toolroll 0.9.58 → 0.9.60

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 (86) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +8 -6
  3. package/dist/action-ledger.js +10 -1
  4. package/dist/assignment-adapters.d.ts +1 -1
  5. package/dist/assignment-adapters.js +1 -1
  6. package/dist/assignment-delivery.d.ts +16 -0
  7. package/dist/assignment-delivery.js +54 -28
  8. package/dist/assignment-status.d.ts +13 -4
  9. package/dist/assignment-status.js +70 -39
  10. package/dist/assignment.d.ts +6 -2
  11. package/dist/assignment.js +13 -8
  12. package/dist/bin.js +11 -2
  13. package/dist/brief-fence.d.ts +9 -0
  14. package/dist/brief-fence.js +22 -0
  15. package/dist/browser/workspace.css +2 -2
  16. package/dist/browser/workspace.js +19 -19
  17. package/dist/builder.js +78 -27
  18. package/dist/chat-channel.d.ts +1 -0
  19. package/dist/chat-channel.js +4 -1
  20. package/dist/chat-delivery.js +2 -0
  21. package/dist/cli-fast.d.ts +8 -0
  22. package/dist/cli-fast.js +18 -0
  23. package/dist/cli-help.d.ts +5 -0
  24. package/dist/cli-help.js +91 -0
  25. package/dist/cli.d.ts +6 -5
  26. package/dist/cli.js +17 -96
  27. package/dist/contract-changes.d.ts +52 -0
  28. package/dist/contract-changes.js +61 -0
  29. package/dist/decision.d.ts +4 -0
  30. package/dist/decision.js +8 -0
  31. package/dist/deploy-lock.d.ts +8 -0
  32. package/dist/deploy-lock.js +45 -0
  33. package/dist/design/tokens.css +234 -0
  34. package/dist/discord.js +2 -1
  35. package/dist/dispatch.js +1 -1
  36. package/dist/evidence.d.ts +22 -0
  37. package/dist/evidence.js +76 -17
  38. package/dist/flow-actions.js +2 -1
  39. package/dist/guides.js +14 -11
  40. package/dist/lead-cli.js +4 -1
  41. package/dist/lead-contract.js +1 -1
  42. package/dist/lead-doors.js +8 -2
  43. package/dist/lead-follow.d.ts +2 -0
  44. package/dist/lead-follow.js +138 -88
  45. package/dist/lead-tools.js +11 -3
  46. package/dist/mailbox.js +3 -2
  47. package/dist/mcp-core.js +1 -1
  48. package/dist/needs-you.js +3 -1
  49. package/dist/operate.js +38 -17
  50. package/dist/password-bearer.d.ts +4 -4
  51. package/dist/password-bearer.js +2 -2
  52. package/dist/planner-source.d.ts +23 -53
  53. package/dist/planner-source.js +43 -68
  54. package/dist/provider.d.ts +8 -0
  55. package/dist/provider.js +12 -1
  56. package/dist/release-gate.d.ts +2 -3
  57. package/dist/release-gate.js +44 -16
  58. package/dist/scope.js +14 -0
  59. package/dist/serve.js +3 -2
  60. package/dist/server/chrome.d.ts +6 -3
  61. package/dist/server/chrome.js +8 -4
  62. package/dist/server/console.css +9 -64
  63. package/dist/server/render-chat.js +12 -2
  64. package/dist/server/render-tasks.d.ts +2 -0
  65. package/dist/server/render-tasks.js +30 -22
  66. package/dist/server/task-views.js +1 -0
  67. package/dist/server/tasks.js +3 -1
  68. package/dist/slack-socket.d.ts +12 -0
  69. package/dist/slack-socket.js +29 -0
  70. package/dist/slack.d.ts +0 -7
  71. package/dist/slack.js +2 -24
  72. package/dist/steer-mailbox.d.ts +39 -0
  73. package/dist/steer-mailbox.js +207 -0
  74. package/dist/steer-status.d.ts +19 -0
  75. package/dist/steer-status.js +30 -0
  76. package/dist/store.d.ts +85 -8
  77. package/dist/store.js +203 -15
  78. package/dist/surface.js +2 -2
  79. package/dist/sweep-gate.d.ts +10 -0
  80. package/dist/sweep-gate.js +18 -0
  81. package/dist/task-status.js +3 -1
  82. package/dist/team-updates.js +3 -3
  83. package/dist/telegram-lead.js +6 -0
  84. package/dist/telegram.js +9 -7
  85. package/dist/toolroll-update.js +20 -1
  86. package/package.json +21 -21
package/dist/operate.js CHANGED
@@ -10,6 +10,8 @@ import { runBatchChecks } from "./batch-checks.js";
10
10
  import { BATCH_HINT, BATCH_WINDOW_MS, projectBatchChecks, setProjectBatchChecks } from "./batch-policy.js";
11
11
  import { buildReviewPass } from "./build-review.js";
12
12
  import { buildReviewLines, buildReviewOf } from "./review-switch.js";
13
+ import { steerDestinationWords, steerStandingOf, steerStandingWords } from "./steer-status.js";
14
+ import { utcMinute } from "./when-html.js";
13
15
  import { parseProtectedPaths } from "./approval-policy.js";
14
16
  import { rulesSummary } from "./approval-rules-ui.js";
15
17
  import { evidencePack, exportDay, ledgerExportChunks, standaloneEvidenceHtml } from "./evidence-pack.js";
@@ -106,7 +108,7 @@ import { spawn as spawnChild } from "node:child_process";
106
108
  // Every envelope is checked against its command's schema (logged, never refused), then serialized unchanged.
107
109
  import { checkedEnvelopeJson as envelopeJson } from "./contracts/cli.js";
108
110
  import { auditCommand } from "./audit-cli.js";
109
- import { hasDisguisedText, hasForbiddenControls, validateNote } from "./decision.js";
111
+ import { closedQuestionWords, hasDisguisedText, hasForbiddenControls, validateNote } from "./decision.js";
110
112
  import { readVerifiedArtifact, readVerifiedReport, storeEvidence } from "./evidence.js";
111
113
  import { contractChangesOf, decodePlanContractRecord, describeContractChanges, encodePlannerSource, plannerSourceOf } from "./planner-source.js";
112
114
  /**
@@ -289,7 +291,7 @@ export const OPERATE_HELP = `toolroll — operating the queue
289
291
  toolroll task add-tests <id> file a small task to write tests for that change
290
292
  toolroll task revise <id> --feedback "requested change"
291
293
  toolroll assignment show <task> root, current work and exact handoff
292
- toolroll assignment updates durable updates (--after <cursor>)
294
+ toolroll assignment updates history of handoffs sent to people (--after <cursor>)
293
295
  toolroll assignment brief catch up from the local database
294
296
  toolroll assignment inbox --consumer <name> receive saved status changes
295
297
  toolroll assignment ack --consumer <name> --batch <id> acknowledge delivery
@@ -306,9 +308,10 @@ export const OPERATE_HELP = `toolroll — operating the queue
306
308
  is still required); --undo puts it
307
309
  back in filing order
308
310
  toolroll task steer <id> --note "..."
309
- guidance for the next attempt — it
310
- reads the note before starting; a
311
- running agent is not interrupted
311
+ guidance for the agent — a running
312
+ build reads it when it next checks
313
+ for notes, otherwise the next
314
+ attempt does
312
315
  toolroll task assign <id> --runner <name> | --anyone
313
316
  reserve it for one worker (it joins
314
317
  the back of that worker's queue) or
@@ -3721,7 +3724,7 @@ async function decideCommand(positional, flags, context) {
3721
3724
  write(envelopeJson({ ok: true, command: "decide", decision, taskId, evidence }));
3722
3725
  return EXIT.ok;
3723
3726
  }
3724
- write(`${taskId} — ${decision.state.toUpperCase()}${decision.deadline === null ? "" : ` · deadline ${decision.deadline}`}`);
3727
+ write(`${taskId} — ${decision.supersededReason !== null ? "CLOSED" : decision.state.toUpperCase()}${decision.deadline === null ? "" : ` · deadline ${decision.deadline}`}`);
3725
3728
  write("");
3726
3729
  write(` ${decision.recap}`);
3727
3730
  write("");
@@ -3739,7 +3742,9 @@ async function decideCommand(positional, flags, context) {
3739
3742
  }
3740
3743
  if (decision.state === "answered") {
3741
3744
  write("");
3742
- write(` answered: ${decision.choice} by ${decision.answeredBy} at ${decision.answeredAt}${decision.note === null ? "" : ` — ${decision.note}`}`);
3745
+ write(decision.supersededReason !== null
3746
+ ? ` ${closedQuestionWords(decision.supersededReason)} (${decision.answeredAt})`
3747
+ : ` answered: ${decision.choice} by ${decision.answeredBy} at ${decision.answeredAt}${decision.note === null ? "" : ` — ${decision.note}`}`);
3743
3748
  }
3744
3749
  if (evidence.length > 0) {
3745
3750
  write("");
@@ -3747,8 +3752,10 @@ async function decideCommand(positional, flags, context) {
3747
3752
  write(` evidence ${artifact.kind.padEnd(14)} ${artifact.key}${artifact.truncated ? " (truncated)" : ""}`);
3748
3753
  }
3749
3754
  }
3750
- write("");
3751
- write(` → toolroll decide ${id} --choose <option> --as <you> --token <t>`);
3755
+ if (decision.state !== "answered") {
3756
+ write("");
3757
+ write(` → toolroll decide ${id} --choose <option> --as <you> --token <t>`);
3758
+ }
3752
3759
  return EXIT.ok;
3753
3760
  }
3754
3761
  const acting = await askCredentials(flags, context);
@@ -3767,9 +3774,11 @@ async function decideCommand(positional, flags, context) {
3767
3774
  ? `"${choice}" is not one of this decision's options — \`toolroll decide ${id}\` shows them`
3768
3775
  : answered.reason === "already-answered"
3769
3776
  ? `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}`;
3777
+ : answered.reason === "closed"
3778
+ ? `decision ${id}: ${closedQuestionWords(store.getDecision(id)?.supersededReason ?? "cancelled")} Nothing to answer.`
3779
+ : answered.reason === "bad-note"
3780
+ ? "the note is too long or carries control characters"
3781
+ : `no decision ${id}`;
3773
3782
  return fail(write, json, "decide", answered.reason, why, EXIT.refused);
3774
3783
  }
3775
3784
  return succeed(write, json, "decide", { decision: answered.decision, duplicate: answered.duplicate === true }, () => answered.duplicate === true
@@ -8999,6 +9008,7 @@ function showTask(positional, context) {
8999
9008
  const listed = workIndexTask(store, id, now, access, context.evidenceRoot);
9000
9009
  const shared = listed === null ? null : { headline: listed.status.label, sentence: listed.status.detail };
9001
9010
  const primaryAction = listed === null ? work?.primaryAction ?? null : listed.primaryAction;
9011
+ const steeringLive = store.steeringLiveBuild(ref.id, now) !== null;
9002
9012
  const detail = {
9003
9013
  task,
9004
9014
  work: work === null || listed === null ? work : { ...work, status: { ...work.status, label: listed.status.label, detail: listed.status.detail, tone: listed.status.tone }, primaryAction },
@@ -9038,6 +9048,9 @@ function showTask(positional, context) {
9038
9048
  stops: store.stopsForTask(ref.id),
9039
9049
  // The pull request Complete opened (any version), with its CI state and merge commit — the console's view.
9040
9050
  pullRequest: newestPullRequestOf(store, store.taskFamilyOf(id, null, true)?.versions.map(one => one.id) ?? [id]),
9051
+ // Operator steering (v120), in the task page's words: read (and when), waiting, or for the next attempt.
9052
+ steering: store.listSteerNotes(ref.id).map(one => ({ id: one.id, author: one.author, createdAt: one.createdAt, note: one.note,
9053
+ standing: steerStandingOf(one, steeringLive), words: steerStandingWords(one, steeringLive, utcMinute) })),
9041
9054
  };
9042
9055
  return succeed(write, json, "task show", detail, () => [
9043
9056
  `${task.id} ${task.state}${ref.deliverable === "report" ? " (scout — delivers a report)" : ""}`,
@@ -9072,6 +9085,7 @@ function showTask(positional, context) {
9072
9085
  ? []
9073
9086
  : [` position ${detail.position.position} of ${detail.position.total}${detail.position.column === null ? " in the shared queue" : ` in ${detail.position.column}'s queue`}`]),
9074
9087
  ...(detail.hold === null ? [] : [` held: ${detail.hold.reason}`]),
9088
+ ...detail.steering.slice(-5).map(one => ` note #${one.id} to the agent: ${one.words}`),
9075
9089
  ...(detail.claim === null ? [] : [` claimed by ${detail.claim.runner} until ${detail.claim.expiresAt}`]),
9076
9090
  ...(detail.dispatch === null ? [] : [` dispatch: ${detail.dispatch.summary} — ${detail.dispatch.detail}`]),
9077
9091
  ...(detail.control.kind === "none"
@@ -9427,7 +9441,7 @@ async function steerTask(positional, flags, context) {
9427
9441
  const id = positional[0];
9428
9442
  const note = text(flags, "note");
9429
9443
  if (id === undefined || note === undefined) {
9430
- return fail(write, json, "task steer", "usage", "`toolroll task steer <id> --note \"...\" --as <you> --token <t>` — steering speaks with the operator's voice, so it takes your credential; the note reaches the next attempt's brief, fenced, inside the approved scope", EXIT.usage);
9444
+ return fail(write, json, "task steer", "usage", "`toolroll task steer <id> --note \"...\" --as <you> --token <t>` — steering speaks with the operator's voice, so it takes your credential; a running build's agent reads the note when it next checks for notes, otherwise the next attempt's brief quotes it, fenced, inside the approved scope", EXIT.usage);
9431
9445
  }
9432
9446
  // Ruling 11: authorship derives from a VERIFIED principal, never a flag.
9433
9447
  // Missing credentials are usage (the invocation is incomplete); present
@@ -9449,8 +9463,10 @@ async function steerTask(positional, flags, context) {
9449
9463
  : (filed.problem ?? "that note will not store");
9450
9464
  return fail(write, json, "task steer", filed.reason, detail, EXIT.refused);
9451
9465
  }
9452
- return succeed(write, json, "task steer", { task: id, note: filed.id }, () => [
9453
- `Noted. The next attempt at ${id} reads it before starting — a running agent is not interrupted.`,
9466
+ const steered = store.lookupRef(id);
9467
+ const live = steered !== null && store.steeringLiveBuild(steered.id, context.now) !== null;
9468
+ return succeed(write, json, "task steer", { task: id, note: filed.id, standing: live ? "waiting" : "next-attempt" }, () => [
9469
+ `Noted. ${steerDestinationWords(live)} \`task show ${id}\` says when it has been read.`,
9454
9470
  ]);
9455
9471
  }
9456
9472
  /** The mirror of block: stop waiting. Removal cannot create a cycle. */
@@ -9707,6 +9723,7 @@ async function scopeTask(positional, flags, context) {
9707
9723
  // the mode seal record as a single operation —
9708
9724
  // a replayed key returns the FIRST answer whole instead of re-sealing
9709
9725
  // whatever scope happens to be current.
9726
+ const generationBefore = store.lookupRef(id)?.planningGeneration ?? 0;
9710
9727
  const filed = store.replay(mutationFrom(flags, now), "task-scope-filed", () => store.transact(() => {
9711
9728
  const coverage = actor !== null
9712
9729
  ? modeFilingCoverage(store, store.refFor(BUILT_IN, id).repo, actor, now)
@@ -9763,8 +9780,12 @@ async function scopeTask(positional, flags, context) {
9763
9780
  ...describeScope(scope),
9764
9781
  ]);
9765
9782
  }
9766
- return succeed(write, json, "task scope", { scope }, () => [
9767
- `Scope written for ${id}. Nothing will build it until somebody approves it.`,
9783
+ // A changed scope on a planning task plans again (v119): say so once, instead of leaving the old plan implied.
9784
+ const replanned = (store.lookupRef(id)?.planningGeneration ?? 0) > generationBefore;
9785
+ return succeed(write, json, "task scope", { scope, ...(replanned ? { planningRestarted: true } : {}) }, () => [
9786
+ replanned
9787
+ ? `Scope written for ${id}. The scope changed, so planning starts again; nothing builds until somebody approves the new plan.`
9788
+ : `Scope written for ${id}. Nothing will build it until somebody approves it.`,
9768
9789
  ...describeScope(scope),
9769
9790
  "",
9770
9791
  ` 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
  /**
@@ -38,6 +38,8 @@
38
38
  import { createHash } from "node:crypto";
39
39
  import { EVIDENCE_CAPS, readVerifiedArtifact } from "./evidence.js";
40
40
  import { TEXT_LIMITS } from "./text-limits.js";
41
+ import { PLAN_TOUCHES } from "./contracts/plan.js";
42
+ import { contractChangesOf } from "./contract-changes.js";
41
43
  export const PLANNER_SOURCE_VERSION = 1;
42
44
  export const PLANNER_SOURCE_LIMITS = {
43
45
  /** The whole recorded source before lossless prompt fencing. Equal to
@@ -48,6 +50,9 @@ export const PLANNER_SOURCE_LIMITS = {
48
50
  amendment: TEXT_LIMITS.planAmendment,
49
51
  /** Earlier answers quoted into the brief. */
50
52
  answers: 5,
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
+ handoffTerms: TEXT_LIMITS.planPayloadBytes,
51
56
  };
52
57
  /** Stable JSON for hashing and equality only — no filling, no trimming. */
53
58
  export function canonicalContractJson(value) {
@@ -192,26 +197,55 @@ export function plannerSourceOf(store, root, taskId, answers) {
192
197
  `(scope ${scopeBytes}, revision brief ${briefBytes}); nothing is trimmed silently — shorten the scope or brief, then plan again`,
193
198
  };
194
199
  }
200
+ const handoff = source.contract.scope === null ? null : plannerHandoffProblemOf(source.contract.scope);
201
+ if (handoff !== null)
202
+ return { ok: false, reason: "too-long", message: handoff };
195
203
  return { ok: true, source, bytes };
196
204
  }
197
205
  /**
198
206
  * The dispatch diagnosis's read of the same gate, from the store alone:
199
207
  * why the next pass would refuse to plan this task before spending — the
200
- * revision brief unreadable, or the filed request over the byte cap. The
201
- * size is a floor (the brief's stored bytes, before JSON quoting), so a
202
- * "yes" here is certain and the dispatch refusal itself stays the exact
203
- * word. Null when nothing here stands in the way.
208
+ * revision brief unreadable, the filed request over the byte cap, or terms
209
+ * the planner's handoff cannot carry. The size is a floor (the brief's
210
+ * stored bytes, before JSON quoting), so a "yes" here is certain and the
211
+ * dispatch refusal itself stays the exact word. Null when nothing here
212
+ * stands in the way.
204
213
  */
205
214
  export function plannerSourceProblemOf(store, taskId) {
215
+ const filed = (detail) => ({ title: "The filed request cannot be planned as filed", detail });
206
216
  if (store.answeredDecisionsFor(taskId, PLANNER_SOURCE_LIMITS.answers + 1).length > PLANNER_SOURCE_LIMITS.answers)
207
- return `more than ${PLANNER_SOURCE_LIMITS.answers} answered decisions exceed the planner history bound; file a follow-up task with the consolidated decisions`;
217
+ return filed(`more than ${PLANNER_SOURCE_LIMITS.answers} answered decisions exceed the planner history bound; file a follow-up task with the consolidated decisions`);
208
218
  const contract = plannerContractOf(store, taskId);
209
219
  if (!contract.ok)
210
- return contract.message;
220
+ return filed(contract.message);
211
221
  const briefBytes = contract.contract.revision === null ? 0 : (store.getArtifact(contract.contract.revision.briefArtifact)?.bytesStored ?? 0);
212
222
  const floor = Buffer.byteLength(JSON.stringify(contract.contract, null, 2), "utf8") + briefBytes;
213
223
  if (floor > PLANNER_SOURCE_LIMITS.bytes) {
214
- return `the filed request is at least ${floor} bytes — over the ${PLANNER_SOURCE_LIMITS.bytes}-byte planner source cap; shorten the scope or brief before planning`;
224
+ return filed(`the filed request is at least ${floor} bytes — over the ${PLANNER_SOURCE_LIMITS.bytes}-byte planner source cap; shorten the scope or brief before planning`);
225
+ }
226
+ const handoff = contract.contract.scope === null ? null : plannerHandoffProblemOf(contract.contract.scope);
227
+ return handoff === null ? null : { title: "Scope too long to plan", detail: handoff };
228
+ }
229
+ /**
230
+ * Whether the planner can hand the filed terms back (contracts/plan.ts): it must reproduce the goal, exclusions,
231
+ * touches and criteria in its handoff, so terms its handoff cannot carry would end every attempt as a malformed plan.
232
+ * This names the problem before any workspace, run or provider spend instead: the actual size, the limit, and what to
233
+ * shorten. Null when the terms fit.
234
+ */
235
+ export function plannerHandoffProblemOf(terms) {
236
+ const n = (value) => value.toLocaleString("en-US");
237
+ for (const [label, is, text] of [["goal", "is", terms.goal], ["exclusions", "are", terms.outOfScope ?? ""]]) {
238
+ if (text.length > TEXT_LIMITS.goal)
239
+ return `The ${label} ${is} ${n(text.length)} characters; the planner can carry at most ${n(TEXT_LIMITS.goal)}. Shorten the ${label} and planning starts again.`;
240
+ }
241
+ if (terms.touches.length > PLAN_TOUCHES)
242
+ return `The scope lists ${n(terms.touches.length)} paths; the planner can carry at most ${PLAN_TOUCHES}. List fewer paths and planning starts again.`;
243
+ const path = terms.touches.find(one => one.length > TEXT_LIMITS.planTouch);
244
+ if (path !== undefined)
245
+ return `A path is ${n(path.length)} characters; the planner can carry at most ${n(TEXT_LIMITS.planTouch)}. Shorten it and planning starts again.`;
246
+ const bytes = Buffer.byteLength(JSON.stringify({ goal: terms.goal, outOfScope: terms.outOfScope, touches: terms.touches, acceptance: terms.acceptance }), "utf8");
247
+ if (bytes > PLANNER_SOURCE_LIMITS.handoffTerms) {
248
+ return `The goal, exclusions, paths and criteria are ${n(bytes)} bytes together; the planner can carry at most ${n(PLANNER_SOURCE_LIMITS.handoffTerms)} bytes in its whole handoff. Shorten the scope and planning starts again.`;
215
249
  }
216
250
  return null;
217
251
  }
@@ -257,67 +291,8 @@ export function decodePlannerSource(content) {
257
291
  return null;
258
292
  }
259
293
  }
260
- const trimmed = (text) => {
261
- if (text === null)
262
- return null;
263
- const t = text.trim();
264
- return t === "" ? null : t;
265
- };
266
- /**
267
- * Every addition, change, and removal between the filed contract and a
268
- * proposed one. Whitespace at the ends and touch/evidence order do not
269
- * count (the scope digest ignores them too); everything else does,
270
- * including the advisory `how`, because the operator wrote it.
271
- */
272
- export function contractChangesOf(filed, proposed) {
273
- const changes = [];
274
- if (filed.goal.trim() !== proposed.goal.trim()) {
275
- changes.push({ field: "goal", kind: "changed", before: filed.goal, after: proposed.goal });
276
- }
277
- const filedOut = trimmed(filed.outOfScope);
278
- const proposedOut = trimmed(proposed.outOfScope);
279
- if (filedOut !== proposedOut) {
280
- changes.push({
281
- field: "outOfScope",
282
- kind: filedOut === null ? "added" : proposedOut === null ? "removed" : "changed",
283
- before: filed.outOfScope,
284
- after: proposed.outOfScope,
285
- });
286
- }
287
- const filedTouches = new Set(filed.touches.map(one => one.trim()).filter(one => one !== ""));
288
- const proposedTouches = new Set(proposed.touches.map(one => one.trim()).filter(one => one !== ""));
289
- for (const path of filedTouches)
290
- if (!proposedTouches.has(path))
291
- changes.push({ field: "touches", kind: "removed", path });
292
- for (const path of proposedTouches)
293
- if (!filedTouches.has(path))
294
- changes.push({ field: "touches", kind: "added", path });
295
- const criterionOf = (one) => ({ statement: one.statement, evidence: [...one.evidence].sort(), how: one.how });
296
- const filedById = new Map(filed.acceptance.map(one => [one.id, one]));
297
- const proposedById = new Map(proposed.acceptance.map(one => [one.id, one]));
298
- for (const [id, before] of filedById) {
299
- const after = proposedById.get(id);
300
- if (after === undefined) {
301
- changes.push({ field: "acceptance", kind: "removed", id, before: criterionOf(before), after: null, moved: [] });
302
- continue;
303
- }
304
- const moved = [];
305
- if (before.statement.trim() !== after.statement.trim())
306
- moved.push("statement");
307
- if ([...before.evidence].sort().join(",") !== [...after.evidence].sort().join(","))
308
- moved.push("evidence");
309
- if (trimmed(before.how) !== trimmed(after.how))
310
- moved.push("how");
311
- if (moved.length > 0) {
312
- changes.push({ field: "acceptance", kind: "changed", id, before: criterionOf(before), after: criterionOf(after), moved });
313
- }
314
- }
315
- for (const [id, after] of proposedById) {
316
- if (!filedById.has(id))
317
- changes.push({ field: "acceptance", kind: "added", id, before: null, after: criterionOf(after), moved: [] });
318
- }
319
- return changes;
320
- }
294
+ // ---- amendments -----------------------------------------------------------
295
+ export { contractChangesOf } from "./contract-changes.js";
321
296
  const clip = (text, max) => (text.length > max ? `${text.slice(0, max - 1)}…` : text);
322
297
  /** One line per change, for terminals, notifications, and validation messages. */
323
298
  export function describeContractChanges(changes) {
@@ -64,6 +64,14 @@ export type Invocation = {
64
64
  * Codex renders it as its own sandbox profile; Claude as deny rules for
65
65
  * its file tools (its shell is fenced by the macOS sandbox around it). */
66
66
  fence?: readonly string[];
67
+ /** A running build's steering folder (steer-mailbox.ts): the absolute path
68
+ * and the worktree-relative name its brief uses. Claude renders a
69
+ * PostToolUse hook that says when a note is waiting; every other provider
70
+ * relies on the brief's own rule to look. */
71
+ steerFolder?: {
72
+ dir: string;
73
+ name: string;
74
+ };
67
75
  /** Claude's structured-output floor for a non-review phase: the scout's
68
76
  * report rides the terminal result event instead of a file plan mode
69
77
  * would refuse to write. Ignored by providers without `--json-schema`. */
package/dist/provider.js CHANGED
@@ -19,6 +19,7 @@
19
19
  * shell the model itself launches (Codex provider review, Q5).
20
20
  */
21
21
  import { claudeFenceSettings, codexFenceArgv } from "./agent-fence.js";
22
+ import { claudeSteerHooks } from "./steer-mailbox.js";
22
23
  import {} from "./exec.js";
23
24
  import { runStreamJsonl, runClaudeStreamJsonl, runGeminiStreamJsonl } from "./exec.js";
24
25
  import { scanForSecrets } from "./evidence.js";
@@ -198,10 +199,20 @@ const claudeArgv = (invocation) => [
198
199
  ...(invocation.allowedTools === undefined || invocation.allowedTools.length === 0 ? [] : ["--allowedTools", invocation.allowedTools.join(",")]),
199
200
  ...(invocation.toolArgv ?? []),
200
201
  ...(invocation.extraMcpServers === undefined ? [] : ["--mcp-config", JSON.stringify({ mcpServers: invocation.extraMcpServers })]),
201
- ...(invocation.fence !== undefined && invocation.fence.length > 0 ? ["--settings", claudeFenceSettings(invocation.fence)] : []),
202
+ ...claudeSettingsArgv(invocation),
202
203
  ...(invocation.jsonSchema === undefined ? [] : ["--json-schema", JSON.stringify(invocation.jsonSchema)])
203
204
  ]),
204
205
  ];
206
+ /** One --settings for a build: the fence's deny rules and the steering doorbell, whichever the run has. */
207
+ function claudeSettingsArgv(invocation) {
208
+ const fenced = invocation.fence !== undefined && invocation.fence.length > 0;
209
+ if (!fenced && invocation.steerFolder === undefined)
210
+ return [];
211
+ return ["--settings", JSON.stringify({
212
+ ...(fenced ? JSON.parse(claudeFenceSettings(invocation.fence)) : {}),
213
+ ...(invocation.steerFolder === undefined ? {} : { hooks: claudeSteerHooks(invocation.steerFolder.dir, invocation.steerFolder.name) }),
214
+ })];
215
+ }
205
216
  /**
206
217
  * The turn's own words, preferring the schema-validated structured field
207
218
  * (present only under `--json-schema`) over the plain result string — the
@@ -1,8 +1,7 @@
1
1
  /**
2
2
  * `toolroll release --release-gate <id>`: lift one update pause that a browser deployment (scripts/deploy-browser.mjs)
3
- * left behind when it stopped before the swap and its own recovery could not tell. The typical case is a deployment
4
- * killed between installing the pause and journaling that it had: the journal still says `preparing`, so
5
- * `--phase recover` leaves the pause alone, and new work stays paused.
3
+ * left behind when it stopped before the swap and its own exit recovery never ran. The typical case is a deployment
4
+ * killed outright (SIGKILL, a crash or power loss) while paused: nothing lifted the pause, and new work stays paused.
6
5
  *
7
6
  * The id alone proves nothing, and neither does the journal's phase. The pause is lifted only when every piece of
8
7
  * evidence agrees that the service was never swapped:
@@ -1,8 +1,7 @@
1
1
  /**
2
2
  * `toolroll release --release-gate <id>`: lift one update pause that a browser deployment (scripts/deploy-browser.mjs)
3
- * left behind when it stopped before the swap and its own recovery could not tell. The typical case is a deployment
4
- * killed between installing the pause and journaling that it had: the journal still says `preparing`, so
5
- * `--phase recover` leaves the pause alone, and new work stays paused.
3
+ * left behind when it stopped before the swap and its own exit recovery never ran. The typical case is a deployment
4
+ * killed outright (SIGKILL, a crash or power loss) while paused: nothing lifted the pause, and new work stays paused.
6
5
  *
7
6
  * The id alone proves nothing, and neither does the journal's phase. The pause is lifted only when every piece of
8
7
  * evidence agrees that the service was never swapped:
@@ -23,6 +22,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
23
22
  import { join } from "node:path";
24
23
  import { durableJson } from "./desktop-update.js";
25
24
  import { removeUpdateGate, updateGateOwned } from "./desktop-update-gate.js";
25
+ import { acquireDeploymentLock } from "./deploy-lock.js";
26
26
  /** The journal phases a deployment passes before it stops the old service (deploy-phases.mjs). */
27
27
  export const BEFORE_SWAP = Object.freeze(["preparing", "admission-paused", "frozen", "backup-verified", "rehearsed"]);
28
28
  /** Journal fields that only the swap and later phases write. */
@@ -129,22 +129,50 @@ export function proveBeforeSwap(id, world) {
129
129
  }
130
130
  /** Prove, then (with `yes`) lift exactly that pause and journal the release in the deployment's own journal. */
131
131
  export function releaseGate(id, world, yes) {
132
- const proved = proveBeforeSwap(id, world);
133
- if (!proved.ok)
134
- return proved;
132
+ // Discovery is not authority to remove anything. Dry-run remains read-only, even for legacy stages.
133
+ const discovered = proveBeforeSwap(id, world);
134
+ if (!discovered.ok)
135
+ return discovered;
135
136
  if (!yes)
136
- return { ok: true, released: false, id, stage: proved.stage, proof: proved.proof };
137
- const db = world.open();
138
- // removeUpdateGate checks the owner again inside its own write: a pause that changed hands is not touched.
137
+ return { ok: true, released: false, id, stage: discovered.stage, proof: discovered.proof };
138
+ const journalFile = join(discovered.stage, "deployment.json");
139
+ let lock;
139
140
  try {
140
- removeUpdateGate(db, id);
141
+ lock = acquireDeploymentLock(journalFile);
142
+ }
143
+ catch (error) {
144
+ return refuse("deploying", error.message);
145
+ }
146
+ try {
147
+ const proved = proveBeforeSwap(id, world);
148
+ if (!proved.ok)
149
+ return proved;
150
+ if (proved.stage !== discovered.stage)
151
+ return refuse("conflict", "The deployment stage changed during release.");
152
+ const db = world.open();
153
+ try {
154
+ // Repeat the entire proof at the mutation boundary, under the same exclusion as deploy/resume/recover.
155
+ const fresh = proveBeforeSwap(id, world);
156
+ if (!fresh.ok)
157
+ return fresh;
158
+ if (fresh.stage !== proved.stage || JSON.stringify(fresh.journal) !== JSON.stringify(proved.journal) ||
159
+ JSON.stringify(JSON.parse(readFileSync(journalFile, "utf8"))) !== JSON.stringify(fresh.journal)) {
160
+ return refuse("conflict", "The deployment journal changed during release.");
161
+ }
162
+ lock.assertHeld(journalFile);
163
+ // The owner is checked again within removeUpdateGate's short write transaction.
164
+ removeUpdateGate(db, id);
165
+ durableJson(journalFile, {
166
+ ...fresh.journal, phase: "released", updatedAt: world.now().toISOString(),
167
+ releasedBeforeSwap: { at: world.now().toISOString(), by: "toolroll release --release-gate", from: fresh.journal.phase, proof: fresh.proof },
168
+ });
169
+ return { ok: true, released: true, id, stage: fresh.stage, proof: fresh.proof };
170
+ }
171
+ finally {
172
+ db.close();
173
+ }
141
174
  }
142
175
  finally {
143
- db.close();
176
+ lock.release();
144
177
  }
145
- durableJson(join(proved.stage, "deployment.json"), {
146
- ...proved.journal, phase: "released", updatedAt: world.now().toISOString(),
147
- releasedBeforeSwap: { at: world.now().toISOString(), by: "toolroll release --release-gate", from: proved.journal.phase, proof: proved.proof },
148
- });
149
- return { ok: true, released: true, id, stage: proved.stage, proof: proved.proof };
150
178
  }
package/dist/scope.js CHANGED
@@ -2,6 +2,7 @@
2
2
  export { ACCEPTANCE_LIMITS, EVIDENCE_KINDS } from "./contracts/acceptance-terms.js";
3
3
  import { currentPasswordSource, passwordGuardOf, provenPasswordAccount } from "./sign-in-guard.js";
4
4
  import { validateScopeText } from "./task-text.js";
5
+ import { contractChangesOf } from "./contract-changes.js";
5
6
  import { AsyncLocalStorage } from "node:async_hooks";
6
7
  import { projectAuthority } from "./project-access.js";
7
8
  import { activeRemote } from "./remote-run.js";
@@ -558,6 +559,19 @@ route) {
558
559
  .slice(0, 32);
559
560
  }
560
561
  export function propose(store, input) {
562
+ return store.transact(() => {
563
+ const previous = store.getScope(input.taskId);
564
+ const saved = proposeLocked(store, input);
565
+ // A rewritten goal, exclusions, touches or rubric on a task that is planning starts its planning again (v119), in
566
+ // the same transaction: the next planner plans for the new terms, never from the old plan or its answers. An
567
+ // identical refile changes nothing; the planner's own handoff writes its draft without coming through here.
568
+ const ref = store.lookupRef(input.taskId);
569
+ if (ref !== null && ref.plan !== null && (previous === null || contractChangesOf(previous, saved).length > 0))
570
+ store.restartPlanning(ref.id, input.now);
571
+ return saved;
572
+ });
573
+ }
574
+ function proposeLocked(store, input) {
561
575
  const { taskId, goal, outOfScope = null, touches = [], budgetMicrousd = null, acceptance = [], qualityMode = "default", now, mutation = {}, profile, permissionMode, posture, proposedVia = null } = input;
562
576
  const draft = { goal, outOfScope, touches: [...touches], budgetMicrousd, acceptance: [...acceptance], qualityMode, candidate: input.candidate ?? null };
563
577
  const previous = store.getScope(taskId);