@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 +1 -1
- package/schema/backlog.schema.json +13 -1
- package/schema/roadmap.schema.json +16 -2
- package/scripts/cli.mjs +12 -10
- package/scripts/doctor.mjs +87 -0
- package/scripts/fanout.mjs +36 -31
- package/scripts/lib/backlog-core.mjs +10 -5
- package/scripts/lib/cli-core.mjs +3 -3
- package/scripts/lib/doctor-core.mjs +63 -0
- package/scripts/lib/external-state.mjs +67 -0
- package/scripts/lib/graph.mjs +69 -1
- package/scripts/lib/linear-core.mjs +7 -1
- package/scripts/lib/mcp-core.mjs +3 -3
- package/scripts/lib/plan.mjs +9 -4
- package/scripts/lib/priority.mjs +8 -0
- package/scripts/lib/recommend.mjs +31 -3
- package/scripts/lib/render-core.mjs +15 -3
- package/scripts/lib/review-core.mjs +9 -1
- package/scripts/lib/validate-core.mjs +63 -11
- package/scripts/next.mjs +1 -1
- package/scripts/review.mjs +1 -0
- package/scripts/scheduler.mjs +16 -2
- package/scripts/test/run.mjs +412 -25
|
@@ -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
|
}
|
package/scripts/lib/mcp-core.mjs
CHANGED
|
@@ -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];
|
package/scripts/lib/plan.mjs
CHANGED
|
@@ -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
|
|
15
|
-
// disk: undefined -> probe the
|
|
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
|
-
|
|
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".
|
package/scripts/lib/priority.mjs
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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.)");
|
package/scripts/review.mjs
CHANGED
|
@@ -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}`);
|
package/scripts/scheduler.mjs
CHANGED
|
@@ -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}`);
|