toolroll 0.9.13 → 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.
@@ -86,10 +86,12 @@ export type TaskStatusFacts = {
86
86
  report?: boolean;
87
87
  checks?: ChecksFact | null;
88
88
  pullRequest?: PullRequestFact | null;
89
+ /** `unverified`: the report is refuted, so no requirement counts as met however it was marked. */
89
90
  requirements?: {
90
91
  met: number;
91
92
  total: number;
92
93
  yours: number;
94
+ unverified?: boolean;
93
95
  } | null;
94
96
  evidence?: {
95
97
  shortened: number;
@@ -159,6 +161,8 @@ export declare function requirementsOf(matrix: readonly {
159
161
  } | undefined;
160
162
  review?: unknown;
161
163
  }[] | null | undefined): NonNullable<TaskStatusFacts["requirements"]> | null;
164
+ /** A refuted report verifies none of its requirements, whatever it marked met (a person's acceptance doesn't change that). */
165
+ export declare function unverifiedWhenRefuted(requirements: NonNullable<TaskStatusFacts["requirements"]> | null, verdict: string | null | undefined): NonNullable<TaskStatusFacts["requirements"]> | null;
162
166
  /** Saved evidence health from the receipt's artifact list and its caveats. */
163
167
  export declare function evidenceOf(receipt: AssignmentSnapshot["receipt"], problems?: readonly string[]): NonNullable<TaskStatusFacts["evidence"]> | null;
164
168
  /** The status facts of one assignment, as every surface reads them. */
@@ -114,7 +114,9 @@ function detailsOf(headline, facts) {
114
114
  row("pull-request", "Pull request", `${name} open`, "none", { href: github });
115
115
  }
116
116
  const req = facts.requirements;
117
- if (req != null && req.total > 0) {
117
+ if (req != null && req.total > 0 && req.unverified === true)
118
+ row("requirements", "Requirements", "Unverified", "none");
119
+ else if (req != null && req.total > 0) {
118
120
  const unmet = req.total - req.met - req.yours;
119
121
  const text = `${req.met} of ${req.total} met${req.yours > 0 ? ` · You check ${req.yours}` : ""}`;
120
122
  row("requirements", "Requirements", text, unmet > 0 ? problem(headline) : req.met === req.total ? "ok" : "none", unmet > 0 && headline !== "Failed" ? { action: { label: "See which", href: facts.links?.checks ?? facts.links?.result ?? null } } : {});
@@ -264,6 +266,10 @@ export function requirementsOf(matrix) {
264
266
  const met = (row) => row.state === "pass" || (row.assessment?.evidenceState === "pass" && (row.review ?? null) === null);
265
267
  return { met: matrix.filter(met).length, total: matrix.length, yours: matrix.filter(row => !met(row) && row.state === "manual-review").length };
266
268
  }
269
+ /** A refuted report verifies none of its requirements, whatever it marked met (a person's acceptance doesn't change that). */
270
+ export function unverifiedWhenRefuted(requirements, verdict) {
271
+ return requirements === null || verdict !== "refuted" ? requirements : { ...requirements, unverified: true };
272
+ }
267
273
  /** Saved evidence health from the receipt's artifact list and its caveats. */
268
274
  export function evidenceOf(receipt, problems = receipt?.caveats ?? []) {
269
275
  if (receipt === null)
@@ -297,7 +303,7 @@ export function assignmentStatusFacts(assignment, options = {}) {
297
303
  stage, ...(need === undefined ? {} : { need }), ...(wait === undefined ? {} : { wait }), ...(build === null ? {} : { needContext: { build } }), reason, report,
298
304
  checks,
299
305
  pullRequest: withResult ? pullRequest : null,
300
- requirements: withResult ? requirementsOf(receipt?.proof?.matrix) : null,
306
+ requirements: withResult ? unverifiedWhenRefuted(requirementsOf(receipt?.proof?.matrix), receipt?.proof?.verdict) : null,
301
307
  evidence: withResult ? options.evidence ?? evidenceOf(receipt, [...(receipt?.caveats ?? []), ...assignment.attention]) : null,
302
308
  completedBy: assignment.completion === null ? null : assignment.completion.lead === true ? "the lead" : assignment.completion.actor.replace(/^(?:operator|coordinator|lead):/, ""),
303
309
  action: options.action ?? null,
@@ -7,7 +7,7 @@
7
7
  import { assignmentOf } from "./assignment.js";
8
8
  import { chatResultHref, chatControlHref } from "./chat-controls.js";
9
9
  import { phoneText, projectLabel } from "./telegram-status.js";
10
- import { assignmentStatusFacts, headlineEmoji, pullRequestFactOf, requirementsOf, statusDetailLines, taskStatusOf } from "./task-status.js";
10
+ import { assignmentStatusFacts, headlineEmoji, pullRequestFactOf, requirementsOf, statusDetailLines, unverifiedWhenRefuted, taskStatusOf } from "./task-status.js";
11
11
  import { leadClaimOf } from "./lead-voice.js";
12
12
  import { chatTitle } from "./chat-voice.js";
13
13
  import { manualReviewOnly } from "./proof.js";
@@ -49,7 +49,7 @@ export function telegramProgressCard(store, run, taskId, project, now = new Date
49
49
  : exit !== null ? { status: "failed", exitCode: exit, head: run.headRevision }
50
50
  : machine === "verified" ? { status: "passed", exitCode: null, head: run.headRevision }
51
51
  : machine === "attested" ? { status: "not-run", exitCode: null, head: null } : null;
52
- facts = { stage: "finished", report: run.role === "scout", checks, requirements: requirementsOf(proof?.matrix),
52
+ facts = { stage: "finished", report: run.role === "scout", checks, requirements: unverifiedWhenRefuted(requirementsOf(proof?.matrix), proof?.verdict),
53
53
  ...(pullRequest === undefined ? {} : { pullRequest }) };
54
54
  }
55
55
  else if (accessBlocked) {
@@ -11,7 +11,7 @@ import { plainReasonOf, replacedWords, stageOfCode, taskStatusOf, workToneOf } f
11
11
  import { isCheckLevel } from './check-levels.js';
12
12
  import { MARKER } from './worktree.js';
13
13
  import { withFollowUps } from './result-follow-ups.js';
14
- import { ASKS, NEED_ASK, NEEDS, processNeedOf } from './needs-you.js';
14
+ import { ASKS, NEED_ASK, NEEDS, failedAttemptSentence, processNeedOf } from './needs-you.js';
15
15
  import { LEAD_CLAIM_SQL, LEAD_IDLE_MS } from './lead-voice.js';
16
16
  export const WORK_INDEX_PAGE_LIMIT = 40;
17
17
  export const WORK_INDEX_MAX_LIMIT = 100;
@@ -369,7 +369,7 @@ export function workIndexPage(store, now, access, options = {}) {
369
369
  'running_count',running_count,'completed_count',completed_count,'queued',queued,'done_recently',done_recently)) FROM project_totals) row_json FROM totals
370
370
  UNION ALL SELECT 1,0,0,0,0,0,0,0,0,0,json_object('root_ref',root_ref,'root_id',root_id,'id',id,'root_title',root_title,'repo',repo,
371
371
  'state',state,'created_at',created_at,'family_updated',family_updated,'version_count',version_count,'earlier_active',earlier_active,
372
- 'earlier_id',earlier_id,'broken',broken,'live_run',live_run,'result_id',page_result_id,'result_outcome',(SELECT outcome FROM run WHERE id=page_result_id),
372
+ 'earlier_id',earlier_id,'broken',broken,'live_run',live_run,'result_id',page_result_id,'result_outcome',(SELECT outcome FROM run WHERE id=page_result_id),'attempt_reason',(SELECT substr(reason,1,1000) FROM run WHERE task_ref IN (SELECT ref_id FROM members WHERE root_ref=page.root_ref) AND (finished_at IS NOT NULL OR outcome IS NOT NULL) AND role NOT IN ('planner','reviewer') ORDER BY id DESC LIMIT 1),
373
373
  'publication_url',(SELECT pr_url FROM publication WHERE run=page_result_id),
374
374
  'result_role',(SELECT role FROM run WHERE id=page_result_id),'live_role',(SELECT role FROM run WHERE id=page.live_run),
375
375
  'check_status',(SELECT status FROM run_check WHERE run=page_result_id),
@@ -462,7 +462,7 @@ function itemOf(row, principal, probe) {
462
462
  'decision-queue': ['Needs your decision', 'The decision queue is full.', 'inspect-decisions', 'Review open questions'],
463
463
  'result-needs-attention': ['Needs your decision', 'Inspect the saved result and resolve its remaining execution or scope issue.', 'inspect-run', 'Inspect run'],
464
464
  'process-needs-attention': ['Needs your decision', 'A process exit is not recorded. Open the attempt to check whether its work has stopped.', 'inspect-run', 'Inspect run'],
465
- failed: ['Needs your decision', 'The last attempt stopped; review its incident before retrying.', 'retry-task', 'Review and retry'],
465
+ failed: ['Needs your decision', 'The last attempt stopped.', 'retry-task', 'Review and retry'],
466
466
  'vanished-run': ['Needs your decision', 'An unfinished attempt has no current live claim.', 'reconcile-run', 'Check the unfinished attempt'],
467
467
  stopping: ['Needs your decision', 'Stopping. The work so far is kept.', 'inspect-stop', 'View stop details'],
468
468
  stopped: ['Needs your decision', 'Paused by a person. The work so far is kept.', 'resume-run', 'Review pause'],
@@ -502,6 +502,9 @@ function itemOf(row, principal, probe) {
502
502
  }
503
503
  if (code === 'terminal-dependency' && s(row, 'dependency_id') !== null)
504
504
  detail = `${s(row, 'dependency_id')} ${s(row, 'dependency_state') === 'cancelled' ? 'was cancelled' : 'failed'} before it finished.`;
505
+ // A failed task says the latest finished attempt across its whole family, whatever its outcome: its recorded reason, or that none was recorded.
506
+ if (code === 'failed')
507
+ detail = failedAttemptSentence(s(row, 'attempt_reason'));
505
508
  if (code === 'result-needs-attention' && n(row, 'custody_unresolved'))
506
509
  detail = 'A process exit is not recorded. Open the result to check whether its work has stopped.';
507
510
  if (code === 'result-needs-attention' && ['built', 'no-change'].includes(s(row, 'result_outcome') ?? '')) {
@@ -17,6 +17,8 @@
17
17
  * claimed without one.
18
18
  */
19
19
  import type { DispatchDiagnosis } from "./dispatch.js";
20
+ import { ACCEPT_NEEDS_REASON } from "./result-acts.js";
21
+ export { ACCEPT_NEEDS_REASON };
20
22
  import { type ProofVerdict } from "./proof.js";
21
23
  import type { ReviewRetryState, TaskState } from "./store.js";
22
24
  import type { TaskControlView } from "./task-control.js";
@@ -194,6 +196,48 @@ export declare function dispatchActionLabel(dispatch: DispatchDiagnosis | null):
194
196
  * when nothing refutes it (a person's acceptance settles it). The result page
195
197
  * shows it before Request changes, its one ink act. */
196
198
  export declare function cantAcceptYetOf(verdict: ProofVerdict | null, reasons: readonly string[], accepted: boolean): string | null;
199
+ /** The result's headline while its report disagrees with its saved changes. */
200
+ export declare const MISMATCH_HEADLINE = "The report doesn't match the changes";
201
+ /** One way the report disagrees with the saved changes, in plain words: the
202
+ * file and lines it concerns when they are in the saved changes, or
203
+ * `inChanges: false` for a file the report names that the changes don't have.
204
+ * `reason` is the recorded words it says plainly. */
205
+ export type ReportMismatch = {
206
+ text: string;
207
+ path: string | null;
208
+ lines: {
209
+ from: number;
210
+ to: number;
211
+ } | null;
212
+ inChanges: boolean | null;
213
+ /** The report's own note (1-based) the disagreement is about, when it names one. */
214
+ note: number | null;
215
+ reason: string;
216
+ };
217
+ /** Each recorded way a refuted report disagrees with its saved changes, said
218
+ * plainly and tied to the changed lines it concerns. `changes` maps each
219
+ * changed file to the lines of its first change (null when it has no line
220
+ * changes, a binary file say). Presentation only: the verdict is unchanged. */
221
+ export declare function reportMismatchesOf(reasons: readonly string[], criteria: readonly {
222
+ id: string;
223
+ statement: string;
224
+ answered: readonly {
225
+ kind: string;
226
+ ref: string;
227
+ }[];
228
+ state?: string;
229
+ detail?: readonly string[];
230
+ }[], changes: ReadonlyMap<string, {
231
+ from: number;
232
+ to: number;
233
+ } | null>): ReportMismatch[];
234
+ /** The line a failing check ended on: the last line of its error output that
235
+ * names an error, else the last line it printed, with its 1-based number in
236
+ * the saved log. Null when the log printed nothing. */
237
+ export declare function lastErrorLineOf(log: string): {
238
+ line: number;
239
+ text: string;
240
+ } | null;
197
241
  /** The result page's own sentence when the decision is on it. */
198
242
  export declare const RESULT_DECISION_SENTENCE = "Review the change, then accept it or ask for changes.";
199
243
  /** What the result page's Accept says, from facts it already shows: the
@@ -16,7 +16,9 @@
16
16
  * observed" name observed publication records, and deployment is never
17
17
  * claimed without one.
18
18
  */
19
- import { GOAL_ASSESSMENT_PENDING, manualReviewOnly } from "./proof.js";
19
+ import { ACCEPT_NEEDS_REASON } from "./result-acts.js";
20
+ export { ACCEPT_NEEDS_REASON };
21
+ import { GOAL_ASSESSMENT_PENDING, manualReviewOnly, plainReasonWords } from "./proof.js";
20
22
  import { plainReasonOf, stageOfCode, taskStatusOf, workToneOf } from "./task-status.js";
21
23
  export const WORK_VIEWS = [
22
24
  { key: "all", label: "All", hint: "Every task in view, most urgent first.", empty: "Nothing is in progress. Describe work in chat or add a task, and it appears here." },
@@ -318,9 +320,89 @@ const VERIFIED_RESULT = new Set(["ready-to-review", "pr-opened", "merge-observed
318
320
  export function cantAcceptYetOf(verdict, reasons, accepted) {
319
321
  if (accepted || verdict !== "refuted")
320
322
  return null;
323
+ // A mismatch is the status card's own headline; here only what accepting takes.
321
324
  return evidenceProblemOf(verdict, reasons) === "checks-failed"
322
325
  ? "Can't accept yet: the project's check failed on these changes."
323
- : "Can't accept yet: what the agent reported doesn't match the changes it saved.";
326
+ : ACCEPT_NEEDS_REASON;
327
+ }
328
+ /** The result's headline while its report disagrees with its saved changes. */
329
+ export const MISMATCH_HEADLINE = "The report doesn't match the changes";
330
+ const OVERCLAIMED = /^claimed changed paths? not in the sealed diff: ([\s\S]+)$/;
331
+ const RESTATED = /^criterion "([^"]+)" was signed as "[\s\S]*" and the proof restates it as "([\s\S]*)"$/;
332
+ const CONTRADICTED_BY_CAVEAT = /^criterion "([^"]+)" is marked met, but caveat [0-9]+ admits an exception to it: ([\s\S]*)$/;
333
+ const NOTE_NUMBER = /^caveat ([0-9]+) /;
334
+ const CONTRADICTED_BY_REVIEW = /^(?:reviewer:)?[^\s"]+ contradicts criterion "([^"]+)": [\s\S]*$/i;
335
+ /** Each recorded way a refuted report disagrees with its saved changes, said
336
+ * plainly and tied to the changed lines it concerns. `changes` maps each
337
+ * changed file to the lines of its first change (null when it has no line
338
+ * changes, a binary file say). Presentation only: the verdict is unchanged. */
339
+ export function reportMismatchesOf(reasons, criteria, changes) {
340
+ const named = (id) => {
341
+ const statement = criteria.find(one => one.id === id)?.statement.trim() ?? "";
342
+ if (statement === "")
343
+ return `requirement ${id}`;
344
+ return `“${statement.length > 90 ? `${statement.slice(0, 90).replace(/\s+\S*$/, "")}…` : statement}”`;
345
+ };
346
+ // The first file the requirement cites that the saved changes have.
347
+ const place = (id) => {
348
+ const path = criteria.find(one => one.id === id)?.answered.find(ref => ref.kind === "changed-path" && changes.has(ref.ref))?.ref ?? null;
349
+ return path === null ? { path: null, lines: null, inChanges: null } : { path, lines: changes.get(path) ?? null, inChanges: true };
350
+ };
351
+ const rows = reasons.flatMap((reason) => {
352
+ const over = OVERCLAIMED.exec(reason);
353
+ if (over !== null)
354
+ return over[1].split(", ").map(path => ({ text: "The report says it changed", path, lines: null, inChanges: changes.has(path), note: null, reason }));
355
+ const restated = RESTATED.exec(reason);
356
+ if (restated !== null)
357
+ return [{ text: `The report rewords ${named(restated[1])} as “${restated[2]}”`, ...place(restated[1]), note: null, reason }];
358
+ const caveat = CONTRADICTED_BY_CAVEAT.exec(reason);
359
+ const note = /^criterion "[^"]+" is marked met, but caveat ([0-9]+) /.exec(reason)?.[1] ?? NOTE_NUMBER.exec(reason)?.[1] ?? null;
360
+ if (caveat !== null)
361
+ return [{ text: `The report marks ${named(caveat[1])} met, but its own note says: ${caveat[2]}`, ...place(caveat[1]), note: Number(note), reason }];
362
+ const review = CONTRADICTED_BY_REVIEW.exec(reason);
363
+ if (review !== null)
364
+ return [{ text: plainReasonWords(reason), ...place(review[1]), note: null, reason }];
365
+ const unsigned = UNSIGNED_NOTE.exec(reason);
366
+ if (unsigned !== null)
367
+ return [{ text: `The report's note ${unsigned[1]} is about ${unsigned[2]}, which isn't one of the signed requirements: ${unsigned[3]}`, path: null, lines: null, inChanges: null, note: Number(unsigned[1]), reason }];
368
+ if (FAILED_CHECK.test(reason))
369
+ return [];
370
+ return [{ text: sentenceOf(plainReasonWords(reason)), path: null, lines: null, inChanges: null, note: note === null ? null : Number(note), reason }];
371
+ });
372
+ // Evidence a requirement cites that doesn't hold, unless a reason above already says so.
373
+ const evidence = criteria.flatMap(one => one.state !== "failed" && one.state !== "missing" ? [] : (one.detail ?? [])
374
+ .filter(detail => !reasons.includes(detail) && !/is waiting for the final check|requires manual-review evidence|is marked met, but caveat|was signed as/.test(detail))
375
+ .map((detail) => {
376
+ const lead = [`criterion "${one.id}"'s `, `criterion "${one.id}" `].find(prefix => detail.startsWith(prefix));
377
+ const rest = lead === undefined ? detail : detail.slice(lead.length);
378
+ return { text: `${sentenceOf(named(one.id))}: ${rest}`, ...place(one.id), note: null, reason: detail };
379
+ }));
380
+ return [...rows, ...evidence];
381
+ }
382
+ const UNSIGNED_NOTE = /^caveat ([0-9]+) names ("[^"]*"(?:, "[^"]*")*), (?:a criterion the proof authored|which is no signed)[\s\S]*?: ([\s\S]*)$/;
383
+ /** A sentence starts with a capital letter. */
384
+ function sentenceOf(text) {
385
+ return text === "" ? text : `${text.charAt(0).toUpperCase()}${text.slice(1)}`;
386
+ }
387
+ /** The line a failing check ended on: the last line of its error output that
388
+ * names an error, else the last line it printed, with its 1-based number in
389
+ * the saved log. Null when the log printed nothing. */
390
+ export function lastErrorLineOf(log) {
391
+ const lines = log.split("\n");
392
+ const printed = (text) => text.trim() !== "" && !/^\$ /.test(text) && !/^\(exit -?[0-9]+\)$/.test(text.trim()) && !/^--- (stdout|stderr) ---$/.test(text.trim());
393
+ const stderr = lines.findIndex(text => text.trim() === "--- stderr ---");
394
+ const pick = (from, to) => {
395
+ let last = null;
396
+ for (let index = to - 1; index >= from; index -= 1) {
397
+ if (!printed(lines[index]))
398
+ continue;
399
+ if (/error|fail|✗|✕|×|assert|exception|panic|cannot|not found/i.test(lines[index]))
400
+ return { line: index + 1, text: lines[index].trim() };
401
+ last ??= index;
402
+ }
403
+ return last === null ? null : { line: last + 1, text: lines[last].trim() };
404
+ };
405
+ return (stderr >= 0 ? pick(stderr + 1, lines.length) : null) ?? pick(0, stderr >= 0 ? stderr : lines.length);
324
406
  }
325
407
  /** The result page's own sentence when the decision is on it. */
326
408
  export const RESULT_DECISION_SENTENCE = "Review the change, then accept it or ask for changes.";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolroll",
3
- "version": "0.9.13",
3
+ "version": "0.9.14",
4
4
  "description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
5
5
  "directories": {
6
6
  "doc": "docs"