toolroll 0.9.60 → 0.9.62

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 (128) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/dist/agent-board.d.ts +208 -0
  3. package/dist/agent-board.js +454 -0
  4. package/dist/agent-fence.d.ts +19 -0
  5. package/dist/agent-fence.js +100 -8
  6. package/dist/agent-pulse.d.ts +43 -0
  7. package/dist/agent-pulse.js +214 -0
  8. package/dist/agent-turns.d.ts +52 -0
  9. package/dist/agent-turns.js +137 -0
  10. package/dist/agents-board.d.ts +101 -0
  11. package/dist/agents-board.js +225 -0
  12. package/dist/assignment-adapters.d.ts +1 -0
  13. package/dist/assignment-status.d.ts +1 -1
  14. package/dist/assignment.d.ts +4 -0
  15. package/dist/assignment.js +8 -6
  16. package/dist/browser/workspace.css +1 -1
  17. package/dist/browser/workspace.js +29 -25
  18. package/dist/browser-crew.js +2 -0
  19. package/dist/browser-workspace.d.ts +79 -1
  20. package/dist/browser-workspace.js +1 -0
  21. package/dist/builder.d.ts +8 -0
  22. package/dist/builder.js +113 -13
  23. package/dist/chat-channel.js +9 -0
  24. package/dist/chat-core.d.ts +6 -0
  25. package/dist/chat-core.js +8 -0
  26. package/dist/chat-delivery.js +22 -0
  27. package/dist/chat-messages.js +8 -1
  28. package/dist/chat-quiet.js +1 -0
  29. package/dist/chat-voice.d.ts +2 -0
  30. package/dist/chat-voice.js +2 -0
  31. package/dist/checkout-cleanup.d.ts +3 -0
  32. package/dist/checkout-cleanup.js +11 -2
  33. package/dist/cli.js +28 -11
  34. package/dist/contracts/console-api.d.ts +3 -1
  35. package/dist/contracts/console-api.js +5 -1
  36. package/dist/contracts/lead-tools.d.ts +16 -2
  37. package/dist/contracts/lead-tools.js +5 -1
  38. package/dist/desktop-update.d.ts +1 -1
  39. package/dist/evidence-pack.js +1 -1
  40. package/dist/evidence.d.ts +6 -0
  41. package/dist/evidence.js +14 -3
  42. package/dist/exec.d.ts +32 -0
  43. package/dist/exec.js +229 -10
  44. package/dist/invoke.js +110 -18
  45. package/dist/landed-cleanup.d.ts +170 -0
  46. package/dist/landed-cleanup.js +814 -0
  47. package/dist/lead-cli.js +22 -19
  48. package/dist/lead-contract.d.ts +1 -1
  49. package/dist/lead-contract.js +4 -4
  50. package/dist/lead-doors.js +45 -1
  51. package/dist/lead-tools.js +40 -7
  52. package/dist/live-input.d.ts +68 -0
  53. package/dist/live-input.js +254 -0
  54. package/dist/live.d.ts +1 -1
  55. package/dist/live.js +4 -2
  56. package/dist/maintenance.d.ts +2 -0
  57. package/dist/maintenance.js +5 -2
  58. package/dist/operate-remote-arguments.js +5 -1
  59. package/dist/operate-remote.js +1 -1
  60. package/dist/operate.d.ts +1 -1
  61. package/dist/operate.js +249 -26
  62. package/dist/orphan-sweep.d.ts +2 -0
  63. package/dist/orphan-sweep.js +5 -3
  64. package/dist/project-admission.d.ts +10 -6
  65. package/dist/project-admission.js +33 -11
  66. package/dist/project-delete-ui.d.ts +12 -0
  67. package/dist/project-delete-ui.js +32 -2
  68. package/dist/project-tools.d.ts +7 -11
  69. package/dist/project-tools.js +13 -37
  70. package/dist/provider.d.ts +65 -8
  71. package/dist/provider.js +80 -21
  72. package/dist/remote-main.d.ts +57 -0
  73. package/dist/remote-main.js +179 -0
  74. package/dist/result-completion.d.ts +7 -0
  75. package/dist/result-completion.js +9 -0
  76. package/dist/runner.d.ts +4 -2
  77. package/dist/runner.js +3 -3
  78. package/dist/serve.js +19 -1
  79. package/dist/server/live.d.ts +3 -0
  80. package/dist/server/live.js +35 -2
  81. package/dist/server/pages.js +59 -35
  82. package/dist/server/render-chat.js +12 -0
  83. package/dist/server/render-tasks.d.ts +15 -2
  84. package/dist/server/render-tasks.js +30 -7
  85. package/dist/server/route-table.js +4 -1
  86. package/dist/server/settings.js +5 -3
  87. package/dist/server/task-views.js +11 -0
  88. package/dist/server/tasks.js +63 -0
  89. package/dist/shared-deps.d.ts +9 -4
  90. package/dist/shared-deps.js +89 -13
  91. package/dist/staged-runtimes.d.ts +16 -4
  92. package/dist/staged-runtimes.js +160 -38
  93. package/dist/steer-live.d.ts +19 -0
  94. package/dist/steer-live.js +93 -0
  95. package/dist/steer-status.d.ts +8 -2
  96. package/dist/steer-status.js +19 -5
  97. package/dist/storage-sweep-child.d.ts +1 -0
  98. package/dist/storage-sweep-child.js +76 -0
  99. package/dist/storage-sweep-process.d.ts +48 -0
  100. package/dist/storage-sweep-process.js +204 -0
  101. package/dist/storage-sweep-schedule.d.ts +30 -0
  102. package/dist/storage-sweep-schedule.js +164 -0
  103. package/dist/storage-sweep.d.ts +31 -6
  104. package/dist/storage-sweep.js +165 -39
  105. package/dist/storage-ui.d.ts +4 -0
  106. package/dist/storage-ui.js +22 -7
  107. package/dist/storage.d.ts +6 -0
  108. package/dist/storage.js +35 -0
  109. package/dist/store.d.ts +200 -3
  110. package/dist/store.js +443 -17
  111. package/dist/surface.js +8 -4
  112. package/dist/sweep-quarantine.d.ts +28 -0
  113. package/dist/sweep-quarantine.js +153 -0
  114. package/dist/task-base.d.ts +35 -0
  115. package/dist/task-base.js +51 -0
  116. package/dist/task-status.d.ts +2 -0
  117. package/dist/task-status.js +5 -1
  118. package/dist/test-temp.d.ts +6 -3
  119. package/dist/test-temp.js +15 -9
  120. package/dist/toolroll-update.js +4 -2
  121. package/dist/work-index.d.ts +1 -0
  122. package/dist/work-index.js +32 -12
  123. package/dist/work-summary.d.ts +2 -2
  124. package/dist/workspace-revision.js +3 -1
  125. package/dist/worktree.d.ts +57 -3
  126. package/dist/worktree.js +201 -23
  127. package/package.json +1 -1
  128. package/scripts/provider-canary.mjs +235 -147
@@ -0,0 +1,225 @@
1
+ /**
2
+ * The Agents board's projection (one place to check on running agents): each
3
+ * task's newest build attempt from the last day, in the viewer's admitted
4
+ * projects, grouped Needs you, Working, Stuck, Done (not viewed yet), Idle.
5
+ * A protocol repair never takes the row: while one runs, its parent's row says
6
+ * "Fixing the handoff" and keeps the parent's transcript, changes and link.
7
+ * Saved facts only (agent-board.ts, the run, its activity and its sealed diff
8
+ * stats); nothing here verifies evidence or grants an action.
9
+ */
10
+ import { quietSince, stuckSince } from "./agent-board.js";
11
+ import { activityLine } from "./activity-line.js";
12
+ import { readVerifiedArtifact } from "./evidence.js";
13
+ import { failedAttemptSentence } from "./needs-you.js";
14
+ import { projectName } from "./project.js";
15
+ import { providerName } from "./provider-auth.js";
16
+ import { runActivityOf } from "./task-activity.js";
17
+ export const AGENT_GROUPS = [
18
+ { key: "needs-you", label: "Needs you" },
19
+ { key: "working", label: "Working" },
20
+ { key: "stuck", label: "Stuck" },
21
+ { key: "done", label: "Done" },
22
+ { key: "idle", label: "Idle" },
23
+ ];
24
+ /** Finished attempts stay on the board this long; unread Done and Needs you stay three days. */
25
+ const RECENT_MS = 24 * 60 * 60_000;
26
+ const KEPT_MS = 3 * RECENT_MS;
27
+ const BOARD_LIMIT = 80;
28
+ const str = (row, key) => row[key] == null ? null : String(row[key]);
29
+ const FACTS = `SELECT r.id, r.task_ref, r.provider, r.runner, r.outcome, r.reason, r.started_at, r.finished_at, r.phase, r.attempt_key IS NULL AS legacy,
30
+ tr.external_id AS task_id, tr.repo, t.title, t.state AS task_state,
31
+ n.state AS turn, n.state_at, n.transcript_at, n.stuck_at, n.said, n.said_note, n.said_at, n.diff_files, n.tool_kind, n.tool_at, n.question_note,
32
+ EXISTS (SELECT 1 FROM run p WHERE p.parent_run = r.id AND p.role = 'repair' AND p.outcome IS NULL) AS repairing,
33
+ (SELECT json_object('id', q.id, 'key', q.key, 'text', q.question) FROM agent_question q
34
+ WHERE q.run = r.id AND q.closed_at IS NULL AND q.attempt_key = r.attempt_key ORDER BY q.id LIMIT 1) AS question,
35
+ EXISTS (SELECT 1 FROM agent_seen s WHERE s.account = $account AND s.run = r.id) AS seen
36
+ FROM run r
37
+ JOIN task_ref tr ON tr.id = r.task_ref AND tr.backend = 'built-in' AND tr.repo IS NOT NULL
38
+ JOIN task t ON t.id = tr.external_id
39
+ LEFT JOIN agent_now n ON n.task_ref = r.task_ref AND n.run = r.id AND n.attempt_key = r.attempt_key
40
+ WHERE r.role = 'builder'`;
41
+ function readFacts(row) {
42
+ let question = null;
43
+ try {
44
+ const parsed = row["question"] == null ? null : JSON.parse(String(row["question"]));
45
+ question = parsed === null ? null : { id: Number(parsed.id), key: String(parsed.key), text: String(parsed.text) };
46
+ }
47
+ catch {
48
+ question = null;
49
+ }
50
+ return {
51
+ runId: Number(row["id"]), taskRef: Number(row["task_ref"]), taskId: String(row["task_id"]), title: String(row["title"]), repo: str(row, "repo"),
52
+ provider: String(row["provider"] ?? "claude"), runner: String(row["runner"]), outcome: str(row, "outcome"), reason: str(row, "reason"),
53
+ startedAt: String(row["started_at"]), finishedAt: str(row, "finished_at"), legacy: Number(row["legacy"]) === 1, taskState: String(row["task_state"]),
54
+ turn: str(row, "turn"), stateAt: str(row, "state_at"), transcriptAt: str(row, "transcript_at"), stuckAt: str(row, "stuck_at"),
55
+ said: str(row, "said"), saidNote: str(row, "said_note"), saidAt: str(row, "said_at"), diffFiles: row["diff_files"] == null ? null : Number(row["diff_files"]),
56
+ toolKind: str(row, "tool_kind"), toolAt: str(row, "tool_at"), phase: str(row, "phase"), questionNote: str(row, "question_note"), repairing: Number(row["repairing"]) === 1,
57
+ question, seen: Number(row["seen"]) === 1,
58
+ };
59
+ }
60
+ /**
61
+ * The board's candidate builds, through the partial indexes (agent_board_open, agent_board_recent): open ones and
62
+ * those finished since $kept, one per task (its newest), never a scan of every run. A candidate a newer build of the
63
+ * same task has replaced is dropped by an indexed probe, so the row is still the task's newest build.
64
+ */
65
+ export const BOARD_CANDIDATES = `WITH candidate AS (
66
+ SELECT task_ref, id FROM run WHERE role = 'builder' AND outcome IS NULL
67
+ UNION ALL
68
+ SELECT task_ref, id FROM run WHERE role = 'builder' AND outcome IS NOT NULL AND finished_at >= $kept
69
+ ), newest AS (SELECT task_ref, MAX(id) AS id FROM candidate GROUP BY task_ref)`;
70
+ /** The board's candidate attempts: each task's newest build, open or recently finished, in the admitted projects. */
71
+ export function agentFacts(store, now, access, account) {
72
+ return store.handle.prepare(agentFactsSql())
73
+ .all({ $account: account, $kept: new Date(now.getTime() - KEPT_MS).toISOString(), $all: access.repos === null ? 1 : 0, $repos: JSON.stringify(access.repos ?? []) })
74
+ .map(readFacts);
75
+ }
76
+ /** The board's query, exported for its query-plan test. */
77
+ export function agentFactsSql() {
78
+ return `${BOARD_CANDIDATES}
79
+ ${FACTS.replace("FROM run r", "FROM newest c JOIN run r ON r.id = c.id")}
80
+ AND NOT EXISTS (SELECT 1 FROM run x WHERE x.task_ref = r.task_ref AND x.role = 'builder' AND x.id > r.id)
81
+ AND NOT EXISTS (SELECT 1 FROM task_ref c WHERE c.backend = tr.backend AND c.revision_of = tr.external_id AND c.repo IS tr.repo)
82
+ AND ($all = 1 OR tr.repo IN (SELECT value FROM json_each($repos)))
83
+ ORDER BY r.id DESC LIMIT ${BOARD_LIMIT}`;
84
+ }
85
+ /** One exact build attempt, whatever its age (the opened row), in the admitted projects: a repair opens as its parent. */
86
+ export function agentFactsFor(store, run, access, account) {
87
+ const row = store.handle.prepare(`${FACTS} AND r.id = (SELECT CASE WHEN role = 'repair' AND parent_run IS NOT NULL THEN parent_run ELSE id END FROM run WHERE id = $run) AND ($all = 1 OR tr.repo IN (SELECT value FROM json_each($repos)))`)
88
+ .get({ $account: account, $run: run, $all: access.repos === null ? 1 : 0, $repos: JSON.stringify(access.repos ?? []) });
89
+ return row === undefined ? null : readFacts(row);
90
+ }
91
+ const FINISHED_WELL = new Set(["built", "no-change"]);
92
+ const SETTLED_TASK = new Set(["done", "cancelled"]);
93
+ /** Where an attempt sits on the board, and its one state word. Stuck is worked out from the facts now, so fresh
94
+ * transcript or activity clears it at once (the pulse's saved mark only tells open boards it crossed). */
95
+ export function agentGroupOf(facts, activityAt, now) {
96
+ if (facts.outcome === null) {
97
+ if (facts.question !== null)
98
+ return { group: "needs-you", label: "Question" };
99
+ if (facts.repairing)
100
+ return { group: "working", label: "Working" };
101
+ if (facts.turn === "crashed")
102
+ return { group: "needs-you", label: "Stopped unexpectedly" };
103
+ if (facts.said === "blocked" && facts.turn !== "idle")
104
+ return { group: "needs-you", label: "Blocked" };
105
+ if (facts.turn === "busy" && stuckSince({ state: "busy", stateAt: facts.stateAt ?? facts.startedAt, transcriptAt: facts.transcriptAt ?? facts.startedAt, toolAt: facts.toolAt }, activityAt, now) !== null) {
106
+ return { group: "stuck", label: "Stuck" };
107
+ }
108
+ // Its turn has its result, but the build is still capturing and checking it: still at work, not idle.
109
+ return { group: "working", label: "Working" };
110
+ }
111
+ if (FINISHED_WELL.has(facts.outcome)) {
112
+ const label = facts.outcome === "built" ? "Done" : "No changes";
113
+ // Unread only for attempts the board saw start: a run from before it never counts as news.
114
+ return { group: facts.seen || facts.legacy ? "idle" : "done", label };
115
+ }
116
+ if (SETTLED_TASK.has(facts.taskState))
117
+ return { group: "idle", label: facts.outcome === "interrupted" ? "Stopped" : "Failed" };
118
+ return { group: "needs-you", label: facts.outcome === "parked" ? "Needs a decision" : facts.outcome === "interrupted" ? "Stopped" : facts.outcome === "refused" ? "Not started" : "Failed" };
119
+ }
120
+ const sealed = new Map();
121
+ /** A finished attempt's sealed diff stats (cached by artifact: sealed bytes never change). */
122
+ export function sealedDiffOf(store, root, run) {
123
+ if (root === undefined)
124
+ return null;
125
+ const artifact = store.artifactsFor(run).find(one => one.kind === "diff-stat");
126
+ if (artifact === undefined || artifact.captureStatus !== "ok")
127
+ return null;
128
+ if (sealed.has(artifact.id))
129
+ return sealed.get(artifact.id) ?? null;
130
+ let summary = null;
131
+ try {
132
+ const read = readVerifiedArtifact(root, artifact);
133
+ if (read.ok) {
134
+ const stat = JSON.parse(read.content.toString("utf8"));
135
+ const files = Array.isArray(stat.files) ? stat.files.filter(one => typeof one?.["path"] === "string").map(one => ({
136
+ path: String(one["path"]), additions: typeof one["additions"] === "number" ? one["additions"] : null, deletions: typeof one["deletions"] === "number" ? one["deletions"] : null
137
+ })) : [];
138
+ summary = { fileCount: Number(stat.fileCount ?? files.length) || 0, additions: Number(stat.additions ?? 0) || 0, deletions: Number(stat.deletions ?? 0) || 0, files };
139
+ }
140
+ }
141
+ catch {
142
+ summary = null;
143
+ }
144
+ if (sealed.size > 500)
145
+ sealed.clear();
146
+ sealed.set(artifact.id, summary);
147
+ return summary;
148
+ }
149
+ const files = (count) => `${count} ${count === 1 ? "file" : "files"}`;
150
+ /** A row's diff size: the sealed stats once finished, the pulse's count while it runs. */
151
+ export function diffWords(facts, diff) {
152
+ if (facts.outcome !== null) {
153
+ if (diff === null)
154
+ return null;
155
+ if (diff.fileCount === 0)
156
+ return "No changes";
157
+ return `+${diff.additions} −${diff.deletions} · ${files(diff.fileCount)}`;
158
+ }
159
+ if (facts.diffFiles === null)
160
+ return null;
161
+ return facts.diffFiles === 0 ? "No changes yet" : `${files(facts.diffFiles)} changed`;
162
+ }
163
+ const SETTLING_PHASES = new Set(["validating-handoff", "capturing-evidence", "committing", "verifying-proof", "correcting-proof"]);
164
+ /** Reasons whose words only restate the Failed chip: the row shows the agent's own last word instead, or nothing. */
165
+ const RESTATES_FAILED = new Set(["agent"]);
166
+ const minutes = (ms) => Math.max(1, Math.floor(ms / 60_000));
167
+ /** A finished attempt's one line: never the state again (the chip says it), only why. */
168
+ function endedLine(facts) {
169
+ const own = facts.said === "blocked" || facts.said === "done" ? facts.saidNote : null;
170
+ if (FINISHED_WELL.has(facts.outcome ?? ""))
171
+ return facts.said === "done" ? facts.saidNote : null;
172
+ if (facts.outcome === "parked")
173
+ return "Stopped to ask a question";
174
+ if (RESTATES_FAILED.has(facts.reason?.trim() ?? ""))
175
+ return own;
176
+ return failedAttemptSentence(facts.reason);
177
+ }
178
+ /** One board row. */
179
+ export function agentRowOf(store, facts, now, root) {
180
+ const activity = facts.outcome === null ? runActivityOf(store, { id: facts.runId, runner: facts.runner, startedAt: facts.startedAt }, now) : null;
181
+ const { group, label } = agentGroupOf(facts, activity?.at ?? null, now);
182
+ let line, quiet = false;
183
+ if (facts.outcome !== null)
184
+ line = endedLine(facts);
185
+ else if (facts.question !== null)
186
+ line = facts.question.text;
187
+ else if (facts.repairing)
188
+ line = "Fixing the handoff";
189
+ else if (group === "stuck")
190
+ line = `No new output for ${Math.max(4, minutes(now.getTime() - quietSince({ stateAt: facts.stateAt ?? facts.startedAt, transcriptAt: facts.transcriptAt ?? facts.startedAt, toolAt: facts.toolAt }, activity.at)))} min`;
191
+ else if (facts.turn === "idle" || (facts.turn !== "crashed" && SETTLING_PHASES.has(facts.phase ?? "")))
192
+ line = "Checking the result";
193
+ else if (facts.turn === "busy" && facts.toolAt !== null && now.getTime() - Date.parse(facts.toolAt) >= 60_000) {
194
+ line = `${facts.toolKind === "command" ? "Running a command" : "Waiting on a tool"} · ${minutes(now.getTime() - Date.parse(facts.toolAt))} min`;
195
+ }
196
+ else {
197
+ const said = facts.saidNote !== null && facts.saidAt !== null && facts.saidAt >= activity.at ? facts.saidNote : null;
198
+ const read = activityLine(activity, now.getTime());
199
+ line = said ?? read.text;
200
+ quiet = said === null && read.quiet;
201
+ }
202
+ return {
203
+ runId: facts.runId, taskId: facts.taskId, title: facts.title, project: facts.repo === null ? null : projectName(facts.repo), href: `/agents?run=${facts.runId}`,
204
+ group, label, line, quiet, startedAt: facts.startedAt, endedAt: facts.finishedAt,
205
+ diff: diffWords(facts, facts.outcome === null ? null : sealedDiffOf(store, root, facts.runId)), agent: providerName(facts.provider),
206
+ };
207
+ }
208
+ /** The whole board: groups in order, rows newest first within each; empty groups dropped. */
209
+ export function agentsBoardOf(store, now, access, account, root) {
210
+ const recent = new Date(now.getTime() - RECENT_MS).toISOString();
211
+ const rows = agentFacts(store, now, access, account).map(facts => ({ facts, row: agentRowOf(store, facts, now, root) }))
212
+ .filter(({ facts, row }) => facts.outcome === null || row.group === "done" || row.group === "needs-you" || (facts.finishedAt ?? "") >= recent)
213
+ .map(({ row }) => row);
214
+ return AGENT_GROUPS.map(group => ({ ...group, rows: rows.filter(row => row.group === group.key) })).filter(group => group.rows.length > 0);
215
+ }
216
+ /** A board fingerprint for one exact build: what its open view shows moved, its repairs included. */
217
+ export function agentRunFingerprint(store, run) {
218
+ const row = store.handle.prepare(`SELECT r.outcome, r.finished_at, r.phase, n.state, n.state_at, n.transcript_bytes, n.stuck_at, n.said_at, n.diff_files, n.tool_at, n.question_note,
219
+ (SELECT count(*) || ':' || coalesce(max(id), 0) || ':' || coalesce(sum(closed_at IS NULL), 0) FROM agent_question WHERE run = r.id) AS asks,
220
+ (SELECT count(*) || ':' || coalesce(max(id), 0) || ':' || coalesce(sum(outcome IS NULL), 0) FROM run p WHERE p.parent_run = r.id AND p.role = 'repair') AS repairs,
221
+ (SELECT at FROM run_activity WHERE run = r.id) AS activity,
222
+ (SELECT count(*) || ':' || coalesce(max(id), 0) FROM task_steer WHERE task_ref = r.task_ref) AS steer
223
+ FROM run r LEFT JOIN agent_now n ON n.run = r.id AND n.attempt_key = r.attempt_key WHERE r.id = ?`).get(run);
224
+ return row === undefined ? null : Object.values(row).map(value => String(value)).join("|");
225
+ }
@@ -83,6 +83,7 @@ export declare function assignmentForCoordinator(store: Store, token: string, op
83
83
  at: string;
84
84
  digest: string;
85
85
  lead?: true;
86
+ landed?: string;
86
87
  } | null;
87
88
  result: {
88
89
  digest: string;
@@ -23,7 +23,7 @@ export declare function assignmentStatusBrief(assignment: AssignmentSnapshot): {
23
23
  } | null;
24
24
  runId: number | null;
25
25
  primaryAction: {
26
- code: "confirm-stopped" | "inspect-decisions" | "inspect-run" | "inspect-stop" | "inspect-task" | "open-pr" | "reconcile-run" | "sign-in" | import("./dispatch.js").DispatchAction;
26
+ code: "answer-agent" | "confirm-stopped" | "inspect-decisions" | "inspect-run" | "inspect-stop" | "inspect-task" | "open-pr" | "reconcile-run" | "sign-in" | import("./dispatch.js").DispatchAction;
27
27
  label: string;
28
28
  target: import("./work-summary.js").WorkTarget;
29
29
  } | null;
@@ -86,11 +86,13 @@ export type AssignmentSnapshot = {
86
86
  }[];
87
87
  };
88
88
  /** `lead`: marked complete by the person's lead (`toolroll lead token`), shown as "by the lead". */
89
+ /** `landed`: Toolroll marked it complete because its work is already in main, and why ("its pull request merged"). */
89
90
  completion: {
90
91
  actor: string;
91
92
  at: string;
92
93
  digest: string;
93
94
  lead?: true;
95
+ landed?: string;
94
96
  } | null;
95
97
  handoff: {
96
98
  kind: "result" | "decision" | "attention";
@@ -154,6 +156,7 @@ export declare function assignmentBrief(assignment: AssignmentSnapshot | null):
154
156
  at: string;
155
157
  digest: string;
156
158
  lead?: true;
159
+ landed?: string;
157
160
  } | null;
158
161
  result: {
159
162
  digest: string;
@@ -272,6 +275,7 @@ export declare function assignmentUpdates(store: Store, now: Date, access: Assig
272
275
  at: string;
273
276
  digest: string;
274
277
  lead?: true;
278
+ landed?: string;
275
279
  } | null;
276
280
  result: {
277
281
  digest: string;
@@ -4,7 +4,7 @@
4
4
  import { createHash } from "node:crypto";
5
5
  import { currentActor, withActor } from "./actor.js";
6
6
  import { replacedWords } from "./task-status.js";
7
- import { COMPLETION_ACTION, familyChangedFiles } from "./result-completion.js";
7
+ import { COMPLETER_ACTOR, COMPLETION_ACTION, familyChangedFiles, landedReasonOf } from "./result-completion.js";
8
8
  import { homedir } from "node:os";
9
9
  import { readSchemaVersion, stopFactOf } from "./store.js";
10
10
  import { approvalOf } from "./scope.js";
@@ -263,20 +263,22 @@ export function assignmentOf(store, taskId, now, access, root, memo) {
263
263
  : completionKind === "accepted-exception" ? "An operator accepted this result with its recorded limitations. The lead can inspect that decision; the recorded checks are unchanged."
264
264
  : receipt.checks.detail;
265
265
  primaryAction = { code: "open-result", label: completionKind === "research-report" ? "Read report" : "Open result", target: { taskId: current.id, runId: result.id, decisionId: null }, access: "read", retry: "read-again" };
266
- const checked = store.handle.prepare("SELECT actor,at FROM action_ledger WHERE task_id = ? AND run_id = ? AND action = ? AND outcome = ? AND source = 'work' ORDER BY id DESC")
267
- .all(family.root.id, result.id, CHECK_ACTION, receipt.digest).find(row => /^(operator|coordinator|lead):.+/.test(String(row["actor"])));
266
+ const checked = store.handle.prepare("SELECT actor,at,detail FROM action_ledger WHERE task_id = ? AND run_id = ? AND action = ? AND outcome = ? AND source = 'work' ORDER BY id DESC")
267
+ .all(family.root.id, result.id, CHECK_ACTION, receipt.digest).find(row => COMPLETER_ACTOR.test(String(row["actor"])));
268
268
  // This ledger fact was authorized when written. Credential rotation,
269
269
  // membership changes and ownership transfer cannot revoke past completion.
270
270
  // The exact result digest still fences changes to work, scope and history.
271
271
  if (checked !== undefined) {
272
272
  const rootRef = store.lookupRef(family.root.id);
273
273
  const byLead = rootRef !== null && olderStoreSafe(() => store.taskActs(rootRef.id), []).filter(one => one.act === "completed").at(-1)?.lead === true;
274
- completion = { actor: String(checked["actor"]), at: String(checked["at"]), digest: receipt.digest, ...(byLead ? { lead: true } : {}) };
274
+ const landed = landedReasonOf(String(checked["actor"]), checked["detail"] == null ? null : String(checked["detail"]));
275
+ completion = { actor: String(checked["actor"]), at: String(checked["at"]), digest: receipt.digest, ...(byLead && landed === null ? { lead: true } : {}), ...(landed === null ? {} : { landed }) };
275
276
  state = "complete";
276
277
  primaryAction = { ...primaryAction, label: completionKind === "research-report" ? "Read report" : "Open result" };
277
278
  const label = byLead ? "the lead" : completion.actor.startsWith("operator:") ? completion.actor.slice(9) : owner && completion.actor === actorOf(owner) ? owner.label : "the previous lead";
278
- detail = completionKind === "research-report" ? `Research report checked by ${label}. No deployment is implied.`
279
- : `Handled by ${label}. ${receipt.checks.detail} Publication and deployment are separate.`;
279
+ detail = landed !== null ? `Marked complete automatically: ${landed}. ${receipt.checks.detail}`
280
+ : completionKind === "research-report" ? `Research report checked by ${label}. No deployment is implied.`
281
+ : `Handled by ${label}. ${receipt.checks.detail} Publication and deployment are separate.`;
280
282
  }
281
283
  }
282
284
  else if (work.status.rank === 0 || (!live && (current.state === "done" || current.state === "failed" || work.status.views.includes("needs-you")))) {