toolroll 0.9.91 → 0.9.93

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 (124) 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/assignment-status.d.ts +1 -1
  5. package/dist/assignment-ui.js +2 -2
  6. package/dist/assignment.js +11 -3
  7. package/dist/browser/workspace.css +1 -1
  8. package/dist/browser/workspace.js +30 -30
  9. package/dist/browser-crew.js +2 -2
  10. package/dist/browser-workspace.d.ts +3 -5
  11. package/dist/build-review.js +2 -0
  12. package/dist/builder.js +21 -2
  13. package/dist/chat-actions.d.ts +11 -3
  14. package/dist/chat-actions.js +47 -2
  15. package/dist/chat-decide.js +2 -0
  16. package/dist/chat-review.d.ts +2 -1
  17. package/dist/chat-review.js +2 -2
  18. package/dist/check-facts.d.ts +124 -0
  19. package/dist/check-facts.js +272 -0
  20. package/dist/check-report.d.ts +5 -0
  21. package/dist/check-report.js +4 -3
  22. package/dist/cli.js +5 -0
  23. package/dist/contracts/chat-actions.d.ts +10 -0
  24. package/dist/contracts/chat-actions.js +4 -0
  25. package/dist/contracts/console-api.d.ts +3 -2
  26. package/dist/contracts/console-api.js +5 -2
  27. package/dist/contracts/release-check.d.ts +25 -1
  28. package/dist/contracts/release-check.js +19 -0
  29. package/dist/contracts/route-results.d.ts +121 -0
  30. package/dist/contracts/route-results.js +71 -0
  31. package/dist/contracts/route.d.ts +343 -5
  32. package/dist/contracts/route.js +36 -7
  33. package/dist/contracts/store-json.d.ts +2 -0
  34. package/dist/desktop-update.d.ts +2 -2
  35. package/dist/desktop-update.js +13 -9
  36. package/dist/dispose.js +6 -0
  37. package/dist/eval-hooks.d.ts +55 -0
  38. package/dist/eval-hooks.js +227 -4
  39. package/dist/flaky.d.ts +17 -0
  40. package/dist/flaky.js +9 -0
  41. package/dist/flow-engine.js +1 -1
  42. package/dist/flow-pull-request.js +1 -1
  43. package/dist/lead-doors.js +4 -2
  44. package/dist/lead-tools.js +10 -2
  45. package/dist/local-owner.d.ts +1 -1
  46. package/dist/local-owner.js +11 -0
  47. package/dist/memory-cli.d.ts +3 -0
  48. package/dist/memory-cli.js +3 -3
  49. package/dist/memory-pass.js +5 -1
  50. package/dist/model-choices.d.ts +7 -3
  51. package/dist/model-choices.js +26 -11
  52. package/dist/models-cli.d.ts +205 -0
  53. package/dist/models-cli.js +143 -4
  54. package/dist/models-ui.d.ts +57 -0
  55. package/dist/models-ui.js +44 -1
  56. package/dist/needs-you.d.ts +7 -7
  57. package/dist/needs-you.js +12 -10
  58. package/dist/operate-remote-arguments.js +4 -4
  59. package/dist/operate-remote.js +1 -1
  60. package/dist/operate.js +191 -23
  61. package/dist/phase-routing.d.ts +82 -2
  62. package/dist/phase-routing.js +132 -18
  63. package/dist/project-delete.d.ts +4 -0
  64. package/dist/project-delete.js +16 -4
  65. package/dist/project-memory.d.ts +8 -0
  66. package/dist/project-memory.js +20 -1
  67. package/dist/pull-request-flow.js +1 -1
  68. package/dist/push.d.ts +10 -3
  69. package/dist/push.js +22 -6
  70. package/dist/release-adapters.js +11 -1
  71. package/dist/release-approval.d.ts +191 -0
  72. package/dist/release-approval.js +382 -0
  73. package/dist/release-auto.js +22 -4
  74. package/dist/release-card.d.ts +73 -4
  75. package/dist/release-card.js +168 -23
  76. package/dist/release-cli.js +9 -1
  77. package/dist/release.d.ts +30 -0
  78. package/dist/release.js +42 -3
  79. package/dist/result-actions.d.ts +4 -0
  80. package/dist/result-actions.js +3 -0
  81. package/dist/result-acts.d.ts +3 -3
  82. package/dist/result-acts.js +1 -1
  83. package/dist/route-hooks.d.ts +54 -0
  84. package/dist/route-hooks.js +179 -0
  85. package/dist/route-results.d.ts +70 -0
  86. package/dist/route-results.js +340 -0
  87. package/dist/route-rules.d.ts +324 -0
  88. package/dist/route-rules.js +781 -0
  89. package/dist/route-savings.d.ts +136 -0
  90. package/dist/route-savings.js +193 -0
  91. package/dist/route-store.d.ts +300 -0
  92. package/dist/route-store.js +598 -0
  93. package/dist/scope.d.ts +3 -1
  94. package/dist/scope.js +4 -1
  95. package/dist/server/guards.js +1 -0
  96. package/dist/server/pages-assets.js +1 -1
  97. package/dist/server/render-chat.js +2 -2
  98. package/dist/server/render-release.d.ts +17 -0
  99. package/dist/server/render-release.js +84 -0
  100. package/dist/server/render-results.d.ts +0 -2
  101. package/dist/server/render-results.js +4 -6
  102. package/dist/server/render-tasks.d.ts +7 -0
  103. package/dist/server/render-tasks.js +16 -3
  104. package/dist/server/route-table.d.ts +1 -1
  105. package/dist/server/route-table.js +19 -1
  106. package/dist/server/settings.d.ts +2 -1
  107. package/dist/server/settings.js +87 -1
  108. package/dist/server/task-views.js +6 -0
  109. package/dist/server/tasks.d.ts +15 -0
  110. package/dist/server/tasks.js +105 -20
  111. package/dist/settings-proofs.js +2 -0
  112. package/dist/spend-ui.d.ts +11 -0
  113. package/dist/spend-ui.js +49 -2
  114. package/dist/stop-proof.d.ts +67 -0
  115. package/dist/stop-proof.js +293 -0
  116. package/dist/store.d.ts +106 -31
  117. package/dist/store.js +653 -128
  118. package/dist/surface.js +17 -5
  119. package/dist/task-outcome-cli.js +24 -2
  120. package/dist/toolroll-update-ui.js +2 -2
  121. package/dist/toolroll-update.js +4 -2
  122. package/dist/work-index.js +7 -9
  123. package/dist/work-summary.d.ts +2 -2
  124. package/package.json +1 -1
@@ -50,7 +50,7 @@
50
50
  */
51
51
  import { ACP_AGENTS, isAcpProvider } from "./acp-agents.js";
52
52
  import { createHash } from "node:crypto";
53
- import { MODEL_ID_SHAPE, postureOf, readRouteOverrides, readSealedRoute, RISKS, ROUTE_PHASES, ROUTE_PROVIDERS, ROUTE_VERSION, TASK_SIZE_WORDS, } from "./contracts/route.js";
53
+ import { MODEL_ID_SHAPE, postureOf, readRouteOverrides, PLAIN_ROUTE_VERSION, readSealedRoute, RISKS, ROUTE_PHASES, ROUTE_PROVIDERS, ROUTE_VERSION, TASK_SIZE_WORDS, } from "./contracts/route.js";
54
54
  import { canonicalJson } from "./canonical-json.js";
55
55
  export { MODEL_ID_SHAPE };
56
56
  // Type-only imports from provider.ts: it sits under evidence.ts and scope.ts
@@ -72,7 +72,7 @@ export function exactModelId(v) {
72
72
  /** v2 adds the task's size (small / medium / large, risky or not), the
73
73
  * light tier, and plan headroom. A v1 route — sealed before sizing — still
74
74
  * reads back exactly and keeps its own digest domain. */
75
- export { ROUTE_VERSION };
75
+ export { PLAIN_ROUTE_VERSION, ROUTE_VERSION };
76
76
  /** The durable route ERA a scope row carries once it was filed under this
77
77
  * policy: NULL on a row proven to predate v47 (legacy — the sealed profile
78
78
  * alone governs), this value on every row filed since. A row with an era
@@ -270,23 +270,13 @@ export function recommendRoute(input) {
270
270
  legs.push({ phase: "plan", provider: rec.spec.provider, model: rec.spec.model, tier: rec.tier, chosen: "recommended", recommended, reasons: rec.reasons, problem: planProblem(rec.spec) });
271
271
  }
272
272
  }
273
- // build — pin > override > recommendation.
273
+ // build — one precedence order (selectBuildLeg): pin > override > trial > earned rule > configured > headroom.
274
274
  let buildMoved = false;
275
275
  const build = (() => {
276
- const rec = pick("build");
277
- buildMoved = rec.moved;
278
- const pin = input.pins?.build ?? null;
279
- const override = overrideFor("build");
280
- const recommended = { provider: rec.spec.provider, model: rec.spec.model, tier: rec.tier };
281
- if (pin !== null || override !== null)
282
- buildMoved = false;
283
- const leg = pin !== null
284
- ? { phase: "build", provider: pin.provider, model: pin.model, tier: rec.tier, chosen: "pinned", recommended, reasons: [`pinned to ${specWords(pin)} by the firing that filed this task — nothing overrides a pin`], problem: null }
285
- : override !== null
286
- ? { phase: "build", provider: override.provider, model: override.model, tier: rec.tier, chosen: "override", recommended, reasons: [`overridden by ${override.by} to ${specWords(override)} (recommended ${specWords(rec.spec)})`], problem: null }
287
- : { phase: "build", provider: rec.spec.provider, model: rec.spec.model, tier: rec.tier, chosen: "recommended", recommended, reasons: rec.reasons, problem: null };
288
- legs.push(leg);
289
- return leg;
276
+ const chosen = selectBuildLeg(input, pickTier("build"));
277
+ buildMoved = chosen.moved;
278
+ legs.push(chosen.leg);
279
+ return chosen.leg;
290
280
  })();
291
281
  // repair — ALWAYS the build provider (repair resumes the builder's
292
282
  // session; cross-provider repair does not exist). Only the model routes:
@@ -383,7 +373,8 @@ export function recommendRoute(input) {
383
373
  }
384
374
  }
385
375
  return {
386
- version: ROUTE_VERSION,
376
+ // Version 3 only for a build leg that carries a selection: every other route's bytes stay version 2's.
377
+ version: build.selection !== undefined ? ROUTE_VERSION : PLAIN_ROUTE_VERSION,
387
378
  risk: "routine",
388
379
  qualityMode: input.qualityMode,
389
380
  publication: input.publication,
@@ -398,6 +389,127 @@ export function recommendRoute(input) {
398
389
  size: input.size ?? null,
399
390
  };
400
391
  }
392
+ /**
393
+ * THE BUILD LEG'S ONE PRECEDENCE ORDER (results routing, docs/plans/results-routing.md slice 2), pure:
394
+ *
395
+ * pin > person override > trial > earned rule > configured row (light / routine / strong by size)
396
+ * > headroom move
397
+ *
398
+ * Headroom moves only a configured leg, never a rule's or a trial's: their evidence is about one exact model. A rule
399
+ * or trial whose provider is past the headroom limits is skipped for this filing, and its reason says so. A trial
400
+ * applies only with a place reserved for this task (slice 3). A rule or trial applies only to an
401
+ * eligible task — small or medium, not risky, default quality, no screenshot or manual-review evidence, not a
402
+ * merging mode, not a release or candidate, nothing protected declared — and only over the configured builder it was
403
+ * earned against. Its leg says how it was selected (`selection`), so the route seals as version 3 and every later
404
+ * check reads the sealed selection, never the current rules or the reason text.
405
+ */
406
+ export function selectBuildLeg(input, configured) {
407
+ const pin = input.pins?.build ?? null;
408
+ const override = input.overrides.find(one => one.phase === "build") ?? null;
409
+ if (pin !== null || override !== null) {
410
+ // What would have run otherwise is said as it always was: the configured leg after headroom.
411
+ const rec = withHeadroom(input, "build", configured);
412
+ const recommended = { provider: rec.spec.provider, model: rec.spec.model, tier: rec.tier };
413
+ const leg = pin !== null
414
+ ? { phase: "build", provider: pin.provider, model: pin.model, tier: rec.tier, chosen: "pinned", recommended, reasons: [`pinned to ${specWords(pin)} by the firing that filed this task — nothing overrides a pin`], problem: null }
415
+ : { phase: "build", provider: override.provider, model: override.model, tier: rec.tier, chosen: "override", recommended, reasons: [`overridden by ${override.by} to ${specWords(override)} (recommended ${specWords(rec.spec)})`], problem: null };
416
+ return { leg, moved: false };
417
+ }
418
+ const trial = trialFor(input, configured);
419
+ if (trial !== null && trial.kind === "trial") {
420
+ const one = trial.trial;
421
+ const spec = { provider: one.provider, model: one.model };
422
+ return {
423
+ leg: {
424
+ phase: "build", provider: one.provider, model: one.model, tier: configured.tier, chosen: "recommended",
425
+ recommended: { ...spec, tier: configured.tier },
426
+ reasons: [...configured.reasons.slice(0, 1), `trial: ${specWords(spec)} is being tried on this project's next ${one.limit} eligible ${one.cohortSize === "small" ? "small" : "routine"} tasks instead of the configured ${specWords({ provider: one.configured.provider, model: one.configured.model ?? "default" })}; a person approves each one`],
427
+ problem: null,
428
+ selection: { kind: "trial", rule: one.id, reservation: one.reservation },
429
+ },
430
+ moved: false,
431
+ };
432
+ }
433
+ const earned = earnedRuleFor(input, configured);
434
+ if (earned !== null && earned.kind === "rule") {
435
+ const rule = earned.rule;
436
+ const spec = { provider: rule.provider, model: rule.model };
437
+ return {
438
+ leg: {
439
+ phase: "build", provider: rule.provider, model: rule.model, tier: configured.tier, chosen: "recommended",
440
+ recommended: { ...spec, tier: configured.tier },
441
+ reasons: [...configured.reasons.slice(0, 1), `earned rule: ${specWords(spec)} builds this project's eligible ${rule.cohortSize === "small" ? "small" : "routine"} tasks instead of the configured ${specWords({ provider: rule.configured.provider, model: rule.configured.model ?? "default" })}, on its recorded results here`],
442
+ problem: null,
443
+ selection: { kind: "rule", rule: rule.id, ruleGeneration: rule.generation },
444
+ },
445
+ moved: false,
446
+ };
447
+ }
448
+ const rec = withHeadroom(input, "build", configured);
449
+ const reasons = [...rec.reasons, ...(trial === null ? [] : [trial.reason]), ...(earned === null ? [] : [earned.reason])];
450
+ return {
451
+ leg: { phase: "build", provider: rec.spec.provider, model: rec.spec.model, tier: rec.tier, chosen: "recommended", recommended: { provider: rec.spec.provider, model: rec.spec.model, tier: rec.tier }, reasons, problem: null },
452
+ moved: rec.moved,
453
+ };
454
+ }
455
+ /** The earned rule this task's build takes (pure): null when none could apply (no rule for its size, or the task
456
+ * isn't eligible), or a rule skipped for this filing with the words why. */
457
+ export function earnedRuleFor(input, configured) {
458
+ const earned = input.earned ?? null;
459
+ const size = input.size ?? null;
460
+ if (earned === null || size === null || size.risky || (size.size !== "small" && size.size !== "medium"))
461
+ return null;
462
+ const rule = earned.rules.find(one => one.cohortSize === size.size) ?? null;
463
+ if (rule === null)
464
+ return null;
465
+ if (earned.release || earned.protected || input.qualityMode !== "default" || input.publication === "automerge")
466
+ return null;
467
+ if (input.evidence.some(kind => kind === "screenshot" || kind === "manual-review"))
468
+ return null;
469
+ if (demandedTier(input, "build").tier !== "routine")
470
+ return null;
471
+ // Only Claude reports the model it ran, so only a Claude cohort can earn a rule.
472
+ if (rule.provider !== "claude" || !exactModel(rule.model))
473
+ return null;
474
+ if (configured.spec.provider !== rule.configured.provider || configured.spec.model !== rule.configured.model) {
475
+ return { kind: "skipped", reason: `the earned rule for ${size.size === "small" ? "small" : "routine"} tasks was earned against another configured builder — not applied` };
476
+ }
477
+ const room = (input.headroom ?? []).find(one => one.provider === rule.provider) ?? null;
478
+ const tight = room === null ? null : tightWords(room);
479
+ if (tight !== null)
480
+ return { kind: "skipped", reason: `earned rule skipped for this filing: ${tight} — the configured builder runs it` };
481
+ return { kind: "rule", rule };
482
+ }
483
+ /** The trial this task's build takes (pure, results routing slice 3): null when none could apply (no live trial for
484
+ * its size, or the task isn't eligible: the same facts an earned rule needs), a trial with this task's place, or a
485
+ * trial skipped for this filing with the words why (another configured builder, no place left, no headroom). */
486
+ export function trialFor(input, configured) {
487
+ const trial = input.earned?.trial ?? null;
488
+ const size = input.size ?? null;
489
+ if (trial === null || size === null || size.risky || (size.size !== "small" && size.size !== "medium") || trial.cohortSize !== size.size)
490
+ return null;
491
+ if (input.earned.release || input.earned.protected || input.qualityMode !== "default" || input.publication === "automerge")
492
+ return null;
493
+ if (input.evidence.some(kind => kind === "screenshot" || kind === "manual-review"))
494
+ return null;
495
+ if (demandedTier(input, "build").tier !== "routine")
496
+ return null;
497
+ // Only Claude reports the model it ran, so only a Claude cohort runs a trial.
498
+ if (trial.provider !== "claude" || !exactModel(trial.model) || !Number.isSafeInteger(trial.limit) || trial.limit < 1)
499
+ return null;
500
+ const words = size.size === "small" ? "small" : "routine";
501
+ if (configured.spec.provider !== trial.configured.provider || configured.spec.model !== trial.configured.model) {
502
+ return { kind: "skipped", reason: `the trial for ${words} tasks was started against another configured builder — not applied` };
503
+ }
504
+ const room = (input.headroom ?? []).find(one => one.provider === trial.provider) ?? null;
505
+ const tight = room === null ? null : tightWords(room);
506
+ if (tight !== null)
507
+ return { kind: "skipped", reason: `trial skipped for this filing: ${tight} — the configured builder runs it` };
508
+ if (trial.reservation === null || !Number.isSafeInteger(trial.reservation) || trial.reservation < 1) {
509
+ return { kind: "skipped", reason: `every place in the trial for ${words} tasks is taken — the configured builder runs it` };
510
+ }
511
+ return { kind: "trial", trial: { ...trial, reservation: trial.reservation } };
512
+ }
401
513
  /**
402
514
  * PLAN HEADROOM (v2), at filing:
403
515
  *
@@ -494,6 +606,8 @@ export function canonicalRouteJson(route) {
494
606
  recommended: leg.recommended,
495
607
  reasons: leg.reasons,
496
608
  problem: leg.problem,
609
+ // v3 (results routing): the build leg's selection is in the bytes, so in the digest; v1 and v2 bytes never change.
610
+ ...(route.version === 3 && leg.selection !== undefined ? { selection: leg.selection } : {}),
497
611
  })),
498
612
  overrides: route.overrides.map(one => ({ phase: one.phase, provider: one.provider, model: one.model, by: one.by, at: one.at })),
499
613
  // v2 carries the size it was routed for; a v1 snapshot's bytes never change.
@@ -30,6 +30,10 @@ export type ProjectDeleteResult = {
30
30
  said: string;
31
31
  running?: string[];
32
32
  };
33
+ /** The project's Models rows, counted with its settings and deleted by forgetProjectModels only. */
34
+ export declare const MODEL_SETTINGS: readonly [table: string, column: string][];
35
+ /** The project's settings tables, for the deletion test's inventory. */
36
+ export declare const PROJECT_SETTINGS: readonly [table: string, column: string][];
33
37
  /** What Toolroll holds for a project, counted. */
34
38
  export declare function projectHoldings(store: Store, repo: string): ProjectHoldings;
35
39
  /** What's in it, in words: "12 tasks, 3 versions, 30 runs, …". Empty kinds are left out. */
@@ -15,7 +15,7 @@
15
15
  import { existsSync, lstatSync, rmSync } from "node:fs";
16
16
  import { isAbsolute, relative, resolve } from "node:path";
17
17
  import { teardownGit, refusal } from "./teardown-git.js";
18
- import { BUILT_IN } from "./store.js";
18
+ import { BUILT_IN, forgetProjectModels, V149_REPO_TABLES } from "./store.js";
19
19
  import { BRANCH_PREFIXES, isOwnBranch } from "./names.js";
20
20
  /** The tables whose history is the audit record itself: never touched. */
21
21
  const KEPT = new Set(["action_ledger", "ledger_seal", "ledger_checkpoint"]);
@@ -42,13 +42,19 @@ function doomed(db, repo) {
42
42
  const subagents = ids(db, "SELECT id FROM subagent WHERE repo = ?", repo);
43
43
  return { refs, tasks, runs, artifacts, threads, turns, flows, cards, subagents };
44
44
  }
45
- /** A project's settings: one row per thing someone set for it. */
45
+ /** A project's settings: one row per thing someone set for it. Its Models rows (phase_config, phase_tier_config,
46
+ * phase_tier_alternate) are settings too, but go only through store.ts forgetProjectModels, which moves their
47
+ * generations and takes the project's results-routing rows with them (MODEL_SETTINGS). */
46
48
  const SETTINGS = [
47
49
  ["approval_policy", "repo"], ["verify_command", "repo"], ["worktree_setup", "repo"], ["backend_grant", "repo"], ["publication_grant", "repo"],
48
- ["intake_grant", "repo"], ["capability", "repo"], ["project_tool", "repo"], ["phase_config", "scope"], ["phase_tier_config", "scope"],
50
+ ["intake_grant", "repo"], ["capability", "repo"], ["project_tool", "repo"],
49
51
  ["learning_policy", "repo"], ["operating_mode", "repo"], ["project_knowledge", "repo"], ["live_detail_setting", "repo"],
50
52
  ["project_decision", "repo"], ["flow_script", "repo"], ["workflow_recipe", "repo"], ["team_lead_project", "project"],
51
53
  ];
54
+ /** The project's Models rows, counted with its settings and deleted by forgetProjectModels only. */
55
+ export const MODEL_SETTINGS = Object.freeze([["phase_config", "scope"], ["phase_tier_config", "scope"], ["phase_tier_alternate", "scope"]]);
56
+ /** The project's settings tables, for the deletion test's inventory. */
57
+ export const PROJECT_SETTINGS = Object.freeze(SETTINGS.map(one => [...one]));
52
58
  /** What Toolroll holds for a project, counted. */
53
59
  export function projectHoldings(store, repo) {
54
60
  const db = store.handle;
@@ -61,7 +67,7 @@ export function projectHoldings(store, repo) {
61
67
  checkouts: count("SELECT COUNT(*) AS n FROM worktree WHERE repo = ?", repo),
62
68
  chats: d.threads.length, flows: d.flows.length, cards: d.cards.length, subagents: d.subagents.length,
63
69
  budgets: count(`SELECT COUNT(*) AS n FROM budget WHERE removed_at IS NULL AND ((scope_kind = 'project' AND scope_key = ?) OR (scope_kind = 'subagent' AND scope_key IN (SELECT CAST(value AS TEXT) FROM json_each(?))))`, repo, json(d.subagents)),
64
- settings: SETTINGS.filter(([table]) => tableExists(db, table)).reduce((sum, [table, column]) => sum + count(`SELECT COUNT(*) AS n FROM ${table} WHERE ${column} = ?`, repo), 0),
70
+ settings: [...SETTINGS, ...MODEL_SETTINGS].filter(([table]) => tableExists(db, table)).reduce((sum, [table, column]) => sum + count(`SELECT COUNT(*) AS n FROM ${table} WHERE ${column} = ?`, repo), 0),
65
71
  };
66
72
  }
67
73
  /** What's in it, in words: "12 tasks, 3 versions, 30 runs, …". Empty kinds are left out. */
@@ -312,9 +318,15 @@ function deleteRows(store, repo, d, now) {
312
318
  // Settings, knowledge and the project itself.
313
319
  del("operating_mode_event", "mode IN (SELECT id FROM operating_mode WHERE repo = ?)", repo);
314
320
  del("decision_change", "decision IN (SELECT id FROM project_decision WHERE repo = ?)", repo);
321
+ // Its learning-loops rows (v149), by repo; a check's results, a note's tallies and a merge of its decisions go with
322
+ // the rows they hang off (the deleted runs, notes and decisions), below.
323
+ for (const table of V149_REPO_TABLES)
324
+ del(table, "repo = ?", repo);
315
325
  del("memory_sighting", "gap IN (SELECT id FROM memory_gap WHERE repo = ?) OR session IN (SELECT id FROM memory_session WHERE repo = ?)", repo, repo);
316
326
  for (const [table, column] of SETTINGS)
317
327
  del(table, `${column} = ?`, repo);
328
+ // Its Models rows and results-routing rows: through the Models counter, so whatever depended on them pauses.
329
+ rows += forgetProjectModels(store, repo, now);
318
330
  del("project_mute", "repo = ?", repo);
319
331
  for (const table of ["knowledge_change", "project_identity_carry", "project_skill_change", "memory_gap", "memory_proposal", "memory_rejection", "memory_session", "memory_search", "mode_rail", "side_spend", "watch_episode", "worktree", "coordinator_proposal", "lead_commitment"])
320
332
  del(table, "repo = ?", repo);
@@ -7,6 +7,14 @@ export declare function listDecisions(store: Store, repo: string, actor: string,
7
7
  limit?: number;
8
8
  }): Decision[];
9
9
  export declare function getDecision(store: Store, repo: string, actor: string, id: number): Decision | null;
10
+ /**
11
+ * Project memory is changed by a person (like `knowledge apply`, project-knowledge.ts applySavedKnowledge): never in the
12
+ * lead's chain and never inside a remote run (an API token), whatever credential reached here. The lead proposes (chat
13
+ * cards decision_record/decision_retire) and a person confirms. Every writer here and in memory-pass.ts calls this first,
14
+ * and again inside its transaction beside the person's standing (`admission`), so a revocation made since the caller
15
+ * proved them is seen by the write.
16
+ */
17
+ export declare function refuseLeadMemoryWrite(): void;
10
18
  /** Record one settled choice. A superseding decision retires the older one in the same transaction. */
11
19
  export declare function recordDecision(store: Store, args: {
12
20
  repo: string;
@@ -8,6 +8,8 @@
8
8
  * model; the backward pass that proposes edits lives in memory-pass.ts.
9
9
  */
10
10
  import { createHash } from 'node:crypto';
11
+ import { currentActor } from './actor.js';
12
+ import { activeRemote } from './remote-run.js';
11
13
  import { scanForSecrets } from './evidence.js';
12
14
  import { learningIdentity } from './project-learning.js';
13
15
  import { TEXT_LIMITS } from './text-limits.js';
@@ -112,8 +114,22 @@ export function getDecision(store, repo, actor, id) {
112
114
  throw Error('This decision could not be verified.');
113
115
  return d;
114
116
  }
117
+ /**
118
+ * Project memory is changed by a person (like `knowledge apply`, project-knowledge.ts applySavedKnowledge): never in the
119
+ * lead's chain and never inside a remote run (an API token), whatever credential reached here. The lead proposes (chat
120
+ * cards decision_record/decision_retire) and a person confirms. Every writer here and in memory-pass.ts calls this first,
121
+ * and again inside its transaction beside the person's standing (`admission`), so a revocation made since the caller
122
+ * proved them is seen by the write.
123
+ */
124
+ export function refuseLeadMemoryWrite() {
125
+ if (currentActor()?.lead === true)
126
+ throw Error('Project memory is changed by a person, not the lead.');
127
+ if (activeRemote() !== null)
128
+ throw Error('Project memory is changed by a person with their own password, not an API token.');
129
+ }
115
130
  /** Record one settled choice. A superseding decision retires the older one in the same transaction. */
116
131
  export function recordDecision(store, args, now = new Date()) {
132
+ refuseLeadMemoryWrite();
117
133
  const identity = admission(store, args.repo, args.actor, true);
118
134
  const claim = clean(args.draft.claim, TEXT_LIMITS.decisionClaimBytes, 'The decision'), why = clean(args.draft.why, TEXT_LIMITS.decisionWhyBytes, 'The reason');
119
135
  const decidedBy = args.draft.decidedBy === undefined ? args.actor : clean(args.draft.decidedBy, TEXT_LIMITS.decisionByBytes, 'Who decided');
@@ -123,6 +139,8 @@ export function recordDecision(store, args, now = new Date()) {
123
139
  const sourceKind = args.draft.sourceKind ?? 'manual';
124
140
  const sourceRef = args.draft.sourceRef === undefined || args.draft.sourceRef === null ? null : clean(String(args.draft.sourceRef), TEXT_LIMITS.decisionSourceBytes, 'The source');
125
141
  return store.transact(() => {
142
+ refuseLeadMemoryWrite();
143
+ admission(store, args.repo, args.actor, true);
126
144
  let supersedes = null;
127
145
  if (args.draft.supersedes !== undefined && args.draft.supersedes !== null) {
128
146
  const older = getDecision(store, args.repo, args.actor, Number(args.draft.supersedes));
@@ -155,9 +173,10 @@ function changeStatus(store, repo, actor, id, status, reason, now) {
155
173
  }
156
174
  /** Retire a decision that no longer holds; history keeps it. */
157
175
  export function retireDecision(store, args, now = new Date()) {
176
+ refuseLeadMemoryWrite();
158
177
  admission(store, args.repo, args.actor, true);
159
178
  const reason = clean(args.reason, TEXT_LIMITS.decisionRetireBytes, 'The reason');
160
- return store.transact(() => { const d = changeStatus(store, args.repo, args.actor, args.id, 'retired', reason, now); reindexProjectMemory(store, args.repo); return d; });
179
+ return store.transact(() => { refuseLeadMemoryWrite(); admission(store, args.repo, args.actor, true); const d = changeStatus(store, args.repo, args.actor, args.id, 'retired', reason, now); reindexProjectMemory(store, args.repo); return d; });
161
180
  }
162
181
  export function decisionHistory(store, repo, actor, id) {
163
182
  admission(store, repo, actor);
@@ -319,7 +319,7 @@ export async function followPullRequests(store, seen, options) {
319
319
  const filed = store.transact(() => {
320
320
  const revised = requestResultChanges(store, options.evidenceRoot, {
321
321
  run: publication.run, batch: "", source: revisionSourceOf(scope?.digest ?? null), actor: CI_ACTOR, repos: null, includeUnplaced: true,
322
- allowMode: false, note, path: "", line: "", request,
322
+ allowMode: false, note, path: "", line: "", request, origin: "flow",
323
323
  }, now);
324
324
  if (!revised.ok)
325
325
  return revised;
package/dist/push.d.ts CHANGED
@@ -61,14 +61,21 @@ export declare function validatePushEndpoint(raw: string): {
61
61
  ok: false;
62
62
  problem: string;
63
63
  };
64
+ /** A push's class: a stored class, or `release`, which is never stored: it is derived at send time from the plane's own
65
+ * state (release-approval.ts releasePushOf), never from a title, a task id or a link. */
66
+ export type PushClass = NonNullable<Notification["pushClass"]> | "release";
64
67
  /** Fixed words, except for one validated machine-only suite/count line. */
65
- export declare const PUSH_WORDS: Record<NonNullable<Notification["pushClass"]>, {
68
+ export declare const PUSH_WORDS: Record<PushClass, {
66
69
  title: string;
67
70
  body: string;
68
71
  }>;
69
72
  /** Static words and machine-minted numbers only — re-proved at send time. */
70
- export declare function safePushLink(pushClass: NonNullable<Notification["pushClass"]>, link: string | null): string;
71
- export declare function buildPushPayload(notification: Notification, waiting?: number | null): string;
73
+ export declare function safePushLink(pushClass: PushClass, link: string | null): string;
74
+ /** `release`: the class and link the plane derived for this row at send time (releasePushOf); absent or null, the
75
+ * row's stored class and link, revalidated. */
76
+ export declare function buildPushPayload(notification: Notification, waiting?: number | null, release?: {
77
+ link: string;
78
+ } | null): string;
72
79
  export type PushFetch = (url: string, init: Record<string, unknown>) => Promise<{
73
80
  status: number;
74
81
  headers: {
package/dist/push.js CHANGED
@@ -20,6 +20,7 @@ import { closeSync, constants as fsConstants, fsyncSync, openSync, readFileSync,
20
20
  import { join } from "node:path";
21
21
  import { createCipheriv, createECDH, createPrivateKey, hkdfSync, randomBytes, createHash, sign as cryptoSign } from "node:crypto";
22
22
  import { isCheckProgressLine } from "./check-progress.js";
23
+ import { RELEASE_PUSH_WORDS, releasePushOf, releaseReadyObsolete } from "./release-approval.js";
23
24
  export const VAPID_FILE = "vapid-keys.json";
24
25
  /** RFC 8291 §4: rs 4096 leaves room for the record's delimiter and tag. */
25
26
  const RECORD_SIZE = 4096;
@@ -193,7 +194,6 @@ export function validatePushEndpoint(raw) {
193
194
  return { ok: false, problem: "that host is not a known push service — extending the list is a code change, deliberately" };
194
195
  return { ok: true, url };
195
196
  }
196
- // ---- the payload ------------------------------------------------------------
197
197
  /** Fixed words, except for one validated machine-only suite/count line. */
198
198
  export const PUSH_WORDS = {
199
199
  decision: { title: "Toolroll", body: "a decision needs you" },
@@ -201,28 +201,37 @@ export const PUSH_WORDS = {
201
201
  merge: { title: "Toolroll", body: "a pull request needs a person" },
202
202
  attention: { title: "Toolroll", body: "the plane needs attention" },
203
203
  progress: { title: "Toolroll", body: "checks in progress" },
204
+ release: { title: "Toolroll", body: RELEASE_PUSH_WORDS },
204
205
  };
205
- const LINK_SHAPES = [/^\/next$/, /^\/review$/, /^\/system$/, /^\/routines$/, /^\/routines\/[0-9]{1,15}$/, /^\/d\/[0-9]{1,15}$/, /^\/r\/[0-9]{1,15}$/];
206
+ // `/approve/<n>`: a release's opaque locator (a task ref number: no version, commit or branch), resolved by the console.
207
+ const RELEASE_LINK = /^\/approve\/[0-9]{1,15}$/;
208
+ const LINK_SHAPES = [/^\/next$/, /^\/review$/, /^\/system$/, /^\/routines$/, /^\/routines\/[0-9]{1,15}$/, /^\/d\/[0-9]{1,15}$/, /^\/r\/[0-9]{1,15}$/, RELEASE_LINK];
206
209
  const CLASS_FALLBACK = {
207
210
  decision: "/next",
208
211
  pick: "/next",
209
212
  merge: "/review",
210
213
  attention: "/next",
211
214
  progress: "/next",
215
+ release: "/next",
212
216
  };
213
217
  /** Static words and machine-minted numbers only — re-proved at send time. */
214
218
  export function safePushLink(pushClass, link) {
219
+ // The release locator rides only the derived `release` class: a stored link of that shape on any other row is not one.
220
+ if (link !== null && RELEASE_LINK.test(link))
221
+ return pushClass === "release" ? link : CLASS_FALLBACK[pushClass];
215
222
  if (link !== null && LINK_SHAPES.some(shape => shape.test(link)))
216
223
  return link;
217
224
  return CLASS_FALLBACK[pushClass];
218
225
  }
219
- export function buildPushPayload(notification, waiting = null) {
220
- const pushClass = notification.pushClass;
226
+ /** `release`: the class and link the plane derived for this row at send time (releasePushOf); absent or null, the
227
+ * row's stored class and link, revalidated. */
228
+ export function buildPushPayload(notification, waiting = null, release = null) {
229
+ const pushClass = release !== null ? "release" : notification.pushClass;
221
230
  const words = PUSH_WORDS[pushClass];
222
231
  return JSON.stringify({
223
232
  title: words.title,
224
233
  body: pushClass === "progress" && isCheckProgressLine(notification.body) ? notification.body : words.body,
225
- url: safePushLink(pushClass, notification.link),
234
+ url: safePushLink(pushClass, release !== null ? release.link : notification.link),
226
235
  // The opaque tag collapses a crash-duplicate on the lock screen.
227
236
  tag: pushClass === "progress" && notification.run !== null ? `so-run-${notification.run}` : `so-${notification.id}`,
228
237
  // The app-icon badge (Phase 2E, v2 S4): the server-computed
@@ -268,6 +277,13 @@ export async function pushPass(store, options) {
268
277
  if (fenced === null)
269
278
  continue; // resolved, retired, rotated, or lost — nothing to send
270
279
  const subscription = fenced.subscriptionRow;
280
+ // A release's "ready for your approval" that no longer stands (approved, superseded, put away) is resolved, never
281
+ // sent; one that stands is sent as `release`, its class and link derived from the plane's state now.
282
+ if (releaseReadyObsolete(store, fenced.notificationRow)) {
283
+ store.handle.prepare("UPDATE notification SET resolved_at = ? WHERE id = ? AND resolved_at IS NULL").run(clock().toISOString(), fenced.notificationRow.id);
284
+ continue;
285
+ }
286
+ const release = releasePushOf(store, fenced.notificationRow);
271
287
  const endpoint = validatePushEndpoint(subscription.endpoint);
272
288
  if (!endpoint.ok) {
273
289
  store.retirePushSubscription(subscription.id, "invalid-endpoint", clock());
@@ -280,7 +296,7 @@ export async function pushPass(store, options) {
280
296
  // The badge count rides every push: the same inbox classifier the
281
297
  // console's own sidebar badge reads, unscoped (a phone has no
282
298
  // project pane), computed at send time.
283
- buildPushPayload(fenced.notificationRow, store.countInboxScoped(null, clock(), 100, null).count));
299
+ buildPushPayload(fenced.notificationRow, store.countInboxScoped(null, clock(), 100, null).count, release));
284
300
  }
285
301
  catch {
286
302
  // Off-curve or malformed subscriber material: the device, not us.
@@ -31,6 +31,8 @@ import { randomBytes, randomUUID } from "node:crypto";
31
31
  import { deploymentGate } from "./deploy-gate.js";
32
32
  import { failedBeforeTests } from "./github-jobs.js";
33
33
  import { mainHistoryOf, nightlyBoundary } from "./nightly-github.js";
34
+ import { noteReleaseReady, noteReleaseStopped, recordReleaseCi } from "./release-approval.js";
35
+ import { recordVerifiedRelease } from "./release-card.js";
34
36
  export { failedBeforeTests } from "./github-jobs.js";
35
37
  import { extractCommit } from "./commit-extract.js";
36
38
  import { teardownGit, refusal as gitRefusal } from "./teardown-git.js";
@@ -529,7 +531,7 @@ export function releaseAdapters(options) {
529
531
  },
530
532
  async complete(taskId, receipt) {
531
533
  const answer = await toolroll("task", "complete", taskId, "--digest", receipt);
532
- return answer["ok"] === true ? { ok: true } : { ok: false, message: refused(answer) };
534
+ return answer["ok"] === true ? { ok: true } : { ok: false, message: refused(answer), ...(typeof answer["reason"] === "string" ? { reason: answer["reason"] } : {}) };
533
535
  },
534
536
  async busy() {
535
537
  const db = new (sqlite().DatabaseSync)(database, { readOnly: true });
@@ -971,6 +973,14 @@ nightlyReads) {
971
973
  } };
972
974
  return {
973
975
  ...(auto === undefined ? {} : { auto: autoReleaseAdapter(withStore, now, auto) }),
976
+ // One-tap release approval (trusted-release-reports.md, slice A): ids and the coordinator's own readings only; the
977
+ // plane decides from its own records whether anything is written.
978
+ ready: {
979
+ async asked(taskId) { withStore(store => { noteReleaseReady(store, { taskId, now: now() }); }); },
980
+ async ci(input) { withStore(store => { recordReleaseCi(store, { ...input, now: now() }); }); },
981
+ async stopped(input) { withStore(store => { noteReleaseStopped(store, { ...input, now: now() }); }); },
982
+ async verified(input) { withStore(store => { recordVerifiedRelease(store, { ...input, now: now() }); }); },
983
+ },
974
984
  // After a failed release check, and before a pass is taken: the plane's own classification (check-failure.ts).
975
985
  ...(auto === undefined ? {} : {
976
986
  async recheck(taskId) {