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
@@ -1,11 +1,22 @@
1
1
  /** Private lead catch-up, separate from human notification delivery. These are
2
2
  * observed assignment transitions, not a promise to capture every intermediate
3
- * state between scans. Recording or reading them never dispatches agent work. */
3
+ * state between scans. Recording or reading them never dispatches agent work.
4
+ * One stream serves every lead: a coordinator's or team lead's events are
5
+ * recorded under its owner, and the built-in lead's crew updates under its
6
+ * follow grant (`follow:<grant>`, lead-follow.ts); each reads its own through
7
+ * the delivery inbox (assignment-delivery.ts). */
4
8
  import { createHash } from "node:crypto";
5
9
  import { assignmentOf } from "./assignment.js";
6
10
  import { publicChatText } from "./chat-display.js";
11
+ import { sweepIfMoved } from "./sweep-gate.js";
7
12
  export const ASSIGNMENT_STATUS_ACTION = "assignment status observed";
13
+ /** A reader's first sight of a task that is not news: the state later events are compared to, never delivered. */
14
+ export const ASSIGNMENT_STATUS_BASELINE = "assignment status baseline";
8
15
  export const ASSIGNMENT_STATUS_SOURCE = "work";
16
+ /** The states that wake a lead: a result to check, a question, completion, or cancellation. */
17
+ export const LEAD_WAKE_STATES = ["ready-to-check", "needs-decision", "complete", "cancelled"];
18
+ /** The built-in lead's events, one stream per follow grant. */
19
+ export const followActor = (grant) => `follow:${grant}`;
9
20
  const hash = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
10
21
  const text = publicChatText;
11
22
  /** No saved artifact contents, credential bytes, or polling timestamps. Exact
@@ -51,7 +62,7 @@ export function parseAssignmentStatusEvent(value) {
51
62
  !Array.isArray(a["attention"]) || a["attention"].length > 8 || !a["attention"].every(one => string(one, 500)))
52
63
  return null;
53
64
  const owner = a["owner"], action = a["primaryAction"], result = a["result"], completion = a["completion"], publication = a["publication"];
54
- if (!object(owner) || !keys(owner, ["kind", "id", "label"]) || !["coordinator", "lead"].includes(String(owner["kind"])) || !string(owner["id"], 128) || !string(owner["label"], 100))
65
+ if (owner !== null && (!object(owner) || !keys(owner, ["kind", "id", "label"]) || !["coordinator", "lead"].includes(String(owner["kind"])) || !string(owner["id"], 128) || !string(owner["label"], 100)))
55
66
  return null;
56
67
  if (action !== null && (!object(action) || !keys(action, ["code", "label", "target"]) || !string(action["code"], 80) || !string(action["label"], 160) ||
57
68
  !object(action["target"]) || !keys(action["target"], ["taskId", "runId", "decisionId"]) || !string(action["target"]["taskId"], 64) || !id(action["target"]["runId"]) || !id(action["target"]["decisionId"])))
@@ -103,50 +114,70 @@ export function noteAssignmentStatus(store, snapshot, now) {
103
114
  if (!Array.isArray(repos) || !repos.includes(snapshot.repo))
104
115
  return null;
105
116
  }
106
- const assignment = assignmentStatusBrief(snapshot);
107
- if (assignment.owner === null)
117
+ if (assignmentStatusBrief(snapshot).owner === null)
108
118
  return null;
109
- const stateDigest = hash(assignment);
110
- const previous = store.handle.prepare("SELECT id,outcome FROM action_ledger WHERE task_id=? AND action=? AND source=? ORDER BY id DESC LIMIT 1")
111
- .get(snapshot.rootId, ASSIGNMENT_STATUS_ACTION, ASSIGNMENT_STATUS_SOURCE);
112
- if (previous !== undefined) {
113
- try {
114
- if (JSON.parse(String(previous["outcome"]))?.stateDigest === stateDigest)
115
- return null;
116
- }
117
- catch { /* Preserve an unreadable older event; append the current observation. */ }
118
- }
119
- const digest = hash({ rootRef: ref.id, owner: owner.id, previous: previous === undefined ? null : Number(previous["id"]), stateDigest });
120
- const event = { version: 1, digest, stateDigest, assignment: { ...assignment, owner: assignment.owner } };
121
- return store.recordAction({ at: now.toISOString(), actor: `${owner.kind}:${owner.id}`, repo: snapshot.repo,
122
- taskId: snapshot.rootId, runId: assignment.runId, action: ASSIGNMENT_STATUS_ACTION,
123
- outcome: JSON.stringify(event), source: ASSIGNMENT_STATUS_SOURCE });
119
+ return appendStatusEvent(store, `${owner.kind}:${owner.id}`, owner.id, ref.id, snapshot, now);
124
120
  });
125
121
  }
122
+ /** The built-in lead's crew update (lead-follow.ts): the same event, under its follow grant. The
123
+ * caller has already proved the grant and its projects; the root must still be a placed task. */
124
+ export function noteFollowStatus(store, grant, snapshot, now, action = ASSIGNMENT_STATUS_ACTION) {
125
+ if (snapshot.repo === null)
126
+ return null;
127
+ return store.transact(() => {
128
+ const ref = store.lookupRef(snapshot.rootId);
129
+ if (!ref || ref.repo !== snapshot.repo || ref.revisionOf !== null)
130
+ return null;
131
+ return appendStatusEvent(store, followActor(grant), followActor(grant), ref.id, snapshot, now, action);
132
+ });
133
+ }
134
+ /** Each reader's latest event or baseline, not a permanent digest set, deduplicates its retries, so Working → Needs
135
+ * decision → Working is retained. One reader's events never suppress or duplicate another's. */
136
+ function appendStatusEvent(store, actor, ownerId, rootRef, snapshot, now, action = ASSIGNMENT_STATUS_ACTION) {
137
+ const assignment = assignmentStatusBrief(snapshot);
138
+ const stateDigest = hash(assignment);
139
+ const previous = store.handle.prepare("SELECT id,outcome FROM action_ledger WHERE task_id=? AND action IN (?,?) AND source=? AND actor=? ORDER BY id DESC LIMIT 1")
140
+ .get(snapshot.rootId, ASSIGNMENT_STATUS_ACTION, ASSIGNMENT_STATUS_BASELINE, ASSIGNMENT_STATUS_SOURCE, actor);
141
+ if (previous !== undefined) {
142
+ try {
143
+ if (JSON.parse(String(previous["outcome"]))?.stateDigest === stateDigest)
144
+ return null;
145
+ }
146
+ catch { /* Preserve an unreadable older event; append the current observation. */ }
147
+ }
148
+ const digest = hash({ rootRef, owner: ownerId, previous: previous === undefined ? null : Number(previous["id"]), stateDigest });
149
+ const event = { version: 1, digest, stateDigest, assignment };
150
+ return store.recordAction({ at: now.toISOString(), actor, repo: snapshot.repo,
151
+ taskId: snapshot.rootId, runId: assignment.runId, action,
152
+ outcome: JSON.stringify(event), source: ASSIGNMENT_STATUS_SOURCE });
153
+ }
126
154
  /** At most fifty roots per pass; the cursor survives process restarts. A full
127
- * pass cycles back, so changes below the cursor are eventually observed too. */
155
+ * pass cycles back, so changes below the cursor are eventually observed too.
156
+ * A pass is skipped when nothing moved since the cycle began (sweep-gate.ts). */
128
157
  export function syncAssignmentStatuses(store, now, repos, root) {
129
158
  if (repos.length === 0)
130
159
  return;
131
160
  const allowed = [...new Set(repos)].sort();
132
161
  const key = `assignment-status:${hash(allowed)}`;
133
- const rows = store.handle.prepare(`SELECT t.id,t.external_id FROM task_ref t
134
- WHERE t.backend='built-in' AND t.revision_of IS NULL
135
- AND t.repo IN (SELECT value FROM json_each(?)) AND t.id>?
136
- AND (t.coordinator_cid IS NOT NULL OR EXISTS(SELECT 1 FROM team_task_owner o WHERE o.task_ref=t.id) OR EXISTS
137
- (SELECT 1 FROM action_ledger a WHERE a.task_id=t.external_id AND a.action='assignment claimed' AND a.source='work'))
138
- ORDER BY t.id LIMIT 50`).all(JSON.stringify(allowed), store.serviceCursor(key));
139
- // Release the writer between families. Each observation and its durable
140
- // position still commit together; an interrupted sweep resumes after the
141
- // last committed family instead of holding every heartbeat behind a batch.
142
- if (rows.length === 0)
143
- store.setServiceCursor(key, 0, now);
144
- for (const [index, row] of rows.entries()) {
145
- store.transact(() => {
146
- const snapshot = assignmentOf(store, String(row['external_id']), now, { principal: 'coordinator', repos: allowed }, root);
147
- if (snapshot !== null)
148
- noteAssignmentStatus(store, snapshot, now);
149
- store.setServiceCursor(key, index === rows.length - 1 && rows.length < 50 ? 0 : Number(row['id']), now);
150
- });
151
- }
162
+ sweepIfMoved(store, key, now, () => {
163
+ const rows = store.handle.prepare(`SELECT t.id,t.external_id FROM task_ref t
164
+ WHERE t.backend='built-in' AND t.revision_of IS NULL
165
+ AND t.repo IN (SELECT value FROM json_each(?)) AND t.id>?
166
+ AND (t.coordinator_cid IS NOT NULL OR EXISTS(SELECT 1 FROM team_task_owner o WHERE o.task_ref=t.id) OR EXISTS
167
+ (SELECT 1 FROM action_ledger a WHERE a.task_id=t.external_id AND a.action='assignment claimed' AND a.source='work'))
168
+ ORDER BY t.id LIMIT 50`).all(JSON.stringify(allowed), store.serviceCursor(key));
169
+ // Release the writer between families. Each observation and its durable
170
+ // position still commit together; an interrupted sweep resumes after the
171
+ // last committed family instead of holding every heartbeat behind a batch.
172
+ if (rows.length === 0)
173
+ store.setServiceCursor(key, 0, now);
174
+ for (const [index, row] of rows.entries()) {
175
+ store.transact(() => {
176
+ const snapshot = assignmentOf(store, String(row['external_id']), now, { principal: 'coordinator', repos: allowed }, root);
177
+ if (snapshot !== null)
178
+ noteAssignmentStatus(store, snapshot, now);
179
+ store.setServiceCursor(key, index === rows.length - 1 && rows.length < 50 ? 0 : Number(row['id']), now);
180
+ });
181
+ }
182
+ });
152
183
  }
@@ -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;
@@ -233,9 +236,13 @@ export declare function acceptAndCompleteAsOperator(store: Store, taskId: string
233
236
  receiptDigest: string;
234
237
  note: string | null;
235
238
  }, who: VerifiedApprover, now: Date, root?: string, finish?: (digest: string) => Finished): Finished;
236
- /** The ordinary worker reconciles at most 50 owned roots per pass. A lost
237
- * wakeup is recovered after restart; immutable notification keys dedupe it. */
239
+ /** The ordinary worker reconciles at most 50 owned roots per pass, and skips a
240
+ * pass when nothing moved (sweep-gate.ts). A lost wakeup is recovered after
241
+ * restart; immutable notification keys dedupe it. */
238
242
  export declare function syncAssignmentHandoffs(store: Store, now: Date, repos: readonly string[], root?: string): void;
243
+ /** The history of assignment handoffs sent to people (the `assignment-handoff` notifications), read by cursor. It is
244
+ * not a delivery path: nothing is saved, replayed or acknowledged here. Leads receive their updates through the
245
+ * assignment inbox (assignment-delivery.ts), the one road results take to a lead. */
239
246
  export declare function assignmentUpdates(store: Store, now: Date, access: AssignmentAccess, query: {
240
247
  after: number;
241
248
  limit: number;
@@ -268,6 +275,7 @@ export declare function assignmentUpdates(store: Store, now: Date, access: Assig
268
275
  at: string;
269
276
  digest: string;
270
277
  lead?: true;
278
+ landed?: string;
271
279
  } | null;
272
280
  result: {
273
281
  digest: string;
@@ -4,12 +4,13 @@
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";
11
11
  import { openWorkDecisionOf, taskWorkSummaryOf, workDecisionAction } from "./work-summary.js";
12
- import { evidenceRoot, readVerifiedArtifact, readVerifiedReport } from "./evidence.js";
12
+ import { evidenceRoot, readVerifiedArtifact, readVerifiedReport, verifyArtifact } from "./evidence.js";
13
+ import { sweepIfMoved } from "./sweep-gate.js";
13
14
  import { verificationEvidence } from "./verification-evidence.js";
14
15
  import { reproveApprover } from "./principal.js";
15
16
  import { noteAssignmentStatus } from "./assignment-status.js";
@@ -163,7 +164,7 @@ export function assignmentOf(store, taskId, now, access, root, memo) {
163
164
  });
164
165
  const artifacts = result === null ? [] : store.artifactsFor(result.id);
165
166
  const reports = artifacts.filter(a => a.kind === "report");
166
- const unavailable = family.versions.flatMap(version => store.runsFor(version.refId).flatMap(run => store.artifactsFor(run.id).filter(artifact => root === undefined || !readVerifiedArtifact(root, artifact).ok)
167
+ const unavailable = family.versions.flatMap(version => store.runsFor(version.refId).flatMap(run => store.artifactsFor(run.id).filter(artifact => root === undefined || !verifyArtifact(root, artifact).ok)
167
168
  .map(artifact => `Saved ${artifact.kind} #${artifact.id} (run ${run.id}) is unavailable or changed.`)));
168
169
  const ownChecks = result === null ? null : assignmentChecksForRun(store, root, result.id, now);
169
170
  // Checks Off: a passing full release check that contains this commit covers it, read now from the existing receipts.
@@ -262,20 +263,22 @@ export function assignmentOf(store, taskId, now, access, root, memo) {
262
263
  : completionKind === "accepted-exception" ? "An operator accepted this result with its recorded limitations. The lead can inspect that decision; the recorded checks are unchanged."
263
264
  : receipt.checks.detail;
264
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" };
265
- 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")
266
- .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"])));
267
268
  // This ledger fact was authorized when written. Credential rotation,
268
269
  // membership changes and ownership transfer cannot revoke past completion.
269
270
  // The exact result digest still fences changes to work, scope and history.
270
271
  if (checked !== undefined) {
271
272
  const rootRef = store.lookupRef(family.root.id);
272
273
  const byLead = rootRef !== null && olderStoreSafe(() => store.taskActs(rootRef.id), []).filter(one => one.act === "completed").at(-1)?.lead === true;
273
- 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 }) };
274
276
  state = "complete";
275
277
  primaryAction = { ...primaryAction, label: completionKind === "research-report" ? "Read report" : "Open result" };
276
278
  const label = byLead ? "the lead" : completion.actor.startsWith("operator:") ? completion.actor.slice(9) : owner && completion.actor === actorOf(owner) ? owner.label : "the previous lead";
277
- detail = completionKind === "research-report" ? `Research report checked by ${label}. No deployment is implied.`
278
- : `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.`;
279
282
  }
280
283
  }
281
284
  else if (work.status.rank === 0 || (!live && (current.state === "done" || current.state === "failed" || work.status.views.includes("needs-you")))) {
@@ -418,7 +421,7 @@ export function assignmentEvidenceIntact(store, root, receipt) {
418
421
  try {
419
422
  const family = store.taskFamilyOf(receipt.taskId, null, true);
420
423
  if (family === null || family.problem !== null || family.root.id !== receipt.rootId ||
421
- !family.versions.every(version => store.runsFor(version.refId).every(run => store.artifactsFor(run.id).every(artifact => readVerifiedArtifact(root, artifact).ok))))
424
+ !family.versions.every(version => store.runsFor(version.refId).every(run => store.artifactsFor(run.id).every(artifact => verifyArtifact(root, artifact).ok))))
422
425
  return false;
423
426
  if (receipt.completionKind === "research-report") {
424
427
  const report = readVerifiedReport(store, root, store.lookupRef(receipt.taskId).id);
@@ -567,13 +570,14 @@ function noteAssignmentHandoff(store, assignment, now) {
567
570
  kind: "assignment-handoff", source: { taskRef: ref.id }, subject: assignment.state === "cancelled" ? "Assignment cancelled" : assignment.state === "ready-to-check" ? "Ready" : "Assignment needs a decision",
568
571
  body: assignment.detail, link: `/t/${encodeURIComponent(assignment.rootId)}` }, now);
569
572
  }
570
- /** The ordinary worker reconciles at most 50 owned roots per pass. A lost
571
- * wakeup is recovered after restart; immutable notification keys dedupe it. */
573
+ /** The ordinary worker reconciles at most 50 owned roots per pass, and skips a
574
+ * pass when nothing moved (sweep-gate.ts). A lost wakeup is recovered after
575
+ * restart; immutable notification keys dedupe it. */
572
576
  export function syncAssignmentHandoffs(store, now, repos, root) {
573
577
  if (repos.length === 0)
574
578
  return;
575
- store.transact(() => {
576
- const key = `assignment-handoff:${digest([...new Set(repos)].sort())}`;
579
+ const key = `assignment-handoff:${digest([...new Set(repos)].sort())}`;
580
+ sweepIfMoved(store, key, now, () => store.transact(() => {
577
581
  const cursor = store.serviceCursor(key);
578
582
  const rows = store.handle.prepare(`SELECT t.id,t.external_id FROM task_ref t WHERE t.backend = 'built-in' AND t.revision_of IS NULL
579
583
  AND t.repo IN (SELECT value FROM json_each(?)) AND t.id > ?
@@ -587,8 +591,11 @@ export function syncAssignmentHandoffs(store, now, repos, root) {
587
591
  }
588
592
  }
589
593
  store.setServiceCursor(key, rows.length < 50 ? 0 : Number(rows.at(-1)["id"]), now);
590
- });
594
+ }));
591
595
  }
596
+ /** The history of assignment handoffs sent to people (the `assignment-handoff` notifications), read by cursor. It is
597
+ * not a delivery path: nothing is saved, replayed or acknowledged here. Leads receive their updates through the
598
+ * assignment inbox (assignment-delivery.ts), the one road results take to a lead. */
592
599
  export function assignmentUpdates(store, now, access, query, root) {
593
600
  const after = Number.isSafeInteger(query.after) && query.after >= 0 ? query.after : 0;
594
601
  const limit = Math.max(1, Math.min(100, Math.floor(query.limit) || 50));
package/dist/bin.js CHANGED
@@ -17,6 +17,15 @@ if (major < 22 || (major === 22 && minor < 13)) {
17
17
  process.stderr.write(`Toolroll needs Node 22.13 or newer (this is ${versions.node}) — it uses node:sqlite.\n`);
18
18
  process.exit(2);
19
19
  }
20
- const { runCliProcess } = await import("./cli.js");
21
- await runCliProcess(process.argv.slice(2));
20
+ // --version and the top-level usage answer here, before the CLI and its command families load.
21
+ const argv = process.argv.slice(2);
22
+ const { quickAnswer } = await import("./cli-fast.js");
23
+ const quick = quickAnswer(argv);
24
+ if (quick !== null) {
25
+ console.log(quick);
26
+ }
27
+ else {
28
+ const { runCliProcess } = await import("./cli.js");
29
+ await runCliProcess(argv);
30
+ }
22
31
  export {};
@@ -0,0 +1,9 @@
1
+ /**
2
+ * One line, prefixed, with nothing that can end the block or start a new rule.
3
+ *
4
+ * Scope text is written by whoever filed the task. A goal containing a newline
5
+ * and a bullet would otherwise read to the agent as another rule in the list,
6
+ * which is how "add a guard" becomes "add a guard, and ignore the rules below".
7
+ * The same quoting wraps a steering note the agent reads from its mailbox.
8
+ */
9
+ export declare function fence(text: string): string;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * One line, prefixed, with nothing that can end the block or start a new rule.
3
+ *
4
+ * Scope text is written by whoever filed the task. A goal containing a newline
5
+ * and a bullet would otherwise read to the agent as another rule in the list,
6
+ * which is how "add a guard" becomes "add a guard, and ignore the rules below".
7
+ * The same quoting wraps a steering note the agent reads from its mailbox.
8
+ */
9
+ export function fence(text) {
10
+ // Newlines and other control characters collapse to a space: they are the
11
+ // only way a value can stop being one line and start looking like a new
12
+ // rule. The visible text is otherwise left exactly as written — mangling
13
+ // somebody's scope to defend against it would be its own kind of wrong.
14
+ return `| ${text
15
+ // C0/C1 plus the Unicode line and paragraph separators: everything
16
+ // that could end this physical line (Codex free-text review, finding 5).
17
+ .replace(/[\u0000-\u001F\u007F-\u009F\u2028\u2029]+/g, " ")
18
+ // A quoted protocol-shaped name is broken VISIBLY, so untrusted text
19
+ // can never collide with the real nonce-bearing filename that follows.
20
+ .replace(/STANDING-ORDERS-/g, "NIGHTORDERS[quoted]-")
21
+ .trim()}`;
22
+ }