Skip to content
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ and the versions follow [semver](https://semver.org).

## [Unreleased]

### Added

- Proof receipts (#17). `taskguard --receipt ID -- COMMAND` runs a job's
command and, when the files did not change during the run, keeps a receipt
and publishes it as the commit status `taskguard/ID` on the commit with
exactly those files: from a laptop once the branch on GitHub has them, from
GitHub Actions at once on the PR head. `taskguard proof check ID` tells CI
per job whether to skip or run. `proof publish`, `proof show` and `proof
log` publish by hand and read proofs back.

## [0.8.0] - 2026-10-09

### Added
Expand Down
55 changes: 55 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -481,12 +481,64 @@ jobs. Set `TASKGUARD=/path/to/taskguard` to try a local build.
| `taskguard doctor` | Configuration, live readings, and leftover tsc-queue shims |
| `taskguard doctor --explain "COMMAND"` | How one command is matched, pooled and learned |
| `taskguard import-history` | Load tsc-queue's memory history |
| `taskguard --receipt ID -- COMMAND` | Run COMMAND as job ID; when the files did not change during the run, publish a proof that CI can skip on. See [Proof receipts](#proof-receipts) |
| `taskguard proof check\|publish\|show\|log` | Skip or run a job in CI, publish by hand, and read proofs back |
| `taskguard help COMMAND`, `taskguard COMMAND --help` | Help for one command: what it does, its options, examples |
| `taskguard help --all` | Every command in full, in one text, for LLM agents |

`--help` after the command you run belongs to that command:
`taskguard -- tsc --help` shows tsc's help.

## Proof receipts

CI runs the checks that the developer or the agent already ran before the
push. taskguard can record that a job passed on an exact set of files, and
publish that as a GitHub commit status, so CI can skip the job.

A repo adds three things:

```sh
# 1. Where the job's command runs, in CI and in the local pipeline:
taskguard --receipt unit-tests -- bun run ci

# 2. One step in a small first CI job:
taskguard proof check unit-tests --github-output

# 3. On the heavy job:
# if: needs.<that job>.outputs.unit-tests != 'skip'
```

`--receipt ID` is the opt-in, per command. Every other taskguard call stays
as it was. The receipt name is the contract: a run under `--receipt
unit-tests` says "this is the Unit Tests job".

1. **Run.** taskguard hashes the files on disk before and after the command,
uncommitted and untracked files included, ignored files left out, through
a temporary index: the real index does not change. That is the tree hash
a commit of exactly those files gets. When the hash did not change, it
keeps a receipt, green or red. A file that changed during the run means no
receipt: nobody knows which version passed. An ignored `.env*` file means
no receipt either, because it can change a result that the hash cannot see
(`--allow-env-file GLOB` lets one through).
2. **Publish.** On a laptop, a background process watches the branch on
GitHub for an hour. When the branch head has exactly the tested files, it
posts the status `taskguard/ID`. It needs no checkout, so a pipeline may
delete its copy right after the push. In GitHub Actions the job posts at
once, on the PR head. The description is `f:FINGERPRINT, OS/ARCH,
DURATION, HOST`; the fingerprint is a hash of the command line.
`--no-publish` keeps a receipt on the machine, and `taskguard proof
publish` posts it by hand.
3. **Check.** `taskguard proof check ID` reads the PR head and its labels
from the GitHub Actions event, and waits up to 30 s for a status that is
still on its way. It says `skip` for a green status, and `run` for no
status, a red one, a `taskguard:ci` or `taskguard:ci:ID` label, or an
error from `gh`. `--os linux` accepts only proof made on Linux.

`taskguard proof log ID` lists the commits on main, the least proven first: the
suspects when a scheduled full run goes red. A squash merge counts the
statuses of its PR head when the two trees are the same. `taskguard proof
show` lists the receipts and statuses of the current checkout.

## Configuration

Settings come from files, in layers. Later layers win:
Expand Down Expand Up @@ -607,6 +659,9 @@ the others write:
- `taskguard prune` moves runs into the table `pruned_runs`, so every
version stops learning from them; `prune --undo` moves them back. Without
`--apply`, prune only shows what it would do.
- Proof receipts live in their own tables, `receipts` and `receipt_posts`,
keyed by tree hash. A version without them ignores them, and its
`--receipt` is an unknown option.
- The database only gains tables and columns. A new column is nullable or has
a default, so older versions can still insert rows.
- A job gets the same history key in every version.
Expand Down
44 changes: 44 additions & 0 deletions src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,35 @@ CREATE TABLE IF NOT EXISTS ns_1m (
cores_avg REAL, mem_avg_kb REAL,
PRIMARY KEY (minute, ns)
);
-- Proof receipts (`taskguard --receipt`): a command that ran on an exact tree of
-- files. Keyed by the tree hash, not the checkout, so a receipt made in one
-- worktree publishes from any other that holds the same files.
CREATE TABLE IF NOT EXISTS receipts (
id INTEGER PRIMARY KEY,
receipt TEXT NOT NULL,
tree TEXT NOT NULL,
head TEXT,
repo TEXT,
level TEXT NOT NULL,
reason TEXT,
ok INTEGER NOT NULL,
exit INTEGER,
cmd TEXT NOT NULL,
host TEXT,
os TEXT,
arch TEXT,
version TEXT,
started_at REAL NOT NULL,
duration_s REAL NOT NULL,
fingerprint TEXT
);
CREATE INDEX IF NOT EXISTS receipts_tree ON receipts(tree);
-- Each commit status a receipt was posted as.
CREATE TABLE IF NOT EXISTS receipt_posts (
receipt_row INTEGER NOT NULL,
sha TEXT NOT NULL,
posted_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS pruned_runs (
id INTEGER PRIMARY KEY,
ns TEXT NOT NULL,
Expand Down Expand Up @@ -529,6 +558,11 @@ impl Db {
conn.execute_batch(&format!("ALTER TABLE runs ADD COLUMN {col} {kind}"))?;
}
}
// Receipt tables of the 0.9.0 pre-releases: the command fingerprint.
let has_fp: bool = conn.prepare("SELECT 1 FROM pragma_table_info('receipts') WHERE name = 'fingerprint'")?.exists([])?;
if !has_fp {
conn.execute_batch("ALTER TABLE receipts ADD COLUMN fingerprint TEXT")?;
}
Ok(Db { conn })
}

Expand Down Expand Up @@ -1091,6 +1125,16 @@ mod tests {
"pruned_runs.prune_reason",
"pruned_runs.pruned_at",
"pruned_runs.queued_at",
"receipt_posts.posted_at",
"receipt_posts.receipt_row",
"receipt_posts.sha",
"receipts.cmd",
"receipts.duration_s",
"receipts.level",
"receipts.ok",
"receipts.receipt",
"receipts.started_at",
"receipts.tree",
];
let mut want: Vec<String> = v0_1_0.iter().chain(added.iter()).map(|s| s.to_string()).collect();
want.sort();
Expand Down
66 changes: 64 additions & 2 deletions src/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ options (sem style; the default is to run in the foreground):
--pipe with --bg: pass stdin to the command
-q, --quiet only print waits longer than the status interval
--hints / --no-hints agent hints on or off for this call
--receipt ID run COMMAND as job ID and publish a proof that CI
can skip on (taskguard help proof)
--allow-env-file GLOB with --receipt: an ignored .env* file that may exist
--no-publish with --receipt: keep the receipt on this machine
";

const RUN: &str = "\
Expand Down Expand Up @@ -197,6 +201,63 @@ Load tsc-queue's memory history (~/.cache/tsc-queue/history.tsv, or
$TSC_QUEUE_DIR/history.tsv) into taskguard's history, once.
";

const PROOF: &str = "\
taskguard --receipt ID [--allow-env-file GLOB]... [--no-publish] -- COMMAND
taskguard proof check ID... [--os OS] [--github-output] [--sha SHA] [--labels L,L] [--wait SECS]
taskguard proof publish [--sha SHA]... [--wait SECS] [--again] [-q]
taskguard proof show [--history]
taskguard proof log ID... [--since SHA] [-n N] [--branch REF]

Proof receipts: publish what passed on this machine as a GitHub commit status
(taskguard/ID), so CI can skip a job that is already proven.

A repo adds three things:

1. Where the job's command runs, in CI and in the local pipeline:
taskguard --receipt unit-tests -- <the command, unchanged>
2. One step in a small first CI job:
taskguard proof check unit-tests --github-output
3. On the heavy job:
if: needs.<that job>.outputs.unit-tests != 'skip'

--receipt ID is the opt-in, per command; every other taskguard call stays as
it was. The receipt name is the contract: a run under --receipt unit-tests
says \"this is the Unit Tests job\".

Run. taskguard hashes the files on disk before and after the command,
uncommitted and untracked files included, ignored files left out. The same
hash both times: a receipt, green or red, on that tree. A file that changed
during the run, or an ignored .env* file, means no receipt. The command runs
at once, outside the queue: it is mostly a task runner whose leaves queue.

Publish. On a laptop a background process watches the branch on GitHub for an
hour, and posts the status when the branch head has exactly the tested files;
the checkout may be gone by then. Any commit with exactly those files gets
it, so commit first, then run the receipt, then push. In GitHub Actions the job posts at once, on
the PR head (it needs statuses: write and GH_TOKEN). The description is
\"f:FINGERPRINT, OS/ARCH, DURATION, HOST\"; the fingerprint is a hash of the
command line.

Check. In GitHub Actions, proof check reads the PR head and its labels from
the event, and waits up to 30 s for a status that is still on its way. Per ID:
skip for a green status from an OS that --os accepts (default any); run for no
status, red, a taskguard:ci or taskguard:ci:ID label, or a gh error. Exit 0
when all skip; --github-output writes ID=skip|run to $GITHUB_OUTPUT and
exits 0.

commands:
check skip or run per ID, for CI
publish post receipts by hand: for HEAD (or each --sha) once it is
on GitHub; --wait SECS waits for that
show this checkout: local receipts and the statuses on HEAD
log each ID per commit on the main branch, the least proven
first when one ID is given; a squash merge reads the PR head
(~ marks a different tree)

GitHub is reached through the gh CLI (GH_TOKEN in CI, GH_REPO to name the
repo). TASKGUARD_GH names another gh.
";

const VERSION: &str = "\
taskguard version

Expand Down Expand Up @@ -226,15 +287,16 @@ pub fn text(cmd: &str) -> Option<String> {
"prune" => PRUNE.into(),
"doctor" => DOCTOR.into(),
"import-history" => IMPORT.into(),
"proof" | "receipt" => PROOF.into(),
"version" => VERSION.into(),
"help" => HELP.into(),
_ => return None,
})
}

/// Every command in the order `help --all` prints them.
pub const ALL: [&str; 12] =
["run", "wait", "top", "status", "pause", "start", "history", "outliers", "prune", "doctor", "import-history", "version"];
pub const ALL: [&str; 13] =
["run", "wait", "top", "status", "pause", "start", "history", "outliers", "prune", "doctor", "import-history", "proof", "version"];

/// The full help, every command in one text: for LLM agents and for reading
/// it all at once. `usage` is the overview that comes first.
Expand Down
23 changes: 23 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ mod insight;
mod key;
mod machine;
mod matcher;
mod proof;
mod queue;
mod recorder;
mod report;
Expand Down Expand Up @@ -41,6 +42,8 @@ usage:
drop runs from the history, or put them back (a dry run without --apply)
taskguard doctor [--explain \"COMMAND\"] configuration, readings, and how a command matches
taskguard import-history load tsc-queue's history
taskguard --receipt ID -- COMMAND run COMMAND as job ID, and publish a proof that CI can skip on
taskguard proof check|publish|show|log skip or run in CI, publish by hand, and read proofs back
taskguard run [options] -- COMMAND the same as the first form, for a command named like a subcommand
taskguard help COMMAND more about one command; COMMAND --help shows the same
taskguard help --all every command in full, in one text (for LLM agents)
Expand All @@ -67,6 +70,7 @@ const SUBCOMMANDS: &[&str] = &[
"prune",
"doctor",
"import-history",
"proof",
"version",
"help",
"__recorder",
Expand Down Expand Up @@ -129,6 +133,9 @@ pub fn parse_opts(args: &[String]) -> Result<Opts> {
"--hints" => o.hints = Some(true),
"--no-hints" => o.hints = Some(false),
"--wait" => o.wait = true,
"--receipt" => o.receipt = Some(take_value(args, &mut i, name)?),
"--allow-env-file" => o.allow_env_files.push(take_value(args, &mut i, name)?),
"--no-publish" => o.no_publish = true,
"-h" | "--help" => o.help = true,
_ if a.starts_with('-') && o.cmd.is_empty() => bail!("unknown option {a}\n\n{}", usage()),
_ => break,
Expand Down Expand Up @@ -188,6 +195,7 @@ fn dispatch(args: &[String]) -> Result<i32> {
"prune" => commands::prune(rest),
"doctor" => commands::doctor(rest),
"import-history" => commands::import_history(),
"proof" => proof::dispatch(rest),
"top" => top::run(rest),
"__recorder" => recorder::run().map(|_| 0),
_ => unreachable!(),
Expand All @@ -199,6 +207,16 @@ fn dispatch(args: &[String]) -> Result<i32> {
print!("{}", help::text(if o.wait { "wait" } else { "run" }).unwrap_or_default());
return Ok(0);
}
if let Some(id) = &o.receipt {
if o.jobs.is_some() || o.id.is_some() || o.bg || o.wait {
bail!("--receipt runs the command outside the queue; put taskguard inside the command for that");
}
let ro = proof::ReceiptOpts { id: id.clone(), allow_env_files: o.allow_env_files.clone(), no_publish: o.no_publish };
return proof::run_receipt(&ro, &o.cmd);
}
if !o.allow_env_files.is_empty() || o.no_publish {
bail!("--allow-env-file and --no-publish go with --receipt ID");
}
if o.wait && o.cmd.is_empty() {
return runner::wait_pool(o.id.as_deref());
}
Expand Down Expand Up @@ -249,6 +267,11 @@ mod tests {
assert_eq!(o.cmd, v("tsc --watch"), "options after the command belong to it");

assert!(parse_opts(&v("--bogus tsc")).is_err());
let o = parse_opts(&v("--receipt unit-tests --allow-env-file '**/.env.test' --no-publish -- bun run ci")).unwrap();
assert_eq!(o.receipt.as_deref(), Some("unit-tests"));
assert_eq!(o.allow_env_files, ["**/.env.test"]);
assert!(o.no_publish);
assert_eq!(o.cmd, v("bun run ci"));
let o = parse_opts(&v("--wait --id build")).unwrap();
assert!(o.wait && o.cmd.is_empty());
}
Expand Down
Loading
Loading