@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.
- package/README.md +44 -42
- package/package.json +14 -2
- package/skills/flow/SKILL.md +10 -137
- package/skills/flow/build/step-01-execute-task.md +9 -0
- package/skills/flow/discovery/step-01-project.md +5 -0
- package/skills/flow/discovery/step-02-await-approval.md +3 -0
- package/skills/flow/engineering/profiles/readability-first.md +20 -0
- package/skills/flow/engineering/step-02-synthesize.md +37 -0
- package/skills/flow/engineering/step-05-present.md +3 -0
- package/skills/flow/engineering/technology-defaults.md +44 -0
- package/skills/flow/invariants.md +16 -0
- package/skills/flow/migration/step-01-reconcile.md +7 -0
- package/skills/flow/planning/step-01-plan-work-item.md +9 -0
- package/skills/flow/planning/step-02-prepare-plan.md +33 -0
- package/skills/flow/planning/step-03-await-approval.md +3 -0
- package/skills/flow/reconcile/step-01-reconcile.md +7 -0
- package/skills/flow/review/step-01-review-work-item.md +5 -0
- package/src/artifacts/backlog.mjs +162 -0
- package/src/artifacts/document.mjs +30 -0
- package/src/artifacts/engineering.mjs +26 -0
- package/src/artifacts/gates.mjs +28 -0
- package/src/artifacts/implementation-plan.mjs +27 -0
- package/src/artifacts/prd.mjs +12 -0
- package/src/artifacts/state.mjs +63 -0
- package/src/artifacts/tasks.mjs +90 -0
- package/src/cli.mjs +34 -309
- package/src/commands/gates.mjs +68 -0
- package/src/commands/graph.mjs +83 -0
- package/src/commands/init.mjs +111 -0
- package/src/commands/migrate.mjs +189 -0
- package/src/commands/route.mjs +135 -0
- package/src/commands/status.mjs +32 -0
- package/src/commands/trace.mjs +44 -0
- package/src/commands/validate.mjs +174 -0
- package/src/shared/cli-io.mjs +86 -0
- package/src/shared/profiles.mjs +22 -0
- package/src/shared/project-config.mjs +39 -0
- package/src/shared/project-path.mjs +10 -0
- package/src/shared/skill-installer.mjs +34 -0
- package/skills/flow/references/build.md +0 -7
- package/skills/flow/references/discovery.md +0 -15
- package/skills/flow/references/graph.md +0 -62
- package/skills/flow/references/planning.md +0 -7
- package/skills/flow/references/reconcile.md +0 -5
- package/skills/flow/references/review.md +0 -5
package/src/cli.mjs
CHANGED
|
@@ -2,324 +2,49 @@
|
|
|
2
2
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
-
import { execFileSync } from 'node:child_process';
|
|
6
5
|
import { fileURLToPath } from 'node:url';
|
|
7
|
-
import
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
claude: { label: 'Claude Code', skillsPath: '.claude/skills' }
|
|
6
|
+
import { CliError, info } from './shared/cli-io.mjs';
|
|
7
|
+
|
|
8
|
+
const currentFile = fileURLToPath(import.meta.url);
|
|
9
|
+
const packageRoot = path.resolve(path.dirname(currentFile), '..');
|
|
10
|
+
const packageManifest = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'));
|
|
11
|
+
const commands = {
|
|
12
|
+
init: async (context) => (await import('./commands/init.mjs')).runInit(context),
|
|
13
|
+
graph: async (context) => (await import('./commands/graph.mjs')).runGraph(context),
|
|
14
|
+
status: async (context) => (await import('./commands/status.mjs')).runStatus(context),
|
|
15
|
+
validate: async (context) => (await import('./commands/validate.mjs')).runValidate(context),
|
|
16
|
+
route: async (context) => (await import('./commands/route.mjs')).runRoute(context),
|
|
17
|
+
trace: async (context) => (await import('./commands/trace.mjs')).runTrace(context),
|
|
18
|
+
migrate: async (context) => (await import('./commands/migrate.mjs')).runMigrate(context)
|
|
21
19
|
};
|
|
22
20
|
|
|
23
|
-
function fail(message, code = 1) { console.error(`flow: ${message}`); process.exit(code); }
|
|
24
|
-
function info(message = '') { console.log(message); }
|
|
25
|
-
function hasFlag(name) { return args.includes(name); }
|
|
26
|
-
function valueAfter(name) { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; }
|
|
27
|
-
function npmCommand() { return process.platform === 'win32' ? 'npm.cmd' : 'npm'; }
|
|
28
|
-
function projectRoot() { return path.resolve(valueAfter('--path') || process.cwd()); }
|
|
29
|
-
function configPath(root) { return path.join(root, '.flow', 'config.yaml'); }
|
|
30
|
-
function packagePath(root) { return path.join(root, 'node_modules', '@caiqueoak', 'flow'); }
|
|
31
|
-
|
|
32
|
-
function abortedPromptError() {
|
|
33
|
-
const error = new Error('Prompt aborted.');
|
|
34
|
-
error.code = 'ABORT_ERR';
|
|
35
|
-
return error;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function question(rl, message) {
|
|
39
|
-
return new Promise((resolve, reject) => {
|
|
40
|
-
let settled = false;
|
|
41
|
-
const finish = (callback, value) => {
|
|
42
|
-
if (settled) return;
|
|
43
|
-
settled = true;
|
|
44
|
-
rl.removeListener('close', onClose);
|
|
45
|
-
callback(value);
|
|
46
|
-
};
|
|
47
|
-
const onClose = () => finish(reject, abortedPromptError());
|
|
48
|
-
rl.once('close', onClose);
|
|
49
|
-
rl.question(message).then(
|
|
50
|
-
(answer) => finish(resolve, answer),
|
|
51
|
-
(error) => finish(reject, error)
|
|
52
|
-
);
|
|
53
|
-
});
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function runNpm(npmArgs, options = {}) {
|
|
57
|
-
if (process.platform === 'win32') {
|
|
58
|
-
return execFileSync(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', npmCommand(), ...npmArgs], options);
|
|
59
|
-
}
|
|
60
|
-
return execFileSync(npmCommand(), npmArgs, {
|
|
61
|
-
...options
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function copyDir(source, target) {
|
|
66
|
-
fs.mkdirSync(target, { recursive: true });
|
|
67
|
-
for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
|
|
68
|
-
const src = path.join(source, entry.name);
|
|
69
|
-
const dst = path.join(target, entry.name);
|
|
70
|
-
if (entry.isDirectory()) copyDir(src, dst);
|
|
71
|
-
else fs.copyFileSync(src, dst);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function quoteYaml(value) { return /^[A-Za-z0-9_.\/-]+$/.test(value) ? value : JSON.stringify(value); }
|
|
76
|
-
function defaultConfig() {
|
|
77
|
-
return { frameworkVersion: VERSION, runtimes: [], continueAcrossWorkItems: true, workItemsConcurrency: 'auto', taskConcurrency: 'auto' };
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function readConfig(root) {
|
|
81
|
-
const file = configPath(root);
|
|
82
|
-
if (!fs.existsSync(file)) return null;
|
|
83
|
-
const text = fs.readFileSync(file, 'utf8');
|
|
84
|
-
const cfg = defaultConfig();
|
|
85
|
-
const v = text.match(/^[ \t]*version:[ \t]*([^\s#]+)[ \t]*$/m);
|
|
86
|
-
if (v) cfg.frameworkVersion = v[1].replace(/^['"]|['"]$/g, '');
|
|
87
|
-
const runtimeBlock = text.match(/^runtimes:\s*\n([\s\S]*?)(?=^[A-Za-z_][A-Za-z0-9_]*:|\Z)/m)?.[1] || '';
|
|
88
|
-
const entries = runtimeBlock.split(/(?=^\s*-\s+type:)/m).filter((x) => /-\s+type:/.test(x));
|
|
89
|
-
for (const entry of entries) {
|
|
90
|
-
const type = entry.match(/-\s+type:\s*([^\s#]+)/)?.[1]?.replace(/^['"]|['"]$/g, '');
|
|
91
|
-
const skillsPath = entry.match(/skills_path:\s*([^\n#]+)/)?.[1]?.trim().replace(/^['"]|['"]$/g, '');
|
|
92
|
-
if (type && skillsPath) cfg.runtimes.push({ type, skills_path: skillsPath });
|
|
93
|
-
}
|
|
94
|
-
return cfg;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function writeConfig(root, config) {
|
|
98
|
-
const lines = ['schema_version: 1', 'framework:', ' name: flow', ` version: ${VERSION}`, 'runtimes:'];
|
|
99
|
-
if (!config.runtimes.length) lines.push(' []');
|
|
100
|
-
else for (const runtime of config.runtimes) {
|
|
101
|
-
lines.push(` - type: ${quoteYaml(runtime.type)}`);
|
|
102
|
-
lines.push(` skills_path: ${quoteYaml(runtime.skills_path)}`);
|
|
103
|
-
}
|
|
104
|
-
lines.push('autonomy:', ' continue_across_work_items: true', ' stop_on:', ' - consequential_decision', ' - external_approval', ' - unrecoverable_blocker', ' - no_ready_work', 'parallelism:', ' strategy: maximum_safe', ' max_concurrent_work_items: auto', ' max_concurrent_tasks_per_work_item: auto', 'efficiency:', ' token_usage: optimize', ' prefer_primary_orchestrator: true', ' delegate_only_when_beneficial: true', '');
|
|
105
|
-
fs.mkdirSync(path.join(root, '.flow'), { recursive: true });
|
|
106
|
-
fs.writeFileSync(configPath(root), lines.join('\n'), 'utf8');
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function installRuntimeSkill(root, runtime, packageRoot = ROOT) {
|
|
110
|
-
const target = path.join(root, runtime.skills_path, 'flow');
|
|
111
|
-
copyDir(path.join(packageRoot, 'skills', 'flow'), target);
|
|
112
|
-
return target;
|
|
113
|
-
}
|
|
114
|
-
function parseRuntimeFlag() {
|
|
115
|
-
const raw = valueAfter('--runtime');
|
|
116
|
-
return raw ? raw.split(',').map((x) => x.trim().toLowerCase()).filter(Boolean) : null;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function missingBuiltinRuntimes(existing) {
|
|
120
|
-
const existingTypes = new Set(existing.map((r) => r.type));
|
|
121
|
-
return Object.keys(RUNTIME_DEFINITIONS).filter((type) => !existingTypes.has(type));
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
async function promptMultiSelect(existing) {
|
|
125
|
-
const existingTypes = new Set(existing.map((r) => r.type));
|
|
126
|
-
const options = Object.entries(RUNTIME_DEFINITIONS).filter(([type]) => !existingTypes.has(type)).map(([value, def]) => ({ value, label: def.label }));
|
|
127
|
-
options.push({ value: 'custom', label: 'Custom coding agent / skills path' });
|
|
128
|
-
info(existing.length ? 'Select coding agents to add:' : 'Select coding agents:');
|
|
129
|
-
options.forEach((opt, i) => info(` [ ] ${i + 1}. ${opt.label}`));
|
|
130
|
-
info(' (Select multiple with comma-separated numbers, e.g. 1,2)');
|
|
131
|
-
const rl = readline.createInterface({ input: inputStream, output: outputStream });
|
|
132
|
-
try {
|
|
133
|
-
while (true) {
|
|
134
|
-
const answer = (await question(rl, 'Selection: ')).trim();
|
|
135
|
-
const indices = [...new Set(answer.split(',').map((v) => Number.parseInt(v.trim(), 10)).filter(Number.isInteger))];
|
|
136
|
-
if (indices.length && indices.every((n) => n >= 1 && n <= options.length)) return indices.map((n) => options[n - 1].value);
|
|
137
|
-
info('Choose one or more valid numbers.');
|
|
138
|
-
}
|
|
139
|
-
} finally { rl.close(); }
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
async function promptText(message, defaultValue = '') {
|
|
143
|
-
const rl = readline.createInterface({ input: inputStream, output: outputStream });
|
|
144
|
-
try {
|
|
145
|
-
const answer = (await question(rl, `${message}${defaultValue ? ` [${defaultValue}]` : ''}: `)).trim();
|
|
146
|
-
return answer || defaultValue;
|
|
147
|
-
} finally { rl.close(); }
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
async function resolveRuntime(type, existing) {
|
|
151
|
-
if (RUNTIME_DEFINITIONS[type]) return { type, skills_path: RUNTIME_DEFINITIONS[type].skillsPath };
|
|
152
|
-
if (type !== 'custom') fail(`unsupported runtime '${type}'. Use codex, claude, or custom.`);
|
|
153
|
-
const fallback = `custom-${existing.filter((r) => r.type.startsWith('custom')).length + 1}`;
|
|
154
|
-
const name = await promptText('Custom coding agent id', fallback);
|
|
155
|
-
while (true) {
|
|
156
|
-
const skillsPath = await promptText('Project-local skills directory', `.${name}/skills`);
|
|
157
|
-
if (!path.isAbsolute(skillsPath) && !skillsPath.split(/[\\/]/).includes('..')) return { type: name, skills_path: skillsPath };
|
|
158
|
-
info('Skills path must be relative and remain inside the project.');
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
async function initProject() {
|
|
163
|
-
const root = projectRoot();
|
|
164
|
-
const flowDir = path.join(root, '.flow');
|
|
165
|
-
const existed = fs.existsSync(flowDir);
|
|
166
|
-
const config = readConfig(root) || defaultConfig();
|
|
167
|
-
if (existed) {
|
|
168
|
-
info('Flow project already exists. Canonical project artifacts will not be created or modified.');
|
|
169
|
-
if (config.runtimes.length) info(`Configured coding agents: ${config.runtimes.map((r) => r.type).join(', ')}`);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
const requested = parseRuntimeFlag();
|
|
173
|
-
if (existed && !requested && missingBuiltinRuntimes(config.runtimes).length === 0) {
|
|
174
|
-
info('All built-in coding agents are already configured. Nothing to add.');
|
|
175
|
-
info('Use --runtime custom only when you intentionally want to add a custom coding agent.');
|
|
176
|
-
return;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
const selected = requested || await promptMultiSelect(config.runtimes);
|
|
180
|
-
const knownTypes = new Set(config.runtimes.map((r) => r.type));
|
|
181
|
-
const added = [];
|
|
182
|
-
for (const type of selected) {
|
|
183
|
-
if (knownTypes.has(type)) continue;
|
|
184
|
-
const runtime = await resolveRuntime(type, config.runtimes);
|
|
185
|
-
if (knownTypes.has(runtime.type)) continue;
|
|
186
|
-
config.runtimes.push(runtime); knownTypes.add(runtime.type); added.push(runtime);
|
|
187
|
-
}
|
|
188
|
-
fs.mkdirSync(flowDir, { recursive: true });
|
|
189
|
-
writeConfig(root, config);
|
|
190
|
-
for (const runtime of added) info(`✓ ${runtime.type}: ${path.relative(root, installRuntimeSkill(root, runtime))}`);
|
|
191
|
-
if (!added.length) info('No new coding-agent integration was added.');
|
|
192
|
-
else { info(); info('Flow is ready. Open a configured coding agent and invoke /flow.'); }
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
function containingNodeModules(packageRoot) {
|
|
196
|
-
let current = path.resolve(packageRoot);
|
|
197
|
-
while (true) {
|
|
198
|
-
if (path.basename(current).toLowerCase() === 'node_modules') return current;
|
|
199
|
-
const parent = path.dirname(current);
|
|
200
|
-
if (parent === current) return null;
|
|
201
|
-
current = parent;
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function installedPackageVersion(packageRoot) {
|
|
206
|
-
const manifest = path.join(packageRoot, 'package.json');
|
|
207
|
-
if (!fs.existsSync(manifest)) return null;
|
|
208
|
-
return JSON.parse(fs.readFileSync(manifest, 'utf8')).version || null;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
function lockedPackageVersion(root) {
|
|
212
|
-
const lockfile = path.join(root, 'package-lock.json');
|
|
213
|
-
if (!fs.existsSync(lockfile)) return null;
|
|
214
|
-
try {
|
|
215
|
-
return JSON.parse(fs.readFileSync(lockfile, 'utf8')).packages?.[`node_modules/${PACKAGE_NAME}`]?.version || null;
|
|
216
|
-
} catch { return null; }
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function globalUpdatePlan(root) {
|
|
220
|
-
try {
|
|
221
|
-
const globalNodeModules = path.resolve(runNpm(['root', '--global'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim());
|
|
222
|
-
const globalPackageRoot = path.join(globalNodeModules, '@caiqueoak', 'flow');
|
|
223
|
-
if (path.resolve(ROOT).startsWith(`${globalNodeModules}${path.sep}`) && fs.existsSync(path.join(globalPackageRoot, 'package.json'))) {
|
|
224
|
-
return {
|
|
225
|
-
npmArgs: ['update', '--global', PACKAGE_NAME],
|
|
226
|
-
cwd: root,
|
|
227
|
-
packageRoot: globalPackageRoot,
|
|
228
|
-
mode: 'global'
|
|
229
|
-
};
|
|
230
|
-
}
|
|
231
|
-
} catch { /* fall through to local installation detection */ }
|
|
232
|
-
return null;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
function updatePlan(root) {
|
|
236
|
-
const global = globalUpdatePlan(root);
|
|
237
|
-
if (global) return global;
|
|
238
|
-
|
|
239
|
-
const nodeModules = containingNodeModules(ROOT);
|
|
240
|
-
if (nodeModules) {
|
|
241
|
-
const installRoot = path.dirname(nodeModules);
|
|
242
|
-
return {
|
|
243
|
-
npmArgs: ['update', PACKAGE_NAME],
|
|
244
|
-
cwd: installRoot,
|
|
245
|
-
packageRoot: ROOT,
|
|
246
|
-
mode: 'local'
|
|
247
|
-
};
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
const projectPackageRoot = packagePath(root);
|
|
251
|
-
if (fs.existsSync(path.join(projectPackageRoot, 'package.json'))) {
|
|
252
|
-
return {
|
|
253
|
-
npmArgs: ['update', PACKAGE_NAME],
|
|
254
|
-
cwd: root,
|
|
255
|
-
packageRoot: projectPackageRoot,
|
|
256
|
-
mode: 'project'
|
|
257
|
-
};
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
fail('cannot determine how this Flow CLI was installed. Reinstall @caiqueoak/flow with npm, then run flow update again.');
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
function repairDivergentProjectInstall(plan) {
|
|
264
|
-
if (plan.mode === 'global') return;
|
|
265
|
-
const expected = lockedPackageVersion(plan.cwd);
|
|
266
|
-
const actual = installedPackageVersion(plan.packageRoot);
|
|
267
|
-
if (!expected || !actual || expected === actual) return;
|
|
268
|
-
|
|
269
|
-
const backup = path.join(plan.cwd, `.flow-update-backup-${process.pid}-${Date.now()}`);
|
|
270
|
-
info(`Repairing divergent ${PACKAGE_NAME} installation (${actual} on disk, ${expected} in package-lock.json)...`);
|
|
271
|
-
fs.renameSync(plan.packageRoot, backup);
|
|
272
|
-
try {
|
|
273
|
-
runNpm(['install'], { cwd: plan.cwd, stdio: 'inherit' });
|
|
274
|
-
if (installedPackageVersion(plan.packageRoot) !== expected) {
|
|
275
|
-
throw new Error(`npm install did not restore ${PACKAGE_NAME}@${expected}.`);
|
|
276
|
-
}
|
|
277
|
-
fs.rmSync(backup, { recursive: true, force: true });
|
|
278
|
-
} catch (error) {
|
|
279
|
-
try {
|
|
280
|
-
fs.rmSync(plan.packageRoot, { recursive: true, force: true });
|
|
281
|
-
fs.renameSync(backup, plan.packageRoot);
|
|
282
|
-
} catch { /* preserve the original npm error below */ }
|
|
283
|
-
throw error;
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
function update() {
|
|
288
|
-
const root = projectRoot();
|
|
289
|
-
const config = readConfig(root);
|
|
290
|
-
if (!config) fail('this project is not initialized. Run flow init first.');
|
|
291
|
-
if (!config.runtimes.length) fail('no coding agents are configured. Run flow init to add one.');
|
|
292
|
-
|
|
293
|
-
const plan = updatePlan(root);
|
|
294
|
-
info(`Updating ${PACKAGE_NAME} (${plan.mode} installation)...`);
|
|
295
|
-
try {
|
|
296
|
-
repairDivergentProjectInstall(plan);
|
|
297
|
-
runNpm(plan.npmArgs, { cwd: plan.cwd, stdio: 'inherit' });
|
|
298
|
-
} catch { fail('npm update failed. Existing project state and installed skills were not intentionally removed.'); }
|
|
299
|
-
|
|
300
|
-
if (!fs.existsSync(path.join(plan.packageRoot, 'package.json'))) fail(`updated package not found at ${plan.packageRoot}.`);
|
|
301
|
-
const latest = JSON.parse(fs.readFileSync(path.join(plan.packageRoot, 'package.json'), 'utf8'));
|
|
302
|
-
for (const runtime of config.runtimes) info(`✓ ${runtime.type}: ${path.relative(root, installRuntimeSkill(root, runtime, plan.packageRoot))}`);
|
|
303
|
-
const text = fs.readFileSync(configPath(root), 'utf8');
|
|
304
|
-
fs.writeFileSync(configPath(root), text.replace(/(^framework:\s*\n(?:.*\n)*?\s+version:\s*)[^\n]+/m, `$1${latest.version}`), 'utf8');
|
|
305
|
-
info(`Flow updated to ${latest.version}.`);
|
|
306
|
-
}
|
|
307
|
-
|
|
308
21
|
function help() {
|
|
309
|
-
info(
|
|
22
|
+
info(
|
|
23
|
+
`Flow ${packageManifest.version}\n\nUse the project-local installation: npx --no-install flow <command>\n\nCommands:\n init Configure Flow and install/refresh local agent integrations.\n migrate Normalize older artifacts; /flow then reconciles legacy decisions.\n status Show progress and dependency/external blockers.\n validate Check schemas, approvals, DAGs, traceability and deterministic gates.\n graph Regenerate the derived work-item dependency graph.\n route Return the next repository-resumable agent step (--json available).\n trace Resolve W015-T003 to a HEAD-reachable commit with both Flow trailers.\n\nCommon options: --path <project>, --help, --version\ninit options: --runtime codex,claude --profile readability-first --existing-code improve|preserve\n Readability First: reusable preferences for SRP, semantic naming, cohesion and simple vertical slices.\n improve: recommend clearer structure while retaining behavior and external contracts.\n preserve: retain consistent existing conventions unless a concrete problem warrants change.\n Neither option authorizes refactoring; engineering changes require human approval.\nvalidate options: --json --pre-commit W015-T003\ntrace options: W015-T003 --json\n\nDiscovery → PRD approval → engineering approval → complete backlog → implementation-plan approval → serial implementation → review.\nUse /flow for agent workflows and engineering changes. Approved engineering.md is authoritative.\nUpdate the npm package with your package manager, then rerun init to refresh integrations.`
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function runCli(args = process.argv.slice(2)) {
|
|
28
|
+
if (!args.length || args.includes('--help') || args.includes('-h')) return help();
|
|
29
|
+
if (args.includes('--version') || args.includes('-v')) return info(packageManifest.version);
|
|
30
|
+
const command = commands[args[0]];
|
|
31
|
+
if (!command) throw new CliError(`unknown command '${args[0]}'. Run flow --help.`);
|
|
32
|
+
return command({
|
|
33
|
+
args: args.slice(1),
|
|
34
|
+
packageRoot,
|
|
35
|
+
packageName: packageManifest.name,
|
|
36
|
+
version: packageManifest.version
|
|
37
|
+
});
|
|
310
38
|
}
|
|
311
39
|
|
|
312
40
|
try {
|
|
313
|
-
|
|
314
|
-
else if (hasFlag('--version') || hasFlag('-v')) info(VERSION);
|
|
315
|
-
else if (args[0] === 'init') await initProject();
|
|
316
|
-
else if (args[0] === 'update') update();
|
|
317
|
-
else fail(`unknown command '${args[0]}'. Run flow --help.`);
|
|
41
|
+
await runCli();
|
|
318
42
|
} catch (error) {
|
|
319
43
|
if (error?.code === 'ABORT_ERR') {
|
|
320
44
|
info('\nFlow command canceled.');
|
|
321
45
|
process.exitCode = 130;
|
|
322
|
-
} else {
|
|
323
|
-
|
|
324
|
-
|
|
46
|
+
} else if (error instanceof CliError) {
|
|
47
|
+
console.error(`flow: ${error.message}`);
|
|
48
|
+
process.exitCode = error.exitCode || 1;
|
|
49
|
+
} else throw error;
|
|
325
50
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { fail } from '../shared/cli-io.mjs';
|
|
5
|
+
import { parseGates } from '../artifacts/gates.mjs';
|
|
6
|
+
|
|
7
|
+
function runCommandGate(root, gate) {
|
|
8
|
+
const shell = process.platform === 'win32' ? process.env.ComSpec || 'cmd.exe' : '/bin/sh';
|
|
9
|
+
const args = process.platform === 'win32' ? ['/d', '/s', '/c', gate.command] : ['-lc', gate.command];
|
|
10
|
+
const result = spawnSync(shell, args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
11
|
+
return {
|
|
12
|
+
id: gate.id,
|
|
13
|
+
kind: gate.kind,
|
|
14
|
+
blocking: gate.blocking,
|
|
15
|
+
status: result.status === 0 ? 'passed' : 'failed',
|
|
16
|
+
exit_code: result.status,
|
|
17
|
+
stdout: result.stdout,
|
|
18
|
+
stderr: result.stderr
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function runBuiltinGate(root, gate) {
|
|
23
|
+
if (gate.rule !== 'kebab-case-files')
|
|
24
|
+
return {
|
|
25
|
+
id: gate.id,
|
|
26
|
+
kind: gate.kind,
|
|
27
|
+
blocking: gate.blocking,
|
|
28
|
+
status: 'unsupported',
|
|
29
|
+
message: `Unknown builtin rule '${gate.rule}'.`
|
|
30
|
+
};
|
|
31
|
+
const excluded = new Set(['.git', '.flow', 'node_modules']);
|
|
32
|
+
const violations = [];
|
|
33
|
+
function walk(directory) {
|
|
34
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
35
|
+
if (excluded.has(entry.name)) continue;
|
|
36
|
+
const full = path.join(directory, entry.name);
|
|
37
|
+
if (entry.isDirectory()) {
|
|
38
|
+
walk(full);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (entry.name.startsWith('.')) continue;
|
|
42
|
+
const ext = path.extname(entry.name);
|
|
43
|
+
const stem = ext ? entry.name.slice(0, -ext.length) : entry.name;
|
|
44
|
+
if (/^[A-Z0-9_.-]+$/.test(entry.name)) continue;
|
|
45
|
+
const valid = stem.split('.').every((segment) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(segment));
|
|
46
|
+
if (!valid) violations.push(path.relative(root, full));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
walk(root);
|
|
50
|
+
return {
|
|
51
|
+
id: gate.id,
|
|
52
|
+
kind: gate.kind,
|
|
53
|
+
blocking: gate.blocking,
|
|
54
|
+
status: violations.length ? 'failed' : 'passed',
|
|
55
|
+
violations
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function evaluateGates(root) {
|
|
60
|
+
const file = path.join(root, '.flow', 'gates.yaml');
|
|
61
|
+
if (!fs.existsSync(file)) fail('gates.yaml does not exist.');
|
|
62
|
+
const { gates } = parseGates(fs.readFileSync(file, 'utf8'));
|
|
63
|
+
return gates.map((gate) => {
|
|
64
|
+
if (gate.kind === 'command') return runCommandGate(root, gate);
|
|
65
|
+
if (gate.kind === 'builtin') return runBuiltinGate(root, gate);
|
|
66
|
+
throw new Error(`Unsupported gate kind '${gate.kind}'.`);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
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 {
|
|
6
|
+
ArtifactValidationError,
|
|
7
|
+
deriveExecutionStatus,
|
|
8
|
+
parseBacklog,
|
|
9
|
+
topologicalOrder
|
|
10
|
+
} from '../artifacts/backlog.mjs';
|
|
11
|
+
|
|
12
|
+
export { ArtifactValidationError as GraphValidationError };
|
|
13
|
+
|
|
14
|
+
function escapeMermaid(text) {
|
|
15
|
+
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const CLASSES = [
|
|
19
|
+
' classDef completed fill:#dcfce7,stroke:#16a34a,color:#14532d;',
|
|
20
|
+
' classDef in_progress fill:#dbeafe,stroke:#2563eb,color:#1e3a8a;',
|
|
21
|
+
' classDef ready fill:#fef3c7,stroke:#d97706,color:#78350f;',
|
|
22
|
+
' classDef blocked fill:#fee2e2,stroke:#dc2626,color:#7f1d1d;'
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
export function generateGraphMarkdown(backlogText) {
|
|
26
|
+
const { work_items: items } = parseBacklog(backlogText);
|
|
27
|
+
const ordered = topologicalOrder(items);
|
|
28
|
+
const byId = new Map(items.map((item) => [item.id, item]));
|
|
29
|
+
const statuses = new Map(items.map((item) => [item.id, deriveExecutionStatus(item, byId).status]));
|
|
30
|
+
const edges = ordered.flatMap((source) =>
|
|
31
|
+
ordered
|
|
32
|
+
.filter((target) => target.depends_on.includes(source.id))
|
|
33
|
+
.map((target) => ({ source: source.id, target: target.id }))
|
|
34
|
+
);
|
|
35
|
+
const lines = [
|
|
36
|
+
'# Work-item dependency graph',
|
|
37
|
+
'',
|
|
38
|
+
'> Derived from `../backlog.yaml`. Do not edit this file manually.',
|
|
39
|
+
'',
|
|
40
|
+
'## Status',
|
|
41
|
+
'',
|
|
42
|
+
'- **Completed** — accepted work.',
|
|
43
|
+
'- **In Progress** — currently executing.',
|
|
44
|
+
'- **Ready** — pending work with all dependencies satisfied.',
|
|
45
|
+
'- **Blocked** — pending work with at least one incomplete dependency or explicit external blocker.',
|
|
46
|
+
'',
|
|
47
|
+
'```mermaid',
|
|
48
|
+
"%%{init: {'flowchart': {'curve': 'linear', 'nodeSpacing': 32, 'rankSpacing': 54}} }%%",
|
|
49
|
+
'flowchart TD'
|
|
50
|
+
];
|
|
51
|
+
for (const item of ordered) lines.push(` ${item.id}["${escapeMermaid(item.id)}<br/>${escapeMermaid(item.title)}"]`);
|
|
52
|
+
lines.push('');
|
|
53
|
+
for (const edge of edges) lines.push(` ${edge.source} --> ${edge.target}`);
|
|
54
|
+
lines.push('', ...CLASSES);
|
|
55
|
+
for (const status of ['completed', 'in_progress', 'ready', 'blocked']) {
|
|
56
|
+
const ids = ordered.filter((item) => statuses.get(item.id) === status).map((item) => item.id);
|
|
57
|
+
if (ids.length) lines.push(` class ${ids.join(',')} ${status};`);
|
|
58
|
+
}
|
|
59
|
+
lines.push(
|
|
60
|
+
'```',
|
|
61
|
+
'',
|
|
62
|
+
'Dependency arrows are authoritative for execution ordering. Flow executes one mutating task at a time; independent read-only research or review may run in parallel.',
|
|
63
|
+
''
|
|
64
|
+
);
|
|
65
|
+
return lines.join('\n');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function writeGraph(root) {
|
|
69
|
+
const backlogPath = path.join(root, '.flow', 'backlog.yaml');
|
|
70
|
+
if (!fs.existsSync(backlogPath)) throw new ArtifactValidationError(`Backlog not found: ${backlogPath}`);
|
|
71
|
+
const graphPath = path.join(root, '.flow', 'docs', 'graph.md');
|
|
72
|
+
fs.mkdirSync(path.dirname(graphPath), { recursive: true });
|
|
73
|
+
const markdown = generateGraphMarkdown(fs.readFileSync(backlogPath, 'utf8'));
|
|
74
|
+
const temporaryPath = `${graphPath}.${process.pid}.${Date.now()}.tmp`;
|
|
75
|
+
fs.writeFileSync(temporaryPath, markdown, 'utf8');
|
|
76
|
+
fs.renameSync(temporaryPath, graphPath);
|
|
77
|
+
return { graphPath, markdown };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function runGraph({ args }) {
|
|
81
|
+
const result = writeGraph(projectRoot(args));
|
|
82
|
+
info(`Generated ${result.graphPath}`);
|
|
83
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fail, info, promptMultiSelect, promptSelect, promptText } from '../shared/cli-io.mjs';
|
|
4
|
+
import { defaultConfig, readConfig, writeConfig } from '../shared/project-config.mjs';
|
|
5
|
+
import { projectRoot, valueAfter } from '../shared/project-path.mjs';
|
|
6
|
+
import { installRuntimeSkill } from '../shared/skill-installer.mjs';
|
|
7
|
+
import { BROWNFIELD_POLICIES, ENGINEERING_PROFILES } from '../shared/profiles.mjs';
|
|
8
|
+
|
|
9
|
+
const RUNTIME_DEFINITIONS = {
|
|
10
|
+
codex: { label: 'Codex', skillsPath: '.codex/skills' },
|
|
11
|
+
claude: { label: 'Claude Code', skillsPath: '.claude/skills' }
|
|
12
|
+
};
|
|
13
|
+
function parseRuntimeFlag(args) {
|
|
14
|
+
const raw = valueAfter(args, '--runtime');
|
|
15
|
+
return raw
|
|
16
|
+
? raw
|
|
17
|
+
.split(',')
|
|
18
|
+
.map((v) => v.trim().toLowerCase())
|
|
19
|
+
.filter(Boolean)
|
|
20
|
+
: null;
|
|
21
|
+
}
|
|
22
|
+
function missingBuiltinRuntimes(existing) {
|
|
23
|
+
const types = new Set(existing.map((r) => r.type));
|
|
24
|
+
return Object.keys(RUNTIME_DEFINITIONS).filter((type) => !types.has(type));
|
|
25
|
+
}
|
|
26
|
+
async function selectRuntimes(existing) {
|
|
27
|
+
const types = new Set(existing.map((r) => r.type));
|
|
28
|
+
const options = Object.entries(RUNTIME_DEFINITIONS)
|
|
29
|
+
.filter(([type]) => !types.has(type))
|
|
30
|
+
.map(([value, d]) => ({ value, label: d.label }));
|
|
31
|
+
options.push({ value: 'custom', label: 'Custom coding agent / skills path' });
|
|
32
|
+
return promptMultiSelect({
|
|
33
|
+
title: existing.length ? 'Select coding agents to add:' : 'Select coding agents:',
|
|
34
|
+
options
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
async function resolveRuntime(type, existing) {
|
|
38
|
+
if (RUNTIME_DEFINITIONS[type]) return { type, skills_path: RUNTIME_DEFINITIONS[type].skillsPath };
|
|
39
|
+
if (type !== 'custom') fail(`unsupported runtime '${type}'. Use codex, claude, or custom.`);
|
|
40
|
+
const fallback = `custom-${existing.filter((r) => r.type.startsWith('custom')).length + 1}`;
|
|
41
|
+
const name = await promptText('Custom coding agent id', fallback);
|
|
42
|
+
while (true) {
|
|
43
|
+
const skillsPath = await promptText('Project-local skills directory', `.${name}/skills`);
|
|
44
|
+
if (!path.isAbsolute(skillsPath) && !skillsPath.split(/[\\/]/).includes('..'))
|
|
45
|
+
return { type: name, skills_path: skillsPath };
|
|
46
|
+
info('Skills path must be relative and remain inside the project.');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async function selectEngineering(args, root, config, existed) {
|
|
50
|
+
const profileFlag = valueAfter(args, '--profile');
|
|
51
|
+
const brownfieldFlag = valueAfter(args, '--existing-code');
|
|
52
|
+
if (args.includes('--brownfield'))
|
|
53
|
+
fail(
|
|
54
|
+
'Use --existing-code improve|preserve: improve recommends clearer structure; preserve keeps consistent conventions.'
|
|
55
|
+
);
|
|
56
|
+
if (existed && (profileFlag || brownfieldFlag))
|
|
57
|
+
fail('Change engineering through /flow and human approval, not init.');
|
|
58
|
+
if (profileFlag && !ENGINEERING_PROFILES[profileFlag]) fail(`unknown engineering profile '${profileFlag}'.`);
|
|
59
|
+
if (brownfieldFlag && !BROWNFIELD_POLICIES[brownfieldFlag]) fail(`unknown brownfield policy '${brownfieldFlag}'.`);
|
|
60
|
+
if (profileFlag) config.engineering.profile = ENGINEERING_PROFILES[profileFlag].id;
|
|
61
|
+
const hasProjectFiles = fs
|
|
62
|
+
.readdirSync(root)
|
|
63
|
+
.some((name) => ['src', 'app', 'lib', 'packages'].includes(name) || /\.(m?[jt]sx?|py|java|go|rs|cs)$/.test(name));
|
|
64
|
+
if (brownfieldFlag) config.engineering.existing_code_policy = brownfieldFlag;
|
|
65
|
+
else if (!existed && hasProjectFiles)
|
|
66
|
+
config.engineering.existing_code_policy = await promptSelect({
|
|
67
|
+
title: 'How should Flow treat existing code conventions?',
|
|
68
|
+
options: Object.entries(BROWNFIELD_POLICIES).map(([value, policy]) => ({
|
|
69
|
+
value,
|
|
70
|
+
label: policy.label,
|
|
71
|
+
description: policy.description
|
|
72
|
+
}))
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function runInit({ args, packageRoot }) {
|
|
77
|
+
const root = projectRoot(args);
|
|
78
|
+
const flowDirectory = path.join(root, '.flow');
|
|
79
|
+
const existed = fs.existsSync(flowDirectory);
|
|
80
|
+
const config = readConfig(root) || defaultConfig();
|
|
81
|
+
if (config.schema_version !== 2)
|
|
82
|
+
fail('Existing Flow project requires npx --no-install flow migrate before init. No files were changed.');
|
|
83
|
+
if (existed) info('Flow project already exists. Canonical project artifacts will not be created or modified.');
|
|
84
|
+
await selectEngineering(args, root, config, existed);
|
|
85
|
+
const requested = parseRuntimeFlag(args);
|
|
86
|
+
if (existed && !requested && missingBuiltinRuntimes(config.runtimes).length === 0) {
|
|
87
|
+
writeConfig(root, config);
|
|
88
|
+
for (const runtime of config.runtimes) installRuntimeSkill(root, runtime, packageRoot);
|
|
89
|
+
info('All built-in coding agents are already configured. Configuration is up to date.');
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const selected = requested || (await selectRuntimes(config.runtimes));
|
|
93
|
+
const known = new Set(config.runtimes.map((r) => r.type));
|
|
94
|
+
const added = [];
|
|
95
|
+
for (const type of selected) {
|
|
96
|
+
if (known.has(type)) continue;
|
|
97
|
+
const runtime = await resolveRuntime(type, config.runtimes);
|
|
98
|
+
if (known.has(runtime.type)) continue;
|
|
99
|
+
config.runtimes.push(runtime);
|
|
100
|
+
known.add(runtime.type);
|
|
101
|
+
added.push(runtime);
|
|
102
|
+
}
|
|
103
|
+
fs.mkdirSync(flowDirectory, { recursive: true });
|
|
104
|
+
writeConfig(root, config);
|
|
105
|
+
for (const runtime of config.runtimes)
|
|
106
|
+
info(`✓ ${runtime.type}: ${path.relative(root, installRuntimeSkill(root, runtime, packageRoot))}`);
|
|
107
|
+
info(`Engineering profile: ${ENGINEERING_PROFILES[config.engineering.profile]?.label ?? config.engineering.profile}`);
|
|
108
|
+
info(
|
|
109
|
+
'Flow is ready. Invoke /flow; engineering bootstrap runs before implementation when no approved contract exists.'
|
|
110
|
+
);
|
|
111
|
+
}
|