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/store.js CHANGED
@@ -559,7 +559,10 @@ const REGISTERED_AT_FILING = "again, as it was filed";
559
559
  // v118 (D7) keeps every machine credential in one table, `credential`: a person's API tokens (so_…), the lead's tokens
560
560
  // (lt_…) and coordinators, each with its kind, read/act access, project limit, expiry, revocation and generation. Every
561
561
  // row moves with its id, hash and dates; the references to coordinators and API tokens point at it.
562
- export const SCHEMA_VERSION = 118;
562
+ // v119 keeps planning generations: a rewritten scope starts a clean planning context (task_ref, run and plan_revision
563
+ // carry planning_generation, 0 for all earlier history), and a question closed without an answer says why
564
+ // (decision.superseded_reason: its task's scope changed, or the task was cancelled).
565
+ export const SCHEMA_VERSION = 119;
563
566
  /** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
564
567
  export const V118_MERGED_TABLES = Object.freeze({ api_token: "person", coordinator_credential: "coordinator", lead_credential: "lead" });
565
568
  /** The foreign keys that named a merged table, exactly as their DDL wrote them; each names `credential(id)` instead. */
@@ -680,9 +683,18 @@ const V115_DROPPED_REFERENCES = /\s+REFERENCES (?:contestant|contest|tournament_
680
683
  * 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
681
684
  * each renamed table's rows arrived under its new name. v118 (D7) is checked the same way: every row of api_token,
682
685
  * coordinator_credential and lead_credential moves into credential under its kind in one transaction that counts them
683
- * before the old tables drop, and the references that named them name credential in place, their rows untouched.
686
+ * before the old tables drop, and the references that named them name credential in place, their rows untouched. v119 only adds four columns
687
+ * (planning_generation on task_ref, run and plan_revision, defaulting to 0, and decision.superseded_reason, null);
688
+ * the rehearsal checks every saved row reads the declared default (HISTORY_RULES' added columns).
684
689
  */
685
- export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118]);
690
+ export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119]);
691
+ /** v119: the columns it adds, by table, with the value every row saved before it reads (toolroll-update's HISTORY_RULES). */
692
+ export const V119_ADDED_COLUMNS = Object.freeze({
693
+ task_ref: { planning_generation: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" } },
694
+ run: { planning_generation: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" } },
695
+ plan_revision: { planning_generation: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" } },
696
+ decision: { superseded_reason: { definition: "TEXT CHECK (superseded_reason IN ('scope-changed','cancelled'))", saved: "NULL" } },
697
+ });
686
698
  /** Whether a database settled at `version` reaches this build's schema through update-safe migrations alone. */
687
699
  export function updateSafeSchema(version) {
688
700
  if (version === null || !Number.isSafeInteger(version) || version <= 0 || version > SCHEMA_VERSION)
@@ -3474,6 +3486,11 @@ function initializeStore(db, file) {
3474
3486
  // v115: the task each standing-order firing filed, kept on the firing itself (a card's task changes as it moves).
3475
3487
  addColumn(db, "flow_trigger_event", "task", "TEXT");
3476
3488
  db.exec(PLAN_AUTO_SCHEMA);
3489
+ // v119: planning generations, and why a question closed unanswered (additive only; saved rows read the defaults).
3490
+ for (const [table, columns] of Object.entries(V119_ADDED_COLUMNS)) {
3491
+ for (const [column, { definition }] of Object.entries(columns))
3492
+ addColumn(db, table, column, definition);
3493
+ }
3477
3494
  installLedgerTriggers(db);
3478
3495
  // Attention/history indexes come AFTER migration: on a database whose
3479
3496
  // constrained tables still carry a pre-rebuild shape, creating a partial
@@ -4796,6 +4813,10 @@ export class Store {
4796
4813
  // just the state verb's.
4797
4814
  if (ref !== undefined)
4798
4815
  this.supersedeSteerNotes(Number(ref["id"]), now);
4816
+ // Its open questions close the same way (v119), typed 'cancelled', with their holds and pages: a question about
4817
+ // work that will never run must leave every decision queue. Answered ones stay exactly as they were.
4818
+ if (ref !== undefined)
4819
+ this.closeQuestionsLocked(Number(ref["id"]), "cancelled", now);
4799
4820
  // The reason is part of the transition, not decoration: a
4800
4821
  // coordinator-filed task's dismissal writes its durable event IN THIS
4801
4822
  // transaction — operator text and typed machine codes alike (MCP spec
@@ -9942,6 +9963,66 @@ export class Store {
9942
9963
  return { ok: true };
9943
9964
  });
9944
9965
  }
9966
+ /**
9967
+ * A rewritten scope starts planning again (v119), in one transaction: the task's planning generation advances, a
9968
+ * requested or drafted plan becomes requested with no strikes, and what the old goal produced leaves the active
9969
+ * planning context — its open questions close (typed 'scope-changed', never a fake answer, their holds and pages
9970
+ * with them) and a revision still waiting for a person is rejected with its hold. The old runs, plans, revisions,
9971
+ * answers and ledger rows all stay: generation-bound reads simply stop offering them to the next planner. Only a
9972
+ * task already planning restarts; false (nothing changed) for any other.
9973
+ */
9974
+ restartPlanning(taskRef, now) {
9975
+ return this.transact(() => {
9976
+ const moved = this.db
9977
+ .prepare("UPDATE task_ref SET planning_generation = planning_generation + 1, plan = 'requested', plan_strikes = 0 WHERE id = ? AND plan IN ('requested','drafted')")
9978
+ .run(taskRef);
9979
+ if (Number(moved.changes) === 0)
9980
+ return false;
9981
+ const ref = this.db.prepare("SELECT external_id, repo, planning_generation FROM task_ref WHERE id = ?").get(taskRef);
9982
+ const generation = Number(ref["planning_generation"]);
9983
+ this.closeQuestionsLocked(taskRef, "scope-changed", now, generation);
9984
+ const blocked = this.db
9985
+ .prepare("SELECT id FROM plan_revision WHERE task_ref = ? AND status = 'blocked' AND planning_generation < ?")
9986
+ .all(taskRef, generation)
9987
+ .map(row => Number(row["id"]));
9988
+ for (const id of blocked) {
9989
+ this.resolvePlanRevision(id, "rejected", "toolroll", now);
9990
+ this.releaseOwnedHold("revision", String(id));
9991
+ }
9992
+ this.recordAction({
9993
+ at: now.toISOString(), actor: currentActor() === null ? "system" : actorLabel(currentActor()), repo: ref["repo"] === null ? null : String(ref["repo"]),
9994
+ taskId: String(ref["external_id"]), runId: null, action: "planning restarted", outcome: "scope changed", source: "work",
9995
+ });
9996
+ this.bumpWake();
9997
+ return true;
9998
+ });
9999
+ }
10000
+ /**
10001
+ * Close a task's open and expired questions without answering them (v119): typed with why, no choice, their
10002
+ * holds lifted and their pages resolved, so they leave every decision queue. Answered questions are never touched.
10003
+ * `belowGeneration` limits it to questions asked in earlier planning generations. Caller holds the transaction.
10004
+ */
10005
+ closeQuestionsLocked(taskRef, reason, now, belowGeneration) {
10006
+ const ids = this.db
10007
+ .prepare(`SELECT decision.id FROM decision JOIN run ON run.id = decision.run
10008
+ WHERE run.task_ref = ? AND decision.state IN ('open','expired') AND (? IS NULL OR run.planning_generation < ?)
10009
+ ORDER BY decision.id`)
10010
+ .all(taskRef, belowGeneration ?? null, belowGeneration ?? null)
10011
+ .map(row => Number(row["id"]));
10012
+ const close = this.db.prepare("UPDATE decision SET state = 'answered', answered_at = ?, answered_by = 'toolroll', superseded_reason = ? WHERE id = ? AND state IN ('open','expired')");
10013
+ for (const id of ids) {
10014
+ close.run(now.toISOString(), reason, id);
10015
+ this.releaseOwnedHold("decision", String(id));
10016
+ this.resolveEpisode(`decision:${id}`, now);
10017
+ }
10018
+ return ids;
10019
+ }
10020
+ /** The number the next plan revision of a task takes: one past every revision it ever had, in any planning
10021
+ * generation, so a restarted plan's revisions never collide with the history they follow. */
10022
+ nextPlanRevisionNumber(taskRef) {
10023
+ const row = this.db.prepare("SELECT coalesce(max(revision), 0) AS n FROM plan_revision WHERE task_ref = ?").get(taskRef);
10024
+ return Number(row?.["n"] ?? 0) + 1;
10025
+ }
9945
10026
  /** Move the planning state; null clears it. Caller owns the transaction story. */
9946
10027
  setPlanState(taskRef, state) {
9947
10028
  this.db.prepare("UPDATE task_ref SET plan = ? WHERE id = ?").run(state, taskRef);
@@ -11653,8 +11734,8 @@ export class Store {
11653
11734
  })();
11654
11735
  const inserted = this.db
11655
11736
  .prepare(`INSERT INTO run (task_ref, lease_id, runner, branch, worktree, model, role, provider, parent_run, session_id, quality_mode,
11656
- started_at, review_attempt)
11657
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
11737
+ started_at, review_attempt, planning_generation)
11738
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, (SELECT planning_generation FROM task_ref WHERE id = ?1))`)
11658
11739
  .run(run.taskRef, run.leaseId, run.runner, run.branch ?? null, run.worktree ?? null, model, role, provider ?? "claude", run.parentRun ?? null, run.sessionId ?? null, qualityMode, run.now.toISOString(),
11659
11740
  // The root reviewer's ordinal (v50) rides the insert: a root and
11660
11741
  // its attempt number are one fact, and the ordinal index refuses a
@@ -12599,12 +12680,14 @@ export class Store {
12599
12680
  * reads in the order the questions arrived. "unanswered" is open + expired:
12600
12681
  * expiry makes a decision louder, never gone.
12601
12682
  */
12602
- /** The newest planner run's plan document for a task, when one exists. */
12683
+ /** The newest planner run's plan document for a task, when one exists in its current planning generation (v119:
12684
+ * a plan drafted for a scope since rewritten is history, read by its artifact id). */
12603
12685
  latestPlanArtifact(taskRef) {
12604
12686
  const row = this.db
12605
12687
  .prepare(`SELECT artifact.* FROM artifact
12606
12688
  JOIN run ON run.id = artifact.run
12607
12689
  WHERE run.task_ref = ? AND run.role = 'planner' AND artifact.kind = 'plan'
12690
+ AND run.planning_generation = (SELECT planning_generation FROM task_ref WHERE id = run.task_ref)
12608
12691
  ORDER BY artifact.id DESC LIMIT 1`)
12609
12692
  .get(taskRef);
12610
12693
  return row === undefined ? null : readArtifact(row);
@@ -12619,6 +12702,7 @@ export class Store {
12619
12702
  JOIN run ON run.id = artifact.run
12620
12703
  WHERE run.task_ref = ? AND run.role = 'planner' AND artifact.kind = 'plan-contract'
12621
12704
  AND artifact.key LIKE '%/plan-contract.json'
12705
+ AND run.planning_generation = (SELECT planning_generation FROM task_ref WHERE id = run.task_ref)
12622
12706
  ORDER BY artifact.id DESC LIMIT 1`)
12623
12707
  .get(taskRef);
12624
12708
  return row === undefined ? null : readArtifact(row);
@@ -12646,16 +12730,17 @@ export class Store {
12646
12730
  const inserted = this.db
12647
12731
  .prepare(`INSERT INTO plan_revision
12648
12732
  (task_ref, revision, artifact, parent_hash, reason, evidence_link, author, origin_run,
12649
- kind, authority_kind, authority_digest, changed_fields, status, created_at)
12650
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
12733
+ kind, authority_kind, authority_digest, changed_fields, status, created_at, planning_generation)
12734
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, (SELECT planning_generation FROM task_ref WHERE id = ?1))`)
12651
12735
  .run(revision.taskRef, revision.revision, revision.artifact, revision.parentHash, revision.reason, revision.evidenceLink, revision.author, revision.originRun, revision.kind, revision.authorityKind, revision.authorityDigest, JSON.stringify(revision.changedFields), revision.status, now.toISOString());
12652
12736
  return Number(inserted.lastInsertRowid);
12653
12737
  }
12654
- /** Every revision for a task, newest first — the immutable history a
12655
- * reader can show without a second table. */
12738
+ /** Every revision for a task in its current planning generation, newest
12739
+ * first — the immutable history a reader can show without a second table
12740
+ * (v119: an earlier generation's rows stay, read by id). */
12656
12741
  listPlanRevisions(taskRef) {
12657
12742
  return this.db
12658
- .prepare("SELECT * FROM plan_revision WHERE task_ref = ? ORDER BY revision DESC")
12743
+ .prepare("SELECT * FROM plan_revision WHERE task_ref = ? AND planning_generation = (SELECT planning_generation FROM task_ref WHERE id = plan_revision.task_ref) ORDER BY revision DESC")
12659
12744
  .all(taskRef)
12660
12745
  .map(readPlanRevision);
12661
12746
  }
@@ -12666,7 +12751,7 @@ export class Store {
12666
12751
  * `latestPlanArtifact`'s synthetic revision 1 in both cases. */
12667
12752
  currentPlanRevision(taskRef) {
12668
12753
  const row = this.db
12669
- .prepare("SELECT * FROM plan_revision WHERE task_ref = ? AND status = 'applied' ORDER BY revision DESC LIMIT 1")
12754
+ .prepare("SELECT * FROM plan_revision WHERE task_ref = ? AND planning_generation = (SELECT planning_generation FROM task_ref WHERE id = plan_revision.task_ref) AND status = 'applied' ORDER BY revision DESC LIMIT 1")
12670
12755
  .get(taskRef);
12671
12756
  return row === undefined ? null : readPlanRevision(row);
12672
12757
  }
@@ -12674,7 +12759,7 @@ export class Store {
12674
12759
  * 'blocked' awaiting an operator's decision — what a reader shows
12675
12760
  * alongside the current one to render a pending proposal at all. */
12676
12761
  latestPlanRevision(taskRef) {
12677
- const row = this.db.prepare("SELECT * FROM plan_revision WHERE task_ref = ? ORDER BY revision DESC LIMIT 1").get(taskRef);
12762
+ const row = this.db.prepare("SELECT * FROM plan_revision WHERE task_ref = ? AND planning_generation = (SELECT planning_generation FROM task_ref WHERE id = plan_revision.task_ref) ORDER BY revision DESC LIMIT 1").get(taskRef);
12678
12763
  return row === undefined ? null : readPlanRevision(row);
12679
12764
  }
12680
12765
  getPlanRevision(id) {
@@ -12762,7 +12847,8 @@ export class Store {
12762
12847
  .get(taskRef);
12763
12848
  return row === undefined ? null : readArtifact(row);
12764
12849
  }
12765
- /** Answered questions for one task, newest first — the planner's memory. */
12850
+ /** Answered questions for one task, newest first — the planner's memory. Only real answers from its current
12851
+ * planning generation (v119): a question closed unanswered, or asked about a scope since rewritten, is history. */
12766
12852
  answeredDecisionsFor(taskId, limit = 5) {
12767
12853
  return this.db
12768
12854
  .prepare(`SELECT decision.question, decision.choice, decision.note
@@ -12770,7 +12856,8 @@ export class Store {
12770
12856
  JOIN run ON run.id = decision.run
12771
12857
  JOIN task_ref ON task_ref.id = run.task_ref
12772
12858
  WHERE task_ref.backend = ? AND task_ref.external_id = ?
12773
- AND decision.answered_at IS NOT NULL
12859
+ AND decision.answered_at IS NOT NULL AND decision.superseded_reason IS NULL
12860
+ AND run.planning_generation = task_ref.planning_generation
12774
12861
  ORDER BY decision.answered_at DESC, decision.id DESC LIMIT ?`)
12775
12862
  .all(BUILT_IN, taskId, Math.max(1, Math.min(Math.floor(limit), 20)))
12776
12863
  .map(row => ({
@@ -12869,6 +12956,9 @@ export class Store {
12869
12956
  JOIN task_ref ON task_ref.id = run.task_ref
12870
12957
  WHERE run.task_ref = ? AND decision.state = 'answered'
12871
12958
  AND decision.contestant IS NULL
12959
+ -- v119: a question closed unanswered carries no answer, and one asked about a rewritten scope is history.
12960
+ AND decision.superseded_reason IS NULL
12961
+ AND run.planning_generation = task_ref.planning_generation
12872
12962
  AND NOT EXISTS (
12873
12963
  SELECT 1 FROM run_decision
12874
12964
  JOIN run AS delivered ON delivered.id = run_decision.run
@@ -13022,6 +13112,9 @@ export class Store {
13022
13112
  const existing = this.getDecision(answer.id);
13023
13113
  if (existing === null)
13024
13114
  return { ok: false, reason: "unknown-decision" };
13115
+ // A question closed without an answer (v119) stays closed: its task's scope changed or the task was cancelled.
13116
+ if (existing.supersededReason !== null)
13117
+ return { ok: false, reason: "closed" };
13025
13118
  if (!existing.options.some(option => option.id === answer.choice)) {
13026
13119
  return { ok: false, reason: "bad-option" };
13027
13120
  }
@@ -17443,6 +17536,7 @@ function readRun(row) {
17443
17536
  taskRef: Number(row["task_ref"]),
17444
17537
  leaseId: String(row["lease_id"]),
17445
17538
  runner: String(row["runner"]),
17539
+ planningGeneration: Number(row["planning_generation"] ?? 0),
17446
17540
  watchIncarnation: row["watch_incarnation"] == null ? null : String(row["watch_incarnation"]),
17447
17541
  reviewAttempt: wholeNumber(row["review_attempt"]),
17448
17542
  role: String(row["role"] ?? "builder"),
@@ -17575,6 +17669,7 @@ function readDecision(row) {
17575
17669
  answeredVia: row["answered_via"] === null ? null : String(row["answered_via"]),
17576
17670
  choice: row["choice"] === null ? null : String(row["choice"]),
17577
17671
  note: row["note"] === null ? null : String(row["note"]),
17672
+ supersededReason: row["superseded_reason"] === "scope-changed" || row["superseded_reason"] === "cancelled" ? row["superseded_reason"] : null,
17578
17673
  };
17579
17674
  }
17580
17675
  function readArtifact(row) {
@@ -17762,6 +17857,7 @@ function readTaskRef(row) {
17762
17857
  ? null
17763
17858
  : String(row["plan"]),
17764
17859
  planStrikes: Number(row["plan_strikes"] ?? 0),
17860
+ planningGeneration: Number(row["planning_generation"] ?? 0),
17765
17861
  deliverable: row["deliverable"] === "report" ? "report" : "branch",
17766
17862
  strikes: Number(row["strikes"] ?? 0),
17767
17863
  agentProvider: row["agent_provider"] === null || row["agent_provider"] === undefined
@@ -261,7 +261,9 @@ export const taskStatusOf = statusOf;
261
261
  * wait on a person stay Needs you; everything else waits in the queue. */
262
262
  const CODE_STAGE = {
263
263
  queued: ["queued"], ready: ["queued"], "worker-at-capacity": ["queued"], "retry-scheduled": ["queued"], updating: ["queued"],
264
- "waiting-dependency": ["queued"], "scouting-ready": ["queued"], "planning-ready": ["queued"], "provider-quota": ["queued"], "planner-source": ["queued"],
264
+ "waiting-dependency": ["queued"], "scouting-ready": ["queued"], "planning-ready": ["queued"], "provider-quota": ["queued"],
265
+ // A request the planner cannot be given (too long to hand back, say) waits on a person to shorten it.
266
+ "planner-source": ["needs-you", "fix-request"],
265
267
  running: ["building"], reviewing: ["reviewing"], "review-pending": ["building"],
266
268
  "signed-out": ["needs-you", "sign-in"],
267
269
  "needs-approval": ["needs-you", "approval"],
@@ -204,6 +204,12 @@ export function applyProposalTap(store, binding, token, message, repos, options,
204
204
  const who = verified.who;
205
205
  const preview = proposalPreview(store, proposal, actionRepos, "telegram");
206
206
  const origin = options.phoneOrigin?.() ?? null;
207
+ if (preview.closed) {
208
+ store.consumeTelegramProposalActions(proposal.id, now);
209
+ ack("This question is closed.");
210
+ edit(preview.text, []);
211
+ return { effects, confirmed: false, ignored: true };
212
+ }
207
213
  if (action.phase === "dismiss") {
208
214
  if (!store.consumeTelegramProposalAction(token, now)) {
209
215
  ack("that button was already used");
package/dist/telegram.js CHANGED
@@ -25,7 +25,7 @@ import { resultImageFileName, resultTaskLabel, verifyResultImage } from "./chat-
25
25
  import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
26
26
  import { chmodSync, readFileSync, rmSync, writeFileSync } from "node:fs";
27
27
  import { join } from "node:path";
28
- import { validateNote } from "./decision.js";
28
+ import { closedQuestionWords, validateNote } from "./decision.js";
29
29
  import { isShotsKind, resultShotsFor } from "./result-shots.js";
30
30
  import { isLifecycleNotification, isTelegramProgressNotification, RESULT_SHOTS_KIND, TELEGRAM_HOLD_REASONS } from "./store.js";
31
31
  import { telegramProgressCard } from "./telegram-progress.js";
@@ -1998,7 +1998,7 @@ function applyNote(context, update, effects) {
1998
1998
  return;
1999
1999
  }
2000
2000
  if (decision.state === "answered") {
2001
- say(`already answered: ${decision.choice ?? "?"} — this note did not travel`);
2001
+ say(`${decision.supersededReason !== null ? closedQuestionWords(decision.supersededReason) : `already answered: ${decision.choice ?? "?"}`} — this note did not travel`);
2002
2002
  report.ignored++;
2003
2003
  return;
2004
2004
  }
@@ -2344,8 +2344,8 @@ function applyCallback(context, update, effects) {
2344
2344
  }
2345
2345
  if (decision.state === "answered") {
2346
2346
  store.consumeTelegramAction(token, clock());
2347
- ack(`already answered: ${decision.choice ?? "?"}`);
2348
- editText(answeredText(store, decision));
2347
+ ack(decision.supersededReason !== null ? closedQuestionWords(decision.supersededReason) : `already answered: ${decision.choice ?? "?"}`);
2348
+ editText(answeredText(store, decision), []);
2349
2349
  return;
2350
2350
  }
2351
2351
  if (action.phase === "choose") {
@@ -2451,13 +2451,13 @@ function answerNow(context, decision, choice, binding, ack, editText) {
2451
2451
  editText(answeredText(store, answered.decision));
2452
2452
  return;
2453
2453
  }
2454
- if (answered.reason === "already-answered") {
2454
+ if (answered.reason === "already-answered" || answered.reason === "closed") {
2455
2455
  if (draft !== null)
2456
2456
  store.setNoteDraftState(draft.id, "discarded");
2457
2457
  const settled = store.getDecision(decision.id);
2458
- ack(`already answered: ${settled?.choice ?? "?"}${draft === null ? "" : " — your note did NOT travel"}`);
2458
+ ack(`${settled?.supersededReason != null ? closedQuestionWords(settled.supersededReason) : `already answered: ${settled?.choice ?? "?"}`}${draft === null ? "" : " — your note did NOT travel"}`);
2459
2459
  if (settled !== null)
2460
- editText(answeredText(store, settled));
2460
+ editText(answeredText(store, settled), []);
2461
2461
  return;
2462
2462
  }
2463
2463
  ack(`could not answer: ${answered.reason}`);
@@ -2479,6 +2479,8 @@ function armedDecisionText(option, note) {
2479
2479
  ].join("\n");
2480
2480
  }
2481
2481
  function answeredText(store, decision) {
2482
+ if (decision.supersededReason !== null)
2483
+ return [closedQuestionWords(decision.supersededReason), taskOf(store, decision)].join("\n");
2482
2484
  const chosen = decision.options.find(one => one.id === decision.choice)?.label ?? decision.choice ?? "?";
2483
2485
  return [
2484
2486
  `✓ Answered: ${chosen}`,
@@ -35,7 +35,7 @@ import { processMayBeAlive } from "./process-liveness.js";
35
35
  import { assertCodingUpdateStopped, backupCodingCatalog, codingCatalogExists, releaseStaleCodingOwner, removeCodingUpdateGate } from "./coding-update.js";
36
36
  import { installLaunchdService, launchdPlist, stopLaunchdService, writeFileDurably } from "./daemon.js";
37
37
  import { NAME } from "./names.js";
38
- import { orphanKey, updateSafeSchema, V117_RENAMED_COLUMNS, V117_RENAMED_TABLES, V118_MERGED_TABLES } from "./store.js";
38
+ import { orphanKey, updateSafeSchema, V117_RENAMED_COLUMNS, V117_RENAMED_TABLES, V118_MERGED_TABLES, V119_ADDED_COLUMNS } from "./store.js";
39
39
  import { isNewer, REGISTRY } from "./releases.js";
40
40
  import { markNeverIndex } from "./never-index.js";
41
41
  import { readRuntimeUpdateJournal, RUNTIME_PHASES, RUNTIME_UPDATE_STEPS, stagedStartedAt, updaterStartingOf } from "./contracts/update-journal.js";
@@ -384,6 +384,9 @@ const HISTORY_RULES = {
384
384
  ...Object.fromEntries(V117_RENAMED_COLUMNS.filter(([table]) => !Object.values(V117_RENAMED_TABLES).includes(table)).map(([table, from, to]) => [table, { renamed: { [from]: to } }])),
385
385
  // v118 (D7): every API token, coordinator and lead token arrives in the one credential table under its kind.
386
386
  ...Object.fromEntries(Object.entries(V118_MERGED_TABLES).map(([table, kind]) => [table, { moved: { into: "credential", where: `kind = '${kind}'` } }])),
387
+ // v119: planning generations and a question's closure reason are new columns; every row saved before them reads
388
+ // the declared value (generation 0, no closure reason).
389
+ ...Object.fromEntries(Object.entries(V119_ADDED_COLUMNS).map(([table, columns]) => [table, { added: Object.fromEntries(Object.entries(columns).map(([column, { saved }]) => [column, saved])) }])),
387
390
  };
388
391
  /** The tables a migration may remove whole (v115's removed features). */
389
392
  export const RETIRED_TABLES = Object.freeze(Object.keys(HISTORY_RULES).filter(name => HISTORY_RULES[name].retired));
@@ -537,6 +540,10 @@ export function changedHistory(db, before) {
537
540
  if (t.columns.some(c => !kept(c) && (!rule?.dropped?.includes(c) || (t.nonNull?.[c] !== 0 && !accounted(rule, t)))))
538
541
  return true;
539
542
  const columns = t.columns.filter(c => present.has(c));
543
+ // A declared new column: every row saved before it reads its declared value.
544
+ const added = Object.entries(rule?.added ?? {}).filter(([c]) => !t.columns.includes(c) && present.has(c));
545
+ if (t.rowid && added.some(([c, saved]) => Number(db.prepare("SELECT count(*) AS n FROM " + quote(t.name) + " WHERE rowid <= ? AND " + quote(c) + " IS NOT " + saved).get(t.last)["n"]) > 0))
546
+ return true;
540
547
  if (!t.rowid) {
541
548
  const after = rowsHash(db, t.name, columns);
542
549
  return after.count !== t.count || after.hash !== t.hash;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolroll",
3
- "version": "0.9.58",
3
+ "version": "0.9.59",
4
4
  "description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
5
5
  "directories": {
6
6
  "doc": "docs"