@dpeek/codeless 0.1.0 → 0.1.1

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
@@ -154,9 +154,18 @@ planners or worktree shells are running.
154
154
  worktree/<slug>/ # stream/<slug> branch
155
155
  worktree/main/ # example integration checkout location
156
156
  .land-lock/ # shared landing owner and recorded integration commit
157
- metrics/<slug>/NNN.json # first dispatch, landing time, and landed commit
157
+ metrics/<slug>/NNN.json # dispatch/landing times and deduplicated implementer attempts
158
158
  ```
159
159
 
160
+ Run `codeless init` once after configuring a project. It creates the shared state
161
+ layout and the dedicated integration worktree at
162
+ `<workspace>/worktree/<integration-branch>` without requiring Herdr. For the
163
+ default workspace it adds only `/.codeless/state/` to the primary checkout's
164
+ `.gitignore`; an absolute workspace override does not modify repository ignores.
165
+ It is safe to repeat when that exact worktree is registered. It stops rather
166
+ than moving a branch checkout, replacing an occupied target, or broadening a
167
+ repository ignore rule that covers configuration or prompts.
168
+
160
169
  Normal `git clean -fd` preserves ignored state. `git clean -fdx` removes ignored
161
170
  files and can therefore destroy local Codeless journals, metrics, and worktrees;
162
171
  inspect its targets before using it.
@@ -167,6 +176,7 @@ Run creation, opening, and planner launch from a Herdr-managed shell. Landing
167
176
  needs no Herdr session.
168
177
 
169
178
  ```sh
179
+ codeless init
170
180
  codeless create <slug>
171
181
  codeless open <slug>
172
182
  codeless planner <slug>
@@ -177,21 +187,35 @@ codeless next <numbered-change-file> <landed-commit>
177
187
  codeless metrics
178
188
  ```
179
189
 
180
- Slugs are lowercase kebab-case, at most 24 characters. `create` starts
190
+ Slugs are lowercase kebab-case, at most 24 characters. `init` validates the
191
+ existing configuration and integration branch, reports the branch, primary
192
+ checkout, workspace, and integration worktree, then creates only the shared
193
+ state directories and canonical integration worktree when absent. All other
194
+ commands validate their prerequisites and never bootstrap this setup. `create` starts
181
195
  `stream/<slug>` from the integration branch and creates its local documents;
182
196
  it refuses existing streams. `open` resumes a stream. Both run the configured
183
197
  install command, then validate and open a planner beside an idle shell. `planner`
184
198
  starts Pi in an existing stream's lone shell after the same role preflight. Its
185
199
  activation establishes the same identity as creation and reopening.
186
200
  Dispatch validates the implementer selection before touching the planner's
187
- right-hand pane, starts a fresh ephemeral implementer, and waits for completion.
201
+ 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,
205
+ terminal text and outcome, full-session Pi usage and available Pi cost estimate,
206
+ timestamps, and tool/error counts; prompts, source, thinking, credentials, and
207
+ transcripts are not retained.
188
208
 
189
209
  The first valid dispatch creates one atomic local JSON metric record for its
190
- stream and numbered change. Retries preserve its original dispatch time. After a
191
- successful integration fast-forward, Codeless records the landed time and commit
192
- on that change's canonical record, creating a landed record without elapsed time
193
- when dispatch collection was unavailable; collection warnings never alter
194
- dispatch or landing.
210
+ stream and numbered change. Each accepted run receives a new attempt ID and is
211
+ added idempotently under that record; re-ingesting an ID preserves the original
212
+ attempt and retries preserve the original dispatch time. Missing, malformed, or
213
+ unwritable collection data warns and stores an incomplete attempt when possible
214
+ without retrying or failing a settled implementation. After a successful
215
+ integration fast-forward, Codeless records the landed time and commit on that
216
+ change's canonical record, creating a landed record without elapsed time when
217
+ dispatch collection was unavailable; collection warnings never alter dispatch or
218
+ landing.
195
219
  `codeless metrics` prints every recorded stream and a project total. Its elapsed
196
220
  columns are dispatch-to-land wall-clock time; among landed changes, records
197
221
  without a measured duration are explicitly unavailable. Dispatched-but-unlanded
@@ -233,8 +257,35 @@ From this package directory, run `bun run check` for formatting, lint, types,
233
257
  and tests, or `bun run test` for tests alone. The integration tests use real Git
234
258
  worktrees and mock Herdr/Pi commands; they never launch actual agents.
235
259
 
236
- `npm publish` runs the full check through `prepublishOnly` and publishes
237
- `@dpeek/codeless` with public access.
260
+ From a clean `main` checkout, publish the next patch release with:
261
+
262
+ ```sh
263
+ bun run release
264
+ ```
265
+
266
+ The script verifies npm authentication, runs the full check and an npm package
267
+ dry run, increments the patch version, commits `package.json` with the version as
268
+ the complete commit message, creates the matching version tag, and publishes
269
+ `@dpeek/codeless`. It intentionally does not push the commit or tag.
270
+
271
+ For non-interactive local publishing, create an npm granular access token with
272
+ read/write access to `@dpeek/codeless` (or the `@dpeek` scope) and **Bypass 2FA**
273
+ enabled. Put the token in the repository's ignored `.env` file:
274
+
275
+ ```sh
276
+ NPM_TOKEN=npm_your_token_here
277
+ ```
278
+
279
+ Reference that variable from your user-level `~/.npmrc`:
280
+
281
+ ```ini
282
+ //registry.npmjs.org/:_authToken=${NPM_TOKEN}
283
+ ```
284
+
285
+ `bun run release` loads `.env` and passes the token to npm. Never put the token
286
+ directly in a committed `.npmrc` or source file. If npm package settings disallow
287
+ tokens, publishing will still require an OTP. For hosted CI, prefer npm trusted
288
+ publishing instead of a long-lived token.
238
289
 
239
290
  Keep source, tests, executable, extension, and dependencies inside this project.
240
291
  Keep project policies and real prompts outside it. Add automation only for
@@ -0,0 +1,147 @@
1
+ import { existsSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
+
3
+ function text(message) {
4
+ if (message?.role !== "assistant" || !Array.isArray(message.content)) return undefined;
5
+ const value = message.content
6
+ .filter((part) => part?.type === "text" && typeof part.text === "string")
7
+ .map((part) => part.text)
8
+ .join("");
9
+ return value || undefined;
10
+ }
11
+
12
+ function validUsage(value) {
13
+ return (
14
+ typeof value === "object" &&
15
+ value !== null &&
16
+ ["input", "output", "cacheRead", "cacheWrite"].every(
17
+ (key) => Number.isFinite(value[key]) && value[key] >= 0,
18
+ )
19
+ );
20
+ }
21
+
22
+ function sessionUsage(entries) {
23
+ const usages = [];
24
+ for (const entry of entries) {
25
+ if (
26
+ entry?.type === "message" &&
27
+ (entry.message?.role === "assistant" || entry.message?.role === "toolResult") &&
28
+ validUsage(entry.message.usage)
29
+ )
30
+ usages.push(entry.message.usage);
31
+ if (
32
+ (entry?.type === "compaction" || entry?.type === "branch_summary") &&
33
+ validUsage(entry.usage)
34
+ )
35
+ usages.push(entry.usage);
36
+ }
37
+ if (usages.length === 0) return undefined;
38
+ const total = (key) => usages.reduce((sum, usage) => sum + usage[key], 0);
39
+ const costs = usages.map((usage) => usage.cost?.total);
40
+ return {
41
+ input: total("input"),
42
+ output: total("output"),
43
+ cacheRead: total("cacheRead"),
44
+ cacheWrite: total("cacheWrite"),
45
+ ...(costs.every((cost) => Number.isFinite(cost) && cost >= 0)
46
+ ? {
47
+ cost: {
48
+ amount: costs.reduce((sum, cost) => sum + cost, 0),
49
+ currency: "USD",
50
+ source: "pi-model-estimate",
51
+ },
52
+ }
53
+ : {}),
54
+ };
55
+ }
56
+
57
+ function messages(entries) {
58
+ return entries
59
+ .filter((entry) => entry?.type === "message")
60
+ .map((entry) => entry.message)
61
+ .filter((message) => message?.role === "assistant" || message?.role === "toolResult");
62
+ }
63
+
64
+ function write(path, value) {
65
+ const temporary = `${path}.${process.pid}.${crypto.randomUUID()}`;
66
+ try {
67
+ writeFileSync(temporary, `${JSON.stringify(value)}\n`, { flag: "wx" });
68
+ renameSync(temporary, path);
69
+ } finally {
70
+ if (existsSync(temporary)) unlinkSync(temporary);
71
+ }
72
+ }
73
+
74
+ export default function implementerReportingExtension(pi) {
75
+ pi.registerFlag("codeless-attempt", { type: "string" });
76
+ let configuration;
77
+ let startedAt;
78
+ let toolCalls = 0;
79
+ let errorCount = 0;
80
+ pi.on("session_start", () => {
81
+ try {
82
+ 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;
90
+ } catch {}
91
+ });
92
+ pi.on("agent_start", () => {
93
+ startedAt ??= new Date().toISOString();
94
+ });
95
+ pi.on("tool_execution_end", (event) => {
96
+ toolCalls += 1;
97
+ if (event.isError) errorCount += 1;
98
+ });
99
+ pi.on("agent_settled", (_event, ctx) => {
100
+ if (configuration === undefined) return;
101
+ const report = configuration;
102
+ configuration = undefined;
103
+ const settledMessages = messages(ctx.sessionManager.getEntries());
104
+ const final = [...settledMessages].reverse().find((message) => message.role === "assistant");
105
+ const totals = sessionUsage(ctx.sessionManager.getEntries());
106
+ const model = final?.responseModel ?? final?.model ?? ctx.model?.id;
107
+ const provider = final?.provider ?? ctx.model?.provider;
108
+ const selection =
109
+ typeof provider === "string" &&
110
+ typeof model === "string" &&
111
+ typeof pi.getThinkingLevel() === "string"
112
+ ? { provider, model, thinking: pi.getThinkingLevel() }
113
+ : undefined;
114
+ const finalText = text(final);
115
+ const complete =
116
+ selection !== undefined &&
117
+ typeof final?.stopReason === "string" &&
118
+ final.stopReason.length > 0 &&
119
+ finalText !== undefined &&
120
+ totals !== undefined;
121
+ write(report.path, {
122
+ id: report.id,
123
+ stream: report.stream,
124
+ change: report.change,
125
+ role: "implementer",
126
+ startedAt: startedAt ?? new Date().toISOString(),
127
+ endedAt: new Date().toISOString(),
128
+ ...(selection === undefined ? {} : { selection }),
129
+ outcome: final?.stopReason ?? "unknown",
130
+ ...(finalText === undefined ? {} : { text: finalText }),
131
+ ...(totals === undefined
132
+ ? {}
133
+ : {
134
+ usage: {
135
+ input: totals.input,
136
+ output: totals.output,
137
+ cacheRead: totals.cacheRead,
138
+ cacheWrite: totals.cacheWrite,
139
+ },
140
+ ...(totals.cost === undefined ? {} : { cost: totals.cost }),
141
+ }),
142
+ toolCalls,
143
+ errorCount,
144
+ incomplete: !complete,
145
+ });
146
+ });
147
+ }
@@ -1,4 +1,5 @@
1
1
  import { fileURLToPath } from "node:url";
2
+ import { validAttempt } from "../src/attempt.ts";
2
3
 
3
4
  const codeless = fileURLToPath(new URL("../bin/codeless", import.meta.url));
4
5
  const thinkingLevels = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
@@ -322,13 +323,22 @@ export default function plannerExtension(pi) {
322
323
  if (execution.code !== 0) {
323
324
  throw new Error(output || `codeless dispatch failed with exit code ${execution.code}`);
324
325
  }
326
+ let attempt;
327
+ try {
328
+ attempt = JSON.parse(execution.stdout);
329
+ } catch {
330
+ throw new Error("Codeless returned an invalid implementer attempt");
331
+ }
332
+ if (!validAttempt(attempt, attempt?.stream, attempt?.change)) {
333
+ throw new Error("Codeless returned an invalid implementer attempt");
334
+ }
325
335
  pi.sendUserMessage(`/review ${JSON.stringify(changePath)}`, {
326
336
  deliverAs: "steer",
327
337
  expandPromptTemplates: true,
328
338
  });
329
339
  return {
330
- content: [{ type: "text", text: output || "Implementer settled." }],
331
- details: { changePath },
340
+ content: [{ type: "text", text: attempt.text || "Implementer settled." }],
341
+ details: { changePath, attempt },
332
342
  };
333
343
  },
334
344
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dpeek/codeless",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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": {
@@ -27,7 +27,7 @@
27
27
  },
28
28
  "scripts": {
29
29
  "check": "oxfmt --config .oxfmtrc.json --write . && oxlint --config .oxlintrc.json --fix --type-aware --type-check . && bun run test",
30
- "prepublishOnly": "bun run check",
30
+ "release": "bun ./scripts/release.ts",
31
31
  "test": "bun test ./test --dots"
32
32
  },
33
33
  "dependencies": {
package/spec/workflow.md CHANGED
@@ -49,6 +49,18 @@ Git-ignored, and every linked worktree resolves the same primary-checkout state:
49
49
  metrics/<slug>/NNN.json
50
50
  ```
51
51
 
52
+ `codeless init` is the explicit, idempotent bootstrap for a configured project.
53
+ It requires the configured integration branch to exist and creates the shared
54
+ state directories plus that branch's worktree only at
55
+ `<workspace>/worktree/<integration-branch>`. It reuses only the exact registered
56
+ canonical checkout. An occupied target, a branch registered elsewhere, invalid
57
+ checkout, or ambiguous Git registration stops unchanged; init never switches
58
+ branches, moves worktrees, or repairs conflicts. With the default workspace,
59
+ it accepts a repository ignore rule only when it ignores the state path without
60
+ covering configuration or configured prompts, otherwise appending the narrow
61
+ `/.codeless/state/` rule. An absolute workspace never changes repository
62
+ ignores. No other command bootstraps this layout.
63
+
52
64
  `planner.md` owns decisions, approvals, review outcomes, landing history, and
53
65
  the context needed by a fresh planner. `change.md` is the editable current
54
66
  proposal. `changes/NNN.md` is the immutable-by-policy approved input to one
@@ -108,7 +120,23 @@ right-hand Herdr pane only when that pane is an available shell or the expected
108
120
  idle implementer, starts a fresh ephemeral Pi implementer in the stream
109
121
  worktree, submits `/implement`, and waits for at most one hour.
110
122
 
111
- Successful dispatch queues the expanded `/review` prompt back into the planner.
123
+ Successful dispatch loads the package-owned reporting extension while retaining
124
+ `--no-session` and passes its report configuration through that extension's
125
+ explicit Pi string flag, then returns one normalized attempt to the planner tool
126
+ before it queues the expanded `/review` prompt. Attempts have a stable ID and
127
+ capture only stream/change/role, start and settlement timestamps, Pi's actual
128
+ settled provider/model/thinking selection, terminal outcome and final text,
129
+ full-session Pi input/output/cache usage (including tool results, compaction,
130
+ and branch summaries), available Pi model cost estimate with USD currency and
131
+ source, and tool/error counts. Cost is omitted when Pi did not supply valid
132
+ cost totals. They do not retain prompts, source, credentials, thinking, or a
133
+ transcript. The extension writes its narrow report atomically once, then
134
+ remains disarmed for remediation; Codeless atomically deduplicates it inside the
135
+ per-change metric record, rejecting a
136
+ conflicting duplicate ID. Missing, malformed, or unwritable collection warns
137
+ and yields an explicitly incomplete attempt when possible without failing or
138
+ repeating a settled implementation.
139
+
112
140
  The planner inspects the full diff and relevant code, checks the approved
113
141
  acceptance criteria, and runs focused checks when the implementation output is
114
142
  insufficient. Remediation reuses the same implementer context. Once approved,
@@ -116,10 +144,8 @@ the planner records the review result, exits the implementer so its pane returns
116
144
  to a shell, and follows the commit-and-land prompt without another approval
117
145
  round.
118
146
 
119
- Dispatch and remediation do not retry automatically. Dispatch currently exposes
120
- Herdr command output rather than a normalized implementation result, and
121
- remediation and implementer shutdown are still performed through prompt-owned
122
- Herdr commands.
147
+ Dispatch and remediation do not retry automatically. Remediation and implementer
148
+ shutdown are still performed through prompt-owned Herdr commands.
123
149
 
124
150
  ## Commit and landing
125
151
 
@@ -162,10 +188,11 @@ before another implementation.
162
188
  ## Local workflow metrics
163
189
 
164
190
  The first dispatch for a stream and numbered change creates one atomic local
165
- metric record. Retrying dispatch preserves the original timestamp. Successful
166
- landing adds its timestamp and commit, or creates a landed record with
167
- unavailable elapsed time when dispatch collection was unavailable. Collection
168
- warnings do not change dispatch or landing outcomes.
191
+ metric record. Every accepted dispatch creates a new attempt ID; re-ingesting an
192
+ attempt ID is atomic and idempotent, while the original dispatch timestamp stays
193
+ unchanged. Successful landing adds its timestamp and commit, or creates a landed
194
+ record with unavailable elapsed time when dispatch collection was unavailable.
195
+ Collection warnings do not change dispatch or landing outcomes.
169
196
 
170
197
  `codeless metrics` reports every recorded stream and a project total with:
171
198
 
@@ -174,9 +201,9 @@ warnings do not change dispatch or landing outcomes.
174
201
  - total and average dispatch-to-land wall-clock time.
175
202
 
176
203
  The measurements are prospective, local observations. They are not journal
177
- state, an approval source, or a recovery mechanism. Token usage, cost,
178
- normalized attempt results, review rework, and failure breakdowns are not yet
179
- collected.
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.
180
207
 
181
208
  ## Limits
182
209
 
package/src/attempt.ts ADDED
@@ -0,0 +1,101 @@
1
+ export type Attempt = {
2
+ id: string;
3
+ stream: string;
4
+ change: string;
5
+ role: "implementer";
6
+ startedAt: string;
7
+ endedAt: string;
8
+ selection?: { provider: string; model: string; thinking: string };
9
+ outcome: string;
10
+ text?: string;
11
+ usage?: { input: number; output: number; cacheRead: number; cacheWrite: number };
12
+ cost?: { amount: number; currency: string; source: string };
13
+ toolCalls: number;
14
+ errorCount: number;
15
+ incomplete: boolean;
16
+ };
17
+
18
+ export function validAttempt(value: unknown, stream: string, change: string): value is Attempt {
19
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
20
+ const attempt = value as Record<string, unknown>;
21
+ const allowed = new Set([
22
+ "id",
23
+ "stream",
24
+ "change",
25
+ "role",
26
+ "startedAt",
27
+ "endedAt",
28
+ "selection",
29
+ "outcome",
30
+ "text",
31
+ "usage",
32
+ "cost",
33
+ "toolCalls",
34
+ "errorCount",
35
+ "incomplete",
36
+ ]);
37
+ const strings = ["id", "stream", "change", "role", "startedAt", "endedAt", "outcome"];
38
+ if (
39
+ !Object.keys(attempt).every((key) => allowed.has(key)) ||
40
+ !strings.every((key) => typeof attempt[key] === "string" && attempt[key].length > 0) ||
41
+ !Number.isFinite(Date.parse(attempt["startedAt"] as string)) ||
42
+ !Number.isFinite(Date.parse(attempt["endedAt"] as string)) ||
43
+ attempt["stream"] !== stream ||
44
+ attempt["change"] !== change ||
45
+ attempt["role"] !== "implementer" ||
46
+ !Number.isSafeInteger(attempt["toolCalls"]) ||
47
+ (attempt["toolCalls"] as number) < 0 ||
48
+ !Number.isSafeInteger(attempt["errorCount"]) ||
49
+ (attempt["errorCount"] as number) < 0 ||
50
+ typeof attempt["incomplete"] !== "boolean"
51
+ )
52
+ return false;
53
+ const selection = attempt["selection"];
54
+ if (
55
+ selection !== undefined &&
56
+ (typeof selection !== "object" ||
57
+ selection === null ||
58
+ ["provider", "model", "thinking"].some(
59
+ (key) =>
60
+ typeof (selection as Record<string, unknown>)[key] !== "string" ||
61
+ !(selection as Record<string, unknown>)[key],
62
+ ))
63
+ )
64
+ return false;
65
+ const usage = attempt["usage"];
66
+ if (
67
+ usage !== undefined &&
68
+ (typeof usage !== "object" ||
69
+ usage === null ||
70
+ ["input", "output", "cacheRead", "cacheWrite"].some(
71
+ (key) =>
72
+ !Number.isSafeInteger((usage as Record<string, unknown>)[key]) ||
73
+ ((usage as Record<string, unknown>)[key] as number) < 0,
74
+ ))
75
+ )
76
+ return false;
77
+ if (
78
+ attempt["incomplete"] === false &&
79
+ (selection === undefined ||
80
+ usage === undefined ||
81
+ attempt["outcome"] === "unknown" ||
82
+ typeof attempt["text"] !== "string" ||
83
+ attempt["text"].length === 0)
84
+ )
85
+ return false;
86
+ const cost = attempt["cost"];
87
+ if (
88
+ cost !== undefined &&
89
+ (usage === undefined ||
90
+ typeof cost !== "object" ||
91
+ cost === null ||
92
+ !Number.isFinite((cost as Record<string, unknown>)["amount"]) ||
93
+ ((cost as Record<string, unknown>)["amount"] as number) < 0 ||
94
+ typeof (cost as Record<string, unknown>)["currency"] !== "string" ||
95
+ !(cost as Record<string, unknown>)["currency"] ||
96
+ typeof (cost as Record<string, unknown>)["source"] !== "string" ||
97
+ !(cost as Record<string, unknown>)["source"])
98
+ )
99
+ return false;
100
+ return attempt["text"] === undefined || typeof attempt["text"] === "string";
101
+ }
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,11 +16,13 @@ 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>
@@ -36,9 +39,14 @@ export async function runCodeless(args: string[]): Promise<void> {
36
39
  return;
37
40
  }
38
41
  const repository = canonicalPath(run("git", ["rev-parse", "--show-toplevel"]).trim());
39
- const { integrationBranch } = readProject(repository);
42
+ const project = readProject(repository);
43
+ const { integrationBranch } = project;
40
44
  run("git", ["check-ref-format", "--branch", integrationBranch], repository);
41
45
  const plannerExtension = resolve(import.meta.dir, "../extension/planner.js");
46
+ const implementerReportingExtension = resolve(
47
+ import.meta.dir,
48
+ "../extension/implementer-reporting.js",
49
+ );
42
50
  const configuredWorkspace = Bun.spawnSync(
43
51
  ["git", "config", "--local", "--get", "codeless.workspaceRoot"],
44
52
  { cwd: repository },
@@ -123,28 +131,146 @@ export async function runCodeless(args: string[]): Promise<void> {
123
131
  .join("-");
124
132
  }
125
133
 
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}`)) {
134
+ type Worktree = { path: string; branch?: string };
135
+
136
+ function registeredWorktrees(): Worktree[] {
137
+ return run("git", ["worktree", "list", "--porcelain", "-z"], repository)
138
+ .split("\0\0")
139
+ .filter(Boolean)
140
+ .map((entry) => {
141
+ const fields = entry.split("\0");
131
142
  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`);
143
+ if (path === undefined) throw new Error("Git did not report a worktree path");
144
+ const branch = fields.find((field) => field.startsWith("branch "))?.slice(7);
145
+ return { path: canonicalPath(path), ...(branch === undefined ? {} : { branch }) };
146
+ });
147
+ }
148
+
149
+ function integrationWorktree(): string {
150
+ const matches = registeredWorktrees().filter(
151
+ (worktree) => worktree.branch === `refs/heads/${integrationBranch}`,
152
+ );
153
+ if (matches.length !== 1)
154
+ throw new Error(`${integrationBranch} needs exactly one dedicated integration worktree`);
155
+ return matches[0]!.path;
136
156
  }
137
157
 
138
158
  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);
159
+ const path = registeredWorktrees()[0]?.path;
146
160
  if (path === undefined) throw new Error("Git did not report a primary worktree");
147
- return canonicalPath(path);
161
+ return path;
162
+ }
163
+
164
+ function init(): void {
165
+ run("git", ["show-ref", "--verify", `refs/heads/${integrationBranch}`], repository);
166
+ const primary = primaryWorktree();
167
+ const target = canonicalPath(join(workspaceRoot, "worktree", integrationBranch));
168
+ const matchingBranch = registeredWorktrees().filter(
169
+ (worktree) => worktree.branch === `refs/heads/${integrationBranch}`,
170
+ );
171
+ const targetExists = existsSync(target);
172
+
173
+ let workspaceParent = workspaceRoot;
174
+ while (!existsSync(workspaceParent)) workspaceParent = dirname(workspaceParent);
175
+ if (!statSync(workspaceParent).isDirectory()) {
176
+ throw new Error(`Workspace parent is not a directory: ${workspaceParent}`);
177
+ }
178
+ const worktreeRoot = join(workspaceRoot, "worktree");
179
+ const stateDirectories: [string, string][] = [
180
+ ["Workspace", workspaceRoot],
181
+ ["Workspace stream path", join(workspaceRoot, "stream")],
182
+ ["Workspace worktree path", worktreeRoot],
183
+ ["Workspace metrics path", join(workspaceRoot, "metrics")],
184
+ ];
185
+ for (const [label, path] of stateDirectories) {
186
+ if (existsSync(path) && !statSync(path).isDirectory()) {
187
+ throw new Error(`${label} is not a directory: ${path}`);
188
+ }
189
+ }
190
+ const defaultWorkspace = workspaceRoot === canonicalPath(join(primary, ".codeless", "state"));
191
+ const ignoreFile = join(primary, ".gitignore");
192
+ let stateIgnored = false;
193
+ if (defaultWorkspace) {
194
+ if (existsSync(ignoreFile) && !statSync(ignoreFile).isFile()) {
195
+ throw new Error(`Repository ignore file is not a file: ${ignoreFile}`);
196
+ }
197
+
198
+ function ignoredByRepository(path: string): boolean {
199
+ const effective = Bun.spawnSync(["git", "check-ignore", "-q", "--no-index", path], {
200
+ cwd: primary,
201
+ env: process.env,
202
+ stdin: "ignore",
203
+ stdout: "ignore",
204
+ stderr: "pipe",
205
+ });
206
+ if (effective.exitCode === 1) return false;
207
+ if (effective.exitCode !== 0) {
208
+ throw new Error(
209
+ effective.stderr.toString().trim() || "Could not inspect repository ignores",
210
+ );
211
+ }
212
+ const source = run("git", ["check-ignore", "-v", "--no-index", path], primary).split(
213
+ ":",
214
+ 1,
215
+ )[0];
216
+ return source === ".gitignore" || source === ignoreFile;
217
+ }
218
+
219
+ const protectedPaths = [
220
+ ".codeless/config.json",
221
+ ...["change", "implement", "review", "commit"].map((name) =>
222
+ join(project.prompts, `${name}.md`),
223
+ ),
224
+ ];
225
+ if (protectedPaths.some((path) => ignoredByRepository(path))) {
226
+ throw new Error(
227
+ `Repository ignore rule conflicts with Codeless configuration or prompts; narrow ${ignoreFile} to /.codeless/state/`,
228
+ );
229
+ }
230
+ stateIgnored = ignoredByRepository(join(".codeless", "state", ".codeless-init-probe"));
231
+ }
232
+
233
+ if (matchingBranch.length > 1) {
234
+ throw new Error(`Git reports ${integrationBranch} checked out in multiple worktrees`);
235
+ }
236
+ if (matchingBranch.length === 1 && matchingBranch[0]!.path !== target) {
237
+ throw new Error(
238
+ `${integrationBranch} is checked out at ${matchingBranch[0]!.path}, expected ${target}`,
239
+ );
240
+ }
241
+ if (matchingBranch.length === 1 && (!targetExists || !statSync(target).isDirectory())) {
242
+ throw new Error(
243
+ `Git registers ${integrationBranch} at invalid integration worktree ${target}`,
244
+ );
245
+ }
246
+ if (
247
+ matchingBranch.length === 1 &&
248
+ run("git", ["branch", "--show-current"], target).trim() !== integrationBranch
249
+ ) {
250
+ throw new Error(`Integration worktree is not on ${integrationBranch}: ${target}`);
251
+ }
252
+ if (matchingBranch.length === 0 && targetExists) {
253
+ throw new Error(`Integration worktree target is occupied: ${target}`);
254
+ }
255
+
256
+ if (defaultWorkspace && !stateIgnored) {
257
+ const currentIgnore = existsSync(ignoreFile) ? readFileSync(ignoreFile, "utf8") : "";
258
+ appendFileSync(
259
+ ignoreFile,
260
+ `${currentIgnore.length > 0 && !currentIgnore.endsWith("\n") ? "\n" : ""}/.codeless/state/\n`,
261
+ );
262
+ }
263
+ mkdirSync(join(workspaceRoot, "stream"), { recursive: true });
264
+ mkdirSync(worktreeRoot, { recursive: true });
265
+ mkdirSync(join(workspaceRoot, "metrics"), { recursive: true });
266
+ if (matchingBranch.length === 0) {
267
+ run("git", ["worktree", "add", target, integrationBranch], repository);
268
+ }
269
+
270
+ console.log(`Integration branch: ${integrationBranch}`);
271
+ console.log(`Primary checkout: ${primary}`);
272
+ console.log(`Workspace: ${workspaceRoot}`);
273
+ console.log(`Integration worktree: ${target}`);
148
274
  }
149
275
 
150
276
  function requireDirection(slug: string, worktree: string): string {
@@ -313,6 +439,48 @@ export async function runCodeless(args: string[]): Promise<void> {
313
439
  }
314
440
  }
315
441
 
442
+ function incompleteAttempt(
443
+ id: string,
444
+ slug: string,
445
+ number: string,
446
+ selection: { provider: string; model: string; thinking: string },
447
+ ): Attempt {
448
+ const timestamp = new Date().toISOString();
449
+ return {
450
+ id,
451
+ stream: slug,
452
+ change: number,
453
+ role: "implementer",
454
+ startedAt: timestamp,
455
+ endedAt: timestamp,
456
+ selection,
457
+ outcome: "unknown",
458
+ toolCalls: 0,
459
+ errorCount: 0,
460
+ incomplete: true,
461
+ };
462
+ }
463
+
464
+ function collectedAttempt(value: unknown, fallback: Attempt): Attempt {
465
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return fallback;
466
+ const attempt = value as Partial<Attempt>;
467
+ if (
468
+ attempt.id !== fallback.id ||
469
+ attempt.stream !== fallback.stream ||
470
+ attempt.change !== fallback.change ||
471
+ attempt.role !== "implementer" ||
472
+ typeof attempt.startedAt !== "string" ||
473
+ typeof attempt.endedAt !== "string" ||
474
+ typeof attempt.outcome !== "string" ||
475
+ typeof attempt.toolCalls !== "number" ||
476
+ typeof attempt.errorCount !== "number" ||
477
+ attempt.incomplete !== false ||
478
+ !validAttempt(attempt, fallback.stream, fallback.change)
479
+ )
480
+ return fallback;
481
+ return attempt;
482
+ }
483
+
316
484
  function latestChangeNumber(slug: string): string {
317
485
  const change = readdirSync(join(workspaceRoot, "stream", slug, "changes"))
318
486
  .filter((file) => /^\d{3}\.md$/.test(file))
@@ -538,9 +706,11 @@ export async function runCodeless(args: string[]): Promise<void> {
538
706
  requireClean(worktree, branch);
539
707
  const prompts = promptDirectory(worktree);
540
708
  const selection = readProject(worktree).implementer;
709
+ const attemptId = crypto.randomUUID();
710
+ const reportPath = join(workspaceRoot, "metrics", slug, `.attempt-${attemptId}.json`);
711
+ const fallbackAttempt = incompleteAttempt(attemptId, slug, number, selection);
541
712
  observe("dispatch metrics", () => recordDispatch(workspaceRoot, slug, number));
542
713
  await validateRoleSelection("implementer", selection, worktree);
543
- console.log(roleSelectionSummary("implementer", selection));
544
714
 
545
715
  const plannerPane = string(process.env["HERDR_PANE_ID"], "HERDR_PANE_ID");
546
716
  const plannerProcesses = foregroundProcesses(paneProcessInfo(plannerPane));
@@ -592,6 +762,12 @@ export async function runCodeless(args: string[]): Promise<void> {
592
762
  }
593
763
  requirePaneShell(implementerPane, worktree);
594
764
 
765
+ const collection = JSON.stringify({
766
+ id: attemptId,
767
+ stream: slug,
768
+ change: number,
769
+ path: reportPath,
770
+ });
595
771
  const started = herdr([
596
772
  "agent",
597
773
  "start",
@@ -602,6 +778,10 @@ export async function runCodeless(args: string[]): Promise<void> {
602
778
  implementerPane,
603
779
  "--",
604
780
  "--no-session",
781
+ "--extension",
782
+ implementerReportingExtension,
783
+ "--codeless-attempt",
784
+ collection,
605
785
  "--name",
606
786
  `${slug}-impl`,
607
787
  ...roleSelectionArguments(selection),
@@ -618,7 +798,7 @@ export async function runCodeless(args: string[]): Promise<void> {
618
798
  throw new Error(`${implementerName} started in ${startedCwd}, expected ${worktree}`);
619
799
  }
620
800
 
621
- const completed = run("herdr", [
801
+ run("herdr", [
622
802
  "agent",
623
803
  "prompt",
624
804
  implementerName,
@@ -627,7 +807,19 @@ export async function runCodeless(args: string[]): Promise<void> {
627
807
  "--timeout",
628
808
  "3600000",
629
809
  ]);
630
- console.log(completed.trim());
810
+ let attempt = fallbackAttempt;
811
+ try {
812
+ attempt = collectedAttempt(JSON.parse(readFileSync(reportPath, "utf8")), fallbackAttempt);
813
+ if (attempt === fallbackAttempt) throw new Error("report did not match its dispatch attempt");
814
+ } catch (error) {
815
+ console.error(
816
+ `codeless: warning: could not collect implementer attempt: ${error instanceof Error ? error.message : String(error)}`,
817
+ );
818
+ } finally {
819
+ if (existsSync(reportPath)) unlinkSync(reportPath);
820
+ }
821
+ observe("implementer attempt", () => recordAttempt(workspaceRoot, slug, number, attempt));
822
+ console.log(JSON.stringify(attempt));
631
823
  }
632
824
 
633
825
  async function launchPlanner(slug: string): Promise<void> {
@@ -785,6 +977,11 @@ export async function runCodeless(args: string[]): Promise<void> {
785
977
  }
786
978
  }
787
979
 
980
+ if (action === "init") {
981
+ if (target !== undefined || details.length > 0) throw new Error(usage);
982
+ init();
983
+ return;
984
+ }
788
985
  if (action === "metrics") {
789
986
  if (target !== undefined || details.length > 0) throw new Error(usage);
790
987
  for (const line of metricReport(workspaceRoot)) console.log(line);
package/src/metrics.ts CHANGED
@@ -5,17 +5,21 @@ import {
5
5
  readFileSync,
6
6
  readdirSync,
7
7
  renameSync,
8
+ rmdirSync,
8
9
  unlinkSync,
9
10
  writeFileSync,
10
11
  } from "node:fs";
11
12
  import { dirname, join } from "node:path";
12
13
 
14
+ import { type Attempt, validAttempt } from "./attempt.ts";
15
+
13
16
  export type Metric = {
14
17
  stream: string;
15
18
  change: string;
16
19
  dispatchedAt?: string;
17
20
  landedAt?: string;
18
21
  landedCommit?: string;
22
+ attempts?: Record<string, Attempt>;
19
23
  };
20
24
 
21
25
  function metricPath(workspaceRoot: string, stream: string, change: string): string {
@@ -32,7 +36,16 @@ function readMetric(path: string): Metric {
32
36
  typeof metric["change"] !== "string" ||
33
37
  (metric["dispatchedAt"] !== undefined && typeof metric["dispatchedAt"] !== "string") ||
34
38
  (metric["landedAt"] !== undefined && typeof metric["landedAt"] !== "string") ||
35
- (metric["landedCommit"] !== undefined && typeof metric["landedCommit"] !== "string")
39
+ (metric["landedCommit"] !== undefined && typeof metric["landedCommit"] !== "string") ||
40
+ (metric["attempts"] !== undefined &&
41
+ (typeof metric["attempts"] !== "object" ||
42
+ metric["attempts"] === null ||
43
+ Array.isArray(metric["attempts"]) ||
44
+ !Object.entries(metric["attempts"] as Record<string, unknown>).every(
45
+ ([id, attempt]) =>
46
+ validAttempt(attempt, metric["stream"] as string, metric["change"] as string) &&
47
+ attempt.id === id,
48
+ )))
36
49
  )
37
50
  throw new Error(`${path} is not a metric record`);
38
51
  return metric as Metric;
@@ -73,6 +86,50 @@ export function recordDispatch(workspaceRoot: string, stream: string, change: st
73
86
  readMetric(path);
74
87
  }
75
88
 
89
+ function withMetricLock(path: string, action: () => void): void {
90
+ const lock = `${path}.lock`;
91
+ mkdirSync(dirname(path), { recursive: true });
92
+ for (let attempt = 0; attempt < 100; attempt += 1) {
93
+ try {
94
+ mkdirSync(lock);
95
+ } catch (error) {
96
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
97
+ Bun.sleepSync(10);
98
+ continue;
99
+ }
100
+ try {
101
+ action();
102
+ } finally {
103
+ rmdirSync(lock);
104
+ }
105
+ return;
106
+ }
107
+ throw new Error(`could not acquire metric lock ${lock}`);
108
+ }
109
+
110
+ export function recordAttempt(
111
+ workspaceRoot: string,
112
+ stream: string,
113
+ change: string,
114
+ attempt: Attempt,
115
+ ): void {
116
+ if (!validAttempt(attempt, stream, change))
117
+ throw new Error("attempt is not a valid metric attempt");
118
+ const path = metricPath(workspaceRoot, stream, change);
119
+ withMetricLock(path, () => {
120
+ const metric = existsSync(path) ? readMetric(path) : { stream, change };
121
+ if (metric.stream !== stream || metric.change !== change)
122
+ throw new Error(`${path} does not match ${stream} change ${change}`);
123
+ const existing = metric.attempts?.[attempt.id];
124
+ if (existing !== undefined) {
125
+ if (JSON.stringify(existing) !== JSON.stringify(attempt))
126
+ throw new Error(`attempt ${attempt.id} conflicts with its existing metric record`);
127
+ return;
128
+ }
129
+ writeMetric(path, { ...metric, attempts: { ...metric.attempts, [attempt.id]: attempt } });
130
+ });
131
+ }
132
+
76
133
  export function recordLanding(
77
134
  workspaceRoot: string,
78
135
  stream: string,