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
package/CHANGELOG.md CHANGED
@@ -2,6 +2,55 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.9.7 — 2026-10-01
6
+
7
+ - **Onboarding without a password or a form.** `toolroll up` prints three
8
+ lines and opens the console already signed in, through a one-time link that
9
+ works once, for ten minutes, from this computer only. The lead turns on by
10
+ itself with the agent that's signed in (Claude Code, else Codex); the old
11
+ setup form moved to Settings → Lead → Advanced. With no agent signed in,
12
+ Chat shows the command to install and sign in, and checks again on its own.
13
+ A short "how it works", a live Plan → Ready timeline on your first task, a
14
+ card for pairing your phone, and a "wrong host" page that says what to run.
15
+ - **The task page is one conversation.** The status stays on top; below it
16
+ the plan, the agent's notes and results, questions and your replies, in
17
+ order, ending in a box to message the agent (Build, Plan only, or Just
18
+ answer). Details (scope, attempts, audit) moved to a side panel, or a sheet
19
+ on phones. The Chat home shows who is working now and four counts, and
20
+ Catch up and the Inbox have tabs: Needs you, Ready, Running, All.
21
+ - **Checks: quick while building, easy to turn off.** Each project has a
22
+ check level: Quick (the default for new projects), Full or Off, and a task
23
+ can override it ("skip the tests"). With checks Off a result is still
24
+ Ready for review, and says no check ran. Any result can run its checks again or file a task to add tests.
25
+ - **A flow gallery, and flows as files.** Flows → New is a gallery of
26
+ templates grouped by what they're for, with new ones for developers
27
+ (Overnight bug bash, Flaky test hunter, Release notes writer, Docs follow
28
+ the code, Error to fix, PR second opinion and more). Export any flow as a
29
+ `.toolroll-flow.json` file and import one from a file or a link, with a
30
+ preview; secrets never leave, and imported triggers start switched off.
31
+ - **Several builds at once on one project.** The service builds up to two
32
+ tasks of a project at a time (Settings → Projects, `toolroll project
33
+ concurrency`), within the machine's limit. Stopping the service mid-build
34
+ saves the work and resumes it next time.
35
+ - **The lead's housekeeping stays quiet.** An agent acting as your lead gets
36
+ its own credential (`toolroll lead token`); its work pings nobody unless it
37
+ hands something to you. Your own actions never ping you, a replaced task
38
+ reads "Replaced by", and a whole project can be muted.
39
+ - **A faster release check.** Flow journeys run in parallel groups, browser
40
+ journeys are rebalanced, and a version-only bump no longer forces every
41
+ test.
42
+
43
+ ## 0.9.6 — 2026-10-01
44
+
45
+ - **Every task's status, clear at a glance.** One headline from a fixed set
46
+ (Queued, Planning, Needs you, Building, Ready for review, Complete, Failed,
47
+ Stopped) and one plain sentence, the same words on the Tasks list, the task
48
+ page, the result, Crew, the Telegram/Slack/Discord/Teams card and
49
+ `toolroll status`. Checks, the pull request, requirements and saved
50
+ evidence sit underneath as quiet rows. A pull request that couldn't open
51
+ is an amber note with one action, never a red chip beside Complete: red
52
+ now means only Failed. The exact technical reason stays under Details.
53
+
5
54
  ## 0.9.5 — 2026-10-01
6
55
 
7
56
  - **A demo you can feel in 90 seconds.** In `npx toolroll demo`, Chat has a
@@ -0,0 +1,21 @@
1
+ export type Actor = {
2
+ account: string;
3
+ lead: boolean;
4
+ };
5
+ /** Run `fn` with this actor (or an empty slot a verified sign-in may fill). */
6
+ export declare function withActor<T>(actor: Actor | null, fn: () => T): T;
7
+ export declare function currentActor(): Actor | null;
8
+ /** A person verified their password inside this command: their acts are now theirs. A lead keeps its own label. */
9
+ export declare function claimActor(account: string): void;
10
+ /** How the ledger names an actor. */
11
+ export declare function actorLabel(actor: Actor): string;
12
+ export declare function mintLeadToken(): {
13
+ id: string;
14
+ token: string;
15
+ hash: string;
16
+ };
17
+ export declare function parseLeadToken(presented: string): {
18
+ id: string;
19
+ secret: string;
20
+ } | null;
21
+ export declare function leadSecretMatches(secret: string, keptHash: string): boolean;
package/dist/actor.js ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Who is acting right now, for pings that follow responsibility.
3
+ *
4
+ * A lead is an agent acting for one person under a lead token (`toolroll lead
5
+ * token`): it signs in as that person, and its acts are recorded as "lead for
6
+ * <owner>". A person acting for themselves is recorded by name. Work whose
7
+ * actor is the lead pings nobody, and nobody is pinged about their own act.
8
+ *
9
+ * The actor is held for one command or one console request, never for a
10
+ * long-running service: a worker's facts are nobody's act.
11
+ */
12
+ import { AsyncLocalStorage } from "node:async_hooks";
13
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
14
+ /** The slot is mutable so a command can name its person after it has verified their password. */
15
+ const slot = new AsyncLocalStorage();
16
+ /** Run `fn` with this actor (or an empty slot a verified sign-in may fill). */
17
+ export function withActor(actor, fn) {
18
+ return slot.run({ actor }, fn);
19
+ }
20
+ export function currentActor() {
21
+ return slot.getStore()?.actor ?? null;
22
+ }
23
+ /** A person verified their password inside this command: their acts are now theirs. A lead keeps its own label. */
24
+ export function claimActor(account) {
25
+ const held = slot.getStore();
26
+ if (held !== undefined && held.actor === null)
27
+ held.actor = { account, lead: false };
28
+ }
29
+ /** How the ledger names an actor. */
30
+ export function actorLabel(actor) {
31
+ return actor.lead ? `lead for ${actor.account}` : actor.account;
32
+ }
33
+ // ---- the lead token ---------------------------------------------------------
34
+ /** `lt_<id>_<secret>`: shown once, only its hash kept. */
35
+ const SHAPE = /^lt_([a-f0-9]{12})_([A-Za-z0-9_-]{43})$/;
36
+ export function mintLeadToken() {
37
+ const id = randomBytes(6).toString("hex"), secret = randomBytes(32).toString("base64url");
38
+ return { id, token: `lt_${id}_${secret}`, hash: hashLeadSecret(secret) };
39
+ }
40
+ const hashLeadSecret = (secret) => createHash("sha256").update(secret, "utf8").digest("hex");
41
+ export function parseLeadToken(presented) {
42
+ const match = SHAPE.exec(presented.trim());
43
+ return match === null ? null : { id: match[1], secret: match[2] };
44
+ }
45
+ export function leadSecretMatches(secret, keptHash) {
46
+ const a = Buffer.from(hashLeadSecret(secret), "hex"), b = Buffer.from(keptHash, "hex");
47
+ return a.length === b.length && timingSafeEqual(a, b);
48
+ }
@@ -117,6 +117,7 @@ export declare function assignmentForCoordinator(store: Store, token: string, op
117
117
  actor: string;
118
118
  at: string;
119
119
  digest: string;
120
+ lead?: true;
120
121
  } | null;
121
122
  result: {
122
123
  digest: string;
@@ -3,6 +3,7 @@
3
3
  * checks, approvals, ownership, or the exact completion identity. */
4
4
  import type { AssignmentSnapshot } from './assignment.js';
5
5
  import { type DisplayStatus, type WorkStatus } from './workspace-ui.js';
6
+ import { type PullRequestFact, type StatusAction, type TaskStatus, type TaskStatusFacts } from './task-status.js';
6
7
  export type AssignmentWorkStatus = DisplayStatus & Partial<Pick<WorkStatus, 'views' | 'rank'>>;
7
8
  export type AssignmentAttention = {
8
9
  id: string;
@@ -14,12 +15,22 @@ export type AssignmentAttention = {
14
15
  export declare function historicalAssessmentReason(reason: string): boolean;
15
16
  /** Known storage-cap notices do not mean the retained bytes are damaged. */
16
17
  export declare function shortenedMaterialReason(detail: string): boolean;
17
- export declare function assignmentPresentationOf(assignment: AssignmentSnapshot, options?: {
18
+ export type AssignmentStatusOptions = {
18
19
  workStatus?: AssignmentWorkStatus;
19
20
  diagnostics?: WorkStatus['diagnostics'];
20
21
  additionalAttention?: readonly string[];
21
- }): {
22
+ /** A live planner rather than a builder holds the work. */
23
+ planning?: boolean;
24
+ pullRequest?: PullRequestFact | null;
25
+ action?: StatusAction | null;
26
+ links?: TaskStatusFacts['links'];
27
+ evidence?: TaskStatusFacts['evidence'];
28
+ };
29
+ /** The one shared status (task-status.ts) for this assignment. */
30
+ export declare function assignmentTaskStatusOf(assignment: AssignmentSnapshot, options?: AssignmentStatusOptions): TaskStatus;
31
+ export declare function assignmentPresentationOf(assignment: AssignmentSnapshot, options?: AssignmentStatusOptions): {
22
32
  status: WorkStatus;
33
+ taskStatus: TaskStatus;
23
34
  primaryAction: import("./work-summary.js").WorkAction | null;
24
35
  attention: AssignmentAttention[];
25
36
  diagnostics: Pick<DisplayStatus, "detail" | "label" | "token" | "tone">[];
@@ -1,9 +1,6 @@
1
1
  import { GOAL_ASSESSMENT_PENDING, manualReviewCriterionOf } from './proof.js';
2
2
  import { failedCheckExit } from './workspace-ui.js';
3
- const LABELS = {
4
- working: 'Working', checking: 'Checking', 'needs-decision': 'Needs your decision',
5
- 'ready-to-check': 'Ready', complete: 'Complete', cancelled: 'Cancelled',
6
- };
3
+ import { assignmentStatusFacts, taskStatusOf, workToneOf } from './task-status.js';
7
4
  /** Only the retired assessment requirement is history-only. Specific missing
8
5
  * material, defects and failed checks remain visible, including reviewer findings. */
9
6
  export function historicalAssessmentReason(reason) {
@@ -19,13 +16,29 @@ export function shortenedMaterialReason(detail) {
19
16
  detail === 'The changed-file list was cut short; the counts are complete.' ||
20
17
  /^Saved (?:terminal-diff|report) #\d+ is incomplete\.$/.test(detail);
21
18
  }
19
+ /** The one shared status (task-status.ts) for this assignment. */
20
+ export function assignmentTaskStatusOf(assignment, options = {}) {
21
+ const work = options.workStatus;
22
+ return taskStatusOf(assignmentStatusFacts(assignment, {
23
+ work: work === undefined ? null : { token: work.token, ...(work.views === undefined ? {} : { views: work.views }), detail: work.detail },
24
+ ...(options.planning === undefined ? {} : { planning: options.planning }),
25
+ ...(options.pullRequest === undefined ? {} : { pullRequest: options.pullRequest }),
26
+ ...(options.action === undefined ? {} : { action: options.action }),
27
+ ...(options.links === undefined ? {} : { links: options.links }),
28
+ ...(options.evidence === undefined ? {} : { evidence: options.evidence }),
29
+ }));
30
+ }
22
31
  export function assignmentPresentationOf(assignment, options = {}) {
23
32
  const { state } = assignment, work = options.workStatus;
33
+ const taskStatus = assignmentTaskStatusOf(assignment, options);
34
+ // The headline and its sentence are the shared words; the token, views and
35
+ // rank stay the existing projection's (pages and tests key off them).
36
+ const shared = { label: taskStatus.headline, tone: workToneOf(taskStatus.headline), detail: taskStatus.sentence, action: null };
37
+ const needsYou = taskStatus.headline === 'Needs you';
24
38
  const status = state === 'working'
25
- ? { ...(work ?? { label: LABELS[state], tone: 'muted' }), token: `assignment-${state}`,
26
- detail: assignment.detail, action: null, views: work?.views?.filter(view => view !== 'completed') ?? ['all'], rank: work?.rank ?? 2 }
27
- : { token: `assignment-${state}`, label: state === 'checking' ? work?.label ?? LABELS[state] : LABELS[state], detail: assignment.detail,
28
- action: null, tone: state === 'needs-decision' ? 'attention' : state === 'checking' ? work?.tone ?? 'live' : state === 'ready-to-check' ? 'ready' : state === 'complete' ? 'done' : 'muted',
39
+ ? { ...shared, token: `assignment-${state}`,
40
+ views: [...new Set([...(work?.views?.filter(view => view !== 'completed') ?? ['all']), ...(needsYou ? ['needs-you'] : [])])], rank: needsYou ? 0 : work?.rank ?? 2 }
41
+ : { ...shared, token: `assignment-${state}`,
29
42
  views: state === 'ready-to-check' || state === 'needs-decision' ? ['all', 'needs-you'] : state === 'complete' ? ['all', 'completed']
30
43
  : state === 'checking' && work?.views?.includes('running') ? ['all', 'running'] : ['all'],
31
44
  rank: state === 'needs-decision' || state === 'ready-to-check' ? 0 : state === 'checking' ? 1 : state === 'complete' ? 3 : 4 };
@@ -78,5 +91,5 @@ export function assignmentPresentationOf(assignment, options = {}) {
78
91
  return false;
79
92
  return true;
80
93
  });
81
- return { status, primaryAction: assignment.primaryAction, attention: [...attention.values()], diagnostics };
94
+ return { status, taskStatus, primaryAction: assignment.primaryAction, attention: [...attention.values()], diagnostics };
82
95
  }
@@ -2,7 +2,8 @@
2
2
  * existing projections and signed operating-mode terms, never this renderer. */
3
3
  import type { AssignmentSnapshot } from './assignment.js';
4
4
  import type { DisplayStatus, StatusTone, WorkStatus } from './workspace-ui.js';
5
- import { type AssignmentWorkStatus } from './assignment-presentation.js';
5
+ import { type AssignmentStatusOptions, type AssignmentWorkStatus } from './assignment-presentation.js';
6
+ import { type PullRequestFact, type TaskStatus } from './task-status.js';
6
7
  /** The existing verified receipt reader may discover damage after a verdict
7
8
  * was saved. Keep the limitation visible without inventing another work stage. */
8
9
  export declare function assignmentWithEvidence(assignment: AssignmentSnapshot, resultStatus: DisplayStatus | null, resultRunId?: number | null): AssignmentSnapshot;
@@ -19,38 +20,32 @@ export type AssignmentAttemptRow = {
19
20
  /** Every version of the assignment, oldest first, as the card lists them. */
20
21
  export declare function assignmentAttemptsOf(assignment: AssignmentSnapshot): AssignmentAttemptRow[];
21
22
  export declare function assignmentAttemptsHtml(assignment: AssignmentSnapshot): string;
22
- type AssignmentCardOptions = {
23
+ type AssignmentCardOptions = Omit<AssignmentStatusOptions, 'links'> & {
23
24
  hideAction?: boolean;
24
25
  problem?: boolean;
25
- diagnostics?: WorkStatus['diagnostics'];
26
- workStatus?: AssignmentWorkStatus;
27
26
  resultHref?: string;
27
+ pullRequest?: PullRequestFact | null;
28
28
  };
29
29
  /** The status card's content: one projection for the server HTML and the
30
- * rebuilt task page, so the two never word the same state differently. */
30
+ * rebuilt task page, so the two never word the same state differently. The
31
+ * headline, sentence and detail rows are the shared task status; the exact
32
+ * recorded reasons stay one tap away under Details, never as red text. */
31
33
  export type AssignmentCard = {
32
34
  token: string;
33
35
  tone: StatusTone;
34
36
  label: string;
37
+ status: TaskStatus;
35
38
  action: {
36
39
  label: string;
37
40
  href: string;
38
41
  openResult: boolean;
39
42
  } | null;
40
- /** Ready with passing checks: a verdict chip replaces the detail sentence. */
41
- passed: {
42
- by: string | null;
43
- } | null;
44
- detail: {
45
- text: string;
46
- problem: boolean;
47
- };
48
- problems: string[];
43
+ /** Exact technical reasons and diagnostics, shown on request. */
44
+ reasons: string[];
49
45
  diagnostics: {
50
46
  token: string;
51
47
  label: string;
52
48
  detail: string;
53
- problem: boolean;
54
49
  }[];
55
50
  notices: {
56
51
  summary: string;
@@ -1,5 +1,7 @@
1
1
  import { assignmentPresentationOf, shortenedMaterialReason } from './assignment-presentation.js';
2
2
  import { plainReasonWords } from './proof.js';
3
+ import { chatResultHref } from './chat-controls.js';
4
+ import { statusDetailsHtml, statusWhyHtml } from './task-status.js';
3
5
  const escape = (value) => value.replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char]);
4
6
  /** The existing verified receipt reader may discover damage after a verdict
5
7
  * was saved. Keep the limitation visible without inventing another work stage. */
@@ -63,25 +65,25 @@ function savedMaterialNotice(detail, assignment) {
63
65
  !assignment.receipt.artifacts.some(one => one.id === Number(earlier[1])) ? 'history' : null;
64
66
  }
65
67
  export function assignmentCardOf(assignment, options = {}) {
66
- const presentation = assignmentPresentationOf(assignment, options);
67
- const { status, diagnostics } = presentation;
68
+ const receipt = assignment.receipt;
69
+ const links = receipt === null ? {} : { result: options.resultHref ?? chatResultHref(receipt.taskId, receipt.runId), checks: chatResultHref(receipt.taskId, receipt.runId, 'checks'),
70
+ pullRequest: `/t/${encodeURIComponent(assignment.rootId)}#merge`,
71
+ // Run checks lives with the result's checks; the row's action leads there.
72
+ ...(receipt.completionKind === 'research-report' ? {} : { runChecks: `${chatResultHref(receipt.taskId, receipt.runId, 'checks')}#follow-ups` }) };
73
+ const presentation = assignmentPresentationOf(assignment, { ...options, links });
74
+ const { status, diagnostics, taskStatus } = presentation;
68
75
  const href = assignment.primaryAction?.code === 'open-result' && options.resultHref !== undefined ? options.resultHref : assignmentActionHref(assignment);
69
76
  const attention = presentation.attention.map(one => one.detail);
70
77
  const notices = [...attention, ...diagnostics.map(one => one.detail)].filter(one => savedMaterialNotice(one, assignment) !== null);
71
78
  const partial = notices.some(one => savedMaterialNotice(one, assignment) === 'partial');
72
79
  const history = notices.some(one => savedMaterialNotice(one, assignment) === 'history');
73
- const ready = assignment.state === "ready-to-check" || assignment.state === "complete";
74
- const checkProblem = ready && assignment.receipt !== null && assignment.receipt.completionKind !== 'research-report' && assignment.receipt.checks.status !== 'passed';
80
+ const recorded = assignment.detail === taskStatus.sentence || taskStatus.headline === 'Ready for review' || taskStatus.headline === 'Complete' ? [] : [assignment.detail];
75
81
  return {
76
- token: status.token, tone: status.tone, label: status.label,
82
+ token: status.token, tone: status.tone, label: status.label, status: taskStatus,
77
83
  action: options.hideAction || href === null ? null : { label: assignment.primaryAction.label, href, openResult: options.resultHref !== undefined },
78
- // A good outcome at a glance; the same words stay in the result.
79
- passed: ready && !checkProblem && assignment.receipt !== null && assignment.receipt.checks.status === 'passed'
80
- ? { by: assignment.completion === null ? null : assignment.completion.actor.replace(/^(?:operator|coordinator|lead):/, '') } : null,
81
- detail: { text: assignment.detail, problem: checkProblem || options.problem === true && !ready },
82
- problems: attention.filter(one => savedMaterialNotice(one, assignment) === null).map(plainReasonWords),
84
+ reasons: [...new Set([...recorded, ...attention.filter(one => savedMaterialNotice(one, assignment) === null).map(plainReasonWords)])].filter(one => one !== taskStatus.sentence),
83
85
  diagnostics: diagnostics.filter(one => savedMaterialNotice(one.detail, assignment) === null)
84
- .map(one => ({ token: one.token, label: one.label, detail: one.detail, problem: one.tone === 'problem' || one.tone === 'attention' })),
86
+ .map(one => ({ token: one.token, label: one.label, detail: one.detail })),
85
87
  notices: notices.length === 0 ? null : { summary: [partial ? 'Saved output is partial' : '', history ? 'Earlier material unavailable' : ''].filter(Boolean).join(' · '), lines: [...new Set(notices)] },
86
88
  attempts: assignmentAttemptsOf(assignment),
87
89
  lead: assignment.owner === null ? null : { label: assignment.owner.label, active: assignment.owner.active },
@@ -89,14 +91,13 @@ export function assignmentCardOf(assignment, options = {}) {
89
91
  }
90
92
  export function assignmentSummaryHtml(assignment, options = {}) {
91
93
  const card = assignmentCardOf(assignment, options);
92
- return `<section class="${options.compact ? 'assignment-summary' : 'card assignment-summary'}" aria-label="assignment progress" data-assignment="${escape(assignment.rootId)}" data-work-status="${card.token}" data-tone="${card.tone}"${options.compact ? '' : ' data-task-status'}>` +
93
- (options.compact ? `<span class="status-line" data-work-status="${card.token}" data-tone="${card.tone}"><i class="status-dot" aria-hidden="true"></i><span class="status-label">${card.label}</span></span>` : `<h2 class="assignment-state">${card.label}</h2>`) +
94
+ const tone = card.status.tone;
95
+ return `<section class="${options.compact ? 'assignment-summary' : 'card assignment-summary'}" aria-label="assignment progress" data-assignment="${escape(assignment.rootId)}" data-work-status="${card.token}" data-tone="${card.tone}" data-headline="${escape(card.status.headline)}" data-headline-tone="${tone}"${options.compact ? '' : ' data-task-status'}>` +
96
+ (options.compact ? `<span class="status-line" data-work-status="${card.token}" data-tone="${card.tone}"><i class="status-dot" aria-hidden="true"></i><span class="status-label">${card.label}</span></span>` : `<h2 class="assignment-state status-headline"><i aria-hidden="true"></i>${escape(card.label)}</h2>`) +
97
+ `<p class="meta assignment-detail status-sentence">${escape(card.status.sentence)}</p>` +
94
98
  (card.action === null ? '' : `<a class="${options.compact ? 'work-action' : 'button-link'}" href="${escape(card.action.href)}"${card.action.openResult ? ' data-open-result' : ''}${options.compact ? '' : ' data-primary-action'}>${escape(card.action.label)}${options.compact ? ' →' : ''}</a>`) +
95
- (!options.compact && card.passed !== null
96
- ? `<p class="meta assignment-detail assignment-verdict"><span class="verdict-chip verdict-chip--success">Checks passed</span>${card.passed.by === null ? '' : ` Completed by ${escape(card.passed.by)}`}</p>`
97
- : `<p class="${card.detail.problem ? 'problem' : 'meta'} assignment-detail">${escape(card.detail.text)}</p>`) +
98
- card.problems.map(one => `<p class="problem">${escape(one)}</p>`).join('') +
99
- card.diagnostics.map(one => `<p class="${one.problem ? 'problem' : 'meta'}" data-work-diagnostic="${escape(one.token)}">${escape(one.label)} · ${escape(one.detail)}</p>`).join('') +
99
+ (options.compact ? '' : statusDetailsHtml(card.status)) +
100
+ statusWhyHtml(card.status, card.reasons, card.diagnostics) +
100
101
  (card.notices === null ? '' : `<details class="assignment-notices"><summary>${card.notices.summary}</summary>${card.notices.lines.map(one => `<p class="meta">${escape(one)}</p>`).join('')}</details>`) +
101
102
  assignmentAttemptsHtml(assignment) + `</section>`;
102
103
  }
@@ -1,6 +1,7 @@
1
1
  import { type Store, type ProofVerdictRow, type ProofAcceptanceRow } from "./store.js";
2
2
  import { type WorkAction, type WorkSummaryAccess } from "./work-summary.js";
3
3
  import { type VerifiedApprover } from "./principal.js";
4
+ import { type CheckLevel } from "./check-levels.js";
4
5
  export type AssignmentAccess = WorkSummaryAccess;
5
6
  export type AssignmentOwner = {
6
7
  kind: "coordinator" | "lead";
@@ -13,6 +14,10 @@ export type AssignmentChecks = {
13
14
  command: string | null;
14
15
  logArtifactId: number | null;
15
16
  detail: string;
17
+ /** Which check this is (check-levels.ts): quick, full, or off; null before levels. */
18
+ level?: CheckLevel | null;
19
+ /** A follow-up check waiting or running on this commit. */
20
+ running?: "quick" | "full" | null;
16
21
  };
17
22
  export type AssignmentReceipt = {
18
23
  digest: string;
@@ -70,10 +75,12 @@ export type AssignmentSnapshot = {
70
75
  problem: string | null;
71
76
  }[];
72
77
  };
78
+ /** `lead`: marked complete by the person's lead (`toolroll lead token`), shown as "by the lead". */
73
79
  completion: {
74
80
  actor: string;
75
81
  at: string;
76
82
  digest: string;
83
+ lead?: true;
77
84
  } | null;
78
85
  handoff: {
79
86
  kind: "result" | "decision" | "attention";
@@ -112,6 +119,7 @@ export declare function assignmentBrief(assignment: AssignmentSnapshot | null):
112
119
  actor: string;
113
120
  at: string;
114
121
  digest: string;
122
+ lead?: true;
115
123
  } | null;
116
124
  result: {
117
125
  digest: string;
@@ -189,6 +197,7 @@ export declare function assignmentUpdates(store: Store, now: Date, access: Assig
189
197
  actor: string;
190
198
  at: string;
191
199
  digest: string;
200
+ lead?: true;
192
201
  } | null;
193
202
  result: {
194
203
  digest: string;
@@ -2,6 +2,8 @@
2
2
  * approval or proof state. Lead ownership and exact receipt acknowledgments
3
3
  * are append-only actions; handoffs use the existing durable outbox. */
4
4
  import { createHash } from "node:crypto";
5
+ import { currentActor, withActor } from "./actor.js";
6
+ import { replacedWords } from "./task-status.js";
5
7
  import { COMPLETION_ACTION, familyChangedFiles } from "./result-completion.js";
6
8
  import { homedir } from "node:os";
7
9
  import { readSchemaVersion } from "./store.js";
@@ -12,6 +14,8 @@ import { verificationEvidence } from "./verification-evidence.js";
12
14
  import { reproveApprover } from "./principal.js";
13
15
  import { noteAssignmentStatus } from "./assignment-status.js";
14
16
  import { historicalAssessmentReason } from "./assignment-presentation.js";
17
+ import { runCheckLevel } from "./check-levels.js";
18
+ import { followUpChecksOf, withFollowUps } from "./result-follow-ups.js";
15
19
  /** Status-first handoff for routine reads. Fetch get_assignment only when
16
20
  * inspecting its exact evidence; do not repeat a full proof in every poll. */
17
21
  export function assignmentBrief(assignment) {
@@ -28,6 +32,20 @@ export function assignmentBrief(assignment) {
28
32
  publication: assignment.publication, deployment: assignment.deployment };
29
33
  }
30
34
  const digest = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
35
+ /** A lookup added with lead-quiet, read safely: a deploy proves completion with this code over the INSTALLED runtime's
36
+ * store, before this version's migration, so the method (or its table) may not exist yet. Absent reads as none. */
37
+ function olderStoreSafe(read, absent) {
38
+ try {
39
+ return read();
40
+ }
41
+ catch (error) {
42
+ if (error instanceof TypeError || /no such (table|column)/.test(String(error)))
43
+ return absent;
44
+ throw error;
45
+ }
46
+ }
47
+ /** A check as the receipt digest seals it: presentation-only fields (check-levels.ts) left out. */
48
+ const sealedChecks = ({ level: _level, running: _running, ...proof }) => proof;
31
49
  const OWNER_ACTION = "assignment claimed";
32
50
  const CHECK_ACTION = COMPLETION_ACTION;
33
51
  const actorOf = (owner) => `${owner.kind}:${owner.id}`;
@@ -58,8 +76,24 @@ function ownerOf(store, rootId, repo) {
58
76
  active: credential !== undefined && credential["revoked_at"] === null && repo !== null && Array.isArray(repos) && repos.includes(repo) };
59
77
  }
60
78
  // Report the recorded check outcome, never turn a model verdict into a check.
61
- function nativeChecks(store, root, runId) {
62
- const unavailable = (detail) => ({ status: "unavailable", exitCode: null, command: null, logArtifactId: null, detail });
79
+ // Follow-up checks on the same commit (Run checks) upgrade a pass and show a failure.
80
+ function nativeChecks(store, root, runId, now) {
81
+ const own = buildChecks(store, root, runId);
82
+ const run = store.getRun(runId);
83
+ const followUps = followUpChecksOf(store, runId, now, root);
84
+ if (followUps.length === 0)
85
+ return own;
86
+ const read = withFollowUps({ status: own.status, level: own.level ?? null, exitCode: own.exitCode, head: run?.headRevision ?? null }, followUps);
87
+ const latest = [...followUps].reverse().find(one => one.state === "passed" || one.state === "failed");
88
+ const changed = read.status !== own.status || read.level !== (own.level ?? null) || read.exitCode !== own.exitCode;
89
+ const words = read.level === "quick" ? "Quick checks" : "Checks";
90
+ return { ...own, running: read.running,
91
+ ...(changed ? { status: read.status, level: read.level, exitCode: read.exitCode, logArtifactId: latest?.logArtifactId ?? own.logArtifactId,
92
+ detail: read.status === "passed" ? `${words} passed.` : `${words} failed (exit ${read.exitCode}).` } : {}) };
93
+ }
94
+ function buildChecks(store, root, runId) {
95
+ const level = runCheckLevel(store, runId);
96
+ const unavailable = (detail) => ({ status: "unavailable", exitCode: null, command: null, logArtifactId: null, detail, level });
63
97
  if (root === undefined)
64
98
  return unavailable("Saved checks are unavailable.");
65
99
  try {
@@ -68,11 +102,14 @@ function nativeChecks(store, root, runId) {
68
102
  return unavailable(gate.problem);
69
103
  const receipt = gate.bytes === null ? null : JSON.parse(gate.bytes);
70
104
  if (receipt === null)
71
- return { ...unavailable("No machine check is recorded."), status: "not-run" };
105
+ return { ...unavailable(level === "off" ? "Checks were off for this build." : "No machine check is recorded."), status: "not-run" };
72
106
  const ran = receipt.result.ran === true, exitCode = ran ? receipt.result.exitCode : null;
107
+ // The sealed receipt names the grant that ran: the quick one, or the full one.
108
+ const sealedLevel = typeof receipt.command?.repo === "string" && receipt.command.repo.startsWith("quick:") ? "quick" : "full";
109
+ const words = sealedLevel === "quick" ? "Quick checks" : "Checks";
73
110
  return { status: ran ? exitCode === 0 ? "passed" : "failed" : "not-run", exitCode,
74
- command: receipt.command.command, logArtifactId: receipt.log.artifactId,
75
- detail: ran ? exitCode === 0 ? "Checks passed." : `Checks failed (exit ${exitCode}).` : "Checks did not finish." };
111
+ command: receipt.command.command, logArtifactId: receipt.log.artifactId, level: level === null ? null : sealedLevel,
112
+ detail: ran ? exitCode === 0 ? `${words} passed.` : `${words} failed (exit ${exitCode}).` : `${words} did not finish.` };
76
113
  }
77
114
  catch {
78
115
  return unavailable("Saved checks could not be read.");
@@ -112,7 +149,7 @@ export function assignmentOf(store, taskId, now, access, root) {
112
149
  const reports = artifacts.filter(a => a.kind === "report");
113
150
  const unavailable = family.versions.flatMap(version => store.runsFor(version.refId).flatMap(run => store.artifactsFor(run.id).filter(artifact => root === undefined || !readVerifiedArtifact(root, artifact).ok)
114
151
  .map(artifact => `Saved ${artifact.kind} #${artifact.id} (run ${run.id}) is unavailable or changed.`)));
115
- const checks = result === null ? null : nativeChecks(store, root, result.id);
152
+ const checks = result === null ? null : nativeChecks(store, root, result.id, now);
116
153
  const finishedBuild = result?.role === "builder" && (result.outcome === "built" || result.outcome === "no-change") &&
117
154
  result.finishedAt !== null && /^[a-f0-9]{40}$/.test(result.headRevision ?? "");
118
155
  // Finished work returns to its lead or user. Strict terms and previous model
@@ -133,7 +170,11 @@ export function assignmentOf(store, taskId, now, access, root) {
133
170
  ...artifacts.filter(a => a.kind !== "check-log" && (a.truncated || a.captureStatus === "failed")).map(a => `Saved ${a.kind} #${a.id} is ${a.captureStatus === "failed" ? "a failed capture" : "incomplete"}.`)],
134
171
  agentReport: result.handoff, evidence: "recorded",
135
172
  };
136
- const receipt = receiptBody === null ? null : { ...receiptBody, digest: digest({ receipt: receiptBody,
173
+ // The digest seals the proof, not how it is shown: the check's `level` (read from its sealed command) and `running`
174
+ // (a follow-up in flight) stay out of it, so a result completed before check levels keeps its completion, and a
175
+ // follow-up check that is still running never unseals one.
176
+ const sealedBody = receiptBody === null ? null : { ...receiptBody, checks: sealedChecks(receiptBody.checks) };
177
+ const receipt = receiptBody === null ? null : { ...receiptBody, digest: digest({ receipt: sealedBody,
137
178
  scope: scope === null ? null : { digest: scope.digest, approved: approvalOf(scope).approved, termsProblem: scope.termsProblem ?? null },
138
179
  versions: family.versions.map(v => ({ id: v.id, state: v.state })) }) };
139
180
  let completion = null;
@@ -154,8 +195,10 @@ export function assignmentOf(store, taskId, now, access, root) {
154
195
  state = "needs-decision";
155
196
  else if (current.state === "cancelled") {
156
197
  state = "cancelled";
157
- detail = "This assignment was cancelled.";
158
- primaryAction = { code: "inspect-task", label: "View assignment", target: { taskId: current.id, runId: null, decisionId: null }, access: "read", retry: "read-again" };
198
+ const successor = olderStoreSafe(() => store.replacementOf(current.id) ?? store.replacementOf(family.root.id), null);
199
+ detail = successor === null ? "This assignment was cancelled." : `${replacedWords(successor)}.`;
200
+ primaryAction = successor === null ? { code: "inspect-task", label: "View assignment", target: { taskId: current.id, runId: null, decisionId: null }, access: "read", retry: "read-again" }
201
+ : { code: "inspect-task", label: `Open ${successor}`, target: { taskId: successor, runId: null, decisionId: null }, access: "read", retry: "read-again" };
159
202
  }
160
203
  else if (!live && processProblem !== null && result !== null) {
161
204
  state = "needs-decision";
@@ -182,10 +225,12 @@ export function assignmentOf(store, taskId, now, access, root) {
182
225
  // membership changes and ownership transfer cannot revoke past completion.
183
226
  // The exact result digest still fences changes to work, scope and history.
184
227
  if (checked !== undefined) {
185
- completion = { actor: String(checked["actor"]), at: String(checked["at"]), digest: receipt.digest };
228
+ const rootRef = store.lookupRef(family.root.id);
229
+ const byLead = rootRef !== null && olderStoreSafe(() => store.taskActs(rootRef.id), []).filter(one => one.act === "completed").at(-1)?.lead === true;
230
+ completion = { actor: String(checked["actor"]), at: String(checked["at"]), digest: receipt.digest, ...(byLead ? { lead: true } : {}) };
186
231
  state = "complete";
187
232
  primaryAction = { ...primaryAction, label: completionKind === "research-report" ? "Read report" : "Open result" };
188
- const label = completion.actor.startsWith("operator:") ? completion.actor.slice(9) : owner && completion.actor === actorOf(owner) ? owner.label : "the previous lead";
233
+ const label = byLead ? "the lead" : completion.actor.startsWith("operator:") ? completion.actor.slice(9) : owner && completion.actor === actorOf(owner) ? owner.label : "the previous lead";
189
234
  detail = completionKind === "research-report" ? `Research report checked by ${label}. No deployment is implied.`
190
235
  : `Handled by ${label}. ${receipt.checks.detail} Publication and deployment are separate.`;
191
236
  }
@@ -316,6 +361,9 @@ function acknowledgeCurrent(store, current, receiptDigest, actor, now, root, acc
316
361
  return { ok: false, reason: "approval-rules", message: problem };
317
362
  store.recordAction({ at: now.toISOString(), actor, repo: current.repo,
318
363
  taskId: current.rootId, runId: receipt.runId, action: CHECK_ACTION, outcome: receiptDigest, source: "work" });
364
+ const ref = store.lookupRef(current.rootId);
365
+ if (ref !== null)
366
+ store.noteTaskAct(ref.id, "completed", now);
319
367
  store.bumpWake();
320
368
  }
321
369
  const assignment = assignmentOf(store, current.rootId, now, access, root);
@@ -335,7 +383,8 @@ export function checkAssignment(store, taskId, receiptDigest, owner, now, root =
335
383
  /** The signed-in user can mark the same exact receipt handled without taking
336
384
  * lead ownership. This records review, never check success or new authority. */
337
385
  export function checkAssignmentAsOperator(store, taskId, receiptDigest, who, now, root = evidenceRoot(homedir())) {
338
- return store.transact(() => {
386
+ // Completing is this person's act (or their lead's): it never pings them.
387
+ return withActor(currentActor() ?? { account: who.name, lead: false }, () => store.transact(() => {
339
388
  if (!reproveApprover(store, who).ok)
340
389
  return { ok: false, reason: "unauthenticated", message: "Sign in again before marking this result complete." };
341
390
  const access = { principal: "operator", repos: who.repos };
@@ -343,7 +392,7 @@ export function checkAssignmentAsOperator(store, taskId, receiptDigest, who, now
343
392
  if (current === null || !store.accountCanAccess(who.name, current.repo))
344
393
  return { ok: false, reason: "not-found", message: "No assignment is available in your projects." };
345
394
  return acknowledgeCurrent(store, current, receiptDigest, `operator:${who.name}`, now, root, access);
346
- });
395
+ }));
347
396
  }
348
397
  function noteAssignmentHandoff(store, assignment, now) {
349
398
  if (!assignment.owner?.active || assignment.handoff === null || assignment.handoff.acknowledged)