toolroll 0.9.5 → 0.9.7

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 (92) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/actor.d.ts +21 -0
  3. package/dist/actor.js +48 -0
  4. package/dist/assignment-adapters.d.ts +1 -0
  5. package/dist/assignment-presentation.d.ts +13 -2
  6. package/dist/assignment-presentation.js +22 -9
  7. package/dist/assignment-ui.d.ts +10 -15
  8. package/dist/assignment-ui.js +19 -18
  9. package/dist/assignment.d.ts +9 -0
  10. package/dist/assignment.js +62 -13
  11. package/dist/browser/workspace.css +2 -2
  12. package/dist/browser/workspace.js +29 -24
  13. package/dist/browser-workspace.d.ts +120 -12
  14. package/dist/builder.d.ts +1 -0
  15. package/dist/builder.js +33 -13
  16. package/dist/chat-acceptance.js +2 -2
  17. package/dist/chat-actions.js +2 -1
  18. package/dist/chat-delivery.js +2 -0
  19. package/dist/chat-quiet.js +30 -19
  20. package/dist/chat-task-actions.d.ts +13 -0
  21. package/dist/chat-task-actions.js +19 -1
  22. package/dist/check-levels-ui.d.ts +21 -0
  23. package/dist/check-levels-ui.js +70 -0
  24. package/dist/check-levels.d.ts +67 -0
  25. package/dist/check-levels.js +171 -0
  26. package/dist/claim.d.ts +22 -0
  27. package/dist/claim.js +43 -0
  28. package/dist/cli.d.ts +1 -1
  29. package/dist/cli.js +4 -0
  30. package/dist/demo-chat.d.ts +0 -5
  31. package/dist/demo-chat.js +14 -3
  32. package/dist/dispatch.js +7 -2
  33. package/dist/dispose.d.ts +8 -0
  34. package/dist/dispose.js +7 -1
  35. package/dist/export.js +3 -2
  36. package/dist/first-run.d.ts +34 -0
  37. package/dist/first-run.js +37 -1
  38. package/dist/flow-gallery-ui.d.ts +30 -0
  39. package/dist/flow-gallery-ui.js +85 -0
  40. package/dist/flow-gallery.d.ts +111 -0
  41. package/dist/flow-gallery.js +445 -0
  42. package/dist/flow-share.d.ts +102 -0
  43. package/dist/flow-share.js +614 -0
  44. package/dist/flow-starters.d.ts +2 -0
  45. package/dist/flow-starters.js +1 -1
  46. package/dist/flow-steps.js +7 -0
  47. package/dist/flow-triggers.js +2 -0
  48. package/dist/flows-cli.d.ts +56 -0
  49. package/dist/flows-cli.js +109 -3
  50. package/dist/flows-ui.d.ts +15 -1
  51. package/dist/flows-ui.js +31 -6
  52. package/dist/guides.js +27 -2
  53. package/dist/lead-context.js +6 -2
  54. package/dist/lead-status.d.ts +7 -0
  55. package/dist/lead-status.js +11 -2
  56. package/dist/mate-doors.d.ts +0 -15
  57. package/dist/mate-doors.js +5 -1
  58. package/dist/mate-tools.js +9 -4
  59. package/dist/models-ui.js +1 -1
  60. package/dist/operate.d.ts +4 -1
  61. package/dist/operate.js +601 -85
  62. package/dist/project-concurrency.d.ts +62 -0
  63. package/dist/project-concurrency.js +116 -0
  64. package/dist/project-delete-ui.d.ts +7 -0
  65. package/dist/project-delete-ui.js +19 -2
  66. package/dist/project-delete.js +4 -0
  67. package/dist/pull-request-flow.d.ts +9 -0
  68. package/dist/pull-request-flow.js +21 -3
  69. package/dist/result-follow-ups.d.ts +101 -0
  70. package/dist/result-follow-ups.js +333 -0
  71. package/dist/scope.js +11 -0
  72. package/dist/serve.d.ts +211 -2
  73. package/dist/serve.js +1070 -204
  74. package/dist/store.d.ts +76 -3
  75. package/dist/store.js +311 -22
  76. package/dist/surface.js +31 -8
  77. package/dist/tailnet.d.ts +11 -0
  78. package/dist/tailnet.js +47 -0
  79. package/dist/task-outcome-cli.js +2 -2
  80. package/dist/task-status.d.ts +186 -0
  81. package/dist/task-status.js +335 -0
  82. package/dist/team-cli.js +3 -0
  83. package/dist/telegram-progress.js +62 -100
  84. package/dist/telegram-status.js +21 -8
  85. package/dist/verification-evidence.js +21 -1
  86. package/dist/webhooks.js +5 -0
  87. package/dist/work-index.js +60 -11
  88. package/dist/work-summary.d.ts +0 -4
  89. package/dist/work-summary.js +2 -0
  90. package/dist/workspace-ui.d.ts +10 -0
  91. package/dist/workspace-ui.js +33 -2
  92. package/package.json +1 -1
@@ -8,7 +8,8 @@ import type { Store } from './store.js';
8
8
  import type { WorkSummaryAccess } from './work-summary.js';
9
9
  import type { StatusTone } from './workspace-ui.js';
10
10
  import type { AssignmentCard } from './assignment-ui.js';
11
- import type { FirstRunStep, FirstTaskSuggestion } from './first-run.js';
11
+ import type { TaskStatus } from './task-status.js';
12
+ import type { FirstRunStep, FirstTaskSuggestion, JourneyStep } from './first-run.js';
12
13
  export type BrowserProject = {
13
14
  name: string;
14
15
  path: string;
@@ -205,6 +206,12 @@ export type BrowserSettingsView = {
205
206
  chat?: {
206
207
  mode: 'quiet' | 'all';
207
208
  digestAt: string | null;
209
+ /** Each project this person can see; a muted one sends no pings but stays in Tasks and the digest. */
210
+ projects?: {
211
+ repo: string;
212
+ name: string;
213
+ muted: boolean;
214
+ }[];
208
215
  } | null;
209
216
  digest: {
210
217
  every: string;
@@ -284,12 +291,39 @@ export type BrowserTaskSection = {
284
291
  open: boolean;
285
292
  count: number | null;
286
293
  };
294
+ /** `at`: a moment, shown in the reader's own time ("16:39", "Yesterday 16:39", "Sep 28"). */
287
295
  export type BrowserTaskFact = {
288
296
  label: string;
289
297
  parts: (string | BrowserLink | {
290
298
  seal: string;
299
+ } | {
300
+ at: string;
291
301
  })[];
292
302
  };
303
+ /** One entry in a task's thread, in time order beside the task's own
304
+ * conversation: the plan, the agent's progress notes and results, its
305
+ * questions, and the person's replies. `html` is the server's own card (same
306
+ * forms and ids) when the entry carries one. */
307
+ export type BrowserTaskThreadItem = {
308
+ key: string;
309
+ at: string;
310
+ kind: 'filed' | 'plan' | 'progress' | 'result' | 'question' | 'reply';
311
+ who: 'agent' | 'person';
312
+ author: string;
313
+ title: string;
314
+ text: string | null;
315
+ link: BrowserLink | null;
316
+ html: string;
317
+ more: {
318
+ summary: string;
319
+ html: string;
320
+ } | null;
321
+ };
322
+ /** The task's metadata, grouped for the Details panel (desk) or sheet (phone). */
323
+ export type BrowserTaskDetailGroup = {
324
+ title: 'Work' | 'Links' | 'Review' | 'About';
325
+ facts: BrowserTaskFact[];
326
+ };
293
327
  export type BrowserTaskView = {
294
328
  kind: 'task';
295
329
  id: string;
@@ -327,6 +361,13 @@ export type BrowserTaskView = {
327
361
  href: string;
328
362
  starter: string;
329
363
  } | null;
364
+ /** Until the first Ready result: Plan → You approve → Build → Checks → Ready, filled in as it moves. */
365
+ journey?: JourneyStep[] | null;
366
+ /** The thread's server-side entries; the conversation's messages join them in time order. */
367
+ thread?: BrowserTaskThreadItem[];
368
+ details?: BrowserTaskDetailGroup[];
369
+ /** Where to message the agent when this page carries no conversation (null: messaging isn't available here). */
370
+ chatHref?: string | null;
330
371
  };
331
372
  /** One project on the Projects page; opening it is a POST to /projects/open. */
332
373
  export type BrowserProjectRow = {
@@ -342,6 +383,11 @@ export type BrowserProjectRow = {
342
383
  href: string;
343
384
  on: boolean;
344
385
  };
386
+ /** Settings → Projects → Checks for this project, and its level in words. */
387
+ checks?: {
388
+ href: string;
389
+ level: string;
390
+ };
345
391
  /** What waits, runs, queues or finished today; null when not scanned. */
346
392
  peek: {
347
393
  label: string;
@@ -365,12 +411,6 @@ export type BrowserProjectsView = {
365
411
  };
366
412
  };
367
413
  export type BrowserResultTab = 'summary' | 'changes' | 'checks';
368
- export type BrowserResultChip = {
369
- tone: 'success' | 'danger' | 'warning' | 'info' | 'neutral';
370
- label: string;
371
- icon: 'check' | 'x' | null;
372
- title: string | null;
373
- };
374
414
  /** The shared result panel in parts. Each tab's content, the feedback
375
415
  * section and the learning card are the server's own HTML; the page script
376
416
  * binds to the same data attributes and ids (tabs, drafts, line notes). */
@@ -378,10 +418,8 @@ export type BrowserResultPanel = {
378
418
  attributes: Record<string, string>;
379
419
  heading: string;
380
420
  outcome: string;
381
- verdict: {
382
- chips: BrowserResultChip[];
383
- by: string | null;
384
- } | null;
421
+ /** The shared task status (task-status.ts): headline, sentence and detail rows. */
422
+ status: TaskStatus | null;
385
423
  reviewHistory: string | null;
386
424
  attention: string[];
387
425
  /** Requirements only a person can confirm, in plain words, and the one
@@ -759,6 +797,8 @@ export type BrowserFlowView = {
759
797
  usedHere: string[];
760
798
  language: "shell" | "python" | "node";
761
799
  file: string | null;
800
+ /** It came with an imported flow and waits for a person's approval before it runs. */
801
+ held: boolean;
762
802
  }[];
763
803
  start: string;
764
804
  stages: BrowserFlowStage[];
@@ -818,12 +858,80 @@ export type BrowserWorkspace = {
818
858
  update?: BrowserUpdateNotice;
819
859
  /** Chat's first run, until the first Ready result: the three steps and first tasks to try. */
820
860
  firstRun?: BrowserFirstRun;
861
+ /** Chat, after the first Ready result until put away: use it from the phone. */
862
+ phone?: BrowserPhoneCard;
863
+ /** The Chat landing's live view: who is working now, four counts, and Catch up in tabs. */
864
+ home?: BrowserHome | null;
821
865
  };
822
- /** `sandbox`: the demo command, offered beside the sign-in command while no agent is signed in. */
866
+ /** One agent at work now: its task and the machine's own phase, in words. */
867
+ export type BrowserHomeAgent = {
868
+ runId: number;
869
+ taskId: string;
870
+ title: string;
871
+ href: string;
872
+ agent: string;
873
+ phase: string;
874
+ project: string | null;
875
+ since: string;
876
+ };
877
+ export type BrowserHomeCount = {
878
+ key: 'working' | 'waiting' | 'ready' | 'done';
879
+ label: string;
880
+ value: number;
881
+ href: string;
882
+ };
883
+ export type BrowserCatchUpTab = 'needs-you' | 'ready' | 'running' | 'all';
884
+ export type BrowserCatchUpItem = {
885
+ id: string;
886
+ title: string;
887
+ href: string;
888
+ project: string | null;
889
+ tab: Exclude<BrowserCatchUpTab, 'all'> | 'finished';
890
+ label: string;
891
+ tone: StatusTone;
892
+ detail: string;
893
+ at: string;
894
+ };
895
+ /** Plan-window use stands in for spend: subscriptions don't bill per run. */
896
+ export type BrowserHome = {
897
+ agents: BrowserHomeAgent[];
898
+ counts: BrowserHomeCount[];
899
+ planUse: {
900
+ name: string;
901
+ window: string;
902
+ percent: number;
903
+ detail: string;
904
+ tone: 'neutral' | 'warning' | 'danger';
905
+ }[];
906
+ catchUp: BrowserCatchUpItem[];
907
+ allHref: string;
908
+ };
909
+ /** `sandbox`: the demo command, offered beside the sign-in command while no agent is signed in. `intro`: how it works,
910
+ * once, above the composer with the suggestions. `lead`: the one line saying what runs the lead, and where to change it.
911
+ * `recheck`: while no agent is signed in, where the page asks again on its own. */
823
912
  export type BrowserFirstRun = {
824
913
  steps: FirstRunStep[];
825
914
  suggestions: FirstTaskSuggestion[];
826
915
  sandbox: string | null;
916
+ intro?: string;
917
+ lead?: {
918
+ words: string;
919
+ href: string;
920
+ } | null;
921
+ recheck?: string | null;
922
+ };
923
+ /** After the first Ready result: the phone, through a chat app or the console over Tailscale. `tailnet.restart`: the
924
+ * command that makes the console listen beyond this computer, when it doesn't yet. */
925
+ export type BrowserPhoneCard = {
926
+ chatApps: {
927
+ label: string;
928
+ href: string;
929
+ }[];
930
+ tailnet: {
931
+ address: string;
932
+ restart: string | null;
933
+ } | null;
934
+ dismissHref: string;
827
935
  };
828
936
  /** The console's update notice: neutral, never the accent — an update does not need a person. */
829
937
  export type BrowserUpdateNotice = {
package/dist/builder.d.ts CHANGED
@@ -368,6 +368,7 @@ export declare const NO_HANDOFF_WORDS = "The agent stopped before handing off; i
368
368
  export declare const WIP_COMMIT_WORDS = "Work in progress: the agent stopped before handing off. Kept so the next attempt continues from it.";
369
369
  /** The short turn a stopped agent's own session is resumed with. */
370
370
  export declare function handoffResumePrompt(done: string): string;
371
+ export declare const SERVICE_STOP_WORDS = "Toolroll stopped while this task was building.";
371
372
  /**
372
373
  * What a non-zero agent exit means, in words a person can act on: the
373
374
  * harness's own result line names the ending (a turn ceiling, an error
package/dist/builder.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { OBSERVATION_MAILBOX, observationBrief, parseObservationCases, collectObservations } from "./observations.js";
2
+ import { checkCommandFor, effectiveCheckLevel, recordRunCheckLevel } from "./check-levels.js";
2
3
  import { skillsContext } from "./project-skills.js";
3
4
  import { failedVerificationEvidence, sealVerificationReceipt, verificationEvidence, reuseObservationVerification } from "./verification-evidence.js";
4
5
  import { learningContext } from "./project-learning.js";
@@ -1469,11 +1470,7 @@ export async function settleProviderOutcome(captured, result) {
1469
1470
  // own words.
1470
1471
  if (stopRequestedFor(store, request.runId, request.shouldStop)) {
1471
1472
  quarantineMailboxes(worktree, root, request.runId);
1472
- return {
1473
- ok: false,
1474
- reason: "stopped",
1475
- message: stopWords(store, request.runId, worktree, `the operator stopped this watch while the agent ran — the work is preserved uncommitted in ${worktree}`),
1476
- };
1473
+ return { ok: false, reason: "stopped", message: await stoppedWords(captured) };
1477
1474
  }
1478
1475
  if (result.timedOut) {
1479
1476
  // A mailbox cut down mid-write is quarantined, never ingested: whatever
@@ -1782,11 +1779,7 @@ export async function settleProviderOutcome(captured, result) {
1782
1779
  // (audit IV-1): an operator's stop beats an agent's finish. The work
1783
1780
  // stays in the worktree, uncommitted, preserved for the successor.
1784
1781
  if (stopRequestedFor(store, request.runId, request.shouldStop)) {
1785
- return {
1786
- ok: false,
1787
- reason: "stopped",
1788
- message: stopWords(store, request.runId, worktree, `the operator stopped this watch while the agent ran — the work is preserved uncommitted in ${worktree}`),
1789
- };
1782
+ return { ok: false, reason: "stopped", message: await stoppedWords(captured) };
1790
1783
  }
1791
1784
  store.setRunPhase(request.runId, "committing");
1792
1785
  // Kept work that is already complete is already committed: the attempt's
@@ -2145,7 +2138,14 @@ async function settleProof(captured, statArtifactId, sealedHead) {
2145
2138
  // re-proved before every authorized spawn, and tracked post-commit changes
2146
2139
  // stop recovery rather than being silently certified.
2147
2140
  const repo = store.getWorktree(worktree)?.repo ?? null;
2148
- const configured = repo === null ? null : store.liveVerifyCommand(repo);
2141
+ // The check level (check-levels.ts): the task's own choice, else the
2142
+ // project's. Quick runs its own approved command (or the full one when it
2143
+ // has none); Off runs nothing. The level that ran is recorded either way.
2144
+ const checkChoice = effectiveCheckLevel(store, repo, request.taskId);
2145
+ const chosenCheck = repo === null ? null : checkCommandFor(store, repo, checkChoice.level);
2146
+ const configured = chosenCheck?.command ?? null;
2147
+ if (repo !== null && chosenCheck !== null)
2148
+ recordRunCheckLevel(store, { id: runId, taskId: request.taskId, repo }, chosenCheck.level, checkChoice.from, now());
2149
2149
  let verifyCommand;
2150
2150
  const checkLog = [];
2151
2151
  const checkOutcomes = [];
@@ -2238,7 +2238,7 @@ async function settleProof(captured, statArtifactId, sealedHead) {
2238
2238
  const liveRecoverySetup = () => {
2239
2239
  if (repo === null || recoveryDigest === null)
2240
2240
  return null;
2241
- const liveVerify = store.liveVerifyCommand(repo);
2241
+ const liveVerify = store.liveVerifyCommand(configured.repo);
2242
2242
  const liveSetup = store.liveWorktreeSetup(repo);
2243
2243
  return liveVerify !== null &&
2244
2244
  liveVerify.digest === configured.digest &&
@@ -2374,7 +2374,7 @@ async function settleProof(captured, statArtifactId, sealedHead) {
2374
2374
  : checkSuites.some(one => one.status === "failed") ? "failed"
2375
2375
  : "not-run";
2376
2376
  const suites = checkSuites.length > 0 ? checkSuites : verifyCommand.configured === false ? [] : [{
2377
- name: reused === null ? "Project check" : "Project check (reused)",
2377
+ name: reused === null ? chosenCheck?.level === "quick" ? "Quick check" : "Project check" : "Project check (reused)",
2378
2378
  status: checkStatus,
2379
2379
  exitCode: "ran" in verifyCommand && verifyCommand.ran ? verifyCommand.exitCode : null,
2380
2380
  }];
@@ -2589,6 +2589,26 @@ async function resumeUnhandedWork(captured, sessionId) {
2589
2589
  }
2590
2590
  return null;
2591
2591
  }
2592
+ /**
2593
+ * The words for an attempt a stop ended after its agent ran. A task stop
2594
+ * names who asked. A SERVICE stop (the builder stopping or restarting) also
2595
+ * saves the work as a patch in the run's evidence before saying so: the
2596
+ * changes stay uncommitted in the checkout, and the requeued task resumes
2597
+ * from them on the builder's next pass.
2598
+ */
2599
+ async function stoppedWords(captured) {
2600
+ const { store, request, git, worktree, root } = captured;
2601
+ if (store.applicableStopFor(request.runId) !== null) {
2602
+ return stopWords(store, request.runId, worktree, `stopped while the agent ran — the work is preserved uncommitted in ${worktree}`);
2603
+ }
2604
+ const changes = await unhandedChanges(git, worktree);
2605
+ const saved = changes === null || changes.length === 0 ? null
2606
+ : await saveWorkPatch(git, worktree, join(root, String(request.runId), "service-stop-work.patch"), `# work in ${worktree} when Toolroll stopped\n# kept ${captured.clock().toISOString()}\n`);
2607
+ const kept = changes !== null && changes.length === 0 ? "It had not changed any files yet"
2608
+ : saved !== null && saved.ok ? `Its work is kept in ${worktree} and saved as ${saved.file}` : `Its work is kept uncommitted in ${worktree}`;
2609
+ return `${SERVICE_STOP_WORDS} ${kept}; the task is back in the queue and resumes from it when the builder runs again.`;
2610
+ }
2611
+ export const SERVICE_STOP_WORDS = "Toolroll stopped while this task was building.";
2592
2612
  /**
2593
2613
  * The attempt ended without a handoff, even after any resumed turn. When it
2594
2614
  * left changes, they are never discarded: first saved as a patch in this
@@ -6,7 +6,7 @@ export function acceptanceStatus(proof, accepted) {
6
6
  if (accepted)
7
7
  return manualReviewOnly(proof) ? "Accepted by a person" : "Accepted with an exception";
8
8
  if (manualReviewOnly(proof))
9
- return "Ready to inspect";
9
+ return "You check the remaining requirements";
10
10
  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
11
  }
12
12
  export function readAcceptanceEvidence(store, who, root, task, runId, offset = 0) {
@@ -75,7 +75,7 @@ export function readAcceptanceEvidence(store, who, root, task, runId, offset = 0
75
75
  accepted: acceptance !== null, acceptance: acceptance === null ? null : { at: acceptance.acceptedAt, note: brief(acceptance.note) },
76
76
  reasons: (recorded?.reasons ?? []).map(one => brief(one)), gate,
77
77
  criteriaTotal: matrix.length, nextCriterionOffset: offset + page.length < matrix.length ? offset + page.length : null,
78
- criteria: page.map(row => ({ id: row.id, requirement: brief(row.statement), state: row.state === "manual-review" ? "Human review required" : row.state,
78
+ criteria: page.map(row => ({ id: row.id, requirement: brief(row.statement), state: row.state === "manual-review" ? "You check" : row.state,
79
79
  details: row.detail.map(one => brief(one)), requiredEvidence: row.requiredEvidence,
80
80
  reviewer: row.review == null ? null : { judgement: row.review.judgement, note: brief(row.review.note) },
81
81
  evidence: row.answered.map(one => ({ kind: one.kind, reference: brief(one.ref) })),
@@ -1,5 +1,6 @@
1
1
  /** Shared, exact-state actions. Models may prepare; only the existing human
2
2
  * confirmation door executes. Protected actions use a one-use review receipt. */
3
+ import { withActor } from "./actor.js";
3
4
  import { publicChatText } from "./chat-display.js";
4
5
  import { gateWords } from "./approval-policy.js";
5
6
  import { createHash, randomBytes } from "node:crypto";
@@ -1102,7 +1103,7 @@ export function executeSharedAction(store, who, id, payload, now, options) {
1102
1103
  throw Error(completed.message);
1103
1104
  }
1104
1105
  else if (payload.operation === "task_cancel") {
1105
- const result = store.cancelTask(task, now);
1106
+ const result = withActor({ account: who.name, lead: false }, () => store.cancelTask(task, now));
1106
1107
  if (!result.ok)
1107
1108
  throw Error(`Task was not cancelled: ${result.reason}.`);
1108
1109
  }
@@ -627,6 +627,8 @@ export async function planChatNotifications(options) {
627
627
  const personal = notification.recipient !== null;
628
628
  if (notification.createdAt >= binding.created &&
629
629
  notification.resolvedAt === null &&
630
+ // Pings follow responsibility: the lead's work, this person's own act and a muted project stay in the console.
631
+ store.pingAllowed(notification, binding.approver) &&
630
632
  // A flow decision for "anyone who approves" reaches every approver who can see the project.
631
633
  // A notification addressed to this person reaches them whatever project
632
634
  // their channel follows (a sign-in pause is the installation's, v108).
@@ -7,6 +7,7 @@
7
7
  import { assignmentOf } from "./assignment.js";
8
8
  import { chatControlHref } from "./chat-controls.js";
9
9
  import { telegramProgressCard } from "./telegram-progress.js";
10
+ import { assignmentStatusFacts, headlineEmoji, replacedWords, taskStatusOf } from "./task-status.js";
10
11
  import { phoneText, projectLabel } from "./telegram-status.js";
11
12
  import { isLifecycleNotification } from "./store.js";
12
13
  /** What always makes a new message, even in quiet mode: a question or approval waiting, a failure or other
@@ -37,15 +38,16 @@ function cardRun(store, taskRef) {
37
38
  return store.runsFor(taskRef).find(run => run.role === "builder" && run.contestant === null) ?? null;
38
39
  }
39
40
  /** Before any attempt starts, the card says where the task stands in the words of its last lifecycle fact. */
41
+ const before = (headline) => ({ icon: headlineEmoji(headline), status: headline });
40
42
  const BEFORE_RUN = {
41
- "task-filed": { icon: "📝", status: "Filed" },
42
- "scope-approved": { icon: "✓", status: "Approved · Waiting to start" },
43
- "approval-withdrawn": { icon: "📝", status: "Approval withdrawn" },
44
- "task-held": { icon: "⏸", status: "Paused" },
45
- "task-released": { icon: "✓", status: "Waiting to start" },
46
- "task-queued": { icon: "✓", status: "Waiting to start" },
47
- "task-requeued": { icon: "✓", status: "Waiting to start" },
48
- "task-cancelled": { icon: "⏹", status: "Cancelled" },
43
+ "task-filed": before("Queued"),
44
+ "scope-approved": before("Queued"),
45
+ "approval-withdrawn": before("Needs you"),
46
+ "task-held": before("Stopped"),
47
+ "task-released": before("Queued"),
48
+ "task-queued": before("Queued"),
49
+ "task-requeued": before("Queued"),
50
+ "task-cancelled": before("Stopped"),
49
51
  };
50
52
  function taskLine(store, taskRef, now, root) {
51
53
  const ref = store.refById(taskRef);
@@ -53,6 +55,16 @@ function taskLine(store, taskRef, now, root) {
53
55
  return null;
54
56
  const task = store.getTask(ref.externalId);
55
57
  const title = phoneText(task?.title ?? ref.externalId, 88);
58
+ // Replaced, never "Cancelled": the card names its successor and links to it.
59
+ const successor = task?.state === "cancelled" ? store.replacementOf(ref.externalId) : null;
60
+ if (successor !== null) {
61
+ const heading = `${headlineEmoji("Stopped")} ${replacedWords(successor)}`;
62
+ const text = [title, heading, "", `${projectLabel(ref.repo)} · ${ref.externalId}`].join("\n");
63
+ return { title, icon: headlineEmoji("Stopped"), status: replacedWords(successor), project: ref.repo, view: {
64
+ text, entities: [{ type: "bold", offset: 0, length: title.length }, { type: "bold", offset: title.length + 1, length: heading.length }],
65
+ link: { label: `Open ${phoneText(successor, 40)}`, path: chatControlHref("task", successor) },
66
+ } };
67
+ }
56
68
  const run = cardRun(store, taskRef);
57
69
  if (run !== null) {
58
70
  const view = telegramProgressCard(store, run, ref.externalId, ref.repo, now, root);
@@ -85,10 +97,8 @@ export function quietCardView(store, taskRefs, now, root) {
85
97
  export function readyPingView(store, run, taskId, project, now, root) {
86
98
  const card = telegramProgressCard(store, run, taskId, project, now, root);
87
99
  const [title = "", shown = ""] = card.entities.map(one => card.text.slice(one.offset, one.offset + one.length));
88
- // A saved result is Ready even in the moment before its worker lets go of the task.
89
- const saved = shown.endsWith(" Result saved");
90
- const heading = saved ? "✅ Ready" : shown;
91
- const next = card.next !== "" ? card.next : saved ? "Next: open the result, then mark it complete or request changes." : "";
100
+ const heading = shown;
101
+ const next = card.next;
92
102
  const first = `${heading} · ${title}`;
93
103
  return { text: next === "" ? first : `${first}\n${next}`, entities: [{ type: "bold", offset: 0, length: heading.length }], link: card.link };
94
104
  }
@@ -120,18 +130,19 @@ export function eveningDigestText(store, account, now, root) {
120
130
  failed.set(row.taskId, title(row.taskId));
121
131
  }
122
132
  for (const taskId of new Set(facts.map(row => row.taskId).filter((one) => one !== null))) {
123
- let state = null;
133
+ let headline = null;
124
134
  try {
125
135
  const project = facts.find(row => row.taskId === taskId).project;
126
- state = assignmentOf(store, taskId, now, { principal: "operator", repos: [project] }, root)?.state ?? null;
136
+ const assignment = assignmentOf(store, taskId, now, { principal: "operator", repos: [project] }, root);
137
+ headline = assignment === null ? null : taskStatusOf(assignmentStatusFacts(assignment)).headline;
127
138
  }
128
139
  catch {
129
- state = null;
140
+ headline = null;
130
141
  }
131
- if (state === "ready-to-check")
132
- waits.set(taskId, `${title(taskId)} · Ready for review`);
133
- else if (state === "needs-decision")
134
- waits.set(taskId, `${title(taskId)} · Needs your decision`);
142
+ if (headline === "Ready for review" || headline === "Needs you")
143
+ waits.set(taskId, `${title(taskId)} · ${headline}`);
144
+ else if (headline === "Failed")
145
+ failed.set(taskId, title(taskId));
135
146
  }
136
147
  if (finished.size + failed.size + waits.size === 0)
137
148
  return null;
@@ -26,6 +26,18 @@ export declare const CHAT_TASK_ACTIONS: {
26
26
  readonly label: "Remove dependency";
27
27
  readonly detail: "Stops waiting for the selected task. Other requirements still apply.";
28
28
  };
29
+ readonly run_checks: {
30
+ readonly label: "Run checks";
31
+ readonly detail: "Runs the project's quick check on this result's exact commit. A pass upgrades its status; a failure stays visible.";
32
+ };
33
+ readonly run_full_checks: {
34
+ readonly label: "Run full checks";
35
+ readonly detail: "Runs the project's full check on this result's exact commit. A pass upgrades its status; a failure stays visible.";
36
+ };
37
+ readonly add_tests: {
38
+ readonly label: "Add tests";
39
+ readonly detail: "Files a small task to write tests for this change. Nothing runs until it is approved.";
40
+ };
29
41
  };
30
42
  export declare function isChatTaskAction(value: unknown): value is keyof typeof CHAT_TASK_ACTIONS;
31
43
  /** Bind cards to a specific execution and its current state, not the family's moving pointer. */
@@ -34,6 +46,7 @@ export declare function chatTaskStamp(store: Store, who: VerifiedApprover, task:
34
46
  export declare function chatTaskRun(store: Store, task: string, operation: "stop" | "resume", now: Date): number | null;
35
47
  export declare function applyChatTaskAction(store: Store, who: VerifiedApprover, payload: Record<string, unknown>, now: Date, web: boolean, controls?: Pick<StopRequest, "held" | "deferSignal"> & {
36
48
  via?: StopRequest["via"];
49
+ evidenceRoot?: string;
37
50
  }): {
38
51
  ok: true;
39
52
  taskId: string;
@@ -1,6 +1,7 @@
1
1
  import { verifiedAuthor } from "./store.js";
2
2
  import { requestTaskStop, taskControlOf } from "./task-control.js";
3
3
  import { authorizePlanUnderMode } from "./plan-auto.js";
4
+ import { fileAddTestsTask, requestFollowUpChecks } from "./result-follow-ups.js";
4
5
  export const CHAT_TASK_ACTIONS = {
5
6
  // Build #1604 feedback: one short consequence that still says saved work
6
7
  // remains, other tasks continue, and the pause waits for acknowledgement.
@@ -10,6 +11,10 @@ export const CHAT_TASK_ACTIONS = {
10
11
  plan: { label: "Plan first", detail: "Requests a plan. Building still requires approval." },
11
12
  wait_for: { label: "Add dependency", detail: "Waits for the selected task to finish." },
12
13
  stop_waiting: { label: "Remove dependency", detail: "Stops waiting for the selected task. Other requirements still apply." },
14
+ // Follow-ups on a result (result-follow-ups.ts): the same actions as the result page.
15
+ run_checks: { label: "Run checks", detail: "Runs the project's quick check on this result's exact commit. A pass upgrades its status; a failure stays visible." },
16
+ run_full_checks: { label: "Run full checks", detail: "Runs the project's full check on this result's exact commit. A pass upgrades its status; a failure stays visible." },
17
+ add_tests: { label: "Add tests", detail: "Files a small task to write tests for this change. Nothing runs until it is approved." },
13
18
  };
14
19
  export function isChatTaskAction(value) {
15
20
  return typeof value === "string" && Object.hasOwn(CHAT_TASK_ACTIONS, value);
@@ -52,7 +57,7 @@ export function applyChatTaskAction(store, who, payload, now, web, controls = {}
52
57
  // Resume remains the existing nonce + password ceremony. A proposal only opens it.
53
58
  if (operation === "resume")
54
59
  return { ok: true, taskId: task, said: `Resume review requested for ${task}. Complete the password confirmation on the task to resume.` };
55
- const { via, ...rest } = controls;
60
+ const { via, evidenceRoot: _root, ...rest } = controls;
56
61
  const result = requestTaskStop(store, { taskId: task, runId: run, by: verifiedAuthor(who.name), via: via ?? (web ? "web" : "cli"), ...rest }, now);
57
62
  return result.ok ? { ok: true, taskId: task, said: `Stop requested for ${task}, run #${run}.` }
58
63
  : { ok: false, message: result.detail };
@@ -61,6 +66,19 @@ export function applyChatTaskAction(store, who, payload, now, web, controls = {}
61
66
  const result = store.requeueTask(task, who.name, now);
62
67
  return result.ok ? { ok: true, taskId: task, said: "Task queued again." } : { ok: false, message: result.reason === "claimed" ? "This task is already running." : result.reason === "not-stalled" ? "This task does not need another attempt." : "That task is no longer available." };
63
68
  }
69
+ if (operation === "run_checks" || operation === "run_full_checks" || operation === "add_tests") {
70
+ const result = store.runsFor(ref.id).find(one => one.role === "builder" && one.finishedAt !== null && one.headRevision !== null) ?? null;
71
+ if (result === null)
72
+ return { ok: false, message: "This task has no finished result yet." };
73
+ if (operation === "add_tests") {
74
+ const filed = fileAddTestsTask(store, controls.evidenceRoot ?? null, { runId: result.id, actor: who.name, filedVia: web ? "console" : "cli", admittedRepos: who.repos }, now);
75
+ return filed.ok ? { ok: true, taskId: filed.id, said: filed.existing ? `Tests are already filed as ${filed.id}.` : `Filed ${filed.id} to add tests. Review and approve it to start.` }
76
+ : { ok: false, message: filed.message };
77
+ }
78
+ const asked = requestFollowUpChecks(store, { runId: result.id, level: operation === "run_full_checks" ? "full" : "quick", actor: who.name }, now);
79
+ return asked.ok ? { ok: true, taskId: task, said: `${asked.level === "full" ? "Full" : "Quick"} checks ${asked.existing ? "are already" : "are"} queued on this result's commit.` }
80
+ : { ok: false, message: asked.message };
81
+ }
64
82
  if (operation === "plan") {
65
83
  const result = store.requestPlan(ref.id, now);
66
84
  if (!result.ok)
@@ -0,0 +1,21 @@
1
+ /** Settings → Projects → Checks: how much checks after each build, in plain
2
+ * words. One choice (Quick, Full, Off) and the quick command beside the full
3
+ * one. Every change takes the password and lands in the ledger; approving a
4
+ * command restates exactly what will run, unattended, before the yes. */
5
+ import { type CheckLevel } from "./check-levels.js";
6
+ import type { VerifyCommand } from "./store.js";
7
+ export type CheckSettingsView = {
8
+ repo: string;
9
+ name: string;
10
+ csrf: string;
11
+ canChange: boolean;
12
+ level: CheckLevel;
13
+ full: VerifyCommand | null;
14
+ quick: VerifyCommand | null;
15
+ /** A starting point for the quick command, from the project's scripts. */
16
+ suggestion: string | null;
17
+ said: string | null;
18
+ problem: string | null;
19
+ };
20
+ export declare function checkSettingsHtml(view: CheckSettingsView): string;
21
+ export declare const CHECK_SETTINGS_CSS = "\n .check-settings { display: grid; gap: .6rem; max-width: 40rem; }\n .check-settings p, .check-settings h2 { margin: 0; }\n .check-settings h2 { font-size: 1rem; }\n .check-settings fieldset { border: 0; margin: 0; padding: 0; display: grid; gap: .25rem; }\n .check-settings legend { font-weight: 600; margin-bottom: .25rem; }\n .check-settings label { display: grid; gap: .25rem; }\n .check-settings .check-choice { display: flex; align-items: flex-start; gap: .6rem; min-height: 44px; padding: .35rem 0; cursor: pointer; }\n .check-settings .check-choice > span { display: grid; gap: .1rem; }\n .check-settings .check-choice input { margin-top: .2rem; }\n .check-settings input[type=password], .check-settings input[type=number], .check-settings textarea { min-height: 44px; box-sizing: border-box; width: 100%; }\n .check-settings button { min-height: 44px; justify-self: start; }\n .check-settings .check-command { overflow-wrap: anywhere; }\n .check-terms { margin: 0; padding-left: 1.1rem; display: grid; gap: .25rem; }\n @media (max-width: 480px) { .check-settings button { justify-self: stretch; } }\n .follow-ups .follow-up-list { margin: .4rem 0; padding-left: 1.1rem; display: grid; gap: .25rem; }\n .follow-ups .follow-up-acts, .follow-ups .follow-up-act { display: flex; flex-wrap: wrap; gap: .5rem; margin: .5rem 0 0; }\n .follow-ups .follow-up-act { margin: 0; }\n .follow-ups button { min-height: 44px; }\n @media (max-width: 480px) { .follow-ups .follow-up-acts, .follow-ups .follow-up-act { display: grid; } .follow-ups .follow-up-act button { width: 100%; } }\n";
@@ -0,0 +1,70 @@
1
+ /** Settings → Projects → Checks: how much checks after each build, in plain
2
+ * words. One choice (Quick, Full, Off) and the quick command beside the full
3
+ * one. Every change takes the password and lands in the ledger; approving a
4
+ * command restates exactly what will run, unattended, before the yes. */
5
+ import { CHECK_LEVELS, CHECK_LEVEL_HINTS, CHECK_LEVEL_WORDS } from "./check-levels.js";
6
+ const escape = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
7
+ export function checkSettingsHtml(view) {
8
+ const hidden = (act) => `<input type="hidden" name="csrf" value="${escape(view.csrf)}"><input type="hidden" name="repo" value="${escape(view.repo)}"><input type="hidden" name="act" value="${act}">`;
9
+ const password = `<label>Password<input type="password" name="password" autocomplete="current-password" required></label>`;
10
+ const notes = [
11
+ view.said === null ? "" : `<p class="notice" role="status">${escape(view.said)}</p>`,
12
+ view.problem === null ? "" : `<p class="problem" role="alert">${escape(view.problem)}</p>`,
13
+ ].join("");
14
+ const head = `<p class="meta">${escape(view.name)} · <span class="mono">${escape(view.repo)}</span></p>${notes}`;
15
+ const command = (one) => `<p class="mono check-command">${escape(one.command)}</p><p class="meta">Up to ${Math.round(one.timeoutMs / 1000)}s · approved by ${escape(one.approvedBy)}</p>`;
16
+ // Quick with no quick command runs the full check; say so where the choice is made.
17
+ const fallsBack = view.level === "quick" && view.quick === null;
18
+ const current = `<p><strong>${CHECK_LEVEL_WORDS[view.level]}</strong> · ${escape(CHECK_LEVEL_HINTS[view.level])}</p>` +
19
+ (fallsBack ? `<p class="meta">No quick command yet, so builds run the full check.</p>` : "") +
20
+ (view.level !== "full" && view.full !== null ? `<p class="meta">The full check still runs when a pull request opens, and Merge waits for it.</p>` : "");
21
+ if (!view.canChange) {
22
+ return head + `<section class="card check-settings">${current}</section>` +
23
+ `<section class="card check-settings"><h2>Quick check</h2>${view.quick === null ? `<p class="meta">None yet.</p>` : command(view.quick)}</section>` +
24
+ `<section class="card check-settings"><h2>Full check</h2>${view.full === null ? `<p class="meta">None yet.</p>` : command(view.full)}</section>`;
25
+ }
26
+ const choices = CHECK_LEVELS.map(level => `<label class="check-choice"><input type="radio" name="level" value="${level}"${level === view.level ? " checked" : ""}>` +
27
+ `<span><strong>${CHECK_LEVEL_WORDS[level]}</strong><span class="meta">${escape(CHECK_LEVEL_HINTS[level])}</span></span></label>`).join("");
28
+ const levelForm = `<form method="post" action="/settings/checks" class="card check-settings">${hidden("level")}` +
29
+ `<fieldset><legend>After each build</legend>${choices}</fieldset>` +
30
+ (fallsBack ? `<p class="meta">No quick command yet, so Quick runs the full check.</p>` : "") +
31
+ `<p class="meta">A task can choose its own checks when it's filed. With Quick or Off, the full check runs when a pull request opens, and Merge waits for it unless you merge anyway.</p>` +
32
+ `${password}<button type="submit">Save</button></form>`;
33
+ const quickTerms = `<ul class="check-terms"><li>Runs unattended right after every Quick build, in that build's checkout.</li><li>Plain environment: no credentials.</li><li>A failure shows on the result; nothing is blocked.</li></ul>`;
34
+ const quickForm = (value, submit) => `<form method="post" action="/settings/checks" class="check-settings">${hidden("quick")}` +
35
+ `<label>Command<textarea name="command" rows="2" maxlength="2000" spellcheck="false" class="mono" required>${escape(value)}</textarea></label>` +
36
+ `<label>Time limit (seconds)<input type="number" name="timeout" min="1" max="3600" value="${view.quick === null ? 180 : Math.round(view.quick.timeoutMs / 1000)}" required></label>` +
37
+ `${quickTerms}${password}<button type="submit">${submit}</button></form>`;
38
+ const quick = view.quick === null
39
+ ? `<section class="card check-settings"><h2>Quick check</h2>` +
40
+ (view.suggestion === null ? `<p class="meta">Typecheck and the tests near the change, ideally under 2 minutes.</p>` : `<p class="meta">Suggested from the project's scripts. Edit it before approving.</p>`) +
41
+ quickForm(view.suggestion ?? "", "Approve quick check") + `</section>`
42
+ : `<section class="card check-settings"><h2>Quick check</h2>${command(view.quick)}` +
43
+ `<details class="settings-more"><summary>Change quick check</summary>${quickForm(view.quick.command, "Approve change")}</details>` +
44
+ `<details class="settings-more"><summary>Remove quick check</summary><form method="post" action="/settings/checks" class="check-settings">${hidden("quick-clear")}` +
45
+ `<p class="meta">Quick builds run the full check instead.</p>${password}<button type="submit" class="danger">Remove</button></form></details></section>`;
46
+ const full = `<details class="settings-more"><summary>Full check</summary><section class="card check-settings">` +
47
+ (view.full === null ? `<p class="meta">None yet. Approve one with <span class="mono">toolroll verify set --repo … --command "…"</span>.</p>` : command(view.full)) + `</section></details>`;
48
+ return head + levelForm + quick + full;
49
+ }
50
+ export const CHECK_SETTINGS_CSS = `
51
+ .check-settings { display: grid; gap: .6rem; max-width: 40rem; }
52
+ .check-settings p, .check-settings h2 { margin: 0; }
53
+ .check-settings h2 { font-size: 1rem; }
54
+ .check-settings fieldset { border: 0; margin: 0; padding: 0; display: grid; gap: .25rem; }
55
+ .check-settings legend { font-weight: 600; margin-bottom: .25rem; }
56
+ .check-settings label { display: grid; gap: .25rem; }
57
+ .check-settings .check-choice { display: flex; align-items: flex-start; gap: .6rem; min-height: 44px; padding: .35rem 0; cursor: pointer; }
58
+ .check-settings .check-choice > span { display: grid; gap: .1rem; }
59
+ .check-settings .check-choice input { margin-top: .2rem; }
60
+ .check-settings input[type=password], .check-settings input[type=number], .check-settings textarea { min-height: 44px; box-sizing: border-box; width: 100%; }
61
+ .check-settings button { min-height: 44px; justify-self: start; }
62
+ .check-settings .check-command { overflow-wrap: anywhere; }
63
+ .check-terms { margin: 0; padding-left: 1.1rem; display: grid; gap: .25rem; }
64
+ @media (max-width: 480px) { .check-settings button { justify-self: stretch; } }
65
+ .follow-ups .follow-up-list { margin: .4rem 0; padding-left: 1.1rem; display: grid; gap: .25rem; }
66
+ .follow-ups .follow-up-acts, .follow-ups .follow-up-act { display: flex; flex-wrap: wrap; gap: .5rem; margin: .5rem 0 0; }
67
+ .follow-ups .follow-up-act { margin: 0; }
68
+ .follow-ups button { min-height: 44px; }
69
+ @media (max-width: 480px) { .follow-ups .follow-up-acts, .follow-ups .follow-up-act { display: grid; } .follow-ups .follow-up-act button { width: 100%; } }
70
+ `;