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.
@@ -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) {
@@ -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);
@@ -2,6 +2,7 @@
2
2
  import { assignmentSummaryHtml } from "../assignment-ui.js";
3
3
  import {} from "../assignment.js";
4
4
  import {} from "../browser-workspace.js";
5
+ import { closedQuestionReason } from "../decision.js";
5
6
  import { CHAT_ACTIONS, sharedActionNeedsReview, sharedActionPayload, sharedActionReviewPath } from "../chat-actions.js";
6
7
  import { CHAT_CONTINUITY_SCRIPT } from "../chat-continuity.js";
7
8
  import { CHAT_CONTROLS, chatControlHref, isChatControl } from "../chat-controls.js";
@@ -478,6 +479,15 @@ export function proposalCardParts(view, inert, decision, returnTo = null) {
478
479
  const text = (key) => (typeof payload[key] === "string" ? payload[key] : "");
479
480
  const task = text("task");
480
481
  const repoId = text("repoId");
482
+ if (view.kind === "answer" && decision?.supersededReason != null) {
483
+ // The proposal remains in history, but its question no longer needs an answer.
484
+ // Both the server page and browser card use the same closed view, without stale consent controls.
485
+ const body = html `<p><a href="/d/${decision.id}">${decision.question}</a></p><p class="meta">${closedQuestionReason(decision.supersededReason)}</p>`;
486
+ return {
487
+ html: html `<article class="card proposal proposal-answer" data-card-kind="answer"><header class="proposal-head"><strong>Question closed</strong></header><div class="proposal-body">${body}</div></article>`,
488
+ card: { id: view.id, kind: view.kind, label: "Question closed", state: view.state, body: htmlString(body), said: null, links: [], primary: null, dismissable: false, note: null },
489
+ };
490
+ }
481
491
  const presentations = {
482
492
  task: { label: payload["report"] === true ? "Scout investigation" : "New task", action: "file task", icon: html `<path d="M12 5v14"/><path d="M5 12h14"/>` },
483
493
  next: { label: "Queue priority", action: "move to front", icon: html `<path d="M12 19V5"/><path d="m5 12 7-7 7 7"/>` },
@@ -1,5 +1,6 @@
1
1
  /** Task pages: approval, agents, plans, controls, decisions and the task body. */
2
2
  import { html, htmlString, joinHtml, postForm, replaceMarkup } from "../html.js";
3
+ import { closedQuestionReason, closedQuestionWords } from "../decision.js";
3
4
  import {} from "../activity-line.js";
4
5
  import {} from "../agentconfig.js";
5
6
  import { gateWords } from "../approval-policy.js";
@@ -1469,9 +1470,12 @@ export function taskBodyParts(data) {
1469
1470
  !(data.revision != null && "problem" in data.revision) && scope.profileState !== "unresolved" && consentDoorOf(scope, data.route).open;
1470
1471
  const planCard = data.planDocument === null
1471
1472
  ? data.plan === "requested"
1472
- ? data.revision != null && !("problem" in data.revision)
1473
- ? html `<div class="card planner-status"><span class="planner-orb" aria-hidden="true"></span><p><strong>Updating the plan</strong><span class="meta">Adding your notes to the plan. You approve any change before it builds.</span></p></div>`
1474
- : html `<div class="card planner-status"><span class="planner-orb" aria-hidden="true"></span><p><strong>Planning requested</strong><span class="meta">${data.planAuto ? "Automatic approval is enabled for a verified plan that preserves your filed contract. Amendments and unanswered questions still pause." : "The agent is inspecting the repository and drafting the goal, acceptance criteria, and approach. It will ask only if a missing answer changes the work."}</span></p></div>`
1473
+ // A request the planner cannot be given waits on a person; the status card above already says what to shorten.
1474
+ ? data.dispatch?.code === "planner-source"
1475
+ ? ""
1476
+ : data.revision != null && !("problem" in data.revision)
1477
+ ? html `<div class="card planner-status"><span class="planner-orb" aria-hidden="true"></span><p><strong>Updating the plan</strong><span class="meta">Adding your notes to the plan. You approve any change before it builds.</span></p></div>`
1478
+ : html `<div class="card planner-status"><span class="planner-orb" aria-hidden="true"></span><p><strong>Planning requested</strong><span class="meta">${data.planAuto ? "Automatic approval is enabled for a verified plan that preserves your filed contract. Amendments and unanswered questions still pause." : "The agent is inspecting the repository and drafting the goal, acceptance criteria, and approach. It will ask only if a missing answer changes the work."}</span></p></div>`
1475
1479
  : ""
1476
1480
  : joinHtml([
1477
1481
  approval.approved ? html `<details class="card planner-plan planner-plan-collapsed"><summary class="execution-plan-head">` : html `<section class="card planner-plan"><div class="execution-plan-head">`,
@@ -1788,7 +1792,7 @@ export function taskBodyParts(data) {
1788
1792
  })();
1789
1793
  const decisions = data.decisions.length === 0
1790
1794
  ? ""
1791
- : html `<h2>Decisions</h2>${joinHtml(data.decisions.map(decision => html `<p class="row"><a href="/d/${decision.id}">${decision.question}</a> <span class="meta">${decision.state}${isOverdue(decision, data.now) ? " · overdue" : ""}</span></p>`), "\n")}`;
1795
+ : html `<h2>Decisions</h2>${joinHtml(data.decisions.map(decision => html `<p class="row"><a href="/d/${decision.id}">${decision.question}</a> <span class="meta">${decision.supersededReason !== null ? closedQuestionWords(decision.supersededReason) : decision.state}${isOverdue(decision, data.now) ? " · overdue" : ""}</span></p>`), "\n")}`;
1792
1796
  const incidents = data.incidents.length === 0
1793
1797
  ? ""
1794
1798
  : html `<h2>Incidents</h2>${joinHtml(data.incidents.map(one => one.resolvedAt === null
@@ -2068,7 +2072,11 @@ export function taskBodyParts(data) {
2068
2072
  continue;
2069
2073
  thread.push({ key: `question-${decision.id}`, at: decision.createdAt, kind: "question", who: "agent", author: "", title: "Asked",
2070
2074
  text: decision.question, link: { label: "Question", href: `/d/${decision.id}` }, html: "", more: null });
2071
- if (decision.answeredAt !== null) {
2075
+ if (decision.supersededReason !== null) {
2076
+ thread.push({ key: `answer-${decision.id}`, at: decision.answeredAt ?? decision.createdAt, kind: "progress", who: "agent", author: "", title: "Closed without an answer",
2077
+ text: closedQuestionReason(decision.supersededReason), link: null, html: "", more: null });
2078
+ }
2079
+ else if (decision.answeredAt !== null) {
2072
2080
  const chosen = decision.options.find(one => one.id === decision.choice)?.label ?? decision.choice;
2073
2081
  thread.push({ key: `answer-${decision.id}`, at: decision.answeredAt, kind: "reply", who: "person", author: decision.answeredBy ?? "You", title: "Answered",
2074
2082
  text: [chosen, decision.note].filter((one) => one !== null && one !== "").join(" — ") || null, link: null, html: "", more: null });
@@ -2320,9 +2328,11 @@ export function decisionOptionForms(decision, returnTo) {
2320
2328
  }
2321
2329
  export function decisionPage(chrome, decision, taskId, artifacts, who, now, returnTo = null) {
2322
2330
  const options = decisionOptionForms(decision, returnTo);
2323
- const answered = decision.state === "answered"
2324
- ? html `<div class="answered">Answered: <strong>${decision.choice ?? ""}</strong> by ${decision.answeredBy ?? ""}${decision.note === null ? "" : ` — ${decision.note}`}</div>`
2325
- : "";
2331
+ const answered = decision.supersededReason !== null
2332
+ ? html `<p class="meta">${closedQuestionReason(decision.supersededReason)}</p>`
2333
+ : decision.state === "answered"
2334
+ ? html `<div class="answered">Answered: <strong>${decision.choice ?? ""}</strong> by ${decision.answeredBy ?? ""}${decision.note === null ? "" : ` — ${decision.note}`}</div>`
2335
+ : "";
2326
2336
  const evidence = artifacts.length === 0
2327
2337
  ? ""
2328
2338
  : joinHtml([
@@ -2331,7 +2341,7 @@ export function decisionPage(chrome, decision, taskId, artifacts, who, now, retu
2331
2341
  html `</div>`,
2332
2342
  ]);
2333
2343
  return screen(`decide · ${taskId}`, joinHtml([
2334
- html `<h1>${taskId} <span class="badge badge-${decision.state}">${decision.state}</span>${isOverdue(decision, now) ? html ` <span class="badge badge-overdue">Overdue</span>` : ""}${decision.deadline === null ? "" : html ` <span class="meta">deadline ${decision.deadline}</span>`}</h1>`,
2344
+ html `<h1>${taskId} <span class="badge badge-${decision.state}">${decision.supersededReason !== null ? "closed" : decision.state}</span>${isOverdue(decision, now) ? html ` <span class="badge badge-overdue">Overdue</span>` : ""}${decision.deadline === null ? "" : html ` <span class="meta">deadline ${decision.deadline}</span>`}</h1>`,
2335
2345
  html `<div class="recap">${decision.recap}</div>`,
2336
2346
  html `<div class="question">${decision.question}</div>`,
2337
2347
  decision.state === "answered" ? answered : options,
@@ -1,4 +1,5 @@
1
1
  import { handlersOf } from './handler-registry.js';
2
+ import { closedQuestionWords } from "../decision.js";
2
3
  import { DEPRECATED_PAGE } from "../deprecations.js";
3
4
  /** tasks handlers, moved without changing their route bodies. */
4
5
  import { html, htmlString, joinHtml, postForm, textHtml } from "../html.js";
@@ -1118,7 +1119,8 @@ export function createTasksHandlers(runtime) {
1118
1119
  }, now);
1119
1120
  if (!answered.ok) {
1120
1121
  const status = answered.reason === "bad-option" || answered.reason === "bad-note" ? 400 : 409;
1121
- const why = answered.reason === "already-answered" ? "already answered — somebody got there first" : answered.reason;
1122
+ const closed = answered.reason === "closed" ? store.getDecision(id)?.supersededReason ?? null : null;
1123
+ const why = closed !== null ? closedQuestionWords(closed) : answered.reason === "already-answered" ? "already answered — somebody got there first" : answered.reason;
1122
1124
  return refuse(response, who, status, why, requestedReturn === null ? `/d/${id}` : decisionBack);
1123
1125
  }
1124
1126
  return redirect(response, decisionBack);
package/dist/store.d.ts CHANGED
@@ -152,7 +152,7 @@ export type SealedLedgerEntry = LedgerEntry & {
152
152
  hash: string;
153
153
  } | null;
154
154
  };
155
- export declare const SCHEMA_VERSION = 118;
155
+ export declare const SCHEMA_VERSION = 119;
156
156
  /** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
157
157
  export declare const V118_MERGED_TABLES: Readonly<Record<string, CredentialKind>>;
158
158
  /**
@@ -207,9 +207,16 @@ export declare const V115_DROPPED_TABLES: readonly string[];
207
207
  * teammates' to its subagents' (SQLite's RENAME, every row, id and link carried), renames their teammate column, and rewrites four internal kinds from 'teammate' to 'subagent' in place; the rehearsal checks
208
208
  * each renamed table's rows arrived under its new name. v118 (D7) is checked the same way: every row of api_token,
209
209
  * coordinator_credential and lead_credential moves into credential under its kind in one transaction that counts them
210
- * before the old tables drop, and the references that named them name credential in place, their rows untouched.
210
+ * before the old tables drop, and the references that named them name credential in place, their rows untouched. v119 only adds four columns
211
+ * (planning_generation on task_ref, run and plan_revision, defaulting to 0, and decision.superseded_reason, null);
212
+ * the rehearsal checks every saved row reads the declared default (HISTORY_RULES' added columns).
211
213
  */
212
214
  export declare const UPDATE_SAFE_MIGRATIONS: readonly number[];
215
+ /** v119: the columns it adds, by table, with the value every row saved before it reads (toolroll-update's HISTORY_RULES). */
216
+ export declare const V119_ADDED_COLUMNS: Readonly<Record<string, Readonly<Record<string, {
217
+ definition: string;
218
+ saved: string;
219
+ }>>>>;
213
220
  /** Whether a database settled at `version` reaches this build's schema through update-safe migrations alone. */
214
221
  export declare function updateSafeSchema(version: number | null): boolean;
215
222
  export type OAuthClientRow = {
@@ -330,6 +337,9 @@ export type TaskRef = {
330
337
  plan: "requested" | "drafted" | null;
331
338
  /** Planning failures, counted apart from build strikes by design. */
332
339
  planStrikes: number;
340
+ /** v119: which planning context is active. A rewritten scope advances it; plans, revisions and answers from an
341
+ * earlier generation stay as history, never as the next planner's context. */
342
+ planningGeneration: number;
333
343
  /** What comes back (v34): a branch to publish, or a report to read.
334
344
  * Set at filing and never rewritten — a scout's strikes are the task's. */
335
345
  deliverable: "branch" | "report";
@@ -1050,6 +1060,8 @@ export type Run = {
1050
1060
  taskRef: number;
1051
1061
  leaseId: string;
1052
1062
  runner: string;
1063
+ /** v119: the task's planning generation when this run opened (0 for every run before it). */
1064
+ planningGeneration: number;
1053
1065
  /** Explicit watch ownership for claimless review runs; null on cron/legacy reviews. */
1054
1066
  watchIncarnation?: string | null;
1055
1067
  /** v50: a ROOT reviewer's attempt ordinal for its source run (1..3);
@@ -1194,7 +1206,12 @@ export type Decision = {
1194
1206
  answeredVia: "cli" | "web" | "telegram" | "slack" | "discord" | "teams" | null;
1195
1207
  choice: string | null;
1196
1208
  note: string | null;
1209
+ /** v119: closed without an answer (state 'answered', no choice) because its task's scope changed or the task was
1210
+ * cancelled. null is every real answer and every question still waiting. */
1211
+ supersededReason: DecisionClosure | null;
1197
1212
  };
1213
+ /** Why a question closed without an answer (v119). */
1214
+ export type DecisionClosure = "scope-changed" | "cancelled";
1198
1215
  /** Evidence, by reference. `key` is relative to the evidence root, never absolute. */
1199
1216
  export type Artifact = {
1200
1217
  id: number;
@@ -3446,6 +3463,24 @@ export declare class Store {
3446
3463
  ok: false;
3447
3464
  reason: "live-claim";
3448
3465
  };
3466
+ /**
3467
+ * A rewritten scope starts planning again (v119), in one transaction: the task's planning generation advances, a
3468
+ * requested or drafted plan becomes requested with no strikes, and what the old goal produced leaves the active
3469
+ * planning context — its open questions close (typed 'scope-changed', never a fake answer, their holds and pages
3470
+ * with them) and a revision still waiting for a person is rejected with its hold. The old runs, plans, revisions,
3471
+ * answers and ledger rows all stay: generation-bound reads simply stop offering them to the next planner. Only a
3472
+ * task already planning restarts; false (nothing changed) for any other.
3473
+ */
3474
+ restartPlanning(taskRef: number, now: Date): boolean;
3475
+ /**
3476
+ * Close a task's open and expired questions without answering them (v119): typed with why, no choice, their
3477
+ * holds lifted and their pages resolved, so they leave every decision queue. Answered questions are never touched.
3478
+ * `belowGeneration` limits it to questions asked in earlier planning generations. Caller holds the transaction.
3479
+ */
3480
+ private closeQuestionsLocked;
3481
+ /** The number the next plan revision of a task takes: one past every revision it ever had, in any planning
3482
+ * generation, so a restarted plan's revisions never collide with the history they follow. */
3483
+ nextPlanRevisionNumber(taskRef: number): number;
3449
3484
  /** Move the planning state; null clears it. Caller owns the transaction story. */
3450
3485
  setPlanState(taskRef: number, state: "requested" | "drafted" | null): void;
3451
3486
  addPlanStrike(taskRef: number): number;
@@ -4475,7 +4510,8 @@ export declare class Store {
4475
4510
  * reads in the order the questions arrived. "unanswered" is open + expired:
4476
4511
  * expiry makes a decision louder, never gone.
4477
4512
  */
4478
- /** The newest planner run's plan document for a task, when one exists. */
4513
+ /** The newest planner run's plan document for a task, when one exists in its current planning generation (v119:
4514
+ * a plan drafted for a scope since rewritten is history, read by its artifact id). */
4479
4515
  latestPlanArtifact(taskRef: number): Artifact | null;
4480
4516
  /** The newest plan-contract INGESTION record on one of this task's
4481
4517
  * planner runs (contract handoff, task 1): what the draft proposed
@@ -4508,8 +4544,9 @@ export declare class Store {
4508
4544
  changedFields: readonly string[];
4509
4545
  status: PlanRevisionStatus;
4510
4546
  }, now: Date): number;
4511
- /** Every revision for a task, newest first — the immutable history a
4512
- * reader can show without a second table. */
4547
+ /** Every revision for a task in its current planning generation, newest
4548
+ * first — the immutable history a reader can show without a second table
4549
+ * (v119: an earlier generation's rows stay, read by id). */
4513
4550
  listPlanRevisions(taskRef: number): PlanRevision[];
4514
4551
  /** The current plan revision: the newest row an operator (or an
4515
4552
  * auto-applied plan-only proposal) has actually put in force. Null for
@@ -4566,7 +4603,8 @@ export declare class Store {
4566
4603
  checkpointHistory(run: number): RunCheckpoint[];
4567
4604
  /** The newest scout run's report for a task (v34), when one exists. */
4568
4605
  latestReportArtifact(taskRef: number): Artifact | null;
4569
- /** Answered questions for one task, newest first — the planner's memory. */
4606
+ /** Answered questions for one task, newest first — the planner's memory. Only real answers from its current
4607
+ * planning generation (v119): a question closed unanswered, or asked about a scope since rewritten, is history. */
4570
4608
  answeredDecisionsFor(taskId: string, limit?: number): {
4571
4609
  question: string;
4572
4610
  choice: string | null;
@@ -4668,7 +4706,7 @@ export declare class Store {
4668
4706
  duplicate?: boolean;
4669
4707
  } | {
4670
4708
  ok: false;
4671
- reason: "unknown-decision" | "bad-option" | "already-answered" | "bad-note";
4709
+ reason: "unknown-decision" | "bad-option" | "already-answered" | "closed" | "bad-note";
4672
4710
  };
4673
4711
  /**
4674
4712
  * The answer's body, for callers already holding the transaction — the
@@ -4689,7 +4727,7 @@ export declare class Store {
4689
4727
  duplicate?: boolean;
4690
4728
  } | {
4691
4729
  ok: false;
4692
- reason: "unknown-decision" | "bad-option" | "already-answered" | "bad-note";
4730
+ reason: "unknown-decision" | "bad-option" | "already-answered" | "closed" | "bad-note";
4693
4731
  };
4694
4732
  saveArtifact(artifact: {
4695
4733
  run: number;