toolroll 0.9.31 → 0.9.33

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 (68) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/assignment.d.ts +3 -0
  3. package/dist/assignment.js +8 -2
  4. package/dist/batch-checks.d.ts +15 -0
  5. package/dist/batch-checks.js +244 -0
  6. package/dist/batch-policy.d.ts +23 -0
  7. package/dist/batch-policy.js +25 -0
  8. package/dist/browser/workspace.js +13 -13
  9. package/dist/builder.js +14 -3
  10. package/dist/chat-actions.js +2 -2
  11. package/dist/check-levels-ui.d.ts +4 -0
  12. package/dist/check-levels-ui.js +21 -2
  13. package/dist/checkout-cleanup.d.ts +2 -1
  14. package/dist/checkout-cleanup.js +24 -8
  15. package/dist/coding-context.js +2 -2
  16. package/dist/coding-handoff.js +2 -2
  17. package/dist/desktop-update.d.ts +1 -1
  18. package/dist/exec.d.ts +2 -0
  19. package/dist/exec.js +11 -0
  20. package/dist/guides.js +3 -0
  21. package/dist/knowledge-cli.d.ts +31 -0
  22. package/dist/knowledge-cli.js +35 -2
  23. package/dist/knowledge-ui.d.ts +1 -1
  24. package/dist/knowledge-ui.js +7 -0
  25. package/dist/lead-capabilities.d.ts +0 -7
  26. package/dist/lead-capabilities.js +11 -1
  27. package/dist/mate-doors.js +2 -2
  28. package/dist/mate.d.ts +6 -2
  29. package/dist/mate.js +63 -38
  30. package/dist/never-index.d.ts +3 -0
  31. package/dist/never-index.js +21 -0
  32. package/dist/operate.js +99 -5
  33. package/dist/orphan-sweep.d.ts +63 -0
  34. package/dist/orphan-sweep.js +235 -0
  35. package/dist/project-delete.js +1 -1
  36. package/dist/project-knowledge.d.ts +28 -3
  37. package/dist/project-knowledge.js +64 -25
  38. package/dist/project-learning.d.ts +33 -0
  39. package/dist/project-learning.js +105 -15
  40. package/dist/project-skills.js +27 -21
  41. package/dist/result-follow-ups.d.ts +22 -2
  42. package/dist/result-follow-ups.js +44 -10
  43. package/dist/serve.js +33 -11
  44. package/dist/shared-deps.d.ts +18 -2
  45. package/dist/shared-deps.js +129 -15
  46. package/dist/staged-runtimes.d.ts +42 -0
  47. package/dist/staged-runtimes.js +191 -0
  48. package/dist/storage-sweep.d.ts +50 -0
  49. package/dist/storage-sweep.js +203 -0
  50. package/dist/storage-ui.d.ts +5 -0
  51. package/dist/storage-ui.js +30 -4
  52. package/dist/storage.d.ts +2 -0
  53. package/dist/storage.js +15 -3
  54. package/dist/store.d.ts +17 -2
  55. package/dist/store.js +37 -5
  56. package/dist/subscription-chat.d.ts +3 -1
  57. package/dist/subscription-chat.js +1 -0
  58. package/dist/surface.js +2 -1
  59. package/dist/task-status.d.ts +13 -0
  60. package/dist/task-status.js +37 -3
  61. package/dist/team-leads.js +6 -2
  62. package/dist/team-runtime.js +2 -1
  63. package/dist/test-temp.d.ts +20 -2
  64. package/dist/test-temp.js +53 -13
  65. package/dist/toolroll-update.js +2 -0
  66. package/dist/workspace-ui.js +3 -1
  67. package/dist/worktree.js +9 -0
  68. package/package.json +1 -1
package/dist/builder.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { OBSERVATION_MAILBOX, observationBrief, parseObservationCases, collectObservations } from "./observations.js";
2
2
  import { checkCommandFor, effectiveCheckLevel, recordRunCheckLevel } from "./check-levels.js";
3
+ import { queueBatchCheck } from "./batch-checks.js";
4
+ import { batchesFullCheck } from "./batch-policy.js";
3
5
  import { skillsContext } from "./project-skills.js";
4
6
  import { failedVerificationEvidence, sealVerificationReceipt, verificationEvidence, reuseObservationVerification } from "./verification-evidence.js";
5
7
  import { learningContext } from "./project-learning.js";
@@ -684,7 +686,7 @@ export async function build(store, request) {
684
686
  // copy links it instead of installing its own; a setup that is about to
685
687
  // run never reaches through an old link into a shared copy.
686
688
  const depsRoot = sharedDepsRootOf(store);
687
- const sharing = depsRoot === null ? null : sharingFor({ repo: leased.repo, worktree, setup: setupWanted });
689
+ const sharing = depsRoot === null ? null : sharingFor({ repo: leased.repo, worktree, setup: setupWanted, root: depsRoot });
688
690
  if (depsRoot !== null)
689
691
  detachShared(worktree, depsRoot);
690
692
  if (depsRoot !== null && sharing !== null) {
@@ -2175,6 +2177,11 @@ async function settleProof(captured, statArtifactId, sealedHead) {
2175
2177
  const configured = chosenCheck?.command ?? null;
2176
2178
  if (repo !== null && chosenCheck !== null)
2177
2179
  recordRunCheckLevel(store, { id: runId, taskId: request.taskId, repo }, chosenCheck.level, checkChoice.from, now());
2180
+ // Batch checks (batch-checks.ts): with the project's batching on, a Full check waits to run once with
2181
+ // other results on a temporary batch commit. The build seals no check of its own; the batch's sealed
2182
+ // receipt is this result's check, and Merge waits for it.
2183
+ const batched = repo !== null && chosenCheck !== null && batchesFullCheck(store, repo, chosenCheck.level, configured);
2184
+ const gateCommand = batched ? null : configured;
2178
2185
  let verifyCommand;
2179
2186
  const checkLog = [];
2180
2187
  const checkOutcomes = [];
@@ -2210,6 +2217,10 @@ async function settleProof(captured, statArtifactId, sealedHead) {
2210
2217
  else if (configured === null) {
2211
2218
  verifyCommand = { configured: false };
2212
2219
  }
2220
+ else if (batched && repo !== null) {
2221
+ verifyCommand = { configured: false };
2222
+ queueBatchCheck(store, { runId, taskId: request.taskId, repo, head: sealedHead, base: captured.baseRevision, command: configured }, now());
2223
+ }
2213
2224
  else if (!store.proveRunnerCustodyForSpawn(runId, now())) {
2214
2225
  verifyCommand = { configured: true, ran: false, attemptFailed: true, failure: "custody-lost" };
2215
2226
  recordCheckNote("Verification did not start: this worker no longer owned the build.");
@@ -2258,7 +2269,7 @@ async function settleProof(captured, statArtifactId, sealedHead) {
2258
2269
  const live = linked === null ? null : store.liveWorktreeSetup(repo);
2259
2270
  if (linked === null || live === null)
2260
2271
  return null;
2261
- if (sharingFor({ repo, worktree, setup: live })?.key === linked && readyCopy(depsRoot, linked) !== null)
2272
+ if (sharingFor({ repo, worktree, setup: live, root: depsRoot })?.key === linked && readyCopy(depsRoot, linked) !== null)
2262
2273
  return null;
2263
2274
  if (!store.proveRunnerCustodyForSpawn(runId, now()))
2264
2275
  return "custody-lost";
@@ -2497,7 +2508,7 @@ async function settleProof(captured, statArtifactId, sealedHead) {
2497
2508
  }
2498
2509
  const { verdict, reasons, matrix, machineVerdict } = adjudicate({
2499
2510
  directAssessment: true,
2500
- ...(configured === null ? {} : { verificationCommand: configured.command }),
2511
+ ...(gateCommand === null ? {} : { verificationCommand: gateCommand.command }),
2501
2512
  proofArtifactPresent,
2502
2513
  proofParse,
2503
2514
  handoffPresent: handoffArtifact !== null,
@@ -5,7 +5,7 @@ import { TEXT_LIMITS } from "./text-limits.js";
5
5
  import { publicChatText } from "./chat-display.js";
6
6
  import { gateWords } from "./approval-policy.js";
7
7
  import { createHash, randomBytes } from "node:crypto";
8
- import { verifiedAuthor } from "./store.js";
8
+ import { mateTurnKeepsProposals, verifiedAuthor } from "./store.js";
9
9
  import { isVerifiedApprover, reproveApprover, } from "./principal.js";
10
10
  import { changeSkills, importSkill, skillsView, skillsVersion, testSkill, validateSkill, } from "./project-skills.js";
11
11
  import { changeKnowledge, knowledgeVersion, knowledgeView, } from "./project-knowledge.js";
@@ -1025,7 +1025,7 @@ function savedActionContext(store, who, id, state) {
1025
1025
  session.ceilingDigest !== who.ceilingDigest ||
1026
1026
  proposal.ceilingDigest !== who.ceilingDigest ||
1027
1027
  (!shared && turn?.session !== session.id) ||
1028
- turn?.state !== "answered")
1028
+ !mateTurnKeepsProposals(turn))
1029
1029
  throw Error("This conversation ended or its project access changed. Ask for a fresh proposal.");
1030
1030
  requireActor(store, who, payload.repo, payload.operation);
1031
1031
  return payload;
@@ -19,6 +19,10 @@ export type CheckSettingsView = {
19
19
  on: boolean;
20
20
  source: "project" | "hands-off" | "default";
21
21
  };
22
+ /** Batch checks (batch-checks.ts): off by default. */
23
+ batch?: {
24
+ on: boolean;
25
+ };
22
26
  said: string | null;
23
27
  problem: string | null;
24
28
  };
@@ -3,6 +3,7 @@
3
3
  * one. Every change takes the password and lands in the ledger; approving a
4
4
  * command restates exactly what will run, unattended, before the yes. */
5
5
  import { CHECK_LEVELS, CHECK_LEVEL_HINTS, CHECK_LEVEL_WORDS } from "./check-levels.js";
6
+ import { BATCH_HINT } from "./batch-policy.js";
6
7
  const escape = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
7
8
  export function checkSettingsHtml(view) {
8
9
  const hidden = (act) => `<input type="hidden" name="csrf" value="${escape(view.csrf)}"><input type="hidden" name="repo" value="${escape(view.repo)}"><input type="hidden" name="act" value="${act}">`;
@@ -19,7 +20,7 @@ export function checkSettingsHtml(view) {
19
20
  (fallsBack ? `<p class="meta">No quick command yet, so builds run the full check.</p>` : "") +
20
21
  (view.level !== "full" && view.full !== null ? `<p class="meta">The full check still runs when a pull request opens, and Merge waits for it.</p>` : "");
21
22
  if (!view.canChange) {
22
- return head + `<section class="card check-settings">${current}</section>` + reviewHtml(view, "") +
23
+ return head + `<section class="card check-settings">${current}</section>` + batchHtml(view, "") + reviewHtml(view, "") +
23
24
  `<section class="card check-settings"><h2>Quick check</h2>${view.quick === null ? `<p class="meta">None yet.</p>` : command(view.quick)}</section>` +
24
25
  `<section class="card check-settings"><h2>Full check</h2>${view.full === null ? `<p class="meta">None yet.</p>` : command(view.full)}</section>`;
25
26
  }
@@ -45,7 +46,25 @@ export function checkSettingsHtml(view) {
45
46
  `<p class="meta">Quick builds run the full check instead.</p>${password}<button type="submit" class="danger">Remove</button></form></details></section>`;
46
47
  const full = `<details class="settings-more"><summary>Full check</summary><section class="card check-settings">` +
47
48
  (view.full === null ? `<p class="meta">None yet. Approve one with <span class="mono">toolroll verify set --repo … --command "…"</span>.</p>` : command(view.full)) + `</section></details>`;
48
- return head + levelForm + reviewHtml(view, password) + quick + full;
49
+ return head + levelForm + batchHtml(view, password) + reviewHtml(view, password) + quick + full;
50
+ }
51
+ /** Batch checks: one state and one sentence; turning it on restates exactly what happens, behind a disclosure with the password. */
52
+ function batchHtml(view, password) {
53
+ const batch = view.batch;
54
+ if (batch === undefined)
55
+ return "";
56
+ const act = batch.on ? "Turn off" : "Turn on";
57
+ const terms = batch.on ? `<p class="meta">Each result runs its own full check again. Results already waiting are checked now.</p>`
58
+ : `<ul class="check-terms"><li>When results finish within 10 minutes of each other, they're merged in a temporary checkout and the full check runs once.</li>` +
59
+ `<li>If it fails, the batch is split until the result that breaks it is found. Results that conflict are checked on their own.</li>` +
60
+ `<li>Nothing is merged into a real branch. Each result still lands on its own.</li></ul>`;
61
+ const change = password === "" ? ""
62
+ : `<details class="settings-more"><summary>${act}</summary><form method="post" action="/settings/checks" class="check-settings">` +
63
+ `<input type="hidden" name="csrf" value="${escape(view.csrf)}"><input type="hidden" name="repo" value="${escape(view.repo)}"><input type="hidden" name="act" value="batch">` +
64
+ `<input type="hidden" name="on" value="${batch.on ? "0" : "1"}">${terms}${password}<button type="submit">${act}</button></form></details>`;
65
+ return `<section class="card check-settings" data-batch-checks="${batch.on ? "on" : "off"}"><h2>Batch checks</h2>` +
66
+ `<p><strong>${batch.on ? "On" : "Off"}</strong> · ${escape(BATCH_HINT)}</p>` +
67
+ (batch.on && view.level !== "full" ? `<p class="meta">Only Full checks are batched.</p>` : "") + `${change}</section>`;
49
68
  }
50
69
  /** Automatic review: one state, one sentence, and its one change behind a disclosure (the password). */
51
70
  function reviewHtml(view, password) {
@@ -1,7 +1,7 @@
1
1
  import { type CheckoutCleanup } from "./storage.js";
2
2
  import type { Store, WorktreeRow } from "./store.js";
3
3
  import { type WorktreePool } from "./worktree.js";
4
- /** A release candidate's checkout holds the build a deploy installs: kept a week after its task was done. */
4
+ /** A release candidate's checkout holds the build a deploy installs: the newest complete one waits at most a week for it. */
5
5
  export declare const CANDIDATE_KEEP_MS: number;
6
6
  /** The worker's pass removes at most this many; a clean-up by hand has no cap. */
7
7
  export declare const AUTO_MAX = 20;
@@ -43,6 +43,7 @@ type Status = {
43
43
  export type Deployed = {
44
44
  head: string | null;
45
45
  run: number | null;
46
+ at?: string | null;
46
47
  };
47
48
  export declare function deployedRelease(stateDir: string | null): Deployed | null;
48
49
  /**
@@ -7,9 +7,10 @@
7
7
  *
8
8
  * Always kept: a checkout in use, one whose task isn't finished (on hold, failed, a revision under way), a result
9
9
  * still waiting for review, a checkout with changes (files Toolroll wrote itself aside), and one with commits on no
10
- * branch. A release candidate's checkout (a deploy installs from it) is kept only while it is the newest gate for its
11
- * release (for a week after it was done) or the one deployed; a superseded or cancelled gate goes at once, whatever
12
- * its task's state (staged releases keep the rollback copy). A checkout no task names (adopted after a crash) goes only
10
+ * branch. A release check's checkout (a deploy installs from it) goes once its task is complete, with its dependency
11
+ * install and journey output, except the one deployed now and the project's newest complete one still waiting for its
12
+ * deploy (no deploy since it was complete; at most a week). A superseded or cancelled gate goes at once, whatever its
13
+ * task's state (staged releases keep the rollback copy). A checkout no task names (adopted after a crash) goes only
13
14
  * in a clean-up by hand. Every removal is in the ledger.
14
15
  *
15
16
  * A kept checkout waiting for review or with changes drops its dependencies and build output once its run ends
@@ -23,7 +24,7 @@ import { COMPLETION_ACTION } from "./result-completion.js";
23
24
  import { CLEANUP_CHOICES, bytesWords, diskBytes } from "./storage.js";
24
25
  import { worktreePath } from "./worktree.js";
25
26
  const DAY_MS = 86_400_000;
26
- /** A release candidate's checkout holds the build a deploy installs: kept a week after its task was done. */
27
+ /** A release candidate's checkout holds the build a deploy installs: the newest complete one waits at most a week for it. */
27
28
  export const CANDIDATE_KEEP_MS = 7 * DAY_MS;
28
29
  /** The worker's pass removes at most this many; a clean-up by hand has no cap. */
29
30
  export const AUTO_MAX = 20;
@@ -56,7 +57,7 @@ export function deployedRelease(stateDir) {
56
57
  continue;
57
58
  newest = { at, head: typeof journal["candidate"] === "string" ? journal["candidate"] : null, run: Number.isSafeInteger(journal["builder"]) ? Number(journal["builder"]) : null };
58
59
  }
59
- return newest === null ? null : { head: newest.head, run: newest.run };
60
+ return newest === null ? null : { head: newest.head, run: newest.run, at: newest.at === "" ? null : newest.at };
60
61
  }
61
62
  /** The folder beside the database (where staged releases live), or null for a database in memory. */
62
63
  function stateDirOf(store) {
@@ -117,6 +118,19 @@ export function taskStatuses(store, now, deployed) {
117
118
  if (ref !== undefined && (completedRef.get(ref) ?? "") < at)
118
119
  completedRef.set(ref, at);
119
120
  }
121
+ const completedAtOf = (row) => [completed.get(String(row["id"])), completedRef.get(Number(row["ref"]))].filter((one) => one !== undefined).sort().at(-1) ?? null;
122
+ // Per project, the newest complete gate (by when it was complete): the one a deploy may still be about to install.
123
+ // Once the project deployed after it was complete, it waits for nothing.
124
+ const deployedRepo = deployedRef === undefined ? (live?.head == null ? undefined : gates.find(row => String(row["candidate"]) === live.head)?.["repo"]) : refs.find(row => Number(row["ref"]) === deployedRef)?.["repo"];
125
+ const newestDone = new Map();
126
+ for (const row of gates) {
127
+ if (row["state"] !== "done")
128
+ continue;
129
+ const at = completedAtOf(row) ?? (row["updated_at"] === null ? "" : String(row["updated_at"]));
130
+ const seen = newestDone.get(String(row["repo"]));
131
+ if (seen === undefined || at > seen.at || (at === seen.at && Number(row["ref"]) > seen.ref))
132
+ newestDone.set(String(row["repo"]), { ref: Number(row["ref"]), at });
133
+ }
120
134
  const byRef = new Map();
121
135
  const byId = new Map();
122
136
  const parentOf = new Map();
@@ -139,11 +153,13 @@ export function taskStatuses(store, now, deployed) {
139
153
  else if (state === "cancelled")
140
154
  status = { keep: null, taskId: id, finishedAt: updated };
141
155
  else {
142
- const completedAt = [completed.get(id), completedRef.get(ref)].filter((one) => one !== undefined).sort().at(-1) ?? null;
156
+ const completedAt = completedAtOf(row);
143
157
  if (gate) {
144
158
  const since = completedAt ?? updated ?? now.toISOString();
145
159
  const until = new Date(Date.parse(since) + CANDIDATE_KEEP_MS).toISOString();
146
- status = until > now.toISOString() ? { keep: "release candidate", taskId: id, finishedAt: since, dueAt: until } : { keep: null, taskId: id, finishedAt: since };
160
+ const deployedSince = live?.at != null && deployedRepo !== undefined && String(row["repo"]) === String(deployedRepo) && live.at >= since;
161
+ const waiting = newestDone.get(String(row["repo"]))?.ref === ref && !deployedSince && until > now.toISOString();
162
+ status = waiting ? { keep: "release candidate", taskId: id, finishedAt: since, dueAt: until } : { keep: null, taskId: id, finishedAt: since, atOnce: true };
147
163
  }
148
164
  else
149
165
  status = completedAt === null ? { keep: "waiting for review", taskId: id, finishedAt: null } : { keep: null, taskId: id, finishedAt: completedAt };
@@ -353,7 +369,7 @@ export function whyWords(item) {
353
369
  case "in use": return "in use";
354
370
  case "task not finished": return "its task isn't finished";
355
371
  case "waiting for review": return "its result is waiting for review";
356
- case "release candidate": return item.dueAt === undefined ? "the deployed release candidate" : `newest release candidate, kept until ${item.dueAt.slice(0, 10)}`;
372
+ case "release candidate": return item.dueAt === undefined ? "the deployed release candidate" : `newest release candidate, kept for its deploy until ${item.dueAt.slice(0, 10)}`;
357
373
  case "has changes": return "has changes";
358
374
  case "has commits": return "has commits on no branch";
359
375
  case "unreadable": return "git couldn't read it";
@@ -4,7 +4,7 @@ import { createHash } from 'node:crypto';
4
4
  import { basename, dirname, join, resolve, relative, isAbsolute, sep } from 'node:path';
5
5
  import { KNOWLEDGE_GUIDANCE, selectProjectKnowledge } from './project-knowledge.js';
6
6
  import { materializeProjectSkills, PROJECT_SKILLS_GUIDANCE, selectProjectSkills } from './project-skills.js';
7
- import { learningIdentity, learningSha } from './project-learning.js';
7
+ import { identityMatches, learningIdentity, learningSha } from './project-learning.js';
8
8
  function freeze(value) {
9
9
  if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
10
10
  for (const child of Object.values(value))
@@ -74,7 +74,7 @@ export function prepareCodingContext(store, args) {
74
74
  }
75
75
  /** Integrity check for a persisted capture. The caller must separately recheck current account access. */
76
76
  export function verifyCodingContext(context, repo, baseRevision) {
77
- if (context.metadata.version !== 1 || context.metadata.repo !== repo || context.metadata.baseRevision !== baseRevision || context.metadata.identity !== learningIdentity(repo) || context.sha256 !== digest(context.text, context.metadata))
77
+ if (context.metadata.version !== 1 || context.metadata.repo !== repo || context.metadata.baseRevision !== baseRevision || !identityMatches(repo, context.metadata.identity) || context.sha256 !== digest(context.text, context.metadata))
78
78
  throw Error('The coding session context could not be verified.');
79
79
  if (context.metadata.files.length && context.metadata.directory === null)
80
80
  throw Error('The coding session skill files are unavailable.');
@@ -4,7 +4,7 @@ import { lstatSync, realpathSync } from 'node:fs';
4
4
  import { sqliteRuntime } from './sqlite-runtime.js';
5
5
  import { codingCatalogPath } from './coding-update.js';
6
6
  import { updateAdmissionPaused } from './desktop-update-gate.js';
7
- import { learningIdentity, learningSha } from './project-learning.js';
7
+ import { acceptedIdentities, learningIdentity, learningSha } from './project-learning.js';
8
8
  import { fileTaskProposal } from './proposal.js';
9
9
  import { parseAcceptanceCriteria, proposeGuarded } from './scope.js';
10
10
  import { validateTaskText } from './task-text.js';
@@ -164,7 +164,7 @@ export function readCodingHandoff(store, taskId) {
164
164
  if (!row)
165
165
  throw Error('The coding handoff receipt is missing. Restore it before review.');
166
166
  const receipt = parseReceipt(row), scope = store.getScope(taskId), ref = store.lookupRef(taskId);
167
- if (receipt.taskId !== taskId || ref?.repo !== receipt.repo || receipt.identity !== learningIdentity(receipt.repo) || !scope || scope.termsProblem != null || scope.candidate !== receipt.candidate)
167
+ if (receipt.taskId !== taskId || ref?.repo !== receipt.repo || !acceptedIdentities(store, receipt.repo).has(receipt.identity) || !scope || scope.termsProblem != null || scope.candidate !== receipt.candidate)
168
168
  throw Error('The coding review no longer matches its saved project, candidate or scope.');
169
169
  verifySeal(db, receipt, scope.digest);
170
170
  return { taskId, scopeDigest: scope.digest, receipt };
@@ -125,13 +125,13 @@ export declare function prepareDesktopUpdate(stateDir: string, installed: string
125
125
  export declare function sqliteLock(file: string, busyTimeoutMs?: number): DatabaseSync | null;
126
126
  export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
127
127
  export declare function desktopUpdateStatus(stateDir: string): {
128
- error?: never;
129
128
  active: boolean;
130
129
  phase: string;
131
130
  detail: string;
132
131
  running: boolean;
133
132
  canResume: boolean;
134
133
  canCancel: boolean;
134
+ error?: never;
135
135
  automaticRecovery?: never;
136
136
  recoveryAttempts?: never;
137
137
  recoveryRepairs?: never;
package/dist/exec.d.ts CHANGED
@@ -56,6 +56,8 @@ export type RunOptions = ProcessTreeObserver & {
56
56
  * timeoutMs there is deliberately no absolute wall-clock ceiling.
57
57
  */
58
58
  idleTimeoutMs?: number;
59
+ /** Ends a process-group run early, exactly as its timeout does: the whole group is killed and the result reads timed out. */
60
+ signal?: AbortSignal;
59
61
  maxBuffer?: number;
60
62
  /**
61
63
  * Extra environment, merged over the process's own. A capability probe is a
package/dist/exec.js CHANGED
@@ -526,6 +526,7 @@ export function run(file, args, options = {}) {
526
526
  return retryTransientSpawn(() => runBufferedGroup(file, args, {
527
527
  timeoutMs,
528
528
  maxBuffer,
529
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
529
530
  ...(options.stdin === undefined ? {} : { stdin: options.stdin }),
530
531
  ...(cwd === undefined ? {} : { cwd }),
531
532
  ...(childEnv === undefined ? {} : { childEnv }),
@@ -610,6 +611,15 @@ function runBufferedGroup(file, args, bag) {
610
611
  timedOut = true;
611
612
  killGroup(child);
612
613
  }, bag.timeoutMs);
614
+ // The caller's deadline, when it owns one: the same group kill as the timer.
615
+ const aborted = () => {
616
+ timedOut = true;
617
+ killGroup(child);
618
+ };
619
+ if (bag.signal?.aborted === true)
620
+ aborted();
621
+ else
622
+ bag.signal?.addEventListener("abort", aborted, { once: true });
613
623
  child.stdout?.setEncoding("utf8");
614
624
  child.stdout?.on("data", (chunk) => {
615
625
  stdout += chunk;
@@ -638,6 +648,7 @@ function runBufferedGroup(file, args, bag) {
638
648
  return;
639
649
  settled = true;
640
650
  clearTimeout(timer);
651
+ bag.signal?.removeEventListener("abort", aborted);
641
652
  liveProviders.delete(child);
642
653
  // The OS object is settled BEFORE the answer: a root that exited is
643
654
  // not an empty container until the OS says so.
package/dist/guides.js CHANGED
@@ -419,6 +419,9 @@ and crew agent reads from it before working:
419
419
  - Instructions and references change on the Knowledge page under Settings,
420
420
  or through the lead's knowledge cards. Every change keeps a revision you
421
421
  can restore.
422
+ - If a project was replaced at the same path, its saved knowledge waits:
423
+ tasks run without it until an approver applies it on the Knowledge page
424
+ or with \`toolroll knowledge apply --repo PATH --as <you> --token <t>\`.
422
425
  - Agent conclusions are not instructions: a crew agent reports; a person or
423
426
  the lead's confirmed card records.
424
427
 
@@ -89,7 +89,38 @@ export declare const KNOWLEDGE_DESCRIPTORS: readonly [{
89
89
  readonly takesValue: true;
90
90
  readonly meaning: 'environment variable containing a scoped coordinator token';
91
91
  }];
92
+ }, {
93
+ readonly action: 'apply';
94
+ readonly synopsis: "carry this project's saved knowledge from before a change forward as a new revision; an approver's --as/--token";
95
+ readonly mutation: 'identity-idempotent';
96
+ readonly takesQuery: false;
97
+ readonly flags: readonly [{
98
+ readonly name: 'repo';
99
+ readonly takesValue: true;
100
+ readonly meaning: 'admitted project checkout root';
101
+ }, {
102
+ readonly name: 'as';
103
+ readonly takesValue: true;
104
+ readonly meaning: 'approver account name';
105
+ }, {
106
+ readonly name: 'token';
107
+ readonly takesValue: true;
108
+ readonly meaning: "the approver's password (never a lead token)";
109
+ }, {
110
+ readonly name: 'json';
111
+ readonly takesValue: false;
112
+ readonly meaning: 'versioned structured response';
113
+ }, {
114
+ readonly name: 'db';
115
+ readonly takesValue: true;
116
+ readonly meaning: 'local Toolroll database';
117
+ }];
118
+ readonly audience: 'operator';
92
119
  }];
120
+ /** `knowledge apply`: the caller has verified `actor` as an approver of `repo` (never the lead). */
121
+ export declare function runKnowledgeApply(options: Map<string, string | true>, context: KnowledgeCliContext & {
122
+ actor: string | null;
123
+ }): number;
93
124
  export type KnowledgeCliContext = {
94
125
  store: Store;
95
126
  write: (line: string) => void;
@@ -6,6 +6,7 @@ import { authenticateCoordinator } from './coordinator.js';
6
6
  import { envelopeJson } from './envelope.js';
7
7
  import { repositoryContext } from './repository-context.js';
8
8
  import { namedPath } from './names.js';
9
+ import { applySavedKnowledge } from './project-knowledge.js';
9
10
  const flags = [
10
11
  { name: 'repo', takesValue: true, meaning: 'admitted project checkout root' },
11
12
  { name: 'base', takesValue: true, meaning: 'exact committed task base; defaults to current HEAD' },
@@ -14,11 +15,43 @@ const flags = [
14
15
  { name: 'token-file', takesValue: true, meaning: 'explicit scoped coordinator credential file' },
15
16
  { name: 'token-env', takesValue: true, meaning: 'environment variable containing a scoped coordinator token' },
16
17
  ];
18
+ const applyFlags = [
19
+ { name: 'repo', takesValue: true, meaning: 'admitted project checkout root' },
20
+ { name: 'as', takesValue: true, meaning: 'approver account name' },
21
+ { name: 'token', takesValue: true, meaning: "the approver's password (never a lead token)" },
22
+ { name: 'json', takesValue: false, meaning: 'versioned structured response' },
23
+ { name: 'db', takesValue: true, meaning: 'local Toolroll database' },
24
+ ];
17
25
  export const KNOWLEDGE_DESCRIPTORS = [
18
26
  { action: 'search', synopsis: 'find bounded source excerpts and relevant code symbols', mutation: 'none', takesQuery: true, flags },
19
27
  { action: 'impact', synopsis: 'inspect static imports affected by one file or unambiguous symbol', mutation: 'none', takesQuery: true, flags },
20
28
  { action: 'refresh', synopsis: 'refresh the optional local code index; never starts a model or task', mutation: 'identity-idempotent', takesQuery: false, flags },
29
+ // A person's action: the approver carries saved knowledge from before a project change forward.
30
+ { action: 'apply', synopsis: "carry this project's saved knowledge from before a change forward as a new revision; an approver's --as/--token", mutation: 'identity-idempotent', takesQuery: false, flags: applyFlags, audience: 'operator' },
21
31
  ];
32
+ /** `knowledge apply`: the caller has verified `actor` as an approver of `repo` (never the lead). */
33
+ export function runKnowledgeApply(options, context) {
34
+ const command = 'knowledge apply';
35
+ const fail = (reason, message) => { context.write(context.json ? envelopeJson({ ok: false, command, reason, message }) : message); return reason === 'usage' ? 2 : 3; };
36
+ for (const key of options.keys())
37
+ if (!applyFlags.some(flag => flag.name === key))
38
+ return fail('usage', `--${key} is not a knowledge apply option.`);
39
+ const repo = options.get('repo');
40
+ if (typeof repo !== 'string' || !repo.trim() || repo.length > 4096 || /[\x00-\x1f]/.test(repo))
41
+ return fail('usage', 'Use --repo with the project checkout root.');
42
+ if (context.actor === null)
43
+ return fail('unauthenticated', 'An approver of this project applies saved knowledge: --as <you> --token <password>.');
44
+ let revision;
45
+ try {
46
+ revision = applySavedKnowledge(context.store, resolve(repo), context.actor, context.now);
47
+ }
48
+ catch (error) {
49
+ return fail('failed', error instanceof Error ? error.message : 'Saved knowledge could not be applied.');
50
+ }
51
+ const message = revision === null ? 'Nothing to apply.' : `Applied saved knowledge as version ${revision}.`;
52
+ context.write(context.json ? envelopeJson({ ok: true, command, result: { revision, message } }) : message);
53
+ return 0;
54
+ }
22
55
  function lines(result) {
23
56
  return [
24
57
  `Repository context: ${result.index.status}${result.index.engine === 'text-search' ? ' · source search' : ' · code relationships'}`,
@@ -32,8 +65,8 @@ export async function runKnowledgeCommand(positional, options, context) {
32
65
  const spec = KNOWLEDGE_DESCRIPTORS.find(s => s.action === positional[0]);
33
66
  const command = spec ? `knowledge ${spec.action}` : 'knowledge';
34
67
  const fail = (reason, message) => { context.write(context.json ? envelopeJson({ ok: false, command, reason, message }) : message); return reason === 'usage' ? 2 : 3; };
35
- if (!spec)
36
- return fail('usage', 'Use knowledge search <query>, impact <file-or-symbol>, or refresh with --repo PATH.');
68
+ if (!spec || spec.action === 'apply')
69
+ return fail('usage', 'Use knowledge search <query>, impact <file-or-symbol>, refresh or apply with --repo PATH.');
37
70
  if (positional.length !== (spec.takesQuery ? 2 : 1))
38
71
  return fail('usage', spec.takesQuery ? 'Provide one quoted query or source path.' : 'knowledge refresh takes no query.');
39
72
  for (const key of options.keys())
@@ -1,4 +1,4 @@
1
- import type { KnowledgeDraft, KnowledgeSelection, KnowledgeView } from './project-knowledge.js';
1
+ import { type KnowledgeDraft, type KnowledgeSelection, type KnowledgeView } from './project-knowledge.js';
2
2
  export declare const KNOWLEDGE_CSS = ".knowledge{max-width:780px;min-width:0;overflow-wrap:anywhere}.knowledge .card{padding:20px;min-width:0;margin:16px 0}.knowledge h2{margin:0 0 12px}.knowledge form{display:grid;gap:12px;margin:0}.knowledge label{display:grid;gap:6px}.knowledge textarea,.knowledge input,.knowledge select{box-sizing:border-box;width:100%;min-width:0;max-width:100%}.knowledge textarea{resize:vertical;min-height:110px}.knowledge button,.knowledge summary,.knowledge .button-link{min-height:44px}.knowledge button{justify-self:start;white-space:nowrap}.knowledge summary{padding:12px 0;cursor:pointer}.knowledge pre{white-space:pre-wrap;overflow-wrap:anywhere;font:inherit}.knowledge .knowledge-actions{display:flex;gap:12px;flex-wrap:wrap}.knowledge .meta{font-size:.86rem}.knowledge .knowledge-source{display:block}.knowledge details+details{border-top:1px solid var(--so-line)}.memory-stats{display:flex;flex-wrap:wrap;gap:.4rem}.memory-stats span{padding:.15rem .55rem;border-radius:.375rem;background:var(--so-neutral-soft);color:var(--so-neutral-ink)}@media(max-width:600px){.knowledge .card{padding:16px}.knowledge h2{font-size:1.1rem}}";
3
3
  export declare function knowledgeHtml(view: KnowledgeView, csrf: string, canManage: boolean, draft?: KnowledgeDraft, error?: string): string;
4
4
  export declare function knowledgeContextHtml(snapshot: KnowledgeSelection | null): string;
@@ -1,3 +1,4 @@
1
+ import { STALE_KNOWLEDGE } from './project-knowledge.js';
1
2
  const e = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
2
3
  const fields = (data) => Object.entries(data).map(([k, v]) => `<input type="hidden" name="${e(k)}" value="${e(v)}">`).join('');
3
4
  export const KNOWLEDGE_CSS = `.knowledge{max-width:780px;min-width:0;overflow-wrap:anywhere}.knowledge .card{padding:20px;min-width:0;margin:16px 0}.knowledge h2{margin:0 0 12px}.knowledge form{display:grid;gap:12px;margin:0}.knowledge label{display:grid;gap:6px}.knowledge textarea,.knowledge input,.knowledge select{box-sizing:border-box;width:100%;min-width:0;max-width:100%}.knowledge textarea{resize:vertical;min-height:110px}.knowledge button,.knowledge summary,.knowledge .button-link{min-height:44px}.knowledge button{justify-self:start;white-space:nowrap}.knowledge summary{padding:12px 0;cursor:pointer}.knowledge pre{white-space:pre-wrap;overflow-wrap:anywhere;font:inherit}.knowledge .knowledge-actions{display:flex;gap:12px;flex-wrap:wrap}.knowledge .meta{font-size:.86rem}.knowledge .knowledge-source{display:block}.knowledge details+details{border-top:1px solid var(--so-line)}.memory-stats{display:flex;flex-wrap:wrap;gap:.4rem}.memory-stats span{padding:.15rem .55rem;border-radius:.375rem;background:var(--so-neutral-soft);color:var(--so-neutral-ink)}@media(max-width:600px){.knowledge .card{padding:16px}.knowledge h2{font-size:1.1rem}}`;
@@ -8,6 +9,12 @@ export function knowledgeHtml(view, csrf, canManage, draft, error) {
8
9
  const referenceFields = (d) => `<label>Name<input name="title" maxlength="120" value="${e(d.title)}" required></label><label>Reference text<textarea name="content" rows="5" maxlength="12000">${e(d.content)}</textarea></label><details${d.path ? ' open' : ''}><summary>Use a project document</summary><label>Committed .md or .txt path<input name="path" value="${e(d.path)}" placeholder="docs/design-guide.md"></label><p class="meta">Uses the committed file instead of the text above. Save again to refresh it.</p></details><button type="submit">Save reference</button>`;
9
10
  const refs = view.knowledge.references.map(ref => `<details class="card"${draft?.id === ref.id ? ' open' : ''}><summary><strong>${e(ref.title)}</strong></summary>${ref.path ? `<span class="meta knowledge-source">${e(ref.path)} · ${e(ref.sourceRevision?.slice(0, 7))}</span>` : ''}<pre>${e(ref.content)}</pre>${manage ? `<details${draft?.id === ref.id ? ' open' : ''}><summary>Edit reference</summary>${form('save', referenceFields(draft?.id === ref.id ? draft : { title: ref.title, content: ref.path ? '' : ref.content, path: ref.path ?? '' }), { id: ref.id })}<details><summary>Remove reference</summary>${form('remove', '<p>Remove from future tasks. Previous run context stays available.</p><button>Remove reference</button>', { id: ref.id })}</details></details>` : ''}</details>`).join('');
10
11
  const history = view.history.map(h => `<li><a class="button-link" href="/settings/knowledge?repo=${encodeURIComponent(view.repo)}&version=${h.revision}">Version ${h.revision}</a> · ${e(h.actor)} · ${e(h.at.slice(0, 16).replace('T', ' '))} UTC</li>`).join('');
12
+ if (view.stale) {
13
+ // One notice, the saved knowledge on demand, one approver action. Editing waits until it is applied.
14
+ const saved = view.stale.knowledge;
15
+ const review = `<details><summary>Review saved knowledge</summary><pre class="knowledge-instructions">${e(saved.instructions || 'No project instructions.')}</pre>${saved.references.map(ref => `<details><summary>${e(ref.title)}</summary><pre>${e(ref.content)}</pre></details>`).join('')}</details>`;
16
+ return `<section class="knowledge knowledge-library">${error ? `<p class="problem" role="alert">${e(error)}</p>` : ''}<section class="card knowledge-stale"><p role="status">${e(STALE_KNOWLEDGE)}.</p>${review}${manage ? form('apply', '<button>Apply saved knowledge</button>') : ''}</section></section>`;
17
+ }
11
18
  const instructions = `<pre class="knowledge-instructions">${e(view.knowledge.instructions || 'No project instructions yet.')}</pre>`;
12
19
  const editor = manage ? `<details class="knowledge-editor"${draft?.instructions !== undefined ? ' open' : ''}><summary>${view.knowledge.instructions ? 'Edit instructions' : 'Add instructions'}</summary>${form('instructions', `<textarea aria-label="Project instructions" name="instructions" rows="5" maxlength="4000" placeholder="Goals, preferences, and what good work looks like…">${e(draft?.instructions ?? view.knowledge.instructions)}</textarea><button>Save instructions</button>`)}</details>` : '';
13
20
  return `<section class="knowledge knowledge-library">${error ? `<p class="problem" role="alert">${e(error)}</p>` : ''}<section class="knowledge-intro"><h2>Instructions</h2>${instructions}${editor}</section><section><h2>References</h2>${refs || '<p class="meta">Add a spec, design guide, or useful project document.</p>'}${manage ? `<details class="card"${draft?.title && !draft.id ? ' open' : ''}><summary>Add reference</summary>${form('save', referenceFields(draft?.id ? {} : draft ?? {}))}</details>` : ''}</section>${history ? `<details><summary>Change history</summary><ol>${history}</ol></details>` : ''}</section>`;
@@ -1,10 +1,3 @@
1
- /**
2
- * What the lead can rely on right now, in one read (get_capabilities): the agents a project's route would run, the
3
- * workers that would run them, the project's tools and skills, the integrations, and the project's checks. Every entry
4
- * says what it lets the lead do and, when it can't, the one next step and the settings control (show_control) that
5
- * takes the owner there. Everything here reads what is already recorded — runner readiness reports, sign-in pauses,
6
- * plan-limit readings, tool tests, skill tests, integration checks — and never probes anything that spends.
7
- */
8
1
  import type { Store } from "./store.js";
9
2
  import type { ChatControl } from "./chat-controls.js";
10
3
  import type { ProviderId } from "./provider.js";
@@ -1,3 +1,11 @@
1
+ /**
2
+ * What the lead can rely on right now, in one read (get_capabilities): the agents a project's route would run, the
3
+ * workers that would run them, the project's tools and skills, the integrations, and the project's checks. Every entry
4
+ * says what it lets the lead do and, when it can't, the one next step and the settings control (show_control) that
5
+ * takes the owner there. Everything here reads what is already recorded — runner readiness reports, sign-in pauses,
6
+ * plan-limit readings, tool tests, skill tests, integration checks — and never probes anything that spends.
7
+ */
8
+ import { projectBatchChecks } from "./batch-policy.js";
1
9
  import { resolveRouteCandidates } from "./agentconfig.js";
2
10
  import { authPauseOf, messageCommand, providerName } from "./provider-auth.js";
3
11
  import { readAuthMode, SUBSCRIPTION_CAPABLE } from "./keys.js";
@@ -136,7 +144,9 @@ function checksOf(store, repo) {
136
144
  return { ...base, state: "Off", ok: false, next: "Builds are not checked. Turn checks on for the project if results need them.", link: "projects" };
137
145
  if (!releaseCheck)
138
146
  return { ...base, state: `${CHECK_LEVEL_WORDS[level]} · no release check`, ok: false, next: "No release check is set up, so no result can show a passing check. Set one for the project.", link: "projects" };
139
- return { ...base, state: level === "quick" && !quickCheck ? "Quick · runs the release check" : `${CHECK_LEVEL_WORDS[level]} · release check set`, ok: true, next: null, link: null };
147
+ // Batch checks are changed in Settings → Projects → Checks (or `project checks --batch on|off`), never from chat.
148
+ const batched = level === "full" && projectBatchChecks(store, repo).on ? " · batched with results that finish together" : "";
149
+ return { ...base, state: level === "quick" && !quickCheck ? "Quick · runs the release check" : `${CHECK_LEVEL_WORDS[level]} · release check set${batched}`, ok: true, next: null, link: null };
140
150
  }
141
151
  function workersOf(store, repos, now) {
142
152
  const live = store.handle.prepare(`SELECT ref.external_id AS task, ref.repo AS repo FROM claim JOIN task_ref AS ref ON ref.id = claim.task_ref
@@ -15,7 +15,7 @@ import { executeSharedAction, sharedActionPayload, sharedActionAllowsChallenge,
15
15
  * silent re-read.
16
16
  */
17
17
  import { isCheckLevel, setTaskCheckLevel } from "./check-levels.js";
18
- import { verifiedAuthor } from "./store.js";
18
+ import { mateTurnKeepsProposals, verifiedAuthor } from "./store.js";
19
19
  import { isVerifiedApprover, reproveApprover } from "./principal.js";
20
20
  import { TeamLeads } from './team-leads.js';
21
21
  import { fileTaskProposal } from "./proposal.js";
@@ -97,7 +97,7 @@ export function confirmMateProposal(store, who, proposalId, now, options) {
97
97
  return { ok: false, kind: proposal.kind, reason: "ceiling-changed", said: "the admitted projects changed since this was proposed — it cannot be confirmed" };
98
98
  }
99
99
  const turn = store.getMateTurn(proposal.turn);
100
- if (turn === null || turn.state !== "answered") {
100
+ if (!mateTurnKeepsProposals(turn) || proposal.state === "drafting") {
101
101
  return { ok: false, kind: proposal.kind, reason: "turn-not-answered", said: "this proposal's turn did not finish — it cannot be confirmed" };
102
102
  }
103
103
  if (proposal.kind === "cancel") {
package/dist/mate.d.ts CHANGED
@@ -93,6 +93,7 @@ export type MateTurnOutcome = {
93
93
  failed: MateFailure;
94
94
  message: string;
95
95
  unknownSpend: boolean;
96
+ saved?: boolean;
96
97
  };
97
98
  /** What happened, what it means, and one next step: plain words a person can act on, never internal terms. */
98
99
  export declare const MATE_REFUSAL_COPY: Record<MateRefusal, string>;
@@ -102,7 +103,7 @@ export declare const MATE_CHANNEL_COPY: Record<MateChannelProblem, string>;
102
103
  export declare const MATE_FAILURE_COPY: {
103
104
  readonly stopped: "This reply was stopped before it finished, from another window or by a newer message. Nothing it proposed was kept. Send your message again if you still need an answer.";
104
105
  readonly ended: "Your sign-in or this conversation ended while the lead was answering. Nothing it proposed was kept. Sign in again or start a new chat, then send your message again.";
105
- readonly tooLong: "The reply took too long and was stopped. Nothing it proposed was kept. Send your message again, or ask for less at once.";
106
+ readonly tooLong: string;
106
107
  readonly secretRead: "Something the lead read looked like a password or key, so it stopped before sending it anywhere. Nothing was kept. Remove the password or key from that task or note, then ask again.";
107
108
  readonly secretReply: "The lead's reply contained something that looked like a password or key, so it was thrown away. Nothing was kept. Ask again, without asking for a password or key.";
108
109
  readonly paused: "An earlier reply stopped before its cost was known, so chat is paused. This message wasn't answered. Confirm that earlier reply on the Chat page, then send again.";
@@ -111,7 +112,7 @@ export declare const MATE_FAILURE_COPY: {
111
112
  readonly unreadable: "The chat provider sent back an answer that couldn't be read, so it was thrown away. Nothing was kept. Send your message again.";
112
113
  readonly signIn: "The chat provider couldn't answer; its sign-in may have expired. Nothing was kept. Sign in to it again on this computer, then send your message again.";
113
114
  readonly refused: "The chat provider turned the request down. Nothing was kept or charged. Try again in a minute; if it keeps happening, check the provider in Settings → Lead.";
114
- readonly tooLongUnknownCost: "The reply took too long and was stopped, and its cost isn't known yet, so chat is paused. Nothing it proposed was kept. Confirm the cost on the Chat page to turn chat back on.";
115
+ readonly tooLongUnknownCost: string;
115
116
  readonly lostUnknownCost: "The chat provider stopped responding partway through, and the cost isn't known, so chat is paused. Nothing it proposed was kept. Confirm the cost on the Chat page to turn chat back on.";
116
117
  readonly unreadableUnknownCost: "The chat provider sent back an answer that couldn't be read, and its cost isn't known, so chat is paused. Nothing it proposed was kept. Confirm the cost on the Chat page to turn chat back on.";
117
118
  readonly empty: "The lead sent back an empty answer. Nothing was kept. Send your message again.";
@@ -119,6 +120,9 @@ export declare const MATE_FAILURE_COPY: {
119
120
  };
120
121
  /** A failed turn's saved reason as plain words, for a surface that reads the turn back later (a restart, a delivery retry). */
121
122
  export declare function mateFailureText(reason: string | null): string;
123
+ /** How long a provider that ignored its abort at the turn's deadline is waited for before the turn ends without it.
124
+ * Its process group was already killed at the deadline; this only bounds the wait for the transport to say so. */
125
+ export declare const MATE_ABORT_GRACE_MS = 5000;
122
126
  /** The last messages of the thread as provider-neutral history, newest kept first until the byte cap. */
123
127
  export declare function historyFor(store: Store, thread: number, queuedMessageId?: number): MateHistoryMessage[];
124
128
  /** "read 3 · proposed 1 · 2 steps" — counts only, never results (ruling 11). */