@dtlabs/ai-toolkit 0.13.0 → 0.14.1

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.
@@ -0,0 +1,725 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+
5
+ // Plan parser/scheduler module (FTR-018, INFRA-TASK-BE-03 skeleton).
6
+ //
7
+ // Real implementation: lossless MD task-detail parsing (US-01-TASK-BE-01,
8
+ // DONE — see parseMarkdown below), CSV parsing and phase-task mapping
9
+ // (US-01-TASK-BE-02, DONE — see parseCSV/mapPhases below), DAG validation
10
+ // and cycle detection (US-01-TASK-BE-03, DONE — see validateDAG below),
11
+ // content digest and immutable plan snapshot (US-01-TASK-BE-04, DONE — see
12
+ // createPlanSnapshot below), and the stable Kahn topological scheduler
13
+ // (US-01-TASK-BE-05, DONE — see computeReadyQueue below). See
14
+ // FTR-018-Tech-Spec.md section 4.
15
+
16
+ function _parseError(message) {
17
+ const err = new Error(message);
18
+ err.code = 'PLAN_PARSE_ERROR';
19
+ return err;
20
+ }
21
+
22
+ // ── parseMarkdown (US-01-TASK-BE-01) ─────────────────────────────────────────
23
+ //
24
+ // Extracts the authoritative "## Task Details" section rendered by
25
+ // scripts/wb-render.js (see renderMarkdown / taskAnchor / fencedCommandBlock
26
+ // there) into an ordered array of plain task-detail objects. Every one of the
27
+ // 14 rendered fields is preserved: id, title, outcome, domain, agentType,
28
+ // dependsOn, acceptanceCriteria, agentMinutes, tokens, outputCount,
29
+ // groupingRationale, and commit.{type,scope,subject}. Verification commands
30
+ // are preserved losslessly (byte-for-byte, including embedded pipes/`||`/
31
+ // backticks) by locating fenced blocks via their own opening delimiter
32
+ // length rather than splitting on a fixed ``` token — this mirrors
33
+ // fencedCommandBlock's dynamic fence-length strategy on the render side.
34
+ //
35
+ // This function performs no cross-document (CSV) reconciliation and no DAG
36
+ // validation — those are US-01-TASK-BE-02 and US-01-TASK-BE-03. It only
37
+ // fails on malformed/incomplete Markdown (missing section, missing task
38
+ // anchor body, missing one of the 14 fields, task-id/anchor mismatch).
39
+
40
+ const _FIELD_LABELS = [
41
+ 'Task ID',
42
+ 'Title',
43
+ 'Outcome',
44
+ 'Domain',
45
+ 'Agent type',
46
+ 'Dependencies',
47
+ 'Acceptance criteria',
48
+ 'Estimate — agent minutes',
49
+ 'Estimate — tokens',
50
+ 'Output count',
51
+ 'Grouping rationale',
52
+ 'Commit type',
53
+ 'Commit scope',
54
+ 'Commit subject',
55
+ ];
56
+
57
+ const _DASH = '—'; // '—' — the placeholder wb-render.js emits for absent values
58
+
59
+ const _FIELD_LINE_RE = /^- \*\*(.+?):\*\*[ \t]?(.*)$/gm;
60
+ const _FENCE_RE = /^(`{3,})\r?\n([\s\S]*?)\r?\n\1[ \t]*$/gm;
61
+ const _NO_VERIF_RE = /_No verification commands\._/;
62
+ const _TASK_ANCHOR_RE = /<a id="task-[^"]*"><\/a>\n### ([^\n]+)\n([\s\S]*?)(?=\n<a id="task-|$)/g;
63
+
64
+ function _listOrEmpty(value) {
65
+ if (value === _DASH) return [];
66
+ return value.split(',').map(function (s) { return s.trim(); }).filter(Boolean);
67
+ }
68
+
69
+ function _numberOrNull(value) {
70
+ if (value === _DASH) return null;
71
+ const n = Number(value);
72
+ if (Number.isNaN(n)) {
73
+ throw _parseError('parseMarkdown: expected a numeric value, got ' + JSON.stringify(value));
74
+ }
75
+ return n;
76
+ }
77
+
78
+ function _stringOrNull(value) {
79
+ return value === _DASH ? null : value;
80
+ }
81
+
82
+ function _extractTaskDetailsSection(markdown) {
83
+ const headingRe = /^## .*$/gm;
84
+ const headings = [];
85
+ let hm;
86
+ while ((hm = headingRe.exec(markdown))) {
87
+ headings.push({ index: hm.index, text: hm[0].trim() });
88
+ }
89
+ const start = headings.find(function (h) { return h.text === '## Task Details'; });
90
+ if (!start) {
91
+ throw _parseError('parseMarkdown: "## Task Details" section not found');
92
+ }
93
+ const startIdx = start.index + start.text.length;
94
+ const after = headings.filter(function (h) { return h.index > start.index; });
95
+ const endIdx = after.length > 0 ? after[0].index : markdown.length;
96
+ return markdown.slice(startIdx, endIdx);
97
+ }
98
+
99
+ function _parseVerificationCommands(text) {
100
+ if (_NO_VERIF_RE.test(text)) return [];
101
+ const cmds = [];
102
+ let m;
103
+ _FENCE_RE.lastIndex = 0;
104
+ while ((m = _FENCE_RE.exec(text))) {
105
+ cmds.push(m[2]);
106
+ }
107
+ return cmds;
108
+ }
109
+
110
+ function _parseTaskFields(anchorId, body) {
111
+ const verifMarker = '**Verification commands:**';
112
+ const verifIdx = body.indexOf(verifMarker);
113
+ const fieldsPart = verifIdx === -1 ? body : body.slice(0, verifIdx);
114
+ const verifPart = verifIdx === -1 ? '' : body.slice(verifIdx + verifMarker.length);
115
+
116
+ const fields = {};
117
+ let fm;
118
+ _FIELD_LINE_RE.lastIndex = 0;
119
+ while ((fm = _FIELD_LINE_RE.exec(fieldsPart))) {
120
+ fields[fm[1].trim()] = fm[2].trim();
121
+ }
122
+
123
+ const missing = _FIELD_LABELS.filter(function (label) {
124
+ return !Object.prototype.hasOwnProperty.call(fields, label);
125
+ });
126
+ if (missing.length > 0) {
127
+ throw _parseError(
128
+ 'parseMarkdown: task "' + anchorId + '" is missing required field(s): ' + missing.join(', ')
129
+ );
130
+ }
131
+
132
+ const id = fields['Task ID'];
133
+ if (id !== anchorId) {
134
+ throw _parseError(
135
+ 'parseMarkdown: task anchor id "' + anchorId + '" does not match "Task ID" field "' + id + '"'
136
+ );
137
+ }
138
+
139
+ return {
140
+ id: id,
141
+ title: fields['Title'],
142
+ outcome: fields['Outcome'],
143
+ domain: fields['Domain'],
144
+ agentType: fields['Agent type'],
145
+ dependsOn: _listOrEmpty(fields['Dependencies']),
146
+ acceptanceCriteria: _listOrEmpty(fields['Acceptance criteria']),
147
+ agentMinutes: _numberOrNull(fields['Estimate — agent minutes']),
148
+ tokens: _numberOrNull(fields['Estimate — tokens']),
149
+ outputCount: _numberOrNull(fields['Output count']),
150
+ groupingRationale: _stringOrNull(fields['Grouping rationale']),
151
+ commit: {
152
+ type: _stringOrNull(fields['Commit type']),
153
+ scope: _stringOrNull(fields['Commit scope']),
154
+ subject: _stringOrNull(fields['Commit subject']),
155
+ },
156
+ verificationCommands: _parseVerificationCommands(verifPart),
157
+ };
158
+ }
159
+
160
+ function parseMarkdown(markdown) {
161
+ const source = typeof markdown === 'string' ? markdown : String(markdown == null ? '' : markdown);
162
+ const section = _extractTaskDetailsSection(source);
163
+
164
+ const tasks = [];
165
+ _TASK_ANCHOR_RE.lastIndex = 0;
166
+ let m;
167
+ while ((m = _TASK_ANCHOR_RE.exec(section))) {
168
+ const anchorId = m[1].trim();
169
+ const body = m[2];
170
+ tasks.push(_parseTaskFields(anchorId, body));
171
+ }
172
+
173
+ if (tasks.length === 0) {
174
+ throw _parseError('parseMarkdown: "## Task Details" section contained no task entries');
175
+ }
176
+
177
+ return tasks;
178
+ }
179
+
180
+ // ── parseCSV / mapPhases (US-01-TASK-BE-02) ──────────────────────────────────
181
+ //
182
+ // parseCSV validates and parses the eight-column pipe-separated dispatch CSV
183
+ // rendered by scripts/wb-render.js (renderCsv): one row per task, carrying
184
+ // the phase-level projection (phase_id, phase_title, commit_message,
185
+ // depends_on) redundantly alongside the task-level fields (task_id,
186
+ // task_title, domain, agent_type). depends_on is a space-separated list of
187
+ // external phase IDs (see computePhaseDependsOn in wb-render.js).
188
+ //
189
+ // mapPhases aggregates the per-row phase projection into one entry per phase
190
+ // (validating that every row belonging to the same phase agrees on
191
+ // phase_title / commit_message / depends_on), then reconciles task IDs
192
+ // against the per-task view produced by parseMarkdown: every task_id in the
193
+ // CSV must appear in the MD, and every MD task id must appear in the CSV.
194
+ // Mismatches fail with a structured PLAN_PARSE_ERROR before any state
195
+ // mutation, per AC-02. The MD task objects are the source of truth for task
196
+ // detail; mapPhases only adds phaseId membership to them.
197
+
198
+ const _CSV_HEADER = 'phase_id|phase_title|commit_message|depends_on|task_id|task_title|domain|agent_type';
199
+ const _CSV_COLUMNS = _CSV_HEADER.split('|');
200
+
201
+ function parseCSV(csv) {
202
+ const source = typeof csv === 'string' ? csv : String(csv == null ? '' : csv);
203
+ const lines = source.split(/\r\n|\r|\n/);
204
+ while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
205
+
206
+ if (lines.length === 0) {
207
+ throw _parseError('parseCSV: input is empty');
208
+ }
209
+
210
+ const header = lines[0];
211
+ if (header !== _CSV_HEADER) {
212
+ throw _parseError(
213
+ 'parseCSV: expected header "' + _CSV_HEADER + '", got ' + JSON.stringify(header)
214
+ );
215
+ }
216
+
217
+ const dataLines = lines.slice(1);
218
+ if (dataLines.length === 0) {
219
+ throw _parseError('parseCSV: no data rows found after header');
220
+ }
221
+
222
+ return dataLines.map(function (line, idx) {
223
+ const lineNumber = idx + 2; // 1-based; header occupies line 1
224
+ if (line === '') {
225
+ throw _parseError('parseCSV: line ' + lineNumber + ' is blank');
226
+ }
227
+ const fields = line.split('|');
228
+ if (fields.length !== _CSV_COLUMNS.length) {
229
+ throw _parseError(
230
+ 'parseCSV: line ' + lineNumber + ' has ' + fields.length +
231
+ ' field(s), expected ' + _CSV_COLUMNS.length
232
+ );
233
+ }
234
+
235
+ const row = {
236
+ phaseId: fields[0],
237
+ phaseTitle: fields[1],
238
+ commitMessage: fields[2],
239
+ dependsOn: fields[3].split(/\s+/).filter(Boolean),
240
+ taskId: fields[4],
241
+ taskTitle: fields[5],
242
+ domain: fields[6],
243
+ agentType: fields[7],
244
+ };
245
+
246
+ if (!row.phaseId) {
247
+ throw _parseError('parseCSV: line ' + lineNumber + ' is missing phase_id');
248
+ }
249
+ if (!row.taskId) {
250
+ throw _parseError('parseCSV: line ' + lineNumber + ' is missing task_id');
251
+ }
252
+
253
+ return row;
254
+ });
255
+ }
256
+
257
+ function mapPhases(csvRows, mdTasks) {
258
+ const phasesById = new Map();
259
+ const phaseOrder = [];
260
+ const seenTaskIds = new Set();
261
+ const taskOrder = [];
262
+
263
+ for (const row of csvRows) {
264
+ if (seenTaskIds.has(row.taskId)) {
265
+ throw _parseError('mapPhases: duplicate task_id "' + row.taskId + '" in CSV');
266
+ }
267
+ seenTaskIds.add(row.taskId);
268
+ taskOrder.push(row.taskId);
269
+
270
+ let phase = phasesById.get(row.phaseId);
271
+ if (!phase) {
272
+ phase = {
273
+ id: row.phaseId,
274
+ title: row.phaseTitle,
275
+ commitMessage: row.commitMessage,
276
+ dependsOn: row.dependsOn,
277
+ taskIds: [],
278
+ };
279
+ phasesById.set(row.phaseId, phase);
280
+ phaseOrder.push(row.phaseId);
281
+ } else {
282
+ if (phase.title !== row.phaseTitle) {
283
+ throw _parseError(
284
+ 'mapPhases: phase "' + row.phaseId + '" has inconsistent phase_title across rows: ' +
285
+ JSON.stringify(phase.title) + ' vs ' + JSON.stringify(row.phaseTitle)
286
+ );
287
+ }
288
+ if (phase.commitMessage !== row.commitMessage) {
289
+ throw _parseError(
290
+ 'mapPhases: phase "' + row.phaseId + '" has inconsistent commit_message across rows: ' +
291
+ JSON.stringify(phase.commitMessage) + ' vs ' + JSON.stringify(row.commitMessage)
292
+ );
293
+ }
294
+ if (phase.dependsOn.join(' ') !== row.dependsOn.join(' ')) {
295
+ throw _parseError(
296
+ 'mapPhases: phase "' + row.phaseId + '" has inconsistent depends_on across rows: ' +
297
+ JSON.stringify(phase.dependsOn) + ' vs ' + JSON.stringify(row.dependsOn)
298
+ );
299
+ }
300
+ }
301
+ phase.taskIds.push(row.taskId);
302
+ }
303
+
304
+ const mdIds = new Set(mdTasks.map(function (t) { return t.id; }));
305
+ const csvOnly = taskOrder.filter(function (id) { return !mdIds.has(id); });
306
+ if (csvOnly.length > 0) {
307
+ throw _parseError(
308
+ 'mapPhases: task ID(s) present in CSV but missing from MD: ' + csvOnly.join(', ')
309
+ );
310
+ }
311
+
312
+ const csvIds = new Set(taskOrder);
313
+ const mdOnly = mdTasks
314
+ .map(function (t) { return t.id; })
315
+ .filter(function (id) { return !csvIds.has(id); });
316
+ if (mdOnly.length > 0) {
317
+ throw _parseError(
318
+ 'mapPhases: task ID(s) present in MD but missing from CSV: ' + mdOnly.join(', ')
319
+ );
320
+ }
321
+
322
+ const taskIdToPhaseId = new Map();
323
+ for (const phase of phasesById.values()) {
324
+ for (const taskId of phase.taskIds) {
325
+ taskIdToPhaseId.set(taskId, phase.id);
326
+ }
327
+ }
328
+
329
+ const tasks = mdTasks.map(function (task) {
330
+ return Object.assign({}, task, { phaseId: taskIdToPhaseId.get(task.id) });
331
+ });
332
+
333
+ const phases = phaseOrder.map(function (id) { return phasesById.get(id); });
334
+
335
+ return { phases: phases, tasks: tasks };
336
+ }
337
+
338
+ // ── validateDAG (US-01-TASK-BE-03) ───────────────────────────────────────────
339
+ //
340
+ // Graph-level validation of the task array produced by mapPhases(): duplicate
341
+ // task IDs, missing required fields, self-dependencies, dependsOn references
342
+ // to unknown task IDs, and dependency cycles. Rejects before any state
343
+ // mutation (AC-02).
344
+ //
345
+ // Scope boundary: MD/CSV parity (every CSV task_id present in the MD and vice
346
+ // versa) is already reconciled by mapPhases() before validateDAG ever sees
347
+ // the task array — see the mapPhases header comment above. validateDAG does
348
+ // NOT repeat that reconciliation; it only validates the shape of the
349
+ // dependency graph itself (cycles, missing deps, duplicates, self-deps, and
350
+ // the minimal required-field set below). Do not add MD/CSV cross-referencing
351
+ // logic here — that belongs to mapPhases.
352
+ //
353
+ // Fails fast on the first violation found, in the order: required fields,
354
+ // duplicate IDs, self-dependency / dangling references, then cycles. This
355
+ // mirrors the fail-fast PLAN_PARSE_ERROR convention used by parseCSV/mapPhases
356
+ // above, and reuses the three-color DFS (WHITE/GRAY/BLACK) cycle-detection
357
+ // strategy from src/claude/scripts/wb-validate.js (check 11) — that script is
358
+ // an authoring-time structural validator invoked as a separate CLI process;
359
+ // it is not imported here because validateDAG is a runtime library check with
360
+ // its own error contract (thrown PLAN_PARSE_ERROR, not a JSON report).
361
+
362
+ const _REQUIRED_TASK_FIELDS = ['id', 'dependsOn'];
363
+
364
+ function validateDAG(tasks) {
365
+ const list = Array.isArray(tasks) ? tasks : [];
366
+
367
+ // 1. Required fields present on every task.
368
+ for (const task of list) {
369
+ const label = task && task.id != null ? task.id : '(unknown)';
370
+ const missing = _REQUIRED_TASK_FIELDS.filter(function (field) {
371
+ return task == null || task[field] == null;
372
+ });
373
+ if (missing.length > 0) {
374
+ throw _parseError(
375
+ 'validateDAG: task "' + label + '" is missing required field(s): ' + missing.join(', ')
376
+ );
377
+ }
378
+ }
379
+
380
+ // 2. Duplicate task IDs.
381
+ const seenIds = new Set();
382
+ for (const task of list) {
383
+ if (seenIds.has(task.id)) {
384
+ throw _parseError('validateDAG: duplicate task ID "' + task.id + '"');
385
+ }
386
+ seenIds.add(task.id);
387
+ }
388
+
389
+ // 3. dependsOn shape, self-dependency, and dangling references.
390
+ for (const task of list) {
391
+ if (!Array.isArray(task.dependsOn)) {
392
+ throw _parseError(
393
+ 'validateDAG: task "' + task.id + '" field "dependsOn" must be an array'
394
+ );
395
+ }
396
+ if (task.dependsOn.includes(task.id)) {
397
+ throw _parseError(
398
+ 'validateDAG: task "' + task.id + '" lists itself in dependsOn (self-dependency)'
399
+ );
400
+ }
401
+ for (const depId of task.dependsOn) {
402
+ if (!seenIds.has(depId)) {
403
+ throw _parseError(
404
+ 'validateDAG: task "' + task.id + '" depends on unknown task ID "' + depId + '"'
405
+ );
406
+ }
407
+ }
408
+ }
409
+
410
+ // 4. Cycle detection — three-color DFS (WHITE = unvisited, GRAY = on the
411
+ // current path, BLACK = fully processed). A back-edge to a GRAY node means
412
+ // the path from that node to the current node (inclusive) forms a cycle.
413
+ const adjMap = new Map();
414
+ for (const task of list) {
415
+ adjMap.set(task.id, task.dependsOn);
416
+ }
417
+
418
+ const WHITE = 0;
419
+ const GRAY = 1;
420
+ const BLACK = 2;
421
+ const colors = new Map();
422
+ for (const id of adjMap.keys()) colors.set(id, WHITE);
423
+
424
+ function dfs(nodeId, pathStack) {
425
+ colors.set(nodeId, GRAY);
426
+ pathStack.push(nodeId);
427
+
428
+ for (const depId of adjMap.get(nodeId)) {
429
+ const color = colors.get(depId);
430
+ if (color === GRAY) {
431
+ const cycleStart = pathStack.indexOf(depId);
432
+ const cycle = pathStack.slice(cycleStart);
433
+ throw _parseError(
434
+ 'validateDAG: dependency cycle detected: ' + cycle.join(' -> ') + ' -> ' + cycle[0]
435
+ );
436
+ } else if (color === WHITE) {
437
+ dfs(depId, pathStack);
438
+ }
439
+ // BLACK: already fully processed, no cycle through it.
440
+ }
441
+
442
+ pathStack.pop();
443
+ colors.set(nodeId, BLACK);
444
+ }
445
+
446
+ for (const id of adjMap.keys()) {
447
+ if (colors.get(id) === WHITE) {
448
+ dfs(id, []);
449
+ }
450
+ }
451
+
452
+ return true;
453
+ }
454
+
455
+ // ── createPlanSnapshot (US-01-TASK-BE-04) ────────────────────────────────────
456
+ //
457
+ // Builds the read-only, run-lifetime plan snapshot described in
458
+ // FTR-018-Tech-Spec.md section 4: "Digest: SHA256 over length-prefixed UTF-8
459
+ // raw MD bytes followed by length-prefixed raw CSV bytes and parser format
460
+ // version. Also persist independent file hashes. [...] Read-only snapshot =
461
+ // normalized validated task objects + raw-input hashes; immutable for the
462
+ // run."
463
+ //
464
+ // Byte layout fed to the digest hash (all lengths are 8-byte big-endian
465
+ // unsigned integers, so the boundary between fields is unambiguous
466
+ // regardless of content — this is what makes the order stable and prevents
467
+ // e.g. an MD/CSV byte-boundary shift from colliding with a different-length
468
+ // split that happens to hash the same concatenated bytes):
469
+ // [8-byte len(mdBytes)] [mdBytes]
470
+ // [8-byte len(csvBytes)] [csvBytes]
471
+ // [8-byte len(versionBytes)] [versionBytes]
472
+ // where versionBytes is the UTF-8 encoding of String(formatVersion).
473
+ //
474
+ // PARSER_FORMAT_VERSION identifies the parser/normalization rules
475
+ // (parseMarkdown/parseCSV/mapPhases/validateDAG above) that produced the
476
+ // snapshot's tasks/phases. A caller may pass an explicit formatVersion (e.g.
477
+ // to reproduce a prior run's digest); it defaults to the module's current
478
+ // version otherwise. Bumping PARSER_FORMAT_VERSION is a deliberate migration
479
+ // (see Tech-Spec section 4: "parser version changes require explicit
480
+ // migration") — do not bump it silently.
481
+ const PARSER_FORMAT_VERSION = 1;
482
+
483
+ function _lengthPrefixed(buffer) {
484
+ const len = Buffer.alloc(8);
485
+ len.writeBigUInt64BE(BigInt(buffer.length));
486
+ return Buffer.concat([len, buffer]);
487
+ }
488
+
489
+ function _computePlanDigest(mdBuffer, csvBuffer, formatVersion) {
490
+ const versionBuffer = Buffer.from(String(formatVersion), 'utf8');
491
+ const material = Buffer.concat([
492
+ _lengthPrefixed(mdBuffer),
493
+ _lengthPrefixed(csvBuffer),
494
+ _lengthPrefixed(versionBuffer),
495
+ ]);
496
+ return crypto.createHash('sha256').update(material).digest('hex');
497
+ }
498
+
499
+ // Independent per-file hash (Tech-Spec: "Also persist independent file
500
+ // hashes"), distinct from the combined planDigest above so a tampered input
501
+ // can be attributed to the MD or the CSV specifically. Uses the same
502
+ // 'sha256:'-prefixed format as bin/cli.js's computeFileSha256.
503
+ function _independentFileHash(buffer) {
504
+ return 'sha256:' + crypto.createHash('sha256').update(buffer).digest('hex');
505
+ }
506
+
507
+ // Recursively freezes plain objects/arrays so the snapshot's normalized
508
+ // tasks/phases (and everything they contain — e.g. each task's `commit`
509
+ // object and `dependsOn`/`acceptanceCriteria`/`verificationCommands` arrays)
510
+ // are read-only for the run's lifetime, per the Work Breakdown outcome. This
511
+ // module runs under 'use strict' (top of file), so assigning to or mutating
512
+ // a frozen property/array throws a TypeError rather than silently failing.
513
+ function _deepFreeze(value) {
514
+ if (Array.isArray(value)) {
515
+ value.forEach(_deepFreeze);
516
+ return Object.freeze(value);
517
+ }
518
+ if (value !== null && typeof value === 'object') {
519
+ Object.keys(value).forEach(function (key) { _deepFreeze(value[key]); });
520
+ return Object.freeze(value);
521
+ }
522
+ return value;
523
+ }
524
+
525
+ // createPlanSnapshot(markdownText, csvText, [formatVersion]) parses and
526
+ // validates the raw MD/CSV documents (parseMarkdown -> parseCSV -> mapPhases
527
+ // -> validateDAG, in that order — any PLAN_PARSE_ERROR from those calls
528
+ // propagates unmodified, per AC-02's fail-before-mutation contract) and
529
+ // binds the computed digest to the resulting normalized tasks/phases in a
530
+ // single immutable snapshot object. formatVersion defaults to
531
+ // PARSER_FORMAT_VERSION.
532
+ function createPlanSnapshot(markdownText, csvText, formatVersion) {
533
+ const version = formatVersion == null ? PARSER_FORMAT_VERSION : formatVersion;
534
+ const mdSource = typeof markdownText === 'string' ? markdownText : String(markdownText == null ? '' : markdownText);
535
+ const csvSource = typeof csvText === 'string' ? csvText : String(csvText == null ? '' : csvText);
536
+ const mdBuffer = Buffer.from(mdSource, 'utf8');
537
+ const csvBuffer = Buffer.from(csvSource, 'utf8');
538
+
539
+ const mdTasks = parseMarkdown(mdSource);
540
+ const csvRows = parseCSV(csvSource);
541
+ const { phases, tasks } = mapPhases(csvRows, mdTasks);
542
+ validateDAG(tasks);
543
+
544
+ const snapshot = {
545
+ planDigest: _computePlanDigest(mdBuffer, csvBuffer, version),
546
+ formatVersion: version,
547
+ mdHash: _independentFileHash(mdBuffer),
548
+ csvHash: _independentFileHash(csvBuffer),
549
+ phases: phases,
550
+ tasks: tasks,
551
+ };
552
+
553
+ return _deepFreeze(snapshot);
554
+ }
555
+
556
+ // ── computeReadyQueue (US-01-TASK-BE-05) ─────────────────────────────────────
557
+ //
558
+ // Tech-Spec section 4: "Ready queue: stable Kahn topological ordering, ties
559
+ // by source phase index then source task index then ID." This function
560
+ // computes that full stable ordering up front, over the complete dependency
561
+ // graph, in one pass. It intentionally does NOT model "one ready batch at a
562
+ // time" / re-queueing as tasks complete — the Tech-Spec's dynamic dispatch
563
+ // loop ("Dispatch from ready tasks only... Independent tasks may finish out
564
+ // of order but do not overtake earlier dispatched tasks in integration") is
565
+ // the concern of the executor loop (US-07/US-08), which walks this
566
+ // precomputed order and defers a task only if a real (asynchronous) run
567
+ // hasn't finished a prerequisite yet. Because the graph and its edges are
568
+ // fixed for a run (the plan snapshot is immutable, per US-01-TASK-BE-04),
569
+ // the full stable Kahn order computed here is exactly the order the executor
570
+ // loop will dispatch in when nothing is actually blocked — one full,
571
+ // deterministic pass is sufficient and simplest for this module's job:
572
+ // parsing/scheduling, not live execution state.
573
+ //
574
+ // Accepts either:
575
+ // - a plan snapshot as returned by createPlanSnapshot() (an object with
576
+ // .tasks and .phases) — the expected caller shape once index.js wires
577
+ // up execution; snapshot.phases[i].taskIds gives the authoritative
578
+ // "source task index within phase" ordering (that array is itself built
579
+ // from CSV row order in mapPhases, i.e. original document order), and
580
+ // the phase's position in snapshot.phases is the "source phase index".
581
+ // The snapshot is deep-frozen by createPlanSnapshot — this function only
582
+ // reads it, never mutates it.
583
+ // - a plain array of task objects (each with at least `id` and
584
+ // `dependsOn`, and optionally `phaseId`) — for callers/tests that have
585
+ // not gone through createPlanSnapshot. Tasks are grouped into pseudo-
586
+ // phases by first-seen order of `phaseId` (mirroring mapPhases' own
587
+ // phaseOrder construction); tasks sharing no `phaseId` field all fall
588
+ // into one implicit phase, so the tie-break degrades cleanly to plain
589
+ // source-array order then ID.
590
+ //
591
+ // Returns the full array of task objects in stable ready order (not batches
592
+ // — see rationale above).
593
+ //
594
+ // Defense in depth: validateDAG() should already have rejected any cycle
595
+ // earlier in the pipeline (createPlanSnapshot calls it before ever building
596
+ // a snapshot), but this function does not assume that happened — a plain
597
+ // array can be handed in without ever going through validateDAG. If Kahn's
598
+ // algorithm cannot fully order the graph (in-degree never reaches zero for
599
+ // some task — a cycle, or a dependsOn reference that never resolves), this
600
+ // throws a PLAN_PARSE_ERROR rather than silently returning a partial order.
601
+
602
+ function _orderIndexFromSnapshot(snapshot) {
603
+ const orderIndex = new Map();
604
+ snapshot.phases.forEach(function (phase, phaseIndex) {
605
+ phase.taskIds.forEach(function (taskId, taskIndexInPhase) {
606
+ orderIndex.set(taskId, { phaseIndex: phaseIndex, taskIndexInPhase: taskIndexInPhase });
607
+ });
608
+ });
609
+ // Defensive: any task not referenced by any phase.taskIds (should not
610
+ // happen for a snapshot that passed mapPhases/validateDAG) sorts after all
611
+ // phased tasks, in source array order.
612
+ snapshot.tasks.forEach(function (task, sourceIndex) {
613
+ if (!orderIndex.has(task.id)) {
614
+ orderIndex.set(task.id, { phaseIndex: Number.MAX_SAFE_INTEGER, taskIndexInPhase: sourceIndex });
615
+ }
616
+ });
617
+ return orderIndex;
618
+ }
619
+
620
+ function _orderIndexFromPlainTasks(tasks) {
621
+ const orderIndex = new Map();
622
+ const phaseIndexByKey = new Map();
623
+ const nextTaskIndexByKey = new Map();
624
+ let nextPhaseIndex = 0;
625
+
626
+ tasks.forEach(function (task) {
627
+ const phaseKey = task.phaseId == null ? '__no_phase__' : task.phaseId;
628
+ if (!phaseIndexByKey.has(phaseKey)) {
629
+ phaseIndexByKey.set(phaseKey, nextPhaseIndex++);
630
+ nextTaskIndexByKey.set(phaseKey, 0);
631
+ }
632
+ const phaseIndex = phaseIndexByKey.get(phaseKey);
633
+ const taskIndexInPhase = nextTaskIndexByKey.get(phaseKey);
634
+ nextTaskIndexByKey.set(phaseKey, taskIndexInPhase + 1);
635
+ orderIndex.set(task.id, { phaseIndex: phaseIndex, taskIndexInPhase: taskIndexInPhase });
636
+ });
637
+
638
+ return orderIndex;
639
+ }
640
+
641
+ function _normalizeSchedulerInput(input) {
642
+ if (input != null && Array.isArray(input.tasks) && Array.isArray(input.phases)) {
643
+ return { tasks: input.tasks, orderIndex: _orderIndexFromSnapshot(input) };
644
+ }
645
+ if (Array.isArray(input)) {
646
+ return { tasks: input, orderIndex: _orderIndexFromPlainTasks(input) };
647
+ }
648
+ throw _parseError(
649
+ 'computeReadyQueue: expected a plan snapshot (with .tasks/.phases) or a plain array of tasks'
650
+ );
651
+ }
652
+
653
+ function computeReadyQueue(input) {
654
+ const normalized = _normalizeSchedulerInput(input);
655
+ const tasks = normalized.tasks;
656
+ const orderIndex = normalized.orderIndex;
657
+
658
+ const byId = new Map(tasks.map(function (task) { return [task.id, task]; }));
659
+ const inDegree = new Map();
660
+ const dependents = new Map();
661
+ tasks.forEach(function (task) {
662
+ inDegree.set(task.id, task.dependsOn.length);
663
+ if (!dependents.has(task.id)) dependents.set(task.id, []);
664
+ });
665
+ tasks.forEach(function (task) {
666
+ task.dependsOn.forEach(function (depId) {
667
+ if (!dependents.has(depId)) dependents.set(depId, []);
668
+ dependents.get(depId).push(task.id);
669
+ });
670
+ });
671
+
672
+ function compareIds(aId, bId) {
673
+ const a = orderIndex.get(aId);
674
+ const b = orderIndex.get(bId);
675
+ if (a.phaseIndex !== b.phaseIndex) return a.phaseIndex - b.phaseIndex;
676
+ if (a.taskIndexInPhase !== b.taskIndexInPhase) return a.taskIndexInPhase - b.taskIndexInPhase;
677
+ if (aId < bId) return -1;
678
+ if (aId > bId) return 1;
679
+ return 0;
680
+ }
681
+
682
+ let ready = tasks
683
+ .filter(function (task) { return inDegree.get(task.id) === 0; })
684
+ .map(function (task) { return task.id; });
685
+ ready.sort(compareIds);
686
+
687
+ const orderedIds = [];
688
+ while (ready.length > 0) {
689
+ const nextId = ready.shift();
690
+ orderedIds.push(nextId);
691
+ dependents.get(nextId).forEach(function (dependentId) {
692
+ const remaining = inDegree.get(dependentId) - 1;
693
+ inDegree.set(dependentId, remaining);
694
+ if (remaining === 0) {
695
+ ready.push(dependentId);
696
+ }
697
+ });
698
+ ready.sort(compareIds);
699
+ }
700
+
701
+ if (orderedIds.length !== tasks.length) {
702
+ const orderedSet = new Set(orderedIds);
703
+ const unresolved = tasks
704
+ .map(function (task) { return task.id; })
705
+ .filter(function (id) { return !orderedSet.has(id); });
706
+ throw _parseError(
707
+ 'computeReadyQueue: unable to fully order the task graph — dependency cycle or ' +
708
+ 'unresolved dependency reference among task(s): ' + unresolved.join(', ') +
709
+ ' (validateDAG should be run before computeReadyQueue to catch this earlier; this is a ' +
710
+ 'defense-in-depth check, not a substitute for it)'
711
+ );
712
+ }
713
+
714
+ return orderedIds.map(function (id) { return byId.get(id); });
715
+ }
716
+
717
+ module.exports = {
718
+ parseMarkdown,
719
+ parseCSV,
720
+ mapPhases,
721
+ validateDAG,
722
+ PARSER_FORMAT_VERSION,
723
+ createPlanSnapshot,
724
+ computeReadyQueue,
725
+ };