@coreplane/switchboard 1.252.0 → 1.254.0

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 (143) hide show
  1. package/dist/assets/config/config.example.yaml +54 -1
  2. package/dist/assets/deploy/cloudflare-memory/runMetricsSink.ts +31 -0
  3. package/dist/assets/deploy/cloudflare-memory/worker.ts +455 -32
  4. package/dist/assets/deploy/cloudflare-memory/wrangler.template.jsonc +12 -0
  5. package/dist/assets/deploy/profile.example.json +1 -1
  6. package/dist/assets/package-lock.json +3 -3
  7. package/dist/assets/package.json +1 -1
  8. package/dist/assets/source.json +3 -3
  9. package/dist/assets/src/core/authz/grants.ts +6 -0
  10. package/dist/assets/src/core/authz/policy.ts +19 -0
  11. package/dist/assets/src/core/budgets.ts +25 -0
  12. package/dist/assets/src/core/coordinator/contract.ts +51 -1
  13. package/dist/assets/src/core/coordinator/driver.ts +111 -11
  14. package/dist/assets/src/core/costs.ts +221 -2
  15. package/dist/assets/src/core/costsSnapshotStore.ts +22 -1
  16. package/dist/assets/src/core/memory/engine.ts +25 -4
  17. package/dist/assets/src/core/memory/types.ts +6 -2
  18. package/dist/assets/src/core/pipelineStanding.ts +294 -0
  19. package/dist/assets/src/core/plane/decide.ts +203 -0
  20. package/dist/assets/src/core/provider.ts +11 -0
  21. package/dist/assets/src/core/runEvents.ts +57 -1
  22. package/dist/assets/src/core/runFriction.ts +1 -0
  23. package/dist/assets/src/core/runLedger/transcript.ts +23 -3
  24. package/dist/assets/src/core/runMetrics.ts +237 -0
  25. package/dist/assets/src/core/runRecord.ts +61 -0
  26. package/dist/assets/src/core/runUsage.ts +41 -9
  27. package/dist/assets/src/core/ship/contract.ts +8 -15
  28. package/dist/assets/src/core/ship/coordinator.ts +150 -11
  29. package/dist/assets/src/deploy/profile.ts +16 -0
  30. package/dist/assets/web/dist/.vite/manifest.json +461 -427
  31. package/dist/assets/web/dist/assets/{AppShell-DAtHiuI6.js → AppShell-CSXDCV4x.js} +1 -1
  32. package/dist/assets/web/dist/assets/CostChart-DsuPS2g7.js +2 -0
  33. package/dist/assets/web/dist/assets/CostsPage-BuKjw3nv.js +1 -0
  34. package/dist/assets/web/dist/assets/{DeliveryPage-3ELQWM0r.js → DeliveryPage-ngPsO2to.js} +1 -1
  35. package/dist/assets/web/dist/assets/HomePage-DvxTHzPx.js +3 -0
  36. package/dist/assets/web/dist/assets/{InputMenu-C6aPpe30.js → InputMenu-BdZXnibG.js} +1 -1
  37. package/dist/assets/web/dist/assets/MetricsPage-o5fSaCTe.js +1 -0
  38. package/dist/assets/web/dist/assets/{NotFoundPage-DtE-GgTk.js → NotFoundPage-DHuWyjlq.js} +1 -1
  39. package/dist/assets/web/dist/assets/PendingTurnRow-ZYIRCCZ2.js +1 -0
  40. package/dist/assets/web/dist/assets/PlanePage-DpWfiX4C.js +1 -0
  41. package/dist/assets/web/dist/assets/{ResidentDetailPage-C9y3nbo8.js → ResidentDetailPage-DG86v39Y.js} +1 -1
  42. package/dist/assets/web/dist/assets/{ResidentsIndexPage-i1RG9e7g.js → ResidentsIndexPage-x6p689VH.js} +1 -1
  43. package/dist/assets/web/dist/assets/RunFoldRow-DG29LOTs.js +1 -0
  44. package/dist/assets/web/dist/assets/RunRoutePage-ysJBY8xQ.js +9 -0
  45. package/dist/assets/web/dist/assets/RunsIndexPage-CuzFchcn.js +1 -0
  46. package/dist/assets/web/dist/assets/{RunsTabs-YSbUu5py.js → RunsTabs-kHbbxVHx.js} +1 -1
  47. package/dist/assets/web/dist/assets/{ScheduledPage-DvYwM2TE.js → ScheduledPage-BuLmfcbG.js} +1 -1
  48. package/dist/assets/web/dist/assets/{SettingSelect-BIzAsLk1.js → SettingSelect-BfID5nF4.js} +1 -1
  49. package/dist/assets/web/dist/assets/{SettingsPage-Bo6yCyXZ.js → SettingsPage-BujWkdU_.js} +1 -1
  50. package/dist/assets/web/dist/assets/{StatusDot-CAfS1AUi.js → StatusDot-C8Bc0pTX.js} +1 -1
  51. package/dist/assets/web/dist/assets/{Tooltip-tZoum_T-.js → Tooltip-BWwJx27K.js} +1 -1
  52. package/dist/assets/web/dist/assets/{UnitRoutePage-BmdOHwNn.js → UnitRoutePage-B9kjA1AT.js} +1 -1
  53. package/dist/assets/web/dist/assets/{angular-html-BjeQZdCq.js → angular-html-C8b5YTHf.js} +1 -1
  54. package/dist/assets/web/dist/assets/{angular-ts-ZGdORrL2.js → angular-ts-C3f4WYNe.js} +1 -1
  55. package/dist/assets/web/dist/assets/{apl-DhvV_X93.js → apl-DSHxUJc3.js} +1 -1
  56. package/dist/assets/web/dist/assets/{astro-DiRSE4Ug.js → astro-BSGCCBP6.js} +1 -1
  57. package/dist/assets/web/dist/assets/{blade-D8Fr5Wvq.js → blade-DFeYrMKk.js} +1 -1
  58. package/dist/assets/web/dist/assets/{c-BwbC64D4.js → c-L_QOnFVv.js} +1 -1
  59. package/dist/assets/web/dist/assets/{chapel-DgmqRK97.js → chapel-DLQWUwhu.js} +1 -1
  60. package/dist/assets/web/dist/assets/{cobol-DJod2RNK.js → cobol-1DlMmrEO.js} +1 -1
  61. package/dist/assets/web/dist/assets/{coffee-D1Y2CgBY.js → coffee-BTB6os6N.js} +1 -1
  62. package/dist/assets/web/dist/assets/{cpp-CsO97YOM.js → cpp-BvJI2z3u.js} +1 -1
  63. package/dist/assets/web/dist/assets/{crystal-CJ7m2tOc.js → crystal-B11wGW_h.js} +1 -1
  64. package/dist/assets/web/dist/assets/{css-B2M-NKoj.js → css-BiIWx5Pw.js} +1 -1
  65. package/dist/assets/web/dist/assets/{dist-DfbEpHXR.js → dist-CpnyQGOb.js} +2 -2
  66. package/dist/assets/web/dist/assets/{edge-CFVW-m9B.js → edge-nSglEXaD.js} +1 -1
  67. package/dist/assets/web/dist/assets/{elixir-B2mtRgCr.js → elixir-x7o8qMl8.js} +1 -1
  68. package/dist/assets/web/dist/assets/{elm-2teVAwCi.js → elm-DGrRyqTQ.js} +1 -1
  69. package/dist/assets/web/dist/assets/{erb-LAbeNqen.js → erb--_dh58Nw.js} +1 -1
  70. package/dist/assets/web/dist/assets/{git-rebase-DnhzH_sp.js → git-rebase-DbpUXwyg.js} +1 -1
  71. package/dist/assets/web/dist/assets/{glimmer-js-DPKjXf1-.js → glimmer-js-BG0CuZG5.js} +1 -1
  72. package/dist/assets/web/dist/assets/{glimmer-ts-CABXnt5z.js → glimmer-ts-CQIl5qyR.js} +1 -1
  73. package/dist/assets/web/dist/assets/{glsl-Dz4-3-gJ.js → glsl-9PJNQTcw.js} +1 -1
  74. package/dist/assets/web/dist/assets/{graphql-ByWUD1DL.js → graphql-CzVTzjEI.js} +1 -1
  75. package/dist/assets/web/dist/assets/{hack-CdmOK2K-.js → hack-BhCmAz3Q.js} +1 -1
  76. package/dist/assets/web/dist/assets/{haml-DzegMpd2.js → haml-BmI12COR.js} +1 -1
  77. package/dist/assets/web/dist/assets/{handlebars-B5NeiP0e.js → handlebars-Cib-ISTS.js} +1 -1
  78. package/dist/assets/web/dist/assets/{html-Zg_NHLv9.js → html-D3lDapvI.js} +1 -1
  79. package/dist/assets/web/dist/assets/{html-derivative-DZf9JNzh.js → html-derivative-DRPY8vOK.js} +1 -1
  80. package/dist/assets/web/dist/assets/{http-CVFrQZeN.js → http-B9VttK6m.js} +1 -1
  81. package/dist/assets/web/dist/assets/{hurl-L5brWAJe.js → hurl-C0saNJYj.js} +1 -1
  82. package/dist/assets/web/dist/assets/indexRow-DABQtONT.js +1 -0
  83. package/dist/assets/web/dist/assets/{java-hFphyZ4R.js → java-BCFbtYAj.js} +1 -1
  84. package/dist/assets/web/dist/assets/{javascript-CAJO1WKX.js → javascript-nwBjnrhh.js} +1 -1
  85. package/dist/assets/web/dist/assets/{jinja-N4A4srXI.js → jinja-CMYO9byN.js} +1 -1
  86. package/dist/assets/web/dist/assets/{jison-D3BZa_iU.js → jison-Cko8HnE_.js} +1 -1
  87. package/dist/assets/web/dist/assets/{json-DxD1Qh8W.js → json-Dzv__YsX.js} +1 -1
  88. package/dist/assets/web/dist/assets/{jsx-CHhag69S.js → jsx-CTFxagih.js} +1 -1
  89. package/dist/assets/web/dist/assets/{julia-CrhkZ6Tt.js → julia-_Xvten1-.js} +1 -1
  90. package/dist/assets/web/dist/assets/{just-D0LFOuhq.js → just-bLps4qOu.js} +1 -1
  91. package/dist/assets/web/dist/assets/{latex-BHAlwCXK.js → latex-D6UaYsUP.js} +1 -1
  92. package/dist/assets/web/dist/assets/{liquid-CkaLfCWh.js → liquid-CoDIeULa.js} +1 -1
  93. package/dist/assets/web/dist/assets/{lua-B1h4aAzP.js → lua-D1KFRnVf.js} +1 -1
  94. package/dist/assets/web/dist/assets/main-C4QfwybO.css +1 -0
  95. package/dist/assets/web/dist/assets/main-Comxmwi4.js +28 -0
  96. package/dist/assets/web/dist/assets/{marko-D6CG4hvX.js → marko-B5N5XcOw.js} +1 -1
  97. package/dist/assets/web/dist/assets/{mdc-CVQzimAU.js → mdc-uxzjQO3q.js} +1 -1
  98. package/dist/assets/web/dist/assets/{nginx-h-9Ir54h.js → nginx-BMJxs2TK.js} +1 -1
  99. package/dist/assets/web/dist/assets/{nim-DHVI30sp.js → nim-DqQ3-8p1.js} +1 -1
  100. package/dist/assets/web/dist/assets/{org-AasAXfMF.js → org-DsY6LK59.js} +1 -1
  101. package/dist/assets/web/dist/assets/{perl-BVi5aTEy.js → perl--pq9iJFp.js} +1 -1
  102. package/dist/assets/web/dist/assets/{php-qOkCeUKr.js → php-RHCMbsM2.js} +1 -1
  103. package/dist/assets/web/dist/assets/{pug-8TnUGvEX.js → pug-CFNq_FCp.js} +1 -1
  104. package/dist/assets/web/dist/assets/{qml-CpJbhoxn.js → qml-C9Acx9E8.js} +1 -1
  105. package/dist/assets/web/dist/assets/{r-JkUNcAJW.js → r-DxlU8FBd.js} +1 -1
  106. package/dist/assets/web/dist/assets/{razor-CtU4ws5a.js → razor-CLJ2kKih.js} +1 -1
  107. package/dist/assets/web/dist/assets/{regexp-J2zA6ceN.js → regexp-CzmdGoEp.js} +1 -1
  108. package/dist/assets/web/dist/assets/{rst-CMCvf-yZ.js → rst-D9yuxBET.js} +1 -1
  109. package/dist/assets/web/dist/assets/{ruby-ChLBY_iR.js → ruby-CIRcEvnD.js} +1 -1
  110. package/dist/assets/web/dist/assets/{sas-cwn4x2VY.js → sas-LOl-uUPh.js} +1 -1
  111. package/dist/assets/web/dist/assets/{scss-jSrJlzLI.js → scss-BX2_sZHY.js} +1 -1
  112. package/dist/assets/web/dist/assets/{shellscript-D5nAd0gs.js → shellscript-4d-QvKkY.js} +1 -1
  113. package/dist/assets/web/dist/assets/{shellsession-BZY4-Tdh.js → shellsession-DhB16UGy.js} +1 -1
  114. package/dist/assets/web/dist/assets/{soy-COppPOKr.js → soy-Cm1xTUBr.js} +1 -1
  115. package/dist/assets/web/dist/assets/{sql-Blc_6dOA.js → sql-BsfcBJdu.js} +1 -1
  116. package/dist/assets/web/dist/assets/{sseReplay-DmyMXfRC.js → sseReplay-IzTdD4-3.js} +5 -5
  117. package/dist/assets/web/dist/assets/{stata-DRv3OY-h.js → stata-Dwat3BSw.js} +1 -1
  118. package/dist/assets/web/dist/assets/{surrealql-qMv_eeJ6.js → surrealql-P4rtxzAW.js} +1 -1
  119. package/dist/assets/web/dist/assets/{svelte-d1Edb6Rz.js → svelte-DTcsi8S8.js} +1 -1
  120. package/dist/assets/web/dist/assets/{templ-CUMSIOXG.js → templ-D-kP0Tg5.js} +1 -1
  121. package/dist/assets/web/dist/assets/{tex-BGZQA-Ei.js → tex-D-4vuedK.js} +1 -1
  122. package/dist/assets/web/dist/assets/{ts-tags-Z9ulxVsq.js → ts-tags-jEEWYxsu.js} +1 -1
  123. package/dist/assets/web/dist/assets/{tsx-oI4bI3Vf.js → tsx-CFnlrVzT.js} +1 -1
  124. package/dist/assets/web/dist/assets/{twig-D_RXwBIX.js → twig-D5rGrPjs.js} +1 -1
  125. package/dist/assets/web/dist/assets/{typescript-BgqQDYoN.js → typescript-nYIQ35or.js} +1 -1
  126. package/dist/assets/web/dist/assets/{typst-5MW-KRDd.js → typst-3-UX8dna.js} +1 -1
  127. package/dist/assets/web/dist/assets/{vue-CZhHHsPm.js → vue-CLIPIX03.js} +1 -1
  128. package/dist/assets/web/dist/assets/{vue-html-DlYy2d8v.js → vue-html-Ci9CpkHU.js} +1 -1
  129. package/dist/assets/web/dist/assets/{vue-vine-CJDnx-qn.js → vue-vine-DL5pEYYk.js} +1 -1
  130. package/dist/assets/web/dist/assets/{xml-BRBDk65Q.js → xml-bO80qikC.js} +1 -1
  131. package/dist/assets/web/dist/assets/{xsl-BlCykPxo.js → xsl-BPze60Ej.js} +1 -1
  132. package/dist/assets/web/dist/assets/{yaml-BqW48szf.js → yaml-BQUSon_o.js} +1 -1
  133. package/dist/cli.js +36552 -33171
  134. package/package.json +1 -1
  135. package/dist/assets/web/dist/assets/CostsPage-BoKzYa4B.js +0 -2
  136. package/dist/assets/web/dist/assets/HomePage-BG_ok-K2.js +0 -2
  137. package/dist/assets/web/dist/assets/PendingTurnRow-ChCQOLgZ.js +0 -1
  138. package/dist/assets/web/dist/assets/RunFoldRow-D3wVpzBa.js +0 -1
  139. package/dist/assets/web/dist/assets/RunRoutePage-B3IirUVi.js +0 -9
  140. package/dist/assets/web/dist/assets/RunsIndexPage-DiFmtGaJ.js +0 -1
  141. package/dist/assets/web/dist/assets/indexRow-BT0cPVRw.js +0 -1
  142. package/dist/assets/web/dist/assets/main-5Gm_1Gv8.js +0 -28
  143. package/dist/assets/web/dist/assets/main-zbP_dTjR.css +0 -1
@@ -14,6 +14,7 @@ import {
14
14
  type ReviewVerdict,
15
15
  } from "./reviewVerdict.js";
16
16
  import { IDEMPOTENCY_KEY_PATTERN, INSTANCE_ID_PATTERN } from "./coordinator/contract.js";
17
+ import { isPipelineSummaryShape, type PipelineSummary } from "./pipelineStanding.js";
17
18
  import {
18
19
  FRICTION_CATEGORIES,
19
20
  type CategoryTotals,
@@ -184,6 +185,13 @@ export interface RunRecord {
184
185
  * reading the events. Absent for a preset a person, a scope or the default
185
186
  * chose, and on records written before it existed. */
186
187
  route?: RunRouteDecision;
188
+ /** The operator's shadow decision beside the routed request (record 0057;
189
+ * the one-door plan's operator unit; run-history item 60): what the operator
190
+ * bound for the same event, redacted like the receipt — never the message
191
+ * text — with the intake gate's verdict when the gate was present. The
192
+ * agreement row reads it from here; it follows the record's retention.
193
+ * Absent under `routing.operator: off` and on records written before it. */
194
+ operator?: RunOperatorDecision;
187
195
  /** The effective profile the run was admitted with (docs/decisions/0026-capability-profiles-and-request-routing.md):
188
196
  * the preset, its machine class and identity, the minutes it ran on and —
189
197
  * when a boundary clipped the budget — the scope that did, so a reader can
@@ -208,6 +216,15 @@ export interface RunRecord {
208
216
  * coordinator tag. Present only on a ship run whose hand-off succeeded;
209
217
  * a record written before the event has none. */
210
218
  instanceId?: string;
219
+ /** The pipeline's standing (record 0065): the fold of the run's own
220
+ * `ship_round`/`ship_unit` events, summarized at the seal so the stored row
221
+ * reads it without the events. Present only on a run that published ship
222
+ * facts; a record written before the field carries none and reads as today. */
223
+ pipeline?: PipelineSummary;
224
+ /** A ship pipeline's parent run (record 0060; record 0065): `RunMeta.hosted`
225
+ * carried onto the record at the seal, so a history reader draws the run as
226
+ * a pipeline. Absent on every other run and on older records. */
227
+ hosted?: true;
211
228
  /** Where the run's conversation started (item 52): `channel` — its own
212
229
  * thread's history, as for every run a person, a schedule or a coordinator
213
230
  * started — or `parent` — a spawned child seeded from its parent's text
@@ -404,6 +421,45 @@ export function routeOfEvents(events: readonly RunEvent[]): RunRouteDecision | u
404
421
  };
405
422
  }
406
423
 
424
+ /** The operator's decision as a record carries it — the `operator` event's
425
+ * fields less the stream bookkeeping (record 0057; run-history item 60). */
426
+ export interface RunOperatorDecision {
427
+ mode: "shadow" | "on";
428
+ outcome: "binds" | "question" | "refusal";
429
+ reason: string;
430
+ binds?: { line: string; reason: string }[];
431
+ question?: string;
432
+ /** A question's proposed line — what the next turn's "yes" binds. */
433
+ proposal?: string;
434
+ refusalCause?: string;
435
+ refusalText?: string;
436
+ intake?: { verdict: string; reason: string };
437
+ latencyMs?: number;
438
+ outputTokens?: number;
439
+ }
440
+
441
+ /** The operator's decision the run's events carry: the one `operator` event
442
+ * (record 0057), whatever the run's own agent source — the shadow decision
443
+ * rides routed and unrouted runs alike, or there would be no disagreement to
444
+ * count. Undefined on a run the operator never saw. */
445
+ export function operatorOfEvents(events: readonly RunEvent[]): RunOperatorDecision | undefined {
446
+ const e = events.find((e) => e.type === "operator");
447
+ if (e?.type !== "operator") return undefined;
448
+ return {
449
+ mode: e.mode,
450
+ outcome: e.outcome,
451
+ reason: e.reason,
452
+ ...(e.binds ? { binds: e.binds.map((b) => ({ line: b.line, reason: b.reason })) } : {}),
453
+ ...(e.question !== undefined ? { question: e.question } : {}),
454
+ ...(e.proposal !== undefined ? { proposal: e.proposal } : {}),
455
+ ...(e.refusalCause !== undefined ? { refusalCause: e.refusalCause } : {}),
456
+ ...(e.refusalText !== undefined ? { refusalText: e.refusalText } : {}),
457
+ ...(e.intake ? { intake: { verdict: e.intake.verdict, reason: e.intake.reason } } : {}),
458
+ ...(e.latencyMs !== undefined ? { latencyMs: e.latencyMs } : {}),
459
+ ...(e.outputTokens !== undefined ? { outputTokens: e.outputTokens } : {}),
460
+ };
461
+ }
462
+
407
463
  function isRunRouteShape(v: unknown): v is RunRouteDecision {
408
464
  if (typeof v !== "object" || v === null) return false;
409
465
  const r = v as Record<string, unknown>;
@@ -939,6 +995,11 @@ export function isRunRecord(v: unknown): v is RunRecord {
939
995
  // The instance a ship run's hand-off created (record 0051 R2; item 2).
940
996
  if (r.instanceId !== undefined && (typeof r.instanceId !== "string" || !INSTANCE_ID_PATTERN.test(r.instanceId)))
941
997
  return false;
998
+ // The pipeline's standing and the hosted marker (record 0065): the summary
999
+ // is checked for shape — an older reader accepts a newer record — and the
1000
+ // marker, like `provisional`, is the literal `true` or absent.
1001
+ if (r.pipeline !== undefined && !isPipelineSummaryShape(r.pipeline)) return false;
1002
+ if (r.hosted !== undefined && r.hosted !== true) return false;
942
1003
  if (typeof r.channelId !== "string" || typeof r.userId !== "string" || typeof r.threadKey !== "string") return false;
943
1004
  if (r.relayedBy !== undefined && typeof r.relayedBy !== "string") return false;
944
1005
  if (r.authenticatedAs !== undefined && typeof r.authenticatedAs !== "string") return false;
@@ -31,6 +31,11 @@ export interface ModelUsage {
31
31
  * a record whose turns predate the meter row (the price table then prices
32
32
  * the tokens, costs.md item 4b). */
33
33
  usd?: number | null;
34
+ /** The model's BYOK aggregator fees summed from its turns' `feeUsd` attrs —
35
+ * the share of `usd` that is the aggregator's own charge, what the biller's
36
+ * invoice tie-out compares against its `usage` column (costs.md item 4d).
37
+ * Absent when no turn carried one. */
38
+ feeUsd?: number;
34
39
  /** The distinct `priceSource` words the turns carried, sorted — what the
35
40
  * by-model view names as the source. Absent when no turn carried one. */
36
41
  priceSources?: string[];
@@ -59,12 +64,24 @@ export function usageOfEvents(events: readonly RunEvent[]): RunUsage {
59
64
  const usage = emptyUsage();
60
65
  // The meter row per model (model-proxy item 6): the turns' own dollars and
61
66
  // sources. `usd` sums only when every turn that counted tokens carried one —
62
- // a turn that counted none (an upstream error, a retry the harness's SDK
63
- // spent, a stream broken before its usage) has nothing a sum could leave
64
- // out, so it never turns the model unpriced; a model none of whose turns
65
- // carried price attrs stays without the fields (a record from before the
66
- // meter row — the table prices it, costs.md item 4b).
67
- const priced = new Map<string, { usd: number; pricedTurns: number; tokenTurns: number; sources: Set<string> }>();
67
+ // the token-counting turns that lack a figure are counted directly
68
+ // (`unpricedTokenTurns`), so a priced zero-token turn can never stand in for
69
+ // an unpriced one; a turn that counted none (an upstream error, a retry the
70
+ // harness's SDK spent, a stream broken before its usage) has nothing a sum
71
+ // could leave out, so it never turns the model unpriced; a model none of
72
+ // whose turns carried price attrs stays without the fields (a record from
73
+ // before the meter row — the table prices it, costs.md item 4b).
74
+ const priced = new Map<
75
+ string,
76
+ {
77
+ usd: number;
78
+ feeUsd: number;
79
+ feeTurns: number;
80
+ pricedTurns: number;
81
+ unpricedTokenTurns: number;
82
+ sources: Set<string>;
83
+ }
84
+ >();
68
85
  for (const e of events) {
69
86
  if (e.type !== "span_end" || e.name !== MODEL_TURN) continue;
70
87
  const attrs = (e.attrs ?? {}) as Record<string, unknown>;
@@ -87,19 +104,31 @@ export function usageOfEvents(events: readonly RunEvent[]): RunUsage {
87
104
  usage.turns += 1;
88
105
  const hasUsd = typeof attrs.usd === "number" && Number.isFinite(attrs.usd);
89
106
  const hasSource = typeof attrs.priceSource === "string" && attrs.priceSource !== "";
90
- const p = priced.get(model) ?? { usd: 0, pricedTurns: 0, tokenTurns: 0, sources: new Set<string>() };
91
- if (counted > 0) p.tokenTurns += 1;
107
+ const p = priced.get(model) ?? {
108
+ usd: 0,
109
+ feeUsd: 0,
110
+ feeTurns: 0,
111
+ pricedTurns: 0,
112
+ unpricedTokenTurns: 0,
113
+ sources: new Set<string>(),
114
+ };
115
+ if (counted > 0 && !hasUsd) p.unpricedTokenTurns += 1;
92
116
  if (hasUsd) {
93
117
  p.usd += attrs.usd as number;
94
118
  p.pricedTurns += 1;
95
119
  }
120
+ if (typeof attrs.feeUsd === "number" && Number.isFinite(attrs.feeUsd)) {
121
+ p.feeUsd += attrs.feeUsd;
122
+ p.feeTurns += 1;
123
+ }
96
124
  if (hasSource) p.sources.add(attrs.priceSource as string);
97
125
  priced.set(model, p);
98
126
  }
99
127
  for (const [model, p] of priced) {
100
128
  if (p.pricedTurns === 0 && p.sources.size === 0) continue;
101
129
  const m = usage.byModel[model]!;
102
- m.usd = p.pricedTurns >= p.tokenTurns ? p.usd : null;
130
+ m.usd = p.unpricedTokenTurns > 0 ? null : p.usd;
131
+ if (p.feeTurns > 0) m.feeUsd = p.feeUsd;
103
132
  if (p.sources.size > 0) m.priceSources = [...p.sources].sort();
104
133
  }
105
134
  return usage;
@@ -128,6 +157,8 @@ export function addUsage(a: RunUsage, b: RunUsage): RunUsage {
128
157
  const usd = foldUsd(acc.usd, m.usd);
129
158
  if (usd === undefined) delete acc.usd;
130
159
  else acc.usd = usd;
160
+ // A side without a fee spent none — a fee of nothing, never an unknown.
161
+ if (acc.feeUsd !== undefined || m.feeUsd !== undefined) acc.feeUsd = (acc.feeUsd ?? 0) + (m.feeUsd ?? 0);
131
162
  const sources = new Set([...(acc.priceSources ?? []), ...(m.priceSources ?? [])]);
132
163
  if (sources.size > 0) acc.priceSources = [...sources].sort();
133
164
  }
@@ -145,6 +176,7 @@ const isModelUsage = (v: unknown): v is ModelUsage => {
145
176
  )
146
177
  return false;
147
178
  if (m.usd !== undefined && m.usd !== null && typeof m.usd !== "number") return false;
179
+ if (m.feeUsd !== undefined && typeof m.feeUsd !== "number") return false;
148
180
  return (
149
181
  m.priceSources === undefined ||
150
182
  (Array.isArray(m.priceSources) && m.priceSources.every((s) => typeof s === "string"))
@@ -451,16 +451,16 @@ export const TIMEOUT_ON_LONG_COMMANDS =
451
451
  * exit line goes into the PR description's validation table as the row's proof
452
452
  * (the handoff has no verified list; parseHandoff carries deviations, followUps,
453
453
  * unproven and landed), and a gate the child could not run goes under the
454
- * handoff's unproven list, never claimed clean. The tests compare against the
455
- * pull request's own base — `origin/<base>` — which the render substitutes
456
- * from the rebase's `onto` when the contract knows it. */
457
- /** The changed-set test command as the contract renders it before the base is
458
- * known; the render substitutes the unit's base for `<base>` (`renderFirstInstruction`). */
459
- export const CHANGED_SET_TEST_PLACEHOLDER = "`npx vitest run --changed origin/<base>`";
454
+ * handoff's unproven list, never claimed clean. The test gate is the touched
455
+ * files by name, never a changed-set or directory run. */
456
+ /** The test command the contract hands a child: the touched files by name, once.
457
+ * Never `--changed`: against a base that moves, it selects most of the suite, and on the
458
+ * shared resident that is the memory incident the coding contract exists to prevent. */
459
+ export const TOUCHED_TESTS_COMMAND = "`npx vitest run` on the test files you touched, by name,";
460
460
 
461
461
  export const FAST_GATES_BEFORE_PUSH =
462
462
  "The fast gates, before every push — each scoped to the changed set, never the whole project: " +
463
- `${CHANGED_SET_TEST_PLACEHOLDER} (the pull request's base; or the touched test files), ` +
463
+ `${TOUCHED_TESTS_COMMAND} once (never \`--changed\`, never a directory: on a moving base that is most of the suite), ` +
464
464
  "`tsc --noEmit -p` the touched tsconfig under `NODE_OPTIONS=--max-old-space-size=6144`, " +
465
465
  "`npx prettier --check` on the changed files, `npm run hygiene:check` and `npm run specs:check` — " +
466
466
  "then your judgement on what else this change needs, not a longer checklist. Every CI pipeline runs the " +
@@ -474,14 +474,7 @@ export const FAST_GATES_BEFORE_PUSH =
474
474
  function renderFirstInstruction(rebase: ChildContract["rebase"]): string {
475
475
  const branch = rebase.branch ? `\`${rebase.branch}\`` : "the unit's branch";
476
476
  const onto = rebase.onto ? `\`${rebase.onto}\`` : "the merged parent";
477
- // the changed-set test run compares against the unit's own base, which the
478
- // contract knows as the rebase target; unknown, the placeholder stands
479
- // split/join, never String.replace: a `$` in a branch name is literal text here
480
- const gates = rebase.onto
481
- ? FAST_GATES_BEFORE_PUSH.split(`${CHANGED_SET_TEST_PLACEHOLDER} (the pull request's base; or`).join(
482
- `\`npx vitest run --changed origin/${rebase.onto}\` (or`,
483
- )
484
- : FAST_GATES_BEFORE_PUSH;
477
+ const gates = FAST_GATES_BEFORE_PUSH;
485
478
  return (
486
479
  `Rebase ${branch} onto ${onto} before any other work — the parent unit has merged and the base has moved; ` +
487
480
  `the only writes are your own on that branch. A conflict ends the unit: report it as the handoff and stop. ` +
@@ -461,6 +461,12 @@ export type CoordinatorAction =
461
461
  | {
462
462
  type: "pr-check";
463
463
  step: string;
464
+ /** The unit-start's pre-check (issue 1689): the bot reads the entry facts
465
+ * beside the listing — the branch's own tip, whether its approval
466
+ * stands at the head, and the checks there — so a re-issued plan's
467
+ * unit resumes at review, or at the merge decision, never at coding
468
+ * over an already-shipped pull request. */
469
+ entry?: true;
464
470
  /** Set after a coding child died: the bot opens the pull request from the
465
471
  * pushed branch itself (title from the unit, body from this run's
466
472
  * submitted description when the record holds one) instead of answering
@@ -545,6 +551,15 @@ export type PrCheck =
545
551
  prNumber: number;
546
552
  url: string;
547
553
  headSha?: string;
554
+ /** The unit branch's own tip, read beside the listing on the entry check
555
+ * (issue 1689): the listing's `headSha` can lag a force-push, so the
556
+ * machine resumes at review only when the pull request heads the
557
+ * branch's actual tip — a head behind the branch is round 0's. */
558
+ branchHead?: string;
559
+ /** Whether an approving review by the bot stands at the head (the entry
560
+ * check, issue 1689): with green checks the attempt resumes straight at
561
+ * the merge decision instead of a review round. */
562
+ approved?: boolean;
548
563
  autoMergeEnabled?: boolean;
549
564
  /** The check runs at the head, when the read asked for them (the ending's facts). */
550
565
  checks?: CommitChecksFacts;
@@ -696,7 +711,46 @@ export type UnitEnding =
696
711
  }
697
712
  | { kind: "no_verdict"; round: RoundRef; reviewRounds: number; finalReply?: string }
698
713
  | { kind: "interrupted"; round: RoundRef; runId: string; reviewRounds: number }
699
- | { kind: "refused"; refusal: string; message?: string; round: RoundRef; reviewRounds: number };
714
+ | { kind: "refused"; refusal: string; message?: string; round: RoundRef; reviewRounds: number }
715
+ /** The unit idles instead of ending (record 0051): with the resolved
716
+ * `ship.idleDays` above zero, `end()` wraps an idling kind — every kind but
717
+ * the four ended ones (`merged`, `already_landed`, `merge_ready`,
718
+ * `refused`) — in this ending: the old kind as `why`, the old kind's
719
+ * report unchanged, and what a continuation needs (the renewals the grant
720
+ * still holds — unspent: the wake spends one, this plan's fifth unit — the
721
+ * head to continue from, the last coding child's run, the spend and its
722
+ * handoff). `runId` is absent when the pipeline capped before any coding
723
+ * child ran. */
724
+ | {
725
+ kind: "idle";
726
+ why: IdleWhy;
727
+ /** The ending the idle stands in for, whole: its report is rendered per
728
+ * copy at that copy's level — the row's and the board's in full, the
729
+ * thread's at the request's verbosity — never once at mapping time. */
730
+ idled: Exclude<UnitEnding, { kind: "idle" }>;
731
+ renewalsLeft: number;
732
+ from?: string;
733
+ runId?: string;
734
+ spendUsd: number | null;
735
+ handoff?: Handoff;
736
+ round?: RoundRef;
737
+ reviewRounds: number;
738
+ };
739
+
740
+ /** The kinds that idle: every ending but the four ended ones — the unit is
741
+ * unfinished (a cap, a stop, an abort, a refused merge, a segment's end) and
742
+ * a reply could continue it. `merged`, `already_landed`, `merge_ready` and
743
+ * `refused` never idle: the first two are done, merge-ready waits only for a
744
+ * person's merge, and a refused child would be refused again. */
745
+ export type IdleWhy = Exclude<UnitEnding["kind"], "idle" | "merged" | "already_landed" | "merge_ready" | "refused">;
746
+
747
+ const NEVER_IDLES: ReadonlySet<UnitEnding["kind"]> = new Set([
748
+ "idle",
749
+ "merged",
750
+ "already_landed",
751
+ "merge_ready",
752
+ "refused",
753
+ ]);
700
754
 
701
755
  /** What a transition tells the driver beyond the next action: a round boundary
702
756
  * the card draws (`shipRoundHeader`) and the run stream records, and the end. */
@@ -783,6 +837,11 @@ export interface UnitPipelineInput {
783
837
  * `plan` with an id and no `path` — whose unit runs in the requesting
784
838
  * thread and is re-issued with the request's own text, never a plan path. */
785
839
  generated: boolean;
840
+ /** The idle flag (record 0051; agent-ship item 8): `ship.idleDays` as the
841
+ * ship fork resolved it onto the instance — above zero, an idling kind ends
842
+ * `idle` instead; zero or absent is today's behavior byte for byte. The
843
+ * wait itself lands with this plan's fifth unit. */
844
+ idleDays?: number;
786
845
  /** Resume at review: an open pull request of ship's own the requester named. */
787
846
  resume?: { pr: number; headSha?: string; url?: string };
788
847
  /** The head the previous attempt's coding child last pushed (a
@@ -876,6 +935,10 @@ export interface UnitPipelineState {
876
935
  readonly findingsRunByRound: Readonly<Record<number, string>>;
877
936
  /** The last coding run (round 0's child or a findings step's): its record carries the unit's handoff. */
878
937
  readonly lastCodingRunId?: string;
938
+ /** The head the last coding child left the branch at, and its handoff — the
939
+ * continuation facts an `idle` ending carries (record 0051). */
940
+ readonly lastChildHead?: string;
941
+ readonly lastCodingHandoff?: Handoff;
879
942
  /** How the budget went so far, accrued as each answer moves the clock. */
880
943
  readonly spentMs: ShipBudgetSpent;
881
944
  /** The session's dollars so far: the input's from earlier segments plus each
@@ -1033,7 +1096,7 @@ export function nextAction(s: UnitPipelineState): CoordinatorAction {
1033
1096
  const p = s.phase;
1034
1097
  switch (p.at) {
1035
1098
  case "pre-check":
1036
- return { type: "pr-check", step: `${unit}/pr-check` };
1099
+ return { type: "pr-check", step: `${unit}/pr-check`, entry: true };
1037
1100
  case "branch":
1038
1101
  return { type: "branch", step: `${unit}/branch`, branch: s.input.unit.branch, from: s.input.base };
1039
1102
  case "spawn": {
@@ -1117,7 +1180,55 @@ function addSpend(sum: number | null, cost: number | null | undefined): number |
1117
1180
  }
1118
1181
 
1119
1182
  function end(s: UnitPipelineState, ending: UnitEnding, notes: CoordinatorNote[] = []): Transition {
1120
- return { state: { ...s, phase: ENDED, ending }, notes: [...notes, { type: "ended", ending }] };
1183
+ const idled = idleEnding(s, ending);
1184
+ const final = idled ?? ending;
1185
+ return { state: { ...s, phase: ENDED, ending: final }, notes: [...notes, { type: "ended", ending: final }] };
1186
+ }
1187
+
1188
+ /** The idle mapping (record 0051; agent-ship item 8): with the input's
1189
+ * `idleDays` above zero an idling kind ends `idle` — the old kind as `why`,
1190
+ * the old ending itself for the report, and the continuation facts off the state
1191
+ * (a `continued` ending's own where it carries them). At zero, undefined:
1192
+ * every ending is byte for byte today's. The round notes keep the old kind's
1193
+ * outcome — the row and the card say what happened. */
1194
+ function idleEnding(s: UnitPipelineState, ending: UnitEnding): Extract<UnitEnding, { kind: "idle" }> | undefined {
1195
+ if ((s.input.idleDays ?? 0) <= 0) return undefined;
1196
+ if (NEVER_IDLES.has(ending.kind)) return undefined;
1197
+ const old = ending as Exclude<UnitEnding, { kind: "idle" | "merged" | "already_landed" | "merge_ready" | "refused" }>;
1198
+ const grant = s.input.grant ?? DEFAULT_GRANT;
1199
+ // Unspent: an idle spends no renewal — the wake's segment does (this plan's
1200
+ // fifth unit) — so the row says what the grant still holds.
1201
+ const renewalsLeft = Math.max(0, grant.renewals - (s.input.session?.renewalsSpent ?? 0));
1202
+ // The head to continue from: a segment's own; a review_pending's reviewed
1203
+ // head — the pull request's, known even when the coding record carried none,
1204
+ // so the re-issue's resume-at-review fact survives the idle; else the last
1205
+ // coding child's recorded head.
1206
+ const from =
1207
+ old.kind === "continued"
1208
+ ? old.from
1209
+ : old.kind === "review_pending"
1210
+ ? (old.headSha ?? s.lastChildHead)
1211
+ : s.lastChildHead;
1212
+ // The last coding child's run: a segment's own, a dead coding child's own —
1213
+ // a review child that died names nothing here, the coding run before it does.
1214
+ const runId =
1215
+ old.kind === "continued" || (old.kind === "interrupted" && old.round.kind !== "review")
1216
+ ? old.runId
1217
+ : s.lastCodingRunId;
1218
+ const handoff = old.kind === "continued" ? old.handoff : s.lastCodingHandoff;
1219
+ const round = "round" in old ? old.round : undefined;
1220
+ return {
1221
+ kind: "idle",
1222
+ why: old.kind,
1223
+ idled: old,
1224
+ renewalsLeft,
1225
+ ...(from !== undefined ? { from } : {}),
1226
+ ...(runId !== undefined ? { runId } : {}),
1227
+ spendUsd: s.spendUsd,
1228
+ ...(handoff !== undefined ? { handoff } : {}),
1229
+ ...(round !== undefined ? { round } : {}),
1230
+ reviewRounds: s.reviewRounds,
1231
+ };
1121
1232
  }
1122
1233
 
1123
1234
  /** A gated finding as the gate note names it: `F1 (minor)`. */
@@ -1190,6 +1301,9 @@ function settleCoding(
1190
1301
  let next: UnitPipelineState = {
1191
1302
  ...s,
1192
1303
  spendUsd: addSpend(s.spendUsd, facts.costUsd),
1304
+ // The continuation facts an idle ending reads off the state (record 0051).
1305
+ ...(facts.headSha !== undefined ? { lastChildHead: facts.headSha } : {}),
1306
+ ...(facts.handoffLists !== undefined ? { lastCodingHandoff: facts.handoffLists } : {}),
1193
1307
  ...(facts.pr !== undefined ? { pr: { number: facts.pr.number, url: facts.pr.url } } : {}),
1194
1308
  ...(round.kind === "findings" && facts.dispositions !== undefined
1195
1309
  ? {
@@ -1760,19 +1874,40 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
1760
1874
  // by the coding child and adopted at the round's own pr-check.
1761
1875
  const r = ret as Extract<StepReturn, { type: "pr-check" }>;
1762
1876
  if (r.pr.state === "merged") return foundMerged(clocked, r.pr);
1763
- // The open pull request still heads at the child's own last push (the
1764
- // previous attempt ended `review_pending`): nothing to code, so the
1765
- // attempt adopts it and starts at the review round — never a fresh
1766
- // coding round on an already-shipped pull request.
1877
+ // The open pull request still heads the branch — its head is the
1878
+ // branch's own tip (the entry facts, issue 1689) or the child's own last
1879
+ // push (the previous attempt ended `review_pending`, the row's
1880
+ // `lastPush`): nothing to code, so the attempt adopts it and starts at
1881
+ // the review round — never a fresh coding round on an already-shipped
1882
+ // pull request. Approved at that very head with green checks, there is
1883
+ // nothing to review either: the attempt resumes straight at the merge
1884
+ // decision — `merge_ready` for a person, the merge door under
1885
+ // `merge: runner`, which re-verifies the approval and the checks itself.
1767
1886
  if (r.pr.state === "open") {
1768
1887
  const head = normalizeHead(r.pr.headSha);
1888
+ const branchHead = normalizeHead(r.pr.branchHead);
1769
1889
  const lastPush = normalizeHead(s.input.lastPush);
1770
- if (head !== undefined && lastPush !== undefined && sameCommit(head, lastPush))
1771
- return nextReview({
1890
+ const atBranchHead = head !== undefined && branchHead !== undefined && sameCommit(head, branchHead);
1891
+ const atLastPush = head !== undefined && lastPush !== undefined && sameCommit(head, lastPush);
1892
+ if (atBranchHead || atLastPush) {
1893
+ // The entry facts — the approval and the checks — were read at the
1894
+ // branch's own tip when GitHub answered it (`branchHead`, the
1895
+ // fresher read of the same head ref), else at the listing's sha.
1896
+ // The resume pins the review — and the merge decision — at that
1897
+ // same head, so the approved+green shortcut never fires at a stale
1898
+ // listing sha the facts do not describe.
1899
+ const entryHead = branchHead ?? head;
1900
+ const adopted: UnitPipelineState = {
1772
1901
  ...clocked,
1773
1902
  pr: { number: r.pr.prNumber, url: r.pr.url },
1774
- lastReviewHead: head,
1775
- });
1903
+ lastReviewHead: entryHead,
1904
+ };
1905
+ const checks = r.pr.checks;
1906
+ const green =
1907
+ checks !== undefined && checks.total > 0 && checks.failed.length === 0 && checks.pending.length === 0;
1908
+ if (r.pr.approved === true && green) return approveOutcome(adopted, []);
1909
+ return nextReview(adopted);
1910
+ }
1776
1911
  }
1777
1912
  return { state: { ...clocked, phase: { at: "branch" } }, notes: [] };
1778
1913
  }
@@ -2242,5 +2377,9 @@ export function renderUnitReport(
2242
2377
  `🚫 The ${presetOf(e.round.kind)} child of round ${e.round.index} was refused by the authorize stage (${e.refusal})${e.message ? `: ${e.message}` : ""} — every child is authorized as the requesting user, so the pipeline ends here.`,
2243
2378
  reissue,
2244
2379
  ]);
2380
+ case "idle":
2381
+ // The old kind's sentence at this copy's level — the report is unchanged
2382
+ // by the idle at every verbosity (record 0051).
2383
+ return renderUnitReport({ ...s, ending: e.idled }, facts, verbosity);
2245
2384
  }
2246
2385
  }
@@ -100,6 +100,22 @@ export const profileSchema = z.object({
100
100
  .regex(/^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/, "an R2 bucket name: 3–63 lowercase letters, digits and hyphens"),
101
101
  })
102
102
  .optional(),
103
+ /** The run-metrics dataset (docs/reference/specs/run-metrics.md item 6), when the installation
104
+ * writes points: the state Worker's template binds it as `RUN_METRICS` with the name beside it
105
+ * in `RUN_METRICS_DATASET`, and `deploy plan` names the binding on the memory step. Nothing is
106
+ * created ahead of the deploy — the platform creates a dataset on first write. The bot's runtime
107
+ * config (`metrics.dataset`) must name the same dataset; the two are held together by the bot's
108
+ * boot warning, not by the profile. Analytics Engine's own dataset-name rules. */
109
+ metrics: z
110
+ .object({
111
+ dataset: z
112
+ .string()
113
+ .regex(
114
+ /^[A-Za-z_][A-Za-z0-9_]{0,63}$/,
115
+ "an Analytics Engine dataset name: 1–64 letters, digits and underscores, not starting with a digit",
116
+ ),
117
+ })
118
+ .optional(),
103
119
  });
104
120
 
105
121
  export type DeploymentProfile = z.infer<typeof profileSchema>;