toolroll 0.9.4 → 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.
@@ -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;
package/dist/operate.js CHANGED
@@ -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";
@@ -367,7 +367,9 @@ External trackers — build what a tracker nominates, under local approvals
367
367
  refuses to spawn where none exists
368
368
  (macOS) instead of downgrading.
369
369
  Also read by watch, tick and
370
- daemon install.
370
+ daemon install. --verbose prints
371
+ the exact containment status (a
372
+ service log always has it).
371
373
  toolroll reconcile --repo <path> the morning sweep: recover dead
372
374
  runners, reap expired leases, adopt
373
375
  or forget orphaned worktrees. Run it
@@ -612,6 +614,8 @@ export const OPERATE_BOOLEAN_FLAGS = new Set([
612
614
  "saved",
613
615
  // task complete: also open the result's pull request.
614
616
  "pull-request",
617
+ // up: the exact start-up details (containment) in the terminal too.
618
+ "verbose",
615
619
  ]);
616
620
  export function parseOperateArgs(argv, ownValues = new Set()) {
617
621
  const positional = [];
@@ -7131,7 +7135,8 @@ async function runWatchLoop(args) {
7131
7135
  }
7132
7136
  // What this runner's spawns actually get, in one honest line: native
7133
7137
  // and which backend, observed, or a required policy that will refuse.
7134
- progress(`watch: ${describeContainment(currentContainment())}`);
7138
+ for (const line of args.containmentLines?.(currentContainment()) ?? [`watch: ${describeContainment(currentContainment())}`])
7139
+ progress(line);
7135
7140
  // The night is a row, not "the last 24 hours": everything this watch does
7136
7141
  // attributes to this episode by runner and window, and `brief
7137
7142
  // --latest-watch` bounds itself to exactly it.
@@ -8157,6 +8162,18 @@ async function upCommand(flags, context) {
8157
8162
  return copy;
8158
8163
  };
8159
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
+ };
8160
8177
  const loopResults = new Map();
8161
8178
  const launchRepo = async (repo, bind) => {
8162
8179
  if (loopResults.has(repo) || stopping)
@@ -8184,6 +8201,7 @@ async function upCommand(flags, context) {
8184
8201
  progress: line => progress(`${prefix(repo)}${line}`),
8185
8202
  isStopping: () => stopping,
8186
8203
  onFollowController: controller => followControllers.push(controller),
8204
+ containmentLines,
8187
8205
  onReady: () => {
8188
8206
  markReady();
8189
8207
  if (bind)
@@ -8323,10 +8341,10 @@ async function upCommand(flags, context) {
8323
8341
  }
8324
8342
  write(repos.length === 0
8325
8343
  ? ` builder ${runnerName} is ready — add a local folder or GitHub repository in Projects`
8326
- : ` builder ${runnerName} is watching ${repos.length === 1 ? repos[0] : `${repos.length} repositories`}`);
8344
+ : ` builder watching ${repos.length === 1 ? repos[0] : `${repos.length} repositories`}`);
8327
8345
  if (projectRoots.length > 0)
8328
8346
  write(` projects new repositories under ${projectRoots.length === 1 ? projectRoots[0] : `${projectRoots.length} saved folders`} connect automatically`);
8329
- write(" The inbox checklist shows what remains before approved work builds unattended.");
8347
+ write(" Chat shows the steps to your first result.");
8330
8348
  write(" Ctrl-C stops Toolroll on this machine.");
8331
8349
  }
8332
8350
  if (!json && !flags.has("no-open") && terminal)
@@ -8335,7 +8353,7 @@ async function upCommand(flags, context) {
8335
8353
  // 10. Supervise to the end.
8336
8354
  await stopped;
8337
8355
  await registrySupervisor;
8338
- const results = await Promise.all([...loopResults.values()]);
8356
+ await Promise.all([...loopResults.values()]);
8339
8357
  clearInterval(runnerHeartbeat);
8340
8358
  if (runTimer !== undefined)
8341
8359
  clearTimeout(runTimer);
@@ -8368,9 +8386,7 @@ async function upCommand(flags, context) {
8368
8386
  process.stderr.write(`up: ${fatal}\n`);
8369
8387
  return EXIT.failed;
8370
8388
  }
8371
- const ticks = results.reduce((sum, one) => sum + one.result.ticks, 0);
8372
- const built = results.reduce((sum, one) => sum + one.result.built, 0);
8373
- 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.");
8374
8390
  return EXIT.ok;
8375
8391
  }
8376
8392
  // ---- the telegram bridge ---------------------------------------------------
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
@@ -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({
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. */