@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.
Files changed (53) hide show
  1. package/README.md +495 -0
  2. package/docs/DEPLOYMENT.md +195 -0
  3. package/package.json +46 -0
  4. package/schema/backlog.schema.json +65 -0
  5. package/schema/roadmap.schema.json +273 -0
  6. package/scripts/assistant.mjs +30 -0
  7. package/scripts/backlog.mjs +81 -0
  8. package/scripts/cleanup.mjs +69 -0
  9. package/scripts/cli.mjs +119 -0
  10. package/scripts/cycle.mjs +89 -0
  11. package/scripts/dispatch.mjs +302 -0
  12. package/scripts/estimate.mjs +201 -0
  13. package/scripts/fanout.mjs +355 -0
  14. package/scripts/grab.mjs +119 -0
  15. package/scripts/init.mjs +60 -0
  16. package/scripts/lib/assistant-core.mjs +64 -0
  17. package/scripts/lib/backlog-core.mjs +318 -0
  18. package/scripts/lib/brief.mjs +123 -0
  19. package/scripts/lib/cli-core.mjs +97 -0
  20. package/scripts/lib/cycle-core.mjs +58 -0
  21. package/scripts/lib/estimate-core.mjs +204 -0
  22. package/scripts/lib/execution.mjs +186 -0
  23. package/scripts/lib/fanout-core.mjs +39 -0
  24. package/scripts/lib/graph.mjs +346 -0
  25. package/scripts/lib/journal-core.mjs +48 -0
  26. package/scripts/lib/linear-core.mjs +812 -0
  27. package/scripts/lib/mcp-core.mjs +313 -0
  28. package/scripts/lib/plan.mjs +68 -0
  29. package/scripts/lib/plate-core.mjs +53 -0
  30. package/scripts/lib/pr-watch-core.mjs +72 -0
  31. package/scripts/lib/priority.mjs +43 -0
  32. package/scripts/lib/recommend.mjs +178 -0
  33. package/scripts/lib/render-core.mjs +215 -0
  34. package/scripts/lib/review-core.mjs +104 -0
  35. package/scripts/lib/store.mjs +113 -0
  36. package/scripts/lib/sync-core.mjs +89 -0
  37. package/scripts/lib/validate-core.mjs +130 -0
  38. package/scripts/lib/wizard-core.mjs +50 -0
  39. package/scripts/linear.mjs +780 -0
  40. package/scripts/mcp.mjs +193 -0
  41. package/scripts/next.mjs +43 -0
  42. package/scripts/plate.mjs +77 -0
  43. package/scripts/promote.mjs +27 -0
  44. package/scripts/prompt.mjs +124 -0
  45. package/scripts/render.mjs +39 -0
  46. package/scripts/review.mjs +79 -0
  47. package/scripts/scheduler.mjs +78 -0
  48. package/scripts/set.mjs +31 -0
  49. package/scripts/show.mjs +53 -0
  50. package/scripts/test/run.mjs +3943 -0
  51. package/scripts/validate.mjs +28 -0
  52. package/scripts/watch-prs.mjs +72 -0
  53. package/scripts/wizard.mjs +97 -0
@@ -0,0 +1,89 @@
1
+ // roadmap — reconcile brain (PURE). Detects slices whose work has merged but whose roadmap
2
+ // status still says open, so the SessionStart hook and the PR monitor can nudge the agent to
3
+ // reconcile (mark complete + record the PR, then re-render). No IO: the caller supplies the
4
+ // merged-PR list (from gh); this just matches it against the graph by fanout branch.
5
+
6
+ import { flatten, isDone } from "./graph.mjs";
7
+ import { branchFor } from "./brief.mjs";
8
+ import { normalizeExecution, dirClusters } from "./execution.mjs";
9
+
10
+ // findUnrecordedMerges(graph, mergedPrs): mergedPrs is [{ number, headRefName, title?, body? }]
11
+ // (state=merged). Returns the not-done slices whose work has a merged PR: [{ invoke, pr, branch }].
12
+ // Two matching layers:
13
+ // 1. branch — headRefName equals the slice's convention branch (local fanout worktrees).
14
+ // 2. marker — the PR title/body contains the machine line `roadmap: slice=<invoke>` (cloud
15
+ // dispatches push unpredictable claude/-prefixed branches, so the capsule + dispatch
16
+ // guidance require the marker in the PR description; the kickoff brief adds it too).
17
+ export function findUnrecordedMerges(graph, mergedPrs) {
18
+ const byBranch = new Map();
19
+ const byMarker = new Map(); // invoke -> pr number (first match wins)
20
+ for (const pr of mergedPrs || []) {
21
+ if (!pr) continue;
22
+ if (pr.headRefName && !byBranch.has(pr.headRefName)) byBranch.set(pr.headRefName, pr.number);
23
+ const text = `${pr.title || ""}\n${pr.body || ""}`;
24
+ for (const m of text.matchAll(/roadmap: slice=([a-z0-9][a-z0-9-]*)/g)) {
25
+ if (!byMarker.has(m[1])) byMarker.set(m[1], pr.number);
26
+ }
27
+ }
28
+ const model = flatten(graph);
29
+ const out = [];
30
+ for (const n of model.nodes) {
31
+ if (isDone(n.status)) continue;
32
+ const branch = branchFor(n, graph);
33
+ if (byBranch.has(branch)) out.push({ invoke: n.invoke, pr: byBranch.get(branch), branch });
34
+ else if (byMarker.has(n.invoke)) out.push({ invoke: n.invoke, pr: byMarker.get(n.invoke), branch: "(cloud — matched by PR marker)" });
35
+ }
36
+ return out;
37
+ }
38
+
39
+ // Post-run guardrail (the under-parallelization warning surfaced in /sync). `runStats` is the
40
+ // observed run telemetry/log: [{ invoke, workers }] (the LIVE worker count a slice actually ran with).
41
+ // Flags any slice that declares a `min_concurrency` floor, touches ≥2 disjoint dir clusters (so it
42
+ // COULD have parallelized), and ran with fewer live workers than its floor. Returns warning strings.
43
+ export function underParallelizedWarnings(graph, runStats) {
44
+ const model = flatten(graph);
45
+ const byInvoke = new Map(model.nodes.map((n) => [n.invoke, n]));
46
+ const out = [];
47
+ for (const r of runStats || []) {
48
+ if (!r || r.workers == null || !Number.isFinite(r.workers)) continue;
49
+ const n = byInvoke.get(r.invoke);
50
+ if (!n || !n.execution) continue;
51
+ const exec = normalizeExecution(n.execution);
52
+ if (!exec || exec.minConcurrency == null) continue;
53
+ const disjoint = dirClusters([...(n.touches || []), ...(n.owns || [])]).size >= 2;
54
+ if (disjoint && r.workers < exec.minConcurrency) {
55
+ out.push(`slice ${r.invoke} ran ${r.workers} worker${r.workers === 1 ? "" : "s"}; min_concurrency ${exec.minConcurrency} — under-parallelized`);
56
+ }
57
+ }
58
+ return out;
59
+ }
60
+
61
+ // Scope-discipline knob: max (captured items + added sprints) per completed slice per review
62
+ // window before the sprawl warning fires. The knob is meta.discipline.capture_ratio.
63
+ export const captureRatio = (meta) => (meta && meta.discipline && meta.discipline.capture_ratio) ?? 2;
64
+
65
+ // Post-run scope guardrail (the sprawl warning surfaced in /sync and /debrief). Counts are
66
+ // window-relative: what completed vs what was captured/added since the last review anchor.
67
+ // PIs are ALWAYS flagged regardless of ratio — a PI is strategic scope and should never
68
+ // appear from a worker session without a human decision.
69
+ export function sprawlWarnings({ completed = 0, captured = 0, addedSprints = 0, addedPis = [], ratioThreshold = 2 } = {}) {
70
+ const out = [];
71
+ const grown = captured + addedSprints;
72
+ const ratio = grown / Math.max(completed, 1);
73
+ if (grown > 0 && ratio > ratioThreshold) {
74
+ out.push(`sprawl: ${grown} captured (${captured} item(s) + ${addedSprints} sprint(s)) vs ${completed} completed since the last review — ratio ${ratio.toFixed(1)} exceeds capture_ratio ${ratioThreshold}; scope is growing faster than it ships. Triage before adding more.`);
75
+ }
76
+ for (const pi of addedPis || []) {
77
+ out.push(`sprawl: PI "${pi}" added since the last review — new PIs are strategic scope; confirm this was a human decision, not an agent capture.`);
78
+ }
79
+ return out;
80
+ }
81
+
82
+ // One-line, actionable nudge for the agent. Empty string when nothing is unrecorded (stay quiet).
83
+ export function reconcileNudge(unrecorded) {
84
+ if (!unrecorded || !unrecorded.length) return "";
85
+ const items = unrecorded.map((u) => `${u.invoke} (PR #${u.pr})`).join(", ");
86
+ return `${unrecorded.length} slice(s) have a merged PR but are still open: ${items}. `
87
+ + `Reconcile the roadmap: run /sync, or call the roadmap set_status tool for each `
88
+ + `(status=complete, record the PR). It re-renders SLICES.md.`;
89
+ }
@@ -0,0 +1,130 @@
1
+ // roadmap — pure validator: roadmap graph -> { errors, warnings, nodeCount }.
2
+ // No IO. validate.mjs prints + exits on the result; the MCP `validate` read tool returns it.
3
+ // Structural checks + dependency resolution (via flatten) + cycle detection.
4
+
5
+ import { flatten, detectCycle, STATUS } from "./graph.mjs";
6
+ import { validateExecution } from "./execution.mjs";
7
+ import { validatePriority } from "./priority.mjs";
8
+ import { validateLinearConfig } from "./linear-core.mjs";
9
+ import { validateEstimation } from "./estimate-core.mjs";
10
+
11
+ const isDone = (s) => !!(STATUS[s] && STATUS[s].done);
12
+
13
+ export function validateGraph(graph) {
14
+ const errors = [];
15
+ const warnings = [];
16
+ const err = (m) => errors.push(m);
17
+ const warn = (m) => warnings.push(m);
18
+
19
+ const meta = graph.meta || {};
20
+ if (meta.schema_version !== 1) err(`meta.schema_version must be 1 (got ${JSON.stringify(meta.schema_version)})`);
21
+ if (!meta.program) err("meta.program is required");
22
+ if (meta.terminal && !["warp", "wt", "tmux", "iterm", "background", "print"].includes(meta.terminal)) {
23
+ err(`meta.terminal "${meta.terminal}" is not a known adapter`);
24
+ }
25
+ if (meta.assistants != null) {
26
+ if (typeof meta.assistants !== "object" || Array.isArray(meta.assistants)) err("meta.assistants must be a mapping");
27
+ else {
28
+ if (meta.assistants.default != null && typeof meta.assistants.default !== "string") err("meta.assistants.default must be a profile name string");
29
+ if (meta.assistants.profiles != null && (typeof meta.assistants.profiles !== "object" || Array.isArray(meta.assistants.profiles))) err("meta.assistants.profiles must be a mapping");
30
+ }
31
+ }
32
+
33
+ // Jira is the designed follow-up but NOT implemented — surface a stray block instead of
34
+ // letting someone believe it syncs (docs/DEPLOYMENT.md documents the planned shape).
35
+ if (meta.jira != null) warn("meta.jira is not implemented yet (Linear is the only tracker today) — the block is ignored");
36
+
37
+ // Scope-discipline knobs + the review anchor. Absent → no-op.
38
+ if (meta.discipline != null) {
39
+ if (typeof meta.discipline !== "object" || Array.isArray(meta.discipline)) err("meta.discipline must be a mapping");
40
+ else {
41
+ if (meta.discipline.capture_ratio != null && !(typeof meta.discipline.capture_ratio === "number" && meta.discipline.capture_ratio > 0)) {
42
+ err("meta.discipline.capture_ratio must be a number > 0");
43
+ }
44
+ if (meta.discipline.coherence != null && typeof meta.discipline.coherence !== "boolean") {
45
+ err("meta.discipline.coherence must be a boolean");
46
+ }
47
+ if (meta.discipline.pi_min_slices != null && !(Number.isInteger(meta.discipline.pi_min_slices) && meta.discipline.pi_min_slices >= 1)) {
48
+ err("meta.discipline.pi_min_slices must be an integer >= 1 (a bad knob silently disabling a guardrail is the failure mode)");
49
+ }
50
+ // Composition SHAPE (capture_ratio guards growth RATE): a PI under the floor is usually a
51
+ // slice wearing a PI's coat — 13 one-slice PIs is how a 64-project wall happens. ONE
52
+ // aggregated warning (signal, not a wall); complete PIs exempt (history is what it is).
53
+ const floor = meta.discipline.pi_min_slices;
54
+ if (Number.isInteger(floor) && floor >= 1) {
55
+ const thin = (graph.pis || []).filter((pi) => !isDone(pi.status) && (pi.sprints || []).length < floor);
56
+ if (thin.length) {
57
+ warn(`composition: ${thin.length} non-complete PI(s) hold fewer than ${floor} slice(s) (${thin.map((p) => p.id).join(", ")}) — a PI is a strategic bet; fold these into siblings or grow them`);
58
+ }
59
+ }
60
+ }
61
+ }
62
+ if (meta.last_review != null) {
63
+ if (typeof meta.last_review !== "object" || Array.isArray(meta.last_review) || typeof meta.last_review.date !== "string" || typeof meta.last_review.commit !== "string") {
64
+ err("meta.last_review must be a mapping with string date + commit");
65
+ }
66
+ }
67
+
68
+ // Optional meta.linear + per-PI overrides + sprint linear fields. Absent → no-op.
69
+ const lin = validateLinearConfig(graph);
70
+ for (const e of lin.errors) err(e);
71
+ for (const w of lin.warnings) warn(w);
72
+
73
+ // Optional agent-time estimation config + per-slice estimate fields. Absent → no-op.
74
+ const est = validateEstimation(graph);
75
+ for (const e of est.errors) err(e);
76
+ for (const w of est.warnings) warn(w);
77
+
78
+ const validStatus = new Set(Object.keys(STATUS));
79
+ const seenPiIds = new Set();
80
+ for (const pi of graph.pis || []) {
81
+ if (!pi.id) { err("a PI is missing id"); continue; }
82
+ if (seenPiIds.has(pi.id)) err(`duplicate PI id "${pi.id}"`);
83
+ seenPiIds.add(pi.id);
84
+ if (!pi.title) err(`PI ${pi.id}: title required`);
85
+ if (pi.initiative != null && typeof pi.initiative !== "string") err(`PI ${pi.id}: initiative must be a string (the Linear initiative name)`);
86
+ for (const e of validatePriority(pi.priority, `PI ${pi.id}`).errors) err(e);
87
+ if (pi.target_date != null && !/^\d{4}-\d{2}-\d{2}$/.test(pi.target_date)) err(`PI ${pi.id}: target_date must be YYYY-MM-DD (got ${JSON.stringify(pi.target_date)})`);
88
+ if (pi.start_date != null && !/^\d{4}-\d{2}-\d{2}$/.test(pi.start_date)) err(`PI ${pi.id}: start_date must be YYYY-MM-DD (got ${JSON.stringify(pi.start_date)})`);
89
+ if (pi.projected_target_date != null && !/^\d{4}-\d{2}-\d{2}$/.test(pi.projected_target_date)) err(`PI ${pi.id}: projected_target_date must be YYYY-MM-DD (got ${JSON.stringify(pi.projected_target_date)})`);
90
+ if (pi.summary != null && (typeof pi.summary !== "string" || pi.summary.length > 255)) err(`PI ${pi.id}: summary must be a string of at most 255 chars (it's the Linear subtitle — keep it to one line)`);
91
+ if (!validStatus.has(pi.status)) err(`PI ${pi.id}: status "${pi.status}" invalid`);
92
+ if (!Array.isArray(pi.sprints) || pi.sprints.length === 0) { err(`PI ${pi.id}: needs >=1 sprint`); continue; }
93
+ const seenSprintIds = new Set();
94
+ for (const sp of pi.sprints) {
95
+ const where = `${pi.id}/${sp.id || "?"}`;
96
+ if (!sp.id) err(`${pi.id}: a sprint is missing id`);
97
+ else if (seenSprintIds.has(sp.id)) err(`${pi.id}: duplicate sprint id "${sp.id}"`);
98
+ seenSprintIds.add(sp.id);
99
+ if (!sp.title) err(`${where}: title required`);
100
+ if (!validStatus.has(sp.status)) err(`${where}: status "${sp.status}" invalid`);
101
+ if (!sp.invoke) err(`${where}: invoke key required`);
102
+ if (sp.gated_on && sp.status !== "gated") warn(`${where}: gated_on set but status is "${sp.status}" (expected gated)`);
103
+ if (!isDone(sp.status) && sp.est_sessions == null) warn(`${where}: no est_sessions (sessions-remaining rollup will undercount)`);
104
+ // Optional execution-strategy hint. Absent → no-op (backward-compatible); present → enum/type/consistency checks.
105
+ const exec = validateExecution(sp.execution, where);
106
+ for (const e of exec.errors) err(e);
107
+ for (const w of exec.warnings) warn(w);
108
+ // Optional priority block. Absent → no-op.
109
+ for (const e of validatePriority(sp.priority, where).errors) err(e);
110
+ }
111
+ }
112
+
113
+ // flatten resolves deps + unique invoke keys (throws on failure), then cycle-detect.
114
+ let model = null;
115
+ try {
116
+ model = flatten(graph);
117
+ } catch (e) {
118
+ err(e.message);
119
+ }
120
+ if (model) {
121
+ try {
122
+ const cyc = detectCycle(model);
123
+ if (cyc) err(`dependency cycle: ${cyc.join(" → ")}`);
124
+ } catch (e) {
125
+ err(`cycle detection failed: ${e.message}`);
126
+ }
127
+ }
128
+
129
+ return { errors, warnings, nodeCount: model ? model.nodes.length : 0 };
130
+ }
@@ -0,0 +1,50 @@
1
+ // roadmap — interactive console core (PURE, unit-tested).
2
+ // The bare-`roadmap` wizard's decisions live here so they're testable without a TTY:
3
+ // which terminal to default to, how arrow-keys move a selection, how the cap field parses,
4
+ // and how the collected answers translate into the exact `fanout.mjs` flags. The raw-mode IO
5
+ // lives in prompt.mjs; the orchestration in wizard.mjs. No side effects on import.
6
+
7
+ // Terminal adapters offered by the wizard, platform default first (it's the safe Enter).
8
+ // Windows → Windows Terminal; everything else → tmux. All adapters stay selectable.
9
+ export function terminalChoices(platform) {
10
+ const def = platform === "win32" ? "wt" : "tmux";
11
+ const all = ["wt", "warp", "tmux", "print", "background"];
12
+ return [def, ...all.filter((t) => t !== def)];
13
+ }
14
+
15
+ // Arrow-key navigation for a list of length `len`, wrapping at both ends (k/j alias up/down).
16
+ // Unrelated keys leave the index unchanged.
17
+ export function moveSelection(idx, key, len) {
18
+ if (len <= 0) return 0;
19
+ if (key === "up" || key === "k") return (idx - 1 + len) % len;
20
+ if (key === "down" || key === "j") return (idx + 1) % len;
21
+ return idx;
22
+ }
23
+
24
+ // Parse the max-concurrency field. Blank → the recommended default; non-numeric or out-of-range
25
+ // → an error message (so the prompt re-asks rather than silently coercing a thrashing/no-op cap).
26
+ export function parseCap(input, { min = 1, max = Infinity, def } = {}) {
27
+ const str = String(input ?? "").trim();
28
+ if (str === "") return { value: def };
29
+ if (!/^\d+$/.test(str)) return { error: `Enter a whole number between ${min} and ${max}.` };
30
+ const n = Number(str);
31
+ if (n < min || n > max) return { error: `Must be between ${min} and ${max}.` };
32
+ return { value: n };
33
+ }
34
+
35
+ // Translate the wizard's collected answers into the argv `fanout.mjs` understands.
36
+ // launch = fanout's default (no extra flag); dry = --dry; save = --out <name>.
37
+ export function buildFanArgs({ term, cap, wave, lead, mode, outName }) {
38
+ const args = ["--term", String(term), "--cap", String(cap), "--wave", String(wave)];
39
+ if (lead) args.push("--lead-claude");
40
+ if (mode === "dry") args.push("--dry");
41
+ else if (mode === "save") args.push("--out", outName);
42
+ return args;
43
+ }
44
+
45
+ // Default filename for the "save script" action — the extension must match the target shell
46
+ // (PowerShell for wt/warp, bash for tmux/print/background) or the saved script won't run.
47
+ export function autoOutName(term, wave) {
48
+ const ext = term === "wt" || term === "warp" ? "ps1" : "sh";
49
+ return `wave${wave}.${ext}`;
50
+ }