@smartmemory/compose 0.2.24-beta → 0.2.26-beta
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/.claude/skills/context-budget/SKILL.md +19 -0
- package/bin/compose.js +41 -0
- package/contracts/roadmap-deps.schema.json +31 -0
- package/contracts/roadmap-graph-frontmatter.schema.json +27 -0
- package/lib/context-budget.js +99 -24
- package/lib/roadmap-graph/collect.js +178 -0
- package/lib/roadmap-graph/config.js +57 -0
- package/lib/roadmap-graph/index.js +93 -0
- package/lib/roadmap-graph/model.js +128 -0
- package/lib/roadmap-graph/render.js +60 -0
- package/lib/roadmap-graph/template.html +194 -0
- package/package.json +1 -1
- package/server/compose-mcp-tools.js +51 -0
- package/server/compose-mcp.js +26 -0
- package/server/mcp-tool-policy.js +1 -1
|
@@ -43,6 +43,25 @@ node <compose-root>/lib/context-budget.js <project-root> \
|
|
|
43
43
|
- Token estimate is a dependency-free ~4-chars-per-token heuristic — **relative budgeting,
|
|
44
44
|
not billing-accurate**. Use it to rank, not to bill.
|
|
45
45
|
|
|
46
|
+
### Surface vs. live — read this before recommending cuts
|
|
47
|
+
|
|
48
|
+
The report prints **two numbers per component**: `surface` (full file on disk) and `live`
|
|
49
|
+
(what actually loads into context at session start). They differ because of **progressive
|
|
50
|
+
disclosure**:
|
|
51
|
+
|
|
52
|
+
- **Skills & agents** load only their **frontmatter (name + description)** at startup; the body
|
|
53
|
+
loads when the skill/agent is invoked. So a 5K-token skill costs ~40 live tokens until used.
|
|
54
|
+
**Deleting it reclaims its description, not its body.**
|
|
55
|
+
- **Rules & the CLAUDE.md chain** are inlined into the system prompt at startup → `live == surface`.
|
|
56
|
+
- **MCP tool schemas** load fully *when eagerly loaded*, but tool-deferral harnesses (e.g.
|
|
57
|
+
ToolSearch) load them on demand — flagged `mcp-may-defer`. Treat their live cost as an upper bound.
|
|
58
|
+
|
|
59
|
+
**Always reason about cuts in `live` tokens, not `surface`.** TOP 5 RECLAIMS is ranked by live.
|
|
60
|
+
The common trap: a catalog of 50 skills shows a huge `surface` total but a tiny `live` total —
|
|
61
|
+
mass-deleting them reclaims almost nothing while destroying capability. The real micro-levers are
|
|
62
|
+
usually **trimming verbose agent/skill descriptions**, **removing genuinely-unused entries** (their
|
|
63
|
+
descriptions are pure live cost), and **disabling unused MCP servers** (the biggest live line items).
|
|
64
|
+
|
|
46
65
|
### Step 3 — Interpret the report
|
|
47
66
|
|
|
48
67
|
The report prints three buckets and a TOP 5 RECLAIMS list. Walk the user through:
|
package/bin/compose.js
CHANGED
|
@@ -1154,6 +1154,47 @@ if (cmd === 'roadmap') {
|
|
|
1154
1154
|
process.exit(0)
|
|
1155
1155
|
}
|
|
1156
1156
|
|
|
1157
|
+
// compose roadmap graph — generate a self-contained dependency-graph HTML
|
|
1158
|
+
// from feature.json + deps.yaml + frontmatter (COMP-ROADMAP-GRAPH-1).
|
|
1159
|
+
if (subcmd === 'graph') {
|
|
1160
|
+
const { generateRoadmapGraph, checkRoadmapGraph } = await import('../lib/roadmap-graph/index.js')
|
|
1161
|
+
let cwd
|
|
1162
|
+
const projIdx = args.indexOf('--project')
|
|
1163
|
+
if (projIdx !== -1 && args[projIdx + 1]) {
|
|
1164
|
+
cwd = resolve(args[projIdx + 1])
|
|
1165
|
+
} else {
|
|
1166
|
+
cwd = resolveCwdWithWorkspace(args).root
|
|
1167
|
+
}
|
|
1168
|
+
let out
|
|
1169
|
+
const outIdx = args.indexOf('--out')
|
|
1170
|
+
if (outIdx !== -1 && args[outIdx + 1]) out = args[outIdx + 1]
|
|
1171
|
+
const checkMode = args.includes('--check')
|
|
1172
|
+
try {
|
|
1173
|
+
if (checkMode) {
|
|
1174
|
+
const r = checkRoadmapGraph(cwd, { out })
|
|
1175
|
+
for (const w of r.warnings) console.warn(` warning: ${w}`)
|
|
1176
|
+
if (r.matches) {
|
|
1177
|
+
console.log(`roadmap-graph up to date (${r.nodeCount} nodes, ${r.edgeCount} edges): ${r.path}`)
|
|
1178
|
+
process.exit(0)
|
|
1179
|
+
}
|
|
1180
|
+
console.error(r.diffSummary)
|
|
1181
|
+
process.exit(1)
|
|
1182
|
+
}
|
|
1183
|
+
const r = generateRoadmapGraph(cwd, { out })
|
|
1184
|
+
for (const w of r.warnings) console.warn(` warning: ${w}`)
|
|
1185
|
+
console.log(`Generated ${r.path} — ${r.nodeCount} nodes, ${r.edgeCount} edges` +
|
|
1186
|
+
(r.droppedCount ? ` (${r.droppedCount} completed/superseded/killed dropped)` : ''))
|
|
1187
|
+
process.exit(0)
|
|
1188
|
+
} catch (err) {
|
|
1189
|
+
if (err && err.code === 'DANGLING_EDGE') {
|
|
1190
|
+
console.error(err.message)
|
|
1191
|
+
console.error('\nFix the deps.yaml edge(s) above, or complete/register the missing feature(s).')
|
|
1192
|
+
process.exit(1)
|
|
1193
|
+
}
|
|
1194
|
+
throw err
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1157
1198
|
// Default: compose roadmap (show status)
|
|
1158
1199
|
const { parseRoadmap, filterBuildable } = await import('../lib/roadmap-parser.js')
|
|
1159
1200
|
const { buildDag, topoSort } = await import('../lib/build-dag.js')
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "https://regression.io/compose/roadmap-deps.schema.json",
|
|
4
|
+
"title": "Roadmap graph dependency manifest (deps.yaml)",
|
|
5
|
+
"description": "Per-feature-folder edge declarations for the roadmap dependency graph. All keys optional; absent means no edges of that kind. COMP-ROADMAP-GRAPH-1.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"properties": {
|
|
9
|
+
"depends_on": {
|
|
10
|
+
"type": "array",
|
|
11
|
+
"description": "Feature codes that are prerequisites of this feature.",
|
|
12
|
+
"items": { "$ref": "#/definitions/featureCode" }
|
|
13
|
+
},
|
|
14
|
+
"concurrent_with": {
|
|
15
|
+
"type": "array",
|
|
16
|
+
"description": "Sibling feature codes with no blocking relationship.",
|
|
17
|
+
"items": { "$ref": "#/definitions/featureCode" }
|
|
18
|
+
},
|
|
19
|
+
"blocks": {
|
|
20
|
+
"type": "array",
|
|
21
|
+
"description": "Feature codes that this feature is a prerequisite of (inverse convenience).",
|
|
22
|
+
"items": { "$ref": "#/definitions/featureCode" }
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"definitions": {
|
|
26
|
+
"featureCode": {
|
|
27
|
+
"type": "string",
|
|
28
|
+
"pattern": "^[A-Z][A-Z0-9]*(-[A-Z0-9]+)*$"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "https://regression.io/compose/roadmap-graph-frontmatter.schema.json",
|
|
4
|
+
"title": "Roadmap graph node display metadata",
|
|
5
|
+
"description": "Per-feature display fields for the roadmap dependency graph, sourced from design.md YAML frontmatter or feature.json extension keys. All optional; extra keys ignored. COMP-ROADMAP-GRAPH-1.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": true,
|
|
8
|
+
"properties": {
|
|
9
|
+
"name": {
|
|
10
|
+
"type": "string",
|
|
11
|
+
"description": "Human-readable feature title."
|
|
12
|
+
},
|
|
13
|
+
"priority": {
|
|
14
|
+
"type": "string",
|
|
15
|
+
"enum": ["high", "medium", "low"],
|
|
16
|
+
"description": "Drives node border weight."
|
|
17
|
+
},
|
|
18
|
+
"track": {
|
|
19
|
+
"type": "string",
|
|
20
|
+
"description": "Project-defined grouping; drives node color via the track->color map."
|
|
21
|
+
},
|
|
22
|
+
"desc": {
|
|
23
|
+
"type": "string",
|
|
24
|
+
"description": "One-paragraph blurb shown in the node tooltip."
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
package/lib/context-budget.js
CHANGED
|
@@ -52,6 +52,48 @@ function contentHash(text) {
|
|
|
52
52
|
return createHash('sha1').update(text || '').digest('hex');
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Extract the YAML frontmatter block (including the `---` fences) from a skill or
|
|
57
|
+
* agent file. This is what Claude Code surfaces at session start — name +
|
|
58
|
+
* description — under progressive disclosure; the body loads only on invocation.
|
|
59
|
+
* Returns null if there is no leading frontmatter.
|
|
60
|
+
*/
|
|
61
|
+
export function extractFrontmatter(text) {
|
|
62
|
+
if (!text || !text.startsWith('---')) return null;
|
|
63
|
+
const end = text.indexOf('\n---', 3);
|
|
64
|
+
if (end === -1) return null;
|
|
65
|
+
return text.slice(0, end + 4);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The text that is actually loaded into context at session start for a component.
|
|
70
|
+
* - skill / agent: progressive disclosure → only the frontmatter (name+description)
|
|
71
|
+
* loads until the component is invoked. Falls back to the first line if no
|
|
72
|
+
* frontmatter is present.
|
|
73
|
+
* - rule / claude-md: inlined into the CLAUDE.md context at startup → full text.
|
|
74
|
+
* - mcp-server: handled in scanMcpServers (full schema estimate).
|
|
75
|
+
*/
|
|
76
|
+
function matchFrontmatterField(fm, key) {
|
|
77
|
+
const re = new RegExp(`^${key}:[ \\t]*(.*)$`, 'mi');
|
|
78
|
+
const m = fm.match(re);
|
|
79
|
+
return m ? m[1].trim() : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function liveTextFor(kind, text) {
|
|
83
|
+
if (kind === 'skill' || kind === 'agent') {
|
|
84
|
+
const fm = extractFrontmatter(text);
|
|
85
|
+
if (fm == null) return (text || '').split('\n').find((l) => l.trim()) || '';
|
|
86
|
+
// Only name + description surface at startup — count those fields specifically
|
|
87
|
+
// (robust to extra frontmatter keys like allowed-tools). If neither is present
|
|
88
|
+
// (unusual shape), fall back to the whole block as a conservative estimate.
|
|
89
|
+
const name = matchFrontmatterField(fm, 'name');
|
|
90
|
+
const desc = matchFrontmatterField(fm, 'description');
|
|
91
|
+
if (name == null && desc == null) return fm;
|
|
92
|
+
return [name, desc].filter(Boolean).join(' ');
|
|
93
|
+
}
|
|
94
|
+
return text || '';
|
|
95
|
+
}
|
|
96
|
+
|
|
55
97
|
function makeComponent(kind, path, label, text, extraFlags = []) {
|
|
56
98
|
const lines = lineCount(text);
|
|
57
99
|
const flags = [...extraFlags];
|
|
@@ -62,7 +104,8 @@ function makeComponent(kind, path, label, text, extraFlags = []) {
|
|
|
62
104
|
path,
|
|
63
105
|
label,
|
|
64
106
|
lines,
|
|
65
|
-
tokens: estimateTokens(text),
|
|
107
|
+
tokens: estimateTokens(text), // on-disk surface (full file)
|
|
108
|
+
liveTokens: estimateTokens(liveTextFor(kind, text)), // loaded at startup
|
|
66
109
|
hash: contentHash(text),
|
|
67
110
|
flags,
|
|
68
111
|
};
|
|
@@ -190,12 +233,16 @@ function scanMcpServers(mcpConfigPath, toolCounts = {}) {
|
|
|
190
233
|
} else {
|
|
191
234
|
flags.push('tool-count-unknown');
|
|
192
235
|
}
|
|
236
|
+
// MCP tool schemas load fully at startup in most harnesses, but tool-deferral
|
|
237
|
+
// harnesses (e.g. ToolSearch) load them on demand — so the live cost may be 0.
|
|
238
|
+
flags.push('mcp-may-defer');
|
|
193
239
|
out.push({
|
|
194
240
|
kind: 'mcp-server',
|
|
195
241
|
path: mcpConfigPath,
|
|
196
242
|
label: `mcp-server:${name}`,
|
|
197
243
|
lines: 0,
|
|
198
244
|
tokens,
|
|
245
|
+
liveTokens: tokens, // full schema when eagerly loaded (see mcp-may-defer)
|
|
199
246
|
hash: contentHash(`mcp:${name}`),
|
|
200
247
|
flags,
|
|
201
248
|
toolCount: hasCount ? count : null,
|
|
@@ -261,7 +308,7 @@ export function dedupeSkills(components) {
|
|
|
261
308
|
if (c.kind !== 'skill') return c;
|
|
262
309
|
const key = `${c.label}::${c.hash}`;
|
|
263
310
|
if (seen.has(key)) {
|
|
264
|
-
return { ...c, duplicateOf: seen.get(key).path, tokens: 0, flags: [...c.flags, 'duplicate'] };
|
|
311
|
+
return { ...c, duplicateOf: seen.get(key).path, tokens: 0, liveTokens: 0, flags: [...c.flags, 'duplicate'] };
|
|
265
312
|
}
|
|
266
313
|
seen.set(key, c);
|
|
267
314
|
return c;
|
|
@@ -343,69 +390,97 @@ function formatTokens(n) {
|
|
|
343
390
|
* components already carrying a `bucket`.
|
|
344
391
|
*/
|
|
345
392
|
export function buildReport(components, ctx = {}) {
|
|
346
|
-
// Ensure each component is classified.
|
|
393
|
+
// Ensure each component is classified and carries a liveTokens estimate.
|
|
394
|
+
// scanSurface() always sets liveTokens. For a hand-built component that omits
|
|
395
|
+
// it, default CONSERVATIVELY to the full surface tokens — a budget tool should
|
|
396
|
+
// over-report cost, never hide it. (We can't recompute a description-only
|
|
397
|
+
// estimate here without the source text.)
|
|
347
398
|
const classified = components.map((c) => {
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
399
|
+
const withLive = c.liveTokens == null ? { ...c, liveTokens: c.tokens } : c;
|
|
400
|
+
if (withLive.bucket) return withLive;
|
|
401
|
+
const { bucket, reason } = classifyComponent(withLive, ctx);
|
|
402
|
+
return { ...withLive, bucket, reason };
|
|
351
403
|
});
|
|
352
404
|
|
|
353
405
|
const buckets = { always: [], sometimes: [], rarely: [] };
|
|
354
406
|
for (const c of classified) buckets[c.bucket].push(c);
|
|
355
407
|
|
|
356
|
-
const totalTokens = classified.reduce((a, c) => a + c.tokens, 0);
|
|
408
|
+
const totalTokens = classified.reduce((a, c) => a + c.tokens, 0); // on-disk surface
|
|
409
|
+
const totalLiveTokens = classified.reduce((a, c) => a + c.liveTokens, 0); // loaded at startup
|
|
357
410
|
|
|
358
|
-
// Top reclaims:
|
|
411
|
+
// Top reclaims: ranked by LIVE tokens — the savings you actually get back by
|
|
412
|
+
// cutting it (progressive disclosure means a big on-disk skill reclaims only
|
|
413
|
+
// its description). Among sometimes+rarely with non-zero live cost.
|
|
359
414
|
const topReclaims = [...buckets.sometimes, ...buckets.rarely]
|
|
360
|
-
.filter((c) => c.
|
|
361
|
-
.sort((a, b) => b.
|
|
415
|
+
.filter((c) => c.liveTokens > 0)
|
|
416
|
+
.sort((a, b) => b.liveTokens - a.liveTokens)
|
|
362
417
|
.slice(0, 5);
|
|
363
418
|
|
|
364
|
-
const text = renderReport({ buckets, totalTokens, topReclaims });
|
|
365
|
-
return { totalTokens, buckets, topReclaims, classified, text };
|
|
419
|
+
const text = renderReport({ buckets, totalTokens, totalLiveTokens, topReclaims });
|
|
420
|
+
return { totalTokens, totalLiveTokens, buckets, topReclaims, classified, text };
|
|
366
421
|
}
|
|
367
422
|
|
|
368
423
|
function renderBucketLines(list) {
|
|
369
424
|
return list
|
|
370
425
|
.slice()
|
|
371
|
-
.sort((a, b) => b.tokens - a.tokens)
|
|
426
|
+
.sort((a, b) => b.liveTokens - a.liveTokens || b.tokens - a.tokens)
|
|
372
427
|
.map((c) => {
|
|
373
428
|
const flagStr = c.flags && c.flags.length ? ` [${c.flags.join(', ')}]` : '';
|
|
374
|
-
return ` - ${c.label} (${c.lines} lines, ~${formatTokens(c.tokens)}
|
|
429
|
+
return ` - ${c.label} (${c.lines} lines, ~${formatTokens(c.tokens)} surface / ~${formatTokens(c.liveTokens)} live) — ${c.reason}${flagStr}`;
|
|
375
430
|
})
|
|
376
431
|
.join('\n');
|
|
377
432
|
}
|
|
378
433
|
|
|
379
|
-
function
|
|
434
|
+
function bucketSurface(list) {
|
|
380
435
|
return list.reduce((a, c) => a + c.tokens, 0);
|
|
381
436
|
}
|
|
437
|
+
function bucketLive(list) {
|
|
438
|
+
return list.reduce((a, c) => a + c.liveTokens, 0);
|
|
439
|
+
}
|
|
382
440
|
|
|
383
|
-
function renderReport({ buckets, totalTokens, topReclaims }) {
|
|
441
|
+
function renderReport({ buckets, totalTokens, totalLiveTokens, topReclaims }) {
|
|
384
442
|
const lines = [];
|
|
385
|
-
lines.push(
|
|
443
|
+
lines.push(
|
|
444
|
+
`CONTEXT BUDGET — ~${formatTokens(totalTokens)} tokens on disk / ~${formatTokens(totalLiveTokens)} loaded at startup`
|
|
445
|
+
);
|
|
446
|
+
lines.push(
|
|
447
|
+
' (skills & agents are progressive-disclosure: only their description loads until invoked,'
|
|
448
|
+
);
|
|
449
|
+
lines.push(
|
|
450
|
+
' so "live" is the real session-start cost; MCP schemas may also defer — see mcp-may-defer)'
|
|
451
|
+
);
|
|
386
452
|
lines.push('');
|
|
387
|
-
lines.push(
|
|
453
|
+
lines.push(
|
|
454
|
+
`ALWAYS NEEDED (keep, ~${formatTokens(bucketSurface(buckets.always))} surface / ~${formatTokens(bucketLive(buckets.always))} live)`
|
|
455
|
+
);
|
|
388
456
|
lines.push(renderBucketLines(buckets.always) || ' (none)');
|
|
389
457
|
lines.push('');
|
|
390
458
|
lines.push(
|
|
391
|
-
`SOMETIMES NEEDED (consider lazy-load,
|
|
459
|
+
`SOMETIMES NEEDED (consider lazy-load, ~${formatTokens(bucketSurface(buckets.sometimes))} surface / ~${formatTokens(bucketLive(buckets.sometimes))} live)`
|
|
392
460
|
);
|
|
393
461
|
lines.push(renderBucketLines(buckets.sometimes) || ' (none)');
|
|
394
462
|
lines.push('');
|
|
395
|
-
lines.push(
|
|
463
|
+
lines.push(
|
|
464
|
+
`RARELY NEEDED (recommend cut, ~${formatTokens(bucketSurface(buckets.rarely))} surface / ~${formatTokens(bucketLive(buckets.rarely))} live)`
|
|
465
|
+
);
|
|
396
466
|
lines.push(renderBucketLines(buckets.rarely) || ' (none)');
|
|
397
467
|
lines.push('');
|
|
398
|
-
lines.push('TOP 5 RECLAIMS:');
|
|
468
|
+
lines.push('TOP 5 RECLAIMS (by live tokens — what you actually get back):');
|
|
399
469
|
if (topReclaims.length === 0) {
|
|
400
470
|
lines.push(' (none)');
|
|
401
471
|
} else {
|
|
402
472
|
topReclaims.forEach((c, i) => {
|
|
403
|
-
lines.push(
|
|
473
|
+
lines.push(
|
|
474
|
+
` ${i + 1}. ${c.label} (~${formatTokens(c.liveTokens)} live / ~${formatTokens(c.tokens)} surface) — ${c.reason}`
|
|
475
|
+
);
|
|
404
476
|
});
|
|
405
477
|
}
|
|
406
|
-
const
|
|
478
|
+
const potentialLive = bucketLive(buckets.sometimes) + bucketLive(buckets.rarely);
|
|
479
|
+
const potentialSurface = bucketSurface(buckets.sometimes) + bucketSurface(buckets.rarely);
|
|
407
480
|
lines.push('');
|
|
408
|
-
lines.push(
|
|
481
|
+
lines.push(
|
|
482
|
+
`Potential reclaim if all sometimes+rarely cut: ~${formatTokens(potentialLive)} live (~${formatTokens(potentialSurface)} surface)`
|
|
483
|
+
);
|
|
409
484
|
return lines.join('\n');
|
|
410
485
|
}
|
|
411
486
|
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* collect.js — gather the roadmap-graph node universe + raw edges from a
|
|
3
|
+
* project's feature folders and ROADMAP.md (COMP-ROADMAP-GRAPH-1).
|
|
4
|
+
*
|
|
5
|
+
* Node universe = (feature folders with feature.json) ∪ (real-coded ROADMAP.md
|
|
6
|
+
* rows). feature.json wins on status when both exist; ROADMAP-only features get
|
|
7
|
+
* a warning + minimal display metadata. The union's code set is the dangling
|
|
8
|
+
* oracle for model.buildGraph.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
11
|
+
import { join, dirname, resolve } from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { parse as parseYaml } from 'yaml';
|
|
14
|
+
import { listFeatures } from '../feature-json.js';
|
|
15
|
+
import { loadFeaturesDir, loadExternalPrefixes } from '../project-paths.js';
|
|
16
|
+
import { parseRoadmap } from '../roadmap-parser.js';
|
|
17
|
+
import { SchemaValidator } from '../../server/schema-validator.js';
|
|
18
|
+
import { depsToEdges } from './model.js';
|
|
19
|
+
|
|
20
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const DEPS_SCHEMA = resolve(__dirname, '../../contracts/roadmap-deps.schema.json');
|
|
22
|
+
const FRONTMATTER_SCHEMA = resolve(__dirname, '../../contracts/roadmap-graph-frontmatter.schema.json');
|
|
23
|
+
|
|
24
|
+
const FM_KEYS = ['name', 'priority', 'track', 'desc'];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {string} cwd project root
|
|
28
|
+
* @param {string} [featuresDir] relative features dir (default from compose.json)
|
|
29
|
+
* @returns {{ nodes: object[], rawEdges: object[], knownCodes: Set<string>, warnings: string[] }}
|
|
30
|
+
*/
|
|
31
|
+
export function collectGraphInputs(cwd, featuresDir = loadFeaturesDir(cwd)) {
|
|
32
|
+
const warnings = [];
|
|
33
|
+
const depsValidator = new SchemaValidator(DEPS_SCHEMA);
|
|
34
|
+
const fmValidator = new SchemaValidator(FRONTMATTER_SCHEMA);
|
|
35
|
+
const externalPrefixes = loadExternalPrefixes(cwd);
|
|
36
|
+
const isExternal = (code) => externalPrefixes.some((p) => code.startsWith(p));
|
|
37
|
+
|
|
38
|
+
/** @type {Map<string, object>} id -> collected node (rendered features only) */
|
|
39
|
+
const universe = new Map();
|
|
40
|
+
// Codes that exist as cross-project references — known (so deps to them don't
|
|
41
|
+
// dangle) but not rendered as nodes and not warned about.
|
|
42
|
+
const externalCodes = new Set();
|
|
43
|
+
|
|
44
|
+
// (a) Feature folders — authoritative source.
|
|
45
|
+
const features = listFeatures(cwd, featuresDir);
|
|
46
|
+
for (const f of features) {
|
|
47
|
+
if (!f || typeof f.code !== 'string') continue;
|
|
48
|
+
// An external-prefixed folder is a cross-project reference living here —
|
|
49
|
+
// known (so edges resolve) but never rendered as one of THIS project's nodes.
|
|
50
|
+
if (isExternal(f.code)) { externalCodes.add(f.code); continue; }
|
|
51
|
+
const folder = join(cwd, featuresDir, f.code);
|
|
52
|
+
const fm = readDisplayMetadata(folder, f, fmValidator, warnings);
|
|
53
|
+
universe.set(f.code, {
|
|
54
|
+
id: f.code,
|
|
55
|
+
status: String(f.status || 'PLANNED').toUpperCase(),
|
|
56
|
+
name: fm.name,
|
|
57
|
+
priority: fm.priority,
|
|
58
|
+
track: fm.track,
|
|
59
|
+
desc: fm.desc,
|
|
60
|
+
_hasFolder: true,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// (b) ROADMAP.md rows — fallback for features not registered as folders.
|
|
65
|
+
const roadmapPath = join(cwd, 'ROADMAP.md');
|
|
66
|
+
if (existsSync(roadmapPath)) {
|
|
67
|
+
let entries = [];
|
|
68
|
+
try { entries = parseRoadmap(readFileSync(roadmapPath, 'utf-8')); } catch { /* ignore */ }
|
|
69
|
+
for (const e of entries) {
|
|
70
|
+
if (!e.code || e.code.startsWith('_anon_')) continue;
|
|
71
|
+
if (universe.has(e.code)) continue; // folder wins
|
|
72
|
+
if (isExternal(e.code)) { externalCodes.add(e.code); continue; } // cross-project ref
|
|
73
|
+
universe.set(e.code, {
|
|
74
|
+
id: e.code,
|
|
75
|
+
status: String(e.status || 'PLANNED').toUpperCase(),
|
|
76
|
+
name: e.code,
|
|
77
|
+
priority: 'medium',
|
|
78
|
+
track: 'standalone',
|
|
79
|
+
desc: stripMd(e.description || ''),
|
|
80
|
+
_hasFolder: false,
|
|
81
|
+
});
|
|
82
|
+
warnings.push(`${e.code}: unregistered (ROADMAP.md fallback — no feature.json)`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Edges — only feature folders may declare deps.yaml.
|
|
87
|
+
const rawEdges = [];
|
|
88
|
+
for (const f of features) {
|
|
89
|
+
if (!f || typeof f.code !== 'string') continue;
|
|
90
|
+
if (isExternal(f.code)) continue;
|
|
91
|
+
const depsPath = join(cwd, featuresDir, f.code, 'deps.yaml');
|
|
92
|
+
if (!existsSync(depsPath)) continue;
|
|
93
|
+
let deps;
|
|
94
|
+
try {
|
|
95
|
+
deps = parseYaml(readFileSync(depsPath, 'utf-8')) || {};
|
|
96
|
+
} catch (err) {
|
|
97
|
+
warnings.push(`${f.code}: unparseable deps.yaml (${err.message}) — skipped`);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const res = depsValidator.validateRoot(deps);
|
|
101
|
+
if (!res.valid) {
|
|
102
|
+
const msg = res.errors.map((e) => `${e.instancePath || '/'} ${e.message}`).join('; ');
|
|
103
|
+
warnings.push(`${f.code}: invalid deps.yaml (${msg}) — skipped`);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
rawEdges.push(...depsToEdges(f.code, deps));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// An edge endpoint matching an external prefix is a cross-project reference we
|
|
110
|
+
// cannot validate locally — treat it as known (never dangling). The node is
|
|
111
|
+
// not rendered, so model.buildGraph silently drops the edge.
|
|
112
|
+
for (const e of rawEdges) {
|
|
113
|
+
for (const code of [e.from, e.to]) {
|
|
114
|
+
if (!knownLocally(code, universe, externalCodes) && isExternal(code)) externalCodes.add(code);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const knownCodes = new Set([...universe.keys(), ...externalCodes]);
|
|
119
|
+
return { nodes: [...universe.values()], rawEdges, knownCodes, warnings };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function knownLocally(code, universe, externalCodes) {
|
|
123
|
+
return universe.has(code) || externalCodes.has(code);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Display metadata precedence: design.md YAML frontmatter > feature.json keys >
|
|
128
|
+
* defaults. Returns { name, priority, track, desc }.
|
|
129
|
+
*/
|
|
130
|
+
function readDisplayMetadata(folder, feature, fmValidator, warnings) {
|
|
131
|
+
const fromFeature = pick(feature, FM_KEYS);
|
|
132
|
+
const fromDesign = readDesignFrontmatter(folder, fmValidator, feature.code, warnings);
|
|
133
|
+
const merged = { ...fromFeature, ...fromDesign };
|
|
134
|
+
return {
|
|
135
|
+
name: merged.name || firstLine(feature.description) || feature.code,
|
|
136
|
+
priority: merged.priority,
|
|
137
|
+
track: merged.track,
|
|
138
|
+
desc: merged.desc || stripMd(feature.description || ''),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function readDesignFrontmatter(folder, fmValidator, code, warnings) {
|
|
143
|
+
const designPath = join(folder, 'design.md');
|
|
144
|
+
if (!existsSync(designPath)) return {};
|
|
145
|
+
const text = readFileSync(designPath, 'utf-8');
|
|
146
|
+
const m = text.match(/^---\n([\s\S]*?)\n---/);
|
|
147
|
+
if (!m) return {};
|
|
148
|
+
let data;
|
|
149
|
+
try { data = parseYaml(m[1]) || {}; } catch { return {}; }
|
|
150
|
+
if (typeof data !== 'object' || Array.isArray(data)) return {};
|
|
151
|
+
const res = fmValidator.validateRoot(data);
|
|
152
|
+
if (!res.valid) {
|
|
153
|
+
warnings.push(`${code}: invalid design.md frontmatter — ignored`);
|
|
154
|
+
return {};
|
|
155
|
+
}
|
|
156
|
+
return pick(data, FM_KEYS);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function pick(obj, keys) {
|
|
160
|
+
const out = {};
|
|
161
|
+
for (const k of keys) {
|
|
162
|
+
if (obj && obj[k] != null && obj[k] !== '') out[k] = obj[k];
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function firstLine(s) {
|
|
168
|
+
return stripMd(String(s || '').split('\n')[0] || '').trim();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Strip the heaviest markdown so node names/descs render cleanly as plain text.
|
|
172
|
+
function stripMd(s) {
|
|
173
|
+
return String(s || '')
|
|
174
|
+
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
|
175
|
+
.replace(/`([^`]+)`/g, '$1')
|
|
176
|
+
.replace(/^\s*#+\s*/, '')
|
|
177
|
+
.trim();
|
|
178
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config.js — read `.compose/compose.json#roadmap_graph` display config for the
|
|
3
|
+
* roadmap dependency graph generator (COMP-ROADMAP-GRAPH-1).
|
|
4
|
+
*
|
|
5
|
+
* All fields optional. Defaults keep the generator usable with zero config.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
8
|
+
import { join, basename } from 'node:path';
|
|
9
|
+
|
|
10
|
+
// Default track -> accent color map. Projects override via
|
|
11
|
+
// compose.json#roadmap_graph.tracks. 'standalone' is the fallback track.
|
|
12
|
+
export const DEFAULT_TRACKS = {
|
|
13
|
+
knowledge: '#0ea5e9',
|
|
14
|
+
distribution: '#10b981',
|
|
15
|
+
governance: '#a855f7',
|
|
16
|
+
agent: '#f59e0b',
|
|
17
|
+
worker: '#ef4444',
|
|
18
|
+
platform: '#ec4899',
|
|
19
|
+
developer: '#f97316',
|
|
20
|
+
async: '#6b7280',
|
|
21
|
+
standalone: '#64748b',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const DEFAULT_OUT = 'roadmap-graph.html';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve graph display config for a project root.
|
|
28
|
+
* @param {string} cwd
|
|
29
|
+
* @returns {{ title: string, subtitle: string, tracks: Record<string,string>, out: string }}
|
|
30
|
+
*/
|
|
31
|
+
export function loadGraphConfig(cwd) {
|
|
32
|
+
let raw = {};
|
|
33
|
+
const cfgPath = join(cwd, '.compose', 'compose.json');
|
|
34
|
+
if (existsSync(cfgPath)) {
|
|
35
|
+
try {
|
|
36
|
+
const cfg = JSON.parse(readFileSync(cfgPath, 'utf-8'));
|
|
37
|
+
raw = (cfg && typeof cfg.roadmap_graph === 'object' && cfg.roadmap_graph) || {};
|
|
38
|
+
} catch { /* fall through to defaults */ }
|
|
39
|
+
}
|
|
40
|
+
const projectName = deriveProjectName(cwd, cfgPath);
|
|
41
|
+
return {
|
|
42
|
+
title: typeof raw.title === 'string' && raw.title ? raw.title : `${projectName} — Roadmap Dependency Graph`,
|
|
43
|
+
subtitle: typeof raw.subtitle === 'string' ? raw.subtitle : '',
|
|
44
|
+
tracks: { ...DEFAULT_TRACKS, ...(raw.tracks && typeof raw.tracks === 'object' ? raw.tracks : {}) },
|
|
45
|
+
out: typeof raw.out === 'string' && raw.out ? raw.out : DEFAULT_OUT,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function deriveProjectName(cwd, cfgPath) {
|
|
50
|
+
if (existsSync(cfgPath)) {
|
|
51
|
+
try {
|
|
52
|
+
const cfg = JSON.parse(readFileSync(cfgPath, 'utf-8'));
|
|
53
|
+
if (typeof cfg.workspaceId === 'string' && cfg.workspaceId) return cfg.workspaceId;
|
|
54
|
+
} catch { /* ignore */ }
|
|
55
|
+
}
|
|
56
|
+
return basename(cwd) || 'Project';
|
|
57
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* index.js — public API for the roadmap dependency graph generator
|
|
3
|
+
* (COMP-ROADMAP-GRAPH-1).
|
|
4
|
+
*
|
|
5
|
+
* generateRoadmapGraph(cwd, opts) — render + atomic-write the HTML
|
|
6
|
+
* checkRoadmapGraph(cwd, opts) — render in-memory, diff vs on-disk
|
|
7
|
+
*
|
|
8
|
+
* Both throw DanglingEdgeError (code DANGLING_EDGE) when an edge points at an
|
|
9
|
+
* unknown feature — the Cytoscape-crash bug class this feature kills.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync, mkdirSync } from 'node:fs';
|
|
12
|
+
import { join, isAbsolute, dirname } from 'node:path';
|
|
13
|
+
import { loadFeaturesDir } from '../project-paths.js';
|
|
14
|
+
import { collectGraphInputs } from './collect.js';
|
|
15
|
+
import { buildGraph } from './model.js';
|
|
16
|
+
import { renderGraphHtml } from './render.js';
|
|
17
|
+
import { loadGraphConfig } from './config.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Build the HTML + graph stats for a project without writing to disk.
|
|
21
|
+
* @param {string} cwd
|
|
22
|
+
* @returns {{ html: string, nodes: object[], edges: object[], dropped: string[], warnings: string[], config: object }}
|
|
23
|
+
*/
|
|
24
|
+
export function buildRoadmapGraph(cwd) {
|
|
25
|
+
const featuresDir = loadFeaturesDir(cwd);
|
|
26
|
+
const inputs = collectGraphInputs(cwd, featuresDir);
|
|
27
|
+
const graph = buildGraph(inputs); // throws DanglingEdgeError
|
|
28
|
+
const config = loadGraphConfig(cwd);
|
|
29
|
+
const html = renderGraphHtml({ nodes: graph.nodes, edges: graph.edges, config });
|
|
30
|
+
return { html, nodes: graph.nodes, edges: graph.edges, dropped: graph.dropped, warnings: inputs.warnings, config };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Generate and atomically write roadmap-graph.html.
|
|
35
|
+
* @param {string} cwd project root
|
|
36
|
+
* @param {{ out?: string }} [opts] out path (relative to cwd unless absolute); defaults to config.out
|
|
37
|
+
* @returns {{ path: string, nodeCount: number, edgeCount: number, droppedCount: number, warnings: string[] }}
|
|
38
|
+
*/
|
|
39
|
+
export function generateRoadmapGraph(cwd, opts = {}) {
|
|
40
|
+
const built = buildRoadmapGraph(cwd);
|
|
41
|
+
const outRel = opts.out || built.config.out;
|
|
42
|
+
const outPath = isAbsolute(outRel) ? outRel : join(cwd, outRel);
|
|
43
|
+
atomicWrite(outPath, built.html);
|
|
44
|
+
return {
|
|
45
|
+
path: outPath,
|
|
46
|
+
nodeCount: built.nodes.length,
|
|
47
|
+
edgeCount: built.edges.length,
|
|
48
|
+
droppedCount: built.dropped.length,
|
|
49
|
+
warnings: built.warnings,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Render in-memory and compare to the on-disk file.
|
|
55
|
+
* @param {string} cwd
|
|
56
|
+
* @param {{ out?: string }} [opts]
|
|
57
|
+
* @returns {{ matches: boolean, path: string, exists: boolean, diffSummary: string, nodeCount: number, edgeCount: number, warnings: string[] }}
|
|
58
|
+
*/
|
|
59
|
+
export function checkRoadmapGraph(cwd, opts = {}) {
|
|
60
|
+
const built = buildRoadmapGraph(cwd);
|
|
61
|
+
const outRel = opts.out || built.config.out;
|
|
62
|
+
const outPath = isAbsolute(outRel) ? outRel : join(cwd, outRel);
|
|
63
|
+
const exists = existsSync(outPath);
|
|
64
|
+
const onDisk = exists ? readFileSync(outPath, 'utf-8') : null;
|
|
65
|
+
const matches = exists && onDisk === built.html;
|
|
66
|
+
let diffSummary = '';
|
|
67
|
+
if (!exists) diffSummary = `missing: ${outPath} not generated yet`;
|
|
68
|
+
else if (!matches) diffSummary = `stale: ${outPath} differs from regenerated output (run \`compose roadmap graph\`)`;
|
|
69
|
+
return {
|
|
70
|
+
matches,
|
|
71
|
+
path: outPath,
|
|
72
|
+
exists,
|
|
73
|
+
diffSummary,
|
|
74
|
+
nodeCount: built.nodes.length,
|
|
75
|
+
edgeCount: built.edges.length,
|
|
76
|
+
warnings: built.warnings,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function atomicWrite(outPath, content) {
|
|
81
|
+
const dir = dirname(outPath);
|
|
82
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
83
|
+
const tmp = `${outPath}.tmp.${process.pid}`;
|
|
84
|
+
try {
|
|
85
|
+
writeFileSync(tmp, content);
|
|
86
|
+
renameSync(tmp, outPath);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
try { unlinkSync(tmp); } catch { /* tmp may not exist */ }
|
|
89
|
+
throw err;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export { DanglingEdgeError } from './model.js';
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* model.js — pure graph rules for the roadmap dependency graph
|
|
3
|
+
* (COMP-ROADMAP-GRAPH-1).
|
|
4
|
+
*
|
|
5
|
+
* Takes the collected node universe + raw edge declarations and produces the
|
|
6
|
+
* final `{ nodes, edges }` arrays the template renders — applying the drop
|
|
7
|
+
* rules and refusing to emit when an edge would dangle (the Cytoscape-crash
|
|
8
|
+
* bug class this feature kills).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Statuses whose nodes are dropped from the graph entirely. */
|
|
12
|
+
export const DROP_STATUSES = new Set(['COMPLETE', 'SUPERSEDED', 'KILLED']);
|
|
13
|
+
|
|
14
|
+
/** UPPERCASE feature status -> lowercase template status vocabulary. */
|
|
15
|
+
const STATUS_MAP = {
|
|
16
|
+
PLANNED: 'planned',
|
|
17
|
+
IN_PROGRESS: 'in_progress',
|
|
18
|
+
PARTIAL: 'partial',
|
|
19
|
+
PARKED: 'parked',
|
|
20
|
+
BLOCKED: 'blocked',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export class DanglingEdgeError extends Error {
|
|
24
|
+
/** @param {{from:string,to:string,kind:string}[]} dangling */
|
|
25
|
+
constructor(dangling) {
|
|
26
|
+
const lines = dangling.map((d) => ` ${d.from} --${d.kind}--> ${d.to} (${d.to} is not a known feature)`);
|
|
27
|
+
super(`roadmap-graph: refusing to emit — ${dangling.length} dangling edge(s):\n${lines.join('\n')}`);
|
|
28
|
+
this.code = 'DANGLING_EDGE';
|
|
29
|
+
this.dangling = dangling;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @typedef {object} CollectedNode
|
|
35
|
+
* @property {string} id
|
|
36
|
+
* @property {string} status UPPERCASE source status
|
|
37
|
+
* @property {string} [name]
|
|
38
|
+
* @property {string} [priority]
|
|
39
|
+
* @property {string} [track]
|
|
40
|
+
* @property {string} [desc]
|
|
41
|
+
*
|
|
42
|
+
* @typedef {object} RawEdge
|
|
43
|
+
* @property {string} from prerequisite (source)
|
|
44
|
+
* @property {string} to dependent (target)
|
|
45
|
+
* @property {'dep'|'concurrent'} type
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Build the final graph from collected inputs.
|
|
50
|
+
* @param {{ nodes: CollectedNode[], rawEdges: RawEdge[], knownCodes: Set<string> }} inputs
|
|
51
|
+
* @returns {{ nodes: object[], edges: object[], dropped: string[] }}
|
|
52
|
+
* @throws {DanglingEdgeError} when any non-dropped edge points at an unknown code
|
|
53
|
+
*/
|
|
54
|
+
export function buildGraph({ nodes, rawEdges, knownCodes }) {
|
|
55
|
+
const known = knownCodes instanceof Set ? knownCodes : new Set(knownCodes || []);
|
|
56
|
+
|
|
57
|
+
const dropped = [];
|
|
58
|
+
const kept = new Map(); // id -> rendered node
|
|
59
|
+
for (const n of nodes) {
|
|
60
|
+
if (DROP_STATUSES.has(n.status)) {
|
|
61
|
+
dropped.push(n.id);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
kept.set(n.id, renderNode(n));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const dangling = [];
|
|
68
|
+
const seen = new Set();
|
|
69
|
+
const edges = [];
|
|
70
|
+
for (const e of rawEdges) {
|
|
71
|
+
// Dangling = endpoint not a known feature anywhere (typo / never existed).
|
|
72
|
+
const fromUnknown = !known.has(e.from);
|
|
73
|
+
const toUnknown = !known.has(e.to);
|
|
74
|
+
if (fromUnknown || toUnknown) {
|
|
75
|
+
dangling.push({ from: e.from, to: e.to, kind: e.type });
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
// Known but not rendered (dropped by status) -> silently drop the edge.
|
|
79
|
+
if (!kept.has(e.from) || !kept.has(e.to)) continue;
|
|
80
|
+
|
|
81
|
+
const key = `${e.type}|${e.from}|${e.to}`;
|
|
82
|
+
if (seen.has(key)) continue;
|
|
83
|
+
seen.add(key);
|
|
84
|
+
edges.push({ source: e.from, target: e.to, type: e.type });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (dangling.length > 0) throw new DanglingEdgeError(dangling);
|
|
88
|
+
|
|
89
|
+
const nodeList = [...kept.values()];
|
|
90
|
+
// kept preserves insertion order (already phase->position->code sorted by caller);
|
|
91
|
+
// sort edges for deterministic, idempotent output.
|
|
92
|
+
edges.sort((a, b) =>
|
|
93
|
+
a.type.localeCompare(b.type) ||
|
|
94
|
+
a.source.localeCompare(b.source) ||
|
|
95
|
+
a.target.localeCompare(b.target));
|
|
96
|
+
|
|
97
|
+
return { nodes: nodeList, edges, dropped };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function renderNode(n) {
|
|
101
|
+
const name = n.name || n.id;
|
|
102
|
+
const status = STATUS_MAP[n.status] || String(n.status || '').toLowerCase();
|
|
103
|
+
const priority = ['high', 'medium', 'low'].includes(n.priority) ? n.priority : 'medium';
|
|
104
|
+
const track = n.track || 'standalone';
|
|
105
|
+
const desc = n.desc || '';
|
|
106
|
+
// label: code + short name, wrapped by the template at render time.
|
|
107
|
+
const shortName = name.length > 48 ? name.slice(0, 45) + '…' : name;
|
|
108
|
+
return { id: n.id, label: `${n.id}\n${shortName}`, name, status, priority, track, desc };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Normalize a feature's deps.yaml into directed raw edges.
|
|
113
|
+
* @param {string} code
|
|
114
|
+
* @param {{depends_on?:string[], concurrent_with?:string[], blocks?:string[]}} deps
|
|
115
|
+
* @returns {RawEdge[]}
|
|
116
|
+
*/
|
|
117
|
+
export function depsToEdges(code, deps) {
|
|
118
|
+
const out = [];
|
|
119
|
+
const arr = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string' && x) : []);
|
|
120
|
+
for (const dep of arr(deps?.depends_on)) out.push({ from: dep, to: code, type: 'dep' });
|
|
121
|
+
for (const blk of arr(deps?.blocks)) out.push({ from: code, to: blk, type: 'dep' });
|
|
122
|
+
for (const sib of arr(deps?.concurrent_with)) {
|
|
123
|
+
// canonicalize undirected concurrent edges so A+B never emit twice
|
|
124
|
+
const [a, b] = code < sib ? [code, sib] : [sib, code];
|
|
125
|
+
out.push({ from: a, to: b, type: 'concurrent' });
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* render.js — render the roadmap-graph HTML from the packaged template by
|
|
3
|
+
* replacing the @generated data regions (COMP-ROADMAP-GRAPH-1).
|
|
4
|
+
*
|
|
5
|
+
* Deterministic: identical inputs produce byte-identical output (no wall-clock
|
|
6
|
+
* timestamps), which underpins the `--check` idempotency guarantee.
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync } from 'node:fs';
|
|
9
|
+
import { dirname, resolve } from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
|
|
12
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const TEMPLATE_PATH = resolve(__dirname, 'template.html');
|
|
14
|
+
|
|
15
|
+
const REGIONS = {
|
|
16
|
+
config: ['/* @generated:config:start */', '/* @generated:config:end */'],
|
|
17
|
+
nodes: ['/* @generated:nodes:start */', '/* @generated:nodes:end */'],
|
|
18
|
+
edges: ['/* @generated:edges:start */', '/* @generated:edges:end */'],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {{ nodes: object[], edges: object[], config: object }} input
|
|
23
|
+
* @returns {string} full HTML document
|
|
24
|
+
*/
|
|
25
|
+
export function renderGraphHtml({ nodes, edges, config }) {
|
|
26
|
+
let html = readFileSync(TEMPLATE_PATH, 'utf-8');
|
|
27
|
+
const cfg = {
|
|
28
|
+
title: config?.title || 'Roadmap Dependency Graph',
|
|
29
|
+
subtitle: config?.subtitle || '',
|
|
30
|
+
tracks: config?.tracks || {},
|
|
31
|
+
};
|
|
32
|
+
html = replaceRegion(html, 'config', `const GRAPH_CONFIG = ${stable(cfg)};`);
|
|
33
|
+
html = replaceRegion(html, 'nodes', `const nodes = ${stable(nodes)};`);
|
|
34
|
+
html = replaceRegion(html, 'edges', `const edges = ${stable(edges)};`);
|
|
35
|
+
return html;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function replaceRegion(html, key, body) {
|
|
39
|
+
const [start, end] = REGIONS[key];
|
|
40
|
+
const s = html.indexOf(start);
|
|
41
|
+
const e = html.indexOf(end);
|
|
42
|
+
if (s === -1 || e === -1 || e < s) {
|
|
43
|
+
throw new Error(`roadmap-graph template missing @generated:${key} region`);
|
|
44
|
+
}
|
|
45
|
+
return html.slice(0, s + start.length) + '\n' + body + '\n' + html.slice(e);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Stable JSON serialization with sorted object keys for deterministic output.
|
|
49
|
+
function stable(value) {
|
|
50
|
+
return JSON.stringify(sortKeys(value), null, 2);
|
|
51
|
+
}
|
|
52
|
+
function sortKeys(v) {
|
|
53
|
+
if (Array.isArray(v)) return v.map(sortKeys);
|
|
54
|
+
if (v && typeof v === 'object') {
|
|
55
|
+
const out = {};
|
|
56
|
+
for (const k of Object.keys(v).sort()) out[k] = sortKeys(v[k]);
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
return v;
|
|
60
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<!--
|
|
3
|
+
Roadmap Dependency Graph — generated by `compose roadmap graph` (COMP-ROADMAP-GRAPH-1).
|
|
4
|
+
DO NOT hand-edit the @generated data regions below; they are overwritten on
|
|
5
|
+
every run. Re-generate with: compose roadmap graph
|
|
6
|
+
-->
|
|
7
|
+
<html lang="en">
|
|
8
|
+
<head>
|
|
9
|
+
<meta charset="utf-8" />
|
|
10
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
11
|
+
<title>Roadmap Dependency Graph</title>
|
|
12
|
+
<script src="https://cdn.jsdelivr.net/npm/cytoscape@3.28.1/dist/cytoscape.min.js"></script>
|
|
13
|
+
<script src="https://cdn.jsdelivr.net/npm/dagre@0.8.5/dist/dagre.min.js"></script>
|
|
14
|
+
<script src="https://cdn.jsdelivr.net/npm/cytoscape-dagre@2.5.0/cytoscape-dagre.min.js"></script>
|
|
15
|
+
<style>
|
|
16
|
+
:root { --bg:#0f172a; --panel:#1e293b; --ink:#e2e8f0; --muted:#94a3b8; --line:#334155; }
|
|
17
|
+
* { box-sizing: border-box; }
|
|
18
|
+
html, body { margin:0; height:100%; background:var(--bg); color:var(--ink);
|
|
19
|
+
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; }
|
|
20
|
+
header { padding:12px 18px; border-bottom:1px solid var(--line); display:flex;
|
|
21
|
+
align-items:baseline; gap:14px; flex-wrap:wrap; }
|
|
22
|
+
header h1 { font-size:15px; margin:0; font-weight:600; }
|
|
23
|
+
header .sub { color:var(--muted); font-size:12px; }
|
|
24
|
+
header .controls { margin-left:auto; display:flex; gap:6px; }
|
|
25
|
+
button { background:var(--panel); color:var(--ink); border:1px solid var(--line);
|
|
26
|
+
border-radius:6px; padding:4px 9px; font-size:12px; cursor:pointer; }
|
|
27
|
+
button:hover { border-color:#64748b; }
|
|
28
|
+
#cy { position:absolute; top:49px; bottom:0; left:0; right:0; }
|
|
29
|
+
#legend { position:absolute; left:14px; bottom:14px; background:rgba(30,41,59,.92);
|
|
30
|
+
border:1px solid var(--line); border-radius:8px; padding:10px 12px; font-size:11px;
|
|
31
|
+
color:var(--muted); line-height:1.7; z-index:5; }
|
|
32
|
+
#legend b { color:var(--ink); }
|
|
33
|
+
.swatch { display:inline-block; width:9px; height:9px; border-radius:2px; margin-right:5px;
|
|
34
|
+
vertical-align:middle; }
|
|
35
|
+
#tracks { position:absolute; right:14px; bottom:14px; background:rgba(30,41,59,.92);
|
|
36
|
+
border:1px solid var(--line); border-radius:8px; padding:10px 12px; font-size:11px;
|
|
37
|
+
color:var(--muted); max-height:45vh; overflow:auto; z-index:5; }
|
|
38
|
+
#tooltip { position:absolute; display:none; max-width:320px; background:#020617;
|
|
39
|
+
border:1px solid var(--line); border-radius:8px; padding:10px 12px; font-size:12px;
|
|
40
|
+
color:var(--ink); z-index:10; pointer-events:none; box-shadow:0 8px 24px rgba(0,0,0,.5); }
|
|
41
|
+
#tooltip .tid { font-weight:600; }
|
|
42
|
+
#tooltip .badges { margin:4px 0; }
|
|
43
|
+
#tooltip .badge { display:inline-block; font-size:10px; padding:1px 6px; border-radius:10px;
|
|
44
|
+
background:#1e293b; margin-right:4px; color:var(--muted); }
|
|
45
|
+
#tooltip .d { color:var(--muted); margin-top:5px; max-height:140px; overflow:hidden; }
|
|
46
|
+
#empty { position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
|
|
47
|
+
color:var(--muted); font-size:13px; }
|
|
48
|
+
</style>
|
|
49
|
+
</head>
|
|
50
|
+
<body>
|
|
51
|
+
<header>
|
|
52
|
+
<h1 id="title">Roadmap Dependency Graph</h1>
|
|
53
|
+
<span class="sub" id="subtitle"></span>
|
|
54
|
+
<div class="controls">
|
|
55
|
+
<button id="fit">Fit</button>
|
|
56
|
+
<button id="zin">+</button>
|
|
57
|
+
<button id="zout">−</button>
|
|
58
|
+
</div>
|
|
59
|
+
</header>
|
|
60
|
+
<div id="cy"></div>
|
|
61
|
+
<div id="empty" style="display:none">No active features to graph.</div>
|
|
62
|
+
<div id="legend">
|
|
63
|
+
<div><b>Status</b></div>
|
|
64
|
+
<div><span class="swatch" style="background:#3b82f6"></span>planned</div>
|
|
65
|
+
<div><span class="swatch" style="background:#22c55e"></span>in progress</div>
|
|
66
|
+
<div><span class="swatch" style="background:#f59e0b"></span>partial</div>
|
|
67
|
+
<div><span class="swatch" style="background:#ef4444"></span>blocked</div>
|
|
68
|
+
<div><span class="swatch" style="background:#6b7280"></span>parked</div>
|
|
69
|
+
<div style="margin-top:6px"><b>Edges</b></div>
|
|
70
|
+
<div>→ depends on · ⇢ concurrent</div>
|
|
71
|
+
<div style="margin-top:4px">border weight = priority</div>
|
|
72
|
+
</div>
|
|
73
|
+
<div id="tracks"></div>
|
|
74
|
+
<div id="tooltip"></div>
|
|
75
|
+
<script>
|
|
76
|
+
/* @generated:config:start */
|
|
77
|
+
const GRAPH_CONFIG = {};
|
|
78
|
+
/* @generated:config:end */
|
|
79
|
+
/* @generated:nodes:start */
|
|
80
|
+
const nodes = [];
|
|
81
|
+
/* @generated:nodes:end */
|
|
82
|
+
/* @generated:edges:start */
|
|
83
|
+
const edges = [];
|
|
84
|
+
/* @generated:edges:end */
|
|
85
|
+
|
|
86
|
+
const STATUS_BORDER = { planned:'#3b82f6', in_progress:'#22c55e', partial:'#f59e0b',
|
|
87
|
+
blocked:'#ef4444', parked:'#6b7280' };
|
|
88
|
+
|
|
89
|
+
function hexToRgb(h){ const m=/^#?([0-9a-f]{6})$/i.exec(h||''); if(!m) return [100,116,139];
|
|
90
|
+
const n=parseInt(m[1],16); return [(n>>16)&255,(n>>8)&255,n&255]; }
|
|
91
|
+
function darken(h,f){ const [r,g,b]=hexToRgb(h);
|
|
92
|
+
return 'rgb('+Math.round(r*f)+','+Math.round(g*f)+','+Math.round(b*f)+')'; }
|
|
93
|
+
|
|
94
|
+
document.getElementById('title').textContent = GRAPH_CONFIG.title || 'Roadmap Dependency Graph';
|
|
95
|
+
document.getElementById('subtitle').textContent = GRAPH_CONFIG.subtitle || '';
|
|
96
|
+
|
|
97
|
+
const tracks = GRAPH_CONFIG.tracks || {};
|
|
98
|
+
const trackStyles = Object.keys(tracks).map(t => ({
|
|
99
|
+
selector: 'node[track="'+t+'"]',
|
|
100
|
+
style: { 'background-color': darken(tracks[t], 0.22) }
|
|
101
|
+
}));
|
|
102
|
+
|
|
103
|
+
const elements = [
|
|
104
|
+
...nodes.map(n => ({ data: n })),
|
|
105
|
+
...edges.map(e => ({ data: e })),
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
if (nodes.length === 0) { document.getElementById('empty').style.display = 'flex'; }
|
|
109
|
+
|
|
110
|
+
const cy = cytoscape({
|
|
111
|
+
container: document.getElementById('cy'),
|
|
112
|
+
elements,
|
|
113
|
+
wheelSensitivity: 0.2,
|
|
114
|
+
style: [
|
|
115
|
+
{ selector: 'node', style: {
|
|
116
|
+
'label':'data(label)','text-valign':'center','text-halign':'center','font-size':'9px',
|
|
117
|
+
'font-weight':'500','color':'#e2e8f0','text-wrap':'wrap','text-max-width':'92px',
|
|
118
|
+
'width':'112px','height':'48px','shape':'round-rectangle','background-color':'#1e293b',
|
|
119
|
+
'border-color':'#3b82f6','border-width':2,'border-style':'solid' } },
|
|
120
|
+
...trackStyles,
|
|
121
|
+
{ selector: 'node[status="planned"]', style:{ 'border-color': STATUS_BORDER.planned } },
|
|
122
|
+
{ selector: 'node[status="in_progress"]', style:{ 'border-color': STATUS_BORDER.in_progress } },
|
|
123
|
+
{ selector: 'node[status="partial"]', style:{ 'border-color': STATUS_BORDER.partial } },
|
|
124
|
+
{ selector: 'node[status="blocked"]', style:{ 'border-color': STATUS_BORDER.blocked } },
|
|
125
|
+
{ selector: 'node[status="parked"]', style:{ 'border-color': STATUS_BORDER.parked, 'opacity':0.78 } },
|
|
126
|
+
{ selector: 'node[priority="high"]', style:{ 'border-width':3 } },
|
|
127
|
+
{ selector: 'node[priority="medium"]', style:{ 'border-width':2 } },
|
|
128
|
+
{ selector: 'node[priority="low"]', style:{ 'border-width':1 } },
|
|
129
|
+
{ selector: 'edge[type="dep"]', style:{ 'width':1.5,'line-color':'#475569',
|
|
130
|
+
'target-arrow-color':'#475569','target-arrow-shape':'triangle','arrow-scale':0.9,
|
|
131
|
+
'curve-style':'bezier' } },
|
|
132
|
+
{ selector: 'edge[type="concurrent"]', style:{ 'width':1.4,'line-color':'#64748b',
|
|
133
|
+
'line-style':'dashed','line-dash-pattern':[6,3],'target-arrow-shape':'none',
|
|
134
|
+
'curve-style':'bezier' } },
|
|
135
|
+
{ selector: '.dim', style:{ 'opacity':0.12 } },
|
|
136
|
+
],
|
|
137
|
+
layout: { name:'dagre', rankDir:'LR', nodeSep:28, rankSep:68, edgeSep:10, padding:20,
|
|
138
|
+
animate:false, fit:true },
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// track panel
|
|
142
|
+
const tp = document.getElementById('tracks');
|
|
143
|
+
const usedTracks = [...new Set(nodes.map(n => n.track))].filter(Boolean).sort();
|
|
144
|
+
if (usedTracks.length) {
|
|
145
|
+
tp.innerHTML = '<div><b style="color:var(--ink)">Tracks</b></div>' + usedTracks.map(t =>
|
|
146
|
+
'<div><span class="swatch" style="background:'+(tracks[t]||'#64748b')+'"></span>'+t+'</div>').join('');
|
|
147
|
+
} else { tp.style.display = 'none'; }
|
|
148
|
+
|
|
149
|
+
// tooltip
|
|
150
|
+
const tip = document.getElementById('tooltip');
|
|
151
|
+
cy.on('mouseover', 'node', (ev) => {
|
|
152
|
+
const d = ev.target.data();
|
|
153
|
+
// depends-on / unblocks are directed 'dep' edges only; concurrent edges are
|
|
154
|
+
// undirected (canonicalized order) so list their peers separately.
|
|
155
|
+
const deps = ev.target.incomers('edge[type="dep"]').sources().map(n => n.data('id'));
|
|
156
|
+
const unblocks = ev.target.outgoers('edge[type="dep"]').targets().map(n => n.data('id'));
|
|
157
|
+
const concurrent = ev.target.connectedEdges('edge[type="concurrent"]')
|
|
158
|
+
.connectedNodes().filter(n => n.id() !== ev.target.id()).map(n => n.data('id'));
|
|
159
|
+
tip.innerHTML = '<div class="tid">'+esc(d.id)+'</div>'
|
|
160
|
+
+ '<div>'+esc(d.name||'')+'</div>'
|
|
161
|
+
+ '<div class="badges"><span class="badge">'+esc(d.status)+'</span>'
|
|
162
|
+
+ '<span class="badge">'+esc(d.priority)+'</span><span class="badge">'+esc(d.track)+'</span></div>'
|
|
163
|
+
+ (deps.length ? '<div class="d"><b>depends on:</b> '+esc(deps.join(', '))+'</div>' : '')
|
|
164
|
+
+ (unblocks.length ? '<div class="d"><b>unblocks:</b> '+esc(unblocks.join(', '))+'</div>' : '')
|
|
165
|
+
+ (concurrent.length ? '<div class="d"><b>concurrent with:</b> '+esc(concurrent.join(', '))+'</div>' : '')
|
|
166
|
+
+ (d.desc ? '<div class="d">'+esc(d.desc)+'</div>' : '');
|
|
167
|
+
tip.style.display = 'block';
|
|
168
|
+
});
|
|
169
|
+
cy.on('mouseout', 'node', () => { tip.style.display = 'none'; });
|
|
170
|
+
cy.on('mousemove', (ev) => {
|
|
171
|
+
if (tip.style.display !== 'block') return;
|
|
172
|
+
const x = ev.originalEvent.clientX + 14, y = ev.originalEvent.clientY + 14;
|
|
173
|
+
tip.style.left = Math.min(x, window.innerWidth - 340) + 'px';
|
|
174
|
+
tip.style.top = Math.min(y, window.innerHeight - 160) + 'px';
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// click to highlight dependency chain
|
|
178
|
+
cy.on('tap', 'node', (ev) => {
|
|
179
|
+
const n = ev.target;
|
|
180
|
+
const chain = n.predecessors().union(n.successors()).union(n);
|
|
181
|
+
cy.elements().addClass('dim');
|
|
182
|
+
chain.removeClass('dim');
|
|
183
|
+
});
|
|
184
|
+
cy.on('tap', (ev) => { if (ev.target === cy) cy.elements().removeClass('dim'); });
|
|
185
|
+
|
|
186
|
+
document.getElementById('fit').onclick = () => cy.fit(undefined, 30);
|
|
187
|
+
document.getElementById('zin').onclick = () => cy.zoom(cy.zoom() * 1.2);
|
|
188
|
+
document.getElementById('zout').onclick = () => cy.zoom(cy.zoom() / 1.2);
|
|
189
|
+
|
|
190
|
+
function esc(s){ return String(s).replace(/[&<>"]/g, c =>
|
|
191
|
+
({'&':'&','<':'<','>':'>','"':'"'}[c])); }
|
|
192
|
+
</script>
|
|
193
|
+
</body>
|
|
194
|
+
</html>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smartmemory/compose",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.26-beta",
|
|
4
4
|
"description": "Structured AI dev pipeline — goal-to-product orchestration with gates, iteration loops, and feature lifecycle management.",
|
|
5
5
|
"author": "SmartMemory",
|
|
6
6
|
"license": "MIT",
|
|
@@ -457,6 +457,57 @@ export async function toolValidateProject(args = {}) {
|
|
|
457
457
|
return { ...finalResult, reconcile };
|
|
458
458
|
}
|
|
459
459
|
|
|
460
|
+
// COMP-ROADMAP-GRAPH-1: generate / verify the roadmap dependency graph HTML.
|
|
461
|
+
// Returns small summaries only (counts + warning/dangling lists) — never the
|
|
462
|
+
// HTML body, which would blow the MCP response token cap.
|
|
463
|
+
export async function toolRoadmapGraph({ project, out } = {}) {
|
|
464
|
+
const { generateRoadmapGraph } = await import('../lib/roadmap-graph/index.js');
|
|
465
|
+
const cwd = project || getTargetRoot();
|
|
466
|
+
try {
|
|
467
|
+
const r = generateRoadmapGraph(cwd, { out });
|
|
468
|
+
return {
|
|
469
|
+
path: r.path,
|
|
470
|
+
nodeCount: r.nodeCount,
|
|
471
|
+
edgeCount: r.edgeCount,
|
|
472
|
+
droppedCount: r.droppedCount,
|
|
473
|
+
warnings: r.warnings,
|
|
474
|
+
};
|
|
475
|
+
} catch (err) {
|
|
476
|
+
if (err && err.code === 'DANGLING_EDGE') {
|
|
477
|
+
const e = new Error(err.message);
|
|
478
|
+
e.code = 'DANGLING_EDGE';
|
|
479
|
+
e.dangling = err.dangling;
|
|
480
|
+
throw e;
|
|
481
|
+
}
|
|
482
|
+
throw err;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
export async function toolRoadmapGraphCheck({ project, out } = {}) {
|
|
487
|
+
const { checkRoadmapGraph } = await import('../lib/roadmap-graph/index.js');
|
|
488
|
+
const cwd = project || getTargetRoot();
|
|
489
|
+
try {
|
|
490
|
+
const r = checkRoadmapGraph(cwd, { out });
|
|
491
|
+
return {
|
|
492
|
+
matches: r.matches,
|
|
493
|
+
exists: r.exists,
|
|
494
|
+
path: r.path,
|
|
495
|
+
diffSummary: r.diffSummary,
|
|
496
|
+
nodeCount: r.nodeCount,
|
|
497
|
+
edgeCount: r.edgeCount,
|
|
498
|
+
warnings: r.warnings,
|
|
499
|
+
};
|
|
500
|
+
} catch (err) {
|
|
501
|
+
if (err && err.code === 'DANGLING_EDGE') {
|
|
502
|
+
const e = new Error(err.message);
|
|
503
|
+
e.code = 'DANGLING_EDGE';
|
|
504
|
+
e.dangling = err.dangling;
|
|
505
|
+
throw e;
|
|
506
|
+
}
|
|
507
|
+
throw err;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
460
511
|
export async function toolBindSession({ featureCode, profile } = {}) {
|
|
461
512
|
let result;
|
|
462
513
|
try {
|
package/server/compose-mcp.js
CHANGED
|
@@ -59,6 +59,8 @@ import {
|
|
|
59
59
|
toolGetCompletions,
|
|
60
60
|
toolValidateFeature,
|
|
61
61
|
toolValidateProject,
|
|
62
|
+
toolRoadmapGraph,
|
|
63
|
+
toolRoadmapGraphCheck,
|
|
62
64
|
toolSetWorkspace,
|
|
63
65
|
toolGetWorkspace,
|
|
64
66
|
toolWriteCheckpoint,
|
|
@@ -383,6 +385,28 @@ const TOOLS = [
|
|
|
383
385
|
},
|
|
384
386
|
},
|
|
385
387
|
},
|
|
388
|
+
{
|
|
389
|
+
name: 'roadmap_graph',
|
|
390
|
+
description: 'COMP-ROADMAP-GRAPH-1: generate a self-contained roadmap dependency-graph HTML from feature.json status/phase + per-feature deps.yaml edges + display frontmatter. Drops COMPLETE/SUPERSEDED/KILLED nodes; refuses (DANGLING_EDGE) when any edge points at an unknown feature. Deterministic/idempotent. Returns a small summary (path + counts + warnings), never the HTML body.',
|
|
391
|
+
inputSchema: {
|
|
392
|
+
type: 'object',
|
|
393
|
+
properties: {
|
|
394
|
+
project: { type: 'string', description: 'Project root path. Default: current workspace.' },
|
|
395
|
+
out: { type: 'string', description: 'Output HTML path (relative to project root unless absolute). Default: compose.json#roadmap_graph.out or roadmap-graph.html.' },
|
|
396
|
+
},
|
|
397
|
+
},
|
|
398
|
+
},
|
|
399
|
+
{
|
|
400
|
+
name: 'roadmap_graph_check',
|
|
401
|
+
description: 'COMP-ROADMAP-GRAPH-1: render the roadmap graph in-memory and diff against the on-disk HTML without writing. Returns { matches, exists, diffSummary, counts, warnings }. matches:false means the file is stale/missing — run roadmap_graph. Raises DANGLING_EDGE on a bad edge.',
|
|
402
|
+
inputSchema: {
|
|
403
|
+
type: 'object',
|
|
404
|
+
properties: {
|
|
405
|
+
project: { type: 'string', description: 'Project root path. Default: current workspace.' },
|
|
406
|
+
out: { type: 'string', description: 'Output HTML path to compare against. Default: compose.json#roadmap_graph.out or roadmap-graph.html.' },
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
},
|
|
386
410
|
{
|
|
387
411
|
name: 'propose_followup',
|
|
388
412
|
description: 'File a follow-up feature against a parent. Auto-numbers the next code in the parent\'s namespace (parent_code-N), adds the ROADMAP row, links surfaced_by from new → parent, and scaffolds design.md with a "## Why" rationale block. Idempotent on (parent_code, idempotency_key); resumes across partial failures via an inflight ledger.',
|
|
@@ -709,6 +733,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
709
733
|
case 'get_completions': result = await toolGetCompletions(args); break;
|
|
710
734
|
case 'validate_feature': result = await toolValidateFeature(args); break;
|
|
711
735
|
case 'validate_project': result = await toolValidateProject(args); break;
|
|
736
|
+
case 'roadmap_graph': result = await toolRoadmapGraph(args); break;
|
|
737
|
+
case 'roadmap_graph_check': result = await toolRoadmapGraphCheck(args); break;
|
|
712
738
|
case 'propose_followup': result = await toolProposeFollowup(args); break;
|
|
713
739
|
case 'write_checkpoint': result = await toolWriteCheckpoint(args); break;
|
|
714
740
|
case 'compose_resume': result = await toolComposeResume(args); break;
|
|
@@ -34,7 +34,7 @@ const REVIEWER_ALLOW = [
|
|
|
34
34
|
'get_vision_items', 'get_item_detail', 'get_phase_summary', 'get_blocked_items',
|
|
35
35
|
'get_current_session', 'get_feature_lifecycle', 'get_feature_artifacts', 'get_feature_links',
|
|
36
36
|
'get_pending_gates', 'get_changelog_entries', 'get_journal_entries', 'get_completions',
|
|
37
|
-
'validate_feature', 'validate_project', 'roadmap_diff', 'assess_feature_artifacts',
|
|
37
|
+
'validate_feature', 'validate_project', 'roadmap_diff', 'roadmap_graph_check', 'assess_feature_artifacts',
|
|
38
38
|
'set_workspace', 'get_workspace', 'bind_session',
|
|
39
39
|
];
|
|
40
40
|
|