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] Optional positional: bucket name or id
--bucket <id-or-name> Same thing as a flag
--task <uuid> Optional task to attribute against
--description <string> Free-form description
--json
mal time start centsure --description "Pairing on V1 sweep"
# equivalent, by flag and id
mal time start \
--bucket 9c449c4c-1080-451b-b6ce-d8482f763c0f \
--description "Pairing on V1 sweep"
The bucket can be named right in the command: a UUID is trusted as an id, anything else matches bucket names case-insensitively (exact match wins, a substring match is accepted only when it's unique, otherwise the CLI refuses and names the candidates). With no bucket given, the CLI resolves one from the repo you're standing in. Passing both the positional and --bucket refuses with CONFLICTING_TARGET.
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
[target] Optional positional: bucket name/id, or a session id
--session <uuid> Stop this specific timer
--bucket <name-or-id> Stop every running timer billing this bucket
--all Stop every running timer
--json
$ mal time stop centsure
Timer stopped
duration: 47m
billed to ● Centsure
The easiest form names the target right in the command. A non-UUID positional is a bucket query (same matching as --bucket below); a UUID matches a running timer's session id first, then a billed bucket id. Combining the positional with --session or --bucket refuses with CONFLICTING_TARGET.
With exactly one timer running, bare mal time stop still stops it, no flag needed. With several running and no target named, 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
2 timers are running. Which one?
e8a1... Centsure 1h 12m
a92f... Roanoke docs 22m
mal time stop <bucket-name> stops by bucket, mal time stop <session-id> stops one, --all stops them all.
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.