@caiqueoak/flow 0.3.3 → 0.5.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 (45) hide show
  1. package/README.md +44 -42
  2. package/package.json +14 -2
  3. package/skills/flow/SKILL.md +10 -137
  4. package/skills/flow/build/step-01-execute-task.md +9 -0
  5. package/skills/flow/discovery/step-01-project.md +5 -0
  6. package/skills/flow/discovery/step-02-await-approval.md +3 -0
  7. package/skills/flow/engineering/profiles/readability-first.md +20 -0
  8. package/skills/flow/engineering/step-02-synthesize.md +37 -0
  9. package/skills/flow/engineering/step-05-present.md +3 -0
  10. package/skills/flow/engineering/technology-defaults.md +44 -0
  11. package/skills/flow/invariants.md +16 -0
  12. package/skills/flow/migration/step-01-reconcile.md +7 -0
  13. package/skills/flow/planning/step-01-plan-work-item.md +9 -0
  14. package/skills/flow/planning/step-02-prepare-plan.md +33 -0
  15. package/skills/flow/planning/step-03-await-approval.md +3 -0
  16. package/skills/flow/reconcile/step-01-reconcile.md +7 -0
  17. package/skills/flow/review/step-01-review-work-item.md +5 -0
  18. package/src/artifacts/backlog.mjs +162 -0
  19. package/src/artifacts/document.mjs +30 -0
  20. package/src/artifacts/engineering.mjs +26 -0
  21. package/src/artifacts/gates.mjs +28 -0
  22. package/src/artifacts/implementation-plan.mjs +27 -0
  23. package/src/artifacts/prd.mjs +12 -0
  24. package/src/artifacts/state.mjs +63 -0
  25. package/src/artifacts/tasks.mjs +90 -0
  26. package/src/cli.mjs +34 -309
  27. package/src/commands/gates.mjs +68 -0
  28. package/src/commands/graph.mjs +83 -0
  29. package/src/commands/init.mjs +111 -0
  30. package/src/commands/migrate.mjs +189 -0
  31. package/src/commands/route.mjs +135 -0
  32. package/src/commands/status.mjs +32 -0
  33. package/src/commands/trace.mjs +44 -0
  34. package/src/commands/validate.mjs +174 -0
  35. package/src/shared/cli-io.mjs +86 -0
  36. package/src/shared/profiles.mjs +22 -0
  37. package/src/shared/project-config.mjs +39 -0
  38. package/src/shared/project-path.mjs +10 -0
  39. package/src/shared/skill-installer.mjs +34 -0
  40. package/skills/flow/references/build.md +0 -7
  41. package/skills/flow/references/discovery.md +0 -15
  42. package/skills/flow/references/graph.md +0 -62
  43. package/skills/flow/references/planning.md +0 -7
  44. package/skills/flow/references/reconcile.md +0 -5
  45. package/skills/flow/references/review.md +0 -5
@@ -0,0 +1,162 @@
1
+ import { parseDocument } from 'yaml';
2
+
3
+ export class ArtifactValidationError extends Error {
4
+ constructor(message) {
5
+ super(message);
6
+ this.name = 'ArtifactValidationError';
7
+ }
8
+ }
9
+
10
+ const KINDS = new Set(['feature', 'technical', 'maintenance']);
11
+ const STATES = new Set(['pending', 'in_progress', 'completed']);
12
+ const WORK_ITEM_ID = /^W\d{3,}$/;
13
+ const WORK_ITEM_FOLDER = /^W\d{3,}-[a-z0-9]+(?:-[a-z0-9]+)*$/;
14
+
15
+ function fail(message) {
16
+ throw new ArtifactValidationError(message);
17
+ }
18
+
19
+ function requireObject(value, label) {
20
+ if (!value || typeof value !== 'object' || Array.isArray(value)) fail(`${label} must be a mapping.`);
21
+ return value;
22
+ }
23
+
24
+ function requireString(value, label) {
25
+ if (typeof value !== 'string' || !value.trim()) fail(`${label} must be a non-empty string.`);
26
+ return value.trim();
27
+ }
28
+
29
+ export function parseBacklog(text, { source = 'backlog.yaml' } = {}) {
30
+ const document = parseDocument(text, { prettyErrors: false, uniqueKeys: true });
31
+ if (document.errors.length) fail(`${source} is invalid: ${document.errors[0].message}`);
32
+ const backlog = requireObject(document.toJS(), source);
33
+ if (backlog.schema_version !== 2) fail(`${source} schema_version must be 2.`);
34
+ if (!Array.isArray(backlog.work_items)) fail(`${source} work_items must be a list.`);
35
+
36
+ const ids = new Set();
37
+ const items = backlog.work_items.map((raw, index) => {
38
+ const label = `work_items[${index}]`;
39
+ const item = requireObject(raw, label);
40
+ const id = requireString(item.id, `${label}.id`);
41
+ if (!WORK_ITEM_ID.test(id)) fail(`${label}.id must use W followed by a zero-padded numeric sequence, e.g. W015.`);
42
+ if (ids.has(id)) fail(`${source} contains duplicate work-item ID '${id}'.`);
43
+ ids.add(id);
44
+
45
+ const folder = requireString(item.folder ?? item.path, `${label}.folder`);
46
+ if (!WORK_ITEM_FOLDER.test(folder))
47
+ fail(`${label}.folder must use W###-kebab-case, e.g. W015-learner-web-application.`);
48
+ const numericId = id.slice(1);
49
+ if (!folder.startsWith(`W${numericId}-`)) fail(`${label}.folder must preserve the numeric sequence from ${id}.`);
50
+
51
+ if (!KINDS.has(item.kind)) fail(`${label}.kind must be feature, technical, or maintenance.`);
52
+ const title = requireString(item.title, `${label}.title`);
53
+ const state = item.state ?? item.status;
54
+ if (!STATES.has(state)) fail(`${label}.state must be pending, in_progress, or completed.`);
55
+ if (!Number.isInteger(item.priority) || item.priority < 1) fail(`${label}.priority must be a positive integer.`);
56
+ if (!Array.isArray(item.depends_on ?? [])) fail(`${label}.depends_on must be a list.`);
57
+ if (!Array.isArray(item.blockers ?? [])) fail(`${label}.blockers must be a list when present.`);
58
+
59
+ const dependencies = [];
60
+ const seenDependencies = new Set();
61
+ for (const rawDependency of item.depends_on ?? []) {
62
+ const dependency = requireString(rawDependency, `${label}.depends_on entry`);
63
+ if (!WORK_ITEM_ID.test(dependency)) fail(`${label}.depends_on contains invalid work-item ID '${dependency}'.`);
64
+ if (dependency === id) fail(`Work item '${id}' cannot depend on itself.`);
65
+ if (seenDependencies.has(dependency)) fail(`Work item '${id}' lists dependency '${dependency}' more than once.`);
66
+ seenDependencies.add(dependency);
67
+ dependencies.push(dependency);
68
+ }
69
+
70
+ return {
71
+ id,
72
+ folder,
73
+ kind: item.kind,
74
+ title,
75
+ state,
76
+ priority: item.priority,
77
+ depends_on: dependencies,
78
+ blockers: (item.blockers ?? []).map((blocker) => {
79
+ requireObject(blocker, `${id} blocker`);
80
+ if (!/^[a-z][a-z0-9-]*$/.test(blocker.id ?? '')) fail(`${id} blocker.id must be stable kebab-case.`);
81
+ if (!['external_action', 'consequential_decision'].includes(blocker.type))
82
+ fail(`${id} blocker.type is invalid.`);
83
+ requireString(blocker.description, `${id} blocker.description`);
84
+ if (!['unresolved', 'resolved'].includes(blocker.status)) fail(`${id} blocker.status is invalid.`);
85
+ return blocker;
86
+ })
87
+ };
88
+ });
89
+
90
+ const byId = new Map(items.map((item) => [item.id, item]));
91
+ for (const item of items) {
92
+ for (const dependency of item.depends_on) {
93
+ if (!byId.has(dependency)) fail(`Work item '${item.id}' depends on unknown work item '${dependency}'.`);
94
+ }
95
+ }
96
+ validateAcyclic(items);
97
+ if (items.filter((item) => item.state === 'in_progress').length > 1)
98
+ fail('Only one mutating work item may be in_progress.');
99
+ return { schema_version: 2, work_items: items };
100
+ }
101
+
102
+ export function validateAcyclic(items) {
103
+ const indegree = new Map(items.map((item) => [item.id, item.depends_on.length]));
104
+ const dependents = new Map(items.map((item) => [item.id, []]));
105
+ for (const item of items) for (const dependency of item.depends_on) dependents.get(dependency).push(item.id);
106
+ const queue = items.filter((item) => indegree.get(item.id) === 0).map((item) => item.id);
107
+ let visited = 0;
108
+ while (queue.length) {
109
+ const id = queue.shift();
110
+ visited += 1;
111
+ for (const dependent of dependents.get(id)) {
112
+ const next = indegree.get(dependent) - 1;
113
+ indegree.set(dependent, next);
114
+ if (next === 0) queue.push(dependent);
115
+ }
116
+ }
117
+ if (visited !== items.length) fail('backlog.yaml work-item dependencies contain a cycle.');
118
+ }
119
+
120
+ export function deriveExecutionStatus(item, byId) {
121
+ if (item.state === 'completed') return { status: 'completed', reasons: [] };
122
+ const incompleteDependencies = item.depends_on.filter((id) => byId.get(id)?.state !== 'completed');
123
+ const explicitBlockers = (item.blockers ?? []).filter((blocker) => blocker.status !== 'resolved');
124
+ if (incompleteDependencies.length || explicitBlockers.length) {
125
+ return {
126
+ status: 'blocked',
127
+ reasons: [...incompleteDependencies.map((id) => ({ type: 'dependency', ref: id })), ...explicitBlockers]
128
+ };
129
+ }
130
+ return { status: item.state === 'in_progress' ? 'in_progress' : 'ready', reasons: [] };
131
+ }
132
+
133
+ export function topologicalOrder(items) {
134
+ const byId = new Map(items.map((item) => [item.id, item]));
135
+ const remaining = new Map(items.map((item) => [item.id, item.depends_on.length]));
136
+ const dependents = new Map(items.map((item) => [item.id, []]));
137
+ for (const item of items) for (const dependency of item.depends_on) dependents.get(dependency).push(item.id);
138
+ const compare = (a, b) => a.localeCompare(b);
139
+ for (const ids of dependents.values()) ids.sort(compare);
140
+ const ready = items
141
+ .filter((item) => item.depends_on.length === 0)
142
+ .map((item) => item.id)
143
+ .sort(compare);
144
+ const levels = new Map(ready.map((id) => [id, 0]));
145
+ const orderedIds = [];
146
+ while (ready.length) {
147
+ const id = ready.shift();
148
+ orderedIds.push(id);
149
+ for (const dependent of dependents.get(id)) {
150
+ levels.set(dependent, Math.max(levels.get(dependent) ?? 0, (levels.get(id) ?? 0) + 1));
151
+ const count = remaining.get(dependent) - 1;
152
+ remaining.set(dependent, count);
153
+ if (count === 0) {
154
+ ready.push(dependent);
155
+ ready.sort(compare);
156
+ }
157
+ }
158
+ }
159
+ return orderedIds
160
+ .map((id) => byId.get(id))
161
+ .sort((a, b) => levels.get(a.id) - levels.get(b.id) || compare(a.id, b.id));
162
+ }
@@ -0,0 +1,30 @@
1
+ import { parseDocument } from 'yaml';
2
+ import { createHash } from 'node:crypto';
3
+ export function documentRevision(text) {
4
+ return createHash('sha256').update(text).digest('hex');
5
+ }
6
+ export function documentMetadata(text) {
7
+ const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
8
+ if (!match) throw new Error('Missing YAML frontmatter.');
9
+ const doc = parseDocument(match[1], { uniqueKeys: true });
10
+ if (doc.errors.length) throw new Error(doc.errors[0].message);
11
+ const value = doc.toJS();
12
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Frontmatter must be a mapping.');
13
+ return value;
14
+ }
15
+ export function validateDocument(text, headings) {
16
+ let metadata = {};
17
+ const errors = [];
18
+ try {
19
+ metadata = documentMetadata(text);
20
+ } catch (error) {
21
+ errors.push(error.message);
22
+ }
23
+ if (metadata.schema_version !== 1) errors.push('schema_version must be 1.');
24
+ if (!['draft', 'approved'].includes(metadata.status)) errors.push('status must be draft or approved.');
25
+ if (metadata.status === 'approved' && (!metadata.approved_at || Number.isNaN(Date.parse(metadata.approved_at))))
26
+ errors.push('approved_at must record the human approval timestamp.');
27
+ const actual = new Set(text.split(/\r?\n/).filter((line) => /^#{1,2} /.test(line)));
28
+ for (const heading of headings) if (!actual.has(heading)) errors.push(`Missing ${heading}.`);
29
+ return { ...metadata, errors };
30
+ }
@@ -0,0 +1,26 @@
1
+ import { validateDocument } from './document.mjs';
2
+ import { READABILITY_FIRST_PROFILE } from '../shared/profiles.mjs';
3
+ export const ENGINEERING_HEADINGS = [
4
+ '# Engineering',
5
+ '## System shape',
6
+ '## Modules and ownership',
7
+ '## Dependency direction and boundaries',
8
+ '## Vertical slices and code organization',
9
+ '## Naming and readability conventions',
10
+ '## Data ownership and persistence',
11
+ '## Error handling',
12
+ '## Testing and verification',
13
+ '## Dependencies and external services',
14
+ '## Security and operations',
15
+ '## Deterministic gates',
16
+ '## Deferred complexity',
17
+ '## Exceptions'
18
+ ];
19
+ export function validateEngineeringDocument(text) {
20
+ const result = validateDocument(text, ENGINEERING_HEADINGS);
21
+ if (result.baseline?.profile !== READABILITY_FIRST_PROFILE.id)
22
+ result.errors.push('baseline.profile must be flow/readability-first@1.');
23
+ if (!['improve', 'preserve', 'not_applicable'].includes(result.baseline?.existing_code_policy))
24
+ result.errors.push('baseline.existing_code_policy is invalid.');
25
+ return result;
26
+ }
@@ -0,0 +1,28 @@
1
+ import { parseDocument } from 'yaml';
2
+ import { ArtifactValidationError } from './backlog.mjs';
3
+
4
+ const KINDS = new Set(['command', 'builtin']);
5
+
6
+ export function parseGates(text, { source = 'gates.yaml' } = {}) {
7
+ const document = parseDocument(text, { prettyErrors: false, uniqueKeys: true });
8
+ if (document.errors.length) throw new ArtifactValidationError(`${source} is invalid: ${document.errors[0].message}`);
9
+ const value = document.toJS();
10
+ if (!value || typeof value !== 'object' || Array.isArray(value))
11
+ throw new ArtifactValidationError(`${source} must be a mapping.`);
12
+ if (value.schema_version !== 1) throw new ArtifactValidationError(`${source} schema_version must be 1.`);
13
+ if (!Array.isArray(value.gates)) throw new ArtifactValidationError(`${source} gates must be a list.`);
14
+ const ids = new Set();
15
+ const gates = value.gates.map((gate, index) => {
16
+ if (!gate || typeof gate !== 'object' || Array.isArray(gate))
17
+ throw new ArtifactValidationError(`gates[${index}] must be a mapping.`);
18
+ if (typeof gate.id !== 'string' || !/^[a-z][a-z0-9-]*$/.test(gate.id))
19
+ throw new ArtifactValidationError(`gates[${index}].id must be lower kebab-case.`);
20
+ if (ids.has(gate.id)) throw new ArtifactValidationError(`duplicate gate '${gate.id}'.`);
21
+ ids.add(gate.id);
22
+ if (!KINDS.has(gate.kind)) throw new ArtifactValidationError(`${gate.id}.kind must be command or builtin.`);
23
+ if (gate.kind === 'command' && (!gate.command || typeof gate.command !== 'string'))
24
+ throw new ArtifactValidationError(`${gate.id} command gate requires command.`);
25
+ return { ...gate, blocking: gate.blocking !== false };
26
+ });
27
+ return { schema_version: 1, gates };
28
+ }
@@ -0,0 +1,27 @@
1
+ import { documentRevision, validateDocument } from './document.mjs';
2
+ export { documentRevision } from './document.mjs';
3
+ export const PLAN_HEADINGS = [
4
+ '# Implementation Plan',
5
+ '## Outcome',
6
+ '## Current state',
7
+ '## Proposed changes',
8
+ '## Execution sequence',
9
+ '## Task mapping',
10
+ '## Data and control flow',
11
+ '## Engineering compliance',
12
+ '## Tests and validation',
13
+ '## Risks and rollback',
14
+ '## Deliberately excluded',
15
+ '## Human decisions required'
16
+ ];
17
+ export function validateImplementationPlan(text, { workItem, engineeringText, specText, checkRevisions = true }) {
18
+ const result = validateDocument(text, PLAN_HEADINGS);
19
+ if (result.work_item !== workItem) result.errors.push('Plan belongs to a different work item.');
20
+ for (const key of ['engineering_revision', 'spec_revision'])
21
+ if (!/^[a-f0-9]{64}$/.test(result[key] ?? '')) result.errors.push(`${key} must be SHA256.`);
22
+ if (checkRevisions && result.engineering_revision !== documentRevision(engineeringText))
23
+ result.errors.push('Engineering revision is stale.');
24
+ if (checkRevisions && result.spec_revision !== documentRevision(specText))
25
+ result.errors.push('Spec revision is stale.');
26
+ return result;
27
+ }
@@ -0,0 +1,12 @@
1
+ import { validateDocument } from './document.mjs';
2
+ export function validatePrdDocument(text) {
3
+ return validateDocument(text, [
4
+ '# Product Requirements',
5
+ '## Purpose',
6
+ '## Users',
7
+ '## Scope',
8
+ '## Requirements',
9
+ '## Constraints',
10
+ '## Non-goals'
11
+ ]);
12
+ }
@@ -0,0 +1,63 @@
1
+ import { parseDocument, stringify } from 'yaml';
2
+ import { ArtifactValidationError } from './backlog.mjs';
3
+
4
+ const PHASES = new Set([
5
+ 'engineering_bootstrap',
6
+ 'discovery',
7
+ 'engineering',
8
+ 'planning',
9
+ 'backlog_planning',
10
+ 'work_item_plan_approval',
11
+ 'build',
12
+ 'implementation',
13
+ 'review',
14
+ 'work_item_review',
15
+ 'reconcile',
16
+ 'migration_reconciliation',
17
+ 'complete'
18
+ ]);
19
+ const STOP_REASONS = new Set([null, 'consequential_decision', 'external_action', 'unrecoverable_blocker', 'finished']);
20
+
21
+ export function emptyState() {
22
+ return {
23
+ schema_version: 1,
24
+ execution: { id: null, phase: 'discovery', step: 'define_project', workflow_hash: null },
25
+ active: { work_item: null, task: null },
26
+ stop_reason: null,
27
+ migration: { status: 'not_required' }
28
+ };
29
+ }
30
+
31
+ export function parseState(text, { source = 'state.yaml' } = {}) {
32
+ const document = parseDocument(text, { prettyErrors: false, uniqueKeys: true });
33
+ if (document.errors.length) throw new ArtifactValidationError(`${source} is invalid: ${document.errors[0].message}`);
34
+ const value = document.toJS();
35
+ if (!value || typeof value !== 'object' || Array.isArray(value))
36
+ throw new ArtifactValidationError(`${source} must be a mapping.`);
37
+ if (value.schema_version !== 1) throw new ArtifactValidationError(`${source} schema_version must be 1.`);
38
+ const phase = value.execution?.phase;
39
+ if (!PHASES.has(phase)) throw new ArtifactValidationError(`${source} execution.phase is invalid.`);
40
+ if (!STOP_REASONS.has(value.stop_reason ?? null))
41
+ throw new ArtifactValidationError(`${source} stop_reason is invalid.`);
42
+ if (!['not_required', 'pending_reconciliation', 'completed'].includes(value.migration?.status ?? 'not_required'))
43
+ throw new ArtifactValidationError(`${source} migration.status is invalid.`);
44
+ return {
45
+ schema_version: 1,
46
+ execution: {
47
+ id: value.execution?.id ?? null,
48
+ phase,
49
+ step: value.execution?.step ?? null,
50
+ workflow_hash: value.execution?.workflow_hash ?? null
51
+ },
52
+ active: {
53
+ work_item: value.active?.work_item ?? null,
54
+ task: value.active?.task ?? null
55
+ },
56
+ stop_reason: value.stop_reason ?? null,
57
+ migration: value.migration ?? { status: 'not_required' }
58
+ };
59
+ }
60
+
61
+ export function stringifyState(state) {
62
+ return stringify(state, { lineWidth: 0 });
63
+ }
@@ -0,0 +1,90 @@
1
+ import { parseDocument } from 'yaml';
2
+ import { ArtifactValidationError } from './backlog.mjs';
3
+
4
+ const STATES = new Set(['pending', 'in_progress', 'completed']);
5
+ const TASK_ID = /^T\d{3,}$/;
6
+
7
+ function fail(message) {
8
+ throw new ArtifactValidationError(message);
9
+ }
10
+ function requireObject(value, label) {
11
+ if (!value || typeof value !== 'object' || Array.isArray(value)) fail(`${label} must be a mapping.`);
12
+ return value;
13
+ }
14
+ function requireString(value, label) {
15
+ if (typeof value !== 'string' || !value.trim()) fail(`${label} must be a non-empty string.`);
16
+ return value.trim();
17
+ }
18
+
19
+ export function parseTasks(text, { source = 'tasks.yaml', expectedWorkItem = null } = {}) {
20
+ const document = parseDocument(text, { prettyErrors: false, uniqueKeys: true });
21
+ if (document.errors.length) fail(`${source} is invalid: ${document.errors[0].message}`);
22
+ const value = requireObject(document.toJS(), source);
23
+ if (value.schema_version !== 1) fail(`${source} schema_version must be 1.`);
24
+ const workItem = requireString(value.work_item, `${source} work_item`);
25
+ if (expectedWorkItem && workItem !== expectedWorkItem)
26
+ fail(`${source} belongs to ${workItem}, expected ${expectedWorkItem}.`);
27
+ if (!Array.isArray(value.tasks)) fail(`${source} tasks must be a list.`);
28
+ const ids = new Set();
29
+ const tasks = value.tasks.map((raw, index) => {
30
+ const label = `tasks[${index}]`;
31
+ const task = requireObject(raw, label);
32
+ const id = requireString(task.id, `${label}.id`);
33
+ if (!TASK_ID.test(id)) fail(`${label}.id must use T followed by a zero-padded numeric sequence, e.g. T003.`);
34
+ if (ids.has(id)) fail(`${source} contains duplicate task ID '${id}'.`);
35
+ ids.add(id);
36
+ const title = requireString(task.title, `${label}.title`);
37
+ const state = task.state ?? task.status;
38
+ if (!STATES.has(state)) fail(`${label}.state must be pending, in_progress, or completed.`);
39
+ if (!Array.isArray(task.depends_on ?? [])) fail(`${label}.depends_on must be a list.`);
40
+ const dependencies = [...new Set(task.depends_on ?? [])];
41
+ if (dependencies.length !== (task.depends_on ?? []).length) fail(`${id} contains duplicate task dependencies.`);
42
+ for (const dependency of dependencies) {
43
+ if (!TASK_ID.test(dependency)) fail(`${id} depends on invalid task ID '${dependency}'.`);
44
+ if (dependency === id) fail(`${id} cannot depend on itself.`);
45
+ }
46
+ const implementation = task.implementation ?? 'commit';
47
+ if (!['commit', 'none', 'legacy'].includes(implementation))
48
+ fail(`${id}.implementation must be commit, none or legacy.`);
49
+ if (implementation === 'legacy' && state !== 'completed')
50
+ fail(`${id}: legacy is reserved for completed migrated tasks.`);
51
+ return {
52
+ id,
53
+ title,
54
+ state,
55
+ depends_on: dependencies,
56
+ implementation,
57
+ ...(task.legacy_commit ? { legacy_commit: task.legacy_commit } : {})
58
+ };
59
+ });
60
+ const byId = new Map(tasks.map((task) => [task.id, task]));
61
+ for (const task of tasks)
62
+ for (const dependency of task.depends_on)
63
+ if (!byId.has(dependency)) fail(`${task.id} depends on unknown task '${dependency}'.`);
64
+ validateTaskDag(tasks);
65
+ if (tasks.filter((task) => task.state === 'in_progress').length > 1)
66
+ fail('Only one mutating task may be in_progress.');
67
+ return { schema_version: 1, work_item: workItem, tasks };
68
+ }
69
+
70
+ function validateTaskDag(tasks) {
71
+ const indegree = new Map(tasks.map((task) => [task.id, task.depends_on.length]));
72
+ const dependents = new Map(tasks.map((task) => [task.id, []]));
73
+ for (const task of tasks) for (const dependency of task.depends_on) dependents.get(dependency).push(task.id);
74
+ const queue = tasks.filter((task) => task.depends_on.length === 0).map((task) => task.id);
75
+ let visited = 0;
76
+ while (queue.length) {
77
+ const id = queue.shift();
78
+ visited += 1;
79
+ for (const dependent of dependents.get(id)) {
80
+ const next = indegree.get(dependent) - 1;
81
+ indegree.set(dependent, next);
82
+ if (next === 0) queue.push(dependent);
83
+ }
84
+ }
85
+ if (visited !== tasks.length) fail('tasks.yaml task dependencies contain a cycle.');
86
+ }
87
+
88
+ export function qualifiedTaskId(workItem, task) {
89
+ return `${workItem}-${task}`;
90
+ }