@dpeek/codeless 0.1.1 → 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 CHANGED
@@ -97,7 +97,7 @@ the runner does not generate or copy project instructions.
97
97
  The package-owned planner extension activates every planner session. Before its
98
98
  first project prompt, it requires the exact `<slug>-planner` Pi name, establishes
99
99
  and verifies the `<slug>_planner` Herdr identity, and confirms `approve_stream_change`,
100
- `dispatch_stream_implementer`, and `next_stream_change` are active. Missing or
100
+ `dispatch_stream_implementer`, `rework_stream_implementer`, `finish_stream_implementer`, and `next_stream_change` are active. Missing or
101
101
  incompatible activation, identity mismatch, or inactive tools stops before
102
102
  `/change`; global Pi extension installation is unnecessary.
103
103
  The approval tool has no arguments. Its extension derives the active
@@ -182,6 +182,8 @@ codeless open <slug>
182
182
  codeless planner <slug>
183
183
  codeless approve <planner-session>
184
184
  codeless dispatch <numbered-change-file>
185
+ codeless rework <numbered-change-file> <feedback>
186
+ codeless finish <numbered-change-file>
185
187
  codeless land <slug>
186
188
  codeless next <numbered-change-file> <landed-commit>
187
189
  codeless metrics
@@ -199,9 +201,15 @@ starts Pi in an existing stream's lone shell after the same role preflight. Its
199
201
  activation establishes the same identity as creation and reopening.
200
202
  Dispatch validates the implementer selection before touching the planner's
201
203
  right-hand pane, starts a fresh ephemeral implementer with Codeless's reporting
202
- extension and its explicit Pi extension flag, and waits for completion. Its JSON
203
- result is a normalized attempt report, which the planner tool exposes before
204
- queueing review. It includes the actual settled model/thinking selection,
204
+ extension and its explicit Pi extension flag, and waits for completion. Rework verifies
205
+ that same change's idle implementer and worktree, invokes one package-owned Pi command
206
+ that verifies its immutable stream/change scope, arms reporting, and submits one feedback
207
+ turn with the same one-hour limit, records a `rework`
208
+ attempt, and returns it before review is queued again. Finish verifies that identity,
209
+ gracefully exits it, and waits for the right pane's stream-worktree shell. Neither operation
210
+ replaces the agent, changes its selection, retries, or continues after a mismatch, timeout,
211
+ blocked agent, or ambiguous pane. Its JSON result is a normalized attempt report, which the
212
+ planner tool exposes before queueing review. It includes the actual settled model/thinking selection,
205
213
  terminal text and outcome, full-session Pi usage and available Pi cost estimate,
206
214
  timestamps, and tool/error counts; prompts, source, thinking, credentials, and
207
215
  transcripts are not retained.
@@ -216,11 +224,17 @@ integration fast-forward, Codeless records the landed time and commit on that
216
224
  change's canonical record, creating a landed record without elapsed time when
217
225
  dispatch collection was unavailable; collection warnings never alter dispatch or
218
226
  landing.
219
- `codeless metrics` prints every recorded stream and a project total. Its elapsed
220
- columns are dispatch-to-land wall-clock time; among landed changes, records
227
+ `codeless metrics` prints every recorded stream and a project total. Its first
228
+ table reports dispatch-to-land wall-clock time; among landed changes, records
221
229
  without a measured duration are explicitly unavailable. Dispatched-but-unlanded
222
- changes remain a separate count. Metrics are prospective local observations, not
223
- journal state or a recovery mechanism.
230
+ changes remain a separate count. Its second table reports implementer attempts:
231
+ distinct changes with rework, initial and rework turns, incomplete collection,
232
+ exact stored terminal outcomes, and tool errors. Usage and cost coverage count
233
+ measured attempts separately from unavailable collection; token totals include
234
+ only measured usage, and cost totals remain grouped by recorded currency without
235
+ conversion. These counts do not establish implementation quality or review
236
+ success. Metrics are prospective local observations, not journal state or a
237
+ recovery mechanism.
224
238
 
225
239
  Landing requires clean stream and integration worktrees and exactly one stream
226
240
  commit outside their merge base. It acquires `.land-lock` atomically, recording
@@ -74,23 +74,89 @@ function write(path, value) {
74
74
  export default function implementerReportingExtension(pi) {
75
75
  pi.registerFlag("codeless-attempt", { type: "string" });
76
76
  let configuration;
77
+ let startupScope;
78
+ let rearm;
77
79
  let startedAt;
80
+ let entryOffset = 0;
78
81
  let toolCalls = 0;
79
82
  let errorCount = 0;
83
+
84
+ function configure(value, offset = 0) {
85
+ if (
86
+ typeof value?.path !== "string" ||
87
+ typeof value?.id !== "string" ||
88
+ typeof value?.stream !== "string" ||
89
+ typeof value?.change !== "string" ||
90
+ !["initial", "rework"].includes(value?.kind)
91
+ )
92
+ throw new Error("Codeless attempt configuration is invalid");
93
+ return { value, offset };
94
+ }
80
95
  pi.on("session_start", () => {
81
96
  try {
82
97
  const value = JSON.parse(pi.getFlag("codeless-attempt") ?? "");
83
- if (
84
- typeof value?.path === "string" &&
85
- typeof value?.id === "string" &&
86
- typeof value?.stream === "string" &&
87
- typeof value?.change === "string"
88
- )
89
- configuration = value;
98
+ configuration = configure(value).value;
99
+ startupScope = { stream: configuration.stream, change: configuration.change };
90
100
  } catch {}
91
101
  });
102
+ function requireScope(value) {
103
+ if (
104
+ startupScope === undefined ||
105
+ value?.stream !== startupScope.stream ||
106
+ value?.change !== startupScope.change
107
+ )
108
+ throw new Error("Codeless stream/change scope does not match this implementer session");
109
+ }
110
+
111
+ pi.registerCommand("codeless-rework", {
112
+ description: "Submit one scope-verified Codeless implementer rework turn",
113
+ handler: async (args, ctx) => {
114
+ let value;
115
+ try {
116
+ value = JSON.parse(args);
117
+ } catch {
118
+ throw new Error("Codeless rework requires one JSON-quoted request");
119
+ }
120
+ requireScope(value);
121
+ if (typeof value?.feedback !== "string" || value.feedback.trim().length === 0)
122
+ throw new Error("Codeless rework requires concise non-empty feedback");
123
+ const next = configure(value, ctx.sessionManager.getEntries().length);
124
+ if (next.value.kind !== "rework") throw new Error("Codeless rework attempt must be rework");
125
+ configuration = undefined;
126
+ rearm = next;
127
+ try {
128
+ pi.sendUserMessage(`Review feedback: ${value.feedback.trim()}`, { deliverAs: "followUp" });
129
+ } catch (error) {
130
+ rearm = undefined;
131
+ throw error;
132
+ }
133
+ },
134
+ });
135
+
136
+ pi.registerCommand("codeless-finish", {
137
+ description: "Gracefully finish a scope-verified Codeless implementer",
138
+ handler: async (args, ctx) => {
139
+ let value;
140
+ try {
141
+ value = JSON.parse(args);
142
+ } catch {
143
+ throw new Error("Codeless finish requires one JSON-quoted scope");
144
+ }
145
+ requireScope(value);
146
+ await ctx.shutdown();
147
+ },
148
+ });
92
149
  pi.on("agent_start", () => {
93
- startedAt ??= new Date().toISOString();
150
+ if (rearm !== undefined) {
151
+ configuration = rearm.value;
152
+ entryOffset = rearm.offset;
153
+ rearm = undefined;
154
+ startedAt = new Date().toISOString();
155
+ toolCalls = 0;
156
+ errorCount = 0;
157
+ } else {
158
+ startedAt ??= new Date().toISOString();
159
+ }
94
160
  });
95
161
  pi.on("tool_execution_end", (event) => {
96
162
  toolCalls += 1;
@@ -100,9 +166,10 @@ export default function implementerReportingExtension(pi) {
100
166
  if (configuration === undefined) return;
101
167
  const report = configuration;
102
168
  configuration = undefined;
103
- const settledMessages = messages(ctx.sessionManager.getEntries());
169
+ const entries = ctx.sessionManager.getEntries().slice(entryOffset);
170
+ const settledMessages = messages(entries);
104
171
  const final = [...settledMessages].reverse().find((message) => message.role === "assistant");
105
- const totals = sessionUsage(ctx.sessionManager.getEntries());
172
+ const totals = sessionUsage(entries);
106
173
  const model = final?.responseModel ?? final?.model ?? ctx.model?.id;
107
174
  const provider = final?.provider ?? ctx.model?.provider;
108
175
  const selection =
@@ -123,6 +190,7 @@ export default function implementerReportingExtension(pi) {
123
190
  stream: report.stream,
124
191
  change: report.change,
125
192
  role: "implementer",
193
+ kind: report.kind,
126
194
  startedAt: startedAt ?? new Date().toISOString(),
127
195
  endedAt: new Date().toISOString(),
128
196
  ...(selection === undefined ? {} : { selection }),
@@ -6,6 +6,8 @@ const thinkingLevels = new Set(["off", "minimal", "low", "medium", "high", "xhig
6
6
  const requiredTools = [
7
7
  "approve_stream_change",
8
8
  "dispatch_stream_implementer",
9
+ "rework_stream_implementer",
10
+ "finish_stream_implementer",
9
11
  "next_stream_change",
10
12
  ];
11
13
 
@@ -290,6 +292,98 @@ export default function plannerExtension(pi) {
290
292
  },
291
293
  });
292
294
 
295
+ pi.registerTool({
296
+ name: "rework_stream_implementer",
297
+ label: "Remediate stream implementation",
298
+ description:
299
+ "Reuse the idle implementer for this approved change, submit one actionable feedback turn, collect its rework attempt, and queue review again.",
300
+ promptSnippet: "Send one concise remediation request to the existing stream implementer",
301
+ promptGuidelines: [
302
+ "Call rework_stream_implementer only when review finds actionable defects in the approved change. Use its approved changePath and concise feedback; never reproduce Herdr commands.",
303
+ "The tool queues review only after a settled rework attempt. Stop on any error and do not retry automatically.",
304
+ ],
305
+ parameters: {
306
+ type: "object",
307
+ properties: {
308
+ changePath: {
309
+ type: "string",
310
+ description: "Absolute path to the approved changes/NNN.md file",
311
+ },
312
+ feedback: { type: "string", description: "Concise actionable review feedback" },
313
+ },
314
+ required: ["changePath", "feedback"],
315
+ additionalProperties: false,
316
+ },
317
+ async execute(_toolCallId, params, signal) {
318
+ const changePath = params.changePath.replace(/^@/, "");
319
+ const execution = await pi.exec("bun", [codeless, "rework", changePath, params.feedback], {
320
+ signal,
321
+ timeout: 3_700_000,
322
+ });
323
+ const output = [execution.stdout.trim(), execution.stderr.trim()]
324
+ .filter(Boolean)
325
+ .join("\n");
326
+ if (execution.code !== 0)
327
+ throw new Error(output || `codeless rework failed with exit code ${execution.code}`);
328
+ let attempt;
329
+ try {
330
+ attempt = JSON.parse(execution.stdout);
331
+ } catch {
332
+ throw new Error("Codeless returned an invalid rework attempt");
333
+ }
334
+ if (!validAttempt(attempt, attempt?.stream, attempt?.change) || attempt.kind !== "rework")
335
+ throw new Error("Codeless returned an invalid rework attempt");
336
+ pi.sendUserMessage(`/review ${JSON.stringify(changePath)}`, {
337
+ deliverAs: "steer",
338
+ expandPromptTemplates: true,
339
+ });
340
+ return {
341
+ content: [{ type: "text", text: attempt.text || "Implementer rework settled." }],
342
+ details: { changePath, attempt },
343
+ };
344
+ },
345
+ });
346
+
347
+ pi.registerTool({
348
+ name: "finish_stream_implementer",
349
+ label: "Finish stream implementer",
350
+ description:
351
+ "Gracefully exit the verified idle implementer after review approval and confirm its right-hand pane returned to the stream shell.",
352
+ promptSnippet: "Finish the approved stream implementer before commit and landing",
353
+ promptGuidelines: [
354
+ "Call finish_stream_implementer exactly once after recording review approval and before following commit-and-land instructions.",
355
+ "Stop on failure; do not use Herdr commands or continue to commit and land.",
356
+ ],
357
+ parameters: {
358
+ type: "object",
359
+ properties: {
360
+ changePath: {
361
+ type: "string",
362
+ description: "Absolute path to the approved changes/NNN.md file",
363
+ },
364
+ },
365
+ required: ["changePath"],
366
+ additionalProperties: false,
367
+ },
368
+ async execute(_toolCallId, params, signal) {
369
+ const changePath = params.changePath.replace(/^@/, "");
370
+ const execution = await pi.exec("bun", [codeless, "finish", changePath], {
371
+ signal,
372
+ timeout: 35_000,
373
+ });
374
+ const output = [execution.stdout.trim(), execution.stderr.trim()]
375
+ .filter(Boolean)
376
+ .join("\n");
377
+ if (execution.code !== 0)
378
+ throw new Error(output || `codeless finish failed with exit code ${execution.code}`);
379
+ return {
380
+ content: [
381
+ { type: "text", text: "Implementer exited and its pane returned to the stream shell." },
382
+ ],
383
+ };
384
+ },
385
+ });
386
+
293
387
  pi.registerTool({
294
388
  name: "dispatch_stream_implementer",
295
389
  label: "Dispatch stream implementer",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dpeek/codeless",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "An attended planner and implementer workflow for parallel capability development",
5
5
  "homepage": "https://github.com/dpeek/codeless#readme",
6
6
  "bugs": {
package/spec/workflow.md CHANGED
@@ -103,8 +103,8 @@ post-landing replacement—uses the package-owned extension as its activation
103
103
  boundary. Before its first project prompt, activation requires the exact
104
104
  `<slug>-planner` Pi session name, establishes and verifies Herdr reports
105
105
  `<slug-with-hyphens-replaced>_planner`, and verifies
106
- `approve_stream_change`, `dispatch_stream_implementer`, and
107
- `next_stream_change` are active. Missing or incompatible activation, identity
106
+ `approve_stream_change`, `dispatch_stream_implementer`, `rework_stream_implementer`,
107
+ `finish_stream_implementer`, and `next_stream_change` are active. Missing or incompatible activation, identity
108
108
  mismatch, or an incomplete tool set stops visibly before `/change`. A direct
109
109
  restart may begin with Herdr's `pi` fallback identity; activation renames and
110
110
  rereads only that fallback. Any other identity mismatch stops. Implementers use
@@ -139,13 +139,22 @@ repeating a settled implementation.
139
139
 
140
140
  The planner inspects the full diff and relevant code, checks the approved
141
141
  acceptance criteria, and runs focused checks when the implementation output is
142
- insufficient. Remediation reuses the same implementer context. Once approved,
143
- the planner records the review result, exits the implementer so its pane returns
144
- to a shell, and follows the commit-and-land prompt without another approval
145
- round.
146
-
147
- Dispatch and remediation do not retry automatically. Remediation and implementer
148
- shutdown are still performed through prompt-owned Herdr commands.
142
+ insufficient. The planner-only `rework_stream_implementer` tool accepts that
143
+ approved path and concise feedback, verifies the expected idle implementer, its
144
+ right-hand pane and worktree, then invokes one package-owned Pi command. That command
145
+ verifies the immutable startup stream/change scope, arms package reporting in the existing
146
+ conversation, and submits one bounded feedback turn. It records
147
+ and returns one `rework` attempt before queueing review again; missing or malformed
148
+ reports warn and yield an incomplete attempt after settlement. Prompt rejection,
149
+ timeout, blocked state, identity/worktree/change mismatch, or ambiguous pane stops
150
+ without a completed attempt or queued review. The separate planner-only
151
+ `finish_stream_implementer` tool first verifies that immutable stream/change scope in the
152
+ same idle implementer, then gracefully exits it and waits for its pane to become the
153
+ stream-worktree shell. Its failure
154
+ stops before commit or landing instructions continue.
155
+
156
+ Dispatch, remediation, and shutdown do not retry automatically or replace an
157
+ implementer session or its selected model.
149
158
 
150
159
  ## Commit and landing
151
160
 
@@ -194,16 +203,21 @@ unchanged. Successful landing adds its timestamp and commit, or creates a landed
194
203
  record with unavailable elapsed time when dispatch collection was unavailable.
195
204
  Collection warnings do not change dispatch or landing outcomes.
196
205
 
197
- `codeless metrics` reports every recorded stream and a project total with:
198
-
199
- - landed and dispatched-but-unlanded change counts;
200
- - measured versus unavailable elapsed coverage; and
201
- - total and average dispatch-to-land wall-clock time.
202
-
203
- The measurements are prospective, local observations. They are not journal
204
- state, an approval source, or a recovery mechanism. Attempt usage and cost are
205
- stored for later aggregation; `codeless metrics`, review rework, and failure
206
- breakdowns do not yet report them.
206
+ `codeless metrics` reports every recorded stream and a project total in two
207
+ tables. The elapsed table reports landed and dispatched-but-unlanded change
208
+ counts, measured versus unavailable elapsed coverage, and total and average
209
+ dispatch-to-land wall-clock time. The attempt table aggregates only validated
210
+ canonical attempt records and reports distinct changes with rework, initial and
211
+ rework turns, incomplete collection, exact stored terminal-outcome labels, and
212
+ summed tool errors.
213
+
214
+ Usage coverage is measured versus unavailable attempts; input, output,
215
+ cache-read, and cache-write totals include only attempts with recorded usage.
216
+ Cost coverage follows the same rule, and totals are grouped by recorded currency
217
+ without conversion. Missing usage or cost is unavailable, never zero. These
218
+ measurements do not establish implementation quality or review success. They
219
+ are prospective local observations, not journal state, an approval source, or a
220
+ recovery mechanism.
207
221
 
208
222
  ## Limits
209
223
 
package/src/attempt.ts CHANGED
@@ -3,6 +3,7 @@ export type Attempt = {
3
3
  stream: string;
4
4
  change: string;
5
5
  role: "implementer";
6
+ kind: "initial" | "rework";
6
7
  startedAt: string;
7
8
  endedAt: string;
8
9
  selection?: { provider: string; model: string; thinking: string };
@@ -23,6 +24,7 @@ export function validAttempt(value: unknown, stream: string, change: string): va
23
24
  "stream",
24
25
  "change",
25
26
  "role",
27
+ "kind",
26
28
  "startedAt",
27
29
  "endedAt",
28
30
  "selection",
@@ -34,7 +36,7 @@ export function validAttempt(value: unknown, stream: string, change: string): va
34
36
  "errorCount",
35
37
  "incomplete",
36
38
  ]);
37
- const strings = ["id", "stream", "change", "role", "startedAt", "endedAt", "outcome"];
39
+ const strings = ["id", "stream", "change", "role", "kind", "startedAt", "endedAt", "outcome"];
38
40
  if (
39
41
  !Object.keys(attempt).every((key) => allowed.has(key)) ||
40
42
  !strings.every((key) => typeof attempt[key] === "string" && attempt[key].length > 0) ||
@@ -43,6 +45,7 @@ export function validAttempt(value: unknown, stream: string, change: string): va
43
45
  attempt["stream"] !== stream ||
44
46
  attempt["change"] !== change ||
45
47
  attempt["role"] !== "implementer" ||
48
+ !["initial", "rework"].includes(attempt["kind"] as string) ||
46
49
  !Number.isSafeInteger(attempt["toolCalls"]) ||
47
50
  (attempt["toolCalls"] as number) < 0 ||
48
51
  !Number.isSafeInteger(attempt["errorCount"]) ||
package/src/cli.ts CHANGED
@@ -28,6 +28,8 @@ const usage = `Usage:
28
28
  codeless planner <slug>
29
29
  codeless approve <planner-session>
30
30
  codeless dispatch <numbered-change-file>
31
+ codeless rework <numbered-change-file> <feedback>
32
+ codeless finish <numbered-change-file>
31
33
  codeless land <slug>
32
34
  codeless next <numbered-change-file> <landed-commit>
33
35
  codeless metrics`;
@@ -386,9 +388,9 @@ export async function runCodeless(args: string[]): Promise<void> {
386
388
  return Number(rect["x"]) >= rightEdge && candidateTop < bottom && candidateBottom > top;
387
389
  })
388
390
  .sort((left, right) => Number(left["rect"]["x"]) - Number(right["rect"]["x"]));
389
- return candidates.length === 0
390
- ? undefined
391
- : 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");
392
394
  }
393
395
 
394
396
  function requirePaneShell(pane: string, worktree: string): void {
@@ -443,7 +445,8 @@ export async function runCodeless(args: string[]): Promise<void> {
443
445
  id: string,
444
446
  slug: string,
445
447
  number: string,
446
- selection: { provider: string; model: string; thinking: string },
448
+ selection: { provider: string; model: string; thinking: string } | undefined,
449
+ kind: "initial" | "rework",
447
450
  ): Attempt {
448
451
  const timestamp = new Date().toISOString();
449
452
  return {
@@ -451,9 +454,10 @@ export async function runCodeless(args: string[]): Promise<void> {
451
454
  stream: slug,
452
455
  change: number,
453
456
  role: "implementer",
457
+ kind,
454
458
  startedAt: timestamp,
455
459
  endedAt: timestamp,
456
- selection,
460
+ ...(selection === undefined ? {} : { selection }),
457
461
  outcome: "unknown",
458
462
  toolCalls: 0,
459
463
  errorCount: 0,
@@ -469,6 +473,7 @@ export async function runCodeless(args: string[]): Promise<void> {
469
473
  attempt.stream !== fallback.stream ||
470
474
  attempt.change !== fallback.change ||
471
475
  attempt.role !== "implementer" ||
476
+ attempt.kind !== fallback.kind ||
472
477
  typeof attempt.startedAt !== "string" ||
473
478
  typeof attempt.endedAt !== "string" ||
474
479
  typeof attempt.outcome !== "string" ||
@@ -708,7 +713,7 @@ export async function runCodeless(args: string[]): Promise<void> {
708
713
  const selection = readProject(worktree).implementer;
709
714
  const attemptId = crypto.randomUUID();
710
715
  const reportPath = join(workspaceRoot, "metrics", slug, `.attempt-${attemptId}.json`);
711
- const fallbackAttempt = incompleteAttempt(attemptId, slug, number, selection);
716
+ const fallbackAttempt = incompleteAttempt(attemptId, slug, number, selection, "initial");
712
717
  observe("dispatch metrics", () => recordDispatch(workspaceRoot, slug, number));
713
718
  await validateRoleSelection("implementer", selection, worktree);
714
719
 
@@ -766,6 +771,7 @@ export async function runCodeless(args: string[]): Promise<void> {
766
771
  id: attemptId,
767
772
  stream: slug,
768
773
  change: number,
774
+ kind: "initial",
769
775
  path: reportPath,
770
776
  });
771
777
  const started = herdr([
@@ -822,6 +828,108 @@ export async function runCodeless(args: string[]): Promise<void> {
822
828
  console.log(JSON.stringify(attempt));
823
829
  }
824
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 }));
931
+ }
932
+
825
933
  async function launchPlanner(slug: string): Promise<void> {
826
934
  if (process.env["HERDR_ENV"] !== "1") {
827
935
  throw new Error("Run codeless planner from the stream's Herdr-managed shell");
@@ -997,6 +1105,16 @@ export async function runCodeless(args: string[]): Promise<void> {
997
1105
  await dispatch(target);
998
1106
  return;
999
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
+ }
1000
1118
  if (action === "next") {
1001
1119
  if (target === undefined || details.length !== 1) throw new Error(usage);
1002
1120
  await nextChange(target, details[0]!);
package/src/metrics.ts CHANGED
@@ -201,12 +201,98 @@ export function metricReport(workspaceRoot: string): string[] {
201
201
  return `${name}\t${value.landed}\t${value.unlanded}\t${value.coverage}\t${value.total}\t${value.average}`;
202
202
  };
203
203
  const rows = [...byStream.entries()].map(([stream, records]) => ({ stream, records }));
204
+ const allRecords = rows.flatMap(({ records }) => records);
205
+ const formatCost = (amounts: number[]) => {
206
+ const parts = amounts.map((amount) => {
207
+ const [coefficient, exponent = "0"] = String(amount).toLowerCase().split("e");
208
+ const [whole, fraction = ""] = coefficient!.split(".");
209
+ return { digits: BigInt(`${whole}${fraction}`), scale: fraction.length - Number(exponent) };
210
+ });
211
+ const scale = Math.max(0, ...parts.map((part) => part.scale));
212
+ const total = parts.reduce(
213
+ (sum, part) => sum + part.digits * 10n ** BigInt(scale - part.scale),
214
+ 0n,
215
+ );
216
+ const digits = total.toString().padStart(scale + 1, "0");
217
+ if (scale === 0) return digits;
218
+ const fraction = digits.slice(-scale).replace(/0+$/, "");
219
+ return fraction.length === 0
220
+ ? digits.slice(0, -scale)
221
+ : `${digits.slice(0, -scale)}.${fraction}`;
222
+ };
223
+ const attemptSummary = (records: Metric[]) => {
224
+ const attempts = records.flatMap((record) => Object.values(record.attempts ?? {}));
225
+ const usage = attempts.filter((attempt) => attempt.usage !== undefined);
226
+ const costs = attempts.filter((attempt) => attempt.cost !== undefined);
227
+ const outcomes = new Map<string, number>();
228
+ const currencies = new Map<string, number[]>();
229
+ for (const attempt of attempts) {
230
+ outcomes.set(attempt.outcome, (outcomes.get(attempt.outcome) ?? 0) + 1);
231
+ if (attempt.cost !== undefined)
232
+ currencies.set(attempt.cost.currency, [
233
+ ...(currencies.get(attempt.cost.currency) ?? []),
234
+ attempt.cost.amount,
235
+ ]);
236
+ }
237
+ const coverage = (measured: number) =>
238
+ `${measured} measured, ${attempts.length - measured} unavailable`;
239
+ return {
240
+ reworkedChanges: new Set(
241
+ records
242
+ .filter((record) =>
243
+ Object.values(record.attempts ?? {}).some((attempt) => attempt.kind === "rework"),
244
+ )
245
+ .map((record) => `${record.stream}\0${record.change}`),
246
+ ).size,
247
+ initial: attempts.filter((attempt) => attempt.kind === "initial").length,
248
+ rework: attempts.filter((attempt) => attempt.kind === "rework").length,
249
+ incomplete: attempts.filter((attempt) => attempt.incomplete).length,
250
+ outcomes:
251
+ outcomes.size === 0
252
+ ? "none"
253
+ : [...outcomes.entries()]
254
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
255
+ .map(([outcome, count]) => `${outcome}: ${count}`)
256
+ .join(", "),
257
+ toolErrors: attempts.reduce((total, attempt) => total + attempt.errorCount, 0),
258
+ usageCoverage: coverage(usage.length),
259
+ input:
260
+ usage.length === 0
261
+ ? "unavailable"
262
+ : usage.reduce((total, attempt) => total + attempt.usage!.input, 0),
263
+ output:
264
+ usage.length === 0
265
+ ? "unavailable"
266
+ : usage.reduce((total, attempt) => total + attempt.usage!.output, 0),
267
+ cacheRead:
268
+ usage.length === 0
269
+ ? "unavailable"
270
+ : usage.reduce((total, attempt) => total + attempt.usage!.cacheRead, 0),
271
+ cacheWrite:
272
+ usage.length === 0
273
+ ? "unavailable"
274
+ : usage.reduce((total, attempt) => total + attempt.usage!.cacheWrite, 0),
275
+ costCoverage: coverage(costs.length),
276
+ costs:
277
+ currencies.size === 0
278
+ ? "unavailable"
279
+ : [...currencies.entries()]
280
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
281
+ .map(([currency, amounts]) => `${currency} ${formatCost(amounts)}`)
282
+ .join(", "),
283
+ };
284
+ };
285
+ const attemptRow = (name: string, records: Metric[]) => {
286
+ const value = attemptSummary(records);
287
+ return `${name}\t${value.reworkedChanges}\t${value.initial}\t${value.rework}\t${value.incomplete}\t${value.outcomes}\t${value.toolErrors}\t${value.usageCoverage}\t${value.input}\t${value.output}\t${value.cacheRead}\t${value.cacheWrite}\t${value.costCoverage}\t${value.costs}`;
288
+ };
204
289
  return [
205
290
  "Stream\tLanded\tNot landed\tElapsed coverage\tDispatch-to-land wall clock total\tAverage",
206
291
  ...rows.map(({ stream, records }) => row(stream, records)),
207
- row(
208
- "Project total",
209
- rows.flatMap(({ records }) => records),
210
- ),
292
+ row("Project total", allRecords),
293
+ "",
294
+ "Stream\tChanges with rework\tInitial attempts\tRework attempts\tIncomplete collection\tTerminal outcomes\tTool errors\tUsage coverage\tInput tokens\tOutput tokens\tCache-read tokens\tCache-write tokens\tCost coverage\tCost totals",
295
+ ...rows.map(({ stream, records }) => attemptRow(stream, records)),
296
+ attemptRow("Project total", allRecords),
211
297
  ];
212
298
  }