Someday Triage
Two modes over pages/someday.md in the Obsidian vault. All mechanical work is done by
scripts/someday.py — never hand-edit the markdown. You emit a JSON plan; the script
validates it, reconciles item identity across both files, and refuses stale plans.
Invoke the script by absolute path:
python3 ~/.claude/skills/someday-triage/scripts/someday.py <command>
SOMEDAY_VAULT overrides the vault root (default ~/Documents/Obsidian/main); the script
always reads pages/someday.md and pages/someday-done.md beneath it.
Hard rules
- Never edit
someday.md or someday-done.md directly. Every change goes through
someday.py apply. The script guarantees no item is silently lost; freehand editing does not.
- Always
apply --dry-run first, show Les the reconciliation line, then apply.
- Retirements need consent — except when the box is already ticked. The rule is about the
item's state, which the script can see (
item.checked), not about the reason string alone:
archive reason=done on an item that is already - [x] → autonomous. Les ticked it
himself; that is his own declaration the thing is finished, not the tool guessing.
archive reason=done on an item that is still open, proposed from a done-check hit →
requires Les's yes/no. There the tool is inferring, not reading a fact he already recorded.
closed and missed → always require yes/no, regardless of checkbox state.
routed (not someday-shaped, going to the journal) → always autonomous.
The script does not enforce any of this — it only records the reason, so the archive diff
shows which case happened. The rule lives here; keep it.
- The archive entry preserves the checkbox.
archive_entry renders - [x] , - [ ] , or a
bare - for a non-checkbox fragment, matching the item's state at the moment it was retired —
not always - [x] and not always bare. The one thing that does not travel from the source
line: a needs-research flag line is stripped. That is deliberate (a stale research question on
a retired item is noise) but it is the single carve-out from "everything indented below the item
travels with it" — worth knowing before you go looking for a flag note that's supposed to be
gone.
- Never run git against the vault.
/Users/lorchard/Documents/Obsidian/main/.git is an
empty directory — Syncthing carries files but not git internals. History lives in the private
obsidian-main-backup repo. Anything that walks the vault for git history will correctly find
nothing; that is expected, not a failure.
- Write the plan JSON to a temp path (
"$TMPDIR"/someday-plan.json). Never into the vault.
What the script cannot do
Read this before trusting any command's output. Each line below was measured, not guessed.
dupes finds near-verbatim duplicates only — byte-identical texts and case/punctuation
variants. It cannot find reworded duplicates, and no threshold change would fix that: on the
real file a true paraphrased duplicate pair scored 0.4304 while an unrelated but
surface-similar pair (Led strip for under bar / Led strip for above sink) scored 0.4858
— the real duplicate scored lower. A test pins this limitation deliberately.
You must read the intake items yourself for reworded duplicates. Expect dupes to print
nothing most runs; that is the tool working, not a clean list.
lint's research_candidates is the provable subset only — items containing a URL or a
markdown link, or already carrying a needs-research flag. It cannot know that "get a vectrex
flash card" names a purchasable product. The script nominates; you add the rest.
done-check cannot tell "already finished" from "finished before, due again." Real cases:
Dentist appointment is open and - [x] dentist appointment sits in the archive from an
earlier cycle; likewise haircut appointment (twice) and Clean furnace filter (twice). The
evidence is genuinely identical, so no metric separates them. Never propose archiving a
recurring task on a done-check hit. Appointments, filter changes, haircuts, renewals,
inspections — judgment call, always.
status --json's buckets holds every level-1 heading except # intake, not just the
tiers. The real file yields triage notes, the five tier N … headings, and unclear. Do not
write logic that assumes five keys, and copy bucket titles from this output rather than typing
them — they contain em dashes.
- The staleness sweep is currently inert. No
(verified YYYY-MM-DD) annotation exists
anywhere in the real someday.md yet — the research notes added in the 2026-08-04
reorganization carry no dates — so lint's stale category reports nothing. Audit mode has to
backfill those dates before the mechanism can ever fire. See the audit section.
lint's malformed_dates category catches a marker the tooling can't read. VERIFIED_RE
only checks digit shape, so a typo like (verified 2026-13-45) matches the pattern but isn't a
real calendar date. Such a note is skipped when computing staleness — the item is treated as
having no usable verification date, same as if the note weren't there at all — and surfaced
separately under malformed_dates so you know to go fix the date rather than have it silently
ignored.
- No op touches headings or a section's prose. Every operation acts on an item. There is no
way to add, rename, remove, or reorder a
#/## heading, and no way to edit the prose body
under one. place can file an item into an existing bucket or create a cluster with
--allow-new-clusters, and that is the whole of the structural surface. So if Les asks to drop a
section, rename a tier, or delete a paragraph of preamble, say plainly that the tool cannot and
let him decide — do not reach for a hand-edit on your own initiative. If he authorises one
anyway (reasonable for a section holding zero items, where the never-lose-an-item guarantee is
not in play), verify afterwards that status's open_total is unchanged and that
serialize(parse(text)) == text still holds, and say you checked.
The cheap path
Start every invocation with:
someday.py status --json
A nonzero completed is never "nothing to do" — it is the completed-items sweep's whole reason
to exist (see intake mode, step 2), and it must run regardless of how empty everything else looks.
Only when intake, needs_research, and completed are all 0 should you report that plus
one line of lint totals and stop without reading the rest of the list. This is the common
morning case and the whole reason status exists — but it is not the common case between
capture bursts, when intake is typically empty and a backlog of ticked boxes is exactly what's
sitting there waiting to be swept.
Mode: intake (default, run often)
status --json. Bail per the cheap path — checking intake, needs_research, and
completed — if there is genuinely nothing to do. Keep the digest.
- Sweep completed items — before touching intake, and before any decision to stop. For each
entry in
completed_items, emit an archive op with reason: done. This runs whenever
completed is nonzero, independent of intake and needs_research — a completed item pending
sweep is never "nothing to do," which is exactly why the cheap path in step 1 checks completed
too. Do not let a future reordering of these steps put a bail check ahead of this one. This op
is autonomous — no confirmation needed: a ticked - [x] box is Les's own declaration that
the thing is finished, not the tool inferring one. Fold these ops into the same plan as the
intake work below; at step 7's yes/no, ask only about the ops that need it — sweep archives are
not among them.
- Read the
# intake items and the heading skeleton only — not all existing items. The ids you
will file ops against come from that same status --json, under intake_items.
- Gather signals:
lint --json, dupes --json, and done-check --json (archive-only, no
--repos; in intake you are only judging a handful of new items, so the noise rate is a
non-issue).
- Drain order: existing
needs-research flags first, oldest flag date first, then new
intake items. Budget 3 research tasks per run — a convention you enforce, not a script
flag — so the queue cannot starve behind a drip of new ideas. Overflow gets a flag op
carrying the specific question, not research.
- For each intake item decide, in this order:
- Not someday-shaped? →
archive with reason routed, then invoke the journal-note
skill to place it. Appointments and anything with a date go to # followup; blog ideas to
# notes; near-term todos to # today or # this week. Autonomous.
- Duplicate of an existing item? → propose
merge. Remember dupes only catches
near-verbatim repeats; you own paraphrases.
- Already done? → propose
archive with reason done. Not if it is recurring. (This is
the done-check-inferred case, still open — different from step 2's sweep of items already
ticked; this one needs Les's yes/no.)
- Dead on the merits? → propose
archive with reason closed. Pruning on arrival is the
point: dead ideas should never join the list.
- Otherwise →
place into a bucket and cluster, retitle if the wording is vague, and
annotate with one concrete first step. If the first step involves a product, part, or external resource, do the quick research to ground it in reality.
- Research an item when the answer would change its disposition or to flesh out its concrete first step. Every factual note you write must end in
(verified YYYY-MM-DD). Always include markdown links for any outside sites, tools, or products you cite.
- Emit the plan →
apply --dry-run → show Les the reconciliation line → get yes/no on any
retirements and merges that need it (see step 2 for the ones that don't) → apply. Report one
line per item.
Mode: audit (periodic, or when the list feels stale)
Runs intake first. Because pruning already happens at intake, the rest is maintenance:
lint --json and dupes --json over the whole file. done-check --repos ~/devel only here,
and only with noise expected — measured on the real 174-item file, archive-only nominates 19
items (11%), while adding ~100 repos under `/devel` nominates 105 (~60%), which is close to
useless. Review every evidence line; never batch-accept.
- Stale-annotation sweep — the main job. Research facts rot: stock counts, version numbers,
"actively maintained."
- First run: backfill.
lint --stale-days N finds nothing today because no note carries a
(verified …) date. Walk the existing research notes and annotate each with a dated note
recording what is currently believed and when it was checked. Until this happens the whole
staleness mechanism silently never fires.
- Later runs: re-verify anything
lint reports under stale and annotate the fresh
finding with today's date.
- No op edits or removes an existing note line, so re-verification appends a fresh
(verified YYYY-MM-DD) note beside the old one rather than replacing it. lint decides
staleness from the most recent (verified …) date on an item, not the oldest, so the
new note is what counts — the item clears on the next lint once it carries a date inside
the window, and the old note is just left in place as history. Do not work around this by
hand-editing the file.
- Also check
lint's malformed_dates category: a (verified …) note whose date isn't a
real calendar date (e.g. a typo'd 2026-13-45) is skipped, not treated as fresh or stale, so
it never fires the mechanism at all. annotate a corrected (verified YYYY-MM-DD) note the
same way you would re-verify a stale one.
- Expiry sweep: route anything date-bound out of the list (
lint's dated category).
- Cluster drift: propose restructuring where clusters have grown lopsided or incoherent. New
clusters require
apply --allow-new-clusters.
- Apply, then write the findings to the journal via the
journal-note skill.
Choosing a bucket
Tiers describe division of labour, not priority:
- tier 1 — small enough to finish in one sitting; a PR-sized change.
- tier 2 — real projects in an agent's wheelhouse; multi-session, mostly code.
- tier 3 — an agent plans it or writes the code; Les does the physical half.
- tier 4 — an agent can research, spec, or draft; Les has to pull the trigger — purchases,
appointments, visits.
- tier 5 — all Les: physical labour, in person, personal.
unclear is the honest answer when the division of labour is genuinely undecided; do not guess a
tier to empty it.
Research brief template
Include these three instructions verbatim in every research dispatch. Each exists because
its absence cost something in the 2026-08-04 manual pass:
- "If you cannot ground a claim, say insufficient research for that item. Do not fill gaps
with plausible-sounding guesses. Five solid items and four honest gaps beat nine where I cannot
tell which is which."
- "Verify last-commit and last-release dates. Do not infer liveness from impressions — check."
- "Distinguish what you tested live from what you only read in docs. Label each."
- "Always include markdown links (
[text](url)) to any external sites, products, or documentation you cite so I don't have to google for it later."
Target decision-changing questions: is it maintained? superseded? purchasable and in stock?
already solved elsewhere? is the obvious approach a trap?
Plan format
digest must be the digest from the status --json you based the plan on; apply exits 2 if
the file changed since.
Where ids come from. Ids are content hashes — sha1(normalized text)[:8], -2 suffix on
exact collisions — so never compute or guess one by hand. Normalization lowercases and
collapses runs of whitespace before hashing, so a hand-derived id is wrong for any text with mixed
case or a doubled space, and apply rejects the plan (exit 2, unknown item ids). Read every id
out of command output:
- Intake work →
status --json's intake_items, a {id, text} entry per open item in the
# intake bucket, in document order. This is the only place ids for ordinary items appear;
without it the intake flow has nothing to file ops against.
lint --json, dupes --json, done-check --json carry ids only for the items they surface
— one that trips no lint category, matches no duplicate and hits no archive line appears in
none of them. Use these when acting on what they found; use intake_items otherwise.
- An id changes when the item's text changes, so re-snapshot after any apply.
{
"digest": "bb04e52349d8",
"ops": [
{"op": "place", "item": "a1b2c3d4", "bucket": "tier 1 — small enough to finish in one sitting", "cluster": "blog & site"},
{"op": "retitle", "item": "a1b2c3d4", "text": "sharpened wording"},
{"op": "annotate", "item": "a1b2c3d4", "notes": ["first step: ... (verified 2026-08-04)"]},
{"op": "flag", "item": "e5f6a7b8", "question": "is it still maintained?"},
{"op": "flag", "item": "e5f6a7b8", "remove": true},
{"op": "merge", "into": "a1b2c3d4", "from": ["e5f6a7b8"], "note": "collapsed duplicate"},
{"op": "archive", "item": "c9d0e1f2", "reason": "closed", "note": "why it is dead"}
]
}
place — cluster is optional; omit it to file directly under the level-1 bucket (tier 5 has
no clusters). Titles match exactly, no fuzzy matching. An unknown cluster is rejected unless
--allow-new-clusters; an unknown bucket is always rejected.
annotate — notes is a list of strings, appended as tab-indented bullets.
flag — writes needs-research (today): <question>; the script formats the marker, so don't.
"remove": true clears the flag instead.
merge — the winner (into) stays; each id in from is archived under collapsed duplicates.
archive — reason is one of done, missed, closed, routed, merged.
Two rules about plan shape, both enforced before anything is written:
- Nothing may reference an item after the op that removes it. Ops run in order and removal is
final, so an
annotate sequenced after that item's archive — or after the merge that
archived it — would write its note to neither file. apply rejects the whole plan (exit 2,
naming the id and both op indices) rather than discard the edit. Put edits before the removing
op, where they still reach the archive entry; a removing op is the last word on its item, and one
item gets at most one.
- Field types are checked.
notes and from must be lists of strings; item, into,
bucket, cluster, reason, question, note, text must be strings; remove must be a
real true/false. "notes": "buy it" is refused, not spread one character per bullet, and
"remove": "false" is refused rather than read as truthy and clearing the flag.
Exit codes: 0 success, 2 plan rejected (bad JSON, stale digest, unknown id, unknown bucket or
cluster, unknown reason, unsupported op, an op after the one that removes its item, a wrongly typed
field), 3 reconciliation or serializer check failed — an internal invariant broke, so stop and
report rather than retry.
Commands
someday.py status # counts, buckets, digest
someday.py status --json
someday.py dupes [--threshold 0.75] [--json]
someday.py lint [--json] [--stale-days 180] [--today YYYY-MM-DD]
someday.py done-check [--repos DIR ...] [--json] # no --repos = archive only
someday.py apply PLAN [--dry-run] [--allow-new-clusters] [--today YYYY-MM-DD]
apply is the only command that writes. --today on lint and apply exists for testing.
JSON shapes:
status → intake, intake_items (list of {id, text}, open # intake items in document
order), completed (count of items with checked is True, whole file), completed_items
(list of {id, text}, same shape and ordering guarantee as intake_items, whole file, document
order), needs_research, open_total, buckets (title → open count), digest
lint → dated, fragments, untiered, research_candidates, stale, malformed_dates;
each a list of {id, text, bucket}
dupes → threshold, groups[].items[].{id, text}
done-check → candidates[].{id, text, matched, evidence: {source, line}}
1---2name: someday-triage3description: Use when Les wants to triage pages/someday.md — process the4---56# Someday Triage78Two modes over `pages/someday.md` in the Obsidian vault. All mechanical work is done by9`scripts/someday.py` — **never hand-edit the markdown.** You emit a JSON plan; the script10validates it, reconciles item identity across both files, and refuses stale plans.1112Invoke the script by absolute path:1314 python3 ~/.claude/skills/someday-triage/scripts/someday.py <command>1516`SOMEDAY_VAULT` overrides the vault root (default `~/Documents/Obsidian/main`); the script17always reads `pages/someday.md` and `pages/someday-done.md` beneath it.1819## Hard rules2021- **Never edit `someday.md` or `someday-done.md` directly.** Every change goes through22 `someday.py apply`. The script guarantees no item is silently lost; freehand editing does not.23- **Always `apply --dry-run` first**, show Les the reconciliation line, then apply.24- **Retirements need consent — except when the box is already ticked.** The rule is about the25 item's *state*, which the script can see (`item.checked`), not about the reason string alone:26 - `archive reason=done` on an item that is **already `- [x]`** → autonomous. Les ticked it27 himself; that is his own declaration the thing is finished, not the tool guessing.28 - `archive reason=done` on an item that is **still open**, proposed from a `done-check` hit →29 requires Les's yes/no. There the tool is inferring, not reading a fact he already recorded.30 - `closed` and `missed` → always require yes/no, regardless of checkbox state.31 - `routed` (not someday-shaped, going to the journal) → always autonomous.32 The script does **not** enforce any of this — it only records the reason, so the archive diff33 shows which case happened. The rule lives here; keep it.34- **The archive entry preserves the checkbox.** `archive_entry` renders `- [x] `, `- [ ] `, or a35 bare `- ` for a non-checkbox fragment, matching the item's state at the moment it was retired —36 not always `- [x]` and not always bare. The one thing that does **not** travel from the source37 line: a `needs-research` flag line is stripped. That is deliberate (a stale research question on38 a retired item is noise) but it is the single carve-out from "everything indented below the item39 travels with it" — worth knowing before you go looking for a flag note that's supposed to be40 gone.41- **Never run git against the vault.** `/Users/lorchard/Documents/Obsidian/main/.git` is an42 empty directory — Syncthing carries files but not git internals. History lives in the private43 `obsidian-main-backup` repo. Anything that walks the vault for git history will correctly find44 nothing; that is expected, not a failure.45- Write the plan JSON to a temp path (`"$TMPDIR"/someday-plan.json`). Never into the vault.4647## What the script cannot do4849Read this before trusting any command's output. Each line below was measured, not guessed.5051- **`dupes` finds near-verbatim duplicates only** — byte-identical texts and case/punctuation52 variants. It cannot find reworded duplicates, and no threshold change would fix that: on the53 real file a true paraphrased duplicate pair scored **0.4304** while an unrelated but54 surface-similar pair (`Led strip for under bar` / `Led strip for above sink`) scored **0.4858**55 — the real duplicate scored *lower*. A test pins this limitation deliberately.56 **You must read the intake items yourself for reworded duplicates.** Expect `dupes` to print57 nothing most runs; that is the tool working, not a clean list.58- **`lint`'s `research_candidates` is the provable subset only** — items containing a URL or a59 markdown link, or already carrying a `needs-research` flag. It cannot know that "get a vectrex60 flash card" names a purchasable product. **The script nominates; you add the rest.**61- **`done-check` cannot tell "already finished" from "finished before, due again."** Real cases:62 `Dentist appointment` is open *and* `- [x] dentist appointment` sits in the archive from an63 earlier cycle; likewise `haircut appointment` (twice) and `Clean furnace filter` (twice). The64 evidence is genuinely identical, so no metric separates them. **Never propose archiving a65 recurring task on a `done-check` hit.** Appointments, filter changes, haircuts, renewals,66 inspections — judgment call, always.67- **`status --json`'s `buckets` holds every level-1 heading except `# intake`**, not just the68 tiers. The real file yields `triage notes`, the five `tier N …` headings, and `unclear`. Do not69 write logic that assumes five keys, and copy bucket titles from this output rather than typing70 them — they contain em dashes.71- **The staleness sweep is currently inert.** No `(verified YYYY-MM-DD)` annotation exists72 anywhere in the real `someday.md` yet — the research notes added in the 2026-08-0473 reorganization carry no dates — so `lint`'s `stale` category reports nothing. Audit mode has to74 backfill those dates before the mechanism can ever fire. See the audit section.75- **`lint`'s `malformed_dates` category catches a marker the tooling can't read.** `VERIFIED_RE`76 only checks digit shape, so a typo like `(verified 2026-13-45)` matches the pattern but isn't a77 real calendar date. Such a note is skipped when computing staleness — the item is treated as78 having no usable verification date, same as if the note weren't there at all — and surfaced79 separately under `malformed_dates` so you know to go fix the date rather than have it silently80 ignored.81- **No op touches headings or a section's prose.** Every operation acts on an *item*. There is no82 way to add, rename, remove, or reorder a `#`/`##` heading, and no way to edit the prose body83 under one. `place` can file an item into an existing bucket or create a *cluster* with84 `--allow-new-clusters`, and that is the whole of the structural surface. So if Les asks to drop a85 section, rename a tier, or delete a paragraph of preamble, say plainly that the tool cannot and86 let him decide — do **not** reach for a hand-edit on your own initiative. If he authorises one87 anyway (reasonable for a section holding zero items, where the never-lose-an-item guarantee is88 not in play), verify afterwards that `status`'s `open_total` is unchanged and that89 `serialize(parse(text)) == text` still holds, and say you checked.9091## The cheap path9293Start every invocation with:9495 someday.py status --json9697A nonzero `completed` is never "nothing to do" — it is the completed-items sweep's whole reason98to exist (see intake mode, step 2), and it must run regardless of how empty everything else looks.99Only when `intake`, `needs_research`, **and** `completed` are all `0` should you report that plus100one line of `lint` totals and **stop** without reading the rest of the list. This is the common101morning case and the whole reason `status` exists — but it is not the common case *between*102capture bursts, when intake is typically empty and a backlog of ticked boxes is exactly what's103sitting there waiting to be swept.104105## Mode: intake (default, run often)1061071. `status --json`. Bail per the cheap path — checking `intake`, `needs_research`, **and**108 `completed` — if there is genuinely nothing to do. Keep the `digest`.1092. **Sweep completed items — before touching intake, and before any decision to stop.** For each110 entry in `completed_items`, emit an `archive` op with `reason: done`. This runs whenever111 `completed` is nonzero, independent of `intake` and `needs_research` — a completed item pending112 sweep is never "nothing to do," which is exactly why the cheap path in step 1 checks `completed`113 too. Do not let a future reordering of these steps put a bail check ahead of this one. This op114 is **autonomous — no confirmation needed**: a ticked `- [x]` box is Les's own declaration that115 the thing is finished, not the tool inferring one. Fold these ops into the same plan as the116 intake work below; at step 7's yes/no, ask only about the ops that need it — sweep archives are117 not among them.1183. Read the `# intake` items and the heading skeleton only — not all existing items. The ids you119 will file ops against come from that same `status --json`, under `intake_items`.1204. Gather signals: `lint --json`, `dupes --json`, and `done-check --json` (archive-only, no121 `--repos`; in intake you are only judging a handful of new items, so the noise rate is a122 non-issue).1235. **Drain order:** existing `needs-research` flags first, oldest flag date first, then new124 intake items. Budget **3 research tasks per run** — a convention you enforce, not a script125 flag — so the queue cannot starve behind a drip of new ideas. Overflow gets a `flag` op126 carrying the specific question, not research.1276. For each intake item decide, in this order:128 - **Not someday-shaped?** → `archive` with reason `routed`, then invoke the `journal-note`129 skill to place it. Appointments and anything with a date go to `# followup`; blog ideas to130 `# notes`; near-term todos to `# today` or `# this week`. Autonomous.131 - **Duplicate of an existing item?** → propose `merge`. Remember `dupes` only catches132 near-verbatim repeats; you own paraphrases.133 - **Already done?** → propose `archive` with reason `done`. Not if it is recurring. (This is134 the `done-check`-inferred case, still open — different from step 2's sweep of items already135 ticked; this one needs Les's yes/no.)136 - **Dead on the merits?** → propose `archive` with reason `closed`. Pruning on arrival is the137 point: dead ideas should never join the list.138 - **Otherwise** → `place` into a bucket and cluster, `retitle` if the wording is vague, and139 `annotate` with one concrete first step. If the first step involves a product, part, or external resource, do the quick research to ground it in reality.1407. Research an item when the answer would change its disposition **or to flesh out its concrete first step**. Every factual note you write must end in `(verified YYYY-MM-DD)`. **Always include markdown links for any outside sites, tools, or products you cite.**1418. Emit the plan → `apply --dry-run` → show Les the reconciliation line → get yes/no on any142 retirements and merges that need it (see step 2 for the ones that don't) → `apply`. Report one143 line per item.144145## Mode: audit (periodic, or when the list feels stale)146147Runs intake first. Because pruning already happens at intake, the rest is maintenance:1481491. `lint --json` and `dupes --json` over the whole file. `done-check --repos ~/devel` only here,150 and only with noise expected — measured on the real 174-item file, archive-only nominates 19151 items (~11%), while adding ~100 repos under `~/devel` nominates 105 (~60%), which is close to152 useless. Review every evidence line; never batch-accept.1532. **Stale-annotation sweep — the main job.** Research facts rot: stock counts, version numbers,154 "actively maintained."155 - **First run: backfill.** `lint --stale-days N` finds nothing today because no note carries a156 `(verified …)` date. Walk the existing research notes and `annotate` each with a dated note157 recording what is currently believed and when it was checked. Until this happens the whole158 staleness mechanism silently never fires.159 - **Later runs:** re-verify anything `lint` reports under `stale` and `annotate` the fresh160 finding with today's date.161 - No op edits or removes an existing note line, so re-verification *appends* a fresh162 `(verified YYYY-MM-DD)` note beside the old one rather than replacing it. `lint` decides163 staleness from the **most recent** `(verified …)` date on an item, not the oldest, so the164 new note is what counts — the item clears on the next `lint` once it carries a date inside165 the window, and the old note is just left in place as history. Do not work around this by166 hand-editing the file.167 - Also check `lint`'s `malformed_dates` category: a `(verified …)` note whose date isn't a168 real calendar date (e.g. a typo'd `2026-13-45`) is skipped, not treated as fresh or stale, so169 it never fires the mechanism at all. `annotate` a corrected `(verified YYYY-MM-DD)` note the170 same way you would re-verify a stale one.1713. Expiry sweep: route anything date-bound out of the list (`lint`'s `dated` category).1724. Cluster drift: propose restructuring where clusters have grown lopsided or incoherent. New173 clusters require `apply --allow-new-clusters`.1745. Apply, then write the findings to the journal via the `journal-note` skill.175176## Choosing a bucket177178Tiers describe **division of labour, not priority**:1791801. **tier 1** — small enough to finish in one sitting; a PR-sized change.1812. **tier 2** — real projects in an agent's wheelhouse; multi-session, mostly code.1823. **tier 3** — an agent plans it or writes the code; Les does the physical half.1834. **tier 4** — an agent can research, spec, or draft; Les has to pull the trigger — purchases,184 appointments, visits.1855. **tier 5** — all Les: physical labour, in person, personal.186187`unclear` is the honest answer when the division of labour is genuinely undecided; do not guess a188tier to empty it.189190## Research brief template191192Include these three instructions **verbatim** in every research dispatch. Each exists because193its absence cost something in the 2026-08-04 manual pass:194195- "If you cannot ground a claim, say **insufficient research** for that item. Do not fill gaps196 with plausible-sounding guesses. Five solid items and four honest gaps beat nine where I cannot197 tell which is which."198- "Verify last-commit and last-release dates. Do not infer liveness from impressions — check."199- "Distinguish what you **tested live** from what you only **read in docs**. Label each."200- "Always include markdown links (`[text](url)`) to any external sites, products, or documentation you cite so I don't have to google for it later."201202Target decision-changing questions: is it maintained? superseded? purchasable and in stock?203already solved elsewhere? is the obvious approach a trap?204205## Plan format206207`digest` must be the `digest` from the `status --json` you based the plan on; `apply` exits 2 if208the file changed since.209210**Where ids come from.** Ids are content hashes — `sha1(normalized text)[:8]`, `-2` suffix on211exact collisions — so **never compute or guess one by hand.** Normalization lowercases and212collapses runs of whitespace before hashing, so a hand-derived id is wrong for any text with mixed213case or a doubled space, and `apply` rejects the plan (exit 2, `unknown item ids`). Read every id214out of command output:215216- **Intake work → `status --json`'s `intake_items`**, a `{id, text}` entry per open item in the217 `# intake` bucket, in document order. This is the only place ids for *ordinary* items appear;218 without it the intake flow has nothing to file ops against.219- `lint --json`, `dupes --json`, `done-check --json` carry ids **only for the items they surface**220 — one that trips no lint category, matches no duplicate and hits no archive line appears in221 none of them. Use these when acting on what they found; use `intake_items` otherwise.222- An id changes when the item's text changes, so re-snapshot after any apply.223224```json225{226 "digest": "bb04e52349d8",227 "ops": [228 {"op": "place", "item": "a1b2c3d4", "bucket": "tier 1 — small enough to finish in one sitting", "cluster": "blog & site"},229 {"op": "retitle", "item": "a1b2c3d4", "text": "sharpened wording"},230 {"op": "annotate", "item": "a1b2c3d4", "notes": ["first step: ... (verified 2026-08-04)"]},231 {"op": "flag", "item": "e5f6a7b8", "question": "is it still maintained?"},232 {"op": "flag", "item": "e5f6a7b8", "remove": true},233 {"op": "merge", "into": "a1b2c3d4", "from": ["e5f6a7b8"], "note": "collapsed duplicate"},234 {"op": "archive", "item": "c9d0e1f2", "reason": "closed", "note": "why it is dead"}235 ]236}237```238239- `place` — `cluster` is optional; omit it to file directly under the level-1 bucket (tier 5 has240 no clusters). Titles match exactly, no fuzzy matching. An unknown cluster is rejected unless241 `--allow-new-clusters`; an unknown bucket is always rejected.242- `annotate` — `notes` is a list of strings, appended as tab-indented bullets.243- `flag` — writes `needs-research (today): <question>`; the script formats the marker, so don't.244 `"remove": true` clears the flag instead.245- `merge` — the winner (`into`) stays; each id in `from` is archived under `collapsed duplicates`.246- `archive` — `reason` is one of `done`, `missed`, `closed`, `routed`, `merged`.247248Two rules about plan *shape*, both enforced before anything is written:249250- **Nothing may reference an item after the op that removes it.** Ops run in order and removal is251 final, so an `annotate` sequenced after that item's `archive` — or after the `merge` that252 archived it — would write its note to neither file. `apply` rejects the whole plan (exit 2,253 naming the id and both op indices) rather than discard the edit. Put edits *before* the removing254 op, where they still reach the archive entry; a removing op is the last word on its item, and one255 item gets at most one.256- **Field types are checked.** `notes` and `from` must be lists of strings; `item`, `into`,257 `bucket`, `cluster`, `reason`, `question`, `note`, `text` must be strings; `remove` must be a258 real `true`/`false`. `"notes": "buy it"` is refused, not spread one character per bullet, and259 `"remove": "false"` is refused rather than read as truthy and clearing the flag.260261Exit codes: `0` success, `2` plan rejected (bad JSON, stale digest, unknown id, unknown bucket or262cluster, unknown reason, unsupported op, an op after the one that removes its item, a wrongly typed263field), `3` reconciliation or serializer check failed — an internal invariant broke, so stop and264report rather than retry.265266## Commands267268 someday.py status # counts, buckets, digest269 someday.py status --json270 someday.py dupes [--threshold 0.75] [--json]271 someday.py lint [--json] [--stale-days 180] [--today YYYY-MM-DD]272 someday.py done-check [--repos DIR ...] [--json] # no --repos = archive only273 someday.py apply PLAN [--dry-run] [--allow-new-clusters] [--today YYYY-MM-DD]274275`apply` is the only command that writes. `--today` on `lint` and `apply` exists for testing.276277JSON shapes:278279- `status` → `intake`, `intake_items` (list of `{id, text}`, open `# intake` items in document280 order), `completed` (count of items with `checked is True`, whole file), `completed_items`281 (list of `{id, text}`, same shape and ordering guarantee as `intake_items`, whole file, document282 order), `needs_research`, `open_total`, `buckets` (title → open count), `digest`283- `lint` → `dated`, `fragments`, `untiered`, `research_candidates`, `stale`, `malformed_dates`;284 each a list of `{id, text, bucket}`285- `dupes` → `threshold`, `groups[].items[].{id, text}`286- `done-check` → `candidates[].{id, text, matched, evidence: {source, line}}`