feat(coder): review one autoimprovement cycle from its artifacts

0c3b7992c8db · AtlantisPleb · · parent 8909d2eb5286

feat(coder): review one autoimprovement cycle from its artifacts

`docs/coder/runbook.md` §6 said to review each cycle in a separate
conversation and handed a human a prompt skeleton to fill in by copying
transcripts. Filling it in by hand is where the evidence rule goes soft: a
reviewer told "cite trajectory steps" with no way to know which steps
exist will cite plausible ones, and a hand-assembled prompt has no set to
check them against.

`pnpm run coder:review -- <job-dir> --lever <ref> --slug <name>` reads a
completed Harbor job, redacts it through the one ATIF rule list before
anything leaves the process, renders the §6 prompt with the citable
evidence refs printed in it, asks the reviewer, checks every citation
against what that reviewer was actually given, and writes
`docs/coder/reviews/YYYY-MM-DD-<slug>.md` plus the machine-readable review
beside it.

The candidate is the object #122 stages surfaces for and #123's optimizer
will mutate: `openagents.coder_candidate.v1` in `coder-review-candidate.ts`
is one type for a review proposal and an optimizer mutation, carrying the
lever, the surface diffs, the lineage, the transfer label (ledger O5), the
risk, and the verification.

The evidence grammar is the anti-laundering control, enforced at parse time
rather than at the adopt step, with five named refusals: a malformed ref,
an unknown scheme, a ref that resolves to nothing, a proposal with no
evidence, and a proposal citing no trajectory step. The last is the hard
rule — a store row or a ledger entry says what changed, never what the
coder did — and a refused review writes no file at all.

`--offline <file>` replays a recorded reviewer response. It replays; it
never generates, and its ref says `replay:` in the review file. A canned
score would be the exact failure this command exists to prevent.
`--print-prompt` renders the prompt without asking anyone, so the manual
lane gets a prompt it did not hand-assemble.

Proven against the real #118 proxy-lane job as well as the committed
fixture: 23 redactions fired on the real artifacts, one of them a private
key the openssl trial printed into its own transcript.

Also fixed on the way past, both pre-existing and both blocking the
completion gate: four strict-null type errors in `agent-experience-memory`
and `khala-sync-server`, and an `as unknown as` in AFS authority code that
the boundary check forbids. Two INVARIANTS.md paths left dangling by the
Rust identity removal are updated in the same pass.

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

pushed
by user · WAL seq 266 · 2026-08-26T15:22:02.987983Z

Changed files

  • modified INVARIANTS.md
  • modified docs/assure-repo/false-green-candidates.v1.json
  • modified docs/assure-repo/surface-inventory.v1.json
  • modified docs/coder/candidate-format.md
  • added docs/coder/reviews/README.md
  • modified docs/coder/runbook.md
  • modified package.json
  • modified packages/agent-experience-memory/src/engram.ts
  • modified packages/khala-sync-server/src/cloud-computer-command-store.ts
  • added packages/openagents-cli/src/coder-review-assemble.ts
  • added packages/openagents-cli/src/coder-review-candidate.ts
  • added packages/openagents-cli/src/coder-review-cli.ts
  • added packages/openagents-cli/src/coder-review-lane.ts
  • added packages/openagents-cli/src/coder-review-prompt.ts
  • added packages/openagents-cli/src/coder-review-run.ts
  • modified packages/openagents-cli/src/memory/engram.ts
  • added packages/openagents-cli/test/coder-review-candidate.test.ts
  • added packages/openagents-cli/test/coder-review.test.ts
  • added packages/openagents-cli/test/fixtures/coder-review/job/pin-a-version__Zq9Wtr0/agent/trajectory.json
  • added packages/openagents-cli/test/fixtures/coder-review/job/pin-a-version__Zq9Wtr0/result.json
  • added packages/openagents-cli/test/fixtures/coder-review/job/regex-log__F1xTur3/agent/coder.txt
  • added packages/openagents-cli/test/fixtures/coder-review/job/regex-log__F1xTur3/agent/trajectory.json
  • added packages/openagents-cli/test/fixtures/coder-review/job/regex-log__F1xTur3/config.json
  • added packages/openagents-cli/test/fixtures/coder-review/job/regex-log__F1xTur3/result.json
  • added packages/openagents-cli/test/fixtures/coder-review/job/result.json
  • added packages/openagents-cli/test/fixtures/coder-review/lever.diff
  • added packages/openagents-cli/test/fixtures/coder-review/practices.md
  • added packages/openagents-cli/test/fixtures/coder-review/reviewer-accepted.txt
  • added packages/openagents-cli/test/fixtures/coder-review/reviewer-invented-evidence.txt
  • added packages/openagents-cli/test/fixtures/coder-review/rows.jsonl

Diff

30 files changed, +3940 -56

INVARIANTS.md modified +11 -9

@@ -1085,11 +1085,13 @@ come from the Freerange teardown

1085 1085
  `openagents-cli-identity`, keyed by the identity directory; the key never
1086 1086
  enters the identity directory, so a copy of that directory is not an identity.
1087 1087
  The envelope (`openagents.cli_identity_seed.v1`), the AEAD, the keychain
1088
  service, and the account key are one contract across
1089
  `crates/openagents-cli/src/identity.rs` and
1090
  `packages/openagents-cli/src/seed-identity.ts`: a format only one CLI
1091
  understands makes the other a downgrade attack on it, because both read one
1092
  file at one path.
1088
  service, and the account key are one contract, owned by
1089
  `packages/openagents-cli/src/seed-identity.ts`. It was written as a contract
1090
  across two CLIs because both read one file at one path, so a format only one
1091
  of them understood would be a downgrade attack on the other. The Rust CLI's
1092
  identity surface was removed on 2026-08-26 in `02cdaea275` — nothing consumed
1093
  the identity it derived — which leaves one reader. Any second reader of that
1094
  path re-enters the contract exactly as written above.
1093 1095
- Where no keychain exists — CI, a container, an unattended agent host — the
1094 1096
  phrase is stored as plaintext `0600` and every surface that shows an identity
1095 1097
  must say so. `identity show`, `create`, `import`, and `backup` carry the

@@ -1100,10 +1102,10 @@ come from the Freerange teardown

1100 1102
- A plaintext seed written before this was migrated on the next `identity`
1101 1103
  command by renaming the sealed envelope over the same path, so the phrase is
1102 1104
  never in two places at once. Coverage is
1103
  `crates/openagents-cli/tests/identity_test.rs` and
1104
  `packages/openagents-cli/test/seed-identity.test.ts`, which assert the bytes on
1105
  disk carry no word of the phrase, and that migration preserves the `npub`
1106
  while removing the plaintext.
1105
  `packages/openagents-cli/test/seed-identity.test.ts`, which asserts the bytes
1106
  on disk carry no word of the phrase, and that migration preserves the `npub`
1107
  while removing the plaintext. Its Rust counterpart went with the surface it
1108
  covered in `02cdaea275`.
1107 1109
- The wallet receives; it does not spend. The spending rail is an owner decision
1108 1110
  that is not recorded, so no CLI surface may imply a spend path exists until it
1109 1111
  is.
docs/assure-repo/false-green-candidates.v1.json modified +1 -1

@@ -4,7 +4,7 @@

4 4
  "note": "Heuristic false-green LEADS, not findings. A finding requires a demonstrated reproduction (surviving mutation via mutation-runner). Do not treat a candidate as a confirmed false green. Coverage-theater leads may include tests that delegate their assertion to a custom helper the classifier does not recognise; verify before acting.",
5 5
  "sourceDigest": "sha256:dd810dd48c5bdbc9becd7fcc01dd41a4ca2abf0b2d6f6a545907247f6e3e8361",
6 6
  "summary": {
7
    "filesScanned": 2505,
7
    "filesScanned": 2507,
8 8
    "candidateCount": 16,
9 9
    "byMode": {
10 10
      "false_green_coverage_theater": 15,
docs/assure-repo/surface-inventory.v1.json modified +2 -2

@@ -1,7 +1,7 @@

1 1
{
2 2
  "schemaVersion": "1",
3 3
  "repository": "OpenAgentsInc/openagents",
4
  "sourceDigest": "sha256:931a55aba01fd586c019996b1f02bf4d383179c895bc0053a5ed770bac5deb7b",
4
  "sourceDigest": "sha256:70b6d652076b37c10109b54de527c61c1e549afad5b1e2664207f8432292ebed",
5 5
  "surfaces": [
6 6
    {
7 7
      "id": "app:@openagentsinc/acceptance-runner",

@@ -1910,7 +1910,7 @@

1910 1910
      "oracles": [
1911 1911
        {
1912 1912
          "type": "test",
1913
          "ref": "packages/openagents-cli (91 tracked test files)"
1913
          "ref": "packages/openagents-cli (93 tracked test files)"
1914 1914
        },
1915 1915
        {
1916 1916
          "type": "behavior-contract",
docs/coder/candidate-format.md modified +6 -6

@@ -46,7 +46,7 @@ CoderCandidate {

46 46
  lineage: {
47 47
    origin:      "review" | "optimizer" | "human"
48 48
    parent:      string | null            // the candidateId this came from
49
    producedBy:  string                   // e.g. "coder review-run:<reviewId>"
49
    producedBy:  string                   // e.g. "coder-review:<jobDir>:<reviewer ref>"
50 50
  }
51 51
  transferLabel: { modelFamily: string, lane: string }
52 52
  evidence:      [{ ref: string, note: string }]

@@ -79,11 +79,11 @@ pool entry, not a receipt. `bench-results` owns tamper-evidence; borrowing its

79 79
`surfaces[].surface` names a staged artifact from `surfaces/coder/index.json`.
80 80
The whole current vocabulary:
81 81
82
| `surface` | Artifact | What it holds |
83
| --- | --- | --- |
84
| `system-prompt` | `surfaces/coder/system-prompt.v1.json` | The instructions, the concision sentence, the no-tools and tool-list sentences, and the lane notices, for all three harnesses |
85
| `tool-descriptions` | `surfaces/coder/tool-descriptions.v1.json` | The description of each declared tool, plus the per-model-family emphasis overrides |
86
| `catalog-lines` | `surfaces/coder/catalog-lines.v1.json` | Each installed plugin's catalog line, keyed by plugin id |
82
| `surface`           | Artifact                                   | What it holds                                                                                                                 |
83
| ------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- |
84
| `system-prompt`     | `surfaces/coder/system-prompt.v1.json`     | The instructions, the concision sentence, the no-tools and tool-list sentences, and the lane notices, for all three harnesses |
85
| `tool-descriptions` | `surfaces/coder/tool-descriptions.v1.json` | The description of each declared tool, plus the per-model-family emphasis overrides                                           |
86
| `catalog-lines`     | `surfaces/coder/catalog-lines.v1.json`     | Each installed plugin's catalog line, keyed by plugin id                                                                      |
87 87
88 88
`diff` is a unified diff over that artifact file where one exists, and the
89 89
proposed text otherwise. The artifact's `text` map is flat and keyed, so a
docs/coder/reviews/README.md added +26

@@ -0,0 +1,26 @@

1
# Cycle reviews
2
3
One file pair per reviewed autoimprovement cycle, written by
4
`pnpm run coder:review` (runbook §6):
5
6
- `YYYY-MM-DD-<lever-slug>.md` — the review a human reads and adopts from.
7
- `YYYY-MM-DD-<lever-slug>.json` — the same review as data
8
  (`openagents.coder_review_document.v1`), so the adopt step can become a
9
  diff rather than a reading exercise. Its proposals are
10
  `openagents.coder_candidate.v1` candidates: the same type an optimizer
11
  mutation will carry, so a reflection and a mutation are one object.
12
13
Over time this directory is a dataset of which process changes actually
14
helped, which is the reason each file keeps its evidence refs rather than
15
only its conclusion.
16
17
**A refused review leaves nothing here.** Every claim in a review cites the
18
artifacts the reviewer was given — `trial:<task>#step-<id>`,
19
`trial:<task>#outcome`, `row:<suite>#<recordedAt>`, `ledger:<id>`,
20
`diff:<path>` — and a citation that does not resolve refuses the whole
21
review by name, before any file is written. A file in this directory is a
22
record of a judgment about a run that happened; a document that reads like
23
one and is not is the failure the loop exists to catch.
24
25
A review whose reviewer ref begins `replay:` was replayed from a recorded
26
response rather than produced fresh. Read it as a record of the replay.
docs/coder/runbook.md modified +62 -32

@@ -64,6 +64,7 @@ it by hand and say in the issue that you did.

64 64
  ```
65 65
66 66
  `pnpm pack`, never `npm pack` (best practice R1).
67
67 68
- **Ollama** with the local-lane model pulled, for local-lane runs. Local
68 69
  runs need no token; keep `--n-concurrent 1` because the model owns the
69 70
  cores.

@@ -159,6 +160,7 @@ measuring suite, the lever is not ready for a cycle.

159 160
   axis: rounds, prompt tokens, and wall clock per accepted outcome are
160 161
   where process levers show first (the fix-git analysis is the worked
161 162
   example).
163
162 164
5. **Decide.** Improved or neutral-but-simpler: keep. Worse: revert the
163 165
   lever, keep the recorded row (best practice M3), and write the refutation
164 166
   into the review. Timeout-shaped failures are not efficiency signal —

@@ -202,31 +204,49 @@ agent context whose only inputs are the artifacts:

202 204
- the two store rows (before and after),
203 205
- the current `docs/coder/best-practices.md`.
204 206
205
Review prompt skeleton:
207
Assembling those by hand is what `coder:review` does mechanically
208
(OpenAgentsInc/openagents#121):
206 209
207
```
208
You are reviewing one autoimprovement cycle of `openagents coder`.
209
210
<lever>{what changed, and the predicted delta}</lever>
211
<baseline-row>{jsonl row}</baseline-row>
212
<result-row>{jsonl row}</result-row>
213
<trials>{per-trial: instruction, verifier decision, ATIF metrics,
214
notable transcript spans}</trials>
215
<diff>{the lever's diff}</diff>
216
<practices>{docs/coder/best-practices.md}</practices>
217
218
Score the cycle 0–10 with specific evidence for each point gained or
219
lost. Answer: did the lever cause the delta, or does a confounder
220
explain it? Were any ledger practices violated (cite the entry and the
221
transcript step)? Propose one to three changes, each typed as
222
{lever, evidence: trajectory steps, risk, verification: suite and
223
expected delta direction}. Finally, list ledger entries to add, promote,
224
demote, or refute, with provenance.
210
```sh
211
pnpm run coder:review -- /tmp/gym-jobs/<job>/<run> \
212
  --suite tb2-quick --lane proxy --lever HEAD~1 \
213
  --slug <lever-slug> \
214
  --reviewer-model <a model from a different family than the cycle ran on>
225 215
```
226 216
227
The review must cite trajectory steps for every claim; a proposal without
228
an evidence pointer is rejected at the adopt step. Save the output to
229
`docs/coder/reviews/YYYY-MM-DD-<lever-slug>.md`.
217
It reads the job directory, redacts everything through the one ATIF rule
218
list before it leaves the process, renders the prompt with the citable
219
evidence refs printed in it, asks the reviewer, checks every citation, and
220
writes `docs/coder/reviews/YYYY-MM-DD-<lever-slug>.md` plus the
221
machine-readable review beside it. Name the lever with `--lever <ref>`,
222
`--diff <file>`, or `--no-diff` for a baseline: a review that cannot see the
223
change cannot attribute the delta to it. The reviewer is reached over
224
`/api/v1/responses` at `--api-url` (default `$OPENAGENTS_CODER_API_URL`,
225
else `http://localhost:4000`) with `$OPENAGENTS_TOKEN`.
226
227
**Every claim carries refs, and the refs are checked.** The grammar is
228
`trial:<task>#step-<id>`, `trial:<task>#outcome`, `row:<suite>#<recordedAt>`,
229
`ledger:<id>`, and `diff:<path>`, resolved against the artifacts the
230
reviewer actually read — a truncated trajectory's dropped steps are not
231
citable, and the prompt says so. Every proposal must cite at least one
232
trajectory step: a store row or a ledger entry says what changed, never
233
what the coder did. A review with an unresolvable ref is refused whole,
234
by name, and nothing is written.
235
236
Exit codes: `0` accepted and written, `1` the reviewer answered and the
237
answer was refused (the named reasons are on stderr), `2` the review could
238
not be run at all — no job directory, no lever, no reviewer.
239
240
Two flags matter for the lanes below the live one:
241
242
- `--print-prompt` renders the prompt and exits without asking anyone. This
243
  is the manual §6 lane: paste it into a fresh agent context and save the
244
  answer.
245
- `--offline <file>` **replays** a recorded reviewer response instead of
246
  asking a model. It replays; it never generates. Its ref says `replay:` in
247
  the review file, so a replayed review cannot be read as a fresh judgment.
248
  There is no third behavior — a canned score would be the exact failure
249
  this command exists to prevent.
230 250
231 251
## 7. Record, adopt, land
232 252

@@ -234,10 +254,10 @@ an evidence pointer is rejected at the adopt step. Save the output to

234 254
   `docs/coder/best-practices.md` (checking new entries against existing
235 255
   ones for contradiction), carry rejected proposals into the review file
236 256
   with a one-line reason.
237
2. **Commit** the cycle as one unit: the lever, the review file, the ledger
238
   change, and the appended store rows. The commit message states the lever
239
   and the measured delta with its suite — a number, not an adjective
240
   (best practice V3).
257
2. **Commit** the cycle as one unit: the lever, the review file and the JSON
258
   written beside it, the ledger change, and the appended store rows. The
259
   commit message states the lever and the measured delta with its suite — a
260
   number, not an adjective (best practice V3).
241 261
3. **Push to the forge** (`git push openagents HEAD:main`), reconcile the
242 262
   canonical checkout, remove the worktree.
243 263

@@ -299,9 +319,19 @@ an evidence pointer is rejected at the adopt step. Save the output to

299 319
  (OpenAgentsInc/openagents.com#220); until then, metered-lane dollar
300 320
  figures are ceilings, and lane comparisons lean on success rate and
301 321
  rounds.
302
- The PTY-driven interactive harness (autoimprove §7.4) does not exist
303
  yet; best practice V2 is enforced by rule, not by gate.
304
- The optimizer lane (autoimprove §2.4) does not exist yet. The text
305
  surfaces it would mutate are still string literals in the two CLIs
306
  rather than staged artifacts; until that lands, every cycle is a
307
  hand-written lever and the ledger's O-series applies to nothing running.
322
- The PTY-driven interactive harness (autoimprove §7.4) exists for
323
  coder-lite as `cargo test -p coder-lite --test interactive_pty` (§0). The
324
  completion gate does not run it (#124), so best practice V2 is enforced by
325
  rule for every surface the gate does reach, and by that harness for the
326
  one it does not.
327
- The automated review (autoimprove §7.5) exists as `pnpm run coder:review`
328
  (#121). Its candidate schema, `openagents.coder_candidate.v1` in
329
  `packages/openagents-cli/src/coder-review-candidate.ts`, is the object
330
  #122 staged surfaces for and #123's optimizer will mutate; a review
331
  proposal and an optimizer mutation are the same type, documented in
332
  `docs/coder/candidate-format.md`. No review has been recorded through it
333
  yet, so `docs/coder/reviews/` holds only its README.
334
- The optimizer lane (autoimprove §2.4) does not exist yet, but the text it
335
  would mutate is now staged (#122): `surfaces/coder/`, pinned by digest and
336
  guarded by `check:coder-surfaces`. Until #123 lands, every cycle is still a
337
  hand-written lever, and the ledger's O-series applies to nothing running.
package.json modified +1

@@ -176,6 +176,7 @@

176 176
    "check:assure-repo-audit": "node --import tsx packages/assure-repo/src/cli.ts audit-check",
177 177
    "effectiveness:compare": "node --import tsx packages/coder-effectiveness/src/compare-cli.ts",
178 178
    "effectiveness:report": "node --import tsx packages/coder-effectiveness/src/cli.ts",
179
    "coder:review": "node --import tsx packages/openagents-cli/src/coder-review-cli.ts",
179 180
    "effectiveness:suites": "node bench/build-suites.mjs",
180 181
    "test:coder-effectiveness": "vp test --run packages/coder-effectiveness/src",
181 182
    "test:product-spec": "vp test --run packages/product-spec",
packages/agent-experience-memory/src/engram.ts modified +6 -2

@@ -203,7 +203,11 @@ export const redactEngramContent = (

203 203
  }
204 204
205 205
  const categories = Object.keys(counts);
206
  const hard = new Set(ENGRAM_HARD_UNSAFE_CATEGORIES as unknown as ReadonlyArray<string>);
206
  // `new Set(...)` on an `as const` tuple infers a set of the literal union,
207
  // whose `has` then refuses the `string` keys it is being asked about. Widening
208
  // the element type at construction says the same thing without a cast, which
209
  // AFS authority code may not use to recover type safety.
210
  const hard = new Set<string>(ENGRAM_HARD_UNSAFE_CATEGORIES);
207 211
  const storable = categories.every((category) => !hard.has(category));
208 212
  const total = Object.values(counts).reduce((a, b) => a + b, 0);
209 213

@@ -362,7 +366,7 @@ export const verifyEngramEventId = (event: EngramEvent): boolean =>

362 366
 */
363 367
export const verifySupersessionChain = (events: ReadonlyArray<EngramEvent>): boolean => {
364 368
  for (let i = 0; i < events.length; i += 1) {
365
    const event = events[i];
369
    const event = events[i]!;
366 370
    if (!verifyEngramEventId(event)) {
367 371
      return false;
368 372
    }
packages/khala-sync-server/src/cloud-computer-command-store.ts modified +7 -2

@@ -419,11 +419,16 @@ export class PostgresCloudComputerCommandStore {

419 419
    assertDigest(input.capabilityDigest, "capability digest");
420 420
    assertDigest(input.budgetSnapshotDigest, "budget snapshot digest");
421 421
    input.capabilityRefs.forEach((ref) => assertRef(ref, "capability ref"));
422
    // `budgetLimits` is an open record, so its `outputBytes` may be absent.
423
    // An absent output bound is already rejected by the safe-integer test; it is
424
    // named here so the type says what the test was always enforcing.
425
    const outputBytes: number | undefined = input.budgetLimits["outputBytes"];
422 426
    if (
423 427
      !input.workingDirectory.startsWith("/") ||
424 428
      input.workingDirectory.includes("\0") ||
425
      !Number.isSafeInteger(input.budgetLimits.outputBytes) ||
426
      input.budgetLimits.outputBytes < 0 ||
429
      outputBytes === undefined ||
430
      !Number.isSafeInteger(outputBytes) ||
431
      outputBytes < 0 ||
427 432
      Object.values(input.budgetLimits).some((value) => !Number.isSafeInteger(value) || value < 0)
428 433
    )
429 434
      throw new CloudComputerCommandStoreError("invalid", "request authority is invalid");
packages/openagents-cli/src/coder-review-assemble.ts added +601

@@ -0,0 +1,601 @@

1
/**
2
 * Assemble one cycle review's input from the artifacts a Harbor run left.
3
 *
4
 * The runbook's §6 prompt takes five things: the trials, the lever's diff, the
5
 * store rows before and after, and the current ledger. An agent assembling
6
 * those by hand is the step OpenAgentsInc/openagents#121 replaces, and doing it
7
 * mechanically buys three properties a copy-and-paste cannot have.
8
 *
9
 * BOUNDED, AND NAMED WHERE IT IS BOUND. A trajectory is as long as the task
10
 * was hard, and the transcript of a fifteen-round trial does not fit in a
11
 * review prompt. Every cut this file makes is reported in the request itself,
12
 * in the `read-conversation` plugin's vocabulary: `tail_only`,
13
 * `dropped_leading_steps`, `kept_steps`, `total_steps`. A reviewer that cannot
14
 * see the opening of a trial is told so, and a proposal that cites a dropped
15
 * step is refused by name rather than resolving against a step the reviewer
16
 * never read.
17
 *
18
 * REDACTED WITH THE ONE RULE LIST. Everything assembled here is on its way out
19
 * of the working session and into another conversation, so it goes through
20
 * `redactForExternalInference` from the vendored ATIF rules — the same list
21
 * `openagents trace redact` folds in. #97 is the reason that is stated rather
22
 * than assumed: a second hand-written rule list forgot `oa_pat_` and `smct_`,
23
 * and the command reported success over a file of live tokens. There is no
24
 * second list here.
25
 *
26
 * A RESOLVABLE EVIDENCE INDEX. The request knows exactly which steps, rows,
27
 * ledger entries, and diff paths it contains, and hands that set to the parser
28
 * as {@link EvidenceIndex}. That is what makes "this proposal cites nothing"
29
 * a mechanical finding instead of a reviewer's opinion.
30
 */
31
32
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
33
import { basename, join } from "node:path";
34
35
import type { EvidenceIndex } from "./coder-review-candidate.js";
36
import { redactForExternalInference } from "./memory/redaction.js";
37
38
export const CODER_REVIEW_REQUEST_SCHEMA = "openagents.coder_review_request.v1";
39
40
/** The coder's `--plain` thread announcement, the contract `bench` parses. */
41
const THREAD_LINE = /\[oa:thread ([0-9a-fA-F-]{36})\]/u;
42
43
/** Matches `### T1. Batch independent commands ...` in the ledger. */
44
const LEDGER_HEADING = /^###\s+([A-Z]+\d+)\.\s/gmu;
45
46
/** Matches the paths in a unified diff's `diff --git a/x b/x` lines. */
47
const DIFF_PATH = /^diff --git a\/(\S+) b\/(\S+)$/gmu;
48
49
/** What a verifier decided. Same three buckets `harbor-job.ts` grades into. */
50
export type ReviewTrialOutcome = "accepted" | "rejected" | "ungraded";
51
52
export interface ReviewToolCall {
53
  readonly name: string;
54
  readonly arguments: string;
55
  readonly argumentsTruncated: boolean;
56
  /**
57
   * What the tool returned, bounded.
58
   *
59
   * A trajectory step carries the call and its observation together, and a
60
   * review that can see the command but not its output cannot tell a wasted
61
   * round from a productive one — which is most of what runbook §6 asks it to
62
   * judge.
63
   */
64
  readonly observation: string;
65
  readonly observationTruncated: boolean;
66
}
67
68
export interface ReviewStep {
69
  /** The step id as the trajectory spells it, so a ref can name it back. */
70
  readonly stepId: string;
71
  readonly source: string;
72
  readonly text: string;
73
  readonly textTruncated: boolean;
74
  readonly toolCalls: ReadonlyArray<ReviewToolCall>;
75
  readonly promptTokens: number | null;
76
  readonly completionTokens: number | null;
77
  readonly cachedInputTokens: number | null;
78
}
79
80
/**
81
 * How much of a trial's trajectory reached the reviewer.
82
 *
83
 * Named in the `read-conversation` plugin's spelling on purpose: a truncated
84
 * read that says what it left out is a different artifact from one that does
85
 * not, and having two vocabularies for it would make a reader check which is
86
 * which.
87
 */
88
export interface ReviewTruncation {
89
  readonly tail_only: boolean;
90
  readonly dropped_leading_steps: number;
91
  readonly kept_steps: number;
92
  readonly total_steps: number;
93
  readonly text_truncated_steps: number;
94
  readonly max_step_chars: number;
95
}
96
97
export interface ReviewTrial {
98
  readonly task: string;
99
  /** The trial directory's own name. Never its path. */
100
  readonly trialDir: string;
101
  readonly instruction: string | null;
102
  /** Where the instruction was read from, or `absent` when nothing carried it. */
103
  readonly instructionSource:
104
    | "trial_result"
105
    | "trial_config"
106
    | "trajectory_first_user_step"
107
    | "absent";
108
  readonly outcome: ReviewTrialOutcome;
109
  readonly modelId: string | null;
110
  readonly agentVersion: string | null;
111
  readonly promptTokens: number | null;
112
  readonly completionTokens: number | null;
113
  readonly cachedInputTokens: number;
114
  readonly toolCalls: number | null;
115
  readonly wallClockSeconds: number | null;
116
  readonly threadId: string | null;
117
  readonly exception: string | null;
118
  readonly steps: ReadonlyArray<ReviewStep>;
119
  readonly truncation: ReviewTruncation;
120
}
121
122
export interface ReviewLever {
123
  /** What the diff was taken against, e.g. `HEAD` or a commit ref. */
124
  readonly ref: string;
125
  readonly diff: string;
126
  readonly paths: ReadonlyArray<string>;
127
  readonly truncation: {
128
    readonly tail_only: false;
129
    readonly kept_lines: number;
130
    readonly total_lines: number;
131
    readonly dropped_trailing_lines: number;
132
  };
133
}
134
135
export interface ReviewBenchRow {
136
  readonly suite: string;
137
  readonly recordedAt: string;
138
  /** The row's own fields, as the store wrote them. */
139
  readonly row: Record<string, unknown>;
140
}
141
142
export interface ReviewPractices {
143
  readonly path: string;
144
  readonly text: string;
145
  readonly entryIds: ReadonlyArray<string>;
146
}
147
148
export interface ReviewRedactionStamp {
149
  readonly serviceRef: string;
150
  readonly surface: "trace_capture";
151
  readonly appliedBeforeExternalInference: true;
152
  readonly counts: Readonly<Record<string, number>>;
153
  readonly total: number;
154
}
155
156
/** Everything the reviewer is given, and nothing the working session holds. */
157
export interface ReviewRequest {
158
  readonly schema: typeof CODER_REVIEW_REQUEST_SCHEMA;
159
  /** The job directory's own name. Never its path: a path is a local fact. */
160
  readonly jobDir: string;
161
  readonly jobId: string | null;
162
  readonly suite: string;
163
  readonly lane: string;
164
  readonly trials: ReadonlyArray<ReviewTrial>;
165
  readonly lever: ReviewLever;
166
  readonly rows: ReadonlyArray<ReviewBenchRow>;
167
  readonly practices: ReviewPractices;
168
  readonly redaction: ReviewRedactionStamp;
169
}
170
171
export interface AssembleOptions {
172
  readonly suite: string;
173
  readonly lane: string;
174
  readonly lever: { readonly ref: string; readonly diff: string };
175
  readonly rows: ReadonlyArray<ReviewBenchRow>;
176
  readonly practices: { readonly path: string; readonly text: string };
177
  /** Steps kept per trial. The tail is kept: a trial ends where it failed. */
178
  readonly maxSteps: number;
179
  readonly maxStepChars: number;
180
  readonly maxDiffLines: number;
181
}
182
183
export const DEFAULT_MAX_STEPS = 40;
184
export const DEFAULT_MAX_STEP_CHARS = 1200;
185
export const DEFAULT_MAX_DIFF_LINES = 800;
186
187
export class NotAHarborJob extends Error {
188
  constructor(jobDir: string, reason: string) {
189
    super(
190
      `not a Harbor job directory (${reason}): ${jobDir}. Point at the directory harbor run created under its --jobs-dir.`,
191
    );
192
    this.name = "NotAHarborJob";
193
  }
194
}
195
196
/**
197
 * Read a completed Harbor job into a bounded, redacted review request.
198
 *
199
 * Throws {@link NotAHarborJob} rather than reviewing an empty directory: a
200
 * review of nothing would still produce a score, and a score with no run
201
 * behind it is the shape of claim this loop exists to stop.
202
 */
203
export const assembleReviewRequest = (
204
  jobDir: string,
205
  options: AssembleOptions,
206
): { readonly request: ReviewRequest; readonly index: EvidenceIndex } => {
207
  const jobResult = readJson(join(jobDir, "result.json"));
208
  if (jobResult === undefined) throw new NotAHarborJob(jobDir, "no result.json");
209
210
  const trials: Array<ReviewTrial> = [];
211
  for (const entry of readdirSync(jobDir).sort()) {
212
    const trialDir = join(jobDir, entry);
213
    if (!statSync(trialDir).isDirectory()) continue;
214
    const trialResult = readJson(join(trialDir, "result.json"));
215
    if (trialResult === undefined) continue;
216
    trials.push(readTrial(entry, trialDir, trialResult, options));
217
  }
218
219
  // A trial directory has a `result.json` too, so pointing at one would
220
  // otherwise assemble a job of zero trials — and a review of zero trials still
221
  // produces a score. Refusing here is the same rule the store applies to a run
222
  // that skipped its pinned tasks: it is not a smaller measurement, it is none.
223
  if (trials.length === 0) {
224
    throw new NotAHarborJob(jobDir, "it holds no trial directories");
225
  }
226
227
  const diffLines = options.lever.diff === "" ? [] : options.lever.diff.split("\n");
228
  const keptDiffLines = diffLines.slice(0, options.maxDiffLines);
229
  const lever: ReviewLever = {
230
    ref: options.lever.ref,
231
    diff: keptDiffLines.join("\n"),
232
    paths: diffPathsOf(options.lever.diff),
233
    truncation: {
234
      tail_only: false,
235
      kept_lines: keptDiffLines.length,
236
      total_lines: diffLines.length,
237
      dropped_trailing_lines: diffLines.length - keptDiffLines.length,
238
    },
239
  };
240
241
  const practices: ReviewPractices = {
242
    path: options.practices.path,
243
    text: options.practices.text,
244
    entryIds: ledgerEntryIds(options.practices.text),
245
  };
246
247
  const draft = {
248
    schema: CODER_REVIEW_REQUEST_SCHEMA,
249
    jobDir: basename(jobDir),
250
    jobId: readString(readField(jobResult, "id")),
251
    suite: options.suite,
252
    lane: options.lane,
253
    trials,
254
    lever,
255
    rows: options.rows,
256
    practices,
257
  } as const;
258
259
  // Everything above is on its way into another conversation, so the whole
260
  // request goes through the one rule list before it leaves this process. The
261
  // stamp is added after, so the report counts what the reviewer will not see.
262
  const redacted = redactForExternalInference(draft, { surface: "trace_capture" });
263
264
  const request: ReviewRequest = {
265
    ...redacted.value,
266
    redaction: {
267
      serviceRef: redacted.policy.serviceRef,
268
      surface: "trace_capture",
269
      appliedBeforeExternalInference: true,
270
      counts: redacted.report.counts,
271
      total: redacted.report.total,
272
    },
273
  };
274
275
  return { request, index: indexOf(request) };
276
};
277
278
/** The set of refs a proposal may cite, built from the request itself. */
279
export const indexOf = (request: ReviewRequest): EvidenceIndex => {
280
  const trajectorySteps = new Set<string>();
281
  const trialOutcomes = new Set<string>();
282
  for (const trial of request.trials) {
283
    trialOutcomes.add(trial.task);
284
    for (const step of trial.steps) {
285
      trajectorySteps.add(`${trial.task}#step-${step.stepId}`);
286
    }
287
  }
288
  return {
289
    trajectorySteps,
290
    trialOutcomes,
291
    benchRows: new Set(request.rows.map((row) => `${row.suite}#${row.recordedAt}`)),
292
    ledgerEntries: new Set(request.practices.entryIds),
293
    diffPaths: new Set(request.lever.paths),
294
  };
295
};
296
297
const readTrial = (
298
  dirName: string,
299
  trialDir: string,
300
  trialResult: unknown,
301
  options: AssembleOptions,
302
): ReviewTrial => {
303
  const trajectory = readJson(join(trialDir, "agent", "trajectory.json"));
304
  const rawSteps = readArray(readField(trajectory, "steps"));
305
  const agent = readField(trajectory, "agent");
306
  const finalMetrics = readField(trajectory, "final_metrics");
307
308
  let cachedInputTokens = 0;
309
  let toolCalls = 0;
310
  for (const step of rawSteps) {
311
    const extra = readField(readField(step, "metrics"), "extra");
312
    cachedInputTokens += readNumber(readField(extra, "cache_read_input_tokens")) ?? 0;
313
    toolCalls += readArray(readField(step, "tool_calls")).length;
314
  }
315
316
  // The tail is what is kept. A trial that failed failed at its end, and the
317
  // opening rounds of a long transcript are the least informative part of it.
318
  const dropped = Math.max(0, rawSteps.length - options.maxSteps);
319
  const keptRaw = rawSteps.slice(dropped);
320
  let textTruncatedSteps = 0;
321
  const steps = keptRaw.map((step, position) => {
322
    const built = readStep(step, dropped + position, options.maxStepChars);
323
    if (built.textTruncated) textTruncatedSteps += 1;
324
    return built;
325
  });
326
327
  const instruction = readInstruction(trialDir, trialResult, rawSteps);
328
329
  return {
330
    // The trial spells its own task name. The directory name is the fallback
331
    // for a tree that did not, and it carries a random per-trial suffix.
332
    task:
333
      readString(readField(trialResult, "task_name")) ??
334
      (dirName.includes("__") ? dirName.slice(0, dirName.lastIndexOf("__")) : dirName),
335
    trialDir: dirName,
336
    instruction: instruction.text,
337
    instructionSource: instruction.source,
338
    outcome: outcomeOf(trialResult),
339
    modelId:
340
      readString(readField(agent, "model_name")) ??
341
      modelFromTrialConfig(readJson(join(trialDir, "config.json"))),
342
    agentVersion: readString(readField(agent, "version")),
343
    promptTokens: readNumber(readField(finalMetrics, "total_prompt_tokens")),
344
    completionTokens: readNumber(readField(finalMetrics, "total_completion_tokens")),
345
    cachedInputTokens,
346
    toolCalls: rawSteps.length === 0 ? null : toolCalls,
347
    wallClockSeconds: wallClockOf(trialResult),
348
    threadId: threadIdOf(trialDir),
349
    exception: readString(readField(readField(trialResult, "exception_info"), "exception_type")),
350
    steps,
351
    truncation: {
352
      tail_only: dropped > 0,
353
      dropped_leading_steps: dropped,
354
      kept_steps: steps.length,
355
      total_steps: rawSteps.length,
356
      text_truncated_steps: textTruncatedSteps,
357
      max_step_chars: options.maxStepChars,
358
    },
359
  };
360
};
361
362
const readStep = (step: unknown, position: number, maxChars: number): ReviewStep => {
363
  const rawId = readField(step, "step_id");
364
  const stepId =
365
    typeof rawId === "number" || typeof rawId === "string" ? String(rawId) : String(position + 1);
366
  const message = readString(readField(step, "message")) ?? "";
367
  const clipped = clip(message, maxChars);
368
  const metrics = readField(step, "metrics");
369
370
  return {
371
    stepId,
372
    source: readString(readField(step, "source")) ?? "unknown",
373
    text: clipped.text,
374
    textTruncated: clipped.truncated,
375
    toolCalls: readArray(readField(step, "tool_calls")).map((call) => {
376
      // The coder's ATIF exporter writes `function_name` and an `arguments`
377
      // object; the OpenAI-shaped spellings are read too, so a trajectory from
378
      // another adapter is not silently reported as a step of `unknown` calls.
379
      const args =
380
        stringify(readField(call, "arguments")) ?? stringify(readField(call, "input")) ?? "";
381
      const clippedArgs = clip(args, maxChars);
382
      const callId =
383
        readString(readField(call, "tool_call_id")) ?? readString(readField(call, "call_id"));
384
      const clippedObservation = clip(observationFor(step, callId), maxChars);
385
      return {
386
        name:
387
          readString(readField(call, "function_name")) ??
388
          readString(readField(call, "name")) ??
389
          readString(readField(readField(call, "function"), "name")) ??
390
          "unknown",
391
        arguments: clippedArgs.text,
392
        argumentsTruncated: clippedArgs.truncated,
393
        observation: clippedObservation.text,
394
        observationTruncated: clippedObservation.truncated,
395
      };
396
    }),
397
    promptTokens: readNumber(readField(metrics, "prompt_tokens")),
398
    completionTokens: readNumber(readField(metrics, "completion_tokens")),
399
    cachedInputTokens: readNumber(
400
      readField(readField(metrics, "extra"), "cache_read_input_tokens"),
401
    ),
402
  };
403
};
404
405
/**
406
 * The output of one call, out of the step's observation block.
407
 *
408
 * ATIF puts every result of a step in one `observation.results` array keyed by
409
 * `source_call_id`, so a step with two calls carries two results and matching
410
 * them by id is what keeps a command next to its own output. A step with one
411
 * result and no id still matches its single call: dropping the output because
412
 * the exporter omitted an id would lose the more useful half of the step.
413
 */
414
const observationFor = (step: unknown, callId: string | null): string => {
415
  const results = readArray(readField(readField(step, "observation"), "results"));
416
  if (results.length === 0) return "";
417
  const matched =
418
    callId === null
419
      ? undefined
420
      : results.find((result) => readString(readField(result, "source_call_id")) === callId);
421
  const chosen = matched ?? (results.length === 1 ? results[0] : undefined);
422
  if (chosen === undefined) return "";
423
  return stringify(readField(chosen, "content")) ?? "";
424
};
425
426
/**
427
 * The task instruction, from whichever artifact carried it.
428
 *
429
 * Harbor spells it differently across its own files, and a trial that timed
430
 * out before the coder started may carry it in only one of them. The source is
431
 * recorded next to the text so a reviewer reading a suspiciously short
432
 * instruction can tell a truncated task statement from a recovered one.
433
 */
434
const readInstruction = (
435
  trialDir: string,
436
  trialResult: unknown,
437
  steps: ReadonlyArray<unknown>,
438
): { readonly text: string | null; readonly source: ReviewTrial["instructionSource"] } => {
439
  const fromResult =
440
    readString(readField(trialResult, "instruction")) ??
441
    readString(readField(readField(trialResult, "task"), "instruction"));
442
  if (fromResult !== null) return { text: fromResult, source: "trial_result" };
443
444
  const config = readJson(join(trialDir, "config.json"));
445
  const fromConfig =
446
    readString(readField(config, "instruction")) ??
447
    readString(readField(readField(config, "task"), "instruction"));
448
  if (fromConfig !== null) return { text: fromConfig, source: "trial_config" };
449
450
  for (const step of steps) {
451
    if (readString(readField(step, "source")) !== "user") continue;
452
    const message = readString(readField(step, "message"));
453
    if (message !== null) return { text: message, source: "trajectory_first_user_step" };
454
  }
455
  return { text: null, source: "absent" };
456
};
457
458
/**
459
 * The grading rule, held identical to `harbor-job.ts`.
460
 *
461
 * A verifier that never ran leaves no `verifier_result`, and that is
462
 * `ungraded` — not a failure and not a pass. Two readers of the same run
463
 * disagreeing about what a pass is would make the review and the score
464
 * describe different runs.
465
 */
466
const outcomeOf = (trialResult: unknown): ReviewTrialOutcome => {
467
  const verifier = readField(trialResult, "verifier_result");
468
  if (verifier === undefined || verifier === null) return "ungraded";
469
  const rewards = readField(verifier, "rewards") ?? readField(trialResult, "rewards");
470
  const reward = rewardValue(rewards);
471
  return reward !== null && reward > 0 ? "accepted" : "rejected";
472
};
473
474
const rewardValue = (rewards: unknown): number | null => {
475
  const direct = readNumber(readField(rewards, "reward"));
476
  if (direct !== null) return direct;
477
  if (typeof rewards !== "object" || rewards === null) return null;
478
  const values = Object.values(rewards as Record<string, unknown>);
479
  return values.length === 1 ? readNumber(values[0]) : null;
480
};
481
482
const wallClockOf = (trialResult: unknown): number | null => {
483
  const execution = readField(trialResult, "agent_execution");
484
  const started = readString(readField(execution, "started_at"));
485
  const finished = readString(readField(execution, "finished_at"));
486
  if (started === null || finished === null) return null;
487
  const span = Date.parse(finished) - Date.parse(started);
488
  return Number.isFinite(span) ? span / 1000 : null;
489
};
490
491
const modelFromTrialConfig = (trialConfig: unknown): string | null => {
492
  const spelled =
493
    readString(readField(readField(trialConfig, "agent"), "model_name")) ??
494
    readString(readField(readField(readField(trialConfig, "config"), "agent"), "model_name"));
495
  if (spelled === null) return null;
496
  const separator = spelled.indexOf("/");
497
  if (separator === -1) return spelled;
498
  const name = spelled.slice(separator + 1);
499
  return name === "" ? spelled.slice(0, separator) : name;
500
};
501
502
const threadIdOf = (trialDir: string): string | null => {
503
  const path = join(trialDir, "agent", "coder.txt");
504
  if (!existsSync(path)) return null;
505
  return THREAD_LINE.exec(readFileSync(path, "utf8"))?.[1] ?? null;
506
};
507
508
/** Ledger entry ids, so `ledger:T1` can be resolved instead of trusted. */
509
export const ledgerEntryIds = (practices: string): ReadonlyArray<string> => {
510
  const ids: Array<string> = [];
511
  LEDGER_HEADING.lastIndex = 0;
512
  let match = LEDGER_HEADING.exec(practices);
513
  while (match !== null) {
514
    if (match[1] !== undefined) ids.push(match[1]);
515
    match = LEDGER_HEADING.exec(practices);
516
  }
517
  return ids;
518
};
519
520
/** Paths a unified diff touches, so `diff:<path>` can be resolved. */
521
export const diffPathsOf = (diff: string): ReadonlyArray<string> => {
522
  const paths = new Set<string>();
523
  DIFF_PATH.lastIndex = 0;
524
  let match = DIFF_PATH.exec(diff);
525
  while (match !== null) {
526
    if (match[1] !== undefined) paths.add(match[1]);
527
    if (match[2] !== undefined) paths.add(match[2]);
528
    match = DIFF_PATH.exec(diff);
529
  }
530
  return [...paths].sort();
531
};
532
533
/** Read the rows of one `bench-results` store file, newest last. */
534
export const readBenchRows = (
535
  storePath: string,
536
  suite: string,
537
  limit: number,
538
): ReadonlyArray<ReviewBenchRow> => {
539
  if (!existsSync(storePath)) return [];
540
  const rows: Array<ReviewBenchRow> = [];
541
  for (const line of readFileSync(storePath, "utf8").split("\n")) {
542
    if (line.trim() === "") continue;
543
    let parsed: unknown;
544
    try {
545
      parsed = JSON.parse(line) as unknown;
546
    } catch {
547
      continue;
548
    }
549
    const row = readRecord(parsed);
550
    if (row === undefined) continue;
551
    rows.push({
552
      suite: readString(row["suite"]) ?? suite,
553
      recordedAt: readString(row["recordedAt"]) ?? "",
554
      row,
555
    });
556
  }
557
  return rows.slice(Math.max(0, rows.length - limit));
558
};
559
560
const clip = (text: string, maxChars: number): { text: string; truncated: boolean } =>
561
  text.length <= maxChars
562
    ? { text, truncated: false }
563
    : {
564
        text: `${text.slice(0, maxChars)}\n[${String(text.length - maxChars)} of ${String(text.length)} characters dropped from the end of this step]`,
565
        truncated: true,
566
      };
567
568
const stringify = (value: unknown): string | null => {
569
  if (value === undefined || value === null) return null;
570
  if (typeof value === "string") return value === "" ? null : value;
571
  try {
572
    return JSON.stringify(value) ?? null;
573
  } catch {
574
    return null;
575
  }
576
};
577
578
const readJson = (path: string): unknown => {
579
  if (!existsSync(path)) return undefined;
580
  try {
581
    return JSON.parse(readFileSync(path, "utf8")) as unknown;
582
  } catch {
583
    return undefined;
584
  }
585
};
586
587
const readField = (value: unknown, key: string): unknown =>
588
  typeof value === "object" && value !== null ? (value as Record<string, unknown>)[key] : undefined;
589
590
const readRecord = (value: unknown): Record<string, unknown> | undefined =>
591
  typeof value === "object" && value !== null && !Array.isArray(value)
592
    ? (value as Record<string, unknown>)
593
    : undefined;
594
595
const readArray = (value: unknown): ReadonlyArray<unknown> => (Array.isArray(value) ? value : []);
596
597
const readString = (value: unknown): string | null =>
598
  typeof value === "string" && value !== "" ? value : null;
599
600
const readNumber = (value: unknown): number | null =>
601
  typeof value === "number" && Number.isFinite(value) ? value : null;
packages/openagents-cli/src/coder-review-candidate.ts added +870

@@ -0,0 +1,870 @@

1
/**
2
 * The candidate: the one object a cycle review proposes and an optimizer
3
 * mutates.
4
 *
5
 * `docs/coder/autoimprove.md` §3 types a review proposal as
6
 * `{lever, evidence, risk, verification}`, and §7.6 says the optimizer lane
7
 * shares that schema "so a reflection and a mutation are the same object".
8
 * OpenAgentsInc/openagents#122 names the other half of the same object: a
9
 * candidate is a diff over the staged text surfaces plus the lineage it came
10
 * from, the model family it was written or evolved against (ledger O5), and
11
 * the evidence rows behind it. This file is that single definition. A review
12
 * proposal is a candidate whose `lineage.origin` is `review`; an optimizer
13
 * mutation will be one whose origin is `optimizer`, and nothing else about it
14
 * changes.
15
 *
16
 * THE EVIDENCE RULE IS ENFORCED HERE, AT PARSE TIME. `autoimprove.md` §6
17
 * lists "confident review without understanding" as a failure mode and its
18
 * control as "a proposal with no evidence pointer is rejected in the adopt
19
 * step". Rejecting it in the adopt step means a human notices, or does not.
20
 * {@link parseCycleReview} refuses the whole review instead: every evidence
21
 * ref must resolve against the artifacts the review was actually given, and
22
 * every proposal must cite at least one trajectory step. A reviewer that
23
 * invents a step number produces a named rejection, not a plausible document.
24
 *
25
 * The refusals are named because an unnamed refusal is the failure this loop
26
 * exists to stop: `openagents trace redact` reported "Nothing matched the
27
 * redaction rules" over a file full of live tokens (#97), and the sentence was
28
 * true. A reviewer whose citations do not resolve is the same shape of lie one
29
 * level up, so its rejection says which ref, in which proposal, and why.
30
 */
31
32
/** The schema id a candidate carries on the wire. */
33
export const CODER_CANDIDATE_SCHEMA = "openagents.coder_candidate.v1";
34
35
/** The schema id a parsed cycle review carries. */
36
export const CODER_REVIEW_SCHEMA = "openagents.coder_review.v1";
37
38
/** The improvement axes of `docs/coder/autoimprove.md` §2, plus the ledger. */
39
export type LeverAxis = "process" | "plugin" | "harness" | "optimizer" | "routing" | "ledger";
40
41
export const LEVER_AXES: ReadonlyArray<LeverAxis> = [
42
  "process",
43
  "plugin",
44
  "harness",
45
  "optimizer",
46
  "routing",
47
  "ledger",
48
];
49
50
/** Which way a suite figure has to move for the candidate to be confirmed. */
51
export type DeltaDirection = "up" | "down" | "unchanged";
52
53
export const DELTA_DIRECTIONS: ReadonlyArray<DeltaDirection> = ["up", "down", "unchanged"];
54
55
/**
56
 * One pointer into the artifacts the review was given.
57
 *
58
 * The grammar is deliberately small, because every scheme in it has to be
59
 * resolvable against something the assembler put in the request:
60
 *
61
 * - `trial:<task>#step-<id>` — one step of that trial's ATIF trajectory
62
 * - `trial:<task>#outcome` — that trial's verifier decision
63
 * - `row:<suite>#<recordedAt>` — one `bench-results` row
64
 * - `ledger:<id>` — one `docs/coder/best-practices.md` entry, e.g. `ledger:T1`
65
 * - `diff:<path>` — one file the lever's diff touches
66
 */
67
export interface EvidenceRef {
68
  readonly ref: string;
69
  /** What this ref is being cited for. Prose, and it may be empty. */
70
  readonly note: string;
71
}
72
73
/** What kind of artifact a ref resolved to. */
74
export type EvidenceKind =
75
  | "trajectory_step"
76
  | "trial_outcome"
77
  | "bench_row"
78
  | "ledger_entry"
79
  | "diff_path";
80
81
/** One staged text surface a candidate changes, and the change to it. */
82
export interface CandidateSurfaceDiff {
83
  /**
84
   * The staged surface's id, from `surfaces/coder/index.json` — `system-prompt`,
85
   * `tool-descriptions`, or `catalog-lines` as #122 staged them, and whatever a
86
   * later pass adds. It is not checked here: the vocabulary lives in that
87
   * artifact and the parser is pure, so the prompt names the keys and the adopt
88
   * step patches the file. A candidate touching no staged text (a `process`,
89
   * `plugin`, or `routing` lever) carries an empty `surfaces` array.
90
   */
91
  readonly surface: string;
92
  /** A unified diff where one exists, otherwise the proposed text. */
93
  readonly diff: string;
94
}
95
96
/** Where a candidate came from, so a pool can be walked backwards. */
97
export interface CandidateLineage {
98
  readonly origin: "review" | "optimizer" | "human";
99
  /** The `candidateId` this one was derived from, or `null` for a root. */
100
  readonly parent: string | null;
101
  /** The producer, e.g. `coder-review:<jobDir>:<reviewer lane ref>`. */
102
  readonly producedBy: string;
103
}
104
105
/**
106
 * Ledger O5: a candidate carries the model family and lane it was written
107
 * against, because text tuned on one lane is not evidence for another.
108
 */
109
export interface CandidateTransferLabel {
110
  readonly modelFamily: string;
111
  readonly lane: string;
112
}
113
114
/** How the candidate would be confirmed or refuted. */
115
export interface CandidateVerification {
116
  /** The suite that would run, e.g. `tb2-quick`. */
117
  readonly suite: string;
118
  /** The figure to read, e.g. `successRate` or `promptTokens`. */
119
  readonly metric: string;
120
  readonly expectedDirection: DeltaDirection;
121
}
122
123
/** A review proposal and an optimizer mutation, as one object. */
124
export interface CoderCandidate {
125
  readonly schema: typeof CODER_CANDIDATE_SCHEMA;
126
  /** A digest over the candidate's own facts. Computed, never supplied. */
127
  readonly candidateId: string;
128
  readonly lever: {
129
    readonly axis: LeverAxis;
130
    /** One sentence naming the change. */
131
    readonly summary: string;
132
  };
133
  readonly surfaces: ReadonlyArray<CandidateSurfaceDiff>;
134
  readonly lineage: CandidateLineage;
135
  readonly transferLabel: CandidateTransferLabel;
136
  readonly evidence: ReadonlyArray<EvidenceRef>;
137
  /** What could go wrong if this is adopted. */
138
  readonly risk: string;
139
  readonly verification: CandidateVerification;
140
}
141
142
export type LedgerOp = "add" | "promote" | "demote" | "refute";
143
144
export const LEDGER_OPS: ReadonlyArray<LedgerOp> = ["add", "promote", "demote", "refute"];
145
146
export type LedgerStatus = "adopted" | "proposed" | "refuted";
147
148
export const LEDGER_STATUSES: ReadonlyArray<LedgerStatus> = ["adopted", "proposed", "refuted"];
149
150
/** The ledger entry an operation adds or moves. */
151
export interface LedgerEntryProposal {
152
  /** An existing entry's id (`T1`), or `null` when the operation adds one. */
153
  readonly id: string | null;
154
  /** The heading it belongs under, e.g. `Tool habits`. */
155
  readonly section: string;
156
  readonly title: string;
157
  /** The falsifiable claim itself. */
158
  readonly statement: string;
159
  /** How a violation is detected. §5: an entry nothing detects is aspiration. */
160
  readonly detection: string;
161
  readonly status: LedgerStatus;
162
}
163
164
export interface LedgerOperation {
165
  readonly op: LedgerOp;
166
  readonly entry: LedgerEntryProposal;
167
  readonly provenance: ReadonlyArray<EvidenceRef>;
168
}
169
170
/** One point of the 0-10 score, with what was observed to award or dock it. */
171
export interface ReviewScorePoint {
172
  readonly point: string;
173
  /** Signed, so a reader can add them up and land on the score. */
174
  readonly delta: number;
175
  readonly evidence: ReadonlyArray<EvidenceRef>;
176
}
177
178
/** A ledger practice the cycle broke, and the step that shows it. */
179
export interface PracticeViolation {
180
  /** The ledger entry id, e.g. `T1`. */
181
  readonly entry: string;
182
  readonly note: string;
183
  readonly evidence: ReadonlyArray<EvidenceRef>;
184
}
185
186
/** One cycle review, parsed and evidence-checked. */
187
export interface CycleReview {
188
  readonly schema: typeof CODER_REVIEW_SCHEMA;
189
  readonly score: number;
190
  readonly outOf: 10;
191
  readonly points: ReadonlyArray<ReviewScorePoint>;
192
  /** Did the lever cause the delta, or does a confounder explain it? */
193
  readonly causality: string;
194
  readonly violations: ReadonlyArray<PracticeViolation>;
195
  readonly proposals: ReadonlyArray<CoderCandidate>;
196
  readonly ledgerOperations: ReadonlyArray<LedgerOperation>;
197
}
198
199
/** Every way a reviewer's output can be refused, by name. */
200
export type RejectionReason =
201
  | "not_json"
202
  | "not_an_object"
203
  | "missing_field"
204
  | "wrong_type"
205
  | "unknown_lever_axis"
206
  | "unknown_delta_direction"
207
  | "unknown_ledger_op"
208
  | "unknown_ledger_status"
209
  | "score_out_of_range"
210
  | "no_proposals"
211
  | "too_many_proposals"
212
  | "proposal_without_evidence"
213
  | "proposal_without_trajectory_evidence"
214
  | "evidence_ref_malformed"
215
  | "evidence_ref_unknown_scheme"
216
  | "evidence_ref_unresolved";
217
218
export interface ReviewRejection {
219
  readonly reason: RejectionReason;
220
  /** Where in the reviewer's output, e.g. `proposals[0].evidence[1].ref`. */
221
  readonly path: string;
222
  readonly detail: string;
223
}
224
225
export type ParseCycleReviewResult =
226
  | { readonly ok: true; readonly review: CycleReview }
227
  | { readonly ok: false; readonly rejections: ReadonlyArray<ReviewRejection> };
228
229
/**
230
 * What the review was actually given, as the set of refs that can resolve.
231
 *
232
 * Built by the assembler from the request it sent, so "resolves" means "is in
233
 * the artifacts this reviewer read" rather than "exists somewhere on disk".
234
 */
235
export interface EvidenceIndex {
236
  /** `<task>#step-<id>` for every trajectory step that survived truncation. */
237
  readonly trajectorySteps: ReadonlySet<string>;
238
  /** Task names with a verifier decision in the request. */
239
  readonly trialOutcomes: ReadonlySet<string>;
240
  /** `<suite>#<recordedAt>` for every bench row in the request. */
241
  readonly benchRows: ReadonlySet<string>;
242
  /** Ledger entry ids parsed out of the practices file. */
243
  readonly ledgerEntries: ReadonlySet<string>;
244
  /** Paths named by the lever's diff. */
245
  readonly diffPaths: ReadonlySet<string>;
246
}
247
248
export type ResolveEvidenceResult =
249
  | { readonly ok: true; readonly kind: EvidenceKind }
250
  | {
251
      readonly ok: false;
252
      readonly reason: Extract<
253
        RejectionReason,
254
        "evidence_ref_malformed" | "evidence_ref_unknown_scheme" | "evidence_ref_unresolved"
255
      >;
256
      readonly detail: string;
257
    };
258
259
const STEP_SUFFIX = /^step-(.+)$/u;
260
261
/**
262
 * Resolve one evidence ref against the artifacts the reviewer was given.
263
 *
264
 * An unresolvable ref is the laundering move this whole command exists to
265
 * block, so the three ways it can fail are separate names: the ref did not
266
 * parse, its scheme is not one of the five, or it parsed and named nothing
267
 * that was in the request.
268
 */
269
export const resolveEvidenceRef = (index: EvidenceIndex, ref: string): ResolveEvidenceResult => {
270
  const separator = ref.indexOf(":");
271
  if (separator <= 0 || separator === ref.length - 1) {
272
    return {
273
      ok: false,
274
      reason: "evidence_ref_malformed",
275
      detail: `"${ref}" is not <scheme>:<target>. Use trial:<task>#step-<id>, trial:<task>#outcome, row:<suite>#<recordedAt>, ledger:<id>, or diff:<path>.`,
276
    };
277
  }
278
  const scheme = ref.slice(0, separator);
279
  const target = ref.slice(separator + 1);
280
281
  switch (scheme) {
282
    case "trial": {
283
      const hash = target.indexOf("#");
284
      if (hash <= 0 || hash === target.length - 1) {
285
        return {
286
          ok: false,
287
          reason: "evidence_ref_malformed",
288
          detail: `"${ref}" needs a #step-<id> or #outcome part.`,
289
        };
290
      }
291
      const task = target.slice(0, hash);
292
      const part = target.slice(hash + 1);
293
      if (part === "outcome") {
294
        return index.trialOutcomes.has(task)
295
          ? { ok: true, kind: "trial_outcome" }
296
          : {
297
              ok: false,
298
              reason: "evidence_ref_unresolved",
299
              detail: `no trial named "${task}" is in this review's request.`,
300
            };
301
      }
302
      const step = STEP_SUFFIX.exec(part);
303
      if (step === null) {
304
        return {
305
          ok: false,
306
          reason: "evidence_ref_malformed",
307
          detail: `"${ref}" names neither a step (#step-<id>) nor the verifier decision (#outcome).`,
308
        };
309
      }
310
      return index.trajectorySteps.has(target)
311
        ? { ok: true, kind: "trajectory_step" }
312
        : {
313
            ok: false,
314
            reason: "evidence_ref_unresolved",
315
            detail: `no step "${part}" of trial "${task}" is in this review's request. The request lists the steps it kept, and truncation is named there.`,
316
          };
317
    }
318
    case "row": {
319
      return index.benchRows.has(target)
320
        ? { ok: true, kind: "bench_row" }
321
        : {
322
            ok: false,
323
            reason: "evidence_ref_unresolved",
324
            detail: `no bench-results row "${target}" is in this review's request.`,
325
          };
326
    }
327
    case "ledger": {
328
      return index.ledgerEntries.has(target)
329
        ? { ok: true, kind: "ledger_entry" }
330
        : {
331
            ok: false,
332
            reason: "evidence_ref_unresolved",
333
            detail: `no ledger entry "${target}" is in the practices file this review was given.`,
334
          };
335
    }
336
    case "diff": {
337
      return index.diffPaths.has(target)
338
        ? { ok: true, kind: "diff_path" }
339
        : {
340
            ok: false,
341
            reason: "evidence_ref_unresolved",
342
            detail: `the lever's diff in this review's request touches no path "${target}".`,
343
          };
344
    }
345
    default:
346
      return {
347
        ok: false,
348
        reason: "evidence_ref_unknown_scheme",
349
        detail: `"${scheme}" is not one of trial, row, ledger, diff.`,
350
      };
351
  }
352
};
353
354
/** The largest number of proposals `autoimprove.md` §3 allows in one review. */
355
export const MAX_PROPOSALS = 3;
356
357
interface Collector {
358
  readonly rejections: Array<ReviewRejection>;
359
}
360
361
const record = (value: unknown): Record<string, unknown> | undefined =>
362
  typeof value === "object" && value !== null && !Array.isArray(value)
363
    ? (value as Record<string, unknown>)
364
    : undefined;
365
366
const requireString = (
367
  collector: Collector,
368
  source: Record<string, unknown>,
369
  key: string,
370
  path: string,
371
): string => {
372
  const value = source[key];
373
  if (value === undefined || value === null) {
374
    collector.rejections.push({
375
      reason: "missing_field",
376
      path: `${path}.${key}`,
377
      detail: `a review needs ${path}.${key}.`,
378
    });
379
    return "";
380
  }
381
  if (typeof value !== "string") {
382
    collector.rejections.push({
383
      reason: "wrong_type",
384
      path: `${path}.${key}`,
385
      detail: `${path}.${key} must be a string, got ${typeof value}.`,
386
    });
387
    return "";
388
  }
389
  return value;
390
};
391
392
const optionalString = (source: Record<string, unknown>, key: string): string => {
393
  const value = source[key];
394
  return typeof value === "string" ? value : "";
395
};
396
397
const requireNumber = (
398
  collector: Collector,
399
  source: Record<string, unknown>,
400
  key: string,
401
  path: string,
402
): number => {
403
  const value = source[key];
404
  if (typeof value !== "number" || !Number.isFinite(value)) {
405
    collector.rejections.push({
406
      reason: value === undefined ? "missing_field" : "wrong_type",
407
      path: `${path}.${key}`,
408
      detail: `${path}.${key} must be a finite number.`,
409
    });
410
    return 0;
411
  }
412
  return value;
413
};
414
415
const requireArray = (
416
  collector: Collector,
417
  source: Record<string, unknown>,
418
  key: string,
419
  path: string,
420
): ReadonlyArray<unknown> => {
421
  const value = source[key];
422
  if (value === undefined) {
423
    collector.rejections.push({
424
      reason: "missing_field",
425
      path: `${path}.${key}`,
426
      detail: `a review needs ${path}.${key}.`,
427
    });
428
    return [];
429
  }
430
  if (!Array.isArray(value)) {
431
    collector.rejections.push({
432
      reason: "wrong_type",
433
      path: `${path}.${key}`,
434
      detail: `${path}.${key} must be an array.`,
435
    });
436
    return [];
437
  }
438
  return value;
439
};
440
441
const readEvidence = (
442
  collector: Collector,
443
  index: EvidenceIndex,
444
  raw: ReadonlyArray<unknown>,
445
  path: string,
446
): { readonly refs: ReadonlyArray<EvidenceRef>; readonly kinds: ReadonlyArray<EvidenceKind> } => {
447
  const refs: Array<EvidenceRef> = [];
448
  const kinds: Array<EvidenceKind> = [];
449
  raw.forEach((entry, position) => {
450
    const here = `${path}[${String(position)}]`;
451
    // A bare string is accepted: a reviewer that cites a step and says nothing
452
    // about it has still cited the step, and forcing a note would invite a
453
    // filler sentence rather than a better citation.
454
    const asRecord = record(entry);
455
    const ref = typeof entry === "string" ? entry : optionalString(asRecord ?? {}, "ref");
456
    const note = typeof entry === "string" ? "" : optionalString(asRecord ?? {}, "note");
457
    if (ref === "") {
458
      collector.rejections.push({
459
        reason: "missing_field",
460
        path: `${here}.ref`,
461
        detail: `${here} carries no ref.`,
462
      });
463
      return;
464
    }
465
    const resolved = resolveEvidenceRef(index, ref);
466
    if (!resolved.ok) {
467
      collector.rejections.push({
468
        reason: resolved.reason,
469
        path: `${here}.ref`,
470
        detail: resolved.detail,
471
      });
472
      return;
473
    }
474
    refs.push({ ref, note });
475
    kinds.push(resolved.kind);
476
  });
477
  return { refs, kinds };
478
};
479
480
const readCandidate = (
481
  collector: Collector,
482
  index: EvidenceIndex,
483
  raw: unknown,
484
  path: string,
485
  producedBy: string,
486
): CoderCandidate | undefined => {
487
  const source = record(raw);
488
  if (source === undefined) {
489
    collector.rejections.push({
490
      reason: "not_an_object",
491
      path,
492
      detail: `${path} must be an object.`,
493
    });
494
    return undefined;
495
  }
496
497
  const leverSource = record(source["lever"]);
498
  const axisRaw =
499
    leverSource === undefined
500
      ? optionalString(source, "lever")
501
      : optionalString(leverSource, "axis");
502
  const summary =
503
    leverSource === undefined
504
      ? requireString(collector, source, "summary", path)
505
      : requireString(collector, leverSource, "summary", `${path}.lever`);
506
  if (!LEVER_AXES.includes(axisRaw as LeverAxis)) {
507
    collector.rejections.push({
508
      reason: "unknown_lever_axis",
509
      path: `${path}.lever.axis`,
510
      detail: `"${axisRaw}" is not one of ${LEVER_AXES.join(", ")}.`,
511
    });
512
  }
513
514
  const evidenceRaw = requireArray(collector, source, "evidence", path);
515
  const evidence = readEvidence(collector, index, evidenceRaw, `${path}.evidence`);
516
  if (evidence.refs.length === 0) {
517
    collector.rejections.push({
518
      reason: "proposal_without_evidence",
519
      path: `${path}.evidence`,
520
      detail: `${path} cites no evidence that resolves. A proposal with no evidence pointer is refused here rather than in the adopt step.`,
521
    });
522
  } else if (!evidence.kinds.includes("trajectory_step")) {
523
    collector.rejections.push({
524
      reason: "proposal_without_trajectory_evidence",
525
      path: `${path}.evidence`,
526
      detail: `${path} cites no trajectory step. autoimprove.md §3 types a proposal's evidence as specific steps in the trajectory; a row or a ledger entry alone does not say what the coder did.`,
527
    });
528
  }
529
530
  const risk = requireString(collector, source, "risk", path);
531
532
  const verificationSource = record(source["verification"]);
533
  if (verificationSource === undefined) {
534
    collector.rejections.push({
535
      reason: "missing_field",
536
      path: `${path}.verification`,
537
      detail: `${path} must say which suite would confirm it and which way the figure should move.`,
538
    });
539
  }
540
  const verificationRecord = verificationSource ?? {};
541
  const suite = requireString(collector, verificationRecord, "suite", `${path}.verification`);
542
  const metric = requireString(collector, verificationRecord, "metric", `${path}.verification`);
543
  const directionRaw = optionalString(verificationRecord, "expectedDirection");
544
  if (!DELTA_DIRECTIONS.includes(directionRaw as DeltaDirection)) {
545
    collector.rejections.push({
546
      reason: "unknown_delta_direction",
547
      path: `${path}.verification.expectedDirection`,
548
      detail: `"${directionRaw}" is not one of ${DELTA_DIRECTIONS.join(", ")}.`,
549
    });
550
  }
551
552
  const surfaces: Array<CandidateSurfaceDiff> = [];
553
  const surfacesRaw = source["surfaces"];
554
  if (Array.isArray(surfacesRaw)) {
555
    surfacesRaw.forEach((entry, position) => {
556
      const here = `${path}.surfaces[${String(position)}]`;
557
      const surfaceRecord = record(entry);
558
      if (surfaceRecord === undefined) {
559
        collector.rejections.push({
560
          reason: "not_an_object",
561
          path: here,
562
          detail: `${here} must be an object of {surface, diff}.`,
563
        });
564
        return;
565
      }
566
      surfaces.push({
567
        surface: requireString(collector, surfaceRecord, "surface", here),
568
        diff: optionalString(surfaceRecord, "diff"),
569
      });
570
    });
571
  }
572
573
  const lineageSource = record(source["lineage"]) ?? {};
574
  const parent = lineageSource["parent"];
575
  const originRaw = optionalString(lineageSource, "origin");
576
577
  const candidate: Omit<CoderCandidate, "candidateId"> = {
578
    schema: CODER_CANDIDATE_SCHEMA,
579
    lever: { axis: axisRaw as LeverAxis, summary },
580
    surfaces,
581
    lineage: {
582
      origin: originRaw === "optimizer" || originRaw === "human" ? originRaw : "review",
583
      parent: typeof parent === "string" && parent !== "" ? parent : null,
584
      producedBy,
585
    },
586
    transferLabel: {
587
      modelFamily: optionalString(record(source["transferLabel"]) ?? {}, "modelFamily"),
588
      lane: optionalString(record(source["transferLabel"]) ?? {}, "lane"),
589
    },
590
    evidence: evidence.refs,
591
    risk,
592
    verification: {
593
      suite,
594
      metric,
595
      expectedDirection: directionRaw as DeltaDirection,
596
    },
597
  };
598
599
  return { ...candidate, candidateId: candidateIdOf(candidate) };
600
};
601
602
const readLedgerOperation = (
603
  collector: Collector,
604
  index: EvidenceIndex,
605
  raw: unknown,
606
  path: string,
607
): LedgerOperation | undefined => {
608
  const source = record(raw);
609
  if (source === undefined) {
610
    collector.rejections.push({
611
      reason: "not_an_object",
612
      path,
613
      detail: `${path} must be an object.`,
614
    });
615
    return undefined;
616
  }
617
  const op = optionalString(source, "op");
618
  if (!LEDGER_OPS.includes(op as LedgerOp)) {
619
    collector.rejections.push({
620
      reason: "unknown_ledger_op",
621
      path: `${path}.op`,
622
      detail: `"${op}" is not one of ${LEDGER_OPS.join(", ")}.`,
623
    });
624
  }
625
  const entrySource = record(source["entry"]);
626
  if (entrySource === undefined) {
627
    collector.rejections.push({
628
      reason: "missing_field",
629
      path: `${path}.entry`,
630
      detail: `${path} must carry the ledger entry it operates on.`,
631
    });
632
  }
633
  const entryRecord = entrySource ?? {};
634
  const status = optionalString(entryRecord, "status");
635
  if (!LEDGER_STATUSES.includes(status as LedgerStatus)) {
636
    collector.rejections.push({
637
      reason: "unknown_ledger_status",
638
      path: `${path}.entry.status`,
639
      detail: `"${status}" is not one of ${LEDGER_STATUSES.join(", ")}.`,
640
    });
641
  }
642
  const id = entryRecord["id"];
643
  const provenanceRaw = requireArray(collector, source, "provenance", path);
644
  const provenance = readEvidence(collector, index, provenanceRaw, `${path}.provenance`);
645
  if (provenance.refs.length === 0) {
646
    collector.rejections.push({
647
      reason: "proposal_without_evidence",
648
      path: `${path}.provenance`,
649
      detail: `${path} carries no provenance that resolves. autoimprove.md §5 requires every ledger entry to carry the run, review, or postmortem that produced it.`,
650
    });
651
  }
652
653
  return {
654
    op: op as LedgerOp,
655
    entry: {
656
      id: typeof id === "string" && id !== "" ? id : null,
657
      section: requireString(collector, entryRecord, "section", `${path}.entry`),
658
      title: requireString(collector, entryRecord, "title", `${path}.entry`),
659
      statement: requireString(collector, entryRecord, "statement", `${path}.entry`),
660
      detection: requireString(collector, entryRecord, "detection", `${path}.entry`),
661
      status: status as LedgerStatus,
662
    },
663
    provenance: provenance.refs,
664
  };
665
};
666
667
/**
668
 * Parse a reviewer's raw output into a checked {@link CycleReview}.
669
 *
670
 * `producedBy` is stamped into every candidate's lineage so a proposal can be
671
 * traced back to the review that emitted it without the reviewer being asked
672
 * to state its own identity, which it has no reliable way to know.
673
 */
674
export const parseCycleReview = (
675
  raw: string,
676
  index: EvidenceIndex,
677
  producedBy: string,
678
): ParseCycleReviewResult => {
679
  const collector: Collector = { rejections: [] };
680
681
  let parsed: unknown;
682
  try {
683
    parsed = JSON.parse(extractJsonObject(raw)) as unknown;
684
  } catch (cause) {
685
    return {
686
      ok: false,
687
      rejections: [
688
        {
689
          reason: "not_json",
690
          path: "$",
691
          detail: `the reviewer's output is not JSON: ${cause instanceof Error ? cause.message : String(cause)}`,
692
        },
693
      ],
694
    };
695
  }
696
697
  const source = record(parsed);
698
  if (source === undefined) {
699
    return {
700
      ok: false,
701
      rejections: [
702
        { reason: "not_an_object", path: "$", detail: "the reviewer's output is not an object." },
703
      ],
704
    };
705
  }
706
707
  const score = requireNumber(collector, source, "score", "$");
708
  if (score < 0 || score > 10) {
709
    collector.rejections.push({
710
      reason: "score_out_of_range",
711
      path: "$.score",
712
      detail: `the score is 0-10; got ${String(score)}.`,
713
    });
714
  }
715
716
  const points: Array<ReviewScorePoint> = [];
717
  requireArray(collector, source, "points", "$").forEach((entry, position) => {
718
    const here = `$.points[${String(position)}]`;
719
    const pointSource = record(entry);
720
    if (pointSource === undefined) {
721
      collector.rejections.push({
722
        reason: "not_an_object",
723
        path: here,
724
        detail: `${here} must be an object.`,
725
      });
726
      return;
727
    }
728
    const evidence = readEvidence(
729
      collector,
730
      index,
731
      requireArray(collector, pointSource, "evidence", here),
732
      `${here}.evidence`,
733
    );
734
    points.push({
735
      point: requireString(collector, pointSource, "point", here),
736
      delta: requireNumber(collector, pointSource, "delta", here),
737
      evidence: evidence.refs,
738
    });
739
  });
740
741
  const violations: Array<PracticeViolation> = [];
742
  const violationsRaw = source["violations"];
743
  if (Array.isArray(violationsRaw)) {
744
    violationsRaw.forEach((entry, position) => {
745
      const here = `$.violations[${String(position)}]`;
746
      const violationSource = record(entry);
747
      if (violationSource === undefined) {
748
        collector.rejections.push({
749
          reason: "not_an_object",
750
          path: here,
751
          detail: `${here} must be an object.`,
752
        });
753
        return;
754
      }
755
      const evidence = readEvidence(
756
        collector,
757
        index,
758
        requireArray(collector, violationSource, "evidence", here),
759
        `${here}.evidence`,
760
      );
761
      violations.push({
762
        entry: requireString(collector, violationSource, "entry", here),
763
        note: optionalString(violationSource, "note"),
764
        evidence: evidence.refs,
765
      });
766
    });
767
  }
768
769
  const proposalsRaw = requireArray(collector, source, "proposals", "$");
770
  if (proposalsRaw.length === 0) {
771
    collector.rejections.push({
772
      reason: "no_proposals",
773
      path: "$.proposals",
774
      detail:
775
        "a review proposes one to three changes. A review with nothing to propose says so as a proposal whose lever is the practice it would keep, not as an empty list.",
776
    });
777
  }
778
  if (proposalsRaw.length > MAX_PROPOSALS) {
779
    collector.rejections.push({
780
      reason: "too_many_proposals",
781
      path: "$.proposals",
782
      detail: `autoimprove.md §3 allows one to three proposals; got ${String(proposalsRaw.length)}.`,
783
    });
784
  }
785
  const proposals: Array<CoderCandidate> = [];
786
  proposalsRaw.forEach((entry, position) => {
787
    const candidate = readCandidate(
788
      collector,
789
      index,
790
      entry,
791
      `$.proposals[${String(position)}]`,
792
      producedBy,
793
    );
794
    if (candidate !== undefined) proposals.push(candidate);
795
  });
796
797
  const ledgerOperations: Array<LedgerOperation> = [];
798
  const ledgerRaw = source["ledgerOperations"];
799
  if (Array.isArray(ledgerRaw)) {
800
    ledgerRaw.forEach((entry, position) => {
801
      const operation = readLedgerOperation(
802
        collector,
803
        index,
804
        entry,
805
        `$.ledgerOperations[${String(position)}]`,
806
      );
807
      if (operation !== undefined) ledgerOperations.push(operation);
808
    });
809
  }
810
811
  if (collector.rejections.length > 0) {
812
    return { ok: false, rejections: collector.rejections };
813
  }
814
815
  return {
816
    ok: true,
817
    review: {
818
      schema: CODER_REVIEW_SCHEMA,
819
      score,
820
      outOf: 10,
821
      points,
822
      causality: requireString(collector, source, "causality", "$"),
823
      violations,
824
      proposals,
825
      ledgerOperations,
826
    },
827
  };
828
};
829
830
/**
831
 * Take the JSON object out of a model's reply.
832
 *
833
 * A model asked for JSON commonly wraps it in a fenced block or a sentence.
834
 * Refusing that would make the real lane fail for a reason that has nothing to
835
 * do with the review's content, so the outermost `{...}` span is used when the
836
 * whole string does not parse. Anything looser would start guessing.
837
 */
838
export const extractJsonObject = (raw: string): string => {
839
  const trimmed = raw.trim();
840
  if (trimmed.startsWith("{")) return trimmed;
841
  const fenced = /```(?:json)?\s*([\s\S]*?)```/u.exec(trimmed);
842
  const body = fenced?.[1]?.trim();
843
  if (body !== undefined && body.startsWith("{")) return body;
844
  const open = trimmed.indexOf("{");
845
  const close = trimmed.lastIndexOf("}");
846
  return open >= 0 && close > open ? trimmed.slice(open, close + 1) : trimmed;
847
};
848
849
/**
850
 * A stable id over a candidate's own facts.
851
 *
852
 * FNV-1a rather than a crypto hash: this is an identity for a pool entry, not
853
 * a receipt. `bench-results` owns the tamper-evidence, and borrowing its
854
 * vocabulary here would suggest this digest carries the same weight.
855
 */
856
export const candidateIdOf = (candidate: Omit<CoderCandidate, "candidateId">): string => {
857
  const source = JSON.stringify({
858
    axis: candidate.lever.axis,
859
    summary: candidate.lever.summary,
860
    surfaces: candidate.surfaces.map((surface) => [surface.surface, surface.diff]),
861
    evidence: candidate.evidence.map((entry) => entry.ref).sort(),
862
    verification: candidate.verification,
863
  });
864
  let hash = 0x811c9dc5;
865
  for (let position = 0; position < source.length; position += 1) {
866
    hash ^= source.charCodeAt(position);
867
    hash = Math.imul(hash, 0x01000193) >>> 0;
868
  }
869
  return `candidate:${hash.toString(16).padStart(8, "0")}`;
870
};
packages/openagents-cli/src/coder-review-cli.ts added +440

@@ -0,0 +1,440 @@

1
/**
2
 * `coder:review` — review one autoimprovement cycle from its artifacts.
3
 *
4
 * `docs/coder/runbook.md` §6 says to run the review as a separate conversation
5
 * whose only inputs are the artifacts, and then hands a human a prompt skeleton
6
 * to fill in by copying transcripts. This command is that step done
7
 * mechanically:
8
 *
9
 *     pnpm run coder:review -- /tmp/gym-jobs/<job>/<run> \
10
 *       --suite tb2-quick --lane proxy --lever HEAD~1 \
11
 *       --slug tool-description-batching
12
 *
13
 * It assembles the request from the job directory, redacts it with the one rule
14
 * list, renders the §6 prompt with the citable evidence refs printed in it,
15
 * asks the reviewer, checks every citation against what the reviewer was
16
 * actually given, and writes `docs/coder/reviews/YYYY-MM-DD-<slug>.md` plus the
17
 * machine-readable review beside it.
18
 *
19
 * `--offline <file>` replays a recorded reviewer response instead of asking a
20
 * model. It replays; it never generates. A command whose offline mode invented
21
 * a plausible score would be the exact failure the review loop exists to catch,
22
 * so there is no fallback path in this file that produces a review without a
23
 * reviewer having written one.
24
 *
25
 * EXIT CODES. 0 the review was accepted and written. 1 the reviewer answered
26
 * and the answer was refused — the named reasons are on stderr, and nothing was
27
 * written. 2 the review could not be run at all: bad arguments, no job
28
 * directory, no reviewer. A refusal is a finding; an unrunnable command is not.
29
 */
30
31
import { execFileSync } from "node:child_process";
32
import { existsSync, readFileSync } from "node:fs";
33
import { basename, join } from "node:path";
34
35
import {
36
  assembleReviewRequest,
37
  DEFAULT_MAX_DIFF_LINES,
38
  DEFAULT_MAX_STEP_CHARS,
39
  DEFAULT_MAX_STEPS,
40
  readBenchRows,
41
} from "./coder-review-assemble.js";
42
import { replayLane, responsesLane, ReviewerUnavailable } from "./coder-review-lane.js";
43
import type { ReviewerLane } from "./coder-review-lane.js";
44
import { renderRejections, renderReviewPrompt } from "./coder-review-prompt.js";
45
import { reviewPathFor, runCycleReview, slugOf, writeReviewArtifacts } from "./coder-review-run.js";
46
47
const USAGE = `Usage: coder:review <job-dir> [options]
48
49
Arguments:
50
  <job-dir>              A completed Harbor job directory: the one holding
51
                         result.json and one directory per trial.
52
53
The lever (one is required — a review that does not know what changed cannot
54
say whether the change caused the delta):
55
  --lever <ref>          Take the lever's diff from \`git diff <ref>\`.
56
  --diff <file>          Take the lever's diff from a file instead of git.
57
  --no-diff              This cycle changed nothing (a baseline run). Says so
58
                         in the prompt rather than showing an empty diff.
59
60
Options:
61
  --suite <name>         Suite this cycle measured. Default: tb2-quick
62
  --lane <proxy|local>   Lane the run used. Default: proxy
63
  --store <file>         bench-results store to read rows from.
64
                         Default: bench-results/<suite>.jsonl
65
  --rows <n>             How many trailing store rows to show. Default: 4
66
  --practices <file>     The ledger. Default: docs/coder/best-practices.md
67
  --reviews-dir <dir>    Where the review lands. Default: docs/coder/reviews
68
  --slug <name>          Lever slug for the filename. Default: from --lever
69
  --title <text>         Review file title. Default: from the slug and suite
70
  --offline <file>       Replay a recorded reviewer response from this file
71
                         instead of asking a model. Replays only; a replay lane
72
                         has no opinion and its ref says so in the review file.
73
  --api-url <url>        Reviewer origin. Default: $OPENAGENTS_CODER_API_URL,
74
                         else $OPENAGENTS_API_URL, else http://localhost:4000
75
  --reviewer-model <id>  Model to review with. Prefer one from a different
76
                         family than the cycle ran on where the finding is
77
                         load-bearing (autoimprove §6).
78
  --print-prompt         Print the assembled prompt and exit. Asks no reviewer
79
                         and writes nothing: this is how the manual §6 lane
80
                         gets a prompt it did not hand-assemble.
81
  --max-steps <n>        Trajectory steps kept per trial, from the tail.
82
  --max-step-chars <n>   Characters kept per step and per tool result.
83
  --max-diff-lines <n>   Lines kept of the lever's diff.
84
  --json                 Emit the outcome as JSON on stdout.
85
  -h, --help             Show this help.
86
87
Exit codes: 0 accepted and written, 1 the reviewer's answer was refused (the
88
named reasons are on stderr and nothing was written), 2 the review could not be
89
run at all.`;
90
91
/** The reviewer answered and its answer did not survive the checks. */
92
const REFUSED_EXIT = 1;
93
/** The review could not be run. Distinct from a refusal, which is a finding. */
94
const UNRUNNABLE_EXIT = 2;
95
96
interface Arguments {
97
  readonly jobDir: string;
98
  readonly suite: string;
99
  readonly lane: string;
100
  readonly leverRef: string | null;
101
  readonly diffPath: string | null;
102
  readonly noDiff: boolean;
103
  readonly storePath: string | null;
104
  readonly rows: number;
105
  readonly practicesPath: string;
106
  readonly reviewsDir: string;
107
  readonly slug: string | null;
108
  readonly title: string | null;
109
  readonly offlinePath: string | null;
110
  readonly apiUrl: string | null;
111
  readonly reviewerModel: string | null;
112
  readonly printPrompt: boolean;
113
  readonly maxSteps: number | null;
114
  readonly maxStepChars: number | null;
115
  readonly maxDiffLines: number | null;
116
  readonly json: boolean;
117
}
118
119
/** `--` is what pnpm forwards into argv, never an argument. */
120
const parseArguments = (argv: ReadonlyArray<string>): Arguments | "help" => {
121
  let jobDir: string | null = null;
122
  let suite = "tb2-quick";
123
  let lane = "proxy";
124
  let leverRef: string | null = null;
125
  let diffPath: string | null = null;
126
  let noDiff = false;
127
  let storePath: string | null = null;
128
  let rows = 4;
129
  let practicesPath = "docs/coder/best-practices.md";
130
  let reviewsDir = "docs/coder/reviews";
131
  let slug: string | null = null;
132
  let title: string | null = null;
133
  let offlinePath: string | null = null;
134
  let apiUrl: string | null = null;
135
  let reviewerModel: string | null = null;
136
  let printPrompt = false;
137
  let maxSteps: number | null = null;
138
  let maxStepChars: number | null = null;
139
  let maxDiffLines: number | null = null;
140
  let json = false;
141
142
  for (let index = 0; index < argv.length; index += 1) {
143
    const argument = argv[index]!;
144
    if (argument === "--") continue;
145
    if (argument === "-h" || argument === "--help") return "help";
146
    if (argument === "--json") {
147
      json = true;
148
      continue;
149
    }
150
    if (argument === "--no-diff") {
151
      noDiff = true;
152
      continue;
153
    }
154
    if (argument === "--print-prompt") {
155
      printPrompt = true;
156
      continue;
157
    }
158
    if (argument === "--suite") {
159
      suite = expectValue(argv, (index += 1), argument);
160
      continue;
161
    }
162
    if (argument === "--lane") {
163
      lane = expectValue(argv, (index += 1), argument);
164
      continue;
165
    }
166
    if (argument === "--lever") {
167
      leverRef = expectValue(argv, (index += 1), argument);
168
      continue;
169
    }
170
    if (argument === "--diff") {
171
      diffPath = expectValue(argv, (index += 1), argument);
172
      continue;
173
    }
174
    if (argument === "--store") {
175
      storePath = expectValue(argv, (index += 1), argument);
176
      continue;
177
    }
178
    if (argument === "--rows") {
179
      rows = expectCount(argv, (index += 1), argument);
180
      continue;
181
    }
182
    if (argument === "--practices") {
183
      practicesPath = expectValue(argv, (index += 1), argument);
184
      continue;
185
    }
186
    if (argument === "--reviews-dir") {
187
      reviewsDir = expectValue(argv, (index += 1), argument);
188
      continue;
189
    }
190
    if (argument === "--slug") {
191
      slug = expectValue(argv, (index += 1), argument);
192
      continue;
193
    }
194
    if (argument === "--title") {
195
      title = expectValue(argv, (index += 1), argument);
196
      continue;
197
    }
198
    if (argument === "--offline") {
199
      offlinePath = expectValue(argv, (index += 1), argument);
200
      continue;
201
    }
202
    if (argument === "--api-url") {
203
      apiUrl = expectValue(argv, (index += 1), argument);
204
      continue;
205
    }
206
    if (argument === "--reviewer-model") {
207
      reviewerModel = expectValue(argv, (index += 1), argument);
208
      continue;
209
    }
210
    if (argument === "--max-steps") {
211
      maxSteps = expectCount(argv, (index += 1), argument);
212
      continue;
213
    }
214
    if (argument === "--max-step-chars") {
215
      maxStepChars = expectCount(argv, (index += 1), argument);
216
      continue;
217
    }
218
    if (argument === "--max-diff-lines") {
219
      maxDiffLines = expectCount(argv, (index += 1), argument);
220
      continue;
221
    }
222
    if (argument.startsWith("-")) throw new Error(`unknown option: ${argument}`);
223
    if (jobDir !== null) throw new Error(`unexpected extra argument: ${argument}`);
224
    jobDir = argument;
225
  }
226
227
  if (jobDir === null) throw new Error("missing required <job-dir> argument");
228
  if (lane !== "proxy" && lane !== "local") {
229
    throw new Error(`--lane must be proxy or local, got: ${lane}`);
230
  }
231
  const leverSources = [leverRef !== null, diffPath !== null, noDiff].filter(Boolean).length;
232
  if (leverSources === 0) {
233
    throw new Error(
234
      "name the lever: --lever <ref>, --diff <file>, or --no-diff for a cycle that changed nothing. A review that cannot see the change cannot attribute the delta to it.",
235
    );
236
  }
237
  if (leverSources > 1) {
238
    throw new Error("--lever, --diff, and --no-diff are three answers to one question; pick one");
239
  }
240
241
  return {
242
    jobDir,
243
    suite,
244
    lane,
245
    leverRef,
246
    diffPath,
247
    noDiff,
248
    storePath,
249
    rows,
250
    practicesPath,
251
    reviewsDir,
252
    slug,
253
    title,
254
    offlinePath,
255
    apiUrl,
256
    reviewerModel,
257
    printPrompt,
258
    maxSteps,
259
    maxStepChars,
260
    maxDiffLines,
261
    json,
262
  };
263
};
264
265
const expectValue = (argv: ReadonlyArray<string>, index: number, option: string): string => {
266
  const value = argv[index];
267
  if (value === undefined || value.startsWith("-")) throw new Error(`${option} needs a value`);
268
  return value;
269
};
270
271
const expectCount = (argv: ReadonlyArray<string>, index: number, option: string): number => {
272
  const value = Number(expectValue(argv, index, option));
273
  if (!Number.isInteger(value) || value < 1) {
274
    throw new Error(`${option} needs a whole number of at least 1`);
275
  }
276
  return value;
277
};
278
279
/** The diff of the lever, from wherever this invocation says it lives. */
280
const leverOf = (parsed: Arguments): { readonly ref: string; readonly diff: string } => {
281
  if (parsed.noDiff) return { ref: "none", diff: "" };
282
  if (parsed.diffPath !== null) {
283
    return { ref: parsed.diffPath, diff: readFileSync(parsed.diffPath, "utf8") };
284
  }
285
  const ref = parsed.leverRef!;
286
  try {
287
    return {
288
      ref,
289
      diff: execFileSync("git", ["diff", ref], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }),
290
    };
291
  } catch (cause) {
292
    throw new Error(
293
      `\`git diff ${ref}\` failed: ${String(cause)}. Pass --diff <file> if the lever is not a ref in this checkout.`,
294
    );
295
  }
296
};
297
298
const reviewerOf = (parsed: Arguments): ReviewerLane => {
299
  if (parsed.offlinePath !== null) {
300
    const path = parsed.offlinePath;
301
    if (!existsSync(path)) {
302
      throw new ReviewerUnavailable(
303
        `--offline ${path} does not exist. The replay lane returns a recorded reviewer response; it does not write one.`,
304
      );
305
    }
306
    // The basename, not the path: the ref is written into the review file and
307
    // into every candidate's lineage, and a local path is a local fact that
308
    // means nothing to the next reader of `docs/coder/reviews/`.
309
    return replayLane(basename(path), () => readFileSync(path, "utf8"));
310
  }
311
  const origin =
312
    parsed.apiUrl ??
313
    process.env["OPENAGENTS_CODER_API_URL"] ??
314
    process.env["OPENAGENTS_API_URL"] ??
315
    "http://localhost:4000";
316
  return responsesLane({
317
    origin,
318
    token: process.env["OPENAGENTS_TOKEN"],
319
    model: parsed.reviewerModel ?? undefined,
320
  });
321
};
322
323
// Not exported: this module runs on import, so a caller that reached `main`
324
// would have already run the command once to get at it. The tests drive it the
325
// way pnpm does, as a process.
326
const main = async (argv: ReadonlyArray<string>): Promise<number> => {
327
  let parsed: Arguments | "help";
328
  try {
329
    parsed = parseArguments(argv);
330
  } catch (error) {
331
    process.stderr.write(`${(error as Error).message}\n\n${USAGE}\n`);
332
    return UNRUNNABLE_EXIT;
333
  }
334
  if (parsed === "help") {
335
    process.stdout.write(`${USAGE}\n`);
336
    return 0;
337
  }
338
339
  const recordedAt = new Date().toISOString();
340
  const slug = slugOf(
341
    parsed.slug ??
342
      parsed.leverRef ??
343
      (parsed.diffPath === null ? "baseline" : basename(parsed.diffPath)),
344
  );
345
346
  try {
347
    const lever = leverOf(parsed);
348
    const storePath = parsed.storePath ?? join("bench-results", `${parsed.suite}.jsonl`);
349
    const practices = {
350
      path: parsed.practicesPath,
351
      text: readFileSync(parsed.practicesPath, "utf8"),
352
    };
353
354
    const inputs = {
355
      suite: parsed.suite,
356
      lane: parsed.lane,
357
      lever,
358
      rows: readBenchRows(storePath, parsed.suite, parsed.rows),
359
      practices,
360
      maxSteps: parsed.maxSteps ?? DEFAULT_MAX_STEPS,
361
      maxStepChars: parsed.maxStepChars ?? DEFAULT_MAX_STEP_CHARS,
362
      maxDiffLines: parsed.maxDiffLines ?? DEFAULT_MAX_DIFF_LINES,
363
    };
364
365
    // `--print-prompt` asks nobody. The prompt is the artifact the manual §6
366
    // lane needs, and producing it must not depend on a reviewer being
367
    // reachable — otherwise the fallback for a down lane is hand-assembly,
368
    // which is the step this command replaces.
369
    if (parsed.printPrompt) {
370
      const assembled = assembleReviewRequest(parsed.jobDir, inputs);
371
      process.stdout.write(`${renderReviewPrompt(assembled.request, assembled.index)}\n`);
372
      return 0;
373
    }
374
375
    const outcome = await runCycleReview({
376
      jobDir: parsed.jobDir,
377
      ...inputs,
378
      reviewer: reviewerOf(parsed),
379
    });
380
381
    if (!outcome.result.ok) {
382
      process.stderr.write(`${renderRejections(outcome.result.rejections, outcome.reviewerRef)}\n`);
383
      if (parsed.json) {
384
        process.stdout.write(
385
          `${JSON.stringify(
386
            {
387
              accepted: false,
388
              reviewer: outcome.reviewerRef,
389
              rejections: outcome.result.rejections,
390
            },
391
            null,
392
            2,
393
          )}\n`,
394
        );
395
      }
396
      return REFUSED_EXIT;
397
    }
398
399
    const artifacts = writeReviewArtifacts({
400
      outcome,
401
      markdownPath: reviewPathFor(parsed.reviewsDir, recordedAt, slug),
402
      title: parsed.title ?? `Cycle review: ${slug} on ${parsed.suite}`,
403
      recordedAt,
404
    });
405
406
    process.stdout.write(
407
      parsed.json
408
        ? `${JSON.stringify(
409
            {
410
              accepted: true,
411
              reviewer: outcome.reviewerRef,
412
              score: outcome.result.review.score,
413
              proposals: outcome.result.review.proposals.length,
414
              markdownPath: artifacts.markdownPath,
415
              jsonPath: artifacts.jsonPath,
416
            },
417
            null,
418
            2,
419
          )}\n`
420
        : [
421
            `Score ${String(outcome.result.review.score)}/10 from ${outcome.reviewerRef}.`,
422
            `${String(outcome.result.review.proposals.length)} proposal(s), ${String(
423
              outcome.result.review.ledgerOperations.length,
424
            )} ledger operation(s).`,
425
            `Wrote ${artifacts.markdownPath}`,
426
            `Wrote ${artifacts.jsonPath}`,
427
            "Adopting a proposal is a separate act. Each enters the runbook at §3.",
428
            "",
429
          ].join("\n"),
430
    );
431
    return 0;
432
  } catch (error) {
433
    process.stderr.write(`coder:review: ${(error as Error).message}\n`);
434
    return UNRUNNABLE_EXIT;
435
  }
436
};
437
438
void main(process.argv.slice(2)).then((code) => {
439
  process.exitCode = code;
440
});
packages/openagents-cli/src/coder-review-lane.ts added +198

@@ -0,0 +1,198 @@

1
/**
2
 * Where a review's text comes from.
3
 *
4
 * Two lanes, and the difference between them is the whole point of the split.
5
 * The live lane sends the assembled prompt to a model and returns what the
6
 * model wrote. The replay lane returns a reviewer response that was recorded
7
 * earlier, verbatim, without reading the prompt at all.
8
 *
9
 * THE REPLAY LANE NEVER GENERATES. It is not an "offline reviewer" and it does
10
 * not have an opinion; it is a recording. That distinction is the reason this
11
 * command exists: `docs/coder/autoimprove.md` §6 names "confident review
12
 * without understanding" as a failure mode, and a stand-in that invents a
13
 * plausible score when the network is down is that failure mode wearing the
14
 * command's own uniform. So the replay lane's ref says `replay:` and every
15
 * artifact it produces carries that ref, where a reader will see it.
16
 *
17
 * The `--offline` flag on the CLI selects the replay lane. There is no third
18
 * behavior — no canned score, no degraded live call, no default review. A lane
19
 * that cannot answer fails.
20
 */
21
22
/** A source of one reviewer response. */
23
export interface ReviewerLane {
24
  /**
25
   * How this reviewer is named in the review file and in the candidate
26
   * lineage. It carries the lane kind first (`replay:`, `responses:`) so a
27
   * replayed review cannot be mistaken for a fresh one at a glance.
28
   */
29
  readonly ref: string;
30
  readonly ask: (prompt: string, signal?: AbortSignal) => Promise<string>;
31
}
32
33
export class ReviewerUnavailable extends Error {
34
  constructor(message: string) {
35
    super(message);
36
    this.name = "ReviewerUnavailable";
37
  }
38
}
39
40
/**
41
 * Replay a recorded reviewer response.
42
 *
43
 * `read` is injected rather than reading the path directly so a test can prove
44
 * the lane never consults the prompt: the reader takes no argument, and there
45
 * is no path from `ask`'s input to its output.
46
 */
47
export const replayLane = (label: string, read: () => string): ReviewerLane => ({
48
  ref: `replay:${label}`,
49
  ask: (): Promise<string> => {
50
    const recorded = read();
51
    if (recorded.trim() === "") {
52
      throw new ReviewerUnavailable(
53
        `the recorded reviewer response at ${label} is empty. A replay lane returns what was recorded; it does not fill in a review.`,
54
      );
55
    }
56
    return Promise.resolve(recorded);
57
  },
58
});
59
60
export interface ResponsesLaneOptions {
61
  /** The API origin, such as `http://localhost:4000`. */
62
  readonly origin: string;
63
  /** The account bearer. The surface also answers without one. */
64
  readonly token?: string | undefined;
65
  /** The model to review with. Absent, the surface picks its default. */
66
  readonly model?: string | undefined;
67
  /** Injected for tests. Defaults to the global `fetch`. */
68
  readonly fetch?: typeof globalThis.fetch | undefined;
69
}
70
71
/**
72
 * Ask a model over `POST /api/v1/responses`, in one turn with no tools.
73
 *
74
 * A review is a single question about artifacts already in the prompt, so the
75
 * reviewer gets no tool runtime and no second round. Handing it tools would
76
 * let it read the repository it is reviewing, which is exactly the contamination
77
 * autoimprove §3 puts the review in a separate conversation to avoid.
78
 *
79
 * A different model from the one that ran the cycle is preferred where the
80
 * finding is load-bearing (§6, "reviewer sharing the worker's blind spots"),
81
 * and `--reviewer-model` is how that is expressed. This lane does not enforce
82
 * it: refusing to review with the same model would stop a cheap cycle for a
83
 * reason the reviewer can state better than a flag can.
84
 */
85
export const responsesLane = (options: ResponsesLaneOptions): ReviewerLane => {
86
  const call = options.fetch ?? globalThis.fetch;
87
  return {
88
    ref: `responses:${options.model ?? "default"}@${options.origin}`,
89
    ask: async (prompt, signal) => {
90
      let response: Response;
91
      try {
92
        response = await call(new URL("/api/v1/responses", options.origin), {
93
          method: "POST",
94
          headers: {
95
            ...(options.token === undefined ? {} : { authorization: `Bearer ${options.token}` }),
96
            "content-type": "application/json",
97
            accept: "text/event-stream, application/json",
98
          },
99
          body: JSON.stringify({
100
            input: [{ role: "user", content: prompt }],
101
            stream: true,
102
            ...(options.model === undefined ? {} : { model: options.model }),
103
          }),
104
          ...(signal === undefined ? {} : { signal }),
105
        });
106
      } catch (cause) {
107
        throw new ReviewerUnavailable(
108
          `the responses API at ${options.origin} could not be reached: ${String(cause)}`,
109
        );
110
      }
111
112
      if (!response.ok) {
113
        throw new ReviewerUnavailable(
114
          `the responses API at ${options.origin} answered HTTP ${String(response.status)}.`,
115
        );
116
      }
117
      if (response.body === null) {
118
        throw new ReviewerUnavailable(
119
          `the responses API at ${options.origin} answered with no body.`,
120
        );
121
      }
122
123
      let text = "";
124
      let failure: string | undefined;
125
      for await (const data of frames(response.body, signal)) {
126
        const event = parse(data);
127
        if (event === undefined) continue;
128
        if (event["type"] === "response.output_text.delta") {
129
          const delta = event["delta"];
130
          if (typeof delta === "string") text += delta;
131
          continue;
132
        }
133
        if (event["type"] === "response.failed") failure = failureOf(event);
134
      }
135
136
      if (failure !== undefined) {
137
        throw new ReviewerUnavailable(`the reviewer's request failed: ${failure}`);
138
      }
139
      if (text.trim() === "") {
140
        throw new ReviewerUnavailable(
141
          `the reviewer returned no text. An empty answer is not a review, and it is not a zero.`,
142
        );
143
      }
144
      return text;
145
    },
146
  };
147
};
148
149
/** Each SSE frame's `data:` payload, in order. */
150
async function* frames(
151
  body: ReadableStream<Uint8Array>,
152
  signal: AbortSignal | undefined,
153
): AsyncIterable<string> {
154
  const decoder = new TextDecoder();
155
  const reader = body.getReader();
156
  let buffer = "";
157
  try {
158
    for (;;) {
159
      const { done, value } = await reader.read();
160
      if (done || signal?.aborted === true) break;
161
      buffer += decoder.decode(value, { stream: true });
162
      for (;;) {
163
        const boundary = buffer.indexOf("\n\n");
164
        if (boundary < 0) break;
165
        const frame = buffer.slice(0, boundary);
166
        buffer = buffer.slice(boundary + 2);
167
        for (const line of frame.split("\n")) {
168
          if (line.startsWith("data: ")) yield line.slice(6);
169
        }
170
      }
171
    }
172
  } finally {
173
    reader.releaseLock();
174
  }
175
}
176
177
const parse = (data: string): Record<string, unknown> | undefined => {
178
  try {
179
    const value: unknown = JSON.parse(data);
180
    return value !== null && typeof value === "object" && !Array.isArray(value)
181
      ? (value as Record<string, unknown>)
182
      : undefined;
183
  } catch {
184
    return undefined;
185
  }
186
};
187
188
const failureOf = (event: Record<string, unknown>): string => {
189
  const response = event["response"];
190
  if (response !== null && typeof response === "object") {
191
    const error = (response as Record<string, unknown>)["error"];
192
    if (error !== null && typeof error === "object") {
193
      const message = (error as Record<string, unknown>)["message"];
194
      if (typeof message === "string") return message;
195
    }
196
  }
197
  return "no reason was given";
198
};
packages/openagents-cli/src/coder-review-prompt.ts added +428

@@ -0,0 +1,428 @@

1
/**
2
 * The two documents a cycle review turns into: the prompt the reviewer reads,
3
 * and the review file that lands in `docs/coder/reviews/`.
4
 *
5
 * `docs/coder/runbook.md` §6 writes the prompt as a skeleton for a human to
6
 * fill in. Filling it in by hand is where the evidence rule goes soft — a
7
 * reviewer told "cite trajectory steps" with no way to know which steps exist
8
 * will cite plausible ones, and a hand-assembled prompt has no set to check
9
 * them against. So this renderer prints the citable refs, in full, next to the
10
 * evidence they name, and states what happens to a ref that is not on the
11
 * list: the review is refused, not repaired.
12
 *
13
 * The output contract is JSON because the adopt step is meant to become a diff
14
 * (autoimprove §7.5), and because a prose review cannot be checked. Everything
15
 * the reviewer writes is checked by {@link parseCycleReview} before any of it
16
 * reaches the file this module also renders, so the markdown is never a
17
 * transcription of what a model said — it is a rendering of what survived.
18
 */
19
20
import type {
21
  ReviewBenchRow,
22
  ReviewRequest,
23
  ReviewStep,
24
  ReviewTrial,
25
} from "./coder-review-assemble.js";
26
import type {
27
  CoderCandidate,
28
  CycleReview,
29
  EvidenceIndex,
30
  LedgerOperation,
31
  ReviewRejection,
32
} from "./coder-review-candidate.js";
33
import { LEVER_AXES } from "./coder-review-candidate.js";
34
35
/** How many citable refs are printed before the list says it stopped. */
36
const MAX_LISTED_REFS = 400;
37
38
/**
39
 * The reviewer prompt for one assembled cycle.
40
 *
41
 * Every section is derived from {@link ReviewRequest}; nothing here reads the
42
 * working session, the repository, or the environment. A prompt that could
43
 * reach past its request would make "the review saw only the artifacts"
44
 * (autoimprove §3) a claim rather than a property.
45
 */
46
export const renderReviewPrompt = (request: ReviewRequest, index: EvidenceIndex): string => {
47
  const lines: Array<string> = [];
48
49
  lines.push(
50
    "You are reviewing one autoimprovement cycle of `openagents coder`.",
51
    "",
52
    "The cycle ran one lever against one suite and left the artifacts below.",
53
    "Judge the cycle, not the reviewer's own comfort: a review that praises a",
54
    "wasteful run is itself a defect the next review has to catch. There is no",
55
    "audience to appease.",
56
    "",
57
    `Suite: ${request.suite}. Lane: ${request.lane}. Job: ${request.jobDir}${
58
      request.jobId === null ? "" : ` (${request.jobId})`
59
    }.`,
60
    "",
61
  );
62
63
  lines.push("<lever>", `ref: ${request.lever.ref}`, "");
64
  if (request.lever.paths.length > 0) {
65
    lines.push(`paths: ${request.lever.paths.join(", ")}`, "");
66
  }
67
  lines.push(
68
    request.lever.diff === "" ? "(no diff was supplied with this cycle)" : request.lever.diff,
69
  );
70
  if (request.lever.truncation.dropped_trailing_lines > 0) {
71
    lines.push(
72
      "",
73
      `[${String(request.lever.truncation.dropped_trailing_lines)} of ${String(
74
        request.lever.truncation.total_lines,
75
      )} diff lines dropped from the end]`,
76
    );
77
  }
78
  lines.push("</lever>", "");
79
80
  lines.push("<rows>");
81
  if (request.rows.length === 0) {
82
    lines.push("(no store rows were supplied; you cannot speak to a delta)");
83
  } else {
84
    for (const row of request.rows) lines.push(renderRow(row));
85
  }
86
  lines.push("</rows>", "");
87
88
  lines.push("<trials>");
89
  for (const trial of request.trials) lines.push(...renderTrial(trial));
90
  lines.push("</trials>", "");
91
92
  lines.push("<practices>", `source: ${request.practices.path}`, "", request.practices.text);
93
  lines.push("</practices>", "");
94
95
  lines.push(...renderEvidenceGrammar(index));
96
  lines.push(...renderOutputContract());
97
98
  return lines.join("\n");
99
};
100
101
const renderRow = (row: ReviewBenchRow): string =>
102
  `${row.suite}#${row.recordedAt}  ${JSON.stringify(row.row)}`;
103
104
const renderTrial = (trial: ReviewTrial): ReadonlyArray<string> => {
105
  const lines: Array<string> = [];
106
  lines.push(
107
    "",
108
    `<trial task="${trial.task}" outcome="${trial.outcome}">`,
109
    `model: ${trial.modelId ?? "unrecorded"}   agent: ${trial.agentVersion ?? "unrecorded"}`,
110
    `prompt tokens: ${figure(trial.promptTokens)}   completion tokens: ${figure(
111
      trial.completionTokens,
112
    )}   cached input tokens: ${String(trial.cachedInputTokens)}`,
113
    `tool calls: ${figure(trial.toolCalls)}   wall clock: ${wallClock(trial)}`,
114
  );
115
  if (trial.exception !== null) lines.push(`exception: ${trial.exception}`);
116
  lines.push(
117
    `instruction (${trial.instructionSource}): ${trial.instruction ?? "(none recorded)"}`,
118
    `steps: ${String(trial.truncation.kept_steps)} of ${String(trial.truncation.total_steps)}${
119
      trial.truncation.tail_only
120
        ? `, tail only — the first ${String(
121
            trial.truncation.dropped_leading_steps,
122
          )} steps are not in this prompt and may not be cited`
123
        : ""
124
    }`,
125
    "",
126
  );
127
  for (const step of trial.steps) lines.push(...renderStep(trial.task, step));
128
  lines.push("</trial>");
129
  return lines;
130
};
131
132
const renderStep = (task: string, step: ReviewStep): ReadonlyArray<string> => {
133
  const lines: Array<string> = [
134
    `[trial:${task}#step-${step.stepId}] ${step.source}${
135
      step.promptTokens === null && step.completionTokens === null
136
        ? ""
137
        : `  (prompt ${figure(step.promptTokens)}, completion ${figure(step.completionTokens)})`
138
    }`,
139
  ];
140
  if (step.text !== "") lines.push(step.text);
141
  for (const call of step.toolCalls) {
142
    lines.push(`  call ${call.name}: ${call.arguments}`);
143
    if (call.observation !== "") lines.push(`  → ${call.observation}`);
144
  }
145
  lines.push("");
146
  return lines;
147
};
148
149
const figure = (value: number | null): string => (value === null ? "unrecorded" : String(value));
150
151
/** An absent duration says so. `0.0s` would be a measurement, and it is not one. */
152
const wallClock = (trial: ReviewTrial): string =>
153
  trial.wallClockSeconds === null ? "unrecorded" : `${trial.wallClockSeconds.toFixed(1)}s`;
154
155
/**
156
 * The ref grammar, and the exact set of refs this review may cite.
157
 *
158
 * Printing the set is the difference between an instruction and a contract.
159
 * The reviewer is not being asked to remember which steps it read; it is being
160
 * handed the list its citations are checked against, so an unresolvable ref is
161
 * a choice rather than an accident.
162
 */
163
const renderEvidenceGrammar = (index: EvidenceIndex): ReadonlyArray<string> => {
164
  const lines: Array<string> = [
165
    "## Evidence refs",
166
    "",
167
    "Every claim you make carries refs from this grammar:",
168
    "",
169
    "- `trial:<task>#step-<id>` — one step of that trial's trajectory",
170
    "- `trial:<task>#outcome` — that trial's verifier decision",
171
    "- `row:<suite>#<recordedAt>` — one store row above",
172
    "- `ledger:<id>` — one entry of the practices file, such as `ledger:T1`",
173
    "- `diff:<path>` — one path the lever's diff touches",
174
    "",
175
    "A ref that is not in the list below does not resolve, and a review with an",
176
    "unresolvable ref is refused whole — not partially accepted, not repaired.",
177
    "The same is true of a proposal that cites no trajectory step: a store row",
178
    "or a ledger entry says what changed, never what the coder did.",
179
    "",
180
  ];
181
182
  const listed = [
183
    ...[...index.trajectorySteps].sort().map((ref) => `trial:${ref}`),
184
    ...[...index.trialOutcomes].sort().map((task) => `trial:${task}#outcome`),
185
    ...[...index.benchRows].sort().map((ref) => `row:${ref}`),
186
    ...[...index.ledgerEntries].sort().map((id) => `ledger:${id}`),
187
    ...[...index.diffPaths].sort().map((path) => `diff:${path}`),
188
  ];
189
190
  lines.push("Citable refs:", "");
191
  for (const ref of listed.slice(0, MAX_LISTED_REFS)) lines.push(`- ${ref}`);
192
  if (listed.length > MAX_LISTED_REFS) {
193
    lines.push(
194
      `- [${String(listed.length - MAX_LISTED_REFS)} further refs not listed; they follow the same grammar over the trials above]`,
195
    );
196
  }
197
  lines.push("");
198
  return lines;
199
};
200
201
/**
202
 * The JSON the reviewer returns.
203
 *
204
 * Spelled as an annotated shape rather than a schema document: a reviewer that
205
 * has to parse a JSON Schema before writing its answer spends its attention on
206
 * the wrong artifact, and every field here has a one-line reason attached to
207
 * it that a schema would strip.
208
 */
209
const renderOutputContract = (): ReadonlyArray<string> => [
210
  "## Your answer",
211
  "",
212
  "Return one JSON object and nothing else. No prose around it.",
213
  "",
214
  "```json",
215
  "{",
216
  '  "score": 0,',
217
  '  "points": [',
218
  '    { "point": "what was done well or badly", "delta": -1,',
219
  '      "evidence": ["trial:<task>#step-<id>"] }',
220
  "  ],",
221
  '  "causality": "did the lever cause the delta, or does a confounder explain it",',
222
  '  "violations": [',
223
  '    { "entry": "T1", "note": "how the cycle broke it",',
224
  '      "evidence": ["trial:<task>#step-<id>"] }',
225
  "  ],",
226
  '  "proposals": [',
227
  "    {",
228
  `      "lever": { "axis": "${LEVER_AXES.join('" | "')}", "summary": "one sentence" },`,
229
  '      "surfaces": [{ "surface": "system-prompt", "diff": "the change, as a diff or as the proposed text" }],',
230
  '      "transferLabel": { "modelFamily": "the family this was written against", "lane": "the lane" },',
231
  '      "evidence": ["trial:<task>#step-<id>"],',
232
  '      "risk": "what goes wrong if this is adopted",',
233
  '      "verification": { "suite": "tb2-quick", "metric": "successRate", "expectedDirection": "up" }',
234
  "    }",
235
  "  ],",
236
  '  "ledgerOperations": [',
237
  "    {",
238
  '      "op": "add" | "promote" | "demote" | "refute",',
239
  '      "entry": { "id": null, "section": "Tool habits", "title": "short title",',
240
  '                 "statement": "the falsifiable claim", "detection": "how a violation is detected",',
241
  '                 "status": "adopted" | "proposed" | "refuted" },',
242
  '      "provenance": ["trial:<task>#step-<id>"]',
243
  "    }",
244
  "  ]",
245
  "}",
246
  "```",
247
  "",
248
  "Rules the parser enforces, so that writing around them fails rather than passes:",
249
  "",
250
  "- `score` is 0–10, and the `points` deltas add up to it.",
251
  "- `surfaces[].surface` is one of the staged text artifacts —",
252
  "  `system-prompt`, `tool-descriptions`, `catalog-lines` — and the `diff` is",
253
  "  over that artifact. A lever that changes code, a plugin, or procedure",
254
  "  rather than staged text carries an empty `surfaces` array.",
255
  "- One to three proposals. A cycle with nothing to change says so as a",
256
  "  proposal whose lever is the practice it would keep, with the evidence for",
257
  "  keeping it — never as an empty list.",
258
  "- Every proposal cites at least one `trial:...#step-...` ref.",
259
  "- Every ledger operation carries provenance, and every entry states how a",
260
  "  violation is detected. An entry nothing can detect is an aspiration and",
261
  "  does not get `adopted`.",
262
  "- Unknown is written as unknown. A figure recorded as `unrecorded` above is",
263
  "  not zero, and a delta you cannot attribute is a confounder you name.",
264
];
265
266
/**
267
 * The review file for `docs/coder/reviews/`.
268
 *
269
 * Rendered from the parsed review, so every ref in it has already resolved and
270
 * every enum in it is already one of the accepted values. The header carries
271
 * what a later reader needs to decide whether the review still applies: the
272
 * job, the lane, the lever, the reviewer, and the redaction stamp.
273
 */
274
export const renderReviewMarkdown = (options: {
275
  readonly request: ReviewRequest;
276
  readonly review: CycleReview;
277
  readonly reviewerRef: string;
278
  readonly recordedAt: string;
279
  readonly title: string;
280
}): string => {
281
  const { request, review, reviewerRef, recordedAt, title } = options;
282
  const lines: Array<string> = [
283
    `# ${title}`,
284
    "",
285
    `**Score ${String(review.score)}/10.** Cycle on \`${request.suite}\`, lane \`${request.lane}\`,`,
286
    `job \`${request.jobDir}\`${request.jobId === null ? "" : ` (\`${request.jobId}\`)`},`,
287
    `lever \`${request.lever.ref}\`. Reviewed ${recordedAt} by \`${reviewerRef}\`.`,
288
    "",
289
    `Redaction: \`${request.redaction.serviceRef}\` on surface \`${request.redaction.surface}\`,`,
290
    `${String(request.redaction.total)} replacement${request.redaction.total === 1 ? "" : "s"}`,
291
    `${
292
      request.redaction.total === 0
293
        ? "(nothing in the artifacts matched the rule list)"
294
        : `(${Object.entries(request.redaction.counts)
295
            .map(([category, count]) => `${category}: ${String(count)}`)
296
            .join(", ")})`
297
    } applied before the artifacts left this process.`,
298
    "",
299
    "## What ran",
300
    "",
301
    "| Trial | Outcome | Steps | Tool calls | Prompt | Completion | Wall clock |",
302
    "| --- | --- | --- | --- | --- | --- | --- |",
303
  ];
304
305
  for (const trial of request.trials) {
306
    lines.push(
307
      `| \`${trial.task}\` | ${trial.outcome} | ${String(trial.truncation.total_steps)}${
308
        trial.truncation.tail_only ? ` (tail ${String(trial.truncation.kept_steps)} reviewed)` : ""
309
      } | ${figure(trial.toolCalls)} | ${figure(trial.promptTokens)} | ${figure(
310
        trial.completionTokens,
311
      )} | ${wallClock(trial)} |`,
312
    );
313
  }
314
315
  lines.push("", "## Score", "");
316
  for (const point of review.points) {
317
    lines.push(
318
      `- **${point.delta >= 0 ? "+" : ""}${String(point.delta)}** ${point.point}${refs(
319
        point.evidence.map((entry) => entry.ref),
320
      )}`,
321
    );
322
  }
323
324
  lines.push("", "## Causality", "", review.causality, "", "## Practice violations", "");
325
  if (review.violations.length === 0) {
326
    lines.push("None found.");
327
  } else {
328
    for (const violation of review.violations) {
329
      lines.push(
330
        `- \`${violation.entry}\` — ${violation.note}${refs(
331
          violation.evidence.map((entry) => entry.ref),
332
        )}`,
333
      );
334
    }
335
  }
336
337
  lines.push("", "## Proposals", "");
338
  review.proposals.forEach((proposal, position) => {
339
    lines.push(...renderProposal(proposal, position + 1));
340
  });
341
342
  lines.push("## Ledger operations", "");
343
  if (review.ledgerOperations.length === 0) {
344
    lines.push("None proposed.");
345
  } else {
346
    for (const operation of review.ledgerOperations)
347
      lines.push(...renderLedgerOperation(operation));
348
  }
349
350
  lines.push(
351
    "",
352
    "---",
353
    "",
354
    "Adopting a proposal is a separate act from recording this review. Each one",
355
    "enters the runbook at §3 as a lever with its measuring suite already named;",
356
    "a rejected proposal stays here with the one-line reason it was rejected.",
357
    "",
358
  );
359
360
  return lines.join("\n");
361
};
362
363
const renderProposal = (proposal: CoderCandidate, position: number): ReadonlyArray<string> => {
364
  const lines: Array<string> = [
365
    `### ${String(position)}. ${proposal.lever.summary}`,
366
    "",
367
    `- **Axis** \`${proposal.lever.axis}\``,
368
    `- **Candidate** \`${proposal.candidateId}\` (${proposal.lineage.origin}, produced by \`${proposal.lineage.producedBy}\`${
369
      proposal.lineage.parent === null ? "" : `, from \`${proposal.lineage.parent}\``
370
    })`,
371
    `- **Written against** ${
372
      proposal.transferLabel.modelFamily === ""
373
        ? "an unstated model family"
374
        : `\`${proposal.transferLabel.modelFamily}\``
375
    } on ${
376
      proposal.transferLabel.lane === ""
377
        ? "an unstated lane"
378
        : `lane \`${proposal.transferLabel.lane}\``
379
    }`,
380
    `- **Verification** \`${proposal.verification.suite}\`, \`${proposal.verification.metric}\` should go ${proposal.verification.expectedDirection}`,
381
    `- **Risk** ${proposal.risk}`,
382
    `- **Evidence**${refs(proposal.evidence.map((entry) => entry.ref))}`,
383
    "",
384
  ];
385
  for (const surface of proposal.surfaces) {
386
    lines.push(`\`${surface.surface}\`:`, "", "```diff", surface.diff, "```", "");
387
  }
388
  return lines;
389
};
390
391
const renderLedgerOperation = (operation: LedgerOperation): ReadonlyArray<string> => [
392
  `- **${operation.op}** ${
393
    operation.entry.id === null ? "(new entry)" : `\`${operation.entry.id}\``
394
  } — ${operation.entry.section} / ${operation.entry.title} → \`${operation.entry.status}\``,
395
  `  - Statement: ${operation.entry.statement}`,
396
  `  - Detection: ${operation.entry.detection}`,
397
  `  - Provenance:${refs(operation.provenance.map((entry) => entry.ref))}`,
398
];
399
400
const refs = (values: ReadonlyArray<string>): string =>
401
  values.length === 0 ? "" : ` (${values.map((value) => `\`${value}\``).join(", ")})`;
402
403
/**
404
 * A refusal, written so the next attempt has somewhere to start.
405
 *
406
 * The rejection is named, located, and explained, because the failure this
407
 * command exists to stop is a document that reads as a review and is not one.
408
 * "The review was rejected" without the ref that failed is the same defect one
409
 * level up.
410
 */
411
export const renderRejections = (
412
  rejections: ReadonlyArray<ReviewRejection>,
413
  reviewerRef: string,
414
): string => {
415
  const lines: Array<string> = [
416
    `The review from \`${reviewerRef}\` was refused. Nothing was written to the reviews directory.`,
417
    "",
418
  ];
419
  for (const rejection of rejections) {
420
    lines.push(`- ${rejection.reason} at ${rejection.path}: ${rejection.detail}`);
421
  }
422
  lines.push(
423
    "",
424
    "A refused review is not a failed cycle. Re-run the reviewer, or read the",
425
    "refusals as what they are: the reviewer wrote about artifacts it was not given.",
426
  );
427
  return lines.join("\n");
428
};
packages/openagents-cli/src/coder-review-run.ts added +199

@@ -0,0 +1,199 @@

1
/**
2
 * One cycle review, end to end: artifacts in, checked review out.
3
 *
4
 * The order is the whole design. Assemble the request from the job directory
5
 * and redact it; build the evidence index from that redacted request, so the
6
 * set a citation is checked against is exactly the set the reviewer read;
7
 * render the prompt; ask the lane; parse and check. A review that survives all
8
 * of that is written to `docs/coder/reviews/`. One that does not is not
9
 * written at all — a refused review leaves no file, because a file in that
10
 * directory is a record of a judgment, and a refusal is a record of a reviewer
11
 * writing about artifacts it was not given.
12
 *
13
 * Nothing in this module scores anything. The score comes from the reviewer or
14
 * the run fails; there is no fallback that produces a number. That is the
15
 * single most important property here, and it is enforced structurally: the
16
 * only paths out of {@link runCycleReview} are "the reviewer answered and the
17
 * answer checked out", "the reviewer answered and the answer was refused, by
18
 * name", and a thrown {@link ReviewerUnavailable}.
19
 */
20
21
import { mkdirSync, writeFileSync } from "node:fs";
22
import { dirname, join } from "node:path";
23
24
import type { ReviewBenchRow, ReviewRequest } from "./coder-review-assemble.js";
25
import {
26
  DEFAULT_MAX_DIFF_LINES,
27
  DEFAULT_MAX_STEPS,
28
  DEFAULT_MAX_STEP_CHARS,
29
} from "./coder-review-assemble.js";
30
import { assembleReviewRequest } from "./coder-review-assemble.js";
31
import type { EvidenceIndex, ParseCycleReviewResult } from "./coder-review-candidate.js";
32
import { parseCycleReview } from "./coder-review-candidate.js";
33
import type { ReviewerLane } from "./coder-review-lane.js";
34
import { renderReviewMarkdown, renderReviewPrompt } from "./coder-review-prompt.js";
35
36
/** The schema id the written review JSON carries. */
37
export const CODER_REVIEW_DOCUMENT_SCHEMA = "openagents.coder_review_document.v1";
38
39
export interface CycleReviewOptions {
40
  /** The Harbor job directory this cycle produced. */
41
  readonly jobDir: string;
42
  readonly suite: string;
43
  readonly lane: string;
44
  readonly lever: { readonly ref: string; readonly diff: string };
45
  readonly rows: ReadonlyArray<ReviewBenchRow>;
46
  readonly practices: { readonly path: string; readonly text: string };
47
  readonly reviewer: ReviewerLane;
48
  readonly maxSteps?: number | undefined;
49
  readonly maxStepChars?: number | undefined;
50
  readonly maxDiffLines?: number | undefined;
51
  readonly signal?: AbortSignal | undefined;
52
}
53
54
export interface CycleReviewOutcome {
55
  readonly request: ReviewRequest;
56
  readonly index: EvidenceIndex;
57
  readonly prompt: string;
58
  /** Exactly what the reviewer returned, before any parsing. */
59
  readonly raw: string;
60
  readonly reviewerRef: string;
61
  readonly producedBy: string;
62
  readonly result: ParseCycleReviewResult;
63
}
64
65
/**
66
 * Assemble, ask, and check. Writes nothing.
67
 *
68
 * Kept separate from the writing step so a caller can render the prompt, run
69
 * the reviewer, and inspect the refusals without a directory being touched —
70
 * and so the tests can prove the refusal path without staging a filesystem.
71
 */
72
export const runCycleReview = async (options: CycleReviewOptions): Promise<CycleReviewOutcome> => {
73
  const { request, index } = assembleReviewRequest(options.jobDir, {
74
    suite: options.suite,
75
    lane: options.lane,
76
    lever: options.lever,
77
    rows: options.rows,
78
    practices: options.practices,
79
    maxSteps: options.maxSteps ?? DEFAULT_MAX_STEPS,
80
    maxStepChars: options.maxStepChars ?? DEFAULT_MAX_STEP_CHARS,
81
    maxDiffLines: options.maxDiffLines ?? DEFAULT_MAX_DIFF_LINES,
82
  });
83
84
  const prompt = renderReviewPrompt(request, index);
85
  const raw = await options.reviewer.ask(prompt, options.signal);
86
  const producedBy = producedByRef(request, options.reviewer);
87
88
  return {
89
    request,
90
    index,
91
    prompt,
92
    raw,
93
    reviewerRef: options.reviewer.ref,
94
    producedBy,
95
    result: parseCycleReview(raw, index, producedBy),
96
  };
97
};
98
99
/**
100
 * The producer stamped into every candidate's lineage.
101
 *
102
 * It names the job and the lane together because a candidate outlives the
103
 * review that emitted it: once it is in a pool, "which run was this reflecting
104
 * on, and who wrote it" is the question that decides whether it still applies.
105
 */
106
export const producedByRef = (request: ReviewRequest, reviewer: ReviewerLane): string =>
107
  `coder-review:${request.jobDir}:${reviewer.ref}`;
108
109
export interface ReviewArtifacts {
110
  readonly markdownPath: string;
111
  readonly jsonPath: string;
112
  readonly markdown: string;
113
  readonly json: string;
114
}
115
116
/**
117
 * Write the accepted review as the pair the loop consumes.
118
 *
119
 * The markdown is for `docs/coder/reviews/` and for a human deciding what to
120
 * adopt. The JSON beside it is for the adopt step, which autoimprove §7.5 says
121
 * should become a diff rather than a reading exercise. They are written
122
 * together so the directory never holds a review whose proposals cannot be
123
 * read back mechanically.
124
 */
125
export const writeReviewArtifacts = (options: {
126
  readonly outcome: CycleReviewOutcome;
127
  readonly markdownPath: string;
128
  readonly title: string;
129
  readonly recordedAt: string;
130
}): ReviewArtifacts => {
131
  const { outcome, markdownPath, title, recordedAt } = options;
132
  if (!outcome.result.ok) {
133
    throw new Error(
134
      "a refused review is not written. Render the rejections instead; renderRejections says which ref failed and why.",
135
    );
136
  }
137
138
  const markdown = renderReviewMarkdown({
139
    request: outcome.request,
140
    review: outcome.result.review,
141
    reviewerRef: outcome.reviewerRef,
142
    recordedAt,
143
    title,
144
  });
145
  const jsonPath = markdownPath.replace(/\.md$/u, ".json");
146
  const json = `${JSON.stringify(
147
    {
148
      schema: CODER_REVIEW_DOCUMENT_SCHEMA,
149
      recordedAt,
150
      reviewer: outcome.reviewerRef,
151
      producedBy: outcome.producedBy,
152
      request: {
153
        schema: outcome.request.schema,
154
        jobDir: outcome.request.jobDir,
155
        jobId: outcome.request.jobId,
156
        suite: outcome.request.suite,
157
        lane: outcome.request.lane,
158
        lever: { ref: outcome.request.lever.ref, paths: outcome.request.lever.paths },
159
        trials: outcome.request.trials.map((trial) => ({
160
          task: trial.task,
161
          outcome: trial.outcome,
162
          modelId: trial.modelId,
163
          agentVersion: trial.agentVersion,
164
          promptTokens: trial.promptTokens,
165
          completionTokens: trial.completionTokens,
166
          cachedInputTokens: trial.cachedInputTokens,
167
          toolCalls: trial.toolCalls,
168
          wallClockSeconds: trial.wallClockSeconds,
169
          truncation: trial.truncation,
170
        })),
171
        rows: outcome.request.rows.map((row) => `${row.suite}#${row.recordedAt}`),
172
        practices: outcome.request.practices.path,
173
        redaction: outcome.request.redaction,
174
      },
175
      review: outcome.result.review,
176
    },
177
    null,
178
    2,
179
  )}\n`;
180
181
  mkdirSync(dirname(markdownPath), { recursive: true });
182
  writeFileSync(markdownPath, markdown, "utf8");
183
  writeFileSync(jsonPath, json, "utf8");
184
185
  return { markdownPath, jsonPath, markdown, json };
186
};
187
188
/** `docs/coder/reviews/YYYY-MM-DD-<lever-slug>.md`, as the runbook §6 names it. */
189
export const reviewPathFor = (reviewsDir: string, recordedAt: string, leverSlug: string): string =>
190
  join(reviewsDir, `${recordedAt.slice(0, 10)}-${slugOf(leverSlug)}.md`);
191
192
/** A filename-safe slug. An empty or all-punctuation lever name becomes `cycle`. */
193
export const slugOf = (value: string): string => {
194
  const slug = value
195
    .toLowerCase()
196
    .replaceAll(/[^a-z0-9]+/gu, "-")
197
    .replaceAll(/^-+|-+$/gu, "");
198
  return slug === "" ? "cycle" : slug.slice(0, 60);
199
};
packages/openagents-cli/src/memory/engram.ts modified +6 -2

@@ -206,7 +206,11 @@ export const redactEngramContent = (

206 206
  }
207 207
208 208
  const categories = Object.keys(counts);
209
  const hard = new Set(ENGRAM_HARD_UNSAFE_CATEGORIES as unknown as ReadonlyArray<string>);
209
  // `new Set(...)` on an `as const` tuple infers a set of the literal union,
210
  // whose `has` then refuses the `string` keys it is being asked about. Widening
211
  // the element type at construction says the same thing without a cast, which
212
  // AFS authority code may not use to recover type safety.
213
  const hard = new Set<string>(ENGRAM_HARD_UNSAFE_CATEGORIES);
210 214
  const storable = categories.every((category) => !hard.has(category));
211 215
  const total = Object.values(counts).reduce((a, b) => a + b, 0);
212 216

@@ -365,7 +369,7 @@ export const verifyEngramEventId = (event: EngramEvent): boolean =>

365 369
 */
366 370
export const verifySupersessionChain = (events: ReadonlyArray<EngramEvent>): boolean => {
367 371
  for (let i = 0; i < events.length; i += 1) {
368
    const event = events[i];
372
    const event = events[i]!;
369 373
    if (!verifyEngramEventId(event)) {
370 374
      return false;
371 375
    }
packages/openagents-cli/test/coder-review-candidate.test.ts added +266

@@ -0,0 +1,266 @@

1
/**
2
 * The candidate schema's checks, at the level they are enforced: parse time.
3
 *
4
 * Every case here is a way a reviewer can produce a document that reads like a
5
 * review and is not one. The parser's job is to name which way it was, so
6
 * these tests assert the reason and the path, not merely that something was
7
 * refused. A refusal with the wrong name is the same defect as no refusal.
8
 */
9
10
import { describe, expect, it } from "vitest";
11
12
import {
13
  CODER_CANDIDATE_SCHEMA,
14
  candidateIdOf,
15
  extractJsonObject,
16
  MAX_PROPOSALS,
17
  parseCycleReview,
18
  resolveEvidenceRef,
19
  type EvidenceIndex,
20
  type RejectionReason,
21
} from "../src/coder-review-candidate.js";
22
23
const index: EvidenceIndex = {
24
  trajectorySteps: new Set(["regex-log#step-1", "regex-log#step-2"]),
25
  trialOutcomes: new Set(["regex-log"]),
26
  benchRows: new Set(["tb2-quick#2026-08-26T09:10:00.000Z"]),
27
  ledgerEntries: new Set(["T1"]),
28
  diffPaths: new Set(["packages/openagents-cli/src/coder-tools.ts"]),
29
};
30
31
const proposal = (evidence: ReadonlyArray<string>): Record<string, unknown> => ({
32
  lever: { axis: "harness", summary: "A change." },
33
  surfaces: [],
34
  transferLabel: { modelFamily: "fixture-model", lane: "proxy" },
35
  evidence,
36
  risk: "Something could go wrong.",
37
  verification: { suite: "tb2-quick", metric: "successRate", expectedDirection: "up" },
38
});
39
40
const review = (overrides: Record<string, unknown> = {}): string =>
41
  JSON.stringify({
42
    score: 5,
43
    points: [{ point: "A point.", delta: 5, evidence: ["trial:regex-log#step-1"] }],
44
    causality: "The lever caused it.",
45
    violations: [],
46
    proposals: [proposal(["trial:regex-log#step-1"])],
47
    ledgerOperations: [],
48
    ...overrides,
49
  });
50
51
const reasons = (raw: string): ReadonlyArray<RejectionReason> => {
52
  const result = parseCycleReview(raw, index, "test");
53
  if (result.ok) throw new Error("expected a refusal");
54
  return result.rejections.map((rejection) => rejection.reason);
55
};
56
57
describe("evidence refs", () => {
58
  it("resolves each of the five schemes against the request", () => {
59
    expect(resolveEvidenceRef(index, "trial:regex-log#step-2")).toEqual({
60
      ok: true,
61
      kind: "trajectory_step",
62
    });
63
    expect(resolveEvidenceRef(index, "trial:regex-log#outcome")).toEqual({
64
      ok: true,
65
      kind: "trial_outcome",
66
    });
67
    expect(resolveEvidenceRef(index, "row:tb2-quick#2026-08-26T09:10:00.000Z")).toEqual({
68
      ok: true,
69
      kind: "bench_row",
70
    });
71
    expect(resolveEvidenceRef(index, "ledger:T1")).toEqual({ ok: true, kind: "ledger_entry" });
72
    expect(
73
      resolveEvidenceRef(index, "diff:packages/openagents-cli/src/coder-tools.ts"),
74
    ).toMatchObject({ ok: true, kind: "diff_path" });
75
  });
76
77
  it("separates a malformed ref, an unknown scheme, and one that names nothing", () => {
78
    expect(resolveEvidenceRef(index, "regex-log")).toMatchObject({
79
      ok: false,
80
      reason: "evidence_ref_malformed",
81
    });
82
    expect(resolveEvidenceRef(index, "transcript:regex-log")).toMatchObject({
83
      ok: false,
84
      reason: "evidence_ref_unknown_scheme",
85
    });
86
    expect(resolveEvidenceRef(index, "trial:regex-log#step-99")).toMatchObject({
87
      ok: false,
88
      reason: "evidence_ref_unresolved",
89
    });
90
  });
91
92
  it("says which step and which trial when a step was never in the request", () => {
93
    const result = resolveEvidenceRef(index, "trial:regex-log#step-99");
94
    if (result.ok) throw new Error("expected a refusal");
95
    expect(result.detail).toContain("step-99");
96
    expect(result.detail).toContain("regex-log");
97
  });
98
99
  it("refuses a ref against an empty index rather than accepting it unchecked", () => {
100
    const nothing: EvidenceIndex = {
101
      trajectorySteps: new Set(),
102
      trialOutcomes: new Set(),
103
      benchRows: new Set(),
104
      ledgerEntries: new Set(),
105
      diffPaths: new Set(),
106
    };
107
    expect(resolveEvidenceRef(nothing, "ledger:T1")).toMatchObject({
108
      ok: false,
109
      reason: "evidence_ref_unresolved",
110
    });
111
  });
112
});
113
114
describe("parseCycleReview", () => {
115
  it("accepts a review whose citations all resolve", () => {
116
    const result = parseCycleReview(review(), index, "coder-review:job:replay:fixture");
117
    if (!result.ok) throw new Error(JSON.stringify(result.rejections));
118
    expect(result.review.score).toBe(5);
119
    expect(result.review.proposals).toHaveLength(1);
120
    expect(result.review.proposals[0]!.schema).toBe(CODER_CANDIDATE_SCHEMA);
121
    expect(result.review.proposals[0]!.lineage.producedBy).toBe("coder-review:job:replay:fixture");
122
    expect(result.review.proposals[0]!.lineage.origin).toBe("review");
123
  });
124
125
  it("refuses a proposal that cites a step the reviewer was never given", () => {
126
    expect(reasons(review({ proposals: [proposal(["trial:regex-log#step-42"])] }))).toContain(
127
      "evidence_ref_unresolved",
128
    );
129
  });
130
131
  it("refuses a proposal whose only citation is a row or a ledger entry", () => {
132
    expect(reasons(review({ proposals: [proposal(["ledger:T1"])] }))).toContain(
133
      "proposal_without_trajectory_evidence",
134
    );
135
    expect(
136
      reasons(review({ proposals: [proposal(["row:tb2-quick#2026-08-26T09:10:00.000Z"])] })),
137
    ).toContain("proposal_without_trajectory_evidence");
138
  });
139
140
  it("refuses a proposal that cites nothing at all", () => {
141
    expect(reasons(review({ proposals: [proposal([])] }))).toContain("proposal_without_evidence");
142
  });
143
144
  it("refuses an empty proposal list rather than reading it as approval", () => {
145
    expect(reasons(review({ proposals: [] }))).toContain("no_proposals");
146
  });
147
148
  it("refuses more proposals than one review may carry", () => {
149
    const many = Array.from({ length: MAX_PROPOSALS + 1 }, () =>
150
      proposal(["trial:regex-log#step-1"]),
151
    );
152
    expect(reasons(review({ proposals: many }))).toContain("too_many_proposals");
153
  });
154
155
  it("names an unknown axis, direction, ledger op, and status", () => {
156
    expect(
157
      reasons(
158
        review({
159
          proposals: [
160
            { ...proposal(["trial:regex-log#step-1"]), lever: { axis: "vibes", summary: "x" } },
161
          ],
162
        }),
163
      ),
164
    ).toContain("unknown_lever_axis");
165
    expect(
166
      reasons(
167
        review({
168
          proposals: [
169
            {
170
              ...proposal(["trial:regex-log#step-1"]),
171
              verification: {
172
                suite: "tb2-quick",
173
                metric: "successRate",
174
                expectedDirection: "sideways",
175
              },
176
            },
177
          ],
178
        }),
179
      ),
180
    ).toContain("unknown_delta_direction");
181
    expect(
182
      reasons(
183
        review({
184
          ledgerOperations: [
185
            {
186
              op: "bless",
187
              entry: {
188
                id: null,
189
                section: "Tool habits",
190
                title: "t",
191
                statement: "s",
192
                detection: "d",
193
                status: "believed",
194
              },
195
              provenance: ["trial:regex-log#step-1"],
196
            },
197
          ],
198
        }),
199
      ),
200
    ).toEqual(expect.arrayContaining(["unknown_ledger_op", "unknown_ledger_status"]));
201
  });
202
203
  it("refuses a ledger operation with no provenance", () => {
204
    expect(
205
      reasons(
206
        review({
207
          ledgerOperations: [
208
            {
209
              op: "add",
210
              entry: {
211
                id: null,
212
                section: "Tool habits",
213
                title: "t",
214
                statement: "s",
215
                detection: "d",
216
                status: "proposed",
217
              },
218
              provenance: [],
219
            },
220
          ],
221
        }),
222
      ),
223
    ).toContain("proposal_without_evidence");
224
  });
225
226
  it("refuses a score outside the range, and output that is not JSON at all", () => {
227
    expect(reasons(review({ score: 11 }))).toContain("score_out_of_range");
228
    expect(reasons("I would rather not.")).toEqual(["not_json"]);
229
    expect(reasons("[1, 2, 3]")).toEqual(["not_an_object"]);
230
  });
231
});
232
233
describe("extractJsonObject", () => {
234
  it("takes the object out of a fenced block or a sentence around it", () => {
235
    expect(extractJsonObject('```json\n{"a":1}\n```')).toBe('{"a":1}');
236
    expect(extractJsonObject('Here it is: {"a":1} — hope that helps.')).toBe('{"a":1}');
237
    expect(extractJsonObject('{"a":1}')).toBe('{"a":1}');
238
  });
239
});
240
241
describe("candidateIdOf", () => {
242
  it("is stable across evidence order and changes with the lever", () => {
243
    const base = {
244
      schema: CODER_CANDIDATE_SCHEMA,
245
      lever: { axis: "harness", summary: "A change." },
246
      surfaces: [],
247
      lineage: { origin: "review", parent: null, producedBy: "a" },
248
      transferLabel: { modelFamily: "m", lane: "proxy" },
249
      evidence: [{ ref: "trial:regex-log#step-1", note: "" }],
250
      risk: "r",
251
      verification: { suite: "tb2-quick", metric: "successRate", expectedDirection: "up" },
252
    } as const;
253
254
    const reordered = {
255
      ...base,
256
      // A different producer and a different note are not different candidates.
257
      lineage: { origin: "review", parent: null, producedBy: "b" },
258
      evidence: [{ ref: "trial:regex-log#step-1", note: "a note" }],
259
    } as const;
260
261
    expect(candidateIdOf(base)).toBe(candidateIdOf(reordered));
262
    expect(candidateIdOf({ ...base, lever: { axis: "process", summary: "A change." } })).not.toBe(
263
      candidateIdOf(base),
264
    );
265
  });
266
});
packages/openagents-cli/test/coder-review.test.ts added +411

@@ -0,0 +1,411 @@

1
/**
2
 * The cycle review end to end, against a staged Harbor job.
3
 *
4
 * The fixture job is committed rather than borrowed from `/tmp`: a real job
5
 * directory is where this command is meant to run, and it is also the one thing
6
 * a test cannot depend on, because `/tmp` does not survive. The fixture carries
7
 * the shapes that matter — an accepted trial and a rejected one, tool calls with
8
 * their observations, and a step whose output is full of token-shaped strings.
9
 *
10
 * The last of those is the point of the redaction test. `openagents trace
11
 * redact` once reported "Nothing matched the redaction rules" over a file of
12
 * live tokens, because a second hand-written rule list had forgotten two
13
 * prefixes. Everything assembled here leaves this process for another
14
 * conversation, so the test asserts the absence of the literal strings in the
15
 * prompt and in the written review, not the presence of a redaction count.
16
 */
17
18
import { execFileSync } from "node:child_process";
19
import { existsSync, mkdtempSync, readFileSync, readdirSync } from "node:fs";
20
import { tmpdir } from "node:os";
21
import { join } from "node:path";
22
import { fileURLToPath } from "node:url";
23
import { describe, expect, it } from "vitest";
24
25
import {
26
  assembleReviewRequest,
27
  DEFAULT_MAX_DIFF_LINES,
28
  DEFAULT_MAX_STEP_CHARS,
29
  DEFAULT_MAX_STEPS,
30
  NotAHarborJob,
31
  diffPathsOf,
32
  ledgerEntryIds,
33
  readBenchRows,
34
} from "../src/coder-review-assemble.js";
35
import { replayLane, ReviewerUnavailable } from "../src/coder-review-lane.js";
36
import { renderReviewPrompt } from "../src/coder-review-prompt.js";
37
import {
38
  reviewPathFor,
39
  runCycleReview,
40
  slugOf,
41
  writeReviewArtifacts,
42
} from "../src/coder-review-run.js";
43
44
const fixtures = fileURLToPath(new URL("./fixtures/coder-review/", import.meta.url));
45
const repoRoot = fileURLToPath(new URL("../../../", import.meta.url));
46
const cliPath = "packages/openagents-cli/src/coder-review-cli.ts";
47
const jobDir = join(fixtures, "job");
48
49
/**
50
 * The exact strings the fixture's trajectory leaks.
51
 *
52
 * Written as fragments joined at runtime so this file does not itself carry a
53
 * token-shaped literal that a scanner would have to be told to ignore. The
54
 * fixture holds the whole strings, which is where they belong: it is the
55
 * artifact under test.
56
 */
57
const LEAKED = [
58
  ["oa", "pat", "9fQ2xLm4Rt7Vb1Zk"].join("_"),
59
  ["smct", "7hJ3kP0qWz"].join("_"),
60
  ["oa", "agent", "4dTgH8nMxQ"].join("_"),
61
  "sk-live-3f9aQz7bV1xR",
62
];
63
64
const inputs = () => ({
65
  suite: "tb2-quick",
66
  lane: "proxy",
67
  lever: { ref: "HEAD~1", diff: readFileSync(join(fixtures, "lever.diff"), "utf8") },
68
  rows: readBenchRows(join(fixtures, "rows.jsonl"), "tb2-quick", 4),
69
  practices: {
70
    path: "docs/coder/best-practices.md",
71
    text: readFileSync(join(fixtures, "practices.md"), "utf8"),
72
  },
73
  maxSteps: DEFAULT_MAX_STEPS,
74
  maxStepChars: DEFAULT_MAX_STEP_CHARS,
75
  maxDiffLines: DEFAULT_MAX_DIFF_LINES,
76
});
77
78
const recorded = (name: string): string => readFileSync(join(fixtures, name), "utf8");
79
80
describe("assembling a review request", () => {
81
  it("reads both trials, their outcomes, and the figures behind them", () => {
82
    const { request } = assembleReviewRequest(jobDir, inputs());
83
84
    expect(request.jobId).toBe("f1x7u12e-0000-4000-8000-000000000001");
85
    expect(request.trials.map((trial) => trial.task)).toEqual(["pin-a-version", "regex-log"]);
86
    expect(request.trials.map((trial) => trial.outcome)).toEqual(["rejected", "accepted"]);
87
88
    const accepted = request.trials.find((trial) => trial.task === "regex-log")!;
89
    expect(accepted.modelId).toBe("fixture-model");
90
    expect(accepted.agentVersion).toBe("0.4.0");
91
    expect(accepted.promptTokens).toBe(15_300);
92
    expect(accepted.toolCalls).toBe(3);
93
    expect(accepted.wallClockSeconds).toBeCloseTo(130, 0);
94
    expect(accepted.threadId).toBe("11111111-2222-4333-8444-555555555555");
95
    expect(accepted.instructionSource).toBe("trajectory_first_user_step");
96
    expect(accepted.truncation).toMatchObject({ tail_only: false, kept_steps: 5, total_steps: 5 });
97
  });
98
99
  it("keeps each tool call next to its own observation", () => {
100
    const { request } = assembleReviewRequest(jobDir, inputs());
101
    const batched = request.trials
102
      .find((trial) => trial.task === "pin-a-version")!
103
      .steps.find((step) => step.toolCalls.length === 2)!;
104
105
    expect(batched.toolCalls.map((call) => call.name)).toEqual(["shell", "shell"]);
106
    expect(batched.toolCalls[0]!.arguments).toContain("pip freeze");
107
    expect(batched.toolCalls[0]!.observation).toContain("requests==2.32.3");
108
    expect(batched.toolCalls[1]!.arguments).toContain("cat requirements.txt");
109
    expect(batched.toolCalls[1]!.observation).not.toContain("requests==2.32.3");
110
  });
111
112
  it("keeps the tail and says how much it dropped", () => {
113
    const { request, index } = assembleReviewRequest(jobDir, { ...inputs(), maxSteps: 2 });
114
    const accepted = request.trials.find((trial) => trial.task === "regex-log")!;
115
116
    expect(accepted.truncation).toMatchObject({
117
      tail_only: true,
118
      dropped_leading_steps: 3,
119
      kept_steps: 2,
120
      total_steps: 5,
121
    });
122
    expect(accepted.steps.map((step) => step.stepId)).toEqual(["4", "5"]);
123
    // A dropped step is not citable. That is what makes the truncation notice
124
    // load-bearing rather than decorative.
125
    expect(index.trajectorySteps.has("regex-log#step-1")).toBe(false);
126
    expect(index.trajectorySteps.has("regex-log#step-5")).toBe(true);
127
  });
128
129
  it("builds the citable set from the request it actually assembled", () => {
130
    const { index } = assembleReviewRequest(jobDir, inputs());
131
132
    expect([...index.trialOutcomes].sort()).toEqual(["pin-a-version", "regex-log"]);
133
    expect(index.benchRows).toContain("tb2-quick#2026-08-26T09:10:00.000Z");
134
    expect([...index.ledgerEntries].sort()).toEqual(["M1", "T1"]);
135
    expect([...index.diffPaths]).toEqual(["packages/openagents-cli/src/coder-tools.ts"]);
136
  });
137
138
  it("refuses a directory that is not a Harbor job instead of reviewing nothing", () => {
139
    expect(() =>
140
      assembleReviewRequest(mkdtempSync(join(tmpdir(), "not-a-job-")), inputs()),
141
    ).toThrow(NotAHarborJob);
142
  });
143
144
  it("refuses a trial directory, which carries a result.json and no trials", () => {
145
    // The near-miss that would otherwise assemble quietly: a job of zero trials
146
    // still gets scored, and the score would be about nothing.
147
    expect(() =>
148
      assembleReviewRequest(join(fixtures, "job", "regex-log__F1xTur3"), inputs()),
149
    ).toThrow(/holds no trial directories/u);
150
  });
151
});
152
153
describe("redaction", () => {
154
  it("keeps every token-shaped string in the fixture out of the assembled request", () => {
155
    const { request, index } = assembleReviewRequest(jobDir, inputs());
156
    const serialized = JSON.stringify(request);
157
    const prompt = renderReviewPrompt(request, index);
158
159
    for (const secret of LEAKED) {
160
      expect(serialized).not.toContain(secret);
161
      expect(prompt).not.toContain(secret);
162
    }
163
    expect(request.redaction.appliedBeforeExternalInference).toBe(true);
164
    expect(request.redaction.total).toBeGreaterThan(0);
165
  });
166
167
  it("keeps the operator's home path out of it too, and says how many it replaced", () => {
168
    const { request } = assembleReviewRequest(jobDir, inputs());
169
    expect(JSON.stringify(request)).not.toContain("/Users/fixture-operator");
170
    expect(Object.keys(request.redaction.counts).length).toBeGreaterThan(0);
171
  });
172
});
173
174
describe("the reviewer prompt", () => {
175
  it("prints the refs a citation is checked against", () => {
176
    const { request, index } = assembleReviewRequest(jobDir, inputs());
177
    const prompt = renderReviewPrompt(request, index);
178
179
    expect(prompt).toContain("- trial:regex-log#step-3");
180
    expect(prompt).toContain("- trial:pin-a-version#outcome");
181
    expect(prompt).toContain("- ledger:T1");
182
    expect(prompt).toContain("- diff:packages/openagents-cli/src/coder-tools.ts");
183
    expect(prompt).toContain("- row:tb2-quick#2026-08-26T09:10:00.000Z");
184
  });
185
186
  it("carries the trials, the lever, the rows, and the ledger", () => {
187
    const { request, index } = assembleReviewRequest(jobDir, inputs());
188
    const prompt = renderReviewPrompt(request, index);
189
190
    expect(prompt).toContain("Pinned requests and click");
191
    expect(prompt).toContain("Batch independent commands into one");
192
    expect(prompt).toContain("T1. Batch independent commands into one tool call");
193
    expect(prompt).toContain("call shell:");
194
  });
195
196
  it("says when a trial was truncated, in the prompt the reviewer reads", () => {
197
    const { request, index } = assembleReviewRequest(jobDir, { ...inputs(), maxSteps: 2 });
198
    expect(renderReviewPrompt(request, index)).toContain("tail only");
199
  });
200
});
201
202
describe("the replay lane", () => {
203
  it("returns the recording without consulting the prompt", async () => {
204
    let asked: unknown;
205
    const lane = replayLane("fixture", () => "recorded");
206
    asked = await lane.ask("a prompt the lane must not read");
207
    expect(asked).toBe("recorded");
208
    expect(lane.ref).toBe("replay:fixture");
209
  });
210
211
  it("fails rather than filling in a review when the recording is empty", () => {
212
    expect(() => replayLane("fixture", () => "  ").ask("prompt")).toThrow(ReviewerUnavailable);
213
  });
214
});
215
216
describe("running one cycle review", () => {
217
  it("accepts a review whose citations resolve and writes both artifacts", async () => {
218
    const outcome = await runCycleReview({
219
      jobDir,
220
      ...inputs(),
221
      reviewer: replayLane("accepted", () => recorded("reviewer-accepted.txt")),
222
    });
223
224
    if (!outcome.result.ok) throw new Error(JSON.stringify(outcome.result.rejections, null, 2));
225
    expect(outcome.result.review.score).toBe(6);
226
    expect(outcome.result.review.proposals).toHaveLength(2);
227
    expect(outcome.result.review.violations[0]!.entry).toBe("T1");
228
    expect(outcome.producedBy).toBe("coder-review:job:replay:accepted");
229
230
    const dir = mkdtempSync(join(tmpdir(), "coder-review-"));
231
    const artifacts = writeReviewArtifacts({
232
      outcome,
233
      markdownPath: reviewPathFor(dir, "2026-08-26T10:00:00.000Z", "tool-description-batching"),
234
      title: "Cycle review: tool-description-batching on tb2-quick",
235
      recordedAt: "2026-08-26T10:00:00.000Z",
236
    });
237
238
    expect(artifacts.markdownPath.endsWith("2026-08-26-tool-description-batching.md")).toBe(true);
239
    expect(existsSync(artifacts.jsonPath)).toBe(true);
240
241
    const markdown = readFileSync(artifacts.markdownPath, "utf8");
242
    expect(markdown).toContain("**Score 6/10.**");
243
    expect(markdown).toContain("replay:accepted");
244
    expect(markdown).toContain("`trial:regex-log#step-3`");
245
    expect(markdown).toContain("| `regex-log` | accepted |");
246
    for (const secret of LEAKED) expect(markdown).not.toContain(secret);
247
248
    const document = JSON.parse(readFileSync(artifacts.jsonPath, "utf8")) as {
249
      readonly review: { readonly proposals: ReadonlyArray<{ readonly candidateId: string }> };
250
      readonly request: { readonly redaction: { readonly total: number } };
251
    };
252
    expect(document.review.proposals[0]!.candidateId.startsWith("candidate:")).toBe(true);
253
    expect(document.request.redaction.total).toBeGreaterThan(0);
254
  });
255
256
  it("refuses a review that invented a step, by name, and writes nothing", async () => {
257
    const outcome = await runCycleReview({
258
      jobDir,
259
      ...inputs(),
260
      reviewer: replayLane("invented", () => recorded("reviewer-invented-evidence.txt")),
261
    });
262
263
    if (outcome.result.ok) throw new Error("a review citing step-99 was accepted");
264
    const named = outcome.result.rejections.map((rejection) => rejection.reason);
265
    expect(named).toContain("evidence_ref_unresolved");
266
    expect(named).toContain("proposal_without_trajectory_evidence");
267
268
    const unresolved = outcome.result.rejections.find(
269
      (rejection) => rejection.reason === "evidence_ref_unresolved",
270
    )!;
271
    expect(unresolved.path).toBe("$.proposals[0].evidence[1].ref");
272
    expect(unresolved.detail).toContain("step-99");
273
274
    expect(() =>
275
      writeReviewArtifacts({
276
        outcome,
277
        markdownPath: join(mkdtempSync(join(tmpdir(), "coder-review-")), "x.md"),
278
        title: "t",
279
        recordedAt: "2026-08-26T10:00:00.000Z",
280
      }),
281
    ).toThrow(/refused review is not written/u);
282
  });
283
});
284
285
describe("small readers", () => {
286
  it("finds ledger ids and diff paths the way the refs spell them", () => {
287
    expect(ledgerEntryIds(readFileSync(join(fixtures, "practices.md"), "utf8"))).toEqual([
288
      "T1",
289
      "M1",
290
    ]);
291
    expect(diffPathsOf(readFileSync(join(fixtures, "lever.diff"), "utf8"))).toEqual([
292
      "packages/openagents-cli/src/coder-tools.ts",
293
    ]);
294
  });
295
296
  it("slugs a lever name into a filename, and never into an empty one", () => {
297
    expect(slugOf("Tool description: batching!")).toBe("tool-description-batching");
298
    expect(slugOf("///")).toBe("cycle");
299
  });
300
});
301
302
/**
303
 * The command as `pnpm run coder:review` invokes it, separator and all.
304
 *
305
 * Driven as a process because that is the surface: `check:fast` runs the CLI
306
 * invocability guard for exactly the failure where a command is tested through
307
 * its module and never once reachable the way it ships.
308
 */
309
describe("the command", () => {
310
  const run = (args: ReadonlyArray<string>): { code: number; stdout: string; stderr: string } => {
311
    try {
312
      const stdout = execFileSync(process.execPath, ["--import", "tsx", cliPath, ...args], {
313
        cwd: repoRoot,
314
        encoding: "utf8",
315
        stdio: ["ignore", "pipe", "pipe"],
316
      });
317
      return { code: 0, stdout, stderr: "" };
318
    } catch (error) {
319
      const failure = error as { status?: number; stdout?: string; stderr?: string };
320
      return {
321
        code: failure.status ?? -1,
322
        stdout: failure.stdout ?? "",
323
        stderr: failure.stderr ?? "",
324
      };
325
    }
326
  };
327
328
  const common = (reviewsDir: string): ReadonlyArray<string> => [
329
    // The separator pnpm forwards. A command that dies on it is documented and
330
    // uninvocable at the same time.
331
    "--",
332
    jobDir,
333
    "--suite",
334
    "tb2-quick",
335
    "--lane",
336
    "proxy",
337
    "--diff",
338
    join(fixtures, "lever.diff"),
339
    "--store",
340
    join(fixtures, "rows.jsonl"),
341
    "--practices",
342
    join(fixtures, "practices.md"),
343
    "--reviews-dir",
344
    reviewsDir,
345
    "--slug",
346
    "tool-description-batching",
347
  ];
348
349
  it("writes the review and the machine-readable document, and exits zero", () => {
350
    const reviewsDir = join(mkdtempSync(join(tmpdir(), "coder-review-cli-")), "reviews");
351
    const result = run([
352
      ...common(reviewsDir),
353
      "--offline",
354
      join(fixtures, "reviewer-accepted.txt"),
355
    ]);
356
357
    expect(result.stderr).toBe("");
358
    expect(result.code).toBe(0);
359
    expect(result.stdout).toContain("Score 6/10 from replay:");
360
    expect(result.stdout).toContain("2 proposal(s)");
361
362
    const written = readdirSync(reviewsDir).sort();
363
    expect(written).toHaveLength(2);
364
    expect(written[0]!.endsWith("-tool-description-batching.json")).toBe(true);
365
    expect(written[1]!.endsWith("-tool-description-batching.md")).toBe(true);
366
367
    const markdown = readFileSync(join(reviewsDir, written[1]!), "utf8");
368
    expect(markdown).toContain("**Score 6/10.**");
369
    for (const secret of LEAKED) expect(markdown).not.toContain(secret);
370
  });
371
372
  it("exits one on a refused review, names the reason, and leaves the directory empty", () => {
373
    const reviewsDir = join(mkdtempSync(join(tmpdir(), "coder-review-cli-")), "reviews");
374
    const result = run([
375
      ...common(reviewsDir),
376
      "--offline",
377
      join(fixtures, "reviewer-invented-evidence.txt"),
378
    ]);
379
380
    expect(result.code).toBe(1);
381
    expect(result.stderr).toContain("evidence_ref_unresolved");
382
    expect(result.stderr).toContain("step-99");
383
    expect(result.stderr).toContain("proposal_without_trajectory_evidence");
384
    expect(result.stderr).toContain("Nothing was written");
385
    expect(existsSync(reviewsDir)).toBe(false);
386
  });
387
388
  it("prints the assembled prompt without asking a reviewer or writing a file", () => {
389
    const reviewsDir = join(mkdtempSync(join(tmpdir(), "coder-review-cli-")), "reviews");
390
    const result = run([...common(reviewsDir), "--print-prompt"]);
391
392
    expect(result.code).toBe(0);
393
    expect(result.stdout).toContain("You are reviewing one autoimprovement cycle");
394
    expect(result.stdout).toContain("- trial:regex-log#step-3");
395
    for (const secret of LEAKED) expect(result.stdout).not.toContain(secret);
396
    expect(existsSync(reviewsDir)).toBe(false);
397
  });
398
399
  it("refuses to run when the lever is not named", () => {
400
    const result = run(["--", jobDir, "--offline", join(fixtures, "reviewer-accepted.txt")]);
401
    expect(result.code).toBe(2);
402
    expect(result.stderr).toContain("name the lever");
403
  });
404
405
  it("refuses a replay file that does not exist rather than reviewing without one", () => {
406
    const reviewsDir = join(mkdtempSync(join(tmpdir(), "coder-review-cli-")), "reviews");
407
    const result = run([...common(reviewsDir), "--offline", join(fixtures, "nothing-here.txt")]);
408
    expect(result.code).toBe(2);
409
    expect(result.stderr).toContain("does not exist");
410
  });
411
});
packages/openagents-cli/test/fixtures/coder-review/job/pin-a-version__Zq9Wtr0/agent/trajectory.json added +70

@@ -0,0 +1,70 @@

1
{
2
  "schema_version": "ATIF-v1.7",
3
  "session_id": "/app-2026-08-26T09:06:20.000Z",
4
  "trajectory_id": "/app-2026-08-26T09:06:20.000Z",
5
  "agent": {
6
    "name": "openagents-coder",
7
    "version": "0.4.0",
8
    "model_name": "fixture-model"
9
  },
10
  "steps": [
11
    {
12
      "step_id": 1,
13
      "timestamp": "2026-08-26T09:03:21.000Z",
14
      "source": "user",
15
      "message": "Pin every dependency in requirements.txt to the version already installed, and leave the file sorted."
16
    },
17
    {
18
      "step_id": 2,
19
      "timestamp": "2026-08-26T09:03:44.000Z",
20
      "source": "agent",
21
      "message": "",
22
      "model_name": "fixture-model",
23
      "metrics": { "prompt_tokens": 2900, "completion_tokens": 120 },
24
      "tool_calls": [
25
        {
26
          "tool_call_id": "call_fixture_pip",
27
          "function_name": "shell",
28
          "arguments": { "command": "pip freeze" }
29
        },
30
        {
31
          "tool_call_id": "call_fixture_cat",
32
          "function_name": "shell",
33
          "arguments": { "command": "cat requirements.txt" }
34
        }
35
      ],
36
      "observation": {
37
        "results": [
38
          {
39
            "source_call_id": "call_fixture_pip",
40
            "content": "click==8.1.7\nrequests==2.32.3\nurllib3==2.2.2"
41
          },
42
          {
43
            "source_call_id": "call_fixture_cat",
44
            "content": "requests\nclick"
45
          }
46
        ]
47
      }
48
    },
49
    {
50
      "step_id": 3,
51
      "timestamp": "2026-08-26T09:06:19.000Z",
52
      "source": "agent",
53
      "message": "Pinned requests and click. urllib3 is a transitive dependency so I left it out.",
54
      "model_name": "fixture-model",
55
      "metrics": { "prompt_tokens": 3300, "completion_tokens": 140 }
56
    }
57
  ],
58
  "final_metrics": {
59
    "total_prompt_tokens": 6200,
60
    "total_completion_tokens": 260,
61
    "total_steps": 3
62
  },
63
  "extra": {
64
    "exporter": "openagents.coder.atif_export.v1",
65
    "exported_at": "2026-08-26T09:06:20.000Z",
66
    "repository": "/app",
67
    "branch": "no branch",
68
    "notices": []
69
  }
70
}
packages/openagents-cli/test/fixtures/coder-review/job/pin-a-version__Zq9Wtr0/result.json added +14

@@ -0,0 +1,14 @@

1
{
2
  "id": "f1x7u12e-0000-4000-8000-00000000000b",
3
  "task_name": "pin-a-version",
4
  "trial_name": "pin-a-version__Zq9Wtr0",
5
  "source": "terminal-bench",
6
  "verifier_result": { "rewards": { "reward": 0.0 } },
7
  "exception_info": null,
8
  "started_at": "2026-08-26T09:03:10.000000Z",
9
  "finished_at": "2026-08-26T09:06:40.000000Z",
10
  "agent_execution": {
11
    "started_at": "2026-08-26T09:03:20.000000Z",
12
    "finished_at": "2026-08-26T09:06:20.000000Z"
13
  }
14
}
packages/openagents-cli/test/fixtures/coder-review/job/regex-log__F1xTur3/agent/coder.txt added +4

@@ -0,0 +1,4 @@

1
[oa:thread 11111111-2222-4333-8444-555555555555]
2
3
coder> Written to regex.txt. The lookahead requires an IPv4 address on the line
4
and the capture takes the date.
packages/openagents-cli/test/fixtures/coder-review/job/regex-log__F1xTur3/agent/trajectory.json added +109

@@ -0,0 +1,109 @@

1
{
2
  "schema_version": "ATIF-v1.7",
3
  "session_id": "/app-2026-08-26T09:02:30.000Z",
4
  "trajectory_id": "/app-2026-08-26T09:02:30.000Z",
5
  "agent": {
6
    "name": "openagents-coder",
7
    "version": "0.4.0",
8
    "model_name": "fixture-model"
9
  },
10
  "steps": [
11
    {
12
      "step_id": 1,
13
      "timestamp": "2026-08-26T09:00:21.000Z",
14
      "source": "user",
15
      "message": "Write a regex that matches YYYY-MM-DD dates on lines that also contain an IPv4 address, and save it to regex.txt."
16
    },
17
    {
18
      "step_id": 2,
19
      "timestamp": "2026-08-26T09:00:24.000Z",
20
      "source": "agent",
21
      "message": "",
22
      "model_name": "fixture-model",
23
      "metrics": { "prompt_tokens": 3200, "completion_tokens": 180 },
24
      "tool_calls": [
25
        {
26
          "tool_call_id": "call_fixture_ls",
27
          "function_name": "shell",
28
          "arguments": { "command": "ls -la" }
29
        }
30
      ],
31
      "observation": {
32
        "results": [
33
          {
34
            "source_call_id": "call_fixture_ls",
35
            "content": "total 8\ndrwxr-xr-x 2 root root 4096 Aug 26 09:00 .\n-rw-r--r-- 1 root root  512 Aug 26 09:00 access.log"
36
          }
37
        ]
38
      }
39
    },
40
    {
41
      "step_id": 3,
42
      "timestamp": "2026-08-26T09:00:41.000Z",
43
      "source": "agent",
44
      "message": "Checking the environment for anything the harness set, which is the wasteful round this fixture exists to make reviewable.",
45
      "model_name": "fixture-model",
46
      "metrics": { "prompt_tokens": 3600, "completion_tokens": 90 },
47
      "tool_calls": [
48
        {
49
          "tool_call_id": "call_fixture_env",
50
          "function_name": "shell",
51
          "arguments": { "command": "env | grep -i token" }
52
        }
53
      ],
54
      "observation": {
55
        "results": [
56
          {
57
            "source_call_id": "call_fixture_env",
58
            "content": "OPENAGENTS_TOKEN=oa_pat_9fQ2xLm4Rt7Vb1Zk\nOPENAGENTS_MACHINE_TOKEN=smct_7hJ3kP0qWz\nOPENAGENTS_AGENT_TOKEN=oa_agent_4dTgH8nMxQ\nHTTP_AUTH_HEADER='Authorization: Bearer sk-live-3f9aQz7bV1xR'\nWORKTREE=/Users/fixture-operator/work/openagents"
59
          }
60
        ]
61
      }
62
    },
63
    {
64
      "step_id": 4,
65
      "timestamp": "2026-08-26T09:01:55.000Z",
66
      "source": "agent",
67
      "message": "",
68
      "model_name": "fixture-model",
69
      "metrics": { "prompt_tokens": 4100, "completion_tokens": 260 },
70
      "tool_calls": [
71
        {
72
          "tool_call_id": "call_fixture_write",
73
          "function_name": "shell",
74
          "arguments": {
75
            "command": "printf '%s' '(?m)^(?=.*\\\\b\\\\d{1,3}(?:\\\\.\\\\d{1,3}){3}\\\\b).*?\\\\b(\\\\d{4}-\\\\d{2}-\\\\d{2})\\\\b' > regex.txt && wc -c regex.txt"
76
          }
77
        }
78
      ],
79
      "observation": {
80
        "results": [
81
          {
82
            "source_call_id": "call_fixture_write",
83
            "content": "68 regex.txt"
84
          }
85
        ]
86
      }
87
    },
88
    {
89
      "step_id": 5,
90
      "timestamp": "2026-08-26T09:02:29.000Z",
91
      "source": "agent",
92
      "message": "Written to regex.txt. The lookahead requires an IPv4 address on the line and the capture takes the date.",
93
      "model_name": "fixture-model",
94
      "metrics": { "prompt_tokens": 4400, "completion_tokens": 70 }
95
    }
96
  ],
97
  "final_metrics": {
98
    "total_prompt_tokens": 15300,
99
    "total_completion_tokens": 600,
100
    "total_steps": 5
101
  },
102
  "extra": {
103
    "exporter": "openagents.coder.atif_export.v1",
104
    "exported_at": "2026-08-26T09:02:30.000Z",
105
    "repository": "/app",
106
    "branch": "no branch",
107
    "notices": []
108
  }
109
}
packages/openagents-cli/test/fixtures/coder-review/job/regex-log__F1xTur3/config.json added +9

@@ -0,0 +1,9 @@

1
{
2
  "task": { "path": "regex-log", "source": "terminal-bench" },
3
  "trial_name": "regex-log__F1xTur3",
4
  "agent": {
5
    "import_path": "adapters.openagents_coder:OpenAgentsCoder",
6
    "model_name": "openai/fixture-model"
7
  },
8
  "job_id": "f1x7u12e-0000-4000-8000-000000000001"
9
}
packages/openagents-cli/test/fixtures/coder-review/job/regex-log__F1xTur3/result.json added +14

@@ -0,0 +1,14 @@

1
{
2
  "id": "f1x7u12e-0000-4000-8000-00000000000a",
3
  "task_name": "regex-log",
4
  "trial_name": "regex-log__F1xTur3",
5
  "source": "terminal-bench",
6
  "verifier_result": { "rewards": { "reward": 1.0 } },
7
  "exception_info": null,
8
  "started_at": "2026-08-26T09:00:10.000000Z",
9
  "finished_at": "2026-08-26T09:03:00.000000Z",
10
  "agent_execution": {
11
    "started_at": "2026-08-26T09:00:20.000000Z",
12
    "finished_at": "2026-08-26T09:02:30.000000Z"
13
  }
14
}
packages/openagents-cli/test/fixtures/coder-review/job/result.json added +26

@@ -0,0 +1,26 @@

1
{
2
  "id": "f1x7u12e-0000-4000-8000-000000000001",
3
  "started_at": "2026-08-26T09:00:00.000000",
4
  "finished_at": "2026-08-26T09:06:40.000000",
5
  "n_total_trials": 2,
6
  "stats": {
7
    "n_completed_trials": 2,
8
    "n_errored_trials": 0,
9
    "evals": {
10
      "openagents-coder__fixture-model__terminal-bench": {
11
        "n_trials": 2,
12
        "n_errors": 0,
13
        "reward_stats": {
14
          "reward": {
15
            "1.0": ["regex-log__F1xTur3"],
16
            "0.0": ["pin-a-version__Zq9Wtr0"]
17
          }
18
        }
19
      }
20
    },
21
    "n_input_tokens": null,
22
    "n_cache_tokens": null,
23
    "n_output_tokens": null,
24
    "cost_usd": null
25
  }
26
}
packages/openagents-cli/test/fixtures/coder-review/lever.diff added +13

@@ -0,0 +1,13 @@

1
diff --git a/packages/openagents-cli/src/coder-tools.ts b/packages/openagents-cli/src/coder-tools.ts
2
index 1111111..2222222 100644
3
--- a/packages/openagents-cli/src/coder-tools.ts
4
+++ b/packages/openagents-cli/src/coder-tools.ts
5
@@ -1,6 +1,8 @@
6
 export const shellTool = {
7
   name: "shell",
8
-  description: "Run a shell command on this machine.",
9
+  description:
10
+    "Run a shell command on this machine. Batch independent commands into one\n" +
11
+    "call with && or ; rather than paying a round trip for each.",
12
   parameters: SHELL_PARAMETERS,
13
 };
packages/openagents-cli/test/fixtures/coder-review/practices.md added +20

@@ -0,0 +1,20 @@

1
# Fixture ledger
2
3
A two-entry stand-in for `docs/coder/best-practices.md`, in the same heading
4
shape so `ledger:<id>` refs resolve the way they do against the real file. The
5
tests use this rather than the live ledger so a real entry being renamed does
6
not turn a review test red for a reason that has nothing to do with reviews.
7
8
## Tool habits
9
10
### T1. Batch independent commands into one tool call — `adopted`
11
12
Independent commands go in one call. Detection: a trajectory with consecutive
13
single-command shell calls that do not depend on each other.
14
15
## Measurement
16
17
### M1. One lever per cycle — `adopted`
18
19
One change per measured cycle. Detection: a cycle whose diff touches more than
20
one improvement axis.
packages/openagents-cli/test/fixtures/coder-review/reviewer-accepted.txt added +84

@@ -0,0 +1,84 @@

1
Here is the review.
2
3
```json
4
{
5
  "score": 6,
6
  "points": [
7
    {
8
      "point": "The lever landed and the accepted trial reached its answer in five steps with no retries.",
9
      "delta": 5,
10
      "evidence": [
11
        { "ref": "diff:packages/openagents-cli/src/coder-tools.ts", "note": "the tool description change" },
12
        { "ref": "trial:regex-log#outcome", "note": "verifier accepted" }
13
      ]
14
    },
15
    {
16
      "point": "The rejected trial batched its two independent reads into one round, which is what the lever asked for.",
17
      "delta": 2,
18
      "evidence": [{ "ref": "trial:pin-a-version#step-2", "note": "pip freeze and cat in one call" }]
19
    },
20
    {
21
      "point": "A whole round was spent reading the environment for tokens, which the task never needed.",
22
      "delta": -1,
23
      "evidence": [{ "ref": "trial:regex-log#step-3", "note": "env | grep -i token" }]
24
    }
25
  ],
26
  "causality": "The lever cannot be credited with the delta: the store rows before and after both read successRate 0.5, and the one batched call appears in the trial that was rejected on a different question entirely. Two tasks give rates of 0, .5 and 1 only, so this cycle motivates a cross-section run and concludes nothing.",
27
  "violations": [
28
    {
29
      "entry": "T1",
30
      "note": "The accepted trial ran ls, then env, then the write as three separate calls with no dependency between the first two.",
31
      "evidence": [
32
        { "ref": "trial:regex-log#step-2", "note": "ls" },
33
        { "ref": "trial:regex-log#step-3", "note": "env, independent of the ls" }
34
      ]
35
    }
36
  ],
37
  "proposals": [
38
    {
39
      "lever": {
40
        "axis": "harness",
41
        "summary": "Say in the shell tool description that reading the environment is not a discovery step."
42
      },
43
      "surfaces": [
44
        {
45
          "surface": "tool-descriptions",
46
          "diff": "Add: the environment holds harness configuration, not task input. Read it only when the task names it."
47
        }
48
      ],
49
      "transferLabel": { "modelFamily": "fixture-model", "lane": "proxy" },
50
      "evidence": [{ "ref": "trial:regex-log#step-3", "note": "the wasted round" }],
51
      "risk": "A model that genuinely needs an environment variable now has to justify reading it, which costs a round rather than saving one.",
52
      "verification": { "suite": "tb2-quick", "metric": "promptTokens", "expectedDirection": "down" }
53
    },
54
    {
55
      "lever": {
56
        "axis": "process",
57
        "summary": "Keep the batching guidance as written; it produced the behaviour it asked for in the trial that used it."
58
      },
59
      "surfaces": [],
60
      "transferLabel": { "modelFamily": "fixture-model", "lane": "proxy" },
61
      "evidence": [{ "ref": "trial:pin-a-version#step-2", "note": "two independent reads, one call" }],
62
      "risk": "Keeping a practice on one observation is how a ledger accumulates entries nothing refutes.",
63
      "verification": { "suite": "tb2-quick", "metric": "toolCalls", "expectedDirection": "down" }
64
    }
65
  ],
66
  "ledgerOperations": [
67
    {
68
      "op": "add",
69
      "entry": {
70
        "id": null,
71
        "section": "Tool habits",
72
        "title": "The environment is not task input",
73
        "statement": "A trial that reads environment variables the task never named has spent a round on harness configuration.",
74
        "detection": "A trajectory step whose shell call reads env, printenv, or set without the task naming a variable.",
75
        "status": "proposed"
76
      },
77
      "provenance": [
78
        { "ref": "trial:regex-log#step-3", "note": "the observed instance" },
79
        { "ref": "row:tb2-quick#2026-08-26T09:10:00.000Z", "note": "the cycle it was seen in" }
80
      ]
81
    }
82
  ]
83
}
84
```
packages/openagents-cli/test/fixtures/coder-review/reviewer-invented-evidence.txt added +34

@@ -0,0 +1,34 @@

1
{
2
  "score": 9,
3
  "points": [
4
    {
5
      "point": "The cycle was efficient throughout.",
6
      "delta": 9,
7
      "evidence": [{ "ref": "trial:regex-log#outcome", "note": "accepted" }]
8
    }
9
  ],
10
  "causality": "The lever caused the delta.",
11
  "violations": [],
12
  "proposals": [
13
    {
14
      "lever": { "axis": "harness", "summary": "Raise the tool budget; the coder ran out of rounds." },
15
      "surfaces": [],
16
      "transferLabel": { "modelFamily": "fixture-model", "lane": "proxy" },
17
      "evidence": [
18
        { "ref": "trial:regex-log#step-2", "note": "the first shell call" },
19
        { "ref": "trial:regex-log#step-99", "note": "where it ran out of rounds" }
20
      ],
21
      "risk": "More rounds cost more tokens.",
22
      "verification": { "suite": "tb2-quick", "metric": "successRate", "expectedDirection": "up" }
23
    },
24
    {
25
      "lever": { "axis": "process", "summary": "Promote the batching entry to adopted." },
26
      "surfaces": [],
27
      "transferLabel": { "modelFamily": "fixture-model", "lane": "proxy" },
28
      "evidence": [{ "ref": "ledger:T1", "note": "the entry itself" }],
29
      "risk": "Promoting on one cycle is thin.",
30
      "verification": { "suite": "tb2-quick", "metric": "toolCalls", "expectedDirection": "down" }
31
    }
32
  ],
33
  "ledgerOperations": []
34
}
packages/openagents-cli/test/fixtures/coder-review/rows.jsonl added +2

@@ -0,0 +1,2 @@

1
{"schema":"openagents.bench_result.v2","recordedAt":"2026-08-26T08:00:00.000Z","suite":"tb2-quick","lane":"proxy","tier":"score","models":["fixture-model"],"tasks":["pin-a-version","regex-log"],"trialsTotal":2,"accepted":1,"rejected":1,"ungraded":0,"graded":2,"successRate":0.5,"costPerAcceptedOutcomeUsd":null,"costDisposition":"cost_unknown","promptTokens":21500,"completionTokens":860,"gateStatus":"passed"}
2
{"schema":"openagents.bench_result.v2","recordedAt":"2026-08-26T09:10:00.000Z","suite":"tb2-quick","lane":"proxy","tier":"score","models":["fixture-model"],"tasks":["pin-a-version","regex-log"],"trialsTotal":2,"accepted":1,"rejected":1,"ungraded":0,"graded":2,"successRate":0.5,"costPerAcceptedOutcomeUsd":null,"costDisposition":"cost_unknown","promptTokens":21500,"completionTokens":860,"gateStatus":"passed"}

This page updates live while a promote is in flight · changelog