@caiqueoak/flow 0.3.2 → 0.4.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 +4 -3
- package/package.json +4 -1
- package/skills/flow/SKILL.md +2 -2
- package/skills/flow/references/graph.md +12 -0
- package/src/cli.mjs +72 -28
- package/src/graph.mjs +161 -0
package/README.md
CHANGED
|
@@ -38,7 +38,7 @@ Flow internally loads only the guidance needed for discovery, planning, build, r
|
|
|
38
38
|
|
|
39
39
|
When authorized, Flow keeps a concise artifact model: product truth in `PRD.md`, engineering truth in `ENGINEERING.md`, decisions in `DECISIONS.md`, execution context in `STATE.md`, the canonical work-item DAG in `BACKLOG.yaml`, and a human-readable derived projection in `GRAPH.md`. Work items use `SPEC.md` plus `TASKS.yaml`. `SUMMARY.md` is not part of the model.
|
|
40
40
|
|
|
41
|
-
In `GRAPH.md`, work-item states are consistent: complete is green, in progress is blue, blocked is red when unfinished dependencies remain, and pending is yellow when all dependencies are complete and the item is ready to execute. Each Mermaid card contains only the work-item ID and canonical title. Every dependency arrow goes from dependency to dependent item and uses the color and line style of its source card.
|
|
41
|
+
In `GRAPH.md`, work-item states are consistent: complete is green, in progress is blue, blocked is red when unfinished dependencies remain, and pending is yellow when all dependencies are complete and the item is ready to execute. Each Mermaid card contains only the work-item ID and canonical title. Every dependency arrow goes from dependency to dependent item and uses the color and line style of its source card. `flow graph` deterministically regenerates this projection from `BACKLOG.yaml`, using straight Mermaid arrows only.
|
|
42
42
|
|
|
43
43
|
## Update
|
|
44
44
|
|
|
@@ -48,9 +48,9 @@ Updates are explicit:
|
|
|
48
48
|
flow update
|
|
49
49
|
```
|
|
50
50
|
|
|
51
|
-
`flow update`
|
|
51
|
+
`flow update` updates the installation that provides the active CLI: the global package for `flow update`, or the project dependency for `npx flow update`. It uses npm's update operation and refreshes `/flow` for every coding agent configured in `.flow/config.yaml` without modifying canonical project state. If the project lockfile and the package on disk disagree, Flow safely reinstalls only its own package before updating.
|
|
52
52
|
|
|
53
|
-
On Windows, npm is invoked through
|
|
53
|
+
On Windows, npm is invoked through `cmd.exe` without Node's `shell: true` option, so `npm.cmd` executes without the `DEP0190` warning. When Flow is installed as a project dependency, `npx flow update` is equivalent.
|
|
54
54
|
|
|
55
55
|
There is no background update check or automatic update mechanism.
|
|
56
56
|
|
|
@@ -65,6 +65,7 @@ Use Conventional Commit prefixes in the PR title (and retained commits): `fix:`
|
|
|
65
65
|
```text
|
|
66
66
|
flow init
|
|
67
67
|
flow update
|
|
68
|
+
flow graph
|
|
68
69
|
flow --version
|
|
69
70
|
flow --help
|
|
70
71
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@caiqueoak/flow",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Readability-first, agent-agnostic software development workflow for coding agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
"bin": {
|
|
11
11
|
"flow": "./src/cli.mjs"
|
|
12
12
|
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"yaml": "^2.9.0"
|
|
15
|
+
},
|
|
13
16
|
"files": [
|
|
14
17
|
"src",
|
|
15
18
|
"skills",
|
package/skills/flow/SKILL.md
CHANGED
|
@@ -47,7 +47,7 @@ Graph/control data is YAML:
|
|
|
47
47
|
|
|
48
48
|
Do not create `SUMMARY.md`, completion logs, ad-hoc progress documents, handoff documents, reports, or any other new project artifact unless the developer explicitly requests or authorizes them. Completed `SPEC.md` files retain concise Overview and Validation sections.
|
|
49
49
|
|
|
50
|
-
`GRAPH.md` is derived only. It must never become an independent source of truth
|
|
50
|
+
`GRAPH.md` is derived only. It must never become an independent source of truth. Whenever work-item existence, title, dependencies, or status change, update `BACKLOG.yaml` first and then run `flow graph --path .` to regenerate it. Never edit `GRAPH.md` manually. Before changing the backlog, read `references/graph.md` and follow that contract exactly.
|
|
51
51
|
|
|
52
52
|
## Work item model
|
|
53
53
|
|
|
@@ -132,7 +132,7 @@ Load only the reference needed for current state:
|
|
|
132
132
|
5. Route to the minimal internal reference.
|
|
133
133
|
6. Claim selected work before parallel execution.
|
|
134
134
|
7. Continue planning, build, gates, review, fixes/reconciliation, and completion without another invocation.
|
|
135
|
-
8. Recompute work-item states and readiness after every meaningful transition, synchronize `BACKLOG.yaml`, `
|
|
135
|
+
8. Recompute work-item states and readiness after every meaningful transition, synchronize `BACKLOG.yaml`, run `flow graph --path .`, then synchronize `STATE.md`, and continue if ready work exists.
|
|
136
136
|
9. Do not stop or return control merely to announce that a task/work item completed or to describe the next step. Stop only when developer input is required for a consequential decision, an external approval is required, an unrecoverable blocker prevents useful progress, or no ready work remains.
|
|
137
137
|
|
|
138
138
|
When execution stops, report the stopping reason and the smallest relevant status summary. During uninterrupted execution, avoid progress-only messages.
|
|
@@ -3,6 +3,18 @@
|
|
|
3
3
|
Every Flow `GRAPH.md` must be a derived, human-readable projection of
|
|
4
4
|
`.flow/BACKLOG.yaml`. It never becomes an independent source of truth.
|
|
5
5
|
|
|
6
|
+
## Generation
|
|
7
|
+
|
|
8
|
+
After every change to work-item existence, title, status, or dependencies, run:
|
|
9
|
+
|
|
10
|
+
```text
|
|
11
|
+
flow graph --path .
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The command validates the backlog DAG and regenerates the complete file. Do
|
|
15
|
+
not edit `GRAPH.md` directly. It always emits Mermaid with `curve: 'linear'`;
|
|
16
|
+
do not replace that setting or introduce curved arrows.
|
|
17
|
+
|
|
6
18
|
## Card content
|
|
7
19
|
|
|
8
20
|
Each Mermaid card contains exactly two lines:
|
package/src/cli.mjs
CHANGED
|
@@ -54,9 +54,11 @@ function question(rl, message) {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
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
|
+
}
|
|
57
60
|
return execFileSync(npmCommand(), npmArgs, {
|
|
58
|
-
...options
|
|
59
|
-
shell: process.platform === 'win32'
|
|
61
|
+
...options
|
|
60
62
|
});
|
|
61
63
|
}
|
|
62
64
|
|
|
@@ -190,17 +192,6 @@ async function initProject() {
|
|
|
190
192
|
else { info(); info('Flow is ready. Open a configured coding agent and invoke /flow.'); }
|
|
191
193
|
}
|
|
192
194
|
|
|
193
|
-
function dependencySection(root) {
|
|
194
|
-
const file = path.join(root, 'package.json');
|
|
195
|
-
if (!fs.existsSync(file)) return '--save-dev';
|
|
196
|
-
try {
|
|
197
|
-
const pkg = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
198
|
-
if (pkg.dependencies?.[PACKAGE_NAME]) return '--save';
|
|
199
|
-
if (pkg.optionalDependencies?.[PACKAGE_NAME]) return '--save-optional';
|
|
200
|
-
} catch { /* use default */ }
|
|
201
|
-
return '--save-dev';
|
|
202
|
-
}
|
|
203
|
-
|
|
204
195
|
function containingNodeModules(packageRoot) {
|
|
205
196
|
let current = path.resolve(packageRoot);
|
|
206
197
|
while (true) {
|
|
@@ -211,44 +202,88 @@ function containingNodeModules(packageRoot) {
|
|
|
211
202
|
}
|
|
212
203
|
}
|
|
213
204
|
|
|
214
|
-
function
|
|
215
|
-
const
|
|
216
|
-
if (fs.existsSync(
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
+
}
|
|
224
218
|
|
|
219
|
+
function globalUpdatePlan(root) {
|
|
225
220
|
try {
|
|
226
221
|
const globalNodeModules = path.resolve(runNpm(['root', '--global'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim());
|
|
227
222
|
const globalPackageRoot = path.join(globalNodeModules, '@caiqueoak', 'flow');
|
|
228
223
|
if (path.resolve(ROOT).startsWith(`${globalNodeModules}${path.sep}`) && fs.existsSync(path.join(globalPackageRoot, 'package.json'))) {
|
|
229
224
|
return {
|
|
230
|
-
npmArgs: ['
|
|
225
|
+
npmArgs: ['update', '--global', PACKAGE_NAME],
|
|
231
226
|
cwd: root,
|
|
232
227
|
packageRoot: globalPackageRoot,
|
|
233
228
|
mode: 'global'
|
|
234
229
|
};
|
|
235
230
|
}
|
|
236
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;
|
|
237
238
|
|
|
238
239
|
const nodeModules = containingNodeModules(ROOT);
|
|
239
240
|
if (nodeModules) {
|
|
240
241
|
const installRoot = path.dirname(nodeModules);
|
|
241
242
|
return {
|
|
242
|
-
npmArgs: ['
|
|
243
|
+
npmArgs: ['update', PACKAGE_NAME],
|
|
243
244
|
cwd: installRoot,
|
|
244
245
|
packageRoot: ROOT,
|
|
245
246
|
mode: 'local'
|
|
246
247
|
};
|
|
247
248
|
}
|
|
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
|
+
|
|
249
260
|
fail('cannot determine how this Flow CLI was installed. Reinstall @caiqueoak/flow with npm, then run flow update again.');
|
|
250
261
|
}
|
|
251
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
|
+
|
|
252
287
|
function update() {
|
|
253
288
|
const root = projectRoot();
|
|
254
289
|
const config = readConfig(root);
|
|
@@ -257,8 +292,10 @@ function update() {
|
|
|
257
292
|
|
|
258
293
|
const plan = updatePlan(root);
|
|
259
294
|
info(`Updating ${PACKAGE_NAME} (${plan.mode} installation)...`);
|
|
260
|
-
try {
|
|
261
|
-
|
|
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.'); }
|
|
262
299
|
|
|
263
300
|
if (!fs.existsSync(path.join(plan.packageRoot, 'package.json'))) fail(`updated package not found at ${plan.packageRoot}.`);
|
|
264
301
|
const latest = JSON.parse(fs.readFileSync(path.join(plan.packageRoot, 'package.json'), 'utf8'));
|
|
@@ -268,8 +305,14 @@ function update() {
|
|
|
268
305
|
info(`Flow updated to ${latest.version}.`);
|
|
269
306
|
}
|
|
270
307
|
|
|
308
|
+
async function graph() {
|
|
309
|
+
const { writeGraph } = await import('./graph.mjs');
|
|
310
|
+
const result = writeGraph(projectRoot());
|
|
311
|
+
info(`Generated ${result.graphPath}`);
|
|
312
|
+
}
|
|
313
|
+
|
|
271
314
|
function help() {
|
|
272
|
-
info(`Flow ${VERSION}\n\nUsage:\n flow init [--path <project>] [--runtime codex,claude]\n flow update [--path <project>]\n flow --version\n\nflow init creates only .flow/config.yaml and installs the project-local /flow skill for selected coding agents.\nIf .flow already exists, init only adds coding-agent integrations and exits without prompting when all built-in integrations are already configured.\nflow update updates the installation that provides the Flow CLI (project-local or global) and refreshes every configured project-local skill.\
|
|
315
|
+
info(`Flow ${VERSION}\n\nUsage:\n flow init [--path <project>] [--runtime codex,claude]\n flow update [--path <project>]\n flow graph [--path <project>]\n flow --version\n\nflow init creates only .flow/config.yaml and installs the project-local /flow skill for selected coding agents.\nIf .flow already exists, init only adds coding-agent integrations and exits without prompting when all built-in integrations are already configured.\nflow update updates the installation that provides the Flow CLI (project-local or global) and refreshes every configured project-local skill.\nflow graph deterministically projects .flow/BACKLOG.yaml into .flow/GRAPH.md. There is no flow install command and no automatic/background update mechanism.`);
|
|
273
316
|
}
|
|
274
317
|
|
|
275
318
|
try {
|
|
@@ -277,6 +320,7 @@ try {
|
|
|
277
320
|
else if (hasFlag('--version') || hasFlag('-v')) info(VERSION);
|
|
278
321
|
else if (args[0] === 'init') await initProject();
|
|
279
322
|
else if (args[0] === 'update') update();
|
|
323
|
+
else if (args[0] === 'graph') await graph();
|
|
280
324
|
else fail(`unknown command '${args[0]}'. Run flow --help.`);
|
|
281
325
|
} catch (error) {
|
|
282
326
|
if (error?.code === 'ABORT_ERR') {
|
package/src/graph.mjs
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parseDocument } from 'yaml';
|
|
4
|
+
|
|
5
|
+
const KINDS = new Set(['feature', 'technical', 'maintenance']);
|
|
6
|
+
const STATUSES = new Set(['completed', 'in_progress', 'pending', 'blocked']);
|
|
7
|
+
|
|
8
|
+
export class GraphValidationError extends Error {
|
|
9
|
+
constructor(message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'GraphValidationError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function compareIds(left, right) { return left < right ? -1 : left > right ? 1 : 0; }
|
|
16
|
+
function fail(message) { throw new GraphValidationError(message); }
|
|
17
|
+
function requireString(value, label) {
|
|
18
|
+
if (typeof value !== 'string' || !value.trim()) fail(`${label} must be a non-empty string.`);
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
function requireObject(value, label) {
|
|
22
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) fail(`${label} must be a mapping.`);
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseBacklog(text) {
|
|
27
|
+
const document = parseDocument(text, { prettyErrors: false, uniqueKeys: true });
|
|
28
|
+
if (document.errors.length) fail(`BACKLOG.yaml is invalid: ${document.errors[0].message}`);
|
|
29
|
+
const backlog = requireObject(document.toJS(), 'BACKLOG.yaml');
|
|
30
|
+
if (backlog.schema_version !== 1) fail('BACKLOG.yaml schema_version must be 1.');
|
|
31
|
+
requireString(backlog.source_of_truth, 'BACKLOG.yaml source_of_truth');
|
|
32
|
+
if (!Array.isArray(backlog.work_items)) fail('BACKLOG.yaml work_items must be a list.');
|
|
33
|
+
|
|
34
|
+
const ids = new Set();
|
|
35
|
+
const items = backlog.work_items.map((raw, index) => {
|
|
36
|
+
const label = `work_items[${index}]`;
|
|
37
|
+
const item = requireObject(raw, label);
|
|
38
|
+
const id = requireString(item.id, `${label}.id`);
|
|
39
|
+
if (ids.has(id)) fail(`BACKLOG.yaml contains duplicate work-item ID '${id}'.`);
|
|
40
|
+
ids.add(id);
|
|
41
|
+
const folder = requireString(item.folder, `${label}.folder`);
|
|
42
|
+
if (!KINDS.has(item.kind)) fail(`${label}.kind must be feature, technical, or maintenance.`);
|
|
43
|
+
const title = requireString(item.title, `${label}.title`);
|
|
44
|
+
if (!STATUSES.has(item.status)) fail(`${label}.status must be completed, in_progress, pending, or blocked.`);
|
|
45
|
+
if (!Number.isInteger(item.priority) || item.priority < 1) fail(`${label}.priority must be a positive integer.`);
|
|
46
|
+
if (!Array.isArray(item.depends_on)) fail(`${label}.depends_on must be a list.`);
|
|
47
|
+
const dependencySet = new Set();
|
|
48
|
+
for (const dependency of item.depends_on) {
|
|
49
|
+
requireString(dependency, `${label}.depends_on entry`);
|
|
50
|
+
if (dependency === id) fail(`Work item '${id}' cannot depend on itself.`);
|
|
51
|
+
if (dependencySet.has(dependency)) fail(`Work item '${id}' lists dependency '${dependency}' more than once.`);
|
|
52
|
+
dependencySet.add(dependency);
|
|
53
|
+
}
|
|
54
|
+
return { id, folder, kind: item.kind, title, status: item.status, priority: item.priority, depends_on: [...dependencySet] };
|
|
55
|
+
});
|
|
56
|
+
const byId = new Map(items.map((item) => [item.id, item]));
|
|
57
|
+
for (const item of items) for (const dependency of item.depends_on) {
|
|
58
|
+
if (!byId.has(dependency)) fail(`Work item '${item.id}' depends on unknown work item '${dependency}'.`);
|
|
59
|
+
}
|
|
60
|
+
return items;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function topology(items) {
|
|
64
|
+
const byId = new Map(items.map((item) => [item.id, item]));
|
|
65
|
+
const remaining = new Map(items.map((item) => [item.id, item.depends_on.length]));
|
|
66
|
+
const dependents = new Map(items.map((item) => [item.id, []]));
|
|
67
|
+
for (const item of items) for (const dependency of item.depends_on) dependents.get(dependency).push(item.id);
|
|
68
|
+
for (const ids of dependents.values()) ids.sort(compareIds);
|
|
69
|
+
const ready = items.filter((item) => item.depends_on.length === 0).map((item) => item.id).sort(compareIds);
|
|
70
|
+
const levels = new Map(ready.map((id) => [id, 0]));
|
|
71
|
+
const orderedIds = [];
|
|
72
|
+
while (ready.length) {
|
|
73
|
+
const id = ready.shift();
|
|
74
|
+
orderedIds.push(id);
|
|
75
|
+
for (const dependent of dependents.get(id)) {
|
|
76
|
+
levels.set(dependent, Math.max(levels.get(dependent) ?? 0, levels.get(id) + 1));
|
|
77
|
+
const count = remaining.get(dependent) - 1;
|
|
78
|
+
remaining.set(dependent, count);
|
|
79
|
+
if (count === 0) { ready.push(dependent); ready.sort(compareIds); }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (orderedIds.length !== items.length) fail('BACKLOG.yaml work-item dependencies contain a cycle.');
|
|
83
|
+
const ordered = orderedIds.map((id) => byId.get(id)).sort((left, right) => (levels.get(left.id) - levels.get(right.id)) || compareIds(left.id, right.id));
|
|
84
|
+
return { ordered, byId };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function displayStatus(item, byId) {
|
|
88
|
+
if (item.status === 'completed') return 'complete';
|
|
89
|
+
if (item.status === 'in_progress') return 'active';
|
|
90
|
+
const derived = item.depends_on.every((id) => byId.get(id).status === 'completed') ? 'pending' : 'blocked';
|
|
91
|
+
if (item.status !== derived) fail(`Work item '${item.id}' is declared ${item.status} but derives as ${derived}.`);
|
|
92
|
+
return derived;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function escapeMermaid(text) {
|
|
96
|
+
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const CLASS_DEFINITIONS = [
|
|
100
|
+
' classDef complete fill:#dcfce7,stroke:#16a34a,color:#14532d;',
|
|
101
|
+
' classDef active fill:#dbeafe,stroke:#2563eb,color:#1e3a8a;',
|
|
102
|
+
' classDef pending fill:#fef3c7,stroke:#d97706,color:#78350f;',
|
|
103
|
+
' classDef blocked fill:#fee2e2,stroke:#dc2626,color:#7f1d1d;'
|
|
104
|
+
];
|
|
105
|
+
const LINK_STYLES = {
|
|
106
|
+
complete: 'stroke:#16a34a,stroke-width:2px',
|
|
107
|
+
active: 'stroke:#2563eb,stroke-width:3px',
|
|
108
|
+
pending: 'stroke:#d97706,stroke-width:2px,stroke-dasharray:6 3',
|
|
109
|
+
blocked: 'stroke:#dc2626,stroke-width:2px,stroke-dasharray:2 3'
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export function generateGraphMarkdown(backlogText) {
|
|
113
|
+
const items = parseBacklog(backlogText);
|
|
114
|
+
const { ordered, byId } = topology(items);
|
|
115
|
+
const statuses = new Map(ordered.map((item) => [item.id, displayStatus(item, byId)]));
|
|
116
|
+
const edges = ordered.flatMap((source) => ordered
|
|
117
|
+
.filter((target) => target.depends_on.includes(source.id))
|
|
118
|
+
.map((target) => ({ source: source.id, target: target.id, status: statuses.get(source.id) })));
|
|
119
|
+
const lines = [
|
|
120
|
+
'# Work-item dependency graph', '', '## Status', '',
|
|
121
|
+
'- <span style="color:#16a34a">Complete</span> — all work accepted.',
|
|
122
|
+
'- <span style="color:#2563eb">In progress</span> — active delivery path.',
|
|
123
|
+
'- <span style="color:#d97706">Pending</span> — all dependencies complete; ready to start.',
|
|
124
|
+
'- <span style="color:#dc2626">Blocked</span> — one or more dependencies remain incomplete.',
|
|
125
|
+
'', '```mermaid',
|
|
126
|
+
"%%{init: {'flowchart': {'curve': 'linear', 'nodeSpacing': 32, 'rankSpacing': 54}} }%%",
|
|
127
|
+
'flowchart TD'
|
|
128
|
+
];
|
|
129
|
+
for (const item of ordered) lines.push(` ${item.id}["${escapeMermaid(item.id)}<br/>${escapeMermaid(item.title)}"]`);
|
|
130
|
+
lines.push('');
|
|
131
|
+
for (const edge of edges) lines.push(` ${edge.source} --> ${edge.target}`);
|
|
132
|
+
lines.push('', ...CLASS_DEFINITIONS);
|
|
133
|
+
for (const status of ['complete', 'active', 'pending', 'blocked']) {
|
|
134
|
+
const ids = ordered.filter((item) => statuses.get(item.id) === status).map((item) => item.id);
|
|
135
|
+
if (ids.length) lines.push(` class ${ids.join(',')} ${status};`);
|
|
136
|
+
}
|
|
137
|
+
for (const status of ['complete', 'active', 'pending', 'blocked']) {
|
|
138
|
+
const indices = edges.map((edge, index) => edge.status === status ? index : null).filter((index) => index !== null);
|
|
139
|
+
if (indices.length) lines.push(` linkStyle ${indices.join(',')} ${LINK_STYLES[status]};`);
|
|
140
|
+
}
|
|
141
|
+
lines.push(
|
|
142
|
+
'```', '',
|
|
143
|
+
'Cards in the same vertical rank have no dependency between them and can be',
|
|
144
|
+
'worked in parallel. Arrow color and line style are inherited from the source',
|
|
145
|
+
'card: solid green for complete, solid blue for active, dashed yellow for',
|
|
146
|
+
'pending, and dotted red for blocked.', ''
|
|
147
|
+
);
|
|
148
|
+
return lines.join('\n');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function writeGraph(projectRoot) {
|
|
152
|
+
const flowDir = path.join(projectRoot, '.flow');
|
|
153
|
+
const backlogPath = path.join(flowDir, 'BACKLOG.yaml');
|
|
154
|
+
if (!fs.existsSync(backlogPath)) fail(`Backlog not found: ${backlogPath}`);
|
|
155
|
+
const markdown = generateGraphMarkdown(fs.readFileSync(backlogPath, 'utf8'));
|
|
156
|
+
const graphPath = path.join(flowDir, 'GRAPH.md');
|
|
157
|
+
const temporaryPath = path.join(flowDir, `.GRAPH.md.${process.pid}.${Date.now()}.tmp`);
|
|
158
|
+
fs.writeFileSync(temporaryPath, markdown, 'utf8');
|
|
159
|
+
fs.renameSync(temporaryPath, graphPath);
|
|
160
|
+
return { graphPath, markdown };
|
|
161
|
+
}
|