toolroll 0.9.58 → 0.9.60

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 (86) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +8 -6
  3. package/dist/action-ledger.js +10 -1
  4. package/dist/assignment-adapters.d.ts +1 -1
  5. package/dist/assignment-adapters.js +1 -1
  6. package/dist/assignment-delivery.d.ts +16 -0
  7. package/dist/assignment-delivery.js +54 -28
  8. package/dist/assignment-status.d.ts +13 -4
  9. package/dist/assignment-status.js +70 -39
  10. package/dist/assignment.d.ts +6 -2
  11. package/dist/assignment.js +13 -8
  12. package/dist/bin.js +11 -2
  13. package/dist/brief-fence.d.ts +9 -0
  14. package/dist/brief-fence.js +22 -0
  15. package/dist/browser/workspace.css +2 -2
  16. package/dist/browser/workspace.js +19 -19
  17. package/dist/builder.js +78 -27
  18. package/dist/chat-channel.d.ts +1 -0
  19. package/dist/chat-channel.js +4 -1
  20. package/dist/chat-delivery.js +2 -0
  21. package/dist/cli-fast.d.ts +8 -0
  22. package/dist/cli-fast.js +18 -0
  23. package/dist/cli-help.d.ts +5 -0
  24. package/dist/cli-help.js +91 -0
  25. package/dist/cli.d.ts +6 -5
  26. package/dist/cli.js +17 -96
  27. package/dist/contract-changes.d.ts +52 -0
  28. package/dist/contract-changes.js +61 -0
  29. package/dist/decision.d.ts +4 -0
  30. package/dist/decision.js +8 -0
  31. package/dist/deploy-lock.d.ts +8 -0
  32. package/dist/deploy-lock.js +45 -0
  33. package/dist/design/tokens.css +234 -0
  34. package/dist/discord.js +2 -1
  35. package/dist/dispatch.js +1 -1
  36. package/dist/evidence.d.ts +22 -0
  37. package/dist/evidence.js +76 -17
  38. package/dist/flow-actions.js +2 -1
  39. package/dist/guides.js +14 -11
  40. package/dist/lead-cli.js +4 -1
  41. package/dist/lead-contract.js +1 -1
  42. package/dist/lead-doors.js +8 -2
  43. package/dist/lead-follow.d.ts +2 -0
  44. package/dist/lead-follow.js +138 -88
  45. package/dist/lead-tools.js +11 -3
  46. package/dist/mailbox.js +3 -2
  47. package/dist/mcp-core.js +1 -1
  48. package/dist/needs-you.js +3 -1
  49. package/dist/operate.js +38 -17
  50. package/dist/password-bearer.d.ts +4 -4
  51. package/dist/password-bearer.js +2 -2
  52. package/dist/planner-source.d.ts +23 -53
  53. package/dist/planner-source.js +43 -68
  54. package/dist/provider.d.ts +8 -0
  55. package/dist/provider.js +12 -1
  56. package/dist/release-gate.d.ts +2 -3
  57. package/dist/release-gate.js +44 -16
  58. package/dist/scope.js +14 -0
  59. package/dist/serve.js +3 -2
  60. package/dist/server/chrome.d.ts +6 -3
  61. package/dist/server/chrome.js +8 -4
  62. package/dist/server/console.css +9 -64
  63. package/dist/server/render-chat.js +12 -2
  64. package/dist/server/render-tasks.d.ts +2 -0
  65. package/dist/server/render-tasks.js +30 -22
  66. package/dist/server/task-views.js +1 -0
  67. package/dist/server/tasks.js +3 -1
  68. package/dist/slack-socket.d.ts +12 -0
  69. package/dist/slack-socket.js +29 -0
  70. package/dist/slack.d.ts +0 -7
  71. package/dist/slack.js +2 -24
  72. package/dist/steer-mailbox.d.ts +39 -0
  73. package/dist/steer-mailbox.js +207 -0
  74. package/dist/steer-status.d.ts +19 -0
  75. package/dist/steer-status.js +30 -0
  76. package/dist/store.d.ts +85 -8
  77. package/dist/store.js +203 -15
  78. package/dist/surface.js +2 -2
  79. package/dist/sweep-gate.d.ts +10 -0
  80. package/dist/sweep-gate.js +18 -0
  81. package/dist/task-status.js +3 -1
  82. package/dist/team-updates.js +3 -3
  83. package/dist/telegram-lead.js +6 -0
  84. package/dist/telegram.js +9 -7
  85. package/dist/toolroll-update.js +20 -1
  86. package/package.json +21 -21
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.60 — 2026-10-11
4
+
5
+ - **Steer a running build.** A note now reaches the agent while it works, and
6
+ shows when the agent has read it. Notes it misses go to the next attempt.
7
+ - **One inbox for the lead.** Every update, including cancellations, reaches
8
+ the lead through one inbox.
9
+ - **Lighter status work.** Status sweeps skip when nothing changed, and
10
+ `--version` and `--help` answer instantly.
11
+ - **Signal tokens.** The console's colours and type come from the generated
12
+ Signal design tokens.
13
+
14
+ ## 0.9.59 — 2026-10-10
15
+
16
+ - **Safer deploys.** A deploy that fails before the swap releases its own
17
+ lock. A stranded lock clears with one guided command.
18
+ - **Planning restarts on a new scope.** Changing a task's scope restarts its
19
+ planning. Cancelling a task closes its open questions.
20
+
3
21
  ## 0.9.58 — 2026-10-09
4
22
 
5
23
  - **One token kind.** Personal, lead and coordinator tokens now live in one
package/README.md CHANGED
@@ -100,9 +100,10 @@ separate model reviewer or automatic evidence resubmission loop. Deployment is
100
100
  reported separately and still requires the exact passing native machine check.
101
101
 
102
102
  Enable **Automatic crew updates** in a conversation, or use `toolroll chat
103
- --follow`. Meaningful updates reach the same lead within its saved permissions
104
- and limits. Idle scanning uses no model. Restart recovery reuses saved responses;
105
- a failed response does not rerun a task. `--no-follow` pauses automatic responses.
103
+ --follow`. Meaningful updates (a result, a question, completion or cancellation)
104
+ reach the same lead within its saved permissions and limits, through the same
105
+ acknowledged inbox an external coordinator reads. Idle scanning uses no model.
106
+ Restart recovery reuses saved responses; a failed response does not rerun a task. `--no-follow` pauses automatic responses.
106
107
  This wakes Toolroll's own lead, not an unrelated external agent session.
107
108
 
108
109
  `toolroll brief` reads the local database. `knowledge search`, `knowledge
@@ -816,7 +817,7 @@ The console, the terminal, Telegram, Slack, Discord and Teams all talk to the
816
817
  same lead with the same tools. The lead reads the fleet
817
818
  and **only proposes**: file a task (or a scout), move one to the front,
818
819
  reserve it for a worker, hold it, rewrite a scope, retry/replace/unlink a
819
- terminal dependency, guide a task's next attempt, cancel, or suggest an answer to a parked decision. Every
820
+ terminal dependency, send a task's agent guidance, cancel, or suggest an answer to a parked decision. Every
820
821
  proposal is a card you confirm, with
821
822
  every consequence and the builder's recommendation shown beside the
822
823
  lead's pick; a scope the lead wrote never seals under an operating mode.
@@ -843,8 +844,9 @@ Every task has an **Overview / Ask** switch. **Ask** opens a focused companion
843
844
  to that task without creating another conversation: Toolroll attaches
844
845
  the current task to each new message, keeps the live status beside the thread,
845
846
  and offers plain-language starters for status, scope revision, steering, and
846
- result inspection. Proposed guidance is inert until you confirm its card, then it
847
- reaches the next attempt without interrupting work already running.
847
+ result inspection. Proposed guidance is inert until you confirm its card. A
848
+ running build's agent then reads it when it next checks for notes; otherwise
849
+ the next attempt does. Each note says whether the agent has read it.
848
850
 
849
851
  New work starts the same way: describe the outcome once in ordinary language.
850
852
  The lead infers a concise title, narrow scope, safe non-goals, and proof
@@ -36,6 +36,11 @@ export function installLedgerTriggers(db) {
36
36
  const task = (ref) => `(SELECT external_id FROM task_ref WHERE id = ${ref})`;
37
37
  const runRef = `(SELECT task_ref FROM run WHERE id = NEW.run)`;
38
38
  const event = (at, actor, project, taskId, runId, action, outcome) => `INSERT INTO action_ledger(at,actor,repo,task_id,run_id,action,outcome,source) VALUES (${at},${actor},${project},${taskId},${runId},'${action}',${outcome},'work');`;
39
+ // v119: closing a question without an answer is its own event. Replace only the old trigger's future behavior;
40
+ // every ledger entry already recorded, and its seal, stays unchanged.
41
+ const answeredTrigger = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = 'ledger_decision_answered'").get();
42
+ if (answeredTrigger !== undefined && !String(answeredTrigger["sql"]).includes("superseded_reason"))
43
+ db.exec("DROP TRIGGER ledger_decision_answered");
39
44
  db.exec(`
40
45
  CREATE TRIGGER IF NOT EXISTS ledger_run_started AFTER INSERT ON run BEGIN
41
46
  ${event("NEW.started_at", "NEW.runner", repo("NEW.task_ref"), task("NEW.task_ref"), "NEW.id", "run started", "NEW.role")}
@@ -48,9 +53,13 @@ CREATE TRIGGER IF NOT EXISTS ledger_decision_opened AFTER INSERT ON decision BEG
48
53
  ${event("NEW.created_at", "'system'", repo(runRef), task(runRef), "NEW.run", "decision opened", "NEW.state")}
49
54
  END;
50
55
  CREATE TRIGGER IF NOT EXISTS ledger_decision_answered AFTER UPDATE OF answered_at ON decision
51
- WHEN NEW.answered_at IS NOT NULL AND OLD.answered_at IS NULL BEGIN
56
+ WHEN NEW.answered_at IS NOT NULL AND OLD.answered_at IS NULL AND NEW.superseded_reason IS NULL BEGIN
52
57
  ${event("NEW.answered_at", "COALESCE(NEW.answered_by,'system')", repo(runRef), task(runRef), "NEW.run", "decision answered", "NEW.state")}
53
58
  END;
59
+ CREATE TRIGGER IF NOT EXISTS ledger_decision_closed AFTER UPDATE OF answered_at ON decision
60
+ WHEN NEW.answered_at IS NOT NULL AND OLD.answered_at IS NULL AND NEW.superseded_reason IS NOT NULL BEGIN
61
+ ${event("NEW.answered_at", "COALESCE(NEW.answered_by,'system')", repo(runRef), task(runRef), "NEW.run", "decision closed", "NEW.superseded_reason")}
62
+ END;
54
63
  CREATE TRIGGER IF NOT EXISTS ledger_scope_approved AFTER UPDATE OF approved_at, approved_digest ON task_scope
55
64
  WHEN NEW.approved_at IS NOT NULL AND (OLD.approved_at IS NOT NEW.approved_at OR OLD.approved_digest IS NOT NEW.approved_digest) BEGIN
56
65
  ${event("NEW.approved_at", "COALESCE(NEW.approved_by,'system')", "(SELECT repo FROM task_ref WHERE backend = 'built-in' AND external_id = NEW.task_id)", "NEW.task_id", "NULL", "scope approved", "'approved'")}
@@ -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";
@@ -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): {
@@ -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;
@@ -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
  }
@@ -233,9 +233,13 @@ export declare function acceptAndCompleteAsOperator(store: Store, taskId: string
233
233
  receiptDigest: string;
234
234
  note: string | null;
235
235
  }, 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. */
236
+ /** The ordinary worker reconciles at most 50 owned roots per pass, and skips a
237
+ * pass when nothing moved (sweep-gate.ts). A lost wakeup is recovered after
238
+ * restart; immutable notification keys dedupe it. */
238
239
  export declare function syncAssignmentHandoffs(store: Store, now: Date, repos: readonly string[], root?: string): void;
240
+ /** The history of assignment handoffs sent to people (the `assignment-handoff` notifications), read by cursor. It is
241
+ * not a delivery path: nothing is saved, replayed or acknowledged here. Leads receive their updates through the
242
+ * assignment inbox (assignment-delivery.ts), the one road results take to a lead. */
239
243
  export declare function assignmentUpdates(store: Store, now: Date, access: AssignmentAccess, query: {
240
244
  after: number;
241
245
  limit: number;
@@ -9,7 +9,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.
@@ -418,7 +419,7 @@ export function assignmentEvidenceIntact(store, root, receipt) {
418
419
  try {
419
420
  const family = store.taskFamilyOf(receipt.taskId, null, true);
420
421
  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))))
422
+ !family.versions.every(version => store.runsFor(version.refId).every(run => store.artifactsFor(run.id).every(artifact => verifyArtifact(root, artifact).ok))))
422
423
  return false;
423
424
  if (receipt.completionKind === "research-report") {
424
425
  const report = readVerifiedReport(store, root, store.lookupRef(receipt.taskId).id);
@@ -567,13 +568,14 @@ function noteAssignmentHandoff(store, assignment, now) {
567
568
  kind: "assignment-handoff", source: { taskRef: ref.id }, subject: assignment.state === "cancelled" ? "Assignment cancelled" : assignment.state === "ready-to-check" ? "Ready" : "Assignment needs a decision",
568
569
  body: assignment.detail, link: `/t/${encodeURIComponent(assignment.rootId)}` }, now);
569
570
  }
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. */
571
+ /** The ordinary worker reconciles at most 50 owned roots per pass, and skips a
572
+ * pass when nothing moved (sweep-gate.ts). A lost wakeup is recovered after
573
+ * restart; immutable notification keys dedupe it. */
572
574
  export function syncAssignmentHandoffs(store, now, repos, root) {
573
575
  if (repos.length === 0)
574
576
  return;
575
- store.transact(() => {
576
- const key = `assignment-handoff:${digest([...new Set(repos)].sort())}`;
577
+ const key = `assignment-handoff:${digest([...new Set(repos)].sort())}`;
578
+ sweepIfMoved(store, key, now, () => store.transact(() => {
577
579
  const cursor = store.serviceCursor(key);
578
580
  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
581
  AND t.repo IN (SELECT value FROM json_each(?)) AND t.id > ?
@@ -587,8 +589,11 @@ export function syncAssignmentHandoffs(store, now, repos, root) {
587
589
  }
588
590
  }
589
591
  store.setServiceCursor(key, rows.length < 50 ? 0 : Number(rows.at(-1)["id"]), now);
590
- });
592
+ }));
591
593
  }
594
+ /** The history of assignment handoffs sent to people (the `assignment-handoff` notifications), read by cursor. It is
595
+ * not a delivery path: nothing is saved, replayed or acknowledged here. Leads receive their updates through the
596
+ * assignment inbox (assignment-delivery.ts), the one road results take to a lead. */
592
597
  export function assignmentUpdates(store, now, access, query, root) {
593
598
  const after = Number.isSafeInteger(query.after) && query.after >= 0 ? query.after : 0;
594
599
  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;