@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
package/package.json
CHANGED
|
@@ -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);
|
package/scripts/fanout.mjs
CHANGED
|
@@ -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, {
|
|
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).
|
|
233
|
-
|
|
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);
|
package/scripts/lib/cli-core.mjs
CHANGED
|
@@ -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
|
+
}
|
package/scripts/lib/graph.mjs
CHANGED
|
@@ -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) {
|