@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,302 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// roadmap dispatch <key> [--to claude-cloud|claude|codex|oz] — send a slice/backlog item to a
|
|
3
|
+
// CLOUD agent instead of a local worktree. The wave-scale version is `roadmap fan --cloud`.
|
|
4
|
+
//
|
|
5
|
+
// Two transports:
|
|
6
|
+
// claude-cloud (RECOMMENDED) — fires a Claude Code cloud session directly via the Routines
|
|
7
|
+
// API (code.claude.com/docs/en/routines). Needs NO Linear at all; runs on the CURRENTLY
|
|
8
|
+
// AUTHED claude.ai account's plan limits (multi-account hot-swap via ~/.claude-routines.json
|
|
9
|
+
// keyed by account email — see docs/DEPLOYMENT.md § Cloud dispatch). BETA API: the fire
|
|
10
|
+
// endpoint ships under an experimental header and may change.
|
|
11
|
+
// claude|codex|oz — posts an @-mention capsule comment on the mapped Linear issue. The
|
|
12
|
+
// comment is live-verified; whether the mention SUMMONS an agent requires the agent's
|
|
13
|
+
// integration installed in the Linear workspace (Linear's native coding sessions are
|
|
14
|
+
// paid-plan-gated; the delegate-field mutation remains unverified).
|
|
15
|
+
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
import { resolve, join } from "node:path";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { spawnSync } from "node:child_process";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
import { loadGraph, flatten, computeWaves, coherenceEnabled } from "./lib/graph.mjs";
|
|
22
|
+
import { loadBacklog, roadmapPaths } from "./lib/store.mjs";
|
|
23
|
+
import { runSync, postDispatchComment } from "./linear.mjs";
|
|
24
|
+
import { linearState, linearStatusLine, machineFooter, normalizeLinearConfig } from "./lib/linear-core.mjs";
|
|
25
|
+
import { outOfCycle } from "./lib/cycle-core.mjs";
|
|
26
|
+
|
|
27
|
+
export const DISPATCH_AGENTS = { claude: "@Claude", codex: "@Codex", oz: "@Oz" };
|
|
28
|
+
|
|
29
|
+
// ── claude-cloud transport ────────────────────────────────────────────────────
|
|
30
|
+
// Resolve WHICH routine to fire (PURE — inputs injected). Precedence:
|
|
31
|
+
// 1. CLAUDE_ROUTINE_TRIGGER + CLAUDE_ROUTINE_TOKEN env (explicit single-account / CI override)
|
|
32
|
+
// 2. CLAUDE_ROUTINE_PROFILE env naming a profile in the routines file (manual pin)
|
|
33
|
+
// 3. the profile whose `account` matches the CURRENTLY AUTHED claude.ai account email —
|
|
34
|
+
// this is the multi-account hot-swap: `claude /login` as someone else, next dispatch
|
|
35
|
+
// fires on their limits, no config change.
|
|
36
|
+
// Within a profile: routines[<owner/repo>] wins over routines.default (routines are repo-bound).
|
|
37
|
+
// `tier` selects an alternate routine variant keyed "<repo>#<tier>" (or "default#<tier>") —
|
|
38
|
+
// e.g. a "fable" routine whose claude.ai model selector is set to the frontier tier for
|
|
39
|
+
// slices that need judgment, not just execution. A requested tier that isn't configured
|
|
40
|
+
// THROWS rather than silently downgrading: the slice asked for frontier intelligence, and a
|
|
41
|
+
// quiet fallback to the standard tier is exactly the kind of silent failure that erodes trust.
|
|
42
|
+
export function resolveRoutine({ env = {}, profiles = null, accountEmail = null, repoSlug = null, tier = null } = {}) {
|
|
43
|
+
if (env.CLAUDE_ROUTINE_TRIGGER && env.CLAUDE_ROUTINE_TOKEN) {
|
|
44
|
+
return { trigger: env.CLAUDE_ROUTINE_TRIGGER, token: env.CLAUDE_ROUTINE_TOKEN, source: "env" };
|
|
45
|
+
}
|
|
46
|
+
if (!profiles || !Object.keys(profiles).length) {
|
|
47
|
+
throw new Error("no claude-cloud routine configured — set CLAUDE_ROUTINE_TRIGGER + CLAUDE_ROUTINE_TOKEN, or create ~/.claude-routines.json (docs/DEPLOYMENT.md § Cloud dispatch)");
|
|
48
|
+
}
|
|
49
|
+
let label = env.CLAUDE_ROUTINE_PROFILE || null;
|
|
50
|
+
let entry = label ? profiles[label] : null;
|
|
51
|
+
if (label && !entry) throw new Error(`CLAUDE_ROUTINE_PROFILE "${label}" not in the routines file (profiles: ${Object.keys(profiles).join(", ")})`);
|
|
52
|
+
if (!entry) {
|
|
53
|
+
if (!accountEmail) throw new Error("couldn't detect the current claude.ai account (is the Claude CLI logged in?) — set CLAUDE_ROUTINE_PROFILE to pin a profile explicitly");
|
|
54
|
+
const found = Object.entries(profiles).find(([, p]) => p.account && String(p.account).toLowerCase() === accountEmail.toLowerCase());
|
|
55
|
+
if (!found) throw new Error(`no routines profile matches the authed claude.ai account ${accountEmail} (profiles: ${Object.keys(profiles).join(", ")}) — add one or set CLAUDE_ROUTINE_PROFILE`);
|
|
56
|
+
[label, entry] = found;
|
|
57
|
+
}
|
|
58
|
+
const routines = entry.routines || {};
|
|
59
|
+
if (tier) {
|
|
60
|
+
const rt = (repoSlug && routines[`${repoSlug}#${tier}`]) || routines[`default#${tier}`];
|
|
61
|
+
if (!rt || !rt.trigger || !rt.token) {
|
|
62
|
+
throw new Error(`profile "${label}" has no "${tier}"-tier routine for ${repoSlug || "(unknown repo)"} — add routines["${repoSlug || "owner/repo"}#${tier}"] (or routines["default#${tier}"]) { trigger, token }; a ${tier}-tier dispatch never silently falls back to the standard routine`);
|
|
63
|
+
}
|
|
64
|
+
return { trigger: rt.trigger, token: rt.token, source: `profile:${label}:${repoSlug && routines[`${repoSlug}#${tier}`] ? repoSlug : "default"}#${tier}`, account: entry.account };
|
|
65
|
+
}
|
|
66
|
+
const r = (repoSlug && routines[repoSlug]) || routines.default;
|
|
67
|
+
if (!r || !r.trigger || !r.token) {
|
|
68
|
+
throw new Error(`profile "${label}" has no routine for ${repoSlug || "(unknown repo)"} and no default — add routines["${repoSlug || "owner/repo"}"] or routines.default { trigger, token }`);
|
|
69
|
+
}
|
|
70
|
+
return { trigger: r.trigger, token: r.token, source: `profile:${label}${repoSlug && routines[repoSlug] ? `:${repoSlug}` : ":default"}`, account: entry.account };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The currently AUTHED claude.ai account email (from the CLI's own config) — the hot-swap key.
|
|
74
|
+
export function currentClaudeAccount(home = homedir()) {
|
|
75
|
+
try {
|
|
76
|
+
const j = JSON.parse(readFileSync(join(home, ".claude.json"), "utf8"));
|
|
77
|
+
return (j.oauthAccount && j.oauthAccount.emailAddress) || null;
|
|
78
|
+
} catch { return null; }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function loadRoutineProfiles(env = process.env, home = homedir()) {
|
|
82
|
+
const p = env.CLAUDE_ROUTINES_FILE || join(home, ".claude-routines.json");
|
|
83
|
+
try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// owner/repo from the git remote — routines are repo-bound, so this keys the per-repo lookup.
|
|
87
|
+
export function repoSlugOf(root) {
|
|
88
|
+
try {
|
|
89
|
+
const r = spawnSync("git", ["remote", "get-url", "origin"], { cwd: root, encoding: "utf8" });
|
|
90
|
+
const m = /github\.com[:/]([^/]+)\/([^/\s]+?)(?:\.git)?\s*$/.exec(r.stdout || "");
|
|
91
|
+
return m ? `${m[1]}/${m[2]}` : null;
|
|
92
|
+
} catch { return null; }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Fire the routine (BETA endpoint — experimental header, shapes may change).
|
|
96
|
+
// `trigger` accepts either the bare trig_… id OR the full endpoint URL exactly as
|
|
97
|
+
// claude.ai's API-trigger modal shows it (the modal displays a URL, never a labeled id).
|
|
98
|
+
export function routineEndpoint(trigger) {
|
|
99
|
+
if (/^https?:\/\//.test(trigger)) {
|
|
100
|
+
const t = trigger.replace(/\/+$/, "");
|
|
101
|
+
return t.endsWith("/fire") ? t : `${t}/fire`;
|
|
102
|
+
}
|
|
103
|
+
return `https://api.anthropic.com/v1/claude_code/routines/${trigger}/fire`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function fireRoutine(routine, text, fetchImpl = fetch) {
|
|
107
|
+
const res = await fetchImpl(routineEndpoint(routine.trigger), {
|
|
108
|
+
method: "POST",
|
|
109
|
+
headers: {
|
|
110
|
+
Authorization: `Bearer ${routine.token}`,
|
|
111
|
+
"anthropic-beta": "experimental-cc-routine-2026-04-01",
|
|
112
|
+
"anthropic-version": "2023-06-01",
|
|
113
|
+
"Content-Type": "application/json",
|
|
114
|
+
},
|
|
115
|
+
body: JSON.stringify({ text }),
|
|
116
|
+
});
|
|
117
|
+
if (!res.ok) {
|
|
118
|
+
const hint = res.status === 401 ? " — routine token invalid/expired (re-create the API trigger)"
|
|
119
|
+
: res.status === 404 ? " — trigger id wrong, routine deleted, or the beta shape changed" : "";
|
|
120
|
+
throw new Error(`routine fire HTTP ${res.status}${hint} (beta API — check code.claude.com/docs/en/routines)`);
|
|
121
|
+
}
|
|
122
|
+
return await res.json(); // { claude_code_session_id, claude_code_session_url }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function runDispatch(root, key, opts = {}) {
|
|
126
|
+
const env = opts.env || process.env;
|
|
127
|
+
const to = (opts.to || "claude-cloud").toLowerCase();
|
|
128
|
+
const io = { apiKey: env.LINEAR_API_KEY, fetchImpl: opts.fetchImpl || fetch };
|
|
129
|
+
|
|
130
|
+
const find = () => {
|
|
131
|
+
const graph = loadGraph(roadmapPaths(root).yaml);
|
|
132
|
+
const node = flatten(graph).nodes.find((n) => n.invoke === key);
|
|
133
|
+
if (node) return { type: "slice", identifier: node.linear, graph, status: node.status, tier: node.dispatchTier || null };
|
|
134
|
+
const item = ((loadBacklog(root) || {}).items || []).find((i) => i.id === key);
|
|
135
|
+
if (item) return { type: "backlog", identifier: item.linear, graph, tier: item.dispatch_tier || null };
|
|
136
|
+
return null;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
let found = find();
|
|
140
|
+
if (!found) throw new Error(`no slice or backlog item "${key}"`);
|
|
141
|
+
|
|
142
|
+
// Cycle lock (slices only — backlog is erratic work by design, grab stays unguarded): with
|
|
143
|
+
// cycles on, out-of-cycle work doesn't dispatch. --force is the logged escape hatch; the
|
|
144
|
+
// launch surfaces as scope change on Linear's cycle graph either way.
|
|
145
|
+
if (found.type === "slice" && !opts.force && outOfCycle(normalizeLinearConfig(found.graph.meta || {}), found.status)) {
|
|
146
|
+
throw new Error(`'${key}' is out of the current cycle (status ${found.status}) — elect it first ('roadmap cycle plan', then 'roadmap cycle lock --promote ${key}'), or re-run with --force to override the cycle lock.`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── claude-cloud: fire a Claude Code cloud session directly (NO Linear required) ──
|
|
150
|
+
if (to === "claude-cloud") {
|
|
151
|
+
// Tier precedence: explicit --tier beats the node's declared dispatch_tier (the human is
|
|
152
|
+
// overriding for this one launch); the node's field is the durable routing.
|
|
153
|
+
const tier = opts.tier || found.tier || null;
|
|
154
|
+
const routine = resolveRoutine({
|
|
155
|
+
env,
|
|
156
|
+
profiles: opts.profiles !== undefined ? opts.profiles : loadRoutineProfiles(env),
|
|
157
|
+
accountEmail: opts.accountEmail !== undefined ? opts.accountEmail : currentClaudeAccount(),
|
|
158
|
+
repoSlug: opts.repoSlug !== undefined ? opts.repoSlug : repoSlugOf(root),
|
|
159
|
+
tier,
|
|
160
|
+
});
|
|
161
|
+
const text = [
|
|
162
|
+
machineFooter({ type: found.type, key }, null),
|
|
163
|
+
"",
|
|
164
|
+
"This is a roadmap cloud dispatch. The repo is cloned for you. Open docs/SLICES.md#" + key +
|
|
165
|
+
" and the entry (including its prompt) in docs/roadmap/roadmap.yaml — the YAML is canonical." +
|
|
166
|
+
" Honor the verification gate before committing, then open a PR whose DESCRIPTION includes the exact line" +
|
|
167
|
+
` 'roadmap: ${found.type}=${key}' (that line is how the roadmap reconciles cloud PRs). NEVER merge.` +
|
|
168
|
+
" Leftovers go to the BACKLOG ONLY — never new sprints or PIs (YAGNI applies to captures).",
|
|
169
|
+
].join("\n");
|
|
170
|
+
const fired = await fireRoutine(routine, text, opts.fetchImpl || fetch);
|
|
171
|
+
const result = { dispatched: key, transport: "claude-cloud", routine: routine.source,
|
|
172
|
+
sessionId: fired.claude_code_session_id, sessionUrl: fired.claude_code_session_url };
|
|
173
|
+
// Board loop: when the node is Linear-mapped and we're authed, link the session on the
|
|
174
|
+
// issue. Best-effort — a failure here never fails the dispatch.
|
|
175
|
+
if (found.identifier && linearState({ meta: found.graph.meta, env }).authed) {
|
|
176
|
+
try {
|
|
177
|
+
await postDispatchComment(found.identifier, `Claude Code cloud session started for \`${key}\`: ${result.sessionUrl}`, io);
|
|
178
|
+
result.linearComment = found.identifier;
|
|
179
|
+
} catch { /* board link is a bonus, not a dependency */ }
|
|
180
|
+
}
|
|
181
|
+
return result;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const agent = DISPATCH_AGENTS[to];
|
|
185
|
+
if (!agent) throw new Error(`unknown dispatch target "${opts.to}" (claude-cloud | ${Object.keys(DISPATCH_AGENTS).join(" | ")})`);
|
|
186
|
+
const state = linearState({ meta: found.graph.meta, env });
|
|
187
|
+
if (!state.configured || !state.authed) throw new Error(linearStatusLine(state));
|
|
188
|
+
|
|
189
|
+
let pushed = false;
|
|
190
|
+
if (!found.identifier) {
|
|
191
|
+
await runSync(root, { pushOnly: true, env, fetchImpl: opts.fetchImpl });
|
|
192
|
+
pushed = true;
|
|
193
|
+
found = find();
|
|
194
|
+
if (!found.identifier) {
|
|
195
|
+
throw new Error(`push didn't map "${key}" to a Linear issue — check meta.linear.granularity (and per-PI overrides) covers it.`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const body = [
|
|
200
|
+
`${agent} please work this issue.`,
|
|
201
|
+
"",
|
|
202
|
+
machineFooter({ type: found.type, key }, null),
|
|
203
|
+
"",
|
|
204
|
+
`Follow the repo's dispatch guidance (CLAUDE.md/AGENTS.md — 'Working a roadmap-dispatched Linear issue'). In short: the repo's docs/roadmap YAML is canonical, honor the slice's gate, open a PR whose description includes the line 'roadmap: ${found.type}=${key}', never merge, leftovers to the backlog only.`,
|
|
205
|
+
].join("\n");
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
await postDispatchComment(found.identifier, body, io);
|
|
209
|
+
} catch (e) {
|
|
210
|
+
throw new Error(`dispatch failed — tried: push-map (${pushed ? "pushed" : "already mapped"} → ${found.identifier}), commentCreate on ${found.identifier} (${e.message}). Delegate-field mutation not attempted (unverified).`);
|
|
211
|
+
}
|
|
212
|
+
return { dispatched: key, identifier: found.identifier, agent, pushed };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Conduct a cloud fanout of one ready wave — the worktree-free, disk-free fanout that lets a
|
|
216
|
+
// local session orchestrate cloud workers. PREVIEW unless opts.confirm (firing spends plan
|
|
217
|
+
// usage + opens PRs). Cloud has no machine ceiling, so the cap defaults to the review ceiling
|
|
218
|
+
// (a human still merges) — mirrors `fan --cloud`. opts.dispatch is forwarded to each
|
|
219
|
+
// runDispatch (transport injection for tests).
|
|
220
|
+
export async function runFanCloud(root, opts = {}) {
|
|
221
|
+
const graph = loadGraph(roadmapPaths(root).yaml);
|
|
222
|
+
const cap = opts.cap || 5;
|
|
223
|
+
const { waves } = computeWaves(flatten(graph), cap, { coherence: coherenceEnabled(graph.meta) });
|
|
224
|
+
const waveIdx = opts.wave || 1;
|
|
225
|
+
let wave = waves[waveIdx - 1] || [];
|
|
226
|
+
// Cycle lock at wave scale: out-of-cycle slices drop from the launch selection (reported,
|
|
227
|
+
// never silent — a silent cap reads as "covered everything"). opts.all includes them anyway.
|
|
228
|
+
// computeWaves itself stays untouched: the wave math is shared planning, the lock is a launch gate.
|
|
229
|
+
const cfg = normalizeLinearConfig(graph.meta || {});
|
|
230
|
+
let excluded = [];
|
|
231
|
+
if (!opts.all) {
|
|
232
|
+
excluded = wave.filter((n) => outOfCycle(cfg, n.status)).map((n) => n.invoke);
|
|
233
|
+
if (excluded.length) wave = wave.filter((n) => !outOfCycle(cfg, n.status));
|
|
234
|
+
}
|
|
235
|
+
const slices = wave.map((n) => n.invoke);
|
|
236
|
+
if (!opts.confirm) {
|
|
237
|
+
// Read-only pre-flight: resolve each slice's routine (its dispatch_tier against the
|
|
238
|
+
// active profile) WITHOUT firing, so a wave that would burn N frontier launches shows
|
|
239
|
+
// its unresolvable slices before confirm instead of failing them mid-wave. Resolution
|
|
240
|
+
// failures are captured per-slice — the whole point is showing them, never throwing.
|
|
241
|
+
const d = opts.dispatch || {};
|
|
242
|
+
const envv = d.env || opts.env || process.env;
|
|
243
|
+
const profiles = d.profiles !== undefined ? d.profiles : loadRoutineProfiles(envv);
|
|
244
|
+
const accountEmail = d.accountEmail !== undefined ? d.accountEmail : currentClaudeAccount();
|
|
245
|
+
const repoSlug = d.repoSlug !== undefined ? d.repoSlug : repoSlugOf(root);
|
|
246
|
+
const byTier = new Map();
|
|
247
|
+
const preflight = (tier) => {
|
|
248
|
+
if (!byTier.has(tier)) {
|
|
249
|
+
try { resolveRoutine({ env: envv, profiles, accountEmail, repoSlug, tier }); byTier.set(tier, { resolvable: true }); }
|
|
250
|
+
catch (e) { byTier.set(tier, { resolvable: false, error: e.message }); }
|
|
251
|
+
}
|
|
252
|
+
return byTier.get(tier);
|
|
253
|
+
};
|
|
254
|
+
const detail = wave.map((nd) => {
|
|
255
|
+
const tier = nd.dispatchTier || null;
|
|
256
|
+
const pf = preflight(tier);
|
|
257
|
+
return { invoke: nd.invoke, tier, resolvable: pf.resolvable, ...(pf.error ? { error: pf.error } : {}) };
|
|
258
|
+
});
|
|
259
|
+
const unresolvable = detail.filter((s) => !s.resolvable).map((s) => s.invoke);
|
|
260
|
+
return { preview: true, wave: waveIdx, cap, slices: detail,
|
|
261
|
+
...(excluded.length ? { excludedOutOfCycle: excluded } : {}),
|
|
262
|
+
note: `${detail.length} slice(s) would each fire a cloud session on the authed claude.ai account and open a PR.${unresolvable.length ? ` ${unresolvable.length} slice(s) CANNOT resolve their routine (${unresolvable.join(", ")}) — fix ~/.claude-routines.json or their dispatch_tier before confirming.` : ""}${excluded.length ? ` ${excluded.length} out-of-cycle slice(s) excluded (${excluded.join(", ")}) — elect them or pass all=true.` : ""} Re-call with confirm=true to fire.` };
|
|
263
|
+
}
|
|
264
|
+
const results = [];
|
|
265
|
+
for (const invoke of slices) {
|
|
266
|
+
// force: the fan-level filter above IS the cycle gate (and opts.all is an explicit human
|
|
267
|
+
// include) — re-guarding per dispatch would veto what the wave already admitted.
|
|
268
|
+
try { const r = await runDispatch(root, invoke, { ...(opts.dispatch || {}), force: true }); results.push({ slice: invoke, ok: true, sessionUrl: r.sessionUrl }); }
|
|
269
|
+
catch (e) { results.push({ slice: invoke, ok: false, error: e.message }); }
|
|
270
|
+
}
|
|
271
|
+
return { fired: results.filter((r) => r.ok).length, of: slices.length, wave: waveIdx, ...(excluded.length ? { excludedOutOfCycle: excluded } : {}), results };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ── CLI ───────────────────────────────────────────────────────────────────────
|
|
275
|
+
const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
276
|
+
if (isMain) {
|
|
277
|
+
const args = process.argv.slice(2);
|
|
278
|
+
const val = (n) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : undefined; };
|
|
279
|
+
const key = args.find((a) => !a.startsWith("-"));
|
|
280
|
+
if (!key) {
|
|
281
|
+
console.error(`usage: roadmap dispatch <slice-invoke|backlog-id> [--to claude-cloud|${Object.keys(DISPATCH_AGENTS).join("|")}] (default claude-cloud)`);
|
|
282
|
+
process.exit(2);
|
|
283
|
+
}
|
|
284
|
+
try {
|
|
285
|
+
const r = await runDispatch(process.cwd(), key, { to: val("--to"), force: args.includes("--force"), tier: val("--tier") });
|
|
286
|
+
if (r.transport === "claude-cloud") {
|
|
287
|
+
console.log(`dispatched ${r.dispatched} → Claude Code cloud session (${r.routine}).`);
|
|
288
|
+
console.log(`session: ${r.sessionUrl}`);
|
|
289
|
+
if (r.linearComment) console.log(`board: session link commented on ${r.linearComment}`);
|
|
290
|
+
console.log(`(beta Routines API — if shapes change, check code.claude.com/docs/en/routines)`);
|
|
291
|
+
} else {
|
|
292
|
+
console.log(`dispatched ${r.dispatched} → ${r.identifier} via ${r.agent} @-mention comment.`);
|
|
293
|
+
console.log(`VERIFY the agent picked it up. Live-tested finding: the comment posts fine, but summoning requires the agent's`);
|
|
294
|
+
console.log(`integration to be INSTALLED in the Linear workspace (Linear's native coding sessions are paid-plan-gated);`);
|
|
295
|
+
console.log(`without it there is nothing to summon. If installed and nothing happens, delegate by hand — the capsule`);
|
|
296
|
+
console.log(`comment is already on the issue to orient the agent.`);
|
|
297
|
+
}
|
|
298
|
+
} catch (e) {
|
|
299
|
+
console.error(`✗ ${e.message}`);
|
|
300
|
+
process.exit(1);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// roadmap estimate — bridges agent-time's estimator.py into the roadmap.
|
|
3
|
+
// roadmap estimate <slice> [--force] estimate one slice (skips if already estimated)
|
|
4
|
+
// roadmap estimate --all [--force] estimate every classified, un-estimated slice
|
|
5
|
+
// roadmap estimate timeline [--now DATE] roll durations up into projected_target_date per PI
|
|
6
|
+
// roadmap estimate log <slice> --status … log a completed slice's outcome → agent-time calibration
|
|
7
|
+
// The brain is lib/estimate-core.mjs (pure). This layer resolves the engine, spawns python
|
|
8
|
+
// (injectable for tests), and writes the est_minutes block back via lib/store.mjs.
|
|
9
|
+
|
|
10
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { join, resolve } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { loadGraph } from "./lib/graph.mjs";
|
|
16
|
+
import { mutateRoadmap, roadmapPaths } from "./lib/store.mjs";
|
|
17
|
+
import { setFields } from "./lib/mcp-core.mjs";
|
|
18
|
+
import { estimationConfig, estimateArgs, parseEstimateRecord, applyEstimate, timelinePlan, logArgs, alreadyLogged, LOG_STATUSES } from "./lib/estimate-core.mjs";
|
|
19
|
+
|
|
20
|
+
// Resolve estimator.py: explicit meta.estimation.engine → $AGENT_TIME_ENGINE → the installed skill.
|
|
21
|
+
export function resolveEngine(cfg, env = process.env, exists = existsSync) {
|
|
22
|
+
const cands = [cfg && cfg.engine, env.AGENT_TIME_ENGINE,
|
|
23
|
+
join(homedir(), ".claude", "skills", "agent-time-estimator", "estimator.py")].filter(Boolean);
|
|
24
|
+
for (const c of cands) if (exists(c)) return c;
|
|
25
|
+
throw new Error(
|
|
26
|
+
"agent-time estimator not found. Install the agent-time-estimator skill, set $AGENT_TIME_ENGINE, " +
|
|
27
|
+
"or set meta.estimation.engine to the path of estimator.py."
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Default runner: spawn python, return spawnSync's { status, stdout, stderr, error }.
|
|
32
|
+
// python3 → python fallback for Windows boxes without the python3 shim. PYTHONIOENCODING
|
|
33
|
+
// forces UTF-8 stdout — the estimator prints →/× and Python otherwise defaults to the
|
|
34
|
+
// Windows cp1252 codec on a non-console pipe and crashes on encode.
|
|
35
|
+
function spawnEstimator(python, engine, args, cwd) {
|
|
36
|
+
const opts = { cwd, encoding: "utf8", env: { ...process.env, PYTHONIOENCODING: "utf-8" } };
|
|
37
|
+
const r = spawnSync(python, [engine, ...args], opts);
|
|
38
|
+
if (r.error && r.error.code === "ENOENT" && python === "python3") {
|
|
39
|
+
return spawnSync("python", [engine, ...args], opts);
|
|
40
|
+
}
|
|
41
|
+
return r;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Estimate selected slices. opts: { invoke?, all?, force?, runEstimator? }. runEstimator(args,{cwd})
|
|
45
|
+
// is injectable (default = real spawn) and returns { status, stdout, stderr }. The python calls
|
|
46
|
+
// happen here (IO); a single transactional mutateRoadmap writes every successful estimate back.
|
|
47
|
+
// Returns { estimated, skipped, errors }.
|
|
48
|
+
export function runEstimate(root, opts = {}) {
|
|
49
|
+
const graph = loadGraph(roadmapPaths(root).yaml);
|
|
50
|
+
const cfg = estimationConfig(graph.meta || {});
|
|
51
|
+
const run = opts.runEstimator || (() => { const e = resolveEngine(cfg); return (args, o) => spawnEstimator(cfg.python, e, args, o.cwd); })();
|
|
52
|
+
|
|
53
|
+
const all = [];
|
|
54
|
+
for (const pi of graph.pis || []) for (const sp of pi.sprints || []) all.push(sp);
|
|
55
|
+
let targets;
|
|
56
|
+
if (opts.invoke) {
|
|
57
|
+
const sp = all.find((s) => s.invoke === opts.invoke);
|
|
58
|
+
if (!sp) throw new Error(`no slice "${opts.invoke}"`);
|
|
59
|
+
targets = [sp];
|
|
60
|
+
} else if (opts.all) {
|
|
61
|
+
targets = all.filter((s) => s.shape); // only classified slices are candidates
|
|
62
|
+
} else {
|
|
63
|
+
throw new Error("estimate needs a slice invoke key or --all");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const estimated = [], skipped = [], errors = [];
|
|
67
|
+
const blocks = {}; // invoke -> estimate block
|
|
68
|
+
for (const sp of targets) {
|
|
69
|
+
if (!sp.shape) { skipped.push({ invoke: sp.invoke, why: "no shape" }); continue; }
|
|
70
|
+
if (sp.estimate && !opts.force) { skipped.push({ invoke: sp.invoke, why: "already estimated (use --force)" }); continue; }
|
|
71
|
+
let args;
|
|
72
|
+
try { args = estimateArgs(sp, cfg); } catch (e) { errors.push({ invoke: sp.invoke, error: e.message }); continue; }
|
|
73
|
+
const r = run(args, { cwd: root });
|
|
74
|
+
if (!r || r.status !== 0) {
|
|
75
|
+
const detail = (r && (r.stderr || (r.error && r.error.message))) || "";
|
|
76
|
+
errors.push({ invoke: sp.invoke, error: `estimator exited ${r ? r.status : "?"}: ${String(detail).trim().slice(0, 200)}` });
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
try { blocks[sp.invoke] = applyEstimate(parseEstimateRecord(r.stdout)); }
|
|
80
|
+
catch (e) { errors.push({ invoke: sp.invoke, error: e.message }); continue; }
|
|
81
|
+
const b = blocks[sp.invoke];
|
|
82
|
+
estimated.push({ invoke: sp.invoke, minutes: b.minutes, confidence: b.confidence });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (Object.keys(blocks).length) {
|
|
86
|
+
// One validate + write for the whole batch; a throw leaves the YAML untouched.
|
|
87
|
+
mutateRoadmap(root, (doc) => {
|
|
88
|
+
for (const [invoke, block] of Object.entries(blocks)) setFields(doc, { invoke, fields: { estimate: block } });
|
|
89
|
+
return { estimated: Object.keys(blocks) };
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return { estimated, skipped, errors };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Roll the per-slice estimates up into a projected_target_date per PI and write it back (never
|
|
96
|
+
// touching an explicit target_date — a separate field, so the commitment always wins). Only PIs
|
|
97
|
+
// whose projection changed are written. `now` is injectable for deterministic tests. Returns the
|
|
98
|
+
// plan plus `changed` (the PI ids whose projected_target_date moved).
|
|
99
|
+
export function runTimeline(root, opts = {}) {
|
|
100
|
+
const graph = loadGraph(roadmapPaths(root).yaml);
|
|
101
|
+
const now = opts.now || new Date().toISOString();
|
|
102
|
+
const plan = timelinePlan(graph, { now, concurrency: opts.concurrency });
|
|
103
|
+
const wanted = new Map(plan.pis.map((p) => [p.pi, p.projected_target_date]));
|
|
104
|
+
const changed = [];
|
|
105
|
+
for (const pi of graph.pis || []) {
|
|
106
|
+
const cur = pi.projected_target_date || null;
|
|
107
|
+
const want = wanted.has(pi.id) ? wanted.get(pi.id) : null;
|
|
108
|
+
if (cur !== want) changed.push(pi.id);
|
|
109
|
+
}
|
|
110
|
+
if (changed.length) {
|
|
111
|
+
mutateRoadmap(root, (doc) => {
|
|
112
|
+
const items = (doc.get("pis") || {}).items || [];
|
|
113
|
+
for (let i = 0; i < items.length; i++) {
|
|
114
|
+
const id = String(items[i].get("id"));
|
|
115
|
+
const want = wanted.has(id) ? wanted.get(id) : null;
|
|
116
|
+
if (want) doc.setIn(["pis", i, "projected_target_date"], want);
|
|
117
|
+
else if (items[i].get("projected_target_date") != null) doc.deleteIn(["pis", i, "projected_target_date"]);
|
|
118
|
+
}
|
|
119
|
+
return { projected: plan.pis.length };
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return { ...plan, changed };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// agent-time's history path for this repo — same resolution the estimator uses when spawned with
|
|
126
|
+
// cwd=root (<root>/.claude/agent-time/history.jsonl), or $AGENT_TIME_DATA. Used only for idempotency.
|
|
127
|
+
export function resolveHistory(root, env = process.env) {
|
|
128
|
+
return env.AGENT_TIME_DATA || join(root, ".claude", "agent-time", "history.jsonl");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Log a completed slice's outcome to agent-time so its estimates self-correct (the calibration loop).
|
|
132
|
+
// Idempotent per task_id via agent-time's own history — re-firing (e.g. the Stop hook on every session
|
|
133
|
+
// end) never double-counts. Best-effort on the spawn: a failure is returned, never thrown through.
|
|
134
|
+
// opts: { invoke, status?, force?, actualMinutes?, actualRounds?, runEstimator? }.
|
|
135
|
+
export function runLog(root, opts = {}) {
|
|
136
|
+
const graph = loadGraph(roadmapPaths(root).yaml);
|
|
137
|
+
const cfg = estimationConfig(graph.meta || {});
|
|
138
|
+
let sprint = null;
|
|
139
|
+
for (const pi of graph.pis || []) for (const sp of pi.sprints || []) if (sp.invoke === opts.invoke) sprint = sp;
|
|
140
|
+
if (!sprint) throw new Error(`no slice "${opts.invoke}"`);
|
|
141
|
+
const status = opts.status || "pass";
|
|
142
|
+
const args = logArgs(sprint, status, { actualMinutes: opts.actualMinutes, actualRounds: opts.actualRounds }); // throws if not estimated
|
|
143
|
+
|
|
144
|
+
const histPath = resolveHistory(root);
|
|
145
|
+
if (!opts.force && existsSync(histPath) && alreadyLogged(readFileSync(histPath, "utf8"), sprint.estimate.task_id)) {
|
|
146
|
+
return { skipped: true, invoke: opts.invoke, reason: "already calibrated (use --force)" };
|
|
147
|
+
}
|
|
148
|
+
const run = opts.runEstimator || (() => { const e = resolveEngine(cfg); return (a, o) => spawnEstimator(cfg.python, e, a, o.cwd); })();
|
|
149
|
+
const r = run(args, { cwd: root });
|
|
150
|
+
if (!r || r.status !== 0) {
|
|
151
|
+
const detail = (r && (r.stderr || (r.error && r.error.message))) || "";
|
|
152
|
+
return { error: `estimator log exited ${r ? r.status : "?"}: ${String(detail).trim().slice(0, 200)}`, invoke: opts.invoke };
|
|
153
|
+
}
|
|
154
|
+
return { invoke: opts.invoke, logged: true, status, task_id: sprint.estimate.task_id };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── CLI ───────────────────────────────────────────────────────────────────────
|
|
158
|
+
const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
159
|
+
if (isMain) {
|
|
160
|
+
const args = process.argv.slice(2);
|
|
161
|
+
const has = (n) => args.includes(n);
|
|
162
|
+
const val = (n) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : undefined; };
|
|
163
|
+
const positional = args.filter((a) => !a.startsWith("-"));
|
|
164
|
+
const sub = positional[0];
|
|
165
|
+
const root = process.cwd();
|
|
166
|
+
try {
|
|
167
|
+
if (sub === "timeline") {
|
|
168
|
+
const nowArg = val("--now");
|
|
169
|
+
const r = runTimeline(root, nowArg ? { now: nowArg } : {});
|
|
170
|
+
console.log(`timeline: anchored ${r.anchor} · ${r.concurrency}-wide · ${r.point} · ${r.hoursPerDay}h/day`);
|
|
171
|
+
for (const p of r.pis) console.log(` ${p.pi} → ${p.projected_target_date}`);
|
|
172
|
+
console.log(r.changed.length ? `projected_target_date updated on ${r.changed.length} PI(s): ${r.changed.join(", ")}` : "projected_target_date: already current (no change).");
|
|
173
|
+
if (r.unpriced.length) console.log(`unpriced (no estimate — 0 span, run 'roadmap estimate --all'): ${[...new Set(r.unpriced)].join(", ")}`);
|
|
174
|
+
if (r.held.length) console.log(`held (blocked / gated — not scheduled, excluded): ${r.held.join(", ")}`);
|
|
175
|
+
process.exit(0);
|
|
176
|
+
}
|
|
177
|
+
if (sub === "log") {
|
|
178
|
+
const invoke = positional[1];
|
|
179
|
+
if (!invoke) { console.error(`usage: roadmap estimate log <slice> [--status ${LOG_STATUSES.join("|")}] [--actual-rounds N] [--actual-minutes M] [--force]`); process.exit(2); }
|
|
180
|
+
const ar = val("--actual-rounds"), am = val("--actual-minutes");
|
|
181
|
+
const r = runLog(root, { invoke, status: val("--status"), force: has("--force"),
|
|
182
|
+
actualRounds: ar != null ? Number(ar) : undefined, actualMinutes: am != null ? Number(am) : undefined });
|
|
183
|
+
if (r.skipped) console.log(`- ${r.invoke}: ${r.reason}`);
|
|
184
|
+
else if (r.error) { console.error(`✗ ${r.invoke}: ${r.error}`); process.exit(1); }
|
|
185
|
+
else console.log(`✓ ${r.invoke}: outcome logged (${r.status}) → agent-time calibration updated.`);
|
|
186
|
+
process.exit(0);
|
|
187
|
+
}
|
|
188
|
+
const opts = { force: has("--force") };
|
|
189
|
+
if (has("--all")) opts.all = true;
|
|
190
|
+
else if (sub) opts.invoke = sub;
|
|
191
|
+
else { console.error("usage: roadmap estimate <slice> [--force] | roadmap estimate --all [--force]"); process.exit(2); }
|
|
192
|
+
const r = runEstimate(root, opts);
|
|
193
|
+
for (const e of r.estimated) console.log(`✓ ${e.invoke}: ${e.minutes.expected} min (≈ ${e.minutes.low}–${e.minutes.high}) · ${e.confidence}`);
|
|
194
|
+
for (const s of r.skipped) console.log(`- ${s.invoke} skipped (${s.why})`);
|
|
195
|
+
for (const x of r.errors) console.error(`✗ ${x.invoke}: ${x.error}`);
|
|
196
|
+
if (!r.estimated.length && r.errors.length) process.exit(1);
|
|
197
|
+
} catch (e) {
|
|
198
|
+
console.error(`✗ ${e.message}`);
|
|
199
|
+
process.exit(1);
|
|
200
|
+
}
|
|
201
|
+
}
|