@dpeek/codeless 0.1.0 → 0.1.2
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 +80 -15
- package/extension/implementer-reporting.js +215 -0
- package/extension/planner.js +106 -2
- package/package.json +2 -2
- package/spec/workflow.md +68 -27
- package/src/attempt.ts +104 -0
- package/src/cli.ts +340 -25
- package/src/metrics.ts +148 -5
package/src/attempt.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
export type Attempt = {
|
|
2
|
+
id: string;
|
|
3
|
+
stream: string;
|
|
4
|
+
change: string;
|
|
5
|
+
role: "implementer";
|
|
6
|
+
kind: "initial" | "rework";
|
|
7
|
+
startedAt: string;
|
|
8
|
+
endedAt: string;
|
|
9
|
+
selection?: { provider: string; model: string; thinking: string };
|
|
10
|
+
outcome: string;
|
|
11
|
+
text?: string;
|
|
12
|
+
usage?: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
|
13
|
+
cost?: { amount: number; currency: string; source: string };
|
|
14
|
+
toolCalls: number;
|
|
15
|
+
errorCount: number;
|
|
16
|
+
incomplete: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function validAttempt(value: unknown, stream: string, change: string): value is Attempt {
|
|
20
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
21
|
+
const attempt = value as Record<string, unknown>;
|
|
22
|
+
const allowed = new Set([
|
|
23
|
+
"id",
|
|
24
|
+
"stream",
|
|
25
|
+
"change",
|
|
26
|
+
"role",
|
|
27
|
+
"kind",
|
|
28
|
+
"startedAt",
|
|
29
|
+
"endedAt",
|
|
30
|
+
"selection",
|
|
31
|
+
"outcome",
|
|
32
|
+
"text",
|
|
33
|
+
"usage",
|
|
34
|
+
"cost",
|
|
35
|
+
"toolCalls",
|
|
36
|
+
"errorCount",
|
|
37
|
+
"incomplete",
|
|
38
|
+
]);
|
|
39
|
+
const strings = ["id", "stream", "change", "role", "kind", "startedAt", "endedAt", "outcome"];
|
|
40
|
+
if (
|
|
41
|
+
!Object.keys(attempt).every((key) => allowed.has(key)) ||
|
|
42
|
+
!strings.every((key) => typeof attempt[key] === "string" && attempt[key].length > 0) ||
|
|
43
|
+
!Number.isFinite(Date.parse(attempt["startedAt"] as string)) ||
|
|
44
|
+
!Number.isFinite(Date.parse(attempt["endedAt"] as string)) ||
|
|
45
|
+
attempt["stream"] !== stream ||
|
|
46
|
+
attempt["change"] !== change ||
|
|
47
|
+
attempt["role"] !== "implementer" ||
|
|
48
|
+
!["initial", "rework"].includes(attempt["kind"] as string) ||
|
|
49
|
+
!Number.isSafeInteger(attempt["toolCalls"]) ||
|
|
50
|
+
(attempt["toolCalls"] as number) < 0 ||
|
|
51
|
+
!Number.isSafeInteger(attempt["errorCount"]) ||
|
|
52
|
+
(attempt["errorCount"] as number) < 0 ||
|
|
53
|
+
typeof attempt["incomplete"] !== "boolean"
|
|
54
|
+
)
|
|
55
|
+
return false;
|
|
56
|
+
const selection = attempt["selection"];
|
|
57
|
+
if (
|
|
58
|
+
selection !== undefined &&
|
|
59
|
+
(typeof selection !== "object" ||
|
|
60
|
+
selection === null ||
|
|
61
|
+
["provider", "model", "thinking"].some(
|
|
62
|
+
(key) =>
|
|
63
|
+
typeof (selection as Record<string, unknown>)[key] !== "string" ||
|
|
64
|
+
!(selection as Record<string, unknown>)[key],
|
|
65
|
+
))
|
|
66
|
+
)
|
|
67
|
+
return false;
|
|
68
|
+
const usage = attempt["usage"];
|
|
69
|
+
if (
|
|
70
|
+
usage !== undefined &&
|
|
71
|
+
(typeof usage !== "object" ||
|
|
72
|
+
usage === null ||
|
|
73
|
+
["input", "output", "cacheRead", "cacheWrite"].some(
|
|
74
|
+
(key) =>
|
|
75
|
+
!Number.isSafeInteger((usage as Record<string, unknown>)[key]) ||
|
|
76
|
+
((usage as Record<string, unknown>)[key] as number) < 0,
|
|
77
|
+
))
|
|
78
|
+
)
|
|
79
|
+
return false;
|
|
80
|
+
if (
|
|
81
|
+
attempt["incomplete"] === false &&
|
|
82
|
+
(selection === undefined ||
|
|
83
|
+
usage === undefined ||
|
|
84
|
+
attempt["outcome"] === "unknown" ||
|
|
85
|
+
typeof attempt["text"] !== "string" ||
|
|
86
|
+
attempt["text"].length === 0)
|
|
87
|
+
)
|
|
88
|
+
return false;
|
|
89
|
+
const cost = attempt["cost"];
|
|
90
|
+
if (
|
|
91
|
+
cost !== undefined &&
|
|
92
|
+
(usage === undefined ||
|
|
93
|
+
typeof cost !== "object" ||
|
|
94
|
+
cost === null ||
|
|
95
|
+
!Number.isFinite((cost as Record<string, unknown>)["amount"]) ||
|
|
96
|
+
((cost as Record<string, unknown>)["amount"] as number) < 0 ||
|
|
97
|
+
typeof (cost as Record<string, unknown>)["currency"] !== "string" ||
|
|
98
|
+
!(cost as Record<string, unknown>)["currency"] ||
|
|
99
|
+
typeof (cost as Record<string, unknown>)["source"] !== "string" ||
|
|
100
|
+
!(cost as Record<string, unknown>)["source"])
|
|
101
|
+
)
|
|
102
|
+
return false;
|
|
103
|
+
return attempt["text"] === undefined || typeof attempt["text"] === "string";
|
|
104
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
readdirSync,
|
|
9
9
|
realpathSync,
|
|
10
10
|
rmdirSync,
|
|
11
|
+
statSync,
|
|
11
12
|
unlinkSync,
|
|
12
13
|
writeFileSync,
|
|
13
14
|
} from "node:fs";
|
|
@@ -15,16 +16,20 @@ import { mkdir, writeFile } from "node:fs/promises";
|
|
|
15
16
|
import { createHash } from "node:crypto";
|
|
16
17
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
17
18
|
|
|
18
|
-
import { metricReport, recordDispatch, recordLanding } from "./metrics.ts";
|
|
19
|
+
import { metricReport, recordAttempt, recordDispatch, recordLanding } from "./metrics.ts";
|
|
20
|
+
import { type Attempt, validAttempt } from "./attempt.ts";
|
|
19
21
|
import { roleSelectionArguments, roleSelectionSummary, validateRoleSelection } from "./pi.ts";
|
|
20
22
|
import { readProject } from "./project.ts";
|
|
21
23
|
|
|
22
24
|
const usage = `Usage:
|
|
25
|
+
codeless init
|
|
23
26
|
codeless create <slug>
|
|
24
27
|
codeless open <slug>
|
|
25
28
|
codeless planner <slug>
|
|
26
29
|
codeless approve <planner-session>
|
|
27
30
|
codeless dispatch <numbered-change-file>
|
|
31
|
+
codeless rework <numbered-change-file> <feedback>
|
|
32
|
+
codeless finish <numbered-change-file>
|
|
28
33
|
codeless land <slug>
|
|
29
34
|
codeless next <numbered-change-file> <landed-commit>
|
|
30
35
|
codeless metrics`;
|
|
@@ -36,9 +41,14 @@ export async function runCodeless(args: string[]): Promise<void> {
|
|
|
36
41
|
return;
|
|
37
42
|
}
|
|
38
43
|
const repository = canonicalPath(run("git", ["rev-parse", "--show-toplevel"]).trim());
|
|
39
|
-
const
|
|
44
|
+
const project = readProject(repository);
|
|
45
|
+
const { integrationBranch } = project;
|
|
40
46
|
run("git", ["check-ref-format", "--branch", integrationBranch], repository);
|
|
41
47
|
const plannerExtension = resolve(import.meta.dir, "../extension/planner.js");
|
|
48
|
+
const implementerReportingExtension = resolve(
|
|
49
|
+
import.meta.dir,
|
|
50
|
+
"../extension/implementer-reporting.js",
|
|
51
|
+
);
|
|
42
52
|
const configuredWorkspace = Bun.spawnSync(
|
|
43
53
|
["git", "config", "--local", "--get", "codeless.workspaceRoot"],
|
|
44
54
|
{ cwd: repository },
|
|
@@ -123,28 +133,146 @@ export async function runCodeless(args: string[]): Promise<void> {
|
|
|
123
133
|
.join("-");
|
|
124
134
|
}
|
|
125
135
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
136
|
+
type Worktree = { path: string; branch?: string };
|
|
137
|
+
|
|
138
|
+
function registeredWorktrees(): Worktree[] {
|
|
139
|
+
return run("git", ["worktree", "list", "--porcelain", "-z"], repository)
|
|
140
|
+
.split("\0\0")
|
|
141
|
+
.filter(Boolean)
|
|
142
|
+
.map((entry) => {
|
|
143
|
+
const fields = entry.split("\0");
|
|
131
144
|
const path = fields.find((field) => field.startsWith("worktree "))?.slice(9);
|
|
132
|
-
if (path
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
145
|
+
if (path === undefined) throw new Error("Git did not report a worktree path");
|
|
146
|
+
const branch = fields.find((field) => field.startsWith("branch "))?.slice(7);
|
|
147
|
+
return { path: canonicalPath(path), ...(branch === undefined ? {} : { branch }) };
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function integrationWorktree(): string {
|
|
152
|
+
const matches = registeredWorktrees().filter(
|
|
153
|
+
(worktree) => worktree.branch === `refs/heads/${integrationBranch}`,
|
|
154
|
+
);
|
|
155
|
+
if (matches.length !== 1)
|
|
156
|
+
throw new Error(`${integrationBranch} needs exactly one dedicated integration worktree`);
|
|
157
|
+
return matches[0]!.path;
|
|
136
158
|
}
|
|
137
159
|
|
|
138
160
|
function primaryWorktree(): string {
|
|
139
|
-
const
|
|
140
|
-
"\0\0",
|
|
141
|
-
)[0];
|
|
142
|
-
const path = entry
|
|
143
|
-
?.split("\0")
|
|
144
|
-
.find((field) => field.startsWith("worktree "))
|
|
145
|
-
?.slice(9);
|
|
161
|
+
const path = registeredWorktrees()[0]?.path;
|
|
146
162
|
if (path === undefined) throw new Error("Git did not report a primary worktree");
|
|
147
|
-
return
|
|
163
|
+
return path;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function init(): void {
|
|
167
|
+
run("git", ["show-ref", "--verify", `refs/heads/${integrationBranch}`], repository);
|
|
168
|
+
const primary = primaryWorktree();
|
|
169
|
+
const target = canonicalPath(join(workspaceRoot, "worktree", integrationBranch));
|
|
170
|
+
const matchingBranch = registeredWorktrees().filter(
|
|
171
|
+
(worktree) => worktree.branch === `refs/heads/${integrationBranch}`,
|
|
172
|
+
);
|
|
173
|
+
const targetExists = existsSync(target);
|
|
174
|
+
|
|
175
|
+
let workspaceParent = workspaceRoot;
|
|
176
|
+
while (!existsSync(workspaceParent)) workspaceParent = dirname(workspaceParent);
|
|
177
|
+
if (!statSync(workspaceParent).isDirectory()) {
|
|
178
|
+
throw new Error(`Workspace parent is not a directory: ${workspaceParent}`);
|
|
179
|
+
}
|
|
180
|
+
const worktreeRoot = join(workspaceRoot, "worktree");
|
|
181
|
+
const stateDirectories: [string, string][] = [
|
|
182
|
+
["Workspace", workspaceRoot],
|
|
183
|
+
["Workspace stream path", join(workspaceRoot, "stream")],
|
|
184
|
+
["Workspace worktree path", worktreeRoot],
|
|
185
|
+
["Workspace metrics path", join(workspaceRoot, "metrics")],
|
|
186
|
+
];
|
|
187
|
+
for (const [label, path] of stateDirectories) {
|
|
188
|
+
if (existsSync(path) && !statSync(path).isDirectory()) {
|
|
189
|
+
throw new Error(`${label} is not a directory: ${path}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const defaultWorkspace = workspaceRoot === canonicalPath(join(primary, ".codeless", "state"));
|
|
193
|
+
const ignoreFile = join(primary, ".gitignore");
|
|
194
|
+
let stateIgnored = false;
|
|
195
|
+
if (defaultWorkspace) {
|
|
196
|
+
if (existsSync(ignoreFile) && !statSync(ignoreFile).isFile()) {
|
|
197
|
+
throw new Error(`Repository ignore file is not a file: ${ignoreFile}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function ignoredByRepository(path: string): boolean {
|
|
201
|
+
const effective = Bun.spawnSync(["git", "check-ignore", "-q", "--no-index", path], {
|
|
202
|
+
cwd: primary,
|
|
203
|
+
env: process.env,
|
|
204
|
+
stdin: "ignore",
|
|
205
|
+
stdout: "ignore",
|
|
206
|
+
stderr: "pipe",
|
|
207
|
+
});
|
|
208
|
+
if (effective.exitCode === 1) return false;
|
|
209
|
+
if (effective.exitCode !== 0) {
|
|
210
|
+
throw new Error(
|
|
211
|
+
effective.stderr.toString().trim() || "Could not inspect repository ignores",
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
const source = run("git", ["check-ignore", "-v", "--no-index", path], primary).split(
|
|
215
|
+
":",
|
|
216
|
+
1,
|
|
217
|
+
)[0];
|
|
218
|
+
return source === ".gitignore" || source === ignoreFile;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const protectedPaths = [
|
|
222
|
+
".codeless/config.json",
|
|
223
|
+
...["change", "implement", "review", "commit"].map((name) =>
|
|
224
|
+
join(project.prompts, `${name}.md`),
|
|
225
|
+
),
|
|
226
|
+
];
|
|
227
|
+
if (protectedPaths.some((path) => ignoredByRepository(path))) {
|
|
228
|
+
throw new Error(
|
|
229
|
+
`Repository ignore rule conflicts with Codeless configuration or prompts; narrow ${ignoreFile} to /.codeless/state/`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
stateIgnored = ignoredByRepository(join(".codeless", "state", ".codeless-init-probe"));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (matchingBranch.length > 1) {
|
|
236
|
+
throw new Error(`Git reports ${integrationBranch} checked out in multiple worktrees`);
|
|
237
|
+
}
|
|
238
|
+
if (matchingBranch.length === 1 && matchingBranch[0]!.path !== target) {
|
|
239
|
+
throw new Error(
|
|
240
|
+
`${integrationBranch} is checked out at ${matchingBranch[0]!.path}, expected ${target}`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
if (matchingBranch.length === 1 && (!targetExists || !statSync(target).isDirectory())) {
|
|
244
|
+
throw new Error(
|
|
245
|
+
`Git registers ${integrationBranch} at invalid integration worktree ${target}`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
if (
|
|
249
|
+
matchingBranch.length === 1 &&
|
|
250
|
+
run("git", ["branch", "--show-current"], target).trim() !== integrationBranch
|
|
251
|
+
) {
|
|
252
|
+
throw new Error(`Integration worktree is not on ${integrationBranch}: ${target}`);
|
|
253
|
+
}
|
|
254
|
+
if (matchingBranch.length === 0 && targetExists) {
|
|
255
|
+
throw new Error(`Integration worktree target is occupied: ${target}`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (defaultWorkspace && !stateIgnored) {
|
|
259
|
+
const currentIgnore = existsSync(ignoreFile) ? readFileSync(ignoreFile, "utf8") : "";
|
|
260
|
+
appendFileSync(
|
|
261
|
+
ignoreFile,
|
|
262
|
+
`${currentIgnore.length > 0 && !currentIgnore.endsWith("\n") ? "\n" : ""}/.codeless/state/\n`,
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
mkdirSync(join(workspaceRoot, "stream"), { recursive: true });
|
|
266
|
+
mkdirSync(worktreeRoot, { recursive: true });
|
|
267
|
+
mkdirSync(join(workspaceRoot, "metrics"), { recursive: true });
|
|
268
|
+
if (matchingBranch.length === 0) {
|
|
269
|
+
run("git", ["worktree", "add", target, integrationBranch], repository);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
console.log(`Integration branch: ${integrationBranch}`);
|
|
273
|
+
console.log(`Primary checkout: ${primary}`);
|
|
274
|
+
console.log(`Workspace: ${workspaceRoot}`);
|
|
275
|
+
console.log(`Integration worktree: ${target}`);
|
|
148
276
|
}
|
|
149
277
|
|
|
150
278
|
function requireDirection(slug: string, worktree: string): string {
|
|
@@ -260,9 +388,9 @@ export async function runCodeless(args: string[]): Promise<void> {
|
|
|
260
388
|
return Number(rect["x"]) >= rightEdge && candidateTop < bottom && candidateBottom > top;
|
|
261
389
|
})
|
|
262
390
|
.sort((left, right) => Number(left["rect"]["x"]) - Number(right["rect"]["x"]));
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
391
|
+
if (candidates.length === 0) return undefined;
|
|
392
|
+
if (candidates.length > 1) throw new Error("Planner layout has an ambiguous right-hand pane");
|
|
393
|
+
return string(candidates[0]!.pane["pane_id"], "right-hand pane id");
|
|
266
394
|
}
|
|
267
395
|
|
|
268
396
|
function requirePaneShell(pane: string, worktree: string): void {
|
|
@@ -313,6 +441,51 @@ export async function runCodeless(args: string[]): Promise<void> {
|
|
|
313
441
|
}
|
|
314
442
|
}
|
|
315
443
|
|
|
444
|
+
function incompleteAttempt(
|
|
445
|
+
id: string,
|
|
446
|
+
slug: string,
|
|
447
|
+
number: string,
|
|
448
|
+
selection: { provider: string; model: string; thinking: string } | undefined,
|
|
449
|
+
kind: "initial" | "rework",
|
|
450
|
+
): Attempt {
|
|
451
|
+
const timestamp = new Date().toISOString();
|
|
452
|
+
return {
|
|
453
|
+
id,
|
|
454
|
+
stream: slug,
|
|
455
|
+
change: number,
|
|
456
|
+
role: "implementer",
|
|
457
|
+
kind,
|
|
458
|
+
startedAt: timestamp,
|
|
459
|
+
endedAt: timestamp,
|
|
460
|
+
...(selection === undefined ? {} : { selection }),
|
|
461
|
+
outcome: "unknown",
|
|
462
|
+
toolCalls: 0,
|
|
463
|
+
errorCount: 0,
|
|
464
|
+
incomplete: true,
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function collectedAttempt(value: unknown, fallback: Attempt): Attempt {
|
|
469
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return fallback;
|
|
470
|
+
const attempt = value as Partial<Attempt>;
|
|
471
|
+
if (
|
|
472
|
+
attempt.id !== fallback.id ||
|
|
473
|
+
attempt.stream !== fallback.stream ||
|
|
474
|
+
attempt.change !== fallback.change ||
|
|
475
|
+
attempt.role !== "implementer" ||
|
|
476
|
+
attempt.kind !== fallback.kind ||
|
|
477
|
+
typeof attempt.startedAt !== "string" ||
|
|
478
|
+
typeof attempt.endedAt !== "string" ||
|
|
479
|
+
typeof attempt.outcome !== "string" ||
|
|
480
|
+
typeof attempt.toolCalls !== "number" ||
|
|
481
|
+
typeof attempt.errorCount !== "number" ||
|
|
482
|
+
attempt.incomplete !== false ||
|
|
483
|
+
!validAttempt(attempt, fallback.stream, fallback.change)
|
|
484
|
+
)
|
|
485
|
+
return fallback;
|
|
486
|
+
return attempt;
|
|
487
|
+
}
|
|
488
|
+
|
|
316
489
|
function latestChangeNumber(slug: string): string {
|
|
317
490
|
const change = readdirSync(join(workspaceRoot, "stream", slug, "changes"))
|
|
318
491
|
.filter((file) => /^\d{3}\.md$/.test(file))
|
|
@@ -538,9 +711,11 @@ export async function runCodeless(args: string[]): Promise<void> {
|
|
|
538
711
|
requireClean(worktree, branch);
|
|
539
712
|
const prompts = promptDirectory(worktree);
|
|
540
713
|
const selection = readProject(worktree).implementer;
|
|
714
|
+
const attemptId = crypto.randomUUID();
|
|
715
|
+
const reportPath = join(workspaceRoot, "metrics", slug, `.attempt-${attemptId}.json`);
|
|
716
|
+
const fallbackAttempt = incompleteAttempt(attemptId, slug, number, selection, "initial");
|
|
541
717
|
observe("dispatch metrics", () => recordDispatch(workspaceRoot, slug, number));
|
|
542
718
|
await validateRoleSelection("implementer", selection, worktree);
|
|
543
|
-
console.log(roleSelectionSummary("implementer", selection));
|
|
544
719
|
|
|
545
720
|
const plannerPane = string(process.env["HERDR_PANE_ID"], "HERDR_PANE_ID");
|
|
546
721
|
const plannerProcesses = foregroundProcesses(paneProcessInfo(plannerPane));
|
|
@@ -592,6 +767,13 @@ export async function runCodeless(args: string[]): Promise<void> {
|
|
|
592
767
|
}
|
|
593
768
|
requirePaneShell(implementerPane, worktree);
|
|
594
769
|
|
|
770
|
+
const collection = JSON.stringify({
|
|
771
|
+
id: attemptId,
|
|
772
|
+
stream: slug,
|
|
773
|
+
change: number,
|
|
774
|
+
kind: "initial",
|
|
775
|
+
path: reportPath,
|
|
776
|
+
});
|
|
595
777
|
const started = herdr([
|
|
596
778
|
"agent",
|
|
597
779
|
"start",
|
|
@@ -602,6 +784,10 @@ export async function runCodeless(args: string[]): Promise<void> {
|
|
|
602
784
|
implementerPane,
|
|
603
785
|
"--",
|
|
604
786
|
"--no-session",
|
|
787
|
+
"--extension",
|
|
788
|
+
implementerReportingExtension,
|
|
789
|
+
"--codeless-attempt",
|
|
790
|
+
collection,
|
|
605
791
|
"--name",
|
|
606
792
|
`${slug}-impl`,
|
|
607
793
|
...roleSelectionArguments(selection),
|
|
@@ -618,7 +804,7 @@ export async function runCodeless(args: string[]): Promise<void> {
|
|
|
618
804
|
throw new Error(`${implementerName} started in ${startedCwd}, expected ${worktree}`);
|
|
619
805
|
}
|
|
620
806
|
|
|
621
|
-
|
|
807
|
+
run("herdr", [
|
|
622
808
|
"agent",
|
|
623
809
|
"prompt",
|
|
624
810
|
implementerName,
|
|
@@ -627,7 +813,121 @@ export async function runCodeless(args: string[]): Promise<void> {
|
|
|
627
813
|
"--timeout",
|
|
628
814
|
"3600000",
|
|
629
815
|
]);
|
|
630
|
-
|
|
816
|
+
let attempt = fallbackAttempt;
|
|
817
|
+
try {
|
|
818
|
+
attempt = collectedAttempt(JSON.parse(readFileSync(reportPath, "utf8")), fallbackAttempt);
|
|
819
|
+
if (attempt === fallbackAttempt) throw new Error("report did not match its dispatch attempt");
|
|
820
|
+
} catch (error) {
|
|
821
|
+
console.error(
|
|
822
|
+
`codeless: warning: could not collect implementer attempt: ${error instanceof Error ? error.message : String(error)}`,
|
|
823
|
+
);
|
|
824
|
+
} finally {
|
|
825
|
+
if (existsSync(reportPath)) unlinkSync(reportPath);
|
|
826
|
+
}
|
|
827
|
+
observe("implementer attempt", () => recordAttempt(workspaceRoot, slug, number, attempt));
|
|
828
|
+
console.log(JSON.stringify(attempt));
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function activeImplementer(changeArgument: string): {
|
|
832
|
+
change: string;
|
|
833
|
+
slug: string;
|
|
834
|
+
number: string;
|
|
835
|
+
worktree: string;
|
|
836
|
+
implementerName: string;
|
|
837
|
+
pane: string;
|
|
838
|
+
} {
|
|
839
|
+
if (process.env["HERDR_ENV"] !== "1")
|
|
840
|
+
throw new Error("Implementer control must run from a Herdr-managed planner");
|
|
841
|
+
const { change, slug, number, worktree, branch } = requireChange(changeArgument);
|
|
842
|
+
if (canonicalPath(process.cwd()) !== worktree)
|
|
843
|
+
throw new Error(`Implementer control cwd is ${process.cwd()}, expected ${worktree}`);
|
|
844
|
+
if (run("git", ["branch", "--show-current"], worktree).trim() !== branch)
|
|
845
|
+
throw new Error(`${worktree} is not on ${branch}`);
|
|
846
|
+
const plannerPane = string(process.env["HERDR_PANE_ID"], "HERDR_PANE_ID");
|
|
847
|
+
const plannerProcesses = foregroundProcesses(paneProcessInfo(plannerPane));
|
|
848
|
+
if (
|
|
849
|
+
!plannerProcesses.some(
|
|
850
|
+
(process) => canonicalPath(string(process["cwd"], "planner cwd")) === worktree,
|
|
851
|
+
)
|
|
852
|
+
)
|
|
853
|
+
throw new Error(`Planner pane ${plannerPane} is not running in ${worktree}`);
|
|
854
|
+
const layout = object(
|
|
855
|
+
result(herdr(["pane", "layout", "--pane", plannerPane]))["layout"],
|
|
856
|
+
"result.layout",
|
|
857
|
+
);
|
|
858
|
+
const pane = rightPane(layout, plannerPane);
|
|
859
|
+
if (pane === undefined) throw new Error("Planner has no right-hand implementer pane");
|
|
860
|
+
const implementerName = `${slug.replaceAll("-", "_")}_impl`;
|
|
861
|
+
const agent = object(result(herdr(["agent", "get", pane]))["agent"], "result.agent");
|
|
862
|
+
if (string(agent["name"], "result.agent.name") !== implementerName)
|
|
863
|
+
throw new Error(`Right-hand pane ${pane} is not implementer ${implementerName}`);
|
|
864
|
+
if (!["idle", "done"].includes(string(agent["agent_status"], "result.agent.agent_status")))
|
|
865
|
+
throw new Error(`Implementer ${implementerName} is not settled`);
|
|
866
|
+
const agentCwd = string(agent["foreground_cwd"] ?? agent["cwd"], "result.agent.foreground_cwd");
|
|
867
|
+
if (canonicalPath(agentCwd) !== worktree)
|
|
868
|
+
throw new Error(`Implementer ${implementerName} is in ${agentCwd}, expected ${worktree}`);
|
|
869
|
+
const processes = foregroundProcesses(paneProcessInfo(pane));
|
|
870
|
+
if (
|
|
871
|
+
!processes.some(
|
|
872
|
+
(process) => canonicalPath(string(process["cwd"], "implementer cwd")) === worktree,
|
|
873
|
+
)
|
|
874
|
+
)
|
|
875
|
+
throw new Error(`Implementer pane ${pane} is not running in ${worktree}`);
|
|
876
|
+
return { change, slug, number, worktree, implementerName, pane };
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
async function rework(changeArgument: string, feedback: string): Promise<void> {
|
|
880
|
+
if (feedback.trim().length === 0 || feedback.trim().length > 2_000)
|
|
881
|
+
throw new Error("Rework feedback must be concise non-empty text");
|
|
882
|
+
const { slug, number, implementerName } = activeImplementer(changeArgument);
|
|
883
|
+
const attemptId = crypto.randomUUID();
|
|
884
|
+
const reportPath = join(workspaceRoot, "metrics", slug, `.attempt-${attemptId}.json`);
|
|
885
|
+
const fallbackAttempt = incompleteAttempt(attemptId, slug, number, undefined, "rework");
|
|
886
|
+
const collection = JSON.stringify({
|
|
887
|
+
id: attemptId,
|
|
888
|
+
stream: slug,
|
|
889
|
+
change: number,
|
|
890
|
+
kind: "rework",
|
|
891
|
+
path: reportPath,
|
|
892
|
+
feedback: feedback.trim(),
|
|
893
|
+
});
|
|
894
|
+
run("herdr", [
|
|
895
|
+
"agent",
|
|
896
|
+
"prompt",
|
|
897
|
+
implementerName,
|
|
898
|
+
`/codeless-rework ${collection}`,
|
|
899
|
+
"--wait",
|
|
900
|
+
"--timeout",
|
|
901
|
+
"3600000",
|
|
902
|
+
]);
|
|
903
|
+
let attempt = fallbackAttempt;
|
|
904
|
+
try {
|
|
905
|
+
attempt = collectedAttempt(JSON.parse(readFileSync(reportPath, "utf8")), fallbackAttempt);
|
|
906
|
+
if (attempt === fallbackAttempt) throw new Error("report did not match its rework attempt");
|
|
907
|
+
} catch (error) {
|
|
908
|
+
console.error(
|
|
909
|
+
`codeless: warning: could not collect implementer attempt: ${error instanceof Error ? error.message : String(error)}`,
|
|
910
|
+
);
|
|
911
|
+
} finally {
|
|
912
|
+
if (existsSync(reportPath)) unlinkSync(reportPath);
|
|
913
|
+
}
|
|
914
|
+
observe("implementer attempt", () => recordAttempt(workspaceRoot, slug, number, attempt));
|
|
915
|
+
console.log(JSON.stringify(attempt));
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function finish(changeArgument: string): void {
|
|
919
|
+
const { slug, number, worktree, implementerName, pane } = activeImplementer(changeArgument);
|
|
920
|
+
run("herdr", [
|
|
921
|
+
"agent",
|
|
922
|
+
"prompt",
|
|
923
|
+
implementerName,
|
|
924
|
+
`/codeless-finish ${JSON.stringify({ stream: slug, change: number })}`,
|
|
925
|
+
"--wait",
|
|
926
|
+
"--timeout",
|
|
927
|
+
"30000",
|
|
928
|
+
]);
|
|
929
|
+
requirePaneShell(pane, worktree);
|
|
930
|
+
console.log(JSON.stringify({ pane, worktree }));
|
|
631
931
|
}
|
|
632
932
|
|
|
633
933
|
async function launchPlanner(slug: string): Promise<void> {
|
|
@@ -785,6 +1085,11 @@ export async function runCodeless(args: string[]): Promise<void> {
|
|
|
785
1085
|
}
|
|
786
1086
|
}
|
|
787
1087
|
|
|
1088
|
+
if (action === "init") {
|
|
1089
|
+
if (target !== undefined || details.length > 0) throw new Error(usage);
|
|
1090
|
+
init();
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
788
1093
|
if (action === "metrics") {
|
|
789
1094
|
if (target !== undefined || details.length > 0) throw new Error(usage);
|
|
790
1095
|
for (const line of metricReport(workspaceRoot)) console.log(line);
|
|
@@ -800,6 +1105,16 @@ export async function runCodeless(args: string[]): Promise<void> {
|
|
|
800
1105
|
await dispatch(target);
|
|
801
1106
|
return;
|
|
802
1107
|
}
|
|
1108
|
+
if (action === "rework") {
|
|
1109
|
+
if (target === undefined || details.length !== 1) throw new Error(usage);
|
|
1110
|
+
await rework(target, details[0]!);
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
if (action === "finish") {
|
|
1114
|
+
if (target === undefined || details.length > 0) throw new Error(usage);
|
|
1115
|
+
finish(target);
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
803
1118
|
if (action === "next") {
|
|
804
1119
|
if (target === undefined || details.length !== 1) throw new Error(usage);
|
|
805
1120
|
await nextChange(target, details[0]!);
|