toolroll 0.9.5 → 0.9.7

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 (92) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/actor.d.ts +21 -0
  3. package/dist/actor.js +48 -0
  4. package/dist/assignment-adapters.d.ts +1 -0
  5. package/dist/assignment-presentation.d.ts +13 -2
  6. package/dist/assignment-presentation.js +22 -9
  7. package/dist/assignment-ui.d.ts +10 -15
  8. package/dist/assignment-ui.js +19 -18
  9. package/dist/assignment.d.ts +9 -0
  10. package/dist/assignment.js +62 -13
  11. package/dist/browser/workspace.css +2 -2
  12. package/dist/browser/workspace.js +29 -24
  13. package/dist/browser-workspace.d.ts +120 -12
  14. package/dist/builder.d.ts +1 -0
  15. package/dist/builder.js +33 -13
  16. package/dist/chat-acceptance.js +2 -2
  17. package/dist/chat-actions.js +2 -1
  18. package/dist/chat-delivery.js +2 -0
  19. package/dist/chat-quiet.js +30 -19
  20. package/dist/chat-task-actions.d.ts +13 -0
  21. package/dist/chat-task-actions.js +19 -1
  22. package/dist/check-levels-ui.d.ts +21 -0
  23. package/dist/check-levels-ui.js +70 -0
  24. package/dist/check-levels.d.ts +67 -0
  25. package/dist/check-levels.js +171 -0
  26. package/dist/claim.d.ts +22 -0
  27. package/dist/claim.js +43 -0
  28. package/dist/cli.d.ts +1 -1
  29. package/dist/cli.js +4 -0
  30. package/dist/demo-chat.d.ts +0 -5
  31. package/dist/demo-chat.js +14 -3
  32. package/dist/dispatch.js +7 -2
  33. package/dist/dispose.d.ts +8 -0
  34. package/dist/dispose.js +7 -1
  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/lead-context.js +6 -2
  54. package/dist/lead-status.d.ts +7 -0
  55. package/dist/lead-status.js +11 -2
  56. package/dist/mate-doors.d.ts +0 -15
  57. package/dist/mate-doors.js +5 -1
  58. package/dist/mate-tools.js +9 -4
  59. package/dist/models-ui.js +1 -1
  60. package/dist/operate.d.ts +4 -1
  61. package/dist/operate.js +601 -85
  62. package/dist/project-concurrency.d.ts +62 -0
  63. package/dist/project-concurrency.js +116 -0
  64. package/dist/project-delete-ui.d.ts +7 -0
  65. package/dist/project-delete-ui.js +19 -2
  66. package/dist/project-delete.js +4 -0
  67. package/dist/pull-request-flow.d.ts +9 -0
  68. package/dist/pull-request-flow.js +21 -3
  69. package/dist/result-follow-ups.d.ts +101 -0
  70. package/dist/result-follow-ups.js +333 -0
  71. package/dist/scope.js +11 -0
  72. package/dist/serve.d.ts +211 -2
  73. package/dist/serve.js +1070 -204
  74. package/dist/store.d.ts +76 -3
  75. package/dist/store.js +311 -22
  76. package/dist/surface.js +31 -8
  77. package/dist/tailnet.d.ts +11 -0
  78. package/dist/tailnet.js +47 -0
  79. package/dist/task-outcome-cli.js +2 -2
  80. package/dist/task-status.d.ts +186 -0
  81. package/dist/task-status.js +335 -0
  82. package/dist/team-cli.js +3 -0
  83. package/dist/telegram-progress.js +62 -100
  84. package/dist/telegram-status.js +21 -8
  85. package/dist/verification-evidence.js +21 -1
  86. package/dist/webhooks.js +5 -0
  87. package/dist/work-index.js +60 -11
  88. package/dist/work-summary.d.ts +0 -4
  89. package/dist/work-summary.js +2 -0
  90. package/dist/workspace-ui.d.ts +10 -0
  91. package/dist/workspace-ui.js +33 -2
  92. package/package.json +1 -1
package/dist/operate.js CHANGED
@@ -1,4 +1,6 @@
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";
2
4
  import { parseProtectedPaths } from "./approval-policy.js";
3
5
  import { rulesSummary } from "./approval-rules-ui.js";
4
6
  import { evidencePack, exportDay, ledgerExportChunks, standaloneEvidenceHtml } from "./evidence-pack.js";
@@ -79,6 +81,7 @@ import { deleteProject, holdingsWords, projectHoldings, projectRunning } from ".
79
81
  import { pushPass } from "./push.js";
80
82
  import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, fsyncSync, openSync, readFileSync, readSync, realpathSync, renameSync, unlinkSync, writeSync, writeFileSync, mkdirSync } from "node:fs";
81
83
  import { BRANCH_PREFIX, envTwins, envValue, existingOrFirst, namedPath, taskBranches } from "./names.js";
84
+ import { claimActor, currentActor, parseLeadToken, withActor } from "./actor.js";
82
85
  import { createServer as createNetServer } from "node:net";
83
86
  import { spawn as spawnChild } from "node:child_process";
84
87
  import { envelopeJson } from "./envelope.js";
@@ -127,6 +130,7 @@ import { verdictWords as proofVerdictWords, matrixWords, semanticCoverage, cover
127
130
  import { probeRepo, isVerified } from "./probe.js";
128
131
  import { diagnoseTaskDispatch, diagnosisIsDispatchable, scopeApprovedForDispatch, } from "./dispatch.js";
129
132
  import { createDecisionServer } from "./serve.js";
133
+ import { readTailnetNames } from "./tailnet.js";
130
134
  import { daemonLaunchCommand, daemonStatus, installReplacingLegacy, installedLegacyDaemon, planDaemon, uninstallDaemon, awaitFreshHeartbeat, } from "./daemon.js";
131
135
  import { bridgePass, clearBotToken, createTransport, followBridge, hashPairingCode, loadBotToken, mintPairingCode, redactToken, saveBotToken, PAIRING_TTL_MS, TOKEN_ENV, telegramHookSecret, telegramPushUrl, } from "./telegram.js";
132
136
  import { scanRepo } from "./capscan.js";
@@ -150,7 +154,7 @@ import { register, authenticate, heartbeat as heartbeatRunner, isAlive, recoverD
150
154
  import { mintCoordinator, revokeCoordinator, listCoordinators } from "./coordinator.js";
151
155
  import { serveMcp } from "./mcp.js";
152
156
  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";
157
+ import { propose, approve, addApprover, authenticateAccount, authenticateApprover, describeScope, approvalOf, hashToken as hashApproverToken, profileFromJson, fileAndSealUnderMode, modeFilingCoverage, acceptanceLinesToInput, parseAcceptanceCriteria, splitAcceptanceRubric, rubricIsPlaceholder, isCommitSha } from "./scope.js";
154
158
  import { presetTerms, modeTermsJson, modeDigestOf, modeTermsFromJson, modeWords, MODE_MAX_DAYS } from "./modes.js";
155
159
  import { WorktreePool } from "./worktree.js";
156
160
  import { requestTaskStop, resumeTaskStop, taskControlOf } from "./task-control.js";
@@ -167,6 +171,7 @@ import { isDirectChatProvider, isSubscriptionChatProvider, priceOf, PRICED_MODEL
167
171
  import { resolvePhaseAgent, resolveScopeProfile, resolveScopeChain, resolveRouteCandidates, routeOfTask, INSTALLATION_SCOPE } from "./agentconfig.js";
168
172
  import { isRiskLevel, legOf, projectRoute, riskConsequence, routeDigestOf, routeWords, RISK_LEVELS, PHASES as ROUTE_PHASES } from "./phase-routing.js";
169
173
  import { observeProviderReadiness, reportProviderReadinessAuthed } from "./runner.js";
174
+ import { effectiveConcurrency, maySlotTake, parseProjectConcurrency, PROJECT_CONCURRENCY_DEFAULT, ProjectPasses, projectConcurrency, saveProjectConcurrency, savedProjectConcurrency } from "./project-concurrency.js";
170
175
  import { clearWebhook, effectivePrimary, isMessagingChannel, loadConsoleUrl, loadPrimary, loadWebhookTargets, phoneOrigin, saveConsoleUrl, savePrimary, saveWebhook, webhookPass, SLACK_ENV, DISCORD_ENV } from "./webhooks.js";
171
176
  import { auditOf, inspectionOf, isProviderId, MONEY_CAPABILITIES, PROVIDER_IDS, validModelId, validateSpec, ALL_CREDENTIAL_ENV } from "./provider.js";
172
177
  import { attestProvider, attestationOf, versionInRange } from "./attest.js";
@@ -203,7 +208,11 @@ export const OPERATE_HELP = `toolroll — operating the queue
203
208
 
204
209
  toolroll status running, queued, ready results, release check and plan windows
205
210
  toolroll ready what could be dispatched right now
206
- toolroll task add <title> queue work
211
+ 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)
212
+ toolroll lead token mint the lead's credential for you (your password; --revoke ends it);
213
+ the lead passes it as --token, or TOOLROLL_LEAD_TOKEN, and its work pings nobody
214
+ toolroll task ask <id> --person <name> --why "<text>"
215
+ hand a task to a person: it pings them, and it is theirs
207
216
  toolroll task list [--view <v>] [--limit <n>] [--cursor <c>] paginated saved task status
208
217
  toolroll task show <id>
209
218
  toolroll task wait <id> [--timeout <seconds>]
@@ -212,6 +221,9 @@ export const OPERATE_HELP = `toolroll — operating the queue
212
221
  toolroll project show show the current project and credential reference
213
222
  toolroll project rules --repo <p> a project's approval rules; an instance operator changes them with
214
223
  --not-requester on|off, --protect none|project, --protect-paths "a/**,b/**"
224
+ toolroll project concurrency [<n>] --repo <p>
225
+ how many of a project's tasks build at once (default 2, never past the
226
+ worker's capacity); an approver changes it with <n>
215
227
  toolroll task evidence <id> the task's evidence pack as JSON (--html for a printable page; --out <file>)
216
228
  toolroll storage where the disk goes: database, build checkouts, releases, evidence
217
229
  toolroll storage clean [--yes] preview removing finished tasks' clean checkouts (their branches stay); --yes removes them
@@ -225,6 +237,7 @@ export const OPERATE_HELP = `toolroll — operating the queue
225
237
  toolroll budget list|set|remove monthly budgets (--all | --project <p> | --person <name> | --teammate <id>) --usd <n> [--alerts-only]
226
238
  toolroll notifications [quiet|all] how chats reach you: only when you're needed (the default), or every step
227
239
  toolroll notifications digest <HH:MM>|off one evening message: what finished, what waits, what failed
240
+ toolroll notifications mute|unmute --repo <p> no pings for a project; the console and digest keep it
228
241
  toolroll retention show|preview how long evidence, checkout records, chat and notifications are kept; what the daily sweep would remove
229
242
  toolroll retention set <kind> <period> evidence|checkouts|chat|notifications, 30d|1y|forever (instance operator)
230
243
  toolroll backup now|list back the database up now; list backups and how the last ones went
@@ -239,6 +252,10 @@ export const OPERATE_HELP = `toolroll — operating the queue
239
252
  [--pull-request] --pull-request also opens its pull request
240
253
  toolroll task merge <id> --as <you> --token <t>
241
254
  merge the task's pull request once checks pass
255
+ [--anyway] merge without waiting for the full check
256
+ toolroll task checks <id> [--level quick|full]
257
+ run checks on the result's exact commit now
258
+ toolroll task add-tests <id> file a small task to write tests for that change
242
259
  toolroll task revise <id> --feedback "requested change"
243
260
  toolroll assignment show <task> root, current work and exact handoff
244
261
  toolroll assignment updates durable updates (--after <cursor>)
@@ -250,6 +267,7 @@ export const OPERATE_HELP = `toolroll — operating the queue
250
267
  claim/check use --token-env NAME or --token-file PATH for a coordinator;
251
268
  checking a receipt never approves work, accepts proof or deploys it
252
269
  toolroll task state <id> <state> [--reason <text>] queued|running|done|failed|cancelled
270
+ [--replaced-by <id>] cancelled because <id> replaces it: it reads "Replaced by <id>"
253
271
  toolroll task block <id> --on <id> <id> waits for <on>
254
272
  toolroll task unblock <id> --on <id> stop waiting for <on>
255
273
  toolroll task next <id> [--undo] move it to the front of ITS
@@ -487,7 +505,13 @@ Agents — which provider and model each phase runs on
487
505
  executable is missing, then retry
488
506
  this check once; approval requires
489
507
  its previewed setup digest
490
- toolroll verify clear --repo <path> --as <you> --token <t>
508
+ toolroll verify clear --repo <path> --as <you> --token <t> (--level quick clears the quick command)
509
+ toolroll verify level quick|full|off --repo <path> --as <you> --token <t>
510
+ how much checks after each build: Quick (the
511
+ quick command), Full, or Off (no check)
512
+ toolroll verify set --repo <path> --quick "<cmd>" --as <you> --token <t> [--yes]
513
+ approve the fast check Quick runs; verify show
514
+ suggests one from the project's scripts
491
515
  Pass flags still win for one pass: --provider/--model,
492
516
  --plan-provider/--plan-model, --repair-model. A routine instance is
493
517
  pinned at fire time and ignores all of them.
@@ -559,7 +583,8 @@ Exit codes
559
583
  export const TASK_ACTIONS = [
560
584
  "add", "list", "show", "wait", "state", "block", "unblock", "next", "steer", "assign",
561
585
  "reopen", "scope", "approve", "hold", "unhold", "require", "requeue", "regate", "plan",
562
- "review", "accept", "repair", "route", "stop", "resume", "complete", "revise", "merge",
586
+ "review", "accept", "repair", "route", "stop", "resume", "complete", "revise", "merge", "ask",
587
+ "checks", "add-tests",
563
588
  ];
564
589
  export const PUBLISH_ACTIONS = ["setup", "grant", "revoke", "status", "unblock", "rearm", "merge", "refire"];
565
590
  export const CONFIG_ACTIONS = ["show", "set", "clear"];
@@ -601,6 +626,10 @@ export const OPERATE_VALUE_FLAGS = new Set([
601
626
  // flows: templates, steps, scripts and cards.
602
627
  "template", "steps", "about", "language", "timeout-minutes", "body", "description", "zone",
603
628
  "token-env", "after", "repair-max-attempts", "consumer", "batch", "feedback", "source", "view", "cursor", "why", "supersedes", "decision", "sessions", "timeout",
629
+ // pings follow responsibility: replacements.
630
+ "replaced-by", "replaces",
631
+ // Check levels: a project's level, the quick command, a task's choice.
632
+ "quick", "level", "checks",
604
633
  ]);
605
634
  export const OPERATE_BOOLEAN_FLAGS = new Set([
606
635
  "json", "yes", "all", "local", "history", "latest-watch", "dry-run", "file", "allow-paid-fallback",
@@ -616,6 +645,10 @@ export const OPERATE_BOOLEAN_FLAGS = new Set([
616
645
  "pull-request",
617
646
  // up: the exact start-up details (containment) in the terminal too.
618
647
  "verbose",
648
+ // lead token: end the lead's credential.
649
+ "revoke",
650
+ // task merge: merge without waiting for the full check (Quick or Off results).
651
+ "anyway",
619
652
  ]);
620
653
  export function parseOperateArgs(argv, ownValues = new Set()) {
621
654
  const positional = [];
@@ -660,7 +693,8 @@ export function parseOperateArgs(argv, ownValues = new Set()) {
660
693
  const mintedToken = name === "token" && value !== undefined && /^[A-Za-z0-9_-]{43}$/.test(value);
661
694
  if (value === undefined || (equals === -1 && value.startsWith("--") && !mintedToken))
662
695
  return { error: `--${name} needs a value` };
663
- flags.set(name, value);
696
+ // `flows import --param a=1 --param b=2`: each value kept, one per line.
697
+ flags.set(name, name === "param" && typeof flags.get(name) === "string" ? `${flags.get(name)}\n${value}` : value);
664
698
  if (name === "repo") {
665
699
  for (const one of value.split(",").map(part => part.trim()).filter(part => part !== ""))
666
700
  repoList.push(one);
@@ -727,6 +761,27 @@ export async function runOperate(command, argv, write, options = {}) {
727
761
  catch (error) {
728
762
  return databaseFailure(write, json, command, file, error);
729
763
  }
764
+ // The lead (a lead token, as --token or TOOLROLL_LEAD_TOKEN) acts for its owner in single commands; its work
765
+ // pings nobody. Anyone else who signs in inside one of these commands is who acted. Services name nobody.
766
+ const presentedFlag = text(flags, "token");
767
+ const flagLead = presentedFlag !== undefined && parseLeadToken(presentedFlag) !== null;
768
+ const envLead = envValue(process.env, "LEAD_TOKEN");
769
+ const presented = flagLead ? presentedFlag : LEAD_COMMANDS.has(command) && envLead !== undefined && envLead !== "" ? envLead : undefined;
770
+ let actor = null;
771
+ if (presented !== undefined) {
772
+ if (!LEAD_COMMANDS.has(command)) {
773
+ store.close();
774
+ 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);
775
+ }
776
+ const lead = store.leadFor(presented);
777
+ if (lead === null) {
778
+ store.close();
779
+ 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);
780
+ }
781
+ if (flagLead)
782
+ flags.delete("token");
783
+ actor = { account: lead.owner, lead: true };
784
+ }
730
785
  // v105: a Claude turn anywhere in this command says its plan's usage windows; keep the latest.
731
786
  const dropLimitSink = pushLimitSink(reading => store.recordProviderLimits(reading, new Date()));
732
787
  // One `Date` per command is fine for a lookup and wrong for a pass that
@@ -735,7 +790,8 @@ export async function runOperate(command, argv, write, options = {}) {
735
790
  // must not advance under it — while real time is read again at each step.
736
791
  const clock = options.now === undefined ? () => new Date() : () => now;
737
792
  try {
738
- return await dispatch(command, positional, flags, {
793
+ const run = () => dispatch(command, positional, flags, {
794
+ ...(actor === null ? {} : { leadToken: presented }),
739
795
  store,
740
796
  write,
741
797
  json,
@@ -760,6 +816,7 @@ export async function runOperate(command, argv, write, options = {}) {
760
816
  ...(options.dispatchAdapter === undefined ? {} : { dispatchAdapter: options.dispatchAdapter }),
761
817
  ...(options.flowTriggerIo === undefined ? {} : { flowTriggerIo: options.flowTriggerIo }),
762
818
  ...(options.flowStepIo === undefined ? {} : { flowStepIo: options.flowStepIo }),
819
+ ...(options.flowFetch === undefined ? {} : { flowFetch: options.flowFetch }),
763
820
  ...(options.integrationIo === undefined ? {} : { integrationIo: options.integrationIo }),
764
821
  ...(options.shouldStop === undefined ? {} : { shouldStop: options.shouldStop }),
765
822
  ...(options.mateSeams === undefined ? {} : { mateSeams: options.mateSeams }),
@@ -770,6 +827,7 @@ export async function runOperate(command, argv, write, options = {}) {
770
827
  ...(options.onboardSeams === undefined ? {} : { onboardSeams: options.onboardSeams }),
771
828
  ...(options.upSeams === undefined ? {} : { upSeams: options.upSeams }),
772
829
  });
830
+ return await (LEAD_COMMANDS.has(command) ? withActor(actor, run) : run());
773
831
  }
774
832
  catch (error) {
775
833
  if (isDatabaseBusy(error))
@@ -781,6 +839,8 @@ export async function runOperate(command, argv, write, options = {}) {
781
839
  store.close();
782
840
  }
783
841
  }
842
+ /** The short commands a lead token works for, and where whoever signs in is recorded as the actor. */
843
+ const LEAD_COMMANDS = new Set(["task", "assignment", "status", "ready", "brief", "peek", "check-progress", "notifications", "lead"]);
784
844
  /**
785
845
  * The enrolled-project registry lives beside the database — the same rule
786
846
  * as evidence and the bot token — so an isolated installation (`--db` in a
@@ -860,6 +920,8 @@ async function dispatch(command, positional, flags, context) {
860
920
  return retentionCommand(positional, flags, context);
861
921
  case "notifications":
862
922
  return notificationsCommand(positional, flags, context);
923
+ case "lead":
924
+ return leadCommand(positional, flags, context);
863
925
  case "backup":
864
926
  return backupCommand(positional, flags, context);
865
927
  case "export":
@@ -890,6 +952,8 @@ async function dispatch(command, positional, flags, context) {
890
952
  return projectRulesCommand(positional, flags, context);
891
953
  if (positional[0] === "delete")
892
954
  return projectDeleteCommand(positional, flags, context);
955
+ if (positional[0] === "concurrency")
956
+ return projectConcurrencyCommand(positional, flags, context);
893
957
  return runProjectCommand(positional, flags, context);
894
958
  case "assignment":
895
959
  return runAssignmentCommand(positional, flags, context);
@@ -1957,10 +2021,15 @@ async function tickCommand(flags, context) {
1957
2021
  if (updateAdmissionPaused(store.raw()))
1958
2022
  return fail(write, json, "tick", "updating", UPDATE_PAUSED, EXIT.refused);
1959
2023
  const maxGiven = text(flags, "max");
1960
- const max = maxGiven === undefined ? 1 : Number(maxGiven);
1961
- if (!Number.isInteger(max) || max <= 0) {
2024
+ const maxAsked = maxGiven === undefined ? 1 : Number(maxGiven);
2025
+ if (!Number.isInteger(maxAsked) || maxAsked <= 0) {
1962
2026
  return fail(write, json, "tick", "usage", "--max takes a whole number of tasks", EXIT.usage);
1963
2027
  }
2028
+ // The service's build lanes (project-concurrency.ts): a lane over its
2029
+ // project's fair share still runs its housekeeping but starts no
2030
+ // unattended build this pass; an extra lane only builds.
2031
+ const max = flags.has("yield-builds") ? 0 : maxAsked;
2032
+ const buildsOnly = flags.has("builds-only");
1964
2033
  const syncAgeGiven = text(flags, "sync-max-age");
1965
2034
  const syncMaxAgeMs = syncAgeGiven === undefined ? SYNC_MAX_AGE_MS : Number(syncAgeGiven) * 1000;
1966
2035
  if (!Number.isInteger(syncMaxAgeMs) || syncMaxAgeMs <= 0) {
@@ -2013,7 +2082,7 @@ async function tickCommand(flags, context) {
2013
2082
  // fireRoutine's own transaction, and skipped slots ledger and page
2014
2083
  // themselves there. This loop just reports.
2015
2084
  const routines = [];
2016
- for (const routine of store.dueRoutines(repo, clock())) {
2085
+ for (const routine of buildsOnly ? [] : store.dueRoutines(repo, clock())) {
2017
2086
  // The stop fence (audit IV-1): a signal that landed mid-pass stops
2018
2087
  // every further admission — a routine not yet fired stays unfired.
2019
2088
  if (context.shouldStop?.() === true || context.shouldPauseAdmission?.() === true)
@@ -2033,7 +2102,7 @@ async function tickCommand(flags, context) {
2033
2102
  // Triggers first: a schedule, GitHub, Linear or another flow may start
2034
2103
  // cards, which then move in the same pass. Checking an outside service is
2035
2104
  // `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: [] }
2105
+ const triggerPass = buildsOnly || context.shouldStop?.() === true || context.shouldPauseAdmission?.() === true ? { added: 0, checked: 0, problems: [] }
2037
2106
  : await runFlowTriggers(store, repo, clock(), { gh: context.flowTriggerIo?.gh ?? run, fetch: context.flowTriggerIo?.fetch ?? fetch, dir: context.flowTriggerIo?.dir ?? dirname(context.databaseFile),
2038
2107
  // A schedule's script (v90) runs like a code step: the same runner, in a clean folder beside the step copies.
2039
2108
  shell: context.flowTriggerIo?.shell ?? context.flowStepIo?.shell ?? run, scratch: context.flowTriggerIo?.scratch ?? context.flowStepIo?.scratch ?? join(pool, "flow-checks"),
@@ -2041,28 +2110,29 @@ async function tickCommand(flags, context) {
2041
2110
  // Replies to cards' emails (v91): read about once a minute, and only while
2042
2111
  // a card is in an email conversation. A reply moves a waiting card on here,
2043
2112
  // before its next step runs.
2044
- const replyPass = context.shouldStop?.() === true || context.shouldPauseAdmission?.() === true ? { read: 0, taken: 0, problem: null }
2113
+ const replyPass = buildsOnly || context.shouldStop?.() === true || context.shouldPauseAdmission?.() === true ? { read: 0, taken: 0, problem: null }
2045
2114
  : await watchFlowReplies(store, clock(), { fetch: context.flowTriggerIo?.fetch ?? fetch, dir: context.flowTriggerIo?.dir ?? dirname(context.databaseFile),
2046
2115
  ...(context.flowTriggerIo?.mail === undefined ? {} : { mail: context.flowTriggerIo.mail }) });
2047
2116
  // Check and update steps run outside a model: an approved command in a
2048
2117
  // 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: [] }
2118
+ const stepPass = buildsOnly || context.shouldStop?.() === true || context.shouldPauseAdmission?.() === true ? { ran: 0, problems: [] }
2050
2119
  : await runFlowSteps(store, repo, clock(), {
2051
2120
  gh: context.flowStepIo?.gh ?? run, git: context.flowStepIo?.git ?? git, shell: context.flowStepIo?.shell ?? run, fetch: context.flowStepIo?.fetch ?? fetch,
2052
2121
  dir: context.flowStepIo?.dir ?? dirname(context.databaseFile), scratch: context.flowStepIo?.scratch ?? join(pool, "flow-checks"), base,
2053
2122
  ...(context.evidenceRoot === undefined ? {} : { evidenceRoot: context.evidenceRoot }),
2054
2123
  });
2055
- const flowPass = advanceFlows(store, repo, clock(), context.evidenceRoot === undefined ? {} : { evidenceRoot: context.evidenceRoot });
2124
+ const flowPass = buildsOnly ? { moved: 0, filed: [], problems: [] } : advanceFlows(store, repo, clock(), context.evidenceRoot === undefined ? {} : { evidenceRoot: context.evidenceRoot });
2056
2125
  // 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
- }
2126
+ if (!buildsOnly)
2127
+ try {
2128
+ sendTeammateSummaries(store, repo, clock());
2129
+ sendTeammateWeeklies(store, repo, clock());
2130
+ }
2131
+ catch (error) {
2132
+ flowPass.problems.push(`teammate summaries: ${error instanceof Error ? error.message : "could not send"}`);
2133
+ }
2064
2134
  // Undoing a teammate's tool call asked for in chat (v97): made here, as the person who asked.
2065
- if (context.shouldStop?.() !== true) {
2135
+ if (!buildsOnly && context.shouldStop?.() !== true) {
2066
2136
  try {
2067
2137
  await runRequestedUndos(store, {}, clock());
2068
2138
  }
@@ -2071,7 +2141,7 @@ async function tickCommand(flags, context) {
2071
2141
  }
2072
2142
  }
2073
2143
  // 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) {
2144
+ if (!buildsOnly && context.shouldStop?.() !== true) {
2075
2145
  try {
2076
2146
  flowPass.problems.push(...(await refreshConnections(store, [repo], clock(), context.flowStepIo?.fetch === undefined ? {} : { fetcher: context.flowStepIo.fetch })).problems);
2077
2147
  }
@@ -2086,7 +2156,8 @@ async function tickCommand(flags, context) {
2086
2156
  // races recover by CAS, and an ANSWERED question re-admits its parked
2087
2157
  // agent — fresh claim, fresh slot, remaining budget only, the SAME
2088
2158
  // verified checkout on the SAME runner (finding 29's custody rule).
2089
- recoverContests(store, clock());
2159
+ if (!buildsOnly)
2160
+ recoverContests(store, clock());
2090
2161
  // Expired ceremony nonces are litter with a bound (round-3 finding 30):
2091
2162
  // the mint refuses past 50 open per approver, so the sweep keeps the
2092
2163
  // ceiling meaningful rather than letting dead rows consume it.
@@ -2094,8 +2165,10 @@ async function tickCommand(flags, context) {
2094
2165
  // Decided tournaments give their checkouts back (stage 6) — this runner's
2095
2166
  // custody only; a checkout that will not release cleanly is flagged and
2096
2167
  // 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());
2168
+ if (!buildsOnly) {
2169
+ await sweepContestCleanup(store, path => worktrees.release(path, clock()), runner, clock());
2170
+ escalateOverdueContests(store, clock());
2171
+ }
2099
2172
  const resumed = [];
2100
2173
  // v105: monthly budgets, read when first needed and again after any build (its spend counts): a used-up hard-stop
2101
2174
  // budget holds back new work billed to an API key, on every road below.
@@ -2124,7 +2197,7 @@ async function tickCommand(flags, context) {
2124
2197
  }
2125
2198
  return null;
2126
2199
  };
2127
- for (const waiting of store.contestsInStates(["decision-wait"])) {
2200
+ for (const waiting of buildsOnly ? [] : store.contestsInStates(["decision-wait"])) {
2128
2201
  if (context.shouldStop?.() === true || context.shouldPauseAdmission?.() === true)
2129
2202
  break;
2130
2203
  // D1 belt-and-braces (external dispatch, finding 41): a mirror and a
@@ -2403,7 +2476,8 @@ async function tickCommand(flags, context) {
2403
2476
  // uses, before any admission can re-tag or race it. Advancing here lands
2404
2477
  // the cycle in pending-admission for THIS pass's chain admission below.
2405
2478
  // One shared piece with the fault tests (F+G review, finding 5).
2406
- store.reconcileStrandedChains(repo, clock());
2479
+ if (!buildsOnly)
2480
+ store.reconcileStrandedChains(repo, clock());
2407
2481
  // The DISPATCH view of the queue (queue columns, v19): this runner's own
2408
2482
  // reserved work first, then the shared queue; work reserved for other
2409
2483
  // workers is absent. The claim primitive re-proves the reservation.
@@ -3790,6 +3864,11 @@ async function tickCommand(flags, context) {
3790
3864
  // not a break, not a strike; the task waits under the stop's hold.
3791
3865
  dispatched.push({ id, outcome: "stopped", reason: `stop:${disposition.stopRun}`, branch, worktree: leased.worktree.path });
3792
3866
  break;
3867
+ case "handed-back":
3868
+ // The service is stopping: the work is kept and the task requeued
3869
+ // to resume on the next pass — not a break, not a strike.
3870
+ dispatched.push({ id, outcome: "stopped", reason: "service-stop", detail: result.ok ? "" : result.message.slice(0, 200), branch, worktree: leased.worktree.path });
3871
+ break;
3793
3872
  case "invariant":
3794
3873
  dispatched.push({ id, outcome: "failed", reason: disposition.reason });
3795
3874
  broke++;
@@ -3804,7 +3883,7 @@ async function tickCommand(flags, context) {
3804
3883
  // paid-fallback grant + the single-use pending edge); this loop carries
3805
3884
  // only claim, worktree, and rail, and its run then re-proves the
3806
3885
  // chain-entry dispatch proof inside build() before any money moves.
3807
- for (const pending of store.pendingChainAdmissions(repo)) {
3886
+ for (const pending of buildsOnly ? [] : store.pendingChainAdmissions(repo)) {
3808
3887
  if (context.shouldStop?.() === true || context.shouldPauseAdmission?.() === true)
3809
3888
  break;
3810
3889
  if (built >= max)
@@ -3975,6 +4054,9 @@ async function tickCommand(flags, context) {
3975
4054
  case "stopped":
3976
4055
  dispatched.push({ id: pending.taskId, outcome: "stopped", reason: `stop:${disposition.stopRun}`, branch, worktree: leased.worktree.path });
3977
4056
  break;
4057
+ case "handed-back":
4058
+ dispatched.push({ id: pending.taskId, outcome: "stopped", reason: "service-stop", branch, worktree: leased.worktree.path });
4059
+ break;
3978
4060
  case "parked":
3979
4061
  dispatched.push({ id: pending.taskId, outcome: "parked", reason: `decision:${disposition.decisionId}`, worktree: leased.worktree.path });
3980
4062
  parked++;
@@ -3994,7 +4076,7 @@ async function tickCommand(flags, context) {
3994
4076
  // Only a co-located coordinator can hold the session, and everything
3995
4077
  // else (liveness, one attempt, the final proof at the parent's exact
3996
4078
  // head) is re-proved on the way in.
3997
- if (context.heldCoordinator !== undefined) {
4079
+ if (context.heldCoordinator !== undefined && !buildsOnly) {
3998
4080
  for (const continuation of store.openContinuationAuthorizations(runner)) {
3999
4081
  if (context.shouldStop?.() === true || context.shouldPauseAdmission?.() === true)
4000
4082
  break;
@@ -4197,18 +4279,20 @@ async function tickCommand(flags, context) {
4197
4279
  }
4198
4280
  // Retire queued model-review work without creating a run or a passing verdict.
4199
4281
  // 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());
4282
+ if (!buildsOnly)
4283
+ store.transact(() => {
4284
+ const current = authenticate(store, runner, token);
4285
+ if (!current.ok)
4286
+ return;
4287
+ for (const request of store.openReviewRequests()) {
4288
+ if (request.repo !== null && current.runner.repos.includes(request.repo)) {
4289
+ store.consumeReviewRequest(request.id, "model-review-retired", clock());
4290
+ }
4207
4291
  }
4208
- }
4209
- });
4292
+ });
4210
4293
  // Existing worker pass owns durable lead handoffs; reads never create events.
4211
- syncAssignmentHandoffs(store, clock(), auth.runner.repos, context.evidenceRoot);
4294
+ if (!buildsOnly)
4295
+ syncAssignmentHandoffs(store, clock(), auth.runner.repos, context.evidenceRoot);
4212
4296
  const summary = () => {
4213
4297
  const lines = [`Considered ${considered}, built ${built}, parked ${parked}, broke ${broke}.`];
4214
4298
  for (const entry of routines) {
@@ -4716,6 +4800,10 @@ async function startConsole(options) {
4716
4800
  ...(options.localRunner === undefined ? {} : { localRunner: options.localRunner }),
4717
4801
  poolRoot: options.poolRoot,
4718
4802
  ...(options.allowedHosts === undefined ? {} : { allowedHosts: options.allowedHosts }),
4803
+ // This computer's own tailnet names are admitted on the served port without an --allow-host (onboarding).
4804
+ tailnetNames: context.tailnetNames ?? (() => readTailnetNames()),
4805
+ // With no lead set up yet, the agent signed in on this computer runs it (onboarding).
4806
+ leadByDefault: true,
4719
4807
  ...(options.setupCode === undefined ? {} : { setupCode: options.setupCode }),
4720
4808
  ...(options.repos === undefined ? {} : { repos: options.repos }),
4721
4809
  ...(options.projectRoots === undefined ? {} : { projectRoots: options.projectRoots }),
@@ -6040,18 +6128,51 @@ async function verifyCommand(positional, flags, context) {
6040
6128
  return fail(write, json, "verify show", "usage", "which repo? --repo <path>", EXIT.usage);
6041
6129
  }
6042
6130
  const live = store.liveVerifyCommand(repo);
6131
+ const quick = liveQuickCommand(store, repo);
6132
+ const level = projectCheckLevel(store, repo);
6133
+ const suggestion = quick === null ? suggestQuickCommand(repo) : null;
6043
6134
  if (json) {
6044
- write(envelopeJson({ ok: true, command: "verify show", repo, verify: live }));
6135
+ write(envelopeJson({ ok: true, command: "verify show", repo, verify: live, quick, level: level.level, ...(suggestion === null ? {} : { suggestedQuick: suggestion }) }));
6045
6136
  return EXIT.ok;
6046
6137
  }
6047
6138
  write(live === null
6048
6139
  ? `No verification command for ${repo}. A build's proof lands "attested" at best — nothing re-runs it.`
6049
6140
  : `${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}`}`);
6141
+ 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}`}`);
6142
+ if (quick !== null)
6143
+ write(`Quick check:\n ${quick.command}\n timeout ${Math.round(quick.timeoutMs / 1000)}s · digest ${quick.digest} · approved by ${quick.approvedBy}`);
6144
+ else if (level.level === "quick")
6145
+ 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
6146
  return EXIT.ok;
6051
6147
  }
6148
+ // An approver's act: the project's check level. The ledger keeps before → after.
6149
+ if (action === "level") {
6150
+ if (repo === undefined)
6151
+ return fail(write, json, "verify level", "usage", "which repo? --repo <path>", EXIT.usage);
6152
+ const wanted = positional[1];
6153
+ if (!isCheckLevel(wanted))
6154
+ return fail(write, json, "verify level", "usage", "`toolroll verify level quick|full|off --repo <path> --as <you> --token <t>`", EXIT.usage);
6155
+ const acting = await askCredentials(flags, context);
6156
+ if (acting === null)
6157
+ return fail(write, json, "verify level", "usage", "changing a project's checks takes `--as <you> --token <t>`", EXIT.usage);
6158
+ const authenticated = authenticateApprover(store, acting.name, acting.token, repo);
6159
+ if (!authenticated.ok)
6160
+ return fail(write, json, "verify level", authenticated.reason, describeApproveFailure(authenticated.reason, repo), EXIT.refused);
6161
+ const changed = setProjectCheckLevel(store, repo, wanted, acting.name, clock());
6162
+ const runsFull = wanted === "quick" && liveQuickCommand(store, repo) === null;
6163
+ return succeed(write, json, "verify level", { repo, level: wanted, before: changed.before, changed: changed.changed }, () => [
6164
+ `${repo}: checks are ${CHECK_LEVEL_WORDS[wanted]}${changed.changed ? ` (was ${CHECK_LEVEL_WORDS[changed.before]})` : ""}. ${CHECK_LEVEL_HINTS[wanted]}`,
6165
+ ...(runsFull ? [`No quick command is approved yet, so builds run the full check. Approve one with \`toolroll verify set --repo ${repo} --quick "<cmd>"\`.`] : []),
6166
+ ...(wanted !== "full" ? ["The full check still runs when a pull request opens, and Merge waits for it unless a person merges anyway."] : []),
6167
+ ]);
6168
+ }
6052
6169
  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);
6170
+ 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
6171
  }
6172
+ // The quick command is its own approved grant beside the full one.
6173
+ const quickValue = text(flags, "quick");
6174
+ const quickClear = action === "clear" && text(flags, "level") === "quick";
6175
+ const grantKey = repo === undefined ? undefined : quickValue !== undefined || quickClear ? quickVerifyKey(repo) : repo;
6055
6176
  if (repo === undefined) {
6056
6177
  return fail(write, json, `verify ${action}`, "usage", "which repo? --repo <path>", EXIT.usage);
6057
6178
  }
@@ -6064,12 +6185,22 @@ async function verifyCommand(positional, flags, context) {
6064
6185
  return fail(write, json, `verify ${action}`, authenticated.reason, describeApproveFailure(authenticated.reason, repo), EXIT.refused);
6065
6186
  }
6066
6187
  if (action === "clear") {
6067
- const cleared = store.clearVerifyCommand(repo, acting.name, clock());
6188
+ const cleared = store.clearVerifyCommand(grantKey, acting.name, clock());
6189
+ if (quickClear)
6190
+ return succeed(write, json, "verify clear", { repo, cleared, level: "quick" }, () => [
6191
+ cleared ? `Cleared the quick command — Quick builds of ${repo} run the full check now.` : `No quick command was set for ${repo}.`,
6192
+ ]);
6068
6193
  return succeed(write, json, "verify clear", { repo, cleared }, () => [
6069
6194
  cleared ? `Cleared — builds of ${repo} land "attested" at best now; nothing re-runs.` : `Nothing was set for ${repo}.`,
6070
6195
  ]);
6071
6196
  }
6072
- const command = text(flags, "command");
6197
+ const command = quickValue ?? text(flags, "command");
6198
+ if (quickValue !== undefined && text(flags, "command") !== undefined) {
6199
+ return fail(write, json, "verify set", "usage", "give the quick command with --quick \"<cmd>\" or the full one with --command, not both", EXIT.usage);
6200
+ }
6201
+ if (quickValue !== undefined && flags.get("self-heal") === true) {
6202
+ return fail(write, json, "verify set", "usage", "--self-heal applies to the full command only", EXIT.usage);
6203
+ }
6073
6204
  if (command === undefined || command.trim() === "") {
6074
6205
  return fail(write, json, "verify set", "usage", "--command <cmd> is what the plane re-runs after every commit", EXIT.usage);
6075
6206
  }
@@ -6103,6 +6234,7 @@ async function verifyCommand(positional, flags, context) {
6103
6234
  command: "verify set",
6104
6235
  reason: "unconfirmed",
6105
6236
  repo,
6237
+ ...(quickValue === undefined ? {} : { level: "quick" }),
6106
6238
  verifyCommand: command,
6107
6239
  timeoutSeconds,
6108
6240
  selfHeal,
@@ -6116,7 +6248,7 @@ async function verifyCommand(positional, flags, context) {
6116
6248
  ` command ${command}`,
6117
6249
  ` timeout ${timeoutSeconds}s`,
6118
6250
  ``,
6119
- `Every FUTURE build of this repo re-runs this command unattended,`,
6251
+ 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
6252
  `once, right after it commits — under an ALLOWLISTED environment`,
6121
6253
  `(PATH, HOME, locale, temp — no credentials). A pass lands the`,
6122
6254
  `build "verified"; a failure lands it "refuted", never blocked.`,
@@ -6145,12 +6277,17 @@ async function verifyCommand(positional, flags, context) {
6145
6277
  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
6278
  }
6147
6279
  const saved = store.setVerifyCommand({
6148
- repo,
6280
+ repo: grantKey,
6149
6281
  command,
6150
6282
  timeoutMs: timeoutSeconds * 1000,
6151
6283
  approvedBy: acting.name,
6152
6284
  recoverySetupDigest: recoverySetup?.digest ?? null,
6153
6285
  }, clock());
6286
+ if (quickValue !== undefined) {
6287
+ return succeed(write, json, "verify set", { repo, level: "quick", digest: saved.digest, timeoutSeconds }, () => [
6288
+ `Approved the quick check: Quick builds of ${repo} run \`${command}\` (digest ${saved.digest}, ${timeoutSeconds}s) right after commit.`,
6289
+ ]);
6290
+ }
6154
6291
  return succeed(write, json, "verify set", { repo, digest: saved.digest, timeoutSeconds, selfHeal }, () => [
6155
6292
  `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
6293
  ]);
@@ -7295,6 +7432,20 @@ async function runWatchLoop(args) {
7295
7432
  });
7296
7433
  // Settings → Models: a quiet check of the public model lists and CLI
7297
7434
  // versions, only when the person turned it on. It never touches work.
7435
+ // Follow-up checks (Run checks, and the full check a pull request waits for):
7436
+ // each waiting request runs once, on its exact commit, in a fresh checkout.
7437
+ const followUpChecks = startMaintenance({
7438
+ intervalMs: 5_000,
7439
+ shouldStop: stopping,
7440
+ onError: error => progress(`watch: a follow-up check failed to run — ${describe(error)}; it is recorded on the result`),
7441
+ run: async () => {
7442
+ if (store.isDemo())
7443
+ return;
7444
+ const ran = await runWaitingChecks(store, context.evidenceRoot, { now: context.clock, shouldStop: stopping });
7445
+ if (ran > 0)
7446
+ progress(`watch: ran ${ran} follow-up check(s)`);
7447
+ },
7448
+ });
7298
7449
  const modelWatch = startMaintenance({
7299
7450
  intervalMs: 15 * 60_000,
7300
7451
  shouldStop: stopping,
@@ -7302,6 +7453,94 @@ async function runWatchLoop(args) {
7302
7453
  run: async () => { if (await modelWatchPass(store, context.clock()))
7303
7454
  progress("watch: checked models and CLI versions"); },
7304
7455
  });
7456
+ // Parallel builds (project-concurrency.ts): this loop is the project's
7457
+ // first build lane and keeps every housekeeping duty; extra lanes, up to
7458
+ // the project's own number (never past the worker's capacity), only
7459
+ // build — each attempt in its own checkout, each under its own claim,
7460
+ // lease and run, so reconcile, retries, the update drain and status count
7461
+ // every one. A lane over the project's fair share leaves the free slot to
7462
+ // a project building fewer, until that project has had a pass to take it.
7463
+ const passes = args.passes ?? new ProjectPasses();
7464
+ const slotFacts = (yieldingSince) => {
7465
+ const now = new Date();
7466
+ const found = store.getRunner(runner);
7467
+ const bound = new Set(found?.runner.repos ?? [repo]);
7468
+ const saved = savedProjectConcurrency(context.databaseFile);
7469
+ return {
7470
+ repo,
7471
+ capacity: found?.runner.capacity ?? 1,
7472
+ running: store.liveClaimCountsByRepo(runner, now),
7473
+ limitOf: one => saved.get(one) ?? PROJECT_CONCURRENCY_DEFAULT,
7474
+ waiting: new Set(store.listReady(now, runner).map(one => one.repo).filter((one) => one !== null && bound.has(one))),
7475
+ yieldingSince,
7476
+ passes,
7477
+ };
7478
+ };
7479
+ const lanes = [];
7480
+ const buildLane = async (index) => {
7481
+ const laneLines = [];
7482
+ const laneContext = { ...quietContext, write: line => laneLines.push(line) };
7483
+ let yieldingSince = null;
7484
+ let yieldingTo = null;
7485
+ while (!stopping() && (deadline === null || Date.now() < deadline)) {
7486
+ const seqBefore = store.wakeSeq();
7487
+ let broke = false;
7488
+ if (!paused()) {
7489
+ const facts = slotFacts(yieldingSince);
7490
+ const answer = index < effectiveConcurrency(facts.limitOf(repo), facts.capacity) ? maySlotTake(facts) : null;
7491
+ if (answer !== null && answer.take) {
7492
+ yieldingSince = null;
7493
+ yieldingTo = null;
7494
+ laneLines.length = 0;
7495
+ let code = EXIT.failed;
7496
+ try {
7497
+ code = await tickCommand(passFlags({ "builds-only": "1" }), laneContext);
7498
+ }
7499
+ catch (error) {
7500
+ laneLines.push(JSON.stringify({ dispatched: [{ id: "?", outcome: "failed", reason: "lane", detail: describe(error) }] }));
7501
+ }
7502
+ passes.passed(repo);
7503
+ if (code === EXIT.ok) {
7504
+ built++;
7505
+ progress(`watch: build ${index + 1} did work (${new Date().toISOString()})`);
7506
+ continue;
7507
+ }
7508
+ if (code === EXIT.failed) {
7509
+ brokeCount++;
7510
+ broke = true;
7511
+ progress(`watch: build ${index + 1} — ${brokenWords(laneLines)}`);
7512
+ }
7513
+ }
7514
+ else if (answer !== null && !answer.take && answer.why === "fair-share") {
7515
+ if (yieldingSince === null) {
7516
+ yieldingSince = passes.stamp();
7517
+ store.bumpWake();
7518
+ }
7519
+ yieldingTo = answer.to ?? null;
7520
+ }
7521
+ else {
7522
+ yieldingSince = null;
7523
+ yieldingTo = null;
7524
+ }
7525
+ }
7526
+ if (!broke && store.wakeSeq() !== seqBefore)
7527
+ continue;
7528
+ const seqIdle = store.wakeSeq();
7529
+ const dozeUntil = Math.min(Date.now() + tickEveryMs, deadline ?? Number.MAX_SAFE_INTEGER);
7530
+ const theirTurnEnded = () => yieldingTo !== null && yieldingSince !== null && passes.lastPass(yieldingTo) > yieldingSince;
7531
+ while (!stopping() && Date.now() < dozeUntil && store.wakeSeq() === seqIdle && !theirTurnEnded()) {
7532
+ await sleep(paused() ? 250 : 50);
7533
+ }
7534
+ }
7535
+ };
7536
+ const ensureLanes = () => {
7537
+ const facts = slotFacts(null);
7538
+ const wanted = effectiveConcurrency(facts.limitOf(repo), facts.capacity);
7539
+ while (lanes.length + 1 < wanted && !stopping())
7540
+ lanes.push(buildLane(lanes.length + 1));
7541
+ };
7542
+ let firstLaneYieldingSince = null;
7543
+ let firstLaneYieldingTo = null;
7305
7544
  try {
7306
7545
  // Startup recovery still precedes the first dispatch.
7307
7546
  await maintenance.runNow();
@@ -7310,6 +7549,22 @@ async function runWatchLoop(args) {
7310
7549
  await sleep(250);
7311
7550
  continue;
7312
7551
  }
7552
+ ensureLanes();
7553
+ // This lane builds only within the project's number and its fair
7554
+ // share; over either, its pass still does every housekeeping duty.
7555
+ const share = maySlotTake(slotFacts(firstLaneYieldingSince));
7556
+ const yieldBuilds = !share.take && share.why !== "capacity";
7557
+ if (!share.take && share.why === "fair-share") {
7558
+ if (firstLaneYieldingSince === null) {
7559
+ firstLaneYieldingSince = passes.stamp();
7560
+ store.bumpWake();
7561
+ }
7562
+ firstLaneYieldingTo = share.to ?? null;
7563
+ }
7564
+ else {
7565
+ firstLaneYieldingSince = null;
7566
+ firstLaneYieldingTo = null;
7567
+ }
7313
7568
  const seqBefore = store.wakeSeq();
7314
7569
  const now = Date.now();
7315
7570
  // The tick pass runs whenever the loop spins — and the loop only
@@ -7321,7 +7576,8 @@ async function runWatchLoop(args) {
7321
7576
  lastTick = now;
7322
7577
  quiet.length = 0;
7323
7578
  store.expireOverdueDecisions(new Date());
7324
- const code = await tickCommand(passFlags(), quietContext);
7579
+ const code = await tickCommand(passFlags(yieldBuilds ? { "yield-builds": "1" } : {}), quietContext);
7580
+ passes.passed(repo);
7325
7581
  ticks++;
7326
7582
  if (code === EXIT.ok) {
7327
7583
  tickDidWork = true;
@@ -7412,13 +7668,18 @@ async function runWatchLoop(args) {
7412
7668
  // nothing to do). The 50ms poll is what keeps a stop prompt.
7413
7669
  const seqIdle = store.wakeSeq();
7414
7670
  const dozeUntil = Date.now() + Math.max(idleUntil, 0);
7415
- while (!stopping() && Date.now() < dozeUntil && store.wakeSeq() === seqIdle) {
7671
+ const theirTurnEnded = () => firstLaneYieldingTo !== null && firstLaneYieldingSince !== null && passes.lastPass(firstLaneYieldingTo) > firstLaneYieldingSince;
7672
+ while (!stopping() && Date.now() < dozeUntil && store.wakeSeq() === seqIdle && !theirTurnEnded()) {
7416
7673
  await sleep(50);
7417
7674
  }
7418
7675
  }
7419
7676
  }
7420
7677
  finally {
7678
+ // Every build lane finishes or hands its attempt back before the
7679
+ // loop's lease and episode end.
7680
+ await Promise.all(lanes);
7421
7681
  await maintenance.stop();
7682
+ await followUpChecks.stop();
7422
7683
  await modelWatch.stop();
7423
7684
  clearInterval(heartbeat);
7424
7685
  followController.abort();
@@ -7491,7 +7752,7 @@ async function watchCommand(flags, context) {
7491
7752
  const hardStop = () => {
7492
7753
  const terminated = terminateLiveProviders();
7493
7754
  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.`);
7755
+ 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
7756
  }
7496
7757
  };
7497
7758
  const stop = () => {
@@ -7701,9 +7962,12 @@ async function resolveUpApprover(context, flags, canPrompt) {
7701
7962
  }
7702
7963
  /** The platform browser opener — detached, silent, never load-bearing. */
7703
7964
  function openBrowser(url) {
7704
- const command = process.platform === "darwin" ? ["open", url]
7705
- : process.platform === "win32" ? ["cmd", "/c", "start", "", url]
7706
- : ["xdg-open", url];
7965
+ // TOOLROLL_BROWSER_COMMAND names another program to open it with (it gets the address as its one argument).
7966
+ const chosen = envValue(process.env, "BROWSER_COMMAND");
7967
+ const command = chosen !== undefined && chosen !== "" ? [chosen, url]
7968
+ : process.platform === "darwin" ? ["open", url]
7969
+ : process.platform === "win32" ? ["cmd", "/c", "start", "", url]
7970
+ : ["xdg-open", url];
7707
7971
  try {
7708
7972
  const child = spawnChild(command[0], command.slice(1), { detached: true, stdio: "ignore" });
7709
7973
  child.unref();
@@ -7951,10 +8215,14 @@ async function upCommand(flags, context) {
7951
8215
  if (repos.length === 0 && projectRoots.length === 0) {
7952
8216
  return fail(write, json, "up", "no-projects", "choose where your projects live once: `toolroll up --project-root <dir>`", EXIT.refused);
7953
8217
  }
7954
- for (const repo of repos)
7955
- progress(`repository ${repo}`);
7956
- for (const root of projectRoots)
7957
- progress(`projects ${root}`);
8218
+ // A person at a terminal reads three lines (onboarding); the folders are for --verbose, a service log, or an agent.
8219
+ const quietStart = !json && !flags.has("verbose") && (context.upSeams?.terminal ?? process.stdout.isTTY === true) && !underAgent(context.upSeams?.env ?? process.env);
8220
+ if (!quietStart) {
8221
+ for (const repo of repos)
8222
+ progress(`repository ${repo}`);
8223
+ for (const root of projectRoots)
8224
+ progress(`projects ${root}`);
8225
+ }
7958
8226
  // 3. Reserve the port BEFORE any identity or enrollment mutation
7959
8227
  // (finding 7/19): a busy port must refuse while the world is untouched.
7960
8228
  const probe = createNetServer();
@@ -8128,7 +8396,7 @@ async function upCommand(flags, context) {
8128
8396
  const hardStop = () => {
8129
8397
  const terminated = terminateLiveProviders();
8130
8398
  if (terminated > 0)
8131
- progress(`Hard stop: ${terminated} provider process group(s) terminated.`);
8399
+ progress(`Stopped ${terminated} running build(s). Their work is saved, and each resumes when the builder starts again.`);
8132
8400
  };
8133
8401
  const stop = () => {
8134
8402
  if (stopping) {
@@ -8139,6 +8407,10 @@ async function upCommand(flags, context) {
8139
8407
  resolveStopped();
8140
8408
  for (const controller of followControllers)
8141
8409
  controller.abort();
8410
+ const building = store.liveClaimCount(runnerName, clock());
8411
+ if (building > 0) {
8412
+ 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.`);
8413
+ }
8142
8414
  graceTimer = setTimeout(hardStop, stopGraceMs);
8143
8415
  graceTimer.unref?.();
8144
8416
  };
@@ -8175,6 +8447,9 @@ async function upCommand(flags, context) {
8175
8447
  return containmentDetail || effective.refusal !== null ? [`watch: ${describeContainment(effective)}`] : [];
8176
8448
  };
8177
8449
  const loopResults = new Map();
8450
+ // One record of project passes for the whole service: fair scheduling
8451
+ // across its projects (project-concurrency.ts).
8452
+ const projectPasses = new ProjectPasses();
8178
8453
  const launchRepo = async (repo, bind) => {
8179
8454
  if (loopResults.has(repo) || stopping)
8180
8455
  return;
@@ -8202,6 +8477,7 @@ async function upCommand(flags, context) {
8202
8477
  isStopping: () => stopping,
8203
8478
  onFollowController: controller => followControllers.push(controller),
8204
8479
  containmentLines,
8480
+ passes: projectPasses,
8205
8481
  onReady: () => {
8206
8482
  markReady();
8207
8483
  if (bind)
@@ -8324,31 +8600,28 @@ async function upCommand(flags, context) {
8324
8600
  write("Run `toolroll onboard` inside a repository to add it and install the Toolroll skill for your agent.");
8325
8601
  }
8326
8602
  else {
8603
+ // Three lines (onboarding): where it is, that it opens signed in, and what to do if it doesn't. The browser gets a
8604
+ // one-time sign-in link (single use, ten minutes, this computer only); it is never printed. The password stays
8605
+ // saved beside the database, for later and for other devices, and is never printed either.
8606
+ const account = approverPlan2.approver;
8607
+ const link = account === null || flags.has("no-open") ? null : console_.server.mintSignInLink(account);
8608
+ const home = homedir();
8609
+ const file = approverPlan2.passwordFile === null ? null : approverPlan2.passwordFile.startsWith(`${home}/`) ? `~${approverPlan2.passwordFile.slice(home.length)}` : approverPlan2.passwordFile;
8610
+ const signInWords = account === null
8611
+ ? `sign in as one of ${approverPlan2.approvers.join(", ")}`
8612
+ : file === null ? `sign in as ${account} with your password` : `sign in as ${account}: the password is in ${file}`;
8327
8613
  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`);
8614
+ write(`Toolroll is on ${url}`);
8615
+ if (link !== null) {
8616
+ write("Opening it in your browser now, already signed in.");
8617
+ write(`If it doesn't open, go to that address and ${signInWords}.`);
8618
+ (context.upSeams?.openBrowser ?? openBrowser)(new URL(link, url).toString());
8338
8619
  }
8339
8620
  else {
8340
- write(` login one of: ${approverPlan2.approvers.join(", ")}`);
8621
+ write(`${signInWords.charAt(0).toUpperCase()}${signInWords.slice(1)}.`);
8622
+ write("Ctrl-C stops it.");
8341
8623
  }
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
8624
  }
8350
- if (!json && !flags.has("no-open") && terminal)
8351
- (context.upSeams?.openBrowser ?? openBrowser)(url);
8352
8625
  }
8353
8626
  // 10. Supervise to the end.
8354
8627
  await stopped;
@@ -9239,7 +9512,23 @@ async function statusCommand(positional, flags, context) {
9239
9512
  const brokenIntegrations = await savedIntegrations(context).then(list => list.filter(one => one.state === "broken"), () => []);
9240
9513
  const brokenLine = integrationsBrokenLine(brokenIntegrations);
9241
9514
  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])]);
9515
+ const projects = projectBuildsStatus(context);
9516
+ const projectsLine = projects.length === 0 ? null
9517
+ : `Builds by project: ${projects.slice(0, 8).map(one => `${one.name} ${one.running} of ${one.limit}`).join(", ")}${projects.length > 8 ? ", …" : ""}`;
9518
+ return succeed(context.write, context.json, command, { ...status, projects, ...update, ...integrations }, () => [...renderInstallationStatus(status), ...(projectsLine === null ? [] : [projectsLine]), ...(line === null ? [] : [line]), ...(brokenLine === null ? [] : [brokenLine])]);
9519
+ }
9520
+ /** Each known project's running builds against its limit: its own number, never past its workers' capacity. */
9521
+ function projectBuildsStatus(context) {
9522
+ const now = context.clock();
9523
+ const running = context.store.runningBuildsByRepo(now);
9524
+ const saved = savedProjectConcurrency(context.databaseFile);
9525
+ const workers = context.store.listRunners().filter(one => one.retiredAt === null);
9526
+ const repos = [...new Set([...context.store.listProjects().map(one => one.path), ...context.store.knownRepos(), ...running.keys()])].sort();
9527
+ return repos.map(repo => {
9528
+ const capacities = workers.filter(one => one.repos.includes(repo)).map(one => one.capacity);
9529
+ const setting = saved.get(repo) ?? PROJECT_CONCURRENCY_DEFAULT;
9530
+ return { repo, name: projectName(repo), running: running.get(repo) ?? 0, limit: capacities.length === 0 ? setting : effectiveConcurrency(setting, Math.max(...capacities)) };
9531
+ });
9243
9532
  }
9244
9533
  /** Where `integrations` looks: the installation's files beside the database, and every project it knows. */
9245
9534
  async function integrationIoFor(context) {
@@ -9351,6 +9640,10 @@ function taskCommand(positional, flags, context) {
9351
9640
  demo: context.store.isDemo(), credentials: () => askCredentials(flags, context), ...(context.publishExec === undefined ? {} : { exec: context.publishExec }) });
9352
9641
  case "add":
9353
9642
  return addTask(rest, flags, context);
9643
+ case "checks":
9644
+ return runTaskChecks(rest, flags, context);
9645
+ case "add-tests":
9646
+ return addTestsTask(rest, flags, context);
9354
9647
  case "list":
9355
9648
  return listTasks(flags, context);
9356
9649
  case "show":
@@ -9361,6 +9654,8 @@ function taskCommand(positional, flags, context) {
9361
9654
  return taskEvidenceCommand(rest, flags, context);
9362
9655
  case "state":
9363
9656
  return stateTask(rest, flags, context);
9657
+ case "ask":
9658
+ return askTask(rest, flags, context);
9364
9659
  case "block":
9365
9660
  return blockTask(rest, flags, context);
9366
9661
  case "unblock":
@@ -9410,6 +9705,10 @@ async function addTask(positional, flags, context) {
9410
9705
  const title = positional.join(" ").trim();
9411
9706
  if (title === "")
9412
9707
  return fail(write, json, "task add", "usage", "a task needs a title", EXIT.usage);
9708
+ // --checks quick|full|off: this task's checks, overriding the project's level.
9709
+ const checks = text(flags, "checks");
9710
+ if (checks !== undefined && !isCheckLevel(checks))
9711
+ return fail(write, json, "task add", "usage", "--checks is quick, full or off", EXIT.usage);
9413
9712
  // The same text rules every filing door applies (Codex adoption review,
9414
9713
  // finding 7): the bare CLI path must not accept a title the console or a
9415
9714
  // template would refuse.
@@ -9471,6 +9770,12 @@ async function addTask(positional, flags, context) {
9471
9770
  ]);
9472
9771
  }
9473
9772
  const id = text(flags, "id") ?? slug(title, now);
9773
+ // Re-filing: the new task replaces an earlier one, which is cancelled and reads "Replaced by <id>".
9774
+ const replaces = text(flags, "replaces");
9775
+ if (replaces !== undefined && (replaces === id || store.getTask(replaces) === null)) {
9776
+ return fail(write, json, "task add", "unknown-task", `no task \`${replaces}\` to replace`, EXIT.refused);
9777
+ }
9778
+ noteActingPerson(flags, context);
9474
9779
  // The existence check goes *inside* the replayed body. Outside it, a retry
9475
9780
  // with the same key hits "already exists" and reports failure for a task the
9476
9781
  // first attempt created — which is precisely the retry idempotency exists to
@@ -9497,12 +9802,91 @@ async function addTask(positional, flags, context) {
9497
9802
  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
9803
  }
9499
9804
  }
9805
+ if (replaces !== undefined)
9806
+ store.setTaskState(replaces, "cancelled", now, {}, undefined, outcome.task.id);
9807
+ if (checks !== undefined) {
9808
+ const chosen = setTaskCheckLevel(store, outcome.task.id, checks, "cli", now);
9809
+ if (!chosen.ok)
9810
+ return fail(write, json, "task add", "checks-fixed", chosen.message, EXIT.refused);
9811
+ }
9500
9812
  const link = consoleLinkFor(context, `/t/${encodeURIComponent(outcome.task.id)}`);
9501
9813
  return succeed(write, json, "task add", {
9502
9814
  task: outcome.task,
9815
+ ...(replaces === undefined ? {} : { replaces }),
9503
9816
  repo: placedIn === undefined ? null : resolve(placedIn),
9817
+ ...(checks === undefined ? {} : { checks }),
9504
9818
  ...(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}`])]);
9819
+ }, () => [`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}`])]);
9820
+ }
9821
+ /** The newest finished build result of a task (any version), for follow-ups. */
9822
+ function latestBuildResult(store, taskId) {
9823
+ const family = store.taskFamilyOf(taskId, null, true);
9824
+ const versions = family === null ? [taskId] : [family.current.id, ...family.versions.map(one => one.id).filter(id => id !== family.current.id)];
9825
+ for (const id of versions) {
9826
+ const ref = store.lookupRef(id);
9827
+ const run = ref === null ? undefined : store.runsFor(ref.id).find(one => one.role === "builder" && one.finishedAt !== null && one.headRevision !== null);
9828
+ if (run !== undefined)
9829
+ return { runId: run.id, head: run.headRevision };
9830
+ }
9831
+ return null;
9832
+ }
9833
+ /**
9834
+ * `toolroll task checks <task> [--level quick|full]` — Run checks on the
9835
+ * result's exact commit, now, in the foreground. A pass upgrades what the
9836
+ * result says; a failure stays visible. Asking again while one runs is the same ask.
9837
+ */
9838
+ async function runTaskChecks(positional, flags, context) {
9839
+ const { store, write, json } = context;
9840
+ const clock = context.clock ?? (() => new Date());
9841
+ const taskId = positional[0];
9842
+ if (taskId === undefined)
9843
+ return fail(write, json, "task checks", "usage", "`toolroll task checks <task> [--level quick|full]`", EXIT.usage);
9844
+ const level = text(flags, "level") ?? "quick";
9845
+ if (level !== "quick" && level !== "full")
9846
+ return fail(write, json, "task checks", "usage", "--level is quick or full", EXIT.usage);
9847
+ const demoFence = refuseDemo(context, "task checks");
9848
+ if (demoFence !== null)
9849
+ return demoFence;
9850
+ const result = latestBuildResult(store, taskId);
9851
+ if (result === null)
9852
+ return fail(write, json, "task checks", "no-result", `${taskId} has no finished result to check`, EXIT.refused);
9853
+ const asked = requestFollowUpChecks(store, { runId: result.runId, level, actor: "cli" }, clock());
9854
+ if (!asked.ok)
9855
+ return fail(write, json, "task checks", asked.reason, asked.message, EXIT.refused);
9856
+ const done = await runFollowUpCheck(store, context.evidenceRoot, asked.request, { now: clock });
9857
+ const state = done?.state ?? "waiting";
9858
+ const words = `${CHECK_LEVEL_WORDS[asked.level]} checks`;
9859
+ const payload = { task: taskId, run: result.runId, head: result.head, request: asked.request, level: asked.level, state, exitCode: done?.exitCode ?? null };
9860
+ if (state === "failed") {
9861
+ if (json) {
9862
+ write(envelopeJson({ ok: false, command: "task checks", reason: "checks-failed", ...payload }));
9863
+ return EXIT.failed;
9864
+ }
9865
+ write(`${words} failed on ${result.head.slice(0, 7)}${done?.exitCode == null ? "" : ` (exit ${done.exitCode})`}. The result shows the failure.`);
9866
+ return EXIT.failed;
9867
+ }
9868
+ return succeed(write, json, "task checks", payload, () => [
9869
+ state === "passed" ? `${words} passed on ${result.head.slice(0, 7)}. The result's status is upgraded.`
9870
+ : state === "running" ? `${words} are already running on ${result.head.slice(0, 7)}.`
9871
+ : state === "not-run" ? `${words} didn't run: ${done?.note ?? "see the result's log"}`
9872
+ : `${words} are queued on ${result.head.slice(0, 7)}.`,
9873
+ ]);
9874
+ }
9875
+ /** `toolroll task add-tests <task>` — file a small task to write tests for its change. */
9876
+ function addTestsTask(positional, _flags, context) {
9877
+ const { store, write, json, now } = context;
9878
+ const taskId = positional[0];
9879
+ if (taskId === undefined)
9880
+ return fail(write, json, "task add-tests", "usage", "`toolroll task add-tests <task>`", EXIT.usage);
9881
+ const result = latestBuildResult(store, taskId);
9882
+ if (result === null)
9883
+ return fail(write, json, "task add-tests", "no-result", `${taskId} has no finished result to write tests for`, EXIT.refused);
9884
+ const filed = fileAddTestsTask(store, context.evidenceRoot, { runId: result.runId, actor: "cli", filedVia: "cli" }, now);
9885
+ if (!filed.ok)
9886
+ return fail(write, json, "task add-tests", filed.reason, filed.message, EXIT.refused);
9887
+ return succeed(write, json, "task add-tests", { task: taskId, filed: filed.id, existing: filed.existing }, () => [
9888
+ filed.existing ? `Tests for ${taskId} are already filed as ${filed.id}.` : `Filed ${filed.id} to add tests for ${taskId}. Approve its scope to start.`,
9889
+ ]);
9506
9890
  }
9507
9891
  /**
9508
9892
  * Say what a task needs before it may run: capability keys, `kind:name`.
@@ -10006,7 +10390,15 @@ async function stateTask(positional, flags, context) {
10006
10390
  `${id} is now ${state} in ${backendName}.`,
10007
10391
  ]);
10008
10392
  }
10009
- const moved = store.setTaskState(id, state, now, mutationFrom(flags, now), text(flags, "reason"));
10393
+ const replacedBy = text(flags, "replaced-by");
10394
+ if (replacedBy !== undefined) {
10395
+ if (state !== "cancelled")
10396
+ return fail(write, json, "task state", "usage", "--replaced-by goes with cancelled: `task state <id> cancelled --replaced-by <id>`", EXIT.usage);
10397
+ if (replacedBy === id || store.getTask(replacedBy) === null)
10398
+ return fail(write, json, "task state", "unknown-task", `no task \`${replacedBy}\` to replace it`, EXIT.refused);
10399
+ }
10400
+ noteActingPerson(flags, context);
10401
+ const moved = store.setTaskState(id, state, now, mutationFrom(flags, now), text(flags, "reason"), replacedBy);
10010
10402
  if (!moved.ok) {
10011
10403
  if (moved.reason === "reason-required" || moved.reason === "bad-reason") {
10012
10404
  return fail(write, json, "task state", moved.reason, moved.reason === "reason-required"
@@ -10017,7 +10409,72 @@ async function stateTask(positional, flags, context) {
10017
10409
  ? fail(write, json, "task state", "external-closed", "the tracker closed this — reopen it first, or leave it cancelled", EXIT.refused)
10018
10410
  : fail(write, json, "task state", "unknown-task", `no task \`${id}\``, EXIT.refused);
10019
10411
  }
10020
- return succeed(write, json, "task state", { id, state }, () => [`${id} is now ${state}.`]);
10412
+ return succeed(write, json, "task state", { id, state, ...(replacedBy === undefined ? {} : { replacedBy }) }, () => [replacedBy === undefined ? `${id} is now ${state}.` : `${id} is replaced by ${replacedBy}.`]);
10413
+ }
10414
+ /** 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). */
10415
+ function noteActingPerson(flags, context) {
10416
+ if (currentActor() !== null)
10417
+ return;
10418
+ const name = text(flags, "as"), token = text(flags, "token");
10419
+ // A typed password is checked (a success names the person); the remembered login was checked when it was saved.
10420
+ if (token !== undefined) {
10421
+ if (name !== undefined && name !== "" && token !== "")
10422
+ authenticateAccount(context.store, name, token);
10423
+ return;
10424
+ }
10425
+ const remembered = readLoginFile(join(dirname(context.databaseFile), UP_LOGIN_FILE));
10426
+ if (remembered !== null && (name === undefined || name === remembered.name))
10427
+ claimActor(remembered.name);
10428
+ }
10429
+ /** `task ask <id> --person <name> --why "…"`: the lead hands a task to a person. It pings them once, and the task is theirs. */
10430
+ function askTask(positional, flags, context) {
10431
+ const { store, write, json, now } = context;
10432
+ const [id, ...extra] = positional;
10433
+ const person = text(flags, "person"), why = text(flags, "why")?.trim();
10434
+ if (id === undefined || extra.length > 0 || person === undefined || why === undefined || why === "") {
10435
+ return fail(write, json, "task ask", "usage", "`toolroll task ask <id> --person <name> --why \"what they need to do\"`", EXIT.usage);
10436
+ }
10437
+ if (why.length > 500 || /[\u0000-\u001f\u007f]/.test(why))
10438
+ return fail(write, json, "task ask", "usage", "--why is at most 500 plain characters on one line", EXIT.usage);
10439
+ noteActingPerson(flags, context);
10440
+ if (currentActor() === null)
10441
+ 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);
10442
+ const asked = store.askPerson(id, person, why, now);
10443
+ if (!asked.ok) {
10444
+ return fail(write, json, "task ask", asked.reason, asked.reason === "unknown-task" ? `no task \`${id}\``
10445
+ : 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);
10446
+ }
10447
+ return succeed(write, json, "task ask", { id, person, why }, () => [`Asked ${person} about ${id}. It is theirs now, and its updates reach them.`]);
10448
+ }
10449
+ /** `lead token [--revoke]`: the owner (behind their password) mints the credential their lead acts with. */
10450
+ async function leadCommand(positional, flags, context) {
10451
+ const { store, write, json } = context;
10452
+ const [action, ...extra] = positional;
10453
+ const command = "lead token";
10454
+ if (action !== "token" || extra.length > 0)
10455
+ return fail(write, json, `lead ${action ?? ""}`.trim(), "usage", "`toolroll lead token [--revoke] --as <you> --token <password>`", EXIT.usage);
10456
+ for (const name of flags.keys())
10457
+ if (!["as", "token", "revoke", "db", "json"].includes(name))
10458
+ return fail(write, json, command, "usage", `--${name} is not a lead token option.`, EXIT.usage);
10459
+ if (currentActor()?.lead === true)
10460
+ return fail(write, json, command, "refused", "A lead cannot mint lead tokens. The person it acts for runs this with their password.", EXIT.refused);
10461
+ const acting = await askCredentials(flags, context);
10462
+ const verified = acting === null ? null : authenticateApprover(store, acting.name, acting.token);
10463
+ if (acting === null || verified === null || !verified.ok) {
10464
+ 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);
10465
+ }
10466
+ const now = context.clock();
10467
+ if (flags.has("revoke")) {
10468
+ const ended = store.revokeLeadCredentials(acting.name, acting.name, now);
10469
+ return succeed(write, json, command, { owner: acting.name, revoked: ended }, () => [ended === 0 ? "You had no lead token." : "Your lead token no longer works."]);
10470
+ }
10471
+ const minted = store.mintLeadCredential(acting.name, acting.name, now);
10472
+ return succeed(write, json, command, { owner: acting.name, id: minted.id, token: minted.token, actor: `lead for ${acting.name}` }, () => [
10473
+ minted.token,
10474
+ `Lead for ${acting.name}. Shown once; it replaces any earlier lead token.`,
10475
+ "Your lead passes it as --token <it> (or TOOLROLL_LEAD_TOKEN) on task and assignment commands. Its own work pings nobody;",
10476
+ "it reaches you when the lead asks you (task ask), when it fails with nothing left to try, or for a security alert.",
10477
+ ]);
10021
10478
  }
10022
10479
  function blockTask(positional, flags, context) {
10023
10480
  const { store, write, json } = context;
@@ -10971,6 +11428,10 @@ async function chatCommand(flags, context) {
10971
11428
  * win, and a different --as than the remembered name is not answered.
10972
11429
  */
10973
11430
  function credentialsFrom(flags, context) {
11431
+ // The lead signs in as its owner with its own token, never the owner's password.
11432
+ const lead = currentActor();
11433
+ if (context.leadToken !== undefined && lead?.lead === true)
11434
+ return { name: lead.account, token: context.leadToken };
10974
11435
  let name = text(flags, "as");
10975
11436
  let token = text(flags, "token");
10976
11437
  if (token === undefined) {
@@ -10997,11 +11458,12 @@ async function flowsCommand(positional, flags, context) {
10997
11458
  const { store } = context;
10998
11459
  const registered = await loadRepos(registryPathOf(context)).catch(() => ({ error: "unreadable" }));
10999
11460
  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";
11461
+ const writes = positional[0] !== undefined && positional[0] !== "list" && positional[0] !== "show" && positional[0] !== "export";
11001
11462
  const acting = writes && !flags.has("help") ? await askCredentials(flags, context) : null;
11002
11463
  const dir = dirname(context.databaseFile);
11003
11464
  return runFlowsCommand(positional, flags, {
11004
11465
  store, write: context.write, json: context.json, clock: context.clock, credentials: acting, projects, configDir: dir, evidenceRoot: context.evidenceRoot,
11466
+ ...(context.flowFetch === undefined ? {} : { fetchFlow: context.flowFetch }),
11005
11467
  // "Check now": the same io the worker's pass checks triggers with.
11006
11468
  triggerIo: { gh: context.flowTriggerIo?.gh ?? run, fetch: context.flowTriggerIo?.fetch ?? fetch, dir: context.flowTriggerIo?.dir ?? dir,
11007
11469
  shell: context.flowTriggerIo?.shell ?? context.flowStepIo?.shell ?? run, scratch: context.flowTriggerIo?.scratch ?? join(dir, "flow-scratch"),
@@ -11250,6 +11712,45 @@ async function projectRulesCommand(positional, flags, context) {
11250
11712
  context.store.setApprovalRules(repo, next, acting.name, context.clock());
11251
11713
  return succeed(context.write, context.json, command, { repo, rules: next }, () => [`${repo}: ${rulesSummary(next)}`]);
11252
11714
  }
11715
+ /** `project concurrency [<n>] --repo <p>`: how many of a project's tasks build at once. Changing it is an approver's act. */
11716
+ async function projectConcurrencyCommand(positional, flags, context) {
11717
+ const command = "project concurrency";
11718
+ const allowed = new Set(["repo", "as", "token", "token-file", "token-env", "db", "json"]);
11719
+ for (const name of flags.keys())
11720
+ if (!allowed.has(name))
11721
+ return fail(context.write, context.json, command, "usage", `--${name} is not a project concurrency option.`, EXIT.usage);
11722
+ const repoFlag = text(flags, "repo");
11723
+ if (positional.length > 2 || repoFlag === undefined)
11724
+ return fail(context.write, context.json, command, "usage", "Use project concurrency [<n>] --repo <project path>.", EXIT.usage);
11725
+ const registered = await loadRepos(registryPathOf(context)).catch(() => ({ error: "unreadable" }));
11726
+ const known = [...new Set([...context.store.knownRepos(), ...("error" in registered ? [] : registered.repos)])];
11727
+ const repo = known.find(one => one === repoFlag || one === resolve(repoFlag) || one === canonicalProject(repoFlag));
11728
+ if (repo === undefined)
11729
+ return fail(context.write, context.json, command, "not-found", "That isn't a project Toolroll knows.", EXIT.refused);
11730
+ const workers = context.store.listRunners().filter(one => one.retiredAt === null && one.repos.includes(repo));
11731
+ const capacity = workers.length === 0 ? null : Math.max(...workers.map(one => one.capacity));
11732
+ const words = (n) => `${repo}: builds up to ${n} at once${capacity !== null && capacity < n ? ` (its worker runs ${capacity} at once, so ${capacity} for now)` : ""}.`;
11733
+ const given = positional[1];
11734
+ if (given === undefined) {
11735
+ const current = projectConcurrency(context.databaseFile, repo);
11736
+ return succeed(context.write, context.json, command, { repo, concurrency: current, workerCapacity: capacity }, () => [words(current)]);
11737
+ }
11738
+ const n = parseProjectConcurrency(given);
11739
+ if (n === null)
11740
+ return fail(context.write, context.json, command, "usage", `Builds at once is a whole number from 1 to ${RUNNER_CAPACITY_MAX}.`, EXIT.usage);
11741
+ const acting = await askCredentials(flags, context);
11742
+ const verified = acting === null ? null : authenticateApprover(context.store, acting.name, acting.token);
11743
+ if (acting === null || verified === null || !verified.ok || !context.store.accountCanAccess(acting.name, repo)) {
11744
+ 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);
11745
+ }
11746
+ const changed = saveProjectConcurrency(context.databaseFile, repo, n);
11747
+ context.store.recordProjectConcurrency(acting.name, repo, changed.before, changed.after, context.clock());
11748
+ context.store.bumpWake();
11749
+ return succeed(context.write, context.json, command, { repo, before: changed.before, concurrency: changed.after, workerCapacity: capacity }, () => [
11750
+ words(changed.after),
11751
+ ...(changed.before > changed.after ? ["Builds already running carry on; the new number applies to the next one."] : []),
11752
+ ]);
11753
+ }
11253
11754
  /** Write a pack or export where asked (never over an existing file), or to the terminal. */
11254
11755
  function writeOut(context, command, out, content, summary, line, data) {
11255
11756
  if (out === undefined) {
@@ -11474,11 +11975,12 @@ async function notificationsCommand(positional, flags, context) {
11474
11975
  const [action, value, ...extra] = positional;
11475
11976
  const command = `notifications${action === undefined ? "" : ` ${action}`}`;
11476
11977
  const store = context.store;
11978
+ const muting = action === "mute" || action === "unmute";
11477
11979
  for (const name of flags.keys())
11478
- if (!["as", "token", "token-file", "token-env", "db", "json"].includes(name))
11980
+ if (!["as", "token", "token-file", "token-env", "db", "json", ...(muting ? ["repo"] : [])].includes(name))
11479
11981
  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)) {
11982
+ const usage = "Use notifications quiet, notifications all, notifications digest <HH:MM>|off, or notifications mute|unmute --repo <project>.";
11983
+ 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
11984
  return fail(context.write, context.json, command, "usage", usage, EXIT.usage);
11483
11985
  }
11484
11986
  if (action === "digest" && value !== "off" && !isDigestTime(value))
@@ -11489,12 +11991,26 @@ async function notificationsCommand(positional, flags, context) {
11489
11991
  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
11992
  }
11491
11993
  const now = context.clock();
11994
+ if (muting) {
11995
+ const typed = text(flags, "repo");
11996
+ const repo = canonicalProject(typed) ?? resolve(typed);
11997
+ const known = new Set([...store.knownRepos(), ...store.listProjects().map(one => one.path)]);
11998
+ if (!known.has(repo) || !store.accountCanAccess(acting.name, repo))
11999
+ return fail(context.write, context.json, command, "unknown-project", `${typed} is not a project you can see.`, EXIT.refused);
12000
+ store.setProjectMuted(acting.name, repo, action === "mute", now);
12001
+ const muted = store.mutedProjects(acting.name);
12002
+ return succeed(context.write, context.json, command, { repo, muted: action === "mute", mutedProjects: muted }, () => [
12003
+ action === "mute" ? `${basename(repo)} is muted: no pings for it. The console and your evening digest still show it.` : `${basename(repo)} pings you again.`,
12004
+ ]);
12005
+ }
11492
12006
  const preference = action === "quiet" || action === "all" ? store.setNotificationPreference(acting.name, { mode: action }, acting.name, now)
11493
12007
  : action === "digest" ? store.setNotificationPreference(acting.name, { digestAt: value === "off" ? null : value }, acting.name, now)
11494
12008
  : store.notificationPreference(acting.name);
11495
- return succeed(context.write, context.json, command, { mode: preference.mode, digestAt: preference.digestAt }, () => [
12009
+ const muted = store.mutedProjects(acting.name);
12010
+ return succeed(context.write, context.json, command, { mode: preference.mode, digestAt: preference.digestAt, mutedProjects: muted }, () => [
11496
12011
  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
12012
  preference.digestAt === null ? "No evening digest." : `Evening digest at ${preference.digestAt}: what finished, what waits and what failed.`,
12013
+ ...(muted.length === 0 ? [] : [`Muted: ${muted.map(one => basename(one)).join(", ")}.`]),
11498
12014
  ]);
11499
12015
  }
11500
12016
  /** `retention show | preview | set <kind> <period>` (v105): how long each kind of data is kept, and what the daily