@connorbritain/roadmap 0.3.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 +495 -0
- package/docs/DEPLOYMENT.md +195 -0
- package/package.json +46 -0
- package/schema/backlog.schema.json +65 -0
- package/schema/roadmap.schema.json +273 -0
- package/scripts/assistant.mjs +30 -0
- package/scripts/backlog.mjs +81 -0
- package/scripts/cleanup.mjs +69 -0
- package/scripts/cli.mjs +119 -0
- package/scripts/cycle.mjs +89 -0
- package/scripts/dispatch.mjs +302 -0
- package/scripts/estimate.mjs +201 -0
- package/scripts/fanout.mjs +355 -0
- package/scripts/grab.mjs +119 -0
- package/scripts/init.mjs +60 -0
- package/scripts/lib/assistant-core.mjs +64 -0
- package/scripts/lib/backlog-core.mjs +318 -0
- package/scripts/lib/brief.mjs +123 -0
- package/scripts/lib/cli-core.mjs +97 -0
- package/scripts/lib/cycle-core.mjs +58 -0
- package/scripts/lib/estimate-core.mjs +204 -0
- package/scripts/lib/execution.mjs +186 -0
- package/scripts/lib/fanout-core.mjs +39 -0
- package/scripts/lib/graph.mjs +346 -0
- package/scripts/lib/journal-core.mjs +48 -0
- package/scripts/lib/linear-core.mjs +812 -0
- package/scripts/lib/mcp-core.mjs +313 -0
- package/scripts/lib/plan.mjs +68 -0
- package/scripts/lib/plate-core.mjs +53 -0
- package/scripts/lib/pr-watch-core.mjs +72 -0
- package/scripts/lib/priority.mjs +43 -0
- package/scripts/lib/recommend.mjs +178 -0
- package/scripts/lib/render-core.mjs +215 -0
- package/scripts/lib/review-core.mjs +104 -0
- package/scripts/lib/store.mjs +113 -0
- package/scripts/lib/sync-core.mjs +89 -0
- package/scripts/lib/validate-core.mjs +130 -0
- package/scripts/lib/wizard-core.mjs +50 -0
- package/scripts/linear.mjs +780 -0
- package/scripts/mcp.mjs +193 -0
- package/scripts/next.mjs +43 -0
- package/scripts/plate.mjs +77 -0
- package/scripts/promote.mjs +27 -0
- package/scripts/prompt.mjs +124 -0
- package/scripts/render.mjs +39 -0
- package/scripts/review.mjs +79 -0
- package/scripts/scheduler.mjs +78 -0
- package/scripts/set.mjs +31 -0
- package/scripts/show.mjs +53 -0
- package/scripts/test/run.mjs +3943 -0
- package/scripts/validate.mjs +28 -0
- package/scripts/watch-prs.mjs +72 -0
- package/scripts/wizard.mjs +97 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// roadmap — concurrency recommender.
|
|
2
|
+
// Recommends a max-parallel-sessions cap from a resource + repo-purpose eval, and
|
|
3
|
+
// reports WHICH constraint binds. The point: concurrency that exceeds your RAM/CPU
|
|
4
|
+
// thrashes the machine, concurrency that exceeds free DISK fails mid-checkout, and
|
|
5
|
+
// concurrency that exceeds independent work or your ability to review the resulting
|
|
6
|
+
// PRs is wasted. So we take the min of five real ceilings.
|
|
7
|
+
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import { existsSync, statfsSync } from "node:fs";
|
|
10
|
+
import { spawnSync } from "node:child_process";
|
|
11
|
+
import { dirname, resolve } from "node:path";
|
|
12
|
+
import { resolveGate } from "./graph.mjs";
|
|
13
|
+
|
|
14
|
+
// Per-session resource cost by weight class — cross-language defaults (a full test
|
|
15
|
+
// suite / heavy compile ~3.5GB/2 cores; a build/lint ~1.5GB/1 core; docs cheap).
|
|
16
|
+
// A repo can override any of these via meta.weight_cost: { heavy: {ram, cores}, ... }.
|
|
17
|
+
const WEIGHT_COST = {
|
|
18
|
+
heavy: { ram: 3.5, cores: 2.0 },
|
|
19
|
+
medium: { ram: 1.5, cores: 1.0 },
|
|
20
|
+
light: { ram: 0.6, cores: 0.5 },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// Anything that isn't documentation counts as "code-ish" (so it floors at medium when
|
|
24
|
+
// the gate's runner isn't recognized). Language-agnostic on purpose.
|
|
25
|
+
const DOCISH = /\.(md|mdx|rst|txt|ya?ml|toml|json)$|(^|\/)docs?\//i;
|
|
26
|
+
|
|
27
|
+
// Built-in, MULTI-ECOSYSTEM runner → weight. heavy = a full test suite or a heavy
|
|
28
|
+
// compile; medium = a build / typecheck / lint. Repos EXTEND or override via
|
|
29
|
+
// meta.weight_patterns: { heavy: ["regex", ...], medium: [...] } (strings, case-insensitive).
|
|
30
|
+
const BUILTIN_PATTERNS = {
|
|
31
|
+
heavy: [
|
|
32
|
+
/dotnet test/, /\bcargo test\b/, /\bpytest\b/, /\bgo test\b/, /\btox\b/, /\bnox\b/,
|
|
33
|
+
/gradlew?\b[^\n]*\b(test|check)\b/, /\bmvn\b[^\n]*\b(test|verify)\b/, /\brspec\b/, /\bphpunit\b/,
|
|
34
|
+
/\bmix test\b/, /\bctest\b/, /\bbazel test\b/, /\bjest\b/, /vitest\b[^\n]*\brun\b/,
|
|
35
|
+
/(npm|yarn|pnpm)\s+(run\s+)?test\b/, /\bdeno test\b/, /\bmake\b[^\n]*\btest\b/, /\brake\b[^\n]*\btest\b/,
|
|
36
|
+
],
|
|
37
|
+
medium: [
|
|
38
|
+
/dotnet build/, /\bcargo (build|check|clippy)\b/, /\bgo build\b/, /gradlew?\b[^\n]*\bbuild\b/,
|
|
39
|
+
/\bmvn\b[^\n]*\b(compile|package)\b/, /\btsc\b/, /\bvitest\b/, /\beslint\b/, /\bruff\b/, /\bmypy\b/,
|
|
40
|
+
/(npm|yarn|pnpm)\s+(run\s+)?build\b/, /\bmake\b/, /\bcmake\b/, /\bbundle\b/, /\bwebpack\b/, /\bvite build\b/,
|
|
41
|
+
],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function compilePatterns(graph) {
|
|
45
|
+
const extra = (graph.meta && graph.meta.weight_patterns) || {};
|
|
46
|
+
const toRe = (s) => (s instanceof RegExp ? s : new RegExp(s, "i"));
|
|
47
|
+
return {
|
|
48
|
+
heavy: [...BUILTIN_PATTERNS.heavy, ...((extra.heavy) || []).map(toRe)],
|
|
49
|
+
medium: [...BUILTIN_PATTERNS.medium, ...((extra.medium) || []).map(toRe)],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function costTable(graph) {
|
|
54
|
+
const o = (graph.meta && graph.meta.weight_cost) || {};
|
|
55
|
+
return {
|
|
56
|
+
heavy: { ...WEIGHT_COST.heavy, ...(o.heavy || {}) },
|
|
57
|
+
medium: { ...WEIGHT_COST.medium, ...(o.medium || {}) },
|
|
58
|
+
light: { ...WEIGHT_COST.light, ...(o.light || {}) },
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Classify a slice's weight: explicit `weight` override wins; else docs-only → light;
|
|
63
|
+
// else match the resolved gate against the built-in + repo runner patterns; else a slice
|
|
64
|
+
// that touches non-docs files floors at medium; else meta.default_weight (or light).
|
|
65
|
+
export function nodeWeight(node, graph) {
|
|
66
|
+
if (node.sprint && node.sprint.weight) return node.sprint.weight;
|
|
67
|
+
const gate = (resolveGate(node, graph) || "").toLowerCase();
|
|
68
|
+
const touches = [...(node.touches || []), ...(node.owns || [])];
|
|
69
|
+
const allDocish = touches.length > 0 && touches.every((f) => DOCISH.test(f));
|
|
70
|
+
if (allDocish) return "light";
|
|
71
|
+
const pats = compilePatterns(graph);
|
|
72
|
+
if (pats.heavy.some((re) => re.test(gate))) return "heavy";
|
|
73
|
+
if (pats.medium.some((re) => re.test(gate))) return "medium";
|
|
74
|
+
if (touches.some((f) => !DOCISH.test(f))) return "medium"; // unknown runner but touches code
|
|
75
|
+
return (graph.meta && graph.meta.default_weight) || "light";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Disk feasibility probe: what one more worktree costs vs what the worktree volume has free.
|
|
79
|
+
// Per-worktree cost = the checked-out tree's blob bytes (git ls-tree -r -l HEAD; one fast
|
|
80
|
+
// command, no fs walk) × a safety factor, overridable via meta.worktree_gb — the calibration
|
|
81
|
+
// knob for repos whose gates install node_modules or build artifacts per worktree.
|
|
82
|
+
// ponytail: 1.3× covers normal build litter; per-worktree package installs can exceed it —
|
|
83
|
+
// meta.worktree_gb is the knob, no measurement machinery.
|
|
84
|
+
// Returns { perWorktreeGb, freeGb } or null when undetectable (no git HEAD, statfs
|
|
85
|
+
// unsupported) — the caller then simply skips the disk ceiling.
|
|
86
|
+
export function probeDisk(graph, cwd = process.cwd()) {
|
|
87
|
+
try {
|
|
88
|
+
const meta = (graph && graph.meta) || {};
|
|
89
|
+
let perWorktreeGb = typeof meta.worktree_gb === "number" ? meta.worktree_gb : null;
|
|
90
|
+
if (perWorktreeGb == null) {
|
|
91
|
+
const r = spawnSync("git", ["ls-tree", "-r", "-l", "HEAD"], { cwd, encoding: "utf8", maxBuffer: 64 * 2 ** 20 });
|
|
92
|
+
if (r.status !== 0 || !r.stdout) return null;
|
|
93
|
+
let bytes = 0;
|
|
94
|
+
for (const line of r.stdout.split("\n")) {
|
|
95
|
+
const m = /\s(\d+)\t/.exec(line); // blob size column; trees show "-"
|
|
96
|
+
if (m) bytes += Number(m[1]);
|
|
97
|
+
}
|
|
98
|
+
if (!bytes) return null;
|
|
99
|
+
perWorktreeGb = (bytes / 2 ** 30) * 1.3;
|
|
100
|
+
}
|
|
101
|
+
// Free space on the volume that will hold the worktrees — walk worktree_root up to its
|
|
102
|
+
// nearest EXISTING ancestor (the root itself may not exist until the first fanout).
|
|
103
|
+
let dir = resolve(meta.worktree_root || resolve(cwd, "..", "_worktrees"));
|
|
104
|
+
while (!existsSync(dir)) {
|
|
105
|
+
const up = dirname(dir);
|
|
106
|
+
if (up === dir) break;
|
|
107
|
+
dir = up;
|
|
108
|
+
}
|
|
109
|
+
const s = statfsSync(dir); // Node >= 18.15, win32 + POSIX
|
|
110
|
+
return { perWorktreeGb, freeGb: (s.bavail * s.bsize) / 2 ** 30 };
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function systemInfo() {
|
|
117
|
+
const cpus = os.cpus() || [];
|
|
118
|
+
return {
|
|
119
|
+
cores: cpus.length || 4,
|
|
120
|
+
totalGb: os.totalmem() / 2 ** 30,
|
|
121
|
+
freeGb: os.freemem() / 2 ** 30,
|
|
122
|
+
platform: os.platform(),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const avg = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0);
|
|
127
|
+
|
|
128
|
+
// Recommend a concurrency cap over the set of slices we'd actually fan out (`ready`).
|
|
129
|
+
// opts: { sys, useFree (RAM basis), reviewCeiling, osCoreReserve, osRamReserveGb,
|
|
130
|
+
// disk ({ perWorktreeGb, freeGb } from probeDisk; absent/null → no disk ceiling) }
|
|
131
|
+
export function recommendConcurrency(ready, graph, opts = {}) {
|
|
132
|
+
const sys = opts.sys || systemInfo();
|
|
133
|
+
const reviewCeiling = opts.reviewCeiling ?? 5;
|
|
134
|
+
const coreReserve = opts.osCoreReserve ?? 2; // leave cores for the OS/editor/lead
|
|
135
|
+
const ramReserve = opts.osRamReserveGb ?? 4;
|
|
136
|
+
const disk = opts.disk || null; // stays PURE: callers probe (probeDisk) and inject
|
|
137
|
+
|
|
138
|
+
const COST = costTable(graph);
|
|
139
|
+
const costs = (ready.length ? ready : [null]).map((n) =>
|
|
140
|
+
n ? COST[nodeWeight(n, graph)] : COST.medium
|
|
141
|
+
);
|
|
142
|
+
const avgRam = avg(costs.map((c) => c.ram)) || COST.medium.ram;
|
|
143
|
+
const avgCores = avg(costs.map((c) => c.cores)) || COST.medium.cores;
|
|
144
|
+
|
|
145
|
+
const usableCores = Math.max(1, sys.cores - coreReserve);
|
|
146
|
+
const ramBasis = opts.useFree ? sys.freeGb : sys.totalGb * 0.75; // 75% of total, or live-free
|
|
147
|
+
const usableRamGb = Math.max(1, ramBasis - ramReserve);
|
|
148
|
+
|
|
149
|
+
const cpuCap = Math.max(1, Math.floor(usableCores / avgCores));
|
|
150
|
+
const ramCap = Math.max(1, Math.floor(usableRamGb / avgRam));
|
|
151
|
+
const workCap = Math.max(1, ready.length || 1);
|
|
152
|
+
|
|
153
|
+
const candidates = [
|
|
154
|
+
{ n: cpuCap, why: `CPU — ${sys.cores} cores (− ${coreReserve} reserved) ÷ ~${avgCores.toFixed(1)}/session` },
|
|
155
|
+
{ n: ramCap, why: `RAM — ~${ramBasis.toFixed(0)}GB usable ÷ ~${avgRam.toFixed(1)}GB/session` },
|
|
156
|
+
{ n: workCap, why: `work — ${ready.length} independent ready slice(s)` },
|
|
157
|
+
{ n: reviewCeiling, why: `review — PR review/merge bottleneck (soft ceiling)` },
|
|
158
|
+
];
|
|
159
|
+
// Disk ceiling — the only one allowed to compute to 0: recommended stays >= 1 (soft
|
|
160
|
+
// auto-dial), but callers that create worktrees (fan, grab) hard-block on disk.cap < 1.
|
|
161
|
+
let diskCap = null;
|
|
162
|
+
if (disk) {
|
|
163
|
+
const diskReserve = opts.diskReserveGb ?? 2;
|
|
164
|
+
diskCap = Math.floor(Math.max(0, disk.freeGb - diskReserve) / Math.max(disk.perWorktreeGb, 0.01));
|
|
165
|
+
candidates.push({ n: Math.max(diskCap, 0), why: `disk — need ~${disk.perWorktreeGb.toFixed(1)}GB/worktree, ${disk.freeGb.toFixed(1)}GB free` });
|
|
166
|
+
}
|
|
167
|
+
const binding = candidates.reduce((a, b) => (b.n < a.n ? b : a));
|
|
168
|
+
return {
|
|
169
|
+
recommended: Math.max(1, binding.n),
|
|
170
|
+
binding,
|
|
171
|
+
candidates,
|
|
172
|
+
sys,
|
|
173
|
+
disk: disk ? { ...disk, cap: diskCap } : null,
|
|
174
|
+
avgRam,
|
|
175
|
+
avgCores,
|
|
176
|
+
weights: ready.map((n) => ({ invoke: n.invoke, weight: nodeWeight(n, graph) })),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// roadmap — pure renderer: roadmap graph -> SLICES.md string.
|
|
2
|
+
// No IO: takes a parsed graph, returns the markdown. render.mjs writes it to a file;
|
|
3
|
+
// the MCP mutate tools call it to re-render SLICES.md after editing the YAML, so the
|
|
4
|
+
// generated view and the source can never drift.
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
flatten, computeWaves, execPlan, sessionsRemaining,
|
|
8
|
+
statusDisplay, emojiFor, isDone, coherenceEnabled,
|
|
9
|
+
} from "./graph.mjs";
|
|
10
|
+
import { executionDirectiveLines } from "./execution.mjs";
|
|
11
|
+
import { tierBadge } from "./priority.mjs";
|
|
12
|
+
|
|
13
|
+
// renderMarkdown(graph, { cap }) -> string. cap defaults to meta.default_concurrency (or 3).
|
|
14
|
+
export function renderMarkdown(graph, opts = {}) {
|
|
15
|
+
const model = flatten(graph);
|
|
16
|
+
const cap = opts.cap != null ? Number(opts.cap) : ((graph.meta && graph.meta.default_concurrency) || 3);
|
|
17
|
+
const links = (graph.meta && graph.meta.links) || {}; // optional repo cross-refs (narrative/status/tracker/bootstrap/strategy)
|
|
18
|
+
const mdlink = (label, path) => (path ? `[\`${path}\`](${path})` : null);
|
|
19
|
+
|
|
20
|
+
const B = "`";
|
|
21
|
+
const lines = [];
|
|
22
|
+
const w = (s = "") => lines.push(s);
|
|
23
|
+
|
|
24
|
+
// ---- header / intro -------------------------------------------------------
|
|
25
|
+
w("<!-- GENERATED from docs/roadmap/roadmap.yaml by /sync (render.mjs). Do not edit this file directly — edit the YAML and re-render. -->");
|
|
26
|
+
w("# Slices — the pickup-able work catalog");
|
|
27
|
+
w("");
|
|
28
|
+
const introRefs = [
|
|
29
|
+
links.narrative && `mirrored in ${mdlink("narrative", links.narrative)}`,
|
|
30
|
+
(links.status || links.tracker) && `tracked in ${[mdlink("status", links.status), mdlink("tracker", links.tracker)].filter(Boolean).join(" + ")}`,
|
|
31
|
+
].filter(Boolean);
|
|
32
|
+
w(`> **What this is:** the menu, grouped by PI (Program Increment) with sprints nested under each. Each sprint is a named **slice** you can pick up cold: spin up a session, name the slice, and it reads that slice's prescribed read-order and self-orients. Slice names are the \`/slice\` keys${introRefs.length ? ", " + introRefs.join(", ") : ""}.`);
|
|
33
|
+
w("");
|
|
34
|
+
w("Use `roadmap show <name>` to orient on a menu entry; `roadmap plan` computes ready waves; `roadmap fan` prepares worktrees and can launch an explicitly configured assistant; `roadmap render` refreshes this file after merges. Claude slash skills are optional conveniences where installed.");
|
|
35
|
+
w("");
|
|
36
|
+
// Backlog pointer — emitted only when the repo has a backlog (opts.backlog), so a
|
|
37
|
+
// backlog-free repo renders byte-identically.
|
|
38
|
+
if (opts.backlog) {
|
|
39
|
+
w(`> **Backlog:** ${opts.backlog.open} open item(s) — erratic/follow-up work lives in [\`docs/BACKLOG.md\`](BACKLOG.md); capture with \`/backlog\` or \`roadmap backlog add\`, launch with \`roadmap grab <id>\`.`);
|
|
40
|
+
w("");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---- cross-PI wave map ----------------------------------------------------
|
|
44
|
+
w(`## Ready now — wave map (cap ${cap})`);
|
|
45
|
+
w("");
|
|
46
|
+
w("Derived from the dependency graph: which slices can run concurrently right now, what waits behind them, and what is held on a human. Change the cap with `roadmap plan --cap N` or `roadmap fan --cap N`.");
|
|
47
|
+
w("");
|
|
48
|
+
let waveResult;
|
|
49
|
+
try {
|
|
50
|
+
waveResult = computeWaves(model, cap, { coherence: coherenceEnabled(graph.meta) });
|
|
51
|
+
} catch (e) {
|
|
52
|
+
w(`> ⚠ ${e.message}`);
|
|
53
|
+
waveResult = { waves: [], held: { onHuman: [], blocked: [] } };
|
|
54
|
+
}
|
|
55
|
+
if (waveResult.waves.length === 0) {
|
|
56
|
+
w("_No agent-runnable slices right now._");
|
|
57
|
+
} else {
|
|
58
|
+
waveResult.waves.forEach((wave, i) => {
|
|
59
|
+
w(`**Wave ${i + 1}** — launch concurrently (disjoint files, deps satisfied):`);
|
|
60
|
+
for (const n of wave) {
|
|
61
|
+
const files = [...n.touches, ...n.owns];
|
|
62
|
+
const fileNote = files.length ? ` · touches ${files.map((f) => B + short(f) + B).join(", ")}` : "";
|
|
63
|
+
const badge = tierBadge(n.priority);
|
|
64
|
+
w(`- ${badge ? `**[${badge}]** ` : ""}${B}/slice ${n.invoke}${B} — ${n.what}${fileNote}`);
|
|
65
|
+
}
|
|
66
|
+
w("");
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
if (waveResult.held.onHuman.length) {
|
|
70
|
+
w("**Held on a human:**");
|
|
71
|
+
for (const n of waveResult.held.onHuman) {
|
|
72
|
+
w(`- ${B}/slice ${n.invoke}${B} — gated on **${n.gatedOn}** — ${n.what}`);
|
|
73
|
+
}
|
|
74
|
+
w("");
|
|
75
|
+
}
|
|
76
|
+
w("---");
|
|
77
|
+
w("");
|
|
78
|
+
|
|
79
|
+
// ---- roadmap: PIs → sprints ----------------------------------------------
|
|
80
|
+
w("## Roadmap — PIs → sprints");
|
|
81
|
+
w("");
|
|
82
|
+
for (const pi of graph.pis) {
|
|
83
|
+
const rem = sessionsRemaining(pi);
|
|
84
|
+
const remStr = rem > 0 ? ` · ~${fmt(rem)} session${rem === 1 ? "" : "s"} remaining` : "";
|
|
85
|
+
const plan = execPlan(pi);
|
|
86
|
+
w(`### ${pi.program_label || pi.id.toUpperCase()} — ${pi.title} · ${statusDisplay(pi.status)}${remStr}`);
|
|
87
|
+
if (pi.theme) w(`> ${pi.theme}`);
|
|
88
|
+
// sprint-status strip
|
|
89
|
+
const strip = (pi.sprints || []).map((s) => `${s.id.toUpperCase()} ${emojiFor(s.status)}`).join(" · ");
|
|
90
|
+
if (strip) w(`> Sprints: ${strip}`);
|
|
91
|
+
if (plan) w(`> Exec plan: ${plan}${pi.exec_hint ? ` _(human hint: ${pi.exec_hint})_` : ""}`);
|
|
92
|
+
if (pi.deps && pi.deps.length) w(`> Deps: ${pi.deps.map((d) => d.toUpperCase()).join(", ")}`);
|
|
93
|
+
if (pi.exit_criteria) w(`> Exit: ${oneLine(pi.exit_criteria)}`);
|
|
94
|
+
w("");
|
|
95
|
+
w("| Sprint | Invoke | Status | Sessions | Deps | What |");
|
|
96
|
+
w("|---|---|---|---|---|---|");
|
|
97
|
+
for (const sp of pi.sprints) {
|
|
98
|
+
const node = model.nodes.find((n) => n.nodeKey === `${pi.id}/${sp.id}`);
|
|
99
|
+
const sess = isDone(sp.status) ? "—" : (typeof sp.est_sessions === "number" ? `~${fmt(sp.est_sessions)}` : "?");
|
|
100
|
+
const deps = depCell(node);
|
|
101
|
+
const prs = (sp.prs && sp.prs.length) ? ` · ${sp.prs.join(" ")}` : "";
|
|
102
|
+
const badge = tierBadge(sp.priority);
|
|
103
|
+
const dtier = sp.dispatch_tier ? ` · ${B}dispatch: ${sp.dispatch_tier}${B}` : "";
|
|
104
|
+
w(`| ${sp.id.toUpperCase()} | ${B}/slice ${sp.invoke}${B} | ${statusDisplay(sp.status, sp.status_label)}${badge ? ` · **${badge}**` : ""}${dtier} | ${sess} | ${deps} | ${sp.what || sp.title}${prs} |`);
|
|
105
|
+
}
|
|
106
|
+
w("");
|
|
107
|
+
}
|
|
108
|
+
w("---");
|
|
109
|
+
w("");
|
|
110
|
+
|
|
111
|
+
// ---- recently completed ---------------------------------------------------
|
|
112
|
+
const completed = model.nodes.filter((n) => isDone(n.status) && (n.prs.length || n.completedOn));
|
|
113
|
+
if (completed.length) {
|
|
114
|
+
w(`**Recently completed:**`);
|
|
115
|
+
w("");
|
|
116
|
+
w("| Slice | PI | PRs |");
|
|
117
|
+
w("|---|---|---|");
|
|
118
|
+
for (const n of completed) {
|
|
119
|
+
w(`| ${n.id.toUpperCase()} | ${n.programLabel} | ${n.prs.join(" ") || "—"} |`);
|
|
120
|
+
}
|
|
121
|
+
w("");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---- default gate ---------------------------------------------------------
|
|
125
|
+
if (graph.meta && graph.meta.default_gate) {
|
|
126
|
+
w("**Default verification gate** (every slice, unless its entry overrides `gate`):");
|
|
127
|
+
w("");
|
|
128
|
+
w("```bash");
|
|
129
|
+
w(graph.meta.default_gate.trimEnd());
|
|
130
|
+
w("```");
|
|
131
|
+
w("");
|
|
132
|
+
}
|
|
133
|
+
w("---");
|
|
134
|
+
w("");
|
|
135
|
+
|
|
136
|
+
// ---- detail entries (pickup-able = not complete) --------------------------
|
|
137
|
+
w("## Detail — `/slice <name>` reads these");
|
|
138
|
+
w("");
|
|
139
|
+
for (const pi of graph.pis) {
|
|
140
|
+
for (const sp of pi.sprints) {
|
|
141
|
+
if (isDone(sp.status)) continue;
|
|
142
|
+
const node = model.nodes.find((n) => n.nodeKey === `${pi.id}/${sp.id}`);
|
|
143
|
+
w(`### ${B}${sp.invoke}${B}`);
|
|
144
|
+
// Execution directive at the TOP of the read-out (only when the slice declares one — a
|
|
145
|
+
// slice without an execution block renders byte-identically to before).
|
|
146
|
+
const exec = executionDirectiveLines(node);
|
|
147
|
+
if (exec) {
|
|
148
|
+
exec.forEach((line) => w(`> ${line}`));
|
|
149
|
+
w("");
|
|
150
|
+
}
|
|
151
|
+
w(`- **What:** ${sp.what || sp.title}`);
|
|
152
|
+
w(`- **Status:** ${statusDisplay(sp.status, sp.status_label)} (${pi.program_label || pi.id.toUpperCase()}${pi.id ? ` · ${sp.id.toUpperCase()}` : ""})`);
|
|
153
|
+
if (sp.priority && (tierBadge(sp.priority) || sp.priority.weight != null || sp.priority.reason)) {
|
|
154
|
+
const parts = [tierBadge(sp.priority), sp.priority.weight != null ? `weight ${sp.priority.weight}` : null].filter(Boolean).join(" · ");
|
|
155
|
+
w(`- **Priority:** ${parts || "(set)"}${sp.priority.reason ? ` — ${oneLine(sp.priority.reason)}` : ""}`);
|
|
156
|
+
}
|
|
157
|
+
if (node && (node.deps.length || node.piDeps.length)) {
|
|
158
|
+
w(`- **Deps:** ${depCell(node)}`);
|
|
159
|
+
}
|
|
160
|
+
if (sp.read_order && sp.read_order.length) {
|
|
161
|
+
w(`- **Read-order:**`);
|
|
162
|
+
sp.read_order.forEach((r, i) => w(` ${i + 1}. ${r}`));
|
|
163
|
+
}
|
|
164
|
+
if (sp.resume_action) w(`- **Resume / next action:** ${oneLine(sp.resume_action)}`);
|
|
165
|
+
const gateText = !sp.gate || sp.gate === "default"
|
|
166
|
+
? "default verification gate"
|
|
167
|
+
: oneLine(sp.gate).replace(/\{\{\s*default\s*\}\}/gi, "default gate");
|
|
168
|
+
w(`- **Gate:** ${gateText}`);
|
|
169
|
+
if (sp.gated_on) w(`- **Gated on:** ${sp.gated_on} (an agent prepares; it does not perform the gate).`);
|
|
170
|
+
w("");
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---- footer ---------------------------------------------------------------
|
|
175
|
+
w("---");
|
|
176
|
+
w("");
|
|
177
|
+
w("## Maintaining this catalog");
|
|
178
|
+
w("");
|
|
179
|
+
w("This file is **generated** from [`roadmap/roadmap.yaml`](roadmap/roadmap.yaml). To change it, edit the YAML and run `/sync` (which reconciles statuses against merged PRs + the tracker, then re-renders). `/slice` reads the YAML directly to orient; `/fanout` reads it to compute waves and launch sessions. Slice **invoke keys are stable** — they are the `/slice` keys and are referenced from `ROADMAP.md`.");
|
|
180
|
+
w("");
|
|
181
|
+
const seeAlso = [
|
|
182
|
+
links.narrative && `- **Narrative shape** → ${mdlink("narrative", links.narrative)}`,
|
|
183
|
+
links.bootstrap && `- **Cold-start orientation** → ${mdlink("bootstrap", links.bootstrap)}`,
|
|
184
|
+
(links.status || links.tracker) && `- **Live status** → ${[mdlink("status", links.status), mdlink("tracker", links.tracker)].filter(Boolean).join(" + ")}`,
|
|
185
|
+
links.strategy && `- **Strategy / positioning** → ${mdlink("strategy", links.strategy)}`,
|
|
186
|
+
"- **Resume a specific session** → `/pickup`",
|
|
187
|
+
].filter(Boolean);
|
|
188
|
+
if (seeAlso.length) {
|
|
189
|
+
w("## See also");
|
|
190
|
+
w("");
|
|
191
|
+
seeAlso.forEach((l) => w(l));
|
|
192
|
+
w("");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return lines.join("\n") + "\n";
|
|
196
|
+
|
|
197
|
+
// ---- helpers --------------------------------------------------------------
|
|
198
|
+
function depCell(node) {
|
|
199
|
+
if (!node) return "—";
|
|
200
|
+
const parts = [];
|
|
201
|
+
for (const d of node.deps) parts.push(d.split("/")[1].toUpperCase());
|
|
202
|
+
for (const p of node.piDeps) parts.push(p.toUpperCase());
|
|
203
|
+
return parts.length ? parts.join(", ") : "—";
|
|
204
|
+
}
|
|
205
|
+
function oneLine(s) {
|
|
206
|
+
return String(s).replace(/\s*\n\s*/g, " ").trim();
|
|
207
|
+
}
|
|
208
|
+
function fmt(n) {
|
|
209
|
+
return String(n);
|
|
210
|
+
}
|
|
211
|
+
function short(p) {
|
|
212
|
+
const segs = String(p).split("/");
|
|
213
|
+
return segs[segs.length - 1] || p;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// roadmap — review brain (PURE). Diffs two graph/backlog snapshots (old = the YAML at the
|
|
2
|
+
// review anchor via git show, new = the working tree) into the /debrief digest: what shipped,
|
|
3
|
+
// what grew, what's stuck, and whether scope is outrunning shipping. No IO — scripts/review.mjs
|
|
4
|
+
// resolves the anchor commit and injects both parsed YAMLs.
|
|
5
|
+
|
|
6
|
+
import { flatten, isDone, HELD_STATUSES } from "./graph.mjs";
|
|
7
|
+
import { sprawlWarnings, captureRatio } from "./sync-core.mjs";
|
|
8
|
+
|
|
9
|
+
const HELD = new Set(HELD_STATUSES);
|
|
10
|
+
|
|
11
|
+
// Sprints keyed by invoke (the stable key); PIs by id. A sprint added AND completed inside
|
|
12
|
+
// the window appears in both addedSprints and completedSlices — that's honest, not a bug.
|
|
13
|
+
export function graphDiff(oldGraph, newGraph) {
|
|
14
|
+
const oldM = flatten(oldGraph && oldGraph.pis ? oldGraph : { pis: [] });
|
|
15
|
+
const newM = flatten(newGraph);
|
|
16
|
+
const oldByInvoke = new Map(oldM.nodes.map((n) => [n.invoke, n]));
|
|
17
|
+
const newByInvoke = new Map(newM.nodes.map((n) => [n.invoke, n]));
|
|
18
|
+
const oldPis = new Set(((oldGraph && oldGraph.pis) || []).map((p) => p.id));
|
|
19
|
+
|
|
20
|
+
const addedPis = (newGraph.pis || []).filter((p) => !oldPis.has(p.id)).map((p) => ({ id: p.id, title: p.title }));
|
|
21
|
+
const addedSprints = [];
|
|
22
|
+
const completedSlices = [];
|
|
23
|
+
const statusFlips = [];
|
|
24
|
+
const priorityChanges = [];
|
|
25
|
+
const stillHeld = [];
|
|
26
|
+
|
|
27
|
+
for (const n of newM.nodes) {
|
|
28
|
+
const o = oldByInvoke.get(n.invoke);
|
|
29
|
+
if (!o) {
|
|
30
|
+
addedSprints.push({ invoke: n.invoke, pi: n.piId, title: n.title });
|
|
31
|
+
if (isDone(n.status)) completedSlices.push({ invoke: n.invoke, pi: n.piId, title: n.title, prs: n.prs });
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (!isDone(o.status) && isDone(n.status)) {
|
|
35
|
+
completedSlices.push({ invoke: n.invoke, pi: n.piId, title: n.title, prs: n.prs });
|
|
36
|
+
} else if (o.status !== n.status) {
|
|
37
|
+
statusFlips.push({ invoke: n.invoke, from: o.status, to: n.status });
|
|
38
|
+
}
|
|
39
|
+
const ot = (o.priority && o.priority.tier) || null;
|
|
40
|
+
const nt = (n.priority && n.priority.tier) || null;
|
|
41
|
+
if (ot !== nt) priorityChanges.push({ invoke: n.invoke, from: ot, to: nt });
|
|
42
|
+
if (HELD.has(o.status) && HELD.has(n.status)) stillHeld.push({ invoke: n.invoke, status: n.status });
|
|
43
|
+
}
|
|
44
|
+
const removedSprints = oldM.nodes.filter((o) => !newByInvoke.has(o.invoke))
|
|
45
|
+
.map((o) => ({ invoke: o.invoke, pi: o.piId, title: o.title }));
|
|
46
|
+
|
|
47
|
+
return { addedPis, addedSprints, completedSlices, removedSprints, statusFlips, priorityChanges, stillHeld };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Either snapshot may be null (no backlog.yaml then/now).
|
|
51
|
+
export function backlogDiff(oldB, newB) {
|
|
52
|
+
const oldItems = new Map((((oldB && oldB.items) || [])).map((i) => [i.id, i]));
|
|
53
|
+
const captured = [];
|
|
54
|
+
const closed = [];
|
|
55
|
+
const promoted = [];
|
|
56
|
+
for (const i of (newB && newB.items) || []) {
|
|
57
|
+
const o = oldItems.get(i.id);
|
|
58
|
+
if (!o) { captured.push({ id: i.id, title: i.title, kind: i.kind }); continue; }
|
|
59
|
+
const wasOpen = o.status === "open" || o.status === "in_progress";
|
|
60
|
+
if (wasOpen && (i.status === "done" || i.status === "dropped")) closed.push({ id: i.id, title: i.title, status: i.status });
|
|
61
|
+
if (o.status !== "promoted" && i.status === "promoted") promoted.push({ id: i.id, promoted_to: i.promoted_to || null });
|
|
62
|
+
}
|
|
63
|
+
return { captured, closed, promoted };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Fragmentation: PIs with work both started (done/active sprint) AND still open — the count
|
|
67
|
+
// of half-open PIs the coherence scheduler exists to shrink.
|
|
68
|
+
export function pisInFlight(graph) {
|
|
69
|
+
let count = 0;
|
|
70
|
+
for (const pi of graph.pis || []) {
|
|
71
|
+
const sprints = pi.sprints || [];
|
|
72
|
+
const started = sprints.some((s) => isDone(s.status) || s.status === "active");
|
|
73
|
+
const open = sprints.some((s) => !isDone(s.status));
|
|
74
|
+
if (started && open) count += 1;
|
|
75
|
+
}
|
|
76
|
+
return count;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The /debrief evidence base. The sprawl lines come from the SAME function /sync uses —
|
|
80
|
+
// the two guardrails can never disagree.
|
|
81
|
+
export function reviewDigest({ gd, bd, graph }) {
|
|
82
|
+
const added = bd.captured.length + gd.addedSprints.length;
|
|
83
|
+
const completed = gd.completedSlices.length;
|
|
84
|
+
return {
|
|
85
|
+
shipped: gd.completedSlices,
|
|
86
|
+
captured: { items: bd.captured, sprints: gd.addedSprints },
|
|
87
|
+
closedItems: bd.closed,
|
|
88
|
+
promoted: bd.promoted,
|
|
89
|
+
netGrowth: { added, completed, ratio: Math.round((added / Math.max(completed, 1)) * 10) / 10 },
|
|
90
|
+
sprawl: sprawlWarnings({
|
|
91
|
+
completed,
|
|
92
|
+
captured: bd.captured.length,
|
|
93
|
+
addedSprints: gd.addedSprints.length,
|
|
94
|
+
addedPis: gd.addedPis.map((p) => p.id),
|
|
95
|
+
ratioThreshold: captureRatio(graph.meta),
|
|
96
|
+
}),
|
|
97
|
+
aging: gd.stillHeld,
|
|
98
|
+
newPis: gd.addedPis,
|
|
99
|
+
removed: gd.removedSprints,
|
|
100
|
+
pisInFlight: pisInFlight(graph),
|
|
101
|
+
priorityChanges: gd.priorityChanges,
|
|
102
|
+
statusFlips: gd.statusFlips,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// roadmap — shared mutation IO. The ONE read → mutate → validate → write → re-render
|
|
2
|
+
// sequence every mutating surface uses (mcp.mjs, set.mjs, backlog.mjs, promote.mjs), so a
|
|
3
|
+
// validation failure can never leave a half-written YAML or a stale generated view.
|
|
4
|
+
// Both mutators re-render BOTH generated files when both sources exist (a backlog edit
|
|
5
|
+
// changes the open-count pointer in SLICES.md; a roadmap edit changes nothing in BACKLOG.md
|
|
6
|
+
// but re-rendering is cheaper than proving it).
|
|
7
|
+
|
|
8
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { spawnSync } from "node:child_process";
|
|
11
|
+
import YAML from "yaml";
|
|
12
|
+
import { REL } from "./cli-core.mjs";
|
|
13
|
+
import { loadGraph } from "./graph.mjs";
|
|
14
|
+
import { renderMarkdown } from "./render-core.mjs";
|
|
15
|
+
import { validateDocOrThrow, serialize } from "./mcp-core.mjs";
|
|
16
|
+
import { validateBacklogDocOrThrow, renderBacklogMarkdown, openCount } from "./backlog-core.mjs";
|
|
17
|
+
|
|
18
|
+
export const BACKLOG_REL = ["docs", "roadmap", "backlog.yaml"];
|
|
19
|
+
const EMPTY_BACKLOG = "meta:\n schema_version: 1\nitems: []\n";
|
|
20
|
+
|
|
21
|
+
export function roadmapPaths(root) {
|
|
22
|
+
return { yaml: join(root, ...REL), slices: join(root, "docs", "SLICES.md") };
|
|
23
|
+
}
|
|
24
|
+
export function backlogPaths(root) {
|
|
25
|
+
return { yaml: join(root, ...BACKLOG_REL), md: join(root, "docs", "BACKLOG.md") };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Plain backlog object, or null when the repo has no backlog.yaml.
|
|
29
|
+
export function loadBacklog(root) {
|
|
30
|
+
const p = backlogPaths(root).yaml;
|
|
31
|
+
if (!existsSync(p)) return null;
|
|
32
|
+
return YAML.parse(readFileSync(p, "utf8"));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Backlog item ids as of origin/main's LAST-FETCHED state (the remote-tracking ref — no
|
|
36
|
+
// network call). Concurrent sessions each allocate ids against their own stale checkout;
|
|
37
|
+
// five bNN collisions in one day (2026-07-10) came from exactly that. Feeding these ids
|
|
38
|
+
// into addItem closes most of the race window without a fetch. Degrades to [] on any
|
|
39
|
+
// failure (no git, no remote-tracking ref, no backlog upstream) — allocation then falls
|
|
40
|
+
// back to local-only, exactly the pre-guard behavior.
|
|
41
|
+
export function originBacklogIds(root, { ref = "origin/main" } = {}) {
|
|
42
|
+
try {
|
|
43
|
+
const r = spawnSync("git", ["show", `${ref}:${BACKLOG_REL.join("/")}`], { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
44
|
+
if (r.status !== 0 || !r.stdout) return [];
|
|
45
|
+
const parsed = YAML.parse(r.stdout);
|
|
46
|
+
return (parsed && parsed.items ? parsed.items : []).map((i) => String(i.id));
|
|
47
|
+
} catch { return []; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Render opts for SLICES.md: the backlog pointer appears only when backlog.yaml exists.
|
|
51
|
+
export function slicesRenderOpts(root, backlog = loadBacklog(root)) {
|
|
52
|
+
return backlog ? { backlog: { open: openCount(backlog) } } : {};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// fn(doc) mutates the roadmap Document in place and returns a summary object.
|
|
56
|
+
// Any throw (from fn or the pre-write gate) leaves every file untouched.
|
|
57
|
+
export function mutateRoadmap(root, fn) {
|
|
58
|
+
const p = roadmapPaths(root);
|
|
59
|
+
const doc = YAML.parseDocument(readFileSync(p.yaml, "utf8"));
|
|
60
|
+
const summary = fn(doc);
|
|
61
|
+
const graph = validateDocOrThrow(doc);
|
|
62
|
+
writeFileSync(p.yaml, serialize(doc), "utf8");
|
|
63
|
+
writeFileSync(p.slices, renderMarkdown(graph, slicesRenderOpts(root)), "utf8");
|
|
64
|
+
return { ...summary, rerendered: "docs/SLICES.md" };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Two-file mutation (promote): fn(roadmapDoc, backlogDoc) edits both; BOTH are validated
|
|
68
|
+
// before EITHER is written, so the only remaining failure window is an fs error between the
|
|
69
|
+
// two writes. ponytail: not truly atomic; temp-file+rename choreography across two files
|
|
70
|
+
// isn't worth it for a local dev tool.
|
|
71
|
+
export function mutateBoth(root, fn) {
|
|
72
|
+
const rp = roadmapPaths(root);
|
|
73
|
+
const bp = backlogPaths(root);
|
|
74
|
+
if (!existsSync(bp.yaml)) throw new Error(`no ${BACKLOG_REL.join("/")} — nothing to promote from`);
|
|
75
|
+
const rDoc = YAML.parseDocument(readFileSync(rp.yaml, "utf8"));
|
|
76
|
+
const bDoc = YAML.parseDocument(readFileSync(bp.yaml, "utf8"));
|
|
77
|
+
const summary = fn(rDoc, bDoc);
|
|
78
|
+
const graph = validateDocOrThrow(rDoc);
|
|
79
|
+
const backlog = validateBacklogDocOrThrow(bDoc);
|
|
80
|
+
writeFileSync(rp.yaml, serialize(rDoc), "utf8");
|
|
81
|
+
writeFileSync(bp.yaml, serialize(bDoc), "utf8");
|
|
82
|
+
writeFileSync(rp.slices, renderMarkdown(graph, { backlog: { open: openCount(backlog) } }), "utf8");
|
|
83
|
+
writeFileSync(bp.md, renderBacklogMarkdown(backlog), "utf8");
|
|
84
|
+
return { ...summary, rerendered: "docs/SLICES.md + docs/BACKLOG.md" };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Same sequence for backlog.yaml → BACKLOG.md, plus a SLICES.md refresh (open-count pointer).
|
|
88
|
+
// createIfMissing: backlog_add bootstraps the file on first capture.
|
|
89
|
+
export function mutateBacklog(root, fn, { createIfMissing = false } = {}) {
|
|
90
|
+
const p = backlogPaths(root);
|
|
91
|
+
let src;
|
|
92
|
+
let created = false;
|
|
93
|
+
if (existsSync(p.yaml)) src = readFileSync(p.yaml, "utf8");
|
|
94
|
+
else if (createIfMissing) { src = EMPTY_BACKLOG; created = true; }
|
|
95
|
+
else throw new Error(`no ${BACKLOG_REL.join("/")} — capture something first ('roadmap backlog add' or the backlog_add tool creates it)`);
|
|
96
|
+
const doc = YAML.parseDocument(src);
|
|
97
|
+
if (created) {
|
|
98
|
+
// Block style from birth — the template's `items: []` is a flow seq, and items added to a
|
|
99
|
+
// flow collection stay flow (unreadable once prompts go multiline). Existing files keep
|
|
100
|
+
// whatever style their author chose.
|
|
101
|
+
const seq = doc.get("items");
|
|
102
|
+
if (seq) seq.flow = false;
|
|
103
|
+
}
|
|
104
|
+
const summary = fn(doc);
|
|
105
|
+
const backlog = validateBacklogDocOrThrow(doc);
|
|
106
|
+
writeFileSync(p.yaml, serialize(doc), "utf8");
|
|
107
|
+
writeFileSync(p.md, renderBacklogMarkdown(backlog), "utf8");
|
|
108
|
+
const rp = roadmapPaths(root);
|
|
109
|
+
if (existsSync(rp.yaml)) {
|
|
110
|
+
writeFileSync(rp.slices, renderMarkdown(loadGraph(rp.yaml), slicesRenderOpts(root, backlog)), "utf8");
|
|
111
|
+
}
|
|
112
|
+
return { ...summary, rerendered: "docs/BACKLOG.md" };
|
|
113
|
+
}
|