toolroll 0.9.59 → 0.9.61

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 (152) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +8 -6
  3. package/dist/agent-board.d.ts +208 -0
  4. package/dist/agent-board.js +454 -0
  5. package/dist/agent-fence.d.ts +19 -0
  6. package/dist/agent-fence.js +100 -8
  7. package/dist/agent-pulse.d.ts +43 -0
  8. package/dist/agent-pulse.js +214 -0
  9. package/dist/agent-turns.d.ts +52 -0
  10. package/dist/agent-turns.js +137 -0
  11. package/dist/agents-board.d.ts +101 -0
  12. package/dist/agents-board.js +225 -0
  13. package/dist/assignment-adapters.d.ts +2 -1
  14. package/dist/assignment-adapters.js +1 -1
  15. package/dist/assignment-delivery.d.ts +16 -0
  16. package/dist/assignment-delivery.js +54 -28
  17. package/dist/assignment-status.d.ts +14 -5
  18. package/dist/assignment-status.js +70 -39
  19. package/dist/assignment.d.ts +10 -2
  20. package/dist/assignment.js +21 -14
  21. package/dist/bin.js +11 -2
  22. package/dist/brief-fence.d.ts +9 -0
  23. package/dist/brief-fence.js +22 -0
  24. package/dist/browser/workspace.css +2 -2
  25. package/dist/browser/workspace.js +29 -25
  26. package/dist/browser-crew.js +2 -0
  27. package/dist/browser-workspace.d.ts +79 -1
  28. package/dist/browser-workspace.js +1 -0
  29. package/dist/builder.d.ts +8 -0
  30. package/dist/builder.js +178 -29
  31. package/dist/chat-channel.js +10 -1
  32. package/dist/chat-core.d.ts +6 -0
  33. package/dist/chat-core.js +8 -0
  34. package/dist/chat-delivery.js +22 -0
  35. package/dist/chat-messages.js +8 -1
  36. package/dist/chat-quiet.js +1 -0
  37. package/dist/chat-voice.d.ts +2 -0
  38. package/dist/chat-voice.js +2 -0
  39. package/dist/checkout-cleanup.d.ts +3 -0
  40. package/dist/checkout-cleanup.js +11 -2
  41. package/dist/cli-fast.d.ts +8 -0
  42. package/dist/cli-fast.js +18 -0
  43. package/dist/cli-help.d.ts +5 -0
  44. package/dist/cli-help.js +91 -0
  45. package/dist/cli.d.ts +6 -5
  46. package/dist/cli.js +17 -96
  47. package/dist/contracts/console-api.d.ts +3 -1
  48. package/dist/contracts/console-api.js +5 -1
  49. package/dist/contracts/lead-tools.d.ts +16 -2
  50. package/dist/contracts/lead-tools.js +5 -1
  51. package/dist/design/tokens.css +234 -0
  52. package/dist/desktop-update.d.ts +1 -1
  53. package/dist/discord.js +2 -1
  54. package/dist/evidence-pack.js +1 -1
  55. package/dist/evidence.d.ts +28 -0
  56. package/dist/evidence.js +87 -17
  57. package/dist/exec.d.ts +32 -0
  58. package/dist/exec.js +229 -10
  59. package/dist/flow-actions.js +2 -1
  60. package/dist/guides.js +14 -11
  61. package/dist/invoke.js +110 -18
  62. package/dist/landed-cleanup.d.ts +157 -0
  63. package/dist/landed-cleanup.js +707 -0
  64. package/dist/lead-cli.js +22 -19
  65. package/dist/lead-contract.js +1 -1
  66. package/dist/lead-doors.js +49 -2
  67. package/dist/lead-follow.d.ts +2 -0
  68. package/dist/lead-follow.js +138 -88
  69. package/dist/lead-tools.js +48 -8
  70. package/dist/live-input.d.ts +68 -0
  71. package/dist/live-input.js +254 -0
  72. package/dist/live.d.ts +1 -1
  73. package/dist/live.js +4 -2
  74. package/dist/mailbox.js +3 -2
  75. package/dist/maintenance.d.ts +2 -0
  76. package/dist/maintenance.js +5 -2
  77. package/dist/mcp-core.js +1 -1
  78. package/dist/operate-remote-arguments.js +5 -1
  79. package/dist/operate-remote.js +1 -1
  80. package/dist/operate.d.ts +1 -1
  81. package/dist/operate.js +201 -19
  82. package/dist/orphan-sweep.d.ts +2 -0
  83. package/dist/orphan-sweep.js +5 -3
  84. package/dist/project-delete-ui.d.ts +11 -0
  85. package/dist/project-delete-ui.js +29 -2
  86. package/dist/project-tools.d.ts +7 -11
  87. package/dist/project-tools.js +13 -37
  88. package/dist/provider.d.ts +73 -8
  89. package/dist/provider.js +87 -17
  90. package/dist/result-completion.d.ts +7 -0
  91. package/dist/result-completion.js +9 -0
  92. package/dist/runner.d.ts +4 -2
  93. package/dist/runner.js +3 -3
  94. package/dist/serve.js +22 -3
  95. package/dist/server/chrome.d.ts +6 -3
  96. package/dist/server/chrome.js +8 -4
  97. package/dist/server/console.css +9 -64
  98. package/dist/server/live.d.ts +3 -0
  99. package/dist/server/live.js +35 -2
  100. package/dist/server/pages.js +59 -35
  101. package/dist/server/render-chat.js +14 -2
  102. package/dist/server/render-tasks.d.ts +14 -2
  103. package/dist/server/render-tasks.js +38 -20
  104. package/dist/server/route-table.js +4 -1
  105. package/dist/server/settings.js +2 -1
  106. package/dist/server/task-views.js +10 -0
  107. package/dist/server/tasks.js +63 -0
  108. package/dist/shared-deps.d.ts +9 -4
  109. package/dist/shared-deps.js +89 -13
  110. package/dist/slack-socket.d.ts +12 -0
  111. package/dist/slack-socket.js +29 -0
  112. package/dist/slack.d.ts +0 -7
  113. package/dist/slack.js +2 -24
  114. package/dist/staged-runtimes.d.ts +16 -4
  115. package/dist/staged-runtimes.js +160 -38
  116. package/dist/steer-live.d.ts +19 -0
  117. package/dist/steer-live.js +93 -0
  118. package/dist/steer-mailbox.d.ts +39 -0
  119. package/dist/steer-mailbox.js +207 -0
  120. package/dist/steer-status.d.ts +25 -0
  121. package/dist/steer-status.js +44 -0
  122. package/dist/storage-sweep-child.d.ts +1 -0
  123. package/dist/storage-sweep-child.js +76 -0
  124. package/dist/storage-sweep-process.d.ts +41 -0
  125. package/dist/storage-sweep-process.js +187 -0
  126. package/dist/storage-sweep.d.ts +30 -6
  127. package/dist/storage-sweep.js +214 -28
  128. package/dist/storage-ui.d.ts +2 -0
  129. package/dist/storage-ui.js +16 -4
  130. package/dist/storage.d.ts +6 -0
  131. package/dist/storage.js +35 -0
  132. package/dist/store.d.ts +202 -2
  133. package/dist/store.js +440 -13
  134. package/dist/surface.js +5 -3
  135. package/dist/sweep-gate.d.ts +10 -0
  136. package/dist/sweep-gate.js +18 -0
  137. package/dist/sweep-quarantine.d.ts +28 -0
  138. package/dist/sweep-quarantine.js +153 -0
  139. package/dist/task-status.d.ts +2 -0
  140. package/dist/task-status.js +5 -1
  141. package/dist/team-updates.js +3 -3
  142. package/dist/test-temp.d.ts +6 -3
  143. package/dist/test-temp.js +15 -9
  144. package/dist/toolroll-update.js +15 -2
  145. package/dist/work-index.d.ts +1 -0
  146. package/dist/work-index.js +32 -12
  147. package/dist/work-summary.d.ts +2 -2
  148. package/dist/workspace-revision.js +3 -1
  149. package/dist/worktree.d.ts +3 -1
  150. package/dist/worktree.js +93 -10
  151. package/package.json +21 -21
  152. 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
+ }
@@ -18,7 +18,7 @@ export declare const ASSIGNMENT_TOOLS: readonly [{
18
18
  }, {
19
19
  readonly name: "list_assignment_updates";
20
20
  readonly operation: "updates";
21
- readonly description: "Read durable assignment updates in your projects after a cursor. Save nextCursor after processing; repeated reads do not acknowledge or deliver anything.";
21
+ readonly description: "Read the history of assignment handoffs sent to people in your projects, after a cursor. History only: it delivers and acknowledges nothing. To receive your updates, use get_assignment_inbox.";
22
22
  }, {
23
23
  readonly name: "claim_assignment";
24
24
  readonly operation: "claim";
@@ -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;
@@ -16,7 +16,7 @@ const failure = (reason, message) => ({ ok: false, reason, message });
16
16
  * (src/contracts/gateway-tools.ts), which `toolroll assignment` reads its flags with too. */
17
17
  export const ASSIGNMENT_TOOLS = [
18
18
  { name: "get_assignment", operation: "show", description: "Read one assignment's root, current execution, owner, exact result and handoff receipt. Approval, proof and deployment remain separate facts." },
19
- { name: "list_assignment_updates", operation: "updates", description: "Read durable assignment updates in your projects after a cursor. Save nextCursor after processing; repeated reads do not acknowledge or deliver anything." },
19
+ { name: "list_assignment_updates", operation: "updates", description: "Read the history of assignment handoffs sent to people in your projects, after a cursor. History only: it delivers and acknowledges nothing. To receive your updates, use get_assignment_inbox." },
20
20
  { name: "claim_assignment", operation: "claim", description: "Record yourself as this assignment's lead. Repeating your claim is safe; another active owner cannot be replaced. This grants no approval or execution authority." },
21
21
  { name: "acknowledge_assignment", operation: "check", description: "Acknowledge the exact ready-to-check receipt as its lead, using the current receipt digest. This does not accept failed proof, answer decisions, approve work, publish or deploy." },
22
22
  { name: "get_assignment_brief", operation: "brief", description: "Catch up from the local database: current work, decisions, saved results and project knowledge. Bounded read only; no model call or task mutation." },
@@ -47,10 +47,26 @@ export declare function assignmentInbox(store: Store, owner: AssignmentOwner, qu
47
47
  consumer: string;
48
48
  limit?: number;
49
49
  }, now: Date): AssignmentInboxResult;
50
+ /** The built-in lead's inbox: the same page, replay and acknowledgment as a coordinator's, over its follow grant. */
51
+ export declare function leadFollowInbox(store: Store, grant: {
52
+ id: number;
53
+ repos: readonly string[];
54
+ }, query: {
55
+ consumer: string;
56
+ limit?: number;
57
+ }, now: Date): AssignmentInboxResult;
50
58
  /** ACK advances only the exact issued page, atomically. Replaying any prior
51
59
  * ACK is harmless, including while a newer page awaits acknowledgment. */
52
60
  export declare function acknowledgeAssignmentDelivery(store: Store, owner: AssignmentOwner, query: {
53
61
  consumer: string;
54
62
  batchId: string;
55
63
  }, now: Date): AssignmentDeliveryAckResult;
64
+ /** The built-in lead acknowledges its own page the same way, once it has handled it. */
65
+ export declare function acknowledgeLeadFollowDelivery(store: Store, grant: {
66
+ id: number;
67
+ repos: readonly string[];
68
+ }, query: {
69
+ consumer: string;
70
+ batchId: string;
71
+ }, now: Date): AssignmentDeliveryAckResult;
56
72
  export {};
@@ -1,16 +1,28 @@
1
1
  /** Durable, explicitly acknowledged delivery of assignment status events.
2
2
  * Callers authenticate the raw coordinator credential inside the same Store
3
- * transaction. Delivery receipts never complete tasks or grant authority. */
3
+ * transaction. Delivery receipts never complete tasks or grant authority.
4
+ * This is the one way results reach a lead: an external coordinator reads its
5
+ * owned assignments' events, and the built-in lead reads its follow grant's
6
+ * (lead-follow.ts) through the same saved pages, replay and acknowledgment. */
4
7
  import { createHash, randomBytes } from "node:crypto";
5
- import { ASSIGNMENT_STATUS_ACTION, parseAssignmentStatusEvent } from "./assignment-status.js";
8
+ import { ASSIGNMENT_STATUS_ACTION, followActor, parseAssignmentStatusEvent } from "./assignment-status.js";
6
9
  const MAX_PAGE_BYTES = 64 * 1024;
7
10
  const fail = (reason, message) => ({ ok: false, reason, message });
8
- const actor = (owner) => `coordinator:${owner.id}`;
9
- const keyFor = (owner, consumer) => createHash("sha256").update(JSON.stringify([owner.id, consumer])).digest("hex");
10
11
  const validConsumer = (value) => typeof value === "string" && /^[a-z0-9][a-z0-9-]{0,63}$/.test(value);
11
12
  const ownerSql = `COALESCE((SELECT 'lead:'||o.lead FROM team_task_owner o WHERE o.task_ref=t.id),(SELECT a.actor FROM action_ledger a WHERE a.task_id = t.external_id
12
13
  AND a.action = 'assignment claimed' AND a.source = 'work' ORDER BY a.id DESC LIMIT 1),
13
14
  'coordinator:' || t.coordinator_cid)`;
15
+ function coordinatorReader(store, owner) {
16
+ const actor = `coordinator:${owner.id}`;
17
+ return { actor, key: consumer => createHash("sha256").update(JSON.stringify([owner.id, consumer])).digest("hex"),
18
+ repos: () => admittedRepos(store, owner), admits: { sql: `${ownerSql} = ?`, params: [actor] },
19
+ receives: event => event.assignment.owner?.id === owner.id, gone: "The coordinator is no longer active." };
20
+ }
21
+ /** The built-in lead's follow grant (lead-follow.ts), which its caller has already proved, with the projects it covers. */
22
+ function followReader(grant) {
23
+ return { actor: followActor(grant.id), key: consumer => createHash("sha256").update(JSON.stringify(["follow", grant.id, consumer])).digest("hex"),
24
+ repos: () => [...grant.repos], admits: { sql: "1 = 1", params: [] }, receives: () => true, gone: "Automatic crew updates are off." };
25
+ }
14
26
  function admittedRepos(store, owner) {
15
27
  if (owner.kind !== "coordinator" || typeof owner.id !== "string" || owner.id.length === 0)
16
28
  return null;
@@ -25,28 +37,28 @@ function admittedRepos(store, owner) {
25
37
  return null;
26
38
  }
27
39
  }
28
- function exposedBatch(store, owner, repos, batch) {
40
+ function exposedBatch(store, reader, repos, batch) {
29
41
  return { ...batch, events: batch.events.map(event => {
30
42
  const admitted = repos.includes(event.project) && store.handle.prepare(`SELECT 1 FROM task_ref t
31
- WHERE t.backend = 'built-in' AND t.external_id = ? AND t.repo = ? AND t.revision_of IS NULL AND ${ownerSql} = ?`)
32
- .get(event.rootId, event.project, actor(owner)) !== undefined;
43
+ WHERE t.backend = 'built-in' AND t.external_id = ? AND t.repo = ? AND t.revision_of IS NULL AND ${reader.admits.sql}`)
44
+ .get(event.rootId, event.project, ...reader.admits.params) !== undefined;
33
45
  return admitted ? event : { id: event.id, unavailable: true };
34
46
  }) };
35
47
  }
36
- function eventFromRow(row, owner) {
48
+ function eventFromRow(row, reader) {
37
49
  const parsed = parseAssignmentStatusEvent(String(row["outcome"]));
38
- if (parsed === null || parsed.assignment.rootId !== row["task_id"] || parsed.assignment.owner?.id !== owner.id)
50
+ if (parsed === null || parsed.assignment.rootId !== row["task_id"] || !reader.receives(parsed))
39
51
  return null;
40
52
  return { id: Number(row["id"]), createdAt: String(row["at"]), rootId: String(row["task_id"]),
41
53
  project: String(row["repo"]), kind: "assignment-status", digest: parsed.digest, assignment: parsed.assignment };
42
54
  }
43
- function pendingBatch(store, owner, key) {
55
+ function pendingBatch(store, reader, key) {
44
56
  const id = store.serviceCursor(`assignment-inbox:${key}:pending`);
45
57
  if (id === 0)
46
58
  return null;
47
59
  const invalid = () => fail("unavailable", "The saved delivery page is invalid; its cursor has not advanced.");
48
60
  const row = store.handle.prepare("SELECT outcome FROM action_ledger WHERE id = ? AND actor = ? AND action = ? AND source = 'work'")
49
- .get(id, actor(owner), `assignment delivery batch:${key}`);
61
+ .get(id, reader.actor, `assignment delivery batch:${key}`);
50
62
  if (!row || Buffer.byteLength(String(row["outcome"])) > MAX_PAGE_BYTES)
51
63
  return invalid();
52
64
  let parsed;
@@ -69,8 +81,8 @@ function pendingBatch(store, owner, key) {
69
81
  if (!event || !Number.isSafeInteger(event.id) || event.id <= previous)
70
82
  return invalid();
71
83
  const source = store.handle.prepare("SELECT id,at,task_id,repo,outcome FROM action_ledger WHERE id = ? AND actor = ? AND action = ? AND source = 'work'")
72
- .get(event.id, actor(owner), ASSIGNMENT_STATUS_ACTION);
73
- const original = source === undefined ? null : eventFromRow(source, owner);
84
+ .get(event.id, reader.actor, ASSIGNMENT_STATUS_ACTION);
85
+ const original = source === undefined ? null : eventFromRow(source, reader);
74
86
  if (original === null || JSON.stringify(original) !== JSON.stringify(event))
75
87
  return invalid();
76
88
  previous = event.id;
@@ -83,31 +95,38 @@ function pendingBatch(store, owner, key) {
83
95
  * tombstone; it does not prevent acknowledgment of that already issued page.
84
96
  * Consumer names identify independent inboxes. */
85
97
  export function assignmentInbox(store, owner, query, now) {
98
+ return inbox(store, coordinatorReader(store, owner), query, now);
99
+ }
100
+ /** The built-in lead's inbox: the same page, replay and acknowledgment as a coordinator's, over its follow grant. */
101
+ export function leadFollowInbox(store, grant, query, now) {
102
+ return inbox(store, followReader(grant), query, now);
103
+ }
104
+ function inbox(store, reader, query, now) {
86
105
  if (!validConsumer(query.consumer) || (query.limit !== undefined && (!Number.isInteger(query.limit) || query.limit < 1 || query.limit > 100)))
87
106
  return fail("invalid-input", "Use a consumer name of 1–64 characters and a page size of 1–100.");
88
107
  return store.transact(() => store.savepoint(() => {
89
- const repos = admittedRepos(store, owner);
108
+ const repos = reader.repos();
90
109
  if (repos === null)
91
- return fail("unavailable", "The coordinator is no longer active.");
92
- const key = keyFor(owner, query.consumer), cursorKey = `assignment-inbox:${key}:acked`;
93
- const after = store.serviceCursor(cursorKey), pending = pendingBatch(store, owner, key);
110
+ return fail("unavailable", reader.gone);
111
+ const key = reader.key(query.consumer), cursorKey = `assignment-inbox:${key}:acked`;
112
+ const after = store.serviceCursor(cursorKey), pending = pendingBatch(store, reader, key);
94
113
  if (pending !== null) {
95
114
  if ("ok" in pending)
96
115
  return pending;
97
116
  if (pending.consumer !== query.consumer || pending.after !== after)
98
117
  return fail("unavailable", "The saved delivery page does not match its cursor.");
99
- return { ok: true, batch: exposedBatch(store, owner, repos, pending), replayed: true };
118
+ return { ok: true, batch: exposedBatch(store, reader, repos, pending), replayed: true };
100
119
  }
101
120
  const limit = query.limit ?? 10;
102
121
  const rows = store.handle.prepare(`SELECT n.id,n.at,n.task_id,n.repo,n.outcome
103
122
  FROM action_ledger n JOIN task_ref t ON t.backend = 'built-in'
104
123
  AND t.external_id = n.task_id AND t.repo = n.repo AND t.revision_of IS NULL
105
124
  WHERE n.id > ? AND n.action = ? AND n.source = 'work' AND n.actor = ?
106
- AND n.repo IN (SELECT value FROM json_each(?)) AND ${ownerSql} = ?
107
- ORDER BY n.id LIMIT ?`).all(after, ASSIGNMENT_STATUS_ACTION, actor(owner), JSON.stringify(repos), actor(owner), limit + 1);
125
+ AND n.repo IN (SELECT value FROM json_each(?)) AND ${reader.admits.sql}
126
+ ORDER BY n.id LIMIT ?`).all(after, ASSIGNMENT_STATUS_ACTION, reader.actor, JSON.stringify(repos), ...reader.admits.params, limit + 1);
108
127
  const batch = { version: 1, id: null, consumer: query.consumer, after, nextCursor: after, hasMore: rows.length > limit, events: [] };
109
128
  for (const row of rows.slice(0, limit)) {
110
- const event = eventFromRow(row, owner);
129
+ const event = eventFromRow(row, reader);
111
130
  if (event === null)
112
131
  return fail("unavailable", "The next saved assignment event is invalid; no event was skipped.");
113
132
  batch.events.push(event);
@@ -123,7 +142,7 @@ export function assignmentInbox(store, owner, query, now) {
123
142
  if (batch.events.length === 0)
124
143
  return { ok: true, batch, replayed: false };
125
144
  batch.id = randomBytes(16).toString("hex");
126
- const id = store.recordAction({ at: now.toISOString(), actor: actor(owner), repo: null, taskId: null, runId: null,
145
+ const id = store.recordAction({ at: now.toISOString(), actor: reader.actor, repo: null, taskId: null, runId: null,
127
146
  action: `assignment delivery batch:${key}`, outcome: JSON.stringify(batch), source: "work" });
128
147
  store.setServiceCursor(`assignment-inbox:${key}:pending`, id, now);
129
148
  return { ok: true, batch, replayed: false };
@@ -132,26 +151,33 @@ export function assignmentInbox(store, owner, query, now) {
132
151
  /** ACK advances only the exact issued page, atomically. Replaying any prior
133
152
  * ACK is harmless, including while a newer page awaits acknowledgment. */
134
153
  export function acknowledgeAssignmentDelivery(store, owner, query, now) {
154
+ return acknowledge(store, coordinatorReader(store, owner), query, now);
155
+ }
156
+ /** The built-in lead acknowledges its own page the same way, once it has handled it. */
157
+ export function acknowledgeLeadFollowDelivery(store, grant, query, now) {
158
+ return acknowledge(store, followReader(grant), query, now);
159
+ }
160
+ function acknowledge(store, reader, query, now) {
135
161
  if (!validConsumer(query.consumer) || typeof query.batchId !== "string" || !/^[a-f0-9]{32}$/.test(query.batchId))
136
162
  return fail("invalid-input", "Use the exact consumer and batch ID returned by the inbox.");
137
163
  return store.transact(() => store.savepoint(() => {
138
- const repos = admittedRepos(store, owner);
164
+ const repos = reader.repos();
139
165
  if (repos === null)
140
- return fail("unavailable", "The coordinator is no longer active.");
141
- const key = keyFor(owner, query.consumer), cursorKey = `assignment-inbox:${key}:acked`;
166
+ return fail("unavailable", reader.gone);
167
+ const key = reader.key(query.consumer), cursorKey = `assignment-inbox:${key}:acked`;
142
168
  const cursor = store.serviceCursor(cursorKey), action = `assignment delivery acknowledged:${key}`;
143
169
  const acknowledged = store.handle.prepare(`SELECT 1 FROM action_ledger WHERE actor = ? AND action = ? AND source = 'work'
144
- AND json_extract(outcome, '$.batchId') = ? LIMIT 1`).get(actor(owner), action, query.batchId);
170
+ AND json_extract(outcome, '$.batchId') = ? LIMIT 1`).get(reader.actor, action, query.batchId);
145
171
  if (acknowledged)
146
172
  return { ok: true, alreadyAcknowledged: true, cursor };
147
- const pending = pendingBatch(store, owner, key);
173
+ const pending = pendingBatch(store, reader, key);
148
174
  if (pending !== null && "ok" in pending)
149
175
  return pending;
150
176
  if (pending === null || pending.id !== query.batchId || pending.consumer !== query.consumer || pending.after !== cursor)
151
177
  return fail("stale-batch", "This batch was not issued to this inbox; its cursor has not advanced.");
152
178
  // Access loss redacts replayed events; the exact issued page remains
153
179
  // acknowledgeable so its inaccessible entries cannot wedge this inbox.
154
- store.recordAction({ at: now.toISOString(), actor: actor(owner), repo: null, taskId: null, runId: null,
180
+ store.recordAction({ at: now.toISOString(), actor: reader.actor, repo: null, taskId: null, runId: null,
155
181
  action, outcome: JSON.stringify({ batchId: pending.id, after: pending.after, cursor: pending.nextCursor }), source: "work" });
156
182
  store.setServiceCursor(cursorKey, pending.nextCursor, now);
157
183
  store.setServiceCursor(`assignment-inbox:${key}:pending`, 0, now);
@@ -1,7 +1,13 @@
1
1
  import type { Store } from "./store.js";
2
2
  import { type AssignmentSnapshot } from "./assignment.js";
3
3
  export declare const ASSIGNMENT_STATUS_ACTION = "assignment status observed";
4
+ /** A reader's first sight of a task that is not news: the state later events are compared to, never delivered. */
5
+ export declare const ASSIGNMENT_STATUS_BASELINE = "assignment status baseline";
4
6
  export declare const ASSIGNMENT_STATUS_SOURCE: "work";
7
+ /** The states that wake a lead: a result to check, a question, completion, or cancellation. */
8
+ export declare const LEAD_WAKE_STATES: readonly AssignmentSnapshot["state"][];
9
+ /** The built-in lead's events, one stream per follow grant. */
10
+ export declare const followActor: (grant: number) => string;
5
11
  /** No saved artifact contents, credential bytes, or polling timestamps. Exact
6
12
  * IDs and digests identify the full read; display text is deliberately bounded. */
7
13
  export declare function assignmentStatusBrief(assignment: AssignmentSnapshot): {
@@ -17,7 +23,7 @@ export declare function assignmentStatusBrief(assignment: AssignmentSnapshot): {
17
23
  } | null;
18
24
  runId: number | null;
19
25
  primaryAction: {
20
- 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;
21
27
  label: string;
22
28
  target: import("./work-summary.js").WorkTarget;
23
29
  } | null;
@@ -42,13 +48,12 @@ export declare function assignmentStatusBrief(assignment: AssignmentSnapshot): {
42
48
  remoteState: string | null;
43
49
  } | null;
44
50
  };
51
+ /** An owner's events always name it; the built-in lead's follow events name whatever owner the task has, or none. */
45
52
  export type AssignmentStatusEvent = {
46
53
  version: 1;
47
54
  digest: string;
48
55
  stateDigest: string;
49
- assignment: ReturnType<typeof assignmentStatusBrief> & {
50
- owner: NonNullable<ReturnType<typeof assignmentStatusBrief>["owner"]>;
51
- };
56
+ assignment: ReturnType<typeof assignmentStatusBrief>;
52
57
  };
53
58
  /** An unreadable ledger event is an explicit consumer error, not a cursor that
54
59
  * may silently skip the row. This validates the bounded data, not authority. */
@@ -57,6 +62,10 @@ export declare function parseAssignmentStatusEvent(value: string): AssignmentSta
57
62
  * for worker reconciliation. The latest event, not a permanent digest set,
58
63
  * deduplicates retries so Working → Needs decision → Working is retained. */
59
64
  export declare function noteAssignmentStatus(store: Store, snapshot: AssignmentSnapshot, now: Date): number | null;
65
+ /** The built-in lead's crew update (lead-follow.ts): the same event, under its follow grant. The
66
+ * caller has already proved the grant and its projects; the root must still be a placed task. */
67
+ export declare function noteFollowStatus(store: Store, grant: number, snapshot: AssignmentSnapshot, now: Date, action?: typeof ASSIGNMENT_STATUS_ACTION | typeof ASSIGNMENT_STATUS_BASELINE): number | null;
60
68
  /** At most fifty roots per pass; the cursor survives process restarts. A full
61
- * pass cycles back, so changes below the cursor are eventually observed too. */
69
+ * pass cycles back, so changes below the cursor are eventually observed too.
70
+ * A pass is skipped when nothing moved since the cycle began (sweep-gate.ts). */
62
71
  export declare function syncAssignmentStatuses(store: Store, now: Date, repos: readonly string[], root?: string): void;