A flat-YAML task tracker that is just files. No daemon, no database, no git hooks.
diarie.dev
issue-tracker
cli
nodejs
agent-friendly
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);