toolroll 0.9.102 → 0.9.104

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 (62) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +10 -8
  3. package/dist/assignment.d.ts +32 -0
  4. package/dist/assignment.js +140 -4
  5. package/dist/browser/workspace.js +25 -25
  6. package/dist/browser-workspace.d.ts +21 -5
  7. package/dist/contracts/console-api.d.ts +3 -2
  8. package/dist/contracts/console-api.js +4 -2
  9. package/dist/desktop-update.d.ts +2 -2
  10. package/dist/flow-engine.d.ts +3 -0
  11. package/dist/flow-engine.js +4 -1
  12. package/dist/flows-ui.d.ts +31 -1
  13. package/dist/flows-ui.js +40 -12
  14. package/dist/keymap.d.ts +3 -1
  15. package/dist/keymap.js +3 -0
  16. package/dist/local-owner-acts.d.ts +83 -0
  17. package/dist/local-owner-acts.js +221 -0
  18. package/dist/local-owner.d.ts +12 -1
  19. package/dist/local-owner.js +26 -0
  20. package/dist/operate-remote-arguments.js +4 -2
  21. package/dist/operate.js +76 -1
  22. package/dist/plan-auto.js +5 -0
  23. package/dist/project-knowledge.d.ts +6 -6
  24. package/dist/public-access.d.ts +9 -0
  25. package/dist/public-access.js +15 -0
  26. package/dist/pull-request-flow.js +9 -0
  27. package/dist/release-approval.d.ts +9 -20
  28. package/dist/release-approval.js +14 -53
  29. package/dist/release-card.d.ts +15 -1
  30. package/dist/release-card.js +35 -12
  31. package/dist/release-refusal.d.ts +32 -0
  32. package/dist/release-refusal.js +73 -0
  33. package/dist/result-completion.d.ts +6 -2
  34. package/dist/result-completion.js +13 -7
  35. package/dist/scope.d.ts +32 -1
  36. package/dist/scope.js +116 -25
  37. package/dist/serve.js +13 -14
  38. package/dist/server/guards.d.ts +1 -0
  39. package/dist/server/guards.js +11 -1
  40. package/dist/server/home-asks.d.ts +45 -0
  41. package/dist/server/home-asks.js +108 -0
  42. package/dist/server/home.d.ts +15 -5
  43. package/dist/server/home.js +53 -18
  44. package/dist/server/pages.js +15 -3
  45. package/dist/server/remote.d.ts +1 -0
  46. package/dist/server/render-results.d.ts +4 -0
  47. package/dist/server/render-tasks.d.ts +9 -0
  48. package/dist/server/render-tasks.js +4 -4
  49. package/dist/server/route-table.js +4 -0
  50. package/dist/server/runtime.d.ts +1 -0
  51. package/dist/server/task-views.js +3 -0
  52. package/dist/server/tasks.d.ts +25 -0
  53. package/dist/server/tasks.js +46 -6
  54. package/dist/surface.js +12 -2
  55. package/dist/task-outcome-cli.d.ts +18 -0
  56. package/dist/task-outcome-cli.js +60 -2
  57. package/dist/tui-contract.d.ts +61 -3
  58. package/dist/tui-source.js +91 -20
  59. package/dist/tui.js +54 -54
  60. package/dist/work-index.d.ts +10 -5
  61. package/dist/work-index.js +23 -13
  62. package/package.json +2 -2
@@ -30,6 +30,7 @@ import { codingShippingHtml } from '../coding-shipping-ui.js';
30
30
  import { codingWorkspaceHtml, codingWorkspaceScript } from '../coding-ui.js';
31
31
  import { CodingActionError } from '../coding-workspace.js';
32
32
  import { checkResponse, CONSOLE_FORMS, readForm } from "../contracts/console-api.js";
33
+ import { LOCAL_TASK_CHANGED, requestPlanAsLocalOwner, retryAsLocalOwner, retryPinsOf } from "../local-owner-acts.js";
33
34
  import { hasForbiddenControls, validateNote } from "../decision.js";
34
35
  import { builderReadyFor, diagnoseTaskDispatch, firstRunBuilderRepo } from "../dispatch.js";
35
36
  import { firstTaskSuggestions, withBuilderStep } from "../first-run.js";
@@ -276,7 +277,7 @@ export function createTasksHandlers(runtime) {
276
277
  const latest = latestFinishedAttempt(runs);
277
278
  // Retry from here only when the task failed, nothing holds it, and this is its latest attempt or a result it delivered.
278
279
  const retry = failedTask?.state === "failed" && (latest?.id === selected.run.id || delivered) && csrf !== "" && who.role === "approver" && store.currentLiveLease(attemptRun.taskRef, now) === null
279
- ? { action: `${taskHref(selected.taskId)}/requeue` } : null;
280
+ ? { action: `${taskHref(selected.taskId)}/requeue`, pins: retryPinsOf(store, selected.taskId) } : null;
280
281
  // Accept anyway: the same acceptance this task's latest result takes, never for an older one or one already accepted.
281
282
  const family = delivered ? familyOf(selected.taskId) : null;
282
283
  const acceptAnyway = delivered && csrf !== "" && who.role === "approver" && family?.current.id === selected.taskId && family.problem === null &&
@@ -1895,11 +1896,22 @@ export function createTasksHandlers(runtime) {
1895
1896
  return refuse(response, who, 403, "steering is a browser session's act");
1896
1897
  if (note !== "" && !validateNote(note).ok)
1897
1898
  return taskScreen(response, who, taskId, "that note will not store, so nothing was retried", 400);
1898
- const requeued = store.requeueTask(taskId, who.name, now);
1899
- if (!requeued.ok) {
1900
- return taskScreen(response, who, taskId, `not requeued: ${requeued.reason}`, 409);
1899
+ // Local-owner slice 3: the laptop's local owner retries its own approved build on its own road, bound to the
1900
+ // build and plan the page read. The same request again files no second note.
1901
+ let replayed = false;
1902
+ if (who.via === "cookie" && who.authority === "local-owner") {
1903
+ const retried = localOwnerRetry(store, taskId, body.get("run"), body.get("digest"), now);
1904
+ if (!retried.ok)
1905
+ return taskScreen(response, who, taskId, retried.message, retried.status);
1906
+ replayed = retried.replayed;
1907
+ }
1908
+ else {
1909
+ const requeued = store.requeueTask(taskId, who.name, now);
1910
+ if (!requeued.ok) {
1911
+ return taskScreen(response, who, taskId, `not requeued: ${requeued.reason}`, 409);
1912
+ }
1901
1913
  }
1902
- if (note !== "") {
1914
+ if (note !== "" && !replayed) {
1903
1915
  const filed = store.fileSteerNote(taskId, verifiedAuthor(who.name), note, now);
1904
1916
  if (!filed.ok)
1905
1917
  return taskScreen(response, who, taskId, "Retried. The note wasn't saved; add it under Steering.", 409);
@@ -1954,6 +1966,11 @@ export function createTasksHandlers(runtime) {
1954
1966
  return redirect(response, taskHref(filed.id));
1955
1967
  }
1956
1968
  case "plan": {
1969
+ // Local-owner slice 2: the laptop's local owner asks on its own road, bound to the planning source it read.
1970
+ if (who.via === "cookie" && who.authority === "local-owner") {
1971
+ const asked = localOwnerPlan(store, taskId, readForm(posted, CONSOLE_FORMS.taskPlan).get("source"), now);
1972
+ return asked.ok ? redirect(response, taskHref(taskId)) : taskScreen(response, who, taskId, asked.message, asked.status);
1973
+ }
1957
1974
  // The operator's explicit ask, refused transactionally when the
1958
1975
  // moment has passed — approved scope, live claim, or a plan
1959
1976
  // already under way (Codex planning review's requestPlan guard).
@@ -1961,7 +1978,8 @@ export function createTasksHandlers(runtime) {
1961
1978
  if (!asked.ok) {
1962
1979
  return taskScreen(response, who, taskId, `not planned: ${asked.reason}`, 409);
1963
1980
  }
1964
- if (who.via === "cookie")
1981
+ // Only a full session: a local owner never pre-authorizes (plan-auto.ts refuses it there too).
1982
+ if (who.via === "cookie" && who.authority === "full")
1965
1983
  authorizePlanUnderMode(store, taskId, who.name, now);
1966
1984
  return redirect(response, taskHref(taskId));
1967
1985
  }
@@ -3339,3 +3357,25 @@ export function createReleaseSheetHandlers(runtime) {
3339
3357
  export function answerApprovalInPlace(response, outcome, words, status) {
3340
3358
  respond(response, status, "application/json; charset=utf-8", JSON.stringify({ outcome, words }));
3341
3359
  }
3360
+ /**
3361
+ * The task page's plan button for the laptop's local owner (local-owner slice 2): bound to the planning source the page
3362
+ * read (the form's hidden `source`), never the task as it is now; the writer (local-owner-acts.ts) proves the rest in one
3363
+ * transaction. No mode pre-authorization and no pins: the full session's road above is never reached from here.
3364
+ */
3365
+ export function localOwnerPlan(store, taskId, source, now) {
3366
+ if (typeof source !== "string" || !/^[a-f0-9]{64}$/.test(source))
3367
+ return { ok: false, status: 409, message: LOCAL_TASK_CHANGED };
3368
+ const done = requestPlanAsLocalOwner(store, taskId, source, now);
3369
+ return done.ok ? { ok: true } : { ok: false, status: done.reason === "local-owner" || done.reason === "refused" ? 403 : 409, message: done.message };
3370
+ }
3371
+ /**
3372
+ * Retry for the laptop's local owner (local-owner slice 3): bound to the build and the approved plan the page read (the
3373
+ * form's hidden `run` and `digest`), never the task as it is now; the writer proves the rest in one transaction. A form
3374
+ * that carries neither (an older page) is refused, never bound to whatever is current.
3375
+ */
3376
+ export function localOwnerRetry(store, taskId, run, digest, now) {
3377
+ if (typeof run !== "string" || !/^[1-9][0-9]{0,14}$/.test(run) || typeof digest !== "string" || !/^[a-f0-9]{32}$/.test(digest))
3378
+ return { ok: false, status: 409, message: LOCAL_TASK_CHANGED };
3379
+ const done = retryAsLocalOwner(store, taskId, { run: Number(run), digest }, now);
3380
+ return done.ok ? { ok: true, replayed: done.replayed } : { ok: false, status: done.reason === "local-owner" || done.reason === "refused" || done.reason === "limit" ? 403 : 409, message: done.message };
3381
+ }
package/dist/surface.js CHANGED
@@ -222,7 +222,11 @@ const GUIDE_ROWS = [
222
222
  { name: 'token-env', takesValue: true, meaning: 'scoped coordinator credential environment variable' },
223
223
  { name: 'token-file', takesValue: true, meaning: 'scoped coordinator credential file; defaults to selected project reference' },
224
224
  ...(action === 'complete' ? [{ name: 'digest', takesValue: true, meaning: 'exact receipt from assignment show; required for JSON or scoped agents' },
225
- { name: 'pull-request', takesValue: false, meaning: "also open the result's pull request from that commit, as the console's Complete does; an approver's sign-in (--as/--token), never a scoped credential; refuses with pull-requests-off and the setup command" }]
225
+ { name: 'pull-request', takesValue: false, meaning: "also open the result's pull request from that commit, as the console's Complete does; an approver's sign-in (--as/--token), never a scoped credential; refuses with pull-requests-off and the setup command" },
226
+ // Local-owner slice 1: the terminal UI's Accept, for the laptop's local owner only (no password).
227
+ { name: 'run', takesValue: true, meaning: "the terminal's local owner: the exact build of the result read; required there with --digest" },
228
+ { name: 'checked', takesValue: false, meaning: "the terminal's local owner: you checked the result yourself; required when it owes your own check" },
229
+ { name: 'reason', takesValue: true, meaning: "the terminal's local owner: why you accept a result whose requirement isn't shown; required for an exception" }]
226
230
  : [{ name: 'feedback', takesValue: true, meaning: 'specific requested change' }, { name: 'run', takesValue: true, meaning: 'exact result run; defaults to current result' }, { name: 'source', takesValue: true, meaning: 'exact scope digest; defaults to current terms' }, { name: 'key', takesValue: true, meaning: '32 hexadecimal characters for exact replay; defaults to a stable feedback identity' }]),
227
231
  ],
228
232
  })),
@@ -297,7 +301,9 @@ const GUIDE_ROWS = [
297
301
  positionals: [{ name: "id", required: true, meaning: "the task" }],
298
302
  flags: [jsonFlag, dbFlag, keyFlag,
299
303
  { name: "provider", takesValue: true, meaning: "pin which provider plans THIS task (plan phase only)" },
300
- { name: "model", takesValue: true, meaning: "the pinned plan model, riding --provider" }],
304
+ { name: "model", takesValue: true, meaning: "the pinned plan model, riding --provider" },
305
+ // Local-owner slice 2: the terminal UI's plan request, for the laptop's local owner only (no password, no pins).
306
+ { name: "digest", takesValue: true, meaning: "the terminal's local owner: the exact planning source read; required there, and --provider/--model are refused" }],
301
307
  notableReasons: ["unknown-task"] },
302
308
  { invocation: "task hold", synopsis: "park a task with a reason", audience: "agent", agentMayInvoke: true, mutation: "keyed",
303
309
  positionals: [{ name: "id", required: true, meaning: "the task" }],
@@ -592,6 +598,10 @@ const LOCAL_ALLOW = {
592
598
  "task continue": "resume-run",
593
599
  "task unhold": "resume-run",
594
600
  "task complete": "complete-task",
601
+ // Local-owner slice 2: its own road (operate.ts localPlanCommand); the writer checks this exact act.
602
+ "task plan": "request-plan",
603
+ // Local-owner slice 3: its own road (operate.ts localRetryCommand); the writer checks this exact act.
604
+ "task requeue": "retry-build",
595
605
  "task revise": "request-revision",
596
606
  "task archive": "archive-task",
597
607
  "task restore": "archive-task",
@@ -28,3 +28,21 @@ export declare function runTaskMergeCommand(positional: readonly string[], flags
28
28
  }): Promise<number>;
29
29
  /** The lines `task show` prints for a task's pull request. */
30
30
  export declare function pullRequestLines(view: PullRequestView | null): string[];
31
+ /** Toolroll's own stop proof for every finished build of a task's family whose stop it can prove here. Best effort: a
32
+ * failure leaves the completion's own refusal to say what is still unproven. */
33
+ export declare function proveTaskBuildsStopped(store: Store, task: string, clock: () => Date): Promise<void>;
34
+ /**
35
+ * `task complete` for the laptop's local owner (local-owner slice 1; the terminal UI's Accept): the exact result the
36
+ * screen read, accepted and completed with no password. Its own road: it never reaches a password check (so it never
37
+ * moves the sign-in guard), and is chosen only inside the local owner's chain; there, an owner who no longer stands is
38
+ * refused, never handed to another road. Everything is pinned: `--run` and `--digest` are required (never defaulted
39
+ * to the current result), `--checked` is the person's explicit yes to a check they made, and `--reason` (trimmed, not
40
+ * empty) is an exception's why. No pull request: that needs a password.
41
+ */
42
+ export declare function runLocalCompletion(positional: readonly string[], flags: Map<string, string | true>, context: {
43
+ store: Store;
44
+ evidenceRoot: string;
45
+ json: boolean;
46
+ write: (line: string) => void;
47
+ now: Date;
48
+ }): Promise<number>;
@@ -1,6 +1,8 @@
1
1
  /** Everyday task verbs over the existing completion and revision boundaries. */
2
2
  import { createHash } from 'node:crypto';
3
- import { assignmentOf, checkAssignmentAsOperator } from './assignment.js';
3
+ import { assignmentOf, checkAssignmentAsOperator, completeAsLocalOwner } from './assignment.js';
4
+ import { currentLocalOwner } from './local-owner.js';
5
+ import { validateNote } from './decision.js';
4
6
  import { assignmentForCoordinator, coordinatorToken } from './assignment-adapters.js';
5
7
  import { authenticateCoordinator } from './coordinator.js';
6
8
  import { reproveApprover } from './principal.js';
@@ -179,7 +181,7 @@ export function pullRequestLines(view) {
179
181
  }
180
182
  /** Toolroll's own stop proof for every finished build of a task's family whose stop it can prove here. Best effort: a
181
183
  * failure leaves the completion's own refusal to say what is still unproven. */
182
- async function proveTaskBuildsStopped(store, task, clock) {
184
+ export async function proveTaskBuildsStopped(store, task, clock) {
183
185
  try {
184
186
  const family = store.taskFamilyOf(task, null, true);
185
187
  const refs = family === null ? [store.lookupRef(task)?.id].filter((one) => one !== undefined) : family.versions.map(version => version.refId);
@@ -191,3 +193,59 @@ async function proveTaskBuildsStopped(store, task, clock) {
191
193
  }
192
194
  catch { /* the completion below refuses with what is still unproven */ }
193
195
  }
196
+ /**
197
+ * `task complete` for the laptop's local owner (local-owner slice 1; the terminal UI's Accept): the exact result the
198
+ * screen read, accepted and completed with no password. Its own road: it never reaches a password check (so it never
199
+ * moves the sign-in guard), and is chosen only inside the local owner's chain; there, an owner who no longer stands is
200
+ * refused, never handed to another road. Everything is pinned: `--run` and `--digest` are required (never defaulted
201
+ * to the current result), `--checked` is the person's explicit yes to a check they made, and `--reason` (trimmed, not
202
+ * empty) is an exception's why. No pull request: that needs a password.
203
+ */
204
+ export async function runLocalCompletion(positional, flags, context) {
205
+ const command = 'task complete';
206
+ const emit = (result, lines) => {
207
+ context.write(context.json ? envelopeJson({ ...result, command }) : result.ok ? (lines ?? []).join('\n') : result.message ?? 'The action was refused.');
208
+ return result.ok ? 0 : result.reason === 'usage' ? 2 : 3;
209
+ };
210
+ const fail = (reason, message) => emit({ ok: false, reason, message });
211
+ const usage = 'Use task complete <task> --run <build> --digest <result> [--checked] [--reason "why"].';
212
+ const allowed = new Set(['json', 'db', 'run', 'digest', 'checked', 'reason', 'pull-request']);
213
+ for (const key of flags.keys())
214
+ if (!allowed.has(key))
215
+ return fail('usage', `--${key} is not a ${command} option.`);
216
+ if (flags.has('pull-request'))
217
+ return fail('local-owner', `Opening a pull request needs your password. ${context.store.accountOf(currentLocalOwner()?.account ?? '')?.passwordSet === true ? 'Sign in with your password to do this.' : 'Set a password first: run `toolroll password set`.'}`);
218
+ const task = positional[0];
219
+ if (positional.length !== 1 || !task || task.length > 64 || /[\x00-\x1f]/.test(task))
220
+ return fail('usage', usage);
221
+ const run = flags.get('run'), digest = flags.get('digest'), checked = flags.get('checked'), reason = flags.get('reason');
222
+ if (typeof run !== 'string' || !/^[1-9]\d{0,14}$/.test(run))
223
+ return fail('usage', `--run needs the exact build number of the result you read. ${usage}`);
224
+ if (typeof digest !== 'string' || !/^[a-f0-9]{64}$/.test(digest))
225
+ return fail('usage', `--digest needs the exact result you read. ${usage}`);
226
+ if (checked !== undefined && checked !== true)
227
+ return fail('usage', '--checked takes no value.');
228
+ let why = null;
229
+ if (reason !== undefined) {
230
+ // An exception's reason says something: trimmed here, at the command's edge, and never empty or only spaces.
231
+ if (typeof reason !== 'string' || reason.trim() === '')
232
+ return fail('usage', 'Say why you accept it anyway: --reason "<why>".');
233
+ const validated = validateNote(reason);
234
+ if (!validated.ok)
235
+ return fail('usage', `--reason: ${validated.problem}.`);
236
+ why = validated.note;
237
+ }
238
+ const { store, now, evidenceRoot } = context;
239
+ // Toolroll's own stop proof first, as every completion (a build it hasn't proven stopped is refused below).
240
+ await proveTaskBuildsStopped(store, task, () => now);
241
+ const done = completeAsLocalOwner(store, task, { runId: Number(run), receiptDigest: digest, checked: checked === true, reason: why }, now, evidenceRoot);
242
+ if (!done.ok)
243
+ return emit(done);
244
+ const { assignment } = done;
245
+ const receipt = assignment.receipt;
246
+ return emit({ ok: true, replayed: done.replayed, accepted: done.accepted, result: assignment }, [
247
+ `${assignment.rootId} · ${done.replayed ? 'Already accepted' : 'Accepted'}`,
248
+ `Result: ${receipt.taskId} · build #${receipt.runId} · ${receipt.head?.slice(0, 7) ?? 'no commit'}`,
249
+ ...(assignment.readiness?.checksOff ? [] : [receipt.checks.detail]),
250
+ ]);
251
+ }
@@ -46,6 +46,16 @@ export type TuiTask = {
46
46
  readonly decisions: readonly TuiDecision[];
47
47
  /** "Can I merge?" (merge-checklist.ts, `toolroll task ready`'s lines): the answer, then each check. Empty: no result. */
48
48
  readonly ready?: readonly string[];
49
+ /** `p` (local-owner slice 2): the planning source read now, which the request binds to; null when no plan can be asked. */
50
+ readonly planAsk?: {
51
+ readonly source: string;
52
+ } | null;
53
+ /** `r` (local-owner slice 3): the latest finished build and the approved plan read now, which a retry binds to; null
54
+ * when there is nothing to retry. */
55
+ readonly retry?: {
56
+ readonly runId: number;
57
+ readonly digest: string;
58
+ } | null;
49
59
  };
50
60
  /** The welcome's steps, as lines (welcome-facts.ts). */
51
61
  export type TuiSetup = {
@@ -85,12 +95,29 @@ export type TuiAct = {
85
95
  readonly kind: "revise";
86
96
  readonly id: string;
87
97
  readonly text: string;
88
- } | {
98
+ }
99
+ /** Accept: exactly the result the Accept screen read (its build and receipt, pinned when it opened), with the person's
100
+ * explicit yes to their own check, or an exception's reason, when the result owes one. */
101
+ | {
89
102
  readonly kind: "complete";
90
103
  readonly id: string;
91
- } | {
104
+ readonly runId: number;
105
+ readonly digest: string;
106
+ readonly checked: boolean;
107
+ readonly reason: string | null;
108
+ }
109
+ /** Retry: exactly the build and approved plan the screen read (the command refuses a task that changed meanwhile). */
110
+ | {
92
111
  readonly kind: "retry";
93
112
  readonly id: string;
113
+ readonly runId: number;
114
+ readonly digest: string;
115
+ }
116
+ /** Plan it: exactly the planning source the screen read (the command refuses a task that changed meanwhile). */
117
+ | {
118
+ readonly kind: "plan";
119
+ readonly id: string;
120
+ readonly source: string;
94
121
  } | {
95
122
  readonly kind: "answer";
96
123
  readonly decision: number;
@@ -105,7 +132,8 @@ export type TuiFastTerms = {
105
132
  readonly words: string;
106
133
  }[];
107
134
  };
108
- /** A write's answer: its words. `needsPassword`: refused because the local owner may not do it. */
135
+ /** A write's answer: its words. `needsPassword`: refused because the local owner may not do it. `changed`: refused
136
+ * because what it named (a result) changed since the screen read it. */
109
137
  export type TuiActResult = {
110
138
  readonly ok: true;
111
139
  readonly message: string;
@@ -114,6 +142,34 @@ export type TuiActResult = {
114
142
  readonly ok: false;
115
143
  readonly message: string;
116
144
  readonly needsPassword: boolean;
145
+ readonly changed?: boolean;
146
+ };
147
+ /** A finished result as the Accept screen reads it once and pins: which build and receipt, and what it shows. */
148
+ export type TuiResult = {
149
+ /** The task Accept names, and the exact result: what the answer submits, never read again before it is sent. */
150
+ readonly id: string;
151
+ readonly runId: number;
152
+ readonly digest: string;
153
+ readonly title: string;
154
+ /** Ready to accept now; when not, why not (a failed check, a build not yet proven stopped), and no Accept is offered. */
155
+ readonly ready: boolean;
156
+ readonly blockers: readonly string[];
157
+ /** The agent's own summary of what it did (empty when it left none). */
158
+ readonly summary: readonly string[];
159
+ /** The files the result changed (the first ten), how many more, or null when they can't be read. */
160
+ readonly files: {
161
+ readonly names: readonly string[];
162
+ readonly more: number;
163
+ } | null;
164
+ /** Each requirement and how it is shown ("Met", "You check", "Not shown yet"…). */
165
+ readonly requirements: readonly {
166
+ readonly word: string;
167
+ readonly statement: string;
168
+ }[];
169
+ /** "Can I merge?": the actual check status, the same lines as `toolroll task ready`. */
170
+ readonly checks: readonly string[];
171
+ /** What accepting it needs from the person: their own check, a reason, or nothing more. */
172
+ readonly owed: "person-check" | "exception" | null;
117
173
  };
118
174
  /** The plane, as the screen sees it. */
119
175
  export interface TuiSource {
@@ -124,6 +180,8 @@ export interface TuiSource {
124
180
  /** A counter that moves when the plane's work changes (the store's wake sequence). */
125
181
  changed(): number;
126
182
  act(act: TuiAct): Promise<TuiActResult>;
183
+ /** The task's current finished result, read once for the Accept screen; null when it has none. */
184
+ result(id: string): Promise<TuiResult | null>;
127
185
  /** A small task's exact terms, or null when it takes the ordinary road (New task then files it). */
128
186
  fastTerms(text: string, repo: string | null): Promise<TuiFastTerms | null>;
129
187
  /** The welcome's steps, or null once setup is done. */
@@ -8,7 +8,9 @@
8
8
  * nothing ever prompts on the terminal the screen owns.
9
9
  */
10
10
  import { dirname, join } from "node:path";
11
- import { assignmentOf } from "./assignment.js";
11
+ import { assignmentOf, owedAcceptance } from "./assignment.js";
12
+ import { familyChangedFiles } from "./result-completion.js";
13
+ import { checkBackingOf, receiptChecksOf, requirementWordOf } from "./task-status.js";
12
14
  import { checklistLines, mergeChecklistOf } from "./merge-checklist.js";
13
15
  import { runAsLocalOwner } from "./local-owner.js";
14
16
  import { snapshotLiveRuns } from "./peek-cli.js";
@@ -21,14 +23,18 @@ import { memoryWaiting } from "./lesson-notes.js";
21
23
  import { homeGroupOf } from "./home-groups.js";
22
24
  import { terminalText } from "./terminal-text.js";
23
25
  import { welcomeHere, welcomeLines } from "./welcome-facts.js";
26
+ import { planSourceOf, retryPinsOf } from "./local-owner-acts.js";
24
27
  const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
25
28
  const words = (value, fallback = "") => typeof value === "string" ? value : fallback;
26
29
  /** Text for the screen: whatever wrote it (an agent, a person, a project's name), escapes and controls never reach the
27
30
  * terminal (terminal-text.ts). Every displayed string below goes through here; ids and digests are only acted on. */
28
31
  const shown = (value, fallback = "") => terminalText(words(value, fallback));
29
- /** What a refused write says, and whether it is a local-owner refusal (the screen then leads to a password). */
32
+ /** What a refused write says, and whether it is a local-owner refusal (the screen then leads to a password), or a
33
+ * result that changed since the screen read it. */
30
34
  function refusalOf(envelope) {
31
35
  const message = shown(envelope.message, "That was refused.");
36
+ if (envelope.reason === "stale")
37
+ return { ok: false, message, needsPassword: false, changed: true };
32
38
  return { ok: false, message, needsPassword: envelope.reason === "refused" || envelope.reason === "machine-proof" || envelope.reason === "person-required" || /password/i.test(message) };
33
39
  }
34
40
  const routeLines = (store, id) => { const words = routeChangeOf(store, id); return words === null ? [] : [words]; };
@@ -116,6 +122,8 @@ export function createTuiSource(options) {
116
122
  return {
117
123
  id, title: shown(task["title"], id), status: shown(status["label"], words(work["state"])), detail: shown(status["detail"]),
118
124
  plan, liveRunId: typeof work["liveRunId"] === "number" ? work["liveRunId"] : null, hasResult, decisions, ready,
125
+ planAsk: store === null ? null : planAskOf(store, id),
126
+ retry: (() => { const pins = store === null ? null : retryPinsOf(store, id); return pins === null ? null : { runId: pins.run, digest: pins.digest }; })(),
119
127
  };
120
128
  },
121
129
  live(id) {
@@ -141,9 +149,14 @@ export function createTuiSource(options) {
141
149
  let argv;
142
150
  let done;
143
151
  switch (act.kind) {
152
+ // A task too big to build straight away: filed, then its plan asked for at once (local-owner slice 2), below.
144
153
  case "new":
145
154
  argv = ["task", "add", act.text, ...(act.repo === null ? [] : ["--repo", act.repo])];
146
- done = "Filed. Its plan will be ready for you to approve.";
155
+ done = "Filed.";
156
+ break;
157
+ case "plan":
158
+ argv = ["task", "plan", act.id, "--digest", act.source];
159
+ done = "Asked for a plan. You'll approve it before anything is built.";
147
160
  break;
148
161
  case "build":
149
162
  argv = ["task", "new", "--build", act.preview, "--digest", act.digest];
@@ -165,33 +178,49 @@ export function createTuiSource(options) {
165
178
  argv = ["decide", String(act.decision), "--choose", act.option];
166
179
  done = "Answered.";
167
180
  break;
168
- // Retry is today's requeue (the console's Retry), the same command and the same refusals.
181
+ // Retry: exactly the build and approved plan the screen read (local-owner slice 3), never the current ones.
169
182
  case "retry":
170
- argv = ["task", "retry", act.id];
171
- done = "Queued again. A builder starts it next.";
183
+ argv = ["task", "requeue", act.id, "--run", String(act.runId), "--digest", act.digest];
184
+ done = "Retrying. A builder starts it next.";
172
185
  break;
173
- case "complete": {
174
- const store = read();
175
- let digest;
176
- try {
177
- digest = store === null ? undefined : assignmentOf(store, act.id, clock(), { principal: "operator", repos: null })?.receipt?.digest;
178
- }
179
- catch {
180
- digest = undefined;
181
- }
182
- if (digest === undefined)
183
- return { ok: false, message: "This task has no finished result to accept.", needsPassword: false };
184
- argv = ["task", "complete", act.id, "--digest", digest];
185
- done = "Accepted.";
186
+ // Accept: exactly the result the Accept screen read, never the one current when this is sent (the command
187
+ // refuses a result that changed meanwhile). The person's yes to their own check, or a reason, only when given.
188
+ case "complete":
189
+ argv = ["task", "complete", act.id, "--run", String(act.runId), "--digest", act.digest, ...(act.checked ? ["--checked"] : []), ...(act.reason === null ? [] : ["--reason", act.reason])];
190
+ done = "Accepted. The task is done.";
186
191
  break;
187
- }
188
192
  }
189
193
  const answer = await command(argv);
190
194
  if (answer.ok !== true)
191
195
  return refusalOf(answer);
196
+ if (act.kind === "complete" && answer["replayed"] === true)
197
+ return { ok: true, message: "Already accepted. The task is done." };
198
+ if (act.kind === "plan" && answer["replayed"] === true)
199
+ return { ok: true, message: "Already asked for a plan." };
200
+ if (act.kind === "retry" && answer["replayed"] === true)
201
+ return { ok: true, message: "Already retrying. A builder starts it next." };
192
202
  const filed = act.kind === "new" ? words(record(answer["task"])["id"], words(answer["id"])) : act.kind === "build" ? words(answer["task"]) : "";
203
+ // Filed in a project: ask for its plan now, bound to the task just filed (the screen promised a plan).
204
+ if (act.kind === "new" && filed !== "" && act.repo !== null) {
205
+ const store = read();
206
+ const ask = store === null ? null : planAskOf(store, filed);
207
+ const asked = ask === null ? null : await command(["task", "plan", filed, "--digest", ask.source]);
208
+ const message = asked?.ok === true ? "Filed. Planning: you'll approve the plan before anything is built." : `Filed. It wasn't sent for a plan: ${shown(asked?.message, "it can't be planned now.")}`;
209
+ return { ok: true, message, id: filed };
210
+ }
193
211
  return { ok: true, message: done, ...(filed === "" ? {} : { id: filed }) };
194
212
  },
213
+ async result(id) {
214
+ const store = read();
215
+ if (store === null)
216
+ return null;
217
+ try {
218
+ return resultOf(store, id, clock(), evidenceRoot);
219
+ }
220
+ catch {
221
+ return null;
222
+ }
223
+ },
195
224
  async fastTerms(text, repo) {
196
225
  if (repo === null)
197
226
  return null;
@@ -242,3 +271,45 @@ function learningLine(store, repos, now) {
242
271
  const parts = [flaky === 0 ? "" : `${flaky} flaky test${flaky === 1 ? "" : "s"}: open the project's Flaky tests in the browser`, memory === 0 ? "" : `${memory} project memory suggestion${memory === 1 ? "" : "s"}: open Settings → Knowledge`].filter(Boolean);
243
272
  return parts.length === 0 ? null : parts.join(" · ");
244
273
  }
274
+ /** The Accept screen's one read of a task's current result (local-owner slice 1): which build and receipt it pins, and
275
+ * what the result page would show about it — the agent's summary, the files changed, each requirement and how it is
276
+ * shown, the actual check status, and what accepting it needs from the person. Text from outside is cleaned here. */
277
+ function resultOf(store, id, now, evidenceRoot) {
278
+ const assignment = assignmentOf(store, id, now, { principal: "operator", repos: null }, evidenceRoot);
279
+ const receipt = assignment?.receipt ?? null;
280
+ if (assignment === null || receipt === null)
281
+ return null;
282
+ const blockers = assignment.readiness?.blockers ?? [];
283
+ const owed = assignment.state === "ready-to-check" ? owedAcceptance(receipt, blockers) : null;
284
+ // An exception's own blocker is what its reason answers; any other one stands in the way of accepting.
285
+ const standing = blockers.filter(one => !(owed === "exception" && one.key === "criteria")).map(one => terminalText(one.message));
286
+ const notNow = assignment.state === "complete" ? "This result is already accepted." : assignment.state === "ready-to-check" ? null : terminalText(assignment.detail || "This result isn't ready to accept.");
287
+ const changed = familyChangedFiles(store, receipt.taskId, receipt.runId, evidenceRoot);
288
+ const backing = checkBackingOf(receiptChecksOf(receipt));
289
+ let checks = [];
290
+ try {
291
+ const list = mergeChecklistOf(store, id, now, { principal: "operator", repos: null }, evidenceRoot);
292
+ checks = list === null ? [] : checklistLines(list).map(line => terminalText(line));
293
+ }
294
+ catch {
295
+ checks = [];
296
+ }
297
+ return {
298
+ id, runId: receipt.runId, digest: receipt.digest, title: terminalText(assignment.title),
299
+ ready: notNow === null && standing.length === 0, blockers: notNow === null ? standing : [notNow],
300
+ summary: (receipt.agentReport ?? "").split("\n").map(line => terminalText(line).trimEnd()).filter(line => line.trim() !== "").slice(0, 8),
301
+ files: changed === null ? null : { names: changed.slice(0, 10).map(name => terminalText(name)), more: Math.max(0, changed.length - 10) },
302
+ requirements: (receipt.proof?.matrix ?? []).map(row => ({ word: requirementWordOf(row, receipt.proof?.verdict ?? null, backing), statement: terminalText(row.statement ?? row.id ?? "") })),
303
+ checks, owed,
304
+ };
305
+ }
306
+ /** Whether a plan can be asked for this task now, and the planning source a request binds to (local-owner slice 2): a
307
+ * queued task with no plan asked or drafted and no approved plan. What the screen offers only; the command decides. */
308
+ function planAskOf(store, id) {
309
+ const ref = store.lookupRef(id);
310
+ const scope = store.getScope(id);
311
+ if (ref === null || ref.plan !== null || store.getTask(id)?.state !== "queued" || (scope !== null && scope.approvedAt !== null && scope.approvedDigest === scope.digest))
312
+ return null;
313
+ const source = planSourceOf(store, id);
314
+ return source === null ? null : { source };
315
+ }