toolroll 0.9.52 → 0.9.54

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 (187) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +55 -80
  3. package/dist/agentconfig.d.ts +11 -36
  4. package/dist/agentconfig.js +3 -62
  5. package/dist/approval-policy.d.ts +1 -1
  6. package/dist/approval-policy.js +1 -1
  7. package/dist/board.d.ts +3 -19
  8. package/dist/board.js +4 -42
  9. package/dist/browser/workspace.js +18 -18
  10. package/dist/browser-workspace.d.ts +2 -1
  11. package/dist/builder.d.ts +9 -40
  12. package/dist/builder.js +50 -257
  13. package/dist/chat-actions.js +3 -14
  14. package/dist/chat-ask.js +8 -8
  15. package/dist/chat-controls.d.ts +0 -4
  16. package/dist/chat-controls.js +0 -1
  17. package/dist/chat-core.d.ts +171 -0
  18. package/dist/chat-core.js +288 -0
  19. package/dist/chat-decide.d.ts +10 -8
  20. package/dist/chat-decide.js +27 -24
  21. package/dist/chat-delivery-state.d.ts +51 -75
  22. package/dist/chat-delivery-state.js +140 -170
  23. package/dist/chat-delivery.d.ts +21 -4
  24. package/dist/chat-delivery.js +313 -195
  25. package/dist/chat-flow.d.ts +29 -1
  26. package/dist/chat-flow.js +24 -50
  27. package/dist/chat-messages.d.ts +319 -0
  28. package/dist/chat-messages.js +841 -0
  29. package/dist/chat-migration.d.ts +30 -0
  30. package/dist/chat-migration.js +802 -0
  31. package/dist/chat-quiet.d.ts +3 -0
  32. package/dist/chat-quiet.js +7 -2
  33. package/dist/chat-task-actions.d.ts +1 -1
  34. package/dist/chat-task-actions.js +0 -2
  35. package/dist/claim.d.ts +79 -244
  36. package/dist/claim.js +688 -1023
  37. package/dist/cli.d.ts +2 -0
  38. package/dist/cli.js +10 -2
  39. package/dist/contracts/chat-content.d.ts +71 -2
  40. package/dist/contracts/chat-content.js +22 -3
  41. package/dist/contracts/chat-tables.d.ts +11 -4
  42. package/dist/contracts/chat-tables.js +165 -87
  43. package/dist/contracts/cli.d.ts +0 -13
  44. package/dist/contracts/cli.js +0 -2
  45. package/dist/contracts/console-api.d.ts +5 -10
  46. package/dist/contracts/console-api.js +4 -10
  47. package/dist/contracts/discord-callback.js +2 -1
  48. package/dist/contracts/flow.d.ts +50 -0
  49. package/dist/contracts/flow.js +26 -1
  50. package/dist/contracts/lead-tools.d.ts +0 -13
  51. package/dist/contracts/lead-tools.js +3 -3
  52. package/dist/contracts/slack-callback.js +3 -2
  53. package/dist/contracts/store-json.d.ts +1 -29
  54. package/dist/contracts/store-json.js +4 -17
  55. package/dist/contracts/teams-callback.js +2 -1
  56. package/dist/converse.d.ts +1 -11
  57. package/dist/converse.js +6 -38
  58. package/dist/coordinator.d.ts +0 -1
  59. package/dist/coordinator.js +3 -5
  60. package/dist/demo.js +16 -26
  61. package/dist/desktop-update-gate.js +0 -1
  62. package/dist/desktop-update.js +1 -3
  63. package/dist/discord-chat.js +19 -38
  64. package/dist/discord-settings.js +4 -13
  65. package/dist/discord.js +4 -14
  66. package/dist/dispatch.d.ts +1 -1
  67. package/dist/dispatch.js +1 -1
  68. package/dist/dispose.d.ts +2 -3
  69. package/dist/dispose.js +17 -92
  70. package/dist/envelope.js +0 -3
  71. package/dist/exec.d.ts +0 -64
  72. package/dist/exec.js +3 -356
  73. package/dist/exhaustion.d.ts +22 -76
  74. package/dist/exhaustion.js +14 -140
  75. package/dist/export.js +8 -8
  76. package/dist/flow-engine.js +1 -1
  77. package/dist/flow-schedule.d.ts +154 -0
  78. package/dist/flow-schedule.js +321 -0
  79. package/dist/flow-share.js +1 -1
  80. package/dist/flow-triggers.d.ts +6 -1
  81. package/dist/flow-triggers.js +65 -10
  82. package/dist/flows-cli.js +8 -4
  83. package/dist/flows-ui.js +2 -0
  84. package/dist/flows.js +1 -1
  85. package/dist/guides.js +9 -29
  86. package/dist/invoke.d.ts +1 -24
  87. package/dist/invoke.js +17 -259
  88. package/dist/lead-status.js +2 -4
  89. package/dist/lead-voice.js +1 -1
  90. package/dist/mate-doors.d.ts +1 -2
  91. package/dist/mate-doors.js +12 -25
  92. package/dist/mate-tools.d.ts +2 -2
  93. package/dist/mate-tools.js +13 -24
  94. package/dist/modes.d.ts +19 -8
  95. package/dist/modes.js +24 -14
  96. package/dist/notification-rows.d.ts +30 -0
  97. package/dist/notification-rows.js +102 -0
  98. package/dist/operate-remote-arguments.js +2 -6
  99. package/dist/operate-remote.js +6 -7
  100. package/dist/operate.d.ts +0 -6
  101. package/dist/operate.js +226 -2248
  102. package/dist/phase-routing.d.ts +13 -23
  103. package/dist/phase-routing.js +13 -35
  104. package/dist/plan-auto.js +2 -2
  105. package/dist/plane-review.js +5 -11
  106. package/dist/planner-source.js +2 -1
  107. package/dist/policy.d.ts +1 -4
  108. package/dist/policy.js +0 -8
  109. package/dist/process-recovery-eligibility.js +2 -3
  110. package/dist/project-delete.js +24 -41
  111. package/dist/proposal.d.ts +4 -24
  112. package/dist/proposal.js +3 -88
  113. package/dist/provider-auth.d.ts +3 -3
  114. package/dist/provider-auth.js +4 -6
  115. package/dist/provider.d.ts +11 -25
  116. package/dist/provider.js +4 -36
  117. package/dist/push.js +1 -1
  118. package/dist/recipe-ui.js +6 -6
  119. package/dist/recipes.d.ts +3 -3
  120. package/dist/recipes.js +27 -23
  121. package/dist/result-shots.js +2 -2
  122. package/dist/retention.js +1 -1
  123. package/dist/run-closeout.d.ts +1 -1
  124. package/dist/run-closeout.js +1 -4
  125. package/dist/scope.d.ts +11 -65
  126. package/dist/scope.js +17 -82
  127. package/dist/serve.js +15 -24
  128. package/dist/server/chat.js +7 -30
  129. package/dist/server/flows.js +11 -10
  130. package/dist/server/guards.js +0 -1
  131. package/dist/server/pages-assets.js +1 -1
  132. package/dist/server/pages.js +0 -2
  133. package/dist/server/people-tokens.js +0 -4
  134. package/dist/server/route-table.d.ts +1 -1
  135. package/dist/server/route-table.js +2 -17
  136. package/dist/server/settings.js +6 -12
  137. package/dist/server/shared.d.ts +28 -212
  138. package/dist/server/shared.js +71 -780
  139. package/dist/server/task-views.js +9 -76
  140. package/dist/server/tasks.d.ts +1 -50
  141. package/dist/server/tasks.js +36 -946
  142. package/dist/setup-guide.d.ts +2 -2
  143. package/dist/slack-api.d.ts +5 -1
  144. package/dist/slack-api.js +2 -2
  145. package/dist/slack-chat.d.ts +9 -6
  146. package/dist/slack-chat.js +29 -53
  147. package/dist/slack-settings.js +6 -15
  148. package/dist/slack.js +6 -16
  149. package/dist/store.d.ts +200 -1702
  150. package/dist/store.js +811 -5439
  151. package/dist/surface.js +10 -24
  152. package/dist/task-composer.d.ts +1 -1
  153. package/dist/task-composer.js +1 -1
  154. package/dist/task-control.d.ts +3 -10
  155. package/dist/task-control.js +2 -7
  156. package/dist/teammate-desk.js +1 -1
  157. package/dist/teammate-question.js +12 -12
  158. package/dist/teammates-ui.js +1 -1
  159. package/dist/teams-chat.js +12 -9
  160. package/dist/teams-settings.js +2 -2
  161. package/dist/teams.js +4 -5
  162. package/dist/telegram-flow.js +5 -5
  163. package/dist/telegram-mate.d.ts +0 -3
  164. package/dist/telegram-mate.js +0 -1
  165. package/dist/telegram.js +4 -4
  166. package/dist/templates.d.ts +11 -7
  167. package/dist/templates.js +12 -9
  168. package/dist/toolroll-update.d.ts +20 -0
  169. package/dist/toolroll-update.js +104 -5
  170. package/dist/work-index.js +2 -6
  171. package/dist/workspace-revision.js +3 -1
  172. package/dist/workspace-ui.js +1 -1
  173. package/package.json +1 -1
  174. package/scripts/recovery-canary.mjs +3 -3
  175. package/dist/contest.d.ts +0 -318
  176. package/dist/contest.js +0 -753
  177. package/dist/held.d.ts +0 -150
  178. package/dist/held.js +0 -799
  179. package/dist/liveness.d.ts +0 -30
  180. package/dist/liveness.js +0 -42
  181. package/dist/pricing.d.ts +0 -45
  182. package/dist/pricing.js +0 -77
  183. package/dist/routine.d.ts +0 -228
  184. package/dist/routine.js +0 -764
  185. package/dist/slack-state.d.ts +0 -13
  186. package/dist/slack-state.js +0 -10
  187. package/dist/supervisor.mjs +0 -297
package/dist/demo.js CHANGED
@@ -33,8 +33,8 @@ import { addApprover, propose, approve } from "./scope.js";
33
33
  import { legOf, routeDigestOf } from "./phase-routing.js";
34
34
  import { acquire, release } from "./claim.js";
35
35
  import { register } from "./runner.js";
36
- import { approveRoutine, fireRoutine } from "./routine.js";
37
- import { fileTaskProposal, fileRoutineProposal } from "./proposal.js";
36
+ import { createScheduledFlow } from "./flow-schedule.js";
37
+ import { fileTaskProposal } from "./proposal.js";
38
38
  import { storeEvidence, budgetedStatJson, imageDimensions } from "./evidence.js";
39
39
  import { parseProof, adjudicate } from "./proof.js";
40
40
  import { sealVerificationReceipt } from "./verification-evidence.js";
@@ -58,7 +58,7 @@ const DEMO_PROFILE = {
58
58
  function presentedRoute(store, taskRef, role, spend = { provider: "claude", model: null }) {
59
59
  // A task with no scope presents the bare word `legacy` for the exact
60
60
  // pair it spends as (atomic authority closure) — nothing opens unstamped.
61
- const authority = store.routeAuthorityFor(taskRef, role) ?? store.routeAuthorityFor(taskRef, role, null, spend);
61
+ const authority = store.routeAuthorityFor(taskRef, role) ?? store.routeAuthorityFor(taskRef, role, spend);
62
62
  if (authority === null || !authority.ok)
63
63
  throw new Error(`demo seed: ${authority === null ? "no task" : authority.problem}`);
64
64
  return { route: authority.stamp };
@@ -419,11 +419,11 @@ export function seedDemo(store, repos, evidenceRoot, now) {
419
419
  // --- needs-you: an approval waiting -----------------------------------
420
420
  const planned = task("rotate-log-format", "Rotate the request-log format to JSON lines", repos.web, "Switch the request logger to JSON lines so the collector stops parsing free text. Keep the human console formatter for local dev. Migrate the two dashboards that grep the old format.");
421
421
  const plannedRef = store.refFor("built-in", planned).id;
422
- // An explainable route (v47): the operator declared elevated risk and
422
+ // An explainable route (v47): the operator sized the change large and
423
423
  // overrode the reviewer, so the ceremony shows a recommended leg, a
424
424
  // strong-tier leg, and an overridden leg side by side, with the runner's
425
425
  // readiness per provider.
426
- store.editTaskRoute(plannedRef, { by: "demo", authenticate: () => ({ ok: true }), risk: "elevated" }, hoursAgo(3.5));
426
+ store.editTaskRoute(plannedRef, { by: "demo", authenticate: () => ({ ok: true }), size: { size: "large", risky: false } }, hoursAgo(3.5));
427
427
  store.editTaskRoute(plannedRef, { by: "demo", authenticate: () => ({ ok: true }), override: { phase: "review", provider: "claude", model: "opus" } }, hoursAgo(3.2));
428
428
  store.setPlanState(plannedRef, "drafted");
429
429
  const plannerRun = store.startRun({
@@ -948,28 +948,18 @@ export function seedDemo(store, repos, evidenceRoot, now) {
948
948
  store.cancelTask("design-tokens", hoursAgo(10), "the token package was superseded");
949
949
  const held = task("migrate-billing", "Migrate billing exports to the new vendor", repos.api);
950
950
  store.hold(store.refFor("built-in", held).id, "waiting on the vendor sandbox account", null, hoursAgo(12));
951
- // --- a standing order with a track record ------------------------------
952
- const routine = fileRoutineProposal(store, {
953
- name: "nightly-deps",
954
- repo: repos.api,
955
- goal: "Refresh the lockfile within existing ranges, run the suite, summarize anything notable.",
956
- outOfScope: "No major version bumps.",
957
- touches: [],
958
- acceptance: [
959
- { id: "c1", statement: "The full test suite passes against the refreshed lockfile.", how: null, evidence: ["check"] },
960
- ],
961
- requirements: [],
962
- schedule: "daily:03:30",
963
- costCeilingUsd: null,
964
- filedVia: "demo",
951
+ // --- a scheduled flow, waiting for someone to turn its schedule on -----
952
+ const nightly = createScheduledFlow(store, {
953
+ repo: realpathSync(repos.api), name: "Nightly dependency refresh", stem: "nightly-deps", schedule: "daily:03:30", by: "demo",
954
+ terms: {
955
+ goal: "Refresh the lockfile within existing ranges, run the suite, summarize anything notable.",
956
+ outOfScope: "No major version bumps.",
957
+ touches: [], requirements: [], budgetPerRunMicrousd: null, costCeilingUsd: null,
958
+ acceptance: [{ id: "c1", statement: "The full test suite passes against the refreshed lockfile.", how: null, evidence: ["check"] }],
959
+ },
965
960
  }, hoursAgo(70));
966
- if (!routine.ok)
967
- throw new Error(`seed routine: ${routine.reason}`);
968
- approveRoutine(store, routine.id, "demo", hoursAgo(69), routine.digest, token);
969
- // Two nightly slots since approval: one fires, the second records its
970
- // single-flight skip honestly (the first instance is still open).
971
- fireRoutine(store, routine.id, hoursAgo(45));
972
- fireRoutine(store, routine.id, hoursAgo(21));
961
+ if (!nightly.ok)
962
+ throw new Error(`seed scheduled flow: ${nightly.message}`);
973
963
  // --- the outer loop: an opened PR with observed checks ----------------
974
964
  const pub = store.createPublicationIntent({
975
965
  run: doneRun,
@@ -61,7 +61,6 @@ export function activeUpdateWork(db) {
61
61
  runs: count("SELECT count(*) n FROM run WHERE outcome IS NULL"),
62
62
  claims: count("SELECT count(*) n FROM claim WHERE released_at IS NULL"),
63
63
  conversations: count("SELECT count(*) n FROM mate_turn WHERE state IN ('queued','running')") + count("SELECT count(*) n FROM chat_turn WHERE state IN ('queued','running')"),
64
- sessions: count("SELECT count(*) n FROM held_session WHERE ended_at IS NULL"),
65
64
  stopping: count("SELECT count(*) n FROM run_stop WHERE settled_at IS NULL"),
66
65
  ...activeCodingUpdateWork(db),
67
66
  };
@@ -295,8 +295,6 @@ export function plainLingering(fallbackRun, problem) {
295
295
  return { run, on: `Run #${run} finished, but Toolroll can't check whether its folder is still in use`, action: null };
296
296
  if (/may still be running|owned subprocess/.test(problem))
297
297
  return { run, on: `Run #${run} finished, but ${pid ? `its process ${pid}` : "one of its processes"} is still running`, action: null };
298
- if (/held supervisor/.test(problem))
299
- return { run, on: `Run #${run} is still shutting down`, action: null };
300
298
  if (/still open/.test(problem))
301
299
  return { run, on: `Run #${run} is still running`, action: null };
302
300
  return { run, on: `Run #${run} hasn't finished shutting down`, action: null };
@@ -844,7 +842,7 @@ export async function runDesktopUpdate(stateDir, hooks = {}) {
844
842
  if (j.waiting && now().getTime() - Date.parse(j.waiting.since) >= LINGERING_LIMIT_MS)
845
843
  stuck = lingering;
846
844
  else
847
- save(j, "draining", lingering ? `New work is paused. Waiting for run #${lingering.run}: ${lingeringWords(lingering)} No process is being killed.` : `Waiting for current work: ${active.runs} runs, ${active.claims} leases, ${active.conversations} chat requests, ${active.sessions} sessions, ${active.stopping} shutdowns, ${active.coding} coding sessions, ${active.codingDeliveries} unconfirmed messages. Nothing is being cancelled.`);
845
+ save(j, "draining", lingering ? `New work is paused. Waiting for run #${lingering.run}: ${lingeringWords(lingering)} No process is being killed.` : `Waiting for current work: ${active.runs} runs, ${active.claims} leases, ${active.conversations} chat requests, ${active.stopping} shutdowns, ${active.coding} coding sessions, ${active.codingDeliveries} unconfirmed messages. Nothing is being cancelled.`);
848
846
  }
849
847
  finally {
850
848
  checking.close();
@@ -190,17 +190,13 @@ export async function deliverDiscordPart(options) {
190
190
  if (!options.current() ||
191
191
  !state.owns(identity.installation, options.owner, now))
192
192
  return false;
193
- const row = state
194
- .prepare("SELECT p.* FROM chat_part p JOIN chat_event e ON e.id=p.event WHERE e.installation=? AND p.state='pending' AND (e.kind!='notice' OR ?=1) AND (p.next_at IS NULL OR p.next_at<=?) ORDER BY p.id LIMIT 1")
195
- .get(identity.installation, options.canNotify?.() === false ? 0 : 1, now.toISOString());
193
+ const row = state.nextPart(identity.installation, options.canNotify?.() !== false, now);
196
194
  if (!row)
197
195
  return false;
198
196
  const event = state.event(row.event), binding = event.binding === null ? null : state.bindingById(event.binding);
199
197
  if (!binding ||
200
198
  new Date(row.created).getTime() + 86_400_000 < now.getTime()) {
201
- state
202
- .prepare("UPDATE chat_part SET state='dropped',problem='Delivery expired or access changed; open the saved chat' WHERE id=?")
203
- .run(row.id);
199
+ state.dropPart(row.id, "Delivery expired or access changed; open the saved chat");
204
200
  return true;
205
201
  }
206
202
  try {
@@ -214,16 +210,16 @@ export async function deliverDiscordPart(options) {
214
210
  throw new DiscordError("Connected projects changed");
215
211
  let text = content.text, buttons = [], file;
216
212
  if (content.image && content.shot && resultShotsPruned(options.evidenceRoot, content.image.run)) {
217
- state.prepare("UPDATE chat_part SET state='dropped',problem='Removed by retention' WHERE id=?").run(row.id);
213
+ state.dropPart(row.id, "Removed by retention");
218
214
  return true;
219
215
  }
220
216
  // A screenshot sent with a result replies to that result's message once it is placed, and waits until then.
221
- const result = content.shot?.follows == null ? undefined : state.prepare("SELECT message,state,next_at FROM chat_part WHERE id=?").get(content.shot.follows);
222
- if (result !== undefined && !discordId(result["message"]) && result["state"] === "pending") {
223
- state.prepare("UPDATE chat_part SET next_at=? WHERE id=?").run(shotWaitsUntil(result["next_at"], now), row.id);
217
+ const result = content.shot?.follows == null ? null : state.part(content.shot.follows);
218
+ if (result !== null && !discordId(result.message) && result.state === "pending") {
219
+ state.holdPart(row.id, shotWaitsUntil(result.next_at, now));
224
220
  return true;
225
221
  }
226
- const follows = result?.["message"];
222
+ const follows = result?.message;
227
223
  const thread = discordId(follows) ? String(follows) : event.thread;
228
224
  if (content.image) {
229
225
  const image = verifyResultImage(store, options.evidenceRoot, repos, content.image);
@@ -260,8 +256,7 @@ export async function deliverDiscordPart(options) {
260
256
  preview.text.length <= 3400 &&
261
257
  discordPlain(text).length <= 3900) {
262
258
  buttons = state
263
- .prepare("SELECT token,phase FROM chat_action WHERE part=? AND consumed IS NULL AND expires>? ORDER BY rowid")
264
- .all(row.id, now.toISOString())
259
+ .partTokens(row.id, now)
265
260
  .map((action) => ({
266
261
  type: 2,
267
262
  style: action.phase === "yes" ? 4 : action.phase === "confirm" ? 1 : 2,
@@ -317,6 +312,10 @@ export async function deliverDiscordPart(options) {
317
312
  custom_id: `so_${one.token}`,
318
313
  }))
319
314
  : []),
315
+ // A result's, plan's, failure's or pull request's own buttons (chat-decide.ts): its acts, then its links.
316
+ ...(content.decide?.rows.flat() ?? []).flatMap((one, index) => "token" in one
317
+ ? [{ type: 2, style: index === 0 ? 1 : 2, label: one.label.slice(0, 80), custom_id: `so_${one.token}` }]
318
+ : link(options.origin(), one.link)),
320
319
  ...link(options.origin(), content.link),
321
320
  ...(content.also ?? []).flatMap(one => link(options.origin(), one)),
322
321
  ];
@@ -355,13 +354,7 @@ export async function deliverDiscordPart(options) {
355
354
  // Preserve the original remote attachment; never upload it again on an edit.
356
355
  file = undefined;
357
356
  }
358
- state
359
- .prepare("UPDATE chat_part SET message=?,uploaded=? WHERE id=?")
360
- .run(target, content.image &&
361
- Array.isArray(found.attachments) &&
362
- found.attachments.length
363
- ? 1
364
- : 0, row.id);
357
+ state.setPartFile(row.id, null, Boolean(content.image && Array.isArray(found.attachments) && found.attachments.length), target);
365
358
  }
366
359
  }
367
360
  await channelAccess(shared, binding, session?.ceilingDigest);
@@ -396,17 +389,13 @@ export async function deliverDiscordPart(options) {
396
389
  !answer.attachments.some((v) => object(v).filename === file.name &&
397
390
  Number(object(v).size) === file.bytes.length))))
398
391
  throw new DiscordError("Discord did not confirm the message and file identity", 15_000, true);
399
- state
400
- .prepare("UPDATE chat_part SET state='sent',message=?,attempts=attempts+1,problem=NULL,next_at=NULL WHERE id=?")
401
- .run(answer.id, row.id);
402
- state
403
- .prepare("UPDATE chat_runtime SET problem=NULL WHERE installation=? AND owner=?")
404
- .run(identity.installation, options.owner);
392
+ state.partSent(row.id, String(answer.id), options.clock?.() ?? new Date());
393
+ state.setProblem(identity.installation, options.owner, null);
405
394
  return true;
406
395
  }
407
396
  catch (error) {
408
397
  if (error instanceof ChatDeliveryError && error.permanent) {
409
- state.prepare("UPDATE chat_part SET state='dropped',next_at=NULL,problem=? WHERE id=?").run(error.message, row.id);
398
+ state.dropPart(row.id, error.message);
410
399
  return true;
411
400
  }
412
401
  // No permission to attach files here: one plain line instead of the result's screenshots.
@@ -420,18 +409,10 @@ export async function deliverDiscordPart(options) {
420
409
  : new DiscordError("Discord delivery is waiting to retry", 15_000, true);
421
410
  const until = new Date((options.clock?.() ?? new Date()).getTime() +
422
411
  Math.max(problem.retryMs, [5000, 15000, 60000, 300000][Math.min(row.attempts, 3)])).toISOString();
423
- state
424
- .prepare("UPDATE chat_part SET attempts=attempts+1,uncertain=uncertain+?,next_at=?,problem=? WHERE id=?")
425
- .run(problem.uncertain ? 1 : 0, until, problem.message, row.id);
426
- state
427
- .prepare("UPDATE chat_runtime SET problem=? WHERE installation=? AND owner=?")
428
- .run(problem.code === "ratelimited"
429
- ? "Discord asked us to wait. Saved replies will retry."
430
- : problem.message, identity.installation, options.owner);
412
+ state.partFailed(row.id, { problem: problem.message, until, uncertain: problem.uncertain });
413
+ state.setProblem(identity.installation, options.owner, problem.code === "ratelimited" ? "Discord asked us to wait. Saved replies will retry." : problem.message);
431
414
  if (problem.code === "ratelimited")
432
- state
433
- .prepare("UPDATE chat_runtime SET retry_at=? WHERE installation=?")
434
- .run(until, identity.installation);
415
+ state.deferUntil(identity.installation, until);
435
416
  return true;
436
417
  }
437
418
  }
@@ -9,14 +9,10 @@ const escape = (text) => text
9
9
  .replaceAll("'", "&#39;");
10
10
  export function discordSettingsHtml(store, dir, csrf, options = {}) {
11
11
  const credentials = loadDiscordCredentials(dir), state = new ChatState(store, "discord"), bindings = credentials ? state.bindings(credentials.installation).filter(one => state.live(one)) : [], binding = options.who === undefined ? null : bindings.find(one => one.approver === options.who) ?? null, others = bindings.length - (binding === null ? 0 : 1);
12
- const runtime = credentials
13
- ? state.db
14
- .prepare("SELECT connected,problem,lease_until FROM discord_runtime WHERE installation=?")
15
- .get(credentials.installation)
16
- : null;
12
+ const runtime = credentials ? state.runtime(credentials.installation) : null;
17
13
  const now = options.now ?? new Date(), live = runtime &&
18
- typeof runtime.lease_until === "string" &&
19
- runtime.lease_until > now.toISOString() &&
14
+ runtime.leaseUntil !== null &&
15
+ runtime.leaseUntil > now.toISOString() &&
20
16
  runtime.connected;
21
17
  const hidden = `<input type="hidden" name="csrf" value="${escape(csrf)}">`;
22
18
  const password = '<label>Your Toolroll password<input style="min-height:44px" type="password" name="password" autocomplete="current-password" required></label>';
@@ -44,12 +40,7 @@ export function discordSettingsHtml(store, dir, csrf, options = {}) {
44
40
  '<a class="button-link" style="min-height:44px;white-space:nowrap" href="/settings/discord">Check connection</a>';
45
41
  }
46
42
  else if (binding && state.live(binding)) {
47
- const pending = Number(state.db
48
- .prepare("SELECT count(*) n FROM discord_part p JOIN discord_event e ON e.id=p.event WHERE e.binding=? AND p.state='pending'")
49
- .get(binding.id)?.n ?? 0);
50
- const failed = Number(state.db
51
- .prepare("SELECT count(*) n FROM discord_part p JOIN discord_event e ON e.id=p.event WHERE e.binding=? AND p.state='dropped'")
52
- .get(binding.id)?.n ?? 0);
43
+ const { pending, dropped: failed } = state.partCounts(binding.id);
53
44
  content +=
54
45
  `<p>Your Discord account is paired.${others ? ` ${others} teammate${others === 1 ? " is" : "s are"} paired too.` : ""}${pending ? ` ${pending} replies waiting to send.` : ""}${failed ? ` ${failed} replies could not be delivered. Open the saved chat to recover them.` : ""}</p>` +
55
46
  "<p>Try “What needs my attention?” or “Show the evidence for the latest result.”</p>" +
package/dist/discord.js CHANGED
@@ -37,9 +37,7 @@ export async function followDiscord(options) {
37
37
  current.botToken === credentials.botToken &&
38
38
  state.owns(credentials.installation, owner));
39
39
  };
40
- const problem = (message) => state
41
- .prepare("UPDATE chat_runtime SET problem=? WHERE installation=? AND owner=?")
42
- .run(message, credentials.installation, owner);
40
+ const problem = (message) => state.setProblem(credentials.installation, owner, message);
43
41
  const api = discordApi(credentials.botToken);
44
42
  const client = new Client({
45
43
  // Guild messages and their content are needed for rooms; the Message
@@ -63,9 +61,7 @@ export async function followDiscord(options) {
63
61
  if (!same() || !validated)
64
62
  return;
65
63
  connected = true;
66
- state
67
- .prepare("UPDATE chat_runtime SET connected=?,problem=NULL WHERE installation=? AND owner=?")
68
- .run(new Date().toISOString(), credentials.installation, owner);
64
+ state.setConnected(credentials.installation, owner, new Date());
69
65
  };
70
66
  client.on("shardReady", online);
71
67
  client.on("shardResume", online);
@@ -133,11 +129,7 @@ export async function followDiscord(options) {
133
129
  try {
134
130
  await client.login(credentials.botToken);
135
131
  while (same()) {
136
- const retry = state
137
- .prepare("SELECT retry_at FROM chat_runtime WHERE installation=?")
138
- .get(credentials.installation)?.retry_at;
139
- if (!connected ||
140
- (typeof retry === "string" && retry > new Date().toISOString())) {
132
+ if (!connected || state.retryAt(credentials.installation) > new Date().toISOString()) {
141
133
  await pause(1000);
142
134
  continue;
143
135
  }
@@ -160,9 +152,7 @@ export async function followDiscord(options) {
160
152
  alive = false;
161
153
  options.signal.removeEventListener("abort", stop);
162
154
  await client.destroy().catch(() => { });
163
- state
164
- .prepare("UPDATE chat_runtime SET owner=NULL,lease_until=NULL,connected=NULL WHERE installation=? AND owner=?")
165
- .run(credentials.installation, owner);
155
+ state.stop(credentials.installation, owner);
166
156
  }
167
157
  await pause(5000);
168
158
  }
@@ -90,7 +90,7 @@ export declare function taskReadinessBlocker(store: Store, taskRef: number, now:
90
90
  * mode still stand. */
91
91
  export declare function scopeApprovedForDispatch(store: Store, taskRef: number, now: Date): boolean;
92
92
  /** The phase the ordinary dispatch loop would choose before it resolves an
93
- * agent. Attended authorization is deliberately outside this ordinary path. */
93
+ * agent. */
94
94
  export declare function dispatchRoleFor(store: Store, taskRef: number, now: Date): "builder" | "planner" | "scout";
95
95
  /** The first requirement this task fails, in words, or null. */
96
96
  export declare function missingCapability(store: Store, taskRef: number, dispatchRepo: string | null, now: Date): string | null;
package/dist/dispatch.js CHANGED
@@ -85,7 +85,7 @@ export function scopeApprovedForDispatch(store, taskRef, now) {
85
85
  return row !== undefined;
86
86
  }
87
87
  /** The phase the ordinary dispatch loop would choose before it resolves an
88
- * agent. Attended authorization is deliberately outside this ordinary path. */
88
+ * agent. */
89
89
  export function dispatchRoleFor(store, taskRef, now) {
90
90
  const ref = store.refForId(taskRef);
91
91
  const approved = scopeApprovedForDispatch(store, taskRef, now);
package/dist/dispose.d.ts CHANGED
@@ -6,10 +6,9 @@ import { type ProofVerdict } from "./proof.js";
6
6
  * Which road is disposing. 'tick' = the unattended loop: full task
7
7
  * completion, strikes, quota, publication. 'standalone' = the one-off
8
8
  * `build` command: run records only — no task state, no strikes, no
9
- * publication (its historical shape). The held road reuses 'tick' —
10
- * an attended session is still the task's real attempt.
9
+ * publication (its historical shape).
11
10
  */
12
- export type DisposePolicy = "tick" | "standalone" | "continuation";
11
+ export type DisposePolicy = "tick" | "standalone";
13
12
  export type DisposeContext = {
14
13
  store: Store;
15
14
  policy: DisposePolicy;
package/dist/dispose.js CHANGED
@@ -5,16 +5,15 @@ import { originalTaskBase, focusedTestCommandSupported } from "./observations.js
5
5
  * operations that actually END an attempt — sealing parks, accepting
6
6
  * completions behind the completion fence, recording failures with their
7
7
  * strikes and holds, opening publication intents — extracted from tick's
8
- * and the standalone build command's finalizers into ONE place, so the
9
- * held-session coordinator can end a run through exactly the machinery
10
- * every other road uses. The two historical roads differ deliberately
8
+ * and the standalone build command's finalizers into ONE place. The two
9
+ * historical roads differ deliberately
11
10
  * (the standalone command completes no task, strikes nothing, publishes
12
11
  * nothing), and the policy record says so explicitly instead of forking
13
12
  * the logic: behavior on both is byte-identical to what the callers
14
13
  * inlined before.
15
14
  */
16
15
  import { createHash } from "node:crypto";
17
- import { completeFenced, finalizeFailureFenced, finalizeHandBackFenced, finalizeMalformedFenced, finalizeParkFenced, interruptIfStopped, release, } from "./claim.js";
16
+ import { interruptIfStopped, release, finalize, } from "./claim.js";
18
17
  import { bodyHashOf, publicationBody } from "./publish.js";
19
18
  import { modeTermsFromJson } from "./modes.js";
20
19
  import { evidenceRoot, readVerifiedArtifact, writeEvidenceFile } from "./evidence.js";
@@ -101,8 +100,8 @@ function disposeBuildOutcomeLocked(context, result) {
101
100
  if (result.ok && result.parked !== undefined) {
102
101
  if (leaseId === undefined)
103
102
  return { kind: "park-fenced" };
104
- const sealed = finalizeParkFenced(store, {
105
- leaseId,
103
+ const sealed = finalize(store, leaseId, {
104
+ kind: "park",
106
105
  runId,
107
106
  taskId,
108
107
  decision: result.parked.decision,
@@ -118,57 +117,6 @@ function disposeBuildOutcomeLocked(context, result) {
118
117
  return { kind: "parked", decisionId: sealed.decisionId };
119
118
  }
120
119
  if (result.ok) {
121
- if (policy === "continuation") {
122
- // The taskless success (v4 Q7 + v5 P5): run finished, claim released,
123
- // publication intent under the SAME completion latch the ordinary
124
- // road holds — and the parent task untouched in state, strikes,
125
- // holds, and derived stats. All one transaction.
126
- const sealed = store.transact(() => {
127
- const latchOpen = store.mirrorAllowsCompletion(taskId);
128
- store.finishRun(runId, {
129
- outcome: result.noChange === true ? "no-change" : "built",
130
- ...(result.noChange === true ? { reason: "handoff" } : {}),
131
- committed: result.committed,
132
- now: clock(),
133
- });
134
- if (leaseId !== undefined)
135
- release(store, leaseId, clock());
136
- if (!latchOpen)
137
- return { disowned: true };
138
- if (result.noChange !== true && result.committed) {
139
- const grant = store.publicationGrantFor(repo);
140
- const headSha = store.getRun(runId)?.headRevision ?? null;
141
- if (grant !== null &&
142
- grant.publishOn !== "complete" &&
143
- headSha !== null &&
144
- headWithin(branch, grant.headPrefix) &&
145
- (grant.selector === "all" || origin === "ours")) {
146
- const intentId = store.createPublicationIntent({
147
- run: runId,
148
- taskRef,
149
- githubRepo: grant.githubRepo,
150
- remote: grant.remote,
151
- base: grant.base,
152
- head: branch,
153
- headSha,
154
- bodyHash: "",
155
- draft: grant.draft,
156
- }, clock());
157
- const publication = store.publicationForRun(runId);
158
- if (publication !== null) {
159
- store.handle
160
- .prepare("UPDATE publication SET body_hash = ? WHERE id = ?")
161
- .run(bodyHashOf(publicationBody(store, publication)), intentId);
162
- }
163
- }
164
- }
165
- return { disowned: false };
166
- });
167
- store.clearQuota(runner, provider, model ?? "");
168
- if (sealed.disowned)
169
- return { kind: "disowned" };
170
- return { kind: "built", committed: result.committed, noChange: result.noChange === true };
171
- }
172
120
  if (policy === "standalone") {
173
121
  store.finishRun(runId, {
174
122
  outcome: result.noChange === true ? "no-change" : "built",
@@ -184,7 +132,7 @@ function disposeBuildOutcomeLocked(context, result) {
184
132
  // around the fenced release, the run's outcome, and the publication
185
133
  // intent, so "done" and "this must reach a PR" cannot come apart.
186
134
  const sealed = store.transact(() => {
187
- const fence = completeFenced(store, leaseId, "done", clock());
135
+ const fence = finalize(store, leaseId, { kind: "complete", state: "done", now: clock() });
188
136
  if (!fence.ok)
189
137
  return fence;
190
138
  // The disowned arm (external dispatch, v4 §24): the tracker closed
@@ -264,14 +212,14 @@ function disposeBuildOutcomeLocked(context, result) {
264
212
  // THE ALREADY-SEALED ENDINGS (adaptive execution plans). Every other arm
265
213
  // below ends the attempt itself — releases the claim, writes the run's
266
214
  // outcome, counts what it costs. These two arrive with all of that
267
- // already done: `finalizeRevisionFenced` released the lease, appended the
215
+ // already done: `finalize` (a `revision` ending) released the lease, appended the
268
216
  // ledger row, placed the hold when one was owed, finished the run, and
269
217
  // paged, all inside ONE fenced transaction — the planner road's shape,
270
218
  // where claim.ts owns the ending and dispose only reports it.
271
219
  //
272
220
  // So this branch exists to do NOTHING, deliberately, and it is placed
273
- // above the policy arms so that every road — tick, standalone, held,
274
- // continuation — skips them alike.
221
+ // above the policy arms so that every road — tick and standalone —
222
+ // skips them alike.
275
223
  //
276
224
  // Falling through instead would be wrong twice over. `release()` would
277
225
  // survive it (an already-released lease whose `released_by` is
@@ -284,20 +232,10 @@ function disposeBuildOutcomeLocked(context, result) {
284
232
  if (result.reason === "plan-revised" || result.reason === "plan-revision-blocked") {
285
233
  return { kind: "skipped", reason: result.reason };
286
234
  }
287
- if (policy === "continuation") {
288
- // The taskless failure (v4 Q7): the run says what happened, the claim
289
- // releases — NO strikes, NO holds, NO done→failed demotion; three
290
- // failed continuations still leave the parent exactly as it finished.
291
- const outcome = result.reason === "fenced" ? "refused" : "failed";
292
- store.finishRun(runId, { outcome, reason: result.reason, now: clock() });
293
- if (leaseId !== undefined)
294
- release(store, leaseId, clock());
295
- return { kind: "recorded", outcome };
296
- }
297
235
  if (policy === "standalone") {
298
236
  if (result.reason === "malformed-decision" && leaseId !== undefined) {
299
- const sealed = finalizeMalformedFenced(store, {
300
- leaseId,
237
+ const sealed = finalize(store, leaseId, {
238
+ kind: "malformed",
301
239
  runId,
302
240
  taskId,
303
241
  problems: result.problems ?? [],
@@ -309,19 +247,6 @@ function disposeBuildOutcomeLocked(context, result) {
309
247
  store.finishRun(runId, { outcome, reason: result.reason, now: clock() });
310
248
  return { kind: "recorded", outcome };
311
249
  }
312
- // The attended refusal family (v28 sweep): typed, no strike, release and
313
- // move on — the pre-claim gates make every one a rare race, and the
314
- // invariant arm they used to fall through is for BUGS, not races.
315
- if (result.reason === "attended-only" ||
316
- result.reason === "attended-held" ||
317
- result.reason === "stale-authorization" ||
318
- result.reason === "session-cap" ||
319
- result.reason === "run-held") {
320
- if (leaseId !== undefined)
321
- release(store, leaseId, clock());
322
- store.finishRun(runId, { outcome: "refused", reason: result.reason, now: clock() });
323
- return { kind: "skipped", reason: result.reason };
324
- }
325
250
  if (result.reason === "unapproved" || result.reason === "scope-changed") {
326
251
  // Approval drifted between the prefilter and the builder's own gate.
327
252
  if (leaseId !== undefined)
@@ -337,8 +262,8 @@ function disposeBuildOutcomeLocked(context, result) {
337
262
  if (result.reason === "malformed-decision") {
338
263
  if (leaseId === undefined)
339
264
  return { kind: "malformed", sealed: false };
340
- const sealed = finalizeMalformedFenced(store, {
341
- leaseId,
265
+ const sealed = finalize(store, leaseId, {
266
+ kind: "malformed",
342
267
  runId,
343
268
  taskId,
344
269
  problems: result.problems ?? [],
@@ -349,7 +274,7 @@ function disposeBuildOutcomeLocked(context, result) {
349
274
  // A stop no operator asked for is the SERVICE stopping (the fence above
350
275
  // already settled every task stop): hand the attempt back, never strike it.
351
276
  if (result.reason === "stopped" && policy === "tick" && leaseId !== undefined && store.getRun(runId)?.role === "builder") {
352
- const handed = finalizeHandBackFenced(store, { leaseId, runId, taskId, message: result.message, now: clock() });
277
+ const handed = finalize(store, leaseId, { kind: "hand-back", runId, taskId, message: result.message, now: clock() });
353
278
  return { kind: "handed-back", requeued: handed.requeued };
354
279
  }
355
280
  if (TICK_FAILURE_REASONS.has(result.reason)) {
@@ -376,8 +301,8 @@ function disposeBuildOutcomeLocked(context, result) {
376
301
  store.finishRun(runId, { outcome: "failed", reason: result.reason, now: clock() });
377
302
  return { kind: "failed", failureClass, disposition: null, strikes: null, sealed: false };
378
303
  }
379
- const sealed = finalizeFailureFenced(store, {
380
- leaseId,
304
+ const sealed = finalize(store, leaseId, {
305
+ kind: "failure",
381
306
  runId,
382
307
  taskId,
383
308
  failureClass,
@@ -531,7 +456,7 @@ export function regateTask(store, taskId, now, approval) {
531
456
  return store.transact(() => {
532
457
  const proposed = propose(store, {
533
458
  taskId, goal: scope.goal, outOfScope: scope.outOfScope, touches: scope.touches, budgetMicrousd: scope.budgetMicrousd,
534
- acceptance: scope.acceptance, qualityMode: scope.qualityMode ?? "default", riskLevel: scope.riskLevel ?? "routine",
459
+ acceptance: scope.acceptance, qualityMode: scope.qualityMode ?? "default",
535
460
  candidate: head, now,
536
461
  });
537
462
  let sealed = false;
package/dist/envelope.js CHANGED
@@ -55,12 +55,9 @@ export const DOCUMENTED_REASONS = [
55
55
  "unapproved", // no person has agreed to the scope yet
56
56
  "reserved", // the task belongs to another worker's queue
57
57
  "external", // a tracker mirror is not dispatchable (see detail)
58
- "contest-open", // a tournament is running; a person picks
59
- "all-lanes-raceable", // every compared agent could hold a real budget — race them instead
60
58
  "provider-unattested", // the provider binary is outside its attested range
61
59
  "daily-runs", // the active mode's daily run cap is spent
62
60
  "daily-dollars", // the active mode's daily measured-spend rail is reached
63
- "steering-pending", // a steering note is waiting; contests refuse
64
61
  "not-ready", // blocked, held, or otherwise not dispatchable
65
62
  "not-leased", // the lease does not exist or already ended
66
63
  "not-yours", // held or reserved by someone else
package/dist/exec.d.ts CHANGED
@@ -226,67 +226,3 @@ export declare function runStreamJsonl(file: string, args: readonly string[], op
226
226
  */
227
227
  export declare function runGeminiStreamJsonl(file: string, args: readonly string[], options?: RunOptions): Promise<ExecResult>;
228
228
  export declare function runClaudeStreamJsonl(file: string, args: readonly string[], options?: RunOptions): Promise<ExecResult>;
229
- /**
230
- * Where the supervisor script lives: a plain-.mjs sibling of this module in
231
- * BOTH layouts (src/ under tsx, dist/ after build — postbuild copies it).
232
- */
233
- export declare function supervisorPath(): string;
234
- /**
235
- * Darwin's sun_path is 104 bytes including the terminator; Linux allows a
236
- * hair more. Refusing at spawn beats failing inside bind() with a raw
237
- * ENAMETOOLONG after custody was already recorded.
238
- */
239
- export declare const HELD_SOCKET_PATH_LIMIT = 103;
240
- export declare function heldSocketPathProblem(path: string): string | null;
241
- export type HeldSessionEvents = {
242
- /** The nth system/init observed (1-based) — acceptance of the nth written turn. */
243
- onTurnInit?: (seq: number) => void;
244
- /** The nth primary-origin result (1-based) — settlement material for the nth turn. */
245
- onTurnResult?: (seq: number, event: Record<string, unknown>) => void;
246
- /** Every parsed stream event, arc-1 isolation rules unchanged. */
247
- onStreamEvent?: (event: Record<string, unknown>) => void;
248
- onSessionId?: (id: string) => void;
249
- /** The supervisor exited: the hold is over, however it ended. */
250
- onExit?: (info: {
251
- code: number | null;
252
- }) => void;
253
- };
254
- export type HeldSessionHandle = {
255
- supervisorPid: number | null;
256
- agentPgid: number | null;
257
- /** One turn as a stream-json line. False = the pipe is gone; nothing was written. */
258
- writeTurn(json: string): boolean;
259
- /** The EOF road: the supervisor fences (EOF → grace → group kill) on its own. */
260
- endInput(): void;
261
- /** SIGTERM the supervisor — the same fence, for expiry/revocation/lapse. */
262
- terminate(): void;
263
- /** Last resort only: SIGKILL the supervisor itself (can orphan the agent). */
264
- killHard(): void;
265
- exited: Promise<{
266
- code: number | null;
267
- }>;
268
- };
269
- export type HeldSessionStart = {
270
- ok: true;
271
- handle: HeldSessionHandle;
272
- } | {
273
- ok: false;
274
- reason: "socket-path" | "spawn-failed" | "ready-timeout";
275
- message: string;
276
- };
277
- /**
278
- * Hold one claude process open across stream-json turns, under a
279
- * supervisor whose parenthood makes the eventual kill provable. Returns
280
- * once the supervisor's control frame settles the spawn either way.
281
- *
282
- * No timers live here: the per-turn wall deadline, liveness lapse, and
283
- * absolute expiry are the coordinator's exact timers (v2 S1d) — this
284
- * transport only moves bytes and counts the protocol's own marks.
285
- */
286
- export declare function startClaudeHeldSession(file: string, args: readonly string[], options: RunOptions & {
287
- socketPath: string;
288
- cookie: string;
289
- graceMs?: number;
290
- events?: HeldSessionEvents;
291
- readyTimeoutMs?: number;
292
- }): Promise<HeldSessionStart>;