@dpeek/codeless 0.1.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 +242 -0
- package/bin/codeless +10 -0
- package/extension/planner.js +336 -0
- package/package.json +47 -0
- package/spec/workflow.md +200 -0
- package/src/cli.ts +924 -0
- package/src/metrics.ts +155 -0
- package/src/pi.ts +174 -0
- package/src/project.ts +55 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,924 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
existsSync,
|
|
5
|
+
appendFileSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
readdirSync,
|
|
9
|
+
realpathSync,
|
|
10
|
+
rmdirSync,
|
|
11
|
+
unlinkSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from "node:fs";
|
|
14
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
17
|
+
|
|
18
|
+
import { metricReport, recordDispatch, recordLanding } from "./metrics.ts";
|
|
19
|
+
import { roleSelectionArguments, roleSelectionSummary, validateRoleSelection } from "./pi.ts";
|
|
20
|
+
import { readProject } from "./project.ts";
|
|
21
|
+
|
|
22
|
+
const usage = `Usage:
|
|
23
|
+
codeless create <slug>
|
|
24
|
+
codeless open <slug>
|
|
25
|
+
codeless planner <slug>
|
|
26
|
+
codeless approve <planner-session>
|
|
27
|
+
codeless dispatch <numbered-change-file>
|
|
28
|
+
codeless land <slug>
|
|
29
|
+
codeless next <numbered-change-file> <landed-commit>
|
|
30
|
+
codeless metrics`;
|
|
31
|
+
|
|
32
|
+
export async function runCodeless(args: string[]): Promise<void> {
|
|
33
|
+
const [action, target, ...details] = args;
|
|
34
|
+
if (action === undefined || action === "--help" || action === "-h") {
|
|
35
|
+
console.log(usage);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const repository = canonicalPath(run("git", ["rev-parse", "--show-toplevel"]).trim());
|
|
39
|
+
const { integrationBranch } = readProject(repository);
|
|
40
|
+
run("git", ["check-ref-format", "--branch", integrationBranch], repository);
|
|
41
|
+
const plannerExtension = resolve(import.meta.dir, "../extension/planner.js");
|
|
42
|
+
const configuredWorkspace = Bun.spawnSync(
|
|
43
|
+
["git", "config", "--local", "--get", "codeless.workspaceRoot"],
|
|
44
|
+
{ cwd: repository },
|
|
45
|
+
);
|
|
46
|
+
if (![0, 1].includes(configuredWorkspace.exitCode))
|
|
47
|
+
throw new Error(configuredWorkspace.stderr.toString());
|
|
48
|
+
const workspacePath =
|
|
49
|
+
configuredWorkspace.exitCode === 0
|
|
50
|
+
? configuredWorkspace.stdout.toString().trim()
|
|
51
|
+
: join(primaryWorktree(), ".codeless", "state");
|
|
52
|
+
if (!isAbsolute(workspacePath))
|
|
53
|
+
throw new Error("codeless.workspaceRoot must be an absolute path");
|
|
54
|
+
const workspaceRoot = canonicalPath(workspacePath);
|
|
55
|
+
|
|
56
|
+
function canonicalPath(path: string): string {
|
|
57
|
+
return existsSync(path)
|
|
58
|
+
? realpathSync(path)
|
|
59
|
+
: join(canonicalPath(dirname(path)), basename(path));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function run(command: string, args: string[], cwd: string = process.cwd()): string {
|
|
63
|
+
const child = Bun.spawnSync([command, ...args], {
|
|
64
|
+
cwd,
|
|
65
|
+
env: process.env,
|
|
66
|
+
stdin: "inherit",
|
|
67
|
+
stdout: "pipe",
|
|
68
|
+
stderr: "pipe",
|
|
69
|
+
});
|
|
70
|
+
const stdout = child.stdout.toString();
|
|
71
|
+
const stderr = child.stderr.toString();
|
|
72
|
+
if (child.exitCode !== 0) {
|
|
73
|
+
throw new Error(stderr.trim() || stdout.trim() || `${command} failed`);
|
|
74
|
+
}
|
|
75
|
+
return stdout;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function runVisible(command: string, args: string[], cwd: string = process.cwd()): void {
|
|
79
|
+
const child = Bun.spawnSync([command, ...args], {
|
|
80
|
+
cwd,
|
|
81
|
+
env: process.env,
|
|
82
|
+
stdin: "inherit",
|
|
83
|
+
stdout: "inherit",
|
|
84
|
+
stderr: "inherit",
|
|
85
|
+
});
|
|
86
|
+
if (child.exitCode !== 0) throw new Error(`${command} failed with exit code ${child.exitCode}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function succeeds(command: string, args: string[], cwd: string = process.cwd()): boolean {
|
|
90
|
+
return (
|
|
91
|
+
Bun.spawnSync([command, ...args], {
|
|
92
|
+
cwd,
|
|
93
|
+
env: process.env,
|
|
94
|
+
stdin: "ignore",
|
|
95
|
+
stdout: "ignore",
|
|
96
|
+
stderr: "ignore",
|
|
97
|
+
}).exitCode === 0
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function herdr(args: string[]): Record<string, unknown> {
|
|
102
|
+
try {
|
|
103
|
+
return JSON.parse(run("herdr", args)) as Record<string, unknown>;
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (error instanceof SyntaxError) throw new Error("Herdr returned invalid JSON");
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function id(response: Record<string, unknown>, parent: string, key: string): string {
|
|
111
|
+
const result = response["result"] as Record<string, unknown> | undefined;
|
|
112
|
+
const value = (result?.[parent] as Record<string, unknown> | undefined)?.[key];
|
|
113
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
114
|
+
throw new Error(`Herdr response omitted result.${parent}.${key}`);
|
|
115
|
+
}
|
|
116
|
+
return value;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function date(): string {
|
|
120
|
+
const now = new Date();
|
|
121
|
+
return [now.getFullYear(), now.getMonth() + 1, now.getDate()]
|
|
122
|
+
.map((part, index) => String(part).padStart(index === 0 ? 4 : 2, "0"))
|
|
123
|
+
.join("-");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function integrationWorktree(): string {
|
|
127
|
+
const entries = run("git", ["worktree", "list", "--porcelain", "-z"], repository).split("\0\0");
|
|
128
|
+
for (const entry of entries) {
|
|
129
|
+
const fields = entry.split("\0");
|
|
130
|
+
if (fields.includes(`branch refs/heads/${integrationBranch}`)) {
|
|
131
|
+
const path = fields.find((field) => field.startsWith("worktree "))?.slice(9);
|
|
132
|
+
if (path !== undefined) return path;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
throw new Error(`${integrationBranch} needs a dedicated integration worktree`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function primaryWorktree(): string {
|
|
139
|
+
const entry = run("git", ["worktree", "list", "--porcelain", "-z"], repository).split(
|
|
140
|
+
"\0\0",
|
|
141
|
+
)[0];
|
|
142
|
+
const path = entry
|
|
143
|
+
?.split("\0")
|
|
144
|
+
.find((field) => field.startsWith("worktree "))
|
|
145
|
+
?.slice(9);
|
|
146
|
+
if (path === undefined) throw new Error("Git did not report a primary worktree");
|
|
147
|
+
return canonicalPath(path);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function requireDirection(slug: string, worktree: string): string {
|
|
151
|
+
const path = join(worktree, readProject(worktree).directions, `${slug}.md`);
|
|
152
|
+
if (!existsSync(path)) throw new Error(`Missing stream direction: ${path}`);
|
|
153
|
+
return path;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function promptDirectory(worktree: string): string {
|
|
157
|
+
const directory = join(worktree, readProject(worktree).prompts);
|
|
158
|
+
for (const name of ["change", "implement", "review", "commit"]) {
|
|
159
|
+
const path = join(directory, `${name}.md`);
|
|
160
|
+
if (!existsSync(path)) throw new Error(`Missing project prompt: ${path}`);
|
|
161
|
+
}
|
|
162
|
+
return directory;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function changePrompt(slug: string, documents: string, worktree: string): string {
|
|
166
|
+
return `/change ${JSON.stringify(documents)} ${JSON.stringify(requireDirection(slug, worktree))}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function activationPrompt(prompt: string): string {
|
|
170
|
+
return `/streams-activate ${JSON.stringify(prompt)}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function createDocuments(slug: string, directory: string, worktree: string): Promise<void> {
|
|
174
|
+
const project = readProject(worktree);
|
|
175
|
+
const title = slug
|
|
176
|
+
.split("-")
|
|
177
|
+
.map((part) => part[0]!.toUpperCase() + part.slice(1))
|
|
178
|
+
.join(" ");
|
|
179
|
+
await mkdir(join(directory, "changes"), { recursive: true });
|
|
180
|
+
await writeFile(
|
|
181
|
+
join(directory, "planner.md"),
|
|
182
|
+
`# ${title} planner journal\n\n## ${date()} — Stream created\n\nThe stream was created from ${project.integrationBranch}. Direction is owned by ${project.directions}/${slug}.md. No proposal has been approved and no numbered change has been allocated.\n`,
|
|
183
|
+
{ flag: "wx" },
|
|
184
|
+
);
|
|
185
|
+
await writeFile(
|
|
186
|
+
join(directory, "change.md"),
|
|
187
|
+
"# No current proposal\n\nThe planner replaces this file when proposing the next change.\n",
|
|
188
|
+
{ flag: "wx" },
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function requireClean(directory: string, label: string): void {
|
|
193
|
+
const status = run("git", ["status", "--porcelain"], directory).trim();
|
|
194
|
+
if (status) throw new Error(`${label} is not clean:\n${status}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
type JsonObject = Record<string, unknown>;
|
|
198
|
+
|
|
199
|
+
function object(value: unknown, label: string): JsonObject {
|
|
200
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
201
|
+
throw new Error(`Herdr response omitted ${label}`);
|
|
202
|
+
}
|
|
203
|
+
return value as JsonObject;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function string(value: unknown, label: string): string {
|
|
207
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
208
|
+
throw new Error(`Herdr response omitted ${label}`);
|
|
209
|
+
}
|
|
210
|
+
return value;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function result(response: JsonObject): JsonObject {
|
|
214
|
+
return object(response["result"], "result");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function paneProcessInfo(pane: string): JsonObject {
|
|
218
|
+
return object(
|
|
219
|
+
result(herdr(["pane", "process-info", "--pane", pane]))["process_info"],
|
|
220
|
+
"result.process_info",
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function foregroundProcesses(info: JsonObject): JsonObject[] {
|
|
225
|
+
if (!Array.isArray(info["foreground_processes"])) {
|
|
226
|
+
throw new Error("Herdr response omitted result.process_info.foreground_processes");
|
|
227
|
+
}
|
|
228
|
+
return info["foreground_processes"].map((process, index) =>
|
|
229
|
+
object(process, `result["process_info"]["foreground_processes"][${index}]`),
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function isShell(process: JsonObject): boolean {
|
|
234
|
+
const argv0 = typeof process["argv0"] === "string" ? process["argv0"].replace(/^-/, "") : "";
|
|
235
|
+
return ["bash", "fish", "sh", "zsh"].includes(argv0);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function rightPane(layout: JsonObject, plannerPane: string): string | undefined {
|
|
239
|
+
if (!Array.isArray(layout["panes"]))
|
|
240
|
+
throw new Error("Herdr response omitted result.layout.panes");
|
|
241
|
+
const panes = layout["panes"].map((pane, index) =>
|
|
242
|
+
object(pane, `result["layout"]["panes"][${index}]`),
|
|
243
|
+
);
|
|
244
|
+
const planner = panes.find((pane) => pane["pane_id"] === plannerPane);
|
|
245
|
+
if (planner === undefined)
|
|
246
|
+
throw new Error(`Planner pane ${plannerPane} is absent from its layout`);
|
|
247
|
+
const plannerRect = object(planner["rect"], `layout rect for ${plannerPane}`);
|
|
248
|
+
const rightEdge = Number(plannerRect["x"]) + Number(plannerRect["width"]);
|
|
249
|
+
const top = Number(plannerRect["y"]);
|
|
250
|
+
const bottom = top + Number(plannerRect["height"]);
|
|
251
|
+
const candidates = panes
|
|
252
|
+
.filter((pane) => pane["pane_id"] !== plannerPane)
|
|
253
|
+
.map((pane) => ({
|
|
254
|
+
pane,
|
|
255
|
+
rect: object(pane["rect"], `layout rect for ${string(pane["pane_id"], "pane ID")}`),
|
|
256
|
+
}))
|
|
257
|
+
.filter(({ rect }) => {
|
|
258
|
+
const candidateTop = Number(rect["y"]);
|
|
259
|
+
const candidateBottom = candidateTop + Number(rect["height"]);
|
|
260
|
+
return Number(rect["x"]) >= rightEdge && candidateTop < bottom && candidateBottom > top;
|
|
261
|
+
})
|
|
262
|
+
.sort((left, right) => Number(left["rect"]["x"]) - Number(right["rect"]["x"]));
|
|
263
|
+
return candidates.length === 0
|
|
264
|
+
? undefined
|
|
265
|
+
: string(candidates[0]!.pane["pane_id"], "right-hand pane id");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function requirePaneShell(pane: string, worktree: string): void {
|
|
269
|
+
for (let attempt = 0; attempt < 30; attempt += 1) {
|
|
270
|
+
const info = paneProcessInfo(pane);
|
|
271
|
+
const processes = foregroundProcesses(info);
|
|
272
|
+
if (processes.length === 1 && isShell(processes[0]!)) {
|
|
273
|
+
const cwd = string(processes[0]!["cwd"], `shell cwd in ${pane}`);
|
|
274
|
+
if (canonicalPath(cwd) !== worktree) {
|
|
275
|
+
throw new Error(`Right-hand shell ${pane} is in ${cwd}, expected ${worktree}`);
|
|
276
|
+
}
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (attempt < 29) Bun.sleepSync(100);
|
|
280
|
+
}
|
|
281
|
+
throw new Error(`Pane ${pane} did not return to an available shell`);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function requireChange(changeArgument: string) {
|
|
285
|
+
const change = canonicalPath(resolve(changeArgument.replace(/^@/, "")));
|
|
286
|
+
const streamRoot = join(workspaceRoot, "stream");
|
|
287
|
+
const match = relative(streamRoot, change).match(
|
|
288
|
+
/^([a-z][a-z0-9-]{0,23})\/changes\/(\d{3})\.md$/,
|
|
289
|
+
);
|
|
290
|
+
if (match === null || !existsSync(change)) {
|
|
291
|
+
throw new Error(`Expected an existing numbered change under ${streamRoot}`);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const slug = match[1]!;
|
|
295
|
+
const worktree = join(workspaceRoot, "worktree", slug);
|
|
296
|
+
return {
|
|
297
|
+
change,
|
|
298
|
+
slug,
|
|
299
|
+
number: match[2]!,
|
|
300
|
+
worktree,
|
|
301
|
+
documents: join(streamRoot, slug),
|
|
302
|
+
branch: `stream/${slug}`,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function observe(event: string, collect: () => void): void {
|
|
307
|
+
try {
|
|
308
|
+
collect();
|
|
309
|
+
} catch (error) {
|
|
310
|
+
console.error(
|
|
311
|
+
`codeless: warning: could not collect ${event}: ${error instanceof Error ? error.message : String(error)}`,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function latestChangeNumber(slug: string): string {
|
|
317
|
+
const change = readdirSync(join(workspaceRoot, "stream", slug, "changes"))
|
|
318
|
+
.filter((file) => /^\d{3}\.md$/.test(file))
|
|
319
|
+
.sort()
|
|
320
|
+
.at(-1);
|
|
321
|
+
if (change === undefined) throw new Error(`Missing numbered change for ${slug}`);
|
|
322
|
+
return basename(change, ".md");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function nextChange(changeArgument: string, landedCommit: string): Promise<void> {
|
|
326
|
+
const { change, slug, worktree, documents, branch } = requireChange(changeArgument);
|
|
327
|
+
if (canonicalPath(process.cwd()) !== worktree) {
|
|
328
|
+
throw new Error(`Next-loop cwd is ${process.cwd()}, expected ${worktree}`);
|
|
329
|
+
}
|
|
330
|
+
if (run("git", ["branch", "--show-current"], worktree).trim() !== branch) {
|
|
331
|
+
throw new Error(`${worktree} is not on ${branch}`);
|
|
332
|
+
}
|
|
333
|
+
requireClean(worktree, branch);
|
|
334
|
+
const latest = readdirSync(join(documents, "changes"))
|
|
335
|
+
.filter((file) => /^\d{3}\.md$/.test(file))
|
|
336
|
+
.sort()
|
|
337
|
+
.at(-1);
|
|
338
|
+
if (basename(change) !== latest)
|
|
339
|
+
throw new Error("Only the latest approved change can start the next loop");
|
|
340
|
+
if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(landedCommit)) {
|
|
341
|
+
throw new Error("Supply the full landed commit hash");
|
|
342
|
+
}
|
|
343
|
+
const journal = join(documents, "planner.md");
|
|
344
|
+
if (!readFileSync(journal, "utf8").includes(landedCommit)) {
|
|
345
|
+
throw new Error(`Record the landed commit in ${journal} before starting the next loop`);
|
|
346
|
+
}
|
|
347
|
+
const lock = join(workspaceRoot, ".land-lock");
|
|
348
|
+
if (existsSync(lock)) {
|
|
349
|
+
const ownerFile = join(lock, "owner");
|
|
350
|
+
const owner = existsSync(ownerFile) ? readFileSync(ownerFile, "utf8").trim() : "";
|
|
351
|
+
if (!owner || owner === slug) {
|
|
352
|
+
throw new Error(`Integration slot ${lock} still needs resolution before the next loop`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
const base = run("git", ["rev-parse", integrationBranch], worktree).trim();
|
|
356
|
+
if (!succeeds("git", ["merge-base", "--is-ancestor", landedCommit, "HEAD"], worktree)) {
|
|
357
|
+
throw new Error(`${landedCommit} is not included in this stream`);
|
|
358
|
+
}
|
|
359
|
+
if (!succeeds("git", ["merge-base", "--is-ancestor", "HEAD", base], worktree)) {
|
|
360
|
+
throw new Error(
|
|
361
|
+
`${branch} has unlanded or diverged commits; finish landing before the next loop`,
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
run("git", ["merge", "--ff-only", base], worktree);
|
|
365
|
+
requireClean(worktree, branch);
|
|
366
|
+
promptDirectory(worktree);
|
|
367
|
+
const selection = readProject(worktree).planner;
|
|
368
|
+
await validateRoleSelection("planner", selection, worktree, plannerExtension);
|
|
369
|
+
console.log(
|
|
370
|
+
JSON.stringify({
|
|
371
|
+
sessionName: `${slug}-planner`,
|
|
372
|
+
prompt: changePrompt(slug, documents, worktree),
|
|
373
|
+
selection,
|
|
374
|
+
}),
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function proposal(content: string): { title: string; hash: string } {
|
|
379
|
+
const title = /^# ([^\r\n]+)\r?\n/.exec(content)?.[1]?.trim();
|
|
380
|
+
const headings = ["Why", "Change", "Acceptance", "Decisions"].map((heading) => {
|
|
381
|
+
const matches = [...content.matchAll(new RegExp(`^## ${heading}\\s*$`, "gm"))];
|
|
382
|
+
return matches.length === 1 ? matches[0]!.index! : -1;
|
|
383
|
+
});
|
|
384
|
+
if (
|
|
385
|
+
!title ||
|
|
386
|
+
title.toLowerCase() === "no current proposal" ||
|
|
387
|
+
headings.some((position) => position < 0) ||
|
|
388
|
+
headings.some((position, index) => index > 0 && position < headings[index - 1]!)
|
|
389
|
+
) {
|
|
390
|
+
throw new Error("change.md must contain one title and the required proposal headings");
|
|
391
|
+
}
|
|
392
|
+
return { title, hash: createHash("sha256").update(content).digest("hex") };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
type Approval = { number: string; title: string; hash: string };
|
|
396
|
+
|
|
397
|
+
function approvalEntry(approval: Approval): string {
|
|
398
|
+
return `## ${date()} — Change approved\n\nApproved \`changes/${approval.number}.md\` — “${approval.title}” (\`sha256:${approval.hash}\`).\n`;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function approvals(journal: string): Approval[] {
|
|
402
|
+
const header = /^## \d{4}-\d{2}-\d{2} — Change approved$/gm;
|
|
403
|
+
const entry =
|
|
404
|
+
/^## \d{4}-\d{2}-\d{2} — Change approved\n\nApproved `changes\/(\d{3})\.md` — “([^”\n]+)” \(`sha256:([0-9a-f]{64})`\)\.\n/;
|
|
405
|
+
const records: Approval[] = [];
|
|
406
|
+
for (const match of journal.matchAll(header)) {
|
|
407
|
+
const record = entry.exec(journal.slice(match.index));
|
|
408
|
+
if (record === null) throw new Error("planner.md has a malformed approval entry");
|
|
409
|
+
records.push({ number: record[1]!, title: record[2]!, hash: record[3]! });
|
|
410
|
+
}
|
|
411
|
+
return records;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function approve(sessionName: string): void {
|
|
415
|
+
if (process.env["HERDR_ENV"] !== "1")
|
|
416
|
+
throw new Error("Approval must run from a Herdr-managed planner");
|
|
417
|
+
const worktree = canonicalPath(process.cwd());
|
|
418
|
+
const match = relative(join(workspaceRoot, "worktree"), worktree).match(
|
|
419
|
+
/^([a-z][a-z0-9-]{0,23})$/,
|
|
420
|
+
);
|
|
421
|
+
if (match === null) throw new Error(`Approval cwd is ${process.cwd()}, not a stream worktree`);
|
|
422
|
+
const slug = match[1]!;
|
|
423
|
+
if (sessionName !== `${slug}-planner`)
|
|
424
|
+
throw new Error(`Approval Pi session ${sessionName} does not match ${slug}-planner`);
|
|
425
|
+
const branch = `stream/${slug}`;
|
|
426
|
+
if (run("git", ["branch", "--show-current"], worktree).trim() !== branch)
|
|
427
|
+
throw new Error(`${worktree} is not on ${branch}`);
|
|
428
|
+
requireClean(worktree, branch);
|
|
429
|
+
if (
|
|
430
|
+
run("git", ["rev-parse", "HEAD"], worktree).trim() !==
|
|
431
|
+
run("git", ["rev-parse", integrationBranch], worktree).trim()
|
|
432
|
+
)
|
|
433
|
+
throw new Error(`${branch} is not at the current ${integrationBranch} baseline`);
|
|
434
|
+
|
|
435
|
+
const plannerPane = string(process.env["HERDR_PANE_ID"], "HERDR_PANE_ID");
|
|
436
|
+
const agent = object(result(herdr(["agent", "get", plannerPane]))["agent"], "result.agent");
|
|
437
|
+
const expectedPlanner = `${slug.replaceAll("-", "_")}_planner`;
|
|
438
|
+
if (string(agent["name"], "result.agent.name") !== expectedPlanner)
|
|
439
|
+
throw new Error(`Approval must run from planner ${expectedPlanner}`);
|
|
440
|
+
const processes = foregroundProcesses(paneProcessInfo(plannerPane));
|
|
441
|
+
if (
|
|
442
|
+
!processes.some(
|
|
443
|
+
(process) => canonicalPath(string(process["cwd"], "planner cwd")) === worktree,
|
|
444
|
+
)
|
|
445
|
+
)
|
|
446
|
+
throw new Error(`Planner pane ${plannerPane} is not running in ${worktree}`);
|
|
447
|
+
|
|
448
|
+
const documents = join(workspaceRoot, "stream", slug);
|
|
449
|
+
const proposalPath = join(documents, "change.md");
|
|
450
|
+
const journalPath = join(documents, "planner.md");
|
|
451
|
+
const changes = join(documents, "changes");
|
|
452
|
+
const current = proposal(readFileSync(proposalPath, "utf8"));
|
|
453
|
+
if (current.title.includes("”"))
|
|
454
|
+
throw new Error("change.md title cannot contain a closing quotation mark");
|
|
455
|
+
const journal = readFileSync(journalPath, "utf8");
|
|
456
|
+
const recorded = approvals(journal);
|
|
457
|
+
const files = readdirSync(changes).filter((file) => /^\d{3}\.md$/.test(file));
|
|
458
|
+
|
|
459
|
+
for (const record of recorded) {
|
|
460
|
+
const path = join(changes, `${record.number}.md`);
|
|
461
|
+
if (!existsSync(path)) {
|
|
462
|
+
if (record.hash !== current.hash || record.title !== current.title)
|
|
463
|
+
throw new Error(
|
|
464
|
+
`planner.md approval for changes/${record.number}.md has no approved file`,
|
|
465
|
+
);
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
const approved = proposal(readFileSync(path, "utf8"));
|
|
469
|
+
if (approved.hash !== record.hash || approved.title !== record.title)
|
|
470
|
+
throw new Error(`planner.md approval conflicts with changes/${record.number}.md`);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const matchingRecords = recorded.filter((record) => record.hash === current.hash);
|
|
474
|
+
if (
|
|
475
|
+
matchingRecords.some((record) => record.title !== current.title) ||
|
|
476
|
+
matchingRecords.length > 1
|
|
477
|
+
)
|
|
478
|
+
throw new Error("planner.md has an ambiguous approval for change.md");
|
|
479
|
+
const matchingFiles = files.filter(
|
|
480
|
+
(file) =>
|
|
481
|
+
createHash("sha256")
|
|
482
|
+
.update(readFileSync(join(changes, file), "utf8"))
|
|
483
|
+
.digest("hex") === current.hash,
|
|
484
|
+
);
|
|
485
|
+
if (matchingFiles.length > 1) throw new Error("Multiple approved files match change.md");
|
|
486
|
+
|
|
487
|
+
let approval: Approval;
|
|
488
|
+
if (matchingRecords.length === 1) {
|
|
489
|
+
approval = matchingRecords[0]!;
|
|
490
|
+
if (matchingFiles.length === 1 && matchingFiles[0] !== `${approval.number}.md`)
|
|
491
|
+
throw new Error("planner.md approval conflicts with the approved change file");
|
|
492
|
+
const path = join(changes, `${approval.number}.md`);
|
|
493
|
+
if (!existsSync(path)) writeFileSync(path, readFileSync(proposalPath), { flag: "wx" });
|
|
494
|
+
} else if (matchingFiles.length === 1) {
|
|
495
|
+
const number = basename(matchingFiles[0]!, ".md");
|
|
496
|
+
if (`${number}.md` !== files.sort().at(-1))
|
|
497
|
+
throw new Error("Approved file matching change.md is ambiguous");
|
|
498
|
+
approval = { number, ...current };
|
|
499
|
+
appendFileSync(
|
|
500
|
+
journalPath,
|
|
501
|
+
`${journal.endsWith("\n") ? "\n" : "\n\n"}${approvalEntry(approval)}`,
|
|
502
|
+
);
|
|
503
|
+
} else {
|
|
504
|
+
const greatest = files
|
|
505
|
+
.map((file) => Number(basename(file, ".md")))
|
|
506
|
+
.reduce((max, number) => Math.max(max, number), 0);
|
|
507
|
+
if (greatest >= 999) throw new Error("Cannot approve another change after 999");
|
|
508
|
+
approval = { number: String(greatest + 1).padStart(3, "0"), ...current };
|
|
509
|
+
writeFileSync(join(changes, `${approval.number}.md`), readFileSync(proposalPath), {
|
|
510
|
+
flag: "wx",
|
|
511
|
+
});
|
|
512
|
+
appendFileSync(
|
|
513
|
+
journalPath,
|
|
514
|
+
`${journal.endsWith("\n") ? "\n" : "\n\n"}${approvalEntry(approval)}`,
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
console.log(
|
|
518
|
+
JSON.stringify({
|
|
519
|
+
number: approval.number,
|
|
520
|
+
changePath: join(changes, `${approval.number}.md`),
|
|
521
|
+
title: approval.title,
|
|
522
|
+
}),
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
async function dispatch(changeArgument: string): Promise<void> {
|
|
527
|
+
if (process.env["HERDR_ENV"] !== "1")
|
|
528
|
+
throw new Error("Dispatch must run from a Herdr-managed planner");
|
|
529
|
+
|
|
530
|
+
const { change, slug, number, worktree, branch } = requireChange(changeArgument);
|
|
531
|
+
const implementerName = `${slug.replaceAll("-", "_")}_impl`;
|
|
532
|
+
if (canonicalPath(process.cwd()) !== worktree) {
|
|
533
|
+
throw new Error(`Dispatch cwd is ${process.cwd()}, expected ${worktree}`);
|
|
534
|
+
}
|
|
535
|
+
if (run("git", ["branch", "--show-current"], worktree).trim() !== branch) {
|
|
536
|
+
throw new Error(`${worktree} is not on ${branch}`);
|
|
537
|
+
}
|
|
538
|
+
requireClean(worktree, branch);
|
|
539
|
+
const prompts = promptDirectory(worktree);
|
|
540
|
+
const selection = readProject(worktree).implementer;
|
|
541
|
+
observe("dispatch metrics", () => recordDispatch(workspaceRoot, slug, number));
|
|
542
|
+
await validateRoleSelection("implementer", selection, worktree);
|
|
543
|
+
console.log(roleSelectionSummary("implementer", selection));
|
|
544
|
+
|
|
545
|
+
const plannerPane = string(process.env["HERDR_PANE_ID"], "HERDR_PANE_ID");
|
|
546
|
+
const plannerProcesses = foregroundProcesses(paneProcessInfo(plannerPane));
|
|
547
|
+
if (
|
|
548
|
+
!plannerProcesses.some(
|
|
549
|
+
(process) => canonicalPath(string(process["cwd"], "planner cwd")) === worktree,
|
|
550
|
+
)
|
|
551
|
+
) {
|
|
552
|
+
throw new Error(`Planner pane ${plannerPane} is not running in ${worktree}`);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const layout = object(
|
|
556
|
+
result(herdr(["pane", "layout", "--pane", plannerPane]))["layout"],
|
|
557
|
+
"result.layout",
|
|
558
|
+
);
|
|
559
|
+
let implementerPane = rightPane(layout, plannerPane);
|
|
560
|
+
if (implementerPane === undefined) {
|
|
561
|
+
const split = herdr([
|
|
562
|
+
"pane",
|
|
563
|
+
"split",
|
|
564
|
+
"--pane",
|
|
565
|
+
plannerPane,
|
|
566
|
+
"--direction",
|
|
567
|
+
"right",
|
|
568
|
+
"--ratio",
|
|
569
|
+
"0.5",
|
|
570
|
+
"--cwd",
|
|
571
|
+
worktree,
|
|
572
|
+
"--no-focus",
|
|
573
|
+
]);
|
|
574
|
+
implementerPane = id(split, "pane", "pane_id");
|
|
575
|
+
} else {
|
|
576
|
+
const info = paneProcessInfo(implementerPane);
|
|
577
|
+
const processes = foregroundProcesses(info);
|
|
578
|
+
if (!(processes.length === 1 && isShell(processes[0]!))) {
|
|
579
|
+
const occupant = object(
|
|
580
|
+
result(herdr(["agent", "get", implementerPane]))["agent"],
|
|
581
|
+
"result.agent",
|
|
582
|
+
);
|
|
583
|
+
const occupantName = string(occupant["name"], "result.agent.name");
|
|
584
|
+
const occupantStatus = string(occupant["agent_status"], "result.agent.agent_status");
|
|
585
|
+
if (occupantName !== implementerName || !["idle", "done"].includes(occupantStatus)) {
|
|
586
|
+
throw new Error(
|
|
587
|
+
`Right-hand pane ${implementerPane} is occupied by ${occupantName} in ${occupantStatus} state`,
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
herdr(["agent", "send-keys", implementerName, "ctrl+d"]);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
requirePaneShell(implementerPane, worktree);
|
|
594
|
+
|
|
595
|
+
const started = herdr([
|
|
596
|
+
"agent",
|
|
597
|
+
"start",
|
|
598
|
+
implementerName,
|
|
599
|
+
"--kind",
|
|
600
|
+
"pi",
|
|
601
|
+
"--pane",
|
|
602
|
+
implementerPane,
|
|
603
|
+
"--",
|
|
604
|
+
"--no-session",
|
|
605
|
+
"--name",
|
|
606
|
+
`${slug}-impl`,
|
|
607
|
+
...roleSelectionArguments(selection),
|
|
608
|
+
"--prompt-template",
|
|
609
|
+
prompts,
|
|
610
|
+
"--approve",
|
|
611
|
+
]);
|
|
612
|
+
const startedAgent = object(result(started)["agent"], "result.agent");
|
|
613
|
+
const startedCwd = string(
|
|
614
|
+
startedAgent["foreground_cwd"] ?? startedAgent["cwd"],
|
|
615
|
+
"started agent cwd",
|
|
616
|
+
);
|
|
617
|
+
if (canonicalPath(startedCwd) !== worktree) {
|
|
618
|
+
throw new Error(`${implementerName} started in ${startedCwd}, expected ${worktree}`);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const completed = run("herdr", [
|
|
622
|
+
"agent",
|
|
623
|
+
"prompt",
|
|
624
|
+
implementerName,
|
|
625
|
+
`/implement ${JSON.stringify(change)}`,
|
|
626
|
+
"--wait",
|
|
627
|
+
"--timeout",
|
|
628
|
+
"3600000",
|
|
629
|
+
]);
|
|
630
|
+
console.log(completed.trim());
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
async function launchPlanner(slug: string): Promise<void> {
|
|
634
|
+
if (process.env["HERDR_ENV"] !== "1") {
|
|
635
|
+
throw new Error("Run codeless planner from the stream's Herdr-managed shell");
|
|
636
|
+
}
|
|
637
|
+
const documents = join(workspaceRoot, "stream", slug);
|
|
638
|
+
const worktree = join(workspaceRoot, "worktree", slug);
|
|
639
|
+
const branch = `stream/${slug}`;
|
|
640
|
+
for (const file of ["planner.md", "change.md"]) {
|
|
641
|
+
if (!existsSync(join(documents, file))) throw new Error(`Missing ${join(documents, file)}`);
|
|
642
|
+
}
|
|
643
|
+
if (canonicalPath(process.cwd()) !== worktree) {
|
|
644
|
+
throw new Error(`Planner shell is in ${process.cwd()}, expected ${worktree}`);
|
|
645
|
+
}
|
|
646
|
+
if (run("git", ["branch", "--show-current"], worktree).trim() !== branch) {
|
|
647
|
+
throw new Error(`${worktree} is not on ${branch}`);
|
|
648
|
+
}
|
|
649
|
+
const prompt = changePrompt(slug, documents, worktree);
|
|
650
|
+
const selection = readProject(worktree).planner;
|
|
651
|
+
await validateRoleSelection("planner", selection, worktree, plannerExtension);
|
|
652
|
+
console.log(roleSelectionSummary("planner", selection));
|
|
653
|
+
runVisible(
|
|
654
|
+
"pi",
|
|
655
|
+
[
|
|
656
|
+
"--name",
|
|
657
|
+
`${slug}-planner`,
|
|
658
|
+
...roleSelectionArguments(selection),
|
|
659
|
+
"--extension",
|
|
660
|
+
plannerExtension,
|
|
661
|
+
"--prompt-template",
|
|
662
|
+
promptDirectory(worktree),
|
|
663
|
+
"--approve",
|
|
664
|
+
activationPrompt(prompt),
|
|
665
|
+
],
|
|
666
|
+
worktree,
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function acquireLandSlot(lock: string, slug: string, base: string): "acquired" | "resumed" {
|
|
671
|
+
let acquired = false;
|
|
672
|
+
try {
|
|
673
|
+
mkdirSync(lock);
|
|
674
|
+
acquired = true;
|
|
675
|
+
} catch {
|
|
676
|
+
if (!existsSync(lock)) throw new Error(`Could not create integration slot ${lock}`);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const ownerFile = join(lock, "owner");
|
|
680
|
+
const baseFile = join(lock, "base");
|
|
681
|
+
if (acquired) {
|
|
682
|
+
writeFileSync(ownerFile, `${slug}\n`, { flag: "wx" });
|
|
683
|
+
writeFileSync(baseFile, `${base}\n`, { flag: "wx" });
|
|
684
|
+
return "acquired";
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const owner = existsSync(ownerFile) ? readFileSync(ownerFile, "utf8").trim() : "";
|
|
688
|
+
if (owner !== slug) {
|
|
689
|
+
throw new Error(
|
|
690
|
+
owner
|
|
691
|
+
? `Integration slot is held by ${owner}; ${slug} remains committed and must try again later`
|
|
692
|
+
: `Integration slot ${lock} has no owner; inspect it manually`,
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const recordedBase = existsSync(baseFile) ? readFileSync(baseFile, "utf8").trim() : "";
|
|
697
|
+
if (recordedBase !== base) {
|
|
698
|
+
throw new Error(
|
|
699
|
+
`Integration slot for ${slug} recorded ${integrationBranch} at ${recordedBase || "<missing>"}, but ${integrationBranch} is now ${base}; inspect it manually`,
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
return "resumed";
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function releaseLandSlot(lock: string, slug: string): void {
|
|
706
|
+
const ownerFile = join(lock, "owner");
|
|
707
|
+
const owner = existsSync(ownerFile) ? readFileSync(ownerFile, "utf8").trim() : "";
|
|
708
|
+
if (owner !== slug)
|
|
709
|
+
throw new Error(`Refusing to release integration slot owned by ${owner || "<unknown>"}`);
|
|
710
|
+
for (const name of ["owner", "base"]) {
|
|
711
|
+
const file = join(lock, name);
|
|
712
|
+
if (existsSync(file)) unlinkSync(file);
|
|
713
|
+
}
|
|
714
|
+
rmdirSync(lock);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function land(slug: string): void {
|
|
718
|
+
const worktree = join(workspaceRoot, "worktree", slug);
|
|
719
|
+
const integrationCheckout = integrationWorktree();
|
|
720
|
+
const branch = `stream/${slug}`;
|
|
721
|
+
const lock = join(workspaceRoot, ".land-lock");
|
|
722
|
+
|
|
723
|
+
if (!existsSync(worktree)) throw new Error(`Missing ${worktree}`);
|
|
724
|
+
if (!existsSync(integrationCheckout)) throw new Error(`Missing ${integrationCheckout}`);
|
|
725
|
+
if (run("git", ["branch", "--show-current"], worktree).trim() !== branch) {
|
|
726
|
+
throw new Error(`${worktree} is not on ${branch}`);
|
|
727
|
+
}
|
|
728
|
+
if (
|
|
729
|
+
run("git", ["branch", "--show-current"], integrationCheckout).trim() !== integrationBranch
|
|
730
|
+
) {
|
|
731
|
+
throw new Error(`${integrationCheckout} is not on ${integrationBranch}`);
|
|
732
|
+
}
|
|
733
|
+
requireClean(worktree, branch);
|
|
734
|
+
|
|
735
|
+
requireClean(integrationCheckout, `${integrationBranch} integration worktree`);
|
|
736
|
+
const mergeBase = run("git", ["merge-base", integrationBranch, branch], repository).trim();
|
|
737
|
+
const streamCommits = Number(
|
|
738
|
+
run("git", ["rev-list", "--count", `${mergeBase}..${branch}`], repository).trim(),
|
|
739
|
+
);
|
|
740
|
+
if (streamCommits !== 1) {
|
|
741
|
+
throw new Error(`${branch} must contain exactly one change commit, found ${streamCommits}`);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const base = run("git", ["rev-parse", integrationBranch], repository).trim();
|
|
745
|
+
const slot = acquireLandSlot(lock, slug, base);
|
|
746
|
+
console.log(
|
|
747
|
+
`${slot === "acquired" ? "Acquired" : "Resumed"} integration slot for ${slug} at ${base}`,
|
|
748
|
+
);
|
|
749
|
+
|
|
750
|
+
try {
|
|
751
|
+
if (
|
|
752
|
+
!succeeds("git", ["merge-base", "--is-ancestor", integrationBranch, branch], repository)
|
|
753
|
+
) {
|
|
754
|
+
console.log(`Rebasing ${branch} onto current ${integrationBranch}...`);
|
|
755
|
+
runVisible("git", ["rebase", integrationBranch], worktree);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
if (
|
|
759
|
+
run(
|
|
760
|
+
"git",
|
|
761
|
+
["rev-list", "--count", `${integrationBranch}..${branch}`],
|
|
762
|
+
repository,
|
|
763
|
+
).trim() !== "1"
|
|
764
|
+
) {
|
|
765
|
+
throw new Error(
|
|
766
|
+
`${branch} is not exactly one commit ahead of ${integrationBranch} after rebase`,
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
console.log("Running project checks...");
|
|
771
|
+
const [command, ...args] = readProject(worktree).check;
|
|
772
|
+
runVisible(command, args, worktree);
|
|
773
|
+
requireClean(worktree, branch);
|
|
774
|
+
run("git", ["merge", "--ff-only", branch], integrationCheckout);
|
|
775
|
+
const commit = run("git", ["rev-parse", integrationBranch], repository).trim();
|
|
776
|
+
observe("landing metrics", () =>
|
|
777
|
+
recordLanding(workspaceRoot, slug, latestChangeNumber(slug), commit),
|
|
778
|
+
);
|
|
779
|
+
releaseLandSlot(lock, slug);
|
|
780
|
+
console.log(`Landed ${branch} on ${integrationBranch} at ${commit}`);
|
|
781
|
+
} catch (error) {
|
|
782
|
+
throw new Error(
|
|
783
|
+
`${error instanceof Error ? error.message : String(error)}\nIntegration slot remains held by ${slug}; resolve the problem in its worktree, then run codeless land ${slug} again`,
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
if (action === "metrics") {
|
|
789
|
+
if (target !== undefined || details.length > 0) throw new Error(usage);
|
|
790
|
+
for (const line of metricReport(workspaceRoot)) console.log(line);
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
if (action === "approve") {
|
|
794
|
+
if (target === undefined || details.length > 0) throw new Error(usage);
|
|
795
|
+
approve(target);
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
if (action === "dispatch") {
|
|
799
|
+
if (target === undefined || details.length > 0) throw new Error(usage);
|
|
800
|
+
await dispatch(target);
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
if (action === "next") {
|
|
804
|
+
if (target === undefined || details.length !== 1) throw new Error(usage);
|
|
805
|
+
await nextChange(target, details[0]!);
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
const slug = target;
|
|
809
|
+
if (
|
|
810
|
+
(action !== "create" && action !== "open" && action !== "planner" && action !== "land") ||
|
|
811
|
+
slug === undefined ||
|
|
812
|
+
!/^[a-z][a-z0-9-]{0,23}$/.test(slug) ||
|
|
813
|
+
details.length > 0
|
|
814
|
+
) {
|
|
815
|
+
throw new Error(`${usage}\n\nSlug must be lowercase kebab-case and at most 24 characters.`);
|
|
816
|
+
}
|
|
817
|
+
if (action === "land") {
|
|
818
|
+
land(slug);
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
if (action === "planner") {
|
|
822
|
+
await launchPlanner(slug);
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
if (process.env["HERDR_ENV"] !== "1") {
|
|
826
|
+
throw new Error("Run codeless from a Herdr-managed shell pane");
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const documents = join(workspaceRoot, "stream", slug);
|
|
830
|
+
const worktree = join(workspaceRoot, "worktree", slug);
|
|
831
|
+
const branch = `stream/${slug}`;
|
|
832
|
+
let opened: Record<string, unknown>;
|
|
833
|
+
if (action === "open") {
|
|
834
|
+
requireDirection(slug, worktree);
|
|
835
|
+
promptDirectory(worktree);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
if (action === "create") {
|
|
839
|
+
if (existsSync(documents) || existsSync(worktree)) {
|
|
840
|
+
throw new Error(`Stream already exists: ${slug}`);
|
|
841
|
+
}
|
|
842
|
+
requireDirection(slug, integrationWorktree());
|
|
843
|
+
promptDirectory(integrationWorktree());
|
|
844
|
+
run("git", ["show-ref", "--verify", `refs/heads/${integrationBranch}`], repository);
|
|
845
|
+
opened = herdr([
|
|
846
|
+
"worktree",
|
|
847
|
+
"create",
|
|
848
|
+
"--cwd",
|
|
849
|
+
repository,
|
|
850
|
+
"--branch",
|
|
851
|
+
branch,
|
|
852
|
+
"--base",
|
|
853
|
+
integrationBranch,
|
|
854
|
+
"--path",
|
|
855
|
+
worktree,
|
|
856
|
+
"--label",
|
|
857
|
+
slug,
|
|
858
|
+
"--no-focus",
|
|
859
|
+
]);
|
|
860
|
+
await createDocuments(slug, documents, worktree);
|
|
861
|
+
} else {
|
|
862
|
+
for (const file of ["planner.md", "change.md"]) {
|
|
863
|
+
if (!existsSync(join(documents, file))) throw new Error(`Missing ${join(documents, file)}`);
|
|
864
|
+
}
|
|
865
|
+
if (!existsSync(worktree)) throw new Error(`Missing ${worktree}`);
|
|
866
|
+
const actualBranch = run("git", ["branch", "--show-current"], worktree).trim();
|
|
867
|
+
if (actualBranch !== branch)
|
|
868
|
+
throw new Error(`${worktree} is on ${actualBranch}, expected ${branch}`);
|
|
869
|
+
opened = herdr(["worktree", "open", "--path", worktree, "--label", slug, "--no-focus"]);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
console.log("Installing project dependencies...");
|
|
873
|
+
const [install, ...installArgs] = readProject(worktree).install;
|
|
874
|
+
run(install, installArgs, worktree);
|
|
875
|
+
|
|
876
|
+
const selection = readProject(worktree).planner;
|
|
877
|
+
await validateRoleSelection("planner", selection, worktree, plannerExtension);
|
|
878
|
+
console.log(roleSelectionSummary("planner", selection));
|
|
879
|
+
|
|
880
|
+
const workspace = id(opened, "workspace", "workspace_id");
|
|
881
|
+
const plannerPane = id(opened, "root_pane", "pane_id");
|
|
882
|
+
const split = herdr([
|
|
883
|
+
"pane",
|
|
884
|
+
"split",
|
|
885
|
+
"--pane",
|
|
886
|
+
plannerPane,
|
|
887
|
+
"--direction",
|
|
888
|
+
"right",
|
|
889
|
+
"--ratio",
|
|
890
|
+
"0.5",
|
|
891
|
+
"--cwd",
|
|
892
|
+
worktree,
|
|
893
|
+
"--no-focus",
|
|
894
|
+
]);
|
|
895
|
+
id(split, "pane", "pane_id");
|
|
896
|
+
|
|
897
|
+
const planner = `${slug.replaceAll("-", "_")}_planner`;
|
|
898
|
+
run("herdr", [
|
|
899
|
+
"agent",
|
|
900
|
+
"start",
|
|
901
|
+
planner,
|
|
902
|
+
"--kind",
|
|
903
|
+
"pi",
|
|
904
|
+
"--pane",
|
|
905
|
+
plannerPane,
|
|
906
|
+
"--",
|
|
907
|
+
"--name",
|
|
908
|
+
`${slug}-planner`,
|
|
909
|
+
...roleSelectionArguments(selection),
|
|
910
|
+
"--extension",
|
|
911
|
+
plannerExtension,
|
|
912
|
+
"--prompt-template",
|
|
913
|
+
promptDirectory(worktree),
|
|
914
|
+
"--approve",
|
|
915
|
+
]);
|
|
916
|
+
run("herdr", [
|
|
917
|
+
"agent",
|
|
918
|
+
"prompt",
|
|
919
|
+
planner,
|
|
920
|
+
activationPrompt(changePrompt(slug, documents, worktree)),
|
|
921
|
+
]);
|
|
922
|
+
run("herdr", ["workspace", "focus", workspace]);
|
|
923
|
+
console.log(`Opened ${slug}: planner on the left, implementer shell on the right.`);
|
|
924
|
+
}
|