Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions crates/forge-cli/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,13 +217,30 @@ pub(crate) struct AttemptArgs {

#[derive(Debug, Subcommand)]
pub(crate) enum AttemptCommand {
/// Start a new attempt for an existing intent.
///
/// The reported workspace_path (.forge/worktrees/<attempt-id>) is
/// materialized state, not an editing surface; the repo root worktree
/// (after `attempt attach`) is where edits belong.
Start(AttemptStartArgs),
List,
Show {
attempt_id: String,
},
/// Attach the repo root worktree to an attempt and materialize its tree there.
///
/// The attempt's .forge/worktrees/<attempt-id> workspace dir is
/// materialized state, not an editing surface; the repo root worktree
/// (after attach) is where edits belong.
Attach {
attempt_id: String,
/// Proceed even when the attempt's workspace dir (.forge/worktrees/<attempt>)
/// has drifted from its recorded materialized content, discarding the drifted
/// workspace edits (NER-382). Without this flag such an attach refuses with
/// WORKSPACE_DRIFT. Discards workspace-dir drift ONLY — it never bypasses the
/// repo-root DIRTY_WORKTREE or ATTEMPT_WORKTREE_MISMATCH protections.
#[arg(long)]
discard_workspace_changes: bool,
},
/// Compare competing attempts (per intent) on verified evidence + rank them.
Compare(CompareArgs),
Expand Down
27 changes: 26 additions & 1 deletion crates/forge-cli/src/commands/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,10 @@ pub(crate) fn attempt_response(request_id: Option<String>, args: AttemptArgs) ->
))
})
}
AttemptCommand::Attach { attempt_id } => {
AttemptCommand::Attach {
attempt_id,
discard_workspace_changes,
} => {
command_result("attempt attach", request_id, |cwd, request_id| {
// NER-134: worktree/base materialization goes through `ContentBackend`,
// not `forge_content_git::` directly, so git-worktree semantics stay out
Expand All @@ -191,6 +194,16 @@ pub(crate) fn attempt_response(request_id: Option<String>, args: AttemptArgs) ->
}
.into());
}
// NER-382: refuse (WORKSPACE_DRIFT) BEFORE any materialization write when
// the target attempt's workspace dir no longer equals its recorded
// materialized content — re-materializing below would silently discard
// those workspace edits. Checked AFTER the switching-baseline dirty-check
// above, and only skipped by the explicit flag, so
// --discard-workspace-changes discards workspace-dir drift ONLY and can
// never bypass DIRTY_WORKTREE.
if !discard_workspace_changes {
forge_store::verify_attempt_workspace_undrifted(&cwd, &attempt_id)?;
}
let content_ref = match forge_store::attempt_materialization_ref(&cwd, &attempt_id)?
{
Some(content_ref) => content_ref,
Expand Down Expand Up @@ -1544,6 +1557,18 @@ pub(crate) fn replay_response(
for (key, value) in replay_data {
object.insert(key.clone(), value.clone());
}
// NER-382 replay gap: `attempt_started` rows recorded before the
// workspace_role upgrade carry workspace_path in replay_data but no
// workspace_role. Inject the constant so a replayed payload matches
// the schema promise (workspace_path is always role-qualified).
if replay_data.contains_key("workspace_path")
&& !replay_data.contains_key("workspace_role")
{
object.insert(
"workspace_role".to_string(),
json!(forge_store::WORKSPACE_ROLE_MATERIALIZATION_TARGET),
);
}
object.insert(
"operation_id".to_string(),
json!(existing.operation_id.clone()),
Expand Down
6 changes: 3 additions & 3 deletions crates/forge-cli/src/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,11 @@ fn envelope_shape() -> Value {
fn command_shapes() -> Value {
let commands = [
("init", "Initializes a .forge repository; data carries root_path and the genesis operation."),
("start", "Starts an intent + its first attempt; accepts repeatable --require <command> gates and --require-tests-pass <command> structured gates (which also require zero parsed failures) persisted on the intent, and an optional --actor; data carries the started attempt + operation_id."),
("attempt start", "Starts a new attempt for an existing intent; data carries the attempt + operation_id."),
("start", "Starts an intent + its first attempt; accepts repeatable --require <command> gates and --require-tests-pass <command> structured gates (which also require zero parsed failures) persisted on the intent, and an optional --actor; data carries the started attempt + operation_id, with workspace_path qualified by workspace_role=\"materialization_target\" (the workspace dir is materialized state, not an editing surface; edits belong in the repo root worktree)."),
("attempt start", "Starts a new attempt for an existing intent; data carries the attempt + operation_id, with workspace_path qualified by workspace_role=\"materialization_target\" (the workspace dir is materialized state, not an editing surface; edits belong in the repo root worktree after `attempt attach`)."),
("attempt list", "Lists attempts; data carries { attempts: [...] }."),
("attempt show", "Shows one attempt; data carries the attempt detail."),
("attempt attach", "Attaches the active view to an attempt; data carries attempt_id, content_ref, current_view_id."),
("attempt attach", "Attaches the active view to an attempt; refuses with WORKSPACE_DRIFT when the attempt's workspace dir no longer equals its recorded materialized content (pass --discard-workspace-changes to explicitly discard workspace-dir drift; the flag never bypasses DIRTY_WORKTREE); data carries attempt_id, content_ref, current_view_id."),
("intent list", "Lists intents; data carries { intents: [...] } with id, title, derived status (accepted if any linked attempt was accepted, else open), the declared gate spec ({ program, args, structured } per gate), and linked attempt ids."),
("intent show", "Shows one intent; data carries intent_id, title/text, derived status, the declared gate spec ({ program, args, structured } per gate), and linked attempt ids. Unknown id -> UNKNOWN_INTENT."),
("save", "Snapshots the worktree; data carries the saved snapshot + operation_id. Native repos with local private path labels exclude those exact paths from the public forge-tree and record encrypted private overlay payload metadata."),
Expand Down
Loading