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
@@ -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')],
package/dist/operate.js CHANGED
@@ -89,7 +89,8 @@ import { runKnowledgeApply, runKnowledgeCommand } from "./knowledge-cli.js";
89
89
  import { FLOWS_VALUE_FLAGS, runFlowsCommand } from "./flows-cli.js";
90
90
  import { runAssignmentCommand } from "./assignment-adapters.js";
91
91
  import { applyProjectProfile, runProjectCommand } from "./project-cli.js";
92
- import { pullRequestLines, runTaskMergeCommand, runTaskOutcomeCommand } from "./task-outcome-cli.js";
92
+ import { proveTaskBuildsStopped, pullRequestLines, runLocalCompletion, runTaskMergeCommand, runTaskOutcomeCommand } from "./task-outcome-cli.js";
93
+ import { requestPlanAsLocalOwner, retryAsLocalOwner } from "./local-owner-acts.js";
93
94
  import { withTaskAlias } from "./task-aliases.js";
94
95
  import { checklistLines, mergeChecklistOf } from "./merge-checklist.js";
95
96
  import { taskReviewBrief, renderReviewBrief } from "./task-review-brief.js";
@@ -821,6 +822,8 @@ export const OPERATE_BOOLEAN_FLAGS = new Set([
821
822
  "saved",
822
823
  // task complete: also open the result's pull request.
823
824
  "pull-request",
825
+ // task complete as the terminal's local owner: the person says they checked the result themselves.
826
+ "checked",
824
827
  // storage clean --landed: work already in main.
825
828
  "landed",
826
829
  // up: the exact start-up details (containment) in the terminal too.
@@ -8864,6 +8867,10 @@ function taskAction(action, rest, flags, context) {
8864
8867
  switch (action) {
8865
8868
  case "complete":
8866
8869
  case "revise":
8870
+ // Local-owner slice 1: the terminal's local owner accepts on its own road, which never checks a password; chosen
8871
+ // only inside its chain, where an owner that no longer stands is refused there, never sent down the road below.
8872
+ if (action === "complete" && currentLocalOwner() !== null)
8873
+ return runLocalCompletion(rest, flags, context);
8867
8874
  return runTaskOutcomeCommand(action, rest, flags, { ...context, operator: async () => {
8868
8875
  const acting = await askCredentials(flags, context);
8869
8876
  if (acting === null)
@@ -8937,6 +8944,10 @@ function taskAction(action, rest, flags, context) {
8937
8944
  case "require":
8938
8945
  return requireTask(rest, flags, context);
8939
8946
  case "requeue":
8947
+ // Local-owner slice 3: the terminal's local owner retries its own approved build on its own road (no password);
8948
+ // chosen only inside its chain, where an owner that no longer stands is refused there.
8949
+ if (currentLocalOwner() !== null)
8950
+ return localRetryCommand(rest, flags, context);
8940
8951
  return requeueTask(rest, flags, context);
8941
8952
  case "regate":
8942
8953
  return regateTaskCommand(rest, flags, context);
@@ -8947,6 +8958,10 @@ function taskAction(action, rest, flags, context) {
8947
8958
  case "failing-first":
8948
8959
  return failingFirstCommand(rest, followThroughContext(context, flags));
8949
8960
  case "plan":
8961
+ // Local-owner slice 2: the terminal's local owner asks for a plan on its own road (no password, no pins, no mode
8962
+ // pre-authorization); chosen only inside its chain, where an owner that no longer stands is refused there.
8963
+ if (currentLocalOwner() !== null)
8964
+ return localPlanCommand(rest, flags, context);
8950
8965
  return planTaskCommand(rest, flags, context);
8951
8966
  case "review":
8952
8967
  return reviewTaskCommand(rest, flags, context);
@@ -14340,3 +14355,63 @@ function releaseToQueue(store, lease, now) {
14340
14355
  return result;
14341
14356
  });
14342
14357
  }
14358
+ // ---- Local owner, slice 2 (docs/plans/local-owner-acts.md §1): ask for a plan in the terminal. Kept last.
14359
+ /**
14360
+ * `task plan <id> --digest <planning source>` for the laptop's local owner (the terminal UI's `p`, and `n` on a bigger
14361
+ * task): its own road, which never reaches a password check. The digest is the planning source the screen read
14362
+ * (local-owner-acts.ts planSourceOf), required and only compared. No plan pins (`--provider`, `--model`): choosing which
14363
+ * agent plans needs a password. requestPlanAsLocalOwner proves the rest in one transaction.
14364
+ */
14365
+ async function localPlanCommand(positional, flags, context) {
14366
+ const { store, write, json, clock } = context;
14367
+ const command = "task plan";
14368
+ if (flags.has("provider") || flags.has("model")) {
14369
+ const passwordSet = store.accountOf(currentLocalOwner()?.account ?? "")?.passwordSet === true;
14370
+ return fail(write, json, command, "local-owner", `Choosing which agent plans needs your password. ${passwordSet ? "Sign in with your password to do this." : "Set a password first: run \`toolroll password set\`."}`, EXIT.refused);
14371
+ }
14372
+ for (const name of flags.keys())
14373
+ if (!["json", "db", "digest"].includes(name))
14374
+ return fail(write, json, command, "usage", `--${name} is not a ${command} option.`, EXIT.usage);
14375
+ const id = positional[0], digest = flags.get("digest");
14376
+ const usage = "Use task plan <task> --digest <the plan source you read>.";
14377
+ if (positional.length !== 1 || !id || id.length > 64 || /[\x00-\x1f]/.test(id))
14378
+ return fail(write, json, command, "usage", usage, EXIT.usage);
14379
+ if (typeof digest !== "string" || !/^[a-f0-9]{64}$/.test(digest))
14380
+ return fail(write, json, command, "usage", `--digest needs the exact task you read. ${usage}`, EXIT.usage);
14381
+ const done = requestPlanAsLocalOwner(store, id, digest, clock());
14382
+ if (!done.ok)
14383
+ return fail(write, json, command, done.reason, done.message, EXIT.refused);
14384
+ return succeed(write, json, command, { id, replayed: done.replayed, autoPlan: false }, () => [
14385
+ done.replayed ? `${id} · Already asked for a plan.` : `${id} · Asked for a plan. You approve the plan before anything is built.`,
14386
+ ]);
14387
+ }
14388
+ // ---- Local owner, slice 3 (docs/plans/local-owner-acts.md §2): retry its own approved build in the terminal. Kept last.
14389
+ /**
14390
+ * `task requeue <id> --run <build> --digest <scope digest>` (and its alias `task retry`) for the laptop's local owner (the
14391
+ * terminal UI's `r`): its own road, which never reaches a password check. Both pins are required and only compared:
14392
+ * the failed build and the approved scope the screen read. Toolroll's own stop proof runs first, as every completion
14393
+ * does; retryAsLocalOwner then proves everything again in one transaction.
14394
+ */
14395
+ async function localRetryCommand(positional, flags, context) {
14396
+ const { store, write, json, clock } = context;
14397
+ const command = "task requeue";
14398
+ for (const name of flags.keys())
14399
+ if (!["json", "db", "run", "digest"].includes(name))
14400
+ return fail(write, json, command, "usage", `--${name} is not a ${command} option.`, EXIT.usage);
14401
+ const id = positional[0], run = flags.get("run"), digest = flags.get("digest");
14402
+ const usage = "Use task requeue <task> --run <the build you read> --digest <its approved plan>.";
14403
+ if (positional.length !== 1 || !id || id.length > 64 || /[\x00-\x1f]/.test(id))
14404
+ return fail(write, json, command, "usage", usage, EXIT.usage);
14405
+ if (typeof run !== "string" || !/^[1-9]\d{0,14}$/.test(run))
14406
+ return fail(write, json, command, "usage", `--run needs the exact build you read. ${usage}`, EXIT.usage);
14407
+ // A scope digest (scope.ts digestOf): 32 hexadecimal characters.
14408
+ if (typeof digest !== "string" || !/^[a-f0-9]{32}$/.test(digest))
14409
+ return fail(write, json, command, "usage", `--digest needs the exact plan you read. ${usage}`, EXIT.usage);
14410
+ await proveTaskBuildsStopped(store, id, clock);
14411
+ const done = retryAsLocalOwner(store, id, { run: Number(run), digest }, clock());
14412
+ if (!done.ok)
14413
+ return fail(write, json, command, done.reason, done.message, EXIT.refused);
14414
+ return succeed(write, json, command, { id, replayed: done.replayed, retries: done.retries }, () => [
14415
+ done.replayed ? `${id} · Already retrying build #${run}.` : `${id} · Retrying build #${run}. A builder starts it next.`,
14416
+ ]);
14417
+ }
package/dist/plan-auto.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { releaseUnattendedRun, reserveUnattendedRun, unattendedGrantStands } from "./flow-unattended.js";
2
2
  import { modeTermsFromJson } from "./modes.js";
3
+ import { currentLocalOwner } from "./local-owner.js";
3
4
  import { plannerContractOf, plannerSourceDigest } from "./planner-source.js";
4
5
  import { modeFilingCoverage, propose, quarantinedAuthor } from "./scope.js";
5
6
  export const PLAN_AUTO_SCHEMA = `CREATE TABLE IF NOT EXISTS plan_authorization (
@@ -37,6 +38,10 @@ export function applyModeToNewFiling(store, taskId, actor, now) {
37
38
  * planner text. Rewrites, mode renewal, expiry and revocation fail closed. */
38
39
  export function authorizePlanUnderMode(store, taskId, actor, now) {
39
40
  return store.transact(() => {
41
+ // A laptop's local owner (no password) never pre-authorizes a plan under a mode, on any road (filing, the plan
42
+ // button, chat, the terminal): its plan waits for its own approval (local-owner slice 0).
43
+ if (currentLocalOwner() !== null)
44
+ return false;
40
45
  const ref = store.lookupRef(taskId);
41
46
  if (ref === null || ref.repo === null || ref.plan !== "requested" || !plainPlan(store, taskId) || store.hasLiveClaim(ref.id, now))
42
47
  return false;
@@ -23,19 +23,19 @@ export declare function knowledgeVersion(store: Store, repo: string, actor: stri
23
23
  /** Chat reads only an admitted project. Keep the full source in the project UI. */
24
24
  export declare function conversationKnowledge(store: Store, repo: string, actor: string, reference?: string, decision?: number): {
25
25
  title?: never;
26
+ references?: never;
26
27
  content?: never;
27
- history?: never;
28
28
  revision: number;
29
29
  stale: boolean;
30
30
  notice: string;
31
+ history?: never;
31
32
  instructions?: never;
32
- references?: never;
33
33
  decisions?: never;
34
34
  waiting?: never;
35
35
  } | {
36
36
  title?: never;
37
+ references?: never;
37
38
  content?: never;
38
- history?: never;
39
39
  stale?: never;
40
40
  decision: {
41
41
  repo: string;
@@ -54,19 +54,19 @@ export declare function conversationKnowledge(store: Store, repo: string, actor:
54
54
  afterwards?: string;
55
55
  notice: string;
56
56
  revision?: never;
57
+ history?: never;
57
58
  instructions?: never;
58
- references?: never;
59
59
  decisions?: never;
60
60
  waiting?: never;
61
61
  } | {
62
- history?: never;
62
+ references?: never;
63
63
  stale?: never;
64
64
  revision: number;
65
65
  title: string;
66
66
  content: string;
67
67
  notice: string;
68
+ history?: never;
68
69
  instructions?: never;
69
- references?: never;
70
70
  decisions?: never;
71
71
  waiting?: never;
72
72
  } | {
@@ -50,3 +50,12 @@ export declare function carriesToken(pathname: string, authorization: string | u
50
50
  export declare function plainHttpRefusal(transport: Transport, pathname: string, authorization: string | undefined): string | null;
51
51
  /** The Strict-Transport-Security value for a request to the configured public https host that arrived over HTTPS. */
52
52
  export declare function hstsFor(publicHost: string | null, host: string | undefined, transport: Transport): string | null;
53
+ /** This computer, and nothing in front of it: a loopback peer that names a loopback address, with no sign of a proxy —
54
+ * any forwarding header at all disqualifies the request. serve.ts's fromThisComputer, and guards.ts's check on every
55
+ * request a local owner's session makes (local-owner slice 0), read it here. */
56
+ export declare function requestFromThisComputer(request: {
57
+ socket: {
58
+ remoteAddress?: string | undefined;
59
+ };
60
+ headers: Record<string, string | string[] | undefined>;
61
+ }): boolean;
@@ -89,3 +89,18 @@ export function plainHttpRefusal(transport, pathname, authorization) {
89
89
  export function hstsFor(publicHost, host, transport) {
90
90
  return publicHost !== null && host === publicHost && transport.https ? HSTS_VALUE : null;
91
91
  }
92
+ /** Any forwarding header at all (Forwarded, X-Forwarded-*, X-Real-IP, Via, …): a sign of a proxy in front. */
93
+ 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;
94
+ /** This computer, and nothing in front of it: a loopback peer that names a loopback address, with no sign of a proxy —
95
+ * any forwarding header at all disqualifies the request. serve.ts's fromThisComputer, and guards.ts's check on every
96
+ * request a local owner's session makes (local-owner slice 0), read it here. */
97
+ export function requestFromThisComputer(request) {
98
+ const peer = request.socket.remoteAddress ?? "";
99
+ if (!LOOPBACK_PEERS.has(peer))
100
+ return false;
101
+ for (const name of Object.keys(request.headers))
102
+ if (PROXY_HEADER.test(name))
103
+ return false;
104
+ const host = request.headers["host"];
105
+ return /^(localhost|127\.0\.0\.1|\[::1\]):[0-9]{1,5}$/.test(typeof host === "string" ? host : "");
106
+ }
@@ -23,6 +23,7 @@ import { assignmentOf, checkAssignmentAsOperator } from "./assignment.js";
23
23
  import { requestResultChanges } from "./result-actions.js";
24
24
  import { revisionSourceOf } from "./result-review.js";
25
25
  import { authenticateApprover, personProof } from "./scope.js";
26
+ import { currentLocalOwner } from "./local-owner.js";
26
27
  import { BRANCH_PREFIX, headWithin } from "./names.js";
27
28
  import { NIGHTLY_CHECK_ONLY, RELEASE_MERGES_ONLY, nightlyTaskRef, refArchived, releaseTaskRef } from "./store.js";
28
29
  import { belongsToRelease } from "./release-authority.js";
@@ -167,6 +168,11 @@ export function pullRequestBlocker(store, runId) {
167
168
  */
168
169
  export function completeAndOpenPullRequest(store, input, now) {
169
170
  return store.transact(() => {
171
+ // A laptop's local owner (no password) never opens a pull request: it publishes outside this computer. Refused
172
+ // before anything is read or written, whatever the result (local-owner slice 0).
173
+ const local = currentLocalOwner();
174
+ if (local !== null)
175
+ return refuse("local-owner", `Opening a pull request needs your password. ${store.accountOf(local.account)?.passwordSet === true ? "Sign in with your password to do this." : "Set a password first: run \`toolroll password set\`."}`);
170
176
  const current = assignmentOf(store, input.taskId, now, { principal: "operator", repos: input.who.repos }, input.root);
171
177
  const receipt = current?.receipt ?? null;
172
178
  if (current === null || receipt === null || receipt.runId !== input.runId || receipt.digest !== input.digest) {
@@ -191,6 +197,9 @@ export function completeAndOpenPullRequest(store, input, now) {
191
197
  */
192
198
  export function owePullRequest(store, runId, actor, flowCard, now) {
193
199
  return store.transact(() => {
200
+ // The belt under every road that owes one: a local owner's chain never does (local-owner slice 0).
201
+ if (currentLocalOwner() !== null)
202
+ throw new Error("Opening a pull request needs your password.");
194
203
  const run = store.getRun(runId);
195
204
  const ref = store.refById(run.taskRef);
196
205
  const grant = publicationGrantOf(store, run.taskRef).grant;
@@ -1,22 +1,8 @@
1
1
  import { type PullRequestReader, type ReleaseCard, type ReleaseSeal, type TermLine } from "./release-card.js";
2
2
  import { type ApproveResult, type Scope } from "./scope.js";
3
3
  import { type Notification, type Store } from "./store.js";
4
- /** The ledger act holding the coordinator's latest reading of the pull request's CI on the exact candidate. */
5
- export declare const RELEASE_CI_SEEN_ACTION = "release CI observed";
6
- /** One check's newest attempt, as the coordinator's own gh read it. A re-run is a new check run id. */
7
- export type ReleaseCiRun = {
8
- id: number | null;
9
- name: string;
10
- status: string;
11
- conclusion: string | null;
12
- };
13
- export type ReleaseCiSnapshot = {
14
- candidate: string;
15
- at: string;
16
- state: "passed" | "running" | "failed";
17
- failed: string[];
18
- runs: ReleaseCiRun[];
19
- };
4
+ import { type ReleaseCiSnapshot } from "./release-refusal.js";
5
+ export { ciWords, releaseApprovalRefusal, releaseCiOf, RELEASE_CI_SEEN_ACTION, type ReleaseCiRun, type ReleaseCiSnapshot } from "./release-refusal.js";
20
6
  /**
21
7
  * Record what the coordinator just read of the pull request's CI on `candidate` (its verdict and every check's newest
22
8
  * attempt), when it differs from the last reading recorded: the sheet and the CLI show it, and the card digest binds
@@ -35,13 +21,9 @@ export declare function recordReleaseCi(store: Store, input: {
35
21
  }[];
36
22
  now: Date;
37
23
  }): boolean;
38
- /** The coordinator's latest CI reading for this task on exactly `candidate`, or null (not read yet, or another commit). */
39
- export declare function releaseCiOf(store: Store, taskId: string, candidate: string): ReleaseCiSnapshot | null;
40
24
  /** What of a CI reading the card binds: its state and each check's attempt, whether it finished, and how. Queued and
41
25
  * in progress read alike (neither is finished); the time it was read is not bound. */
42
26
  export declare function ciBinding(ci: ReleaseCiSnapshot | null): unknown;
43
- /** GitHub CI in one line, as the sheet and the CLI say it. */
44
- export declare function ciWords(ci: ReleaseCiSnapshot | null, candidate: string): string;
45
27
  export type ReleaseApprovalCard = {
46
28
  taskId: string;
47
29
  taskRef: number;
@@ -189,3 +171,10 @@ export declare function waitingJournals(stateDir: string, expectedFile: (branch:
189
171
  journals: WaitingJournal[];
190
172
  unreadable: string[];
191
173
  };
174
+ /** A release as Home names it (docs/plans/home-other-asks.md, part 1): its version, and why it can't take a yes now
175
+ * in the sheet's own words (`refusalOf`, the one source), or null when this isn't a release. Reads only; Home never
176
+ * approves one, it links to the sheet. */
177
+ export declare function releaseAskOf(store: Store, taskId: string): {
178
+ version: string;
179
+ refusal: string | null;
180
+ } | null;