toolroll 0.9.93 → 0.9.94

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 (328) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/dist/activity-line.d.ts +7 -4
  3. package/dist/activity-line.js +9 -7
  4. package/dist/agent-fence.d.ts +4 -0
  5. package/dist/agent-fence.js +4 -1
  6. package/dist/approval-authority.d.ts +7 -1
  7. package/dist/approval-authority.js +11 -2
  8. package/dist/approval-password.d.ts +31 -0
  9. package/dist/approval-password.js +26 -0
  10. package/dist/assignment-ui.js +1 -1
  11. package/dist/assignment.js +5 -0
  12. package/dist/boot-identity.d.ts +2 -1
  13. package/dist/boot-identity.js +6 -4
  14. package/dist/bounded-spawn.d.ts +57 -0
  15. package/dist/bounded-spawn.js +220 -0
  16. package/dist/browser/terminal.js +42 -42
  17. package/dist/browser/workspace.css +1 -1
  18. package/dist/browser/workspace.js +32 -32
  19. package/dist/browser-shell.d.ts +1 -1
  20. package/dist/browser-shell.js +2 -2
  21. package/dist/browser-workspace.d.ts +45 -2
  22. package/dist/browser-workspace.js +3 -3
  23. package/dist/builder-restart.d.ts +21 -0
  24. package/dist/builder-restart.js +50 -0
  25. package/dist/builder.d.ts +4 -0
  26. package/dist/builder.js +53 -24
  27. package/dist/chat-acceptance.js +1 -1
  28. package/dist/chat-actions.d.ts +12 -2
  29. package/dist/chat-actions.js +25 -2
  30. package/dist/chat-channel.js +11 -0
  31. package/dist/chat-decide.d.ts +7 -2
  32. package/dist/chat-decide.js +37 -9
  33. package/dist/chat-quiet.js +3 -3
  34. package/dist/chat-voice.d.ts +1 -1
  35. package/dist/chat-voice.js +2 -2
  36. package/dist/check-facts.d.ts +3 -1
  37. package/dist/check-facts.js +57 -11
  38. package/dist/check-failure.d.ts +22 -3
  39. package/dist/check-failure.js +57 -13
  40. package/dist/check-levels-ui.d.ts +4 -0
  41. package/dist/check-levels-ui.js +10 -1
  42. package/dist/check-load.d.ts +85 -0
  43. package/dist/check-load.js +147 -0
  44. package/dist/check-only-standing.d.ts +36 -0
  45. package/dist/check-only-standing.js +39 -0
  46. package/dist/check-only.d.ts +15 -0
  47. package/dist/check-only.js +99 -0
  48. package/dist/check-report.d.ts +2 -0
  49. package/dist/check-report.js +1 -1
  50. package/dist/check-stamp.d.ts +46 -0
  51. package/dist/check-stamp.js +93 -0
  52. package/dist/checkout-cleanup.d.ts +36 -5
  53. package/dist/checkout-cleanup.js +166 -14
  54. package/dist/cli-help.d.ts +4 -1
  55. package/dist/cli-help.js +47 -30
  56. package/dist/cli.js +11 -1
  57. package/dist/code-extract.d.ts +44 -0
  58. package/dist/code-extract.js +88 -0
  59. package/dist/code-map-acts.d.ts +24 -0
  60. package/dist/code-map-acts.js +53 -0
  61. package/dist/code-map-admission.d.ts +52 -0
  62. package/dist/code-map-admission.js +295 -0
  63. package/dist/code-map-build.d.ts +63 -0
  64. package/dist/code-map-build.js +415 -0
  65. package/dist/code-map-cli.d.ts +153 -0
  66. package/dist/code-map-cli.js +103 -0
  67. package/dist/code-map-main.d.ts +1 -0
  68. package/dist/code-map-main.js +48 -0
  69. package/dist/code-map-read.d.ts +106 -0
  70. package/dist/code-map-read.js +171 -0
  71. package/dist/code-map-report.d.ts +37 -0
  72. package/dist/code-map-report.js +95 -0
  73. package/dist/code-map-shape.d.ts +172 -0
  74. package/dist/code-map-shape.js +327 -0
  75. package/dist/code-map-store.d.ts +136 -0
  76. package/dist/code-map-store.js +233 -0
  77. package/dist/coding-provider.d.ts +2 -0
  78. package/dist/coding-provider.js +3 -2
  79. package/dist/coding-update.js +3 -2
  80. package/dist/coding-workspace.js +4 -4
  81. package/dist/console-chrome.js +2 -2
  82. package/dist/containment.d.ts +5 -0
  83. package/dist/containment.js +14 -1
  84. package/dist/contracts/chat-actions.d.ts +9 -0
  85. package/dist/contracts/chat-actions.js +2 -0
  86. package/dist/contracts/cli.d.ts +52 -0
  87. package/dist/contracts/cli.js +4 -0
  88. package/dist/contracts/console-api.d.ts +10 -3
  89. package/dist/contracts/console-api.js +10 -3
  90. package/dist/contracts/gateway-tools.d.ts +17 -0
  91. package/dist/contracts/gateway-tools.js +3 -1
  92. package/dist/contracts/lead-tools.d.ts +61 -0
  93. package/dist/contracts/lead-tools.js +10 -1
  94. package/dist/contracts/project-knowledge.d.ts +38 -5
  95. package/dist/contracts/project-knowledge.js +11 -5
  96. package/dist/contracts/project-memory.d.ts +3 -1
  97. package/dist/contracts/project-memory.js +3 -2
  98. package/dist/contracts/storage.d.ts +14 -0
  99. package/dist/contracts/storage.js +3 -1
  100. package/dist/contracts/store-json.d.ts +2 -0
  101. package/dist/contracts/youiyoux-request.d.ts +1 -1
  102. package/dist/contracts/youiyoux-request.js +1 -1
  103. package/dist/desktop-access.js +6 -2
  104. package/dist/dispatch.js +18 -15
  105. package/dist/dispose.js +8 -2
  106. package/dist/easy-start.js +2 -3
  107. package/dist/evals.js +2 -1
  108. package/dist/fast-start.d.ts +136 -0
  109. package/dist/fast-start.js +431 -0
  110. package/dist/fast-terms.d.ts +9 -0
  111. package/dist/fast-terms.js +23 -0
  112. package/dist/fenced-check-coverage.d.ts +62 -0
  113. package/dist/fenced-check-coverage.js +54 -0
  114. package/dist/first-run.js +1 -1
  115. package/dist/flaky-ci.d.ts +31 -0
  116. package/dist/flaky-ci.js +149 -0
  117. package/dist/flaky-cli.d.ts +60 -0
  118. package/dist/flaky-cli.js +60 -0
  119. package/dist/flaky.d.ts +75 -9
  120. package/dist/flaky.js +255 -0
  121. package/dist/flow-card-task.js +3 -0
  122. package/dist/flow-triggers.d.ts +1 -1
  123. package/dist/flow-triggers.js +1 -1
  124. package/dist/github-import.d.ts +62 -0
  125. package/dist/github-import.js +146 -0
  126. package/dist/guides.js +15 -10
  127. package/dist/invoke.d.ts +5 -1
  128. package/dist/invoke.js +49 -14
  129. package/dist/job-object-helper.ps1 +10 -3
  130. package/dist/keymap.d.ts +41 -0
  131. package/dist/keymap.js +51 -0
  132. package/dist/knowledge-cli.js +1 -1
  133. package/dist/knowledge-ui.d.ts +21 -4
  134. package/dist/knowledge-ui.js +75 -7
  135. package/dist/laptop-background.d.ts +16 -0
  136. package/dist/laptop-background.js +108 -2
  137. package/dist/lead-context.js +1 -1
  138. package/dist/lead-contract.d.ts +1 -1
  139. package/dist/lead-contract.js +2 -2
  140. package/dist/lead-progress.js +3 -0
  141. package/dist/lead-status.d.ts +2 -0
  142. package/dist/lead-status.js +6 -2
  143. package/dist/lead-tools.js +51 -2
  144. package/dist/lead.js +1 -1
  145. package/dist/lesson-notes.d.ts +81 -0
  146. package/dist/lesson-notes.js +336 -0
  147. package/dist/limits-ui.js +1 -1
  148. package/dist/local-owner.d.ts +1 -1
  149. package/dist/local-owner.js +11 -0
  150. package/dist/machine-check.d.ts +16 -0
  151. package/dist/machine-check.js +26 -0
  152. package/dist/machine-identity.d.ts +75 -0
  153. package/dist/machine-identity.js +170 -0
  154. package/dist/mcp-core.js +11 -1
  155. package/dist/memory-cli.d.ts +300 -1
  156. package/dist/memory-cli.js +62 -9
  157. package/dist/memory-pass.d.ts +16 -0
  158. package/dist/memory-pass.js +67 -3
  159. package/dist/memory-tidy.d.ts +181 -0
  160. package/dist/memory-tidy.js +523 -0
  161. package/dist/memory-use.d.ts +35 -0
  162. package/dist/memory-use.js +109 -0
  163. package/dist/merge-checklist.d.ts +55 -0
  164. package/dist/merge-checklist.js +182 -0
  165. package/dist/needs-you.d.ts +27 -8
  166. package/dist/needs-you.js +96 -39
  167. package/dist/nightly-github.d.ts +2 -0
  168. package/dist/nightly-github.js +4 -4
  169. package/dist/operate-remote-arguments.js +6 -1
  170. package/dist/operate-remote.js +3 -1
  171. package/dist/operate.d.ts +6 -1
  172. package/dist/operate.js +402 -52
  173. package/dist/phase-routing.js +2 -2
  174. package/dist/plain-schema.d.ts +23 -0
  175. package/dist/plain-schema.js +94 -0
  176. package/dist/plain-words.d.ts +20 -0
  177. package/dist/plain-words.js +66 -0
  178. package/dist/planner.js +2 -1
  179. package/dist/process-liveness.d.ts +2 -0
  180. package/dist/process-liveness.js +61 -17
  181. package/dist/project-delete-ui.d.ts +7 -2
  182. package/dist/project-delete-ui.js +12 -1
  183. package/dist/project-delete.js +8 -1
  184. package/dist/project-knowledge.d.ts +18 -11
  185. package/dist/project-knowledge.js +39 -9
  186. package/dist/project-learning.d.ts +2 -1
  187. package/dist/project-learning.js +15 -45
  188. package/dist/project-memory.d.ts +5 -0
  189. package/dist/project-memory.js +22 -2
  190. package/dist/proof.js +30 -5
  191. package/dist/provider-auth.js +1 -1
  192. package/dist/publish.js +9 -1
  193. package/dist/pull-request-flow.js +5 -0
  194. package/dist/push.d.ts +18 -2
  195. package/dist/push.js +40 -16
  196. package/dist/release-adapters.d.ts +9 -0
  197. package/dist/release-adapters.js +76 -0
  198. package/dist/release-auto.js +13 -13
  199. package/dist/release-card.d.ts +1 -2
  200. package/dist/release-card.js +11 -0
  201. package/dist/release-carry.d.ts +7 -2
  202. package/dist/release-carry.js +39 -9
  203. package/dist/release-cli.d.ts +4 -0
  204. package/dist/release-cli.js +29 -22
  205. package/dist/release-coverage.js +1 -1
  206. package/dist/release-next.d.ts +48 -0
  207. package/dist/release-next.js +161 -0
  208. package/dist/release.d.ts +20 -1
  209. package/dist/release.js +76 -8
  210. package/dist/remote-destination.d.ts +36 -0
  211. package/dist/remote-destination.js +217 -0
  212. package/dist/remote-main.d.ts +48 -0
  213. package/dist/remote-main.js +84 -5
  214. package/dist/repository-context.d.ts +3 -2
  215. package/dist/repository-context.js +112 -70
  216. package/dist/restart-certification.d.ts +1 -0
  217. package/dist/restart-certification.js +8 -6
  218. package/dist/result-acts.d.ts +27 -19
  219. package/dist/result-acts.js +36 -25
  220. package/dist/result-follow-ups.d.ts +2 -2
  221. package/dist/result-follow-ups.js +18 -4
  222. package/dist/result-review.d.ts +1 -1
  223. package/dist/route-change-check.d.ts +4 -0
  224. package/dist/route-change-check.js +13 -0
  225. package/dist/route-change.d.ts +22 -0
  226. package/dist/route-change.js +89 -0
  227. package/dist/route-hooks.js +3 -2
  228. package/dist/route-results.d.ts +6 -1
  229. package/dist/route-results.js +14 -5
  230. package/dist/run-closeout.d.ts +1 -1
  231. package/dist/run-closeout.js +7 -5
  232. package/dist/runner-process.js +3 -2
  233. package/dist/runner.js +2 -1
  234. package/dist/scope.d.ts +6 -1
  235. package/dist/scope.js +22 -9
  236. package/dist/serve.d.ts +1 -1
  237. package/dist/serve.js +24 -18
  238. package/dist/server/chat.js +14 -12
  239. package/dist/server/chrome.d.ts +1 -1
  240. package/dist/server/chrome.js +26 -35
  241. package/dist/server/console.css +23 -0
  242. package/dist/server/guards.js +3 -0
  243. package/dist/server/home.d.ts +6 -0
  244. package/dist/server/home.js +21 -13
  245. package/dist/server/http.js +1 -1
  246. package/dist/server/kit.js +1 -1
  247. package/dist/server/merge-ready-view.d.ts +10 -0
  248. package/dist/server/merge-ready-view.js +30 -0
  249. package/dist/server/pages-assets.d.ts +1 -0
  250. package/dist/server/pages-assets.js +8 -5
  251. package/dist/server/pages.js +103 -82
  252. package/dist/server/render-chat.js +2 -2
  253. package/dist/server/render-code-map.d.ts +30 -0
  254. package/dist/server/render-code-map.js +137 -0
  255. package/dist/server/render-flaky.d.ts +20 -0
  256. package/dist/server/render-flaky.js +59 -0
  257. package/dist/server/render-history.js +1 -1
  258. package/dist/server/render-new-task.d.ts +26 -1
  259. package/dist/server/render-new-task.js +30 -5
  260. package/dist/server/render-pages.d.ts +1 -175
  261. package/dist/server/render-pages.js +38 -313
  262. package/dist/server/render-projects.d.ts +2 -1
  263. package/dist/server/render-projects.js +9 -7
  264. package/dist/server/render-release.d.ts +2 -0
  265. package/dist/server/render-release.js +3 -3
  266. package/dist/server/render-results.d.ts +2 -0
  267. package/dist/server/render-results.js +67 -67
  268. package/dist/server/render-tasks.d.ts +13 -2
  269. package/dist/server/render-tasks.js +153 -136
  270. package/dist/server/retired-routes.d.ts +17 -0
  271. package/dist/server/retired-routes.js +53 -0
  272. package/dist/server/route-table.d.ts +3 -0
  273. package/dist/server/route-table.js +45 -12
  274. package/dist/server/settings.js +129 -22
  275. package/dist/server/task-views.js +61 -26
  276. package/dist/server/tasks.d.ts +2 -6
  277. package/dist/server/tasks.js +174 -321
  278. package/dist/server/welcome.js +1 -1
  279. package/dist/settings-groups-ui.js +1 -1
  280. package/dist/setup-guide.d.ts +2 -2
  281. package/dist/stop-proof.js +6 -4
  282. package/dist/storage-sweep-process.js +1 -1
  283. package/dist/storage-sweep.d.ts +5 -3
  284. package/dist/storage-sweep.js +30 -9
  285. package/dist/storage-ui.js +28 -17
  286. package/dist/storage.d.ts +27 -2
  287. package/dist/storage.js +39 -2
  288. package/dist/store.d.ts +80 -5
  289. package/dist/store.js +296 -87
  290. package/dist/surface.js +45 -4
  291. package/dist/sweep-quarantine.d.ts +1 -0
  292. package/dist/sweep-quarantine.js +6 -4
  293. package/dist/tamper-scan.d.ts +4 -0
  294. package/dist/tamper-scan.js +90 -0
  295. package/dist/task-aliases.d.ts +5 -0
  296. package/dist/task-aliases.js +9 -0
  297. package/dist/task-control.js +4 -1
  298. package/dist/task-outcome-cli.d.ts +2 -0
  299. package/dist/task-outcome-cli.js +17 -17
  300. package/dist/task-sizing.d.ts +5 -0
  301. package/dist/task-sizing.js +2 -0
  302. package/dist/task-source.d.ts +7 -0
  303. package/dist/task-source.js +14 -0
  304. package/dist/task-status.d.ts +9 -0
  305. package/dist/task-status.js +40 -49
  306. package/dist/teardown-git.d.ts +16 -0
  307. package/dist/teardown-git.js +63 -5
  308. package/dist/telegram-progress.js +5 -3
  309. package/dist/telegram-status.js +6 -4
  310. package/dist/test-infra.d.ts +8 -0
  311. package/dist/test-infra.js +18 -0
  312. package/dist/tidy.js +1 -1
  313. package/dist/toolroll-update.js +17 -5
  314. package/dist/tui-contract.d.ts +24 -0
  315. package/dist/tui-source.js +67 -8
  316. package/dist/tui.js +51 -51
  317. package/dist/vanished-retry.d.ts +30 -0
  318. package/dist/vanished-retry.js +174 -0
  319. package/dist/work-index.d.ts +2 -0
  320. package/dist/work-index.js +76 -62
  321. package/dist/work-summary.js +2 -0
  322. package/dist/workspace-ui.d.ts +4 -2
  323. package/dist/workspace-ui.js +28 -22
  324. package/dist/worktree.d.ts +12 -3
  325. package/dist/worktree.js +68 -13
  326. package/package.json +1 -1
  327. package/dist/server/render-next.d.ts +0 -63
  328. package/dist/server/render-next.js +0 -109
@@ -13,7 +13,7 @@
13
13
  * Every doubt fails closed: no recorded remote, a URL that isn't allowed, a failed fetch or a remote without main all
14
14
  * read as "not proven", and the task stays for a person.
15
15
  */
16
- import { mkdirSync, mkdtempSync, rmSync, existsSync } from "node:fs";
16
+ import { lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, existsSync } from "node:fs";
17
17
  import { isAbsolute, join } from "node:path";
18
18
  import { run } from "./exec.js";
19
19
  /** The ref a fetched remote main is kept under, inside the private directory only. */
@@ -35,7 +35,9 @@ export const CREDENTIAL_HELPER = process.platform === "darwin" ? "osxkeychain" :
35
35
  function hardened(helper, file = "never") {
36
36
  return ["-c", "core.hooksPath=/dev/null", "-c", "protocol.allow=never", "-c", "protocol.https.allow=always", "-c", "protocol.ssh.allow=always",
37
37
  "-c", `protocol.file.allow=${file}`, "-c", "protocol.ext.allow=never", "-c", "credential.helper=", ...(helper === "" ? [] : ["-c", `credential.helper=${helper}`]),
38
- "-c", "gc.auto=0", "-c", "maintenance.auto=false", "-c", "fetch.recurseSubmodules=false", "-c", "core.fsmonitor=false"];
38
+ "-c", "gc.auto=0", "-c", "maintenance.auto=false", "-c", "fetch.recurseSubmodules=false", "-c", "core.fsmonitor=false",
39
+ // Never to an address the recorded URL doesn't name: a moved repository is a person's choice to record (MOVED).
40
+ "-c", "http.followRedirects=false"];
39
41
  }
40
42
  /**
41
43
  * A URL Toolroll may fetch remote main from: https or ssh (either form). Local paths, file URLs and loopback hosts
@@ -96,6 +98,14 @@ export function remoteUrlProblem(url) {
96
98
  const parsed = parseRemoteUrl(url);
97
99
  return "why" in parsed ? parsed.why : null;
98
100
  }
101
+ /** What every reader of a remote main shows when its address redirects. The target is remote-supplied text: it is never
102
+ * shown, stored or followed, and the new remote is never adopted automatically. */
103
+ export const REMOTE_MOVED = "The repository moved: its address now redirects. Update the project's remote in Toolroll (Settings → Projects), then try again.";
104
+ /** git's words for a refused redirect (http.followRedirects=false), read for the status code alone. */
105
+ export function redirected(stderr) {
106
+ return /returned error: 30[12378]\b/.test(stderr);
107
+ }
108
+ const UNREAD = "Toolroll couldn't read the project's remote main";
99
109
  /**
100
110
  * One pass's reads of recorded remotes. Every `read` fetches again (the pre-teardown re-check is never older than
101
111
  * the teardown); a project's private directory lasts until `close`, so a fetch after the first only brings what
@@ -145,14 +155,16 @@ export class RemoteMains {
145
155
  return { ok: false, why: problem };
146
156
  const dir = await this.dirFor(repo);
147
157
  if (dir === null)
148
- return { ok: false, why: "Toolroll couldn't read the project's remote main" };
158
+ return { ok: false, why: UNREAD };
149
159
  const fetched = await this.exec(dir, ["fetch", "--quiet", "--no-tags", "--no-write-fetch-head", "--", url, `+refs/heads/main:${REMOTE_MAIN}`]);
160
+ if (fetched.code !== 0 && redirected(fetched.stderr))
161
+ return { ok: false, why: REMOTE_MOVED, reason: "moved" };
150
162
  if (fetched.code !== 0 || !this.clean(dir))
151
- return { ok: false, why: "Toolroll couldn't read the project's remote main" };
163
+ return { ok: false, why: UNREAD };
152
164
  const resolved = await this.exec(dir, ["rev-parse", "--verify", "--quiet", `${REMOTE_MAIN}^{commit}`]);
153
165
  const main = resolved.stdout.trim();
154
166
  if (resolved.code !== 0 || !SHA.test(main))
155
- return { ok: false, why: "Toolroll couldn't read the project's remote main" };
167
+ return { ok: false, why: UNREAD };
156
168
  const git = args => this.exec(dir, args);
157
169
  const bring = async (from, head) => {
158
170
  if (!SHA.test(head))
@@ -177,3 +189,70 @@ export class RemoteMains {
177
189
  this.dirs.clear();
178
190
  }
179
191
  }
192
+ // ---- the code map's own read: pinned, depth 1, bounded (docs/plans/code-map.md §1) ---------------------------------
193
+ const MAP_MAIN = "refs/toolroll/map-main";
194
+ /** What keeps git's own memory down. index-pack still materializes delta bases, so these reduce; they don't bound. */
195
+ const LOW_MEMORY = ["-c", "pack.threads=1", "-c", "core.deltaBaseCacheLimit=32m", "-c", "core.bigFileThreshold=1m"];
196
+ export const MAP_FETCH_LIMITS = { timeoutMs: 120_000, memoryBytes: 1024 ** 3, diskBytes: 1024 ** 3, fileBytes: 512 * 1024 ** 2, maxStderr: 64 * 1024 };
197
+ /** Git in a private directory under the map's rules: the same hardening as `RemoteMains`, plus the pin. */
198
+ export function mapGitArgs(dir, pin, args, options = {}) {
199
+ return ["--git-dir", dir, ...hardened(options.helper ?? CREDENTIAL_HELPER), ...pin.gitArgs, ...(options.extra ?? []), ...args];
200
+ }
201
+ /** The environment for it: exactly the allowed names, the fixed settings, and the pin's. */
202
+ export function mapGitEnv(pin, from = process.env) {
203
+ const env = {};
204
+ for (const name of ENV_ALLOWLIST) {
205
+ const value = from[name];
206
+ if (value !== undefined)
207
+ env[name] = value;
208
+ }
209
+ return { ...env, ...ENV, ...pin.env };
210
+ }
211
+ export function initPrivateDir(dir, bounded) {
212
+ return bounded("git", ["init", "--quiet", "--bare", "--template=", dir], { cwd: dir, env: mapGitEnv({ gitArgs: [], env: {}, proxy: null }), timeoutMs: 30_000, maxStdout: 4096, maxStderr: 4096, label: "code-map-init" });
213
+ }
214
+ /** The commit the recorded remote's main is at, without fetching: null when the remote has no `main`. */
215
+ export async function peekMain(url, dir, pin, bounded, helper) {
216
+ const ran = await bounded("git", mapGitArgs(dir, pin, ["ls-remote", "--", url, "refs/heads/main"], helper === undefined ? {} : { helper }), { cwd: dir, env: mapGitEnv(pin), timeoutMs: 30_000, maxStdout: 64 * 1024, maxStderr: MAP_FETCH_LIMITS.maxStderr, label: "code-map-peek" });
217
+ if (ran.code !== 0 && redirected(ran.stderr))
218
+ return { ok: false, why: REMOTE_MOVED, reason: "moved" };
219
+ if (ran.code !== 0 || ran.killed !== null)
220
+ return { ok: false, why: UNREAD };
221
+ const line = ran.stdout.toString("utf8").split("\n").find(one => one.endsWith("\trefs/heads/main"));
222
+ if (line === undefined)
223
+ return ran.stdout.length === 0 ? { ok: true, commit: null } : { ok: false, why: UNREAD };
224
+ const commit = line.slice(0, line.indexOf("\t"));
225
+ return SHA.test(commit) ? { ok: true, commit } : { ok: false, why: UNREAD };
226
+ }
227
+ /** A depth-1 private folder is clean when it holds only what the fetch made: a `shallow` file naming exactly the fetched
228
+ * commit, and no grafts or alternates. */
229
+ export function shallowClean(dir, commit) {
230
+ if ([join("info", "grafts"), join("objects", "info", "alternates"), join("objects", "info", "http-alternates")].some(name => existsSync(join(dir, name))))
231
+ return false;
232
+ try {
233
+ const held = lstatSync(join(dir, "shallow"));
234
+ if (!held.isFile() || held.size > 128)
235
+ return false;
236
+ return readFileSync(join(dir, "shallow"), "utf8").trim() === commit;
237
+ }
238
+ catch (error) {
239
+ // A history of one commit needs no shallow boundary, and git writes none.
240
+ return error.code === "ENOENT";
241
+ }
242
+ }
243
+ /** Fetch main at depth 1 into `dir`, under the map's limits. `onStart` records the spawn for crash cleanup. */
244
+ export async function readShallowMain(url, dir, pin, bounded, onStart, helper) {
245
+ const fetched = await bounded("git", mapGitArgs(dir, pin, ["fetch", "--quiet", "--depth=1", "--no-tags", "--no-write-fetch-head", "--", url, `+refs/heads/main:${MAP_MAIN}`], { extra: LOW_MEMORY, ...(helper === undefined ? {} : { helper }) }), { cwd: dir, env: mapGitEnv(pin), timeoutMs: MAP_FETCH_LIMITS.timeoutMs, maxStdout: 4096, maxStderr: MAP_FETCH_LIMITS.maxStderr, memoryBytes: MAP_FETCH_LIMITS.memoryBytes,
246
+ diskBytes: MAP_FETCH_LIMITS.diskBytes, fileBytes: MAP_FETCH_LIMITS.fileBytes, folder: dir, label: "code-map-fetch", ...(onStart === undefined ? {} : { onStart }) });
247
+ if (fetched.code !== 0 && redirected(fetched.stderr))
248
+ return { ok: false, why: REMOTE_MOVED, reason: "moved" };
249
+ if (fetched.killed !== null)
250
+ return { ok: false, why: `the fetch went past its ${fetched.killed} limit` };
251
+ if (fetched.code !== 0)
252
+ return { ok: false, why: UNREAD };
253
+ const resolved = await bounded("git", mapGitArgs(dir, pin, ["rev-parse", "--verify", "--quiet", `${MAP_MAIN}^{commit}`]), { cwd: dir, env: mapGitEnv(pin), timeoutMs: 30_000, maxStdout: 4096, maxStderr: 4096, label: "code-map-rev" });
254
+ const commit = resolved.stdout.toString("utf8").trim();
255
+ if (resolved.code !== 0 || !SHA.test(commit) || !shallowClean(dir, commit))
256
+ return { ok: false, why: UNREAD };
257
+ return { ok: true, commit, enforced: fetched.enforced };
258
+ }
@@ -3,7 +3,8 @@ export type RepositoryContextRequest = {
3
3
  query: string;
4
4
  mode?: 'search' | 'impact';
5
5
  audience?: 'lead' | 'crew';
6
- /** Canonical admitted project when repo is a crew worktree. Must share its Git common directory. */
6
+ /** The admitted project from Toolroll's records: repo itself, or the project a crew worktree belongs to. Every read is
7
+ * bound to its git folder. */
7
8
  project?: string;
8
9
  baseRevision?: string;
9
10
  cacheRoot?: string;
@@ -16,7 +17,7 @@ export type RepositoryCheckout = {
16
17
  head: string;
17
18
  baseRevision: string;
18
19
  sourceHash: string;
19
- source: 'working-tree';
20
+ source: 'working-tree' | 'base-commit';
20
21
  };
21
22
  export type RepositoryRelationship = {
22
23
  from: string;
@@ -1,11 +1,12 @@
1
1
  /** Optional repository context. Callers admit project access; this module grants no authority.
2
2
  * The index is disposable. Every read hashes the current bounded tracked source set;
3
3
  * a missing/changed/unreadable index falls back to ordinary text search, never a task gate. */
4
- import { checkoutGitSync } from './teardown-git.js';
4
+ import { checkoutGitSync, viewedGitSync } from './teardown-git.js';
5
5
  import { createHash, randomUUID } from 'node:crypto';
6
6
  import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
7
7
  import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
8
8
  import { scanForSecrets } from './evidence.js';
9
+ import { compilerOptionsOf, extractFile, resolveAll } from './code-extract.js';
9
10
  const VERSION = 1;
10
11
  const MAX_FILES = 1200, MAX_FILE_BYTES = 256_000, MAX_SOURCE_BYTES = 12_000_000;
11
12
  const CODE = /\.(?:[cm]?[jt]sx?)$/i;
@@ -15,31 +16,23 @@ const sha = (value) => createHash('sha256').update(value).digest('hex');
15
16
  // Read-only and bounded, and always the hardened checkout git (a crew worktree is agent-written, and a page read or a
16
17
  // lead's refresh reads it alike): no filter, hook, program or network, its config and info frozen in a view of the
17
18
  // plane's own before they're inspected (teardown-git.ts checkoutGitSync).
18
- const git = (repo, args) => checkoutGitSync(repo, args, { timeoutMs: 10_000, maxBuffer: 2_000_000 }).trimEnd();
19
+ // Bound to the project's record, so a pointer or commondir can't turn the read to another repository.
20
+ const git = (repo, args, binding) => checkoutGitSync(repo, args, { timeoutMs: 10_000, maxBuffer: 2_000_000, binding }).trimEnd();
19
21
  const inside = (root, path) => { const r = relative(root, path); return r !== '..' && !r.startsWith('..' + sep) && !isAbsolute(r); };
20
- function capture(request) {
21
- const repo = realpathSync(request.repo);
22
- if (repo !== resolve(request.repo))
23
- throw Error('The project location changed.');
24
- if (realpathSync(git(repo, ['rev-parse', '--show-toplevel'])) !== repo)
25
- throw Error('Choose the project checkout root.');
26
- const head = git(repo, ['rev-parse', '--verify', 'HEAD']);
27
- const baseRevision = request.baseRevision ?? head;
28
- if (!/^[a-f0-9]{40,64}$/.test(baseRevision) || git(repo, ['rev-parse', '--verify', `${baseRevision}^{commit}`]) !== baseRevision)
29
- throw Error('The requested context base is unavailable.');
30
- if (request.audience === 'crew' && baseRevision !== head)
31
- throw Error('Crew context must start at its exact task base.');
32
- const gitDir = realpathSync(git(repo, ['rev-parse', '--absolute-git-dir']));
33
- if (request.project && realpathSync(git(repo, ['rev-parse', '--path-format=absolute', '--git-common-dir'])) !== realpathSync(git(request.project, ['rev-parse', '--path-format=absolute', '--git-common-dir'])))
34
- throw Error('The worktree belongs to a different project.');
35
- const files = git(repo, ['ls-files', '-z', '--cached']).split('\0').filter(Boolean).sort();
36
- const sources = [], warnings = [];
22
+ /** Whether a tracked path is one the index reads, by its name alone. */
23
+ const skippedPath = (file) => !TEXT.test(file) || /(?:^|\/)(?:node_modules|vendor|dist|build|coverage|\.git)(?:\/|$)/.test(file);
24
+ const unsafePath = (file) => file.split('/').some(p => p === '..') || /[\x00-\x1f]/.test(file);
25
+ const unreadableText = (data, text) => data.length > MAX_FILE_BYTES || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f\ufffd]/u.test(text) || scanForSecrets(text).length > 0;
26
+ /** The working tree's tracked files, for a lead or a person looking at the project's own checkout. */
27
+ function workingTreeSources(repo, binding) {
28
+ const files = git(repo, ['ls-files', '-z', '--cached'], binding).split('\0').filter(Boolean).sort();
29
+ const sources = [];
37
30
  let omitted = 0, omittedBytes = 0, bytes = 0;
38
31
  for (const file of [...new Set(files)]) {
39
- if (!TEXT.test(file) || /(?:^|\/)(?:node_modules|vendor|dist|build|coverage|\.git)(?:\/|$)/.test(file))
32
+ if (skippedPath(file))
40
33
  continue;
41
34
  const path = resolve(repo, file);
42
- if (!inside(repo, path) || file.split('/').some(p => p === '..') || /[\x00-\x1f]/.test(file)) {
35
+ if (!inside(repo, path) || unsafePath(file)) {
43
36
  omitted++;
44
37
  continue;
45
38
  }
@@ -57,7 +50,7 @@ function capture(request) {
57
50
  }
58
51
  const data = readFileSync(path);
59
52
  const text = data.toString('utf8');
60
- if (data.length > MAX_FILE_BYTES || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f\ufffd]/u.test(text) || scanForSecrets(text).length) {
53
+ if (unreadableText(data, text)) {
61
54
  omitted++;
62
55
  omittedBytes += data.length;
63
56
  continue;
@@ -69,13 +62,91 @@ function capture(request) {
69
62
  omitted++;
70
63
  }
71
64
  }
72
- if (git(repo, ['rev-parse', '--verify', 'HEAD']) !== head)
65
+ return { sources, omitted, omittedBytes };
66
+ }
67
+ /**
68
+ * A crew's sources: the task base's tree and raw blobs, never the working tree an agent writes. A dirty tracked file,
69
+ * a symlink swapped in or a replace ref is not read; no filter runs on a raw blob.
70
+ */
71
+ function baseCommitSources(repo, base, binding) {
72
+ const read = (args, maxBuffer, input) => {
73
+ const ran = viewedGitSync(repo, ['--no-replace-objects', ...args], { timeoutMs: 10_000, maxBuffer, binding, buffer: true, ...(input === undefined ? {} : { input }) });
74
+ if (ran.status !== 0)
75
+ throw Error('The task base could not be read.');
76
+ return ran.stdout;
77
+ };
78
+ const wanted = [];
79
+ let omitted = 0, omittedBytes = 0, bytes = 0;
80
+ for (const entry of read(['ls-tree', '-r', '-l', '-z', base], 16_000_000).toString('utf8').split('\0')) {
81
+ const match = /^(\d{6}) (\w+) ([0-9a-f]{40,64}) +(\d+|-)\t(.+)$/s.exec(entry);
82
+ if (!match)
83
+ continue;
84
+ const [, mode, kind, oid, sized, file] = match;
85
+ if (skippedPath(file))
86
+ continue;
87
+ const size = sized === '-' ? 0 : Number(sized);
88
+ if (unsafePath(file) || kind !== 'blob' || (mode !== '100644' && mode !== '100755')) {
89
+ omitted++;
90
+ continue;
91
+ }
92
+ if (wanted.length >= MAX_FILES || size > MAX_FILE_BYTES || bytes + size > MAX_SOURCE_BYTES) {
93
+ omitted++;
94
+ omittedBytes += size;
95
+ continue;
96
+ }
97
+ wanted.push({ file, oid });
98
+ bytes += size;
99
+ }
100
+ wanted.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
101
+ const sources = [];
102
+ if (wanted.length === 0)
103
+ return { sources, omitted, omittedBytes };
104
+ const out = read(['cat-file', '--batch'], MAX_SOURCE_BYTES + wanted.length * 128 + 4096, Buffer.from(wanted.map(w => w.oid).join('\n') + '\n'));
105
+ let at = 0;
106
+ for (const { file, oid } of wanted) {
107
+ const end = out.indexOf(0x0a, at);
108
+ const header = end < 0 ? null : /^([0-9a-f]{40,64}) blob (\d+)$/.exec(out.subarray(at, end).toString('latin1'));
109
+ if (end < 0 || header === null || header[1] !== oid)
110
+ throw Error('The task base could not be read.');
111
+ const size = Number(header[2]);
112
+ const data = out.subarray(end + 1, end + 1 + size);
113
+ at = end + 1 + size + 1;
114
+ const text = data.toString('utf8');
115
+ if (unreadableText(data, text)) {
116
+ omitted++;
117
+ omittedBytes += data.length;
118
+ continue;
119
+ }
120
+ sources.push({ file, text, sha256: sha(data) });
121
+ }
122
+ return { sources, omitted, omittedBytes };
123
+ }
124
+ function capture(request) {
125
+ const repo = realpathSync(request.repo);
126
+ if (repo !== resolve(request.repo))
127
+ throw Error('The project location changed.');
128
+ // The project's record, never the checkout's own pointer files: the admitted project, or the one a crew worktree
129
+ // belongs to. Only a person's local look at an unrecorded folder has none.
130
+ const binding = { repo: request.project ?? null };
131
+ if (realpathSync(git(repo, ['rev-parse', '--show-toplevel'], binding)) !== repo)
132
+ throw Error('Choose the project checkout root.');
133
+ const head = git(repo, ['rev-parse', '--verify', 'HEAD'], binding);
134
+ const baseRevision = request.baseRevision ?? head;
135
+ if (!/^[a-f0-9]{40,64}$/.test(baseRevision) || git(repo, ['rev-parse', '--verify', `${baseRevision}^{commit}`], binding) !== baseRevision)
136
+ throw Error('The requested context base is unavailable.');
137
+ if (request.audience === 'crew' && baseRevision !== head)
138
+ throw Error('Crew context must start at its exact task base.');
139
+ const gitDir = realpathSync(git(repo, ['rev-parse', '--absolute-git-dir'], binding));
140
+ const crew = request.audience === 'crew';
141
+ const { sources, omitted, omittedBytes } = crew ? baseCommitSources(repo, baseRevision, binding) : workingTreeSources(repo, binding);
142
+ if (git(repo, ['rev-parse', '--verify', 'HEAD'], binding) !== head)
73
143
  throw Error('The checkout changed while context was being read.');
144
+ const warnings = [];
74
145
  if (omitted)
75
146
  warnings.push(`${omitted} source files were omitted because they were unavailable, excluded, sensitive or beyond the read limits.`);
76
147
  warnings.push('Tracked files only. Static imports do not establish every runtime dependency or sufficient test coverage.');
77
148
  const sourceHash = sha(JSON.stringify(sources.map(({ file, sha256 }) => [file, sha256])));
78
- return { checkout: { repo, gitDir, head, baseRevision, sourceHash, source: 'working-tree' }, sources, omitted, omittedBytes, warnings };
149
+ return { checkout: { repo, gitDir, head, baseRevision, sourceHash, source: crew ? 'base-commit' : 'working-tree' }, sources, omitted, omittedBytes, warnings };
79
150
  }
80
151
  function cacheFile(root, checkout) {
81
152
  const absolute = resolve(root);
@@ -135,62 +206,33 @@ async function buildIndex(snapshot) {
135
206
  dir = dirname(dir);
136
207
  }
137
208
  }
138
- let options = { module: compiler.ModuleKind.NodeNext, moduleResolution: compiler.ModuleResolutionKind.NodeNext, allowJs: true, resolveJsonModule: true };
139
209
  const config = byPath.get(join(snapshot.checkout.repo, 'tsconfig.json'));
210
+ const { options, problem } = compilerOptionsOf(compiler, join(snapshot.checkout.repo, 'tsconfig.json'), config?.text, snapshot.checkout.repo);
140
211
  const warnings = [];
141
- if (config) {
142
- const parsed = compiler.parseConfigFileTextToJson(join(snapshot.checkout.repo, config.file), config.text);
143
- if (!parsed.error && parsed.config?.compilerOptions) {
144
- const converted = compiler.convertCompilerOptionsFromJson(parsed.config.compilerOptions, snapshot.checkout.repo);
145
- options = { ...options, ...converted.options };
146
- if (parsed.config.extends)
147
- warnings.push('Inherited tsconfig settings are not indexed; unresolved aliases remain omitted.');
148
- }
149
- else
150
- warnings.push('TypeScript configuration could not be read; relative imports still work.');
151
- }
212
+ if (problem === 'extends')
213
+ warnings.push('Inherited tsconfig settings are not indexed; unresolved aliases remain omitted.');
214
+ else if (problem === 'unreadable')
215
+ warnings.push('TypeScript configuration could not be read; relative imports still work.');
216
+ const resolveOne = resolveAll(compiler, snapshot.checkout.repo, byPath, directories, options, resolve);
152
217
  const nodes = [], relationships = [];
153
218
  let unresolved = 0, parseFailures = 0;
154
- const host = {
155
- fileExists: file => byPath.has(resolve(file)),
156
- readFile: file => byPath.get(resolve(file))?.text,
157
- directoryExists: dir => directories.has(resolve(dir)),
158
- getCurrentDirectory: () => snapshot.checkout.repo,
159
- realpath: file => resolve(file),
160
- };
161
219
  for (const source of snapshot.sources.filter(s => CODE.test(s.file))) {
162
220
  const fileName = resolve(snapshot.checkout.repo, source.file);
163
- const tree = compiler.createSourceFile(fileName, source.text, compiler.ScriptTarget.Latest, true);
164
- if (tree.parseDiagnostics?.length)
221
+ const read = extractFile(compiler, fileName, source.text);
222
+ if (read.parseFailed)
165
223
  parseFailures++;
166
- const line = (pos) => tree.getLineAndCharacterOfPosition(Math.min(pos, source.text.length)).line + 1;
167
- const relationship = (specifier, kind, at) => {
168
- const resolved = compiler.resolveModuleName(specifier, fileName, options, host).resolvedModule?.resolvedFileName;
169
- if (!resolved || !byPath.has(resolve(resolved))) {
224
+ for (const one of read.declarations)
225
+ if (nodes.length < MAX_NODES)
226
+ nodes.push({ file: source.file, name: one.name, line: one.line, endLine: one.endLine });
227
+ for (const one of read.specifiers) {
228
+ const resolved = resolveOne(one.specifier, fileName);
229
+ if (resolved === null) {
170
230
  unresolved++;
171
- return;
231
+ continue;
172
232
  }
173
233
  if (relationships.length < MAX_EDGES)
174
- relationships.push({ from: source.file, to: byPath.get(resolve(resolved)).file, kind, file: source.file, line: line(at), provenance: 'explicit' });
175
- };
176
- const visit = (node) => {
177
- if (nodes.length < MAX_NODES && (compiler.isFunctionDeclaration(node) || compiler.isClassDeclaration(node) || compiler.isInterfaceDeclaration(node) || compiler.isTypeAliasDeclaration(node) || compiler.isEnumDeclaration(node) || compiler.isMethodDeclaration(node) || compiler.isVariableDeclaration(node))) {
178
- const name = node.name;
179
- if (name && compiler.isIdentifier(name))
180
- nodes.push({ file: source.file, name: name.text, line: line(node.getStart(tree)), endLine: line(node.end) });
181
- }
182
- if (compiler.isImportDeclaration(node) && compiler.isStringLiteral(node.moduleSpecifier))
183
- relationship(node.moduleSpecifier.text, 'imports', node.getStart(tree));
184
- if (compiler.isExportDeclaration(node) && node.moduleSpecifier && compiler.isStringLiteral(node.moduleSpecifier))
185
- relationship(node.moduleSpecifier.text, 're-exports', node.getStart(tree));
186
- if (compiler.isCallExpression(node) && node.arguments.length === 1 && compiler.isStringLiteral(node.arguments[0])) {
187
- if (node.expression.kind === compiler.SyntaxKind.ImportKeyword)
188
- relationship(node.arguments[0].text, 'dynamic-import', node.getStart(tree));
189
- // require may be shadowed; do not present a guessed call as a proven relationship.
190
- }
191
- compiler.forEachChild(node, visit);
192
- };
193
- visit(tree);
234
+ relationships.push({ from: source.file, to: byPath.get(resolved).file, kind: one.kind, file: source.file, line: one.line, provenance: 'explicit' });
235
+ }
194
236
  }
195
237
  if (unresolved)
196
238
  warnings.push(`${unresolved} external or unresolved imports were omitted.`);
@@ -77,6 +77,7 @@ export type RestartBaseline = {
77
77
  bootId: string | null;
78
78
  containment: string | null;
79
79
  open: boolean;
80
+ machineId?: string | null;
80
81
  }[];
81
82
  };
82
83
  limits: readonly string[];
@@ -25,6 +25,7 @@ import { dirname, join } from "node:path";
25
25
  import { fileURLToPath } from "node:url";
26
26
  import { existsSync, readFileSync, realpathSync, readdirSync, statSync } from "node:fs";
27
27
  import { bootIdentity, provenDeadByBootChange } from "./boot-identity.js";
28
+ import { sameMachineHost } from "./machine-identity.js";
28
29
  import { containmentStatus, currentContainment } from "./containment.js";
29
30
  import { daemonStatus } from "./daemon.js";
30
31
  import { isAlive } from "./runner.js";
@@ -70,8 +71,8 @@ export async function recordRestartBaseline(inputs) {
70
71
  .map(row => ({ run: Number(row["run"]), taskRef: Number(row["task_ref"]), requestedAt: String(row["requested_at"]), problem: store.stopQuiescenceProblem(Number(row["run"])) }));
71
72
  const openRuns = raw.prepare("SELECT id, task_ref, role, runner, lease_id FROM run WHERE outcome IS NULL ORDER BY id").all()
72
73
  .map(row => ({ id: Number(row["id"]), taskRef: Number(row["task_ref"]), role: String(row["role"]), runner: String(row["runner"]), leaseId: String(row["lease_id"]) }));
73
- const witnesses = raw.prepare("SELECT run, pid, host, boot_id, containment, exited_at FROM run_process WHERE exited_at IS NULL ORDER BY id").all()
74
- .map(row => ({ run: Number(row["run"]), pid: row["pid"] === null ? null : Number(row["pid"]), host: String(row["host"]), bootId: row["boot_id"] === null ? null : String(row["boot_id"]), containment: row["containment"] === null ? null : String(row["containment"]), open: true }));
74
+ const witnesses = raw.prepare("SELECT run, pid, host, boot_id, containment, exited_at, machine_id FROM run_process WHERE exited_at IS NULL ORDER BY id").all()
75
+ .map(row => ({ run: Number(row["run"]), pid: row["pid"] === null ? null : Number(row["pid"]), host: String(row["host"]), bootId: row["boot_id"] === null ? null : String(row["boot_id"]), containment: row["containment"] === null ? null : String(row["containment"]), open: true, machineId: row["machine_id"] == null ? null : String(row["machine_id"]) }));
75
76
  return {
76
77
  version: 2,
77
78
  runnerName: inputs.runnerName ?? null,
@@ -123,7 +124,8 @@ export async function verifyRestartRecovery(inputs) {
123
124
  const sameDatabase = baseline.version === 2 && baseline.database.identity === current.database.identity;
124
125
  checks.push({ name: "database", ok: sameDatabase, detail: sameDatabase ? "same database file identity" : "database identity differs or baseline predates identity verification; no recovery mutations performed" });
125
126
  // 1. Boot identity, as the OS reports it.
126
- const sameHost = baseline.host === current.host;
127
+ // This machine under any of its own names (machine-identity.ts): a reboot onto another network can move the hostname.
128
+ const sameHost = sameMachineHost(baseline.host, current.host);
127
129
  const bootChanged = !sameHost || baseline.boot.id === null || current.boot.id === null
128
130
  ? null
129
131
  : provenDeadByBootChange({ host: current.host, bootId: baseline.boot.id }, { host: current.host, bootId: current.boot.id });
@@ -153,7 +155,7 @@ export async function verifyRestartRecovery(inputs) {
153
155
  }
154
156
  const baselineBeats = new Map(baseline.database.runners.map(one => [one.name, one.heartbeatAt]));
155
157
  const liveRunners = current.database.runners.filter(one => !one.retired);
156
- const fresh = liveRunners.filter(one => one.host === current.host && one.alive && (baseline.runnerName === null || one.name === baseline.runnerName) && baselineBeats.has(one.name) && one.heartbeatAt > baselineBeats.get(one.name));
158
+ const fresh = liveRunners.filter(one => sameMachineHost(one.host, current.host) && one.alive && (baseline.runnerName === null || one.name === baseline.runnerName) && baselineBeats.has(one.name) && one.heartbeatAt > baselineBeats.get(one.name));
157
159
  checks.push({
158
160
  name: "heartbeat",
159
161
  ok: fresh.length > 0,
@@ -187,8 +189,8 @@ export async function verifyRestartRecovery(inputs) {
187
189
  ? `every run open at the baseline has ended (${baseline.database.openRuns.map(run => `#${run.id}`).join(", ")})`
188
190
  : `still open from before: ${staleOpen.map(run => `#${run.id} (${run.role}, ${run.runner})`).join(", ")} — recovery has not run, or its lease is still live`,
189
191
  });
190
- const oldWitnesses = baseline.database.witnesses.filter(one => one.bootId !== null && current.boot.id !== null && one.host === current.host);
191
- const survivors = oldWitnesses.filter(one => !provenDeadByBootChange({ host: one.host, bootId: one.bootId }, { host: current.host, bootId: current.boot.id }));
192
+ const oldWitnesses = baseline.database.witnesses.filter(one => one.bootId !== null && current.boot.id !== null && sameMachineHost(one.host, current.host, one.machineId));
193
+ const survivors = oldWitnesses.filter(one => !provenDeadByBootChange({ host: one.host, bootId: one.bootId }, { host: one.host, bootId: current.boot.id }));
192
194
  checks.push({
193
195
  name: "old-boot-custody",
194
196
  ok: bootChanged !== true || survivors.length === 0,
@@ -1,26 +1,25 @@
1
- /** The result page's acts (2026-10-02), chosen in one place: exactly one ink
2
- * act, the one that resolves the result's state, and never navigation; at
3
- * most one outline act beside it. A result that can't be accepted says why
4
- * in one line before them.
1
+ /** The result page's acts (2026-10-02; docs/plans/plain-toolroll.md P1.3), chosen in one place: three verbs, each a
2
+ * relabel over the post it has always made. Exactly one ink act at most, the one that resolves the result's state,
3
+ * and never navigation; at most one outline act beside it. A result that can't be accepted says why in one line
4
+ * before them. Run checks stays in the Checks row, and a person's own checks in the Checks list: neither is an act here.
5
5
  *
6
- * everything met → Accept and finish
7
- * a "You check this one" unanswered → the next check, then Accept without your check
8
- * a check you marked Not right → Request changes, then Accept anyway
9
- * checks queued or running → Checks running (disabled), then Accept without checks
10
- * checks didn't run, a check exists → Run checks, then Accept without checks
6
+ * everything met → Accept
7
+ * a "You check this one" unanswered → (the check itself, above), then Accept without your check
8
+ * a check you marked Not right → Send back, then Accept anyway
9
+ * checks queued or running → (Checks running, in its row), then Accept without checks
10
+ * checks didn't run, a check exists → (Run checks, in its row), then Accept without checks
11
11
  * report and changes disagree,
12
- * or the proof is refuted → Request changes, then Accept (as allowed);
12
+ * or the proof is refuted → Send back, then Accept (as allowed);
13
13
  * never Accept as ink, even with no form here
14
- * a Needs you form resolves it → that form (Build again, Check again)
15
- * saved notes or a failing PR → Revise / Draft a repair task
16
- * a failed build → Retry (its note starts with what to change), then Run checks when they
17
- * didn't run; never Accept: a failed build is not a result to accept
14
+ * a Needs you form resolves it → that form (Retry: Build again; Check again)
15
+ * saved notes or a failing PR → Send back (the revision from the notes, or the repair of the PR)
16
+ * a failed build → Retry (its note starts with what to change); never Accept: a failed build
17
+ * is not a result to accept
18
18
  * a failed task's delivered result → Retry, then Accept anyway (in outline, with a reason) where it may be
19
- * accepted; Run checks stays in its Checks row
19
+ * accepted
20
20
  *
21
- * Accepting finishes the task in one request (Accept and finish): the person's
22
- * acceptance, when one is owed, and the completion together. This only decides
23
- * which act is ink; the page recomputes it as the person answers each check. */
21
+ * Accepting finishes the task in one request: the person's acceptance, when one is owed, and the completion
22
+ * together. This only decides which act is ink; the page recomputes it as the person answers each check. */
24
23
  import type { ProofVerdict } from "./proof.js";
25
24
  /** The words over the reason field an Accept of a refuted result requires. */
26
25
  export declare const ACCEPT_NEEDS_REASON = "Accepting needs a reason";
@@ -74,12 +73,21 @@ export type ResultActs = {
74
73
  /** The one line before the acts: why it can't be accepted yet. */
75
74
  line: string | null;
76
75
  };
76
+ /** The three verbs, and the posts each one makes (docs/plans/plain-toolroll.md P1.3): Accept is today's Accept (and
77
+ * finish), or Accept anyway with a reason; Send back is today's request for changes and its revision from saved notes
78
+ * for an unpublished result, or the repair of a published one; Retry is today's Retry, or Build again. Check again
79
+ * keeps its own words (its act stands on the build, not on the result). */
80
+ export type ResultVerb = "Accept" | "Send back" | "Retry";
81
+ export declare const RESULT_VERB: Readonly<Record<ResultActKind, ResultVerb | null>>;
82
+ /** What Send back does, said on its own confirm line: the rule is fixed by whether the result is already published. */
83
+ export declare const SEND_BACK_UNPUBLISHED = "Sends it back with your notes. The same task builds again once you approve the revision.";
84
+ export declare const SEND_BACK_PUBLISHED = "It's already published, so this files a fix as a new version for you to approve.";
77
85
  export declare function resultActsOf(facts: ResultActFacts): ResultActs;
78
86
  /** The words a result's Accept wears and the one line before it, given the
79
87
  * server's words for everything but the person's own checks (`base`) and how
80
88
  * those stand on this page: an unanswered check names itself; a check marked
81
89
  * Not right says so; otherwise the base words stand. */
82
- export type AcceptLabel = "Accept and finish" | "Accept without checks" | "Accept without your check" | "Accept anyway";
90
+ export type AcceptLabel = "Accept" | "Accept without checks" | "Accept without your check" | "Accept anyway";
83
91
  export declare function acceptWithChecksOf(base: {
84
92
  label: AcceptLabel;
85
93
  ready: boolean;
@@ -1,26 +1,25 @@
1
- /** The result page's acts (2026-10-02), chosen in one place: exactly one ink
2
- * act, the one that resolves the result's state, and never navigation; at
3
- * most one outline act beside it. A result that can't be accepted says why
4
- * in one line before them.
1
+ /** The result page's acts (2026-10-02; docs/plans/plain-toolroll.md P1.3), chosen in one place: three verbs, each a
2
+ * relabel over the post it has always made. Exactly one ink act at most, the one that resolves the result's state,
3
+ * and never navigation; at most one outline act beside it. A result that can't be accepted says why in one line
4
+ * before them. Run checks stays in the Checks row, and a person's own checks in the Checks list: neither is an act here.
5
5
  *
6
- * everything met → Accept and finish
7
- * a "You check this one" unanswered → the next check, then Accept without your check
8
- * a check you marked Not right → Request changes, then Accept anyway
9
- * checks queued or running → Checks running (disabled), then Accept without checks
10
- * checks didn't run, a check exists → Run checks, then Accept without checks
6
+ * everything met → Accept
7
+ * a "You check this one" unanswered → (the check itself, above), then Accept without your check
8
+ * a check you marked Not right → Send back, then Accept anyway
9
+ * checks queued or running → (Checks running, in its row), then Accept without checks
10
+ * checks didn't run, a check exists → (Run checks, in its row), then Accept without checks
11
11
  * report and changes disagree,
12
- * or the proof is refuted → Request changes, then Accept (as allowed);
12
+ * or the proof is refuted → Send back, then Accept (as allowed);
13
13
  * never Accept as ink, even with no form here
14
- * a Needs you form resolves it → that form (Build again, Check again)
15
- * saved notes or a failing PR → Revise / Draft a repair task
16
- * a failed build → Retry (its note starts with what to change), then Run checks when they
17
- * didn't run; never Accept: a failed build is not a result to accept
14
+ * a Needs you form resolves it → that form (Retry: Build again; Check again)
15
+ * saved notes or a failing PR → Send back (the revision from the notes, or the repair of the PR)
16
+ * a failed build → Retry (its note starts with what to change); never Accept: a failed build
17
+ * is not a result to accept
18
18
  * a failed task's delivered result → Retry, then Accept anyway (in outline, with a reason) where it may be
19
- * accepted; Run checks stays in its Checks row
19
+ * accepted
20
20
  *
21
- * Accepting finishes the task in one request (Accept and finish): the person's
22
- * acceptance, when one is owed, and the completion together. This only decides
23
- * which act is ink; the page recomputes it as the person answers each check. */
21
+ * Accepting finishes the task in one request: the person's acceptance, when one is owed, and the completion
22
+ * together. This only decides which act is ink; the page recomputes it as the person answers each check. */
24
23
  /** The words over the reason field an Accept of a refuted result requires. */
25
24
  export const ACCEPT_NEEDS_REASON = "Accepting needs a reason";
26
25
  export const FAILED_CHECK = /^the repository's approved verification command exited (-?[0-9]+)/;
@@ -42,34 +41,46 @@ export function cantAcceptYetOf(verdict, reasons, accepted) {
42
41
  ? "Can't accept yet: the project's check failed on these changes."
43
42
  : ACCEPT_NEEDS_REASON;
44
43
  }
44
+ export const RESULT_VERB = {
45
+ accept: "Accept", "accept-anyway": "Accept",
46
+ "request-changes": "Send back", revise: "Send back", "draft-repair": "Send back",
47
+ retry: "Retry", rebuild: "Retry",
48
+ "recheck-stopped": null,
49
+ // Never in the act row: Run checks lives in the Checks row, and a person's own checks in the Checks list.
50
+ "next-check": null, "checks-running": null, "run-checks": null,
51
+ };
52
+ /** What Send back does, said on its own confirm line: the rule is fixed by whether the result is already published. */
53
+ export const SEND_BACK_UNPUBLISHED = "Sends it back with your notes. The same task builds again once you approve the revision.";
54
+ export const SEND_BACK_PUBLISHED = "It's already published, so this files a fix as a new version for you to approve.";
45
55
  export function resultActsOf(facts) {
46
56
  const request = facts.canRequest ? "request-changes" : null;
47
57
  const accept = facts.accept === null ? null : "accept";
48
58
  if (facts.failed != null) {
49
- const checks = facts.runChecks && !facts.checksRunning ? "run-checks" : facts.checksRunning ? "checks-running" : null;
50
59
  const anyway = facts.failed.acceptAnyway === true ? "accept-anyway" : null;
51
- return facts.failed.retry ? { primary: "retry", secondary: anyway ?? checks, line: null } : { primary: checks, secondary: anyway, line: null };
60
+ return facts.failed.retry ? { primary: "retry", secondary: anyway, line: null } : { primary: null, secondary: anyway, line: null };
52
61
  }
53
62
  if (facts.need !== null)
54
63
  return { primary: facts.need, secondary: request, line: null };
64
+ // Saved notes or a failing pull request: Send back is ink, and Accept the quiet other way.
55
65
  if (facts.next !== null)
56
- return { primary: facts.next, secondary: accept ?? request, line: null };
66
+ return { primary: facts.next, secondary: accept, line: null };
57
67
  // Can't be accepted yet: Accept is never the ink act, even when the feedback form isn't here.
58
68
  if (facts.blocked !== null)
59
69
  return { primary: request, secondary: accept, line: facts.blocked };
60
- // The person's own checks come first: the next one is ink until each is answered.
70
+ // The person's own checks come first, in the Checks list above: nothing here is ink until each is answered.
61
71
  if (accept !== null && (facts.unanswered ?? 0) > 0)
62
- return { primary: "next-check", secondary: accept, line: null };
72
+ return { primary: null, secondary: accept, line: null };
63
73
  if (accept !== null && (facts.notRight ?? 0) > 0)
64
74
  return { primary: request, secondary: accept, line: null };
75
+ // Checks running, or not run: their row says so and offers Run checks; Accept waits in outline.
65
76
  if (facts.checksRunning)
66
- return { primary: "checks-running", secondary: accept ?? request, line: null };
77
+ return { primary: null, secondary: accept ?? request, line: null };
67
78
  if (facts.accept === null)
68
79
  return { primary: null, secondary: request, line: null };
69
80
  if (facts.accept.ready)
70
81
  return { primary: "accept", secondary: request, line: null };
71
82
  if (facts.runChecks)
72
- return { primary: "run-checks", secondary: "accept", line: null };
83
+ return { primary: null, secondary: "accept", line: null };
73
84
  return { primary: "accept", secondary: request, line: null };
74
85
  }
75
86
  export function acceptWithChecksOf(base, checks) {