toolroll 0.9.57 → 0.9.59
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 +15 -0
- package/dist/action-ledger.js +10 -5
- package/dist/actor.d.ts +0 -10
- package/dist/actor.js +1 -18
- package/dist/api-tokens.d.ts +33 -2
- package/dist/api-tokens.js +39 -11
- package/dist/assignment-delivery.js +1 -1
- package/dist/assignment-status.js +1 -1
- package/dist/assignment.js +3 -2
- package/dist/browser/workspace.js +1 -1
- package/dist/builder.js +4 -2
- package/dist/chat-channel.d.ts +1 -0
- package/dist/chat-channel.js +3 -0
- package/dist/chat-delivery.js +2 -0
- package/dist/cli-http.d.ts +7 -0
- package/dist/cli-http.js +30 -4
- package/dist/cli.js +2 -1
- package/dist/contract-changes.d.ts +52 -0
- package/dist/contract-changes.js +61 -0
- package/dist/coordinator.d.ts +2 -1
- package/dist/coordinator.js +22 -31
- package/dist/decision.d.ts +4 -0
- package/dist/decision.js +8 -0
- package/dist/deploy-lock.d.ts +8 -0
- package/dist/deploy-lock.js +45 -0
- package/dist/desktop-update.d.ts +1 -1
- package/dist/dispatch.js +1 -1
- package/dist/export.js +2 -2
- package/dist/lead-cli.js +3 -0
- package/dist/lead-doors.js +3 -0
- package/dist/lead-tools.js +2 -1
- package/dist/mcp-http.js +5 -5
- package/dist/needs-you.js +3 -1
- package/dist/operate.js +23 -11
- package/dist/password-bearer.d.ts +27 -0
- package/dist/password-bearer.js +59 -0
- package/dist/planner-source.d.ts +23 -53
- package/dist/planner-source.js +43 -68
- package/dist/release-gate.d.ts +2 -3
- package/dist/release-gate.js +44 -16
- package/dist/remote-command.js +0 -2
- package/dist/remote-exec.d.ts +19 -0
- package/dist/remote-exec.js +9 -8
- package/dist/request-budget.d.ts +1 -1
- package/dist/scope.js +16 -1
- package/dist/serve.js +15 -0
- package/dist/server/edge-refusal.d.ts +2 -0
- package/dist/server/edge-refusal.js +8 -6
- package/dist/server/guards.js +5 -4
- package/dist/server/http.d.ts +2 -0
- package/dist/server/remote.js +6 -13
- package/dist/server/render-chat.js +10 -0
- package/dist/server/render-tasks.js +19 -9
- package/dist/server/route-table.js +0 -3
- package/dist/server/tasks.js +3 -1
- package/dist/session-cli.d.ts +38 -9
- package/dist/session-cli.js +141 -154
- package/dist/session-contract.js +2 -2
- package/dist/session-remote.d.ts +19 -0
- package/dist/session-remote.js +86 -0
- package/dist/store.d.ts +72 -12
- package/dist/store.js +367 -105
- package/dist/task-status.js +3 -1
- package/dist/team-cli.d.ts +3 -0
- package/dist/team-cli.js +23 -3
- package/dist/telegram-lead.js +6 -0
- package/dist/telegram.js +9 -7
- package/dist/toolroll-update.js +10 -1
- package/package.json +1 -1
- package/dist/session-http.d.ts +0 -24
- package/dist/session-http.js +0 -170
- package/dist/session-server.d.ts +0 -17
- package/dist/session-server.js +0 -39
package/dist/needs-you.js
CHANGED
|
@@ -20,7 +20,9 @@ export const NEEDS = {
|
|
|
20
20
|
hold: { sentence: () => "A hold is keeping this task from starting. Review it to release it.", action: { code: "inspect-hold", label: "Review hold" } },
|
|
21
21
|
"choose-project": { sentence: () => "Choose a project so a builder can start.", action: { code: "place-task", label: "Choose a project" } },
|
|
22
22
|
"define-task": { sentence: () => "Say what the task should do so it can be planned.", action: { code: "write-scope", label: "Define the task" } },
|
|
23
|
-
"fix-request": { sentence: () => "The request can't be planned as written. Edit it so the planner can start.", action: { code: "write-scope", label: "Edit the request" }
|
|
23
|
+
"fix-request": { sentence: () => "The request can't be planned as written. Edit it so the planner can start.", action: { code: "write-scope", label: "Edit the request" },
|
|
24
|
+
// Its own size and limit say exactly what to shorten.
|
|
25
|
+
useReason: reason => /the planner can carry at most/.test(reason) },
|
|
24
26
|
"choose-agent": { sentence: () => "Choose an agent to build this.", action: { code: "select-agent", label: "Choose an agent" } },
|
|
25
27
|
"fix-dependency": { sentence: () => "A task this one waits on didn't finish. Fix or remove it so this one can start.", action: { code: "repair-dependency", label: "Fix the required task" },
|
|
26
28
|
useReason: reason => /didn't finish|did not finish|before it finished/i.test(reason), withReason: reason => `${reason.replace(/[.\s]*$/, ".")} Fix or remove it so this one can start.` },
|
package/dist/operate.js
CHANGED
|
@@ -96,7 +96,8 @@ import { deleteProject, holdingsWords, projectHoldings, projectRunning } from ".
|
|
|
96
96
|
import { pushPass } from "./push.js";
|
|
97
97
|
import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, fsyncSync, openSync, readFileSync, readSync, realpathSync, renameSync, unlinkSync, writeSync, writeFileSync } from "node:fs";
|
|
98
98
|
import { BRANCH_PREFIX, envTwins, envValue, existingOrFirst, taskBranches } from "./names.js";
|
|
99
|
-
import { claimActor, currentActor,
|
|
99
|
+
import { claimActor, currentActor, withActor } from "./actor.js";
|
|
100
|
+
import { parseLeadToken } from "./api-tokens.js";
|
|
100
101
|
import { CLI_PASSWORD_SOURCE, withPasswordSource } from "./sign-in-guard.js";
|
|
101
102
|
import { admissionRecorded, admitProject, commandLineActor } from "./project-admission.js";
|
|
102
103
|
import { leadClaim, leadSay, noteLeadWork } from "./lead-voice.js";
|
|
@@ -105,7 +106,7 @@ import { spawn as spawnChild } from "node:child_process";
|
|
|
105
106
|
// Every envelope is checked against its command's schema (logged, never refused), then serialized unchanged.
|
|
106
107
|
import { checkedEnvelopeJson as envelopeJson } from "./contracts/cli.js";
|
|
107
108
|
import { auditCommand } from "./audit-cli.js";
|
|
108
|
-
import { hasDisguisedText, hasForbiddenControls, validateNote } from "./decision.js";
|
|
109
|
+
import { closedQuestionWords, hasDisguisedText, hasForbiddenControls, validateNote } from "./decision.js";
|
|
109
110
|
import { readVerifiedArtifact, readVerifiedReport, storeEvidence } from "./evidence.js";
|
|
110
111
|
import { contractChangesOf, decodePlanContractRecord, describeContractChanges, encodePlannerSource, plannerSourceOf } from "./planner-source.js";
|
|
111
112
|
/**
|
|
@@ -3720,7 +3721,7 @@ async function decideCommand(positional, flags, context) {
|
|
|
3720
3721
|
write(envelopeJson({ ok: true, command: "decide", decision, taskId, evidence }));
|
|
3721
3722
|
return EXIT.ok;
|
|
3722
3723
|
}
|
|
3723
|
-
write(`${taskId} — ${decision.state.toUpperCase()}${decision.deadline === null ? "" : ` · deadline ${decision.deadline}`}`);
|
|
3724
|
+
write(`${taskId} — ${decision.supersededReason !== null ? "CLOSED" : decision.state.toUpperCase()}${decision.deadline === null ? "" : ` · deadline ${decision.deadline}`}`);
|
|
3724
3725
|
write("");
|
|
3725
3726
|
write(` ${decision.recap}`);
|
|
3726
3727
|
write("");
|
|
@@ -3738,7 +3739,9 @@ async function decideCommand(positional, flags, context) {
|
|
|
3738
3739
|
}
|
|
3739
3740
|
if (decision.state === "answered") {
|
|
3740
3741
|
write("");
|
|
3741
|
-
write(
|
|
3742
|
+
write(decision.supersededReason !== null
|
|
3743
|
+
? ` ${closedQuestionWords(decision.supersededReason)} (${decision.answeredAt})`
|
|
3744
|
+
: ` answered: ${decision.choice} by ${decision.answeredBy} at ${decision.answeredAt}${decision.note === null ? "" : ` — ${decision.note}`}`);
|
|
3742
3745
|
}
|
|
3743
3746
|
if (evidence.length > 0) {
|
|
3744
3747
|
write("");
|
|
@@ -3746,8 +3749,10 @@ async function decideCommand(positional, flags, context) {
|
|
|
3746
3749
|
write(` evidence ${artifact.kind.padEnd(14)} ${artifact.key}${artifact.truncated ? " (truncated)" : ""}`);
|
|
3747
3750
|
}
|
|
3748
3751
|
}
|
|
3749
|
-
|
|
3750
|
-
|
|
3752
|
+
if (decision.state !== "answered") {
|
|
3753
|
+
write("");
|
|
3754
|
+
write(` → toolroll decide ${id} --choose <option> --as <you> --token <t>`);
|
|
3755
|
+
}
|
|
3751
3756
|
return EXIT.ok;
|
|
3752
3757
|
}
|
|
3753
3758
|
const acting = await askCredentials(flags, context);
|
|
@@ -3766,9 +3771,11 @@ async function decideCommand(positional, flags, context) {
|
|
|
3766
3771
|
? `"${choice}" is not one of this decision's options — \`toolroll decide ${id}\` shows them`
|
|
3767
3772
|
: answered.reason === "already-answered"
|
|
3768
3773
|
? `decision ${id} was already answered differently — "decided" is not negotiable; park a new task if the answer must change`
|
|
3769
|
-
: answered.reason === "
|
|
3770
|
-
?
|
|
3771
|
-
:
|
|
3774
|
+
: answered.reason === "closed"
|
|
3775
|
+
? `decision ${id}: ${closedQuestionWords(store.getDecision(id)?.supersededReason ?? "cancelled")} Nothing to answer.`
|
|
3776
|
+
: answered.reason === "bad-note"
|
|
3777
|
+
? "the note is too long or carries control characters"
|
|
3778
|
+
: `no decision ${id}`;
|
|
3772
3779
|
return fail(write, json, "decide", answered.reason, why, EXIT.refused);
|
|
3773
3780
|
}
|
|
3774
3781
|
return succeed(write, json, "decide", { decision: answered.decision, duplicate: answered.duplicate === true }, () => answered.duplicate === true
|
|
@@ -9706,6 +9713,7 @@ async function scopeTask(positional, flags, context) {
|
|
|
9706
9713
|
// the mode seal record as a single operation —
|
|
9707
9714
|
// a replayed key returns the FIRST answer whole instead of re-sealing
|
|
9708
9715
|
// whatever scope happens to be current.
|
|
9716
|
+
const generationBefore = store.lookupRef(id)?.planningGeneration ?? 0;
|
|
9709
9717
|
const filed = store.replay(mutationFrom(flags, now), "task-scope-filed", () => store.transact(() => {
|
|
9710
9718
|
const coverage = actor !== null
|
|
9711
9719
|
? modeFilingCoverage(store, store.refFor(BUILT_IN, id).repo, actor, now)
|
|
@@ -9762,8 +9770,12 @@ async function scopeTask(positional, flags, context) {
|
|
|
9762
9770
|
...describeScope(scope),
|
|
9763
9771
|
]);
|
|
9764
9772
|
}
|
|
9765
|
-
|
|
9766
|
-
|
|
9773
|
+
// A changed scope on a planning task plans again (v119): say so once, instead of leaving the old plan implied.
|
|
9774
|
+
const replanned = (store.lookupRef(id)?.planningGeneration ?? 0) > generationBefore;
|
|
9775
|
+
return succeed(write, json, "task scope", { scope, ...(replanned ? { planningRestarted: true } : {}) }, () => [
|
|
9776
|
+
replanned
|
|
9777
|
+
? `Scope written for ${id}. The scope changed, so planning starts again; nothing builds until somebody approves the new plan.`
|
|
9778
|
+
: `Scope written for ${id}. Nothing will build it until somebody approves it.`,
|
|
9767
9779
|
...describeScope(scope),
|
|
9768
9780
|
"",
|
|
9769
9781
|
` toolroll task approve ${id} --yes`,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D7: a password on each request (`Authorization: Bearer <name>:<password>`) is deprecated. This release still
|
|
3
|
+
* accepts it everywhere it was accepted, with the same per-address tries, locks and budgets, but every answer carries
|
|
4
|
+
* a deprecation warning, and every refusal of such a request names the replacement: an API token. From
|
|
5
|
+
* PASSWORD_BEARER_REFUSED_FROM on, the server refuses it before checking the password.
|
|
6
|
+
*/
|
|
7
|
+
import type { ServerResponse } from "node:http";
|
|
8
|
+
/** The one replacement every machine-credential refusal names. */
|
|
9
|
+
export declare const TOKEN_REPLACEMENT = "Create an API token with `toolroll tokens create`";
|
|
10
|
+
/** The first release that refuses a password on a request; 0.9.58 deprecated it with a warning. */
|
|
11
|
+
export declare const PASSWORD_BEARER_REFUSED_FROM = "0.10.0";
|
|
12
|
+
export declare const PASSWORD_BEARER_WARNING = "Signing in with a password on each request is deprecated and stops working in Toolroll 0.10.0. Create an API token with `toolroll tokens create` and send it as the bearer instead.";
|
|
13
|
+
/** What the CLI says on stderr when a saved connection signs in with a password instead of an API token. */
|
|
14
|
+
export declare const PASSWORD_PROFILE_WARNING = "Warning: this connection signs in with your password on each request, which is deprecated and stops working in Toolroll 0.10.0. Create an API token with `toolroll tokens create`, then connect again with --token-stdin.";
|
|
15
|
+
export declare const PASSWORD_BEARER_REFUSED = "Toolroll no longer accepts a password on a request. Create an API token with `toolroll tokens create` and send it as the bearer instead.";
|
|
16
|
+
/** Whether an Authorization header is a password bearer: a name and a secret, never an API token. */
|
|
17
|
+
export declare function isPasswordBearer(authorization: string | undefined): boolean;
|
|
18
|
+
/** Whether this build still accepts a password bearer: only a release before PASSWORD_BEARER_REFUSED_FROM does. */
|
|
19
|
+
export declare function passwordBearerAccepted(version?: string): boolean;
|
|
20
|
+
/** A refusal's words for this request: unchanged, or with the replacement when it came with a password bearer. */
|
|
21
|
+
export declare function withReplacement(authorization: string | undefined, message: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* The refusal boundary for one password-bearer request, whichever adapter answers it: the deprecation is announced in
|
|
24
|
+
* headers (RFC 9745 Deprecation, and a Warning naming the replacement), and any refusal's words, plain text or a JSON
|
|
25
|
+
* `message`, gain the replacement. Successful answers are left byte for byte.
|
|
26
|
+
*/
|
|
27
|
+
export declare function announcePasswordBearer(response: ServerResponse): void;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { PACKAGE_VERSION } from "./version.js";
|
|
2
|
+
/** The one replacement every machine-credential refusal names. */
|
|
3
|
+
export const TOKEN_REPLACEMENT = "Create an API token with `toolroll tokens create`";
|
|
4
|
+
/** The first release that refuses a password on a request; 0.9.58 deprecated it with a warning. */
|
|
5
|
+
export const PASSWORD_BEARER_REFUSED_FROM = "0.10.0";
|
|
6
|
+
export const PASSWORD_BEARER_WARNING = `Signing in with a password on each request is deprecated and stops working in Toolroll ${PASSWORD_BEARER_REFUSED_FROM}. ${TOKEN_REPLACEMENT} and send it as the bearer instead.`;
|
|
7
|
+
/** What the CLI says on stderr when a saved connection signs in with a password instead of an API token. */
|
|
8
|
+
export const PASSWORD_PROFILE_WARNING = `Warning: this connection signs in with your password on each request, which is deprecated and stops working in Toolroll ${PASSWORD_BEARER_REFUSED_FROM}. ${TOKEN_REPLACEMENT}, then connect again with --token-stdin.`;
|
|
9
|
+
export const PASSWORD_BEARER_REFUSED = `Toolroll no longer accepts a password on a request. ${TOKEN_REPLACEMENT} and send it as the bearer instead.`;
|
|
10
|
+
/** Whether an Authorization header is a password bearer: a name and a secret, never an API token. */
|
|
11
|
+
export function isPasswordBearer(authorization) {
|
|
12
|
+
return authorization !== undefined && /^Bearer (.+):(.+)$/.test(authorization) && !/^Bearer so_/.test(authorization);
|
|
13
|
+
}
|
|
14
|
+
const parts = (version) => (/^(\d+)\.(\d+)\.(\d+)/.exec(version) ?? [0, 0, 0, 0]).slice(1).map(Number);
|
|
15
|
+
/** Whether this build still accepts a password bearer: only a release before PASSWORD_BEARER_REFUSED_FROM does. */
|
|
16
|
+
export function passwordBearerAccepted(version = PACKAGE_VERSION) {
|
|
17
|
+
const have = parts(version), limit = parts(PASSWORD_BEARER_REFUSED_FROM);
|
|
18
|
+
for (let index = 0; index < 3; index++)
|
|
19
|
+
if (have[index] !== limit[index])
|
|
20
|
+
return have[index] < limit[index];
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
/** A refusal's words for this request: unchanged, or with the replacement when it came with a password bearer. */
|
|
24
|
+
export function withReplacement(authorization, message) {
|
|
25
|
+
return !isPasswordBearer(authorization) || message.includes("toolroll tokens create") ? message : `${message} ${PASSWORD_BEARER_WARNING}`;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The refusal boundary for one password-bearer request, whichever adapter answers it: the deprecation is announced in
|
|
29
|
+
* headers (RFC 9745 Deprecation, and a Warning naming the replacement), and any refusal's words, plain text or a JSON
|
|
30
|
+
* `message`, gain the replacement. Successful answers are left byte for byte.
|
|
31
|
+
*/
|
|
32
|
+
export function announcePasswordBearer(response) {
|
|
33
|
+
response.setHeader("Deprecation", "true");
|
|
34
|
+
response.setHeader("Warning", `299 toolroll "${PASSWORD_BEARER_WARNING.replace(/"/g, "'")}"`);
|
|
35
|
+
const end = response.end.bind(response);
|
|
36
|
+
response.end = ((...args) => {
|
|
37
|
+
const [chunk] = args;
|
|
38
|
+
if (response.statusCode >= 400 && typeof chunk === "string" && !chunk.includes("toolroll tokens create"))
|
|
39
|
+
args[0] = annotated(chunk);
|
|
40
|
+
return end(...args);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
function annotated(body) {
|
|
44
|
+
if (/^\s*\{/.test(body)) {
|
|
45
|
+
try {
|
|
46
|
+
const value = JSON.parse(body);
|
|
47
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
48
|
+
const row = value;
|
|
49
|
+
if (typeof row["message"] === "string")
|
|
50
|
+
return JSON.stringify({ ...row, message: `${row["message"]} ${PASSWORD_BEARER_WARNING}` });
|
|
51
|
+
if (typeof row["error_description"] === "string")
|
|
52
|
+
return JSON.stringify({ ...row, error_description: `${row["error_description"]} ${PASSWORD_BEARER_WARNING}` });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch { /* not JSON after all: plain text below */ }
|
|
56
|
+
return body;
|
|
57
|
+
}
|
|
58
|
+
return body.trimStart().startsWith("<") ? body : `${body.replace(/\s+$/, "")}\n${PASSWORD_BEARER_WARNING}`;
|
|
59
|
+
}
|
package/dist/planner-source.d.ts
CHANGED
|
@@ -36,9 +36,10 @@
|
|
|
36
36
|
* transactions of its own: the fenced finalizers in claim.ts seal.
|
|
37
37
|
*/
|
|
38
38
|
import type { Store } from "./store.js";
|
|
39
|
-
import type {
|
|
39
|
+
import type { Scope } from "./scope.js";
|
|
40
40
|
import type { QualityMode } from "./quality.js";
|
|
41
41
|
import type { RiskLevel } from "./phase-routing.js";
|
|
42
|
+
import { type ContractChange, type ContractTerms } from "./contract-changes.js";
|
|
42
43
|
export declare const PLANNER_SOURCE_VERSION = 1;
|
|
43
44
|
export declare const PLANNER_SOURCE_LIMITS: {
|
|
44
45
|
/** The whole recorded source before lossless prompt fencing. Equal to
|
|
@@ -49,13 +50,9 @@ export declare const PLANNER_SOURCE_LIMITS: {
|
|
|
49
50
|
readonly amendment: 1000;
|
|
50
51
|
/** Earlier answers quoted into the brief. */
|
|
51
52
|
readonly answers: 5;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
goal: string;
|
|
56
|
-
outOfScope: string | null;
|
|
57
|
-
touches: string[];
|
|
58
|
-
acceptance: AcceptanceCriterion[];
|
|
53
|
+
/** Terms that exceed the entire handoff cap cannot fit regardless of the plan's size. Do not reserve a maximum-
|
|
54
|
+
* sized plan here: a shorter document can carry larger valid terms. The final payload still enforces its byte cap. */
|
|
55
|
+
readonly handoffTerms: number;
|
|
59
56
|
};
|
|
60
57
|
export type PlannerContractScope = ContractTerms & {
|
|
61
58
|
/** The filed row's digest — the exact bytes the terms carry today. */
|
|
@@ -154,7 +151,7 @@ export type PlannerSourceResult = {
|
|
|
154
151
|
bytes: number;
|
|
155
152
|
} | {
|
|
156
153
|
ok: false;
|
|
157
|
-
reason: "no-task" | "revision-brief" | "oversized";
|
|
154
|
+
reason: "no-task" | "revision-brief" | "oversized" | "too-long";
|
|
158
155
|
message: string;
|
|
159
156
|
};
|
|
160
157
|
/**
|
|
@@ -168,55 +165,28 @@ export declare function plannerSourceOf(store: Store, root: string, taskId: stri
|
|
|
168
165
|
/**
|
|
169
166
|
* The dispatch diagnosis's read of the same gate, from the store alone:
|
|
170
167
|
* why the next pass would refuse to plan this task before spending — the
|
|
171
|
-
* revision brief unreadable,
|
|
172
|
-
* size is a floor (the brief's
|
|
173
|
-
* "yes" here is certain and the
|
|
174
|
-
* word. Null when nothing here
|
|
168
|
+
* revision brief unreadable, the filed request over the byte cap, or terms
|
|
169
|
+
* the planner's handoff cannot carry. The size is a floor (the brief's
|
|
170
|
+
* stored bytes, before JSON quoting), so a "yes" here is certain and the
|
|
171
|
+
* dispatch refusal itself stays the exact word. Null when nothing here
|
|
172
|
+
* stands in the way.
|
|
173
|
+
*/
|
|
174
|
+
export declare function plannerSourceProblemOf(store: Store, taskId: string): {
|
|
175
|
+
title: string;
|
|
176
|
+
detail: string;
|
|
177
|
+
} | null;
|
|
178
|
+
/**
|
|
179
|
+
* Whether the planner can hand the filed terms back (contracts/plan.ts): it must reproduce the goal, exclusions,
|
|
180
|
+
* touches and criteria in its handoff, so terms its handoff cannot carry would end every attempt as a malformed plan.
|
|
181
|
+
* This names the problem before any workspace, run or provider spend instead: the actual size, the limit, and what to
|
|
182
|
+
* shorten. Null when the terms fit.
|
|
175
183
|
*/
|
|
176
|
-
export declare function
|
|
184
|
+
export declare function plannerHandoffProblemOf(terms: ContractTerms): string | null;
|
|
177
185
|
/** The exact bytes recorded as evidence; prompt fencing preserves the JSON value. */
|
|
178
186
|
export declare function encodePlannerSource(source: PlannerSource): Buffer;
|
|
179
187
|
/** Read a recorded source back; null when the bytes are not one. */
|
|
180
188
|
export declare function decodePlannerSource(content: Buffer): PlannerSource | null;
|
|
181
|
-
export type ContractChange
|
|
182
|
-
field: "goal";
|
|
183
|
-
kind: "changed";
|
|
184
|
-
before: string;
|
|
185
|
-
after: string;
|
|
186
|
-
} | {
|
|
187
|
-
field: "outOfScope";
|
|
188
|
-
kind: "added" | "removed" | "changed";
|
|
189
|
-
before: string | null;
|
|
190
|
-
after: string | null;
|
|
191
|
-
} | {
|
|
192
|
-
field: "touches";
|
|
193
|
-
kind: "added" | "removed";
|
|
194
|
-
path: string;
|
|
195
|
-
} | {
|
|
196
|
-
field: "acceptance";
|
|
197
|
-
kind: "added" | "removed" | "changed";
|
|
198
|
-
id: string;
|
|
199
|
-
before: {
|
|
200
|
-
statement: string;
|
|
201
|
-
evidence: EvidenceKind[];
|
|
202
|
-
how: string | null;
|
|
203
|
-
} | null;
|
|
204
|
-
after: {
|
|
205
|
-
statement: string;
|
|
206
|
-
evidence: EvidenceKind[];
|
|
207
|
-
how: string | null;
|
|
208
|
-
} | null;
|
|
209
|
-
/** For `changed`: which parts moved. `how` is advisory and unsigned,
|
|
210
|
-
* but a planner rewriting it is still a change the operator sees. */
|
|
211
|
-
moved: ("statement" | "evidence" | "how")[];
|
|
212
|
-
};
|
|
213
|
-
/**
|
|
214
|
-
* Every addition, change, and removal between the filed contract and a
|
|
215
|
-
* proposed one. Whitespace at the ends and touch/evidence order do not
|
|
216
|
-
* count (the scope digest ignores them too); everything else does,
|
|
217
|
-
* including the advisory `how`, because the operator wrote it.
|
|
218
|
-
*/
|
|
219
|
-
export declare function contractChangesOf(filed: ContractTerms, proposed: ContractTerms): ContractChange[];
|
|
189
|
+
export { contractChangesOf, type ContractChange, type ContractTerms } from "./contract-changes.js";
|
|
220
190
|
/** One line per change, for terminals, notifications, and validation messages. */
|
|
221
191
|
export declare function describeContractChanges(changes: readonly ContractChange[]): string[];
|
|
222
192
|
/**
|
package/dist/planner-source.js
CHANGED
|
@@ -38,6 +38,8 @@
|
|
|
38
38
|
import { createHash } from "node:crypto";
|
|
39
39
|
import { EVIDENCE_CAPS, readVerifiedArtifact } from "./evidence.js";
|
|
40
40
|
import { TEXT_LIMITS } from "./text-limits.js";
|
|
41
|
+
import { PLAN_TOUCHES } from "./contracts/plan.js";
|
|
42
|
+
import { contractChangesOf } from "./contract-changes.js";
|
|
41
43
|
export const PLANNER_SOURCE_VERSION = 1;
|
|
42
44
|
export const PLANNER_SOURCE_LIMITS = {
|
|
43
45
|
/** The whole recorded source before lossless prompt fencing. Equal to
|
|
@@ -48,6 +50,9 @@ export const PLANNER_SOURCE_LIMITS = {
|
|
|
48
50
|
amendment: TEXT_LIMITS.planAmendment,
|
|
49
51
|
/** Earlier answers quoted into the brief. */
|
|
50
52
|
answers: 5,
|
|
53
|
+
/** Terms that exceed the entire handoff cap cannot fit regardless of the plan's size. Do not reserve a maximum-
|
|
54
|
+
* sized plan here: a shorter document can carry larger valid terms. The final payload still enforces its byte cap. */
|
|
55
|
+
handoffTerms: TEXT_LIMITS.planPayloadBytes,
|
|
51
56
|
};
|
|
52
57
|
/** Stable JSON for hashing and equality only — no filling, no trimming. */
|
|
53
58
|
export function canonicalContractJson(value) {
|
|
@@ -192,26 +197,55 @@ export function plannerSourceOf(store, root, taskId, answers) {
|
|
|
192
197
|
`(scope ${scopeBytes}, revision brief ${briefBytes}); nothing is trimmed silently — shorten the scope or brief, then plan again`,
|
|
193
198
|
};
|
|
194
199
|
}
|
|
200
|
+
const handoff = source.contract.scope === null ? null : plannerHandoffProblemOf(source.contract.scope);
|
|
201
|
+
if (handoff !== null)
|
|
202
|
+
return { ok: false, reason: "too-long", message: handoff };
|
|
195
203
|
return { ok: true, source, bytes };
|
|
196
204
|
}
|
|
197
205
|
/**
|
|
198
206
|
* The dispatch diagnosis's read of the same gate, from the store alone:
|
|
199
207
|
* why the next pass would refuse to plan this task before spending — the
|
|
200
|
-
* revision brief unreadable,
|
|
201
|
-
* size is a floor (the brief's
|
|
202
|
-
* "yes" here is certain and the
|
|
203
|
-
* word. Null when nothing here
|
|
208
|
+
* revision brief unreadable, the filed request over the byte cap, or terms
|
|
209
|
+
* the planner's handoff cannot carry. The size is a floor (the brief's
|
|
210
|
+
* stored bytes, before JSON quoting), so a "yes" here is certain and the
|
|
211
|
+
* dispatch refusal itself stays the exact word. Null when nothing here
|
|
212
|
+
* stands in the way.
|
|
204
213
|
*/
|
|
205
214
|
export function plannerSourceProblemOf(store, taskId) {
|
|
215
|
+
const filed = (detail) => ({ title: "The filed request cannot be planned as filed", detail });
|
|
206
216
|
if (store.answeredDecisionsFor(taskId, PLANNER_SOURCE_LIMITS.answers + 1).length > PLANNER_SOURCE_LIMITS.answers)
|
|
207
|
-
return `more than ${PLANNER_SOURCE_LIMITS.answers} answered decisions exceed the planner history bound; file a follow-up task with the consolidated decisions
|
|
217
|
+
return filed(`more than ${PLANNER_SOURCE_LIMITS.answers} answered decisions exceed the planner history bound; file a follow-up task with the consolidated decisions`);
|
|
208
218
|
const contract = plannerContractOf(store, taskId);
|
|
209
219
|
if (!contract.ok)
|
|
210
|
-
return contract.message;
|
|
220
|
+
return filed(contract.message);
|
|
211
221
|
const briefBytes = contract.contract.revision === null ? 0 : (store.getArtifact(contract.contract.revision.briefArtifact)?.bytesStored ?? 0);
|
|
212
222
|
const floor = Buffer.byteLength(JSON.stringify(contract.contract, null, 2), "utf8") + briefBytes;
|
|
213
223
|
if (floor > PLANNER_SOURCE_LIMITS.bytes) {
|
|
214
|
-
return `the filed request is at least ${floor} bytes — over the ${PLANNER_SOURCE_LIMITS.bytes}-byte planner source cap; shorten the scope or brief before planning
|
|
224
|
+
return filed(`the filed request is at least ${floor} bytes — over the ${PLANNER_SOURCE_LIMITS.bytes}-byte planner source cap; shorten the scope or brief before planning`);
|
|
225
|
+
}
|
|
226
|
+
const handoff = contract.contract.scope === null ? null : plannerHandoffProblemOf(contract.contract.scope);
|
|
227
|
+
return handoff === null ? null : { title: "Scope too long to plan", detail: handoff };
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Whether the planner can hand the filed terms back (contracts/plan.ts): it must reproduce the goal, exclusions,
|
|
231
|
+
* touches and criteria in its handoff, so terms its handoff cannot carry would end every attempt as a malformed plan.
|
|
232
|
+
* This names the problem before any workspace, run or provider spend instead: the actual size, the limit, and what to
|
|
233
|
+
* shorten. Null when the terms fit.
|
|
234
|
+
*/
|
|
235
|
+
export function plannerHandoffProblemOf(terms) {
|
|
236
|
+
const n = (value) => value.toLocaleString("en-US");
|
|
237
|
+
for (const [label, is, text] of [["goal", "is", terms.goal], ["exclusions", "are", terms.outOfScope ?? ""]]) {
|
|
238
|
+
if (text.length > TEXT_LIMITS.goal)
|
|
239
|
+
return `The ${label} ${is} ${n(text.length)} characters; the planner can carry at most ${n(TEXT_LIMITS.goal)}. Shorten the ${label} and planning starts again.`;
|
|
240
|
+
}
|
|
241
|
+
if (terms.touches.length > PLAN_TOUCHES)
|
|
242
|
+
return `The scope lists ${n(terms.touches.length)} paths; the planner can carry at most ${PLAN_TOUCHES}. List fewer paths and planning starts again.`;
|
|
243
|
+
const path = terms.touches.find(one => one.length > TEXT_LIMITS.planTouch);
|
|
244
|
+
if (path !== undefined)
|
|
245
|
+
return `A path is ${n(path.length)} characters; the planner can carry at most ${n(TEXT_LIMITS.planTouch)}. Shorten it and planning starts again.`;
|
|
246
|
+
const bytes = Buffer.byteLength(JSON.stringify({ goal: terms.goal, outOfScope: terms.outOfScope, touches: terms.touches, acceptance: terms.acceptance }), "utf8");
|
|
247
|
+
if (bytes > PLANNER_SOURCE_LIMITS.handoffTerms) {
|
|
248
|
+
return `The goal, exclusions, paths and criteria are ${n(bytes)} bytes together; the planner can carry at most ${n(PLANNER_SOURCE_LIMITS.handoffTerms)} bytes in its whole handoff. Shorten the scope and planning starts again.`;
|
|
215
249
|
}
|
|
216
250
|
return null;
|
|
217
251
|
}
|
|
@@ -257,67 +291,8 @@ export function decodePlannerSource(content) {
|
|
|
257
291
|
return null;
|
|
258
292
|
}
|
|
259
293
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
return null;
|
|
263
|
-
const t = text.trim();
|
|
264
|
-
return t === "" ? null : t;
|
|
265
|
-
};
|
|
266
|
-
/**
|
|
267
|
-
* Every addition, change, and removal between the filed contract and a
|
|
268
|
-
* proposed one. Whitespace at the ends and touch/evidence order do not
|
|
269
|
-
* count (the scope digest ignores them too); everything else does,
|
|
270
|
-
* including the advisory `how`, because the operator wrote it.
|
|
271
|
-
*/
|
|
272
|
-
export function contractChangesOf(filed, proposed) {
|
|
273
|
-
const changes = [];
|
|
274
|
-
if (filed.goal.trim() !== proposed.goal.trim()) {
|
|
275
|
-
changes.push({ field: "goal", kind: "changed", before: filed.goal, after: proposed.goal });
|
|
276
|
-
}
|
|
277
|
-
const filedOut = trimmed(filed.outOfScope);
|
|
278
|
-
const proposedOut = trimmed(proposed.outOfScope);
|
|
279
|
-
if (filedOut !== proposedOut) {
|
|
280
|
-
changes.push({
|
|
281
|
-
field: "outOfScope",
|
|
282
|
-
kind: filedOut === null ? "added" : proposedOut === null ? "removed" : "changed",
|
|
283
|
-
before: filed.outOfScope,
|
|
284
|
-
after: proposed.outOfScope,
|
|
285
|
-
});
|
|
286
|
-
}
|
|
287
|
-
const filedTouches = new Set(filed.touches.map(one => one.trim()).filter(one => one !== ""));
|
|
288
|
-
const proposedTouches = new Set(proposed.touches.map(one => one.trim()).filter(one => one !== ""));
|
|
289
|
-
for (const path of filedTouches)
|
|
290
|
-
if (!proposedTouches.has(path))
|
|
291
|
-
changes.push({ field: "touches", kind: "removed", path });
|
|
292
|
-
for (const path of proposedTouches)
|
|
293
|
-
if (!filedTouches.has(path))
|
|
294
|
-
changes.push({ field: "touches", kind: "added", path });
|
|
295
|
-
const criterionOf = (one) => ({ statement: one.statement, evidence: [...one.evidence].sort(), how: one.how });
|
|
296
|
-
const filedById = new Map(filed.acceptance.map(one => [one.id, one]));
|
|
297
|
-
const proposedById = new Map(proposed.acceptance.map(one => [one.id, one]));
|
|
298
|
-
for (const [id, before] of filedById) {
|
|
299
|
-
const after = proposedById.get(id);
|
|
300
|
-
if (after === undefined) {
|
|
301
|
-
changes.push({ field: "acceptance", kind: "removed", id, before: criterionOf(before), after: null, moved: [] });
|
|
302
|
-
continue;
|
|
303
|
-
}
|
|
304
|
-
const moved = [];
|
|
305
|
-
if (before.statement.trim() !== after.statement.trim())
|
|
306
|
-
moved.push("statement");
|
|
307
|
-
if ([...before.evidence].sort().join(",") !== [...after.evidence].sort().join(","))
|
|
308
|
-
moved.push("evidence");
|
|
309
|
-
if (trimmed(before.how) !== trimmed(after.how))
|
|
310
|
-
moved.push("how");
|
|
311
|
-
if (moved.length > 0) {
|
|
312
|
-
changes.push({ field: "acceptance", kind: "changed", id, before: criterionOf(before), after: criterionOf(after), moved });
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
for (const [id, after] of proposedById) {
|
|
316
|
-
if (!filedById.has(id))
|
|
317
|
-
changes.push({ field: "acceptance", kind: "added", id, before: null, after: criterionOf(after), moved: [] });
|
|
318
|
-
}
|
|
319
|
-
return changes;
|
|
320
|
-
}
|
|
294
|
+
// ---- amendments -----------------------------------------------------------
|
|
295
|
+
export { contractChangesOf } from "./contract-changes.js";
|
|
321
296
|
const clip = (text, max) => (text.length > max ? `${text.slice(0, max - 1)}…` : text);
|
|
322
297
|
/** One line per change, for terminals, notifications, and validation messages. */
|
|
323
298
|
export function describeContractChanges(changes) {
|
package/dist/release-gate.d.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `toolroll release --release-gate <id>`: lift one update pause that a browser deployment (scripts/deploy-browser.mjs)
|
|
3
|
-
* left behind when it stopped before the swap and its own recovery
|
|
4
|
-
* killed
|
|
5
|
-
* `--phase recover` leaves the pause alone, and new work stays paused.
|
|
3
|
+
* left behind when it stopped before the swap and its own exit recovery never ran. The typical case is a deployment
|
|
4
|
+
* killed outright (SIGKILL, a crash or power loss) while paused: nothing lifted the pause, and new work stays paused.
|
|
6
5
|
*
|
|
7
6
|
* The id alone proves nothing, and neither does the journal's phase. The pause is lifted only when every piece of
|
|
8
7
|
* evidence agrees that the service was never swapped:
|
package/dist/release-gate.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `toolroll release --release-gate <id>`: lift one update pause that a browser deployment (scripts/deploy-browser.mjs)
|
|
3
|
-
* left behind when it stopped before the swap and its own recovery
|
|
4
|
-
* killed
|
|
5
|
-
* `--phase recover` leaves the pause alone, and new work stays paused.
|
|
3
|
+
* left behind when it stopped before the swap and its own exit recovery never ran. The typical case is a deployment
|
|
4
|
+
* killed outright (SIGKILL, a crash or power loss) while paused: nothing lifted the pause, and new work stays paused.
|
|
6
5
|
*
|
|
7
6
|
* The id alone proves nothing, and neither does the journal's phase. The pause is lifted only when every piece of
|
|
8
7
|
* evidence agrees that the service was never swapped:
|
|
@@ -23,6 +22,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
|
23
22
|
import { join } from "node:path";
|
|
24
23
|
import { durableJson } from "./desktop-update.js";
|
|
25
24
|
import { removeUpdateGate, updateGateOwned } from "./desktop-update-gate.js";
|
|
25
|
+
import { acquireDeploymentLock } from "./deploy-lock.js";
|
|
26
26
|
/** The journal phases a deployment passes before it stops the old service (deploy-phases.mjs). */
|
|
27
27
|
export const BEFORE_SWAP = Object.freeze(["preparing", "admission-paused", "frozen", "backup-verified", "rehearsed"]);
|
|
28
28
|
/** Journal fields that only the swap and later phases write. */
|
|
@@ -129,22 +129,50 @@ export function proveBeforeSwap(id, world) {
|
|
|
129
129
|
}
|
|
130
130
|
/** Prove, then (with `yes`) lift exactly that pause and journal the release in the deployment's own journal. */
|
|
131
131
|
export function releaseGate(id, world, yes) {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
132
|
+
// Discovery is not authority to remove anything. Dry-run remains read-only, even for legacy stages.
|
|
133
|
+
const discovered = proveBeforeSwap(id, world);
|
|
134
|
+
if (!discovered.ok)
|
|
135
|
+
return discovered;
|
|
135
136
|
if (!yes)
|
|
136
|
-
return { ok: true, released: false, id, stage:
|
|
137
|
-
const
|
|
138
|
-
|
|
137
|
+
return { ok: true, released: false, id, stage: discovered.stage, proof: discovered.proof };
|
|
138
|
+
const journalFile = join(discovered.stage, "deployment.json");
|
|
139
|
+
let lock;
|
|
139
140
|
try {
|
|
140
|
-
|
|
141
|
+
lock = acquireDeploymentLock(journalFile);
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
return refuse("deploying", error.message);
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
const proved = proveBeforeSwap(id, world);
|
|
148
|
+
if (!proved.ok)
|
|
149
|
+
return proved;
|
|
150
|
+
if (proved.stage !== discovered.stage)
|
|
151
|
+
return refuse("conflict", "The deployment stage changed during release.");
|
|
152
|
+
const db = world.open();
|
|
153
|
+
try {
|
|
154
|
+
// Repeat the entire proof at the mutation boundary, under the same exclusion as deploy/resume/recover.
|
|
155
|
+
const fresh = proveBeforeSwap(id, world);
|
|
156
|
+
if (!fresh.ok)
|
|
157
|
+
return fresh;
|
|
158
|
+
if (fresh.stage !== proved.stage || JSON.stringify(fresh.journal) !== JSON.stringify(proved.journal) ||
|
|
159
|
+
JSON.stringify(JSON.parse(readFileSync(journalFile, "utf8"))) !== JSON.stringify(fresh.journal)) {
|
|
160
|
+
return refuse("conflict", "The deployment journal changed during release.");
|
|
161
|
+
}
|
|
162
|
+
lock.assertHeld(journalFile);
|
|
163
|
+
// The owner is checked again within removeUpdateGate's short write transaction.
|
|
164
|
+
removeUpdateGate(db, id);
|
|
165
|
+
durableJson(journalFile, {
|
|
166
|
+
...fresh.journal, phase: "released", updatedAt: world.now().toISOString(),
|
|
167
|
+
releasedBeforeSwap: { at: world.now().toISOString(), by: "toolroll release --release-gate", from: fresh.journal.phase, proof: fresh.proof },
|
|
168
|
+
});
|
|
169
|
+
return { ok: true, released: true, id, stage: fresh.stage, proof: fresh.proof };
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
db.close();
|
|
173
|
+
}
|
|
141
174
|
}
|
|
142
175
|
finally {
|
|
143
|
-
|
|
176
|
+
lock.release();
|
|
144
177
|
}
|
|
145
|
-
durableJson(join(proved.stage, "deployment.json"), {
|
|
146
|
-
...proved.journal, phase: "released", updatedAt: world.now().toISOString(),
|
|
147
|
-
releasedBeforeSwap: { at: world.now().toISOString(), by: "toolroll release --release-gate", from: proved.journal.phase, proof: proved.proof },
|
|
148
|
-
});
|
|
149
|
-
return { ok: true, released: true, id, stage: proved.stage, proof: proved.proof };
|
|
150
178
|
}
|
package/dist/remote-command.js
CHANGED
|
@@ -34,8 +34,6 @@ export const REMOTE_PATH_ARGUMENTS = [
|
|
|
34
34
|
...['link', 'unlink'].map(invocation => ({ invocation, argument: { flag: '--to' }, policy: 'refuse' })),
|
|
35
35
|
...['repos add', 'repos remove', 'pulls', 'graph', ''].map(invocation => ({ invocation, argument: { position: 0, name: 'path', rest: true }, policy: 'refuse' })),
|
|
36
36
|
{ invocation: 'project use', argument: { position: 0, name: 'checkout path' }, policy: 'refuse' },
|
|
37
|
-
// These local HTTP clients read prompts themselves; they do not consume runOperateAs's files map.
|
|
38
|
-
...['session start', 'session send'].map(invocation => ({ invocation, argument: { flag: '--file' }, policy: 'refuse' })),
|
|
39
37
|
];
|
|
40
38
|
/**
|
|
41
39
|
* Parse once using declared arity, so a flag's value cannot be mistaken for another flag or a
|
package/dist/remote-exec.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type TeamCliOptions } from './team-cli.js';
|
|
2
|
+
import { type CliRequest } from './cli-http.js';
|
|
2
3
|
import { type RemoteCommandLookup } from './remote-command.js';
|
|
3
4
|
export { contractRow, STEP_UP_MESSAGE, type RemoteMode } from './remote-command.js';
|
|
4
5
|
export type RemoteExecOptions = TeamCliOptions & {
|
|
@@ -9,6 +10,24 @@ export type RemoteExecOptions = TeamCliOptions & {
|
|
|
9
10
|
/** Injected by tests: the contract row lookup (default: COMMAND_GUIDE's `remote` field). */
|
|
10
11
|
modeOf?: RemoteCommandLookup;
|
|
11
12
|
};
|
|
13
|
+
type Answer = {
|
|
14
|
+
exitCode: number;
|
|
15
|
+
stdout: string;
|
|
16
|
+
stderr: string;
|
|
17
|
+
};
|
|
18
|
+
export type Outcome = {
|
|
19
|
+
kind: 'answer';
|
|
20
|
+
answer: Answer;
|
|
21
|
+
} | {
|
|
22
|
+
kind: 'refused';
|
|
23
|
+
status: number;
|
|
24
|
+
code: string;
|
|
25
|
+
message: string;
|
|
26
|
+
} | {
|
|
27
|
+
kind: 'unconfirmed';
|
|
28
|
+
};
|
|
29
|
+
/** One request, never retried. */
|
|
30
|
+
export declare function postCli(origin: string, token: string, request: CliRequest, options: RemoteExecOptions): Promise<Outcome>;
|
|
12
31
|
/**
|
|
13
32
|
* null: run locally with argv unchanged. `{ local }`: run locally with this argv (`--local` removed).
|
|
14
33
|
* A number: the command ran remotely (or was refused before sending) and this is its exit code.
|