@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
@@ -81,7 +81,7 @@ export interface TopologyOption {
81
81
  const TOPOLOGY_OPTION_METADATA = {
82
82
  vm: {
83
83
  headline:
84
- "A VM or server over ssh — RECOMMENDED, because a reviewer that is asleep reviews nothing",
84
+ "A VM or server over ssh — RECOMMENDED when this computer sleeps (a laptop), because a reviewer that is asleep reviews nothing",
85
85
  tradeoffs: [
86
86
  "it is on 24/7: pull requests opened while you sleep, while your laptop is shut, or while you are on a plane still get reviewed",
87
87
  "you bring the box (any ssh-reachable Linux machine — a cheap cloud VM is plenty); the wizard does not create one for you",
@@ -91,12 +91,13 @@ const TOPOLOGY_OPTION_METADATA = {
91
91
  ],
92
92
  },
93
93
  local: {
94
- headline: "This computer — everything in Docker on the machine you are sitting at",
94
+ headline:
95
+ "This computer — everything in Docker on the machine this wizard runs on; THE pick when it is itself an always-on server (a cloud VM you are logged into)",
95
96
  tradeoffs: [
96
- "nothing else to own: `docker compose up` and the whole system is here — daemon, memory engine, tunnel",
97
- "it reviews only while this machine is awake and the containers are running; close the lid and the reviewer stops",
98
- "zero third-party signup on the default path: the quick tunnel needs no account, and the daemon re-points its own webhook URL when that hostname rotates",
99
- "the honest way to try QuickReview, and a perfectly good permanent setup for one person on a desktop that stays on",
97
+ "nothing else to own: `docker compose up` and the whole system is here — daemon and memory engine, and the TUI reads them locally with no ssh",
98
+ "it reviews only while this machine is on and the containers are running: on a server that is always; on a laptop, close the lid and the reviewer stops",
99
+ "nothing to expose on the default path: the daemon polls GitHub for new pull requests every few minutes, with no webhook, tunnel or public URL",
100
+ "the honest way to try QuickReview, and a perfectly good permanent setup on a desktop that stays on or a cloud VM",
100
101
  "you can switch to a server later by re-running this step — the answers you gave (repo, App, keys) are kept",
101
102
  ],
102
103
  },
@@ -37,6 +37,7 @@ import {
37
37
  composePs,
38
38
  containerStateCheck,
39
39
  daemonExecArgs,
40
+ daemonRootExecArgs,
40
41
  healthUrl,
41
42
  memoryOn,
42
43
  isUp,
@@ -87,6 +88,10 @@ export interface StackTransport {
87
88
  daemonState(): Promise<DaemonState>;
88
89
  /** Run an argv INSIDE the daemon container. */
89
90
  exec(argv: readonly string[]): Promise<ProcessResult>;
91
+ /** The same, as uid 0: the one way to write the root-sealed operator
92
+ * command directory (`commands/operator/`), as `qcr drain` and the TUI's
93
+ * levers do. Used for the operator's own commands only. */
94
+ execAsRoot(argv: readonly string[]): Promise<ProcessResult>;
90
95
  /** Run an argv INSIDE the container that owns the memory engine: the memory
91
96
  * sidecar on the compose stack, `[tui] memory_container` (else the daemon's
92
97
  * container) on the server route. */
@@ -155,6 +160,10 @@ function composeTransport(ctx: StepContext, opts: TransportOptions): StackTransp
155
160
  return ctx.adapters.runner.run("docker", daemonExecArgs(root, argv));
156
161
  },
157
162
 
163
+ async execAsRoot(argv) {
164
+ return ctx.adapters.runner.run("docker", daemonRootExecArgs(root, argv));
165
+ },
166
+
158
167
  async execMemory(argv) {
159
168
  return ctx.adapters.runner.run(
160
169
  "docker",
@@ -465,6 +474,12 @@ function sshTransport(ctx: StepContext, opts: TransportOptions): StackTransport
465
474
  return remote([...dockerPrefix, "exec", facts.container, ...argv]);
466
475
  },
467
476
 
477
+ async execAsRoot(argv) {
478
+ if (facts.alias === "")
479
+ return { code: 1, stdout: "", stderr: "no ssh alias recorded for the server route" };
480
+ return remote([...dockerPrefix, "exec", "-u", "0", facts.container, ...argv]);
481
+ },
482
+
468
483
  async execMemory(argv) {
469
484
  if (facts.alias === "")
470
485
  return { code: 1, stdout: "", stderr: "no ssh alias recorded for the server route" };
@@ -0,0 +1,119 @@
1
+ // The TUI's own route, checked by the layered verification.
2
+ //
3
+ // The containers being up and the daemon answering /health prove the stack;
4
+ // they do not prove that `qcr tui`, opened right after the wizard, can READ
5
+ // it. The TUI reads status.json its own way — the [tui] block, resolved by
6
+ // src/tui/route.ts, through `runnerFor` (local docker, or ssh to the VM) — so
7
+ // this check runs exactly that: the same resolution, the same argv
8
+ // (`statusArgv`), the same runner, and it needs a status.json that parses.
9
+ // A red check here is the board that would open on "✖ unreachable".
10
+ import { loadConfig, type TuiConfig } from "../../config.js";
11
+ import { runnerFor, statusArgv, transportOf, type SshRunner } from "../../tui/remote.js";
12
+ import { readRouteFacts, resolveTuiRoute, type RouteFacts } from "../../tui/route.js";
13
+ import { sandboxRoot, type CheckResult, type StepContext } from "../engine.js";
14
+
15
+ export const TUI_ROUTE_CHECK = "tui-route";
16
+
17
+ export interface TuiRouteCheckOptions {
18
+ /** The runner for a resolved route; default `runnerFor` (spawns docker/ssh). */
19
+ runner?: (cfg: TuiConfig) => SshRunner;
20
+ /** Route facts that override what is read from the sandbox root. */
21
+ facts?: Partial<RouteFacts>;
22
+ /** Attempts while status.json is not written yet (a daemon just booted). */
23
+ attempts?: number;
24
+ intervalMs?: number;
25
+ sleep?: (ms: number) => Promise<void>;
26
+ }
27
+
28
+ function firstLine(text: string): string {
29
+ return (
30
+ text
31
+ .split("\n")
32
+ .map((l) => l.trim())
33
+ .find((l) => l !== "") ?? ""
34
+ );
35
+ }
36
+
37
+ /** Total: every outcome is a check, never a throw. */
38
+ export async function tuiRouteCheck(
39
+ ctx: StepContext,
40
+ opts: TuiRouteCheckOptions = {},
41
+ ): Promise<CheckResult> {
42
+ if (ctx.adapters.host.inContainer) {
43
+ return {
44
+ name: TUI_ROUTE_CHECK,
45
+ ok: true,
46
+ detail:
47
+ "not checked here — this wizard runs inside the image, and the TUI runs on the host: open `qcr tui` there",
48
+ };
49
+ }
50
+ let tui: TuiConfig;
51
+ try {
52
+ tui = loadConfig(ctx.adapters.paths.configPath).tui;
53
+ } catch (err) {
54
+ return {
55
+ name: TUI_ROUTE_CHECK,
56
+ ok: false,
57
+ detail: `config.toml does not load, so the TUI could not start either: ${String(err instanceof Error ? err.message : err)}`,
58
+ };
59
+ }
60
+ const route = resolveTuiRoute(tui, {
61
+ ...readRouteFacts(sandboxRoot(ctx.adapters.paths)),
62
+ ...opts.facts,
63
+ });
64
+ if (route.unconfigured !== undefined)
65
+ return {
66
+ name: TUI_ROUTE_CHECK,
67
+ ok: false,
68
+ detail: `${route.unconfigured} — Fix: run the config_fill step again`,
69
+ };
70
+ const cfg = route.cfg;
71
+ const run = (opts.runner ?? runnerFor)(cfg);
72
+ const argv = statusArgv(cfg);
73
+ const where =
74
+ transportOf(cfg) === "local"
75
+ ? `local docker, container ${cfg.container}`
76
+ : `ssh ${cfg.ssh_host}`;
77
+ const attempts = Math.max(1, opts.attempts ?? 4);
78
+ const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
79
+ let last = "";
80
+ for (let i = 1; i <= attempts; i++) {
81
+ let res;
82
+ try {
83
+ res = await run(cfg.ssh_host, argv, { timeoutMs: 15_000 });
84
+ } catch (err) {
85
+ res = { ok: false, stdout: "", stderr: String(err), code: null };
86
+ }
87
+ if (res.ok) {
88
+ try {
89
+ const parsed = JSON.parse(res.stdout) as unknown;
90
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
91
+ const prs = (parsed as { prs?: unknown }).prs;
92
+ return {
93
+ name: TUI_ROUTE_CHECK,
94
+ ok: true,
95
+ detail:
96
+ `the TUI's own read works (${where}: \`${argv.join(" ")}\`) — status.json parses` +
97
+ `${Array.isArray(prs) ? `, ${prs.length} PR${prs.length === 1 ? "" : "s"} on the board` : ""}`,
98
+ };
99
+ }
100
+ last = "status.json is not a JSON object";
101
+ } catch {
102
+ last = "status.json is not valid JSON";
103
+ }
104
+ } else {
105
+ last = firstLine(res.stderr) || `exit ${String(res.code)}`;
106
+ }
107
+ if (i < attempts) await sleep(opts.intervalMs ?? 5_000);
108
+ }
109
+ return {
110
+ name: TUI_ROUTE_CHECK,
111
+ ok: false,
112
+ detail:
113
+ `the TUI could not read status.json the way it will (${where}: \`${argv.join(" ")}\`): ${last} — Fix: ` +
114
+ (transportOf(cfg) === "local"
115
+ ? "the [tui] container must be the running daemon container and remote_state_dir its state dir " +
116
+ "(run the config_fill step again to rewrite both)"
117
+ : `\`ssh ${cfg.ssh_host} true\` must work without a prompt, and remote_state_dir must be the VM's state dir`),
118
+ };
119
+ }
@@ -34,7 +34,7 @@
34
34
  // server, a curl on the box). Layer 3 needs no transport at all: it reads
35
35
  // GitHub's OWN delivery log, which is the same record wherever the daemon
36
36
  // runs, and that is exactly why it is the layer that proves the path.
37
- import { CHOICE_KEYS } from "../choices.js";
37
+ import { CHOICE_KEYS, userAuthRecorded } from "../choices.js";
38
38
  import { mintAppJwt } from "@quickchatai/pi-quickreview/src/githubAuth.js";
39
39
  import type { CheckResult, OnboardingStep, StepContext, StepReport } from "../engine.js";
40
40
  import { ingressOptionOf, secretsDirFor, readConfigText } from "./compose.js";
@@ -42,6 +42,7 @@ import { api as githubApi, readCredentials } from "./githubapp.js";
42
42
  import { readTomlKey, WEBHOOK_PATH } from "./ingress.js";
43
43
  import { sshCommandLine } from "./ssh.js";
44
44
  import { sshAliasOf, topologyOf } from "./topology.js";
45
+ import { tuiRouteCheck, type TuiRouteCheckOptions } from "./tuiroute.js";
45
46
  import {
46
47
  DEFAULT_HEALTH_TIMEOUT_MS,
47
48
  stackTransportFor,
@@ -67,6 +68,8 @@ export interface VerifyStackStepOptions {
67
68
  * WHICH transport is never an option: `stackTransportFor` decides from the
68
69
  * recorded topology, and this step never learns which one it got. */
69
70
  transport?: TransportOptions;
71
+ /** The TUI-route check's seams (tuiroute.ts): its runner and route facts. */
72
+ tuiRoute?: TuiRouteCheckOptions;
70
73
  }
71
74
 
72
75
  // ---- GitHub's delivery log ---------------------------------------------------
@@ -415,6 +418,22 @@ export function verifyStackStep(opts: VerifyStackStepOptions = {}): OnboardingSt
415
418
  });
416
419
  return { ok: false, checks };
417
420
  }
421
+ // The operator's own window onto the stack: can `qcr tui` read it the way
422
+ // it will? Independent of webhook delivery, so it never stops layer 3.
423
+ checks.push(await tuiRouteCheck(ctx, opts.tuiRoute));
424
+ // Layer 3 reads the App's own hook config and delivery log — App-JWT
425
+ // calls. The own-account route has no App, so there is no hook to read
426
+ // and nothing to deliver: the layer is skipped, and the reason said.
427
+ if (userAuthRecorded(ctx.choices)) {
428
+ checks.push({
429
+ name: "ping-delivery",
430
+ ok: true,
431
+ detail:
432
+ 'not checked — this install runs on your own GitHub account ([github] auth = "user"), which has no GitHub App, ' +
433
+ "so there is no webhook and no App delivery log to read; the reconcile poll is the only trigger path",
434
+ });
435
+ return { ok: checks.every((c) => c.ok), checks };
436
+ }
418
437
  // Layer 3 exists only where GitHub can deliver. The polling topology
419
438
  // deliberately exposes nothing, so a ping can never land there — requiring
420
439
  // one would keep this step (and so onboarding's finish) red forever.
@@ -0,0 +1,34 @@
1
+ // The onboarding session's tool names: one source for the extension that
2
+ // registers them (extensions/onboarding.ts) and the argv that activates them
3
+ // (launch.ts `onboardingPiArgv`).
4
+ //
5
+ // pi's `--tools <list>` is an allowlist over EVERY tool, extension tools
6
+ // included (pi 1.4.0: createAgentSession({ tools }) leaves an extension tool
7
+ // that is not on the list inactive). So the argv must name each wizard tool,
8
+ // or the guide runs with no tools at all. The built-in belt stays read-only:
9
+ // bash, write and edit are never on the list.
10
+
11
+ /** pi's built-in tools the wizard may use: reading only. */
12
+ export const ONBOARD_BUILTIN_TOOLS: readonly string[] = ["read", "ls", "grep"];
13
+
14
+ /** The checklist tool. */
15
+ export const ONBOARD_STATUS_TOOL = "onboard_status";
16
+ /** The verify-everything-and-exit tool. */
17
+ export const ONBOARD_FINISH_TOOL = "finish_and_exit";
18
+
19
+ /** A step's tool name. */
20
+ export function onboardStepToolName(stepId: string): string {
21
+ return `onboard_${stepId}`;
22
+ }
23
+
24
+ /** Every tool the onboarding extension registers over these step ids, in
25
+ * registration order. */
26
+ export function onboardingExtensionToolNames(stepIds: readonly string[]): string[] {
27
+ return [...stepIds.map(onboardStepToolName), ONBOARD_STATUS_TOOL, ONBOARD_FINISH_TOOL];
28
+ }
29
+
30
+ /** The whole `--tools` allowlist for the onboarding pi: the read-only belt,
31
+ * then every wizard tool. */
32
+ export function onboardingToolAllowlist(stepIds: readonly string[]): string[] {
33
+ return [...ONBOARD_BUILTIN_TOOLS, ...onboardingExtensionToolNames(stepIds)];
34
+ }
@@ -0,0 +1,25 @@
1
+ // The one in-container shell that writes an operator command file as root
2
+ // into the root-sealed `<commands>/operator/` — its full rationale is on
3
+ // `operatorCommandWriteScript` in src/tui/unwedge.ts, the TUI's caller. A
4
+ // leaf module (no imports) so the onboarding wizard's proof round, which knows
5
+ // the release image's command root rather than a TUI config, uses the same
6
+ // script without reaching the TUI's module graph.
7
+
8
+ /** `operatorCommandWriteScript` for a known command root (`<root>/commands`
9
+ * inside the container) — the onboarding wizard's caller, which knows the
10
+ * release image's layout rather than a TUI config. */
11
+ export function operatorCommandWriteScriptAt(root: string, name: string): string {
12
+ const dir = `${root}/operator`;
13
+ const tmp = `.${name}.tmp`;
14
+ return (
15
+ `[ -d '${root}' ] || { echo 'daemon commands dir ${root} missing - is the daemon running?' >&2; exit 1; }; ` +
16
+ `mkdir -p '${dir}' || exit 1; ` +
17
+ `[ ! -L '${dir}' ] || { echo '${dir} is a symlink - refusing to write the operator command' >&2; exit 1; }; ` +
18
+ `R=$(cd -P '${root}' && pwd -P) || exit 1; ` +
19
+ `cd -P '${dir}' || exit 1; ` +
20
+ `[ "$(pwd -P)" = "$R/operator" ] || { echo '${dir} was replaced while writing - refusing to write the operator command' >&2; exit 1; }; ` +
21
+ `chown -h 0:0 . && chmod 0755 . || exit 1; ` +
22
+ `find . -maxdepth 1 -name '*.json' -mmin +1440 -delete 2>/dev/null; ` +
23
+ `rm -f '${tmp}' '${name}' && cat > '${tmp}' && chmod 0644 '${tmp}' && mv '${tmp}' '${name}'`
24
+ );
25
+ }
package/src/outcomes.ts CHANGED
@@ -97,6 +97,7 @@ import { prActivity, type PrActivity } from "./pr-activity.js";
97
97
  // read-only and memoised; a commit the clone does not hold is UNKNOWN, never a
98
98
  // change and never no-change. Every failure is the sweep's own — reconcile's
99
99
  // health (which lifts a deploy drain) never waits on it.
100
+ import { carriesOwnMarker } from "@quickchatai/pi-quickreview/src/identity.js";
100
101
  import {
101
102
  carriedToRef,
102
103
  isPostedRow,
@@ -519,6 +520,10 @@ export interface OutcomeCaptureDeps {
519
520
  store: StateStore;
520
521
  /** `[github] bot_login` — whose root comments are findings. */
521
522
  botLogin: string;
523
+ /** `[github] auth = "user"`: the login is the person's, so a root comment is
524
+ * a finding only when it carries the reviewer's marker, and the person's
525
+ * own replies are a human's. */
526
+ personMode?: boolean;
522
527
  /** Absent ⇒ every "did the code change" question is unknown. */
523
528
  git?: AnchorGit;
524
529
  now?: () => Date;
@@ -585,6 +590,7 @@ export class OutcomeCapture {
585
590
  this.store.db.transaction(() => {
586
591
  for (const c of comments) {
587
592
  if (c.user !== this.deps.botLogin || c.in_reply_to_id !== null) continue;
593
+ if (this.deps.personMode && !carriesOwnMarker(c.body)) continue;
588
594
  const marker = parseFindingMarker(c.body);
589
595
  const head = commentHead(c.body);
590
596
  const info = stmt.run({
@@ -823,7 +829,8 @@ export class OutcomeCapture {
823
829
  const replies = new Map<number, PrCommentRow[]>();
824
830
  for (const c of this.store.prComments(pr)) {
825
831
  if (c.source !== "review_comment" || c.in_reply_to_id === null) continue;
826
- if (c.author === this.deps.botLogin) continue;
832
+ if (c.author === this.deps.botLogin && (!this.deps.personMode || carriesOwnMarker(c.body)))
833
+ continue;
827
834
  replies.set(c.in_reply_to_id, [...(replies.get(c.in_reply_to_id) ?? []), c]);
828
835
  }
829
836
  const events = this.store.prEvents(pr, [
@@ -106,6 +106,10 @@ export interface JournalOptions {
106
106
  /** `<state>/posting-receipts`, the dir the verdict watcher and the outbox read. */
107
107
  dir: string;
108
108
  repo: string;
109
+ /** `[github] auth = "user"`: every receipt says `person_mode`, the posting
110
+ * guard posts every event as a COMMENT, and an APPROVE posted as a COMMENT
111
+ * is NOT the withheld-approval shape (posting-receipt.ts). */
112
+ personMode?: boolean;
109
113
  now?: () => Date;
110
114
  }
111
115
 
@@ -357,7 +361,7 @@ export class PostingJournal {
357
361
  // An APPROVE that GitHub holds as a COMMENT was softened by guard 4 on
358
362
  // the gate after the CI preflight: the withheld-approval shape, which
359
363
  // the outbox's confirmation stamps the same way.
360
- ...(o.intended === "APPROVE" && actual === "COMMENT"
364
+ ...(o.intended === "APPROVE" && actual === "COMMENT" && !this.o.personMode
361
365
  ? { delivered_by: "daemon_retry" as const }
362
366
  : {}),
363
367
  }),
@@ -519,6 +523,7 @@ export class PostingJournal {
519
523
  actual_event: actual,
520
524
  payload_sha256: payloadHash(payload),
521
525
  prepared_at: base.prepared_at ?? this.iso(),
526
+ ...(this.o.personMode ? { person_mode: true } : {}),
522
527
  ...extra,
523
528
  };
524
529
  }
@@ -285,6 +285,10 @@ export class PostingOutbox {
285
285
  repo: string;
286
286
  dryRun: boolean;
287
287
  botLogins: string[];
288
+ /** `[github] auth = "user"`: receipts say `person_mode`, and an APPROVE
289
+ * delivered as a COMMENT is the person-mode rule, not a guard-4
290
+ * softening (posting-receipt.ts). */
291
+ personMode?: boolean;
288
292
  adapter: PostingDeliveryAdapter;
289
293
  current: (job: PostingJob) => boolean;
290
294
  now?: () => Date;
@@ -432,10 +436,13 @@ export class PostingOutbox {
432
436
  gh_state: state,
433
437
  // An APPROVE that GitHub holds as a COMMENT was softened by guard 4 on
434
438
  // the gate, which only the daemon's delivery passes through (the shell
435
- // route refuses a red-CI approval): the withheld-approval shape.
436
- ...(deliveredBy || (j.intended_event === "APPROVE" && event === "COMMENT")
439
+ // route refuses a red-CI approval): the withheld-approval shape. Not in
440
+ // person mode, where every review is a COMMENT by rule.
441
+ ...(deliveredBy ||
442
+ (j.intended_event === "APPROVE" && event === "COMMENT" && !this.deps.personMode)
437
443
  ? { delivered_by: "daemon_retry" as const }
438
444
  : {}),
445
+ ...(this.deps.personMode ? { person_mode: true as const } : {}),
439
446
  } satisfies PostingReceipt);
440
447
  j.status = "done";
441
448
  writePostingFile(path, j);
@@ -762,6 +769,7 @@ export class PostingOutbox {
762
769
  status: "prepared",
763
770
  payload_sha256: j.payload_sha256,
764
771
  prepared_at: j.attempted_at,
772
+ ...(this.deps.personMode ? { person_mode: true as const } : {}),
765
773
  } satisfies PostingReceipt);
766
774
  try {
767
775
  const result = await this.deps.adapter.createReview(j.pr, j.payload);
@@ -52,6 +52,11 @@ export interface PostingReceipt {
52
52
  * (APPROVE posted as a COMMENT, approval withheld for red CI) is something
53
53
  * the verdict file cannot know: see `retrySoftenedApproval`. */
54
54
  delivered_by?: "daemon_retry";
55
+ /** Written by a person-mode install (`[github] auth = "user"`): the posting
56
+ * guard posts EVERY review as a COMMENT there, so `actual_event` COMMENT
57
+ * proves nothing about the decision — `intended_event` carries it, and an
58
+ * APPROVE posted as a COMMENT is not a withheld approval. */
59
+ person_mode?: true;
55
60
  /** In memory only, never written by the posting scripts: the
56
61
  * daemon SYNTHESISED this receipt from a bot review carrying the round's
57
62
  * marker, because the shell never persisted its own. `intended_event` is
@@ -209,6 +214,7 @@ export function parsePostingReceipt(raw: string): PostingOutcome | undefined {
209
214
  value.status !== "deferred") ||
210
215
  (value.status === "deferred" && !isoDate(value.deferred_at)) ||
211
216
  (value.delivered_by !== undefined && value.delivered_by !== "daemon_retry") ||
217
+ (value.person_mode !== undefined && value.person_mode !== true) ||
212
218
  typeof value.payload_sha256 !== "string" ||
213
219
  !/^[a-f0-9]{64}$/.test(value.payload_sha256) ||
214
220
  !isoDate(value.prepared_at)
@@ -245,6 +251,7 @@ export function parsePostingReceipt(raw: string): PostingOutcome | undefined {
245
251
  * the receipt is the evidence that the approval was withheld. */
246
252
  export function retrySoftenedApproval(receipt: PostingReceipt): boolean {
247
253
  return (
254
+ receipt.person_mode !== true &&
248
255
  receipt.delivered_by === "daemon_retry" &&
249
256
  receipt.status === "confirmed" &&
250
257
  receipt.intended_event === "APPROVE" &&
@@ -259,6 +266,16 @@ export function receiptMatchesVerdict(receipt: PostingReceipt, verdict: VerdictF
259
266
  receipt.head_sha.toLowerCase() !== verdict.head_sha.toLowerCase()
260
267
  )
261
268
  return false;
269
+ // Person mode posts every review as a COMMENT. A receipt synthesised from
270
+ // GitHub knows nothing more than that; any other one is matched on what
271
+ // the session asked for, as if it had been posted as asked.
272
+ if (receipt.person_mode === true && receipt.actual_event === "COMMENT") {
273
+ if (receipt.reconciled === true) return true;
274
+ const asAsked: PostingReceipt = { ...receipt, actual_event: receipt.intended_event };
275
+ delete asAsked.person_mode;
276
+ delete asAsked.delivered_by;
277
+ return receiptMatchesVerdict(asAsked, verdict);
278
+ }
262
279
  if (verdict.verdict === "REQUEST_CHANGES")
263
280
  return receipt.actual_event === "REQUEST_CHANGES" || receipt.actual_event === "COMMENT";
264
281
  if (verdict.verdict === "APPROVE" && retrySoftenedApproval(receipt)) return true;
package/src/recheck.ts CHANGED
@@ -38,6 +38,7 @@
38
38
  // 5. **No GitHub write lives here.** The module reads (compare, files) and
39
39
  // writes nothing; the session posts its review through the gated scripts.
40
40
  // The MutationGate keeps its monopoly by this module having no mutation.
41
+ import { botAccountLogin } from "@quickchatai/pi-quickreview/src/identity.js";
41
42
  import { existsSync, mkdirSync, readdirSync, renameSync, watch, type FSWatcher } from "node:fs";
42
43
  import { join } from "node:path";
43
44
  import type { Config } from "./config.js";
@@ -1647,7 +1648,7 @@ export class RecheckWatcher {
1647
1648
  { type: "verdict", verdict: "REQUEST_CHANGES" },
1648
1649
  {},
1649
1650
  {
1650
- botLogin: this.deps.cfg.github.bot_login,
1651
+ botLogin: botAccountLogin(this.deps.cfg.github),
1651
1652
  ...(this.deps.now ? { now: this.deps.now } : {}),
1652
1653
  reason: "recheck:request-changes",
1653
1654
  },
package/src/reconcile.ts CHANGED
@@ -29,7 +29,12 @@ import { DEFAULT_ROUND_STALL_GRACE_MIN, type Config } from "./config.js";
29
29
  import type { IssueCommentFull, PrRef, ReviewCommentFull, ReviewFull } from "./github.js";
30
30
  import type { OutcomeCapture, OutcomeSweepReport } from "./outcomes.js";
31
31
  import { applyEvent, isBotAuthor, mergedOverBlock } from "./statemachine.js";
32
- import type { RoundRow, StateStore } from "./state.js";
32
+ import {
33
+ botAccountLogin,
34
+ isOwnContent,
35
+ isPersonMode,
36
+ } from "@quickchatai/pi-quickreview/src/identity.js";
37
+ import type { OpenPrSummary, RoundRow, StateStore } from "./state.js";
33
38
  import type { OvSessionSweepReport, SessionHooks } from "@quickchatai/pi-quickreview/src/types.js";
34
39
  import { performance } from "node:perf_hooks";
35
40
  import { yieldToLoop } from "@quickchatai/pi-quickreview/src/yieldloop.js";
@@ -132,6 +137,9 @@ export const RECEIPT_SWEEP_BUDGET_MS = 20_000;
132
137
  export interface ReconcileReport {
133
138
  ran_at: string;
134
139
  open_prs: number;
140
+ /** The open PRs this pass listed, tracked or not; absent when the list
141
+ * step failed. The daemon publishes it in status.json for the board. */
142
+ open_list?: OpenPrSummary[];
135
143
  triggered: number[];
136
144
  closed: number[];
137
145
  backfilled_comments: number;
@@ -255,7 +263,11 @@ export function reconcileOrphanedRounds(
255
263
  export async function reconcileOnce(deps: ReconcileDeps): Promise<ReconcileReport> {
256
264
  const { store, cfg, adapter } = deps;
257
265
  const now = deps.now ?? (() => new Date());
258
- const botLogin = cfg.github.bot_login;
266
+ // The bot ACCOUNT: whose PRs are a bot's and whose captured comments are not
267
+ // a human's. "" in person mode (`[github] auth = "user"`), where the
268
+ // configured login is the person's: their PRs are reviewed, and their own
269
+ // comments are told from the reviewer's by its marker (identity.ts).
270
+ const botLogin = botAccountLogin(cfg.github);
259
271
  const report: ReconcileReport = {
260
272
  ran_at: now().toISOString(),
261
273
  open_prs: 0,
@@ -287,6 +299,13 @@ export async function reconcileOnce(deps: ReconcileDeps): Promise<ReconcileRepor
287
299
  for (const pr of await adapter.listOpenPrs()) open.set(pr.number, pr);
288
300
  openListed = true;
289
301
  report.open_prs = open.size;
302
+ report.open_list = [...open.values()].map((p) => ({
303
+ number: p.number,
304
+ title: p.title,
305
+ author: p.author,
306
+ draft: p.draft,
307
+ updated_at: p.updated_at,
308
+ }));
290
309
  for (const pr of open.values()) {
291
310
  if (isBotAuthor(pr.author, botLogin)) continue;
292
311
  const local = store.getPr(pr.number);
@@ -921,6 +940,8 @@ export async function reconcileOnce(deps: ReconcileDeps): Promise<ReconcileRepor
921
940
  }
922
941
  for (const c of rows) {
923
942
  if (!isHumanCapture(c, botLogin)) continue;
943
+ // Person mode: the reviewer's own words under the person's login.
944
+ if (isOwnContent(cfg.github, c.author, c.body)) continue;
924
945
  if (recordCapturedComment(store, c, "reconcile")) report.captured_comments++;
925
946
  }
926
947
  // ---- the exec boundary's OUTCOME layer ------------------------------
@@ -947,7 +968,12 @@ export async function reconcileOnce(deps: ReconcileDeps): Promise<ReconcileRepor
947
968
  reviewComments: commentsByReview(inlineFetched),
948
969
  gatedCommentIds: store.botCommentIds(n),
949
970
  },
950
- { botLogin, appSlug: cfg.github.app_slug },
971
+ {
972
+ botLogin: cfg.github.bot_login,
973
+ appSlug: cfg.github.app_slug,
974
+ // Person mode: only marker-bearing reviews are the reviewer's.
975
+ personMode: isPersonMode(cfg.github),
976
+ },
951
977
  );
952
978
  if (ungated.length > 0) {
953
979
  store.logEvent("ungated_review_detected", n, {
@@ -18,6 +18,11 @@
18
18
  // Total at the edge: a PR that cannot be read is counted, never fatal; an
19
19
  // empty repo — no merged PRs, no rounds, no feedback — is an ordinary, EMPTY
20
20
  // digest, not an error.
21
+ import {
22
+ excludeOwnAppQualifier,
23
+ isOwnContent,
24
+ isPersonMode,
25
+ } from "@quickchatai/pi-quickreview/src/identity.js";
21
26
  import type { Config } from "./config.js";
22
27
  import type { SearchIssueItem } from "./github.js";
23
28
  import type { FeedbackSignalRow, RoundRow } from "./state.js";
@@ -146,7 +151,7 @@ export function mergedPrsQuery(cfg: Config, since: string, until?: string): stri
146
151
  until === undefined
147
152
  ? `>=${since.slice(0, 10)}`
148
153
  : `${since.slice(0, 10)}..${until.slice(0, 10)}`;
149
- return `repo:${cfg.github.repo} is:pr is:merged merged:${range} -author:app/${cfg.github.app_slug}`;
154
+ return `repo:${cfg.github.repo} is:pr is:merged merged:${range}${excludeOwnAppQualifier(cfg.github)}`;
150
155
  }
151
156
 
152
157
  /**
@@ -173,11 +178,20 @@ function clip(body: string): string {
173
178
  }
174
179
 
175
180
  /** A human, and not the PR's own author (their replies answer a review; they
176
- * are not one) nor this App under either spelling. `[feedback]` comments are
177
- * left out here: they are the feedback half, read from the ledger. */
178
- function humanReviewer(a: HumanReviewAuthor | null, pr: PrReviews, cfg: Config): string | null {
181
+ * are not one) nor this App under either spelling. In person mode
182
+ * (`[github] auth = "user"`) the login is the person's: their words are a
183
+ * human's, and only the reviewer's marker-bearing ones are left out.
184
+ * `[feedback]` comments are left out here: they are the feedback half, read
185
+ * from the ledger. */
186
+ function humanReviewer(
187
+ a: HumanReviewAuthor | null,
188
+ pr: PrReviews,
189
+ cfg: Config,
190
+ body: string,
191
+ ): string | null {
179
192
  if (!a || a.type !== "User" || a.login === "") return null;
180
193
  if (a.login === pr.author) return null;
194
+ if (isPersonMode(cfg.github)) return isOwnContent(cfg.github, a.login, body) ? null : a.login;
181
195
  if (a.login === cfg.backfill.bot_login || a.login === cfg.backfill.bot_actor) return null;
182
196
  return a.login;
183
197
  }
@@ -187,7 +201,7 @@ export function humanCommentsOf(pr: PrReviews, cfg: Config): HumanComment[] {
187
201
  const out: HumanComment[] = [];
188
202
  const keep = (body: string): boolean => body.trim() !== "" && !/^\s*\[feedback\]/i.test(body);
189
203
  for (const r of pr.reviews) {
190
- const reviewer = humanReviewer(r.author, pr, cfg);
204
+ const reviewer = humanReviewer(r.author, pr, cfg, r.body);
191
205
  if (reviewer !== null && keep(r.body)) {
192
206
  out.push({
193
207
  pr: pr.number,
@@ -200,7 +214,7 @@ export function humanCommentsOf(pr: PrReviews, cfg: Config): HumanComment[] {
200
214
  });
201
215
  }
202
216
  for (const c of r.comments) {
203
- const who = humanReviewer(c.author, pr, cfg);
217
+ const who = humanReviewer(c.author, pr, cfg, c.body);
204
218
  if (who === null || !keep(c.body)) continue;
205
219
  out.push({
206
220
  pr: pr.number,