toolroll 0.9.6 → 0.9.8

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 (102) hide show
  1. package/CHANGELOG.md +64 -1
  2. package/dist/actor.d.ts +21 -0
  3. package/dist/actor.js +48 -0
  4. package/dist/assignment-adapters.d.ts +2 -0
  5. package/dist/assignment-adapters.js +2 -0
  6. package/dist/assignment-ui.js +3 -1
  7. package/dist/assignment.d.ts +15 -0
  8. package/dist/assignment.js +80 -16
  9. package/dist/browser/workspace.css +2 -2
  10. package/dist/browser/workspace.js +28 -24
  11. package/dist/browser-workspace.d.ts +117 -2
  12. package/dist/build-review.d.ts +38 -0
  13. package/dist/build-review.js +341 -0
  14. package/dist/builder.d.ts +1 -0
  15. package/dist/builder.js +33 -13
  16. package/dist/chat-actions.js +2 -1
  17. package/dist/chat-delivery.js +2 -0
  18. package/dist/chat-quiet.js +11 -1
  19. package/dist/chat-task-actions.d.ts +13 -0
  20. package/dist/chat-task-actions.js +19 -1
  21. package/dist/check-levels-ui.d.ts +26 -0
  22. package/dist/check-levels-ui.js +84 -0
  23. package/dist/check-levels.d.ts +67 -0
  24. package/dist/check-levels.js +171 -0
  25. package/dist/claim.d.ts +22 -0
  26. package/dist/claim.js +43 -0
  27. package/dist/cli.d.ts +1 -1
  28. package/dist/cli.js +6 -0
  29. package/dist/demo-chat.js +4 -1
  30. package/dist/dispatch.js +9 -3
  31. package/dist/dispose.d.ts +12 -3
  32. package/dist/dispose.js +19 -5
  33. package/dist/exec.d.ts +5 -0
  34. package/dist/exec.js +30 -4
  35. package/dist/export.js +3 -2
  36. package/dist/first-run.d.ts +34 -0
  37. package/dist/first-run.js +37 -1
  38. package/dist/flow-gallery-ui.d.ts +30 -0
  39. package/dist/flow-gallery-ui.js +85 -0
  40. package/dist/flow-gallery.d.ts +111 -0
  41. package/dist/flow-gallery.js +445 -0
  42. package/dist/flow-share.d.ts +102 -0
  43. package/dist/flow-share.js +614 -0
  44. package/dist/flow-starters.d.ts +2 -0
  45. package/dist/flow-starters.js +1 -1
  46. package/dist/flow-steps.js +7 -0
  47. package/dist/flow-triggers.js +2 -0
  48. package/dist/flows-cli.d.ts +56 -0
  49. package/dist/flows-cli.js +109 -3
  50. package/dist/flows-ui.d.ts +15 -1
  51. package/dist/flows-ui.js +31 -6
  52. package/dist/guides.js +27 -2
  53. package/dist/invoke.js +5 -0
  54. package/dist/lead-context.js +6 -2
  55. package/dist/mate-doors.d.ts +0 -15
  56. package/dist/mate-doors.js +5 -1
  57. package/dist/mate-tools.js +9 -4
  58. package/dist/models-ui.js +1 -1
  59. package/dist/modes.d.ts +3 -1
  60. package/dist/modes.js +3 -1
  61. package/dist/operate.d.ts +4 -1
  62. package/dist/operate.js +711 -98
  63. package/dist/process-custody.js +8 -0
  64. package/dist/project-concurrency.d.ts +62 -0
  65. package/dist/project-concurrency.js +116 -0
  66. package/dist/project-delete-ui.d.ts +7 -0
  67. package/dist/project-delete-ui.js +19 -2
  68. package/dist/project-delete.js +4 -0
  69. package/dist/provider.js +16 -0
  70. package/dist/pull-request-flow.d.ts +9 -0
  71. package/dist/pull-request-flow.js +21 -3
  72. package/dist/result-follow-ups.d.ts +101 -0
  73. package/dist/result-follow-ups.js +333 -0
  74. package/dist/retention-ui.d.ts +3 -1
  75. package/dist/retention-ui.js +4 -4
  76. package/dist/retention.d.ts +13 -6
  77. package/dist/retention.js +50 -12
  78. package/dist/review-switch.d.ts +41 -0
  79. package/dist/review-switch.js +88 -0
  80. package/dist/reviewer.d.ts +24 -2
  81. package/dist/reviewer.js +49 -1
  82. package/dist/scope.js +11 -0
  83. package/dist/serve.d.ts +211 -2
  84. package/dist/serve.js +1038 -145
  85. package/dist/store.d.ts +112 -4
  86. package/dist/store.js +447 -27
  87. package/dist/surface.js +36 -9
  88. package/dist/tailnet.d.ts +11 -0
  89. package/dist/tailnet.js +47 -0
  90. package/dist/task-outcome-cli.js +2 -2
  91. package/dist/task-status.d.ts +13 -3
  92. package/dist/task-status.js +38 -16
  93. package/dist/team-cli.js +3 -0
  94. package/dist/telegram-status.js +2 -2
  95. package/dist/verification-evidence.js +21 -1
  96. package/dist/webhooks.js +5 -0
  97. package/dist/work-index.js +39 -6
  98. package/dist/work-summary.d.ts +0 -4
  99. package/dist/work-summary.js +4 -1
  100. package/dist/workspace-ui.d.ts +6 -1
  101. package/dist/workspace-ui.js +14 -11
  102. package/package.json +1 -1
package/dist/operate.js CHANGED
@@ -1,11 +1,15 @@
1
1
  import { maybeTriggerRepair } from "./dispose.js";
2
+ import { CHECK_LEVEL_HINTS, CHECK_LEVEL_WORDS, isCheckLevel, liveQuickCommand, projectCheckLevel, quickVerifyKey, setProjectCheckLevel, setTaskCheckLevel, suggestQuickCommand } from "./check-levels.js";
3
+ import { fileAddTestsTask, followUpChecksOf, requestFollowUpChecks, runFollowUpCheck, runWaitingChecks } from "./result-follow-ups.js";
4
+ import { buildReviewPass } from "./build-review.js";
5
+ import { buildReviewLines, buildReviewOf } from "./review-switch.js";
2
6
  import { parseProtectedPaths } from "./approval-policy.js";
3
7
  import { rulesSummary } from "./approval-rules-ui.js";
4
8
  import { evidencePack, exportDay, ledgerExportChunks, standaloneEvidenceHtml } from "./evidence-pack.js";
5
9
  import { matchesOutsideCheckpoint } from "./ledger-chain.js";
6
10
  import { CLEANUP_CHOICES, bytesWords, cleanupWords, parseCleanup, storageReport } from "./storage.js";
7
11
  import { checkoutPlan, cleanCheckouts, discardCheckout, whyWords } from "./checkout-cleanup.js";
8
- import { MIN_DAYS, RETENTION_KINDS, countWords, dailyRetention, isRetentionKind, lastSweepAt, parsePeriod, periodWords, retentionPlan, sweepWords } from "./retention.js";
12
+ import { MIN_DAYS, RETENTION_KINDS, countWords, dailyRetention, isRetentionKind, lastSweepAt, parsePeriod, periodLabel, periodWords, retentionPlan, sweepWords } from "./retention.js";
9
13
  import { checkPolicy, parseList, policyParts } from "./policy.js";
10
14
  import { billingOf, budgetHoldWords, budgetLabel, budgetStates, monthNamed, monthOf, spendItems, teammateNames, usd as spendUsd } from "./spend.js";
11
15
  import { spendCsv } from "./spend-ui.js";
@@ -79,6 +83,7 @@ import { deleteProject, holdingsWords, projectHoldings, projectRunning } from ".
79
83
  import { pushPass } from "./push.js";
80
84
  import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, fsyncSync, openSync, readFileSync, readSync, realpathSync, renameSync, unlinkSync, writeSync, writeFileSync, mkdirSync } from "node:fs";
81
85
  import { BRANCH_PREFIX, envTwins, envValue, existingOrFirst, namedPath, taskBranches } from "./names.js";
86
+ import { claimActor, currentActor, parseLeadToken, withActor } from "./actor.js";
82
87
  import { createServer as createNetServer } from "node:net";
83
88
  import { spawn as spawnChild } from "node:child_process";
84
89
  import { envelopeJson } from "./envelope.js";
@@ -127,6 +132,7 @@ import { verdictWords as proofVerdictWords, matrixWords, semanticCoverage, cover
127
132
  import { probeRepo, isVerified } from "./probe.js";
128
133
  import { diagnoseTaskDispatch, diagnosisIsDispatchable, scopeApprovedForDispatch, } from "./dispatch.js";
129
134
  import { createDecisionServer } from "./serve.js";
135
+ import { readTailnetNames } from "./tailnet.js";
130
136
  import { daemonLaunchCommand, daemonStatus, installReplacingLegacy, installedLegacyDaemon, planDaemon, uninstallDaemon, awaitFreshHeartbeat, } from "./daemon.js";
131
137
  import { bridgePass, clearBotToken, createTransport, followBridge, hashPairingCode, loadBotToken, mintPairingCode, redactToken, saveBotToken, PAIRING_TTL_MS, TOKEN_ENV, telegramHookSecret, telegramPushUrl, } from "./telegram.js";
132
138
  import { scanRepo } from "./capscan.js";
@@ -150,7 +156,7 @@ import { register, authenticate, heartbeat as heartbeatRunner, isAlive, recoverD
150
156
  import { mintCoordinator, revokeCoordinator, listCoordinators } from "./coordinator.js";
151
157
  import { serveMcp } from "./mcp.js";
152
158
  import { createInterface } from "node:readline";
153
- import { propose, approve, addApprover, authenticateApprover, describeScope, approvalOf, hashToken as hashApproverToken, profileFromJson, fileAndSealUnderMode, modeFilingCoverage, acceptanceLinesToInput, parseAcceptanceCriteria, splitAcceptanceRubric, rubricIsPlaceholder, isCommitSha } from "./scope.js";
159
+ import { propose, approve, addApprover, authenticateAccount, authenticateApprover, describeScope, approvalOf, hashToken as hashApproverToken, profileFromJson, fileAndSealUnderMode, modeFilingCoverage, acceptanceLinesToInput, parseAcceptanceCriteria, splitAcceptanceRubric, rubricIsPlaceholder, isCommitSha } from "./scope.js";
154
160
  import { presetTerms, modeTermsJson, modeDigestOf, modeTermsFromJson, modeWords, MODE_MAX_DAYS } from "./modes.js";
155
161
  import { WorktreePool } from "./worktree.js";
156
162
  import { requestTaskStop, resumeTaskStop, taskControlOf } from "./task-control.js";
@@ -167,6 +173,7 @@ import { isDirectChatProvider, isSubscriptionChatProvider, priceOf, PRICED_MODEL
167
173
  import { resolvePhaseAgent, resolveScopeProfile, resolveScopeChain, resolveRouteCandidates, routeOfTask, INSTALLATION_SCOPE } from "./agentconfig.js";
168
174
  import { isRiskLevel, legOf, projectRoute, riskConsequence, routeDigestOf, routeWords, RISK_LEVELS, PHASES as ROUTE_PHASES } from "./phase-routing.js";
169
175
  import { observeProviderReadiness, reportProviderReadinessAuthed } from "./runner.js";
176
+ import { effectiveConcurrency, maySlotTake, parseProjectConcurrency, PROJECT_CONCURRENCY_DEFAULT, ProjectPasses, projectConcurrency, saveProjectConcurrency, savedProjectConcurrency } from "./project-concurrency.js";
170
177
  import { clearWebhook, effectivePrimary, isMessagingChannel, loadConsoleUrl, loadPrimary, loadWebhookTargets, phoneOrigin, saveConsoleUrl, savePrimary, saveWebhook, webhookPass, SLACK_ENV, DISCORD_ENV } from "./webhooks.js";
171
178
  import { auditOf, inspectionOf, isProviderId, MONEY_CAPABILITIES, PROVIDER_IDS, validModelId, validateSpec, ALL_CREDENTIAL_ENV } from "./provider.js";
172
179
  import { attestProvider, attestationOf, versionInRange } from "./attest.js";
@@ -203,7 +210,11 @@ export const OPERATE_HELP = `toolroll — operating the queue
203
210
 
204
211
  toolroll status running, queued, ready results, release check and plan windows
205
212
  toolroll ready what could be dispatched right now
206
- toolroll task add <title> queue work
213
+ toolroll task add <title> queue work (--replaces <id>: it replaces that task, which reads "Replaced by"; --checks quick|full|off overrides the project's checks)
214
+ toolroll lead token mint the lead's credential for you (your password; --revoke ends it);
215
+ the lead passes it as --token, or TOOLROLL_LEAD_TOKEN, and its work pings nobody
216
+ toolroll task ask <id> --person <name> --why "<text>"
217
+ hand a task to a person: it pings them, and it is theirs
207
218
  toolroll task list [--view <v>] [--limit <n>] [--cursor <c>] paginated saved task status
208
219
  toolroll task show <id>
209
220
  toolroll task wait <id> [--timeout <seconds>]
@@ -212,6 +223,9 @@ export const OPERATE_HELP = `toolroll — operating the queue
212
223
  toolroll project show show the current project and credential reference
213
224
  toolroll project rules --repo <p> a project's approval rules; an instance operator changes them with
214
225
  --not-requester on|off, --protect none|project, --protect-paths "a/**,b/**"
226
+ toolroll project concurrency [<n>] --repo <p>
227
+ how many of a project's tasks build at once (default 2, never past the
228
+ worker's capacity); an approver changes it with <n>
215
229
  toolroll task evidence <id> the task's evidence pack as JSON (--html for a printable page; --out <file>)
216
230
  toolroll storage where the disk goes: database, build checkouts, releases, evidence
217
231
  toolroll storage clean [--yes] preview removing finished tasks' clean checkouts (their branches stay); --yes removes them
@@ -225,10 +239,12 @@ export const OPERATE_HELP = `toolroll — operating the queue
225
239
  toolroll budget list|set|remove monthly budgets (--all | --project <p> | --person <name> | --teammate <id>) --usd <n> [--alerts-only]
226
240
  toolroll notifications [quiet|all] how chats reach you: only when you're needed (the default), or every step
227
241
  toolroll notifications digest <HH:MM>|off one evening message: what finished, what waits, what failed
242
+ toolroll notifications mute|unmute --repo <p> no pings for a project; the console and digest keep it
228
243
  toolroll retention show|preview how long evidence, checkout records, chat and notifications are kept; what the daily sweep would remove
229
- toolroll retention set <kind> <period> evidence|checkouts|chat|notifications, 30d|1y|forever (instance operator)
244
+ toolroll retention set <kind> <period> evidence|checkouts|chat|notifications, 1d|30d|1y|forever (instance operator)
230
245
  toolroll backup now|list back the database up now; list backups and how the last ones went
231
246
  toolroll restore <file> [--dry-run] put a backup back (Toolroll stopped; the current database is kept)
247
+ toolroll review show|on|off --repo <path> one automatic review of each finished build; only HIGH findings send it back
232
248
  toolroll policy show|set the organisation policy; an instance operator sets it with --providers claude,codex|any,
233
249
  --models <m,…>|any, --tools <t,…>|any, --ceiling safe|standard|escalated
234
250
  toolroll ledger verify check the action ledger's hash chain (--checkpoint <n:hash> to compare a copied head)
@@ -239,6 +255,10 @@ export const OPERATE_HELP = `toolroll — operating the queue
239
255
  [--pull-request] --pull-request also opens its pull request
240
256
  toolroll task merge <id> --as <you> --token <t>
241
257
  merge the task's pull request once checks pass
258
+ [--anyway] merge without waiting for the full check
259
+ toolroll task checks <id> [--level quick|full]
260
+ run checks on the result's exact commit now
261
+ toolroll task add-tests <id> file a small task to write tests for that change
242
262
  toolroll task revise <id> --feedback "requested change"
243
263
  toolroll assignment show <task> root, current work and exact handoff
244
264
  toolroll assignment updates durable updates (--after <cursor>)
@@ -250,6 +270,7 @@ export const OPERATE_HELP = `toolroll — operating the queue
250
270
  claim/check use --token-env NAME or --token-file PATH for a coordinator;
251
271
  checking a receipt never approves work, accepts proof or deploys it
252
272
  toolroll task state <id> <state> [--reason <text>] queued|running|done|failed|cancelled
273
+ [--replaced-by <id>] cancelled because <id> replaces it: it reads "Replaced by <id>"
253
274
  toolroll task block <id> --on <id> <id> waits for <on>
254
275
  toolroll task unblock <id> --on <id> stop waiting for <on>
255
276
  toolroll task next <id> [--undo] move it to the front of ITS
@@ -487,7 +508,13 @@ Agents — which provider and model each phase runs on
487
508
  executable is missing, then retry
488
509
  this check once; approval requires
489
510
  its previewed setup digest
490
- toolroll verify clear --repo <path> --as <you> --token <t>
511
+ toolroll verify clear --repo <path> --as <you> --token <t> (--level quick clears the quick command)
512
+ toolroll verify level quick|full|off --repo <path> --as <you> --token <t>
513
+ how much checks after each build: Quick (the
514
+ quick command), Full, or Off (no check)
515
+ toolroll verify set --repo <path> --quick "<cmd>" --as <you> --token <t> [--yes]
516
+ approve the fast check Quick runs; verify show
517
+ suggests one from the project's scripts
491
518
  Pass flags still win for one pass: --provider/--model,
492
519
  --plan-provider/--plan-model, --repair-model. A routine instance is
493
520
  pinned at fire time and ignores all of them.
@@ -559,7 +586,8 @@ Exit codes
559
586
  export const TASK_ACTIONS = [
560
587
  "add", "list", "show", "wait", "state", "block", "unblock", "next", "steer", "assign",
561
588
  "reopen", "scope", "approve", "hold", "unhold", "require", "requeue", "regate", "plan",
562
- "review", "accept", "repair", "route", "stop", "resume", "complete", "revise", "merge",
589
+ "review", "accept", "repair", "route", "stop", "resume", "complete", "revise", "merge", "ask",
590
+ "checks", "add-tests",
563
591
  ];
564
592
  export const PUBLISH_ACTIONS = ["setup", "grant", "revoke", "status", "unblock", "rearm", "merge", "refire"];
565
593
  export const CONFIG_ACTIONS = ["show", "set", "clear"];
@@ -601,6 +629,10 @@ export const OPERATE_VALUE_FLAGS = new Set([
601
629
  // flows: templates, steps, scripts and cards.
602
630
  "template", "steps", "about", "language", "timeout-minutes", "body", "description", "zone",
603
631
  "token-env", "after", "repair-max-attempts", "consumer", "batch", "feedback", "source", "view", "cursor", "why", "supersedes", "decision", "sessions", "timeout",
632
+ // pings follow responsibility: replacements.
633
+ "replaced-by", "replaces",
634
+ // Check levels: a project's level, the quick command, a task's choice.
635
+ "quick", "level", "checks",
604
636
  ]);
605
637
  export const OPERATE_BOOLEAN_FLAGS = new Set([
606
638
  "json", "yes", "all", "local", "history", "latest-watch", "dry-run", "file", "allow-paid-fallback",
@@ -616,6 +648,10 @@ export const OPERATE_BOOLEAN_FLAGS = new Set([
616
648
  "pull-request",
617
649
  // up: the exact start-up details (containment) in the terminal too.
618
650
  "verbose",
651
+ // lead token: end the lead's credential.
652
+ "revoke",
653
+ // task merge: merge without waiting for the full check (Quick or Off results).
654
+ "anyway",
619
655
  ]);
620
656
  export function parseOperateArgs(argv, ownValues = new Set()) {
621
657
  const positional = [];
@@ -660,7 +696,8 @@ export function parseOperateArgs(argv, ownValues = new Set()) {
660
696
  const mintedToken = name === "token" && value !== undefined && /^[A-Za-z0-9_-]{43}$/.test(value);
661
697
  if (value === undefined || (equals === -1 && value.startsWith("--") && !mintedToken))
662
698
  return { error: `--${name} needs a value` };
663
- flags.set(name, value);
699
+ // `flows import --param a=1 --param b=2`: each value kept, one per line.
700
+ flags.set(name, name === "param" && typeof flags.get(name) === "string" ? `${flags.get(name)}\n${value}` : value);
664
701
  if (name === "repo") {
665
702
  for (const one of value.split(",").map(part => part.trim()).filter(part => part !== ""))
666
703
  repoList.push(one);
@@ -727,6 +764,27 @@ export async function runOperate(command, argv, write, options = {}) {
727
764
  catch (error) {
728
765
  return databaseFailure(write, json, command, file, error);
729
766
  }
767
+ // The lead (a lead token, as --token or TOOLROLL_LEAD_TOKEN) acts for its owner in single commands; its work
768
+ // pings nobody. Anyone else who signs in inside one of these commands is who acted. Services name nobody.
769
+ const presentedFlag = text(flags, "token");
770
+ const flagLead = presentedFlag !== undefined && parseLeadToken(presentedFlag) !== null;
771
+ const envLead = envValue(process.env, "LEAD_TOKEN");
772
+ const presented = flagLead ? presentedFlag : LEAD_COMMANDS.has(command) && envLead !== undefined && envLead !== "" ? envLead : undefined;
773
+ let actor = null;
774
+ if (presented !== undefined) {
775
+ if (!LEAD_COMMANDS.has(command)) {
776
+ store.close();
777
+ return fail(write, json, command, "usage", `A lead token works for ${[...LEAD_COMMANDS].filter(one => one !== "lead").join(", ")}. Use your own sign-in for ${command}.`, EXIT.usage);
778
+ }
779
+ const lead = store.leadFor(presented);
780
+ if (lead === null) {
781
+ store.close();
782
+ return fail(write, json, command, "unauthenticated", "That lead token is not valid (revoked, replaced, or its owner can no longer approve). The owner mints a new one with: toolroll lead token", EXIT.refused);
783
+ }
784
+ if (flagLead)
785
+ flags.delete("token");
786
+ actor = { account: lead.owner, lead: true };
787
+ }
730
788
  // v105: a Claude turn anywhere in this command says its plan's usage windows; keep the latest.
731
789
  const dropLimitSink = pushLimitSink(reading => store.recordProviderLimits(reading, new Date()));
732
790
  // One `Date` per command is fine for a lookup and wrong for a pass that
@@ -735,7 +793,8 @@ export async function runOperate(command, argv, write, options = {}) {
735
793
  // must not advance under it — while real time is read again at each step.
736
794
  const clock = options.now === undefined ? () => new Date() : () => now;
737
795
  try {
738
- return await dispatch(command, positional, flags, {
796
+ const run = () => dispatch(command, positional, flags, {
797
+ ...(actor === null ? {} : { leadToken: presented }),
739
798
  store,
740
799
  write,
741
800
  json,
@@ -760,6 +819,7 @@ export async function runOperate(command, argv, write, options = {}) {
760
819
  ...(options.dispatchAdapter === undefined ? {} : { dispatchAdapter: options.dispatchAdapter }),
761
820
  ...(options.flowTriggerIo === undefined ? {} : { flowTriggerIo: options.flowTriggerIo }),
762
821
  ...(options.flowStepIo === undefined ? {} : { flowStepIo: options.flowStepIo }),
822
+ ...(options.flowFetch === undefined ? {} : { flowFetch: options.flowFetch }),
763
823
  ...(options.integrationIo === undefined ? {} : { integrationIo: options.integrationIo }),
764
824
  ...(options.shouldStop === undefined ? {} : { shouldStop: options.shouldStop }),
765
825
  ...(options.mateSeams === undefined ? {} : { mateSeams: options.mateSeams }),
@@ -770,6 +830,7 @@ export async function runOperate(command, argv, write, options = {}) {
770
830
  ...(options.onboardSeams === undefined ? {} : { onboardSeams: options.onboardSeams }),
771
831
  ...(options.upSeams === undefined ? {} : { upSeams: options.upSeams }),
772
832
  });
833
+ return await (LEAD_COMMANDS.has(command) ? withActor(actor, run) : run());
773
834
  }
774
835
  catch (error) {
775
836
  if (isDatabaseBusy(error))
@@ -781,6 +842,8 @@ export async function runOperate(command, argv, write, options = {}) {
781
842
  store.close();
782
843
  }
783
844
  }
845
+ /** The short commands a lead token works for, and where whoever signs in is recorded as the actor. */
846
+ const LEAD_COMMANDS = new Set(["task", "assignment", "status", "ready", "brief", "peek", "check-progress", "notifications", "lead"]);
784
847
  /**
785
848
  * The enrolled-project registry lives beside the database — the same rule
786
849
  * as evidence and the bot token — so an isolated installation (`--db` in a
@@ -860,6 +923,8 @@ async function dispatch(command, positional, flags, context) {
860
923
  return retentionCommand(positional, flags, context);
861
924
  case "notifications":
862
925
  return notificationsCommand(positional, flags, context);
926
+ case "lead":
927
+ return leadCommand(positional, flags, context);
863
928
  case "backup":
864
929
  return backupCommand(positional, flags, context);
865
930
  case "export":
@@ -870,6 +935,8 @@ async function dispatch(command, positional, flags, context) {
870
935
  return storageCommand(positional, flags, context);
871
936
  case "integrations":
872
937
  return integrationsCommand(positional, flags, context);
938
+ case "review":
939
+ return reviewSwitchCommand(positional, flags, context);
873
940
  case "monitoring": {
874
941
  // Where the audit stream and traces go, and how each destination is doing. Settings → Monitoring changes it.
875
942
  const settings = readMonitoring(dirname(context.databaseFile));
@@ -890,6 +957,8 @@ async function dispatch(command, positional, flags, context) {
890
957
  return projectRulesCommand(positional, flags, context);
891
958
  if (positional[0] === "delete")
892
959
  return projectDeleteCommand(positional, flags, context);
960
+ if (positional[0] === "concurrency")
961
+ return projectConcurrencyCommand(positional, flags, context);
893
962
  return runProjectCommand(positional, flags, context);
894
963
  case "assignment":
895
964
  return runAssignmentCommand(positional, flags, context);
@@ -928,6 +997,8 @@ async function dispatch(command, positional, flags, context) {
928
997
  return tickCommand(flags, context);
929
998
  case "reconcile":
930
999
  return reconcileCommand(flags, context);
1000
+ case "run":
1001
+ return runCommand(positional, flags, context);
931
1002
  case "cap":
932
1003
  return capCommand(positional, flags, context);
933
1004
  case "gaps":
@@ -1957,10 +2028,15 @@ async function tickCommand(flags, context) {
1957
2028
  if (updateAdmissionPaused(store.raw()))
1958
2029
  return fail(write, json, "tick", "updating", UPDATE_PAUSED, EXIT.refused);
1959
2030
  const maxGiven = text(flags, "max");
1960
- const max = maxGiven === undefined ? 1 : Number(maxGiven);
1961
- if (!Number.isInteger(max) || max <= 0) {
2031
+ const maxAsked = maxGiven === undefined ? 1 : Number(maxGiven);
2032
+ if (!Number.isInteger(maxAsked) || maxAsked <= 0) {
1962
2033
  return fail(write, json, "tick", "usage", "--max takes a whole number of tasks", EXIT.usage);
1963
2034
  }
2035
+ // The service's build lanes (project-concurrency.ts): a lane over its
2036
+ // project's fair share still runs its housekeeping but starts no
2037
+ // unattended build this pass; an extra lane only builds.
2038
+ const max = flags.has("yield-builds") ? 0 : maxAsked;
2039
+ const buildsOnly = flags.has("builds-only");
1964
2040
  const syncAgeGiven = text(flags, "sync-max-age");
1965
2041
  const syncMaxAgeMs = syncAgeGiven === undefined ? SYNC_MAX_AGE_MS : Number(syncAgeGiven) * 1000;
1966
2042
  if (!Number.isInteger(syncMaxAgeMs) || syncMaxAgeMs <= 0) {
@@ -2013,7 +2089,7 @@ async function tickCommand(flags, context) {
2013
2089
  // fireRoutine's own transaction, and skipped slots ledger and page
2014
2090
  // themselves there. This loop just reports.
2015
2091
  const routines = [];
2016
- for (const routine of store.dueRoutines(repo, clock())) {
2092
+ for (const routine of buildsOnly ? [] : store.dueRoutines(repo, clock())) {
2017
2093
  // The stop fence (audit IV-1): a signal that landed mid-pass stops
2018
2094
  // every further admission — a routine not yet fired stays unfired.
2019
2095
  if (context.shouldStop?.() === true || context.shouldPauseAdmission?.() === true)
@@ -2033,7 +2109,7 @@ async function tickCommand(flags, context) {
2033
2109
  // Triggers first: a schedule, GitHub, Linear or another flow may start
2034
2110
  // cards, which then move in the same pass. Checking an outside service is
2035
2111
  // `gh` or one HTTPS request, never a model, and only when it is due.
2036
- const triggerPass = context.shouldStop?.() === true || context.shouldPauseAdmission?.() === true ? { added: 0, checked: 0, problems: [] }
2112
+ const triggerPass = buildsOnly || context.shouldStop?.() === true || context.shouldPauseAdmission?.() === true ? { added: 0, checked: 0, problems: [] }
2037
2113
  : await runFlowTriggers(store, repo, clock(), { gh: context.flowTriggerIo?.gh ?? run, fetch: context.flowTriggerIo?.fetch ?? fetch, dir: context.flowTriggerIo?.dir ?? dirname(context.databaseFile),
2038
2114
  // A schedule's script (v90) runs like a code step: the same runner, in a clean folder beside the step copies.
2039
2115
  shell: context.flowTriggerIo?.shell ?? context.flowStepIo?.shell ?? run, scratch: context.flowTriggerIo?.scratch ?? context.flowStepIo?.scratch ?? join(pool, "flow-checks"),
@@ -2041,28 +2117,29 @@ async function tickCommand(flags, context) {
2041
2117
  // Replies to cards' emails (v91): read about once a minute, and only while
2042
2118
  // a card is in an email conversation. A reply moves a waiting card on here,
2043
2119
  // before its next step runs.
2044
- const replyPass = context.shouldStop?.() === true || context.shouldPauseAdmission?.() === true ? { read: 0, taken: 0, problem: null }
2120
+ const replyPass = buildsOnly || context.shouldStop?.() === true || context.shouldPauseAdmission?.() === true ? { read: 0, taken: 0, problem: null }
2045
2121
  : await watchFlowReplies(store, clock(), { fetch: context.flowTriggerIo?.fetch ?? fetch, dir: context.flowTriggerIo?.dir ?? dirname(context.databaseFile),
2046
2122
  ...(context.flowTriggerIo?.mail === undefined ? {} : { mail: context.flowTriggerIo.mail }) });
2047
2123
  // Check and update steps run outside a model: an approved command in a
2048
2124
  // fresh copy of the card's work, or a comment on the issue it came from.
2049
- const stepPass = context.shouldStop?.() === true || context.shouldPauseAdmission?.() === true ? { ran: 0, problems: [] }
2125
+ const stepPass = buildsOnly || context.shouldStop?.() === true || context.shouldPauseAdmission?.() === true ? { ran: 0, problems: [] }
2050
2126
  : await runFlowSteps(store, repo, clock(), {
2051
2127
  gh: context.flowStepIo?.gh ?? run, git: context.flowStepIo?.git ?? git, shell: context.flowStepIo?.shell ?? run, fetch: context.flowStepIo?.fetch ?? fetch,
2052
2128
  dir: context.flowStepIo?.dir ?? dirname(context.databaseFile), scratch: context.flowStepIo?.scratch ?? join(pool, "flow-checks"), base,
2053
2129
  ...(context.evidenceRoot === undefined ? {} : { evidenceRoot: context.evidenceRoot }),
2054
2130
  });
2055
- const flowPass = advanceFlows(store, repo, clock(), context.evidenceRoot === undefined ? {} : { evidenceRoot: context.evidenceRoot });
2131
+ const flowPass = buildsOnly ? { moved: 0, filed: [], problems: [] } : advanceFlows(store, repo, clock(), context.evidenceRoot === undefined ? {} : { evidenceRoot: context.evidenceRoot });
2056
2132
  // Each AI teammate's daily summary to its manager (v92), once, after 5 pm; its weekly report (v97), Monday mornings.
2057
- try {
2058
- sendTeammateSummaries(store, repo, clock());
2059
- sendTeammateWeeklies(store, repo, clock());
2060
- }
2061
- catch (error) {
2062
- flowPass.problems.push(`teammate summaries: ${error instanceof Error ? error.message : "could not send"}`);
2063
- }
2133
+ if (!buildsOnly)
2134
+ try {
2135
+ sendTeammateSummaries(store, repo, clock());
2136
+ sendTeammateWeeklies(store, repo, clock());
2137
+ }
2138
+ catch (error) {
2139
+ flowPass.problems.push(`teammate summaries: ${error instanceof Error ? error.message : "could not send"}`);
2140
+ }
2064
2141
  // Undoing a teammate's tool call asked for in chat (v97): made here, as the person who asked.
2065
- if (context.shouldStop?.() !== true) {
2142
+ if (!buildsOnly && context.shouldStop?.() !== true) {
2066
2143
  try {
2067
2144
  await runRequestedUndos(store, {}, clock());
2068
2145
  }
@@ -2071,7 +2148,7 @@ async function tickCommand(flags, context) {
2071
2148
  }
2072
2149
  }
2073
2150
  // Tools connected by signing in: a sign-in that runs out within ten minutes is renewed here, so a build or a teammate's call starts with a fresh one.
2074
- if (context.shouldStop?.() !== true) {
2151
+ if (!buildsOnly && context.shouldStop?.() !== true) {
2075
2152
  try {
2076
2153
  flowPass.problems.push(...(await refreshConnections(store, [repo], clock(), context.flowStepIo?.fetch === undefined ? {} : { fetcher: context.flowStepIo.fetch })).problems);
2077
2154
  }
@@ -2086,7 +2163,8 @@ async function tickCommand(flags, context) {
2086
2163
  // races recover by CAS, and an ANSWERED question re-admits its parked
2087
2164
  // agent — fresh claim, fresh slot, remaining budget only, the SAME
2088
2165
  // verified checkout on the SAME runner (finding 29's custody rule).
2089
- recoverContests(store, clock());
2166
+ if (!buildsOnly)
2167
+ recoverContests(store, clock());
2090
2168
  // Expired ceremony nonces are litter with a bound (round-3 finding 30):
2091
2169
  // the mint refuses past 50 open per approver, so the sweep keeps the
2092
2170
  // ceiling meaningful rather than letting dead rows consume it.
@@ -2094,8 +2172,10 @@ async function tickCommand(flags, context) {
2094
2172
  // Decided tournaments give their checkouts back (stage 6) — this runner's
2095
2173
  // custody only; a checkout that will not release cleanly is flagged and
2096
2174
  // paged, never force-cleaned. Undecided ones escalate once at 14 days.
2097
- await sweepContestCleanup(store, path => worktrees.release(path, clock()), runner, clock());
2098
- escalateOverdueContests(store, clock());
2175
+ if (!buildsOnly) {
2176
+ await sweepContestCleanup(store, path => worktrees.release(path, clock()), runner, clock());
2177
+ escalateOverdueContests(store, clock());
2178
+ }
2099
2179
  const resumed = [];
2100
2180
  // v105: monthly budgets, read when first needed and again after any build (its spend counts): a used-up hard-stop
2101
2181
  // budget holds back new work billed to an API key, on every road below.
@@ -2124,7 +2204,7 @@ async function tickCommand(flags, context) {
2124
2204
  }
2125
2205
  return null;
2126
2206
  };
2127
- for (const waiting of store.contestsInStates(["decision-wait"])) {
2207
+ for (const waiting of buildsOnly ? [] : store.contestsInStates(["decision-wait"])) {
2128
2208
  if (context.shouldStop?.() === true || context.shouldPauseAdmission?.() === true)
2129
2209
  break;
2130
2210
  // D1 belt-and-braces (external dispatch, finding 41): a mirror and a
@@ -2403,7 +2483,8 @@ async function tickCommand(flags, context) {
2403
2483
  // uses, before any admission can re-tag or race it. Advancing here lands
2404
2484
  // the cycle in pending-admission for THIS pass's chain admission below.
2405
2485
  // One shared piece with the fault tests (F+G review, finding 5).
2406
- store.reconcileStrandedChains(repo, clock());
2486
+ if (!buildsOnly)
2487
+ store.reconcileStrandedChains(repo, clock());
2407
2488
  // The DISPATCH view of the queue (queue columns, v19): this runner's own
2408
2489
  // reserved work first, then the shared queue; work reserved for other
2409
2490
  // workers is absent. The claim primitive re-proves the reservation.
@@ -3790,6 +3871,11 @@ async function tickCommand(flags, context) {
3790
3871
  // not a break, not a strike; the task waits under the stop's hold.
3791
3872
  dispatched.push({ id, outcome: "stopped", reason: `stop:${disposition.stopRun}`, branch, worktree: leased.worktree.path });
3792
3873
  break;
3874
+ case "handed-back":
3875
+ // The service is stopping: the work is kept and the task requeued
3876
+ // to resume on the next pass — not a break, not a strike.
3877
+ dispatched.push({ id, outcome: "stopped", reason: "service-stop", detail: result.ok ? "" : result.message.slice(0, 200), branch, worktree: leased.worktree.path });
3878
+ break;
3793
3879
  case "invariant":
3794
3880
  dispatched.push({ id, outcome: "failed", reason: disposition.reason });
3795
3881
  broke++;
@@ -3804,7 +3890,7 @@ async function tickCommand(flags, context) {
3804
3890
  // paid-fallback grant + the single-use pending edge); this loop carries
3805
3891
  // only claim, worktree, and rail, and its run then re-proves the
3806
3892
  // chain-entry dispatch proof inside build() before any money moves.
3807
- for (const pending of store.pendingChainAdmissions(repo)) {
3893
+ for (const pending of buildsOnly ? [] : store.pendingChainAdmissions(repo)) {
3808
3894
  if (context.shouldStop?.() === true || context.shouldPauseAdmission?.() === true)
3809
3895
  break;
3810
3896
  if (built >= max)
@@ -3975,6 +4061,9 @@ async function tickCommand(flags, context) {
3975
4061
  case "stopped":
3976
4062
  dispatched.push({ id: pending.taskId, outcome: "stopped", reason: `stop:${disposition.stopRun}`, branch, worktree: leased.worktree.path });
3977
4063
  break;
4064
+ case "handed-back":
4065
+ dispatched.push({ id: pending.taskId, outcome: "stopped", reason: "service-stop", branch, worktree: leased.worktree.path });
4066
+ break;
3978
4067
  case "parked":
3979
4068
  dispatched.push({ id: pending.taskId, outcome: "parked", reason: `decision:${disposition.decisionId}`, worktree: leased.worktree.path });
3980
4069
  parked++;
@@ -3994,7 +4083,7 @@ async function tickCommand(flags, context) {
3994
4083
  // Only a co-located coordinator can hold the session, and everything
3995
4084
  // else (liveness, one attempt, the final proof at the parent's exact
3996
4085
  // head) is re-proved on the way in.
3997
- if (context.heldCoordinator !== undefined) {
4086
+ if (context.heldCoordinator !== undefined && !buildsOnly) {
3998
4087
  for (const continuation of store.openContinuationAuthorizations(runner)) {
3999
4088
  if (context.shouldStop?.() === true || context.shouldPauseAdmission?.() === true)
4000
4089
  break;
@@ -4195,20 +4284,23 @@ async function tickCommand(flags, context) {
4195
4284
  broke++;
4196
4285
  }
4197
4286
  }
4198
- // Retire queued model-review work without creating a run or a passing verdict.
4199
- // Reauthenticate inside the write transaction and touch only admitted projects.
4200
- store.transact(() => {
4201
- const current = authenticate(store, runner, token);
4202
- if (!current.ok)
4203
- return;
4204
- for (const request of store.openReviewRequests()) {
4205
- if (request.repo !== null && current.runner.repos.includes(request.repo)) {
4206
- store.consumeReviewRequest(request.id, "model-review-retired", clock());
4207
- }
4208
- }
4209
- });
4287
+ // The one automatic review each finished build gets when its project's
4288
+ // switch is on, before the result is handed on below. Other queued review
4289
+ // asks close unrun. Reauthenticated first; only admitted projects.
4290
+ if (!buildsOnly && authenticate(store, runner, token).ok) {
4291
+ const reviewed = await buildReviewPass(store, {
4292
+ runner, token, repos: auth.runner.repos, clock,
4293
+ root: context.evidenceRoot,
4294
+ ...(text(flags, "incarnation") === undefined ? {} : { watchIncarnation: text(flags, "incarnation") }),
4295
+ ...(context.agentRunner === undefined ? {} : { agent: context.agentRunner }),
4296
+ shouldStop: () => context.shouldStop?.() === true || context.shouldPauseAdmission?.() === true,
4297
+ });
4298
+ for (const one of reviewed)
4299
+ dispatched.push({ id: `review of run ${one.run}`, outcome: one.outcome === "not-reviewed" ? "not-reviewed" : "reviewed", reason: one.detail });
4300
+ }
4210
4301
  // Existing worker pass owns durable lead handoffs; reads never create events.
4211
- syncAssignmentHandoffs(store, clock(), auth.runner.repos, context.evidenceRoot);
4302
+ if (!buildsOnly)
4303
+ syncAssignmentHandoffs(store, clock(), auth.runner.repos, context.evidenceRoot);
4212
4304
  const summary = () => {
4213
4305
  const lines = [`Considered ${considered}, built ${built}, parked ${parked}, broke ${broke}.`];
4214
4306
  for (const entry of routines) {
@@ -4243,7 +4335,7 @@ async function tickCommand(flags, context) {
4243
4335
  routines,
4244
4336
  });
4245
4337
  }
4246
- if (built > 0 || parked > 0 || dispatched.some(one => one.outcome === "planned" || one.outcome === "reported" || one.outcome === "held")) {
4338
+ if (built > 0 || parked > 0 || dispatched.some(one => one.outcome === "planned" || one.outcome === "reported" || one.outcome === "held" || one.outcome === "reviewed" || one.outcome === "not-reviewed")) {
4247
4339
  return succeed(write, json, "tick", { considered, dispatched, routines, ...flows }, summary);
4248
4340
  }
4249
4341
  if (considered === 0) {
@@ -4305,6 +4397,9 @@ async function reconcileCommand(flags, context) {
4305
4397
  }
4306
4398
  }
4307
4399
  const recovered = recoverDead(store, clock());
4400
+ // A witness reserved for a spawn that never made a process (a crash or a
4401
+ // thrown spawn) settles once its run's process groups are proven gone.
4402
+ store.settleUnspawnedWitnesses(clock());
4308
4403
  store.settleQuiescentStops(clock());
4309
4404
  for (const one of recovered) {
4310
4405
  for (const leaseId of one.claims) {
@@ -4716,6 +4811,10 @@ async function startConsole(options) {
4716
4811
  ...(options.localRunner === undefined ? {} : { localRunner: options.localRunner }),
4717
4812
  poolRoot: options.poolRoot,
4718
4813
  ...(options.allowedHosts === undefined ? {} : { allowedHosts: options.allowedHosts }),
4814
+ // This computer's own tailnet names are admitted on the served port without an --allow-host (onboarding).
4815
+ tailnetNames: context.tailnetNames ?? (() => readTailnetNames()),
4816
+ // With no lead set up yet, the agent signed in on this computer runs it (onboarding).
4817
+ leadByDefault: true,
4719
4818
  ...(options.setupCode === undefined ? {} : { setupCode: options.setupCode }),
4720
4819
  ...(options.repos === undefined ? {} : { repos: options.repos }),
4721
4820
  ...(options.projectRoots === undefined ? {} : { projectRoots: options.projectRoots }),
@@ -4957,13 +5056,18 @@ async function planTaskCommand(positional, flags, context) {
4957
5056
  }
4958
5057
  /** The retired command is a typed refusal, never a new provider request. */
4959
5058
  async function reviewTaskCommand(positional, flags, context) {
4960
- const { store, write, json, clock } = context;
5059
+ const { store, write, json } = context;
4961
5060
  const [runText] = positional;
4962
5061
  const runId = Number(runText ?? "");
4963
5062
  if (runText === undefined || !Number.isInteger(runId) || runId < 1) {
4964
5063
  return fail(write, json, "task review", "usage", "`toolroll task review <run-id>`", EXIT.usage);
4965
5064
  }
4966
- return fail(write, json, "task review", "model-review-retired", `Separate model review has been removed. Inspect run ${runId}'s saved work and checks, then give feedback or request a revision.`, EXIT.refused);
5065
+ // Read-only: the run's one automatic review, when it had one.
5066
+ const review = buildReviewOf(store, runId);
5067
+ if (review !== null) {
5068
+ return succeed(write, json, "task review", { run: runId, review }, () => [`Run ${runId}`, ...buildReviewLines(review)]);
5069
+ }
5070
+ return fail(write, json, "task review", "model-review-retired", `Run ${runId} has no automatic review. Manual review requests have been removed; a project's builds are reviewed automatically when its switch is on (\`toolroll review on --repo <path>\`). Inspect the saved work and checks, then give feedback or request a revision.`, EXIT.refused);
4967
5071
  }
4968
5072
  /**
4969
5073
  * `toolroll incident list|resolve <id>` — the parks that never became
@@ -6040,18 +6144,51 @@ async function verifyCommand(positional, flags, context) {
6040
6144
  return fail(write, json, "verify show", "usage", "which repo? --repo <path>", EXIT.usage);
6041
6145
  }
6042
6146
  const live = store.liveVerifyCommand(repo);
6147
+ const quick = liveQuickCommand(store, repo);
6148
+ const level = projectCheckLevel(store, repo);
6149
+ const suggestion = quick === null ? suggestQuickCommand(repo) : null;
6043
6150
  if (json) {
6044
- write(envelopeJson({ ok: true, command: "verify show", repo, verify: live }));
6151
+ write(envelopeJson({ ok: true, command: "verify show", repo, verify: live, quick, level: level.level, ...(suggestion === null ? {} : { suggestedQuick: suggestion }) }));
6045
6152
  return EXIT.ok;
6046
6153
  }
6047
6154
  write(live === null
6048
6155
  ? `No verification command for ${repo}. A build's proof lands "attested" at best — nothing re-runs it.`
6049
6156
  : `${repo} re-runs after every commit:\n ${live.command}\n timeout ${Math.round(live.timeoutMs / 1000)}s · digest ${live.digest} · approved by ${live.approvedBy} at ${live.approvedAt}${live.recoverySetupDigest === null ? "" : `\n self-healing on · approved setup ${live.recoverySetupDigest}`}`);
6157
+ write(`Check level: ${CHECK_LEVEL_WORDS[level.level]}${level.setBy === null ? " (not set; projects from before levels keep the full check)" : ` · set by ${level.setBy}`}`);
6158
+ if (quick !== null)
6159
+ write(`Quick check:\n ${quick.command}\n timeout ${Math.round(quick.timeoutMs / 1000)}s · digest ${quick.digest} · approved by ${quick.approvedBy}`);
6160
+ else if (level.level === "quick")
6161
+ write(`No quick command yet, so Quick runs the full check.${suggestion === null ? "" : ` Suggested from the project's scripts:\n toolroll verify set --repo ${repo} --quick "${suggestion}"`}`);
6050
6162
  return EXIT.ok;
6051
6163
  }
6164
+ // An approver's act: the project's check level. The ledger keeps before → after.
6165
+ if (action === "level") {
6166
+ if (repo === undefined)
6167
+ return fail(write, json, "verify level", "usage", "which repo? --repo <path>", EXIT.usage);
6168
+ const wanted = positional[1];
6169
+ if (!isCheckLevel(wanted))
6170
+ return fail(write, json, "verify level", "usage", "`toolroll verify level quick|full|off --repo <path> --as <you> --token <t>`", EXIT.usage);
6171
+ const acting = await askCredentials(flags, context);
6172
+ if (acting === null)
6173
+ return fail(write, json, "verify level", "usage", "changing a project's checks takes `--as <you> --token <t>`", EXIT.usage);
6174
+ const authenticated = authenticateApprover(store, acting.name, acting.token, repo);
6175
+ if (!authenticated.ok)
6176
+ return fail(write, json, "verify level", authenticated.reason, describeApproveFailure(authenticated.reason, repo), EXIT.refused);
6177
+ const changed = setProjectCheckLevel(store, repo, wanted, acting.name, clock());
6178
+ const runsFull = wanted === "quick" && liveQuickCommand(store, repo) === null;
6179
+ return succeed(write, json, "verify level", { repo, level: wanted, before: changed.before, changed: changed.changed }, () => [
6180
+ `${repo}: checks are ${CHECK_LEVEL_WORDS[wanted]}${changed.changed ? ` (was ${CHECK_LEVEL_WORDS[changed.before]})` : ""}. ${CHECK_LEVEL_HINTS[wanted]}`,
6181
+ ...(runsFull ? [`No quick command is approved yet, so builds run the full check. Approve one with \`toolroll verify set --repo ${repo} --quick "<cmd>"\`.`] : []),
6182
+ ...(wanted !== "full" ? ["The full check still runs when a pull request opens, and Merge waits for it unless a person merges anyway."] : []),
6183
+ ]);
6184
+ }
6052
6185
  if (action !== "set" && action !== "clear") {
6053
- return fail(write, json, "verify", "usage", "`toolroll verify [show|set --command <cmd> [--timeout-seconds <n>] [--self-heal --setup-digest <shown>] --yes|clear] --repo <path> --as <you> --token <t>`", EXIT.usage);
6186
+ return fail(write, json, "verify", "usage", "`toolroll verify [show|level quick|full|off|set --command <cmd>|--quick <cmd> [--timeout-seconds <n>] [--self-heal --setup-digest <shown>] --yes|clear [--level quick]] --repo <path> --as <you> --token <t>`", EXIT.usage);
6054
6187
  }
6188
+ // The quick command is its own approved grant beside the full one.
6189
+ const quickValue = text(flags, "quick");
6190
+ const quickClear = action === "clear" && text(flags, "level") === "quick";
6191
+ const grantKey = repo === undefined ? undefined : quickValue !== undefined || quickClear ? quickVerifyKey(repo) : repo;
6055
6192
  if (repo === undefined) {
6056
6193
  return fail(write, json, `verify ${action}`, "usage", "which repo? --repo <path>", EXIT.usage);
6057
6194
  }
@@ -6064,12 +6201,22 @@ async function verifyCommand(positional, flags, context) {
6064
6201
  return fail(write, json, `verify ${action}`, authenticated.reason, describeApproveFailure(authenticated.reason, repo), EXIT.refused);
6065
6202
  }
6066
6203
  if (action === "clear") {
6067
- const cleared = store.clearVerifyCommand(repo, acting.name, clock());
6204
+ const cleared = store.clearVerifyCommand(grantKey, acting.name, clock());
6205
+ if (quickClear)
6206
+ return succeed(write, json, "verify clear", { repo, cleared, level: "quick" }, () => [
6207
+ cleared ? `Cleared the quick command — Quick builds of ${repo} run the full check now.` : `No quick command was set for ${repo}.`,
6208
+ ]);
6068
6209
  return succeed(write, json, "verify clear", { repo, cleared }, () => [
6069
6210
  cleared ? `Cleared — builds of ${repo} land "attested" at best now; nothing re-runs.` : `Nothing was set for ${repo}.`,
6070
6211
  ]);
6071
6212
  }
6072
- const command = text(flags, "command");
6213
+ const command = quickValue ?? text(flags, "command");
6214
+ if (quickValue !== undefined && text(flags, "command") !== undefined) {
6215
+ return fail(write, json, "verify set", "usage", "give the quick command with --quick \"<cmd>\" or the full one with --command, not both", EXIT.usage);
6216
+ }
6217
+ if (quickValue !== undefined && flags.get("self-heal") === true) {
6218
+ return fail(write, json, "verify set", "usage", "--self-heal applies to the full command only", EXIT.usage);
6219
+ }
6073
6220
  if (command === undefined || command.trim() === "") {
6074
6221
  return fail(write, json, "verify set", "usage", "--command <cmd> is what the plane re-runs after every commit", EXIT.usage);
6075
6222
  }
@@ -6103,6 +6250,7 @@ async function verifyCommand(positional, flags, context) {
6103
6250
  command: "verify set",
6104
6251
  reason: "unconfirmed",
6105
6252
  repo,
6253
+ ...(quickValue === undefined ? {} : { level: "quick" }),
6106
6254
  verifyCommand: command,
6107
6255
  timeoutSeconds,
6108
6256
  selfHeal,
@@ -6116,7 +6264,7 @@ async function verifyCommand(positional, flags, context) {
6116
6264
  ` command ${command}`,
6117
6265
  ` timeout ${timeoutSeconds}s`,
6118
6266
  ``,
6119
- `Every FUTURE build of this repo re-runs this command unattended,`,
6267
+ quickValue === undefined ? `Every FUTURE build of this repo re-runs this command unattended,` : `Every FUTURE Quick build of this repo runs this command unattended,`,
6120
6268
  `once, right after it commits — under an ALLOWLISTED environment`,
6121
6269
  `(PATH, HOME, locale, temp — no credentials). A pass lands the`,
6122
6270
  `build "verified"; a failure lands it "refuted", never blocked.`,
@@ -6145,12 +6293,17 @@ async function verifyCommand(positional, flags, context) {
6145
6293
  return fail(write, json, "verify set", "stale-approval", `the approved setup changed after preview — expected ${expectedSetupDigest}, current ${recoverySetup?.digest}; preview verification again before approving`, EXIT.refused);
6146
6294
  }
6147
6295
  const saved = store.setVerifyCommand({
6148
- repo,
6296
+ repo: grantKey,
6149
6297
  command,
6150
6298
  timeoutMs: timeoutSeconds * 1000,
6151
6299
  approvedBy: acting.name,
6152
6300
  recoverySetupDigest: recoverySetup?.digest ?? null,
6153
6301
  }, clock());
6302
+ if (quickValue !== undefined) {
6303
+ return succeed(write, json, "verify set", { repo, level: "quick", digest: saved.digest, timeoutSeconds }, () => [
6304
+ `Approved the quick check: Quick builds of ${repo} run \`${command}\` (digest ${saved.digest}, ${timeoutSeconds}s) right after commit.`,
6305
+ ]);
6306
+ }
6154
6307
  return succeed(write, json, "verify set", { repo, digest: saved.digest, timeoutSeconds, selfHeal }, () => [
6155
6308
  `Approved: builds of ${repo} re-run \`${command}\` (digest ${saved.digest}, ${timeoutSeconds}s) right after commit${selfHeal ? "; a missing required project executable gets one bounded approved-setup recovery" : ""}.`,
6156
6309
  ]);
@@ -7295,6 +7448,20 @@ async function runWatchLoop(args) {
7295
7448
  });
7296
7449
  // Settings → Models: a quiet check of the public model lists and CLI
7297
7450
  // versions, only when the person turned it on. It never touches work.
7451
+ // Follow-up checks (Run checks, and the full check a pull request waits for):
7452
+ // each waiting request runs once, on its exact commit, in a fresh checkout.
7453
+ const followUpChecks = startMaintenance({
7454
+ intervalMs: 5_000,
7455
+ shouldStop: stopping,
7456
+ onError: error => progress(`watch: a follow-up check failed to run — ${describe(error)}; it is recorded on the result`),
7457
+ run: async () => {
7458
+ if (store.isDemo())
7459
+ return;
7460
+ const ran = await runWaitingChecks(store, context.evidenceRoot, { now: context.clock, shouldStop: stopping });
7461
+ if (ran > 0)
7462
+ progress(`watch: ran ${ran} follow-up check(s)`);
7463
+ },
7464
+ });
7298
7465
  const modelWatch = startMaintenance({
7299
7466
  intervalMs: 15 * 60_000,
7300
7467
  shouldStop: stopping,
@@ -7302,6 +7469,94 @@ async function runWatchLoop(args) {
7302
7469
  run: async () => { if (await modelWatchPass(store, context.clock()))
7303
7470
  progress("watch: checked models and CLI versions"); },
7304
7471
  });
7472
+ // Parallel builds (project-concurrency.ts): this loop is the project's
7473
+ // first build lane and keeps every housekeeping duty; extra lanes, up to
7474
+ // the project's own number (never past the worker's capacity), only
7475
+ // build — each attempt in its own checkout, each under its own claim,
7476
+ // lease and run, so reconcile, retries, the update drain and status count
7477
+ // every one. A lane over the project's fair share leaves the free slot to
7478
+ // a project building fewer, until that project has had a pass to take it.
7479
+ const passes = args.passes ?? new ProjectPasses();
7480
+ const slotFacts = (yieldingSince) => {
7481
+ const now = new Date();
7482
+ const found = store.getRunner(runner);
7483
+ const bound = new Set(found?.runner.repos ?? [repo]);
7484
+ const saved = savedProjectConcurrency(context.databaseFile);
7485
+ return {
7486
+ repo,
7487
+ capacity: found?.runner.capacity ?? 1,
7488
+ running: store.liveClaimCountsByRepo(runner, now),
7489
+ limitOf: one => saved.get(one) ?? PROJECT_CONCURRENCY_DEFAULT,
7490
+ waiting: new Set(store.listReady(now, runner).map(one => one.repo).filter((one) => one !== null && bound.has(one))),
7491
+ yieldingSince,
7492
+ passes,
7493
+ };
7494
+ };
7495
+ const lanes = [];
7496
+ const buildLane = async (index) => {
7497
+ const laneLines = [];
7498
+ const laneContext = { ...quietContext, write: line => laneLines.push(line) };
7499
+ let yieldingSince = null;
7500
+ let yieldingTo = null;
7501
+ while (!stopping() && (deadline === null || Date.now() < deadline)) {
7502
+ const seqBefore = store.wakeSeq();
7503
+ let broke = false;
7504
+ if (!paused()) {
7505
+ const facts = slotFacts(yieldingSince);
7506
+ const answer = index < effectiveConcurrency(facts.limitOf(repo), facts.capacity) ? maySlotTake(facts) : null;
7507
+ if (answer !== null && answer.take) {
7508
+ yieldingSince = null;
7509
+ yieldingTo = null;
7510
+ laneLines.length = 0;
7511
+ let code = EXIT.failed;
7512
+ try {
7513
+ code = await tickCommand(passFlags({ "builds-only": "1" }), laneContext);
7514
+ }
7515
+ catch (error) {
7516
+ laneLines.push(JSON.stringify({ dispatched: [{ id: "?", outcome: "failed", reason: "lane", detail: describe(error) }] }));
7517
+ }
7518
+ passes.passed(repo);
7519
+ if (code === EXIT.ok) {
7520
+ built++;
7521
+ progress(`watch: build ${index + 1} did work (${new Date().toISOString()})`);
7522
+ continue;
7523
+ }
7524
+ if (code === EXIT.failed) {
7525
+ brokeCount++;
7526
+ broke = true;
7527
+ progress(`watch: build ${index + 1} — ${brokenWords(laneLines)}`);
7528
+ }
7529
+ }
7530
+ else if (answer !== null && !answer.take && answer.why === "fair-share") {
7531
+ if (yieldingSince === null) {
7532
+ yieldingSince = passes.stamp();
7533
+ store.bumpWake();
7534
+ }
7535
+ yieldingTo = answer.to ?? null;
7536
+ }
7537
+ else {
7538
+ yieldingSince = null;
7539
+ yieldingTo = null;
7540
+ }
7541
+ }
7542
+ if (!broke && store.wakeSeq() !== seqBefore)
7543
+ continue;
7544
+ const seqIdle = store.wakeSeq();
7545
+ const dozeUntil = Math.min(Date.now() + tickEveryMs, deadline ?? Number.MAX_SAFE_INTEGER);
7546
+ const theirTurnEnded = () => yieldingTo !== null && yieldingSince !== null && passes.lastPass(yieldingTo) > yieldingSince;
7547
+ while (!stopping() && Date.now() < dozeUntil && store.wakeSeq() === seqIdle && !theirTurnEnded()) {
7548
+ await sleep(paused() ? 250 : 50);
7549
+ }
7550
+ }
7551
+ };
7552
+ const ensureLanes = () => {
7553
+ const facts = slotFacts(null);
7554
+ const wanted = effectiveConcurrency(facts.limitOf(repo), facts.capacity);
7555
+ while (lanes.length + 1 < wanted && !stopping())
7556
+ lanes.push(buildLane(lanes.length + 1));
7557
+ };
7558
+ let firstLaneYieldingSince = null;
7559
+ let firstLaneYieldingTo = null;
7305
7560
  try {
7306
7561
  // Startup recovery still precedes the first dispatch.
7307
7562
  await maintenance.runNow();
@@ -7310,6 +7565,22 @@ async function runWatchLoop(args) {
7310
7565
  await sleep(250);
7311
7566
  continue;
7312
7567
  }
7568
+ ensureLanes();
7569
+ // This lane builds only within the project's number and its fair
7570
+ // share; over either, its pass still does every housekeeping duty.
7571
+ const share = maySlotTake(slotFacts(firstLaneYieldingSince));
7572
+ const yieldBuilds = !share.take && share.why !== "capacity";
7573
+ if (!share.take && share.why === "fair-share") {
7574
+ if (firstLaneYieldingSince === null) {
7575
+ firstLaneYieldingSince = passes.stamp();
7576
+ store.bumpWake();
7577
+ }
7578
+ firstLaneYieldingTo = share.to ?? null;
7579
+ }
7580
+ else {
7581
+ firstLaneYieldingSince = null;
7582
+ firstLaneYieldingTo = null;
7583
+ }
7313
7584
  const seqBefore = store.wakeSeq();
7314
7585
  const now = Date.now();
7315
7586
  // The tick pass runs whenever the loop spins — and the loop only
@@ -7321,7 +7592,8 @@ async function runWatchLoop(args) {
7321
7592
  lastTick = now;
7322
7593
  quiet.length = 0;
7323
7594
  store.expireOverdueDecisions(new Date());
7324
- const code = await tickCommand(passFlags(), quietContext);
7595
+ const code = await tickCommand(passFlags(yieldBuilds ? { "yield-builds": "1" } : {}), quietContext);
7596
+ passes.passed(repo);
7325
7597
  ticks++;
7326
7598
  if (code === EXIT.ok) {
7327
7599
  tickDidWork = true;
@@ -7412,13 +7684,18 @@ async function runWatchLoop(args) {
7412
7684
  // nothing to do). The 50ms poll is what keeps a stop prompt.
7413
7685
  const seqIdle = store.wakeSeq();
7414
7686
  const dozeUntil = Date.now() + Math.max(idleUntil, 0);
7415
- while (!stopping() && Date.now() < dozeUntil && store.wakeSeq() === seqIdle) {
7687
+ const theirTurnEnded = () => firstLaneYieldingTo !== null && firstLaneYieldingSince !== null && passes.lastPass(firstLaneYieldingTo) > firstLaneYieldingSince;
7688
+ while (!stopping() && Date.now() < dozeUntil && store.wakeSeq() === seqIdle && !theirTurnEnded()) {
7416
7689
  await sleep(50);
7417
7690
  }
7418
7691
  }
7419
7692
  }
7420
7693
  finally {
7694
+ // Every build lane finishes or hands its attempt back before the
7695
+ // loop's lease and episode end.
7696
+ await Promise.all(lanes);
7421
7697
  await maintenance.stop();
7698
+ await followUpChecks.stop();
7422
7699
  await modelWatch.stop();
7423
7700
  clearInterval(heartbeat);
7424
7701
  followController.abort();
@@ -7491,7 +7768,7 @@ async function watchCommand(flags, context) {
7491
7768
  const hardStop = () => {
7492
7769
  const terminated = terminateLiveProviders();
7493
7770
  if (terminated > 0) {
7494
- progress(`Hard stop: ${terminated} provider process group(s) terminated. Their runs finalize as failures; worktrees are preserved; fences keep late output out of every commit.`);
7771
+ progress(`Hard stop: ${terminated} provider process group(s) terminated. Their work is saved and kept in each checkout, and each task resumes from it when the builder runs again; fences keep late output out of every commit.`);
7495
7772
  }
7496
7773
  };
7497
7774
  const stop = () => {
@@ -7701,9 +7978,12 @@ async function resolveUpApprover(context, flags, canPrompt) {
7701
7978
  }
7702
7979
  /** The platform browser opener — detached, silent, never load-bearing. */
7703
7980
  function openBrowser(url) {
7704
- const command = process.platform === "darwin" ? ["open", url]
7705
- : process.platform === "win32" ? ["cmd", "/c", "start", "", url]
7706
- : ["xdg-open", url];
7981
+ // TOOLROLL_BROWSER_COMMAND names another program to open it with (it gets the address as its one argument).
7982
+ const chosen = envValue(process.env, "BROWSER_COMMAND");
7983
+ const command = chosen !== undefined && chosen !== "" ? [chosen, url]
7984
+ : process.platform === "darwin" ? ["open", url]
7985
+ : process.platform === "win32" ? ["cmd", "/c", "start", "", url]
7986
+ : ["xdg-open", url];
7707
7987
  try {
7708
7988
  const child = spawnChild(command[0], command.slice(1), { detached: true, stdio: "ignore" });
7709
7989
  child.unref();
@@ -7951,10 +8231,14 @@ async function upCommand(flags, context) {
7951
8231
  if (repos.length === 0 && projectRoots.length === 0) {
7952
8232
  return fail(write, json, "up", "no-projects", "choose where your projects live once: `toolroll up --project-root <dir>`", EXIT.refused);
7953
8233
  }
7954
- for (const repo of repos)
7955
- progress(`repository ${repo}`);
7956
- for (const root of projectRoots)
7957
- progress(`projects ${root}`);
8234
+ // A person at a terminal reads three lines (onboarding); the folders are for --verbose, a service log, or an agent.
8235
+ const quietStart = !json && !flags.has("verbose") && (context.upSeams?.terminal ?? process.stdout.isTTY === true) && !underAgent(context.upSeams?.env ?? process.env);
8236
+ if (!quietStart) {
8237
+ for (const repo of repos)
8238
+ progress(`repository ${repo}`);
8239
+ for (const root of projectRoots)
8240
+ progress(`projects ${root}`);
8241
+ }
7958
8242
  // 3. Reserve the port BEFORE any identity or enrollment mutation
7959
8243
  // (finding 7/19): a busy port must refuse while the world is untouched.
7960
8244
  const probe = createNetServer();
@@ -8128,7 +8412,7 @@ async function upCommand(flags, context) {
8128
8412
  const hardStop = () => {
8129
8413
  const terminated = terminateLiveProviders();
8130
8414
  if (terminated > 0)
8131
- progress(`Hard stop: ${terminated} provider process group(s) terminated.`);
8415
+ progress(`Stopped ${terminated} running build(s). Their work is saved, and each resumes when the builder starts again.`);
8132
8416
  };
8133
8417
  const stop = () => {
8134
8418
  if (stopping) {
@@ -8139,6 +8423,10 @@ async function upCommand(flags, context) {
8139
8423
  resolveStopped();
8140
8424
  for (const controller of followControllers)
8141
8425
  controller.abort();
8426
+ const building = store.liveClaimCount(runnerName, clock());
8427
+ if (building > 0) {
8428
+ progress(`Stopping: ${building} build(s) running. Waiting up to ${Math.round(stopGraceMs / 1000)}s for them to finish; any still running then is saved and resumes when the builder starts again.`);
8429
+ }
8142
8430
  graceTimer = setTimeout(hardStop, stopGraceMs);
8143
8431
  graceTimer.unref?.();
8144
8432
  };
@@ -8175,6 +8463,9 @@ async function upCommand(flags, context) {
8175
8463
  return containmentDetail || effective.refusal !== null ? [`watch: ${describeContainment(effective)}`] : [];
8176
8464
  };
8177
8465
  const loopResults = new Map();
8466
+ // One record of project passes for the whole service: fair scheduling
8467
+ // across its projects (project-concurrency.ts).
8468
+ const projectPasses = new ProjectPasses();
8178
8469
  const launchRepo = async (repo, bind) => {
8179
8470
  if (loopResults.has(repo) || stopping)
8180
8471
  return;
@@ -8202,6 +8493,7 @@ async function upCommand(flags, context) {
8202
8493
  isStopping: () => stopping,
8203
8494
  onFollowController: controller => followControllers.push(controller),
8204
8495
  containmentLines,
8496
+ passes: projectPasses,
8205
8497
  onReady: () => {
8206
8498
  markReady();
8207
8499
  if (bind)
@@ -8324,31 +8616,28 @@ async function upCommand(flags, context) {
8324
8616
  write("Run `toolroll onboard` inside a repository to add it and install the Toolroll skill for your agent.");
8325
8617
  }
8326
8618
  else {
8619
+ // Three lines (onboarding): where it is, that it opens signed in, and what to do if it doesn't. The browser gets a
8620
+ // one-time sign-in link (single use, ten minutes, this computer only); it is never printed. The password stays
8621
+ // saved beside the database, for later and for other devices, and is never printed either.
8622
+ const account = approverPlan2.approver;
8623
+ const link = account === null || flags.has("no-open") ? null : console_.server.mintSignInLink(account);
8624
+ const home = homedir();
8625
+ const file = approverPlan2.passwordFile === null ? null : approverPlan2.passwordFile.startsWith(`${home}/`) ? `~${approverPlan2.passwordFile.slice(home.length)}` : approverPlan2.passwordFile;
8626
+ const signInWords = account === null
8627
+ ? `sign in as one of ${approverPlan2.approvers.join(", ")}`
8628
+ : file === null ? `sign in as ${account} with your password` : `sign in as ${account}: the password is in ${file}`;
8327
8629
  write("");
8328
- write(`The console is on ${url}`);
8329
- if (approverPlan2.mintedPassword !== null && canPrompt) {
8330
- write(` login ${approverPlan2.approver} / ${approverPlan2.mintedPassword}`);
8331
- write(` (also saved to ${approverPlan2.passwordFile})`);
8332
- }
8333
- else if (approverPlan2.passwordFile !== null) {
8334
- write(` login ${approverPlan2.approver} — the password is in ${approverPlan2.passwordFile}`);
8335
- }
8336
- else if (approverPlan2.approver !== null) {
8337
- write(` login ${approverPlan2.approver} — with your password`);
8630
+ write(`Toolroll is on ${url}`);
8631
+ if (link !== null) {
8632
+ write("Opening it in your browser now, already signed in.");
8633
+ write(`If it doesn't open, go to that address and ${signInWords}.`);
8634
+ (context.upSeams?.openBrowser ?? openBrowser)(new URL(link, url).toString());
8338
8635
  }
8339
8636
  else {
8340
- write(` login one of: ${approverPlan2.approvers.join(", ")}`);
8637
+ write(`${signInWords.charAt(0).toUpperCase()}${signInWords.slice(1)}.`);
8638
+ write("Ctrl-C stops it.");
8341
8639
  }
8342
- write(repos.length === 0
8343
- ? ` builder ${runnerName} is ready — add a local folder or GitHub repository in Projects`
8344
- : ` builder watching ${repos.length === 1 ? repos[0] : `${repos.length} repositories`}`);
8345
- if (projectRoots.length > 0)
8346
- write(` projects new repositories under ${projectRoots.length === 1 ? projectRoots[0] : `${projectRoots.length} saved folders`} connect automatically`);
8347
- write(" Chat shows the steps to your first result.");
8348
- write(" Ctrl-C stops Toolroll on this machine.");
8349
8640
  }
8350
- if (!json && !flags.has("no-open") && terminal)
8351
- (context.upSeams?.openBrowser ?? openBrowser)(url);
8352
8641
  }
8353
8642
  // 10. Supervise to the end.
8354
8643
  await stopped;
@@ -9239,7 +9528,23 @@ async function statusCommand(positional, flags, context) {
9239
9528
  const brokenIntegrations = await savedIntegrations(context).then(list => list.filter(one => one.state === "broken"), () => []);
9240
9529
  const brokenLine = integrationsBrokenLine(brokenIntegrations);
9241
9530
  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 })) } };
9242
- return succeed(context.write, context.json, command, { ...status, ...update, ...integrations }, () => [...renderInstallationStatus(status), ...(line === null ? [] : [line]), ...(brokenLine === null ? [] : [brokenLine])]);
9531
+ const projects = projectBuildsStatus(context);
9532
+ const projectsLine = projects.length === 0 ? null
9533
+ : `Builds by project: ${projects.slice(0, 8).map(one => `${one.name} ${one.running} of ${one.limit}`).join(", ")}${projects.length > 8 ? ", …" : ""}`;
9534
+ return succeed(context.write, context.json, command, { ...status, projects, ...update, ...integrations }, () => [...renderInstallationStatus(status), ...(projectsLine === null ? [] : [projectsLine]), ...(line === null ? [] : [line]), ...(brokenLine === null ? [] : [brokenLine])]);
9535
+ }
9536
+ /** Each known project's running builds against its limit: its own number, never past its workers' capacity. */
9537
+ function projectBuildsStatus(context) {
9538
+ const now = context.clock();
9539
+ const running = context.store.runningBuildsByRepo(now);
9540
+ const saved = savedProjectConcurrency(context.databaseFile);
9541
+ const workers = context.store.listRunners().filter(one => one.retiredAt === null);
9542
+ const repos = [...new Set([...context.store.listProjects().map(one => one.path), ...context.store.knownRepos(), ...running.keys()])].sort();
9543
+ return repos.map(repo => {
9544
+ const capacities = workers.filter(one => one.repos.includes(repo)).map(one => one.capacity);
9545
+ const setting = saved.get(repo) ?? PROJECT_CONCURRENCY_DEFAULT;
9546
+ return { repo, name: projectName(repo), running: running.get(repo) ?? 0, limit: capacities.length === 0 ? setting : effectiveConcurrency(setting, Math.max(...capacities)) };
9547
+ });
9243
9548
  }
9244
9549
  /** Where `integrations` looks: the installation's files beside the database, and every project it knows. */
9245
9550
  async function integrationIoFor(context) {
@@ -9351,6 +9656,10 @@ function taskCommand(positional, flags, context) {
9351
9656
  demo: context.store.isDemo(), credentials: () => askCredentials(flags, context), ...(context.publishExec === undefined ? {} : { exec: context.publishExec }) });
9352
9657
  case "add":
9353
9658
  return addTask(rest, flags, context);
9659
+ case "checks":
9660
+ return runTaskChecks(rest, flags, context);
9661
+ case "add-tests":
9662
+ return addTestsTask(rest, flags, context);
9354
9663
  case "list":
9355
9664
  return listTasks(flags, context);
9356
9665
  case "show":
@@ -9361,6 +9670,8 @@ function taskCommand(positional, flags, context) {
9361
9670
  return taskEvidenceCommand(rest, flags, context);
9362
9671
  case "state":
9363
9672
  return stateTask(rest, flags, context);
9673
+ case "ask":
9674
+ return askTask(rest, flags, context);
9364
9675
  case "block":
9365
9676
  return blockTask(rest, flags, context);
9366
9677
  case "unblock":
@@ -9410,6 +9721,10 @@ async function addTask(positional, flags, context) {
9410
9721
  const title = positional.join(" ").trim();
9411
9722
  if (title === "")
9412
9723
  return fail(write, json, "task add", "usage", "a task needs a title", EXIT.usage);
9724
+ // --checks quick|full|off: this task's checks, overriding the project's level.
9725
+ const checks = text(flags, "checks");
9726
+ if (checks !== undefined && !isCheckLevel(checks))
9727
+ return fail(write, json, "task add", "usage", "--checks is quick, full or off", EXIT.usage);
9413
9728
  // The same text rules every filing door applies (Codex adoption review,
9414
9729
  // finding 7): the bare CLI path must not accept a title the console or a
9415
9730
  // template would refuse.
@@ -9471,6 +9786,12 @@ async function addTask(positional, flags, context) {
9471
9786
  ]);
9472
9787
  }
9473
9788
  const id = text(flags, "id") ?? slug(title, now);
9789
+ // Re-filing: the new task replaces an earlier one, which is cancelled and reads "Replaced by <id>".
9790
+ const replaces = text(flags, "replaces");
9791
+ if (replaces !== undefined && (replaces === id || store.getTask(replaces) === null)) {
9792
+ return fail(write, json, "task add", "unknown-task", `no task \`${replaces}\` to replace`, EXIT.refused);
9793
+ }
9794
+ noteActingPerson(flags, context);
9474
9795
  // The existence check goes *inside* the replayed body. Outside it, a retry
9475
9796
  // with the same key hits "already exists" and reports failure for a task the
9476
9797
  // first attempt created — which is precisely the retry idempotency exists to
@@ -9497,12 +9818,91 @@ async function addTask(positional, flags, context) {
9497
9818
  return fail(write, json, "task add", "scoped", "this task already has a scope — placement is immutable once somebody could have approved it", EXIT.refused);
9498
9819
  }
9499
9820
  }
9821
+ if (replaces !== undefined)
9822
+ store.setTaskState(replaces, "cancelled", now, {}, undefined, outcome.task.id);
9823
+ if (checks !== undefined) {
9824
+ const chosen = setTaskCheckLevel(store, outcome.task.id, checks, "cli", now);
9825
+ if (!chosen.ok)
9826
+ return fail(write, json, "task add", "checks-fixed", chosen.message, EXIT.refused);
9827
+ }
9500
9828
  const link = consoleLinkFor(context, `/t/${encodeURIComponent(outcome.task.id)}`);
9501
9829
  return succeed(write, json, "task add", {
9502
9830
  task: outcome.task,
9831
+ ...(replaces === undefined ? {} : { replaces }),
9503
9832
  repo: placedIn === undefined ? null : resolve(placedIn),
9833
+ ...(checks === undefined ? {} : { checks }),
9504
9834
  ...(link === null ? {} : { links: { task: link } }),
9505
- }, () => [`Queued ${outcome.task.id} — ${outcome.task.title}${flags.has("report") ? " (a scout task: it delivers a report)" : ""}`, ...(link === null ? [] : [` ${link}`])]);
9835
+ }, () => [`Queued ${outcome.task.id} — ${outcome.task.title}${flags.has("report") ? " (a scout task: it delivers a report)" : ""}${checks === undefined ? "" : ` · checks: ${CHECK_LEVEL_WORDS[checks]}`}`, ...(replaces === undefined ? [] : [` It replaces ${replaces}.`]), ...(link === null ? [] : [` ${link}`])]);
9836
+ }
9837
+ /** The newest finished build result of a task (any version), for follow-ups. */
9838
+ function latestBuildResult(store, taskId) {
9839
+ const family = store.taskFamilyOf(taskId, null, true);
9840
+ const versions = family === null ? [taskId] : [family.current.id, ...family.versions.map(one => one.id).filter(id => id !== family.current.id)];
9841
+ for (const id of versions) {
9842
+ const ref = store.lookupRef(id);
9843
+ const run = ref === null ? undefined : store.runsFor(ref.id).find(one => one.role === "builder" && one.finishedAt !== null && one.headRevision !== null);
9844
+ if (run !== undefined)
9845
+ return { runId: run.id, head: run.headRevision };
9846
+ }
9847
+ return null;
9848
+ }
9849
+ /**
9850
+ * `toolroll task checks <task> [--level quick|full]` — Run checks on the
9851
+ * result's exact commit, now, in the foreground. A pass upgrades what the
9852
+ * result says; a failure stays visible. Asking again while one runs is the same ask.
9853
+ */
9854
+ async function runTaskChecks(positional, flags, context) {
9855
+ const { store, write, json } = context;
9856
+ const clock = context.clock ?? (() => new Date());
9857
+ const taskId = positional[0];
9858
+ if (taskId === undefined)
9859
+ return fail(write, json, "task checks", "usage", "`toolroll task checks <task> [--level quick|full]`", EXIT.usage);
9860
+ const level = text(flags, "level") ?? "quick";
9861
+ if (level !== "quick" && level !== "full")
9862
+ return fail(write, json, "task checks", "usage", "--level is quick or full", EXIT.usage);
9863
+ const demoFence = refuseDemo(context, "task checks");
9864
+ if (demoFence !== null)
9865
+ return demoFence;
9866
+ const result = latestBuildResult(store, taskId);
9867
+ if (result === null)
9868
+ return fail(write, json, "task checks", "no-result", `${taskId} has no finished result to check`, EXIT.refused);
9869
+ const asked = requestFollowUpChecks(store, { runId: result.runId, level, actor: "cli" }, clock());
9870
+ if (!asked.ok)
9871
+ return fail(write, json, "task checks", asked.reason, asked.message, EXIT.refused);
9872
+ const done = await runFollowUpCheck(store, context.evidenceRoot, asked.request, { now: clock });
9873
+ const state = done?.state ?? "waiting";
9874
+ const words = `${CHECK_LEVEL_WORDS[asked.level]} checks`;
9875
+ const payload = { task: taskId, run: result.runId, head: result.head, request: asked.request, level: asked.level, state, exitCode: done?.exitCode ?? null };
9876
+ if (state === "failed") {
9877
+ if (json) {
9878
+ write(envelopeJson({ ok: false, command: "task checks", reason: "checks-failed", ...payload }));
9879
+ return EXIT.failed;
9880
+ }
9881
+ write(`${words} failed on ${result.head.slice(0, 7)}${done?.exitCode == null ? "" : ` (exit ${done.exitCode})`}. The result shows the failure.`);
9882
+ return EXIT.failed;
9883
+ }
9884
+ return succeed(write, json, "task checks", payload, () => [
9885
+ state === "passed" ? `${words} passed on ${result.head.slice(0, 7)}. The result's status is upgraded.`
9886
+ : state === "running" ? `${words} are already running on ${result.head.slice(0, 7)}.`
9887
+ : state === "not-run" ? `${words} didn't run: ${done?.note ?? "see the result's log"}`
9888
+ : `${words} are queued on ${result.head.slice(0, 7)}.`,
9889
+ ]);
9890
+ }
9891
+ /** `toolroll task add-tests <task>` — file a small task to write tests for its change. */
9892
+ function addTestsTask(positional, _flags, context) {
9893
+ const { store, write, json, now } = context;
9894
+ const taskId = positional[0];
9895
+ if (taskId === undefined)
9896
+ return fail(write, json, "task add-tests", "usage", "`toolroll task add-tests <task>`", EXIT.usage);
9897
+ const result = latestBuildResult(store, taskId);
9898
+ if (result === null)
9899
+ return fail(write, json, "task add-tests", "no-result", `${taskId} has no finished result to write tests for`, EXIT.refused);
9900
+ const filed = fileAddTestsTask(store, context.evidenceRoot, { runId: result.runId, actor: "cli", filedVia: "cli" }, now);
9901
+ if (!filed.ok)
9902
+ return fail(write, json, "task add-tests", filed.reason, filed.message, EXIT.refused);
9903
+ return succeed(write, json, "task add-tests", { task: taskId, filed: filed.id, existing: filed.existing }, () => [
9904
+ filed.existing ? `Tests for ${taskId} are already filed as ${filed.id}.` : `Filed ${filed.id} to add tests for ${taskId}. Approve its scope to start.`,
9905
+ ]);
9506
9906
  }
9507
9907
  /**
9508
9908
  * Say what a task needs before it may run: capability keys, `kind:name`.
@@ -9763,6 +10163,9 @@ function showTask(positional, context) {
9763
10163
  // v50: the latest build's bounded review history — every root attempt
9764
10164
  // in order, the open request, and the one state they add up to.
9765
10165
  review: latestFinished === null ? null : store.reviewRetryStateOf(latestFinished.id),
10166
+ // The one automatic review of the latest build: HIGH findings, suggested
10167
+ // follow-ups (MEDIUM/LOW), or why it was not reviewed.
10168
+ automaticReview: latestFinished === null ? null : buildReviewOf(store, latestFinished.id),
9766
10169
  dispatch: diagnoseTaskDispatch(store, id, now),
9767
10170
  // v52: the exact-run control the console shows — Stop, Stopping,
9768
10171
  // Paused (resume), or the review-retry door — and every stop on record.
@@ -9815,7 +10218,7 @@ function showTask(positional, context) {
9815
10218
  : detail.control.kind === "paused"
9816
10219
  ? [` control: paused — run #${detail.control.run} was stopped by ${detail.control.stop.requestedBy} (${detail.control.stop.settlement ?? "?"})${detail.control.committed ? "; its commit is on the branch" : ""}; work preserved${detail.control.worktree === null ? "" : ` in ${detail.control.worktree}`} — \`task resume ${task.id} --run ${detail.control.run} --as <you> --token <t>\``]
9817
10220
  : [` control: review #${detail.control.run} was stopped by ${detail.control.stop.requestedBy} — inspect its saved result and give feedback; separate model review has been removed`]),
9818
- ...reviewStatusLines(detail.review, latestFinished?.id ?? null),
10221
+ ...(detail.automaticReview === null ? reviewStatusLines(detail.review, latestFinished?.id ?? null) : buildReviewLines(detail.automaticReview)),
9819
10222
  ...(scope === null
9820
10223
  ? [" no scope — nothing will build this until one is written and approved"]
9821
10224
  : describeScope(scope, readiness)),
@@ -10006,7 +10409,15 @@ async function stateTask(positional, flags, context) {
10006
10409
  `${id} is now ${state} in ${backendName}.`,
10007
10410
  ]);
10008
10411
  }
10009
- const moved = store.setTaskState(id, state, now, mutationFrom(flags, now), text(flags, "reason"));
10412
+ const replacedBy = text(flags, "replaced-by");
10413
+ if (replacedBy !== undefined) {
10414
+ if (state !== "cancelled")
10415
+ return fail(write, json, "task state", "usage", "--replaced-by goes with cancelled: `task state <id> cancelled --replaced-by <id>`", EXIT.usage);
10416
+ if (replacedBy === id || store.getTask(replacedBy) === null)
10417
+ return fail(write, json, "task state", "unknown-task", `no task \`${replacedBy}\` to replace it`, EXIT.refused);
10418
+ }
10419
+ noteActingPerson(flags, context);
10420
+ const moved = store.setTaskState(id, state, now, mutationFrom(flags, now), text(flags, "reason"), replacedBy);
10010
10421
  if (!moved.ok) {
10011
10422
  if (moved.reason === "reason-required" || moved.reason === "bad-reason") {
10012
10423
  return fail(write, json, "task state", moved.reason, moved.reason === "reason-required"
@@ -10017,7 +10428,72 @@ async function stateTask(positional, flags, context) {
10017
10428
  ? fail(write, json, "task state", "external-closed", "the tracker closed this — reopen it first, or leave it cancelled", EXIT.refused)
10018
10429
  : fail(write, json, "task state", "unknown-task", `no task \`${id}\``, EXIT.refused);
10019
10430
  }
10020
- return succeed(write, json, "task state", { id, state }, () => [`${id} is now ${state}.`]);
10431
+ return succeed(write, json, "task state", { id, state, ...(replacedBy === undefined ? {} : { replacedBy }) }, () => [replacedBy === undefined ? `${id} is now ${state}.` : `${id} is replaced by ${replacedBy}.`]);
10432
+ }
10433
+ /** Who acts in this command, for pings: a lead is already known; a person only by a sign-in that verifies here (never asked for). */
10434
+ function noteActingPerson(flags, context) {
10435
+ if (currentActor() !== null)
10436
+ return;
10437
+ const name = text(flags, "as"), token = text(flags, "token");
10438
+ // A typed password is checked (a success names the person); the remembered login was checked when it was saved.
10439
+ if (token !== undefined) {
10440
+ if (name !== undefined && name !== "" && token !== "")
10441
+ authenticateAccount(context.store, name, token);
10442
+ return;
10443
+ }
10444
+ const remembered = readLoginFile(join(dirname(context.databaseFile), UP_LOGIN_FILE));
10445
+ if (remembered !== null && (name === undefined || name === remembered.name))
10446
+ claimActor(remembered.name);
10447
+ }
10448
+ /** `task ask <id> --person <name> --why "…"`: the lead hands a task to a person. It pings them once, and the task is theirs. */
10449
+ function askTask(positional, flags, context) {
10450
+ const { store, write, json, now } = context;
10451
+ const [id, ...extra] = positional;
10452
+ const person = text(flags, "person"), why = text(flags, "why")?.trim();
10453
+ if (id === undefined || extra.length > 0 || person === undefined || why === undefined || why === "") {
10454
+ return fail(write, json, "task ask", "usage", "`toolroll task ask <id> --person <name> --why \"what they need to do\"`", EXIT.usage);
10455
+ }
10456
+ if (why.length > 500 || /[\u0000-\u001f\u007f]/.test(why))
10457
+ return fail(write, json, "task ask", "usage", "--why is at most 500 plain characters on one line", EXIT.usage);
10458
+ noteActingPerson(flags, context);
10459
+ if (currentActor() === null)
10460
+ return fail(write, json, "task ask", "unauthenticated", "Hand a task on as the lead (its lead token) or with your sign-in (--as and --token, or the remembered login).", EXIT.refused);
10461
+ const asked = store.askPerson(id, person, why, now);
10462
+ if (!asked.ok) {
10463
+ return fail(write, json, "task ask", asked.reason, asked.reason === "unknown-task" ? `no task \`${id}\``
10464
+ : asked.reason === "unplaced" ? `${id} has no project yet, so nobody can be asked about it` : `${person} is not someone who can see this task's project`, EXIT.refused);
10465
+ }
10466
+ return succeed(write, json, "task ask", { id, person, why }, () => [`Asked ${person} about ${id}. It is theirs now, and its updates reach them.`]);
10467
+ }
10468
+ /** `lead token [--revoke]`: the owner (behind their password) mints the credential their lead acts with. */
10469
+ async function leadCommand(positional, flags, context) {
10470
+ const { store, write, json } = context;
10471
+ const [action, ...extra] = positional;
10472
+ const command = "lead token";
10473
+ if (action !== "token" || extra.length > 0)
10474
+ return fail(write, json, `lead ${action ?? ""}`.trim(), "usage", "`toolroll lead token [--revoke] --as <you> --token <password>`", EXIT.usage);
10475
+ for (const name of flags.keys())
10476
+ if (!["as", "token", "revoke", "db", "json"].includes(name))
10477
+ return fail(write, json, command, "usage", `--${name} is not a lead token option.`, EXIT.usage);
10478
+ if (currentActor()?.lead === true)
10479
+ return fail(write, json, command, "refused", "A lead cannot mint lead tokens. The person it acts for runs this with their password.", EXIT.refused);
10480
+ const acting = await askCredentials(flags, context);
10481
+ const verified = acting === null ? null : authenticateApprover(store, acting.name, acting.token);
10482
+ if (acting === null || verified === null || !verified.ok) {
10483
+ return fail(write, json, command, "unauthenticated", "A lead token is minted behind your password: pass --as and --token (or use the remembered login).", EXIT.refused);
10484
+ }
10485
+ const now = context.clock();
10486
+ if (flags.has("revoke")) {
10487
+ const ended = store.revokeLeadCredentials(acting.name, acting.name, now);
10488
+ return succeed(write, json, command, { owner: acting.name, revoked: ended }, () => [ended === 0 ? "You had no lead token." : "Your lead token no longer works."]);
10489
+ }
10490
+ const minted = store.mintLeadCredential(acting.name, acting.name, now);
10491
+ return succeed(write, json, command, { owner: acting.name, id: minted.id, token: minted.token, actor: `lead for ${acting.name}` }, () => [
10492
+ minted.token,
10493
+ `Lead for ${acting.name}. Shown once; it replaces any earlier lead token.`,
10494
+ "Your lead passes it as --token <it> (or TOOLROLL_LEAD_TOKEN) on task and assignment commands. Its own work pings nobody;",
10495
+ "it reaches you when the lead asks you (task ask), when it fails with nothing left to try, or for a security alert.",
10496
+ ]);
10021
10497
  }
10022
10498
  function blockTask(positional, flags, context) {
10023
10499
  const { store, write, json } = context;
@@ -10971,6 +11447,10 @@ async function chatCommand(flags, context) {
10971
11447
  * win, and a different --as than the remembered name is not answered.
10972
11448
  */
10973
11449
  function credentialsFrom(flags, context) {
11450
+ // The lead signs in as its owner with its own token, never the owner's password.
11451
+ const lead = currentActor();
11452
+ if (context.leadToken !== undefined && lead?.lead === true)
11453
+ return { name: lead.account, token: context.leadToken };
10974
11454
  let name = text(flags, "as");
10975
11455
  let token = text(flags, "token");
10976
11456
  if (token === undefined) {
@@ -10997,11 +11477,12 @@ async function flowsCommand(positional, flags, context) {
10997
11477
  const { store } = context;
10998
11478
  const registered = await loadRepos(registryPathOf(context)).catch(() => ({ error: "unreadable" }));
10999
11479
  const projects = [...new Set([...store.knownRepos(), ...store.listProjects().map(one => one.path), ...("error" in registered ? [] : registered.repos)])];
11000
- const writes = positional[0] !== undefined && positional[0] !== "list" && positional[0] !== "show";
11480
+ const writes = positional[0] !== undefined && positional[0] !== "list" && positional[0] !== "show" && positional[0] !== "export";
11001
11481
  const acting = writes && !flags.has("help") ? await askCredentials(flags, context) : null;
11002
11482
  const dir = dirname(context.databaseFile);
11003
11483
  return runFlowsCommand(positional, flags, {
11004
11484
  store, write: context.write, json: context.json, clock: context.clock, credentials: acting, projects, configDir: dir, evidenceRoot: context.evidenceRoot,
11485
+ ...(context.flowFetch === undefined ? {} : { fetchFlow: context.flowFetch }),
11005
11486
  // "Check now": the same io the worker's pass checks triggers with.
11006
11487
  triggerIo: { gh: context.flowTriggerIo?.gh ?? run, fetch: context.flowTriggerIo?.fetch ?? fetch, dir: context.flowTriggerIo?.dir ?? dir,
11007
11488
  shell: context.flowTriggerIo?.shell ?? context.flowStepIo?.shell ?? run, scratch: context.flowTriggerIo?.scratch ?? join(dir, "flow-scratch"),
@@ -11200,6 +11681,48 @@ async function resumeTaskCommand(positional, flags, context) {
11200
11681
  : ` Not starting yet — ${resumed.gate.summary}: ${resumed.gate.detail}`,
11201
11682
  ]);
11202
11683
  }
11684
+ /**
11685
+ * `toolroll run settle <id> --why "<text>" --as <you> --token <t>`: an
11686
+ * approver's last resort for a finished run whose process witnesses can be
11687
+ * proven neither exited nor alive (a spawn interrupted before its pid was
11688
+ * written). Refuses while anything of the run is alive; the reason is kept
11689
+ * in the ledger against the approver's name. Signals nothing.
11690
+ */
11691
+ async function runCommand(positional, flags, context) {
11692
+ const { store, write, json, clock } = context;
11693
+ const [action, id, ...extra] = positional;
11694
+ const command = "run settle";
11695
+ const usage = "`toolroll run settle <run-id> --why \"<reason>\" --as <you> --token <t>`";
11696
+ if (action !== "settle")
11697
+ return fail(write, json, "run", "usage", usage, EXIT.usage);
11698
+ const runId = Number(id ?? "");
11699
+ const why = text(flags, "why")?.trim();
11700
+ if (id === undefined || extra.length > 0 || !/^[1-9]\d*$/.test(id) || !Number.isSafeInteger(runId))
11701
+ return fail(write, json, command, "usage", usage, EXIT.usage);
11702
+ if (why === undefined || why === "" || why.length > 300 || /[\x00-\x1f\x7f]/.test(why)) {
11703
+ return fail(write, json, command, "usage", "say why in one line with --why (at most 300 characters) — it is kept in the ledger", EXIT.usage);
11704
+ }
11705
+ const run = store.getRun(runId);
11706
+ if (run === null)
11707
+ return fail(write, json, command, "unknown-run", `no run #${runId}`, EXIT.refused);
11708
+ const acting = await askCredentials(flags, context);
11709
+ if (acting === null) {
11710
+ return fail(write, json, command, "usage", "settling takes `--as <you> --token <t>` — it is an approver's act and is recorded against your name", EXIT.usage);
11711
+ }
11712
+ const authenticated = authenticateApprover(store, acting.name, acting.token);
11713
+ const repo = store.refById(run.taskRef)?.repo ?? null;
11714
+ if (!authenticated.ok || !store.accountCanAccess(acting.name, repo)) {
11715
+ return fail(write, json, command, "not-an-approver", "that is not an approver for this run's project, or the token does not match", EXIT.refused);
11716
+ }
11717
+ const settled = store.settleRunWitnessesByApprover({ runId, by: acting.name, why }, clock());
11718
+ if (!settled.ok)
11719
+ return fail(write, json, command, settled.reason, settled.detail, EXIT.refused, { run: runId });
11720
+ return succeed(write, json, command, { run: runId, witnesses: settled.witnesses, repeated: settled.repeated }, () => [
11721
+ settled.repeated
11722
+ ? `Run #${runId} has no unsettled process witness; nothing changed.`
11723
+ : `Settled run #${runId}: ${settled.witnesses.length} process witness${settled.witnesses.length === 1 ? "" : "es"} recorded as ended, with your reason in the ledger.`,
11724
+ ]);
11725
+ }
11203
11726
  // ---- shared ---------------------------------------------------------------
11204
11727
  /**
11205
11728
  * `project rules --repo <path>` shows a project's approval rules (v102);
@@ -11250,6 +11773,45 @@ async function projectRulesCommand(positional, flags, context) {
11250
11773
  context.store.setApprovalRules(repo, next, acting.name, context.clock());
11251
11774
  return succeed(context.write, context.json, command, { repo, rules: next }, () => [`${repo}: ${rulesSummary(next)}`]);
11252
11775
  }
11776
+ /** `project concurrency [<n>] --repo <p>`: how many of a project's tasks build at once. Changing it is an approver's act. */
11777
+ async function projectConcurrencyCommand(positional, flags, context) {
11778
+ const command = "project concurrency";
11779
+ const allowed = new Set(["repo", "as", "token", "token-file", "token-env", "db", "json"]);
11780
+ for (const name of flags.keys())
11781
+ if (!allowed.has(name))
11782
+ return fail(context.write, context.json, command, "usage", `--${name} is not a project concurrency option.`, EXIT.usage);
11783
+ const repoFlag = text(flags, "repo");
11784
+ if (positional.length > 2 || repoFlag === undefined)
11785
+ return fail(context.write, context.json, command, "usage", "Use project concurrency [<n>] --repo <project path>.", EXIT.usage);
11786
+ const registered = await loadRepos(registryPathOf(context)).catch(() => ({ error: "unreadable" }));
11787
+ const known = [...new Set([...context.store.knownRepos(), ...("error" in registered ? [] : registered.repos)])];
11788
+ const repo = known.find(one => one === repoFlag || one === resolve(repoFlag) || one === canonicalProject(repoFlag));
11789
+ if (repo === undefined)
11790
+ return fail(context.write, context.json, command, "not-found", "That isn't a project Toolroll knows.", EXIT.refused);
11791
+ const workers = context.store.listRunners().filter(one => one.retiredAt === null && one.repos.includes(repo));
11792
+ const capacity = workers.length === 0 ? null : Math.max(...workers.map(one => one.capacity));
11793
+ const words = (n) => `${repo}: builds up to ${n} at once${capacity !== null && capacity < n ? ` (its worker runs ${capacity} at once, so ${capacity} for now)` : ""}.`;
11794
+ const given = positional[1];
11795
+ if (given === undefined) {
11796
+ const current = projectConcurrency(context.databaseFile, repo);
11797
+ return succeed(context.write, context.json, command, { repo, concurrency: current, workerCapacity: capacity }, () => [words(current)]);
11798
+ }
11799
+ const n = parseProjectConcurrency(given);
11800
+ if (n === null)
11801
+ return fail(context.write, context.json, command, "usage", `Builds at once is a whole number from 1 to ${RUNNER_CAPACITY_MAX}.`, EXIT.usage);
11802
+ const acting = await askCredentials(flags, context);
11803
+ const verified = acting === null ? null : authenticateApprover(context.store, acting.name, acting.token);
11804
+ if (acting === null || verified === null || !verified.ok || !context.store.accountCanAccess(acting.name, repo)) {
11805
+ return fail(context.write, context.json, command, "refused", "An approver for this project changes how many tasks build at once: pass --as and --token (or use the remembered login).", EXIT.refused);
11806
+ }
11807
+ const changed = saveProjectConcurrency(context.databaseFile, repo, n);
11808
+ context.store.recordProjectConcurrency(acting.name, repo, changed.before, changed.after, context.clock());
11809
+ context.store.bumpWake();
11810
+ return succeed(context.write, context.json, command, { repo, before: changed.before, concurrency: changed.after, workerCapacity: capacity }, () => [
11811
+ words(changed.after),
11812
+ ...(changed.before > changed.after ? ["Builds already running carry on; the new number applies to the next one."] : []),
11813
+ ]);
11814
+ }
11253
11815
  /** Write a pack or export where asked (never over an existing file), or to the terminal. */
11254
11816
  function writeOut(context, command, out, content, summary, line, data) {
11255
11817
  if (out === undefined) {
@@ -11474,11 +12036,12 @@ async function notificationsCommand(positional, flags, context) {
11474
12036
  const [action, value, ...extra] = positional;
11475
12037
  const command = `notifications${action === undefined ? "" : ` ${action}`}`;
11476
12038
  const store = context.store;
12039
+ const muting = action === "mute" || action === "unmute";
11477
12040
  for (const name of flags.keys())
11478
- if (!["as", "token", "token-file", "token-env", "db", "json"].includes(name))
12041
+ if (!["as", "token", "token-file", "token-env", "db", "json", ...(muting ? ["repo"] : [])].includes(name))
11479
12042
  return fail(context.write, context.json, command, "usage", `--${name} is not a notifications option.`, EXIT.usage);
11480
- const usage = "Use notifications quiet, notifications all, or notifications digest <HH:MM>|off.";
11481
- if (extra.length > 0 || (action !== undefined && !["quiet", "all", "digest"].includes(action)) || (action === "digest") !== (value !== undefined)) {
12043
+ const usage = "Use notifications quiet, notifications all, notifications digest <HH:MM>|off, or notifications mute|unmute --repo <project>.";
12044
+ if (extra.length > 0 || (action !== undefined && !["quiet", "all", "digest", "mute", "unmute"].includes(action)) || (action === "digest") !== (value !== undefined) || (muting && (value !== undefined || text(flags, "repo") === undefined))) {
11482
12045
  return fail(context.write, context.json, command, "usage", usage, EXIT.usage);
11483
12046
  }
11484
12047
  if (action === "digest" && value !== "off" && !isDigestTime(value))
@@ -11489,12 +12052,26 @@ async function notificationsCommand(positional, flags, context) {
11489
12052
  return fail(context.write, context.json, command, "refused", "Notifications are set per person: pass --as and --token (or use the remembered login).", EXIT.refused);
11490
12053
  }
11491
12054
  const now = context.clock();
12055
+ if (muting) {
12056
+ const typed = text(flags, "repo");
12057
+ const repo = canonicalProject(typed) ?? resolve(typed);
12058
+ const known = new Set([...store.knownRepos(), ...store.listProjects().map(one => one.path)]);
12059
+ if (!known.has(repo) || !store.accountCanAccess(acting.name, repo))
12060
+ return fail(context.write, context.json, command, "unknown-project", `${typed} is not a project you can see.`, EXIT.refused);
12061
+ store.setProjectMuted(acting.name, repo, action === "mute", now);
12062
+ const muted = store.mutedProjects(acting.name);
12063
+ return succeed(context.write, context.json, command, { repo, muted: action === "mute", mutedProjects: muted }, () => [
12064
+ action === "mute" ? `${basename(repo)} is muted: no pings for it. The console and your evening digest still show it.` : `${basename(repo)} pings you again.`,
12065
+ ]);
12066
+ }
11492
12067
  const preference = action === "quiet" || action === "all" ? store.setNotificationPreference(acting.name, { mode: action }, acting.name, now)
11493
12068
  : action === "digest" ? store.setNotificationPreference(acting.name, { digestAt: value === "off" ? null : value }, acting.name, now)
11494
12069
  : store.notificationPreference(acting.name);
11495
- return succeed(context.write, context.json, command, { mode: preference.mode, digestAt: preference.digestAt }, () => [
12070
+ const muted = store.mutedProjects(acting.name);
12071
+ return succeed(context.write, context.json, command, { mode: preference.mode, digestAt: preference.digestAt, mutedProjects: muted }, () => [
11496
12072
  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.",
11497
12073
  preference.digestAt === null ? "No evening digest." : `Evening digest at ${preference.digestAt}: what finished, what waits and what failed.`,
12074
+ ...(muted.length === 0 ? [] : [`Muted: ${muted.map(one => basename(one)).join(", ")}.`]),
11498
12075
  ]);
11499
12076
  }
11500
12077
  /** `retention show | preview | set <kind> <period>` (v105): how long each kind of data is kept, and what the daily
@@ -11510,9 +12087,11 @@ async function retentionCommand(positional, flags, context) {
11510
12087
  const label = (kind) => RETENTION_KINDS.find(one => one.kind === kind).label;
11511
12088
  if (action === undefined || action === "show") {
11512
12089
  const periods = store.retentionPeriods();
12090
+ const chosen = store.retentionChosen();
12091
+ const defaulted = RETENTION_KINDS.map(one => one.kind).filter(kind => !(kind in chosen));
11513
12092
  const last = lastSweepAt(store);
11514
- return succeed(context.write, context.json, command, { periods, lastSweep: last }, () => [
11515
- ...RETENTION_KINDS.map(one => ` ${one.kind.padEnd(14)} ${periodWords(periods[one.kind]).padEnd(9)} ${one.label}`),
12093
+ return succeed(context.write, context.json, command, { periods, defaulted, lastSweep: last }, () => [
12094
+ ...RETENTION_KINDS.map(one => ` ${one.kind.padEnd(14)} ${periodLabel(one.kind, periods[one.kind], one.kind in chosen).toLowerCase().padEnd(18)} ${one.label}`),
11516
12095
  last === null ? "No sweep yet; the worker runs one a day." : `Last sweep ${last.slice(0, 16).replace("T", " ")} UTC.`,
11517
12096
  ]);
11518
12097
  }
@@ -11528,11 +12107,11 @@ async function retentionCommand(positional, flags, context) {
11528
12107
  return fail(context.write, context.json, command, "usage", "Use retention show, retention preview or retention set <kind> <period>.", EXIT.usage);
11529
12108
  const [kind, period, ...extra] = rest;
11530
12109
  if (kind === undefined || !isRetentionKind(kind) || period === undefined || extra.length > 0) {
11531
- return fail(context.write, context.json, command, "usage", `Use retention set <${RETENTION_KINDS.map(one => one.kind).join("|")}> <days, like 90d or 1y, or forever>.`, EXIT.usage);
12110
+ return fail(context.write, context.json, command, "usage", `Use retention set <${RETENTION_KINDS.map(one => one.kind).join("|")}> <days, like 1d, 90d or 1y, or forever>.`, EXIT.usage);
11532
12111
  }
11533
12112
  const days = parsePeriod(period);
11534
12113
  if (days === undefined)
11535
- return fail(context.write, context.json, command, "usage", `A period is forever, or ${MIN_DAYS} days to 10 years (90d, 12w, 1y).`, EXIT.usage);
12114
+ return fail(context.write, context.json, command, "usage", `A period is forever, or ${MIN_DAYS} day to 10 years (1d, 90d, 12w, 1y).`, EXIT.usage);
11536
12115
  const acting = await askCredentials(flags, context);
11537
12116
  const verified = acting === null ? null : authenticateApprover(store, acting.name, acting.token);
11538
12117
  if (acting === null || verified === null || !verified.ok || !store.isInstanceOperator(acting.name)) {
@@ -11632,6 +12211,40 @@ async function storageCommand(positional, flags, context) {
11632
12211
  function checkoutSummary(plan) {
11633
12212
  return { count: plan.count, totalBytes: plan.totalBytes, waitingReview: plan.waitingReview, withChanges: plan.withChanges, cleanBytes: plan.freeBytes, cleanCount: plan.go.length, cleanup: plan.cleanup };
11634
12213
  }
12214
+ /** `review [show] | on | off --repo <path>`: the project's one automatic review
12215
+ * of finished builds. Changing it takes an approver of that project; the
12216
+ * policy log keeps before → after. */
12217
+ async function reviewSwitchCommand(positional, flags, context) {
12218
+ const { store, write, json } = context;
12219
+ const [action = "show", ...extra] = positional;
12220
+ const command = `review ${action}`;
12221
+ if (extra.length > 0 || !["show", "on", "off"].includes(action))
12222
+ return fail(write, json, command, "usage", "Use toolroll review show|on|off --repo <path>.", EXIT.usage);
12223
+ const allowed = new Set(["repo", "db", "json", ...(action === "show" ? [] : ["as", "token", "token-file", "token-env"])]);
12224
+ for (const name of flags.keys())
12225
+ if (!allowed.has(name))
12226
+ return fail(write, json, command, "usage", `--${name} is not a ${command} option.`, EXIT.usage);
12227
+ const given = text(flags, "repo");
12228
+ if (given === undefined)
12229
+ return fail(write, json, command, "usage", "Name the project: toolroll review " + action + " --repo <path>.", EXIT.usage);
12230
+ const repo = canonicalProject(given) ?? resolve(given);
12231
+ const words = (on, source) => `${projectName(repo)}: automatic review is ${on ? "on" : "off"}${source === "hands-off" ? " (on while the hands-off mode lasts)" : source === "default" ? " (the default)" : ""}.`;
12232
+ if (action === "show") {
12233
+ const state = store.reviewSwitch(repo, context.clock());
12234
+ return succeed(write, json, command, { repo, ...state }, () => [words(state.on, state.source)]);
12235
+ }
12236
+ const acting = await askCredentials(flags, context);
12237
+ if (acting === null)
12238
+ return fail(write, json, command, "usage", `Sign in first, or pass --as <you> --token <t>: toolroll review ${action} --repo <path>.`, EXIT.usage);
12239
+ const verified = authenticateApprover(store, acting.name, acting.token, repo);
12240
+ if (!verified.ok)
12241
+ return fail(write, json, command, verified.reason, `Only an approver of ${projectName(repo)} can change its review switch.`, EXIT.refused);
12242
+ const changed = store.setReviewSwitch(repo, action === "on", acting.name, context.clock());
12243
+ return succeed(write, json, command, { repo, ...changed.after, before: changed.before }, () => [
12244
+ words(changed.after.on, changed.after.source),
12245
+ changed.after.on ? "Each finished build whose check passes gets one read-only review; only HIGH findings send it back, once." : "Finished builds go straight to you.",
12246
+ ]);
12247
+ }
11635
12248
  /** `policy show | set` (sprint 8): the organisation policy. Setting it takes an instance operator's credentials; each rule
11636
12249
  * that changed is in the ledger, before → after. A flag left out keeps its rule; "any" lifts it. */
11637
12250
  async function policyCommand(positional, flags, context) {