@caiqueoak/flow 0.4.0 → 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 (46) hide show
  1. package/README.md +44 -43
  2. package/package.json +11 -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 -316
  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 -74
  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
  46. package/src/graph.mjs +0 -161
@@ -0,0 +1,189 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { parse, stringify } from 'yaml';
4
+ import { info } from '../shared/cli-io.mjs';
5
+ import { projectRoot } from '../shared/project-path.mjs';
6
+ import { emptyState, stringifyState } from '../artifacts/state.mjs';
7
+ import { parseBacklog } from '../artifacts/backlog.mjs';
8
+ import { parseTasks } from '../artifacts/tasks.mjs';
9
+ import { generateGraphMarkdown } from './graph.mjs';
10
+ import { readConfig, writeConfig, defaultConfig } from '../shared/project-config.mjs';
11
+
12
+ const STATES = {
13
+ done: 'completed',
14
+ complete: 'completed',
15
+ completed: 'completed',
16
+ in_progress: 'in_progress',
17
+ blocked: 'pending',
18
+ pending: 'pending',
19
+ todo: 'pending'
20
+ };
21
+ function lifecycle(value) {
22
+ if (!STATES[value]) throw new Error(`Unknown legacy state '${value}'.`);
23
+ return STATES[value];
24
+ }
25
+ function number(value) {
26
+ const match = String(value).match(/\d+/);
27
+ if (!match) throw new Error(`Cannot normalize ID '${value}'.`);
28
+ return match[0].padStart(3, '0');
29
+ }
30
+ function move(from, to) {
31
+ if (!fs.existsSync(from)) return;
32
+ if (fs.existsSync(to)) throw new Error(`Migration destination already exists: ${to}`);
33
+ fs.mkdirSync(path.dirname(to), { recursive: true });
34
+ fs.renameSync(from, to);
35
+ }
36
+ function normalizeTasks(file, item) {
37
+ const raw = parse(fs.readFileSync(file, 'utf8'));
38
+ const mapping = new Map();
39
+ const ids = new Set();
40
+ for (const task of raw.tasks ?? []) {
41
+ const match = String(task.id).match(/^(?:([A-Z]\d+)-)?T(\d+)$/);
42
+ if (!match || (match[1] && number(match[1]) !== item.id.slice(1)))
43
+ throw new Error(`Invalid/cross-work-item task ID '${task.id}'.`);
44
+ const id = `T${match[2].padStart(3, '0')}`;
45
+ if (ids.has(id)) throw new Error(`Task ID collision: ${id}`);
46
+ ids.add(id);
47
+ mapping.set(task.id, id);
48
+ }
49
+ const tasks = (raw.tasks ?? []).map((task) => {
50
+ const state = lifecycle(task.state ?? task.status ?? 'pending');
51
+ const result = {
52
+ ...task,
53
+ id: mapping.get(task.id),
54
+ state,
55
+ depends_on: (task.depends_on ?? []).map((dep) => {
56
+ if (!mapping.has(dep)) throw new Error(`Unknown task dependency '${dep}'.`);
57
+ return mapping.get(dep);
58
+ }),
59
+ implementation: state === 'completed' ? 'legacy' : task.implementation === 'none' ? 'none' : 'commit'
60
+ };
61
+ if (task.commit) result.legacy_commit = task.commit;
62
+ delete result.commit;
63
+ delete result.status;
64
+ delete result.execution_id;
65
+ return result;
66
+ });
67
+ const text = stringify({ schema_version: 1, work_item: item.id, tasks }, { lineWidth: 0 });
68
+ parseTasks(text, { expectedWorkItem: item.id });
69
+ fs.writeFileSync(file, text);
70
+ }
71
+ function migrateStaged(root) {
72
+ const flow = path.join(root, '.flow');
73
+ const oldConfig = readConfig(root);
74
+ const oldFile = path.join(flow, fs.existsSync(path.join(flow, 'BACKLOG.yaml')) ? 'BACKLOG.yaml' : 'backlog.yaml');
75
+ const raw = parse(fs.readFileSync(oldFile, 'utf8'));
76
+ const mapping = new Map();
77
+ const ids = new Set();
78
+ for (const item of raw.work_items ?? []) {
79
+ const id = `W${number(item.id)}`;
80
+ if (ids.has(id)) throw new Error(`Work-item ID collision: ${id}`);
81
+ ids.add(id);
82
+ mapping.set(item.id, id);
83
+ }
84
+ const original = new Map();
85
+ const work_items = (raw.work_items ?? []).map((item) => {
86
+ const id = mapping.get(item.id);
87
+ const folder = `${id}-${
88
+ String(item.folder ?? item.title)
89
+ .replace(/^(?:\d+[A-Za-z]|[A-Za-z]\d+)-?/, '')
90
+ .toLowerCase()
91
+ .replace(/[^a-z0-9]+/g, '-')
92
+ .replace(/^-|-$/g, '') || 'work-item'
93
+ }`;
94
+ original.set(id, item.folder);
95
+ return {
96
+ id,
97
+ folder,
98
+ title: item.title,
99
+ kind: item.kind,
100
+ state: lifecycle(item.state ?? item.status ?? 'pending'),
101
+ priority: item.priority ?? 1,
102
+ depends_on: (item.depends_on ?? []).map((dep) => {
103
+ if (!mapping.has(dep)) throw new Error(`Unknown work-item dependency '${dep}'.`);
104
+ return mapping.get(dep);
105
+ }),
106
+ blockers: []
107
+ };
108
+ });
109
+ const text = stringify({ schema_version: 2, work_items }, { lineWidth: 0 });
110
+ parseBacklog(text);
111
+ const workRoot = path.join(flow, 'work-items');
112
+ for (const item of work_items) {
113
+ const previous = path.join(workRoot, original.get(item.id) ?? item.folder);
114
+ const folder = path.join(workRoot, item.folder);
115
+ if (previous !== folder && fs.existsSync(previous)) move(previous, folder);
116
+ if (!fs.existsSync(folder)) {
117
+ if (item.state !== 'pending') throw new Error(`${item.id}: nonpending legacy work has no folder.`);
118
+ continue;
119
+ }
120
+ for (const [old, next] of [
121
+ ['SPEC.md', 'spec.md'],
122
+ ['TASKS.yaml', 'tasks.yaml'],
123
+ ['DECISIONS.md', 'legacy-decisions.md']
124
+ ])
125
+ move(path.join(folder, old), path.join(folder, next));
126
+ if (fs.existsSync(path.join(folder, 'tasks.yaml'))) normalizeTasks(path.join(folder, 'tasks.yaml'), item);
127
+ }
128
+ fs.mkdirSync(path.join(flow, 'docs'), { recursive: true });
129
+ fs.writeFileSync(path.join(flow, 'docs', 'legacy-backlog.yaml'), stringify(raw, { lineWidth: 0 }));
130
+ for (const [old, next] of [
131
+ ['PRD.md', 'prd.md'],
132
+ ['ENGINEERING.md', 'legacy-engineering.md'],
133
+ ['STATE.md', 'legacy-state.md'],
134
+ ['DECISIONS.md', 'legacy-decisions.md'],
135
+ ['SUMMARY.md', 'legacy-summary.md']
136
+ ])
137
+ move(path.join(flow, old), path.join(flow, 'docs', next));
138
+ if (fs.existsSync(path.join(flow, 'GRAPH.md'))) fs.unlinkSync(path.join(flow, 'GRAPH.md'));
139
+ if (oldFile.endsWith('BACKLOG.yaml')) fs.unlinkSync(oldFile);
140
+ fs.writeFileSync(path.join(flow, 'backlog.yaml'), text);
141
+ fs.writeFileSync(path.join(flow, 'docs', 'graph.md'), generateGraphMarkdown(text));
142
+ const state = emptyState();
143
+ state.execution.phase = 'migration_reconciliation';
144
+ state.migration.status = 'pending_reconciliation';
145
+ fs.writeFileSync(path.join(flow, 'state.yaml'), stringifyState(state));
146
+ if (!fs.existsSync(path.join(flow, 'gates.yaml')))
147
+ fs.writeFileSync(path.join(flow, 'gates.yaml'), 'schema_version: 1\ngates: []\n');
148
+ const config = defaultConfig();
149
+ config.runtimes = oldConfig?.runtimes ?? [];
150
+ config.engineering.existing_code_policy = 'improve';
151
+ writeConfig(root, config);
152
+ }
153
+ export function migrateProject(root) {
154
+ const flow = path.join(root, '.flow');
155
+ if (!fs.existsSync(flow)) throw new Error('.flow does not exist.');
156
+ const config = readConfig(root);
157
+ if (
158
+ config?.schema_version === 2 &&
159
+ fs.existsSync(path.join(flow, 'backlog.yaml')) &&
160
+ parse(fs.readFileSync(path.join(flow, 'backlog.yaml'), 'utf8')).schema_version === 2
161
+ )
162
+ return { unresolved: [], unchanged: true };
163
+ const staging = fs.mkdtempSync(path.join(root, '.flow-migration-'));
164
+ const backup = path.join(staging, 'backup');
165
+ const staged = path.join(staging, '.flow');
166
+ try {
167
+ fs.cpSync(flow, staged, { recursive: true });
168
+ migrateStaged(staging);
169
+ fs.renameSync(flow, backup);
170
+ try {
171
+ fs.renameSync(staged, flow);
172
+ } catch (error) {
173
+ fs.renameSync(backup, flow);
174
+ throw error;
175
+ }
176
+ return { unresolved: ['semantic reconciliation'], unchanged: false };
177
+ } finally {
178
+ // Retain the original backup if even rollback failed; never delete the only copy.
179
+ if (!fs.existsSync(backup) || fs.existsSync(flow)) fs.rmSync(staging, { recursive: true, force: true });
180
+ }
181
+ }
182
+ export function runMigrate({ args }) {
183
+ const result = migrateProject(projectRoot(args));
184
+ info(
185
+ result.unchanged
186
+ ? 'Already migrated; no files changed.'
187
+ : 'Structural migration complete. Invoke /flow for semantic reconciliation before implementation.'
188
+ );
189
+ }
@@ -0,0 +1,135 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { info } from '../shared/cli-io.mjs';
4
+ import { projectRoot } from '../shared/project-path.mjs';
5
+ import { readConfig } from '../shared/project-config.mjs';
6
+ import { parseBacklog, deriveExecutionStatus } from '../artifacts/backlog.mjs';
7
+ import { parseTasks, qualifiedTaskId } from '../artifacts/tasks.mjs';
8
+ import { validateEngineeringDocument } from '../artifacts/engineering.mjs';
9
+ import { validatePrdDocument } from '../artifacts/prd.mjs';
10
+ import { validateImplementationPlan } from '../artifacts/implementation-plan.mjs';
11
+ import { parseState } from '../artifacts/state.mjs';
12
+
13
+ function step(phase, instruction, extra = {}) {
14
+ return { action: 'continue', phase, instruction, ...extra };
15
+ }
16
+ function awaitApproval(phase, instruction, extra = {}) {
17
+ return { action: 'stop', reason: 'consequential_decision', phase, instruction, ...extra };
18
+ }
19
+ function artifactApproval(file, validator, phase, draftInstruction, approvalInstruction) {
20
+ if (!fs.existsSync(file)) return step(phase, draftInstruction);
21
+ const result = validator(fs.readFileSync(file, 'utf8'));
22
+ if (result.errors.length) return step(phase, draftInstruction, { details: result.errors });
23
+ if (result.status !== 'approved') return awaitApproval(phase, approvalInstruction);
24
+ return null;
25
+ }
26
+ export function routeProject(root) {
27
+ const flow = path.join(root, '.flow');
28
+ const config = readConfig(root);
29
+ if (!config) return { action: 'stop', reason: 'unrecoverable_blocker', details: 'Run npx --no-install flow init.' };
30
+ if (config.schema_version !== 2)
31
+ return { action: 'stop', reason: 'unrecoverable_blocker', details: 'Run npx --no-install flow migrate.' };
32
+ const read = (relative) => fs.readFileSync(path.join(flow, relative), 'utf8');
33
+ const exists = (relative) => fs.existsSync(path.join(flow, relative));
34
+ const state = exists('state.yaml') ? parseState(read('state.yaml')) : null;
35
+ if (state?.migration.status === 'pending_reconciliation')
36
+ return step('migration_reconciliation', 'migration/step-01-reconcile.md', {
37
+ required_context: [
38
+ 'backlog.yaml',
39
+ 'docs/prd.md',
40
+ 'docs/legacy-state.md',
41
+ 'docs/legacy-decisions.md',
42
+ 'docs/legacy-engineering.md'
43
+ ]
44
+ .filter(exists)
45
+ .map((file) => `.flow/${file}`)
46
+ });
47
+ if (state?.stop_reason && state.stop_reason !== 'finished')
48
+ return { action: 'stop', reason: state.stop_reason, phase: state.execution.phase, step: state.execution.step };
49
+ const productRoute = artifactApproval(
50
+ path.join(flow, 'docs/prd.md'),
51
+ validatePrdDocument,
52
+ 'discovery',
53
+ 'discovery/step-01-project.md',
54
+ 'discovery/step-02-await-approval.md'
55
+ );
56
+ if (productRoute) return productRoute;
57
+ const engineeringRoute = artifactApproval(
58
+ path.join(flow, 'docs/engineering.md'),
59
+ validateEngineeringDocument,
60
+ 'engineering',
61
+ 'engineering/step-02-synthesize.md',
62
+ 'engineering/step-05-present.md'
63
+ );
64
+ if (engineeringRoute) return engineeringRoute;
65
+ const engineeringText = read('docs/engineering.md');
66
+ const planning = () =>
67
+ step('backlog_planning', 'planning/step-01-plan-work-item.md', {
68
+ required_context: ['.flow/docs/prd.md', '.flow/docs/engineering.md']
69
+ });
70
+ if (!exists('backlog.yaml')) return planning();
71
+ const backlog = parseBacklog(read('backlog.yaml'));
72
+ for (const item of backlog.work_items) {
73
+ if (!exists(`work-items/${item.folder}/spec.md`) || !exists(`work-items/${item.folder}/tasks.yaml`))
74
+ return planning();
75
+ parseTasks(read(`work-items/${item.folder}/tasks.yaml`), { expectedWorkItem: item.id });
76
+ }
77
+ const byId = new Map(backlog.work_items.map((item) => [item.id, item]));
78
+ const active = backlog.work_items.find((item) => item.state === 'in_progress');
79
+ if (active && deriveExecutionStatus(active, byId).status === 'blocked')
80
+ return {
81
+ action: 'stop',
82
+ reason: 'external_action',
83
+ work_item: active.id,
84
+ details: deriveExecutionStatus(active, byId).reasons
85
+ };
86
+ const item =
87
+ active ??
88
+ backlog.work_items
89
+ .filter((item) => deriveExecutionStatus(item, byId).status === 'ready')
90
+ .sort((a, b) => a.priority - b.priority || Number(a.id.slice(1)) - Number(b.id.slice(1)))[0];
91
+ if (!item)
92
+ return {
93
+ action: 'stop',
94
+ reason: backlog.work_items.every((item) => item.state === 'completed') ? 'finished' : 'external_action'
95
+ };
96
+ const base = `work-items/${item.folder}`;
97
+ const context = ['.flow/docs/engineering.md', `.flow/${base}/spec.md`, `.flow/${base}/tasks.yaml`];
98
+ const tasks = parseTasks(read(`${base}/tasks.yaml`), { expectedWorkItem: item.id });
99
+ const extra = { work_item: item.id, required_context: context };
100
+ const prepare = () => step('work_item_plan_approval', 'planning/step-02-prepare-plan.md', extra);
101
+ if (!exists(`${base}/implementation-plan.md`)) return prepare();
102
+ const plan = validateImplementationPlan(read(`${base}/implementation-plan.md`), {
103
+ workItem: item.id,
104
+ engineeringText,
105
+ specText: read(`${base}/spec.md`)
106
+ });
107
+ if (plan.errors.length) return prepare();
108
+ if (plan.status !== 'approved')
109
+ return awaitApproval('work_item_plan_approval', 'planning/step-03-await-approval.md', extra);
110
+ context.push(`.flow/${base}/implementation-plan.md`);
111
+ const taskById = new Map(tasks.tasks.map((task) => [task.id, task]));
112
+ const task =
113
+ tasks.tasks.find((task) => task.state === 'in_progress') ??
114
+ tasks.tasks
115
+ .filter(
116
+ (task) => task.state === 'pending' && task.depends_on.every((id) => taskById.get(id).state === 'completed')
117
+ )
118
+ .sort((a, b) => Number(a.id.slice(1)) - Number(b.id.slice(1)))[0];
119
+ if (task)
120
+ return step('implementation', 'build/step-01-execute-task.md', {
121
+ ...extra,
122
+ task: qualifiedTaskId(item.id, task.id)
123
+ });
124
+ if (tasks.tasks.every((task) => task.state === 'completed'))
125
+ return step('work_item_review', 'review/step-01-review-work-item.md', extra);
126
+ return step('reconcile', 'reconcile/step-01-reconcile.md', extra);
127
+ }
128
+ export function runRoute({ args }) {
129
+ const result = routeProject(projectRoot(args));
130
+ info(
131
+ args.includes('--json')
132
+ ? JSON.stringify(result, null, 2)
133
+ : `${result.action}: ${result.phase ?? result.reason}${result.work_item ? ` ${result.work_item}` : ''}`
134
+ );
135
+ }
@@ -0,0 +1,32 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { info } from '../shared/cli-io.mjs';
4
+ import { projectRoot } from '../shared/project-path.mjs';
5
+ import { deriveExecutionStatus, parseBacklog } from '../artifacts/backlog.mjs';
6
+ import { routeProject } from './route.mjs';
7
+
8
+ export function runStatus({ args }) {
9
+ const root = projectRoot(args);
10
+ if (!fs.existsSync(path.join(root, '.flow', 'backlog.yaml')))
11
+ return info(`Next: ${routeProject(root).phase ?? 'setup'}`);
12
+ const backlog = parseBacklog(fs.readFileSync(path.join(root, '.flow', 'backlog.yaml'), 'utf8'));
13
+ const byId = new Map(backlog.work_items.map((item) => [item.id, item]));
14
+ const groups = new Map(['in_progress', 'ready', 'blocked', 'completed'].map((status) => [status, []]));
15
+ for (const item of backlog.work_items) {
16
+ const derived = deriveExecutionStatus(item, byId);
17
+ groups.get(derived.status).push({ ...item, reasons: derived.reasons });
18
+ }
19
+ const lines = [];
20
+ for (const [status, items] of groups) {
21
+ if (!items.length) continue;
22
+ lines.push(status.replace('_', ' ').replace(/^./, (c) => c.toUpperCase()));
23
+ for (const item of items) {
24
+ const deps = item.reasons.filter((r) => r.type === 'dependency').map((r) => r.ref);
25
+ lines.push(` ${item.id} — ${item.title}${deps.length ? ` ← ${deps.join(', ')}` : ''}`);
26
+ for (const reason of item.reasons.filter((reason) => reason.type !== 'dependency'))
27
+ lines.push(` ${reason.type}: ${reason.description} [${reason.id}]`);
28
+ }
29
+ lines.push('');
30
+ }
31
+ info(lines.join('\n').trim());
32
+ }
@@ -0,0 +1,44 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { info, fail } from '../shared/cli-io.mjs';
3
+ import { projectRoot } from '../shared/project-path.mjs';
4
+
5
+ export function traceTask(root, qualifiedTaskId) {
6
+ if (!/^W\d{3,}-T\d{3,}$/.test(qualifiedTaskId))
7
+ fail(`invalid qualified task ID '${qualifiedTaskId}'. Expected W015-T003.`);
8
+ let output;
9
+ try {
10
+ output = execFileSync(
11
+ 'git',
12
+ ['log', 'HEAD', '--fixed-strings', `--grep=Flow-Task: ${qualifiedTaskId}`, '--format=%H%x1f%s%x1f%B%x1e'],
13
+ { cwd: root, encoding: 'utf8' }
14
+ );
15
+ } catch {
16
+ fail('git history is unavailable; task traceability requires a Git repository.');
17
+ }
18
+ const trailerPattern = new RegExp(`^Flow-Task:\\s*${qualifiedTaskId}\\s*$`, 'm');
19
+ const matches = output
20
+ .split('\x1e')
21
+ .filter(Boolean)
22
+ .map((record) => {
23
+ const [sha, subject, ...bodyParts] = record.replace(/^\n+|\n+$/g, '').split('\x1f');
24
+ return { sha, subject, body: bodyParts.join('\x1f') };
25
+ })
26
+ .filter(
27
+ (entry) =>
28
+ trailerPattern.test(entry.body) &&
29
+ new RegExp(`^Flow-Work-Item:\\s*${qualifiedTaskId.split('-')[0]}\\s*$`, 'm').test(entry.body)
30
+ );
31
+ if (!matches.length) return { task: qualifiedTaskId, commits: [], status: 'missing' };
32
+ if (matches.length > 1) return { task: qualifiedTaskId, commits: matches, status: 'ambiguous' };
33
+ return { task: qualifiedTaskId, commit: matches[0], commits: matches, status: 'resolved' };
34
+ }
35
+
36
+ export function runTrace({ args }) {
37
+ const task = args.find((arg) => !arg.startsWith('--'));
38
+ if (!task) fail('flow trace requires a qualified task ID, e.g. W015-T003.');
39
+ const result = traceTask(projectRoot(args), task);
40
+ if (args.includes('--json')) return info(JSON.stringify(result, null, 2));
41
+ if (result.status === 'missing') fail(`No reachable commit declares Flow-Task: ${task}.`);
42
+ if (result.status === 'ambiguous') fail(`Multiple reachable commits declare Flow-Task: ${task}.`);
43
+ info(`${task} -> ${result.commit.sha}\n${result.commit.subject}`);
44
+ }
@@ -0,0 +1,174 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { info, fail } from '../shared/cli-io.mjs';
4
+ import { projectRoot } from '../shared/project-path.mjs';
5
+ import { readConfig } from '../shared/project-config.mjs';
6
+ import { parseBacklog, deriveExecutionStatus } from '../artifacts/backlog.mjs';
7
+ import { parseTasks, qualifiedTaskId } from '../artifacts/tasks.mjs';
8
+ import { parseState } from '../artifacts/state.mjs';
9
+ import { parseGates } from '../artifacts/gates.mjs';
10
+ import { validateEngineeringDocument } from '../artifacts/engineering.mjs';
11
+ import { validatePrdDocument } from '../artifacts/prd.mjs';
12
+ import { validateImplementationPlan } from '../artifacts/implementation-plan.mjs';
13
+ import { traceTask } from './trace.mjs';
14
+ import { generateGraphMarkdown } from './graph.mjs';
15
+ import { evaluateGates } from './gates.mjs';
16
+
17
+ export function validateProject(root, { preCommitTask = null, skipTrace = false } = {}) {
18
+ const findings = [];
19
+ const error = (code, message) => findings.push({ level: 'error', code, message });
20
+ const flow = path.join(root, '.flow');
21
+ const exists = (relative) => fs.existsSync(path.join(flow, relative));
22
+ const read = (relative) => fs.readFileSync(path.join(flow, relative), 'utf8');
23
+ let config;
24
+ try {
25
+ config = readConfig(root);
26
+ } catch (failure) {
27
+ error('CONFIG', failure.message);
28
+ }
29
+ if (!config || config.schema_version !== 2) {
30
+ error('CONFIG', 'Initialize Flow or migrate the existing project.');
31
+ return findings;
32
+ }
33
+ if (
34
+ config.engineering.profile !== 'flow/readability-first@1' ||
35
+ !['improve', 'preserve', 'not_applicable'].includes(config.engineering.existing_code_policy)
36
+ )
37
+ error('CONFIG', 'Invalid engineering bootstrap preferences.');
38
+ for (const name of ['STATE.md', 'DECISIONS.md', 'SUMMARY.md', 'BACKLOG.yaml', 'PRD.md', 'ENGINEERING.md', 'GRAPH.md'])
39
+ if (exists(name)) error('LEGACY', `${name} must be migrated.`);
40
+ let state;
41
+ try {
42
+ state = exists('state.yaml') ? parseState(read('state.yaml')) : null;
43
+ } catch (failure) {
44
+ error('STATE', failure.message);
45
+ }
46
+ const migrationPending = state?.migration.status === 'pending_reconciliation';
47
+ const backlogRequired = [
48
+ 'backlog_planning',
49
+ 'work_item_plan_approval',
50
+ 'implementation',
51
+ 'work_item_review',
52
+ 'complete'
53
+ ].includes(state?.execution.phase);
54
+ if (!exists('backlog.yaml')) {
55
+ if (backlogRequired) error('MISSING', 'Missing backlog.yaml.');
56
+ return findings;
57
+ }
58
+ let backlog;
59
+ try {
60
+ backlog = parseBacklog(read('backlog.yaml'));
61
+ } catch (failure) {
62
+ error('BACKLOG', failure.message);
63
+ return findings;
64
+ }
65
+ const anyStarted = backlog.work_items.some((item) => item.state !== 'pending');
66
+ if (!migrationPending) {
67
+ for (const [name, validator] of [
68
+ ['prd.md', validatePrdDocument],
69
+ ['engineering.md', validateEngineeringDocument]
70
+ ]) {
71
+ if (!exists(`docs/${name}`)) {
72
+ error('MISSING', `Missing docs/${name}.`);
73
+ continue;
74
+ }
75
+ const document = validator(read(`docs/${name}`));
76
+ for (const message of document.errors) error('DOCUMENT', `${name}: ${message}`);
77
+ if (anyStarted && document.status !== 'approved')
78
+ error('APPROVAL', `${name} must be approved before implementation.`);
79
+ }
80
+ }
81
+ const byId = new Map(backlog.work_items.map((item) => [item.id, item]));
82
+ let activeTasks = 0;
83
+ for (const item of backlog.work_items) {
84
+ const base = `work-items/${item.folder}`;
85
+ const missing = ['spec.md', 'tasks.yaml'].filter((name) => !exists(`${base}/${name}`));
86
+ if (missing.length) {
87
+ if (!(migrationPending && item.state === 'pending'))
88
+ error('WORK_ITEM', `${item.id} is missing ${missing.join(', ')}.`);
89
+ continue;
90
+ }
91
+ let tasks;
92
+ try {
93
+ tasks = parseTasks(read(`${base}/tasks.yaml`), { expectedWorkItem: item.id });
94
+ } catch (failure) {
95
+ error('TASKS', failure.message);
96
+ continue;
97
+ }
98
+ const historical = tasks.tasks.length > 0 && tasks.tasks.every((task) => task.implementation === 'legacy');
99
+ activeTasks += tasks.tasks.filter((task) => task.state === 'in_progress').length;
100
+ if (tasks.tasks.some((task) => task.state === 'in_progress') && item.state !== 'in_progress')
101
+ error('STATE', `${item.id}: active task requires active work item.`);
102
+ if (item.state === 'completed' && tasks.tasks.some((task) => task.state !== 'completed'))
103
+ error('STATE', `${item.id}: completed work has incomplete tasks.`);
104
+ if (item.state === 'in_progress' && deriveExecutionStatus(item, byId).status === 'blocked')
105
+ error('STATE', `${item.id}: active work is blocked.`);
106
+ if (!historical && !migrationPending) {
107
+ const spec = read(`${base}/spec.md`);
108
+ for (const heading of [
109
+ '## Status',
110
+ '## Goal',
111
+ '## Scope',
112
+ '## Non-goals',
113
+ '## Requirements',
114
+ '## Acceptance criteria',
115
+ '## Decisions'
116
+ ])
117
+ if (!spec.split(/\r?\n/).includes(heading)) error('SPEC', `${item.id} missing ${heading}.`);
118
+ const planPath = `${base}/implementation-plan.md`;
119
+ if (!exists(planPath)) {
120
+ if (item.state !== 'pending') error('PLAN', `${item.id} requires an approved implementation plan.`);
121
+ } else if (exists('docs/engineering.md')) {
122
+ const plan = validateImplementationPlan(read(planPath), {
123
+ workItem: item.id,
124
+ engineeringText: read('docs/engineering.md'),
125
+ specText: spec,
126
+ checkRevisions: item.state !== 'completed'
127
+ });
128
+ for (const message of plan.errors) error('PLAN', `${item.id}: ${message}`);
129
+ if (item.state !== 'pending' && plan.status !== 'approved') error('PLAN', `${item.id}: plan is not approved.`);
130
+ }
131
+ }
132
+ for (const task of tasks.tasks) {
133
+ if (task.state !== 'completed' || task.implementation !== 'commit' || skipTrace) continue;
134
+ const qualified = qualifiedTaskId(item.id, task.id);
135
+ if (qualified === preCommitTask) continue;
136
+ try {
137
+ if (traceTask(root, qualified).status !== 'resolved')
138
+ error('TRACE', `${qualified}: expected exactly one HEAD-reachable commit with both Flow trailers.`);
139
+ } catch (failure) {
140
+ error('TRACE', failure.message);
141
+ }
142
+ }
143
+ }
144
+ if (activeTasks > 1) error('STATE', 'Only one mutating task may be active across the project.');
145
+ if (exists('gates.yaml')) {
146
+ try {
147
+ parseGates(read('gates.yaml'));
148
+ if (!migrationPending)
149
+ for (const gate of evaluateGates(root))
150
+ if (gate.blocking && ['failed', 'unsupported'].includes(gate.status))
151
+ error('GATE', `${gate.id}: ${gate.status}`);
152
+ } catch (failure) {
153
+ error('GATES', failure.message);
154
+ }
155
+ } else if (!migrationPending) error('MISSING', 'Missing gates.yaml.');
156
+ if (!exists('docs/graph.md') || read('docs/graph.md') !== generateGraphMarkdown(read('backlog.yaml')))
157
+ error('GRAPH', 'Graph is stale; run npx --no-install flow graph.');
158
+ return findings;
159
+ }
160
+ export function runValidate({ args }) {
161
+ const index = args.indexOf('--pre-commit');
162
+ const findings = validateProject(projectRoot(args), {
163
+ preCommitTask: index < 0 ? null : args[index + 1],
164
+ skipTrace: args.includes('--skip-trace')
165
+ });
166
+ if (args.includes('--json')) {
167
+ info(JSON.stringify({ valid: findings.length === 0, findings }, null, 2));
168
+ if (findings.length) process.exitCode = 1;
169
+ return;
170
+ }
171
+ if (!findings.length) return info('Flow project is valid.');
172
+ for (const finding of findings) info(`${finding.code}: ${finding.message}`);
173
+ fail(`${findings.length} validation finding(s).`);
174
+ }
@@ -0,0 +1,86 @@
1
+ import readline from 'node:readline/promises';
2
+ import { stdin as inputStream, stdout as outputStream } from 'node:process';
3
+
4
+ export class CliError extends Error {}
5
+ export function fail(message, code = 1) {
6
+ const error = new CliError(message);
7
+ error.exitCode = code;
8
+ throw error;
9
+ }
10
+ export function info(message = '') {
11
+ console.log(message);
12
+ }
13
+ function abortedPromptError() {
14
+ const error = new Error('Prompt aborted.');
15
+ error.code = 'ABORT_ERR';
16
+ return error;
17
+ }
18
+ function question(readlineInterface, message) {
19
+ return new Promise((resolve, reject) => {
20
+ let settled = false;
21
+ const finish = (callback, value) => {
22
+ if (settled) return;
23
+ settled = true;
24
+ readlineInterface.removeListener('close', onClose);
25
+ callback(value);
26
+ };
27
+ const onClose = () => finish(reject, abortedPromptError());
28
+ readlineInterface.once('close', onClose);
29
+ readlineInterface.question(message).then(
30
+ (answer) => finish(resolve, answer),
31
+ (error) => finish(reject, error)
32
+ );
33
+ });
34
+ }
35
+ export async function promptText(message, defaultValue = '') {
36
+ const rl = readline.createInterface({ input: inputStream, output: outputStream });
37
+ try {
38
+ const answer = (await question(rl, `${message}${defaultValue ? ` [${defaultValue}]` : ''}: `)).trim();
39
+ return answer || defaultValue;
40
+ } finally {
41
+ rl.close();
42
+ }
43
+ }
44
+ export async function promptSelect({ title, options, defaultIndex = 0 }) {
45
+ info(title);
46
+ options.forEach((option, index) =>
47
+ info(
48
+ ` ${index === defaultIndex ? '›' : ' '} ${index + 1}. ${option.label}${option.description ? `\n ${option.description}` : ''}`
49
+ )
50
+ );
51
+ const rl = readline.createInterface({ input: inputStream, output: outputStream });
52
+ try {
53
+ while (true) {
54
+ const answer = (await question(rl, `Selection [${defaultIndex + 1}]: `)).trim();
55
+ const index = answer ? Number.parseInt(answer, 10) - 1 : defaultIndex;
56
+ if (Number.isInteger(index) && index >= 0 && index < options.length) return options[index].value;
57
+ info('Choose a valid number.');
58
+ }
59
+ } finally {
60
+ rl.close();
61
+ }
62
+ }
63
+ export async function promptMultiSelect({ title, options }) {
64
+ info(title);
65
+ options.forEach((option, index) => info(` [ ] ${index + 1}. ${option.label}`));
66
+ info(' (Select multiple with comma-separated numbers, e.g. 1,2)');
67
+ const rl = readline.createInterface({ input: inputStream, output: outputStream });
68
+ try {
69
+ while (true) {
70
+ const answer = (await question(rl, 'Selection: ')).trim();
71
+ const indices = [
72
+ ...new Set(
73
+ answer
74
+ .split(',')
75
+ .map((value) => Number.parseInt(value.trim(), 10))
76
+ .filter(Number.isInteger)
77
+ )
78
+ ];
79
+ if (indices.length && indices.every((index) => index >= 1 && index <= options.length))
80
+ return indices.map((index) => options[index - 1].value);
81
+ info('Choose one or more valid numbers.');
82
+ }
83
+ } finally {
84
+ rl.close();
85
+ }
86
+ }