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/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,17 +23,18 @@ 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
28
  revision: number;
28
29
  stale: boolean;
29
30
  notice: string;
30
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
39
  stale?: never;
39
40
  decision: {
@@ -55,10 +56,10 @@ export declare function conversationKnowledge(store: Store, repo: string, actor:
55
56
  revision?: never;
56
57
  history?: never;
57
58
  instructions?: never;
58
- references?: never;
59
59
  decisions?: never;
60
60
  waiting?: never;
61
61
  } | {
62
+ references?: never;
62
63
  stale?: never;
63
64
  revision: number;
64
65
  title: string;
@@ -66,7 +67,6 @@ export declare function conversationKnowledge(store: Store, repo: string, actor:
66
67
  notice: string;
67
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;
@@ -82,10 +82,24 @@ export declare function releaseBaselineRecord(store: Store, repo: string): {
82
82
  recorded: true;
83
83
  sha: string | null;
84
84
  };
85
+ /** How long the change list's settings check may take before the list is unread. */
86
+ export declare const CONFIG_LIST_MS = 60000;
85
87
  /** The commits after `from` up to `to`, as verified Git objects only, from the project's recorded Git directory: the
86
88
  * release coordinator's checkout config allowlist first, then commit-extract's verified range (every commit hashed here,
87
89
  * a shallow boundary, grafts or replacement refs refused, and the range proved to stop only in the last release's own
88
- * history). Null when they can't be read, so a card never shows a shortened list. */
90
+ * history). When they can't be read, why, in Toolroll's own fixed words (never the repository's text), so a card says
91
+ * why it shows no list rather than a shortened one. */
92
+ export declare function readCommitsBetween(repo: string, from: string, to: string): {
93
+ ok: true;
94
+ commits: {
95
+ sha: string;
96
+ subject: string;
97
+ }[];
98
+ } | {
99
+ ok: false;
100
+ why: string;
101
+ };
102
+ /** readCommitsBetween's list, or null when it can't be read. */
89
103
  export declare function commitsBetween(repo: string, from: string, to: string): {
90
104
  sha: string;
91
105
  subject: string;
@@ -207,32 +207,52 @@ export function releaseBaselineRecord(store, repo) {
207
207
  const ref = row["external_ref"];
208
208
  return { recorded: true, sha: typeof ref === "string" && SHA.test(ref) ? ref : null };
209
209
  }
210
+ /** How long the change list's settings check may take before the list is unread. */
211
+ export const CONFIG_LIST_MS = 60_000;
210
212
  /** The commits after `from` up to `to`, as verified Git objects only, from the project's recorded Git directory: the
211
213
  * release coordinator's checkout config allowlist first, then commit-extract's verified range (every commit hashed here,
212
214
  * a shallow boundary, grafts or replacement refs refused, and the range proved to stop only in the last release's own
213
- * history). Null when they can't be read, so a card never shows a shortened list. */
214
- export function commitsBetween(repo, from, to) {
215
+ * history). When they can't be read, why, in Toolroll's own fixed words (never the repository's text), so a card says
216
+ * why it shows no list rather than a shortened one. */
217
+ export function readCommitsBetween(repo, from, to) {
215
218
  if (!SHA.test(from) || !SHA.test(to))
216
- return null;
219
+ return { ok: false, why: "a commit id isn't a full id" };
217
220
  let gitDir;
218
221
  try {
219
222
  const named = join(realpathSync(repo), ".git");
220
223
  if (!lstatSync(named).isDirectory())
221
- return null;
224
+ return { ok: false, why: "the project has no Git folder of its own" };
222
225
  gitDir = realpathSync(named);
223
226
  }
224
227
  catch {
225
- return null;
228
+ return { ok: false, why: "the project's Git folder can't be read" };
226
229
  }
227
230
  const tools = releaseTools();
228
231
  const env = { ...releaseGitEnv(), PATH: RELEASE_PATH };
229
- const listing = spawnSync(tools.git, [`--git-dir=${gitDir}`, "--no-optional-locks", "config", "--list", "--no-includes", "--show-scope", "-z"], { cwd: "/", env, encoding: "utf8", timeout: 15_000 });
230
- if (listing.status !== 0 || releaseConfigProblem(listing.stdout) !== null)
231
- return null;
232
+ // The list's first Git call took over 15 s on GitHub's busy macOS runners (2026-11-12, "timed out after 15 s"; the
233
+ // same call takes 0.1 s on an idle Mac), and the card lost its change list; the verified range's own Git calls allow
234
+ // far longer. One minute, then the list is unread, as before.
235
+ const listing = spawnSync(tools.git, [`--git-dir=${gitDir}`, "--no-optional-locks", "config", "--list", "--no-includes", "--show-scope", "-z"], { cwd: "/", env, encoding: "utf8", timeout: CONFIG_LIST_MS });
236
+ if (listing.status !== 0) {
237
+ const how = listing.error?.code === "ETIMEDOUT" ? `timed out after ${CONFIG_LIST_MS / 1000} s`
238
+ : listing.signal !== null ? `stopped by ${listing.signal}` : listing.error !== undefined ? "couldn't start" : `exited ${listing.status}`;
239
+ return { ok: false, why: `Git couldn't list the project's settings (${how})` };
240
+ }
241
+ // The key is the repository's own text: named only by kind, never echoed.
242
+ if (releaseConfigProblem(listing.stdout) !== null)
243
+ return { ok: false, why: "the project's Git settings hold an entry a release read refuses" };
232
244
  const range = verifiedRange(gitDir, from, to, { git: tools.git, env });
233
- if (!range.ok)
234
- return null;
235
- return range.value.map(one => ({ sha: one.id, subject: subjectText(one.message) }));
245
+ // commit-extract's own words, made one control-free line like any shown text, in the card's lower-case clause form.
246
+ if (!range.ok) {
247
+ const said = subjectText(range.message).replace(/\.$/, "");
248
+ return { ok: false, why: /^[A-Z][a-z]/.test(said) ? said[0].toLowerCase() + said.slice(1) : said };
249
+ }
250
+ return { ok: true, commits: range.value.map(one => ({ sha: one.id, subject: subjectText(one.message) })) };
251
+ }
252
+ /** readCommitsBetween's list, or null when it can't be read. */
253
+ export function commitsBetween(repo, from, to) {
254
+ const read = readCommitsBetween(repo, from, to);
255
+ return read.ok ? read.commits : null;
236
256
  }
237
257
  /** GitHub lists at most this many of a pull request's commits; a longer one is never shown as complete. */
238
258
  export const PULL_REQUEST_COMMITS_MAX = 250;
@@ -355,7 +375,10 @@ export function releaseChangeList(store, terms, read) {
355
375
  let source;
356
376
  let why;
357
377
  if (baseline !== null) {
358
- commits = commitsBetween(terms.repo, baseline, terms.approvedSha);
378
+ const read = readCommitsBetween(terms.repo, baseline, terms.approvedSha);
379
+ commits = read.ok ? read.commits : null;
380
+ if (!read.ok)
381
+ why = read.why;
359
382
  source = commits === null ? "unread" : "recorded";
360
383
  }
361
384
  else {
@@ -21,8 +21,12 @@ export declare function familyCompleted(store: Store, taskId: string): boolean;
21
21
  export declare function changedFilesOf(store: Store, runId: number, root: string): string[] | null;
22
22
  /** Everything a result's branch carries: this build's changes and every build of every task it revises (a
23
23
  * revision builds on its source's head, so its branch holds the source's commits too). null when any of
24
- * them can't be read whole. */
25
- export declare function familyChangedFiles(store: Store, taskId: string, runId: number, root: string): string[] | null;
24
+ * them can't be read whole. An earlier build with no recorded diff is skipped by default; with
25
+ * `missing: "unknown"` (the local owner's boundary) it is skipped only when its run record proves it committed
26
+ * nothing, and any other one makes the whole answer unknown. */
27
+ export declare function familyChangedFiles(store: Store, taskId: string, runId: number, root: string, options?: {
28
+ missing?: "skip" | "unknown";
29
+ }): string[] | null;
26
30
  /** Why a build's branch must wait before it's pushed (and so before any PR or merge): it (or a task it
27
31
  * revises) changed protected files on a one-person approval, and nobody but its approver and requesters
28
32
  * has marked it complete. */
@@ -42,10 +42,12 @@ export function changedFilesOf(store, runId, root) {
42
42
  }
43
43
  /** Everything a result's branch carries: this build's changes and every build of every task it revises (a
44
44
  * revision builds on its source's head, so its branch holds the source's commits too). null when any of
45
- * them can't be read whole. */
46
- export function familyChangedFiles(store, taskId, runId, root) {
45
+ * them can't be read whole. An earlier build with no recorded diff is skipped by default; with
46
+ * `missing: "unknown"` (the local owner's boundary) it is skipped only when its run record proves it committed
47
+ * nothing, and any other one makes the whole answer unknown. */
48
+ export function familyChangedFiles(store, taskId, runId, root, options = {}) {
47
49
  const files = new Set();
48
- const runs = new Set([runId]);
50
+ const runs = new Map([[runId, null]]);
49
51
  const chain = store.revisionAncestryStatus(taskId).chain;
50
52
  for (const id of chain.length === 0 ? [taskId] : chain) {
51
53
  const ref = store.lookupRef(id);
@@ -53,12 +55,16 @@ export function familyChangedFiles(store, taskId, runId, root) {
53
55
  continue;
54
56
  for (const run of store.runsFor(ref.id))
55
57
  if ((run.role === "builder" || run.role === "repair") && (id !== taskId || run.id === runId))
56
- runs.add(run.id);
58
+ runs.set(run.id, run);
57
59
  }
58
- for (const id of runs) {
60
+ for (const [id, run] of runs) {
59
61
  // A build that never recorded a diff contributed nothing to the branch; one whose diff can't be read counts as protected.
60
- if (!store.artifactsFor(id).some(one => one.kind === "diff-stat") && id !== runId)
61
- continue;
62
+ // Under "unknown", a missing inventory proves nothing by itself: only a finished run that committed nothing is skipped.
63
+ if (!store.artifactsFor(id).some(one => one.kind === "diff-stat") && id !== runId) {
64
+ if (options.missing !== "unknown" || (run?.committed === false && run.finishedAt !== null))
65
+ continue;
66
+ return null;
67
+ }
62
68
  const changed = changedFilesOf(store, id, root);
63
69
  if (changed === null)
64
70
  return null;
package/dist/scope.d.ts CHANGED
@@ -1044,7 +1044,7 @@ export declare function releaseApprovalOf(store: Store, taskId: string, _now?: D
1044
1044
  /** When a password typed at `at` (ISO) stops standing in, for a page's words (ISO); null when `at` isn't a time. Words
1045
1045
  * only: freshnessFor decides whether it stands in, at the moment of use. */
1046
1046
  export declare function freshUntil(at: string): string | null;
1047
- export type LocalApprovalRefusal = "local-mode-off" | "not-owner" | "no-scope" | "release" | "protected" | "escalated" | "coordinator" | "unsandboxed";
1047
+ export type LocalApprovalRefusal = "local-mode-off" | "not-owner" | "no-scope" | "release" | "protected" | "escalated" | "coordinator" | "unsandboxed" | "imported";
1048
1048
  /**
1049
1049
  * Whether the local owner may approve this task without a password (easy-start plan C5). ONE rule, read by the
1050
1050
  * preview (the task page's approve form and the chat review render no password field when it is ok), the chat
@@ -1065,3 +1065,34 @@ export declare function liveChainFromJson(json: string | null): ChainEntry[] | n
1065
1065
  /** The chain in full, the words a yes agrees to: order, agent, model, how it bills, what it may do, and whether Toolroll
1066
1066
  * can see it run out. Every approval surface shows these lines; empty for a scope with no chain. */
1067
1067
  export declare function chainTermsLines(scope: Pick<Scope, "proposedChainJson">): string[];
1068
+ /** The act a local owner's work rule is read for: approving a plan, or completing (accepting) a result whose build
1069
+ * changed `changed` (the build's own diff; null when it can't be read, which counts as protected). */
1070
+ export type LocalWorkPurpose = {
1071
+ act: "approve";
1072
+ } | {
1073
+ act: "complete";
1074
+ changed: readonly string[] | null;
1075
+ } | {
1076
+ act: "plan";
1077
+ } | {
1078
+ act: "retry";
1079
+ };
1080
+ /**
1081
+ * Whether the local owner may do this act on this task without a password: ONE rule (easy-start C5, split out for
1082
+ * local-owner slice 0), read inside the act's own transaction. Both acts refuse: local mode off or another person;
1083
+ * release or deploy work; protected or two-person work; work a coordinator filed (or a task with no built-in record).
1084
+ * Approving also refuses no plan, an escalated profile and a project stored as unsandboxed (what a build may do, which
1085
+ * is what an approval agrees to). Completing reads release work from the release records too, and protected work from
1086
+ * what the result actually changed as well as what its scope declared (v102's rule: declared paths are only a promise).
1087
+ * Asking for a plan (local-owner slice 2) reads approve's rule for a task that may have no scope yet (no declared paths
1088
+ * count as protected; the task's own permission mode counts as escalated), the release records too, and also refuses a
1089
+ * task from outside (an imported issue): a new act, so no existing act changes. Retrying (slice 3) is approve's rule
1090
+ * exactly (work the owner could approve again now), plus the release records and a task from outside.
1091
+ */
1092
+ export declare function localWorkEligible(store: Store, by: string, taskId: string, purpose: LocalWorkPurpose): {
1093
+ ok: true;
1094
+ } | {
1095
+ ok: false;
1096
+ reason: LocalApprovalRefusal;
1097
+ message: string;
1098
+ };
package/dist/scope.js CHANGED
@@ -1449,11 +1449,12 @@ function describeApproval(approval) {
1449
1449
  // ---- Only a person approves (docs/plans/approval-policy.md, phase 1). Kept last: the freshness tripwire pins places.
1450
1450
  export { personProof } from "./person-proof.js";
1451
1451
  import { personProof } from "./person-proof.js";
1452
+ import { isProtectedWork, protectedChanges } from "./approval-policy.js";
1452
1453
  import { permissionPlainWords } from "./chat-terms.js";
1453
1454
  import { limitRecognised } from "./exhaustion.js";
1454
1455
  import { SUBSCRIPTION_CAPABLE } from "./keys.js";
1455
1456
  import { releaseCheckPurposeDigest, releaseTermsDigest } from "./contracts/release-terms.js";
1456
- import { CHECK_ACCESS_WIDTH, autoEvidenceOf, carriesUnsandboxed, newestReleaseGeneration, releaseAuthorityOfTask, releaseAutoApproves, releaseSealProblem, releaseStateAt } from "./release-authority.js";
1457
+ import { CHECK_ACCESS_WIDTH, autoEvidenceOf, belongsToRelease, carriesUnsandboxed, newestReleaseGeneration, releaseAuthorityOfTask, releaseAutoApproves, releaseSealProblem, releaseStateAt } from "./release-authority.js";
1457
1458
  /** Whether the command running now is a machine's: the lead (its token) or a remote API run. Trusted context only. */
1458
1459
  export const machineActing = () => currentActor()?.lead === true || activeRemote() !== null;
1459
1460
  /** A proof row's detail, either form: the legacy text (`<at> · generation <n> · digest <d>`) or the versioned record
@@ -1709,6 +1710,8 @@ const LOCAL_APPROVAL_WORDS = {
1709
1710
  escalated: "Work with wider permissions needs your password.",
1710
1711
  coordinator: "Work a coordinator filed needs your password.",
1711
1712
  unsandboxed: "This project's checks run without a sandbox, so approving needs your password.",
1713
+ // Not an approve refusal (approve keeps today's rule); the type needs words for it.
1714
+ imported: "A task from outside needs your password.",
1712
1715
  };
1713
1716
  /**
1714
1717
  * Whether the local owner may approve this task without a password (easy-start plan C5). ONE rule, read by the
@@ -1719,30 +1722,8 @@ const LOCAL_APPROVAL_WORDS = {
1719
1722
  * a scout proposed are allowed.
1720
1723
  */
1721
1724
  export function localApprovalEligible(store, by, taskId) {
1722
- const no = (reason) => ({ ok: false, reason, message: LOCAL_APPROVAL_WORDS[reason] });
1723
- if (!localModeOn(store))
1724
- return no("local-mode-off");
1725
- const owner = store.handle.prepare("SELECT owner FROM local_mode WHERE id = 1").get()?.["owner"];
1726
- if (owner !== by)
1727
- return no("not-owner");
1728
- const scope = store.getScope(taskId);
1729
- if (scope === null)
1730
- return no("no-scope");
1731
- if (deployGateScope(scope) || releaseCheckScope(scope))
1732
- return no("release");
1733
- const gate = store.approvalGate(taskId, by, "person");
1734
- if (gate.verdict !== "seal" || gate.protectedWork)
1735
- return no("protected");
1736
- if (scope.profile == null || levelOfProfile(scope.profile) === "escalated")
1737
- return no("escalated");
1738
- const ref = store.handle.prepare("SELECT repo, coordinator_cid FROM task_ref WHERE backend = 'built-in' AND external_id = ?").get(taskId);
1739
- if (ref === undefined || ref["coordinator_cid"] != null)
1740
- return no("coordinator");
1741
- // The stored level, never the launch cap: a project saved as unsandboxed is refused even though local mode confines it.
1742
- const stored = ref["repo"] == null ? undefined : store.handle.prepare("SELECT level FROM project_check_access WHERE repo = ?").get(ref["repo"])?.["level"];
1743
- if (stored === "unsandboxed" || scope.profile.checkAccess === "unsandboxed")
1744
- return no("unsandboxed");
1745
- return { ok: true };
1725
+ // The one rule, shared with completing a result (localWorkEligible, below).
1726
+ return localWorkEligible(store, by, taskId, { act: "approve" });
1746
1727
  }
1747
1728
  // ---- Fallback chains (fallback.ts). Kept last too.
1748
1729
  /** A chain that may dispatch: version 2, a base and at least one fallback. An older chain is history, never this. */
@@ -1768,3 +1749,107 @@ export function chainTermsLines(scope) {
1768
1749
  }),
1769
1750
  ];
1770
1751
  }
1752
+ // ---- Local owner, slice 0 (docs/plans/local-owner-acts.md): one work rule for a no-password act. Kept last.
1753
+ /** What a local owner hears when a result isn't one it may accept without a password. */
1754
+ const LOCAL_COMPLETION_WORDS = {
1755
+ "local-mode-off": "Local mode is off, so accepting this result needs your password.",
1756
+ "not-owner": "Only this computer's owner accepts results here without a password.",
1757
+ "no-scope": "This task has no plan, so accepting its result needs your password.",
1758
+ release: "Accepting a release needs your password.",
1759
+ protected: "This result is protected work, so accepting it needs your password and a second person.",
1760
+ escalated: "Work with wider permissions needs your password.",
1761
+ coordinator: "Work a coordinator filed needs your password to accept.",
1762
+ unsandboxed: "This project's checks run without a sandbox, so accepting needs your password.",
1763
+ // Not a completion refusal (completion keeps slice 0's rule); the type needs words for it.
1764
+ imported: "A task from outside needs your password to accept.",
1765
+ };
1766
+ /** What a local owner hears when a task isn't one it may ask a plan for without a password (local-owner slice 2). */
1767
+ const LOCAL_PLAN_WORDS = {
1768
+ "local-mode-off": "Local mode is off, so asking for a plan needs your password.",
1769
+ "not-owner": "Only this computer's owner asks for plans here without a password.",
1770
+ "no-scope": "This task has nothing to plan yet.",
1771
+ release: "Planning a release needs your password.",
1772
+ protected: "This is protected work, so planning it needs your password.",
1773
+ escalated: "Work with wider permissions needs your password to plan.",
1774
+ coordinator: "Work a coordinator filed needs your password to plan.",
1775
+ unsandboxed: "This project's checks run without a sandbox, so planning needs your password.",
1776
+ imported: "A task from outside needs your password to plan.",
1777
+ };
1778
+ /** What a local owner hears when a build isn't one it may retry without a password (local-owner slice 3). */
1779
+ const LOCAL_RETRY_WORDS = {
1780
+ "local-mode-off": "Local mode is off, so retrying needs your password.",
1781
+ "not-owner": "Only this computer's owner retries builds here without a password.",
1782
+ "no-scope": "This task has no approved plan to build again.",
1783
+ release: "Retrying a release needs your password.",
1784
+ protected: "This is protected work, so retrying it needs your password.",
1785
+ escalated: "Work with wider permissions needs your password to retry.",
1786
+ coordinator: "Work a coordinator filed needs your password to retry.",
1787
+ unsandboxed: "This project's checks run without a sandbox, so retrying needs your password.",
1788
+ imported: "A task from outside needs your password to retry.",
1789
+ };
1790
+ /**
1791
+ * Whether the local owner may do this act on this task without a password: ONE rule (easy-start C5, split out for
1792
+ * local-owner slice 0), read inside the act's own transaction. Both acts refuse: local mode off or another person;
1793
+ * release or deploy work; protected or two-person work; work a coordinator filed (or a task with no built-in record).
1794
+ * Approving also refuses no plan, an escalated profile and a project stored as unsandboxed (what a build may do, which
1795
+ * is what an approval agrees to). Completing reads release work from the release records too, and protected work from
1796
+ * what the result actually changed as well as what its scope declared (v102's rule: declared paths are only a promise).
1797
+ * Asking for a plan (local-owner slice 2) reads approve's rule for a task that may have no scope yet (no declared paths
1798
+ * count as protected; the task's own permission mode counts as escalated), the release records too, and also refuses a
1799
+ * task from outside (an imported issue): a new act, so no existing act changes. Retrying (slice 3) is approve's rule
1800
+ * exactly (work the owner could approve again now), plus the release records and a task from outside.
1801
+ */
1802
+ export function localWorkEligible(store, by, taskId, purpose) {
1803
+ const words = purpose.act === "approve" ? LOCAL_APPROVAL_WORDS : purpose.act === "complete" ? LOCAL_COMPLETION_WORDS : purpose.act === "plan" ? LOCAL_PLAN_WORDS : LOCAL_RETRY_WORDS;
1804
+ const no = (reason) => ({ ok: false, reason, message: words[reason] });
1805
+ if (!localModeOn(store))
1806
+ return no("local-mode-off");
1807
+ const owner = store.handle.prepare("SELECT owner FROM local_mode WHERE id = 1").get()?.["owner"];
1808
+ if (owner !== by)
1809
+ return no("not-owner");
1810
+ const scope = store.getScope(taskId);
1811
+ if ((purpose.act === "approve" || purpose.act === "retry") && scope === null)
1812
+ return no("no-scope");
1813
+ if (scope !== null && (deployGateScope(scope) || releaseCheckScope(scope)))
1814
+ return no("release");
1815
+ if (purpose.act !== "approve" && (belongsToRelease(store, taskId) || releaseAuthorityOfTask(store, taskId) !== null))
1816
+ return no("release");
1817
+ const ref = store.handle.prepare("SELECT repo, coordinator_cid, permission_mode FROM task_ref WHERE backend = 'built-in' AND external_id = ?").get(taskId);
1818
+ // Retrying spends again under an existing yes: only on work the owner could approve again now (approve's own rule).
1819
+ if (purpose.act === "approve" || purpose.act === "retry") {
1820
+ const gate = store.approvalGate(taskId, by, "person");
1821
+ if (gate.verdict !== "seal" || gate.protectedWork)
1822
+ return no("protected");
1823
+ if (scope.profile == null || levelOfProfile(scope.profile) === "escalated")
1824
+ return no("escalated");
1825
+ }
1826
+ else if (purpose.act === "plan") {
1827
+ // Approve's rule, read for a task that may have no scope yet: protected or two-person work (no declared paths count
1828
+ // as protected), and an escalated profile or task-level permission mode.
1829
+ const gate = store.approvalGate(taskId, by, "person");
1830
+ if (gate.verdict !== "seal" || gate.protectedWork)
1831
+ return no("protected");
1832
+ if ((scope?.profile != null && levelOfProfile(scope.profile) === "escalated") || ref?.["permission_mode"] === "bypassPermissions")
1833
+ return no("escalated");
1834
+ }
1835
+ else if (ref !== undefined && ref["repo"] != null) {
1836
+ // Declared (no declared paths counts as protected) or actually changed (an unreadable diff counts as changed).
1837
+ const rules = store.approvalRules(String(ref["repo"]));
1838
+ if ((rules.protectProject || rules.protectedPaths.length > 0)
1839
+ && (isProtectedWork(rules, scope?.touches ?? []) || purpose.changed === null || protectedChanges(rules, purpose.changed).length > 0))
1840
+ return no("protected");
1841
+ }
1842
+ if (ref === undefined || ref["coordinator_cid"] != null)
1843
+ return no("coordinator");
1844
+ // What the build may do was agreed at its approval; a result already built is judged by what it is and changed.
1845
+ if (purpose.act === "complete")
1846
+ return { ok: true };
1847
+ // Asking for a plan (a new act, so no behaviour changes): never a task from outside (a GitHub import, or a revision of one).
1848
+ if ((purpose.act === "plan" || purpose.act === "retry") && issueImported(store, taskId))
1849
+ return no("imported");
1850
+ // The stored level, never the launch cap: a project saved as unsandboxed is refused even though local mode confines it.
1851
+ const stored = ref["repo"] == null ? undefined : store.handle.prepare("SELECT level FROM project_check_access WHERE repo = ?").get(ref["repo"])?.["level"];
1852
+ if (stored === "unsandboxed" || scope?.profile?.checkAccess === "unsandboxed")
1853
+ return no("unsandboxed");
1854
+ return { ok: true };
1855
+ }