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.
- package/CHANGELOG.md +22 -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/coding-context.js +2 -2
- package/dist/coding-handoff.js +2 -2
- 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/guides.js +3 -0
- package/dist/knowledge-cli.d.ts +31 -0
- package/dist/knowledge-cli.js +35 -2
- package/dist/knowledge-ui.d.ts +1 -1
- package/dist/knowledge-ui.js +7 -0
- package/dist/operate.js +65 -13
- 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/project-delete.js +1 -1
- package/dist/project-knowledge.d.ts +28 -3
- package/dist/project-knowledge.js +64 -25
- package/dist/project-learning.d.ts +33 -0
- package/dist/project-learning.js +105 -15
- package/dist/project-skills.js +27 -21
- 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 +111 -65
- package/dist/store.d.ts +2 -0
- package/dist/store.js +4 -0
- package/dist/surface.js +9 -3
- 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
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
|
-
|
|
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
|
|
8355
|
-
if (!
|
|
8356
|
-
return { ok: false, message:
|
|
8357
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}
|
|
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
|
-
/**
|
|
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 = {
|
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 =
|
|
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") >
|
|
21
|
-
return { ok: false, problems: [{ reason: "plan-too-long", message: `plan is over ${
|
|
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 >
|
|
67
|
-
problems.push({ reason: "plan-approach-too-long", message:
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
184
|
-
|
|
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
|
-
|
|
187
|
-
|
|
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
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
222
|
-
|
|
272
|
+
let parsed;
|
|
273
|
+
try {
|
|
274
|
+
parsed = JSON.parse(raw);
|
|
223
275
|
}
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
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: {
|
|
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",
|
|
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);
|
package/dist/planner-source.js
CHANGED
|
@@ -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:
|
|
48
|
+
amendment: TEXT_LIMITS.planAmendment,
|
|
48
49
|
/** Earlier answers quoted into the brief. */
|
|
49
50
|
answers: 5,
|
|
50
51
|
};
|