toolroll 0.9.71 → 0.9.73

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 (131) hide show
  1. package/CHANGELOG.md +155 -0
  2. package/dist/agent-fence.d.ts +10 -2
  3. package/dist/agent-fence.js +47 -9
  4. package/dist/approval-authority.d.ts +20 -0
  5. package/dist/approval-authority.js +67 -0
  6. package/dist/assignment.js +26 -4
  7. package/dist/browser/workspace.css +1 -1
  8. package/dist/browser/workspace.js +28 -28
  9. package/dist/builder.js +48 -6
  10. package/dist/chat-actions.js +13 -8
  11. package/dist/chat-decide.d.ts +47 -1
  12. package/dist/chat-decide.js +186 -5
  13. package/dist/check-access.js +2 -2
  14. package/dist/check-report.d.ts +109 -0
  15. package/dist/check-report.js +205 -0
  16. package/dist/claim.js +1 -1
  17. package/dist/coding-provider.js +4 -2
  18. package/dist/commit-extract.d.ts +111 -0
  19. package/dist/commit-extract.js +514 -0
  20. package/dist/contracts/console-api.d.ts +4 -4
  21. package/dist/contracts/console-api.js +5 -4
  22. package/dist/contracts/flow.d.ts +16 -0
  23. package/dist/contracts/flow.js +3 -0
  24. package/dist/contracts/release-check.d.ts +335 -0
  25. package/dist/contracts/release-check.js +159 -0
  26. package/dist/contracts/release-terms.d.ts +66 -0
  27. package/dist/contracts/release-terms.js +74 -0
  28. package/dist/contracts/verification-receipt.d.ts +42 -8
  29. package/dist/contracts/verification-receipt.js +24 -7
  30. package/dist/contracts/verification-view.d.ts +1 -0
  31. package/dist/deploy-gate.d.ts +17 -0
  32. package/dist/deploy-gate.js +68 -0
  33. package/dist/dispatch.js +3 -10
  34. package/dist/dispose.js +4 -1
  35. package/dist/evidence-pack.d.ts +1 -1
  36. package/dist/evidence-pack.js +20 -2
  37. package/dist/evidence.d.ts +1 -0
  38. package/dist/evidence.js +2 -1
  39. package/dist/exec.d.ts +1 -1
  40. package/dist/exec.js +7 -3
  41. package/dist/flow-engine.js +5 -1
  42. package/dist/flow-gallery-ui.js +4 -2
  43. package/dist/flow-gallery.d.ts +3 -0
  44. package/dist/flow-gallery.js +6 -2
  45. package/dist/flow-pull-request.d.ts +5 -2
  46. package/dist/flow-pull-request.js +14 -5
  47. package/dist/flow-schedule.js +25 -6
  48. package/dist/flow-triggers.d.ts +8 -0
  49. package/dist/flow-triggers.js +15 -0
  50. package/dist/flows-cli.js +13 -3
  51. package/dist/flows-ui.js +7 -1
  52. package/dist/guides.js +10 -1
  53. package/dist/invoke.d.ts +17 -6
  54. package/dist/invoke.js +48 -18
  55. package/dist/kits-ui.js +4 -2
  56. package/dist/kits.d.ts +6 -4
  57. package/dist/kits.js +9 -11
  58. package/dist/lead-status.js +5 -2
  59. package/dist/live-terminal.d.ts +10 -6
  60. package/dist/live-terminal.js +55 -14
  61. package/dist/mcp-connect.d.ts +10 -0
  62. package/dist/mcp-connect.js +2 -0
  63. package/dist/nightly-check.d.ts +104 -0
  64. package/dist/nightly-check.js +362 -0
  65. package/dist/operate.js +39 -2
  66. package/dist/person-proof.d.ts +2 -0
  67. package/dist/person-proof.js +14 -0
  68. package/dist/plan-auto.js +4 -4
  69. package/dist/planner-source.d.ts +1 -1
  70. package/dist/project-delete-ui.js +1 -1
  71. package/dist/project-tools.js +3 -1
  72. package/dist/proof.js +2 -0
  73. package/dist/protected-storage.d.ts +67 -0
  74. package/dist/protected-storage.js +398 -0
  75. package/dist/publish.js +156 -144
  76. package/dist/pull-request-flow.d.ts +2 -0
  77. package/dist/pull-request-flow.js +24 -2
  78. package/dist/release-adapters.d.ts +16 -1
  79. package/dist/release-adapters.js +292 -29
  80. package/dist/release-authority.d.ts +118 -0
  81. package/dist/release-authority.js +135 -0
  82. package/dist/release-card.d.ts +76 -0
  83. package/dist/release-card.js +252 -0
  84. package/dist/release-carry.d.ts +236 -0
  85. package/dist/release-carry.js +664 -0
  86. package/dist/release-cli.d.ts +3 -0
  87. package/dist/release-cli.js +158 -6
  88. package/dist/release-coverage.js +3 -0
  89. package/dist/release-git.d.ts +43 -0
  90. package/dist/release-git.js +106 -0
  91. package/dist/release-recheck.d.ts +146 -0
  92. package/dist/release-recheck.js +657 -0
  93. package/dist/release-view.d.ts +34 -0
  94. package/dist/release-view.js +27 -0
  95. package/dist/release.d.ts +139 -3
  96. package/dist/release.js +641 -250
  97. package/dist/remote-main.d.ts +2 -0
  98. package/dist/remote-main.js +1 -1
  99. package/dist/result-completion.d.ts +6 -0
  100. package/dist/result-completion.js +15 -1
  101. package/dist/result-follow-ups.js +29 -1
  102. package/dist/sandbox-runtime.d.ts +16 -5
  103. package/dist/sandbox-runtime.js +19 -6
  104. package/dist/scope.d.ts +122 -5
  105. package/dist/scope.js +236 -7
  106. package/dist/server/flows.js +40 -10
  107. package/dist/server/render-settings.js +1 -1
  108. package/dist/server/render-tasks.d.ts +8 -0
  109. package/dist/server/render-tasks.js +50 -9
  110. package/dist/server/route-table.js +1 -0
  111. package/dist/server/settings.js +26 -5
  112. package/dist/server/task-views.js +4 -0
  113. package/dist/server/tasks.js +12 -0
  114. package/dist/setup-guide.d.ts +2 -2
  115. package/dist/store.d.ts +230 -5
  116. package/dist/store.js +733 -37
  117. package/dist/subagent-memory.d.ts +1 -0
  118. package/dist/subagent-memory.js +1 -1
  119. package/dist/subagent-tools.d.ts +22 -6
  120. package/dist/subagent-tools.js +44 -8
  121. package/dist/subagent-work.d.ts +2 -0
  122. package/dist/subagent-work.js +5 -2
  123. package/dist/subagents-ui.js +6 -4
  124. package/dist/task-outcome-cli.js +7 -3
  125. package/dist/telegram.d.ts +6 -1
  126. package/dist/telegram.js +58 -6
  127. package/dist/toolroll-update.js +2 -2
  128. package/dist/verification-evidence.d.ts +25 -1
  129. package/dist/verification-evidence.js +57 -2
  130. package/dist/work-index.js +3 -6
  131. package/package.json +1 -1
@@ -20,5 +20,8 @@ export type ReleaseCliDeps = {
20
20
  warn?: (line: string) => void;
21
21
  /** What --release-gate reads and changes (release-gate.ts); the installation's own by default. */
22
22
  gateWorld?: GateReleaseWorld;
23
+ /** The plane's database for the authority commands (default: this installation's). */
24
+ database?: string;
25
+ now?: () => Date;
23
26
  };
24
27
  export declare function runReleaseCommand(args: readonly string[], write: (line: string) => void, deps?: ReleaseCliDeps): Promise<number>;
@@ -15,10 +15,17 @@ import { checkedEnvelopeJson } from "./contracts/cli.js";
15
15
  import { RELEASE_LIMITS, runRelease } from "./release.js";
16
16
  import { processes, releaseAdapters } from "./release-adapters.js";
17
17
  import { releaseGate } from "./release-gate.js";
18
- import { databasePath } from "./store.js";
18
+ import { databasePath, openStore, revokeRelease } from "./store.js";
19
+ import { carryRefusalWords, proveCarry, setAskEveryReleaseCommit, setUnsandboxedCarry } from "./release-carry.js";
20
+ import { authenticateApprover, machineActing, personProof } from "./scope.js";
21
+ import { createNightlyCheckFlow } from "./nightly-check.js";
22
+ import { parseSchedule } from "./flow-schedule.js";
19
23
  export const RELEASE_HELP = `toolroll release <branch> — release Toolroll itself, from a pushed branch with an open pull request
20
24
 
21
- Requires an active approver's scope approval (the only human step) and a passing Full check on the exact commit.
25
+ First looks at the pull request's checks: a required check that failed stops before anything is filed or sent for
26
+ approval, and checks not started or still running are named and waited for (stopping if they don't finish within
27
+ the gate's limit). Then requires an active approver's scope approval (the only human step) and a passing Full check
28
+ on the exact commit.
22
29
  Completes that result, drains, backs up, deploys and relinks both CLI names to that commit. After PR checks pass,
23
30
  squash-merges, deletes the matching remote branch, verifies the merged tree and tags. Both macOS legs gate npm
24
31
  publication; Homebrew waits for its checks to register and all pass before merging.
@@ -31,7 +38,29 @@ toolroll release --release-gate <id> [--yes] — lift the update pause a deploym
31
38
  database, service definition and running service are all still the previous build's. --yes lifts that one pause.
32
39
  Any missing or contradicting evidence refuses and leaves the pause in place.
33
40
 
41
+ toolroll release carry <release> --to <commit> [--request <id>] — carry a person's release approval to a later commit
42
+
43
+ Only tests, docs and the changelog may change after the approval, within the limits, with an identical production
44
+ build. Anything else is refused, naming the commit, path and rule, and that commit needs a person's approval.
45
+
46
+ toolroll release revoke <release> — stop carrying later commits (before the merge starts)
47
+
48
+ toolroll release nightly --repo <path> --as <you> --token <password> [--schedule daily:03:00@<zone>] [--start]
49
+ — the full release check of main's head every night, approved once with your password; it never deploys,
50
+ merges or completes, and a failure tells you and holds composed releases until a full check passes
51
+
52
+ toolroll release ask-every-commit on|off --repo <path> [--as <you> --token <password>] — this project's releases ask
53
+ for every later commit (on), or carry test-only commits again (off: your password, never the lead's token)
54
+
55
+ toolroll release unsandboxed-carry on|off --repo <path> [--as <you> --token <password>] — carry release approvals
56
+ when this project's checks aren't sandboxed (on: your password, never the lead's token). With it on, later
57
+ test-only commits of a release run outside the sandbox without being shown to you first. Off by default; it counts
58
+ only for releases approved while it is on.
59
+
34
60
  Options
61
+ --no-carry every later commit of this release asks for approval again
62
+ --to <commit> with carry: the later commit
63
+ --request <id> with carry: the request's own id, so a retry is the same request
35
64
  --repo <path> the gate checkout the release check runs in (default: this checkout)
36
65
  --new release a new commit on the branch, setting the earlier unfinished release aside
37
66
  --limit <step>=<min> change one step's time limit (${Object.entries(RELEASE_LIMITS).map(([step, minutes]) => `${step} ${minutes}`).join(", ")})
@@ -44,8 +73,8 @@ A runner's lease is returned with \`toolroll worker release <lease>\`.`;
44
73
  /** A runner lease id (claim.ts mints randomUUID()). */
45
74
  export const LEASE_SHAPED = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
46
75
  export const LEASE_DEPRECATION = "`toolroll release <lease>` is now `toolroll worker release <lease>`; the old form goes in the next minor version.";
47
- const VALUE_FLAGS = new Set(["--repo", "--limit", "--tap", "--release-gate"]);
48
- const BARE_FLAGS = new Set(["--new", "--json", "--help", "--yes"]);
76
+ const VALUE_FLAGS = new Set(["--repo", "--limit", "--tap", "--release-gate", "--to", "--request", "--as", "--token", "--schedule"]);
77
+ const BARE_FLAGS = new Set(["--new", "--json", "--help", "--yes", "--no-carry", "--start"]);
49
78
  export async function runReleaseCommand(args, write, deps = {}) {
50
79
  const json = args.includes("--json");
51
80
  const refuse = (reason, message, code) => {
@@ -54,7 +83,7 @@ export async function runReleaseCommand(args, write, deps = {}) {
54
83
  };
55
84
  const positional = [];
56
85
  const limits = {};
57
- let repo, tap, gate;
86
+ let repo, tap, gate, to, request, as, token, scheduleFlag;
58
87
  for (let i = 0; i < args.length; i += 1) {
59
88
  const arg = args[i];
60
89
  if (BARE_FLAGS.has(arg))
@@ -66,6 +95,16 @@ export async function runReleaseCommand(args, write, deps = {}) {
66
95
  i += 1;
67
96
  if (arg === "--repo")
68
97
  repo = value;
98
+ else if (arg === "--to")
99
+ to = value;
100
+ else if (arg === "--request")
101
+ request = value;
102
+ else if (arg === "--as")
103
+ as = value;
104
+ else if (arg === "--token")
105
+ token = value;
106
+ else if (arg === "--schedule")
107
+ scheduleFlag = value;
69
108
  else if (arg === "--tap")
70
109
  tap = value;
71
110
  else if (arg === "--release-gate")
@@ -93,6 +132,11 @@ export async function runReleaseCommand(args, write, deps = {}) {
93
132
  }
94
133
  if (args.includes("--yes"))
95
134
  return refuse("usage", "--yes goes with --release-gate <id>; a release needs no confirmation beyond the owner's approval.", 2);
135
+ if (positional[0] === "nightly" && positional.length === 1)
136
+ return runNightlyCommand({ repo: repo === undefined ? undefined : resolve(deps.cwd ?? process.cwd(), repo), as, token, schedule: scheduleFlag, start: args.includes("--start") }, write, json, deps);
137
+ if (["carry", "revoke", "ask-every-commit", "unsandboxed-carry"].includes(positional[0] ?? "") && positional.length === 2) {
138
+ return runAuthorityCommand(positional[0], positional[1], { to, request, as, token, repo: repo === undefined ? undefined : resolve(deps.cwd ?? process.cwd(), repo) }, write, json, deps);
139
+ }
96
140
  if (positional.length !== 1)
97
141
  return refuse("usage", `Which branch? \`toolroll release <branch>\`.\n${RELEASE_HELP}`, 2);
98
142
  const branch = positional[0];
@@ -117,7 +161,7 @@ export async function runReleaseCommand(args, write, deps = {}) {
117
161
  const adapters = deps.adapters === undefined
118
162
  ? releaseAdapters({ checkout, say, ...(tap === undefined ? {} : { tap }), ...(limits["deploy"] === undefined ? {} : { deployMinutes: limits["deploy"] }) })
119
163
  : { ...deps.adapters, say };
120
- const outcome = await runRelease({ branch, checkout, stateDir, limits, fresh: args.includes("--new") }, adapters);
164
+ const outcome = await runRelease({ branch, checkout, stateDir, limits, fresh: args.includes("--new"), noCarry: args.includes("--no-carry") }, adapters);
121
165
  return report(outcome, write, json, lines);
122
166
  }
123
167
  function report(outcome, write, json, lines) {
@@ -179,3 +223,111 @@ function runGateRelease(id, yes, write, json, world) {
179
223
  write(`Lift it: toolroll release --release-gate ${outcome.id} --yes`);
180
224
  return 0;
181
225
  }
226
+ /**
227
+ * The release's authority from the command line (release-once.md): ask for a carry (anyone may ask, the lead
228
+ * included; only the rules decide), stop carrying (only ever stricter), or change the project's carry setting
229
+ * (turning carrying back on is a person's own command, never the lead's or a remote token's).
230
+ */
231
+ async function runAuthorityCommand(command, target, flags, write, json, deps) {
232
+ const name = `release ${command}`;
233
+ const answer = (ok, fields, words, code) => {
234
+ write(json ? checkedEnvelopeJson({ ok, command: name, ...fields }) : words);
235
+ return code;
236
+ };
237
+ const database = deps.database ?? databasePath(process.env, homedir());
238
+ if (!existsSync(database))
239
+ return answer(false, { reason: "no-plane", message: "This machine has no Toolroll database." }, "This machine has no Toolroll database.", 1);
240
+ const store = openStore(database);
241
+ const now = (deps.now ?? (() => new Date()))();
242
+ try {
243
+ if (command === "ask-every-commit") {
244
+ if (target !== "on" && target !== "off")
245
+ return answer(false, { reason: "usage", message: "ask-every-commit takes on or off." }, "ask-every-commit takes on or off.", 2);
246
+ if (flags.repo === undefined)
247
+ return answer(false, { reason: "usage", message: "Name the project with --repo <path>." }, "Name the project with --repo <path>.", 2);
248
+ // Turning carrying back on relaxes a release's rules: a person's own ceremony, as an approval needs (a password
249
+ // typed now, a re-check, or their own session), never a lead or remote token. Turning it off is only stricter.
250
+ let actor = "cli";
251
+ if (target === "off") {
252
+ const refused = (message) => answer(false, { reason: "person-only", message }, message, 1);
253
+ if (machineActing() || process.env["TOOLROLL_LEAD_TOKEN"] !== undefined || process.env["TOOLROLL_REMOTE_TOKEN"] !== undefined)
254
+ return refused("Only a person turns carrying back on, never the lead or a remote token.");
255
+ if (flags.as === undefined || flags.token === undefined)
256
+ return refused("Turning carrying back on needs your password: add --as <you> --token <password>.");
257
+ const proved = authenticateApprover(store, flags.as, flags.token, flags.repo);
258
+ if (!proved.ok || !personProof(proved.method))
259
+ return refused("Only an approver of this project, proved as a person, turns carrying back on.");
260
+ actor = flags.as;
261
+ }
262
+ setAskEveryReleaseCommit(store, flags.repo, target === "on", actor, now);
263
+ const words = target === "on" ? "Every later release commit in this project asks for approval." : "Later test-only release commits ship under the release approval.";
264
+ return answer(true, { repo: flags.repo, ask: target === "on" }, words, 0);
265
+ }
266
+ if (command === "unsandboxed-carry") {
267
+ if (target !== "on" && target !== "off")
268
+ return answer(false, { reason: "usage", message: "unsandboxed-carry takes on or off." }, "unsandboxed-carry takes on or off.", 2);
269
+ if (flags.repo === undefined)
270
+ return answer(false, { reason: "usage", message: "Name the project with --repo <path>." }, "Name the project with --repo <path>.", 2);
271
+ // Turning it on relaxes a release's rules: a person's own ceremony, as an approval needs, never a lead or remote
272
+ // token. Turning it off is only stricter.
273
+ let actor = "cli";
274
+ if (target === "on") {
275
+ const refused = (message) => answer(false, { reason: "person-only", message }, message, 1);
276
+ if (machineActing() || process.env["TOOLROLL_LEAD_TOKEN"] !== undefined || process.env["TOOLROLL_REMOTE_TOKEN"] !== undefined)
277
+ return refused("Only a person turns this on, never the lead or a remote token.");
278
+ if (flags.as === undefined || flags.token === undefined)
279
+ return refused("Turning this on needs your password: add --as <you> --token <password>.");
280
+ const proved = authenticateApprover(store, flags.as, flags.token, flags.repo);
281
+ if (!proved.ok || !personProof(proved.method))
282
+ return refused("Only an approver of this project, proved as a person, turns this on.");
283
+ actor = flags.as;
284
+ }
285
+ setUnsandboxedCarry(store, flags.repo, target === "on", actor, now);
286
+ const words = target === "on"
287
+ ? "Release approvals approved from now on carry when this project's checks aren't sandboxed: later test-only commits will run outside the sandbox without being shown to you first."
288
+ : "Release approvals carry only when this project's checks are sandboxed.";
289
+ return answer(true, { repo: flags.repo, unsandboxedCarry: target === "on" }, words, 0);
290
+ }
291
+ const id = Number(target);
292
+ if (!Number.isSafeInteger(id) || id < 1)
293
+ return answer(false, { reason: "usage", message: `${target} isn't a release id.` }, `${target} isn't a release id.`, 2);
294
+ if (command === "revoke") {
295
+ const done = revokeRelease(store, id, "cli", now);
296
+ return done.ok ? answer(true, { release: id }, `Release ${id} no longer carries later commits.`, 0) : answer(false, { reason: "refused", message: done.why }, done.why, 1);
297
+ }
298
+ if (flags.to === undefined || !/^[0-9a-f]{40}$/.test(flags.to))
299
+ return answer(false, { reason: "usage", message: "carry takes --to <full commit id>." }, "carry takes --to <full commit id>.", 2);
300
+ const result = await proveCarry(store, { authority: id, to: flags.to, ...(flags.request === undefined ? {} : { requestId: flags.request }), now });
301
+ if (result.ok)
302
+ return answer(true, { release: id, task: result.taskId, retried: result.retried, carry: { files: result.carry.files, lines: result.carry.changedLines } }, `Carried release ${id} to ${flags.to.slice(0, 7)}: task ${result.taskId} (${result.carry.files} files, ${result.carry.changedLines} lines).`, 0);
303
+ return answer(false, { reason: result.reason, message: result.message, ...(result.path === undefined ? {} : { path: result.path }) }, carryRefusalWords(result, flags.to), 1);
304
+ }
305
+ finally {
306
+ store.close();
307
+ }
308
+ }
309
+ /** `toolroll release nightly`: the nightly full check on main, approved with a person's password (nightly-check.ts). */
310
+ async function runNightlyCommand(flags, write, json, deps) {
311
+ const answer = (ok, fields, words, code) => { write(json ? checkedEnvelopeJson({ ok, command: "release nightly", ...fields }) : words); return code; };
312
+ if (flags.repo === undefined)
313
+ return answer(false, { reason: "usage", message: "Name the project with --repo <path>." }, "Name the project with --repo <path>.", 2);
314
+ if (flags.as === undefined || flags.token === undefined)
315
+ return answer(false, { reason: "usage", message: "The nightly check is approved with your password: add --as <you> --token <password>." }, "The nightly check is approved with your password: add --as <you> --token <password>.", 2);
316
+ if (machineActing() || process.env["TOOLROLL_LEAD_TOKEN"] !== undefined || process.env["TOOLROLL_REMOTE_TOKEN"] !== undefined)
317
+ return answer(false, { reason: "person-only", message: "Only a person sets up the nightly check." }, "Only a person sets up the nightly check.", 1);
318
+ const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
319
+ const schedule = flags.schedule ?? `daily:03:00${zone ? `@${zone}` : ""}`;
320
+ if (parseSchedule(schedule) === null)
321
+ return answer(false, { reason: "usage", message: `${schedule} isn't a schedule (daily:03:00@Europe/Paris, say).` }, `${schedule} isn't a schedule (daily:03:00@Europe/Paris, say).`, 2);
322
+ const database = deps.database ?? databasePath(process.env, homedir());
323
+ const store = openStore(database);
324
+ try {
325
+ const made = createNightlyCheckFlow(store, { repo: flags.repo, schedule, as: flags.as, token: flags.token, start: flags.start }, (deps.now ?? (() => new Date()))());
326
+ if (!made.ok)
327
+ return answer(false, { reason: "refused", message: made.message }, made.message, 1);
328
+ return answer(true, { flow: made.flow, trigger: made.trigger, schedule }, `The nightly check of main runs ${schedule}${flags.start ? "" : " once you turn its schedule on"} (flow ${made.flow}).`, 0);
329
+ }
330
+ finally {
331
+ store.close();
332
+ }
333
+ }
@@ -63,6 +63,9 @@ function passingReleases(store, root, repo) {
63
63
  if (!gate.ok || gate.bytes === null)
64
64
  return [];
65
65
  const receipt = JSON.parse(gate.bytes);
66
+ // A release check whose coverage is partial or lost a required test covers nothing.
67
+ if (receipt.coverage !== undefined && receipt.coverage !== "full" && receipt.coverage !== "composed")
68
+ return [];
66
69
  const head = store.getRun(run)?.headRevision ?? null;
67
70
  if (receipt.run !== run || receipt.result?.ran !== true || receipt.result.exitCode !== 0)
68
71
  return [];
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The release's repository as Toolroll records it (docs/plans/chat-management.md §0a.2), never as the gate checkout
3
+ * says: the checkout is one of the plane's recorded projects (by real path), its Git directory is that project's own
4
+ * plain `.git` folder, and its GitHub `owner/name` and the URL fetched from come from the recorded remote
5
+ * (`project.remote_url`), not the checkout's `origin`. The checkout is where the release check ran the build's own code,
6
+ * so its `.git/config` is treated as build-written.
7
+ */
8
+ export type ReleaseRepo = {
9
+ checkout: string;
10
+ gitDir: string;
11
+ remote: string;
12
+ slug: string;
13
+ ssh: boolean;
14
+ };
15
+ /** The programs the coordinator runs, by absolute path, found once when it starts. */
16
+ export type ReleaseTools = {
17
+ git: string;
18
+ gh: string;
19
+ ssh: string;
20
+ };
21
+ /** owner/name and transport of a recorded GitHub remote, read strictly (https or ssh to github.com, nothing else). */
22
+ export declare function releaseRemote(url: string): {
23
+ slug: string;
24
+ ssh: boolean;
25
+ } | null;
26
+ /** Fixed system folders: the only PATH the coordinator's Git and gh run with. */
27
+ export declare const RELEASE_PATH = "/usr/bin:/bin:/usr/sbin:/sbin";
28
+ /** Git, gh and ssh by absolute path: the system's Git (or the first in the fixed system folders), the installed gh as
29
+ * this process's PATH finds it now, and the system ssh. Resolved once; never looked up again. */
30
+ export declare function releaseTools(path?: string): ReleaseTools;
31
+ /** The coordinator's Git and gh environment: an allowlist, a fixed PATH, no system or global Git config, no prompts. */
32
+ export declare function releaseGitEnv(base?: Readonly<NodeJS.ProcessEnv>): NodeJS.ProcessEnv;
33
+ /** Every coordinator Git command's fixed settings, ahead of its own arguments: the recorded Git directory and checkout,
34
+ * no hooks, fsmonitor, replacement objects, pager or submodule recursion, only https (and ssh for an ssh remote), the
35
+ * system ssh, and gh as the only credential helper. */
36
+ export declare function releaseGitArgs(repo: ReleaseRepo, tools: ReleaseTools): string[];
37
+ /** The named remote the coordinator fetches, looks and pushes through: defined only on its own command line (`-c`), so
38
+ * no checkout setting names or rewrites it. */
39
+ export declare const RELEASE_REMOTE = "toolroll-release";
40
+ /** The first local or worktree setting a gate checkout's config holds that a release won't run with, or null: one
41
+ * outside the allowlist (an include, a command, a proxy, TLS or header setting, a credential helper, a URL rewrite, a
42
+ * hooks folder), a remote named like a URL or after the coordinator's own, or a remote over a transport helper. */
43
+ export declare function releaseConfigProblem(listing: string): string | null;
@@ -0,0 +1,106 @@
1
+ /**
2
+ * The release coordinator's Git, as one hardened runner's pieces (docs/plans/chat-management.md §0a.2): the recorded
3
+ * repository's shape, the programs by absolute path, the allowlisted environment, the fixed settings and the checkout
4
+ * config allowlist. Kept apart from release-adapters.ts so the release card (release-card.ts) reads Git the same way
5
+ * without loading the coordinator.
6
+ */
7
+ import { accessSync, constants as fsConstants, statSync } from "node:fs";
8
+ import { delimiter, isAbsolute, join } from "node:path";
9
+ import { HARDENED_GIT_ARGS } from "./agent-fence.js";
10
+ import { TEARDOWN_FLAGS } from "./teardown-git.js";
11
+ /** owner/name and transport of a recorded GitHub remote, read strictly (https or ssh to github.com, nothing else). */
12
+ export function releaseRemote(url) {
13
+ const part = "([A-Za-z0-9_.-]+)";
14
+ const https = new RegExp(`^https://github\\.com/${part}/${part}?(?:\\.git)?/?$`).exec(url);
15
+ const ssh = new RegExp(`^(?:ssh://)?git@github\\.com[:/]${part}/${part}?(?:\\.git)?/?$`).exec(url);
16
+ const match = https ?? ssh;
17
+ if (match === null || match[2] === undefined || match[1].startsWith(".") || match[2].startsWith("."))
18
+ return null;
19
+ return { slug: `${match[1]}/${match[2].replace(/\.git$/, "")}`, ssh: https === null };
20
+ }
21
+ const executable = (path) => { try {
22
+ accessSync(path, fsConstants.X_OK);
23
+ return statSync(path).isFile();
24
+ }
25
+ catch {
26
+ return false;
27
+ } };
28
+ /** Fixed system folders: the only PATH the coordinator's Git and gh run with. */
29
+ export const RELEASE_PATH = "/usr/bin:/bin:/usr/sbin:/sbin";
30
+ /** Git, gh and ssh by absolute path: the system's Git (or the first in the fixed system folders), the installed gh as
31
+ * this process's PATH finds it now, and the system ssh. Resolved once; never looked up again. */
32
+ export function releaseTools(path = process.env["PATH"] ?? "") {
33
+ const find = (name, dirs) => {
34
+ for (const dir of dirs) {
35
+ if (!isAbsolute(dir))
36
+ continue;
37
+ const candidate = join(dir, name);
38
+ if (executable(candidate))
39
+ return candidate;
40
+ }
41
+ return null;
42
+ };
43
+ const system = ["/usr/bin", "/bin", "/usr/local/bin", "/opt/homebrew/bin"];
44
+ return {
45
+ git: find("git", system) ?? "/usr/bin/git",
46
+ gh: find("gh", [...path.split(delimiter), ...system]) ?? "/usr/bin/gh",
47
+ ssh: find("ssh", ["/usr/bin", "/bin"]) ?? "/usr/bin/ssh",
48
+ };
49
+ }
50
+ /** The coordinator's Git and gh environment: an allowlist, a fixed PATH, no system or global Git config, no prompts. */
51
+ export function releaseGitEnv(base = process.env) {
52
+ const kept = ["HOME", "LANG", "LC_ALL", "LC_CTYPE", "TMPDIR", "USER", "LOGNAME", "SSH_AUTH_SOCK", "GH_CONFIG_DIR", "XDG_CONFIG_HOME", "GH_TOKEN", "GITHUB_TOKEN", "GH_HOST"];
53
+ return {
54
+ ...Object.fromEntries(kept.flatMap(name => base[name] === undefined ? [] : [[name, base[name]]])),
55
+ PATH: RELEASE_PATH, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_TERMINAL_PROMPT: "0", GH_PROMPT_DISABLED: "1", GIT_ASKPASS: "", SSH_ASKPASS: "",
56
+ };
57
+ }
58
+ /** Every coordinator Git command's fixed settings, ahead of its own arguments: the recorded Git directory and checkout,
59
+ * no hooks, fsmonitor, replacement objects, pager or submodule recursion, only https (and ssh for an ssh remote), the
60
+ * system ssh, and gh as the only credential helper. */
61
+ export function releaseGitArgs(repo, tools) {
62
+ return [`--git-dir=${repo.gitDir}`, `--work-tree=${repo.checkout}`, "--no-replace-objects", "--no-pager", ...HARDENED_GIT_ARGS, ...TEARDOWN_FLAGS,
63
+ "-c", "protocol.allow=never", "-c", "protocol.https.allow=always", ...(repo.ssh ? ["-c", "protocol.ssh.allow=always"] : []),
64
+ "-c", `core.sshCommand=${tools.ssh}`, "-c", "core.askPass=", "-c", "fetch.recurseSubmodules=false", "-c", "core.pager=cat",
65
+ // Where it connects and what it sends: verified TLS, no proxy, no extra header or cookie file. (An empty CA file
66
+ // or path can't be given here, Git then trusts nothing; a checkout setting either stops the release instead.)
67
+ "-c", "http.sslVerify=true", "-c", "http.proxy=", "-c", "http.extraHeader=", "-c", "http.cookieFile=",
68
+ // The recorded URL, as a remote that exists only here.
69
+ "-c", `remote.${RELEASE_REMOTE}.url=${repo.remote}`, "-c", `remote.${RELEASE_REMOTE}.pushurl=${repo.remote}`, "-c", `remote.${RELEASE_REMOTE}.proxy=`,
70
+ ...(repo.ssh ? [] : ["-c", `credential.helper=!'${tools.gh.replace(/'/g, "'\\''")}' auth git-credential`])];
71
+ }
72
+ /** The named remote the coordinator fetches, looks and pushes through: defined only on its own command line (`-c`), so
73
+ * no checkout setting names or rewrites it. */
74
+ export const RELEASE_REMOTE = "toolroll-release";
75
+ /** The local and worktree settings a gate checkout may hold for a release (an allowlist): plain values Git writes
76
+ * itself and that never decide where it connects, what it sends or what it runs. Anything else stops the release. */
77
+ const RELEASE_KEYS = [
78
+ /^core\.(repositoryformatversion|filemode|bare|logallrefupdates|ignorecase|precomposeunicode|symlinks|autocrlf|eol|safecrlf|quotepath|longpaths|checkstat|trustctime)$/,
79
+ /^extensions\.(worktreeconfig|objectformat|refstorage)$/,
80
+ /^remote\.[^/:\\]+\.(url|fetch|pushurl|tagopt|prune)$/,
81
+ /^branch\..+\.(remote|merge|rebase|pushremote|description|vscode-merge-base)$/,
82
+ /^(user\.(name|email)|pull\.(rebase|ff)|init\.defaultbranch|lfs\.repositoryformatversion)$/,
83
+ ];
84
+ /** The first local or worktree setting a gate checkout's config holds that a release won't run with, or null: one
85
+ * outside the allowlist (an include, a command, a proxy, TLS or header setting, a credential helper, a URL rewrite, a
86
+ * hooks folder), a remote named like a URL or after the coordinator's own, or a remote over a transport helper. */
87
+ export function releaseConfigProblem(listing) {
88
+ const fields = listing.split("\0");
89
+ for (let at = 0; at + 1 < fields.length; at += 2) {
90
+ if (fields[at] !== "local" && fields[at] !== "worktree")
91
+ continue;
92
+ const entry = fields[at + 1];
93
+ const newline = entry.indexOf("\n");
94
+ const key = (newline < 0 ? entry : entry.slice(0, newline)).toLowerCase();
95
+ const value = (newline < 0 ? "" : entry.slice(newline + 1)).trim();
96
+ if (key.includes(RELEASE_REMOTE))
97
+ return key;
98
+ if (key === "core.fsmonitor" && /^(false|no|off|0)$/i.test(value))
99
+ continue;
100
+ if (!RELEASE_KEYS.some(pattern => pattern.test(key)))
101
+ return key;
102
+ if (/^remote\./.test(key) && /^[A-Za-z][A-Za-z0-9+.-]*::/.test(value))
103
+ return key;
104
+ }
105
+ return null;
106
+ }
@@ -0,0 +1,146 @@
1
+ import { type CheckContext, type CheckPart, type CheckReport, type ObligationSnapshot, type SealedInventory, type TargetedPlan } from "./contracts/release-check.js";
2
+ import { type CheckAttempt, type Store, type VerifyCommand } from "./store.js";
3
+ /** The sanity pass every targeted check runs beside its plan: a few fast tests of the core rules, when the tree has them. */
4
+ export declare const SANITY_UNITS: readonly string[];
5
+ /** A run's sealed check evidence, read back and verified (check-report.ts). */
6
+ export type SealedRun = {
7
+ run: number;
8
+ attempt: CheckAttempt;
9
+ report: CheckReport | null;
10
+ reportDigest: string | null;
11
+ inventory: SealedInventory | null;
12
+ context: CheckContext | null;
13
+ plan: TargetedPlan | null;
14
+ planDigest: string | null;
15
+ snapshot: ObligationSnapshot | null;
16
+ snapshotDigest: string | null;
17
+ };
18
+ export declare function sealedRun(store: Store, root: string, run: number): SealedRun | null;
19
+ /** R(A): what a later check must run again, from A's sealed evidence alone — or why only a full check will do. */
20
+ export type Obligations = {
21
+ full: string | null;
22
+ units: string[];
23
+ parts: CheckPart[];
24
+ groups: {
25
+ script: "app" | "flows";
26
+ kind: "scripted" | "real";
27
+ groups: string[];
28
+ }[];
29
+ versions: string[];
30
+ /** The identities that have to be reported, and pass. */
31
+ required: string[];
32
+ /** Identities skipped at A: they may stay skipped. */
33
+ skipped: string[];
34
+ };
35
+ /** The disposition of every outcome at A (the table in the plan). */
36
+ export declare function requiredSet(a: {
37
+ report: CheckReport | null;
38
+ inventory: SealedInventory | null;
39
+ }): Obligations;
40
+ /** The identities that failed in one run's sealed report (for an obligation snapshot). */
41
+ export declare function failedIdentities(report: CheckReport | null): string[];
42
+ /** The earlier targeted runs of this generation after A, with their sealed report digests and what failed in them. */
43
+ export declare function obligationSnapshot(store: Store, root: string, authority: number, base: number, before: number): ObligationSnapshot;
44
+ /** The plan a targeted run runs: R(A), the snapshot's failures, every test changed since A, and the sanity pass. */
45
+ export declare function planFrom(base: number, obligations: Obligations, snapshot: ObligationSnapshot, changed: readonly string[], units: readonly string[], journeysRan: readonly CheckPart[]): TargetedPlan;
46
+ export type RecheckDecision = {
47
+ mode: "targeted";
48
+ authority: number;
49
+ plan: TargetedPlan;
50
+ snapshot: ObligationSnapshot;
51
+ watch: string[];
52
+ } | {
53
+ mode: "full";
54
+ authority: number | null;
55
+ why: string;
56
+ watch: string[];
57
+ };
58
+ /**
59
+ * Whether the check about to run for this release task may be targeted (planTargetedRecheck): only when A and this run
60
+ * are gate tasks of the same current generation, A is the generation's newest full check with a valid report that
61
+ * accounts for every item and reused nothing from CI, everything since A is test-only (the carry proved it), the
62
+ * execution context matches A's, and no main-check failure is open. Otherwise a full check, told which identities it
63
+ * must still report (watch).
64
+ */
65
+ export declare function planTargetedRecheck(store: Store, root: string, input: {
66
+ runId: number;
67
+ taskId: string;
68
+ repo: string;
69
+ head: string;
70
+ command: VerifyCommand;
71
+ access: string;
72
+ now: Date;
73
+ }): RecheckDecision;
74
+ /** Coverage of a check: full, composed from A, partial (never passes), or identity-removed (asks again). */
75
+ export type Coverage = "full" | "composed" | "partial" | "identity-removed";
76
+ /**
77
+ * Whether a targeted run B, with A's sealed evidence, its plan and snapshot, covers every item of B's inventory
78
+ * (coverage conservation), and every required identity passed — re-derived from sealed values only.
79
+ */
80
+ export declare function coverageOf(input: {
81
+ a: {
82
+ report: CheckReport;
83
+ inventory: SealedInventory;
84
+ };
85
+ b: {
86
+ report: CheckReport | null;
87
+ inventory: SealedInventory | null;
88
+ exitCode: number | null;
89
+ };
90
+ plan: TargetedPlan;
91
+ snapshot: ObligationSnapshot;
92
+ }): Coverage;
93
+ /** A full run's own check: a required identity of the generation that is gone or newly skipped. */
94
+ export declare function fullRunRemovedIdentity(report: CheckReport | null, watch: readonly string[], skippedAtBase: readonly string[]): boolean;
95
+ /** What a composed receipt names (receipt version 3): each link is proved again by provedComposition. */
96
+ export type ComposedLinks = {
97
+ base: number;
98
+ baseReceipt: string;
99
+ baseReport: string;
100
+ authority: number;
101
+ carries: number[];
102
+ artifacts: string;
103
+ plan: string;
104
+ snapshot: string;
105
+ report: string;
106
+ };
107
+ /**
108
+ * After the check: its coverage, and for a composed one the links its receipt names. An identity-removed result stops
109
+ * the release carrying (a person approves it again). `gateOf` reads a run's verified receipt digest.
110
+ */
111
+ export declare function settleCoverage(store: Store, root: string, input: {
112
+ runId: number;
113
+ taskId: string;
114
+ exitCode: number | null;
115
+ now: Date;
116
+ }, gateOf: (run: number) => string | null): {
117
+ coverage: Coverage;
118
+ composed: ComposedLinks | null;
119
+ };
120
+ /**
121
+ * Re-prove every link a composed receipt names, from sealed artifacts and the store: A's receipt and report, this run's
122
+ * plan (re-derived from A's sealed evidence and the sealed snapshot), its snapshot and report, the release's artifact
123
+ * digest on every carry of this task, the generation and the grant. Null when every link holds; else why not.
124
+ */
125
+ export declare function provedComposition(store: Store, root: string, runId: number, links: ComposedLinks, gateOf: (run: number) => string | null, grantOf: (run: number) => string | null): string | null;
126
+ /** The identities a later check of this release still has to run again and pass (the anchor page's obligations). */
127
+ export declare function releaseObligationsOf(store: Store, root: string, authority: number): string[];
128
+ /**
129
+ * The identities a release generation still owes, independent of whether a targeted check is allowed (review round
130
+ * 1): every identity that failed in any of its checks before `before`, until a later check reported it PASSED by name.
131
+ * A failed typecheck or build doesn't wipe them, and a full fallback keeps them: each must be reported again, and a
132
+ * check that drops or newly skips one asks a person again.
133
+ */
134
+ export declare function owedIdentities(store: Store, root: string, authority: number, before: number): string[];
135
+ /**
136
+ * The generation's protected identities (review round 2): every identity that failed in any of its checks before
137
+ * `before`. Never shrinks — a later pass doesn't forgive a later removal: removing, renaming or newly skipping any of
138
+ * them, in any later run of the generation, asks a person again. Separate from what is still owed (owedIdentities).
139
+ */
140
+ export declare function protectedIdentities(store: Store, root: string, authority: number, before: number): string[];
141
+ /**
142
+ * Whether a run's report removed one of the protected identities: a unit test or journey gone or newly skipped from a
143
+ * file or group the run did report, a unit file gone from its inventory, a group or upgrade version missing from a lane
144
+ * that finished. A lane that didn't run or finish is no pass, so it is judged by the next run that does.
145
+ */
146
+ export declare function removedProtected(report: CheckReport | null, inventory: SealedInventory | null, kept: readonly string[]): boolean;