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/store.js CHANGED
@@ -559,7 +559,12 @@ 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
+ // v120 lets a running build read steering: a note the pulse put in the build's steering folder (task_steer.mailed_run,
566
+ // mailed_at) and the moment the agent moved it to handled/ (acked_at).
567
+ export const SCHEMA_VERSION = 120;
563
568
  /** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
564
569
  export const V118_MERGED_TABLES = Object.freeze({ api_token: "person", coordinator_credential: "coordinator", lead_credential: "lead" });
565
570
  /** The foreign keys that named a merged table, exactly as their DDL wrote them; each names `credential(id)` instead. */
@@ -680,9 +685,31 @@ const V115_DROPPED_REFERENCES = /\s+REFERENCES (?:contestant|contest|tournament_
680
685
  * 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
686
  * each renamed table's rows arrived under its new name. v118 (D7) is checked the same way: every row of api_token,
682
687
  * 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.
688
+ * before the old tables drop, and the references that named them name credential in place, their rows untouched. v119 only adds four columns
689
+ * (planning_generation on task_ref, run and plan_revision, defaulting to 0, and decision.superseded_reason, null);
690
+ * the rehearsal checks every saved row reads the declared default (HISTORY_RULES' added columns). v120 only adds
691
+ * three nullable task_steer columns (the running build's steering folder), checked the same way.
684
692
  */
685
- export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118]);
693
+ export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120]);
694
+ /** v119: the columns it adds, by table, with the value every row saved before it reads (toolroll-update's HISTORY_RULES). */
695
+ export const V119_ADDED_COLUMNS = Object.freeze({
696
+ task_ref: { planning_generation: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" } },
697
+ run: { planning_generation: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" } },
698
+ plan_revision: { planning_generation: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" } },
699
+ decision: { superseded_reason: { definition: "TEXT CHECK (superseded_reason IN ('scope-changed','cancelled'))", saved: "NULL" } },
700
+ });
701
+ /** v120: the columns it adds, by table, with the value every row saved before it reads. */
702
+ export const V120_ADDED_COLUMNS = Object.freeze({
703
+ task_steer: {
704
+ mailed_run: { definition: "INTEGER REFERENCES run(id)", saved: "NULL" },
705
+ mailed_at: { definition: "TEXT", saved: "NULL" },
706
+ acked_at: { definition: "TEXT", saved: "NULL" },
707
+ },
708
+ run: {
709
+ steer_opened_at: { definition: "TEXT", saved: "NULL" },
710
+ steer_closed_at: { definition: "TEXT", saved: "NULL" },
711
+ },
712
+ });
686
713
  /** Whether a database settled at `version` reaches this build's schema through update-safe migrations alone. */
687
714
  export function updateSafeSchema(version) {
688
715
  if (version === null || !Number.isSafeInteger(version) || version <= 0 || version > SCHEMA_VERSION)
@@ -2878,6 +2905,9 @@ CREATE TABLE IF NOT EXISTS review_request (
2878
2905
  -- rode toward; delivered_at settles only on the stream's own receipt (the
2879
2906
  -- agent's first words, or a successful completion). At-least-once, honestly
2880
2907
  -- labeled. Superseded means the task ended with the note still pending.
2908
+ -- v120 adds mailed_run, mailed_at and acked_at (V120_ADDED_COLUMNS): a note
2909
+ -- filed while a build runs is written into its steering folder, and is read
2910
+ -- (and delivered) only when that agent moves it to handled/.
2881
2911
  CREATE TABLE IF NOT EXISTS task_steer (
2882
2912
  id INTEGER PRIMARY KEY AUTOINCREMENT,
2883
2913
  task_ref INTEGER NOT NULL REFERENCES task_ref(id) ON DELETE CASCADE,
@@ -3474,6 +3504,16 @@ function initializeStore(db, file) {
3474
3504
  // v115: the task each standing-order firing filed, kept on the firing itself (a card's task changes as it moves).
3475
3505
  addColumn(db, "flow_trigger_event", "task", "TEXT");
3476
3506
  db.exec(PLAN_AUTO_SCHEMA);
3507
+ // v119: planning generations, and why a question closed unanswered (additive only; saved rows read the defaults).
3508
+ for (const [table, columns] of Object.entries(V119_ADDED_COLUMNS)) {
3509
+ for (const [column, { definition }] of Object.entries(columns))
3510
+ addColumn(db, table, column, definition);
3511
+ }
3512
+ // v120: a running build's steering folder (additive only; saved notes read as never mailed).
3513
+ for (const [table, columns] of Object.entries(V120_ADDED_COLUMNS)) {
3514
+ for (const [column, { definition }] of Object.entries(columns))
3515
+ addColumn(db, table, column, definition);
3516
+ }
3477
3517
  installLedgerTriggers(db);
3478
3518
  // Attention/history indexes come AFTER migration: on a database whose
3479
3519
  // constrained tables still carry a pre-rebuild shape, creating a partial
@@ -4796,6 +4836,10 @@ export class Store {
4796
4836
  // just the state verb's.
4797
4837
  if (ref !== undefined)
4798
4838
  this.supersedeSteerNotes(Number(ref["id"]), now);
4839
+ // Its open questions close the same way (v119), typed 'cancelled', with their holds and pages: a question about
4840
+ // work that will never run must leave every decision queue. Answered ones stay exactly as they were.
4841
+ if (ref !== undefined)
4842
+ this.closeQuestionsLocked(Number(ref["id"]), "cancelled", now);
4799
4843
  // The reason is part of the transition, not decoration: a
4800
4844
  // coordinator-filed task's dismissal writes its durable event IN THIS
4801
4845
  // transaction — operator text and typed machine codes alike (MCP spec
@@ -9942,6 +9986,66 @@ export class Store {
9942
9986
  return { ok: true };
9943
9987
  });
9944
9988
  }
9989
+ /**
9990
+ * A rewritten scope starts planning again (v119), in one transaction: the task's planning generation advances, a
9991
+ * requested or drafted plan becomes requested with no strikes, and what the old goal produced leaves the active
9992
+ * planning context — its open questions close (typed 'scope-changed', never a fake answer, their holds and pages
9993
+ * with them) and a revision still waiting for a person is rejected with its hold. The old runs, plans, revisions,
9994
+ * answers and ledger rows all stay: generation-bound reads simply stop offering them to the next planner. Only a
9995
+ * task already planning restarts; false (nothing changed) for any other.
9996
+ */
9997
+ restartPlanning(taskRef, now) {
9998
+ return this.transact(() => {
9999
+ const moved = this.db
10000
+ .prepare("UPDATE task_ref SET planning_generation = planning_generation + 1, plan = 'requested', plan_strikes = 0 WHERE id = ? AND plan IN ('requested','drafted')")
10001
+ .run(taskRef);
10002
+ if (Number(moved.changes) === 0)
10003
+ return false;
10004
+ const ref = this.db.prepare("SELECT external_id, repo, planning_generation FROM task_ref WHERE id = ?").get(taskRef);
10005
+ const generation = Number(ref["planning_generation"]);
10006
+ this.closeQuestionsLocked(taskRef, "scope-changed", now, generation);
10007
+ const blocked = this.db
10008
+ .prepare("SELECT id FROM plan_revision WHERE task_ref = ? AND status = 'blocked' AND planning_generation < ?")
10009
+ .all(taskRef, generation)
10010
+ .map(row => Number(row["id"]));
10011
+ for (const id of blocked) {
10012
+ this.resolvePlanRevision(id, "rejected", "toolroll", now);
10013
+ this.releaseOwnedHold("revision", String(id));
10014
+ }
10015
+ this.recordAction({
10016
+ at: now.toISOString(), actor: currentActor() === null ? "system" : actorLabel(currentActor()), repo: ref["repo"] === null ? null : String(ref["repo"]),
10017
+ taskId: String(ref["external_id"]), runId: null, action: "planning restarted", outcome: "scope changed", source: "work",
10018
+ });
10019
+ this.bumpWake();
10020
+ return true;
10021
+ });
10022
+ }
10023
+ /**
10024
+ * Close a task's open and expired questions without answering them (v119): typed with why, no choice, their
10025
+ * holds lifted and their pages resolved, so they leave every decision queue. Answered questions are never touched.
10026
+ * `belowGeneration` limits it to questions asked in earlier planning generations. Caller holds the transaction.
10027
+ */
10028
+ closeQuestionsLocked(taskRef, reason, now, belowGeneration) {
10029
+ const ids = this.db
10030
+ .prepare(`SELECT decision.id FROM decision JOIN run ON run.id = decision.run
10031
+ WHERE run.task_ref = ? AND decision.state IN ('open','expired') AND (? IS NULL OR run.planning_generation < ?)
10032
+ ORDER BY decision.id`)
10033
+ .all(taskRef, belowGeneration ?? null, belowGeneration ?? null)
10034
+ .map(row => Number(row["id"]));
10035
+ const close = this.db.prepare("UPDATE decision SET state = 'answered', answered_at = ?, answered_by = 'toolroll', superseded_reason = ? WHERE id = ? AND state IN ('open','expired')");
10036
+ for (const id of ids) {
10037
+ close.run(now.toISOString(), reason, id);
10038
+ this.releaseOwnedHold("decision", String(id));
10039
+ this.resolveEpisode(`decision:${id}`, now);
10040
+ }
10041
+ return ids;
10042
+ }
10043
+ /** The number the next plan revision of a task takes: one past every revision it ever had, in any planning
10044
+ * generation, so a restarted plan's revisions never collide with the history they follow. */
10045
+ nextPlanRevisionNumber(taskRef) {
10046
+ const row = this.db.prepare("SELECT coalesce(max(revision), 0) AS n FROM plan_revision WHERE task_ref = ?").get(taskRef);
10047
+ return Number(row?.["n"] ?? 0) + 1;
10048
+ }
9945
10049
  /** Move the planning state; null clears it. Caller owns the transaction story. */
9946
10050
  setPlanState(taskRef, state) {
9947
10051
  this.db.prepare("UPDATE task_ref SET plan = ? WHERE id = ?").run(state, taskRef);
@@ -11653,8 +11757,8 @@ export class Store {
11653
11757
  })();
11654
11758
  const inserted = this.db
11655
11759
  .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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
11760
+ started_at, review_attempt, planning_generation)
11761
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, (SELECT planning_generation FROM task_ref WHERE id = ?1))`)
11658
11762
  .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
11763
  // The root reviewer's ordinal (v50) rides the insert: a root and
11660
11764
  // its attempt number are one fact, and the ordinal index refuses a
@@ -12599,12 +12703,14 @@ export class Store {
12599
12703
  * reads in the order the questions arrived. "unanswered" is open + expired:
12600
12704
  * expiry makes a decision louder, never gone.
12601
12705
  */
12602
- /** The newest planner run's plan document for a task, when one exists. */
12706
+ /** The newest planner run's plan document for a task, when one exists in its current planning generation (v119:
12707
+ * a plan drafted for a scope since rewritten is history, read by its artifact id). */
12603
12708
  latestPlanArtifact(taskRef) {
12604
12709
  const row = this.db
12605
12710
  .prepare(`SELECT artifact.* FROM artifact
12606
12711
  JOIN run ON run.id = artifact.run
12607
12712
  WHERE run.task_ref = ? AND run.role = 'planner' AND artifact.kind = 'plan'
12713
+ AND run.planning_generation = (SELECT planning_generation FROM task_ref WHERE id = run.task_ref)
12608
12714
  ORDER BY artifact.id DESC LIMIT 1`)
12609
12715
  .get(taskRef);
12610
12716
  return row === undefined ? null : readArtifact(row);
@@ -12619,6 +12725,7 @@ export class Store {
12619
12725
  JOIN run ON run.id = artifact.run
12620
12726
  WHERE run.task_ref = ? AND run.role = 'planner' AND artifact.kind = 'plan-contract'
12621
12727
  AND artifact.key LIKE '%/plan-contract.json'
12728
+ AND run.planning_generation = (SELECT planning_generation FROM task_ref WHERE id = run.task_ref)
12622
12729
  ORDER BY artifact.id DESC LIMIT 1`)
12623
12730
  .get(taskRef);
12624
12731
  return row === undefined ? null : readArtifact(row);
@@ -12646,16 +12753,17 @@ export class Store {
12646
12753
  const inserted = this.db
12647
12754
  .prepare(`INSERT INTO plan_revision
12648
12755
  (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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
12756
+ kind, authority_kind, authority_digest, changed_fields, status, created_at, planning_generation)
12757
+ 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
12758
  .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
12759
  return Number(inserted.lastInsertRowid);
12653
12760
  }
12654
- /** Every revision for a task, newest first — the immutable history a
12655
- * reader can show without a second table. */
12761
+ /** Every revision for a task in its current planning generation, newest
12762
+ * first — the immutable history a reader can show without a second table
12763
+ * (v119: an earlier generation's rows stay, read by id). */
12656
12764
  listPlanRevisions(taskRef) {
12657
12765
  return this.db
12658
- .prepare("SELECT * FROM plan_revision WHERE task_ref = ? ORDER BY revision DESC")
12766
+ .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
12767
  .all(taskRef)
12660
12768
  .map(readPlanRevision);
12661
12769
  }
@@ -12666,7 +12774,7 @@ export class Store {
12666
12774
  * `latestPlanArtifact`'s synthetic revision 1 in both cases. */
12667
12775
  currentPlanRevision(taskRef) {
12668
12776
  const row = this.db
12669
- .prepare("SELECT * FROM plan_revision WHERE task_ref = ? AND status = 'applied' ORDER BY revision DESC LIMIT 1")
12777
+ .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
12778
  .get(taskRef);
12671
12779
  return row === undefined ? null : readPlanRevision(row);
12672
12780
  }
@@ -12674,7 +12782,7 @@ export class Store {
12674
12782
  * 'blocked' awaiting an operator's decision — what a reader shows
12675
12783
  * alongside the current one to render a pending proposal at all. */
12676
12784
  latestPlanRevision(taskRef) {
12677
- const row = this.db.prepare("SELECT * FROM plan_revision WHERE task_ref = ? ORDER BY revision DESC LIMIT 1").get(taskRef);
12785
+ 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
12786
  return row === undefined ? null : readPlanRevision(row);
12679
12787
  }
12680
12788
  getPlanRevision(id) {
@@ -12762,7 +12870,8 @@ export class Store {
12762
12870
  .get(taskRef);
12763
12871
  return row === undefined ? null : readArtifact(row);
12764
12872
  }
12765
- /** Answered questions for one task, newest first — the planner's memory. */
12873
+ /** Answered questions for one task, newest first — the planner's memory. Only real answers from its current
12874
+ * planning generation (v119): a question closed unanswered, or asked about a scope since rewritten, is history. */
12766
12875
  answeredDecisionsFor(taskId, limit = 5) {
12767
12876
  return this.db
12768
12877
  .prepare(`SELECT decision.question, decision.choice, decision.note
@@ -12770,7 +12879,8 @@ export class Store {
12770
12879
  JOIN run ON run.id = decision.run
12771
12880
  JOIN task_ref ON task_ref.id = run.task_ref
12772
12881
  WHERE task_ref.backend = ? AND task_ref.external_id = ?
12773
- AND decision.answered_at IS NOT NULL
12882
+ AND decision.answered_at IS NOT NULL AND decision.superseded_reason IS NULL
12883
+ AND run.planning_generation = task_ref.planning_generation
12774
12884
  ORDER BY decision.answered_at DESC, decision.id DESC LIMIT ?`)
12775
12885
  .all(BUILT_IN, taskId, Math.max(1, Math.min(Math.floor(limit), 20)))
12776
12886
  .map(row => ({
@@ -12869,6 +12979,9 @@ export class Store {
12869
12979
  JOIN task_ref ON task_ref.id = run.task_ref
12870
12980
  WHERE run.task_ref = ? AND decision.state = 'answered'
12871
12981
  AND decision.contestant IS NULL
12982
+ -- v119: a question closed unanswered carries no answer, and one asked about a rewritten scope is history.
12983
+ AND decision.superseded_reason IS NULL
12984
+ AND run.planning_generation = task_ref.planning_generation
12872
12985
  AND NOT EXISTS (
12873
12986
  SELECT 1 FROM run_decision
12874
12987
  JOIN run AS delivered ON delivered.id = run_decision.run
@@ -12977,6 +13090,72 @@ export class Store {
12977
13090
  .run(now.toISOString(), runId);
12978
13091
  return Number(changes);
12979
13092
  }
13093
+ /**
13094
+ * The task's build whose agent is running right now and reads its steering
13095
+ * folder (v120): the current live lease's open builder run, in its
13096
+ * agent-running phase, whose steering folder is open. Null otherwise — a
13097
+ * note then waits for the next attempt. Display state, never evidence.
13098
+ */
13099
+ steeringLiveBuild(taskRef, now) {
13100
+ const live = this.currentLiveLease(taskRef, now);
13101
+ if (live === null)
13102
+ return null;
13103
+ const row = this.db
13104
+ .prepare(`SELECT id FROM run WHERE task_ref = ? AND lease_id = ? AND role = 'builder'
13105
+ AND outcome IS NULL AND phase = 'agent-running' AND steer_opened_at IS NOT NULL AND steer_closed_at IS NULL
13106
+ ORDER BY id DESC LIMIT 1`)
13107
+ .get(taskRef, live);
13108
+ return row === undefined ? null : Number(row["id"]);
13109
+ }
13110
+ /** This run made its steering folder (v120): its agent can read a note filed now. */
13111
+ markSteerFolderOpened(runId, now) {
13112
+ this.db.prepare("UPDATE run SET steer_opened_at = ? WHERE id = ? AND outcome IS NULL AND steer_opened_at IS NULL").run(now.toISOString(), runId);
13113
+ }
13114
+ /** This run's steering folder is gone (v120): a note filed now reaches the next attempt. */
13115
+ markSteerFolderClosed(runId, now) {
13116
+ this.db.prepare("UPDATE run SET steer_closed_at = ? WHERE id = ? AND steer_opened_at IS NOT NULL AND steer_closed_at IS NULL").run(now.toISOString(), runId);
13117
+ }
13118
+ /**
13119
+ * The notes a RUNNING build may still read (v120): verified, pending, not
13120
+ * already quoted in this run's brief, and not already in its folder. The
13121
+ * pulse mails exactly these, in filing order.
13122
+ */
13123
+ mailableSteerNotes(taskRef, runId) {
13124
+ return this.db
13125
+ .prepare(`SELECT * FROM task_steer
13126
+ WHERE task_ref = ? AND authorship_state = 'verified'
13127
+ AND delivered_at IS NULL AND superseded_at IS NULL
13128
+ AND (attached_run IS NULL OR attached_run <> ?)
13129
+ AND (mailed_run IS NULL OR mailed_run <> ?)
13130
+ ORDER BY id`)
13131
+ .all(taskRef, runId, runId)
13132
+ .map(readSteerNote);
13133
+ }
13134
+ /** The pulse wrote this note into the run's steering folder. Mailing is not
13135
+ * reading: delivery settles only on the agent's own acknowledgement. */
13136
+ markSteerMailed(noteId, runId, now) {
13137
+ const { changes } = this.db
13138
+ .prepare(`UPDATE task_steer SET mailed_run = ?, mailed_at = ?
13139
+ WHERE id = ? AND delivered_at IS NULL AND superseded_at IS NULL`)
13140
+ .run(runId, now.toISOString(), noteId);
13141
+ return Number(changes) > 0;
13142
+ }
13143
+ /**
13144
+ * The agent moved this note to handled/ (v120): it was read, by THIS run.
13145
+ * Acknowledgement is the only road from mailed to delivered — a hook firing
13146
+ * or a file written proves nothing. First acknowledgement wins; a note the
13147
+ * task already ended, or mailed to another run since, is left alone.
13148
+ */
13149
+ ackSteerNote(noteId, runId, now) {
13150
+ const stamp = now.toISOString();
13151
+ const { changes } = this.db
13152
+ .prepare(`UPDATE task_steer SET acked_at = ?, delivered_at = ?, attached_run = ?, attached_at = COALESCE(mailed_at, ?)
13153
+ WHERE id = ? AND mailed_run = ? AND acked_at IS NULL AND delivered_at IS NULL AND superseded_at IS NULL`)
13154
+ .run(stamp, stamp, runId, stamp, noteId, runId);
13155
+ if (Number(changes) > 0)
13156
+ this.bumpWake();
13157
+ return Number(changes) > 0;
13158
+ }
12980
13159
  /** A finished task's pending notes settle superseded — shown, typed, done. */
12981
13160
  supersedeSteerNotes(taskRef, now) {
12982
13161
  const { changes } = this.db
@@ -13022,6 +13201,9 @@ export class Store {
13022
13201
  const existing = this.getDecision(answer.id);
13023
13202
  if (existing === null)
13024
13203
  return { ok: false, reason: "unknown-decision" };
13204
+ // A question closed without an answer (v119) stays closed: its task's scope changed or the task was cancelled.
13205
+ if (existing.supersededReason !== null)
13206
+ return { ok: false, reason: "closed" };
13025
13207
  if (!existing.options.some(option => option.id === answer.choice)) {
13026
13208
  return { ok: false, reason: "bad-option" };
13027
13209
  }
@@ -17443,6 +17625,7 @@ function readRun(row) {
17443
17625
  taskRef: Number(row["task_ref"]),
17444
17626
  leaseId: String(row["lease_id"]),
17445
17627
  runner: String(row["runner"]),
17628
+ planningGeneration: Number(row["planning_generation"] ?? 0),
17446
17629
  watchIncarnation: row["watch_incarnation"] == null ? null : String(row["watch_incarnation"]),
17447
17630
  reviewAttempt: wholeNumber(row["review_attempt"]),
17448
17631
  role: String(row["role"] ?? "builder"),
@@ -17555,6 +17738,9 @@ function readSteerNote(row) {
17555
17738
  supersededAt: row["superseded_at"] === null ? null : String(row["superseded_at"]),
17556
17739
  authorshipState: row["authorship_state"] === "verified" ? "verified" : "unverified-legacy",
17557
17740
  supersededReason: row["superseded_reason"] === null || row["superseded_reason"] === undefined ? null : String(row["superseded_reason"]),
17741
+ mailedRun: row["mailed_run"] === null || row["mailed_run"] === undefined ? null : Number(row["mailed_run"]),
17742
+ mailedAt: row["mailed_at"] === null || row["mailed_at"] === undefined ? null : String(row["mailed_at"]),
17743
+ ackedAt: row["acked_at"] === null || row["acked_at"] === undefined ? null : String(row["acked_at"]),
17558
17744
  };
17559
17745
  }
17560
17746
  function readDecision(row) {
@@ -17575,6 +17761,7 @@ function readDecision(row) {
17575
17761
  answeredVia: row["answered_via"] === null ? null : String(row["answered_via"]),
17576
17762
  choice: row["choice"] === null ? null : String(row["choice"]),
17577
17763
  note: row["note"] === null ? null : String(row["note"]),
17764
+ supersededReason: row["superseded_reason"] === "scope-changed" || row["superseded_reason"] === "cancelled" ? row["superseded_reason"] : null,
17578
17765
  };
17579
17766
  }
17580
17767
  function readArtifact(row) {
@@ -17762,6 +17949,7 @@ function readTaskRef(row) {
17762
17949
  ? null
17763
17950
  : String(row["plan"]),
17764
17951
  planStrikes: Number(row["plan_strikes"] ?? 0),
17952
+ planningGeneration: Number(row["planning_generation"] ?? 0),
17765
17953
  deliverable: row["deliverable"] === "report" ? "report" : "branch",
17766
17954
  strikes: Number(row["strikes"] ?? 0),
17767
17955
  agentProvider: row["agent_provider"] === null || row["agent_provider"] === undefined
package/dist/surface.js CHANGED
@@ -201,7 +201,7 @@ const GUIDE_ROWS = [
201
201
  ...["show", "updates", "claim", "check", "brief", "inbox", "ack"].map(action => ({
202
202
  invocation: `assignment ${action}`,
203
203
  synopsis: action === "show" ? "read the root, current work, owner and exact receipt"
204
- : action === "updates" ? "read durable updates after a cursor; save nextCursor after processing"
204
+ : action === "updates" ? "read the history of handoffs sent to people after a cursor; delivers nothing — receive updates with assignment inbox"
205
205
  : action === "claim" ? "record lead ownership; with the lead token the task reads \"<the lead's name> is on it\" and leaves Needs you until done, handed over, or 2 hours idle; grants no approval or execution authority"
206
206
  : action === "brief" ? "catch up from current assignments and project knowledge in the local database"
207
207
  : action === "inbox" ? "receive or replay a saved batch of lead status updates"
@@ -242,7 +242,7 @@ const GUIDE_ROWS = [
242
242
  positionals: [{ name: "id", required: true, meaning: "the task" }],
243
243
  flags: [jsonFlag, dbFlag, keyFlag, { name: "undo", takesValue: false, meaning: "back to filing order" }],
244
244
  notableReasons: ["unknown-task", "claimed"] },
245
- operator("task steer", "one credentialed note read before the task's next attempt — steering speaks with the operator's voice"),
245
+ operator("task steer", "one credentialed note for the task's agent — a running build reads it when it next checks for notes, otherwise the next attempt does; steering speaks with the operator's voice"),
246
246
  { invocation: "task assign", synopsis: "reserve a task for one worker, or return it to the shared queue", audience: "agent", agentMayInvoke: true, mutation: "keyed",
247
247
  positionals: [{ name: "id", required: true, meaning: "the task" }],
248
248
  flags: [jsonFlag, dbFlag, keyFlag,
@@ -0,0 +1,10 @@
1
+ /** Assignment sweeps re-project every owned root on each pass. A pass is
2
+ * skipped when nothing has moved since the last full cycle began: the store's
3
+ * wake sequence is unchanged and that cycle started under a minute ago. The
4
+ * minute covers changes that don't bump wake (time-based lease expiry, some
5
+ * ledger-only writes). A cycle in progress (its cursor past 0) always goes on,
6
+ * and a restarted process sweeps at once. */
7
+ import type { Store } from "./store.js";
8
+ export declare const SWEEP_FALLBACK_MS = 60000;
9
+ /** Runs `sweep` unless the cycle under `cursorKey` would see what its last start saw. Returns whether it ran. */
10
+ export declare function sweepIfMoved(store: Store, cursorKey: string, now: Date, sweep: () => void): boolean;
@@ -0,0 +1,18 @@
1
+ export const SWEEP_FALLBACK_MS = 60_000;
2
+ const cycles = new WeakMap();
3
+ /** Runs `sweep` unless the cycle under `cursorKey` would see what its last start saw. Returns whether it ran. */
4
+ export function sweepIfMoved(store, cursorKey, now, sweep) {
5
+ const seq = store.wakeSeq();
6
+ const startsCycle = store.serviceCursor(cursorKey) === 0;
7
+ const seen = cycles.get(store) ?? new Map();
8
+ cycles.set(store, seen);
9
+ const last = seen.get(cursorKey);
10
+ const elapsed = last === undefined ? Infinity : now.getTime() - last.at;
11
+ if (startsCycle && last?.seq === seq && elapsed >= 0 && elapsed < SWEEP_FALLBACK_MS)
12
+ return false;
13
+ sweep();
14
+ // Recorded only once the pass returns: a pass that threw is retried next time.
15
+ if (startsCycle)
16
+ seen.set(cursorKey, { seq, at: now.getTime() });
17
+ return true;
18
+ }
@@ -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"],
@@ -1,5 +1,5 @@
1
1
  import { TeamLeads } from './team-leads.js';
2
- import { ASSIGNMENT_STATUS_ACTION, parseAssignmentStatusEvent } from './assignment-status.js';
2
+ import { ASSIGNMENT_STATUS_ACTION, LEAD_WAKE_STATES, parseAssignmentStatusEvent } from './assignment-status.js';
3
3
  export const teamUpdateCursor = (conversation) => `team-updates:${conversation}`;
4
4
  export function startTeamUpdates(store, conversation, now) {
5
5
  // Enabling updates starts here. Past events remain available in task history.
@@ -33,10 +33,10 @@ export function deliverTeamUpdates(store, domain, authorized, now) {
33
33
  for (const row of rows) {
34
34
  const event = parseAssignmentStatusEvent(String(row['outcome']));
35
35
  // Keep an unreadable row pending instead of silently losing delivery.
36
- if (!event || event.assignment.rootId !== row['task_id'] || event.assignment.owner.kind !== 'lead' || event.assignment.owner.id !== current.lead.id)
36
+ if (!event || event.assignment.rootId !== row['task_id'] || event.assignment.owner?.kind !== 'lead' || event.assignment.owner.id !== current.lead.id)
37
37
  break;
38
38
  through = Number(row['id']);
39
- if (!['ready-to-check', 'needs-decision', 'complete', 'cancelled'].includes(event.assignment.state))
39
+ if (!LEAD_WAKE_STATES.includes(event.assignment.state))
40
40
  continue;
41
41
  changes.push(`${event.assignment.rootId}: ${event.assignment.title.slice(0, 100)} — ${event.assignment.state}. ${event.assignment.detail.slice(0, 180)}`);
42
42
  if (changes.length === 4)
@@ -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, V120_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";
@@ -363,6 +363,16 @@ export const CHAT_MOVES = {
363
363
  ...Object.fromEntries(Object.entries(TELEGRAM_MOVES).map(([table, move]) => [table, { ...move, where: `provider = 'telegram' AND (${move.where})` }])),
364
364
  ...Object.fromEntries(["slack", "discord", "teams"].flatMap(app => APP_TABLES.map(suffix => [`${app}_${suffix}`, { into: `chat_${suffix}`, where: `provider = '${app}'` }]))),
365
365
  };
366
+ /** Each table's added columns across versions, merged: a table two versions both extend keeps every column. */
367
+ function addedColumns(...versions) {
368
+ const rules = {};
369
+ for (const version of versions) {
370
+ for (const [table, columns] of Object.entries(version)) {
371
+ rules[table] = { added: { ...rules[table]?.added, ...Object.fromEntries(Object.entries(columns).map(([column, { saved }]) => [column, saved])) } };
372
+ }
373
+ }
374
+ return rules;
375
+ }
366
376
  const HISTORY_RULES = {
367
377
  ...Object.fromEntries(Object.entries(CHAT_MOVES).map(([table, moved]) => [table, { moved }])),
368
378
  action_ledger: { appendOnly: true }, ledger_seal: { appendOnly: true }, ledger_checkpoint: { appendOnly: true },
@@ -384,6 +394,11 @@ const HISTORY_RULES = {
384
394
  ...Object.fromEntries(V117_RENAMED_COLUMNS.filter(([table]) => !Object.values(V117_RENAMED_TABLES).includes(table)).map(([table, from, to]) => [table, { renamed: { [from]: to } }])),
385
395
  // v118 (D7): every API token, coordinator and lead token arrives in the one credential table under its kind.
386
396
  ...Object.fromEntries(Object.entries(V118_MERGED_TABLES).map(([table, kind]) => [table, { moved: { into: "credential", where: `kind = '${kind}'` } }])),
397
+ // v119: planning generations and a question's closure reason are new columns; every row saved before them reads
398
+ // the declared value (generation 0, no closure reason).
399
+ // v120: a steering note's mailing and acknowledgement, and a run's steering folder, are new columns; every row
400
+ // saved before them reads none.
401
+ ...addedColumns(V119_ADDED_COLUMNS, V120_ADDED_COLUMNS),
387
402
  };
388
403
  /** The tables a migration may remove whole (v115's removed features). */
389
404
  export const RETIRED_TABLES = Object.freeze(Object.keys(HISTORY_RULES).filter(name => HISTORY_RULES[name].retired));
@@ -537,6 +552,10 @@ export function changedHistory(db, before) {
537
552
  if (t.columns.some(c => !kept(c) && (!rule?.dropped?.includes(c) || (t.nonNull?.[c] !== 0 && !accounted(rule, t)))))
538
553
  return true;
539
554
  const columns = t.columns.filter(c => present.has(c));
555
+ // A declared new column: every row saved before it reads its declared value.
556
+ const added = Object.entries(rule?.added ?? {}).filter(([c]) => !t.columns.includes(c) && present.has(c));
557
+ 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))
558
+ return true;
540
559
  if (!t.rowid) {
541
560
  const after = rowsHash(db, t.name, columns);
542
561
  return after.count !== t.count || after.hash !== t.hash;