toolroll 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/agent-onboard.d.ts +7 -0
  3. package/dist/agent-onboard.js +23 -5
  4. package/dist/browser/workspace.css +1 -1
  5. package/dist/browser/workspace.js +21 -20
  6. package/dist/browser-workspace.d.ts +10 -0
  7. package/dist/chat-delivery.js +51 -3
  8. package/dist/chat-quiet.d.ts +28 -0
  9. package/dist/chat-quiet.js +161 -0
  10. package/dist/checkout-cleanup.d.ts +84 -0
  11. package/dist/checkout-cleanup.js +245 -0
  12. package/dist/cli.d.ts +1 -1
  13. package/dist/cli.js +3 -0
  14. package/dist/digest-times.d.ts +3 -0
  15. package/dist/digest-times.js +7 -0
  16. package/dist/dispose.js +2 -0
  17. package/dist/export.js +1 -1
  18. package/dist/first-run.d.ts +4 -1
  19. package/dist/first-run.js +2 -1
  20. package/dist/integrations-ui.d.ts +13 -0
  21. package/dist/integrations-ui.js +65 -0
  22. package/dist/integrations.d.ts +113 -0
  23. package/dist/integrations.js +526 -0
  24. package/dist/mcp.js +26 -0
  25. package/dist/operate.d.ts +5 -6
  26. package/dist/operate.js +254 -25
  27. package/dist/project-delete.js +1 -0
  28. package/dist/prompt.d.ts +7 -2
  29. package/dist/prompt.js +36 -7
  30. package/dist/publish.d.ts +11 -0
  31. package/dist/publish.js +9 -2
  32. package/dist/pull-request-flow.d.ts +140 -0
  33. package/dist/pull-request-flow.js +440 -0
  34. package/dist/pull-request-schema.d.ts +5 -0
  35. package/dist/pull-request-schema.js +21 -0
  36. package/dist/pull-request-ui.d.ts +23 -0
  37. package/dist/pull-request-ui.js +55 -0
  38. package/dist/pulls.d.ts +5 -0
  39. package/dist/pulls.js +11 -0
  40. package/dist/serve.d.ts +6 -0
  41. package/dist/serve.js +323 -26
  42. package/dist/setup-guide.d.ts +2 -2
  43. package/dist/storage-ui.d.ts +10 -0
  44. package/dist/storage-ui.js +67 -0
  45. package/dist/storage.d.ts +15 -1
  46. package/dist/storage.js +62 -7
  47. package/dist/store.d.ts +66 -5
  48. package/dist/store.js +160 -19
  49. package/dist/surface.js +2 -0
  50. package/dist/telegram-progress.d.ts +1 -0
  51. package/dist/telegram-progress.js +1 -1
  52. package/dist/telegram.js +95 -15
  53. package/dist/toolroll-update-ui.d.ts +2 -0
  54. package/dist/toolroll-update-ui.js +1 -1
  55. package/dist/toolroll-update.d.ts +26 -6
  56. package/dist/toolroll-update.js +188 -50
  57. package/dist/worktree.d.ts +24 -10
  58. package/dist/worktree.js +84 -44
  59. package/package.json +1 -1
package/dist/operate.js CHANGED
@@ -3,7 +3,8 @@ import { parseProtectedPaths } from "./approval-policy.js";
3
3
  import { rulesSummary } from "./approval-rules-ui.js";
4
4
  import { evidencePack, exportDay, ledgerExportChunks, standaloneEvidenceHtml } from "./evidence-pack.js";
5
5
  import { matchesOutsideCheckpoint } from "./ledger-chain.js";
6
- import { bytesWords, storageReport } from "./storage.js";
6
+ import { CLEANUP_CHOICES, bytesWords, cleanupWords, parseCleanup, storageReport } from "./storage.js";
7
+ import { checkoutPlan, cleanCheckouts, discardCheckout, whyWords } from "./checkout-cleanup.js";
7
8
  import { MIN_DAYS, RETENTION_KINDS, countWords, dailyRetention, isRetentionKind, lastSweepAt, parsePeriod, periodWords, retentionPlan, sweepWords } from "./retention.js";
8
9
  import { checkPolicy, parseList, policyParts } from "./policy.js";
9
10
  import { billingOf, budgetHoldWords, budgetLabel, budgetStates, monthNamed, monthOf, spendItems, teammateNames, usd as spendUsd } from "./spend.js";
@@ -12,6 +13,7 @@ import { buildExport, exportSummary, exportZip, writeExportFolder } from "./expo
12
13
  import { startBudgetAlerts } from "./budget-alerts.js";
13
14
  import { liftAuthPause, openAuthPauses, signInGate, signInWords, startSignInProbes } from "./provider-auth.js";
14
15
  import { createConnectionChecker } from "./provider-connection.js";
16
+ import { checkIntegrations, integrationsBrokenLine, integrationsNow, renderIntegrations } from "./integrations.js";
15
17
  import { buildHandoff, handoffLines, loginAccount, runOnboard } from "./agent-onboard.js";
16
18
  import { backupFiles, backupFolderOf, backupOwner, backupNow, restoreDatabase, startBackups } from "./backup.js";
17
19
  import { pushLimitSink } from "./provider-limits.js";
@@ -66,7 +68,7 @@ import { runTaskOutcomeCommand } from "./task-outcome-cli.js";
66
68
  */
67
69
  import { homedir, hostname, tmpdir } from "node:os";
68
70
  import { basename, dirname, join, resolve } from "node:path";
69
- import { openStore, openStoreNoMigrate, CONCURRENT_WRITER_WAIT_MS, isDatabaseBusy, databasePath, BUILT_IN, DEFAULT_ACTOR, parseCapabilityKey, verifiedAuthor, contestantProfileOf, } from "./store.js";
71
+ import { openStore, openStoreNoMigrate, CONCURRENT_WRITER_WAIT_MS, isDatabaseBusy, databasePath, BUILT_IN, DEFAULT_ACTOR, parseCapabilityKey, verifiedAuthor, contestantProfileOf, isDigestTime, } from "./store.js";
70
72
  import { randomBytes, randomInt, randomUUID } from "node:crypto";
71
73
  import { authorizePlanUnderMode } from "./plan-auto.js";
72
74
  import { ghDispatchAdapter, mirrorTaskId, syncPass } from "./sync.js";
@@ -135,6 +137,7 @@ import { verifyApproverByPassword } from "./principal.js";
135
137
  import { authorizedProject, canonicalProject, projectName, resolveCeiling } from "./project.js";
136
138
  import { tally, spendLine } from "./summary.js";
137
139
  import { bodyHashOf, describePublicationGrant, observeChecks, publicationBody, publishPass, sweepMerges, } from "./publish.js";
140
+ import { checkPublishing, publishingOf, savePublishing, followPullRequests } from "./pull-request-flow.js";
138
141
  import { acquire, acquireFallback, acquireIfReady, completeFenced, finalizeFailureFenced, finalizeMalformedFenced, finalizeParkFenced, finalizePlanFenced, finalizePlanFailureFenced, finalizeScoutFenced, finalizeScoutFailureFenced, heartbeat, release, reap, currentClaim, DEFAULT_LEASE_MS, SYNC_MAX_AGE_MS, acquireContinuation, } from "./claim.js";
139
142
  import { disposeBuildOutcome, holdStaleApproval, regateTask } from "./dispose.js";
140
143
  import { attendedLivenessState } from "./liveness.js";
@@ -193,10 +196,6 @@ import { PACKAGE_VERSION } from "./version.js";
193
196
  * caller deciding whether to try the next task or wake somebody up.
194
197
  */
195
198
  export const EXIT = { ok: 0, failed: 1, usage: 2, refused: 3 };
196
- /** Storage retention: how long a finished task's clean checkout is kept after it was let go, and a result marked
197
- * complete's or a release candidate's after it was (its checkout holds the build a deploy installs). */
198
- export const CHECKOUT_KEEP_MS = 2 * 24 * 60 * 60_000;
199
- export const RESULT_KEEP_MS = 7 * 24 * 60 * 60_000;
200
199
  const STATES = ["queued", "running", "done", "failed", "cancelled"];
201
200
  export const OPERATE_HELP = `toolroll — operating the queue
202
201
 
@@ -213,11 +212,17 @@ export const OPERATE_HELP = `toolroll — operating the queue
213
212
  --not-requester on|off, --protect none|project, --protect-paths "a/**,b/**"
214
213
  toolroll task evidence <id> the task's evidence pack as JSON (--html for a printable page; --out <file>)
215
214
  toolroll storage where the disk goes: database, build checkouts, releases, evidence
215
+ toolroll storage clean [--yes] preview removing finished tasks' clean checkouts (their branches stay); --yes removes them
216
+ toolroll storage cleanup <when> remove a finished task's clean checkout: finished|2d|7d|never (instance operator)
217
+ toolroll storage discard <path> --yes throw away a checkout kept for its changes; its branch stays
218
+ toolroll integrations [--json] which integrations work: Connected, Not set up or Broken, and what to do (--saved: no new checks)
216
219
  toolroll monitoring where the audit stream and traces go, and how each destination is doing
217
220
  toolroll check-progress <run> current or final approved check progress
218
221
  toolroll spend [--month YYYY-MM] [--csv] what agent work cost, by project, person, teammate and model
219
222
  toolroll export --out <path> [--zip] everything Toolroll knows, in a folder or .zip (no secrets)
220
223
  toolroll budget list|set|remove monthly budgets (--all | --project <p> | --person <name> | --teammate <id>) --usd <n> [--alerts-only]
224
+ toolroll notifications [quiet|all] how chats reach you: only when you're needed (the default), or every step
225
+ toolroll notifications digest <HH:MM>|off one evening message: what finished, what waits, what failed
221
226
  toolroll retention show|preview how long evidence, checkout records, chat and notifications are kept; what the daily sweep would remove
222
227
  toolroll retention set <kind> <period> evidence|checkouts|chat|notifications, 30d|1y|forever (instance operator)
223
228
  toolroll backup now|list back the database up now; list backups and how the last ones went
@@ -264,6 +269,10 @@ External trackers — build what a tracker nominates, under local approvals
264
269
  the dispatch grant: its own explicit
265
270
  yes, never in any default; writes a
266
271
  plane marker label to the repository
272
+ toolroll publish setup [--repo <path>] [--merge-method squash|merge|rebase]
273
+ [--merge-when-green] --yes one-step pull requests: checks gh sign-in
274
+ and push rights, then Complete offers
275
+ "Complete and open a pull request"
267
276
  toolroll publish grant --github <owner/name> --allow-merge
268
277
  --merge-method squash|merge|rebase [--merge-delete-branch] --yes
269
278
  auto-merge this plane's own PRs —
@@ -532,7 +541,7 @@ export const TASK_ACTIONS = [
532
541
  "reopen", "scope", "approve", "hold", "unhold", "require", "requeue", "regate", "plan",
533
542
  "review", "accept", "repair", "route", "stop", "resume", "complete", "revise",
534
543
  ];
535
- export const PUBLISH_ACTIONS = ["grant", "revoke", "status", "unblock", "rearm", "merge", "refire"];
544
+ export const PUBLISH_ACTIONS = ["setup", "grant", "revoke", "status", "unblock", "rearm", "merge", "refire"];
536
545
  export const CONFIG_ACTIONS = ["show", "set", "clear"];
537
546
  export const APPROVER_ACTIONS = ["list", "add"];
538
547
  export const ROUTINE_ACTIONS = ["list", "add", "show", "approve", "refresh", "pause", "resume", "run-now"];
@@ -577,6 +586,8 @@ export const OPERATE_BOOLEAN_FLAGS = new Set([
577
586
  "html", "csv", "alerts-only",
578
587
  // v105: the full export.
579
588
  "zip",
589
+ // Settings → Integrations: the last checks, without checking again.
590
+ "saved",
580
591
  ]);
581
592
  export function parseOperateArgs(argv) {
582
593
  const positional = [];
@@ -720,6 +731,7 @@ export async function runOperate(command, argv, write, options = {}) {
720
731
  ...(options.dispatchAdapter === undefined ? {} : { dispatchAdapter: options.dispatchAdapter }),
721
732
  ...(options.flowTriggerIo === undefined ? {} : { flowTriggerIo: options.flowTriggerIo }),
722
733
  ...(options.flowStepIo === undefined ? {} : { flowStepIo: options.flowStepIo }),
734
+ ...(options.integrationIo === undefined ? {} : { integrationIo: options.integrationIo }),
723
735
  ...(options.shouldStop === undefined ? {} : { shouldStop: options.shouldStop }),
724
736
  ...(options.mateSeams === undefined ? {} : { mateSeams: options.mateSeams }),
725
737
  ...(options.heldCoordinator === undefined ? {} : { heldCoordinator: options.heldCoordinator }),
@@ -817,19 +829,18 @@ async function dispatch(command, positional, flags, context) {
817
829
  return budgetCommand(positional, flags, context);
818
830
  case "retention":
819
831
  return retentionCommand(positional, flags, context);
832
+ case "notifications":
833
+ return notificationsCommand(positional, flags, context);
820
834
  case "backup":
821
835
  return backupCommand(positional, flags, context);
822
836
  case "export":
823
837
  return exportCommand(positional, flags, context);
824
838
  case "policy":
825
839
  return policyCommand(positional, flags, context);
826
- case "storage": {
827
- const report = storageReport(context.store, context.databaseFile, context.clock(), CHECKOUT_KEEP_MS);
828
- return succeed(context.write, context.json, "storage", report, () => [
829
- `${report.folder}: ${bytesWords(report.total)}`,
830
- ...report.lines.map(one => ` ${one.what.padEnd(17)} ${bytesWords(one.bytes).padStart(8)}${one.count === undefined ? "" : ` (${one.count})`}${one.note === undefined ? "" : ` ${one.note}`}`),
831
- ]);
832
- }
840
+ case "storage":
841
+ return storageCommand(positional, flags, context);
842
+ case "integrations":
843
+ return integrationsCommand(positional, flags, context);
833
844
  case "monitoring": {
834
845
  // Where the audit stream and traces go, and how each destination is doing. Settings → Monitoring changes it.
835
846
  const settings = readMonitoring(dirname(context.databaseFile));
@@ -4313,13 +4324,9 @@ async function reconcileCommand(flags, context) {
4313
4324
  reaped: reaped.map(claim => claim.leaseId),
4314
4325
  });
4315
4326
  }
4316
- // Storage retention: finished work's clean checkouts go two days after they were let go (their branches stay); a
4317
- // result marked complete, or a release candidate, a week after (a deploy installs the build in its checkout).
4318
- const pruned = await worktrees.prune(repo, clock(), CHECKOUT_KEEP_MS, () => store.keptBranches(repo, clock(), RESULT_KEEP_MS));
4319
- for (const row of pruned.removed) {
4320
- store.recordAction({ at: clock().toISOString(), actor: "worker", repo, taskId: row.taskRef === null ? null : store.externalIdFor(row.taskRef), runId: null,
4321
- action: "checkout removed", outcome: "removed", source: "work", detail: `${basename(row.path)} (released ${row.releasedAt?.slice(0, 10) ?? "?"})` });
4322
- }
4327
+ // Checkout cleanup (Settings → Storage): finished work's clean checkouts go as the setting says (by default when
4328
+ // the task is complete or cancelled); their branches stay. Each removal is in the ledger.
4329
+ const pruned = await cleanCheckouts(store, worktrees, clock, { manual: false, actor: "worker", repo });
4323
4330
  // Retention (Settings → Retention): once a day, what's older than its setting goes; one ledger entry says what.
4324
4331
  let retention = null;
4325
4332
  try {
@@ -4341,7 +4348,7 @@ async function reconcileCommand(flags, context) {
4341
4348
  reaped: reaped.map(claim => claim.leaseId),
4342
4349
  adopted: adoption.adopted,
4343
4350
  forgotten: adoption.forgotten,
4344
- checkoutsRemoved: pruned.removed.map(row => row.path),
4351
+ checkoutsRemoved: pruned.removed.map(one => one.path),
4345
4352
  checkoutsKept: pruned.kept,
4346
4353
  liveViewsSwept: liveSwept.removed.length,
4347
4354
  retention: retention === null ? null : { counts: retention.counts, freedBytes: retention.freed },
@@ -4352,7 +4359,7 @@ async function reconcileCommand(flags, context) {
4352
4359
  ...(reaped.length === 0 ? [] : [`Reaped ${reaped.length} expired lease(s).`]),
4353
4360
  ...adoption.adopted.map(path => `Adopted ${path} — released, unverified, somebody should look.`),
4354
4361
  ...adoption.forgotten.map(path => `Forgot ${path} — its directory is gone.`),
4355
- ...(pruned.removed.length === 0 ? [] : [`Removed ${pruned.removed.length} checkout(s) nobody has used for two days; their branches stay.`]),
4362
+ ...(pruned.removed.length === 0 ? [] : [`Removed ${pruned.removed.length} finished task checkout(s), about ${bytesWords(pruned.freed)}; their branches stay.`]),
4356
4363
  ...(liveSwept.removed.length === 0 ? [] : [`Cleared ${liveSwept.removed.length} finished live view(s).`]),
4357
4364
  ...(retention === null || retained.length === 0 ? [] : [`Retention removed ${sweepWords(retention.counts, retention.freed)}.`]),
4358
4365
  ]);
@@ -7239,6 +7246,14 @@ async function runWatchLoop(args) {
7239
7246
  });
7240
7247
  if (checks.failing > 0)
7241
7248
  progress(`watch: CI is red on ${checks.failing} published PR(s) — the outbox has it`);
7249
+ const followed = await followPullRequests(store, checks.seen, {
7250
+ evidenceRoot: context.evidenceRoot,
7251
+ ...(context.publishExec === undefined ? {} : { exec: context.publishExec }),
7252
+ });
7253
+ if (followed.revisions > 0)
7254
+ progress(`watch: filed ${followed.revisions} CI revision(s)`);
7255
+ if (followed.merged > 0)
7256
+ progress(`watch: merged ${followed.merged} pull request(s) whose checks passed`);
7242
7257
  }
7243
7258
  catch (error) {
7244
7259
  progress(`watch: CI observation failed — ${describe(error)}; retrying on its next interval`);
@@ -7719,7 +7734,7 @@ async function onboardCommand(flags, context) {
7719
7734
  const gitRun = context.gitRunner ?? ((file, args, opts) => run(file, [...args], { ...(opts?.cwd === undefined ? {} : { cwd: opts.cwd }), timeoutMs: 10_000 }));
7720
7735
  const registryPath = registryPathOf(context);
7721
7736
  const checker = createConnectionChecker({ home, env });
7722
- return runOnboard({
7737
+ const code = await runOnboard({
7723
7738
  write,
7724
7739
  json,
7725
7740
  yes: flag(flags, "yes"),
@@ -7759,7 +7774,23 @@ async function onboardCommand(flags, context) {
7759
7774
  return { ok: true, added: "error" in before || !before.repos.includes(repo) };
7760
7775
  },
7761
7776
  checkConnection: seams.checkConnection ?? (agent => checker(agent)),
7777
+ pullRequests: seams.pullRequests ?? (async (repo) => {
7778
+ const publishing = publishingOf(store, repo);
7779
+ if (publishing.on)
7780
+ return `on for ${publishing.githubRepo} into ${publishing.base}`;
7781
+ const checked = await checkPublishing(repo, context.publishExec === undefined ? {} : { exec: context.publishExec });
7782
+ if (!checked.ok)
7783
+ return `off: ${checked.message}`;
7784
+ return `off: turn on with toolroll publish setup --repo ${repo} --yes (asks your password), or in Settings → Projects`;
7785
+ }),
7762
7786
  });
7787
+ // One line when an integration is Broken, from the last checks only (never a new check here).
7788
+ if (!json) {
7789
+ const line = integrationsBrokenLine(await savedIntegrations(context).catch(() => []));
7790
+ if (line !== null)
7791
+ write(line);
7792
+ }
7793
+ return code;
7763
7794
  }
7764
7795
  async function upCommand(flags, context) {
7765
7796
  const { store, write, json, clock } = context;
@@ -8548,6 +8579,42 @@ async function publishCommand(positional, flags, context) {
8548
8579
  if (action !== undefined && !PUBLISH_ACTIONS.includes(action)) {
8549
8580
  return fail(write, json, "publish", "usage", `unknown \`publish ${action}\` — try ${PUBLISH_ACTIONS.join(", ")}, or bare \`publish\` for the publication pass`, EXIT.usage);
8550
8581
  }
8582
+ if (action === "setup") {
8583
+ const mergeMethod = text(flags, "merge-method") ?? "squash";
8584
+ if (!["squash", "merge", "rebase"].includes(mergeMethod)) {
8585
+ return fail(write, json, "publish setup", "usage", "--merge-method is squash, merge or rebase", EXIT.usage);
8586
+ }
8587
+ const checked = await checkPublishing(repo, context.publishExec === undefined ? {} : { exec: context.publishExec });
8588
+ if (!checked.ok)
8589
+ return fail(write, json, "publish setup", checked.reason, checked.message, EXIT.refused);
8590
+ const settings = { mergeMethod: mergeMethod, mergeWhenGreen: flags.has("merge-when-green") };
8591
+ const plan = checked.plan;
8592
+ const terms = [
8593
+ `Pull requests for ${plan.githubRepo} into ${plan.base}${plan.account === null ? "" : `, as ${plan.account}`}.`,
8594
+ "Complete will offer \"Complete and open a pull request\"; the pull request opens from the exact completed commit.",
8595
+ `Merges ${settings.mergeMethod} and delete the branch${settings.mergeWhenGreen ? ", by themselves once checks pass" : " when a person selects Merge"}.`,
8596
+ ];
8597
+ if (!flags.has("yes")) {
8598
+ if (json) {
8599
+ write(envelopeJson({ ok: false, command: "publish setup", reason: "unconfirmed", proposed: { ...plan, ...settings } }));
8600
+ return EXIT.refused;
8601
+ }
8602
+ for (const line of terms)
8603
+ write(line);
8604
+ write("Nothing is set up yet. Repeat with --yes --as <you> --token <password> to turn it on.");
8605
+ return EXIT.refused;
8606
+ }
8607
+ const acting = await askCredentials(flags, context);
8608
+ if (acting === null) {
8609
+ return fail(write, json, "publish setup", "usage", "turning on pull requests takes --as <you> --token <your password>", EXIT.usage);
8610
+ }
8611
+ const authenticated = authenticateApprover(store, acting.name, acting.token, repo);
8612
+ if (!authenticated.ok) {
8613
+ return fail(write, json, "publish setup", authenticated.reason, describeApproveFailure(authenticated.reason, acting.name), EXIT.refused);
8614
+ }
8615
+ savePublishing(store, plan, acting.name, settings, clock());
8616
+ return succeed(write, json, "publish setup", { publishing: publishingOf(store, repo) }, () => ["Pull requests are on.", ...terms]);
8617
+ }
8551
8618
  if (action === "grant") {
8552
8619
  const github = text(flags, "github");
8553
8620
  if (github === undefined || !/^[\w.-]+\/[\w.-]+$/.test(github)) {
@@ -9110,7 +9177,49 @@ async function statusCommand(positional, flags, context) {
9110
9177
  notifySecurityRelease(context.store, release, current, method.updateCommand, context.clock());
9111
9178
  const line = updateLine(release, current, method.updateCommand);
9112
9179
  const update = release === null ? {} : { update: { current, latest: release.version, security: release.security, updateCommand: method.updateCommand, url: release.url } };
9113
- return succeed(context.write, context.json, command, { ...status, ...update }, () => [...renderInstallationStatus(status), ...(line === null ? [] : [line])]);
9180
+ // One line when an integration is Broken, from the last checks only: status never waits on a check.
9181
+ const brokenIntegrations = await savedIntegrations(context).then(list => list.filter(one => one.state === "broken"), () => []);
9182
+ const brokenLine = integrationsBrokenLine(brokenIntegrations);
9183
+ const integrations = brokenIntegrations.length === 0 ? {} : { integrations: { broken: brokenIntegrations.map(one => ({ key: one.key, name: one.name, fix: one.action.kind === "fix" ? one.action.words : null })) } };
9184
+ return succeed(context.write, context.json, command, { ...status, ...update, ...integrations }, () => [...renderInstallationStatus(status), ...(line === null ? [] : [line]), ...(brokenLine === null ? [] : [brokenLine])]);
9185
+ }
9186
+ /** Where `integrations` looks: the installation's files beside the database, and every project it knows. */
9187
+ async function integrationIoFor(context) {
9188
+ const registered = await loadProjectRegistry(registryPathOf(context)).catch(() => ({ error: "unreadable" }));
9189
+ const repos = [...new Set([...("error" in registered ? [] : registered.repos), ...context.store.listProjects().map(one => one.path)])];
9190
+ const seams = context.integrationIo ?? {};
9191
+ return {
9192
+ store: context.store,
9193
+ dir: dirname(context.databaseFile),
9194
+ telegramTokenFile: context.telegramTokenFile,
9195
+ env: process.env,
9196
+ repos,
9197
+ gh: (file, args, options) => run(file, [...args], options),
9198
+ checkConnection: createConnectionChecker(),
9199
+ clock: context.clock,
9200
+ ...seams,
9201
+ };
9202
+ }
9203
+ /** The integrations from files and the last checks: no network, for `status` and `onboard`. */
9204
+ async function savedIntegrations(context) {
9205
+ return integrationsNow(await integrationIoFor(context));
9206
+ }
9207
+ /** `toolroll integrations [--json]`: check every integration now and say which work. `--saved` skips the checks. */
9208
+ async function integrationsCommand(positional, flags, context) {
9209
+ const command = "integrations";
9210
+ const allowed = new Set(["db", "json", "saved"]);
9211
+ for (const name of flags.keys()) {
9212
+ if (!allowed.has(name))
9213
+ return fail(context.write, context.json, command, "usage", `--${name} is not an integrations option.`, EXIT.usage);
9214
+ }
9215
+ if (positional.length > 0)
9216
+ return fail(context.write, context.json, command, "usage", "Use `toolroll integrations [--saved] [--json]`.", EXIT.usage);
9217
+ const io = await integrationIoFor(context);
9218
+ const list = flag(flags, "saved") ? integrationsNow(io) : await checkIntegrations(io);
9219
+ const counts = { connected: 0, "not-set-up": 0, broken: 0 };
9220
+ for (const one of list)
9221
+ counts[one.state] += 1;
9222
+ return succeed(context.write, context.json, command, { integrations: list, counts }, () => renderIntegrations(list));
9114
9223
  }
9115
9224
  async function waitTask(positional, flags, context) {
9116
9225
  const command = "task wait";
@@ -11279,6 +11388,35 @@ async function budgetCommand(positional, flags, context) {
11279
11388
  const saved = store.setBudget({ scope: target.scope, key: target.key, limitMicrousd: Math.round(dollars) * 1_000_000, hardStop: !flags.has("alerts-only") }, acting.name, context.clock());
11280
11389
  return succeed(context.write, context.json, command, { budget: saved }, () => [`${budgetLabel(saved).replace(/'s$/, "")}: ${spendUsd(saved.limitMicrousd)} a month${saved.hardStop ? ", API work stops at 100%" : ", alerts only"}.`]);
11281
11390
  }
11391
+ /** `notifications [quiet | all | digest <HH:MM> | digest off]`: how chats reach this person — only when they are
11392
+ * needed (the default), every step, and an optional evening digest. Each person sets their own. */
11393
+ async function notificationsCommand(positional, flags, context) {
11394
+ const [action, value, ...extra] = positional;
11395
+ const command = `notifications${action === undefined ? "" : ` ${action}`}`;
11396
+ const store = context.store;
11397
+ for (const name of flags.keys())
11398
+ if (!["as", "token", "token-file", "token-env", "db", "json"].includes(name))
11399
+ return fail(context.write, context.json, command, "usage", `--${name} is not a notifications option.`, EXIT.usage);
11400
+ const usage = "Use notifications quiet, notifications all, or notifications digest <HH:MM>|off.";
11401
+ if (extra.length > 0 || (action !== undefined && !["quiet", "all", "digest"].includes(action)) || (action === "digest") !== (value !== undefined)) {
11402
+ return fail(context.write, context.json, command, "usage", usage, EXIT.usage);
11403
+ }
11404
+ if (action === "digest" && value !== "off" && !isDigestTime(value))
11405
+ return fail(context.write, context.json, command, "usage", "The digest time is HH:MM on a 24-hour clock, like 18:30, or off.", EXIT.usage);
11406
+ const acting = await askCredentials(flags, context);
11407
+ const verified = acting === null ? null : authenticateApprover(store, acting.name, acting.token);
11408
+ if (acting === null || verified === null || !verified.ok) {
11409
+ return fail(context.write, context.json, command, "refused", "Notifications are set per person: pass --as and --token (or use the remembered login).", EXIT.refused);
11410
+ }
11411
+ const now = context.clock();
11412
+ const preference = action === "quiet" || action === "all" ? store.setNotificationPreference(acting.name, { mode: action }, acting.name, now)
11413
+ : action === "digest" ? store.setNotificationPreference(acting.name, { digestAt: value === "off" ? null : value }, acting.name, now)
11414
+ : store.notificationPreference(acting.name);
11415
+ return succeed(context.write, context.json, command, { mode: preference.mode, digestAt: preference.digestAt }, () => [
11416
+ preference.mode === "quiet" ? "Only when you're needed: one message per task, updated as it moves, and a new one when something needs you." : "Every step: a message for each update.",
11417
+ preference.digestAt === null ? "No evening digest." : `Evening digest at ${preference.digestAt}: what finished, what waits and what failed.`,
11418
+ ]);
11419
+ }
11282
11420
  /** `retention show | preview | set <kind> <period>` (v105): how long each kind of data is kept, and what the daily
11283
11421
  * sweep would remove now. Setting takes an instance operator. */
11284
11422
  async function retentionCommand(positional, flags, context) {
@@ -11323,6 +11461,97 @@ async function retentionCommand(positional, flags, context) {
11323
11461
  const periods = store.setRetentionPeriod(kind, days, acting.name, context.clock());
11324
11462
  return succeed(context.write, context.json, command, { periods }, () => [`${label(kind)}: kept ${days === null ? "forever" : `for ${periodWords(days)}`}.`]);
11325
11463
  }
11464
+ /** `storage`, `storage cleanup [<when>]`, `storage clean [--yes]`, `storage discard <path> --yes`: where the disk goes,
11465
+ * and task checkouts: when a finished task's clean checkout is removed, cleaning up now, and throwing away one kept for
11466
+ * its changes. Changing anything takes an instance operator. */
11467
+ async function storageCommand(positional, flags, context) {
11468
+ const [action, ...rest] = positional;
11469
+ const command = `storage ${action ?? ""}`.trim();
11470
+ const store = context.store;
11471
+ const changes = (action === "cleanup" && rest.length > 0) || (action === "clean" && flags.has("yes")) || action === "discard";
11472
+ const allowed = new Set([...(changes ? ["as", "token", "token-file", "token-env"] : []), ...(action === "clean" || action === "discard" ? ["yes"] : []), "db", "json"]);
11473
+ for (const name of flags.keys())
11474
+ if (!allowed.has(name))
11475
+ return fail(context.write, context.json, command, "usage", `--${name} is not a ${command} option.`, EXIT.usage);
11476
+ const pool = new WorktreePool(store, { root: join(dirname(context.databaseFile), "worktrees"), ...(context.gitRunner === undefined ? {} : { runner: context.gitRunner }) });
11477
+ const operator = async () => {
11478
+ const acting = await askCredentials(flags, context);
11479
+ const verified = acting === null ? null : authenticateApprover(store, acting.name, acting.token);
11480
+ return acting !== null && verified !== null && verified.ok && store.isInstanceOperator(acting.name) ? acting.name : null;
11481
+ };
11482
+ const refused = (what) => fail(context.write, context.json, command, "refused", `An instance operator ${what}: pass --as and --token (or use the remembered login).`, EXIT.refused);
11483
+ const summary = (plan) => [
11484
+ `Checkouts: ${plan.count}, ${bytesWords(plan.totalBytes)}. ${plan.waitingReview} waiting for review, ${plan.withChanges} kept because they have changes.`,
11485
+ plan.go.length === 0 ? "A clean up now would free nothing." : `A clean up now would free about ${bytesWords(plan.freeBytes)} (${plan.go.length} checkout${plan.go.length === 1 ? "" : "s"}): toolroll storage clean`,
11486
+ `A finished task's clean checkout is removed ${cleanupWords(plan.cleanup)}; its branch stays.`,
11487
+ ];
11488
+ const itemLine = (one) => ` ${bytesWords(one.bytes).padStart(8)} ${one.path}${one.why === null ? "" : ` (${whyWords(one)})`}`;
11489
+ if (action === undefined) {
11490
+ const plan = await checkoutPlan(store, pool, context.clock(), { manual: true });
11491
+ const report = storageReport(store, context.databaseFile, plan.go.length === 0 ? "nothing to clean up" : `about ${bytesWords(plan.freeBytes)} to clean up`);
11492
+ return succeed(context.write, context.json, "storage", { ...report, checkouts: checkoutSummary(plan) }, () => [
11493
+ `${report.folder}: ${bytesWords(report.total)}`,
11494
+ ...report.lines.map(one => ` ${one.what.padEnd(17)} ${bytesWords(one.bytes).padStart(8)}${one.count === undefined ? "" : ` (${one.count})`}${one.note === undefined ? "" : ` ${one.note}`}`),
11495
+ ...summary(plan),
11496
+ ]);
11497
+ }
11498
+ if (action === "cleanup") {
11499
+ if (rest.length === 0) {
11500
+ const cleanup = store.checkoutCleanup();
11501
+ return succeed(context.write, context.json, command, { cleanup }, () => [`A finished task's clean checkout is removed ${cleanupWords(cleanup)}; its branch stays.`]);
11502
+ }
11503
+ const cleanup = rest.length === 1 ? parseCleanup(rest[0]) : undefined;
11504
+ if (cleanup === undefined)
11505
+ return fail(context.write, context.json, command, "usage", `Use storage cleanup <${CLEANUP_CHOICES.map(one => one.value).join("|")}>.`, EXIT.usage);
11506
+ const actor = await operator();
11507
+ if (actor === null)
11508
+ return refused("sets checkout cleanup");
11509
+ const saved = store.setCheckoutCleanup(cleanup, actor, context.clock());
11510
+ return succeed(context.write, context.json, command, { cleanup: saved }, () => [`Saved. A finished task's clean checkout is removed ${cleanupWords(saved)}; its branch stays.`]);
11511
+ }
11512
+ if (action === "clean") {
11513
+ if (rest.length > 0)
11514
+ return fail(context.write, context.json, command, "usage", "Use storage clean to preview, then storage clean --yes.", EXIT.usage);
11515
+ if (!flags.has("yes")) {
11516
+ const plan = await checkoutPlan(store, pool, context.clock(), { manual: true });
11517
+ return succeed(context.write, context.json, command, { preview: true, go: plan.go, stay: plan.stay, freeBytes: plan.freeBytes }, () => [
11518
+ ...(plan.go.length === 0 ? ["Nothing to clean up."] : [`Would remove ${plan.go.length} checkout${plan.go.length === 1 ? "" : "s"}, freeing about ${bytesWords(plan.freeBytes)} (their branches stay):`, ...plan.go.map(itemLine)]),
11519
+ ...(plan.stay.length === 0 ? [] : [`Stays (${plan.stay.length}):`, ...plan.stay.map(itemLine)]),
11520
+ plan.go.length === 0 ? "Nothing was removed." : "Nothing was removed. Run toolroll storage clean --yes to remove them.",
11521
+ ]);
11522
+ }
11523
+ const actor = await operator();
11524
+ if (actor === null)
11525
+ return refused("cleans up checkouts");
11526
+ const done = await cleanCheckouts(store, pool, context.clock, { manual: true, actor });
11527
+ return succeed(context.write, context.json, command, { removed: done.removed, kept: done.kept, freedBytes: done.freed }, () => [
11528
+ done.removed.length === 0 ? "Nothing to clean up." : `Removed ${done.removed.length} checkout${done.removed.length === 1 ? "" : "s"}, about ${bytesWords(done.freed)}; their branches stay.`,
11529
+ ...done.removed.map(itemLine),
11530
+ ...done.kept.map(one => ` kept ${one.path} (${whyWords({ why: one.why })})`),
11531
+ ]);
11532
+ }
11533
+ if (action === "discard") {
11534
+ const [path, ...extra] = rest;
11535
+ if (path === undefined || extra.length > 0)
11536
+ return fail(context.write, context.json, command, "usage", "Use storage discard <path> --yes.", EXIT.usage);
11537
+ const target = resolve(path);
11538
+ if (store.getWorktree(target) === null)
11539
+ return fail(context.write, context.json, command, "not-found", `${target} is not a task checkout. toolroll storage clean lists them.`, EXIT.refused);
11540
+ if (!flags.has("yes"))
11541
+ return fail(context.write, context.json, command, "usage", `This throws away ${target} and its uncommitted changes (its branch stays). Add --yes to discard it.`, EXIT.usage);
11542
+ const actor = await operator();
11543
+ if (actor === null)
11544
+ return refused("discards a checkout");
11545
+ const done = await discardCheckout(store, pool, target, context.clock(), actor);
11546
+ if (!done.ok)
11547
+ return fail(context.write, context.json, command, "refused", done.message, EXIT.refused);
11548
+ return succeed(context.write, context.json, command, { discarded: target, freedBytes: done.bytes }, () => [`Discarded ${target}, about ${bytesWords(done.bytes)}; its branch stays.`]);
11549
+ }
11550
+ return fail(context.write, context.json, command, "usage", "Use storage, storage cleanup <when>, storage clean [--yes] or storage discard <path> --yes.", EXIT.usage);
11551
+ }
11552
+ function checkoutSummary(plan) {
11553
+ return { count: plan.count, totalBytes: plan.totalBytes, waitingReview: plan.waitingReview, withChanges: plan.withChanges, cleanBytes: plan.freeBytes, cleanCount: plan.go.length, cleanup: plan.cleanup };
11554
+ }
11326
11555
  /** `policy show | set` (sprint 8): the organisation policy. Setting it takes an instance operator's credentials; each rule
11327
11556
  * that changed is in the ledger, before → after. A flag left out keeps its rule; "any" lifts it. */
11328
11557
  async function policyCommand(positional, flags, context) {
@@ -231,6 +231,7 @@ function deleteRows(store, repo, d, now) {
231
231
  del("telegram_conversation_part", `${IN("conversation")} OR ${IN("source_run")} OR ${IN("task_id")}`, conversations, R, K);
232
232
  del("telegram_conversation", IN("id"), conversations);
233
233
  del("telegram_outbound_message", `project = ? OR ${IN("notification")} OR ${IN("task_ref")} OR ${IN("source_run")}`, repo, notifications, T, R);
234
+ del("chat_card_task", IN("task_ref"), T);
234
235
  del("push_delivery", IN("notification"), notifications);
235
236
  del("notification_delivery", IN("notification"), notifications);
236
237
  del("notification", IN("id"), notifications);
package/dist/prompt.d.ts CHANGED
@@ -7,12 +7,17 @@
7
7
  */
8
8
  /** Whether asking is even possible: a human on both ends of the pipe. */
9
9
  export declare function interactive(): boolean;
10
+ /** The program names of this process's ancestors, nearest first: each one's command and, for a script run by node
11
+ * or python, the script's name. Empty where `ps` cannot say. */
12
+ export declare function ancestorPrograms(): string[];
10
13
  /**
11
14
  * Whether a coding agent is running this command. An agent's shell can be a
12
15
  * real terminal, yet whatever is printed lands in its transcript and nobody
13
- * types at the prompt, so it is treated like no terminal at all.
16
+ * types at the prompt, so it is treated like no terminal at all. The agent is
17
+ * known by a variable it sets or, for one that sets none, by the program above
18
+ * this one. `programs` is read only for this process's own environment.
14
19
  */
15
- export declare function underAgent(env?: Record<string, string | undefined>): boolean;
20
+ export declare function underAgent(env?: Record<string, string | undefined>, programs?: () => readonly string[]): boolean;
16
21
  export declare function ask(question: string): Promise<string>;
17
22
  /** Like ask, but what is typed never echoes — passwords live here. */
18
23
  /**
package/dist/prompt.js CHANGED
@@ -5,22 +5,51 @@
5
5
  * gets the same immediate usage refusal as always, because a scheduler
6
6
  * hanging on "password:" at 3am is the one bug this module must not have.
7
7
  */
8
+ import { spawnSync } from "node:child_process";
9
+ import { basename } from "node:path";
8
10
  import { createInterface } from "node:readline";
9
11
  /** Whether asking is even possible: a human on both ends of the pipe. */
10
12
  export function interactive() {
11
13
  return process.stdin.isTTY === true && process.stdout.isTTY === true;
12
14
  }
13
- /** Variables a coding agent sets for the commands it runs. CODEX_HOME and CODEX_API_KEY are a person's own settings. */
14
- const AGENT_VARIABLES = ["CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "GEMINI_CLI", "CURSOR_AGENT", "OPENCODE"];
15
- const PERSON_CODEX_VARIABLES = new Set(["CODEX_HOME", "CODEX_API_KEY"]);
15
+ /** Variables a coding agent sets for the commands it runs. Only Codex's own CODEX_ names: a person exports others
16
+ * (CODEX_HOME, CODEX_API_KEY, a profile) for themselves. */
17
+ const AGENT_VARIABLES = ["CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "GEMINI_CLI", "CURSOR_AGENT", "OPENCODE", "GOOSE_TERMINAL", "AIDER_CHAT",
18
+ "CODEX_SANDBOX", "CODEX_SANDBOX_NETWORK_DISABLED", "CODEX_THREAD_ID", "CODEX_CI", "CODEX_MANAGED_BY_NPM", "CODEX_MANAGED_BY_BUN"];
19
+ /** Agents known by the program running this command, for those that set no variable of their own. */
20
+ const AGENT_PROGRAMS = new Set(["claude", "codex", "gemini", "cursor-agent", "opencode", "aider", "copilot", "amp", "goose", "crush", "qwen"]);
21
+ /** The program names of this process's ancestors, nearest first: each one's command and, for a script run by node
22
+ * or python, the script's name. Empty where `ps` cannot say. */
23
+ export function ancestorPrograms() {
24
+ if (process.platform === "win32")
25
+ return [];
26
+ const names = [];
27
+ let pid = process.ppid;
28
+ for (let depth = 0; depth < 12 && pid > 1; depth++) {
29
+ const answered = spawnSync("/bin/ps", ["-o", "ppid=,args=", "-p", String(pid)], { encoding: "utf8", timeout: 2000 });
30
+ const found = answered.status === 0 ? /^\s*(\d+)\s+(.*)$/.exec(answered.stdout.trim()) : null;
31
+ if (!found)
32
+ break;
33
+ const words = found[2].split(/\s+/);
34
+ const program = basename(words[0] ?? "");
35
+ names.push(program);
36
+ if (/^(node|bun|deno|python[\d.]*)$/.test(program) && words[1] !== undefined && !words[1].startsWith("-"))
37
+ names.push(basename(words[1]));
38
+ pid = Number(found[1]);
39
+ }
40
+ return names;
41
+ }
16
42
  /**
17
43
  * Whether a coding agent is running this command. An agent's shell can be a
18
44
  * real terminal, yet whatever is printed lands in its transcript and nobody
19
- * types at the prompt, so it is treated like no terminal at all.
45
+ * types at the prompt, so it is treated like no terminal at all. The agent is
46
+ * known by a variable it sets or, for one that sets none, by the program above
47
+ * this one. `programs` is read only for this process's own environment.
20
48
  */
21
- export function underAgent(env = process.env) {
22
- return Object.entries(env).some(([name, value]) => value !== undefined && value !== ""
23
- && (AGENT_VARIABLES.includes(name) || (name.startsWith("CODEX_") && !PERSON_CODEX_VARIABLES.has(name))));
49
+ export function underAgent(env = process.env, programs = env === process.env ? ancestorPrograms : () => []) {
50
+ if (AGENT_VARIABLES.some(name => env[name] !== undefined && env[name] !== ""))
51
+ return true;
52
+ return programs().some(name => AGENT_PROGRAMS.has(name.replace(/\.(?:js|mjs|cjs|py|exe)$/, "")));
24
53
  }
25
54
  export function ask(question) {
26
55
  const rl = createInterface({ input: process.stdin, output: process.stdout });
package/dist/publish.d.ts CHANGED
@@ -20,6 +20,7 @@
20
20
  * @-mention in its conclusion cannot acquire GitHub semantics.
21
21
  */
22
22
  import { type ExecResult, type RunOptions } from "./exec.js";
23
+ import { summarizeChecks } from "./pulls.js";
23
24
  import type { Publication, PublicationGrant, Store } from "./store.js";
24
25
  export declare const BODY_TEMPLATE_VERSION = 2;
25
26
  /** After this many failed attempts a publication stops retrying and pages instead. */
@@ -69,6 +70,16 @@ export type CheckReport = {
69
70
  failing: number;
70
71
  resolved: number;
71
72
  problems: string[];
73
+ /** Every observation that won its settle, for the pull-request follower (CI revisions, Ready to merge). */
74
+ seen: ObservedPullRequest[];
75
+ };
76
+ export type ObservedPullRequest = {
77
+ publication: Publication;
78
+ headOid: string;
79
+ state: ReturnType<typeof summarizeChecks>;
80
+ remoteState: "MERGED" | "CLOSED" | null;
81
+ rollup: unknown;
82
+ mergeCommit: string | null;
72
83
  };
73
84
  /**
74
85
  * Read CI for every PR this control plane opened — structured rollup and the
package/dist/publish.js CHANGED
@@ -254,7 +254,7 @@ async function openOrAdopt(store, publication, grant, exec, clock) {
254
254
  export async function observeChecks(store, options) {
255
255
  const clock = options.clock ?? (() => new Date());
256
256
  const exec = options.exec ?? execRun;
257
- const report = { observed: 0, failing: 0, resolved: 0, problems: [] };
257
+ const report = { observed: 0, failing: 0, resolved: 0, problems: [], seen: [] };
258
258
  for (const publication of store.openedPublications()) {
259
259
  if (publication.prNumber === null)
260
260
  continue;
@@ -266,7 +266,7 @@ export async function observeChecks(store, options) {
266
266
  const viewed = await exec("gh", [
267
267
  "pr", "view", String(publication.prNumber),
268
268
  "--repo", publication.githubRepo,
269
- "--json", "statusCheckRollup,headRefOid,state",
269
+ "--json", "statusCheckRollup,headRefOid,state,mergeCommit",
270
270
  ], { timeoutMs: EXEC_TIMEOUT_MS });
271
271
  if (viewed.code !== 0) {
272
272
  report.problems.push(`PR #${publication.prNumber}: not read — ${viewed.notFound ? "gh is not on PATH" : firstLine(viewed.stderr) || `exit ${viewed.code}`}`);
@@ -290,11 +290,15 @@ export async function observeChecks(store, options) {
290
290
  // remote state is recorded, its episodes resolve, and neither the
291
291
  // review queue nor the next observation pass carries it forever.
292
292
  const remoteState = typeof payload.state === "string" ? payload.state.toUpperCase() : null;
293
+ const mergeCommit = typeof payload.mergeCommit === "object" && payload.mergeCommit !== null && typeof payload.mergeCommit.oid === "string"
294
+ ? payload.mergeCommit.oid : null;
293
295
  if (remoteState === "MERGED" || remoteState === "CLOSED") {
296
+ let won = false;
294
297
  store.transact(() => {
295
298
  const settled = store.settleObservation({ githubRepo: publication.githubRepo, prNumber: publication.prNumber, headSha: headOid, state: "none", generation }, clock());
296
299
  if (!settled.won)
297
300
  return;
301
+ won = true;
298
302
  store.recordPublicationRemoteState(publication.id, remoteState, clock());
299
303
  report.resolved += store.resolveCiEpisodes(publication.githubRepo, publication.prNumber, null, clock());
300
304
  // A closed PR moots its merge blocker and supersedes its intent —
@@ -311,6 +315,8 @@ export async function observeChecks(store, options) {
311
315
  });
312
316
  }
313
317
  });
318
+ if (won)
319
+ report.seen.push({ publication, headOid, state: "none", remoteState, rollup: payload.statusCheckRollup, mergeCommit });
314
320
  continue;
315
321
  }
316
322
  const state = summarizeChecks(payload.statusCheckRollup);
@@ -322,6 +328,7 @@ export async function observeChecks(store, options) {
322
328
  continue;
323
329
  }
324
330
  store.recordPublicationCheckState(publication.id, state, clock());
331
+ report.seen.push({ publication, headOid, state, remoteState: null, rollup: payload.statusCheckRollup, mergeCommit });
325
332
  // The episode identity carries the REPOSITORY (audit C-2): PR #55 in
326
333
  // repo A and PR #55 in repo B are different worlds, and one's failure
327
334
  // must never light the other's repair button.