@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/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 { integrationBranch } = readProject(repository);
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
- 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}`)) {
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 !== undefined) return path;
133
- }
134
- }
135
- throw new Error(`${integrationBranch} needs a dedicated integration worktree`);
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 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);
161
+ const path = registeredWorktrees()[0]?.path;
146
162
  if (path === undefined) throw new Error("Git did not report a primary worktree");
147
- return canonicalPath(path);
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
- return candidates.length === 0
264
- ? undefined
265
- : string(candidates[0]!.pane["pane_id"], "right-hand pane id");
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
- const completed = run("herdr", [
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
- console.log(completed.trim());
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]!);