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.
@@ -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
+ }
@@ -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 { PLAN_LIMITS } from "./plan.js";
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: PLAN_LIMITS.document,
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 enrolled = await updateRepos(registryPath, repos => addRepos(repos, [repo]));
8365
- if (!enrolled.ok)
8366
- return { ok: false, message: enrolled.message };
8367
- store.upsertProject(repo, projectName(repo), clock());
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
- if (canonical !== null && activeRepos.has(canonical))
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
- const allowed = canonical !== null && (await authorizedProject(admittedCeiling, canonical)) && (await proveRepo(canonical)) === canonical;
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} is not a Git repository inside a saved projects folder`);
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
- /** Caps matching the scope ritual's fields, plus the document itself. The goal and out-of-scope text share the
73
- * task's own limit: a plan reproduces the filed contract exactly, so a lower cap here would refuse every plan for a
74
- * long goal. */
75
- export declare const PLAN_LIMITS: {
76
- readonly payload: number;
77
- readonly goal: 8000;
78
- readonly outOfScope: 8000;
79
- readonly touch: 200;
80
- readonly touches: 32;
81
- readonly document: number;
82
- readonly amendment: 1000;
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 = {