toolroll 0.9.7 → 0.9.9

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 (97) hide show
  1. package/CHANGELOG.md +48 -1
  2. package/dist/assignment-adapters.d.ts +1 -0
  3. package/dist/assignment-adapters.js +2 -0
  4. package/dist/assignment-brief.d.ts +2 -0
  5. package/dist/assignment-brief.js +2 -1
  6. package/dist/assignment-presentation.js +3 -1
  7. package/dist/assignment-status.d.ts +1 -1
  8. package/dist/assignment-ui.js +5 -0
  9. package/dist/assignment.d.ts +15 -0
  10. package/dist/assignment.js +56 -9
  11. package/dist/browser/workspace.css +1 -1
  12. package/dist/browser/workspace.js +18 -18
  13. package/dist/browser-crew.js +4 -0
  14. package/dist/browser-workspace.d.ts +40 -0
  15. package/dist/build-review.d.ts +38 -0
  16. package/dist/build-review.js +341 -0
  17. package/dist/builder.d.ts +2 -0
  18. package/dist/builder.js +114 -8
  19. package/dist/chat-controls.d.ts +1 -1
  20. package/dist/chat-controls.js +1 -1
  21. package/dist/check-levels-ui.d.ts +5 -0
  22. package/dist/check-levels-ui.js +16 -2
  23. package/dist/check-progress.js +4 -3
  24. package/dist/checkout-cleanup.d.ts +40 -2
  25. package/dist/checkout-cleanup.js +129 -9
  26. package/dist/cli.js +2 -0
  27. package/dist/desktop-update.d.ts +52 -0
  28. package/dist/desktop-update.js +147 -5
  29. package/dist/dispatch.js +2 -1
  30. package/dist/dispose.d.ts +4 -3
  31. package/dist/dispose.js +12 -4
  32. package/dist/evidence.d.ts +6 -0
  33. package/dist/evidence.js +8 -0
  34. package/dist/exec.d.ts +5 -0
  35. package/dist/exec.js +30 -4
  36. package/dist/first-run.d.ts +1 -1
  37. package/dist/first-run.js +8 -6
  38. package/dist/invoke.js +5 -0
  39. package/dist/lead-context.d.ts +1 -1
  40. package/dist/lead-context.js +4 -2
  41. package/dist/lead-status.d.ts +10 -1
  42. package/dist/lead-status.js +21 -24
  43. package/dist/modes.d.ts +3 -1
  44. package/dist/modes.js +3 -1
  45. package/dist/needs-you.d.ts +55 -0
  46. package/dist/needs-you.js +83 -0
  47. package/dist/operate.d.ts +2 -0
  48. package/dist/operate.js +231 -36
  49. package/dist/plane-review.js +4 -0
  50. package/dist/process-custody.js +8 -0
  51. package/dist/process-recovery-eligibility.d.ts +1 -0
  52. package/dist/project-learning.d.ts +3 -3
  53. package/dist/project-learning.js +28 -17
  54. package/dist/proof.d.ts +3 -1
  55. package/dist/proof.js +4 -2
  56. package/dist/provider.js +16 -0
  57. package/dist/result-completion.d.ts +4 -0
  58. package/dist/result-completion.js +11 -0
  59. package/dist/retention-ui.d.ts +3 -1
  60. package/dist/retention-ui.js +4 -4
  61. package/dist/retention.d.ts +13 -6
  62. package/dist/retention.js +50 -12
  63. package/dist/review-context.d.ts +3 -0
  64. package/dist/review-context.js +45 -5
  65. package/dist/review-switch.d.ts +41 -0
  66. package/dist/review-switch.js +88 -0
  67. package/dist/reviewer.d.ts +24 -2
  68. package/dist/reviewer.js +49 -1
  69. package/dist/run-closeout.d.ts +89 -0
  70. package/dist/run-closeout.js +302 -0
  71. package/dist/serve.d.ts +15 -1
  72. package/dist/serve.js +158 -18
  73. package/dist/setup-guide.d.ts +2 -2
  74. package/dist/shared-deps.d.ts +94 -0
  75. package/dist/shared-deps.js +365 -0
  76. package/dist/storage.js +2 -0
  77. package/dist/store.d.ts +72 -1
  78. package/dist/store.js +258 -31
  79. package/dist/surface.js +5 -1
  80. package/dist/task-status.d.ts +22 -5
  81. package/dist/task-status.js +45 -24
  82. package/dist/telegram-progress.js +2 -1
  83. package/dist/telegram-status.js +3 -3
  84. package/dist/test-temp.d.ts +19 -0
  85. package/dist/test-temp.js +98 -0
  86. package/dist/toolroll-update-ui.js +7 -2
  87. package/dist/toolroll-update.d.ts +21 -1
  88. package/dist/toolroll-update.js +135 -5
  89. package/dist/work-index.d.ts +11 -0
  90. package/dist/work-index.js +103 -16
  91. package/dist/work-summary.d.ts +3 -1
  92. package/dist/work-summary.js +2 -1
  93. package/dist/workspace-ui.d.ts +3 -1
  94. package/dist/workspace-ui.js +10 -9
  95. package/dist/worktree.d.ts +16 -0
  96. package/dist/worktree.js +47 -0
  97. package/package.json +1 -1
@@ -66,3 +66,14 @@ export declare class WorkIndexCursorError extends Error {
66
66
  * Cursors bind the admitted scope, project and view, never broaden access. */
67
67
  export declare function workIndexPage(store: Store, now: Date, access: WorkSummaryAccess, options?: WorkIndexOptions): WorkIndexPage;
68
68
  export declare function workCountsByProject(store: Store, now: Date, access: WorkSummaryAccess): WorkProjectCounts[];
69
+ /** Families whose headline is Ready for review, newest first: the Tasks
70
+ * list's own rows, so a completed family (root or any revision), a failed
71
+ * check and a superseded version never count. */
72
+ export declare function workReadyForReview(store: Store, now: Date, access: WorkSummaryAccess, limit?: number): {
73
+ count: number;
74
+ results: {
75
+ rootId: string;
76
+ taskId: string;
77
+ run: number | null;
78
+ }[];
79
+ };
@@ -1,12 +1,17 @@
1
1
  /** Paginated, permission-bound summaries of saved task facts. These reads do
2
- * not verify artifacts, inspect processes, or authorize task operations. */
2
+ * not verify artifacts or authorize task operations; a build Toolroll can't
3
+ * yet confirm stopped is read through the store's own read-only probe. */
3
4
  import { createHash } from 'node:crypto';
5
+ import { existsSync } from 'node:fs';
6
+ import { join } from 'node:path';
4
7
  import { readProjectAccess } from './project-access.js';
5
8
  import { pauseOnTaskProviders, providerName, signInReason } from './provider-auth.js';
6
- import { scopeTermsProblem } from './store.js';
9
+ import { scopeTermsProblem, stopFactOf } from './store.js';
7
10
  import { plainReasonOf, replacedWords, stageOfCode, taskStatusOf, workToneOf } from './task-status.js';
8
11
  import { isCheckLevel } from './check-levels.js';
12
+ import { MARKER } from './worktree.js';
9
13
  import { withFollowUps } from './result-follow-ups.js';
14
+ import { NEEDS, processNeedOf } from './needs-you.js';
10
15
  export const WORK_INDEX_PAGE_LIMIT = 40;
11
16
  export const WORK_INDEX_MAX_LIMIT = 100;
12
17
  const EMPTY = { all: 0, 'needs-you': 0, running: 0, completed: 0 };
@@ -20,6 +25,8 @@ const s = (row, key) => row[key] == null ? null : String(row[key]);
20
25
  * MATERIALIZED prevents counts + page selection repeating correlated reads. */
21
26
  /** A gate note counts only while its pause is on one of the task's current providers. */
22
27
  const GATE_PAUSE_ON_TASK = pauseOnTaskProviders('p', 'c.ref_id');
28
+ /** A row whose build may still be running (the store's reading, custodyReadings): Waiting, not Needs you. */
29
+ const CUSTODY_WAITS = `(custody_unresolved AND code IN ('process-needs-attention','result-needs-attention') AND ref_id IN (SELECT value FROM json_each($waitingCustody)))`;
23
30
  const PROJECTION = `WITH RECURSIVE admitted AS MATERIALIZED (
24
31
  SELECT t.id, t.title, t.state, t.created_at, t.updated_at, r.id ref_id, r.repo,
25
32
  r.revision_of, r.revision_brief_artifact, r.assigned_runner, r.plan,
@@ -55,7 +62,8 @@ const PROJECTION = `WITH RECURSIVE admitted AS MATERIALIZED (
55
62
  (SELECT id FROM run WHERE task_ref=a.ref_id AND outcome IS NULL ORDER BY id DESC LIMIT 1) unfinished,
56
63
  CASE WHEN a.state='done' THEN (SELECT id FROM run WHERE task_ref=a.ref_id AND finished_at IS NOT NULL AND role IN ('builder','scout') ORDER BY id DESC LIMIT 1) END result_id,
57
64
  (SELECT h.id FROM hold h WHERE h.task_ref=a.ref_id AND (h.until IS NULL OR h.until>$now) ORDER BY h.held_at,h.id LIMIT 1) hold_id,
58
- q.decision_id,cu.task_ref IS NOT NULL custody_unresolved
65
+ q.decision_id,(cu.task_ref IS NOT NULL OR a.ref_id IN (SELECT value FROM json_each($workspaceCustody)))
66
+ AND a.ref_id NOT IN (SELECT value FROM json_each($settledCustody)) custody_unresolved
59
67
  FROM admitted a LEFT JOIN lineage l ON l.ref_id=a.ref_id
60
68
  LEFT JOIN questions q ON q.task_ref=a.ref_id LEFT JOIN custody cu ON cu.task_ref=a.ref_id
61
69
  ), family_heads AS MATERIALIZED (
@@ -197,9 +205,9 @@ const PROJECTION = `WITH RECURSIVE admitted AS MATERIALIZED (
197
205
  ELSE 'queued' END code
198
206
  FROM checked f
199
207
  ), ranked AS MATERIALIZED (
200
- SELECT c.*,code NOT IN ('cancelled','complete','running','updating','retry-scheduled','waiting-dependency','worker-at-capacity','planning-ready','scouting-ready','queued') needs,
208
+ SELECT c.*,code NOT IN ('cancelled','complete','running','updating','retry-scheduled','waiting-dependency','worker-at-capacity','planning-ready','scouting-ready','queued') AND NOT ${CUSTODY_WAITS} needs,
201
209
  CASE WHEN code='complete' THEN 3 WHEN code='cancelled' THEN 4 WHEN code='running' THEN 1
202
- WHEN code IN ('updating','retry-scheduled','waiting-dependency','worker-at-capacity','planning-ready','scouting-ready','queued') THEN 2 ELSE 0 END rank,
210
+ WHEN ${CUSTODY_WAITS} OR code IN ('updating','retry-scheduled','waiting-dependency','worker-at-capacity','planning-ready','scouting-ready','queued') THEN 2 ELSE 0 END rank,
203
211
  CASE WHEN code='complete' THEN completed.at ELSE family_updated END sort_at,
204
212
  completed.actor checked_actor,completed.at checked_at,completed.outcome checked_digest
205
213
  FROM classified c LEFT JOIN action_ledger completed ON completed.id=c.checked_id WHERE ($state IS NULL OR c.state=$state) AND ($leadId IS NULL OR EXISTS(SELECT 1 FROM team_task_owner own WHERE own.task_ref=c.root_ref AND own.lead=$leadId))
@@ -235,8 +243,38 @@ function registerValidators(store) {
235
243
  registered.set(db, projection);
236
244
  return projection;
237
245
  }
238
- function parameters(now, access, options) {
239
- return { $now: now.toISOString(), $alive: new Date(now.getTime() - 180_000).toISOString(),
246
+ /** The list can't see processes: each task whose build Toolroll can't yet
247
+ * confirm stopped, as the store reads it now (the task page's own reading).
248
+ * Null when it has since settled; `open` runs are the classification's own.
249
+ * `workspace`: a finished task whose last build's workspace note is still
250
+ * there, which only the store can read (a held or unreadable workspace). */
251
+ function custodyReadings(store) {
252
+ const refs = store.handle.prepare(`SELECT r.task_ref FROM held_session h JOIN run r ON r.id=h.run WHERE h.ended_at IS NULL
253
+ UNION SELECT r.task_ref FROM run r WHERE r.provider_started_at IS NOT NULL AND NOT EXISTS(SELECT 1 FROM run_process p WHERE p.run=r.id)
254
+ UNION SELECT r.task_ref FROM run_process p JOIN run r ON r.id=p.run WHERE p.exited_at IS NULL AND p.container_empty_at IS NULL`).all().map(row => Number(row['task_ref']));
255
+ const noted = store.handle.prepare(`SELECT r.task_ref,r.worktree FROM task t JOIN task_ref tr ON tr.external_id=t.id
256
+ JOIN run r ON r.id=(SELECT MAX(id) FROM run WHERE task_ref=tr.id AND role IN ('builder','scout') AND finished_at IS NOT NULL)
257
+ WHERE t.state='done' AND r.worktree IS NOT NULL`).all()
258
+ .filter(row => existsSync(join(String(row['worktree']), MARKER))).map(row => Number(row['task_ref']));
259
+ const readings = new Map();
260
+ for (const ref of new Set([...refs, ...noted])) {
261
+ let found = null;
262
+ for (const run of store.runsFor(ref)) {
263
+ const fact = stopFactOf(store, run.id);
264
+ if (fact !== null && fact.kind !== 'open') {
265
+ found = fact;
266
+ break;
267
+ }
268
+ }
269
+ readings.set(ref, found);
270
+ }
271
+ return { readings, workspace: new Set(noted) };
272
+ }
273
+ function parameters(now, access, options, custody) {
274
+ const ids = (keep) => JSON.stringify([...custody.readings].filter(([ref, fact]) => keep(ref, fact)).map(([ref]) => ref));
275
+ return { $settledCustody: ids((_, fact) => fact === null), $waitingCustody: ids((_, fact) => fact !== null && 'wait' in (processNeedOf(fact) ?? {})),
276
+ $workspaceCustody: ids((ref, fact) => fact !== null && custody.workspace.has(ref)),
277
+ $now: now.toISOString(), $alive: new Date(now.getTime() - 180_000).toISOString(),
240
278
  $all: access.repos === null ? 1 : 0, $repos: JSON.stringify(access.repos ?? []),
241
279
  $unplaced: access.principal === 'operator' && access.includeUnplaced === true ? 1 : 0,
242
280
  $projectSet: options.project != null ? 1 : 0, $project: options.project ?? null, $state: options.state ?? null, $leadId: options.leadId ?? null };
@@ -279,6 +317,7 @@ function readCursor(value, scope) {
279
317
  * Cursors bind the admitted scope, project and view, never broaden access. */
280
318
  export function workIndexPage(store, now, access, options = {}) {
281
319
  const projection = registerValidators(store);
320
+ const custody = custodyReadings(store);
282
321
  const view = VIEWS.includes(options.view ?? 'all') ? options.view ?? 'all' : 'all';
283
322
  const limit = Number.isFinite(options.limit) ? Math.max(1, Math.min(WORK_INDEX_MAX_LIMIT, Math.floor(options.limit))) : WORK_INDEX_PAGE_LIMIT;
284
323
  const scope = cursorScope(access, options, view), cursor = readCursor(options.cursor, scope);
@@ -309,21 +348,39 @@ export function workIndexPage(store, now, access, options = {}) {
309
348
  'question_run',(SELECT run FROM decision WHERE id=page.question_id),'replaced_by',(SELECT successor FROM task_replacement WHERE task_ref=page.ref_id),
310
349
  'completed_by_lead',(SELECT lead FROM task_act WHERE task_ref=page.root_ref AND act='completed' ORDER BY id DESC LIMIT 1),
311
350
  'question_task',(SELECT r.external_id FROM decision d JOIN run ON run.id=d.run JOIN task_ref r ON r.id=run.task_ref WHERE d.id=page.question_id)) FROM page`)
312
- .all({ ...parameters(now, access, options), $cursorRoot: cursor?.root ?? 0, $cursorRank: cursor?.rank ?? 0, $cursorAt: cursor?.at ?? '', $limit: limit + 1, $recent: new Date(now.getTime() - 86_400_000).toISOString() });
351
+ .all({ ...parameters(now, access, options, custody), $cursorRoot: cursor?.root ?? 0, $cursorRank: cursor?.rank ?? 0, $cursorAt: cursor?.at ?? '', $limit: limit + 1, $recent: new Date(now.getTime() - 86_400_000).toISOString() });
313
352
  const selected = rows.slice(1).map(row => JSON.parse(String(row['row_json'])));
314
353
  const page = selected.slice(0, limit), last = page.at(-1);
315
354
  const nextCursor = selected.length > limit && last !== undefined ? Buffer.from(JSON.stringify({ version: 1, scope,
316
355
  rank: n(last, 'rank'), at: s(last, 'sort_at'), root: n(last, 'root_ref') })).toString('base64url') : null;
317
356
  const projects = JSON.parse(String(rows[0]?.['row_json'] ?? '[]')).map(row => ({ repo: s(row, 'repo'), totals: counts(row), queued: n(row, 'queued'), doneRecently: n(row, 'done_recently') }));
318
- return { items: page.map(row => itemOf(row, access.principal)), projects, totals: rows[0] === undefined ? { ...EMPTY } : counts(rows[0]), nextCursor, limit, view };
357
+ // A row whose build Toolroll can't confirm stopped reads the store's own answer, as the task page does.
358
+ const probe = (taskId) => {
359
+ const ref = store.lookupRef(taskId);
360
+ return ref === null ? null : custody.readings.get(ref.id) ?? null;
361
+ };
362
+ return { items: page.map(row => itemOf(row, access.principal, probe)), projects, totals: rows[0] === undefined ? { ...EMPTY } : counts(rows[0]), nextCursor, limit, view };
319
363
  }
320
364
  export function workCountsByProject(store, now, access) {
321
365
  const projection = registerValidators(store);
322
366
  return store.handle.prepare(`${projection} SELECT repo,${TOTALS},SUM(state='queued' AND family_running=0) queued,
323
367
  SUM(state='done' AND family_running=0 AND updated_at>=$recent) done_recently FROM ranked GROUP BY repo ORDER BY repo`)
324
- .all({ ...parameters(now, access, {}), $recent: new Date(now.getTime() - 86_400_000).toISOString() })
368
+ .all({ ...parameters(now, access, {}, custodyReadings(store)), $recent: new Date(now.getTime() - 86_400_000).toISOString() })
325
369
  .map(row => ({ repo: s(row, 'repo'), totals: counts(row), queued: n(row, 'queued'), doneRecently: n(row, 'done_recently') }));
326
370
  }
371
+ /** Families whose headline is Ready for review, newest first: the Tasks
372
+ * list's own rows, so a completed family (root or any revision), a failed
373
+ * check and a superseded version never count. */
374
+ export function workReadyForReview(store, now, access, limit = 5) {
375
+ const ready = [];
376
+ let cursor = null;
377
+ do {
378
+ const page = workIndexPage(store, now, access, { view: 'needs-you', limit: WORK_INDEX_MAX_LIMIT, cursor });
379
+ ready.push(...page.items.filter(one => one.status.label === 'Ready for review'));
380
+ cursor = page.nextCursor;
381
+ } while (cursor !== null);
382
+ return { count: ready.length, results: ready.slice(0, Math.max(0, limit)).map(one => ({ rootId: one.rootId, taskId: one.activeTaskId, run: one.resultRunId })) };
383
+ }
327
384
  /** Labels stay navigation, never an authorization to mutate. Full task
328
385
  * opening re-proves process custody, receipt bytes and approval terms. */
329
386
  /** The list's Checks: the build's own result, its level, and follow-up checks on the same commit (result-follow-ups.ts). */
@@ -347,9 +404,16 @@ function listChecksOf(own, level, follows) {
347
404
  return null;
348
405
  return { status: read.status, exitCode: null, head: null, level: read.level, running: read.running };
349
406
  }
350
- function itemOf(row, principal) {
407
+ /** Index codes that wait on a person, as their need (needs-you.ts); dispatch codes come from task-status's own table. */
408
+ const INDEX_NEED = {
409
+ 'earlier-active': 'earlier-version', 'process-needs-attention': 'confirm-stopped', 'invalid-scope': 'define-task', 'decision-queue': 'questions',
410
+ };
411
+ function itemOf(row, principal, probe) {
351
412
  const code = String(row['code']), id = String(row['id']);
352
- const need = n(row, 'needs') === 1, running = n(row, 'family_running') === 1;
413
+ const custody = code === 'process-needs-attention' || (code === 'result-needs-attention' && n(row, 'custody_unresolved') === 1);
414
+ const process = custody && probe !== undefined ? processNeedOf(probe(id)) : null;
415
+ const waiting = process !== null && 'wait' in process;
416
+ const need = n(row, 'needs') === 1 && !waiting, running = n(row, 'family_running') === 1;
353
417
  const assignmentState = code === 'complete' ? 'complete' : code === 'ready-to-check' ? 'ready-to-check'
354
418
  : code === 'cancelled' ? 'cancelled' : need ? 'needs-decision' : 'working';
355
419
  const words = {
@@ -375,7 +439,7 @@ function itemOf(row, principal) {
375
439
  'needs-scope': ['Needs your decision', 'Define the task before a worker can start.', 'write-scope', 'Define the task'],
376
440
  'invalid-scope': ['Needs your decision', 'The saved task scope cannot be read safely.', 'write-scope', 'Review scope'],
377
441
  'needs-agent-profile': ['Needs your decision', 'Choose an available agent for this task.', 'select-agent', 'Choose an agent'],
378
- 'needs-approval': ['Needs your decision', 'Review the current plan before a worker can start.', 'approve-scope', 'Review plan'],
442
+ 'needs-approval': ['Needs your decision', 'Review the current plan before a worker can start.', 'approve-scope', 'Approve plan'],
379
443
  'missing-requirement': ['Needs your decision', 'A required capability is missing or expired.', 'repair-capability', 'Review missing requirement'],
380
444
  'no-worker-registered': ['Needs your decision', 'No connected builder includes this project.', 'start-worker', 'Check connection'],
381
445
  'no-worker-online': ['Needs your decision', 'The builder has stopped checking in.', 'start-worker', 'Check connection'],
@@ -409,6 +473,18 @@ function itemOf(row, principal) {
409
473
  actionCode = 'open-result';
410
474
  actionLabel = 'Open result';
411
475
  }
476
+ if (process !== null && 'need' in process) {
477
+ actionCode = 'confirm-stopped';
478
+ actionLabel = NEEDS[process.need].action.label;
479
+ }
480
+ else if (custody && probe === undefined) {
481
+ actionCode = 'confirm-stopped';
482
+ actionLabel = NEEDS['confirm-stopped'].action.label;
483
+ }
484
+ if (code === 'signed-out') {
485
+ actionCode = 'sign-in';
486
+ actionLabel = NEEDS['sign-in'].action.label;
487
+ }
412
488
  if (actionCode === null) {
413
489
  actionCode = code === 'running' ? 'inspect-run' : 'inspect-task';
414
490
  actionLabel = code === 'running' ? 'Watch the build' : 'View task details';
@@ -417,21 +493,32 @@ function itemOf(row, principal) {
417
493
  const primaryAction = actionCode === null ? null : { code: actionCode, label: actionLabel,
418
494
  target: { taskId: successor ?? (code === 'earlier-active' ? s(row, 'earlier_id') ?? id : actionCode === 'answer-decision' ? s(row, 'question_task') ?? id : id),
419
495
  runId: actionCode === 'answer-decision' ? n(row, 'question_run') || null : actionCode === 'inspect-run' && code === 'running' ? n(row, 'live_run') || null : actionCode === 'open-result' || actionCode === 'inspect-run' ? n(row, 'result_id') || null
420
- : actionCode === 'reconcile-run' ? n(row, 'unfinished') || null : ['resume-run', 'inspect-stop'].includes(actionCode) ? n(row, 'stop_run') || null : null,
496
+ : actionCode === 'reconcile-run' ? n(row, 'unfinished') || null : ['resume-run', 'inspect-stop'].includes(actionCode) ? n(row, 'stop_run') || null
497
+ : actionCode === 'confirm-stopped' ? process?.build ?? (n(row, 'result_id') || null) : null,
421
498
  decisionId: actionCode === 'answer-decision' ? n(row, 'question_id') || null : null },
422
499
  access: read ? 'read' : principal === 'operator' ? 'operator-control' : ['answer-decision', 'write-scope', 'unhold'].includes(actionCode) ? 'proposal-only' : 'operator-handoff',
423
500
  retry: actionCode === 'reconcile-run' ? 'reconcile-before-retry' : read ? 'read-again' : 'refresh-before-acting' };
424
501
  // The one shared status: the headline and sentence every surface uses.
425
- const reading = stageOfCode(code, { needsPerson: need, planning: s(row, 'live_role') === 'planner', operatorHold: s(row, 'hold_kind') === 'operator' });
502
+ const coded = stageOfCode(code, { needsPerson: need, planning: s(row, 'live_role') === 'planner', operatorHold: s(row, 'hold_kind') === 'operator' });
503
+ const reading = process !== null
504
+ ? 'wait' in process ? { stage: 'waiting', wait: process.wait } : { stage: 'needs-you', need: process.need }
505
+ : custody ? { stage: 'needs-you', need: 'confirm-stopped' }
506
+ : coded.stage === 'needs-you' && (coded.need === undefined || coded.need === 'other') && INDEX_NEED[code] !== undefined ? { stage: 'needs-you', need: INDEX_NEED[code] } : coded;
426
507
  const checkStatus = s(row, 'check_status'), verdict = s(row, 'proof_verdict');
427
508
  const finished = reading.stage === 'finished' || reading.stage === 'complete';
428
509
  const own = checkStatus === 'failed' || n(row, 'proof_failed_check') === 1 ? 'failed' : checkStatus === 'passed' || verdict === 'verified' ? 'passed' : null;
429
- const shared = taskStatusOf({ stage: reading.stage, ...(reading.need === undefined ? {} : { need: reading.need }),
510
+ const shared = taskStatusOf({ stage: reading.stage, ...(reading.need === undefined ? {} : { need: reading.need }), ...(reading.wait === undefined ? {} : { wait: reading.wait }),
511
+ ...(process === null ? {} : { needContext: { build: process.build } }),
430
512
  reason: finished ? null : plainReasonOf(reading.stage, code, detail || null), report: s(row, 'result_role') === 'scout',
431
513
  checks: !finished ? null : listChecksOf(own, s(row, 'check_level'), row['check_follows']),
432
514
  completedBy: code !== 'complete' ? null : n(row, 'completed_by_lead') === 1 ? 'the lead' : String(row['checked_actor']).replace(/^(?:operator|coordinator|lead):/, '') });
433
515
  label = shared.headline;
434
516
  detail = shared.sentence;
517
+ // Every Needs you action wears its need's own words.
518
+ if (shared.need != null && shared.need.key !== 'other' && shared.need.key !== 'review-result' && primaryAction !== null) {
519
+ actionLabel = shared.need.action.label;
520
+ primaryAction.label = actionLabel;
521
+ }
435
522
  const views = ['all'];
436
523
  if (need)
437
524
  views.push('needs-you');
@@ -8,7 +8,9 @@ export type WorkTarget = {
8
8
  decisionId: number | null;
9
9
  };
10
10
  export type WorkAction = {
11
- code: DispatchAction | "inspect-task" | "inspect-run" | "inspect-stop" | "inspect-decisions" | "reconcile-run" | "open-pr";
11
+ /** `confirm-stopped`: an approver confirms a finished build stopped (behind the password);
12
+ * `sign-in`: the agent's sign-in, in Settings. */
13
+ code: DispatchAction | "inspect-task" | "inspect-run" | "inspect-stop" | "inspect-decisions" | "reconcile-run" | "open-pr" | "confirm-stopped" | "sign-in";
12
14
  label: string;
13
15
  target: WorkTarget;
14
16
  access: "read" | "operator-control" | "proposal-only" | "operator-handoff";
@@ -6,6 +6,7 @@ import { resultCheckLevel } from "./result-follow-ups.js";
6
6
  import { diagnoseTaskDispatch } from "./dispatch.js";
7
7
  import { isAlive } from "./runner.js";
8
8
  import { taskControlOf } from "./task-control.js";
9
+ import { buildReviewOf } from "./review-switch.js";
9
10
  import { reviewFactsOf, workStatusOf } from "./workspace-ui.js";
10
11
  /** Call only after admitting the task's project. Keep the question's
11
12
  * exact run and id; never substitute a family's newest attempt. Expiry
@@ -103,7 +104,7 @@ export function taskWorkSummaryOf(store, taskId, now, access) {
103
104
  review: reviewFactsOf(store.reviewRetryStateOf(result.id), name => {
104
105
  const runner = store.getRunner(name)?.runner;
105
106
  return runner !== undefined && isAlive(runner, now);
106
- }),
107
+ }, buildReviewOf(store, result.id)?.state === "pending"),
107
108
  ...(result.outcome !== "no-change" ? {} : { recordComplete: (() => {
108
109
  const kinds = new Set(store.artifactsFor(result.id).map(one => one.kind));
109
110
  return kinds.has("handoff") && kinds.has("terminal-diff");
@@ -68,11 +68,13 @@ export type ReviewFacts = {
68
68
  queuedOrigin?: "operator" | "automatic" | null;
69
69
  /** Whether the live attempt's reviewer worker is answering. */
70
70
  reviewerAlive: boolean;
71
+ /** The build's one automatic review is still pending (build-review.ts). */
72
+ automaticPending?: boolean;
71
73
  };
72
74
  /** The review facts from the store's bounded retry projection — the same
73
75
  * reading `diagnoseTaskDispatch` makes, so every surface starts from one
74
76
  * record. `reviewerAlive` answers for the live attempt's runner. */
75
- export declare function reviewFactsOf(retry: ReviewRetryState | null, reviewerAlive: (runner: string) => boolean): ReviewFacts | null;
77
+ export declare function reviewFactsOf(retry: ReviewRetryState | null, reviewerAlive: (runner: string) => boolean, automaticPending?: boolean): ReviewFacts | null;
76
78
  /** The tokens a review in flight wears — the dispatch codes, unchanged. */
77
79
  export declare const REVIEW_TOKENS: ReadonlySet<string>;
78
80
  /**
@@ -30,7 +30,7 @@ export function parseWorkView(raw) {
30
30
  /** The review facts from the store's bounded retry projection — the same
31
31
  * reading `diagnoseTaskDispatch` makes, so every surface starts from one
32
32
  * record. `reviewerAlive` answers for the live attempt's runner. */
33
- export function reviewFactsOf(retry, reviewerAlive) {
33
+ export function reviewFactsOf(retry, reviewerAlive, automaticPending = false) {
34
34
  if (retry === null || retry.state === "unrequested")
35
35
  return null;
36
36
  const current = retry.live ?? (retry.state === "queued" ? null : retry.latest);
@@ -45,6 +45,7 @@ export function reviewFactsOf(retry, reviewerAlive) {
45
45
  queuedBy: retry.openRequest?.requestedBy ?? null,
46
46
  queuedOrigin: retry.openRequest?.origin ?? null,
47
47
  reviewerAlive: retry.live !== null && reviewerAlive(retry.live.runner),
48
+ automaticPending,
48
49
  };
49
50
  }
50
51
  /** The tokens a review in flight wears — the dispatch codes, unchanged. */
@@ -59,12 +60,12 @@ const retriesLeft = (count, explicit = true) => `${count}${explicit ? " explicit
59
60
  * the v50 dispatch contract; nothing here changes the lifecycle.
60
61
  */
61
62
  export function reviewStatusOf(review) {
62
- // Model review is retired (2026-09-21): nothing schedules, retries or
63
- // waits for a reviewer, so a recorded review request or attempt is
64
- // history and never the result's primary status. The stored verdict and
65
- // the assignment's Ready/Complete state speak instead.
66
- void review;
67
- return null;
63
+ // The one automatic review (build-review.ts) holds the result only while it
64
+ // is queued or running. A failed review never blocks: the result reads as
65
+ // it would unreviewed, marked "not reviewed" by the result itself.
66
+ if (review === null || review.automaticPending !== true || (review.state !== "queued" && review.state !== "running"))
67
+ return null;
68
+ return { token: "reviewing", label: "Reviewing", detail: "An automatic review is reading this result before it reaches you.", tone: "live", action: { label: "Open the result", kind: "open-result" } };
68
69
  }
69
70
  const FAILED_CHECK = /^the repository's approved verification command exited (-?[0-9]+)/;
70
71
  export function evidenceProblemOf(verdict, reasons) {
@@ -123,7 +124,7 @@ export function publicationStatusOf(publication) {
123
124
  * the detail).
124
125
  */
125
126
  export function resultStatusOf(result, publication = null) {
126
- return storedResultStatusOf(result, publication);
127
+ return (result?.role === "builder" ? reviewStatusOf(result.review ?? null) : null) ?? storedResultStatusOf(result, publication);
127
128
  }
128
129
  /** What the machine itself recorded about a result, in a clause. */
129
130
  function machineVerdictWords(result) {
@@ -290,7 +291,7 @@ const DISPATCH_ACTION_LABELS = {
290
291
  "place-task": "Choose a project",
291
292
  "write-scope": "Define the task",
292
293
  "select-agent": "Choose an agent",
293
- "approve-scope": "Review plan",
294
+ "approve-scope": "Approve plan",
294
295
  "answer-decision": "Answer the question",
295
296
  unhold: "Review hold",
296
297
  "inspect-hold": "Review hold",
@@ -76,6 +76,8 @@ export type KeptWhy = "has changes" | "has commits" | "unreadable" | "git refuse
76
76
  * something is in there.
77
77
  */
78
78
  export declare const MARKER = ".standing-orders-lease";
79
+ /** What a kept checkout drops once its run ends (only where .gitignore hides it): dependencies and build output. */
80
+ export declare const SLIM_NAMES: readonly string[];
79
81
  /** Creating a worktree copies a tree; it is local work but not instant. */
80
82
  export declare const WORKTREE_TIMEOUT_MS = 60000;
81
83
  /**
@@ -251,6 +253,20 @@ export declare class WorktreePool {
251
253
  why: KeptWhy;
252
254
  }[];
253
255
  }>;
256
+ /**
257
+ * Drop a let-go, idle checkout's dependencies and build output: the folders .gitignore hides that are named like
258
+ * them (SLIM_NAMES). Its work, tracked or untracked, is untouched. The setup stamp goes first, so the next lease
259
+ * runs the project's setup again and restores them. `wanted` is asked again right before anything goes; `deps`
260
+ * false keeps node_modules (no setup would restore it).
261
+ */
262
+ slim(row: WorktreeRow, wanted: (row: WorktreeRow) => boolean, deps?: boolean): Promise<{
263
+ ok: true;
264
+ dropped: string[];
265
+ bytes: number;
266
+ } | {
267
+ ok: false;
268
+ message: string;
269
+ }>;
254
270
  /**
255
271
  * Throw away a let-go checkout kept for its changes, on purpose: the working copy and what is uncommitted in it
256
272
  * go; its branch, and every commit on it, stays. Refused while anything holds it.
package/dist/worktree.js CHANGED
@@ -30,6 +30,7 @@ import { run } from "./exec.js";
30
30
  import { currentBootId, provenDeadByBootChange } from "./boot-identity.js";
31
31
  import { HANDOFF_PREFIX, MAILBOX_SUFFIX, looksLikeProtocolFile, readMailbox } from "./evidence.js";
32
32
  import { parseHandoff } from "./decision.js";
33
+ import { diskBytes } from "./storage.js";
33
34
  /**
34
35
  * The note a lease leaves in the checkout, naming the process holding it.
35
36
  *
@@ -40,6 +41,8 @@ import { parseHandoff } from "./decision.js";
40
41
  export const MARKER = ".standing-orders-lease";
41
42
  /** Checkouts this process is leasing or removing right now: a lease and storage retention never overlap on one. */
42
43
  const busy = new Map();
44
+ /** What a kept checkout drops once its run ends (only where .gitignore hides it): dependencies and build output. */
45
+ export const SLIM_NAMES = ["node_modules", "dist", "build", "out", ".next", ".nuxt", ".svelte-kit", ".turbo", ".parcel-cache", "coverage", "target"];
43
46
  /** Creating a worktree copies a tree; it is local work but not instant. */
44
47
  export const WORKTREE_TIMEOUT_MS = 60_000;
45
48
  const GIT = "git";
@@ -706,6 +709,50 @@ export class WorktreePool {
706
709
  }
707
710
  return { removed, kept };
708
711
  }
712
+ /**
713
+ * Drop a let-go, idle checkout's dependencies and build output: the folders .gitignore hides that are named like
714
+ * them (SLIM_NAMES). Its work, tracked or untracked, is untouched. The setup stamp goes first, so the next lease
715
+ * runs the project's setup again and restores them. `wanted` is asked again right before anything goes; `deps`
716
+ * false keeps node_modules (no setup would restore it).
717
+ */
718
+ async slim(row, wanted, deps = true) {
719
+ const idle = (now) => now !== null && now.runner === null && now.releasedAt !== null && now.releasedAt === row.releasedAt && !this.inUse(row.path).held;
720
+ if (busy.has(row.path) || !existsSync(row.path) || !idle(this.store.getWorktree(row.path)))
721
+ return { ok: false, message: `${row.path} is in use` };
722
+ const listed = await this.runner(GIT, [...READ_ONLY, "ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z"], { cwd: row.path, timeoutMs: WORKTREE_TIMEOUT_MS });
723
+ if (listed.code !== 0)
724
+ return { ok: false, message: `git couldn't list ${row.path}` };
725
+ const drop = listed.stdout.split("\0").filter(one => one.endsWith("/")).map(one => one.slice(0, -1))
726
+ .filter(one => { const name = one.split("/").at(-1); return SLIM_NAMES.includes(name) && (deps || name !== "node_modules"); });
727
+ if (drop.length === 0)
728
+ return { ok: true, dropped: [], bytes: 0 };
729
+ busy.set(row.path, "removing");
730
+ const note = join(row.path, MARKER);
731
+ let held = false;
732
+ try {
733
+ const now = this.store.getWorktree(row.path);
734
+ if (!idle(now) || !wanted(now))
735
+ return { ok: false, message: `${row.path} is in use` };
736
+ // Held while it slims: another process's lease sees this process in it.
737
+ try {
738
+ writeFileSync(note, `${process.pid} storage-retention slimming ${currentBootId() ?? "unknown"} ${hostname()}\n`, "utf8");
739
+ held = true;
740
+ }
741
+ catch {
742
+ return { ok: false, message: `${row.path} couldn't be held` };
743
+ }
744
+ const sizes = diskBytes(drop.map(one => join(row.path, one)));
745
+ this.store.forgetWorktreeSetup(row.path);
746
+ for (const one of drop)
747
+ rmSync(join(row.path, one), { recursive: true, force: true, maxRetries: 3 });
748
+ return { ok: true, dropped: drop, bytes: [...sizes.values()].reduce((sum, one) => sum + one, 0) };
749
+ }
750
+ finally {
751
+ if (held)
752
+ rmSync(note, { force: true });
753
+ busy.delete(row.path);
754
+ }
755
+ }
709
756
  /**
710
757
  * Throw away a let-go checkout kept for its changes, on purpose: the working copy and what is uncommitted in it
711
758
  * go; its branch, and every commit on it, stays. Refused while anything holds it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolroll",
3
- "version": "0.9.7",
3
+ "version": "0.9.9",
4
4
  "description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
5
5
  "directories": {
6
6
  "doc": "docs"