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 * ready.js — the ready/blocked partition and summary stats.
3 *
4 * Pure computation over a loaded task list. Finding and loading the store is
5 * `store.js`'s job; this file never touches the filesystem except in the
6 * temporary CLI entry at the bottom.
7 *
8 * Ready rule: a task is READY iff it is `type: task`, `status: pending`, has no open
9 * dependency, AND is not a container (no open children, no `epic` label). BLOCKED if a
10 * dep or a child is still pending/in_progress. NEEDS_ATTENTION if a dep or child is
11 * failed/cancelled/deferred/missing, if a `parent:` dangles, or if an `epic` has nothing
12 * open left inside it. Non-task types (`doc`/`decision`/`milestone`) are records or
13 * markers, never work — they never appear in any partition, and never block one.
14 * (The graph is recomputed, never enforced — see validate.js for the integrity gate.)
15 */
16
17import {
18 PRIORITY_RANK, VALID_PRIORITIES, VALID_STATUSES, VALID_TYPES,
19} from './schema.js';
20
21/** @typedef {import('./store.js').Task} Task */
22
23/**
24 * Build a zero-filled tally keyed by a set of allowed values. Null-prototype, so a junk
25 * key like `toString` reads back `undefined` instead of climbing to Object.prototype and
26 * returning a function — see `bump()`, which relies on exactly that.
27 *
28 * @param {Set<string>} keys
29 * @returns {Record<string, number>}
30 */
31const tally = (keys) => {
32 /** @type {Record<string, number>} */
33 const o = Object.create(null);
34 for (const k of keys) o[k] = 0;
35 return o;
36};
37
38/**
39 * Increment a tally slot, but only one that the tally already declared.
40 *
41 * The lookup IS the guard: a `tally()` object is null-prototype, so an unexpected key
42 * — `toString`, or a status the schema has never heard of — reads back `undefined`
43 * rather than climbing to `Object.prototype` and returning a function. An undeclared
44 * key is therefore left uncounted instead of being invented, which is the behaviour we
45 * want for malformed input: untallied, never misclassified.
46 *
47 * @param {Record<string, number>} counts
48 * @param {string} [key]
49 * @returns {void}
50 */
51const bump = (counts, key) => {
52 if (key === undefined) return;
53 const n = counts[key];
54 if (n !== undefined) counts[key] = n + 1;
55};
56
57/**
58 * Index a parent's WORKABLE children by parent id, split exactly the way deps are.
59 *
60 * The child taxonomy deliberately MIRRORS the dep taxonomy below — same three buckets,
61 * same statuses — because a container and a prerequisite fail in the same ways and a
62 * reader should not have to learn two vocabularies:
63 *
64 * pending / in_progress -> ACTIVE : real remaining work; the parent is blocked
65 * completed -> done : contains nothing; ignore
66 * failed / cancelled / deferred -> STALLED : needs a human, not a blocker
67 *
68 * The first cut got this wrong in a way worth recording. "Open child" was defined as
69 * `status !== 'completed'` — the acceptance criterion's own words — which quietly swept
70 * the terminal statuses into ACTIVE. A parent whose only child was `cancelled` then sat
71 * in `blocked` forever, unworkable, with nothing to point at and no way out: the very
72 * "offered nothing, said nothing" failure this module exists to prevent, reintroduced by
73 * the fix for it. Deps had always got this right; the children just had to be told.
74 *
75 * Only `type: task` children count. `decision` records are safe by construction (they
76 * live in `.diarie/decisions/`, outside `loadTasks`'s glob) — but `milestone` is NOT:
77 * it lives in `tasks-*.yml`, it has "no effort, no assignment", so it is never worked and
78 * therefore never `completed`. A milestone filed under an epic would have blocked it
79 * until the heat death of the repository.
80 *
81 * @param {Task[]} tasks
82 * @returns {Map<string, { active: string[], stalled: string[] }>}
83 */
84function childrenByParent (tasks) {
85 /** @type {Map<string, { active: string[], stalled: string[] }>} */
86 const kids = new Map();
87 for (const t of tasks) {
88 if (!t.parent || t.type !== 'task') continue;
89 if (t.status === 'completed') continue;
90 let entry = kids.get(t.parent);
91 if (!entry) { entry = { active: [], stalled: [] }; kids.set(t.parent, entry); }
92 if (t.status === 'pending' || t.status === 'in_progress') entry.active.push(t.id);
93 else entry.stalled.push(`${t.id} (${t.status})`);
94 }
95 return kids;
96}
97
98/**
99 * Compute ready / blocked / needs-attention partitions over a flat task list.
100 *
101 * A CONTAINER is never ready — you work its children, not the container. Two
102 * predicates, and they are independent on purpose:
103 *
104 * structural — has at least one open child → blocked, by those children
105 * declarative — carries the `epic` label → never workable, children or not
106 *
107 * bd had an `epic` TYPE, so its ready-walk excluded containers structurally. This schema
108 * collapsed epic to `task` + `parent:` (the better model — see the type model in
109 * `./schema.js`), which left `computeReady` gating on type alone. The result was a real
110 * bug: an epic container, having no dependencies of its own and being `type: task` like
111 * anything else, sailed through every check and was offered as the single next thing to
112 * work on — while the children that held the actual work stayed invisible behind it.
113 * Collapsing the type is what removed the structural exclusion; these two predicates are
114 * what put it back.
115 *
116 * `blockers` and `children` are separate fields because they mean different things:
117 * a dep must FINISH FIRST, a child is CONTAINED. Collapsing them into one array would
118 * make "blocked by" ambiguous exactly where it needs to be precise.
119 *
120 * @param {Task[]} tasks
121 * @returns {{ ready: Task[], blocked: Array<Task & { blockers: string[], children?: string[], attention?: string[] }>, needsAttention: Array<Task & { reason: string }> }}
122 */
123export function computeReady (tasks) {
124 const byId = new Map(tasks.map(t => [t.id, t]));
125 const kids = childrenByParent(tasks);
126 /** @type {Task[]} */
127 const ready = [];
128 /** @type {Array<Task & { blockers: string[], children?: string[], attention?: string[] }>} */
129 const blocked = [];
130 /** @type {Array<Task & { reason: string }>} */
131 const needsAttention = [];
132
133 for (const task of tasks) {
134 if (task.status !== 'pending') continue;
135
136 // A row with NO valid type is MALFORMED, not merely non-workable, and the difference
137 // matters. `type` is required (schema.js REQUIRED_FIELDS), so `undefined` here means the
138 // YAML said nothing or said something the schema does not know — and the loader has
139 // already dropped it. Excluding it *silently*, the way we exclude a legitimate
140 // `doc`/`decision`/`milestone`, makes it vanish from every partition while still counting
141 // toward `total`: present in the sum, absent from every answer. Worse, its parent then
142 // sees no open child and gets told to close an epic whose work has not started.
143 //
144 // So: surface it. A `type: bug` typo (a bd fossil — framings live in `labels` now) is an
145 // ordinary slip, and the tracker must say so rather than quietly rewrite the backlog.
146 if (task.type === undefined) {
147 needsAttention.push({ ...task, reason: 'missing or invalid `type` — it belongs in no partition (run `diarie validate`)' });
148 continue;
149 }
150
151 // Only `task`-type items are workable — `doc`/`decision`/`milestone` are records or
152 // markers (a milestone has "no effort, no assignment"). These ARE excluded silently, and
153 // correctly: they are well-formed things that simply are not work.
154 if (task.type !== 'task') continue;
155
156 /** @type {string[]} */ const active = []; // deps still pending/in_progress → blocks
157 /** @type {string[]} */ const stalled = []; // missing, or terminal-but-not-completed
158
159 for (const depId of task.deps ?? []) {
160 const dep = byId.get(depId);
161 if (!dep) stalled.push(`${depId} (missing)`);
162 else if (dep.status === 'completed') continue;
163 else if (dep.status === 'pending' || dep.status === 'in_progress') active.push(depId);
164 else stalled.push(`${depId} (${dep.status})`);
165 }
166
167 // A `parent:` that resolves to nothing gets the same treatment as a dangling dep —
168 // and it is the tripwire for this module's worst bug. Ids here are globalized
169 // (`slug/id`) and `parent` is globalized by the same rule, in the same place. Should
170 // those two ever drift apart again — as they did, silently, for the tracker's whole
171 // life — then every parent stops matching every id, every epic finds zero children,
172 // and every container quietly becomes workable again. No unit test can see that (it
173 // writes both ids by hand, in one consistent space). This makes it scream instead:
174 // one typo surfaces one row, an id-space divergence surfaces EVERY row at once.
175 if (task.parent && !byId.has(task.parent)) stalled.push(`parent ${task.parent} (missing)`);
176
177 // Children fold into the SAME two buckets as deps, deliberately. An abandoned child
178 // is not a blocker — it is a question for a human, exactly as an abandoned dep is.
179 const { active: kidsActive = [], stalled: kidsStalled = [] } = kids.get(task.id) ?? {};
180 const isEpic = task.labels?.includes('epic') ?? false;
181
182 const attention = [...stalled, ...kidsStalled];
183
184 if (active.length || kidsActive.length) {
185 // `attention` rides ALONG on a blocked row — it is not an either/or. A row can be
186 // waiting on a live dep AND carry a dangling parent or a cancelled dep at the same
187 // time, and the old code dropped the second on the floor: one active blocker was
188 // enough to erase every stalled one.
189 //
190 // That silently weakened the dangling-parent tripwire this module leans on. Its claim
191 // is that an id-space divergence surfaces EVERY row at once — but a row with any live
192 // dep would have been swallowed into `blocked` and said nothing. "Every row" has to
193 // mean every row. A task can be blocked AND broken.
194 blocked.push({
195 ...task,
196 blockers: active,
197 ...(kidsActive.length ? { children: kidsActive } : {}),
198 ...(attention.length ? { attention } : {}),
199 });
200 } else if (attention.length) {
201 needsAttention.push({ ...task, reason: attention.join(', ') });
202 } else if (isEpic) {
203 // Labelled a container, but nothing left inside it: either the work is done and
204 // the epic wants closing, or nobody ever filed its children. Both are real states
205 // a human should see — silently dropping it would hide an empty epic forever.
206 needsAttention.push({ ...task, reason: 'epic label, no open children — close it or add children' });
207 } else {
208 // A parent whose children are ALL completed is ready again on its own merits:
209 // the container may still carry integration work. Only OPEN children block.
210 ready.push(task);
211 }
212 }
213
214 ready.sort((a, b) => (PRIORITY_RANK[a.priority ?? 'medium'] ?? 2) - (PRIORITY_RANK[b.priority ?? 'medium'] ?? 2));
215 return { ready, blocked, needsAttention };
216}
217
218/**
219 * Summary counts (the files-native `bd stats`).
220 *
221 * @param {Task[]} tasks
222 * @param {number} [staleDays]
223 * @param {Date} [now]
224 * @returns {{ total: number, ready: number, blocked: number, stale: string[], malformedDates: string[], byStatus: Record<string, number>, byPriority: Record<string, number>, byType: Record<string, number> }}
225 */
226export function computeStats (tasks, staleDays = 30, now = new Date()) {
227 const byStatus = tally(VALID_STATUSES);
228 const byPriority = tally(VALID_PRIORITIES);
229 const byType = tally(VALID_TYPES);
230 /** @type {string[]} */ const stale = [];
231 /** @type {string[]} */ const malformedDates = [];
232 const cutoff = now.getTime() - staleDays * 86_400_000;
233
234 for (const t of tasks) {
235 bump(byStatus, t.status);
236 bump(byPriority, t.priority ?? 'medium');
237 // No `?? 'task'` default — mirrors computeReady's deliberate non-assumption for
238 // malformed (type-less) input: an untallied key is left alone, so a type-less item
239 // is simply not counted, never misclassified.
240 bump(byType, t.type);
241 if (t.status === 'in_progress' && t.updated) {
242 const u = Date.parse(t.updated);
243 if (Number.isNaN(u)) malformedDates.push(t.id);
244 else if (u < cutoff) stale.push(t.id);
245 }
246 }
247
248 const { blocked, ready } = computeReady(tasks);
249 return { total: tasks.length, ready: ready.length, blocked: blocked.length, stale, malformedDates, byStatus, byPriority, byType };
250}
251
252/**
253 * Render one task as a human-readable line.
254 *
255 * @param {Task} t
256 * @returns {string}
257 */
258export const line = (t) => ` ${t.id} [${t.priority ?? 'medium'}] ${t.title ?? ''}`.trimEnd();
259
260/**
261 * Render one blocked task, saying WHY — which is the whole reason the view exists.
262 *
263 * A container and a dep-blocked task are both "blocked", but not for the same reason,
264 * and the reader has to be able to tell: one is waiting on prerequisites, the other IS
265 * the work its children are doing. A task can be both, so these are appended, not
266 * branched.
267 *
268 * The third clause is the one that earns its keep: a blocked row can ALSO be broken,
269 * and a live blocker must not be allowed to hide a dead one.
270 *
271 * @param {Task & { blockers: string[], children?: string[], attention?: string[] }} t
272 * @returns {string}
273 */
274export function blockedLine (t) {
275 const why = [
276 ...(t.blockers.length ? [`← blocked by ${t.blockers.join(', ')}`] : []),
277 ...(t.children?.length ? [`← contains ${t.children.length} open: ${t.children.join(', ')}`] : []),
278 ...(t.attention?.length ? [`! also needs attention: ${t.attention.join(', ')}`] : []),
279 ];
280 return `${line(t)} ${why.join(' ')}`;
281}
282
283/**
284 * Render one malformed task and the reason it cannot be worked.
285 *
286 * @param {Task & { reason: string }} t
287 * @returns {string}
288 */
289export const attentionLine = (t) => `${line(t)} ! needs attention: ${t.reason}`;
290
291/**
292 * Render a tally object as `key=n` pairs, omitting zeros.
293 *
294 * @param {Record<string, number>} obj
295 * @returns {string}
296 */
297const tallyRow = (obj) => Object.entries(obj).filter(([, n]) => n).map(([k, n]) => `${k}=${n}`).join(' ');
298
299/**
300 * @param {ReturnType<typeof computeStats>} s
301 * @returns {string}
302 */
303export function formatStats (s) {
304 const lines = [
305 `total ${s.total} ready ${s.ready} blocked ${s.blocked} stale ${s.stale.length}`,
306 `status: ${tallyRow(s.byStatus)}`,
307 `priority: ${tallyRow(s.byPriority)}`,
308 `type: ${tallyRow(s.byType)}`,
309 ];
310 if (s.malformedDates.length) lines.push(`! malformed updated dates: ${s.malformedDates.join(' ')}`);
311 return lines.join('\n') + '\n';
312}