toolroll 0.9.6 → 0.9.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/CHANGELOG.md +64 -1
  2. package/dist/actor.d.ts +21 -0
  3. package/dist/actor.js +48 -0
  4. package/dist/assignment-adapters.d.ts +2 -0
  5. package/dist/assignment-adapters.js +2 -0
  6. package/dist/assignment-ui.js +3 -1
  7. package/dist/assignment.d.ts +15 -0
  8. package/dist/assignment.js +80 -16
  9. package/dist/browser/workspace.css +2 -2
  10. package/dist/browser/workspace.js +28 -24
  11. package/dist/browser-workspace.d.ts +117 -2
  12. package/dist/build-review.d.ts +38 -0
  13. package/dist/build-review.js +341 -0
  14. package/dist/builder.d.ts +1 -0
  15. package/dist/builder.js +33 -13
  16. package/dist/chat-actions.js +2 -1
  17. package/dist/chat-delivery.js +2 -0
  18. package/dist/chat-quiet.js +11 -1
  19. package/dist/chat-task-actions.d.ts +13 -0
  20. package/dist/chat-task-actions.js +19 -1
  21. package/dist/check-levels-ui.d.ts +26 -0
  22. package/dist/check-levels-ui.js +84 -0
  23. package/dist/check-levels.d.ts +67 -0
  24. package/dist/check-levels.js +171 -0
  25. package/dist/claim.d.ts +22 -0
  26. package/dist/claim.js +43 -0
  27. package/dist/cli.d.ts +1 -1
  28. package/dist/cli.js +6 -0
  29. package/dist/demo-chat.js +4 -1
  30. package/dist/dispatch.js +9 -3
  31. package/dist/dispose.d.ts +12 -3
  32. package/dist/dispose.js +19 -5
  33. package/dist/exec.d.ts +5 -0
  34. package/dist/exec.js +30 -4
  35. package/dist/export.js +3 -2
  36. package/dist/first-run.d.ts +34 -0
  37. package/dist/first-run.js +37 -1
  38. package/dist/flow-gallery-ui.d.ts +30 -0
  39. package/dist/flow-gallery-ui.js +85 -0
  40. package/dist/flow-gallery.d.ts +111 -0
  41. package/dist/flow-gallery.js +445 -0
  42. package/dist/flow-share.d.ts +102 -0
  43. package/dist/flow-share.js +614 -0
  44. package/dist/flow-starters.d.ts +2 -0
  45. package/dist/flow-starters.js +1 -1
  46. package/dist/flow-steps.js +7 -0
  47. package/dist/flow-triggers.js +2 -0
  48. package/dist/flows-cli.d.ts +56 -0
  49. package/dist/flows-cli.js +109 -3
  50. package/dist/flows-ui.d.ts +15 -1
  51. package/dist/flows-ui.js +31 -6
  52. package/dist/guides.js +27 -2
  53. package/dist/invoke.js +5 -0
  54. package/dist/lead-context.js +6 -2
  55. package/dist/mate-doors.d.ts +0 -15
  56. package/dist/mate-doors.js +5 -1
  57. package/dist/mate-tools.js +9 -4
  58. package/dist/models-ui.js +1 -1
  59. package/dist/modes.d.ts +3 -1
  60. package/dist/modes.js +3 -1
  61. package/dist/operate.d.ts +4 -1
  62. package/dist/operate.js +711 -98
  63. package/dist/process-custody.js +8 -0
  64. package/dist/project-concurrency.d.ts +62 -0
  65. package/dist/project-concurrency.js +116 -0
  66. package/dist/project-delete-ui.d.ts +7 -0
  67. package/dist/project-delete-ui.js +19 -2
  68. package/dist/project-delete.js +4 -0
  69. package/dist/provider.js +16 -0
  70. package/dist/pull-request-flow.d.ts +9 -0
  71. package/dist/pull-request-flow.js +21 -3
  72. package/dist/result-follow-ups.d.ts +101 -0
  73. package/dist/result-follow-ups.js +333 -0
  74. package/dist/retention-ui.d.ts +3 -1
  75. package/dist/retention-ui.js +4 -4
  76. package/dist/retention.d.ts +13 -6
  77. package/dist/retention.js +50 -12
  78. package/dist/review-switch.d.ts +41 -0
  79. package/dist/review-switch.js +88 -0
  80. package/dist/reviewer.d.ts +24 -2
  81. package/dist/reviewer.js +49 -1
  82. package/dist/scope.js +11 -0
  83. package/dist/serve.d.ts +211 -2
  84. package/dist/serve.js +1038 -145
  85. package/dist/store.d.ts +112 -4
  86. package/dist/store.js +447 -27
  87. package/dist/surface.js +36 -9
  88. package/dist/tailnet.d.ts +11 -0
  89. package/dist/tailnet.js +47 -0
  90. package/dist/task-outcome-cli.js +2 -2
  91. package/dist/task-status.d.ts +13 -3
  92. package/dist/task-status.js +38 -16
  93. package/dist/team-cli.js +3 -0
  94. package/dist/telegram-status.js +2 -2
  95. package/dist/verification-evidence.js +21 -1
  96. package/dist/webhooks.js +5 -0
  97. package/dist/work-index.js +39 -6
  98. package/dist/work-summary.d.ts +0 -4
  99. package/dist/work-summary.js +4 -1
  100. package/dist/workspace-ui.d.ts +6 -1
  101. package/dist/workspace-ui.js +14 -11
  102. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,6 +1,69 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.9.8 — 2026-10-01
4
+
5
+ - **One automatic review per build.** When a build's checks pass (or it
6
+ finishes, with checks Off) in a project with review on, one read-only
7
+ reviewer reads the scope, acceptance and diff and grades each finding HIGH,
8
+ MEDIUM or LOW. Any HIGH sends the task back once, with the findings as its
9
+ note; a HIGH on that revision comes to you. MEDIUM and LOW never block: they
10
+ show on the result as suggested follow-ups. A review that fails or times
11
+ out never blocks: the result reaches you marked Not reviewed. Turn it on or
12
+ off per project in Settings or with `toolroll review on|off --repo <path>`;
13
+ it's on by default under a hands-off mode. Release candidate checks are
14
+ never reviewed.
15
+ - **Evidence is kept 1, 7, 14 or 28 days, or forever.** Settings →
16
+ Retention and `toolroll retention set evidence 1d` take any of them. Until
17
+ someone chooses, evidence is kept 28 days: **after upgrading, the daily
18
+ sweep starts removing finished tasks' evidence older than 28 days.** A
19
+ period chosen before stays as it was; one the page no longer offers shows
20
+ as "30 days (custom)".
21
+ Nothing a task still needs is removed: unfinished tasks, results Ready for
22
+ review, anything on hold, live runs and release candidates.
23
+ - **A run whose process never started no longer blocks updates.** A spawn
24
+ that fails now settles its own record, and Toolroll settles a finished run's
25
+ leftover record once its processes are provably gone, noting it in the
26
+ ledger. For the rare record that can't be proven either way, an approver
27
+ can run `toolroll run settle <run> --why "<reason>"`; it refuses while
28
+ anything of the run is alive.
29
+
30
+ ## 0.9.7 — 2026-10-01
31
+
32
+ - **Onboarding without a password or a form.** `toolroll up` prints three
33
+ lines and opens the console already signed in, through a one-time link that
34
+ works once, for ten minutes, from this computer only. The lead turns on by
35
+ itself with the agent that's signed in (Claude Code, else Codex); the old
36
+ setup form moved to Settings → Lead → Advanced. With no agent signed in,
37
+ Chat shows the command to install and sign in, and checks again on its own.
38
+ A short "how it works", a live Plan → Ready timeline on your first task, a
39
+ card for pairing your phone, and a "wrong host" page that says what to run.
40
+ - **The task page is one conversation.** The status stays on top; below it
41
+ the plan, the agent's notes and results, questions and your replies, in
42
+ order, ending in a box to message the agent (Build, Plan only, or Just
43
+ answer). Details (scope, attempts, audit) moved to a side panel, or a sheet
44
+ on phones. The Chat home shows who is working now and four counts, and
45
+ Catch up and the Inbox have tabs: Needs you, Ready, Running, All.
46
+ - **Checks: quick while building, easy to turn off.** Each project has a
47
+ check level: Quick (the default for new projects), Full or Off, and a task
48
+ can override it ("skip the tests"). With checks Off a result is still
49
+ Ready for review, and says no check ran. Any result can run its checks again or file a task to add tests.
50
+ - **A flow gallery, and flows as files.** Flows → New is a gallery of
51
+ templates grouped by what they're for, with new ones for developers
52
+ (Overnight bug bash, Flaky test hunter, Release notes writer, Docs follow
53
+ the code, Error to fix, PR second opinion and more). Export any flow as a
54
+ `.toolroll-flow.json` file and import one from a file or a link, with a
55
+ preview; secrets never leave, and imported triggers start switched off.
56
+ - **Several builds at once on one project.** The service builds up to two
57
+ tasks of a project at a time (Settings → Projects, `toolroll project
58
+ concurrency`), within the machine's limit. Stopping the service mid-build
59
+ saves the work and resumes it next time.
60
+ - **The lead's housekeeping stays quiet.** An agent acting as your lead gets
61
+ its own credential (`toolroll lead token`); its work pings nobody unless it
62
+ hands something to you. Your own actions never ping you, a replaced task
63
+ reads "Replaced by", and a whole project can be muted.
64
+ - **A faster release check.** Flow journeys run in parallel groups, browser
65
+ journeys are rebalanced, and a version-only bump no longer forces every
66
+ test.
4
67
 
5
68
  ## 0.9.6 — 2026-10-01
6
69
 
@@ -0,0 +1,21 @@
1
+ export type Actor = {
2
+ account: string;
3
+ lead: boolean;
4
+ };
5
+ /** Run `fn` with this actor (or an empty slot a verified sign-in may fill). */
6
+ export declare function withActor<T>(actor: Actor | null, fn: () => T): T;
7
+ export declare function currentActor(): Actor | null;
8
+ /** A person verified their password inside this command: their acts are now theirs. A lead keeps its own label. */
9
+ export declare function claimActor(account: string): void;
10
+ /** How the ledger names an actor. */
11
+ export declare function actorLabel(actor: Actor): string;
12
+ export declare function mintLeadToken(): {
13
+ id: string;
14
+ token: string;
15
+ hash: string;
16
+ };
17
+ export declare function parseLeadToken(presented: string): {
18
+ id: string;
19
+ secret: string;
20
+ } | null;
21
+ export declare function leadSecretMatches(secret: string, keptHash: string): boolean;
package/dist/actor.js ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Who is acting right now, for pings that follow responsibility.
3
+ *
4
+ * A lead is an agent acting for one person under a lead token (`toolroll lead
5
+ * token`): it signs in as that person, and its acts are recorded as "lead for
6
+ * <owner>". A person acting for themselves is recorded by name. Work whose
7
+ * actor is the lead pings nobody, and nobody is pinged about their own act.
8
+ *
9
+ * The actor is held for one command or one console request, never for a
10
+ * long-running service: a worker's facts are nobody's act.
11
+ */
12
+ import { AsyncLocalStorage } from "node:async_hooks";
13
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
14
+ /** The slot is mutable so a command can name its person after it has verified their password. */
15
+ const slot = new AsyncLocalStorage();
16
+ /** Run `fn` with this actor (or an empty slot a verified sign-in may fill). */
17
+ export function withActor(actor, fn) {
18
+ return slot.run({ actor }, fn);
19
+ }
20
+ export function currentActor() {
21
+ return slot.getStore()?.actor ?? null;
22
+ }
23
+ /** A person verified their password inside this command: their acts are now theirs. A lead keeps its own label. */
24
+ export function claimActor(account) {
25
+ const held = slot.getStore();
26
+ if (held !== undefined && held.actor === null)
27
+ held.actor = { account, lead: false };
28
+ }
29
+ /** How the ledger names an actor. */
30
+ export function actorLabel(actor) {
31
+ return actor.lead ? `lead for ${actor.account}` : actor.account;
32
+ }
33
+ // ---- the lead token ---------------------------------------------------------
34
+ /** `lt_<id>_<secret>`: shown once, only its hash kept. */
35
+ const SHAPE = /^lt_([a-f0-9]{12})_([A-Za-z0-9_-]{43})$/;
36
+ export function mintLeadToken() {
37
+ const id = randomBytes(6).toString("hex"), secret = randomBytes(32).toString("base64url");
38
+ return { id, token: `lt_${id}_${secret}`, hash: hashLeadSecret(secret) };
39
+ }
40
+ const hashLeadSecret = (secret) => createHash("sha256").update(secret, "utf8").digest("hex");
41
+ export function parseLeadToken(presented) {
42
+ const match = SHAPE.exec(presented.trim());
43
+ return match === null ? null : { id: match[1], secret: match[2] };
44
+ }
45
+ export function leadSecretMatches(secret, keptHash) {
46
+ const a = Buffer.from(hashLeadSecret(secret), "hex"), b = Buffer.from(keptHash, "hex");
47
+ return a.length === b.length && timingSafeEqual(a, b);
48
+ }
@@ -117,6 +117,7 @@ export declare function assignmentForCoordinator(store: Store, token: string, op
117
117
  actor: string;
118
118
  at: string;
119
119
  digest: string;
120
+ lead?: true;
120
121
  } | null;
121
122
  result: {
122
123
  digest: string;
@@ -139,6 +140,7 @@ export declare function assignmentForCoordinator(store: Store, token: string, op
139
140
  prUrl: string | null;
140
141
  remoteState: string | null;
141
142
  } | null;
143
+ review: import("./review-switch.js").BuildReviewView | null;
142
144
  deployment: {
143
145
  status: "not-recorded";
144
146
  };
@@ -1,5 +1,6 @@
1
1
  /** Assignment reads and delivery acknowledgments. These adapters never start
2
2
  * work, answer a decision, accept proof, or change an approval. */
3
+ import { buildReviewLines } from "./review-switch.js";
3
4
  import { readFileSync, statSync } from "node:fs";
4
5
  import { authenticateCoordinator } from "./coordinator.js";
5
6
  import { assignmentOf, assignmentUpdates, claimAssignment, checkAssignment } from "./assignment.js";
@@ -113,6 +114,7 @@ function assignmentLines(assignment) {
113
114
  ...(receipt.agentReport === null ? [] : [`Agent report: ${receipt.agentReport}`]),
114
115
  `Receipt: ${receipt.digest}`,
115
116
  ]),
117
+ ...buildReviewLines(assignment.review ?? null).map(line => line.trimStart().replace(/^review: /, "Review: ")),
116
118
  ...(assignment.savedContext === undefined ? [] : [
117
119
  ...(assignment.savedContext.goal === null ? [] : [`Goal: ${assignment.savedContext.goal}`]),
118
120
  ...(assignment.savedContext.outOfScope === null ? [] : [`Out of scope: ${assignment.savedContext.outOfScope}`]),
@@ -67,7 +67,9 @@ function savedMaterialNotice(detail, assignment) {
67
67
  export function assignmentCardOf(assignment, options = {}) {
68
68
  const receipt = assignment.receipt;
69
69
  const links = receipt === null ? {} : { result: options.resultHref ?? chatResultHref(receipt.taskId, receipt.runId), checks: chatResultHref(receipt.taskId, receipt.runId, 'checks'),
70
- pullRequest: `/t/${encodeURIComponent(assignment.rootId)}#merge` };
70
+ pullRequest: `/t/${encodeURIComponent(assignment.rootId)}#merge`,
71
+ // Run checks lives with the result's checks; the row's action leads there.
72
+ ...(receipt.completionKind === 'research-report' ? {} : { runChecks: `${chatResultHref(receipt.taskId, receipt.runId, 'checks')}#follow-ups` }) };
71
73
  const presentation = assignmentPresentationOf(assignment, { ...options, links });
72
74
  const { status, diagnostics, taskStatus } = presentation;
73
75
  const href = assignment.primaryAction?.code === 'open-result' && options.resultHref !== undefined ? options.resultHref : assignmentActionHref(assignment);
@@ -1,6 +1,8 @@
1
1
  import { type Store, type ProofVerdictRow, type ProofAcceptanceRow } from "./store.js";
2
2
  import { type WorkAction, type WorkSummaryAccess } from "./work-summary.js";
3
3
  import { type VerifiedApprover } from "./principal.js";
4
+ import { type CheckLevel } from "./check-levels.js";
5
+ import { type BuildReviewView } from "./review-switch.js";
4
6
  export type AssignmentAccess = WorkSummaryAccess;
5
7
  export type AssignmentOwner = {
6
8
  kind: "coordinator" | "lead";
@@ -13,6 +15,10 @@ export type AssignmentChecks = {
13
15
  command: string | null;
14
16
  logArtifactId: number | null;
15
17
  detail: string;
18
+ /** Which check this is (check-levels.ts): quick, full, or off; null before levels. */
19
+ level?: CheckLevel | null;
20
+ /** A follow-up check waiting or running on this commit. */
21
+ running?: "quick" | "full" | null;
16
22
  };
17
23
  export type AssignmentReceipt = {
18
24
  digest: string;
@@ -70,10 +76,12 @@ export type AssignmentSnapshot = {
70
76
  problem: string | null;
71
77
  }[];
72
78
  };
79
+ /** `lead`: marked complete by the person's lead (`toolroll lead token`), shown as "by the lead". */
73
80
  completion: {
74
81
  actor: string;
75
82
  at: string;
76
83
  digest: string;
84
+ lead?: true;
77
85
  } | null;
78
86
  handoff: {
79
87
  kind: "result" | "decision" | "attention";
@@ -85,6 +93,9 @@ export type AssignmentSnapshot = {
85
93
  prUrl: string | null;
86
94
  remoteState: string | null;
87
95
  } | null;
96
+ /** The result's one automatic review, when its project had review on:
97
+ * pending, HIGH findings, suggested follow-ups (MEDIUM/LOW), or not reviewed. */
98
+ review: BuildReviewView | null;
88
99
  deployment: {
89
100
  status: "not-recorded";
90
101
  };
@@ -112,6 +123,7 @@ export declare function assignmentBrief(assignment: AssignmentSnapshot | null):
112
123
  actor: string;
113
124
  at: string;
114
125
  digest: string;
126
+ lead?: true;
115
127
  } | null;
116
128
  result: {
117
129
  digest: string;
@@ -134,6 +146,7 @@ export declare function assignmentBrief(assignment: AssignmentSnapshot | null):
134
146
  prUrl: string | null;
135
147
  remoteState: string | null;
136
148
  } | null;
149
+ review: BuildReviewView | null;
137
150
  deployment: {
138
151
  status: "not-recorded";
139
152
  };
@@ -189,6 +202,7 @@ export declare function assignmentUpdates(store: Store, now: Date, access: Assig
189
202
  actor: string;
190
203
  at: string;
191
204
  digest: string;
205
+ lead?: true;
192
206
  } | null;
193
207
  result: {
194
208
  digest: string;
@@ -211,6 +225,7 @@ export declare function assignmentUpdates(store: Store, now: Date, access: Assig
211
225
  prUrl: string | null;
212
226
  remoteState: string | null;
213
227
  } | null;
228
+ review: BuildReviewView | null;
214
229
  deployment: {
215
230
  status: "not-recorded";
216
231
  };
@@ -2,6 +2,8 @@
2
2
  * approval or proof state. Lead ownership and exact receipt acknowledgments
3
3
  * are append-only actions; handoffs use the existing durable outbox. */
4
4
  import { createHash } from "node:crypto";
5
+ import { currentActor, withActor } from "./actor.js";
6
+ import { replacedWords } from "./task-status.js";
5
7
  import { COMPLETION_ACTION, familyChangedFiles } from "./result-completion.js";
6
8
  import { homedir } from "node:os";
7
9
  import { readSchemaVersion } from "./store.js";
@@ -12,6 +14,9 @@ import { verificationEvidence } from "./verification-evidence.js";
12
14
  import { reproveApprover } from "./principal.js";
13
15
  import { noteAssignmentStatus } from "./assignment-status.js";
14
16
  import { historicalAssessmentReason } from "./assignment-presentation.js";
17
+ import { runCheckLevel } from "./check-levels.js";
18
+ import { followUpChecksOf, withFollowUps } from "./result-follow-ups.js";
19
+ import { buildReviewOf, findingWords } from "./review-switch.js";
15
20
  /** Status-first handoff for routine reads. Fetch get_assignment only when
16
21
  * inspecting its exact evidence; do not repeat a full proof in every poll. */
17
22
  export function assignmentBrief(assignment) {
@@ -25,9 +30,23 @@ export function assignmentBrief(assignment) {
25
30
  base: receipt.base, head: receipt.head, completionKind: receipt.completionKind,
26
31
  checks: receipt.checks, proofAcceptance: receipt.proofAcceptance, verdict: receipt.proof?.verdict ?? null,
27
32
  criteria: { passed: receipt.proof?.matrix.filter(row => row.state === "pass").length ?? 0, total: receipt.proof?.matrix.length ?? 0 }, evidence: receipt.evidence },
28
- publication: assignment.publication, deployment: assignment.deployment };
33
+ publication: assignment.publication, review: assignment.review, deployment: assignment.deployment };
29
34
  }
30
35
  const digest = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
36
+ /** A lookup added with lead-quiet, read safely: a deploy proves completion with this code over the INSTALLED runtime's
37
+ * store, before this version's migration, so the method (or its table) may not exist yet. Absent reads as none. */
38
+ function olderStoreSafe(read, absent) {
39
+ try {
40
+ return read();
41
+ }
42
+ catch (error) {
43
+ if (error instanceof TypeError || /no such (table|column)/.test(String(error)))
44
+ return absent;
45
+ throw error;
46
+ }
47
+ }
48
+ /** A check as the receipt digest seals it: presentation-only fields (check-levels.ts) left out. */
49
+ const sealedChecks = ({ level: _level, running: _running, ...proof }) => proof;
31
50
  const OWNER_ACTION = "assignment claimed";
32
51
  const CHECK_ACTION = COMPLETION_ACTION;
33
52
  const actorOf = (owner) => `${owner.kind}:${owner.id}`;
@@ -58,8 +77,24 @@ function ownerOf(store, rootId, repo) {
58
77
  active: credential !== undefined && credential["revoked_at"] === null && repo !== null && Array.isArray(repos) && repos.includes(repo) };
59
78
  }
60
79
  // Report the recorded check outcome, never turn a model verdict into a check.
61
- function nativeChecks(store, root, runId) {
62
- const unavailable = (detail) => ({ status: "unavailable", exitCode: null, command: null, logArtifactId: null, detail });
80
+ // Follow-up checks on the same commit (Run checks) upgrade a pass and show a failure.
81
+ function nativeChecks(store, root, runId, now) {
82
+ const own = buildChecks(store, root, runId);
83
+ const run = store.getRun(runId);
84
+ const followUps = followUpChecksOf(store, runId, now, root);
85
+ if (followUps.length === 0)
86
+ return own;
87
+ const read = withFollowUps({ status: own.status, level: own.level ?? null, exitCode: own.exitCode, head: run?.headRevision ?? null }, followUps);
88
+ const latest = [...followUps].reverse().find(one => one.state === "passed" || one.state === "failed");
89
+ const changed = read.status !== own.status || read.level !== (own.level ?? null) || read.exitCode !== own.exitCode;
90
+ const words = read.level === "quick" ? "Quick checks" : "Checks";
91
+ return { ...own, running: read.running,
92
+ ...(changed ? { status: read.status, level: read.level, exitCode: read.exitCode, logArtifactId: latest?.logArtifactId ?? own.logArtifactId,
93
+ detail: read.status === "passed" ? `${words} passed.` : `${words} failed (exit ${read.exitCode}).` } : {}) };
94
+ }
95
+ function buildChecks(store, root, runId) {
96
+ const level = runCheckLevel(store, runId);
97
+ const unavailable = (detail) => ({ status: "unavailable", exitCode: null, command: null, logArtifactId: null, detail, level });
63
98
  if (root === undefined)
64
99
  return unavailable("Saved checks are unavailable.");
65
100
  try {
@@ -68,11 +103,14 @@ function nativeChecks(store, root, runId) {
68
103
  return unavailable(gate.problem);
69
104
  const receipt = gate.bytes === null ? null : JSON.parse(gate.bytes);
70
105
  if (receipt === null)
71
- return { ...unavailable("No machine check is recorded."), status: "not-run" };
106
+ return { ...unavailable(level === "off" ? "Checks were off for this build." : "No machine check is recorded."), status: "not-run" };
72
107
  const ran = receipt.result.ran === true, exitCode = ran ? receipt.result.exitCode : null;
108
+ // The sealed receipt names the grant that ran: the quick one, or the full one.
109
+ const sealedLevel = typeof receipt.command?.repo === "string" && receipt.command.repo.startsWith("quick:") ? "quick" : "full";
110
+ const words = sealedLevel === "quick" ? "Quick checks" : "Checks";
73
111
  return { status: ran ? exitCode === 0 ? "passed" : "failed" : "not-run", exitCode,
74
- command: receipt.command.command, logArtifactId: receipt.log.artifactId,
75
- detail: ran ? exitCode === 0 ? "Checks passed." : `Checks failed (exit ${exitCode}).` : "Checks did not finish." };
112
+ command: receipt.command.command, logArtifactId: receipt.log.artifactId, level: level === null ? null : sealedLevel,
113
+ detail: ran ? exitCode === 0 ? `${words} passed.` : `${words} failed (exit ${exitCode}).` : `${words} did not finish.` };
76
114
  }
77
115
  catch {
78
116
  return unavailable("Saved checks could not be read.");
@@ -112,7 +150,7 @@ export function assignmentOf(store, taskId, now, access, root) {
112
150
  const reports = artifacts.filter(a => a.kind === "report");
113
151
  const unavailable = family.versions.flatMap(version => store.runsFor(version.refId).flatMap(run => store.artifactsFor(run.id).filter(artifact => root === undefined || !readVerifiedArtifact(root, artifact).ok)
114
152
  .map(artifact => `Saved ${artifact.kind} #${artifact.id} (run ${run.id}) is unavailable or changed.`)));
115
- const checks = result === null ? null : nativeChecks(store, root, result.id);
153
+ const checks = result === null ? null : nativeChecks(store, root, result.id, now);
116
154
  const finishedBuild = result?.role === "builder" && (result.outcome === "built" || result.outcome === "no-change") &&
117
155
  result.finishedAt !== null && /^[a-f0-9]{40}$/.test(result.headRevision ?? "");
118
156
  // Finished work returns to its lead or user. Strict terms and previous model
@@ -133,7 +171,11 @@ export function assignmentOf(store, taskId, now, access, root) {
133
171
  ...artifacts.filter(a => a.kind !== "check-log" && (a.truncated || a.captureStatus === "failed")).map(a => `Saved ${a.kind} #${a.id} is ${a.captureStatus === "failed" ? "a failed capture" : "incomplete"}.`)],
134
172
  agentReport: result.handoff, evidence: "recorded",
135
173
  };
136
- const receipt = receiptBody === null ? null : { ...receiptBody, digest: digest({ receipt: receiptBody,
174
+ // The digest seals the proof, not how it is shown: the check's `level` (read from its sealed command) and `running`
175
+ // (a follow-up in flight) stay out of it, so a result completed before check levels keeps its completion, and a
176
+ // follow-up check that is still running never unseals one.
177
+ const sealedBody = receiptBody === null ? null : { ...receiptBody, checks: sealedChecks(receiptBody.checks) };
178
+ const receipt = receiptBody === null ? null : { ...receiptBody, digest: digest({ receipt: sealedBody,
137
179
  scope: scope === null ? null : { digest: scope.digest, approved: approvalOf(scope).approved, termsProblem: scope.termsProblem ?? null },
138
180
  versions: family.versions.map(v => ({ id: v.id, state: v.state })) }) };
139
181
  let completion = null;
@@ -145,7 +187,11 @@ export function assignmentOf(store, taskId, now, access, root) {
145
187
  // The exact completed result, scope, family and custody fences apply to
146
188
  // every deliverable. Human acceptance remains its own recorded authority;
147
189
  // it never changes a machine verdict or supplies a missing report.
148
- const ready = current.state === "done" && result !== null && completionKind !== null &&
190
+ // Kept out of the sealed receipt (sealedChecks): the review never changes the result's digest.
191
+ const review = result === null ? null : olderStoreSafe(() => buildReviewOf(store, result.id), null);
192
+ // A result under its one automatic review has not reached the person yet.
193
+ const reviewing = review?.state === "pending" && current.state === "done";
194
+ const ready = !reviewing && current.state === "done" && result !== null && completionKind !== null &&
149
195
  scope !== null && scope.termsProblem == null && approvalOf(scope).approved && scope.digest === result.scopeDigest &&
150
196
  store.activeHolds(current.refId, now).length === 0 && store.applicableStopFor(result.id) === null &&
151
197
  processProblem === null &&
@@ -154,8 +200,10 @@ export function assignmentOf(store, taskId, now, access, root) {
154
200
  state = "needs-decision";
155
201
  else if (current.state === "cancelled") {
156
202
  state = "cancelled";
157
- detail = "This assignment was cancelled.";
158
- primaryAction = { code: "inspect-task", label: "View assignment", target: { taskId: current.id, runId: null, decisionId: null }, access: "read", retry: "read-again" };
203
+ const successor = olderStoreSafe(() => store.replacementOf(current.id) ?? store.replacementOf(family.root.id), null);
204
+ detail = successor === null ? "This assignment was cancelled." : `${replacedWords(successor)}.`;
205
+ primaryAction = successor === null ? { code: "inspect-task", label: "View assignment", target: { taskId: current.id, runId: null, decisionId: null }, access: "read", retry: "read-again" }
206
+ : { code: "inspect-task", label: `Open ${successor}`, target: { taskId: successor, runId: null, decisionId: null }, access: "read", retry: "read-again" };
159
207
  }
160
208
  else if (!live && processProblem !== null && result !== null) {
161
209
  state = "needs-decision";
@@ -170,6 +218,11 @@ export function assignmentOf(store, taskId, now, access, root) {
170
218
  detail = question.decision.question;
171
219
  primaryAction = workDecisionAction(question.taskId, question.decision, access.principal);
172
220
  }
221
+ else if (reviewing && result !== null) {
222
+ state = "checking";
223
+ detail = "An automatic review is reading this result before it reaches you.";
224
+ primaryAction = { code: "inspect-run", label: "Open result", target: { taskId: current.id, runId: result.id, decisionId: null }, access: "read", retry: "read-again" };
225
+ }
173
226
  else if (ready && receipt !== null) {
174
227
  state = "ready-to-check";
175
228
  detail = completionKind === "research-report" ? "The research report is ready for the lead to read."
@@ -182,10 +235,12 @@ export function assignmentOf(store, taskId, now, access, root) {
182
235
  // membership changes and ownership transfer cannot revoke past completion.
183
236
  // The exact result digest still fences changes to work, scope and history.
184
237
  if (checked !== undefined) {
185
- completion = { actor: String(checked["actor"]), at: String(checked["at"]), digest: receipt.digest };
238
+ const rootRef = store.lookupRef(family.root.id);
239
+ const byLead = rootRef !== null && olderStoreSafe(() => store.taskActs(rootRef.id), []).filter(one => one.act === "completed").at(-1)?.lead === true;
240
+ completion = { actor: String(checked["actor"]), at: String(checked["at"]), digest: receipt.digest, ...(byLead ? { lead: true } : {}) };
186
241
  state = "complete";
187
242
  primaryAction = { ...primaryAction, label: completionKind === "research-report" ? "Read report" : "Open result" };
188
- const label = completion.actor.startsWith("operator:") ? completion.actor.slice(9) : owner && completion.actor === actorOf(owner) ? owner.label : "the previous lead";
243
+ const label = byLead ? "the lead" : completion.actor.startsWith("operator:") ? completion.actor.slice(9) : owner && completion.actor === actorOf(owner) ? owner.label : "the previous lead";
189
244
  detail = completionKind === "research-report" ? `Research report checked by ${label}. No deployment is implied.`
190
245
  : `Handled by ${label}. ${receipt.checks.detail} Publication and deployment are separate.`;
191
246
  }
@@ -218,6 +273,11 @@ export function assignmentOf(store, taskId, now, access, root) {
218
273
  attention.push(...(proof?.reasons ?? []).filter(reason => !historicalAssessmentReason(reason)));
219
274
  attention.push(...receipt.caveats.filter(reason => !proof?.reasons.includes(reason)));
220
275
  }
276
+ if (review !== null && review.sentBackAs === null) {
277
+ if (review.state === "not-reviewed")
278
+ attention.push(`Not reviewed: ${review.reason ?? "the automatic review did not finish"}.`);
279
+ attention.push(...review.high.map(one => `Review: ${findingWords(one)}`));
280
+ }
221
281
  if (state !== "complete")
222
282
  completion = null;
223
283
  if (current.state !== "cancelled")
@@ -247,7 +307,7 @@ export function assignmentOf(store, taskId, now, access, root) {
247
307
  return { version: 1, rootId: family.root.id, activeTaskId: current.id, repo: current.repo, title: family.root.title,
248
308
  state, detail, primaryAction, attention: [...new Set(attention)], attempts, owner, receipt, savedContext, completion, handoff,
249
309
  publication: publication === null ? null : { state: publication.state, prUrl: publication.prUrl, remoteState: publication.remoteState },
250
- deployment: { status: "not-recorded" } };
310
+ review, deployment: { status: "not-recorded" } };
251
311
  }
252
312
  function admittedOwner(store, taskId, owner, now, root) {
253
313
  if (owner.kind !== "coordinator")
@@ -316,6 +376,9 @@ function acknowledgeCurrent(store, current, receiptDigest, actor, now, root, acc
316
376
  return { ok: false, reason: "approval-rules", message: problem };
317
377
  store.recordAction({ at: now.toISOString(), actor, repo: current.repo,
318
378
  taskId: current.rootId, runId: receipt.runId, action: CHECK_ACTION, outcome: receiptDigest, source: "work" });
379
+ const ref = store.lookupRef(current.rootId);
380
+ if (ref !== null)
381
+ store.noteTaskAct(ref.id, "completed", now);
319
382
  store.bumpWake();
320
383
  }
321
384
  const assignment = assignmentOf(store, current.rootId, now, access, root);
@@ -335,7 +398,8 @@ export function checkAssignment(store, taskId, receiptDigest, owner, now, root =
335
398
  /** The signed-in user can mark the same exact receipt handled without taking
336
399
  * lead ownership. This records review, never check success or new authority. */
337
400
  export function checkAssignmentAsOperator(store, taskId, receiptDigest, who, now, root = evidenceRoot(homedir())) {
338
- return store.transact(() => {
401
+ // Completing is this person's act (or their lead's): it never pings them.
402
+ return withActor(currentActor() ?? { account: who.name, lead: false }, () => store.transact(() => {
339
403
  if (!reproveApprover(store, who).ok)
340
404
  return { ok: false, reason: "unauthenticated", message: "Sign in again before marking this result complete." };
341
405
  const access = { principal: "operator", repos: who.repos };
@@ -343,7 +407,7 @@ export function checkAssignmentAsOperator(store, taskId, receiptDigest, who, now
343
407
  if (current === null || !store.accountCanAccess(who.name, current.repo))
344
408
  return { ok: false, reason: "not-found", message: "No assignment is available in your projects." };
345
409
  return acknowledgeCurrent(store, current, receiptDigest, `operator:${who.name}`, now, root, access);
346
- });
410
+ }));
347
411
  }
348
412
  function noteAssignmentHandoff(store, assignment, now) {
349
413
  if (!assignment.owner?.active || assignment.handoff === null || assignment.handoff.acknowledged)