Skip to content
Closed
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ first added in the same Unreleased section is not an entry: edit that entry inst

## Unreleased

### Added

- `tsk check T12 2 passed` marks one review check open, passed, or failed from the command line ([check](https://gettsk.sh/docs/cli/#check)).

### Changed

- `tsk setup` names both installed skills, `tsk-cli` and `tsk-coordinator`, beside each agent's skills directory instead of printing only the `tsk-cli` file.
Expand Down
2 changes: 1 addition & 1 deletion site/public/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ The human works the board in a TUI; agents read and write the same tasks with th
- Assignee: an agent profile, the human (`you`), or a named person. Only an agent assignee dispatches.
- Dispatch: in Herdr, starting a project task assigned to an agent launches it in a new Git worktree and Herdr workspace, from the task's base branch.
- Block: a task waiting on an answer, with why, what it needs, and options to choose from. Replies answer it; unblocking sends them to the running dispatched agent. `tsk edit T12 --on <who>` changes who an open block or review waits on.
- Review: work handed back with what was done and checks to verify. The human marks the checks, then approves or sends it back with feedback.
- Review: work handed back with what was done and checks to verify. The human marks the checks on the board or with `tsk check T12 2 passed`, then approves or sends it back with feedback.
- After: a task waits until the tasks it runs after are done. The last done starts it if it is ready.
- Paper trail: who changed what on a task, newest first.

Expand Down
26 changes: 26 additions & 0 deletions site/src/content/docs/docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ tsk list
| [`tsk edit`](#edit) | Change a task's title, notes, assignee, base, after list, or who its block or review waits on |
| [`tsk steps`](#steps) | Add, toggle, rename, move, or remove a step |
| [`tsk reply`](#reply) | Answer a block, or give feedback on a review |
| [`tsk check`](#check) | Mark a review check open, passed, or failed |
| [`tsk dispatch`](#dispatch) | Launch a task's assigned agent in its own worktree |
| [`tsk clean`](#clean) | Remove a dispatched task's worktree and merged branch |
| [`tsk archive`](#archive) / [`unarchive`](#unarchive) | Hide a task, or put it back |
Expand Down Expand Up @@ -462,6 +463,30 @@ tsk reply <task> <text> [--send] [--state-dir <dir>]
tsk reply T12 "Use postgres" --send
```

## check

Sets one check of a task's open review round, as `Enter` on a check does on the task page.

```text
tsk check <task> <n> <state> [--state-dir <dir>]
```

| Argument | Meaning |
| --- | --- |
| `<n>` | The check's position in the round, from 1, in the order the task page and `tsk list T12` show them |
| `<state>` | `open`, `passed`, or `failed` |

- One check per run. Setting the state it already has changes nothing and exits 0, so a retry is safe.
- The check's text, the other checks, the round, and the status stay as they are.
- Output is `T12 check 2 passed`.
- A task with no open review round refuses with `not-in-review`. A number past the last check refuses with `unknown-check`. Any other state word is a usage error, exit 2.

**Refusals:** codes and exits in the [exit contract](#exit-contract).

```sh
tsk check T12 2 passed
```

## dispatch

Launches an assigned task's agent in its own Git worktree and Herdr workspace, then sets the task `started`. [Dispatch](/docs/dispatch/#dispatch) explains names, prompts, and what the agent gets.
Expand Down Expand Up @@ -771,6 +796,7 @@ A refusal prints `tsk <command>: <code>: <message>` on stderr, such as `tsk stat
| `edit` | `unknown-task`, `soft-deleted-task`, `empty-title`, `invalid-title`, `unknown-base`, `invalid-after`, `after-loop`; with `--on` also `not-blocked`, `invalid-blocker`, `text-too-long` |
| `steps` | `unknown-task`, `soft-deleted-task`, `empty-step-text`, `invalid-step-text`, `unknown-step`, `ambiguous-step`, `same-step` |
| `reply` | `unknown-task`, `soft-deleted-task`, `not-blocked`, `empty-reply`, `text-too-long` |
| `check` | `unknown-task`, `soft-deleted-task`, `not-in-review`, `unknown-check` |
| `dispatch` | `unknown-task`, `soft-deleted-task`, `done-task`, `archived-task`, `no-assignee`, `unknown-agent`, `agent-config`, `already-dispatched`, `not-in-herdr`, `needs-git-project`, `unknown-base`, `no-default-base`, `herdr-failed`, `unsafe-state-dir`; on Windows `unsupported-platform` |
| `clean` | `unknown-task`, `not-dispatched`, `already-cleaned`, `dirty-worktree`, `worktree-mismatch`, `herdr-failed`; on Windows `files-in-use`, `path-too-long`, `removal-timed-out`, `partly-removed`; with `--merge` also `gh-unavailable`, `no-pr`, `pr-not-open`, `pr-base-mismatch`, `merge-failed`, `merge-config` |
| `archive`, `unarchive` | `unknown-task`, `soft-deleted-task` |
Expand Down
96 changes: 96 additions & 0 deletions src/cli/check.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
//! Headless `tsk check`: set one check of a task's open review round.
//!
//! Saves through `DomainState::set_check`, like the board's `Enter` on a check, and adds no
//! undo entry, matching the board.

use std::path::PathBuf;

use crate::cli::parser::TaskAddress;
use crate::domain::{CheckState, DomainError, DomainState};
use crate::store::{default_state_dir, TaskStore};

/// A set check: the task and the check's 1-based position.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CheckResult {
pub number: u64,
pub position: usize,
pub state: CheckState,
}

/// A check failure after parsing and before presenting output.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CheckError {
UnknownTask,
SoftDeletedTask,
NotInReview,
UnknownCheck,
Store(String),
}

impl CheckError {
pub fn code(&self) -> &'static str {
match self {
Self::UnknownTask => "unknown-task",
Self::SoftDeletedTask => "soft-deleted-task",
Self::NotInReview => "not-in-review",
Self::UnknownCheck => "unknown-check",
Self::Store(_) => "store-error",
}
}
}

/// Set check `position` (1-based) of the task's open review round to `state`. Setting the
/// state it already has saves nothing and succeeds. Refusals persist nothing.
pub fn run(
target: TaskAddress,
position: usize,
state: CheckState,
state_dir: Option<PathBuf>,
) -> Result<CheckResult, CheckError> {
let store = TaskStore::new(state_dir.unwrap_or_else(default_state_dir));
store
.locked_transition_if_changed(|domain| {
let outcome = apply(domain, target, position, state);
let changed = outcome.as_ref().is_ok_and(|(_, changed)| *changed);
Ok((outcome.map(|(result, _)| result), changed))
})
.map_err(CheckError::Store)?
}

fn apply(
domain: &mut DomainState,
target: TaskAddress,
position: usize,
state: CheckState,
) -> Result<(CheckResult, bool), CheckError> {
let task = domain
.tasks()
.iter()
.find(|task| target.matches(task))
.ok_or(CheckError::UnknownTask)?;
if task.soft_deleted {
return Err(CheckError::SoftDeletedTask);
}
let (id, number) = (task.id, task.number.unwrap_or_default());
let index = position.checked_sub(1).ok_or(CheckError::UnknownCheck)?;
let changed = domain
.set_check(id, index, state)
.map_err(map_domain_error)?;
Ok((
CheckResult {
number,
position,
state,
},
changed,
))
}

fn map_domain_error(error: DomainError) -> CheckError {
match error {
DomainError::UnknownId(_) => CheckError::UnknownTask,
DomainError::NotInReview(_) => CheckError::NotInReview,
DomainError::UnknownCheck(_) => CheckError::UnknownCheck,
other => CheckError::Store(other.to_string()),
}
}
23 changes: 22 additions & 1 deletion src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use crate::dispatch::CleanupError;
pub mod add;
pub mod archive;
pub mod backup;
pub mod check;
pub mod clean;
pub mod dispatch;
pub mod edit;
Expand Down Expand Up @@ -110,6 +111,7 @@ fn run_verb<R: Read>(
Some("list") => run_list(args, terminal_width),
Some("status") => run_status(args),
Some("reply") => run_reply(args),
Some("check") => run_check(args),
Some("dispatch") => run_dispatch(args),
Some("clean") => run_clean(args),
Some("edit") => run_edit(args),
Expand All @@ -126,7 +128,7 @@ fn run_verb<R: Read>(
run_archive(args, verb, archive)
}
_ => presenter::usage(
"expected add, steps, list, status, reply, dispatch, clean, edit, trash, backup, restore, archive, unarchive, or project command",
"expected add, steps, list, status, reply, check, dispatch, clean, edit, trash, backup, restore, archive, unarchive, or project command",
),
}
}
Expand All @@ -153,6 +155,7 @@ fn run_help(args: Vec<String>) -> CliOutput {
"list" => presenter::list_help(None),
"status" => presenter::status_help(),
"reply" => presenter::reply_help(),
"check" => presenter::check_help(),
"dispatch" => presenter::dispatch_help(),
"clean" => presenter::clean_help(),
"edit" => presenter::edit_help(),
Expand Down Expand Up @@ -459,6 +462,24 @@ fn run_reply(args: Vec<String>) -> CliOutput {
}
}

fn run_check(args: Vec<String>) -> CliOutput {
let input = match parser::parse_flag_check(&args) {
Ok(input) => input,
Err(reason) => return presenter::check_usage(&reason),
};
if input.help {
return presenter::check_help();
}
let (Some(task), Some(position), Some(state)) = (input.task, input.position, input.state)
else {
return presenter::check_usage("task number, check number, and state are required");
};
match check::run(task, position, state, input.state_dir) {
Ok(result) => presenter::checked(result),
Err(error) => presenter::check_rejected(error, task),
}
}

fn run_edit(args: Vec<String>) -> CliOutput {
let input = match parser::parse_flag_edit(&args) {
Ok(input) => input,
Expand Down
72 changes: 71 additions & 1 deletion src/cli/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use std::path::PathBuf;
use uuid::Uuid;

use super::steps::{StepPlacement, StepsAction};
use crate::domain::{normalize_thread, thread_refusal_message, HumanStatus};
use crate::domain::{normalize_thread, thread_refusal_message, CheckState, HumanStatus};

/// A direct task operand, either the internal UUID or its human task number.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
Expand Down Expand Up @@ -674,6 +674,76 @@ pub fn parse_flag_reply(args: &[String]) -> Result<FlagReply, String> {
Ok(parsed)
}

/// Parsed `check` input: the task address, the check's 1-based position, and its new state.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FlagCheck {
pub task: Option<TaskAddress>,
pub position: Option<usize>,
pub state: Option<CheckState>,
pub state_dir: Option<PathBuf>,
pub help: bool,
}

/// Parse `tsk check <task> <n> <state>` arguments, including argv0.
pub fn parse_flag_check(args: &[String]) -> Result<FlagCheck, String> {
if args.get(1).map(String::as_str) != Some("check") {
return Err("expected check command".into());
}
let mut parsed = FlagCheck {
task: None,
position: None,
state: None,
state_dir: None,
help: false,
};
let mut positionals: Vec<&str> = Vec::new();
let mut index = 2;
while let Some(arg) = args.get(index).map(String::as_str) {
index += 1;
if !arg.starts_with('-') {
if positionals.len() == 3 {
return Err(format!("unexpected check argument {arg}"));
}
positionals.push(arg);
continue;
}
match arg {
"--help" => parsed.help = true,
flag if flag.starts_with("--state-dir=") => {
parsed.state_dir = Some(PathBuf::from(&flag["--state-dir=".len()..]));
}
"--state-dir" => match args.get(index) {
Some(value) if !value.starts_with('-') => {
parsed.state_dir = Some(PathBuf::from(value));
index += 1;
}
_ => return Err("missing value for --state-dir".into()),
},
flag => return Err(format!("unknown check argument {flag}")),
}
}
if parsed.help {
return Ok(parsed);
}
if let Some(task) = positionals.first() {
parsed.task = Some(parse_task_address(task)?);
}
if let Some(position) = positionals.get(1) {
let valid = !position.is_empty() && position.bytes().all(|byte| byte.is_ascii_digit());
if !valid {
return Err(format!("invalid check number {position}"));
}
// Too many digits for any round is past the last check, not a usage error.
parsed.position = Some(position.parse::<usize>().unwrap_or(usize::MAX));
}
if let Some(state) = positionals.get(2) {
parsed.state = Some(CheckState::from_name(state).ok_or_else(|| {
format!("unknown check state {state}; expected open, passed, or failed")
})?);
}
Ok(parsed)
}

fn parse_human_status(value: &str) -> Result<HumanStatus, String> {
match value {
"open" => Ok(HumanStatus::Open),
Expand Down
61 changes: 61 additions & 0 deletions src/cli/presenter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ const STEPS_USAGE: &[&str] = &[
"tsk steps <task> remove <step-short-id> [--state-dir <dir>]",
];
const REPLY_USAGE: &[&str] = &["tsk reply <task> <text> [--send] [--state-dir <dir>]"];
const CHECK_USAGE: &[&str] = &["tsk check <task> <n> <state> [--state-dir <dir>]"];
const DISPATCH_USAGE: &[&str] = &[
"tsk dispatch <task> [--again] [--base <branch>] [--state-dir <dir>]",
"tsk dispatch <task> --show-prompt [--base <branch>] [--state-dir <dir>]",
Expand Down Expand Up @@ -207,6 +208,7 @@ pub fn top_level_help() -> String {
" edit change title, notes, assignee, base, after list, or who it waits on\n",
" steps add, toggle, rename, move, or remove a step\n",
" reply answer a block, or give feedback on a review\n",
" check mark a review check open, passed, or failed\n",
" dispatch launch a task's assigned agent in its own worktree\n",
" clean remove a dispatched task's worktree and merged branch\n\n",
"Board\n",
Expand Down Expand Up @@ -1516,6 +1518,8 @@ fn task_refusal_message(code: &str, task: TaskAddress) -> String {
"not-blocked" => format!("{display} is not blocked or in review; nothing to reply to"),
"empty-reply" => "the reply is empty".to_string(),
"same-step" => "a step cannot be placed relative to itself".to_string(),
"not-in-review" => format!("{display} has no open review round"),
"unknown-check" => format!("{display}'s review round has no check with that number"),
_ => return code.to_string(),
};
format!("{code}: {message}")
Expand Down Expand Up @@ -2025,6 +2029,63 @@ pub fn reply_rejected(error: crate::cli::reply::ReplyError, task: TaskAddress) -
}
}

pub fn check_help() -> CliOutput {
help(HelpDoc {
usage: lines(CHECK_USAGE),
purpose: "Set one check of a task's open review round. Setting the state it already has changes nothing, so a retry is safe."
.into(),
groups: vec![group(
"Arguments",
&[
("<task>", "a task number or UUID"),
("<n>", "the check's position in the round, from 1, as list prints them"),
("<state>", "open, passed, or failed"),
],
),
group("Options", &[("--state-dir <dir>", "use another board store")]),
],
examples: vec![
"tsk check T12 2 passed".into(),
"tsk check T12 1 failed".into(),
],
refusals: vec!["unknown-task, soft-deleted-task, not-in-review, unknown-check".into()],
exit: exit_line(
"check set, or it already had that state",
Some("check refusal, nothing persisted"),
true,
),
})
}

pub fn checked(result: crate::cli::check::CheckResult) -> CliOutput {
CliOutput {
stdout: format!(
"T{} check {} {}\n",
result.number,
result.position,
result.state.name()
),
stderr: String::new(),
code: 0,
}
}

pub fn check_usage(reason: &str) -> CliOutput {
usage_error("check", reason, CHECK_USAGE)
}

pub fn check_rejected(error: crate::cli::check::CheckError, task: TaskAddress) -> CliOutput {
let (detail, code) = match error {
crate::cli::check::CheckError::Store(detail) => (detail, 3),
other => (task_refusal_message(other.code(), task), 1),
};
CliOutput {
stdout: String::new(),
stderr: format!("tsk check: {detail}\n"),
code,
}
}

pub fn status(result: StatusResult) -> CliOutput {
CliOutput {
stdout: format!(
Expand Down
Loading