toolroll 0.9.61 → 0.9.63

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 (117) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +51 -3
  3. package/dist/agent-fence.d.ts +155 -10
  4. package/dist/agent-fence.js +473 -38
  5. package/dist/browser/workspace.css +1 -1
  6. package/dist/browser/workspace.js +21 -21
  7. package/dist/browser-workspace.d.ts +28 -0
  8. package/dist/chat-actions.js +2 -2
  9. package/dist/check-levels.js +3 -0
  10. package/dist/claim.js +1 -1
  11. package/dist/cli-help.d.ts +1 -1
  12. package/dist/cli-help.js +1 -0
  13. package/dist/cli.js +31 -11
  14. package/dist/contracts/cli.d.ts +8 -0
  15. package/dist/contracts/cli.js +3 -1
  16. package/dist/contracts/console-api.d.ts +19 -1
  17. package/dist/contracts/console-api.js +2 -1
  18. package/dist/contracts/integration-metadata.d.ts +2 -1
  19. package/dist/contracts/integration-metadata.js +2 -1
  20. package/dist/desktop-update.d.ts +1 -1
  21. package/dist/dispatch.js +7 -4
  22. package/dist/doctor.d.ts +53 -0
  23. package/dist/doctor.js +427 -0
  24. package/dist/evidence-pack.d.ts +1 -1
  25. package/dist/evidence-pack.js +2 -2
  26. package/dist/exec.d.ts +3 -0
  27. package/dist/exec.js +5 -4
  28. package/dist/flow-code.d.ts +2 -2
  29. package/dist/flow-code.js +4 -15
  30. package/dist/flow-engine.js +11 -6
  31. package/dist/flow-triggers.d.ts +8 -4
  32. package/dist/flow-triggers.js +45 -25
  33. package/dist/flow-unattended-words.d.ts +28 -0
  34. package/dist/flow-unattended-words.js +50 -0
  35. package/dist/flow-unattended.d.ts +227 -0
  36. package/dist/flow-unattended.js +776 -0
  37. package/dist/flows-cli.d.ts +28 -1
  38. package/dist/flows-cli.js +55 -10
  39. package/dist/flows-ui.d.ts +2 -1
  40. package/dist/flows-ui.js +4 -1
  41. package/dist/google-mail.d.ts +1 -0
  42. package/dist/google-mail.js +61 -18
  43. package/dist/integrations-ui.d.ts +2 -1
  44. package/dist/integrations-ui.js +6 -3
  45. package/dist/integrations.d.ts +17 -3
  46. package/dist/integrations.js +38 -18
  47. package/dist/invoke.js +300 -168
  48. package/dist/landed-cleanup.d.ts +26 -12
  49. package/dist/landed-cleanup.js +199 -85
  50. package/dist/lead-contract.d.ts +1 -1
  51. package/dist/lead-contract.js +3 -3
  52. package/dist/lead-tools.js +11 -3
  53. package/dist/mcp-connect.d.ts +8 -7
  54. package/dist/mcp-connect.js +49 -51
  55. package/dist/oauth-run-token.d.ts +51 -0
  56. package/dist/oauth-run-token.js +127 -0
  57. package/dist/operate.js +74 -17
  58. package/dist/plan-auto.d.ts +11 -0
  59. package/dist/plan-auto.js +62 -11
  60. package/dist/process-recovery-eligibility.d.ts +1 -1
  61. package/dist/project-access.d.ts +3 -0
  62. package/dist/project-access.js +3 -0
  63. package/dist/project-admission.d.ts +10 -6
  64. package/dist/project-admission.js +33 -11
  65. package/dist/project-delete-ui.d.ts +1 -0
  66. package/dist/project-delete-ui.js +4 -1
  67. package/dist/project-delete.d.ts +3 -1
  68. package/dist/project-delete.js +10 -4
  69. package/dist/project-file.d.ts +3 -0
  70. package/dist/project-file.js +16 -0
  71. package/dist/project-tools.d.ts +99 -25
  72. package/dist/project-tools.js +394 -98
  73. package/dist/provider-home.d.ts +22 -0
  74. package/dist/provider-home.js +99 -0
  75. package/dist/provider.d.ts +17 -3
  76. package/dist/provider.js +34 -10
  77. package/dist/remote-main.d.ts +61 -0
  78. package/dist/remote-main.js +179 -0
  79. package/dist/scope.d.ts +1 -1
  80. package/dist/serve.js +11 -3
  81. package/dist/server/flows.js +27 -5
  82. package/dist/server/render-tasks.d.ts +3 -0
  83. package/dist/server/render-tasks.js +3 -0
  84. package/dist/server/route-table.js +3 -0
  85. package/dist/server/runtime.d.ts +1 -1
  86. package/dist/server/settings.js +14 -5
  87. package/dist/server/task-views.js +2 -0
  88. package/dist/storage-sweep-child.js +3 -1
  89. package/dist/storage-sweep-process.d.ts +14 -0
  90. package/dist/storage-sweep-process.js +37 -3
  91. package/dist/storage-sweep-schedule.d.ts +30 -0
  92. package/dist/storage-sweep-schedule.js +164 -0
  93. package/dist/storage-sweep.d.ts +6 -5
  94. package/dist/storage-sweep.js +10 -70
  95. package/dist/storage-ui.d.ts +2 -0
  96. package/dist/storage-ui.js +7 -4
  97. package/dist/store.d.ts +75 -7
  98. package/dist/store.js +241 -17
  99. package/dist/subagent-tools.d.ts +1 -1
  100. package/dist/surface.js +10 -6
  101. package/dist/task-base.d.ts +35 -0
  102. package/dist/task-base.js +51 -0
  103. package/dist/teardown-git.d.ts +14 -0
  104. package/dist/teardown-git.js +119 -0
  105. package/dist/tool-broker.d.ts +37 -0
  106. package/dist/tool-broker.js +369 -0
  107. package/dist/tool-launcher.js +5 -4
  108. package/dist/tool-secret-store.d.ts +170 -0
  109. package/dist/tool-secret-store.js +834 -0
  110. package/dist/toolroll-update.js +4 -2
  111. package/dist/tools-ui.d.ts +7 -1
  112. package/dist/tools-ui.js +17 -8
  113. package/dist/work-index.js +5 -3
  114. package/dist/worktree.d.ts +64 -6
  115. package/dist/worktree.js +171 -35
  116. package/package.json +3 -1
  117. package/scripts/provider-canary.mjs +210 -31
package/dist/store.js CHANGED
@@ -74,9 +74,10 @@ import { isProviderId } from "./provider.js";
74
74
  import { authenticate as runnerAuthenticate } from "./runner.js";
75
75
  import { stampNewProjectLevel } from "./check-levels.js";
76
76
  import { parseCheckProgressSnapshot } from "./check-progress.js";
77
- import { normalizeProjectAccess, projectAccessAllows, readProjectAccess } from "./project-access.js";
77
+ import { accountReachesSql, normalizeProjectAccess, projectAccessAllows, readProjectAccess } from "./project-access.js";
78
78
  import { LEDGER_COLUMNS, LEDGER_SCHEMA, LEDGER_TABLE, LEDGER_V99_TABLE, installLedgerTriggers } from "./action-ledger.js";
79
79
  import { PLAN_AUTO_SCHEMA } from "./plan-auto.js";
80
+ import { endUnattendedGrants, FLOW_UNATTENDED_SCHEMA, grantStandsSql } from "./flow-unattended.js";
80
81
  import { RECIPE_SCHEMA } from "./recipes.js";
81
82
  import { POLICY_SCHEMA, agentRefusal, approvalRefusal, policyParts, readPolicy, underCeiling, sessionCeilingRefusal } from "./policy.js";
82
83
  import { PROVIDER_AUTH_SCHEMA } from "./provider-auth.js";
@@ -355,6 +356,22 @@ CREATE TABLE IF NOT EXISTS run_activity (
355
356
  at TEXT NOT NULL
356
357
  );
357
358
  `;
359
+ /** A task branch moved to current main before an attempt (fresh base; v122, a new table: additive only): the branch
360
+ * had no commits of its own and no changes, so it started over from `to_rev`. `after_run` is the newest run id when it
361
+ * moved: the branch's whole-task diff runs from the first builder base recorded after it (firstBuilderBase), never from
362
+ * the older main it left. */
363
+ export const BRANCH_BASE_MOVE_SCHEMA = `
364
+ CREATE TABLE IF NOT EXISTS branch_base_move (
365
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
366
+ task_ref INTEGER NOT NULL REFERENCES task_ref(id) ON DELETE CASCADE,
367
+ branch TEXT NOT NULL,
368
+ from_rev TEXT NOT NULL,
369
+ to_rev TEXT NOT NULL,
370
+ after_run INTEGER NOT NULL,
371
+ moved_at TEXT NOT NULL
372
+ );
373
+ CREATE INDEX IF NOT EXISTS branch_base_move_branch ON branch_base_move (task_ref, branch, id);
374
+ `;
358
375
  /** Pausing a running Claude build (live steering, v121: additive only). A person's Pause asks the build to
359
376
  * stop its current turn and keep its session: one row per request, bound to the exact run it was asked of, under the
360
377
  * request id the asker chose (a repeated request is the same request). `note` is the steering note a Pause and steer
@@ -459,6 +476,23 @@ CREATE TABLE IF NOT EXISTS integration_check (
459
476
  error_at TEXT
460
477
  );
461
478
  `;
479
+ /** Tool secrets (no version bump): which sealing key Toolroll made for the secrets it keeps in the keychain, as a
480
+ * digest of the key, never the key. A key found beside them that isn't this one was put there by something else and is
481
+ * refused (tool-secret-store.ts). */
482
+ export const TOOL_SECRET_KEY_SCHEMA = `
483
+ CREATE TABLE IF NOT EXISTS tool_secret_key (
484
+ id INTEGER PRIMARY KEY CHECK (id = 1),
485
+ digest TEXT NOT NULL CHECK (length(digest) = 64),
486
+ made_at TEXT NOT NULL
487
+ );
488
+ -- Where secrets were kept before Toolroll's folder, decided once at this database's first start (null: none) and never
489
+ -- probed again, so a folder a build makes later under an old name is never migrated in.
490
+ CREATE TABLE IF NOT EXISTS tool_secret_legacy (
491
+ id INTEGER PRIMARY KEY CHECK (id = 1),
492
+ dir TEXT,
493
+ decided_at TEXT NOT NULL
494
+ );
495
+ `;
462
496
  /** Sprint 8: database backups. One settings row (off, or every so many hours, keeping the newest N in a folder; no
463
497
  * folder means `backups` beside the database), every backup's outcome, and a lease the console's backup loop holds
464
498
  * and renews while it runs: one process backs up at a time, and a restore sees that the console is running. */
@@ -605,7 +639,13 @@ const REGISTERED_AT_FILING = "again, as it was filed";
605
639
  // v121 adds, and changes no saved row: the Agents board (run.attempt_key; agent_turn, agent_now, agent_question,
606
640
  // agent_seen, agent_view), live steering (a note's live identity and sends, a run's stdin session; run_pause and
607
641
  // run_resume) and landed work's teardown journal (landed_cleanup, landed_cleanup_item).
608
- export const SCHEMA_VERSION = 121;
642
+ // v122 adds, and changes no saved row: the remote URL an owner's project add recorded (project.remote_url), the only
643
+ // remote whose main automatic completion trusts (remote-main.ts; saved projects read none until it is recorded), and
644
+ // fresh base's record of a task branch moved to current main before an attempt (branch_base_move).
645
+ // v123 adds, and changes no saved row: flows that run on their own (flow_script_provenance, flow_unattended_grant,
646
+ // flow_unattended_run, plan_authorization.basis, flow_trigger_event.on_time and ran_script) and the tool-secret
647
+ // sealing key's record (tool_secret_key, tool_secret_legacy).
648
+ export const SCHEMA_VERSION = 123;
609
649
  /** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
610
650
  export const V118_MERGED_TABLES = Object.freeze({ api_token: "person", coordinator_credential: "coordinator", lead_credential: "lead" });
611
651
  /** The foreign keys that named a merged table, exactly as their DDL wrote them; each names `credential(id)` instead. */
@@ -730,9 +770,12 @@ const V115_DROPPED_REFERENCES = /\s+REFERENCES (?:contestant|contest|tournament_
730
770
  * (planning_generation on task_ref, run and plan_revision, defaulting to 0, and decision.superseded_reason, null);
731
771
  * the rehearsal checks every saved row reads the declared default (HISTORY_RULES' added columns). v120 only adds
732
772
  * three nullable task_steer columns (the running build's steering folder), checked the same way. v121 only adds
733
- * new tables and the columns in V121_ADDED_COLUMNS (null, or no sends), checked the same way.
773
+ * new tables and the columns in V121_ADDED_COLUMNS (null, or no sends), checked the same way. v122 only adds a new
774
+ * table (branch_base_move) and the nullable column in V122_ADDED_COLUMNS (a project's recorded remote), checked the
775
+ * same way. v123 only adds new tables (unattended flows, tool secrets) and the columns in V123_ADDED_COLUMNS ('mode',
776
+ * 0 or null), checked the same way.
734
777
  */
735
- export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121]);
778
+ export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123]);
736
779
  /** v119: the columns it adds, by table, with the value every row saved before it reads (toolroll-update's HISTORY_RULES). */
737
780
  export const V119_ADDED_COLUMNS = Object.freeze({
738
781
  task_ref: { planning_generation: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" } },
@@ -778,6 +821,21 @@ export const V121_ADDED_COLUMNS = Object.freeze({
778
821
  task_steer: LIVE_STEER_COLUMNS.task_steer,
779
822
  run: { ...LIVE_STEER_COLUMNS.run, attempt_key: { definition: "TEXT", saved: "NULL" } },
780
823
  });
824
+ /** v122: the column it adds, with the value every row saved before it reads: the remote URL an owner's project add
825
+ * recorded (projects added before it read none, until `repos add <project> --remote <url>` records one). */
826
+ export const V122_ADDED_COLUMNS = Object.freeze({
827
+ project: { remote_url: { definition: "TEXT", saved: "NULL" } },
828
+ });
829
+ /** v123: the columns it adds, with the value every row saved before it reads: which authority armed a plan's
830
+ * unchanged-plan approval (saved rows read the operating mode), whether a trigger's firing was the clock's own (saved
831
+ * firings read not), and what code an on-time script firing a grant signed for ran (saved firings read none). */
832
+ export const V123_ADDED_COLUMNS = Object.freeze({
833
+ plan_authorization: { basis: { definition: "TEXT NOT NULL DEFAULT 'mode' CHECK (basis IN ('mode', 'grant'))", saved: "'mode'" } },
834
+ flow_trigger_event: {
835
+ on_time: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" },
836
+ ran_script: { definition: "TEXT", saved: "NULL" },
837
+ },
838
+ });
781
839
  /** Whether a database settled at `version` reaches this build's schema through update-safe migrations alone. */
782
840
  export function updateSafeSchema(version) {
783
841
  if (version === null || !Number.isSafeInteger(version) || version <= 0 || version > SCHEMA_VERSION)
@@ -3501,6 +3559,8 @@ function initializeStore(db, file) {
3501
3559
  db.exec(LEDGER_CHAIN_SCHEMA);
3502
3560
  db.exec(MONITORING_SCHEMA);
3503
3561
  db.exec(INTEGRATIONS_SCHEMA);
3562
+ // v123: the tool-secret sealing key's record and where secrets were kept before (tool-secret-store.ts): new tables.
3563
+ db.exec(TOOL_SECRET_KEY_SCHEMA);
3504
3564
  db.exec(LEAD_ASK_SCHEMA);
3505
3565
  // Decisions in the chat app (no version bump: additive only): result, plan and merge buttons, and Request changes.
3506
3566
  db.exec(CHAT_DECIDE_SCHEMA);
@@ -3508,6 +3568,8 @@ function initializeStore(db, file) {
3508
3568
  db.exec(CHAT_APPROVAL_SCHEMA);
3509
3569
  // What a running agent did last (no version bump: additive only; absent means no word yet).
3510
3570
  db.exec(RUN_ACTIVITY_SCHEMA);
3571
+ // v122: a task branch moved to current main before an attempt (fresh base): a new table, nothing carried over.
3572
+ db.exec(BRANCH_BASE_MOVE_SCHEMA);
3511
3573
  // v121: the Agents board: each run's attempt key (V121_ADDED_COLUMNS, below), its turn history and current snapshot,
3512
3574
  // the agent's open questions, who has seen a finished run and who is watching one now. Older runs read null keys.
3513
3575
  db.exec(AGENT_BOARD_SCHEMA);
@@ -3540,6 +3602,12 @@ function initializeStore(db, file) {
3540
3602
  db.exec(STORAGE_SWEEP_SCHEMA);
3541
3603
  // v121: landed work's teardown journal (landed-cleanup.ts): new tables.
3542
3604
  db.exec(LANDED_CLEANUP_SCHEMA);
3605
+ // v122: the remote URL an owner's project add recorded (V122_ADDED_COLUMNS; additive only, saved projects read none):
3606
+ // automatic completion trusts only that remote's main (remote-main.ts), never the shared repository's config.
3607
+ for (const [table, columns] of Object.entries(V122_ADDED_COLUMNS)) {
3608
+ for (const [column, { definition }] of Object.entries(columns))
3609
+ addColumn(db, table, column, definition);
3610
+ }
3543
3611
  db.exec(BACKUP_SCHEMA);
3544
3612
  // Sprint 8: the organisation policy (one row, or none: nothing restricted).
3545
3613
  db.exec(POLICY_SCHEMA);
@@ -3579,6 +3647,16 @@ function initializeStore(db, file) {
3579
3647
  // v115: the task each standing-order firing filed, kept on the firing itself (a card's task changes as it moves).
3580
3648
  addColumn(db, "flow_trigger_event", "task", "TEXT");
3581
3649
  db.exec(PLAN_AUTO_SCHEMA);
3650
+ // v123: a flow that runs on its own (flow-unattended.ts): its grants and daily runs (new tables), and which authority
3651
+ // armed a plan's unchanged-plan approval (plan_authorization.basis, V123_ADDED_COLUMNS: saved rows read the mode).
3652
+ db.exec(FLOW_UNATTENDED_SCHEMA);
3653
+ // v123: whether a trigger's firing was the clock's own (flow_trigger_event.on_time, V123_ADDED_COLUMNS: a schedule
3654
+ // due, not Run now; only those can run on their own), and what code an on-time script firing a grant signed for ran
3655
+ // (flow_trigger_event.ran_script, V123_ADDED_COLUMNS: flow-unattended.ts scriptRanDigestOf; older firings read null).
3656
+ for (const [table, columns] of Object.entries(V123_ADDED_COLUMNS)) {
3657
+ for (const [column, { definition }] of Object.entries(columns))
3658
+ addColumn(db, table, column, definition);
3659
+ }
3582
3660
  // v119: planning generations, and why a question closed unanswered (additive only; saved rows read the defaults).
3583
3661
  for (const [table, columns] of Object.entries(V119_ADDED_COLUMNS)) {
3584
3662
  for (const [column, { definition }] of Object.entries(columns))
@@ -3712,6 +3790,8 @@ function createBaseline(db, file) {
3712
3790
  }
3713
3791
  db.exec(BASELINE_SCHEMA);
3714
3792
  db.prepare("INSERT INTO schema_version (version) VALUES (?)").run(BASELINE_SCHEMA_VERSION);
3793
+ const installedAt = new Date().toISOString();
3794
+ db.prepare("INSERT INTO installation_fact (key, value, created_at) VALUES ('installed-at', ?, ?)").run(installedAt, installedAt);
3715
3795
  db.exec("COMMIT");
3716
3796
  }
3717
3797
  catch (error) {
@@ -4782,6 +4862,36 @@ export class Store {
4782
4862
  close() {
4783
4863
  this.db.close();
4784
4864
  }
4865
+ /** The digest of the sealing key Toolroll made for its tool secrets; null before it made one (or in an older database). */
4866
+ toolSecretKeyDigest() {
4867
+ if (!tableExists(this.db, "tool_secret_key"))
4868
+ return null;
4869
+ const row = this.db.prepare("SELECT digest FROM tool_secret_key WHERE id = 1").get();
4870
+ return row === undefined ? null : String(row["digest"]);
4871
+ }
4872
+ /**
4873
+ * The folder secrets were kept in before Toolroll's own, as decided at this database's first start of a command that
4874
+ * can start builds (`record`): `probe` runs until then, and its answer (null: none) is kept for every later start.
4875
+ * Other commands never write, so a preview stays read-only and a busy database doesn't stop them.
4876
+ */
4877
+ toolSecretLegacyDir(probe, now, options) {
4878
+ const read = () => tableExists(this.db, "tool_secret_legacy") ? this.db.prepare("SELECT dir FROM tool_secret_legacy WHERE id = 1").get() : undefined;
4879
+ let row = read();
4880
+ if (row === undefined) {
4881
+ if (!options.record)
4882
+ return probe();
4883
+ this.db.prepare("INSERT OR IGNORE INTO tool_secret_legacy (id, dir, decided_at) VALUES (1, ?, ?)").run(probe(), now.toISOString());
4884
+ row = read();
4885
+ }
4886
+ return row?.["dir"] === null || row?.["dir"] === undefined ? null : String(row["dir"]);
4887
+ }
4888
+ /** Record a new sealing key's digest only if the one recorded is still `expected` (null: none yet). */
4889
+ swapToolSecretKeyDigest(expected, next, now) {
4890
+ const changed = expected === null
4891
+ ? this.db.prepare("INSERT OR IGNORE INTO tool_secret_key (id, digest, made_at) VALUES (1, ?, ?)").run(next, now.toISOString()).changes
4892
+ : this.db.prepare("UPDATE tool_secret_key SET digest = ?, made_at = ? WHERE id = 1 AND digest = ?").run(next, now.toISOString(), expected).changes;
4893
+ return Number(changed) === 1;
4894
+ }
4785
4895
  // ---- the built-in task store -------------------------------------------
4786
4896
  /**
4787
4897
  * Creating a task also creates its overlay reference, because a task with no
@@ -6308,7 +6418,9 @@ export class Store {
6308
6418
  ? `Approved by ${currentActor()?.lead === true ? "the lead" : lifecycleWords(by, 40)}. A connected worker can take it next.`
6309
6419
  : basis.kind === "chat"
6310
6420
  ? `Approved by ${lifecycleWords(by, 40)} in chat. A connected worker can take it next.`
6311
- : "Approved automatically under the operating mode. A connected worker can take it next.",
6421
+ : basis.kind === "grant"
6422
+ ? "Approved automatically: its flow runs on its own. A connected worker can take it next."
6423
+ : "Approved automatically under the operating mode. A connected worker can take it next.",
6312
6424
  link: chatControlHref("task", taskId),
6313
6425
  }, now);
6314
6426
  }
@@ -6339,16 +6451,19 @@ export class Store {
6339
6451
  * scopes answer true: this belt only ever REMOVES mode authority.
6340
6452
  */
6341
6453
  modeApprovalLive(taskRef, now) {
6454
+ // A flow's "runs on its own" grant (flow-unattended.ts) is held to the same belt.
6342
6455
  const dead = this.db
6343
6456
  .prepare(`SELECT 1 AS dead FROM task_scope
6344
6457
  JOIN task_ref ON task_ref.id = ? AND task_ref.backend = 'built-in' AND task_scope.task_id = task_ref.external_id
6345
- WHERE task_scope.approval_basis = 'mode' AND task_scope.approved_at IS NOT NULL
6458
+ WHERE task_scope.approved_at IS NOT NULL
6459
+ AND ((task_scope.approval_basis = 'mode'
6346
6460
  AND NOT EXISTS (SELECT 1 FROM operating_mode om
6347
6461
  JOIN approver signer ON signer.name = om.signed_by
6348
6462
  WHERE om.repo = task_ref.repo AND om.revoked_at IS NULL
6349
6463
  AND om.absolute_expiry > ? AND om.digest = task_scope.mode_digest
6350
- AND signer.revoked_at IS NULL AND signer.role = 'approver')`)
6351
- .get(taskRef, now.toISOString());
6464
+ AND signer.revoked_at IS NULL AND signer.role = 'approver' AND ${accountReachesSql("signer", "task_ref.repo")}))
6465
+ OR (task_scope.approval_basis = 'grant' AND NOT ${grantStandsSql("task_scope.mode_digest", "?", "task_ref.repo")}))`)
6466
+ .get(taskRef, now.toISOString(), now.toISOString());
6352
6467
  return dead === undefined;
6353
6468
  }
6354
6469
  // ---- approvers ----------------------------------------------------------
@@ -6475,6 +6590,29 @@ export class Store {
6475
6590
  return true;
6476
6591
  });
6477
6592
  }
6593
+ /** A flow's "runs on its own" grant ended (revoked, expired, renewed): the undispatched work it sealed falls back
6594
+ * to a person, as a mode's does, and plans it armed lose their pre-authorization. Running and finished work keeps
6595
+ * its history. */
6596
+ withdrawGrantApprovals(digest, now) {
6597
+ const demotable = `WHERE approval_basis = 'grant' AND approved_at IS NOT NULL AND mode_digest = ?
6598
+ AND EXISTS (SELECT 1 FROM task_ref JOIN task ON task.id = task_ref.external_id
6599
+ WHERE task_ref.backend = 'built-in' AND task_ref.external_id = task_scope.task_id AND task.state = 'queued')
6600
+ AND NOT EXISTS (SELECT 1 FROM claim JOIN task_ref tr ON tr.id = claim.task_ref
6601
+ WHERE tr.backend = 'built-in' AND tr.external_id = task_scope.task_id
6602
+ AND claim.released_at IS NULL AND claim.expires_at > ?)`;
6603
+ const demoted = this.db.prepare(`SELECT task_id FROM task_scope ${demotable}`).all(digest, now.toISOString()).map(row => String(row["task_id"]));
6604
+ this.db
6605
+ .prepare(`UPDATE task_scope SET approved_at = NULL, approved_by = NULL, approved_digest = NULL,
6606
+ approved_profile_json = NULL, approval_basis = 'password', mode_digest = NULL ${demotable}`)
6607
+ .run(digest, now.toISOString());
6608
+ this.db.prepare("DELETE FROM plan_authorization WHERE basis = 'grant' AND mode_digest = ?").run(digest);
6609
+ for (const taskId of demoted) {
6610
+ const ref = this.lookupRef(taskId);
6611
+ if (ref === null)
6612
+ continue;
6613
+ this.noteLifecycle({ taskRef: ref.id, kind: "approval-withdrawn", identity: `t${ref.id}`, subject: "Approval needs renewing", body: "Its flow no longer runs on its own. Approve it before it runs.", link: chatControlHref("approval", taskId) }, now);
6614
+ }
6615
+ }
6478
6616
  /** F2/R-REVOKE: the ONE reconciliation road. newMode null = no live
6479
6617
  * automerge authority (revocation, expiry, or a notify posture):
6480
6618
  * mode-basis pending/claimed intents drop to waiting-human, leases
@@ -6699,6 +6837,7 @@ export class Store {
6699
6837
  for (const row of this.db.prepare("SELECT repo FROM operating_mode WHERE signed_by = ? AND revoked_at IS NULL").all(name)) {
6700
6838
  this.revokeMode(String(row["repo"]), by, "operator", now);
6701
6839
  }
6840
+ endUnattendedGrants(this, { signer: name }, by, "access changed", now);
6702
6841
  this.revokeDerivedAuthority(name, by, now);
6703
6842
  for (const repo of [...new Set([...(account.projects ?? []), ...(access ?? [])])]) {
6704
6843
  this.recordAction({ at: now.toISOString(), actor: by, repo, taskId: null, runId: null,
@@ -6726,6 +6865,10 @@ export class Store {
6726
6865
  ON CONFLICT(repo) DO UPDATE SET not_requester = excluded.not_requester, protect_project = excluded.protect_project, protected_paths = excluded.protected_paths, updated_by = excluded.updated_by, updated_at = excluded.updated_at`)
6727
6866
  .run(repo, next.notRequester ? 1 : 0, next.protectProject ? 1 : 0, JSON.stringify(next.protectedPaths), by, now.toISOString());
6728
6867
  this.recordPolicy(by, repo, "approval rules changed", rulesWords(before), rulesWords(next), now);
6868
+ // Protected work is always a person's: a flow here (or building here) stops running on its own, and its queued
6869
+ // approvals are withdrawn (flow-unattended.ts). The dispatch belts check the rules again for any race.
6870
+ if (isProtectedWork(next, []))
6871
+ endUnattendedGrants(this, { repo }, by, "project protected", now);
6729
6872
  });
6730
6873
  }
6731
6874
  /** Who filed a task, when anyone knows. */
@@ -12140,13 +12283,46 @@ export class Store {
12140
12283
  * legacy first-attempt behavior, unchanged byte-for-byte.
12141
12284
  */
12142
12285
  firstBuilderBase(taskRef, branch) {
12286
+ // A branch moved to current main (fresh base) had no commits of its own:
12287
+ // its origin is the first base recorded after the move, not the older main.
12143
12288
  const row = this.db
12144
12289
  .prepare(`SELECT base_revision FROM run
12145
12290
  WHERE task_ref = ? AND branch = ? AND role = 'builder' AND base_revision IS NOT NULL
12291
+ AND id > COALESCE((SELECT after_run FROM branch_base_move WHERE task_ref = ? AND branch = ? ORDER BY id DESC LIMIT 1), 0)
12146
12292
  ORDER BY id ASC LIMIT 1`)
12147
- .get(taskRef, branch);
12293
+ .get(taskRef, branch, taskRef, branch);
12294
+ return row === undefined ? null : row.base_revision;
12295
+ }
12296
+ /** Where a task branch's attempts start now: a builder branch's pinned base, or the newest base any other attempt on
12297
+ * it recorded (a planner never commits). Null before any attempt on the branch recorded one, or after a move to
12298
+ * current main until the next attempt records its base. */
12299
+ recordedBranchBase(taskRef, branch) {
12300
+ const pinned = this.firstBuilderBase(taskRef, branch);
12301
+ if (pinned !== null)
12302
+ return pinned;
12303
+ const row = this.db
12304
+ .prepare(`SELECT base_revision FROM run
12305
+ WHERE task_ref = ? AND branch = ? AND role <> 'builder' AND base_revision IS NOT NULL
12306
+ AND id > COALESCE((SELECT after_run FROM branch_base_move WHERE task_ref = ? AND branch = ? ORDER BY id DESC LIMIT 1), 0)
12307
+ ORDER BY id DESC LIMIT 1`)
12308
+ .get(taskRef, branch, taskRef, branch);
12148
12309
  return row === undefined ? null : row.base_revision;
12149
12310
  }
12311
+ /** Record a task branch's move to current main, before git moves it: a move that then fails leaves the next
12312
+ * attempt's own base (still the old one) as the branch's origin, which is exactly right. */
12313
+ recordBranchBaseMove(taskRef, branch, from, to, now) {
12314
+ this.db
12315
+ .prepare(`INSERT INTO branch_base_move (task_ref, branch, from_rev, to_rev, after_run, moved_at)
12316
+ VALUES (?, ?, ?, ?, (SELECT COALESCE(MAX(id), 0) FROM run), ?)`)
12317
+ .run(taskRef, branch, from, to, now.toISOString());
12318
+ }
12319
+ /** The newest move of a task branch to current main, or null. */
12320
+ latestBranchBaseMove(taskRef, branch) {
12321
+ const row = this.db
12322
+ .prepare("SELECT from_rev, to_rev, after_run, moved_at FROM branch_base_move WHERE task_ref = ? AND branch = ? ORDER BY id DESC LIMIT 1")
12323
+ .get(taskRef, branch);
12324
+ return row === undefined ? null : { from: String(row["from_rev"]), to: String(row["to_rev"]), afterRun: Number(row["after_run"]), movedAt: String(row["moved_at"]) };
12325
+ }
12150
12326
  finishRun(id, result) {
12151
12327
  this.transact(() => {
12152
12328
  // Read before the write (Telegram task updates): only the OPEN → closed
@@ -12256,6 +12432,12 @@ export class Store {
12256
12432
  .prepare("UPDATE run SET auth_mode = COALESCE(auth_mode, ?), terminal_class = ? WHERE id = ? AND terminal_class IS NULL")
12257
12433
  .run(authMode, terminalClass, id);
12258
12434
  }
12435
+ /** A launch refused before any model turn (an installed CLI too old for a
12436
+ * build's flags): the start stamp goes, its version and evidence stay. Only
12437
+ * while the run is open, so a settled run's account never changes. */
12438
+ withdrawProviderStart(id) {
12439
+ this.db.prepare("UPDATE run SET provider_started_at = NULL WHERE id = ? AND outcome IS NULL").run(id);
12440
+ }
12259
12441
  /** The race-refusal stamp (Phase 3 C5): the version that was REFUSED,
12260
12442
  * recorded without provider_started_at — no provider process started. */
12261
12443
  stampProviderVersion(id, providerVersion) {
@@ -14859,6 +15041,8 @@ export class Store {
14859
15041
  const version = (current?.version ?? Number(this.db.prepare("SELECT COALESCE(MAX(version), 0) AS v FROM flow_script WHERE repo = ? AND name = ?").get(script.repo, script.name)?.["v"] ?? 0)) + 1;
14860
15042
  if (current !== null)
14861
15043
  this.db.prepare("UPDATE flow_script SET state = 'removed' WHERE id = ?").run(current.id);
15044
+ // A "runs on its own" grant signed this script's code: a new version ends it (flow-unattended.ts).
15045
+ endUnattendedGrants(this, { script: { repo: script.repo, name: script.name } }, script.by, "script changed", now);
14862
15046
  this.db.prepare(`INSERT INTO flow_script (repo, name, about, body, timeout_minutes, version, digest, saved_by, saved_at, state, language, file, held)
14863
15047
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)`).run(script.repo, script.name, script.about, script.body, script.timeoutMinutes, version, script.digest, script.by, now.toISOString(), script.language ?? "shell", script.file ?? null, script.held ?? null);
14864
15048
  return version;
@@ -14869,9 +15053,14 @@ export class Store {
14869
15053
  const { changes } = this.db.prepare("UPDATE flow_script SET held = NULL WHERE repo = ? AND name = ? AND state = 'active' AND held IS NOT NULL").run(repo, name);
14870
15054
  return Number(changes) === 1;
14871
15055
  }
14872
- removeFlowScript(repo, name) {
14873
- const { changes } = this.db.prepare("UPDATE flow_script SET state = 'removed' WHERE repo = ? AND name = ? AND state = 'active'").run(repo, name);
14874
- return Number(changes) === 1;
15056
+ removeFlowScript(repo, name, by = "flow", now = new Date()) {
15057
+ return this.transact(() => {
15058
+ const { changes } = this.db.prepare("UPDATE flow_script SET state = 'removed' WHERE repo = ? AND name = ? AND state = 'active'").run(repo, name);
15059
+ // A grant that signed this script's code covers nothing without it: it ends (flow-unattended.ts).
15060
+ if (Number(changes) === 1)
15061
+ endUnattendedGrants(this, { script: { repo, name } }, by, "script changed", now);
15062
+ return Number(changes) === 1;
15063
+ });
14875
15064
  }
14876
15065
  /** Claim a card's visit to a step zone. False when another pass holds it or it already finished. */
14877
15066
  claimFlowStep(step, now) {
@@ -14960,6 +15149,13 @@ export class Store {
14960
15149
  // ---- flow triggers (v82) -----------------------------------------------------
14961
15150
  addFlowTrigger(trigger, now) {
14962
15151
  const stamp = now.toISOString();
15152
+ // A new schedule is one the flow's "runs on its own" grant never read: it ends (flow-unattended.ts).
15153
+ if (trigger.kind === "schedule")
15154
+ return this.transact(() => {
15155
+ endUnattendedGrants(this, { flow: trigger.flow }, trigger.by, "schedule changed", now);
15156
+ return Number(this.db.prepare("INSERT INTO flow_trigger (flow, kind, config_json, state, hook_hash, cursor, next_at, created_by, created_at, updated_at) VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?, ?)")
15157
+ .run(trigger.flow, trigger.kind, trigger.configJson, trigger.hookHash, trigger.cursor, trigger.nextAt, trigger.by, stamp, stamp).lastInsertRowid);
15158
+ });
14963
15159
  return Number(this.db.prepare("INSERT INTO flow_trigger (flow, kind, config_json, state, hook_hash, cursor, next_at, created_by, created_at, updated_at) VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?, ?)")
14964
15160
  .run(trigger.flow, trigger.kind, trigger.configJson, trigger.hookHash, trigger.cursor, trigger.nextAt, trigger.by, stamp, stamp).lastInsertRowid);
14965
15161
  }
@@ -14980,11 +15176,17 @@ export class Store {
14980
15176
  const row = this.db.prepare("SELECT t.* FROM flow_trigger t JOIN flow f ON f.id = t.flow WHERE t.hook_hash = ? AND t.state = 'active' AND f.state = 'active'").get(hash);
14981
15177
  return row === undefined ? null : readFlowTriggerRow(row);
14982
15178
  }
14983
- updateFlowTrigger(id, change, now) {
15179
+ updateFlowTrigger(id, change, now, by = "a person") {
14984
15180
  const trigger = this.getFlowTrigger(id);
14985
15181
  if (trigger === null)
14986
15182
  return;
14987
- this.db.prepare("UPDATE flow_trigger SET state = ?, cursor = ?, next_at = ?, last_at = ?, last_outcome = ?, failures = ?, hook_hash = ?, updated_at = ? WHERE id = ?").run(change.state ?? trigger.state, change.cursor === undefined ? trigger.cursor : change.cursor, change.nextAt === undefined ? trigger.nextAt : change.nextAt, change.lastAt ?? trigger.lastAt, change.lastOutcome ?? trigger.lastOutcome, change.failures ?? trigger.failures, change.hookHash === undefined ? trigger.hookHash : change.hookHash, now.toISOString(), id);
15183
+ // Pausing, resuming or removing a schedule ends the flow's "runs on its own" grant (flow-unattended.ts).
15184
+ const write = () => this.db.prepare("UPDATE flow_trigger SET state = ?, cursor = ?, next_at = ?, last_at = ?, last_outcome = ?, failures = ?, hook_hash = ?, updated_at = ? WHERE id = ?").run(change.state ?? trigger.state, change.cursor === undefined ? trigger.cursor : change.cursor, change.nextAt === undefined ? trigger.nextAt : change.nextAt, change.lastAt ?? trigger.lastAt, change.lastOutcome ?? trigger.lastOutcome, change.failures ?? trigger.failures, change.hookHash === undefined ? trigger.hookHash : change.hookHash, now.toISOString(), id);
15185
+ if (trigger.kind !== "schedule" || change.state === undefined || change.state === trigger.state) {
15186
+ write();
15187
+ return;
15188
+ }
15189
+ this.transact(() => { endUnattendedGrants(this, { flow: trigger.flow }, by, "schedule changed", now); write(); });
14988
15190
  }
14989
15191
  /** Whether a trigger already handled this outside thing (an issue, a run, a schedule slot). */
14990
15192
  flowTriggerSaw(trigger, key) {
@@ -15005,8 +15207,8 @@ export class Store {
15005
15207
  * Record what a trigger did with one outside thing: the card it made, or why it made none, and (a standing order's
15006
15208
  * firing) the task it filed. False when it was already recorded.
15007
15209
  */
15008
- recordFlowTriggerEvent(trigger, key, card, note, now, task = null) {
15009
- const { changes } = this.db.prepare("INSERT OR IGNORE INTO flow_trigger_event (trigger, key, card, note, at, task) VALUES (?, ?, ?, ?, ?, ?)").run(trigger, key, card, note, now.toISOString(), task);
15210
+ recordFlowTriggerEvent(trigger, key, card, note, now, task = null, onTime = false, ranScript = null) {
15211
+ const { changes } = this.db.prepare("INSERT OR IGNORE INTO flow_trigger_event (trigger, key, card, note, at, task, on_time, ran_script) VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run(trigger, key, card, note, now.toISOString(), task, onTime ? 1 : 0, ranScript);
15010
15212
  return Number(changes) === 1;
15011
15213
  }
15012
15214
  /** The last card a trigger made, if any. */
@@ -15594,17 +15796,38 @@ export class Store {
15594
15796
  // ---- projects ------------------------------------------------------------
15595
15797
  /** Remember a project was opened. Upsert keeps added_at; recency always moves.
15596
15798
  * A project added for the first time starts on Quick checks. */
15597
- upsertProject(path, name, now) {
15799
+ upsertProject(path, name, now, admission) {
15598
15800
  this.transact(() => {
15599
15801
  const added = this.db.prepare("SELECT 1 FROM project WHERE path = ?").get(path) === undefined;
15600
15802
  this.db
15601
15803
  .prepare(`INSERT INTO project (path, name, added_at, last_opened_at) VALUES (?, ?, ?, ?)
15602
15804
  ON CONFLICT (path) DO UPDATE SET name = excluded.name, last_opened_at = excluded.last_opened_at`)
15603
15805
  .run(path, name, now.toISOString(), now.toISOString());
15806
+ // Admission owns remote changes; ordinary openings preserve the saved value.
15807
+ if (admission !== undefined)
15808
+ this.db.prepare("UPDATE project SET remote_url = ? WHERE path = ?").run(admission.remoteUrl, path);
15604
15809
  if (added)
15605
15810
  stampNewProjectLevel(this, path, now);
15606
15811
  });
15607
15812
  }
15813
+ /** Includes removed/legacy projects, so deleting a row never makes build-writable config authoritative again. */
15814
+ projectAdmissionKnown(path) {
15815
+ return this.db.prepare(`SELECT 1 FROM project WHERE path = ?
15816
+ UNION ALL SELECT 1 FROM action_ledger WHERE repo = ?
15817
+ UNION ALL SELECT 1 FROM task_ref WHERE repo = ?
15818
+ UNION ALL SELECT 1 FROM worktree WHERE repo = ? LIMIT 1`).get(path, path, path, path) !== undefined;
15819
+ }
15820
+ /** Full structured URL audit, without the generic ledger's display-text truncation. */
15821
+ recordProjectRemote(actor, repo, previous, remoteUrl, now) {
15822
+ this.db.prepare(`INSERT INTO action_ledger(at,actor,repo,task_id,run_id,action,outcome,source,detail)
15823
+ VALUES (?,?,?,NULL,NULL,'project remote recorded','recorded','access',?)`)
15824
+ .run(now.toISOString(), actor, repo, JSON.stringify({ previousRemote: previous, remoteUrl }));
15825
+ }
15826
+ /** The remote URL captured on admission or explicitly supplied by the owner; null when none was (or the project was removed). */
15827
+ projectRemote(path) {
15828
+ const url = this.db.prepare("SELECT remote_url FROM project WHERE path = ?").get(path)?.["remote_url"];
15829
+ return typeof url === "string" && url !== "" ? url : null;
15830
+ }
15608
15831
  /** A project removed from the lists (project-admission.ts): only its row goes; its tasks, results and settings stay. */
15609
15832
  forgetProject(path) {
15610
15833
  this.db.prepare("DELETE FROM project WHERE path = ?").run(path);
@@ -15619,6 +15842,7 @@ export class Store {
15619
15842
  name: String(row["name"]),
15620
15843
  addedAt: String(row["added_at"]),
15621
15844
  lastOpenedAt: String(row["last_opened_at"]),
15845
+ remoteUrl: typeof row["remote_url"] === "string" ? row["remote_url"] : null,
15622
15846
  }));
15623
15847
  }
15624
15848
  /**
@@ -12,7 +12,7 @@ export type ToolLister = (spec: ToolSpec, values: Record<string, string>) => Pro
12
12
  export type ToolIo = {
13
13
  callTool?: ToolCaller;
14
14
  listTools?: ToolLister;
15
- toolHome?: string;
15
+ toolHome?: string | undefined;
16
16
  };
17
17
  /** Actions whose names say they only read start as "do it"; everything else asks first until its manager says otherwise. */
18
18
  export declare const READ_VERB: RegExp;
package/dist/surface.js CHANGED
@@ -67,9 +67,11 @@ const GUIDE_ROWS = [
67
67
  // D5: the central team service (connect, the central lead verbs, conversation …) and native coding sessions are
68
68
  // deprecated: they still run this release, with a warning, but are no longer declared here.
69
69
  operator("chat", "talk to your lead: one conversation across every project; --say sends one message; --follow enables crew updates and --no-follow pauses them; proposals keep their approval requirements"),
70
- { invocation: "repos add", synopsis: "add a project: a Git repository, in the console at once; the lead adds with its token", audience: "agent", agentMayInvoke: true, mutation: "identity-idempotent",
70
+ // Unkeyed: --remote with a different URL replaces the project's trusted remote (recorded in the action ledger).
71
+ { invocation: "repos add", synopsis: "add a project: a Git repository, in the console at once; the lead adds with its token; --remote records or replaces its trusted remote", audience: "agent", agentMayInvoke: true, mutation: "unkeyed",
71
72
  positionals: [{ name: "path", required: false, meaning: "the repository (default: the one you are in)" }],
72
- flags: [jsonFlag, { name: "token", takesValue: true, meaning: "the lead token (or TOOLROLL_LEAD_TOKEN)" }],
73
+ flags: [jsonFlag, { name: "token", takesValue: true, meaning: "the lead token (or TOOLROLL_LEAD_TOKEN)" },
74
+ { name: "remote", takesValue: true, meaning: "an HTTPS or SSH URL without credentials whose main may complete landed work; replaces a different recorded one (owner or an instance operator's lead only)" }],
73
75
  notableReasons: ["not-git", "unavailable", "refused", "locked"] },
74
76
  { invocation: "repos remove", synopsis: "remove a project from the console and the builder; its tasks and results stay saved", audience: "agent", agentMayInvoke: true, mutation: "identity-idempotent",
75
77
  positionals: [{ name: "path", required: true, meaning: "the repository" }],
@@ -114,8 +116,9 @@ const GUIDE_ROWS = [
114
116
  ...KNOWLEDGE_DESCRIPTORS.map(spec => ({ invocation: `knowledge ${spec.action}`, synopsis: spec.synopsis, audience: "audience" in spec ? spec.audience : "agent", agentMayInvoke: !("audience" in spec), mutation: spec.mutation, flags: spec.flags, ...(spec.takesQuery ? { positionals: [{ name: "query", required: true, meaning: "search text or source file for impact" }] } : {}) })),
115
117
  ...MEMORY_DESCRIPTORS.map(spec => ({ invocation: `memory ${spec.action}`, synopsis: spec.synopsis, audience: "agent", agentMayInvoke: true, mutation: spec.mutation, flags: spec.flags, ...(spec.takesQuery ? { positionals: [{ name: "query", required: true, meaning: "search text, a decision id, or the decision sentence" }] } : {}) })),
116
118
  // Flow writes carry an approver's credential: an agent runs them only as the person asked, after showing the preview.
117
- ...FLOWS_DESCRIPTORS.map(spec => ({ invocation: `flows ${spec.action}`, synopsis: spec.synopsis, audience: "agent", agentMayInvoke: true, mutation: spec.mutation, flags: spec.flags,
118
- ...("positionals" in spec ? { positionals: spec.positionals.map(name => ({ name, required: true, meaning: name === "flow" ? "a flow id from flows list" : name === "card" ? "a card id from flows show" : "a trigger id from flows show, or for trigger add its settings as JSON, a JSON file, or -" })) } : {}) })),
119
+ // Letting a flow run on its own raises authority: the owner's own password ceremony, never an agent's act.
120
+ ...FLOWS_DESCRIPTORS.map(spec => ({ invocation: `flows ${spec.action}`, synopsis: spec.synopsis, audience: "audience" in spec ? spec.audience : "agent", agentMayInvoke: !("audience" in spec), mutation: spec.mutation, flags: spec.flags,
121
+ ...("positionals" in spec ? { positionals: spec.positionals.map(name => ({ name, required: true, meaning: name === "flow" ? "a flow id from flows list" : name === "card" ? "a card id from flows show" : name === "on|off" ? "on signs the grant, off revokes it" : "a trigger id from flows show, or for trigger add its settings as JSON, a JSON file, or -" })) } : {}) })),
119
122
  ...MODELS_DESCRIPTORS.map(spec => ({ invocation: `models ${spec.action}`, synopsis: spec.synopsis, audience: "agent", agentMayInvoke: true, mutation: spec.mutation, flags: spec.flags, ...(spec.takesQuery ? { positionals: [{ name: "target", required: true, meaning: "the CLI to update, or on/off" }] } : {}) })),
120
123
  // ---- the queue (agent surface) ----
121
124
  { invocation: "status", synopsis: "running work, queued reasons, results to review, the latest release check and plan windows", audience: "agent", agentMayInvoke: true, mutation: "none", flags: [jsonFlag, dbFlag] },
@@ -409,6 +412,7 @@ const GUIDE_ROWS = [
409
412
  operator("up", "console + worker + browser, one command"),
410
413
  operator("onboard", "add this repository, report signed-in agents, install or --remove the operator skill (--yes), offer the starter flows (--starter <ids>), and print the handoff"),
411
414
  operator("daemon", "install the loop under the OS service manager"),
415
+ { ...operatorRead("doctor", "check that Toolroll's service holds no key, token or password a build could read; names what it finds and the steps to fix it, never a value, and changes nothing"), flags: [jsonFlag] },
412
416
  ];
413
417
  /**
414
418
  * Whether each row may run on a central server for a person signed in with their own API token (`runOperateAs` in
@@ -427,7 +431,7 @@ const REMOTE_NO = [
427
431
  // Workers, the loop and the services that run here.
428
432
  "claim", "heartbeat", "worker release", "reap", "tick", "build", "reconcile",
429
433
  "runner register", "runner retire", "runner bind", "runner capacity", "coordinator mint", "coordinator revoke", "mcp", "outbox deliver", "peek",
430
- "serve", "serve check-public", "watch", "up", "onboard", "daemon", "bridge", "setup show", "setup clear",
434
+ "serve", "serve check-public", "watch", "up", "onboard", "daemon", "doctor", "bridge", "setup show", "setup clear",
431
435
  // Provider keys and model tools on this machine.
432
436
  "providers", "keys status", "keys set", "keys clear", "keys verify", "keys auth", "models check", "models update", "models watch",
433
437
  // Publication and release.
@@ -441,7 +445,7 @@ const REMOTE_NO = [
441
445
  ];
442
446
  const REMOTE_STEP_UP = [
443
447
  "proposals", "decide", "task regate", "task approve", "task accept", "task merge", "run settle", "knowledge apply", "memory apply",
444
- "flows card approve", "flows card send-back", "flows script approve",
448
+ "flows card approve", "flows card send-back", "flows script approve", "flows unattended",
445
449
  "approver list", "approver add", "people list", "people invite", "people projects", "people revoke",
446
450
  // A person's own API tokens: a password step-up on the server's machine, or the console. Never with a token.
447
451
  "tokens create", "tokens list", "tokens revoke", "tokens rotate",
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Where a task's work starts, against current main (fresh base): one view for
3
+ * the task page and `task show`. The base is saved run lineage — a builder
4
+ * branch's pinned origin, or the newest plan's base — and the behind count is
5
+ * read from Git now. Reads only.
6
+ */
7
+ import type { Store } from "./store.js";
8
+ export type TaskBaseView = {
9
+ /** The base commit, in full. */
10
+ base: string;
11
+ branch: string;
12
+ /** The project's main: its checked-out branch, as a dispatch pass resolves it. Null when Git could not say. */
13
+ main: string | null;
14
+ /** Commits on main the base does not have; null when Git could not say. */
15
+ behind: number | null;
16
+ /** One short line: `Base 9d087efe · Behind main by 2 commits`. */
17
+ words: string;
18
+ };
19
+ /** A synchronous, read-only Git call in `cwd`: its trimmed output, or null on any failure. */
20
+ export type GitRead = (cwd: string, args: readonly string[]) => string | null;
21
+ export declare const readGit: GitRead;
22
+ /** The base line on its own, for surfaces that label it already: `9d087efe · Behind main by 2 commits`. */
23
+ export declare function baseStandingWords(view: Pick<TaskBaseView, "base" | "main" | "behind">): string;
24
+ /**
25
+ * The lease's fresh-base request for one attempt on `branch`, or nothing when the task's start is pinned elsewhere: a
26
+ * revision keeps the head of the attempt it revises, and a prepared candidate or coding review keeps the base its
27
+ * candidate was proved against.
28
+ */
29
+ export declare function freshBaseFor(store: Store, taskId: string, branch: string, main: string): {
30
+ freshBase: {
31
+ main: string;
32
+ recordedBase: string | null;
33
+ };
34
+ } | Record<string, never>;
35
+ export declare function taskBaseOf(store: Store, taskId: string, git?: GitRead): TaskBaseView | null;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Where a task's work starts, against current main (fresh base): one view for
3
+ * the task page and `task show`. The base is saved run lineage — a builder
4
+ * branch's pinned origin, or the newest plan's base — and the behind count is
5
+ * read from Git now. Reads only.
6
+ */
7
+ import { execFileSync } from "node:child_process";
8
+ export const readGit = (cwd, args) => {
9
+ try {
10
+ return execFileSync("git", ["--no-optional-locks", ...args], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000, maxBuffer: 65_536 }).trim();
11
+ }
12
+ catch {
13
+ return null;
14
+ }
15
+ };
16
+ const commits = (count) => `${count} commit${count === 1 ? "" : "s"}`;
17
+ /** The base line on its own, for surfaces that label it already: `9d087efe · Behind main by 2 commits`. */
18
+ export function baseStandingWords(view) {
19
+ const short = view.base.slice(0, 8);
20
+ if (view.main === null || view.behind === null)
21
+ return short;
22
+ return view.behind === 0 ? `${short} · Up to date with ${view.main}` : `${short} · Behind ${view.main} by ${commits(view.behind)}`;
23
+ }
24
+ /**
25
+ * The lease's fresh-base request for one attempt on `branch`, or nothing when the task's start is pinned elsewhere: a
26
+ * revision keeps the head of the attempt it revises, and a prepared candidate or coding review keeps the base its
27
+ * candidate was proved against.
28
+ */
29
+ export function freshBaseFor(store, taskId, branch, main) {
30
+ const ref = store.lookupRef(taskId);
31
+ if (ref === null || ref.revisionOf !== null || store.getScope(taskId)?.candidate != null || store.filedViaOf(taskId)?.startsWith("coding:") === true)
32
+ return {};
33
+ return { freshBase: { main, recordedBase: store.recordedBranchBase(ref.id, branch) } };
34
+ }
35
+ export function taskBaseOf(store, taskId, git = readGit) {
36
+ const ref = store.lookupRef(taskId);
37
+ if (ref === null || ref.repo === null)
38
+ return null;
39
+ const latest = store.runsFor(ref.id).find(one => one.role !== "reviewer" && one.branch !== null && one.baseRevision !== null);
40
+ if (latest === undefined || latest.branch === null || latest.baseRevision === null)
41
+ return null;
42
+ const branch = latest.branch;
43
+ // A move to current main newer than the latest attempt is where the next one starts.
44
+ const move = store.latestBranchBaseMove(ref.id, branch);
45
+ const base = move !== null && move.afterRun >= latest.id ? move.to : store.recordedBranchBase(ref.id, branch) ?? latest.baseRevision;
46
+ const main = git(ref.repo, ["symbolic-ref", "--short", "-q", "HEAD"]) || null;
47
+ const counted = main === null ? null : git(ref.repo, ["rev-list", "--count", `${base}..refs/heads/${main}`]);
48
+ const behind = counted === null || !/^\d+$/.test(counted) ? null : Number(counted);
49
+ const view = { base, branch, main: behind === null ? null : main, behind };
50
+ return { ...view, words: `Base ${baseStandingWords(view)}` };
51
+ }