toolroll 0.9.33 → 0.9.34
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 +13 -0
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +17 -17
- package/dist/browser-crew.js +1 -0
- package/dist/browser-workspace.d.ts +2 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +124 -63
- package/dist/contracts/contract-test.d.ts +45 -0
- package/dist/contracts/contract-test.js +69 -0
- package/dist/contracts/contract.d.ts +59 -0
- package/dist/contracts/contract.js +181 -0
- package/dist/contracts/plan.d.ts +58 -0
- package/dist/contracts/plan.js +67 -0
- package/dist/desktop-update.d.ts +1 -1
- package/dist/evidence.js +2 -2
- package/dist/operate.js +52 -10
- package/dist/plan.d.ts +12 -12
- package/dist/plan.js +129 -72
- package/dist/planner-source.js +2 -1
- package/dist/planner.js +55 -25
- package/dist/project-admission.d.ts +93 -0
- package/dist/project-admission.js +134 -0
- package/dist/project-delete-ui.js +5 -2
- package/dist/runner.d.ts +10 -0
- package/dist/runner.js +16 -0
- package/dist/scope.js +4 -3
- package/dist/serve.d.ts +4 -0
- package/dist/serve.js +104 -61
- package/dist/store.d.ts +2 -0
- package/dist/store.js +4 -0
- package/dist/surface.js +8 -2
- package/dist/text-limits.d.ts +17 -0
- package/dist/text-limits.js +16 -0
- package/dist/when-html.d.ts +2 -0
- package/dist/when-html.js +13 -0
- package/package.json +3 -2
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one way Toolroll describes a contract (docs/plans/zod-revamp.md): one Zod schema, and everything else — the
|
|
3
|
+
* TypeScript type, the runtime check, the JSON Schema a model gets, the text limits — derived from it.
|
|
4
|
+
*
|
|
5
|
+
* The helpers here hold the ground rules every contract shares: limits come from TEXT_LIMITS, persisted payloads are
|
|
6
|
+
* versioned, refusals name the path, and a model-facing schema is exactly what its JSON Schema says (no refinements or
|
|
7
|
+
* transforms; those checks run in plain code after parsing, with a named error).
|
|
8
|
+
*/
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { limitRule, TEXT_LIMITS } from "../text-limits.js";
|
|
11
|
+
/**
|
|
12
|
+
* A string bounded by its TEXT_LIMITS entry, never a literal. The limit rule rides along as the field's description,
|
|
13
|
+
* so a model reads the bound before it writes. Zod counts UTF-16 code units; for a limit named in bytes this is the
|
|
14
|
+
* schema's necessary bound (a string within N bytes is within N code units), and the byte count itself is checked in
|
|
15
|
+
* plain code after parsing.
|
|
16
|
+
*/
|
|
17
|
+
export function limited(field, limitKey) {
|
|
18
|
+
const limit = TEXT_LIMITS[limitKey];
|
|
19
|
+
const unit = limitKey.endsWith("Bytes") ? "bytes" : "characters";
|
|
20
|
+
return z.string().max(limit, { error: `over ${limit.toLocaleString("en-US")} ${unit}` }).describe(limitRule(field, limit, unit));
|
|
21
|
+
}
|
|
22
|
+
/** A versioned payload envelope: a strict object whose `version` is exactly `version`. */
|
|
23
|
+
export function versioned(version, shape) {
|
|
24
|
+
return z.strictObject({ version: z.literal(version), ...shape });
|
|
25
|
+
}
|
|
26
|
+
/** `["steps", 0, "routes", 0, "goesTo"]` as `steps[0].routes[0].goesTo`. */
|
|
27
|
+
export function pathOf(path) {
|
|
28
|
+
let out = "";
|
|
29
|
+
for (const part of path) {
|
|
30
|
+
if (typeof part === "number")
|
|
31
|
+
out += `[${part}]`;
|
|
32
|
+
else
|
|
33
|
+
out += out === "" ? String(part) : `.${String(part)}`;
|
|
34
|
+
}
|
|
35
|
+
return out === "" ? "payload" : out;
|
|
36
|
+
}
|
|
37
|
+
function got(input) {
|
|
38
|
+
if (input === null)
|
|
39
|
+
return "null";
|
|
40
|
+
if (Array.isArray(input))
|
|
41
|
+
return "an array";
|
|
42
|
+
return typeof input === "object" ? "an object" : `a ${typeof input}`;
|
|
43
|
+
}
|
|
44
|
+
function issuesOf(issue) {
|
|
45
|
+
const at = pathOf(issue.path);
|
|
46
|
+
const one = (kind, what) => ({ path: at, kind, line: `${at}: ${what}` });
|
|
47
|
+
switch (issue.code) {
|
|
48
|
+
case "unrecognized_keys":
|
|
49
|
+
return issue.keys.map(key => ({ path: at, kind: "unknown-key", line: `${at}: unknown key '${key}'` }));
|
|
50
|
+
case "invalid_type": {
|
|
51
|
+
const missing = "input" in issue ? issue.input === undefined : / received undefined$/.test(issue.message);
|
|
52
|
+
if (missing)
|
|
53
|
+
return [one("required", "required")];
|
|
54
|
+
return [one("wrong-type", `must be ${issue.expected === "array" ? "an array" : issue.expected === "object" ? "an object" : `a ${issue.expected}`}${"input" in issue ? ` (got ${got(issue.input)})` : ""}`)];
|
|
55
|
+
}
|
|
56
|
+
case "too_big":
|
|
57
|
+
if (issue.origin === "array" || issue.origin === "set")
|
|
58
|
+
return [one("too-many", `at most ${String(issue.maximum)} items`)];
|
|
59
|
+
return [one("too-long", issue.origin === "string" && issue.message.startsWith("over ") ? issue.message : `at most ${String(issue.maximum)}${issue.origin === "string" ? " characters" : ""}`)];
|
|
60
|
+
case "too_small":
|
|
61
|
+
if (issue.origin === "string" && Number(issue.minimum) === 1)
|
|
62
|
+
return [one("empty", "must not be empty")];
|
|
63
|
+
if (issue.origin === "array" || issue.origin === "set")
|
|
64
|
+
return [one("too-few", `at least ${String(issue.minimum)} item${Number(issue.minimum) === 1 ? "" : "s"}`)];
|
|
65
|
+
return [one("too-few", `at least ${String(issue.minimum)}`)];
|
|
66
|
+
case "invalid_value":
|
|
67
|
+
return [one("bad-value", `must be ${issue.values.length === 1 ? JSON.stringify(issue.values[0]) : `one of ${issue.values.map(value => JSON.stringify(value)).join(", ")}`}`)];
|
|
68
|
+
case "invalid_union":
|
|
69
|
+
return [one("invalid", "does not match any allowed shape")];
|
|
70
|
+
default:
|
|
71
|
+
return [one("invalid", issue.message)];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** A Zod error as path-named issues, in the error's own order. */
|
|
75
|
+
export function contractIssues(error) {
|
|
76
|
+
return error.issues.flatMap(issuesOf);
|
|
77
|
+
}
|
|
78
|
+
/** A Zod error as path-named lines: `steps[0].routes[0].goesTo: required`, `routes[0]: unknown key 'to'`. */
|
|
79
|
+
export function contractError(error) {
|
|
80
|
+
return contractIssues(error).map(issue => issue.line);
|
|
81
|
+
}
|
|
82
|
+
/** Parse with the input reported, so a missing field reads `required` rather than a wrong type. */
|
|
83
|
+
export function parseContract(schema, input) {
|
|
84
|
+
const parsed = schema.safeParse(input, { reportInput: true });
|
|
85
|
+
return parsed.success ? { ok: true, value: parsed.data } : { ok: false, issues: contractIssues(parsed.error) };
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Read a versioned payload. The current version parses as itself; an older known version (0 for a payload saved
|
|
89
|
+
* before it carried `version`) is upgraded by its adapter and then parsed by the same schema; a newer version is
|
|
90
|
+
* refused plainly; anything else is an unknown version.
|
|
91
|
+
*/
|
|
92
|
+
export function readVersioned(schema, input, upgrades = {}) {
|
|
93
|
+
const current = schema.shape.version.value;
|
|
94
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
|
95
|
+
return { ok: false, issues: [{ path: "payload", kind: "wrong-type", line: `payload: must be an object (got ${got(input)})` }] };
|
|
96
|
+
}
|
|
97
|
+
const body = input;
|
|
98
|
+
const version = Object.prototype.hasOwnProperty.call(body, "version") ? body["version"] : 0;
|
|
99
|
+
if (version === current)
|
|
100
|
+
return parseContract(schema, body);
|
|
101
|
+
if (typeof version === "number" && Number.isInteger(version) && version > current) {
|
|
102
|
+
return { ok: false, issues: [{ path: "version", kind: "newer-version", line: `version: made by a newer Toolroll (version ${version}; this one reads up to ${current})` }] };
|
|
103
|
+
}
|
|
104
|
+
const upgrade = typeof version === "number" ? upgrades[version] : undefined;
|
|
105
|
+
if (upgrade === undefined) {
|
|
106
|
+
return { ok: false, issues: [{ path: "version", kind: "bad-value", line: `version: unknown version ${JSON.stringify(version)} (this Toolroll reads ${current}${Object.keys(upgrades).length === 0 ? "" : ` and ${Object.keys(upgrades).join(", ")}`})` }] };
|
|
107
|
+
}
|
|
108
|
+
return parseContract(schema, upgrade(body));
|
|
109
|
+
}
|
|
110
|
+
/** The Zod node kinds a model-facing schema may use: each says exactly what its JSON Schema says. */
|
|
111
|
+
const MODEL_FACING_TYPES = new Set(["string", "number", "int", "boolean", "null", "literal", "enum", "array", "object", "optional", "nullable", "union", "tuple", "record", "lazy", "readonly", "unknown", "any"]);
|
|
112
|
+
/** Checks that only constrain (never rewrite) and that JSON Schema can state. */
|
|
113
|
+
const MODEL_FACING_CHECKS = new Set(["max_length", "min_length", "length_equals", "greater_than", "less_than", "multiple_of", "number_format", "string_format", "size_equals", "max_size", "min_size"]);
|
|
114
|
+
/** Every node of a schema graph, refusing a refinement, transform, preprocess or unknown node by its schema path. */
|
|
115
|
+
function assertModelFacing(schema, at, seen) {
|
|
116
|
+
if (seen.has(schema))
|
|
117
|
+
return;
|
|
118
|
+
seen.add(schema);
|
|
119
|
+
const def = schema._zod.def;
|
|
120
|
+
const where = at === "" ? "the schema" : at;
|
|
121
|
+
if (def.type === "pipe") {
|
|
122
|
+
const into = def["in"]?._zod.def.type;
|
|
123
|
+
const out = def["out"]?._zod.def.type;
|
|
124
|
+
const what = into === "transform" ? "preprocess" : out === "transform" ? "transform" : "pipe";
|
|
125
|
+
throw new Error(`toModelSchema: ${where} uses ${what}; a model-facing schema must be JSON-Schema-exact — check it in plain code after parsing`);
|
|
126
|
+
}
|
|
127
|
+
if (def.type === "transform")
|
|
128
|
+
throw new Error(`toModelSchema: ${where} uses transform; a model-facing schema must be JSON-Schema-exact — check it in plain code after parsing`);
|
|
129
|
+
if (!MODEL_FACING_TYPES.has(def.type))
|
|
130
|
+
throw new Error(`toModelSchema: ${where} uses a ${def.type} node, which a model-facing schema may not use`);
|
|
131
|
+
for (const check of def.checks ?? []) {
|
|
132
|
+
const kind = check._zod.def.check;
|
|
133
|
+
if (kind === "custom")
|
|
134
|
+
throw new Error(`toModelSchema: ${where} uses refine or superRefine; a model-facing schema must be JSON-Schema-exact — check it in plain code after parsing`);
|
|
135
|
+
if (!MODEL_FACING_CHECKS.has(kind))
|
|
136
|
+
throw new Error(`toModelSchema: ${where} uses a ${kind} check, which rewrites or cannot be stated in JSON Schema`);
|
|
137
|
+
}
|
|
138
|
+
const child = (value, path) => assertModelFacing(value, path, seen);
|
|
139
|
+
const join = (key) => (at === "" ? key : `${at}.${key}`);
|
|
140
|
+
switch (def.type) {
|
|
141
|
+
case "object":
|
|
142
|
+
for (const [key, value] of Object.entries(def["shape"]))
|
|
143
|
+
child(value, join(key));
|
|
144
|
+
// A strict object's catchall is `never` (additionalProperties: false); any other catchall is a schema too.
|
|
145
|
+
if (def["catchall"] !== undefined && def["catchall"]._zod.def.type !== "never")
|
|
146
|
+
child(def["catchall"], join("*"));
|
|
147
|
+
break;
|
|
148
|
+
case "array":
|
|
149
|
+
child(def["element"], `${at}[]`);
|
|
150
|
+
break;
|
|
151
|
+
case "optional":
|
|
152
|
+
case "nullable":
|
|
153
|
+
case "readonly":
|
|
154
|
+
child(def["innerType"], at);
|
|
155
|
+
break;
|
|
156
|
+
case "union":
|
|
157
|
+
def["options"].forEach((option, index) => child(option, `${at}|${index}`));
|
|
158
|
+
break;
|
|
159
|
+
case "tuple":
|
|
160
|
+
def["items"].forEach((item, index) => child(item, `${at}[${index}]`));
|
|
161
|
+
if (def["rest"] !== undefined && def["rest"] !== null)
|
|
162
|
+
child(def["rest"], `${at}[]`);
|
|
163
|
+
break;
|
|
164
|
+
case "record":
|
|
165
|
+
child(def["keyType"], `${at}{key}`);
|
|
166
|
+
child(def["valueType"], `${at}{}`);
|
|
167
|
+
break;
|
|
168
|
+
case "lazy":
|
|
169
|
+
child(def["getter"](), at);
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* The JSON Schema a model gets for `schema` (Claude's `--json-schema`). Throws when the schema uses refine,
|
|
175
|
+
* superRefine, transform or preprocess anywhere: what a model is told must be everything the schema checks.
|
|
176
|
+
*/
|
|
177
|
+
export function toModelSchema(schema) {
|
|
178
|
+
assertModelFacing(schema, "", new Set());
|
|
179
|
+
const { $schema: _dialect, ...json } = z.toJSONSchema(schema, { target: "draft-2020-12", unrepresentable: "throw" });
|
|
180
|
+
return json;
|
|
181
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The planner's handoff: one schema for the file a planner writes, the JSON Schema it is given (`--json-schema`) and
|
|
3
|
+
* the parser that reads it back (`parsePlan` in plan.ts). The rules JSON Schema cannot state — the plan document's
|
|
4
|
+
* sections, UTF-8 byte limits, control characters, duplicate criterion ids, the Proof naming every criterion and the
|
|
5
|
+
* silent-amendment contract check — run in plain code after parsing, each with a named error.
|
|
6
|
+
*/
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { type ContractResult } from "./contract.js";
|
|
9
|
+
/** How many paths a plan may say it touches. */
|
|
10
|
+
export declare const PLAN_TOUCHES = 32;
|
|
11
|
+
/** One acceptance criterion: the scope's own shape (scope.ts `AcceptanceCriterion`), `how` advisory and unsigned. */
|
|
12
|
+
export declare const acceptanceCriterionSchema: z.ZodObject<{
|
|
13
|
+
id: z.ZodString;
|
|
14
|
+
statement: z.ZodString;
|
|
15
|
+
how: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
16
|
+
evidence: z.ZodArray<z.ZodEnum<{
|
|
17
|
+
"changed-path": "changed-path";
|
|
18
|
+
check: "check";
|
|
19
|
+
"manual-review": "manual-review";
|
|
20
|
+
screenshot: "screenshot";
|
|
21
|
+
}>>;
|
|
22
|
+
}, z.core.$strict>;
|
|
23
|
+
export declare const PLAN_VERSION = 1;
|
|
24
|
+
export declare const planSchema: z.ZodObject<{
|
|
25
|
+
version: z.ZodLiteral<1>;
|
|
26
|
+
goal: z.ZodString;
|
|
27
|
+
outOfScope: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
28
|
+
touches: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
29
|
+
acceptance: z.ZodArray<z.ZodObject<{
|
|
30
|
+
id: z.ZodString;
|
|
31
|
+
statement: z.ZodString;
|
|
32
|
+
how: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
33
|
+
evidence: z.ZodArray<z.ZodEnum<{
|
|
34
|
+
"changed-path": "changed-path";
|
|
35
|
+
check: "check";
|
|
36
|
+
"manual-review": "manual-review";
|
|
37
|
+
screenshot: "screenshot";
|
|
38
|
+
}>>;
|
|
39
|
+
}, z.core.$strict>>;
|
|
40
|
+
plan: z.ZodString;
|
|
41
|
+
amendment: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
42
|
+
}, z.core.$strict>;
|
|
43
|
+
export type PlanPayload = z.infer<typeof planSchema>;
|
|
44
|
+
/** What the planner's `--json-schema` is: the plan schema, exactly. */
|
|
45
|
+
export declare const PLAN_MODEL_SCHEMA: Readonly<Record<string, unknown>>;
|
|
46
|
+
/**
|
|
47
|
+
* A plan written before plans carried `version` (every plan through 0.9.33), as version 1. The old parser read only
|
|
48
|
+
* the fields it knew and ignored any others, read a null goal, plan or acceptance as missing and null touches as
|
|
49
|
+
* none; this keeps exactly that, so every plan that parsed then parses now. Values themselves are never rewritten.
|
|
50
|
+
*/
|
|
51
|
+
export declare function upgradeUnversionedPlan(body: Record<string, unknown>): Record<string, unknown>;
|
|
52
|
+
export declare const PLAN_UPGRADES: {
|
|
53
|
+
readonly 0: typeof upgradeUnversionedPlan;
|
|
54
|
+
};
|
|
55
|
+
/** The payload as version 1, upgraded when it was saved unversioned; null for anything that is not an object. */
|
|
56
|
+
export declare function planPayloadBody(input: unknown): Record<string, unknown> | null;
|
|
57
|
+
/** Read a plan payload: version 1 as itself, an unversioned one upgraded, a newer one refused plainly. */
|
|
58
|
+
export declare function readPlanPayload(input: unknown): ContractResult<PlanPayload>;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The planner's handoff: one schema for the file a planner writes, the JSON Schema it is given (`--json-schema`) and
|
|
3
|
+
* the parser that reads it back (`parsePlan` in plan.ts). The rules JSON Schema cannot state — the plan document's
|
|
4
|
+
* sections, UTF-8 byte limits, control characters, duplicate criterion ids, the Proof naming every criterion and the
|
|
5
|
+
* silent-amendment contract check — run in plain code after parsing, each with a named error.
|
|
6
|
+
*/
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { ACCEPTANCE_LIMITS, EVIDENCE_KINDS } from "../scope.js";
|
|
9
|
+
import { limited, readVersioned, toModelSchema, versioned } from "./contract.js";
|
|
10
|
+
/** How many paths a plan may say it touches. */
|
|
11
|
+
export const PLAN_TOUCHES = 32;
|
|
12
|
+
/** One acceptance criterion: the scope's own shape (scope.ts `AcceptanceCriterion`), `how` advisory and unsigned. */
|
|
13
|
+
export const acceptanceCriterionSchema = z.strictObject({
|
|
14
|
+
id: limited("acceptance id", "acceptanceIdBytes").min(1),
|
|
15
|
+
statement: limited("acceptance statement", "acceptanceStatementBytes").min(1),
|
|
16
|
+
how: limited("acceptance how", "acceptanceHowBytes").nullable().optional(),
|
|
17
|
+
evidence: z.array(z.enum(EVIDENCE_KINDS)).min(1).max(EVIDENCE_KINDS.length),
|
|
18
|
+
});
|
|
19
|
+
export const PLAN_VERSION = 1;
|
|
20
|
+
export const planSchema = versioned(PLAN_VERSION, {
|
|
21
|
+
goal: limited("goal", "goal").min(1),
|
|
22
|
+
outOfScope: limited("outOfScope", "goal").nullable().optional(),
|
|
23
|
+
touches: z.array(limited("touch", "planTouch").min(1)).max(PLAN_TOUCHES).optional(),
|
|
24
|
+
acceptance: z.array(acceptanceCriterionSchema).min(1).max(ACCEPTANCE_LIMITS.criteria),
|
|
25
|
+
plan: limited("plan", "planDocumentBytes").min(1),
|
|
26
|
+
amendment: limited("amendment", "planAmendment").nullable().optional(),
|
|
27
|
+
});
|
|
28
|
+
/** What the planner's `--json-schema` is: the plan schema, exactly. */
|
|
29
|
+
export const PLAN_MODEL_SCHEMA = toModelSchema(planSchema);
|
|
30
|
+
const PLAN_FIELDS = ["goal", "outOfScope", "touches", "acceptance", "plan", "amendment"];
|
|
31
|
+
const CRITERION_FIELDS = ["id", "statement", "how", "evidence"];
|
|
32
|
+
function pick(body, fields) {
|
|
33
|
+
const out = {};
|
|
34
|
+
for (const field of fields) {
|
|
35
|
+
if (Object.prototype.hasOwnProperty.call(body, field) && body[field] !== undefined)
|
|
36
|
+
out[field] = body[field];
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
41
|
+
/**
|
|
42
|
+
* A plan written before plans carried `version` (every plan through 0.9.33), as version 1. The old parser read only
|
|
43
|
+
* the fields it knew and ignored any others, read a null goal, plan or acceptance as missing and null touches as
|
|
44
|
+
* none; this keeps exactly that, so every plan that parsed then parses now. Values themselves are never rewritten.
|
|
45
|
+
*/
|
|
46
|
+
export function upgradeUnversionedPlan(body) {
|
|
47
|
+
const out = { version: PLAN_VERSION, ...pick(body, PLAN_FIELDS) };
|
|
48
|
+
for (const field of ["goal", "plan", "touches", "acceptance"]) {
|
|
49
|
+
if (out[field] === null)
|
|
50
|
+
delete out[field];
|
|
51
|
+
}
|
|
52
|
+
if (Array.isArray(out["acceptance"])) {
|
|
53
|
+
out["acceptance"] = out["acceptance"].map(criterion => (isRecord(criterion) ? pick(criterion, CRITERION_FIELDS) : criterion));
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
export const PLAN_UPGRADES = { 0: upgradeUnversionedPlan };
|
|
58
|
+
/** The payload as version 1, upgraded when it was saved unversioned; null for anything that is not an object. */
|
|
59
|
+
export function planPayloadBody(input) {
|
|
60
|
+
if (!isRecord(input))
|
|
61
|
+
return null;
|
|
62
|
+
return Object.prototype.hasOwnProperty.call(input, "version") ? input : upgradeUnversionedPlan(input);
|
|
63
|
+
}
|
|
64
|
+
/** Read a plan payload: version 1 as itself, an unversioned one upgraded, a newer one refused plainly. */
|
|
65
|
+
export function readPlanPayload(input) {
|
|
66
|
+
return readVersioned(planSchema, input, PLAN_UPGRADES);
|
|
67
|
+
}
|
package/dist/desktop-update.d.ts
CHANGED
|
@@ -125,13 +125,13 @@ export declare function prepareDesktopUpdate(stateDir: string, installed: string
|
|
|
125
125
|
export declare function sqliteLock(file: string, busyTimeoutMs?: number): DatabaseSync | null;
|
|
126
126
|
export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
|
|
127
127
|
export declare function desktopUpdateStatus(stateDir: string): {
|
|
128
|
+
error?: never;
|
|
128
129
|
active: boolean;
|
|
129
130
|
phase: string;
|
|
130
131
|
detail: string;
|
|
131
132
|
running: boolean;
|
|
132
133
|
canResume: boolean;
|
|
133
134
|
canCancel: boolean;
|
|
134
|
-
error?: never;
|
|
135
135
|
automaticRecovery?: never;
|
|
136
136
|
recoveryAttempts?: never;
|
|
137
137
|
recoveryRepairs?: never;
|
package/dist/evidence.js
CHANGED
|
@@ -18,7 +18,7 @@ import { closeSync, existsSync, constants, fstatSync, mkdirSync, openSync, readd
|
|
|
18
18
|
import { join, sep } from "node:path";
|
|
19
19
|
import { namedPath } from "./names.js";
|
|
20
20
|
import { LIMITS } from "./decision.js";
|
|
21
|
-
import {
|
|
21
|
+
import { TEXT_LIMITS } from "./text-limits.js";
|
|
22
22
|
import { parseReport, REPORT_LIMITS } from "./scout-report.js";
|
|
23
23
|
import { parseProof, PROOF_LIMITS } from "./proof.js";
|
|
24
24
|
import { encodeBaseTreeSnapshot, parseBaseTreeSnapshot } from "./peek.js";
|
|
@@ -84,7 +84,7 @@ export const EVIDENCE_CAPS = {
|
|
|
84
84
|
diff: 256 * 1024,
|
|
85
85
|
status: 64 * 1024,
|
|
86
86
|
"park-payload": LIMITS.payload,
|
|
87
|
-
plan:
|
|
87
|
+
plan: TEXT_LIMITS.planDocumentBytes,
|
|
88
88
|
"terminal-diff": 256 * 1024,
|
|
89
89
|
"diff-stat": 32 * 1024,
|
|
90
90
|
// The enveloped base snapshot (live peek): 20k entries of path+sha+size.
|
package/dist/operate.js
CHANGED
|
@@ -93,6 +93,7 @@ import { pushPass } from "./push.js";
|
|
|
93
93
|
import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, fsyncSync, openSync, readFileSync, readSync, realpathSync, renameSync, unlinkSync, writeSync, writeFileSync, mkdirSync } from "node:fs";
|
|
94
94
|
import { BRANCH_PREFIX, envTwins, envValue, existingOrFirst, namedPath, taskBranches } from "./names.js";
|
|
95
95
|
import { claimActor, currentActor, parseLeadToken, withActor } from "./actor.js";
|
|
96
|
+
import { admissionRecorded, admitProject, commandLineActor } from "./project-admission.js";
|
|
96
97
|
import { leadClaim, leadSay, noteLeadWork } from "./lead-voice.js";
|
|
97
98
|
import { createServer as createNetServer } from "node:net";
|
|
98
99
|
import { spawn as spawnChild } from "node:child_process";
|
|
@@ -162,7 +163,7 @@ import { attendedLivenessState } from "./liveness.js";
|
|
|
162
163
|
import { HeldSessionCoordinator, sweepHeldOrphans } from "./held.js";
|
|
163
164
|
import { proposeGrant, describeGrant, describeWithheld, permits, MUTATION_CLASSES, DEFAULT_MUTATIONS, } from "./grant.js";
|
|
164
165
|
import { builtIn, guarded } from "./backend.js";
|
|
165
|
-
import { register, authenticate, heartbeat as heartbeatRunner, isAlive, recoverDead, recoveredAnything, acquireWatchLeaseAuthed, heartbeatWatchLeaseAuthed, addRunnerReposAuthed, registerRunnerIfIdle, retireRunnerIfCurrent, normalizeRunnerName, validRunnerName, canonicalRepos, RUNNER_NAME_MAX, RUNNER_CAPACITY_MAX, parseRunnerCapacity, } from "./runner.js";
|
|
166
|
+
import { register, authenticate, heartbeat as heartbeatRunner, isAlive, recoverDead, recoveredAnything, acquireWatchLeaseAuthed, heartbeatWatchLeaseAuthed, addRunnerReposAuthed, removeRunnerRepoAuthed, registerRunnerIfIdle, retireRunnerIfCurrent, normalizeRunnerName, validRunnerName, canonicalRepos, RUNNER_NAME_MAX, RUNNER_CAPACITY_MAX, parseRunnerCapacity, } from "./runner.js";
|
|
166
167
|
import { mintCoordinator, revokeCoordinator, listCoordinators } from "./coordinator.js";
|
|
167
168
|
import { serveMcp } from "./mcp.js";
|
|
168
169
|
import { createInterface } from "node:readline";
|
|
@@ -4934,6 +4935,7 @@ async function startConsole(options) {
|
|
|
4934
4935
|
...(options.repos === undefined ? {} : { repos: options.repos }),
|
|
4935
4936
|
...(options.projectRoots === undefined ? {} : { projectRoots: options.projectRoots }),
|
|
4936
4937
|
...(options.currentRepos === undefined ? {} : { currentRepos: options.currentRepos }),
|
|
4938
|
+
...(options.admittedRepos === undefined ? {} : { admittedRepos: options.admittedRepos }),
|
|
4937
4939
|
...(options.publicUrl === undefined ? {} : { publicUrl: options.publicUrl }),
|
|
4938
4940
|
...(options.registryPath === undefined ? {} : { registryPath: options.registryPath }),
|
|
4939
4941
|
...(options.upConsole === undefined ? {} : { upConsole: options.upConsole }),
|
|
@@ -8360,12 +8362,15 @@ async function onboardCommand(flags, context) {
|
|
|
8360
8362
|
return !("error" in registry) && registry.repos.includes(repo);
|
|
8361
8363
|
},
|
|
8362
8364
|
enroll: async (repo) => {
|
|
8365
|
+
// The same admission as `repos add` and the console's Add a project.
|
|
8366
|
+
const actor = commandLineActor(store, { databaseFile: context.databaseFile });
|
|
8367
|
+
if ("ok" in actor)
|
|
8368
|
+
return { ok: false, message: actor.message };
|
|
8363
8369
|
const before = await loadProjectRegistry(registryPath);
|
|
8364
|
-
const
|
|
8365
|
-
if (!
|
|
8366
|
-
return { ok: false, message:
|
|
8367
|
-
|
|
8368
|
-
return { ok: true, added: "error" in before || !before.repos.includes(repo) };
|
|
8370
|
+
const admitted = await admitProject(store, { registryFile: registryPath, path: repo, actor, now: clock() });
|
|
8371
|
+
if (!admitted.ok)
|
|
8372
|
+
return { ok: false, message: admitted.message };
|
|
8373
|
+
return { ok: true, added: "error" in before || !before.repos.includes(admitted.repo) };
|
|
8369
8374
|
},
|
|
8370
8375
|
checkConnection: seams.checkConnection ?? (agent => checker(agent)),
|
|
8371
8376
|
pullRequests: seams.pullRequests ?? (async (repo) => {
|
|
@@ -8495,6 +8500,9 @@ async function upCommand(flags, context) {
|
|
|
8495
8500
|
}
|
|
8496
8501
|
}
|
|
8497
8502
|
const explicitRepos = context.repoList ?? [];
|
|
8503
|
+
// Repositories this start was told to serve stay served while it runs: removing one from the saved list takes
|
|
8504
|
+
// effect at the next start, never under the process that was pinned to it.
|
|
8505
|
+
const pinnedRepos = new Set();
|
|
8498
8506
|
if (explicitRepos.length > 0) {
|
|
8499
8507
|
for (const input of explicitRepos) {
|
|
8500
8508
|
const root = await proveRepo(input);
|
|
@@ -8503,12 +8511,15 @@ async function upCommand(flags, context) {
|
|
|
8503
8511
|
}
|
|
8504
8512
|
if (!repos.includes(root))
|
|
8505
8513
|
repos.push(root);
|
|
8514
|
+
pinnedRepos.add(root);
|
|
8506
8515
|
}
|
|
8507
8516
|
}
|
|
8508
8517
|
else if (context.inferProjectFromCwd !== false) {
|
|
8509
8518
|
const cwdRepo = await proveRepo(process.cwd());
|
|
8510
8519
|
if (cwdRepo !== null && !repos.includes(cwdRepo))
|
|
8511
8520
|
repos.push(cwdRepo);
|
|
8521
|
+
if (cwdRepo !== null)
|
|
8522
|
+
pinnedRepos.add(cwdRepo);
|
|
8512
8523
|
}
|
|
8513
8524
|
if (repos.length === 0 && projectRoots.length === 0) {
|
|
8514
8525
|
return fail(write, json, "up", "no-projects", "choose where your projects live once: `toolroll up --project-root <dir>`", EXIT.refused);
|
|
@@ -8660,6 +8671,7 @@ async function upCommand(flags, context) {
|
|
|
8660
8671
|
poolRoot: pool,
|
|
8661
8672
|
repos,
|
|
8662
8673
|
currentRepos: () => [...activeRepos],
|
|
8674
|
+
admittedRepos: () => [...activeRepos],
|
|
8663
8675
|
upConsole: true,
|
|
8664
8676
|
attended: {
|
|
8665
8677
|
runner: runnerName,
|
|
@@ -8752,6 +8764,8 @@ async function upCommand(flags, context) {
|
|
|
8752
8764
|
// One record of project passes for the whole service: fair scheduling
|
|
8753
8765
|
// across its projects (project-concurrency.ts).
|
|
8754
8766
|
const projectPasses = new ProjectPasses();
|
|
8767
|
+
// Projects removed while running: each one's watch stops admitting work, finishes what it holds, then lets go.
|
|
8768
|
+
const releasedRepos = new Set();
|
|
8755
8769
|
const launchRepo = async (repo, bind) => {
|
|
8756
8770
|
if (loopResults.has(repo) || stopping)
|
|
8757
8771
|
return;
|
|
@@ -8776,7 +8790,7 @@ async function upCommand(flags, context) {
|
|
|
8776
8790
|
token: runnerToken,
|
|
8777
8791
|
repo,
|
|
8778
8792
|
progress: line => progress(`${prefix(repo)}${line}`),
|
|
8779
|
-
isStopping: () => stopping,
|
|
8793
|
+
isStopping: () => stopping || releasedRepos.has(repo),
|
|
8780
8794
|
onFollowController: controller => followControllers.push(controller),
|
|
8781
8795
|
containmentLines,
|
|
8782
8796
|
passes: projectPasses,
|
|
@@ -8789,6 +8803,19 @@ async function upCommand(flags, context) {
|
|
|
8789
8803
|
}).then(result => ({ repo, result }), error => ({ repo, result: { ok: false, reason: "loop-failed", detail: describe(error), ticks: 0, built: 0, broke: 0 } }));
|
|
8790
8804
|
loopResults.set(repo, loop);
|
|
8791
8805
|
void loop.then(({ repo: endedRepo, result }) => {
|
|
8806
|
+
if (releasedRepos.has(endedRepo) && !stopping) {
|
|
8807
|
+
// A removed project's watch has stopped: only now does the builder let go of it.
|
|
8808
|
+
const unbound = removeRunnerRepoAuthed(store, { name: runnerName, token: runnerToken, repo: endedRepo }, clock());
|
|
8809
|
+
if (!unbound.ok) {
|
|
8810
|
+
fatal = `the builder identity changed while removing ${endedRepo} (${unbound.reason})`;
|
|
8811
|
+
stop();
|
|
8812
|
+
}
|
|
8813
|
+
loopResults.delete(endedRepo);
|
|
8814
|
+
releasedRepos.delete(endedRepo);
|
|
8815
|
+
progress(`project removed — ${projectName(endedRepo)} is no longer built here`);
|
|
8816
|
+
markReady();
|
|
8817
|
+
return;
|
|
8818
|
+
}
|
|
8792
8819
|
// A loop that dies while its siblings live must not leave a partial
|
|
8793
8820
|
// cockpit standing silently (finding 5).
|
|
8794
8821
|
if (!result.ok && !stopping) {
|
|
@@ -8842,19 +8869,34 @@ async function upCommand(flags, context) {
|
|
|
8842
8869
|
stop();
|
|
8843
8870
|
break;
|
|
8844
8871
|
}
|
|
8872
|
+
// Removed from the saved list: stop building it and take it out of the console. A pinned start keeps its own.
|
|
8873
|
+
const saved = new Set(loaded.repos.map(one => canonicalProject(one) ?? one));
|
|
8874
|
+
for (const repo of [...activeRepos]) {
|
|
8875
|
+
if (stopping || pinnedRepos.has(repo) || saved.has(repo))
|
|
8876
|
+
continue;
|
|
8877
|
+
activeRepos.delete(repo);
|
|
8878
|
+
releasedRepos.add(repo);
|
|
8879
|
+
if (!loopResults.has(repo))
|
|
8880
|
+
releasedRepos.delete(repo);
|
|
8881
|
+
}
|
|
8845
8882
|
for (const candidate of loaded.repos) {
|
|
8846
8883
|
if (stopping)
|
|
8847
8884
|
break;
|
|
8848
8885
|
const canonical = canonicalProject(candidate);
|
|
8849
|
-
|
|
8886
|
+
// Still letting go of an earlier removal: it rejoins on the next pass after its watch has stopped.
|
|
8887
|
+
if (canonical !== null && (activeRepos.has(canonical) || releasedRepos.has(canonical)))
|
|
8850
8888
|
continue;
|
|
8851
8889
|
const approvedAdditions = context.additionalProjectRepos?.() ?? [];
|
|
8852
8890
|
const admittedCeiling = resolveCeiling([...dynamicCeiling.repos, ...approvedAdditions], dynamicCeiling.roots).ceiling;
|
|
8853
|
-
|
|
8891
|
+
// A project added after this start (`repos add`, the lead, the console) carries its recorded admission; a
|
|
8892
|
+
// registry line alone is not authority. Either way the folder is proved to be that Git checkout again.
|
|
8893
|
+
const allowed = canonical !== null &&
|
|
8894
|
+
((await authorizedProject(admittedCeiling, canonical)) || admissionRecorded(store, canonical)) &&
|
|
8895
|
+
(await proveRepo(canonical)) === canonical;
|
|
8854
8896
|
if (!allowed) {
|
|
8855
8897
|
if (!rejected.has(candidate)) {
|
|
8856
8898
|
rejected.add(candidate);
|
|
8857
|
-
progress(`project not connected — ${candidate}
|
|
8899
|
+
progress(`project not connected — ${candidate} isn't a Git repository added with \`toolroll repos add\` or the console`);
|
|
8858
8900
|
}
|
|
8859
8901
|
continue;
|
|
8860
8902
|
}
|
package/dist/plan.d.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* the scope proposal is authority-bearing, so it gets the park discipline).
|
|
7
7
|
*/
|
|
8
8
|
import { type AcceptanceCriterion } from "./scope.js";
|
|
9
|
+
import type { ContractIssue } from "./contracts/contract.js";
|
|
9
10
|
export type PlanProblem = {
|
|
10
11
|
reason: string;
|
|
11
12
|
message: string;
|
|
@@ -69,18 +70,17 @@ export type PlanParseResult = {
|
|
|
69
70
|
ok: false;
|
|
70
71
|
problems: PlanProblem[];
|
|
71
72
|
};
|
|
72
|
-
/**
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
};
|
|
73
|
+
/**
|
|
74
|
+
* A contract issue as a plan problem: the path-named line is the message, and the reason keeps the codes the rest of
|
|
75
|
+
* the code relies on — `missing-goal`, `bad-touches`, `plan-too-long`, `amendment-too-long`, `acceptance-too-many` —
|
|
76
|
+
* so repair eligibility (document-only problems) and durable outcomes read the same as before the contract.
|
|
77
|
+
*/
|
|
78
|
+
export declare function planProblemOf(issue: ContractIssue): PlanProblem;
|
|
79
|
+
/**
|
|
80
|
+
* The planner's handoff, read against the plan contract (src/contracts/plan.ts): fail closed, every problem at once,
|
|
81
|
+
* each one naming its path (`acceptance[0].evidence[0]: must be one of ...`). A plan saved before plans carried
|
|
82
|
+
* `version` reads as it always did.
|
|
83
|
+
*/
|
|
84
84
|
export declare function parsePlan(raw: string): PlanParseResult;
|
|
85
85
|
export type MilestoneState = "pending" | "current" | "completed" | "blocked";
|
|
86
86
|
export type Milestone = {
|