@connorbritain/roadmap 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@connorbritain/roadmap",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Roadmap + backlog graph with prioritized, deterministic multi-session fanout. YAML canonical, SLICES.md/BACKLOG.md generated.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -58,7 +58,19 @@
58
58
  "dispatch_tier": { "type": "string", "description": "Cloud-dispatch routine tier for this item (e.g. 'fable'). Dispatch resolves routines[\"<owner/repo>#<tier>\"] (or \"default#<tier>\") from ~/.claude-routines.json and THROWS if the tier isn't configured — never a silent fallback to the standard routine. Absent: the standard repo routine. CLI --tier overrides per launch." },
59
59
  "prs": { "type": "array", "items": { "type": "string" } },
60
60
  "completed_on": { "type": "string", "description": "ISO date the item closed (done/dropped)" },
61
- "promoted_to": { "type": "string", "description": "pi-id/sprint-id nodeKey written by promote; the back-link to the roadmap" }
61
+ "promoted_to": { "type": "string", "description": "pi-id/sprint-id nodeKey written by promote; the back-link to the roadmap" },
62
+ "receipts": {
63
+ "type": "object", "additionalProperties": false,
64
+ "description": "Optional completion evidence — same shape as a slice's receipts; each value is a PR#, path, url, or note. Carried by grab-launched work.",
65
+ "properties": {
66
+ "build": { "type": "string" },
67
+ "test": { "type": "string" },
68
+ "clone_install": { "type": "string" },
69
+ "screenshot": { "type": "string" },
70
+ "signoff": { "type": "string" },
71
+ "publish": { "type": "string" }
72
+ }
73
+ }
62
74
  }
63
75
  }
64
76
  }
@@ -112,7 +112,8 @@
112
112
  "properties": {
113
113
  "capture_ratio": { "type": "number", "exclusiveMinimum": 0, "default": 2, "description": "Max (captured backlog items + added sprints) per completed slice per review window before the sprawl warning fires" },
114
114
  "pi_min_slices": { "type": "integer", "minimum": 1, "description": "Composition floor: validate warns (one aggregated line) when a non-complete PI holds fewer slices than this. A PI under ~3 slices is usually a slice wearing a PI's coat — fold it into a sibling or grow it. Absent → off." },
115
- "coherence": { "type": "boolean", "default": true, "description": "Wave-packing coherence: prefer finishing started PIs over opening fresh ones (strictly below declared priority). false restores pure priority/status/est ordering." }
115
+ "coherence": { "type": "boolean", "default": true, "description": "Wave-packing coherence: prefer finishing started PIs over opening fresh ones (strictly below declared priority). false restores pure priority/status/est ordering." },
116
+ "required_receipts": { "type": "array", "items": { "enum": ["build", "test", "clone_install", "screenshot", "signoff", "publish"] }, "description": "Finishing-discipline knob: receipt keys a slice MUST carry once complete. A complete slice missing any listed key WARNS (opt-in — absent → off)." }
116
117
  }
117
118
  },
118
119
  "last_review": {
@@ -232,7 +233,20 @@
232
233
  "prompt": { "type": "string", "description": "Author-stashed pickup instructions, embedded VERBATIM in the synthesized kickoff brief and shown by /slice and roadmap show. Update as new info comes in (set_fields / roadmap set)." },
233
234
  "milestone": { "type": "string", "description": "Optional stage name within the PI's Linear project (e.g. 'harness', 'hardening', 'ship'). Sync creates the distinct milestones per project and attaches this slice's issue — turning a project's flat issue list into legible stages. Project-scoped: the same name under two PIs is two milestones. Opt-in; absent everywhere → no milestone sync." },
234
235
  "dispatch_tier": { "type": "string", "description": "Cloud-dispatch routine tier for this slice (e.g. 'fable' = a routine whose claude.ai model selector is set to the frontier tier). Dispatch resolves routines[\"<owner/repo>#<tier>\"] (or \"default#<tier>\") from ~/.claude-routines.json and THROWS if the tier isn't configured — a frontier slice never silently falls back to the standard routine. Absent: the standard repo routine. CLI --tier overrides per launch." },
235
- "linear": { "type": "string", "description": "Linear issue identifier (e.g. ABC-123) this slice maps to — written back by the first push. The {linear} branch_convention token uses it so Linear auto-links the PR." }
236
+ "linear": { "type": "string", "description": "Linear issue identifier (e.g. ABC-123) this slice maps to — written back by the first push. The {linear} branch_convention token uses it so Linear auto-links the PR." },
237
+ "receipts": {
238
+ "type": "object", "additionalProperties": false,
239
+ "description": "Optional per-slice completion evidence — each value is a PR#, path, url, or note proving a finishing step actually happened. Opt-in enforcement: list keys in meta.discipline.required_receipts and a complete slice missing one WARNS. Absent → no-op.",
240
+ "properties": {
241
+ "build": { "type": "string" },
242
+ "test": { "type": "string" },
243
+ "clone_install": { "type": "string" },
244
+ "screenshot": { "type": "string" },
245
+ "signoff": { "type": "string" },
246
+ "publish": { "type": "string" }
247
+ }
248
+ },
249
+ "outcome": { "type": "string", "description": "Optional founder-review outcome this slice rolls up into — sibling slices sharing a name group into one OUTCOME line in SLICES.md summarizing their done-state. Absent → no rollup." }
236
250
  }
237
251
  },
238
252
  "priority": {
package/scripts/cli.mjs CHANGED
@@ -39,10 +39,12 @@ COMMANDS
39
39
  mcp run the MCP server (stdio); read + mutate tools over JSON-RPC
40
40
  watch watch fanout PRs and print a line as each lands (lead notifications)
41
41
  review date-anchored review digest: what shipped vs what grew since meta.last_review
42
+ doctor reconcile the roadmap against reality (merged PRs, docs, Linear, worktrees,
43
+ structure) and report drift — read-only; exits non-zero when drift is found
42
44
  linear optional Linear sync: status [--probe] | auth | setup --team KEY | provision | sync [--dry]
43
45
  | note <key> "<text>" [--kind progress|blocker|done] | notes <key> | post-update
44
- init create a minimal roadmap or configure portable assistant profiles
45
- assistant list | configure | doctor local assistant profiles
46
+ init create a minimal roadmap or configure portable assistant profiles
47
+ assistant list | configure | doctor local assistant profiles
46
48
  help this help
47
49
 
48
50
  OPTIONS (short | long)
@@ -69,12 +71,12 @@ OPTIONS (short | long)
69
71
  bypassPermissions = skip ALL prompts (avoid). Tip: a committed
70
72
  .claude/settings.json permissions.allow is inherited by every
71
73
  worktree. The launch prompt steers the worker to plan + wait first.
72
- -lc | --lead-claude fan: make the lead pane a Claude coordinator (reviews PRs + merges;
74
+ -lc | --lead-claude fan: make the lead pane a Claude coordinator (reviews PRs + merges;
73
75
  it can't see workers' context, but observes via gh/git)
74
76
  --worktree-root <dir> fan: override the worktree parent dir
75
- --review-ceiling N plan/fan: human review cap (default 5)
76
- --assistant <name> fan: manual | claude | codex | custom profile (manual is default)
77
- --launch fan: explicitly launch a locally authorized assistant profile
77
+ --review-ceiling N plan/fan: human review cap (default 5)
78
+ --assistant <name> fan: manual | claude | codex | custom profile (manual is default)
79
+ --launch fan: explicitly launch a locally authorized assistant profile
78
80
 
79
81
  EXAMPLES
80
82
  roadmap # where am I / what's runnable
@@ -112,8 +114,8 @@ if (action.kind === "notyet") {
112
114
  }
113
115
  if (action.kind === "unknown") { console.error(`roadmap: unknown command "${cmd}".\n\n${HELP}`); process.exit(2); }
114
116
 
115
- const root = findRepoRoot(process.cwd());
116
- if (!root && cmd !== "init") { console.error(missingRoadmapHelp(process.cwd())); process.exit(2); }
117
-
118
- const r = spawnSync("node", [join(SCRIPTS, action.script), ...buildArgs(cmd, expandShort(rest))], { stdio: "inherit", cwd: root || process.cwd() });
117
+ const root = findRepoRoot(process.cwd());
118
+ if (!root && cmd !== "init") { console.error(missingRoadmapHelp(process.cwd())); process.exit(2); }
119
+
120
+ const r = spawnSync("node", [join(SCRIPTS, action.script), ...buildArgs(cmd, expandShort(rest))], { stdio: "inherit", cwd: root || process.cwd() });
119
121
  process.exit(r.status ?? 0);
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env node
2
+ // roadmap doctor — reconcile the roadmap against reality and report DRIFT. READ-ONLY: it never
3
+ // writes the YAML, the docs, or Linear. It gathers merged PRs, open PRs, fanout worktrees, a
4
+ // rendered-vs-disk doc diff, and the Linear pull deltas, then lib/doctor-core.mjs classifies
5
+ // them. Every gatherer is guarded: gh/git/Linear missing or slow → that section degrades to
6
+ // empty (like the SessionStart hook), so doctor stays fast and never throws on a bare checkout.
7
+ // Usage: roadmap doctor [--json] (--json like review.mjs; exits 1 when drift is found.)
8
+
9
+ import { readFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { loadGraph } from "./lib/graph.mjs";
12
+ import { loadBacklog, roadmapPaths, slicesRenderOpts } from "./lib/store.mjs";
13
+ import { renderMarkdown } from "./lib/render-core.mjs";
14
+ import { renderBacklogMarkdown } from "./lib/backlog-core.mjs";
15
+ import { mergedPrs, allPrs, worktrees } from "./lib/external-state.mjs";
16
+ import { doctorReport } from "./lib/doctor-core.mjs";
17
+
18
+ const root = process.cwd();
19
+ const args = process.argv.slice(2);
20
+
21
+ // The merged-PR / open-PR / fanout-worktree probes are the shared, guarded gatherers in
22
+ // external-state.mjs (imported above). Only the two doctor-specific reads live here:
23
+
24
+ // Generated docs whose on-disk bytes differ from a fresh render. Line endings are normalized
25
+ // (renderMarkdown emits LF; a Windows checkout may hold CRLF) so a checkout-style newline
26
+ // difference never masquerades as content drift. A MISSING generated doc counts as stale — a
27
+ // roadmap with content but no rendered catalog is drift the same 'roadmap render' fixes.
28
+ function staleDocs(graph, backlog) {
29
+ const stale = [];
30
+ const norm = (s) => s.replace(/\r\n/g, "\n");
31
+ const check = (rel, rendered) => {
32
+ let disk = null;
33
+ try { disk = readFileSync(join(root, rel), "utf8"); } catch { /* missing/unreadable → below */ }
34
+ if (disk == null || norm(disk) !== norm(rendered)) stale.push(rel);
35
+ };
36
+ try { check("docs/SLICES.md", renderMarkdown(graph, slicesRenderOpts(root, backlog))); } catch { /* render failure → skip */ }
37
+ if (backlog) { try { check("docs/BACKLOG.md", renderBacklogMarkdown(backlog)); } catch { /* skip */ } }
38
+ return { staleDocs: stale };
39
+ }
40
+
41
+ // Linear pull deltas (dry, pull-only → zero writes). null when Linear is off/unauthed/unreachable,
42
+ // which doctor-core reads as "skip the Linear section".
43
+ async function linearDeltas() {
44
+ try {
45
+ const { runSync } = await import("./linear.mjs");
46
+ const r = await runSync(root, { dry: true, pullOnly: true });
47
+ return (r.proposals && r.proposals.deltas) || null;
48
+ } catch { return null; }
49
+ }
50
+
51
+ // ── run ──────────────────────────────────────────────────────────────────────
52
+ let graph;
53
+ try {
54
+ graph = loadGraph(roadmapPaths(root).yaml);
55
+ } catch (e) {
56
+ console.error(`✗ could not load the roadmap: ${e.message}`);
57
+ process.exit(2);
58
+ }
59
+ const backlog = loadBacklog(root);
60
+
61
+ const report = doctorReport({
62
+ graph,
63
+ backlog,
64
+ mergedPrs: mergedPrs(root),
65
+ allPrs: allPrs(root) || [],
66
+ worktrees: worktrees(root, graph.meta || {}),
67
+ renderedVsDisk: staleDocs(graph, backlog),
68
+ linearDeltas: await linearDeltas(),
69
+ });
70
+
71
+ if (args.includes("--json")) {
72
+ process.stdout.write(JSON.stringify(report, null, 2) + "\n");
73
+ process.exit(report.driftCount ? 1 : 0);
74
+ }
75
+
76
+ if (!report.driftCount) {
77
+ console.log("✓ no drift — the roadmap matches reality (merged PRs, docs, Linear, worktrees, structure).");
78
+ process.exit(0);
79
+ }
80
+ console.log(`roadmap doctor — ${report.driftCount} drift signal(s):\n`);
81
+ for (const s of report.sections) {
82
+ console.log(`${s.title} (${s.items.length}):`);
83
+ for (const item of s.items) console.log(` • ${item}`);
84
+ console.log("");
85
+ }
86
+ console.log("Read-only — nothing was changed. Reconcile with /sync, 'roadmap render', or the set tools.");
87
+ process.exit(1);
@@ -19,12 +19,12 @@ import os from "node:os";
19
19
  import { join, dirname } from "node:path";
20
20
  import { fileURLToPath } from "node:url";
21
21
  import { loadGraph, flatten, computeWaves, readyNodes, coherenceEnabled } from "./lib/graph.mjs";
22
- import { recommendConcurrency, probeDisk } from "./lib/recommend.mjs";
22
+ import { recommendConcurrency, probeDisk, probeReviewDebt } from "./lib/recommend.mjs";
23
23
  import { synthesizeBrief, branchFor, worktreeFor, launchPrompt, baseRefOf, remoteOf, agentCmdFor } from "./lib/brief.mjs";
24
24
  import { launchDecision, bashWorktreeLines, pwshWorktreeLines, diskBlockLines } from "./lib/fanout-core.mjs";
25
25
  import { terminalChoices } from "./lib/wizard-core.mjs";
26
- import { filterByTrack } from "./lib/execution.mjs";
27
- import { readLocalConfig, resolveProfile, commandFor, launchDecisionForProfile } from "./lib/assistant-core.mjs";
26
+ import { filterByTrack } from "./lib/execution.mjs";
27
+ import { readLocalConfig, resolveProfile, commandFor, launchDecisionForProfile } from "./lib/assistant-core.mjs";
28
28
 
29
29
  const args = process.argv.slice(2);
30
30
  const val = (n, d) => { const i = args.indexOf(n); return i >= 0 && args[i + 1] && !args[i + 1].startsWith("--") ? args[i + 1] : d; };
@@ -37,9 +37,9 @@ const lane = val("--lane", "max"); // max (subscription) | api (ANT
37
37
  const autonomous = has("--autonomous"); // headless claude -p (else interactive, watchable)
38
38
  const dry = has("--dry") || has("--print"); // preview only — launch is the DEFAULT
39
39
  const okAutonomous = has("--yes-spawn-autonomous");
40
- const leadClaude = has("--lead-claude"); // launch claude in the lead pane (else a shell)
41
- const requestedAssistant = val("--assistant", null);
42
- const requestedLaunch = has("--launch");
40
+ const leadClaude = has("--lead-claude"); // launch claude in the lead pane (else a shell)
41
+ const requestedAssistant = val("--assistant", null);
42
+ const requestedLaunch = has("--launch");
43
43
  const outFile = val("--out", null);
44
44
  // The lead pane's claude prompt (only with --lead-claude). It coordinates; it cannot see the
45
45
  // workers' context (separate processes) but observes their PRs/branches and merges.
@@ -53,9 +53,9 @@ const model = flatten(graph);
53
53
  // Windows → Windows Terminal tabs; elsewhere → tmux panes. terminalChoices() owns that rule.
54
54
  const term = val("--term", (graph.meta && graph.meta.terminal) || terminalChoices(os.platform())[0]);
55
55
  // Worker permission mode: flag > meta.worker_mode > 'plan'. The lead session uses the same mode.
56
- const workerMode = val("--worker-mode", (graph.meta && graph.meta.worker_mode) || "plan");
57
- const { config: localConfig } = readLocalConfig(process.cwd());
58
- const profile = resolveProfile(graph, localConfig, requestedAssistant);
56
+ const workerMode = val("--worker-mode", (graph.meta && graph.meta.worker_mode) || "plan");
57
+ const { config: localConfig } = readLocalConfig(process.cwd());
58
+ const profile = resolveProfile(graph, localConfig, requestedAssistant);
59
59
 
60
60
  // ── cloud fanout: dispatch the wave to CLOUD agents via Linear instead of local worktrees.
61
61
  // Placed BEFORE all worktree/disk/terminal logic on purpose — no disk ceiling, no checkout:
@@ -78,7 +78,12 @@ if (has("--cloud")) {
78
78
  }
79
79
 
80
80
  const ready = readyNodes(model);
81
- const rec = recommendConcurrency(ready, graph, { reviewCeiling: Number(val("--review-ceiling", 5)), disk: probeDisk(graph) });
81
+ const rec = recommendConcurrency(ready, graph, {
82
+ reviewCeiling: Number(val("--review-ceiling", 5)),
83
+ reviewDebt: probeReviewDebt(process.cwd(), graph),
84
+ today: new Date().toISOString().slice(0, 10),
85
+ disk: probeDisk(graph),
86
+ });
82
87
  // Disk hard-block: auto-dialing handles the soft path (recommended >= 1), but when even ONE
83
88
  // worktree won't fit, launching would fail mid-checkout — refuse before creating anything.
84
89
  if (rec.disk && rec.disk.cap < 1) {
@@ -102,13 +107,13 @@ if (!wave.length) {
102
107
 
103
108
  // claude invocation per session. Interactive workers START IN PLAN MODE (--permission-mode
104
109
  // plan) so each plans its slice before touching anything; autonomous workers run headless.
105
- function claudeCmd(node) {
106
- const prompt = launchPrompt(node);
107
- if (profile.name === "manual") return `echo "${node.invoke}: worktree and .kickoff.md ready; start your configured assistant here."`;
108
- // meta.agent_cmd remains the legacy Claude compatibility path. New local profiles win.
109
- const base = profile.command
110
- ? commandFor(profile, { prompt, mode: autonomous ? "acceptEdits" : workerMode })
111
- : agentCmdFor(graph, { prompt, mode: workerMode });
110
+ function claudeCmd(node) {
111
+ const prompt = launchPrompt(node);
112
+ if (profile.name === "manual") return `echo "${node.invoke}: worktree and .kickoff.md ready; start your configured assistant here."`;
113
+ // meta.agent_cmd remains the legacy Claude compatibility path. New local profiles win.
114
+ const base = profile.command
115
+ ? commandFor(profile, { prompt, mode: autonomous ? "acceptEdits" : workerMode })
116
+ : agentCmdFor(graph, { prompt, mode: workerMode });
112
117
  const withLane = lane === "api"
113
118
  ? `ANTHROPIC_API_KEY="$ROADMAP_API_KEY" ${base}` // api overflow lane (rarely used)
114
119
  : base; // max: inherit the logged-in subscription
@@ -177,13 +182,13 @@ function basicTerminalScript(kind) {
177
182
 
178
183
  // claude invocation for a PowerShell tab (single-quote the prompt so the outer -Command "" needs no escaping).
179
184
  // Interactive workers start in PLAN MODE; autonomous run headless.
180
- function claudeCmdPwsh(node) {
181
- const prompt = launchPrompt(node);
182
- if (profile.name === "manual") return `Write-Host '${node.invoke}: worktree and .kickoff.md ready; start your configured assistant here.'`;
183
- return profile.command
184
- ? commandFor(profile, { prompt, mode: autonomous ? "acceptEdits" : workerMode })
185
- : agentCmdFor(graph, { prompt, mode: workerMode, quote: "'" });
186
- }
185
+ function claudeCmdPwsh(node) {
186
+ const prompt = launchPrompt(node);
187
+ if (profile.name === "manual") return `Write-Host '${node.invoke}: worktree and .kickoff.md ready; start your configured assistant here.'`;
188
+ return profile.command
189
+ ? commandFor(profile, { prompt, mode: autonomous ? "acceptEdits" : workerMode })
190
+ : agentCmdFor(graph, { prompt, mode: workerMode, quote: "'" });
191
+ }
187
192
 
188
193
  // Windows Terminal adapter: a self-contained PowerShell script — worktree + brief per slice,
189
194
  // then one `wt` window with a LEAD tab + one tab per slice (each cd'd into its worktree).
@@ -283,13 +288,13 @@ else artifact = basicTerminalScript(term); // background
283
288
  const psScript = term === "wt" || term === "warp";
284
289
  const withBom = (s) => (psScript ? "" : "") + s;
285
290
 
286
- // Manual is the default. A locally authorized profile plus --launch is required to spawn.
287
- let decision;
288
- try {
289
- decision = outFile ? { spawn: false, mode: "wrote-script" } : dry ? { spawn: false, mode: "dry" }
290
- : launchDecisionForProfile(profile, { requestedLaunch, autonomous });
291
- if (autonomous && decision.spawn && !okAutonomous) decision = { spawn: false, mode: "autonomous-needs-ack" };
292
- } catch (e) { console.error(`fanout: ${e.message}`); process.exit(2); }
291
+ // Manual is the default. A locally authorized profile plus --launch is required to spawn.
292
+ let decision;
293
+ try {
294
+ decision = outFile ? { spawn: false, mode: "wrote-script" } : dry ? { spawn: false, mode: "dry" }
295
+ : launchDecisionForProfile(profile, { requestedLaunch, autonomous });
296
+ if (autonomous && decision.spawn && !okAutonomous) decision = { spawn: false, mode: "autonomous-needs-ack" };
297
+ } catch (e) { console.error(`fanout: ${e.message}`); process.exit(2); }
293
298
 
294
299
  console.error(`fanout: wave ${waveIdx}/${waves.length} · cap ${cap} (recommended ${rec.recommended}, bound by ${rec.binding.why.split(" — ")[0]}) · term=${term} · lane=${lane}${track ? ` · track=${track}` : ""} · ${decision.mode}`);
295
300
  console.error(`slices: ${wave.map((n) => n.invoke).join(", ")}`);
@@ -3,8 +3,8 @@
3
3
  // the item→node adapter that lets `grab` reuse the fanout brief machinery, and pickNext —
4
4
  // the highest-priority ready thing across roadmap AND backlog. No IO.
5
5
 
6
- import { flatten, readyNodes } from "./graph.mjs";
7
- import { comparePriority, tierBadge, validatePriority, TIERS } from "./priority.mjs";
6
+ import { flatten, readyNodes, commandLaneMembers, commandLaneActive } from "./graph.mjs";
7
+ import { comparePriority, laneComparator, tierBadge, validatePriority, TIERS } from "./priority.mjs";
8
8
  import { addSprint } from "./mcp-core.mjs";
9
9
 
10
10
  export const KINDS = ["bug", "chore", "followup", "urgent", "idea"];
@@ -15,7 +15,7 @@ const SLUG = /^[a-z0-9][a-z0-9-]*$/;
15
15
  // Fields a caller may set on an item via backlog_set (id is structural, set only at add).
16
16
  const ITEM_SETTABLE = new Set([
17
17
  "title", "kind", "status", "priority", "source", "refs", "touches",
18
- "est_sessions", "gate", "prompt", "prs", "completed_on", "promoted_to", "linear", "dispatch_tier",
18
+ "est_sessions", "gate", "prompt", "prs", "completed_on", "promoted_to", "linear", "dispatch_tier", "receipts",
19
19
  ]);
20
20
 
21
21
  // ── validation (plain object) ───────────────────────────────────────────────
@@ -229,11 +229,16 @@ export function backlogItemToNode(item) {
229
229
 
230
230
  // ── pickNext: the single highest-priority ready thing across both trackers ───
231
231
  // Roadmap wins full ties (planned value work outranks erratic work at equal priority).
232
- // backlog may be null (no backlog.yaml). Returns { type: "slice"|"backlog", node|item } or null.
233
- export function pickNext(graph, backlog) {
232
+ // backlog may be null (no backlog.yaml). `today` (YYYY-MM-DD, optional) arms the command-lane
233
+ // boost among slices; absent → lane inactive → the pick order is byte-identical to today.
234
+ // Returns { type: "slice"|"backlog", node|item } or null.
235
+ export function pickNext(graph, backlog, today) {
234
236
  const ready = readyNodes(flatten(graph));
237
+ const laneCmp = laneComparator(commandLaneMembers(graph), commandLaneActive(graph, today));
235
238
  const topSlice = ready.length
236
239
  ? [...ready].sort((a, b) => {
240
+ const lc = laneCmp(a, b);
241
+ if (lc) return lc;
237
242
  const pc = comparePriority(a.priority, b.priority);
238
243
  if (pc) return pc;
239
244
  return a.invoke.localeCompare(b.invoke);
@@ -5,8 +5,8 @@ import { existsSync } from "node:fs";
5
5
  import { dirname, join, resolve } from "node:path";
6
6
 
7
7
  export const REL = ["docs", "roadmap", "roadmap.yaml"];
8
- const MAP = { plan: "scheduler.mjs", render: "render.mjs", fan: "fanout.mjs", fanout: "fanout.mjs", validate: "validate.mjs", show: "show.mjs", cleanup: "cleanup.mjs", wizard: "wizard.mjs", go: "wizard.mjs", mcp: "mcp.mjs", watch: "watch-prs.mjs", set: "set.mjs", backlog: "backlog.mjs", grab: "grab.mjs", promote: "promote.mjs", next: "next.mjs", linear: "linear.mjs", review: "review.mjs", dispatch: "dispatch.mjs", plate: "plate.mjs", estimate: "estimate.mjs", cycle: "cycle.mjs", init: "init.mjs", assistant: "assistant.mjs" };
9
- const NOT_YET = { sync: "P4" };
8
+ const MAP = { plan: "scheduler.mjs", render: "render.mjs", fan: "fanout.mjs", fanout: "fanout.mjs", validate: "validate.mjs", show: "show.mjs", cleanup: "cleanup.mjs", wizard: "wizard.mjs", go: "wizard.mjs", mcp: "mcp.mjs", watch: "watch-prs.mjs", set: "set.mjs", backlog: "backlog.mjs", grab: "grab.mjs", promote: "promote.mjs", next: "next.mjs", linear: "linear.mjs", review: "review.mjs", dispatch: "dispatch.mjs", plate: "plate.mjs", estimate: "estimate.mjs", cycle: "cycle.mjs", init: "init.mjs", assistant: "assistant.mjs", doctor: "doctor.mjs" };
9
+ const NOT_YET = { sync: "P4" };
10
10
 
11
11
  // Walk up from `start` to the first dir containing docs/roadmap/roadmap.yaml; null if none.
12
12
  // `exists` is injectable for testing.
@@ -92,6 +92,6 @@ export function missingRoadmapHelp(cwd) {
92
92
  ` - { id: s1, title: First sprint, status: next, invoke: first-s1, est_sessions: 1 }`,
93
93
  ``,
94
94
  `Save that to ${rel}, then 'roadmap validate' and 'roadmap render'.`,
95
- `Run 'roadmap init' from the repo root for a safe bootstrap preview.`,
95
+ `Run 'roadmap init' from the repo root for a safe bootstrap preview.`,
96
96
  ].join("\n");
97
97
  }
@@ -0,0 +1,63 @@
1
+ // roadmap — doctor brain (PURE). Reconciles the roadmap against reality and reports DRIFT
2
+ // between the plan and what actually shipped/tracked. No IO: doctor.mjs gathers the merged
3
+ // PRs, open PRs, fanout worktrees, the rendered-vs-disk doc diff, and the Linear pull deltas,
4
+ // then this classifies them into report sections. It REUSES the existing detection brains
5
+ // (findUnrecordedMerges, prPhase/matchesRoadmapBranches, validateGraph) rather than re-deriving
6
+ // them, so doctor can never disagree with /sync or the PR watcher. Structural (static) checks are
7
+ // NOT drift — they're `roadmap validate`'s job — so they are deliberately not folded in here.
8
+
9
+ import { findUnrecordedMerges } from "./sync-core.mjs";
10
+ import { prPhase, matchesRoadmapBranches, checksOf } from "./pr-watch-core.mjs";
11
+
12
+ // Open-PR phases that count as drift — a PR sitting in one of these needs a human/agent nudge.
13
+ // "ready" (mergeable) and the transient "checks-pending" are NOT drift; merged/closed aren't open.
14
+ const OPEN_PR_DRIFT = new Set(["draft", "conflicts", "checks-failing"]);
15
+
16
+ // doctorReport(inputs) -> { sections: [{ title, items }], driftCount }.
17
+ // A section is included ONLY when it has drift items, so a clean roadmap yields
18
+ // { sections: [], driftCount: 0 }. Inputs are already-gathered (see doctor.mjs):
19
+ // graph parsed roadmap YAML
20
+ // mergedPrs [{ number, headRefName, title, body }] (state=merged)
21
+ // allPrs [{ number, headRefName, state, isDraft, mergeStateStatus, statusCheckRollup }]
22
+ // worktrees fanout worktrees [{ branch, path, dirty, isMerged }]
23
+ // renderedVsDisk { staleDocs: [path, ...] } — generated docs whose on-disk bytes != a fresh render
24
+ // linearDeltas result.proposals.deltas, or null when Linear is unconfigured/unreachable
25
+ export function doctorReport({ graph, mergedPrs = [], allPrs = [], worktrees = [], renderedVsDisk = {}, linearDeltas = null } = {}) {
26
+ const sections = [];
27
+ const add = (title, items) => { if (items.length) sections.push({ title, items }); };
28
+
29
+ // 1. SHIPPED-BUT-NOT-MARKED — a merged PR whose slice is still open in the roadmap.
30
+ add("Shipped but not marked complete",
31
+ findUnrecordedMerges(graph, mergedPrs).map(
32
+ (u) => `${u.invoke} — merged in PR #${u.pr} (${u.branch}) but still open; reconcile (roadmap set / /sync).`));
33
+
34
+ // 2. STALE GENERATED DOCS — the YAML moved but SLICES.md/BACKLOG.md weren't re-rendered.
35
+ add("Generated docs stale",
36
+ (renderedVsDisk.staleDocs || []).map((p) => `${p} differs from a fresh render — run 'roadmap render'.`));
37
+
38
+ // 3. LINEAR != ROADMAP — pull-only sync deltas (status/priority the tracker disagrees on).
39
+ // null = Linear off/unreachable → skip the section entirely (the graceful guard).
40
+ if (linearDeltas) {
41
+ add("Linear disagrees with the roadmap",
42
+ linearDeltas.map((d) => `${d.kind} ${d.key}: ${d.field} ${d.from} → ${d.to ?? `(${d.note})`} in Linear.`));
43
+ }
44
+
45
+ // 4. OPEN-PR REALITY — roadmap-branch PRs stuck in a drift phase (draft/conflicts/failing).
46
+ // allPrs may be null (gh absent) — external-state signals "unknown" that way; treat as none.
47
+ // Normalize the raw rollup → `checks` via checksOf FIRST (the same step watch-prs does): prPhase
48
+ // keys off pr.checks, so without this the "checks-failing" signal could never fire.
49
+ add("Open PRs needing attention",
50
+ (allPrs || [])
51
+ .map((pr) => ({ ...pr, checks: checksOf(pr) }))
52
+ .filter((pr) => matchesRoadmapBranches(pr.headRefName, graph) && OPEN_PR_DRIFT.has(prPhase(pr)))
53
+ .map((pr) => `PR #${pr.number} (${pr.headRefName}) — ${prPhase(pr)}.`));
54
+
55
+ // 5. STALE WORKTREES — fanout worktrees left unmerged or dirty (work parked mid-air).
56
+ add("Stale fanout worktrees",
57
+ worktrees
58
+ .filter((w) => !w.isMerged || w.dirty)
59
+ .map((w) => `${w.branch || "(detached)"} — ${w.isMerged ? "merged" : "UNMERGED"}, ${w.dirty ? "dirty" : "clean"} (${w.path}).`));
60
+
61
+ const driftCount = sections.reduce((n, s) => n + s.items.length, 0);
62
+ return { sections, driftCount };
63
+ }
@@ -0,0 +1,67 @@
1
+ // roadmap — external reality probes (git + gh). IMPURE but GUARDED: every probe returns a safe/empty
2
+ // value when git/gh is missing, unauthed, slow, or errors, so callers never throw and stay fast. The
3
+ // same gatherers were inlined in hooks/session-start.mjs, scripts/watch-prs.mjs, and scripts/cleanup.mjs;
4
+ // this is the ONE shared home so drift-doctor + review-debt backpressure don't add a fourth/fifth copy.
5
+ // The parsing is split into a PURE helper (parseWorktrees) so it can be unit-tested without a repo.
6
+
7
+ import { spawnSync } from "node:child_process";
8
+ import { resolve, sep } from "node:path";
9
+
10
+ const git = (root, ...a) => spawnSync("git", a, { cwd: root, encoding: "utf8" });
11
+
12
+ // TODO(dedupe): hooks/session-start.mjs, scripts/watch-prs.mjs, and scripts/cleanup.mjs still carry
13
+ // their own inline copies of these gatherers (pre-dating this module). Fold them onto this one home
14
+ // in a follow-up so there's a single implementation. Tracked in the finishing-discipline backlog.
15
+
16
+ // Merged PRs: [{ number, headRefName, title, body }]. [] on any failure. 5s cap.
17
+ export function mergedPrs(root) {
18
+ try {
19
+ const r = spawnSync("gh", ["pr", "list", "--state", "merged", "--limit", "100", "--json", "number,headRefName,title,body"],
20
+ { cwd: root, encoding: "utf8", timeout: 5000 });
21
+ if (r.status !== 0 || !r.stdout) return [];
22
+ return JSON.parse(r.stdout);
23
+ } catch { return []; }
24
+ }
25
+
26
+ // All PRs incl. open/draft with merge + check state:
27
+ // [{ number, title, headRefName, state, isDraft, mergeStateStatus, statusCheckRollup }].
28
+ // null on failure (lets a caller distinguish "gh absent" from "zero PRs"). 5s cap.
29
+ export function allPrs(root) {
30
+ try {
31
+ const r = spawnSync("gh", ["pr", "list", "--state", "all", "--limit", "100",
32
+ "--json", "number,title,headRefName,state,isDraft,mergeStateStatus,statusCheckRollup"],
33
+ { cwd: root, encoding: "utf8", timeout: 5000 });
34
+ if (r.status !== 0) return null;
35
+ return JSON.parse(r.stdout);
36
+ } catch { return null; }
37
+ }
38
+
39
+ // PURE: parse `git worktree list --porcelain` → [{ path, branch, isMerged }], optionally filtered to
40
+ // those under wtRoot. mergedSet = branch names merged into the base (from `git branch --merged`).
41
+ export function parseWorktrees(porcelain, { mergedSet = new Set(), wtRoot = null } = {}) {
42
+ const all = (porcelain ? porcelain.split(/\n\n+/) : []).map((b) => ({
43
+ path: (b.match(/^worktree (.+)$/m) || [])[1],
44
+ branch: (b.match(/^branch refs\/heads\/(.+)$/m) || [])[1] || null,
45
+ })).filter((w) => w.path);
46
+ const under = wtRoot
47
+ ? all.filter((w) => { const rp = resolve(w.path); return rp === wtRoot || rp.startsWith(wtRoot + sep); })
48
+ : all;
49
+ return under.map((w) => ({ ...w, isMerged: w.branch ? mergedSet.has(w.branch) : false }));
50
+ }
51
+
52
+ // Fanout worktrees under wtRoot with dirty + merged classification: [{ path, branch, isMerged, dirty }].
53
+ // [] on any failure. meta drives remote/base/worktree_root exactly as cleanup.mjs resolves them.
54
+ export function worktrees(root, meta = {}) {
55
+ try {
56
+ const remote = meta.remote || "origin";
57
+ const base = meta.base_branch || "main";
58
+ const wtRoot = resolve(meta.worktree_root || resolve(root, "..", "_worktrees"));
59
+ const porcelain = (git(root, "worktree", "list", "--porcelain").stdout || "").trim();
60
+ const mergedOut = git(root, "branch", "--merged", `${remote}/${base}`, "--format=%(refname:short)").stdout || "";
61
+ const mergedSet = new Set(mergedOut.split("\n").map((s) => s.trim()).filter(Boolean));
62
+ return parseWorktrees(porcelain, { mergedSet, wtRoot }).map((w) => ({
63
+ ...w,
64
+ dirty: ((git(root, "-C", w.path, "status", "--porcelain").stdout) || "").trim().length > 0,
65
+ }));
66
+ } catch { return []; }
67
+ }
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { readFileSync } from "node:fs";
8
8
  import YAML from "yaml";
9
- import { comparePriority } from "./priority.mjs";
9
+ import { comparePriority, laneComparator } from "./priority.mjs";
10
10
 
11
11
  export const STATUS = {
12
12
  active: { emoji: "🟢", label: "Active", done: false, rank: 0 },
@@ -84,6 +84,8 @@ export function flatten(graph) {
84
84
  resumeAction: sp.resume_action || "",
85
85
  kickoffBrief: sp.kickoff_brief || "brief",
86
86
  prs: sp.prs || [],
87
+ receipts: sp.receipts || null, // optional per-slice completion evidence (see validate-core required_receipts)
88
+ outcome: sp.outcome || null, // optional founder-review rollup group (render groups siblings by this)
87
89
  completedOn: sp.completed_on || null,
88
90
  pi,
89
91
  sprint: sp,
@@ -253,9 +255,67 @@ export function coherenceEnabled(meta) {
253
255
  return !(meta && meta.discipline && meta.discipline.coherence === false);
254
256
  }
255
257
 
258
+ // Command lane: meta.command_lane = { objective, until: "YYYY-MM-DD", pi?, slices?: [invoke,...] }.
259
+ // A dated objective that outranks generic priority until its date passes OR its slices all complete —
260
+ // so the roadmap makes finishing the most important outcome easier than discovering the next slice.
261
+ // members(): the set of slice invoke keys in the lane (by explicit `slices` and/or every sprint of `pi`).
262
+ export function commandLaneMembers(graph) {
263
+ const lane = graph && graph.meta && graph.meta.command_lane;
264
+ if (!lane) return new Set();
265
+ const members = new Set();
266
+ if (Array.isArray(lane.slices)) for (const s of lane.slices) members.add(s);
267
+ if (lane.pi) for (const pi of graph.pis || []) if (pi.id === lane.pi) for (const sp of pi.sprints || []) if (sp.invoke) members.add(sp.invoke);
268
+ return members;
269
+ }
270
+
271
+ // active: lane set, not past `until` (YYYY-MM-DD lexical compare), and >=1 member slice still open.
272
+ // Once every member ships (or the date passes) the override releases and ordering returns to normal.
273
+ // `today` is injected (a YYYY-MM-DD string) so the gate is testable and deterministic.
274
+ export function commandLaneActive(graph, today) {
275
+ const lane = graph && graph.meta && graph.meta.command_lane;
276
+ if (!lane) return false;
277
+ if (lane.until && today && String(today) > lane.until) return false;
278
+ const members = commandLaneMembers(graph);
279
+ if (!members.size) return false;
280
+ for (const pi of graph.pis || []) for (const sp of pi.sprints || []) {
281
+ if (sp.invoke && members.has(sp.invoke) && !isDone(sp.status)) return true;
282
+ }
283
+ return false;
284
+ }
285
+
286
+ // SHAPE validation for meta.command_lane (called by validate-core, living beside the lane helpers the
287
+ // way validatePriority lives beside comparePriority). Errors on a malformed lane; WARNS on a dangling
288
+ // pi/slice ref — the lane would look armed but boost nothing. No clock here: expiry vs `until` is a
289
+ // PLAN-time concern. Returns { errors, warnings }; empty when no command_lane is set.
290
+ export function validateCommandLane(graph) {
291
+ const errors = [], warnings = [];
292
+ const lane = graph && graph.meta && graph.meta.command_lane;
293
+ if (lane == null) return { errors, warnings };
294
+ if (typeof lane !== "object" || Array.isArray(lane)) {
295
+ errors.push("meta.command_lane must be a mapping { objective, until, pi?, slices? }");
296
+ return { errors, warnings };
297
+ }
298
+ if (typeof lane.objective !== "string" || !lane.objective) errors.push("meta.command_lane.objective must be a non-empty string");
299
+ if (typeof lane.until !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(lane.until)) errors.push(`meta.command_lane.until must be YYYY-MM-DD (got ${JSON.stringify(lane.until)})`);
300
+ const piIds = new Set((graph.pis || []).map((p) => p.id));
301
+ const invokeKeys = new Set();
302
+ for (const p of graph.pis || []) for (const s of p.sprints || []) if (s.invoke) invokeKeys.add(s.invoke);
303
+ if (lane.pi != null) {
304
+ if (typeof lane.pi !== "string") errors.push("meta.command_lane.pi must be a PI id string");
305
+ else if (!piIds.has(lane.pi)) warnings.push(`meta.command_lane.pi "${lane.pi}" matches no PI — the lane covers nothing via pi`);
306
+ }
307
+ if (lane.slices != null) {
308
+ if (!Array.isArray(lane.slices)) errors.push("meta.command_lane.slices must be an array of invoke keys");
309
+ else for (const s of lane.slices) if (!invokeKeys.has(s)) warnings.push(`meta.command_lane.slices: "${s}" resolves to no slice invoke`);
310
+ }
311
+ return { errors, warnings };
312
+ }
313
+
256
314
  // Compute execution waves under a concurrency cap N.
257
315
  // Returns { waves: [[node,...],...], held: { onHuman:[node], blocked:[node] } }.
258
316
  // opts.coherence (default true): PI-coherence tiebreak in the ready sort — see coherenceEnabled.
317
+ // opts.meta + opts.today: an active command lane (see commandLaneMembers/Active) floats its member
318
+ // slices to the top of the ready sort. Both absent → lane inactive → ordering byte-identical to today.
259
319
  export function computeWaves(model, N = 3, opts = {}) {
260
320
  const coherence = opts.coherence !== false;
261
321
  const { nodes, sprintIndex } = model;
@@ -266,6 +326,12 @@ export function computeWaves(model, N = 3, opts = {}) {
266
326
  throw err;
267
327
  }
268
328
 
329
+ // Command-lane boost, computed once per call. model.pis is graph.pis; meta rides in via opts.
330
+ const laneGraph = { meta: opts.meta, pis: model.pis };
331
+ const laneMembers = commandLaneMembers(laneGraph);
332
+ const laneActive = commandLaneActive(laneGraph, opts.today);
333
+ const laneCmp = laneComparator(laneMembers, laneActive);
334
+
269
335
  // Work on a mutable copy of statuses so optimistic completion drives layering.
270
336
  const status = new Map(nodes.map((n) => [n.nodeKey, n.status]));
271
337
  const localDone = (key) => isDone(status.get(key));
@@ -301,6 +367,8 @@ export function computeWaves(model, N = 3, opts = {}) {
301
367
  }
302
368
 
303
369
  ready.sort((a, b) => {
370
+ const lc = laneCmp(a, b); // active command lane floats its members first
371
+ if (lc) return lc; // inactive/absent → 0 → priority, coherence, existing order
304
372
  const pc = comparePriority(a.priority, b.priority); // declared priority wins the cap slot
305
373
  if (pc) return pc; // both absent → 0 → coherence, then existing order
306
374
  if (coherence && a.piId !== b.piId) {