dw-mc 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/bin.js +2060 -1900
  2. package/dist/bin.js.map +1 -1
  3. package/package.json +1 -1
package/dist/bin.js CHANGED
@@ -112,13 +112,21 @@ 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
+ /**
117
+ * The words a review run is described in.
118
+ *
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
+ */
116
124
  /**
117
125
  * How much a review run spends, in the words the slash command takes.
118
126
  *
119
- * The set is Claude Code's and not this tool's, so it is wider than the three
120
- * words a review used to be held to: a run that would be worth `max` is one I
121
- * should be able to ask for without spelling the whole command out.
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.
122
130
  */
123
131
  const Effort = Schema.Literals([
124
132
  "low",
@@ -133,6 +141,8 @@ const Severity = Schema.Literals([
133
141
  "warning",
134
142
  "info"
135
143
  ]);
144
+ //#endregion
145
+ //#region src/adapters/config.ts
136
146
  /**
137
147
  * What one section of the file may say. Every key is optional: what the file
138
148
  * leaves out is inherited rather than reset, so `defaults` and a repository's
@@ -322,7 +332,7 @@ var ConfigMalformed = class extends Schema.TaggedError()("ConfigMalformed", {
322
332
  return `${this.path} is not valid dw-mc configuration: ${this.reason}\nFix the file, or delete it and run 'dw-mc init' again.`;
323
333
  }
324
334
  };
325
- const reasonOf = (cause) => cause instanceof Error ? cause.message : String(cause);
335
+ const reasonOf = (cause) => Predicate.isError(cause) ? cause.message : String(cause);
326
336
  /** The keys an earlier version had, read off a file loosely enough to find them. */
327
337
  const LegacySection = Schema.Struct({
328
338
  review: Schema.optionalKey(Schema.Struct({
@@ -525,6 +535,18 @@ const textStoreFor = Effect.fn("store.textStoreFor")(function* (namespace) {
525
535
  const store = yield* KeyValueStore.KeyValueStore;
526
536
  return KeyValueStore.prefix(store, `${namespace}/`);
527
537
  });
538
+ /**
539
+ * What a store holds under a key, and nothing where it holds nothing this
540
+ * version can read.
541
+ *
542
+ * A record this version cannot read is one another version of it wrote, and the
543
+ * state directory is a cache of work that can be done again ([ADR
544
+ * 0010](../../docs/adr/0010-configuration-that-cannot-be-read.md)): forgetting a
545
+ * record costs that work once, where failing here would cost the command I
546
+ * asked for. Every read of the tool's own records goes through this, so the
547
+ * bargain is struck once rather than at each of them.
548
+ */
549
+ const remembered = (read) => Effect.orElseSucceed(read, () => Option.none());
528
550
  /** The state directory on disk. */
529
551
  const layer$1 = Layer.unwrap(Effect.map(stateDirectory, (directory) => KeyValueStore.layerFileSystem(directory)));
530
552
  KeyValueStore.layerMemory;
@@ -553,32 +575,63 @@ const sessionOf = (cut) => ({
553
575
  })[cut];
554
576
  /** Where the bare clones sit, under the state directory. */
555
577
  const clonesIn = "repos";
578
+ /** What a bare clone's directory is called, and what tells one from anything beside it. */
579
+ const bare = ".git";
580
+ /** Where the tool keeps one repository's bare clone. */
581
+ const cloneAt = Effect.fn("store.cloneAt")(function* (repo) {
582
+ return (yield* Path.Path).join(yield* stateDirectory, clonesIn, `${repo}${bare}`);
583
+ });
584
+ /** Where one pull request's checkout goes, under the cut it was made for. */
585
+ const cutAt = Effect.fn("store.cutAt")(function* (cut, repo, number) {
586
+ return (yield* Path.Path).join(yield* stateDirectory, cut, repo, String(number));
587
+ });
588
+ /**
589
+ * What the branch a standing session works on is called, inside that clone.
590
+ *
591
+ * It carries the session's name because a fix session and a session on a
592
+ * conflict stand at the same time on the same pull request, and one branch
593
+ * between them would be one holding the other's commits.
594
+ */
595
+ const sessionBranch = (session, number) => `dw-mc/${session}/${number}`;
596
+ /**
597
+ * What the ref a clone keeps one pull request's head under is called.
598
+ *
599
+ * A cut fetches it and a removal reads it back without fetching, so the two
600
+ * have to spell it the same or a session would be asked about a ref nothing
601
+ * ever wrote.
602
+ */
603
+ const pullRef = (number) => `refs/dw-mc/pr/${number}`;
556
604
  /** What a directory holds, or nothing at all where it is not there. */
557
605
  const entriesOf = Effect.fnUntraced(function* (directory) {
558
606
  const fs = yield* FileSystem.FileSystem;
559
607
  return yield* Effect.orElseSucceed(fs.readDirectory(directory), () => []);
560
608
  });
561
609
  /**
562
- * What `directory` and everything below it weighs.
610
+ * What the entries named under `directory` weigh together.
563
611
  *
564
612
  * A file that is gone by the time it is asked about weighs nothing rather than
565
613
  * failing the walk: the directory is being read while the tool may be writing
566
614
  * to it, and a size on a screen is worth less than the listing it sits in.
567
615
  */
568
- const weigh = Effect.fn("store.weigh")(function* (directory) {
616
+ const weightOf = Effect.fnUntraced(function* (directory, entries) {
569
617
  const fs = yield* FileSystem.FileSystem;
570
618
  const path = yield* Path.Path;
571
- const entries = yield* Effect.orElseSucceed(fs.readDirectory(directory, { recursive: true }), () => []);
572
619
  const sizes = yield* Effect.forEach(entries, (entry) => Effect.orElseSucceed(Effect.map(fs.stat(path.join(directory, entry)), (info) => ByteSize.toBigInt(info.size)), () => BigInt(0)), { concurrency: 16 });
573
620
  return ByteSize.bytes(sizes.reduce((total, size) => total + size, BigInt(0)));
574
621
  });
622
+ /** What `directory` and everything below it weighs. */
623
+ const weigh = Effect.fn("store.weigh")(function* (directory) {
624
+ const fs = yield* FileSystem.FileSystem;
625
+ const entries = yield* Effect.orElseSucceed(fs.readDirectory(directory, { recursive: true }), () => []);
626
+ return yield* weightOf(directory, entries);
627
+ });
575
628
  /** The bare clones, named by the `owner/repo` the two directory levels spell. */
576
629
  const clonesOf = Effect.fnUntraced(function* (state) {
577
630
  const path = yield* Path.Path;
578
631
  const root = path.join(state, clonesIn);
579
632
  const clones = [];
580
633
  for (const owner of yield* entriesOf(root)) for (const name of yield* entriesOf(path.join(root, owner))) {
581
- if (!name.endsWith(".git")) continue;
634
+ if (!name.endsWith(bare)) continue;
582
635
  const directory = path.join(root, owner, name);
583
636
  clones.push({
584
637
  repo: `${owner}/${name.slice(0, -4)}`,
@@ -607,19 +660,16 @@ const cuttingsOf = Effect.fnUntraced(function* (state) {
607
660
  });
608
661
  /** Everything the state directory holds, in one pass over the disk. */
609
662
  const inventory = Effect.gen(function* () {
610
- const fs = yield* FileSystem.FileSystem;
611
- const path = yield* Path.Path;
612
663
  const directory = yield* stateDirectory;
613
664
  const directories = /* @__PURE__ */ new Set([clonesIn, ...cuts]);
614
665
  const keys = (yield* entriesOf(directory)).filter((entry) => !directories.has(entry));
615
- const sizes = yield* Effect.forEach(keys, (entry) => Effect.orElseSucceed(Effect.map(fs.stat(path.join(directory, entry)), (info) => ByteSize.toBigInt(info.size)), () => BigInt(0)), { concurrency: 16 });
616
666
  return {
617
667
  directory,
618
668
  clones: yield* clonesOf(directory),
619
669
  cuttings: yield* cuttingsOf(directory),
620
670
  records: {
621
671
  keys: keys.length,
622
- size: ByteSize.bytes(sizes.reduce((a, b) => a + b, BigInt(0)))
672
+ size: yield* weightOf(directory, keys)
623
673
  }
624
674
  };
625
675
  }).pipe(Effect.withSpan("store.inventory"));
@@ -657,6 +707,71 @@ const tidy = Effect.fn("store.tidy")(function* (directory, upTo) {
657
707
  at = path.dirname(at);
658
708
  }
659
709
  });
710
+ //#endregion
711
+ //#region src/adapters/heartbeat.ts
712
+ /** The frames of the spinner, in the order they turn. */
713
+ const frames = [
714
+ "⠋",
715
+ "⠙",
716
+ "⠹",
717
+ "⠸",
718
+ "⠼",
719
+ "⠴",
720
+ "⠦",
721
+ "⠧",
722
+ "⠇",
723
+ "⠏"
724
+ ];
725
+ /** How long one frame is on the screen. */
726
+ const frameFor = Duration.millis(120);
727
+ /** A stretch of time as a terminal says it: `1m12s`, or `9s` under the minute. */
728
+ const elapsed = (millis) => {
729
+ const seconds = Math.floor(millis / 1e3);
730
+ return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, "0")}s`;
731
+ };
732
+ /**
733
+ * Runs `use` while one line says the work is still going, and hands `use` the
734
+ * way to say how that line reads.
735
+ *
736
+ * Work that takes seconds and prints nothing while it does is work I stop
737
+ * trusting. What the screen showed instead was either silence or a line per
738
+ * step, and a wall of `· Bash` says as little as silence did. This keeps one
739
+ * line and rewrites it: the spinner says the work is alive, the words say how
740
+ * far it has got, and the line is gone when the work is over, so what stays on
741
+ * the screen is the report.
742
+ *
743
+ * What the line counts is the command's and not this module's business. A
744
+ * review counts tools, a sweep counts pull requests, and a command reading two
745
+ * guards counts nothing at all - and a screen that worded any of them here
746
+ * would need the words a command already has.
747
+ *
748
+ * Where there is no screen to measure - a pipe, a CI log, a test - a rewritten
749
+ * line would be a mess of half-drawn ones, so nothing is drawn. What goes out
750
+ * instead is whatever `aside` the command gives, one to a line, and where it
751
+ * gives none the output is what it was before there was a heartbeat at all.
752
+ * `columns` is zero exactly there.
753
+ */
754
+ const beating = Effect.fnUntraced(function* (from, use) {
755
+ const terminal = yield* Terminal.Terminal;
756
+ const columns = yield* terminal.columns;
757
+ if (columns === 0) return yield* use((_, aside) => aside === void 0 ? Effect.void : Console.log(aside));
758
+ const started = yield* Clock.currentTimeMillis;
759
+ const draw = (text) => Effect.ignore(terminal.display(`\r${text.slice(0, columns - 1).padEnd(columns - 1)}`));
760
+ let reads = from;
761
+ let at = 0;
762
+ /** The line as it stands: this frame of the spinner, and the latest wording. */
763
+ const paint = Effect.flatMap(Clock.currentTimeMillis, (now) => draw(`${frames[at % frames.length]} ${reads(elapsed(now - started))}`));
764
+ const says = (next) => Effect.andThen(Effect.sync(() => void (reads = next)), paint);
765
+ yield* paint;
766
+ const beat = yield* Effect.forkChild(Effect.gen(function* () {
767
+ for (;;) {
768
+ yield* Effect.sleep(frameFor);
769
+ at = at + 1;
770
+ yield* paint;
771
+ }
772
+ }));
773
+ return yield* Effect.onExit(use(says), () => Effect.flatMap(Fiber.interrupt(beat), () => Effect.ignore(terminal.display(`\r${" ".repeat(columns - 1)}\r`))));
774
+ });
660
775
  new TextEncoder();
661
776
  /** A program that ran but ended badly. */
662
777
  var CommandFailed = class extends Schema.TaggedError()("CommandFailed", {
@@ -712,6 +827,14 @@ const git = (args) => capture("git", args).pipe(Effect.catchTags({
712
827
  detail: error.stderr
713
828
  }))
714
829
  }));
830
+ /**
831
+ * `n` commits, which is the one thing this file counts out loud.
832
+ *
833
+ * It is said twice - before a session is cut and before one is taken away -
834
+ * and about the same commits both times: the ones the pull request's head does
835
+ * not have.
836
+ */
837
+ const commits = (n) => `${n} commit${n === 1 ? "" : "s"}`;
715
838
  /** A fix worktree that still holds work of mine, which nothing may cut away. */
716
839
  var WorktreeHeld = class extends Schema.TaggedError()("WorktreeHeld", {
717
840
  directory: Schema.String,
@@ -739,42 +862,55 @@ var WorktreeHeld = class extends Schema.TaggedError()("WorktreeHeld", {
739
862
  * The head comes from the pull request's ref rather than from what a sweep last
740
863
  * saw, so what is cut is the commit the run really reads.
741
864
  */
865
+ /**
866
+ * How the heartbeat of a cut reads.
867
+ *
868
+ * The stages are named apart because a first clone and a hundredth fetch take
869
+ * wildly different times, and the line is what explains the difference: a
870
+ * `cloning` that sits there for two minutes is a large repository arriving
871
+ * once, not a tool that has hung.
872
+ */
873
+ const cutting = (what, repo) => (since) => `${what} ${repo} · ${since}`;
742
874
  const whereToCut = Effect.fn("git.whereToCut")(function* (repo, number, cut) {
743
- const path = yield* Path.Path;
744
- const state = yield* stateDirectory;
745
- const clone = path.join(state, clonesIn, `${repo}.git`);
746
- if ((yield* Effect.orElseSucceed(git([
875
+ const clone = yield* cloneAt(repo);
876
+ const bare = yield* Effect.orElseSucceed(git([
747
877
  "-C",
748
878
  clone,
749
879
  "rev-parse",
750
880
  "--is-bare-repository"
751
- ]), () => "")) !== "true") yield* git([
752
- "clone",
753
- "--bare",
754
- "--filter=blob:none",
755
- `https://github.com/${repo}.git`,
756
- clone
757
- ]);
758
- const pullRef = `refs/dw-mc/pr/${number}`;
759
- yield* git([
760
- "-C",
761
- clone,
762
- "fetch",
763
- "--no-tags",
764
- "--force",
765
- "origin",
766
- `+refs/pull/${number}/head:${pullRef}`,
767
- "+refs/heads/*:refs/heads/*"
768
- ]);
881
+ ]), () => "");
882
+ const ref = pullRef(number);
769
883
  return {
770
884
  clone,
771
- head: yield* git([
772
- "-C",
773
- clone,
774
- "rev-parse",
775
- pullRef
776
- ]),
777
- directory: path.join(state, cut, repo, String(number))
885
+ head: yield* beating(cutting(bare === "true" ? "fetching" : "cloning", repo), (says) => Effect.gen(function* () {
886
+ if (bare !== "true") {
887
+ yield* git([
888
+ "clone",
889
+ "--bare",
890
+ "--filter=blob:none",
891
+ `https://github.com/${repo}.git`,
892
+ clone
893
+ ]);
894
+ yield* says(cutting("fetching", repo));
895
+ }
896
+ yield* git([
897
+ "-C",
898
+ clone,
899
+ "fetch",
900
+ "--no-tags",
901
+ "--force",
902
+ "origin",
903
+ `+refs/pull/${number}/head:${ref}`,
904
+ "+refs/heads/*:refs/heads/*"
905
+ ]);
906
+ return yield* git([
907
+ "-C",
908
+ clone,
909
+ "rev-parse",
910
+ ref
911
+ ]);
912
+ })),
913
+ directory: yield* cutAt(cut, repo, number)
778
914
  };
779
915
  });
780
916
  /**
@@ -798,7 +934,7 @@ const withWorktree = Effect.fn("git.withWorktree")(function* (repo, number, use)
798
934
  "--force",
799
935
  directory
800
936
  ]));
801
- return yield* Effect.acquireUseRelease(Effect.flatMap(remove, () => git([
937
+ return yield* Effect.acquireUseRelease(beating(cutting("cutting a worktree of", repo), () => Effect.flatMap(remove, () => git([
802
938
  "-C",
803
939
  clone,
804
940
  "worktree",
@@ -806,7 +942,7 @@ const withWorktree = Effect.fn("git.withWorktree")(function* (repo, number, use)
806
942
  "--detach",
807
943
  directory,
808
944
  head
809
- ])), () => use({
945
+ ]))), () => use({
810
946
  directory,
811
947
  head
812
948
  }), () => remove);
@@ -935,11 +1071,11 @@ const reuseResolutions = Effect.fn("git.reuseResolutions")(function* (clone) {
935
1071
  */
936
1072
  const standingWorktree = Effect.fn("git.standingWorktree")(function* (repo, number, prBranch, session) {
937
1073
  const { clone, directory, head } = yield* whereToCut(repo, number, under[session]);
938
- const branch = `dw-mc/${session}/${number}`;
1074
+ const branch = sessionBranch(session, number);
939
1075
  const ahead = yield* aheadOf(clone, branch, head);
940
1076
  if (ahead > 0) return yield* new WorktreeHeld({
941
1077
  directory,
942
- detail: `The last fix session on ${repo}#${number} left ${ahead} commit${ahead === 1 ? "" : "s"} that the pull request's head does not have. Push them or drop them before opening another session.`
1078
+ detail: `The last fix session on ${repo}#${number} left ${commits(ahead)} that the pull request's head does not have. Push them or drop them before opening another session.`
943
1079
  });
944
1080
  if ((yield* worktreesOf(clone)).includes(directory)) yield* git([
945
1081
  "-C",
@@ -950,7 +1086,7 @@ const standingWorktree = Effect.fn("git.standingWorktree")(function* (repo, numb
950
1086
  ]);
951
1087
  yield* perWorktreeConfig(clone);
952
1088
  if (session === "rebase") yield* reuseResolutions(clone);
953
- yield* git([
1089
+ yield* beating(cutting("cutting a worktree of", repo), () => git([
954
1090
  "-C",
955
1091
  clone,
956
1092
  "worktree",
@@ -959,7 +1095,7 @@ const standingWorktree = Effect.fn("git.standingWorktree")(function* (repo, numb
959
1095
  branch,
960
1096
  directory,
961
1097
  head
962
- ]);
1098
+ ]));
963
1099
  yield* git([
964
1100
  "-C",
965
1101
  clone,
@@ -1181,11 +1317,9 @@ const clear = { _tag: "clear" };
1181
1317
  * pruned or moved by hand still leaves the branch holding the commits.
1182
1318
  */
1183
1319
  const holding = Effect.fn("git.holding")(function* (repo, number, session) {
1184
- const path = yield* Path.Path;
1185
- const state = yield* stateDirectory;
1186
- const clone = path.join(state, clonesIn, `${repo}.git`);
1187
- const directory = path.join(state, under[session], repo, String(number));
1188
- const branch = `dw-mc/${session}/${number}`;
1320
+ const clone = yield* cloneAt(repo);
1321
+ const directory = yield* cutAt(under[session], repo, number);
1322
+ const branch = sessionBranch(session, number);
1189
1323
  if ((yield* Effect.orElseSucceed(git([
1190
1324
  "-C",
1191
1325
  directory,
@@ -1208,7 +1342,7 @@ const holding = Effect.fn("git.holding")(function* (repo, number, session) {
1208
1342
  "-C",
1209
1343
  clone,
1210
1344
  "rev-parse",
1211
- `refs/dw-mc/pr/${number}`
1345
+ pullRef(number)
1212
1346
  ]), () => "");
1213
1347
  if (head.trim() === "") return {
1214
1348
  _tag: "held",
@@ -1217,7 +1351,7 @@ const holding = Effect.fn("git.holding")(function* (repo, number, session) {
1217
1351
  const ahead = yield* aheadOf(clone, branch, head.trim());
1218
1352
  return ahead === 0 ? clear : {
1219
1353
  _tag: "held",
1220
- detail: `${ahead} commit${ahead === 1 ? "" : "s"} that the pull request's head does not have`
1354
+ detail: `${commits(ahead)} that the pull request's head does not have`
1221
1355
  };
1222
1356
  });
1223
1357
  /**
@@ -1482,7 +1616,7 @@ const lines$3 = (it, state, path, paint) => {
1482
1616
  const cleanup = Command.make("cleanup", { yes: yesFlag }, Effect.fn("cleanup")(function* ({ yes }) {
1483
1617
  const path = yield* Path.Path;
1484
1618
  const paint = yield* Paint;
1485
- const found = yield* inventory;
1619
+ const found = yield* beating((since) => `measuring the state directory · ${since}`, () => inventory);
1486
1620
  const it = plan(found);
1487
1621
  if (empty(it)) {
1488
1622
  yield* Console.log(`Nothing to take back in ${found.directory}.`);
@@ -1914,6 +2048,40 @@ const prConversation = Effect.fnUntraced(function* (repo, number) {
1914
2048
  }], ...threads];
1915
2049
  });
1916
2050
  //#endregion
2051
+ //#region src/cli/exit.ts
2052
+ /**
2053
+ * The failures a command owes me a sentence for rather than a stack.
2054
+ *
2055
+ * Every one of them is a machine or a file that needs fixing, and the message
2056
+ * says what to fix. Anything not named here is a fault of the tool's own, and a
2057
+ * stack is what I want to see for those.
2058
+ */
2059
+ const userFacing = [
2060
+ "ConfigMalformed",
2061
+ "GhUnavailable",
2062
+ "GhReadFailed",
2063
+ "GhUnreadable"
2064
+ ];
2065
+ /** The same, for a command that also runs `git` against the tool's own clone. */
2066
+ const userFacingAndGit = [...userFacing, "GitFailed"];
2067
+ /**
2068
+ * The same, for a command that cuts a standing worktree and opens an agent
2069
+ * session in it.
2070
+ *
2071
+ * `dw-mc fix` and `dw-mc resolve` are the two, and they fail the same ways
2072
+ * because they do the same thing to different findings: a worktree that holds
2073
+ * work of mine and an agent that would not run are the session's failures, not
2074
+ * either command's.
2075
+ */
2076
+ const userFacingAndSession = [
2077
+ ...userFacing,
2078
+ "GitFailed",
2079
+ "WorktreeHeld",
2080
+ "AgentFailed"
2081
+ ];
2082
+ /** Turns one of those into the sentence the CLI prints, and the exit code it leaves. */
2083
+ const asUserError = (cause) => Effect.fail(new CliError.UserError({ cause }));
2084
+ //#endregion
1917
2085
  //#region src/domain/moment.ts
1918
2086
  const isLater = Order.isGreaterThan(DateTime.Order);
1919
2087
  /** Whether `self` happened after `other`, counting never as before anything. */
@@ -1925,7 +2093,15 @@ const isSame = (self, other) => self === null || other === null ? self === other
1925
2093
  /** The latest of many, or never when there are none. */
1926
2094
  const newest = (moments) => moments.reduce(later, null);
1927
2095
  //#endregion
1928
- //#region src/domain/bucket.ts
2096
+ //#region src/terms/pr.ts
2097
+ /**
2098
+ * What GitHub says about a pull request, in this tool's words.
2099
+ *
2100
+ * The three of them are here because both sides need the same one: `gh` and the
2101
+ * checks adapter answer in these words, and the bucket rules decide on them. A
2102
+ * union restated on each side is a case that goes unreachable the day the other
2103
+ * side gains a member.
2104
+ */
1929
2105
  /** How far GitHub has got towards letting a tracked PR merge. */
1930
2106
  const Mergeability = Schema.Literals([
1931
2107
  "mergeable",
@@ -1946,6 +2122,8 @@ const ChecksState = Schema.Literals([
1946
2122
  "pending",
1947
2123
  "none"
1948
2124
  ]);
2125
+ //#endregion
2126
+ //#region src/domain/bucket.ts
1949
2127
  /**
1950
2128
  * Everything the bucket rules are allowed to know about a tracked PR.
1951
2129
  *
@@ -2002,6 +2180,15 @@ const order = [
2002
2180
  */
2003
2181
  const unanswered = "a comment I have not answered";
2004
2182
  /**
2183
+ * Why a PR is mine to move when a review run found something that withholds
2184
+ * the stamp.
2185
+ *
2186
+ * It is named for the reason `unanswered` is: `dw-mc stamp` says this same
2187
+ * sentence about this same number, and two spellings of it would be two
2188
+ * answers to what a blocking finding is worth.
2189
+ */
2190
+ const blockedBy = (n) => `${n} blocking finding${n === 1 ? "" : "s"}`;
2191
+ /**
2005
2192
  * The first of the rules that makes a PR mine to move, or null when none
2006
2193
  * does. The order is the order I would fix them in: a conflict makes every
2007
2194
  * other signal on the PR stale, and a red build is worth more than a comment.
@@ -2011,7 +2198,7 @@ const needsMe = (facts) => {
2011
2198
  if (facts.rebaseConflictAt === facts.head) return "a rebase onto the base conflicted";
2012
2199
  if (facts.checks === "red" && facts.ciFlaky === null) return "CI is red";
2013
2200
  if (facts.reviewDecision === "changes-requested") return "changes requested";
2014
- if (facts.blockingFindings > 0) return `${facts.blockingFindings} blocking finding${facts.blockingFindings === 1 ? "" : "s"}`;
2201
+ if (facts.blockingFindings > 0) return blockedBy(facts.blockingFindings);
2015
2202
  if (isAfter(facts.newestHumanCommentAt, later(facts.myLastCommentAt, facts.myLastCommitAt))) return unanswered;
2016
2203
  return null;
2017
2204
  };
@@ -2121,2036 +2308,2084 @@ const resolve$1 = (text, registered) => {
2121
2308
  };
2122
2309
  };
2123
2310
  //#endregion
2124
- //#region src/cli/pr.ts
2125
- /** The pull request a command acts on, named the way I actually type it. */
2126
- const prArgument = Argument.String("pr").pipe(Argument.withDescription("The pull request, as 28 or owner/name#28"));
2127
- /** What to say about a reference that named no one pull request. */
2128
- const whyNothingNamed = (reference) => {
2129
- if (reference._tag === "unreadable") return `'${reference.text}' is not a pull request. Name one as 28, or as owner/name#28.`;
2130
- const example = `${reference.repos[0] ?? "owner/name"}#28`;
2131
- return reference.repos.length === 0 ? `No repositories are registered, so a number alone names nothing. Run dw-mc init inside a repository, or name the pull request as ${example}.` : `${reference.repos.length} repositories are registered, so a number alone could be any of them. Name the pull request as ${example}.`;
2311
+ //#region src/domain/findings.ts
2312
+ /** Whether a review run found anything at all. */
2313
+ const Verdict = Schema.Literals(["clean", "findings"]);
2314
+ /**
2315
+ * Every severity word a review may answer with.
2316
+ *
2317
+ * The first three are ours, and the only ones a run is asked for. The rest are
2318
+ * the persona a run with no slash command carries, which grades in its own
2319
+ * words: a turn that comes back in them is worth reading rather than throwing
2320
+ * away.
2321
+ */
2322
+ const Spelling = Schema.Literals([
2323
+ "error",
2324
+ "warning",
2325
+ "info",
2326
+ "Critical",
2327
+ "Required",
2328
+ "Optional",
2329
+ "Nit",
2330
+ "FYI"
2331
+ ]);
2332
+ /** What each of those words weighs. The record is exhaustive, so neither list can drift. */
2333
+ const severityOf = {
2334
+ error: "error",
2335
+ warning: "warning",
2336
+ info: "info",
2337
+ Critical: "error",
2338
+ Required: "error",
2339
+ Optional: "warning",
2340
+ Nit: "info",
2341
+ FYI: "info"
2132
2342
  };
2133
- /** The pull request the argument names, or the sentence saying why it names none. */
2134
- const named = (pr, registered) => {
2135
- const reference = resolve$1(pr, registered);
2136
- return reference._tag === "resolved" ? Effect.succeed(reference) : Effect.fail(new CliError.UserError({ cause: whyNothingNamed(reference) }));
2343
+ const Weighed = Spelling.pipe(Schema.decodeTo(Severity, SchemaTransformation.transform({
2344
+ decode: (word) => severityOf[word],
2345
+ encode: (severity) => severity
2346
+ })));
2347
+ /** The fields both spellings of a finding share. Only the severity differs. */
2348
+ const shared = {
2349
+ file: Schema.String,
2350
+ line: Schema.Int,
2351
+ summary: Schema.String
2137
2352
  };
2353
+ /** One problem a review run reports, at a file and line. */
2354
+ const Finding = Schema.Struct({
2355
+ ...shared,
2356
+ severity: Severity
2357
+ });
2138
2358
  /**
2139
- * A domain guard's word, as the command's own failure.
2140
- *
2141
- * Every guard in the tool answers the same shape - the sentence saying why not,
2142
- * or null - so turning that answer into a refusal is spelled once here rather
2143
- * than beside each command that asks one.
2359
+ * What a review run found: the shape the tool keeps, and the one a fix session
2360
+ * is later handed.
2144
2361
  */
2145
- const refuse = (why) => why === null ? Effect.void : Effect.fail(new CliError.UserError({ cause: why }));
2362
+ const Findings = Schema.Struct({
2363
+ verdict: Verdict,
2364
+ findings: Schema.Array(Finding)
2365
+ });
2146
2366
  /**
2147
- * What the last sweep learned about one pull request, or the sentence sending
2148
- * me to a sweep.
2149
- *
2150
- * A command that reads these rather than GitHub says what the table said: the
2151
- * stamp and the cutoff a conversation is measured against are both computed
2152
- * from the facts a sweep wrote down, and asking GitHub again would make them a
2153
- * different answer from the one `dw-mc status` printed.
2367
+ * The same findings as a runner may spell them, which is what the second turn's
2368
+ * output is read with.
2154
2369
  *
2155
- * Facts this version cannot read are facts another version of them wrote, and a
2156
- * sweep can write them again, so both cases say the same thing.
2370
+ * A word nothing maps fails here, and a failed read is a failure of the run:
2371
+ * findings the tool cannot weigh are not findings it can act on.
2157
2372
  */
2158
- const swept = Effect.fn("pr.swept")(function* (repo, number) {
2159
- const store = yield* storeFor("prs", Facts);
2160
- const facts = yield* Effect.orElseSucceed(store.get(prKey(repo, number)), () => Option.none());
2161
- if (Option.isNone(facts)) return yield* new CliError.UserError({ cause: `Nothing is known about ${repo}#${number} yet. Run dw-mc sweep first.` });
2162
- return facts.value;
2373
+ const Reported = Schema.Struct({
2374
+ verdict: Verdict,
2375
+ findings: Schema.Array(Schema.Struct({
2376
+ ...shared,
2377
+ severity: Weighed
2378
+ }))
2163
2379
  });
2164
- //#endregion
2165
- //#region src/cli/row.ts
2166
2380
  /**
2167
- * How one tracked PR is written down, wherever it is written down.
2168
- *
2169
- * The table `dw-mc status` prints and the list the picker asks me to choose
2170
- * from are the same rows, so a pull request reads the same in both and neither
2171
- * command owns how the other draws it.
2381
+ * The schema every runner must satisfy, as the JSON Schema a runner is handed.
2172
2382
  *
2173
- * Colour here says one thing: which bucket the pull request is in, and so what
2174
- * it waits on. Everything else on the row is either `dim`, because it is
2175
- * context rather than state, or left alone. A row read with no colour at all
2176
- * says the same, which is what the marker is for.
2383
+ * It is derived from the schema the findings are kept under rather than written
2384
+ * out beside it, so a runner is asked for exactly the shape that is persisted.
2385
+ * `Reported` is wider on purpose and only on the severity: what a runner is
2386
+ * asked for is our three words, and a persona's five are read where they arrive
2387
+ * anyway rather than being asked for.
2388
+ */
2389
+ const jsonSchema = JSON.stringify(SchemaRepresentation.toJsonSchemaDocument(SchemaRepresentation.toRepresentation(Findings.ast)).schema);
2390
+ /**
2391
+ * The findings as the Markdown a report is written in.
2177
2392
  *
2178
- * On a table, the pull request opens itself: the reference carries the URL for
2179
- * the terminal to follow, and nothing else on the row does. What it leads to is
2180
- * where the row already says it is, so a row read where no link can be followed
2181
- * - a pipe, a paste, a terminal that ignores the sequence - loses nothing.
2393
+ * It is what a schema-held run's report says: with a schema in force a run
2394
+ * answers in findings and not in prose, so the report kept beside it is written
2395
+ * from the findings themselves rather than left empty.
2182
2396
  */
2183
- /** The glossary's name for each bucket, which is what the heading says. */
2184
- const heading = {
2185
- "needs-me": "Needs me",
2186
- "needs-review-run": "Needs review run",
2187
- "waiting-on-others": "Waiting on others",
2188
- ready: "Ready"
2397
+ 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");
2398
+ /** Where each severity sits against the others, so the bar can be compared with it. */
2399
+ const rank = {
2400
+ info: 0,
2401
+ warning: 1,
2402
+ error: 2
2189
2403
  };
2190
2404
  /**
2191
- * The mark that says which bucket a row is in without being read.
2405
+ * The findings that withhold the stamp: everything at `blocksOn` or above it.
2192
2406
  *
2193
- * One character apiece, from the part of Unicode a terminal font has: the
2194
- * padding is counted in characters, and a glyph a terminal draws double width
2195
- * takes a column the count never gave it. How full the mark looks tracks how
2196
- * much of the pull request is done, so the column reads at a glance even where
2197
- * the colour is off.
2407
+ * `stamp.blocks_on` is my bar rather than a constant, so a repository whose
2408
+ * warnings I do not want to merge past is configured rather than coded. An
2409
+ * error blocks wherever the bar is, because nothing weighs more than one.
2198
2410
  */
2199
- const marker = {
2200
- "needs-me": "●",
2201
- "needs-review-run": "◐",
2202
- "waiting-on-others": "○",
2203
- ready: "◆"
2204
- };
2205
- /** The colour a bucket is said in: red is mine, yellow is next, green is done, dim is not my turn. */
2206
- const tint = (paint, bucket) => ({
2207
- "needs-me": paint.red,
2208
- "needs-review-run": paint.yellow,
2209
- "waiting-on-others": paint.dim,
2210
- ready: paint.green
2211
- })[bucket];
2212
- /** What sits between two columns: three columns of prose run into one another without a rule. */
2213
- const rule = " │ ";
2214
- /**
2215
- * One row: which pull request, what it is, and what it waits on.
2216
- *
2217
- * A stamp is a mark beside the pull request rather than a column of its own, so
2218
- * a table where nothing is stamped is exactly the table it was before: the
2219
- * stamp is a thing I look for, not a thing I read every row of.
2220
- *
2221
- * The title is the only cell with give in it, so how much room it gets is the
2222
- * caller's to say: a table printed down the screen can afford a whole commit
2223
- * subject, and a row inside a prompt has a column more to carry and a frame
2224
- * around it.
2225
- *
2226
- * A named lead carries the colour for the whole row. It is the one place a
2227
- * prompt's row is coloured, and it carries no link at all: a prompt counts the
2228
- * lines it has to erase from the length of what it drew, escape sequences and
2229
- * all, so every colour on a row costs the title characters it could have shown,
2230
- * and a link costs it the whole URL. The table has no such arithmetic to keep
2231
- * straight, so its rows say it in more than one place and open the pull request
2232
- * besides.
2233
- */
2234
- const cells = (placed, stamped, room, paint, lead) => {
2235
- const { facts } = placed;
2236
- const { bucket } = placed.placement;
2237
- const say = tint(paint, bucket);
2238
- const reference = `${facts.repo}#${facts.number}`;
2239
- const named = lead === "named";
2240
- const pr = `${named ? reference : paint.link(reference, facts.url)}${facts.draft ? paint.dim(" (draft)") : ""}${stamped ? ` ${paint.green("✓")}` : ""}`;
2241
- return named ? [
2242
- say(`${marker[bucket]} ${heading[bucket]}`),
2243
- pr,
2244
- truncate(facts.title, room),
2245
- placed.placement.reason
2246
- ] : [
2247
- `${say(marker[bucket])} ${pr}`,
2248
- paint.dim(truncate(facts.title, room)),
2249
- say(placed.placement.reason)
2250
- ];
2251
- };
2411
+ const blocking = (findings, blocksOn) => findings.filter((finding) => rank[finding.severity] >= rank[blocksOn]);
2252
2412
  //#endregion
2253
- //#region src/adapters/ci.ts
2413
+ //#region src/domain/review.ts
2254
2414
  /**
2255
- * What GitHub says about a pull request's checks, and the evidence a red one
2256
- * is classified on. Every read here goes through the same `gh` the rest of the
2257
- * tool does; what it owns is the checks, not the boundary.
2415
+ * What a review run came to, which is what its second turn reported.
2416
+ *
2417
+ * A failure is recorded as one and is never a clean verdict: a turn that exited
2418
+ * badly, ran out of patience or answered in a shape that does not validate has
2419
+ * found nothing, which is not the same as having found nothing wrong.
2258
2420
  */
2259
- const failing = /* @__PURE__ */ new Set([
2260
- "FAILURE",
2261
- "TIMED_OUT",
2262
- "CANCELLED",
2263
- "STARTUP_FAILURE",
2264
- "ACTION_REQUIRED",
2265
- "ERROR"
2266
- ]);
2267
- const running = /* @__PURE__ */ new Set([
2268
- "QUEUED",
2269
- "IN_PROGRESS",
2270
- "WAITING",
2271
- "PENDING",
2272
- "REQUESTED",
2273
- "EXPECTED"
2274
- ]);
2275
- const nameOf = (entry) => entry.name ?? entry.context ?? "";
2276
- const checksThatCount = (entries, ignore) => (entries ?? []).filter((entry) => !ignore.includes(nameOf(entry)));
2277
- const hasFailed = (entry) => failing.has(entry.conclusion ?? "") || failing.has(entry.state ?? "");
2421
+ const Outcome = Schema.Union([Schema.TaggedStruct("reported", {
2422
+ verdict: Verdict,
2423
+ findings: Schema.Array(Finding)
2424
+ }), Schema.TaggedStruct("failed", { detail: Schema.String })]);
2278
2425
  /**
2279
- * What the rollup comes to: red when anything failed, pending only while
2280
- * nothing has failed yet, green when every check that counts has passed.
2426
+ * One review run against a tracked PR at a specific head commit.
2281
2427
  *
2282
- * `ci.ignore` names the checks that do not count towards green, so a check I
2283
- * have decided to live with cannot hold a PR out of Ready.
2428
+ * It is a schema because a review run outlives the command that started it: the
2429
+ * state directory is where the next sweep learns that this head has been
2430
+ * reviewed, and where a fix session finds what there is to fix.
2284
2431
  */
2285
- const rollupState = (entries, ignore) => {
2286
- const checks = checksThatCount(entries, ignore);
2287
- if (checks.length === 0) return "none";
2288
- if (checks.some(hasFailed)) return "red";
2289
- if (checks.some((entry) => entry.status !== void 0 && entry.status !== "COMPLETED" || running.has(entry.state ?? ""))) return "pending";
2290
- return "green";
2291
- };
2432
+ const ReviewRun = Schema.Struct({
2433
+ repo: Schema.String,
2434
+ number: Schema.Int,
2435
+ /** The head the run covers. A run never vouches for code it did not see. */
2436
+ head: Schema.String,
2437
+ /**
2438
+ * The slash command line the run opened on, or null where it opened on the
2439
+ * tool's own prompt. A report found months later says what it was asked, and a
2440
+ * record an earlier version wrote carries no such field and is forgotten.
2441
+ */
2442
+ command: Schema.NullOr(Schema.String),
2443
+ effort: Schema.NullOr(Effort),
2444
+ /**
2445
+ * The agent session the run happened in, or null where it never reached one.
2446
+ *
2447
+ * A run that would not start or exited before it said anything has no session,
2448
+ * and the run is still recorded: a failure is recorded as what it is.
2449
+ */
2450
+ sessionId: Schema.NullOr(Schema.String),
2451
+ ranAt: Schema.DateTimeUtcFromString,
2452
+ outcome: Outcome
2453
+ });
2454
+ /** A head as it is read out loud: the seven characters git itself abbreviates to. */
2455
+ const short = (head) => head.slice(0, 7);
2292
2456
  /**
2293
- * The checks that failed and count, which are the ones there is a log to read.
2294
- *
2295
- * `ci.ignore` is applied here as well as in the rollup: a check that cannot
2296
- * hold a PR out of Ready is not one the classifier should be explaining either.
2457
+ * Where a run is kept: one key per head, so a run and the code it read cannot
2458
+ * drift apart, and a re-review replaces the run before it.
2297
2459
  */
2298
- const failedChecks = (entries, ignore) => checksThatCount(entries, ignore).filter(hasFailed);
2460
+ const runKey = (repo, number, head) => `${prKey(repo, number)}@${head}`;
2461
+ /** Where the run's report is kept: beside the run, as the Markdown it is. */
2462
+ const reportKey = (repo, number, head) => `${runKey(repo, number, head)}.md`;
2299
2463
  /**
2300
- * What a check reports on, out of the URL it reports at.
2464
+ * Which head a pull request was last reviewed at: an index beside `runKey` and
2465
+ * `reportKey` rather than a thing the glossary names.
2301
2466
  *
2302
- * A check run details URL ends `/actions/runs/<run>/job/<job>`. The job id is
2303
- * what the logs endpoint takes and the run id is what `gh run rerun` takes, so
2304
- * the two ids the tool needs are the two halves of one URL and are read
2305
- * together. A commit status points somewhere else entirely, which is null:
2306
- * there is no log of ours to read and no run of ours to re-run.
2467
+ * A run is kept under the head it read, which answers the question a sweep asks
2468
+ * of one head. The re-run rule and `dw-mc findings` ask the other one - which
2469
+ * head the last run was at - and this is where they read it, so neither has to
2470
+ * ask GitHub what is current before it can look anything up.
2307
2471
  */
2308
- const reportedAt = (detailsUrl) => {
2309
- const found = detailsUrl?.match(/\/actions\/runs\/(\d+)\/job\/(\d+)/);
2310
- return found?.[1] === void 0 || found[2] === void 0 ? null : {
2311
- run: found[1],
2312
- job: found[2]
2313
- };
2314
- };
2315
- const RepoDefaultBranch = Schema.fromJsonString(Schema.Struct({ defaultBranchRef: Schema.NullOr(Schema.Struct({ name: Schema.String })) }));
2472
+ const LastReviewed = Schema.Struct({ head: Schema.String });
2473
+ /** Where that head is kept. No head is spelled `latest`, so nothing collides. */
2474
+ const latestKey = (repo, number) => `${prKey(repo, number)}@latest`;
2316
2475
  /**
2317
- * The branch a repository merges into, which is the one the first flaky signal
2318
- * asks about. An empty repository has none, and `main` is the better guess than
2319
- * failing the sweep over it.
2476
+ * The run at one head, or none where nothing has reviewed it.
2477
+ *
2478
+ * A head is where the question is asked - the stamp, the bucket and `dw-mc
2479
+ * findings` all ask about one commit - and one read off the disk answers it
2480
+ * without an index to keep in step.
2481
+ *
2482
+ * Forgetting a run costs one review.
2320
2483
  */
2321
- const defaultBranch = Effect.fnUntraced(function* (repo) {
2322
- return (yield* readJson("repo view defaultBranchRef", "gh", [
2323
- "repo",
2324
- "view",
2325
- repo,
2326
- "--json",
2327
- "defaultBranchRef"
2328
- ], RepoDefaultBranch)).defaultBranchRef?.name ?? "main";
2484
+ const runAt = Effect.fn("review.runAt")(function* (repo, number, head) {
2485
+ const runs = yield* storeFor("runs", ReviewRun);
2486
+ return yield* remembered(runs.get(runKey(repo, number, head)));
2487
+ });
2488
+ /** The last review run on a pull request, or none where it has had none. */
2489
+ const lastRun = Effect.fn("review.lastRun")(function* (repo, number) {
2490
+ const heads = yield* storeFor("runs", LastReviewed);
2491
+ const at = yield* remembered(heads.get(latestKey(repo, number)));
2492
+ return Option.isNone(at) ? Option.none() : yield* runAt(repo, number, at.value.head);
2329
2493
  });
2330
- const Runs = Schema.fromJsonString(Schema.Array(Schema.Struct({ conclusion: Schema.String })));
2331
- /** How far back to look for a run that reached a verdict at all. */
2332
- const recentRuns = 5;
2333
- /** `gh run list` reports a conclusion in lower case, unlike every check on a PR. */
2334
- const failedRun = /* @__PURE__ */ new Set(["failure", "timed_out"]);
2335
- /** A run that decided something. A skipped or cancelled run says nothing either way. */
2336
- const verdicts = /* @__PURE__ */ new Set([
2337
- "failure",
2338
- "timed_out",
2339
- "success"
2340
- ]);
2341
2494
  /**
2342
- * Whether `workflow` is red on `branch` right now.
2495
+ * What a run reported, or null where it reported nothing at all.
2343
2496
  *
2344
- * The newest run that reached a verdict is the whole answer: a workflow that
2345
- * broke last week and was fixed since is not red, and excusing a pull request
2346
- * for it would hide a failure that is real. A handful of runs are asked for
2347
- * because the newest ones are often skipped by a path filter.
2497
+ * A failure is not a clean verdict: a run that could not report has found
2498
+ * nothing, which is not the same as having found nothing wrong. Everything that
2499
+ * reads a run's findings reads them through here, so the distinction is drawn
2500
+ * once rather than at every caller that might forget it.
2348
2501
  */
2349
- const workflowFailsOn = Effect.fnUntraced(function* (repo, branch, workflow) {
2350
- const newest = (yield* readJson("run list", "gh", [
2351
- "run",
2352
- "list",
2353
- "--repo",
2354
- repo,
2355
- "--branch",
2356
- branch,
2357
- "--workflow",
2358
- workflow,
2359
- "--limit",
2360
- String(recentRuns),
2361
- "--json",
2362
- "conclusion"
2363
- ], Runs)).find((run) => verdicts.has(run.conclusion));
2364
- return newest !== void 0 && failedRun.has(newest.conclusion);
2365
- });
2366
- const PrFiles = Schema.fromJsonString(Schema.Struct({ files: Schema.Array(Schema.Struct({ path: Schema.String })) }));
2367
- /** The repository paths a pull request changes. */
2368
- const prFiles = Effect.fnUntraced(function* (repo, number) {
2369
- return (yield* readJson("pr view files", "gh", [
2370
- "pr",
2371
- "view",
2372
- String(number),
2373
- "--repo",
2374
- repo,
2375
- "--json",
2376
- "files"
2377
- ], PrFiles)).files.map((file) => file.path);
2378
- });
2502
+ const reportedBy = (run) => run.outcome._tag === "reported" ? {
2503
+ verdict: run.outcome.verdict,
2504
+ findings: run.outcome.findings
2505
+ } : null;
2379
2506
  /**
2380
- * How much of a failing job's log is kept.
2507
+ * Why a run reported nothing, or null where it reported.
2381
2508
  *
2382
- * A job that failed prints what went wrong at the end, so the tail is the part
2383
- * worth classifying, and a build that logged a whole dependency tree is not
2384
- * worth holding in memory beyond it.
2509
+ * The sibling of `reportedBy`, and here for the same reason: the two halves of
2510
+ * an outcome are read through one place each rather than re-narrowed at every
2511
+ * caller.
2385
2512
  */
2386
- const logTailBytes = 65536;
2513
+ const detailOf = (run) => run.outcome._tag === "failed" ? run.outcome.detail : null;
2387
2514
  /**
2388
- * What one failing job printed, from the end.
2515
+ * Whether the files changed since the last run are worth paying for another.
2389
2516
  *
2390
- * `gh api` refuses a response carrying terminal escape sequences unless it is
2391
- * told otherwise, and a runner log is full of them. Verified by running it: the
2392
- * endpoint answers with the plain log once the flag is passed.
2517
+ * The question is deliberately about what changed rather than how much: one
2518
+ * line outside the `docs_only` globs is code nobody has reviewed, and a
2519
+ * thousand lines inside them are still prose.
2393
2520
  */
2394
- const jobLog = Effect.fnUntraced(function* (repo, jobId) {
2395
- const log = yield* capture("gh", [
2396
- "api",
2397
- `repos/${repo}/actions/jobs/${jobId}/logs`,
2398
- "--allow-escape-sequences"
2399
- ]).pipe(Effect.catchTags({
2400
- PlatformError: (error) => Effect.fail(unavailable(error)),
2401
- CommandFailed: (error) => Effect.fail(new GhReadFailed({
2402
- command: "api job logs",
2403
- detail: error.stderr
2404
- }))
2405
- }));
2406
- return log.length <= logTailBytes ? log : log.slice(-65536);
2407
- });
2521
+ const worthRerunning = (changed, docsOnly) => changed.some((file) => !docsOnly.some((glob) => matchesGlob(file, glob)));
2408
2522
  /**
2409
- * The workflow runs behind the failing checks that count, each named once.
2410
- *
2411
- * One broken run usually fails several jobs, and re-running it once per failing
2412
- * job would start the same run over and over.
2523
+ * The re-run rule: the head this run is skipped against, or null where it runs.
2413
2524
  *
2414
- * `ci.ignore` decides which checks get a run into this list, and no more than
2415
- * that: a run is re-run whole, so an ignored job sharing a run with a counted
2416
- * one is re-run beside it. What the setting buys is that an ignored check is
2417
- * never on its own a reason to spend CI minutes.
2525
+ * A review costs real money and minutes of my attention, and a typo fix is not
2526
+ * worth either. Four things are never skipped, because the rule is here to save
2527
+ * me a review and not to stand between me and one I asked for: a pull request
2528
+ * with no run behind it, a run that reported nothing, a comparison GitHub would
2529
+ * not answer, and anything that changed outside the globs. A head that has
2530
+ * already had a run changed nothing at all, which is the one case that needs no
2531
+ * comparison to decide.
2418
2532
  */
2419
- const failedRuns = (entries, ignore) => [...new Set(failedChecks(entries, ignore).flatMap((check) => {
2420
- const reported = reportedAt(check.detailsUrl);
2421
- return reported === null ? [] : [reported.run];
2422
- }))];
2533
+ const skippedSince = (asked, docsOnly) => {
2534
+ if (asked.last === null || reportedBy(asked.last) === null) return null;
2535
+ const changed = asked.last.head === asked.head ? [] : asked.changed;
2536
+ return changed === null || worthRerunning(changed, docsOnly) ? null : asked.last.head;
2537
+ };
2538
+ /** What a run was opened on, as the report says it. */
2539
+ const askedOf$1 = (run) => run.command === null ? "the tool's own prompt" : [run.command, run.effort].filter((part) => part !== null).join(" ");
2423
2540
  /**
2424
- * Asks GitHub to run one workflow run's failed jobs again.
2541
+ * The report as it is written down: what it is of, then what the run said.
2425
2542
  *
2426
- * `--failed` is what makes this cheap: the jobs that passed are not run a
2427
- * second time, so a flaky job costs the minutes it costs and no more. This is a
2428
- * write to GitHub, and it is one of the three ADR 0002 allows.
2543
+ * The heading is the whole point of writing it rather than storing the prose
2544
+ * alone - a file found months later says which pull request, which commit and
2545
+ * what the run was asked, without anything else having to be open.
2429
2546
  */
2430
- const rerunFailed = Effect.fnUntraced(function* (repo, runId) {
2431
- yield* capture("gh", [
2432
- "run",
2433
- "rerun",
2434
- runId,
2435
- "--repo",
2436
- repo,
2437
- "--failed"
2438
- ]).pipe(Effect.catchTags({
2439
- PlatformError: (error) => Effect.fail(unavailable(error)),
2440
- CommandFailed: (error) => Effect.fail(new GhReadFailed({
2441
- command: "run rerun",
2442
- detail: error.stderr
2443
- }))
2444
- }));
2445
- });
2446
- //#endregion
2447
- //#region src/domain/flaky.ts
2547
+ const reportDocument = (run, title, prose) => [
2548
+ `# ${run.repo}#${run.number} ${title}`,
2549
+ "",
2550
+ `- head: ${run.head}`,
2551
+ `- run: ${askedOf$1(run)}`,
2552
+ `- ran: ${DateTime.formatIso(run.ranAt)}`,
2553
+ "",
2554
+ prose.trim(),
2555
+ ""
2556
+ ].join("\n");
2448
2557
  /**
2449
- * The failures that are flaky wherever they appear: a machine, a network or a
2450
- * runner giving up, never a test disagreeing with the code.
2558
+ * Whether `head` has the review it needs.
2451
2559
  *
2452
- * `ci.flaky_patterns` adds to this list rather than replacing it, because the
2453
- * failures a repository of mine produces are extra ones, not different ones.
2560
+ * A run that reported nothing does not count, which is the same rule
2561
+ * `reportedBy` draws everywhere else: a failure has found nothing, not found
2562
+ * nothing wrong.
2454
2563
  */
2455
- const builtInPatterns = [
2456
- "timed out",
2457
- "deadline exceeded",
2458
- "ETIMEDOUT",
2459
- "ECONNRESET",
2460
- "ECONNREFUSED",
2461
- "connection refused",
2462
- "socket hang up",
2463
- "lock timeout",
2464
- "could not obtain lock",
2465
- "runner lost communication",
2466
- "The runner has received a shutdown signal",
2467
- "net/http: request canceled",
2468
- "ResourceExhausted",
2469
- "Too many open files",
2470
- "no space left on device"
2471
- ];
2472
- const baseName = (path) => path.slice(path.lastIndexOf("/") + 1);
2564
+ const reviewedBy = (run) => run !== null && reportedBy(run) !== null;
2565
+ /** The findings at one head that withhold the stamp. */
2566
+ const blockingIn = (run, blocksOn) => {
2567
+ const found = run === null ? null : reportedBy(run);
2568
+ return found === null ? [] : blocking(found.findings, blocksOn);
2569
+ };
2473
2570
  /**
2474
- * The changed file the log names, preferring one it spells in full.
2571
+ * What the review runs on `head` say about it, for the stamp to rest on.
2475
2572
  *
2476
- * A bare file name is worth matching - a stack trace often prints nothing else
2477
- * - and it is worth matching second, because a name as ordinary as `index.ts`
2478
- * belongs to more repositories than mine.
2479
- */
2480
- const escaped = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2481
- /**
2482
- * Whether the log names a file called `base` rather than some longer name
2483
- * ending in it: a changed `src/a.ts` is not what a log printing `data.ts` is
2484
- * complaining about.
2485
- */
2486
- const namesFile = (log, base) => new RegExp(`(^|[^\\w.-])${escaped(base)}`).test(log);
2487
- const namedChangedFile = (log, changedFiles) => changedFiles.find((file) => log.includes(file)) ?? changedFiles.find((file) => namesFile(log, baseName(file))) ?? null;
2488
- /**
2489
- * The flaky pattern the log matches, mine before the built-in ones.
2573
+ * Whether a head has been reviewed is the runs' to say and no sweep's: a run is
2574
+ * recorded against one head, and a head with no run of its own has not been
2575
+ * reviewed however many sweeps have seen the pull request. A run that could not
2576
+ * report findings does not count either: its verdict is what takes a pull
2577
+ * request out of Needs review run, and it reached none.
2490
2578
  *
2491
- * A pattern is text and not a regular expression: it comes out of a
2492
- * configuration file I edit by hand, where a stray `*` should cost me a missed
2493
- * match and never a crash.
2579
+ * It is one function because the two callers are a sweep and `dw-mc merge`, and
2580
+ * the second exists to land what the first only describes: two spellings of
2581
+ * this would be two answers to whether a head has been reviewed.
2494
2582
  */
2495
- const matchedPattern = (log, patterns) => {
2496
- const haystack = log.toLowerCase();
2497
- return [...patterns, ...builtInPatterns].find((pattern) => haystack.includes(pattern.toLowerCase())) ?? null;
2583
+ const reviewedAt = Effect.fn("review.reviewedAt")(function* (repo, number, head, blocksOn) {
2584
+ const run = Option.getOrNull(yield* runAt(repo, number, head));
2585
+ return {
2586
+ reviewRunHead: reviewedBy(run) ? head : null,
2587
+ blockingFindings: blockingIn(run, blocksOn).length
2588
+ };
2589
+ });
2590
+ //#endregion
2591
+ //#region src/cli/pr.ts
2592
+ /** The pull request a command acts on, named the way I actually type it. */
2593
+ const prArgument = Argument.String("pr").pipe(Argument.withDescription("The pull request, as 28 or owner/name#28"));
2594
+ /** What to say about a reference that named no one pull request. */
2595
+ const whyNothingNamed = (reference) => {
2596
+ if (reference._tag === "unreadable") return `'${reference.text}' is not a pull request. Name one as 28, or as owner/name#28.`;
2597
+ const example = `${reference.repos[0] ?? "owner/name"}#28`;
2598
+ return reference.repos.length === 0 ? `No repositories are registered, so a number alone names nothing. Run dw-mc init inside a repository, or name the pull request as ${example}.` : `${reference.repos.length} repositories are registered, so a number alone could be any of them. Name the pull request as ${example}.`;
2599
+ };
2600
+ /** The pull request the argument names, or the sentence saying why it names none. */
2601
+ const named = (pr, registered) => {
2602
+ const reference = resolve$1(pr, registered);
2603
+ return reference._tag === "resolved" ? Effect.succeed(reference) : Effect.fail(new CliError.UserError({ cause: whyNothingNamed(reference) }));
2498
2604
  };
2499
2605
  /**
2500
- * Whether a red CI is mine to fix, and why.
2606
+ * What a command that acts on one pull request opens with: which pull request
2607
+ * it is, and what the configuration says about its repository.
2501
2608
  *
2502
- * Two of the signals say flaky and one says legitimate, and the one outranks
2503
- * the two: a log that names a file this pull request changes is the failure
2504
- * pointing at my own work, and a workflow that is broken everywhere does not
2505
- * stop it pointing there.
2609
+ * Nine commands ask the file the same three questions before they do anything
2610
+ * else, and asking them here is what keeps the answers the same: which
2611
+ * repositories are registered decides what a bare `28` may name, and a command
2612
+ * that read the file its own way would resolve a different pull request from
2613
+ * the one beside it.
2506
2614
  *
2507
- * Everything else that is unexplained is mine as well. The two mistakes do not
2508
- * cost the same - a real failure called flaky is a broken pull request nobody
2509
- * tells me about, while a flake called mine costs me one look - so the default
2510
- * is the one I can recover from.
2615
+ * `settings` and `launcher` come back whether or not this command wants them,
2616
+ * because both are a merge of records already in hand and neither reads
2617
+ * anything. The file itself does not, so nothing downstream keeps a copy of it.
2511
2618
  */
2512
- const classify = (evidence, flakyPatterns) => {
2513
- const named = namedChangedFile(evidence.log, evidence.changedFiles);
2514
- if (named !== null) return {
2515
- classification: "legitimate",
2516
- reason: `the log names ${named}, which this PR changes`
2517
- };
2518
- const redOnDefaultBranch = evidence.alsoRedOnDefaultBranch[0];
2519
- const pattern = matchedPattern(evidence.log, flakyPatterns);
2520
- const excuses = [redOnDefaultBranch === void 0 ? null : `${redOnDefaultBranch} is red on the default branch too`, pattern === null ? null : `the log matches "${pattern}"`].filter((it) => it !== null);
2521
- return excuses.length === 0 ? {
2522
- classification: "legitimate",
2523
- reason: "nothing explains the failure"
2524
- } : {
2525
- classification: "flaky",
2526
- reason: excuses.join(", and ")
2619
+ const forPr = Effect.fn("pr.forPr")(function* (pr) {
2620
+ const file = Option.getOrElse(yield* read, () => ({}));
2621
+ const { number, repo } = yield* named(pr, Object.keys(file.repos ?? {}).toSorted());
2622
+ return {
2623
+ repo,
2624
+ number,
2625
+ settings: settingsFor(file, repo),
2626
+ launcher: launcherOf(file)
2527
2627
  };
2528
- };
2628
+ });
2529
2629
  /**
2530
- * How many failing jobs the log is read from.
2630
+ * A domain guard's word, as the command's own failure.
2531
2631
  *
2532
- * One broken workflow usually fails several jobs with the same cause, and the
2533
- * logs are the one read here that is measured in megabytes.
2632
+ * Every guard in the tool answers the same shape - the sentence saying why not,
2633
+ * or null - so turning that answer into a refusal is spelled once here rather
2634
+ * than beside each command that asks one.
2534
2635
  */
2535
- const loggedJobs = 3;
2536
- /** The values of `xs` that `f` has one for. */
2537
- const filterMap = (xs, f) => xs.flatMap((x) => {
2538
- const b = f(x);
2539
- return b === null ? [] : [b];
2540
- });
2541
- /** No evidence at all, which is what an unreadable CI comes to. */
2542
- const nothing$1 = {
2543
- alsoRedOnDefaultBranch: [],
2544
- changedFiles: [],
2545
- log: ""
2546
- };
2636
+ const refuse = (why) => why === null ? Effect.void : Effect.fail(new CliError.UserError({ cause: why }));
2547
2637
  /**
2548
- * What a red CI looks like to the classifier.
2638
+ * What the last sweep learned about one pull request, or the sentence sending
2639
+ * me to a sweep.
2549
2640
  *
2550
- * A read that fails costs its own signal and nothing else. GitHub drops an
2551
- * Actions log after ninety days, so a pull request open that long would
2552
- * otherwise lose its row over a log nobody can fetch any more - and a missing
2553
- * signal only ever moves the verdict towards legitimate, which is the answer
2554
- * that puts the pull request in front of me rather than hiding it.
2641
+ * A command that reads these rather than GitHub says what the table said: the
2642
+ * stamp and the cutoff a conversation is measured against are both computed
2643
+ * from the facts a sweep wrote down, and asking GitHub again would make them a
2644
+ * different answer from the one `dw-mc status` printed.
2645
+ *
2646
+ * Facts that are missing and facts this version cannot read come to the same
2647
+ * sentence, because a sweep can write them again either way.
2555
2648
  */
2556
- const evidenceFor = Effect.fn("flaky.evidenceFor")(function* (repo, number, entries, ignore) {
2557
- const failed = failedChecks(entries, ignore);
2558
- const workflows = [...new Set(filterMap(failed, (check) => check.workflowName ?? null))];
2559
- const jobs = filterMap(failed, (check) => reportedAt(check.detailsUrl)?.job ?? null).slice(0, loggedJobs);
2560
- const branch = yield* Effect.orElseSucceed(defaultBranch(repo), () => null);
2561
- if (branch === null) return nothing$1;
2562
- const [alsoRed, changedFiles, logs] = yield* Effect.all([
2563
- Effect.forEach(workflows, (workflow) => Effect.map(Effect.orElseSucceed(workflowFailsOn(repo, branch, workflow), () => false), (red) => red ? [workflow] : [])),
2564
- Effect.orElseSucceed(prFiles(repo, number), () => []),
2565
- Effect.forEach(jobs, (job) => Effect.orElseSucceed(jobLog(repo, job), () => ""))
2566
- ], { concurrency: 3 });
2567
- return {
2568
- alsoRedOnDefaultBranch: alsoRed.flat(),
2569
- changedFiles,
2570
- log: logs.join("\n")
2571
- };
2649
+ const swept = Effect.fn("pr.swept")(function* (repo, number) {
2650
+ const store = yield* storeFor("prs", Facts);
2651
+ const facts = yield* remembered(store.get(prKey(repo, number)));
2652
+ if (Option.isNone(facts)) return yield* new CliError.UserError({ cause: `Nothing is known about ${repo}#${number} yet. Run dw-mc sweep first.` });
2653
+ return facts.value;
2572
2654
  });
2573
2655
  /**
2574
- * Why a red CI is excused, or null where it is mine to fix.
2656
+ * The review run whose findings are the current ones, or the sentence saying
2657
+ * there are none.
2575
2658
  *
2576
- * Reading the evidence and classifying it is one act, so it is one function:
2577
- * a sweep writes what it returns down as `ciFlaky`, and `dw-mc rerun` asks it
2578
- * again live. Two callers asking the same question have to get the same answer,
2579
- * which they cannot if each of them spells the question out.
2659
+ * The last run on the pull request is what "current" means here, and it is read
2660
+ * off the state directory rather than worked out from GitHub: the commands that
2661
+ * ask are ones I run inside a fix session, where another round trip to GitHub
2662
+ * buys nothing the run it is about to fix does not already say.
2580
2663
  */
2581
- const flakyReason = Effect.fn("flaky.flakyReason")(function* (repo, number, entries, ignore, patterns) {
2582
- const verdict = classify(yield* evidenceFor(repo, number, entries, ignore), patterns);
2583
- return verdict.classification === "flaky" ? verdict.reason : null;
2664
+ const currentRun = Effect.fn("pr.currentRun")(function* (repo, number) {
2665
+ const run = yield* lastRun(repo, number);
2666
+ if (Option.isNone(run)) return yield* new CliError.UserError({ cause: `No review run on ${repo}#${number}. Run dw-mc review ${number} first.` });
2667
+ return run.value;
2584
2668
  });
2669
+ /**
2670
+ * The guard reads of one command, under a heartbeat.
2671
+ *
2672
+ * Every command that acts on a pull request reads its guards live rather than
2673
+ * off the last sweep, because each of them is about the pull request as it is
2674
+ * now. That read is a second or two against GitHub before a word can be
2675
+ * printed, and it used to be spent on a blank screen.
2676
+ *
2677
+ * There is nothing to count here - two or three calls, and a number counting to
2678
+ * three says less than the words do - so the line is what is being read and how
2679
+ * long it has taken. It gives the heartbeat no aside, so a piped command prints
2680
+ * what it always printed.
2681
+ */
2682
+ const reading = (where, read) => beating((since) => `reading ${where} · ${since}`, () => read);
2585
2683
  //#endregion
2586
- //#region src/domain/quiet.ts
2587
- /** The pulse of a PR a previous sweep recorded. */
2588
- const pulseOf = (facts) => ({
2589
- head: facts.head,
2590
- checks: facts.checks,
2591
- newestHumanCommentAt: facts.newestHumanCommentAt
2592
- });
2684
+ //#region src/cli/row.ts
2593
2685
  /**
2594
- * Whether a PR is where the last sweep left it.
2686
+ * How one tracked PR is written down, wherever it is written down.
2595
2687
  *
2596
- * A quiet PR keeps the facts it already had rather than being read out again,
2597
- * so a sweep over many pull requests spends its time on the few that moved.
2598
- */
2599
- const isQuiet = (previous, current) => previous.head === current.head && previous.checks === current.checks && isSame(previous.newestHumanCommentAt, current.newestHumanCommentAt);
2600
- //#endregion
2601
- //#region src/domain/rebase.ts
2602
- /**
2603
- * How many pull requests this one stands on.
2688
+ * The table `dw-mc status` prints and the list the picker asks me to choose
2689
+ * from are the same rows, so a pull request reads the same in both and neither
2690
+ * command owns how the other draws it.
2604
2691
  *
2605
- * A branch is walked to what it merges into and on from there, however deep the
2606
- * stack goes. Every pull request the walk has already counted is left alone,
2607
- * which is what keeps two branches that merge into each other from being walked
2608
- * around forever.
2692
+ * Colour here says one thing: which bucket the pull request is in, and so what
2693
+ * it waits on. Everything else on the row is either `dim`, because it is
2694
+ * context rather than state, or left alone. A row read with no colour at all
2695
+ * says the same, which is what the marker is for.
2696
+ *
2697
+ * On a table, the pull request opens itself: the reference carries the URL for
2698
+ * the terminal to follow, and nothing else on the row does. What it leads to is
2699
+ * where the row already says it is, so a row read where no link can be followed
2700
+ * - a pipe, a paste, a terminal that ignores the sequence - loses nothing.
2609
2701
  */
2610
- const ancestorsOf = (pr, open, seen) => {
2611
- let count = 0;
2612
- let current = pr;
2613
- for (;;) {
2614
- const parent = open.find((it) => it.head === current.base && !seen.has(it.number));
2615
- if (parent === void 0) return count;
2616
- seen.add(parent.number);
2617
- count += 1;
2618
- current = parent;
2619
- }
2702
+ /** The glossary's name for each bucket, which is what the heading says. */
2703
+ const heading = {
2704
+ "needs-me": "Needs me",
2705
+ "needs-review-run": "Needs review run",
2706
+ "waiting-on-others": "Waiting on others",
2707
+ ready: "Ready"
2620
2708
  };
2621
2709
  /**
2622
- * How deep the stack goes above this pull request.
2710
+ * The mark that says which bucket a row is in without being read.
2623
2711
  *
2624
- * Two branches cut from the same one are not two stacks deep, they are two
2625
- * branches, so what counts is the deepest single line of them rather than how
2626
- * many pull requests stand above it in total.
2712
+ * One character apiece, from the part of Unicode a terminal font has: the
2713
+ * padding is counted in characters, and a glyph a terminal draws double width
2714
+ * takes a column the count never gave it. How full the mark looks tracks how
2715
+ * much of the pull request is done, so the column reads at a glance even where
2716
+ * the colour is off.
2627
2717
  */
2628
- const descendantsOf = (pr, open, seen) => {
2629
- let deepest = 0;
2630
- for (const child of open.filter((it) => it.base === pr.head && !seen.has(it.number))) {
2631
- seen.add(child.number);
2632
- deepest = Math.max(deepest, 1 + descendantsOf(child, open, seen));
2633
- }
2634
- return deepest;
2718
+ const marker = {
2719
+ "needs-me": "●",
2720
+ "needs-review-run": "◐",
2721
+ "waiting-on-others": "○",
2722
+ ready: "◆"
2635
2723
  };
2724
+ /** The colour a bucket is said in: red is mine, yellow is next, green is done, dim is not my turn. */
2725
+ const tint = (paint, bucket) => ({
2726
+ "needs-me": paint.red,
2727
+ "needs-review-run": paint.yellow,
2728
+ "waiting-on-others": paint.dim,
2729
+ ready: paint.green
2730
+ })[bucket];
2731
+ /** What sits between two columns: three columns of prose run into one another without a rule. */
2732
+ const rule = " │ ";
2636
2733
  /**
2637
- * Where a pull request sits in its stack, or null where it is in none.
2734
+ * One row: which pull request, what it is, and what it waits on.
2638
2735
  *
2639
- * A stack is read off the branches alone: a pull request that merges into
2640
- * another pull request's branch, or that another one merges into, is part of
2641
- * one. The tool does not understand stacks and never drives them, so this
2642
- * exists to recognise one and say where the pull request sits in it.
2736
+ * A stamp is a mark beside the pull request rather than a column of its own, so
2737
+ * a table where nothing is stamped is exactly the table it was before: the
2738
+ * stamp is a thing I look for, not a thing I read every row of.
2739
+ *
2740
+ * The title is the only cell with give in it, so how much room it gets is the
2741
+ * caller's to say: a table printed down the screen can afford a whole commit
2742
+ * subject, and a row inside a prompt has a column more to carry and a frame
2743
+ * around it.
2744
+ *
2745
+ * A named lead carries the colour for the whole row. It is the one place a
2746
+ * prompt's row is coloured, and it carries no link at all: a prompt counts the
2747
+ * lines it has to erase from the length of what it drew, escape sequences and
2748
+ * all, so every colour on a row costs the title characters it could have shown,
2749
+ * and a link costs it the whole URL. The table has no such arithmetic to keep
2750
+ * straight, so its rows say it in more than one place and open the pull request
2751
+ * besides.
2643
2752
  */
2644
- const stackOf = (number, open) => {
2645
- const pr = open.find((it) => it.number === number);
2646
- if (pr === void 0) return null;
2647
- const seen = /* @__PURE__ */ new Set([number]);
2648
- const below = ancestorsOf(pr, open, seen);
2649
- const above = descendantsOf(pr, open, seen);
2650
- return below === 0 && above === 0 ? null : {
2651
- position: below + 1,
2652
- length: below + above + 1
2653
- };
2753
+ const cells = (placed, stamped, room, paint, lead) => {
2754
+ const { facts } = placed;
2755
+ const { bucket } = placed.placement;
2756
+ const say = tint(paint, bucket);
2757
+ const reference = `${facts.repo}#${facts.number}`;
2758
+ const named = lead === "named";
2759
+ const pr = `${named ? reference : paint.link(reference, facts.url)}${facts.draft ? paint.dim(" (draft)") : ""}${stamped ? ` ${paint.green("✓")}` : ""}`;
2760
+ return named ? [
2761
+ say(`${marker[bucket]} ${heading[bucket]}`),
2762
+ pr,
2763
+ truncate(facts.title, room),
2764
+ placed.placement.reason
2765
+ ] : [
2766
+ `${say(marker[bucket])} ${pr}`,
2767
+ paint.dim(truncate(facts.title, room)),
2768
+ say(placed.placement.reason)
2769
+ ];
2654
2770
  };
2771
+ //#endregion
2772
+ //#region src/domain/comments.ts
2655
2773
  /**
2656
- * Why this branch is nobody's to touch here, or null where it is mine.
2774
+ * One thread's share of a strand, cut to what is worth reading.
2657
2775
  *
2658
- * These are the guards about the branch rather than about what is done to it,
2659
- * which is why they are their own and why they say nothing about pushing: who
2660
- * authored the pull request and where its branch lives is the boundary itself -
2661
- * a branch somebody else authored and a branch in a fork are not mine to work
2662
- * on, whatever else is true of them and whichever command asks. A stack comes
2663
- * next, and a pull request the stack was not read from counts as one, because a
2664
- * stack the tool cannot see is one it could drive: the tool does not understand
2665
- * stacks, so the one thing it has to say about one is where the pull request
2666
- * sits in it.
2776
+ * A review thread is answered as a whole, so a single comment newer than my
2777
+ * last activity brings the whole thread with it: the follow-up on its own is a
2778
+ * line answering something the screen does not show, which is what sends me to
2779
+ * the browser.
2780
+ *
2781
+ * The pull request's own comments are not a thread but a stream, and there is
2782
+ * no reply to lose the question of, so they are cut comment by comment.
2667
2783
  */
2668
- const boundary = (branch) => {
2669
- const where = `${branch.repo}#${branch.number}`;
2670
- if (!branch.mine) return `${where} is not mine. dw-mc works on branches I author and on nothing else.`;
2671
- if (branch.fromFork) return `${where} is opened from a fork, so its branch is not in ${branch.repo}. dw-mc works only on a branch in the repository it read.`;
2672
- if (!branch.listed) return `${where} was not among the open pull requests of ${branch.repo}, so nothing here can say whether it is in a stack. Read it again before touching the branch.`;
2673
- if (branch.stack !== null) return `${where} is ${branch.stack.position} of ${branch.stack.length} in a stack. dw-mc does not understand stacks and will not drive one; rebase it with whatever built the stack.`;
2674
- return null;
2784
+ const only = (thread, keep, since, all) => {
2785
+ const strand = thread.comments.filter((it) => keep(it.bot));
2786
+ const comments = all ? strand : thread.path === null ? strand.filter((it) => isAfter(it.at, since)) : strand.some((it) => isAfter(it.at, since)) ? strand : [];
2787
+ return comments.length === 0 ? [] : [{
2788
+ ...thread,
2789
+ comments
2790
+ }];
2675
2791
  };
2676
2792
  /**
2677
- * Why this branch is not one to rebase, or null where it is.
2793
+ * The threads worth putting on screen, given what I have already done.
2678
2794
  *
2679
- * This is the single place the guards live, and they matter more than the
2680
- * rebase itself: a force push is the one write the tool makes that can lose
2681
- * work, and every rule here is about it never being a surprise.
2795
+ * `since` is my last activity on the pull request - the later of my last
2796
+ * comment and my last commit - which is the same moment the bucket rule
2797
+ * measures a comment against. Showing exactly what is newer than it means the
2798
+ * command answers the question the bucket asked.
2682
2799
  *
2683
- * Being off is said first, because a repository that has not turned rebase on
2684
- * has decided the question and nothing else about the pull request changes it.
2685
- * The branch's own guards come next. CI is last and costs the most to get
2686
- * wrong - rebasing while a run is in flight cancels the run I am waiting on,
2687
- * and a red build is mine to fix where it is.
2800
+ * A thread somebody resolved and one against code that is gone are left out:
2801
+ * neither is something to answer, and both are still there to read under
2802
+ * `--all`, which asks for the whole conversation and so measures nothing
2803
+ * against anything.
2688
2804
  */
2689
- const decide$3 = (situation) => {
2690
- const where = `${situation.repo}#${situation.number}`;
2691
- if (!situation.enabled) return `Rebase is off for ${situation.repo}. Set rebase.enabled: true for it in the config to turn it on, so a force push is never a surprise.`;
2692
- const refused = boundary(situation);
2693
- if (refused !== null) return refused;
2694
- if (situation.checks === "pending") return `CI is still running on ${where}. A rebase now would cancel the run you are waiting on.`;
2695
- if (situation.checks === "red") return `CI is red on ${where}, which is yours to fix before the branch moves.`;
2696
- return null;
2805
+ const shown = (threads, options) => {
2806
+ const kept = options.all ? threads : threads.filter((it) => !it.resolved && !it.outdated);
2807
+ return {
2808
+ people: kept.flatMap((it) => only(it, (bot) => !bot, options.since, options.all)),
2809
+ bots: kept.flatMap((it) => only(it, (bot) => bot, options.since, options.all))
2810
+ };
2697
2811
  };
2812
+ //#endregion
2813
+ //#region src/cli/comments.ts
2814
+ const allFlag = Flag.Boolean("all").pipe(Flag.withDefault(false), Flag.withDescription("Print the whole conversation, including what is resolved, outdated and already answered"));
2815
+ /** Where a thread hangs: a line of the diff, or the pull request itself. */
2816
+ const where$1 = (thread) => thread.path === null ? "Conversation" : thread.line === null ? thread.path : `${thread.path}:${thread.line}`;
2698
2817
  /**
2699
- * A rebase that conflicted: the head it conflicted at and the files it stopped
2700
- * on.
2701
- *
2702
- * The head is what the record is scoped to, as it is for a withdrawn stamp: a
2703
- * conflict is about the code the branch is at, so it lasts exactly as long as
2704
- * that code is what the pull request is. A branch that moved is a branch
2705
- * nothing here has tried to rebase yet.
2818
+ * What is true of a thread beyond where it hangs.
2706
2819
  *
2707
- * The paths are what makes the conflict something to open: `a rebase
2708
- * conflicted` cannot tell a stale lockfile from half the pull request. They are
2709
- * an optional key rather than a required one so a record an older version wrote
2710
- * still reads, and a conflict with no paths still puts the pull request in
2711
- * Needs me.
2820
+ * It is only ever printed under `--all`, which is the only way a settled thread
2821
+ * reaches the screen at all, and it is there so that reading one is never
2822
+ * reading it as something still open.
2712
2823
  */
2713
- const Conflict = Schema.Struct({
2714
- head: Schema.String,
2715
- paths: Schema.optionalKey(Schema.Array(Schema.String))
2716
- });
2824
+ const settled = (thread) => [thread.resolved ? "resolved" : null, thread.outdated ? "outdated" : null].filter((it) => it !== null).join(", ");
2717
2825
  /**
2718
- * The conflict a rebase last left on this pull request, or null where it left
2719
- * none.
2826
+ * One thread as a block: where it hangs, then everybody who said something in
2827
+ * it, then what they said in full.
2720
2828
  *
2721
- * A record this version cannot read is one another version of it wrote, and a
2722
- * conflict is worth a bucket rather than a failed sweep: forgetting it costs
2723
- * the pull request one reason to be in Needs me, where failing here would cost
2724
- * me the whole table.
2829
+ * In full because a review comment is usually a paragraph carrying a
2830
+ * suggestion, and a first line is what sends me to the browser this command
2831
+ * exists to replace. No diff hunk with it: the code is on this machine, under
2832
+ * the path the heading already prints.
2725
2833
  */
2726
- const conflictFor = Effect.fn("rebase.conflictFor")(function* (repo, number) {
2727
- const store = yield* storeFor("rebases", Conflict);
2728
- const conflict = yield* Effect.orElseSucceed(store.get(prKey(repo, number)), () => Option.none());
2729
- return Option.getOrNull(conflict);
2730
- });
2731
- /** Writes down that a rebase of `head` conflicted on `paths`, which is the only head it holds for. */
2732
- const recordConflict = Effect.fn("rebase.recordConflict")(function* (repo, number, head, paths) {
2733
- yield* (yield* storeFor("rebases", Conflict)).set(prKey(repo, number), {
2734
- head,
2735
- paths
2736
- });
2737
- });
2738
- //#endregion
2739
- //#region src/domain/findings.ts
2740
- /** Whether a review run found anything at all. */
2741
- const Verdict = Schema.Literals(["clean", "findings"]);
2834
+ const block$1 = (thread, paint) => [`${paint.bold(where$1(thread))}${settled(thread) === "" ? "" : paint.dim(` (${settled(thread)})`)}`, ...thread.comments.flatMap((comment) => [` ${paint.dim(`@${comment.login} ${DateTime.formatIso(comment.at)}`)}`, ...comment.body.split("\n").map((line) => ` ${line}`)])];
2835
+ const separated = (blocks) => blocks.flatMap((lines, index) => index === 0 ? lines : ["", ...lines]);
2742
2836
  /**
2743
- * Every severity word a review may answer with.
2837
+ * The conversation on screen: people first, then a rule, then the bots.
2744
2838
  *
2745
- * The first three are ours, and the only ones a run is asked for. The rest are
2746
- * the persona a run with no slash command carries, which grades in its own
2747
- * words: a turn that comes back in them is worth reading rather than throwing
2748
- * away.
2749
- */
2750
- const Spelling = Schema.Literals([
2751
- "error",
2752
- "warning",
2753
- "info",
2754
- "Critical",
2755
- "Required",
2756
- "Optional",
2757
- "Nit",
2758
- "FYI"
2759
- ]);
2760
- /** What each of those words weighs. The record is exhaustive, so neither list can drift. */
2761
- const severityOf = {
2762
- error: "error",
2763
- warning: "warning",
2764
- info: "info",
2765
- Critical: "error",
2766
- Required: "error",
2767
- Optional: "warning",
2768
- Nit: "info",
2769
- FYI: "info"
2839
+ * The rule is there so the two are never read as one list. A bot's comment is
2840
+ * observed and never answered, and the bucket rules ignore bots for exactly
2841
+ * this reason.
2842
+ *
2843
+ * A bot is cut at the same moment I am measured against, because the window is
2844
+ * what has happened since I last acted rather than what is owed an answer. A
2845
+ * verdict older than my last push is one I have already had the chance to read,
2846
+ * and `--all` is where it still is.
2847
+ */
2848
+ const lines$2 = (view, paint) => {
2849
+ const people = view.people.map((thread) => block$1(thread, paint));
2850
+ const bots = view.bots.map((thread) => block$1(thread, paint));
2851
+ return separated([...people, ...bots.length === 0 ? [] : [[paint.dim("── bots ──")], ...bots]]);
2770
2852
  };
2771
- const Weighed = Spelling.pipe(Schema.decodeTo(Severity, SchemaTransformation.transform({
2772
- decode: (word) => severityOf[word],
2773
- encode: (severity) => severity
2774
- })));
2775
- /** The fields both spellings of a finding share. Only the severity differs. */
2776
- const shared = {
2777
- file: Schema.String,
2778
- line: Schema.Int,
2779
- summary: Schema.String
2853
+ /** What to say where there is nothing to print, which depends on why there is not. */
2854
+ const nothing$1 = (facts, all) => {
2855
+ const pr = `${facts.repo}#${facts.number}`;
2856
+ if (all) return [`Nothing has been said on ${pr}.`];
2857
+ const placement = place(facts);
2858
+ const rest = `dw-mc comments ${facts.number} --all prints the whole conversation.`;
2859
+ return placement.bucket === "needs-me" && placement.reason === "a comment I have not answered" ? [
2860
+ "Nothing here is waiting on you: every thread is resolved, outdated, or older than your last comment or commit.",
2861
+ `${pr} sits in ${heading[placement.bucket]} all the same, and a reply or a push is what settles it.`,
2862
+ rest
2863
+ ] : [`Nothing has been said on ${pr} since your last comment or commit.`, rest];
2780
2864
  };
2781
- /** One problem a review run reports, at a file and line. */
2782
- const Finding = Schema.Struct({
2783
- ...shared,
2784
- severity: Severity
2785
- });
2786
- /**
2787
- * What a review run found: the shape the tool keeps, and the one a fix session
2788
- * is later handed.
2789
- */
2790
- const Findings = Schema.Struct({
2791
- verdict: Verdict,
2792
- findings: Schema.Array(Finding)
2793
- });
2794
2865
  /**
2795
- * The same findings as a runner may spell them, which is what the second turn's
2796
- * output is read with.
2866
+ * The conversation on one tracked pull request, and nothing else.
2797
2867
  *
2798
- * A word nothing maps fails here, and a failed read is a failure of the run:
2799
- * findings the tool cannot weigh are not findings it can act on.
2868
+ * What it shows by default is what the bucket rule measures: the comments newer
2869
+ * than the later of my last comment and my last commit, which are the ones that
2870
+ * put the pull request in Needs me. Reading it answers the question the table
2871
+ * asked.
2872
+ *
2873
+ * The cutoff is read off the last sweep rather than worked out again here, so
2874
+ * the command shows exactly what `dw-mc status` counted rather than a second
2875
+ * opinion about it.
2876
+ *
2877
+ * It writes nothing, here or on GitHub: no reply, no resolve, no reaction
2878
+ * (ADR 0002). Reading is the whole command.
2800
2879
  */
2801
- const Reported = Schema.Struct({
2802
- verdict: Verdict,
2803
- findings: Schema.Array(Schema.Struct({
2804
- ...shared,
2805
- severity: Weighed
2806
- }))
2807
- });
2880
+ const comments = Command.make("comments", {
2881
+ pr: prArgument,
2882
+ all: allFlag
2883
+ }, Effect.fn("comments")(function* ({ all, pr }) {
2884
+ const { number, repo } = yield* forPr(pr);
2885
+ const facts = yield* swept(repo, number);
2886
+ const paint = yield* Paint;
2887
+ const view = shown(yield* reading(`${repo}#${number}`, prConversation(repo, number)), {
2888
+ since: later(facts.myLastCommentAt, facts.myLastCommitAt),
2889
+ all
2890
+ });
2891
+ if (view.people.length === 0 && view.bots.length === 0) {
2892
+ yield* Effect.forEach(nothing$1(facts, all), (line) => Console.log(line));
2893
+ return;
2894
+ }
2895
+ yield* Console.log(paint.bold(`${repo}#${number}`) + ` ${paint.dim(facts.title)}`);
2896
+ yield* Console.log("");
2897
+ yield* Effect.forEach(lines$2(view, paint), (line) => Console.log(line));
2898
+ }, Effect.catchTag(userFacing, asUserError))).pipe(Command.withDescription("Print the conversation on one pull request, and what is waiting on me in it"));
2899
+ //#endregion
2900
+ //#region src/cli/findings.ts
2901
+ /** The findings as the JSON the schema defines, rather than as this file spells it. */
2902
+ const asJson$2 = Schema.encodeEffect(Schema.fromJsonString(Findings));
2903
+ const jsonFlag = Flag.Boolean("json").pipe(Flag.withDefault(false), Flag.withDescription("Print the findings as the JSON a fix session is handed"));
2904
+ /** What a run's findings come to in one line, against the bar that blocks. */
2905
+ const summary = (found, blocksOn) => {
2906
+ if (found.findings.length === 0) return "clean, nothing to fix";
2907
+ const blocked = blocking(found.findings, blocksOn).length;
2908
+ return `${count(found.findings.length, "finding")}, ${blocked} blocking`;
2909
+ };
2910
+ /** Which run these findings are, and what they come to: the line above the list. */
2911
+ const header$1 = (run, found, blocksOn) => `${run.repo}#${run.number} ${short(run.head)} ${summary(found, blocksOn)}`;
2808
2912
  /**
2809
- * The schema every runner must satisfy, as the JSON Schema a runner is handed.
2810
- *
2811
- * It is derived from the schema the findings are kept under rather than written
2812
- * out beside it, so a runner is asked for exactly the shape that is persisted.
2813
- * `Reported` is wider on purpose and only on the severity: what a runner is
2814
- * asked for is our three words, and a persona's five are read where they arrive
2815
- * anyway rather than being asked for.
2913
+ * The findings one to a line, in the order the run reported them, ruled so the
2914
+ * three columns read apart.
2816
2915
  */
2817
- const jsonSchema = JSON.stringify(SchemaRepresentation.toJsonSchemaDocument(SchemaRepresentation.toRepresentation(Findings.ast)).schema);
2916
+ const lines$1 = (found) => table(found.findings.map((finding) => [
2917
+ `${finding.file}:${finding.line}`,
2918
+ finding.severity,
2919
+ finding.summary
2920
+ ]), rule);
2818
2921
  /**
2819
- * The findings as the Markdown a report is written in.
2922
+ * What the run reported, or the sentence saying it reported nothing at all.
2820
2923
  *
2821
- * It is what a schema-held run's report says: with a schema in force a run
2822
- * answers in findings and not in prose, so the report kept beside it is written
2823
- * from the findings themselves rather than left empty.
2924
+ * A run that failed is not a clean one: a pipe must never be handed "no
2925
+ * findings" when what happened is that nothing could be read.
2824
2926
  */
2825
- const asMarkdown = (found) => found.findings.length === 0 ? "Clean: the run found nothing to report." : found.findings.map((finding) => `- \`${finding.file}:${finding.line}\` ${finding.severity}: ${finding.summary}`).join("\n");
2826
- /** Where each severity sits against the others, so the bar can be compared with it. */
2827
- const rank = {
2828
- info: 0,
2829
- warning: 1,
2830
- error: 2
2927
+ const whatItFound = (run) => {
2928
+ const found = reportedBy(run);
2929
+ return found === null ? Effect.fail(new CliError.UserError({ cause: `The review run on ${short(run.head)} reported no findings: ${run.outcome._tag === "failed" ? run.outcome.detail : ""}\nRun dw-mc review ${run.number} --force to run it again.` })) : Effect.succeed(found);
2831
2930
  };
2832
2931
  /**
2833
- * The findings that withhold the stamp: everything at `blocksOn` or above it.
2932
+ * What the current review run found, as a table or as the JSON it is kept in.
2834
2933
  *
2835
- * `stamp.blocks_on` is my bar rather than a constant, so a repository whose
2836
- * warnings I do not want to merge past is configured rather than coded. An
2837
- * error blocks wherever the bar is, because nothing weighs more than one.
2934
+ * `--json` is the whole point of the command: it prints the findings and
2935
+ * nothing else, so I can pipe them anywhere, and a fix session inside an open
2936
+ * agent reads exactly what the tool recorded rather than a retelling of it.
2937
+ *
2938
+ * A run that failed prints no findings and fails: a review run that could not
2939
+ * report has found nothing, which is not the same as having found nothing
2940
+ * wrong, and a pipe must never be handed the second when the first is true.
2838
2941
  */
2839
- const blocking = (findings, blocksOn) => findings.filter((finding) => rank[finding.severity] >= rank[blocksOn]);
2942
+ const findings = Command.make("findings", {
2943
+ pr: prArgument,
2944
+ json: jsonFlag
2945
+ }, Effect.fn("findings")(function* ({ json, pr }) {
2946
+ const { number, repo, settings } = yield* forPr(pr);
2947
+ const run = yield* currentRun(repo, number);
2948
+ const found = yield* whatItFound(run);
2949
+ if (json) {
2950
+ yield* Console.log(yield* asJson$2(found));
2951
+ return;
2952
+ }
2953
+ yield* Console.log(header$1(run, found, settings.stamp.blocks_on));
2954
+ for (const line of lines$1(found)) yield* Console.log(` ${line}`);
2955
+ }, Effect.catchTag(["ConfigMalformed"], asUserError))).pipe(Command.withDescription("Print what the current review run found on one pull request"));
2840
2956
  //#endregion
2841
- //#region src/domain/review.ts
2957
+ //#region src/adapters/agent.ts
2842
2958
  /**
2843
- * What a review run came to, which is what its second turn reported.
2959
+ * How one turn of Claude Code is spawned and given up on, and what a turn that
2960
+ * answers against a schema comes back with.
2844
2961
  *
2845
- * A failure is recorded as one and is never a clean verdict: a turn that exited
2846
- * badly, ran out of patience or answered in a shape that does not validate has
2847
- * found nothing, which is not the same as having found nothing wrong.
2962
+ * Every turn is reached this way, so the spawn, the patience and the one failure
2963
+ * they can end in live here rather than once per turn.
2848
2964
  */
2849
- const Outcome = Schema.Union([Schema.TaggedStruct("reported", {
2850
- verdict: Verdict,
2851
- findings: Schema.Array(Finding)
2852
- }), Schema.TaggedStruct("failed", { detail: Schema.String })]);
2965
+ /** A review run that would not start, would not finish, or finished badly. */
2966
+ var AgentFailed = class extends Schema.TaggedError()("AgentFailed", {
2967
+ /** The program that was spawned, which is what a search for it has to name. */
2968
+ program: Schema.String,
2969
+ detail: Schema.String
2970
+ }) {
2971
+ get message() {
2972
+ return `The ${this.program} review run failed: ${this.detail}`;
2973
+ }
2974
+ };
2853
2975
  /**
2854
- * One review run against a tracked PR at a specific head commit.
2976
+ * Failures in the name of the program that was spawned.
2855
2977
  *
2856
- * It is a schema because a review run outlives the command that started it: the
2857
- * state directory is where the next sweep learns that this head has been
2858
- * reviewed, and where a fix session finds what there is to fix.
2978
+ * Where the launcher starts `claude` through another program, it is that
2979
+ * program that would not start or exited badly, and saying `claude` sends the
2980
+ * search to the wrong process.
2859
2981
  */
2860
- const ReviewRun = Schema.Struct({
2861
- repo: Schema.String,
2862
- number: Schema.Int,
2863
- /** The head the run covers. A run never vouches for code it did not see. */
2864
- head: Schema.String,
2865
- /**
2866
- * The slash command line the run opened on, or null where it opened on the
2867
- * tool's own prompt. A report found months later says what it was asked, and a
2868
- * record an earlier version wrote carries no such field and is forgotten.
2869
- */
2870
- command: Schema.NullOr(Schema.String),
2871
- effort: Schema.NullOr(Effort),
2872
- /**
2873
- * The agent session the run happened in, or null where it never reached one.
2874
- *
2875
- * A run that would not start or exited before it said anything has no session,
2876
- * and the run is still recorded: a failure is recorded as what it is.
2877
- */
2878
- sessionId: Schema.NullOr(Schema.String),
2879
- ranAt: Schema.DateTimeUtcFromString,
2880
- outcome: Outcome
2982
+ const failedBy = (program) => (detail) => new AgentFailed({
2983
+ program,
2984
+ detail
2881
2985
  });
2882
- /** A head as it is read out loud: the seven characters git itself abbreviates to. */
2883
- const short = (head) => head.slice(0, 7);
2884
- /**
2885
- * Where a run is kept: one key per head, so a run and the code it read cannot
2886
- * drift apart, and a re-review replaces the run before it.
2887
- */
2888
- const runKey = (repo, number, head) => `${repo}#${number}@${head}`;
2889
- /** Where the run's report is kept: beside the run, as the Markdown it is. */
2890
- const reportKey = (repo, number, head) => `${runKey(repo, number, head)}.md`;
2891
2986
  /**
2892
- * Which head a pull request was last reviewed at: an index beside `runKey` and
2893
- * `reportKey` rather than a thing the glossary names.
2987
+ * How long each turn gets before it is given up on.
2894
2988
  *
2895
- * A run is kept under the head it read, which answers the question a sweep asks
2896
- * of one head. The re-run rule and `dw-mc findings` ask the other one - which
2897
- * head the last run was at - and this is where they read it, so neither has to
2898
- * ask GitHub what is current before it can look anything up.
2989
+ * The review is the turn that thinks, and a high-effort one that fans out to
2990
+ * subagents takes real minutes, so its limit is there to catch a run that has
2991
+ * stopped rather than one that is slow. The second turn reads no code and
2992
+ * decides nothing - the review it reports on is already in the session it
2993
+ * resumes - and every run of it by hand came back in seconds.
2994
+ *
2995
+ * Either way, a command that hangs forever is worse than one that says it
2996
+ * failed: a review I walked away from is one I need to be able to come back to.
2899
2997
  */
2900
- const LastReviewed = Schema.Struct({ head: Schema.String });
2901
- /** Where that head is kept. No head is spelled `latest`, so nothing collides. */
2902
- const latestKey = (repo, number) => `${repo}#${number}@latest`;
2998
+ const patience = {
2999
+ reviewing: Duration.minutes(45),
3000
+ reporting: Duration.minutes(5)
3001
+ };
2903
3002
  /**
2904
- * The run at one head, or none where nothing has reviewed it.
3003
+ * One turn of the launcher in `directory`, with `read` over its standard output.
2905
3004
  *
2906
- * A head is where the question is asked - the stamp, the bucket and `dw-mc
2907
- * findings` all ask about one commit - and one read off the disk answers it
2908
- * without an index to keep in step.
2909
- *
2910
- * A run this version cannot read is a run another version of this record wrote,
2911
- * and the state directory is a cache of work that can be done again: forgetting
2912
- * it costs one review, where failing here would cost me the command I asked for.
3005
+ * The launcher's own arguments go in front of the turn's, because they are what
3006
+ * gets `claude` started at all. The two output streams are drained together,
3007
+ * because draining one to the end first can block a run that is still writing to
3008
+ * the other. Every way a turn can fail to finish comes back from here as a
3009
+ * `AgentFailed`, so a caller is left with the turn's own answer and nothing else
3010
+ * to translate - a turn that never comes back included.
2913
3011
  */
2914
- const runAt = Effect.fn("review.runAt")(function* (repo, number, head) {
2915
- const runs = yield* storeFor("runs", ReviewRun);
2916
- return yield* Effect.orElseSucceed(runs.get(runKey(repo, number, head)), () => Option.none());
2917
- });
2918
- /** The last review run on a pull request, or none where it has had none. */
2919
- const lastRun = Effect.fn("review.lastRun")(function* (repo, number) {
2920
- const heads = yield* storeFor("runs", LastReviewed);
2921
- const at = yield* Effect.orElseSucceed(heads.get(latestKey(repo, number)), () => Option.none());
2922
- return Option.isNone(at) ? Option.none() : yield* runAt(repo, number, at.value.head);
3012
+ const turn = Effect.fnUntraced(function* (options) {
3013
+ const [program, ...prefix] = options.command;
3014
+ const failed = failedBy(program);
3015
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
3016
+ const running = Effect.gen(function* () {
3017
+ const handle = yield* Effect.mapError(spawner.spawn(ChildProcess.make(program, [...prefix, ...options.args], {
3018
+ cwd: options.directory,
3019
+ stdin: "pipe"
3020
+ })), (error) => failed(error.message));
3021
+ const [got, stderr] = yield* Effect.mapError(Effect.all([options.read(handle.stdout), Stream.mkString(Stream.decodeText(handle.stderr))], { concurrency: 2 }), (error) => failed(error.message));
3022
+ const exitCode = yield* Effect.mapError(handle.exitCode, (error) => failed(error.message));
3023
+ if (exitCode !== 0) return yield* failed(stderr.trim() === "" ? `${program} exited ${exitCode}` : stderr.trim());
3024
+ return got;
3025
+ });
3026
+ return yield* Effect.timeoutOrElse(running, {
3027
+ duration: options.patience.duration,
3028
+ orElse: () => failed(`${options.patience.turn} did not come back within ${Duration.format(options.patience.duration)}`)
3029
+ });
2923
3030
  });
3031
+ //#endregion
3032
+ //#region src/adapters/claude.ts
2924
3033
  /**
2925
- * What a run reported, or null where it reported nothing at all.
2926
- *
2927
- * A failure is not a clean verdict: a run that could not report has found
2928
- * nothing, which is not the same as having found nothing wrong. Everything that
2929
- * reads a run's findings reads them through here, so the distinction is drawn
2930
- * once rather than at every caller that might forget it.
2931
- */
2932
- const reportedBy = (run) => run.outcome._tag === "reported" ? {
2933
- verdict: run.outcome.verdict,
2934
- findings: run.outcome.findings
2935
- } : null;
2936
- /**
2937
- * Why a run reported nothing, or null where it reported.
2938
- *
2939
- * The sibling of `reportedBy`, and here for the same reason: the two halves of
2940
- * an outcome are read through one place each rather than re-narrowed at every
2941
- * caller.
3034
+ * Claude Code: a review on a slash command, a review on the tool's own prompt,
3035
+ * and the sessions I steer.
2942
3036
  */
2943
- const detailOf = (run) => run.outcome._tag === "failed" ? run.outcome.detail : null;
2944
3037
  /**
2945
- * Whether the files changed since the last run are worth paying for another.
2946
- *
2947
- * The question is deliberately about what changed rather than how much: one
2948
- * line outside the `docs_only` globs is code nobody has reviewed, and a
2949
- * thousand lines inside them are still prose.
3038
+ * The two events of a stream-json run this reads, as the runner really writes
3039
+ * them. Every other field of both, and every other event, is ignored: a
3040
+ * transcript carries hooks, rate limits, thinking and tool results, and a
3041
+ * version that adds another must not stop a run from being read.
2950
3042
  */
2951
- const worthRerunning = (changed, docsOnly) => changed.some((file) => !docsOnly.some((glob) => matchesGlob(file, glob)));
3043
+ const Working = Schema.Struct({
3044
+ type: Schema.Literal("assistant"),
3045
+ message: Schema.Struct({ content: Schema.Array(Schema.Struct({
3046
+ type: Schema.String,
3047
+ name: Schema.optionalKey(Schema.String),
3048
+ text: Schema.optionalKey(Schema.String)
3049
+ })) })
3050
+ });
3051
+ const Ended = Schema.Struct({
3052
+ type: Schema.Literal("result"),
3053
+ subtype: Schema.String,
3054
+ is_error: Schema.Boolean,
3055
+ session_id: Schema.String,
3056
+ result: Schema.optionalKey(Schema.String),
3057
+ /** What a turn given a JSON schema validated, which this hands on unread. */
3058
+ structured_output: Schema.optionalKey(Schema.Unknown)
3059
+ });
3060
+ const asWorking = Schema.decodeUnknownOption(Schema.fromJsonString(Working));
3061
+ const asResult = Schema.decodeUnknownOption(Schema.fromJsonString(Ended));
3062
+ const heardIn = (line) => {
3063
+ const blocks = Option.match(asWorking(line), {
3064
+ onNone: () => [],
3065
+ onSome: (event) => event.message.content
3066
+ });
3067
+ return {
3068
+ tools: blocks.flatMap((block) => block.type === "tool_use" && block.name !== void 0 ? [block.name] : []),
3069
+ said: blocks.flatMap((block) => block.type === "text" && block.text !== void 0 ? [block.text] : [])
3070
+ };
3071
+ };
2952
3072
  /**
2953
- * The re-run rule: the head this run is skipped against, or null where it runs.
3073
+ * The result a turn ended on, or the failure it really was.
2954
3074
  *
2955
- * A review costs real money and minutes of my attention, and a typo fix is not
2956
- * worth either. Four things are never skipped, because the rule is here to save
2957
- * me a review and not to stand between me and one I asked for: a pull request
2958
- * with no run behind it, a run that reported nothing, a comparison GitHub would
2959
- * not answer, and anything that changed outside the globs. A head that has
2960
- * already had a run changed nothing at all, which is the one case that needs no
2961
- * comparison to decide.
3075
+ * A turn that said nothing this can read and a turn Claude Code itself calls an
3076
+ * error are both failures: `subtype` is where a run that hit its turn limit or
3077
+ * lost its connection says so, and its `result` is the only word on why.
2962
3078
  */
2963
- const skippedSince = (asked, docsOnly) => {
2964
- if (asked.last === null || reportedBy(asked.last) === null) return null;
2965
- const changed = asked.last.head === asked.head ? [] : asked.changed;
2966
- return changed === null || worthRerunning(changed, docsOnly) ? null : asked.last.head;
3079
+ const ended = (program, result) => {
3080
+ const failed = failedBy(program);
3081
+ if (Option.isNone(result)) return Effect.fail(failed("the turn came back with no result"));
3082
+ const { is_error, result: lastWord, subtype } = result.value;
3083
+ return is_error || subtype !== "success" ? Effect.fail(failed(`${subtype}: ${lastWord ?? "nothing else was said"}`)) : Effect.succeed(result.value);
2967
3084
  };
2968
- /** What a run was opened on, as the report says it. */
2969
- const askedOf$1 = (run) => run.command === null ? "the tool's own prompt" : [run.command, run.effort].filter((part) => part !== null).join(" ");
2970
3085
  /**
2971
- * The report as it is written down: what it is of, then what the run said.
3086
+ * A Claude Code `stream-json` turn, read as it arrives: what it reached for goes
3087
+ * to `onTool` while the run is still going, and what it said and how it ended
3088
+ * are what comes back.
2972
3089
  *
2973
- * The heading is the whole point of writing it rather than storing the prose
2974
- * alone - a file found months later says which pull request, which commit and
2975
- * what the run was asked, without anything else having to be open.
3090
+ * Both shapes of review read a turn the same way, so the fold is here rather
3091
+ * than once per shape.
2976
3092
  */
2977
- const reportDocument = (run, title, prose) => [
2978
- `# ${run.repo}#${run.number} ${title}`,
2979
- "",
2980
- `- head: ${run.head}`,
2981
- `- run: ${askedOf$1(run)}`,
2982
- `- ran: ${DateTime.formatIso(run.ranAt)}`,
2983
- "",
2984
- prose.trim(),
2985
- ""
2986
- ].join("\n");
3093
+ const transcript = (onTool) => (stdout) => stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.mapEffect((line) => {
3094
+ const heard = heardIn(line);
3095
+ return Effect.as(Effect.forEach(heard.tools, onTool, { discard: true }), {
3096
+ line,
3097
+ heard
3098
+ });
3099
+ }), Stream.runFold(() => ({
3100
+ said: [],
3101
+ result: Option.none()
3102
+ }), (soFar, { heard, line }) => ({
3103
+ said: [...soFar.said, ...heard.said],
3104
+ result: Option.orElse(asResult(line), () => soFar.result)
3105
+ })));
2987
3106
  /**
2988
- * Whether `head` has the review it needs.
3107
+ * One review run on a slash command, headless, in `directory`.
2989
3108
  *
2990
- * A run that reported nothing does not count, which is the same rule
2991
- * `reportedBy` draws everywhere else: a failure has found nothing, not found
2992
- * nothing wrong.
2993
- */
2994
- const reviewedBy = (run) => run !== null && reportedBy(run) !== null;
2995
- /** The findings at one head that withhold the stamp. */
2996
- const blockingIn = (run, blocksOn) => {
2997
- const found = run === null ? null : reportedBy(run);
2998
- return found === null ? [] : blocking(found.findings, blocksOn);
2999
- };
3000
- /**
3001
- * What the review runs on `head` say about it, for the stamp to rest on.
3109
+ * The run is in the foreground and says what it is doing as it does it, which
3110
+ * is what `onTool` is for: a review takes minutes, and a terminal that prints
3111
+ * nothing for minutes is one I stop trusting.
3002
3112
  *
3003
- * Whether a head has been reviewed is the runs' to say and no sweep's: a run is
3004
- * recorded against one head, and a head with no run of its own has not been
3005
- * reviewed however many sweeps have seen the pull request. A run that could not
3006
- * report findings does not count either: its verdict is what takes a pull
3007
- * request out of Needs review run, and it reached none.
3113
+ * `--json-schema` is never passed here: verified by running it, the flag beside
3114
+ * `/code-review` breaks the run, which is why a slash command costs a second
3115
+ * turn that resumes the session and asks for the findings. My own instructions
3116
+ * ride on `--append-system-prompt` rather than on the command's own line,
3117
+ * because what a slash command does with its arguments is its business and not
3118
+ * this tool's.
3008
3119
  *
3009
- * It is one function because the two callers are a sweep and `dw-mc merge`, and
3010
- * the second exists to land what the first only describes: two spellings of
3011
- * this would be two answers to whether a head has been reviewed.
3120
+ * `--comment` is the flag that makes the built-in review post on the pull
3121
+ * request, and it is never passed either (ADR 0002). The report is everything
3122
+ * the run said on its own turns rather than the `result` alone: verified by
3123
+ * running it, a repository whose review command fans out to subagents can end on
3124
+ * a remark about them, and the report is the turn before that.
3012
3125
  */
3013
- const reviewedAt = Effect.fn("review.reviewedAt")(function* (repo, number, head, settings) {
3014
- const run = Option.getOrNull(yield* runAt(repo, number, head));
3126
+ const commandReview = Effect.fn("claude.commandReview")(function* (options) {
3127
+ const [program] = options.launcher.command;
3128
+ const run = yield* turn({
3129
+ command: options.launcher.command,
3130
+ directory: options.directory,
3131
+ args: [
3132
+ "-p",
3133
+ options.line,
3134
+ "--output-format",
3135
+ "stream-json",
3136
+ "--verbose",
3137
+ ...options.instructions === null ? [] : ["--append-system-prompt", options.instructions],
3138
+ ...options.model === null ? [] : ["--model", options.model]
3139
+ ],
3140
+ patience: {
3141
+ turn: "the review",
3142
+ duration: patience.reviewing
3143
+ },
3144
+ read: transcript(options.onTool)
3145
+ });
3146
+ const { result: lastWord, session_id } = yield* ended(program, run.result);
3147
+ const report = (run.said.length === 0 ? lastWord ?? "" : run.said.join("\n\n")).trim();
3148
+ if (report === "") return yield* failedBy(program)("the run came back with an empty report");
3015
3149
  return {
3016
- reviewRunHead: reviewedBy(run) ? head : null,
3017
- blockingFindings: blockingIn(run, settings.stamp.blocks_on).length
3150
+ report,
3151
+ sessionId: session_id
3018
3152
  };
3019
- });
3020
- //#endregion
3021
- //#region src/cli/sweep.ts
3022
- const writtenBy = (comments, login) => comments.filter((comment) => comment.login === login).map((comment) => comment.at);
3023
- const byHumansOtherThan = (comments, login) => comments.filter((comment) => !comment.bot && comment.login !== login).map((comment) => comment.at);
3153
+ }, Effect.scoped);
3024
3154
  /**
3025
- * The facts about one tracked PR, read from GitHub and kept on disk.
3155
+ * What the second turn asks for.
3026
3156
  *
3027
- * The cheap reads happen every time, because they are what says whether the PR
3028
- * moved. The commits are asked for only when it did: `gh` returns every commit
3029
- * message in full, and on a PR that is where the last sweep left it that whole
3030
- * read buys a timestamp the state directory already has.
3157
+ * It asks for a report of what was already said rather than for another look:
3158
+ * the prose is the review, and this turn is only what makes it machine
3159
+ * readable. The shape it must answer in arrives as a JSON schema beside it, so
3160
+ * the prompt does not describe the schema twice.
3031
3161
  */
3032
- const sweepPr = Effect.fn("sweep.pullRequest")(function* (store, me, found, settings) {
3033
- const view = yield* prView(found.repo, found.number);
3034
- const [onThePr, inReviews] = yield* Effect.all([prComments(found.repo, found.number), prReviews(found.repo, found.number)], { concurrency: 2 });
3035
- const comments = [...onThePr, ...inReviews];
3036
- const checks = rollupState(view.statusCheckRollup, settings.ci.ignore);
3037
- const newestHumanCommentAt = newest(byHumansOtherThan(comments, me));
3038
- const key = prKey(found.repo, found.number);
3039
- const previous = Option.getOrUndefined(yield* Effect.orElseSucceed(store.get(key), () => Option.none()));
3040
- const reviewed = yield* reviewedAt(found.repo, found.number, view.headRefOid, settings);
3041
- const quiet = previous !== void 0 && isQuiet(pulseOf(previous), {
3042
- head: view.headRefOid,
3043
- checks,
3044
- newestHumanCommentAt
3045
- }) ? previous : void 0;
3046
- const myLastCommitAt = quiet !== void 0 ? quiet.myLastCommitAt : newest((yield* prCommits(found.repo, found.number)).filter((commit) => commit.logins.includes(me)).map((commit) => commit.at));
3047
- const ciFlaky = checks !== "red" ? null : quiet !== void 0 ? quiet.ciFlaky : yield* flakyReason(found.repo, found.number, view.statusCheckRollup, settings.ci.ignore, settings.ci.flaky_patterns);
3048
- const rebaseConflictAt = yield* Effect.map(conflictFor(found.repo, found.number), (it) => it?.head ?? null);
3049
- const facts = {
3050
- repo: found.repo,
3051
- number: found.number,
3052
- title: view.title,
3053
- url: view.url,
3054
- draft: view.isDraft,
3055
- head: view.headRefOid,
3056
- mergeable: mergeabilityOf(view.mergeable),
3057
- reviewDecision: reviewDecisionOf(view.reviewDecision),
3058
- checks,
3059
- ciFlaky,
3060
- rebaseConflictAt,
3061
- newestHumanCommentAt,
3062
- myLastCommentAt: newest(writtenBy(comments, me)),
3063
- myLastCommitAt,
3064
- ...reviewed
3065
- };
3066
- yield* store.set(key, facts);
3067
- return facts;
3068
- });
3069
- /** A read that came back, or the trouble it came back with instead. */
3070
- const attempt = (where, read) => read.pipe(Effect.map((got) => ({
3071
- got,
3072
- troubles: []
3073
- })), Effect.catch((error) => Effect.succeed({
3074
- got: [],
3075
- troubles: [{
3076
- where,
3077
- detail: error.message
3078
- }]
3079
- })));
3080
- const gather = (attempts) => ({
3081
- got: attempts.flatMap((it) => it.got),
3082
- troubles: attempts.flatMap((it) => it.troubles)
3083
- });
3084
- /** How many reads of GitHub are in flight at once. */
3085
- const concurrency = 4;
3162
+ const reportFindings = [
3163
+ "Report the findings of the review you just gave as structured output.",
3164
+ "Every finding carries the file it is in as a repository path, the line it is at,",
3165
+ "its severity and a one-sentence summary.",
3166
+ "The verdict is clean when there is nothing to report and findings otherwise.",
3167
+ "Report nothing you did not already say."
3168
+ ].join(" ");
3086
3169
  /**
3087
- * One pass over every tracked PR, and nothing else: a sweep only ever reads.
3170
+ * The second turn of a review run: the prose the first one wrote, back as
3171
+ * findings that validate.
3088
3172
  *
3089
- * Every repository and every pull request is read on its own, so one of them
3090
- * failing costs me its rows and leaves the rest of the table standing. What
3091
- * failed comes back beside the facts rather than instead of them.
3173
+ * It resumes the first turn's session rather than reading the diff again, which
3174
+ * is what makes it cheap and what makes it accurate - verified by running it,
3175
+ * the line numbers it reports beat the ones the prose gives. The output is
3176
+ * handed on as it arrived: what the findings must look like belongs to the
3177
+ * domain, and the schema the run is held to comes in from there too.
3178
+ *
3179
+ * Every way this can end badly ends as an `AgentFailed`, because a review run
3180
+ * that could not report is a failure and never a clean verdict.
3092
3181
  */
3093
- const sweep = Effect.gen(function* () {
3094
- const file = Option.getOrElse(yield* read, () => ({}));
3095
- const repos = Object.keys(file.repos ?? {}).toSorted();
3096
- if (repos.length === 0) return {
3097
- repos,
3098
- facts: [],
3099
- troubles: []
3100
- };
3101
- const store = yield* storeFor("prs", Facts);
3102
- const me = yield* viewer;
3103
- const found = gather(yield* Effect.forEach(repos, (repo) => attempt(repo, searchPrs(repo)), { concurrency }));
3104
- 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 }));
3182
+ const findingsTurn = Effect.fn("claude.findingsTurn")(function* (options) {
3183
+ const [program] = options.launcher.command;
3184
+ const printed = yield* turn({
3185
+ command: options.launcher.command,
3186
+ directory: options.directory,
3187
+ patience: {
3188
+ turn: "the findings turn",
3189
+ duration: patience.reporting
3190
+ },
3191
+ args: [
3192
+ "-p",
3193
+ "--resume",
3194
+ options.sessionId,
3195
+ reportFindings,
3196
+ "--output-format",
3197
+ "json",
3198
+ "--json-schema",
3199
+ options.jsonSchema
3200
+ ],
3201
+ read: (stdout) => Stream.mkString(Stream.decodeText(stdout))
3202
+ });
3203
+ const { structured_output } = yield* ended(program, asResult(printed.trim()));
3204
+ if (structured_output === void 0) return yield* failedBy(program)("the findings turn came back with no structured output");
3205
+ return structured_output;
3206
+ }, Effect.scoped);
3207
+ /**
3208
+ * One review run of the tool's own review prompt, in `directory`.
3209
+ *
3210
+ * It is one turn rather than two: verified by running it, `--json-schema` beside
3211
+ * an ordinary prompt gives both the prose the run wrote and the
3212
+ * `structured_output` it validated, where the same flag on a slash command
3213
+ * breaks the run. The schema arrives as inline JSON and never as a path - a path
3214
+ * is where Claude Code reports `--json-schema is not valid JSON`.
3215
+ */
3216
+ const promptReview = Effect.fn("claude.promptReview")(function* (options) {
3217
+ const [program] = options.launcher.command;
3218
+ const run = yield* turn({
3219
+ command: options.launcher.command,
3220
+ directory: options.directory,
3221
+ patience: {
3222
+ turn: "the review",
3223
+ duration: patience.reviewing
3224
+ },
3225
+ args: [
3226
+ "-p",
3227
+ options.prompt,
3228
+ "--output-format",
3229
+ "stream-json",
3230
+ "--verbose",
3231
+ "--json-schema",
3232
+ options.jsonSchema,
3233
+ ...options.model === null ? [] : ["--model", options.model]
3234
+ ],
3235
+ read: transcript(options.onTool)
3236
+ });
3237
+ const { session_id, structured_output } = yield* ended(program, run.result);
3238
+ if (structured_output === void 0) return yield* failedBy(program)("the review came back with no structured output");
3239
+ const prose = run.said.join("\n\n").trim();
3105
3240
  return {
3106
- repos,
3107
- facts: swept.got,
3108
- troubles: [...found.troubles, ...swept.troubles]
3241
+ findings: structured_output,
3242
+ sessionId: session_id,
3243
+ prose: prose === "" ? null : prose
3109
3244
  };
3110
- }).pipe(Effect.withSpan("sweep"));
3245
+ }, Effect.scoped);
3111
3246
  /**
3112
- * The failures a sweep can hit before it has a single row, which are the ones
3113
- * worth a sentence: a machine or a file that needs fixing says what to fix
3114
- * instead of printing a stack.
3247
+ * One review run, in whichever shape it was configured in.
3248
+ *
3249
+ * A slash command takes two turns and the tool's own prompt takes one, which is
3250
+ * Claude Code's doing and nobody else's: a caller hands over the turn and gets
3251
+ * the same answer back either way.
3252
+ *
3253
+ * The second turn's failure is kept beside the first turn's prose rather than
3254
+ * replacing it. A review that ran and could not report is still worth reading,
3255
+ * and it is recorded as the failure it is.
3115
3256
  */
3116
- const userFacing = [
3117
- "ConfigMalformed",
3118
- "GhUnavailable",
3119
- "GhReadFailed",
3120
- "GhUnreadable"
3121
- ];
3122
- /** Turns one of those into the sentence the CLI prints. */
3123
- const asUserError = (cause) => Effect.fail(new CliError.UserError({ cause }));
3124
- /** What a sweep could not read, under a heading, so the table above it stands alone. */
3125
- const printTroubles = Effect.fn("sweep.printTroubles")(function* (troubles) {
3126
- if (troubles.length === 0) return;
3127
- yield* Console.log("");
3128
- yield* Console.log("Could not load");
3129
- for (const trouble of troubles) yield* Console.log(` ${trouble.where} ${trouble.detail}`);
3257
+ const reviewTurns = Effect.fn("claude.reviewTurns")(function* (options) {
3258
+ const { directory, jsonSchema, launcher, model, onTool } = options;
3259
+ if (options.turn._tag === "prompt") {
3260
+ const run = yield* promptReview({
3261
+ launcher,
3262
+ directory,
3263
+ prompt: options.turn.text,
3264
+ model,
3265
+ jsonSchema,
3266
+ onTool
3267
+ });
3268
+ return {
3269
+ sessionId: run.sessionId,
3270
+ prose: run.prose,
3271
+ findings: Result.succeed(run.findings)
3272
+ };
3273
+ }
3274
+ const run = yield* commandReview({
3275
+ launcher,
3276
+ directory,
3277
+ line: options.turn.line,
3278
+ instructions: options.turn.instructions,
3279
+ model,
3280
+ onTool
3281
+ });
3282
+ const findings = yield* Effect.result(findingsTurn({
3283
+ launcher,
3284
+ directory,
3285
+ sessionId: run.sessionId,
3286
+ jsonSchema
3287
+ }));
3288
+ return {
3289
+ sessionId: run.sessionId,
3290
+ prose: run.report,
3291
+ findings
3292
+ };
3130
3293
  });
3131
3294
  /**
3132
- * Refreshes what mission control knows about every tracked PR.
3295
+ * An interactive `claude` in `directory`, opened on `prompt`, with my terminal
3296
+ * handed straight to it.
3133
3297
  *
3134
- * `dw-mc status` does this too, so this command is for the pass on its own:
3135
- * warming the state directory, or seeing what GitHub would not answer.
3298
+ * The launcher's `fix_args` go here and nowhere else: they are the flags of
3299
+ * every session I steer - the one on findings and the one on a conflict - which
3300
+ * no headless review turn wants. They sit in front of the
3301
+ * prompt, because `claude` takes its flags before its positional argument.
3302
+ *
3303
+ * This is the one place a run is not read: the three streams are inherited,
3304
+ * so what is on the screen is the session itself and not a transcript of it,
3305
+ * and what I type reaches it. The child is not detached for the same reason -
3306
+ * a detached child sits outside the terminal's foreground process group, where
3307
+ * neither my keystrokes nor Ctrl-C would reach it.
3308
+ *
3309
+ * There is no patience here either. A session I steer lasts as long as I am in
3310
+ * it, and a timeout would be the tool closing a session I was still working in.
3311
+ *
3312
+ * What comes back is the code the session ended on. A session I left with
3313
+ * Ctrl-C ended badly for `claude` and not for me, so this reports it rather
3314
+ * than failing on it; only a `claude` that would not start at all is a failure.
3136
3315
  */
3137
- const sweepCommand = Command.make("sweep", {}, Effect.fn("sweep.command")(function* () {
3138
- const report = yield* sweep;
3139
- 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`}`);
3140
- yield* printTroubles(report.troubles);
3141
- }, Effect.catchTag(userFacing, asUserError))).pipe(Command.withDescription("Refresh what mission control knows about every tracked pull request"));
3316
+ const steeredSession = Effect.fn("claude.steeredSession")(function* (options) {
3317
+ const [program, ...prefix] = options.launcher.command;
3318
+ const failed = failedBy(program);
3319
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
3320
+ const handle = yield* Effect.mapError(spawner.spawn(ChildProcess.make(program, [
3321
+ ...prefix,
3322
+ ...options.launcher.fix_args,
3323
+ options.prompt
3324
+ ], {
3325
+ cwd: options.directory,
3326
+ stdin: "inherit",
3327
+ stdout: "inherit",
3328
+ stderr: "inherit",
3329
+ detached: false
3330
+ })), (error) => failed(error.message));
3331
+ return yield* Effect.mapError(handle.exitCode, (error) => failed(error.message));
3332
+ }, Effect.scoped);
3142
3333
  //#endregion
3143
- //#region src/domain/comments.ts
3334
+ //#region src/domain/fix.ts
3335
+ /** One finding I chose to act on, carrying what I think about it. */
3336
+ const Chosen = Schema.Struct({
3337
+ ...Finding.fields,
3338
+ note: Schema.optionalKey(Schema.String)
3339
+ });
3144
3340
  /**
3145
- * One thread's share of a strand, cut to what is worth reading.
3146
- *
3147
- * A review thread is answered as a whole, so a single comment newer than my
3148
- * last activity brings the whole thread with it: the follow-up on its own is a
3149
- * line answering something the screen does not show, which is what sends me to
3150
- * the browser.
3341
+ * What a fix session is handed: the findings I picked, and the review run they
3342
+ * came from.
3151
3343
  *
3152
- * The pull request's own comments are not a thread but a stream, and there is
3153
- * no reply to lose the question of, so they are cut comment by comment.
3344
+ * The head is in it because a fix session opens on the commit that was
3345
+ * reviewed, and a finding's line means nothing away from it.
3154
3346
  */
3155
- const only = (thread, keep, since, all) => {
3156
- const strand = thread.comments.filter((it) => keep(it.bot));
3157
- const comments = all ? strand : thread.path === null ? strand.filter((it) => isAfter(it.at, since)) : strand.some((it) => isAfter(it.at, since)) ? strand : [];
3158
- return comments.length === 0 ? [] : [{
3159
- ...thread,
3160
- comments
3161
- }];
3162
- };
3347
+ const Selection = Schema.Struct({
3348
+ repo: Schema.String,
3349
+ number: Schema.Int,
3350
+ head: Schema.String,
3351
+ findings: Schema.Array(Chosen)
3352
+ });
3353
+ /** The selection as the JSON the schema defines, rather than as this file spells it. */
3354
+ const asJson$1 = Schema.encodeEffect(Schema.fromJsonString(Selection));
3163
3355
  /**
3164
- * The threads worth putting on screen, given what I have already done.
3356
+ * The prompt a fix session opens on: what these findings are, and the findings
3357
+ * themselves as JSON.
3165
3358
  *
3166
- * `since` is my last activity on the pull request - the later of my last
3167
- * comment and my last commit - which is the same moment the bucket rule
3168
- * measures a comment against. Showing exactly what is newer than it means the
3169
- * command answers the question the bucket asked.
3359
+ * The findings go in verbatim rather than described, because a re-description
3360
+ * is where a file, a line or my own note quietly changes. A note outranks the
3361
+ * finding it is on: the finding is what the review thought, the note is what I
3362
+ * think, and I am the one who picked it.
3170
3363
  *
3171
- * A thread somebody resolved and one against code that is gone are left out:
3172
- * neither is something to answer, and both are still there to read under
3173
- * `--all`, which asks for the whole conversation and so measures nothing
3174
- * against anything.
3364
+ * Pushing is mine either way, and `commits` says whether committing is too.
3365
+ * The tool itself never commits and never pushes; what the session may do
3366
+ * inside the worktree is my call, made once in `fix.commits` or for one session
3367
+ * with the flag.
3175
3368
  */
3176
- const shown = (threads, options) => {
3177
- const kept = options.all ? threads : threads.filter((it) => !it.resolved && !it.outdated);
3178
- return {
3179
- people: kept.flatMap((it) => only(it, (bot) => !bot, options.since, options.all)),
3180
- bots: kept.flatMap((it) => only(it, (bot) => bot, options.since, options.all))
3181
- };
3182
- };
3369
+ const promptFor$1 = (selection, commits) => Effect.map(asJson$1(selection), (json) => [
3370
+ `These are the findings I picked from a dw-mc review run on ${selection.repo}#${selection.number}, at ${short(selection.head)}, the commit their lines are counted from.`,
3371
+ "Work through them one at a time. Where a finding carries a note, the note is mine and outranks the finding's own summary; where it carries none, the summary is the whole brief.",
3372
+ commits ? `Commit what you change, one logical change to a commit. Do not push: I read the commits and push them myself.` : `Do not commit and do not push: I do both myself when I have read what you changed.`,
3373
+ json
3374
+ ].join("\n\n"));
3375
+ /**
3376
+ * Why these findings cannot be fixed where the pull request now is, or nothing
3377
+ * where they can.
3378
+ *
3379
+ * A pull request that moved since its last review run has findings at lines
3380
+ * that may no longer be there, and a worktree cut at the new head would carry
3381
+ * them into code they were never about. Reviewing again is cheap next to fixing
3382
+ * the wrong thing.
3383
+ */
3384
+ const staleAt = (number, run, now) => run === now ? null : `The findings are from ${short(run)} and the pull request is now at ${short(now)}. Run dw-mc review ${number} again to review the head you would be fixing.`;
3183
3385
  //#endregion
3184
- //#region src/cli/comments.ts
3185
- const allFlag = Flag.Boolean("all").pipe(Flag.withDefault(false), Flag.withDescription("Print the whole conversation, including what is resolved, outdated and already answered"));
3186
- /** Where a thread hangs: a line of the diff, or the pull request itself. */
3187
- const where$1 = (thread) => thread.path === null ? "Conversation" : thread.line === null ? thread.path : `${thread.path}:${thread.line}`;
3386
+ //#region src/cli/fix.ts
3387
+ const printFlag$1 = Flag.Boolean("print").pipe(Flag.withDefault(false), Flag.withDescription("Print the prompt a session would open on, and open none"));
3388
+ const commitFlag = Flag.Boolean("commit").pipe(Flag.withDescription("Let this session commit what it changes, over what the repository configured"), Flag.optional);
3188
3389
  /**
3189
- * What is true of a thread beyond where it hangs.
3390
+ * The findings to pick from, each on the line `dw-mc findings` gives it.
3190
3391
  *
3191
- * It is only ever printed under `--all`, which is the only way a settled thread
3192
- * reaches the screen at all, and it is there so that reading one is never
3193
- * reading it as something still open.
3392
+ * The rows come from there rather than being built again here, so the list I
3393
+ * pick from and the list I read are the same list. A row that does not fit the
3394
+ * screen is cut: a prompt draws its own frame around the row, and a row that
3395
+ * wraps takes the whole list's alignment with it.
3194
3396
  */
3195
- const settled = (thread) => [thread.resolved ? "resolved" : null, thread.outdated ? "outdated" : null].filter((it) => it !== null).join(", ");
3397
+ const choicesOf$1 = (found, screen) => {
3398
+ const rows = lines$1(found);
3399
+ const room = screen === 0 ? Number.POSITIVE_INFINITY : screen - 6;
3400
+ return found.findings.map((finding, index) => ({
3401
+ title: truncate(rows[index] ?? finding.summary, room),
3402
+ value: finding
3403
+ }));
3404
+ };
3196
3405
  /**
3197
- * One thread as a block: where it hangs, then everybody who said something in
3198
- * it, then what they said in full.
3406
+ * Each picked finding with whatever I have to say about it.
3199
3407
  *
3200
- * In full because a review comment is usually a paragraph carrying a
3201
- * suggestion, and a first line is what sends me to the browser this command
3202
- * exists to replace. No diff hunk with it: the code is on this machine, under
3203
- * the path the heading already prints.
3408
+ * The note is asked for one finding at a time, in the order I see them, and
3409
+ * having nothing to say is the ordinary answer rather than a step I have to get
3410
+ * past.
3204
3411
  */
3205
- const block$1 = (thread, paint) => [`${paint.bold(where$1(thread))}${settled(thread) === "" ? "" : paint.dim(` (${settled(thread)})`)}`, ...thread.comments.flatMap((comment) => [` ${paint.dim(`@${comment.login} ${DateTime.formatIso(comment.at)}`)}`, ...comment.body.split("\n").map((line) => ` ${line}`)])];
3206
- const separated = (blocks) => blocks.flatMap((lines, index) => index === 0 ? lines : ["", ...lines]);
3412
+ const noted = Effect.fn("fix.noted")(function* (picked) {
3413
+ const chosen = [];
3414
+ for (const finding of picked) {
3415
+ const said = yield* note(`Note on ${finding.file}:${finding.line}, or nothing`);
3416
+ chosen.push(Option.match(said, {
3417
+ onNone: () => finding,
3418
+ onSome: (text) => ({
3419
+ ...finding,
3420
+ note: text
3421
+ })
3422
+ }));
3423
+ }
3424
+ return chosen;
3425
+ });
3207
3426
  /**
3208
- * The conversation on screen: people first, then a rule, then the bots.
3427
+ * A fix session: the findings I picked, in an agent session I steer.
3209
3428
  *
3210
- * The rule is there so the two are never read as one list. A bot's comment is
3211
- * observed and never answered, and the bucket rules ignore bots for exactly
3212
- * this reason.
3429
+ * The tool fixes nothing. It picks the findings apart with me, cuts a worktree
3430
+ * on a branch of its own that tracks the pull request's, and hands the session
3431
+ * what I chose as JSON; then it is out of the way. I steer and I push. Nothing
3432
+ * here writes to GitHub, and the tool itself commits nothing: whether the
3433
+ * session may commit inside the worktree is `fix.commits`, or `--commit` for
3434
+ * one session.
3213
3435
  *
3214
- * A bot is cut at the same moment I am measured against, because the window is
3215
- * what has happened since I last acted rather than what is owed an answer. A
3216
- * verdict older than my last push is one I have already had the chance to read,
3217
- * and `--all` is where it still is.
3436
+ * The worktree is left standing when the session ends, because the work in it
3437
+ * is mine and an unpushed commit lives nowhere else. Re-reviewing the result is
3438
+ * a new review run against the new head, never a continuation of the run that
3439
+ * produced these findings, so what was reviewed at which commit stays honest.
3218
3440
  */
3219
- const lines$2 = (view, paint) => {
3220
- const people = view.people.map((thread) => block$1(thread, paint));
3221
- const bots = view.bots.map((thread) => block$1(thread, paint));
3222
- return separated([...people, ...bots.length === 0 ? [] : [[paint.dim("── bots ──")], ...bots]]);
3223
- };
3224
- /** What to say where there is nothing to print, which depends on why there is not. */
3225
- const nothing = (facts, all) => {
3226
- const pr = `${facts.repo}#${facts.number}`;
3227
- if (all) return [`Nothing has been said on ${pr}.`];
3228
- const placement = place(facts);
3229
- const rest = `dw-mc comments ${facts.number} --all prints the whole conversation.`;
3230
- return placement.bucket === "needs-me" && placement.reason === "a comment I have not answered" ? [
3231
- "Nothing here is waiting on you: every thread is resolved, outdated, or older than your last comment or commit.",
3232
- `${pr} sits in ${heading[placement.bucket]} all the same, and a reply or a push is what settles it.`,
3233
- rest
3234
- ] : [`Nothing has been said on ${pr} since your last comment or commit.`, rest];
3441
+ const fix = Command.make("fix", {
3442
+ pr: prArgument,
3443
+ commit: commitFlag,
3444
+ print: printFlag$1
3445
+ }, Effect.fn("fix")(function* ({ commit, pr, print }) {
3446
+ const { number, repo, settings, launcher } = yield* forPr(pr);
3447
+ const run = yield* currentRun(repo, number);
3448
+ const found = yield* whatItFound(run);
3449
+ yield* Console.log(header$1(run, found, settings.stamp.blocks_on));
3450
+ if (found.findings.length === 0) return;
3451
+ const view = yield* reading(`${repo}#${number}`, prView(repo, number));
3452
+ yield* refuse(staleAt(number, run.head, view.headRefOid));
3453
+ const picked = yield* choose("Which findings does the session carry?", choicesOf$1(found, yield* width));
3454
+ const chosen = yield* Effect.catchTag(noted(Option.getOrElse(picked, () => [])), "QuitError", () => Effect.succeed([]));
3455
+ if (chosen.length === 0) {
3456
+ yield* Console.log("Nothing picked, so no session was opened.");
3457
+ return;
3458
+ }
3459
+ const commits = Option.getOrElse(commit, () => settings.fix.commits);
3460
+ if (print) {
3461
+ yield* Console.log(yield* promptFor$1({
3462
+ repo,
3463
+ number,
3464
+ head: run.head,
3465
+ findings: chosen
3466
+ }, commits));
3467
+ return;
3468
+ }
3469
+ const worktree = yield* standingWorktree(repo, number, view.headRefName, "fix");
3470
+ yield* Console.log(` ${chosen.length} of ${found.findings.length} findings, ${commits ? "committing" : "not committing"}`);
3471
+ yield* Console.log(` ${worktree.directory}, pushing to ${view.headRefName}`);
3472
+ const ended = yield* steeredSession({
3473
+ launcher,
3474
+ directory: worktree.directory,
3475
+ prompt: yield* promptFor$1({
3476
+ repo,
3477
+ number,
3478
+ head: worktree.head,
3479
+ findings: chosen
3480
+ }, commits)
3481
+ });
3482
+ yield* Console.log(ended === 0 ? "The session is over." : `The session ended with ${ended}.`);
3483
+ yield* Console.log(`${commits ? "Nothing was pushed" : "Nothing was committed or pushed"} for you; the worktree stands at ${worktree.directory}.`);
3484
+ yield* Console.log(`Once you have pushed, dw-mc review ${number} reviews the new head as a new run.`);
3485
+ }, Effect.catchTag(userFacingAndSession, asUserError))).pipe(Command.withDescription("Pick findings from the current review run and open a fix session on them"));
3486
+ //#endregion
3487
+ //#region src/cli/init.ts
3488
+ const effortFlag$1 = Flag.Literals("effort", [
3489
+ "low",
3490
+ "medium",
3491
+ "high",
3492
+ "xhigh",
3493
+ "max"
3494
+ ]).pipe(Flag.withDescription("How much a review run spends on this repository"), Flag.optional);
3495
+ const baseFlag = Flag.String("base").pipe(Flag.withDescription("The branch this repository's pull requests target, over the default one"), Flag.optional);
3496
+ /** The settings the flags asked for, and only those. */
3497
+ const asked = (base, effort) => ({
3498
+ ...Option.isSome(base) ? { base: base.value } : {},
3499
+ ...Option.isSome(effort) ? { review: { effort: effort.value } } : {}
3500
+ });
3501
+ /** What a review will open on, as the setup prints it back. */
3502
+ const opening = (defaults) => {
3503
+ const review = {
3504
+ ...builtIn.review,
3505
+ ...defaults.review
3506
+ };
3507
+ return review.command === null ? "my own prompt" : [review.command, review.effort].filter((part) => part !== null).join(" ");
3235
3508
  };
3509
+ const row = (label, value) => `${label.padEnd(12)}${value}`;
3236
3510
  /**
3237
- * The conversation on one tracked pull request, and nothing else.
3238
- *
3239
- * What it shows by default is what the bucket rule measures: the comments newer
3240
- * than the later of my last comment and my last commit, which are the ones that
3241
- * put the pull request in Needs me. Reading it answers the question the table
3242
- * asked.
3511
+ * Both the machine setup and the repository registration: there is deliberately
3512
+ * no separate `setup` command.
3243
3513
  *
3244
- * The cutoff is read off the last sweep rather than worked out again here, so
3245
- * the command shows exactly what `dw-mc status` counted rather than a second
3246
- * opinion about it.
3514
+ * The first run on a machine checks `gh` and spells the defaults out in the
3515
+ * configuration file. Run inside a repository, it also registers that
3516
+ * `owner/repo`, taking the name from `gh` so I never type it. Run again, it
3517
+ * changes what the flags name, keeps every other setting the file already had,
3518
+ * and leaves the file untouched where nothing was decided differently.
3247
3519
  *
3248
- * It writes nothing, here or on GitHub: no reply, no resolve, no reaction
3249
- * (ADR 0002). Reading is the whole command.
3520
+ * It asks nothing. Reviews run on Claude Code, and what a run opens on is
3521
+ * `review.command` and `review.prompt` - a line and a paragraph that belong in
3522
+ * the file rather than in a terminal prompt.
3523
+ *
3524
+ * `--effort` and `--base` are about one repository, so they land on the
3525
+ * repository this ran in, or in the defaults when it ran outside one.
3250
3526
  */
3251
- const comments = Command.make("comments", {
3252
- pr: prArgument,
3253
- all: allFlag
3254
- }, Effect.fn("comments")(function* ({ all, pr }) {
3255
- const file = Option.getOrElse(yield* read, () => ({}));
3256
- const { number, repo } = yield* named(pr, Object.keys(file.repos ?? {}).toSorted());
3257
- const facts = yield* swept(repo, number);
3258
- const paint = yield* Paint;
3259
- const view = shown(yield* prConversation(repo, number), {
3260
- since: later(facts.myLastCommentAt, facts.myLastCommitAt),
3261
- all
3262
- });
3263
- if (view.people.length === 0 && view.bots.length === 0) {
3264
- yield* Effect.forEach(nothing(facts, all), (line) => Console.log(line));
3265
- return;
3266
- }
3267
- yield* Console.log(paint.bold(`${repo}#${number}`) + ` ${paint.dim(facts.title)}`);
3268
- yield* Console.log("");
3269
- yield* Effect.forEach(lines$2(view, paint), (line) => Console.log(line));
3270
- }, Effect.catchTag(["ConfigMalformed", ...userFacing], asUserError))).pipe(Command.withDescription("Print the conversation on one pull request, and what is waiting on me in it"));
3527
+ const init = Command.make("init", {
3528
+ effort: effortFlag$1,
3529
+ base: baseFlag
3530
+ }, Effect.fn("init")(function* ({ base, effort }) {
3531
+ yield* requireAuth;
3532
+ const config = yield* ConfigStore;
3533
+ const before = yield* read;
3534
+ const file = Option.getOrElse(before, () => ({}));
3535
+ const defaults = file.defaults === void 0 ? merge$1(builtIn, file.defaults ?? {}) : file.defaults ?? {};
3536
+ const state = yield* stateDirectory;
3537
+ const repo = yield* currentRepo.pipe(Effect.asSome, Effect.catchTag("NoRepository", () => Effect.succeedNone));
3538
+ const overrides = asked(base, effort);
3539
+ const written = Option.isSome(repo) ? withRepo(withDefaults(file, defaults), repo.value, overrides) : withDefaults(file, merge$1(defaults, overrides));
3540
+ if (encode(written) !== encode(file) || Option.isNone(before)) yield* write(written);
3541
+ yield* Console.log(row("review", opening(written.defaults ?? {})));
3542
+ yield* Console.log(row("config", config.path));
3543
+ yield* Console.log(row("state", state));
3544
+ 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"})`));
3545
+ }, Effect.catchTag([
3546
+ "ConfigMalformed",
3547
+ "GhUnauthenticated",
3548
+ "GhUnavailable",
3549
+ "GhUnreadable"
3550
+ ], asUserError))).pipe(Command.withDescription("Set this machine up and register the repository I am in"));
3271
3551
  //#endregion
3272
- //#region src/cli/findings.ts
3273
- /** The findings as the JSON the schema defines, rather than as this file spells it. */
3274
- const asJson$2 = Schema.encodeEffect(Schema.fromJsonString(Findings));
3275
- const jsonFlag = Flag.Boolean("json").pipe(Flag.withDefault(false), Flag.withDescription("Print the findings as the JSON a fix session is handed"));
3276
- /** What a run's findings come to in one line, against the bar that blocks. */
3277
- const summary = (found, blocksOn) => {
3278
- if (found.findings.length === 0) return "clean, nothing to fix";
3279
- const blocked = blocking(found.findings, blocksOn).length;
3280
- return `${count(found.findings.length, "finding")}, ${blocked} blocking`;
3281
- };
3282
- /** Which run these findings are, and what they come to: the line above the list. */
3283
- const header$1 = (run, found, blocksOn) => `${run.repo}#${run.number} ${short(run.head)} ${summary(found, blocksOn)}`;
3552
+ //#region src/adapters/ci.ts
3284
3553
  /**
3285
- * The findings one to a line, in the order the run reported them, ruled so the
3286
- * three columns read apart.
3554
+ * What GitHub says about a pull request's checks, and the evidence a red one
3555
+ * is classified on. Every read here goes through the same `gh` the rest of the
3556
+ * tool does; what it owns is the checks, not the boundary.
3287
3557
  */
3288
- const lines$1 = (found) => table(found.findings.map((finding) => [
3289
- `${finding.file}:${finding.line}`,
3290
- finding.severity,
3291
- finding.summary
3292
- ]), "");
3558
+ const failing = /* @__PURE__ */ new Set([
3559
+ "FAILURE",
3560
+ "TIMED_OUT",
3561
+ "CANCELLED",
3562
+ "STARTUP_FAILURE",
3563
+ "ACTION_REQUIRED",
3564
+ "ERROR"
3565
+ ]);
3566
+ const running = /* @__PURE__ */ new Set([
3567
+ "QUEUED",
3568
+ "IN_PROGRESS",
3569
+ "WAITING",
3570
+ "PENDING",
3571
+ "REQUESTED",
3572
+ "EXPECTED"
3573
+ ]);
3574
+ const nameOf = (entry) => entry.name ?? entry.context ?? "";
3575
+ const checksThatCount = (entries, ignore) => (entries ?? []).filter((entry) => !ignore.includes(nameOf(entry)));
3576
+ const hasFailed = (entry) => failing.has(entry.conclusion ?? "") || failing.has(entry.state ?? "");
3293
3577
  /**
3294
- * The review run whose findings are the current ones, or the sentence saying
3295
- * there are none.
3578
+ * What the rollup comes to: red when anything failed, pending only while
3579
+ * nothing has failed yet, green when every check that counts has passed.
3296
3580
  *
3297
- * The last run on the pull request is what "current" means here, and it is read
3298
- * off the state directory rather than worked out from GitHub: this command is
3299
- * one I run inside a fix session, where another round trip to GitHub buys
3300
- * nothing the run it is about to fix does not already say.
3581
+ * `ci.ignore` names the checks that do not count towards green, so a check I
3582
+ * have decided to live with cannot hold a PR out of Ready.
3301
3583
  */
3302
- const currentRun = Effect.fn("findings.currentRun")(function* (repo, number) {
3303
- const run = yield* lastRun(repo, number);
3304
- return Option.isSome(run) ? run.value : yield* asUserError(`No review run on ${repo}#${number}. Run dw-mc review ${number} first.`);
3305
- });
3584
+ const rollupState = (entries, ignore) => {
3585
+ const checks = checksThatCount(entries, ignore);
3586
+ if (checks.length === 0) return "none";
3587
+ if (checks.some(hasFailed)) return "red";
3588
+ if (checks.some((entry) => entry.status !== void 0 && entry.status !== "COMPLETED" || running.has(entry.state ?? ""))) return "pending";
3589
+ return "green";
3590
+ };
3306
3591
  /**
3307
- * What the run reported, or the sentence saying it reported nothing at all.
3592
+ * The checks that failed and count, which are the ones there is a log to read.
3308
3593
  *
3309
- * A run that failed is not a clean one: a pipe must never be handed "no
3310
- * findings" when what happened is that nothing could be read.
3594
+ * `ci.ignore` is applied here as well as in the rollup: a check that cannot
3595
+ * hold a PR out of Ready is not one the classifier should be explaining either.
3311
3596
  */
3312
- const whatItFound = (run) => {
3313
- const found = reportedBy(run);
3314
- return found === null ? Effect.fail(new CliError.UserError({ cause: `The review run on ${short(run.head)} reported no findings: ${run.outcome._tag === "failed" ? run.outcome.detail : ""}\nRun dw-mc review ${run.number} --force to run it again.` })) : Effect.succeed(found);
3315
- };
3597
+ const failedChecks = (entries, ignore) => checksThatCount(entries, ignore).filter(hasFailed);
3316
3598
  /**
3317
- * What the current review run found, as a table or as the JSON it is kept in.
3599
+ * What a check reports on, out of the URL it reports at.
3318
3600
  *
3319
- * `--json` is the whole point of the command: it prints the findings and
3320
- * nothing else, so I can pipe them anywhere, and a fix session inside an open
3321
- * agent reads exactly what the tool recorded rather than a retelling of it.
3601
+ * A check run details URL ends `/actions/runs/<run>/job/<job>`. The job id is
3602
+ * what the logs endpoint takes and the run id is what `gh run rerun` takes, so
3603
+ * the two ids the tool needs are the two halves of one URL and are read
3604
+ * together. A commit status points somewhere else entirely, which is null:
3605
+ * there is no log of ours to read and no run of ours to re-run.
3606
+ */
3607
+ const reportedAt = (detailsUrl) => {
3608
+ const found = detailsUrl?.match(/\/actions\/runs\/(\d+)\/job\/(\d+)/);
3609
+ return found?.[1] === void 0 || found[2] === void 0 ? null : {
3610
+ run: found[1],
3611
+ job: found[2]
3612
+ };
3613
+ };
3614
+ const RepoDefaultBranch = Schema.fromJsonString(Schema.Struct({ defaultBranchRef: Schema.NullOr(Schema.Struct({ name: Schema.String })) }));
3615
+ /**
3616
+ * The branch a repository merges into, which is the one the first flaky signal
3617
+ * asks about. An empty repository has none, and `main` is the better guess than
3618
+ * failing the sweep over it.
3619
+ */
3620
+ const defaultBranch = Effect.fnUntraced(function* (repo) {
3621
+ return (yield* readJson("repo view defaultBranchRef", "gh", [
3622
+ "repo",
3623
+ "view",
3624
+ repo,
3625
+ "--json",
3626
+ "defaultBranchRef"
3627
+ ], RepoDefaultBranch)).defaultBranchRef?.name ?? "main";
3628
+ });
3629
+ const Runs = Schema.fromJsonString(Schema.Array(Schema.Struct({ conclusion: Schema.String })));
3630
+ /** How far back to look for a run that reached a verdict at all. */
3631
+ const recentRuns = 5;
3632
+ /** `gh run list` reports a conclusion in lower case, unlike every check on a PR. */
3633
+ const failedRun = /* @__PURE__ */ new Set(["failure", "timed_out"]);
3634
+ /** A run that decided something. A skipped or cancelled run says nothing either way. */
3635
+ const verdicts = /* @__PURE__ */ new Set([
3636
+ "failure",
3637
+ "timed_out",
3638
+ "success"
3639
+ ]);
3640
+ /**
3641
+ * Whether `workflow` is red on `branch` right now.
3322
3642
  *
3323
- * A run that failed prints no findings and fails: a review run that could not
3324
- * report has found nothing, which is not the same as having found nothing
3325
- * wrong, and a pipe must never be handed the second when the first is true.
3643
+ * The newest run that reached a verdict is the whole answer: a workflow that
3644
+ * broke last week and was fixed since is not red, and excusing a pull request
3645
+ * for it would hide a failure that is real. A handful of runs are asked for
3646
+ * because the newest ones are often skipped by a path filter.
3326
3647
  */
3327
- const findings = Command.make("findings", {
3328
- pr: prArgument,
3329
- json: jsonFlag
3330
- }, Effect.fn("findings")(function* ({ json, pr }) {
3331
- const file = Option.getOrElse(yield* read, () => ({}));
3332
- const { number, repo } = yield* named(pr, Object.keys(file.repos ?? {}).toSorted());
3333
- const settings = settingsFor(file, repo);
3334
- const run = yield* currentRun(repo, number);
3335
- const found = yield* whatItFound(run);
3336
- if (json) {
3337
- yield* Console.log(yield* asJson$2(found));
3338
- return;
3339
- }
3340
- yield* Console.log(header$1(run, found, settings.stamp.blocks_on));
3341
- for (const line of lines$1(found)) yield* Console.log(` ${line}`);
3342
- }, Effect.catchTag(["ConfigMalformed"], asUserError))).pipe(Command.withDescription("Print what the current review run found on one pull request"));
3343
- //#endregion
3344
- //#region src/adapters/agent.ts
3648
+ const workflowFailsOn = Effect.fnUntraced(function* (repo, branch, workflow) {
3649
+ const newest = (yield* readJson("run list", "gh", [
3650
+ "run",
3651
+ "list",
3652
+ "--repo",
3653
+ repo,
3654
+ "--branch",
3655
+ branch,
3656
+ "--workflow",
3657
+ workflow,
3658
+ "--limit",
3659
+ String(recentRuns),
3660
+ "--json",
3661
+ "conclusion"
3662
+ ], Runs)).find((run) => verdicts.has(run.conclusion));
3663
+ return newest !== void 0 && failedRun.has(newest.conclusion);
3664
+ });
3665
+ const PrFiles = Schema.fromJsonString(Schema.Struct({ files: Schema.Array(Schema.Struct({ path: Schema.String })) }));
3666
+ /** The repository paths a pull request changes. */
3667
+ const prFiles = Effect.fnUntraced(function* (repo, number) {
3668
+ return (yield* readJson("pr view files", "gh", [
3669
+ "pr",
3670
+ "view",
3671
+ String(number),
3672
+ "--repo",
3673
+ repo,
3674
+ "--json",
3675
+ "files"
3676
+ ], PrFiles)).files.map((file) => file.path);
3677
+ });
3345
3678
  /**
3346
- * How one turn of Claude Code is spawned and given up on, and what a turn that
3347
- * answers against a schema comes back with.
3679
+ * How much of a failing job's log is kept.
3348
3680
  *
3349
- * Every turn is reached this way, so the spawn, the patience and the one failure
3350
- * they can end in live here rather than once per turn.
3681
+ * A job that failed prints what went wrong at the end, so the tail is the part
3682
+ * worth classifying, and a build that logged a whole dependency tree is not
3683
+ * worth holding in memory beyond it.
3351
3684
  */
3352
- /** A review run that would not start, would not finish, or finished badly. */
3353
- var AgentFailed = class extends Schema.TaggedError()("AgentFailed", {
3354
- /** The program that was spawned, which is what a search for it has to name. */
3355
- program: Schema.String,
3356
- detail: Schema.String
3357
- }) {
3358
- get message() {
3359
- return `The ${this.program} review run failed: ${this.detail}`;
3360
- }
3361
- };
3685
+ const logTailBytes = 65536;
3362
3686
  /**
3363
- * Failures in the name of the program that was spawned.
3687
+ * What one failing job printed, from the end.
3364
3688
  *
3365
- * Where the launcher starts `claude` through another program, it is that
3366
- * program that would not start or exited badly, and saying `claude` sends the
3367
- * search to the wrong process.
3689
+ * `gh api` refuses a response carrying terminal escape sequences unless it is
3690
+ * told otherwise, and a runner log is full of them. Verified by running it: the
3691
+ * endpoint answers with the plain log once the flag is passed.
3368
3692
  */
3369
- const failedBy = (program) => (detail) => new AgentFailed({
3370
- program,
3371
- detail
3693
+ const jobLog = Effect.fnUntraced(function* (repo, jobId) {
3694
+ const log = yield* capture("gh", [
3695
+ "api",
3696
+ `repos/${repo}/actions/jobs/${jobId}/logs`,
3697
+ "--allow-escape-sequences"
3698
+ ]).pipe(Effect.catchTags({
3699
+ PlatformError: (error) => Effect.fail(unavailable(error)),
3700
+ CommandFailed: (error) => Effect.fail(new GhReadFailed({
3701
+ command: "api job logs",
3702
+ detail: error.stderr
3703
+ }))
3704
+ }));
3705
+ return log.length <= logTailBytes ? log : log.slice(-65536);
3372
3706
  });
3373
3707
  /**
3374
- * How long each turn gets before it is given up on.
3708
+ * The workflow runs behind the failing checks that count, each named once.
3375
3709
  *
3376
- * The review is the turn that thinks, and a high-effort one that fans out to
3377
- * subagents takes real minutes, so its limit is there to catch a run that has
3378
- * stopped rather than one that is slow. The second turn reads no code and
3379
- * decides nothing - the review it reports on is already in the session it
3380
- * resumes - and every run of it by hand came back in seconds.
3710
+ * One broken run usually fails several jobs, and re-running it once per failing
3711
+ * job would start the same run over and over.
3381
3712
  *
3382
- * Either way, a command that hangs forever is worse than one that says it
3383
- * failed: a review I walked away from is one I need to be able to come back to.
3713
+ * `ci.ignore` decides which checks get a run into this list, and no more than
3714
+ * that: a run is re-run whole, so an ignored job sharing a run with a counted
3715
+ * one is re-run beside it. What the setting buys is that an ignored check is
3716
+ * never on its own a reason to spend CI minutes.
3384
3717
  */
3385
- const patience = {
3386
- reviewing: Duration.minutes(45),
3387
- reporting: Duration.minutes(5)
3388
- };
3718
+ const failedRuns = (entries, ignore) => [...new Set(failedChecks(entries, ignore).flatMap((check) => {
3719
+ const reported = reportedAt(check.detailsUrl);
3720
+ return reported === null ? [] : [reported.run];
3721
+ }))];
3389
3722
  /**
3390
- * One turn of the launcher in `directory`, with `read` over its standard output.
3723
+ * Asks GitHub to run one workflow run's failed jobs again.
3391
3724
  *
3392
- * The launcher's own arguments go in front of the turn's, because they are what
3393
- * gets `claude` started at all. The two output streams are drained together,
3394
- * because draining one to the end first can block a run that is still writing to
3395
- * the other. Every way a turn can fail to finish comes back from here as a
3396
- * `AgentFailed`, so a caller is left with the turn's own answer and nothing else
3397
- * to translate - a turn that never comes back included.
3725
+ * `--failed` is what makes this cheap: the jobs that passed are not run a
3726
+ * second time, so a flaky job costs the minutes it costs and no more. This is a
3727
+ * write to GitHub, and it is one of the three ADR 0002 allows.
3398
3728
  */
3399
- const turn = Effect.fnUntraced(function* (options) {
3400
- const [program, ...prefix] = options.command;
3401
- const failed = failedBy(program);
3402
- const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
3403
- const running = Effect.gen(function* () {
3404
- const handle = yield* Effect.mapError(spawner.spawn(ChildProcess.make(program, [...prefix, ...options.args], {
3405
- cwd: options.directory,
3406
- stdin: "pipe"
3407
- })), (error) => failed(error.message));
3408
- const [got, stderr] = yield* Effect.mapError(Effect.all([options.read(handle.stdout), Stream.mkString(Stream.decodeText(handle.stderr))], { concurrency: 2 }), (error) => failed(error.message));
3409
- const exitCode = yield* Effect.mapError(handle.exitCode, (error) => failed(error.message));
3410
- if (exitCode !== 0) return yield* failed(stderr.trim() === "" ? `${program} exited ${exitCode}` : stderr.trim());
3411
- return got;
3412
- });
3413
- return yield* Effect.timeoutOrElse(running, {
3414
- duration: options.patience.duration,
3415
- orElse: () => failed(`${options.patience.turn} did not come back within ${Duration.format(options.patience.duration)}`)
3416
- });
3729
+ const rerunFailed = Effect.fnUntraced(function* (repo, runId) {
3730
+ yield* capture("gh", [
3731
+ "run",
3732
+ "rerun",
3733
+ runId,
3734
+ "--repo",
3735
+ repo,
3736
+ "--failed"
3737
+ ]).pipe(Effect.catchTags({
3738
+ PlatformError: (error) => Effect.fail(unavailable(error)),
3739
+ CommandFailed: (error) => Effect.fail(new GhReadFailed({
3740
+ command: "run rerun",
3741
+ detail: error.stderr
3742
+ }))
3743
+ }));
3417
3744
  });
3418
3745
  //#endregion
3419
- //#region src/adapters/claude.ts
3420
- /**
3421
- * Claude Code: a review on a slash command, a review on the tool's own prompt,
3422
- * and the sessions I steer.
3423
- */
3746
+ //#region src/domain/stamp.ts
3424
3747
  /**
3425
- * The two events of a stream-json run this reads, as the runner really writes
3426
- * them. Every other field of both, and every other event, is ignored: a
3427
- * transcript carries hooks, rate limits, thinking and tool results, and a
3428
- * version that adds another must not stop a run from being read.
3748
+ * A stamp I took off a pull request by hand, and the head I took it off at.
3749
+ *
3750
+ * The head is the whole record: a withdrawal is my overruling the computation
3751
+ * on code I have read, so it lasts exactly as long as that code is what the
3752
+ * pull request is.
3429
3753
  */
3430
- const Working = Schema.Struct({
3431
- type: Schema.Literal("assistant"),
3432
- message: Schema.Struct({ content: Schema.Array(Schema.Struct({
3433
- type: Schema.String,
3434
- name: Schema.optionalKey(Schema.String),
3435
- text: Schema.optionalKey(Schema.String)
3436
- })) })
3437
- });
3438
- const Ended = Schema.Struct({
3439
- type: Schema.Literal("result"),
3440
- subtype: Schema.String,
3441
- is_error: Schema.Boolean,
3442
- session_id: Schema.String,
3443
- result: Schema.optionalKey(Schema.String),
3444
- /** What a turn given a JSON schema validated, which this hands on unread. */
3445
- structured_output: Schema.optionalKey(Schema.Unknown)
3754
+ const Withdrawal = Schema.Struct({ head: Schema.String });
3755
+ /** The stamp a pull request has not earned, and the first reason it has not. */
3756
+ const withheld = (reason) => ({
3757
+ stamped: false,
3758
+ reason
3446
3759
  });
3447
- const asWorking = Schema.decodeUnknownOption(Schema.fromJsonString(Working));
3448
- const asResult = Schema.decodeUnknownOption(Schema.fromJsonString(Ended));
3449
- const heardIn = (line) => {
3450
- const blocks = Option.match(asWorking(line), {
3451
- onNone: () => [],
3452
- onSome: (event) => event.message.content
3453
- });
3454
- return {
3455
- tools: blocks.flatMap((block) => block.type === "tool_use" && block.name !== void 0 ? [block.name] : []),
3456
- said: blocks.flatMap((block) => block.type === "text" && block.text !== void 0 ? [block.text] : [])
3457
- };
3760
+ /** What CI has to say before the stamp will rest on it, which is green and nothing else. */
3761
+ const whyNotGreen = {
3762
+ green: null,
3763
+ red: "CI is red",
3764
+ pending: "CI is still running",
3765
+ none: "no CI ran on this head"
3458
3766
  };
3459
- /**
3460
- * The result a turn ended on, or the failure it really was.
3461
- *
3462
- * A turn that said nothing this can read and a turn Claude Code itself calls an
3463
- * error are both failures: `subtype` is where a run that hit its turn limit or
3464
- * lost its connection says so, and its `result` is the only word on why.
3465
- */
3466
- const ended = (program, result) => {
3467
- const failed = failedBy(program);
3468
- if (Option.isNone(result)) return Effect.fail(failed("the turn came back with no result"));
3469
- const { is_error, result: lastWord, subtype } = result.value;
3470
- return is_error || subtype !== "success" ? Effect.fail(failed(`${subtype}: ${lastWord ?? "nothing else was said"}`)) : Effect.succeed(result.value);
3767
+ /** What GitHub has to say about merging, which is that it would. */
3768
+ const whyNotMergeable = {
3769
+ mergeable: null,
3770
+ conflicting: "merge conflict",
3771
+ unknown: "GitHub has not said whether it merges"
3471
3772
  };
3472
3773
  /**
3473
- * A Claude Code `stream-json` turn, read as it arrives: what it reached for goes
3474
- * to `onTool` while the run is still going, and what it said and how it ended
3475
- * are what comes back.
3476
- *
3477
- * Both shapes of review read a turn the same way, so the fold is here rather
3478
- * than once per shape.
3479
- */
3480
- const transcript = (onTool) => (stdout) => stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.mapEffect((line) => {
3481
- const heard = heardIn(line);
3482
- return Effect.as(Effect.forEach(heard.tools, onTool, { discard: true }), {
3483
- line,
3484
- heard
3485
- });
3486
- }), Stream.runFold(() => ({
3487
- said: [],
3488
- result: Option.none()
3489
- }), (soFar, { heard, line }) => ({
3490
- said: [...soFar.said, ...heard.said],
3491
- result: Option.orElse(asResult(line), () => soFar.result)
3492
- })));
3493
- /**
3494
- * One review run on a slash command, headless, in `directory`.
3774
+ * The stamp of one tracked PR: whether it has passed my bar, and why.
3495
3775
  *
3496
- * The run is in the foreground and says what it is doing as it does it, which
3497
- * is what `onTool` is for: a review takes minutes, and a terminal that prints
3498
- * nothing for minutes is one I stop trusting.
3776
+ * The mark is computed rather than clicked, so it means the same thing every
3777
+ * time: a review run on this head that found nothing blocking, CI green as the
3778
+ * repository's `ci.ignore` defines green, and a pull request GitHub would
3779
+ * merge. A red CI the flaky classifier excused is still not green here: an
3780
+ * excuse is a reason not to fix a check, not a reason to land code behind one,
3781
+ * and this mark is what clears `dw-mc merge` (ADR 0008).
3499
3782
  *
3500
- * `--json-schema` is never passed here: verified by running it, the flag beside
3501
- * `/code-review` breaks the run, which is why a slash command costs a second
3502
- * turn that resumes the session and asks for the findings. My own instructions
3503
- * ride on `--append-system-prompt` rather than on the command's own line,
3504
- * because what a slash command does with its arguments is its business and not
3505
- * this tool's.
3783
+ * Nothing about this rests on a previous stamp, which is what makes a head
3784
+ * change clear it: facts are about one head, and a run is recorded against one.
3506
3785
  *
3507
- * `--comment` is the flag that makes the built-in review post on the pull
3508
- * request, and it is never passed either (ADR 0002). The report is everything
3509
- * the run said on its own turns rather than the `result` alone: verified by
3510
- * running it, a repository whose review command fans out to subagents can end on
3511
- * a remark about them, and the report is the turn before that.
3786
+ * A withdrawal comes first, because it is the one thing here I decided rather
3787
+ * than computed.
3512
3788
  */
3513
- const commandReview = Effect.fn("claude.commandReview")(function* (options) {
3514
- const [program] = options.launcher.command;
3515
- const run = yield* turn({
3516
- command: options.launcher.command,
3517
- directory: options.directory,
3518
- args: [
3519
- "-p",
3520
- options.line,
3521
- "--output-format",
3522
- "stream-json",
3523
- "--verbose",
3524
- ...options.instructions === null ? [] : ["--append-system-prompt", options.instructions],
3525
- ...options.model === null ? [] : ["--model", options.model]
3526
- ],
3527
- patience: {
3528
- turn: "the review",
3529
- duration: patience.reviewing
3530
- },
3531
- read: transcript(options.onTool)
3532
- });
3533
- const { result: lastWord, session_id } = yield* ended(program, run.result);
3534
- const report = (run.said.length === 0 ? lastWord ?? "" : run.said.join("\n\n")).trim();
3535
- if (report === "") return yield* failedBy(program)("the run came back with an empty report");
3789
+ const stampFor = (facts, withdrawnAt) => {
3790
+ if (withdrawnAt === facts.head) return withheld("withdrawn by hand");
3791
+ if (facts.reviewRunHead !== facts.head) return withheld("no review run on this head");
3792
+ if (facts.blockingFindings > 0) return withheld(blockedBy(facts.blockingFindings));
3793
+ const ci = whyNotGreen[facts.checks];
3794
+ if (ci !== null) return withheld(ci);
3795
+ const merge = whyNotMergeable[facts.mergeable];
3796
+ if (merge !== null) return withheld(merge);
3536
3797
  return {
3537
- report,
3538
- sessionId: session_id
3798
+ stamped: true,
3799
+ reason: "a clean review run on this head, green CI, mergeable"
3539
3800
  };
3540
- }, Effect.scoped);
3801
+ };
3541
3802
  /**
3542
- * What the second turn asks for.
3803
+ * The head a stamp was withdrawn at, or null where none was.
3543
3804
  *
3544
- * It asks for a report of what was already said rather than for another look:
3545
- * the prose is the review, and this turn is only what makes it machine
3546
- * readable. The shape it must answer in arrives as a JSON schema beside it, so
3547
- * the prompt does not describe the schema twice.
3805
+ * Forgetting a withdrawal hands the pull request back to the computation, which
3806
+ * is what every other input to a stamp already is.
3807
+ */
3808
+ const withdrawnAt = Effect.fn("stamp.withdrawnAt")(function* (repo, number) {
3809
+ const store = yield* storeFor("stamps", Withdrawal);
3810
+ const withdrawal = yield* remembered(store.get(prKey(repo, number)));
3811
+ return Option.match(withdrawal, {
3812
+ onNone: () => null,
3813
+ onSome: (it) => it.head
3814
+ });
3815
+ });
3816
+ /** Takes the stamp off a pull request at `head`, which is the only head it stays off. */
3817
+ const withdraw = Effect.fn("stamp.withdraw")(function* (repo, number, head) {
3818
+ yield* (yield* storeFor("stamps", Withdrawal)).set(prKey(repo, number), { head });
3819
+ });
3820
+ /** The stamp of one tracked PR, with the withdrawal this machine holds against it. */
3821
+ const stampOf = Effect.fn("stamp.stampOf")(function* (facts) {
3822
+ return stampFor(facts, yield* withdrawnAt(facts.repo, facts.number));
3823
+ });
3824
+ /**
3825
+ * Which of these tracked PRs carry a stamp, keyed the way their facts are.
3826
+ *
3827
+ * A table asks the question of every row at once, and the withdrawals are the
3828
+ * only thing here that has to be read off the disk.
3548
3829
  */
3549
- const reportFindings = [
3550
- "Report the findings of the review you just gave as structured output.",
3551
- "Every finding carries the file it is in as a repository path, the line it is at,",
3552
- "its severity and a one-sentence summary.",
3553
- "The verdict is clean when there is nothing to report and findings otherwise.",
3554
- "Report nothing you did not already say."
3555
- ].join(" ");
3830
+ const stampedAmong = Effect.fn("stamp.stampedAmong")(function* (facts) {
3831
+ const marks = yield* Effect.forEach(facts, (it) => Effect.map(stampOf(it), (stamp) => ({
3832
+ key: prKey(it.repo, it.number),
3833
+ stamped: stamp.stamped
3834
+ })));
3835
+ return new Set(marks.filter((mark) => mark.stamped).map((mark) => mark.key));
3836
+ });
3837
+ //#endregion
3838
+ //#region src/domain/merge.ts
3556
3839
  /**
3557
- * The second turn of a review run: the prose the first one wrote, back as
3558
- * findings that validate.
3840
+ * Why GitHub would not call this pull request Ready, or null where it would.
3559
3841
  *
3560
- * It resumes the first turn's session rather than reading the diff again, which
3561
- * is what makes it cheap and what makes it accurate - verified by running it,
3562
- * the line numbers it reports beat the ones the prose gives. The output is
3563
- * handed on as it arrived: what the findings must look like belongs to the
3564
- * domain, and the schema the run is held to comes in from there too.
3842
+ * Ready is GitHub's opinion and nothing of mine: approved, green, mergeable.
3843
+ * A repository that requires no reviewer produces no approval, which is why
3844
+ * `none` passes and `review-required` does not - what holds a merge is somebody
3845
+ * having been asked and not yet answered.
3565
3846
  *
3566
- * Every way this can end badly ends as an `AgentFailed`, because a review run
3567
- * that could not report is a failure and never a clean verdict.
3847
+ * A red CI the flaky classifier excused is still red here. The excuse is a
3848
+ * reason not to fix a check; it is not a reason to land code behind one.
3568
3849
  */
3569
- const findingsTurn = Effect.fn("claude.findingsTurn")(function* (options) {
3570
- const [program] = options.launcher.command;
3571
- const printed = yield* turn({
3572
- command: options.launcher.command,
3573
- directory: options.directory,
3574
- patience: {
3575
- turn: "the findings turn",
3576
- duration: patience.reporting
3577
- },
3578
- args: [
3579
- "-p",
3580
- "--resume",
3581
- options.sessionId,
3582
- reportFindings,
3583
- "--output-format",
3584
- "json",
3585
- "--json-schema",
3586
- options.jsonSchema
3587
- ],
3588
- read: (stdout) => Stream.mkString(Stream.decodeText(stdout))
3589
- });
3590
- const { structured_output } = yield* ended(program, asResult(printed.trim()));
3591
- if (structured_output === void 0) return yield* failedBy(program)("the findings turn came back with no structured output");
3592
- return structured_output;
3593
- }, Effect.scoped);
3850
+ const whyNotReady = (situation) => {
3851
+ if (situation.reviewDecision === "changes-requested") return "changes are requested";
3852
+ if (situation.reviewDecision === "review-required") return "a review from someone else is still wanted";
3853
+ return whyNotGreen[situation.checks] ?? whyNotMergeable[situation.mergeable];
3854
+ };
3594
3855
  /**
3595
- * One review run of the tool's own review prompt, in `directory`.
3856
+ * What to do about a pull request that is Ready and carries no stamp.
3596
3857
  *
3597
- * It is one turn rather than two: verified by running it, `--json-schema` beside
3598
- * an ordinary prompt gives both the prose the run wrote and the
3599
- * `structured_output` it validated, where the same flag on a slash command
3600
- * breaks the run. The schema arrives as inline JSON and never as a path - a path
3601
- * is where Claude Code reports `--json-schema is not valid JSON`.
3858
+ * The stamp is withheld for one of three reasons and each has its own next
3859
+ * step, so the refusal names that step rather than leaving me to work out which
3860
+ * of the three it was. A withdrawal is the one with no command: I took the mark
3861
+ * off code I had read, and only that code changing puts it back.
3602
3862
  */
3603
- const promptReview = Effect.fn("claude.promptReview")(function* (options) {
3604
- const [program] = options.launcher.command;
3605
- const run = yield* turn({
3606
- command: options.launcher.command,
3607
- directory: options.directory,
3608
- patience: {
3609
- turn: "the review",
3610
- duration: patience.reviewing
3611
- },
3612
- args: [
3613
- "-p",
3614
- options.prompt,
3615
- "--output-format",
3616
- "stream-json",
3617
- "--verbose",
3618
- "--json-schema",
3619
- options.jsonSchema,
3620
- ...options.model === null ? [] : ["--model", options.model]
3621
- ],
3622
- read: transcript(options.onTool)
3623
- });
3624
- const { session_id, structured_output } = yield* ended(program, run.result);
3625
- if (structured_output === void 0) return yield* failedBy(program)("the review came back with no structured output");
3626
- const prose = run.said.join("\n\n").trim();
3627
- return {
3628
- findings: structured_output,
3629
- sessionId: session_id,
3630
- prose: prose === "" ? null : prose
3863
+ const earnsIt = (situation) => {
3864
+ if (situation.withdrawnAt === situation.head) return "\n\nYou took it off at this head, and it stays off until the head changes.";
3865
+ const next = situation.reviewRunHead !== situation.head ? {
3866
+ command: `dw-mc review ${situation.number}`,
3867
+ says: "That reviews this head, and a run that finds nothing blocking stamps it."
3868
+ } : {
3869
+ command: `dw-mc fix ${situation.number}`,
3870
+ says: "That opens a session on the findings. The stamp is back once the head has moved and a review run has read it."
3631
3871
  };
3632
- }, Effect.scoped);
3872
+ return `\n\n ${next.command}\n\n${next.says}`;
3873
+ };
3633
3874
  /**
3634
- * One review run, in whichever shape it was configured in.
3875
+ * Why this pull request is not one to merge, or null where it is.
3635
3876
  *
3636
- * A slash command takes two turns and the tool's own prompt takes one, which is
3637
- * Claude Code's doing and nobody else's: a caller hands over the turn and gets
3638
- * the same answer back either way.
3877
+ * This is the single place the merge guards live, and they carry more than the
3878
+ * merge does: it is the one write the tool makes that no reflog of mine undoes
3879
+ * (ADR 0008). Two bars have to be clear, because each is blind to what the
3880
+ * other sees - GitHub does not know whether anything read the diff, and the
3881
+ * stamp does not know whether a reviewer asked for changes.
3639
3882
  *
3640
- * The second turn's failure is kept beside the first turn's prose rather than
3641
- * replacing it. A review that ran and could not report is still worth reading,
3642
- * and it is recorded as the failure it is.
3883
+ * Whose pull request it is comes first, as it does everywhere else: one
3884
+ * somebody else opened is none of this tool's business, whatever is true of it.
3885
+ * A draft is next, because a pull request I have not offered to anybody is not
3886
+ * one to land however green it is.
3887
+ *
3888
+ * Ready is asked before the stamp so that the refusal names the bar I am
3889
+ * actually under. The stamp insists on green CI and a mergeable pull request
3890
+ * too, so everything it can be withheld for here is mine rather than GitHub's.
3643
3891
  */
3644
- const reviewTurns = Effect.fn("claude.reviewTurns")(function* (options) {
3645
- const { directory, jsonSchema, launcher, model, onTool } = options;
3646
- if (options.turn._tag === "prompt") {
3647
- const run = yield* promptReview({
3648
- launcher,
3649
- directory,
3650
- prompt: options.turn.text,
3651
- model,
3652
- jsonSchema,
3653
- onTool
3654
- });
3655
- return {
3656
- sessionId: run.sessionId,
3657
- prose: run.prose,
3658
- findings: Result.succeed(run.findings)
3659
- };
3660
- }
3661
- const run = yield* commandReview({
3662
- launcher,
3663
- directory,
3664
- line: options.turn.line,
3665
- instructions: options.turn.instructions,
3666
- model,
3667
- onTool
3668
- });
3669
- const findings = yield* Effect.result(findingsTurn({
3670
- launcher,
3671
- directory,
3672
- sessionId: run.sessionId,
3673
- jsonSchema
3674
- }));
3675
- return {
3676
- sessionId: run.sessionId,
3677
- prose: run.report,
3678
- findings
3679
- };
3680
- });
3892
+ const decide$3 = (situation) => {
3893
+ const where = `${situation.repo}#${situation.number}`;
3894
+ if (!situation.mine) return `${where} is not mine. dw-mc merges pull requests I author and nothing else.`;
3895
+ if (situation.draft) return `${where} is a draft. Mark it ready for review before merging it.`;
3896
+ const ready = whyNotReady(situation);
3897
+ if (ready !== null) return `${where} is not Ready: ${ready}. dw-mc merges nothing GitHub would not merge itself.`;
3898
+ const stamp = stampFor(situation, situation.withdrawnAt);
3899
+ return stamp.stamped ? null : `${where} is Ready and carries no stamp: ${stamp.reason}.${earnsIt(situation)}`;
3900
+ };
3901
+ //#endregion
3902
+ //#region src/cli/merge.ts
3681
3903
  /**
3682
- * An interactive `claude` in `directory`, opened on `prompt`, with my terminal
3683
- * handed straight to it.
3904
+ * Lands one pull request of mine: squashed, with its branch deleted.
3684
3905
  *
3685
- * The launcher's `fix_args` go here and nowhere else: they are the flags of
3686
- * every session I steer - the one on findings and the one on a conflict - which
3687
- * no headless review turn wants. They sit in front of the
3688
- * prompt, because `claude` takes its flags before its positional argument.
3906
+ * This is the write ADR 0008 is about, and the only one the tool makes that no
3907
+ * reflog of mine brings back. It is outside ADR 0002's three because it moves a
3908
+ * shared branch; everything 0002 bars - comment, reply, thread resolve, label,
3909
+ * review, approval, status - still holds here as it does everywhere.
3689
3910
  *
3690
- * This is the one place a run is not read: the three streams are inherited,
3691
- * so what is on the screen is the session itself and not a transcript of it,
3692
- * and what I type reaches it. The child is not detached for the same reason -
3693
- * a detached child sits outside the terminal's foreground process group, where
3694
- * neither my keystrokes nor Ctrl-C would reach it.
3911
+ * The threshold is two bars at one head: Ready, which is GitHub's opinion, and
3912
+ * my stamp, which is mine. Each is blind to what the other sees, so the write
3913
+ * that cannot be undone clears both.
3695
3914
  *
3696
- * There is no patience here either. A session I steer lasts as long as I am in
3697
- * it, and a timeout would be the tool closing a session I was still working in.
3915
+ * GitHub's half is read live from a fresh `pr view` rather than off the last
3916
+ * sweep, the way the rebase and re-run guards are. A stale verdict costs a
3917
+ * re-run some CI minutes; here it costs merging code nobody read. My half comes
3918
+ * from the state directory, because the review runs and the withdrawal live
3919
+ * there and are already scoped to the head this read just named.
3698
3920
  *
3699
- * What comes back is the code the session ended on. A session I left with
3700
- * Ctrl-C ended badly for `claude` and not for me, so this reports it rather
3701
- * than failing on it; only a `claude` that would not start at all is a failure.
3921
+ * Typing the command is the confirmation, so it takes no flag. The picker,
3922
+ * where a keystroke is cheaper, asks before it dispatches.
3702
3923
  */
3703
- const steeredSession = Effect.fn("claude.steeredSession")(function* (options) {
3704
- const [program, ...prefix] = options.launcher.command;
3705
- const failed = failedBy(program);
3706
- const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
3707
- const handle = yield* Effect.mapError(spawner.spawn(ChildProcess.make(program, [
3708
- ...prefix,
3709
- ...options.launcher.fix_args,
3710
- options.prompt
3711
- ], {
3712
- cwd: options.directory,
3713
- stdin: "inherit",
3714
- stdout: "inherit",
3715
- stderr: "inherit",
3716
- detached: false
3717
- })), (error) => failed(error.message));
3718
- return yield* Effect.mapError(handle.exitCode, (error) => failed(error.message));
3719
- }, Effect.scoped);
3924
+ const merge = Command.make("merge", { pr: prArgument }, Effect.fn("merge")(function* ({ pr }) {
3925
+ const { number, repo, settings } = yield* forPr(pr);
3926
+ const [view, me] = yield* reading(`${repo}#${number}`, Effect.all([prView(repo, number), viewer]));
3927
+ const head = view.headRefOid;
3928
+ yield* refuse(decide$3({
3929
+ repo,
3930
+ number,
3931
+ head,
3932
+ mine: view.author?.login === me,
3933
+ draft: view.isDraft,
3934
+ reviewDecision: reviewDecisionOf(view.reviewDecision),
3935
+ checks: rollupState(view.statusCheckRollup, settings.ci.ignore),
3936
+ mergeable: mergeabilityOf(view.mergeable),
3937
+ ...yield* reviewedAt(repo, number, head, settings.stamp.blocks_on),
3938
+ withdrawnAt: yield* withdrawnAt(repo, number)
3939
+ }));
3940
+ yield* mergePr(repo, number);
3941
+ yield* Console.log(`${repo}#${number} ${short(head)} squash-merged into ${view.baseRefName}, and ${view.headRefName} deleted`);
3942
+ yield* Console.log(`The squash subject is the pull request title: ${view.title}`);
3943
+ }, Effect.catchTag(userFacing, asUserError))).pipe(Command.withDescription("Squash-merge a Ready, stamped pull request of mine and delete its branch"));
3720
3944
  //#endregion
3721
- //#region src/domain/fix.ts
3722
- /** One finding I chose to act on, carrying what I think about it. */
3723
- const Chosen = Schema.Struct({
3724
- ...Finding.fields,
3725
- note: Schema.optionalKey(Schema.String)
3726
- });
3945
+ //#region src/domain/flaky.ts
3727
3946
  /**
3728
- * What a fix session is handed: the findings I picked, and the review run they
3729
- * came from.
3947
+ * The failures that are flaky wherever they appear: a machine, a network or a
3948
+ * runner giving up, never a test disagreeing with the code.
3730
3949
  *
3731
- * The head is in it because a fix session opens on the commit that was
3732
- * reviewed, and a finding's line means nothing away from it.
3950
+ * `ci.flaky_patterns` adds to this list rather than replacing it, because the
3951
+ * failures a repository of mine produces are extra ones, not different ones.
3733
3952
  */
3734
- const Selection = Schema.Struct({
3735
- repo: Schema.String,
3736
- number: Schema.Int,
3737
- head: Schema.String,
3738
- findings: Schema.Array(Chosen)
3739
- });
3740
- /** The selection as the JSON the schema defines, rather than as this file spells it. */
3741
- const asJson$1 = Schema.encodeEffect(Schema.fromJsonString(Selection));
3953
+ const builtInPatterns = [
3954
+ "timed out",
3955
+ "deadline exceeded",
3956
+ "ETIMEDOUT",
3957
+ "ECONNRESET",
3958
+ "ECONNREFUSED",
3959
+ "connection refused",
3960
+ "socket hang up",
3961
+ "lock timeout",
3962
+ "could not obtain lock",
3963
+ "runner lost communication",
3964
+ "The runner has received a shutdown signal",
3965
+ "net/http: request canceled",
3966
+ "ResourceExhausted",
3967
+ "Too many open files",
3968
+ "no space left on device"
3969
+ ];
3970
+ const baseName = (path) => path.slice(path.lastIndexOf("/") + 1);
3742
3971
  /**
3743
- * The prompt a fix session opens on: what these findings are, and the findings
3744
- * themselves as JSON.
3745
- *
3746
- * The findings go in verbatim rather than described, because a re-description
3747
- * is where a file, a line or my own note quietly changes. A note outranks the
3748
- * finding it is on: the finding is what the review thought, the note is what I
3749
- * think, and I am the one who picked it.
3972
+ * The changed file the log names, preferring one it spells in full.
3750
3973
  *
3751
- * Pushing is mine either way, and `commits` says whether committing is too.
3752
- * The tool itself never commits and never pushes; what the session may do
3753
- * inside the worktree is my call, made once in `fix.commits` or for one session
3754
- * with the flag.
3974
+ * A bare file name is worth matching - a stack trace often prints nothing else
3975
+ * - and it is worth matching second, because a name as ordinary as `index.ts`
3976
+ * belongs to more repositories than mine.
3755
3977
  */
3756
- const promptFor$1 = (selection, commits) => Effect.map(asJson$1(selection), (json) => [
3757
- `These are the findings I picked from a dw-mc review run on ${selection.repo}#${selection.number}, at ${short(selection.head)}, the commit their lines are counted from.`,
3758
- "Work through them one at a time. Where a finding carries a note, the note is mine and outranks the finding's own summary; where it carries none, the summary is the whole brief.",
3759
- commits ? `Commit what you change, one logical change to a commit. Do not push: I read the commits and push them myself.` : `Do not commit and do not push: I do both myself when I have read what you changed.`,
3760
- json
3761
- ].join("\n\n"));
3978
+ const escaped = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3762
3979
  /**
3763
- * Why these findings cannot be fixed where the pull request now is, or nothing
3764
- * where they can.
3980
+ * Whether the log names a file called `base` rather than some longer name
3981
+ * ending in it: a changed `src/a.ts` is not what a log printing `data.ts` is
3982
+ * complaining about.
3983
+ */
3984
+ const namesFile = (log, base) => new RegExp(`(^|[^\\w.-])${escaped(base)}`).test(log);
3985
+ const namedChangedFile = (log, changedFiles) => changedFiles.find((file) => log.includes(file)) ?? changedFiles.find((file) => namesFile(log, baseName(file))) ?? null;
3986
+ /**
3987
+ * The flaky pattern the log matches, mine before the built-in ones.
3765
3988
  *
3766
- * A pull request that moved since its last review run has findings at lines
3767
- * that may no longer be there, and a worktree cut at the new head would carry
3768
- * them into code they were never about. Reviewing again is cheap next to fixing
3769
- * the wrong thing.
3989
+ * A pattern is text and not a regular expression: it comes out of a
3990
+ * configuration file I edit by hand, where a stray `*` should cost me a missed
3991
+ * match and never a crash.
3770
3992
  */
3771
- const staleAt = (number, run, now) => run === now ? null : `The findings are from ${short(run)} and the pull request is now at ${short(now)}. Run dw-mc review ${number} again to review the head you would be fixing.`;
3772
- //#endregion
3773
- //#region src/cli/fix.ts
3774
- const printFlag$1 = Flag.Boolean("print").pipe(Flag.withDefault(false), Flag.withDescription("Print the prompt a session would open on, and open none"));
3775
- const commitFlag = Flag.Boolean("commit").pipe(Flag.withDescription("Let this session commit what it changes, over what the repository configured"), Flag.optional);
3993
+ const matchedPattern = (log, patterns) => {
3994
+ const haystack = log.toLowerCase();
3995
+ return [...patterns, ...builtInPatterns].find((pattern) => haystack.includes(pattern.toLowerCase())) ?? null;
3996
+ };
3776
3997
  /**
3777
- * The findings to pick from, each on the line `dw-mc findings` gives it.
3998
+ * Whether a red CI is mine to fix, and why.
3778
3999
  *
3779
- * The rows come from there rather than being built again here, so the list I
3780
- * pick from and the list I read are the same list. A row that does not fit the
3781
- * screen is cut: a prompt draws its own frame around the row, and a row that
3782
- * wraps takes the whole list's alignment with it.
4000
+ * Two of the signals say flaky and one says legitimate, and the one outranks
4001
+ * the two: a log that names a file this pull request changes is the failure
4002
+ * pointing at my own work, and a workflow that is broken everywhere does not
4003
+ * stop it pointing there.
4004
+ *
4005
+ * Everything else that is unexplained is mine as well. The two mistakes do not
4006
+ * cost the same - a real failure called flaky is a broken pull request nobody
4007
+ * tells me about, while a flake called mine costs me one look - so the default
4008
+ * is the one I can recover from.
3783
4009
  */
3784
- const choicesOf$1 = (found, screen) => {
3785
- const rows = lines$1(found);
3786
- const room = screen === 0 ? Number.POSITIVE_INFINITY : screen - 6;
3787
- return found.findings.map((finding, index) => ({
3788
- title: truncate(rows[index] ?? finding.summary, room),
3789
- value: finding
3790
- }));
4010
+ const classify = (evidence, flakyPatterns) => {
4011
+ const named = namedChangedFile(evidence.log, evidence.changedFiles);
4012
+ if (named !== null) return {
4013
+ classification: "legitimate",
4014
+ reason: `the log names ${named}, which this PR changes`
4015
+ };
4016
+ const redOnDefaultBranch = evidence.alsoRedOnDefaultBranch[0];
4017
+ const pattern = matchedPattern(evidence.log, flakyPatterns);
4018
+ const excuses = [redOnDefaultBranch === void 0 ? null : `${redOnDefaultBranch} is red on the default branch too`, pattern === null ? null : `the log matches "${pattern}"`].filter((it) => it !== null);
4019
+ return excuses.length === 0 ? {
4020
+ classification: "legitimate",
4021
+ reason: "nothing explains the failure"
4022
+ } : {
4023
+ classification: "flaky",
4024
+ reason: excuses.join(", and ")
4025
+ };
3791
4026
  };
3792
4027
  /**
3793
- * Each picked finding with whatever I have to say about it.
4028
+ * How many failing jobs the log is read from.
3794
4029
  *
3795
- * The note is asked for one finding at a time, in the order I see them, and
3796
- * having nothing to say is the ordinary answer rather than a step I have to get
3797
- * past.
4030
+ * One broken workflow usually fails several jobs with the same cause, and the
4031
+ * logs are the one read here that is measured in megabytes.
3798
4032
  */
3799
- const noted = Effect.fn("fix.noted")(function* (picked) {
3800
- const chosen = [];
3801
- for (const finding of picked) {
3802
- const said = yield* note(`Note on ${finding.file}:${finding.line}, or nothing`);
3803
- chosen.push(Option.match(said, {
3804
- onNone: () => finding,
3805
- onSome: (text) => ({
3806
- ...finding,
3807
- note: text
3808
- })
3809
- }));
3810
- }
3811
- return chosen;
4033
+ const loggedJobs = 3;
4034
+ /** The values of `xs` that `f` has one for. */
4035
+ const filterMap = (xs, f) => xs.flatMap((x) => {
4036
+ const b = f(x);
4037
+ return b === null ? [] : [b];
3812
4038
  });
3813
- /** The domain's word on a head that has moved, as the command's own failure. */
3814
- const fixable = (number, run, now) => {
3815
- const stale = staleAt(number, run, now);
3816
- return stale === null ? Effect.void : Effect.fail(new CliError.UserError({ cause: stale }));
4039
+ /** No evidence at all, which is what an unreadable CI comes to. */
4040
+ const nothing = {
4041
+ alsoRedOnDefaultBranch: [],
4042
+ changedFiles: [],
4043
+ log: ""
3817
4044
  };
3818
4045
  /**
3819
- * A fix session: the findings I picked, in an agent session I steer.
4046
+ * What a red CI looks like to the classifier.
3820
4047
  *
3821
- * The tool fixes nothing. It picks the findings apart with me, cuts a worktree
3822
- * on a branch of its own that tracks the pull request's, and hands the session
3823
- * what I chose as JSON; then it is out of the way. I steer and I push. Nothing
3824
- * here writes to GitHub, and the tool itself commits nothing: whether the
3825
- * session may commit inside the worktree is `fix.commits`, or `--commit` for
3826
- * one session.
4048
+ * A read that fails costs its own signal and nothing else. GitHub drops an
4049
+ * Actions log after ninety days, so a pull request open that long would
4050
+ * otherwise lose its row over a log nobody can fetch any more - and a missing
4051
+ * signal only ever moves the verdict towards legitimate, which is the answer
4052
+ * that puts the pull request in front of me rather than hiding it.
4053
+ */
4054
+ const evidenceFor = Effect.fn("flaky.evidenceFor")(function* (repo, number, entries, ignore) {
4055
+ const failed = failedChecks(entries, ignore);
4056
+ const workflows = [...new Set(filterMap(failed, (check) => check.workflowName ?? null))];
4057
+ const jobs = filterMap(failed, (check) => reportedAt(check.detailsUrl)?.job ?? null).slice(0, loggedJobs);
4058
+ const branch = yield* Effect.orElseSucceed(defaultBranch(repo), () => null);
4059
+ if (branch === null) return nothing;
4060
+ const [alsoRed, changedFiles, logs] = yield* Effect.all([
4061
+ Effect.forEach(workflows, (workflow) => Effect.map(Effect.orElseSucceed(workflowFailsOn(repo, branch, workflow), () => false), (red) => red ? [workflow] : [])),
4062
+ Effect.orElseSucceed(prFiles(repo, number), () => []),
4063
+ Effect.forEach(jobs, (job) => Effect.orElseSucceed(jobLog(repo, job), () => ""))
4064
+ ], { concurrency: 3 });
4065
+ return {
4066
+ alsoRedOnDefaultBranch: alsoRed.flat(),
4067
+ changedFiles,
4068
+ log: logs.join("\n")
4069
+ };
4070
+ });
4071
+ /**
4072
+ * Why a red CI is excused, or null where it is mine to fix.
3827
4073
  *
3828
- * The worktree is left standing when the session ends, because the work in it
3829
- * is mine and an unpushed commit lives nowhere else. Re-reviewing the result is
3830
- * a new review run against the new head, never a continuation of the run that
3831
- * produced these findings, so what was reviewed at which commit stays honest.
4074
+ * Reading the evidence and classifying it is one act, so it is one function:
4075
+ * a sweep writes what it returns down as `ciFlaky`, and `dw-mc rerun` asks it
4076
+ * again live. Two callers asking the same question have to get the same answer,
4077
+ * which they cannot if each of them spells the question out.
3832
4078
  */
3833
- const fix = Command.make("fix", {
3834
- pr: prArgument,
3835
- commit: commitFlag,
3836
- print: printFlag$1
3837
- }, Effect.fn("fix")(function* ({ commit, pr, print }) {
3838
- const file = Option.getOrElse(yield* read, () => ({}));
3839
- const { number, repo } = yield* named(pr, Object.keys(file.repos ?? {}).toSorted());
3840
- const settings = settingsFor(file, repo);
3841
- const run = yield* currentRun(repo, number);
3842
- const found = yield* whatItFound(run);
3843
- yield* Console.log(header$1(run, found, settings.stamp.blocks_on));
3844
- if (found.findings.length === 0) return;
3845
- const view = yield* prView(repo, number);
3846
- yield* fixable(number, run.head, view.headRefOid);
3847
- const picked = yield* choose("Which findings does the session carry?", choicesOf$1(found, yield* width));
3848
- const chosen = yield* Effect.catchTag(noted(Option.getOrElse(picked, () => [])), "QuitError", () => Effect.succeed([]));
3849
- if (chosen.length === 0) {
3850
- yield* Console.log("Nothing picked, so no session was opened.");
3851
- return;
3852
- }
3853
- const commits = Option.getOrElse(commit, () => settings.fix.commits);
3854
- if (print) {
3855
- yield* Console.log(yield* promptFor$1({
3856
- repo,
3857
- number,
3858
- head: run.head,
3859
- findings: chosen
3860
- }, commits));
3861
- return;
3862
- }
3863
- const worktree = yield* standingWorktree(repo, number, view.headRefName, "fix");
3864
- yield* Console.log(` ${chosen.length} of ${found.findings.length} findings, ${commits ? "committing" : "not committing"}`);
3865
- yield* Console.log(` ${worktree.directory}, pushing to ${view.headRefName}`);
3866
- const ended = yield* steeredSession({
3867
- launcher: launcherOf(file),
3868
- directory: worktree.directory,
3869
- prompt: yield* promptFor$1({
3870
- repo,
3871
- number,
3872
- head: worktree.head,
3873
- findings: chosen
3874
- }, commits)
3875
- });
3876
- yield* Console.log(ended === 0 ? "The session is over." : `The session ended with ${ended}.`);
3877
- yield* Console.log(`${commits ? "Nothing was pushed" : "Nothing was committed or pushed"} for you; the worktree stands at ${worktree.directory}.`);
3878
- yield* Console.log(`Once you have pushed, dw-mc review ${number} reviews the new head as a new run.`);
3879
- }, Effect.catchTag([
3880
- ...userFacing,
3881
- "GitFailed",
3882
- "WorktreeHeld",
3883
- "AgentFailed"
3884
- ], asUserError))).pipe(Command.withDescription("Pick findings from the current review run and open a fix session on them"));
4079
+ const flakyReason = Effect.fn("flaky.flakyReason")(function* (repo, number, entries, ignore, patterns) {
4080
+ const verdict = classify(yield* evidenceFor(repo, number, entries, ignore), patterns);
4081
+ return verdict.classification === "flaky" ? verdict.reason : null;
4082
+ });
3885
4083
  //#endregion
3886
- //#region src/cli/init.ts
3887
- const effortFlag$1 = Flag.Literals("effort", [
3888
- "low",
3889
- "medium",
3890
- "high",
3891
- "xhigh",
3892
- "max"
3893
- ]).pipe(Flag.withDescription("How much a review run spends on this repository"), Flag.optional);
3894
- const baseFlag = Flag.String("base").pipe(Flag.withDescription("The branch this repository's pull requests target, over the default one"), Flag.optional);
3895
- /** The settings the flags asked for, and only those. */
3896
- const asked = (base, effort) => ({
3897
- ...Option.isSome(base) ? { base: base.value } : {},
3898
- ...Option.isSome(effort) ? { review: { effort: effort.value } } : {}
4084
+ //#region src/domain/quiet.ts
4085
+ /** The pulse of a PR a previous sweep recorded. */
4086
+ const pulseOf = (facts) => ({
4087
+ head: facts.head,
4088
+ checks: facts.checks,
4089
+ newestHumanCommentAt: facts.newestHumanCommentAt
3899
4090
  });
3900
- /** What a review will open on, as the setup prints it back. */
3901
- const opening = (defaults) => {
3902
- const review = {
3903
- ...builtIn.review,
3904
- ...defaults.review
3905
- };
3906
- return review.command === null ? "my own prompt" : [review.command, review.effort].filter((part) => part !== null).join(" ");
3907
- };
3908
- const row = (label, value) => `${label.padEnd(12)}${value}`;
3909
4091
  /**
3910
- * Both the machine setup and the repository registration: there is deliberately
3911
- * no separate `setup` command.
3912
- *
3913
- * The first run on a machine checks `gh` and spells the defaults out in the
3914
- * configuration file. Run inside a repository, it also registers that
3915
- * `owner/repo`, taking the name from `gh` so I never type it. Run again, it
3916
- * changes what the flags name, keeps every other setting the file already had,
3917
- * and leaves the file untouched where nothing was decided differently.
3918
- *
3919
- * It asks nothing. Reviews run on Claude Code, and what a run opens on is
3920
- * `review.command` and `review.prompt` - a line and a paragraph that belong in
3921
- * the file rather than in a terminal prompt.
4092
+ * Whether a PR is where the last sweep left it.
3922
4093
  *
3923
- * `--effort` and `--base` are about one repository, so they land on the
3924
- * repository this ran in, or in the defaults when it ran outside one.
4094
+ * A quiet PR keeps the facts it already had rather than being read out again,
4095
+ * so a sweep over many pull requests spends its time on the few that moved.
3925
4096
  */
3926
- const init = Command.make("init", {
3927
- effort: effortFlag$1,
3928
- base: baseFlag
3929
- }, Effect.fn("init")(function* ({ base, effort }) {
3930
- yield* requireAuth;
3931
- const config = yield* ConfigStore;
3932
- const before = yield* read;
3933
- const file = Option.getOrElse(before, () => ({}));
3934
- const defaults = file.defaults === void 0 ? merge$1(builtIn, file.defaults ?? {}) : file.defaults ?? {};
3935
- const state = yield* stateDirectory;
3936
- const repo = yield* currentRepo.pipe(Effect.asSome, Effect.catchTag("NoRepository", () => Effect.succeedNone));
3937
- const overrides = asked(base, effort);
3938
- const written = Option.isSome(repo) ? withRepo(withDefaults(file, defaults), repo.value, overrides) : withDefaults(file, merge$1(defaults, overrides));
3939
- if (encode(written) !== encode(file) || Option.isNone(before)) yield* write(written);
3940
- yield* Console.log(row("review", opening(written.defaults ?? {})));
3941
- yield* Console.log(row("config", config.path));
3942
- yield* Console.log(row("state", state));
3943
- yield* Console.log(Option.isNone(repo) ? row("repository", "none here - run dw-mc init inside a repository to register it") : row("repository", `${repo.value} (${file.repos?.[repo.value] === void 0 ? "registered" : "already registered"})`));
3944
- }, Effect.catchTag([
3945
- "ConfigMalformed",
3946
- "GhUnauthenticated",
3947
- "GhUnavailable",
3948
- "GhUnreadable"
3949
- ], asUserError))).pipe(Command.withDescription("Set this machine up and register the repository I am in"));
4097
+ const isQuiet = (previous, current) => previous.head === current.head && previous.checks === current.checks && isSame(previous.newestHumanCommentAt, current.newestHumanCommentAt);
3950
4098
  //#endregion
3951
- //#region src/domain/stamp.ts
4099
+ //#region src/domain/rebase.ts
3952
4100
  /**
3953
- * A stamp I took off a pull request by hand, and the head I took it off at.
4101
+ * How many pull requests this one stands on.
3954
4102
  *
3955
- * The head is the whole record: a withdrawal is my overruling the computation
3956
- * on code I have read, so it lasts exactly as long as that code is what the
3957
- * pull request is.
4103
+ * A branch is walked to what it merges into and on from there, however deep the
4104
+ * stack goes. Every pull request the walk has already counted is left alone,
4105
+ * which is what keeps two branches that merge into each other from being walked
4106
+ * around forever.
3958
4107
  */
3959
- const Withdrawal = Schema.Struct({ head: Schema.String });
3960
- /** The stamp a pull request has not earned, and the first reason it has not. */
3961
- const withheld = (reason) => ({
3962
- stamped: false,
3963
- reason
3964
- });
3965
- /** What CI has to say before the stamp will rest on it, which is green and nothing else. */
3966
- const whyNotGreen = {
3967
- green: null,
3968
- red: "CI is red",
3969
- pending: "CI is still running",
3970
- none: "no CI ran on this head"
4108
+ const ancestorsOf = (pr, open, seen) => {
4109
+ let count = 0;
4110
+ let current = pr;
4111
+ for (;;) {
4112
+ const parent = open.find((it) => it.head === current.base && !seen.has(it.number));
4113
+ if (parent === void 0) return count;
4114
+ seen.add(parent.number);
4115
+ count += 1;
4116
+ current = parent;
4117
+ }
3971
4118
  };
3972
- /** What GitHub has to say about merging, which is that it would. */
3973
- const whyNotMergeable = {
3974
- mergeable: null,
3975
- conflicting: "merge conflict",
3976
- unknown: "GitHub has not said whether it merges"
4119
+ /**
4120
+ * How deep the stack goes above this pull request.
4121
+ *
4122
+ * Two branches cut from the same one are not two stacks deep, they are two
4123
+ * branches, so what counts is the deepest single line of them rather than how
4124
+ * many pull requests stand above it in total.
4125
+ */
4126
+ const descendantsOf = (pr, open, seen) => {
4127
+ let deepest = 0;
4128
+ for (const child of open.filter((it) => it.base === pr.head && !seen.has(it.number))) {
4129
+ seen.add(child.number);
4130
+ deepest = Math.max(deepest, 1 + descendantsOf(child, open, seen));
4131
+ }
4132
+ return deepest;
3977
4133
  };
3978
4134
  /**
3979
- * The stamp of one tracked PR: whether it has passed my bar, and why.
4135
+ * Where a pull request sits in its stack, or null where it is in none.
3980
4136
  *
3981
- * The mark is computed rather than clicked, so it means the same thing every
3982
- * time: a review run on this head that found nothing blocking, CI green as the
3983
- * repository's `ci.ignore` defines green, and a pull request GitHub would
3984
- * merge. A red CI the flaky classifier excused is still not green here: an
3985
- * excuse is a reason not to fix a check, not a reason to land code behind one,
3986
- * and this mark is what clears `dw-mc merge` (ADR 0008).
4137
+ * A stack is read off the branches alone: a pull request that merges into
4138
+ * another pull request's branch, or that another one merges into, is part of
4139
+ * one. The tool does not understand stacks and never drives them, so this
4140
+ * exists to recognise one and say where the pull request sits in it.
4141
+ */
4142
+ const stackOf = (number, open) => {
4143
+ const pr = open.find((it) => it.number === number);
4144
+ if (pr === void 0) return null;
4145
+ const seen = /* @__PURE__ */ new Set([number]);
4146
+ const below = ancestorsOf(pr, open, seen);
4147
+ const above = descendantsOf(pr, open, seen);
4148
+ return below === 0 && above === 0 ? null : {
4149
+ position: below + 1,
4150
+ length: below + above + 1
4151
+ };
4152
+ };
4153
+ /**
4154
+ * Why this branch is nobody's to touch here, or null where it is mine.
3987
4155
  *
3988
- * Nothing about this rests on a previous stamp, which is what makes a head
3989
- * change clear it: facts are about one head, and a run is recorded against one.
4156
+ * These are the guards about the branch rather than about what is done to it,
4157
+ * which is why they are their own and why they say nothing about pushing: who
4158
+ * authored the pull request and where its branch lives is the boundary itself -
4159
+ * a branch somebody else authored and a branch in a fork are not mine to work
4160
+ * on, whatever else is true of them and whichever command asks. A stack comes
4161
+ * next, and a pull request the stack was not read from counts as one, because a
4162
+ * stack the tool cannot see is one it could drive: the tool does not understand
4163
+ * stacks, so the one thing it has to say about one is where the pull request
4164
+ * sits in it.
4165
+ */
4166
+ const boundary = (branch) => {
4167
+ const where = `${branch.repo}#${branch.number}`;
4168
+ if (!branch.mine) return `${where} is not mine. dw-mc works on branches I author and on nothing else.`;
4169
+ if (branch.fromFork) return `${where} is opened from a fork, so its branch is not in ${branch.repo}. dw-mc works only on a branch in the repository it read.`;
4170
+ if (!branch.listed) return `${where} was not among the open pull requests of ${branch.repo}, so nothing here can say whether it is in a stack. Read it again before touching the branch.`;
4171
+ if (branch.stack !== null) return `${where} is ${branch.stack.position} of ${branch.stack.length} in a stack. dw-mc does not understand stacks and will not drive one; rebase it with whatever built the stack.`;
4172
+ return null;
4173
+ };
4174
+ /**
4175
+ * Why this branch is not one to rebase, or null where it is.
3990
4176
  *
3991
- * A withdrawal comes first, because it is the one thing here I decided rather
3992
- * than computed.
4177
+ * This is the single place the guards live, and they matter more than the
4178
+ * rebase itself: a force push is the one write the tool makes that can lose
4179
+ * work, and every rule here is about it never being a surprise.
4180
+ *
4181
+ * Being off is said first, because a repository that has not turned rebase on
4182
+ * has decided the question and nothing else about the pull request changes it.
4183
+ * The branch's own guards come next. CI is last and costs the most to get
4184
+ * wrong - rebasing while a run is in flight cancels the run I am waiting on,
4185
+ * and a red build is mine to fix where it is.
3993
4186
  */
3994
- const stampFor = (facts, withdrawnAt) => {
3995
- if (withdrawnAt === facts.head) return withheld("withdrawn by hand");
3996
- if (facts.reviewRunHead !== facts.head) return withheld("no review run on this head");
3997
- if (facts.blockingFindings > 0) return withheld(`${facts.blockingFindings} blocking finding${facts.blockingFindings === 1 ? "" : "s"}`);
3998
- const ci = whyNotGreen[facts.checks];
3999
- if (ci !== null) return withheld(ci);
4000
- const merge = whyNotMergeable[facts.mergeable];
4001
- if (merge !== null) return withheld(merge);
4002
- return {
4003
- stamped: true,
4004
- reason: "a clean review run on this head, green CI, mergeable"
4005
- };
4187
+ const decide$2 = (situation) => {
4188
+ const where = `${situation.repo}#${situation.number}`;
4189
+ if (!situation.enabled) return `Rebase is off for ${situation.repo}. Set rebase.enabled: true for it in the config to turn it on, so a force push is never a surprise.`;
4190
+ const refused = boundary(situation);
4191
+ if (refused !== null) return refused;
4192
+ if (situation.checks === "pending") return `CI is still running on ${where}. A rebase now would cancel the run you are waiting on.`;
4193
+ if (situation.checks === "red") return `CI is red on ${where}, which is yours to fix before the branch moves.`;
4194
+ return null;
4006
4195
  };
4007
4196
  /**
4008
- * The head a stamp was withdrawn at, or null where none was.
4197
+ * A rebase that conflicted: the head it conflicted at and the files it stopped
4198
+ * on.
4199
+ *
4200
+ * The head is what the record is scoped to, as it is for a withdrawn stamp: a
4201
+ * conflict is about the code the branch is at, so it lasts exactly as long as
4202
+ * that code is what the pull request is. A branch that moved is a branch
4203
+ * nothing here has tried to rebase yet.
4009
4204
  *
4010
- * A withdrawal this version cannot read is one another version of this record
4011
- * wrote, and a stamp is computed from everything else: forgetting it hands the
4012
- * pull request back to the computation, where failing here would cost me the
4013
- * command I asked for.
4205
+ * The paths are what makes the conflict something to open: `a rebase
4206
+ * conflicted` cannot tell a stale lockfile from half the pull request. They are
4207
+ * an optional key rather than a required one so a record an older version wrote
4208
+ * still reads, and a conflict with no paths still puts the pull request in
4209
+ * Needs me.
4014
4210
  */
4015
- const withdrawnAt = Effect.fn("stamp.withdrawnAt")(function* (repo, number) {
4016
- const store = yield* storeFor("stamps", Withdrawal);
4017
- const withdrawal = yield* Effect.orElseSucceed(store.get(prKey(repo, number)), () => Option.none());
4018
- return Option.match(withdrawal, {
4019
- onNone: () => null,
4020
- onSome: (it) => it.head
4021
- });
4211
+ const Conflict = Schema.Struct({
4212
+ head: Schema.String,
4213
+ paths: Schema.optionalKey(Schema.Array(Schema.String))
4022
4214
  });
4023
- /** Takes the stamp off a pull request at `head`, which is the only head it stays off. */
4024
- const withdraw = Effect.fn("stamp.withdraw")(function* (repo, number, head) {
4025
- yield* (yield* storeFor("stamps", Withdrawal)).set(prKey(repo, number), { head });
4215
+ /**
4216
+ * The conflict a rebase last left on this pull request, or null where it left
4217
+ * none.
4218
+ *
4219
+ * Forgetting one costs the pull request one reason to be in Needs me, where
4220
+ * failing here would cost me the whole table.
4221
+ */
4222
+ const conflictFor = Effect.fn("rebase.conflictFor")(function* (repo, number) {
4223
+ const store = yield* storeFor("rebases", Conflict);
4224
+ const conflict = yield* remembered(store.get(prKey(repo, number)));
4225
+ return Option.getOrNull(conflict);
4026
4226
  });
4027
- /** The stamp of one tracked PR, with the withdrawal this machine holds against it. */
4028
- const stampOf = Effect.fn("stamp.stampOf")(function* (facts) {
4029
- return stampFor(facts, yield* withdrawnAt(facts.repo, facts.number));
4227
+ /** Writes down that a rebase of `head` conflicted on `paths`, which is the only head it holds for. */
4228
+ const recordConflict = Effect.fn("rebase.recordConflict")(function* (repo, number, head, paths) {
4229
+ yield* (yield* storeFor("rebases", Conflict)).set(prKey(repo, number), {
4230
+ head,
4231
+ paths
4232
+ });
4233
+ });
4234
+ //#endregion
4235
+ //#region src/cli/sweep.ts
4236
+ const writtenBy = (comments, login) => comments.filter((comment) => comment.login === login).map((comment) => comment.at);
4237
+ const byHumansOtherThan = (comments, login) => comments.filter((comment) => !comment.bot && comment.login !== login).map((comment) => comment.at);
4238
+ /**
4239
+ * The facts about one tracked PR, read from GitHub and kept on disk.
4240
+ *
4241
+ * The cheap reads happen every time, because they are what says whether the PR
4242
+ * moved. The commits are asked for only when it did: `gh` returns every commit
4243
+ * message in full, and on a PR that is where the last sweep left it that whole
4244
+ * read buys a timestamp the state directory already has.
4245
+ */
4246
+ const sweepPr = Effect.fn("sweep.pullRequest")(function* (store, me, found, settings) {
4247
+ const view = yield* prView(found.repo, found.number);
4248
+ const [onThePr, inReviews] = yield* Effect.all([prComments(found.repo, found.number), prReviews(found.repo, found.number)], { concurrency: 2 });
4249
+ const comments = [...onThePr, ...inReviews];
4250
+ const checks = rollupState(view.statusCheckRollup, settings.ci.ignore);
4251
+ const newestHumanCommentAt = newest(byHumansOtherThan(comments, me));
4252
+ const key = prKey(found.repo, found.number);
4253
+ const previous = Option.getOrUndefined(yield* remembered(store.get(key)));
4254
+ const reviewed = yield* reviewedAt(found.repo, found.number, view.headRefOid, settings.stamp.blocks_on);
4255
+ const quiet = previous !== void 0 && isQuiet(pulseOf(previous), {
4256
+ head: view.headRefOid,
4257
+ checks,
4258
+ newestHumanCommentAt
4259
+ }) ? previous : void 0;
4260
+ const myLastCommitAt = quiet !== void 0 ? quiet.myLastCommitAt : newest((yield* prCommits(found.repo, found.number)).filter((commit) => commit.logins.includes(me)).map((commit) => commit.at));
4261
+ const ciFlaky = checks !== "red" ? null : quiet !== void 0 ? quiet.ciFlaky : yield* flakyReason(found.repo, found.number, view.statusCheckRollup, settings.ci.ignore, settings.ci.flaky_patterns);
4262
+ const rebaseConflictAt = yield* Effect.map(conflictFor(found.repo, found.number), (it) => it?.head ?? null);
4263
+ const facts = {
4264
+ repo: found.repo,
4265
+ number: found.number,
4266
+ title: view.title,
4267
+ url: view.url,
4268
+ draft: view.isDraft,
4269
+ head: view.headRefOid,
4270
+ mergeable: mergeabilityOf(view.mergeable),
4271
+ reviewDecision: reviewDecisionOf(view.reviewDecision),
4272
+ checks,
4273
+ ciFlaky,
4274
+ rebaseConflictAt,
4275
+ newestHumanCommentAt,
4276
+ myLastCommentAt: newest(writtenBy(comments, me)),
4277
+ myLastCommitAt,
4278
+ ...reviewed
4279
+ };
4280
+ yield* store.set(key, facts);
4281
+ return facts;
4030
4282
  });
4031
- /**
4032
- * Which of these tracked PRs carry a stamp, keyed the way their facts are.
4033
- *
4034
- * A table asks the question of every row at once, and the withdrawals are the
4035
- * only thing here that has to be read off the disk.
4036
- */
4037
- const stampedAmong = Effect.fn("stamp.stampedAmong")(function* (facts) {
4038
- const marks = yield* Effect.forEach(facts, (it) => Effect.map(stampOf(it), (stamp) => ({
4039
- key: prKey(it.repo, it.number),
4040
- stamped: stamp.stamped
4041
- })));
4042
- return new Set(marks.filter((mark) => mark.stamped).map((mark) => mark.key));
4283
+ /** A read that came back, or the trouble it came back with instead. */
4284
+ const attempt = (where, read) => read.pipe(Effect.map((got) => ({
4285
+ got,
4286
+ troubles: []
4287
+ })), Effect.catch((error) => Effect.succeed({
4288
+ got: [],
4289
+ troubles: [{
4290
+ where,
4291
+ detail: error.message
4292
+ }]
4293
+ })));
4294
+ const gather = (attempts) => ({
4295
+ got: attempts.flatMap((it) => it.got),
4296
+ troubles: attempts.flatMap((it) => it.troubles)
4043
4297
  });
4044
- //#endregion
4045
- //#region src/domain/merge.ts
4298
+ /** How many reads of GitHub are in flight at once. */
4299
+ const concurrency = 4;
4300
+ /** `n` repositories, which `count` cannot say: the plural is not the noun plus s. */
4301
+ const repositories = (n) => n === 1 ? "1 repository" : `${n} repositories`;
4302
+ /** How the heartbeat of a sweep reads, wherever a command turns one. */
4303
+ const saying$1 = (swept) => (since) => [
4304
+ "sweeping",
4305
+ swept._tag === "searching" ? `${swept.done} of ${repositories(swept.of)}` : `${swept.done} of ${count(swept.of, "pull request")}`,
4306
+ since
4307
+ ].join(" · ");
4046
4308
  /**
4047
- * Why GitHub would not call this pull request Ready, or null where it would.
4048
- *
4049
- * Ready is GitHub's opinion and nothing of mine: approved, green, mergeable.
4050
- * A repository that requires no reviewer produces no approval, which is why
4051
- * `none` passes and `review-required` does not - what holds a merge is somebody
4052
- * having been asked and not yet answered.
4309
+ * One pass over every tracked PR, and nothing else: a sweep only ever reads.
4053
4310
  *
4054
- * A red CI the flaky classifier excused is still red here. The excuse is a
4055
- * reason not to fix a check; it is not a reason to land code behind one.
4056
- */
4057
- const whyNotReady = (situation) => {
4058
- if (situation.reviewDecision === "changes-requested") return "changes are requested";
4059
- if (situation.reviewDecision === "review-required") return "a review from someone else is still wanted";
4060
- return whyNotGreen[situation.checks] ?? whyNotMergeable[situation.mergeable];
4061
- };
4062
- /**
4063
- * What to do about a pull request that is Ready and carries no stamp.
4311
+ * Every repository and every pull request is read on its own, so one of them
4312
+ * failing costs me its rows and leaves the rest of the table standing. What
4313
+ * failed comes back beside the facts rather than instead of them.
4064
4314
  *
4065
- * The stamp is withheld for one of three reasons and each has its own next
4066
- * step, so the refusal names that step rather than leaving me to work out which
4067
- * of the three it was. A withdrawal is the one with no command: I took the mark
4068
- * off code I had read, and only that code changing puts it back.
4315
+ * `report` is told how far the pass has got, every time it gets further. What
4316
+ * that is worth saying is the caller's, which is why it is handed a count and
4317
+ * not a sentence.
4069
4318
  */
4070
- const earnsIt = (situation) => {
4071
- if (situation.withdrawnAt === situation.head) return "\n\nYou took it off at this head, and it stays off until the head changes.";
4072
- const next = situation.reviewRunHead !== situation.head ? {
4073
- command: `dw-mc review ${situation.number}`,
4074
- says: "That reviews this head, and a run that finds nothing blocking stamps it."
4075
- } : {
4076
- command: `dw-mc fix ${situation.number}`,
4077
- says: "That opens a session on the findings. The stamp is back once the head has moved and a review run has read it."
4319
+ const sweep = Effect.fn("sweep")(function* (report) {
4320
+ const file = Option.getOrElse(yield* read, () => ({}));
4321
+ const repos = Object.keys(file.repos ?? {}).toSorted();
4322
+ if (repos.length === 0) return {
4323
+ repos,
4324
+ facts: [],
4325
+ troubles: []
4078
4326
  };
4079
- return `\n\n ${next.command}\n\n${next.says}`;
4080
- };
4327
+ const store = yield* storeFor("prs", Facts);
4328
+ const me = yield* viewer;
4329
+ let searched = 0;
4330
+ yield* report({
4331
+ _tag: "searching",
4332
+ done: 0,
4333
+ of: repos.length
4334
+ });
4335
+ const found = gather(yield* Effect.forEach(repos, (repo) => Effect.tap(attempt(repo, searchPrs(repo)), () => {
4336
+ searched = searched + 1;
4337
+ return report({
4338
+ _tag: "searching",
4339
+ done: searched,
4340
+ of: repos.length
4341
+ });
4342
+ }), { concurrency }));
4343
+ let read$1 = 0;
4344
+ yield* report({
4345
+ _tag: "reading",
4346
+ done: 0,
4347
+ of: found.got.length
4348
+ });
4349
+ 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])), () => {
4350
+ read$1 = read$1 + 1;
4351
+ return report({
4352
+ _tag: "reading",
4353
+ done: read$1,
4354
+ of: found.got.length
4355
+ });
4356
+ }), { concurrency }));
4357
+ return {
4358
+ repos,
4359
+ facts: swept.got,
4360
+ troubles: [...found.troubles, ...swept.troubles]
4361
+ };
4362
+ });
4081
4363
  /**
4082
- * Why this pull request is not one to merge, or null where it is.
4083
- *
4084
- * This is the single place the merge guards live, and they carry more than the
4085
- * merge does: it is the one write the tool makes that no reflog of mine undoes
4086
- * (ADR 0008). Two bars have to be clear, because each is blind to what the
4087
- * other sees - GitHub does not know whether anything read the diff, and the
4088
- * stamp does not know whether a reviewer asked for changes.
4089
- *
4090
- * Whose pull request it is comes first, as it does everywhere else: one
4091
- * somebody else opened is none of this tool's business, whatever is true of it.
4092
- * A draft is next, because a pull request I have not offered to anybody is not
4093
- * one to land however green it is.
4364
+ * A sweep under its heartbeat, which is how every command that sweeps runs one.
4094
4365
  *
4095
- * Ready is asked before the stamp so that the refusal names the bar I am
4096
- * actually under. The stamp insists on green CI and a mergeable pull request
4097
- * too, so everything it can be withheld for here is mine rather than GitHub's.
4366
+ * The three of them want the same line, so they say it once here rather than
4367
+ * three times over. It gives the heartbeat no aside, so a piped `dw-mc status`
4368
+ * prints exactly what it printed before there was a heartbeat at all.
4098
4369
  */
4099
- const decide$2 = (situation) => {
4100
- const where = `${situation.repo}#${situation.number}`;
4101
- if (!situation.mine) return `${where} is not mine. dw-mc merges pull requests I author and nothing else.`;
4102
- if (situation.draft) return `${where} is a draft. Mark it ready for review before merging it.`;
4103
- const ready = whyNotReady(situation);
4104
- if (ready !== null) return `${where} is not Ready: ${ready}. dw-mc merges nothing GitHub would not merge itself.`;
4105
- const stamp = stampFor(situation, situation.withdrawnAt);
4106
- return stamp.stamped ? null : `${where} is Ready and carries no stamp: ${stamp.reason}.${earnsIt(situation)}`;
4107
- };
4108
- //#endregion
4109
- //#region src/cli/merge.ts
4370
+ const sweeping = beating((since) => `sweeping · ${since}`, (says) => sweep((swept) => says(saying$1(swept))));
4371
+ /** What a sweep could not read, under a heading, so the table above it stands alone. */
4372
+ const printTroubles = Effect.fn("sweep.printTroubles")(function* (troubles) {
4373
+ if (troubles.length === 0) return;
4374
+ yield* Console.log("");
4375
+ yield* Console.log("Could not load");
4376
+ for (const trouble of troubles) yield* Console.log(` ${trouble.where} ${trouble.detail}`);
4377
+ });
4110
4378
  /**
4111
- * Lands one pull request of mine: squashed, with its branch deleted.
4112
- *
4113
- * This is the write ADR 0008 is about, and the only one the tool makes that no
4114
- * reflog of mine brings back. It is outside ADR 0002's three because it moves a
4115
- * shared branch; everything 0002 bars - comment, reply, thread resolve, label,
4116
- * review, approval, status - still holds here as it does everywhere.
4117
- *
4118
- * The threshold is two bars at one head: Ready, which is GitHub's opinion, and
4119
- * my stamp, which is mine. Each is blind to what the other sees, so the write
4120
- * that cannot be undone clears both.
4121
- *
4122
- * GitHub's half is read live from a fresh `pr view` rather than off the last
4123
- * sweep, the way the rebase and re-run guards are. A stale verdict costs a
4124
- * re-run some CI minutes; here it costs merging code nobody read. My half comes
4125
- * from the state directory, because the review runs and the withdrawal live
4126
- * there and are already scoped to the head this read just named.
4379
+ * Refreshes what mission control knows about every tracked PR.
4127
4380
  *
4128
- * Typing the command is the confirmation, so it takes no flag. The picker,
4129
- * where a keystroke is cheaper, asks before it dispatches.
4381
+ * `dw-mc status` does this too, so this command is for the pass on its own:
4382
+ * warming the state directory, or seeing what GitHub would not answer.
4130
4383
  */
4131
- const merge = Command.make("merge", { pr: prArgument }, Effect.fn("merge")(function* ({ pr }) {
4132
- const file = Option.getOrElse(yield* read, () => ({}));
4133
- const { number, repo } = yield* named(pr, Object.keys(file.repos ?? {}).toSorted());
4134
- const settings = settingsFor(file, repo);
4135
- const view = yield* prView(repo, number);
4136
- const me = yield* viewer;
4137
- const head = view.headRefOid;
4138
- yield* refuse(decide$2({
4139
- repo,
4140
- number,
4141
- head,
4142
- mine: view.author?.login === me,
4143
- draft: view.isDraft,
4144
- reviewDecision: reviewDecisionOf(view.reviewDecision),
4145
- checks: rollupState(view.statusCheckRollup, settings.ci.ignore),
4146
- mergeable: mergeabilityOf(view.mergeable),
4147
- ...yield* reviewedAt(repo, number, head, settings),
4148
- withdrawnAt: yield* withdrawnAt(repo, number)
4149
- }));
4150
- yield* mergePr(repo, number);
4151
- yield* Console.log(`${repo}#${number} ${short(head)} squash-merged into ${view.baseRefName}, and ${view.headRefName} deleted`);
4152
- yield* Console.log(`The squash subject is the pull request title: ${view.title}`);
4153
- }, Effect.catchTag(userFacing, asUserError))).pipe(Command.withDescription("Squash-merge a Ready, stamped pull request of mine and delete its branch"));
4384
+ const sweepCommand = Command.make("sweep", {}, Effect.fn("sweep.command")(function* () {
4385
+ const report = yield* sweeping;
4386
+ 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 ${repositories(report.repos.length)}`);
4387
+ yield* printTroubles(report.troubles);
4388
+ }, Effect.catchTag(userFacing, asUserError))).pipe(Command.withDescription("Refresh what mission control knows about every tracked pull request"));
4154
4389
  //#endregion
4155
4390
  //#region src/domain/pick.ts
4156
4391
  /**
@@ -4275,13 +4510,11 @@ const Rerun = Schema.Struct({ head: Schema.String });
4275
4510
  * The head a re-run was last asked for at on this pull request, or null where
4276
4511
  * none has been.
4277
4512
  *
4278
- * A record this version cannot read is one another version of it wrote. Reading
4279
- * it again as nothing costs a flaky pull request one extra re-run, where failing
4280
- * here would cost the command outright.
4513
+ * Forgetting one costs a flaky pull request one extra re-run.
4281
4514
  */
4282
4515
  const rerunFor = Effect.fn("rerun.rerunFor")(function* (repo, number) {
4283
4516
  const store = yield* storeFor("reruns", Rerun);
4284
- const rerun = yield* Effect.orElseSucceed(store.get(prKey(repo, number)), () => Option.none());
4517
+ const rerun = yield* remembered(store.get(prKey(repo, number)));
4285
4518
  return Option.getOrNull(rerun)?.head ?? null;
4286
4519
  });
4287
4520
  /** Writes down that a re-run was asked for at `head`, which is the only head it caps. */
@@ -4370,7 +4603,7 @@ const where = (facts) => `${facts.repo}#${facts.number}`;
4370
4603
  * and a merge must never cost only that (ADR 0008).
4371
4604
  */
4372
4605
  const picker = (dispatch) => Effect.fn("pick")(function* () {
4373
- const report = yield* sweep;
4606
+ const report = yield* sweeping;
4374
4607
  if (report.repos.length === 0) {
4375
4608
  yield* Console.log("No repositories registered. Run dw-mc init inside a repository to register it.");
4376
4609
  return;
@@ -4433,13 +4666,13 @@ const picker = (dispatch) => Effect.fn("pick")(function* () {
4433
4666
  * so what it has to say about one is where the pull request sits in it.
4434
4667
  */
4435
4668
  const rebase = Command.make("rebase", { pr: prArgument }, Effect.fn("rebase")(function* ({ pr }) {
4436
- const file = Option.getOrElse(yield* read, () => ({}));
4437
- const { number, repo } = yield* named(pr, Object.keys(file.repos ?? {}).toSorted());
4438
- const settings = settingsFor(file, repo);
4439
- const view = yield* prView(repo, number);
4440
- const open = yield* openPrs(repo);
4441
- const me = yield* viewer;
4442
- yield* refuse(decide$3({
4669
+ const { number, repo, settings } = yield* forPr(pr);
4670
+ const [view, open, me] = yield* reading(`${repo}#${number}`, Effect.all([
4671
+ prView(repo, number),
4672
+ openPrs(repo),
4673
+ viewer
4674
+ ]));
4675
+ yield* refuse(decide$2({
4443
4676
  repo,
4444
4677
  number,
4445
4678
  base: view.baseRefName,
@@ -4472,7 +4705,7 @@ const rebase = Command.make("rebase", { pr: prArgument }, Effect.fn("rebase")(fu
4472
4705
  return;
4473
4706
  }
4474
4707
  yield* Console.log(`${where} ${short(done.before)} → ${short(done.after)} rebased ${count(done.behind, "commit")} of ${view.baseRefName} and pushed with a lease`);
4475
- }, Effect.catchTag([...userFacing, "GitFailed"], asUserError))).pipe(Command.withDescription("Rebase one branch onto its base and push it with a lease"));
4708
+ }, Effect.catchTag(userFacingAndGit, asUserError))).pipe(Command.withDescription("Rebase one branch onto its base and push it with a lease"));
4476
4709
  //#endregion
4477
4710
  //#region src/cli/rerun.ts
4478
4711
  /**
@@ -4494,11 +4727,8 @@ const rebase = Command.make("rebase", { pr: prArgument }, Effect.fn("rebase")(fu
4494
4727
  * it is a pull request re-running itself until the minutes run out.
4495
4728
  */
4496
4729
  const rerun = Command.make("rerun", { pr: prArgument }, Effect.fn("rerun")(function* ({ pr }) {
4497
- const file = Option.getOrElse(yield* read, () => ({}));
4498
- const { number, repo } = yield* named(pr, Object.keys(file.repos ?? {}).toSorted());
4499
- const settings = settingsFor(file, repo);
4500
- const view = yield* prView(repo, number);
4501
- const me = yield* viewer;
4730
+ const { number, repo, settings } = yield* forPr(pr);
4731
+ const [view, me] = yield* reading(`${repo}#${number}`, Effect.all([prView(repo, number), viewer]));
4502
4732
  const unclassified = {
4503
4733
  repo,
4504
4734
  number,
@@ -4585,11 +4815,6 @@ const promptFor = (conflicted) => Effect.map(asJson(conflicted), (json) => [
4585
4815
  //#endregion
4586
4816
  //#region src/cli/resolve.ts
4587
4817
  const printFlag = Flag.Boolean("print").pipe(Flag.withDefault(false), Flag.withDescription("Print the prompt a session would open on, and open none"));
4588
- /** The domain's word on a conflict that is not one to open, as the command's own failure. */
4589
- const allowed = (situation) => {
4590
- const refused = decide(situation);
4591
- return refused === null ? Effect.void : Effect.fail(new CliError.UserError({ cause: refused }));
4592
- };
4593
4818
  /**
4594
4819
  * A session on the conflict that stopped a rebase, in a worktree that is mine.
4595
4820
  *
@@ -4614,13 +4839,14 @@ const resolve = Command.make("resolve", {
4614
4839
  pr: prArgument,
4615
4840
  print: printFlag
4616
4841
  }, Effect.fn("resolve")(function* ({ pr, print }) {
4617
- const file = Option.getOrElse(yield* read, () => ({}));
4618
- const { number, repo } = yield* named(pr, Object.keys(file.repos ?? {}).toSorted());
4619
- const view = yield* prView(repo, number);
4620
- const open = yield* openPrs(repo);
4621
- const me = yield* viewer;
4842
+ const { number, repo, launcher } = yield* forPr(pr);
4843
+ const [view, open, me] = yield* reading(`${repo}#${number}`, Effect.all([
4844
+ prView(repo, number),
4845
+ openPrs(repo),
4846
+ viewer
4847
+ ]));
4622
4848
  const conflict = yield* conflictFor(repo, number);
4623
- yield* allowed({
4849
+ yield* refuse(decide({
4624
4850
  repo,
4625
4851
  number,
4626
4852
  mine: view.author?.login === me,
@@ -4629,7 +4855,7 @@ const resolve = Command.make("resolve", {
4629
4855
  stack: stackOf(number, open),
4630
4856
  head: view.headRefOid,
4631
4857
  conflictAt: conflict === null ? null : conflict.head
4632
- });
4858
+ }));
4633
4859
  /** The conflict as the prompt takes it, around whichever paths are known by then. */
4634
4860
  const conflicted = (paths) => ({
4635
4861
  repo,
@@ -4662,7 +4888,7 @@ const resolve = Command.make("resolve", {
4662
4888
  yield* Console.log(`It stopped on ${count(stopped.paths.length, "file")}:`);
4663
4889
  yield* Effect.forEach(stopped.paths, (path) => Console.log(` ${path}`));
4664
4890
  const ended = yield* steeredSession({
4665
- launcher: launcherOf(file),
4891
+ launcher,
4666
4892
  directory: worktree.directory,
4667
4893
  prompt: yield* promptFor(conflicted(stopped.paths))
4668
4894
  });
@@ -4676,12 +4902,7 @@ const resolve = Command.make("resolve", {
4676
4902
  ``
4677
4903
  ], (line) => Console.log(line));
4678
4904
  yield* Console.log(`Once you have pushed, dw-mc review ${number} reviews the new head as a new run.`);
4679
- }, Effect.catchTag([
4680
- ...userFacing,
4681
- "GitFailed",
4682
- "WorktreeHeld",
4683
- "AgentFailed"
4684
- ], asUserError))).pipe(Command.withDescription("Open a session on the conflict that stopped a rebase, in a worktree of my own"));
4905
+ }, Effect.catchTag(userFacingAndSession, asUserError))).pipe(Command.withDescription("Open a session on the conflict that stopped a rebase, in a worktree of my own"));
4685
4906
  //#endregion
4686
4907
  //#region src/adapters/notify.ts
4687
4908
  /** A string as AppleScript spells one, so a quotation mark cannot end it early. */
@@ -4701,73 +4922,6 @@ const announce = Effect.fn("notify.announce")(function* (title, message) {
4701
4922
  yield* Effect.ignore(capture("osascript", ["-e", `display notification ${quoted(message)} with title ${quoted(title)}`]));
4702
4923
  });
4703
4924
  //#endregion
4704
- //#region src/adapters/progress.ts
4705
- /** The frames of the spinner, in the order they turn. */
4706
- const frames = [
4707
- "⠋",
4708
- "⠙",
4709
- "⠹",
4710
- "⠸",
4711
- "⠼",
4712
- "⠴",
4713
- "⠦",
4714
- "⠧",
4715
- "⠇",
4716
- "⠏"
4717
- ];
4718
- /** How long one frame is on the screen. */
4719
- const frameFor = Duration.millis(120);
4720
- /** A stretch of time as a terminal says it: `1m12s`, or `9s` under the minute. */
4721
- const elapsed = (millis) => {
4722
- const seconds = Math.floor(millis / 1e3);
4723
- return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, "0")}s`;
4724
- };
4725
- /**
4726
- * Runs `use` while the screen says it is still going, and hands `use` the way
4727
- * to report what the run reached for.
4728
- *
4729
- * A review takes minutes, and a terminal that prints nothing for minutes is one
4730
- * I stop trusting. What it printed instead was a line per tool call, which is a
4731
- * wall of `· Bash` that says as little as silence did. This keeps one line and
4732
- * rewrites it: the spinner says the run is alive, the counts say how far it has
4733
- * got, and the line is gone when the run is over, so what stays on the screen is
4734
- * the report.
4735
- *
4736
- * How that line reads is `reads` and not this module's business. What a count
4737
- * is worth saying belongs to the command that is counting, and a screen that
4738
- * worded it here would need the words a command already has.
4739
- *
4740
- * Where there is no screen to measure - a pipe, a CI log, a test - the counts
4741
- * would be a mess of half-drawn lines, so the tools go out one to a line as
4742
- * they did before. `columns` is zero exactly there.
4743
- */
4744
- const spinning = Effect.fnUntraced(function* (reads, use) {
4745
- const terminal = yield* Terminal.Terminal;
4746
- const columns = yield* terminal.columns;
4747
- if (columns === 0) return yield* use((tool) => Console.log(` · ${tool}`));
4748
- let doing = {
4749
- tools: 0,
4750
- subagents: 0
4751
- };
4752
- const onTool = (tool) => Effect.sync(() => {
4753
- doing = {
4754
- tools: doing.tools + 1,
4755
- subagents: doing.subagents + (tool === "Agent" ? 1 : 0)
4756
- };
4757
- });
4758
- const started = yield* Clock.currentTimeMillis;
4759
- const draw = (text) => Effect.ignore(terminal.display(`\r${text.slice(0, columns - 1).padEnd(columns - 1)}`));
4760
- const frame = (since, turn) => `${frames[turn % frames.length]} ${reads(doing, elapsed(since))}`;
4761
- yield* draw(frame(0, 0));
4762
- const turning = yield* Effect.forkChild(Effect.gen(function* () {
4763
- for (let turn = 1;; turn = turn + 1) {
4764
- yield* Effect.sleep(frameFor);
4765
- yield* draw(frame((yield* Clock.currentTimeMillis) - started, turn));
4766
- }
4767
- }));
4768
- return yield* Effect.onExit(use(onTool), () => Effect.flatMap(Fiber.interrupt(turning), () => Effect.ignore(terminal.display(`\r${" ".repeat(columns - 1)}\r`))));
4769
- });
4770
- //#endregion
4771
4925
  //#region src/domain/persona.ts
4772
4926
  /**
4773
4927
  * The reviewer persona, derived from Addy Osmani's `code-reviewer` agent
@@ -4859,8 +5013,8 @@ const turnFor = (review, about) => review.command === null ? {
4859
5013
  };
4860
5014
  //#endregion
4861
5015
  //#region src/cli/review.ts
4862
- /** What the spinner says a run has got through, while it is still going. */
4863
- const saying = (doing, since) => [
5016
+ /** What the heartbeat says a run has got through, while it is still going. */
5017
+ const saying = (doing) => (since) => [
4864
5018
  "reviewing",
4865
5019
  count(doing.tools, "tool"),
4866
5020
  doing.subagents === 0 ? null : count(doing.subagents, "subagent"),
@@ -4925,13 +5079,23 @@ const spending = (turn, model) => [
4925
5079
  */
4926
5080
  const reviewOn = Effect.fn("review.reviewOn")(function* (options) {
4927
5081
  const { directory, launcher, model, turn } = options;
4928
- const run = yield* spinning(saying, (onTool) => reviewTurns({
5082
+ let doing = {
5083
+ tools: 0,
5084
+ subagents: 0
5085
+ };
5086
+ const run = yield* beating(saying(doing), (says) => reviewTurns({
4929
5087
  launcher,
4930
5088
  directory,
4931
5089
  turn,
4932
5090
  model,
4933
5091
  jsonSchema,
4934
- onTool
5092
+ onTool: (tool) => {
5093
+ doing = {
5094
+ tools: doing.tools + 1,
5095
+ subagents: doing.subagents + (tool === "Agent" ? 1 : 0)
5096
+ };
5097
+ return says(saying(doing), ` · ${tool}`);
5098
+ }
4935
5099
  }));
4936
5100
  const answered = Result.isFailure(run.findings) ? Effect.fail(run.findings.failure) : Effect.succeed(run.findings.success);
4937
5101
  const reported = yield* Effect.result(Effect.flatMap(answered, (output) => Schema.decodeUnknownEffect(Reported)(output)));
@@ -5014,10 +5178,7 @@ const review = Command.make("review", {
5014
5178
  commandOnly: commandOnlyFlag,
5015
5179
  force: forceFlag$1
5016
5180
  }, Effect.fn("review")(function* ({ command, commandOnly, effort, force, model, pr, prompt, promptOnly }) {
5017
- const file = Option.getOrElse(yield* read, () => ({}));
5018
- const { number, repo } = yield* named(pr, Object.keys(file.repos ?? {}).toSorted());
5019
- const settings = settingsFor(file, repo);
5020
- const launcher = launcherOf(file);
5181
+ const { number, repo, settings, launcher } = yield* forPr(pr);
5021
5182
  const asked = yield* asking({
5022
5183
  settings,
5023
5184
  command,
@@ -5091,7 +5252,7 @@ const review = Command.make("review", {
5091
5252
  yield* Console.log(`Recorded against ${short(ran.head)} in ${yield* stateDirectory}`);
5092
5253
  yield* unreported(run, number);
5093
5254
  }).pipe(Effect.onExit((exit) => announce("dw-mc review", `${repo}#${number} ${Exit.isSuccess(exit) ? "reviewed" : "could not be reviewed"}`)));
5094
- }, Effect.catchTag([...userFacing, "GitFailed"], asUserError))).pipe(Command.withDescription("Review one pull request on Claude Code, in a throwaway worktree"));
5255
+ }, Effect.catchTag(userFacingAndGit, asUserError))).pipe(Command.withDescription("Review one pull request on Claude Code, in a throwaway worktree"));
5095
5256
  //#endregion
5096
5257
  //#region src/cli/stamp.ts
5097
5258
  const withdrawFlag = Flag.Boolean("withdraw").pipe(Flag.withDefault(false), Flag.withDescription("Take the stamp off this pull request, until its head changes"));
@@ -5116,8 +5277,7 @@ const stampCommand = Command.make("stamp", {
5116
5277
  pr: prArgument,
5117
5278
  withdraw: withdrawFlag
5118
5279
  }, Effect.fn("stamp")(function* ({ pr, withdraw: byHand }) {
5119
- const file = Option.getOrElse(yield* read, () => ({}));
5120
- const { number, repo } = yield* named(pr, Object.keys(file.repos ?? {}).toSorted());
5280
+ const { number, repo } = yield* forPr(pr);
5121
5281
  const facts = yield* swept(repo, number);
5122
5282
  const where = `${repo}#${number} ${short(facts.head)}`;
5123
5283
  if (byHand) {
@@ -5157,7 +5317,7 @@ const lines = (grouped, stamped, paint) => {
5157
5317
  * It sweeps first, every time: a table I read is never one I forgot to refresh.
5158
5318
  */
5159
5319
  const status = Command.make("status", {}, Effect.fn("status")(function* () {
5160
- const report = yield* sweep;
5320
+ const report = yield* sweeping;
5161
5321
  if (report.repos.length === 0) {
5162
5322
  yield* Console.log("No repositories registered. Run dw-mc init inside a repository to register it.");
5163
5323
  return;
@@ -5249,7 +5409,7 @@ const uninstall = Command.make("uninstall", {
5249
5409
  * Running from source leaves the constant undeclared rather than undefined, so
5250
5410
  * the check has to be `typeof` and the fallback is what a test reads.
5251
5411
  */
5252
- const version = "0.4.0";
5412
+ const version = "0.5.1";
5253
5413
  /** Where the project lives, printed beside the version in the header. */
5254
5414
  const projectUrl = "github.com/dominikwozniak/dw-mc";
5255
5415
  const subcommands = [