toolroll 0.9.74 → 0.9.76

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 (119) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/dist/agent-fence.js +8 -0
  3. package/dist/approval-authority.js +6 -2
  4. package/dist/approval-rules-ui.d.ts +11 -0
  5. package/dist/approval-rules-ui.js +13 -1
  6. package/dist/assignment.d.ts +3 -0
  7. package/dist/assignment.js +27 -8
  8. package/dist/batch-checks.js +3 -1
  9. package/dist/browser/workspace.js +27 -27
  10. package/dist/browser-workspace.d.ts +1 -1
  11. package/dist/builder.js +1 -1
  12. package/dist/chat-acceptance.d.ts +4 -1
  13. package/dist/chat-acceptance.js +13 -3
  14. package/dist/cli.js +1 -1
  15. package/dist/coding-handoff.js +4 -3
  16. package/dist/coding-ui.js +2 -2
  17. package/dist/coding-workspace.js +23 -9
  18. package/dist/contracts/console-api.d.ts +6 -1
  19. package/dist/contracts/console-api.js +11 -0
  20. package/dist/control-ui.d.ts +3 -2
  21. package/dist/control-ui.js +7 -2
  22. package/dist/demo.js +5 -3
  23. package/dist/deploy-gate.d.ts +3 -0
  24. package/dist/deploy-gate.js +3 -2
  25. package/dist/desktop-update.d.ts +4 -1
  26. package/dist/desktop-update.js +43 -9
  27. package/dist/discover.js +6 -1
  28. package/dist/first-run.d.ts +8 -5
  29. package/dist/first-run.js +42 -7
  30. package/dist/flow-card-task.js +3 -2
  31. package/dist/flow-unattended.d.ts +2 -0
  32. package/dist/flow-unattended.js +12 -1
  33. package/dist/git-incident.d.ts +49 -0
  34. package/dist/git-incident.js +69 -0
  35. package/dist/git-views.d.ts +20 -0
  36. package/dist/git-views.js +120 -0
  37. package/dist/grant.js +3 -1
  38. package/dist/integrations.d.ts +4 -3
  39. package/dist/integrations.js +5 -6
  40. package/dist/lead-contract.d.ts +1 -1
  41. package/dist/lead-contract.js +2 -2
  42. package/dist/lead-follow.js +3 -1
  43. package/dist/openrouter-models.d.ts +5 -2
  44. package/dist/openrouter-models.js +7 -4
  45. package/dist/operate.js +9 -1
  46. package/dist/planner.js +5 -1
  47. package/dist/project-knowledge.js +1 -1
  48. package/dist/project-learning.js +65 -7
  49. package/dist/project-tools.js +19 -1
  50. package/dist/pull-request-flow.js +4 -0
  51. package/dist/pull-request-ui.d.ts +1 -1
  52. package/dist/pull-request-ui.js +5 -1
  53. package/dist/push.d.ts +2 -0
  54. package/dist/push.js +24 -19
  55. package/dist/read-only-request.d.ts +4 -0
  56. package/dist/read-only-request.js +15 -0
  57. package/dist/release-adapters.d.ts +24 -2
  58. package/dist/release-adapters.js +136 -10
  59. package/dist/release-authority.d.ts +46 -0
  60. package/dist/release-authority.js +64 -0
  61. package/dist/release-auto.d.ts +153 -0
  62. package/dist/release-auto.js +496 -0
  63. package/dist/release-cli.js +56 -2
  64. package/dist/release-coverage.js +1 -1
  65. package/dist/release.d.ts +105 -1
  66. package/dist/release.js +229 -9
  67. package/dist/repository-context.js +5 -2
  68. package/dist/result-follow-ups.js +23 -2
  69. package/dist/result-review.js +5 -1
  70. package/dist/scope.d.ts +31 -2
  71. package/dist/scope.js +44 -3
  72. package/dist/scout.js +5 -1
  73. package/dist/serve.js +162 -6
  74. package/dist/server/agents-live-filed.d.ts +17 -0
  75. package/dist/server/agents-live-filed.js +97 -0
  76. package/dist/server/agents-live-room.js +4 -1
  77. package/dist/server/chat.js +31 -23
  78. package/dist/server/http.d.ts +5 -0
  79. package/dist/server/last-checks.d.ts +17 -0
  80. package/dist/server/last-checks.js +22 -0
  81. package/dist/server/pages.js +18 -4
  82. package/dist/server/people-tokens.js +7 -4
  83. package/dist/server/render-pages.d.ts +2 -1
  84. package/dist/server/render-pages.js +28 -24
  85. package/dist/server/render-results.d.ts +11 -2
  86. package/dist/server/render-results.js +54 -12
  87. package/dist/server/render-settings.js +3 -3
  88. package/dist/server/render-tasks.d.ts +10 -0
  89. package/dist/server/render-tasks.js +30 -17
  90. package/dist/server/route-table.js +17 -1
  91. package/dist/server/runtime.d.ts +16 -1
  92. package/dist/server/settings.js +111 -31
  93. package/dist/server/task-views.js +8 -1
  94. package/dist/server/tasks.js +11 -2
  95. package/dist/storage-ui.d.ts +2 -0
  96. package/dist/storage-ui.js +1 -1
  97. package/dist/storage.js +2 -1
  98. package/dist/store.d.ts +4 -1
  99. package/dist/store.js +52 -13
  100. package/dist/subagent-work.js +3 -0
  101. package/dist/task-base.js +1 -1
  102. package/dist/task-status.d.ts +28 -1
  103. package/dist/task-status.js +93 -11
  104. package/dist/teardown-git.d.ts +59 -5
  105. package/dist/teardown-git.js +1022 -90
  106. package/dist/telegram-progress.js +4 -4
  107. package/dist/telegram-status.js +8 -3
  108. package/dist/test-infra.d.ts +26 -0
  109. package/dist/test-infra.js +321 -0
  110. package/dist/tool-secret-store.js +8 -0
  111. package/dist/toolroll-update-ui.d.ts +2 -0
  112. package/dist/toolroll-update-ui.js +4 -3
  113. package/dist/toolroll-update.js +10 -9
  114. package/dist/work-index.js +29 -12
  115. package/dist/workspace-ui.d.ts +5 -0
  116. package/dist/workspace-ui.js +38 -10
  117. package/dist/worktree.d.ts +4 -0
  118. package/dist/worktree.js +13 -7
  119. package/package.json +1 -1
@@ -276,7 +276,7 @@ export type BrowserSettingsView = {
276
276
  connection: {
277
277
  words: string;
278
278
  facts: string;
279
- checkHref: string;
279
+ checkAction: string;
280
280
  } | null;
281
281
  usage: string;
282
282
  envName: string;
package/dist/builder.js CHANGED
@@ -255,7 +255,7 @@ const BRANCH_QUERIES = [
255
255
  function buildCheckoutGit(given, worktree, mainCheckout = null) {
256
256
  if (given !== run)
257
257
  return given;
258
- const hardened = checkoutGit(given);
258
+ const hardened = checkoutGit(given, "build", { repo: mainCheckout });
259
259
  const real = (path) => { try {
260
260
  return realpathSync(resolve(path));
261
261
  }
@@ -4,12 +4,15 @@
4
4
  import type { Store } from "./store.js";
5
5
  import type { VerifiedApprover } from "./principal.js";
6
6
  import { type CriterionMatrixRow } from "./proof.js";
7
+ import { type ApprovedCheckState } from "./task-status.js";
7
8
  export declare const ACCEPTANCE_PAGE_SIZE = 3;
9
+ /** `check`: the approved check read now (task-status.ts approvedCheckOf); undefined when this read had no saved files,
10
+ * and then a verified result claims nothing about its check. */
8
11
  export declare function acceptanceStatus(proof: {
9
12
  verdict: string;
10
13
  reasons: readonly string[];
11
14
  matrix: readonly CriterionMatrixRow[];
12
- } | null, accepted: boolean): string;
15
+ } | null, accepted: boolean, check?: ApprovedCheckState | null): string;
13
16
  export declare function readAcceptanceEvidence(store: Store, who: VerifiedApprover, root: string | undefined, task: string, runId?: number, offset?: number): {
14
17
  ok: false;
15
18
  message: string;
@@ -1,12 +1,19 @@
1
1
  import { readVerifiedArtifact, readVerifiedProofForRun, scanForSecrets } from "./evidence.js";
2
2
  import { verificationEvidence } from "./verification-evidence.js";
3
3
  import { manualReviewOnly } from "./proof.js";
4
+ import { assignmentChecksForRun } from "./assignment.js";
5
+ import { approvedCheckOf, approvedCheckStateOf, checksFactOf } from "./task-status.js";
4
6
  export const ACCEPTANCE_PAGE_SIZE = 3;
5
- export function acceptanceStatus(proof, accepted) {
7
+ /** `check`: the approved check read now (task-status.ts approvedCheckOf); undefined when this read had no saved files,
8
+ * and then a verified result claims nothing about its check. */
9
+ export function acceptanceStatus(proof, accepted, check) {
6
10
  if (accepted)
7
11
  return manualReviewOnly(proof) ? "Accepted by a person" : "Accepted with an exception";
8
12
  if (manualReviewOnly(proof))
9
13
  return "You check the remaining requirements";
14
+ if (proof?.verdict === "verified" && check !== "passed") {
15
+ return check === undefined ? "Open the result to see its checks" : check === "failed" ? "Checks failed" : check === "running" ? "Checks running" : "The check result couldn't be read";
16
+ }
10
17
  return proof === null ? "No checks recorded" : ({ verified: "Checks passed", attested: "Checks reported by the agent", short: "Some required material is missing", refuted: "Saved result conflicts with the approved scope" }[proof.verdict] ?? "Some required material is missing");
11
18
  }
12
19
  export function readAcceptanceEvidence(store, who, root, task, runId, offset = 0) {
@@ -48,6 +55,9 @@ export function readAcceptanceEvidence(store, who, root, task, runId, offset = 0
48
55
  else if (!proof.ok)
49
56
  problems.push("The saved proof is unavailable or cannot be read.");
50
57
  const verification = root === undefined ? null : verificationEvidence(store, root, run.id);
58
+ // The one answer to "did the approved check pass" (task-status.ts), with follow-ups on the same commit.
59
+ const checkNow = root === undefined || run.role === "scout" ? undefined
60
+ : approvedCheckStateOf(approvedCheckOf(recorded?.verdict ?? null, recorded?.reasons ?? [], checksFactOf(assignmentChecksForRun(store, root, run.id, new Date()), run.headRevision)));
51
61
  let gate = { status: "No machine verification receipt available" };
52
62
  if (verification !== null && !verification.ok) {
53
63
  problems.push(verification.problem);
@@ -55,7 +65,7 @@ export function readAcceptanceEvidence(store, who, root, task, runId, offset = 0
55
65
  }
56
66
  else if (verification?.ok && verification.bytes !== null) {
57
67
  const receipt = JSON.parse(verification.bytes);
58
- gate = { status: receipt.result.ran ? (receipt.result.exitCode === 0 ? "Passed" : "Failed") : "Did not run", exitCode: receipt.result.exitCode ?? null, command: "The approved project verification command", logShortened: receipt.log.truncated };
68
+ gate = { status: receipt.result.ran ? (receipt.result.exitCode === 0 ? checkNow === undefined || checkNow === "passed" ? "Passed" : "Passed when it ran; not counted now" : "Failed") : "Did not run", exitCode: receipt.result.exitCode ?? null, command: "The approved project verification command", logShortened: receipt.log.truncated };
59
69
  }
60
70
  // These are summaries for conversation, not substitute approval terms.
61
71
  // Full statements, caveats and references remain on the authenticated screen.
@@ -71,7 +81,7 @@ export function readAcceptanceEvidence(store, who, root, task, runId, offset = 0
71
81
  return { ok: true, body: {
72
82
  task, run: run.id, title: store.getTask(task)?.title ?? task, currentExecution: family.current.id,
73
83
  isCurrent: family.current.id === task && store.runsFor(ref.id).find(one => one.finishedAt !== null && ["builder", "repair", "scout"].includes(one.role))?.id === run.id,
74
- status: acceptanceStatus(recorded, acceptance !== null), recordedVerdict: recorded?.verdict ?? null,
84
+ status: acceptanceStatus(recorded, acceptance !== null, checkNow), recordedVerdict: recorded?.verdict ?? null,
75
85
  accepted: acceptance !== null, acceptance: acceptance === null ? null : { at: acceptance.acceptedAt, note: brief(acceptance.note) },
76
86
  reasons: (recorded?.reasons ?? []).map(one => brief(one)), gate,
77
87
  criteriaTotal: matrix.length, nextCriterionOffset: offset + page.length < matrix.length ? offset + page.length : null,
package/dist/cli.js CHANGED
@@ -1301,7 +1301,7 @@ async function runDemoCommand(argv, write) {
1301
1301
  const now = new Date();
1302
1302
  const { sandbox, store, seed, evidenceRoot, passwordFile, lead } = createDemoSandbox(now);
1303
1303
  const server = createDecisionServer({
1304
- store, evidenceRoot, clock: () => new Date(), repos: seed.repos, demoLead: lead,
1304
+ store, evidenceRoot, clock: () => new Date(), repos: seed.repos, demoLead: lead, backgroundChecks: true,
1305
1305
  ...(allowedHosts.length === 0 ? {} : { allowedHosts }),
1306
1306
  });
1307
1307
  await new Promise((ready, failed) => {
@@ -1,4 +1,5 @@
1
1
  /** A committed native session enters the existing review flow, never another executor. */
2
+ import { checkoutGitSync } from './teardown-git.js';
2
3
  import { execFileSync } from 'node:child_process';
3
4
  import { lstatSync, realpathSync } from 'node:fs';
4
5
  import { sqliteRuntime } from './sqlite-runtime.js';
@@ -52,11 +53,11 @@ function catalog(store, readOnly) {
52
53
  throw error;
53
54
  }
54
55
  }
56
+ // The coding checkout is written by an agent (its config, hooks, filters): every read goes through the hardened
57
+ // checkout git, which the code page's GET preview also uses (docs/plans/get-side-effects.md).
55
58
  function git(repo, args) {
56
59
  try {
57
- return execFileSync('git', ['--no-optional-locks', '--no-replace-objects', '--no-lazy-fetch', '-c', 'core.hooksPath=/dev/null', '-C', repo, ...args], {
58
- encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000, maxBuffer: 1_048_576,
59
- }).trimEnd();
60
+ return checkoutGitSync(repo, ['--no-replace-objects', '--no-lazy-fetch', ...args]).trimEnd();
60
61
  }
61
62
  catch {
62
63
  throw Error('The committed coding result could not be verified in Git. Check the project and try again.');
package/dist/coding-ui.js CHANGED
@@ -76,7 +76,7 @@ export function codingWorkspaceHtml(input) {
76
76
  const blocked = !s.nativeThreadId || !['ready', 'working', 'failed', 'interrupted'].includes(s.status);
77
77
  const messages = selected.items.filter(item => item.type !== 'reasoning').map(itemHtml);
78
78
  const composer = postForm(`${sessionUrl(s.id)}/send`, html `<label for="coding-prompt">Message Codex</label><textarea id="coding-prompt" name="prompt" rows="2" maxlength="24000" required placeholder="Describe a change or ask about the result…">${input.draft ?? ''}</textarea><div class="coding-actions"><button type="submit"${blocked ? html ` disabled` : ''}>${s.status === 'working' ? 'Send update' : 'Send message'}</button></div>`, { attrs: { 'data-coding-form': 'send', class: 'coding-composer', hidden: s.status === 'closed' }, hidden: { requestId: randomUUID() } });
79
- main = html `<header class="coding-header"><div><h1>${s.title}</h1><p class="coding-meta">${projectName(s.repo)} · Codex${s.model ? html ` · ${s.model}` : ''}</p></div><details class="coding-session-detail"><summary aria-label="Session details">Details</summary><dl><dt>Branch</dt><dd>${s.branch}</dd><dt>Worktree</dt><dd>${s.worktree}</dd><dt>Native session</dt><dd>${s.nativeThreadId ?? 'Not established'}</dd>${s.initialRequest ? html `<dt>Initial request</dt><dd class="coding-text">${s.initialRequest.prompt}</dd>` : ''}</dl></details></header><div class="coding-state-row"><p id="coding-state" role="status">${stateLabel(s, selected.requests)}</p><div id="coding-controls">${controlsHtml(s)}</div></div><div id="coding-requests">${selected.requests.map(r => requestHtml(r, s.id))}</div><div id="coding-conversation" class="coding-conversation" tabindex="0" aria-label="Conversation"${s.status === 'closed' && messages.length === 0 ? html ` hidden` : ''}>${messages.length ? messages : html `<p class="coding-meta coding-no-messages">No messages yet.</p>`}</div><button type="button" id="coding-latest" class="coding-secondary" hidden>Latest update ↓</button>${composer}<details id="coding-changes" class="coding-changes"><summary>Changes</summary><div id="coding-change-content"><p class="coding-meta">Open to load the current changes.</p></div><button type="button" id="coding-refresh-changes" class="coding-secondary">Refresh changes</button><noscript><a href="${sessionUrl(s.id)}/changes">View changes</a></noscript><p id="coding-shipping"${['closed', 'uncertain'].includes(s.status) ? html ` hidden` : ''}><a class="coding-link-button" href="${sessionUrl(s.id)}/ship">Review for shipping</a></p></details>`;
79
+ main = html `<header class="coding-header"><div><h1>${s.title}</h1><p class="coding-meta">${projectName(s.repo)} · Codex${s.model ? html ` · ${s.model}` : ''}</p></div><details class="coding-session-detail"><summary aria-label="Session details">Details</summary><dl><dt>Branch</dt><dd>${s.branch}</dd><dt>Worktree</dt><dd>${s.worktree}</dd><dt>Native session</dt><dd>${s.nativeThreadId ?? 'Not established'}</dd>${s.initialRequest ? html `<dt>Initial request</dt><dd class="coding-text">${s.initialRequest.prompt}</dd>` : ''}</dl></details></header><div class="coding-state-row"><p id="coding-state" role="status">${stateLabel(s, selected.requests)}</p><div id="coding-controls">${controlsHtml(s)}</div></div><div id="coding-requests">${selected.requests.map(r => requestHtml(r, s.id))}</div><div id="coding-conversation" class="coding-conversation" tabindex="0" aria-label="Conversation"${s.status === 'closed' && messages.length === 0 ? html ` hidden` : ''}>${messages.length ? messages : html `<p class="coding-meta coding-no-messages">No messages yet.</p>`}</div><button type="button" id="coding-latest" class="coding-secondary" hidden>Latest update ↓</button>${composer}<details id="coding-changes" class="coding-changes"><summary>Changes</summary><div id="coding-change-content"><p class="coding-meta">Open to load the current changes.</p></div><button type="button" id="coding-refresh-changes" class="coding-secondary">Refresh changes</button><noscript>${postForm(`${sessionUrl(s.id)}/changes`, html `<button type="submit" class="coding-secondary">View changes</button>`)}</noscript><p id="coding-shipping"${['closed', 'uncertain'].includes(s.status) ? html ` hidden` : ''}>${postForm(`${sessionUrl(s.id)}/ship/preview`, html `<button type="submit" class="coding-link-button">Review for shipping</button>`)}</p></details>`;
80
80
  }
81
81
  return html `<section class="coding-workspace" data-coding-session="${selected?.session.id ?? ''}" data-coding-owner="${selected?.session.owner ?? input.owner ?? ''}" data-coding-project="${selectedRepo ?? input.projects[0]?.path ?? ''}" data-coding-status="${selected?.session.status ?? ''}" data-coding-native-thread="${String(Boolean(selected?.session.nativeThreadId))}" data-coding-revision="${selected?.revision ?? -1}" data-coding-csrf="${csrf}">${sidebar}<div class="coding-main"><div id="coding-error" class="coding-notice" role="alert"${error ? '' : html ` hidden`}>${noticeHtml(error)}</div><p id="coding-connection" class="coding-meta" role="status" hidden></p>${main}</div></section>`;
82
82
  }
@@ -276,7 +276,7 @@ export function codingWorkspaceScript() {
276
276
  const area = root.querySelector('#coding-change-content');
277
277
  const button = root.querySelector('#coding-refresh-changes'); button.disabled = true;
278
278
  try {
279
- const response = await fetch(base + '/changes', {headers:{accept:'application/json'},credentials:'same-origin',cache:'no-store',signal:requestSignal()});
279
+ const response = await fetch(base + '/changes', {method:'POST',headers:{accept:'application/json'},body:new URLSearchParams({csrf}),credentials:'same-origin',cache:'no-store',signal:requestSignal()});
280
280
  if (response.status === 401 || response.redirected) { signedOut(); return; }
281
281
  if (!response.ok) throw new Error('Could not load changes. Try Refresh changes.');
282
282
  const changes = await response.json();
@@ -1,3 +1,4 @@
1
+ import { checkoutGit } from './teardown-git.js';
1
2
  import { createHash, randomUUID } from 'node:crypto';
2
3
  import { mkdirSync, chmodSync, realpathSync } from 'node:fs';
3
4
  import { dirname, join } from 'node:path';
@@ -399,8 +400,10 @@ export class CodingWorkspace {
399
400
  }, 50);
400
401
  }
401
402
  }
402
- async git(repo, args) {
403
- const result = await run('git', ['--no-optional-locks', '-c', 'core.hooksPath=/dev/null', ...args], { cwd: repo, timeoutMs: 60_000, maxBuffer: 1_048_576 });
403
+ async git(repo, args, project = repo) {
404
+ // The project's repository and the session's checkout are agent-written: the hardened git, against a frozen view of
405
+ // the repository's config and info (teardown-git.ts checkoutGit).
406
+ const result = await checkoutGit(run, 'build', { repo: project })('git', ['--no-optional-locks', ...args], { cwd: repo, timeoutMs: 60_000, maxBuffer: 1_048_576 });
404
407
  if (result.code !== 0)
405
408
  throw Error('Git could not complete this workspace operation. Check the repository and try again.');
406
409
  return result.stdout;
@@ -519,13 +522,13 @@ export class CodingWorkspace {
519
522
  }
520
523
  async verifyWorktree(session) {
521
524
  const [branch, common, original] = await Promise.all([
522
- this.git(session.worktree, ['symbolic-ref', '--short', 'HEAD']),
523
- this.git(session.worktree, ['rev-parse', '--path-format=absolute', '--git-common-dir']),
525
+ this.git(session.worktree, ['symbolic-ref', '--short', 'HEAD'], session.repo),
526
+ this.git(session.worktree, ['rev-parse', '--path-format=absolute', '--git-common-dir'], session.repo),
524
527
  this.git(session.repo, ['rev-parse', '--path-format=absolute', '--git-common-dir']),
525
528
  ]);
526
529
  if (branch.trim() !== session.branch || realpathSync(common.trim()) !== realpathSync(original.trim()))
527
530
  throw Error('This checkout no longer matches the saved project and branch. Restore that checkout before resuming.');
528
- await this.git(session.worktree, ['merge-base', '--is-ancestor', session.base, 'HEAD']);
531
+ await this.git(session.worktree, ['merge-base', '--is-ancestor', session.base, 'HEAD'], session.repo);
529
532
  }
530
533
  priorProcessesGone() {
531
534
  const row = this.db.prepare('SELECT payload FROM coding_custody WHERE singleton=1').get();
@@ -729,10 +732,21 @@ export class CodingWorkspace {
729
732
  }
730
733
  async changes(id, actor) {
731
734
  const session = this.get(id, actor);
735
+ // The code page reads this on a GET (docs/plans/get-side-effects.md), from a checkout the agent writes: every git
736
+ // here is the hardened checkout git, which refuses a config that could run a command and reads no global config.
737
+ // Read mode: the read flags, and the checkout's config and info/attributes as read once (teardown-git.ts readView),
738
+ // hold even if the agent changes them after checkoutGit read them: no hook, pager, filter or network.
739
+ const safe = checkoutGit(run, 'read');
740
+ const read = async (args) => {
741
+ const result = await safe('git', args, { cwd: session.worktree, timeoutMs: 60_000, maxBuffer: 1_048_576 });
742
+ if (result.code !== 0)
743
+ throw Error('Git could not complete this workspace operation. Check the repository and try again.');
744
+ return result.stdout;
745
+ };
732
746
  const [head, status, tracked, untracked] = await Promise.all([
733
- this.git(session.worktree, ['rev-parse', 'HEAD']), this.git(session.worktree, ['status', '--short']),
734
- run('git', ['--no-optional-locks', 'diff', '--no-ext-diff', '--no-textconv', session.base, '--'], { cwd: session.worktree, timeoutMs: 60_000, maxBuffer: 1_048_576 }),
735
- this.git(session.worktree, ['ls-files', '--others', '--exclude-standard', '-z']),
747
+ read(['rev-parse', 'HEAD']), read(['status', '--short']),
748
+ safe('git', ['diff', '--no-ext-diff', '--no-textconv', session.base, '--'], { cwd: session.worktree, timeoutMs: 60_000, maxBuffer: 1_048_576 }),
749
+ read(['ls-files', '--others', '--exclude-standard', '-z']),
736
750
  ]);
737
751
  let diff = tracked.stdout, truncated = tracked.code !== 0;
738
752
  // git's no-index diff describes symlinks themselves; it does not follow a
@@ -743,7 +757,7 @@ export class CodingWorkspace {
743
757
  truncated = true;
744
758
  break;
745
759
  }
746
- const part = await run('git', ['--no-optional-locks', 'diff', '--no-index', '--no-ext-diff', '--no-textconv', '--', '/dev/null', paths[index]], { cwd: session.worktree, timeoutMs: 60_000, maxBuffer: 131_072 });
760
+ const part = await safe('git', ['diff', '--no-index', '--no-ext-diff', '--no-textconv', '--', '/dev/null', paths[index]], { cwd: session.worktree, timeoutMs: 60_000, maxBuffer: 131_072 });
747
761
  diff += part.stdout;
748
762
  if (part.code !== 0 && part.code !== 1)
749
763
  truncated = true;
@@ -72,6 +72,7 @@ export declare const CONSOLE_FORMS: {
72
72
  readonly code: FormContract<"acceptance" | "answers" | "base" | "candidate" | "decision" | "goal" | "model" | "password" | "prompt" | "repo" | "requestId" | "title" | "visual" | `question:${string}`>;
73
73
  readonly sweep: FormContract<"confirm" | "snapshot">;
74
74
  readonly passwordless: FormContract<"action" | "password">;
75
+ readonly releaseApproval: FormContract<"action" | "password" | "repo">;
75
76
  readonly term: FormContract<"password" | "program" | "repo">;
76
77
  readonly skillsRevise: FormContract<"feedback" | "nonce" | "repo" | "run">;
77
78
  readonly skillsChange: FormContract<"action" | "content" | "files" | "identity" | "method" | "nonce" | "repo" | "restore" | "revision" | "sample" | "sha" | "url">;
@@ -123,6 +124,9 @@ export declare const CONSOLE_FORMS: {
123
124
  readonly notifications: FormContract<"digest" | "mode" | "screenshots">;
124
125
  readonly notificationsMute: FormContract<"pings" | "repo">;
125
126
  readonly telegramDigest: FormContract<"every">;
127
+ readonly pullRequestsCheck: FormContract<"repo">;
128
+ readonly connectionCheck: FormContract<"auth-mode" | "provider" | "return" | "value">;
129
+ readonly modelsRefresh: FormContract<"command" | "fingerprint" | "model" | "nonce" | "provider" | "repo" | "seconds" | "task" | "token">;
126
130
  readonly providerKey: FormContract<"auth-mode" | "provider" | "value">;
127
131
  readonly telegramToken: FormContract<"token">;
128
132
  readonly email: FormContract<"from" | "host" | "imapHost" | "imapPort" | "password" | "port" | "secure" | "user">;
@@ -149,6 +153,7 @@ export declare const CONSOLE_FORMS: {
149
153
  readonly leadIdentity: FormContract<"name" | "persona">;
150
154
  readonly leadAbout: FormContract<"about">;
151
155
  readonly leadPromiseCancel: FormContract<"promise">;
156
+ readonly leadCheck: FormContract<"return">;
152
157
  readonly chatConfig: FormContract<"daily-turns" | "forget-key" | "key" | "model" | "off" | "provider" | "return" | "token" | "weekly-usd">;
153
158
  readonly leadMint: FormContract<"ceiling-usd" | "follow" | "return" | "token">;
154
159
  readonly leadFollow: FormContract<"enabled" | "return">;
@@ -190,7 +195,7 @@ export declare const CONSOLE_FORMS: {
190
195
  };
191
196
  export type ConsoleFormName = keyof typeof CONSOLE_FORMS;
192
197
  /** The console POSTs whose handlers read no field (sign-out not even the shared guard): their contract is the empty form. */
193
- export declare const BODILESS_POSTS: readonly ["POST /settings/models/<unknown action>", "POST /settings/lead/on", "POST /r/<run>/draft-repair", "POST /ledger/checkpoint", "POST /settings/backups/now", "POST /mode/revoke", "POST /settings/telegram/retry", "POST /providers/<provider>/resume", "POST /logout", "POST /t/<task>/unhold", "POST /t/<task>/stop-carrying", "POST /t/<task>/plan", "POST /t/<task>/retry-review"];
198
+ export declare const BODILESS_POSTS: readonly ["POST /settings/models/<unknown action>", "POST /settings/lead/on", "POST /r/<run>/draft-repair", "POST /ledger/checkpoint", "POST /settings/backups/now", "POST /mode/revoke", "POST /settings/telegram/retry", "POST /providers/<provider>/resume", "POST /logout", "POST /t/<task>/unhold", "POST /t/<task>/stop-carrying", "POST /t/<task>/plan", "POST /t/<task>/retry-review", "POST /settings/updates/check", "POST /settings/storage/check", "POST /projects/github", "POST /code/<session>/ship/preview"];
194
199
  /** The field names a console form's handler may read. */
195
200
  export type FormFieldOf<N extends ConsoleFormName> = (typeof CONSOLE_FORMS)[N] extends FormContract<infer K> ? K : never;
196
201
  /** `GET /ledger?format=json`: the chain's state, a page of entries (newest first) and the cursor for the next. */
@@ -81,6 +81,7 @@ export const CONSOLE_FORMS = {
81
81
  code: formContract("POST /code, /code/start, /code/<session>/<act>, /code/<anything else>", ["repo", "password", "title", "model", "prompt", "requestId", "base", "candidate", "goal", "acceptance", "visual", "decision", "answers"], { prefixes: ["question:"] }),
82
82
  sweep: formContract("POST /sweeps/<sweep>/cancel-rest", ["confirm", "snapshot"]),
83
83
  passwordless: formContract("POST /settings/passwordless", ["action", "password"]),
84
+ releaseApproval: formContract("POST /settings/release-approval", ["repo", "action", "password"]),
84
85
  term: formContract("POST /term/start, /term/<terminal>/close, /term/<terminal>/remove", ["repo", "program", "password"]),
85
86
  skillsRevise: formContract("POST /settings/skills/revise", ["run", "repo", "feedback", "nonce"]),
86
87
  skillsChange: formContract("POST /settings/skills/import, /settings/skills/change", ["repo", "identity", "revision", "method", "content", "files", "url", "action", "sha", "sample", "nonce", "restore"]),
@@ -132,6 +133,10 @@ export const CONSOLE_FORMS = {
132
133
  notifications: formContract("POST /settings/notifications", ["mode", "digest", "screenshots"]),
133
134
  notificationsMute: formContract("POST /settings/notifications/mute", ["repo", "pings"]),
134
135
  telegramDigest: formContract("POST /settings/telegram-digest", ["every"]),
136
+ // Check again sits in the provider-key form and the setup card; Refresh models in the setup form.
137
+ pullRequestsCheck: formContract("POST /settings/pull-requests/check", ["repo"]),
138
+ connectionCheck: formContract("POST /settings/connection/check", ["provider", "auth-mode", "value", "return"]),
139
+ modelsRefresh: formContract("POST /control/models-refresh", ["repo", "nonce", "fingerprint", "token", "provider", "model", "command", "seconds", "task"]),
135
140
  providerKey: formContract("POST /settings/provider-key, /settings/provider-key-clear", ["provider", "auth-mode", "value"]),
136
141
  telegramToken: formContract("POST /settings/telegram-token", ["token"]),
137
142
  email: formContract("POST /settings/email, /settings/email-test, /settings/email-read-test", ["host", "port", "secure", "user", "from", "password", "imapHost", "imapPort"]),
@@ -158,6 +163,7 @@ export const CONSOLE_FORMS = {
158
163
  leadIdentity: formContract("POST /settings/lead/identity", ["name", "persona"]),
159
164
  leadAbout: formContract("POST /settings/lead/about", ["about"]),
160
165
  leadPromiseCancel: formContract("POST /settings/lead/promise/cancel", ["promise"]),
166
+ leadCheck: formContract("POST /lead/check", ["return"]),
161
167
  chatConfig: formContract("POST /chat/config", ["return", "token", "off", "forget-key", "provider", "model", "weekly-usd", "daily-turns", "key"]),
162
168
  leadMint: formContract("POST /chat/mate/mint", ["return", "ceiling-usd", "token", "follow"]),
163
169
  leadFollow: formContract("POST /chat/mate/follow", ["enabled", "return"]),
@@ -216,6 +222,11 @@ export const BODILESS_POSTS = [
216
222
  "POST /t/<task>/stop-carrying",
217
223
  "POST /t/<task>/plan",
218
224
  "POST /t/<task>/retry-review",
225
+ // Check now and its kin (docs/plans/get-side-effects.md): the form token only, no fields read.
226
+ "POST /settings/updates/check",
227
+ "POST /settings/storage/check",
228
+ "POST /projects/github",
229
+ "POST /code/<session>/ship/preview",
219
230
  ];
220
231
  // ---- the JSON API ------------------------------------------------------------------------------------------------
221
232
  const ledgerEntrySchema = z.looseObject({
@@ -6,14 +6,15 @@ import { type ModelChoice } from "./setup-guide.js";
6
6
  import type { SetupInputs } from "./control-setup.js";
7
7
  import { type OpenRouterModels } from "./openrouter-models.js";
8
8
  export declare function connectionWords(connection: ProviderConnection): string;
9
- export declare function connectionHtml(provider: ProviderId, connection: ProviderConnection, task?: string): Html;
9
+ /** The last sign-in check (null: none yet). Check again posts: a page never runs a check (docs/plans/get-side-effects.md). */
10
+ export declare function connectionHtml(provider: ProviderId, connection: ProviderConnection | null, task?: string): Html;
10
11
  export declare function controlSetupHtml(data: {
11
12
  repo: string;
12
13
  csrf: string;
13
14
  provider: ProviderId;
14
15
  inputs: SetupInputs;
15
16
  models: ModelChoice[];
16
- connection: ProviderConnection;
17
+ connection: ProviderConnection | null;
17
18
  catalog: OpenRouterModels | null;
18
19
  task?: string;
19
20
  preparation: {
@@ -8,15 +8,20 @@ export function connectionWords(connection) {
8
8
  unverified: "Not verified", "key-present": "API key saved · not verified", "key-works": "API key works", "key-refused": "API key refused", "missing-key": "API key needed",
9
9
  }[connection.state];
10
10
  }
11
+ /** The last sign-in check (null: none yet). Check again posts: a page never runs a check (docs/plans/get-side-effects.md). */
11
12
  export function connectionHtml(provider, connection, task = "") {
12
13
  const query = new URLSearchParams({ provider, ...(task ? { task } : {}) });
13
- return html `<div class="card assistant-account"><p><strong>${ASSISTANTS[provider].name} · ${connectionWords(connection)}</strong></p><p class="meta">${[connection.email, connection.plan, connection.method].filter(Boolean).join(" · ")}</p><p class="meta">Checked ${new Date(connection.checkedAt).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", timeZoneName: "short" })} on the computer running this console. Sign-in does not prove model access or remaining quota.</p><a class="button-link" href="/control/connection?${query.toString()}">${connection.state === "connected" ? "Manage connection" : "Connect account"}</a> · <a href="/control?${query.toString()}&amp;check-connection=1">Check again</a></div>`;
14
+ const back = `/control?${query.toString()}`;
15
+ const check = postForm("/settings/connection/check", html `<button type="submit" class="secondary">${connection === null ? "Check now" : "Check again"}</button>`, { attrs: { class: "inline" }, returnTo: back, hidden: { provider } });
16
+ if (connection === null)
17
+ return html `<div class="card assistant-account"><p><strong>${ASSISTANTS[provider].name} · Not checked yet</strong></p>${check}</div>`;
18
+ return html `<div class="card assistant-account"><p><strong>${ASSISTANTS[provider].name} · ${connectionWords(connection)}</strong></p><p class="meta">${[connection.email, connection.plan, connection.method].filter(Boolean).join(" · ")}</p><p class="meta">Checked ${new Date(connection.checkedAt).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", timeZoneName: "short" })} on the computer running this console. Sign-in does not prove model access or remaining quota.</p><a class="button-link" href="/control/connection?${query.toString()}">${connection.state === "connected" ? "Manage connection" : "Connect account"}</a> ${check}</div>`;
14
19
  }
15
20
  export function controlSetupHtml(data) {
16
21
  const { provider, inputs, task = "" } = data;
17
22
  const query = new URLSearchParams({ provider, ...(task ? { task } : {}) });
18
23
  const model = data.catalog !== null
19
- ? openRouterPicker(data.catalog, inputs.model || null, `/control?${query}&refresh-models=1`)
24
+ ? openRouterPicker(data.catalog, inputs.model || null, "/control/models-refresh")
20
25
  : html `<label>Model<input type="text" name="model" value="${inputs.model}" list="setup-models" required autocomplete="off" placeholder="Exact model id or supported alias"></label><datalist id="setup-models">${data.models.map(one => html `<option value="${one.value}">${one.label}</option>`)}</datalist>`;
21
26
  return html `<h1>Project setup</h1><p>${data.repo}</p><p class="meta">Choose the default builder for new tasks and the preparation command for this project.</p><nav class="assistant-picker row" aria-label="Choose provider">${joinHtml(Object.keys(ASSISTANTS).map(id => html `<a class="button-link" href="/control?${new URLSearchParams({ provider: id, ...(task ? { task } : {}) }).toString()}"${id === provider ? html ` aria-current="page"` : ""}>${ASSISTANTS[id].name}</a>`), " ")}</nav>${connectionHtml(provider, data.connection, task)}${postForm("/control/setup-preview", html `${model}<details class="setup-advanced"><summary>Project preparation</summary>${data.preparation === null ? "" : html `<p class="meta">Suggested from ${data.preparation.evidence}: ${data.preparation.label} — <code>${data.preparation.command}</code>. Review before approving.</p>`}<label>Preparation command<textarea name="command" rows="2" maxlength="2000">${inputs.command}</textarea></label><label>Preparation timeout in seconds<input name="seconds" type="number" min="1" max="3600" value="${inputs.seconds}" required></label><p class="meta">Preparation applies to subsequent runs, including already approved tasks. Saving setup does not execute it.</p></details><button type="submit">Review project setup</button>`, { attrs: { class: "card" }, hidden: { repo: data.repo, provider, task } })}<section class="card"><h2>Agent instructions</h2>${data.instructions.message ? html `<p>${data.instructions.message}</p>` : data.instructions.installed ? html `<p>Toolroll instructions are installed.</p>` :
22
27
  html `<p>Give the agent the project's Toolroll handoff instructions.</p>${postForm("/control/instructions-preview", html `<button>Review instructions</button>`, { hidden: { repo: data.repo } })}`}</section><p><a href="/fleet">Configure planning, repair, and review agents</a> · <a href="/tasks/new">Describe a task</a></p>`;
package/dist/demo.js CHANGED
@@ -1481,7 +1481,7 @@ export function createDemoLead(input) {
1481
1481
  try {
1482
1482
  advance(clock());
1483
1483
  }
1484
- catch { /* the next read advances it again */ }
1484
+ catch { /* the next beat or background pass advances it again */ }
1485
1485
  }, beat * stepMs + 20);
1486
1486
  timer.unref();
1487
1487
  timers.add(timer);
@@ -1489,8 +1489,10 @@ export function createDemoLead(input) {
1489
1489
  void exchange;
1490
1490
  };
1491
1491
  return {
1492
- exchanges: () => { advance(clock()); return list; },
1493
- version: () => { advance(clock()); return changes; },
1492
+ // Reads only (docs/plans/get-side-effects.md): the beat timers, the console's background pass and the person's own
1493
+ // acts move the story on, never a page visit.
1494
+ exchanges: () => list,
1495
+ version: () => changes,
1494
1496
  ask(text, now) {
1495
1497
  advance(now);
1496
1498
  const asked = said(text);
@@ -14,4 +14,7 @@ export declare function deploymentGate(store: Store, input: {
14
14
  runId: number;
15
15
  evidenceRoot: string;
16
16
  now: Date;
17
+ /** Inside `toolroll release` (its own reserved deploy): a release's own automatic approval also stands
18
+ * (docs/plans/auto-approve-green.md). Anywhere else only a person's approval deploys. */
19
+ releaseFlow?: boolean;
17
20
  }): DeploymentGate;
@@ -6,7 +6,7 @@
6
6
  * same checks for the deployment script, proven again there with the installed runtime's modules.)
7
7
  */
8
8
  import { assignmentOf } from "./assignment.js";
9
- import { liveReleaseOf, personApprovalOf } from "./scope.js";
9
+ import { liveReleaseOf, personApprovalOf, releaseApprovalOf } from "./scope.js";
10
10
  import { verificationEvidence } from "./verification-evidence.js";
11
11
  /** Whether run `runId` is a deployable candidate now, read only from the plane's records and this runtime's code. */
12
12
  export function deploymentGate(store, input) {
@@ -33,7 +33,8 @@ export function deploymentGate(store, input) {
33
33
  const release = liveReleaseOf(store, ref.externalId, "deploy", input.now);
34
34
  if (release.applies && !release.ok)
35
35
  return refuse(`This release can't deploy: ${release.why}.`);
36
- const anchorPerson = release.applies && release.ok ? personApprovalOf(store, release.authority.anchorTask) : null;
36
+ const anchorPerson = release.applies && release.ok
37
+ ? (input.releaseFlow === true ? releaseApprovalOf(store, release.authority.anchorTask, input.now) : personApprovalOf(store, release.authority.anchorTask)) : null;
37
38
  const person = anchorPerson ?? personApprovalOf(store, ref.externalId);
38
39
  if (!person.ok)
39
40
  return refuse(`A person has to approve this candidate's scope: ${person.why}.`);
@@ -92,9 +92,11 @@ export declare function previewDesktopUpdate(stateDir: string, installed: string
92
92
  }>;
93
93
  export declare function prepareDesktopUpdate(stateDir: string, installed: string, candidate: string, label: string, expected: string, hooks?: UpdateHooks): Promise<UpdateJournal>;
94
94
  export declare function sqliteLock(file: string, busyTimeoutMs?: number): DatabaseSync | null;
95
+ /** Whether another process holds the lock in `file`, without making the file or changing it: a status read (a page
96
+ * shows it, docs/plans/get-side-effects.md). A file that isn't there yet is held by nobody. */
97
+ export declare function lockHeld(file: string): boolean;
95
98
  export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
96
99
  export declare function desktopUpdateStatus(stateDir: string): {
97
- version?: never;
98
100
  active: boolean;
99
101
  phase: string;
100
102
  detail: string;
@@ -108,6 +110,7 @@ export declare function desktopUpdateStatus(stateDir: string): {
108
110
  wasRunning?: never;
109
111
  backupPath?: never;
110
112
  updateId?: never;
113
+ version?: never;
111
114
  buildId?: never;
112
115
  workDir?: never;
113
116
  } | {
@@ -1,3 +1,4 @@
1
+ import { inReadOnlyRequest } from "./read-only-request.js";
1
2
  import { createHash, randomUUID } from "node:crypto";
2
3
  import { closeSync, chmodSync, copyFileSync, cpSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
4
  import { homedir } from "node:os";
@@ -261,11 +262,14 @@ export function desktopUpdateWaitingOf(databaseFile, inTheWay, stateDirs = [dirn
261
262
  }
262
263
  if (!j || resolve(j.databaseFile) !== resolve(databaseFile))
263
264
  continue;
264
- try {
265
- if (releaseStalledDesktopUpdate(stateDir))
266
- j = readUpdateJournal(stateDir);
265
+ // A page visit only reads (docs/plans/get-side-effects.md): the console's background pass releases it.
266
+ if (!inReadOnlyRequest()) {
267
+ try {
268
+ if (releaseStalledDesktopUpdate(stateDir))
269
+ j = readUpdateJournal(stateDir);
270
+ }
271
+ catch { /* shown as last saved */ }
267
272
  }
268
- catch { /* shown as last saved */ }
269
273
  const version = j.next.version;
270
274
  if (j.phase === "draining")
271
275
  return updateWaitingWords({ app: true, version, stopped: false, lingering: j.waiting ?? null });
@@ -394,14 +398,46 @@ export function sqliteLock(file, busyTimeoutMs = 0) {
394
398
  throw error;
395
399
  }
396
400
  }
401
+ /** Whether another process holds the lock in `file`, without making the file or changing it: a status read (a page
402
+ * shows it, docs/plans/get-side-effects.md). A file that isn't there yet is held by nobody. */
403
+ export function lockHeld(file) {
404
+ let st;
405
+ try {
406
+ st = lstatSync(file);
407
+ }
408
+ catch {
409
+ return false;
410
+ }
411
+ // Not a plain file of ours (a link, or one someone else made): treated as held, so nothing goes ahead on it.
412
+ if (!st.isFile() || (typeof process.getuid === "function" && st.uid !== process.getuid()))
413
+ return true;
414
+ let db;
415
+ // Read-only: it never makes the file. The holder's exclusive lock stops even a read.
416
+ try {
417
+ db = new (sqlite().DatabaseSync)(file, { readOnly: true });
418
+ }
419
+ catch {
420
+ return true;
421
+ }
422
+ try {
423
+ db.exec("PRAGMA busy_timeout=0");
424
+ db.prepare("SELECT COUNT(*) FROM sqlite_master").get();
425
+ return false;
426
+ }
427
+ catch (error) {
428
+ return String(error).includes("locked") || String(error).includes("busy") || [5, 6].includes(error.errcode ?? 0);
429
+ }
430
+ finally {
431
+ db.close();
432
+ }
433
+ }
397
434
  const workerLock = (j, busyTimeoutMs = 0) => sqliteLock(join(j.workDir, "worker.sqlite"), busyTimeoutMs);
398
435
  export const installationLock = (j) => sqliteLock(join(dirname(j.workDir), `installation-${digest(j.old.path).slice(0, 24)}.sqlite`));
399
436
  export function desktopUpdateStatus(stateDir) {
400
437
  const j = readUpdateJournal(stateDir);
401
438
  if (!j)
402
439
  return { active: false, phase: "none", detail: "No update is in progress.", running: false, canResume: false, canCancel: false };
403
- const lock = workerLock(j), running = lock === null;
404
- lock?.close();
440
+ const running = lockHeld(join(j.workDir, "worker.sqlite"));
405
441
  let recovery = null;
406
442
  try {
407
443
  privateFile(join(j.workDir, "recovery.json"));
@@ -409,9 +445,7 @@ export function desktopUpdateStatus(stateDir) {
409
445
  }
410
446
  catch { /* A legacy/manual update has no guardian. */ }
411
447
  const wantsAutomatic = recovery?.id === j.id && ["armed", "running", "backoff"].includes(recovery.state ?? "") && !terminal(j.phase);
412
- const guardianLock = wantsAutomatic ? sqliteLock(join(j.workDir, "guardian.sqlite")) : undefined;
413
- const automatic = wantsAutomatic && (guardianLock === null || Date.now() - Date.parse(recovery?.updatedAt ?? "") < 45_000);
414
- guardianLock?.close();
448
+ const automatic = wantsAutomatic && (lockHeld(join(j.workDir, "guardian.sqlite")) || Date.now() - Date.parse(recovery?.updatedAt ?? "") < 45_000);
415
449
  const detail = wantsAutomatic && !automatic ? "Automatic recovery has not restarted. Work remains paused; use Retry safely in Update status." : recovery?.state === "attention" || (automatic && (!running || j.phase === "needs-attention")) ? recovery?.detail ?? j.detail : j.detail;
416
450
  return { active: !terminal(j.phase), phase: j.phase, detail, error: j.error ?? null, running, automaticRecovery: automatic, recoveryAttempts: recovery?.attempts ?? 0, recoveryRepairs: recovery?.repairs ?? 0, canResume: !running && !automatic && !terminal(j.phase), canCancel: !terminal(j.phase), wasRunning: j.wasRunning && !updateStopRequested(j), backupPath: j.backupPath ?? null, updateId: j.id, version: j.next.version, buildId: j.next.buildId, workDir: j.workDir };
417
451
  }
package/dist/discover.js CHANGED
@@ -19,6 +19,7 @@
19
19
  */
20
20
  import { basename } from "node:path";
21
21
  import { run } from "./exec.js";
22
+ import { checkoutGit } from "./teardown-git.js";
22
23
  import { parseWorktrees, parseBranches } from "./git.js";
23
24
  import { findRepos } from "./scan.js";
24
25
  /** Enough to keep git busy without turning a laptop fan on. */
@@ -43,7 +44,11 @@ const BRANCH_FORMAT = "--format=%(refname:short)%09%(upstream:track)%09%(committ
43
44
  const BRANCH_FORMAT_CHEAP = "--format=%(refname:short)%09%09%(committerdate:iso8601-strict)";
44
45
  export async function inspectRepo(path, options = {}) {
45
46
  const { runner = run, dirty = false } = options;
46
- const git = (args, timeoutMs = REF_TIMEOUT_MS) => runner(GIT, [...READ_ONLY, ...args], { cwd: path, timeoutMs });
47
+ // Scanned and enrolled projects share their repository with every build, which can write its config, info and hooks
48
+ // (`--dirty`'s status would run a planted fsmonitor or filter): the hardened git, against a frozen view of the
49
+ // repository (teardown-git.ts checkoutGit). An injected runner (tests) is used as given.
50
+ const hardened = runner === run ? checkoutGit(run, "build") : runner;
51
+ const git = (args, timeoutMs = REF_TIMEOUT_MS) => hardened(GIT, [...READ_ONLY, ...args], { cwd: path, timeoutMs });
47
52
  const [worktrees, branchRead, head, remote, status] = await Promise.all([
48
53
  git(["worktree", "list", "--porcelain"]),
49
54
  readBranches(git),
@@ -94,14 +94,17 @@ export declare function firstTaskJourney(read: {
94
94
  stage: "queued" | "planning" | "needs-you" | "waiting" | "building" | "checking" | "reviewing" | "finished" | "complete" | "failed" | "stopped";
95
95
  need?: string | undefined;
96
96
  }, approved: boolean, built?: boolean): JourneyStep[];
97
- /** What a task's checks recorded, for its road: `null` when nothing says (the step is then shown skipped, never done). */
98
- export type RoadChecks = "passed" | "running" | "failed" | "not-run" | "unread" | null;
97
+ /** The approved check as the task's status reads it (task-status.ts `approvedCheckStateOf`), for its road: `null` when
98
+ * nothing says (the step is then shown skipped, never done). The road reads this one answer, never a row's words. */
99
+ export type RoadChecks = "passed" | "running" | "failed" | "not-run" | "off" | "unreadable" | null;
99
100
  /**
100
101
  * Any task's road (the read-only stepper on its page, after ReUI's Stepper): the first task's five steps, told only
101
102
  * from recorded facts. You approve is done only when the plan was approved (a rewritten plan waiting on approval
102
- * stands there again); a task that never needed one shows it skipped. Checks is done only when a check passed; a
103
- * running, failed, missing or unread check says so, and with no checks fact it is skipped. A research report has
104
- * no Checks step: a report stopped there stops at Ready. The step that waits on a person carries `needsYou`.
103
+ * stands there again); a task that never needed one shows it skipped. Checks is done only when the approved check
104
+ * passed; a running, missing or Off check says so, and with no checks fact it is skipped. A failed check, or one that
105
+ * can't be read, stops the road at Checks: Ready isn't done (fail closed), even on a task a person marked complete.
106
+ * A research report has no Checks step: a report stopped there stops at
107
+ * Ready. The step that waits on a person carries `needsYou`.
105
108
  */
106
109
  export declare function taskRoad(read: Parameters<typeof firstTaskJourney>[0], approved: boolean, built: boolean, facts: {
107
110
  report?: boolean;