toolroll 0.9.63 → 0.9.67

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 (302) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/README.md +33 -5
  3. package/dist/acp-agents.d.ts +43 -0
  4. package/dist/acp-agents.js +62 -0
  5. package/dist/acp-attest.d.ts +44 -0
  6. package/dist/acp-attest.js +269 -0
  7. package/dist/acp-check.d.ts +28 -0
  8. package/dist/acp-check.js +410 -0
  9. package/dist/acp-client.d.ts +163 -0
  10. package/dist/acp-client.js +738 -0
  11. package/dist/acp-inspect.d.ts +23 -0
  12. package/dist/acp-inspect.js +109 -0
  13. package/dist/agent-board.d.ts +19 -6
  14. package/dist/agent-board.js +30 -9
  15. package/dist/agent-fence.d.ts +117 -2
  16. package/dist/agent-fence.js +439 -12
  17. package/dist/agent-pulse.js +9 -2
  18. package/dist/agent-state-words.d.ts +19 -0
  19. package/dist/agent-state-words.js +20 -0
  20. package/dist/agent-state.d.ts +65 -0
  21. package/dist/agent-state.js +229 -0
  22. package/dist/agentconfig.js +39 -13
  23. package/dist/agents-board.d.ts +34 -2
  24. package/dist/agents-board.js +110 -1
  25. package/dist/approval-rules-ui.js +2 -2
  26. package/dist/assignment-delivery.d.ts +14 -0
  27. package/dist/assignment-delivery.js +13 -0
  28. package/dist/assignment.js +13 -0
  29. package/dist/attest.js +7 -0
  30. package/dist/backup-ui.js +2 -2
  31. package/dist/batch-checks.js +3 -2
  32. package/dist/browser/THIRD_PARTY_NOTICES.txt +94 -0
  33. package/dist/browser/terminal.css +37 -0
  34. package/dist/browser/terminal.js +228 -0
  35. package/dist/browser/workspace.css +2 -2
  36. package/dist/browser/workspace.js +42 -38
  37. package/dist/browser-shell.js +6 -3
  38. package/dist/browser-workspace.d.ts +235 -1
  39. package/dist/browser-workspace.js +60 -0
  40. package/dist/builder.d.ts +10 -1
  41. package/dist/builder.js +87 -24
  42. package/dist/chat-actions.d.ts +13 -1
  43. package/dist/chat-actions.js +197 -15
  44. package/dist/chat-ask.d.ts +4 -0
  45. package/dist/chat-ask.js +14 -0
  46. package/dist/chat-channel.js +20 -0
  47. package/dist/chat-continuity.js +5 -4
  48. package/dist/chat-decide.d.ts +0 -14
  49. package/dist/chat-decide.js +4 -1
  50. package/dist/chat-delivery.d.ts +9 -0
  51. package/dist/chat-delivery.js +45 -6
  52. package/dist/chat-messages.d.ts +8 -0
  53. package/dist/chat-messages.js +36 -4
  54. package/dist/check-levels-ui.js +2 -2
  55. package/dist/checkout-proof.d.ts +28 -0
  56. package/dist/checkout-proof.js +62 -0
  57. package/dist/claim.d.ts +12 -0
  58. package/dist/claim.js +19 -0
  59. package/dist/cli-help.d.ts +1 -1
  60. package/dist/cli-help.js +4 -1
  61. package/dist/cli.d.ts +3 -0
  62. package/dist/cli.js +21 -2
  63. package/dist/coding-ui.js +6 -4
  64. package/dist/console-instance.d.ts +124 -0
  65. package/dist/console-instance.js +337 -0
  66. package/dist/contracts/chat-actions.d.ts +16 -0
  67. package/dist/contracts/chat-actions.js +4 -0
  68. package/dist/contracts/chat-content.d.ts +19 -0
  69. package/dist/contracts/chat-content.js +6 -0
  70. package/dist/contracts/console-api.d.ts +13 -4
  71. package/dist/contracts/console-api.js +13 -4
  72. package/dist/contracts/flow-card.d.ts +4 -0
  73. package/dist/contracts/flow-card.js +3 -0
  74. package/dist/contracts/flow.d.ts +46 -0
  75. package/dist/contracts/flow.js +11 -2
  76. package/dist/contracts/lead-tools.d.ts +92 -0
  77. package/dist/contracts/lead-tools.js +13 -0
  78. package/dist/contracts/provider.d.ts +11 -1
  79. package/dist/contracts/provider.js +1 -1
  80. package/dist/contracts/route.d.ts +76 -1
  81. package/dist/contracts/route.js +1 -1
  82. package/dist/contracts/store-json.d.ts +2 -0
  83. package/dist/control-setup.d.ts +1 -1
  84. package/dist/control-ui.js +2 -2
  85. package/dist/converse.d.ts +16 -0
  86. package/dist/converse.js +32 -4
  87. package/dist/demo-chat.js +4 -4
  88. package/dist/demo.js +2 -2
  89. package/dist/desktop-update-gate.js +6 -0
  90. package/dist/desktop-update.d.ts +1 -1
  91. package/dist/discord-chat.js +4 -1
  92. package/dist/doctor.js +1 -1
  93. package/dist/egress-bridge.d.ts +17 -0
  94. package/dist/egress-bridge.js +111 -0
  95. package/dist/egress.d.ts +119 -0
  96. package/dist/egress.js +423 -0
  97. package/dist/exec.d.ts +78 -0
  98. package/dist/exec.js +367 -9
  99. package/dist/export-ui.js +2 -2
  100. package/dist/flow-engine.js +24 -5
  101. package/dist/flow-gallery-ui.js +2 -2
  102. package/dist/flow-gallery.js +27 -8
  103. package/dist/flow-share.js +16 -5
  104. package/dist/flow-triggers.d.ts +77 -8
  105. package/dist/flow-triggers.js +517 -70
  106. package/dist/flows-cli.js +8 -2
  107. package/dist/flows-ui.d.ts +0 -2
  108. package/dist/flows-ui.js +52 -4
  109. package/dist/flows.d.ts +4 -0
  110. package/dist/flows.js +9 -1
  111. package/dist/html.d.ts +9 -0
  112. package/dist/html.js +11 -0
  113. package/dist/invoke.d.ts +35 -0
  114. package/dist/invoke.js +347 -28
  115. package/dist/keys.d.ts +18 -14
  116. package/dist/keys.js +5 -0
  117. package/dist/lead-capabilities.js +3 -2
  118. package/dist/lead-contract.js +2 -0
  119. package/dist/lead-doors.d.ts +2 -0
  120. package/dist/lead-doors.js +7 -0
  121. package/dist/lead-follow.d.ts +2 -0
  122. package/dist/lead-follow.js +58 -2
  123. package/dist/lead-progress.js +1 -0
  124. package/dist/lead-queue-runtime.d.ts +46 -0
  125. package/dist/lead-queue-runtime.js +191 -0
  126. package/dist/lead-queue.d.ts +104 -0
  127. package/dist/lead-queue.js +187 -0
  128. package/dist/lead-sweep.d.ts +67 -0
  129. package/dist/lead-sweep.js +99 -0
  130. package/dist/lead-tools.d.ts +22 -0
  131. package/dist/lead-tools.js +152 -1
  132. package/dist/lead.d.ts +13 -5
  133. package/dist/lead.js +89 -29
  134. package/dist/limits-ui.d.ts +8 -1
  135. package/dist/limits-ui.js +26 -0
  136. package/dist/live-bus.d.ts +8 -0
  137. package/dist/live-bus.js +40 -4
  138. package/dist/live-detail.d.ts +27 -0
  139. package/dist/live-detail.js +57 -0
  140. package/dist/live-notes.d.ts +20 -0
  141. package/dist/live-notes.js +47 -0
  142. package/dist/live-records.d.ts +140 -0
  143. package/dist/live-records.js +935 -0
  144. package/dist/live-terminal.d.ts +269 -0
  145. package/dist/live-terminal.js +686 -0
  146. package/dist/live.d.ts +38 -27
  147. package/dist/live.js +142 -124
  148. package/dist/mcp-oauth.d.ts +6 -3
  149. package/dist/mcp-oauth.js +8 -4
  150. package/dist/memory-pass.js +2 -2
  151. package/dist/mobile-viewport.d.ts +5 -1
  152. package/dist/mobile-viewport.js +18 -2
  153. package/dist/model-catalog.js +4 -0
  154. package/dist/monitoring-ui.js +2 -2
  155. package/dist/needs-you.d.ts +1 -1
  156. package/dist/needs-you.js +11 -4
  157. package/dist/operate.d.ts +4 -0
  158. package/dist/operate.js +335 -19
  159. package/dist/passwordless.d.ts +23 -0
  160. package/dist/passwordless.js +26 -0
  161. package/dist/peek-cli.d.ts +12 -4
  162. package/dist/peek-cli.js +19 -11
  163. package/dist/phase-routing.js +10 -3
  164. package/dist/planner-source.d.ts +3 -0
  165. package/dist/planner-source.js +14 -0
  166. package/dist/planner.d.ts +2 -1
  167. package/dist/planner.js +7 -1
  168. package/dist/policy-ui.js +2 -2
  169. package/dist/policy.d.ts +1 -20
  170. package/dist/policy.js +29 -2
  171. package/dist/project-delete-ui.d.ts +18 -0
  172. package/dist/project-delete-ui.js +60 -9
  173. package/dist/project-delete.js +2 -2
  174. package/dist/project-memory.d.ts +3 -1
  175. package/dist/project-memory.js +57 -25
  176. package/dist/project-tools.d.ts +5 -3
  177. package/dist/project-tools.js +12 -9
  178. package/dist/provider-auth.d.ts +4 -3
  179. package/dist/provider-auth.js +5 -4
  180. package/dist/provider-connection.d.ts +2 -1
  181. package/dist/provider-connection.js +5 -0
  182. package/dist/provider.d.ts +25 -2
  183. package/dist/provider.js +63 -9
  184. package/dist/pull-request-ui.js +2 -2
  185. package/dist/push.js +1 -1
  186. package/dist/quick-start.d.ts +24 -0
  187. package/dist/quick-start.js +127 -0
  188. package/dist/release-adapters.d.ts +6 -0
  189. package/dist/release-adapters.js +70 -3
  190. package/dist/release.d.ts +10 -1
  191. package/dist/release.js +34 -15
  192. package/dist/request-budget-ui.js +2 -2
  193. package/dist/result-follow-ups.js +3 -2
  194. package/dist/result-review.d.ts +1 -1
  195. package/dist/result-review.js +9 -3
  196. package/dist/retention-ui.js +2 -2
  197. package/dist/retention.js +10 -1
  198. package/dist/run-efficiency.d.ts +73 -0
  199. package/dist/run-efficiency.js +228 -0
  200. package/dist/runner.d.ts +5 -1
  201. package/dist/runner.js +22 -1
  202. package/dist/scope.d.ts +183 -5
  203. package/dist/scope.js +256 -31
  204. package/dist/scout-net.d.ts +4 -1
  205. package/dist/scout-net.js +37 -12
  206. package/dist/scout.d.ts +20 -6
  207. package/dist/scout.js +27 -3
  208. package/dist/serve.js +248 -40
  209. package/dist/server/chat.d.ts +1 -1
  210. package/dist/server/chat.js +122 -31
  211. package/dist/server/chrome.d.ts +16 -2
  212. package/dist/server/chrome.js +63 -11
  213. package/dist/server/console.css +107 -42
  214. package/dist/server/flows.js +38 -8
  215. package/dist/server/guards.d.ts +1 -0
  216. package/dist/server/guards.js +23 -9
  217. package/dist/server/http.d.ts +7 -0
  218. package/dist/server/http.js +3 -2
  219. package/dist/server/live.d.ts +34 -1
  220. package/dist/server/live.js +39 -17
  221. package/dist/server/pages-assets.js +5 -0
  222. package/dist/server/pages.js +90 -13
  223. package/dist/server/people-tokens.d.ts +3 -0
  224. package/dist/server/people-tokens.js +55 -17
  225. package/dist/server/remote-hooks.js +12 -6
  226. package/dist/server/remote.d.ts +2 -1
  227. package/dist/server/remote.js +14 -5
  228. package/dist/server/render-chat.d.ts +12 -3
  229. package/dist/server/render-chat.js +47 -16
  230. package/dist/server/render-pages.d.ts +32 -4
  231. package/dist/server/render-pages.js +83 -13
  232. package/dist/server/render-people.d.ts +3 -2
  233. package/dist/server/render-people.js +3 -2
  234. package/dist/server/render-results.d.ts +18 -2
  235. package/dist/server/render-results.js +37 -28
  236. package/dist/server/render-settings.js +6 -5
  237. package/dist/server/render-tasks.d.ts +35 -10
  238. package/dist/server/render-tasks.js +111 -27
  239. package/dist/server/request-context.d.ts +2 -0
  240. package/dist/server/request-context.js +2 -1
  241. package/dist/server/route-table.d.ts +3 -0
  242. package/dist/server/route-table.js +47 -5
  243. package/dist/server/runtime.d.ts +33 -2
  244. package/dist/server/session.d.ts +25 -1
  245. package/dist/server/session.js +18 -4
  246. package/dist/server/settings.js +142 -10
  247. package/dist/server/sweeps.d.ts +4 -0
  248. package/dist/server/sweeps.js +58 -0
  249. package/dist/server/task-views.d.ts +2 -1
  250. package/dist/server/task-views.js +96 -6
  251. package/dist/server/tasks.js +327 -78
  252. package/dist/server/terminals.d.ts +31 -0
  253. package/dist/server/terminals.js +365 -0
  254. package/dist/setup-guide.js +3 -2
  255. package/dist/slack-chat.js +4 -1
  256. package/dist/spend-ui.js +3 -3
  257. package/dist/spend.js +2 -1
  258. package/dist/steer-capabilities.d.ts +32 -0
  259. package/dist/steer-capabilities.js +60 -0
  260. package/dist/steer-controls.d.ts +14 -0
  261. package/dist/steer-controls.js +36 -0
  262. package/dist/steer-status.js +2 -2
  263. package/dist/storage-ui.js +4 -4
  264. package/dist/store-baseline.d.ts +1 -1
  265. package/dist/store-baseline.js +1 -1
  266. package/dist/store.d.ts +440 -15
  267. package/dist/store.js +1072 -52
  268. package/dist/subagent-desk.js +1 -1
  269. package/dist/summary.js +3 -0
  270. package/dist/task-source.d.ts +58 -0
  271. package/dist/task-source.js +93 -0
  272. package/dist/team-leads.js +1 -1
  273. package/dist/teams-chat.js +4 -1
  274. package/dist/teardown-git.d.ts +22 -0
  275. package/dist/teardown-git.js +136 -2
  276. package/dist/telegram-lead.d.ts +3 -0
  277. package/dist/telegram-lead.js +44 -7
  278. package/dist/telegram.d.ts +11 -2
  279. package/dist/telegram.js +51 -17
  280. package/dist/terminal-catalog.d.ts +11 -0
  281. package/dist/terminal-catalog.js +129 -0
  282. package/dist/text-limits.d.ts +2 -0
  283. package/dist/text-limits.js +2 -0
  284. package/dist/tool-broker.d.ts +14 -0
  285. package/dist/tool-broker.js +67 -2
  286. package/dist/toolroll-update-ui.js +2 -2
  287. package/dist/toolroll-update.js +13 -3
  288. package/dist/tools-ui.js +2 -2
  289. package/dist/unfenced-test-support.d.ts +2 -0
  290. package/dist/unfenced-test-support.js +11 -0
  291. package/dist/webhook-limits.d.ts +23 -0
  292. package/dist/webhook-limits.js +30 -0
  293. package/dist/webhook-readers.d.ts +56 -0
  294. package/dist/webhook-readers.js +138 -0
  295. package/dist/webhook-signing.d.ts +27 -0
  296. package/dist/webhook-signing.js +119 -0
  297. package/dist/work-index.d.ts +29 -0
  298. package/dist/work-index.js +16 -2
  299. package/dist/workspace-revision.js +3 -1
  300. package/dist/writes-mode.d.ts +9 -0
  301. package/dist/writes-mode.js +8 -0
  302. package/package.json +15 -2
@@ -0,0 +1,127 @@
1
+ /**
2
+ * BARE `toolroll` IN A REPOSITORY (docs/plans/agents-cockpit.md B1): the two-action start.
3
+ *
4
+ * Inside a git repository, `toolroll` with nothing else (or only --json) either starts the console for it (`up`), or —
5
+ * when this installation's console is already running and PROVES it is (console-instance.ts: a fresh challenge on the
6
+ * discovery socket and on its HTTP listener, answered with the secret in the database this CLI opened itself) —
7
+ * adds the repository through the ordinary project admission and opens that proved console. Under an agent, or with
8
+ * --json, it starts nothing and changes nothing: it says what it would do. Outside a repository, it scans as before
9
+ * (the scan is also `toolroll scan`).
10
+ */
11
+ import { lstatSync, realpathSync } from "node:fs";
12
+ import { proveLiveConsole } from "./console-instance.js";
13
+ import { underAgent } from "./prompt.js";
14
+ import { envelopeJson } from "./envelope.js";
15
+ import { createRequire } from "node:module";
16
+ import { openStoreReadOnly, readSchemaVersion, SCHEMA_VERSION } from "./store.js";
17
+ const EXIT = { ok: 0, refused: 3 };
18
+ /** Whether argv is a bare `toolroll` (nothing, or only --json). */
19
+ export const isBareInvocation = (argv) => argv.every(one => one === "--json");
20
+ /** The database's schema version by the store's own strict reader, read without changing anything. */
21
+ function schemaOf(file) {
22
+ try {
23
+ // Loaded only here, as the store loads it: the CLI's other commands never pay for (or warn about) it.
24
+ const { DatabaseSync } = createRequire(import.meta.url)("node:sqlite");
25
+ const db = new DatabaseSync(file, { readOnly: true });
26
+ try {
27
+ const read = readSchemaVersion(db);
28
+ return read.ok ? read : { ok: false };
29
+ }
30
+ finally {
31
+ db.close();
32
+ }
33
+ }
34
+ catch {
35
+ return { ok: false };
36
+ }
37
+ }
38
+ /** Whether the database file is there: "absent" only when the system says so (ENOENT), never for a path it won't show. */
39
+ function databasePresence(file) {
40
+ try {
41
+ lstatSync(file);
42
+ return "present";
43
+ }
44
+ catch (error) {
45
+ return error.code === "ENOENT" ? "absent" : "unknown";
46
+ }
47
+ }
48
+ async function prove(seams) {
49
+ // Read only, never created or migrated, never cleaned up: proving changes nothing. "None" only when nothing of this
50
+ // build can be running on it (no database yet, or one older than this build, which `up` brings up to date); a
51
+ // database that can't be read, is mid-upgrade or is newer than this build can't be checked, so it is refused.
52
+ const none = { ok: false, state: "none", message: "no console is running" };
53
+ const unverifiable = (why) => ({ ok: false, state: "refused", message: `${why}, so a running console can't be checked — run \`toolroll up\` to see what's wrong` });
54
+ const presence = databasePresence(seams.databaseFile);
55
+ if (presence === "absent")
56
+ return none;
57
+ if (presence === "unknown")
58
+ return unverifiable("Toolroll's database can't be reached");
59
+ const schema = schemaOf(seams.databaseFile);
60
+ if (!schema.ok)
61
+ return unverifiable("Toolroll's database can't be read");
62
+ if (schema.version === null || (schema.version > 0 && schema.version < SCHEMA_VERSION))
63
+ return none;
64
+ if (schema.version !== SCHEMA_VERSION)
65
+ return unverifiable("Toolroll's database is being upgraded");
66
+ const store = openStoreReadOnly(seams.databaseFile);
67
+ if (store === null)
68
+ return unverifiable("Toolroll's database can't be read");
69
+ try {
70
+ return await proveLiveConsole(seams.databaseFile, store, seams.build, seams.attach ?? {});
71
+ }
72
+ catch {
73
+ return unverifiable("the running console couldn't be checked");
74
+ }
75
+ finally {
76
+ store.close();
77
+ }
78
+ }
79
+ /** null: not this command (outside a repository) — the caller scans as before. */
80
+ export async function runQuickStart(argv, write, seams) {
81
+ const found = await seams.findRepo(seams.cwd);
82
+ if (found === null)
83
+ return null;
84
+ let repo = found;
85
+ try {
86
+ repo = realpathSync(found);
87
+ }
88
+ catch { /* as found */ }
89
+ const json = argv.includes("--json");
90
+ const dry = json || (seams.underAgent ?? underAgent(seams.env));
91
+ const proof = await prove(seams);
92
+ const enrolled = await seams.enrolled(repo);
93
+ const would = proof.ok ? (enrolled ? "open" : "add-and-open") : proof.state === "none" ? "start" : "refuse";
94
+ if (dry) {
95
+ // Nothing starts, nothing is added, nothing opens: what a person would get, in words or one envelope.
96
+ const words = would === "start" ? `Would start Toolroll for ${repo} (toolroll up).`
97
+ : would === "refuse" ? `Would refuse: ${proof.message}.`
98
+ : `Would ${would === "add-and-open" ? `add ${repo} and ` : ""}open the running console at ${proof.url}.`;
99
+ if (json)
100
+ write(envelopeJson({ ok: would !== "refuse", command: "toolroll", repo, would, ...(proof.ok ? { url: proof.url } : { console: proof.state, message: proof.message }), dryRun: true }));
101
+ else
102
+ write(words);
103
+ return would === "refuse" ? EXIT.refused : EXIT.ok;
104
+ }
105
+ if (!proof.ok && proof.state === "refused") {
106
+ write(`Toolroll can't attach: ${proof.message}.`);
107
+ return EXIT.refused;
108
+ }
109
+ if (!proof.ok)
110
+ return seams.startUp();
111
+ if (!enrolled) {
112
+ const admitted = await seams.admit(repo);
113
+ if (admitted !== 0)
114
+ return admitted;
115
+ }
116
+ // Whatever happened since the first proof (reading the registry, adding the project), the console is proved again,
117
+ // on both channels, right before its address is shown or opened — never a listener that took the port meanwhile.
118
+ const again = await prove(seams);
119
+ if (!again.ok || again.url !== proof.url || again.pid !== proof.pid) {
120
+ write(`Toolroll can't attach: ${again.ok ? "the console changed while the project was being added" : again.message} — run \`toolroll up\`.`);
121
+ return EXIT.refused;
122
+ }
123
+ write(`Toolroll is on ${proof.url}`);
124
+ if (seams.terminal)
125
+ await seams.openBrowser(proof.url);
126
+ return EXIT.ok;
127
+ }
@@ -55,6 +55,12 @@ export type AdapterOptions = {
55
55
  sleep?: (ms: number) => Promise<void>;
56
56
  };
57
57
  export declare function releaseAdapters(options: AdapterOptions): ReleaseAdapters;
58
+ /**
59
+ * Whether a workflow's triggers (its top-level `on:` as a name, a list or a map) include a change to the repository.
60
+ * A file that doesn't parse, or whose triggers aren't one of those shapes, counts as running, so the release waits
61
+ * rather than skipping checks it owes.
62
+ */
63
+ export declare function workflowRunsOnChange(text: string): boolean;
58
64
  /** The formula with its first `url` and `sha256` replaced; anything else is left exactly as it was. */
59
65
  export declare function bumpFormula(text: string, url: string, sha256: string): string;
60
66
  export {};
@@ -10,6 +10,7 @@ import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
10
10
  import { homedir } from "node:os";
11
11
  import { basename, delimiter, dirname, join } from "node:path";
12
12
  import { createRequire } from "node:module";
13
+ import { parse as parseYaml } from "yaml";
13
14
  // Loaded on first use, like the store: a static import prints Node's SQLite warning before the CLI can quiet it.
14
15
  const sqlite = () => createRequire(import.meta.url)("node:sqlite");
15
16
  import { activeUpdateWork } from "./desktop-update-gate.js";
@@ -225,7 +226,14 @@ export function releaseAdapters(options) {
225
226
  return;
226
227
  if (current !== head)
227
228
  throw new Error(`${branch} moved after merging; its new head was not deleted.`);
228
- await must(git("push", `--force-with-lease=${ref}:${head}`, "origin", `:${ref}`), `deleting merged branch ${branch}`);
229
+ const pushed = await git("push", `--force-with-lease=${ref}:${head}`, "origin", `:${ref}`);
230
+ if (pushed.code === 0)
231
+ return;
232
+ // GitHub may delete a merged branch itself between the look and the push; gone is what was wanted.
233
+ const after = (await must(git("ls-remote", "origin", ref), "git ls-remote")).trim().split(/\s+/)[0] || null;
234
+ if (after === null)
235
+ return;
236
+ throw new Error(`deleting merged branch ${branch} failed: ${pushed.stderr.trim() || `exit ${pushed.code}`}`);
229
237
  },
230
238
  async tree(sha) { return (await must(git("rev-parse", `${sha}^{tree}`), `git rev-parse ${sha.slice(0, 7)}^{tree}`)).trim(); },
231
239
  async fileAt(sha, path) {
@@ -338,10 +346,10 @@ export function releaseAdapters(options) {
338
346
  return prs.find(one => one.state === "open") ?? prs.filter(one => one.state === "merged").sort((a, b) => b.number - a.number)[0] ?? null;
339
347
  },
340
348
  async checks(repo, sha) {
341
- const out = await must(gh("api", "--paginate", `repos/${repo}/commits/${sha}/check-runs?per_page=100`, "--jq", ".check_runs[] | {id, name, status, conclusion}"), "gh api check-runs");
349
+ const out = await must(gh("api", "--paginate", `repos/${repo}/commits/${sha}/check-runs?per_page=100`, "--jq", ".check_runs[] | {id, name, status, conclusion, app: .app.slug}"), "gh api check-runs");
342
350
  const runs = out.split("\n").filter(Boolean).map(line => object(JSON.parse(line)));
343
351
  // Oldest first, so a rerun's attempt is the last one listed for its name.
344
- return runs.sort((a, b) => Number(a["id"]) - Number(b["id"])).map((one) => ({ name: String(one["name"]), status: String(one["status"]), conclusion: textOr(one["conclusion"]) }));
352
+ return runs.sort((a, b) => Number(a["id"]) - Number(b["id"])).map((one) => ({ name: String(one["name"]), status: String(one["status"]), conclusion: textOr(one["conclusion"]), app: textOr(one["app"]) }));
345
353
  },
346
354
  async merge(repo, pr, head) {
347
355
  const merged = object(await ghJson("gh api pulls merge", "api", "-X", "PUT", `repos/${repo}/pulls/${pr}/merge`, "-f", "merge_method=squash", "-f", `sha=${head}`));
@@ -379,6 +387,37 @@ export function releaseAdapters(options) {
379
387
  },
380
388
  homebrew: {
381
389
  repo: tap,
390
+ async checked(head) {
391
+ // Only a workflow that runs on pull requests (or pushes) puts checks on the tap's change; apps that open a
392
+ // check suite and never run it don't count. Workflows are read as the change has them and as the tap's default
393
+ // branch has them (pull_request_target runs from there); a file this can't read counts as checking.
394
+ for (const ref of [head, await defaultBranch()]) {
395
+ const listed = await gh("api", `repos/${tap}/contents/.github/workflows?ref=${encodeURIComponent(ref)}`, "--jq", ".[] | select(.type == \"file\") | .path");
396
+ if (listed.code !== 0) {
397
+ if (/Not Found|HTTP 404/.test(listed.stderr + listed.stdout))
398
+ continue;
399
+ throw new Error(`listing ${tap}'s workflows at ${ref}: ${listed.stderr.trim()}`);
400
+ }
401
+ for (const path of listed.stdout.split("\n").filter(Boolean)) {
402
+ if (!/\.ya?ml$/.test(path))
403
+ continue;
404
+ const file = await gh("api", `repos/${tap}/contents/${path}?ref=${encodeURIComponent(ref)}`, "--jq", ".content");
405
+ if (file.code !== 0)
406
+ return true;
407
+ if (workflowRunsOnChange(Buffer.from(file.stdout.replace(/\\n|\s/g, ""), "base64").toString("utf8")))
408
+ return true;
409
+ }
410
+ }
411
+ return false;
412
+ },
413
+ async statuses(head) {
414
+ // The older commit-status API, which some CI reports through instead of checks; newest first, so reversed.
415
+ const out = await must(gh("api", "--paginate", `repos/${tap}/commits/${head}/statuses?per_page=100`, "--jq", ".[] | {context, state}"), "gh api statuses");
416
+ return out.split("\n").filter(Boolean).map(line => object(JSON.parse(line))).reverse().map((one) => {
417
+ const state = String(one["state"]);
418
+ return { name: `status: ${String(one["context"])}`, status: state === "pending" ? "in_progress" : "completed", conclusion: state === "pending" ? null : state === "success" ? "success" : "failure" };
419
+ });
420
+ },
382
421
  async state(version) {
383
422
  const branch = await defaultBranch();
384
423
  const current = await formulaAt(branch);
@@ -427,6 +466,34 @@ export function releaseAdapters(options) {
427
466
  }
428
467
  }
429
468
  const brewBranch = (version) => `toolroll-${version}`;
469
+ /** The events that put checks on a change. */
470
+ const CHANGE_EVENTS = new Set(["pull_request", "pull_request_target", "push", "merge_group"]);
471
+ /**
472
+ * Whether a workflow's triggers (its top-level `on:` as a name, a list or a map) include a change to the repository.
473
+ * A file that doesn't parse, or whose triggers aren't one of those shapes, counts as running, so the release waits
474
+ * rather than skipping checks it owes.
475
+ */
476
+ export function workflowRunsOnChange(text) {
477
+ let parsed;
478
+ try {
479
+ parsed = parseYaml(text);
480
+ }
481
+ catch {
482
+ return true;
483
+ }
484
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
485
+ return true;
486
+ const workflow = parsed;
487
+ // YAML 1.1 readers take a bare `on` for true; either key names the triggers.
488
+ const on = "on" in workflow ? workflow["on"] : workflow["true"];
489
+ const events = typeof on === "string" ? [on]
490
+ : Array.isArray(on) ? on
491
+ : on !== null && typeof on === "object" ? Object.keys(on)
492
+ : null;
493
+ if (events === null || !events.every(one => typeof one === "string"))
494
+ return true;
495
+ return events.some(one => CHANGE_EVENTS.has(one));
496
+ }
430
497
  /** The formula with its first `url` and `sha256` replaced; anything else is left exactly as it was. */
431
498
  export function bumpFormula(text, url, sha256) {
432
499
  if (!/^\s*url "[^"]*"/m.test(text) || !/^\s*sha256 "[0-9a-f]{64}"/m.test(text))
package/dist/release.d.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  * skipped or failed never merges. merge
18
18
  * squash-merges with the head pinned to the gated commit, tree proves main's new commit has exactly the gated tree,
19
19
  * and only then tag pushes the version tag, whose macOS matrix must pass before npm publication (publish.yml). publish
20
- * waits for npm, the GitHub release and the macOS checks; homebrew updates the tap's formula and merges it.
20
+ * waits for npm, the GitHub release and the macOS checks; homebrew updates the tap's formula and merges it (after its checks, when the tap runs any).
21
21
  *
22
22
  * A private journal (mode 0600, written whole then renamed) records what each step is about to do before it does
23
23
  * anything outside this machine, and what it saw after. A stop — a time limit, a failure, a crash, Ctrl-C — leaves the
@@ -38,10 +38,12 @@ export type ReleaseStep = typeof RELEASE_STEPS[number];
38
38
  type WaitingStep = "approval" | "check" | "quiet" | "deploy" | "ci" | "publish" | "homebrew";
39
39
  /** How long each waiting step may take, in minutes, before the release stops there (rerunning continues from it). */
40
40
  export declare const RELEASE_LIMITS: Readonly<Record<WaitingStep, number>>;
41
+ /** One check attempt; `app` is the GitHub App that made it (github-actions for a workflow), when known. */
41
42
  export type CheckRun = {
42
43
  name: string;
43
44
  status: string;
44
45
  conclusion: string | null;
46
+ app?: string | null;
45
47
  };
46
48
  export type PullRequest = {
47
49
  number: number;
@@ -195,6 +197,10 @@ export type ReleaseAdapters = {
195
197
  };
196
198
  homebrew: {
197
199
  repo: string;
200
+ /** Whether any of the tap's workflows, at the change or its base, runs on a change; if none does, no checks are owed. */
201
+ checked(head: string): Promise<boolean>;
202
+ /** The change's commit statuses, as check runs oldest first. */
203
+ statuses(head: string): Promise<CheckRun[]>;
198
204
  state(version: string): Promise<HomebrewState>;
199
205
  propose(version: string): Promise<{
200
206
  pr: number;
@@ -226,10 +232,13 @@ export type ReleaseJournal = {
226
232
  by: string;
227
233
  at: string;
228
234
  } | null;
235
+ /** The passing result: its run, receipt and checkout, and the commit it built (`head`), which a prepared candidate
236
+ * records as a commit of its own with exactly the candidate's tree (absent in a journal from before that was known). */
229
237
  check: {
230
238
  run: number;
231
239
  receipt: string;
232
240
  worktree: string;
241
+ head?: string;
233
242
  } | null;
234
243
  completion: {
235
244
  actor: string;
package/dist/release.js CHANGED
@@ -17,7 +17,7 @@
17
17
  * skipped or failed never merges. merge
18
18
  * squash-merges with the head pinned to the gated commit, tree proves main's new commit has exactly the gated tree,
19
19
  * and only then tag pushes the version tag, whose macOS matrix must pass before npm publication (publish.yml). publish
20
- * waits for npm, the GitHub release and the macOS checks; homebrew updates the tap's formula and merges it.
20
+ * waits for npm, the GitHub release and the macOS checks; homebrew updates the tap's formula and merges it (after its checks, when the tap runs any).
21
21
  *
22
22
  * A private journal (mode 0600, written whole then renamed) records what each step is about to do before it does
23
23
  * anything outside this machine, and what it saw after. A stop — a time limit, a failure, a crash, Ctrl-C — leaves the
@@ -213,9 +213,11 @@ export async function runRelease(options, adapters) {
213
213
  if (gate.result.check !== "passed")
214
214
  throw new Stop("check-failed", `The Full release check of ${j.candidate.sha.slice(0, 7)} did not pass (run ${gate.result.run}: ${gate.result.check}). It stays as it is: fix the branch and release the new commit with --new.`);
215
215
  };
216
+ /** The commit the passing build recorded: what the deployed runtime is stamped with. */
217
+ const builtHead = () => j.check?.head ?? j.candidate.sha;
216
218
  const checked = (gate) => {
217
219
  approved(gate);
218
- if (gate.result?.head !== j.candidate.sha || gate.result?.receipt !== j.check?.receipt)
220
+ if (gate.result?.head !== builtHead() || gate.result?.receipt !== j.check?.receipt)
219
221
  throw new Stop("drift", `Task ${j.task.id}'s result changed since its check passed.`);
220
222
  fullCheck(gate);
221
223
  };
@@ -268,12 +270,16 @@ export async function runRelease(options, adapters) {
268
270
  throw new Stop("check-failed", `The release build of ${j.task.id} failed: ${gate.failed}. Fix it and release the new commit with --new.`);
269
271
  if (gate.result === null || gate.result.check === "running" || gate.result.check === "pending")
270
272
  return null;
271
- if (gate.result.head !== j.candidate.sha)
272
- throw new Stop("drift", `The release result is for ${gate.result.head?.slice(0, 7) ?? "no commit"}, not ${j.candidate.sha.slice(0, 7)}.`);
273
+ // The build records a prepared candidate as a commit of its own (its message names the task): it must hold
274
+ // exactly the candidate's tree, or it isn't the release.
275
+ const head = gate.result.head;
276
+ if (head === null || (head !== j.candidate.sha && await adapters.repo.tree(head) !== await adapters.repo.tree(j.candidate.sha))) {
277
+ throw new Stop("drift", `The release result is for ${head?.slice(0, 7) ?? "no commit"}, which doesn't hold exactly ${j.candidate.sha.slice(0, 7)}'s files.`);
278
+ }
273
279
  fullCheck(gate);
274
280
  if (gate.result.worktree === null)
275
281
  throw new Stop("worktree", `Run ${gate.result.run} names no checkout to deploy from.`);
276
- return { run: gate.result.run, receipt: gate.result.receipt, worktree: gate.result.worktree };
282
+ return { run: gate.result.run, receipt: gate.result.receipt, worktree: gate.result.worktree, head };
277
283
  });
278
284
  j.check = result;
279
285
  say(` run ${result.run} passed on ${j.candidate.sha.slice(0, 7)}`);
@@ -310,7 +316,7 @@ export async function runRelease(options, adapters) {
310
316
  await recoverDeploy();
311
317
  checked(await adapters.toolroll.gate(j.task.id));
312
318
  // A deployment that finished but whose answer was lost: the services already run a runtime of this commit.
313
- let runtime = await served(adapters, j.candidate.sha);
319
+ let runtime = await served(adapters, builtHead());
314
320
  if (runtime === null) {
315
321
  await pinned();
316
322
  j.deployAttempt = { stage: join(options.stateDir, "staged-upgrades", `browser-${j.candidate.sha.slice(0, 7)}-${randomUUID()}`) };
@@ -326,7 +332,7 @@ export async function runRelease(options, adapters) {
326
332
  save();
327
333
  }
328
334
  const want = runtime;
329
- await waitFor("deploy", `the console and worker to run the gated commit from ${want}`, async () => (await served(adapters, j.candidate.sha)) === want ? true : null);
335
+ await waitFor("deploy", `the console and worker to run the gated commit from ${want}`, async () => (await served(adapters, builtHead())) === want ? true : null);
330
336
  j.deploy = { runtime: want, at: j.deploy?.runtime === want ? j.deploy.at : adapters.now().toISOString() };
331
337
  j.deployAttempt = null;
332
338
  delete j.intents.deploy;
@@ -336,7 +342,7 @@ export async function runRelease(options, adapters) {
336
342
  }
337
343
  case "link": {
338
344
  const runtime = j.deploy.runtime;
339
- if (await served(adapters, j.candidate.sha) !== runtime)
345
+ if (await served(adapters, builtHead()) !== runtime)
340
346
  throw new Stop("drift", `The console and worker no longer run ${runtime}; the CLI names were not moved.`);
341
347
  if (j.links === undefined || j.links === null) {
342
348
  j.links = await adapters.toolroll.cliLinks();
@@ -348,7 +354,7 @@ export async function runRelease(options, adapters) {
348
354
  catch (error) {
349
355
  throw new Stop("link", `The CLI names were not moved to ${runtime} (they point where they did): ${error.message}. The service already runs the new build; rerun to try again.`);
350
356
  }
351
- const everywhere = await deployed(adapters, j.candidate.sha);
357
+ const everywhere = await deployed(adapters, builtHead());
352
358
  if (everywhere !== runtime)
353
359
  throw new Stop("mixed-runtime", `After relinking, the console, worker and both CLI names don't all run ${runtime} at ${j.candidate.sha.slice(0, 7)}.`);
354
360
  say(` toolroll and standing-orders run ${join(runtime, "bin.js")}`);
@@ -448,15 +454,28 @@ export async function runRelease(options, adapters) {
448
454
  const proposed = state;
449
455
  j.homebrew = { pr: proposed.pr, head: proposed.head, merged: false };
450
456
  save();
457
+ // Whatever checks do register are always judged; an empty set passes only for a tap whose workflows never run
458
+ // on a change, and only when it's still empty a poll later.
459
+ const checked = await brew.checked(proposed.head);
460
+ let emptyLooks = 0;
451
461
  await waitFor("homebrew", `the Homebrew change's checks (${brew.repo} #${proposed.pr})`, async () => {
452
- const runs = await adapters.github.checks(brew.repo, proposed.head);
462
+ // Check runs and commit statuses are judged apart, so neither stands in for the other or hides its failure.
463
+ // Two apps may name a check alike; each app's attempts stand for themselves.
464
+ const runs = (await adapters.github.checks(brew.repo, proposed.head)).map(run => ({ ...run, name: `${run.app ?? "unknown app"}: ${run.name}` }));
465
+ const statuses = await brew.statuses(proposed.head);
466
+ const verdicts = [runs, statuses].filter(one => one.length > 0).map(one => checksVerdict(one, [...new Set(one.map(run => run.name))]));
467
+ const failed = verdicts.flatMap(one => one.state === "failed" ? one.failed : []);
468
+ if (failed.length > 0)
469
+ throw new Stop("homebrew-failed", `The Homebrew change's checks failed: ${failed.join(", ")}.`);
470
+ if (verdicts.some(one => one.state === "pending"))
471
+ return null;
453
472
  // GitHub registers checks asynchronously; an empty set says nothing about success.
454
- if (runs.length === 0)
473
+ // A workflow is owed: only its own run counts, not another app's green check.
474
+ if (checked && !runs.some(run => run.app === "github-actions"))
455
475
  return null;
456
- const verdict = checksVerdict(runs, [...new Set(runs.map(one => one.name))]);
457
- if (verdict.state === "failed")
458
- throw new Stop("homebrew-failed", `The Homebrew change's checks failed: ${verdict.failed.join(", ")}.`);
459
- return verdict.state === "passed" ? true : null;
476
+ if (runs.length === 0 && statuses.length === 0)
477
+ return ++emptyLooks >= 2 ? true : null;
478
+ return true;
460
479
  });
461
480
  await brew.merge(proposed.pr, proposed.head);
462
481
  }
@@ -1,4 +1,4 @@
1
- import { html, postForm } from "./html.js";
1
+ import { html, postForm, reauth } from "./html.js";
2
2
  import { effectiveLimits, limitOverrides, PER_DAY_MAX, PER_MINUTE_MAX, REQUEST_BUDGET_DEFAULTS } from "./request-budget.js";
3
3
  const n = (value) => value.toLocaleString("en-US");
4
4
  export const REQUEST_LIMITS_CSS = `.credentials .limits{display:grid;gap:14px}.credentials .limits form{display:grid;gap:10px}.credentials .limits label{display:grid;gap:6px}` +
@@ -14,7 +14,7 @@ export function requestLimitsHtml(db, tokens, now) {
14
14
  const overrides = limitOverrides(db);
15
15
  const everyone = overrides.get("*") ?? { readPerMinute: null, actPerMinute: null, perDay: null };
16
16
  const field = (name, label, value, placeholder, max) => html `<label>${label}<input type="number" name="${name}" min="1" max="${max}" step="1" inputmode="numeric" value="${value === null ? "" : value}" placeholder="${placeholder}"></label>`;
17
- const password = html `<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password"></label>`;
17
+ const password = html `${reauth(html `<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password"></label>`)}`;
18
18
  const buttons = html `<div class="actions"><button name="action" value="save">Save limits</button><button class="secondary" name="action" value="clear">Use defaults</button></div>`;
19
19
  const live = tokens.filter(one => one.revokedAt === null && Date.parse(one.expiresAt) > now);
20
20
  const installation = postForm("/settings/request-limits", html `\
@@ -9,6 +9,7 @@
9
9
  *
10
10
  * Add tests files a small task, unapproved like any other filing, to write
11
11
  * tests for that change. */
12
+ import { fencedCheckoutCommands } from "./invoke.js";
12
13
  import { mkdtempSync, rmSync } from "node:fs";
13
14
  import { tmpdir } from "node:os";
14
15
  import { join } from "node:path";
@@ -201,7 +202,7 @@ export async function runFollowUpCheck(store, root, request, deps = {}) {
201
202
  let ready = true;
202
203
  if (setup !== null) {
203
204
  const shell = approvedCommandShell(setup.command);
204
- const prepared = await runWithIsolatedDatabase(runner, shell.file, shell.args, { cwd: checkout, timeoutMs: setup.timeoutMs, processGroup: true, envAllowlist: SETUP_ENV_ALLOWLIST, omitEnv: SETUP_ENV_DENYLIST });
205
+ const prepared = await runWithIsolatedDatabase(fencedCheckoutCommands(store, target.repo, checkout, runner), shell.file, shell.args, { cwd: checkout, timeoutMs: setup.timeoutMs, processGroup: true, envAllowlist: SETUP_ENV_ALLOWLIST, omitEnv: SETUP_ENV_DENYLIST });
205
206
  note("Approved project setup", setup.command, prepared);
206
207
  if (prepared.code !== 0 || prepared.timedOut || prepared.notFound) {
207
208
  ready = false;
@@ -216,7 +217,7 @@ export async function runFollowUpCheck(store, root, request, deps = {}) {
216
217
  }
217
218
  else {
218
219
  const shell = approvedCommandShell(live.command);
219
- const ran = await runWithIsolatedDatabase(runner, shell.file, shell.args, { cwd: checkout, timeoutMs: live.timeoutMs, processGroup: true, envAllowlist: SETUP_ENV_ALLOWLIST, omitEnv: SETUP_ENV_DENYLIST });
220
+ const ran = await runWithIsolatedDatabase(fencedCheckoutCommands(store, target.repo, checkout, runner), shell.file, shell.args, { cwd: checkout, timeoutMs: live.timeoutMs, processGroup: true, envAllowlist: SETUP_ENV_ALLOWLIST, omitEnv: SETUP_ENV_DENYLIST });
220
221
  note(`${CHECK_LEVEL_WORDS[target.level]} check`, live.command, ran);
221
222
  result = ran.timedOut ? { ran: false, failure: "The check ran out of time." } : ran.notFound ? { ran: false, failure: "The check couldn't start." } : { ran: true, exitCode: ran.code };
222
223
  }
@@ -149,7 +149,7 @@ export declare const RESULT_FACT_KEYS: readonly ["run", "head", "base", "head-so
149
149
  export declare function resultFactsFromHtml(html: string): Record<string, string>[];
150
150
  /** Where a result form may send its reader back: the review cockpit's
151
151
  * own deep link, the chat's result detail, the page of a task named in
152
- * `tasks` (the run's own), or the run page. Anything else lands on the run
152
+ * `tasks` (the run's own) or its Result tab at this run, or the run page. Anything else lands on the run
153
153
  * page. Matched exactly — never a bare open redirect. */
154
154
  export declare function resultReturnTarget(raw: string | null | undefined, runId: number, tasks?: readonly string[]): string;
155
155
  /** A form's own request token, minted per render — the same shape the
@@ -147,7 +147,7 @@ export function resultFactsFromHtml(html) {
147
147
  }
148
148
  /** Where a result form may send its reader back: the review cockpit's
149
149
  * own deep link, the chat's result detail, the page of a task named in
150
- * `tasks` (the run's own), or the run page. Anything else lands on the run
150
+ * `tasks` (the run's own) or its Result tab at this run, or the run page. Anything else lands on the run
151
151
  * page. Matched exactly — never a bare open redirect. */
152
152
  export function resultReturnTarget(raw, runId, tasks = []) {
153
153
  if (raw !== null && raw !== undefined) {
@@ -160,6 +160,10 @@ export function resultReturnTarget(raw, runId, tasks = []) {
160
160
  // The run's own task page, which offered Run checks in place; never another task's.
161
161
  if (tasks.some(task => raw === `/t/${encodeURIComponent(task)}`))
162
162
  return raw;
163
+ // That task page's Result tab at this run (docs/plans/one-page-task-and-agent.md), its version and view kept.
164
+ const tab = /^\/t\/([A-Za-z0-9._~%-]{1,200})\?(?:version=[A-Za-z0-9._~%-]{1,200}&)?tab=result&run=([0-9]{1,15})(?:&section=(?:summary|changes|checks))?$/.exec(raw);
165
+ if (tab !== null && Number(tab[2]) === runId && tasks.some(task => tab[1] === encodeURIComponent(task)))
166
+ return raw;
163
167
  }
164
168
  return `/r/${runId}`;
165
169
  }
@@ -240,6 +244,8 @@ export const RESULT_REVIEW_SCRIPT = String.raw `
240
244
  var user=panel?panel.getAttribute('data-result-user')||'':'';
241
245
  var task=panel?panel.getAttribute('data-result-task')||'':'';
242
246
  var run=panel?panel.getAttribute('data-result-run')||'':'';
247
+ // The address parameter that holds the result's own view: 'tab' on its page, 'section' on a task page's Result tab.
248
+ var viewParam=panel&&panel.getAttribute('data-result-param')==='section'?'section':'tab';
243
249
  var draftPrefix='standing-orders:review-draft:',scrollPrefix='standing-orders:result-scroll:';
244
250
  var day=86400000,storage=true,rememberPosition=null;
245
251
  function read(key){try{return JSON.parse(sessionStorage.getItem(key)||'null');}catch(e){return null;}}
@@ -262,7 +268,7 @@ export const RESULT_REVIEW_SCRIPT = String.raw `
262
268
  tab.addEventListener('click',function(ev){
263
269
  if(ev.metaKey||ev.ctrlKey||ev.shiftKey||ev.altKey||ev.button!==0)return;
264
270
  ev.preventDefault();var name=tab.getAttribute('data-result-tab');show(name);
265
- try{var url=new URL(location.href);url.searchParams.set('tab',name);url.hash='';history.replaceState(history.state,'',url.toString());}catch(e){}
271
+ try{var url=new URL(location.href);url.searchParams.set(viewParam,name);url.hash='';history.replaceState(history.state,'',url.toString());}catch(e){}
266
272
  if(rememberPosition)rememberPosition();
267
273
  });
268
274
  tab.addEventListener('keydown',function(ev){
@@ -383,7 +389,7 @@ export const RESULT_REVIEW_SCRIPT = String.raw `
383
389
  // view the URL now names.
384
390
  var returnKey=scrollPrefix+user+':'+task+':return';
385
391
  function scrollKeyFor(){var url=null;try{url=new URL(location.href);}catch(e){}
386
- return scrollPrefix+user+':'+task+':'+(url?(url.searchParams.get('result')||run)+':'+(url.searchParams.get('tab')||'summary'):run);}
392
+ return scrollPrefix+user+':'+task+':'+(url?(url.searchParams.get('result')||run)+':'+(url.searchParams.get(viewParam)||'summary'):run);}
387
393
  // Once a navigation has begun (pageswap / beforeunload) the document's
388
394
  // scroll is no longer the reader's — a cross-document transition can
389
395
  // move it to the top while it is captured — so remembering stops.
@@ -3,7 +3,7 @@
3
3
  * the next daily sweep would remove. An instance operator's page; changes
4
4
  * take a step-up.
5
5
  */
6
- import { html, postForm } from "./html.js";
6
+ import { html, postForm, reauth } from "./html.js";
7
7
  import { bytesWords } from "./storage.js";
8
8
  import { RETENTION_KINDS, countWords, periodChoices, periodLabel } from "./retention.js";
9
9
  export const RETENTION_CSS = `.retention{max-width:720px;min-width:0}.retention-next{margin:0 0 16px}` +
@@ -31,5 +31,5 @@ export function retentionHtml(view, notice) {
31
31
  return html `<div class="kind" data-kind="${kind}"><label class="name" for="keep-${kind}">${label}</label><p class="meta">${detail}</p><select id="keep-${kind}" name="${kind}">${options}</select></div>`;
32
32
  });
33
33
  const last = view.lastSweep === null ? "" : ` Last sweep ${view.lastSweep.slice(0, 10)}.`;
34
- return html `<article class="retention">${note}<p class="retention-next" data-retention-next>${nextWords(view)}${last}</p>${postForm("/settings/retention", html `${rows}<label class="step-up">Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label><button type="submit">Save</button>`)}<p class="meta">Never removed: the action ledger, and anything a task still needs (unfinished tasks, results Ready for review, anything on hold).</p></article>`;
34
+ return html `<article class="retention">${note}<p class="retention-next" data-retention-next>${nextWords(view)}${last}</p>${postForm("/settings/retention", html `${rows}${reauth(html `<label class="step-up">Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label>`)}<button type="submit">Save</button>`)}<p class="meta">Never removed: the action ledger, and anything a task still needs (unfinished tasks, results Ready for review, anything on hold).</p></article>`;
35
35
  }
package/dist/retention.js CHANGED
@@ -24,6 +24,10 @@ import { RETENTION_NOTE } from "./evidence.js";
24
24
  import { COMPLETION_ACTION } from "./result-completion.js";
25
25
  import { bytesWords, treeBytes } from "./storage.js";
26
26
  import { RETENTION_DAYS, retentionDaysSchema, retentionKindSchema } from "./contracts/retention.js";
27
+ /** A personal lead-queue row the sweep keeps (with its message): one still waiting, running or uncertain, and any of a
28
+ * conversation that is still open — its request id is the tombstone that stops a removed message being sent again. */
29
+ const KEPT_QUEUE_ROW = (message) => `lead_queue lq WHERE lq.message = ${message}
30
+ AND (lq.status IN ('queued', 'running', 'uncertain') OR EXISTS (SELECT 1 FROM lead_session ls WHERE ls.id = lq.session AND ls.ended_at IS NULL))`;
27
31
  const RETENTION_TABLE = (name) => `
28
32
  CREATE TABLE IF NOT EXISTS ${name} (
29
33
  kind TEXT PRIMARY KEY CHECK (kind IN ('evidence', 'checkouts', 'chat', 'notifications')),
@@ -177,6 +181,7 @@ export function retentionPlan(store, evidenceRoot, now, periods = store.retentio
177
181
  const rows = db.prepare(`SELECT m.id AS id, length(m.text) + COALESCE(length(m.activity), 0) + 64 AS bytes FROM lead_message m JOIN lead_thread th ON th.id = m.thread
178
182
  WHERE m.created_at < ?
179
183
  AND NOT EXISTS (SELECT 1 FROM team_message tm WHERE tm.message = m.id AND tm.status IN ('queued', 'running', 'uncertain'))
184
+ AND NOT EXISTS (SELECT 1 FROM ${KEPT_QUEUE_ROW("m.id")})
180
185
  AND NOT EXISTS (SELECT 1 FROM mate_turn turn WHERE turn.id = m.turn AND turn.state IN ('queued', 'running'))
181
186
  AND NOT (th.scope_kind = 'task' AND NOT EXISTS (SELECT 1 FROM task_ref r WHERE r.external_id = th.scope_key AND ${finishedTask("r")}))
182
187
  ORDER BY m.id LIMIT ?`).all(cutoff(now, periods.chat), SWEEP_BATCH + 1);
@@ -263,14 +268,18 @@ export function sweepRetention(store, evidenceRoot, now, actor = "worker") {
263
268
  }
264
269
  if (periods.chat !== null) {
265
270
  const team = db.prepare("DELETE FROM team_message WHERE message = ? AND status NOT IN ('queued', 'running', 'uncertain')");
266
- const message = db.prepare("DELETE FROM lead_message WHERE id = ? AND NOT EXISTS (SELECT 1 FROM team_message WHERE message = ?)");
271
+ const message = db.prepare(`DELETE FROM lead_message WHERE id = ? AND NOT EXISTS (SELECT 1 FROM team_message WHERE message = ?)
272
+ AND NOT EXISTS (SELECT 1 FROM ${KEPT_QUEUE_ROW("lead_message.id")})`);
267
273
  const asked = db.prepare("SELECT turn FROM lead_message WHERE id = ? AND role = 'assistant' AND turn IS NOT NULL");
274
+ const searchCopy = db.prepare("DELETE FROM memory_search WHERE kind = 'conversation' AND ref = ?");
268
275
  // The lead's question goes with the reply that asked it; its buttons then say it expired.
269
276
  const ask = db.prepare("DELETE FROM lead_ask WHERE turn = ? AND NOT EXISTS (SELECT 1 FROM lead_message WHERE turn = ? AND role = 'assistant')");
270
277
  for (const one of plan.items.chat) {
271
278
  team.run(one.id);
272
279
  const turn = asked.get(one.id)?.["turn"];
273
280
  if (Number(message.run(one.id, one.id).changes) === 1) {
281
+ // Its search copy goes with it (search never shows words the store no longer keeps).
282
+ searchCopy.run(`message:${one.id}`);
274
283
  done.chat.count++;
275
284
  done.chat.bytes += one.bytes;
276
285
  if (turn !== undefined && turn !== null)