toolroll 0.9.3 → 0.9.5

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.
@@ -241,21 +241,32 @@ function workStage(store, flow, stage, card, now, options, outcome, onward) {
241
241
  wait(`${stage.title}'s instructions are too long for a task. Open the flow and shorten them.`);
242
242
  return;
243
243
  }
244
- const filed = fileTaskProposal(store, {
245
- title: (report ? `${stage.title}: ${card.title}` : card.title).slice(0, 200),
246
- repo: flow.repo,
247
- goal,
248
- filedVia: `flow:${flow.id}`, filedBy: filerFor(card.createdBy), // who made the card asked for the work (an owner can be reassigned by anyone)
249
- deliverable: report ? "report" : "branch",
250
- planning: report ? "skip" : stage.planning ?? "auto",
251
- acceptance: report ? ACCEPTANCE.report : ACCEPTANCE.task,
252
- admittedRepos: [flow.repo],
253
- }, now);
244
+ // Filed exactly once however many passes advance this project: the card is claimed (still this entry, in this zone,
245
+ // with no task) in the same transaction that files its task, so another pass finds it claimed and leaves it.
246
+ const filed = store.transact(() => {
247
+ const fresh = store.getFlowCard(card.id);
248
+ if (fresh === null || fresh.state !== "active" || fresh.entry !== card.entry || fresh.stage !== stage.id || fresh.task !== null)
249
+ return null;
250
+ const made = fileTaskProposal(store, {
251
+ title: (report ? `${stage.title}: ${card.title}` : card.title).slice(0, 200),
252
+ repo: flow.repo,
253
+ goal,
254
+ filedVia: `flow:${flow.id}`, filedBy: filerFor(card.createdBy), // who made the card asked for the work (an owner can be reassigned by anyone)
255
+ deliverable: report ? "report" : "branch",
256
+ planning: report ? "skip" : stage.planning ?? "auto",
257
+ acceptance: report ? ACCEPTANCE.report : ACCEPTANCE.task,
258
+ admittedRepos: [flow.repo],
259
+ }, now);
260
+ if (made.ok)
261
+ store.updateFlowCard(card.id, { task: made.id, ...(report || fresh.primaryTask !== null ? {} : { primaryTask: made.id }), waiting: "Filed as a task" }, now);
262
+ return made;
263
+ });
264
+ if (filed === null)
265
+ return;
254
266
  if (!filed.ok) {
255
267
  wait(/[.!?]$/.test(filed.message) ? filed.message : `${filed.message}.`);
256
268
  return;
257
269
  }
258
- store.updateFlowCard(card.id, { task: filed.id, ...(report || card.primaryTask !== null ? {} : { primaryTask: filed.id }), waiting: "Filed as a task" }, now);
259
270
  outcome.filed.push(filed.id);
260
271
  return;
261
272
  }
@@ -15,6 +15,7 @@
15
15
  import { createHash } from "node:crypto";
16
16
  import { assignmentOf } from "./assignment.js";
17
17
  import { cardFollowers, notifyPeople } from "./flow-people.js";
18
+ import { adoptPublishedPullRequest } from "./publish.js";
18
19
  import { failingCheckOf, mergePullRequest, owePullRequest, pullRequestBlocker, pullRequestFollowOf } from "./pull-request-flow.js";
19
20
  import { requestResultChanges } from "./result-actions.js";
20
21
  import { revisionSourceOf } from "./result-review.js";
@@ -56,6 +57,15 @@ export async function pullRequestStep(store, flow, definition, stage, card, now,
56
57
  body: `${said}${stage.onFail === null ? "" : `\n\nIt's back in ${titleOf(stage.onFail)}${revision === null ? "" : `, as revision ${revision}`}.`}` }, now);
57
58
  return true;
58
59
  };
60
+ // Publishing that can't proceed is not the build's fault: rebuilding wouldn't change it, so the card waits here,
61
+ // saying what to do, and the people on it hear once. It carries on by itself once the cause is fixed.
62
+ const stuck = (said) => {
63
+ if (!waiting(said))
64
+ return false;
65
+ const people = card.owner === null ? cardFollowers(store, card) : [card.owner];
66
+ notifyPeople(store, card, people, null, { key: `pr-stuck:${card.entry}`, subject: `${stage.title} is waiting on you for “${card.title}”`, attention: true, body: said }, now);
67
+ return true;
68
+ };
59
69
  // With no failure path, a card that didn't pass waits for a person to move it (a move is a fresh visit).
60
70
  if (stage.onFail === null && card.waiting !== null && card.waiting.endsWith(" Fix it, then move the card to try again."))
61
71
  return false;
@@ -68,14 +78,27 @@ export async function pullRequestStep(store, flow, definition, stage, card, now,
68
78
  if (publication === null || pullRequestFollowOf(store, publication.id) === null) {
69
79
  if (publication === null) {
70
80
  const blocked = pullRequestBlocker(store, runId);
81
+ // Only a result that can't be published as built goes back to be built again.
82
+ if (blocked !== null && (blocked === "This result has no commit to publish." || blocked.includes("looks like a secret")))
83
+ return failed(blocked);
84
+ if (blocked === "Pull requests aren't set up for this project.")
85
+ return stuck("Pull requests aren't set up for this project. Turn them on in Projects → Pull requests (or run toolroll publish setup); the card carries on by itself.");
71
86
  if (blocked !== null)
72
- return failed(blocked === "Pull requests aren't set up for this project." ? "Pull requests aren't set up for this project. Turn them on in Projects → Pull requests, then move the card back here." : blocked);
87
+ return stuck(blocked);
73
88
  }
74
89
  publication = owePullRequest(store, runId, `flow ${flow.name}`, card.id, now);
75
90
  }
91
+ if (publication.state === "failed") {
92
+ const said = `Couldn't open the pull request: ${publication.lastError ?? "GitHub refused it"}. The commit is safe locally. Fix that, then move the card back to Build for a fresh result.`;
93
+ if (card.waiting === said)
94
+ return false;
95
+ // A pull request opened for this commit anyway (another pass, or a person) is the card's pull request.
96
+ if (await adoptPublishedPullRequest(store, publication, io.gh, () => now))
97
+ publication = store.publicationForRun(runId);
98
+ else
99
+ return stuck(said);
100
+ }
76
101
  const pr = publication.prNumber === null ? "the pull request" : `PR #${publication.prNumber}`;
77
- if (publication.state === "failed")
78
- return failed(`Couldn't open the pull request: ${publication.lastError ?? "GitHub refused it"}. The commit is safe locally.`);
79
102
  if (publication.state !== "opened")
80
103
  return waiting(publication.lastError === null ? "Opening the pull request…" : `Opening the pull request. Retrying: ${publication.lastError}`);
81
104
  const link = publication.prUrl ?? pr;
@@ -137,7 +137,7 @@ export async function runFlowSteps(store, repo, now, io) {
137
137
  store.updateFlowCard(card.id, { waiting }, now);
138
138
  continue;
139
139
  }
140
- if (!store.claimFlowStep({ card: card.id, entry: card.entry, stage: stage.id, kind: stage.kind, script: script?.name ?? null, scriptVersion: script?.version ?? null }, now))
140
+ if (!claimStep(store, card, { stage: stage.id, kind: stage.kind, script: script?.name ?? null, scriptVersion: script?.version ?? null }, now))
141
141
  continue;
142
142
  pass.ran++;
143
143
  store.updateFlowCard(card.id, { waiting: { check: "Running its check…", sort: "Sorting…", draft: "Writing the draft…", request: "Calling the address…", email: "Sending the email…", tool: "Using the tool…" }[stage.kind] ?? "Updating the issue…" }, now);
@@ -164,6 +164,16 @@ export async function runFlowSteps(store, repo, now, io) {
164
164
  }
165
165
  return pass;
166
166
  }
167
+ /** Claim this card's visit to its step zone, only while the card is still there on that visit: a pass that read the card
168
+ * before another pass (or a person) moved it never runs the step for a visit that's over. */
169
+ function claimStep(store, card, step, now) {
170
+ return store.transact(() => {
171
+ const fresh = store.getFlowCard(card.id);
172
+ if (fresh === null || fresh.state !== "active" || fresh.entry !== card.entry || fresh.stage !== step.stage)
173
+ return false;
174
+ return store.claimFlowStep({ card: card.id, entry: card.entry, ...step }, now);
175
+ });
176
+ }
167
177
  /** One teammate turn, when one is due for this card: claimed like any step, retried on failure, and waiting while its question is open. */
168
178
  async function teammateStep(store, flow, definition, stage, card, now, io, pass) {
169
179
  // v102: on a protected project an AI teammate never decides an approval; the engine asks the person.
@@ -183,7 +193,7 @@ async function teammateStep(store, flow, definition, stage, card, now, io, pass)
183
193
  // Waiting on a person: its own question, or (v94) a tool call they approve first.
184
194
  if (store.openTeammateQuestionOn(card.id, card.entry) !== null)
185
195
  return true;
186
- if (!store.claimFlowStep({ card: card.id, entry: card.entry, stage: stage.id, kind: "teammate", script: null, scriptVersion: null }, now))
196
+ if (!claimStep(store, card, { stage: stage.id, kind: "teammate", script: null, scriptVersion: null }, now))
187
197
  return true;
188
198
  pass.ran++;
189
199
  const started = Date.now();
package/dist/guides.js CHANGED
@@ -79,6 +79,23 @@ ${AUTHORITY_LINE}
79
79
  detail says why; \`toolroll sync\` refreshes trackers.
80
80
  - \`contest-open\`: a tournament is running on the task; a person picks.
81
81
 
82
+ ## Complete, pull request, merge
83
+
84
+ The console's Complete and Merge, from a terminal. Both are a person's
85
+ acts behind their sign-in (\`--as <you> --token <t>\`, or the remembered
86
+ login); run them only when the person asked for exactly that.
87
+
88
+ - \`task complete <id> --digest <receipt> --pull-request\` marks the exact
89
+ inspected result complete and opens its pull request from that commit
90
+ under the project's publishing setup. \`pull-requests-off\` names the
91
+ setup command (\`publish setup --repo <path> --yes\`).
92
+ - CI is watched on GitHub. \`task show <id> --json\` carries
93
+ \`pullRequest\`: \`prUrl\`, \`state\` (opening, waiting, running, ready,
94
+ failing, merged, closed, failed) and \`mergeCommit\`.
95
+ - \`task merge <id>\` merges it once checks pass, by the project's method
96
+ (squash by default), deletes the branch and records who merged.
97
+ \`checks\` means they are running or failing; the message says which.
98
+
82
99
  ## Flows
83
100
 
84
101
  A flow is a process cards move through, drawn as zones. The terminal
package/dist/operate.d.ts CHANGED
@@ -91,7 +91,7 @@ type Args = {
91
91
  * exports are behavior, not commentary — and the declared command guide
92
92
  * (surface.ts) is tested for exact equality against them.
93
93
  */
94
- export declare const TASK_ACTIONS: readonly ["add", "list", "show", "wait", "state", "block", "unblock", "next", "steer", "assign", "reopen", "scope", "approve", "hold", "unhold", "require", "requeue", "regate", "plan", "review", "accept", "repair", "route", "stop", "resume", "complete", "revise"];
94
+ export declare const TASK_ACTIONS: readonly ["add", "list", "show", "wait", "state", "block", "unblock", "next", "steer", "assign", "reopen", "scope", "approve", "hold", "unhold", "require", "requeue", "regate", "plan", "review", "accept", "repair", "route", "stop", "resume", "complete", "revise", "merge"];
95
95
  export declare const PUBLISH_ACTIONS: readonly ["setup", "grant", "revoke", "status", "unblock", "rearm", "merge", "refire"];
96
96
  export declare const CONFIG_ACTIONS: readonly ["show", "set", "clear"];
97
97
  export declare const APPROVER_ACTIONS: readonly ["list", "add"];
package/dist/operate.js CHANGED
@@ -40,7 +40,7 @@ import { runKnowledgeCommand } from "./knowledge-cli.js";
40
40
  import { FLOWS_VALUE_FLAGS, runFlowsCommand } from "./flows-cli.js";
41
41
  import { runAssignmentCommand } from "./assignment-adapters.js";
42
42
  import { applyProjectProfile, runProjectCommand } from "./project-cli.js";
43
- import { runTaskOutcomeCommand } from "./task-outcome-cli.js";
43
+ import { pullRequestLines, runTaskMergeCommand, runTaskOutcomeCommand } from "./task-outcome-cli.js";
44
44
  /**
45
45
  * The commands that actually move work: authoring tasks, and the claim loop.
46
46
  *
@@ -138,7 +138,7 @@ import { verifyApproverByPassword } from "./principal.js";
138
138
  import { authorizedProject, canonicalProject, projectName, resolveCeiling } from "./project.js";
139
139
  import { tally, spendLine } from "./summary.js";
140
140
  import { bodyHashOf, describePublicationGrant, observeChecks, publicationBody, publishPass, sweepMerges, } from "./publish.js";
141
- import { checkPublishing, publishingOf, savePublishing, followPullRequests } from "./pull-request-flow.js";
141
+ import { checkPublishing, publishingOf, savePublishing, followPullRequests, newestPullRequestOf } from "./pull-request-flow.js";
142
142
  import { starterOf, startersFor, switchOnStarter } from "./flow-starters.js";
143
143
  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";
144
144
  import { disposeBuildOutcome, holdStaleApproval, regateTask } from "./dispose.js";
@@ -178,7 +178,7 @@ import { scout as scoutTask } from "./scout.js";
178
178
  import { profileDigestOf, chainDigestOf, entryDigestOf } from "./scope.js";
179
179
  import { PROVIDER_KEY_ENV, SUBSCRIPTION_CAPABLE, clearProviderKey, keyStatus, readAuthMode, readAuthModeStrict, readProviderKey, saveProviderKey, setAuthMode, verifyProviderKey, verdictWords } from "./keys.js";
180
180
  import { run, terminateLiveProviders, run as execRun } from "./exec.js";
181
- import { containmentStatus, currentContainment, describeContainment, resolveContainment } from "./containment.js";
181
+ import { containmentNotice, containmentStatus, currentContainment, describeContainment, resolveContainment } from "./containment.js";
182
182
  import { readPulls } from "./pulls.js";
183
183
  import { startMaintenance } from "./maintenance.js";
184
184
  import { livePin, modelWatchPass } from "./model-catalog.js";
@@ -235,7 +235,10 @@ export const OPERATE_HELP = `toolroll — operating the queue
235
235
  toolroll ledger checkpoint record the chain's head to copy off this machine (instance operator)
236
236
  toolroll ledger export --from <YYYY-MM-DD> --to <YYYY-MM-DD> [--out <file>]
237
237
  every sealed entry in the range, with an evidence pack per task
238
- toolroll task complete <id> mark the current result complete (--digest for JSON/agents)
238
+ toolroll task complete <id> mark the current result complete (--digest for JSON/agents);
239
+ [--pull-request] --pull-request also opens its pull request
240
+ toolroll task merge <id> --as <you> --token <t>
241
+ merge the task's pull request once checks pass
239
242
  toolroll task revise <id> --feedback "requested change"
240
243
  toolroll assignment show <task> root, current work and exact handoff
241
244
  toolroll assignment updates durable updates (--after <cursor>)
@@ -364,7 +367,9 @@ External trackers — build what a tracker nominates, under local approvals
364
367
  refuses to spawn where none exists
365
368
  (macOS) instead of downgrading.
366
369
  Also read by watch, tick and
367
- daemon install.
370
+ daemon install. --verbose prints
371
+ the exact containment status (a
372
+ service log always has it).
368
373
  toolroll reconcile --repo <path> the morning sweep: recover dead
369
374
  runners, reap expired leases, adopt
370
375
  or forget orphaned worktrees. Run it
@@ -554,7 +559,7 @@ Exit codes
554
559
  export const TASK_ACTIONS = [
555
560
  "add", "list", "show", "wait", "state", "block", "unblock", "next", "steer", "assign",
556
561
  "reopen", "scope", "approve", "hold", "unhold", "require", "requeue", "regate", "plan",
557
- "review", "accept", "repair", "route", "stop", "resume", "complete", "revise",
562
+ "review", "accept", "repair", "route", "stop", "resume", "complete", "revise", "merge",
558
563
  ];
559
564
  export const PUBLISH_ACTIONS = ["setup", "grant", "revoke", "status", "unblock", "rearm", "merge", "refire"];
560
565
  export const CONFIG_ACTIONS = ["show", "set", "clear"];
@@ -607,6 +612,10 @@ export const OPERATE_BOOLEAN_FLAGS = new Set([
607
612
  "zip",
608
613
  // Settings → Integrations: the last checks, without checking again.
609
614
  "saved",
615
+ // task complete: also open the result's pull request.
616
+ "pull-request",
617
+ // up: the exact start-up details (containment) in the terminal too.
618
+ "verbose",
610
619
  ]);
611
620
  export function parseOperateArgs(argv, ownValues = new Set()) {
612
621
  const positional = [];
@@ -7126,7 +7135,8 @@ async function runWatchLoop(args) {
7126
7135
  }
7127
7136
  // What this runner's spawns actually get, in one honest line: native
7128
7137
  // and which backend, observed, or a required policy that will refuse.
7129
- progress(`watch: ${describeContainment(currentContainment())}`);
7138
+ for (const line of args.containmentLines?.(currentContainment()) ?? [`watch: ${describeContainment(currentContainment())}`])
7139
+ progress(line);
7130
7140
  // The night is a row, not "the last 24 hours": everything this watch does
7131
7141
  // attributes to this episode by runner and window, and `brief
7132
7142
  // --latest-watch` bounds itself to exactly it.
@@ -8152,6 +8162,18 @@ async function upCommand(flags, context) {
8152
8162
  return copy;
8153
8163
  };
8154
8164
  const prefix = (repo) => (activeRepos.size > 1 ? `[${projectName(repo)}] ` : "");
8165
+ // Containment at start: one plain sentence, once, for a person at a
8166
+ // terminal; the exact status line with --verbose, in a service log (no
8167
+ // terminal), and always when a required policy will refuse spawns.
8168
+ const containmentDetail = flags.has("verbose") || !(context.upSeams?.terminal ?? process.stdout.isTTY === true);
8169
+ let containmentSaid = false;
8170
+ const containmentLines = (effective) => {
8171
+ const notice = containmentNotice(effective);
8172
+ if (notice !== null && !containmentSaid)
8173
+ progress(notice);
8174
+ containmentSaid = true;
8175
+ return containmentDetail || effective.refusal !== null ? [`watch: ${describeContainment(effective)}`] : [];
8176
+ };
8155
8177
  const loopResults = new Map();
8156
8178
  const launchRepo = async (repo, bind) => {
8157
8179
  if (loopResults.has(repo) || stopping)
@@ -8179,6 +8201,7 @@ async function upCommand(flags, context) {
8179
8201
  progress: line => progress(`${prefix(repo)}${line}`),
8180
8202
  isStopping: () => stopping,
8181
8203
  onFollowController: controller => followControllers.push(controller),
8204
+ containmentLines,
8182
8205
  onReady: () => {
8183
8206
  markReady();
8184
8207
  if (bind)
@@ -8318,10 +8341,10 @@ async function upCommand(flags, context) {
8318
8341
  }
8319
8342
  write(repos.length === 0
8320
8343
  ? ` builder ${runnerName} is ready — add a local folder or GitHub repository in Projects`
8321
- : ` builder ${runnerName} is watching ${repos.length === 1 ? repos[0] : `${repos.length} repositories`}`);
8344
+ : ` builder watching ${repos.length === 1 ? repos[0] : `${repos.length} repositories`}`);
8322
8345
  if (projectRoots.length > 0)
8323
8346
  write(` projects new repositories under ${projectRoots.length === 1 ? projectRoots[0] : `${projectRoots.length} saved folders`} connect automatically`);
8324
- write(" The inbox checklist shows what remains before approved work builds unattended.");
8347
+ write(" Chat shows the steps to your first result.");
8325
8348
  write(" Ctrl-C stops Toolroll on this machine.");
8326
8349
  }
8327
8350
  if (!json && !flags.has("no-open") && terminal)
@@ -8330,7 +8353,7 @@ async function upCommand(flags, context) {
8330
8353
  // 10. Supervise to the end.
8331
8354
  await stopped;
8332
8355
  await registrySupervisor;
8333
- const results = await Promise.all([...loopResults.values()]);
8356
+ await Promise.all([...loopResults.values()]);
8334
8357
  clearInterval(runnerHeartbeat);
8335
8358
  if (runTimer !== undefined)
8336
8359
  clearTimeout(runTimer);
@@ -8363,9 +8386,7 @@ async function upCommand(flags, context) {
8363
8386
  process.stderr.write(`up: ${fatal}\n`);
8364
8387
  return EXIT.failed;
8365
8388
  }
8366
- const ticks = results.reduce((sum, one) => sum + one.result.ticks, 0);
8367
- const built = results.reduce((sum, one) => sum + one.result.built, 0);
8368
- progress(`up: stopped cleanly — ${ticks} pass(es), ${built} with work. Run \`toolroll up\` anywhere on this machine to reconnect every saved project.`);
8389
+ progress("Toolroll stopped. Run `toolroll up` anywhere on this machine to start it again with every saved project.");
8369
8390
  return EXIT.ok;
8370
8391
  }
8371
8392
  // ---- the telegram bridge ---------------------------------------------------
@@ -9325,6 +9346,9 @@ function taskCommand(positional, flags, context) {
9325
9346
  const authenticated = verifyApproverByPassword(context.store, acting.name, acting.token, context.store.knownRepos().filter(repo => context.store.accountCanAccess(acting.name, repo)));
9326
9347
  return authenticated.ok ? authenticated.who : null;
9327
9348
  } });
9349
+ case "merge":
9350
+ return runTaskMergeCommand(rest, flags, { store: context.store, json: context.json, write: context.write, clock: context.clock,
9351
+ demo: context.store.isDemo(), credentials: () => askCredentials(flags, context), ...(context.publishExec === undefined ? {} : { exec: context.publishExec }) });
9328
9352
  case "add":
9329
9353
  return addTask(rest, flags, context);
9330
9354
  case "list":
@@ -9744,6 +9768,8 @@ function showTask(positional, context) {
9744
9768
  // Paused (resume), or the review-retry door — and every stop on record.
9745
9769
  control: taskControlOf(store, ref.id, now),
9746
9770
  stops: store.stopsForTask(ref.id),
9771
+ // The pull request Complete opened (any version), with its CI state and merge commit — the console's view.
9772
+ pullRequest: newestPullRequestOf(store, store.taskFamilyOf(id, null, true)?.versions.map(one => one.id) ?? [id]),
9747
9773
  };
9748
9774
  return succeed(write, json, "task show", detail, () => [
9749
9775
  `${task.id} ${task.state}${ref.deliverable === "report" ? " (scout — delivers a report)" : ""}`,
@@ -9772,6 +9798,7 @@ function showTask(positional, context) {
9772
9798
  ...detail.report.report.followUps.map((one, index) => ` follow-up ${index + 1}: ${one.title}`),
9773
9799
  ]
9774
9800
  : [` report: ${detail.report.problem} (run ${detail.report.run})`]),
9801
+ ...pullRequestLines(detail.pullRequest),
9775
9802
  ...(detail.blockedBy.length > 0 ? [` waits for ${detail.blockedBy.join(", ")}`] : []),
9776
9803
  ...(detail.position === null
9777
9804
  ? []
package/dist/proof.d.ts CHANGED
@@ -332,6 +332,15 @@ export declare function manualReviewOnly(proof: {
332
332
  reasons: readonly string[];
333
333
  matrix?: readonly CriterionMatrixRow[];
334
334
  } | null): boolean;
335
+ /** The criterion id a stored "needs a person's check" reason names, or null
336
+ * for any other reason. The stored words stay as recorded; pages say them
337
+ * through `personCheckWords`. */
338
+ export declare function manualReviewCriterionOf(reason: string): string | null;
339
+ /** A stored verification reason as a person would say it, for pages and
340
+ * cards. The record keeps its exact words; unknown reasons pass through. */
341
+ export declare function plainReasonWords(reason: string): string;
342
+ /** A requirement only a person can confirm, as a person would say it. */
343
+ export declare function personCheckWords(statement: string | null): string;
335
344
  export type CriterionMatrixRow = {
336
345
  /** Controller-captured evidence for direct goal assessment. Kept separately
337
346
  * from the final row state: available evidence is not a satisfied goal. */
package/dist/proof.js CHANGED
@@ -509,7 +509,31 @@ export function manualReviewOnly(proof) {
509
509
  return proof?.verdict === "short" && (proof.matrix === undefined || (proof.matrix.some(row => row.state === "manual-review")
510
510
  && proof.matrix.every(row => row.state === "pass" || row.state === "manual-review")))
511
511
  && proof.reasons.length > 0
512
- && proof.reasons.every(reason => /^criterion "[^"]+" requires manual-review evidence — an operator must accept it before this can verify$/.test(reason));
512
+ && proof.reasons.every(reason => manualReviewCriterionOf(reason) !== null);
513
+ }
514
+ /** The criterion id a stored "needs a person's check" reason names, or null
515
+ * for any other reason. The stored words stay as recorded; pages say them
516
+ * through `personCheckWords`. */
517
+ export function manualReviewCriterionOf(reason) {
518
+ return /^criterion "([^"]+)" requires manual-review evidence — an operator must accept it before this can verify$/.exec(reason)?.[1] ?? null;
519
+ }
520
+ /** A stored verification reason as a person would say it, for pages and
521
+ * cards. The record keeps its exact words; unknown reasons pass through. */
522
+ export function plainReasonWords(reason) {
523
+ if (manualReviewCriterionOf(reason) !== null)
524
+ return "A requirement needs your own check.";
525
+ const unassigned = /^caveat [0-9]+ names no criterion — every caveat is an exception to exactly one signed criterion, named by its exact id \(an unrelated idea belongs in the handoff's follow-ups\): ([\s\S]*)$/.exec(reason);
526
+ if (unassigned !== null)
527
+ return `The agent left a note without saying which requirement it affects: ${unassigned[1]}`;
528
+ const reviewed = /^(?:reviewer:)?([^\s"]+) (contradicts|needs more evidence for) criterion "[^"]+": ([\s\S]*)$/i.exec(reason);
529
+ if (reviewed !== null)
530
+ return `${reviewed[1]} ${reviewed[2].toLowerCase() === "contradicts" ? "says a requirement is not met" : "isn't sure a requirement is met"}: ${reviewed[3]}`;
531
+ return reason;
532
+ }
533
+ /** A requirement only a person can confirm, as a person would say it. */
534
+ export function personCheckWords(statement) {
535
+ const plain = statement?.trim() ?? "";
536
+ return plain === "" ? "You check this one yourself." : `You check this one: ${plain}`;
513
537
  }
514
538
  /** Set equality, exactly — the "must equal the complete sealed git diff
515
539
  * exactly" rule (v39) needs both directions, unlike the legacy subset
package/dist/publish.d.ts CHANGED
@@ -25,6 +25,15 @@ import type { Publication, PublicationGrant, Store } from "./store.js";
25
25
  export declare const BODY_TEMPLATE_VERSION = 2;
26
26
  /** After this many failed attempts a publication stops retrying and pages instead. */
27
27
  export declare const MAX_PUBLISH_ATTEMPTS = 5;
28
+ /**
29
+ * The one grant lookup for a task's publication: its project is the task's repo, and that project's live grant
30
+ * governs it. The publisher, Complete → pull request and a flow's Pull request zone all ask here, so none of them
31
+ * can judge a publication by another project's grant. `fallback`: the repo to assume for a task with none recorded.
32
+ */
33
+ export declare function publicationGrantOf(store: Store, taskRef: number, fallback?: string | null): {
34
+ repo: string | null;
35
+ grant: PublicationGrant | null;
36
+ };
28
37
  export type PublishExec = (file: string, args: readonly string[], options?: RunOptions) => Promise<ExecResult>;
29
38
  /** The grant's terms, in the words the approver must be shown before agreeing. */
30
39
  export declare function describePublicationGrant(grant: {
@@ -65,6 +74,12 @@ export declare function permitsPublication(grant: PublicationGrant | null, publi
65
74
  ok: false;
66
75
  message: string;
67
76
  };
77
+ /**
78
+ * A publication given up on whose pull request exists anyway — a pass that gave up while another opened it, or a
79
+ * person who opened it by hand: the PR on its exact head and base, carrying its exact commit, is adopted as opened
80
+ * (merged or closed ones too; the next observation records which). Whether one was found.
81
+ */
82
+ export declare function adoptPublishedPullRequest(store: Store, publication: Publication, exec: PublishExec, clock: () => Date): Promise<boolean>;
68
83
  export type CheckReport = {
69
84
  observed: number;
70
85
  failing: number;
package/dist/publish.js CHANGED
@@ -31,6 +31,15 @@ export const BODY_TEMPLATE_VERSION = 2;
31
31
  /** After this many failed attempts a publication stops retrying and pages instead. */
32
32
  export const MAX_PUBLISH_ATTEMPTS = 5;
33
33
  const EXEC_TIMEOUT_MS = 60_000;
34
+ /**
35
+ * The one grant lookup for a task's publication: its project is the task's repo, and that project's live grant
36
+ * governs it. The publisher, Complete → pull request and a flow's Pull request zone all ask here, so none of them
37
+ * can judge a publication by another project's grant. `fallback`: the repo to assume for a task with none recorded.
38
+ */
39
+ export function publicationGrantOf(store, taskRef, fallback = null) {
40
+ const repo = store.refById(taskRef)?.repo ?? fallback;
41
+ return { repo, grant: repo === null ? null : store.publicationGrantFor(repo) };
42
+ }
34
43
  /** The grant's terms, in the words the approver must be shown before agreeing. */
35
44
  export function describePublicationGrant(grant) {
36
45
  return [
@@ -107,9 +116,15 @@ export async function publishPass(store, options) {
107
116
  concede(store, publication, "the accepted diff carries a detected secret — rewrite the branch before anything publishes", report, clock);
108
117
  continue;
109
118
  }
119
+ // Each project's watch publishes its own project's work: the push runs in
120
+ // that checkout, under that project's grant. Another project's pass must
121
+ // not touch it — judging it by the wrong grant once recorded "no live
122
+ // grant" errors until it gave up, while its own pass opened the PR.
110
123
  // The grant is re-read per publication, live: revocation is immediate,
111
124
  // and an intent created under a grant that has since died goes nowhere.
112
- const grant = store.publicationGrantFor(options.repo);
125
+ const { repo, grant } = publicationGrantOf(store, publication.taskRef, options.repo);
126
+ if (repo !== options.repo)
127
+ continue;
113
128
  const verdict = permitsPublication(grant, publication);
114
129
  if (!verdict.ok) {
115
130
  report.problems.push(`publication ${publication.id}: ${verdict.message}`);
@@ -243,6 +258,31 @@ async function openOrAdopt(store, publication, grant, exec, clock) {
243
258
  rmSync(dir, { recursive: true, force: true });
244
259
  }
245
260
  }
261
+ /**
262
+ * A publication given up on whose pull request exists anyway — a pass that gave up while another opened it, or a
263
+ * person who opened it by hand: the PR on its exact head and base, carrying its exact commit, is adopted as opened
264
+ * (merged or closed ones too; the next observation records which). Whether one was found.
265
+ */
266
+ export async function adoptPublishedPullRequest(store, publication, exec, clock) {
267
+ if (publication.state !== "failed")
268
+ return false;
269
+ const listed = await exec("gh", ["pr", "list", "--repo", publication.githubRepo, "--head", publication.head, "--base", publication.base, "--state", "all", "--json", "number,url,headRefOid"], { timeoutMs: EXEC_TIMEOUT_MS });
270
+ if (listed.code !== 0)
271
+ return false;
272
+ let found;
273
+ try {
274
+ found = JSON.parse(listed.stdout || "[]");
275
+ }
276
+ catch {
277
+ return false;
278
+ }
279
+ const pr = Array.isArray(found) ? found.find(one => one.headRefOid === publication.headSha && typeof one.number === "number" && typeof one.url === "string") : undefined;
280
+ if (pr === undefined)
281
+ return false;
282
+ store.markPublicationOpened(publication.id, pr.number, pr.url, clock());
283
+ enqueueOpened(store, publication, pr.url, clock);
284
+ return true;
285
+ }
246
286
  /**
247
287
  * Read CI for every PR this control plane opened — structured rollup and the
248
288
  * exact head OID, never a command's exit status (finding 18). Episodes are
@@ -169,4 +169,6 @@ export type PullRequestView = {
169
169
  };
170
170
  /** The pull request a result opened through Complete, in plain words; null when it opened none. */
171
171
  export declare function pullRequestViewOf(store: Store, runId: number): PullRequestView | null;
172
+ /** The newest pull request any version of a task opened through Complete (versions oldest first), or null. */
173
+ export declare function newestPullRequestOf(store: Store, versions: readonly string[]): PullRequestView | null;
172
174
  export {};
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import { createHash } from "node:crypto";
18
18
  import { run as execRun } from "./exec.js";
19
- import { bodyHashOf, publicationBody } from "./publish.js";
19
+ import { bodyHashOf, publicationBody, publicationGrantOf } from "./publish.js";
20
20
  import { failingChecks, summarizeChecks } from "./pulls.js";
21
21
  import { assignmentOf, checkAssignmentAsOperator } from "./assignment.js";
22
22
  import { requestResultChanges } from "./result-actions.js";
@@ -137,7 +137,7 @@ export function pullRequestBlocker(store, runId) {
137
137
  const ref = run === null ? null : store.refById(run.taskRef);
138
138
  if (run === null || ref === null)
139
139
  return "No such result.";
140
- const grant = ref.repo === null ? null : store.publicationGrantFor(ref.repo);
140
+ const { grant } = publicationGrantOf(store, run.taskRef);
141
141
  if (grant === null || !grant.capabilities.includes("push-branch") || !grant.capabilities.includes("open-pr"))
142
142
  return "Pull requests aren't set up for this project.";
143
143
  if (run.headRevision === null || run.committed === false)
@@ -184,7 +184,7 @@ export function owePullRequest(store, runId, actor, flowCard, now) {
184
184
  return store.transact(() => {
185
185
  const run = store.getRun(runId);
186
186
  const ref = store.refById(run.taskRef);
187
- const grant = store.publicationGrantFor(ref.repo);
187
+ const grant = publicationGrantOf(store, run.taskRef).grant;
188
188
  let publication = store.publicationForRun(run.id);
189
189
  if (publication === null) {
190
190
  const id = store.createPublicationIntent({
@@ -473,3 +473,17 @@ export function pullRequestViewOf(store, runId) {
473
473
  return { ...base, state: "waiting", label: "Waiting for checks", detail: "No CI checks have reported on GitHub yet." };
474
474
  }
475
475
  }
476
+ /** The newest pull request any version of a task opened through Complete (versions oldest first), or null. */
477
+ export function newestPullRequestOf(store, versions) {
478
+ for (const id of [...versions].reverse()) {
479
+ const ref = store.lookupRef(id);
480
+ if (ref === null)
481
+ continue;
482
+ for (const run of store.runsFor(ref.id)) {
483
+ const view = pullRequestViewOf(store, run.id);
484
+ if (view !== null)
485
+ return view;
486
+ }
487
+ }
488
+ return null;
489
+ }
package/dist/serve.d.ts CHANGED
@@ -18,6 +18,8 @@ import type { RevisionLineage } from "./store.js";
18
18
  import type { SubscriptionMateRunner } from "./subscription-chat.js";
19
19
  import { type InstallMethod } from "./install-method.js";
20
20
  export type ServeOptions = {
21
+ /** `toolroll demo`: the scripted lead that answers Chat instead of a model. */
22
+ demoLead?: import("./demo.js").DemoLead;
21
23
  /** Tests: the bin whose real path says how Toolroll was installed (Settings → Updates' command). */
22
24
  installBin?: string;
23
25
  /** Native coding workspace injection for isolated integration tests. */