toolroll 0.9.6 → 0.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/actor.d.ts +21 -0
  3. package/dist/actor.js +48 -0
  4. package/dist/assignment-adapters.d.ts +1 -0
  5. package/dist/assignment-ui.js +3 -1
  6. package/dist/assignment.d.ts +9 -0
  7. package/dist/assignment.js +62 -13
  8. package/dist/browser/workspace.css +2 -2
  9. package/dist/browser/workspace.js +28 -24
  10. package/dist/browser-workspace.d.ts +117 -2
  11. package/dist/builder.d.ts +1 -0
  12. package/dist/builder.js +33 -13
  13. package/dist/chat-actions.js +2 -1
  14. package/dist/chat-delivery.js +2 -0
  15. package/dist/chat-quiet.js +11 -1
  16. package/dist/chat-task-actions.d.ts +13 -0
  17. package/dist/chat-task-actions.js +19 -1
  18. package/dist/check-levels-ui.d.ts +21 -0
  19. package/dist/check-levels-ui.js +70 -0
  20. package/dist/check-levels.d.ts +67 -0
  21. package/dist/check-levels.js +171 -0
  22. package/dist/claim.d.ts +22 -0
  23. package/dist/claim.js +43 -0
  24. package/dist/cli.d.ts +1 -1
  25. package/dist/cli.js +4 -0
  26. package/dist/demo-chat.js +4 -1
  27. package/dist/dispatch.js +7 -2
  28. package/dist/dispose.d.ts +8 -0
  29. package/dist/dispose.js +7 -1
  30. package/dist/export.js +3 -2
  31. package/dist/first-run.d.ts +34 -0
  32. package/dist/first-run.js +37 -1
  33. package/dist/flow-gallery-ui.d.ts +30 -0
  34. package/dist/flow-gallery-ui.js +85 -0
  35. package/dist/flow-gallery.d.ts +111 -0
  36. package/dist/flow-gallery.js +445 -0
  37. package/dist/flow-share.d.ts +102 -0
  38. package/dist/flow-share.js +614 -0
  39. package/dist/flow-starters.d.ts +2 -0
  40. package/dist/flow-starters.js +1 -1
  41. package/dist/flow-steps.js +7 -0
  42. package/dist/flow-triggers.js +2 -0
  43. package/dist/flows-cli.d.ts +56 -0
  44. package/dist/flows-cli.js +109 -3
  45. package/dist/flows-ui.d.ts +15 -1
  46. package/dist/flows-ui.js +31 -6
  47. package/dist/guides.js +27 -2
  48. package/dist/lead-context.js +6 -2
  49. package/dist/mate-doors.d.ts +0 -15
  50. package/dist/mate-doors.js +5 -1
  51. package/dist/mate-tools.js +9 -4
  52. package/dist/models-ui.js +1 -1
  53. package/dist/operate.d.ts +4 -1
  54. package/dist/operate.js +601 -85
  55. package/dist/project-concurrency.d.ts +62 -0
  56. package/dist/project-concurrency.js +116 -0
  57. package/dist/project-delete-ui.d.ts +7 -0
  58. package/dist/project-delete-ui.js +19 -2
  59. package/dist/project-delete.js +4 -0
  60. package/dist/pull-request-flow.d.ts +9 -0
  61. package/dist/pull-request-flow.js +21 -3
  62. package/dist/result-follow-ups.d.ts +101 -0
  63. package/dist/result-follow-ups.js +333 -0
  64. package/dist/scope.js +11 -0
  65. package/dist/serve.d.ts +211 -2
  66. package/dist/serve.js +1009 -141
  67. package/dist/store.d.ts +76 -3
  68. package/dist/store.js +311 -22
  69. package/dist/surface.js +31 -8
  70. package/dist/tailnet.d.ts +11 -0
  71. package/dist/tailnet.js +47 -0
  72. package/dist/task-outcome-cli.js +2 -2
  73. package/dist/task-status.d.ts +8 -1
  74. package/dist/task-status.js +28 -10
  75. package/dist/team-cli.js +3 -0
  76. package/dist/telegram-status.js +2 -2
  77. package/dist/verification-evidence.js +21 -1
  78. package/dist/webhooks.js +5 -0
  79. package/dist/work-index.js +39 -6
  80. package/dist/work-summary.d.ts +0 -4
  81. package/dist/work-summary.js +2 -0
  82. package/dist/workspace-ui.d.ts +3 -0
  83. package/dist/workspace-ui.js +5 -3
  84. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,44 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.9.7 — 2026-10-01
6
+
7
+ - **Onboarding without a password or a form.** `toolroll up` prints three
8
+ lines and opens the console already signed in, through a one-time link that
9
+ works once, for ten minutes, from this computer only. The lead turns on by
10
+ itself with the agent that's signed in (Claude Code, else Codex); the old
11
+ setup form moved to Settings → Lead → Advanced. With no agent signed in,
12
+ Chat shows the command to install and sign in, and checks again on its own.
13
+ A short "how it works", a live Plan → Ready timeline on your first task, a
14
+ card for pairing your phone, and a "wrong host" page that says what to run.
15
+ - **The task page is one conversation.** The status stays on top; below it
16
+ the plan, the agent's notes and results, questions and your replies, in
17
+ order, ending in a box to message the agent (Build, Plan only, or Just
18
+ answer). Details (scope, attempts, audit) moved to a side panel, or a sheet
19
+ on phones. The Chat home shows who is working now and four counts, and
20
+ Catch up and the Inbox have tabs: Needs you, Ready, Running, All.
21
+ - **Checks: quick while building, easy to turn off.** Each project has a
22
+ check level: Quick (the default for new projects), Full or Off, and a task
23
+ can override it ("skip the tests"). With checks Off a result is still
24
+ Ready for review, and says no check ran. Any result can run its checks again or file a task to add tests.
25
+ - **A flow gallery, and flows as files.** Flows → New is a gallery of
26
+ templates grouped by what they're for, with new ones for developers
27
+ (Overnight bug bash, Flaky test hunter, Release notes writer, Docs follow
28
+ the code, Error to fix, PR second opinion and more). Export any flow as a
29
+ `.toolroll-flow.json` file and import one from a file or a link, with a
30
+ preview; secrets never leave, and imported triggers start switched off.
31
+ - **Several builds at once on one project.** The service builds up to two
32
+ tasks of a project at a time (Settings → Projects, `toolroll project
33
+ concurrency`), within the machine's limit. Stopping the service mid-build
34
+ saves the work and resumes it next time.
35
+ - **The lead's housekeeping stays quiet.** An agent acting as your lead gets
36
+ its own credential (`toolroll lead token`); its work pings nobody unless it
37
+ hands something to you. Your own actions never ping you, a replaced task
38
+ reads "Replaced by", and a whole project can be muted.
39
+ - **A faster release check.** Flow journeys run in parallel groups, browser
40
+ journeys are rebalanced, and a version-only bump no longer forces every
41
+ test.
42
+
5
43
  ## 0.9.6 — 2026-10-01
6
44
 
7
45
  - **Every task's status, clear at a glance.** One headline from a fixed set
@@ -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;
@@ -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,7 @@
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";
4
5
  export type AssignmentAccess = WorkSummaryAccess;
5
6
  export type AssignmentOwner = {
6
7
  kind: "coordinator" | "lead";
@@ -13,6 +14,10 @@ export type AssignmentChecks = {
13
14
  command: string | null;
14
15
  logArtifactId: number | null;
15
16
  detail: string;
17
+ /** Which check this is (check-levels.ts): quick, full, or off; null before levels. */
18
+ level?: CheckLevel | null;
19
+ /** A follow-up check waiting or running on this commit. */
20
+ running?: "quick" | "full" | null;
16
21
  };
17
22
  export type AssignmentReceipt = {
18
23
  digest: string;
@@ -70,10 +75,12 @@ export type AssignmentSnapshot = {
70
75
  problem: string | null;
71
76
  }[];
72
77
  };
78
+ /** `lead`: marked complete by the person's lead (`toolroll lead token`), shown as "by the lead". */
73
79
  completion: {
74
80
  actor: string;
75
81
  at: string;
76
82
  digest: string;
83
+ lead?: true;
77
84
  } | null;
78
85
  handoff: {
79
86
  kind: "result" | "decision" | "attention";
@@ -112,6 +119,7 @@ export declare function assignmentBrief(assignment: AssignmentSnapshot | null):
112
119
  actor: string;
113
120
  at: string;
114
121
  digest: string;
122
+ lead?: true;
115
123
  } | null;
116
124
  result: {
117
125
  digest: string;
@@ -189,6 +197,7 @@ export declare function assignmentUpdates(store: Store, now: Date, access: Assig
189
197
  actor: string;
190
198
  at: string;
191
199
  digest: string;
200
+ lead?: true;
192
201
  } | null;
193
202
  result: {
194
203
  digest: string;
@@ -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,8 @@ 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";
15
19
  /** Status-first handoff for routine reads. Fetch get_assignment only when
16
20
  * inspecting its exact evidence; do not repeat a full proof in every poll. */
17
21
  export function assignmentBrief(assignment) {
@@ -28,6 +32,20 @@ export function assignmentBrief(assignment) {
28
32
  publication: assignment.publication, deployment: assignment.deployment };
29
33
  }
30
34
  const digest = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
35
+ /** A lookup added with lead-quiet, read safely: a deploy proves completion with this code over the INSTALLED runtime's
36
+ * store, before this version's migration, so the method (or its table) may not exist yet. Absent reads as none. */
37
+ function olderStoreSafe(read, absent) {
38
+ try {
39
+ return read();
40
+ }
41
+ catch (error) {
42
+ if (error instanceof TypeError || /no such (table|column)/.test(String(error)))
43
+ return absent;
44
+ throw error;
45
+ }
46
+ }
47
+ /** A check as the receipt digest seals it: presentation-only fields (check-levels.ts) left out. */
48
+ const sealedChecks = ({ level: _level, running: _running, ...proof }) => proof;
31
49
  const OWNER_ACTION = "assignment claimed";
32
50
  const CHECK_ACTION = COMPLETION_ACTION;
33
51
  const actorOf = (owner) => `${owner.kind}:${owner.id}`;
@@ -58,8 +76,24 @@ function ownerOf(store, rootId, repo) {
58
76
  active: credential !== undefined && credential["revoked_at"] === null && repo !== null && Array.isArray(repos) && repos.includes(repo) };
59
77
  }
60
78
  // 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 });
79
+ // Follow-up checks on the same commit (Run checks) upgrade a pass and show a failure.
80
+ function nativeChecks(store, root, runId, now) {
81
+ const own = buildChecks(store, root, runId);
82
+ const run = store.getRun(runId);
83
+ const followUps = followUpChecksOf(store, runId, now, root);
84
+ if (followUps.length === 0)
85
+ return own;
86
+ const read = withFollowUps({ status: own.status, level: own.level ?? null, exitCode: own.exitCode, head: run?.headRevision ?? null }, followUps);
87
+ const latest = [...followUps].reverse().find(one => one.state === "passed" || one.state === "failed");
88
+ const changed = read.status !== own.status || read.level !== (own.level ?? null) || read.exitCode !== own.exitCode;
89
+ const words = read.level === "quick" ? "Quick checks" : "Checks";
90
+ return { ...own, running: read.running,
91
+ ...(changed ? { status: read.status, level: read.level, exitCode: read.exitCode, logArtifactId: latest?.logArtifactId ?? own.logArtifactId,
92
+ detail: read.status === "passed" ? `${words} passed.` : `${words} failed (exit ${read.exitCode}).` } : {}) };
93
+ }
94
+ function buildChecks(store, root, runId) {
95
+ const level = runCheckLevel(store, runId);
96
+ const unavailable = (detail) => ({ status: "unavailable", exitCode: null, command: null, logArtifactId: null, detail, level });
63
97
  if (root === undefined)
64
98
  return unavailable("Saved checks are unavailable.");
65
99
  try {
@@ -68,11 +102,14 @@ function nativeChecks(store, root, runId) {
68
102
  return unavailable(gate.problem);
69
103
  const receipt = gate.bytes === null ? null : JSON.parse(gate.bytes);
70
104
  if (receipt === null)
71
- return { ...unavailable("No machine check is recorded."), status: "not-run" };
105
+ return { ...unavailable(level === "off" ? "Checks were off for this build." : "No machine check is recorded."), status: "not-run" };
72
106
  const ran = receipt.result.ran === true, exitCode = ran ? receipt.result.exitCode : null;
107
+ // The sealed receipt names the grant that ran: the quick one, or the full one.
108
+ const sealedLevel = typeof receipt.command?.repo === "string" && receipt.command.repo.startsWith("quick:") ? "quick" : "full";
109
+ const words = sealedLevel === "quick" ? "Quick checks" : "Checks";
73
110
  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." };
111
+ command: receipt.command.command, logArtifactId: receipt.log.artifactId, level: level === null ? null : sealedLevel,
112
+ detail: ran ? exitCode === 0 ? `${words} passed.` : `${words} failed (exit ${exitCode}).` : `${words} did not finish.` };
76
113
  }
77
114
  catch {
78
115
  return unavailable("Saved checks could not be read.");
@@ -112,7 +149,7 @@ export function assignmentOf(store, taskId, now, access, root) {
112
149
  const reports = artifacts.filter(a => a.kind === "report");
113
150
  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
151
  .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);
152
+ const checks = result === null ? null : nativeChecks(store, root, result.id, now);
116
153
  const finishedBuild = result?.role === "builder" && (result.outcome === "built" || result.outcome === "no-change") &&
117
154
  result.finishedAt !== null && /^[a-f0-9]{40}$/.test(result.headRevision ?? "");
118
155
  // Finished work returns to its lead or user. Strict terms and previous model
@@ -133,7 +170,11 @@ export function assignmentOf(store, taskId, now, access, root) {
133
170
  ...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
171
  agentReport: result.handoff, evidence: "recorded",
135
172
  };
136
- const receipt = receiptBody === null ? null : { ...receiptBody, digest: digest({ receipt: receiptBody,
173
+ // The digest seals the proof, not how it is shown: the check's `level` (read from its sealed command) and `running`
174
+ // (a follow-up in flight) stay out of it, so a result completed before check levels keeps its completion, and a
175
+ // follow-up check that is still running never unseals one.
176
+ const sealedBody = receiptBody === null ? null : { ...receiptBody, checks: sealedChecks(receiptBody.checks) };
177
+ const receipt = receiptBody === null ? null : { ...receiptBody, digest: digest({ receipt: sealedBody,
137
178
  scope: scope === null ? null : { digest: scope.digest, approved: approvalOf(scope).approved, termsProblem: scope.termsProblem ?? null },
138
179
  versions: family.versions.map(v => ({ id: v.id, state: v.state })) }) };
139
180
  let completion = null;
@@ -154,8 +195,10 @@ export function assignmentOf(store, taskId, now, access, root) {
154
195
  state = "needs-decision";
155
196
  else if (current.state === "cancelled") {
156
197
  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" };
198
+ const successor = olderStoreSafe(() => store.replacementOf(current.id) ?? store.replacementOf(family.root.id), null);
199
+ detail = successor === null ? "This assignment was cancelled." : `${replacedWords(successor)}.`;
200
+ primaryAction = successor === null ? { code: "inspect-task", label: "View assignment", target: { taskId: current.id, runId: null, decisionId: null }, access: "read", retry: "read-again" }
201
+ : { code: "inspect-task", label: `Open ${successor}`, target: { taskId: successor, runId: null, decisionId: null }, access: "read", retry: "read-again" };
159
202
  }
160
203
  else if (!live && processProblem !== null && result !== null) {
161
204
  state = "needs-decision";
@@ -182,10 +225,12 @@ export function assignmentOf(store, taskId, now, access, root) {
182
225
  // membership changes and ownership transfer cannot revoke past completion.
183
226
  // The exact result digest still fences changes to work, scope and history.
184
227
  if (checked !== undefined) {
185
- completion = { actor: String(checked["actor"]), at: String(checked["at"]), digest: receipt.digest };
228
+ const rootRef = store.lookupRef(family.root.id);
229
+ const byLead = rootRef !== null && olderStoreSafe(() => store.taskActs(rootRef.id), []).filter(one => one.act === "completed").at(-1)?.lead === true;
230
+ completion = { actor: String(checked["actor"]), at: String(checked["at"]), digest: receipt.digest, ...(byLead ? { lead: true } : {}) };
186
231
  state = "complete";
187
232
  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";
233
+ const label = byLead ? "the lead" : completion.actor.startsWith("operator:") ? completion.actor.slice(9) : owner && completion.actor === actorOf(owner) ? owner.label : "the previous lead";
189
234
  detail = completionKind === "research-report" ? `Research report checked by ${label}. No deployment is implied.`
190
235
  : `Handled by ${label}. ${receipt.checks.detail} Publication and deployment are separate.`;
191
236
  }
@@ -316,6 +361,9 @@ function acknowledgeCurrent(store, current, receiptDigest, actor, now, root, acc
316
361
  return { ok: false, reason: "approval-rules", message: problem };
317
362
  store.recordAction({ at: now.toISOString(), actor, repo: current.repo,
318
363
  taskId: current.rootId, runId: receipt.runId, action: CHECK_ACTION, outcome: receiptDigest, source: "work" });
364
+ const ref = store.lookupRef(current.rootId);
365
+ if (ref !== null)
366
+ store.noteTaskAct(ref.id, "completed", now);
319
367
  store.bumpWake();
320
368
  }
321
369
  const assignment = assignmentOf(store, current.rootId, now, access, root);
@@ -335,7 +383,8 @@ export function checkAssignment(store, taskId, receiptDigest, owner, now, root =
335
383
  /** The signed-in user can mark the same exact receipt handled without taking
336
384
  * lead ownership. This records review, never check success or new authority. */
337
385
  export function checkAssignmentAsOperator(store, taskId, receiptDigest, who, now, root = evidenceRoot(homedir())) {
338
- return store.transact(() => {
386
+ // Completing is this person's act (or their lead's): it never pings them.
387
+ return withActor(currentActor() ?? { account: who.name, lead: false }, () => store.transact(() => {
339
388
  if (!reproveApprover(store, who).ok)
340
389
  return { ok: false, reason: "unauthenticated", message: "Sign in again before marking this result complete." };
341
390
  const access = { principal: "operator", repos: who.repos };
@@ -343,7 +392,7 @@ export function checkAssignmentAsOperator(store, taskId, receiptDigest, who, now
343
392
  if (current === null || !store.accountCanAccess(who.name, current.repo))
344
393
  return { ok: false, reason: "not-found", message: "No assignment is available in your projects." };
345
394
  return acknowledgeCurrent(store, current, receiptDigest, `operator:${who.name}`, now, root, access);
346
- });
395
+ }));
347
396
  }
348
397
  function noteAssignmentHandoff(store, assignment, now) {
349
398
  if (!assignment.owner?.active || assignment.handoff === null || assignment.handoff.acknowledged)