@mmerterden/multi-agent-pipeline 20.23.2 → 20.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +7 -6
  3. package/README.tr.md +6 -5
  4. package/docs/architecture.md +3 -3
  5. package/docs/ecosystem.md +5 -5
  6. package/docs/facts.json +3 -3
  7. package/install/catalog-history.json +1 -1
  8. package/install/templates/claude-hooks.json +14 -1
  9. package/manifest.json +75 -54
  10. package/package.json +1 -1
  11. package/pipeline/commands/multi-agent/analysis-jira/SKILL.md +4 -2
  12. package/pipeline/commands/multi-agent/postmortem/SKILL.md +78 -0
  13. package/pipeline/commands/multi-agent/setup/SKILL.md +1 -1
  14. package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
  15. package/pipeline/lib/analysis-jira-write.sh +70 -0
  16. package/pipeline/lib/decisions.mjs +130 -0
  17. package/pipeline/lib/plan-todos.sh +156 -33
  18. package/pipeline/lib/task-graph.mjs +446 -0
  19. package/pipeline/lib/tdd-evidence.mjs +579 -0
  20. package/pipeline/multi-agent-refs/analysis-template-corporate.md +2 -0
  21. package/pipeline/multi-agent-refs/analysis-template.md +4 -2
  22. package/pipeline/multi-agent-refs/cross-cli-contract.md +4 -4
  23. package/pipeline/multi-agent-refs/features/analysis-jira.md +42 -0
  24. package/pipeline/multi-agent-refs/features/autopilot-decisions.md +34 -0
  25. package/pipeline/multi-agent-refs/features/model-fallback.md +4 -2
  26. package/pipeline/multi-agent-refs/features/mutation-probe.md +58 -0
  27. package/pipeline/multi-agent-refs/features/plan-checks.md +67 -0
  28. package/pipeline/multi-agent-refs/features/plan-todos.md +40 -1
  29. package/pipeline/multi-agent-refs/features/rework-escalation.md +49 -0
  30. package/pipeline/multi-agent-refs/features/scope-check.md +1 -0
  31. package/pipeline/multi-agent-refs/help/en.md +1 -0
  32. package/pipeline/multi-agent-refs/help/tr.md +1 -0
  33. package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -3
  34. package/pipeline/multi-agent-refs/phases/phase-2-dev.md +5 -7
  35. package/pipeline/multi-agent-refs/phases/phase-3-review.md +13 -11
  36. package/pipeline/multi-agent-refs/phases/phase-5-report.md +3 -3
  37. package/pipeline/multi-agent-refs/picker-contract.md +3 -2
  38. package/pipeline/multi-agent-refs/response-shape.md +65 -0
  39. package/pipeline/rules/response-shape.md +18 -0
  40. package/pipeline/schemas/agent-state.schema.json +31 -1
  41. package/pipeline/schemas/plan-todos.schema.json +101 -0
  42. package/pipeline/schemas/planning-output.schema.json +46 -0
  43. package/pipeline/schemas/prefs.schema.json +96 -0
  44. package/pipeline/scripts/_jsonl.mjs +151 -0
  45. package/pipeline/scripts/_plan-overlap.mjs +231 -0
  46. package/pipeline/scripts/_redact.mjs +124 -10
  47. package/pipeline/scripts/analysis-story-tree.mjs +153 -8
  48. package/pipeline/scripts/autopilot-publish.mjs +12 -1
  49. package/pipeline/scripts/cost-analyze.mjs +6 -24
  50. package/pipeline/scripts/decisions-log.mjs +118 -0
  51. package/pipeline/scripts/edit-syntax-gate.mjs +240 -0
  52. package/pipeline/scripts/edit-syntax-gate.sh +29 -0
  53. package/pipeline/scripts/find-polluter.mjs +281 -0
  54. package/pipeline/scripts/gate-ledger.mjs +46 -13
  55. package/pipeline/scripts/mutation-probe.mjs +484 -0
  56. package/pipeline/scripts/plan-complexity.mjs +259 -0
  57. package/pipeline/scripts/review-decision-gate.mjs +33 -1
  58. package/pipeline/scripts/rework-escalation.mjs +264 -0
  59. package/pipeline/scripts/test-strength.mjs +5 -3
  60. package/pipeline/scripts/transcript-index.mjs +950 -0
  61. package/pipeline/scripts/validate-planning.mjs +48 -6
  62. package/pipeline/skills/.skill-manifest.json +11 -7
  63. package/pipeline/skills/.skills-index.json +12 -1
  64. package/pipeline/skills/shared/README.md +4 -3
  65. package/pipeline/skills/shared/core/multi-agent-analysis-jira/SKILL.md +4 -2
  66. package/pipeline/skills/shared/core/multi-agent-postmortem/SKILL.md +66 -0
  67. package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +1 -1
  68. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
  69. package/pipeline/skills/shared/external/app-store-optimization/SKILL.md +15 -0
  70. package/pipeline/skills/shared/external/app-store-optimization/evals/evals.json +13 -0
  71. package/pipeline/skills/shared/external/debugging-strategies/SKILL.md +8 -0
  72. package/pipeline/skills/skills-index.md +2 -1
@@ -0,0 +1,446 @@
1
+ /**
2
+ * @file task-graph.mjs - what a plan can work on next, and why the rest waits.
3
+ *
4
+ * A plan's todos form a graph through `deps`. A pending todo is:
5
+ *
6
+ * ready every dependency is completed or skipped
7
+ * waiting a dependency is still pending or in progress
8
+ * blocked it can never start as the plan stands: a dependency failed (or
9
+ * ended in any status other than completed / skipped), a
10
+ * dependency id is not in the plan, or it sits on a cycle
11
+ *
12
+ * A blocked todo names the root cause (`on`), not the nearest dependency, so a
13
+ * chain behind one failed task points at that task.
14
+ *
15
+ * A pending todo carrying an unconfirmed `needsReplan` is waiting with
16
+ * `reason: "needs-replan"` and `because: <the drifted todo>`, even when its
17
+ * dependencies are done: a todo upstream ended off-plan, so the plan this one
18
+ * was written against no longer holds, and someone confirms it before it
19
+ * starts (plan-todos.sh confirm-replan). markDrift sets the flag on every
20
+ * pending transitive dependent of the drifted todo, and only on those.
21
+ *
22
+ * The ready set is ranked by how much pending work each task unblocks
23
+ * (transitive dependents), then by the longest chain of dependents behind it
24
+ * (critical path), then by plan order. Doing the task that frees the most work
25
+ * first keeps parallel workers fed.
26
+ *
27
+ * Zero runtime dependencies (ADR-0004).
28
+ *
29
+ * @module pipeline/lib/task-graph
30
+ */
31
+
32
+ import { readFileSync } from "node:fs";
33
+ import { normalizeFile } from "../scripts/_plan-overlap.mjs";
34
+ import { invokedDirectly } from "./invoked-directly.mjs";
35
+
36
+ const DONE = new Set(["completed", "skipped"]);
37
+ const OPEN = new Set(["pending", "in_progress"]);
38
+
39
+ const depsOf = (todo) => [...new Set((Array.isArray(todo.deps) ? todo.deps : []).map(String))];
40
+
41
+ const awaitsReplan = (todo) =>
42
+ Boolean(
43
+ todo.needsReplan && typeof todo.needsReplan === "object" && !todo.needsReplan.confirmedAt,
44
+ );
45
+
46
+ /**
47
+ * The strongly connected components of the `deps` graph that hold a cycle:
48
+ * every component of two or more todos, and a todo that depends on itself.
49
+ * Tarjan's algorithm, so a todo that closes a second loop through a member
50
+ * already visited is still found. Each component is ordered by a walk along
51
+ * `deps` from its first todo in plan order; components come in plan order.
52
+ *
53
+ * @returns {string[][]}
54
+ */
55
+ function cyclicComponents(order, byId) {
56
+ const index = new Map();
57
+ const low = new Map();
58
+ const stack = [];
59
+ const onStack = new Set();
60
+ const found = [];
61
+ let next = 0;
62
+ const strong = (id) => {
63
+ index.set(id, next);
64
+ low.set(id, next);
65
+ next++;
66
+ stack.push(id);
67
+ onStack.add(id);
68
+ for (const d of depsOf(byId.get(id))) {
69
+ if (!byId.has(d)) continue;
70
+ if (!index.has(d)) {
71
+ strong(d);
72
+ low.set(id, Math.min(low.get(id), low.get(d)));
73
+ } else if (onStack.has(d)) {
74
+ low.set(id, Math.min(low.get(id), index.get(d)));
75
+ }
76
+ }
77
+ if (low.get(id) !== index.get(id)) return;
78
+ const members = new Set();
79
+ let m;
80
+ do {
81
+ m = stack.pop();
82
+ onStack.delete(m);
83
+ members.add(m);
84
+ } while (m !== id);
85
+ if (members.size > 1 || depsOf(byId.get(id)).includes(id)) found.push(members);
86
+ };
87
+ for (const id of order) if (!index.has(id)) strong(id);
88
+
89
+ const position = new Map(order.map((id, i) => [id, i]));
90
+ return found
91
+ .map((members) => {
92
+ const first = [...members].sort((a, b) => position.get(a) - position.get(b))[0];
93
+ const walk = [];
94
+ const seen = new Set();
95
+ const go = (id) => {
96
+ if (seen.has(id)) return;
97
+ seen.add(id);
98
+ walk.push(id);
99
+ for (const d of depsOf(byId.get(id))) if (members.has(d)) go(d);
100
+ };
101
+ go(first);
102
+ return walk;
103
+ })
104
+ .sort((a, b) => position.get(a[0]) - position.get(b[0]));
105
+ }
106
+
107
+ function indexTodos(todos) {
108
+ const list = (Array.isArray(todos) ? todos : []).filter((t) => t && t.id != null);
109
+ return {
110
+ order: list.map((t) => String(t.id)),
111
+ byId: new Map(list.map((t) => [String(t.id), t])),
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Which cycle each todo sits on: todo id -> the first todo of its strongly
117
+ * connected component. Todos on no cycle are absent, including one that only
118
+ * depends on a cycle. Two todos share a value exactly when each reaches the
119
+ * other through `deps`.
120
+ *
121
+ * @param {Array<{id: string, deps?: string[]}>} todos
122
+ * @returns {Map<string, string>}
123
+ */
124
+ export function cyclicComponentOf(todos) {
125
+ const { order, byId } = indexTodos(todos);
126
+ const of = new Map();
127
+ for (const c of cyclicComponents(order, byId)) for (const id of c) of.set(id, c[0]);
128
+ return of;
129
+ }
130
+
131
+ /**
132
+ * @param {Array<{id: string, task?: string, deps?: string[], status?: string}>} todos
133
+ * @returns {{
134
+ * ready: Array<{id: string, task: string|null, unblocks: number, criticalPath: number}>,
135
+ * waiting: Array<{id: string, on: string[], reason?: "needs-replan", because?: string}>,
136
+ * blocked: Array<{id: string, reason: "failed-dependency"|"missing-dependency"|"cycle", on: string}>,
137
+ * cycles: string[][],
138
+ * }} `cycles`: one entry per strongly connected component on a cycle, every member listed.
139
+ */
140
+ export function analyzeGraph(todos) {
141
+ const { order, byId } = indexTodos(todos);
142
+ const cycles = cyclicComponents(order, byId);
143
+ const onCycle = new Map();
144
+ for (const c of cycles) for (const id of c) onCycle.set(id, c[0]);
145
+
146
+ // Resolved state per todo, memoised. A cycle member resolves to blocked
147
+ // before its dependencies are read, so the recursion always ends.
148
+ const state = new Map();
149
+ const resolve = (id) => {
150
+ if (state.has(id)) return state.get(id);
151
+ const todo = byId.get(id);
152
+ const status = String(todo.status || "pending");
153
+ let s;
154
+ if (DONE.has(status)) s = { kind: "done" };
155
+ else if (status === "in_progress") s = { kind: "active" };
156
+ else if (!OPEN.has(status)) s = { kind: "failed" };
157
+ else if (onCycle.has(id)) s = { kind: "blocked", reason: "cycle", on: onCycle.get(id) };
158
+ else {
159
+ state.set(id, { kind: "resolving" });
160
+ s = null;
161
+ const pendingOn = [];
162
+ for (const d of depsOf(todo)) {
163
+ if (!byId.has(d)) {
164
+ s = { kind: "blocked", reason: "missing-dependency", on: d };
165
+ break;
166
+ }
167
+ const ds = resolve(d);
168
+ if (ds.kind === "failed") {
169
+ s = { kind: "blocked", reason: "failed-dependency", on: d };
170
+ break;
171
+ }
172
+ if (ds.kind === "blocked") {
173
+ s = { kind: "blocked", reason: ds.reason, on: ds.on };
174
+ break;
175
+ }
176
+ if (ds.kind !== "done") pendingOn.push(d);
177
+ }
178
+ if (!s && awaitsReplan(todo))
179
+ s = {
180
+ kind: "waiting",
181
+ on: pendingOn,
182
+ reason: "needs-replan",
183
+ because: String(todo.needsReplan.because ?? ""),
184
+ };
185
+ if (!s) s = pendingOn.length ? { kind: "waiting", on: pendingOn } : { kind: "ready" };
186
+ }
187
+ state.set(id, s);
188
+ return s;
189
+ };
190
+ for (const id of order) resolve(id);
191
+
192
+ // Reverse edges among open todos, for the ranking.
193
+ const dependents = new Map(order.map((id) => [id, []]));
194
+ for (const id of order) {
195
+ if (!OPEN.has(String(byId.get(id).status || "pending"))) continue;
196
+ for (const d of depsOf(byId.get(id))) if (dependents.has(d)) dependents.get(d).push(id);
197
+ }
198
+ const reach = (id) => {
199
+ const seen = new Set();
200
+ const stack = [...dependents.get(id)];
201
+ while (stack.length) {
202
+ const n = stack.pop();
203
+ if (seen.has(n) || n === id) continue;
204
+ seen.add(n);
205
+ stack.push(...dependents.get(n));
206
+ }
207
+ return seen.size;
208
+ };
209
+ const depthMemo = new Map();
210
+ const depth = (id, trail = new Set()) => {
211
+ if (depthMemo.has(id)) return depthMemo.get(id);
212
+ if (trail.has(id)) return 0;
213
+ trail.add(id);
214
+ let best = 0;
215
+ for (const n of dependents.get(id)) best = Math.max(best, 1 + depth(n, trail));
216
+ trail.delete(id);
217
+ depthMemo.set(id, best);
218
+ return best;
219
+ };
220
+
221
+ const ready = [];
222
+ const waiting = [];
223
+ const blocked = [];
224
+ order.forEach((id, index) => {
225
+ if (String(byId.get(id).status || "pending") !== "pending") return;
226
+ const s = state.get(id);
227
+ if (s.kind === "ready")
228
+ ready.push({
229
+ id,
230
+ task: byId.get(id).task ?? null,
231
+ unblocks: reach(id),
232
+ criticalPath: depth(id),
233
+ index,
234
+ });
235
+ else if (s.kind === "waiting")
236
+ waiting.push(
237
+ s.reason ? { id, on: s.on, reason: s.reason, because: s.because } : { id, on: s.on },
238
+ );
239
+ else if (s.kind === "blocked") blocked.push({ id, reason: s.reason, on: s.on });
240
+ });
241
+ ready.sort(
242
+ (a, b) => b.unblocks - a.unblocks || b.criticalPath - a.criticalPath || a.index - b.index,
243
+ );
244
+ return { ready: ready.map(({ index: _index, ...r }) => r), waiting, blocked, cycles };
245
+ }
246
+
247
+ /**
248
+ * Every todo that depends on `id`, directly or through other todos, in plan
249
+ * order. `id` itself is never listed, also when it sits on a cycle.
250
+ *
251
+ * @param {Array<{id: string, deps?: string[]}>} todos
252
+ * @param {string} id
253
+ * @returns {string[]}
254
+ */
255
+ export function dependentsOf(todos, id) {
256
+ const list = (Array.isArray(todos) ? todos : []).filter((t) => t && t.id != null);
257
+ const start = String(id);
258
+ const reverse = new Map(list.map((t) => [String(t.id), []]));
259
+ for (const t of list)
260
+ for (const d of depsOf(t)) if (reverse.has(d)) reverse.get(d).push(String(t.id));
261
+ const seen = new Set();
262
+ const stack = [...(reverse.get(start) || [])];
263
+ while (stack.length) {
264
+ const n = stack.pop();
265
+ if (n === start || seen.has(n)) continue;
266
+ seen.add(n);
267
+ stack.push(...reverse.get(n));
268
+ }
269
+ return list.map((t) => String(t.id)).filter((x) => seen.has(x));
270
+ }
271
+
272
+ const baseName = (path) => path.slice(path.lastIndexOf("/") + 1);
273
+ const stemOf = (path) => {
274
+ const name = baseName(path);
275
+ const dot = name.indexOf(".", 1);
276
+ return dot > 0 ? name.slice(0, dot) : name;
277
+ };
278
+
279
+ /**
280
+ * The stem of the source file a test file covers, or "" when `path` is not a
281
+ * test file by name: `a.test.ts`, `a.spec.ts`, `util_test.go`, `FooTests.swift`,
282
+ * `BarTest.kt` and `test_calc.py` cover `a`, `a`, `util`, `Foo`, `Bar`, `calc`.
283
+ */
284
+ function testedStem(path) {
285
+ const name = baseName(path);
286
+ const lastDot = name.lastIndexOf(".");
287
+ const bare = lastDot > 0 ? name.slice(0, lastDot) : name;
288
+ const m =
289
+ /^(.+)[._-](?:test|spec)$/.exec(bare) || /^(.+?)Tests?$/.exec(bare) || /^test_(.+)$/.exec(bare);
290
+ return m ? m[1] : "";
291
+ }
292
+
293
+ /**
294
+ * Record that todo `id` ended off-plan and flag what was planned on top of it.
295
+ *
296
+ * Drift is what the scope self-check reports for one todo
297
+ * (features/scope-check.md): files it changed that its plan entry does not
298
+ * list, or planned items it did not do. An explicit `reason` counts as drift
299
+ * on its own. Planned files are `files` plus `creates`, compared after
300
+ * normalizeFile on both sides; a changed test file whose name covers the stem
301
+ * of a planned file (testedStem) is planned too. A todo with neither list
302
+ * cannot be judged on files, so only `notDone` and `reason` apply to it.
303
+ *
304
+ * On drift the todo gets `drift: {outside, notDone, at}` and every pending
305
+ * transitive dependent gets `needsReplan: {because, at, reason}`, replacing an
306
+ * earlier flag and its confirmation. Started or finished dependents are left
307
+ * alone: their plan is already spent. Pure: returns a new todos array.
308
+ *
309
+ * @param {Array<object>} todos
310
+ * @param {string} id
311
+ * @param {{changedFiles?: string[], notDone?: number, reason?: string, at?: string}} signal
312
+ * @returns {{drifted: boolean, outside: string[], notDone: number, flagged: string[], todos: object[]}}
313
+ */
314
+ export function markDrift(todos, id, signal = {}) {
315
+ const list = Array.isArray(todos) ? todos : [];
316
+ const self = list.find((t) => t && String(t.id) === String(id));
317
+ if (!self) throw new Error(`todo '${id}' is not in the plan`);
318
+ const at = signal.at || new Date().toISOString();
319
+ const listed = [self.files, self.creates].filter(Array.isArray);
320
+ const planned = listed.length
321
+ ? new Set(listed.flat().map(String).filter(Boolean).map(normalizeFile))
322
+ : null;
323
+ const plannedStems = new Set([...(planned || [])].map(stemOf));
324
+ const isPlanned = (f) => planned.has(f) || plannedStems.has(testedStem(f) || "\0");
325
+ const changed = [
326
+ ...new Set((signal.changedFiles || []).map(String).filter(Boolean).map(normalizeFile)),
327
+ ];
328
+ const outside = planned ? changed.filter((f) => !isPlanned(f)) : [];
329
+ const notDone = Number.isInteger(signal.notDone) && signal.notDone > 0 ? signal.notDone : 0;
330
+ const reason =
331
+ signal.reason ||
332
+ [
333
+ outside.length
334
+ ? `${outside.length} file${outside.length === 1 ? "" : "s"} outside the plan`
335
+ : "",
336
+ notDone ? `${notDone} planned item${notDone === 1 ? "" : "s"} not done` : "",
337
+ ]
338
+ .filter(Boolean)
339
+ .join(", ");
340
+ if (!reason) return { drifted: false, outside, notDone, flagged: [], todos: list };
341
+
342
+ const pending = new Set(
343
+ list.filter((t) => String(t.status || "pending") === "pending").map((t) => String(t.id)),
344
+ );
345
+ const flagged = dependentsOf(list, id).filter((d) => pending.has(d));
346
+ const flag = new Set(flagged);
347
+ const because = String(id);
348
+ const next = list.map((t) => {
349
+ if (String(t.id) === because) return { ...t, drift: { outside, notDone, at } };
350
+ if (flag.has(String(t.id))) return { ...t, needsReplan: { because, at, reason } };
351
+ return t;
352
+ });
353
+ return { drifted: true, outside, notDone, flagged, todos: next };
354
+ }
355
+
356
+ /** The todo to start next, or null when nothing is ready. */
357
+ export function nextReady(todos) {
358
+ return analyzeGraph(todos).ready[0] ?? null;
359
+ }
360
+
361
+ /**
362
+ * CLI: `task-graph.mjs next <agent-state.json> [--task-id <id>]` prints
363
+ * `id<TAB>task` for the todo to start next, or nothing. When nothing is ready it
364
+ * says on stderr which todos are blocked and by what, and which wait for a
365
+ * re-plan confirmation, so an empty answer is never silent; with --task-id the
366
+ * confirm-replan hint names that task, ready to run.
367
+ * `task-graph.mjs analyze <agent-state.json>` prints the whole analysis as JSON.
368
+ * `task-graph.mjs drift <agent-state.json> <todo-id> [--files <path|->]
369
+ * [--not-done N] [--reason <text>]` prints markDrift's result as JSON, the
370
+ * updated todos included; it never writes the state file (plan-todos.sh does).
371
+ */
372
+ function readTodos(file) {
373
+ return JSON.parse(readFileSync(file, "utf8"))?.plan?.todos ?? [];
374
+ }
375
+
376
+ function driftMain(file, id, rest) {
377
+ const opts = {};
378
+ for (let i = 0; i < rest.length; i++) {
379
+ const a = rest[i];
380
+ if (a === "--files") opts.files = rest[++i];
381
+ else if (a === "--not-done") opts.notDone = Number(rest[++i]);
382
+ else if (a === "--reason") opts.reason = rest[++i];
383
+ else throw Object.assign(new Error(`unknown drift option '${a}'`), { usage: true });
384
+ }
385
+ if (opts.notDone !== undefined && !(Number.isInteger(opts.notDone) && opts.notDone >= 0))
386
+ throw Object.assign(new Error("--not-done takes a whole number"), { usage: true });
387
+ const changedFiles = opts.files
388
+ ? readFileSync(opts.files === "-" ? 0 : opts.files, "utf8")
389
+ .split("\n")
390
+ .map((l) => l.trim())
391
+ .filter(Boolean)
392
+ : [];
393
+ return markDrift(readTodos(file), id, {
394
+ changedFiles,
395
+ notDone: opts.notDone,
396
+ reason: opts.reason,
397
+ });
398
+ }
399
+
400
+ function main(argv) {
401
+ const [cmd, file, ...rest] = argv;
402
+ if (!["next", "analyze", "drift"].includes(cmd) || !file || (cmd === "drift" && !rest[0])) {
403
+ process.stderr.write(
404
+ "usage: task-graph.mjs next <agent-state.json> [--task-id <id>] | analyze <agent-state.json> | drift <agent-state.json> <todo-id> [--files <path|->] [--not-done N] [--reason <text>]\n",
405
+ );
406
+ process.exitCode = 64;
407
+ return;
408
+ }
409
+ if (cmd === "drift") {
410
+ try {
411
+ process.stdout.write(`${JSON.stringify(driftMain(file, rest[0], rest.slice(1)))}\n`);
412
+ } catch (e) {
413
+ process.stderr.write(`task-graph: ${e.message}\n`);
414
+ process.exitCode = e.usage ? 64 : 1;
415
+ }
416
+ return;
417
+ }
418
+ let todos;
419
+ try {
420
+ todos = readTodos(file);
421
+ } catch (e) {
422
+ process.stderr.write(`task-graph: ${e.message}\n`);
423
+ process.exitCode = 3;
424
+ return;
425
+ }
426
+ const g = analyzeGraph(todos);
427
+ if (cmd === "analyze") {
428
+ process.stdout.write(`${JSON.stringify(g)}\n`);
429
+ return;
430
+ }
431
+ const taskId = rest[0] === "--task-id" && rest[1] ? rest[1] : "<task-id>";
432
+ const top = g.ready[0];
433
+ if (top) {
434
+ process.stdout.write(`${top.id}\t${top.task ?? ""}\n`);
435
+ return;
436
+ }
437
+ for (const b of g.blocked)
438
+ process.stderr.write(`plan-todos: ${b.id} is blocked (${b.reason}: ${b.on})\n`);
439
+ for (const w of g.waiting)
440
+ if (w.reason === "needs-replan")
441
+ process.stderr.write(
442
+ `plan-todos: ${w.id} waits for re-plan confirmation (${w.because} drifted): plan-todos.sh confirm-replan ${taskId} ${w.id}\n`,
443
+ );
444
+ }
445
+
446
+ if (invokedDirectly(import.meta.url)) main(process.argv.slice(2));