toolroll 0.9.58 → 0.9.59

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/dist/builder.js CHANGED
@@ -857,9 +857,11 @@ export async function build(store, request) {
857
857
  // still-blocked history) is left alone rather than having a
858
858
  // revision 1 invented underneath it.
859
859
  if (planDocument !== null && store.latestPlanRevision(taskRef) === null) {
860
+ // Revision 1 for a task planned once; past every earlier generation's revisions for one planned again (v119).
861
+ planRevisionNumber = store.nextPlanRevisionNumber(taskRef);
860
862
  planRevisionId = store.insertPlanRevision({
861
863
  taskRef,
862
- revision: 1,
864
+ revision: planRevisionNumber,
863
865
  artifact: planArtifact.id,
864
866
  parentHash: null,
865
867
  reason: "the plan the operator approved",
@@ -1803,7 +1805,7 @@ function settleRevisionProposal(captured, binding) {
1803
1805
  if (latest !== null && latest.status === "blocked") {
1804
1806
  return broke("the agent proposed a plan revision, but one is already awaiting your approval on this task");
1805
1807
  }
1806
- const revisionNumber = (latest?.revision ?? 0) + 1;
1808
+ const revisionNumber = store.nextPlanRevisionNumber(taskRef);
1807
1809
  // Stored re-serialized from the validated shape, never the agent's raw
1808
1810
  // bytes: what a later brief quotes and hash-verifies is exactly what this
1809
1811
  // parser admitted.
@@ -81,6 +81,7 @@ export declare function confirmedLink(store: Store, outcome: DoorOutcome, propos
81
81
  export declare function proposalPreview(store: Store, proposal: LeadProposal, repos: readonly string[], channel?: ChatChannelName | null): {
82
82
  text: string;
83
83
  buttons: boolean;
84
+ closed?: true;
84
85
  };
85
86
  /** The card after it resolves, from the recorded outcome — the same words every surface shows. */
86
87
  export declare function proposalOutcomeText(proposal: LeadProposal): string;
@@ -1,5 +1,6 @@
1
1
  /** Shared membership-chat identity, proposal previews and result links. Transport adapters never grant authority. */
2
2
  import { FLOW_HREF } from "./flow-engine.js";
3
+ import { closedQuestionWords } from "./decision.js";
3
4
  import { createHash } from "node:crypto";
4
5
  import { sharedActionPayload, sharedActionNeedsReview, sharedActionReviewPath, sharedActionAllowsChallenge, CHAT_ACTIONS, } from "./chat-actions.js";
5
6
  import { isDirectChatProvider, subscriptionCredentialKey } from "./converse.js";
@@ -366,6 +367,8 @@ export function proposalPreview(store, proposal, repos, channel = null) {
366
367
  case "answer": {
367
368
  const id = typeof payload["decision"] === "number" ? payload["decision"] : null;
368
369
  const decision = id === null ? null : store.getDecision(id);
370
+ if (decision?.supersededReason != null)
371
+ return { text: closedQuestionWords(decision.supersededReason), buttons: false, closed: true };
369
372
  const pick = t("option", 64);
370
373
  const irreversible = payload["reversible"] === false;
371
374
  const body = decision === null
@@ -600,6 +600,8 @@ export function applyChatAction(options, event, binding, repos) {
600
600
  const phase = action.phase;
601
601
  if (proposal.state !== "pending")
602
602
  content = { text: proposalOutcomeText(proposal), edit: event.ts };
603
+ else if (preview.closed)
604
+ content = { text: preview.text, edit: event.ts };
603
605
  else if (!preview.buttons ||
604
606
  preview.text.length > (options.maxProposal ?? 10_000))
605
607
  content = { ...content, text: "Review this action in Toolroll." };
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The mechanical difference between two sets of contract terms — a task's goal, exclusions, touches and acceptance
3
+ * rubric. Pure and import-light, so both the planner's source (planner-source.ts) and scope filing (scope.ts, which
4
+ * restarts planning when the terms truly change) read the same comparison.
5
+ */
6
+ import type { AcceptanceCriterion, EvidenceKind } from "./scope.js";
7
+ /** The four contract fields a planner may propose to amend. */
8
+ export type ContractTerms = {
9
+ goal: string;
10
+ outOfScope: string | null;
11
+ touches: string[];
12
+ acceptance: AcceptanceCriterion[];
13
+ };
14
+ export type ContractChange = {
15
+ field: "goal";
16
+ kind: "changed";
17
+ before: string;
18
+ after: string;
19
+ } | {
20
+ field: "outOfScope";
21
+ kind: "added" | "removed" | "changed";
22
+ before: string | null;
23
+ after: string | null;
24
+ } | {
25
+ field: "touches";
26
+ kind: "added" | "removed";
27
+ path: string;
28
+ } | {
29
+ field: "acceptance";
30
+ kind: "added" | "removed" | "changed";
31
+ id: string;
32
+ before: {
33
+ statement: string;
34
+ evidence: EvidenceKind[];
35
+ how: string | null;
36
+ } | null;
37
+ after: {
38
+ statement: string;
39
+ evidence: EvidenceKind[];
40
+ how: string | null;
41
+ } | null;
42
+ /** For `changed`: which parts moved. `how` is advisory and unsigned,
43
+ * but a planner rewriting it is still a change the operator sees. */
44
+ moved: ("statement" | "evidence" | "how")[];
45
+ };
46
+ /**
47
+ * Every addition, change, and removal between the filed contract and a
48
+ * proposed one. Whitespace at the ends and touch/evidence order do not
49
+ * count (the scope digest ignores them too); everything else does,
50
+ * including the advisory `how`, because the operator wrote it.
51
+ */
52
+ export declare function contractChangesOf(filed: ContractTerms, proposed: ContractTerms): ContractChange[];
@@ -0,0 +1,61 @@
1
+ const trimmed = (text) => {
2
+ if (text === null)
3
+ return null;
4
+ const t = text.trim();
5
+ return t === "" ? null : t;
6
+ };
7
+ /**
8
+ * Every addition, change, and removal between the filed contract and a
9
+ * proposed one. Whitespace at the ends and touch/evidence order do not
10
+ * count (the scope digest ignores them too); everything else does,
11
+ * including the advisory `how`, because the operator wrote it.
12
+ */
13
+ export function contractChangesOf(filed, proposed) {
14
+ const changes = [];
15
+ if (filed.goal.trim() !== proposed.goal.trim()) {
16
+ changes.push({ field: "goal", kind: "changed", before: filed.goal, after: proposed.goal });
17
+ }
18
+ const filedOut = trimmed(filed.outOfScope);
19
+ const proposedOut = trimmed(proposed.outOfScope);
20
+ if (filedOut !== proposedOut) {
21
+ changes.push({
22
+ field: "outOfScope",
23
+ kind: filedOut === null ? "added" : proposedOut === null ? "removed" : "changed",
24
+ before: filed.outOfScope,
25
+ after: proposed.outOfScope,
26
+ });
27
+ }
28
+ const filedTouches = new Set(filed.touches.map(one => one.trim()).filter(one => one !== ""));
29
+ const proposedTouches = new Set(proposed.touches.map(one => one.trim()).filter(one => one !== ""));
30
+ for (const path of filedTouches)
31
+ if (!proposedTouches.has(path))
32
+ changes.push({ field: "touches", kind: "removed", path });
33
+ for (const path of proposedTouches)
34
+ if (!filedTouches.has(path))
35
+ changes.push({ field: "touches", kind: "added", path });
36
+ const criterionOf = (one) => ({ statement: one.statement, evidence: [...one.evidence].sort(), how: one.how });
37
+ const filedById = new Map(filed.acceptance.map(one => [one.id, one]));
38
+ const proposedById = new Map(proposed.acceptance.map(one => [one.id, one]));
39
+ for (const [id, before] of filedById) {
40
+ const after = proposedById.get(id);
41
+ if (after === undefined) {
42
+ changes.push({ field: "acceptance", kind: "removed", id, before: criterionOf(before), after: null, moved: [] });
43
+ continue;
44
+ }
45
+ const moved = [];
46
+ if (before.statement.trim() !== after.statement.trim())
47
+ moved.push("statement");
48
+ if ([...before.evidence].sort().join(",") !== [...after.evidence].sort().join(","))
49
+ moved.push("evidence");
50
+ if (trimmed(before.how) !== trimmed(after.how))
51
+ moved.push("how");
52
+ if (moved.length > 0) {
53
+ changes.push({ field: "acceptance", kind: "changed", id, before: criterionOf(before), after: criterionOf(after), moved });
54
+ }
55
+ }
56
+ for (const [id, after] of proposedById) {
57
+ if (!filedById.has(id))
58
+ changes.push({ field: "acceptance", kind: "added", id, before: null, after: criterionOf(after), moved: [] });
59
+ }
60
+ return changes;
61
+ }
@@ -43,6 +43,10 @@ export declare const LIMITS: {
43
43
  };
44
44
  /** Bytes an operator's note may occupy — the UTF-8 backstop under LIMITS.note. */
45
45
  export declare const NOTE_BYTE_CAP: 16000;
46
+ /** Why a question closed without an answer (v119), in plain words. */
47
+ export declare function closedQuestionReason(reason: "scope-changed" | "cancelled"): string;
48
+ /** The same, said once on one line wherever its answer would show. */
49
+ export declare function closedQuestionWords(reason: "scope-changed" | "cancelled"): string;
46
50
  /**
47
51
  * The one validator every note passes — before a draft persists AND inside
48
52
  * the answer CAS, which stays the final gate. Trims, then refuses empty,
package/dist/decision.js CHANGED
@@ -25,6 +25,14 @@ export const LIMITS = {
25
25
  };
26
26
  /** Bytes an operator's note may occupy — the UTF-8 backstop under LIMITS.note. */
27
27
  export const NOTE_BYTE_CAP = TEXT_LIMITS.noteBytes;
28
+ /** Why a question closed without an answer (v119), in plain words. */
29
+ export function closedQuestionReason(reason) {
30
+ return reason === "scope-changed" ? "The scope changed, so planning started again." : "The task was cancelled.";
31
+ }
32
+ /** The same, said once on one line wherever its answer would show. */
33
+ export function closedQuestionWords(reason) {
34
+ return `Closed without an answer. ${closedQuestionReason(reason)}`;
35
+ }
28
36
  /**
29
37
  * Unicode that reorders or breaks lines invisibly: bidi controls and the
30
38
  * line/paragraph separators. A note carrying these can spoof what a
@@ -0,0 +1,8 @@
1
+ /** Separate from orders.db: no live writer is reserved while deployment drains or backs up.
2
+ * SQLite owns the OS lock until close/process death, including SIGKILL. Never unlink this file:
3
+ * replacing its inode would let two contenders hold different locks for the same journal. */
4
+ export type DeploymentLock = {
5
+ assertHeld(journalFile: string): void;
6
+ release(): void;
7
+ };
8
+ export declare function acquireDeploymentLock(journalFile: string, waitMs?: number): DeploymentLock;
@@ -0,0 +1,45 @@
1
+ import { createRequire } from "node:module";
2
+ import { closeSync, constants, lstatSync, openSync, realpathSync } from "node:fs";
3
+ import { dirname, join, basename } from "node:path";
4
+ const canonical = (file) => join(realpathSync(dirname(file)), basename(file));
5
+ export function acquireDeploymentLock(journalFile, waitMs = 5000) {
6
+ if (!Number.isInteger(waitMs) || waitMs < 0 || waitMs > 5000)
7
+ throw Error("Invalid deployment lock wait.");
8
+ const journal = canonical(journalFile), file = `${journal}.lock.sqlite`;
9
+ // Only create a missing file. On POSIX, closing ANY independently opened descriptor
10
+ // for an existing inode can drop this process's fcntl lock, even another connection's.
11
+ // Let SQLite manage all descriptors for an existing coordination file.
12
+ try {
13
+ const fd = openSync(file, constants.O_CREAT | constants.O_EXCL | constants.O_RDWR | constants.O_NOFOLLOW, 0o600);
14
+ closeSync(fd);
15
+ }
16
+ catch (error) {
17
+ if (error.code !== "EEXIST")
18
+ throw error;
19
+ }
20
+ if (!lstatSync(file).isFile())
21
+ throw Error(`Unexpected deployment lock: ${file}`);
22
+ // Lazy, as elsewhere in the CLI: merely importing the help must not load SQLite.
23
+ const { DatabaseSync } = createRequire(import.meta.url)("node:sqlite");
24
+ const db = new DatabaseSync(file);
25
+ try {
26
+ db.exec(`PRAGMA busy_timeout=${waitMs}; BEGIN EXCLUSIVE`);
27
+ }
28
+ catch (cause) {
29
+ db.close();
30
+ throw new Error(`Deployment is busy or its lock is unavailable: ${journal}. Let it finish, then retry.`, { cause });
31
+ }
32
+ let held = true;
33
+ return {
34
+ assertHeld(expected) {
35
+ if (!held || canonical(expected) !== journal)
36
+ throw Error("The deployment journal lock is not held.");
37
+ },
38
+ release() {
39
+ if (!held)
40
+ return;
41
+ db.close();
42
+ held = false;
43
+ },
44
+ };
45
+ }
package/dist/dispatch.js CHANGED
@@ -319,7 +319,7 @@ export function diagnoseTaskDispatch(store, taskId, now, readableRepos = null) {
319
319
  // this is where the task page says so.
320
320
  const sourceProblem = plannerSourceProblemOf(store, taskId);
321
321
  if (sourceProblem !== null)
322
- return answer("planner-source", "waiting", "The filed request cannot be planned as filed", sourceProblem, { action: "write-scope", role });
322
+ return answer("planner-source", "waiting", sourceProblem.title, sourceProblem.detail, { action: "write-scope", role });
323
323
  return answer("planning-ready", "retrying", "Planner ready", "An eligible worker can draft the scope on the next pass.", { role });
324
324
  }
325
325
  if (role === "scout")
package/dist/lead-cli.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { configureLeadFollow, leadFollowStatus } from './lead-follow.js';
2
+ import { closedQuestionWords } from "./decision.js";
2
3
  /**
3
4
  * `toolroll chat` (mate arc §6): the same thread the console shows,
4
5
  * driven from a terminal. The password is typed once — it mints the lead
@@ -45,6 +46,8 @@ export function answerContextLines(store, payload) {
45
46
  const decision = id === null ? null : store.getDecision(id);
46
47
  if (decision === null)
47
48
  return [" (the decision is gone)"];
49
+ if (decision.supersededReason !== null)
50
+ return [` ${decision.question}`, ` ${closedQuestionWords(decision.supersededReason)}`];
48
51
  const pick = typeof payload["option"] === "string" ? payload["option"] : "";
49
52
  return [
50
53
  ` ${decision.question}${decision.state !== "open" ? ` [${decision.state}]` : ""}`,
@@ -14,6 +14,7 @@ import { executeSharedAction, sharedActionPayload, sharedActionAllowsChallenge,
14
14
  * scope was rewritten, the decision answered) is a refusal, never a
15
15
  * silent re-read.
16
16
  */
17
+ import { closedQuestionWords } from "./decision.js";
17
18
  import { isCheckLevel, setTaskCheckLevel } from "./check-levels.js";
18
19
  import { leadTurnKeepsProposals, verifiedAuthor } from "./store.js";
19
20
  import { isVerifiedApprover, reproveApprover } from "./principal.js";
@@ -317,6 +318,8 @@ function executeProposal(store, actor, kind, payload, now, options) {
317
318
  return refuse("stale", "that option no longer exists on the decision");
318
319
  // The proposal contract is "open": a decision past its deadline — swept
319
320
  // or not — is not answered through a card (v3 review, finding 7).
321
+ if (decision.supersededReason !== null)
322
+ return refuse("stale", closedQuestionWords(decision.supersededReason));
320
323
  if (decision.state === "answered")
321
324
  return refuse("already-answered", "already answered — somebody got there first");
322
325
  if (decision.state !== "open" || (decision.deadline !== null && Date.parse(decision.deadline) <= now.getTime())) {
@@ -284,12 +284,13 @@ export function decisionOver(store, repos, decisionId, now) {
284
284
  return null;
285
285
  // "Open" is derived, not stored (v3 review, finding 7): a decision past
286
286
  // its deadline is expired here whether or not the sweep has run.
287
- const state = decision.state === "open" && decision.deadline !== null && Date.parse(decision.deadline) <= now.getTime() ? "expired" : decision.state;
287
+ const state = decision.supersededReason !== null ? "closed" : decision.state === "open" && decision.deadline !== null && Date.parse(decision.deadline) <= now.getTime() ? "expired" : decision.state;
288
288
  return {
289
289
  repoIndex,
290
290
  decision: decision.id,
291
291
  task: ref.externalId,
292
292
  state,
293
+ ...(decision.supersededReason === null ? {} : { closedBecause: decision.supersededReason }),
293
294
  question: decision.question,
294
295
  options: decision.options.map(one => ({ id: one.id, label: one.label, reversible: one.reversible, consequence: one.consequence })),
295
296
  ageHours: Math.max(0, Math.round((now.getTime() - Date.parse(decision.createdAt)) / 3_600_000)),
package/dist/needs-you.js CHANGED
@@ -20,7 +20,9 @@ export const NEEDS = {
20
20
  hold: { sentence: () => "A hold is keeping this task from starting. Review it to release it.", action: { code: "inspect-hold", label: "Review hold" } },
21
21
  "choose-project": { sentence: () => "Choose a project so a builder can start.", action: { code: "place-task", label: "Choose a project" } },
22
22
  "define-task": { sentence: () => "Say what the task should do so it can be planned.", action: { code: "write-scope", label: "Define the task" } },
23
- "fix-request": { sentence: () => "The request can't be planned as written. Edit it so the planner can start.", action: { code: "write-scope", label: "Edit the request" } },
23
+ "fix-request": { sentence: () => "The request can't be planned as written. Edit it so the planner can start.", action: { code: "write-scope", label: "Edit the request" },
24
+ // Its own size and limit say exactly what to shorten.
25
+ useReason: reason => /the planner can carry at most/.test(reason) },
24
26
  "choose-agent": { sentence: () => "Choose an agent to build this.", action: { code: "select-agent", label: "Choose an agent" } },
25
27
  "fix-dependency": { sentence: () => "A task this one waits on didn't finish. Fix or remove it so this one can start.", action: { code: "repair-dependency", label: "Fix the required task" },
26
28
  useReason: reason => /didn't finish|did not finish|before it finished/i.test(reason), withReason: reason => `${reason.replace(/[.\s]*$/, ".")} Fix or remove it so this one can start.` },
package/dist/operate.js CHANGED
@@ -106,7 +106,7 @@ import { spawn as spawnChild } from "node:child_process";
106
106
  // Every envelope is checked against its command's schema (logged, never refused), then serialized unchanged.
107
107
  import { checkedEnvelopeJson as envelopeJson } from "./contracts/cli.js";
108
108
  import { auditCommand } from "./audit-cli.js";
109
- import { hasDisguisedText, hasForbiddenControls, validateNote } from "./decision.js";
109
+ import { closedQuestionWords, hasDisguisedText, hasForbiddenControls, validateNote } from "./decision.js";
110
110
  import { readVerifiedArtifact, readVerifiedReport, storeEvidence } from "./evidence.js";
111
111
  import { contractChangesOf, decodePlanContractRecord, describeContractChanges, encodePlannerSource, plannerSourceOf } from "./planner-source.js";
112
112
  /**
@@ -3721,7 +3721,7 @@ async function decideCommand(positional, flags, context) {
3721
3721
  write(envelopeJson({ ok: true, command: "decide", decision, taskId, evidence }));
3722
3722
  return EXIT.ok;
3723
3723
  }
3724
- write(`${taskId} — ${decision.state.toUpperCase()}${decision.deadline === null ? "" : ` · deadline ${decision.deadline}`}`);
3724
+ write(`${taskId} — ${decision.supersededReason !== null ? "CLOSED" : decision.state.toUpperCase()}${decision.deadline === null ? "" : ` · deadline ${decision.deadline}`}`);
3725
3725
  write("");
3726
3726
  write(` ${decision.recap}`);
3727
3727
  write("");
@@ -3739,7 +3739,9 @@ async function decideCommand(positional, flags, context) {
3739
3739
  }
3740
3740
  if (decision.state === "answered") {
3741
3741
  write("");
3742
- write(` answered: ${decision.choice} by ${decision.answeredBy} at ${decision.answeredAt}${decision.note === null ? "" : ` — ${decision.note}`}`);
3742
+ write(decision.supersededReason !== null
3743
+ ? ` ${closedQuestionWords(decision.supersededReason)} (${decision.answeredAt})`
3744
+ : ` answered: ${decision.choice} by ${decision.answeredBy} at ${decision.answeredAt}${decision.note === null ? "" : ` — ${decision.note}`}`);
3743
3745
  }
3744
3746
  if (evidence.length > 0) {
3745
3747
  write("");
@@ -3747,8 +3749,10 @@ async function decideCommand(positional, flags, context) {
3747
3749
  write(` evidence ${artifact.kind.padEnd(14)} ${artifact.key}${artifact.truncated ? " (truncated)" : ""}`);
3748
3750
  }
3749
3751
  }
3750
- write("");
3751
- write(` → toolroll decide ${id} --choose <option> --as <you> --token <t>`);
3752
+ if (decision.state !== "answered") {
3753
+ write("");
3754
+ write(` → toolroll decide ${id} --choose <option> --as <you> --token <t>`);
3755
+ }
3752
3756
  return EXIT.ok;
3753
3757
  }
3754
3758
  const acting = await askCredentials(flags, context);
@@ -3767,9 +3771,11 @@ async function decideCommand(positional, flags, context) {
3767
3771
  ? `"${choice}" is not one of this decision's options — \`toolroll decide ${id}\` shows them`
3768
3772
  : answered.reason === "already-answered"
3769
3773
  ? `decision ${id} was already answered differently — "decided" is not negotiable; park a new task if the answer must change`
3770
- : answered.reason === "bad-note"
3771
- ? "the note is too long or carries control characters"
3772
- : `no decision ${id}`;
3774
+ : answered.reason === "closed"
3775
+ ? `decision ${id}: ${closedQuestionWords(store.getDecision(id)?.supersededReason ?? "cancelled")} Nothing to answer.`
3776
+ : answered.reason === "bad-note"
3777
+ ? "the note is too long or carries control characters"
3778
+ : `no decision ${id}`;
3773
3779
  return fail(write, json, "decide", answered.reason, why, EXIT.refused);
3774
3780
  }
3775
3781
  return succeed(write, json, "decide", { decision: answered.decision, duplicate: answered.duplicate === true }, () => answered.duplicate === true
@@ -9707,6 +9713,7 @@ async function scopeTask(positional, flags, context) {
9707
9713
  // the mode seal record as a single operation —
9708
9714
  // a replayed key returns the FIRST answer whole instead of re-sealing
9709
9715
  // whatever scope happens to be current.
9716
+ const generationBefore = store.lookupRef(id)?.planningGeneration ?? 0;
9710
9717
  const filed = store.replay(mutationFrom(flags, now), "task-scope-filed", () => store.transact(() => {
9711
9718
  const coverage = actor !== null
9712
9719
  ? modeFilingCoverage(store, store.refFor(BUILT_IN, id).repo, actor, now)
@@ -9763,8 +9770,12 @@ async function scopeTask(positional, flags, context) {
9763
9770
  ...describeScope(scope),
9764
9771
  ]);
9765
9772
  }
9766
- return succeed(write, json, "task scope", { scope }, () => [
9767
- `Scope written for ${id}. Nothing will build it until somebody approves it.`,
9773
+ // A changed scope on a planning task plans again (v119): say so once, instead of leaving the old plan implied.
9774
+ const replanned = (store.lookupRef(id)?.planningGeneration ?? 0) > generationBefore;
9775
+ return succeed(write, json, "task scope", { scope, ...(replanned ? { planningRestarted: true } : {}) }, () => [
9776
+ replanned
9777
+ ? `Scope written for ${id}. The scope changed, so planning starts again; nothing builds until somebody approves the new plan.`
9778
+ : `Scope written for ${id}. Nothing will build it until somebody approves it.`,
9768
9779
  ...describeScope(scope),
9769
9780
  "",
9770
9781
  ` toolroll task approve ${id} --yes`,
@@ -7,11 +7,11 @@
7
7
  import type { ServerResponse } from "node:http";
8
8
  /** The one replacement every machine-credential refusal names. */
9
9
  export declare const TOKEN_REPLACEMENT = "Create an API token with `toolroll tokens create`";
10
- /** The first release that refuses a password on a request: one release after this one (0.9.58) deprecated it. */
11
- export declare const PASSWORD_BEARER_REFUSED_FROM = "0.9.59";
12
- export declare const PASSWORD_BEARER_WARNING = "Signing in with a password on each request is deprecated and stops working in Toolroll 0.9.59. Create an API token with `toolroll tokens create` and send it as the bearer instead.";
10
+ /** The first release that refuses a password on a request; 0.9.58 deprecated it with a warning. */
11
+ export declare const PASSWORD_BEARER_REFUSED_FROM = "0.10.0";
12
+ export declare const PASSWORD_BEARER_WARNING = "Signing in with a password on each request is deprecated and stops working in Toolroll 0.10.0. Create an API token with `toolroll tokens create` and send it as the bearer instead.";
13
13
  /** What the CLI says on stderr when a saved connection signs in with a password instead of an API token. */
14
- export declare const PASSWORD_PROFILE_WARNING = "Warning: this connection signs in with your password on each request, which is deprecated and stops working in Toolroll 0.9.59. Create an API token with `toolroll tokens create`, then connect again with --token-stdin.";
14
+ export declare const PASSWORD_PROFILE_WARNING = "Warning: this connection signs in with your password on each request, which is deprecated and stops working in Toolroll 0.10.0. Create an API token with `toolroll tokens create`, then connect again with --token-stdin.";
15
15
  export declare const PASSWORD_BEARER_REFUSED = "Toolroll no longer accepts a password on a request. Create an API token with `toolroll tokens create` and send it as the bearer instead.";
16
16
  /** Whether an Authorization header is a password bearer: a name and a secret, never an API token. */
17
17
  export declare function isPasswordBearer(authorization: string | undefined): boolean;
@@ -1,8 +1,8 @@
1
1
  import { PACKAGE_VERSION } from "./version.js";
2
2
  /** The one replacement every machine-credential refusal names. */
3
3
  export const TOKEN_REPLACEMENT = "Create an API token with `toolroll tokens create`";
4
- /** The first release that refuses a password on a request: one release after this one (0.9.58) deprecated it. */
5
- export const PASSWORD_BEARER_REFUSED_FROM = "0.9.59";
4
+ /** The first release that refuses a password on a request; 0.9.58 deprecated it with a warning. */
5
+ export const PASSWORD_BEARER_REFUSED_FROM = "0.10.0";
6
6
  export const PASSWORD_BEARER_WARNING = `Signing in with a password on each request is deprecated and stops working in Toolroll ${PASSWORD_BEARER_REFUSED_FROM}. ${TOKEN_REPLACEMENT} and send it as the bearer instead.`;
7
7
  /** What the CLI says on stderr when a saved connection signs in with a password instead of an API token. */
8
8
  export const PASSWORD_PROFILE_WARNING = `Warning: this connection signs in with your password on each request, which is deprecated and stops working in Toolroll ${PASSWORD_BEARER_REFUSED_FROM}. ${TOKEN_REPLACEMENT}, then connect again with --token-stdin.`;
@@ -36,9 +36,10 @@
36
36
  * transactions of its own: the fenced finalizers in claim.ts seal.
37
37
  */
38
38
  import type { Store } from "./store.js";
39
- import type { AcceptanceCriterion, EvidenceKind, Scope } from "./scope.js";
39
+ import type { Scope } from "./scope.js";
40
40
  import type { QualityMode } from "./quality.js";
41
41
  import type { RiskLevel } from "./phase-routing.js";
42
+ import { type ContractChange, type ContractTerms } from "./contract-changes.js";
42
43
  export declare const PLANNER_SOURCE_VERSION = 1;
43
44
  export declare const PLANNER_SOURCE_LIMITS: {
44
45
  /** The whole recorded source before lossless prompt fencing. Equal to
@@ -49,13 +50,9 @@ export declare const PLANNER_SOURCE_LIMITS: {
49
50
  readonly amendment: 1000;
50
51
  /** Earlier answers quoted into the brief. */
51
52
  readonly answers: 5;
52
- };
53
- /** The four contract fields a planner may propose to amend. */
54
- export type ContractTerms = {
55
- goal: string;
56
- outOfScope: string | null;
57
- touches: string[];
58
- acceptance: AcceptanceCriterion[];
53
+ /** Terms that exceed the entire handoff cap cannot fit regardless of the plan's size. Do not reserve a maximum-
54
+ * sized plan here: a shorter document can carry larger valid terms. The final payload still enforces its byte cap. */
55
+ readonly handoffTerms: number;
59
56
  };
60
57
  export type PlannerContractScope = ContractTerms & {
61
58
  /** The filed row's digest — the exact bytes the terms carry today. */
@@ -154,7 +151,7 @@ export type PlannerSourceResult = {
154
151
  bytes: number;
155
152
  } | {
156
153
  ok: false;
157
- reason: "no-task" | "revision-brief" | "oversized";
154
+ reason: "no-task" | "revision-brief" | "oversized" | "too-long";
158
155
  message: string;
159
156
  };
160
157
  /**
@@ -168,55 +165,28 @@ export declare function plannerSourceOf(store: Store, root: string, taskId: stri
168
165
  /**
169
166
  * The dispatch diagnosis's read of the same gate, from the store alone:
170
167
  * why the next pass would refuse to plan this task before spending — the
171
- * revision brief unreadable, or the filed request over the byte cap. The
172
- * size is a floor (the brief's stored bytes, before JSON quoting), so a
173
- * "yes" here is certain and the dispatch refusal itself stays the exact
174
- * word. Null when nothing here stands in the way.
168
+ * revision brief unreadable, the filed request over the byte cap, or terms
169
+ * the planner's handoff cannot carry. The size is a floor (the brief's
170
+ * stored bytes, before JSON quoting), so a "yes" here is certain and the
171
+ * dispatch refusal itself stays the exact word. Null when nothing here
172
+ * stands in the way.
173
+ */
174
+ export declare function plannerSourceProblemOf(store: Store, taskId: string): {
175
+ title: string;
176
+ detail: string;
177
+ } | null;
178
+ /**
179
+ * Whether the planner can hand the filed terms back (contracts/plan.ts): it must reproduce the goal, exclusions,
180
+ * touches and criteria in its handoff, so terms its handoff cannot carry would end every attempt as a malformed plan.
181
+ * This names the problem before any workspace, run or provider spend instead: the actual size, the limit, and what to
182
+ * shorten. Null when the terms fit.
175
183
  */
176
- export declare function plannerSourceProblemOf(store: Store, taskId: string): string | null;
184
+ export declare function plannerHandoffProblemOf(terms: ContractTerms): string | null;
177
185
  /** The exact bytes recorded as evidence; prompt fencing preserves the JSON value. */
178
186
  export declare function encodePlannerSource(source: PlannerSource): Buffer;
179
187
  /** Read a recorded source back; null when the bytes are not one. */
180
188
  export declare function decodePlannerSource(content: Buffer): PlannerSource | null;
181
- export type ContractChange = {
182
- field: "goal";
183
- kind: "changed";
184
- before: string;
185
- after: string;
186
- } | {
187
- field: "outOfScope";
188
- kind: "added" | "removed" | "changed";
189
- before: string | null;
190
- after: string | null;
191
- } | {
192
- field: "touches";
193
- kind: "added" | "removed";
194
- path: string;
195
- } | {
196
- field: "acceptance";
197
- kind: "added" | "removed" | "changed";
198
- id: string;
199
- before: {
200
- statement: string;
201
- evidence: EvidenceKind[];
202
- how: string | null;
203
- } | null;
204
- after: {
205
- statement: string;
206
- evidence: EvidenceKind[];
207
- how: string | null;
208
- } | null;
209
- /** For `changed`: which parts moved. `how` is advisory and unsigned,
210
- * but a planner rewriting it is still a change the operator sees. */
211
- moved: ("statement" | "evidence" | "how")[];
212
- };
213
- /**
214
- * Every addition, change, and removal between the filed contract and a
215
- * proposed one. Whitespace at the ends and touch/evidence order do not
216
- * count (the scope digest ignores them too); everything else does,
217
- * including the advisory `how`, because the operator wrote it.
218
- */
219
- export declare function contractChangesOf(filed: ContractTerms, proposed: ContractTerms): ContractChange[];
189
+ export { contractChangesOf, type ContractChange, type ContractTerms } from "./contract-changes.js";
220
190
  /** One line per change, for terminals, notifications, and validation messages. */
221
191
  export declare function describeContractChanges(changes: readonly ContractChange[]): string[];
222
192
  /**