Skip to content

Commit 9104a17

Browse files
committed
fix(loop): detect stale loop plans
Reject stale cached loop dependency closures before opening new rounds, and expose fresh/stale plan status in loop show/list.
1 parent 74dc3f0 commit 9104a17

15 files changed

Lines changed: 295 additions & 21 deletions

File tree

.claude/skills/gov/SKILL.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,12 +94,12 @@ The active work item is durable outcome context. Read it with `govctl work show
9494

9595
### Loop usage
9696

97-
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.
97+
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.
9898

9999
1. Create or activate only the work items that represent durable outcomes a future reader should see.
100100
2. Add `depends_on` edges for hard execution ordering.
101101
3. Run `govctl check` so dependency cycles or missing work item IDs are caught before the loop starts.
102-
4. Start one loop for the batch root set with `govctl loop start <ROOT-WI-ID> [<ROOT-WI-ID>...]`; let govctl generate the `LOOP-YYYY-MM-DD-NNN` ID.
102+
4. Start one loop for the root set with `govctl loop start <ROOT-WI-ID> [<ROOT-WI-ID>...]`; let govctl generate the `LOOP-YYYY-MM-DD-NNN` ID.
103103
5. Run `govctl loop run <LOOP-ID>` to open a local round for ready work.
104104
6. Perform implementation, verification, and any explicit `govctl work move` commands yourself.
105105
7. Fill the opened `.govctl/loops/<LOOP-ID>/rounds/round-NNN.toml` summary evidence.
@@ -117,7 +117,7 @@ If the scope changes during execution, keep the same loop identity:
117117

118118
`work` is the editable loop work-item field. `wi` is accepted as a short alias, but examples should prefer `work`.
119119

120-
Do not create scattered single-item loops for work that is part of one coherent batch.
120+
Do not create multiple scattered loops for work that belongs in one coherent execution session.
121121

122122
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.
123123

@@ -145,7 +145,7 @@ govctl work list pending
145145

146146
- Matching active item: use it
147147
- Matching queued item: `govctl work move <WI-ID> active`
148-
- No match and the task has one durable outcome: `govctl work new --active "<concise-title>"`
148+
- No match and the task has one durable outcome: `govctl work new --active "<concise-title>"`, then start a loop if the work is non-trivial
149149
- 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.
150150
- No match and the apparent split is only mechanical implementation steps: create at most one coarse work item, or route trivial cleanup to `/quick`.
151151

.claude/skills/wi-writer/SKILL.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,8 @@ Create multiple work items only when each item is independently meaningful to fu
126126
- For trivial cleanup or docs-only edits, no work item may be the right answer; follow the invoking workflow instead of inventing tracking.
127127
- For one coherent cleanup/refactor, prefer one coarse work item over many narrow slices.
128128
- Use `depends_on` only for hard execution ordering; keep `refs` for informational links.
129-
- Use one batch loop only when there are multiple durable work items; do not use loops to justify creating mechanical work-item fragments.
129+
- 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.
130+
- 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.
130131
- Let govctl generate the loop ID with `govctl loop start <ROOT-WI-ID> [<ROOT-WI-ID>...]`; use the returned `LOOP-YYYY-MM-DD-NNN` ID for later loop commands.
131132
- Use `govctl loop list open` to discover existing non-terminal loops before resuming interrupted batch work.
132133

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,21 @@ Release entries are curated summaries for readers. Work item traceability remain
1010

1111
## [Unreleased]
1212

13+
### Added
14+
15+
- loop list and show expose stale loop plans without mutating local state (WI-2026-06-09-001)
16+
1317
### Changed
1418

1519
- Writer skills include matching authority tests and examples so authors avoid boundary drift before review (WI-2026-06-07-005)
1620
- Reviewer agents include explicit boundary probes and boundary finding output for RFC, ADR, and Work Item reviews (WI-2026-06-07-005)
21+
- init-skills installs full skill directory bundles, including bundled references and assets (WI-2026-06-08-002)
1722

1823
### Fixed
1924

2025
- cargo-binstall Windows override resolves to govctl-v{ version }-{ target }.zip (WI-2026-06-08-001)
2126
- cargo-binstall Unix pkg-url resolves to govctl-v{ version }-{ target }.tar.gz (WI-2026-06-08-001)
27+
- loop run rejects stale stored dependency closures before opening new work (WI-2026-06-09-001)
2228

2329
## [0.9.3] - 2026-06-07
2430

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ insta = { version = "1", features = ["yaml"] }
9999
regex = "1"
100100
chrono = "0.4"
101101

102+
# [[ADR-0041]] governs package.metadata.binstall archive naming, including the Windows override in package.metadata.binstall.overrides.x86_64-pc-windows-msvc.
102103
[package.metadata.binstall]
103104
pkg-url = "{ repo }/releases/download/v{ version }/govctl-v{ version }-{ target }.tar.gz"
104105
bin-dir = "govctl-v{ version }-{ target }/{ bin }{ binary-ext }"

build.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ use std::fs;
1414
use std::path::{Path, PathBuf};
1515

1616
// Project-local assets installed by `govctl init-skills`.
17-
// Plugin/global-only skills such as `init` are intentionally omitted.
17+
// Plugin/global-only skills such as `init` are intentionally omitted. [[RFC-0002:C-GLOBAL-COMMANDS]]
1818
const DISTRIBUTED_SKILL_DIRS: &[&str] = &[
1919
"discuss",
2020
"gov",

docs/guide/recommended-workflows.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,8 @@ Moving to `done` runs verification guards when verification is enabled.
120120

121121
## When To Use Loops
122122

123-
Use a loop when execution is bigger than one simple Work Item or when you need
124-
resumable local round evidence.
123+
Use a loop when non-trivial execution needs resumable local round evidence,
124+
including single-Work-Item work.
125125

126126
Good loop use cases:
127127

@@ -160,6 +160,10 @@ govctl loop remove LOOP-YYYY-MM-DD-NNN work WI-YYYY-MM-DD-002
160160
govctl loop replan LOOP-YYYY-MM-DD-NNN
161161
```
162162

163+
If `loop show` or `loop list` reports a stale plan, the current Work Item
164+
dependency closure differs from the stored loop plan. Run `govctl loop replan
165+
LOOP-YYYY-MM-DD-NNN` before opening another round.
166+
163167
Loop state is local execution memory under `.govctl/loops/`. Work Items remain
164168
the durable task record.
165169

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
#:schema ../schema/work.schema.json
2+
3+
[govctl]
4+
id = "WI-2026-06-09-001"
5+
title = "Detect stale loop plans"
6+
status = "done"
7+
created = "2026-06-09"
8+
started = "2026-06-09"
9+
completed = "2026-06-09"
10+
refs = ["RFC-0006"]
11+
12+
[content]
13+
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."
14+
15+
[[content.acceptance_criteria]]
16+
text = "loop run rejects stale stored dependency closures before opening new work"
17+
status = "done"
18+
category = "fixed"
19+
20+
[[content.acceptance_criteria]]
21+
text = "loop list and show expose stale loop plans without mutating local state"
22+
status = "done"
23+
category = "added"
24+
25+
[[content.acceptance_criteria]]
26+
text = "regression tests cover stale dependency closure detection and replan repair"
27+
status = "done"
28+
category = "chore"
29+
30+
[[content.acceptance_criteria]]
31+
text = "govctl check passes"
32+
status = "done"
33+
category = "chore"

src/cmd/loop_cmd/execution/mod.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use super::output::print_loop;
22
use super::state::{
3-
ensure_loop_not_terminal, ensure_unique_work_item_ids, loop_dependencies, loop_item_state,
3+
ensure_loop_not_terminal, ensure_loop_plan_fresh, ensure_unique_work_item_ids,
4+
loop_dependencies, loop_item_state,
45
};
56
use crate::cmd::work_lookup::load_work_item_by_id;
67
use crate::config::Config;
@@ -129,6 +130,9 @@ fn open_round(
129130
max_rounds: u32,
130131
op: WriteOp,
131132
) -> DiagnosticResult<Diagnostics> {
133+
// Implements [[RFC-0006:C-ROUND-EXECUTION]] by rejecting stale cached plans
134+
// before selecting new round work from current Work Item dependencies.
135+
ensure_loop_plan_fresh(config, state)?;
132136
reflect_terminal_work_statuses(config, state)?;
133137
propagate_blocked_outcomes(state)?;
134138

src/cmd/loop_cmd/mod.rs

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ use crate::loop_state::{
1616
LoopLifecycleState, LoopState, load_loop_state, validate_loop_id, write_loop_state_with_op,
1717
};
1818
use crate::write::WriteOp;
19-
use output::{LoopListEntry, print_loop, print_loop_list};
19+
use output::{LoopListEntry, print_loop, print_loop_list, print_loop_with_plan};
2020
use state::{
2121
canonical_loop_ids, ensure_loop_not_terminal, ensure_work_values, find_reusable_loop,
22-
generated_loop_id,
22+
generated_loop_id, loop_plan_status, loop_plan_status_from_work_items,
2323
};
2424

2525
pub fn start(
@@ -54,7 +54,8 @@ pub fn start(
5454

5555
pub fn show(config: &Config, loop_id: &str) -> DiagnosticResult<Diagnostics> {
5656
let state = load_loop_state(config, loop_id)?;
57-
print_loop("Loop", &state)?;
57+
let plan_status = loop_plan_status(config, &state);
58+
print_loop_with_plan("Loop", &state, plan_status.as_str())?;
5859
Ok(vec![])
5960
}
6061

@@ -74,9 +75,17 @@ pub fn list(
7475
if let Some(limit) = limit {
7576
states.truncate(limit);
7677
}
78+
let all_work_items = crate::parse::load_work_items(config).ok();
7779
let entries = states
7880
.iter()
79-
.map(LoopListEntry::from_state)
81+
.map(|state| {
82+
let plan_status = all_work_items
83+
.as_deref()
84+
.map_or(state::LoopPlanStatus::Stale, |work_items| {
85+
loop_plan_status_from_work_items(state, work_items)
86+
});
87+
LoopListEntry::from_state(state, plan_status.as_str())
88+
})
8089
.collect::<Vec<_>>();
8190
print_loop_list(&entries, output);
8291
Ok(vec![])

src/cmd/loop_cmd/output.rs

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,19 @@ use serde::Serialize;
1111
pub(super) struct LoopListEntry {
1212
id: String,
1313
state: String,
14+
plan: String,
1415
work: Vec<String>,
1516
items: usize,
1617
rounds: u32,
1718
next_action: String,
1819
}
1920

2021
impl LoopListEntry {
21-
pub(super) fn from_state(state: &LoopState) -> Self {
22+
pub(super) fn from_state(state: &LoopState, plan: &str) -> Self {
2223
Self {
2324
id: state.loop_meta.id.clone(),
2425
state: state.loop_meta.state.as_str().to_string(),
26+
plan: plan.to_string(),
2527
work: state.loop_meta.work.clone(),
2628
items: state.loop_meta.resolved.len(),
2729
rounds: state.items.values().map(|item| item.round_count).sum(),
@@ -42,9 +44,10 @@ pub(super) fn print_loop_list(entries: &[LoopListEntry], output: OutputFormat) {
4244
OutputFormat::Plain => {
4345
for entry in entries {
4446
println!(
45-
"{}\t{}\t{}\t{}\t{}\t{}",
47+
"{}\t{}\t{}\t{}\t{}\t{}\t{}",
4648
entry.id,
4749
entry.state,
50+
entry.plan,
4851
entry.work_display(),
4952
entry.items,
5053
entry.rounds,
@@ -53,12 +56,14 @@ pub(super) fn print_loop_list(entries: &[LoopListEntry], output: OutputFormat) {
5356
}
5457
}
5558
OutputFormat::Table => {
56-
let mut table =
57-
table_with_bold_headers(&["ID", "State", "Work", "Items", "Rounds", "Action"]);
59+
let mut table = table_with_bold_headers(&[
60+
"ID", "State", "Plan", "Work", "Items", "Rounds", "Action",
61+
]);
5862
for entry in entries {
5963
table.add_row(vec![
6064
Cell::new(&entry.id),
6165
Cell::new(&entry.state),
66+
Cell::new(&entry.plan),
6267
Cell::new(entry.work_display()),
6368
Cell::new(entry.items.to_string()),
6469
Cell::new(entry.rounds.to_string()),
@@ -71,12 +76,27 @@ pub(super) fn print_loop_list(entries: &[LoopListEntry], output: OutputFormat) {
7176
}
7277

7378
pub(super) fn print_loop(verb: &str, state: &LoopState) -> DiagnosticResult<()> {
79+
print_loop_inner(verb, state, None)
80+
}
81+
82+
pub(super) fn print_loop_with_plan(
83+
verb: &str,
84+
state: &LoopState,
85+
plan: &str,
86+
) -> DiagnosticResult<()> {
87+
print_loop_inner(verb, state, Some(plan))
88+
}
89+
90+
fn print_loop_inner(verb: &str, state: &LoopState, plan: Option<&str>) -> DiagnosticResult<()> {
7491
if verb == "Loop" {
7592
println!("Loop {}", state.loop_meta.id);
7693
} else {
7794
println!("{} loop {}", verb, state.loop_meta.id);
7895
}
7996
println!("State: {}", state.loop_meta.state.as_str());
97+
if let Some(plan) = plan {
98+
println!("Plan status: {plan}");
99+
}
80100
println!("Current round: {}", state.loop_meta.current_round);
81101
println!("Next action: {}", state.loop_meta.next_action.as_str());
82102
println!("Work: {}", state.loop_meta.work.join(", "));

0 commit comments

Comments
 (0)