toolroll 0.9.70 → 0.9.72

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 (91) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/dist/agent-fence.d.ts +7 -0
  3. package/dist/agent-fence.js +10 -2
  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/builder.js +48 -6
  9. package/dist/chat-decide.js +17 -2
  10. package/dist/check-report.d.ts +109 -0
  11. package/dist/check-report.js +204 -0
  12. package/dist/claim.js +1 -1
  13. package/dist/commit-extract.d.ts +94 -0
  14. package/dist/commit-extract.js +436 -0
  15. package/dist/contracts/console-api.d.ts +1 -1
  16. package/dist/contracts/console-api.js +1 -0
  17. package/dist/contracts/flow.d.ts +16 -0
  18. package/dist/contracts/flow.js +3 -0
  19. package/dist/contracts/release-check.d.ts +335 -0
  20. package/dist/contracts/release-check.js +159 -0
  21. package/dist/contracts/release-terms.d.ts +60 -0
  22. package/dist/contracts/release-terms.js +70 -0
  23. package/dist/contracts/verification-receipt.d.ts +42 -8
  24. package/dist/contracts/verification-receipt.js +24 -7
  25. package/dist/contracts/verification-view.d.ts +1 -0
  26. package/dist/deploy-gate.d.ts +17 -0
  27. package/dist/deploy-gate.js +68 -0
  28. package/dist/dispatch.js +3 -10
  29. package/dist/dispose.js +4 -1
  30. package/dist/evidence-pack.d.ts +1 -1
  31. package/dist/evidence-pack.js +20 -2
  32. package/dist/evidence.d.ts +1 -0
  33. package/dist/evidence.js +2 -1
  34. package/dist/flow-engine.js +5 -1
  35. package/dist/flow-pull-request.d.ts +5 -2
  36. package/dist/flow-pull-request.js +14 -5
  37. package/dist/flow-schedule.js +25 -6
  38. package/dist/flows-ui.js +7 -1
  39. package/dist/invoke.d.ts +19 -1
  40. package/dist/invoke.js +49 -2
  41. package/dist/lead-status.js +5 -2
  42. package/dist/live-terminal.d.ts +10 -6
  43. package/dist/live-terminal.js +55 -14
  44. package/dist/nightly-check.d.ts +104 -0
  45. package/dist/nightly-check.js +362 -0
  46. package/dist/operate.js +39 -2
  47. package/dist/person-proof.d.ts +2 -0
  48. package/dist/person-proof.js +14 -0
  49. package/dist/plan-auto.js +4 -4
  50. package/dist/planner-source.d.ts +1 -1
  51. package/dist/proof.js +2 -0
  52. package/dist/publish.js +156 -144
  53. package/dist/pull-request-flow.d.ts +2 -0
  54. package/dist/pull-request-flow.js +24 -2
  55. package/dist/release-adapters.d.ts +8 -0
  56. package/dist/release-adapters.js +212 -15
  57. package/dist/release-authority.d.ts +112 -0
  58. package/dist/release-authority.js +126 -0
  59. package/dist/release-carry.d.ts +233 -0
  60. package/dist/release-carry.js +645 -0
  61. package/dist/release-cli.d.ts +3 -0
  62. package/dist/release-cli.js +124 -5
  63. package/dist/release-coverage.js +3 -0
  64. package/dist/release-recheck.d.ts +146 -0
  65. package/dist/release-recheck.js +657 -0
  66. package/dist/release-view.d.ts +34 -0
  67. package/dist/release-view.js +27 -0
  68. package/dist/release.d.ts +131 -1
  69. package/dist/release.js +562 -244
  70. package/dist/remote-main.d.ts +2 -0
  71. package/dist/remote-main.js +1 -1
  72. package/dist/result-completion.d.ts +6 -0
  73. package/dist/result-completion.js +15 -1
  74. package/dist/result-follow-ups.js +29 -1
  75. package/dist/sandbox-runtime.d.ts +59 -1
  76. package/dist/sandbox-runtime.js +159 -5
  77. package/dist/scope.d.ts +115 -5
  78. package/dist/scope.js +219 -7
  79. package/dist/server/render-tasks.d.ts +8 -0
  80. package/dist/server/render-tasks.js +50 -9
  81. package/dist/server/route-table.js +1 -0
  82. package/dist/server/task-views.js +4 -0
  83. package/dist/server/tasks.js +12 -0
  84. package/dist/store.d.ts +227 -5
  85. package/dist/store.js +727 -37
  86. package/dist/task-outcome-cli.js +7 -3
  87. package/dist/toolroll-update.js +2 -2
  88. package/dist/verification-evidence.d.ts +25 -1
  89. package/dist/verification-evidence.js +57 -2
  90. package/dist/work-index.js +3 -6
  91. package/package.json +1 -1
@@ -5,12 +5,15 @@
5
5
  * command, the retained log and a reused gate's source — are checked in plain code after parsing.
6
6
  *
7
7
  * Version 1 is a gate executed in its own run. Version 2 added `reusedFrom` and `executedHere: false` for a gate
8
- * reused by an unchanged observation follow-up. A direct receipt is still sealed as version 1, so its bytes (and the
9
- * digests bound to them) read the same in every Toolroll that reads receipts.
8
+ * reused by an unchanged observation follow-up. Version 3 (docs/plans/release-once.md) is a release check: it keeps
9
+ * the raw exit (`result`) apart from `coverage` — `full`, `composed` (a targeted run composed with the full check A),
10
+ * `partial` (never passes) or `identity-removed` (a required test went away; asks again) — names the sealed check
11
+ * artifacts, and for a composed check every link its reader proves again. A direct receipt is still sealed as version 1,
12
+ * so its bytes (and the digests bound to them) read the same in every Toolroll that reads receipts.
10
13
  */
11
14
  import { z } from "zod";
12
15
  import { type ContractResult } from "./contract.js";
13
- export declare const VERIFICATION_FAILURES: readonly ["spawn-failed", "timed-out", "dependency-missing", "setup-stale", "setup-failed", "own-install-failed", "tracked-files-changed", "setup-changed-files", "checkout-moved", "cleanliness-unavailable", "dependency-still-missing", "retry-spawn-failed", "retry-timed-out", "custody-lost"];
16
+ export declare const VERIFICATION_FAILURES: readonly ["spawn-failed", "timed-out", "dependency-missing", "setup-stale", "setup-failed", "own-install-failed", "tracked-files-changed", "setup-changed-files", "checkout-moved", "cleanliness-unavailable", "dependency-still-missing", "retry-spawn-failed", "retry-timed-out", "custody-lost", "coverage-incomplete"];
14
17
  /**
15
18
  * What the repository's approved verification command did: not configured; configured but the run could not attempt
16
19
  * it (`attemptFailed`, which downgrades to `short` rather than `refuted`: "we could not check" is not "the claim is
@@ -25,6 +28,7 @@ export declare const verifyCommandFactsSchema: z.ZodUnion<readonly [z.ZodObject<
25
28
  failure: z.ZodOptional<z.ZodEnum<{
26
29
  "checkout-moved": "checkout-moved";
27
30
  "cleanliness-unavailable": "cleanliness-unavailable";
31
+ "coverage-incomplete": "coverage-incomplete";
28
32
  "custody-lost": "custody-lost";
29
33
  "dependency-missing": "dependency-missing";
30
34
  "dependency-still-missing": "dependency-still-missing";
@@ -71,10 +75,12 @@ export declare const receiptLogSchema: z.ZodObject<{
71
75
  ok: "ok";
72
76
  }>>;
73
77
  }, z.core.$strict>;
74
- export declare const RECEIPT_VERSION = 2;
78
+ export declare const RECEIPT_VERSION = 3;
79
+ export declare const RECEIPT_COVERAGES: readonly ["full", "composed", "partial", "identity-removed"];
80
+ export type ReceiptCoverage = typeof RECEIPT_COVERAGES[number];
75
81
  /** The receipt as read: a reused gate names its source run and digest; a direct one names neither. */
76
82
  export declare const verificationReceiptSchema: z.ZodObject<{
77
- version: z.ZodLiteral<2>;
83
+ version: z.ZodLiteral<3>;
78
84
  run: z.ZodInt;
79
85
  head: z.ZodString;
80
86
  base: z.ZodNullable<z.ZodString>;
@@ -105,6 +111,7 @@ export declare const verificationReceiptSchema: z.ZodObject<{
105
111
  failure: z.ZodOptional<z.ZodEnum<{
106
112
  "checkout-moved": "checkout-moved";
107
113
  "cleanliness-unavailable": "cleanliness-unavailable";
114
+ "coverage-incomplete": "coverage-incomplete";
108
115
  "custody-lost": "custody-lost";
109
116
  "dependency-missing": "dependency-missing";
110
117
  "dependency-still-missing": "dependency-still-missing";
@@ -136,13 +143,40 @@ export declare const verificationReceiptSchema: z.ZodObject<{
136
143
  ok: "ok";
137
144
  }>>;
138
145
  }, z.core.$strict>;
146
+ coverage: z.ZodOptional<z.ZodEnum<{
147
+ composed: "composed";
148
+ full: "full";
149
+ "identity-removed": "identity-removed";
150
+ partial: "partial";
151
+ }>>;
152
+ check: z.ZodOptional<z.ZodObject<{
153
+ attempt: z.ZodEnum<{
154
+ full: "full";
155
+ targeted: "targeted";
156
+ }>;
157
+ report: z.ZodNullable<z.ZodString>;
158
+ inventory: z.ZodNullable<z.ZodString>;
159
+ exitCode: z.ZodOptional<z.ZodNullable<z.ZodInt>>;
160
+ }, z.core.$strict>>;
161
+ composed: z.ZodOptional<z.ZodObject<{
162
+ base: z.ZodInt;
163
+ baseReceipt: z.ZodString;
164
+ baseReport: z.ZodString;
165
+ authority: z.ZodInt;
166
+ carries: z.ZodArray<z.ZodInt>;
167
+ artifacts: z.ZodString;
168
+ plan: z.ZodString;
169
+ snapshot: z.ZodString;
170
+ report: z.ZodString;
171
+ }, z.core.$strict>>;
139
172
  }, z.core.$strict>;
140
173
  export type VerificationReceipt = z.infer<typeof verificationReceiptSchema>;
141
- /** A version 1 receipt read as version 2: the same fields. Reuse is a version 2 field; a version 1 receipt naming it
142
- * is refused by its reader, which still knows the version it was sealed as. */
174
+ /** A version 1 or 2 receipt read as version 3: the same fields. Reuse is a version 2 field and coverage a version 3
175
+ * one; a receipt naming a field newer than it was sealed as is refused by its reader, which still knows the version. */
143
176
  export declare function upgradeDirectReceipt(body: Record<string, unknown>): Record<string, unknown>;
144
177
  export declare const RECEIPT_UPGRADES: {
145
178
  readonly 1: typeof upgradeDirectReceipt;
179
+ readonly 2: typeof upgradeDirectReceipt;
146
180
  };
147
- /** Read a receipt: version 2 as itself, version 1 as a direct gate, a newer one refused plainly. */
181
+ /** Read a receipt: version 3 as itself, 1 and 2 upgraded, a newer one refused plainly. */
148
182
  export declare function readVerificationReceipt(input: unknown): ContractResult<VerificationReceipt>;
@@ -5,8 +5,11 @@
5
5
  * command, the retained log and a reused gate's source — are checked in plain code after parsing.
6
6
  *
7
7
  * Version 1 is a gate executed in its own run. Version 2 added `reusedFrom` and `executedHere: false` for a gate
8
- * reused by an unchanged observation follow-up. A direct receipt is still sealed as version 1, so its bytes (and the
9
- * digests bound to them) read the same in every Toolroll that reads receipts.
8
+ * reused by an unchanged observation follow-up. Version 3 (docs/plans/release-once.md) is a release check: it keeps
9
+ * the raw exit (`result`) apart from `coverage` — `full`, `composed` (a targeted run composed with the full check A),
10
+ * `partial` (never passes) or `identity-removed` (a required test went away; asks again) — names the sealed check
11
+ * artifacts, and for a composed check every link its reader proves again. A direct receipt is still sealed as version 1,
12
+ * so its bytes (and the digests bound to them) read the same in every Toolroll that reads receipts.
10
13
  */
11
14
  import { z } from "zod";
12
15
  import { readVersioned, versioned } from "./contract.js";
@@ -25,6 +28,8 @@ export const VERIFICATION_FAILURES = [
25
28
  "retry-spawn-failed",
26
29
  "retry-timed-out",
27
30
  "custody-lost",
31
+ // A release check that didn't cover what it owed, or lost a required test (release-once.md): never a pass.
32
+ "coverage-incomplete",
28
33
  ];
29
34
  /**
30
35
  * What the repository's approved verification command did: not configured; configured but the run could not attempt
@@ -59,7 +64,8 @@ export const receiptLogSchema = z.strictObject({
59
64
  redacted: z.boolean(),
60
65
  captureStatus: z.enum(["ok", "failed"]).nullable(),
61
66
  });
62
- export const RECEIPT_VERSION = 2;
67
+ export const RECEIPT_VERSION = 3;
68
+ export const RECEIPT_COVERAGES = ["full", "composed", "partial", "identity-removed"];
63
69
  const receiptFields = {
64
70
  run: z.int(),
65
71
  head: z.string(),
@@ -74,14 +80,25 @@ export const verificationReceiptSchema = versioned(RECEIPT_VERSION, {
74
80
  command: receiptCommandSchema,
75
81
  result: verifyCommandFactsSchema,
76
82
  log: receiptLogSchema,
83
+ /** Version 3: what the check covered, apart from its exit. */
84
+ coverage: z.enum(RECEIPT_COVERAGES).optional(),
85
+ /** Version 3: the attempt sealed before the check ran, and its sealed report (null: it gave none). */
86
+ check: z.strictObject({ attempt: z.enum(["full", "targeted"]), report: z.string().nullable(), inventory: z.string().nullable(),
87
+ /** The command's own exit, kept here when `result` reads a failed attempt because the coverage fell short. */
88
+ exitCode: z.int().min(0).max(255).nullable().optional() }).optional(),
89
+ /** Version 3, composed: the full check A and every link the reader proves again. */
90
+ composed: z.strictObject({
91
+ base: z.int().min(1), baseReceipt: z.string(), baseReport: z.string(), authority: z.int().min(1), carries: z.array(z.int()),
92
+ artifacts: z.string(), plan: z.string(), snapshot: z.string(), report: z.string(),
93
+ }).optional(),
77
94
  });
78
- /** A version 1 receipt read as version 2: the same fields. Reuse is a version 2 field; a version 1 receipt naming it
79
- * is refused by its reader, which still knows the version it was sealed as. */
95
+ /** A version 1 or 2 receipt read as version 3: the same fields. Reuse is a version 2 field and coverage a version 3
96
+ * one; a receipt naming a field newer than it was sealed as is refused by its reader, which still knows the version. */
80
97
  export function upgradeDirectReceipt(body) {
81
98
  return { ...body, version: RECEIPT_VERSION };
82
99
  }
83
- export const RECEIPT_UPGRADES = { 1: upgradeDirectReceipt };
84
- /** Read a receipt: version 2 as itself, version 1 as a direct gate, a newer one refused plainly. */
100
+ export const RECEIPT_UPGRADES = { 1: upgradeDirectReceipt, 2: upgradeDirectReceipt };
101
+ /** Read a receipt: version 3 as itself, 1 and 2 upgraded, a newer one refused plainly. */
85
102
  export function readVerificationReceipt(input) {
86
103
  return readVersioned(verificationReceiptSchema, input, RECEIPT_UPGRADES);
87
104
  }
@@ -39,6 +39,7 @@ export declare const legacyGateViewSchema: z.ZodObject<{
39
39
  failure: z.ZodOptional<z.ZodEnum<{
40
40
  "checkout-moved": "checkout-moved";
41
41
  "cleanliness-unavailable": "cleanliness-unavailable";
42
+ "coverage-incomplete": "coverage-incomplete";
42
43
  "custody-lost": "custody-lost";
43
44
  "dependency-missing": "dependency-missing";
44
45
  "dependency-still-missing": "dependency-still-missing";
@@ -0,0 +1,17 @@
1
+ import type { Store } from "./store.js";
2
+ export type DeploymentGate = {
3
+ ok: true;
4
+ taskId: string;
5
+ head: string;
6
+ repo: string;
7
+ approvedBy: string;
8
+ } | {
9
+ ok: false;
10
+ message: string;
11
+ };
12
+ /** Whether run `runId` is a deployable candidate now, read only from the plane's records and this runtime's code. */
13
+ export declare function deploymentGate(store: Store, input: {
14
+ runId: number;
15
+ evidenceRoot: string;
16
+ now: Date;
17
+ }): DeploymentGate;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The deployment gate, in the runtime that is releasing (docs/plans/approval-policy.md §1.7): whether a candidate run
3
+ * may be deployed at all — a person approved its scope, its exact result passed the approved check and was marked
4
+ * complete, and nothing still blocks it. `toolroll release` asks this BEFORE it starts anything from the candidate's
5
+ * checkout, so the candidate's own scripts never decide their own deployment. (scripts/deploy-candidate.mjs keeps the
6
+ * same checks for the deployment script, proven again there with the installed runtime's modules.)
7
+ */
8
+ import { assignmentOf } from "./assignment.js";
9
+ import { liveReleaseOf, personApprovalOf } from "./scope.js";
10
+ import { verificationEvidence } from "./verification-evidence.js";
11
+ /** Whether run `runId` is a deployable candidate now, read only from the plane's records and this runtime's code. */
12
+ export function deploymentGate(store, input) {
13
+ const refuse = (message) => ({ ok: false, message });
14
+ const run = store.getRun(input.runId);
15
+ const ref = run === null ? null : store.refById(run.taskRef);
16
+ const scope = ref === null ? null : store.getScope(ref.externalId);
17
+ const head = run?.headRevision ?? null;
18
+ if (run === null || ref === null || head === null)
19
+ return refuse(`Run ${input.runId} is not a built candidate.`);
20
+ // `task regate` reruns the approved check on the last built commit and records no-change: a candidate only for that commit.
21
+ const regated = run.outcome === "no-change" && scope?.candidate === head &&
22
+ store.runsFor(run.taskRef).find(one => one.id < input.runId && one.role === "builder" && one.outcome === "built")?.headRevision === head;
23
+ if (run.role !== "builder" || !(run.outcome === "built" || regated))
24
+ return refuse(`Run ${input.runId} is not a built candidate at ${head.slice(0, 7)}.`);
25
+ if (scope === null || scope.approvedDigest !== scope.digest || scope.digest !== run.scopeDigest)
26
+ return refuse("The run's signed scope is no longer the approved one.");
27
+ // One approval per release (release-once.md): a release's task deploys only while its authority stands for it — the
28
+ // live predicate proves the anchor's person approval (a carried commit's own seal is never a person's). Any other task
29
+ // needs a person's approval of its own.
30
+ const release = liveReleaseOf(store, ref.externalId, "deploy", input.now);
31
+ if (release.applies && !release.ok)
32
+ return refuse(`This release can't deploy: ${release.why}.`);
33
+ const anchorPerson = release.applies && release.ok ? personApprovalOf(store, release.authority.anchorTask) : null;
34
+ const person = anchorPerson ?? personApprovalOf(store, ref.externalId);
35
+ if (!person.ok)
36
+ return refuse(`A person has to approve this candidate's scope: ${person.why}.`);
37
+ const evidence = verificationEvidence(store, input.evidenceRoot, input.runId);
38
+ if (!evidence.ok || evidence.bytes === null)
39
+ return refuse(`Native check unavailable: ${evidence.ok ? "no receipt" : evidence.problem}.`);
40
+ let gate;
41
+ try {
42
+ gate = JSON.parse(evidence.bytes.toString());
43
+ }
44
+ catch {
45
+ return refuse("The native check's receipt can't be read.");
46
+ }
47
+ const live = ref.repo === null ? null : store.liveVerifyCommand(ref.repo);
48
+ const result = gate["result"];
49
+ // A release check's coverage (receipt version 3): partial or identity-removed never deploys, whatever the exit.
50
+ if (gate["coverage"] !== undefined && gate["coverage"] !== "full" && gate["coverage"] !== "composed")
51
+ return refuse(`The release check didn't cover this candidate (${String(gate["coverage"])}).`);
52
+ const command = gate["command"];
53
+ if (gate["run"] !== input.runId || gate["head"] !== head || gate["scopeDigest"] !== scope.digest || result?.ran !== true || result.exitCode !== 0
54
+ || live === null || command?.command !== live.command)
55
+ return refuse("The native check did not pass this candidate under the currently approved command.");
56
+ const assignment = assignmentOf(store, ref.externalId, input.now, { principal: "operator", repos: null, includeUnplaced: true }, input.evidenceRoot);
57
+ if (assignment?.state !== "complete" || assignment.receipt?.runId !== input.runId || assignment.receipt.head !== head
58
+ || assignment.receipt.scopeDigest !== scope.digest || assignment.completion?.digest !== assignment.receipt.digest)
59
+ return refuse("The lead or user has not marked this exact result complete.");
60
+ const blockers = assignment.readiness?.blockers;
61
+ if (!Array.isArray(blockers))
62
+ return refuse("The result's readiness could not be read.");
63
+ if (blockers.length > 0)
64
+ return refuse(`This result isn't ready to deploy: ${blockers.map(one => one.message).join(" ")}`);
65
+ if (ref.repo === null)
66
+ return refuse("The candidate's task is in no project.");
67
+ return { ok: true, taskId: ref.externalId, head, repo: ref.repo, approvedBy: person.by };
68
+ }
package/dist/dispatch.js CHANGED
@@ -6,6 +6,7 @@
6
6
  * fleet observations on top for people and agents reading the queue. A
7
7
  * diagnosis can go stale; it can never grant a lease.
8
8
  */
9
+ import { authorityStandsSql } from "./approval-authority.js";
9
10
  import { isAlive } from "./runner.js";
10
11
  import { updateAdmissionPaused, UPDATE_PAUSED } from "./desktop-update-gate.js";
11
12
  import { approvalOf } from "./scope.js";
@@ -15,8 +16,6 @@ import { REVIEW_TOKENS, resultStatusOf, reviewFactsOf } from "./workspace-ui.js"
15
16
  import { replacedWords } from "./task-status.js";
16
17
  import { failedAttemptSentence, latestFinishedAttempt } from "./needs-you.js";
17
18
  import { buildReviewOf } from "./review-switch.js";
18
- import { grantStandsSql } from "./flow-unattended.js";
19
- import { accountReachesSql } from "./project-access.js";
20
19
  import { BUILT_IN, parseCapabilityKey } from "./store.js";
21
20
  export const DEFAULT_MAX_OPEN_DECISIONS = 5;
22
21
  /**
@@ -77,14 +76,8 @@ export function scopeApprovedForDispatch(store, taskRef, now) {
77
76
  .prepare(`SELECT 1 AS hit FROM task_scope
78
77
  JOIN task_ref ON task_ref.id = ? AND task_scope.task_id = task_ref.external_id
79
78
  WHERE task_scope.approved_digest = task_scope.digest AND task_scope.approved_at IS NOT NULL
80
- AND (COALESCE(task_scope.approval_basis, 'password') NOT IN ('mode', 'grant')
81
- OR (task_scope.approval_basis = 'mode' AND EXISTS (SELECT 1 FROM operating_mode om
82
- JOIN approver signer ON signer.name = om.signed_by
83
- WHERE om.repo = task_ref.repo AND om.revoked_at IS NULL
84
- AND om.absolute_expiry > ? AND om.digest = task_scope.mode_digest
85
- AND signer.revoked_at IS NULL AND signer.role = 'approver' AND ${accountReachesSql("signer", "task_ref.repo")}))
86
- OR (task_scope.approval_basis = 'grant' AND ${grantStandsSql("task_scope.mode_digest", "?", "task_ref.repo")}))`)
87
- .get(taskRef, now.toISOString(), now.toISOString());
79
+ AND ${authorityStandsSql("task_scope", "task_ref.repo", "?")}`)
80
+ .get(taskRef, now.toISOString(), now.toISOString(), now.toISOString());
88
81
  return row !== undefined;
89
82
  }
90
83
  /** The phase the ordinary dispatch loop would choose before it resolves an
package/dist/dispose.js CHANGED
@@ -19,6 +19,7 @@ import { modeTermsFromJson } from "./modes.js";
19
19
  import { evidenceRoot, readVerifiedArtifact, writeEvidenceFile } from "./evidence.js";
20
20
  import { queueBuildReview } from "./build-review.js";
21
21
  import { homedir } from "node:os";
22
+ import { nightlyTaskRef, releaseTaskRef } from "./store.js";
22
23
  import {} from "./proof.js";
23
24
  import { verificationEvidence, failedVerificationEvidence } from "./verification-evidence.js";
24
25
  import { classifyGateFailure, describeGateFailure, gateFailureSummary } from "./gate-failure.js";
@@ -162,7 +163,9 @@ function disposeBuildOutcomeLocked(context, result) {
162
163
  grant.publishOn !== "complete" &&
163
164
  headSha !== null &&
164
165
  headWithin(branch, grant.headPrefix) &&
165
- (grant.selector === "all" || origin === "ours")) {
166
+ (grant.selector === "all" || origin === "ours") &&
167
+ // A nightly check or a release's task is never published (store.createPublicationIntent refuses both).
168
+ !nightlyTaskRef(store, taskRef) && !releaseTaskRef(store, taskRef)) {
166
169
  const intentId = store.createPublicationIntent({
167
170
  run: runId,
168
171
  taskRef,
@@ -49,7 +49,7 @@ export type EvidencePack = {
49
49
  by: string;
50
50
  at: string;
51
51
  digest: string;
52
- basis: "person" | "mode" | "flow";
52
+ basis: "person" | "lead" | "mode" | "flow" | "schedule" | "carried" | "unknown";
53
53
  current: boolean;
54
54
  } | null;
55
55
  writtenBy: {
@@ -15,6 +15,7 @@ import { rulesWords } from "./approval-policy.js";
15
15
  import { assignmentOf } from "./assignment.js";
16
16
  import { projectName } from "./project.js";
17
17
  import { changedFilesOf } from "./result-completion.js";
18
+ import { approvalProofOf, personProof } from "./scope.js";
18
19
  import { html, htmlString, joinHtml, styleElement } from "./html.js";
19
20
  export const EVIDENCE_PACK_FORMAT = "standing-orders/evidence-pack/v1";
20
21
  /** How to recheck one entry's seal, for whoever reads the JSON. */
@@ -55,7 +56,7 @@ export function evidencePack(store, taskId, access, who, now, evidenceRoot, chec
55
56
  goal: scope.goal, outOfScope: scope.outOfScope, touches: scope.touches, acceptance: scope.acceptance.map(one => ({ id: one.id, statement: one.statement })),
56
57
  budgetUsd: scope.budgetMicrousd === null ? null : scope.budgetMicrousd / 1_000_000, digest: scope.digest,
57
58
  approved: scope.approvedBy === null || scope.approvedAt === null || scope.approvedDigest === null ? null
58
- : { by: scope.approvedBy, at: scope.approvedAt, digest: scope.approvedDigest, basis: scope.approvalBasis === "mode" ? "mode" : scope.approvalBasis === "grant" ? "flow" : "person", current: scope.approvedDigest === scope.digest },
59
+ : { by: scope.approvedBy, at: scope.approvedAt, digest: scope.approvedDigest, basis: approvalBasisWord(store, version.id, scope.approvalBasis ?? null, scope.approvedBy, scope.approvedDigest), current: scope.approvedDigest === scope.digest },
59
60
  writtenBy: history.authors,
60
61
  approvals: history.votes,
61
62
  },
@@ -206,7 +207,7 @@ export function evidencePackHtml(pack) {
206
207
  ...(scope.touches.length ? [["Touches", joinHtml(scope.touches, ", ")]] : []),
207
208
  ...(scope.acceptance.length ? [["Acceptance", html `<ol class="criteria">${scope.acceptance.map(one => html `<li>${one.statement}</li>`)}</ol>`]] : []),
208
209
  ...(scope.writtenBy.length ? [["Scope written by", joinHtml([...new Set(scope.writtenBy.map(one => one.author))], ", ")]] : []),
209
- ["Approved", approved === null ? html `Not approved` : html `${approved.by}${approved.basis === "mode" ? " (operating mode)" : approved.basis === "flow" ? " (flow runs on its own)" : ""} · ${when(approved.at)}${approved.current ? "" : " · the scope changed after this"}`],
210
+ ["Approved", approved === null ? html `Not approved` : html `${approved.by}${approved.basis === "mode" ? " (operating mode)" : approved.basis === "flow" ? " (flow runs on its own)" : approved.basis === "lead" ? " (the lead, not a person)" : approved.basis === "schedule" ? " (standing order)" : approved.basis === "carried" ? " (carried from their release approval)" : approved.basis === "unknown" ? " (no person's proof on record)" : ""} · ${when(approved.at)}${approved.current ? "" : " · the scope changed after this"}`],
210
211
  ...(approvals.length > 1 ? [["Approvals", joinHtml(approvals.map(one => html `${one.approver} · ${when(one.at)}`), html `<br>`)]] : []),
211
212
  ["Scope digest", short(scope.digest)],
212
213
  ]),
@@ -252,3 +253,20 @@ export function standaloneEvidenceHtml(pack) {
252
253
  return htmlString(html `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Evidence pack · ${pack.task.id}</title>${styleElement(css)}</head><body>${evidencePackHtml(pack)}</body></html>
253
254
  `);
254
255
  }
256
+ /** Who approved, in the pack's words (approval policy §1.5): `person` only when a person's proof stands behind it. */
257
+ function approvalBasisWord(store, taskId, basis, by, digest) {
258
+ if (basis === "mode")
259
+ return "mode";
260
+ if (basis === "grant")
261
+ return "flow";
262
+ if (basis === "schedule")
263
+ return "schedule";
264
+ // A later release commit carried under a person's release approval (release-once.md): never a person's own yes.
265
+ if (basis === "carried")
266
+ return "carried";
267
+ if (basis === "lead")
268
+ return "lead";
269
+ if (basis === "password" || basis === "chat")
270
+ return personProof(approvalProofOf(store, taskId, by, digest)?.method) ? "person" : "unknown";
271
+ return "unknown";
272
+ }
@@ -201,6 +201,7 @@ export declare function storeEvidence(store: Store, root: string, runId: number,
201
201
  redacted?: boolean;
202
202
  captureStatus?: "ok" | "failed";
203
203
  sourceBytesOriginal?: number;
204
+ cap?: number;
204
205
  }): number;
205
206
  /**
206
207
  * Read an artifact's bytes back, believing nothing until it is proved on the
package/dist/evidence.js CHANGED
@@ -397,7 +397,8 @@ export function imageDimensions(bytes, kind) {
397
397
  * store the caller passed — inside whatever transaction the caller holds.
398
398
  */
399
399
  export function storeEvidence(store, root, runId, kind, name, content, capture, now, options = {}) {
400
- const cap = EVIDENCE_CAPS[kind];
400
+ // `cap`: a sealed structured payload with its own documented bound (a release check's report: 256 KiB), never above 1 MiB.
401
+ const cap = options.cap === undefined ? EVIDENCE_CAPS[kind] : Math.min(options.cap, 1024 * 1024);
401
402
  const stored = content.subarray(0, cap);
402
403
  // Some callers that deliberately summarize/bound a source before storage
403
404
  // may still report its true original size. This keeps `truncated` honest
@@ -6,6 +6,8 @@
6
6
  * waits for people. See flows.ts for what each zone does.
7
7
  */
8
8
  import { assignmentOf } from "./assignment.js";
9
+ import { currentActor } from "./actor.js";
10
+ import { activeRemote } from "./remote-run.js";
9
11
  import { diagnoseTaskDispatch } from "./dispatch.js";
10
12
  import { passOn, TEXT_LIMITS } from "./text-limits.js";
11
13
  import { deciderOf, durationWords, fillFlowText, flowGoal, flowValueLabel, flowWorkTemplate, validateFlowDefinition, withinHours } from "./flows.js";
@@ -528,7 +530,9 @@ export function decideFlowCard(store, input, now) {
528
530
  ledger("approved");
529
531
  return { ok: true, said: "Approved. The card is done." };
530
532
  }
531
- if (!store.moveFlowCard(card.id, { to: stage.next, outcome: "approved", actor: input.actor, note: input.note, expectEntry: card.entry }, now))
533
+ // Who decided, from trusted context (approval policy §1.7): only a person's approval lets a Pull request zone merge.
534
+ const authority = input.subagent !== undefined ? "subagent" : currentActor()?.lead === true ? "lead" : activeRemote() !== null ? "remote" : "person";
535
+ if (!store.moveFlowCard(card.id, { to: stage.next, outcome: "approved", actor: input.actor, note: input.note, expectEntry: card.entry, authority }, now))
532
536
  return { ok: false, message: "That card has moved on since; nothing was changed." };
533
537
  ledger("approved");
534
538
  const title = definition.stages.find(one => one.id === stage.next)?.title ?? stage.next;
@@ -5,7 +5,10 @@ export type PullRequestIo = {
5
5
  gh: Runner;
6
6
  evidenceRoot?: string;
7
7
  };
8
- /** Whether a person approved this card at a "Person decides" zone since it was last built; their name when so. */
9
- export declare function approvedSinceBuild(store: Store, definition: FlowDefinition, card: FlowCardRow): string | null;
8
+ /** Whether a person approved this card at a "Person decides" zone since it was last built; their name when so.
9
+ * The MOST RECENT decision governs (approval policy §1.7): it must be a person's recorded decision, after the card's
10
+ * last build by the current definition AND after the exact build being merged finished (`builtAt`), so editing the
11
+ * flow can't let an older person decision stand behind a newer machine one. */
12
+ export declare function approvedSinceBuild(store: Store, definition: FlowDefinition, card: FlowCardRow, builtAt?: string | null): string | null;
10
13
  /** One pass of a Pull request zone for one card. Returns whether it changed anything. */
11
14
  export declare function pullRequestStep(store: Store, flow: FlowRow, definition: FlowDefinition, stage: FlowStage, card: FlowCardRow, now: Date, io: PullRequestIo): Promise<boolean>;
@@ -19,15 +19,24 @@ import { adoptPublishedPullRequest } from "./publish.js";
19
19
  import { failingCheckOf, mergePullRequest, owePullRequest, pullRequestBlocker, pullRequestFollowOf } from "./pull-request-flow.js";
20
20
  import { requestResultChanges } from "./result-actions.js";
21
21
  import { revisionSourceOf } from "./result-review.js";
22
- /** Whether a person approved this card at a "Person decides" zone since it was last built; their name when so. */
23
- export function approvedSinceBuild(store, definition, card) {
22
+ /** Whether a person approved this card at a "Person decides" zone since it was last built; their name when so.
23
+ * The MOST RECENT decision governs (approval policy §1.7): it must be a person's recorded decision, after the card's
24
+ * last build by the current definition AND after the exact build being merged finished (`builtAt`), so editing the
25
+ * flow can't let an older person decision stand behind a newer machine one. */
26
+ export function approvedSinceBuild(store, definition, card, builtAt = null) {
24
27
  const kindOf = (id) => definition.stages.find(one => one.id === id)?.kind ?? null;
25
28
  const events = store.flowEvents(card.id);
26
29
  let built = -1;
27
30
  events.forEach((event, index) => { if (kindOf(event.fromStage) === "task" || kindOf(event.toStage) === "task")
28
31
  built = index; });
29
- const approval = events.slice(built + 1).reverse().find(event => event.outcome === "approved" && kindOf(event.fromStage) === "approval");
30
- return approval === undefined ? null : approval.actor;
32
+ // The latest DECISION of any kind governs: only decideFlowCard writes `approved` or `sent-back` (from a decision
33
+ // zone), whatever the zones are called now, so a send-back after a yes takes that yes back.
34
+ const latest = events.slice(built + 1).reverse().find(event => event.outcome === "approved" || event.outcome === "sent-back");
35
+ if (latest === undefined || latest.authority !== "person")
36
+ return null;
37
+ if (builtAt !== null && latest.at < builtAt)
38
+ return null;
39
+ return latest.actor;
31
40
  }
32
41
  /** One pass of a Pull request zone for one card. Returns whether it changed anything. */
33
42
  export async function pullRequestStep(store, flow, definition, stage, card, now, io) {
@@ -134,7 +143,7 @@ export async function pullRequestStep(store, flow, definition, stage, card, now,
134
143
  // Checks pass.
135
144
  if (stage.merge === undefined)
136
145
  return passed(`${pr} passed its checks.`, `Pull request: ${link} (checks passed)`);
137
- const approver = approvedSinceBuild(store, definition, card);
146
+ const approver = approvedSinceBuild(store, definition, card, store.getRun(runId)?.finishedAt ?? null);
138
147
  if (approver === null)
139
148
  return waiting(`Checks passed on ${pr}, but no person approved this card since it was built, so it wasn't merged. Merge it from its task, or send it through a decision.`);
140
149
  const merged = await mergePullRequest(store, { runId, by: `${approver} (approved in ${flow.name})`, exec: io.gh, method: stage.merge, clock: () => now });
@@ -14,6 +14,7 @@ import { legOf, routeDigestOf, routeFromJson, routeProblems } from "./phase-rout
14
14
  import { canonicalAcceptance, digestOf, parseAcceptanceCriteria, profileDigestOf, profileFromJson } from "./scope.js";
15
15
  import { hasDisguisedText, hasForbiddenControls } from "./decision.js";
16
16
  import { BUILT_IN, parseCapabilityKey } from "./store.js";
17
+ import { fileNightlyCheck } from "./nightly-check.js";
17
18
  export const WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
18
19
  export function validTimezone(value) {
19
20
  if (value.trim() !== value)
@@ -195,10 +196,22 @@ export class StandingOrderRefused extends Error {
195
196
  */
196
197
  export function fileStandingOrder(store, input, now) {
197
198
  const { order, repo } = input;
199
+ // The nightly full check on main files a check-only task of its own (nightly-check.ts).
200
+ if (order.check !== undefined)
201
+ return fileNightlyCheck(store, input, now);
198
202
  const blocker = store.standingOrderBlocker(input.trigger, order.routine, now);
199
203
  if (blocker !== null)
200
204
  return { made: "skipped", note: `Skipped: the last one (${blocker.taskId}, ${blocker.state}) hasn't finished.` };
201
- const approval = standingApproval(order, repo, input.schedule);
205
+ const standing = standingApproval(order, repo, input.schedule);
206
+ // The carried approval stands only while its approver still can approve here (approval policy §1.6): a demoted
207
+ // person, one off the project, or an approval that names nobody files an ordinary proposal instead.
208
+ const approverStands = standing.live && standing.by !== null && (() => {
209
+ const account = store.accountOf(standing.by);
210
+ return account !== null && account.revokedAt === null && account.role === "approver" && store.accountCanAccess(standing.by, repo);
211
+ })();
212
+ const approval = standing.live && !approverStands
213
+ ? { live: false, problem: standing.by === null ? "it names no approver" : "its approver can no longer approve in this project", acceptance: standing.acceptance }
214
+ : standing;
202
215
  if (approval.acceptance === null)
203
216
  return { made: "skipped", note: "Skipped: its success checks can't be read. Set the flow up again." };
204
217
  if (order.costCeilingUsd !== null) {
@@ -238,11 +251,17 @@ export function fileStandingOrder(store, input, now) {
238
251
  store.setRequirements(ref.id, order.requirements);
239
252
  const draft = { goal: order.goal, outOfScope: order.outOfScope, touches: [...order.touches], acceptance: approval.acceptance, ...(order.budgetPerRunMicrousd == null ? {} : { budgetMicrousd: order.budgetPerRunMicrousd }) };
240
253
  const digest = digestOf(draft, approval.profile, approval.route);
241
- store.saveScope({ taskId, ...draft, proposedAt: now.toISOString(), digest, budgetMicrousd: order.budgetPerRunMicrousd ?? null, approvedAt: order.approval.at, approvedBy: approval.by, approvedDigest: digest }, {}, { profile: approval.profile, route: approval.route });
242
- const sealed = store.sealScopeApproval(taskId, approval.by ?? "schedule", now, {}, undefined, "automation");
243
- const sealedRoute = sealed ? store.sealedRouteOf(taskId) : null;
244
- if (!sealed || sealedRoute === null || !sealedRoute.ok || routeDigestOf(sealedRoute.route) !== routeDigestOf(approval.route)) {
245
- throw new StandingOrderRefused(!sealed ? "the approval couldn't be sealed" : sealedRoute !== null && !sealedRoute.ok ? sealedRoute.detail : "the sealed agents aren't the approved ones");
254
+ // Filed unapproved; only the seal below approves it (no approval fields are written beside it).
255
+ store.saveScope({ taskId, ...draft, proposedAt: now.toISOString(), digest, budgetMicrousd: order.budgetPerRunMicrousd ?? null, approvedAt: null, approvedBy: null, approvedDigest: null }, {}, { profile: approval.profile, route: approval.route });
256
+ // Its own authority (approval policy §1.6): the standing order's carried approval, never a person's ceremony. A seal
257
+ // the project's rules refuse to an automatic approval (checks beyond the sandbox, protected work, policy) leaves the
258
+ // task filed and waiting for a person, as any unapproved firing does — never a dropped firing.
259
+ const sealed = store.sealScopeApproval(taskId, approval.by, now, {}, { kind: "schedule", modeDigest: order.approval.digest }, "automation");
260
+ if (!sealed)
261
+ return { made: "filed", taskId, approved: false, note: "Its approval can't stand on its own here (its checks reach beyond the sandbox, or the project's rules need a person), so this one waits for approval." };
262
+ const sealedRoute = store.sealedRouteOf(taskId);
263
+ if (sealedRoute === null || !sealedRoute.ok || routeDigestOf(sealedRoute.route) !== routeDigestOf(approval.route)) {
264
+ throw new StandingOrderRefused(sealedRoute !== null && !sealedRoute.ok ? sealedRoute.detail : "the sealed agents aren't the approved ones");
246
265
  }
247
266
  return { made: "filed", taskId, approved: true, note: null };
248
267
  }
package/dist/flows-ui.js CHANGED
@@ -8,6 +8,7 @@ import { SENDER_WORDS } from "./webhook-signing.js";
8
8
  import { READER_CAPTURED } from "./webhook-readers.js";
9
9
  import { hostOf, linkWords, provedWords, snapshotOf } from "./task-source.js";
10
10
  import { deciderOf, FLOW_COLORS, FLOW_KIND_WORDS, FLOW_STAGE_KINDS } from "./flows.js";
11
+ import { nightlyHealthWords } from "./nightly-check.js";
11
12
  import { flowInsights, troubleWords } from "./flow-insights.js";
12
13
  import { parseSortDecision, sortChip } from "./flow-sort.js";
13
14
  import { flowSecretNames, readEmailSettings, sendingReady } from "./flow-actions.js";
@@ -68,7 +69,7 @@ export function flowsListHtml(store, flows, projects, canCreate, problem) {
68
69
  const definition = flowDefinitionOf(flow);
69
70
  const waiting = cards.filter(card => ["approval", "choose"].includes(definition?.stages.find(one => one.id === card.stage)?.kind ?? "")).length;
70
71
  const buttons = store.flowTriggers(flow.id).filter(one => one.state === "active").flatMap(one => { const config = triggerConfigOf(one); return config?.kind === "button" ? [{ id: one.id, label: config.label }] : []; });
71
- const trouble = troubleWords(flowInsights(store, flow, new Date(), 7));
72
+ const trouble = troubleWords(flowInsights(store, flow, new Date(), 7)) ?? nightlyHealthOf(store, flow);
72
73
  return html `<article class="card"><div class="flow-row"><h2><a href="/flows/${flow.id}">${flow.name}</a></h2><span class="flow-counts">${projectName(flow.repo)} · ${cards.length} card${cards.length === 1 ? "" : "s"} in progress${waiting > 0 ? ` · ${waiting} waiting for a decision` : ""}${trouble === null ? "" : ` · ${trouble}`}</span></div>${buttons.length > 0 && html `<p class="flow-buttons">${joinHtml(buttons.map(one => html `<a class="button-link" href="/flows/${flow.id}?start=${one.id}">${one.label}</a>`), " ")}</p>`}</article>`;
73
74
  });
74
75
  const projectField = projects.length === 1 ? html `<input type="hidden" name="repo" value="${projects[0]}">` : html `<label>Project<select name="repo">${projects.map(repo => html `<option value="${repo}">${projectName(repo)}</option>`)}</select></label>`;
@@ -275,3 +276,8 @@ export function flowFallbackHtml(view) {
275
276
  return html `<li><strong>${stage.title}</strong> · ${FLOW_KIND_WORDS[stage.kind].label}${cards.length > 0 && html `<ul>${cards.map(card => html `<li>${card.title}${card.waiting !== null && ` — ${card.waiting}`}</li>`)}</ul>`}</li>`;
276
277
  })}</ol></section>`;
277
278
  }
279
+ /** The nightly check's own health on its flow (release-once.md, part 3): nothing since the last firing for 26 hours. */
280
+ function nightlyHealthOf(store, flow) {
281
+ const nightly = store.flowTriggers(flow.id).find(one => one.state === "active" && triggerConfigOf(one)?.kind === "schedule" && triggerConfigOf(one).order?.check !== undefined);
282
+ return nightly === undefined ? null : nightlyHealthWords(store, flow.repo, nightly.updatedAt, new Date());
283
+ }
package/dist/invoke.d.ts CHANGED
@@ -135,7 +135,25 @@ export declare function fencedCheckoutCommands(store: Store, repo: string, check
135
135
  * under: open, or its allowlist through a proxy of its own (on Linux, relayed in by the sandbox runtime). Nothing about it is
136
136
  * started unless all of it is.
137
137
  */
138
- export declare function signInsLaunch(store: Store, own: string, temp: string, checkout: string, policy: NetworkProfile, gitPaths: readonly string[], platform?: NodeJS.Platform): Promise<{
138
+ /**
139
+ * macOS: the sign-ins a check with sign-ins needs that live in the keychain, not in files: Claude Code's
140
+ * ("Claude Code-credentials", its current access token as CLAUDE_CODE_OAUTH_TOKEN) and gh's ("gh:github.com", as
141
+ * GH_TOKEN). Read here, by Toolroll, outside the sandbox, and handed to the check in its environment, so the check never
142
+ * reaches securityd (sandbox-runtime.ts SEATBELT_NO_KEYCHAIN) and nothing else in the keychain. The check can't renew
143
+ * Claude's token: one that has expired fails its sign-in. A value that isn't a plain token is left out.
144
+ */
145
+ export declare const KEYCHAIN_SIGN_INS: readonly {
146
+ service: string;
147
+ env: string;
148
+ token(secret: string): string | null;
149
+ }[];
150
+ /** One keychain item's secret by its service, read with the system's `security` (null: none, or it can't be read). A
151
+ * keychain file may be named (tests: a throwaway one); never the item's value in an argument. */
152
+ export declare function readKeychainSecret(service: string, keychain?: string): string | null;
153
+ /** The keychain's sign-ins as a check's environment (macOS only; elsewhere, for anything missing, and where Toolroll
154
+ * keeps nothing in the keychain (TOOLROLL_SECRET_STORE=files: the test suite), nothing). */
155
+ export declare function keychainSignIns(platform?: NodeJS.Platform, read?: (service: string) => string | null): Record<string, string>;
156
+ export declare function signInsLaunch(store: Store, own: string, temp: string, checkout: string, policy: NetworkProfile, gitPaths: readonly string[], platform?: NodeJS.Platform, keychain?: () => Record<string, string>): Promise<{
139
157
  grants: SignInGrants;
140
158
  network: NetworkFence;
141
159
  env: Record<string, string>;