@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.
@@ -779,7 +779,13 @@ export function buildPullProposals({ cfg, inbound, graph, backlog }) {
779
779
  if (to === "canceled") {
780
780
  if (known.kind === "item") { if (known.status !== "dropped") deltas.push({ kind: known.kind, key: known.key, identifier: iss.identifier, field: "status", from: known.status, to: "dropped" }); }
781
781
  else deltas.push({ kind: known.kind, key: known.key, identifier: iss.identifier, field: "status", from: known.status, to: null, note: `canceled in Linear — no roadmap equivalent; decide by hand` });
782
- } else if (to && to !== known.status && !(known.kind === "item" && to === "active" && known.status === "in_progress")) {
782
+ } else if (to && to !== known.status && pushedType !== "completed" && !(known.kind === "item" && to === "active" && known.status === "in_progress")) {
783
+ // pushedType === "completed" guard: completion is roadmap-authoritative (a slice is done when
784
+ // its PR merges). A stale Linear "In Progress"/"Todo" on a shipped slice must NOT propose
785
+ // un-completing it — that delta would hold the stateId push (buildPushPlan), and since pull is
786
+ // propose-only the delta never applies, so the issue is stuck non-Done in Linear forever. Let
787
+ // the push re-assert Done instead. (A genuine human COMPLETION in Linear — roadmap active,
788
+ // Linear completed — has pushedType "started" and still proposes, as before.)
783
789
  deltas.push({ kind: known.kind, key: known.key, identifier: iss.identifier, field: "status", from: known.status, to: known.kind === "item" && to === "active" ? "in_progress" : to });
784
790
  }
785
791
  }
@@ -22,7 +22,7 @@ const SETTABLE = new Set([
22
22
  "title", "what", "status", "status_label", "est_sessions", "weight",
23
23
  "deps", "touches", "owns", "gate", "gated_on", "read_order", "resume_action",
24
24
  "prs", "completed_on", "optional", "execution", "track", "priority", "prompt", "kickoff_brief", "linear", "milestone", "dispatch_tier",
25
- "shape", "risks", "estimate",
25
+ "shape", "risks", "estimate", "receipts", "outcome",
26
26
  ]);
27
27
 
28
28
  // ── tool registry ─────────────────────────────────────────────────────────────
@@ -54,7 +54,7 @@ export const TOOLS = [
54
54
  owns: { type: "array", items: { type: "string" } }, gate: { type: "string" },
55
55
  weight: { enum: ["heavy", "medium", "light"] }, gated_on: { type: "string" },
56
56
  read_order: { type: "array", items: { type: "string" } }, resume_action: { type: "string" },
57
- prompt: { type: "string" }, milestone: { type: "string" }, status_label: { type: "string" },
57
+ prompt: { type: "string" }, milestone: { type: "string" }, outcome: { type: "string" }, status_label: { type: "string" },
58
58
  dispatch_tier: { type: "string" }, kickoff_brief: { type: "string" }, optional: { type: "boolean" },
59
59
  track: { type: "string" }, risks: { type: "array", items: { type: "string" } },
60
60
  priority: { type: "object", properties: { tier: { enum: ["P0", "P1", "P2", "P3"] }, weight: { type: "number", minimum: 0, maximum: 100 }, reason: { type: "string" } } } } } },
@@ -174,7 +174,7 @@ export function addSprint(doc, args) {
174
174
  if (pi < 0) throw new Error(`no PI "${args.pi}"`);
175
175
  const node = { id: args.id, title: args.title, status: args.status || "scheduled", invoke: args.invoke };
176
176
  // Same silent-drop hazard as addPi: keep in step with the sprint schema.
177
- for (const k of ["what", "est_sessions", "gate", "weight", "gated_on", "resume_action", "prompt", "priority", "linear", "milestone",
177
+ for (const k of ["what", "est_sessions", "gate", "weight", "gated_on", "resume_action", "prompt", "priority", "linear", "milestone", "outcome",
178
178
  "shape", "estimate", "optional", "kickoff_brief", "track", "execution", "status_label", "dispatch_tier"]) if (args[k] != null) node[k] = args[k];
179
179
  for (const k of ["deps", "touches", "owns", "read_order", "risks"]) if (Array.isArray(args[k])) node[k] = args[k];
180
180
  const piMap = pisSeq(doc).items[pi];
@@ -5,24 +5,29 @@
5
5
  // dependency cycle; callers catch.
6
6
 
7
7
  import { flatten, computeWaves, readyNodes, coherenceEnabled, isDone } from "./graph.mjs";
8
- import { recommendConcurrency, nodeWeight, probeDisk } from "./recommend.mjs";
8
+ import { recommendConcurrency, nodeWeight, probeDisk, probeReviewDebt } from "./recommend.mjs";
9
9
  import { branchFor, worktreeFor, launchPrompt } from "./brief.mjs";
10
10
  import { normalizeExecution, suggestedConcurrency } from "./execution.mjs";
11
11
 
12
12
  const round = (x) => Math.round(x * 10) / 10;
13
13
 
14
- // buildPlan(graph, { cap, useFree, reviewCeiling, disk }). cap omitted -> the recommended cap.
15
- // disk: undefined -> probe the machine (the real surfaces); null/object -> injected (tests).
14
+ // buildPlan(graph, { cap, useFree, reviewCeiling, reviewDebt, today, disk }). cap omitted -> the
15
+ // recommended cap. disk/reviewDebt: undefined -> probe the real surfaces; value -> injected (tests).
16
16
  export function buildPlan(graph, opts = {}) {
17
17
  const model = flatten(graph);
18
18
  const ready = readyNodes(model);
19
+ const today = opts.today ?? new Date().toISOString().slice(0, 10);
19
20
  const rec = recommendConcurrency(ready, graph, {
20
21
  useFree: opts.useFree,
21
22
  reviewCeiling: opts.reviewCeiling ?? 5,
23
+ reviewDebt: opts.reviewDebt !== undefined ? opts.reviewDebt : probeReviewDebt(process.cwd(), graph),
24
+ today,
22
25
  disk: opts.disk !== undefined ? opts.disk : probeDisk(graph),
23
26
  });
24
27
  const cap = opts.cap != null ? Number(opts.cap) : rec.recommended;
25
- const { waves, held } = computeWaves(model, cap, { coherence: coherenceEnabled(graph.meta) });
28
+ // Command-lane float: pass meta + today so an active lane's member slices sort first in the wave
29
+ // (see computeWaves). Inactive/absent lane → byte-identical to the pre-lane order.
30
+ const { waves, held } = computeWaves(model, cap, { coherence: coherenceEnabled(graph.meta), meta: graph.meta, today });
26
31
 
27
32
  // Which PIs each wave CLOSES (all sprints done once the wave lands, counting earlier waves
28
33
  // optimistically) — the coherence read-out: "this wave finishes auth".
@@ -41,3 +41,11 @@ export function comparePriority(a, b) {
41
41
  export function tierBadge(p) {
42
42
  return p && TIER_RANK.has(p.tier) ? p.tier : null;
43
43
  }
44
+
45
+ // Command-lane sort boost (see graph.mjs commandLaneMembers/Active). When a dated command lane is
46
+ // active, its member slices sort FIRST — ahead of even declared priority — so finishing the committed
47
+ // objective beats discovering the next slice. Inactive (or no lane) → always 0, so the caller's
48
+ // existing ordering is byte-for-byte untouched: the backward-compat guarantee.
49
+ export function laneComparator(members, active) {
50
+ return (a, b) => active ? ((members.has(a.invoke) ? 0 : 1) - (members.has(b.invoke) ? 0 : 1)) : 0;
51
+ }
@@ -9,7 +9,8 @@ import os from "node:os";
9
9
  import { existsSync, statfsSync } from "node:fs";
10
10
  import { spawnSync } from "node:child_process";
11
11
  import { dirname, resolve } from "node:path";
12
- import { resolveGate } from "./graph.mjs";
12
+ import { resolveGate, commandLaneActive, commandLaneMembers } from "./graph.mjs";
13
+ import { allPrs, worktrees } from "./external-state.mjs";
13
14
 
14
15
  // Per-session resource cost by weight class — cross-language defaults (a full test
15
16
  // suite / heavy compile ~3.5GB/2 cores; a build/lint ~1.5GB/1 core; docs cheap).
@@ -113,6 +114,20 @@ export function probeDisk(graph, cwd = process.cwd()) {
113
114
  }
114
115
  }
115
116
 
117
+ // Review-debt probe: how much unresolved review work already awaits a human — the backpressure
118
+ // signal that dials concurrency DOWN when PRs/worktrees pile up faster than they merge. Counts open
119
+ // non-draft PRs stuck DIRTY/BLOCKED/UNSTABLE + dirty-or-unmerged fanout worktrees. Fully GUARDED via
120
+ // external-state (gh/git absent, slow, or outside a repo → empty), so it returns 0, never throws.
121
+ export function probeReviewDebt(root, graph) {
122
+ const meta = (graph && graph.meta) || {};
123
+ const STUCK = new Set(["DIRTY", "BLOCKED", "UNSTABLE"]);
124
+ const stuckPrs = (allPrs(root) || []).filter(
125
+ (p) => p.state === "OPEN" && !p.isDraft && STUCK.has(p.mergeStateStatus)
126
+ ).length;
127
+ const stuckWts = worktrees(root, meta).filter((w) => w.dirty || !w.isMerged).length;
128
+ return stuckPrs + stuckWts;
129
+ }
130
+
116
131
  export function systemInfo() {
117
132
  const cpus = os.cpus() || [];
118
133
  return {
@@ -130,7 +145,11 @@ const avg = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0);
130
145
  // disk ({ perWorktreeGb, freeGb } from probeDisk; absent/null → no disk ceiling) }
131
146
  export function recommendConcurrency(ready, graph, opts = {}) {
132
147
  const sys = opts.sys || systemInfo();
133
- const reviewCeiling = opts.reviewCeiling ?? 5;
148
+ // Review-debt backpressure: each stuck PR / unresolved worktree lowers the review ceiling, so a
149
+ // growing merge queue auto-throttles new fanout. reviewDebt is INJECTED (stays pure, like disk);
150
+ // callers probe it via probeReviewDebt. Floors at 1 — the soft ceiling never blocks the lane.
151
+ const reviewDebt = opts.reviewDebt ?? 0;
152
+ const reviewCeiling = Math.max(1, (opts.reviewCeiling ?? 5) - reviewDebt);
134
153
  const coreReserve = opts.osCoreReserve ?? 2; // leave cores for the OS/editor/lead
135
154
  const ramReserve = opts.osRamReserveGb ?? 4;
136
155
  const disk = opts.disk || null; // stays PURE: callers probe (probeDisk) and inject
@@ -154,7 +173,9 @@ export function recommendConcurrency(ready, graph, opts = {}) {
154
173
  { n: cpuCap, why: `CPU — ${sys.cores} cores (− ${coreReserve} reserved) ÷ ~${avgCores.toFixed(1)}/session` },
155
174
  { n: ramCap, why: `RAM — ~${ramBasis.toFixed(0)}GB usable ÷ ~${avgRam.toFixed(1)}GB/session` },
156
175
  { n: workCap, why: `work — ${ready.length} independent ready slice(s)` },
157
- { n: reviewCeiling, why: `review PR review/merge bottleneck (soft ceiling)` },
176
+ { n: reviewCeiling, why: reviewDebt > 0
177
+ ? `review — PR review/merge bottleneck (soft ceiling, −${reviewDebt} review debt)`
178
+ : `review — PR review/merge bottleneck (soft ceiling)` },
158
179
  ];
159
180
  // Disk ceiling — the only one allowed to compute to 0: recommended stays >= 1 (soft
160
181
  // auto-dial), but callers that create worktrees (fan, grab) hard-block on disk.cap < 1.
@@ -164,6 +185,13 @@ export function recommendConcurrency(ready, graph, opts = {}) {
164
185
  diskCap = Math.floor(Math.max(0, disk.freeGb - diskReserve) / Math.max(disk.perWorktreeGb, 0.01));
165
186
  candidates.push({ n: Math.max(diskCap, 0), why: `disk — need ~${disk.perWorktreeGb.toFixed(1)}GB/worktree, ${disk.freeGb.toFixed(1)}GB free` });
166
187
  }
188
+ // Command-lane cap — while a dated command-lane objective is ACTIVE, don't fan wider than the lane
189
+ // itself has ready: keep the machine finishing the committed outcome instead of diluting attention
190
+ // across off-lane slices. Gated on active (needs the injected `today`); absent/inactive → no candidate.
191
+ if (commandLaneActive(graph, opts.today)) {
192
+ const members = commandLaneMembers(graph);
193
+ candidates.push({ n: ready.filter((n) => members.has(n.invoke)).length, why: `command-lane — cap to lane` });
194
+ }
167
195
  const binding = candidates.reduce((a, b) => (b.n < a.n ? b : a));
168
196
  return {
169
197
  recommended: Math.max(1, binding.n),
@@ -31,7 +31,7 @@ export function renderMarkdown(graph, opts = {}) {
31
31
  ].filter(Boolean);
32
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
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.");
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
35
  w("");
36
36
  // Backlog pointer — emitted only when the repo has a backlog (opts.backlog), so a
37
37
  // backlog-free repo renders byte-identically.
@@ -43,7 +43,7 @@ export function renderMarkdown(graph, opts = {}) {
43
43
  // ---- cross-PI wave map ----------------------------------------------------
44
44
  w(`## Ready now — wave map (cap ${cap})`);
45
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`.");
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
47
  w("");
48
48
  let waveResult;
49
49
  try {
@@ -88,6 +88,16 @@ export function renderMarkdown(graph, opts = {}) {
88
88
  // sprint-status strip
89
89
  const strip = (pi.sprints || []).map((s) => `${s.id.toUpperCase()} ${emojiFor(s.status)}`).join(" · ");
90
90
  if (strip) w(`> Sprints: ${strip}`);
91
+ // Outcome rollups: group this PI's slices by `outcome` into one founder-review line each
92
+ // (done/total). First-seen order. No slice carries an outcome → no lines (byte-identical).
93
+ const outcomes = new Map();
94
+ for (const s of pi.sprints || []) {
95
+ if (!s.outcome) continue;
96
+ const o = outcomes.get(s.outcome) || { done: 0, total: 0 };
97
+ o.total++; if (isDone(s.status)) o.done++;
98
+ outcomes.set(s.outcome, o);
99
+ }
100
+ for (const [name, o] of outcomes) w(`> OUTCOME ${name}: ${o.done}/${o.total} ${emojiFor("complete")}`);
91
101
  if (plan) w(`> Exec plan: ${plan}${pi.exec_hint ? ` _(human hint: ${pi.exec_hint})_` : ""}`);
92
102
  if (pi.deps && pi.deps.length) w(`> Deps: ${pi.deps.map((d) => d.toUpperCase()).join(", ")}`);
93
103
  if (pi.exit_criteria) w(`> Exit: ${oneLine(pi.exit_criteria)}`);
@@ -99,9 +109,11 @@ export function renderMarkdown(graph, opts = {}) {
99
109
  const sess = isDone(sp.status) ? "—" : (typeof sp.est_sessions === "number" ? `~${fmt(sp.est_sessions)}` : "?");
100
110
  const deps = depCell(node);
101
111
  const prs = (sp.prs && sp.prs.length) ? ` · ${sp.prs.join(" ")}` : "";
112
+ const rcptKeys = sp.receipts ? Object.keys(sp.receipts).filter((k) => sp.receipts[k]) : [];
113
+ const rcpts = rcptKeys.length ? ` · 📎 ${rcptKeys.join(",")}` : "";
102
114
  const badge = tierBadge(sp.priority);
103
115
  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} |`);
116
+ 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}${rcpts} |`);
105
117
  }
106
118
  w("");
107
119
  }
@@ -16,8 +16,10 @@ export function graphDiff(oldGraph, newGraph) {
16
16
  const oldByInvoke = new Map(oldM.nodes.map((n) => [n.invoke, n]));
17
17
  const newByInvoke = new Map(newM.nodes.map((n) => [n.invoke, n]));
18
18
  const oldPis = new Set(((oldGraph && oldGraph.pis) || []).map((p) => p.id));
19
+ const newPis = new Set((newGraph.pis || []).map((p) => p.id));
19
20
 
20
21
  const addedPis = (newGraph.pis || []).filter((p) => !oldPis.has(p.id)).map((p) => ({ id: p.id, title: p.title }));
22
+ const removedPis = ((oldGraph && oldGraph.pis) || []).filter((p) => !newPis.has(p.id)).map((p) => ({ id: p.id, title: p.title }));
21
23
  const addedSprints = [];
22
24
  const completedSlices = [];
23
25
  const statusFlips = [];
@@ -44,7 +46,7 @@ export function graphDiff(oldGraph, newGraph) {
44
46
  const removedSprints = oldM.nodes.filter((o) => !newByInvoke.has(o.invoke))
45
47
  .map((o) => ({ invoke: o.invoke, pi: o.piId, title: o.title }));
46
48
 
47
- return { addedPis, addedSprints, completedSlices, removedSprints, statusFlips, priorityChanges, stillHeld };
49
+ return { addedPis, removedPis, addedSprints, completedSlices, removedSprints, statusFlips, priorityChanges, stillHeld };
48
50
  }
49
51
 
50
52
  // Either snapshot may be null (no backlog.yaml then/now).
@@ -81,6 +83,10 @@ export function pisInFlight(graph) {
81
83
  export function reviewDigest({ gd, bd, graph }) {
82
84
  const added = bd.captured.length + gd.addedSprints.length;
83
85
  const completed = gd.completedSlices.length;
86
+ // Closure budget: sessions still owed across every not-done slice (null est → 0).
87
+ const estOpenSessions = flatten(graph).nodes
88
+ .filter((n) => !isDone(n.status))
89
+ .reduce((acc, n) => acc + (n.estSessions || 0), 0);
84
90
  return {
85
91
  shipped: gd.completedSlices,
86
92
  captured: { items: bd.captured, sprints: gd.addedSprints },
@@ -96,6 +102,8 @@ export function reviewDigest({ gd, bd, graph }) {
96
102
  }),
97
103
  aging: gd.stillHeld,
98
104
  newPis: gd.addedPis,
105
+ removedPis: gd.removedPis,
106
+ estOpenSessions,
99
107
  removed: gd.removedSprints,
100
108
  pisInFlight: pisInFlight(graph),
101
109
  priorityChanges: gd.priorityChanges,
@@ -2,7 +2,7 @@
2
2
  // No IO. validate.mjs prints + exits on the result; the MCP `validate` read tool returns it.
3
3
  // Structural checks + dependency resolution (via flatten) + cycle detection.
4
4
 
5
- import { flatten, detectCycle, STATUS } from "./graph.mjs";
5
+ import { flatten, detectCycle, STATUS, HELD_STATUSES, validateCommandLane } from "./graph.mjs";
6
6
  import { validateExecution } from "./execution.mjs";
7
7
  import { validatePriority } from "./priority.mjs";
8
8
  import { validateLinearConfig } from "./linear-core.mjs";
@@ -10,6 +10,10 @@ import { validateEstimation } from "./estimate-core.mjs";
10
10
 
11
11
  const isDone = (s) => !!(STATUS[s] && STATUS[s].done);
12
12
 
13
+ // Known per-slice completion-evidence keys (sp.receipts). meta.discipline.required_receipts
14
+ // opts a repo into WARNing when a complete slice omits one.
15
+ export const RECEIPT_KEYS = ["build", "test", "clone_install", "screenshot", "signoff", "publish"];
16
+
13
17
  export function validateGraph(graph) {
14
18
  const errors = [];
15
19
  const warnings = [];
@@ -19,16 +23,16 @@ export function validateGraph(graph) {
19
23
  const meta = graph.meta || {};
20
24
  if (meta.schema_version !== 1) err(`meta.schema_version must be 1 (got ${JSON.stringify(meta.schema_version)})`);
21
25
  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
- }
26
+ if (meta.terminal && !["warp", "wt", "tmux", "iterm", "background", "print"].includes(meta.terminal)) {
27
+ err(`meta.terminal "${meta.terminal}" is not a known adapter`);
28
+ }
29
+ if (meta.assistants != null) {
30
+ if (typeof meta.assistants !== "object" || Array.isArray(meta.assistants)) err("meta.assistants must be a mapping");
31
+ else {
32
+ if (meta.assistants.default != null && typeof meta.assistants.default !== "string") err("meta.assistants.default must be a profile name string");
33
+ if (meta.assistants.profiles != null && (typeof meta.assistants.profiles !== "object" || Array.isArray(meta.assistants.profiles))) err("meta.assistants.profiles must be a mapping");
34
+ }
35
+ }
32
36
 
33
37
  // Jira is the designed follow-up but NOT implemented — surface a stray block instead of
34
38
  // letting someone believe it syncs (docs/DEPLOYMENT.md documents the planned shape).
@@ -47,6 +51,15 @@ export function validateGraph(graph) {
47
51
  if (meta.discipline.pi_min_slices != null && !(Number.isInteger(meta.discipline.pi_min_slices) && meta.discipline.pi_min_slices >= 1)) {
48
52
  err("meta.discipline.pi_min_slices must be an integer >= 1 (a bad knob silently disabling a guardrail is the failure mode)");
49
53
  }
54
+ if (meta.discipline.active_pi_cap != null && !(Number.isInteger(meta.discipline.active_pi_cap) && meta.discipline.active_pi_cap >= 1)) {
55
+ err("meta.discipline.active_pi_cap must be an integer >= 1 (the max concurrently-active PIs the portfolio allows)");
56
+ }
57
+ // Finishing-discipline: which receipts a complete slice must carry (opt-in). A bad value
58
+ // would silently disable the check, so reject a non-array or an unknown key.
59
+ const rr = meta.discipline.required_receipts;
60
+ if (rr != null && !(Array.isArray(rr) && rr.every((k) => RECEIPT_KEYS.includes(k)))) {
61
+ err(`meta.discipline.required_receipts must be an array of known receipt keys (${RECEIPT_KEYS.join("|")})`);
62
+ }
50
63
  // Composition SHAPE (capture_ratio guards growth RATE): a PI under the floor is usually a
51
64
  // slice wearing a PI's coat — 13 one-slice PIs is how a 64-project wall happens. ONE
52
65
  // aggregated warning (signal, not a wall); complete PIs exempt (history is what it is).
@@ -57,6 +70,16 @@ export function validateGraph(graph) {
57
70
  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
71
  }
59
72
  }
73
+ // WIP cap: too many concurrently-ACTIVE PIs is how a portfolio stops finishing and starts
74
+ // sprawling. The roadmap should reward closure over discovery — one aggregated warning when
75
+ // the funded-active count exceeds policy (complete/held/scheduled PIs don't count against it).
76
+ const cap = meta.discipline.active_pi_cap;
77
+ if (Number.isInteger(cap) && cap >= 1) {
78
+ const activePis = (graph.pis || []).filter((pi) => pi.status === "active");
79
+ if (activePis.length > cap) {
80
+ warn(`WIP: ${activePis.length} active PI(s) exceed the cap of ${cap} (${activePis.map((p) => p.id).join(", ")}) — finish an active PI before starting another; the roadmap rewards closure over discovery`);
81
+ }
82
+ }
60
83
  }
61
84
  }
62
85
  if (meta.last_review != null) {
@@ -65,6 +88,12 @@ export function validateGraph(graph) {
65
88
  }
66
89
  }
67
90
 
91
+ // Optional meta.command_lane: a dated finishing override, SHAPE-validated in graph.mjs beside the
92
+ // lane helpers (expiry vs `until` is a PLAN-time concern — the pure validator has no clock).
93
+ const cl = validateCommandLane(graph);
94
+ for (const e of cl.errors) err(e);
95
+ for (const w of cl.warnings) warn(w);
96
+
68
97
  // Optional meta.linear + per-PI overrides + sprint linear fields. Absent → no-op.
69
98
  const lin = validateLinearConfig(graph);
70
99
  for (const e of lin.errors) err(e);
@@ -75,6 +104,7 @@ export function validateGraph(graph) {
75
104
  for (const e of est.errors) err(e);
76
105
  for (const w of est.warnings) warn(w);
77
106
 
107
+ const requiredReceipts = (meta.discipline && Array.isArray(meta.discipline.required_receipts)) ? meta.discipline.required_receipts : [];
78
108
  const validStatus = new Set(Object.keys(STATUS));
79
109
  const seenPiIds = new Set();
80
110
  for (const pi of graph.pis || []) {
@@ -100,6 +130,12 @@ export function validateGraph(graph) {
100
130
  if (!validStatus.has(sp.status)) err(`${where}: status "${sp.status}" invalid`);
101
131
  if (!sp.invoke) err(`${where}: invoke key required`);
102
132
  if (sp.gated_on && sp.status !== "gated") warn(`${where}: gated_on set but status is "${sp.status}" (expected gated)`);
133
+ // Finishing discipline (opt-in): a done slice must carry every required receipt; a missing one
134
+ // means "done" was declared without the evidence. Off entirely when required_receipts is absent.
135
+ if (requiredReceipts.length && isDone(sp.status)) {
136
+ const missing = requiredReceipts.filter((k) => !(sp.receipts && sp.receipts[k]));
137
+ if (missing.length) warn(`receipt: ${where} is complete but missing required receipt(s): ${missing.join(", ")}`);
138
+ }
103
139
  if (!isDone(sp.status) && sp.est_sessions == null) warn(`${where}: no est_sessions (sessions-remaining rollup will undercount)`);
104
140
  // Optional execution-strategy hint. Absent → no-op (backward-compatible); present → enum/type/consistency checks.
105
141
  const exec = validateExecution(sp.execution, where);
@@ -108,6 +144,22 @@ export function validateGraph(graph) {
108
144
  // Optional priority block. Absent → no-op.
109
145
  for (const e of validatePriority(sp.priority, where).errors) err(e);
110
146
  }
147
+ // Active-state integrity: an "active" PI must hold REAL agent-runnable work, or the board lies
148
+ // about what's in flight and the concurrency planner over-recommends. Two failure modes:
149
+ // (a) every sprint is complete → a stale-active PI that should have rolled up to complete;
150
+ // (b) no sprint is advanceable → every open sprint is held (gated/blocked/paused) or parked
151
+ // (optionality), so it's a human-gated lane, not active development — a session dispatched
152
+ // here has nothing to run. This is the "gated decision counted as runnable work" smell.
153
+ if (pi.status === "active" && Array.isArray(pi.sprints) && pi.sprints.length) {
154
+ const open = pi.sprints.filter((sp) => !isDone(sp.status));
155
+ if (open.length === 0) {
156
+ warn(`active-integrity: PI ${pi.id} is active but every sprint is complete — mark it complete (a stale-active PI inflates the in-flight count and hides real closure)`);
157
+ } else if (open.every((sp) => HELD_STATUSES.includes(sp.status) || sp.status === "optionality")) {
158
+ const held = open.filter((sp) => HELD_STATUSES.includes(sp.status)).length;
159
+ const parked = open.length - held;
160
+ warn(`active-integrity: PI ${pi.id} is active but has no agent-runnable slice — ${held} held (gated/blocked/paused)${parked ? ` + ${parked} optionality` : ""}; this is a human-gated lane, not active development`);
161
+ }
162
+ }
111
163
  }
112
164
 
113
165
  // flatten resolves deps + unique invoke keys (throws on failure), then cycle-detect.
package/scripts/next.mjs CHANGED
@@ -13,7 +13,7 @@ import { tierBadge } from "./lib/priority.mjs";
13
13
 
14
14
  const graph = loadGraph("docs/roadmap/roadmap.yaml");
15
15
  const backlog = loadBacklog(process.cwd());
16
- const next = pickNext(graph, backlog);
16
+ const next = pickNext(graph, backlog, new Date().toISOString().slice(0, 10));
17
17
 
18
18
  if (!next) {
19
19
  console.log("Nothing ready: no runnable slices, no open backlog items. ('roadmap plan' shows what's held and why.)");
@@ -72,6 +72,7 @@ out.push(anchor
72
72
  out.push(`shipped (${digest.shipped.length}): ${digest.shipped.map((s) => `${s.invoke}${s.prs && s.prs.length ? ` (${s.prs.join(" ")})` : ""}`).join(", ") || "—"}`);
73
73
  out.push(`captured (${digest.netGrowth.added}): ${digest.captured.items.length} backlog item(s), ${digest.captured.sprints.length} sprint(s)`);
74
74
  out.push(`net growth: +${digest.netGrowth.added} / -${digest.shipped.length + digest.closedItems.length} (ratio ${digest.netGrowth.ratio})`);
75
+ out.push(`closure: ${digest.newPis.length} PI(s) born / ${digest.removedPis.length} died · capture-to-ship ratio ${digest.netGrowth.ratio} · ~${digest.estOpenSessions} open session(s) remaining`);
75
76
  if (digest.aging.length) out.push(`held since before last review: ${digest.aging.map((a) => `${a.invoke} (${a.status})`).join(", ")}`);
76
77
  if (digest.newPis.length) out.push(`new PIs: ${digest.newPis.map((p) => p.id).join(", ")}`);
77
78
  if (digest.pisInFlight > 1) out.push(`PIs in flight: ${digest.pisInFlight}`);
@@ -11,7 +11,7 @@
11
11
  // --use-free-ram size RAM off currently-free memory instead of 75% of total
12
12
  // --wave N when printing, mark the launch detail for wave N (default 1)
13
13
 
14
- import { loadGraph } from "./lib/graph.mjs";
14
+ import { loadGraph, commandLaneActive, commandLaneMembers, isDone } from "./lib/graph.mjs";
15
15
  import { buildPlan } from "./lib/plan.mjs";
16
16
  import { baseRefOf, remoteOf } from "./lib/brief.mjs";
17
17
  import { tierBadge } from "./lib/priority.mjs";
@@ -33,10 +33,11 @@ const hasCap = has("--cap");
33
33
  const capVal = hasCap ? Number(val("--cap", "")) : null;
34
34
 
35
35
  const graph = loadGraph(inPath);
36
+ const today = new Date().toISOString().slice(0, 10); // one clock read for the whole run (plan + banner)
36
37
 
37
38
  let plan;
38
39
  try {
39
- plan = buildPlan(graph, { cap: hasCap && Number.isFinite(capVal) ? capVal : undefined, useFree, reviewCeiling });
40
+ plan = buildPlan(graph, { cap: hasCap && Number.isFinite(capVal) ? capVal : undefined, useFree, reviewCeiling, today });
40
41
  } catch (e) {
41
42
  console.error(`✗ ${e.message}`);
42
43
  process.exit(1);
@@ -48,6 +49,19 @@ if (asJson) {
48
49
  }
49
50
 
50
51
  // ── human plan ─────────────────────────────────────────────────────────────
52
+ // Command-lane banner. Prints only while the lane is armed: past `until` or once every member ships,
53
+ // commandLaneActive goes false and the banner disappears. Reuses the single `today` computed above.
54
+ if (commandLaneActive(graph, today)) {
55
+ const lane = graph.meta.command_lane;
56
+ const members = commandLaneMembers(graph);
57
+ let laneOpen = 0;
58
+ for (const pi of graph.pis || []) for (const sp of pi.sprints || []) {
59
+ if (sp.invoke && members.has(sp.invoke) && !isDone(sp.status)) laneOpen++;
60
+ }
61
+ console.log(`COMMAND LANE: ${lane.objective} (until ${lane.until}) — ${laneOpen} lane slice(s) sort first`);
62
+ console.log("");
63
+ }
64
+
51
65
  const capNote = hasCap ? `(you set --cap ${plan.cap}; recommended ${plan.recommended})` : `(recommended)`;
52
66
  console.log(`Concurrency cap: ${plan.cap} ${capNote}`);
53
67
  console.log(` bound by: ${plan.binding.why}`);