@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
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
// roadmap β graph brain.
|
|
2
|
+
// Loads roadmap.yaml, flattens PIsβsprint nodes, resolves dependency edges,
|
|
3
|
+
// detects cycles, derives the exec-plan line + session rollups, and computes
|
|
4
|
+
// the fanout waves. Every consumer (render, validate, scheduler) goes through here
|
|
5
|
+
// so the human-facing view and the executed plan can never disagree.
|
|
6
|
+
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import YAML from "yaml";
|
|
9
|
+
import { comparePriority } from "./priority.mjs";
|
|
10
|
+
|
|
11
|
+
export const STATUS = {
|
|
12
|
+
active: { emoji: "π’", label: "Active", done: false, rank: 0 },
|
|
13
|
+
next: { emoji: "π‘", label: "Next", done: false, rank: 1 },
|
|
14
|
+
scheduled: { emoji: "βͺ", label: "Scheduled", done: false, rank: 2 },
|
|
15
|
+
complete: { emoji: "β
", label: "Complete", done: true, rank: 9 },
|
|
16
|
+
blocked: { emoji: "π΄", label: "Blocked", done: false, rank: 3 },
|
|
17
|
+
paused: { emoji: "βΈοΈ", label: "Paused", done: false, rank: 4 },
|
|
18
|
+
gated: { emoji: "π", label: "Gated", done: false, rank: 5 },
|
|
19
|
+
optionality: { emoji: "π£", label: "Optionality", done: false, rank: 8 },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function emojiFor(status) {
|
|
23
|
+
return (STATUS[status] || { emoji: "β" }).emoji;
|
|
24
|
+
}
|
|
25
|
+
export function statusDisplay(status, statusLabel) {
|
|
26
|
+
const meta = STATUS[status] || { emoji: "β", label: status };
|
|
27
|
+
return `${meta.emoji} ${statusLabel || meta.label}`;
|
|
28
|
+
}
|
|
29
|
+
export function isDone(status) {
|
|
30
|
+
return !!(STATUS[status] && STATUS[status].done);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function loadGraph(path) {
|
|
34
|
+
const raw = readFileSync(path, "utf8");
|
|
35
|
+
const doc = YAML.parse(raw);
|
|
36
|
+
if (!doc || typeof doc !== "object") {
|
|
37
|
+
throw new Error(`roadmap.yaml at ${path} did not parse to an object`);
|
|
38
|
+
}
|
|
39
|
+
return doc;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Flatten to a node list. Each node carries its PI context, its resolved
|
|
43
|
+
// dependency node-keys, and the raw fields. nodeKey = `${piId}/${sprintId}`.
|
|
44
|
+
export function flatten(graph) {
|
|
45
|
+
const pis = graph.pis || [];
|
|
46
|
+
const sprintIndex = new Map(); // nodeKey -> node
|
|
47
|
+
const piIndex = new Map(); // piId -> pi
|
|
48
|
+
const invokeIndex = new Map(); // invoke -> nodeKey
|
|
49
|
+
const nodes = [];
|
|
50
|
+
|
|
51
|
+
for (const pi of pis) {
|
|
52
|
+
piIndex.set(pi.id, pi);
|
|
53
|
+
for (const sp of pi.sprints || []) {
|
|
54
|
+
const nodeKey = `${pi.id}/${sp.id}`;
|
|
55
|
+
const node = {
|
|
56
|
+
nodeKey,
|
|
57
|
+
piId: pi.id,
|
|
58
|
+
piTitle: pi.title,
|
|
59
|
+
piStatus: pi.status,
|
|
60
|
+
programLabel: pi.program_label || pi.id.toUpperCase(),
|
|
61
|
+
id: sp.id,
|
|
62
|
+
invoke: sp.invoke,
|
|
63
|
+
title: sp.title,
|
|
64
|
+
status: sp.status,
|
|
65
|
+
statusLabel: sp.status_label || null,
|
|
66
|
+
what: sp.what || sp.title,
|
|
67
|
+
estSessions: typeof sp.est_sessions === "number" ? sp.est_sessions : null,
|
|
68
|
+
estMinutes: sp.estimate && sp.estimate.minutes ? sp.estimate.minutes : null, // { low, expected, high } from agent-time; drives the timeline rollup
|
|
69
|
+
rawDeps: sp.deps || [],
|
|
70
|
+
deps: [], // resolved sprint nodeKeys
|
|
71
|
+
piDeps: [], // resolved PI ids this sprint waits on
|
|
72
|
+
touches: sp.touches || [],
|
|
73
|
+
owns: sp.owns || [],
|
|
74
|
+
gate: sp.gate || "default",
|
|
75
|
+
gatedOn: sp.gated_on || null,
|
|
76
|
+
optional: !!sp.optional,
|
|
77
|
+
execution: sp.execution || null, // optional staffing-strategy hint (see lib/execution.mjs)
|
|
78
|
+
track: sp.track || null, // optional lane label for the three-track partition (--track)
|
|
79
|
+
priority: sp.priority || null, // optional { tier, weight, reason } (see lib/priority.mjs)
|
|
80
|
+
prompt: sp.prompt || null, // optional author-stashed pickup instructions
|
|
81
|
+
linear: sp.linear || null, // optional Linear issue identifier (see lib/linear-core.mjs)
|
|
82
|
+
dispatchTier: sp.dispatch_tier || null, // optional cloud-routine tier (see dispatch.mjs resolveRoutine)
|
|
83
|
+
readOrder: sp.read_order || [],
|
|
84
|
+
resumeAction: sp.resume_action || "",
|
|
85
|
+
kickoffBrief: sp.kickoff_brief || "brief",
|
|
86
|
+
prs: sp.prs || [],
|
|
87
|
+
completedOn: sp.completed_on || null,
|
|
88
|
+
pi,
|
|
89
|
+
sprint: sp,
|
|
90
|
+
};
|
|
91
|
+
nodes.push(node);
|
|
92
|
+
sprintIndex.set(nodeKey, node);
|
|
93
|
+
if (sp.invoke) {
|
|
94
|
+
if (invokeIndex.has(sp.invoke)) {
|
|
95
|
+
throw new Error(`duplicate invoke key "${sp.invoke}" (${invokeIndex.get(sp.invoke)} and ${nodeKey})`);
|
|
96
|
+
}
|
|
97
|
+
invokeIndex.set(sp.invoke, nodeKey);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Resolve dependency edges now that every node exists.
|
|
103
|
+
for (const node of nodes) {
|
|
104
|
+
for (const dep of node.rawDeps) {
|
|
105
|
+
if (dep.includes("/")) { // fully-qualified pi/sprint
|
|
106
|
+
if (!sprintIndex.has(dep)) {
|
|
107
|
+
throw new Error(`${node.nodeKey}: dep "${dep}" does not resolve to a sprint`);
|
|
108
|
+
}
|
|
109
|
+
node.deps.push(dep);
|
|
110
|
+
} else if (sprintIndex.has(`${node.piId}/${dep}`)) { // sibling sprint id
|
|
111
|
+
node.deps.push(`${node.piId}/${dep}`);
|
|
112
|
+
} else if (piIndex.has(dep)) { // a whole PI
|
|
113
|
+
node.piDeps.push(dep);
|
|
114
|
+
} else if (invokeIndex.has(dep)) { // tolerate an invoke key as a dep
|
|
115
|
+
node.deps.push(invokeIndex.get(dep));
|
|
116
|
+
} else {
|
|
117
|
+
throw new Error(`${node.nodeKey}: dep "${dep}" matches no sprint id, pi/sprint, PI id, or invoke key`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { nodes, sprintIndex, piIndex, invokeIndex, pis };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// PI-dep satisfied iff every sprint of that PI is complete.
|
|
126
|
+
function piComplete(piId, sprintIndex) {
|
|
127
|
+
for (const node of sprintIndex.values()) {
|
|
128
|
+
if (node.piId === piId && !isDone(node.status)) return false;
|
|
129
|
+
}
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 3-color DFS cycle detection over sprint deps (+ PI-deps expanded to that PI's sprints).
|
|
134
|
+
export function detectCycle(model) {
|
|
135
|
+
const { nodes, sprintIndex } = model;
|
|
136
|
+
const WHITE = 0, GRAY = 1, BLACK = 2;
|
|
137
|
+
const color = new Map(nodes.map((n) => [n.nodeKey, WHITE]));
|
|
138
|
+
const adj = new Map();
|
|
139
|
+
for (const n of nodes) {
|
|
140
|
+
const outs = [...n.deps];
|
|
141
|
+
for (const piId of n.piDeps) {
|
|
142
|
+
for (const m of nodes) if (m.piId === piId) outs.push(m.nodeKey);
|
|
143
|
+
}
|
|
144
|
+
adj.set(n.nodeKey, outs);
|
|
145
|
+
}
|
|
146
|
+
const stack = [];
|
|
147
|
+
function dfs(key) {
|
|
148
|
+
color.set(key, GRAY);
|
|
149
|
+
stack.push(key);
|
|
150
|
+
for (const next of adj.get(key) || []) {
|
|
151
|
+
if (color.get(next) === GRAY) {
|
|
152
|
+
const from = stack.indexOf(next);
|
|
153
|
+
return [...stack.slice(from), next];
|
|
154
|
+
}
|
|
155
|
+
if (color.get(next) === WHITE) {
|
|
156
|
+
const cyc = dfs(next);
|
|
157
|
+
if (cyc) return cyc;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
color.set(key, BLACK);
|
|
161
|
+
stack.pop();
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
for (const n of nodes) {
|
|
165
|
+
if (color.get(n.nodeKey) === WHITE) {
|
|
166
|
+
const cyc = dfs(n.nodeKey);
|
|
167
|
+
if (cyc) return cyc; // array of nodeKeys forming the cycle
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function depsSatisfied(node, sprintIndex) {
|
|
174
|
+
for (const dep of node.deps) {
|
|
175
|
+
const d = sprintIndex.get(dep);
|
|
176
|
+
if (!d || !isDone(d.status)) return false;
|
|
177
|
+
}
|
|
178
|
+
for (const piId of node.piDeps) {
|
|
179
|
+
if (!piComplete(piId, sprintIndex)) return false;
|
|
180
|
+
}
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Held statuses: not-done, not-active β a slice that can't auto-schedule and isn't being
|
|
185
|
+
// worked. The one source of truth (linear-core's held labels + review-core's aging both
|
|
186
|
+
// import this, so a new held-like status is added in exactly one place).
|
|
187
|
+
export const HELD_STATUSES = ["blocked", "paused", "gated"];
|
|
188
|
+
const READY_BLOCKING = new Set(HELD_STATUSES);
|
|
189
|
+
|
|
190
|
+
// The pool of slices that COULD start now: deps satisfied, not done, not blocked/gated.
|
|
191
|
+
// (Ignores file contention β that's a wave-packing concern, handled in computeWaves.)
|
|
192
|
+
export function readyNodes(model) {
|
|
193
|
+
const { nodes, sprintIndex } = model;
|
|
194
|
+
return nodes.filter(
|
|
195
|
+
(n) => !isDone(n.status) && !READY_BLOCKING.has(n.status) && !n.gatedOn && depsSatisfied(n, sprintIndex)
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Sessions remaining to clear a PI = sum of est_sessions over its not-complete sprints.
|
|
200
|
+
export function sessionsRemaining(pi) {
|
|
201
|
+
return (pi.sprints || []).reduce((acc, sp) => {
|
|
202
|
+
if (isDone(sp.status)) return acc;
|
|
203
|
+
return acc + (typeof sp.est_sessions === "number" ? sp.est_sessions : 0);
|
|
204
|
+
}, 0);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Derive the compact exec-plan line for a PI from INTRA-PI sprint deps among the
|
|
208
|
+
// NOT-YET-COMPLETE sprints (the remaining execution shape): longest-path layering;
|
|
209
|
+
// same-level sprints with no edge between them are parallel.
|
|
210
|
+
// e.g. "(S0 β₯ S1)βS2βS3". Optional sprints get a trailing "?".
|
|
211
|
+
export function execPlan(pi) {
|
|
212
|
+
const sprints = (pi.sprints || []).filter((s) => !isDone(s.status));
|
|
213
|
+
if (sprints.length === 0) return "";
|
|
214
|
+
if (sprints.length === 1) {
|
|
215
|
+
return sprints.map((s) => label(s)).join("");
|
|
216
|
+
}
|
|
217
|
+
const byId = new Map(sprints.map((s) => [s.id, s]));
|
|
218
|
+
const intraDeps = (s) => (s.deps || []).filter((d) => byId.has(d)); // only same-PI edges
|
|
219
|
+
const level = new Map();
|
|
220
|
+
function lvl(id, seen = new Set()) {
|
|
221
|
+
if (level.has(id)) return level.get(id);
|
|
222
|
+
if (seen.has(id)) return 0; // defensive; real cycles caught by detectCycle
|
|
223
|
+
seen.add(id);
|
|
224
|
+
const deps = intraDeps(byId.get(id));
|
|
225
|
+
const v = deps.length === 0 ? 0 : 1 + Math.max(...deps.map((d) => lvl(d, seen)));
|
|
226
|
+
level.set(id, v);
|
|
227
|
+
return v;
|
|
228
|
+
}
|
|
229
|
+
for (const s of sprints) lvl(s.id);
|
|
230
|
+
const layers = [];
|
|
231
|
+
for (const s of sprints) {
|
|
232
|
+
const v = level.get(s.id);
|
|
233
|
+
(layers[v] ||= []).push(s);
|
|
234
|
+
}
|
|
235
|
+
return layers
|
|
236
|
+
.filter(Boolean)
|
|
237
|
+
.map((group) => {
|
|
238
|
+
const parts = group.map((s) => label(s));
|
|
239
|
+
return parts.length > 1 ? `(${parts.join(" β₯ ")})` : parts[0];
|
|
240
|
+
})
|
|
241
|
+
.join("β");
|
|
242
|
+
|
|
243
|
+
function label(s) {
|
|
244
|
+
const base = s.id.toUpperCase();
|
|
245
|
+
return s.optional ? `${base}?` : base;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Wave-packing coherence knob: prefer finishing STARTED PIs over opening fresh ones, so a
|
|
250
|
+
// capped wave doesn't leave every PI half-open. Strictly subordinate to declared priority.
|
|
251
|
+
// meta.discipline.coherence: false restores pure priority/status/est ordering.
|
|
252
|
+
export function coherenceEnabled(meta) {
|
|
253
|
+
return !(meta && meta.discipline && meta.discipline.coherence === false);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Compute execution waves under a concurrency cap N.
|
|
257
|
+
// Returns { waves: [[node,...],...], held: { onHuman:[node], blocked:[node] } }.
|
|
258
|
+
// opts.coherence (default true): PI-coherence tiebreak in the ready sort β see coherenceEnabled.
|
|
259
|
+
export function computeWaves(model, N = 3, opts = {}) {
|
|
260
|
+
const coherence = opts.coherence !== false;
|
|
261
|
+
const { nodes, sprintIndex } = model;
|
|
262
|
+
const cyc = detectCycle(model);
|
|
263
|
+
if (cyc) {
|
|
264
|
+
const err = new Error(`dependency cycle: ${cyc.join(" β ")}`);
|
|
265
|
+
err.cycle = cyc;
|
|
266
|
+
throw err;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Work on a mutable copy of statuses so optimistic completion drives layering.
|
|
270
|
+
const status = new Map(nodes.map((n) => [n.nodeKey, n.status]));
|
|
271
|
+
const localDone = (key) => isDone(status.get(key));
|
|
272
|
+
const localDepsSatisfied = (node) => {
|
|
273
|
+
for (const dep of node.deps) if (!localDone(dep)) return false;
|
|
274
|
+
for (const piId of node.piDeps) {
|
|
275
|
+
for (const m of nodes) if (m.piId === piId && !localDone(m.nodeKey)) return false;
|
|
276
|
+
}
|
|
277
|
+
return true;
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
let remaining = nodes.filter((n) => !isDone(n.status));
|
|
281
|
+
const waves = [];
|
|
282
|
+
|
|
283
|
+
while (remaining.length) {
|
|
284
|
+
const ready = remaining.filter(
|
|
285
|
+
(n) => !READY_BLOCKING.has(n.status) && !n.gatedOn && localDepsSatisfied(n)
|
|
286
|
+
);
|
|
287
|
+
if (!ready.length) break;
|
|
288
|
+
|
|
289
|
+
// Per-iteration PI coherence stats over the OPTIMISTIC statuses: a PI counts as started
|
|
290
|
+
// once any sprint is done/active (incl. waves already packed this run); `remaining` drives
|
|
291
|
+
// "closest to done first". Siblings share a rank, so the cap fills contiguously.
|
|
292
|
+
const coh = new Map();
|
|
293
|
+
if (coherence) {
|
|
294
|
+
for (const n of nodes) {
|
|
295
|
+
const s = coh.get(n.piId) || { started: false, remaining: 0 };
|
|
296
|
+
const st = status.get(n.nodeKey);
|
|
297
|
+
if (isDone(st) || st === "active") s.started = true;
|
|
298
|
+
if (!isDone(st)) s.remaining += 1;
|
|
299
|
+
coh.set(n.piId, s);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
ready.sort((a, b) => {
|
|
304
|
+
const pc = comparePriority(a.priority, b.priority); // declared priority wins the cap slot
|
|
305
|
+
if (pc) return pc; // both absent β 0 β coherence, then existing order
|
|
306
|
+
if (coherence && a.piId !== b.piId) {
|
|
307
|
+
const ca = coh.get(a.piId), cb = coh.get(b.piId);
|
|
308
|
+
if (ca.started !== cb.started) return ca.started ? -1 : 1; // finish started PIs first
|
|
309
|
+
if (ca.remaining !== cb.remaining) return ca.remaining - cb.remaining; // closest-to-done first
|
|
310
|
+
}
|
|
311
|
+
const ra = (STATUS[a.status] || {}).rank ?? 7;
|
|
312
|
+
const rb = (STATUS[b.status] || {}).rank ?? 7;
|
|
313
|
+
if (ra !== rb) return ra - rb;
|
|
314
|
+
const ea = a.estSessions ?? 99, eb = b.estSessions ?? 99;
|
|
315
|
+
if (ea !== eb) return ea - eb;
|
|
316
|
+
return a.invoke.localeCompare(b.invoke);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
const wave = [];
|
|
320
|
+
const claimed = new Set();
|
|
321
|
+
for (const n of ready) {
|
|
322
|
+
if (wave.length >= N) break;
|
|
323
|
+
const files = [...n.touches, ...n.owns];
|
|
324
|
+
if (files.some((f) => claimed.has(f))) continue; // two-wave: file contention defers
|
|
325
|
+
wave.push(n);
|
|
326
|
+
files.forEach((f) => claimed.add(f));
|
|
327
|
+
}
|
|
328
|
+
if (!wave.length) break; // pure contention with empty file sets shouldn't happen, guard anyway
|
|
329
|
+
|
|
330
|
+
waves.push(wave);
|
|
331
|
+
wave.forEach((n) => status.set(n.nodeKey, "complete")); // optimistic for layering
|
|
332
|
+
const inWave = new Set(wave.map((n) => n.nodeKey));
|
|
333
|
+
remaining = remaining.filter((n) => !inWave.has(n.nodeKey));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const onHuman = remaining.filter((n) => n.gatedOn);
|
|
337
|
+
const blocked = remaining.filter((n) => !n.gatedOn);
|
|
338
|
+
return { waves, held: { onHuman, blocked } };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Resolve a sprint's gate string, interpolating {{default}} / 'default'.
|
|
342
|
+
export function resolveGate(node, graph) {
|
|
343
|
+
const def = (graph.meta && graph.meta.default_gate) || "";
|
|
344
|
+
if (!node.gate || node.gate === "default") return def;
|
|
345
|
+
return node.gate.replace(/\{\{\s*default\s*\}\}/g, def);
|
|
346
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// roadmap β "the journal" (PURE): the progress trail agents leave ON a tracker issue so a session that
|
|
2
|
+
// dies mid-flight is resumable. This module formats note bodies, resolves the current git branch back to
|
|
3
|
+
// a slice (for the auto-post hook), and renders a git-derived snapshot from raw git output. NO IO:
|
|
4
|
+
// linear.mjs posts/reads the comments; hooks/journal-post.mjs runs git + fires the post; this decides.
|
|
5
|
+
|
|
6
|
+
import { flatten } from "./graph.mjs";
|
|
7
|
+
import { branchFor } from "./brief.mjs";
|
|
8
|
+
|
|
9
|
+
export const NOTE_KINDS = ["progress", "blocker", "done", "auto"];
|
|
10
|
+
|
|
11
|
+
// A note body for a Linear/tracker comment. The `[kind]` heading + `roadmap-note` marker make the stream
|
|
12
|
+
// human-scannable on pickup (roadmap linear notes just prints comments chronologically β no parsing).
|
|
13
|
+
export function noteBody({ kind = "progress", text } = {}) {
|
|
14
|
+
const k = NOTE_KINDS.includes(kind) ? kind : "progress";
|
|
15
|
+
return `**[${k}]** ${String(text || "").trim()}\n\n_roadmap-note_`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Invert branchFor over the graph's nodes: given a git branch name, the slice whose branch_convention
|
|
19
|
+
// branch equals it β or null when zero or MORE THAN ONE match (the hook only auto-posts on a clean 1:1).
|
|
20
|
+
export function sliceForBranch(graph, branch) {
|
|
21
|
+
if (!branch) return null;
|
|
22
|
+
const matches = flatten(graph).nodes.filter((n) => branchFor(n, graph) === branch);
|
|
23
|
+
return matches.length === 1 ? matches[0] : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Render the auto-post snapshot from ALREADY-COLLECTED git strings (the hook runs git; this just formats).
|
|
27
|
+
// commits: array of subject lines (newest first); dirty: `git status -s` text. Returns null when there's
|
|
28
|
+
// no real work to report (no commits ahead + clean tree) so the hook skips empty posts.
|
|
29
|
+
export function gitSnapshot({ branch, commits = [], dirty = "" } = {}) {
|
|
30
|
+
const cleanCommits = commits.map((c) => c.trim()).filter(Boolean);
|
|
31
|
+
const dirtyLines = String(dirty || "").split("\n").map((l) => l.trim()).filter(Boolean);
|
|
32
|
+
if (!cleanCommits.length && !dirtyLines.length) return null;
|
|
33
|
+
const parts = [`Session ended on \`${branch}\`.`];
|
|
34
|
+
if (cleanCommits.length) parts.push(`Recent commits:\n${cleanCommits.slice(0, 3).map((c) => `- ${c}`).join("\n")}`);
|
|
35
|
+
if (dirtyLines.length) parts.push(`Uncommitted (${dirtyLines.length} path(s)):\n${dirtyLines.slice(0, 10).map((l) => `- ${l}`).join("\n")}`);
|
|
36
|
+
return parts.join("\n\n");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The session-end auto-post DECISION (PURE): given the graph + collected git facts, the { identifier, body }
|
|
40
|
+
// to post β or null to skip. Guards: the branch must map 1:1 to a MAPPED slice, and there must be real
|
|
41
|
+
// work (gitSnapshot non-null). The hook (hooks/journal-post.mjs) runs git + fetch; this decides.
|
|
42
|
+
export function autoPostPlan(graph, { branch, commits = [], dirty = "" } = {}) {
|
|
43
|
+
const node = sliceForBranch(graph, branch);
|
|
44
|
+
if (!node || !node.linear) return null;
|
|
45
|
+
const snapshot = gitSnapshot({ branch, commits, dirty });
|
|
46
|
+
if (!snapshot) return null;
|
|
47
|
+
return { identifier: node.linear, body: noteBody({ kind: "auto", text: snapshot }) };
|
|
48
|
+
}
|