toolroll 0.9.12 → 0.9.14

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.
@@ -19,13 +19,14 @@ export function browserWorkActionHref(item) {
19
19
  if (action.code === 'open-pr' && /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/pull\/\d+$/.test(item.publicationUrl ?? ''))
20
20
  return item.publicationUrl;
21
21
  if (runId !== null && (action.code === 'open-pr' || action.code === 'retry-review')) {
22
- return `/review?result=${encodeURIComponent(taskId)}&run=${runId}${item.repo === null ? '' : '&project=' + encodeURIComponent(item.repo)}`;
22
+ return `/review?result=${encodeURIComponent(taskId)}&run=${runId}`;
23
23
  }
24
24
  if (runId !== null && (action.code === 'inspect-run' || action.code === 'reconcile-run'))
25
25
  return `/r/${runId}`;
26
26
  const anchor = action.code === 'approve-scope' ? '#approve'
27
27
  : action.code === 'inspect-stop' || action.code === 'resume-run' ? '#task-control'
28
- : action.code === 'unhold' || action.code === 'retry-task' ? '#task-actions'
28
+ // Retry (and Build again) is the task's status card itself, at the top: no anchor to its options.
29
+ : action.code === 'unhold' ? '#task-actions'
29
30
  : action.code === 'write-scope' || action.code === 'select-agent' ? '#scope'
30
31
  : action.code === 'inspect-hold' ? '#holds'
31
32
  : action.code === 'start-worker' || action.code === 'repair-dependency' ? '#run-status' : '';
@@ -11,6 +11,7 @@ import type { Ask } from './needs-you.js';
11
11
  import type { WorkIndexGroup } from './work-index.js';
12
12
  import type { AssignmentCard } from './assignment-ui.js';
13
13
  import type { TaskStatus } from './task-status.js';
14
+ import type { ResultActs } from './result-acts.js';
14
15
  import type { FirstRunStep, FirstTaskSuggestion, JourneyStep } from './first-run.js';
15
16
  export type BrowserProject = {
16
17
  name: string;
@@ -366,6 +367,18 @@ export type BrowserTaskView = {
366
367
  rebuild?: {
367
368
  action: string;
368
369
  } | null;
370
+ /** A failed task's recorded reason in one line (the run's reason, the failing check's last error line, or that none was
371
+ * recorded) and where its log lines are; and Retry itself, with an optional note. */
372
+ failure?: {
373
+ line: string;
374
+ link: {
375
+ label: string;
376
+ href: string;
377
+ } | null;
378
+ } | null;
379
+ retry?: {
380
+ action: string;
381
+ } | null;
369
382
  /** Server cards that may need a person now (stop/resume, scope prompt, plan, live attempt). */
370
383
  lead: {
371
384
  key: string;
@@ -586,6 +599,39 @@ export type BrowserResultView = {
586
599
  problem: boolean;
587
600
  logHref: string | null;
588
601
  } | null;
602
+ /** A refuted result's recorded disagreements: the headline when the report doesn't match the changes (null when a
603
+ * failed check is the blocker, so the status keeps its own), each disagreement in plain words with the changed lines
604
+ * it concerns (`absent`: a file the changes don't have), and the recorded words they already say, so no caveat
605
+ * repeats them. Null otherwise. */
606
+ mismatch: {
607
+ headline: string | null;
608
+ rows: {
609
+ text: string;
610
+ path: string | null;
611
+ lines: string | null;
612
+ href: string | null;
613
+ absent: boolean;
614
+ noteLabel: string | null;
615
+ }[];
616
+ said: string[];
617
+ } | null;
618
+ /** The one ink act that resolves the result, the one outline act beside it, and why it can't be accepted yet (result-acts.ts). */
619
+ acts: ResultActs;
620
+ /** Run checks on this result's commit: the project's check, when it didn't run. */
621
+ runChecks: {
622
+ action: string;
623
+ level: "quick" | "full";
624
+ returnTo: string;
625
+ } | null;
626
+ /** The raw run record, under Details: its facts and the full record. */
627
+ record: {
628
+ build: number;
629
+ href: string;
630
+ facts: {
631
+ label: string;
632
+ value: string;
633
+ }[];
634
+ } | null;
589
635
  /** The signed scope; null when none was filed. */
590
636
  intent: {
591
637
  approval: string;
package/dist/dispatch.js CHANGED
@@ -13,6 +13,7 @@ import { plannerSourceProblemOf } from "./planner-source.js";
13
13
  import { authPauseOf, authWaitOf, providerName, signInReason } from "./provider-auth.js";
14
14
  import { REVIEW_TOKENS, resultStatusOf, reviewFactsOf } from "./workspace-ui.js";
15
15
  import { replacedWords } from "./task-status.js";
16
+ import { failedAttemptSentence, latestFinishedAttempt } from "./needs-you.js";
16
17
  import { buildReviewOf } from "./review-switch.js";
17
18
  import { BUILT_IN, parseCapabilityKey } from "./store.js";
18
19
  export const DEFAULT_MAX_OPEN_DECISIONS = 5;
@@ -218,8 +219,12 @@ export function diagnoseTaskDispatch(store, taskId, now) {
218
219
  return successor === null ? answer("cancelled", "terminal", "Cancelled", "Nothing else will run for this task.")
219
220
  : answer("cancelled", "terminal", replacedWords(successor), `${replacedWords(successor)}. Nothing else will run for this task.`);
220
221
  }
221
- if (task.state === "failed")
222
- return answer("failed", "terminal", "Needs a retry", "The last attempt stopped; review its incident, then retry it.", { action: "retry-task" });
222
+ if (task.state === "failed") {
223
+ // The family's latest finished attempt's own recorded reason, or that none was recorded: never "review its incident".
224
+ const family = store.taskFamilyOf(taskId, ref.repo === null ? [] : [ref.repo], ref.repo === null);
225
+ const runs = family === null || family.problem !== null ? store.runsFor(ref.id) : family.versions.flatMap(version => store.runsFor(version.refId));
226
+ return answer("failed", "terminal", "Needs a retry", failedAttemptSentence(latestFinishedAttempt(runs)?.reason), { action: "retry-task" });
227
+ }
223
228
  if (store.hasLiveClaim(ref.id, now))
224
229
  return answer("running", "running", "Running now", "A worker owns the current live claim.");
225
230
  if (task.state === "running")
@@ -59,4 +59,28 @@ export declare function processNeedOf(fact: {
59
59
  } | null;
60
60
  /** Dispatch and work-index codes that wait on a person, as their need. */
61
61
  export declare const CODE_NEED: Readonly<Record<string, NeedKey>>;
62
+ /** A stopped run's recorded reason code, in words (the run record, the thread, a failed task's status). */
63
+ export declare const RUN_REASON_WORDS: Readonly<Record<string, string>>;
64
+ /** A run's recorded reason in words. A recorded code is never shown as it is
65
+ * stored: one with no words of its own reads as an unexpected stop; a reason
66
+ * already written as words (it has a space) is said as written. */
67
+ export declare function runReasonWords(reason: string): string;
68
+ /** The latest finished build attempt among a task's runs (any order), whatever its outcome: planning and reviewing
69
+ * are not attempts, and an older failure never stands in for a newer attempt. */
70
+ export declare function latestFinishedAttempt<T extends {
71
+ id: number;
72
+ role: string;
73
+ finishedAt: string | null;
74
+ outcome?: string | null;
75
+ }>(runs: readonly T[]): T | null;
76
+ export declare const NO_REASON_RECORDED = "No reason was recorded for this attempt.";
77
+ export declare const INTERNAL_ERROR = "The attempt stopped with an internal error.";
78
+ /** About as long as one plain line reads. */
79
+ export declare const REASON_LINE_LIMIT = 140;
80
+ /** A recorded reason that is machine output, not words: a stack trace, a file path or "Error:" text. Its detail
81
+ * belongs behind a link, never in the line itself. */
82
+ export declare function isInternalErrorReason(reason: string | null | undefined): boolean;
83
+ /** A failed attempt in one plain line: its recorded reason in words (first line only, about 140 characters at most),
84
+ * that it stopped with an internal error when the reason is machine output, or that none was recorded. */
85
+ export declare function failedAttemptSentence(reason: string | null | undefined): string;
62
86
  export {};
package/dist/needs-you.js CHANGED
@@ -90,3 +90,103 @@ export const CODE_NEED = {
90
90
  "no-build-record": "review-result", "verification-needed": "review-result", "evidence-mismatch": "review-result",
91
91
  "record-incomplete": "review-result", "evidence-damaged": "review-result",
92
92
  };
93
+ /** A stopped run's recorded reason code, in words (the run record, the thread, a failed task's status). */
94
+ export const RUN_REASON_WORDS = {
95
+ agent: "the agent failed",
96
+ "agent-reported": "the agent reported it could not finish",
97
+ "no-op": "nothing changed when something should have",
98
+ "no-handoff": "the agent stopped before handing off; its work was kept and it is being resumed",
99
+ "moved-head": "the branch moved underneath the build",
100
+ "moved-branch": "the branch moved underneath the build",
101
+ timeout: "ran out of time",
102
+ git: "a git step failed",
103
+ "malformed-decision": "the agent's question was malformed",
104
+ "malformed-plan": "the plan was malformed",
105
+ fenced: "another worker took the task over",
106
+ unapproved: "the scope was not approved",
107
+ "scope-changed": "the scope changed after approval",
108
+ capability: "a requirement was missing",
109
+ setup: "the workspace preparation step failed",
110
+ "provider-init": "the agent could not start",
111
+ "commit-failure": "the commit failed",
112
+ "protected-branch": "refused to touch a protected branch",
113
+ "wrong-branch": "the checkout was on the wrong branch",
114
+ "not-leased": "the lease was not valid",
115
+ "no-claim": "the lease was not valid",
116
+ "not-yours": "the lease was not valid",
117
+ "no-run-record": "the run record was missing",
118
+ "missing-mailbox": "the resume mailbox could not be read",
119
+ "unreadable-mailbox": "the resume mailbox could not be read",
120
+ "revision-brief": "the revision brief could not be read",
121
+ "repaired-park": "resumed from a parked question",
122
+ stopped: "stopped by the operator",
123
+ acceptance: "the result didn't meet its signed requirements",
124
+ evidence: "its evidence could not be saved",
125
+ interrupted: "the attempt was interrupted",
126
+ orphaned: "the attempt lost its worker",
127
+ "retryable-infra": "a temporary problem on the machine stopped it",
128
+ unknown: "the attempt stopped unexpectedly",
129
+ "spawn-failed": "the agent process could not start",
130
+ "provider-protocol": "the agent's reply could not be read",
131
+ "auth-expired": "the agent's sign-in expired",
132
+ "budget-unenforceable": "its spending limit could not be enforced",
133
+ "stale-source": "the request changed while the plan was being made",
134
+ "stale-approval": "the approval no longer matched the scope",
135
+ "source-invalid": "the plan's source could not be recorded",
136
+ "external-closed": "the linked issue was closed while it was being built",
137
+ "repair-admission": "the repair could not start",
138
+ "reviewer-error": "the review failed",
139
+ "malformed-report": "the scout's report was malformed",
140
+ "attempts-exhausted": "it failed too many times in a row",
141
+ };
142
+ /** A run's recorded reason in words. A recorded code is never shown as it is
143
+ * stored: one with no words of its own reads as an unexpected stop; a reason
144
+ * already written as words (it has a space) is said as written. */
145
+ export function runReasonWords(reason) {
146
+ const known = RUN_REASON_WORDS[reason];
147
+ if (known !== undefined)
148
+ return known;
149
+ if (reason.startsWith("decision:"))
150
+ return "the agent asked a question";
151
+ return /\s/.test(reason.trim()) ? reason.trim() : "the attempt stopped unexpectedly";
152
+ }
153
+ /** The latest finished build attempt among a task's runs (any order), whatever its outcome: planning and reviewing
154
+ * are not attempts, and an older failure never stands in for a newer attempt. */
155
+ export function latestFinishedAttempt(runs) {
156
+ let latest = null;
157
+ // Ended means a finish time or an outcome: a reconcile can mark a run failed without stamping finished_at.
158
+ for (const run of runs)
159
+ if ((run.finishedAt !== null || (run.outcome ?? null) !== null) && run.role !== "planner" && run.role !== "reviewer" && (latest === null || run.id > latest.id))
160
+ latest = run;
161
+ return latest;
162
+ }
163
+ export const NO_REASON_RECORDED = "No reason was recorded for this attempt.";
164
+ export const INTERNAL_ERROR = "The attempt stopped with an internal error.";
165
+ /** About as long as one plain line reads. */
166
+ export const REASON_LINE_LIMIT = 140;
167
+ /** A recorded reason that is machine output, not words: a stack trace, a file path or "Error:" text. Its detail
168
+ * belongs behind a link, never in the line itself. */
169
+ export function isInternalErrorReason(reason) {
170
+ const text = reason?.trim() ?? "";
171
+ if (text === "" || RUN_REASON_WORDS[text] !== undefined)
172
+ return false;
173
+ return /\b[A-Za-z]*(?:Error|Exception):/.test(text) // Error:, TypeError:, SomeException:
174
+ || /^\s*at\s+\S+/m.test(text) && /\n/.test(text) // a stack frame below the first line
175
+ // An absolute path of two or more segments (/Users/…, ~/x/y): plain sentences may name a project file (src/ledger.ts).
176
+ || /(?:^|[\s(“"'])~?\/(?:[\w.@-]+\/)+[\w.@-]+/.test(text)
177
+ || /[A-Za-z]:\\/.test(text) // C:\…
178
+ || /\.[cm]?[jt]sx?:\d+/.test(text); // ledger.ts:14
179
+ }
180
+ /** A failed attempt in one plain line: its recorded reason in words (first line only, about 140 characters at most),
181
+ * that it stopped with an internal error when the reason is machine output, or that none was recorded. */
182
+ export function failedAttemptSentence(reason) {
183
+ const code = reason?.trim() ?? "";
184
+ if (code === "")
185
+ return NO_REASON_RECORDED;
186
+ if (isInternalErrorReason(code))
187
+ return INTERNAL_ERROR;
188
+ const words = runReasonWords(code.split(/\r?\n/, 1)[0].trim()).replace(/\s+/g, " ").replace(/[.\s]+$/, "");
189
+ const line = words.length <= REASON_LINE_LIMIT ? words
190
+ : `${words.slice(0, REASON_LINE_LIMIT - 1).replace(/\s+\S*$/, "").replace(/[,;:\s]+$/, "")}…`;
191
+ return `${line.charAt(0).toUpperCase()}${line.slice(1)}${line.endsWith("…") ? "" : "."}`;
192
+ }
@@ -0,0 +1,46 @@
1
+ /** The result page's acts (2026-10-02), chosen in one place: exactly one ink
2
+ * act, the one that resolves the result's state, and never navigation; at
3
+ * most one outline act beside it. A result that can't be accepted says why
4
+ * in one line before them.
5
+ *
6
+ * everything met → Accept
7
+ * checks queued or running → Checks running (disabled), then Accept without checks
8
+ * checks didn't run, a check exists → Run checks, then Accept without checks
9
+ * report and changes disagree,
10
+ * or the proof is refuted → Request changes, then Accept (as allowed);
11
+ * never Accept as ink, even with no form here
12
+ * a Needs you form resolves it → that form (Build again, Confirm it stopped)
13
+ * saved notes or a failing PR → Revise / Draft a repair task
14
+ *
15
+ * Accepting means what it always meant: the same forms post to the same
16
+ * endpoints. This only decides which one is ink. */
17
+ /** The words over the reason field an Accept of a refuted result requires. */
18
+ export declare const ACCEPT_NEEDS_REASON = "Accepting needs a reason";
19
+ export type ResultActKind = "accept" | "checks-running" | "run-checks" | "request-changes" | "rebuild" | "confirm-stopped" | "revise" | "draft-repair";
20
+ export type ResultActFacts = {
21
+ /** An Accept this page can post (Mark complete, or the person's acceptance); `ready`: every requirement met and checks passed. */
22
+ accept: {
23
+ ready: boolean;
24
+ } | null;
25
+ /** The checks didn't run, the project has one, and this person may run it. */
26
+ runChecks: boolean;
27
+ /** Checks on this result are queued or running. */
28
+ checksRunning: boolean;
29
+ /** Why it can't be accepted yet (cantAcceptYetOf), or null. */
30
+ blocked: string | null;
31
+ /** The feedback form is on this page. */
32
+ canRequest: boolean;
33
+ /** A Needs you form that resolves it here. */
34
+ need: "rebuild" | "confirm-stopped" | null;
35
+ /** Saved notes ready to become a revision, or a failing pull request's repair. */
36
+ next: "revise" | "draft-repair" | null;
37
+ };
38
+ export type ResultActs = {
39
+ /** The one ink act, or null when nothing waits on the person. */
40
+ primary: ResultActKind | null;
41
+ /** The one outline act beside it. */
42
+ secondary: ResultActKind | null;
43
+ /** The one line before the acts: why it can't be accepted yet. */
44
+ line: string | null;
45
+ };
46
+ export declare function resultActsOf(facts: ResultActFacts): ResultActs;
@@ -0,0 +1,38 @@
1
+ /** The result page's acts (2026-10-02), chosen in one place: exactly one ink
2
+ * act, the one that resolves the result's state, and never navigation; at
3
+ * most one outline act beside it. A result that can't be accepted says why
4
+ * in one line before them.
5
+ *
6
+ * everything met → Accept
7
+ * checks queued or running → Checks running (disabled), then Accept without checks
8
+ * checks didn't run, a check exists → Run checks, then Accept without checks
9
+ * report and changes disagree,
10
+ * or the proof is refuted → Request changes, then Accept (as allowed);
11
+ * never Accept as ink, even with no form here
12
+ * a Needs you form resolves it → that form (Build again, Confirm it stopped)
13
+ * saved notes or a failing PR → Revise / Draft a repair task
14
+ *
15
+ * Accepting means what it always meant: the same forms post to the same
16
+ * endpoints. This only decides which one is ink. */
17
+ /** The words over the reason field an Accept of a refuted result requires. */
18
+ export const ACCEPT_NEEDS_REASON = "Accepting needs a reason";
19
+ export function resultActsOf(facts) {
20
+ const request = facts.canRequest ? "request-changes" : null;
21
+ const accept = facts.accept === null ? null : "accept";
22
+ if (facts.need !== null)
23
+ return { primary: facts.need, secondary: request, line: null };
24
+ if (facts.next !== null)
25
+ return { primary: facts.next, secondary: accept ?? request, line: null };
26
+ // Can't be accepted yet: Accept is never the ink act, even when the feedback form isn't here.
27
+ if (facts.blocked !== null)
28
+ return { primary: request, secondary: accept, line: facts.blocked };
29
+ if (facts.checksRunning)
30
+ return { primary: "checks-running", secondary: accept ?? request, line: null };
31
+ if (facts.accept === null)
32
+ return { primary: null, secondary: request, line: null };
33
+ if (facts.accept.ready)
34
+ return { primary: "accept", secondary: request, line: null };
35
+ if (facts.runChecks)
36
+ return { primary: "run-checks", secondary: "accept", line: null };
37
+ return { primary: "accept", secondary: request, line: null };
38
+ }
@@ -153,7 +153,7 @@ export function resultFactsFromHtml(html) {
153
153
  * lands on the run page. Matched exactly — never a bare open redirect. */
154
154
  export function resultReturnTarget(raw, runId) {
155
155
  if (raw !== null && raw !== undefined) {
156
- const review = /^\/review\?result=[A-Za-z0-9._~%-]{1,200}(?:&run=([0-9]{1,15}))?$/.exec(raw);
156
+ const review = /^\/review\?result=[A-Za-z0-9._~%-]{1,200}(?:&run=([0-9]{1,15}))?(?:&tab=(?:summary|changes|checks))?$/.exec(raw);
157
157
  if (review !== null && (review[1] === undefined || Number(review[1]) === runId))
158
158
  return raw;
159
159
  const chat = /^\/chat\?task=([A-Za-z0-9._~%-]{1,200})&result=([0-9]{1,15})(?:&conversation=[A-Za-z0-9-]{1,128})?$/.exec(raw);
package/dist/serve.d.ts CHANGED
@@ -558,6 +558,7 @@ export declare function needActionOf(assignment: AssignmentSnapshot | null, stat
558
558
  run: number;
559
559
  action: string;
560
560
  rebuildable?: boolean;
561
+ refuted?: boolean;
561
562
  }): BrowserNeedAction | null;
562
563
  /** The facts region alone, for the open-run poll (A4). A finished run's
563
564
  * fragment says so instead of quietly growing forms (finding 5), and a run