toolroll 0.9.75 → 0.9.77

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 (129) hide show
  1. package/CHANGELOG.md +241 -0
  2. package/README.md +11 -4
  3. package/THIRD_PARTY_NOTICES.md +5 -5
  4. package/dist/agent-fence.js +36 -8
  5. package/dist/approval-authority.js +6 -2
  6. package/dist/approval-rules-ui.d.ts +11 -0
  7. package/dist/approval-rules-ui.js +13 -1
  8. package/dist/assignment.js +3 -0
  9. package/dist/batch-checks.js +3 -1
  10. package/dist/browser/THIRD_PARTY_NOTICES.txt +5 -5
  11. package/dist/browser/workspace.css +2 -2
  12. package/dist/browser/workspace.js +31 -31
  13. package/dist/builder.d.ts +14 -0
  14. package/dist/builder.js +141 -42
  15. package/dist/chat-decide.js +4 -0
  16. package/dist/check-failure.d.ts +92 -0
  17. package/dist/check-failure.js +267 -0
  18. package/dist/check-progress.d.ts +28 -1
  19. package/dist/check-progress.js +105 -6
  20. package/dist/check-report.d.ts +52 -6
  21. package/dist/check-report.js +193 -8
  22. package/dist/checkout-cleanup.js +2 -2
  23. package/dist/cli.js +8 -5
  24. package/dist/coding-handoff.d.ts +1 -0
  25. package/dist/coding-handoff.js +20 -11
  26. package/dist/coding-workspace.js +7 -5
  27. package/dist/commit-extract.d.ts +6 -0
  28. package/dist/commit-extract.js +63 -8
  29. package/dist/console-chrome.d.ts +41 -0
  30. package/dist/console-chrome.js +166 -0
  31. package/dist/contracts/console-api.d.ts +1 -0
  32. package/dist/contracts/console-api.js +1 -0
  33. package/dist/contracts/release-check.d.ts +12 -0
  34. package/dist/contracts/release-check.js +17 -0
  35. package/dist/contracts/verification-receipt.d.ts +20 -2
  36. package/dist/contracts/verification-receipt.js +15 -3
  37. package/dist/contracts/verification-view.d.ts +8 -0
  38. package/dist/controller-supervisor.d.ts +8 -0
  39. package/dist/controller-supervisor.js +33 -1
  40. package/dist/demo.js +3 -2
  41. package/dist/deploy-gate.d.ts +3 -0
  42. package/dist/deploy-gate.js +8 -2
  43. package/dist/desktop-update.d.ts +1 -1
  44. package/dist/discover.js +6 -1
  45. package/dist/dispose.d.ts +3 -0
  46. package/dist/dispose.js +6 -3
  47. package/dist/doctor.d.ts +7 -0
  48. package/dist/doctor.js +10 -2
  49. package/dist/envelope.js +2 -1
  50. package/dist/evidence.js +4 -1
  51. package/dist/exec.d.ts +7 -0
  52. package/dist/exec.js +7 -1
  53. package/dist/first-run.js +3 -1
  54. package/dist/git-floor.d.ts +16 -0
  55. package/dist/git-floor.js +36 -0
  56. package/dist/git-incident.d.ts +49 -0
  57. package/dist/git-incident.js +69 -0
  58. package/dist/git-views.d.ts +20 -0
  59. package/dist/git-views.js +120 -0
  60. package/dist/grant.js +7 -1
  61. package/dist/guides.js +4 -3
  62. package/dist/operate.d.ts +13 -0
  63. package/dist/operate.js +779 -319
  64. package/dist/planner.js +5 -1
  65. package/dist/prepared-evidence.d.ts +2 -1
  66. package/dist/prepared-evidence.js +8 -3
  67. package/dist/project-knowledge.js +6 -2
  68. package/dist/project-learning.js +23 -3
  69. package/dist/proof.js +6 -0
  70. package/dist/pull-request-flow.js +4 -0
  71. package/dist/read-executor-worker.js +8 -4
  72. package/dist/read-executor.d.ts +18 -1
  73. package/dist/read-executor.js +8 -0
  74. package/dist/release-adapters.d.ts +71 -2
  75. package/dist/release-adapters.js +471 -12
  76. package/dist/release-authority.d.ts +46 -0
  77. package/dist/release-authority.js +68 -0
  78. package/dist/release-auto.d.ts +184 -0
  79. package/dist/release-auto.js +612 -0
  80. package/dist/release-card.d.ts +15 -3
  81. package/dist/release-card.js +25 -9
  82. package/dist/release-ci-hold.d.ts +41 -0
  83. package/dist/release-ci-hold.js +53 -0
  84. package/dist/release-cli.js +77 -14
  85. package/dist/release-coverage.js +5 -2
  86. package/dist/release-recheck.d.ts +1 -1
  87. package/dist/release-recheck.js +6 -1
  88. package/dist/release.d.ts +207 -6
  89. package/dist/release.js +495 -20
  90. package/dist/repository-context.js +4 -6
  91. package/dist/result-follow-ups.js +3 -1
  92. package/dist/review-context.js +8 -6
  93. package/dist/runner.js +2 -1
  94. package/dist/sandbox-runtime.d.ts +8 -0
  95. package/dist/sandbox-runtime.js +14 -2
  96. package/dist/scope.d.ts +31 -2
  97. package/dist/scope.js +51 -4
  98. package/dist/scout.js +5 -1
  99. package/dist/secret-mint.d.ts +1 -0
  100. package/dist/secret-mint.js +14 -0
  101. package/dist/serve.js +101 -51
  102. package/dist/server/chat.js +5 -5
  103. package/dist/server/render-tasks.d.ts +12 -0
  104. package/dist/server/render-tasks.js +25 -14
  105. package/dist/server/request-context.d.ts +6 -0
  106. package/dist/server/route-table.js +3 -0
  107. package/dist/server/runtime.d.ts +2 -0
  108. package/dist/server/settings.js +29 -2
  109. package/dist/server/task-views.js +15 -2
  110. package/dist/setup-guide.d.ts +2 -2
  111. package/dist/storage.d.ts +5 -0
  112. package/dist/storage.js +28 -1
  113. package/dist/store.d.ts +11 -1
  114. package/dist/store.js +44 -5
  115. package/dist/task-base.d.ts +4 -0
  116. package/dist/task-base.js +22 -2
  117. package/dist/teardown-git.d.ts +90 -13
  118. package/dist/teardown-git.js +1027 -129
  119. package/dist/test-infra.d.ts +26 -0
  120. package/dist/test-infra.js +321 -0
  121. package/dist/up-processes.d.ts +137 -0
  122. package/dist/up-processes.js +165 -0
  123. package/dist/up-role-child.d.ts +1 -0
  124. package/dist/up-role-child.js +77 -0
  125. package/dist/verification-evidence.d.ts +1 -0
  126. package/dist/verification-evidence.js +13 -1
  127. package/dist/worktree.d.ts +4 -0
  128. package/dist/worktree.js +13 -7
  129. package/package.json +2 -1
@@ -1,10 +1,8 @@
1
1
  /** Optional repository context. Callers admit project access; this module grants no authority.
2
2
  * The index is disposable. Every read hashes the current bounded tracked source set;
3
3
  * a missing/changed/unreadable index falls back to ordinary text search, never a task gate. */
4
- import { inReadOnlyRequest } from './read-only-request.js';
5
4
  import { checkoutGitSync } from './teardown-git.js';
6
5
  import { createHash, randomUUID } from 'node:crypto';
7
- import { execFileSync } from 'node:child_process';
8
6
  import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
9
7
  import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
10
8
  import { scanForSecrets } from './evidence.js';
@@ -14,10 +12,10 @@ const CODE = /\.(?:[cm]?[jt]sx?)$/i;
14
12
  const TEXT = /\.(?:[cm]?[jt]sx?|md|txt|css|html|json)$/i;
15
13
  const MAX_NODES = 16_000, MAX_EDGES = 12_000;
16
14
  const sha = (value) => createHash('sha256').update(value).digest('hex');
17
- // Read-only and bounded: no index refresh, no hooks, no fsmonitor program.
18
- // On a page read (Settings -> Knowledge's search), the hardened checkout git: no filter, hook, program or network.
19
- const git = (repo, args) => inReadOnlyRequest() ? checkoutGitSync(repo, args, { timeoutMs: 10_000, maxBuffer: 2_000_000 }).trimEnd()
20
- : execFileSync('git', ['--no-optional-locks', '-c', 'core.fsmonitor=false', '-c', 'core.hooksPath=/dev/null', '-C', repo, ...args], { encoding: 'utf8', maxBuffer: 2_000_000, timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'] }).trimEnd();
15
+ // Read-only and bounded, and always the hardened checkout git (a crew worktree is agent-written, and a page read or a
16
+ // lead's refresh reads it alike): no filter, hook, program or network, its config and info frozen in a view of the
17
+ // plane's own before they're inspected (teardown-git.ts checkoutGitSync).
18
+ const git = (repo, args) => checkoutGitSync(repo, args, { timeoutMs: 10_000, maxBuffer: 2_000_000 }).trimEnd();
21
19
  const inside = (root, path) => { const r = relative(root, path); return r !== '..' && !r.startsWith('..' + sep) && !isAbsolute(r); };
22
20
  function capture(request) {
23
21
  const repo = realpathSync(request.repo);
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { belongsToRelease } from "./release-authority.js";
6
6
  import { run as execRun } from "./exec.js";
7
+ import { hardenedGit } from "./teardown-git.js";
7
8
  import { approvedCommandShell, SETUP_ENV_ALLOWLIST, SETUP_ENV_DENYLIST } from "./builder.js";
8
9
  import { runWithIsolatedDatabase } from "./child-database.js";
9
10
  import { boundStreamHeadTail, readVerifiedArtifact, redactSecretLines, scanForSecrets, storeEvidence } from "./evidence.js";
@@ -161,7 +162,8 @@ export function waitingRequestsOfAnyKind(store, now) {
161
162
  export async function runFollowUpCheck(store, root, request, deps = {}) {
162
163
  const now = deps.now ?? (() => new Date());
163
164
  const runner = deps.runner ?? execRun;
164
- const git = deps.git ?? execRun;
165
+ // The project's repository and a checkout of a build's commit, both agent-writable: the hardened git (teardown-git.ts).
166
+ const git = deps.git ?? hardenedGit(execRun);
165
167
  const asked = store.handle.prepare("SELECT run_id FROM action_ledger WHERE id = ? AND action = ?").get(request, CHECKS_REQUESTED);
166
168
  if (asked === undefined)
167
169
  return null;
@@ -45,6 +45,7 @@ import { createHash } from "node:crypto";
45
45
  import { hasForbiddenControls } from "./decision.js";
46
46
  import { EVIDENCE_CAPS, isBinaryAssetPath, isImagePath, readVerifiedArtifact, redactSecretLines, scanForSecrets, storeEvidence } from "./evidence.js";
47
47
  import { parseProof } from "./proof.js";
48
+ import { noLazyFetch } from "./teardown-git.js";
48
49
  /** Explicit bounds. Content bytes are UTF-8 of the stored (possibly
49
50
  * redacted) text; the aggregate is the sum over every stored item. */
50
51
  export const LEGACY_REVIEW_CONTEXT_LIMITS = {
@@ -468,11 +469,12 @@ export function citesSuppliedProvenance(note, supplied) {
468
469
  return true;
469
470
  return false;
470
471
  }
471
- const GIT_READ = ["--literal-pathspecs", "--no-lazy-fetch", "--no-replace-objects", "--no-optional-locks"];
472
+ /** Read flags: --no-lazy-fetch where git has it (the hardened runner refuses a partial clone where it doesn't). */
473
+ const gitRead = () => ["--literal-pathspecs", ...noLazyFetch(), "-c", "protocol.allow=never", "--no-replace-objects", "--no-optional-locks"];
472
474
  async function treeEntries(git, cwd, revision, paths) {
473
475
  if (paths.length === 0)
474
476
  return new Map();
475
- const listed = await git("git", [...GIT_READ, "ls-tree", "-z", "-l", revision, "--", ...paths], { cwd, maxBuffer: 4 * 1024 * 1024 });
477
+ const listed = await git("git", [...gitRead(), "ls-tree", "-z", "-l", revision, "--", ...paths], { cwd, maxBuffer: 4 * 1024 * 1024 });
476
478
  if (listed.code !== 0)
477
479
  return null;
478
480
  const entries = new Map();
@@ -570,7 +572,7 @@ async function ancestorPatch(store, git, args, inventory, path) {
570
572
  if (!base || !head || !SHA1.test(base) || !SHA1.test(head))
571
573
  return fail(`run #${runId} has no exact patch endpoints`);
572
574
  for (const from of new Set([base, previousHead ?? base])) {
573
- const ancestor = await git("git", [...GIT_READ, "merge-base", "--is-ancestor", from, head], { cwd: args.worktree });
575
+ const ancestor = await git("git", [...gitRead(), "merge-base", "--is-ancestor", from, head], { cwd: args.worktree });
574
576
  if (ancestor.code !== 0)
575
577
  return fail(`run #${runId}'s patch ancestry could not be verified`);
576
578
  }
@@ -780,7 +782,7 @@ export async function deriveReviewContext(store, git, args, lineage) {
780
782
  const sourceCriteria = new Map((sourceScopeVerified ? sourceScope.acceptance : []).map(one => [one.id, one]));
781
783
  // 3. Ancestry: is the source head an ancestor of this head?
782
784
  if (sourceHead !== null) {
783
- const check = await git("git", [...GIT_READ, "merge-base", "--is-ancestor", sourceHead, args.head], { cwd: args.worktree });
785
+ const check = await git("git", [...gitRead(), "merge-base", "--is-ancestor", sourceHead, args.head], { cwd: args.worktree });
784
786
  if (check.code === 0)
785
787
  inventory.ancestry = { verified: true, detail: `${sourceHead} is an ancestor of ${args.head}` };
786
788
  else if (check.code === 1) {
@@ -832,7 +834,7 @@ export async function deriveReviewContext(store, git, args, lineage) {
832
834
  if (generalPaths.length === 0 && sourceScopeVerified) {
833
835
  const paths = sourceScope.touches.filter(path => safeContextPath(path.replace(/\/$/, "")) && !/[?*\[\]]/.test(path));
834
836
  if (paths.length > 0) {
835
- const listed = await git("git", [...GIT_READ, "ls-tree", "-r", "-z", "--name-only", args.head, "--", ...paths], { cwd: args.worktree, maxBuffer: 4 * 1024 * 1024 });
837
+ const listed = await git("git", [...gitRead(), "ls-tree", "-r", "-z", "--name-only", args.head, "--", ...paths], { cwd: args.worktree, maxBuffer: 4 * 1024 * 1024 });
836
838
  if (listed.code === 0)
837
839
  generalPaths.push(...[...new Set(listed.stdout.split("\0").filter(safeContextPath))].sort());
838
840
  else
@@ -949,7 +951,7 @@ export async function deriveReviewContext(store, git, args, lineage) {
949
951
  gap("aggregate-limit", `${entry.size} bytes would cross the ${REVIEW_CONTEXT_LIMITS.aggregateBytes}-byte aggregate limit`);
950
952
  continue;
951
953
  }
952
- const read = await git("git", [...GIT_READ, "cat-file", "blob", entry.object], { cwd: args.worktree, maxBuffer: REVIEW_CONTEXT_LIMITS.itemBytes + 4096 });
954
+ const read = await git("git", [...gitRead(), "cat-file", "blob", entry.object], { cwd: args.worktree, maxBuffer: REVIEW_CONTEXT_LIMITS.itemBytes + 4096 });
953
955
  if (read.code !== 0) {
954
956
  gap("capture-failed", `blob ${entry.object} could not be read (git exit ${read.code})`);
955
957
  continue;
package/dist/runner.js CHANGED
@@ -19,6 +19,7 @@
19
19
  * again. Reaping on the second tells you a *machine* is gone, and everything
20
20
  * it was holding — claims and worktrees alike — needs recovering together.
21
21
  */
22
+ import { mintSecret } from "./secret-mint.js";
22
23
  import { isAcpProvider } from "./acp-agents.js";
23
24
  import { verifyLaunch } from "./acp-attest.js";
24
25
  import { inspectAcpAgent, inspectionWords } from "./acp-inspect.js";
@@ -352,7 +353,7 @@ export function recoverDead(store, now, livenessMs = DEFAULT_LIVENESS_MS) {
352
353
  }
353
354
  /** 32 bytes of randomness, urlsafe, so it survives being pasted anywhere. */
354
355
  function mintToken() {
355
- return randomBytes(32).toString("base64url");
356
+ return mintSecret(32);
356
357
  }
357
358
  export function hashToken(token) {
358
359
  return createHash("sha256").update(token, "utf8").digest("hex");
@@ -63,6 +63,14 @@ export declare const SEATBELT_NO_OTHER_TERMINAL: string;
63
63
  * any program read them (anthropics/sandbox-runtime#755). Build agents keep it (Claude Code signs in from the keychain).
64
64
  */
65
65
  export declare const SEATBELT_NO_KEYCHAIN: string;
66
+ /**
67
+ * First after the profile's default deny: a mach service lookup the profile doesn't allow is still denied, unreported.
68
+ * On macOS 26.5 a reported mach-lookup denial now and then never answers: bootstrap_look_up blocks in launchd for good
69
+ * (seen as npm stuck in Node's DNS setup, dns_configuration_copy → com.apple.SystemConfiguration.DNSConfiguration, about
70
+ * 1 launch in 70 under load; never with the report off). Every allow after it still wins, so what is reachable is the
71
+ * same; only the report goes.
72
+ */
73
+ export declare const SEATBELT_QUIET_MACH_DENY = "(deny mach-lookup (with no-report))";
66
74
  /**
67
75
  * Chromium, a project check's launch only: Playwright's default browser (its Chromium headless shell) registers its
68
76
  * port rendezvous service as org.chromium.Chromium.MachPortRendezvousServer.<its pid> at start, and its helpers look
@@ -122,8 +122,16 @@ export const SEATBELT_NO_OTHER_TERMINAL = [
122
122
  */
123
123
  export const SEATBELT_NO_KEYCHAIN = [
124
124
  "; Toolroll: no keychain",
125
- "(deny mach-lookup (global-name \"com.apple.SecurityServer\") (global-name \"com.apple.securityd.xpc\"))",
125
+ "(deny mach-lookup (with no-report) (global-name \"com.apple.SecurityServer\") (global-name \"com.apple.securityd.xpc\"))",
126
126
  ].join("\n");
127
+ /**
128
+ * First after the profile's default deny: a mach service lookup the profile doesn't allow is still denied, unreported.
129
+ * On macOS 26.5 a reported mach-lookup denial now and then never answers: bootstrap_look_up blocks in launchd for good
130
+ * (seen as npm stuck in Node's DNS setup, dns_configuration_copy → com.apple.SystemConfiguration.DNSConfiguration, about
131
+ * 1 launch in 70 under load; never with the report off). Every allow after it still wins, so what is reachable is the
132
+ * same; only the report goes.
133
+ */
134
+ export const SEATBELT_QUIET_MACH_DENY = "(deny mach-lookup (with no-report))";
127
135
  /**
128
136
  * Chromium, a project check's launch only: Playwright's default browser (its Chromium headless shell) registers its
129
137
  * port rendezvous service as org.chromium.Chromium.MachPortRendezvousServer.<its pid> at start, and its helpers look
@@ -226,11 +234,15 @@ export function withSeatbeltRules(command, rules, expected) {
226
234
  const profile = words[exec + 2];
227
235
  if (!profile.startsWith("(version 1)\n(deny default"))
228
236
  throw shape();
237
+ // Where the default deny's line ends (the profile's end when it is the last line).
238
+ const lineEnd = profile.indexOf("\n", "(version 1)\n".length);
239
+ const opening = lineEnd < 0 ? profile.length : lineEnd;
229
240
  if (!/^\/[A-Za-z0-9_./-]+$/.test(words[exec + 3]))
230
241
  throw shape();
231
242
  if (expected !== undefined && words[exec + 5] !== expected)
232
243
  throw shape();
233
- return srtQuote([...words.slice(0, exec + 2), `${profile}\n${rules}\n`, ...words.slice(exec + 3)]);
244
+ const quiet = `${profile.slice(0, opening)}\n${SEATBELT_QUIET_MACH_DENY}${profile.slice(opening)}`;
245
+ return srtQuote([...words.slice(0, exec + 2), `${quiet}\n${rules}\n`, ...words.slice(exec + 3)]);
234
246
  }
235
247
  /** The runtime's macOS invocation for `file args` run from `cwd`: a command line for /bin/sh. */
236
248
  export function srtMacosCommand(file, args, terms, cwd = process.cwd(), temp) {
package/dist/scope.d.ts CHANGED
@@ -418,7 +418,10 @@ export type ScopeAuthor = "mate" | "coordinator" | "scout" | "lead";
418
418
  /** The authorities a seal can stand on (approval-policy plan §1.5). Anything else stands on nothing. */
419
419
  /** `carried`: a later commit of a release carried under a person's release approval (release-once.md); only the carry
420
420
  * transaction ever writes it. */
421
- export type ApprovalBasis = "password" | "mode" | "chat" | "grant" | "schedule" | "lead" | "carried";
421
+ /** `auto-check` / `auto-green`: a release task the plane approved itself under the project's "Approve automatically when
422
+ * checks pass" (docs/plans/auto-approve-green.md) — `auto-check` lets its check run, `auto-green` is the release
423
+ * approval once CI and that check passed. Only release-auto.ts writes them; neither is ever a person's. */
424
+ export type ApprovalBasis = "password" | "mode" | "chat" | "grant" | "schedule" | "lead" | "carried" | "auto-check" | "auto-green";
422
425
  export declare const APPROVAL_BASES: readonly ApprovalBasis[];
423
426
  export type Approval = {
424
427
  approved: true;
@@ -865,7 +868,7 @@ export declare function releaseTermsSentence(terms: ReleaseTerms): string;
865
868
  export declare function releaseAsksAgainWords(terms: ReleaseTerms): string[];
866
869
  export { personProof } from "./person-proof.js";
867
870
  import { type ReleaseTerms } from "./contracts/release-terms.js";
868
- import { type ReleaseAuthority, type ReleaseCarry } from "./release-authority.js";
871
+ import { type AutoEvidence, type ReleaseAuthority, type ReleaseCarry } from "./release-authority.js";
869
872
  /** Whether the command running now is a machine's: the lead (its token) or a remote API run. Trusted context only. */
870
873
  export declare const machineActing: () => boolean;
871
874
  /** A proof row's detail, either form: the legacy text (`<at> · generation <n> · digest <d>`) or the versioned record
@@ -952,3 +955,29 @@ export declare function mainDebtProblem(store: Store, repo: string, taskId: stri
952
955
  * chat it is approved only through the release card.
953
956
  */
954
957
  export declare function deployGateScope(scope: Pick<Scope, "candidate" | "releaseTerms">): boolean;
958
+ /**
959
+ * The plane's own automatic approval of a release task (docs/plans/auto-approve-green.md), read the way the dispatch
960
+ * belts read it (release-authority.ts autoSealSql): sealed `kind` on the current digest, its evidence row naming exactly
961
+ * this task, kind, digest and candidate, a release task (candidate and terms signed in), the project's setting on now,
962
+ * and the person who turned it on still an approver there. Never a person's approval.
963
+ */
964
+ export declare function autoReleaseApprovalOf(store: Store, taskId: string, kind: "auto-check" | "auto-green"): {
965
+ ok: true;
966
+ by: string;
967
+ at: string;
968
+ evidence: AutoEvidence;
969
+ } | {
970
+ ok: false;
971
+ why: string;
972
+ };
973
+ /** A release's approval: a person's (personApprovalOf, unchanged), or the plane's own `auto-green`. Only release
974
+ * readers ask this (liveReleaseOf, and the deploy gates inside `toolroll release`); everything else asks for a person. */
975
+ export declare function releaseApprovalOf(store: Store, taskId: string, _now?: Date): {
976
+ ok: true;
977
+ by: string;
978
+ at: string;
979
+ method: string;
980
+ } | {
981
+ ok: false;
982
+ why: string;
983
+ };
package/dist/scope.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // The rubric's vocabulary loads before anything that reaches the plan contract (see contracts/acceptance-terms.ts).
2
2
  export { ACCEPTANCE_LIMITS, EVIDENCE_KINDS } from "./contracts/acceptance-terms.js";
3
+ import { mintSecret } from "./secret-mint.js";
3
4
  import { passwordlessOn } from "./passwordless.js";
4
5
  import { currentPasswordSource, passwordGuardOf, provenPasswordAccount } from "./sign-in-guard.js";
5
6
  import { validateScopeText } from "./task-text.js";
@@ -47,10 +48,11 @@ import { z } from "zod";
47
48
  import {} from "./contracts/acceptance-terms.js";
48
49
  import { parseContract } from "./contracts/contract.js";
49
50
  import { readAcceptance, readScopeTerms, storedRubricSchema } from "./contracts/scope.js";
51
+ import { releaseCiHoldOf, releaseCiHoldWords } from "./release-ci-hold.js";
50
52
  import { NO_READINESS, exactModelId, legOf, projectRoute, routeDigestOf, routeFromJson, routeJsonProblem, routeWords, } from "./phase-routing.js";
51
53
  /** Same shape as a runner's credential, for the same reasons. */
52
54
  function mintToken() {
53
- return randomBytes(32).toString("base64url");
55
+ return mintSecret(32);
54
56
  }
55
57
  export function hashToken(token) {
56
58
  return createHash("sha256").update(token, "utf8").digest("hex");
@@ -571,7 +573,7 @@ export function canonicalAcceptance(criteria) {
571
573
  .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
572
574
  .map(c => ({ id: c.id, statement: c.statement.trim(), evidence: [...c.evidence].sort() }));
573
575
  }
574
- export const APPROVAL_BASES = ["password", "mode", "chat", "grant", "schedule", "lead", "carried"];
576
+ export const APPROVAL_BASES = ["password", "mode", "chat", "grant", "schedule", "lead", "carried", "auto-check", "auto-green"];
575
577
  /**
576
578
  * The exact bytes an operator agreed to.
577
579
  *
@@ -1098,6 +1100,10 @@ export function approve(store, taskId, by, now, sawDigest, token, mutation = {})
1098
1100
  const releaseProblem = scope.releaseTerms == null ? null : releaseSealProblem(store, scope.releaseTerms);
1099
1101
  if (releaseProblem !== null)
1100
1102
  return { ok: false, reason: "policy", message: releaseProblem };
1103
+ // Filed while CI still ran: nobody approves it before the plane read exact-head CI passing (round 1, finding 4).
1104
+ const ciHold = releaseCiHoldOf(store, taskId);
1105
+ if (ciHold !== null)
1106
+ return { ok: false, reason: "policy", message: releaseCiHoldWords(ciHold) };
1101
1107
  const gate = store.approvalGate(taskId, by, "person");
1102
1108
  if (gate.verdict === "refuse")
1103
1109
  return { ok: false, reason: gate.reason };
@@ -1359,7 +1365,7 @@ function describeApproval(approval) {
1359
1365
  export { personProof } from "./person-proof.js";
1360
1366
  import { personProof } from "./person-proof.js";
1361
1367
  import { releaseTermsDigest } from "./contracts/release-terms.js";
1362
- import { CHECK_ACCESS_WIDTH, carriesUnsandboxed, newestReleaseGeneration, releaseAuthorityOfTask, releaseSealProblem } from "./release-authority.js";
1368
+ import { CHECK_ACCESS_WIDTH, autoEvidenceOf, carriesUnsandboxed, newestReleaseGeneration, releaseAuthorityOfTask, releaseAutoApproves, releaseSealProblem } from "./release-authority.js";
1363
1369
  /** Whether the command running now is a machine's: the lead (its token) or a remote API run. Trusted context only. */
1364
1370
  export const machineActing = () => currentActor()?.lead === true || activeRemote() !== null;
1365
1371
  /** A proof row's detail, either form: the legacy text (`<at> · generation <n> · digest <d>`) or the versioned record
@@ -1400,6 +1406,8 @@ export function personApprovalOf(store, taskId) {
1400
1406
  const basis = scope.approvalBasis ?? null;
1401
1407
  if (basis === "lead")
1402
1408
  return { ok: false, why: "approved by the lead, not by a person" };
1409
+ if (basis === "auto-check" || basis === "auto-green")
1410
+ return { ok: false, why: "approved automatically, not by a person" };
1403
1411
  if (basis !== "password" && basis !== "chat")
1404
1412
  return { ok: false, why: `approved by ${basis === null ? "an unknown road" : basis === "grant" ? "a flow" : basis}, not by a person` };
1405
1413
  const origin = sealOriginOf(store, taskId, scope.approvedBy, scope.approvedDigest, scope.approvedAt);
@@ -1474,7 +1482,8 @@ export function liveReleaseOf(store, taskId, action, now = new Date()) {
1474
1482
  if (a.tipTask !== taskId)
1475
1483
  return refuse("this is not the release's current commit");
1476
1484
  const anchor = store.getScope(a.anchorTask);
1477
- const person = personApprovalOf(store, a.anchorTask);
1485
+ // A person's yes, or the plane's own automatic one while the project still approves releases automatically.
1486
+ const person = releaseApprovalOf(store, a.anchorTask, now);
1478
1487
  if (!person.ok)
1479
1488
  return refuse(`the release approval no longer stands: ${person.why}`);
1480
1489
  if (anchor === null || anchor.approvedDigest !== a.anchorDigest || anchor.digest !== a.anchorDigest)
@@ -1539,3 +1548,41 @@ export function mainDebtProblem(store, repo, taskId) {
1539
1548
  export function deployGateScope(scope) {
1540
1549
  return (scope.candidate ?? null) !== null || (scope.releaseTerms ?? null) !== null;
1541
1550
  }
1551
+ /**
1552
+ * The plane's own automatic approval of a release task (docs/plans/auto-approve-green.md), read the way the dispatch
1553
+ * belts read it (release-authority.ts autoSealSql): sealed `kind` on the current digest, its evidence row naming exactly
1554
+ * this task, kind, digest and candidate, a release task (candidate and terms signed in), the project's setting on now,
1555
+ * and the person who turned it on still an approver there. Never a person's approval.
1556
+ */
1557
+ export function autoReleaseApprovalOf(store, taskId, kind) {
1558
+ const scope = store.getScope(taskId);
1559
+ const ref = store.lookupRef(taskId);
1560
+ if (scope === null || scope.approvedAt === null || scope.approvedBy === null || scope.approvedDigest === null)
1561
+ return { ok: false, why: "not approved yet" };
1562
+ if (scope.approvedDigest !== scope.digest)
1563
+ return { ok: false, why: "the approval is for different scope text" };
1564
+ if (scope.approvalBasis !== kind)
1565
+ return { ok: false, why: kind === "auto-green" ? "not approved automatically" : "not checking automatically" };
1566
+ if ((scope.candidate ?? null) === null || (scope.releaseTerms ?? null) === null || ref === null || ref.repo === null)
1567
+ return { ok: false, why: "only a release task is approved automatically" };
1568
+ const evidence = autoEvidenceOf(store, taskId, scope.modeDigest, kind);
1569
+ if (evidence === null || evidence.digest !== scope.approvedDigest || evidence.candidate !== scope.candidate || evidence.by !== scope.approvedBy)
1570
+ return { ok: false, why: "no record shows what approved it automatically" };
1571
+ if (!releaseAutoApproves(store, ref.repo))
1572
+ return { ok: false, why: "approving releases automatically was turned off" };
1573
+ const account = store.accountOf(scope.approvedBy);
1574
+ if (account === null || account.revokedAt !== null || account.role !== "approver" || !store.accountCanAccess(scope.approvedBy, ref.repo))
1575
+ return { ok: false, why: `${scope.approvedBy}, who turned on automatic release approval, can no longer approve in this project` };
1576
+ return { ok: true, by: scope.approvedBy, at: scope.approvedAt, evidence };
1577
+ }
1578
+ /** A release's approval: a person's (personApprovalOf, unchanged), or the plane's own `auto-green`. Only release
1579
+ * readers ask this (liveReleaseOf, and the deploy gates inside `toolroll release`); everything else asks for a person. */
1580
+ export function releaseApprovalOf(store, taskId, _now = new Date()) {
1581
+ const person = personApprovalOf(store, taskId);
1582
+ if (person.ok)
1583
+ return person;
1584
+ if (store.getScope(taskId)?.approvalBasis !== "auto-green")
1585
+ return person;
1586
+ const auto = autoReleaseApprovalOf(store, taskId, "auto-green");
1587
+ return auto.ok ? { ok: true, by: auto.by, at: auto.at, method: "auto-green" } : auto;
1588
+ }
package/dist/scout.js CHANGED
@@ -18,6 +18,7 @@ import { mkdtempSync, realpathSync, rmSync, unlinkSync } from "node:fs";
18
18
  import { homedir, tmpdir } from "node:os";
19
19
  import { dirname, join, relative, isAbsolute } from "node:path";
20
20
  import { run } from "./exec.js";
21
+ import { checkoutGit } from "./teardown-git.js";
21
22
  import { auditOf } from "./provider.js";
22
23
  import { currentClaim, heartbeat } from "./claim.js";
23
24
  import { heartbeat as runnerHeartbeat } from "./runner.js";
@@ -225,7 +226,10 @@ export async function scout(store, request) {
225
226
  }
226
227
  }
227
228
  async function scoutWith(store, request, outputDir) {
228
- const { taskId, taskRef, runner, worktree, branch, now, agent, git = run, timeoutMs = DEFAULT_SCOUT_TIMEOUT_MS, maxTurns = DEFAULT_SCOUT_TURNS, } = request;
229
+ const { taskId, taskRef, runner, worktree, branch, now, agent, git: givenGit = run, timeoutMs = DEFAULT_SCOUT_TIMEOUT_MS, maxTurns = DEFAULT_SCOUT_TURNS, } = request;
230
+ // The plane's git on the scout's checkout (agent-written, and its repository shared with every build) runs hardened, against
231
+ // a frozen view of the repository's config and info (teardown-git.ts checkoutGit); an injected runner is used as given.
232
+ const git = givenGit === run ? checkoutGit(run, "build", { repo: store.getWorktree(worktree)?.repo ?? null }) : givenGit;
229
233
  const claim = currentClaim(store, taskRef, now);
230
234
  if (claim === null || claim.runner !== runner || claim.leaseId !== request.leaseId) {
231
235
  return { ok: false, kind: "failure", reason: "not-yours", message: `${taskId} is not held under ${request.leaseId}` };
@@ -0,0 +1 @@
1
+ export declare function mintSecret(bytes: number, draw?: (size: number) => Buffer): string;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Secrets a person types or pastes on a command line (passwords, invite and approver tokens, runner credentials):
3
+ * random base64url, never starting with "-". A value starting with "--" reads as a flag to many parsers (`--token
4
+ * --abc…` was refused as a missing value: about 1 in 4096 minted passwords), so it is drawn again. The CLI accepts one
5
+ * anyway (an older secret), but a new one never needs that.
6
+ */
7
+ import { randomBytes } from "node:crypto";
8
+ export function mintSecret(bytes, draw = randomBytes) {
9
+ for (;;) {
10
+ const secret = draw(bytes).toString("base64url");
11
+ if (!secret.startsWith("-"))
12
+ return secret;
13
+ }
14
+ }
package/dist/serve.js CHANGED
@@ -14,19 +14,15 @@ import { signInsUnavailableWords } from "./check-access.js";
14
14
  import { LIVE_VIEW_CONSENT, liveCommandsShown, liveViewConsentDigest } from "./live-detail.js";
15
15
  import { html, htmlString, jsonScript, textHtml } from "./html.js";
16
16
  import { browserAssetsAvailable, browserWorkspaceDocument } from './browser-shell.js';
17
- import { NO_COUNTS, projectStates, STATE_WORDS } from './agent-state.js';
17
+ import { accessKey, countsKey, indexKey, projectSummaryOf, RevisionCache, summaryKey } from './console-chrome.js';
18
18
  import { usageChipOf } from './limits-ui.js';
19
- /** Tasks the rail lists under a project; the rest are a count that links to the project's tasks. */
20
- const RAIL_TASKS = 6;
21
19
  /** Any header a proxy adds: a request carrying one is never "from this computer" (password freshness, one-time links). */
22
20
  const PROXY_HEADER = /^(forwarded|via|x-forwarded-.+|x-real-ip|x-client-ip|x-original-.+|x-cluster-client-ip|true-client-ip|cf-connecting-ip|fastly-client-ip|x-proxyuser-ip)$/i;
23
- /** Asks a project names for the chime (its count is complete beyond them). */
24
- const ASKS_PER_PROJECT = 100;
25
21
  /** How often the background checks look at what is due (docs/plans/get-side-effects.md), and how often this
26
22
  * computer's agent sign-ins are asked again. */
27
23
  const BACKGROUND_CHECKS_MS = 30_000;
28
24
  const LOCAL_AGENTS_EVERY_MS = 2 * 60_000;
29
- import { boundProjectSummary, browserCrewFromIndex, browserCrewOf, browserNavigationOf, browserProjectsOf, needsYouLabelOf } from './browser-workspace.js';
25
+ import { browserCrewFromIndex, browserCrewOf, browserNavigationOf, browserProjectsOf, needsYouLabelOf } from './browser-workspace.js';
30
26
  import { sharedActionPayload } from './chat-actions.js';
31
27
  import { prepareCodingContext } from './coding-context.js';
32
28
  import { CodingWorkspace } from './coding-workspace.js';
@@ -255,6 +251,9 @@ export function createDecisionServer(options) {
255
251
  };
256
252
  };
257
253
  const workspaceRevision = prepareWorkspaceRevision(store);
254
+ // The sidebar, the Tasks count and the crew: read at most once per workspace revision per reader's exact access
255
+ // (console-chrome.ts), and filled from a read worker before a page renders when one is attached.
256
+ const chromeReads = new RevisionCache(workspaceRevision, clock);
258
257
  const workspaceIncarnation = randomBytes(16).toString('hex');
259
258
  const workspaceValidators = new WorkspaceValidatorCache();
260
259
  const providerHome = options.connectionHome ?? homedir();
@@ -881,6 +880,8 @@ export function createDecisionServer(options) {
881
880
  get fresh() { return freshFacts.getStore() != null && ssoNowAvailable(who.name); } } : undefined,
882
881
  refusal: (answer, status, body) => sendScreen(answer, status, screen(status === 404 ? "Not found" : "Request refused", body, { chrome: chromeFor(who.via === "cookie" ? who.session.project : null, "work") })),
883
882
  workspaceRead,
883
+ freshWork: ["request", "task", "result"].some(name => url.searchParams.has(name)),
884
+ ...(workspaceRead && typeof request.headers['if-none-match'] === 'string' ? { workspaceIfNoneMatch: request.headers['if-none-match'] } : {}),
884
885
  workspaceRequest: url.searchParams.get('request'),
885
886
  };
886
887
  if (method === "GET" || method === "POST")
@@ -935,6 +936,16 @@ export function createDecisionServer(options) {
935
936
  facts.workspaceValidator = { key, revision, expiresAt,
936
937
  etag: '"' + createHash('sha256').update(`${key}:${revision}:${expiresAt}`).digest('hex') + '"' };
937
938
  }
939
+ await prefetchChrome(route, url, who);
940
+ // The worker wait lets a sign-out, credential/access change or console ceiling change land. Prove the
941
+ // cookie and route again before rendering protected content or answering with a content validator.
942
+ if (who.via === 'cookie') {
943
+ const current = identify(request, false);
944
+ if (current?.via !== 'cookie' || current.session !== who.session)
945
+ return redirect(response, loginHref(url.pathname + url.search));
946
+ if (!projectRequestAllowed(route, route.project, url, who, request, response, null))
947
+ return;
948
+ }
938
949
  return handleGet(route, url, who, request, response);
939
950
  }
940
951
  return handlePost(route, url, who, request, response, body);
@@ -1480,7 +1491,10 @@ export function createDecisionServer(options) {
1480
1491
  const project = s.chrome.active === 'chat' ? null : s.chrome.project;
1481
1492
  crew = extras.team?.tasks ? browserCrewFromIndex(extras.team.tasks, extras.team.selected?.id) : requestFacts.workCrew?.project === project ? browserCrewFromIndex(requestFacts.workCrew.page)
1482
1493
  // The reader's own lead's claims read "<name> is on it" here too.
1483
- : browserCrewOf(store, clock(), { principal: 'operator', repos: managedRepos(), includeUnplaced: false, viewer: requestFacts.actor ?? null }, { evidenceRoot, project });
1494
+ : (() => {
1495
+ const access = { principal: 'operator', repos: managedRepos(), includeUnplaced: false, viewer: requestFacts.actor ?? null };
1496
+ return chromeRead(`crew\u0000${accessKey(access)}\u0000${project ?? ''}`, () => browserCrewOf(store, clock(), access, { evidenceRoot, project }));
1497
+ })();
1484
1498
  }
1485
1499
  catch {
1486
1500
  notices.push('Crew updates are unavailable. Open Tasks to inspect saved work.');
@@ -1586,7 +1600,17 @@ export function createDecisionServer(options) {
1586
1600
  workspaceValidators.set(validator.key, validator);
1587
1601
  response.setHeader('ETag', validator.etag);
1588
1602
  }
1589
- return respond(response, status, 'application/json; charset=utf-8', JSON.stringify(workspace));
1603
+ const body = JSON.stringify(workspace);
1604
+ // Every other page's re-read after a live `change` (chat keeps its own validator, and its receipt, task and
1605
+ // result reads none): when what it renders now is exactly what the page holds, it hears 304 and keeps its view
1606
+ // (no transfer, no re-render).
1607
+ if (status === 200 && validator === undefined && path.pathname !== '/chat') {
1608
+ const etag = '"w' + createHash('sha256').update(body).digest('base64url') + '"';
1609
+ response.setHeader('ETag', etag);
1610
+ if (requestFacts.workspaceIfNoneMatch === etag)
1611
+ return respond(response, 304, 'application/json; charset=utf-8', '');
1612
+ }
1613
+ return respond(response, status, 'application/json; charset=utf-8', body);
1590
1614
  }
1591
1615
  const nonce = randomBytes(16).toString('base64');
1592
1616
  // React owns the conversation and its refresh/draft lifecycle. Native
@@ -1623,8 +1647,9 @@ export function createDecisionServer(options) {
1623
1647
  return page(response, status, document, nonce, s.functional?.fetches === true || chromeLayer || sensitiveChrome !== "");
1624
1648
  }
1625
1649
  /** The needs-you count every surface wears, read from this request's own
1626
- * Work counts (one query per request, never a cached earlier one), so a
1627
- * live page's badge and its Needs you tab always agree. */
1650
+ * Work counts (one read per request, kept only while the workspace revision
1651
+ * it was read at stands), so a live page's badge and its Needs you tab
1652
+ * always agree. */
1628
1653
  function needsYouBadge(project) {
1629
1654
  return needsYouCount(project);
1630
1655
  }
@@ -1653,44 +1678,8 @@ export function createDecisionServer(options) {
1653
1678
  function projectSummary() {
1654
1679
  const projects = sidebarProjects();
1655
1680
  const actor = requestContext.getStore()?.actor ?? '';
1656
- const states = new Map(projectStates(store, workAccess(), actor, clock(), evidenceRoot, projects.map(one => one.path)).map(one => [one.repo, one]));
1657
- // A row that needs a person or failed says what the Tasks list says it needs (its own next step); a row at work says
1658
- // what its agent did last; a finished one says Done.
1659
- const asking = [...states.values()].flatMap(one => one.families.slice(0, RAIL_TASKS)).filter(one => one.state === 'needs-you' || one.state === 'failed').map(one => one.rootId);
1660
- const needWords = new Map();
1661
- if (asking.length > 0) {
1662
- try {
1663
- // Every row the rail may list, in pages the index allows.
1664
- for (let at = 0; at < asking.length; at += WORK_INDEX_MAX_LIMIT)
1665
- for (const item of workIndexPage(store, clock(), workAccess(), { roots: asking.slice(at, at + WORK_INDEX_MAX_LIMIT), limit: WORK_INDEX_MAX_LIMIT, root: evidenceRoot }).items)
1666
- needWords.set(item.rootId, item.status.detail !== '' ? item.status.detail : item.status.label);
1667
- }
1668
- catch { /* the rows keep their state's words */ }
1669
- }
1670
- // The chime's subjects: an opaque key per obligation and version, scoped to this reader (no words, no ids in it).
1671
- const keyOf = (...parts) => createHash('sha256').update([actor, ...parts].join('\u0000')).digest('base64url').slice(0, 22);
1672
- // Newest first, so a new ask always has a key however many older ones wait (those were heard when they were new).
1673
- const asksOf = (state) => [
1674
- ...(state?.families ?? []).flatMap(one => one.ask === undefined ? [] : [{ at: one.ask.at, ask: { key: keyOf('task', one.rootId, one.ask.version), task: one.rootId, run: one.ask.run, at: one.ask.at } }]),
1675
- ...(state?.flowAsks ?? []).map(one => ({ at: one.at, ask: { key: keyOf('flow', one.card, one.stage, one.entry), task: null, run: null, at: one.at } })),
1676
- ].sort((a, b) => b.at.localeCompare(a.at)).slice(0, ASKS_PER_PROJECT).map(one => one.ask);
1677
- const railOf = (state) => {
1678
- const families = state?.families ?? [];
1679
- const tasks = families.slice(0, RAIL_TASKS).flatMap((one) => {
1680
- const task = store.getTask(one.rootId);
1681
- const line = one.state === 'needs-you' || one.state === 'failed' ? needWords.get(one.rootId) ?? null : one.line;
1682
- return task === null ? [] : [{ id: one.rootId, title: task.title, href: `/t/${encodeURIComponent(one.rootId)}`, state: one.state, line: line ?? STATE_WORDS[one.state] }];
1683
- });
1684
- return { tasks, more: Math.max(0, families.length - RAIL_TASKS), asks: asksOf(state) };
1685
- };
1686
- const rows = projects.map(one => {
1687
- const state = states.get(one.path);
1688
- return { path: one.path, name: one.name, href: `/work?project=${encodeURIComponent(one.path)}`, state: state?.state ?? 'idle', counts: state?.counts ?? { ...NO_COUNTS }, ...railOf(state) };
1689
- });
1690
- const unplaced = states.get(null);
1691
- if (unplaced !== undefined && unplaced.state !== 'idle')
1692
- rows.push({ path: null, name: 'No project', href: '/work', state: unplaced.state, counts: unplaced.counts, ...railOf(unplaced) });
1693
- return boundProjectSummary(rows);
1681
+ const access = workAccess();
1682
+ return chromeRead(summaryKey(actor, access, projects), () => projectSummaryOf(store, clock(), access, actor, projects, evidenceRoot));
1694
1683
  }
1695
1684
  /** What one account may see now, as the summary room keys it: a narrowed ceiling or grant is a different key, so a
1696
1685
  * snapshot read for the wider scope never reaches it. Null: no such account. */
@@ -1777,13 +1766,15 @@ export function createDecisionServer(options) {
1777
1766
  ...(scope === undefined ? {} : { scope }),
1778
1767
  };
1779
1768
  }
1780
- // All project counts are read together. No task artifacts or processes are
1781
- // inspected to paint a badge or a project switcher.
1769
+ // All project counts are read together, at most once per workspace revision
1770
+ // for this reader's access. No task artifacts or processes are inspected to
1771
+ // paint a badge or a project switcher.
1782
1772
  function projectCounts(now) {
1783
1773
  const facts = requestContext.getStore();
1784
1774
  if (facts?.workCounts !== undefined)
1785
1775
  return facts.workCounts;
1786
- const counts = workCountsByProject(store, now, workAccess());
1776
+ const access = workAccess();
1777
+ const counts = chromeRead(countsKey(access), () => workCountsByProject(store, now, access));
1787
1778
  if (facts !== undefined)
1788
1779
  facts.workCounts = counts;
1789
1780
  return counts;
@@ -1793,6 +1784,64 @@ export function createDecisionServer(options) {
1793
1784
  .reduce((sum, one) => sum + one.totals['needs-you'], 0);
1794
1785
  return { count, saturated: false };
1795
1786
  }
1787
+ /** One work index page for an already-admitted reader, through the chrome cache: a whole view the chat landing reads
1788
+ * on every refresh. */
1789
+ function sharedIndexPage(access, options) {
1790
+ // Receipt reconciliation, task and result reads always read work afresh (disk state included).
1791
+ if (requestContext.getStore()?.freshWork === true)
1792
+ return workIndexPage(store, clock(), access, options);
1793
+ return chromeRead(indexKey(access, options), () => workIndexPage(store, clock(), access, options));
1794
+ }
1795
+ /** A chrome read for this request: what it already read (its prefetch included) stands for the whole request, else
1796
+ * the revision cache's value, else `compute()` now. */
1797
+ function chromeRead(key, compute) {
1798
+ const facts = requestContext.getStore();
1799
+ const held = facts?.chromeReads?.get(key);
1800
+ if (held !== undefined)
1801
+ return held;
1802
+ const value = chromeReads.read(key, compute);
1803
+ if (facts !== undefined)
1804
+ (facts.chromeReads ??= new Map()).set(key, value);
1805
+ return value;
1806
+ }
1807
+ /** The chat landing's three whole views (chat.ts chatHomeOf), as it asks for them. */
1808
+ const chatHomeViews = () => ["all", "needs-you", "completed"]
1809
+ .map(view => ({ view, limit: WORK_INDEX_MAX_LIMIT, ...(evidenceRoot === undefined ? {} : { root: evidenceRoot }) }));
1810
+ /** Pages that wear the chrome (the sidebar's states and the Tasks count). */
1811
+ const CHROME_PAGES = new Set(["home", "work", "board", "agents", "agents.live", "task.page", "run.page", "chat.page", "review", "tasks", "inbox",
1812
+ "next", "done", "projects.page", "flows.page", "flow.page", "settings.page", "decision.page", "menu"]);
1813
+ /** Before a chromed page renders: its chrome reads (and the chat landing's views) run on a read worker, so the
1814
+ * request thread finds them cached instead of computing them. The reader is the one this request admitted; the keys
1815
+ * carry its exact access (console-chrome.ts). Without a worker, or when one fails, the page computes them as before. */
1816
+ async function prefetchChrome(route, url, who) {
1817
+ if (reads === null || who.via !== "cookie" || !CHROME_PAGES.has(route.id))
1818
+ return;
1819
+ try {
1820
+ const actor = requestContext.getStore()?.actor ?? "";
1821
+ const access = workAccess(), projects = sidebarProjects(), now = clock();
1822
+ // Populate only the revision cache. Each answer must still pass its revision/expiry check when rendering:
1823
+ // another job can remain pending while an earlier answer's approval or Needs-you state changes.
1824
+ const fill = async (key, read) => { await chromeReads.fill(key, read); };
1825
+ const jobs = [
1826
+ fill(countsKey(access), () => reads.workCounts(now, access)),
1827
+ fill(summaryKey(actor, access, projects), () => reads.projectSummary(now, access, actor, projects, evidenceRoot)),
1828
+ ];
1829
+ if (route.id === "chat.page") {
1830
+ if (!["project", "room", "team"].some(name => url.searchParams.has(name)) && requestContext.getStore()?.freshWork !== true)
1831
+ for (const options of chatHomeViews())
1832
+ jobs.push(fill(indexKey(access, options), () => reads.workIndexPage(now, access, options)));
1833
+ // The conversation's briefing (chat.ts: the fleet snapshot's states), read as chat reads it.
1834
+ const repos = managedRepos();
1835
+ if (repos.length > 0 && requestContext.getStore()?.freshWork !== true) {
1836
+ const fleet = { principal: "operator", repos, includeUnplaced: false, viewer: who.name };
1837
+ const options = { limit: 100, ...(evidenceRoot === undefined ? {} : { root: evidenceRoot }) };
1838
+ jobs.push(fill(indexKey(fleet, options), () => reads.workIndexPage(now, fleet, options)));
1839
+ }
1840
+ }
1841
+ await Promise.all(jobs);
1842
+ }
1843
+ catch { /* the page computes what is missing */ }
1844
+ }
1796
1845
  /** A live page's own beat without a docked chat: 10 s while any admitted task is building, else 30 s. */
1797
1846
  function liveRefreshSeconds() {
1798
1847
  return projectCounts(clock()).some(one => one.totals.running > 0) ? 10 : 30;
@@ -2073,6 +2122,7 @@ export function createDecisionServer(options) {
2073
2122
  get projectFamilyPeek() { return projectFamilyPeek; },
2074
2123
  get needsYouBadge() { return needsYouBadge; },
2075
2124
  get liveRefreshSeconds() { return liveRefreshSeconds; },
2125
+ get sharedIndexPage() { return sharedIndexPage; },
2076
2126
  get chatKeyFor() { return chatKeyFor; },
2077
2127
  get chatCatalog() { return chatCatalog; },
2078
2128
  get chatCatalogKnown() { return chatCatalogKnown; },