Skip to content

Commit 5e56fd2

Browse files
authored
Merge pull request #8 from tteschon/replace-cadence-with-rrule-frequency
Replace cadence with an RRULE-valued frequency field
2 parents 9241014 + 82bea8a commit 5e56fd2

6 files changed

Lines changed: 202 additions & 100 deletions

File tree

plugins/work-skills/skills/obsidian-task-grooming/SKILL.md

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ obsidian base:query path="<base path>" format=json
3939
```
4040

4141
**Test the values this returns, never the note text.** The task template writes
42-
`cadence:` as an empty key on every note, so a check for a missing line matches
42+
`frequency:` as an empty key on every note, so a check for a missing line matches
4343
nothing and reports a clean base no matter how much has drifted. `base:query`
4444
returns `null` for an empty key and a genuinely absent one alike, which is the
4545
only place the two look the same.
@@ -48,7 +48,7 @@ Below, "empty" means the queried value is `null` or `""`.
4848

4949
## Step 2 - Check A, unscheduled one-time tasks
5050

51-
**Rule** - `status` is not `done`, `due` is empty, and `cadence` is empty.
51+
**Rule** - `status` is not `done`, `due` is empty, and `frequency` is empty.
5252

5353
These are invisible to the schedule, not merely unprioritised. The base's
5454
`Today` and `This week` views both filter on `due != null`, so a task with no
@@ -68,7 +68,7 @@ A **recurring** task with an empty `due` is a different thing and not a defect
6868

6969
## Step 3 - Check B, stale active status
7070

71-
**Rule** - `status` is `active`, `cadence` is set, `due` is in the future, and
71+
**Rule** - `status` is `active`, `frequency` is set, `due` is in the future, and
7272
`last done` is not empty.
7373

7474
A correct roll-forward ends at `status: backlog`; that is the last property
@@ -77,11 +77,11 @@ documented lifecycle. It means the roll-forward ran and recomputed `due`, and
7777
only the status reset failed to stick.
7878

7979
Confirm the arithmetic before calling it stale. A task whose `due` sits exactly
80-
one cadence interval past its `last done` was rolled forward, and the status is
80+
one interval past its `last done` was rolled forward, and the status is
8181
the only thing wrong with it:
8282

8383
```
84-
Crosstrek Oil Change cadence: every 6 months
84+
Crosstrek Oil Change frequency: FREQ=MONTHLY;INTERVAL=6;BYMONTHDAY=-1
8585
last done 2026-06-19 due 2026-12-19 <- exactly 6 months on, rolled forward
8686
```
8787

@@ -105,15 +105,15 @@ reason they can be found and cleared at all.
105105

106106
| Row | Meaning | Do |
107107
|---|---|---|
108-
| `done`, `cadence` empty | A finished one-time task | Sweep it - delete after confirmation |
109-
| `done`, `cadence` set | A recurring task that has silently stopped recurring | **Never delete.** Report for `obsidian-tasks` Step 4 roll-forward |
108+
| `done`, `frequency` empty | A finished one-time task | Sweep it - delete after confirmation |
109+
| `done`, `frequency` set | A recurring task that has silently stopped recurring | **Never delete.** Report for `obsidian-tasks` Step 4 roll-forward |
110110

111-
**`cadence` is what keeps a task out of the sweep.** A done row carrying a
112-
cadence is a recurring chore that was marked finished and never rolled
111+
**`frequency` is what keeps a task out of the sweep.** A done row carrying a
112+
rule is a recurring chore that was marked finished and never rolled
113113
forward; deleting it destroys the schedule instead of repairing it. This guard
114114
lives here and only here - never re-implement it elsewhere. It is also the
115115
finding worth naming out loud, because nothing else in the vault will ever
116-
flag it: the row sits in the base looking finished, its cadence intact, and it
116+
flag it: the row sits in the base looking finished, its rule intact, and it
117117
will simply never come due again.
118118

119119
When `base:views` lists a `Sweep` view, `base:query ... view="Sweep"` returns
@@ -158,8 +158,8 @@ is a result, and silence reads as an unrun check.
158158

159159
## Gotchas
160160

161-
- **`cadence` is empty on a one-time task, not missing.** The template writes
162-
the key on every note, so `cadence:` appears everywhere and a test for an
161+
- **`frequency` is empty on a one-time task, not missing.** The template
162+
writes the key on every note, so `frequency:` appears everywhere and a test for an
163163
absent line matches nothing - a grooming run built that way finds zero
164164
problems and reports success. Test the value from `base:query`.
165165
- **Never pass `type=` to `property:set`.** It rewrites the property's type
@@ -172,7 +172,11 @@ is a result, and silence reads as an unrun check.
172172
- **`obsidian delete` trashes by default; never pass `permanent`.** Plain
173173
`delete` prints `Moved to trash: <path>` and the note stays recoverable from
174174
the vault trash, which is what makes the sweep safe to confirm in a batch.
175-
- **Sweep only on the queried `cadence`, never on the note text.** A recurring
175+
- **An empty `frequency` and `frequency: ""` are different to Bases.** A bare
176+
key is `null`; an empty string is not, so a `frequency != null` filter
177+
matches one-time tasks and a view built on it returns wrong rows silently.
178+
If a sweep candidate list looks too short, check for `""` in the notes.
179+
- **Sweep only on the queried `frequency`, never on the note text.** A recurring
176180
task read the wrong way looks one-time, and the sweep deletes the schedule.
177181
This is the one place in the skill where a bad read destroys work.
178182
- **Never read task state from the Kanban board.** It does not write back to

plugins/work-skills/skills/obsidian-tasks/SKILL.md

Lines changed: 55 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
name: obsidian-tasks
3-
description: Creates and manages task notes in an Obsidian vault - one note per task, identified by a type property set to task and collected by a Bases file, carrying status, due, priority, category, and cadence. Use this skill when the user wants to add a task, mark one done, change its priority or due date, roll a recurring chore forward after doing it, or ask what to work on next - what are my top tasks, what is overdue, what should I do this weekend, add mowing the lawn to my tasks, I just changed the oil. Also use it when the user mentions their task base, a task repo, a task note's status or cadence, or a backlog of home, yard, or vehicle chores. Do not use it for checkbox tasks inline in note bodies - what the obsidian tasks command lists - do not use it to review the whole base or to sweep out finished tasks - groom my tasks, what is stale, clear out my done tasks - which is obsidian-task-grooming; and do not use it for general vault reading, searching, or note editing - obsidian-vault covers those.
3+
description: Creates and manages task notes in an Obsidian vault - one note per task, identified by a type property set to task and collected by a Bases file, carrying status, due, priority, category, and an RRULE frequency. Use this skill when the user wants to add a task, mark one done, change its priority or due date, roll a recurring chore forward after doing it, or ask what to work on next - what are my top tasks, what is overdue, what should I do this weekend, add mowing the lawn to my tasks, I just changed the oil. Also use it when the user mentions their task base, a task repo, a task note's status or frequency, or a backlog of home, yard, or vehicle chores. Do not use it for checkbox tasks inline in note bodies - what the obsidian tasks command lists - do not use it to review the whole base or to sweep out finished tasks - groom my tasks, what is stale - which is obsidian-task-grooming; and do not use it for general vault reading, searching, or note editing - obsidian-vault covers those.
44
compatibility: Requires the obsidian CLI and a vault whose task notes carry a type property of task, collected by a Bases file
55
---
66

@@ -23,10 +23,15 @@ exit codes cannot be trusted; do not re-derive it. Run its check, then
2323

2424
```bash
2525
command -v obsidian # exit 1 - stop, not fixable from the shell
26+
command -v uv # needed to evaluate RRULEs in Step 4
2627
obsidian vault info=name # confirm the right vault
2728
obsidian bases # find the task base
2829
```
2930

31+
Rolling a recurring task forward evaluates an RFC 5545 `RRULE` and so needs
32+
Python as well as the CLI - `uv run --with python-dateutil` supplies it per
33+
invocation, with no project dependency. Every other step is shell only.
34+
3035
| Found | Do |
3136
|---|---|
3237
| A task base | Use the path `bases` printed, exactly |
@@ -87,7 +92,7 @@ confusing here, because `type` is also the property that marks a task:
8792
`name=type` is correct, `type=text` is the destructive one.
8893

8994
Ask for `category` and `priority` when the user did not say; leave `due` and
90-
`cadence` empty rather than inventing them. If `base:create` fails, fall back
95+
`frequency` empty rather than inventing them. If `base:create` fails, fall back
9196
to `create name="<name>" path="<folder>"` then
9297
`property:set name=type value=task` - the note is only a task once it has
9398
that property.
@@ -104,9 +109,9 @@ The canonical set is `backlog`, `active`, `done`:
104109
obsidian property:set name=status value=active path="<path>"
105110
```
106111

107-
**Check `cadence` before writing `done`** - it separates the two kinds of
112+
**Check `frequency` before writing `done`** - it separates the two kinds of
108113
task, and the branch is not recoverable by reading the note afterwards. A
109-
task *with* a cadence goes to Step 4 and never gets `status: done`. A task
114+
task *with* a rule goes to Step 4 and never gets `status: done`. A task
110115
*without* one is finished for good here, and finishing it removes the note:
111116

112117
```bash
@@ -130,20 +135,33 @@ after. The ones already sitting in the base are swept by
130135

131136
A recurring task is never left `done`; that is what makes it recur. Marking
132137
one `done` and stopping is still the most likely mistake in this skill, but it
133-
no longer hides: done tasks stay in the base, so a done row carrying a cadence
134-
is visible as the anomaly it is. `obsidian-task-grooming` lists those rows for
135-
roll-forward and never deletes them - **`cadence` is what keeps a task out of
136-
the sweep**, which is one more reason never to clear it.
138+
no longer hides: done tasks stay in the base, so a done row carrying a
139+
`frequency` is visible as the anomaly it is. `obsidian-task-grooming` lists
140+
those rows for roll-forward and never deletes them - **`frequency` is what
141+
keeps a task out of the sweep**, which is one more reason never to clear it.
137142

138-
Due dates snap to the **end of a period**, computed from today rather than
139-
from `last done`. Worked from a completion on Monday 2026-08-17:
143+
`frequency` holds an RFC 5545 `RRULE`. The new `due` is the next occurrence
144+
**strictly after today**, anchored on the task's current `due`:
140145

141-
| Cadence | New `due` |
142-
|---|---|
143-
| `weekly` | 2026-08-30 (Sunday) |
144-
| `monthly` | 2026-09-30 |
145-
| `every 6 months` | 2027-02-28 |
146-
| `annual` | 2027-08-31 |
146+
```bash
147+
uv run --with python-dateutil python3 -c '
148+
import sys, datetime as d
149+
from dateutil.rrule import rrulestr
150+
r = rrulestr(sys.argv[1], dtstart=d.datetime.fromisoformat(sys.argv[2]))
151+
print(r.after(d.datetime.fromisoformat(sys.argv[3])).date().isoformat())
152+
' "<frequency>" "<current due>" "<today>"
153+
```
154+
155+
**Never compute this by hand.** RRULE's `BY*` parts expand or limit depending
156+
on the `FREQ` above them, and the intuitive spelling of "annually on the last
157+
day of the month" - `FREQ=YEARLY;BYMONTHDAY=-1` - silently yields a *monthly*
158+
series. `references/recurrence.md` has the value grammar, the traps, and the
159+
verified cases.
160+
161+
Anchoring on `due` is what makes a late completion land on the next scheduled
162+
slot instead of shifting every future cycle. Confirm afterwards that the new
163+
`due` is itself on the rule's grid - an off-grid anchor rolls forward by days
164+
instead of months.
147165

148166
Then write three properties, in this order, and append to the body:
149167

@@ -159,10 +177,10 @@ part number, what was done - goes on the body log line under a
159177
`## Service log` heading, **never** into those two fields. The body log also
160178
keeps the history that `last done` overwrites.
161179

162-
Read `references/recurrence.md` for any cadence outside that table, the
163-
algorithm behind it, and the month-end and leap-year edges. It exists to stop
164-
you rolling the completion date forward by the interval instead of snapping
165-
to a period end - a drift that compounds every cycle.
180+
Read `references/recurrence.md` before writing or editing any rule. It holds
181+
the `RRULE` value grammar, the evaluator invocation, the grid-alignment
182+
assertion, and the expand-versus-limit traps that make hand-computed dates
183+
wrong without erroring.
166184

167185
## Step 5 - Sweeping the base belongs to grooming
168186

@@ -171,8 +189,8 @@ them out is `obsidian-task-grooming`'s Step 4: it surveys the whole base, lists
171189
every candidate by name, and deletes on a single confirmation. Hand off to it
172190
rather than sweeping here.
173191

174-
The rule that decides what may go - **`cadence` empty means sweepable,
175-
`cadence` set means never delete, roll it forward instead** - lives there and
192+
The rule that decides what may go - **`frequency` empty means sweepable,
193+
`frequency` set means never delete, roll it forward instead** - lives there and
176194
nowhere else. Do not re-derive it in this skill; two copies of that guard are
177195
two things that can drift apart, and the failure mode is a deleted recurring
178196
schedule.
@@ -258,12 +276,21 @@ from the vault trash. If a command printed `Error: `, say what did not happen.
258276
`status: done`, so `base:query` is the full inventory rather than a list of
259277
open work. Drop done rows before ranking, and never schedule one as if it
260278
were outstanding. Clearing them out is grooming's sweep, not this skill's.
261-
- **`cadence` is empty on a one-time task, not missing.** The template writes
262-
the key with no value, so `cadence:` appears on every task note and a test
263-
for an absent line matches nothing - a branch built that way sends every
264-
task down the one-time path. `base:query` returns `null` for an empty key and for a
265-
genuinely absent one alike, which is why the queried value is the one to
266-
test.
279+
- **`frequency` is empty on a one-time task, not missing.** The template
280+
writes the key with no value, so `frequency:` appears on every task note and
281+
a test for an absent line matches nothing - a branch built that way sends
282+
every task down the one-time path. `base:query` returns `null` for an empty
283+
key and for a genuinely absent one alike, which is why the queried value is
284+
the one to test.
285+
- **Never seed an empty `frequency` with `property:set value=""`.** That
286+
writes `frequency: ""`, and an empty string is **not** `null` to Bases, so
287+
every `frequency != null` filter starts matching one-time tasks and the view
288+
returns wrong rows with no error. The template's bare `frequency:` key is
289+
null; let the template write it.
290+
- **A new property is invisible to `base:query` until the base names it.**
291+
Results are keyed by the view's columns, so a property written to every note
292+
reads back as absent until it is added to the `.base` file. Verify a new
293+
field against `obsidian read`, or update the base first.
267294
- **`obsidian delete` trashes by default; never pass `permanent`.** Plain
268295
`delete` prints `Moved to trash: <path>` and the note stays recoverable from
269296
the vault trash until the user empties it. `permanent` skips that, and

plugins/work-skills/skills/obsidian-tasks/references/base.md

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ formulas:
2121
views:
2222
- type: table
2323
name: Table
24-
order: [file.name, category, cadence, due, last done, priority, status]
24+
order: [file.name, category, frequency, due, last done, priority, status]
2525
- type: table
2626
name: Today
2727
filters:
@@ -40,15 +40,15 @@ views:
4040
name: Needs attention
4141
filters:
4242
and:
43-
- 'cadence != null'
43+
- 'frequency != null'
4444
- 'due == null'
45-
order: [file.name, cadence, last done, priority]
45+
order: [file.name, frequency, last done, priority]
4646
- type: table
4747
name: Sweep
4848
filters:
4949
and:
5050
- 'status == "done"'
51-
- 'cadence == null'
51+
- 'frequency == null'
5252
order: [file.name, category, last done, created]
5353
```
5454
@@ -72,7 +72,7 @@ the same numbers instead of recomputing them. The views answer the daily and
7272
weekly questions directly. Two are queues rather than schedules:
7373
`Needs attention` holds recurring tasks with no due date because they have
7474
never been completed, and `Sweep` holds finished one-time tasks waiting to be
75-
deleted. The `cadence == null` clause on `Sweep` is what keeps a recurring
75+
deleted. The `frequency == null` clause on `Sweep` is what keeps a recurring
7676
task that is wrongly sitting in `done` off the deletion list.
7777

7878
## Writing the file
@@ -115,12 +115,19 @@ task work are in `SKILL.md`.
115115
base sorts on a `formula.Untitled` that no `formulas:` block defines, and
116116
the remaining sort keys still apply as though it were not there. Neither
117117
case reports anything, so a sort that looks configured may not be.
118-
- **`== null` matches an empty property, not just a missing one.** The task
119-
template writes `cadence:` with no value on every note, so `cadence == null`
120-
is what correctly selects one-time tasks - a filter written to look for the
121-
key's absence would match nothing. Verified against the live vault: the
122-
`Sweep` view returns the finished one-time tasks and excludes a `done` task
123-
carrying `cadence: weekly`.
118+
- **`== null` matches an empty property, but an empty *string* is not null.**
119+
The template writes `frequency:` with no value, so `frequency == null`
120+
correctly selects one-time tasks - a filter looking for the key's absence
121+
would match nothing. But `frequency: ""`, which is what
122+
`property:set value=""` writes, is **not** null and slips through the
123+
filter. Verified on a scratch base against the live vault: a
124+
`frequency != null` probe returned all 29 tasks while the one-time notes
125+
held `""`, and the correct 11 once they held a bare key. Seed empty keys
126+
from the template, never from `property:set`.
127+
- **A new property is invisible to `base:query` until a view lists it.**
128+
Results are keyed by the view's `order:` columns, so a property written to
129+
every note still reads back as absent until the base file names it. Update
130+
the base before trying to verify a new field.
124131
- **Test formulas and filters on a scratch base, never the live one.**
125132
Create it, query it, delete it. A malformed formula is invisible until a
126133
query returns `Error: ...` in the column where a value should be.

0 commit comments

Comments
 (0)