toolroll 0.9.103 → 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 (47) hide show
  1. package/CHANGELOG.md +8 -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 +13 -0
  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 +1 -1
  10. package/dist/keymap.d.ts +3 -1
  11. package/dist/keymap.js +3 -0
  12. package/dist/local-owner-acts.d.ts +83 -0
  13. package/dist/local-owner-acts.js +221 -0
  14. package/dist/local-owner.d.ts +12 -1
  15. package/dist/local-owner.js +26 -0
  16. package/dist/operate-remote-arguments.js +4 -2
  17. package/dist/operate.js +76 -1
  18. package/dist/plan-auto.js +5 -0
  19. package/dist/project-knowledge.d.ts +3 -3
  20. package/dist/public-access.d.ts +9 -0
  21. package/dist/public-access.js +15 -0
  22. package/dist/pull-request-flow.js +9 -0
  23. package/dist/release-card.d.ts +15 -1
  24. package/dist/release-card.js +35 -12
  25. package/dist/result-completion.d.ts +6 -2
  26. package/dist/result-completion.js +13 -7
  27. package/dist/scope.d.ts +32 -1
  28. package/dist/scope.js +110 -25
  29. package/dist/serve.js +13 -14
  30. package/dist/server/guards.d.ts +1 -0
  31. package/dist/server/guards.js +11 -1
  32. package/dist/server/remote.d.ts +1 -0
  33. package/dist/server/render-results.d.ts +4 -0
  34. package/dist/server/render-tasks.d.ts +9 -0
  35. package/dist/server/render-tasks.js +4 -4
  36. package/dist/server/route-table.js +4 -0
  37. package/dist/server/runtime.d.ts +1 -0
  38. package/dist/server/task-views.js +3 -0
  39. package/dist/server/tasks.d.ts +25 -0
  40. package/dist/server/tasks.js +46 -6
  41. package/dist/surface.js +12 -2
  42. package/dist/task-outcome-cli.d.ts +18 -0
  43. package/dist/task-outcome-cli.js +60 -2
  44. package/dist/tui-contract.d.ts +61 -3
  45. package/dist/tui-source.js +91 -20
  46. package/dist/tui.js +54 -54
  47. package/package.json +2 -2
package/dist/serve.js CHANGED
@@ -20,8 +20,6 @@ import { html, htmlString, jsonScript, textHtml } from "./html.js";
20
20
  import { browserAssetsAvailable, browserWorkspaceDocument } from './browser-shell.js';
21
21
  import { accessKey, countsKey, indexKey, projectSummaryOf, RevisionCache, summaryKey } from './console-chrome.js';
22
22
  import { usageChipOf } from './limits-ui.js';
23
- /** Any header a proxy adds: a request carrying one is never "from this computer" (password freshness, one-time links). */
24
- const PROXY_HEADER = /^(forwarded|via|x-forwarded-.+|x-real-ip|x-client-ip|x-original-.+|x-cluster-client-ip|true-client-ip|cf-connecting-ip|fastly-client-ip|x-proxyuser-ip)$/i;
25
23
  /** How often the background checks look at what is due (docs/plans/get-side-effects.md), and how often this
26
24
  * computer's agent sign-ins are asked again. */
27
25
  const BACKGROUND_CHECKS_MS = 30_000;
@@ -137,7 +135,7 @@ import { canonicalProject, projectName, resolveCeiling, rowVisible } from "./pro
137
135
  import { noteSignInProbe, signInNotices } from "./provider-auth.js";
138
136
  import { createConnectionChecker } from "./provider-connection.js";
139
137
  import { ALL_CREDENTIAL_ENV } from "./provider.js";
140
- import { hstsFor, LOOPBACK_PEERS, plainHttpRefusal, transportOf } from "./public-access.js";
138
+ import { hstsFor, LOOPBACK_PEERS, plainHttpRefusal, requestFromThisComputer, transportOf } from "./public-access.js";
141
139
  import { attachReadExecutor, defaultReadWorkers, ReadExecutor } from "./read-executor.js";
142
140
  import { cachedRelease, isNewer, runnerVersions, updateChecksOff } from "./releases.js";
143
141
  import { limitWords, FLUSH_MS as REQUEST_BUDGET_FLUSH_MS, RequestBudget, SOURCE_BUDGET_DEFAULTS, SourceAdmission } from "./request-budget.js";
@@ -158,7 +156,7 @@ import { createPeopleHandlers } from './server/people-tokens.js';
158
156
  import { createSettingsHandlers } from './server/settings.js';
159
157
  import { readPurpose, requestContext } from "./server/request-context.js";
160
158
  import { inPlaceRoute, prefetchRoute } from "./server/in-place.js";
161
- import { asLocalOwner, localModeOn, localOwnerRefusal, localOwnerStandsNow } from "./local-owner.js";
159
+ import { LOCAL_OWNER_ELSEWHERE, asLocalOwner, localModeOn, localOwnerRefusal, localOwnerStandsNow } from "./local-owner.js";
162
160
  import { defaultAgentOf, fillDefaultRouting } from "./model-choices.js";
163
161
  import { chromeScript, DEMO_BANNER, DEMO_BANNER_SHORT, focusDocument, KBD_HELP, pinnedTheme, QUEUE_VIEW, refuse, screen, SENSITIVE_INPUT, shell, sidebarScript } from "./server/chrome.js";
164
162
  import { BODY_CAP, form, loginHref, passwordSetNext, refusalNext, matchTaskPath, page, projectChatHref, redactedPath, redirect, respond, safeReturn, SHUTDOWN_WAIT_MS, taskChatHref } from "./server/http.js";
@@ -379,16 +377,9 @@ export function createDecisionServer(options) {
379
377
  /** v147: this console start's key epoch (easy-start C2): the browser-key wave binds each session's mutation key to it. */
380
378
  const keyEpoch = options.keyEpoch ?? randomBytes(16).toString("hex");
381
379
  /** This computer, and nothing in front of it: a loopback peer that names a loopback address, with no sign of a proxy —
382
- * any forwarding header at all (Forwarded, X-Forwarded-*, X-Real-IP, Via, …) disqualifies the request. */
383
- const fromThisComputer = (request) => {
384
- const peer = request.socket.remoteAddress ?? "";
385
- if (!LOOPBACK_PEERS.has(peer))
386
- return false;
387
- for (const name of Object.keys(request.headers))
388
- if (PROXY_HEADER.test(name))
389
- return false;
390
- return /^(localhost|127\.0\.0\.1|\[::1\]):[0-9]{1,5}$/.test(request.headers.host ?? "");
391
- };
380
+ * any forwarding header at all (Forwarded, X-Forwarded-*, X-Real-IP, Via, …) disqualifies the request. The one reading
381
+ * (public-access.ts), which guards.ts also applies to every request a local owner's session makes. */
382
+ const fromThisComputer = (request) => requestFromThisComputer(request);
392
383
  /**
393
384
  * Cockpit B2: a request's password-freshness facts — the one place they are made (scope.ts honours them). Every
394
385
  * reader looks at the session as it is at the moment of the act, never a snapshot taken before the body arrived:
@@ -911,6 +902,14 @@ export function createDecisionServer(options) {
911
902
  const refusedToken = {};
912
903
  const who = identify(request, touch, edgeRoute?.domain === 'live', refusedToken);
913
904
  timeline.mark("signed in");
905
+ // Local-owner slice 0: a local owner's session presented from anywhere but this computer (another address, a
906
+ // proxy, a tailnet or LAN name) signs in no one and changes nothing. Sign-in pages still answer (a password signs
907
+ // in there); everything else is refused in plain words.
908
+ if (refusedToken.elsewhere === true && edgeRoute?.domain !== 'people') {
909
+ request.resume();
910
+ response.setHeader("x-toolroll-refused", "local-owner-elsewhere");
911
+ return respond(response, 403, "text/plain; charset=utf-8", LOCAL_OWNER_ELSEWHERE);
912
+ }
914
913
  if (refusedToken.principal !== undefined) {
915
914
  if (refuseBudget(requestBudget.admit(refusedToken.principal.tokenId, "api")))
916
915
  return;
@@ -58,6 +58,7 @@ export declare function createSharedGuards(runtime: GuardsRuntime): {
58
58
  projectRequestAllowed: (route: RouteDeclaration, source: RouteDeclaration['project'], url: URL, who: Who, request: IncomingMessage, response: ServerResponse, body: URLSearchParams | null) => boolean;
59
59
  identify: (request: IncomingMessage, touch?: boolean, limited?: boolean, refusedToken?: {
60
60
  principal?: Principal;
61
+ elsewhere?: boolean;
61
62
  }) => Who | null;
62
63
  lookupSession: (candidate: string, touch?: boolean) => Session | null;
63
64
  restricted: () => boolean;
@@ -15,6 +15,7 @@ import { requestContext } from "./request-context.js";
15
15
  import { refuse } from "./chrome.js";
16
16
  import { matchTaskPath } from "./http.js";
17
17
  import { PersistentSessions, SESSION_COOKIE, sessionWithinLifetime } from "./session.js";
18
+ import { requestFromThisComputer } from "../public-access.js";
18
19
  export function createSharedGuards(runtime) {
19
20
  const syncIdentities = syncProjectIdentities();
20
21
  // v99: wrong passwords. One address guessing across names runs out of tries here; each name
@@ -333,7 +334,16 @@ export function createSharedGuards(runtime) {
333
334
  const match = new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=([0-9a-f]{64})`).exec(cookies);
334
335
  if (match === null)
335
336
  return null;
336
- const session = lookupSession(match[1], touch);
337
+ // Local-owner slice 0: a local owner's session works only from this computer — a loopback peer naming a loopback
338
+ // Host, with no forwarding header — on every request, not only when it was made. From anywhere else it signs in
339
+ // no one, and is not touched (refusedToken.elsewhere says why, for the refusal).
340
+ const found = lookupSession(match[1], false);
341
+ if (found !== null && found.authority !== "full" && !requestFromThisComputer(request)) {
342
+ if (refusedToken !== undefined)
343
+ refusedToken.elsewhere = true;
344
+ return null;
345
+ }
346
+ const session = touch && found !== null ? lookupSession(match[1], true) : found;
337
347
  return session === null ? null : { name: session.name, via: "cookie", session, role: session.role, authority: session.authority };
338
348
  }
339
349
  /**
@@ -12,6 +12,7 @@ import { type Who } from "./session.js";
12
12
  export interface RemoteRuntime {
13
13
  identify: (request: IncomingMessage, touch?: boolean, limited?: boolean, refusedToken?: {
14
14
  principal?: Principal;
15
+ elsewhere?: boolean;
15
16
  }) => Who | null;
16
17
  store: Store;
17
18
  admitPasswordSource: (request: IncomingMessage) => Admission;
@@ -247,6 +247,10 @@ export type ReviewCockpitView = {
247
247
  retry: {
248
248
  action: string;
249
249
  note: string;
250
+ pins?: {
251
+ run: number;
252
+ digest: string;
253
+ } | null;
250
254
  } | null;
251
255
  acceptAnyway?: {
252
256
  action: string;
@@ -513,6 +513,15 @@ export declare function taskBodyParts(data: {
513
513
  needsTwo: boolean;
514
514
  } | null;
515
515
  plan: "requested" | "drafted" | null;
516
+ /** Local-owner slice 2: the planning source this page read (local-owner-acts.ts planSourceOf), which the plan button
517
+ * posts so a local owner's request binds to exactly it. Null when it can't be read. */
518
+ planSource?: string | null;
519
+ /** Local-owner slice 3: the build and approved plan this page read (local-owner-acts.ts retryPinsOf), which Retry posts
520
+ * so a local owner's retry binds to exactly them. */
521
+ retryPins?: {
522
+ run: number;
523
+ digest: string;
524
+ } | null;
516
525
  planDocument: string | null;
517
526
  planAuto?: boolean;
518
527
  /** Hash of the verified plan artifact currently shown. */
@@ -2096,9 +2096,9 @@ export function taskBodyParts(data) {
2096
2096
  // A failed task's status card carries Retry itself (the same requeue); here it would be a second ink act.
2097
2097
  const cardRetries = task.state === "failed" && data.assignment != null && data.csrf !== "";
2098
2098
  const primaryAct = stopControlsActive || cardRetries ? null : stalled && !data.claimed
2099
- ? { html: act("requeue", "Retry, keeping the branch and workspace"), why: "Clears the repeated failures and queues the task again. The branch and workspace are kept.", whyClass: "retry" }
2099
+ ? { html: act("requeue", "Retry, keeping the branch and workspace", data.retryPins == null ? {} : { run: String(data.retryPins.run), digest: data.retryPins.digest }), why: "Clears the repeated failures and queues the task again. The branch and workspace are kept.", whyClass: "retry" }
2100
2100
  : canPlan
2101
- ? { html: act("plan", "plan first"), why: "plan first sends an agent to read the project, ask you questions and propose a plan. Nothing builds until you approve it.", whyClass: "plan" }
2101
+ ? { html: act("plan", "plan first", data.planSource == null ? {} : { source: data.planSource }), why: "plan first sends an agent to read the project, ask you questions and propose a plan. Nothing builds until you approve it.", whyClass: "plan" }
2102
2102
  : data.plan === "requested"
2103
2103
  ? null
2104
2104
  : task.state === "queued" && !data.claimed && (data.position?.position ?? 1) > 1
@@ -2464,9 +2464,9 @@ export function taskBodyParts(data) {
2464
2464
  };
2465
2465
  // Build again, in place, names its form after the card's own act.
2466
2466
  const rebuild = data.csrf !== "" && data.assignment?.primaryAction?.code === "retry-task" && data.assignment.need != null && "key" in data.assignment.need && data.assignment.need.key === "rebuild"
2467
- ? { action: `${taskHref(data.assignment.rootId)}/requeue` } : null;
2467
+ ? { action: `${taskHref(data.assignment.rootId)}/requeue`, pins: data.retryPins ?? null } : null;
2468
2468
  const retry = failedCard && task.state === "failed" && !data.claimed && !stopControlsActive && data.csrf !== ""
2469
- ? { action: `${taskHref(task.id)}/requeue`, ...(data.failure == null ? {} : { note: retryNoteOf(data.failure.suggestion) }) } : null;
2469
+ ? { action: `${taskHref(task.id)}/requeue`, pins: data.retryPins ?? null, ...(data.failure == null ? {} : { note: retryNoteOf(data.failure.suggestion) }) } : null;
2470
2470
  // Run checks happens in place: a status row's Run checks posts here and comes back; with nothing to run, the row offers nothing.
2471
2471
  const runChecks = data.runChecks ?? null;
2472
2472
  // A row's link to a result in Chat opens that result's own page instead: Chat would only lead back here.
@@ -79,6 +79,10 @@ const LOCAL_POSTS = Object.freeze({
79
79
  "task.act.resume-arm": "resume-run",
80
80
  "task.act.resume": "resume-run",
81
81
  "task.act.complete": "complete-task",
82
+ // Local-owner slice 2: the plan button, on its own road (tasks.ts localOwnerPlan; the writer checks this exact act).
83
+ "task.act.plan": "request-plan",
84
+ // Local-owner slice 3: Retry, on its own road (tasks.ts localOwnerRetry; the writer checks this exact act).
85
+ "task.act.requeue": "retry-build",
82
86
  "run.act.revise": "request-revision",
83
87
  "run.act.note": "request-revision",
84
88
  "run.act.comment": "request-revision",
@@ -92,6 +92,7 @@ export interface ServerRuntime {
92
92
  taskRooms: TaskRooms;
93
93
  identify: (request: IncomingMessage, touch?: boolean, limited?: boolean, refusedToken?: {
94
94
  principal?: Principal;
95
+ elsewhere?: boolean;
95
96
  }) => Who | null;
96
97
  liveCeiling: () => {
97
98
  repos: string[];
@@ -25,6 +25,7 @@ import { assignmentOf } from '../assignment.js';
25
25
  import { knowledgeContextHtml } from "../knowledge-ui.js";
26
26
  import { failedAttemptSentence, failingCheckSuggestion, GENERAL_SUGGESTION, isInternalErrorReason, latestFinishedAttempt, missedRequirementLine, missedRequirementSuggestion, NO_REASON_RECORDED, stopSuggestionOf } from '../needs-you.js';
27
27
  import { planAutoPending } from "../plan-auto.js";
28
+ import { planSourceOf, retryPinsOf } from "../local-owner-acts.js";
28
29
  import { readKnowledgeSnapshot } from "../project-knowledge.js";
29
30
  import { learningView } from "../project-learning.js";
30
31
  import { readSkillsSnapshot, skillTestResult } from "../project-skills.js";
@@ -384,6 +385,8 @@ export function createTaskViews(runtime) {
384
385
  })(),
385
386
  plan: ref?.plan ?? null,
386
387
  planAuto: planAutoPending(runtime.store, taskId, now),
388
+ planSource: planSourceOf(runtime.store, taskId),
389
+ retryPins: retryPinsOf(runtime.store, taskId),
387
390
  planDocument: planView?.document ?? null,
388
391
  planSha: planView?.sha256 ?? null,
389
392
  planContract: ref === null || planView === null ? null : planContractViewOf(ref.id, scope),
@@ -81,3 +81,28 @@ export type ApprovalOutcome = "approved" | "vote" | "approved-not-rebuilt" | "re
81
81
  /** The answer to an approval asked in place: its named outcome and the same words the page would show, as JSON. Written
82
82
  * only after the approve route's proofs, by the caller that knows the outcome. */
83
83
  export declare function answerApprovalInPlace(response: ServerResponse, outcome: ApprovalOutcome, words: string, status: number): void;
84
+ /**
85
+ * The task page's plan button for the laptop's local owner (local-owner slice 2): bound to the planning source the page
86
+ * read (the form's hidden `source`), never the task as it is now; the writer (local-owner-acts.ts) proves the rest in one
87
+ * transaction. No mode pre-authorization and no pins: the full session's road above is never reached from here.
88
+ */
89
+ export declare function localOwnerPlan(store: Store, taskId: string, source: string | null | undefined, now: Date): {
90
+ ok: true;
91
+ } | {
92
+ ok: false;
93
+ status: number;
94
+ message: string;
95
+ };
96
+ /**
97
+ * Retry for the laptop's local owner (local-owner slice 3): bound to the build and the approved plan the page read (the
98
+ * form's hidden `run` and `digest`), never the task as it is now; the writer proves the rest in one transaction. A form
99
+ * that carries neither (an older page) is refused, never bound to whatever is current.
100
+ */
101
+ export declare function localOwnerRetry(store: Store, taskId: string, run: string | null | undefined, digest: string | null | undefined, now: Date): {
102
+ ok: true;
103
+ replayed: boolean;
104
+ } | {
105
+ ok: false;
106
+ status: number;
107
+ message: string;
108
+ };
@@ -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. */