A flat-YAML task tracker that is just files. No daemon, no database, no git hooks. diarie.dev
issue-tracker cli nodejs agent-friendly
0

Configure Feed

Select the types of activity you want to include in your feed.

diarie / lib / schema.js
14 kB 255 lines
1/** 2 * schema.js — the single canonical schema for the flat-YAML task substrate. 3 * 4 * THIS IS THE AUTHORITY. `ready.js`, `validate.js`, `store.js`, the migrator, and every 5 * downstream consumer derive their vocabulary from here — never fork it. The enum Sets 6 * carry the runtime vocabulary; the matching `@typedef` unions carry the type-level 7 * vocabulary, checked by the type-check gate. 8 * 9 * A `.diarie/tasks/tasks-<slug>.yml` file is: 10 * 11 * tasks: # top-level: a list (required) 12 * - id: T-1 # unique within the file; namespaced to slug/id on load 13 * title: ... # required 14 * status: pending # required; one of VALID_STATUSES 15 * type: task # required; one of VALID_TYPES (4 kinds — see below) 16 * priority: medium # optional; one of VALID_PRIORITIES (default medium) 17 * labels: [bug] # optional LIST of strings; framings live here, not in type 18 * parent: T-0 # optional; an id in the same or another (slug/id) file 19 * deps: [T-0] # optional LIST; bare ids resolve to slug/id, `slug/id` pass through 20 * acceptance_criteria: # optional LIST; the test-ratchet checks it on completed work 21 * - ... 22 * agent: loop-1 # optional; set when claimed (status in_progress) 23 * updated: "2026-06-10" # optional; ISO date — staleness is computed from it 24 * description: | # optional; the free-text body (see note below) 25 * free-text body 26 * 27 * `description` is a RATIFIED optional field (2026-07-11). It was introduced by 28 * the bd→YAML migration to preserve each issue's body losslessly, which reversed 29 * the original no-body design (an earlier retrospective had deflated "missing body" 30 * as by-design) — so it was held for a decision, and the decision is KEEP. The 31 * evidence: every migrated task carried one, and they hold the *why* a title cannot 32 * — an issue's provenance, and the scoping data behind its estimate. Dropping them 33 * would have moved that context to an archive nobody reads while working. 34 * 35 * It stays OPTIONAL and free-text: a terse row is still a legitimate row, and the 36 * ready computation ignores this field entirely (it is prose for humans, never a 37 * computation input). If a future unknown-field warning is added to `validate`, it 38 * must allowlist `description` or every migrated task will flag at once. 39 * 40 * Type model (2026-06-10): 4 exclusive kinds — `task` (work), `doc` (reference), 41 * `decision` (record), `milestone` (marker). bd's other five types (`bug` / 42 * `feature` / `chore` / `story` / `spike`) are FRAMINGS of `task`, carried in 43 * `labels:`; `epic` is `task` + `parent:` nesting (or an `epic` label). The enum 44 * stays exclusive (exactly one type per item — the property labels can't give); 45 * the framings stay additive. A type answers "what kind of thing is this", which 46 * admits exactly one answer; a label answers "how should I think about it", which 47 * admits several. That is the whole argument for collapsing 9 types into 4. 48 * Spike's "closes with findings, not code" semantics travel with the `spike` label. 49 * 50 * `doc` and `decision` do NOT live as rows in `tasks-<slug>.yml`; their 51 * content-home is frontmatter'd markdown under `<TRACKER_DIR>/decisions/<id>.md` 52 * and `<TRACKER_DIR>/docs/<id>.md` (the frontmatter carries the schema fields; the 53 * body is the prose the terse row can't hold). `milestone` rows DO live in 54 * `tasks-*.yml` (markers, no prose). Because the loader only globs `tasks-*.yml`, 55 * decision/doc files are naturally outside the ready computation — a decision 56 * "in force" is never surfaced as workable. (bd got this wrong: its ready-walk is 57 * type-blind and lists decisions as ready work.) 58 * 59 * Ready rule (the only computation that gates work): an item is READY iff 60 * `type === 'task'`, `status === 'pending'`, every dep is `completed`, AND it is not a 61 * CONTAINER — a task with open children, or one carrying the `epic` label, is the sum of 62 * its children and never work in itself. (Getting that wrong is a real bug with a real 63 * symptom: an epic, having no dependencies of its own, is offered as the next thing to 64 * work on, and the actual work — its children — is never surfaced.) Non-task types 65 * (`doc`/`decision`/`milestone`) never appear in ready/blocked/needsAttention — they are 66 * records or markers, not work (a milestone has "no effort, no assignment") — and for the 67 * same reason they never BLOCK one either. Both `deps` (the `blocks` analog) and `parent` 68 * (containment) affect readiness; transitivity is emergent via the status invariant (a 69 * task can't be `completed` until it was itself ready), not a graph walk. 70 * 71 * Atomic-write contract (load-bearing invariant): the substrate assumes a SOLO, 72 * SINGLE-HOST developer with NO concurrent writers to the same `tasks-<slug>.yml`. 73 * Anything that fans work out in parallel must uphold that itself — partition by 74 * file, one owner per slug, so two writers never hold the same `tasks-<slug>.yml` 75 * at once. Writers mutate tasks by editing the YAML directly; there is deliberately 76 * no CRUD helper (the store is a substrate, not a product with an opinion about how 77 * you change it). If the invariant is ever violated (multi-writer), the upgrade is 78 * write-then-rename, not a lock daemon. 79 */ 80 81import { guardedArrayIncludes } from '@voxpelli/typed-utils'; 82 83/** @typedef {'pending' | 'in_progress' | 'completed' | 'failed' | 'cancelled' | 'deferred'} Status */ 84/** @typedef {'task' | 'doc' | 'decision' | 'milestone'} TaskType */ 85/** @typedef {'critical' | 'high' | 'medium' | 'low' | 'backlog'} Priority */ 86 87/** 88 * A task id that has been globalized into the `slug/id` namespace. 89 * 90 * A BRAND, not an alias. `GlobalId` is assignable to `string`, but a plain `string` is 91 * NOT assignable to `GlobalId` — and the only thing that mints one is `nsId()` below. 92 * 93 * It exists because of a real bug. `loadTasks` globalized `id` and `deps` and left `parent` 94 * raw, so `parent` could never equal any `id`. Every type involved was `string`, so nothing 95 * complained. The container rule was the first code to trust `parent`, and it would have 96 * found zero children for every epic, excluded nothing, and passed a green suite. 97 * 98 * `unknown` does NOT catch it — you satisfy `unknown` with `String(x)`, which is the bug. 99 * 100 * **A template-literal type `` `${string}/${string}` `` DOES catch it**, and an earlier 101 * version of this comment claimed otherwise ("only a brand can…"). That was false, and it 102 * was disproved by probe, not by argument: swapping the brand for the template type keeps 103 * tsc at zero errors, keeps type-coverage identical, and still rejects the reverted bug. 104 * The claim is corrected rather than quietly deleted, because a comment that overstates its 105 * own guard is how the next reader gets talked out of a cheaper, better one. 106 * 107 * What the brand adds over the template type is **provenance, not detection**: any 108 * `` `${a}/${b}` `` satisfies the template, whereas a `GlobalId` can only be minted by 109 * `nsId()` below. Given that the whole bug was one caller globalizing by hand and another 110 * forgetting to, "only nsId may produce these" is the property actually worth buying — but 111 * it is a different property than the one that catches the typo, and they should not be 112 * conflated. 113 * 114 * @typedef {string & { readonly __globalId: unique symbol }} GlobalId 115 */ 116 117/** 118 * A task AS WRITTEN TO DISK — the YAML row, before any loader has seen it. 119 * 120 * The distinction from `store.js`'s `Task` is not pedantry; it is the whole point of the 121 * brand. A row on disk carries BARE ids (`T-1`, `parent: T-0`) which only mean anything 122 * relative to their file's slug. A loaded `Task` carries `GlobalId`s (`alpha/T-1`). They 123 * are different types that had both been called `string`, which is precisely how `parent` 124 * came to be globalized in one place and not the other without anyone noticing. 125 * 126 * The migrator PRODUCES rows (it writes YAML). The readers CONSUME tasks. Nothing should 127 * accept both. 128 * 129 * @typedef TaskRow 130 * @property {string} id 131 * @property {string} [title] 132 * @property {Status} status 133 * @property {Priority} [priority] 134 * @property {TaskType} [type] 135 * @property {string[]} [deps] 136 * @property {string} [parent] 137 * @property {string[]} [labels] 138 * @property {string[]} [acceptance_criteria] 139 * @property {string} [agent] 140 * @property {string} [updated] 141 * @property {string} [description] 142 */ 143 144/** 145 * `deferred` is an open item consciously postponed — distinct from `cancelled` 146 * (won't do) and from `pending` (workable now). Like every non-`completed` 147 * status it is not `ready` and does not resolve a dependency: a task depending 148 * on a deferred item surfaces in `needsAttention`, never `ready` (see 149 * computeReady in ready.js — deferred falls through the dep partition's 150 * catch-all `else` into `stalled`). 151 */ 152/** @type {Set<Status>} */ 153export const VALID_STATUSES = new Set(['pending', 'in_progress', 'completed', 'failed', 'cancelled', 'deferred']); 154 155/** @type {Set<TaskType>} */ 156export const VALID_TYPES = new Set(['task', 'doc', 'decision', 'milestone']); 157 158/** @type {Set<Priority>} */ 159export const VALID_PRIORITIES = new Set(['critical', 'high', 'medium', 'low', 'backlog']); 160 161/** Priority sort order for the ready queue (lower = more urgent). */ 162export const PRIORITY_RANK = { critical: 0, high: 1, medium: 2, low: 3, backlog: 4 }; 163 164/** Allowed shape of a task `id` (letters/digits then word chars, `.`, `-`). */ 165export const ID_RE = /^[A-Z0-9][\w.-]*$/i; 166 167/** Always-required fields on every task entry. */ 168export const REQUIRED_FIELDS = ['id', 'title', 'status', 'type']; 169 170/** 171 * The per-repo directory that holds the flat-YAML task store, resolved relative 172 * to the project root. Dotted + product-namespaced (`.diarie/`, the `diarie` 173 * tracker) so it can't collide on the shared dotfile namespace — but committed, 174 * not ephemeral (cf. `.claude/`, `.github/`; NOT `.beads/`, which was gitignored 175 * tool state). Every tool derives the store location from here — never hardcode 176 * the segment. (Renamed from `backlog/` 2026-07-11.) 177 */ 178export const TRACKER_DIR = '.diarie'; 179 180/** 181 * Types whose completion should carry stated acceptance criteria (the 182 * test-ratchet). A completed item of one of these with an empty 183 * `acceptance_criteria` warns — "state done-ness before marking done". 184 * Under the 4-type model only `task` carries work, so only `task` ratchets; 185 * label-conditional refinements (e.g. `spike` → findings) are a future 186 * ADVISORY layer, never hard errors. 187 */ 188/** @type {Set<TaskType>} */ 189export const RATCHET_TYPES = new Set(['task']); 190 191/** 192 * Nullish check (a missing YAML key → `undefined`; an explicit `key: null` → `null`). 193 * 194 * @param {unknown} v 195 * @returns {boolean} 196 */ 197export const isNil = (v) => v === undefined || v === null; 198 199/** 200 * Globalize a reference to a task: a bare id takes its file's slug (`slug/id`); an 201 * id that already carries a slug passes through. Idempotent, so it is always safe 202 * to apply again. 203 * 204 * THE ONE PLACE THIS RULE LIVES. It used to exist twice — `store.js` had it as `nsId`, 205 * `validate.js` had a behaviourally identical private `glob` — and the copies drifted: 206 * `store.js` applied it to `id` and `deps` but not to `parent`, so `loadTasks` handed out 207 * a half-globalized task whose `parent` (`T-0`) could never match any `id` (`slug/T-0`). 208 * `validate.js` was spared only because its copy WAS applied to `parent`. 209 * 210 * Nothing was broken by this at the time — no reader consumed `parent`. It was a trap 211 * laid for the next one. When the container rule arrived — the rule that stops an epic 212 * being offered as ready work — indexing children by parent would have matched nothing, 213 * excluded no epic, changed no behaviour, and passed a green suite. A latent bug that 214 * only fires when someone finally trusts the data is worse than a loud one, because it 215 * fires as a silent success. 216 * 217 * Two implementations of one id rule is what let one of them be incomplete. Keep it here, 218 * keep it single, and import it. 219 * 220 * @param {unknown} ref a task id or reference, bare or already `slug/id` 221 * @param {string} slug the slug of the file the reference was written in 222 * @returns {GlobalId} 223 */ 224export const nsId = (ref, slug) => /** @type {GlobalId} */ ( 225 String(ref).includes('/') ? String(ref) : `${slug}/${ref}` 226); 227 228/** 229 * Membership tests that actually NARROW. 230 * 231 * `Set<Status>.has()` does not narrow — it returns a bare boolean, and it refuses a 232 * `string` argument outright, which is how `commands/ready.js` ended up casting `--filter` 233 * through `any` at the exact boundary the Set was supposed to be guarding. Widening the 234 * Sets to `Set<string>` would lose the vocabulary; `guardedArrayIncludes` keeps both, by 235 * narrowing `unknown` to the Set's own member type. 236 * 237 * The schema is the authority on the vocabulary; it should also be the authority on how 238 * to CHECK it, or every consumer improvises — and one of them improvised with `any`. 239 * 240 * @param {unknown} v 241 * @returns {v is Status} 242 */ 243export const isStatus = (v) => guardedArrayIncludes(VALID_STATUSES, v); 244 245/** 246 * @param {unknown} v 247 * @returns {v is TaskType} 248 */ 249export const isTaskType = (v) => guardedArrayIncludes(VALID_TYPES, v); 250 251/** 252 * @param {unknown} v 253 * @returns {v is Priority} 254 */ 255export const isPriority = (v) => guardedArrayIncludes(VALID_PRIORITIES, v);