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
@@ -19,20 +19,19 @@ import { lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs";
19
19
  import { join } from "node:path";
20
20
  import { permissionPlainWords } from "./chat-terms.js";
21
21
  import { approvalTermsOf, releaseAccessWords, releaseCardOf, releaseSealOf } from "./release-card.js";
22
- import { releaseCiHoldOf, releaseCiHoldWords } from "./release-ci-hold.js";
23
22
  import { approvalOf } from "./scope.js";
24
23
  import { releaseProgressFor } from "./store.js";
25
24
  import { taskArchived } from "./task-archive.js";
26
25
  import { cleanTerminalLine } from "./terminal-text.js";
26
+ import { ciName, ciWords, refusalOf, releaseCiOf, RELEASE_CI_SEEN_ACTION } from "./release-refusal.js";
27
+ // The CI reading and the refusals live in release-refusal.ts, so scope.ts approve() can read them without a cycle.
28
+ export { ciWords, releaseApprovalRefusal, releaseCiOf, RELEASE_CI_SEEN_ACTION } from "./release-refusal.js";
27
29
  const SHA = /^[0-9a-f]{40}$/;
28
30
  const DIGEST = /^[0-9a-f]{64}$/;
29
31
  /** A scope digest as scope.ts writes it. */
30
32
  const SCOPE_DIGEST = /^[0-9a-f]{32,64}$/;
31
33
  const sha256 = (text) => createHash("sha256").update(text).digest("hex");
32
34
  // ---- GitHub CI as the coordinator read it -------------------------------------------------------------------------
33
- /** The ledger act holding the coordinator's latest reading of the pull request's CI on the exact candidate. */
34
- export const RELEASE_CI_SEEN_ACTION = "release CI observed";
35
- const ciName = (value) => cleanTerminalLine(String(value ?? "")).trim().slice(0, 120);
36
35
  /**
37
36
  * Record what the coordinator just read of the pull request's CI on `candidate` (its verdict and every check's newest
38
37
  * attempt), when it differs from the last reading recorded: the sheet and the CLI show it, and the card digest binds
@@ -58,60 +57,11 @@ export function recordReleaseCi(store, input) {
58
57
  action: RELEASE_CI_SEEN_ACTION, outcome: input.state, source: "work", detail });
59
58
  return true;
60
59
  }
61
- /** The coordinator's latest CI reading for this task on exactly `candidate`, or null (not read yet, or another commit). */
62
- export function releaseCiOf(store, taskId, candidate) {
63
- const row = store.handle.prepare("SELECT detail, at FROM action_ledger WHERE task_id = ? AND action = ? ORDER BY id DESC LIMIT 1").get(taskId, RELEASE_CI_SEEN_ACTION);
64
- if (row === undefined)
65
- return null;
66
- let parsed;
67
- try {
68
- parsed = JSON.parse(String(row["detail"]));
69
- }
70
- catch {
71
- return null;
72
- }
73
- const one = parsed;
74
- if (one.v !== 1 || one.candidate !== candidate || !["passed", "running", "failed"].includes(String(one.state)) || !Array.isArray(one.failed) || !Array.isArray(one.runs))
75
- return null;
76
- return { candidate, at: String(row["at"]), state: one.state, failed: one.failed.map(ciName),
77
- runs: one.runs.map(run => { const r = run; return { id: typeof r["id"] === "number" ? r["id"] : null, name: ciName(r["name"]), status: ciName(r["status"]), conclusion: r["conclusion"] == null ? null : ciName(r["conclusion"]) }; }) };
78
- }
79
60
  /** What of a CI reading the card binds: its state and each check's attempt, whether it finished, and how. Queued and
80
61
  * in progress read alike (neither is finished); the time it was read is not bound. */
81
62
  export function ciBinding(ci) {
82
63
  return ci === null ? null : { candidate: ci.candidate, state: ci.state, failed: ci.failed, runs: ci.runs.map(run => [run.id, run.name, run.status === "completed", run.conclusion]) };
83
64
  }
84
- /** GitHub CI in one line, as the sheet and the CLI say it. */
85
- export function ciWords(ci, candidate) {
86
- const short = candidate.slice(0, 7);
87
- if (ci === null)
88
- return `GitHub CI on ${short} hasn't been read yet.`;
89
- if (ci.state === "passed")
90
- return `GitHub CI passed on ${short}.`;
91
- if (ci.state === "failed")
92
- return `GitHub CI failed on ${short}: ${ci.failed.join(", ") || "a check"}.`;
93
- const finished = ci.runs.filter(run => run.status === "completed").length;
94
- return `GitHub CI is running on ${short}: ${finished} of ${ci.runs.length} checks finished.`;
95
- }
96
- /** The release check failed on this task already (the coordinator's record): nobody approves it again here. */
97
- function checkFailed(store, taskId) {
98
- return store.handle.prepare("SELECT 1 FROM release_step WHERE task_id = ? AND step = 'check' AND state = 'failed' LIMIT 1").get(taskId) !== undefined;
99
- }
100
- /** Why a release can't be approved now, from the plane's own state, or null. */
101
- function refusalOf(store, taskId, scope, ci) {
102
- if (taskArchived(store, taskId))
103
- return "This release is put away. Restore it before approving.";
104
- const hold = releaseCiHoldOf(store, taskId);
105
- if (hold !== null)
106
- return releaseCiHoldWords(hold);
107
- if (ci?.state === "failed")
108
- return `${ciWords(ci, scope.candidate)} Fix the branch or rerun the failed job on GitHub, then release again.`;
109
- if (checkFailed(store, taskId))
110
- return "The release check already failed on this commit. Fix the branch and release the new commit.";
111
- if (scope.approvedAt !== null && scope.approvedDigest !== scope.digest)
112
- return "The release's terms changed after an earlier approval. Read them again.";
113
- return null;
114
- }
115
65
  /** A release task from the plane's own state: a sealed form (terms or a release check's purpose) whose repository is the
116
66
  * task's project and whose approved commit is the scope's candidate. Never from a title or a task id. */
117
67
  export function releaseTaskOf(store, taskId) {
@@ -380,3 +330,14 @@ export function waitingJournals(stateDir, expectedFile) {
380
330
  }
381
331
  return { journals, unreadable };
382
332
  }
333
+ /** 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
334
+ * in the sheet's own words (`refusalOf`, the one source), or null when this isn't a release. Reads only; Home never
335
+ * approves one, it links to the sheet. */
336
+ export function releaseAskOf(store, taskId) {
337
+ const found = releaseTaskOf(store, taskId);
338
+ if (found === null)
339
+ return null;
340
+ const { scope, seal } = found;
341
+ const refusal = approvalOf(scope).approved ? null : refusalOf(store, taskId, scope, releaseCiOf(store, taskId, scope.candidate));
342
+ return { version: seal.identity.packageVersion, refusal };
343
+ }
@@ -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 {
@@ -0,0 +1,32 @@
1
+ import type { Scope } from "./scope.js";
2
+ import type { Store } from "./store.js";
3
+ /** The ledger act holding the coordinator's latest reading of the pull request's CI on the exact candidate. */
4
+ export declare const RELEASE_CI_SEEN_ACTION = "release CI observed";
5
+ /** One check's newest attempt, as the coordinator's own gh read it. A re-run is a new check run id. */
6
+ export type ReleaseCiRun = {
7
+ id: number | null;
8
+ name: string;
9
+ status: string;
10
+ conclusion: string | null;
11
+ };
12
+ export type ReleaseCiSnapshot = {
13
+ candidate: string;
14
+ at: string;
15
+ state: "passed" | "running" | "failed";
16
+ failed: string[];
17
+ runs: ReleaseCiRun[];
18
+ };
19
+ export declare const ciName: (value: unknown) => string;
20
+ /** The coordinator's latest CI reading for this task on exactly `candidate`, or null (not read yet, or another commit). */
21
+ export declare function releaseCiOf(store: Store, taskId: string, candidate: string): ReleaseCiSnapshot | null;
22
+ /** GitHub CI in one line, as the sheet and the CLI say it. */
23
+ export declare function ciWords(ci: ReleaseCiSnapshot | null, candidate: string): string;
24
+ /** Why a release can't be approved now, from the plane's own state, or null. */
25
+ export declare function refusalOf(store: Store, taskId: string, scope: Scope, ci: ReleaseCiSnapshot | null): string | null;
26
+ /**
27
+ * The release refusals every approval road shares (scope.ts approve(), read inside its transaction): for a scope
28
+ * sealing release terms or a release check's purpose, the sheet's own reasons in the sheet's own words, so the task
29
+ * page, Home's in-place yes, chat and `toolroll task approve` refuse exactly what `/t/<id>/release` refuses. Null for
30
+ * every other scope, and for a release nothing stops. The sheet's card digest binding stays the sheet's.
31
+ */
32
+ export declare function releaseApprovalRefusal(store: Store, taskId: string, scope: Scope): string | null;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Why a release can't be approved now, from the plane's own state (release-approval.ts re-exports all of it): the
3
+ * coordinator's GitHub CI reading on the exact candidate, and the one list of refusals the release sheet shows and every
4
+ * approval road applies (scope.ts approve() reads `releaseApprovalRefusal` inside its transaction). Kept apart from
5
+ * release-approval.ts so scope.ts reads it without importing the card machinery (an import cycle through store.ts).
6
+ */
7
+ import { releaseCiHoldOf, releaseCiHoldWords } from "./release-ci-hold.js";
8
+ import { taskArchived } from "./task-archive.js";
9
+ import { cleanTerminalLine } from "./terminal-text.js";
10
+ /** The ledger act holding the coordinator's latest reading of the pull request's CI on the exact candidate. */
11
+ export const RELEASE_CI_SEEN_ACTION = "release CI observed";
12
+ export const ciName = (value) => cleanTerminalLine(String(value ?? "")).trim().slice(0, 120);
13
+ /** The coordinator's latest CI reading for this task on exactly `candidate`, or null (not read yet, or another commit). */
14
+ export function releaseCiOf(store, taskId, candidate) {
15
+ const row = store.handle.prepare("SELECT detail, at FROM action_ledger WHERE task_id = ? AND action = ? ORDER BY id DESC LIMIT 1").get(taskId, RELEASE_CI_SEEN_ACTION);
16
+ if (row === undefined)
17
+ return null;
18
+ let parsed;
19
+ try {
20
+ parsed = JSON.parse(String(row["detail"]));
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ const one = parsed;
26
+ if (one.v !== 1 || one.candidate !== candidate || !["passed", "running", "failed"].includes(String(one.state)) || !Array.isArray(one.failed) || !Array.isArray(one.runs))
27
+ return null;
28
+ return { candidate, at: String(row["at"]), state: one.state, failed: one.failed.map(ciName),
29
+ runs: one.runs.map(run => { const r = run; return { id: typeof r["id"] === "number" ? r["id"] : null, name: ciName(r["name"]), status: ciName(r["status"]), conclusion: r["conclusion"] == null ? null : ciName(r["conclusion"]) }; }) };
30
+ }
31
+ /** GitHub CI in one line, as the sheet and the CLI say it. */
32
+ export function ciWords(ci, candidate) {
33
+ const short = candidate.slice(0, 7);
34
+ if (ci === null)
35
+ return `GitHub CI on ${short} hasn't been read yet.`;
36
+ if (ci.state === "passed")
37
+ return `GitHub CI passed on ${short}.`;
38
+ if (ci.state === "failed")
39
+ return `GitHub CI failed on ${short}: ${ci.failed.join(", ") || "a check"}.`;
40
+ const finished = ci.runs.filter(run => run.status === "completed").length;
41
+ return `GitHub CI is running on ${short}: ${finished} of ${ci.runs.length} checks finished.`;
42
+ }
43
+ /** The release check failed on this task already (the coordinator's record): nobody approves it again here. */
44
+ function checkFailed(store, taskId) {
45
+ return store.handle.prepare("SELECT 1 FROM release_step WHERE task_id = ? AND step = 'check' AND state = 'failed' LIMIT 1").get(taskId) !== undefined;
46
+ }
47
+ /** Why a release can't be approved now, from the plane's own state, or null. */
48
+ export function refusalOf(store, taskId, scope, ci) {
49
+ if (taskArchived(store, taskId))
50
+ return "This release is put away. Restore it before approving.";
51
+ const hold = releaseCiHoldOf(store, taskId);
52
+ if (hold !== null)
53
+ return releaseCiHoldWords(hold);
54
+ if (ci?.state === "failed")
55
+ return `${ciWords(ci, scope.candidate)} Fix the branch or rerun the failed job on GitHub, then release again.`;
56
+ if (checkFailed(store, taskId))
57
+ return "The release check already failed on this commit. Fix the branch and release the new commit.";
58
+ if (scope.approvedAt !== null && scope.approvedDigest !== scope.digest)
59
+ return "The release's terms changed after an earlier approval. Read them again.";
60
+ return null;
61
+ }
62
+ /**
63
+ * The release refusals every approval road shares (scope.ts approve(), read inside its transaction): for a scope
64
+ * sealing release terms or a release check's purpose, the sheet's own reasons in the sheet's own words, so the task
65
+ * page, Home's in-place yes, chat and `toolroll task approve` refuse exactly what `/t/<id>/release` refuses. Null for
66
+ * every other scope, and for a release nothing stops. The sheet's card digest binding stays the sheet's.
67
+ */
68
+ export function releaseApprovalRefusal(store, taskId, scope) {
69
+ // A sealed form, as release-card.ts releaseSealOf reads it (release terms, or a release check's purpose).
70
+ if (scope.releaseTerms == null && scope.releaseCheck == null)
71
+ return null;
72
+ return refusalOf(store, taskId, scope, scope.candidate == null ? null : releaseCiOf(store, taskId, scope.candidate));
73
+ }
@@ -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
@@ -54,6 +54,7 @@ import { parseContract } from "./contracts/contract.js";
54
54
  import { readAcceptance, readScopeTerms, storedRubricSchema } from "./contracts/scope.js";
55
55
  import { taskArchived, taskCancelled } from "./task-archive.js";
56
56
  import { releaseCiHoldOf, releaseCiHoldWords } from "./release-ci-hold.js";
57
+ import { releaseApprovalRefusal } from "./release-refusal.js";
57
58
  import { NO_READINESS, exactModelId, legOf, projectRoute, routeDigestOf, routeFromJson, routeJsonProblem, routeWords, } from "./phase-routing.js";
58
59
  /** Same shape as a runner's credential, for the same reasons. */
59
60
  function mintToken() {
@@ -1117,6 +1118,11 @@ chat) {
1117
1118
  const scope = store.getScope(taskId);
1118
1119
  if (scope === null)
1119
1120
  return { ok: false, reason: "no-scope" };
1121
+ // A release takes no yes the release sheet would refuse (put away, CI held or failed, its check failed on this
1122
+ // commit, terms changed after an earlier yes), in the sheet's words, on every road: read here, before any vote.
1123
+ const releaseRefusal = releaseApprovalRefusal(store, taskId, scope);
1124
+ if (releaseRefusal !== null)
1125
+ return { ok: false, reason: "policy", message: releaseRefusal };
1120
1126
  // A put-away task (v145) is restored first; sealScopeApproval refuses it too, on every road.
1121
1127
  if (taskArchived(store, taskId))
1122
1128
  return { ok: false, reason: "archived" };
@@ -1443,11 +1449,12 @@ function describeApproval(approval) {
1443
1449
  // ---- Only a person approves (docs/plans/approval-policy.md, phase 1). Kept last: the freshness tripwire pins places.
1444
1450
  export { personProof } from "./person-proof.js";
1445
1451
  import { personProof } from "./person-proof.js";
1452
+ import { isProtectedWork, protectedChanges } from "./approval-policy.js";
1446
1453
  import { permissionPlainWords } from "./chat-terms.js";
1447
1454
  import { limitRecognised } from "./exhaustion.js";
1448
1455
  import { SUBSCRIPTION_CAPABLE } from "./keys.js";
1449
1456
  import { releaseCheckPurposeDigest, releaseTermsDigest } from "./contracts/release-terms.js";
1450
- 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";
1451
1458
  /** Whether the command running now is a machine's: the lead (its token) or a remote API run. Trusted context only. */
1452
1459
  export const machineActing = () => currentActor()?.lead === true || activeRemote() !== null;
1453
1460
  /** A proof row's detail, either form: the legacy text (`<at> · generation <n> · digest <d>`) or the versioned record
@@ -1703,6 +1710,8 @@ const LOCAL_APPROVAL_WORDS = {
1703
1710
  escalated: "Work with wider permissions needs your password.",
1704
1711
  coordinator: "Work a coordinator filed needs your password.",
1705
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.",
1706
1715
  };
1707
1716
  /**
1708
1717
  * Whether the local owner may approve this task without a password (easy-start plan C5). ONE rule, read by the
@@ -1713,30 +1722,8 @@ const LOCAL_APPROVAL_WORDS = {
1713
1722
  * a scout proposed are allowed.
1714
1723
  */
1715
1724
  export function localApprovalEligible(store, by, taskId) {
1716
- const no = (reason) => ({ ok: false, reason, message: LOCAL_APPROVAL_WORDS[reason] });
1717
- if (!localModeOn(store))
1718
- return no("local-mode-off");
1719
- const owner = store.handle.prepare("SELECT owner FROM local_mode WHERE id = 1").get()?.["owner"];
1720
- if (owner !== by)
1721
- return no("not-owner");
1722
- const scope = store.getScope(taskId);
1723
- if (scope === null)
1724
- return no("no-scope");
1725
- if (deployGateScope(scope) || releaseCheckScope(scope))
1726
- return no("release");
1727
- const gate = store.approvalGate(taskId, by, "person");
1728
- if (gate.verdict !== "seal" || gate.protectedWork)
1729
- return no("protected");
1730
- if (scope.profile == null || levelOfProfile(scope.profile) === "escalated")
1731
- return no("escalated");
1732
- const ref = store.handle.prepare("SELECT repo, coordinator_cid FROM task_ref WHERE backend = 'built-in' AND external_id = ?").get(taskId);
1733
- if (ref === undefined || ref["coordinator_cid"] != null)
1734
- return no("coordinator");
1735
- // The stored level, never the launch cap: a project saved as unsandboxed is refused even though local mode confines it.
1736
- const stored = ref["repo"] == null ? undefined : store.handle.prepare("SELECT level FROM project_check_access WHERE repo = ?").get(ref["repo"])?.["level"];
1737
- if (stored === "unsandboxed" || scope.profile.checkAccess === "unsandboxed")
1738
- return no("unsandboxed");
1739
- return { ok: true };
1725
+ // The one rule, shared with completing a result (localWorkEligible, below).
1726
+ return localWorkEligible(store, by, taskId, { act: "approve" });
1740
1727
  }
1741
1728
  // ---- Fallback chains (fallback.ts). Kept last too.
1742
1729
  /** A chain that may dispatch: version 2, a base and at least one fallback. An older chain is history, never this. */
@@ -1762,3 +1749,107 @@ export function chainTermsLines(scope) {
1762
1749
  }),
1763
1750
  ];
1764
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
+ }