toolroll 0.9.40 → 0.9.42
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 +28 -0
- package/dist/cli.js +3 -1
- package/dist/coding-types.d.ts +2 -24
- package/dist/coding-workspace.js +12 -11
- package/dist/contracts/cli.d.ts +736 -0
- package/dist/contracts/cli.js +223 -0
- package/dist/contracts/coding-activity.d.ts +64 -0
- package/dist/contracts/coding-activity.js +58 -0
- package/dist/contracts/console-api.d.ts +483 -0
- package/dist/contracts/console-api.js +273 -0
- package/dist/contracts/diff-stat.d.ts +58 -0
- package/dist/contracts/diff-stat.js +59 -0
- package/dist/contracts/integration-metadata.d.ts +99 -0
- package/dist/contracts/integration-metadata.js +101 -0
- package/dist/contracts/model-catalog.d.ts +21 -0
- package/dist/contracts/model-catalog.js +14 -0
- package/dist/contracts/native-census.d.ts +42 -0
- package/dist/contracts/native-census.js +36 -0
- package/dist/contracts/observation-request.d.ts +37 -0
- package/dist/contracts/observation-request.js +25 -0
- package/dist/contracts/provider.d.ts +30 -0
- package/dist/contracts/provider.js +15 -0
- package/dist/contracts/recipes.d.ts +166 -0
- package/dist/contracts/recipes.js +52 -0
- package/dist/contracts/retention.d.ts +42 -0
- package/dist/contracts/retention.js +24 -0
- package/dist/contracts/storage.d.ts +23 -0
- package/dist/contracts/storage.js +10 -0
- package/dist/contracts/store-json.d.ts +315 -0
- package/dist/contracts/store-json.js +196 -0
- package/dist/contracts/trigger-payloads.d.ts +369 -0
- package/dist/contracts/trigger-payloads.js +144 -0
- package/dist/contracts/update-journal.d.ts +254 -0
- package/dist/contracts/update-journal.js +155 -0
- package/dist/contracts/verification-view.d.ts +93 -0
- package/dist/contracts/verification-view.js +43 -0
- package/dist/control-setup.d.ts +1 -1
- package/dist/desktop-update.d.ts +7 -40
- package/dist/desktop-update.js +36 -8
- package/dist/evidence.d.ts +3 -19
- package/dist/flow-triggers.js +61 -60
- package/dist/mcp-connect.d.ts +0 -1
- package/dist/mcp-connect.js +41 -23
- package/dist/model-catalog.js +8 -6
- package/dist/observations.d.ts +2 -6
- package/dist/observations.js +13 -9
- package/dist/operate.js +2 -1
- package/dist/process-recovery-native.d.ts +2 -13
- package/dist/process-recovery-native.js +8 -5
- package/dist/project-tools.d.ts +6 -24
- package/dist/project-tools.js +22 -18
- package/dist/provider.d.ts +3 -8
- package/dist/provider.js +15 -11
- package/dist/recipes.d.ts +3 -28
- package/dist/recipes.js +79 -52
- package/dist/retention.d.ts +4 -5
- package/dist/retention.js +5 -4
- package/dist/serve.js +142 -28
- package/dist/storage.d.ts +2 -1
- package/dist/storage.js +12 -9
- package/dist/store.d.ts +3 -15
- package/dist/store.js +80 -158
- package/dist/surface.d.ts +6 -54
- package/dist/surface.js +6 -1
- package/dist/toolroll-update.d.ts +8 -99
- package/dist/toolroll-update.js +28 -18
- package/dist/verification-evidence.js +27 -12
- package/package.json +1 -1
package/dist/observations.js
CHANGED
|
@@ -6,6 +6,7 @@ import { dirname, join } from "node:path";
|
|
|
6
6
|
import { stripVTControlCharacters } from "node:util";
|
|
7
7
|
import { canonicalAcceptance } from "./scope.js";
|
|
8
8
|
import { readVerifiedArtifact, scanForSecrets, storeEvidence } from "./evidence.js";
|
|
9
|
+
import { readObservationRequest } from "./contracts/observation-request.js";
|
|
9
10
|
export const OBSERVATION_MAILBOX = "STANDING-ORDERS-OBSERVATIONS.json";
|
|
10
11
|
export const OBSERVATION_CAPTURE = "machine focused observations v1";
|
|
11
12
|
export const OBSERVATION_FILE = "REVIEW-OBSERVATIONS.json";
|
|
@@ -62,17 +63,20 @@ export function parseObservationCases(raw, ids) {
|
|
|
62
63
|
if (Buffer.byteLength(raw) > 16 * 1024)
|
|
63
64
|
throw Error("The observation request is too large.");
|
|
64
65
|
const parsed = JSON.parse(raw);
|
|
65
|
-
|
|
66
|
-
|
|
66
|
+
const read = readObservationRequest(parsed);
|
|
67
|
+
// A problem inside one observation is that observation's; anything else is the request's shape.
|
|
68
|
+
if (!read.ok && read.issues.some(issue => !/^observations\[\d+\]/.test(issue.path)))
|
|
67
69
|
throw Error("Use one to four focused test observations.");
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
70
|
+
const bad = () => Error("An observation must name a requested criterion, base/head, one test file and one test name. Shell commands are not accepted.");
|
|
71
|
+
if (!read.ok)
|
|
72
|
+
throw bad();
|
|
73
|
+
for (const item of read.value.observations) {
|
|
74
|
+
if (!ids.includes(item.criterion) || !pathOkay(item.testPath) || !/\.(test|spec)\.[cm]?[jt]sx?$/.test(item.testPath) ||
|
|
75
|
+
item.testName.trim() === "" || item.testName.length > 500 || /[\u0000-\u001f\u007f]/.test(item.testName))
|
|
76
|
+
throw bad();
|
|
75
77
|
}
|
|
78
|
+
// The cases as written: their key order is the receipt's, and two written in different orders are distinct, as before.
|
|
79
|
+
const cases = parsed.observations;
|
|
76
80
|
if (new Set(cases.map(c => JSON.stringify(c))).size !== cases.length || ids.some(id => !cases.some(c => c.criterion === id)))
|
|
77
81
|
throw Error("Every requested criterion needs a distinct observation.");
|
|
78
82
|
return cases;
|
package/dist/operate.js
CHANGED
|
@@ -98,7 +98,8 @@ import { admissionRecorded, admitProject, commandLineActor } from "./project-adm
|
|
|
98
98
|
import { leadClaim, leadSay, noteLeadWork } from "./lead-voice.js";
|
|
99
99
|
import { createServer as createNetServer } from "node:net";
|
|
100
100
|
import { spawn as spawnChild } from "node:child_process";
|
|
101
|
-
|
|
101
|
+
// Every envelope is checked against its command's schema (logged, never refused), then serialized unchanged.
|
|
102
|
+
import { checkedEnvelopeJson as envelopeJson } from "./contracts/cli.js";
|
|
102
103
|
import { hasDisguisedText, hasForbiddenControls, validateNote } from "./decision.js";
|
|
103
104
|
import { readVerifiedArtifact, readVerifiedReport, storeEvidence } from "./evidence.js";
|
|
104
105
|
import { contractChangesOf, decodePlanContractRecord, describeContractChanges, encodePlannerSource, plannerSourceOf } from "./planner-source.js";
|
|
@@ -1,21 +1,11 @@
|
|
|
1
|
+
import { type DarwinProcessIdentity } from "./contracts/native-census.js";
|
|
1
2
|
export type PreparedDarwinProcessCensus = {
|
|
2
3
|
sourcePath: string;
|
|
3
4
|
executablePath: string;
|
|
4
5
|
sourceSha256: string;
|
|
5
6
|
executableSha256: string;
|
|
6
7
|
};
|
|
7
|
-
export type DarwinProcessIdentity
|
|
8
|
-
pid: number;
|
|
9
|
-
ppid: number | null;
|
|
10
|
-
uid: number | null;
|
|
11
|
-
birthMs: number | null;
|
|
12
|
-
uniqueId: string | null;
|
|
13
|
-
parentUniqueId: string | null;
|
|
14
|
-
traced: boolean | null;
|
|
15
|
-
executable: string | null;
|
|
16
|
-
originalParentVersion: number | null;
|
|
17
|
-
pidVersion?: number;
|
|
18
|
-
};
|
|
8
|
+
export type { DarwinProcessIdentity } from "./contracts/native-census.js";
|
|
19
9
|
export type DarwinManagedService = {
|
|
20
10
|
domain: string;
|
|
21
11
|
label: string;
|
|
@@ -169,4 +159,3 @@ export declare function collectDarwinCoalitionSnapshot(args: {
|
|
|
169
159
|
maxAttempts?: number;
|
|
170
160
|
anchorPids?: number[];
|
|
171
161
|
}): Promise<DarwinCoalitionRecoverySnapshot>;
|
|
172
|
-
export {};
|
|
@@ -3,6 +3,7 @@ import { createHash } from "node:crypto";
|
|
|
3
3
|
import { chmodSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { hostname, release } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
+
import { darwinNativeCensusSchema, darwinProcessIdentitySchema, kernelIdInRange } from "./contracts/native-census.js";
|
|
6
7
|
const digest = (data) => createHash("sha256").update(data).digest("hex");
|
|
7
8
|
const cleanEnv = { PATH: "/usr/bin:/bin:/usr/sbin:/sbin", LC_ALL: "C", TZ: "UTC" };
|
|
8
9
|
/** Facts only. Public KERN_PROC_ALL supplies birth/UID/trace metadata without
|
|
@@ -189,15 +190,17 @@ const nullable = (value, test) => value === null || test(value);
|
|
|
189
190
|
const safeText = (value) => typeof value === "string" && value.length <= 4096 && !/[\x00-\x1f\x7f]/.test(value);
|
|
190
191
|
/** Strict parser also strips every field not in the facts contract. */
|
|
191
192
|
export function parseDarwinNativeSnapshot(text) {
|
|
192
|
-
const
|
|
193
|
-
if (!
|
|
193
|
+
const read = darwinNativeCensusSchema.safeParse(JSON.parse(text));
|
|
194
|
+
if (!read.success)
|
|
194
195
|
throw new Error("malformed-native-census");
|
|
196
|
+
const v = read.data;
|
|
195
197
|
const pids = new Set(), ids = new Set();
|
|
196
198
|
const processes = v.processes.map((r) => {
|
|
197
|
-
|
|
199
|
+
const row = darwinProcessIdentitySchema.safeParse(r);
|
|
200
|
+
if (!row.success)
|
|
198
201
|
throw new Error("malformed-process-identity");
|
|
199
|
-
const p =
|
|
200
|
-
if (
|
|
202
|
+
const p = row.data;
|
|
203
|
+
if (pids.has(p.pid) || (p.uniqueId !== null && !kernelIdInRange(p.uniqueId)) || p.uniqueId === "0" || (p.uniqueId !== null && ids.has(p.uniqueId)) || (p.parentUniqueId !== null && !kernelIdInRange(p.parentUniqueId)))
|
|
201
204
|
throw new Error("malformed-process-identity");
|
|
202
205
|
pids.add(p.pid);
|
|
203
206
|
if (p.uniqueId !== null)
|
package/dist/project-tools.d.ts
CHANGED
|
@@ -1,26 +1,6 @@
|
|
|
1
1
|
import type { Store, ToolActionInfo } from "./store.js";
|
|
2
|
-
|
|
3
|
-
export type ToolSecret
|
|
4
|
-
name: string;
|
|
5
|
-
optional: boolean;
|
|
6
|
-
};
|
|
7
|
-
export type ToolSpec = {
|
|
8
|
-
name: string;
|
|
9
|
-
transport: "stdio" | "http";
|
|
10
|
-
/** stdio: the program and its arguments (no secrets in either). */
|
|
11
|
-
command: string | null;
|
|
12
|
-
args: string[];
|
|
13
|
-
/** http: the server's address. */
|
|
14
|
-
url: string | null;
|
|
15
|
-
/** Every secret the tool needs. stdio: each is an env variable of the server process. */
|
|
16
|
-
secrets: ToolSecret[];
|
|
17
|
-
/** http: the secret sent as `Authorization: Bearer <value>`. */
|
|
18
|
-
bearer: string | null;
|
|
19
|
-
/** http: header name → the secret sent as its raw value. */
|
|
20
|
-
headerSecrets: Record<string, string>;
|
|
21
|
-
/** What it does, in plain words. */
|
|
22
|
-
about: string;
|
|
23
|
-
};
|
|
2
|
+
import { type ToolSecret, type ToolSpec } from "./contracts/integration-metadata.js";
|
|
3
|
+
export type { ToolSecret, ToolSpec };
|
|
24
4
|
export type ToolTest = {
|
|
25
5
|
at: string;
|
|
26
6
|
ok: boolean;
|
|
@@ -68,7 +48,10 @@ export declare function catalogTool(id: string): (ToolSpec & {
|
|
|
68
48
|
}) | null;
|
|
69
49
|
/** What a build is held to: how the server starts and which secrets it names — never their values or its description. */
|
|
70
50
|
export declare function toolDigest(spec: ToolSpec): string;
|
|
71
|
-
/**
|
|
51
|
+
/**
|
|
52
|
+
* A tool definition from any source (catalog, console form, the lead, an import, a saved row), checked whole and made
|
|
53
|
+
* the spec its schema (src/contracts/integration-metadata.ts) says. Throws in plain words.
|
|
54
|
+
*/
|
|
72
55
|
export declare function validateToolSpec(input: Record<string, unknown>): ToolSpec;
|
|
73
56
|
/** How the tool starts, in one line a person can check. Never a secret value. */
|
|
74
57
|
export declare function toolCommandLine(spec: ToolSpec): string;
|
|
@@ -234,4 +217,3 @@ export declare function toolStanding(tool: ProjectTool, secretsSet: readonly str
|
|
|
234
217
|
export declare function secretsSetFor(repo: string, tool: ToolSpec, home?: string): string[];
|
|
235
218
|
/** A command line split into program and arguments, honouring "double" and 'single' quotes. */
|
|
236
219
|
export declare function splitCommandLine(line: string): string[];
|
|
237
|
-
export {};
|
package/dist/project-tools.js
CHANGED
|
@@ -23,9 +23,7 @@ import { isAbsolute, join } from "node:path";
|
|
|
23
23
|
import { fileURLToPath } from "node:url";
|
|
24
24
|
import { scanForSecrets } from "./evidence.js";
|
|
25
25
|
import { namedPath } from "./names.js";
|
|
26
|
-
|
|
27
|
-
const SECRET_NAME = /^[A-Z][A-Z0-9_]{0,63}$/;
|
|
28
|
-
const HEADER_NAME = /^[A-Za-z0-9-]{1,64}$/;
|
|
26
|
+
import { HEADER_NAME, SECRET_NAME, TOOL_NAME as NAME, TOOL_SPEC_LIMITS as LIMITS, readToolSpec } from "./contracts/integration-metadata.js";
|
|
29
27
|
/** Env names a tool may never claim: the process's own plumbing, every
|
|
30
28
|
* provider credential, and anything that changes how a program or its
|
|
31
29
|
* interpreter loads (a secret's value must stay data, never code). */
|
|
@@ -89,17 +87,20 @@ const str = (value, cap, what) => {
|
|
|
89
87
|
throw new Error(`${what} cannot contain control characters.`);
|
|
90
88
|
return value.trim();
|
|
91
89
|
};
|
|
92
|
-
/**
|
|
90
|
+
/**
|
|
91
|
+
* A tool definition from any source (catalog, console form, the lead, an import, a saved row), checked whole and made
|
|
92
|
+
* the spec its schema (src/contracts/integration-metadata.ts) says. Throws in plain words.
|
|
93
|
+
*/
|
|
93
94
|
export function validateToolSpec(input) {
|
|
94
|
-
const name = str(input["name"],
|
|
95
|
+
const name = str(input["name"], LIMITS.name, "A short name").toLowerCase();
|
|
95
96
|
if (!NAME.test(name))
|
|
96
|
-
throw new Error(
|
|
97
|
+
throw new Error(`The name is lowercase letters, numbers, - and _ (up to ${LIMITS.name}), starting with a letter or number.`);
|
|
97
98
|
const transport = input["transport"] === "http" ? "http" : input["transport"] === "stdio" || input["transport"] === undefined ? "stdio" : null;
|
|
98
99
|
if (transport === null)
|
|
99
100
|
throw new Error("A tool runs as a local program or at a web address.");
|
|
100
101
|
const secretsIn = Array.isArray(input["secrets"]) ? input["secrets"] : [];
|
|
101
|
-
if (secretsIn.length >
|
|
102
|
-
throw new Error(
|
|
102
|
+
if (secretsIn.length > LIMITS.secrets)
|
|
103
|
+
throw new Error(`A tool can name up to ${LIMITS.secrets} secrets.`);
|
|
103
104
|
const secrets = [];
|
|
104
105
|
for (const one of secretsIn) {
|
|
105
106
|
const entry = typeof one === "string" ? { name: one, optional: false } : one;
|
|
@@ -115,14 +116,14 @@ export function validateToolSpec(input) {
|
|
|
115
116
|
let command = null, args = [], url = null, bearer = null;
|
|
116
117
|
const headerSecrets = {};
|
|
117
118
|
if (transport === "stdio") {
|
|
118
|
-
command = str(input["command"],
|
|
119
|
+
command = str(input["command"], LIMITS.command, "The command");
|
|
119
120
|
const argsIn = input["args"] === undefined ? [] : input["args"];
|
|
120
|
-
if (!Array.isArray(argsIn) || argsIn.length >
|
|
121
|
-
throw new Error(
|
|
121
|
+
if (!Array.isArray(argsIn) || argsIn.length > LIMITS.args || argsIn.some(arg => typeof arg !== "string" || arg.length > LIMITS.arg || /[\u0000-\u001f\u007f]/.test(arg)))
|
|
122
|
+
throw new Error(`Arguments are up to ${LIMITS.args} plain words.`);
|
|
122
123
|
args = argsIn;
|
|
123
124
|
}
|
|
124
125
|
else {
|
|
125
|
-
url = str(input["url"],
|
|
126
|
+
url = str(input["url"], LIMITS.url, "The address");
|
|
126
127
|
let parsed;
|
|
127
128
|
try {
|
|
128
129
|
parsed = new URL(url);
|
|
@@ -149,14 +150,17 @@ export function validateToolSpec(input) {
|
|
|
149
150
|
headerSecrets[header] = secret;
|
|
150
151
|
}
|
|
151
152
|
}
|
|
152
|
-
const about = typeof input["about"] === "string" && input["about"].trim() !== "" ? input["about"].trim().slice(0,
|
|
153
|
+
const about = typeof input["about"] === "string" && input["about"].trim() !== "" ? input["about"].trim().slice(0, LIMITS.about)
|
|
153
154
|
: TOOL_CATALOG.find(one => one.name === name)?.about
|
|
154
155
|
?? (transport === "http" ? `Tools from ${new URL(url).hostname}.` : `Tools from ${programName(command, args)}.`);
|
|
155
156
|
const spec = { name, transport, command, args, url, secrets, bearer, headerSecrets, about };
|
|
156
157
|
// Values belong in the secret store; a key typed into a command, argument or address is refused.
|
|
157
158
|
if (scanForSecrets([command ?? "", ...args, url ?? "", about].join("\n")).length > 0)
|
|
158
159
|
throw new Error("That looks like a key or token in the tool's settings. Name it as a secret instead; its value is set on the secure Tools screen.");
|
|
159
|
-
|
|
160
|
+
const checked = readToolSpec(spec);
|
|
161
|
+
if (!checked.ok)
|
|
162
|
+
throw new Error(`That tool is not valid: ${checked.issues.map(issue => issue.line).join("; ")}`);
|
|
163
|
+
return checked.value;
|
|
160
164
|
}
|
|
161
165
|
/** What a local tool is, in a word: the package a runner like npx starts, else the program's own name. */
|
|
162
166
|
function programName(command, args) {
|
|
@@ -251,7 +255,7 @@ function fromClaudeEntry(name, raw, source) {
|
|
|
251
255
|
if (type === "stdio") {
|
|
252
256
|
for (const [key, value] of Object.entries((entry["env"] ?? {})))
|
|
253
257
|
keep(key, value);
|
|
254
|
-
const spec = validateToolSpec({ name: name.toLowerCase().replace(/[^a-z0-9_-]/g, "-").slice(0,
|
|
258
|
+
const spec = validateToolSpec({ name: name.toLowerCase().replace(/[^a-z0-9_-]/g, "-").slice(0, LIMITS.name), transport: "stdio", command: entry["command"], args: entry["args"] ?? [], secrets });
|
|
255
259
|
return { spec, source, values };
|
|
256
260
|
}
|
|
257
261
|
if (type === "http") {
|
|
@@ -269,7 +273,7 @@ function fromClaudeEntry(name, raw, source) {
|
|
|
269
273
|
else if (keep(secret, value))
|
|
270
274
|
headerSecrets[header] = secret;
|
|
271
275
|
}
|
|
272
|
-
const spec = validateToolSpec({ name: name.toLowerCase().replace(/[^a-z0-9_-]/g, "-").slice(0,
|
|
276
|
+
const spec = validateToolSpec({ name: name.toLowerCase().replace(/[^a-z0-9_-]/g, "-").slice(0, LIMITS.name), transport: "http", url: entry["url"], secrets, bearer, headerSecrets });
|
|
273
277
|
return { spec, source, values };
|
|
274
278
|
}
|
|
275
279
|
}
|
|
@@ -305,7 +309,7 @@ function fromCodexEntry(raw, environment) {
|
|
|
305
309
|
const cwd = typeof transport["cwd"] === "string" ? transport["cwd"] : null;
|
|
306
310
|
if (!isAbsolute(command) && command.includes("/") && cwd !== null && isAbsolute(cwd))
|
|
307
311
|
command = join(cwd, command);
|
|
308
|
-
const spec = validateToolSpec({ name: name.toLowerCase().replace(/[^a-z0-9_-]/g, "-").slice(0,
|
|
312
|
+
const spec = validateToolSpec({ name: name.toLowerCase().replace(/[^a-z0-9_-]/g, "-").slice(0, LIMITS.name), transport: "stdio", command, args: transport["args"] ?? [], secrets });
|
|
309
313
|
return { spec, source: "Codex on this computer", values };
|
|
310
314
|
}
|
|
311
315
|
if (transport["type"] === "streamable_http") {
|
|
@@ -322,7 +326,7 @@ function fromCodexEntry(raw, environment) {
|
|
|
322
326
|
if (keep(secret, value))
|
|
323
327
|
headerSecrets[header] = secret;
|
|
324
328
|
}
|
|
325
|
-
const spec = validateToolSpec({ name: name.toLowerCase().replace(/[^a-z0-9_-]/g, "-").slice(0,
|
|
329
|
+
const spec = validateToolSpec({ name: name.toLowerCase().replace(/[^a-z0-9_-]/g, "-").slice(0, LIMITS.name), transport: "http", url: transport["url"], secrets, bearer, headerSecrets });
|
|
326
330
|
return { spec, source: "Codex on this computer", values };
|
|
327
331
|
}
|
|
328
332
|
}
|
package/dist/provider.d.ts
CHANGED
|
@@ -19,14 +19,10 @@
|
|
|
19
19
|
* shell the model itself launches (Codex provider review, Q5).
|
|
20
20
|
*/
|
|
21
21
|
import { type ExecResult, type RunOptions } from "./exec.js";
|
|
22
|
-
|
|
22
|
+
import { MODEL_ID, type AgentSpec, type ProviderId } from "./contracts/provider.js";
|
|
23
|
+
export type { AgentSpec, ProviderId } from "./contracts/provider.js";
|
|
23
24
|
export declare const PROVIDER_IDS: readonly ProviderId[];
|
|
24
25
|
export declare function isProviderId(value: string): value is ProviderId;
|
|
25
|
-
/** Which harness runs, on which model. model null = the harness's default. */
|
|
26
|
-
export type AgentSpec = {
|
|
27
|
-
provider: ProviderId;
|
|
28
|
-
model: string | null;
|
|
29
|
-
};
|
|
30
26
|
/** 'review' (v29) is the reviewer's artifact-only pass — plan-shaped in
|
|
31
27
|
* every clamp: read, comment, never build. */
|
|
32
28
|
export type Phase = "plan" | "build" | "repair" | "review";
|
|
@@ -36,7 +32,7 @@ export type Phase = "plan" | "build" | "repair" | "review";
|
|
|
36
32
|
* TOML-hostile characters — but NOT alphanumeric-only, which would refuse
|
|
37
33
|
* real ids carrying `/ . : -` (Codex provider review, Q5).
|
|
38
34
|
*/
|
|
39
|
-
export
|
|
35
|
+
export { MODEL_ID };
|
|
40
36
|
export declare function validModelId(model: string | null): boolean;
|
|
41
37
|
/** The one semantic request every provider renders into its own argv. */
|
|
42
38
|
export type Invocation = {
|
|
@@ -297,4 +293,3 @@ export type ProviderInspection = {
|
|
|
297
293
|
measuresCost: boolean;
|
|
298
294
|
};
|
|
299
295
|
export declare function inspectionOf(provider: ProviderId): ProviderInspection;
|
|
300
|
-
export {};
|
package/dist/provider.js
CHANGED
|
@@ -24,9 +24,10 @@ import { runStreamJsonl, runClaudeStreamJsonl, runGeminiStreamJsonl } from "./ex
|
|
|
24
24
|
import { scanForSecrets } from "./evidence.js";
|
|
25
25
|
import { FINDINGS_MODEL_SCHEMA } from "./contracts/review-findings.js";
|
|
26
26
|
import { REVIEW_OUTPUT_LIMITS } from "./structured-output.js";
|
|
27
|
-
|
|
27
|
+
import { agentSpecSchema, MODEL_ID, modelIdSchema, PROVIDER_ID_VALUES, providerIdSchema } from "./contracts/provider.js";
|
|
28
|
+
export const PROVIDER_IDS = PROVIDER_ID_VALUES;
|
|
28
29
|
export function isProviderId(value) {
|
|
29
|
-
return
|
|
30
|
+
return providerIdSchema.safeParse(value).success;
|
|
30
31
|
}
|
|
31
32
|
/**
|
|
32
33
|
* Model ids cross providers ("anthropic/claude-sonnet-4.5", "gpt-5-codex",
|
|
@@ -34,9 +35,10 @@ export function isProviderId(value) {
|
|
|
34
35
|
* TOML-hostile characters — but NOT alphanumeric-only, which would refuse
|
|
35
36
|
* real ids carrying `/ . : -` (Codex provider review, Q5).
|
|
36
37
|
*/
|
|
37
|
-
export
|
|
38
|
+
export { MODEL_ID };
|
|
38
39
|
export function validModelId(model) {
|
|
39
|
-
|
|
40
|
+
// Not a string reads as its text, as the regex always read it (an untyped row's `undefined` passes as "undefined").
|
|
41
|
+
return model === null || modelIdSchema.safeParse(String(model)).success;
|
|
40
42
|
}
|
|
41
43
|
const USAGE_JSON_CAP = 8 * 1024;
|
|
42
44
|
/** Empty and whitespace-only identities are absence, never resumable ids. */
|
|
@@ -919,17 +921,19 @@ export function auditOf(provider) {
|
|
|
919
921
|
* across a 300-model catalog.
|
|
920
922
|
*/
|
|
921
923
|
export function validateSpec(spec) {
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
924
|
+
// A refusal names the field, never the value: a pasted credential must not travel on in a problem.
|
|
925
|
+
const parsed = agentSpecSchema.safeParse({ provider: spec.provider, model: spec.model === null ? null : String(spec.model) });
|
|
926
|
+
if (!parsed.success) {
|
|
927
|
+
const field = parsed.error.issues[0]?.path[0];
|
|
928
|
+
if (field === "provider")
|
|
929
|
+
return { ok: false, problem: `provider: unknown provider — one of ${PROVIDER_IDS.join(", ")}` };
|
|
930
|
+
return { ok: false, problem: "model: a model id is 1–128 characters of letters, digits, and . _ : / - (never leading with a dash)" };
|
|
927
931
|
}
|
|
928
932
|
if (spec.provider === "openrouter" && spec.model === null) {
|
|
929
|
-
return { ok: false, problem: "openrouter needs an explicit model — there is no default across its catalog" };
|
|
933
|
+
return { ok: false, problem: "model: openrouter needs an explicit model — there is no default across its catalog" };
|
|
930
934
|
}
|
|
931
935
|
if (spec.provider === "gemini" && spec.model === null) {
|
|
932
|
-
return { ok: false, problem: "gemini needs an explicit model — the harness default drifts with its releases" };
|
|
936
|
+
return { ok: false, problem: "model: gemini needs an explicit model — the harness default drifts with its releases" };
|
|
933
937
|
}
|
|
934
938
|
return { ok: true };
|
|
935
939
|
}
|
package/dist/recipes.d.ts
CHANGED
|
@@ -1,32 +1,8 @@
|
|
|
1
1
|
import type { Store } from "./store.js";
|
|
2
|
-
import { type
|
|
2
|
+
import { type RecipeDocument } from "./contracts/recipes.js";
|
|
3
3
|
export declare const RECIPE_SCHEMA = "\nCREATE TABLE IF NOT EXISTS workflow_recipe (\n id TEXT NOT NULL, revision INTEGER NOT NULL CHECK(revision > 0),\n repo TEXT NOT NULL, document TEXT NOT NULL, digest TEXT NOT NULL,\n author TEXT NOT NULL, created_at TEXT NOT NULL,\n PRIMARY KEY(id, revision)\n);\nCREATE INDEX IF NOT EXISTS workflow_recipe_project ON workflow_recipe(repo, id, revision DESC);\nCREATE TRIGGER IF NOT EXISTS workflow_recipe_no_update BEFORE UPDATE ON workflow_recipe\nBEGIN SELECT RAISE(ABORT, 'recipe revisions are immutable'); END;\nCREATE TRIGGER IF NOT EXISTS workflow_recipe_no_delete BEFORE DELETE ON workflow_recipe\nBEGIN SELECT RAISE(ABORT, 'recipe revisions are immutable'); END;\nCREATE TABLE IF NOT EXISTS workflow_preview (\n token TEXT PRIMARY KEY, actor TEXT NOT NULL, repo TEXT NOT NULL,\n document TEXT NOT NULL, digest TEXT NOT NULL, source TEXT NOT NULL,\n created_at TEXT NOT NULL, expires_at TEXT NOT NULL,\n task_id TEXT REFERENCES task(id), routine_id INTEGER REFERENCES routine(id),\n saved_id TEXT, saved_revision INTEGER,\n CHECK(task_id IS NULL OR routine_id IS NULL),\n FOREIGN KEY(saved_id, saved_revision) REFERENCES workflow_recipe(id, revision)\n);\nCREATE INDEX IF NOT EXISTS workflow_preview_project ON workflow_preview(repo, created_at DESC);\nCREATE INDEX IF NOT EXISTS workflow_preview_source ON workflow_preview(repo, source, created_at DESC)\nWHERE task_id IS NOT NULL OR routine_id IS NOT NULL;\n";
|
|
4
|
-
type
|
|
5
|
-
|
|
6
|
-
name: string;
|
|
7
|
-
description: string;
|
|
8
|
-
goal: string;
|
|
9
|
-
outOfScope: string | null;
|
|
10
|
-
touches: string[];
|
|
11
|
-
acceptance: AcceptanceCriterion[];
|
|
12
|
-
planning: "auto" | "required" | "skip";
|
|
13
|
-
deliverable: "branch" | "report";
|
|
14
|
-
schedule: string | null;
|
|
15
|
-
costCeilingUsd: number | null;
|
|
16
|
-
};
|
|
17
|
-
export type RecipeInput = {
|
|
18
|
-
key: string;
|
|
19
|
-
label: string;
|
|
20
|
-
defaultValue: string | null;
|
|
21
|
-
};
|
|
22
|
-
export type RecipeDocument = RecipeFields & ({
|
|
23
|
-
version: 1;
|
|
24
|
-
inputs?: never;
|
|
25
|
-
} | {
|
|
26
|
-
version: 2;
|
|
27
|
-
inputs: RecipeInput[];
|
|
28
|
-
});
|
|
29
|
-
export declare const RECIPE_INPUT_LIMIT = 8;
|
|
4
|
+
export type { RecipeDocument, RecipeInput } from "./contracts/recipes.js";
|
|
5
|
+
export declare const RECIPE_INPUT_LIMIT: 8;
|
|
30
6
|
export type RecipeAnswers = ReadonlyMap<string, string>;
|
|
31
7
|
export type Recipe = {
|
|
32
8
|
id: string;
|
|
@@ -78,4 +54,3 @@ export declare function workflowSteps(document: RecipeDocument): {
|
|
|
78
54
|
detail: string;
|
|
79
55
|
}[];
|
|
80
56
|
export declare function recipeScheduleWords(document: RecipeDocument, now: Date): string;
|
|
81
|
-
export {};
|
package/dist/recipes.js
CHANGED
|
@@ -9,6 +9,7 @@ import { validateTaskText, fileTaskProposal, fileRoutineProposal } from "./propo
|
|
|
9
9
|
import { parseSchedule, firstFireAt, describeSchedule, validateRoutineTerms } from "./routine.js";
|
|
10
10
|
import { applyModeToNewFiling } from "./plan-auto.js";
|
|
11
11
|
import { canonicalProject } from "./project.js";
|
|
12
|
+
import { RECIPE_FORMAT, RECIPE_LIMITS, readRecipeDocument } from "./contracts/recipes.js";
|
|
12
13
|
import { resolve } from "node:path";
|
|
13
14
|
export const RECIPE_SCHEMA = `
|
|
14
15
|
CREATE TABLE IF NOT EXISTS workflow_recipe (
|
|
@@ -35,7 +36,7 @@ CREATE INDEX IF NOT EXISTS workflow_preview_project ON workflow_preview(repo, cr
|
|
|
35
36
|
CREATE INDEX IF NOT EXISTS workflow_preview_source ON workflow_preview(repo, source, created_at DESC)
|
|
36
37
|
WHERE task_id IS NOT NULL OR routine_id IS NOT NULL;
|
|
37
38
|
`;
|
|
38
|
-
export const RECIPE_INPUT_LIMIT =
|
|
39
|
+
export const RECIPE_INPUT_LIMIT = RECIPE_LIMITS.inputs;
|
|
39
40
|
export class RecipeError extends Error {
|
|
40
41
|
status;
|
|
41
42
|
constructor(message, status = 400) {
|
|
@@ -43,63 +44,93 @@ export class RecipeError extends Error {
|
|
|
43
44
|
this.status = status;
|
|
44
45
|
}
|
|
45
46
|
}
|
|
46
|
-
const keys = ["format", "version", "name", "description", "goal", "outOfScope", "touches", "acceptance", "planning", "deliverable", "schedule", "costCeilingUsd"];
|
|
47
47
|
const honest = (text) => !hasForbiddenControls(text) && !hasDisguisedText(text);
|
|
48
48
|
const recipeProject = (repo) => canonicalProject(repo) ?? resolve(repo);
|
|
49
|
+
/** What a refusal at each top-level field tells the person, after the path-named line. */
|
|
50
|
+
const FIELD_ADVICE = {
|
|
51
|
+
name: "Name, goal, exclusions, and allowed paths must be readable text.", description: "Name, goal, exclusions, and allowed paths must be readable text.",
|
|
52
|
+
goal: "Name, goal, exclusions, and allowed paths must be readable text.", outOfScope: "Name, goal, exclusions, and allowed paths must be readable text.",
|
|
53
|
+
touches: "Name, goal, exclusions, and allowed paths must be readable text.",
|
|
54
|
+
planning: "Choose a supported planning and result type.", deliverable: "Choose a supported planning and result type.",
|
|
55
|
+
schedule: "Choose a valid schedule.", costCeilingUsd: "The weekly budget must be a positive dollar amount.",
|
|
56
|
+
};
|
|
57
|
+
const FIELDS_MISMATCH = "Recipe fields do not match this version. Recipes contain work definitions, not permissions, credentials, or agent settings.";
|
|
58
|
+
/** The one refusal a recipe structure gets: its first path-named line, with the advice for that field. */
|
|
59
|
+
function structureError(issues) {
|
|
60
|
+
const top = issues.find(one => one.kind === "unknown-key" && one.path === "payload") ?? issues.find(one => one.kind === "required" && !/[.[]/.test(one.path));
|
|
61
|
+
if (top !== undefined)
|
|
62
|
+
return new RecipeError(`${top.line}. ${FIELDS_MISMATCH}`);
|
|
63
|
+
const first = issues[0];
|
|
64
|
+
const field = first.path.split(/[.[]/)[0];
|
|
65
|
+
if (field === "acceptance") {
|
|
66
|
+
if (issues.some(one => one.path.startsWith("acceptance") && one.kind === "unknown-key")) {
|
|
67
|
+
return new RecipeError(`${issues.find(one => one.path.startsWith("acceptance") && one.kind === "unknown-key").line}. Success checks have unsupported fields.`);
|
|
68
|
+
}
|
|
69
|
+
return new RecipeError(issues.filter(one => one.path.startsWith("acceptance")).map(one => one.line).join("; "));
|
|
70
|
+
}
|
|
71
|
+
if (field === "inputs") {
|
|
72
|
+
const advice = first.path === "inputs" ? "Add between 1 and 8 questions, or remove the empty questions to save a fixed recipe."
|
|
73
|
+
: /\.key$/.test(first.path) ? "Give each question a unique key: lowercase letters, numbers, or underscores, starting with a letter (up to 32 characters)."
|
|
74
|
+
: /\.label$/.test(first.path) ? "Give each question a readable label of up to 80 characters."
|
|
75
|
+
: /\.defaultValue$/.test(first.path) && first.kind !== "required" ? "Default answers must be plain text up to 500 characters, without input placeholders."
|
|
76
|
+
: "Questions contain only a key, label, and optional default answer.";
|
|
77
|
+
return new RecipeError(`${first.line}. ${advice}`);
|
|
78
|
+
}
|
|
79
|
+
// Zod states `positive` as an exclusive minimum ("at least 0"); the advice says it plainly.
|
|
80
|
+
if (field === "costCeilingUsd" && first.kind === "bad-value")
|
|
81
|
+
return new RecipeError(`costCeilingUsd: ${FIELD_ADVICE[field]}`);
|
|
82
|
+
return new RecipeError(`${first.line}. ${FIELD_ADVICE[field] ?? FIELDS_MISMATCH}`);
|
|
83
|
+
}
|
|
49
84
|
/** Reject unknown fields, including approval/provider settings, on import. */
|
|
50
85
|
export function parseRecipe(input) {
|
|
51
86
|
if (typeof input !== "object" || input === null || Array.isArray(input))
|
|
52
87
|
throw new RecipeError("Choose a recipe document.");
|
|
53
|
-
if (Buffer.byteLength(JSON.stringify(input), "utf8") >
|
|
88
|
+
if (Buffer.byteLength(JSON.stringify(input), "utf8") > RECIPE_LIMITS.documentBytes)
|
|
54
89
|
throw new RecipeError("A recipe must be at most 32 KB.");
|
|
55
90
|
const raw = input;
|
|
56
|
-
if (raw.format !==
|
|
57
|
-
throw new RecipeError("This recipe format/version is not supported.");
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
throw new RecipeError(
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if (
|
|
64
|
-
throw
|
|
65
|
-
const
|
|
91
|
+
if (raw.format !== RECIPE_FORMAT)
|
|
92
|
+
throw new RecipeError("format: This recipe format/version is not supported.");
|
|
93
|
+
if (raw.version !== 1 && raw.version !== 2) {
|
|
94
|
+
const newer = typeof raw.version === "number" && Number.isInteger(raw.version) && raw.version > 2;
|
|
95
|
+
throw new RecipeError(`version: ${newer ? `made by a newer Toolroll (version ${raw.version}). ` : ""}This recipe format/version is not supported.`);
|
|
96
|
+
}
|
|
97
|
+
const read = readRecipeDocument(raw);
|
|
98
|
+
if (!read.ok)
|
|
99
|
+
throw structureError(read.issues);
|
|
100
|
+
const doc = read.value;
|
|
101
|
+
if (doc.description.length > RECIPE_LIMITS.description || !honest(doc.description))
|
|
102
|
+
throw new RecipeError("description: Keep the description within 400 characters, without hidden text.");
|
|
103
|
+
const bad = validateTaskText({ title: doc.name, goal: doc.goal, outOfScope: doc.outOfScope, touches: doc.touches });
|
|
66
104
|
if (bad !== null)
|
|
67
105
|
throw new RecipeError(bad.message);
|
|
68
|
-
if (
|
|
69
|
-
throw new RecipeError("Choose a
|
|
70
|
-
if (
|
|
71
|
-
throw new RecipeError("
|
|
72
|
-
if (
|
|
73
|
-
throw new RecipeError("
|
|
74
|
-
if (
|
|
75
|
-
throw new RecipeError("
|
|
76
|
-
if (raw.deliverable === "report" && raw.schedule !== null)
|
|
77
|
-
throw new RecipeError("Report recipes run once. Repeating workflows currently produce code changes.");
|
|
78
|
-
if (raw.schedule !== null && raw.planning === "required")
|
|
79
|
-
throw new RecipeError("Plan-first recipes run once. Repeating workflows reuse their approved scope; choose direct execution or run once.");
|
|
80
|
-
if (!Array.isArray(raw.acceptance) || raw.acceptance.some(one => typeof one !== "object" || one === null || Object.keys(one).some(key => !["id", "statement", "how", "evidence"].includes(key))))
|
|
81
|
-
throw new RecipeError("Success checks have unsupported fields.");
|
|
106
|
+
if (doc.schedule !== null && parseSchedule(doc.schedule) === null)
|
|
107
|
+
throw new RecipeError("schedule: Choose a valid schedule.");
|
|
108
|
+
if (doc.schedule === null && doc.costCeilingUsd !== null)
|
|
109
|
+
throw new RecipeError("costCeilingUsd: A weekly budget applies to a repeating workflow. One-time work uses the project's task budget.");
|
|
110
|
+
if (doc.deliverable === "report" && doc.schedule !== null)
|
|
111
|
+
throw new RecipeError("deliverable: Report recipes run once. Repeating workflows currently produce code changes.");
|
|
112
|
+
if (doc.schedule !== null && doc.planning === "required")
|
|
113
|
+
throw new RecipeError("planning: Plan-first recipes run once. Repeating workflows reuse their approved scope; choose direct execution or run once.");
|
|
82
114
|
const parsed = parseAcceptanceCriteria(raw.acceptance);
|
|
83
115
|
if (parsed.problems.length > 0 || parsed.criteria.length === 0)
|
|
84
|
-
throw new RecipeError(parsed.problems.map(one => one.message).join("; ") || "Add at least one success check.");
|
|
85
|
-
const fields = { format:
|
|
86
|
-
touches: [...
|
|
87
|
-
const document = raw.version === 1 ? { ...fields, version: 1 } : { ...fields, version: 2, inputs: parseRecipeInputs(raw.inputs) };
|
|
116
|
+
throw new RecipeError(parsed.problems.map(one => one.message).join("; ") || "acceptance: Add at least one success check.");
|
|
117
|
+
const fields = { format: RECIPE_FORMAT, name: doc.name.trim(), description: doc.description.trim(), goal: doc.goal.trim(), outOfScope: doc.outOfScope?.trim() || null,
|
|
118
|
+
touches: [...doc.touches], acceptance: parsed.criteria, planning: doc.planning, deliverable: doc.deliverable, schedule: doc.schedule, costCeilingUsd: doc.costCeilingUsd };
|
|
88
119
|
// Keep v1's canonical key order stable: existing immutable digests must survive.
|
|
89
|
-
const ordered = { format:
|
|
90
|
-
outOfScope:
|
|
91
|
-
schedule:
|
|
120
|
+
const ordered = { format: fields.format, version: doc.version, name: fields.name, description: fields.description, goal: fields.goal,
|
|
121
|
+
outOfScope: fields.outOfScope, touches: fields.touches, acceptance: fields.acceptance, planning: fields.planning, deliverable: fields.deliverable,
|
|
122
|
+
schedule: fields.schedule, costCeilingUsd: fields.costCeilingUsd, ...(doc.version === 2 ? { inputs: parseRecipeInputs(doc.inputs) } : {}) };
|
|
92
123
|
if (ordered.version === 2)
|
|
93
124
|
validateInputReferences(ordered);
|
|
94
|
-
if (
|
|
95
|
-
const problems = validateRoutineTerms({ repo: "/recipe-preview", ...
|
|
125
|
+
if (ordered.schedule !== null) {
|
|
126
|
+
const problems = validateRoutineTerms({ repo: "/recipe-preview", ...ordered, requirements: [], schedule: ordered.schedule, singleFlight: true });
|
|
96
127
|
if (problems.length)
|
|
97
128
|
throw new RecipeError(problems.map(one => `${one.field}: ${one.problem}`).join("; "));
|
|
98
129
|
}
|
|
99
130
|
return ordered;
|
|
100
131
|
}
|
|
101
132
|
export function importRecipe(text) {
|
|
102
|
-
if (Buffer.byteLength(text, "utf8") >
|
|
133
|
+
if (Buffer.byteLength(text, "utf8") > RECIPE_LIMITS.documentBytes)
|
|
103
134
|
throw new RecipeError("A recipe must be at most 32 KB.");
|
|
104
135
|
let value;
|
|
105
136
|
try {
|
|
@@ -112,23 +143,19 @@ export function importRecipe(text) {
|
|
|
112
143
|
}
|
|
113
144
|
export const recipeDigest = (document) => createHash("sha256").update(JSON.stringify(document)).digest("hex");
|
|
114
145
|
export const exportRecipe = (document) => JSON.stringify(parseRecipe(document), null, 2) + "\n";
|
|
115
|
-
/** Inputs are literal work text, never a program, path grant, or shell fragment. */
|
|
116
|
-
function parseRecipeInputs(
|
|
117
|
-
if (!Array.isArray(raw) || raw.length < 1 || raw.length > RECIPE_INPUT_LIMIT)
|
|
118
|
-
throw new RecipeError("Add between 1 and 8 questions, or remove the empty questions to save a fixed recipe.");
|
|
146
|
+
/** Inputs are literal work text, never a program, path grant, or shell fragment. Their shape is already read. */
|
|
147
|
+
function parseRecipeInputs(inputs) {
|
|
119
148
|
const seen = new Set();
|
|
120
|
-
return
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
if (
|
|
125
|
-
throw new RecipeError(
|
|
126
|
-
if (
|
|
127
|
-
throw new RecipeError(
|
|
128
|
-
if (defaultValue !== null && (typeof defaultValue !== "string" || defaultValue.length > 500 || !honest(defaultValue) || /\{\{|\}\}/.test(defaultValue)))
|
|
129
|
-
throw new RecipeError("Default answers must be plain text up to 500 characters, without input placeholders.");
|
|
149
|
+
return inputs.map(({ key, label, defaultValue }, index) => {
|
|
150
|
+
const at = `inputs[${index}]`;
|
|
151
|
+
if (!/^[a-z][a-z0-9_]{0,31}$/.test(key) || ["constructor", "prototype"].includes(key) || seen.has(key))
|
|
152
|
+
throw new RecipeError(`${at}.key: Give each question a unique key: lowercase letters, numbers, or underscores, starting with a letter (up to 32 characters).`);
|
|
153
|
+
if (!label.trim() || label.length > RECIPE_LIMITS.label || !honest(label))
|
|
154
|
+
throw new RecipeError(`${at}.label: Give each question a readable label of up to 80 characters.`);
|
|
155
|
+
if (defaultValue !== null && (defaultValue.length > RECIPE_LIMITS.answer || !honest(defaultValue) || /\{\{|\}\}/.test(defaultValue)))
|
|
156
|
+
throw new RecipeError(`${at}.defaultValue: Default answers must be plain text up to 500 characters, without input placeholders.`);
|
|
130
157
|
seen.add(key);
|
|
131
|
-
return { key, label: label.trim(), defaultValue:
|
|
158
|
+
return { key, label: label.trim(), defaultValue: defaultValue === null ? null : defaultValue.trim() || null };
|
|
132
159
|
});
|
|
133
160
|
}
|
|
134
161
|
const inputToken = /\{\{\s*([a-z][a-z0-9_]{0,31})\s*\}\}/g;
|