toolroll 0.9.32 → 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.
Files changed (49) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/browser/workspace.css +1 -1
  3. package/dist/browser/workspace.js +17 -17
  4. package/dist/browser-crew.js +1 -0
  5. package/dist/browser-workspace.d.ts +2 -0
  6. package/dist/cli.d.ts +1 -1
  7. package/dist/cli.js +124 -63
  8. package/dist/coding-context.js +2 -2
  9. package/dist/coding-handoff.js +2 -2
  10. package/dist/contracts/contract-test.d.ts +45 -0
  11. package/dist/contracts/contract-test.js +69 -0
  12. package/dist/contracts/contract.d.ts +59 -0
  13. package/dist/contracts/contract.js +181 -0
  14. package/dist/contracts/plan.d.ts +58 -0
  15. package/dist/contracts/plan.js +67 -0
  16. package/dist/desktop-update.d.ts +1 -1
  17. package/dist/evidence.js +2 -2
  18. package/dist/guides.js +3 -0
  19. package/dist/knowledge-cli.d.ts +31 -0
  20. package/dist/knowledge-cli.js +35 -2
  21. package/dist/knowledge-ui.d.ts +1 -1
  22. package/dist/knowledge-ui.js +7 -0
  23. package/dist/operate.js +65 -13
  24. package/dist/plan.d.ts +12 -12
  25. package/dist/plan.js +129 -72
  26. package/dist/planner-source.js +2 -1
  27. package/dist/planner.js +55 -25
  28. package/dist/project-admission.d.ts +93 -0
  29. package/dist/project-admission.js +134 -0
  30. package/dist/project-delete-ui.js +5 -2
  31. package/dist/project-delete.js +1 -1
  32. package/dist/project-knowledge.d.ts +28 -3
  33. package/dist/project-knowledge.js +64 -25
  34. package/dist/project-learning.d.ts +33 -0
  35. package/dist/project-learning.js +105 -15
  36. package/dist/project-skills.js +27 -21
  37. package/dist/runner.d.ts +10 -0
  38. package/dist/runner.js +16 -0
  39. package/dist/scope.js +4 -3
  40. package/dist/serve.d.ts +4 -0
  41. package/dist/serve.js +111 -65
  42. package/dist/store.d.ts +2 -0
  43. package/dist/store.js +4 -0
  44. package/dist/surface.js +9 -3
  45. package/dist/text-limits.d.ts +17 -0
  46. package/dist/text-limits.js +16 -0
  47. package/dist/when-html.d.ts +2 -0
  48. package/dist/when-html.js +13 -0
  49. package/package.json +3 -2
package/dist/operate.js CHANGED
@@ -48,7 +48,7 @@ import { loadSlackCredentials } from "./slack-api.js";
48
48
  import { followSlack } from "./slack.js";
49
49
  import { validateScopeText } from "./task-text.js";
50
50
  import { runMemoryCommand } from "./memory-cli.js";
51
- import { runKnowledgeCommand } from "./knowledge-cli.js";
51
+ import { runKnowledgeApply, runKnowledgeCommand } from "./knowledge-cli.js";
52
52
  import { FLOWS_VALUE_FLAGS, runFlowsCommand } from "./flows-cli.js";
53
53
  import { runAssignmentCommand } from "./assignment-adapters.js";
54
54
  import { applyProjectProfile, runProjectCommand } from "./project-cli.js";
@@ -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";
@@ -1009,8 +1010,18 @@ async function dispatch(command, positional, flags, context) {
1009
1010
  if (positional[0] === "claim" && currentActor()?.lead === true && !flags.has("token-env") && !flags.has("token-file"))
1010
1011
  return leadClaimCommand(positional, flags, context);
1011
1012
  return runAssignmentCommand(positional, flags, context);
1012
- case "knowledge":
1013
- return runKnowledgeCommand(positional, flags, { ...context, now: context.clock() });
1013
+ case "knowledge": {
1014
+ if (positional[0] !== "apply")
1015
+ return runKnowledgeCommand(positional, flags, { ...context, now: context.clock() });
1016
+ if (positional.length !== 1)
1017
+ return fail(context.write, context.json, "knowledge apply", "usage", "knowledge apply takes no query.", EXIT.usage);
1018
+ // A person's password, never the lead's token: the lead may not carry knowledge forward.
1019
+ const acting = currentActor()?.lead === true ? null : await askCredentials(flags, context);
1020
+ const repo = text(flags, "repo");
1021
+ const lead = acting !== null && parseLeadToken(acting.token) !== null;
1022
+ const verified = acting === null || lead ? null : authenticateApprover(context.store, acting.name, acting.token, repo === undefined ? null : resolve(repo));
1023
+ return runKnowledgeApply(flags, { ...context, now: context.clock(), actor: verified !== null && verified.ok ? acting.name : null });
1024
+ }
1014
1025
  case "models": {
1015
1026
  const acting = await askCredentials(flags, context);
1016
1027
  const verified = acting === null ? null : authenticateApprover(context.store, acting.name, acting.token);
@@ -4924,6 +4935,7 @@ async function startConsole(options) {
4924
4935
  ...(options.repos === undefined ? {} : { repos: options.repos }),
4925
4936
  ...(options.projectRoots === undefined ? {} : { projectRoots: options.projectRoots }),
4926
4937
  ...(options.currentRepos === undefined ? {} : { currentRepos: options.currentRepos }),
4938
+ ...(options.admittedRepos === undefined ? {} : { admittedRepos: options.admittedRepos }),
4927
4939
  ...(options.publicUrl === undefined ? {} : { publicUrl: options.publicUrl }),
4928
4940
  ...(options.registryPath === undefined ? {} : { registryPath: options.registryPath }),
4929
4941
  ...(options.upConsole === undefined ? {} : { upConsole: options.upConsole }),
@@ -8350,12 +8362,15 @@ async function onboardCommand(flags, context) {
8350
8362
  return !("error" in registry) && registry.repos.includes(repo);
8351
8363
  },
8352
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 };
8353
8369
  const before = await loadProjectRegistry(registryPath);
8354
- const enrolled = await updateRepos(registryPath, repos => addRepos(repos, [repo]));
8355
- if (!enrolled.ok)
8356
- return { ok: false, message: enrolled.message };
8357
- store.upsertProject(repo, projectName(repo), clock());
8358
- 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) };
8359
8374
  },
8360
8375
  checkConnection: seams.checkConnection ?? (agent => checker(agent)),
8361
8376
  pullRequests: seams.pullRequests ?? (async (repo) => {
@@ -8485,6 +8500,9 @@ async function upCommand(flags, context) {
8485
8500
  }
8486
8501
  }
8487
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();
8488
8506
  if (explicitRepos.length > 0) {
8489
8507
  for (const input of explicitRepos) {
8490
8508
  const root = await proveRepo(input);
@@ -8493,12 +8511,15 @@ async function upCommand(flags, context) {
8493
8511
  }
8494
8512
  if (!repos.includes(root))
8495
8513
  repos.push(root);
8514
+ pinnedRepos.add(root);
8496
8515
  }
8497
8516
  }
8498
8517
  else if (context.inferProjectFromCwd !== false) {
8499
8518
  const cwdRepo = await proveRepo(process.cwd());
8500
8519
  if (cwdRepo !== null && !repos.includes(cwdRepo))
8501
8520
  repos.push(cwdRepo);
8521
+ if (cwdRepo !== null)
8522
+ pinnedRepos.add(cwdRepo);
8502
8523
  }
8503
8524
  if (repos.length === 0 && projectRoots.length === 0) {
8504
8525
  return fail(write, json, "up", "no-projects", "choose where your projects live once: `toolroll up --project-root <dir>`", EXIT.refused);
@@ -8650,6 +8671,7 @@ async function upCommand(flags, context) {
8650
8671
  poolRoot: pool,
8651
8672
  repos,
8652
8673
  currentRepos: () => [...activeRepos],
8674
+ admittedRepos: () => [...activeRepos],
8653
8675
  upConsole: true,
8654
8676
  attended: {
8655
8677
  runner: runnerName,
@@ -8742,6 +8764,8 @@ async function upCommand(flags, context) {
8742
8764
  // One record of project passes for the whole service: fair scheduling
8743
8765
  // across its projects (project-concurrency.ts).
8744
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();
8745
8769
  const launchRepo = async (repo, bind) => {
8746
8770
  if (loopResults.has(repo) || stopping)
8747
8771
  return;
@@ -8766,7 +8790,7 @@ async function upCommand(flags, context) {
8766
8790
  token: runnerToken,
8767
8791
  repo,
8768
8792
  progress: line => progress(`${prefix(repo)}${line}`),
8769
- isStopping: () => stopping,
8793
+ isStopping: () => stopping || releasedRepos.has(repo),
8770
8794
  onFollowController: controller => followControllers.push(controller),
8771
8795
  containmentLines,
8772
8796
  passes: projectPasses,
@@ -8779,6 +8803,19 @@ async function upCommand(flags, context) {
8779
8803
  }).then(result => ({ repo, result }), error => ({ repo, result: { ok: false, reason: "loop-failed", detail: describe(error), ticks: 0, built: 0, broke: 0 } }));
8780
8804
  loopResults.set(repo, loop);
8781
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
+ }
8782
8819
  // A loop that dies while its siblings live must not leave a partial
8783
8820
  // cockpit standing silently (finding 5).
8784
8821
  if (!result.ok && !stopping) {
@@ -8832,19 +8869,34 @@ async function upCommand(flags, context) {
8832
8869
  stop();
8833
8870
  break;
8834
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
+ }
8835
8882
  for (const candidate of loaded.repos) {
8836
8883
  if (stopping)
8837
8884
  break;
8838
8885
  const canonical = canonicalProject(candidate);
8839
- 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)))
8840
8888
  continue;
8841
8889
  const approvedAdditions = context.additionalProjectRepos?.() ?? [];
8842
8890
  const admittedCeiling = resolveCeiling([...dynamicCeiling.repos, ...approvedAdditions], dynamicCeiling.roots).ceiling;
8843
- 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;
8844
8896
  if (!allowed) {
8845
8897
  if (!rejected.has(candidate)) {
8846
8898
  rejected.add(candidate);
8847
- 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`);
8848
8900
  }
8849
8901
  continue;
8850
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 = {
package/dist/plan.js CHANGED
@@ -9,16 +9,17 @@ import { createHash } from "node:crypto";
9
9
  import { hasForbiddenControls } from "./decision.js";
10
10
  import { parseAcceptanceCriteria } from "./scope.js";
11
11
  import { TEXT_LIMITS } from "./text-limits.js";
12
+ import { planPayloadBody, readPlanPayload } from "./contracts/plan.js";
12
13
  const EXECUTION_PLAN_SECTIONS = ["approach", "milestones", "dependencies", "risks", "proof"];
13
14
  const EXECUTION_PLAN_LIST_CAP = 12;
14
- const EXECUTION_PLAN_ITEM_CAP = 600;
15
+ const EXECUTION_PLAN_ITEM_CAP = TEXT_LIMITS.planItem;
15
16
  /** Parse the planner's human-readable artifact without executing Markdown.
16
17
  * Older free-form plan artifacts remain renderable through the caller's
17
18
  * fallback, while every newly accepted planner handoff uses this shape. */
18
19
  export function parseExecutionPlanDocument(raw) {
19
20
  const problems = [];
20
- if (Buffer.byteLength(raw, "utf8") > PLAN_LIMITS.document) {
21
- return { ok: false, problems: [{ reason: "plan-too-long", message: `plan is over ${PLAN_LIMITS.document} bytes` }] };
21
+ if (Buffer.byteLength(raw, "utf8") > TEXT_LIMITS.planDocumentBytes) {
22
+ return { ok: false, problems: [{ reason: "plan-too-long", message: `plan is over ${TEXT_LIMITS.planDocumentBytes} bytes` }] };
22
23
  }
23
24
  const normalized = raw.replace(/\r\n?/g, "\n");
24
25
  if (hasForbiddenControls(normalized)) {
@@ -63,8 +64,8 @@ export function parseExecutionPlanDocument(raw) {
63
64
  const approach = (sections.get("approach") ?? []).join("\n").trim();
64
65
  if (approach === "")
65
66
  problems.push({ reason: "plan-empty-approach", message: "Approach must say how the work will be done" });
66
- if (approach.length > 2_000)
67
- problems.push({ reason: "plan-approach-too-long", message: "Approach is over 2000 characters" });
67
+ if (approach.length > TEXT_LIMITS.planApproach)
68
+ problems.push({ reason: "plan-approach-too-long", message: `Approach is over ${TEXT_LIMITS.planApproach} characters` });
68
69
  const list = (name) => {
69
70
  const items = [];
70
71
  for (const line of sections.get(name) ?? []) {
@@ -128,18 +129,6 @@ export function renderExecutionPlanDocument(document) {
128
129
  "",
129
130
  ].join("\n");
130
131
  }
131
- /** Caps matching the scope ritual's fields, plus the document itself. The goal and out-of-scope text share the
132
- * task's own limit: a plan reproduces the filed contract exactly, so a lower cap here would refuse every plan for a
133
- * long goal. */
134
- export const PLAN_LIMITS = {
135
- payload: 64 * 1024,
136
- goal: TEXT_LIMITS.goal,
137
- outOfScope: TEXT_LIMITS.goal,
138
- touch: 200,
139
- touches: 32,
140
- document: 16 * 1024,
141
- amendment: 1_000,
142
- };
143
132
  function refuse(reason, message) {
144
133
  return { ok: false, problems: [{ reason, message }] };
145
134
  }
@@ -152,6 +141,7 @@ function describe(value) {
152
141
  return `a ${value.length}-char string`;
153
142
  return `a ${Array.isArray(value) ? "array" : typeof value}`;
154
143
  }
144
+ /** A progress note or revision field, checked by hand (those payloads are not on the plan contract yet). */
155
145
  function prose(value, field, cap, required, problems) {
156
146
  if (value === undefined || value === null || value === "") {
157
147
  if (required)
@@ -172,75 +162,142 @@ function prose(value, field, cap, required, problems) {
172
162
  }
173
163
  return value;
174
164
  }
175
- export function parsePlan(raw) {
176
- if (Buffer.byteLength(raw, "utf8") > PLAN_LIMITS.payload) {
177
- return refuse("too-large", `the payload is over ${PLAN_LIMITS.payload} bytes`);
178
- }
179
- let parsed;
180
- try {
181
- parsed = JSON.parse(raw);
165
+ /**
166
+ * A contract issue as a plan problem: the path-named line is the message, and the reason keeps the codes the rest of
167
+ * the code relies on — `missing-goal`, `bad-touches`, `plan-too-long`, `amendment-too-long`, `acceptance-too-many` —
168
+ * so repair eligibility (document-only problems) and durable outcomes read the same as before the contract.
169
+ */
170
+ export function planProblemOf(issue) {
171
+ const at = issue.path;
172
+ const reason = (() => {
173
+ switch (issue.kind) {
174
+ case "required":
175
+ case "empty":
176
+ case "too-few":
177
+ return `missing-${at}`;
178
+ case "too-long":
179
+ return `${at}-too-long`;
180
+ case "too-many":
181
+ return `${at}-too-many`;
182
+ case "unknown-key":
183
+ return `${at}-unknown-key`;
184
+ case "newer-version":
185
+ return "newer-version";
186
+ default:
187
+ return `bad-${at}`;
188
+ }
189
+ })();
190
+ return { reason, message: issue.line };
191
+ }
192
+ /** scope.ts's rubric problem as a path-named line: `acceptance[0].id: is over 40 bytes`. */
193
+ function acceptanceLine(problem) {
194
+ const named = /^(acceptance(?:\[\d+\])?(?:\.[A-Za-z]+)?) (.*)$/.exec(problem.message);
195
+ if (named !== null)
196
+ return `${named[1]}: ${named[2]}`;
197
+ const at = /^(acceptance\[\d+\])-/.exec(problem.reason)?.[1] ?? "acceptance";
198
+ return `${at}: ${problem.message}`;
199
+ }
200
+ /** True when `problems` name `field` or anything under it. */
201
+ const touched = (problems, field) => problems.some(problem => problem.message.startsWith(`${field}:`) || problem.message.startsWith(`${field}[`) || problem.message.startsWith(`${field}.`));
202
+ /**
203
+ * The rules JSON Schema cannot state, in plain code with named errors: control characters, one-line touches, the
204
+ * document's byte cap and sections, the acceptance rubric's byte limits and duplicate ids (scope.ts's own parser),
205
+ * and the Proof naming every criterion. Fields the contract already refused are skipped, so every problem is
206
+ * reported once and all at once.
207
+ */
208
+ function planRuleProblems(body, contract) {
209
+ const problems = [];
210
+ const text = (field) => {
211
+ const value = body[field];
212
+ return typeof value === "string" && !touched(contract, field) ? value : null;
213
+ };
214
+ for (const field of ["goal", "outOfScope", "plan", "amendment"]) {
215
+ const value = text(field);
216
+ if (value !== null && hasForbiddenControls(value)) {
217
+ problems.push({ reason: `${field}-controls`, message: `${field}: carries control characters that could become terminal escapes` });
218
+ }
182
219
  }
183
- catch (error) {
184
- return refuse("not-json", `the payload is not JSON: ${error instanceof Error ? error.message : String(error)}`);
220
+ const touches = body["touches"];
221
+ if (Array.isArray(touches) && !touched(contract, "touches")) {
222
+ for (const [index, path] of touches.entries()) {
223
+ if (typeof path !== "string")
224
+ continue;
225
+ const field = `touches[${index}]`;
226
+ if (hasForbiddenControls(path))
227
+ problems.push({ reason: `${field}-controls`, message: `${field}: carries control characters that could become terminal escapes` });
228
+ else if (/[\n\t]/.test(path))
229
+ problems.push({ reason: `${field}-multiline`, message: `${field}: must be one line` });
230
+ }
185
231
  }
186
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
187
- return refuse("not-an-object", "the payload must be one JSON object");
232
+ let criteria = [];
233
+ let acceptanceOk = false;
234
+ if (!touched(contract, "acceptance")) {
235
+ const acceptance = parseAcceptanceCriteria(body["acceptance"]);
236
+ problems.push(...acceptance.problems.map(problem => ({ reason: problem.reason, message: acceptanceLine(problem) })));
237
+ criteria = acceptance.criteria;
238
+ acceptanceOk = acceptance.problems.length === 0;
188
239
  }
189
- const body = parsed;
190
- const problems = [];
191
- const goal = prose(body["goal"], "goal", PLAN_LIMITS.goal, true, problems);
192
- const outOfScope = prose(body["outOfScope"], "outOfScope", PLAN_LIMITS.outOfScope, false, problems);
193
- const document = prose(body["plan"], "plan", PLAN_LIMITS.document, true, problems);
194
- const amendment = prose(body["amendment"], "amendment", PLAN_LIMITS.amendment, false, problems);
195
- const touches = [];
196
- if (body["touches"] !== undefined && body["touches"] !== null) {
197
- if (!Array.isArray(body["touches"])) {
198
- problems.push({ reason: "bad-touches", message: `touches must be an array of paths (got ${describe(body["touches"])})` });
199
- }
200
- else if (body["touches"].length > PLAN_LIMITS.touches) {
201
- problems.push({ reason: "touches-too-many", message: `touches lists ${body["touches"].length} paths — cap is ${PLAN_LIMITS.touches}` });
240
+ const document = text("plan");
241
+ if (document !== null && document !== "") {
242
+ if (Buffer.byteLength(document, "utf8") > TEXT_LIMITS.planDocumentBytes) {
243
+ problems.push({ reason: "plan-too-long", message: `plan: over ${TEXT_LIMITS.planDocumentBytes.toLocaleString("en-US")} bytes` });
202
244
  }
203
- else {
204
- for (const [index, one] of body["touches"].entries()) {
205
- const path = prose(one, `touches[${index}]`, PLAN_LIMITS.touch, true, problems);
206
- if (path !== null) {
207
- if (/[\n\t]/.test(path)) {
208
- problems.push({ reason: `touches[${index}]-multiline`, message: `touches[${index}] must be one line` });
209
- }
210
- else {
211
- touches.push(path);
245
+ else if (!hasForbiddenControls(document)) {
246
+ const execution = parseExecutionPlanDocument(document);
247
+ if (!execution.ok) {
248
+ problems.push(...execution.problems.map(problem => ({ reason: problem.reason, message: `plan: ${problem.message}` })));
249
+ }
250
+ else if (acceptanceOk) {
251
+ const proof = execution.document.proof.join("\n");
252
+ for (const criterion of criteria) {
253
+ const mentioned = new RegExp(`(^|[^A-Za-z0-9_-])${criterion.id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([^A-Za-z0-9_-]|$)`).test(proof);
254
+ if (!mentioned) {
255
+ problems.push({ reason: `plan-proof-missing-${criterion.id}`, message: `plan: Proof must name acceptance criterion ${criterion.id}` });
212
256
  }
213
257
  }
214
258
  }
215
259
  }
216
260
  }
217
- const acceptanceParse = parseAcceptanceCriteria(body["acceptance"]);
218
- for (const problem of acceptanceParse.problems) {
219
- problems.push({ reason: problem.reason, message: problem.message });
261
+ return { problems, criteria };
262
+ }
263
+ /**
264
+ * The planner's handoff, read against the plan contract (src/contracts/plan.ts): fail closed, every problem at once,
265
+ * each one naming its path (`acceptance[0].evidence[0]: must be one of ...`). A plan saved before plans carried
266
+ * `version` reads as it always did.
267
+ */
268
+ export function parsePlan(raw) {
269
+ if (Buffer.byteLength(raw, "utf8") > TEXT_LIMITS.planPayloadBytes) {
270
+ return refuse("too-large", `payload: over ${TEXT_LIMITS.planPayloadBytes.toLocaleString("en-US")} bytes`);
220
271
  }
221
- if (acceptanceParse.problems.length === 0 && acceptanceParse.criteria.length === 0) {
222
- problems.push({ reason: "missing-acceptance", message: "acceptance is required — at least one signed criterion the build will be judged against" });
272
+ let parsed;
273
+ try {
274
+ parsed = JSON.parse(raw);
223
275
  }
224
- if (document !== null) {
225
- const execution = parseExecutionPlanDocument(document);
226
- if (!execution.ok) {
227
- problems.push(...execution.problems);
228
- }
229
- else if (acceptanceParse.problems.length === 0) {
230
- const proof = execution.document.proof.join("\n");
231
- for (const criterion of acceptanceParse.criteria) {
232
- const mentioned = new RegExp(`(^|[^A-Za-z0-9_-])${criterion.id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([^A-Za-z0-9_-]|$)`).test(proof);
233
- if (!mentioned) {
234
- problems.push({ reason: `plan-proof-missing-${criterion.id}`, message: `Proof must name acceptance criterion ${criterion.id}` });
235
- }
236
- }
237
- }
276
+ catch (error) {
277
+ return refuse("not-json", `the payload is not JSON: ${error instanceof Error ? error.message : String(error)}`);
238
278
  }
239
- if (problems.length > 0)
279
+ const body = planPayloadBody(parsed);
280
+ if (body === null)
281
+ return refuse("not-an-object", "payload: must be one JSON object");
282
+ const read = readPlanPayload(parsed);
283
+ const contract = read.ok ? [] : read.issues.map(planProblemOf);
284
+ if (contract.some(problem => problem.reason === "newer-version" || problem.reason === "bad-version"))
285
+ return { ok: false, problems: contract };
286
+ const rules = planRuleProblems(body, contract);
287
+ const problems = [...contract, ...rules.problems];
288
+ if (!read.ok || problems.length > 0)
240
289
  return { ok: false, problems };
290
+ const payload = read.value;
241
291
  return {
242
292
  ok: true,
243
- plan: { goal: goal, outOfScope, touches, acceptance: acceptanceParse.criteria, plan: document, amendment },
293
+ plan: {
294
+ goal: payload.goal,
295
+ outOfScope: payload.outOfScope === undefined || payload.outOfScope === null || payload.outOfScope === "" ? null : payload.outOfScope,
296
+ touches: payload.touches ?? [],
297
+ acceptance: rules.criteria,
298
+ plan: payload.plan,
299
+ amendment: payload.amendment === undefined || payload.amendment === null || payload.amendment === "" ? null : payload.amendment,
300
+ },
244
301
  };
245
302
  }
246
303
  const MILESTONE_STATES = ["pending", "current", "completed", "blocked"];
@@ -371,7 +428,7 @@ export function parsePlanRevisionProposal(raw) {
371
428
  const problems = [];
372
429
  const reason = prose(body["reason"], "reason", REVISION_LIMITS.reason, true, problems);
373
430
  const evidenceLink = prose(body["evidenceLink"], "evidenceLink", REVISION_LIMITS.evidenceLink, true, problems);
374
- const planText = prose(body["plan"], "plan", PLAN_LIMITS.document, true, problems);
431
+ const planText = prose(body["plan"], "plan", TEXT_LIMITS.planDocumentBytes, true, problems);
375
432
  let document = null;
376
433
  if (planText !== null) {
377
434
  const execution = parseExecutionPlanDocument(planText);
@@ -37,6 +37,7 @@
37
37
  */
38
38
  import { createHash } from "node:crypto";
39
39
  import { EVIDENCE_CAPS, readVerifiedArtifact } from "./evidence.js";
40
+ import { TEXT_LIMITS } from "./text-limits.js";
40
41
  export const PLANNER_SOURCE_VERSION = 1;
41
42
  export const PLANNER_SOURCE_LIMITS = {
42
43
  /** The whole recorded source before lossless prompt fencing. Equal to
@@ -44,7 +45,7 @@ export const PLANNER_SOURCE_LIMITS = {
44
45
  * truncated: what the record holds is exactly what the planner read. */
45
46
  bytes: EVIDENCE_CAPS["plan-contract"],
46
47
  /** A planner's amendment note: why the filed contract must change. */
47
- amendment: 1_000,
48
+ amendment: TEXT_LIMITS.planAmendment,
48
49
  /** Earlier answers quoted into the brief. */
49
50
  answers: 5,
50
51
  };