The time-tracker subcommands wrap /api/v1/time-tracker/* and require the time:read / time:write scopes on the API key. You can run several timers at once, for example one per repo or terminal; the commands below list, target, and stop them individually.
time start
--bucket <uuid> Optional bucket
--task <uuid> Optional task to attribute against
--description <string> Free-form description
--json
mal time start \
--bucket 9c449c4c-1080-451b-b6ce-d8482f763c0f \
--description "Pairing on V1 sweep"
Starting a timer never fails because another one is already running, it always starts. The response carries timer_count (how many timers are now running, including this one) and a warnings array. The CLI prints an informational note for PARALLEL_TIMERS_RUNNING and a stronger one for BUCKET_ALREADY_BILLING, which means another running timer already bills the same bucket, so the overlapping wall clock is being counted against it twice.
time stop
--session <uuid> Stop this specific timer
--bucket <name-or-id> Stop every running timer billing this bucket
--all Stop every running timer
--json
With exactly one timer running, bare mal time stop still stops it, no flag needed. With several running and none of --session, --bucket, or --all passed, the CLI refuses, lists every running timer with its id, and exits 1 rather than guess which one you meant. --session <id> stops one by id, --bucket <name-or-id> stops every running timer that bills the named bucket (membership in its billed_buckets, matched by id or case-insensitive name — an exact name match wins, a substring match is accepted only when it's unique, otherwise the CLI refuses with AMBIGUOUS_BUCKET and names the candidates), and --all stops every running timer. The response surfaces the rolled-up duration in minutes plus a still_running list of any timers left going after this stop.
$ mal time stop
Timer stopped
duration: 47m
$ mal time stop
Multiple timers are running, pass --session or --all:
e8a1... Centsure 1h 12m
a92f... Roanoke docs 22m
Clocking out with --all
mal time stop --all stops every running timer in one command instead of chaining N single stops — the correct verb for "clock out." It prints one outcome line per timer (stopped, already stopped, or failed) and always ends on a definitive final state line, re-checked against the server after the loop rather than inferred from it: either every timer is confirmed stopped, or the still-running ones are listed by name. The process exits non-zero unless that final state is fully clocked out, so a script can test the exit code instead of parsing the printed hours.
$ mal time stop --all
✔ 2 timers processed
duration: 47m
billed to ● Centsure
id: e8a1...
duration: 22m
billed to ● Roanoke docs
id: a92f...
0 timers running — fully clocked out.
If a target can't be stopped, the final line instead lists what's still running (e.g. "1 timer still running — mal time status.") and the process exits 1. A target that another surface stopped first (a race) is reported as already stopped, not a failure — the goal state already holds.
time status
Reads GET /api/v1/time-tracker/active and lists every running timer, not just one. When you run it inside a repo linked to a bucket, the timer billing that bucket is sorted first; the rest follow. If no timer is active the call still succeeds (the list is empty).
The CLI also resolves which bucket the directory you're standing in belongs to — always, whether or not a timer is running — and reports it as cwd_bucket_status, one of three states: linked (cwd_bucket names it), unlinked (checked, and this directory genuinely has no bucket — a timer started here would be unattributed), or unknown (the check itself failed — offline, not logged in, no git — which is not a statement about linkage and must never be read as one). This three-way split replaced an older shape where a bare null meant both "no bucket" and "didn't check," which made an unattributed timer look identical to a skipped check.
$ mal time status
2 timers running
● Centsure
Pairing on V1 sweep
1h 12m since 2026-05-01T14:18:03.000Z
id: e8a1...
● Roanoke docs
22m since 2026-05-01T15:08:11.000Z
id: a92f...
JSON envelope
mal time status --json mirrors the API shape, with the cwd resolution folded in: entry is the timer billing the bucket you're standing in, if any, else the most recently started running timer (timers[0] === entry); timers lists all of them, cwd-first, each carrying billed_buckets (see Buckets on one session below); and cwd_bucket / cwd_bucket_status report what the current directory resolves to, independent of whether anything is running.
{
"ok": true,
"data": {
"timer_count": 2,
"cwd_bucket": { "id": "3f21...", "name": "Roanoke docs", "color": "#f97316" },
"cwd_bucket_status": "linked",
"entry": {
"id": "a92f...",
"bucket_id": "3f21...",
"task_id": null,
"description": null,
"started_at": "2026-05-01T15:08:11.000Z",
"billed_buckets": [
{ "id": "3f21...", "name": "Roanoke docs", "color": "#f97316", "elapsed_seconds": 1320, "duration_basis": "lap" }
]
},
"timers": [
{
"id": "a92f...",
"bucket_id": "3f21...",
"task_id": null,
"description": null,
"started_at": "2026-05-01T15:08:11.000Z",
"billed_buckets": [
{ "id": "3f21...", "name": "Roanoke docs", "color": "#f97316", "elapsed_seconds": 1320, "duration_basis": "lap" }
]
},
{
"id": "e8a1...",
"bucket_id": "9c449c4c-...",
"task_id": null,
"description": "Pairing on V1 sweep",
"started_at": "2026-05-01T14:18:03.000Z",
"billed_buckets": [
{ "id": "9c449c4c-...", "name": "Centsure", "color": "#6366F1", "elapsed_seconds": 4320, "duration_basis": "lap" }
]
}
]
}
}
billed_buckets is the same per-session multi-bucket set documented under V1 REST API → Time Tracker — a single timer session can bill more than one bucket at once, so test membership in it rather than comparing against the scalar bucket_id. For a visual view of every session as a concurrent thread on a shared time axis (the "Weave"), see the dashboard's /time-tracker page — several timers running at once draw as parallel plateaus rather than one line.
time reconcile
Corroborates a timer that ran long, or a completed entry the server's reaper auto-closed, against local evidence: git commit activity in the entry's linked repo, this machine's Claude Code session transcripts, a grep for an issued (but maybe lost) mal time stop, and recent /tmp session activity, then proposes a corrected end time. No LLM call, so it costs nothing. With no evidence at all it proposes nothing rather than invent a number — the same discipline the sessions route's end_basis field already enforces server-side.
mal time reconcile [--apply] [--hours <n>] [--entry <id>] [--json]
--apply Stop (if running) and correct every candidate with evidence
--hours <n> Threshold in hours for a candidate to be flagged (default 3)
--entry <id> Reconcile one specific completed time_entries id directly, skipping the scan
--json
Without --entry, candidates are every currently running timer past --hours, plus every completed entry from the last 14 days that is either tagged auto-reaped (see the auto-reap note under V1 REST API → Time Tracker below) or itself exceeds the threshold. Without --apply it only reports; with it, an evidenced running candidate is stopped (pinned by session id, same as time stop) and every evidenced candidate's duration is corrected through the same call time edit uses.
$ mal time reconcile
Reconcile — threshold 3h
────────────────────────────────────────────────
● RUNNING ● Centsure
started 2026-08-16T14:18:03.000Z
current: 412m (elapsed so far)
repo path(s): /home/ryan/dev/centsure
git last activity: 2026-08-16T18:42:11-04:00
claude session mtime: 2026-08-16T18:44:02.000Z
stop issued (grep): none
/tmp session activity: 2026-08-16T18:44:02.000Z
proposed end: 2026-08-16T18:49:02.000Z
proposed duration: 271m (-141m vs current)
not applied.
re-run with --apply to stop/correct the ones with evidence above.
A candidate with no local evidence prints "no local evidence — proposing nothing" instead of a proposed end time. Running against a server that predates GET /api/v1/time-tracker/entries falls back to running-timer candidates only, and says so.