@dtlabs/ai-toolkit 0.13.0 → 0.14.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.
- package/CHANGELOG.md +44 -0
- package/bin/cli.js +371 -2
- package/docs/task-executor-bootstrap.md +195 -0
- package/docs/task-executor-cli.md +319 -0
- package/docs/workflow-control-plane.md +95 -0
- package/lib/execution-ledger.js +186 -4
- package/lib/task-executor/claude-process.js +563 -0
- package/lib/task-executor/git.js +1362 -0
- package/lib/task-executor/index.js +3729 -0
- package/lib/task-executor/ownership.js +590 -0
- package/lib/task-executor/plan.js +725 -0
- package/lib/task-executor/store.js +1164 -0
- package/lib/workflow-artifacts.js +102 -0
- package/lib/workflow-control.js +219 -0
- package/lib/workflow-runner.js +39 -0
- package/package.json +1 -1
- package/src/claude/agents/gaia-intervention-documentation-standard.md +9 -9
- package/src/claude/skills/assess-codebase/SKILL.md +9 -103
- package/src/claude/skills/implement-feature/SKILL.md +74 -159
- package/src/claude/workflows/am-phase1.js +43 -294
- package/src/claude/workflows/am-phase2.js +10 -164
- package/src/claude/workflows/pm-phase1.js +45 -358
- package/src/claude/workflows/pm-phase2.js +50 -571
- package/src/claude/workflows/pm-phase3.js +5 -824
|
@@ -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
|
+
};
|