1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
// SPDX-License-Identifier: Apache-2.0
//! Thread command definitions.
use clap::{Args, Subcommand};
use super::{
CollapseArgs, ExpandArgs, ThreadAbsorbArgs, ThreadCapturesArgs, ThreadCheckoutArgs,
ThreadDropArgs, ThreadMoveArgs, ThreadNameArgs, ThreadRenameArgs, ThreadResolveArgs,
ThreadShowArgs,
};
#[derive(Subcommand, Clone)]
pub enum ThreadCommands {
/// Create a thread ref at the current state.
#[command(after_help = "\
Advanced split form:
heddle start <name> --path <dir> is the normal one-step isolated-checkout path.
heddle thread create <name> only creates the thread ref. Pair it later with
heddle thread checkout <name> --path <dir> when you intentionally need to
create the ref now and materialize the checkout later.
")]
Create {
/// Thread identifier.
name: String,
/// Mark the thread ephemeral. Auto-collapses after `--ttl` if not
/// promoted. The collapse is recorded as
/// `OpRecord::EphemeralThreadCollapse`; underlying states stay
/// addressable. (W1/A13.)
#[arg(long)]
ephemeral: bool,
/// TTL in seconds. Defaults to 24h when `--ephemeral` is set
/// without `--ttl`.
#[arg(long, requires = "ephemeral")]
ttl_secs: Option<u32>,
},
/// Print the name of the current thread (the thread the working
/// checkout is attached to). Read-only — no state change.
/// Useful in shell pipelines: `cd "$(heddle thread cd "$(heddle thread current)")"`.
Current,
/// Switch the current checkout to an existing thread ref.
Switch {
/// Thread identifier.
name: String,
/// Print only the target thread's checkout path on stdout and
/// exit. Used by the shell hook (`heddle shell init`) to auto-cd
/// into the new thread:
/// dir=$(heddle thread switch X --print-cd-path) && cd "$dir"
/// Auto-capture still runs; rich output is suppressed.
#[arg(long, hide_short_help = true)]
print_cd_path: bool,
/// Discard uncommitted changes in the current checkout before switching.
#[arg(short, long)]
force: bool,
},
/// Print the on-disk path for a thread. Read-only — no state change,
/// no auto-capture. Without a shell hook:
/// cd "$(heddle thread cd X)"
/// With `heddle shell init`, `heddle thread cd X` becomes `cd <path>`.
Cd {
/// Thread identifier.
name: String,
},
/// List threads.
List(ThreadListArgs),
/// Show one thread with actor and workflow context.
Show(ThreadShowArgs),
/// Show granular captures on a thread.
Captures(ThreadCapturesArgs),
/// Rename a thread ref.
Rename(ThreadRenameArgs),
/// Refresh a thread onto its target thread.
Refresh(ThreadNameArgs),
/// Move selected captured paths from one thread into another.
Move(ThreadMoveArgs),
/// Absorb a child thread into its parent or another thread.
Absorb(ThreadAbsorbArgs),
/// Guide a blocked or stale thread toward its next clean state.
Resolve(ThreadResolveArgs),
/// Inspect or explicitly transfer a Thread's native ownership.
Ownership {
#[command(subcommand)]
command: ThreadOwnershipCommands,
},
/// Create a working checkout for this thread.
#[command(after_help = "\
`heddle start <name>` is the one-step default: it creates the thread ref and
isolated checkout together. `thread checkout <name> --path <dir>` is the
second step after `heddle thread create <name>` when you intentionally
created the ref first and want to materialize it later.
")]
Checkout(ThreadCheckoutArgs),
/// Drop a thread and mark it abandoned.
#[command(visible_alias = "delete")]
Drop(ThreadDropArgs),
/// Sweep merged, stale auto-created, or abandoned threads.
#[command(
long_about = "\
Sweep threads that have outlived their usefulness. Cleanup removes recorded checkouts, marks matching thread records abandoned, and prunes live thread refs so everyday thread lists stay focused.
Modes:
- --merged: clean up threads recorded as merged.
- --auto --older-than <duration>: clean up harness-created threads that have not been touched in the given duration.
- --abandoned: remove live refs and checkout residue left by abandoned threads; retain their records, states, and audit history.
The three modes can be combined. Pair with --dry-run to preview the work without changing anything on disk.",
after_help = "\
Examples:
heddle thread cleanup --merged --dry-run
heddle thread cleanup --merged
heddle thread cleanup --auto --older-than 7d --dry-run
heddle thread cleanup --abandoned --dry-run
"
)]
Cleanup(ThreadCleanupArgs),
/// Collapse (squash) multiple states into one.
Collapse(CollapseArgs),
/// Expand a squashed land into the captures it collapsed.
Expand(ExpandArgs),
/// Manage named state markers under the thread namespace.
Marker {
#[command(subcommand)]
command: ThreadMarkerCommands,
},
}
#[derive(Subcommand, Clone)]
pub enum ThreadOwnershipCommands {
/// Show the local owner or the complete signed conflict.
Status { thread: Option<String> },
/// Explicitly transfer a local-key Thread to the currently authorized account.
Claim { thread: Option<String> },
/// Original local owner chooses one accepted claim; the current account accepts.
Resolve {
thread: Option<String>,
/// Full claim ID shown by `heddle thread ownership status`.
#[arg(long)]
claim: String,
},
}
#[cfg(test)]
mod ownership_tests {
use clap::Parser;
use super::*;
use crate::cli::cli_args::{Cli, Commands};
#[test]
fn ownership_commands_select_named_or_current_thread_and_require_explicit_winner() {
let status = Cli::try_parse_from(["heddle", "thread", "ownership", "status"])
.expect("current Thread status");
assert!(matches!(
status.command,
Commands::Thread {
command: ThreadCommands::Ownership {
command: ThreadOwnershipCommands::Status { thread: None }
}
}
));
let resolve = Cli::try_parse_from([
"heddle",
"thread",
"ownership",
"resolve",
"feature",
"--claim",
"ab12",
])
.expect("named Thread and explicit claim");
assert!(
matches!(resolve.command, Commands::Thread { command: ThreadCommands::Ownership {
command: ThreadOwnershipCommands::Resolve { thread: Some(_), claim }
} } if claim == "ab12")
);
assert!(
Cli::try_parse_from(["heddle", "thread", "ownership", "resolve", "feature"]).is_err()
);
}
}
#[derive(Subcommand, Clone)]
pub enum ThreadMarkerCommands {
/// List markers, optionally filtered by name prefix.
///
/// Pass `--filter <PREFIX>` to return only markers whose name
/// starts with the given prefix. The match is a literal
/// `starts_with` check, not a glob.
List {
/// Return only markers whose name starts with this prefix.
#[arg(long, value_name = "PREFIX")]
filter: Option<String>,
},
/// Create marker at current state.
Create {
/// Marker name.
name: String,
},
/// Delete marker(s).
///
/// Pass an exact marker name, or `--prefix <PFX>` to delete every marker
/// whose name starts with the given prefix. Exactly one of `<NAME>` or
/// `--prefix` must be supplied.
Delete {
/// Marker name (exact match). Mutually exclusive with `--prefix`.
#[arg(required_unless_present = "prefix", conflicts_with = "prefix")]
name: Option<String>,
/// Delete every marker whose name starts with this prefix.
#[arg(long)]
prefix: Option<String>,
},
/// Show marker details.
Show {
/// Marker name.
name: String,
},
}
/// Arguments for `heddle thread list`.
///
/// The default view hides harness-auto-created and abandoned threads.
/// Pass the corresponding include flags to surface them.
#[derive(Args, Clone, Debug, Default)]
pub struct ThreadListArgs {
/// Include threads created automatically by harness integrations
/// (e.g. Claude Code segment-rotation). Hidden by default to keep
/// the view focused on threads the user explicitly created.
#[arg(long)]
pub include_auto: bool,
/// Include threads whose lifecycle state is abandoned. Hidden by
/// default because they are no longer actionable.
#[arg(long)]
pub include_abandoned: bool,
}
/// Arguments for `heddle thread cleanup`.
///
/// At least one cleanup mode must be set; otherwise the command refuses
/// with a clear message. `--older-than` is required when `--auto` is set.
#[derive(Args, Clone, Debug)]
pub struct ThreadCleanupArgs {
/// Clean up threads whose recorded state is `merged`.
#[arg(long)]
pub merged: bool,
/// Drop harness-auto-created threads (those tagged `auto: true`).
/// Combine with `--older-than` to gate the sweep on staleness.
#[arg(long)]
pub auto: bool,
/// Clean abandoned threads that still have a live ref or checkout
/// residue. States and audit history are retained.
#[arg(long)]
pub abandoned: bool,
/// Maximum age (since `updated_at`) for an auto-thread to be
/// considered live. Threads older than this are eligible for
/// sweep when `--auto` is set. Accepts a Go-style duration like
/// `7d`, `24h`, `30m`, `15s` (or a raw integer interpreted as
/// seconds).
#[arg(long, value_name = "DURATION")]
pub older_than: Option<String>,
/// Print what would be dropped without actually dropping it.
#[arg(long)]
pub dry_run: bool,
}