dw-mc 0.3.0 → 0.5.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
@@ -112,26 +112,28 @@ const encodeYaml = (value) => {
112
112
  return `${out.join("\n")}\n`;
113
113
  };
114
114
  //#endregion
115
- //#region src/adapters/config.ts
115
+ //#region src/terms/review.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
+ * The words a review run is described in.
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
+ * What a run opens on and how much it spends are facts about Claude Code, and
120
+ * how much a finding weighs is a rule of mine, but all three are read on both
121
+ * sides of the adapter seam: the domain writes the turn and weighs the
122
+ * findings, the adapter spawns the turn and is held to the same words.
123
+ */
124
+ /**
125
+ * How much a review run spends, in the words the slash command takes.
126
+ *
127
+ * The set is Claude Code's and not this tool's, so it is wider than three
128
+ * words: a run that would be worth `max` is one I should be able to ask for
129
+ * without spelling the whole command out.
123
130
  */
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
131
  const Effort = Schema.Literals([
132
132
  "low",
133
133
  "medium",
134
- "high"
134
+ "high",
135
+ "xhigh",
136
+ "max"
135
137
  ]);
136
138
  /** How much a finding weighs. */
137
139
  const Severity = Schema.Literals([
@@ -139,10 +141,8 @@ const Severity = Schema.Literals([
139
141
  "warning",
140
142
  "info"
141
143
  ]);
142
- const PathInstruction = Schema.Struct({
143
- path: Schema.String,
144
- instructions: Schema.String
145
- });
144
+ //#endregion
145
+ //#region src/adapters/config.ts
146
146
  /**
147
147
  * What one section of the file may say. Every key is optional: what the file
148
148
  * leaves out is inherited rather than reset, so `defaults` and a repository's
@@ -151,12 +151,11 @@ const PathInstruction = Schema.Struct({
151
151
  const SettingsPatch = Schema.Struct({
152
152
  base: Schema.optionalKey(Schema.NullOr(Schema.String)),
153
153
  review: Schema.optionalKey(Schema.Struct({
154
- runners: Schema.optionalKey(Schema.Array(Runner)),
155
- effort: Schema.optionalKey(Effort),
154
+ command: Schema.optionalKey(Schema.NullOr(Schema.String)),
155
+ effort: Schema.optionalKey(Schema.NullOr(Effort)),
156
+ prompt: Schema.optionalKey(Schema.NullOr(Schema.String)),
156
157
  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))
158
+ docs_only: Schema.optionalKey(Schema.Array(Schema.String))
160
159
  })),
161
160
  ci: Schema.optionalKey(Schema.Struct({
162
161
  ignore: Schema.optionalKey(Schema.Array(Schema.String)),
@@ -164,16 +163,12 @@ const SettingsPatch = Schema.Struct({
164
163
  })),
165
164
  fix: Schema.optionalKey(Schema.Struct({ commits: Schema.optionalKey(Schema.Boolean) })),
166
165
  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
- }))
166
+ stamp: Schema.optionalKey(Schema.Struct({ blocks_on: Schema.optionalKey(Severity) }))
171
167
  });
172
- /** How this machine starts a runner, where it does not start `claude` itself. */
168
+ /** How this machine starts Claude Code, where it does not start `claude` itself. */
173
169
  const LauncherPatch = Schema.Struct({
174
170
  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" }))))
171
+ fix_args: Schema.optionalKey(Schema.Array(Schema.String))
177
172
  });
178
173
  /** A repository, as `gh` spells it: `owner/name`. */
179
174
  const Repo = Schema.String.pipe(Schema.check(Schema.isPattern(/^[^\s/]+\/[^\s/]+$/, { message: "Expected a repository as owner/name" })));
@@ -187,12 +182,11 @@ const ConfigFile = Schema.Struct({
187
182
  const builtIn = {
188
183
  base: null,
189
184
  review: {
190
- runners: ["builtin"],
185
+ command: "/code-review",
191
186
  effort: "low",
187
+ prompt: null,
192
188
  model: null,
193
- skill: null,
194
- docs_only: ["**/*.md", "docs/**"],
195
- path_instructions: []
189
+ docs_only: ["**/*.md", "docs/**"]
196
190
  },
197
191
  ci: {
198
192
  ignore: [],
@@ -200,16 +194,12 @@ const builtIn = {
200
194
  },
201
195
  fix: { commits: false },
202
196
  rebase: { enabled: false },
203
- stamp: {
204
- blocks_on: "error",
205
- supporting_blocks: false
206
- }
197
+ stamp: { blocks_on: "error" }
207
198
  };
208
- /** `claude` and `codex` themselves, which is what a machine that spawns them directly needs. */
199
+ /** `claude` itself, which is what a machine that spawns it directly needs. */
209
200
  const builtInLauncher = {
210
201
  command: ["claude"],
211
- fix_args: [],
212
- codex: ["codex"]
202
+ fix_args: []
213
203
  };
214
204
  /**
215
205
  * The patch's value where it has one, the inherited value otherwise. A key the
@@ -220,12 +210,11 @@ const over = (patch, inherited) => patch === void 0 ? inherited : patch;
220
210
  const apply = (settings, patch) => patch === void 0 ? settings : {
221
211
  base: over(patch.base, settings.base),
222
212
  review: {
223
- runners: over(patch.review?.runners, settings.review.runners),
213
+ command: over(patch.review?.command, settings.review.command),
224
214
  effort: over(patch.review?.effort, settings.review.effort),
215
+ prompt: over(patch.review?.prompt, settings.review.prompt),
225
216
  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)
217
+ docs_only: over(patch.review?.docs_only, settings.review.docs_only)
229
218
  },
230
219
  ci: {
231
220
  ignore: over(patch.ci?.ignore, settings.ci.ignore),
@@ -233,10 +222,7 @@ const apply = (settings, patch) => patch === void 0 ? settings : {
233
222
  },
234
223
  fix: { commits: over(patch.fix?.commits, settings.fix.commits) },
235
224
  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
- }
225
+ stamp: { blocks_on: over(patch.stamp?.blocks_on, settings.stamp.blocks_on) }
240
226
  };
241
227
  /**
242
228
  * `delta` over `patch`, keeping every key `delta` does not mention.
@@ -290,18 +276,16 @@ const withRepo = (file, repo, patch) => ({
290
276
  }
291
277
  });
292
278
  /**
293
- * What this machine starts a runner with: the file's launcher over `claude`.
279
+ * What this machine starts Claude Code with: the file's launcher over `claude`.
294
280
  *
295
281
  * It is no repository's business. What spawns the agent CLI is a fact of the
296
282
  * machine, which is why it sits beside `defaults` rather than inside it.
297
283
  */
298
284
  const launcherOf = (file) => {
299
285
  const [program = builtInLauncher.command[0], ...prefix] = file.launcher?.command ?? [];
300
- const [codex = builtInLauncher.codex[0], ...codexPrefix] = file.launcher?.codex ?? [];
301
286
  return {
302
287
  command: [program, ...prefix],
303
- fix_args: file.launcher?.fix_args ?? builtInLauncher.fix_args,
304
- codex: [codex, ...codexPrefix]
288
+ fix_args: file.launcher?.fix_args ?? builtInLauncher.fix_args
305
289
  };
306
290
  };
307
291
  /** What `repo` is worth: its own overrides over the global defaults. */
@@ -349,6 +333,43 @@ var ConfigMalformed = class extends Schema.TaggedError()("ConfigMalformed", {
349
333
  }
350
334
  };
351
335
  const reasonOf = (cause) => cause instanceof Error ? cause.message : String(cause);
336
+ /** The keys an earlier version had, read off a file loosely enough to find them. */
337
+ const LegacySection = Schema.Struct({
338
+ review: Schema.optionalKey(Schema.Struct({
339
+ runners: Schema.optionalKey(Schema.Unknown),
340
+ skill: Schema.optionalKey(Schema.Unknown),
341
+ path_instructions: Schema.optionalKey(Schema.Unknown)
342
+ })),
343
+ stamp: Schema.optionalKey(Schema.Struct({ supporting_blocks: Schema.optionalKey(Schema.Unknown) }))
344
+ });
345
+ const Legacy = Schema.Struct({
346
+ launcher: Schema.optionalKey(Schema.Struct({ codex: Schema.optionalKey(Schema.Unknown) })),
347
+ defaults: Schema.optionalKey(LegacySection),
348
+ repos: Schema.optionalKey(Schema.Record(Schema.String, LegacySection))
349
+ });
350
+ const asLegacy = Schema.decodeUnknownOption(Legacy);
351
+ /**
352
+ * What a file from an earlier version says, and what to do about each of it.
353
+ *
354
+ * The excess-property error names a key and stops there, which is enough for a
355
+ * key that is simply gone and not enough for one that moved: `review.skill` is
356
+ * `review.prompt` now, and a file quietly stripped of it is a review brief lost.
357
+ * This is here to be deleted once no file has those keys left.
358
+ */
359
+ const legacyIn = (decided) => {
360
+ const legacy = asLegacy(decided);
361
+ if (Option.isNone(legacy)) return null;
362
+ const sections = [legacy.value.defaults, ...Object.values(legacy.value.repos ?? {})];
363
+ const spelled = (says) => sections.some((section) => section !== void 0 && says(section) !== void 0);
364
+ const said = [
365
+ legacy.value.launcher?.codex === void 0 ? null : "launcher.codex is gone: reviews run on Claude Code alone.",
366
+ spelled((section) => section.review?.runners) ? "review.runners is gone: a head carries one review run, which review.command configures." : null,
367
+ 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,
368
+ spelled((section) => section.review?.path_instructions) ? "review.path_instructions is gone: nothing ever read it." : null,
369
+ spelled((section) => section.stamp?.supporting_blocks) ? "stamp.supporting_blocks is gone: there is no second opinion to let through." : null
370
+ ].filter((sentence) => sentence !== null);
371
+ return said.length === 0 ? null : `it names keys this version does not have.\n${said.join("\n")}`;
372
+ };
352
373
  /**
353
374
  * The configuration file, or `None` when this machine has none yet.
354
375
  *
@@ -368,6 +389,8 @@ const read = Effect.gen(function* () {
368
389
  try: () => Yaml.parse(raw),
369
390
  catch: (cause) => malformed(reasonOf(cause))
370
391
  })) ?? {};
392
+ const legacy = legacyIn(decided);
393
+ if (legacy !== null) return yield* malformed(legacy);
371
394
  return Option.some(yield* Schema.decodeUnknownEffect(ConfigFile)(decided, {
372
395
  onExcessProperty: "error",
373
396
  errors: "all"
@@ -381,17 +404,16 @@ const mapping = (entries) => {
381
404
  const settingsDocument = (patch) => mapping([
382
405
  ["base", patch.base],
383
406
  ["review", patch.review === void 0 ? void 0 : mapping([
384
- ["runners", patch.review.runners],
407
+ ["command", patch.review.command],
385
408
  ["effort", patch.review.effort],
409
+ ["prompt", patch.review.prompt],
386
410
  ["model", patch.review.model],
387
- ["skill", patch.review.skill],
388
- ["docs_only", patch.review.docs_only],
389
- ["path_instructions", patch.review.path_instructions]
411
+ ["docs_only", patch.review.docs_only]
390
412
  ])],
391
413
  ["ci", patch.ci === void 0 ? void 0 : mapping([["ignore", patch.ci.ignore], ["flaky_patterns", patch.ci.flaky_patterns]])],
392
414
  ["fix", patch.fix === void 0 ? void 0 : mapping([["commits", patch.fix.commits]])],
393
415
  ["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]])]
416
+ ["stamp", patch.stamp === void 0 ? void 0 : mapping([["blocks_on", patch.stamp.blocks_on]])]
395
417
  ]);
396
418
  /**
397
419
  * The file as a YAML document, in the order of the schema.
@@ -400,11 +422,7 @@ const settingsDocument = (patch) => mapping([
400
422
  * keeps the file stable across runs, so a rewrite shows only what changed.
401
423
  */
402
424
  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
- ])],
425
+ ["launcher", file.launcher === void 0 ? void 0 : mapping([["command", file.launcher.command], ["fix_args", file.launcher.fix_args]])],
408
426
  ["defaults", file.defaults === void 0 ? void 0 : settingsDocument(file.defaults)],
409
427
  ["repos", file.repos === void 0 ? void 0 : mapping(Object.entries(file.repos).map(([name, patch]) => [name, settingsDocument(patch)]))]
410
428
  ]);
@@ -649,6 +667,71 @@ const tidy = Effect.fn("store.tidy")(function* (directory, upTo) {
649
667
  at = path.dirname(at);
650
668
  }
651
669
  });
670
+ //#endregion
671
+ //#region src/adapters/heartbeat.ts
672
+ /** The frames of the spinner, in the order they turn. */
673
+ const frames = [
674
+ "⠋",
675
+ "⠙",
676
+ "⠹",
677
+ "⠸",
678
+ "⠼",
679
+ "⠴",
680
+ "⠦",
681
+ "⠧",
682
+ "⠇",
683
+ "⠏"
684
+ ];
685
+ /** How long one frame is on the screen. */
686
+ const frameFor = Duration.millis(120);
687
+ /** A stretch of time as a terminal says it: `1m12s`, or `9s` under the minute. */
688
+ const elapsed = (millis) => {
689
+ const seconds = Math.floor(millis / 1e3);
690
+ return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, "0")}s`;
691
+ };
692
+ /**
693
+ * Runs `use` while one line says the work is still going, and hands `use` the
694
+ * way to say how that line reads.
695
+ *
696
+ * Work that takes seconds and prints nothing while it does is work I stop
697
+ * trusting. What the screen showed instead was either silence or a line per
698
+ * step, and a wall of `· Bash` says as little as silence did. This keeps one
699
+ * line and rewrites it: the spinner says the work is alive, the words say how
700
+ * far it has got, and the line is gone when the work is over, so what stays on
701
+ * the screen is the report.
702
+ *
703
+ * What the line counts is the command's and not this module's business. A
704
+ * review counts tools, a sweep counts pull requests, and a command reading two
705
+ * guards counts nothing at all - and a screen that worded any of them here
706
+ * would need the words a command already has.
707
+ *
708
+ * Where there is no screen to measure - a pipe, a CI log, a test - a rewritten
709
+ * line would be a mess of half-drawn ones, so nothing is drawn. What goes out
710
+ * instead is whatever `aside` the command gives, one to a line, and where it
711
+ * gives none the output is what it was before there was a heartbeat at all.
712
+ * `columns` is zero exactly there.
713
+ */
714
+ const beating = Effect.fnUntraced(function* (from, use) {
715
+ const terminal = yield* Terminal.Terminal;
716
+ const columns = yield* terminal.columns;
717
+ if (columns === 0) return yield* use((_, aside) => aside === void 0 ? Effect.void : Console.log(aside));
718
+ const started = yield* Clock.currentTimeMillis;
719
+ const draw = (text) => Effect.ignore(terminal.display(`\r${text.slice(0, columns - 1).padEnd(columns - 1)}`));
720
+ let reads = from;
721
+ let at = 0;
722
+ /** The line as it stands: this frame of the spinner, and the latest wording. */
723
+ const paint = Effect.flatMap(Clock.currentTimeMillis, (now) => draw(`${frames[at % frames.length]} ${reads(elapsed(now - started))}`));
724
+ const says = (next) => Effect.andThen(Effect.sync(() => void (reads = next)), paint);
725
+ yield* paint;
726
+ const beat = yield* Effect.forkChild(Effect.gen(function* () {
727
+ for (;;) {
728
+ yield* Effect.sleep(frameFor);
729
+ at = at + 1;
730
+ yield* paint;
731
+ }
732
+ }));
733
+ return yield* Effect.onExit(use(says), () => Effect.flatMap(Fiber.interrupt(beat), () => Effect.ignore(terminal.display(`\r${" ".repeat(columns - 1)}\r`))));
734
+ });
652
735
  new TextEncoder();
653
736
  /** A program that ran but ended badly. */
654
737
  var CommandFailed = class extends Schema.TaggedError()("CommandFailed", {
@@ -731,41 +814,56 @@ var WorktreeHeld = class extends Schema.TaggedError()("WorktreeHeld", {
731
814
  * The head comes from the pull request's ref rather than from what a sweep last
732
815
  * saw, so what is cut is the commit the run really reads.
733
816
  */
817
+ /**
818
+ * How the heartbeat of a cut reads.
819
+ *
820
+ * The stages are named apart because a first clone and a hundredth fetch take
821
+ * wildly different times, and the line is what explains the difference: a
822
+ * `cloning` that sits there for two minutes is a large repository arriving
823
+ * once, not a tool that has hung.
824
+ */
825
+ const cutting = (what, repo) => (since) => `${what} ${repo} · ${since}`;
734
826
  const whereToCut = Effect.fn("git.whereToCut")(function* (repo, number, cut) {
735
827
  const path = yield* Path.Path;
736
828
  const state = yield* stateDirectory;
737
829
  const clone = path.join(state, clonesIn, `${repo}.git`);
738
- if ((yield* Effect.orElseSucceed(git([
830
+ const bare = yield* Effect.orElseSucceed(git([
739
831
  "-C",
740
832
  clone,
741
833
  "rev-parse",
742
834
  "--is-bare-repository"
743
- ]), () => "")) !== "true") yield* git([
744
- "clone",
745
- "--bare",
746
- "--filter=blob:none",
747
- `https://github.com/${repo}.git`,
748
- clone
749
- ]);
835
+ ]), () => "");
750
836
  const pullRef = `refs/dw-mc/pr/${number}`;
751
- yield* git([
752
- "-C",
753
- clone,
754
- "fetch",
755
- "--no-tags",
756
- "--force",
757
- "origin",
758
- `+refs/pull/${number}/head:${pullRef}`,
759
- "+refs/heads/*:refs/heads/*"
760
- ]);
761
837
  return {
762
838
  clone,
763
- head: yield* git([
764
- "-C",
765
- clone,
766
- "rev-parse",
767
- pullRef
768
- ]),
839
+ head: yield* beating(cutting(bare === "true" ? "fetching" : "cloning", repo), (says) => Effect.gen(function* () {
840
+ if (bare !== "true") {
841
+ yield* git([
842
+ "clone",
843
+ "--bare",
844
+ "--filter=blob:none",
845
+ `https://github.com/${repo}.git`,
846
+ clone
847
+ ]);
848
+ yield* says(cutting("fetching", repo));
849
+ }
850
+ yield* git([
851
+ "-C",
852
+ clone,
853
+ "fetch",
854
+ "--no-tags",
855
+ "--force",
856
+ "origin",
857
+ `+refs/pull/${number}/head:${pullRef}`,
858
+ "+refs/heads/*:refs/heads/*"
859
+ ]);
860
+ return yield* git([
861
+ "-C",
862
+ clone,
863
+ "rev-parse",
864
+ pullRef
865
+ ]);
866
+ })),
769
867
  directory: path.join(state, cut, repo, String(number))
770
868
  };
771
869
  });
@@ -790,7 +888,7 @@ const withWorktree = Effect.fn("git.withWorktree")(function* (repo, number, use)
790
888
  "--force",
791
889
  directory
792
890
  ]));
793
- return yield* Effect.acquireUseRelease(Effect.flatMap(remove, () => git([
891
+ return yield* Effect.acquireUseRelease(beating(cutting("cutting a worktree of", repo), () => Effect.flatMap(remove, () => git([
794
892
  "-C",
795
893
  clone,
796
894
  "worktree",
@@ -798,7 +896,7 @@ const withWorktree = Effect.fn("git.withWorktree")(function* (repo, number, use)
798
896
  "--detach",
799
897
  directory,
800
898
  head
801
- ])), () => use({
899
+ ]))), () => use({
802
900
  directory,
803
901
  head
804
902
  }), () => remove);
@@ -942,7 +1040,7 @@ const standingWorktree = Effect.fn("git.standingWorktree")(function* (repo, numb
942
1040
  ]);
943
1041
  yield* perWorktreeConfig(clone);
944
1042
  if (session === "rebase") yield* reuseResolutions(clone);
945
- yield* git([
1043
+ yield* beating(cutting("cutting a worktree of", repo), () => git([
946
1044
  "-C",
947
1045
  clone,
948
1046
  "worktree",
@@ -951,7 +1049,7 @@ const standingWorktree = Effect.fn("git.standingWorktree")(function* (repo, numb
951
1049
  branch,
952
1050
  directory,
953
1051
  head
954
- ]);
1052
+ ]));
955
1053
  yield* git([
956
1054
  "-C",
957
1055
  clone,
@@ -1474,7 +1572,7 @@ const lines$3 = (it, state, path, paint) => {
1474
1572
  const cleanup = Command.make("cleanup", { yes: yesFlag }, Effect.fn("cleanup")(function* ({ yes }) {
1475
1573
  const path = yield* Path.Path;
1476
1574
  const paint = yield* Paint;
1477
- const found = yield* inventory;
1575
+ const found = yield* beating((since) => `measuring the state directory · ${since}`, () => inventory);
1478
1576
  const it = plan(found);
1479
1577
  if (empty(it)) {
1480
1578
  yield* Console.log(`Nothing to take back in ${found.directory}.`);
@@ -1917,7 +2015,15 @@ const isSame = (self, other) => self === null || other === null ? self === other
1917
2015
  /** The latest of many, or never when there are none. */
1918
2016
  const newest = (moments) => moments.reduce(later, null);
1919
2017
  //#endregion
1920
- //#region src/domain/bucket.ts
2018
+ //#region src/terms/pr.ts
2019
+ /**
2020
+ * What GitHub says about a pull request, in this tool's words.
2021
+ *
2022
+ * The three of them are here because both sides need the same one: `gh` and the
2023
+ * checks adapter answer in these words, and the bucket rules decide on them. A
2024
+ * union restated on each side is a case that goes unreachable the day the other
2025
+ * side gains a member.
2026
+ */
1921
2027
  /** How far GitHub has got towards letting a tracked PR merge. */
1922
2028
  const Mergeability = Schema.Literals([
1923
2029
  "mergeable",
@@ -1938,6 +2044,8 @@ const ChecksState = Schema.Literals([
1938
2044
  "pending",
1939
2045
  "none"
1940
2046
  ]);
2047
+ //#endregion
2048
+ //#region src/domain/bucket.ts
1941
2049
  /**
1942
2050
  * Everything the bucket rules are allowed to know about a tracked PR.
1943
2051
  *
@@ -2032,7 +2140,7 @@ const readyReason = (facts) => {
2032
2140
  * This is the single place the bucket rules exist. Every tracked PR lands in
2033
2141
  * exactly one bucket, so the rules are tried in priority order and the first
2034
2142
  * 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.
2143
+ * requested is mine to move, not the review's.
2036
2144
  *
2037
2145
  * Ready does not insist on an approval, because a repository that requires no
2038
2146
  * reviewer never produces one. What it insists on is that nobody else has been
@@ -2153,6 +2261,20 @@ const swept = Effect.fn("pr.swept")(function* (repo, number) {
2153
2261
  if (Option.isNone(facts)) return yield* new CliError.UserError({ cause: `Nothing is known about ${repo}#${number} yet. Run dw-mc sweep first.` });
2154
2262
  return facts.value;
2155
2263
  });
2264
+ /**
2265
+ * The guard reads of one command, under a heartbeat.
2266
+ *
2267
+ * Every command that acts on a pull request reads its guards live rather than
2268
+ * off the last sweep, because each of them is about the pull request as it is
2269
+ * now. That read is a second or two against GitHub before a word can be
2270
+ * printed, and it used to be spent on a blank screen.
2271
+ *
2272
+ * There is nothing to count here - two or three calls, and a number counting to
2273
+ * three says less than the words do - so the line is what is being read and how
2274
+ * long it has taken. It gives the heartbeat no aside, so a piped command prints
2275
+ * what it always printed.
2276
+ */
2277
+ const reading = (where, read) => beating((since) => `reading ${where} · ${since}`, () => read);
2156
2278
  //#endregion
2157
2279
  //#region src/cli/row.ts
2158
2280
  /**
@@ -2732,11 +2854,12 @@ const recordConflict = Effect.fn("rebase.recordConflict")(function* (repo, numbe
2732
2854
  /** Whether a review run found anything at all. */
2733
2855
  const Verdict = Schema.Literals(["clean", "findings"]);
2734
2856
  /**
2735
- * Every severity word a runner may answer with.
2857
+ * Every severity word a review may answer with.
2736
2858
  *
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.
2859
+ * The first three are ours, and the only ones a run is asked for. The rest are
2860
+ * the persona a run with no slash command carries, which grades in its own
2861
+ * words: a turn that comes back in them is worth reading rather than throwing
2862
+ * away.
2740
2863
  */
2741
2864
  const Spelling = Schema.Literals([
2742
2865
  "error",
@@ -2809,9 +2932,9 @@ const jsonSchema = JSON.stringify(SchemaRepresentation.toJsonSchemaDocument(Sche
2809
2932
  /**
2810
2933
  * The findings as the Markdown a report is written in.
2811
2934
  *
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.
2935
+ * It is what a schema-held run's report says: with a schema in force a run
2936
+ * answers in findings and not in prose, so the report kept beside it is written
2937
+ * from the findings themselves rather than left empty.
2815
2938
  */
2816
2939
  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
2940
  /** Where each severity sits against the others, so the bar can be compared with it. */
@@ -2842,7 +2965,7 @@ const Outcome = Schema.Union([Schema.TaggedStruct("reported", {
2842
2965
  findings: Schema.Array(Finding)
2843
2966
  }), Schema.TaggedStruct("failed", { detail: Schema.String })]);
2844
2967
  /**
2845
- * One execution of a runner against a tracked PR at a specific head commit.
2968
+ * One review run against a tracked PR at a specific head commit.
2846
2969
  *
2847
2970
  * It is a schema because a review run outlives the command that started it: the
2848
2971
  * state directory is where the next sweep learns that this head has been
@@ -2853,13 +2976,18 @@ const ReviewRun = Schema.Struct({
2853
2976
  number: Schema.Int,
2854
2977
  /** The head the run covers. A run never vouches for code it did not see. */
2855
2978
  head: Schema.String,
2856
- runner: Runner,
2857
- effort: Effort,
2979
+ /**
2980
+ * The slash command line the run opened on, or null where it opened on the
2981
+ * tool's own prompt. A report found months later says what it was asked, and a
2982
+ * record an earlier version wrote carries no such field and is forgotten.
2983
+ */
2984
+ command: Schema.NullOr(Schema.String),
2985
+ effort: Schema.NullOr(Effort),
2858
2986
  /**
2859
2987
  * The agent session the run happened in, or null where it never reached one.
2860
2988
  *
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.
2989
+ * A run that would not start or exited before it said anything has no session,
2990
+ * and the run is still recorded: a failure is recorded as what it is.
2863
2991
  */
2864
2992
  sessionId: Schema.NullOr(Schema.String),
2865
2993
  ranAt: Schema.DateTimeUtcFromString,
@@ -2868,17 +2996,12 @@ const ReviewRun = Schema.Struct({
2868
2996
  /** A head as it is read out loud: the seven characters git itself abbreviates to. */
2869
2997
  const short = (head) => head.slice(0, 7);
2870
2998
  /**
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.
2999
+ * Where a run is kept: one key per head, so a run and the code it read cannot
3000
+ * drift apart, and a re-review replaces the run before it.
2878
3001
  */
2879
- const runKey = (repo, number, head, runner) => `${repo}#${number}@${head}:${runner}`;
3002
+ const runKey = (repo, number, head) => `${prKey(repo, number)}@${head}`;
2880
3003
  /** 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`;
3004
+ const reportKey = (repo, number, head) => `${runKey(repo, number, head)}.md`;
2882
3005
  /**
2883
3006
  * Which head a pull request was last reviewed at: an index beside `runKey` and
2884
3007
  * `reportKey` rather than a thing the glossary names.
@@ -2890,35 +3013,27 @@ const reportKey = (repo, number, head, runner) => `${runKey(repo, number, head,
2890
3013
  */
2891
3014
  const LastReviewed = Schema.Struct({ head: Schema.String });
2892
3015
  /** Where that head is kept. No head is spelled `latest`, so nothing collides. */
2893
- const latestKey = (repo, number, runner) => `${repo}#${number}@latest:${runner}`;
3016
+ const latestKey = (repo, number) => `${prKey(repo, number)}@latest`;
2894
3017
  /**
2895
- * The last review run of one runner on a pull request, or none where it has had
2896
- * none.
3018
+ * The run at one head, or none where nothing has reviewed it.
2897
3019
  *
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.
3020
+ * A head is where the question is asked - the stamp, the bucket and `dw-mc
3021
+ * findings` all ask about one commit - and one read off the disk answers it
3022
+ * without an index to keep in step.
2900
3023
  *
2901
3024
  * A run this version cannot read is a run another version of this record wrote,
2902
3025
  * and the state directory is a cache of work that can be done again: forgetting
2903
3026
  * it costs one review, where failing here would cost me the command I asked for.
2904
3027
  */
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();
3028
+ const runAt = Effect.fn("review.runAt")(function* (repo, number, head) {
2909
3029
  const runs = yield* storeFor("runs", ReviewRun);
2910
- return yield* Effect.orElseSucceed(runs.get(runKey(repo, number, at.value.head, runner)), () => Option.none());
3030
+ return yield* Effect.orElseSucceed(runs.get(runKey(repo, number, head)), () => Option.none());
2911
3031
  });
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] : []);
3032
+ /** The last review run on a pull request, or none where it has had none. */
3033
+ const lastRun = Effect.fn("review.lastRun")(function* (repo, number) {
3034
+ const heads = yield* storeFor("runs", LastReviewed);
3035
+ const at = yield* Effect.orElseSucceed(heads.get(latestKey(repo, number)), () => Option.none());
3036
+ return Option.isNone(at) ? Option.none() : yield* runAt(repo, number, at.value.head);
2922
3037
  });
2923
3038
  /**
2924
3039
  * What a run reported, or null where it reported nothing at all.
@@ -2964,73 +3079,38 @@ const skippedSince = (asked, docsOnly) => {
2964
3079
  const changed = asked.last.head === asked.head ? [] : asked.changed;
2965
3080
  return changed === null || worthRerunning(changed, docsOnly) ? null : asked.last.head;
2966
3081
  };
3082
+ /** What a run was opened on, as the report says it. */
3083
+ const askedOf$1 = (run) => run.command === null ? "the tool's own prompt" : [run.command, run.effort].filter((part) => part !== null).join(" ");
2967
3084
  /**
2968
- * The report as it is written down: what it is of, then what the runner said.
3085
+ * The report as it is written down: what it is of, then what the run said.
2969
3086
  *
2970
3087
  * The heading is the whole point of writing it rather than storing the prose
2971
3088
  * 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.
3089
+ * what the run was asked, without anything else having to be open.
2973
3090
  */
2974
3091
  const reportDocument = (run, title, prose) => [
2975
3092
  `# ${run.repo}#${run.number} ${title}`,
2976
3093
  "",
2977
3094
  `- head: ${run.head}`,
2978
- `- runner: ${run.runner}, effort ${run.effort}`,
3095
+ `- run: ${askedOf$1(run)}`,
2979
3096
  `- ran: ${DateTime.formatIso(run.ranAt)}`,
2980
3097
  "",
2981
3098
  prose.trim(),
2982
3099
  ""
2983
3100
  ].join("\n");
2984
3101
  /**
2985
- * The runners one review run executes, in the order the file names them and
2986
- * without repeats.
3102
+ * Whether `head` has the review it needs.
2987
3103
  *
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.
3104
+ * A run that reported nothing does not count, which is the same rule
3105
+ * `reportedBy` draws everywhere else: a failure has found nothing, not found
3106
+ * nothing wrong.
2991
3107
  */
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.
3022
- *
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.
3027
- */
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);
3108
+ const reviewedBy = (run) => run !== null && reportedBy(run) !== null;
3109
+ /** The findings at one head that withhold the stamp. */
3110
+ const blockingIn = (run, blocksOn) => {
3111
+ const found = run === null ? null : reportedBy(run);
3032
3112
  return found === null ? [] : blocking(found.findings, blocksOn);
3033
- });
3113
+ };
3034
3114
  /**
3035
3115
  * What the review runs on `head` say about it, for the stamp to rest on.
3036
3116
  *
@@ -3040,20 +3120,15 @@ const blockingIn = (runs, deciding, blocksOn) => runs.filter((run) => deciding.i
3040
3120
  * report findings does not count either: its verdict is what takes a pull
3041
3121
  * request out of Needs review run, and it reached none.
3042
3122
  *
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
3123
  * It is one function because the two callers are a sweep and `dw-mc merge`, and
3048
3124
  * the second exists to land what the first only describes: two spellings of
3049
3125
  * this would be two answers to whether a head has been reviewed.
3050
3126
  */
3051
- 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);
3127
+ const reviewedAt = Effect.fn("review.reviewedAt")(function* (repo, number, head, blocksOn) {
3128
+ const run = Option.getOrNull(yield* runAt(repo, number, head));
3054
3129
  return {
3055
- reviewRunHead: reviewedBy(runs, deciding) ? head : null,
3056
- blockingFindings: blockingIn(runs, deciding, settings.stamp.blocks_on).length
3130
+ reviewRunHead: reviewedBy(run) ? head : null,
3131
+ blockingFindings: blockingIn(run, blocksOn).length
3057
3132
  };
3058
3133
  });
3059
3134
  //#endregion
@@ -3076,7 +3151,7 @@ const sweepPr = Effect.fn("sweep.pullRequest")(function* (store, me, found, sett
3076
3151
  const newestHumanCommentAt = newest(byHumansOtherThan(comments, me));
3077
3152
  const key = prKey(found.repo, found.number);
3078
3153
  const previous = Option.getOrUndefined(yield* Effect.orElseSucceed(store.get(key), () => Option.none()));
3079
- const reviewed = yield* reviewedAt(found.repo, found.number, view.headRefOid, settings);
3154
+ const reviewed = yield* reviewedAt(found.repo, found.number, view.headRefOid, settings.stamp.blocks_on);
3080
3155
  const quiet = previous !== void 0 && isQuiet(pulseOf(previous), {
3081
3156
  head: view.headRefOid,
3082
3157
  checks,
@@ -3122,14 +3197,26 @@ const gather = (attempts) => ({
3122
3197
  });
3123
3198
  /** How many reads of GitHub are in flight at once. */
3124
3199
  const concurrency = 4;
3200
+ /** `n` repositories, which `count` cannot say: the plural is not the noun plus s. */
3201
+ const repositories = (n) => n === 1 ? "1 repository" : `${n} repositories`;
3202
+ /** How the heartbeat of a sweep reads, wherever a command turns one. */
3203
+ const saying$1 = (swept) => (since) => [
3204
+ "sweeping",
3205
+ swept._tag === "searching" ? `${swept.done} of ${repositories(swept.of)}` : `${swept.done} of ${count(swept.of, "pull request")}`,
3206
+ since
3207
+ ].join(" · ");
3125
3208
  /**
3126
3209
  * One pass over every tracked PR, and nothing else: a sweep only ever reads.
3127
3210
  *
3128
3211
  * Every repository and every pull request is read on its own, so one of them
3129
3212
  * failing costs me its rows and leaves the rest of the table standing. What
3130
3213
  * failed comes back beside the facts rather than instead of them.
3214
+ *
3215
+ * `report` is told how far the pass has got, every time it gets further. What
3216
+ * that is worth saying is the caller's, which is why it is handed a count and
3217
+ * not a sentence.
3131
3218
  */
3132
- const sweep = Effect.gen(function* () {
3219
+ const sweep = Effect.fn("sweep")(function* (report) {
3133
3220
  const file = Option.getOrElse(yield* read, () => ({}));
3134
3221
  const repos = Object.keys(file.repos ?? {}).toSorted();
3135
3222
  if (repos.length === 0) return {
@@ -3139,14 +3226,48 @@ const sweep = Effect.gen(function* () {
3139
3226
  };
3140
3227
  const store = yield* storeFor("prs", Facts);
3141
3228
  const me = yield* viewer;
3142
- const found = gather(yield* Effect.forEach(repos, (repo) => attempt(repo, searchPrs(repo)), { concurrency }));
3143
- const swept = gather(yield* Effect.forEach(found.got, (pr) => attempt(`${pr.repo}#${pr.number}`, Effect.map(sweepPr(store, me, pr, settingsFor(file, pr.repo)), (facts) => [facts])), { concurrency }));
3229
+ let searched = 0;
3230
+ yield* report({
3231
+ _tag: "searching",
3232
+ done: 0,
3233
+ of: repos.length
3234
+ });
3235
+ const found = gather(yield* Effect.forEach(repos, (repo) => Effect.tap(attempt(repo, searchPrs(repo)), () => {
3236
+ searched = searched + 1;
3237
+ return report({
3238
+ _tag: "searching",
3239
+ done: searched,
3240
+ of: repos.length
3241
+ });
3242
+ }), { concurrency }));
3243
+ let read$1 = 0;
3244
+ yield* report({
3245
+ _tag: "reading",
3246
+ done: 0,
3247
+ of: found.got.length
3248
+ });
3249
+ const swept = gather(yield* Effect.forEach(found.got, (pr) => Effect.tap(attempt(`${pr.repo}#${pr.number}`, Effect.map(sweepPr(store, me, pr, settingsFor(file, pr.repo)), (facts) => [facts])), () => {
3250
+ read$1 = read$1 + 1;
3251
+ return report({
3252
+ _tag: "reading",
3253
+ done: read$1,
3254
+ of: found.got.length
3255
+ });
3256
+ }), { concurrency }));
3144
3257
  return {
3145
3258
  repos,
3146
3259
  facts: swept.got,
3147
3260
  troubles: [...found.troubles, ...swept.troubles]
3148
3261
  };
3149
- }).pipe(Effect.withSpan("sweep"));
3262
+ });
3263
+ /**
3264
+ * A sweep under its heartbeat, which is how every command that sweeps runs one.
3265
+ *
3266
+ * The three of them want the same line, so they say it once here rather than
3267
+ * three times over. It gives the heartbeat no aside, so a piped `dw-mc status`
3268
+ * prints exactly what it printed before there was a heartbeat at all.
3269
+ */
3270
+ const sweeping = beating((since) => `sweeping · ${since}`, (says) => sweep((swept) => says(saying$1(swept))));
3150
3271
  /**
3151
3272
  * The failures a sweep can hit before it has a single row, which are the ones
3152
3273
  * worth a sentence: a machine or a file that needs fixing says what to fix
@@ -3174,7 +3295,7 @@ const printTroubles = Effect.fn("sweep.printTroubles")(function* (troubles) {
3174
3295
  * warming the state directory, or seeing what GitHub would not answer.
3175
3296
  */
3176
3297
  const sweepCommand = Command.make("sweep", {}, Effect.fn("sweep.command")(function* () {
3177
- const report = yield* sweep;
3298
+ const report = yield* sweeping;
3178
3299
  yield* Console.log(report.repos.length === 0 ? "No repositories registered. Run dw-mc init inside a repository to register it." : `Swept ${count(report.facts.length, "pull request")} across ${report.repos.length === 1 ? "1 repository" : `${report.repos.length} repositories`}`);
3179
3300
  yield* printTroubles(report.troubles);
3180
3301
  }, Effect.catchTag(userFacing, asUserError))).pipe(Command.withDescription("Refresh what mission control knows about every tracked pull request"));
@@ -3247,9 +3368,8 @@ const separated = (blocks) => blocks.flatMap((lines, index) => index === 0 ? lin
3247
3368
  * The conversation on screen: people first, then a rule, then the bots.
3248
3369
  *
3249
3370
  * 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.
3371
+ * observed and never answered, and the bucket rules ignore bots for exactly
3372
+ * this reason.
3253
3373
  *
3254
3374
  * A bot is cut at the same moment I am measured against, because the window is
3255
3375
  * what has happened since I last acted rather than what is owed an answer. A
@@ -3296,7 +3416,7 @@ const comments = Command.make("comments", {
3296
3416
  const { number, repo } = yield* named(pr, Object.keys(file.repos ?? {}).toSorted());
3297
3417
  const facts = yield* swept(repo, number);
3298
3418
  const paint = yield* Paint;
3299
- const view = shown(yield* prConversation(repo, number), {
3419
+ const view = shown(yield* reading(`${repo}#${number}`, prConversation(repo, number)), {
3300
3420
  since: later(facts.myLastCommentAt, facts.myLastCommitAt),
3301
3421
  all
3302
3422
  });
@@ -3312,7 +3432,6 @@ const comments = Command.make("comments", {
3312
3432
  //#region src/cli/findings.ts
3313
3433
  /** The findings as the JSON the schema defines, rather than as this file spells it. */
3314
3434
  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
3435
  const jsonFlag = Flag.Boolean("json").pipe(Flag.withDefault(false), Flag.withDescription("Print the findings as the JSON a fix session is handed"));
3317
3436
  /** What a run's findings come to in one line, against the bar that blocks. */
3318
3437
  const summary = (found, blocksOn) => {
@@ -3321,10 +3440,10 @@ const summary = (found, blocksOn) => {
3321
3440
  return `${count(found.findings.length, "finding")}, ${blocked} blocking`;
3322
3441
  };
3323
3442
  /** 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)}`;
3443
+ const header$1 = (run, found, blocksOn) => `${run.repo}#${run.number} ${short(run.head)} ${summary(found, blocksOn)}`;
3325
3444
  /**
3326
- * The findings one to a line, in the order the runner reported them, ruled so
3327
- * the three columns read apart.
3445
+ * The findings one to a line, in the order the run reported them, ruled so the
3446
+ * three columns read apart.
3328
3447
  */
3329
3448
  const lines$1 = (found) => table(found.findings.map((finding) => [
3330
3449
  `${finding.file}:${finding.line}`,
@@ -3339,17 +3458,10 @@ const lines$1 = (found) => table(found.findings.map((finding) => [
3339
3458
  * off the state directory rather than worked out from GitHub: this command is
3340
3459
  * one I run inside a fix session, where another round trip to GitHub buys
3341
3460
  * 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
3461
  */
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.`);
3462
+ const currentRun = Effect.fn("findings.currentRun")(function* (repo, number) {
3463
+ const run = yield* lastRun(repo, number);
3464
+ return Option.isSome(run) ? run.value : yield* asUserError(`No review run on ${repo}#${number}. Run dw-mc review ${number} first.`);
3353
3465
  });
3354
3466
  /**
3355
3467
  * What the run reported, or the sentence saying it reported nothing at all.
@@ -3374,16 +3486,12 @@ const whatItFound = (run) => {
3374
3486
  */
3375
3487
  const findings = Command.make("findings", {
3376
3488
  pr: prArgument,
3377
- runner: runnerFlag$1,
3378
3489
  json: jsonFlag
3379
- }, Effect.fn("findings")(function* ({ json, pr, runner }) {
3490
+ }, Effect.fn("findings")(function* ({ json, pr }) {
3380
3491
  const file = Option.getOrElse(yield* read, () => ({}));
3381
3492
  const { number, repo } = yield* named(pr, Object.keys(file.repos ?? {}).toSorted());
3382
3493
  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
- }));
3494
+ const run = yield* currentRun(repo, number);
3387
3495
  const found = yield* whatItFound(run);
3388
3496
  if (json) {
3389
3497
  yield* Console.log(yield* asJson$2(found));
@@ -3395,19 +3503,20 @@ const findings = Command.make("findings", {
3395
3503
  //#endregion
3396
3504
  //#region src/adapters/agent.ts
3397
3505
  /**
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.
3506
+ * How one turn of Claude Code is spawned and given up on, and what a turn that
3507
+ * answers against a schema comes back with.
3400
3508
  *
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.
3509
+ * Every turn is reached this way, so the spawn, the patience and the one failure
3510
+ * they can end in live here rather than once per turn.
3403
3511
  */
3404
3512
  /** 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,
3513
+ var AgentFailed = class extends Schema.TaggedError()("AgentFailed", {
3514
+ /** The program that was spawned, which is what a search for it has to name. */
3515
+ program: Schema.String,
3407
3516
  detail: Schema.String
3408
3517
  }) {
3409
3518
  get message() {
3410
- return `The ${this.runner} review run failed: ${this.detail}`;
3519
+ return `The ${this.program} review run failed: ${this.detail}`;
3411
3520
  }
3412
3521
  };
3413
3522
  /**
@@ -3417,15 +3526,15 @@ var RunnerFailed = class extends Schema.TaggedError()("RunnerFailed", {
3417
3526
  * program that would not start or exited badly, and saying `claude` sends the
3418
3527
  * search to the wrong process.
3419
3528
  */
3420
- const failedBy = (program) => (detail) => new RunnerFailed({
3421
- runner: program,
3529
+ const failedBy = (program) => (detail) => new AgentFailed({
3530
+ program,
3422
3531
  detail
3423
3532
  });
3424
3533
  /**
3425
3534
  * How long each turn gets before it is given up on.
3426
3535
  *
3427
3536
  * 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
3537
+ * subagents takes real minutes, so its limit is there to catch a run that has
3429
3538
  * stopped rather than one that is slow. The second turn reads no code and
3430
3539
  * decides nothing - the review it reports on is already in the session it
3431
3540
  * resumes - and every run of it by hand came back in seconds.
@@ -3442,9 +3551,9 @@ const patience = {
3442
3551
  *
3443
3552
  * The launcher's own arguments go in front of the turn's, because they are what
3444
3553
  * 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
3554
+ * because draining one to the end first can block a run that is still writing to
3555
+ * the other. Every way a turn can fail to finish comes back from here as a
3556
+ * `AgentFailed`, so a caller is left with the turn's own answer and nothing else
3448
3557
  * to translate - a turn that never comes back included.
3449
3558
  */
3450
3559
  const turn = Effect.fnUntraced(function* (options) {
@@ -3454,7 +3563,7 @@ const turn = Effect.fnUntraced(function* (options) {
3454
3563
  const running = Effect.gen(function* () {
3455
3564
  const handle = yield* Effect.mapError(spawner.spawn(ChildProcess.make(program, [...prefix, ...options.args], {
3456
3565
  cwd: options.directory,
3457
- stdin: options.stdin ?? "pipe"
3566
+ stdin: "pipe"
3458
3567
  })), (error) => failed(error.message));
3459
3568
  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
3569
  const exitCode = yield* Effect.mapError(handle.exitCode, (error) => failed(error.message));
@@ -3467,118 +3576,10 @@ const turn = Effect.fnUntraced(function* (options) {
3467
3576
  });
3468
3577
  });
3469
3578
  //#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
3579
+ //#region src/adapters/claude.ts
3579
3580
  /**
3580
- * Claude Code as a runner: its own review command, the tool's own prompt, and
3581
- * the sessions I steer.
3581
+ * Claude Code: a review on a slash command, a review on the tool's own prompt,
3582
+ * and the sessions I steer.
3582
3583
  */
3583
3584
  /**
3584
3585
  * The two events of a stream-json run this reads, as the runner really writes
@@ -3594,7 +3595,7 @@ const Working = Schema.Struct({
3594
3595
  text: Schema.optionalKey(Schema.String)
3595
3596
  })) })
3596
3597
  });
3597
- const Result$1 = Schema.Struct({
3598
+ const Ended = Schema.Struct({
3598
3599
  type: Schema.Literal("result"),
3599
3600
  subtype: Schema.String,
3600
3601
  is_error: Schema.Boolean,
@@ -3604,7 +3605,7 @@ const Result$1 = Schema.Struct({
3604
3605
  structured_output: Schema.optionalKey(Schema.Unknown)
3605
3606
  });
3606
3607
  const asWorking = Schema.decodeUnknownOption(Schema.fromJsonString(Working));
3607
- const asResult = Schema.decodeUnknownOption(Schema.fromJsonString(Result$1));
3608
+ const asResult = Schema.decodeUnknownOption(Schema.fromJsonString(Ended));
3608
3609
  const heardIn = (line) => {
3609
3610
  const blocks = Option.match(asWorking(line), {
3610
3611
  onNone: () => [],
@@ -3618,7 +3619,7 @@ const heardIn = (line) => {
3618
3619
  /**
3619
3620
  * The result a turn ended on, or the failure it really was.
3620
3621
  *
3621
- * A turn that said nothing this can read and a turn the runner itself calls an
3622
+ * A turn that said nothing this can read and a turn Claude Code itself calls an
3622
3623
  * error are both failures: `subtype` is where a run that hit its turn limit or
3623
3624
  * lost its connection says so, and its `result` is the only word on why.
3624
3625
  */
@@ -3633,8 +3634,8 @@ const ended = (program, result) => {
3633
3634
  * to `onTool` while the run is still going, and what it said and how it ended
3634
3635
  * are what comes back.
3635
3636
  *
3636
- * Both of Claude Code's runners read a turn the same way, so the fold is here
3637
- * rather than once per runner.
3637
+ * Both shapes of review read a turn the same way, so the fold is here rather
3638
+ * than once per shape.
3638
3639
  */
3639
3640
  const transcript = (onTool) => (stdout) => stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.mapEffect((line) => {
3640
3641
  const heard = heardIn(line);
@@ -3650,30 +3651,38 @@ const transcript = (onTool) => (stdout) => stdout.pipe(Stream.decodeText(), Stre
3650
3651
  result: Option.orElse(asResult(line), () => soFar.result)
3651
3652
  })));
3652
3653
  /**
3653
- * One review run of Claude Code's own code review, headless, in `directory`.
3654
+ * One review run on a slash command, headless, in `directory`.
3654
3655
  *
3655
3656
  * The run is in the foreground and says what it is doing as it does it, which
3656
3657
  * is what `onTool` is for: a review takes minutes, and a terminal that prints
3657
3658
  * nothing for minutes is one I stop trusting.
3658
3659
  *
3660
+ * `--json-schema` is never passed here: verified by running it, the flag beside
3661
+ * `/code-review` breaks the run, which is why a slash command costs a second
3662
+ * turn that resumes the session and asks for the findings. My own instructions
3663
+ * ride on `--append-system-prompt` rather than on the command's own line,
3664
+ * because what a slash command does with its arguments is its business and not
3665
+ * this tool's.
3666
+ *
3659
3667
  * `--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.
3668
+ * request, and it is never passed either (ADR 0002). The report is everything
3669
+ * the run said on its own turns rather than the `result` alone: verified by
3670
+ * running it, a repository whose review command fans out to subagents can end on
3671
+ * a remark about them, and the report is the turn before that.
3665
3672
  */
3666
- const builtinReview = Effect.fn("runner.builtinReview")(function* (options) {
3673
+ const commandReview = Effect.fn("claude.commandReview")(function* (options) {
3667
3674
  const [program] = options.launcher.command;
3668
3675
  const run = yield* turn({
3669
3676
  command: options.launcher.command,
3670
3677
  directory: options.directory,
3671
3678
  args: [
3672
3679
  "-p",
3673
- `/code-review ${options.effort}`,
3680
+ options.line,
3674
3681
  "--output-format",
3675
3682
  "stream-json",
3676
- "--verbose"
3683
+ "--verbose",
3684
+ ...options.instructions === null ? [] : ["--append-system-prompt", options.instructions],
3685
+ ...options.model === null ? [] : ["--model", options.model]
3677
3686
  ],
3678
3687
  patience: {
3679
3688
  turn: "the review",
@@ -3712,12 +3721,12 @@ const reportFindings = [
3712
3721
  * is what makes it cheap and what makes it accurate - verified by running it,
3713
3722
  * the line numbers it reports beat the ones the prose gives. The output is
3714
3723
  * 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.
3724
+ * domain, and the schema the run is held to comes in from there too.
3716
3725
  *
3717
- * Every way this can end badly ends as a `RunnerFailed`, because a review run
3726
+ * Every way this can end badly ends as an `AgentFailed`, because a review run
3718
3727
  * that could not report is a failure and never a clean verdict.
3719
3728
  */
3720
- const builtinFindings = Effect.fn("runner.builtinFindings")(function* (options) {
3729
+ const findingsTurn = Effect.fn("claude.findingsTurn")(function* (options) {
3721
3730
  const [program] = options.launcher.command;
3722
3731
  const printed = yield* turn({
3723
3732
  command: options.launcher.command,
@@ -3743,19 +3752,15 @@ const builtinFindings = Effect.fn("runner.builtinFindings")(function* (options)
3743
3752
  return structured_output;
3744
3753
  }, Effect.scoped);
3745
3754
  /**
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`.
3755
+ * One review run of the tool's own review prompt, in `directory`.
3754
3756
  *
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.
3757
+ * It is one turn rather than two: verified by running it, `--json-schema` beside
3758
+ * an ordinary prompt gives both the prose the run wrote and the
3759
+ * `structured_output` it validated, where the same flag on a slash command
3760
+ * breaks the run. The schema arrives as inline JSON and never as a path - a path
3761
+ * is where Claude Code reports `--json-schema is not valid JSON`.
3757
3762
  */
3758
- const promptReview = Effect.fn("runner.promptReview")(function* (options) {
3763
+ const promptReview = Effect.fn("claude.promptReview")(function* (options) {
3759
3764
  const [program] = options.launcher.command;
3760
3765
  const run = yield* turn({
3761
3766
  command: options.launcher.command,
@@ -3786,13 +3791,53 @@ const promptReview = Effect.fn("runner.promptReview")(function* (options) {
3786
3791
  };
3787
3792
  }, Effect.scoped);
3788
3793
  /**
3789
- * The tool's own review prompt, on whichever CLI the runner names.
3794
+ * One review run, in whichever shape it was configured in.
3790
3795
  *
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.
3796
+ * A slash command takes two turns and the tool's own prompt takes one, which is
3797
+ * Claude Code's doing and nobody else's: a caller hands over the turn and gets
3798
+ * the same answer back either way.
3799
+ *
3800
+ * The second turn's failure is kept beside the first turn's prose rather than
3801
+ * replacing it. A review that ran and could not report is still worth reading,
3802
+ * and it is recorded as the failure it is.
3794
3803
  */
3795
- const promptRun = (options) => options.runner === "codex" ? codexReview(options) : promptReview(options);
3804
+ const reviewTurns = Effect.fn("claude.reviewTurns")(function* (options) {
3805
+ const { directory, jsonSchema, launcher, model, onTool } = options;
3806
+ if (options.turn._tag === "prompt") {
3807
+ const run = yield* promptReview({
3808
+ launcher,
3809
+ directory,
3810
+ prompt: options.turn.text,
3811
+ model,
3812
+ jsonSchema,
3813
+ onTool
3814
+ });
3815
+ return {
3816
+ sessionId: run.sessionId,
3817
+ prose: run.prose,
3818
+ findings: Result.succeed(run.findings)
3819
+ };
3820
+ }
3821
+ const run = yield* commandReview({
3822
+ launcher,
3823
+ directory,
3824
+ line: options.turn.line,
3825
+ instructions: options.turn.instructions,
3826
+ model,
3827
+ onTool
3828
+ });
3829
+ const findings = yield* Effect.result(findingsTurn({
3830
+ launcher,
3831
+ directory,
3832
+ sessionId: run.sessionId,
3833
+ jsonSchema
3834
+ }));
3835
+ return {
3836
+ sessionId: run.sessionId,
3837
+ prose: run.report,
3838
+ findings
3839
+ };
3840
+ });
3796
3841
  /**
3797
3842
  * An interactive `claude` in `directory`, opened on `prompt`, with my terminal
3798
3843
  * handed straight to it.
@@ -3802,7 +3847,7 @@ const promptRun = (options) => options.runner === "codex" ? codexReview(options)
3802
3847
  * no headless review turn wants. They sit in front of the
3803
3848
  * prompt, because `claude` takes its flags before its positional argument.
3804
3849
  *
3805
- * This is the one place a runner is not read: the three streams are inherited,
3850
+ * This is the one place a run is not read: the three streams are inherited,
3806
3851
  * so what is on the screen is the session itself and not a transcript of it,
3807
3852
  * and what I type reaches it. The child is not detached for the same reason -
3808
3853
  * a detached child sits outside the terminal's foreground process group, where
@@ -3815,7 +3860,7 @@ const promptRun = (options) => options.runner === "codex" ? codexReview(options)
3815
3860
  * Ctrl-C ended badly for `claude` and not for me, so this reports it rather
3816
3861
  * than failing on it; only a `claude` that would not start at all is a failure.
3817
3862
  */
3818
- const steeredSession = Effect.fn("runner.steeredSession")(function* (options) {
3863
+ const steeredSession = Effect.fn("claude.steeredSession")(function* (options) {
3819
3864
  const [program, ...prefix] = options.launcher.command;
3820
3865
  const failed = failedBy(program);
3821
3866
  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
@@ -3860,7 +3905,7 @@ const asJson$1 = Schema.encodeEffect(Schema.fromJsonString(Selection));
3860
3905
  *
3861
3906
  * The findings go in verbatim rather than described, because a re-description
3862
3907
  * 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
3908
+ * finding it is on: the finding is what the review thought, the note is what I
3864
3909
  * think, and I am the one who picked it.
3865
3910
  *
3866
3911
  * Pushing is mine either way, and `commits` says whether committing is too.
@@ -3953,11 +3998,11 @@ const fix = Command.make("fix", {
3953
3998
  const file = Option.getOrElse(yield* read, () => ({}));
3954
3999
  const { number, repo } = yield* named(pr, Object.keys(file.repos ?? {}).toSorted());
3955
4000
  const settings = settingsFor(file, repo);
3956
- const run = yield* currentRun(repo, number, settings.review.runners);
4001
+ const run = yield* currentRun(repo, number);
3957
4002
  const found = yield* whatItFound(run);
3958
4003
  yield* Console.log(header$1(run, found, settings.stamp.blocks_on));
3959
4004
  if (found.findings.length === 0) return;
3960
- const view = yield* prView(repo, number);
4005
+ const view = yield* reading(`${repo}#${number}`, prView(repo, number));
3961
4006
  yield* fixable(number, run.head, view.headRefOid);
3962
4007
  const picked = yield* choose("Which findings does the session carry?", choicesOf$1(found, yield* width));
3963
4008
  const chosen = yield* Effect.catchTag(noted(Option.getOrElse(picked, () => [])), "QuitError", () => Effect.succeed([]));
@@ -3995,105 +4040,64 @@ const fix = Command.make("fix", {
3995
4040
  ...userFacing,
3996
4041
  "GitFailed",
3997
4042
  "WorktreeHeld",
3998
- "RunnerFailed"
4043
+ "AgentFailed"
3999
4044
  ], asUserError))).pipe(Command.withDescription("Pick findings from the current review run and open a fix session on them"));
4000
4045
  //#endregion
4001
4046
  //#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
4047
  const effortFlag$1 = Flag.Literals("effort", [
4005
4048
  "low",
4006
4049
  "medium",
4007
- "high"
4008
- ]).pipe(Flag.withDescription("How much a built-in review run spends on this repository"), Flag.optional);
4050
+ "high",
4051
+ "xhigh",
4052
+ "max"
4053
+ ]).pipe(Flag.withDescription("How much a review run spends on this repository"), Flag.optional);
4009
4054
  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
4055
  /** The settings the flags asked for, and only those. */
4051
4056
  const asked = (base, effort) => ({
4052
4057
  ...Option.isSome(base) ? { base: base.value } : {},
4053
4058
  ...Option.isSome(effort) ? { review: { effort: effort.value } } : {}
4054
4059
  });
4060
+ /** What a review will open on, as the setup prints it back. */
4061
+ const opening = (defaults) => {
4062
+ const review = {
4063
+ ...builtIn.review,
4064
+ ...defaults.review
4065
+ };
4066
+ return review.command === null ? "my own prompt" : [review.command, review.effort].filter((part) => part !== null).join(" ");
4067
+ };
4055
4068
  const row = (label, value) => `${label.padEnd(12)}${value}`;
4056
4069
  /**
4057
4070
  * Both the machine setup and the repository registration: there is deliberately
4058
4071
  * no separate `setup` command.
4059
4072
  *
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.
4073
+ * The first run on a machine checks `gh` and spells the defaults out in the
4074
+ * configuration file. Run inside a repository, it also registers that
4075
+ * `owner/repo`, taking the name from `gh` so I never type it. Run again, it
4076
+ * changes what the flags name, keeps every other setting the file already had,
4077
+ * and leaves the file untouched where nothing was decided differently.
4078
+ *
4079
+ * It asks nothing. Reviews run on Claude Code, and what a run opens on is
4080
+ * `review.command` and `review.prompt` - a line and a paragraph that belong in
4081
+ * the file rather than in a terminal prompt.
4066
4082
  *
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.
4083
+ * `--effort` and `--base` are about one repository, so they land on the
4084
+ * repository this ran in, or in the defaults when it ran outside one.
4070
4085
  */
4071
4086
  const init = Command.make("init", {
4072
- runner: runnerFlag,
4073
- codex: codexFlag,
4074
4087
  effort: effortFlag$1,
4075
4088
  base: baseFlag
4076
- }, Effect.fn("init")(function* ({ base, codex, effort, runner }) {
4089
+ }, Effect.fn("init")(function* ({ base, effort }) {
4077
4090
  yield* requireAuth;
4078
4091
  const config = yield* ConfigStore;
4079
4092
  const before = yield* read;
4080
4093
  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) } } : {});
4094
+ const defaults = file.defaults === void 0 ? merge$1(builtIn, file.defaults ?? {}) : file.defaults ?? {};
4090
4095
  const state = yield* stateDirectory;
4091
4096
  const repo = yield* currentRepo.pipe(Effect.asSome, Effect.catchTag("NoRepository", () => Effect.succeedNone));
4092
4097
  const overrides = asked(base, effort);
4093
4098
  const written = Option.isSome(repo) ? withRepo(withDefaults(file, defaults), repo.value, overrides) : withDefaults(file, merge$1(defaults, overrides));
4094
4099
  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(", ")));
4100
+ yield* Console.log(row("review", opening(written.defaults ?? {})));
4097
4101
  yield* Console.log(row("config", config.path));
4098
4102
  yield* Console.log(row("state", state));
4099
4103
  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 +4105,8 @@ const init = Command.make("init", {
4101
4105
  "ConfigMalformed",
4102
4106
  "GhUnauthenticated",
4103
4107
  "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"));
4108
+ "GhUnreadable"
4109
+ ], asUserError))).pipe(Command.withDescription("Set this machine up and register the repository I am in"));
4110
4110
  //#endregion
4111
4111
  //#region src/domain/stamp.ts
4112
4112
  /**
@@ -4292,8 +4292,7 @@ const merge = Command.make("merge", { pr: prArgument }, Effect.fn("merge")(funct
4292
4292
  const file = Option.getOrElse(yield* read, () => ({}));
4293
4293
  const { number, repo } = yield* named(pr, Object.keys(file.repos ?? {}).toSorted());
4294
4294
  const settings = settingsFor(file, repo);
4295
- const view = yield* prView(repo, number);
4296
- const me = yield* viewer;
4295
+ const [view, me] = yield* reading(`${repo}#${number}`, Effect.all([prView(repo, number), viewer]));
4297
4296
  const head = view.headRefOid;
4298
4297
  yield* refuse(decide$2({
4299
4298
  repo,
@@ -4304,7 +4303,7 @@ const merge = Command.make("merge", { pr: prArgument }, Effect.fn("merge")(funct
4304
4303
  reviewDecision: reviewDecisionOf(view.reviewDecision),
4305
4304
  checks: rollupState(view.statusCheckRollup, settings.ci.ignore),
4306
4305
  mergeable: mergeabilityOf(view.mergeable),
4307
- ...yield* reviewedAt(repo, number, head, settings),
4306
+ ...yield* reviewedAt(repo, number, head, settings.stamp.blocks_on),
4308
4307
  withdrawnAt: yield* withdrawnAt(repo, number)
4309
4308
  }));
4310
4309
  yield* mergePr(repo, number);
@@ -4530,7 +4529,7 @@ const where = (facts) => `${facts.repo}#${facts.number}`;
4530
4529
  * and a merge must never cost only that (ADR 0008).
4531
4530
  */
4532
4531
  const picker = (dispatch) => Effect.fn("pick")(function* () {
4533
- const report = yield* sweep;
4532
+ const report = yield* sweeping;
4534
4533
  if (report.repos.length === 0) {
4535
4534
  yield* Console.log("No repositories registered. Run dw-mc init inside a repository to register it.");
4536
4535
  return;
@@ -4596,9 +4595,11 @@ const rebase = Command.make("rebase", { pr: prArgument }, Effect.fn("rebase")(fu
4596
4595
  const file = Option.getOrElse(yield* read, () => ({}));
4597
4596
  const { number, repo } = yield* named(pr, Object.keys(file.repos ?? {}).toSorted());
4598
4597
  const settings = settingsFor(file, repo);
4599
- const view = yield* prView(repo, number);
4600
- const open = yield* openPrs(repo);
4601
- const me = yield* viewer;
4598
+ const [view, open, me] = yield* reading(`${repo}#${number}`, Effect.all([
4599
+ prView(repo, number),
4600
+ openPrs(repo),
4601
+ viewer
4602
+ ]));
4602
4603
  yield* refuse(decide$3({
4603
4604
  repo,
4604
4605
  number,
@@ -4657,8 +4658,7 @@ const rerun = Command.make("rerun", { pr: prArgument }, Effect.fn("rerun")(funct
4657
4658
  const file = Option.getOrElse(yield* read, () => ({}));
4658
4659
  const { number, repo } = yield* named(pr, Object.keys(file.repos ?? {}).toSorted());
4659
4660
  const settings = settingsFor(file, repo);
4660
- const view = yield* prView(repo, number);
4661
- const me = yield* viewer;
4661
+ const [view, me] = yield* reading(`${repo}#${number}`, Effect.all([prView(repo, number), viewer]));
4662
4662
  const unclassified = {
4663
4663
  repo,
4664
4664
  number,
@@ -4776,9 +4776,11 @@ const resolve = Command.make("resolve", {
4776
4776
  }, Effect.fn("resolve")(function* ({ pr, print }) {
4777
4777
  const file = Option.getOrElse(yield* read, () => ({}));
4778
4778
  const { number, repo } = yield* named(pr, Object.keys(file.repos ?? {}).toSorted());
4779
- const view = yield* prView(repo, number);
4780
- const open = yield* openPrs(repo);
4781
- const me = yield* viewer;
4779
+ const [view, open, me] = yield* reading(`${repo}#${number}`, Effect.all([
4780
+ prView(repo, number),
4781
+ openPrs(repo),
4782
+ viewer
4783
+ ]));
4782
4784
  const conflict = yield* conflictFor(repo, number);
4783
4785
  yield* allowed({
4784
4786
  repo,
@@ -4840,7 +4842,7 @@ const resolve = Command.make("resolve", {
4840
4842
  ...userFacing,
4841
4843
  "GitFailed",
4842
4844
  "WorktreeHeld",
4843
- "RunnerFailed"
4845
+ "AgentFailed"
4844
4846
  ], asUserError))).pipe(Command.withDescription("Open a session on the conflict that stopped a rebase, in a worktree of my own"));
4845
4847
  //#endregion
4846
4848
  //#region src/adapters/notify.ts
@@ -4861,83 +4863,8 @@ const announce = Effect.fn("notify.announce")(function* (title, message) {
4861
4863
  yield* Effect.ignore(capture("osascript", ["-e", `display notification ${quoted(message)} with title ${quoted(title)}`]));
4862
4864
  });
4863
4865
  //#endregion
4864
- //#region src/adapters/progress.ts
4865
- /** The frames of the spinner, in the order they turn. */
4866
- const frames = [
4867
- "⠋",
4868
- "⠙",
4869
- "⠹",
4870
- "⠸",
4871
- "⠼",
4872
- "⠴",
4873
- "⠦",
4874
- "⠧",
4875
- "⠇",
4876
- "⠏"
4877
- ];
4878
- /** How long one frame is on the screen. */
4879
- const frameFor = Duration.millis(120);
4880
- /** A stretch of time as a terminal says it: `1m12s`, or `9s` under the minute. */
4881
- const elapsed = (millis) => {
4882
- const seconds = Math.floor(millis / 1e3);
4883
- return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, "0")}s`;
4884
- };
4885
- /**
4886
- * Runs `use` while the screen says it is still going, and hands `use` the way
4887
- * to report what the run reached for.
4888
- *
4889
- * A review takes minutes, and a terminal that prints nothing for minutes is one
4890
- * I stop trusting. What it printed instead was a line per tool call, which is a
4891
- * wall of `· Bash` that says as little as silence did. This keeps one line and
4892
- * rewrites it: the spinner says the run is alive, the counts say how far it has
4893
- * got, and the line is gone when the run is over, so what stays on the screen is
4894
- * the report.
4895
- *
4896
- * How that line reads is `reads` and not this module's business. What a count
4897
- * is worth saying belongs to the command that is counting, and a screen that
4898
- * worded it here would need the words a command already has.
4899
- *
4900
- * Where there is no screen to measure - a pipe, a CI log, a test - the counts
4901
- * would be a mess of half-drawn lines, so the tools go out one to a line as
4902
- * they did before. `columns` is zero exactly there.
4903
- */
4904
- const spinning = Effect.fnUntraced(function* (reads, use) {
4905
- const terminal = yield* Terminal.Terminal;
4906
- const columns = yield* terminal.columns;
4907
- if (columns === 0) return yield* use((tool) => Console.log(` · ${tool}`));
4908
- let doing = {
4909
- tools: 0,
4910
- subagents: 0
4911
- };
4912
- const onTool = (tool) => Effect.sync(() => {
4913
- doing = {
4914
- tools: doing.tools + 1,
4915
- subagents: doing.subagents + (tool === "Agent" ? 1 : 0)
4916
- };
4917
- });
4918
- const started = yield* Clock.currentTimeMillis;
4919
- const draw = (text) => Effect.ignore(terminal.display(`\r${text.slice(0, columns - 1).padEnd(columns - 1)}`));
4920
- const frame = (since, turn) => `${frames[turn % frames.length]} ${reads(doing, elapsed(since))}`;
4921
- yield* draw(frame(0, 0));
4922
- const turning = yield* Effect.forkChild(Effect.gen(function* () {
4923
- for (let turn = 1;; turn = turn + 1) {
4924
- yield* Effect.sleep(frameFor);
4925
- yield* draw(frame((yield* Clock.currentTimeMillis) - started, turn));
4926
- }
4927
- }));
4928
- return yield* Effect.onExit(use(onTool), () => Effect.flatMap(Fiber.interrupt(turning), () => Effect.ignore(terminal.display(`\r${" ".repeat(columns - 1)}\r`))));
4929
- });
4930
- //#endregion
4931
4866
  //#region src/domain/persona.ts
4932
4867
  /**
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
4868
  * The reviewer persona, derived from Addy Osmani's `code-reviewer` agent
4942
4869
  * (`addyosmani/agent-skills`, MIT, see `NOTICE.md`).
4943
4870
  *
@@ -4983,18 +4910,18 @@ the task or the pull request description before the code. Every Critical and Req
4983
4910
  specific fix in its summary. Where you are uncertain, say so in the summary and say what would settle
4984
4911
  it, rather than guessing.`;
4985
4912
  /**
4986
- * The prompt one `prompt` review run opens on.
4913
+ * The prompt a review run with no slash command opens on.
4987
4914
  *
4988
4915
  * 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.
4916
+ * validated: the schema arrives beside the prompt, so describing it here would
4917
+ * be the same shape written twice.
4991
4918
  *
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.
4919
+ * `review.prompt` is a passthrough and goes in first, spelled exactly as the
4920
+ * file spells it. A repository with its own instructions gets its review with
4921
+ * the persona behind it, and the tool does not try to interpret the value.
4995
4922
  */
4996
4923
  const reviewPrompt = (reviewing) => [
4997
- ...reviewing.skill === null ? [] : [reviewing.skill, ""],
4924
+ ...reviewing.prompt === null ? [] : [reviewing.prompt, ""],
4998
4925
  persona,
4999
4926
  "",
5000
4927
  `The change is ${reviewing.repo}#${reviewing.number}, "${reviewing.title}".`,
@@ -5005,103 +4932,114 @@ const reviewPrompt = (reviewing) => [
5005
4932
  "line it is at, its severity and a one-sentence summary. The verdict is clean when there is",
5006
4933
  "nothing to report, and findings otherwise."
5007
4934
  ].join("\n");
4935
+ /**
4936
+ * What one review run opens on, decided by what the repository configured.
4937
+ *
4938
+ * A slash command is the review, so the persona stays out of its way and my own
4939
+ * instructions ride beside it. Without one the review is the tool's own, and my
4940
+ * instructions go in front of the persona. The effort word follows the command
4941
+ * because that is where a slash command takes its arguments; a repository that
4942
+ * spells its own arguments out sets `review.effort` to null and keeps the line.
4943
+ */
4944
+ const turnFor = (review, about) => review.command === null ? {
4945
+ _tag: "prompt",
4946
+ text: reviewPrompt({
4947
+ ...about,
4948
+ prompt: review.prompt
4949
+ })
4950
+ } : {
4951
+ _tag: "command",
4952
+ line: [review.command, review.effort].filter((part) => part !== null).join(" "),
4953
+ instructions: review.prompt
4954
+ };
5008
4955
  //#endregion
5009
4956
  //#region src/cli/review.ts
5010
- /** What the spinner says a run has got through, while it is still going. */
5011
- const saying = (runner) => (doing, since) => [
5012
- `${runner} reviewing`,
4957
+ /** What the heartbeat says a run has got through, while it is still going. */
4958
+ const saying = (doing) => (since) => [
4959
+ "reviewing",
5013
4960
  count(doing.tools, "tool"),
5014
4961
  doing.subagents === 0 ? null : count(doing.subagents, "subagent"),
5015
4962
  since
5016
4963
  ].filter((part) => part !== null).join(" · ");
4964
+ const commandFlag = Flag.String("command").pipe(Flag.withDescription("The slash command this run opens on, over what the repository configured"), Flag.optional);
4965
+ const promptFlag = Flag.String("prompt").pipe(Flag.withDescription("The review instructions this run carries, over what the repository configured"), Flag.optional);
5017
4966
  const effortFlag = Flag.Literals("effort", [
5018
4967
  "low",
5019
4968
  "medium",
5020
- "high"
4969
+ "high",
4970
+ "xhigh",
4971
+ "max"
5021
4972
  ]).pipe(Flag.withDescription("How much this run spends, over what the repository configured"), Flag.optional);
4973
+ const modelFlag = Flag.String("model").pipe(Flag.withDescription("The model this run reads the code on, over what the repository configured"), Flag.optional);
4974
+ const promptOnlyFlag = Flag.Boolean("prompt-only").pipe(Flag.withDefault(false), Flag.withDescription("Review on the prompt alone, whatever slash command the repository configured"));
4975
+ const commandOnlyFlag = Flag.Boolean("command-only").pipe(Flag.withDefault(false), Flag.withDescription("Review on the slash command alone, whatever instructions the repository configured"));
5022
4976
  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);
4977
+ /** A flag that names a value beside the flag that clears it: one of the two, never both. */
4978
+ const opposite = (flag, given, only) => Option.isSome(given) ? [`--${flag} and --${only} say opposite things. Pass one.`] : [];
4979
+ /** What this run is asked, once the flags have had their say over the file. */
4980
+ const asking = (options) => {
4981
+ const clash = [...options.promptOnly ? opposite("command", options.command, "prompt-only") : [], ...options.commandOnly ? opposite("prompt", options.prompt, "command-only") : []];
4982
+ if (clash.length > 0) return Effect.fail(new CliError.UserError({ cause: clash.join(" ") }));
4983
+ const { review } = options.settings;
4984
+ return Effect.succeed({
4985
+ command: options.promptOnly ? null : Option.getOrElse(options.command, () => review.command),
4986
+ effort: Option.getOrElse(options.effort, () => review.effort),
4987
+ prompt: options.commandOnly ? null : Option.getOrElse(options.prompt, () => review.prompt),
4988
+ model: Option.getOrElse(options.model, () => review.model)
4989
+ });
5027
4990
  };
5028
4991
  /**
5029
4992
  * What the re-run rule is asked about, read before anything is cut or spawned:
5030
4993
  * the whole point of the rule is not paying for the run.
5031
4994
  *
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
4995
  * GitHub is asked what changed only where there is a run to measure from and a
5036
4996
  * different head to measure to. Neither is the rule deciding anything - there is
5037
4997
  * simply nothing to compare - and a comparison GitHub would not answer comes
5038
4998
  * back as nothing known rather than as a failure of the command.
5039
4999
  */
5040
- const askedOf = Effect.fn("review.askedOf")(function* (repo, number, head, runner) {
5041
- const last = Option.getOrNull(yield* lastRun(repo, number, runner));
5000
+ const askedOf = Effect.fn("review.askedOf")(function* (repo, number, head) {
5001
+ const last = Option.getOrNull(yield* lastRun(repo, number));
5042
5002
  return {
5043
5003
  last,
5044
5004
  head,
5045
5005
  changed: last === null || last.head === head ? null : Option.getOrNull(yield* Effect.option(comparedFiles(repo, last.head, head)))
5046
5006
  };
5047
5007
  });
5008
+ /** How a run reads on the line above it: what it opens on, and on which model. */
5009
+ const spending = (turn, model) => [
5010
+ turn._tag === "command" ? turn.line : "the tool's own prompt",
5011
+ turn._tag === "command" && turn.instructions !== null ? "with my own instructions" : null,
5012
+ model === null ? null : `model ${model}`
5013
+ ].filter((part) => part !== null).join(", ");
5048
5014
  /**
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.
5015
+ * The review of the head in the worktree.
5067
5016
  *
5068
5017
  * Whatever the reporting comes to is a value and not a failure: the review is
5069
5018
  * already worth keeping, and a turn that could not report is recorded as the
5070
5019
  * failure it is rather than lost with it.
5071
5020
  */
5072
5021
  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,
5022
+ const { directory, launcher, model, turn } = options;
5023
+ let doing = {
5024
+ tools: 0,
5025
+ subagents: 0
5026
+ };
5027
+ const run = yield* beating(saying(doing), (says) => reviewTurns({
5097
5028
  launcher,
5098
5029
  directory,
5099
- prompt,
5100
- model: settings.review.model,
5030
+ turn,
5031
+ model,
5101
5032
  jsonSchema,
5102
- onTool
5033
+ onTool: (tool) => {
5034
+ doing = {
5035
+ tools: doing.tools + 1,
5036
+ subagents: doing.subagents + (tool === "Agent" ? 1 : 0)
5037
+ };
5038
+ return says(saying(doing), ` · ${tool}`);
5039
+ }
5103
5040
  }));
5104
- const reported = yield* Effect.result(Schema.decodeUnknownEffect(Reported)(run.findings));
5041
+ const answered = Result.isFailure(run.findings) ? Effect.fail(run.findings.failure) : Effect.succeed(run.findings.success);
5042
+ const reported = yield* Effect.result(Effect.flatMap(answered, (output) => Schema.decodeUnknownEffect(Reported)(output)));
5105
5043
  return {
5106
5044
  sessionId: run.sessionId,
5107
5045
  prose: run.prose,
@@ -5109,12 +5047,12 @@ const reviewOn = Effect.fn("review.reviewOn")(function* (options) {
5109
5047
  };
5110
5048
  });
5111
5049
  /**
5112
- * What a runner's review comes to on disk: the findings it reported, or the
5113
- * failure it reached instead.
5050
+ * What the review comes to on disk: the findings it reported, or the failure it
5051
+ * reached instead.
5114
5052
  *
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.
5053
+ * A run that would not start is as much a failure as a turn that answered in a
5054
+ * shape that does not validate, and both are recorded: the head has been tried
5055
+ * and nothing was found, which is not the same as nothing being wrong.
5118
5056
  */
5119
5057
  const ranBy = (got) => {
5120
5058
  if (Result.isFailure(got)) return {
@@ -5146,20 +5084,14 @@ const ranBy = (got) => {
5146
5084
  };
5147
5085
  };
5148
5086
  /**
5149
- * The command's own failure where a runner that decides my bar reported
5150
- * nothing, and nothing where they all reported.
5087
+ * The command's own failure where the review reported nothing.
5151
5088
  *
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.
5089
+ * The run is written down either way; what the exit code says is whether the
5090
+ * review I asked for is one to trust.
5156
5091
  */
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.` }));
5092
+ const unreported = (run, number) => {
5093
+ const detail = detailOf(run);
5094
+ 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
5095
  };
5164
5096
  /**
5165
5097
  * One review run, started by hand, in the foreground.
@@ -5169,109 +5101,102 @@ const unreported = (recorded, deciding, number) => {
5169
5101
  * the one who decides to spend it.
5170
5102
  *
5171
5103
  * 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.
5104
+ * reviewed is the pull request's head rather than whatever I have open.
5176
5105
  *
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.
5106
+ * What it found is kept against that head, which is what takes the pull request
5107
+ * out of Needs review run and what a blocking finding later puts into Needs me.
5180
5108
  *
5181
5109
  * The report is printed as well as kept. A run I waited minutes for should not
5182
5110
  * need a second command to read.
5183
5111
  */
5184
5112
  const review = Command.make("review", {
5185
5113
  pr: prArgument,
5114
+ command: commandFlag,
5115
+ prompt: promptFlag,
5186
5116
  effort: effortFlag,
5117
+ model: modelFlag,
5118
+ promptOnly: promptOnlyFlag,
5119
+ commandOnly: commandOnlyFlag,
5187
5120
  force: forceFlag$1
5188
- }, Effect.fn("review")(function* ({ effort, force, pr }) {
5121
+ }, Effect.fn("review")(function* ({ command, commandOnly, effort, force, model, pr, prompt, promptOnly }) {
5189
5122
  const file = Option.getOrElse(yield* read, () => ({}));
5190
5123
  const { number, repo } = yield* named(pr, Object.keys(file.repos ?? {}).toSorted());
5191
5124
  const settings = settingsFor(file, repo);
5192
5125
  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);
5126
+ const asked = yield* asking({
5127
+ settings,
5128
+ command,
5129
+ prompt,
5130
+ effort,
5131
+ model,
5132
+ promptOnly,
5133
+ commandOnly
5134
+ });
5196
5135
  const view = yield* prView(repo, number);
5197
5136
  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;
5137
+ const since = force ? null : skippedSince(yield* askedOf(repo, number, view.headRefOid), settings.review.docs_only);
5138
+ if (since !== null) {
5139
+ yield* Console.log(` only documentation changed since ${short(since)}, so this run is skipped. Pass --force to review it anyway.`);
5140
+ return;
5141
+ }
5205
5142
  const about = {
5206
5143
  repo,
5207
5144
  number,
5208
5145
  title: view.title,
5209
5146
  base: view.baseRefName,
5210
- skill: settings.review.skill
5147
+ prompt: asked.prompt
5211
5148
  };
5149
+ const turn = turnFor(asked, about);
5212
5150
  yield* Effect.gen(function* () {
5213
5151
  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
- }
5152
+ yield* Console.log(` head ${short(worktree.head)} ${spending(turn, asked.model)}`);
5153
+ const got = yield* Effect.result(reviewOn({
5154
+ launcher,
5155
+ directory: worktree.directory,
5156
+ turn,
5157
+ model: asked.model
5158
+ }));
5230
5159
  return {
5231
5160
  head: worktree.head,
5232
- done
5161
+ ran: ranBy(got)
5233
5162
  };
5234
5163
  }));
5235
5164
  const ranAt = yield* DateTime.now;
5236
5165
  const runs = yield* storeFor("runs", ReviewRun);
5237
5166
  const latest = yield* storeFor("runs", LastReviewed);
5238
5167
  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
- }
5168
+ const got = ran.ran;
5169
+ const run = {
5170
+ repo,
5171
+ number,
5172
+ head: ran.head,
5173
+ command: asked.command,
5174
+ effort: asked.command === null ? null : asked.effort,
5175
+ sessionId: got.sessionId,
5176
+ ranAt,
5177
+ outcome: got.outcome
5178
+ };
5179
+ yield* runs.set(runKey(repo, number, run.head), run);
5180
+ yield* latest.set(latestKey(repo, number), { head: run.head });
5181
+ yield* reports.set(reportKey(repo, number, run.head), reportDocument(run, view.title, got.prose ?? ""));
5182
+ yield* Console.log("");
5183
+ const detail = detailOf(run);
5184
+ if (detail !== null) yield* Console.log(` reported nothing: ${detail}`);
5185
+ else {
5262
5186
  const found = reportedBy(run);
5263
- if (found === null) continue;
5264
- if (got.prose !== null) {
5265
- yield* Console.log(got.prose);
5266
- yield* Console.log("");
5187
+ if (found !== null) {
5188
+ if (got.prose !== null) {
5189
+ yield* Console.log(got.prose);
5190
+ yield* Console.log("");
5191
+ }
5192
+ yield* Console.log(summary(found, settings.stamp.blocks_on));
5193
+ for (const line of lines$1(found)) yield* Console.log(` ${line}`);
5267
5194
  }
5268
- yield* Console.log(summary(found, settings.stamp.blocks_on));
5269
- for (const line of lines$1(found)) yield* Console.log(` ${line}`);
5270
5195
  }
5271
5196
  yield* Console.log(`Recorded against ${short(ran.head)} in ${yield* stateDirectory}`);
5272
- yield* unreported(recorded, deciding, number);
5197
+ yield* unreported(run, number);
5273
5198
  }).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"));
5199
+ }, Effect.catchTag([...userFacing, "GitFailed"], asUserError))).pipe(Command.withDescription("Review one pull request on Claude Code, in a throwaway worktree"));
5275
5200
  //#endregion
5276
5201
  //#region src/cli/stamp.ts
5277
5202
  const withdrawFlag = Flag.Boolean("withdraw").pipe(Flag.withDefault(false), Flag.withDescription("Take the stamp off this pull request, until its head changes"));
@@ -5337,7 +5262,7 @@ const lines = (grouped, stamped, paint) => {
5337
5262
  * It sweeps first, every time: a table I read is never one I forgot to refresh.
5338
5263
  */
5339
5264
  const status = Command.make("status", {}, Effect.fn("status")(function* () {
5340
- const report = yield* sweep;
5265
+ const report = yield* sweeping;
5341
5266
  if (report.repos.length === 0) {
5342
5267
  yield* Console.log("No repositories registered. Run dw-mc init inside a repository to register it.");
5343
5268
  return;
@@ -5429,7 +5354,7 @@ const uninstall = Command.make("uninstall", {
5429
5354
  * Running from source leaves the constant undeclared rather than undefined, so
5430
5355
  * the check has to be `typeof` and the fallback is what a test reads.
5431
5356
  */
5432
- const version = "0.3.0";
5357
+ const version = "0.5.0";
5433
5358
  /** Where the project lives, printed beside the version in the header. */
5434
5359
  const projectUrl = "github.com/dominikwozniak/dw-mc";
5435
5360
  const subcommands = [