dw-mc 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.js CHANGED
@@ -112,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
@@ -657,6 +667,71 @@ const tidy = Effect.fn("store.tidy")(function* (directory, upTo) {
657
667
  at = path.dirname(at);
658
668
  }
659
669
  });
670
+ //#endregion
671
+ //#region src/adapters/heartbeat.ts
672
+ /** The frames of the spinner, in the order they turn. */
673
+ const frames = [
674
+ "⠋",
675
+ "⠙",
676
+ "⠹",
677
+ "⠸",
678
+ "⠼",
679
+ "⠴",
680
+ "⠦",
681
+ "⠧",
682
+ "⠇",
683
+ "⠏"
684
+ ];
685
+ /** How long one frame is on the screen. */
686
+ const frameFor = Duration.millis(120);
687
+ /** A stretch of time as a terminal says it: `1m12s`, or `9s` under the minute. */
688
+ const elapsed = (millis) => {
689
+ const seconds = Math.floor(millis / 1e3);
690
+ return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, "0")}s`;
691
+ };
692
+ /**
693
+ * Runs `use` while one line says the work is still going, and hands `use` the
694
+ * way to say how that line reads.
695
+ *
696
+ * Work that takes seconds and prints nothing while it does is work I stop
697
+ * trusting. What the screen showed instead was either silence or a line per
698
+ * step, and a wall of `· Bash` says as little as silence did. This keeps one
699
+ * line and rewrites it: the spinner says the work is alive, the words say how
700
+ * far it has got, and the line is gone when the work is over, so what stays on
701
+ * the screen is the report.
702
+ *
703
+ * What the line counts is the command's and not this module's business. A
704
+ * review counts tools, a sweep counts pull requests, and a command reading two
705
+ * guards counts nothing at all - and a screen that worded any of them here
706
+ * would need the words a command already has.
707
+ *
708
+ * Where there is no screen to measure - a pipe, a CI log, a test - a rewritten
709
+ * line would be a mess of half-drawn ones, so nothing is drawn. What goes out
710
+ * instead is whatever `aside` the command gives, one to a line, and where it
711
+ * gives none the output is what it was before there was a heartbeat at all.
712
+ * `columns` is zero exactly there.
713
+ */
714
+ const beating = Effect.fnUntraced(function* (from, use) {
715
+ const terminal = yield* Terminal.Terminal;
716
+ const columns = yield* terminal.columns;
717
+ if (columns === 0) return yield* use((_, aside) => aside === void 0 ? Effect.void : Console.log(aside));
718
+ const started = yield* Clock.currentTimeMillis;
719
+ const draw = (text) => Effect.ignore(terminal.display(`\r${text.slice(0, columns - 1).padEnd(columns - 1)}`));
720
+ let reads = from;
721
+ let at = 0;
722
+ /** The line as it stands: this frame of the spinner, and the latest wording. */
723
+ const paint = Effect.flatMap(Clock.currentTimeMillis, (now) => draw(`${frames[at % frames.length]} ${reads(elapsed(now - started))}`));
724
+ const says = (next) => Effect.andThen(Effect.sync(() => void (reads = next)), paint);
725
+ yield* paint;
726
+ const beat = yield* Effect.forkChild(Effect.gen(function* () {
727
+ for (;;) {
728
+ yield* Effect.sleep(frameFor);
729
+ at = at + 1;
730
+ yield* paint;
731
+ }
732
+ }));
733
+ return yield* Effect.onExit(use(says), () => Effect.flatMap(Fiber.interrupt(beat), () => Effect.ignore(terminal.display(`\r${" ".repeat(columns - 1)}\r`))));
734
+ });
660
735
  new TextEncoder();
661
736
  /** A program that ran but ended badly. */
662
737
  var CommandFailed = class extends Schema.TaggedError()("CommandFailed", {
@@ -739,41 +814,56 @@ var WorktreeHeld = class extends Schema.TaggedError()("WorktreeHeld", {
739
814
  * The head comes from the pull request's ref rather than from what a sweep last
740
815
  * saw, so what is cut is the commit the run really reads.
741
816
  */
817
+ /**
818
+ * How the heartbeat of a cut reads.
819
+ *
820
+ * The stages are named apart because a first clone and a hundredth fetch take
821
+ * wildly different times, and the line is what explains the difference: a
822
+ * `cloning` that sits there for two minutes is a large repository arriving
823
+ * once, not a tool that has hung.
824
+ */
825
+ const cutting = (what, repo) => (since) => `${what} ${repo} · ${since}`;
742
826
  const whereToCut = Effect.fn("git.whereToCut")(function* (repo, number, cut) {
743
827
  const path = yield* Path.Path;
744
828
  const state = yield* stateDirectory;
745
829
  const clone = path.join(state, clonesIn, `${repo}.git`);
746
- if ((yield* Effect.orElseSucceed(git([
830
+ const bare = yield* Effect.orElseSucceed(git([
747
831
  "-C",
748
832
  clone,
749
833
  "rev-parse",
750
834
  "--is-bare-repository"
751
- ]), () => "")) !== "true") yield* git([
752
- "clone",
753
- "--bare",
754
- "--filter=blob:none",
755
- `https://github.com/${repo}.git`,
756
- clone
757
- ]);
835
+ ]), () => "");
758
836
  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
- ]);
769
837
  return {
770
838
  clone,
771
- head: yield* git([
772
- "-C",
773
- clone,
774
- "rev-parse",
775
- pullRef
776
- ]),
839
+ head: yield* beating(cutting(bare === "true" ? "fetching" : "cloning", repo), (says) => Effect.gen(function* () {
840
+ if (bare !== "true") {
841
+ yield* git([
842
+ "clone",
843
+ "--bare",
844
+ "--filter=blob:none",
845
+ `https://github.com/${repo}.git`,
846
+ clone
847
+ ]);
848
+ yield* says(cutting("fetching", repo));
849
+ }
850
+ yield* git([
851
+ "-C",
852
+ clone,
853
+ "fetch",
854
+ "--no-tags",
855
+ "--force",
856
+ "origin",
857
+ `+refs/pull/${number}/head:${pullRef}`,
858
+ "+refs/heads/*:refs/heads/*"
859
+ ]);
860
+ return yield* git([
861
+ "-C",
862
+ clone,
863
+ "rev-parse",
864
+ pullRef
865
+ ]);
866
+ })),
777
867
  directory: path.join(state, cut, repo, String(number))
778
868
  };
779
869
  });
@@ -798,7 +888,7 @@ const withWorktree = Effect.fn("git.withWorktree")(function* (repo, number, use)
798
888
  "--force",
799
889
  directory
800
890
  ]));
801
- return yield* Effect.acquireUseRelease(Effect.flatMap(remove, () => git([
891
+ return yield* Effect.acquireUseRelease(beating(cutting("cutting a worktree of", repo), () => Effect.flatMap(remove, () => git([
802
892
  "-C",
803
893
  clone,
804
894
  "worktree",
@@ -806,7 +896,7 @@ const withWorktree = Effect.fn("git.withWorktree")(function* (repo, number, use)
806
896
  "--detach",
807
897
  directory,
808
898
  head
809
- ])), () => use({
899
+ ]))), () => use({
810
900
  directory,
811
901
  head
812
902
  }), () => remove);
@@ -950,7 +1040,7 @@ const standingWorktree = Effect.fn("git.standingWorktree")(function* (repo, numb
950
1040
  ]);
951
1041
  yield* perWorktreeConfig(clone);
952
1042
  if (session === "rebase") yield* reuseResolutions(clone);
953
- yield* git([
1043
+ yield* beating(cutting("cutting a worktree of", repo), () => git([
954
1044
  "-C",
955
1045
  clone,
956
1046
  "worktree",
@@ -959,7 +1049,7 @@ const standingWorktree = Effect.fn("git.standingWorktree")(function* (repo, numb
959
1049
  branch,
960
1050
  directory,
961
1051
  head
962
- ]);
1052
+ ]));
963
1053
  yield* git([
964
1054
  "-C",
965
1055
  clone,
@@ -1482,7 +1572,7 @@ const lines$3 = (it, state, path, paint) => {
1482
1572
  const cleanup = Command.make("cleanup", { yes: yesFlag }, Effect.fn("cleanup")(function* ({ yes }) {
1483
1573
  const path = yield* Path.Path;
1484
1574
  const paint = yield* Paint;
1485
- const found = yield* inventory;
1575
+ const found = yield* beating((since) => `measuring the state directory · ${since}`, () => inventory);
1486
1576
  const it = plan(found);
1487
1577
  if (empty(it)) {
1488
1578
  yield* Console.log(`Nothing to take back in ${found.directory}.`);
@@ -1925,7 +2015,15 @@ const isSame = (self, other) => self === null || other === null ? self === other
1925
2015
  /** The latest of many, or never when there are none. */
1926
2016
  const newest = (moments) => moments.reduce(later, null);
1927
2017
  //#endregion
1928
- //#region src/domain/bucket.ts
2018
+ //#region src/terms/pr.ts
2019
+ /**
2020
+ * What GitHub says about a pull request, in this tool's words.
2021
+ *
2022
+ * The three of them are here because both sides need the same one: `gh` and the
2023
+ * checks adapter answer in these words, and the bucket rules decide on them. A
2024
+ * union restated on each side is a case that goes unreachable the day the other
2025
+ * side gains a member.
2026
+ */
1929
2027
  /** How far GitHub has got towards letting a tracked PR merge. */
1930
2028
  const Mergeability = Schema.Literals([
1931
2029
  "mergeable",
@@ -1946,6 +2044,8 @@ const ChecksState = Schema.Literals([
1946
2044
  "pending",
1947
2045
  "none"
1948
2046
  ]);
2047
+ //#endregion
2048
+ //#region src/domain/bucket.ts
1949
2049
  /**
1950
2050
  * Everything the bucket rules are allowed to know about a tracked PR.
1951
2051
  *
@@ -2161,6 +2261,20 @@ const swept = Effect.fn("pr.swept")(function* (repo, number) {
2161
2261
  if (Option.isNone(facts)) return yield* new CliError.UserError({ cause: `Nothing is known about ${repo}#${number} yet. Run dw-mc sweep first.` });
2162
2262
  return facts.value;
2163
2263
  });
2264
+ /**
2265
+ * The guard reads of one command, under a heartbeat.
2266
+ *
2267
+ * Every command that acts on a pull request reads its guards live rather than
2268
+ * off the last sweep, because each of them is about the pull request as it is
2269
+ * now. That read is a second or two against GitHub before a word can be
2270
+ * printed, and it used to be spent on a blank screen.
2271
+ *
2272
+ * There is nothing to count here - two or three calls, and a number counting to
2273
+ * three says less than the words do - so the line is what is being read and how
2274
+ * long it has taken. It gives the heartbeat no aside, so a piped command prints
2275
+ * what it always printed.
2276
+ */
2277
+ const reading = (where, read) => beating((since) => `reading ${where} · ${since}`, () => read);
2164
2278
  //#endregion
2165
2279
  //#region src/cli/row.ts
2166
2280
  /**
@@ -2885,7 +2999,7 @@ const short = (head) => head.slice(0, 7);
2885
2999
  * Where a run is kept: one key per head, so a run and the code it read cannot
2886
3000
  * drift apart, and a re-review replaces the run before it.
2887
3001
  */
2888
- const runKey = (repo, number, head) => `${repo}#${number}@${head}`;
3002
+ const runKey = (repo, number, head) => `${prKey(repo, number)}@${head}`;
2889
3003
  /** Where the run's report is kept: beside the run, as the Markdown it is. */
2890
3004
  const reportKey = (repo, number, head) => `${runKey(repo, number, head)}.md`;
2891
3005
  /**
@@ -2899,7 +3013,7 @@ const reportKey = (repo, number, head) => `${runKey(repo, number, head)}.md`;
2899
3013
  */
2900
3014
  const LastReviewed = Schema.Struct({ head: Schema.String });
2901
3015
  /** Where that head is kept. No head is spelled `latest`, so nothing collides. */
2902
- const latestKey = (repo, number) => `${repo}#${number}@latest`;
3016
+ const latestKey = (repo, number) => `${prKey(repo, number)}@latest`;
2903
3017
  /**
2904
3018
  * The run at one head, or none where nothing has reviewed it.
2905
3019
  *
@@ -3010,11 +3124,11 @@ const blockingIn = (run, blocksOn) => {
3010
3124
  * the second exists to land what the first only describes: two spellings of
3011
3125
  * this would be two answers to whether a head has been reviewed.
3012
3126
  */
3013
- const reviewedAt = Effect.fn("review.reviewedAt")(function* (repo, number, head, settings) {
3127
+ const reviewedAt = Effect.fn("review.reviewedAt")(function* (repo, number, head, blocksOn) {
3014
3128
  const run = Option.getOrNull(yield* runAt(repo, number, head));
3015
3129
  return {
3016
3130
  reviewRunHead: reviewedBy(run) ? head : null,
3017
- blockingFindings: blockingIn(run, settings.stamp.blocks_on).length
3131
+ blockingFindings: blockingIn(run, blocksOn).length
3018
3132
  };
3019
3133
  });
3020
3134
  //#endregion
@@ -3037,7 +3151,7 @@ const sweepPr = Effect.fn("sweep.pullRequest")(function* (store, me, found, sett
3037
3151
  const newestHumanCommentAt = newest(byHumansOtherThan(comments, me));
3038
3152
  const key = prKey(found.repo, found.number);
3039
3153
  const previous = Option.getOrUndefined(yield* Effect.orElseSucceed(store.get(key), () => Option.none()));
3040
- const reviewed = yield* reviewedAt(found.repo, found.number, view.headRefOid, settings);
3154
+ const reviewed = yield* reviewedAt(found.repo, found.number, view.headRefOid, settings.stamp.blocks_on);
3041
3155
  const quiet = previous !== void 0 && isQuiet(pulseOf(previous), {
3042
3156
  head: view.headRefOid,
3043
3157
  checks,
@@ -3083,14 +3197,26 @@ const gather = (attempts) => ({
3083
3197
  });
3084
3198
  /** How many reads of GitHub are in flight at once. */
3085
3199
  const concurrency = 4;
3200
+ /** `n` repositories, which `count` cannot say: the plural is not the noun plus s. */
3201
+ const repositories = (n) => n === 1 ? "1 repository" : `${n} repositories`;
3202
+ /** How the heartbeat of a sweep reads, wherever a command turns one. */
3203
+ const saying$1 = (swept) => (since) => [
3204
+ "sweeping",
3205
+ swept._tag === "searching" ? `${swept.done} of ${repositories(swept.of)}` : `${swept.done} of ${count(swept.of, "pull request")}`,
3206
+ since
3207
+ ].join(" · ");
3086
3208
  /**
3087
3209
  * One pass over every tracked PR, and nothing else: a sweep only ever reads.
3088
3210
  *
3089
3211
  * Every repository and every pull request is read on its own, so one of them
3090
3212
  * failing costs me its rows and leaves the rest of the table standing. What
3091
3213
  * failed comes back beside the facts rather than instead of them.
3214
+ *
3215
+ * `report` is told how far the pass has got, every time it gets further. What
3216
+ * that is worth saying is the caller's, which is why it is handed a count and
3217
+ * not a sentence.
3092
3218
  */
3093
- const sweep = Effect.gen(function* () {
3219
+ const sweep = Effect.fn("sweep")(function* (report) {
3094
3220
  const file = Option.getOrElse(yield* read, () => ({}));
3095
3221
  const repos = Object.keys(file.repos ?? {}).toSorted();
3096
3222
  if (repos.length === 0) return {
@@ -3100,14 +3226,48 @@ const sweep = Effect.gen(function* () {
3100
3226
  };
3101
3227
  const store = yield* storeFor("prs", Facts);
3102
3228
  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 }));
3229
+ let searched = 0;
3230
+ yield* report({
3231
+ _tag: "searching",
3232
+ done: 0,
3233
+ of: repos.length
3234
+ });
3235
+ const found = gather(yield* Effect.forEach(repos, (repo) => Effect.tap(attempt(repo, searchPrs(repo)), () => {
3236
+ searched = searched + 1;
3237
+ return report({
3238
+ _tag: "searching",
3239
+ done: searched,
3240
+ of: repos.length
3241
+ });
3242
+ }), { concurrency }));
3243
+ let read$1 = 0;
3244
+ yield* report({
3245
+ _tag: "reading",
3246
+ done: 0,
3247
+ of: found.got.length
3248
+ });
3249
+ const swept = gather(yield* Effect.forEach(found.got, (pr) => Effect.tap(attempt(`${pr.repo}#${pr.number}`, Effect.map(sweepPr(store, me, pr, settingsFor(file, pr.repo)), (facts) => [facts])), () => {
3250
+ read$1 = read$1 + 1;
3251
+ return report({
3252
+ _tag: "reading",
3253
+ done: read$1,
3254
+ of: found.got.length
3255
+ });
3256
+ }), { concurrency }));
3105
3257
  return {
3106
3258
  repos,
3107
3259
  facts: swept.got,
3108
3260
  troubles: [...found.troubles, ...swept.troubles]
3109
3261
  };
3110
- }).pipe(Effect.withSpan("sweep"));
3262
+ });
3263
+ /**
3264
+ * A sweep under its heartbeat, which is how every command that sweeps runs one.
3265
+ *
3266
+ * The three of them want the same line, so they say it once here rather than
3267
+ * three times over. It gives the heartbeat no aside, so a piped `dw-mc status`
3268
+ * prints exactly what it printed before there was a heartbeat at all.
3269
+ */
3270
+ const sweeping = beating((since) => `sweeping · ${since}`, (says) => sweep((swept) => says(saying$1(swept))));
3111
3271
  /**
3112
3272
  * The failures a sweep can hit before it has a single row, which are the ones
3113
3273
  * worth a sentence: a machine or a file that needs fixing says what to fix
@@ -3135,7 +3295,7 @@ const printTroubles = Effect.fn("sweep.printTroubles")(function* (troubles) {
3135
3295
  * warming the state directory, or seeing what GitHub would not answer.
3136
3296
  */
3137
3297
  const sweepCommand = Command.make("sweep", {}, Effect.fn("sweep.command")(function* () {
3138
- const report = yield* sweep;
3298
+ const report = yield* sweeping;
3139
3299
  yield* Console.log(report.repos.length === 0 ? "No repositories registered. Run dw-mc init inside a repository to register it." : `Swept ${count(report.facts.length, "pull request")} across ${report.repos.length === 1 ? "1 repository" : `${report.repos.length} repositories`}`);
3140
3300
  yield* printTroubles(report.troubles);
3141
3301
  }, Effect.catchTag(userFacing, asUserError))).pipe(Command.withDescription("Refresh what mission control knows about every tracked pull request"));
@@ -3256,7 +3416,7 @@ const comments = Command.make("comments", {
3256
3416
  const { number, repo } = yield* named(pr, Object.keys(file.repos ?? {}).toSorted());
3257
3417
  const facts = yield* swept(repo, number);
3258
3418
  const paint = yield* Paint;
3259
- const view = shown(yield* prConversation(repo, number), {
3419
+ const view = shown(yield* reading(`${repo}#${number}`, prConversation(repo, number)), {
3260
3420
  since: later(facts.myLastCommentAt, facts.myLastCommitAt),
3261
3421
  all
3262
3422
  });
@@ -3842,7 +4002,7 @@ const fix = Command.make("fix", {
3842
4002
  const found = yield* whatItFound(run);
3843
4003
  yield* Console.log(header$1(run, found, settings.stamp.blocks_on));
3844
4004
  if (found.findings.length === 0) return;
3845
- const view = yield* prView(repo, number);
4005
+ const view = yield* reading(`${repo}#${number}`, prView(repo, number));
3846
4006
  yield* fixable(number, run.head, view.headRefOid);
3847
4007
  const picked = yield* choose("Which findings does the session carry?", choicesOf$1(found, yield* width));
3848
4008
  const chosen = yield* Effect.catchTag(noted(Option.getOrElse(picked, () => [])), "QuitError", () => Effect.succeed([]));
@@ -4132,8 +4292,7 @@ const merge = Command.make("merge", { pr: prArgument }, Effect.fn("merge")(funct
4132
4292
  const file = Option.getOrElse(yield* read, () => ({}));
4133
4293
  const { number, repo } = yield* named(pr, Object.keys(file.repos ?? {}).toSorted());
4134
4294
  const settings = settingsFor(file, repo);
4135
- const view = yield* prView(repo, number);
4136
- const me = yield* viewer;
4295
+ const [view, me] = yield* reading(`${repo}#${number}`, Effect.all([prView(repo, number), viewer]));
4137
4296
  const head = view.headRefOid;
4138
4297
  yield* refuse(decide$2({
4139
4298
  repo,
@@ -4144,7 +4303,7 @@ const merge = Command.make("merge", { pr: prArgument }, Effect.fn("merge")(funct
4144
4303
  reviewDecision: reviewDecisionOf(view.reviewDecision),
4145
4304
  checks: rollupState(view.statusCheckRollup, settings.ci.ignore),
4146
4305
  mergeable: mergeabilityOf(view.mergeable),
4147
- ...yield* reviewedAt(repo, number, head, settings),
4306
+ ...yield* reviewedAt(repo, number, head, settings.stamp.blocks_on),
4148
4307
  withdrawnAt: yield* withdrawnAt(repo, number)
4149
4308
  }));
4150
4309
  yield* mergePr(repo, number);
@@ -4370,7 +4529,7 @@ const where = (facts) => `${facts.repo}#${facts.number}`;
4370
4529
  * and a merge must never cost only that (ADR 0008).
4371
4530
  */
4372
4531
  const picker = (dispatch) => Effect.fn("pick")(function* () {
4373
- const report = yield* sweep;
4532
+ const report = yield* sweeping;
4374
4533
  if (report.repos.length === 0) {
4375
4534
  yield* Console.log("No repositories registered. Run dw-mc init inside a repository to register it.");
4376
4535
  return;
@@ -4436,9 +4595,11 @@ const rebase = Command.make("rebase", { pr: prArgument }, Effect.fn("rebase")(fu
4436
4595
  const file = Option.getOrElse(yield* read, () => ({}));
4437
4596
  const { number, repo } = yield* named(pr, Object.keys(file.repos ?? {}).toSorted());
4438
4597
  const settings = settingsFor(file, repo);
4439
- const view = yield* prView(repo, number);
4440
- const open = yield* openPrs(repo);
4441
- const me = yield* viewer;
4598
+ const [view, open, me] = yield* reading(`${repo}#${number}`, Effect.all([
4599
+ prView(repo, number),
4600
+ openPrs(repo),
4601
+ viewer
4602
+ ]));
4442
4603
  yield* refuse(decide$3({
4443
4604
  repo,
4444
4605
  number,
@@ -4497,8 +4658,7 @@ const rerun = Command.make("rerun", { pr: prArgument }, Effect.fn("rerun")(funct
4497
4658
  const file = Option.getOrElse(yield* read, () => ({}));
4498
4659
  const { number, repo } = yield* named(pr, Object.keys(file.repos ?? {}).toSorted());
4499
4660
  const settings = settingsFor(file, repo);
4500
- const view = yield* prView(repo, number);
4501
- const me = yield* viewer;
4661
+ const [view, me] = yield* reading(`${repo}#${number}`, Effect.all([prView(repo, number), viewer]));
4502
4662
  const unclassified = {
4503
4663
  repo,
4504
4664
  number,
@@ -4616,9 +4776,11 @@ const resolve = Command.make("resolve", {
4616
4776
  }, Effect.fn("resolve")(function* ({ pr, print }) {
4617
4777
  const file = Option.getOrElse(yield* read, () => ({}));
4618
4778
  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;
4779
+ const [view, open, me] = yield* reading(`${repo}#${number}`, Effect.all([
4780
+ prView(repo, number),
4781
+ openPrs(repo),
4782
+ viewer
4783
+ ]));
4622
4784
  const conflict = yield* conflictFor(repo, number);
4623
4785
  yield* allowed({
4624
4786
  repo,
@@ -4701,73 +4863,6 @@ const announce = Effect.fn("notify.announce")(function* (title, message) {
4701
4863
  yield* Effect.ignore(capture("osascript", ["-e", `display notification ${quoted(message)} with title ${quoted(title)}`]));
4702
4864
  });
4703
4865
  //#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
4866
  //#region src/domain/persona.ts
4772
4867
  /**
4773
4868
  * The reviewer persona, derived from Addy Osmani's `code-reviewer` agent
@@ -4859,8 +4954,8 @@ const turnFor = (review, about) => review.command === null ? {
4859
4954
  };
4860
4955
  //#endregion
4861
4956
  //#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) => [
4957
+ /** What the heartbeat says a run has got through, while it is still going. */
4958
+ const saying = (doing) => (since) => [
4864
4959
  "reviewing",
4865
4960
  count(doing.tools, "tool"),
4866
4961
  doing.subagents === 0 ? null : count(doing.subagents, "subagent"),
@@ -4925,13 +5020,23 @@ const spending = (turn, model) => [
4925
5020
  */
4926
5021
  const reviewOn = Effect.fn("review.reviewOn")(function* (options) {
4927
5022
  const { directory, launcher, model, turn } = options;
4928
- const run = yield* spinning(saying, (onTool) => reviewTurns({
5023
+ let doing = {
5024
+ tools: 0,
5025
+ subagents: 0
5026
+ };
5027
+ const run = yield* beating(saying(doing), (says) => reviewTurns({
4929
5028
  launcher,
4930
5029
  directory,
4931
5030
  turn,
4932
5031
  model,
4933
5032
  jsonSchema,
4934
- onTool
5033
+ onTool: (tool) => {
5034
+ doing = {
5035
+ tools: doing.tools + 1,
5036
+ subagents: doing.subagents + (tool === "Agent" ? 1 : 0)
5037
+ };
5038
+ return says(saying(doing), ` · ${tool}`);
5039
+ }
4935
5040
  }));
4936
5041
  const answered = Result.isFailure(run.findings) ? Effect.fail(run.findings.failure) : Effect.succeed(run.findings.success);
4937
5042
  const reported = yield* Effect.result(Effect.flatMap(answered, (output) => Schema.decodeUnknownEffect(Reported)(output)));
@@ -5157,7 +5262,7 @@ const lines = (grouped, stamped, paint) => {
5157
5262
  * It sweeps first, every time: a table I read is never one I forgot to refresh.
5158
5263
  */
5159
5264
  const status = Command.make("status", {}, Effect.fn("status")(function* () {
5160
- const report = yield* sweep;
5265
+ const report = yield* sweeping;
5161
5266
  if (report.repos.length === 0) {
5162
5267
  yield* Console.log("No repositories registered. Run dw-mc init inside a repository to register it.");
5163
5268
  return;
@@ -5249,7 +5354,7 @@ const uninstall = Command.make("uninstall", {
5249
5354
  * Running from source leaves the constant undeclared rather than undefined, so
5250
5355
  * the check has to be `typeof` and the fallback is what a test reads.
5251
5356
  */
5252
- const version = "0.4.0";
5357
+ const version = "0.5.0";
5253
5358
  /** Where the project lives, printed beside the version in the header. */
5254
5359
  const projectUrl = "github.com/dominikwozniak/dw-mc";
5255
5360
  const subcommands = [