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
@@ -392,8 +392,13 @@ export type BrowserTaskView = {
392
392
  run: number;
393
393
  } | null;
394
394
  /** Build again, in place, when the status asks for it (a result built to an earlier plan). */
395
+ /** `pins`: the build and approved plan the page read, which a local owner's Retry binds to (local-owner slice 3). */
395
396
  rebuild?: {
396
397
  action: string;
398
+ pins?: {
399
+ run: number;
400
+ digest: string;
401
+ } | null;
397
402
  } | null;
398
403
  /** What a failed task missed, in one line (the requirement it missed, the failing check's error line, or the stop
399
404
  * reason), the evidence line behind it, one suggestion of what to change, and where to see it (its build's result
@@ -402,6 +407,10 @@ export type BrowserTaskView = {
402
407
  retry?: {
403
408
  action: string;
404
409
  note?: string;
410
+ pins?: {
411
+ run: number;
412
+ digest: string;
413
+ } | null;
405
414
  } | null;
406
415
  /** Run checks in place, on a status row that offers it: posts and comes back to this page. */
407
416
  runChecks?: BrowserRunChecks | null;
@@ -867,6 +876,10 @@ export type BrowserResultView = {
867
876
  retry: {
868
877
  action: string;
869
878
  note: string;
879
+ pins?: {
880
+ run: number;
881
+ digest: string;
882
+ } | null;
870
883
  } | null;
871
884
  /** A failed task's delivered result, accepted only in outline with a reason (the task's accept-proof). */
872
885
  acceptAnyway?: {
@@ -202,7 +202,8 @@ export declare const CONSOLE_FORMS: {
202
202
  readonly taskRepairDependency: FormContract<"blocker" | "operation" | "replacement">;
203
203
  readonly taskNext: FormContract<"undo">;
204
204
  readonly taskReopen: FormContract<"token">;
205
- readonly taskRequeue: FormContract<"note" | "return">;
205
+ readonly taskRequeue: FormContract<"digest" | "note" | "return" | "run">;
206
+ readonly taskPlan: FormContract<"source">;
206
207
  readonly taskFollowUp: FormContract<"index">;
207
208
  readonly taskPlanEdit: FormContract<"plan-document" | "saw-plan">;
208
209
  readonly taskRevision: FormContract<"revision-id" | "token">;
@@ -218,7 +219,7 @@ export declare const CONSOLE_FORMS: {
218
219
  };
219
220
  export type ConsoleFormName = keyof typeof CONSOLE_FORMS;
220
221
  /** The console POSTs whose handlers read no field (sign-out not even the shared guard): their contract is the empty form. */
221
- export declare const BODILESS_POSTS: readonly ["POST /settings/models/<unknown action>", "POST /r/<run>/draft-repair", "POST /ledger/checkpoint", "POST /settings/backups/now", "POST /mode/revoke", "POST /settings/telegram/retry", "POST /providers/<provider>/resume", "POST /logout", "POST /t/<task>/unhold", "POST /t/<task>/stop-carrying", "POST /t/<task>/plan", "POST /t/<task>/retry-review", "POST /settings/updates/check", "POST /settings/storage/check", "POST /projects/github", "POST /code/<session>/ship/preview"];
222
+ export declare const BODILESS_POSTS: readonly ["POST /settings/models/<unknown action>", "POST /r/<run>/draft-repair", "POST /ledger/checkpoint", "POST /settings/backups/now", "POST /mode/revoke", "POST /settings/telegram/retry", "POST /providers/<provider>/resume", "POST /logout", "POST /t/<task>/unhold", "POST /t/<task>/stop-carrying", "POST /t/<task>/retry-review", "POST /settings/updates/check", "POST /settings/storage/check", "POST /projects/github", "POST /code/<session>/ship/preview"];
222
223
  /** The field names a console form's handler may read. */
223
224
  export type FormFieldOf<N extends ConsoleFormName> = (typeof CONSOLE_FORMS)[N] extends FormContract<infer K> ? K : never;
224
225
  /** `GET /ledger?format=json`: the chain's state, a page of entries (newest first) and the cursor for the next. */
@@ -220,7 +220,10 @@ export const CONSOLE_FORMS = {
220
220
  taskRepairDependency: formContract("POST /t/<task>/repair-dependency", ["blocker", "operation", "replacement"]),
221
221
  taskNext: formContract("POST /t/<task>/next", ["undo"]),
222
222
  taskReopen: formContract("POST /t/<task>/reopen", ["token"]),
223
- taskRequeue: formContract("POST /t/<task>/requeue", ["note", "return"]),
223
+ // Local-owner slice 3: the build and approved plan the page read (`run`, `digest`), read only for the laptop's local owner.
224
+ taskRequeue: formContract("POST /t/<task>/requeue", ["note", "return", "run", "digest"]),
225
+ // Local-owner slice 2: the planning source the page read (planner-source.ts), read only for the laptop's local owner.
226
+ taskPlan: formContract("POST /t/<task>/plan", ["source"]),
224
227
  taskFollowUp: formContract("POST /t/<task>/follow-up", ["index"]),
225
228
  taskPlanEdit: formContract("POST /t/<task>/plan-edit", ["saw-plan", "plan-document"]),
226
229
  taskRevision: formContract("POST /t/<task>/accept-revision|reject-revision", ["revision-id", "token"]),
@@ -248,7 +251,6 @@ export const BODILESS_POSTS = [
248
251
  "POST /logout",
249
252
  "POST /t/<task>/unhold",
250
253
  "POST /t/<task>/stop-carrying",
251
- "POST /t/<task>/plan",
252
254
  "POST /t/<task>/retry-review",
253
255
  // Check now and its kin (docs/plans/get-side-effects.md): the form token only, no fields read.
254
256
  "POST /settings/updates/check",
@@ -97,6 +97,7 @@ export declare function sqliteLock(file: string, busyTimeoutMs?: number): Databa
97
97
  export declare function lockHeld(file: string): boolean;
98
98
  export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
99
99
  export declare function desktopUpdateStatus(stateDir: string): {
100
+ version?: never;
100
101
  active: boolean;
101
102
  phase: string;
102
103
  detail: string;
@@ -110,7 +111,6 @@ export declare function desktopUpdateStatus(stateDir: string): {
110
111
  wasRunning?: never;
111
112
  backupPath?: never;
112
113
  updateId?: never;
113
- version?: never;
114
114
  buildId?: never;
115
115
  workDir?: never;
116
116
  } | {
package/dist/keymap.d.ts CHANGED
@@ -15,7 +15,7 @@ export type WebShortcut = {
15
15
  kind: "help";
16
16
  };
17
17
  /** What a key does in the terminal UI, or null with the reason it has none there (a named gap). */
18
- export type TuiShortcut = "new" | "ink" | "send-back" | "retry" | "list" | "help";
18
+ export type TuiShortcut = "new" | "ink" | "send-back" | "retry" | "plan" | "list" | "help";
19
19
  export type Shortcut = {
20
20
  keys: string;
21
21
  says: string;
@@ -30,6 +30,8 @@ export declare const INK_ACT_SELECTORS: readonly ["[data-ink-approve]", '[data-i
30
30
  export declare const SEND_BACK_SELECTORS: readonly ['[data-act="request-changes"]', '[data-act="revise"]', 'a.result-feedback-link[href="#request-changes"]'];
31
31
  /** Retry: the result's Retry, or the failed box's own Retry form. */
32
32
  export declare const RETRY_SELECTORS: readonly ['[data-act="retry"]', 'form[data-retry] button[type="submit"]', 'form[data-rebuild] button[type="submit"]'];
33
+ /** Plan it: the task page's own plan button (local-owner slice 2). */
34
+ export declare const PLAN_SELECTORS: readonly ['form[action$="/plan"] button[type="submit"]'];
33
35
  export declare const SHORTCUTS: readonly Shortcut[];
34
36
  /** The keys typed, as the help list shows them: "g h" reads "g then h". */
35
37
  export declare const keyWords: (keys: string) => string;
package/dist/keymap.js CHANGED
@@ -8,11 +8,14 @@ export const INK_ACT_SELECTORS = ["[data-ink-approve]", '[data-ink-act="accept"]
8
8
  export const SEND_BACK_SELECTORS = ['[data-act="request-changes"]', '[data-act="revise"]', 'a.result-feedback-link[href="#request-changes"]'];
9
9
  /** Retry: the result's Retry, or the failed box's own Retry form. */
10
10
  export const RETRY_SELECTORS = ['[data-act="retry"]', 'form[data-retry] button[type="submit"]', 'form[data-rebuild] button[type="submit"]'];
11
+ /** Plan it: the task page's own plan button (local-owner slice 2). */
12
+ export const PLAN_SELECTORS = ['form[action$="/plan"] button[type="submit"]'];
11
13
  export const SHORTCUTS = [
12
14
  { keys: "n", says: "new task", web: { kind: "go", href: "/tasks/new" }, tui: "new" },
13
15
  { keys: "a", says: "Approve or Accept (presses the button on the page)", web: { kind: "press", selectors: INK_ACT_SELECTORS }, tui: "ink" },
14
16
  { keys: "b", says: "Send back (opens its note)", web: { kind: "press", selectors: SEND_BACK_SELECTORS }, tui: "send-back" },
15
17
  { keys: "r", says: "Retry (presses the button on the page)", web: { kind: "press", selectors: RETRY_SELECTORS }, tui: "retry" },
18
+ { keys: "p", says: "Ask for a plan (presses the button on the page)", web: { kind: "press", selectors: PLAN_SELECTORS }, tui: "plan" },
16
19
  { keys: "/", says: "search", web: { kind: "search" }, tui: { none: "The terminal has no search; press w to open the console." } },
17
20
  { keys: "g h", says: "go Home", web: { kind: "go", href: "/" }, tui: "list" },
18
21
  { keys: "g t", says: "go to Tasks", web: { kind: "go", href: "/tasks" }, tui: "list" },
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The laptop's local owner's password-free acts beyond approving and accepting (docs/plans/local-owner-acts.md, slices
3
+ * 2 and 3): asking for a plan, and retrying its own approved build. One writer per act, shared by the terminal (the
4
+ * CLI rows `task plan` and `task requeue`, run as the local owner) and the browser (the task page's plan button and
5
+ * Retry, posted by a local owner's session). Each writer proves everything inside its own transaction: the local owner
6
+ * acting now as exactly this act (localActNow, never `proveAccountNow` saying yes to any act), the owner's current reach
7
+ * into the project, the one work rule (scope.ts localWorkEligible), the exact thing the screen read, and the act's own
8
+ * guards. Neither is ever chosen for anyone else: a password session, an API token, the lead, a chat bot or a machine
9
+ * keeps the road it had.
10
+ */
11
+ import type { Store } from "./store.js";
12
+ /** What a refused local act says, and a reason the surfaces branch on. */
13
+ export type LocalActRefusal = {
14
+ ok: false;
15
+ reason: string;
16
+ message: string;
17
+ };
18
+ /** The ledger act a local owner's plan request is kept under: the planning source it was bound to, and its transport. */
19
+ export declare const LOCAL_PLAN_ACTION = "task plan (local owner)";
20
+ /** What a stale screen hears: the task is not what it read. */
21
+ export declare const LOCAL_TASK_CHANGED = "This task changed. Look at it again.";
22
+ /**
23
+ * The planning source a screen binds a plan request to (planner-source.ts): the digest over everything the planner
24
+ * would be given, the filed scope when there is one and the title alone when there is none. Null when it can't be read.
25
+ */
26
+ export declare function planSourceOf(store: Store, taskId: string): string | null;
27
+ /**
28
+ * Ask for a plan, as the laptop's local owner, with no password (local-owner slice 2). In one transaction: the owner
29
+ * acting now as exactly `request-plan`; its current reach into the task's project; the task not put away; the one work
30
+ * rule for planning (no release, protected, two-person, escalated, coordinator, imported or unsandboxed work); no plan
31
+ * pins saved on the task (a local request never plans with an agent someone chose; it refuses rather than ignoring or
32
+ * clearing them, so what plans is exactly what the screen's terms say); and the planning source still the one the
33
+ * screen read (`source`). A request already made, with that source unchanged, answers "already" and writes nothing.
34
+ * Otherwise any unchanged-plan pre-authorization saved on the task is withdrawn (with its audit row), so the plan this
35
+ * asks for can only be approved by a person's own yes; then requestPlan's own guards (queued, not planned, not
36
+ * approved, no live claim); then the request's ledger row. It approves nothing, pins nothing and pre-authorizes nothing.
37
+ */
38
+ export declare function requestPlanAsLocalOwner(store: Store, taskId: string, source: string, now: Date): {
39
+ ok: true;
40
+ replayed: boolean;
41
+ } | LocalActRefusal;
42
+ /** The ledger act a local owner's retry is kept under: the build it retried, the exact seal it spent, and its transport. */
43
+ export declare const LOCAL_RETRY_ACTION = "task requeue (local owner)";
44
+ /** Local retries per task and exact seal: then a note (Send back) or a password. */
45
+ export declare const LOCAL_RETRY_LIMIT = 3;
46
+ /** The fourth local retry of one approval. */
47
+ export declare const LOCAL_RETRY_SPENT = "You've retried this 3 times. Send it back with a note, or sign in with your password to retry again.";
48
+ /** What a retry hears while Toolroll can't yet prove the last build stopped. */
49
+ export declare const LOCAL_RETRY_STOPPING = "The last build is still stopping. Try again in a moment.";
50
+ /**
51
+ * What a Retry binds to (local-owner slice 3): the task's latest finished build and its approved scope digest, when the
52
+ * task failed or finished and its scope is approved as it stands. What a screen offers only; the writer decides.
53
+ */
54
+ export declare function retryPinsOf(store: Store, taskId: string): {
55
+ run: number;
56
+ digest: string;
57
+ } | null;
58
+ /**
59
+ * Retry the local owner's own approved build after it stopped (a console restart, a model error), with no password
60
+ * (local-owner slice 3). One transaction, in this order:
61
+ * - the owner acting now as exactly `retry-build`, and its current reach into the task's project;
62
+ * - the task's current approval is this owner's own person yes on the exact seal (personApprovalOf: the ceremony or a
63
+ * chat tap, a person's proof for those bytes — never a mode, grant, schedule, automatic or carried seal, the lead, or
64
+ * another person), still standing for dispatch, and on work the owner could approve again now (localWorkEligible);
65
+ * - bound to what the screen read: `digest` is the approved scope digest, and `run` the latest finished build;
66
+ * - the same request again (this owner's retry of `run` under this seal) answers "already" and writes nothing while
67
+ * that build is still the latest one;
68
+ * - its own stop proof: no unfinished run of the task, no run after `run`, Toolroll's durable stop facts settled for
69
+ * every run (an expired claim alone proves nothing), no final result (accepted or published) in any task state, and a
70
+ * failed task or a finished one whose result wasn't accepted (requeueEligibility re-reads the claim and archive);
71
+ * - at most three local retries of this task under this exact seal (a fresh approval, even of the same bytes, is a new
72
+ * seal), then the words that lead to a note or a password;
73
+ * - then the requeue (incidents resolved, strikes cleared, as the console's Retry) and its ledger row.
74
+ * Nothing about the scope, route, model, chain, budget or permissions is written.
75
+ */
76
+ export declare function retryAsLocalOwner(store: Store, taskId: string, pins: {
77
+ run: number;
78
+ digest: string;
79
+ }, now: Date): {
80
+ ok: true;
81
+ replayed: boolean;
82
+ retries: number;
83
+ } | LocalActRefusal;
@@ -0,0 +1,221 @@
1
+ import { refArchived, requeueEligibility } from "./store.js";
2
+ import { LOCAL_OWNER_LAPSED, localActNow } from "./local-owner.js";
3
+ import { approverStanding, localWorkEligible, personApprovalOf } from "./scope.js";
4
+ import { plannerContractOf, plannerSourceDigest } from "./planner-source.js";
5
+ import { scopeApprovedForDispatch } from "./dispatch.js";
6
+ /** The ledger act a local owner's plan request is kept under: the planning source it was bound to, and its transport. */
7
+ export const LOCAL_PLAN_ACTION = "task plan (local owner)";
8
+ /** What a stale screen hears: the task is not what it read. */
9
+ export const LOCAL_TASK_CHANGED = "This task changed. Look at it again.";
10
+ /**
11
+ * The planning source a screen binds a plan request to (planner-source.ts): the digest over everything the planner
12
+ * would be given, the filed scope when there is one and the title alone when there is none. Null when it can't be read.
13
+ */
14
+ export function planSourceOf(store, taskId) {
15
+ const contract = plannerContractOf(store, taskId);
16
+ return contract.ok ? plannerSourceDigest(contract.contract) : null;
17
+ }
18
+ /**
19
+ * Ask for a plan, as the laptop's local owner, with no password (local-owner slice 2). In one transaction: the owner
20
+ * acting now as exactly `request-plan`; its current reach into the task's project; the task not put away; the one work
21
+ * rule for planning (no release, protected, two-person, escalated, coordinator, imported or unsandboxed work); no plan
22
+ * pins saved on the task (a local request never plans with an agent someone chose; it refuses rather than ignoring or
23
+ * clearing them, so what plans is exactly what the screen's terms say); and the planning source still the one the
24
+ * screen read (`source`). A request already made, with that source unchanged, answers "already" and writes nothing.
25
+ * Otherwise any unchanged-plan pre-authorization saved on the task is withdrawn (with its audit row), so the plan this
26
+ * asks for can only be approved by a person's own yes; then requestPlan's own guards (queued, not planned, not
27
+ * approved, no live claim); then the request's ledger row. It approves nothing, pins nothing and pre-authorizes nothing.
28
+ */
29
+ export function requestPlanAsLocalOwner(store, taskId, source, now) {
30
+ try {
31
+ return store.transact(() => {
32
+ const local = localActNow(store, "request-plan");
33
+ if (local === null)
34
+ return { ok: false, reason: "local-owner", message: LOCAL_OWNER_LAPSED };
35
+ const ref = store.lookupRef(taskId);
36
+ if (ref === null || store.getTask(taskId) === null)
37
+ return { ok: false, reason: "not-found", message: "That task is unavailable in your projects." };
38
+ if (!approverStanding(store, local.account, ref.repo).ok)
39
+ return { ok: false, reason: "not-found", message: "That task is unavailable in your projects." };
40
+ if (refArchived(store, ref.id))
41
+ return { ok: false, reason: "archived", message: "This task is put away. Restore it first." };
42
+ const eligible = localWorkEligible(store, local.account, taskId, { act: "plan" });
43
+ if (!eligible.ok)
44
+ return { ok: false, reason: "refused", message: eligible.message };
45
+ if (ref.planProvider !== null || ref.planModel !== null) {
46
+ return { ok: false, reason: "refused", message: "This task names its own planning agent, so planning it needs your password." };
47
+ }
48
+ if (planSourceOf(store, taskId) !== source)
49
+ return { ok: false, reason: "stale", message: LOCAL_TASK_CHANGED };
50
+ // An unchanged-plan pre-authorization on the task (a mode or a flow's grant armed it on this or an earlier request)
51
+ // never stands behind the local owner's "you'll approve it before anything is built": withdrawn here, in the same
52
+ // transaction and before the "already asked" answer below, and said in the ledger.
53
+ const armed = store.handle.prepare("SELECT signed_by, basis FROM plan_authorization WHERE task_id = ?").get(taskId);
54
+ if (armed !== undefined) {
55
+ store.handle.prepare("DELETE FROM plan_authorization WHERE task_id = ?").run(taskId);
56
+ store.recordAction({ at: now.toISOString(), actor: `operator:${local.account}`, repo: ref.repo, taskId, runId: null, source: "request",
57
+ action: "unchanged plan pre-authorization withdrawn", outcome: "withdrawn",
58
+ detail: JSON.stringify({ v: 1, signedBy: String(armed["signed_by"]), basis: String(armed["basis"]), why: "the local owner asked for the plan" }) });
59
+ }
60
+ // Asked already (by anyone), with nothing about the task changed: the same answer, and nothing else written.
61
+ if (ref.plan === "requested" || ref.plan === "drafted")
62
+ return { ok: true, replayed: true };
63
+ const asked = store.requestPlan(ref.id, now);
64
+ if (!asked.ok)
65
+ throw new Undone({ ok: false, reason: "refused", message: `This task can't be planned now: ${asked.reason}.` });
66
+ store.recordAction({ at: now.toISOString(), actor: `operator:${local.account}`, repo: ref.repo, taskId, runId: null, action: LOCAL_PLAN_ACTION,
67
+ outcome: "requested", source: "request", detail: JSON.stringify({ v: 1, source, via: local.transport }) });
68
+ return { ok: true, replayed: false };
69
+ });
70
+ }
71
+ catch (error) {
72
+ if (error instanceof Undone)
73
+ return error.result;
74
+ // Store.transact's own re-check found the bound owner no longer standing: the same refusal, said, never a crash.
75
+ if (error instanceof Error && error.message === LOCAL_OWNER_LAPSED)
76
+ return { ok: false, reason: "local-owner", message: LOCAL_OWNER_LAPSED };
77
+ throw error;
78
+ }
79
+ }
80
+ // ---- Retry the owner's own approved build (slice 3) ---------------------------------------------------------------------
81
+ /** The ledger act a local owner's retry is kept under: the build it retried, the exact seal it spent, and its transport. */
82
+ export const LOCAL_RETRY_ACTION = "task requeue (local owner)";
83
+ /** Local retries per task and exact seal: then a note (Send back) or a password. */
84
+ export const LOCAL_RETRY_LIMIT = 3;
85
+ /** The fourth local retry of one approval. */
86
+ export const LOCAL_RETRY_SPENT = `You've retried this ${LOCAL_RETRY_LIMIT} times. Send it back with a note, or sign in with your password to retry again.`;
87
+ /** What a retry hears while Toolroll can't yet prove the last build stopped. */
88
+ export const LOCAL_RETRY_STOPPING = "The last build is still stopping. Try again in a moment.";
89
+ const sameSeal = (a, b) => {
90
+ const one = a;
91
+ return one !== null && typeof one === "object" && one.by === b.by && one.at === b.at && one.digest === b.digest;
92
+ };
93
+ /**
94
+ * What a Retry binds to (local-owner slice 3): the task's latest finished build and its approved scope digest, when the
95
+ * task failed or finished and its scope is approved as it stands. What a screen offers only; the writer decides.
96
+ */
97
+ export function retryPinsOf(store, taskId) {
98
+ const ref = store.lookupRef(taskId);
99
+ const scope = store.getScope(taskId);
100
+ const state = store.getTask(taskId)?.state;
101
+ if (ref === null || scope === null || scope.approvedAt === null || scope.approvedDigest !== scope.digest || (state !== "failed" && state !== "done"))
102
+ return null;
103
+ const last = store.lastBuilderRun(ref.id);
104
+ return last === null ? null : { run: last.id, digest: scope.digest };
105
+ }
106
+ /** This owner's earlier retries of this task under exactly this seal: the builds they retried. */
107
+ function localRetriesOf(store, taskId, account, seal) {
108
+ const rows = store.handle.prepare("SELECT run_id, detail FROM action_ledger WHERE action = ? AND task_id = ? AND actor = ? ORDER BY id")
109
+ .all(LOCAL_RETRY_ACTION, taskId, `operator:${account}`);
110
+ const runs = [];
111
+ for (const row of rows) {
112
+ let detail;
113
+ try {
114
+ detail = JSON.parse(String(row["detail"] ?? ""));
115
+ }
116
+ catch {
117
+ continue;
118
+ }
119
+ const bound = detail;
120
+ if (bound.v === 1 && sameSeal(bound.seal, seal))
121
+ runs.push(Number(row["run_id"]));
122
+ }
123
+ return runs;
124
+ }
125
+ /**
126
+ * Retry the local owner's own approved build after it stopped (a console restart, a model error), with no password
127
+ * (local-owner slice 3). One transaction, in this order:
128
+ * - the owner acting now as exactly `retry-build`, and its current reach into the task's project;
129
+ * - the task's current approval is this owner's own person yes on the exact seal (personApprovalOf: the ceremony or a
130
+ * chat tap, a person's proof for those bytes — never a mode, grant, schedule, automatic or carried seal, the lead, or
131
+ * another person), still standing for dispatch, and on work the owner could approve again now (localWorkEligible);
132
+ * - bound to what the screen read: `digest` is the approved scope digest, and `run` the latest finished build;
133
+ * - the same request again (this owner's retry of `run` under this seal) answers "already" and writes nothing while
134
+ * that build is still the latest one;
135
+ * - its own stop proof: no unfinished run of the task, no run after `run`, Toolroll's durable stop facts settled for
136
+ * every run (an expired claim alone proves nothing), no final result (accepted or published) in any task state, and a
137
+ * failed task or a finished one whose result wasn't accepted (requeueEligibility re-reads the claim and archive);
138
+ * - at most three local retries of this task under this exact seal (a fresh approval, even of the same bytes, is a new
139
+ * seal), then the words that lead to a note or a password;
140
+ * - then the requeue (incidents resolved, strikes cleared, as the console's Retry) and its ledger row.
141
+ * Nothing about the scope, route, model, chain, budget or permissions is written.
142
+ */
143
+ export function retryAsLocalOwner(store, taskId, pins, now) {
144
+ try {
145
+ return store.transact(() => {
146
+ const local = localActNow(store, "retry-build");
147
+ if (local === null)
148
+ return { ok: false, reason: "local-owner", message: LOCAL_OWNER_LAPSED };
149
+ const ref = store.lookupRef(taskId);
150
+ if (ref === null || store.getTask(taskId) === null || !approverStanding(store, local.account, ref.repo).ok) {
151
+ return { ok: false, reason: "not-found", message: "That task is unavailable in your projects." };
152
+ }
153
+ const person = personApprovalOf(store, taskId);
154
+ if (!person.ok || person.by !== local.account) {
155
+ return { ok: false, reason: "refused", message: "Only a build of your own approval can be retried without a password. Sign in with your password to retry it." };
156
+ }
157
+ const scope = store.getScope(taskId);
158
+ const seal = { by: scope.approvedBy, at: scope.approvedAt, digest: scope.approvedDigest };
159
+ if (scope.digest !== pins.digest)
160
+ return { ok: false, reason: "stale", message: LOCAL_TASK_CHANGED };
161
+ if (!scopeApprovedForDispatch(store, ref.id, now)) {
162
+ return { ok: false, reason: "refused", message: "This approval no longer stands. Approve the plan again, or sign in with your password." };
163
+ }
164
+ const eligible = localWorkEligible(store, local.account, taskId, { act: "retry" });
165
+ if (!eligible.ok)
166
+ return { ok: false, reason: "refused", message: eligible.message };
167
+ const latest = store.lastBuilderRun(ref.id);
168
+ const retried = localRetriesOf(store, taskId, local.account, seal);
169
+ // The same request again: recognised from its own ledger row while that retry is still waiting or building, with
170
+ // the owner and approval proved above.
171
+ const state = store.getTask(taskId).state;
172
+ if (retried.includes(pins.run) && latest?.id === pins.run && (state === "queued" || state === "running"))
173
+ return { ok: true, replayed: true, retries: retried.length };
174
+ if (store.handle.prepare("SELECT 1 AS hit FROM run WHERE task_ref = ? AND finished_at IS NULL LIMIT 1").get(ref.id) !== undefined) {
175
+ return { ok: false, reason: "stopping", message: LOCAL_RETRY_STOPPING };
176
+ }
177
+ if (latest === null || latest.id !== pins.run || store.handle.prepare("SELECT 1 AS hit FROM run WHERE task_ref = ? AND id > ? LIMIT 1").get(ref.id, pins.run) !== undefined) {
178
+ return { ok: false, reason: "stale", message: LOCAL_TASK_CHANGED };
179
+ }
180
+ for (const run of store.runsFor(ref.id)) {
181
+ if (store.stopQuiescenceFact(run.id) !== null)
182
+ return { ok: false, reason: "stopping", message: LOCAL_RETRY_STOPPING };
183
+ }
184
+ if (store.finalResultReason(pins.run) !== null) {
185
+ return { ok: false, reason: "final", message: "This result was accepted or published, so it isn't built again. Send it back with a note instead." };
186
+ }
187
+ const eligibility = requeueEligibility(store, taskId, now);
188
+ if (!eligibility.ok) {
189
+ return eligibility.reason === "archived" ? { ok: false, reason: "archived", message: "This task is put away. Restore it first." }
190
+ : eligibility.reason === "claimed" ? { ok: false, reason: "stopping", message: LOCAL_RETRY_STOPPING }
191
+ : { ok: false, reason: "refused", message: "This task has nothing to retry." };
192
+ }
193
+ if (eligibility.state !== "failed" && !(eligibility.state === "done" && eligibility.rejectedRun === pins.run)) {
194
+ return { ok: false, reason: "refused", message: "This task has nothing to retry." };
195
+ }
196
+ if (retried.length >= LOCAL_RETRY_LIMIT)
197
+ return { ok: false, reason: "limit", message: LOCAL_RETRY_SPENT };
198
+ const requeued = store.requeueTask(taskId, local.account, now);
199
+ if (!requeued.ok)
200
+ throw new Undone({ ok: false, reason: "refused", message: "This task has nothing to retry." });
201
+ store.recordAction({ at: now.toISOString(), actor: `operator:${local.account}`, repo: ref.repo, taskId, runId: pins.run, action: LOCAL_RETRY_ACTION,
202
+ outcome: "requeued", source: "request", detail: JSON.stringify({ v: 1, seal, via: local.transport }) });
203
+ return { ok: true, replayed: false, retries: retried.length + 1 };
204
+ });
205
+ }
206
+ catch (error) {
207
+ if (error instanceof Undone)
208
+ return error.result;
209
+ if (error instanceof Error && error.message === LOCAL_OWNER_LAPSED)
210
+ return { ok: false, reason: "local-owner", message: LOCAL_OWNER_LAPSED };
211
+ throw error;
212
+ }
213
+ }
214
+ /** A refusal found after a write began: thrown so the transaction rolls back, then answered. */
215
+ class Undone extends Error {
216
+ result;
217
+ constructor(result) {
218
+ super(result.message);
219
+ this.result = result;
220
+ }
221
+ }
@@ -10,7 +10,7 @@ export declare function authorityOf(value: unknown): Authority | null;
10
10
  * The acts a local owner may do (plan C1). Ids only: each surface (a route row, a chat action, a CLI command) names
11
11
  * the act it is, and an act not in this list is refused to a local owner.
12
12
  */
13
- export declare const LOCAL_OWNER_ACTS: readonly ["file-task", "lead-send", "approve-plan", "build-now", "answer-question", "stop-run", "pause-run", "resume-run", "complete-task", "request-revision", "archive-task", "add-project", "choose-default-agent", "mark-seen", "watch-agent", "read", "routing-turn-off", "dismiss-suggestion", "trial-stop", "code-map-switch", "memory-review"];
13
+ export declare const LOCAL_OWNER_ACTS: readonly ["file-task", "lead-send", "approve-plan", "build-now", "answer-question", "stop-run", "pause-run", "resume-run", "complete-task", "request-revision", "archive-task", "add-project", "choose-default-agent", "mark-seen", "watch-agent", "read", "routing-turn-off", "dismiss-suggestion", "trial-stop", "code-map-switch", "memory-review", "request-plan", "retry-build"];
14
14
  export type LocalOwnerAct = (typeof LOCAL_OWNER_ACTS)[number];
15
15
  /** Whether a runtime string names an act a local owner may do. */
16
16
  export declare function localOwnerAct(value: unknown): value is LocalOwnerAct;
@@ -122,3 +122,14 @@ export declare function localOwnerStandsNow(store: Pick<Store, "handle" | "accou
122
122
  export declare const LOCAL_OWNER_LAPSED = "This local sign-in no longer stands. Run `toolroll open` to sign in again.";
123
123
  /** What widening a launch's confinement hears while local mode is on (plan C5). */
124
124
  export declare const LOCAL_MODE_CONFINES = "Turn off local mode first.";
125
+ /** What a local owner's browser session hears from anywhere but this computer (another address, a proxy, a tailnet or
126
+ * LAN name): it works only on the computer running Toolroll (local-owner slice 0). */
127
+ export declare const LOCAL_OWNER_ELSEWHERE = "This sign-in works only on the computer running Toolroll. To use Toolroll from here, set a password on that computer (run `toolroll password set`) and sign in with it.";
128
+ /**
129
+ * The local owner acting now as exactly `act` (local-owner slice 1, `localActNow`), or null: the one in flight (its
130
+ * terminal command, or its browser request not yet answered), still standing (re-read here, so call it inside the
131
+ * write's own transaction), with exactly this act. A lead's token or a remote API run in the chain is never the local
132
+ * owner, so it is null there too. Every password-free writer that needs one act calls this; none relies on
133
+ * `proveAccountNow` saying yes to any act.
134
+ */
135
+ export declare function localActNow(store: Pick<Store, "handle" | "accountOf" | "webSession">, act: LocalOwnerAct): LocalPrincipal | null;
@@ -7,6 +7,8 @@
7
7
  * Types and pure helpers only: enforcement (route marks, the chat confirm gate, the prover's order) reads these.
8
8
  */
9
9
  import { AsyncLocalStorage } from "node:async_hooks";
10
+ import { currentActor } from "./actor.js";
11
+ import { activeRemote } from "./remote-run.js";
10
12
  export const AUTHORITIES = Object.freeze(["full", "local-owner"]);
11
13
  /** A runtime string as an authority; anything else is null (never "full" by default). */
12
14
  export function authorityOf(value) {
@@ -47,6 +49,12 @@ export const LOCAL_OWNER_ACTS = Object.freeze([
47
49
  // Learning loops (D3): deciding a lesson note or a clean-up of project memory. The writers (memory-pass.ts decideProposal,
48
50
  // memory-tidy.ts) check this exact act; instruction proposals stay with full authority.
49
51
  "memory-review",
52
+ // Local-owner slice 2 (docs/plans/local-owner-acts.md §1): ask for a plan. It approves nothing, pins no agent and is
53
+ // never pre-authorized under a mode; the writer (local-owner-acts.ts requestPlanAsLocalOwner) checks this exact act.
54
+ "request-plan",
55
+ // Local-owner slice 3 (§2): retry the owner's own approved build after it stopped. Only the owner's own person yes,
56
+ // on work it could approve again now, three times per seal; the writer (local-owner-acts.ts retryAsLocalOwner) checks it.
57
+ "retry-build",
50
58
  ]);
51
59
  const ACT_SET = new Set(LOCAL_OWNER_ACTS);
52
60
  /** Whether a runtime string names an act a local owner may do. */
@@ -239,3 +247,21 @@ export function localOwnerStandsNow(store, principal) {
239
247
  export const LOCAL_OWNER_LAPSED = "This local sign-in no longer stands. Run `toolroll open` to sign in again.";
240
248
  /** What widening a launch's confinement hears while local mode is on (plan C5). */
241
249
  export const LOCAL_MODE_CONFINES = "Turn off local mode first.";
250
+ /** What a local owner's browser session hears from anywhere but this computer (another address, a proxy, a tailnet or
251
+ * LAN name): it works only on the computer running Toolroll (local-owner slice 0). */
252
+ export const LOCAL_OWNER_ELSEWHERE = "This sign-in works only on the computer running Toolroll. To use Toolroll from here, set a password on that computer (run `toolroll password set`) and sign in with it.";
253
+ /**
254
+ * The local owner acting now as exactly `act` (local-owner slice 1, `localActNow`), or null: the one in flight (its
255
+ * terminal command, or its browser request not yet answered), still standing (re-read here, so call it inside the
256
+ * write's own transaction), with exactly this act. A lead's token or a remote API run in the chain is never the local
257
+ * owner, so it is null there too. Every password-free writer that needs one act calls this; none relies on
258
+ * `proveAccountNow` saying yes to any act.
259
+ */
260
+ export function localActNow(store, act) {
261
+ if (activeRemote() !== null || currentActor()?.lead === true)
262
+ return null;
263
+ const local = currentLocalOwner();
264
+ if (local === null || local.act !== act || !localOwnerInFlight(local) || !localOwnerStandsNow(store, local))
265
+ return null;
266
+ return local;
267
+ }
@@ -25,7 +25,8 @@ export const REMOTE_ARGUMENTS = new Map([
25
25
  ['task list', args('', 'state view limit cursor')],
26
26
  ['task show', args('id:task')],
27
27
  ['task wait', args('id:task', 'timeout')],
28
- ['task complete', args('task:task', 'digest', { run: 'run', 'pull-request': 'refused' })],
28
+ // --checked and --reason are the terminal's local owner's own (a person's check is never an API token's): refused.
29
+ ['task complete', args('task:task', 'digest', { run: 'run', 'pull-request': 'refused', checked: 'refused', reason: 'refused' })],
29
30
  ['task revise', args('task:task', 'feedback source key', { run: 'run' })],
30
31
  ['task state', args('id:task state', 'key reason', { 'replaced-by': 'task' })],
31
32
  ['task block', args('id:task', 'key', { on: 'task' })],
@@ -38,7 +39,8 @@ export const REMOTE_ARGUMENTS = new Map([
38
39
  ['task continue', args('id:task', 'request key', { run: 'run' })],
39
40
  ['task assign', args('id:task', 'key runner anyone')],
40
41
  ['task scope', args('id:task', 'key goal not touches budget-usd risk acceptance candidate model provider repair-model')],
41
- ['task plan', args('id:task', 'key provider model')],
42
+ // --digest is the terminal's local owner's own (its plan request binds to the source it read): refused remotely.
43
+ ['task plan', args('id:task', 'key provider model', { digest: 'refused' })],
42
44
  ['task hold', args('id:task', 'key reason until')],
43
45
  ['task unhold', args('id:task', 'key')],
44
46
  ['task require', args('id:task', 'key cap require')],