toolroll 0.9.90 → 0.9.92

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 (80) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/approval-authority.d.ts +9 -1
  3. package/dist/approval-authority.js +15 -2
  4. package/dist/build-review.js +2 -0
  5. package/dist/chat-actions.d.ts +11 -3
  6. package/dist/chat-actions.js +47 -2
  7. package/dist/chat-decide.js +2 -0
  8. package/dist/chat-review.d.ts +2 -1
  9. package/dist/chat-review.js +2 -2
  10. package/dist/contracts/chat-actions.d.ts +10 -0
  11. package/dist/contracts/chat-actions.js +4 -0
  12. package/dist/contracts/console-api.d.ts +1 -0
  13. package/dist/contracts/console-api.js +3 -0
  14. package/dist/contracts/route-results.d.ts +121 -0
  15. package/dist/contracts/route-results.js +71 -0
  16. package/dist/contracts/route.d.ts +343 -5
  17. package/dist/contracts/route.js +36 -7
  18. package/dist/contracts/store-json.d.ts +2 -0
  19. package/dist/dispose.js +6 -0
  20. package/dist/flow-engine.js +1 -1
  21. package/dist/flow-pull-request.js +1 -1
  22. package/dist/lead-doors.js +4 -2
  23. package/dist/lead-tools.js +10 -2
  24. package/dist/local-owner.d.ts +1 -1
  25. package/dist/local-owner.js +11 -0
  26. package/dist/memory-cli.d.ts +3 -0
  27. package/dist/memory-cli.js +3 -3
  28. package/dist/memory-pass.js +5 -1
  29. package/dist/model-choices.d.ts +7 -3
  30. package/dist/model-choices.js +26 -11
  31. package/dist/models-cli.d.ts +205 -0
  32. package/dist/models-cli.js +143 -4
  33. package/dist/models-ui.d.ts +57 -0
  34. package/dist/models-ui.js +44 -1
  35. package/dist/operate-remote-arguments.js +4 -4
  36. package/dist/operate-remote.js +1 -1
  37. package/dist/operate.js +53 -5
  38. package/dist/phase-routing.d.ts +82 -2
  39. package/dist/phase-routing.js +132 -18
  40. package/dist/project-delete.d.ts +4 -0
  41. package/dist/project-delete.js +12 -4
  42. package/dist/project-knowledge.d.ts +6 -6
  43. package/dist/project-memory.d.ts +8 -0
  44. package/dist/project-memory.js +20 -1
  45. package/dist/pull-request-flow.js +1 -1
  46. package/dist/release-adapters.d.ts +2 -0
  47. package/dist/release-adapters.js +30 -1
  48. package/dist/release-auto.js +12 -3
  49. package/dist/result-actions.d.ts +4 -0
  50. package/dist/result-actions.js +3 -0
  51. package/dist/route-hooks.d.ts +54 -0
  52. package/dist/route-hooks.js +179 -0
  53. package/dist/route-results.d.ts +70 -0
  54. package/dist/route-results.js +340 -0
  55. package/dist/route-rules.d.ts +324 -0
  56. package/dist/route-rules.js +781 -0
  57. package/dist/route-savings.d.ts +136 -0
  58. package/dist/route-savings.js +193 -0
  59. package/dist/route-store.d.ts +300 -0
  60. package/dist/route-store.js +598 -0
  61. package/dist/scope.d.ts +3 -1
  62. package/dist/scope.js +4 -1
  63. package/dist/server/render-chat.js +2 -2
  64. package/dist/server/render-tasks.d.ts +7 -0
  65. package/dist/server/render-tasks.js +13 -0
  66. package/dist/server/route-table.js +12 -0
  67. package/dist/server/settings.d.ts +2 -1
  68. package/dist/server/settings.js +87 -1
  69. package/dist/server/task-views.js +6 -0
  70. package/dist/server/tasks.js +3 -0
  71. package/dist/settings-proofs.js +2 -0
  72. package/dist/setup-guide.d.ts +2 -2
  73. package/dist/spend-ui.d.ts +11 -0
  74. package/dist/spend-ui.js +49 -2
  75. package/dist/store.d.ts +38 -2
  76. package/dist/store.js +320 -35
  77. package/dist/surface.js +16 -4
  78. package/dist/task-outcome-cli.js +5 -2
  79. package/dist/toolroll-update.js +3 -2
  80. package/package.json +1 -1
package/dist/store.js CHANGED
@@ -35,6 +35,7 @@ import { actorLabel, currentActor } from "./actor.js";
35
35
  import { scanForSecrets } from "./evidence.js";
36
36
  import { LIVE_ACTIVITY_KINDS } from "./live.js";
37
37
  import { hashSecret, mintLeadToken, parseCredential, secretMatches, tokenPurpose, tokenRotationProblem } from "./api-tokens.js";
38
+ import { admitTrialRun, bumpModelsGeneration, forgetProjectRoutes, liveEarnedRules, liveTrialFor, noteExposureCancelled, noteExposureFiled, noteExposureSealed, releaseTrialPlaces, reserveTrialPlace, ruleHoldOf, settleTrialPlaces, trialSealProblem, writeRunCohort } from "./route-store.js";
38
39
  /**
39
40
  * The database: a small task store, and the operational overlay beside it.
40
41
  *
@@ -1110,7 +1111,10 @@ const REGISTERED_AT_FILING = "again, as it was filed";
1110
1111
  // read 1), a browser session's authority (web_session.authority; saved rows read 'full'), its local generation, key
1111
1112
  // epoch and key hash (saved rows read NULL: no local binding, and a stale key), and the running console's key epoch
1112
1113
  // (console_instance.key_epoch; NULL). V147_ADDED_COLUMNS.
1113
- export const SCHEMA_VERSION = 147;
1114
+ // v148 adds, and changes no saved row (docs/plans/results-routing.md): the results-routing tables (V148_SCHEMA, new
1115
+ // tables only) and a run's execution class, a revision's origin and a project's "Suggest cheaper models" switch
1116
+ // (V148_ADDED_COLUMNS: saved rows read NULL, NULL and 1).
1117
+ export const SCHEMA_VERSION = 148;
1114
1118
  /** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
1115
1119
  export const V118_MERGED_TABLES = Object.freeze({ api_token: "person", coordinator_credential: "coordinator", lead_credential: "lead" });
1116
1120
  /** The foreign keys that named a merged table, exactly as their DDL wrote them; each names `credential(id)` instead. */
@@ -1243,7 +1247,7 @@ const V115_DROPPED_REFERENCES = /\s+REFERENCES (?:contestant|contest|tournament_
1243
1247
  * unchanged), and adds the nullable column in V125_ADDED_COLUMNS, checked the same way. v126 only adds a new table
1244
1248
  * (task_source) and the columns in V126_ADDED_COLUMNS (null or 0), checked the same way.
1245
1249
  */
1246
- export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147]);
1250
+ export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148]);
1247
1251
  /** v119: the columns it adds, by table, with the value every row saved before it reads (toolroll-update's HISTORY_RULES). */
1248
1252
  export const V119_ADDED_COLUMNS = Object.freeze({
1249
1253
  task_ref: { planning_generation: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" } },
@@ -1498,6 +1502,154 @@ export const V147_ADDED_COLUMNS = Object.freeze({
1498
1502
  });
1499
1503
  /** The v147 migration's steps after the epoch, in order: a test seam (OpenOptions.migrationStep) may stop after each. */
1500
1504
  export const V147_STEPS = Object.freeze(["epoch", "local-mode", "password-set", "web-session", "console-instance"]);
1505
+ /**
1506
+ * v148 (docs/plans/results-routing.md): every table the results-routing arc uses, landed at once because a current
1507
+ * store runs no DDL. Slice 1 writes run_cohort, route_record and models_generation; route_rule, route_exposure* and
1508
+ * route_trial_reservation are written from later slices. src/route-store.ts is the only writer of all of them (the
1509
+ * migration aside), which release-auto.ts's routing scan (route-hooks.ts) holds a release to.
1510
+ *
1511
+ * - run_cohort: one row per code-writing run (builder, repair), written in the run's admission transaction beside its
1512
+ * route stamp and never updated: the sealed facts its eligibility was decided from. cohort_size NULL = not eligible,
1513
+ * with the reason in `ineligible`.
1514
+ * - route_record: a task family's result record (keyed by its root task_ref), recomputed by the reconcile pass.
1515
+ * `settled` = its verdict (state, reason, result, acceptance, decision time) is final; its spending and counts keep
1516
+ * accumulating through later revision and repair runs until `closed` (the family is quiet, or 90 days after its
1517
+ * first run: route-results.ts ACCUMULATE_FOR_MS).
1518
+ * - route_rule: an earned rule or a trial, with its sealed grant, price snapshot and the configuration generations it
1519
+ * depends on; one live winner per project and size (route_rule_one_winner).
1520
+ * - route_exposure / route_exposure_event: one exposure per task and rule, its events append-only.
1521
+ * - route_trial_reservation: a trial's place, reserved, then consumed (once, at admission) or released.
1522
+ * - models_generation: a counter per (scope, phase), moved by every Models row writer in the same transaction.
1523
+ */
1524
+ export const V148_SCHEMA = `
1525
+ CREATE TABLE IF NOT EXISTS run_cohort (
1526
+ run INTEGER PRIMARY KEY REFERENCES run(id) ON DELETE CASCADE,
1527
+ task_ref INTEGER NOT NULL REFERENCES task_ref(id) ON DELETE CASCADE,
1528
+ root_ref INTEGER NOT NULL REFERENCES task_ref(id) ON DELETE CASCADE,
1529
+ repo TEXT,
1530
+ role TEXT NOT NULL CHECK (role IN ('builder','repair')),
1531
+ size TEXT CHECK (size IS NULL OR size IN ('small','medium','large')),
1532
+ risky INTEGER CHECK (risky IS NULL OR risky IN (0,1)),
1533
+ quality_mode TEXT NOT NULL CHECK (quality_mode IN ('default','strict')),
1534
+ evidence_demands INTEGER NOT NULL CHECK (evidence_demands IN (0,1)),
1535
+ publication TEXT NOT NULL CHECK (publication IN ('none','notify','automerge')),
1536
+ release INTEGER NOT NULL CHECK (release IN (0,1)),
1537
+ protected INTEGER NOT NULL CHECK (protected IN (0,1)),
1538
+ selection_json TEXT NOT NULL,
1539
+ route_digest TEXT NOT NULL,
1540
+ provider TEXT NOT NULL,
1541
+ model TEXT,
1542
+ cohort_size TEXT CHECK (cohort_size IS NULL OR cohort_size IN ('small','medium')),
1543
+ ineligible TEXT,
1544
+ started_at TEXT NOT NULL,
1545
+ CHECK ((cohort_size IS NULL) = (ineligible IS NOT NULL))
1546
+ );
1547
+ CREATE INDEX IF NOT EXISTS run_cohort_by_task ON run_cohort (task_ref);
1548
+ CREATE INDEX IF NOT EXISTS run_cohort_by_root ON run_cohort (repo, root_ref);
1549
+ CREATE TABLE IF NOT EXISTS route_record (
1550
+ task_ref INTEGER PRIMARY KEY REFERENCES task_ref(id) ON DELETE CASCADE,
1551
+ repo TEXT,
1552
+ cohort_size TEXT CHECK (cohort_size IS NULL OR cohort_size IN ('small','medium')),
1553
+ provider TEXT,
1554
+ model TEXT,
1555
+ state TEXT NOT NULL CHECK (state IN ('pending','provisional','mature','rejected','not-counted')),
1556
+ reason TEXT,
1557
+ result_run INTEGER REFERENCES run(id) ON DELETE SET NULL,
1558
+ first_started_at TEXT,
1559
+ accepted_at TEXT,
1560
+ decided_at TEXT,
1561
+ model_failures INTEGER NOT NULL DEFAULT 0 CHECK (model_failures >= 0),
1562
+ infra_failures INTEGER NOT NULL DEFAULT 0 CHECK (infra_failures >= 0),
1563
+ api_microusd INTEGER NOT NULL DEFAULT 0 CHECK (api_microusd >= 0),
1564
+ subscription_runs INTEGER NOT NULL DEFAULT 0 CHECK (subscription_runs >= 0),
1565
+ subscription_tokens INTEGER NOT NULL DEFAULT 0 CHECK (subscription_tokens >= 0),
1566
+ unknown_runs INTEGER NOT NULL DEFAULT 0 CHECK (unknown_runs >= 0),
1567
+ remediation_runs INTEGER NOT NULL DEFAULT 0 CHECK (remediation_runs >= 0),
1568
+ person_revisions INTEGER NOT NULL DEFAULT 0 CHECK (person_revisions >= 0),
1569
+ first_check_passed INTEGER CHECK (first_check_passed IS NULL OR first_check_passed IN (0,1)),
1570
+ agent_turns INTEGER NOT NULL DEFAULT 0 CHECK (agent_turns >= 0),
1571
+ turns_unknown_runs INTEGER NOT NULL DEFAULT 0 CHECK (turns_unknown_runs >= 0),
1572
+ provenance_json TEXT NOT NULL DEFAULT '[]',
1573
+ settled INTEGER NOT NULL DEFAULT 0 CHECK (settled IN (0,1)),
1574
+ closed INTEGER NOT NULL DEFAULT 0 CHECK (closed IN (0,1)),
1575
+ evaluated_at TEXT NOT NULL,
1576
+ CHECK ((state IN ('rejected','not-counted')) = (reason IS NOT NULL)),
1577
+ CHECK (closed = 0 OR settled = 1)
1578
+ );
1579
+ CREATE INDEX IF NOT EXISTS route_record_by_cohort ON route_record (repo, cohort_size, state, first_started_at);
1580
+ CREATE INDEX IF NOT EXISTS route_record_open ON route_record (evaluated_at) WHERE closed = 0;
1581
+ CREATE TABLE IF NOT EXISTS route_rule (
1582
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1583
+ repo TEXT NOT NULL,
1584
+ cohort_size TEXT NOT NULL CHECK (cohort_size IN ('small','medium')),
1585
+ kind TEXT NOT NULL CHECK (kind IN ('rule','trial')),
1586
+ provider TEXT NOT NULL,
1587
+ model TEXT NOT NULL,
1588
+ configured_provider TEXT NOT NULL,
1589
+ configured_model TEXT,
1590
+ state TEXT NOT NULL CHECK (state IN ('active','trialling','paused','switched-off','turned-off','expired','dropped','finished')),
1591
+ generation INTEGER NOT NULL DEFAULT 1 CHECK (generation >= 1),
1592
+ grant_json TEXT NOT NULL,
1593
+ prices_json TEXT NOT NULL,
1594
+ depends_json TEXT NOT NULL,
1595
+ trial_limit INTEGER CHECK (trial_limit IS NULL OR trial_limit >= 1),
1596
+ accepted_by TEXT NOT NULL,
1597
+ accepted_at TEXT NOT NULL,
1598
+ ended_at TEXT,
1599
+ ended_by TEXT,
1600
+ end_reason TEXT,
1601
+ CHECK ((kind = 'trial') = (trial_limit IS NOT NULL))
1602
+ );
1603
+ CREATE UNIQUE INDEX IF NOT EXISTS route_rule_one_winner ON route_rule (repo, cohort_size) WHERE state IN ('active','trialling','paused');
1604
+ CREATE TABLE IF NOT EXISTS route_exposure (
1605
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1606
+ task_ref INTEGER NOT NULL REFERENCES task_ref(id) ON DELETE CASCADE,
1607
+ rule INTEGER NOT NULL REFERENCES route_rule(id) ON DELETE CASCADE,
1608
+ created_at TEXT NOT NULL,
1609
+ closed_at TEXT,
1610
+ UNIQUE (task_ref, rule)
1611
+ );
1612
+ CREATE TABLE IF NOT EXISTS route_exposure_event (
1613
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1614
+ exposure INTEGER NOT NULL REFERENCES route_exposure(id) ON DELETE CASCADE,
1615
+ kind TEXT NOT NULL CHECK (kind IN ('proposed','refiled','approved-as-proposed','approved-with-other','cancelled')),
1616
+ model TEXT,
1617
+ at TEXT NOT NULL,
1618
+ CHECK ((kind = 'approved-with-other') = (model IS NOT NULL))
1619
+ );
1620
+ CREATE INDEX IF NOT EXISTS route_exposure_event_by_exposure ON route_exposure_event (exposure, id);
1621
+ CREATE TABLE IF NOT EXISTS route_trial_reservation (
1622
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1623
+ rule INTEGER NOT NULL REFERENCES route_rule(id) ON DELETE CASCADE,
1624
+ task_ref INTEGER NOT NULL REFERENCES task_ref(id) ON DELETE CASCADE,
1625
+ scope_digest TEXT NOT NULL,
1626
+ state TEXT NOT NULL CHECK (state IN ('reserved','consumed','released')),
1627
+ consumed_run INTEGER REFERENCES run(id) ON DELETE SET NULL,
1628
+ created_at TEXT NOT NULL,
1629
+ updated_at TEXT NOT NULL,
1630
+ UNIQUE (rule, task_ref),
1631
+ CHECK (state = 'consumed' OR consumed_run IS NULL)
1632
+ );
1633
+ CREATE TABLE IF NOT EXISTS models_generation (
1634
+ scope TEXT NOT NULL,
1635
+ phase TEXT NOT NULL,
1636
+ generation INTEGER NOT NULL CHECK (generation >= 1),
1637
+ changed_at TEXT NOT NULL,
1638
+ PRIMARY KEY (scope, phase)
1639
+ );
1640
+ `;
1641
+ /** The tables V148_SCHEMA adds, in the order a test drops them to make the v147 shape. */
1642
+ export const V148_TABLES = Object.freeze(["route_trial_reservation", "route_exposure_event", "route_exposure", "route_rule", "route_record", "run_cohort", "models_generation"]);
1643
+ /** v148: a run's execution class (saved runs read NULL: none kept, which the records count as the model's), a
1644
+ * revision's origin (saved revisions read NULL: not recorded, never a person), and a project's "Suggest cheaper models"
1645
+ * switch (saved projects read 1: on). */
1646
+ export const V148_ADDED_COLUMNS = Object.freeze({
1647
+ run: { exec_class: { definition: "TEXT CHECK (exec_class IS NULL OR exec_class IN ('model','infrastructure'))", saved: "NULL" } },
1648
+ task_ref: { revision_origin: { definition: "TEXT CHECK (revision_origin IS NULL OR revision_origin IN ('person','automatic-review','flow','lead','machine'))", saved: "NULL" } },
1649
+ project: { suggest_models: { definition: "INTEGER NOT NULL DEFAULT 1 CHECK (suggest_models IN (0,1))", saved: "1" } },
1650
+ });
1651
+ /** The v148 migration's steps after v147's, in order (OpenOptions.migrationStep may stop after each). */
1652
+ export const V148_STEPS = Object.freeze(["results-tables", "exec-class", "revision-origin", "suggest-models"]);
1501
1653
  /**
1502
1654
  * v135: one approval per release (docs/plans/release-once.md). `release_authority` is the single source of truth for a
1503
1655
  * release a person approved: one row per generation, changed only by compare-and-swap on `rev`, with a lease whose
@@ -4776,6 +4928,19 @@ CREATE INDEX IF NOT EXISTS run_checkpoint_by_task ON run_checkpoint (task_ref, i
4776
4928
  for (const [column, { definition }] of Object.entries(V147_ADDED_COLUMNS["console_instance"]))
4777
4929
  addColumn(db, "console_instance", column, definition);
4778
4930
  step("console-instance");
4931
+ // v148: the results-routing tables (new tables only) and the execution class, revision origin and suggestion switch
4932
+ // (V148_ADDED_COLUMNS: saved rows read their declared values). Each step is idempotent, as v147's are.
4933
+ db.exec(V148_SCHEMA);
4934
+ step("results-tables");
4935
+ for (const [column, { definition }] of Object.entries(V148_ADDED_COLUMNS["run"]))
4936
+ addColumn(db, "run", column, definition);
4937
+ step("exec-class");
4938
+ for (const [column, { definition }] of Object.entries(V148_ADDED_COLUMNS["task_ref"]))
4939
+ addColumn(db, "task_ref", column, definition);
4940
+ step("revision-origin");
4941
+ for (const [column, { definition }] of Object.entries(V148_ADDED_COLUMNS["project"]))
4942
+ addColumn(db, "project", column, definition);
4943
+ step("suggest-models");
4779
4944
  // Keep the epoch until reclamation succeeds, including a retry after compaction already committed. A new file has
4780
4945
  // nothing to reclaim.
4781
4946
  if (!fresh)
@@ -6166,6 +6331,12 @@ export class Store {
6166
6331
  }
6167
6332
  if (ref !== undefined && prior?.["state"] !== "cancelled")
6168
6333
  this.noteTaskAct(Number(ref["id"]), "cancelled", now);
6334
+ // Results routing: a cancel closes the task's open exposure to an earned rule.
6335
+ if (ref !== undefined)
6336
+ noteExposureCancelled(this, Number(ref["id"]), now);
6337
+ // …and gives up any trial place it holds unconsumed (a consumed place is never released).
6338
+ if (ref !== undefined)
6339
+ releaseTrialPlaces(this, Number(ref["id"]), now);
6169
6340
  // A cancelled task's pending steering settles superseded in the SAME
6170
6341
  // transaction, whichever road cancelled it — a note silently waiting
6171
6342
  // for a build that can no longer happen is wrong on every road, not
@@ -7293,6 +7464,9 @@ export class Store {
7293
7464
  const problems = routeProblems(route);
7294
7465
  if (problems.length > 0)
7295
7466
  unresolvedReason = problems.join("; ");
7467
+ // A trial's place is one task's (results routing slice 3): a frozen route never carries one to another.
7468
+ else if (legOf(route, "build").selection?.kind === "trial")
7469
+ unresolvedReason = "a frozen agent route can't try a cheaper model — a trial place belongs to the task it was reserved for";
7296
7470
  }
7297
7471
  }
7298
7472
  else if (!candidatesResolved.ok) {
@@ -7308,7 +7482,16 @@ export class Store {
7308
7482
  unresolvedReason = planPin.problem;
7309
7483
  }
7310
7484
  else {
7311
- route = recommendRoute({
7485
+ // Results routing (route v3): the project's live earned rules and trial, and the two eligibility facts the
7486
+ // route doesn't carry. A release or candidate is read conservatively: terms this filing may still drop count.
7487
+ const earnedBase = ref === null || ref.backend !== BUILT_IN || ref.repo === null ? null : {
7488
+ rules: liveEarnedRules(this, ref.repo),
7489
+ release: (scope.candidate ?? null) !== null || options.releaseTerms != null || options.releaseCheck != null
7490
+ || prior?.["release_terms_json"] != null || prior?.["release_check_json"] != null || releaseTaskRef(this, ref.id) || nightlyTaskRef(this, ref.id),
7491
+ protected: isProtectedWork(this.approvalRules(ref.repo), scope.touches),
7492
+ };
7493
+ const trial = earnedBase === null || ref === null ? null : liveTrialFor(this, ref.repo, ref.sizing?.size);
7494
+ const routeWith = (reservation) => recommendRoute({
7312
7495
  qualityMode,
7313
7496
  evidence: [...new Set(scope.acceptance.flatMap(one => one.evidence))],
7314
7497
  publication: this.publicationAuthorityOf(ref?.repo ?? null, new Date(scope.proposedAt)),
@@ -7319,7 +7502,19 @@ export class Store {
7319
7502
  // with the route like every other input.
7320
7503
  size: ref?.sizing ?? null,
7321
7504
  headroom: this.planHeadroom(new Date(scope.proposedAt)),
7505
+ earned: earnedBase === null ? null : { ...earnedBase, ...(trial === null ? {} : { trial: { ...trial, reservation } }) },
7322
7506
  });
7507
+ // A TRIAL'S PLACE (results routing slice 3): asked first whether this filing would take the trial at all (a
7508
+ // pure probe, nothing written), then its place reserved here, in this write transaction, and the route made
7509
+ // with that place (or with none left: the configured builder runs it). A pin, an override or an ineligible
7510
+ // task never takes a place.
7511
+ route = routeWith(trial === null ? null : Number.MAX_SAFE_INTEGER);
7512
+ if (trial !== null && ref !== null && legOf(route, "build").selection?.kind === "trial") {
7513
+ route = routeWith(reserveTrialPlace(this, trial.id, ref.id, new Date(scope.proposedAt)));
7514
+ }
7515
+ else if (trial !== null) {
7516
+ route = routeWith(null);
7517
+ }
7323
7518
  const problems = routeProblems(route);
7324
7519
  if (problems.length > 0)
7325
7520
  unresolvedReason = problems.join("; ");
@@ -7463,6 +7658,16 @@ export class Store {
7463
7658
  // or a remote API run keeps any mark already there and never erases a
7464
7659
  // quarantine by refiling, and the lead's own text is marked `lead`.
7465
7660
  this.db.prepare("UPDATE task_scope SET proposed_via = ? WHERE task_id = ?").run(provenanceAfter(prior?.["proposed_via"], options.proposedVia ?? null), scope.taskId);
7661
+ // Results routing (exposure, route-store.ts): a build leg under an earned rule opens the task's one exposure to
7662
+ // it; a re-filing adds an event to an open one, never a row.
7663
+ if (ref !== null && route !== null)
7664
+ noteExposureFiled(this, ref.id, legOf(route, "build").selection, new Date(scope.proposedAt));
7665
+ // Results routing (trials, route-store.ts): the place this route seals is bound to the digest it was filed under
7666
+ // (repeated saves reuse it); any other place this task held unconsumed is released.
7667
+ if (ref !== null) {
7668
+ const selection = route === null ? undefined : legOf(route, "build").selection;
7669
+ settleTrialPlaces(this, ref.id, options.route !== undefined ? undefined : selection, boundDigest, new Date(scope.proposedAt));
7670
+ }
7466
7671
  return null;
7467
7672
  }));
7468
7673
  }
@@ -7584,6 +7789,17 @@ export class Store {
7584
7789
  return false;
7585
7790
  if (routeProblems(authority.route).length > 0)
7586
7791
  return false;
7792
+ // RESULTS ROUTING (route v3): a build leg selected by an earned rule that switched itself off since it was
7793
+ // filed seals nothing — re-filing routes it on the configured builder (route-rules.ts holds what was approved).
7794
+ const sealedSelection = legOf(authority.route, "build").selection;
7795
+ if (sealedSelection?.kind === "rule" && this.db.prepare("SELECT state FROM route_rule WHERE id = ?").get(sealedSelection.rule)?.["state"] === "switched-off")
7796
+ return false;
7797
+ // A TRIAL (results routing slice 3): only a person's own yes seals it — the ceremony or their paired-chat tap,
7798
+ // never a mode, a grant, a schedule, an automatic seal, a carry or the lead — and only while its place stands.
7799
+ const trialRef = this.db.prepare("SELECT id FROM task_ref WHERE backend = ? AND external_id = ?").get(BUILT_IN, taskId);
7800
+ if (trialSealProblem(this, { taskRef: trialRef === undefined ? null : Number(trialRef["id"]), selection: sealedSelection, digest: String(working["digest"] ?? ""),
7801
+ basis: basis === undefined ? null : basis.kind, by, machine: machineActing() }) !== null)
7802
+ return false;
7587
7803
  // RELEASE TERMS (release-once.md): only a person's own yes (the ceremony or a paired-chat tap) seals them, and
7588
7804
  // that yes opens the release's authority record in this same transaction — or the seal doesn't stand.
7589
7805
  const terms = storedReleaseTerms(working["release_terms_json"]);
@@ -7650,6 +7866,10 @@ export class Store {
7650
7866
  if (basis === undefined || basis.kind === "chat")
7651
7867
  this.noteTaskAct(Number(ref["id"]), "approved", now);
7652
7868
  this.db.prepare("DELETE FROM hold WHERE owner_kind = 'backoff' AND owner_id = ?").run(`stale:${Number(ref["id"])}`);
7869
+ // Results routing: this yes closes the task's open exposure (as proposed, or with the model it sealed),
7870
+ // and lifts the hold a switched-off rule put on the approval it replaces.
7871
+ noteExposureSealed(this, Number(ref["id"]), { selection: sealedSelection, model: legOf(authority.route, "build").model }, now);
7872
+ this.db.prepare("DELETE FROM hold WHERE owner_kind = 'backoff' AND owner_id = ?").run(`rule-off:${Number(ref["id"])}`);
7653
7873
  this.db
7654
7874
  .prepare("UPDATE notification SET resolved_at = ? WHERE dedupe_key LIKE ? AND resolved_at IS NULL")
7655
7875
  .run(now.toISOString(), `stale-approval:${Number(ref["id"])}:%`);
@@ -11778,22 +11998,32 @@ export class Store {
11778
11998
  }
11779
11999
  /** Write one complete pair. Audited: who changed spend routing, and when. */
11780
12000
  setPhaseConfig(scope, phase, provider, model, by, now) {
11781
- const was = this.phaseConfig(scope, phase);
11782
- const words = (agent) => agent === null ? "not set" : `${agent.provider} · ${agent.model ?? "its default model"}`;
11783
- this.recordPolicy(by, scope === "installation" ? null : scope, `agent for ${phase} changed`, words(was), words({ provider, model }), now);
11784
- this.db
11785
- .prepare(`INSERT INTO phase_config (scope, phase, provider, model, updated_at, updated_by)
11786
- VALUES (?, ?, ?, ?, ?, ?)
11787
- ON CONFLICT (scope, phase) DO UPDATE SET
11788
- provider = excluded.provider, model = excluded.model,
11789
- updated_at = excluded.updated_at, updated_by = excluded.updated_by`)
11790
- .run(scope, phase, provider, model, now.toISOString(), by);
12001
+ // Results routing (v148): a changed row moves its (scope, phase) generation in the same transaction, which pauses
12002
+ // any rule that depended on it (route-store.ts bumpModelsGeneration).
12003
+ this.transact(() => {
12004
+ const was = this.phaseConfig(scope, phase);
12005
+ const words = (agent) => agent === null ? "not set" : `${agent.provider} · ${agent.model ?? "its default model"}`;
12006
+ this.recordPolicy(by, scope === "installation" ? null : scope, `agent for ${phase} changed`, words(was), words({ provider, model }), now);
12007
+ this.db
12008
+ .prepare(`INSERT INTO phase_config (scope, phase, provider, model, updated_at, updated_by)
12009
+ VALUES (?, ?, ?, ?, ?, ?)
12010
+ ON CONFLICT (scope, phase) DO UPDATE SET
12011
+ provider = excluded.provider, model = excluded.model,
12012
+ updated_at = excluded.updated_at, updated_by = excluded.updated_by`)
12013
+ .run(scope, phase, provider, model, now.toISOString(), by);
12014
+ if (was === null || was.provider !== provider || was.model !== model)
12015
+ bumpModelsGeneration(this, scope, phase, now);
12016
+ });
11791
12017
  }
11792
12018
  clearPhaseConfig(scope, phase) {
11793
- const { changes } = this.db
11794
- .prepare("DELETE FROM phase_config WHERE scope = ? AND phase = ?")
11795
- .run(scope, phase);
11796
- return Number(changes) > 0;
12019
+ return this.transact(() => {
12020
+ const { changes } = this.db
12021
+ .prepare("DELETE FROM phase_config WHERE scope = ? AND phase = ?")
12022
+ .run(scope, phase);
12023
+ if (Number(changes) > 0)
12024
+ bumpModelsGeneration(this, scope, phase, new Date());
12025
+ return Number(changes) > 0;
12026
+ });
11797
12027
  }
11798
12028
  // ---- phase routing (v47) -----------------------------------------------
11799
12029
  /** The operator's named STRONG candidate for a phase at a scope, or null.
@@ -11827,16 +12057,25 @@ export class Store {
11827
12057
  // A small change makes no plan and repairs resume the builder: only the build phase has a light agent.
11828
12058
  if (tier === "light" && phase !== "build")
11829
12059
  throw new Error(`a light agent is for the build phase only, not ${phase}`);
11830
- this.db
11831
- .prepare(`INSERT INTO phase_tier_config (scope, phase, tier, provider, model, updated_at, updated_by)
11832
- VALUES (?, ?, ?, ?, ?, ?, ?)
11833
- ON CONFLICT (scope, phase, tier) DO UPDATE SET provider = excluded.provider, model = excluded.model,
11834
- updated_at = excluded.updated_at, updated_by = excluded.updated_by`)
11835
- .run(scope, phase, tier, provider, model, now.toISOString(), by);
12060
+ this.transact(() => {
12061
+ const was = this.phaseTierConfig(scope, phase, tier);
12062
+ this.db
12063
+ .prepare(`INSERT INTO phase_tier_config (scope, phase, tier, provider, model, updated_at, updated_by)
12064
+ VALUES (?, ?, ?, ?, ?, ?, ?)
12065
+ ON CONFLICT (scope, phase, tier) DO UPDATE SET provider = excluded.provider, model = excluded.model,
12066
+ updated_at = excluded.updated_at, updated_by = excluded.updated_by`)
12067
+ .run(scope, phase, tier, provider, model, now.toISOString(), by);
12068
+ if (was === null || was.provider !== provider || was.model !== model)
12069
+ bumpModelsGeneration(this, scope, phase, now);
12070
+ });
11836
12071
  }
11837
12072
  clearPhaseTierConfig(scope, phase, tier) {
11838
- const { changes } = this.db.prepare("DELETE FROM phase_tier_config WHERE scope = ? AND phase = ? AND tier = ?").run(scope, phase, tier);
11839
- return Number(changes) > 0;
12073
+ return this.transact(() => {
12074
+ const { changes } = this.db.prepare("DELETE FROM phase_tier_config WHERE scope = ? AND phase = ? AND tier = ?").run(scope, phase, tier);
12075
+ if (Number(changes) > 0)
12076
+ bumpModelsGeneration(this, scope, phase, new Date());
12077
+ return Number(changes) > 0;
12078
+ });
11840
12079
  }
11841
12080
  /** A tier's candidates on other providers at a scope, in provider order (sized routing). */
11842
12081
  phaseTierAlternates(scope, phase, tier) {
@@ -11863,19 +12102,28 @@ export class Store {
11863
12102
  setPhaseTierAlternate(scope, phase, tier, provider, model, by, now) {
11864
12103
  if (tier === "light" && phase !== "build")
11865
12104
  throw new Error(`a light agent is for the build phase only, not ${phase}`);
11866
- this.db
11867
- .prepare(`INSERT INTO phase_tier_alternate (scope, phase, tier, provider, model, updated_at, updated_by)
11868
- VALUES (?, ?, ?, ?, ?, ?, ?)
11869
- ON CONFLICT (scope, phase, tier, provider) DO UPDATE SET model = excluded.model,
11870
- updated_at = excluded.updated_at, updated_by = excluded.updated_by`)
11871
- .run(scope, phase, tier, provider, model, now.toISOString(), by);
12105
+ this.transact(() => {
12106
+ const was = this.phaseTierAlternates(scope, phase, tier).find(one => one.provider === provider) ?? null;
12107
+ this.db
12108
+ .prepare(`INSERT INTO phase_tier_alternate (scope, phase, tier, provider, model, updated_at, updated_by)
12109
+ VALUES (?, ?, ?, ?, ?, ?, ?)
12110
+ ON CONFLICT (scope, phase, tier, provider) DO UPDATE SET model = excluded.model,
12111
+ updated_at = excluded.updated_at, updated_by = excluded.updated_by`)
12112
+ .run(scope, phase, tier, provider, model, now.toISOString(), by);
12113
+ if (was === null || was.model !== model)
12114
+ bumpModelsGeneration(this, scope, phase, now);
12115
+ });
11872
12116
  }
11873
12117
  /** Remove one provider's candidate from a tier, or every one when no provider is named; how many went. */
11874
12118
  clearPhaseTierAlternates(scope, phase, tier, provider) {
11875
- const { changes } = provider === null
11876
- ? this.db.prepare("DELETE FROM phase_tier_alternate WHERE scope = ? AND phase = ? AND tier = ?").run(scope, phase, tier)
11877
- : this.db.prepare("DELETE FROM phase_tier_alternate WHERE scope = ? AND phase = ? AND tier = ? AND provider = ?").run(scope, phase, tier, provider);
11878
- return Number(changes);
12119
+ return this.transact(() => {
12120
+ const { changes } = provider === null
12121
+ ? this.db.prepare("DELETE FROM phase_tier_alternate WHERE scope = ? AND phase = ? AND tier = ?").run(scope, phase, tier)
12122
+ : this.db.prepare("DELETE FROM phase_tier_alternate WHERE scope = ? AND phase = ? AND tier = ? AND provider = ?").run(scope, phase, tier, provider);
12123
+ if (Number(changes) > 0)
12124
+ bumpModelsGeneration(this, scope, phase, new Date());
12125
+ return Number(changes);
12126
+ });
11879
12127
  }
11880
12128
  /** Each provider's plan headroom right now, from the latest readings of
11881
12129
  * its usage windows — the routing policy's headroom input at filing. */
@@ -13288,6 +13536,14 @@ export class Store {
13288
13536
  const problem = this.routeAdmissionProblem({ taskRef: run.taskRef, role, provider: provider ?? "claude", model }, run.route, run.now);
13289
13537
  if (problem !== null)
13290
13538
  refuse(problem);
13539
+ // RESULTS ROUTING (route v3): a task approved under an earned rule that then switched itself off on its sealed
13540
+ // trigger is held — its first build never starts on that model (no strike: nothing ran). A rule a person turned
13541
+ // off, or one that expired, keeps the sealed model; a started task's later attempts finish under it.
13542
+ if (role === "builder") {
13543
+ const held = ruleHoldOf(this, taskId);
13544
+ if (held !== null)
13545
+ refuse(`rule-switched-off: results for ${held.model} dropped after this was approved — approve it again or re-file it`);
13546
+ }
13291
13547
  const stamp = run.route;
13292
13548
  const qualityMode = role === "reviewer" && parent !== null
13293
13549
  ? parent.qualityMode ?? "default"
@@ -13319,6 +13575,16 @@ export class Store {
13319
13575
  // The provenance row lands in the same transaction as the run row —
13320
13576
  // proved above, written here; a run and its route are one fact.
13321
13577
  this.writeRunRoute(id, stamp, run.now);
13578
+ // Results routing (v148): a code-writing run's cohort snapshot, beside its route in this same transaction and
13579
+ // never updated (route-store.ts writeRunCohort; a snapshot it can't compute is recorded as not eligible).
13580
+ if (role === "builder" || role === "repair") {
13581
+ writeRunCohort(this, { run: id, taskRef: run.taskRef, role, routeDigest: stamp.routeDigest, provider: provider ?? "claude", model, qualityMode, startedAt: run.now.toISOString() });
13582
+ // A TRIAL (results routing slice 3): a person's own approval, and its place consumed exactly once against the
13583
+ // sealed digest (a retry or repair of the same task consumes nothing more). Refused → the row rolls back.
13584
+ const trialProblem = admitTrialRun(this, { taskId, taskRef: run.taskRef, role, run: id, now: run.now });
13585
+ if (trialProblem !== null)
13586
+ refuse(trialProblem);
13587
+ }
13322
13588
  if (role === "reviewer" && parent?.role === "reviewer" && parent.watchIncarnation != null) {
13323
13589
  this.db.prepare("UPDATE run SET watch_incarnation = ? WHERE id = ?").run(parent.watchIncarnation, id);
13324
13590
  }
@@ -22376,3 +22642,22 @@ export function laptopPortOf(store) {
22376
22642
  export function recordLaptopPort(store, port, now) {
22377
22643
  store.recordInstallationFact(LAPTOP_PORT_FACT, String(port), now);
22378
22644
  }
22645
+ // ---- v148: results routing (docs/plans/results-routing.md) ------------------------------------------------------
22646
+ /** The Models rows of a project being deleted (project-delete.ts), through the same counter as every other Models
22647
+ * writer: each phase whose rows go moves its generation (pausing what depended on it), then the project's
22648
+ * results-routing rows go (route-store.ts forgetProjectRoutes). One of the routing scan's named phase_* writers
22649
+ * (route-hooks.ts). How many rows went. */
22650
+ export function forgetProjectModels(store, repo, now) {
22651
+ return store.transact(() => {
22652
+ const db = store.handle;
22653
+ let rows = 0;
22654
+ const phases = new Set(db.prepare(`SELECT phase FROM phase_config WHERE scope = ? UNION SELECT phase FROM phase_tier_config WHERE scope = ?
22655
+ UNION SELECT phase FROM phase_tier_alternate WHERE scope = ?`).all(repo, repo, repo).map(row => String(row["phase"])));
22656
+ rows += Number(db.prepare("DELETE FROM phase_config WHERE scope = ?").run(repo).changes);
22657
+ rows += Number(db.prepare("DELETE FROM phase_tier_config WHERE scope = ?").run(repo).changes);
22658
+ rows += Number(db.prepare("DELETE FROM phase_tier_alternate WHERE scope = ?").run(repo).changes);
22659
+ for (const phase of [...phases].sort())
22660
+ bumpModelsGeneration(store, repo, phase, now);
22661
+ return rows + forgetProjectRoutes(store, repo);
22662
+ });
22663
+ }
package/dist/surface.js CHANGED
@@ -116,12 +116,12 @@ const GUIDE_ROWS = [
116
116
  notableReasons: ["usage", "ambiguous", "drift", "timeout", "check-failed", "ci-failed", "tree-mismatch", "conflict", "mixed-runtime", "gate-held", "after-swap"] },
117
117
  operator("demo", "a seeded throwaway sandbox"),
118
118
  ...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" }] } : {}) })),
119
- ...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" }] } : {}) })),
119
+ ...MEMORY_DESCRIPTORS.map(spec => ({ invocation: `memory ${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, a decision id, or the decision sentence" }] } : {}) })),
120
120
  // Flow writes carry an approver's credential: an agent runs them only as the person asked, after showing the preview.
121
121
  // Letting a flow run on its own raises authority: the owner's own password ceremony, never an agent's act.
122
122
  ...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,
123
123
  ...("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 -" })) } : {}) })),
124
- ...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" }] } : {}) })),
124
+ ...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: "positional" in spec ? spec.positional : "the CLI to update, or on/off" }] } : {}) })),
125
125
  // ---- the queue (agent surface) ----
126
126
  { 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] },
127
127
  { invocation: 'health', synopsis: 'recent server latency, event-loop delay, SQLite writes and live streams from the saved connection; instance operators only', audience: 'agent', agentMayInvoke: true, mutation: 'none', flags: [jsonFlag, { name: 'profile', takesValue: true, meaning: 'saved server connection' }] },
@@ -460,6 +460,11 @@ const REMOTE_NO = [
460
460
  "serve", "serve check-public", "watch", "up", "onboard", "daemon", "background on", "background off", "background show", "stop", "open", "welcome", "password set", "doctor", "bridge", "setup show", "setup clear",
461
461
  // Provider keys and model tools on this machine.
462
462
  "providers", "keys status", "keys set", "keys clear", "keys verify", "keys auth", "models check", "models update", "models watch",
463
+ // Result records read with this machine's sign-in: project access, never a token's narrower scope.
464
+ "models results",
465
+ // Earned rules (results routing): the project's own results, and its rules' doors, with this machine's sign-in; a
466
+ // remote token never changes which models run (the rule door refuses it in the write too).
467
+ "models suggestions", "models accept", "models dismiss", "models turn-off", "models keep", "models try", "models stop",
463
468
  // Publication and release.
464
469
  "publish", "publish status", "publish merge", "publish refire", "publish unblock", "publish rearm", "publish grant", "publish revoke",
465
470
  // A repository's authority grants on this machine.
@@ -474,7 +479,9 @@ const REMOTE_NO = [
474
479
  const REMOTE_STEP_UP = [
475
480
  "proposals", "decide", "task regate", "task approve", "task accept", "task merge", "run settle",
476
481
  // Putting tasks away and restoring them: a person's act, in the console or on the server's machine.
477
- "task archive", "task restore", "task tidy", "knowledge apply", "memory apply",
482
+ "task archive", "task restore", "task tidy", "knowledge apply",
483
+ // Changing project memory: a person's own password, never a token (the lead only proposes it in chat).
484
+ "memory decide", "memory retire", "memory apply",
478
485
  "flows card approve", "flows card send-back", "flows script approve", "flows unattended",
479
486
  "approver list", "approver add", "people list", "people invite", "people projects", "people revoke",
480
487
  // A person's own API tokens: a password step-up on the server's machine, or the console. Never with a token.
@@ -491,7 +498,7 @@ const REMOTE_YES = [
491
498
  "config show", "verify show", "intake show", "intake run", "intake preview", "intake pr-comments", "template list", "template show",
492
499
  "webhook status", "review show", "chat-approval show", "chat-approval off",
493
500
  "knowledge search", "knowledge impact", "knowledge refresh",
494
- "memory search", "memory decisions", "memory show", "memory decide", "memory retire", "memory review", "memory status",
501
+ "memory search", "memory decisions", "memory show", "memory review", "memory status",
495
502
  "flows list", "flows show", "flows export", "flows create", "flows edit", "flows trigger add", "flows trigger pause", "flows trigger resume",
496
503
  "flows trigger remove", "flows card add", "flows archive",
497
504
  "models status", "models list",
@@ -530,6 +537,11 @@ const LOCAL_ALLOW = {
530
537
  "task archive": "archive-task",
531
538
  "task restore": "archive-task",
532
539
  "repos add": "add-project",
540
+ // Results routing: lowering only (route-store.ts proveRuleDoor checks the act is exactly this one).
541
+ "models suggestions": "read",
542
+ "models turn-off": "routing-turn-off",
543
+ "models dismiss": "dismiss-suggestion",
544
+ "models stop": "trial-stop",
533
545
  };
534
546
  /** Every guide row's local-owner mark, by invocation: `allow` with its act, else `deny`. Exported for the inventory test. */
535
547
  export const LOCAL_POLICY = new Map(GUIDE_ROWS.map(row => [row.invocation,
@@ -4,7 +4,8 @@ import { assignmentOf, checkAssignmentAsOperator } from './assignment.js';
4
4
  import { assignmentForCoordinator, coordinatorToken } from './assignment-adapters.js';
5
5
  import { authenticateCoordinator } from './coordinator.js';
6
6
  import { reproveApprover } from './principal.js';
7
- import { authenticateApprover, personProof } from './scope.js';
7
+ import { authenticateApprover, machineActing, personProof } from './scope.js';
8
+ import { currentActor } from './actor.js';
8
9
  import { requestResultChanges } from './result-actions.js';
9
10
  import { commentSourceKey, revisionSourceOf } from './result-review.js';
10
11
  import { envelopeJson } from './envelope.js';
@@ -110,7 +111,9 @@ export async function runTaskOutcomeCommand(action, positional, flags, context)
110
111
  // The shared action returns exact replays before stale-family checks. An
111
112
  // older result cannot create another child once a newer revision is current.
112
113
  const revised = requestResultChanges(store, evidenceRoot, { run: run.id, source, actor, repos,
113
- batch: '', note: feedback, path: '', line: '', request: key, allowMode: operator !== null }, now);
114
+ batch: '', note: feedback, path: '', line: '', request: key, allowMode: operator !== null,
115
+ // A verified approver's own words; a scoped credential or the lead's token is the lead; a remote API run a machine.
116
+ origin: coordinator !== null || currentActor()?.lead === true ? 'lead' : machineActing() ? 'machine' : 'person' }, now);
114
117
  if (!revised.ok)
115
118
  return fail(revised.status === 400 ? 'usage' : 'stale-result', revised.message);
116
119
  const scope = store.getScope(revised.id);
@@ -39,7 +39,7 @@ import { installLaunchdService, launchdPlist, stopLaunchdService, writeFileDurab
39
39
  import { LAPTOP_LABEL_DARWIN, LAPTOP_LABEL_LINUX, unitFlag } from "./laptop-service.js";
40
40
  import { processGone, readRunnerProcessRecord, realProcessProbe, recordStandsFor, RUNNER_PROCESS_DIR } from "./runner-process.js";
41
41
  import { NAME } from "./names.js";
42
- import { orphanKey, updateSafeSchema, V117_RENAMED_COLUMNS, V117_RENAMED_TABLES, V118_MERGED_TABLES, V119_ADDED_COLUMNS, V120_ADDED_COLUMNS, V121_ADDED_COLUMNS, V122_ADDED_COLUMNS, V123_ADDED_COLUMNS, V125_ADDED_COLUMNS, V126_ADDED_COLUMNS, V128_ADDED_COLUMNS, V129_ADDED_COLUMNS, V134_ADDED_COLUMNS, V135_ADDED_COLUMNS, V138_ADDED_COLUMNS, V143_ADDED_COLUMNS, V147_ADDED_COLUMNS } from "./store.js";
42
+ import { orphanKey, updateSafeSchema, V117_RENAMED_COLUMNS, V117_RENAMED_TABLES, V118_MERGED_TABLES, V119_ADDED_COLUMNS, V120_ADDED_COLUMNS, V121_ADDED_COLUMNS, V122_ADDED_COLUMNS, V123_ADDED_COLUMNS, V125_ADDED_COLUMNS, V126_ADDED_COLUMNS, V128_ADDED_COLUMNS, V129_ADDED_COLUMNS, V134_ADDED_COLUMNS, V135_ADDED_COLUMNS, V138_ADDED_COLUMNS, V143_ADDED_COLUMNS, V147_ADDED_COLUMNS, V148_ADDED_COLUMNS } from "./store.js";
43
43
  import { isNewer, REGISTRY } from "./releases.js";
44
44
  import { markNeverIndex } from "./never-index.js";
45
45
  import { readRuntimeUpdateJournal, RUNTIME_PHASES, RUNTIME_UPDATE_STEPS, stagedStartedAt, updaterStartingOf } from "./contracts/update-journal.js";
@@ -413,7 +413,8 @@ const HISTORY_RULES = {
413
413
  // v128: a lead message's pending flag reads waiting exactly for a team queue's unadmitted operator message.
414
414
  // v147: an account's password flag reads set (1), a browser session reads full with no local binding or key, and the
415
415
  // console record reads no key epoch.
416
- ...Object.fromEntries(Object.entries(addedColumns(V119_ADDED_COLUMNS, V120_ADDED_COLUMNS, V121_ADDED_COLUMNS, V122_ADDED_COLUMNS, V123_ADDED_COLUMNS, V125_ADDED_COLUMNS, V126_ADDED_COLUMNS, V128_ADDED_COLUMNS, V129_ADDED_COLUMNS, V134_ADDED_COLUMNS, V135_ADDED_COLUMNS, V138_ADDED_COLUMNS, V143_ADDED_COLUMNS, V147_ADDED_COLUMNS))
416
+ // v148: a run's execution class and a revision's origin read none (NULL), and a project's suggestion switch reads on.
417
+ ...Object.fromEntries(Object.entries(addedColumns(V119_ADDED_COLUMNS, V120_ADDED_COLUMNS, V121_ADDED_COLUMNS, V122_ADDED_COLUMNS, V123_ADDED_COLUMNS, V125_ADDED_COLUMNS, V126_ADDED_COLUMNS, V128_ADDED_COLUMNS, V129_ADDED_COLUMNS, V134_ADDED_COLUMNS, V135_ADDED_COLUMNS, V138_ADDED_COLUMNS, V143_ADDED_COLUMNS, V147_ADDED_COLUMNS, V148_ADDED_COLUMNS))
417
418
  .map(([table, rule]) => [table, { ...EARLIER_RULES[table], ...rule }])),
418
419
  };
419
420
  /** The tables a migration may remove whole (v115's removed features). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolroll",
3
- "version": "0.9.90",
3
+ "version": "0.9.92",
4
4
  "description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
5
5
  "directories": {
6
6
  "doc": "docs"