@connorbritain/roadmap 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +495 -0
- package/docs/DEPLOYMENT.md +195 -0
- package/package.json +46 -0
- package/schema/backlog.schema.json +65 -0
- package/schema/roadmap.schema.json +273 -0
- package/scripts/assistant.mjs +30 -0
- package/scripts/backlog.mjs +81 -0
- package/scripts/cleanup.mjs +69 -0
- package/scripts/cli.mjs +119 -0
- package/scripts/cycle.mjs +89 -0
- package/scripts/dispatch.mjs +302 -0
- package/scripts/estimate.mjs +201 -0
- package/scripts/fanout.mjs +355 -0
- package/scripts/grab.mjs +119 -0
- package/scripts/init.mjs +60 -0
- package/scripts/lib/assistant-core.mjs +64 -0
- package/scripts/lib/backlog-core.mjs +318 -0
- package/scripts/lib/brief.mjs +123 -0
- package/scripts/lib/cli-core.mjs +97 -0
- package/scripts/lib/cycle-core.mjs +58 -0
- package/scripts/lib/estimate-core.mjs +204 -0
- package/scripts/lib/execution.mjs +186 -0
- package/scripts/lib/fanout-core.mjs +39 -0
- package/scripts/lib/graph.mjs +346 -0
- package/scripts/lib/journal-core.mjs +48 -0
- package/scripts/lib/linear-core.mjs +812 -0
- package/scripts/lib/mcp-core.mjs +313 -0
- package/scripts/lib/plan.mjs +68 -0
- package/scripts/lib/plate-core.mjs +53 -0
- package/scripts/lib/pr-watch-core.mjs +72 -0
- package/scripts/lib/priority.mjs +43 -0
- package/scripts/lib/recommend.mjs +178 -0
- package/scripts/lib/render-core.mjs +215 -0
- package/scripts/lib/review-core.mjs +104 -0
- package/scripts/lib/store.mjs +113 -0
- package/scripts/lib/sync-core.mjs +89 -0
- package/scripts/lib/validate-core.mjs +130 -0
- package/scripts/lib/wizard-core.mjs +50 -0
- package/scripts/linear.mjs +780 -0
- package/scripts/mcp.mjs +193 -0
- package/scripts/next.mjs +43 -0
- package/scripts/plate.mjs +77 -0
- package/scripts/promote.mjs +27 -0
- package/scripts/prompt.mjs +124 -0
- package/scripts/render.mjs +39 -0
- package/scripts/review.mjs +79 -0
- package/scripts/scheduler.mjs +78 -0
- package/scripts/set.mjs +31 -0
- package/scripts/show.mjs +53 -0
- package/scripts/test/run.mjs +3943 -0
- package/scripts/validate.mjs +28 -0
- package/scripts/watch-prs.mjs +72 -0
- package/scripts/wizard.mjs +97 -0
package/scripts/mcp.mjs
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// roadmap — MCP server (hand-rolled JSON-RPC 2.0 over stdio, newline-delimited).
|
|
3
|
+
// Exposes the roadmap as agent-callable tools: read (plan / ready_wave / show / validate) and
|
|
4
|
+
// mutate (add_pi / add_sprint / set_status / set_fields / prune). Mutations edit roadmap.yaml
|
|
5
|
+
// through the yaml Document API (comments preserved), validate the result before writing, and
|
|
6
|
+
// re-render SLICES.md. Zero new deps: just `yaml` + the repo's own libs.
|
|
7
|
+
//
|
|
8
|
+
// Bundled in the plugin via .mcp.json; also runnable as `roadmap mcp` for debugging.
|
|
9
|
+
|
|
10
|
+
import { createInterface } from "node:readline";
|
|
11
|
+
import { findRepoRoot, REL } from "./lib/cli-core.mjs";
|
|
12
|
+
import { loadGraph } from "./lib/graph.mjs";
|
|
13
|
+
import { mutateRoadmap, mutateBacklog, mutateBoth, loadBacklog, roadmapPaths, originBacklogIds } from "./lib/store.mjs";
|
|
14
|
+
import { TOOLS, READ_HANDLERS, MUTATION_HANDLERS } from "./lib/mcp-core.mjs";
|
|
15
|
+
import { BACKLOG_TOOLS, BACKLOG_READ_HANDLERS, BACKLOG_MUTATION_HANDLERS, performPromotion } from "./lib/backlog-core.mjs";
|
|
16
|
+
import { linearState, linearStatusLine, normalizeLinearConfig } from "./lib/linear-core.mjs";
|
|
17
|
+
import { platedKeys } from "./lib/plate-core.mjs";
|
|
18
|
+
import { runSync, runNote, runNotes, runProjectUpdate } from "./linear.mjs";
|
|
19
|
+
import { runDispatch, runFanCloud } from "./dispatch.mjs";
|
|
20
|
+
import { runEstimate, runTimeline, runLog } from "./estimate.mjs";
|
|
21
|
+
import { LOG_STATUSES } from "./lib/estimate-core.mjs";
|
|
22
|
+
|
|
23
|
+
// Always registered; politely erroring when unconfigured beats config-gated registration
|
|
24
|
+
// (tools/list would need IO). linear_sync reuses linear.mjs's runSync — one sync implementation.
|
|
25
|
+
const LINEAR_TOOLS = [
|
|
26
|
+
{ name: "linear_status", description: "Linear integration state for this roadmap (configured / authed / last sync). Zero network. Read-only.",
|
|
27
|
+
inputSchema: { type: "object", properties: {} } },
|
|
28
|
+
{ name: "linear_sync", description: "Run the Linear sync: push the roadmap/backlog projection, fetch the pull inbox. dry=true plans without writing. With meta.linear.pull=propose the inbox is returned as proposals for you to apply via backlog_add/set_status/backlog_set.",
|
|
29
|
+
inputSchema: { type: "object", properties: { dry: { type: "boolean" }, push: { type: "boolean" }, pull: { type: "boolean" } } } },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
// Cloud dispatch — lets the SESSION conduct a cloud fanout. Fires on the currently-authed
|
|
33
|
+
// claude.ai account's plan (no local worktree/disk). fan_cloud PREVIEWS by default; confirm=true
|
|
34
|
+
// actually fires (it spends plan usage + opens real PRs). Needs ~/.claude-routines.json configured.
|
|
35
|
+
const CLOUD_TOOLS = [
|
|
36
|
+
{ name: "dispatch", description: "Fire a Claude Code CLOUD session for ONE slice or backlog item via the Routines API — runs on the currently-authed claude.ai account's plan, no local worktree or disk. Returns the session URL (and comments it on the Linear issue when the node is mapped). With meta.linear.cycles on, an out-of-cycle slice refuses (elect it via 'roadmap cycle', or pass force=true — the logged escape hatch, surfaces as scope change). Requires ~/.claude-routines.json (docs/DEPLOYMENT.md § Cloud dispatch).",
|
|
37
|
+
inputSchema: { type: "object", required: ["key"], properties: { key: { type: "string", description: "slice invoke key or backlog id" }, force: { type: "boolean", description: "override the cycle lock for this one dispatch (out-of-cycle work; surfaces as scope change)" } } } },
|
|
38
|
+
{ name: "fan_cloud", description: "Conduct a cloud FANOUT of a ready wave — the worktree-free, disk-free fanout. Each slice fires a Claude Code cloud session on the authed account's plan and opens a PR. DEFAULT is a dry preview (lists what would fire, spawns nothing); pass confirm=true to actually fire. With meta.linear.cycles on, out-of-cycle slices are excluded from the wave (reported in excludedOutOfCycle; pass all=true to include). Returns session URLs when confirmed. The conducting session reconciles the resulting PRs via the roadmap marker (/sync).",
|
|
39
|
+
inputSchema: { type: "object", properties: {
|
|
40
|
+
wave: { type: "integer", minimum: 1, description: "which ready wave (default 1)" },
|
|
41
|
+
cap: { type: "integer", minimum: 1, description: "max slices in the wave (default the review ceiling, 5 — machine limits don't apply to cloud)" },
|
|
42
|
+
confirm: { type: "boolean", description: "false/absent = preview only; true = actually fire the cloud sessions" },
|
|
43
|
+
all: { type: "boolean", description: "include out-of-cycle slices in the wave (explicit override of the cycle lock)" } } } },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
// plate_list is a read that needs the backlog too (in_progress items), so it's handled inline here
|
|
47
|
+
// rather than in mcp-core's graph-only READ_HANDLERS. The plate_set/add/remove mutations live in TOOLS.
|
|
48
|
+
const PLATE_TOOLS = [
|
|
49
|
+
{ name: "plate_list", description: "The current plate — the curated batch projected to Linear's My Issues (assignee=you): explicit meta.plate entries plus auto-included active/in_progress work. Returns { enabled, explicit, plate, plate_max }. Read-only.",
|
|
50
|
+
inputSchema: { type: "object", properties: {} } },
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
// The journal — progress notes on the mapped issue, so in-flight work survives a dead session.
|
|
54
|
+
const JOURNAL_TOOLS = [
|
|
55
|
+
{ name: "issue_note", description: "Post a progress note to a slice/backlog item's mapped Linear issue — the resumability trail. Use at checkpoints (a gate cleared, a blocker hit, a logical unit done) so a session that dies mid-flight can be picked up from where it left off. kind: progress|blocker|done.",
|
|
56
|
+
inputSchema: { type: "object", required: ["key", "text"], properties: { key: { type: "string", description: "slice invoke key or backlog id" }, text: { type: "string" }, kind: { enum: ["progress", "blocker", "done"] } } } },
|
|
57
|
+
{ name: "issue_notes", description: "Read a slice/backlog item's Linear issue comment stream (chronological). Call this FIRST when picking up in-flight work — it's where the last session left off. Read-only.",
|
|
58
|
+
inputSchema: { type: "object", required: ["key"], properties: { key: { type: "string", description: "slice invoke key or backlog id" } } } },
|
|
59
|
+
{ name: "project_update", description: "Post a PI-level digest to its Linear project update (the 'where this bet stands' rollup) — for milestones, not per-checkpoint. Degradation-guarded: returns { posted:false } if Linear rejects it.",
|
|
60
|
+
inputSchema: { type: "object", required: ["pi", "body"], properties: { pi: { type: "string" }, body: { type: "string" } } } },
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
// agent-time bridge — a slice's duration estimate, cached on the slice for the timeline rollup.
|
|
64
|
+
const ESTIMATE_TOOLS = [
|
|
65
|
+
{ name: "estimate", description: "Estimate a slice's duration via agent-time (calibrated agent-rounds → wall-clock minutes) and cache it on the slice. Set the slice's shape (+ optional risks) first — an unclassified slice is skipped. Skips an already-estimated slice unless force=true; all=true estimates every classified slice. Needs the agent-time-estimator skill installed (or meta.estimation.engine).",
|
|
66
|
+
inputSchema: { type: "object", properties: { invoke: { type: "string", description: "slice invoke key" }, all: { type: "boolean" }, force: { type: "boolean" } } } },
|
|
67
|
+
{ name: "timeline", description: "Roll the cached per-slice estimates up into a projected target date per PI (using the same wave/dependency/concurrency schedule the fanout runs) and write pi.projected_target_date back — the estimate-driven Linear timeline. Never overwrites an explicit pi.target_date. Returns the per-PI dates plus any unpriced/held slices excluded from the projection.",
|
|
68
|
+
inputSchema: { type: "object", properties: {} } },
|
|
69
|
+
{ name: "estimate_log", description: "Log a completed slice's outcome to agent-time's calibration history (status pass|fail|partial|abandoned) so future estimates self-correct — the calibration loop. Requires the slice to have been estimated (carries estimate.task_id). Pass actual_rounds (and optionally actual_minutes) unless agent-time's round-counter hook auto-filled them; without either, agent-time rejects the log. Idempotent per task_id.",
|
|
70
|
+
inputSchema: { type: "object", required: ["invoke"], properties: { invoke: { type: "string", description: "slice invoke key" }, status: { enum: LOG_STATUSES }, actual_rounds: { type: "integer", minimum: 0 }, actual_minutes: { type: "number", minimum: 0 }, force: { type: "boolean" } } } },
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
const PROTOCOL_VERSION = "2024-11-05";
|
|
74
|
+
const SERVER_INFO = { name: "graph", version: "0.2.0" };
|
|
75
|
+
|
|
76
|
+
function repoRoot() {
|
|
77
|
+
const root = findRepoRoot(process.env.CODEX_PROJECT_DIR || process.env.CLAUDE_PROJECT_DIR || process.cwd());
|
|
78
|
+
if (!root) throw new Error(`no ${REL.join("/")} found at or above the project directory`);
|
|
79
|
+
return root;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function callTool(name, args) {
|
|
83
|
+
if (READ_HANDLERS[name]) {
|
|
84
|
+
const graph = loadGraph(roadmapPaths(repoRoot()).yaml);
|
|
85
|
+
return READ_HANDLERS[name](graph, args || {});
|
|
86
|
+
}
|
|
87
|
+
if (MUTATION_HANDLERS[name]) {
|
|
88
|
+
// mutateRoadmap = read → mutate → validate → write → re-render; a throw leaves files untouched.
|
|
89
|
+
return mutateRoadmap(repoRoot(), (doc) => MUTATION_HANDLERS[name](doc, args || {}));
|
|
90
|
+
}
|
|
91
|
+
if (BACKLOG_READ_HANDLERS[name]) {
|
|
92
|
+
return BACKLOG_READ_HANDLERS[name](loadBacklog(repoRoot()), args || {});
|
|
93
|
+
}
|
|
94
|
+
if (BACKLOG_MUTATION_HANDLERS[name]) {
|
|
95
|
+
// backlog_add gets origin/main ids injected so concurrent sessions cannot mint the same bNN.
|
|
96
|
+
const margs = name === "backlog_add" ? { ...(args || {}), origin_ids: (args && args.origin_ids) || originBacklogIds(repoRoot()) } : (args || {});
|
|
97
|
+
return mutateBacklog(repoRoot(), (doc) => BACKLOG_MUTATION_HANDLERS[name](doc, margs),
|
|
98
|
+
{ createIfMissing: name === "backlog_add" });
|
|
99
|
+
}
|
|
100
|
+
if (name === "backlog_promote") {
|
|
101
|
+
// Spans both YAMLs: both validated before either is written.
|
|
102
|
+
return mutateBoth(repoRoot(), (rDoc, bDoc) => performPromotion(rDoc, bDoc, args || {}));
|
|
103
|
+
}
|
|
104
|
+
if (name === "linear_status") {
|
|
105
|
+
const root = repoRoot();
|
|
106
|
+
const graph = loadGraph(roadmapPaths(root).yaml);
|
|
107
|
+
const st = linearState({ meta: graph.meta, env: process.env });
|
|
108
|
+
return { configured: st.configured, authed: st.authed,
|
|
109
|
+
...(st.cfg ? { team: st.cfg.team, granularity: st.cfg.granularity, pull: st.cfg.pull } : {}),
|
|
110
|
+
status: linearStatusLine(st) };
|
|
111
|
+
}
|
|
112
|
+
if (name === "linear_sync") {
|
|
113
|
+
// async; the tools/call path awaits. runSync itself throws the setup-guidance errors.
|
|
114
|
+
return runSync(repoRoot(), { dry: !!args.dry, pushOnly: args.pull === false, pullOnly: args.push === false });
|
|
115
|
+
}
|
|
116
|
+
if (name === "dispatch") {
|
|
117
|
+
// async; runDispatch fires the routine (or the Linear @-mention) and returns the session/comment.
|
|
118
|
+
return runDispatch(repoRoot(), args.key, { force: !!args.force });
|
|
119
|
+
}
|
|
120
|
+
if (name === "fan_cloud") {
|
|
121
|
+
// preview unless confirm=true; runFanCloud loops runDispatch over the ready wave.
|
|
122
|
+
return runFanCloud(repoRoot(), args || {});
|
|
123
|
+
}
|
|
124
|
+
if (name === "plate_list") {
|
|
125
|
+
const root = repoRoot();
|
|
126
|
+
const graph = loadGraph(roadmapPaths(root).yaml);
|
|
127
|
+
const set = platedKeys(graph, loadBacklog(root));
|
|
128
|
+
const explicit = Array.isArray(graph.meta && graph.meta.plate) ? graph.meta.plate : [];
|
|
129
|
+
const cfg = normalizeLinearConfig(graph.meta || {});
|
|
130
|
+
return { enabled: set != null, explicit, plate: set ? [...set] : [], plate_max: cfg ? cfg.plate_max : 7 };
|
|
131
|
+
}
|
|
132
|
+
if (name === "estimate") {
|
|
133
|
+
// sync (spawnSync); the tools/call path Promise-wraps it. Writes est_minutes back to the YAML.
|
|
134
|
+
return runEstimate(repoRoot(), { invoke: args.invoke, all: !!args.all, force: !!args.force });
|
|
135
|
+
}
|
|
136
|
+
if (name === "timeline") {
|
|
137
|
+
// pure rollup over cached estimates + write-back; no network.
|
|
138
|
+
return runTimeline(repoRoot(), {});
|
|
139
|
+
}
|
|
140
|
+
if (name === "estimate_log") {
|
|
141
|
+
// sync (spawnSync estimator log); idempotent per task_id. No YAML write.
|
|
142
|
+
return runLog(repoRoot(), { invoke: args.invoke, status: args.status, force: !!args.force,
|
|
143
|
+
actualRounds: args.actual_rounds, actualMinutes: args.actual_minutes });
|
|
144
|
+
}
|
|
145
|
+
if (name === "issue_note") return runNote(repoRoot(), args.key, { kind: args.kind, text: args.text }, {});
|
|
146
|
+
if (name === "issue_notes") return runNotes(repoRoot(), args.key, {});
|
|
147
|
+
if (name === "project_update") return runProjectUpdate(repoRoot(), args.pi, args.body, {});
|
|
148
|
+
throw new Error(`unknown tool "${name}"`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const out = (obj) => process.stdout.write(JSON.stringify(obj) + "\n");
|
|
152
|
+
|
|
153
|
+
function handle(msg) {
|
|
154
|
+
const { id, method, params } = msg;
|
|
155
|
+
if (method === "initialize") {
|
|
156
|
+
return out({ jsonrpc: "2.0", id, result: { protocolVersion: PROTOCOL_VERSION, capabilities: { tools: {} }, serverInfo: SERVER_INFO } });
|
|
157
|
+
}
|
|
158
|
+
if (method === "notifications/initialized" || method === "initialized") return; // notification: no reply
|
|
159
|
+
if (method === "ping") return out({ jsonrpc: "2.0", id, result: {} });
|
|
160
|
+
if (method === "tools/list") {
|
|
161
|
+
return out({ jsonrpc: "2.0", id, result: { tools: [...TOOLS, ...BACKLOG_TOOLS, ...LINEAR_TOOLS, ...CLOUD_TOOLS, ...PLATE_TOOLS, ...JOURNAL_TOOLS, ...ESTIMATE_TOOLS] } });
|
|
162
|
+
}
|
|
163
|
+
if (method === "tools/call") {
|
|
164
|
+
const name = params && params.name;
|
|
165
|
+
const args = (params && params.arguments) || {};
|
|
166
|
+
// Promise-wrapped so async tools (linear_sync) work; sync tools resolve immediately.
|
|
167
|
+
return Promise.resolve().then(() => callTool(name, args)).then(
|
|
168
|
+
(result) => out({ jsonrpc: "2.0", id, result: { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] } }),
|
|
169
|
+
// MCP convention: tool failures come back as a result with isError, so the model sees why.
|
|
170
|
+
(e) => out({ jsonrpc: "2.0", id, result: { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true } }),
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
if (id !== undefined && id !== null) {
|
|
174
|
+
return out({ jsonrpc: "2.0", id, error: { code: -32601, message: `method not found: ${method}` } });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const rl = createInterface({ input: process.stdin });
|
|
179
|
+
rl.on("line", (line) => {
|
|
180
|
+
const text = line.trim();
|
|
181
|
+
if (!text) return;
|
|
182
|
+
let msg;
|
|
183
|
+
try {
|
|
184
|
+
msg = JSON.parse(text);
|
|
185
|
+
} catch {
|
|
186
|
+
return out({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } });
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
handle(msg);
|
|
190
|
+
} catch (e) {
|
|
191
|
+
if (msg && msg.id !== undefined) out({ jsonrpc: "2.0", id: msg.id, error: { code: -32603, message: e.message } });
|
|
192
|
+
}
|
|
193
|
+
});
|
package/scripts/next.mjs
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// roadmap next — print the single highest-priority ready thing across the roadmap AND the
|
|
3
|
+
// backlog, with its pickup brief. Roadmap wins ties (planned value work outranks erratic
|
|
4
|
+
// work at equal priority). Read-only.
|
|
5
|
+
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { loadGraph } from "./lib/graph.mjs";
|
|
10
|
+
import { loadBacklog } from "./lib/store.mjs";
|
|
11
|
+
import { pickNext } from "./lib/backlog-core.mjs";
|
|
12
|
+
import { tierBadge } from "./lib/priority.mjs";
|
|
13
|
+
|
|
14
|
+
const graph = loadGraph("docs/roadmap/roadmap.yaml");
|
|
15
|
+
const backlog = loadBacklog(process.cwd());
|
|
16
|
+
const next = pickNext(graph, backlog);
|
|
17
|
+
|
|
18
|
+
if (!next) {
|
|
19
|
+
console.log("Nothing ready: no runnable slices, no open backlog items. ('roadmap plan' shows what's held and why.)");
|
|
20
|
+
process.exit(0);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (next.type === "slice") {
|
|
24
|
+
console.log(`Next up (roadmap): ${next.node.invoke}\n`);
|
|
25
|
+
const r = spawnSync("node", [join(dirname(fileURLToPath(import.meta.url)), "show.mjs"), next.node.invoke], { stdio: "inherit" });
|
|
26
|
+
console.log(`\nPick it up: /slice ${next.node.invoke} · or fan it out: roadmap fan`);
|
|
27
|
+
process.exit(r.status ?? 0);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const it = next.item;
|
|
31
|
+
const badge = tierBadge(it.priority);
|
|
32
|
+
console.log(`Next up (backlog): ${it.id} [${it.kind}${badge ? ` · ${badge}${it.priority.weight != null ? ` ${it.priority.weight}` : ""}` : ""}]`);
|
|
33
|
+
console.log(`What: ${it.title}`);
|
|
34
|
+
if (it.priority && it.priority.reason) console.log(`Why: ${it.priority.reason}`);
|
|
35
|
+
if (it.source && (it.source.slice || it.source.note)) {
|
|
36
|
+
console.log(`From: ${[it.source.slice, it.source.note].filter(Boolean).join(" — ")}`);
|
|
37
|
+
}
|
|
38
|
+
if (it.est_sessions != null) console.log(`Est: ~${it.est_sessions} session(s)`);
|
|
39
|
+
if (it.prompt) {
|
|
40
|
+
console.log(`\nPrompt (author instructions, verbatim):`);
|
|
41
|
+
String(it.prompt).trimEnd().split("\n").forEach((l) => console.log(` ${l}`));
|
|
42
|
+
}
|
|
43
|
+
console.log(`\nPick it up: roadmap grab ${it.id} · or promote it: roadmap promote ${it.id} --pi <pi>`);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// roadmap plate [list|add|rm|set|clear] — the curated batch that projects to Linear's My Issues (assignee=you).
|
|
3
|
+
// roadmap plate show the current plate (explicit entries + auto-included active work)
|
|
4
|
+
// roadmap plate add <key>... add slice invoke keys / backlog ids (creates meta.plate if absent → feature on)
|
|
5
|
+
// roadmap plate rm <key>... remove explicit entries
|
|
6
|
+
// roadmap plate set <key>... replace the explicit list
|
|
7
|
+
// roadmap plate clear empty the explicit list (feature stays on; active work still auto-shows)
|
|
8
|
+
// Assignment lands on the next 'roadmap linear sync' (or /sync). Completed slices auto-drain there.
|
|
9
|
+
|
|
10
|
+
import { loadGraph } from "./lib/graph.mjs";
|
|
11
|
+
import { loadBacklog, mutateRoadmap, roadmapPaths } from "./lib/store.mjs";
|
|
12
|
+
import { setPlateDoc, platedKeys } from "./lib/plate-core.mjs";
|
|
13
|
+
import { normalizeLinearConfig } from "./lib/linear-core.mjs";
|
|
14
|
+
|
|
15
|
+
const args = process.argv.slice(2);
|
|
16
|
+
const sub = args[0] && !args[0].startsWith("-") ? args[0] : "list";
|
|
17
|
+
const keys = (sub === args[0] ? args.slice(1) : args).filter((a) => !a.startsWith("-"));
|
|
18
|
+
const root = process.cwd();
|
|
19
|
+
|
|
20
|
+
// key → { title, status, mapped } across slices + backlog items (for the list view)
|
|
21
|
+
function indexOf(graph, backlog) {
|
|
22
|
+
const idx = new Map();
|
|
23
|
+
for (const pi of graph.pis || []) for (const sp of pi.sprints || []) idx.set(sp.invoke, { title: sp.title, status: sp.status, mapped: !!sp.linear });
|
|
24
|
+
for (const it of (backlog && backlog.items) || []) idx.set(it.id, { title: it.title, status: it.status, mapped: !!it.linear });
|
|
25
|
+
return idx;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const graph = loadGraph(roadmapPaths(root).yaml);
|
|
30
|
+
const backlog = loadBacklog(root);
|
|
31
|
+
const explicit = Array.isArray(graph.meta && graph.meta.plate) ? graph.meta.plate.slice() : null;
|
|
32
|
+
|
|
33
|
+
if (sub === "list") {
|
|
34
|
+
const set = platedKeys(graph, backlog);
|
|
35
|
+
if (set == null) { console.log("The plate is OFF (no meta.plate). Turn it on: roadmap plate add <slice-or-id>"); process.exit(0); }
|
|
36
|
+
const idx = indexOf(graph, backlog);
|
|
37
|
+
const cfg = normalizeLinearConfig(graph.meta || {});
|
|
38
|
+
const cap = cfg ? cfg.plate_max : 7;
|
|
39
|
+
const exp = Array.isArray(explicit) ? explicit : []; // malformed meta.plate (validate errors on it) shouldn't raw-crash the list
|
|
40
|
+
console.log(`Plate — ${set.size} on My Issues${exp.length > cap ? ` (⚠ ${exp.length} explicit > plate_max ${cap})` : ""}`);
|
|
41
|
+
if (!set.size) console.log(" (empty)");
|
|
42
|
+
for (const key of set) {
|
|
43
|
+
const info = idx.get(key);
|
|
44
|
+
const auto = !exp.includes(key) ? " (auto: active)" : "";
|
|
45
|
+
if (!info) console.log(` • ${key} — ⚠ matches no slice/backlog item (typo?)`);
|
|
46
|
+
else console.log(` • ${key} [${info.status}] ${info.title}${info.mapped ? "" : " — not in Linear yet (pushes on sync)"}${auto}`);
|
|
47
|
+
}
|
|
48
|
+
console.log("\nAssignment lands on the next 'roadmap linear sync'. Completed slices auto-drain.");
|
|
49
|
+
process.exit(0);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (sub === "clear") {
|
|
53
|
+
const r = mutateRoadmap(root, (doc) => { setPlateDoc(doc, []); return { plate: "cleared" }; });
|
|
54
|
+
console.log(`✓ plate cleared (feature stays on; active work still auto-shows). (re-rendered ${r.rerendered})`);
|
|
55
|
+
process.exit(0);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (["add", "rm", "set"].includes(sub)) {
|
|
59
|
+
if (!keys.length) { console.error(`usage: roadmap plate ${sub} <slice-invoke-or-backlog-id> ...`); process.exit(2); }
|
|
60
|
+
let next;
|
|
61
|
+
if (sub === "set") next = [...new Set(keys)];
|
|
62
|
+
else if (sub === "add") next = [...new Set([...(explicit || []), ...keys])];
|
|
63
|
+
else { // rm
|
|
64
|
+
if (explicit == null) { console.log("The plate is off (no meta.plate) — nothing to remove."); process.exit(0); }
|
|
65
|
+
next = explicit.filter((k) => !keys.includes(k));
|
|
66
|
+
}
|
|
67
|
+
const r = mutateRoadmap(root, (doc) => { setPlateDoc(doc, next); return { plate: next.length }; });
|
|
68
|
+
console.log(`✓ plate ${sub}: ${next.length} explicit entr${next.length === 1 ? "y" : "ies"}${next.length ? ` [${next.join(", ")}]` : ""} (sync to apply · re-rendered ${r.rerendered})`);
|
|
69
|
+
process.exit(0);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
console.error(`roadmap plate: unknown subcommand "${sub}" (list | add | rm | set | clear)`);
|
|
73
|
+
process.exit(2);
|
|
74
|
+
} catch (e) {
|
|
75
|
+
console.error(`✗ ${e.message}`);
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// roadmap promote <backlog-id> --pi <pi> [--id sN] — convert a backlog item into a roadmap
|
|
3
|
+
// sprint. The item's id becomes the sprint's invoke key; title/est/gate/touches/prompt/priority
|
|
4
|
+
// carry over; the item is marked promoted with a promoted_to back-link. Both YAMLs are
|
|
5
|
+
// validated before either is written; both generated views re-render.
|
|
6
|
+
|
|
7
|
+
import { mutateBoth } from "./lib/store.mjs";
|
|
8
|
+
import { performPromotion } from "./lib/backlog-core.mjs";
|
|
9
|
+
|
|
10
|
+
const args = process.argv.slice(2);
|
|
11
|
+
const val = (n) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : undefined; };
|
|
12
|
+
const id = args.find((a) => !a.startsWith("-") && a !== val("--pi") && a !== val("--id"));
|
|
13
|
+
const pi = val("--pi");
|
|
14
|
+
|
|
15
|
+
if (!id || !pi) {
|
|
16
|
+
console.error("usage: roadmap promote <backlog-id> --pi <pi> [--id sN]");
|
|
17
|
+
process.exit(2);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const r = mutateBoth(process.cwd(), (rDoc, bDoc) => performPromotion(rDoc, bDoc, { id, pi, sprint_id: val("--id") }));
|
|
22
|
+
console.log(`✓ promoted ${r.promoted} → ${r.to} (invoke stays '${r.promoted}'; re-rendered ${r.rerendered})`);
|
|
23
|
+
console.log(` Next: roadmap show ${r.promoted} · scope it (deps/touches/read_order) via roadmap set or the slice-scoper agent.`);
|
|
24
|
+
} catch (e) {
|
|
25
|
+
console.error(`✗ ${e.message}`);
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// roadmap — zero-dependency terminal prompts for the bare-`roadmap` wizard.
|
|
2
|
+
// IO file (raw-mode stdin, stdout, process.exit on cancel) — lives at scripts/ (not lib/), like the
|
|
3
|
+
// other side-effecting entrypoints. select() uses raw-mode arrow-key navigation when the TTY
|
|
4
|
+
// supports it, and DEGRADES to a numbered text prompt otherwise (so it never hangs on a terminal
|
|
5
|
+
// without raw mode). number() and confirm() are line-based. Ctrl-C / ESC cancel cleanly (restore
|
|
6
|
+
// the TTY, exit 0). The pure decision logic (movement, cap parsing) lives in lib/wizard-core.mjs.
|
|
7
|
+
|
|
8
|
+
import readline from "node:readline";
|
|
9
|
+
import { moveSelection, parseCap } from "./lib/wizard-core.mjs";
|
|
10
|
+
|
|
11
|
+
const S = {
|
|
12
|
+
reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m",
|
|
13
|
+
cyan: "\x1b[36m", green: "\x1b[32m", red: "\x1b[31m",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const question = (rl, q) => new Promise((res) => rl.question(q, res));
|
|
17
|
+
const clamp = (i, len) => Math.min(Math.max(0, i | 0), Math.max(0, len - 1));
|
|
18
|
+
|
|
19
|
+
// Normalize a choice to { label, value, hint }. A bare string is both label and value.
|
|
20
|
+
function norm(choice) {
|
|
21
|
+
if (typeof choice === "string") return { label: choice, value: choice, hint: "" };
|
|
22
|
+
return { label: choice.label, value: "value" in choice ? choice.value : choice.label, hint: choice.hint || "" };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function cancel() {
|
|
26
|
+
process.stdout.write(`\n${S.dim}Cancelled.${S.reset}\n`);
|
|
27
|
+
process.exit(0);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function renderList(items, idx) {
|
|
31
|
+
return items.map((it, i) => {
|
|
32
|
+
const sel = i === idx;
|
|
33
|
+
const cursor = sel ? `${S.cyan}❯${S.reset} ` : " ";
|
|
34
|
+
const label = sel ? `${S.bold}${S.cyan}${it.label}${S.reset}` : it.label;
|
|
35
|
+
const hint = it.hint ? ` ${S.dim}${it.hint}${S.reset}` : "";
|
|
36
|
+
return `${cursor}${label}${hint}`;
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Single-select. Returns the chosen choice's value. Raw-mode arrows when available; else numbered.
|
|
41
|
+
export function select(title, choices, opts = {}) {
|
|
42
|
+
const items = choices.map(norm);
|
|
43
|
+
const start = clamp(opts.defaultIdx ?? 0, items.length);
|
|
44
|
+
const canRaw = process.stdin.isTTY && typeof process.stdin.setRawMode === "function";
|
|
45
|
+
if (!canRaw) return numberedSelect(title, items, start);
|
|
46
|
+
|
|
47
|
+
return new Promise((resolve) => {
|
|
48
|
+
let idx = start;
|
|
49
|
+
process.stdout.write(`${S.bold}${title}${S.reset} ${S.dim}(↑/↓, Enter)${S.reset}\n`);
|
|
50
|
+
process.stdout.write(renderList(items, idx).join("\n") + "\n");
|
|
51
|
+
|
|
52
|
+
readline.emitKeypressEvents(process.stdin);
|
|
53
|
+
process.stdin.setRawMode(true);
|
|
54
|
+
process.stdin.resume();
|
|
55
|
+
|
|
56
|
+
const redraw = () => {
|
|
57
|
+
process.stdout.write(`\x1b[${items.length}A`); // up to the first item line
|
|
58
|
+
for (const line of renderList(items, idx)) process.stdout.write(`\x1b[2K${line}\n`);
|
|
59
|
+
};
|
|
60
|
+
const teardown = () => {
|
|
61
|
+
process.stdin.setRawMode(false);
|
|
62
|
+
process.stdin.removeListener("keypress", onKey);
|
|
63
|
+
process.stdin.pause();
|
|
64
|
+
};
|
|
65
|
+
const onKey = (_str, key) => {
|
|
66
|
+
if (!key) return;
|
|
67
|
+
if (key.ctrl && key.name === "c") { teardown(); cancel(); return; }
|
|
68
|
+
if (key.name === "escape") { teardown(); cancel(); return; }
|
|
69
|
+
if (key.name === "return" || key.name === "enter") {
|
|
70
|
+
teardown();
|
|
71
|
+
process.stdout.write(`${S.green}✓${S.reset} ${S.dim}${title}:${S.reset} ${items[idx].label}\n`);
|
|
72
|
+
resolve(items[idx].value);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const next = moveSelection(idx, key.name, items.length);
|
|
76
|
+
if (next !== idx) { idx = next; redraw(); }
|
|
77
|
+
};
|
|
78
|
+
process.stdin.on("keypress", onKey);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function numberedSelect(title, items, startIdx) {
|
|
83
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
84
|
+
rl.on("SIGINT", () => { rl.close(); cancel(); });
|
|
85
|
+
try {
|
|
86
|
+
process.stdout.write(`${S.bold}${title}${S.reset}\n`);
|
|
87
|
+
items.forEach((it, i) =>
|
|
88
|
+
process.stdout.write(` ${i + 1}) ${it.label}${it.hint ? ` ${S.dim}${it.hint}${S.reset}` : ""}\n`));
|
|
89
|
+
const def = startIdx + 1;
|
|
90
|
+
for (;;) {
|
|
91
|
+
const ans = (await question(rl, `Choose [1-${items.length}] (${def}): `)).trim();
|
|
92
|
+
if (ans === "") return items[startIdx].value;
|
|
93
|
+
const n = Number(ans);
|
|
94
|
+
if (Number.isInteger(n) && n >= 1 && n <= items.length) return items[n - 1].value;
|
|
95
|
+
process.stdout.write(`${S.red}Enter a number between 1 and ${items.length}.${S.reset}\n`);
|
|
96
|
+
}
|
|
97
|
+
} finally { rl.close(); }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Whole-number prompt with a default and a [min,max] range. Re-asks on bad input.
|
|
101
|
+
export async function number(title, { def, min = 1, max = Infinity } = {}) {
|
|
102
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
103
|
+
rl.on("SIGINT", () => { rl.close(); cancel(); });
|
|
104
|
+
try {
|
|
105
|
+
for (;;) {
|
|
106
|
+
const ans = await question(rl, `${S.bold}${title}${S.reset} ${S.dim}[${def}]${S.reset} `);
|
|
107
|
+
const res = parseCap(ans, { min, max, def });
|
|
108
|
+
if (res.error) { process.stdout.write(`${S.red}${res.error}${S.reset}\n`); continue; }
|
|
109
|
+
return res.value;
|
|
110
|
+
}
|
|
111
|
+
} finally { rl.close(); }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Yes/No with a default. Blank → default.
|
|
115
|
+
export async function confirm(title, def = false) {
|
|
116
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
117
|
+
rl.on("SIGINT", () => { rl.close(); cancel(); });
|
|
118
|
+
try {
|
|
119
|
+
const hint = def ? "Y/n" : "y/N";
|
|
120
|
+
const ans = (await question(rl, `${S.bold}${title}${S.reset} ${S.dim}[${hint}]${S.reset} `)).trim().toLowerCase();
|
|
121
|
+
if (ans === "") return def;
|
|
122
|
+
return ans === "y" || ans === "yes";
|
|
123
|
+
} finally { rl.close(); }
|
|
124
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// roadmap — render roadmap.yaml → SLICES.md (+ backlog.yaml → BACKLOG.md when present).
|
|
3
|
+
// Thin file-writer around lib/render-core.mjs / lib/backlog-core.mjs (the pure renderers).
|
|
4
|
+
// Usage:
|
|
5
|
+
// node render.mjs [--in docs/roadmap/roadmap.yaml] [--out docs/SLICES.md] [--cap N] [--stdout]
|
|
6
|
+
// Default: in=docs/roadmap/roadmap.yaml, out=docs/SLICES.md. --stdout prints SLICES only.
|
|
7
|
+
|
|
8
|
+
import { writeFileSync } from "node:fs";
|
|
9
|
+
import { loadGraph } from "./lib/graph.mjs";
|
|
10
|
+
import { renderMarkdown } from "./lib/render-core.mjs";
|
|
11
|
+
import { loadBacklog, slicesRenderOpts, backlogPaths } from "./lib/store.mjs";
|
|
12
|
+
import { renderBacklogMarkdown } from "./lib/backlog-core.mjs";
|
|
13
|
+
|
|
14
|
+
const args = process.argv.slice(2);
|
|
15
|
+
function flag(name, def) {
|
|
16
|
+
const i = args.indexOf(name);
|
|
17
|
+
return i >= 0 && args[i + 1] && !args[i + 1].startsWith("--") ? args[i + 1] : def;
|
|
18
|
+
}
|
|
19
|
+
const inPath = flag("--in", "docs/roadmap/roadmap.yaml");
|
|
20
|
+
const outPath = flag("--out", "docs/SLICES.md");
|
|
21
|
+
const toStdout = args.includes("--stdout");
|
|
22
|
+
const cap = args.includes("--cap") ? Number(flag("--cap")) : undefined;
|
|
23
|
+
|
|
24
|
+
const graph = loadGraph(inPath);
|
|
25
|
+
const backlog = loadBacklog(process.cwd());
|
|
26
|
+
const out = renderMarkdown(graph, { cap, ...slicesRenderOpts(process.cwd(), backlog) });
|
|
27
|
+
|
|
28
|
+
if (toStdout) {
|
|
29
|
+
process.stdout.write(out);
|
|
30
|
+
} else {
|
|
31
|
+
writeFileSync(outPath, out, "utf8");
|
|
32
|
+
const sprintCount = (graph.pis || []).reduce((a, p) => a + ((p.sprints || []).length), 0);
|
|
33
|
+
console.error(`✓ rendered ${outPath} (${(graph.pis || []).length} PIs, ${sprintCount} sprints)`);
|
|
34
|
+
if (backlog) {
|
|
35
|
+
const mdPath = backlogPaths(process.cwd()).md;
|
|
36
|
+
writeFileSync(mdPath, renderBacklogMarkdown(backlog), "utf8");
|
|
37
|
+
console.error(`✓ rendered docs/BACKLOG.md (${(backlog.items || []).length} items)`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// roadmap review [--since <rev|date>] [--json] — the date-anchored review digest: what
|
|
3
|
+
// shipped vs what grew since the last human review. The anchor chain: meta.last_review.commit
|
|
4
|
+
// → --since (a YYYY-MM-DD resolves to the last commit before that date; anything else is a
|
|
5
|
+
// git rev) → the last commit before a default 14-day window. Old YAML snapshots come from
|
|
6
|
+
// `git show <commit>:docs/roadmap/roadmap.yaml` (forward slashes — git pathspecs, not OS paths).
|
|
7
|
+
// Read-only; /debrief and /retro consume --json.
|
|
8
|
+
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
import YAML from "yaml";
|
|
11
|
+
import { loadGraph } from "./lib/graph.mjs";
|
|
12
|
+
import { loadBacklog } from "./lib/store.mjs";
|
|
13
|
+
import { graphDiff, backlogDiff, reviewDigest } from "./lib/review-core.mjs";
|
|
14
|
+
|
|
15
|
+
const args = process.argv.slice(2);
|
|
16
|
+
const val = (n) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : undefined; };
|
|
17
|
+
const git = (...a) => spawnSync("git", a, { encoding: "utf8" });
|
|
18
|
+
|
|
19
|
+
function resolveAnchor(graph) {
|
|
20
|
+
const lr = graph.meta && graph.meta.last_review;
|
|
21
|
+
const since = val("--since");
|
|
22
|
+
if (!since && lr && lr.commit) return { commit: lr.commit, date: lr.date || null, source: "meta.last_review" };
|
|
23
|
+
if (since) {
|
|
24
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(since)) {
|
|
25
|
+
const sha = git("rev-list", "-1", `--before=${since}`, "HEAD").stdout.trim();
|
|
26
|
+
return sha ? { commit: sha, date: since, source: "--since date" } : null;
|
|
27
|
+
}
|
|
28
|
+
return { commit: since, date: null, source: "--since rev" };
|
|
29
|
+
}
|
|
30
|
+
const d = new Date(Date.now() - 14 * 86400e3).toISOString().slice(0, 10);
|
|
31
|
+
const sha = git("rev-list", "-1", `--before=${d}`, "HEAD").stdout.trim();
|
|
32
|
+
return sha ? { commit: sha, date: d, source: "default 14-day window" } : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function yamlAt(commit, rel) {
|
|
36
|
+
const r = git("show", `${commit}:${rel}`);
|
|
37
|
+
return r.status === 0 ? YAML.parse(r.stdout) : null; // file didn't exist at that rev
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const graph = loadGraph("docs/roadmap/roadmap.yaml");
|
|
41
|
+
const backlog = loadBacklog(process.cwd());
|
|
42
|
+
const anchor = resolveAnchor(graph);
|
|
43
|
+
let note = null;
|
|
44
|
+
let oldGraph = { meta: {}, pis: [] };
|
|
45
|
+
let oldBacklog = null;
|
|
46
|
+
if (!anchor) {
|
|
47
|
+
note = "no anchor commit found (young repo?) — diffing against an empty roadmap";
|
|
48
|
+
} else {
|
|
49
|
+
oldGraph = yamlAt(anchor.commit, "docs/roadmap/roadmap.yaml") || { meta: {}, pis: [] };
|
|
50
|
+
oldBacklog = yamlAt(anchor.commit, "docs/roadmap/backlog.yaml");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let digest;
|
|
54
|
+
try {
|
|
55
|
+
digest = reviewDigest({ gd: graphDiff(oldGraph, graph), bd: backlogDiff(oldBacklog, backlog), graph });
|
|
56
|
+
} catch (e) {
|
|
57
|
+
console.error(`✗ ${e.message}${anchor ? ` (old snapshot at ${anchor.commit})` : ""}`);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (args.includes("--json")) {
|
|
62
|
+
process.stdout.write(JSON.stringify({ anchor, note, digest }, null, 2) + "\n");
|
|
63
|
+
process.exit(0);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── human digest ───────────────────────────────────────────────────────────────
|
|
67
|
+
const short = (sha) => String(sha).slice(0, 8);
|
|
68
|
+
const out = [];
|
|
69
|
+
out.push(anchor
|
|
70
|
+
? `review window: ${short(anchor.commit)}${anchor.date ? ` (${anchor.date})` : ""} → HEAD [${anchor.source}]`
|
|
71
|
+
: `review window: (${note})`);
|
|
72
|
+
out.push(`shipped (${digest.shipped.length}): ${digest.shipped.map((s) => `${s.invoke}${s.prs && s.prs.length ? ` (${s.prs.join(" ")})` : ""}`).join(", ") || "—"}`);
|
|
73
|
+
out.push(`captured (${digest.netGrowth.added}): ${digest.captured.items.length} backlog item(s), ${digest.captured.sprints.length} sprint(s)`);
|
|
74
|
+
out.push(`net growth: +${digest.netGrowth.added} / -${digest.shipped.length + digest.closedItems.length} (ratio ${digest.netGrowth.ratio})`);
|
|
75
|
+
if (digest.aging.length) out.push(`held since before last review: ${digest.aging.map((a) => `${a.invoke} (${a.status})`).join(", ")}`);
|
|
76
|
+
if (digest.newPis.length) out.push(`new PIs: ${digest.newPis.map((p) => p.id).join(", ")}`);
|
|
77
|
+
if (digest.pisInFlight > 1) out.push(`PIs in flight: ${digest.pisInFlight}`);
|
|
78
|
+
for (const w of digest.sprawl) out.push(w);
|
|
79
|
+
console.log(out.join("\n"));
|