toolroll 0.8.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/operate.js CHANGED
@@ -13,6 +13,7 @@ import { buildExport, exportSummary, exportZip, writeExportFolder } from "./expo
13
13
  import { startBudgetAlerts } from "./budget-alerts.js";
14
14
  import { liftAuthPause, openAuthPauses, signInGate, signInWords, startSignInProbes } from "./provider-auth.js";
15
15
  import { createConnectionChecker } from "./provider-connection.js";
16
+ import { checkIntegrations, integrationsBrokenLine, integrationsNow, renderIntegrations } from "./integrations.js";
16
17
  import { buildHandoff, handoffLines, loginAccount, runOnboard } from "./agent-onboard.js";
17
18
  import { backupFiles, backupFolderOf, backupOwner, backupNow, restoreDatabase, startBackups } from "./backup.js";
18
19
  import { pushLimitSink } from "./provider-limits.js";
@@ -67,7 +68,7 @@ import { runTaskOutcomeCommand } from "./task-outcome-cli.js";
67
68
  */
68
69
  import { homedir, hostname, tmpdir } from "node:os";
69
70
  import { basename, dirname, join, resolve } from "node:path";
70
- import { openStore, openStoreNoMigrate, CONCURRENT_WRITER_WAIT_MS, isDatabaseBusy, databasePath, BUILT_IN, DEFAULT_ACTOR, parseCapabilityKey, verifiedAuthor, contestantProfileOf, } from "./store.js";
71
+ import { openStore, openStoreNoMigrate, CONCURRENT_WRITER_WAIT_MS, isDatabaseBusy, databasePath, BUILT_IN, DEFAULT_ACTOR, parseCapabilityKey, verifiedAuthor, contestantProfileOf, isDigestTime, } from "./store.js";
71
72
  import { randomBytes, randomInt, randomUUID } from "node:crypto";
72
73
  import { authorizePlanUnderMode } from "./plan-auto.js";
73
74
  import { ghDispatchAdapter, mirrorTaskId, syncPass } from "./sync.js";
@@ -136,6 +137,7 @@ import { verifyApproverByPassword } from "./principal.js";
136
137
  import { authorizedProject, canonicalProject, projectName, resolveCeiling } from "./project.js";
137
138
  import { tally, spendLine } from "./summary.js";
138
139
  import { bodyHashOf, describePublicationGrant, observeChecks, publicationBody, publishPass, sweepMerges, } from "./publish.js";
140
+ import { checkPublishing, publishingOf, savePublishing, followPullRequests } from "./pull-request-flow.js";
139
141
  import { acquire, acquireFallback, acquireIfReady, completeFenced, finalizeFailureFenced, finalizeMalformedFenced, finalizeParkFenced, finalizePlanFenced, finalizePlanFailureFenced, finalizeScoutFenced, finalizeScoutFailureFenced, heartbeat, release, reap, currentClaim, DEFAULT_LEASE_MS, SYNC_MAX_AGE_MS, acquireContinuation, } from "./claim.js";
140
142
  import { disposeBuildOutcome, holdStaleApproval, regateTask } from "./dispose.js";
141
143
  import { attendedLivenessState } from "./liveness.js";
@@ -213,11 +215,14 @@ export const OPERATE_HELP = `toolroll — operating the queue
213
215
  toolroll storage clean [--yes] preview removing finished tasks' clean checkouts (their branches stay); --yes removes them
214
216
  toolroll storage cleanup <when> remove a finished task's clean checkout: finished|2d|7d|never (instance operator)
215
217
  toolroll storage discard <path> --yes throw away a checkout kept for its changes; its branch stays
218
+ toolroll integrations [--json] which integrations work: Connected, Not set up or Broken, and what to do (--saved: no new checks)
216
219
  toolroll monitoring where the audit stream and traces go, and how each destination is doing
217
220
  toolroll check-progress <run> current or final approved check progress
218
221
  toolroll spend [--month YYYY-MM] [--csv] what agent work cost, by project, person, teammate and model
219
222
  toolroll export --out <path> [--zip] everything Toolroll knows, in a folder or .zip (no secrets)
220
223
  toolroll budget list|set|remove monthly budgets (--all | --project <p> | --person <name> | --teammate <id>) --usd <n> [--alerts-only]
224
+ toolroll notifications [quiet|all] how chats reach you: only when you're needed (the default), or every step
225
+ toolroll notifications digest <HH:MM>|off one evening message: what finished, what waits, what failed
221
226
  toolroll retention show|preview how long evidence, checkout records, chat and notifications are kept; what the daily sweep would remove
222
227
  toolroll retention set <kind> <period> evidence|checkouts|chat|notifications, 30d|1y|forever (instance operator)
223
228
  toolroll backup now|list back the database up now; list backups and how the last ones went
@@ -264,6 +269,10 @@ External trackers — build what a tracker nominates, under local approvals
264
269
  the dispatch grant: its own explicit
265
270
  yes, never in any default; writes a
266
271
  plane marker label to the repository
272
+ toolroll publish setup [--repo <path>] [--merge-method squash|merge|rebase]
273
+ [--merge-when-green] --yes one-step pull requests: checks gh sign-in
274
+ and push rights, then Complete offers
275
+ "Complete and open a pull request"
267
276
  toolroll publish grant --github <owner/name> --allow-merge
268
277
  --merge-method squash|merge|rebase [--merge-delete-branch] --yes
269
278
  auto-merge this plane's own PRs —
@@ -532,7 +541,7 @@ export const TASK_ACTIONS = [
532
541
  "reopen", "scope", "approve", "hold", "unhold", "require", "requeue", "regate", "plan",
533
542
  "review", "accept", "repair", "route", "stop", "resume", "complete", "revise",
534
543
  ];
535
- export const PUBLISH_ACTIONS = ["grant", "revoke", "status", "unblock", "rearm", "merge", "refire"];
544
+ export const PUBLISH_ACTIONS = ["setup", "grant", "revoke", "status", "unblock", "rearm", "merge", "refire"];
536
545
  export const CONFIG_ACTIONS = ["show", "set", "clear"];
537
546
  export const APPROVER_ACTIONS = ["list", "add"];
538
547
  export const ROUTINE_ACTIONS = ["list", "add", "show", "approve", "refresh", "pause", "resume", "run-now"];
@@ -577,6 +586,8 @@ export const OPERATE_BOOLEAN_FLAGS = new Set([
577
586
  "html", "csv", "alerts-only",
578
587
  // v105: the full export.
579
588
  "zip",
589
+ // Settings → Integrations: the last checks, without checking again.
590
+ "saved",
580
591
  ]);
581
592
  export function parseOperateArgs(argv) {
582
593
  const positional = [];
@@ -720,6 +731,7 @@ export async function runOperate(command, argv, write, options = {}) {
720
731
  ...(options.dispatchAdapter === undefined ? {} : { dispatchAdapter: options.dispatchAdapter }),
721
732
  ...(options.flowTriggerIo === undefined ? {} : { flowTriggerIo: options.flowTriggerIo }),
722
733
  ...(options.flowStepIo === undefined ? {} : { flowStepIo: options.flowStepIo }),
734
+ ...(options.integrationIo === undefined ? {} : { integrationIo: options.integrationIo }),
723
735
  ...(options.shouldStop === undefined ? {} : { shouldStop: options.shouldStop }),
724
736
  ...(options.mateSeams === undefined ? {} : { mateSeams: options.mateSeams }),
725
737
  ...(options.heldCoordinator === undefined ? {} : { heldCoordinator: options.heldCoordinator }),
@@ -817,6 +829,8 @@ async function dispatch(command, positional, flags, context) {
817
829
  return budgetCommand(positional, flags, context);
818
830
  case "retention":
819
831
  return retentionCommand(positional, flags, context);
832
+ case "notifications":
833
+ return notificationsCommand(positional, flags, context);
820
834
  case "backup":
821
835
  return backupCommand(positional, flags, context);
822
836
  case "export":
@@ -825,6 +839,8 @@ async function dispatch(command, positional, flags, context) {
825
839
  return policyCommand(positional, flags, context);
826
840
  case "storage":
827
841
  return storageCommand(positional, flags, context);
842
+ case "integrations":
843
+ return integrationsCommand(positional, flags, context);
828
844
  case "monitoring": {
829
845
  // Where the audit stream and traces go, and how each destination is doing. Settings → Monitoring changes it.
830
846
  const settings = readMonitoring(dirname(context.databaseFile));
@@ -7230,6 +7246,14 @@ async function runWatchLoop(args) {
7230
7246
  });
7231
7247
  if (checks.failing > 0)
7232
7248
  progress(`watch: CI is red on ${checks.failing} published PR(s) — the outbox has it`);
7249
+ const followed = await followPullRequests(store, checks.seen, {
7250
+ evidenceRoot: context.evidenceRoot,
7251
+ ...(context.publishExec === undefined ? {} : { exec: context.publishExec }),
7252
+ });
7253
+ if (followed.revisions > 0)
7254
+ progress(`watch: filed ${followed.revisions} CI revision(s)`);
7255
+ if (followed.merged > 0)
7256
+ progress(`watch: merged ${followed.merged} pull request(s) whose checks passed`);
7233
7257
  }
7234
7258
  catch (error) {
7235
7259
  progress(`watch: CI observation failed — ${describe(error)}; retrying on its next interval`);
@@ -7710,7 +7734,7 @@ async function onboardCommand(flags, context) {
7710
7734
  const gitRun = context.gitRunner ?? ((file, args, opts) => run(file, [...args], { ...(opts?.cwd === undefined ? {} : { cwd: opts.cwd }), timeoutMs: 10_000 }));
7711
7735
  const registryPath = registryPathOf(context);
7712
7736
  const checker = createConnectionChecker({ home, env });
7713
- return runOnboard({
7737
+ const code = await runOnboard({
7714
7738
  write,
7715
7739
  json,
7716
7740
  yes: flag(flags, "yes"),
@@ -7750,7 +7774,23 @@ async function onboardCommand(flags, context) {
7750
7774
  return { ok: true, added: "error" in before || !before.repos.includes(repo) };
7751
7775
  },
7752
7776
  checkConnection: seams.checkConnection ?? (agent => checker(agent)),
7777
+ pullRequests: seams.pullRequests ?? (async (repo) => {
7778
+ const publishing = publishingOf(store, repo);
7779
+ if (publishing.on)
7780
+ return `on for ${publishing.githubRepo} into ${publishing.base}`;
7781
+ const checked = await checkPublishing(repo, context.publishExec === undefined ? {} : { exec: context.publishExec });
7782
+ if (!checked.ok)
7783
+ return `off: ${checked.message}`;
7784
+ return `off: turn on with toolroll publish setup --repo ${repo} --yes (asks your password), or in Settings → Projects`;
7785
+ }),
7753
7786
  });
7787
+ // One line when an integration is Broken, from the last checks only (never a new check here).
7788
+ if (!json) {
7789
+ const line = integrationsBrokenLine(await savedIntegrations(context).catch(() => []));
7790
+ if (line !== null)
7791
+ write(line);
7792
+ }
7793
+ return code;
7754
7794
  }
7755
7795
  async function upCommand(flags, context) {
7756
7796
  const { store, write, json, clock } = context;
@@ -8539,6 +8579,42 @@ async function publishCommand(positional, flags, context) {
8539
8579
  if (action !== undefined && !PUBLISH_ACTIONS.includes(action)) {
8540
8580
  return fail(write, json, "publish", "usage", `unknown \`publish ${action}\` — try ${PUBLISH_ACTIONS.join(", ")}, or bare \`publish\` for the publication pass`, EXIT.usage);
8541
8581
  }
8582
+ if (action === "setup") {
8583
+ const mergeMethod = text(flags, "merge-method") ?? "squash";
8584
+ if (!["squash", "merge", "rebase"].includes(mergeMethod)) {
8585
+ return fail(write, json, "publish setup", "usage", "--merge-method is squash, merge or rebase", EXIT.usage);
8586
+ }
8587
+ const checked = await checkPublishing(repo, context.publishExec === undefined ? {} : { exec: context.publishExec });
8588
+ if (!checked.ok)
8589
+ return fail(write, json, "publish setup", checked.reason, checked.message, EXIT.refused);
8590
+ const settings = { mergeMethod: mergeMethod, mergeWhenGreen: flags.has("merge-when-green") };
8591
+ const plan = checked.plan;
8592
+ const terms = [
8593
+ `Pull requests for ${plan.githubRepo} into ${plan.base}${plan.account === null ? "" : `, as ${plan.account}`}.`,
8594
+ "Complete will offer \"Complete and open a pull request\"; the pull request opens from the exact completed commit.",
8595
+ `Merges ${settings.mergeMethod} and delete the branch${settings.mergeWhenGreen ? ", by themselves once checks pass" : " when a person selects Merge"}.`,
8596
+ ];
8597
+ if (!flags.has("yes")) {
8598
+ if (json) {
8599
+ write(envelopeJson({ ok: false, command: "publish setup", reason: "unconfirmed", proposed: { ...plan, ...settings } }));
8600
+ return EXIT.refused;
8601
+ }
8602
+ for (const line of terms)
8603
+ write(line);
8604
+ write("Nothing is set up yet. Repeat with --yes --as <you> --token <password> to turn it on.");
8605
+ return EXIT.refused;
8606
+ }
8607
+ const acting = await askCredentials(flags, context);
8608
+ if (acting === null) {
8609
+ return fail(write, json, "publish setup", "usage", "turning on pull requests takes --as <you> --token <your password>", EXIT.usage);
8610
+ }
8611
+ const authenticated = authenticateApprover(store, acting.name, acting.token, repo);
8612
+ if (!authenticated.ok) {
8613
+ return fail(write, json, "publish setup", authenticated.reason, describeApproveFailure(authenticated.reason, acting.name), EXIT.refused);
8614
+ }
8615
+ savePublishing(store, plan, acting.name, settings, clock());
8616
+ return succeed(write, json, "publish setup", { publishing: publishingOf(store, repo) }, () => ["Pull requests are on.", ...terms]);
8617
+ }
8542
8618
  if (action === "grant") {
8543
8619
  const github = text(flags, "github");
8544
8620
  if (github === undefined || !/^[\w.-]+\/[\w.-]+$/.test(github)) {
@@ -9101,7 +9177,49 @@ async function statusCommand(positional, flags, context) {
9101
9177
  notifySecurityRelease(context.store, release, current, method.updateCommand, context.clock());
9102
9178
  const line = updateLine(release, current, method.updateCommand);
9103
9179
  const update = release === null ? {} : { update: { current, latest: release.version, security: release.security, updateCommand: method.updateCommand, url: release.url } };
9104
- return succeed(context.write, context.json, command, { ...status, ...update }, () => [...renderInstallationStatus(status), ...(line === null ? [] : [line])]);
9180
+ // One line when an integration is Broken, from the last checks only: status never waits on a check.
9181
+ const brokenIntegrations = await savedIntegrations(context).then(list => list.filter(one => one.state === "broken"), () => []);
9182
+ const brokenLine = integrationsBrokenLine(brokenIntegrations);
9183
+ const integrations = brokenIntegrations.length === 0 ? {} : { integrations: { broken: brokenIntegrations.map(one => ({ key: one.key, name: one.name, fix: one.action.kind === "fix" ? one.action.words : null })) } };
9184
+ return succeed(context.write, context.json, command, { ...status, ...update, ...integrations }, () => [...renderInstallationStatus(status), ...(line === null ? [] : [line]), ...(brokenLine === null ? [] : [brokenLine])]);
9185
+ }
9186
+ /** Where `integrations` looks: the installation's files beside the database, and every project it knows. */
9187
+ async function integrationIoFor(context) {
9188
+ const registered = await loadProjectRegistry(registryPathOf(context)).catch(() => ({ error: "unreadable" }));
9189
+ const repos = [...new Set([...("error" in registered ? [] : registered.repos), ...context.store.listProjects().map(one => one.path)])];
9190
+ const seams = context.integrationIo ?? {};
9191
+ return {
9192
+ store: context.store,
9193
+ dir: dirname(context.databaseFile),
9194
+ telegramTokenFile: context.telegramTokenFile,
9195
+ env: process.env,
9196
+ repos,
9197
+ gh: (file, args, options) => run(file, [...args], options),
9198
+ checkConnection: createConnectionChecker(),
9199
+ clock: context.clock,
9200
+ ...seams,
9201
+ };
9202
+ }
9203
+ /** The integrations from files and the last checks: no network, for `status` and `onboard`. */
9204
+ async function savedIntegrations(context) {
9205
+ return integrationsNow(await integrationIoFor(context));
9206
+ }
9207
+ /** `toolroll integrations [--json]`: check every integration now and say which work. `--saved` skips the checks. */
9208
+ async function integrationsCommand(positional, flags, context) {
9209
+ const command = "integrations";
9210
+ const allowed = new Set(["db", "json", "saved"]);
9211
+ for (const name of flags.keys()) {
9212
+ if (!allowed.has(name))
9213
+ return fail(context.write, context.json, command, "usage", `--${name} is not an integrations option.`, EXIT.usage);
9214
+ }
9215
+ if (positional.length > 0)
9216
+ return fail(context.write, context.json, command, "usage", "Use `toolroll integrations [--saved] [--json]`.", EXIT.usage);
9217
+ const io = await integrationIoFor(context);
9218
+ const list = flag(flags, "saved") ? integrationsNow(io) : await checkIntegrations(io);
9219
+ const counts = { connected: 0, "not-set-up": 0, broken: 0 };
9220
+ for (const one of list)
9221
+ counts[one.state] += 1;
9222
+ return succeed(context.write, context.json, command, { integrations: list, counts }, () => renderIntegrations(list));
9105
9223
  }
9106
9224
  async function waitTask(positional, flags, context) {
9107
9225
  const command = "task wait";
@@ -11270,6 +11388,35 @@ async function budgetCommand(positional, flags, context) {
11270
11388
  const saved = store.setBudget({ scope: target.scope, key: target.key, limitMicrousd: Math.round(dollars) * 1_000_000, hardStop: !flags.has("alerts-only") }, acting.name, context.clock());
11271
11389
  return succeed(context.write, context.json, command, { budget: saved }, () => [`${budgetLabel(saved).replace(/'s$/, "")}: ${spendUsd(saved.limitMicrousd)} a month${saved.hardStop ? ", API work stops at 100%" : ", alerts only"}.`]);
11272
11390
  }
11391
+ /** `notifications [quiet | all | digest <HH:MM> | digest off]`: how chats reach this person — only when they are
11392
+ * needed (the default), every step, and an optional evening digest. Each person sets their own. */
11393
+ async function notificationsCommand(positional, flags, context) {
11394
+ const [action, value, ...extra] = positional;
11395
+ const command = `notifications${action === undefined ? "" : ` ${action}`}`;
11396
+ const store = context.store;
11397
+ for (const name of flags.keys())
11398
+ if (!["as", "token", "token-file", "token-env", "db", "json"].includes(name))
11399
+ return fail(context.write, context.json, command, "usage", `--${name} is not a notifications option.`, EXIT.usage);
11400
+ const usage = "Use notifications quiet, notifications all, or notifications digest <HH:MM>|off.";
11401
+ if (extra.length > 0 || (action !== undefined && !["quiet", "all", "digest"].includes(action)) || (action === "digest") !== (value !== undefined)) {
11402
+ return fail(context.write, context.json, command, "usage", usage, EXIT.usage);
11403
+ }
11404
+ if (action === "digest" && value !== "off" && !isDigestTime(value))
11405
+ return fail(context.write, context.json, command, "usage", "The digest time is HH:MM on a 24-hour clock, like 18:30, or off.", EXIT.usage);
11406
+ const acting = await askCredentials(flags, context);
11407
+ const verified = acting === null ? null : authenticateApprover(store, acting.name, acting.token);
11408
+ if (acting === null || verified === null || !verified.ok) {
11409
+ return fail(context.write, context.json, command, "refused", "Notifications are set per person: pass --as and --token (or use the remembered login).", EXIT.refused);
11410
+ }
11411
+ const now = context.clock();
11412
+ const preference = action === "quiet" || action === "all" ? store.setNotificationPreference(acting.name, { mode: action }, acting.name, now)
11413
+ : action === "digest" ? store.setNotificationPreference(acting.name, { digestAt: value === "off" ? null : value }, acting.name, now)
11414
+ : store.notificationPreference(acting.name);
11415
+ return succeed(context.write, context.json, command, { mode: preference.mode, digestAt: preference.digestAt }, () => [
11416
+ preference.mode === "quiet" ? "Only when you're needed: one message per task, updated as it moves, and a new one when something needs you." : "Every step: a message for each update.",
11417
+ preference.digestAt === null ? "No evening digest." : `Evening digest at ${preference.digestAt}: what finished, what waits and what failed.`,
11418
+ ]);
11419
+ }
11273
11420
  /** `retention show | preview | set <kind> <period>` (v105): how long each kind of data is kept, and what the daily
11274
11421
  * sweep would remove now. Setting takes an instance operator. */
11275
11422
  async function retentionCommand(positional, flags, context) {
@@ -231,6 +231,7 @@ function deleteRows(store, repo, d, now) {
231
231
  del("telegram_conversation_part", `${IN("conversation")} OR ${IN("source_run")} OR ${IN("task_id")}`, conversations, R, K);
232
232
  del("telegram_conversation", IN("id"), conversations);
233
233
  del("telegram_outbound_message", `project = ? OR ${IN("notification")} OR ${IN("task_ref")} OR ${IN("source_run")}`, repo, notifications, T, R);
234
+ del("chat_card_task", IN("task_ref"), T);
234
235
  del("push_delivery", IN("notification"), notifications);
235
236
  del("notification_delivery", IN("notification"), notifications);
236
237
  del("notification", IN("id"), notifications);
package/dist/publish.d.ts CHANGED
@@ -20,6 +20,7 @@
20
20
  * @-mention in its conclusion cannot acquire GitHub semantics.
21
21
  */
22
22
  import { type ExecResult, type RunOptions } from "./exec.js";
23
+ import { summarizeChecks } from "./pulls.js";
23
24
  import type { Publication, PublicationGrant, Store } from "./store.js";
24
25
  export declare const BODY_TEMPLATE_VERSION = 2;
25
26
  /** After this many failed attempts a publication stops retrying and pages instead. */
@@ -69,6 +70,16 @@ export type CheckReport = {
69
70
  failing: number;
70
71
  resolved: number;
71
72
  problems: string[];
73
+ /** Every observation that won its settle, for the pull-request follower (CI revisions, Ready to merge). */
74
+ seen: ObservedPullRequest[];
75
+ };
76
+ export type ObservedPullRequest = {
77
+ publication: Publication;
78
+ headOid: string;
79
+ state: ReturnType<typeof summarizeChecks>;
80
+ remoteState: "MERGED" | "CLOSED" | null;
81
+ rollup: unknown;
82
+ mergeCommit: string | null;
72
83
  };
73
84
  /**
74
85
  * Read CI for every PR this control plane opened — structured rollup and the
package/dist/publish.js CHANGED
@@ -254,7 +254,7 @@ async function openOrAdopt(store, publication, grant, exec, clock) {
254
254
  export async function observeChecks(store, options) {
255
255
  const clock = options.clock ?? (() => new Date());
256
256
  const exec = options.exec ?? execRun;
257
- const report = { observed: 0, failing: 0, resolved: 0, problems: [] };
257
+ const report = { observed: 0, failing: 0, resolved: 0, problems: [], seen: [] };
258
258
  for (const publication of store.openedPublications()) {
259
259
  if (publication.prNumber === null)
260
260
  continue;
@@ -266,7 +266,7 @@ export async function observeChecks(store, options) {
266
266
  const viewed = await exec("gh", [
267
267
  "pr", "view", String(publication.prNumber),
268
268
  "--repo", publication.githubRepo,
269
- "--json", "statusCheckRollup,headRefOid,state",
269
+ "--json", "statusCheckRollup,headRefOid,state,mergeCommit",
270
270
  ], { timeoutMs: EXEC_TIMEOUT_MS });
271
271
  if (viewed.code !== 0) {
272
272
  report.problems.push(`PR #${publication.prNumber}: not read — ${viewed.notFound ? "gh is not on PATH" : firstLine(viewed.stderr) || `exit ${viewed.code}`}`);
@@ -290,11 +290,15 @@ export async function observeChecks(store, options) {
290
290
  // remote state is recorded, its episodes resolve, and neither the
291
291
  // review queue nor the next observation pass carries it forever.
292
292
  const remoteState = typeof payload.state === "string" ? payload.state.toUpperCase() : null;
293
+ const mergeCommit = typeof payload.mergeCommit === "object" && payload.mergeCommit !== null && typeof payload.mergeCommit.oid === "string"
294
+ ? payload.mergeCommit.oid : null;
293
295
  if (remoteState === "MERGED" || remoteState === "CLOSED") {
296
+ let won = false;
294
297
  store.transact(() => {
295
298
  const settled = store.settleObservation({ githubRepo: publication.githubRepo, prNumber: publication.prNumber, headSha: headOid, state: "none", generation }, clock());
296
299
  if (!settled.won)
297
300
  return;
301
+ won = true;
298
302
  store.recordPublicationRemoteState(publication.id, remoteState, clock());
299
303
  report.resolved += store.resolveCiEpisodes(publication.githubRepo, publication.prNumber, null, clock());
300
304
  // A closed PR moots its merge blocker and supersedes its intent —
@@ -311,6 +315,8 @@ export async function observeChecks(store, options) {
311
315
  });
312
316
  }
313
317
  });
318
+ if (won)
319
+ report.seen.push({ publication, headOid, state: "none", remoteState, rollup: payload.statusCheckRollup, mergeCommit });
314
320
  continue;
315
321
  }
316
322
  const state = summarizeChecks(payload.statusCheckRollup);
@@ -322,6 +328,7 @@ export async function observeChecks(store, options) {
322
328
  continue;
323
329
  }
324
330
  store.recordPublicationCheckState(publication.id, state, clock());
331
+ report.seen.push({ publication, headOid, state, remoteState: null, rollup: payload.statusCheckRollup, mergeCommit });
325
332
  // The episode identity carries the REPOSITORY (audit C-2): PR #55 in
326
333
  // repo A and PR #55 in repo B are different worlds, and one's failure
327
334
  // must never light the other's repair button.
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Complete → pull request → merge, without a person running git.
3
+ *
4
+ * Setup is one step per project: the project's GitHub remote and default branch are read, `gh` must be signed
5
+ * in with push rights, and a publication grant is saved that publishes only when a person chooses "Complete and
6
+ * open a pull request" (never on every build). The publisher (publish.ts) then pushes the exact accepted commit
7
+ * and opens or adopts the PR, as it always has.
8
+ *
9
+ * The follower reads what observeChecks saw: a red head files ONE revision carrying the failing check's name and
10
+ * a short log excerpt, fenced as untrusted CI output — at most two across one task's revisions, then a person is
11
+ * asked instead. A green head on the exact accepted commit is announced once as Ready to merge.
12
+ *
13
+ * Merging is a person's act behind their password (or the project's opt-in "Merge when checks pass"): a fresh
14
+ * read must show the PR open, on the accepted commit, with checks passing; GitHub's head-commit match guards
15
+ * the race; the branch is deleted; the merge commit and who merged are recorded in the ledger.
16
+ */
17
+ import { type ObservedPullRequest, type PublishExec } from "./publish.js";
18
+ import type { VerifiedApprover } from "./principal.js";
19
+ import type { Publication, Store } from "./store.js";
20
+ export type MergeMethod = "squash" | "merge" | "rebase";
21
+ export declare const MERGE_METHODS: readonly MergeMethod[];
22
+ /** CI revisions one task may file across its revisions before a person is asked instead. */
23
+ export declare const MAX_CI_REVISIONS = 2;
24
+ export declare const MERGED_ACTION = "pull request merged";
25
+ export declare const REQUESTED_ACTION = "pull request requested";
26
+ type Result<T> = ({
27
+ ok: true;
28
+ } & T) | {
29
+ ok: false;
30
+ reason: string;
31
+ message: string;
32
+ };
33
+ export type PublishingPlan = {
34
+ repo: string;
35
+ githubRepo: string;
36
+ remote: string;
37
+ base: string;
38
+ account: string | null;
39
+ };
40
+ /** owner/name from a GitHub remote URL (https, ssh or scp form), or null when it is not GitHub. */
41
+ export declare function githubRepoOf(remoteUrl: string): string | null;
42
+ /** Everything setup needs, checked in the order a person would fix it — each refusal says what to do next. */
43
+ export declare function checkPublishing(repo: string, options?: {
44
+ exec?: PublishExec;
45
+ remote?: string;
46
+ }): Promise<Result<{
47
+ plan: PublishingPlan;
48
+ }>>;
49
+ export type PublishingSettings = {
50
+ mergeMethod: MergeMethod;
51
+ mergeWhenGreen: boolean;
52
+ };
53
+ export type Publishing = {
54
+ on: false;
55
+ } | ({
56
+ on: true;
57
+ githubRepo: string;
58
+ base: string;
59
+ grantedBy: string;
60
+ legacy: boolean;
61
+ } & PublishingSettings);
62
+ export declare function publishingOf(store: Store, repo: string | null): Publishing;
63
+ /** The grant setup creates: this project's GitHub repo, its default branch, Toolroll's own branches, pull requests
64
+ * only when a person asks at Complete, ready for review, squash by default, branch deleted on merge. */
65
+ export declare function savePublishing(store: Store, plan: PublishingPlan, by: string, settings: Partial<PublishingSettings>, now: Date): void;
66
+ /** Change how merges happen, keeping every other term of the live grant. */
67
+ export declare function saveMergeSettings(store: Store, repo: string, settings: PublishingSettings, by: string, now: Date): Result<object>;
68
+ /** Whether "Complete and open a pull request" may be offered for this result, and if not, why (null = offer it). */
69
+ export declare function pullRequestBlocker(store: Store, runId: number): string | null;
70
+ /**
71
+ * Mark the exact displayed result complete and owe a pull request for its exact commit — one transaction, so
72
+ * "complete" and "this must reach a PR" can't come apart. A result already marked complete may still open its
73
+ * pull request. The watch process pushes and opens it; the task shows its progress.
74
+ */
75
+ export declare function completeAndOpenPullRequest(store: Store, input: {
76
+ taskId: string;
77
+ digest: string;
78
+ runId: number;
79
+ who: VerifiedApprover;
80
+ root: string;
81
+ }, now: Date): Result<{
82
+ publication: Publication;
83
+ }>;
84
+ export type FollowReport = {
85
+ revisions: number;
86
+ asked: number;
87
+ ready: number;
88
+ merged: number;
89
+ problems: string[];
90
+ };
91
+ /** The revision note: the failing check by name, then its log as fenced, labelled untrusted data. At most 500 characters. */
92
+ export declare function ciRevisionNote(check: string, prNumber: number, head: string, excerpt: string | null): string;
93
+ /**
94
+ * What observeChecks saw, acted on for pull requests opened through Complete: one revision per red head (at most
95
+ * two per task, then a person is asked), Ready to merge once per green head on the accepted commit, and the merge
96
+ * itself when the project merges on green. A remote merge done elsewhere is recorded with its commit.
97
+ */
98
+ export declare function followPullRequests(store: Store, seen: readonly ObservedPullRequest[], options: {
99
+ evidenceRoot: string;
100
+ exec?: PublishExec;
101
+ clock?: () => Date;
102
+ }): Promise<FollowReport>;
103
+ /** A person's merge: their password first, then the same checked merge. */
104
+ export declare function mergeAsPerson(store: Store, input: {
105
+ runId: number;
106
+ name: string;
107
+ password: string;
108
+ exec?: PublishExec;
109
+ clock?: () => Date;
110
+ }): Promise<Result<{
111
+ commit: string | null;
112
+ }>>;
113
+ /**
114
+ * Merge a followed pull request: callers have already established who is merging. A fresh read must show it open,
115
+ * not a draft, on the accepted commit, with checks passing; the merge names that commit so GitHub refuses a head
116
+ * that moved, uses the project's method, and deletes the branch.
117
+ */
118
+ export declare function mergePullRequest(store: Store, input: {
119
+ runId: number;
120
+ by: string;
121
+ exec?: PublishExec;
122
+ clock?: () => Date;
123
+ }): Promise<Result<{
124
+ commit: string | null;
125
+ }>>;
126
+ export type PullRequestView = {
127
+ runId: number;
128
+ prNumber: number | null;
129
+ prUrl: string | null;
130
+ state: "opening" | "running" | "waiting" | "ready" | "failing" | "merged" | "closed" | "failed";
131
+ label: string;
132
+ detail: string;
133
+ mergeCommit: string | null;
134
+ mergeMethod: MergeMethod;
135
+ canMerge: boolean;
136
+ revisionTask: string | null;
137
+ };
138
+ /** The pull request a result opened through Complete, in plain words; null when it opened none. */
139
+ export declare function pullRequestViewOf(store: Store, runId: number): PullRequestView | null;
140
+ export {};