toolroll 0.9.101 → 0.9.103

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 (42) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/browser/terminal.js +9 -9
  3. package/dist/browser/workspace.css +1 -1
  4. package/dist/browser/workspace.js +38 -38
  5. package/dist/browser-workspace.d.ts +51 -14
  6. package/dist/browser-workspace.js +5 -3
  7. package/dist/flow-engine.d.ts +3 -0
  8. package/dist/flow-engine.js +4 -1
  9. package/dist/flows-ui.d.ts +31 -1
  10. package/dist/flows-ui.js +40 -12
  11. package/dist/page-work-contract.d.ts +21 -0
  12. package/dist/page-work-contract.js +5 -0
  13. package/dist/project-knowledge.d.ts +9 -9
  14. package/dist/release-approval.d.ts +9 -20
  15. package/dist/release-approval.js +14 -53
  16. package/dist/release-refusal.d.ts +32 -0
  17. package/dist/release-refusal.js +73 -0
  18. package/dist/run-efficiency.d.ts +3 -2
  19. package/dist/run-efficiency.js +4 -3
  20. package/dist/scope.js +6 -0
  21. package/dist/serve.js +51 -9
  22. package/dist/server/chrome.d.ts +10 -1
  23. package/dist/server/chrome.js +9 -0
  24. package/dist/server/console.css +19 -4
  25. package/dist/server/home-asks.d.ts +45 -0
  26. package/dist/server/home-asks.js +108 -0
  27. package/dist/server/home.d.ts +37 -14
  28. package/dist/server/home.js +113 -63
  29. package/dist/server/http.d.ts +6 -0
  30. package/dist/server/http.js +8 -0
  31. package/dist/server/in-place.d.ts +15 -0
  32. package/dist/server/in-place.js +32 -0
  33. package/dist/server/pages.js +36 -3
  34. package/dist/server/render-tasks.js +14 -3
  35. package/dist/server/request-context.d.ts +14 -0
  36. package/dist/server/request-context.js +3 -0
  37. package/dist/server/task-views.js +6 -3
  38. package/dist/server/tasks.d.ts +8 -2
  39. package/dist/server/tasks.js +31 -17
  40. package/dist/work-index.d.ts +19 -1
  41. package/dist/work-index.js +40 -14
  42. package/package.json +1 -1
@@ -80,7 +80,7 @@ import { buildProgressOf, parseWorkView } from "../workspace-ui.js";
80
80
  import { requestContext } from "./request-context.js";
81
81
  import { boardTickScript, oneLineOf, QUEUE_VIEW, refuse, regionScript, screen, transcriptScript, whenTime } from "./chrome.js";
82
82
  import { retiredRedirect } from "./retired-routes.js";
83
- import { chatReturnWithSaid, matchTaskPath, redirect, respond, reviewHref, safeChatReturn, safeReturn, SAFETY, taskChatHref, taskHref } from "./http.js";
83
+ import { answersInPlace, chatReturnWithSaid, matchTaskPath, redirect, respond, reviewHref, safeChatReturn, safeReturn, SAFETY, taskChatHref, taskHref } from "./http.js";
84
84
  import { putAwayPage, boardBody, boardWorkers, QUEUE_FRONT, queueBody, queueScript, TASK_STATES, workPage } from "./render-pages.js";
85
85
  import { runEfficiencyOf } from "../run-efficiency.js";
86
86
  import { timingFor } from "../latency-timeline.js";
@@ -1165,7 +1165,7 @@ export function createTasksHandlers(runtime) {
1165
1165
  const act = matchTaskPath(url.pathname, `/${verb}$`);
1166
1166
  if (act === null)
1167
1167
  return noPage(who, response);
1168
- return taskMutation(response, who, act.taskId, verb, posted, now);
1168
+ return taskMutation(response, who, act.taskId, verb, posted, now, ctx.request);
1169
1169
  };
1170
1170
  // Cockpit E: Show in Finder — the checkout proved again right now (live, this machine's runner, inside the pool),
1171
1171
  // then Finder asked to reveal it with a fixed argv. Nothing runs in it; a Mac only.
@@ -1632,7 +1632,9 @@ export function createTasksHandlers(runtime) {
1632
1632
  }
1633
1633
  }
1634
1634
  // ---- mutations -----------------------------------------------------------
1635
- function taskMutation(response, who, taskId, verb, posted, now) {
1635
+ function taskMutation(response, who, taskId, verb, posted, now,
1636
+ /** Read only to shape an answer in place (answersInPlace), after the act's proofs. */
1637
+ request = null) {
1636
1638
  const ref = store.lookupRef(taskId);
1637
1639
  if (ref === null || store.getTask(taskId) === null) {
1638
1640
  return refuse(response, who, 404, "no such task", "/tasks");
@@ -2277,9 +2279,13 @@ export function createTasksHandlers(runtime) {
2277
2279
  : safeChatReturn(requestedReturn);
2278
2280
  // The release sheet's Approve release (trusted-release-reports.md A7): the same door, its own page for words.
2279
2281
  const sheet = body.get("sheet") === "release";
2280
- const approvalProblem = (message, status) => {
2282
+ // Each caller names its outcome (live-pages-and-home.md B2.3); none is inferred from the status.
2283
+ const approvalProblem = (outcome, message, status) => {
2281
2284
  if (sheet)
2282
2285
  return releaseSheet.sheetScreen({ who, response, project: null }, taskId, message, status);
2286
+ // Asked in place: the same proofs ran, only the answer's shape differs.
2287
+ if (answersInPlace(request))
2288
+ return answerApprovalInPlace(response, outcome, message, status);
2283
2289
  if (requestedReturn !== null && requestedReturn !== "next") {
2284
2290
  return redirect(response, chatReturnWithSaid(approvalBack, message));
2285
2291
  }
@@ -2287,7 +2293,7 @@ export function createTasksHandlers(runtime) {
2287
2293
  };
2288
2294
  const approvingRef = store.lookupRef(taskId);
2289
2295
  if (approvingRef?.plan === "requested") {
2290
- return approvalProblem("approval is blocked while planning is in progress — review the drafted plan first", 409);
2296
+ return approvalProblem("refused", "approval is blocked while planning is in progress — review the drafted plan first", 409);
2291
2297
  }
2292
2298
  // Step-up: the session got you here; only the token agrees. The
2293
2299
  // digest names what was seen; the nonce proves this exact form was
@@ -2296,25 +2302,25 @@ export function createTasksHandlers(runtime) {
2296
2302
  const token = body.get("token") ?? "";
2297
2303
  const nonce = body.get("nonce") ?? "";
2298
2304
  if (!consumeApprovalNonce(nonce, who.name, taskId, digest)) {
2299
- return approvalProblem("that approval form is stale — read it again", 409);
2305
+ return approvalProblem("refused", "that approval form is stale — read it again", 409);
2300
2306
  }
2301
2307
  // An empty password stands for an explicit identity-provider re-check not yet spent, or (cockpit B2) a password
2302
2308
  // remembered on this session; approve() decides whether these terms may stand on a remembered one.
2303
2309
  // Easy-start C5: a laptop's local owner approves eligible work with no password (approve() proves it again).
2304
2310
  if (token === "" && !passwordlessSessionFor(store, who.name) && !ssoNowAvailable(who.name) && withFreshness("approve-standard", () => freshnessFor(who.name)) === null
2305
2311
  && !(who.authority === "local-owner" && localApprovalEligible(store, who.name, taskId).ok)) {
2306
- return approvalProblem("approval requires your password, typed again", 400);
2312
+ return approvalProblem("refused", "approval requires your password, typed again", 400);
2307
2313
  }
2308
2314
  // The sheet binds the release card's digest (the nonce was minted for it): the shared card wrapper decides.
2309
2315
  if (sheet)
2310
2316
  return releaseSheet.approveFromSheet({ who, response, project: null }, taskId, digest, token);
2311
2317
  const scopeRow = store.getScope(taskId);
2312
2318
  if (scopeRow === null)
2313
- return approvalProblem("this task has no scope to approve", 409);
2319
+ return approvalProblem("refused", "this task has no scope to approve", 409);
2314
2320
  const planView = planViewOf(ref.id);
2315
2321
  const expectedDigest = approvalFormDigest(scopeRow.digest, planView?.sha256 ?? null);
2316
2322
  if (digest !== expectedDigest) {
2317
- return approvalProblem("the scope or plan changed while this form was open — read the latest version and approve again", 409);
2323
+ return approvalProblem("refused", "the scope or plan changed while this form was open — read the latest version and approve again", 409);
2318
2324
  }
2319
2325
  // A revision approves ONLY against a brief that still verifies
2320
2326
  // (Codex M5-M8 audit, IV-3): the batch the screen restated must be
@@ -2324,7 +2330,7 @@ export function createTasksHandlers(runtime) {
2324
2330
  if (approvingRef !== null && approvingRef.revisionBriefArtifact !== null) {
2325
2331
  const view = revisionViewOf(approvingRef);
2326
2332
  if (view !== null && "problem" in view) {
2327
- return approvalProblem(`approval is blocked: ${view.problem}`, 409);
2333
+ return approvalProblem("refused", `approval is blocked: ${view.problem}`, 409);
2328
2334
  }
2329
2335
  }
2330
2336
  // A re-check is spent only if this yes is kept (sealed, or a protected-work vote recorded).
@@ -2332,11 +2338,11 @@ export function createTasksHandlers(runtime) {
2332
2338
  if (!approved.ok) {
2333
2339
  // v102: a first yes on protected work is recorded, not refused — the page says who else is needed.
2334
2340
  if (approved.reason === "second-approver")
2335
- return approvalProblem(gateWords({ verdict: "vote", have: approved.have, need: 2, already: approved.already }), 200);
2341
+ return approvalProblem("vote", gateWords({ verdict: "vote", have: approved.have, need: 2, already: approved.already }), 200);
2336
2342
  if (approved.reason === "policy")
2337
- return approvalProblem(approved.message, 403);
2343
+ return approvalProblem("refused", approved.message, 403);
2338
2344
  const status = approved.reason === "changed" || approved.reason === "unrouted" ? 409 : 403;
2339
- return approvalProblem(approveRefusalWords(approved.reason), status);
2345
+ return approvalProblem("refused", approveRefusalWords(approved.reason), status);
2340
2346
  }
2341
2347
  // Approve & build again (the plan was rewritten after a build): the same yes requeues, only for the build the
2342
2348
  // sheet named, and only while it is still the task's latest result, made to an earlier plan than this one.
@@ -2348,10 +2354,12 @@ export function createTasksHandlers(runtime) {
2348
2354
  const offered = named != null && named.taskRef === ref.id && named.role === "builder" && !!named.scopeDigest && named.scopeDigest !== scopeRow.digest;
2349
2355
  const requeued = offered ? store.requeueTask(taskId, who.name, now, { run: named.id, scopeDigest: scopeRow.digest }) : null;
2350
2356
  if (requeued === null || (!requeued.ok && requeued.reason === "moved"))
2351
- return approvalProblem("Approved. The task changed while this page was open, so it wasn't built again — check its status.", 200);
2357
+ return approvalProblem("approved-not-rebuilt", "Approved. The task changed while this page was open, so it wasn't built again — check its status.", 200);
2352
2358
  if (!requeued.ok)
2353
- return approvalProblem(`Approved, but building again didn't start (${requeued.reason}). Use Build again.`, 200);
2359
+ return approvalProblem("approved-not-rebuilt", `Approved, but building again didn't start (${requeued.reason}). Use Build again.`, 200);
2354
2360
  }
2361
+ if (!sheet && answersInPlace(request))
2362
+ return answerApprovalInPlace(response, "approved", "Approved.", 200);
2355
2363
  return redirect(response, approvalBack);
2356
2364
  }
2357
2365
  case "accept-proof": {
@@ -2759,9 +2767,10 @@ export function createTasksHandlers(runtime) {
2759
2767
  summary: detail.handoff?.conclusion ?? run.handoff,
2760
2768
  },
2761
2769
  detail,
2762
- // How it ran: derived from the run's own records once it has finished, display only.
2770
+ // How it ran: derived from the run's own records once it has finished, display only. A read ahead of the page's
2771
+ // link (x-toolroll-read: prefetch) derives it and saves nothing.
2763
2772
  efficiency: (() => { try {
2764
- return runEfficiencyOf(store, evidenceRoot, run.id, now);
2773
+ return runEfficiencyOf(store, evidenceRoot, run.id, now, requestContext.getStore()?.readPurpose !== "prefetch");
2765
2774
  }
2766
2775
  catch {
2767
2776
  return null;
@@ -3325,3 +3334,8 @@ export function createReleaseSheetHandlers(runtime) {
3325
3334
  }
3326
3335
  return { routes: { "task.release": showSheet, "task.release.go-on": goOn, "release.locate": locate }, sheetScreen, approveFromSheet };
3327
3336
  }
3337
+ /** The answer to an approval asked in place: its named outcome and the same words the page would show, as JSON. Written
3338
+ * only after the approve route's proofs, by the caller that knows the outcome. */
3339
+ export function answerApprovalInPlace(response, outcome, words, status) {
3340
+ respond(response, status, "application/json; charset=utf-8", JSON.stringify({ outcome, words }));
3341
+ }
@@ -27,6 +27,8 @@ export type WorkIndexItem = {
27
27
  ask: Ask | null;
28
28
  /** The row's chip under its group: the specific ask (needs-you.ts askChipOf), or none. */
29
29
  chip: AskChip | null;
30
+ /** What a Needs you row asks, in Home's six groups (ASK_GROUP_SQL); null for every other row. */
31
+ askGroup: WorkAskGroup | null;
30
32
  primaryAction: WorkAction | null;
31
33
  completion: {
32
34
  actor: string;
@@ -45,7 +47,9 @@ export type WorkIndexItem = {
45
47
  at: string;
46
48
  };
47
49
  };
48
- /** `root`: the evidence root, so a result whose checks were Off can read the release check that covers it. */
50
+ /** `root`: the evidence root, so a result whose checks were Off can read the release check that covers it.
51
+ * `completedSince` (an ISO time): the Completed view lists only what was completed at or after it, filtered before the
52
+ * page limit, and the page's `completedSince` counts them all. */
49
53
  export type WorkIndexOptions = {
50
54
  view?: WorkView;
51
55
  limit?: number;
@@ -54,16 +58,30 @@ export type WorkIndexOptions = {
54
58
  state?: TaskState;
55
59
  leadId?: string;
56
60
  root?: string;
61
+ completedSince?: string;
57
62
  /** Only these families (by root task id): the sidebar reads its rows' own words this way. */
58
63
  roots?: readonly string[];
59
64
  };
60
65
  /** How the list is grouped: the Needs you rows by their ask, then building, then the rest. */
61
66
  export type WorkIndexGroup = Ask | 'building' | 'rest';
62
67
  export type WorkIndexGroupCounts = Record<WorkIndexGroup, number>;
68
+ /** What a Needs you row asks, as Home groups it (docs/plans/live-pages-and-home.md B1.2): a release to approve, a plan
69
+ * to approve, a question to answer, something that went wrong, a result to review, or anything else that needs the
70
+ * person. A release is a plan to approve whose active task the plane's sealed state names a release (release-approval.ts
71
+ * releaseTaskOf, docs/plans/home-other-asks.md v2.1); a failed release is still something that went wrong and a
72
+ * release's result still a result to review. */
73
+ export type WorkAskGroup = 'release' | 'approve' | 'answer' | 'wrong' | 'review' | 'other';
74
+ export declare const WORK_ASK_GROUPS: readonly WorkAskGroup[];
75
+ /** `asks`: the exact Needs you totals per ask group, over every row in view (never only the page). `completedSince`:
76
+ * how many the Completed view lists from `options.completedSince` on (0 without it). `builderWaits`: how many Needs you
77
+ * rows wait for a builder to connect or start (no worker registered or none online). */
63
78
  export type WorkIndexPage = {
64
79
  items: WorkIndexItem[];
65
80
  totals: WorkIndexCounts;
66
81
  groups: WorkIndexGroupCounts;
82
+ asks: Record<WorkAskGroup, number>;
83
+ completedSince: number;
84
+ builderWaits: number;
67
85
  projects: WorkProjectCounts[];
68
86
  nextCursor: string | null;
69
87
  limit: number;
@@ -21,11 +21,14 @@ import { withFollowUps } from './result-follow-ups.js';
21
21
  import { LANDED_ACTOR, landedReasonOf } from './result-completion.js';
22
22
  import { ASKS, CODE_READING, STORAGE_HOLD_PREFIX, NEED_ASK, NEEDS, askChipOf, failedAttemptSentence, isWaitCode, processNeedOf, resultHoldUpSentence } from './needs-you.js';
23
23
  import { retryWhenWords } from './dispatch.js';
24
+ import { releaseTaskOf } from './release-approval.js';
24
25
  import { LEAD_CLAIM_SQL, LEAD_IDLE_MS } from './lead-voice.js';
26
+ export const WORK_ASK_GROUPS = ['release', 'approve', 'answer', 'wrong', 'review', 'other'];
25
27
  export const WORK_INDEX_PAGE_LIMIT = 40;
26
28
  export const WORK_INDEX_MAX_LIMIT = 100;
27
29
  const EMPTY = { all: 0, 'needs-you': 0, ready: 0, running: 0, queued: 0, completed: 0, archived: 0 };
28
30
  const NO_GROUPS = { decide: 0, review: 0, unblock: 0, building: 0, rest: 0 };
31
+ const NO_ASKS = { release: 0, approve: 0, answer: 0, wrong: 0, review: 0, other: 0 };
29
32
  const FAMILY_PROBLEM = 'Task history is unavailable or incomplete. This task is shown separately.';
30
33
  const VIEWS = ['all', 'needs-you', 'ready', 'running', 'queued', 'completed', 'archived'];
31
34
  /** The family's current version is put away (v145): it waits on nobody and only the Archived view lists it. */
@@ -70,6 +73,11 @@ export function askOfCode(code, custody = false) {
70
73
  const codesOf = (ask) => PERSON_CODES.filter(code => askOfCode(code) === ask).map(code => `'${code}'`).join(',');
71
74
  /** The ask as its place in the list (ASKS order), read by the same code the row's status reads. */
72
75
  const ASK_RANK = `CASE WHEN code IN (${codesOf('decide')}) THEN 0 WHEN code IN (${codesOf('review')}) AND NOT custody_unresolved THEN 1 ELSE 2 END`;
76
+ /** Home's ask group of a row, by its code alone, so a row's group and its group's count are one rule: a plan waiting
77
+ * for its yes, a question, a build that failed or stopped unexpectedly, a saved result to review (ASK_RANK's Review),
78
+ * and everything else that waits on a person. Read on Needs you rows only. */
79
+ const ASK_GROUP_SQL = `CASE WHEN code='needs-approval' AND c.id IN (SELECT value FROM json_each($releaseTasks)) THEN 'release' WHEN code='needs-approval' THEN 'approve' WHEN code IN ('waiting-decision','decision-queue') THEN 'answer'
80
+ WHEN code IN ('failed','waiting-incident','vanished-run') THEN 'wrong' WHEN code IN (${codesOf('review')}) AND NOT custody_unresolved THEN 'review' ELSE 'other' END`;
73
81
  /** Where a row sits in the list: Decide, Review, Unblock, then building, then the rest by recency. */
74
82
  const ORDER = `(CASE WHEN rank=0 THEN ask_rank WHEN rank=1 THEN 3 ELSE 4 END)`;
75
83
  /** A row whose build may still be running (the store's reading, custodyReadings): Waiting, not Needs you. */
@@ -261,7 +269,7 @@ const PROJECTION = `WITH RECURSIVE admitted AS MATERIALIZED (
261
269
  ${ARCHIVED} archived,
262
270
  CASE WHEN code='complete' THEN 3 WHEN code='cancelled' THEN 4 WHEN code='running' THEN 1
263
271
  WHEN ${CUSTODY_WAITS} OR code IN (${WAITING_SQL.replace(/'(?:cancelled|complete|running)',/g, '')}) THEN 2 ELSE 0 END rank,
264
- ${ASK_RANK} ask_rank,
272
+ ${ASK_RANK} ask_rank,${ASK_GROUP_SQL} ask_group,
265
273
  CASE WHEN code='complete' THEN completed.at ELSE family_updated END sort_at,
266
274
  completed.actor checked_actor,completed.at checked_at,completed.outcome checked_digest,completed.detail checked_detail
267
275
  FROM claimed 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))
@@ -341,9 +349,17 @@ function custodyReadings(store) {
341
349
  }
342
350
  return { readings, workspace: new Set(noted) };
343
351
  }
344
- function parameters(now, access, options, custody) {
352
+ /** The list can't read a release's sealed state in SQL: each queued task (the only state a plan waiting for a yes is in)
353
+ * whose scope carries a release form, kept only where release-approval.ts releaseTaskOf names it a release, the one
354
+ * rule the release sheet reads (never a title or a task id). */
355
+ function releaseTaskIds(store) {
356
+ const sealed = store.handle.prepare(`SELECT s.task_id FROM task_scope s JOIN task t ON t.id=s.task_id
357
+ WHERE t.state='queued' AND (s.release_terms_json IS NOT NULL OR s.release_check_json IS NOT NULL)`).all().map(row => String(row['task_id']));
358
+ return JSON.stringify(sealed.filter(taskId => releaseTaskOf(store, taskId) !== null));
359
+ }
360
+ function parameters(store, now, access, options, custody) {
345
361
  const ids = (keep) => JSON.stringify([...custody.readings].filter(([ref, fact]) => keep(ref, fact)).map(([ref]) => ref));
346
- return { $settledCustody: ids((_, fact) => fact === null), $waitingCustody: ids((_, fact) => fact !== null && 'wait' in (processNeedOf(fact) ?? {})),
362
+ return { $releaseTasks: releaseTaskIds(store), $settledCustody: ids((_, fact) => fact === null), $waitingCustody: ids((_, fact) => fact !== null && 'wait' in (processNeedOf(fact) ?? {})),
347
363
  $workspaceCustody: ids((ref, fact) => fact !== null && custody.workspace.has(ref)),
348
364
  $now: now.toISOString(), $alive: new Date(now.getTime() - 180_000).toISOString(),
349
365
  $all: access.repos === null ? 1 : 0, $repos: JSON.stringify(access.repos ?? []),
@@ -364,6 +380,13 @@ const TOTALS = `COALESCE(SUM(archived=0),0) all_count,COALESCE(SUM(needs),0) nee
364
380
  + `COALESCE(SUM(${QUEUED}),0) queued_count,COALESCE(SUM(code='complete' AND archived=0),0) completed_count,COALESCE(SUM(archived),0) archived_count`;
365
381
  const GROUP_TOTALS = `COALESCE(SUM(rank=0 AND ask_rank=0 AND archived=0),0) decide_count,COALESCE(SUM(rank=0 AND ask_rank=1 AND archived=0),0) review_count,COALESCE(SUM(rank=0 AND ask_rank=2 AND archived=0),0) unblock_count,`
366
382
  + `COALESCE(SUM(rank=1 AND archived=0),0) building_count,COALESCE(SUM(rank>1 AND archived=0),0) rest_count`;
383
+ /** The exact Needs you totals per ask group, and what was completed since the page's `completedSince`: page reads only. */
384
+ const ASK_TOTALS = WORK_ASK_GROUPS.map(group => `COALESCE(SUM(needs AND ask_group='${group}'),0) ask_${group}_count`).join(',')
385
+ + `,COALESCE(SUM(code='complete' AND archived=0 AND $completedSince IS NOT NULL AND sort_at>=$completedSince),0) completed_since_count`
386
+ + `,COALESCE(SUM(needs AND code IN ('no-worker-registered','no-worker-online')),0) builder_wait_count`;
387
+ function askCounts(row) {
388
+ return Object.fromEntries(WORK_ASK_GROUPS.map(group => [group, n(row, `ask_${group}_count`)]));
389
+ }
367
390
  function groupCounts(row) {
368
391
  return { decide: n(row, 'decide_count'), review: n(row, 'review_count'), unblock: n(row, 'unblock_count'), building: n(row, 'building_count'), rest: n(row, 'rest_count') };
369
392
  }
@@ -374,7 +397,8 @@ function cursorScope(access, options, view) {
374
397
  return createHash('sha256').update(JSON.stringify({ principal: access.principal,
375
398
  repos: access.repos === null ? null : [...new Set(access.repos)].sort(),
376
399
  unplaced: access.principal === 'operator' && access.includeUnplaced === true,
377
- leadId: options.leadId ?? null, project: options.project ?? null, state: options.state ?? null, view })).digest('hex');
400
+ leadId: options.leadId ?? null, project: options.project ?? null, state: options.state ?? null, view,
401
+ ...(options.completedSince === undefined ? {} : { completedSince: options.completedSince }) })).digest('hex');
378
402
  }
379
403
  function readCursor(value, scope) {
380
404
  if (value == null)
@@ -413,18 +437,18 @@ function readWorkIndexPage(store, now, access, options, taskId = null) {
413
437
  const view = VIEWS.includes(options.view ?? 'all') ? options.view ?? 'all' : 'all';
414
438
  const limit = Number.isFinite(options.limit) ? Math.max(1, Math.min(WORK_INDEX_MAX_LIMIT, Math.floor(options.limit))) : WORK_INDEX_PAGE_LIMIT;
415
439
  const scope = cursorScope(access, options, view), cursor = readCursor(options.cursor, scope);
416
- const filter = view === 'archived' ? 'archived=1' : view === 'needs-you' ? 'needs=1' : view === 'ready' ? READY : view === 'running' ? 'family_running=1 AND archived=0' : view === 'queued' ? QUEUED : view === 'completed' ? "code='complete' AND archived=0" : 'archived=0';
440
+ const filter = view === 'archived' ? 'archived=1' : view === 'needs-you' ? 'needs=1' : view === 'ready' ? READY : view === 'running' ? 'family_running=1 AND archived=0' : view === 'queued' ? QUEUED : view === 'completed' ? "code='complete' AND archived=0 AND ($completedSince IS NULL OR sort_at>=$completedSince)" : 'archived=0';
417
441
  const rows = prepared(store, `${projection}, selected_page AS MATERIALIZED (
418
442
  SELECT * FROM ranked WHERE ($taskId IS NOT NULL OR ${filter}) AND ($taskId IS NULL OR root_ref IN (SELECT root_ref FROM members WHERE id=$taskId)) AND ($roots IS NULL OR root_id IN (SELECT value FROM json_each($roots))) AND ($cursorRoot=0 OR
419
443
  ${ORDER}>$cursorRank OR (${ORDER}=$cursorRank AND (sort_at<$cursorAt OR (sort_at=$cursorAt AND root_ref<$cursorRoot))))
420
444
  ORDER BY ${ORDER},sort_at DESC,root_ref DESC LIMIT $limit
421
- ), page AS (SELECT p.*,COALESCE(p.result_id,(SELECT id FROM run WHERE task_ref=p.ref_id AND finished_at IS NOT NULL AND role IN ('builder','scout') ORDER BY id DESC LIMIT 1)) page_result_id FROM selected_page p), totals AS (SELECT ${TOTALS},${GROUP_TOTALS} FROM ranked), project_totals AS (
445
+ ), page AS (SELECT p.*,COALESCE(p.result_id,(SELECT id FROM run WHERE task_ref=p.ref_id AND finished_at IS NOT NULL AND role IN ('builder','scout') ORDER BY id DESC LIMIT 1)) page_result_id FROM selected_page p), totals AS (SELECT ${TOTALS},${GROUP_TOTALS},${ASK_TOTALS} FROM ranked), project_totals AS (
422
446
  SELECT repo,${TOTALS},SUM(state='queued' AND family_running=0 AND archived=0) queued,
423
447
  SUM(state='done' AND family_running=0 AND archived=0 AND updated_at>=$recent) done_recently FROM ranked GROUP BY repo
424
448
  )
425
449
  SELECT 0 record_kind,totals.*,(SELECT json_group_array(json_object('repo',repo,'all_count',all_count,'needs_count',needs_count,'ready_count',ready_count,
426
450
  'running_count',running_count,'queued_count',queued_count,'completed_count',completed_count,'archived_count',archived_count,'queued',queued,'done_recently',done_recently)) FROM project_totals) row_json FROM totals
427
- UNION ALL SELECT 1,0,0,0,0,0,0,0,0,0,0,0,0,json_object('archived',archived,'archived_by',(SELECT by FROM task_archive WHERE task_ref=page.ref_id),
451
+ UNION ALL SELECT 1,0,0,0,0,0,0,0,0,0,0,0,0,${WORK_ASK_GROUPS.map(() => '0,').join('')}0,0,json_object('archived',archived,'archived_by',(SELECT by FROM task_archive WHERE task_ref=page.ref_id),
428
452
  'archived_kind',(SELECT by_kind FROM task_archive WHERE task_ref=page.ref_id),'archived_reason',(SELECT reason FROM task_archive WHERE task_ref=page.ref_id),
429
453
  'archived_at',(SELECT at FROM task_archive WHERE task_ref=page.ref_id),'root_ref',root_ref,'root_id',root_id,'id',id,'root_title',root_title,'repo',repo,
430
454
  'state',state,'created_at',created_at,'family_updated',family_updated,'version_count',version_count,'earlier_active',earlier_active,
@@ -436,7 +460,7 @@ function readWorkIndexPage(store, now, access, options, taskId = null) {
436
460
  'check_follows',(SELECT json_group_array(q.outcome||' '||COALESCE((SELECT substr(f.outcome,instr(f.outcome,':')+1) FROM action_ledger f WHERE f.repo IS page.repo AND f.run_id=page_result_id
437
461
  AND f.action='checks finished' AND f.outcome LIKE q.id||':%' LIMIT 1),'open')) FROM (SELECT id,outcome FROM action_ledger WHERE repo IS page.repo AND run_id=page_result_id AND action='checks requested' ORDER BY id) q),
438
462
  'proof_verdict',(SELECT COALESCE(machine_verdict,verdict) FROM proof_verdict WHERE run=page_result_id),
439
- 'proof_failed_check',(SELECT verdict='refuted' AND reasons_json LIKE '%approved verification command exited%' FROM proof_verdict WHERE run=page_result_id),'code',code,'needs',needs,'person_need',person_need,'lead_claim',lead_claim,'family_running',family_running,'rank',rank,'ask_rank',ask_rank,'list_order',${ORDER},'sort_at',sort_at,
463
+ 'proof_failed_check',(SELECT verdict='refuted' AND reasons_json LIKE '%approved verification command exited%' FROM proof_verdict WHERE run=page_result_id),'code',code,'ask_group',ask_group,'needs',needs,'person_need',person_need,'lead_claim',lead_claim,'family_running',family_running,'rank',rank,'ask_rank',ask_rank,'list_order',${ORDER},'sort_at',sort_at,
440
464
  'checked_actor',checked_actor,'checked_at',checked_at,'checked_digest',checked_digest,'checked_detail',checked_detail,'question_id',question_id,
441
465
  'dependency_id',dependency_id,'dependency_state',dependency_state,'hold_until',hold_until,'attempt_run',(SELECT id FROM run WHERE task_ref IN (SELECT ref_id FROM members WHERE root_ref=page.root_ref) AND (finished_at IS NOT NULL OR outcome IS NOT NULL) AND role NOT IN ('planner','reviewer') ORDER BY id DESC LIMIT 1),'custody_unresolved',custody_unresolved,'hold_kind',hold_kind,'hold_reason',substr(hold_reason,1,600),'hold_id',hold_id,'live_lease',live_lease IS NOT NULL,'approved',COALESCE(approved,0),
442
466
  'plan_changed',scope_digest IS NOT NULL AND digest IS NOT scope_digest,'head_ok',length(head_revision)=40 AND head_revision NOT GLOB '*[^a-f0-9]*','stop_run',stop_run,'unfinished',unfinished,'signed_out',signed_out,
@@ -444,7 +468,7 @@ function readWorkIndexPage(store, now, access, options, taskId = null) {
444
468
  'agent_ask_run',(SELECT run FROM agent_question WHERE id=page.agent_ask),'agent_ask_text',(SELECT substr(question,1,300) FROM agent_question WHERE id=page.agent_ask),'replaced_by',(SELECT successor FROM task_replacement WHERE task_ref=page.ref_id),
445
469
  'completed_by_lead',(SELECT lead FROM task_act WHERE task_ref=page.root_ref AND act='completed' ORDER BY id DESC LIMIT 1),'machine_check',COALESCE(machine_check,0),
446
470
  '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`)
447
- .all({ ...parameters(now, access, options, custody), $taskId: taskId, $roots: options.roots === undefined ? null : JSON.stringify(options.roots), $cursorRoot: cursor?.root ?? 0, $cursorRank: cursor?.rank ?? 0, $cursorAt: cursor?.at ?? '', $limit: limit + 1, $recent: new Date(now.getTime() - 86_400_000).toISOString() });
471
+ .all({ ...parameters(store, now, access, options, custody), $taskId: taskId, $roots: options.roots === undefined ? null : JSON.stringify(options.roots), $cursorRoot: cursor?.root ?? 0, $cursorRank: cursor?.rank ?? 0, $cursorAt: cursor?.at ?? '', $limit: limit + 1, $completedSince: options.completedSince ?? null, $recent: new Date(now.getTime() - 86_400_000).toISOString() });
448
472
  const selected = rows.slice(1).map(row => JSON.parse(String(row['row_json'])));
449
473
  const page = selected.slice(0, limit), last = page.at(-1);
450
474
  const nextCursor = selected.length > limit && last !== undefined ? Buffer.from(JSON.stringify({ version: 2, scope,
@@ -462,13 +486,14 @@ function readWorkIndexPage(store, now, access, options, taskId = null) {
462
486
  const memo = new Map();
463
487
  const readiness = (row, checks) => resultReadinessFacts(store, now, options.root, row, checks, memo);
464
488
  return { items: page.map(row => itemOf(row, access.principal, probe, leadName, readiness, options.root !== undefined, now)), projects, totals: rows[0] === undefined ? { ...EMPTY } : counts(rows[0]),
465
- groups: rows[0] === undefined ? { ...NO_GROUPS } : groupCounts(rows[0]), nextCursor, limit, view };
489
+ groups: rows[0] === undefined ? { ...NO_GROUPS } : groupCounts(rows[0]), asks: rows[0] === undefined ? { ...NO_ASKS } : askCounts(rows[0]),
490
+ completedSince: rows[0] === undefined ? 0 : n(rows[0], 'completed_since_count'), builderWaits: rows[0] === undefined ? 0 : n(rows[0], 'builder_wait_count'), nextCursor, limit, view };
466
491
  }
467
492
  export function workCountsByProject(store, now, access) {
468
493
  const projection = registerValidators(store);
469
494
  return store.handle.prepare(`${projection} SELECT repo,${TOTALS},SUM(state='queued' AND family_running=0 AND archived=0) queued,
470
495
  SUM(state='done' AND family_running=0 AND archived=0 AND updated_at>=$recent) done_recently FROM ranked GROUP BY repo ORDER BY repo`)
471
- .all({ ...parameters(now, access, {}, custodyReadings(store)), $recent: new Date(now.getTime() - 86_400_000).toISOString() })
496
+ .all({ ...parameters(store, now, access, {}, custodyReadings(store)), $recent: new Date(now.getTime() - 86_400_000).toISOString() })
472
497
  .map(row => ({ repo: s(row, 'repo'), totals: counts(row), queued: n(row, 'queued'), doneRecently: n(row, 'done_recently') }));
473
498
  }
474
499
  export function workStateRows(store, now, access) {
@@ -480,7 +505,7 @@ export function workStateRows(store, now, access) {
480
505
  COALESCE((SELECT held_at FROM hold WHERE id=hold_id),''),COALESCE((SELECT requested_at FROM run_stop WHERE run=stop_run),'')),''),sort_at) ask_at,
481
506
  COALESCE(live_run,(SELECT run FROM decision WHERE id=question_id),(SELECT run FROM agent_question WHERE id=agent_ask)) ask_run,
482
507
  (SELECT json_group_array(m.ref_id) FROM members m WHERE m.root_ref=ranked.root_ref) members FROM ranked WHERE archived=0`)
483
- .all(parameters(now, access, {}, custodyReadings(store)))
508
+ .all(parameters(store, now, access, {}, custodyReadings(store)))
484
509
  .map(row => ({ repo: s(row, 'repo'), rootId: String(row['root_id']), code: String(row['code']), needs: n(row, 'needs') === 1, personNeed: n(row, 'person_need') === 1,
485
510
  leadClaim: n(row, 'lead_claim') === 1, liveRun: row['live_run'] == null ? null : n(row, 'live_run'), unfinished: row['unfinished'] == null ? null : n(row, 'unfinished'),
486
511
  resultRun: row['result_id'] == null ? null : n(row, 'result_id'), waits: Number(row['rank']) === 2, version: String(row['version']), at: String(row['ask_at'] ?? ''), askRun: row['ask_run'] == null ? null : n(row, 'ask_run'), members: JSON.parse(String(row['members'] ?? '[]')).map(Number) }));
@@ -515,7 +540,7 @@ export function workOutcomeRows(store, now, access, project, since, limit) {
515
540
  (SELECT status FROM run_check WHERE run=f.result_id) check_status,
516
541
  COALESCE(a.runs,0) runs,COALESCE(a.api_runs,0) api_runs,a.api_microusd,COALESCE(a.subscription_runs,0) subscription_runs,a.subscription_usd
517
542
  FROM selected f LEFT JOIN attempts a ON a.root_ref=f.root_ref ORDER BY f.sort_at DESC,f.root_ref DESC`)
518
- .all({ ...parameters(now, access, { project }, custodyReadings(store)), $since: since, $outcomeLimit: limit + 1 });
543
+ .all({ ...parameters(store, now, access, { project }, custodyReadings(store)), $since: since, $outcomeLimit: limit + 1 });
519
544
  }
520
545
  /** Families whose headline is Ready for review, newest first: the Tasks
521
546
  * list's own rows, so a completed family (root or any revision), a failed
@@ -757,6 +782,7 @@ function itemOf(row, principal, probe, leadName, readiness, readable = false, no
757
782
  status: { token: `assignment-${assignmentState}`, label, detail, tone: workToneOf(shared.headline),
758
783
  action: primaryAction === null ? null : { label: actionLabel, kind: actionCode === 'open-result' ? 'open-result' : 'open-task' }, views, rank: n(row, 'rank') },
759
784
  ask: need ? ASKS[n(row, 'ask_rank')] ?? 'unblock' : null,
785
+ askGroup: need ? WORK_ASK_GROUPS.find(group => group === s(row, 'ask_group')) ?? 'other' : null,
760
786
  chip: need ? askChipOf({ headline: shared.headline, need: shared.need?.key ?? null, planChanged, mismatch: verdict === 'refuted' && n(row, 'proof_failed_check') !== 1, ask: ASKS[n(row, 'ask_rank')] ?? 'unblock' }) : null,
761
787
  primaryAction, completion: code === 'complete' ? { actor: String(row['checked_actor']), at: String(row['checked_at']), digest: String(row['checked_digest']), ...(landed === null ? {} : { landed }) } : null,
762
788
  evidence: 'recorded', ...(lead === 'on-it' ? { lead: leadOnIt(leadName) } : {}), ...(archived === null ? {} : { archived }) };
@@ -767,7 +793,7 @@ export function workTidyRows(store, now, access) {
767
793
  (family_running OR live_lease IS NOT NULL OR unfinished IS NOT NULL OR custody_unresolved OR stop_run IS NOT NULL AND stop_settled IS NULL OR state='running') live,
768
794
  (SELECT json_group_array(m.ref_id) FROM members m WHERE m.root_ref=ranked.root_ref) members,
769
795
  (SELECT json_group_array(m.id) FROM members m WHERE m.root_ref=ranked.root_ref) member_ids FROM ranked`)
770
- .all(parameters(now, access, {}, custodyReadings(store)))
796
+ .all(parameters(store, now, access, {}, custodyReadings(store)))
771
797
  .map(row => ({ rootId: String(row['root_id']), currentId: String(row['id']), title: String(row['root_title']), repo: s(row, 'repo'),
772
798
  state: String(row['state']), code: String(row['code']), live: n(row, 'live') === 1, archived: n(row, 'archived') === 1, broken: n(row, 'broken') === 1,
773
799
  updatedAt: String(row['family_updated'] ?? ''), members: JSON.parse(String(row['members'] ?? '[]')).map(Number),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolroll",
3
- "version": "0.9.101",
3
+ "version": "0.9.103",
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"