dw-mc 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.js CHANGED
@@ -114,24 +114,18 @@ const encodeYaml = (value) => {
114
114
  //#endregion
115
115
  //#region src/adapters/config.ts
116
116
  /**
117
- * What a review run executes: the agent's own review command, or the tool's own
118
- * review prompt on one of the two agent CLIs.
117
+ * How much a review run spends, in the words the slash command takes.
119
118
  *
120
- * `builtin` and `prompt` run on the launcher, which is Claude Code. `codex`
121
- * runs the same prompt on the Codex CLI, and is the second opinion: configured
122
- * beside one of the others, its findings inform me without gating my bar.
119
+ * The set is Claude Code's and not this tool's, so it is wider than the three
120
+ * words a review used to be held to: a run that would be worth `max` is one I
121
+ * should be able to ask for without spelling the whole command out.
123
122
  */
124
- const runners = [
125
- "builtin",
126
- "prompt",
127
- "codex"
128
- ];
129
- const Runner = Schema.Literals(runners);
130
- /** How much a built-in review run spends. */
131
123
  const Effort = Schema.Literals([
132
124
  "low",
133
125
  "medium",
134
- "high"
126
+ "high",
127
+ "xhigh",
128
+ "max"
135
129
  ]);
136
130
  /** How much a finding weighs. */
137
131
  const Severity = Schema.Literals([
@@ -139,10 +133,6 @@ const Severity = Schema.Literals([
139
133
  "warning",
140
134
  "info"
141
135
  ]);
142
- const PathInstruction = Schema.Struct({
143
- path: Schema.String,
144
- instructions: Schema.String
145
- });
146
136
  /**
147
137
  * What one section of the file may say. Every key is optional: what the file
148
138
  * leaves out is inherited rather than reset, so `defaults` and a repository's
@@ -151,12 +141,11 @@ const PathInstruction = Schema.Struct({
151
141
  const SettingsPatch = Schema.Struct({
152
142
  base: Schema.optionalKey(Schema.NullOr(Schema.String)),
153
143
  review: Schema.optionalKey(Schema.Struct({
154
- runners: Schema.optionalKey(Schema.Array(Runner)),
155
- effort: Schema.optionalKey(Effort),
144
+ command: Schema.optionalKey(Schema.NullOr(Schema.String)),
145
+ effort: Schema.optionalKey(Schema.NullOr(Effort)),
146
+ prompt: Schema.optionalKey(Schema.NullOr(Schema.String)),
156
147
  model: Schema.optionalKey(Schema.NullOr(Schema.String)),
157
- skill: Schema.optionalKey(Schema.NullOr(Schema.String)),
158
- docs_only: Schema.optionalKey(Schema.Array(Schema.String)),
159
- path_instructions: Schema.optionalKey(Schema.Array(PathInstruction))
148
+ docs_only: Schema.optionalKey(Schema.Array(Schema.String))
160
149
  })),
161
150
  ci: Schema.optionalKey(Schema.Struct({
162
151
  ignore: Schema.optionalKey(Schema.Array(Schema.String)),
@@ -164,16 +153,12 @@ const SettingsPatch = Schema.Struct({
164
153
  })),
165
154
  fix: Schema.optionalKey(Schema.Struct({ commits: Schema.optionalKey(Schema.Boolean) })),
166
155
  rebase: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })),
167
- stamp: Schema.optionalKey(Schema.Struct({
168
- blocks_on: Schema.optionalKey(Severity),
169
- supporting_blocks: Schema.optionalKey(Schema.Boolean)
170
- }))
156
+ stamp: Schema.optionalKey(Schema.Struct({ blocks_on: Schema.optionalKey(Severity) }))
171
157
  });
172
- /** How this machine starts a runner, where it does not start `claude` itself. */
158
+ /** How this machine starts Claude Code, where it does not start `claude` itself. */
173
159
  const LauncherPatch = Schema.Struct({
174
160
  command: Schema.optionalKey(Schema.Array(Schema.String).pipe(Schema.check(Schema.isMinLength(1, { message: "Expected the launcher command to name a program" })))),
175
- fix_args: Schema.optionalKey(Schema.Array(Schema.String)),
176
- codex: Schema.optionalKey(Schema.Array(Schema.String).pipe(Schema.check(Schema.isMinLength(1, { message: "Expected the codex command to name a program" }))))
161
+ fix_args: Schema.optionalKey(Schema.Array(Schema.String))
177
162
  });
178
163
  /** A repository, as `gh` spells it: `owner/name`. */
179
164
  const Repo = Schema.String.pipe(Schema.check(Schema.isPattern(/^[^\s/]+\/[^\s/]+$/, { message: "Expected a repository as owner/name" })));
@@ -187,12 +172,11 @@ const ConfigFile = Schema.Struct({
187
172
  const builtIn = {
188
173
  base: null,
189
174
  review: {
190
- runners: ["builtin"],
175
+ command: "/code-review",
191
176
  effort: "low",
177
+ prompt: null,
192
178
  model: null,
193
- skill: null,
194
- docs_only: ["**/*.md", "docs/**"],
195
- path_instructions: []
179
+ docs_only: ["**/*.md", "docs/**"]
196
180
  },
197
181
  ci: {
198
182
  ignore: [],
@@ -200,16 +184,12 @@ const builtIn = {
200
184
  },
201
185
  fix: { commits: false },
202
186
  rebase: { enabled: false },
203
- stamp: {
204
- blocks_on: "error",
205
- supporting_blocks: false
206
- }
187
+ stamp: { blocks_on: "error" }
207
188
  };
208
- /** `claude` and `codex` themselves, which is what a machine that spawns them directly needs. */
189
+ /** `claude` itself, which is what a machine that spawns it directly needs. */
209
190
  const builtInLauncher = {
210
191
  command: ["claude"],
211
- fix_args: [],
212
- codex: ["codex"]
192
+ fix_args: []
213
193
  };
214
194
  /**
215
195
  * The patch's value where it has one, the inherited value otherwise. A key the
@@ -220,12 +200,11 @@ const over = (patch, inherited) => patch === void 0 ? inherited : patch;
220
200
  const apply = (settings, patch) => patch === void 0 ? settings : {
221
201
  base: over(patch.base, settings.base),
222
202
  review: {
223
- runners: over(patch.review?.runners, settings.review.runners),
203
+ command: over(patch.review?.command, settings.review.command),
224
204
  effort: over(patch.review?.effort, settings.review.effort),
205
+ prompt: over(patch.review?.prompt, settings.review.prompt),
225
206
  model: over(patch.review?.model, settings.review.model),
226
- skill: over(patch.review?.skill, settings.review.skill),
227
- docs_only: over(patch.review?.docs_only, settings.review.docs_only),
228
- path_instructions: over(patch.review?.path_instructions, settings.review.path_instructions)
207
+ docs_only: over(patch.review?.docs_only, settings.review.docs_only)
229
208
  },
230
209
  ci: {
231
210
  ignore: over(patch.ci?.ignore, settings.ci.ignore),
@@ -233,10 +212,7 @@ const apply = (settings, patch) => patch === void 0 ? settings : {
233
212
  },
234
213
  fix: { commits: over(patch.fix?.commits, settings.fix.commits) },
235
214
  rebase: { enabled: over(patch.rebase?.enabled, settings.rebase.enabled) },
236
- stamp: {
237
- blocks_on: over(patch.stamp?.blocks_on, settings.stamp.blocks_on),
238
- supporting_blocks: over(patch.stamp?.supporting_blocks, settings.stamp.supporting_blocks)
239
- }
215
+ stamp: { blocks_on: over(patch.stamp?.blocks_on, settings.stamp.blocks_on) }
240
216
  };
241
217
  /**
242
218
  * `delta` over `patch`, keeping every key `delta` does not mention.
@@ -290,18 +266,16 @@ const withRepo = (file, repo, patch) => ({
290
266
  }
291
267
  });
292
268
  /**
293
- * What this machine starts a runner with: the file's launcher over `claude`.
269
+ * What this machine starts Claude Code with: the file's launcher over `claude`.
294
270
  *
295
271
  * It is no repository's business. What spawns the agent CLI is a fact of the
296
272
  * machine, which is why it sits beside `defaults` rather than inside it.
297
273
  */
298
274
  const launcherOf = (file) => {
299
275
  const [program = builtInLauncher.command[0], ...prefix] = file.launcher?.command ?? [];
300
- const [codex = builtInLauncher.codex[0], ...codexPrefix] = file.launcher?.codex ?? [];
301
276
  return {
302
277
  command: [program, ...prefix],
303
- fix_args: file.launcher?.fix_args ?? builtInLauncher.fix_args,
304
- codex: [codex, ...codexPrefix]
278
+ fix_args: file.launcher?.fix_args ?? builtInLauncher.fix_args
305
279
  };
306
280
  };
307
281
  /** What `repo` is worth: its own overrides over the global defaults. */
@@ -349,6 +323,43 @@ var ConfigMalformed = class extends Schema.TaggedError()("ConfigMalformed", {
349
323
  }
350
324
  };
351
325
  const reasonOf = (cause) => cause instanceof Error ? cause.message : String(cause);
326
+ /** The keys an earlier version had, read off a file loosely enough to find them. */
327
+ const LegacySection = Schema.Struct({
328
+ review: Schema.optionalKey(Schema.Struct({
329
+ runners: Schema.optionalKey(Schema.Unknown),
330
+ skill: Schema.optionalKey(Schema.Unknown),
331
+ path_instructions: Schema.optionalKey(Schema.Unknown)
332
+ })),
333
+ stamp: Schema.optionalKey(Schema.Struct({ supporting_blocks: Schema.optionalKey(Schema.Unknown) }))
334
+ });
335
+ const Legacy = Schema.Struct({
336
+ launcher: Schema.optionalKey(Schema.Struct({ codex: Schema.optionalKey(Schema.Unknown) })),
337
+ defaults: Schema.optionalKey(LegacySection),
338
+ repos: Schema.optionalKey(Schema.Record(Schema.String, LegacySection))
339
+ });
340
+ const asLegacy = Schema.decodeUnknownOption(Legacy);
341
+ /**
342
+ * What a file from an earlier version says, and what to do about each of it.
343
+ *
344
+ * The excess-property error names a key and stops there, which is enough for a
345
+ * key that is simply gone and not enough for one that moved: `review.skill` is
346
+ * `review.prompt` now, and a file quietly stripped of it is a review brief lost.
347
+ * This is here to be deleted once no file has those keys left.
348
+ */
349
+ const legacyIn = (decided) => {
350
+ const legacy = asLegacy(decided);
351
+ if (Option.isNone(legacy)) return null;
352
+ const sections = [legacy.value.defaults, ...Object.values(legacy.value.repos ?? {})];
353
+ const spelled = (says) => sections.some((section) => section !== void 0 && says(section) !== void 0);
354
+ const said = [
355
+ legacy.value.launcher?.codex === void 0 ? null : "launcher.codex is gone: reviews run on Claude Code alone.",
356
+ spelled((section) => section.review?.runners) ? "review.runners is gone: a head carries one review run, which review.command configures." : null,
357
+ spelled((section) => section.review?.skill) ? "review.skill is review.prompt now, unchanged in what it does - move the text across rather than losing it." : null,
358
+ spelled((section) => section.review?.path_instructions) ? "review.path_instructions is gone: nothing ever read it." : null,
359
+ spelled((section) => section.stamp?.supporting_blocks) ? "stamp.supporting_blocks is gone: there is no second opinion to let through." : null
360
+ ].filter((sentence) => sentence !== null);
361
+ return said.length === 0 ? null : `it names keys this version does not have.\n${said.join("\n")}`;
362
+ };
352
363
  /**
353
364
  * The configuration file, or `None` when this machine has none yet.
354
365
  *
@@ -368,6 +379,8 @@ const read = Effect.gen(function* () {
368
379
  try: () => Yaml.parse(raw),
369
380
  catch: (cause) => malformed(reasonOf(cause))
370
381
  })) ?? {};
382
+ const legacy = legacyIn(decided);
383
+ if (legacy !== null) return yield* malformed(legacy);
371
384
  return Option.some(yield* Schema.decodeUnknownEffect(ConfigFile)(decided, {
372
385
  onExcessProperty: "error",
373
386
  errors: "all"
@@ -381,17 +394,16 @@ const mapping = (entries) => {
381
394
  const settingsDocument = (patch) => mapping([
382
395
  ["base", patch.base],
383
396
  ["review", patch.review === void 0 ? void 0 : mapping([
384
- ["runners", patch.review.runners],
397
+ ["command", patch.review.command],
385
398
  ["effort", patch.review.effort],
399
+ ["prompt", patch.review.prompt],
386
400
  ["model", patch.review.model],
387
- ["skill", patch.review.skill],
388
- ["docs_only", patch.review.docs_only],
389
- ["path_instructions", patch.review.path_instructions]
401
+ ["docs_only", patch.review.docs_only]
390
402
  ])],
391
403
  ["ci", patch.ci === void 0 ? void 0 : mapping([["ignore", patch.ci.ignore], ["flaky_patterns", patch.ci.flaky_patterns]])],
392
404
  ["fix", patch.fix === void 0 ? void 0 : mapping([["commits", patch.fix.commits]])],
393
405
  ["rebase", patch.rebase === void 0 ? void 0 : mapping([["enabled", patch.rebase.enabled]])],
394
- ["stamp", patch.stamp === void 0 ? void 0 : mapping([["blocks_on", patch.stamp.blocks_on], ["supporting_blocks", patch.stamp.supporting_blocks]])]
406
+ ["stamp", patch.stamp === void 0 ? void 0 : mapping([["blocks_on", patch.stamp.blocks_on]])]
395
407
  ]);
396
408
  /**
397
409
  * The file as a YAML document, in the order of the schema.
@@ -400,11 +412,7 @@ const settingsDocument = (patch) => mapping([
400
412
  * keeps the file stable across runs, so a rewrite shows only what changed.
401
413
  */
402
414
  const fileDocument = (file) => mapping([
403
- ["launcher", file.launcher === void 0 ? void 0 : mapping([
404
- ["command", file.launcher.command],
405
- ["fix_args", file.launcher.fix_args],
406
- ["codex", file.launcher.codex]
407
- ])],
415
+ ["launcher", file.launcher === void 0 ? void 0 : mapping([["command", file.launcher.command], ["fix_args", file.launcher.fix_args]])],
408
416
  ["defaults", file.defaults === void 0 ? void 0 : settingsDocument(file.defaults)],
409
417
  ["repos", file.repos === void 0 ? void 0 : mapping(Object.entries(file.repos).map(([name, patch]) => [name, settingsDocument(patch)]))]
410
418
  ]);
@@ -2032,7 +2040,7 @@ const readyReason = (facts) => {
2032
2040
  * This is the single place the bucket rules exist. Every tracked PR lands in
2033
2041
  * exactly one bucket, so the rules are tried in priority order and the first
2034
2042
  * that claims the PR wins: a PR that both needs a review run and has changes
2035
- * requested is mine to move, not the runner's.
2043
+ * requested is mine to move, not the review's.
2036
2044
  *
2037
2045
  * Ready does not insist on an approval, because a repository that requires no
2038
2046
  * reviewer never produces one. What it insists on is that nobody else has been
@@ -2732,11 +2740,12 @@ const recordConflict = Effect.fn("rebase.recordConflict")(function* (repo, numbe
2732
2740
  /** Whether a review run found anything at all. */
2733
2741
  const Verdict = Schema.Literals(["clean", "findings"]);
2734
2742
  /**
2735
- * Every severity word a runner may answer with.
2743
+ * Every severity word a review may answer with.
2736
2744
  *
2737
- * The first three are ours, and the only ones a runner is asked for. The rest
2738
- * are the persona the `prompt` runner carries, which grades in its own words:
2739
- * a turn that comes back in them is worth reading rather than throwing away.
2745
+ * The first three are ours, and the only ones a run is asked for. The rest are
2746
+ * the persona a run with no slash command carries, which grades in its own
2747
+ * words: a turn that comes back in them is worth reading rather than throwing
2748
+ * away.
2740
2749
  */
2741
2750
  const Spelling = Schema.Literals([
2742
2751
  "error",
@@ -2809,9 +2818,9 @@ const jsonSchema = JSON.stringify(SchemaRepresentation.toJsonSchemaDocument(Sche
2809
2818
  /**
2810
2819
  * The findings as the Markdown a report is written in.
2811
2820
  *
2812
- * It is what the `prompt` runner's report says: with a schema in force a runner
2813
- * answers in findings and not in prose, so the report kept beside the run is
2814
- * written from the findings themselves rather than left empty.
2821
+ * It is what a schema-held run's report says: with a schema in force a run
2822
+ * answers in findings and not in prose, so the report kept beside it is written
2823
+ * from the findings themselves rather than left empty.
2815
2824
  */
2816
2825
  const asMarkdown = (found) => found.findings.length === 0 ? "Clean: the run found nothing to report." : found.findings.map((finding) => `- \`${finding.file}:${finding.line}\` ${finding.severity}: ${finding.summary}`).join("\n");
2817
2826
  /** Where each severity sits against the others, so the bar can be compared with it. */
@@ -2842,7 +2851,7 @@ const Outcome = Schema.Union([Schema.TaggedStruct("reported", {
2842
2851
  findings: Schema.Array(Finding)
2843
2852
  }), Schema.TaggedStruct("failed", { detail: Schema.String })]);
2844
2853
  /**
2845
- * One execution of a runner against a tracked PR at a specific head commit.
2854
+ * One review run against a tracked PR at a specific head commit.
2846
2855
  *
2847
2856
  * It is a schema because a review run outlives the command that started it: the
2848
2857
  * state directory is where the next sweep learns that this head has been
@@ -2853,13 +2862,18 @@ const ReviewRun = Schema.Struct({
2853
2862
  number: Schema.Int,
2854
2863
  /** The head the run covers. A run never vouches for code it did not see. */
2855
2864
  head: Schema.String,
2856
- runner: Runner,
2857
- effort: Effort,
2865
+ /**
2866
+ * The slash command line the run opened on, or null where it opened on the
2867
+ * tool's own prompt. A report found months later says what it was asked, and a
2868
+ * record an earlier version wrote carries no such field and is forgotten.
2869
+ */
2870
+ command: Schema.NullOr(Schema.String),
2871
+ effort: Schema.NullOr(Effort),
2858
2872
  /**
2859
2873
  * The agent session the run happened in, or null where it never reached one.
2860
2874
  *
2861
- * A runner that would not start or exited before it said anything has no
2862
- * session, and the run is still recorded: a failure is recorded as what it is.
2875
+ * A run that would not start or exited before it said anything has no session,
2876
+ * and the run is still recorded: a failure is recorded as what it is.
2863
2877
  */
2864
2878
  sessionId: Schema.NullOr(Schema.String),
2865
2879
  ranAt: Schema.DateTimeUtcFromString,
@@ -2868,17 +2882,12 @@ const ReviewRun = Schema.Struct({
2868
2882
  /** A head as it is read out loud: the seven characters git itself abbreviates to. */
2869
2883
  const short = (head) => head.slice(0, 7);
2870
2884
  /**
2871
- * Where a run is kept: one key per head and runner, so a run and the code it
2872
- * read cannot drift apart, and a re-review on the same runner replaces the run
2873
- * before it.
2874
- *
2875
- * The runner is in the key because a head can carry two opinions: the review
2876
- * that is my bar and the second one beside it. Without it the second opinion
2877
- * would overwrite the first and the stamp would rest on whichever ran last.
2885
+ * Where a run is kept: one key per head, so a run and the code it read cannot
2886
+ * drift apart, and a re-review replaces the run before it.
2878
2887
  */
2879
- const runKey = (repo, number, head, runner) => `${repo}#${number}@${head}:${runner}`;
2888
+ const runKey = (repo, number, head) => `${repo}#${number}@${head}`;
2880
2889
  /** Where the run's report is kept: beside the run, as the Markdown it is. */
2881
- const reportKey = (repo, number, head, runner) => `${runKey(repo, number, head, runner)}.md`;
2890
+ const reportKey = (repo, number, head) => `${runKey(repo, number, head)}.md`;
2882
2891
  /**
2883
2892
  * Which head a pull request was last reviewed at: an index beside `runKey` and
2884
2893
  * `reportKey` rather than a thing the glossary names.
@@ -2890,35 +2899,27 @@ const reportKey = (repo, number, head, runner) => `${runKey(repo, number, head,
2890
2899
  */
2891
2900
  const LastReviewed = Schema.Struct({ head: Schema.String });
2892
2901
  /** Where that head is kept. No head is spelled `latest`, so nothing collides. */
2893
- const latestKey = (repo, number, runner) => `${repo}#${number}@latest:${runner}`;
2902
+ const latestKey = (repo, number) => `${repo}#${number}@latest`;
2894
2903
  /**
2895
- * The last review run of one runner on a pull request, or none where it has had
2896
- * none.
2904
+ * The run at one head, or none where nothing has reviewed it.
2897
2905
  *
2898
- * It is per runner because the re-run rule is: a second opinion that has never
2899
- * seen this pull request is not skipped because the primary review saw it.
2906
+ * A head is where the question is asked - the stamp, the bucket and `dw-mc
2907
+ * findings` all ask about one commit - and one read off the disk answers it
2908
+ * without an index to keep in step.
2900
2909
  *
2901
2910
  * A run this version cannot read is a run another version of this record wrote,
2902
2911
  * and the state directory is a cache of work that can be done again: forgetting
2903
2912
  * it costs one review, where failing here would cost me the command I asked for.
2904
2913
  */
2905
- const lastRun = Effect.fn("review.lastRun")(function* (repo, number, runner) {
2906
- const heads = yield* storeFor("runs", LastReviewed);
2907
- const at = yield* Effect.orElseSucceed(heads.get(latestKey(repo, number, runner)), () => Option.none());
2908
- if (Option.isNone(at)) return Option.none();
2914
+ const runAt = Effect.fn("review.runAt")(function* (repo, number, head) {
2909
2915
  const runs = yield* storeFor("runs", ReviewRun);
2910
- return yield* Effect.orElseSucceed(runs.get(runKey(repo, number, at.value.head, runner)), () => Option.none());
2916
+ return yield* Effect.orElseSucceed(runs.get(runKey(repo, number, head)), () => Option.none());
2911
2917
  });
2912
- /**
2913
- * Every runner's run at one head, in the order the runners are named.
2914
- *
2915
- * A head is where the question is asked - the stamp, the bucket and `dw-mc
2916
- * findings` all ask about one commit - and a head may carry a run from each
2917
- * runner. Three reads off the disk answer it without an index to keep in step.
2918
- */
2919
- const runsAt = Effect.fn("review.runsAt")(function* (repo, number, head) {
2920
- const runs = yield* storeFor("runs", ReviewRun);
2921
- return (yield* Effect.forEach(runners, (runner) => Effect.orElseSucceed(runs.get(runKey(repo, number, head, runner)), () => Option.none()))).flatMap((run) => Option.isSome(run) ? [run.value] : []);
2918
+ /** The last review run on a pull request, or none where it has had none. */
2919
+ const lastRun = Effect.fn("review.lastRun")(function* (repo, number) {
2920
+ const heads = yield* storeFor("runs", LastReviewed);
2921
+ const at = yield* Effect.orElseSucceed(heads.get(latestKey(repo, number)), () => Option.none());
2922
+ return Option.isNone(at) ? Option.none() : yield* runAt(repo, number, at.value.head);
2922
2923
  });
2923
2924
  /**
2924
2925
  * What a run reported, or null where it reported nothing at all.
@@ -2964,73 +2965,38 @@ const skippedSince = (asked, docsOnly) => {
2964
2965
  const changed = asked.last.head === asked.head ? [] : asked.changed;
2965
2966
  return changed === null || worthRerunning(changed, docsOnly) ? null : asked.last.head;
2966
2967
  };
2968
+ /** What a run was opened on, as the report says it. */
2969
+ const askedOf$1 = (run) => run.command === null ? "the tool's own prompt" : [run.command, run.effort].filter((part) => part !== null).join(" ");
2967
2970
  /**
2968
- * The report as it is written down: what it is of, then what the runner said.
2971
+ * The report as it is written down: what it is of, then what the run said.
2969
2972
  *
2970
2973
  * The heading is the whole point of writing it rather than storing the prose
2971
2974
  * alone - a file found months later says which pull request, which commit and
2972
- * how much the run spent, without anything else having to be open.
2975
+ * what the run was asked, without anything else having to be open.
2973
2976
  */
2974
2977
  const reportDocument = (run, title, prose) => [
2975
2978
  `# ${run.repo}#${run.number} ${title}`,
2976
2979
  "",
2977
2980
  `- head: ${run.head}`,
2978
- `- runner: ${run.runner}, effort ${run.effort}`,
2981
+ `- run: ${askedOf$1(run)}`,
2979
2982
  `- ran: ${DateTime.formatIso(run.ranAt)}`,
2980
2983
  "",
2981
2984
  prose.trim(),
2982
2985
  ""
2983
2986
  ].join("\n");
2984
2987
  /**
2985
- * The runners one review run executes, in the order the file names them and
2986
- * without repeats.
2987
- *
2988
- * The order is the precedence everything downstream reads: it is the order the
2989
- * runners run in, and the first of them is the run `dw-mc findings` and a fix
2990
- * session reach for when I do not name one.
2991
- */
2992
- const runnersFor = (configured) => [...new Set(configured)];
2993
- /** Which agent CLI a runner reaches. `builtin` and `prompt` are Claude Code's. */
2994
- const onCodex = (runner) => runner === "codex";
2995
- /**
2996
- * The configured runners that are a second opinion rather than my bar.
2997
- *
2998
- * Codex is supporting because something else is the review: a machine that
2999
- * configured Codex alone did not ask for a second opinion, it asked for a
3000
- * review, and nothing there could ever earn a stamp if this called it
3001
- * supporting.
3002
- */
3003
- const supportingIn = (configured) => {
3004
- const all = runnersFor(configured);
3005
- return all.some((runner) => !onCodex(runner)) ? all.filter(onCodex) : [];
3006
- };
3007
- /**
3008
- * The configured runners whose findings decide the stamp.
3009
- *
3010
- * A second opinion informs me without gating my bar, which is what
3011
- * `stamp.supporting_blocks` turns off: set, every configured runner decides.
3012
- */
3013
- const decidingIn = (configured, supportingBlocks) => {
3014
- const all = runnersFor(configured);
3015
- if (supportingBlocks) return all;
3016
- const supporting = supportingIn(configured);
3017
- return all.filter((runner) => !supporting.includes(runner));
3018
- };
3019
- /**
3020
- * Whether `head` has the review it needs: every deciding runner has reported on
3021
- * it.
2988
+ * Whether `head` has the review it needs.
3022
2989
  *
3023
- * Every rather than any, because a pull request reviewed by half of what I
3024
- * configured is one that still waits on the other half. A run that reported
3025
- * nothing does not count, which is the same rule `reportedBy` draws everywhere
3026
- * else: a failure has found nothing, not found nothing wrong.
2990
+ * A run that reported nothing does not count, which is the same rule
2991
+ * `reportedBy` draws everywhere else: a failure has found nothing, not found
2992
+ * nothing wrong.
3027
2993
  */
3028
- const reviewedBy = (runs, deciding) => deciding.length > 0 && deciding.every((runner) => runs.some((run) => run.runner === runner && reportedBy(run) !== null));
3029
- /** The findings at one head that withhold the stamp, from the runners that decide it. */
3030
- const blockingIn = (runs, deciding, blocksOn) => runs.filter((run) => deciding.includes(run.runner)).flatMap((run) => {
3031
- const found = reportedBy(run);
2994
+ const reviewedBy = (run) => run !== null && reportedBy(run) !== null;
2995
+ /** The findings at one head that withhold the stamp. */
2996
+ const blockingIn = (run, blocksOn) => {
2997
+ const found = run === null ? null : reportedBy(run);
3032
2998
  return found === null ? [] : blocking(found.findings, blocksOn);
3033
- });
2999
+ };
3034
3000
  /**
3035
3001
  * What the review runs on `head` say about it, for the stamp to rest on.
3036
3002
  *
@@ -3040,20 +3006,15 @@ const blockingIn = (runs, deciding, blocksOn) => runs.filter((run) => deciding.i
3040
3006
  * report findings does not count either: its verdict is what takes a pull
3041
3007
  * request out of Needs review run, and it reached none.
3042
3008
  *
3043
- * A head may carry a run from each configured runner, and it is reviewed once
3044
- * every runner that decides my bar has reported on it. A second opinion's
3045
- * findings are read here only where `stamp.supporting_blocks` lets them block.
3046
- *
3047
3009
  * It is one function because the two callers are a sweep and `dw-mc merge`, and
3048
3010
  * the second exists to land what the first only describes: two spellings of
3049
3011
  * this would be two answers to whether a head has been reviewed.
3050
3012
  */
3051
3013
  const reviewedAt = Effect.fn("review.reviewedAt")(function* (repo, number, head, settings) {
3052
- const deciding = decidingIn(settings.review.runners, settings.stamp.supporting_blocks);
3053
- const runs = yield* runsAt(repo, number, head);
3014
+ const run = Option.getOrNull(yield* runAt(repo, number, head));
3054
3015
  return {
3055
- reviewRunHead: reviewedBy(runs, deciding) ? head : null,
3056
- blockingFindings: blockingIn(runs, deciding, settings.stamp.blocks_on).length
3016
+ reviewRunHead: reviewedBy(run) ? head : null,
3017
+ blockingFindings: blockingIn(run, settings.stamp.blocks_on).length
3057
3018
  };
3058
3019
  });
3059
3020
  //#endregion
@@ -3247,9 +3208,8 @@ const separated = (blocks) => blocks.flatMap((lines, index) => index === 0 ? lin
3247
3208
  * The conversation on screen: people first, then a rule, then the bots.
3248
3209
  *
3249
3210
  * The rule is there so the two are never read as one list. A bot's comment is
3250
- * observed and never answered, which is the same asymmetry between a deciding
3251
- * and a supporting runner the glossary already draws, and the bucket rules
3252
- * ignore bots for exactly this reason.
3211
+ * observed and never answered, and the bucket rules ignore bots for exactly
3212
+ * this reason.
3253
3213
  *
3254
3214
  * A bot is cut at the same moment I am measured against, because the window is
3255
3215
  * what has happened since I last acted rather than what is owed an answer. A
@@ -3312,7 +3272,6 @@ const comments = Command.make("comments", {
3312
3272
  //#region src/cli/findings.ts
3313
3273
  /** The findings as the JSON the schema defines, rather than as this file spells it. */
3314
3274
  const asJson$2 = Schema.encodeEffect(Schema.fromJsonString(Findings));
3315
- const runnerFlag$1 = Flag.Literals("runner", [...runners]).pipe(Flag.withDescription("Which runner's findings to print, over the first the repository configured"), Flag.optional);
3316
3275
  const jsonFlag = Flag.Boolean("json").pipe(Flag.withDefault(false), Flag.withDescription("Print the findings as the JSON a fix session is handed"));
3317
3276
  /** What a run's findings come to in one line, against the bar that blocks. */
3318
3277
  const summary = (found, blocksOn) => {
@@ -3321,10 +3280,10 @@ const summary = (found, blocksOn) => {
3321
3280
  return `${count(found.findings.length, "finding")}, ${blocked} blocking`;
3322
3281
  };
3323
3282
  /** Which run these findings are, and what they come to: the line above the list. */
3324
- const header$1 = (run, found, blocksOn) => `${run.repo}#${run.number} ${short(run.head)} ${run.runner} ${summary(found, blocksOn)}`;
3283
+ const header$1 = (run, found, blocksOn) => `${run.repo}#${run.number} ${short(run.head)} ${summary(found, blocksOn)}`;
3325
3284
  /**
3326
- * The findings one to a line, in the order the runner reported them, ruled so
3327
- * the three columns read apart.
3285
+ * The findings one to a line, in the order the run reported them, ruled so the
3286
+ * three columns read apart.
3328
3287
  */
3329
3288
  const lines$1 = (found) => table(found.findings.map((finding) => [
3330
3289
  `${finding.file}:${finding.line}`,
@@ -3339,17 +3298,10 @@ const lines$1 = (found) => table(found.findings.map((finding) => [
3339
3298
  * off the state directory rather than worked out from GitHub: this command is
3340
3299
  * one I run inside a fix session, where another round trip to GitHub buys
3341
3300
  * nothing the run it is about to fix does not already say.
3342
- *
3343
- * Which run, where a head carries more than one, is the first of `runners` that
3344
- * has one - the order the repository names them in, unless I name one myself.
3345
- * A second opinion is worth reading and is not what I fix by default.
3346
3301
  */
3347
- const currentRun = Effect.fn("findings.currentRun")(function* (repo, number, configured) {
3348
- for (const runner of runnersFor(configured)) {
3349
- const run = yield* lastRun(repo, number, runner);
3350
- if (Option.isSome(run)) return run.value;
3351
- }
3352
- return yield* asUserError(`No review run on ${repo}#${number}. Run dw-mc review ${number} first.`);
3302
+ const currentRun = Effect.fn("findings.currentRun")(function* (repo, number) {
3303
+ const run = yield* lastRun(repo, number);
3304
+ return Option.isSome(run) ? run.value : yield* asUserError(`No review run on ${repo}#${number}. Run dw-mc review ${number} first.`);
3353
3305
  });
3354
3306
  /**
3355
3307
  * What the run reported, or the sentence saying it reported nothing at all.
@@ -3374,16 +3326,12 @@ const whatItFound = (run) => {
3374
3326
  */
3375
3327
  const findings = Command.make("findings", {
3376
3328
  pr: prArgument,
3377
- runner: runnerFlag$1,
3378
3329
  json: jsonFlag
3379
- }, Effect.fn("findings")(function* ({ json, pr, runner }) {
3330
+ }, Effect.fn("findings")(function* ({ json, pr }) {
3380
3331
  const file = Option.getOrElse(yield* read, () => ({}));
3381
3332
  const { number, repo } = yield* named(pr, Object.keys(file.repos ?? {}).toSorted());
3382
3333
  const settings = settingsFor(file, repo);
3383
- const run = yield* currentRun(repo, number, Option.match(runner, {
3384
- onNone: () => settings.review.runners,
3385
- onSome: (only) => [only]
3386
- }));
3334
+ const run = yield* currentRun(repo, number);
3387
3335
  const found = yield* whatItFound(run);
3388
3336
  if (json) {
3389
3337
  yield* Console.log(yield* asJson$2(found));
@@ -3395,19 +3343,20 @@ const findings = Command.make("findings", {
3395
3343
  //#endregion
3396
3344
  //#region src/adapters/agent.ts
3397
3345
  /**
3398
- * How one turn of an agent CLI is spawned and given up on, and what a turn of
3399
- * the tool's own review prompt comes back with.
3346
+ * How one turn of Claude Code is spawned and given up on, and what a turn that
3347
+ * answers against a schema comes back with.
3400
3348
  *
3401
- * Both agent CLIs are reached this way, so the spawn, the patience and the one
3402
- * failure they can end in live here rather than once per CLI.
3349
+ * Every turn is reached this way, so the spawn, the patience and the one failure
3350
+ * they can end in live here rather than once per turn.
3403
3351
  */
3404
3352
  /** A review run that would not start, would not finish, or finished badly. */
3405
- var RunnerFailed = class extends Schema.TaggedError()("RunnerFailed", {
3406
- runner: Schema.String,
3353
+ var AgentFailed = class extends Schema.TaggedError()("AgentFailed", {
3354
+ /** The program that was spawned, which is what a search for it has to name. */
3355
+ program: Schema.String,
3407
3356
  detail: Schema.String
3408
3357
  }) {
3409
3358
  get message() {
3410
- return `The ${this.runner} review run failed: ${this.detail}`;
3359
+ return `The ${this.program} review run failed: ${this.detail}`;
3411
3360
  }
3412
3361
  };
3413
3362
  /**
@@ -3417,15 +3366,15 @@ var RunnerFailed = class extends Schema.TaggedError()("RunnerFailed", {
3417
3366
  * program that would not start or exited badly, and saying `claude` sends the
3418
3367
  * search to the wrong process.
3419
3368
  */
3420
- const failedBy = (program) => (detail) => new RunnerFailed({
3421
- runner: program,
3369
+ const failedBy = (program) => (detail) => new AgentFailed({
3370
+ program,
3422
3371
  detail
3423
3372
  });
3424
3373
  /**
3425
3374
  * How long each turn gets before it is given up on.
3426
3375
  *
3427
3376
  * The review is the turn that thinks, and a high-effort one that fans out to
3428
- * subagents takes real minutes, so its limit is there to catch a runner that has
3377
+ * subagents takes real minutes, so its limit is there to catch a run that has
3429
3378
  * stopped rather than one that is slow. The second turn reads no code and
3430
3379
  * decides nothing - the review it reports on is already in the session it
3431
3380
  * resumes - and every run of it by hand came back in seconds.
@@ -3442,9 +3391,9 @@ const patience = {
3442
3391
  *
3443
3392
  * The launcher's own arguments go in front of the turn's, because they are what
3444
3393
  * gets `claude` started at all. The two output streams are drained together,
3445
- * because draining one to the end first can block a runner that is still writing
3446
- * to the other. Every way a turn can fail to finish comes back from here as a
3447
- * `RunnerFailed`, so a caller is left with the turn's own answer and nothing else
3394
+ * because draining one to the end first can block a run that is still writing to
3395
+ * the other. Every way a turn can fail to finish comes back from here as a
3396
+ * `AgentFailed`, so a caller is left with the turn's own answer and nothing else
3448
3397
  * to translate - a turn that never comes back included.
3449
3398
  */
3450
3399
  const turn = Effect.fnUntraced(function* (options) {
@@ -3454,7 +3403,7 @@ const turn = Effect.fnUntraced(function* (options) {
3454
3403
  const running = Effect.gen(function* () {
3455
3404
  const handle = yield* Effect.mapError(spawner.spawn(ChildProcess.make(program, [...prefix, ...options.args], {
3456
3405
  cwd: options.directory,
3457
- stdin: options.stdin ?? "pipe"
3406
+ stdin: "pipe"
3458
3407
  })), (error) => failed(error.message));
3459
3408
  const [got, stderr] = yield* Effect.mapError(Effect.all([options.read(handle.stdout), Stream.mkString(Stream.decodeText(handle.stderr))], { concurrency: 2 }), (error) => failed(error.message));
3460
3409
  const exitCode = yield* Effect.mapError(handle.exitCode, (error) => failed(error.message));
@@ -3467,118 +3416,10 @@ const turn = Effect.fnUntraced(function* (options) {
3467
3416
  });
3468
3417
  });
3469
3418
  //#endregion
3470
- //#region src/adapters/codex.ts
3471
- /**
3472
- * The events of a `codex exec --json` run this reads, as it really writes them.
3473
- *
3474
- * Every other field and every other event is ignored, the same way the Claude
3475
- * Code transcript is read: a run carries reasoning, tool results and a usage
3476
- * report, and a Codex version that adds another event must not stop a run from
3477
- * being read.
3478
- */
3479
- const CodexEvent = Schema.Struct({
3480
- type: Schema.String,
3481
- /** On `thread.started`: the session the run happens in. */
3482
- thread_id: Schema.optionalKey(Schema.String),
3483
- item: Schema.optionalKey(Schema.Struct({
3484
- type: Schema.String,
3485
- /** On an `agent_message`: what the run answered, which the schema makes JSON. */
3486
- text: Schema.optionalKey(Schema.String)
3487
- }))
3488
- });
3489
- const asCodexEvent = Schema.decodeUnknownOption(Schema.fromJsonString(CodexEvent));
3490
- /**
3491
- * What Codex reached for, as the item types it reports it in.
3492
- *
3493
- * A run says it is alive by what it does, and what Codex does is run shell
3494
- * commands, change files and call tools. Its own words for those are what the
3495
- * progress line prints, so nothing here has to pretend Codex is Claude Code.
3496
- */
3497
- const codexTool = {
3498
- command_execution: "shell",
3499
- file_change: "edit",
3500
- mcp_tool_call: "tool",
3501
- web_search: "search"
3502
- };
3503
- /**
3504
- * One review run of the same prompt on the Codex CLI, in `directory`.
3505
- *
3506
- * This is the second opinion, so it answers the same schema and is recorded as
3507
- * the same review run: everything downstream - the stamp, the buckets, `dw-mc
3508
- * findings`, a fix session - cannot tell which CLI read the code.
3509
- *
3510
- * Codex takes its schema as a file and never inline, which is the mirror image
3511
- * of Claude Code, so the schema is written to a temporary file that lives as
3512
- * long as the run. Standard input is closed because `codex exec` reads it to
3513
- * the end and appends it to the prompt. The sandbox is read-only: a review
3514
- * reads, and a review run of mine has no business writing in the worktree it
3515
- * was cut into.
3516
- *
3517
- * There is no prose: with a schema in force every message Codex sends is the
3518
- * JSON the schema describes, so what it found is all there is, and the report
3519
- * kept beside the run is written from the findings themselves.
3520
- */
3521
- const codexReview = Effect.fn("runner.codexReview")(function* (options) {
3522
- const [program] = options.launcher.codex;
3523
- const failed = failedBy(program);
3524
- const fs = yield* FileSystem.FileSystem;
3525
- const schemaFile = yield* Effect.mapError(fs.makeTempFileScoped({
3526
- prefix: "dw-mc-findings-",
3527
- suffix: ".json"
3528
- }), (error) => failed(error.message));
3529
- yield* Effect.mapError(fs.writeFileString(schemaFile, options.jsonSchema), (error) => failed(error.message));
3530
- const run = yield* turn({
3531
- command: options.launcher.codex,
3532
- directory: options.directory,
3533
- patience: {
3534
- turn: "the review",
3535
- duration: patience.reviewing
3536
- },
3537
- stdin: "ignore",
3538
- args: [
3539
- "exec",
3540
- "--json",
3541
- "--output-schema",
3542
- schemaFile,
3543
- "--sandbox",
3544
- "read-only",
3545
- ...options.model === null ? [] : ["--model", options.model],
3546
- options.prompt
3547
- ],
3548
- read: (stdout) => stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.mapEffect((line) => {
3549
- const event = asCodexEvent(line);
3550
- const reached = Option.isSome(event) && event.value.type === "item.started" ? codexTool[event.value.item?.type ?? ""] : void 0;
3551
- return Effect.as(reached === void 0 ? Effect.void : options.onTool(reached), event);
3552
- }), Stream.runFold(() => ({
3553
- thread: Option.none(),
3554
- said: []
3555
- }), (soFar, event) => {
3556
- if (Option.isNone(event)) return soFar;
3557
- const { item, thread_id, type } = event.value;
3558
- const said = type === "item.completed" && item?.type === "agent_message" && item.text !== void 0 ? [...soFar.said, item.text] : soFar.said;
3559
- return {
3560
- thread: Option.orElse(Option.fromUndefinedOr(thread_id), () => soFar.thread),
3561
- said
3562
- };
3563
- }))
3564
- });
3565
- if (Option.isNone(run.thread)) return yield* failed("the run never said which thread it was in");
3566
- const answer = run.said.at(-1);
3567
- if (answer === void 0) return yield* failed("the run came back with no structured output");
3568
- return {
3569
- findings: yield* Effect.try({
3570
- try: () => JSON.parse(answer),
3571
- catch: () => failed("the run answered in something that is not JSON")
3572
- }),
3573
- sessionId: run.thread.value,
3574
- prose: null
3575
- };
3576
- }, Effect.scoped);
3577
- //#endregion
3578
- //#region src/adapters/runner.ts
3419
+ //#region src/adapters/claude.ts
3579
3420
  /**
3580
- * Claude Code as a runner: its own review command, the tool's own prompt, and
3581
- * the sessions I steer.
3421
+ * Claude Code: a review on a slash command, a review on the tool's own prompt,
3422
+ * and the sessions I steer.
3582
3423
  */
3583
3424
  /**
3584
3425
  * The two events of a stream-json run this reads, as the runner really writes
@@ -3594,7 +3435,7 @@ const Working = Schema.Struct({
3594
3435
  text: Schema.optionalKey(Schema.String)
3595
3436
  })) })
3596
3437
  });
3597
- const Result$1 = Schema.Struct({
3438
+ const Ended = Schema.Struct({
3598
3439
  type: Schema.Literal("result"),
3599
3440
  subtype: Schema.String,
3600
3441
  is_error: Schema.Boolean,
@@ -3604,7 +3445,7 @@ const Result$1 = Schema.Struct({
3604
3445
  structured_output: Schema.optionalKey(Schema.Unknown)
3605
3446
  });
3606
3447
  const asWorking = Schema.decodeUnknownOption(Schema.fromJsonString(Working));
3607
- const asResult = Schema.decodeUnknownOption(Schema.fromJsonString(Result$1));
3448
+ const asResult = Schema.decodeUnknownOption(Schema.fromJsonString(Ended));
3608
3449
  const heardIn = (line) => {
3609
3450
  const blocks = Option.match(asWorking(line), {
3610
3451
  onNone: () => [],
@@ -3618,7 +3459,7 @@ const heardIn = (line) => {
3618
3459
  /**
3619
3460
  * The result a turn ended on, or the failure it really was.
3620
3461
  *
3621
- * A turn that said nothing this can read and a turn the runner itself calls an
3462
+ * A turn that said nothing this can read and a turn Claude Code itself calls an
3622
3463
  * error are both failures: `subtype` is where a run that hit its turn limit or
3623
3464
  * lost its connection says so, and its `result` is the only word on why.
3624
3465
  */
@@ -3633,8 +3474,8 @@ const ended = (program, result) => {
3633
3474
  * to `onTool` while the run is still going, and what it said and how it ended
3634
3475
  * are what comes back.
3635
3476
  *
3636
- * Both of Claude Code's runners read a turn the same way, so the fold is here
3637
- * rather than once per runner.
3477
+ * Both shapes of review read a turn the same way, so the fold is here rather
3478
+ * than once per shape.
3638
3479
  */
3639
3480
  const transcript = (onTool) => (stdout) => stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.mapEffect((line) => {
3640
3481
  const heard = heardIn(line);
@@ -3650,30 +3491,38 @@ const transcript = (onTool) => (stdout) => stdout.pipe(Stream.decodeText(), Stre
3650
3491
  result: Option.orElse(asResult(line), () => soFar.result)
3651
3492
  })));
3652
3493
  /**
3653
- * One review run of Claude Code's own code review, headless, in `directory`.
3494
+ * One review run on a slash command, headless, in `directory`.
3654
3495
  *
3655
3496
  * The run is in the foreground and says what it is doing as it does it, which
3656
3497
  * is what `onTool` is for: a review takes minutes, and a terminal that prints
3657
3498
  * nothing for minutes is one I stop trusting.
3658
3499
  *
3500
+ * `--json-schema` is never passed here: verified by running it, the flag beside
3501
+ * `/code-review` breaks the run, which is why a slash command costs a second
3502
+ * turn that resumes the session and asks for the findings. My own instructions
3503
+ * ride on `--append-system-prompt` rather than on the command's own line,
3504
+ * because what a slash command does with its arguments is its business and not
3505
+ * this tool's.
3506
+ *
3659
3507
  * `--comment` is the flag that makes the built-in review post on the pull
3660
- * request, and it is never passed (ADR 0002). The report is everything the
3661
- * runner said on its own turns rather than the `result` alone: verified by
3662
- * running it, a repository whose review command fans out to subagents can end
3663
- * on a remark about them, and the report is the turn before that. The follow-up
3664
- * turn is what turns the prose into findings, and it needs this run's session.
3508
+ * request, and it is never passed either (ADR 0002). The report is everything
3509
+ * the run said on its own turns rather than the `result` alone: verified by
3510
+ * running it, a repository whose review command fans out to subagents can end on
3511
+ * a remark about them, and the report is the turn before that.
3665
3512
  */
3666
- const builtinReview = Effect.fn("runner.builtinReview")(function* (options) {
3513
+ const commandReview = Effect.fn("claude.commandReview")(function* (options) {
3667
3514
  const [program] = options.launcher.command;
3668
3515
  const run = yield* turn({
3669
3516
  command: options.launcher.command,
3670
3517
  directory: options.directory,
3671
3518
  args: [
3672
3519
  "-p",
3673
- `/code-review ${options.effort}`,
3520
+ options.line,
3674
3521
  "--output-format",
3675
3522
  "stream-json",
3676
- "--verbose"
3523
+ "--verbose",
3524
+ ...options.instructions === null ? [] : ["--append-system-prompt", options.instructions],
3525
+ ...options.model === null ? [] : ["--model", options.model]
3677
3526
  ],
3678
3527
  patience: {
3679
3528
  turn: "the review",
@@ -3712,12 +3561,12 @@ const reportFindings = [
3712
3561
  * is what makes it cheap and what makes it accurate - verified by running it,
3713
3562
  * the line numbers it reports beat the ones the prose gives. The output is
3714
3563
  * handed on as it arrived: what the findings must look like belongs to the
3715
- * domain, and the schema the runner is held to comes in from there too.
3564
+ * domain, and the schema the run is held to comes in from there too.
3716
3565
  *
3717
- * Every way this can end badly ends as a `RunnerFailed`, because a review run
3566
+ * Every way this can end badly ends as an `AgentFailed`, because a review run
3718
3567
  * that could not report is a failure and never a clean verdict.
3719
3568
  */
3720
- const builtinFindings = Effect.fn("runner.builtinFindings")(function* (options) {
3569
+ const findingsTurn = Effect.fn("claude.findingsTurn")(function* (options) {
3721
3570
  const [program] = options.launcher.command;
3722
3571
  const printed = yield* turn({
3723
3572
  command: options.launcher.command,
@@ -3743,19 +3592,15 @@ const builtinFindings = Effect.fn("runner.builtinFindings")(function* (options)
3743
3592
  return structured_output;
3744
3593
  }, Effect.scoped);
3745
3594
  /**
3746
- * One review run of the tool's own review prompt on Claude Code, in `directory`.
3747
- *
3748
- * It is one turn rather than two: verified by running it, `--json-schema`
3749
- * beside an ordinary prompt gives both the prose the run wrote and the
3750
- * `structured_output` it validated, where the same flag on the built-in
3751
- * `/code-review` breaks the run. The schema arrives as inline JSON and never as
3752
- * a path - a path is where Claude Code reports `--json-schema is not valid
3753
- * JSON`.
3595
+ * One review run of the tool's own review prompt, in `directory`.
3754
3596
  *
3755
- * `review.model` reaches the run here, and nowhere in the built-in runner: the
3756
- * prompt is the tool's, so which model reads the code is mine to choose.
3597
+ * It is one turn rather than two: verified by running it, `--json-schema` beside
3598
+ * an ordinary prompt gives both the prose the run wrote and the
3599
+ * `structured_output` it validated, where the same flag on a slash command
3600
+ * breaks the run. The schema arrives as inline JSON and never as a path - a path
3601
+ * is where Claude Code reports `--json-schema is not valid JSON`.
3757
3602
  */
3758
- const promptReview = Effect.fn("runner.promptReview")(function* (options) {
3603
+ const promptReview = Effect.fn("claude.promptReview")(function* (options) {
3759
3604
  const [program] = options.launcher.command;
3760
3605
  const run = yield* turn({
3761
3606
  command: options.launcher.command,
@@ -3786,13 +3631,53 @@ const promptReview = Effect.fn("runner.promptReview")(function* (options) {
3786
3631
  };
3787
3632
  }, Effect.scoped);
3788
3633
  /**
3789
- * The tool's own review prompt, on whichever CLI the runner names.
3634
+ * One review run, in whichever shape it was configured in.
3635
+ *
3636
+ * A slash command takes two turns and the tool's own prompt takes one, which is
3637
+ * Claude Code's doing and nobody else's: a caller hands over the turn and gets
3638
+ * the same answer back either way.
3790
3639
  *
3791
- * The two CLIs are spawned differently and answer differently, and which of
3792
- * them a runner means is the adapter's knowledge: a caller hands over the
3793
- * runner and gets the same `Reported` back either way.
3640
+ * The second turn's failure is kept beside the first turn's prose rather than
3641
+ * replacing it. A review that ran and could not report is still worth reading,
3642
+ * and it is recorded as the failure it is.
3794
3643
  */
3795
- const promptRun = (options) => options.runner === "codex" ? codexReview(options) : promptReview(options);
3644
+ const reviewTurns = Effect.fn("claude.reviewTurns")(function* (options) {
3645
+ const { directory, jsonSchema, launcher, model, onTool } = options;
3646
+ if (options.turn._tag === "prompt") {
3647
+ const run = yield* promptReview({
3648
+ launcher,
3649
+ directory,
3650
+ prompt: options.turn.text,
3651
+ model,
3652
+ jsonSchema,
3653
+ onTool
3654
+ });
3655
+ return {
3656
+ sessionId: run.sessionId,
3657
+ prose: run.prose,
3658
+ findings: Result.succeed(run.findings)
3659
+ };
3660
+ }
3661
+ const run = yield* commandReview({
3662
+ launcher,
3663
+ directory,
3664
+ line: options.turn.line,
3665
+ instructions: options.turn.instructions,
3666
+ model,
3667
+ onTool
3668
+ });
3669
+ const findings = yield* Effect.result(findingsTurn({
3670
+ launcher,
3671
+ directory,
3672
+ sessionId: run.sessionId,
3673
+ jsonSchema
3674
+ }));
3675
+ return {
3676
+ sessionId: run.sessionId,
3677
+ prose: run.report,
3678
+ findings
3679
+ };
3680
+ });
3796
3681
  /**
3797
3682
  * An interactive `claude` in `directory`, opened on `prompt`, with my terminal
3798
3683
  * handed straight to it.
@@ -3802,7 +3687,7 @@ const promptRun = (options) => options.runner === "codex" ? codexReview(options)
3802
3687
  * no headless review turn wants. They sit in front of the
3803
3688
  * prompt, because `claude` takes its flags before its positional argument.
3804
3689
  *
3805
- * This is the one place a runner is not read: the three streams are inherited,
3690
+ * This is the one place a run is not read: the three streams are inherited,
3806
3691
  * so what is on the screen is the session itself and not a transcript of it,
3807
3692
  * and what I type reaches it. The child is not detached for the same reason -
3808
3693
  * a detached child sits outside the terminal's foreground process group, where
@@ -3815,7 +3700,7 @@ const promptRun = (options) => options.runner === "codex" ? codexReview(options)
3815
3700
  * Ctrl-C ended badly for `claude` and not for me, so this reports it rather
3816
3701
  * than failing on it; only a `claude` that would not start at all is a failure.
3817
3702
  */
3818
- const steeredSession = Effect.fn("runner.steeredSession")(function* (options) {
3703
+ const steeredSession = Effect.fn("claude.steeredSession")(function* (options) {
3819
3704
  const [program, ...prefix] = options.launcher.command;
3820
3705
  const failed = failedBy(program);
3821
3706
  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
@@ -3860,7 +3745,7 @@ const asJson$1 = Schema.encodeEffect(Schema.fromJsonString(Selection));
3860
3745
  *
3861
3746
  * The findings go in verbatim rather than described, because a re-description
3862
3747
  * is where a file, a line or my own note quietly changes. A note outranks the
3863
- * finding it is on: the finding is what a runner thought, the note is what I
3748
+ * finding it is on: the finding is what the review thought, the note is what I
3864
3749
  * think, and I am the one who picked it.
3865
3750
  *
3866
3751
  * Pushing is mine either way, and `commits` says whether committing is too.
@@ -3953,7 +3838,7 @@ const fix = Command.make("fix", {
3953
3838
  const file = Option.getOrElse(yield* read, () => ({}));
3954
3839
  const { number, repo } = yield* named(pr, Object.keys(file.repos ?? {}).toSorted());
3955
3840
  const settings = settingsFor(file, repo);
3956
- const run = yield* currentRun(repo, number, settings.review.runners);
3841
+ const run = yield* currentRun(repo, number);
3957
3842
  const found = yield* whatItFound(run);
3958
3843
  yield* Console.log(header$1(run, found, settings.stamp.blocks_on));
3959
3844
  if (found.findings.length === 0) return;
@@ -3995,105 +3880,64 @@ const fix = Command.make("fix", {
3995
3880
  ...userFacing,
3996
3881
  "GitFailed",
3997
3882
  "WorktreeHeld",
3998
- "RunnerFailed"
3883
+ "AgentFailed"
3999
3884
  ], asUserError))).pipe(Command.withDescription("Pick findings from the current review run and open a fix session on them"));
4000
3885
  //#endregion
4001
3886
  //#region src/cli/init.ts
4002
- const runnerFlag = Flag.Literals("runner", [...runners]).pipe(Flag.withDescription("Which runner is my bar, on this machine"), Flag.optional);
4003
- const codexFlag = Flag.Boolean("codex").pipe(Flag.withDescription("Ask Codex for a second opinion beside the runner that is my bar"), Flag.optional);
4004
3887
  const effortFlag$1 = Flag.Literals("effort", [
4005
3888
  "low",
4006
3889
  "medium",
4007
- "high"
4008
- ]).pipe(Flag.withDescription("How much a built-in review run spends on this repository"), Flag.optional);
3890
+ "high",
3891
+ "xhigh",
3892
+ "max"
3893
+ ]).pipe(Flag.withDescription("How much a review run spends on this repository"), Flag.optional);
4009
3894
  const baseFlag = Flag.String("base").pipe(Flag.withDescription("The branch this repository's pull requests target, over the default one"), Flag.optional);
4010
- const askRunner = Prompt.Select({
4011
- message: "Which runner is your bar?",
4012
- choices: [
4013
- {
4014
- title: "builtin",
4015
- value: "builtin",
4016
- description: "Claude Code's own code review"
4017
- },
4018
- {
4019
- title: "prompt",
4020
- value: "prompt",
4021
- description: "The tool's own review prompt, on Claude Code"
4022
- },
4023
- {
4024
- title: "codex",
4025
- value: "codex",
4026
- description: "The tool's own review prompt, on the Codex CLI"
4027
- }
4028
- ]
4029
- });
4030
- /**
4031
- * The second question, and only where the first did not already answer it.
4032
- *
4033
- * Codex is the second opinion, so it is not one more thing in the list of what
4034
- * my bar could be: it is a runner that runs beside it. A machine whose bar is
4035
- * Codex has nothing left to ask.
4036
- */
4037
- const askSecondOpinion = Prompt.Confirm({
4038
- message: "Also ask Codex for a second opinion on every review run?",
4039
- initial: false
4040
- });
4041
- /**
4042
- * What the file should name: the runner that is my bar, and Codex beside it
4043
- * where I asked for one.
4044
- *
4045
- * Codex alone is the review rather than a second opinion, which is the rule
4046
- * `#domain/review.ts` draws too, so it is never listed twice.
4047
- */
4048
- const listing = (bar, secondOpinion) => bar === "codex" || !secondOpinion ? [bar] : [bar, "codex"];
4049
- const noRunnerChosen = "No runner chosen, so nothing was written. Pass --runner builtin, --runner prompt or --runner codex to choose without the prompt, and --codex for a second opinion beside it.";
4050
3895
  /** The settings the flags asked for, and only those. */
4051
3896
  const asked = (base, effort) => ({
4052
3897
  ...Option.isSome(base) ? { base: base.value } : {},
4053
3898
  ...Option.isSome(effort) ? { review: { effort: effort.value } } : {}
4054
3899
  });
3900
+ /** What a review will open on, as the setup prints it back. */
3901
+ const opening = (defaults) => {
3902
+ const review = {
3903
+ ...builtIn.review,
3904
+ ...defaults.review
3905
+ };
3906
+ return review.command === null ? "my own prompt" : [review.command, review.effort].filter((part) => part !== null).join(" ");
3907
+ };
4055
3908
  const row = (label, value) => `${label.padEnd(12)}${value}`;
4056
3909
  /**
4057
3910
  * Both the machine setup and the repository registration: there is deliberately
4058
3911
  * no separate `setup` command.
4059
3912
  *
4060
- * The first run on a machine checks `gh`, settles the runner and spells the
4061
- * defaults out in the configuration file. Run inside a repository, it also
4062
- * registers that `owner/repo`, taking the name from `gh` so I never type it.
4063
- * Run again, it changes what the flags name, keeps every other setting the file
4064
- * already had, and leaves the file untouched where nothing was decided
4065
- * differently.
3913
+ * The first run on a machine checks `gh` and spells the defaults out in the
3914
+ * configuration file. Run inside a repository, it also registers that
3915
+ * `owner/repo`, taking the name from `gh` so I never type it. Run again, it
3916
+ * changes what the flags name, keeps every other setting the file already had,
3917
+ * and leaves the file untouched where nothing was decided differently.
4066
3918
  *
4067
- * `--runner` is a choice about this machine, so it lands in the global
4068
- * defaults. `--effort` and `--base` are about one repository, so they land on
4069
- * the repository this ran in, or in the defaults when it ran outside one.
3919
+ * It asks nothing. Reviews run on Claude Code, and what a run opens on is
3920
+ * `review.command` and `review.prompt` - a line and a paragraph that belong in
3921
+ * the file rather than in a terminal prompt.
3922
+ *
3923
+ * `--effort` and `--base` are about one repository, so they land on the
3924
+ * repository this ran in, or in the defaults when it ran outside one.
4070
3925
  */
4071
3926
  const init = Command.make("init", {
4072
- runner: runnerFlag,
4073
- codex: codexFlag,
4074
3927
  effort: effortFlag$1,
4075
3928
  base: baseFlag
4076
- }, Effect.fn("init")(function* ({ base, codex, effort, runner }) {
3929
+ }, Effect.fn("init")(function* ({ base, effort }) {
4077
3930
  yield* requireAuth;
4078
3931
  const config = yield* ConfigStore;
4079
3932
  const before = yield* read;
4080
3933
  const file = Option.getOrElse(before, () => ({}));
4081
- const firstRun = file.defaults?.review?.runners === void 0;
4082
- const chosen = Option.isSome(runner) ? Option.some(runner.value) : firstRun ? Option.some(yield* askRunner) : Option.none();
4083
- const listed = file.defaults?.review?.runners ?? builtIn.review.runners;
4084
- const bar = Option.getOrElse(chosen, () => listed.find((it) => it !== "codex") ?? "builtin");
4085
- const secondOpinion = firstRun && bar !== "codex" && Option.isNone(runner) && Option.isNone(codex) ? Option.some(yield* askSecondOpinion) : codex;
4086
- const decided = Option.isSome(chosen) || Option.isSome(secondOpinion);
4087
- const beside = Option.getOrElse(secondOpinion, () => listed.includes("codex"));
4088
- const inherited = firstRun ? merge$1(builtIn, file.defaults ?? {}) : file.defaults ?? {};
4089
- const defaults = merge$1(inherited, decided ? { review: { runners: listing(bar, beside) } } : {});
3934
+ const defaults = file.defaults === void 0 ? merge$1(builtIn, file.defaults ?? {}) : file.defaults ?? {};
4090
3935
  const state = yield* stateDirectory;
4091
3936
  const repo = yield* currentRepo.pipe(Effect.asSome, Effect.catchTag("NoRepository", () => Effect.succeedNone));
4092
3937
  const overrides = asked(base, effort);
4093
3938
  const written = Option.isSome(repo) ? withRepo(withDefaults(file, defaults), repo.value, overrides) : withDefaults(file, merge$1(defaults, overrides));
4094
3939
  if (encode(written) !== encode(file) || Option.isNone(before)) yield* write(written);
4095
- const settled = written.defaults?.review?.runners ?? builtIn.review.runners;
4096
- yield* Console.log(row("runner", settled.join(", ")));
3940
+ yield* Console.log(row("review", opening(written.defaults ?? {})));
4097
3941
  yield* Console.log(row("config", config.path));
4098
3942
  yield* Console.log(row("state", state));
4099
3943
  yield* Console.log(Option.isNone(repo) ? row("repository", "none here - run dw-mc init inside a repository to register it") : row("repository", `${repo.value} (${file.repos?.[repo.value] === void 0 ? "registered" : "already registered"})`));
@@ -4101,12 +3945,8 @@ const init = Command.make("init", {
4101
3945
  "ConfigMalformed",
4102
3946
  "GhUnauthenticated",
4103
3947
  "GhUnavailable",
4104
- "GhUnreadable",
4105
- "QuitError"
4106
- ], (cause) => Effect.fail(cause._tag === "QuitError" ? new CliError.UserError({
4107
- cause,
4108
- userMessage: noRunnerChosen
4109
- }) : new CliError.UserError({ cause }))))).pipe(Command.withDescription("Set this machine up and register the repository I am in"));
3948
+ "GhUnreadable"
3949
+ ], asUserError))).pipe(Command.withDescription("Set this machine up and register the repository I am in"));
4110
3950
  //#endregion
4111
3951
  //#region src/domain/stamp.ts
4112
3952
  /**
@@ -4840,7 +4680,7 @@ const resolve = Command.make("resolve", {
4840
4680
  ...userFacing,
4841
4681
  "GitFailed",
4842
4682
  "WorktreeHeld",
4843
- "RunnerFailed"
4683
+ "AgentFailed"
4844
4684
  ], asUserError))).pipe(Command.withDescription("Open a session on the conflict that stopped a rebase, in a worktree of my own"));
4845
4685
  //#endregion
4846
4686
  //#region src/adapters/notify.ts
@@ -4930,14 +4770,6 @@ const spinning = Effect.fnUntraced(function* (reads, use) {
4930
4770
  //#endregion
4931
4771
  //#region src/domain/persona.ts
4932
4772
  /**
4933
- * The review prompt the `prompt` runner owns.
4934
- *
4935
- * Where `builtin` drives the agent's own review command, this is a prompt the
4936
- * tool carries, so my bar is not one agent's idea of a code review. It is the
4937
- * same prompt on either CLI: what differs is how each one is handed the schema,
4938
- * which belongs to the runner adapter and not here.
4939
- */
4940
- /**
4941
4773
  * The reviewer persona, derived from Addy Osmani's `code-reviewer` agent
4942
4774
  * (`addyosmani/agent-skills`, MIT, see `NOTICE.md`).
4943
4775
  *
@@ -4983,18 +4815,18 @@ the task or the pull request description before the code. Every Critical and Req
4983
4815
  specific fix in its summary. Where you are uncertain, say so in the summary and say what would settle
4984
4816
  it, rather than guessing.`;
4985
4817
  /**
4986
- * The prompt one `prompt` review run opens on.
4818
+ * The prompt a review run with no slash command opens on.
4987
4819
  *
4988
4820
  * It says what to review and how to answer, and nothing about how the answer is
4989
- * validated: the schema arrives beside the prompt on both CLIs, so describing it
4990
- * here would be the same shape written twice.
4821
+ * validated: the schema arrives beside the prompt, so describing it here would
4822
+ * be the same shape written twice.
4991
4823
  *
4992
- * `review.skill` is a passthrough and goes in first, spelled exactly as the file
4993
- * spells it. A repository that has its own review skill gets that skill's review
4994
- * with the persona behind it, and the tool does not try to interpret the value.
4824
+ * `review.prompt` is a passthrough and goes in first, spelled exactly as the
4825
+ * file spells it. A repository with its own instructions gets its review with
4826
+ * the persona behind it, and the tool does not try to interpret the value.
4995
4827
  */
4996
4828
  const reviewPrompt = (reviewing) => [
4997
- ...reviewing.skill === null ? [] : [reviewing.skill, ""],
4829
+ ...reviewing.prompt === null ? [] : [reviewing.prompt, ""],
4998
4830
  persona,
4999
4831
  "",
5000
4832
  `The change is ${reviewing.repo}#${reviewing.number}, "${reviewing.title}".`,
@@ -5005,103 +4837,104 @@ const reviewPrompt = (reviewing) => [
5005
4837
  "line it is at, its severity and a one-sentence summary. The verdict is clean when there is",
5006
4838
  "nothing to report, and findings otherwise."
5007
4839
  ].join("\n");
4840
+ /**
4841
+ * What one review run opens on, decided by what the repository configured.
4842
+ *
4843
+ * A slash command is the review, so the persona stays out of its way and my own
4844
+ * instructions ride beside it. Without one the review is the tool's own, and my
4845
+ * instructions go in front of the persona. The effort word follows the command
4846
+ * because that is where a slash command takes its arguments; a repository that
4847
+ * spells its own arguments out sets `review.effort` to null and keeps the line.
4848
+ */
4849
+ const turnFor = (review, about) => review.command === null ? {
4850
+ _tag: "prompt",
4851
+ text: reviewPrompt({
4852
+ ...about,
4853
+ prompt: review.prompt
4854
+ })
4855
+ } : {
4856
+ _tag: "command",
4857
+ line: [review.command, review.effort].filter((part) => part !== null).join(" "),
4858
+ instructions: review.prompt
4859
+ };
5008
4860
  //#endregion
5009
4861
  //#region src/cli/review.ts
5010
4862
  /** What the spinner says a run has got through, while it is still going. */
5011
- const saying = (runner) => (doing, since) => [
5012
- `${runner} reviewing`,
4863
+ const saying = (doing, since) => [
4864
+ "reviewing",
5013
4865
  count(doing.tools, "tool"),
5014
4866
  doing.subagents === 0 ? null : count(doing.subagents, "subagent"),
5015
4867
  since
5016
4868
  ].filter((part) => part !== null).join(" · ");
4869
+ const commandFlag = Flag.String("command").pipe(Flag.withDescription("The slash command this run opens on, over what the repository configured"), Flag.optional);
4870
+ const promptFlag = Flag.String("prompt").pipe(Flag.withDescription("The review instructions this run carries, over what the repository configured"), Flag.optional);
5017
4871
  const effortFlag = Flag.Literals("effort", [
5018
4872
  "low",
5019
4873
  "medium",
5020
- "high"
4874
+ "high",
4875
+ "xhigh",
4876
+ "max"
5021
4877
  ]).pipe(Flag.withDescription("How much this run spends, over what the repository configured"), Flag.optional);
4878
+ const modelFlag = Flag.String("model").pipe(Flag.withDescription("The model this run reads the code on, over what the repository configured"), Flag.optional);
4879
+ const promptOnlyFlag = Flag.Boolean("prompt-only").pipe(Flag.withDefault(false), Flag.withDescription("Review on the prompt alone, whatever slash command the repository configured"));
4880
+ const commandOnlyFlag = Flag.Boolean("command-only").pipe(Flag.withDefault(false), Flag.withDescription("Review on the slash command alone, whatever instructions the repository configured"));
5022
4881
  const forceFlag$1 = Flag.Boolean("force").pipe(Flag.withDefault(false), Flag.withDescription("Review even where the re-run rule would skip it"));
5023
- /** The runners a review run executes, or the sentence saying there are none. */
5024
- const runnersOf = (configured) => {
5025
- const runners = runnersFor(configured);
5026
- return runners.length === 0 ? Effect.fail(new CliError.UserError({ cause: "This repository reviews on no runner at all. Set review.runners to one or more of builtin, prompt and codex." })) : Effect.succeed(runners);
4882
+ /** A flag that names a value beside the flag that clears it: one of the two, never both. */
4883
+ const opposite = (flag, given, only) => Option.isSome(given) ? [`--${flag} and --${only} say opposite things. Pass one.`] : [];
4884
+ /** What this run is asked, once the flags have had their say over the file. */
4885
+ const asking = (options) => {
4886
+ const clash = [...options.promptOnly ? opposite("command", options.command, "prompt-only") : [], ...options.commandOnly ? opposite("prompt", options.prompt, "command-only") : []];
4887
+ if (clash.length > 0) return Effect.fail(new CliError.UserError({ cause: clash.join(" ") }));
4888
+ const { review } = options.settings;
4889
+ return Effect.succeed({
4890
+ command: options.promptOnly ? null : Option.getOrElse(options.command, () => review.command),
4891
+ effort: Option.getOrElse(options.effort, () => review.effort),
4892
+ prompt: options.commandOnly ? null : Option.getOrElse(options.prompt, () => review.prompt),
4893
+ model: Option.getOrElse(options.model, () => review.model)
4894
+ });
5027
4895
  };
5028
4896
  /**
5029
4897
  * What the re-run rule is asked about, read before anything is cut or spawned:
5030
4898
  * the whole point of the rule is not paying for the run.
5031
4899
  *
5032
- * It is asked per runner, because a second opinion that has never seen this
5033
- * pull request is not skipped for the head the primary review already read.
5034
- *
5035
4900
  * GitHub is asked what changed only where there is a run to measure from and a
5036
4901
  * different head to measure to. Neither is the rule deciding anything - there is
5037
4902
  * simply nothing to compare - and a comparison GitHub would not answer comes
5038
4903
  * back as nothing known rather than as a failure of the command.
5039
4904
  */
5040
- const askedOf = Effect.fn("review.askedOf")(function* (repo, number, head, runner) {
5041
- const last = Option.getOrNull(yield* lastRun(repo, number, runner));
4905
+ const askedOf = Effect.fn("review.askedOf")(function* (repo, number, head) {
4906
+ const last = Option.getOrNull(yield* lastRun(repo, number));
5042
4907
  return {
5043
4908
  last,
5044
4909
  head,
5045
4910
  changed: last === null || last.head === head ? null : Option.getOrNull(yield* Effect.option(comparedFiles(repo, last.head, head)))
5046
4911
  };
5047
4912
  });
4913
+ /** How a run reads on the line above it: what it opens on, and on which model. */
4914
+ const spending = (turn, model) => [
4915
+ turn._tag === "command" ? turn.line : "the tool's own prompt",
4916
+ turn._tag === "command" && turn.instructions !== null ? "with my own instructions" : null,
4917
+ model === null ? null : `model ${model}`
4918
+ ].filter((part) => part !== null).join(", ");
5048
4919
  /**
5049
- * How a run reads on the line above it: which runner, and what it was told to
5050
- * spend.
5051
- *
5052
- * `review.effort` drives the built-in review command and nothing else, and
5053
- * `review.model` drives the prompt the tool owns, so each run says the one that
5054
- * decided anything about it.
5055
- */
5056
- const spending = (runner, effort, model) => {
5057
- if (runner === "builtin") return `${runner}, effort ${effort}`;
5058
- return model === null ? runner : `${runner}, model ${model}`;
5059
- };
5060
- /**
5061
- * One runner's review of the head in the worktree.
5062
- *
5063
- * `builtin` is two turns of one session: the agent's own review as prose, then
5064
- * the same review reported as findings. `prompt` and `codex` are one turn on
5065
- * the tool's own prompt, which carries the schema with it, so the findings are
5066
- * what the run answers with.
4920
+ * The review of the head in the worktree.
5067
4921
  *
5068
4922
  * Whatever the reporting comes to is a value and not a failure: the review is
5069
4923
  * already worth keeping, and a turn that could not report is recorded as the
5070
4924
  * failure it is rather than lost with it.
5071
4925
  */
5072
4926
  const reviewOn = Effect.fn("review.reviewOn")(function* (options) {
5073
- const said = saying(options.runner);
5074
- const { directory, launcher, settings } = options;
5075
- if (options.runner === "builtin") {
5076
- const turn = yield* spinning(said, (onTool) => builtinReview({
5077
- launcher,
5078
- directory,
5079
- effort: options.effort,
5080
- onTool
5081
- }));
5082
- const reported = yield* Effect.result(Effect.flatMap(builtinFindings({
5083
- launcher,
5084
- directory,
5085
- sessionId: turn.sessionId,
5086
- jsonSchema
5087
- }), (output) => Schema.decodeUnknownEffect(Reported)(output)));
5088
- return {
5089
- sessionId: turn.sessionId,
5090
- prose: turn.report,
5091
- reported
5092
- };
5093
- }
5094
- const prompt = reviewPrompt(options.about);
5095
- const run = yield* spinning(said, (onTool) => promptRun({
5096
- runner: options.runner,
4927
+ const { directory, launcher, model, turn } = options;
4928
+ const run = yield* spinning(saying, (onTool) => reviewTurns({
5097
4929
  launcher,
5098
4930
  directory,
5099
- prompt,
5100
- model: settings.review.model,
4931
+ turn,
4932
+ model,
5101
4933
  jsonSchema,
5102
4934
  onTool
5103
4935
  }));
5104
- const reported = yield* Effect.result(Schema.decodeUnknownEffect(Reported)(run.findings));
4936
+ const answered = Result.isFailure(run.findings) ? Effect.fail(run.findings.failure) : Effect.succeed(run.findings.success);
4937
+ const reported = yield* Effect.result(Effect.flatMap(answered, (output) => Schema.decodeUnknownEffect(Reported)(output)));
5105
4938
  return {
5106
4939
  sessionId: run.sessionId,
5107
4940
  prose: run.prose,
@@ -5109,12 +4942,12 @@ const reviewOn = Effect.fn("review.reviewOn")(function* (options) {
5109
4942
  };
5110
4943
  });
5111
4944
  /**
5112
- * What a runner's review comes to on disk: the findings it reported, or the
5113
- * failure it reached instead.
4945
+ * What the review comes to on disk: the findings it reported, or the failure it
4946
+ * reached instead.
5114
4947
  *
5115
- * A runner that would not start is as much a failure as a turn that answered in
5116
- * a shape that does not validate, and both are recorded: the head has been
5117
- * tried and nothing was found, which is not the same as nothing being wrong.
4948
+ * A run that would not start is as much a failure as a turn that answered in a
4949
+ * shape that does not validate, and both are recorded: the head has been tried
4950
+ * and nothing was found, which is not the same as nothing being wrong.
5118
4951
  */
5119
4952
  const ranBy = (got) => {
5120
4953
  if (Result.isFailure(got)) return {
@@ -5146,20 +4979,14 @@ const ranBy = (got) => {
5146
4979
  };
5147
4980
  };
5148
4981
  /**
5149
- * The command's own failure where a runner that decides my bar reported
5150
- * nothing, and nothing where they all reported.
4982
+ * The command's own failure where the review reported nothing.
5151
4983
  *
5152
- * Every run is written down either way; what the exit code says is whether the
5153
- * review I asked for is one to trust. A second opinion that could not report is
5154
- * said out loud and no more: it informs me, so its silence is not my command
5155
- * failing.
4984
+ * The run is written down either way; what the exit code says is whether the
4985
+ * review I asked for is one to trust.
5156
4986
  */
5157
- const unreported = (recorded, deciding, number) => {
5158
- const failed = recorded.flatMap((run) => {
5159
- const detail = detailOf(run);
5160
- return deciding.includes(run.runner) && detail !== null ? [`${run.runner}: ${detail}`] : [];
5161
- });
5162
- return failed.length === 0 ? Effect.void : Effect.fail(new CliError.UserError({ cause: `The review ran and its findings did not: ${failed.join("; ")}. Run dw-mc review ${number} --force to run it again.` }));
4987
+ const unreported = (run, number) => {
4988
+ const detail = detailOf(run);
4989
+ return detail === null ? Effect.void : Effect.fail(new CliError.UserError({ cause: `The review ran and its findings did not: ${detail}. Run dw-mc review ${number} --force to run it again.` }));
5163
4990
  };
5164
4991
  /**
5165
4992
  * One review run, started by hand, in the foreground.
@@ -5169,109 +4996,102 @@ const unreported = (recorded, deciding, number) => {
5169
4996
  * the one who decides to spend it.
5170
4997
  *
5171
4998
  * The run happens in a throwaway worktree of the tool's own clone, so what is
5172
- * reviewed is the pull request's head rather than whatever I have open. Every
5173
- * configured runner reviews in that one worktree, one after the other: the
5174
- * cutting is the expensive part that they can share, and a second opinion is
5175
- * worth nothing if it read different code.
4999
+ * reviewed is the pull request's head rather than whatever I have open.
5176
5000
  *
5177
- * What they found is kept against that head, one record per runner, which is
5178
- * what takes the pull request out of Needs review run and what a blocking
5179
- * finding later puts into Needs me.
5001
+ * What it found is kept against that head, which is what takes the pull request
5002
+ * out of Needs review run and what a blocking finding later puts into Needs me.
5180
5003
  *
5181
5004
  * The report is printed as well as kept. A run I waited minutes for should not
5182
5005
  * need a second command to read.
5183
5006
  */
5184
5007
  const review = Command.make("review", {
5185
5008
  pr: prArgument,
5009
+ command: commandFlag,
5010
+ prompt: promptFlag,
5186
5011
  effort: effortFlag,
5012
+ model: modelFlag,
5013
+ promptOnly: promptOnlyFlag,
5014
+ commandOnly: commandOnlyFlag,
5187
5015
  force: forceFlag$1
5188
- }, Effect.fn("review")(function* ({ effort, force, pr }) {
5016
+ }, Effect.fn("review")(function* ({ command, commandOnly, effort, force, model, pr, prompt, promptOnly }) {
5189
5017
  const file = Option.getOrElse(yield* read, () => ({}));
5190
5018
  const { number, repo } = yield* named(pr, Object.keys(file.repos ?? {}).toSorted());
5191
5019
  const settings = settingsFor(file, repo);
5192
5020
  const launcher = launcherOf(file);
5193
- const configured = yield* runnersOf(settings.review.runners);
5194
- const deciding = decidingIn(configured, settings.stamp.supporting_blocks);
5195
- const spend = Option.getOrElse(effort, () => settings.review.effort);
5021
+ const asked = yield* asking({
5022
+ settings,
5023
+ command,
5024
+ prompt,
5025
+ effort,
5026
+ model,
5027
+ promptOnly,
5028
+ commandOnly
5029
+ });
5196
5030
  const view = yield* prView(repo, number);
5197
5031
  yield* Console.log(`${repo}#${number} ${view.title}`);
5198
- const asked = yield* Effect.forEach(configured, (runner) => Effect.map(force ? Effect.succeed(null) : Effect.map(askedOf(repo, number, view.headRefOid, runner), (it) => skippedSince(it, settings.review.docs_only)), (since) => ({
5199
- runner,
5200
- since
5201
- })));
5202
- for (const { runner, since } of asked) if (since !== null) yield* Console.log(` ${runner}: only documentation changed since ${short(since)}, so this run is skipped. Pass --force to review it anyway.`);
5203
- const running = asked.filter((it) => it.since === null).map((it) => it.runner);
5204
- if (running.length === 0) return;
5032
+ const since = force ? null : skippedSince(yield* askedOf(repo, number, view.headRefOid), settings.review.docs_only);
5033
+ if (since !== null) {
5034
+ yield* Console.log(` only documentation changed since ${short(since)}, so this run is skipped. Pass --force to review it anyway.`);
5035
+ return;
5036
+ }
5205
5037
  const about = {
5206
5038
  repo,
5207
5039
  number,
5208
5040
  title: view.title,
5209
5041
  base: view.baseRefName,
5210
- skill: settings.review.skill
5042
+ prompt: asked.prompt
5211
5043
  };
5044
+ const turn = turnFor(asked, about);
5212
5045
  yield* Effect.gen(function* () {
5213
5046
  const ran = yield* withWorktree(repo, number, (worktree) => Effect.gen(function* () {
5214
- const done = [];
5215
- for (const runner of running) {
5216
- yield* Console.log(` head ${short(worktree.head)} ${spending(runner, spend, settings.review.model)}`);
5217
- const got = yield* Effect.result(reviewOn({
5218
- runner,
5219
- launcher,
5220
- directory: worktree.directory,
5221
- effort: spend,
5222
- settings,
5223
- about
5224
- }));
5225
- done.push({
5226
- runner,
5227
- ran: ranBy(got)
5228
- });
5229
- }
5047
+ yield* Console.log(` head ${short(worktree.head)} ${spending(turn, asked.model)}`);
5048
+ const got = yield* Effect.result(reviewOn({
5049
+ launcher,
5050
+ directory: worktree.directory,
5051
+ turn,
5052
+ model: asked.model
5053
+ }));
5230
5054
  return {
5231
5055
  head: worktree.head,
5232
- done
5056
+ ran: ranBy(got)
5233
5057
  };
5234
5058
  }));
5235
5059
  const ranAt = yield* DateTime.now;
5236
5060
  const runs = yield* storeFor("runs", ReviewRun);
5237
5061
  const latest = yield* storeFor("runs", LastReviewed);
5238
5062
  const reports = yield* textStoreFor("runs");
5239
- const recorded = [];
5240
- for (const { ran: got, runner } of ran.done) {
5241
- const run = {
5242
- repo,
5243
- number,
5244
- head: ran.head,
5245
- runner,
5246
- effort: spend,
5247
- sessionId: got.sessionId,
5248
- ranAt,
5249
- outcome: got.outcome
5250
- };
5251
- yield* runs.set(runKey(repo, number, run.head, runner), run);
5252
- yield* latest.set(latestKey(repo, number, runner), { head: run.head });
5253
- yield* reports.set(reportKey(repo, number, run.head, runner), reportDocument(run, view.title, got.prose ?? ""));
5254
- recorded.push(run);
5255
- yield* Console.log("");
5256
- if (ran.done.length > 1) yield* Console.log(`${runner}:`);
5257
- const detail = detailOf(run);
5258
- if (detail !== null) {
5259
- yield* Console.log(` reported nothing: ${detail}`);
5260
- continue;
5261
- }
5063
+ const got = ran.ran;
5064
+ const run = {
5065
+ repo,
5066
+ number,
5067
+ head: ran.head,
5068
+ command: asked.command,
5069
+ effort: asked.command === null ? null : asked.effort,
5070
+ sessionId: got.sessionId,
5071
+ ranAt,
5072
+ outcome: got.outcome
5073
+ };
5074
+ yield* runs.set(runKey(repo, number, run.head), run);
5075
+ yield* latest.set(latestKey(repo, number), { head: run.head });
5076
+ yield* reports.set(reportKey(repo, number, run.head), reportDocument(run, view.title, got.prose ?? ""));
5077
+ yield* Console.log("");
5078
+ const detail = detailOf(run);
5079
+ if (detail !== null) yield* Console.log(` reported nothing: ${detail}`);
5080
+ else {
5262
5081
  const found = reportedBy(run);
5263
- if (found === null) continue;
5264
- if (got.prose !== null) {
5265
- yield* Console.log(got.prose);
5266
- yield* Console.log("");
5082
+ if (found !== null) {
5083
+ if (got.prose !== null) {
5084
+ yield* Console.log(got.prose);
5085
+ yield* Console.log("");
5086
+ }
5087
+ yield* Console.log(summary(found, settings.stamp.blocks_on));
5088
+ for (const line of lines$1(found)) yield* Console.log(` ${line}`);
5267
5089
  }
5268
- yield* Console.log(summary(found, settings.stamp.blocks_on));
5269
- for (const line of lines$1(found)) yield* Console.log(` ${line}`);
5270
5090
  }
5271
5091
  yield* Console.log(`Recorded against ${short(ran.head)} in ${yield* stateDirectory}`);
5272
- yield* unreported(recorded, deciding, number);
5092
+ yield* unreported(run, number);
5273
5093
  }).pipe(Effect.onExit((exit) => announce("dw-mc review", `${repo}#${number} ${Exit.isSuccess(exit) ? "reviewed" : "could not be reviewed"}`)));
5274
- }, Effect.catchTag([...userFacing, "GitFailed"], asUserError))).pipe(Command.withDescription("Review one pull request on the configured runners, in a throwaway worktree"));
5094
+ }, Effect.catchTag([...userFacing, "GitFailed"], asUserError))).pipe(Command.withDescription("Review one pull request on Claude Code, in a throwaway worktree"));
5275
5095
  //#endregion
5276
5096
  //#region src/cli/stamp.ts
5277
5097
  const withdrawFlag = Flag.Boolean("withdraw").pipe(Flag.withDefault(false), Flag.withDescription("Take the stamp off this pull request, until its head changes"));
@@ -5429,7 +5249,7 @@ const uninstall = Command.make("uninstall", {
5429
5249
  * Running from source leaves the constant undeclared rather than undefined, so
5430
5250
  * the check has to be `typeof` and the fallback is what a test reads.
5431
5251
  */
5432
- const version = "0.3.0";
5252
+ const version = "0.4.0";
5433
5253
  /** Where the project lives, printed beside the version in the header. */
5434
5254
  const projectUrl = "github.com/dominikwozniak/dw-mc";
5435
5255
  const subcommands = [