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