pub mod binding;
pub mod clock;
pub mod entry;
pub mod ops;
pub mod session;
pub mod session_start;
use std::io::Read;
use clap::Subcommand;
use serde::Serialize;
use binding::{
is_skipped, open_mission_lines, resolve, resolve_local, unbound_prompt, write_skip,
UNBOUND_PROMPT,
};
use ops::StartOutcome;
use session::Session;
#[derive(Subcommand)]
pub enum ThreadCommands {
#[command(
about = "Mint a thread, bind this session or worktree to it, and push; prints started:<thread-id>, or resume-required:<thread-id> with exit status 2 when a binding exists. With a mission ID the thread works that mission, which must have a record, or a briefing document at missions/<category>/<ID>-<slug>.md; an unknown ID is refused. With no mission ID the thread has no mission and no tasks; tsk thread set mission attaches one later. Clears tsk thread skip"
)]
Start {
#[arg(
help = "Mission ID, for example M-TSK-SCHEMA; omit it to start a thread with no mission"
)]
mission_id: Option<String>,
#[arg(
hide = true,
help = "No longer needed. Accepted and ignored, so older plugin skills keep working"
)]
briefing_path: Option<String>,
},
#[command(
about = "Append a continuation state entry to a thread and push; prints paused:<thread-id>. The mission comes from the thread. When the mission has a record, a task ID must name one of its tasks. A thread with no mission has no tasks: its task ID must be empty"
)]
Pause {
#[arg(help = "Thread ID")]
thread_id: String,
#[arg(
help = "Task in progress, for example T-05 or T-5; can be empty",
allow_hyphen_values = true
)]
task_id: String,
#[arg(
help = "A short account of where things stand",
allow_hyphen_values = true
)]
whats_next: String,
#[arg(
hide = true,
allow_hyphen_values = true,
help = "The old form is pause <thread-id> <mission-link> <task-id> <whats-next>. The mission link is ignored"
)]
legacy_whats_next: Option<String>,
},
#[command(
about = "Bind this session or worktree to a thread, replacing its earlier binding, and print its latest continuation state entry as JSON with a warning when another actor wrote to it. The binding is a local write: it prints a note on stderr and goes out with the next push (thread start, pause or stop, or tsk ledger push)"
)]
Resume {
#[arg(help = "Thread ID")]
thread_id: String,
},
#[command(about = "Change a field of a thread; the one field today is the mission")]
Set {
#[command(subcommand)]
field: SetCommands,
},
#[command(
about = "Remove this session's or worktree's binding without deleting the thread; prints detached:<thread-id>. Every binding is a row in the ledger. The change is a local write: it prints a note on stderr and goes out with the next push (thread start, pause or stop, or tsk ledger push)"
)]
Detach,
#[command(
about = "Record that this session or worktree works without a thread, so the Stop hook check stops asking for one; prints skipped. The record is local to this clone and is never pushed. tsk thread start and tsk thread resume clear it"
)]
Skip,
#[command(
about = "Delete a thread, its continuation state and every binding to it, cloud or worktree, and push; prints stopped:<thread-id>"
)]
Stop {
#[arg(help = "Thread ID; defaults to the thread this session or worktree is bound to")]
thread_id: Option<String>,
},
#[command(
about = "List every thread on the ledger as one JSON object per line, most recently paused first. Each row has actors: the sessions and worktrees bound to the thread, as {\"actor\", \"kind\"} objects, empty when none is"
)]
List,
#[command(
name = "prune-bindings",
about = "Remove the bindings of cloud sessions that have ended: cloud rows registered more than --older-than days ago (30 by default), except this session's own. A worktree binding is never removed. Prints removed <actor> <thread-id> per row, then a count. The change is local: tsk ledger push publishes it. With --dry-run it prints would remove ... and changes nothing"
)]
PruneBindings {
#[arg(
long,
value_name = "DAYS",
default_value_t = ops::DEFAULT_PRUNE_DAYS,
help = "Remove cloud bindings registered more than this many days ago"
)]
older_than: u64,
#[arg(long, help = "Print what would be removed and change nothing")]
dry_run: bool,
},
#[command(
about = "Print this session's or worktree's binding as cloud:<thread-id> or worktree:<thread-id>; exit status 1 and no output with no binding. A worktree that still has a tsk-thread-id marker file gets a binding row from it, and the file is removed (not with --no-fetch)"
)]
Binding {
#[arg(long, help = "Read the existing ledger worktree without fetching")]
no_fetch: bool,
},
#[command(
about = "Stop hook check: when this session or worktree is unbound, has not skipped a thread and the ledger holds missions, print a block decision as JSON that asks for a thread. Otherwise, when the ledger store holds changes that are not pushed (tsk ledger status --local, no network) and the hook input does not say stop_hook_active, print a block decision that asks for tsk ledger push. Otherwise exit 0 with no output. Reads stdin; never fetches"
)]
Guard,
#[command(
about = "SessionStart hook: reads the session ID and source from stdin, exits with no output when another run already claimed that event, fetches the ledger, exports TSK_LEDGER_WT to CLAUDE_ENV_FILE when set, and prints the hook JSON with the session context in the --format shape (claude by default, or copilot); accepts session_id or sessionId on stdin; exits 0 even when the fetch fails"
)]
SessionStart {
#[arg(
long,
value_enum,
default_value = "claude",
help = "Hook output format: claude (hookSpecificOutput JSON) or copilot (additionalContext JSON)"
)]
format: session_start::HookFormat,
#[arg(
long,
value_enum,
value_name = "TARGET",
help = "In a Claude Code cloud session (CLAUDE_CODE_REMOTE=true), install the tsk plugin first, as tsk install-plugin does; only claude-cli is accepted. Step lines go to stderr. A failure adds a warning to the context"
)]
install_plugin: Option<crate::install_plugin::Target>,
#[arg(
long,
value_name = "SEMVER",
help = "The version of the tsk plugin loaded in this session. When it is lower than the plugin version this tsk expects, the context asks for a plugin update; an absent or unparseable value adds nothing"
)]
plugin_version: Option<String>,
},
}
#[derive(Subcommand)]
pub enum SetCommands {
#[command(
about = "Attach a mission to a thread, or change or remove its mission; prints mission-set:<thread-id>:<mission-id>, or mission-cleared:<thread-id> with --none. The mission must have a record, or a briefing document at missions/<category>/<ID>-<slug>.md; an unknown ID is refused. Earlier continuation entries keep the mission they were written with. The change is a local write: it prints a note on stderr and goes out with the next push (thread start, pause or stop, or tsk ledger push)"
)]
Mission {
#[arg(
help = "Mission ID, for example M-TSK-SCHEMA; with --none, the thread ID instead",
required_unless_present = "none"
)]
mission_id: Option<String>,
#[arg(help = "Thread ID; defaults to the thread this session or worktree is bound to")]
thread_id: Option<String>,
#[arg(
long,
help = "Remove the thread's mission instead of setting one; takes the thread ID alone"
)]
none: bool,
},
}
#[derive(Serialize)]
struct BlockDecision<'a> {
decision: &'a str,
reason: &'a str,
}
pub fn block_decision(reason: &str) -> String {
serde_json::to_string(&BlockDecision {
decision: "block",
reason,
})
.unwrap_or_default()
}
fn guard_reason(stop_hook_active: bool) -> Option<String> {
unbound_reason().or_else(|| unpublished_reason(stop_hook_active))
}
fn unbound_reason() -> Option<String> {
let Ok(session) = Session::discover() else {
return Some(UNBOUND_PROMPT.to_string());
};
if is_skipped(&session) {
return None;
}
let local = session.store.exists_locally().unwrap_or(false);
if !local {
return Some(UNBOUND_PROMPT.to_string());
}
if resolve_local(&session).ok().flatten().is_some() {
return None;
}
match (
session.store.missions_load(),
session.store.doc_list("missions/"),
) {
(Ok(missions), Ok(documents)) if missions.is_empty() && documents.is_empty() => None,
(Ok(missions), _) => Some(unbound_prompt(&open_mission_lines(&missions))),
_ => Some(UNBOUND_PROMPT.to_string()),
}
}
pub const UNPUBLISHED_PROMPT: &str = "The ledger store holds changes that are not pushed (records or documents written since the last push). Run `tsk ledger push \"<describe the update>\"` so another actor or a later session can see them. Nothing else is needed: records are published at thread start, thread pause, the end of the session and when asked.";
fn unpublished_reason(stop_hook_active: bool) -> Option<String> {
if stop_hook_active {
return None;
}
let session = Session::discover().ok()?;
if !session.store.exists_locally().unwrap_or(false) {
return None;
}
match session.store.status_local() {
Ok(status) if status.has_unpushed() => Some(UNPUBLISHED_PROMPT.to_string()),
_ => None,
}
}
fn stop_hook_active(input: &str) -> bool {
serde_json::from_str::<serde_json::Value>(input)
.ok()
.and_then(|value| value.get("stop_hook_active").and_then(|v| v.as_bool()))
.unwrap_or(false)
}
pub fn run(action: ThreadCommands) -> Result<i32, String> {
if let ThreadCommands::Guard = action {
let mut input = String::new();
let _ = std::io::stdin().read_to_string(&mut input);
if let Some(reason) = guard_reason(stop_hook_active(&input)) {
println!("{}", block_decision(&reason));
}
return Ok(0);
}
if let ThreadCommands::SessionStart {
format,
install_plugin,
plugin_version,
} = action
{
if install_plugin == Some(crate::install_plugin::Target::CopilotCli) {
return Err(
"error: --install-plugin accepts only claude-cli: GitHub Copilot CLI reads plugin hooks itself"
.to_string(),
);
}
return Ok(session_start::run(
format,
install_plugin.is_some(),
plugin_version.as_deref(),
));
}
let session = Session::discover()?;
match action {
ThreadCommands::Start {
mission_id,
briefing_path,
} => {
if briefing_path.is_some() {
eprintln!(
"note: the briefing path argument of tsk thread start is no longer needed and is ignored"
);
}
match ops::start(&session, mission_id.as_deref())? {
StartOutcome::Started(id) => {
println!("started:{}", id);
Ok(0)
}
StartOutcome::ResumeRequired(id) => {
println!("resume-required:{}", id);
Ok(2)
}
}
}
ThreadCommands::Pause {
thread_id,
task_id,
whats_next,
legacy_whats_next,
} => {
let (task_id, whats_next) = match legacy_whats_next {
Some(last) => {
eprintln!(
"note: the mission link argument of tsk thread pause is no longer needed and is ignored"
);
(whats_next, last)
}
None => (task_id, whats_next),
};
ops::pause(&session, &thread_id, &task_id, &whats_next)?;
println!("paused:{}", thread_id);
Ok(0)
}
ThreadCommands::Set {
field:
SetCommands::Mission {
mission_id,
thread_id,
none,
},
} => {
let (mission_id, thread_id) = if none {
if thread_id.is_some() {
return Err(
"error: --none takes no mission ID: tsk thread set mission --none [<thread-id>]"
.to_string(),
);
}
(None, mission_id)
} else {
(mission_id, thread_id)
};
let done = ops::set_mission(&session, mission_id.as_deref(), thread_id.as_deref())?;
match done.mission_id {
Some(mission_id) => println!("mission-set:{}:{}", done.thread_id, mission_id),
None => println!("mission-cleared:{}", done.thread_id),
}
Ok(0)
}
ThreadCommands::Skip => {
write_skip(&session)?;
println!("skipped");
Ok(0)
}
ThreadCommands::Resume { thread_id } => {
let outcome = ops::resume(&session, &thread_id)?;
println!("{}", ops::render_resume(&outcome));
Ok(0)
}
ThreadCommands::Detach => {
let id = ops::detach(&session)?;
println!("detached:{}", id);
Ok(0)
}
ThreadCommands::Stop { thread_id } => {
let id = ops::stop(&session, thread_id.as_deref())?;
println!("stopped:{}", id);
Ok(0)
}
ThreadCommands::List => {
for row in ops::list(&session)? {
println!(
"{}",
serde_json::to_string(&row)
.map_err(|e| format!("error: could not render a thread: {}", e))?
);
}
Ok(0)
}
ThreadCommands::PruneBindings {
older_than,
dry_run,
} => {
let rows = ops::prune_bindings(&session, older_than, dry_run)?;
for line in ops::render_prune(&rows, dry_run) {
println!("{}", line);
}
Ok(0)
}
ThreadCommands::Binding { no_fetch } => {
let binding = if no_fetch {
resolve_local(&session)?
} else {
session.refresh()?;
resolve(&session)?
};
match binding {
Some(binding) => {
println!("{}", binding);
Ok(0)
}
None => Ok(1),
}
}
ThreadCommands::Guard | ThreadCommands::SessionStart { .. } => Ok(0),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_hook_input_says_whether_the_stop_hook_already_blocked() {
assert!(stop_hook_active("{\"stop_hook_active\":true}"));
assert!(!stop_hook_active("{\"stop_hook_active\":false}"));
assert!(!stop_hook_active("{}"));
assert!(!stop_hook_active(""));
assert!(!stop_hook_active("not json"));
}
#[test]
fn block_decision_is_compact_json_with_the_unbound_prompt() {
let text = block_decision(UNBOUND_PROMPT);
assert!(
text.starts_with("{\"decision\":\"block\",\"reason\":\"No thread binding was found")
);
let value: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(value["reason"], UNBOUND_PROMPT);
}
}