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
@@ -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
package/dist/exec.d.ts CHANGED
@@ -200,7 +200,7 @@ export declare function confined(file: string, args: readonly string[], bag: {
200
200
  env: Record<string, string>;
201
201
  };
202
202
  /** A spawn's environment with what its sandbox needs added (nothing added: the caller's, as it was). */
203
- export declare function withSandboxEnv<T extends Record<string, string | undefined>>(env: T | undefined, added: Record<string, string>): T | Record<string, string | undefined> | undefined;
203
+ export declare function withSandboxEnv<T extends Record<string, string | undefined>>(env: T | undefined, added: Record<string, string>, fenced?: boolean): T | Record<string, string | undefined> | undefined;
204
204
  /** Run IDs are local to a database. Connections to the same file share
205
205
  * custody; separate files and separate in-memory databases never do. */
206
206
  export declare function runOwnerTag(store: Store, runId: number): string;
package/dist/exec.js CHANGED
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { ACP_LINE_CAP, createAcpConversation } from "./acp-client.js";
10
10
  import { linuxFenceAvailable, macosFenceAvailable, sandboxedSpawn } from "./agent-fence.js";
11
+ import { withoutProtectedEnv } from "./protected-storage.js";
11
12
  import { execFile, execFileSync, spawn } from "node:child_process";
12
13
  import { readlinkSync, realpathSync } from "node:fs";
13
14
  import { observeProcessTree, stopProcessTree } from "./process-tree.js";
@@ -75,7 +76,10 @@ export function confined(file, args, bag) {
75
76
  return { file, args: [...args], env: {} };
76
77
  }
77
78
  /** A spawn's environment with what its sandbox needs added (nothing added: the caller's, as it was). */
78
- export function withSandboxEnv(env, added) {
79
+ export function withSandboxEnv(env, added, fenced = false) {
80
+ // A fenced launch never gets a chat or vault variable (protected-storage.ts), whoever made its environment.
81
+ if (fenced)
82
+ return withoutProtectedEnv({ ...(env ?? process.env), ...added });
79
83
  return Object.keys(added).length === 0 ? env : { ...(env ?? process.env), ...added };
80
84
  }
81
85
  /** A spawn callback refused custody AFTER the child existed: the caller
@@ -107,7 +111,7 @@ function spawnContained(file, args, spawnOptions, label, bag, contain = true) {
107
111
  // The agent fence wraps the target itself (sandbox-exec then execs it, same pid).
108
112
  const sandboxed = confined(file, args, { ...bag, cwd: spawnOptions.cwd, temp: spawnOptions.env?.["TMPDIR"] });
109
113
  ({ file, args } = sandboxed);
110
- spawnOptions = { ...spawnOptions, env: withSandboxEnv(spawnOptions.env, sandboxed.env) };
114
+ spawnOptions = { ...spawnOptions, env: withSandboxEnv(spawnOptions.env, sandboxed.env, (bag.fence?.length ?? 0) > 0) };
111
115
  // The policy refusal comes first: nothing is reserved, recorded or
112
116
  // spawned for a spawn that cannot be contained as required.
113
117
  const effective = contain ? currentContainment() : null;
@@ -583,7 +587,7 @@ export function run(file, args, options = {}) {
583
587
  encoding: "utf8",
584
588
  shell: false,
585
589
  windowsHide: true,
586
- ...((env => env === undefined ? {} : { env }))(withSandboxEnv(childEnv, wrapped.env)),
590
+ ...((env => env === undefined ? {} : { env }))(withSandboxEnv(childEnv, wrapped.env, (options.fence?.length ?? 0) > 0)),
587
591
  }, (error, stdout, stderr) => {
588
592
  if (error === null) {
589
593
  resolve({ result: { code: 0, stdout, stderr, timedOut: false, notFound: false }, transient: false });
@@ -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,7 @@
5
5
  * A template's tools show their marks and whether the project has them; one it lacks offers Connect first.
6
6
  */
7
7
  import { brandMarkHtml } from "./brand-mark.js";
8
- import { BLANK, GALLERY, GALLERY_GROUPS, galleryDiagram, galleryToolsOf, OUTDATED_COMMANDS, SEND_RESULT, sendsAlready } from "./flow-gallery.js";
8
+ import { BLANK, GALLERY, GALLERY_GROUPS, galleryAdmitsAnyone, galleryDiagram, galleryToolsOf, OUTDATED_COMMANDS, SEND_RESULT, sendsAlready } from "./flow-gallery.js";
9
9
  import { choiceTargets } from "./flows.js";
10
10
  import { html, joinHtml, postForm, reauth } from "./html.js";
11
11
  export const GALLERY_CSS = `.gallery{max-width:1120px;min-width:0}.gallery h2{margin:28px 0 10px;font-size:.9375rem}.gallery h2:first-of-type{margin-top:8px}` +
@@ -107,7 +107,9 @@ export function galleryUseHtml(input) {
107
107
  : html `<input type="hidden" name="repo" value="${input.repo}">`;
108
108
  const preview = input.preview === null ? "" : html `<section class="gallery-preview" aria-labelledby="gallery-will"><h2 id="gallery-will">What it will do</h2><ul>${input.preview.built.does.map(one => html `<li>${one}</li>`)}</ul><p class="gallery-never">${input.preview.built.never}</p>${needing}${input.preview.startsFrom.length === 0 ? html `<p class="meta">Cards start when you add them.</p>` : input.preview.startsFrom.map(one => html `<p class="meta">Starts from: ${one}</p>`)}<details><summary>Every step</summary><ol>${input.preview.steps.map(one => html `<li>${one.replace(/^\d+\.\s/, "")}</li>`)}</ol></details></section>`;
109
109
  const sendResult = !sendsAlready(template) && html `<label class="gallery-check"><input type="checkbox" name="${SEND_RESULT.key}" value="yes"${input.answers[SEND_RESULT.key] === "yes" && html ` checked`} data-send-result><span>${SEND_RESULT.title}<small>When a card finishes, what was done comes to you in your chat apps.</small></span></label>`;
110
- const actions = html `<p class="gallery-actions">${input.preview !== null && html `<button type="submit" name="intent" value="create"${missing.length > 0 && html ` class="secondary" data-without-tools`}>Create flow</button>`}<button type="submit" name="intent" value="preview" class="secondary">${input.preview === null ? "Preview" : "Update preview"}</button></p>`;
110
+ // A flow that brings in items from anyone takes the password to make (chat-management.md §0a.3).
111
+ const step = input.preview !== null && galleryAdmitsAnyone(template) ? reauth(html `<label>Your password <span class="meta">(it brings in items from anyone)</span><input type="password" name="password" autocomplete="current-password"></label>`) : "";
112
+ const actions = html `${step}<p class="gallery-actions">${input.preview !== null && html `<button type="submit" name="intent" value="create"${missing.length > 0 && html ` class="secondary" data-without-tools`}>Create flow</button>`}<button type="submit" name="intent" value="preview" class="secondary">${input.preview === null ? "Preview" : "Update preview"}</button></p>`;
111
113
  const form = postForm(`/flows/new/${template.id}`, html `${project}${template.asks.map(field)}<label>Name<input name="name" value="${input.name}" maxlength="80"></label>${sendResult}${preview}${actions}`, { attrs: { "data-gallery-use": true }, hidden: { previewed: input.preview?.digest ?? "" } });
112
114
  return html `<section class="gallery-use" data-gallery-template="${template.id}">${input.problem !== null && html `<p class="problem" role="alert">${input.problem}</p>`}${input.said ? html `<p role="status">${input.said}</p>` : ""}<p>${template.promise}</p>${input.diagram !== null && stepStrip(input.diagram)}${needsList(template)}${toolsBlock}${form}<p class="meta"><a href="/flows/new">All templates</a></p></section>`;
113
115
  }
@@ -136,9 +136,12 @@ export type GalleryUsed = {
136
136
  said: string;
137
137
  };
138
138
  /** Use a template in a project: its scripts, its flow and its triggers, together or not at all. */
139
+ /** Whether a template's triggers bring in items from anyone: making it takes the person's password. */
140
+ export declare function galleryAdmitsAnyone(template: GalleryTemplate): boolean;
139
141
  export declare function useGalleryTemplate(store: Store, template: GalleryTemplate, repo: string, given: GalleryAnswers, input: {
140
142
  name?: string | null;
141
143
  by: string;
142
144
  now: Date;
143
145
  dir: string | null;
146
+ widening?: "proved";
144
147
  }): GalleryUsed;
@@ -20,7 +20,7 @@ import { scanForSecrets } from "./evidence.js";
20
20
  import { flowDefinitionOf } from "./flow-engine.js";
21
21
  import { saveScript, validateScript } from "./flow-scripts.js";
22
22
  import { isToolrollRepo, planeReviewSteps } from "./flow-starters.js";
23
- import { addFlowTriggerTo, describeTrigger, githubRepoOf, scheduleFromWords, validateTriggerConfig } from "./flow-triggers.js";
23
+ import { addFlowTriggerTo, describeTrigger, githubRepoOf, rawAdmitsAnyone, scheduleFromWords, validateTriggerConfig } from "./flow-triggers.js";
24
24
  import { choiceTargets, clockTime, flowFromSteps, flowTerms, FLOW_TEMPLATES, ISSUE_LABEL } from "./flows.js";
25
25
  import { connectionsOf, localConnectOf, oneClickOf } from "./mcp-connect.js";
26
26
  import { publishingOf } from "./pull-request-flow.js";
@@ -640,6 +640,10 @@ export function galleryDigest(id, repo, answers) {
640
640
  return createHash("sha256").update(JSON.stringify([id, repo, Object.entries(answers).sort(([a], [b]) => a.localeCompare(b))])).digest("hex").slice(0, 24);
641
641
  }
642
642
  /** Use a template in a project: its scripts, its flow and its triggers, together or not at all. */
643
+ /** Whether a template's triggers bring in items from anyone: making it takes the person's password. */
644
+ export function galleryAdmitsAnyone(template) {
645
+ return template.triggers.some(one => rawAdmitsAnyone(one));
646
+ }
643
647
  export function useGalleryTemplate(store, template, repo, given, input) {
644
648
  try {
645
649
  const built = buildFromGallery(store, template, repo, given);
@@ -656,7 +660,7 @@ export function useGalleryTemplate(store, template, repo, given, input) {
656
660
  const made = store.getFlow(id);
657
661
  for (const trigger of built.triggers) {
658
662
  // No one is shown the gallery's addresses: a webhook is made with none until its panel makes one (v126).
659
- const added = addFlowTriggerTo(store, made, { ...trigger, zone: trigger["zone"] ?? flowDefinitionOf(made).start }, input.by, input.now, input.dir, { shown: false });
663
+ const added = addFlowTriggerTo(store, made, { ...trigger, zone: trigger["zone"] ?? flowDefinitionOf(made).start }, input.by, input.now, input.dir, { shown: false, ...(input.widening === undefined ? {} : { widening: input.widening }) });
660
664
  if (!added.ok)
661
665
  throw new Error(added.message);
662
666
  if (added.reveal !== null)
@@ -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 });