Org habits — repeating tasks, and the consistency graph

Where the code is. Everything this page describes is implemented in lattice-org-plugin, a separate repository. It is a WASM Component plugin: nothing here is compiled into the editor, and lattice has no BufferKind::Org, no Lang::Org arm and no Editor:: method for any of it. What lives in this tree is the seams the plugin contributes through — see plugin-host.md.

The one exception on this page is HB.5's annotation seam, which is lattice's: see org-agenda.md §5b. Org is its first consumer, not its owner.

Sequencing and status: slice-plans/org-habits.md. User documentation: org.md, and the plugin's own doc/org.md for the full reference.

A habit in org is a repeating task with a range: SCHEDULED: <2026-09-03 Wed .+1d/3d> on a headline carrying :STYLE: habit. It is due a day after you last did it, and overdue three days after. The agenda draws a colour-coded bar of the last three weeks so you can see the pattern rather than the next task.

This fragment records what lattice implements, what it deliberately does not, and why the file format is the constraint.

1. The files have another reader

These are the user's own org files, opened by emacs too. That single fact decides the model: byte-compatible with org-habit, or the feature is a regression. A habit lattice marks done must be a habit emacs still understands, and vice versa — otherwise the graph looks better here while the files stop round-tripping, which no feature repays.

So: same :STYLE: habit, same repeater grammar, same :LOGBOOK: entries, same :LAST_REPEAT:. Nothing new is written to the file.

The richer tracking is derived, not stored. Streaks, completion rate, per-weekday patterns and a window longer than org's 21+7 are all computable from the LOGBOOK that is already there. Inventing file syntax to store what can be computed would be the inferior design wearing ambition's clothes — and it would drift the moment a habit is completed in emacs.

A useful consequence: the graph works on day one against real history, because emacs has been writing those LOGBOOK entries for years.

2. The repeater grammar

<date REPEATER> where the repeater is one of:

formmeaningshift base
+1devery daythe OLD timestamp — may land in the past
++1devery day, but catch upthe old timestamp, advanced until future
.+1da day after you did ittoday (the completion date)
.+1d/3dhabit range: ready after 1 day, overdue after 3today

Units: d day, w week, m month, y year.

The /MAX suffix is habit-only and is what the consistency graph's colours read. A habit without it is still a habit; MAX defaults to MIN, which makes every day either done or overdue.

Only .+ uses the completion date. That distinction is the whole reason + and .+ both exist, and getting it wrong is the difference between "water the plants every 3 days" and "water the plants on the 1st, 4th, 7th no matter when you last did".

3. What completing a habit does

Org's org-auto-repeat-maybe, reproduced:

  1. Shift SCHEDULED (and DEADLINE, if it carries a repeater) per §2.
  2. Reset the keyword to :REPEAT_TO_STATE: if the headline has one, else the first non-done keyword of its sequence.
  3. Write a state-change line into the :LOGBOOK: drawer: - State "DONE" from "NEXT" [2026-09-03 Wed 09:14].
  4. Set :LAST_REPEAT: [2026-09-03 Wed 09:14].

The headline never actually stays DONE, which is the part that surprises people reading the code: a repeating task's completion is recorded in the log, not in the keyword. The graph is built from those log lines, so step 3 is not bookkeeping — it is the data.

org-log-into-drawer decides whether step 3 goes into :LOGBOOK: or sits loose under the headline. Lattice honours the option; the user's config sets it, and a plugin that ignored it would scatter log lines through files emacs would then re-file differently.

4. The consistency graph

One column per day over a window (org: 21 preceding + 7 following). Each day is one of four states, and the colour is the whole point. With due the day the task becomes ready and late = due + (MAX - MIN) the deadline day:

statewhenorg face
cleard < due — before the habit is due againorg-habit-clear-face (blue)
readydue <= d < late — you may do itorg-habit-ready-face (green)
alertd == late — the deadline day itselforg-habit-alert-face (yellow)
overdued > late — you missed itorg-habit-overdue-face (red)

Alert is a day, not a mood. An earlier revision of this table said "past MIN, approaching MAX", which reads like a band and invites the guess that yellow means "today, and you should get on with it". It is neither: alert is the deadline day itself, in every column of the window, past or future, and red is only ever past it. Completing ON the deadline day is completing in time, so that cell is green. Verified against org-habit-get-faces, with deadline = scheduled + (MAX - MIN) from org-habit-parse-todo.

A day you completed on is drawn with the completed glyph regardless of state.

Eight faces, not four. Each state has a solid and a muted variant. A future column is muted; so is a past column that was neither missed nor kept. That is what stops three weeks of ordinary days shouting as loudly as a miss, and it is why the theme elements come in pairs (org.habit.ready / org.habit.ready.muted).

The trailing columns of a kept habit go green → yellow → red. A future day assumes you have not done it yet, so even a perfectly kept habit ends its window in red. That run is the graph prompting you, not a record of failure — worth stating because it looks like a bug, and a test asserting "a kept habit shows no red" fails against correct output.

due moves as you look back. A past column is coloured by what was true then, not by today's schedule, or every day before the last completion reads as overdue. So due is recomputed per column from the completion that most recently preceded it, and how depends on the repeater's base — .+ counts from when you did it, + from the stamp, ++ replays its catch-up hops. All three branches are ported.

Colours resolve from the theme registry as elements the mode owns (org.habit.clear, .ready, .alert, .overdue), so :colorscheme recolours the graph — the lesson OA.16's hardcoded hex left behind.

Glyphs follow the icon-degradation rule: a Nerd-Font palette and a BMP fallback occupying the same cell width, so the column geometry does not shift on toggle.

4b. What the history says, beyond the picture (HB.6)

§1's claim — richer tracking is derived, not stored — cashed out. Three numbers, appended to the graph row as an unstyled caption:

  ···○○!●●✓··○!●✓··○!✓··○✓··○  5× · 80% · Mon↓

Org has none of these, so each is a choice rather than a port, and the units are the argument. "A five-day streak" is only true for a .+1d habit; five in a row on .+3d is fifteen days. So:

numberdefinition
streakkept repetitions in a row (), not days. A repetition is kept when the next completion lands within MAX — the range is exactly the tolerance the user wrote. Zero when the habit is currently overdue.
ratecompletions in the graph's past window over the repetitions it had room for (window / MIN), capped at 100. A day-based denominator would report a three-daily habit as 33% while it was kept perfectly.
weakest weekdaythe weekday completed on least often, over the whole history. Reported only for daily habits, and only once every weekday has ≥3 samples.

The gates on the weekday are the interesting part: a .+3d habit lands on a rotating subset of weekdays however well it is kept, so its distribution is an artefact of the cadence rather than of the person — and a young daily habit has not lived through enough Mondays to have a Monday problem. A level history nominates nobody rather than whichever weekday sorts first.

A streak of zero under a habit dropped last week is deliberate. Showing 12× there is the failure that makes people stop trusting the number, which costs more than the number is worth.

The caption carries no span. That is what makes appending it safe: the graph's colour runs still tile the graph exactly, and the text paints in the row's own foreground rather than reading as another day.

org.habit-stats gates the whole suffix, defaulting on — a derived number nobody sees is a number nobody checks. Off is a real setting rather than a courtesy: the graph is 29 cells and the caption a dozen more, which costs something on a narrow terminal.


5. Where it is drawn

A VirtualRow anchored Below the habit's agenda row — the mechanism the clock report and the magit headerline already use.

How the guest's graph reaches that row is designed in org-agenda.md §5b: it rides the scan entry as an annotation, for the same coordinate reason entry.spans does, rather than through a general virtual-row producer seam. That general seam is §6's deferred item below.

Not org's inline column-40 placement, and the cost is stated rather than hidden: the habit block is twice as tall as emacs'.

An earlier revision of this section gave the wrong reason — that appending to the row "would mean editing the file". It would not. Virtual text appends without touching the buffer, and lattice already does exactly that twice on every code buffer: inlay hints splice mid-line, and the cursor-line diagnostic summary splices at end-of-line. The mechanism is there. The reason is different and stronger.

A consistency graph is a bar chart, and a bar chart needs its columns to line up. Reading one is comparing today's cell against the same cell on the row above. That property survives only if every graph starts at the same column, and an agenda row is a verbatim excerpt of a source line whose width is whatever the user typed — 32 to 77 columns across one real corpus. So an inline slot has three placements and none of them work:

  • pad to a fixed column, push right when the headline is longer — the chart starts at four different columns and stops being a chart;
  • truncate the headline — it is editable source text, not a rendering;
  • overwrite at the column — which is exactly what emacs does. Its own defcustom says so: "consistency graphs will overwrite anything else in the buffer", and org-habit-build-graph reaches the column with (move-to-column org-habit-graph-column t) and writes over whatever is there.

That last line is the whole answer. Org can place inline because its agenda line is generated, disposable text it is free to destroy. Ours is the file. The row below is not a compromise forced by a missing mechanism — it is the placement that keeps the columns aligned once rows are editable excerpts.

HB.7 is still worth building, for other consumers. Inlay hints, git blame and lenses are short, per-line and alignment-indifferent, which is precisely the shape an end-of-line slot serves well. It should not be framed as the thing that will one day replace this row; see the slice plan.

6. What is not built

  • org-habit-show-habits-only-for-today and the other display toggles — options with no consumer yet.
  • Habits in a non-agenda buffer. The graph is an agenda-row decoration; an org file shows the raw SCHEDULED line, as emacs does.
  • 3× a week targets. A real gap in org-habit, and not fixable in the file format without inventing syntax (§1). The honest approximation is a derived analytic over the completion history — a rate, not a schedule.