toolroll 0.9.103 → 0.9.104
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/README.md +10 -8
- package/dist/assignment.d.ts +32 -0
- package/dist/assignment.js +140 -4
- package/dist/browser/workspace.js +25 -25
- package/dist/browser-workspace.d.ts +13 -0
- package/dist/contracts/console-api.d.ts +3 -2
- package/dist/contracts/console-api.js +4 -2
- package/dist/desktop-update.d.ts +1 -1
- package/dist/keymap.d.ts +3 -1
- package/dist/keymap.js +3 -0
- package/dist/local-owner-acts.d.ts +83 -0
- package/dist/local-owner-acts.js +221 -0
- package/dist/local-owner.d.ts +12 -1
- package/dist/local-owner.js +26 -0
- package/dist/operate-remote-arguments.js +4 -2
- package/dist/operate.js +76 -1
- package/dist/plan-auto.js +5 -0
- package/dist/project-knowledge.d.ts +3 -3
- package/dist/public-access.d.ts +9 -0
- package/dist/public-access.js +15 -0
- package/dist/pull-request-flow.js +9 -0
- package/dist/release-card.d.ts +15 -1
- package/dist/release-card.js +35 -12
- package/dist/result-completion.d.ts +6 -2
- package/dist/result-completion.js +13 -7
- package/dist/scope.d.ts +32 -1
- package/dist/scope.js +110 -25
- package/dist/serve.js +13 -14
- package/dist/server/guards.d.ts +1 -0
- package/dist/server/guards.js +11 -1
- package/dist/server/remote.d.ts +1 -0
- package/dist/server/render-results.d.ts +4 -0
- package/dist/server/render-tasks.d.ts +9 -0
- package/dist/server/render-tasks.js +4 -4
- package/dist/server/route-table.js +4 -0
- package/dist/server/runtime.d.ts +1 -0
- package/dist/server/task-views.js +3 -0
- package/dist/server/tasks.d.ts +25 -0
- package/dist/server/tasks.js +46 -6
- package/dist/surface.js +12 -2
- package/dist/task-outcome-cli.d.ts +18 -0
- package/dist/task-outcome-cli.js +60 -2
- package/dist/tui-contract.d.ts +61 -3
- package/dist/tui-source.js +91 -20
- package/dist/tui.js +54 -54
- package/package.json +2 -2
package/dist/operate.js
CHANGED
|
@@ -89,7 +89,8 @@ import { runKnowledgeApply, runKnowledgeCommand } from "./knowledge-cli.js";
|
|
|
89
89
|
import { FLOWS_VALUE_FLAGS, runFlowsCommand } from "./flows-cli.js";
|
|
90
90
|
import { runAssignmentCommand } from "./assignment-adapters.js";
|
|
91
91
|
import { applyProjectProfile, runProjectCommand } from "./project-cli.js";
|
|
92
|
-
import { pullRequestLines, runTaskMergeCommand, runTaskOutcomeCommand } from "./task-outcome-cli.js";
|
|
92
|
+
import { proveTaskBuildsStopped, pullRequestLines, runLocalCompletion, runTaskMergeCommand, runTaskOutcomeCommand } from "./task-outcome-cli.js";
|
|
93
|
+
import { requestPlanAsLocalOwner, retryAsLocalOwner } from "./local-owner-acts.js";
|
|
93
94
|
import { withTaskAlias } from "./task-aliases.js";
|
|
94
95
|
import { checklistLines, mergeChecklistOf } from "./merge-checklist.js";
|
|
95
96
|
import { taskReviewBrief, renderReviewBrief } from "./task-review-brief.js";
|
|
@@ -821,6 +822,8 @@ export const OPERATE_BOOLEAN_FLAGS = new Set([
|
|
|
821
822
|
"saved",
|
|
822
823
|
// task complete: also open the result's pull request.
|
|
823
824
|
"pull-request",
|
|
825
|
+
// task complete as the terminal's local owner: the person says they checked the result themselves.
|
|
826
|
+
"checked",
|
|
824
827
|
// storage clean --landed: work already in main.
|
|
825
828
|
"landed",
|
|
826
829
|
// up: the exact start-up details (containment) in the terminal too.
|
|
@@ -8864,6 +8867,10 @@ function taskAction(action, rest, flags, context) {
|
|
|
8864
8867
|
switch (action) {
|
|
8865
8868
|
case "complete":
|
|
8866
8869
|
case "revise":
|
|
8870
|
+
// Local-owner slice 1: the terminal's local owner accepts on its own road, which never checks a password; chosen
|
|
8871
|
+
// only inside its chain, where an owner that no longer stands is refused there, never sent down the road below.
|
|
8872
|
+
if (action === "complete" && currentLocalOwner() !== null)
|
|
8873
|
+
return runLocalCompletion(rest, flags, context);
|
|
8867
8874
|
return runTaskOutcomeCommand(action, rest, flags, { ...context, operator: async () => {
|
|
8868
8875
|
const acting = await askCredentials(flags, context);
|
|
8869
8876
|
if (acting === null)
|
|
@@ -8937,6 +8944,10 @@ function taskAction(action, rest, flags, context) {
|
|
|
8937
8944
|
case "require":
|
|
8938
8945
|
return requireTask(rest, flags, context);
|
|
8939
8946
|
case "requeue":
|
|
8947
|
+
// Local-owner slice 3: the terminal's local owner retries its own approved build on its own road (no password);
|
|
8948
|
+
// chosen only inside its chain, where an owner that no longer stands is refused there.
|
|
8949
|
+
if (currentLocalOwner() !== null)
|
|
8950
|
+
return localRetryCommand(rest, flags, context);
|
|
8940
8951
|
return requeueTask(rest, flags, context);
|
|
8941
8952
|
case "regate":
|
|
8942
8953
|
return regateTaskCommand(rest, flags, context);
|
|
@@ -8947,6 +8958,10 @@ function taskAction(action, rest, flags, context) {
|
|
|
8947
8958
|
case "failing-first":
|
|
8948
8959
|
return failingFirstCommand(rest, followThroughContext(context, flags));
|
|
8949
8960
|
case "plan":
|
|
8961
|
+
// Local-owner slice 2: the terminal's local owner asks for a plan on its own road (no password, no pins, no mode
|
|
8962
|
+
// pre-authorization); chosen only inside its chain, where an owner that no longer stands is refused there.
|
|
8963
|
+
if (currentLocalOwner() !== null)
|
|
8964
|
+
return localPlanCommand(rest, flags, context);
|
|
8950
8965
|
return planTaskCommand(rest, flags, context);
|
|
8951
8966
|
case "review":
|
|
8952
8967
|
return reviewTaskCommand(rest, flags, context);
|
|
@@ -14340,3 +14355,63 @@ function releaseToQueue(store, lease, now) {
|
|
|
14340
14355
|
return result;
|
|
14341
14356
|
});
|
|
14342
14357
|
}
|
|
14358
|
+
// ---- Local owner, slice 2 (docs/plans/local-owner-acts.md §1): ask for a plan in the terminal. Kept last.
|
|
14359
|
+
/**
|
|
14360
|
+
* `task plan <id> --digest <planning source>` for the laptop's local owner (the terminal UI's `p`, and `n` on a bigger
|
|
14361
|
+
* task): its own road, which never reaches a password check. The digest is the planning source the screen read
|
|
14362
|
+
* (local-owner-acts.ts planSourceOf), required and only compared. No plan pins (`--provider`, `--model`): choosing which
|
|
14363
|
+
* agent plans needs a password. requestPlanAsLocalOwner proves the rest in one transaction.
|
|
14364
|
+
*/
|
|
14365
|
+
async function localPlanCommand(positional, flags, context) {
|
|
14366
|
+
const { store, write, json, clock } = context;
|
|
14367
|
+
const command = "task plan";
|
|
14368
|
+
if (flags.has("provider") || flags.has("model")) {
|
|
14369
|
+
const passwordSet = store.accountOf(currentLocalOwner()?.account ?? "")?.passwordSet === true;
|
|
14370
|
+
return fail(write, json, command, "local-owner", `Choosing which agent plans needs your password. ${passwordSet ? "Sign in with your password to do this." : "Set a password first: run \`toolroll password set\`."}`, EXIT.refused);
|
|
14371
|
+
}
|
|
14372
|
+
for (const name of flags.keys())
|
|
14373
|
+
if (!["json", "db", "digest"].includes(name))
|
|
14374
|
+
return fail(write, json, command, "usage", `--${name} is not a ${command} option.`, EXIT.usage);
|
|
14375
|
+
const id = positional[0], digest = flags.get("digest");
|
|
14376
|
+
const usage = "Use task plan <task> --digest <the plan source you read>.";
|
|
14377
|
+
if (positional.length !== 1 || !id || id.length > 64 || /[\x00-\x1f]/.test(id))
|
|
14378
|
+
return fail(write, json, command, "usage", usage, EXIT.usage);
|
|
14379
|
+
if (typeof digest !== "string" || !/^[a-f0-9]{64}$/.test(digest))
|
|
14380
|
+
return fail(write, json, command, "usage", `--digest needs the exact task you read. ${usage}`, EXIT.usage);
|
|
14381
|
+
const done = requestPlanAsLocalOwner(store, id, digest, clock());
|
|
14382
|
+
if (!done.ok)
|
|
14383
|
+
return fail(write, json, command, done.reason, done.message, EXIT.refused);
|
|
14384
|
+
return succeed(write, json, command, { id, replayed: done.replayed, autoPlan: false }, () => [
|
|
14385
|
+
done.replayed ? `${id} · Already asked for a plan.` : `${id} · Asked for a plan. You approve the plan before anything is built.`,
|
|
14386
|
+
]);
|
|
14387
|
+
}
|
|
14388
|
+
// ---- Local owner, slice 3 (docs/plans/local-owner-acts.md §2): retry its own approved build in the terminal. Kept last.
|
|
14389
|
+
/**
|
|
14390
|
+
* `task requeue <id> --run <build> --digest <scope digest>` (and its alias `task retry`) for the laptop's local owner (the
|
|
14391
|
+
* terminal UI's `r`): its own road, which never reaches a password check. Both pins are required and only compared:
|
|
14392
|
+
* the failed build and the approved scope the screen read. Toolroll's own stop proof runs first, as every completion
|
|
14393
|
+
* does; retryAsLocalOwner then proves everything again in one transaction.
|
|
14394
|
+
*/
|
|
14395
|
+
async function localRetryCommand(positional, flags, context) {
|
|
14396
|
+
const { store, write, json, clock } = context;
|
|
14397
|
+
const command = "task requeue";
|
|
14398
|
+
for (const name of flags.keys())
|
|
14399
|
+
if (!["json", "db", "run", "digest"].includes(name))
|
|
14400
|
+
return fail(write, json, command, "usage", `--${name} is not a ${command} option.`, EXIT.usage);
|
|
14401
|
+
const id = positional[0], run = flags.get("run"), digest = flags.get("digest");
|
|
14402
|
+
const usage = "Use task requeue <task> --run <the build you read> --digest <its approved plan>.";
|
|
14403
|
+
if (positional.length !== 1 || !id || id.length > 64 || /[\x00-\x1f]/.test(id))
|
|
14404
|
+
return fail(write, json, command, "usage", usage, EXIT.usage);
|
|
14405
|
+
if (typeof run !== "string" || !/^[1-9]\d{0,14}$/.test(run))
|
|
14406
|
+
return fail(write, json, command, "usage", `--run needs the exact build you read. ${usage}`, EXIT.usage);
|
|
14407
|
+
// A scope digest (scope.ts digestOf): 32 hexadecimal characters.
|
|
14408
|
+
if (typeof digest !== "string" || !/^[a-f0-9]{32}$/.test(digest))
|
|
14409
|
+
return fail(write, json, command, "usage", `--digest needs the exact plan you read. ${usage}`, EXIT.usage);
|
|
14410
|
+
await proveTaskBuildsStopped(store, id, clock);
|
|
14411
|
+
const done = retryAsLocalOwner(store, id, { run: Number(run), digest }, clock());
|
|
14412
|
+
if (!done.ok)
|
|
14413
|
+
return fail(write, json, command, done.reason, done.message, EXIT.refused);
|
|
14414
|
+
return succeed(write, json, command, { id, replayed: done.replayed, retries: done.retries }, () => [
|
|
14415
|
+
done.replayed ? `${id} · Already retrying build #${run}.` : `${id} · Retrying build #${run}. A builder starts it next.`,
|
|
14416
|
+
]);
|
|
14417
|
+
}
|
package/dist/plan-auto.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { releaseUnattendedRun, reserveUnattendedRun, unattendedGrantStands } from "./flow-unattended.js";
|
|
2
2
|
import { modeTermsFromJson } from "./modes.js";
|
|
3
|
+
import { currentLocalOwner } from "./local-owner.js";
|
|
3
4
|
import { plannerContractOf, plannerSourceDigest } from "./planner-source.js";
|
|
4
5
|
import { modeFilingCoverage, propose, quarantinedAuthor } from "./scope.js";
|
|
5
6
|
export const PLAN_AUTO_SCHEMA = `CREATE TABLE IF NOT EXISTS plan_authorization (
|
|
@@ -37,6 +38,10 @@ export function applyModeToNewFiling(store, taskId, actor, now) {
|
|
|
37
38
|
* planner text. Rewrites, mode renewal, expiry and revocation fail closed. */
|
|
38
39
|
export function authorizePlanUnderMode(store, taskId, actor, now) {
|
|
39
40
|
return store.transact(() => {
|
|
41
|
+
// A laptop's local owner (no password) never pre-authorizes a plan under a mode, on any road (filing, the plan
|
|
42
|
+
// button, chat, the terminal): its plan waits for its own approval (local-owner slice 0).
|
|
43
|
+
if (currentLocalOwner() !== null)
|
|
44
|
+
return false;
|
|
40
45
|
const ref = store.lookupRef(taskId);
|
|
41
46
|
if (ref === null || ref.repo === null || ref.plan !== "requested" || !plainPlan(store, taskId) || store.hasLiveClaim(ref.id, now))
|
|
42
47
|
return false;
|
|
@@ -23,17 +23,18 @@ export declare function knowledgeVersion(store: Store, repo: string, actor: stri
|
|
|
23
23
|
/** Chat reads only an admitted project. Keep the full source in the project UI. */
|
|
24
24
|
export declare function conversationKnowledge(store: Store, repo: string, actor: string, reference?: string, decision?: number): {
|
|
25
25
|
title?: never;
|
|
26
|
+
references?: never;
|
|
26
27
|
content?: never;
|
|
27
28
|
revision: number;
|
|
28
29
|
stale: boolean;
|
|
29
30
|
notice: string;
|
|
30
31
|
history?: never;
|
|
31
32
|
instructions?: never;
|
|
32
|
-
references?: never;
|
|
33
33
|
decisions?: never;
|
|
34
34
|
waiting?: never;
|
|
35
35
|
} | {
|
|
36
36
|
title?: never;
|
|
37
|
+
references?: never;
|
|
37
38
|
content?: never;
|
|
38
39
|
stale?: never;
|
|
39
40
|
decision: {
|
|
@@ -55,10 +56,10 @@ export declare function conversationKnowledge(store: Store, repo: string, actor:
|
|
|
55
56
|
revision?: never;
|
|
56
57
|
history?: never;
|
|
57
58
|
instructions?: never;
|
|
58
|
-
references?: never;
|
|
59
59
|
decisions?: never;
|
|
60
60
|
waiting?: never;
|
|
61
61
|
} | {
|
|
62
|
+
references?: never;
|
|
62
63
|
stale?: never;
|
|
63
64
|
revision: number;
|
|
64
65
|
title: string;
|
|
@@ -66,7 +67,6 @@ export declare function conversationKnowledge(store: Store, repo: string, actor:
|
|
|
66
67
|
notice: string;
|
|
67
68
|
history?: never;
|
|
68
69
|
instructions?: never;
|
|
69
|
-
references?: never;
|
|
70
70
|
decisions?: never;
|
|
71
71
|
waiting?: never;
|
|
72
72
|
} | {
|
package/dist/public-access.d.ts
CHANGED
|
@@ -50,3 +50,12 @@ export declare function carriesToken(pathname: string, authorization: string | u
|
|
|
50
50
|
export declare function plainHttpRefusal(transport: Transport, pathname: string, authorization: string | undefined): string | null;
|
|
51
51
|
/** The Strict-Transport-Security value for a request to the configured public https host that arrived over HTTPS. */
|
|
52
52
|
export declare function hstsFor(publicHost: string | null, host: string | undefined, transport: Transport): string | null;
|
|
53
|
+
/** This computer, and nothing in front of it: a loopback peer that names a loopback address, with no sign of a proxy —
|
|
54
|
+
* any forwarding header at all disqualifies the request. serve.ts's fromThisComputer, and guards.ts's check on every
|
|
55
|
+
* request a local owner's session makes (local-owner slice 0), read it here. */
|
|
56
|
+
export declare function requestFromThisComputer(request: {
|
|
57
|
+
socket: {
|
|
58
|
+
remoteAddress?: string | undefined;
|
|
59
|
+
};
|
|
60
|
+
headers: Record<string, string | string[] | undefined>;
|
|
61
|
+
}): boolean;
|
package/dist/public-access.js
CHANGED
|
@@ -89,3 +89,18 @@ export function plainHttpRefusal(transport, pathname, authorization) {
|
|
|
89
89
|
export function hstsFor(publicHost, host, transport) {
|
|
90
90
|
return publicHost !== null && host === publicHost && transport.https ? HSTS_VALUE : null;
|
|
91
91
|
}
|
|
92
|
+
/** Any forwarding header at all (Forwarded, X-Forwarded-*, X-Real-IP, Via, …): a sign of a proxy in front. */
|
|
93
|
+
const PROXY_HEADER = /^(forwarded|via|x-forwarded-.+|x-real-ip|x-client-ip|x-original-.+|x-cluster-client-ip|true-client-ip|cf-connecting-ip|fastly-client-ip|x-proxyuser-ip)$/i;
|
|
94
|
+
/** This computer, and nothing in front of it: a loopback peer that names a loopback address, with no sign of a proxy —
|
|
95
|
+
* any forwarding header at all disqualifies the request. serve.ts's fromThisComputer, and guards.ts's check on every
|
|
96
|
+
* request a local owner's session makes (local-owner slice 0), read it here. */
|
|
97
|
+
export function requestFromThisComputer(request) {
|
|
98
|
+
const peer = request.socket.remoteAddress ?? "";
|
|
99
|
+
if (!LOOPBACK_PEERS.has(peer))
|
|
100
|
+
return false;
|
|
101
|
+
for (const name of Object.keys(request.headers))
|
|
102
|
+
if (PROXY_HEADER.test(name))
|
|
103
|
+
return false;
|
|
104
|
+
const host = request.headers["host"];
|
|
105
|
+
return /^(localhost|127\.0\.0\.1|\[::1\]):[0-9]{1,5}$/.test(typeof host === "string" ? host : "");
|
|
106
|
+
}
|
|
@@ -23,6 +23,7 @@ import { assignmentOf, checkAssignmentAsOperator } from "./assignment.js";
|
|
|
23
23
|
import { requestResultChanges } from "./result-actions.js";
|
|
24
24
|
import { revisionSourceOf } from "./result-review.js";
|
|
25
25
|
import { authenticateApprover, personProof } from "./scope.js";
|
|
26
|
+
import { currentLocalOwner } from "./local-owner.js";
|
|
26
27
|
import { BRANCH_PREFIX, headWithin } from "./names.js";
|
|
27
28
|
import { NIGHTLY_CHECK_ONLY, RELEASE_MERGES_ONLY, nightlyTaskRef, refArchived, releaseTaskRef } from "./store.js";
|
|
28
29
|
import { belongsToRelease } from "./release-authority.js";
|
|
@@ -167,6 +168,11 @@ export function pullRequestBlocker(store, runId) {
|
|
|
167
168
|
*/
|
|
168
169
|
export function completeAndOpenPullRequest(store, input, now) {
|
|
169
170
|
return store.transact(() => {
|
|
171
|
+
// A laptop's local owner (no password) never opens a pull request: it publishes outside this computer. Refused
|
|
172
|
+
// before anything is read or written, whatever the result (local-owner slice 0).
|
|
173
|
+
const local = currentLocalOwner();
|
|
174
|
+
if (local !== null)
|
|
175
|
+
return refuse("local-owner", `Opening a pull request needs your password. ${store.accountOf(local.account)?.passwordSet === true ? "Sign in with your password to do this." : "Set a password first: run \`toolroll password set\`."}`);
|
|
170
176
|
const current = assignmentOf(store, input.taskId, now, { principal: "operator", repos: input.who.repos }, input.root);
|
|
171
177
|
const receipt = current?.receipt ?? null;
|
|
172
178
|
if (current === null || receipt === null || receipt.runId !== input.runId || receipt.digest !== input.digest) {
|
|
@@ -191,6 +197,9 @@ export function completeAndOpenPullRequest(store, input, now) {
|
|
|
191
197
|
*/
|
|
192
198
|
export function owePullRequest(store, runId, actor, flowCard, now) {
|
|
193
199
|
return store.transact(() => {
|
|
200
|
+
// The belt under every road that owes one: a local owner's chain never does (local-owner slice 0).
|
|
201
|
+
if (currentLocalOwner() !== null)
|
|
202
|
+
throw new Error("Opening a pull request needs your password.");
|
|
194
203
|
const run = store.getRun(runId);
|
|
195
204
|
const ref = store.refById(run.taskRef);
|
|
196
205
|
const grant = publicationGrantOf(store, run.taskRef).grant;
|
package/dist/release-card.d.ts
CHANGED
|
@@ -82,10 +82,24 @@ export declare function releaseBaselineRecord(store: Store, repo: string): {
|
|
|
82
82
|
recorded: true;
|
|
83
83
|
sha: string | null;
|
|
84
84
|
};
|
|
85
|
+
/** How long the change list's settings check may take before the list is unread. */
|
|
86
|
+
export declare const CONFIG_LIST_MS = 60000;
|
|
85
87
|
/** The commits after `from` up to `to`, as verified Git objects only, from the project's recorded Git directory: the
|
|
86
88
|
* release coordinator's checkout config allowlist first, then commit-extract's verified range (every commit hashed here,
|
|
87
89
|
* a shallow boundary, grafts or replacement refs refused, and the range proved to stop only in the last release's own
|
|
88
|
-
* history).
|
|
90
|
+
* history). When they can't be read, why, in Toolroll's own fixed words (never the repository's text), so a card says
|
|
91
|
+
* why it shows no list rather than a shortened one. */
|
|
92
|
+
export declare function readCommitsBetween(repo: string, from: string, to: string): {
|
|
93
|
+
ok: true;
|
|
94
|
+
commits: {
|
|
95
|
+
sha: string;
|
|
96
|
+
subject: string;
|
|
97
|
+
}[];
|
|
98
|
+
} | {
|
|
99
|
+
ok: false;
|
|
100
|
+
why: string;
|
|
101
|
+
};
|
|
102
|
+
/** readCommitsBetween's list, or null when it can't be read. */
|
|
89
103
|
export declare function commitsBetween(repo: string, from: string, to: string): {
|
|
90
104
|
sha: string;
|
|
91
105
|
subject: string;
|
package/dist/release-card.js
CHANGED
|
@@ -207,32 +207,52 @@ export function releaseBaselineRecord(store, repo) {
|
|
|
207
207
|
const ref = row["external_ref"];
|
|
208
208
|
return { recorded: true, sha: typeof ref === "string" && SHA.test(ref) ? ref : null };
|
|
209
209
|
}
|
|
210
|
+
/** How long the change list's settings check may take before the list is unread. */
|
|
211
|
+
export const CONFIG_LIST_MS = 60_000;
|
|
210
212
|
/** The commits after `from` up to `to`, as verified Git objects only, from the project's recorded Git directory: the
|
|
211
213
|
* release coordinator's checkout config allowlist first, then commit-extract's verified range (every commit hashed here,
|
|
212
214
|
* a shallow boundary, grafts or replacement refs refused, and the range proved to stop only in the last release's own
|
|
213
|
-
* history).
|
|
214
|
-
|
|
215
|
+
* history). When they can't be read, why, in Toolroll's own fixed words (never the repository's text), so a card says
|
|
216
|
+
* why it shows no list rather than a shortened one. */
|
|
217
|
+
export function readCommitsBetween(repo, from, to) {
|
|
215
218
|
if (!SHA.test(from) || !SHA.test(to))
|
|
216
|
-
return
|
|
219
|
+
return { ok: false, why: "a commit id isn't a full id" };
|
|
217
220
|
let gitDir;
|
|
218
221
|
try {
|
|
219
222
|
const named = join(realpathSync(repo), ".git");
|
|
220
223
|
if (!lstatSync(named).isDirectory())
|
|
221
|
-
return
|
|
224
|
+
return { ok: false, why: "the project has no Git folder of its own" };
|
|
222
225
|
gitDir = realpathSync(named);
|
|
223
226
|
}
|
|
224
227
|
catch {
|
|
225
|
-
return
|
|
228
|
+
return { ok: false, why: "the project's Git folder can't be read" };
|
|
226
229
|
}
|
|
227
230
|
const tools = releaseTools();
|
|
228
231
|
const env = { ...releaseGitEnv(), PATH: RELEASE_PATH };
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
+
// The list's first Git call took over 15 s on GitHub's busy macOS runners (2026-11-12, "timed out after 15 s"; the
|
|
233
|
+
// same call takes 0.1 s on an idle Mac), and the card lost its change list; the verified range's own Git calls allow
|
|
234
|
+
// far longer. One minute, then the list is unread, as before.
|
|
235
|
+
const listing = spawnSync(tools.git, [`--git-dir=${gitDir}`, "--no-optional-locks", "config", "--list", "--no-includes", "--show-scope", "-z"], { cwd: "/", env, encoding: "utf8", timeout: CONFIG_LIST_MS });
|
|
236
|
+
if (listing.status !== 0) {
|
|
237
|
+
const how = listing.error?.code === "ETIMEDOUT" ? `timed out after ${CONFIG_LIST_MS / 1000} s`
|
|
238
|
+
: listing.signal !== null ? `stopped by ${listing.signal}` : listing.error !== undefined ? "couldn't start" : `exited ${listing.status}`;
|
|
239
|
+
return { ok: false, why: `Git couldn't list the project's settings (${how})` };
|
|
240
|
+
}
|
|
241
|
+
// The key is the repository's own text: named only by kind, never echoed.
|
|
242
|
+
if (releaseConfigProblem(listing.stdout) !== null)
|
|
243
|
+
return { ok: false, why: "the project's Git settings hold an entry a release read refuses" };
|
|
232
244
|
const range = verifiedRange(gitDir, from, to, { git: tools.git, env });
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
245
|
+
// commit-extract's own words, made one control-free line like any shown text, in the card's lower-case clause form.
|
|
246
|
+
if (!range.ok) {
|
|
247
|
+
const said = subjectText(range.message).replace(/\.$/, "");
|
|
248
|
+
return { ok: false, why: /^[A-Z][a-z]/.test(said) ? said[0].toLowerCase() + said.slice(1) : said };
|
|
249
|
+
}
|
|
250
|
+
return { ok: true, commits: range.value.map(one => ({ sha: one.id, subject: subjectText(one.message) })) };
|
|
251
|
+
}
|
|
252
|
+
/** readCommitsBetween's list, or null when it can't be read. */
|
|
253
|
+
export function commitsBetween(repo, from, to) {
|
|
254
|
+
const read = readCommitsBetween(repo, from, to);
|
|
255
|
+
return read.ok ? read.commits : null;
|
|
236
256
|
}
|
|
237
257
|
/** GitHub lists at most this many of a pull request's commits; a longer one is never shown as complete. */
|
|
238
258
|
export const PULL_REQUEST_COMMITS_MAX = 250;
|
|
@@ -355,7 +375,10 @@ export function releaseChangeList(store, terms, read) {
|
|
|
355
375
|
let source;
|
|
356
376
|
let why;
|
|
357
377
|
if (baseline !== null) {
|
|
358
|
-
|
|
378
|
+
const read = readCommitsBetween(terms.repo, baseline, terms.approvedSha);
|
|
379
|
+
commits = read.ok ? read.commits : null;
|
|
380
|
+
if (!read.ok)
|
|
381
|
+
why = read.why;
|
|
359
382
|
source = commits === null ? "unread" : "recorded";
|
|
360
383
|
}
|
|
361
384
|
else {
|
|
@@ -21,8 +21,12 @@ export declare function familyCompleted(store: Store, taskId: string): boolean;
|
|
|
21
21
|
export declare function changedFilesOf(store: Store, runId: number, root: string): string[] | null;
|
|
22
22
|
/** Everything a result's branch carries: this build's changes and every build of every task it revises (a
|
|
23
23
|
* revision builds on its source's head, so its branch holds the source's commits too). null when any of
|
|
24
|
-
* them can't be read whole.
|
|
25
|
-
|
|
24
|
+
* them can't be read whole. An earlier build with no recorded diff is skipped by default; with
|
|
25
|
+
* `missing: "unknown"` (the local owner's boundary) it is skipped only when its run record proves it committed
|
|
26
|
+
* nothing, and any other one makes the whole answer unknown. */
|
|
27
|
+
export declare function familyChangedFiles(store: Store, taskId: string, runId: number, root: string, options?: {
|
|
28
|
+
missing?: "skip" | "unknown";
|
|
29
|
+
}): string[] | null;
|
|
26
30
|
/** Why a build's branch must wait before it's pushed (and so before any PR or merge): it (or a task it
|
|
27
31
|
* revises) changed protected files on a one-person approval, and nobody but its approver and requesters
|
|
28
32
|
* has marked it complete. */
|
|
@@ -42,10 +42,12 @@ export function changedFilesOf(store, runId, root) {
|
|
|
42
42
|
}
|
|
43
43
|
/** Everything a result's branch carries: this build's changes and every build of every task it revises (a
|
|
44
44
|
* revision builds on its source's head, so its branch holds the source's commits too). null when any of
|
|
45
|
-
* them can't be read whole.
|
|
46
|
-
|
|
45
|
+
* them can't be read whole. An earlier build with no recorded diff is skipped by default; with
|
|
46
|
+
* `missing: "unknown"` (the local owner's boundary) it is skipped only when its run record proves it committed
|
|
47
|
+
* nothing, and any other one makes the whole answer unknown. */
|
|
48
|
+
export function familyChangedFiles(store, taskId, runId, root, options = {}) {
|
|
47
49
|
const files = new Set();
|
|
48
|
-
const runs = new
|
|
50
|
+
const runs = new Map([[runId, null]]);
|
|
49
51
|
const chain = store.revisionAncestryStatus(taskId).chain;
|
|
50
52
|
for (const id of chain.length === 0 ? [taskId] : chain) {
|
|
51
53
|
const ref = store.lookupRef(id);
|
|
@@ -53,12 +55,16 @@ export function familyChangedFiles(store, taskId, runId, root) {
|
|
|
53
55
|
continue;
|
|
54
56
|
for (const run of store.runsFor(ref.id))
|
|
55
57
|
if ((run.role === "builder" || run.role === "repair") && (id !== taskId || run.id === runId))
|
|
56
|
-
runs.
|
|
58
|
+
runs.set(run.id, run);
|
|
57
59
|
}
|
|
58
|
-
for (const id of runs) {
|
|
60
|
+
for (const [id, run] of runs) {
|
|
59
61
|
// A build that never recorded a diff contributed nothing to the branch; one whose diff can't be read counts as protected.
|
|
60
|
-
|
|
61
|
-
|
|
62
|
+
// Under "unknown", a missing inventory proves nothing by itself: only a finished run that committed nothing is skipped.
|
|
63
|
+
if (!store.artifactsFor(id).some(one => one.kind === "diff-stat") && id !== runId) {
|
|
64
|
+
if (options.missing !== "unknown" || (run?.committed === false && run.finishedAt !== null))
|
|
65
|
+
continue;
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
62
68
|
const changed = changedFilesOf(store, id, root);
|
|
63
69
|
if (changed === null)
|
|
64
70
|
return null;
|
package/dist/scope.d.ts
CHANGED
|
@@ -1044,7 +1044,7 @@ export declare function releaseApprovalOf(store: Store, taskId: string, _now?: D
|
|
|
1044
1044
|
/** When a password typed at `at` (ISO) stops standing in, for a page's words (ISO); null when `at` isn't a time. Words
|
|
1045
1045
|
* only: freshnessFor decides whether it stands in, at the moment of use. */
|
|
1046
1046
|
export declare function freshUntil(at: string): string | null;
|
|
1047
|
-
export type LocalApprovalRefusal = "local-mode-off" | "not-owner" | "no-scope" | "release" | "protected" | "escalated" | "coordinator" | "unsandboxed";
|
|
1047
|
+
export type LocalApprovalRefusal = "local-mode-off" | "not-owner" | "no-scope" | "release" | "protected" | "escalated" | "coordinator" | "unsandboxed" | "imported";
|
|
1048
1048
|
/**
|
|
1049
1049
|
* Whether the local owner may approve this task without a password (easy-start plan C5). ONE rule, read by the
|
|
1050
1050
|
* preview (the task page's approve form and the chat review render no password field when it is ok), the chat
|
|
@@ -1065,3 +1065,34 @@ export declare function liveChainFromJson(json: string | null): ChainEntry[] | n
|
|
|
1065
1065
|
/** The chain in full, the words a yes agrees to: order, agent, model, how it bills, what it may do, and whether Toolroll
|
|
1066
1066
|
* can see it run out. Every approval surface shows these lines; empty for a scope with no chain. */
|
|
1067
1067
|
export declare function chainTermsLines(scope: Pick<Scope, "proposedChainJson">): string[];
|
|
1068
|
+
/** The act a local owner's work rule is read for: approving a plan, or completing (accepting) a result whose build
|
|
1069
|
+
* changed `changed` (the build's own diff; null when it can't be read, which counts as protected). */
|
|
1070
|
+
export type LocalWorkPurpose = {
|
|
1071
|
+
act: "approve";
|
|
1072
|
+
} | {
|
|
1073
|
+
act: "complete";
|
|
1074
|
+
changed: readonly string[] | null;
|
|
1075
|
+
} | {
|
|
1076
|
+
act: "plan";
|
|
1077
|
+
} | {
|
|
1078
|
+
act: "retry";
|
|
1079
|
+
};
|
|
1080
|
+
/**
|
|
1081
|
+
* Whether the local owner may do this act on this task without a password: ONE rule (easy-start C5, split out for
|
|
1082
|
+
* local-owner slice 0), read inside the act's own transaction. Both acts refuse: local mode off or another person;
|
|
1083
|
+
* release or deploy work; protected or two-person work; work a coordinator filed (or a task with no built-in record).
|
|
1084
|
+
* Approving also refuses no plan, an escalated profile and a project stored as unsandboxed (what a build may do, which
|
|
1085
|
+
* is what an approval agrees to). Completing reads release work from the release records too, and protected work from
|
|
1086
|
+
* what the result actually changed as well as what its scope declared (v102's rule: declared paths are only a promise).
|
|
1087
|
+
* Asking for a plan (local-owner slice 2) reads approve's rule for a task that may have no scope yet (no declared paths
|
|
1088
|
+
* count as protected; the task's own permission mode counts as escalated), the release records too, and also refuses a
|
|
1089
|
+
* task from outside (an imported issue): a new act, so no existing act changes. Retrying (slice 3) is approve's rule
|
|
1090
|
+
* exactly (work the owner could approve again now), plus the release records and a task from outside.
|
|
1091
|
+
*/
|
|
1092
|
+
export declare function localWorkEligible(store: Store, by: string, taskId: string, purpose: LocalWorkPurpose): {
|
|
1093
|
+
ok: true;
|
|
1094
|
+
} | {
|
|
1095
|
+
ok: false;
|
|
1096
|
+
reason: LocalApprovalRefusal;
|
|
1097
|
+
message: string;
|
|
1098
|
+
};
|
package/dist/scope.js
CHANGED
|
@@ -1449,11 +1449,12 @@ function describeApproval(approval) {
|
|
|
1449
1449
|
// ---- Only a person approves (docs/plans/approval-policy.md, phase 1). Kept last: the freshness tripwire pins places.
|
|
1450
1450
|
export { personProof } from "./person-proof.js";
|
|
1451
1451
|
import { personProof } from "./person-proof.js";
|
|
1452
|
+
import { isProtectedWork, protectedChanges } from "./approval-policy.js";
|
|
1452
1453
|
import { permissionPlainWords } from "./chat-terms.js";
|
|
1453
1454
|
import { limitRecognised } from "./exhaustion.js";
|
|
1454
1455
|
import { SUBSCRIPTION_CAPABLE } from "./keys.js";
|
|
1455
1456
|
import { releaseCheckPurposeDigest, releaseTermsDigest } from "./contracts/release-terms.js";
|
|
1456
|
-
import { CHECK_ACCESS_WIDTH, autoEvidenceOf, carriesUnsandboxed, newestReleaseGeneration, releaseAuthorityOfTask, releaseAutoApproves, releaseSealProblem, releaseStateAt } from "./release-authority.js";
|
|
1457
|
+
import { CHECK_ACCESS_WIDTH, autoEvidenceOf, belongsToRelease, carriesUnsandboxed, newestReleaseGeneration, releaseAuthorityOfTask, releaseAutoApproves, releaseSealProblem, releaseStateAt } from "./release-authority.js";
|
|
1457
1458
|
/** Whether the command running now is a machine's: the lead (its token) or a remote API run. Trusted context only. */
|
|
1458
1459
|
export const machineActing = () => currentActor()?.lead === true || activeRemote() !== null;
|
|
1459
1460
|
/** A proof row's detail, either form: the legacy text (`<at> · generation <n> · digest <d>`) or the versioned record
|
|
@@ -1709,6 +1710,8 @@ const LOCAL_APPROVAL_WORDS = {
|
|
|
1709
1710
|
escalated: "Work with wider permissions needs your password.",
|
|
1710
1711
|
coordinator: "Work a coordinator filed needs your password.",
|
|
1711
1712
|
unsandboxed: "This project's checks run without a sandbox, so approving needs your password.",
|
|
1713
|
+
// Not an approve refusal (approve keeps today's rule); the type needs words for it.
|
|
1714
|
+
imported: "A task from outside needs your password.",
|
|
1712
1715
|
};
|
|
1713
1716
|
/**
|
|
1714
1717
|
* Whether the local owner may approve this task without a password (easy-start plan C5). ONE rule, read by the
|
|
@@ -1719,30 +1722,8 @@ const LOCAL_APPROVAL_WORDS = {
|
|
|
1719
1722
|
* a scout proposed are allowed.
|
|
1720
1723
|
*/
|
|
1721
1724
|
export function localApprovalEligible(store, by, taskId) {
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
return no("local-mode-off");
|
|
1725
|
-
const owner = store.handle.prepare("SELECT owner FROM local_mode WHERE id = 1").get()?.["owner"];
|
|
1726
|
-
if (owner !== by)
|
|
1727
|
-
return no("not-owner");
|
|
1728
|
-
const scope = store.getScope(taskId);
|
|
1729
|
-
if (scope === null)
|
|
1730
|
-
return no("no-scope");
|
|
1731
|
-
if (deployGateScope(scope) || releaseCheckScope(scope))
|
|
1732
|
-
return no("release");
|
|
1733
|
-
const gate = store.approvalGate(taskId, by, "person");
|
|
1734
|
-
if (gate.verdict !== "seal" || gate.protectedWork)
|
|
1735
|
-
return no("protected");
|
|
1736
|
-
if (scope.profile == null || levelOfProfile(scope.profile) === "escalated")
|
|
1737
|
-
return no("escalated");
|
|
1738
|
-
const ref = store.handle.prepare("SELECT repo, coordinator_cid FROM task_ref WHERE backend = 'built-in' AND external_id = ?").get(taskId);
|
|
1739
|
-
if (ref === undefined || ref["coordinator_cid"] != null)
|
|
1740
|
-
return no("coordinator");
|
|
1741
|
-
// The stored level, never the launch cap: a project saved as unsandboxed is refused even though local mode confines it.
|
|
1742
|
-
const stored = ref["repo"] == null ? undefined : store.handle.prepare("SELECT level FROM project_check_access WHERE repo = ?").get(ref["repo"])?.["level"];
|
|
1743
|
-
if (stored === "unsandboxed" || scope.profile.checkAccess === "unsandboxed")
|
|
1744
|
-
return no("unsandboxed");
|
|
1745
|
-
return { ok: true };
|
|
1725
|
+
// The one rule, shared with completing a result (localWorkEligible, below).
|
|
1726
|
+
return localWorkEligible(store, by, taskId, { act: "approve" });
|
|
1746
1727
|
}
|
|
1747
1728
|
// ---- Fallback chains (fallback.ts). Kept last too.
|
|
1748
1729
|
/** A chain that may dispatch: version 2, a base and at least one fallback. An older chain is history, never this. */
|
|
@@ -1768,3 +1749,107 @@ export function chainTermsLines(scope) {
|
|
|
1768
1749
|
}),
|
|
1769
1750
|
];
|
|
1770
1751
|
}
|
|
1752
|
+
// ---- Local owner, slice 0 (docs/plans/local-owner-acts.md): one work rule for a no-password act. Kept last.
|
|
1753
|
+
/** What a local owner hears when a result isn't one it may accept without a password. */
|
|
1754
|
+
const LOCAL_COMPLETION_WORDS = {
|
|
1755
|
+
"local-mode-off": "Local mode is off, so accepting this result needs your password.",
|
|
1756
|
+
"not-owner": "Only this computer's owner accepts results here without a password.",
|
|
1757
|
+
"no-scope": "This task has no plan, so accepting its result needs your password.",
|
|
1758
|
+
release: "Accepting a release needs your password.",
|
|
1759
|
+
protected: "This result is protected work, so accepting it needs your password and a second person.",
|
|
1760
|
+
escalated: "Work with wider permissions needs your password.",
|
|
1761
|
+
coordinator: "Work a coordinator filed needs your password to accept.",
|
|
1762
|
+
unsandboxed: "This project's checks run without a sandbox, so accepting needs your password.",
|
|
1763
|
+
// Not a completion refusal (completion keeps slice 0's rule); the type needs words for it.
|
|
1764
|
+
imported: "A task from outside needs your password to accept.",
|
|
1765
|
+
};
|
|
1766
|
+
/** What a local owner hears when a task isn't one it may ask a plan for without a password (local-owner slice 2). */
|
|
1767
|
+
const LOCAL_PLAN_WORDS = {
|
|
1768
|
+
"local-mode-off": "Local mode is off, so asking for a plan needs your password.",
|
|
1769
|
+
"not-owner": "Only this computer's owner asks for plans here without a password.",
|
|
1770
|
+
"no-scope": "This task has nothing to plan yet.",
|
|
1771
|
+
release: "Planning a release needs your password.",
|
|
1772
|
+
protected: "This is protected work, so planning it needs your password.",
|
|
1773
|
+
escalated: "Work with wider permissions needs your password to plan.",
|
|
1774
|
+
coordinator: "Work a coordinator filed needs your password to plan.",
|
|
1775
|
+
unsandboxed: "This project's checks run without a sandbox, so planning needs your password.",
|
|
1776
|
+
imported: "A task from outside needs your password to plan.",
|
|
1777
|
+
};
|
|
1778
|
+
/** What a local owner hears when a build isn't one it may retry without a password (local-owner slice 3). */
|
|
1779
|
+
const LOCAL_RETRY_WORDS = {
|
|
1780
|
+
"local-mode-off": "Local mode is off, so retrying needs your password.",
|
|
1781
|
+
"not-owner": "Only this computer's owner retries builds here without a password.",
|
|
1782
|
+
"no-scope": "This task has no approved plan to build again.",
|
|
1783
|
+
release: "Retrying a release needs your password.",
|
|
1784
|
+
protected: "This is protected work, so retrying it needs your password.",
|
|
1785
|
+
escalated: "Work with wider permissions needs your password to retry.",
|
|
1786
|
+
coordinator: "Work a coordinator filed needs your password to retry.",
|
|
1787
|
+
unsandboxed: "This project's checks run without a sandbox, so retrying needs your password.",
|
|
1788
|
+
imported: "A task from outside needs your password to retry.",
|
|
1789
|
+
};
|
|
1790
|
+
/**
|
|
1791
|
+
* Whether the local owner may do this act on this task without a password: ONE rule (easy-start C5, split out for
|
|
1792
|
+
* local-owner slice 0), read inside the act's own transaction. Both acts refuse: local mode off or another person;
|
|
1793
|
+
* release or deploy work; protected or two-person work; work a coordinator filed (or a task with no built-in record).
|
|
1794
|
+
* Approving also refuses no plan, an escalated profile and a project stored as unsandboxed (what a build may do, which
|
|
1795
|
+
* is what an approval agrees to). Completing reads release work from the release records too, and protected work from
|
|
1796
|
+
* what the result actually changed as well as what its scope declared (v102's rule: declared paths are only a promise).
|
|
1797
|
+
* Asking for a plan (local-owner slice 2) reads approve's rule for a task that may have no scope yet (no declared paths
|
|
1798
|
+
* count as protected; the task's own permission mode counts as escalated), the release records too, and also refuses a
|
|
1799
|
+
* task from outside (an imported issue): a new act, so no existing act changes. Retrying (slice 3) is approve's rule
|
|
1800
|
+
* exactly (work the owner could approve again now), plus the release records and a task from outside.
|
|
1801
|
+
*/
|
|
1802
|
+
export function localWorkEligible(store, by, taskId, purpose) {
|
|
1803
|
+
const words = purpose.act === "approve" ? LOCAL_APPROVAL_WORDS : purpose.act === "complete" ? LOCAL_COMPLETION_WORDS : purpose.act === "plan" ? LOCAL_PLAN_WORDS : LOCAL_RETRY_WORDS;
|
|
1804
|
+
const no = (reason) => ({ ok: false, reason, message: words[reason] });
|
|
1805
|
+
if (!localModeOn(store))
|
|
1806
|
+
return no("local-mode-off");
|
|
1807
|
+
const owner = store.handle.prepare("SELECT owner FROM local_mode WHERE id = 1").get()?.["owner"];
|
|
1808
|
+
if (owner !== by)
|
|
1809
|
+
return no("not-owner");
|
|
1810
|
+
const scope = store.getScope(taskId);
|
|
1811
|
+
if ((purpose.act === "approve" || purpose.act === "retry") && scope === null)
|
|
1812
|
+
return no("no-scope");
|
|
1813
|
+
if (scope !== null && (deployGateScope(scope) || releaseCheckScope(scope)))
|
|
1814
|
+
return no("release");
|
|
1815
|
+
if (purpose.act !== "approve" && (belongsToRelease(store, taskId) || releaseAuthorityOfTask(store, taskId) !== null))
|
|
1816
|
+
return no("release");
|
|
1817
|
+
const ref = store.handle.prepare("SELECT repo, coordinator_cid, permission_mode FROM task_ref WHERE backend = 'built-in' AND external_id = ?").get(taskId);
|
|
1818
|
+
// Retrying spends again under an existing yes: only on work the owner could approve again now (approve's own rule).
|
|
1819
|
+
if (purpose.act === "approve" || purpose.act === "retry") {
|
|
1820
|
+
const gate = store.approvalGate(taskId, by, "person");
|
|
1821
|
+
if (gate.verdict !== "seal" || gate.protectedWork)
|
|
1822
|
+
return no("protected");
|
|
1823
|
+
if (scope.profile == null || levelOfProfile(scope.profile) === "escalated")
|
|
1824
|
+
return no("escalated");
|
|
1825
|
+
}
|
|
1826
|
+
else if (purpose.act === "plan") {
|
|
1827
|
+
// Approve's rule, read for a task that may have no scope yet: protected or two-person work (no declared paths count
|
|
1828
|
+
// as protected), and an escalated profile or task-level permission mode.
|
|
1829
|
+
const gate = store.approvalGate(taskId, by, "person");
|
|
1830
|
+
if (gate.verdict !== "seal" || gate.protectedWork)
|
|
1831
|
+
return no("protected");
|
|
1832
|
+
if ((scope?.profile != null && levelOfProfile(scope.profile) === "escalated") || ref?.["permission_mode"] === "bypassPermissions")
|
|
1833
|
+
return no("escalated");
|
|
1834
|
+
}
|
|
1835
|
+
else if (ref !== undefined && ref["repo"] != null) {
|
|
1836
|
+
// Declared (no declared paths counts as protected) or actually changed (an unreadable diff counts as changed).
|
|
1837
|
+
const rules = store.approvalRules(String(ref["repo"]));
|
|
1838
|
+
if ((rules.protectProject || rules.protectedPaths.length > 0)
|
|
1839
|
+
&& (isProtectedWork(rules, scope?.touches ?? []) || purpose.changed === null || protectedChanges(rules, purpose.changed).length > 0))
|
|
1840
|
+
return no("protected");
|
|
1841
|
+
}
|
|
1842
|
+
if (ref === undefined || ref["coordinator_cid"] != null)
|
|
1843
|
+
return no("coordinator");
|
|
1844
|
+
// What the build may do was agreed at its approval; a result already built is judged by what it is and changed.
|
|
1845
|
+
if (purpose.act === "complete")
|
|
1846
|
+
return { ok: true };
|
|
1847
|
+
// Asking for a plan (a new act, so no behaviour changes): never a task from outside (a GitHub import, or a revision of one).
|
|
1848
|
+
if ((purpose.act === "plan" || purpose.act === "retry") && issueImported(store, taskId))
|
|
1849
|
+
return no("imported");
|
|
1850
|
+
// The stored level, never the launch cap: a project saved as unsandboxed is refused even though local mode confines it.
|
|
1851
|
+
const stored = ref["repo"] == null ? undefined : store.handle.prepare("SELECT level FROM project_check_access WHERE repo = ?").get(ref["repo"])?.["level"];
|
|
1852
|
+
if (stored === "unsandboxed" || scope?.profile?.checkAccess === "unsandboxed")
|
|
1853
|
+
return no("unsandboxed");
|
|
1854
|
+
return { ok: true };
|
|
1855
|
+
}
|