@quickchatai/quickreview 1.1.0 → 1.2.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.
Files changed (225) hide show
  1. package/README.md +35 -19
  2. package/config.example.toml +41 -9
  3. package/dist/extensions/cli/login-route.js +5 -0
  4. package/dist/extensions/cli/login-route.js.map +1 -0
  5. package/dist/extensions/login-route.js +180 -0
  6. package/dist/extensions/login-route.js.map +1 -0
  7. package/dist/extensions/onboarding.js +29 -6
  8. package/dist/extensions/onboarding.js.map +1 -1
  9. package/dist/src/ciretry.js +2 -1
  10. package/dist/src/ciretry.js.map +1 -1
  11. package/dist/src/cli.js +43 -12
  12. package/dist/src/cli.js.map +1 -1
  13. package/dist/src/cliops.js +3 -2
  14. package/dist/src/cliops.js.map +1 -1
  15. package/dist/src/commands.js +25 -2
  16. package/dist/src/commands.js.map +1 -1
  17. package/dist/src/config.js +16 -2
  18. package/dist/src/config.js.map +1 -1
  19. package/dist/src/daemon.js +24 -8
  20. package/dist/src/daemon.js.map +1 -1
  21. package/dist/src/debugroundpi.js +5 -1
  22. package/dist/src/debugroundpi.js.map +1 -1
  23. package/dist/src/dispatch.js +5 -4
  24. package/dist/src/dispatch.js.map +1 -1
  25. package/dist/src/engine/debugpi.js +17 -5
  26. package/dist/src/engine/debugpi.js.map +1 -1
  27. package/dist/src/execboundary.js +10 -3
  28. package/dist/src/execboundary.js.map +1 -1
  29. package/dist/src/github.js +51 -16
  30. package/dist/src/github.js.map +1 -1
  31. package/dist/src/githubtoken.js +346 -0
  32. package/dist/src/githubtoken.js.map +1 -0
  33. package/dist/src/loginhint.js +8 -3
  34. package/dist/src/loginhint.js.map +1 -1
  35. package/dist/src/loginrefresh/pirefresh.js +3 -0
  36. package/dist/src/loginrefresh/pirefresh.js.map +1 -1
  37. package/dist/src/npmverbs.js +4 -2
  38. package/dist/src/npmverbs.js.map +1 -1
  39. package/dist/src/onboard/choices.js +29 -1
  40. package/dist/src/onboard/choices.js.map +1 -1
  41. package/dist/src/onboard/engine.js +8 -23
  42. package/dist/src/onboard/engine.js.map +1 -1
  43. package/dist/src/onboard/launch.js +12 -20
  44. package/dist/src/onboard/launch.js.map +1 -1
  45. package/dist/src/onboard/pipin.js +22 -0
  46. package/dist/src/onboard/pipin.js.map +1 -0
  47. package/dist/src/onboard/stepforward.js +41 -0
  48. package/dist/src/onboard/stepforward.js.map +1 -0
  49. package/dist/src/onboard/steps/appslug.js +4 -1
  50. package/dist/src/onboard/steps/appslug.js.map +1 -1
  51. package/dist/src/onboard/steps/compose.js +29 -0
  52. package/dist/src/onboard/steps/compose.js.map +1 -1
  53. package/dist/src/onboard/steps/configfill.js +102 -32
  54. package/dist/src/onboard/steps/configfill.js.map +1 -1
  55. package/dist/src/onboard/steps/githubaccount.js +595 -0
  56. package/dist/src/onboard/steps/githubaccount.js.map +1 -0
  57. package/dist/src/onboard/steps/githubapp.js +65 -7
  58. package/dist/src/onboard/steps/githubapp.js.map +1 -1
  59. package/dist/src/onboard/steps/golive.js +2 -0
  60. package/dist/src/onboard/steps/golive.js.map +1 -1
  61. package/dist/src/onboard/steps/index.js +11 -0
  62. package/dist/src/onboard/steps/index.js.map +1 -1
  63. package/dist/src/onboard/steps/ingress.js +80 -25
  64. package/dist/src/onboard/steps/ingress.js.map +1 -1
  65. package/dist/src/onboard/steps/llmauth.js +103 -30
  66. package/dist/src/onboard/steps/llmauth.js.map +1 -1
  67. package/dist/src/onboard/steps/memory.js +53 -1
  68. package/dist/src/onboard/steps/memory.js.map +1 -1
  69. package/dist/src/onboard/steps/proof.js +366 -56
  70. package/dist/src/onboard/steps/proof.js.map +1 -1
  71. package/dist/src/onboard/steps/topology.js +6 -6
  72. package/dist/src/onboard/steps/topology.js.map +1 -1
  73. package/dist/src/onboard/steps/transport.js +9 -1
  74. package/dist/src/onboard/steps/transport.js.map +1 -1
  75. package/dist/src/onboard/steps/tuiroute.js +102 -0
  76. package/dist/src/onboard/steps/tuiroute.js.map +1 -0
  77. package/dist/src/onboard/steps/verifyup.js +17 -1
  78. package/dist/src/onboard/steps/verifyup.js.map +1 -1
  79. package/dist/src/onboard/toolnames.js +30 -0
  80. package/dist/src/onboard/toolnames.js.map +1 -0
  81. package/dist/src/operatorwrite.js +23 -0
  82. package/dist/src/operatorwrite.js.map +1 -0
  83. package/dist/src/outcomes.js +4 -1
  84. package/dist/src/outcomes.js.map +1 -1
  85. package/dist/src/posting-journal.js +2 -1
  86. package/dist/src/posting-journal.js.map +1 -1
  87. package/dist/src/posting-outbox.js +6 -2
  88. package/dist/src/posting-outbox.js.map +1 -1
  89. package/dist/src/posting-receipt.js +14 -1
  90. package/dist/src/posting-receipt.js.map +1 -1
  91. package/dist/src/recheck.js +2 -1
  92. package/dist/src/recheck.js.map +1 -1
  93. package/dist/src/reconcile.js +22 -2
  94. package/dist/src/reconcile.js.map +1 -1
  95. package/dist/src/reviewhistory.js +32 -6
  96. package/dist/src/reviewhistory.js.map +1 -1
  97. package/dist/src/reviewnow.js +45 -0
  98. package/dist/src/reviewnow.js.map +1 -0
  99. package/dist/src/state.js.map +1 -1
  100. package/dist/src/statemachine.js +4 -1
  101. package/dist/src/statemachine.js.map +1 -1
  102. package/dist/src/takeoverpi.js +13 -0
  103. package/dist/src/takeoverpi.js.map +1 -1
  104. package/dist/src/tui/app.js +49 -0
  105. package/dist/src/tui/app.js.map +1 -1
  106. package/dist/src/tui/board.js +88 -12
  107. package/dist/src/tui/board.js.map +1 -1
  108. package/dist/src/tui/cmdbar.js +4 -1
  109. package/dist/src/tui/cmdbar.js.map +1 -1
  110. package/dist/src/tui/companion.js +74 -10
  111. package/dist/src/tui/companion.js.map +1 -1
  112. package/dist/src/tui/connect.js +7 -1
  113. package/dist/src/tui/connect.js.map +1 -1
  114. package/dist/src/tui/filters.js +5 -2
  115. package/dist/src/tui/filters.js.map +1 -1
  116. package/dist/src/tui/ghread.js +61 -5
  117. package/dist/src/tui/ghread.js.map +1 -1
  118. package/dist/src/tui/index.js +106 -20
  119. package/dist/src/tui/index.js.map +1 -1
  120. package/dist/src/tui/memforward.js +39 -2
  121. package/dist/src/tui/memforward.js.map +1 -1
  122. package/dist/src/tui/model.js +25 -0
  123. package/dist/src/tui/model.js.map +1 -1
  124. package/dist/src/tui/prpause.js +7 -0
  125. package/dist/src/tui/prpause.js.map +1 -1
  126. package/dist/src/tui/remote.js +7 -1
  127. package/dist/src/tui/remote.js.map +1 -1
  128. package/dist/src/tui/route.js +143 -0
  129. package/dist/src/tui/route.js.map +1 -0
  130. package/dist/src/tui/setupstate.js +29 -0
  131. package/dist/src/tui/setupstate.js.map +1 -0
  132. package/dist/src/tui/sshtriage.js +102 -5
  133. package/dist/src/tui/sshtriage.js.map +1 -1
  134. package/dist/src/tui/theme.js +2 -1
  135. package/dist/src/tui/theme.js.map +1 -1
  136. package/dist/src/tui/unwedge.js +2 -12
  137. package/dist/src/tui/unwedge.js.map +1 -1
  138. package/dist/src/verdicts.js +17 -5
  139. package/dist/src/verdicts.js.map +1 -1
  140. package/dist/src/version.js +1 -1
  141. package/dist/src/version.js.map +1 -1
  142. package/dist/src/webhook.js +12 -1
  143. package/dist/src/webhook.js.map +1 -1
  144. package/dist/src/wire.js +28 -6
  145. package/dist/src/wire.js.map +1 -1
  146. package/dist/src/workerworld.js +7 -1
  147. package/dist/src/workerworld.js.map +1 -1
  148. package/docker/companion/entrypoint.sh +20 -1
  149. package/docker/entrypoint.sh +17 -0
  150. package/extensions/cli/login-route.ts +10 -0
  151. package/extensions/login-route.ts +220 -0
  152. package/extensions/onboarding.ts +35 -6
  153. package/package.json +2 -2
  154. package/prompts/onboarding.md +57 -18
  155. package/scripts/mint-token.sh +25 -4
  156. package/src/ciretry.ts +2 -1
  157. package/src/cli.ts +49 -14
  158. package/src/cliops.ts +3 -2
  159. package/src/commands.ts +28 -2
  160. package/src/config.ts +24 -4
  161. package/src/daemon.ts +32 -7
  162. package/src/debugroundpi.ts +11 -1
  163. package/src/dispatch.ts +5 -4
  164. package/src/engine/debugpi.ts +17 -5
  165. package/src/execboundary.ts +16 -4
  166. package/src/github.ts +57 -15
  167. package/src/githubtoken.ts +435 -0
  168. package/src/loginhint.ts +8 -3
  169. package/src/loginrefresh/pirefresh.ts +3 -0
  170. package/src/npmverbs.ts +4 -2
  171. package/src/onboard/choices.ts +34 -1
  172. package/src/onboard/engine.ts +22 -3
  173. package/src/onboard/launch.ts +17 -29
  174. package/src/onboard/pipin.ts +31 -0
  175. package/src/onboard/stepforward.ts +61 -0
  176. package/src/onboard/steps/appslug.ts +4 -1
  177. package/src/onboard/steps/compose.ts +31 -0
  178. package/src/onboard/steps/configfill.ts +121 -33
  179. package/src/onboard/steps/githubaccount.ts +716 -0
  180. package/src/onboard/steps/githubapp.ts +92 -8
  181. package/src/onboard/steps/golive.ts +2 -0
  182. package/src/onboard/steps/index.ts +11 -0
  183. package/src/onboard/steps/ingress.ts +98 -31
  184. package/src/onboard/steps/llmauth.ts +114 -29
  185. package/src/onboard/steps/memory.ts +58 -1
  186. package/src/onboard/steps/proof.ts +473 -55
  187. package/src/onboard/steps/topology.ts +7 -6
  188. package/src/onboard/steps/transport.ts +15 -0
  189. package/src/onboard/steps/tuiroute.ts +119 -0
  190. package/src/onboard/steps/verifyup.ts +20 -1
  191. package/src/onboard/toolnames.ts +34 -0
  192. package/src/operatorwrite.ts +25 -0
  193. package/src/outcomes.ts +8 -1
  194. package/src/posting-journal.ts +6 -1
  195. package/src/posting-outbox.ts +10 -2
  196. package/src/posting-receipt.ts +17 -0
  197. package/src/recheck.ts +2 -1
  198. package/src/reconcile.ts +29 -3
  199. package/src/reviewhistory.ts +20 -6
  200. package/src/reviewnow.ts +76 -0
  201. package/src/state.ts +18 -0
  202. package/src/statemachine.ts +4 -1
  203. package/src/takeoverpi.ts +13 -0
  204. package/src/tui/app.ts +47 -0
  205. package/src/tui/board.ts +111 -14
  206. package/src/tui/cmdbar.ts +4 -1
  207. package/src/tui/companion.ts +78 -10
  208. package/src/tui/connect.ts +7 -1
  209. package/src/tui/filters.ts +7 -3
  210. package/src/tui/ghread.ts +74 -7
  211. package/src/tui/index.ts +114 -23
  212. package/src/tui/memforward.ts +52 -3
  213. package/src/tui/model.ts +36 -2
  214. package/src/tui/prpause.ts +11 -2
  215. package/src/tui/remote.ts +9 -1
  216. package/src/tui/route.ts +173 -0
  217. package/src/tui/setupstate.ts +52 -0
  218. package/src/tui/sshtriage.ts +127 -6
  219. package/src/tui/theme.ts +2 -1
  220. package/src/tui/unwedge.ts +2 -14
  221. package/src/verdicts.ts +23 -5
  222. package/src/version.ts +1 -1
  223. package/src/webhook.ts +10 -1
  224. package/src/wire.ts +36 -7
  225. package/src/workerworld.ts +7 -1
package/src/cli.ts CHANGED
@@ -68,6 +68,7 @@ import { sanitizeText } from "./tui/paint.js";
68
68
  import { operatorCommandWriteScript } from "./tui/unwedge.js";
69
69
  import { shellQuote, transportOf } from "./tui/remote.js";
70
70
  import { MutationGate } from "@quickchatai/pi-quickreview/src/gate.js";
71
+ import { botAccountLogin, isPersonMode } from "@quickchatai/pi-quickreview/src/identity.js";
71
72
  import type { GitHubAdapter } from "./github.js";
72
73
  import type { OutcomeCapture } from "./outcomes.js";
73
74
  import type { Learner, MineReport } from "./learner/learner.js";
@@ -198,17 +199,18 @@ export async function cmdOutcomes(rest: string[], json: boolean): Promise<number
198
199
  const outcomes = new OutcomeCapture({
199
200
  store,
200
201
  botLogin: cfg.github.bot_login,
202
+ personMode: isPersonMode(cfg.github),
201
203
  ...(existsSync(worktrees.basePath) ? { git: worktrees } : {}),
202
204
  });
203
205
  const since = new Date(Date.now() - days * 86_400_000).toISOString();
204
206
  if (capture) {
205
207
  let read: GitHubAdapter | undefined;
206
208
  if (readGithub) {
207
- const { TokenMinter } = await import("@quickchatai/pi-quickreview/src/githubAuth.js");
209
+ const { githubCredential } = await import("@quickchatai/pi-quickreview/src/githubAuth.js");
208
210
  const { GitHubAdapter } = await import("./github.js");
209
211
  read = new GitHubAdapter(
210
212
  cfg,
211
- new TokenMinter(cfg.github.secrets_dir),
213
+ githubCredential(cfg.github),
212
214
  recordOnlyGate(join(stateDir(), "dryrun")),
213
215
  );
214
216
  }
@@ -365,7 +367,8 @@ export async function cmdLessons(rest: string[], json: boolean): Promise<number>
365
367
  root,
366
368
  eodTime: cfg.eod.time,
367
369
  timeZone: cfg.eod.timezone,
368
- botLogin: cfg.github.bot_login,
370
+ // The bot ACCOUNT ("" in person mode, where the person's words are a human's).
371
+ botLogin: botAccountLogin(cfg.github),
369
372
  baseBranch: cfg.github.base_branch,
370
373
  config: learnerKnobsOf(cfg.memory),
371
374
  ...(existsSync(worktrees.basePath) ? { git: worktrees } : {}),
@@ -703,13 +706,13 @@ export interface DoubtedFinding {
703
706
  * when the developer machine carries the app credential. */
704
707
  export async function enrichWhy(payload: WhyPayload, secretsDir?: string): Promise<WhyPayload> {
705
708
  try {
706
- const { TokenMinter } = await import("@quickchatai/pi-quickreview/src/githubAuth.js");
709
+ const { githubCredential } = await import("@quickchatai/pi-quickreview/src/githubAuth.js");
707
710
  const { GitHubAdapter } = await import("./github.js");
708
711
  const cfg = loadConfig();
709
712
  const gate = recordOnlyGate(join(stateDir(), "dryrun"), "qcr why");
710
713
  const adapter = new GitHubAdapter(
711
714
  cfg,
712
- new TokenMinter(secretsDir ?? cfg.github.secrets_dir),
715
+ githubCredential({ ...cfg.github, secrets_dir: secretsDir ?? cfg.github.secrets_dir }),
713
716
  gate,
714
717
  );
715
718
  const convo = await adapter.prConversation(payload.pr, 3);
@@ -928,14 +931,22 @@ export async function cmdBackfill(
928
931
  limit: number | undefined,
929
932
  json: boolean,
930
933
  ): Promise<void> {
931
- const { TokenMinter } = await import("@quickchatai/pi-quickreview/src/githubAuth.js");
934
+ const { githubCredential } = await import("@quickchatai/pi-quickreview/src/githubAuth.js");
932
935
  const { GitHubAdapter } = await import("./github.js");
933
936
  const { runBackfill } = await import("./backfill.js");
934
937
  const cfg = loadConfig();
938
+ // The import reads "reviews by the bot's login" as the bot's history. Under
939
+ // the person's own credential that login is the person's, and their own
940
+ // reviews are not the bot's: there is nothing this importer can tell apart.
941
+ if (isPersonMode(cfg.github))
942
+ throw new Error(
943
+ 'qcr backfill imports a GitHub App\'s review history; under [github] auth = "user" the login is ' +
944
+ "your own, so your reviews and the bot's cannot be told apart by it — nothing to import",
945
+ );
935
946
  // A gate that can only ever record: the importer performs no mutations, and
936
947
  // this keeps that structural rather than prompted.
937
948
  const gate = recordOnlyGate(join(stateDir(), "dryrun"), "qcr backfill");
938
- const adapter = new GitHubAdapter(cfg, new TokenMinter(cfg.github.secrets_dir), gate);
949
+ const adapter = new GitHubAdapter(cfg, githubCredential(cfg.github), gate);
939
950
  const store = openStore();
940
951
  try {
941
952
  const stats = await runBackfill(store, adapter, cfg, {
@@ -976,12 +987,12 @@ export async function cmdHistory(rest: string[]): Promise<number> {
976
987
  const months = intFlag(rest, "--months", HISTORY_USAGE);
977
988
  if (sub === "digest") {
978
989
  const limit = intFlag(rest, "--limit", HISTORY_USAGE);
979
- const { TokenMinter } = await import("@quickchatai/pi-quickreview/src/githubAuth.js");
990
+ const { githubCredential } = await import("@quickchatai/pi-quickreview/src/githubAuth.js");
980
991
  const { GitHubAdapter } = await import("./github.js");
981
992
  const { collectReviewHistory } = await import("./reviewhistory.js");
982
993
  const cfg = loadConfig();
983
994
  const gate = recordOnlyGate(join(stateDir(), "dryrun"), "qcr history");
984
- const adapter = new GitHubAdapter(cfg, new TokenMinter(cfg.github.secrets_dir), gate);
995
+ const adapter = new GitHubAdapter(cfg, githubCredential(cfg.github), gate);
985
996
  const store = openStore();
986
997
  try {
987
998
  const digest = await collectReviewHistory(adapter, store, cfg, {
@@ -1230,12 +1241,13 @@ async function cmdDrain(rest: string[]): Promise<number> {
1230
1241
  // until `qcr resume-pr`, and no session lifts it. The cutover wizard's stage 9
1231
1242
  // pauses every tracked PR but one with it before the drain is lifted.
1232
1243
 
1233
- export const PR_LEVER_USAGE = "usage: qcr pause-pr <pr> [--reason <text>] | qcr resume-pr <pr>";
1244
+ export const PR_LEVER_USAGE =
1245
+ "usage: qcr pause-pr <pr> [--reason <text>] | qcr resume-pr <pr> | qcr review-now <pr>";
1234
1246
 
1235
1247
  /** `qcr pause-pr|resume-pr`'s arguments: one PR number, and for a pause an
1236
1248
  * optional `--reason <text>` (the reason the board and `qcr status` show). */
1237
1249
  export function parsePrLeverArgs(
1238
- verb: "pause-pr" | "resume-pr",
1250
+ verb: "pause-pr" | "resume-pr" | "review-now",
1239
1251
  rest: readonly string[],
1240
1252
  ): import("./tui/prpause.js").PrLeverCommand {
1241
1253
  let pr: number | undefined;
@@ -1251,10 +1263,14 @@ export function parsePrLeverArgs(
1251
1263
  }
1252
1264
  if (pr === undefined) throw new Error(PR_LEVER_USAGE);
1253
1265
  if (verb === "resume-pr") return { command: "resume-pr", pr };
1266
+ if (verb === "review-now") return { command: "review-now", pr };
1254
1267
  return { command: "pause-pr", pr, reason: reason ?? "paused by the operator (qcr pause-pr)" };
1255
1268
  }
1256
1269
 
1257
- async function cmdPrLever(verb: "pause-pr" | "resume-pr", rest: string[]): Promise<number> {
1270
+ async function cmdPrLever(
1271
+ verb: "pause-pr" | "resume-pr" | "review-now",
1272
+ rest: string[],
1273
+ ): Promise<number> {
1258
1274
  const cmd = parsePrLeverArgs(verb, rest);
1259
1275
  const cfg = loadConfig();
1260
1276
  const { runnerFor } = await import("./tui/remote.js");
@@ -1540,6 +1556,7 @@ export async function cmdCompanion(sub: string | undefined): Promise<void> {
1540
1556
  dir: companionGithubDir(),
1541
1557
  repo: full.github.repo,
1542
1558
  secretsDir: full.github.secrets_dir,
1559
+ auth: full.github.auth ?? "app",
1543
1560
  }).refresh();
1544
1561
  console.log(`companion ${cfg.container_name}: ${tokenOutcomeLine(gh)}`);
1545
1562
  const mirror = lc.syncConfigMirror();
@@ -2157,10 +2174,28 @@ export async function main(rawArgv: string[]): Promise<void> {
2157
2174
  return;
2158
2175
  }
2159
2176
  case "pause-pr":
2160
- case "resume-pr": {
2177
+ case "resume-pr":
2178
+ case "review-now": {
2179
+ // review-now: the operator's "review this PR now" — queued as a
2180
+ // human-asked round through the sealed operator channel, no label.
2161
2181
  process.exitCode = await cmdPrLever(cmd, rest);
2162
2182
  return;
2163
2183
  }
2184
+ case "github-token": {
2185
+ // The own-account route's token refresher (src/githubtoken.ts): gh's
2186
+ // token → <secrets>/github_token, 0600. The crontab entry runs this.
2187
+ const { cmdGithubToken } = await import("./githubtoken.js");
2188
+ process.exitCode = await cmdGithubToken(rest, {
2189
+ secretsDir: () => {
2190
+ try {
2191
+ return loadConfig().github.secrets_dir;
2192
+ } catch {
2193
+ return locateSecrets({}).path;
2194
+ }
2195
+ },
2196
+ });
2197
+ return;
2198
+ }
2164
2199
  case "message": {
2165
2200
  process.exitCode = await cmdMessage(rest);
2166
2201
  return;
@@ -2238,7 +2273,7 @@ export async function main(rawArgv: string[]): Promise<void> {
2238
2273
  }
2239
2274
  default:
2240
2275
  console.log(
2241
- "usage: qcr <version|config check [--config-home <dir>]|onboard|setup [validate [<profile>]|commit]|status|spend|today|why <n>|metrics [--pr <n>]|events|takeover <n>|debug-round <n>|release <n>|tui [pr|overseer]|drain <on [--hold]|off>|pause-pr <pr> [--reason <text>]|resume-pr <pr>|message <pr> [--may-post] <text...>|archive <status [--plan]|run|restore <pr>|extract <pr> --to <dir>>|install <where|migrate>|" +
2276
+ "usage: qcr <version|config check [--config-home <dir>]|onboard|setup [validate [<profile>]|commit]|status|spend|today|why <n>|metrics [--pr <n>]|events|takeover <n>|debug-round <n>|release <n>|tui [pr|overseer]|drain <on [--hold]|off>|pause-pr <pr> [--reason <text>]|resume-pr <pr>|review-now <pr>|github-token sync [--gh <path>] [--secrets-dir <dir>]|message <pr> [--may-post] <text...>|archive <status [--plan]|run|restore <pr>|extract <pr> --to <dir>>|install <where|migrate>|" +
2242
2277
  "companion <up|down|status|rebuild>|review [start|status|stop|branch|--uncommitted|--diff FILE|-|<PR number|URL>] [--repo OWNER/NAME] [--base REF] [--preview] [--key K] [--limit USD] [--json]|live [RUN_ID|DIR] [--lens ID] [--round N] [--follow] [--json]|open [RUN_ID|DIR|FILE.jsonl|scan[:RUN]|pr N [--round R]] [--lens ID] [--write] [--print]|scan <preview|start|status|stop|findings|matches|match|suggestions|stage2 propose|stage2 start|dismiss|units show|units propose|units adjust|units save|login|doctor|rebuild>|memory reembed [--user <name>]|memory relayout [--plan|--apply]|backfill [--dry-run] [--limit <n>]|history <digest|pull>|outcomes [--days <n>] [--capture [--read-github]]|lessons [mine|shadow-report [--days <n>]|off|on|suppress <id>|unsuppress <id>]|checklist|compare <pr...>|" +
2243
2278
  "regression [--set <path>] [--clone <path>] [--change <name>] [--limit <n>] [--out <path>]>",
2244
2279
  );
package/src/cliops.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // qcr checklist / qcr compare — production wiring of the cutover harnesses. Local
2
2
  // runs exercise the offline halves; the live halves (tunnel, real searches,
3
3
  // live rounds) light up on the VM where the daemon and pi are running.
4
+ import { botAccountLogin } from "@quickchatai/pi-quickreview/src/identity.js";
4
5
  import { existsSync, mkdirSync, readFileSync } from "node:fs";
5
6
  import { join } from "node:path";
6
7
  import { runChecklist, type ChecklistDeps } from "./checklist.js";
@@ -253,7 +254,7 @@ export async function cmdChecklist(parts?: ProductionParts): Promise<number> {
253
254
  await p.adapter.searchLabelTriggered(),
254
255
  "label",
255
256
  cfg.dry_run,
256
- cfg.github.bot_login,
257
+ botAccountLogin(cfg.github),
257
258
  store,
258
259
  ),
259
260
  teamTriggered: async () =>
@@ -261,7 +262,7 @@ export async function cmdChecklist(parts?: ProductionParts): Promise<number> {
261
262
  await p.adapter.searchTeamTriggered(),
262
263
  "team_request",
263
264
  cfg.dry_run,
264
- cfg.github.bot_login,
265
+ botAccountLogin(cfg.github),
265
266
  store,
266
267
  ),
267
268
  derivedQueue: () => derivedQueueOf(store.allPrs(), cfg.dry_run),
package/src/commands.ts CHANGED
@@ -99,7 +99,10 @@ export const CONTROL_TEXT_MAX = 8000;
99
99
  * session's command file cannot spell them: in the session directories, and
100
100
  * in an UNSEALED operator directory (served as a session's), they are refused
101
101
  * at validation, and `executeCommand` refuses
102
- * them again for any issuer but the operator. The kill switch's state lives
102
+ * them again for any issuer but the operator. `review-now` is the operator's
103
+ * "review this PR now": one PR queued as a human-asked round, exactly as a
104
+ * fresh trigger label would queue it, with no GitHub write to ask for it (the
105
+ * onboarding wizard's proof round sends it). The kill switch's state lives
103
106
  * in state.db (src/lessonledger.ts), never as a memory tag anyone holding a
104
107
  * key could write.
105
108
  */
@@ -110,6 +113,7 @@ export const OPERATOR_COMMANDS = [
110
113
  "unsuppress-lesson",
111
114
  "archive-run",
112
115
  "archive-restore",
116
+ "review-now",
113
117
  ...CONTROL_COMMANDS,
114
118
  ] as const;
115
119
 
@@ -180,6 +184,9 @@ export type Command =
180
184
  * valid in a session's file. */
181
185
  | { command: "archive-run" }
182
186
  | { command: "archive-restore"; pr: number }
187
+ /** The operator's "review this PR now" (`OPERATOR_COMMANDS`): never valid
188
+ * in a session's file. */
189
+ | { command: "review-now"; pr: number }
183
190
  /** The operator's round controls (`CONTROL_COMMANDS`): never valid in a
184
191
  * session's file. */
185
192
  | { command: "steer"; pr: number; text: string }
@@ -263,6 +270,9 @@ export interface CommandDeps {
263
270
  * only for an operator-issued command; optional like `drain`. */
264
271
  archiveRun?(file: string): Promise<FileOutcome | void> | FileOutcome | void;
265
272
  archiveRestore?(pr: number, file: string): Promise<FileOutcome | void> | FileOutcome | void;
273
+ /** The operator's "review this PR now": queue one PR for a human-asked
274
+ * round. Reached only for an operator-issued command; optional like `drain`. */
275
+ reviewNow?(pr: number, file: string): Promise<FileOutcome | void> | FileOutcome | void;
266
276
  /** The operator's round controls (`CONTROL_COMMANDS`), forwarded to the PR's
267
277
  * host. Reached only for an operator-issued command; optional like `drain`,
268
278
  * so a daemon with no worker host refuses them with a reason. */
@@ -338,7 +348,7 @@ export function validateCommand(raw: unknown, opts: ValidateOptions = {}): Valid
338
348
  ok: false,
339
349
  reason:
340
350
  `command '${name}' is the OPERATOR's alone: it is accepted only from the root-sealed commands/operator/ ` +
341
- "channel (`qcr lessons off|on|suppress|unsuppress`, `qcr archive run|restore`), never from a session's command file",
351
+ "channel (`qcr lessons off|on|suppress|unsuppress`, `qcr archive run|restore`, `qcr review-now`), never from a session's command file",
342
352
  };
343
353
  return validateOperatorCommand(name, obj);
344
354
  }
@@ -609,6 +619,12 @@ function validateOperatorCommand(
609
619
  if (!isPrNumber(obj["pr"])) return { ok: false, reason: "'pr' must be a positive integer" };
610
620
  return { ok: true, command: { command: "archive-restore", pr: obj["pr"] } };
611
621
  }
622
+ case "review-now": {
623
+ const err = fieldError(obj, ["pr"]);
624
+ if (err) return { ok: false, reason: err };
625
+ if (!isPrNumber(obj["pr"])) return { ok: false, reason: "'pr' must be a positive integer" };
626
+ return { ok: true, command: { command: "review-now", pr: obj["pr"] } };
627
+ }
612
628
  case "steer":
613
629
  case "follow-up":
614
630
  case "message": {
@@ -971,6 +987,16 @@ export async function executeCommand(
971
987
  return { ok: false, reason: "archive-restore is not wired on this daemon" };
972
988
  return normalize(await deps.archiveRestore(command.pr, ctx.delivery_key ?? ctx.file));
973
989
  }
990
+ case "review-now": {
991
+ // The belt behind validation's brace, as for the kill switch.
992
+ if (ctx.issuer !== "operator")
993
+ return {
994
+ ok: false,
995
+ reason: `'${command.command}' is the operator's alone — refused for issuer '${ctx.issuer}'`,
996
+ };
997
+ if (!deps.reviewNow) return { ok: false, reason: "review-now is not wired on this daemon" };
998
+ return normalize(await deps.reviewNow(command.pr, ctx.delivery_key ?? ctx.file));
999
+ }
974
1000
  case "steer":
975
1001
  case "follow-up":
976
1002
  case "abort-round":
package/src/config.ts CHANGED
@@ -974,8 +974,11 @@ function checkSectionTimeZone<T extends { timezone: string }>(section: T, where:
974
974
  * what an absent section means) starts nothing at all.
975
975
  */
976
976
  export interface IngressConfig {
977
- /** "none" ⇒ no watcher is ever constructed; "tunnel-file" ⇒ self-heal on. */
978
- shape: "none" | "tunnel-file";
977
+ /** "none" ⇒ no watcher is ever constructed; "tunnel-file" ⇒ self-heal on;
978
+ * "polling" ⇒ no webhooks at all: the daemon's `/webhook` route refuses
979
+ * every delivery, no webhook secret is read, and the reconcile poll is the
980
+ * only trigger. `[github] auth = "user"` requires "polling". */
981
+ shape: "none" | "tunnel-file" | "polling";
979
982
  /** File the tunnel sidecar writes the bare public hostname into. Empty ⇒
980
983
  * `<root>/state/ingress/hostname` (see ingressHostnameFile). */
981
984
  hostname_file: string;
@@ -994,6 +997,13 @@ export const INGRESS_DEFAULTS: IngressConfig = {
994
997
  sweep_interval_s: 300,
995
998
  };
996
999
 
1000
+ /** Does this install take webhooks at all? Not under the polling shape
1001
+ * (and so never under `[github] auth = "user"`, which requires it): there the
1002
+ * `/webhook` route refuses every delivery and no webhook secret is read. */
1003
+ export function webhooksEnabled(cfg: Pick<Config, "ingress">): boolean {
1004
+ return cfg.ingress.shape !== "polling";
1005
+ }
1006
+
997
1007
  /** The hostname file's resolved path — the one place the default is spelled. */
998
1008
  export function ingressHostnameFile(cfg: Config, root: string): string {
999
1009
  const configured = cfg.ingress.hostname_file.trim();
@@ -1729,9 +1739,19 @@ export function parseConfig(toml: string): Config {
1729
1739
  ...INGRESS_DEFAULTS,
1730
1740
  ...((raw.ingress ?? {}) as Partial<IngressConfig>),
1731
1741
  };
1732
- if (!["none", "tunnel-file"].includes(ingress.shape)) {
1742
+ if (!["none", "tunnel-file", "polling"].includes(ingress.shape)) {
1743
+ throw new Error(
1744
+ `config.toml [ingress]: shape must be "none", "tunnel-file" or "polling" (got ${JSON.stringify(ingress.shape)})`,
1745
+ );
1746
+ }
1747
+ // The person's own credential has no webhook: GitHub delivers webhooks to
1748
+ // an App, and the person has none. So that mode is polling-only, and any
1749
+ // other shape is refused here rather than left to run half-wired.
1750
+ if (github.auth === "user" && ingress.shape !== "polling") {
1733
1751
  throw new Error(
1734
- `config.toml [ingress]: shape must be "none" or "tunnel-file" (got ${JSON.stringify(ingress.shape)})`,
1752
+ `config.toml: [github] auth = "user" runs polling-only — set [ingress] shape = "polling" ` +
1753
+ `(got ${JSON.stringify(ingress.shape)}${raw.ingress === undefined ? ", the default for an absent [ingress]" : ""}). ` +
1754
+ `Webhooks and the webhook self-heal need a GitHub App: use auth = "app" for them.`,
1735
1755
  );
1736
1756
  }
1737
1757
  const cfg: Config = {
package/src/daemon.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  DEFAULT_POSTING_RETRY_WINDOW_MIN,
19
19
  loadConfig,
20
20
  sandboxRoot,
21
+ webhooksEnabled,
21
22
  type Config,
22
23
  } from "./config.js";
23
24
  import { dispatchOnce, type DispatchAdapter, type PreparedTree } from "./dispatch.js";
@@ -30,7 +31,13 @@ import {
30
31
  } from "./eventloop.js";
31
32
  import { MutationGate, type MutationIntent } from "@quickchatai/pi-quickreview/src/gate.js";
32
33
  import { GitHubAdapter } from "./github.js";
33
- import { appActorAlias, TokenMinter } from "@quickchatai/pi-quickreview/src/githubAuth.js";
34
+ import { githubCredential } from "@quickchatai/pi-quickreview/src/githubAuth.js";
35
+ import {
36
+ botAccountLogin,
37
+ isPersonMode,
38
+ ownActorAlias,
39
+ ownLogins,
40
+ } from "@quickchatai/pi-quickreview/src/identity.js";
34
41
  import { REVIEW_SET_ASIDE_STALE_WINDOW_MS, type FeedbackEvent } from "./feedback.js";
35
42
  import { enforceLedgerFreshness } from "./ledgercheck.js";
36
43
  import { readLedgerCarry } from "@quickchatai/pi-quickreview/src/ledgercarry.js";
@@ -57,7 +64,13 @@ import {
57
64
  } from "./recheck.js";
58
65
  import type { DispatchAnnouncer, VerdictAnnouncer } from "./slack/announce.js";
59
66
  import { applyEvent } from "./statemachine.js";
60
- import { buildPipelineStatus, buildStatusSnapshot, StateStore, writeStatusJson } from "./state.js";
67
+ import {
68
+ buildPipelineStatus,
69
+ buildStatusSnapshot,
70
+ StateStore,
71
+ writeStatusJson,
72
+ type OpenPrsSnapshot,
73
+ } from "./state.js";
61
74
  import { PiAuthWatch } from "@quickchatai/pi-quickreview/src/authwatch.js";
62
75
  import { SessionCommandSupervisor } from "./session-command-supervisor.js";
63
76
  import { readRoundProgress } from "./requiredpasses.js";
@@ -313,13 +326,18 @@ export interface Daemon {
313
326
  firstHealthyReconcileAt(): Date | null;
314
327
  }
315
328
 
329
+ /** The webhook HMAC secret — or "" under the polling shape, which takes no
330
+ * webhooks and needs no secret (the `/webhook` route then refuses every
331
+ * delivery, webhook.ts). `[github] auth = "user"` is always polling. */
316
332
  function readWebhookSecret(cfg: Config): string {
333
+ if (!webhooksEnabled(cfg)) return "";
317
334
  return readFileSync(join(cfg.github.secrets_dir, "webhook_secret"), "utf8").trim();
318
335
  }
319
336
 
320
- /** The real adapter: token minter → mutation gate (dry-run aware) → adapter. */
337
+ /** The real adapter: credential (`[github] auth`) → mutation gate (dry-run
338
+ * aware) → adapter. */
321
339
  function buildGitHubAdapter(cfg: Config, stateDir: string): GitHubAdapter {
322
- const minter = new TokenMinter(cfg.github.secrets_dir);
340
+ const minter = githubCredential(cfg.github);
323
341
  const gate: MutationGate = new MutationGate(
324
342
  cfg.dry_run,
325
343
  join(stateDir, "dryrun"),
@@ -496,7 +514,8 @@ export function createDaemon(deps: DaemonDeps = {}): Daemon {
496
514
  dir: join(stateDir, "posting-receipts"),
497
515
  repo: cfg.github.repo,
498
516
  dryRun: cfg.dry_run,
499
- botLogins: [cfg.github.bot_login, `${cfg.github.app_slug}[bot]`],
517
+ botLogins: ownLogins(cfg.github),
518
+ personMode: isPersonMode(cfg.github),
500
519
  adapter: deps.postingDelivery,
501
520
  current: (job) => {
502
521
  const pr = store.getPr(job.pr);
@@ -587,7 +606,7 @@ export function createDaemon(deps: DaemonDeps = {}): Daemon {
587
606
  log,
588
607
  isDraining: () => store.drainState().draining,
589
608
  botLogin: cfg.github.bot_login,
590
- ownerActorAlias: appActorAlias(cfg.github.bot_login, cfg.github.app_slug),
609
+ ownerActorAlias: ownActorAlias(cfg.github),
591
610
  // The author asked for a person on a review thread.
592
611
  //
593
612
  // There is no park to put them in: the state event is a deliberate no-op
@@ -606,7 +625,7 @@ export function createDaemon(deps: DaemonDeps = {}): Daemon {
606
625
  { type: "human_requested" },
607
626
  {},
608
627
  {
609
- botLogin: cfg.github.bot_login,
628
+ botLogin: botAccountLogin(cfg.github),
610
629
  reason: "pingpong:human-requested",
611
630
  },
612
631
  );
@@ -806,10 +825,13 @@ export function createDaemon(deps: DaemonDeps = {}): Daemon {
806
825
  const outcomes = new OutcomeCapture({
807
826
  store,
808
827
  botLogin: cfg.github.bot_login,
828
+ personMode: isPersonMode(cfg.github),
809
829
  ...(deps.outcomeGit ? { git: deps.outcomeGit } : {}),
810
830
  ...(deps.now ? { now: deps.now } : {}),
811
831
  });
812
832
 
833
+ /** The open PRs the last listing reconcile pass saw (status.json `open_prs`). */
834
+ let openPrs: OpenPrsSnapshot | undefined;
813
835
  let firstReconcileSeen = false;
814
836
  let firstHealthyReconcileAt: Date | null = null;
815
837
  // Deploy-window drain auto-lift: only the boot's first reconcile pass may
@@ -893,6 +915,8 @@ export function createDaemon(deps: DaemonDeps = {}): Daemon {
893
915
  // starts on the next pass to keep the boot pass short.
894
916
  receiptSweep: !bootFirstPass,
895
917
  });
918
+ // The board's "review a PR" list: kept from the last pass that listed.
919
+ if (report.open_list) openPrs = { listed_at: report.ran_at, prs: report.open_list };
896
920
  log(
897
921
  `reconcile: open=${report.open_prs} triggered=${report.triggered.length} ` +
898
922
  `closed=${report.closed.length} captured=${report.captured_comments} ` +
@@ -1097,6 +1121,7 @@ export function createDaemon(deps: DaemonDeps = {}): Daemon {
1097
1121
  ...(job.deferred_at ? { deferred_since: job.deferred_at } : {}),
1098
1122
  };
1099
1123
  }
1124
+ if (openPrs) snapshot.open_prs = openPrs;
1100
1125
  if (deps.runHost) snapshot.run_host = deps.runHost;
1101
1126
  writeStatusJson(stateDir, snapshot);
1102
1127
  } catch (err) {
@@ -53,7 +53,14 @@ import { CONTEXT_PACK_FILE } from "@quickchatai/pi-quickreview/src/contextpack.j
53
53
  import { verifiedPayload } from "./session-command-payload.js";
54
54
  import { DEBUG_START_MESSAGE } from "./engine/debugpi.js";
55
55
  import { DEBUG_STATUS_NAME, debugSession, type DebugRoundSpec } from "./debuground.js";
56
- import { onTerminal, paneLaunch, prepareBorrowedAgent, takeoverEnv } from "./takeoverpi.js";
56
+ import {
57
+ onTerminal,
58
+ paneLaunch,
59
+ prepareBorrowedAgent,
60
+ readLogin,
61
+ takeoverEnv,
62
+ } from "./takeoverpi.js";
63
+ import { routeModelSpecForLogin } from "@quickchatai/pi-quickreview/src/modelroute.js";
57
64
 
58
65
  export { DEBUG_START_MESSAGE };
59
66
 
@@ -225,6 +232,9 @@ export async function runDebugRoundPane(
225
232
  return 3;
226
233
  }
227
234
  const env = debugRoundEnv(procEnv, spec);
235
+ // An OpenAI model runs on the OpenAI login the borrowed copy holds
236
+ // (packages/pi-plugin/src/modelroute.ts): the spec's own provider when it is there.
237
+ spec = { ...spec, model: routeModelSpecForLogin(spec.model, readLogin(spec.borrow_dir), env) };
228
238
  const pi = paneLaunch(debugRoundArgv(spec), deps.piBin ?? spec.pi_bin);
229
239
  const launch = toolIsolation(pi.bin, pi.argv, procEnv);
230
240
  if ("refused" in launch) {
package/src/dispatch.ts CHANGED
@@ -15,6 +15,7 @@
15
15
  // plan and the order are built by the review planner (packages/pi-plugin/src/planner.ts), and the
16
16
  // prompt renderer (packages/pi-plugin/src/templates.ts) is injected so this module has no content
17
17
  // dependency.
18
+ import { botAccountLogin } from "@quickchatai/pi-quickreview/src/identity.js";
18
19
  import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
19
20
  import { join, resolve } from "node:path";
20
21
  import type { Config } from "./config.js";
@@ -1247,7 +1248,7 @@ async function dispatchUnlocked(deps: DispatchDeps): Promise<RoundOrder[]> {
1247
1248
  );
1248
1249
  }
1249
1250
  const restored = restoreStandingLifecycle(store, row.number, standing, {
1250
- botLogin: cfg.github.bot_login,
1251
+ botLogin: botAccountLogin(cfg.github),
1251
1252
  now,
1252
1253
  reason: standing ? "no-new-evidence" : "no-new-evidence:standing-unknown",
1253
1254
  });
@@ -1329,7 +1330,7 @@ async function dispatchUnlocked(deps: DispatchDeps): Promise<RoundOrder[]> {
1329
1330
  { type: "verdict", verdict: standing },
1330
1331
  {},
1331
1332
  {
1332
- botLogin: cfg.github.bot_login,
1333
+ botLogin: botAccountLogin(cfg.github),
1333
1334
  now,
1334
1335
  reason: `dispatch-short-circuit:${sc.reason}`,
1335
1336
  },
@@ -2086,7 +2087,7 @@ async function dispatchUnlocked(deps: DispatchDeps): Promise<RoundOrder[]> {
2086
2087
  row.number,
2087
2088
  { type: "dispatched" },
2088
2089
  {},
2089
- { botLogin: cfg.github.bot_login, now },
2090
+ { botLogin: botAccountLogin(cfg.github), now },
2090
2091
  );
2091
2092
  store.logEvent("round_dispatched", row.number, {
2092
2093
  round_id: roundId,
@@ -2182,7 +2183,7 @@ async function dispatchUnlocked(deps: DispatchDeps): Promise<RoundOrder[]> {
2182
2183
  row.number,
2183
2184
  { type: "dispatched" },
2184
2185
  {},
2185
- { botLogin: cfg.github.bot_login, now },
2186
+ { botLogin: botAccountLogin(cfg.github), now },
2186
2187
  );
2187
2188
  } catch (publishError) {
2188
2189
  log(
@@ -25,6 +25,7 @@ import { readFileSync, rmSync, writeFileSync } from "node:fs";
25
25
  import { join } from "node:path";
26
26
  import { FINDING_CLUSTERS_ENTRY, extensionFile } from "../coreext.js";
27
27
  import { GITHUB_TOKEN_ENVS } from "@quickchatai/pi-quickreview/src/envstrip.js";
28
+ import { routeModelSpecForLogin } from "@quickchatai/pi-quickreview/src/modelroute.js";
28
29
  import { piCliPath } from "../picli.js";
29
30
  import {
30
31
  DEBUG_STATUS_FILE,
@@ -57,6 +58,15 @@ export const DEBUG_ROUND_EXTENSIONS = [
57
58
  FINDING_CLUSTERS_ENTRY,
58
59
  ] as const;
59
60
 
61
+ /** The login file in an agent dir, as text; null when there is none. */
62
+ function loginIn(agentDir: string): string | null {
63
+ try {
64
+ return readFileSync(join(agentDir, "auth.json"), "utf8");
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
60
70
  /** pi's first message: the extension command that starts the round. */
61
71
  export const DEBUG_START_MESSAGE = "/qcr-round";
62
72
 
@@ -186,17 +196,19 @@ export async function runDebugRound(i: RoundSpec, deps: DebugRoundDeps = {}): Pr
186
196
  // an earlier attempt's progress record is not this round's
187
197
  rmSync(join(i.outDir, DEBUG_STATUS_FILE), { force: true });
188
198
  log(`review: ${label} - debug round in pi, lenses ${prep.prompts.plannedLenses.join(" ")}`);
199
+ const env = debugEnv(deps.env ?? process.env, prep, {
200
+ order: join(i.outDir, "round-order.md"),
201
+ tasksDir: join(i.outDir, "tasks"),
202
+ });
189
203
  const args = debugArgv({
190
204
  root: i.root,
191
- model: i.cfg.models.conversation,
205
+ // An OpenAI model runs on the OpenAI login the run's agent dir holds
206
+ // (packages/pi-plugin/src/modelroute.ts).
207
+ model: routeModelSpecForLogin(i.cfg.models.conversation, loginIn(i.agentDir), env),
192
208
  sysPath: prep.sysPath,
193
209
  sessionDir: prep.sessionDir,
194
210
  sessionName: prep.sessionName,
195
211
  });
196
- const env = debugEnv(deps.env ?? process.env, prep, {
197
- order: join(i.outDir, "round-order.md"),
198
- tasksDir: join(i.outDir, "tasks"),
199
- });
200
212
  // The whole pi runs as the tool uid, as the process route's did: its bash,
201
213
  // and the lens sessions of its extension, get no capability and cannot read
202
214
  // this process.
@@ -37,6 +37,7 @@
37
37
  // · It detects; it never blocks. A detector that could wedge a review would
38
38
  // be traded away the first time it misfired.
39
39
 
40
+ import { carriesOwnMarker } from "@quickchatai/pi-quickreview/src/identity.js";
40
41
  import { createHash } from "node:crypto";
41
42
  import { readFile, readdir } from "node:fs/promises";
42
43
  import { join, resolve } from "node:path";
@@ -554,11 +555,22 @@ function hasIssuedMarker(body: string, commitId: string, issued: ReadonlySet<str
554
555
  * every GitHub App posts as a `Bot`, and another installed review bot has no
555
556
  * posting trace here by construction, so a type check would report each of
556
557
  * its reviews (and raise the severe wake on its approvals) as ours. */
557
- function isBotAuthored(review: AuditedReview, botLogin: string, appSlug?: string): boolean {
558
+ function isBotAuthored(
559
+ review: AuditedReview,
560
+ botLogin: string,
561
+ appSlug?: string,
562
+ personMode = false,
563
+ ): boolean {
558
564
  const login = review.user.toLowerCase();
565
+ // Person mode: the login is the person's, who reviews by hand too. Only a
566
+ // review carrying the reviewer's marker or footer is the reviewer's — so a
567
+ // write outside the gate that carries neither is invisible here (recorded
568
+ // in docs/exec-boundary.md), and the person's own approval never alarms.
569
+ if (personMode)
570
+ return login === botLogin.toLowerCase() && botLogin !== "" && carriesOwnMarker(review.body);
559
571
  return (
560
572
  login === botLogin.toLowerCase() ||
561
- (appSlug !== undefined && login === `${appSlug.toLowerCase()}[bot]`)
573
+ (appSlug !== undefined && appSlug !== "" && login === `${appSlug.toLowerCase()}[bot]`)
562
574
  );
563
575
  }
564
576
 
@@ -573,7 +585,7 @@ export function auditUngatedReviews(
573
585
  reviews: readonly AuditedReview[],
574
586
  headSha: string,
575
587
  traces: PostingTraces,
576
- opts: { botLogin: string; appSlug?: string },
588
+ opts: { botLogin: string; appSlug?: string; personMode?: boolean },
577
589
  ): UngatedReview[] {
578
590
  const head = headSha.toLowerCase();
579
591
  const out: UngatedReview[] = [];
@@ -583,7 +595,7 @@ export function auditUngatedReviews(
583
595
  // recorded in docs/exec-boundary.md rather than implied away.
584
596
  let migrationExcusesLeft = traces.migrationPrecheck ? 1 : 0;
585
597
  for (const r of reviews) {
586
- if (!isBotAuthored(r, opts.botLogin, opts.appSlug)) continue;
598
+ if (!isBotAuthored(r, opts.botLogin, opts.appSlug, opts.personMode === true)) continue;
587
599
  const state = (r.state ?? "").toUpperCase();
588
600
  if (state === "PENDING" || state === "DISMISSED") continue;
589
601
  if ((r.commit_id ?? "").toLowerCase() !== head) continue;