toolroll 0.9.13 → 0.9.15

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 (43) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/assignment-ui.d.ts +1 -0
  3. package/dist/assignment-ui.js +8 -5
  4. package/dist/assignment.d.ts +32 -0
  5. package/dist/assignment.js +81 -7
  6. package/dist/browser/workspace.css +1 -1
  7. package/dist/browser/workspace.js +38 -38
  8. package/dist/browser-crew.d.ts +2 -1
  9. package/dist/browser-crew.js +4 -2
  10. package/dist/browser-workspace.d.ts +109 -11
  11. package/dist/chat-actions.d.ts +1 -1
  12. package/dist/chat-actions.js +6 -4
  13. package/dist/chat-delivery-state.d.ts +1 -1
  14. package/dist/chat-voice.d.ts +3 -3
  15. package/dist/chat-voice.js +3 -3
  16. package/dist/demo.js +82 -2
  17. package/dist/disclosure.d.ts +6 -0
  18. package/dist/disclosure.js +13 -0
  19. package/dist/dispatch.js +7 -2
  20. package/dist/lead-status.js +2 -2
  21. package/dist/needs-you.d.ts +72 -0
  22. package/dist/needs-you.js +193 -1
  23. package/dist/result-acts.d.ts +51 -5
  24. package/dist/result-acts.js +36 -3
  25. package/dist/result-review.d.ts +4 -3
  26. package/dist/result-review.js +7 -3
  27. package/dist/serve.d.ts +3 -0
  28. package/dist/serve.js +1235 -711
  29. package/dist/slack-chat.js +1 -1
  30. package/dist/task-status.d.ts +24 -3
  31. package/dist/task-status.js +48 -17
  32. package/dist/telegram-progress.js +2 -2
  33. package/dist/test-temp.js +1 -1
  34. package/dist/transitions-recipes.d.ts +1 -1
  35. package/dist/transitions-recipes.js +5 -9
  36. package/dist/when-html.d.ts +19 -2
  37. package/dist/when-html.js +78 -11
  38. package/dist/work-index.d.ts +3 -1
  39. package/dist/work-index.js +18 -5
  40. package/dist/workspace-motion.js +3 -3
  41. package/dist/workspace-ui.d.ts +86 -6
  42. package/dist/workspace-ui.js +143 -17
  43. package/package.json +1 -1
@@ -17,6 +17,8 @@
17
17
  * claimed without one.
18
18
  */
19
19
  import type { DispatchDiagnosis } from "./dispatch.js";
20
+ import { ACCEPT_NEEDS_REASON, type AcceptLabel } 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,18 +196,60 @@ 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
200
- * recorded checks, the requirements not met, the ones a person still checks,
201
- * whether the saved proof reads, and what the button posts. "Accept" only when everything is met and the
202
- * checks passed; otherwise "Accept without checks" and one line naming what.
244
+ * recorded checks, the requirements not met, whether the saved proof reads,
245
+ * and what the button posts. "Accept and finish" only when everything is met
246
+ * and the checks passed; otherwise "Accept without checks" and one line naming
247
+ * what. The person's own checks are the page's to add (acceptWithChecksOf).
203
248
  * `effect` says what pressing it does, in the action's own terms. */
204
249
  export type AcceptFacts = {
205
250
  checks: "passed" | "failed" | "not-run" | "off" | "running" | "unavailable" | null;
206
251
  unmet: number;
207
- yours: number;
208
- /** `complete`: marks the exact result complete; `accept`: records a person's acceptance only. */
252
+ /** `complete`: accepts and finishes the exact result in one request; `accept`: records a person's acceptance only (nothing here can finish it). */
209
253
  action: "complete" | "accept";
210
254
  /** `pull-request`: one can be opened from the task after; `off`: publishing isn't set up; `other`: neither is said. Accept itself never publishes. */
211
255
  publishing: "pull-request" | "off" | "other";
@@ -213,7 +257,7 @@ export type AcceptFacts = {
213
257
  proof: boolean;
214
258
  };
215
259
  export type AcceptWords = {
216
- label: "Accept" | "Accept without checks";
260
+ label: AcceptLabel;
217
261
  ready: boolean;
218
262
  why: string | null;
219
263
  effect: string;
@@ -243,3 +287,39 @@ export type PrimaryDestination = "chat" | "work" | "projects" | "flows" | "setti
243
287
  export declare function primaryDestinationOf(active: string): PrimaryDestination;
244
288
  /** Learning stays in Settings and a useful, closed result disclosure. */
245
289
  export declare function learningHtml(view: import('./project-learning.js').LearningView, csrf: string, canManage: boolean, source?: number): string;
290
+ /** The first signed requirement a finished attempt missed (failed or unanswered; a person's own check is not a miss),
291
+ * with the evidence line behind it in plain words. Null when every requirement held. */
292
+ export declare function missedRequirementOf(matrix: readonly {
293
+ id: string;
294
+ statement: string;
295
+ state: string;
296
+ detail: readonly string[];
297
+ }[]): {
298
+ id: string;
299
+ statement: string;
300
+ evidence: string | null;
301
+ } | null;
302
+ /** One milestone of a live build's plan, as the agent last reported it; its words are null where only the recorded
303
+ * progress was read (a list row reads no plan file). */
304
+ export type BuildStep = {
305
+ description: string | null;
306
+ state: "pending" | "current" | "completed" | "blocked";
307
+ note: string | null;
308
+ };
309
+ /** Where a live build is, in one line: "Step 3 of 6: …", or, when a step is blocked, "Stuck on step 4 of 6: <why>" in
310
+ * its place. `stuck` says which step and why; null while nothing is blocked. */
311
+ export type BuildProgress = {
312
+ step: number;
313
+ total: number;
314
+ line: string;
315
+ stuck: {
316
+ step: number;
317
+ why: string | null;
318
+ line: string;
319
+ } | null;
320
+ };
321
+ /** A live build's progress: the step in progress (else the first one not done) as "Step N of M: <step>"; a blocked
322
+ * step replaces it as "Stuck on step N of M: <why>" (its note, else the step itself). Null with no plan steps. */
323
+ export declare function buildProgressOf(steps: readonly BuildStep[] | null | undefined): BuildProgress | null;
324
+ /** Earlier attempts that stopped before the one now running, in one quiet line. */
325
+ export declare function earlierAttemptsWords(count: number): string;
@@ -16,8 +16,10 @@
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";
20
- import { plainReasonOf, stageOfCode, taskStatusOf, workToneOf } from "./task-status.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";
22
+ import { OPEN_RESULT, 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." },
23
25
  { key: "needs-you", label: "Needs you", hint: "Tasks waiting on an answer, approval, or your inspection.", empty: "Nothing needs you right now. Queued and running work continues on its own." },
@@ -65,7 +67,7 @@ export function reviewStatusOf(review) {
65
67
  // it would unreviewed, marked "not reviewed" by the result itself.
66
68
  if (review === null || review.automaticPending !== true || (review.state !== "queued" && review.state !== "running"))
67
69
  return null;
68
- return { token: "reviewing", label: "Reviewing", detail: "An automatic review is reading this result before it reaches you.", tone: "live", action: { label: "Open the result", kind: "open-result" } };
70
+ return { token: "reviewing", label: "Reviewing", detail: "An automatic review is reading this result before it reaches you.", tone: "live", action: { label: OPEN_RESULT, kind: "open-result" } };
69
71
  }
70
72
  const FAILED_CHECK = /^the repository's approved verification command exited (-?[0-9]+)/;
71
73
  export function evidenceProblemOf(verdict, reasons) {
@@ -191,11 +193,11 @@ function storedResultStatusOf(result, publication) {
191
193
  // weighed, so it says nothing about whether that check passed.
192
194
  detail: withPublication("What the agent reported doesn't match the changes it saved."),
193
195
  tone: "problem",
194
- action: { label: "Open the result", kind: "open-review" },
196
+ action: { label: OPEN_RESULT, kind: "open-review" },
195
197
  };
196
198
  }
197
199
  if (result.verdict === "short" && humanReview) {
198
- return { token: "verification-needed", label: "Ready to inspect", detail: withPublication("The remaining requirements need a person's inspection."), tone: "attention", action: { label: "Inspect the result", kind: "open-review" } };
200
+ return { token: "verification-needed", label: "Ready to inspect", detail: withPublication("The remaining requirements need a person's inspection."), tone: "attention", action: { label: OPEN_RESULT, kind: "open-review" } };
199
201
  }
200
202
  if (result.verdict === "short") {
201
203
  return {
@@ -212,7 +214,7 @@ function storedResultStatusOf(result, publication) {
212
214
  // presence facts here.
213
215
  return result.recordComplete === false && result.verdict === null
214
216
  ? { token: "record-incomplete", label: "No-change result, record incomplete", detail: "The build concluded nothing needed to change, but its handoff or sealed diff is missing.", tone: "problem", action: { label: "Open the record", kind: "open-run" } }
215
- : { token: "no-change", label: "No changes were needed", detail: withPublication("The build concluded nothing needed to change; its handoff and sealed diff are on record."), tone: "done", action: { label: "Open the result", kind: "open-result" } };
217
+ : { token: "no-change", label: "No changes were needed", detail: withPublication("The build concluded nothing needed to change; its handoff and sealed diff are on record."), tone: "done", action: { label: OPEN_RESULT, kind: "open-result" } };
216
218
  }
217
219
  if (result.verdict === null) {
218
220
  return {
@@ -229,7 +231,7 @@ function storedResultStatusOf(result, publication) {
229
231
  label: "Result saved — checks reported by the agent",
230
232
  detail: withPublication("No independent project check ran; the checks listed are the agent's own report."),
231
233
  tone: "neutral",
232
- action: { label: "Open the result", kind: "open-result" },
234
+ action: { label: OPEN_RESULT, kind: "open-result" },
233
235
  };
234
236
  }
235
237
  if (published !== null && (published.token === "merge-observed" || published.token === "pr-opened" || published.token === "pr-closed")) {
@@ -238,7 +240,7 @@ function storedResultStatusOf(result, publication) {
238
240
  label: published.label,
239
241
  detail: `The approved check passed against this result. ${published.detail}`,
240
242
  tone: published.token === "pr-closed" ? "muted" : "done",
241
- action: published.token === "merge-observed" ? { label: "Open the result", kind: "open-result" } : { label: "Open the pull request", kind: "open-pr" },
243
+ action: published.token === "merge-observed" ? { label: OPEN_RESULT, kind: "open-result" } : { label: "Open the pull request", kind: "open-pr" },
242
244
  };
243
245
  }
244
246
  return {
@@ -246,7 +248,7 @@ function storedResultStatusOf(result, publication) {
246
248
  label: "Ready",
247
249
  detail: withPublication("The approved check passed against this result."),
248
250
  tone: "ready",
249
- action: { label: "Open the result", kind: "open-result" },
251
+ action: { label: OPEN_RESULT, kind: "open-result" },
250
252
  };
251
253
  }
252
254
  /** The completion receipt's heading: what the record actually supports.
@@ -286,7 +288,7 @@ export function needsPerson(dispatch) {
286
288
  /** Navigation labels describe the available help, not a mutation: opening
287
289
  * a hold or pause must never promise that the task has already resumed. */
288
290
  const DISPATCH_ACTION_LABELS = {
289
- "open-result": "Open the result",
291
+ "open-result": OPEN_RESULT,
290
292
  "retry-task": "Review and retry",
291
293
  "place-task": "Choose a project",
292
294
  "write-scope": "Define the task",
@@ -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.";
@@ -331,15 +413,14 @@ export function acceptWordsOf(facts) {
331
413
  ...(facts.checks === "passed" ? [] : [facts.checks === "failed" ? "checks failed" : facts.checks === "off" ? "checks are off for this project"
332
414
  : facts.checks === "running" ? "checks are still running" : facts.checks === "unavailable" ? "saved checks can't be read" : "checks didn't run"]),
333
415
  ...(facts.unmet > 0 ? [`${plural(facts.unmet, "requirement isn't", "requirements aren't")} met`] : []),
334
- ...(facts.yours > 0 ? [`${plural(facts.yours, "item still needs", "items still need")} your check`] : []),
335
416
  ];
336
417
  const ready = parts.length === 0;
337
418
  const joined = parts.length <= 1 ? parts.join("") : `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}`;
338
- const effect = facts.action === "accept" ? "Records that you accept it. You mark it complete next."
339
- : facts.publishing === "pull-request" ? "Marks it complete. No pull request opens; you can open one from the task after."
340
- : facts.publishing === "off" ? "Marks it complete. The branch stays; publishing isn't set up."
341
- : "Marks it complete. Nothing is published.";
342
- return { label: ready ? "Accept" : "Accept without checks", ready, why: ready ? null : `${joined.charAt(0).toUpperCase()}${joined.slice(1)}.`, effect };
419
+ const effect = facts.action === "accept" ? "Records that you accept it. The task stays open."
420
+ : facts.publishing === "pull-request" ? "Finishes the task. No pull request opens; you can open one from the task after."
421
+ : facts.publishing === "off" ? "Finishes the task. The branch stays; publishing isn't set up."
422
+ : "Finishes the task. Nothing is published.";
423
+ return { label: ready ? "Accept and finish" : "Accept without checks", ready, why: ready ? null : `${joined.charAt(0).toUpperCase()}${joined.slice(1)}.`, effect };
343
424
  }
344
425
  export function resultHeadlineOf(status) {
345
426
  const read = RESULT_NEEDS_YOU.has(status.token) ? { stage: "needs-you", need: "other" }
@@ -470,3 +551,48 @@ export function learningHtml(view, csrf, canManage, source) {
470
551
  const history = view.events.map(ev => `<article class="card" data-learning-event="${e(ev.action)}"><p><strong>${e({ assessment: { propose: 'Learning suggested', none: 'No lesson needed', unassessed: 'Learning not assessed', invalid: 'Learning assessment invalid' }[ev.after] ?? 'Learning not assessed', proposal: 'Suggestion recorded', adopt: 'Lesson adopted', disable: 'Lesson disabled', reset: 'Learning reset', enable: 'Reuse enabled', pause: 'Reuse paused', reuse: 'Run context saved', failure: 'Learning issue', capture: 'Capture finished' }[ev.action] ?? ev.action)}</strong>${ev.action === "reuse" ? ` · ${e(ev.after)}` : ""}</p><p class="meta"><time datetime="${e(ev.at)}">${e(ev.at.replace("T", " ").replace(/\.\d+Z$/, " UTC"))}</time> · ${e(ev.actor)}${ev.run === null ? '' : ` · <a href="/r/${ev.run}">Run #${ev.run}</a>${ev.outcome === null ? "" : ` · ${e(ev.outcome)}`}`}</p><details><summary>Details</summary><p>${e(ev.before)} → ${e(ev.after)}</p><p>${e(ev.reason)}</p>${ev.lesson === null ? '' : `<p>Lesson #${ev.lesson}</p>`}<ul>${evidence(ev.evidence, ev.run)}</ul>${ev.snapshot === null ? "" : `<details><summary>Exact context</summary><pre>${e(ev.snapshot)}</pre></details>`}</details></article>`).join('');
471
552
  return `<section class="learning">${view.damaged ? '<p class="problem" role="alert">Some lessons no longer verify and are excluded from reuse.</p>' : ''}<p>${view.enabled ? 'Use adopted lessons: on' : 'Use adopted lessons: off'}</p>${act(view.enabled ? 'pause' : 'enable', view.enabled ? 'Pause reuse' : 'Enable reuse')}${cards || '<p>No lessons yet. Reviews may suggest useful advice here.</p>'}<details><summary>Reset learning</summary><p>Disable all adopted lessons and future reuse. Keep history, code, approvals and active run snapshots.</p>${act('reset', 'Reset learning')}</details><h2>Changes</h2>${history || '<p>No learning changes yet.</p>'}${view.next === null ? '' : `<a class="button-link" href="/settings/learning?repo=${encodeURIComponent(view.repo)}&before=${view.next}">Older changes</a>`}</section>`;
472
553
  }
554
+ /** The first signed requirement a finished attempt missed (failed or unanswered; a person's own check is not a miss),
555
+ * with the evidence line behind it in plain words. Null when every requirement held. */
556
+ export function missedRequirementOf(matrix) {
557
+ const missed = matrix.find(row => row.state === "failed") ?? matrix.find(row => row.state === "missing") ?? null;
558
+ if (missed === null)
559
+ return null;
560
+ const detail = missed.detail.find(one => !/is waiting for the final check/.test(one)) ?? missed.detail[0] ?? null;
561
+ return { id: missed.id, statement: missed.statement, evidence: detail === null ? (missed.state === "missing" ? "The agent's report doesn't answer it." : null) : requirementEvidenceWords(missed.id, detail) };
562
+ }
563
+ /** A matrix row's recorded detail as a sentence a person reads: the agent's own note when one admits the miss. */
564
+ function requirementEvidenceWords(id, detail) {
565
+ const caveat = CONTRADICTED_BY_CAVEAT.exec(detail);
566
+ // The note often names its requirement first ("c1: …"); the line already says which requirement it is.
567
+ if (caveat !== null && caveat[1] === id)
568
+ return sentenceOf(`The agent's own note says: ${caveat[2].trim().replace(new RegExp(`^${id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*[:—-]\\s*`), "")}`);
569
+ const lead = [`criterion "${id}"'s `, `criterion "${id}" `].find(prefix => detail.startsWith(prefix));
570
+ return sentenceOf(plainReasonWords(lead === undefined ? detail : detail.slice(lead.length)));
571
+ }
572
+ const stepWords = (text) => {
573
+ const plain = text?.trim().replace(/\s+/g, " ").replace(/[.\s]+$/, "") ?? "";
574
+ return plain === "" ? null : plain;
575
+ };
576
+ /** A live build's progress: the step in progress (else the first one not done) as "Step N of M: <step>"; a blocked
577
+ * step replaces it as "Stuck on step N of M: <why>" (its note, else the step itself). Null with no plan steps. */
578
+ export function buildProgressOf(steps) {
579
+ if (steps == null || steps.length === 0)
580
+ return null;
581
+ const total = steps.length;
582
+ const blocked = steps.findIndex(one => one.state === "blocked");
583
+ if (blocked >= 0) {
584
+ const why = stepWords(steps[blocked].note) ?? stepWords(steps[blocked].description);
585
+ const line = `Stuck on step ${blocked + 1} of ${total}${why === null ? "" : `: ${why}`}.`;
586
+ return { step: blocked + 1, total, line, stuck: { step: blocked + 1, why, line } };
587
+ }
588
+ const current = steps.findIndex(one => one.state === "current");
589
+ const at = current >= 0 ? current : steps.findIndex(one => one.state !== "completed");
590
+ if (at < 0)
591
+ return { step: total, total, line: `All ${total} steps done. Finishing up.`, stuck: null };
592
+ const words = stepWords(steps[at].description);
593
+ return { step: at + 1, total, line: `Step ${at + 1} of ${total}${words === null ? "" : `: ${words}`}.`, stuck: null };
594
+ }
595
+ /** Earlier attempts that stopped before the one now running, in one quiet line. */
596
+ export function earlierAttemptsWords(count) {
597
+ return `${count} earlier attempt${count === 1 ? "" : "s"} stopped`;
598
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolroll",
3
- "version": "0.9.13",
3
+ "version": "0.9.15",
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"