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 / ready.js
15 kB 312 lines
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}