efforts
v0.1.0
Published
Time tracking without opening a time tracker: a JSON-only CLI plus the agent skill that drives it. Overlapping clocks in, honest wall-clock hours out.
Maintainers
Readme
efforts
Time tracking without opening a time tracker. You say what you are working on; your coding agent drives the CLI.
you: started the webhook retry bug for Client Co
you: also picking up a code review, in parallel
you: switch the webhook one to integration
you: that review stopped at 15:50, I got distracted
you: what did today actually cost per taskNo timer to remember, no window to switch to, no Friday-afternoon reconstruction. Time tracking is frustrating because nobody wants to stop work to press a button, and this never asks you to. Corrections are just sentences: a start you got wrong, a stint that ended an hour ago, an activity that turned into another one mid-flow.
The point is that you can trust what comes out. efforts owns every logged piece of work and
refuses what it cannot know: it will not guess which clock you meant, will not accept a timestamp in
the future, and cannot represent a gap where two activities meet. What it stores is structured for
analysis later, not prose to be read back.
Works with — Claude Code today, with the skill in the box. Any agent that can run a shell command can drive the CLI; the policy that makes it behave well is one markdown file you can point that agent at. A local MCP server is next, for chat clients with no shell of their own.
What you get
- Capture that costs little additional effort. The interface is the conversation you were already having, in the window you were already in.
- Several clocks at once, as the normal case.
startnever stops anything. You can have agents developing two tickets in parallel and a meeting on top, and the report still adds up to a day that exists, with a stated rule for how the overlap was split. - Retroactive everything. "That started at 8", "the demo ran 11 to 12", "I switched to integration an hour ago" — all of it is one command, not a form.
- JSON as the product. Every read is a single envelope with no rendering baked in, so a terminal, a chat client, a spreadsheet and an invoice can each build the view they want from the same payload.
Install
npm i -g efforts # or: npx efforts <command>
efforts statusYour tracked efforts land in ~/.efforts/data — see Where your hours live
to move them, or to keep one ledger across machines.
The package ships the skill as well as the CLI, because the policy is half the tool. To let an agent drive it, put the skill where that agent looks — for Claude Code, either of:
cp -r "$(npm root -g)/efforts/.claude/skills/time-tracking" ~/.claude/skills/ # personal
cp -r "$(npm root -g)/efforts/.claude/skills/time-tracking" .claude/skills/ # one projectWhere exactly an agent looks for skills, and which one wins when two of them answer the same sentence, is that agent's business rather than this tool's — so check its own documentation. If several skills on your machine claim time tracking, invoking this one by name is the reliable way through.
Then check your agent can actually drive it. The skill asks for judgement the CLI cannot
supply — which command a sentence means, when to ask instead of guess, what must never reach a
note — and models differ at that. eval/cases.md ships with the package for this
step: open a session with the skill loaded, say a case's utterance in the state it describes, and
check the calls against the expected shape.
$(npm root -g)/efforts/eval/cases.mdHalf a dozen cases is enough to tell whether your setup captures faithfully or merely captures. The
ones marked observed are the ones a real session has already exercised, so start there.
Requires Node 20+ and has no dependencies.
Where your hours live
The ledger is plain files: one JSON file per day under days/YYYY/MM/, plus a projects.json. It
lives outside the package, so upgrading or uninstalling the CLI cannot touch your history, and you
can read a day with cat if you ever want to.
EFFORTS_DATA_DIR=~/work/hours # move the ledger
EFFORTS_TZ=America/Bogota # override the timezone
EFFORTS_AUTO_COMMIT=1 # commit after every change, if the ledger is a git repo
EFFORTS_LOOKBACK_DAYS=14 # how far back "what is running" looksSettings you want to keep go in a file instead, searched nearest first:
./efforts.config.json this directory — a project's own defaults
~/.config/efforts/config.json you, in every launch contextMerged one key at a time, so a project file can set defaultProject while the user file supplies
everything else. efforts config prints the resolved settings and which file supplied each key.
An efforts.config.json in a repo is the useful case: {"defaultProject": "acme"} there means work
started while you are in that checkout needs no --project and cannot land anywhere surprising.
EFFORTS_TZ matters more than it looks. Day boundaries, --at 9:15 and every "today" resolve in
that zone, so the wrong one files work on the wrong day. It defaults to the system timezone, which
is right until you travel.
One ledger across two machines. git init the ledger directory and treat it as a repo you push:
cd ~/.efforts/data && git init && git add . && git commit -m "hours"Because a day is a file, two machines working different days merge without conflict; the same day on
both, or two new projects at once in projects.json, is an ordinary merge you resolve by hand.
EFFORTS_AUTO_COMMIT=1 # commit the ledger after every changeCommits are local, so pushing stays yours — a cron job or a shell hook, whenever suits. A commit that fails is a warning rather than an error: the ledger is written first, and git is a convenience on top of it.
Commands
start <task…> --project P [--at T] [--tags a,b] [--weight N] [--link k=v] [--note N]
stop [query] [--all] [--at T] [--note N]
log <task…> --project P --from T --to T [--tags a,b] [--weight N] [--link k=v] [--note N]
split <id> --at T [--task T] [--first-task T] [--note N]
switch <task…> --project P
status
today | day [date] [--attribute] [--strategy S]
report [--from D --to D | --week | --month] [--project P] [--attribute]
[--strategy S] [--format json|csv]
analyze [--from D --to D | --week | --month]
export [--from D --to D | --week | --month] [--attribute] [--format json|csv]
note <query|--last> <text…> | <query|--last> --rm
link <query> <key=value…>
edit <id> [--task|--project|--start|--end|--tags|--weight|--link]
rm <id> resume [query]
projects list | rename <id> <name> | merge <from> <into> | resolve <name>
config--json prints a single envelope and nothing else, which is how an agent talks to it. Reads emit
that envelope with or without the flag; commands that change something print a one-line receipt for
the person who typed them, which never enters the envelope. --at takes 9:15, 9:15am, 14:30,
-20m, -2h, or a full ISO instant.
--attribute turns on overlap resolution; --strategy picks equal (default), weighted or
exclusive.
start and stop record what has already happened, so their --at must be in the past. log,
split and edit accept a future instant: log … --from 10:30 --to 12:00 said at 11:58 is one
block someone knows the shape of, and clipping the last two minutes would write a time nobody
said. The trade is that a bare --to 23:00 reads as tonight for log, and as an error pointing
at yesterday for start.
What honest hours look like on a parallel day
On an overlapping day, raw durations add up to more hours than the day contains. Attribution splits each moment among the tasks running in it, so per-task totals sum to the wall clock the work actually occupied.
Given A 09:00–12:00, B 10:00–11:00, C 11:30–13:00, D 14:00–14:30:
| | A | B | C | D | total | | :-- | --: | --: | --: | --: | --: | | raw | 180 | 60 | 90 | 30 | 360 | | attributed | 135 | 30 | 75 | 30 | 270 |
360 minutes of apparent effort; 270 minutes of day. Both numbers are true and they answer different questions, which is why a report shows them side by side and names the strategy it used.
It apportions elapsed time, which is arithmetic. It does not claim how long a task would have
taken without the multitasking — timestamps cannot answer that. analyze gives you the honest
version of that question: the gap between raw and attributed is how much apparent effort was
overlap. See docs/SCHEMA.md for what attribution does and does not claim.
How it behaves
Several clocks run at once. start never stops anything. stop with more than one entry open
and no query is an error listing the candidates, rather than a guess at which task ended.
The CLI owns every file and every number. Nothing else reads or writes day files, so duration maths and attribution hold regardless of how the conversation goes.
Reads emit JSON and no view. status, today, report, analyze, export and
projects list return one envelope: data, plus warnings when something needs saying. Building
the table, the artifact or the invoice belongs to whatever is reading, because arithmetic has one
right answer and a view has as many as there are surfaces.
A boundary is taken once. An activity that changed mid-clock is one split; work that is
already over is one log. Both apply a single instant to both sides, so a gap or an overlap at the
seam cannot be written.
A clock you forgot says so. "What is running" reads back a couple of weeks, so it stays quick —
but an entry left open longer than that would otherwise be invisible to status, unreachable by
stop, and still accruing against the current moment. So status warns about it by name, and so
does any report whose range it distorts.
A project answers to its id or its current name. Client Co, clientco and client-co reach
one project, on reads as much as on writes. A merely similar name still resolves, but the match is
always reported in warnings, since a wrong one would merge two real projects. An unknown name is
an error naming the projects that exist — never an empty report, which would read as "no work on
that".
Nothing about a ticket's state is stored. Entries carry ticket=KEY links and arbitrary notes
about the window they cover. Whether that ticket is merged, blocked or abandoned lives in the
tracker of record, so a report cannot contradict it.
What this expects of the model
Half of this tool is bin/ and half is .claude/skills/time-tracking/SKILL.md.
The CLI owns the arithmetic and the refusals — ambiguous stop errors rather than choosing, --at
rejects a future instant, split applies one boundary to both halves so a gap cannot be
represented. Those are guarantees, and node --test keeps them honest.
The skill owns the judgement, and prose can only request compliance. So the tool assumes a model that will:
- ask instead of picking when an instruction is ambiguous — two open entries and "stop work" is a question, not a coin flip;
- hold a rule together with its reason and generalise past the stated case. "No future work in a note" has to become "write the blocker in the past tense and put the plan on the ticket" without that being spelled out;
- render a view for the surface it is on — the CLI returns JSON and nothing else;
- reach a second tool for a ticket title and degrade to the bare key, silently, when it is not there.
A model that cannot do those still gets correct totals — the ledger is code — but it will capture less faithfully, and it will be quieter about the judgement calls than it should be.
eval/cases.md holds the policy as cases drawn from real sessions: the
utterance, the state, and the shape of the expected calls. Each one is marked observed (a session
did this, with the date) or rule (the skill says so, nothing has exercised it yet). They are evals
rather than tests — read the calls and score them.
Built and used daily since 2026-07-28. The ledger half is held by node --test; the policy half
by the cases above.
Development
Building on it, or changing it: CONTRIBUTING.md covers the checkout, the test suite, the module map, and how the policy half is kept honest.
License
MIT — do what you like with it, keep the copyright notice.
