@coreplane/switchboard 1.251.0 → 1.253.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 (167) hide show
  1. package/dist/assets/config/config.example.yaml +28 -2
  2. package/dist/assets/deploy/cloudflare/preflight.mjs +19 -21
  3. package/dist/assets/deploy/cloudflare/worker.ts +6 -3
  4. package/dist/assets/deploy/cloudflare-memory/runMetricsSink.ts +31 -0
  5. package/dist/assets/deploy/cloudflare-memory/worker.ts +223 -38
  6. package/dist/assets/deploy/cloudflare-memory/wrangler.template.jsonc +12 -0
  7. package/dist/assets/deploy/cloudflare-resident/memoryGuard.ts +212 -0
  8. package/dist/assets/deploy/cloudflare-resident/refresh.ts +1 -1
  9. package/dist/assets/deploy/cloudflare-resident/worker.ts +317 -56
  10. package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +4 -2
  11. package/dist/assets/deploy/cloudflare-sandbox/runtime-supervisor.d.mts +31 -0
  12. package/dist/assets/deploy/cloudflare-sandbox/runtime-supervisor.mjs +119 -0
  13. package/dist/assets/deploy/profile.example.json +1 -1
  14. package/dist/assets/package-lock.json +3 -3
  15. package/dist/assets/package.json +3 -2
  16. package/dist/assets/project.json +13 -9
  17. package/dist/assets/source.json +3 -3
  18. package/dist/assets/src/agents/registry.ts +5 -5
  19. package/dist/assets/src/core/authz/policy.ts +8 -0
  20. package/dist/assets/src/core/budgets.ts +39 -0
  21. package/dist/assets/src/core/coordinator/contract.ts +93 -1
  22. package/dist/assets/src/core/coordinator/driver.ts +236 -21
  23. package/dist/assets/src/core/drain.ts +50 -0
  24. package/dist/assets/src/core/memory/engine.ts +123 -4
  25. package/dist/assets/src/core/memory/scorer.ts +12 -4
  26. package/dist/assets/src/core/memory/types.ts +73 -12
  27. package/dist/assets/src/core/modelCard.ts +32 -4
  28. package/dist/assets/src/core/modelPricing.ts +111 -1
  29. package/dist/assets/src/core/modelProxy/usage.ts +88 -0
  30. package/dist/assets/src/core/modelRegistry.ts +15 -1
  31. package/dist/assets/src/core/refusal.ts +4 -7
  32. package/dist/assets/src/core/reviewVerdict.ts +4 -0
  33. package/dist/assets/src/core/runEvents.ts +82 -3
  34. package/dist/assets/src/core/runFriction.ts +8 -2
  35. package/dist/assets/src/core/runLedger/types.ts +11 -0
  36. package/dist/assets/src/core/runMetrics.ts +237 -0
  37. package/dist/assets/src/core/runRecord.ts +46 -0
  38. package/dist/assets/src/core/runUsage.ts +67 -13
  39. package/dist/assets/src/core/schedules.ts +3 -0
  40. package/dist/assets/src/core/ship/contract.ts +34 -14
  41. package/dist/assets/src/core/ship/coordinator.ts +485 -55
  42. package/dist/assets/src/core/ship/renewal.ts +10 -5
  43. package/dist/assets/src/core/trace/attrs.ts +24 -0
  44. package/dist/assets/src/core/types.ts +5 -5
  45. package/dist/assets/src/core/verbosity.ts +48 -0
  46. package/dist/assets/src/deploy/liveGate.ts +40 -13
  47. package/dist/assets/src/deploy/profile.ts +16 -0
  48. package/dist/assets/src/deploy/restart.ts +11 -12
  49. package/dist/assets/src/execution/residentDepCache.ts +50 -1
  50. package/dist/assets/src/execution/residentDepsStore.ts +40 -2
  51. package/dist/assets/src/execution/residentRefresh.ts +55 -3
  52. package/dist/assets/src/execution/residentSteps.ts +4 -0
  53. package/dist/assets/src/execution/sandboxErrors.ts +8 -0
  54. package/dist/assets/web/dist/.vite/manifest.json +444 -430
  55. package/dist/assets/web/dist/assets/{AppShell-DAtHiuI6.js → AppShell-Bmz8nzz3.js} +1 -1
  56. package/dist/assets/web/dist/assets/{CostsPage-BoKzYa4B.js → CostsPage-CxTKdpzU.js} +1 -1
  57. package/dist/assets/web/dist/assets/{DeliveryPage-DUXd-Sl-.js → DeliveryPage-C7jaEJip.js} +1 -1
  58. package/dist/assets/web/dist/assets/HomePage-cWbupQzy.js +3 -0
  59. package/dist/assets/web/dist/assets/{InputMenu-C6aPpe30.js → InputMenu-C9E7YoII.js} +1 -1
  60. package/dist/assets/web/dist/assets/{NotFoundPage-DtE-GgTk.js → NotFoundPage-4nlrq7DP.js} +1 -1
  61. package/dist/assets/web/dist/assets/{PendingTurnRow-DDhMhrI7.js → PendingTurnRow-C-pUgyyX.js} +1 -1
  62. package/dist/assets/web/dist/assets/PlanePage-BNy4tSgJ.js +1 -0
  63. package/dist/assets/web/dist/assets/{ResidentDetailPage-BnEoOnGQ.js → ResidentDetailPage-Dbtiq6_J.js} +1 -1
  64. package/dist/assets/web/dist/assets/{ResidentsIndexPage-Dxpgf-l-.js → ResidentsIndexPage-CKtz9H1o.js} +1 -1
  65. package/dist/assets/web/dist/assets/RunFoldRow-sYFtuK2G.js +1 -0
  66. package/dist/assets/web/dist/assets/RunRoutePage-Clh68nw9.js +9 -0
  67. package/dist/assets/web/dist/assets/RunsIndexPage-DPDbcOA_.js +1 -0
  68. package/dist/assets/web/dist/assets/{RunsTabs-YSbUu5py.js → RunsTabs-DotpuCSP.js} +1 -1
  69. package/dist/assets/web/dist/assets/{ScheduledPage-B_GgeJrb.js → ScheduledPage-C8FYoZy5.js} +1 -1
  70. package/dist/assets/web/dist/assets/{SettingSelect-BIzAsLk1.js → SettingSelect-CSiBQCSX.js} +1 -1
  71. package/dist/assets/web/dist/assets/{SettingsPage-BXX4R113.js → SettingsPage-CkYIhLUl.js} +1 -1
  72. package/dist/assets/web/dist/assets/{StatusDot-BOaw8le9.js → StatusDot-C0TUgnC1.js} +1 -1
  73. package/dist/assets/web/dist/assets/{Tooltip-DYZZ4l4V.js → Tooltip-CN5cZdl3.js} +1 -1
  74. package/dist/assets/web/dist/assets/{UnitRoutePage-BaSW5Odq.js → UnitRoutePage-D_EtAl8r.js} +1 -1
  75. package/dist/assets/web/dist/assets/{angular-html-BjeQZdCq.js → angular-html-qW40ZL9x.js} +1 -1
  76. package/dist/assets/web/dist/assets/{angular-ts-ZGdORrL2.js → angular-ts-PUtZU8Cq.js} +1 -1
  77. package/dist/assets/web/dist/assets/{apl-DhvV_X93.js → apl-D3fC7Qjd.js} +1 -1
  78. package/dist/assets/web/dist/assets/{astro-DiRSE4Ug.js → astro-Bi3JFHhh.js} +1 -1
  79. package/dist/assets/web/dist/assets/{blade-D8Fr5Wvq.js → blade-CSpsf9Z9.js} +1 -1
  80. package/dist/assets/web/dist/assets/budgets-CbIyPAER.js +1 -0
  81. package/dist/assets/web/dist/assets/{c-BwbC64D4.js → c-DE_b1oWP.js} +1 -1
  82. package/dist/assets/web/dist/assets/{chapel-DgmqRK97.js → chapel-CWXd9giL.js} +1 -1
  83. package/dist/assets/web/dist/assets/{cobol-DJod2RNK.js → cobol-CnMOmWNF.js} +1 -1
  84. package/dist/assets/web/dist/assets/{coffee-D1Y2CgBY.js → coffee-B9WvURu8.js} +1 -1
  85. package/dist/assets/web/dist/assets/{cpp-CsO97YOM.js → cpp-BZAJXlWI.js} +1 -1
  86. package/dist/assets/web/dist/assets/{crystal-CJ7m2tOc.js → crystal-Bm48wAlb.js} +1 -1
  87. package/dist/assets/web/dist/assets/{css-B2M-NKoj.js → css-WtMeC3ab.js} +1 -1
  88. package/dist/assets/web/dist/assets/{dist-BCVXeBJ9.js → dist-B1Mf2JbM.js} +2 -2
  89. package/dist/assets/web/dist/assets/{edge-CFVW-m9B.js → edge-BmwNa6Ss.js} +1 -1
  90. package/dist/assets/web/dist/assets/{elixir-B2mtRgCr.js → elixir-BotfP-Mn.js} +1 -1
  91. package/dist/assets/web/dist/assets/{elm-2teVAwCi.js → elm-DAFc6laN.js} +1 -1
  92. package/dist/assets/web/dist/assets/{erb-LAbeNqen.js → erb-QkOON3bB.js} +1 -1
  93. package/dist/assets/web/dist/assets/{git-rebase-DnhzH_sp.js → git-rebase-BPs-dd73.js} +1 -1
  94. package/dist/assets/web/dist/assets/{glimmer-js-DPKjXf1-.js → glimmer-js-D8eooepk.js} +1 -1
  95. package/dist/assets/web/dist/assets/{glimmer-ts-CABXnt5z.js → glimmer-ts-B0bRiDVg.js} +1 -1
  96. package/dist/assets/web/dist/assets/{glsl-Dz4-3-gJ.js → glsl-Bv6bMe1n.js} +1 -1
  97. package/dist/assets/web/dist/assets/{graphql-ByWUD1DL.js → graphql-Bh9y-n9o.js} +1 -1
  98. package/dist/assets/web/dist/assets/{hack-CdmOK2K-.js → hack-DSEbn7Ir.js} +1 -1
  99. package/dist/assets/web/dist/assets/{haml-DzegMpd2.js → haml-0NW1-z1f.js} +1 -1
  100. package/dist/assets/web/dist/assets/{handlebars-B5NeiP0e.js → handlebars-BfgCHFbK.js} +1 -1
  101. package/dist/assets/web/dist/assets/{html-Zg_NHLv9.js → html-CQHc0gQ5.js} +1 -1
  102. package/dist/assets/web/dist/assets/{html-derivative-DZf9JNzh.js → html-derivative-B0Tm7wLP.js} +1 -1
  103. package/dist/assets/web/dist/assets/{http-CVFrQZeN.js → http-GAuU3aOF.js} +1 -1
  104. package/dist/assets/web/dist/assets/{hurl-L5brWAJe.js → hurl-CrDFbF5B.js} +1 -1
  105. package/dist/assets/web/dist/assets/indexRow-BxKswdlK.js +1 -0
  106. package/dist/assets/web/dist/assets/{java-hFphyZ4R.js → java-DYv5KhUe.js} +1 -1
  107. package/dist/assets/web/dist/assets/{javascript-CAJO1WKX.js → javascript-32SiLGg0.js} +1 -1
  108. package/dist/assets/web/dist/assets/{jinja-N4A4srXI.js → jinja--gJDUGYa.js} +1 -1
  109. package/dist/assets/web/dist/assets/{jison-D3BZa_iU.js → jison-BlKLGzZH.js} +1 -1
  110. package/dist/assets/web/dist/assets/{json-DxD1Qh8W.js → json-BICanD1N.js} +1 -1
  111. package/dist/assets/web/dist/assets/{jsx-CHhag69S.js → jsx-CgW3-uFT.js} +1 -1
  112. package/dist/assets/web/dist/assets/{julia-CrhkZ6Tt.js → julia-BErRpwwL.js} +1 -1
  113. package/dist/assets/web/dist/assets/{just-D0LFOuhq.js → just-CordHv0h.js} +1 -1
  114. package/dist/assets/web/dist/assets/{latex-BHAlwCXK.js → latex-D7LVPLbi.js} +1 -1
  115. package/dist/assets/web/dist/assets/{liquid-CkaLfCWh.js → liquid-3ehz2i4U.js} +1 -1
  116. package/dist/assets/web/dist/assets/{lua-B1h4aAzP.js → lua-C9ppSVrm.js} +1 -1
  117. package/dist/assets/web/dist/assets/main-Cv0q9y56.js +28 -0
  118. package/dist/assets/web/dist/assets/main-DKFCM-sy.css +1 -0
  119. package/dist/assets/web/dist/assets/{marko-D6CG4hvX.js → marko-xBZLUBEJ.js} +1 -1
  120. package/dist/assets/web/dist/assets/{mdc-CVQzimAU.js → mdc-BJxGoVgD.js} +1 -1
  121. package/dist/assets/web/dist/assets/{nginx-h-9Ir54h.js → nginx-B-sZmpsb.js} +1 -1
  122. package/dist/assets/web/dist/assets/{nim-DHVI30sp.js → nim--s9wRABK.js} +1 -1
  123. package/dist/assets/web/dist/assets/{org-AasAXfMF.js → org-DoUvssoI.js} +1 -1
  124. package/dist/assets/web/dist/assets/{perl-BVi5aTEy.js → perl-B-vGHKOy.js} +1 -1
  125. package/dist/assets/web/dist/assets/{php-qOkCeUKr.js → php-CerWx_Xb.js} +1 -1
  126. package/dist/assets/web/dist/assets/{pug-8TnUGvEX.js → pug-CzKrBMb1.js} +1 -1
  127. package/dist/assets/web/dist/assets/{qml-CpJbhoxn.js → qml-Bs8L8I6k.js} +1 -1
  128. package/dist/assets/web/dist/assets/{r-JkUNcAJW.js → r-w6uG6mob.js} +1 -1
  129. package/dist/assets/web/dist/assets/{razor-CtU4ws5a.js → razor-dz2oj2bd.js} +1 -1
  130. package/dist/assets/web/dist/assets/{regexp-J2zA6ceN.js → regexp-CfQdprnY.js} +1 -1
  131. package/dist/assets/web/dist/assets/{rst-CMCvf-yZ.js → rst-DAOaX2tb.js} +1 -1
  132. package/dist/assets/web/dist/assets/{ruby-ChLBY_iR.js → ruby-QcCe4udB.js} +1 -1
  133. package/dist/assets/web/dist/assets/{sas-cwn4x2VY.js → sas-BrM4lAAc.js} +1 -1
  134. package/dist/assets/web/dist/assets/{scss-jSrJlzLI.js → scss-DVbJXoUb.js} +1 -1
  135. package/dist/assets/web/dist/assets/{shellscript-D5nAd0gs.js → shellscript-09fP2hZf.js} +1 -1
  136. package/dist/assets/web/dist/assets/{shellsession-BZY4-Tdh.js → shellsession-Qgr8xjZF.js} +1 -1
  137. package/dist/assets/web/dist/assets/{soy-COppPOKr.js → soy-DIYeNTuz.js} +1 -1
  138. package/dist/assets/web/dist/assets/{sql-Blc_6dOA.js → sql-CzLR9msV.js} +1 -1
  139. package/dist/assets/web/dist/assets/sseReplay-BYo2EZKy.js +11 -0
  140. package/dist/assets/web/dist/assets/{stata-DRv3OY-h.js → stata-zCY1ZzH0.js} +1 -1
  141. package/dist/assets/web/dist/assets/{surrealql-qMv_eeJ6.js → surrealql-CJIjTRV4.js} +1 -1
  142. package/dist/assets/web/dist/assets/{svelte-d1Edb6Rz.js → svelte-C5OTPFZg.js} +1 -1
  143. package/dist/assets/web/dist/assets/{templ-CUMSIOXG.js → templ-DYj-UICF.js} +1 -1
  144. package/dist/assets/web/dist/assets/{tex-BGZQA-Ei.js → tex-xcie-3Gd.js} +1 -1
  145. package/dist/assets/web/dist/assets/{ts-tags-Z9ulxVsq.js → ts-tags-CoN86VTt.js} +1 -1
  146. package/dist/assets/web/dist/assets/{tsx-oI4bI3Vf.js → tsx-n3-No_Br.js} +1 -1
  147. package/dist/assets/web/dist/assets/{twig-D_RXwBIX.js → twig-DPiJ7Vp1.js} +1 -1
  148. package/dist/assets/web/dist/assets/{typescript-BgqQDYoN.js → typescript-BP6HBaOp.js} +1 -1
  149. package/dist/assets/web/dist/assets/{typst-5MW-KRDd.js → typst-Do9ZjsVP.js} +1 -1
  150. package/dist/assets/web/dist/assets/{vue-CZhHHsPm.js → vue-CzAeXNRY.js} +1 -1
  151. package/dist/assets/web/dist/assets/{vue-html-DlYy2d8v.js → vue-html-D7iYmYjd.js} +1 -1
  152. package/dist/assets/web/dist/assets/{vue-vine-CJDnx-qn.js → vue-vine-DtrMRsJx.js} +1 -1
  153. package/dist/assets/web/dist/assets/{xml-BRBDk65Q.js → xml-DGJHo1c0.js} +1 -1
  154. package/dist/assets/web/dist/assets/{xsl-BlCykPxo.js → xsl-Q00kvNgW.js} +1 -1
  155. package/dist/assets/web/dist/assets/{yaml-BqW48szf.js → yaml-F-UGKaiO.js} +1 -1
  156. package/dist/cli.js +5623 -2455
  157. package/package.json +1 -1
  158. package/dist/assets/deploy/cloudflare-sandbox/runtime-supervisor.sh +0 -68
  159. package/dist/assets/web/dist/assets/HomePage-mSiqEEcN.js +0 -2
  160. package/dist/assets/web/dist/assets/RunFoldRow-CSo4-vld.js +0 -1
  161. package/dist/assets/web/dist/assets/RunRoutePage-9klVWhSF.js +0 -9
  162. package/dist/assets/web/dist/assets/RunsIndexPage-BplMIgaw.js +0 -1
  163. package/dist/assets/web/dist/assets/budgets-BvWYKPsY.js +0 -1
  164. package/dist/assets/web/dist/assets/indexRow-Bde9OZxG.js +0 -1
  165. package/dist/assets/web/dist/assets/main-Dkcbtu3u.js +0 -28
  166. package/dist/assets/web/dist/assets/main-zbP_dTjR.css +0 -1
  167. package/dist/assets/web/dist/assets/sseReplay-DPwdsaok.js +0 -9
@@ -33,7 +33,8 @@
33
33
  // plan's re-issue. A task string's ship branch waits for a person. Node-free:
34
34
  // the shim Worker imports this by relative path.
35
35
 
36
- import { DEFAULT_GRANT, GRANT_RENEWALS_MAX, type Grant, type GrantSource } from "../budgets.js";
36
+ import { DEFAULT_GRANT, GRANT_RENEWALS_MAX, IDLE_DAYS_MAX, type Grant, type GrantSource } from "../budgets.js";
37
+ import { DEFAULT_VERBOSITY, isVerbosity, type Verbosity } from "../verbosity.js";
37
38
  import {
38
39
  applyReturn,
39
40
  cursorFinished,
@@ -41,6 +42,8 @@ import {
41
42
  openPlanCursor,
42
43
  openUnitPipeline,
43
44
  readyUnits,
45
+ type PlanCursor,
46
+ type UnitStatus,
44
47
  renderUnitReport,
45
48
  settleUnit,
46
49
  startUnit,
@@ -52,6 +55,7 @@ import {
52
55
  type AddressSeveritySource,
53
56
  type PlanGraph,
54
57
  type PlanUnitNode,
58
+ type RoundChecks,
55
59
  type ShipCaps,
56
60
  type StepReturn,
57
61
  type UnitEnding,
@@ -59,7 +63,14 @@ import {
59
63
  stepPrefixOf,
60
64
  type UnitSession,
61
65
  } from "../ship/coordinator.js";
62
- import { checksSettledEventType, isCoordinatorUnit, runFinishedEventType, type CoordinatorUnit } from "./contract.js";
66
+ import {
67
+ checksSettledEventType,
68
+ childInterruptedEventType,
69
+ childResumedEventType,
70
+ isCoordinatorUnit,
71
+ runFinishedEventType,
72
+ type CoordinatorUnit,
73
+ } from "./contract.js";
63
74
 
64
75
  const MIN = 60_000;
65
76
 
@@ -83,7 +94,17 @@ export interface StepRunner {
83
94
  }
84
95
 
85
96
  export type CoordinatorStepRoute =
86
- "plan" | "unit-start" | "branch" | "spawn" | "read-record" | "pr-check" | "round" | "unit-end" | "merge" | "finish";
97
+ | "plan"
98
+ | "unit-start"
99
+ | "branch"
100
+ | "spawn"
101
+ | "read-record"
102
+ | "pr-check"
103
+ | "round"
104
+ | "unit-end"
105
+ | "checks"
106
+ | "merge"
107
+ | "finish";
87
108
 
88
109
  /** What a step stores: the bot's reply as the wire carried it — its status and
89
110
  * its text, read the same way on replay. Two numbers and a string, so the
@@ -144,8 +165,18 @@ export function readBotAnswer(
144
165
  }
145
166
 
146
167
  /** The answers the bot itself calls a passing condition — the step is asked
147
- * again, under the policy. Everything else, refusals included, is the machine's. */
148
- const TRANSIENT = new Set(["github_unavailable", "no_channel", "thread_failed", "unit_not_started", "not_host"]);
168
+ * again, under the policy. Everything else, refusals included, is the machine's.
169
+ * `queued` is the plane's hold on a spawn (record 0064, "The queue"): a `409`
170
+ * saying the child's admission waits on an event — the step re-asks until the
171
+ * child is admitted, and the instance is never failed over a full plane. */
172
+ const TRANSIENT = new Set([
173
+ "github_unavailable",
174
+ "no_channel",
175
+ "thread_failed",
176
+ "unit_not_started",
177
+ "not_host",
178
+ "queued",
179
+ ]);
149
180
  export function transientRefusal(answer: BotAnswer): string | undefined {
150
181
  const { ok, error, message } = answer.body;
151
182
  if (ok !== false || typeof error !== "string" || !TRANSIENT.has(error)) return undefined;
@@ -170,6 +201,10 @@ interface PlanFacts {
170
201
  /** The grant beside them (decision 0046): what a renewal could spend, and which layer granted it. */
171
202
  grant: Grant;
172
203
  grantSource: GrantSource;
204
+ /** The request's verbosity as the plan route answers it (routing-and-config item 28): what the unit threads hear. */
205
+ verbosity: Verbosity;
206
+ /** The idle flag beside the grant (record 0051): above zero, an idling ending becomes `idle`. */
207
+ idleDays: number;
173
208
  /** The instance's mark as the plan route answers it: a generated one-unit plan (a `plan` with no `path`). */
174
209
  generated: boolean;
175
210
  /** The runs page base the bot answered: the report links a child's write-up to its run page with it. */
@@ -199,6 +234,12 @@ function readGrant(raw: unknown): Grant {
199
234
  return { renewals: raw.renewals, ...(typeof cap === "number" && cap > 0 ? { costCapUsd: cap } : {}) };
200
235
  }
201
236
 
237
+ /** The idle flag as the plan route answers it (record 0051): an integer from 0
238
+ * to the module's cap; anything unreadable is 0 — nothing idles on a guess. */
239
+ function readIdleDays(raw: unknown): number {
240
+ return typeof raw === "number" && Number.isInteger(raw) && raw >= 0 && raw <= IDLE_DAYS_MAX ? raw : 0;
241
+ }
242
+
202
243
  function readPlan(a: BotAnswer): PlanFacts {
203
244
  const b = a.body;
204
245
  if (b.ok !== true) throw new UnreadableAnswer("plan", a, "not ok");
@@ -218,6 +259,8 @@ function readPlan(a: BotAnswer): PlanFacts {
218
259
  grant: readGrant(b.grant),
219
260
  grantSource:
220
261
  b.grantSource === "run" || b.grantSource === "user" || b.grantSource === "channel" ? b.grantSource : "org",
262
+ verbosity: isVerbosity(b.verbosity) ? b.verbosity : DEFAULT_VERBOSITY,
263
+ idleDays: readIdleDays(b.idleDays),
221
264
  generated: b.generated === true,
222
265
  ...(typeof b.runPageBase === "string" && b.runPageBase.length > 0 ? { runPageBase: b.runPageBase } : {}),
223
266
  repo: b.repo,
@@ -349,6 +392,12 @@ function prCheckReturn(step: string, a: BotAnswer): StepReturn {
349
392
  ...(typeof headSha === "string" ? { headSha } : {}),
350
393
  ...(typeof a.body.autoMergeEnabled === "boolean" ? { autoMergeEnabled: a.body.autoMergeEnabled } : {}),
351
394
  ...(isCommitChecks(a.body.checks) ? { checks: a.body.checks } : {}),
395
+ // The ready-state facts beside the checks (agent-ship item 9): the
396
+ // pull request's mergeable state and its self-declared fix-up commits.
397
+ ...(typeof a.body.mergeableState === "string" ? { mergeableState: a.body.mergeableState } : {}),
398
+ ...(Array.isArray(a.body.fixupCommits) && a.body.fixupCommits.every((s: unknown) => typeof s === "string")
399
+ ? { fixupCommits: a.body.fixupCommits as string[] }
400
+ : {}),
352
401
  },
353
402
  at,
354
403
  };
@@ -366,6 +415,31 @@ function prCheckReturn(step: string, a: BotAnswer): StepReturn {
366
415
  throw new UnreadableAnswer("pr-check", a, "state");
367
416
  }
368
417
 
418
+ /** The round's checks read as the bot answered it (record 0055): the runs at
419
+ * the reviewed head, or none when GitHub could not be read — the machine
420
+ * treats an absent read as pending and asks again at the chunk's end. A retry
421
+ * ask's answer carries `retried` instead: whether the re-run was dispatched,
422
+ * so the machine never waits on a head an undispatched re-run left unchanged. */
423
+ function checksReturn(step: string, a: BotAnswer): StepReturn {
424
+ const { ok, checks, retried, at } = a.body;
425
+ if (ok !== true) throw new UnreadableAnswer("checks", a, "ok");
426
+ return {
427
+ type: "checks",
428
+ step,
429
+ ...(isRoundChecks(checks) ? { checks } : {}),
430
+ ...(typeof retried === "boolean" ? { retried } : {}),
431
+ at,
432
+ };
433
+ }
434
+
435
+ const isRoundChecks = (v: unknown): v is RoundChecks =>
436
+ isRecord(v) &&
437
+ typeof v.total === "number" &&
438
+ Array.isArray(v.pending) &&
439
+ v.pending.every((n: unknown) => typeof n === "string") &&
440
+ Array.isArray(v.failed) &&
441
+ v.failed.every((f: unknown) => isRecord(f) && typeof f.name === "string" && typeof f.conclusion === "string");
442
+
369
443
  function mergeReturn(step: string, a: BotAnswer): StepReturn {
370
444
  const { ok, outcome, by, sha, mergedAt, reason, at } = a.body;
371
445
  // The door found the pull request already merged after the approval: the
@@ -403,17 +477,58 @@ function answerOf(route: CoordinatorStepRoute, reply: BotReply): BotAnswer {
403
477
  return read.answer;
404
478
  }
405
479
 
406
- /** A wait's outcome: the event, or anything else — the machine confirms either by `read-record`. */
480
+ /** A wait's outcome: the event, or anything else — the machine confirms either by `read-record`.
481
+ *
482
+ * Three waits under one chunk (run-history item 47a): the child's finish
483
+ * (`run-finished-<runId>`), its deploy-roll interruption
484
+ * (`child-interrupted-<runId>`, the reattach path's word that the child
485
+ * closed `interrupted` — settled as the finish is, so the round ends at once
486
+ * with the child's own reason once `read-record` confirms it) and its resume
487
+ * (`child-resumed-<runId>`, the same run carrying on after a roll — consumed
488
+ * and re-armed, never a settlement: a resumed child keeps the wait). The
489
+ * chunk times out only once the finish AND the interruption waits both have;
490
+ * a resumed wait's own timeout decides nothing. */
407
491
  async function waitForRun(
408
492
  step: StepRunner,
409
493
  action: Extract<CoordinatorAction, { type: "wait" }>,
410
494
  ): Promise<"event" | "timeout"> {
411
- try {
412
- await step.waitForEvent(action.step, { type: runFinishedEventType(action.runId), timeout: action.timeoutMs });
413
- return "event";
414
- } catch {
415
- return "timeout";
416
- }
495
+ return await new Promise((resolve) => {
496
+ let settled = false;
497
+ let timeouts = 0;
498
+ const settle = (outcome: "event" | "timeout") => {
499
+ settled = true;
500
+ resolve(outcome);
501
+ };
502
+ const settling = (name: string, type: string) =>
503
+ step.waitForEvent(name, { type, timeout: action.timeoutMs }).then(
504
+ () => settle("event"),
505
+ () => {
506
+ timeouts += 1;
507
+ // Deferred a microtask so a resume that lands with the chunk's own
508
+ // end is still consumed (re-armed) before the timeout settles.
509
+ if (timeouts === 2) queueMicrotask(() => settle("timeout"));
510
+ },
511
+ );
512
+ void settling(action.step, runFinishedEventType(action.runId));
513
+ void settling(`${action.step}/interrupted`, childInterruptedEventType(action.runId));
514
+ // Each resumed event re-arms under the next durable name, so a second roll
515
+ // in the same chunk is still heard; a timeout here ends nothing, and a
516
+ // settled wait arms no further step.
517
+ const armResumed = (n: number): void => {
518
+ void step
519
+ .waitForEvent(n === 1 ? `${action.step}/resumed` : `${action.step}/resumed/${n}`, {
520
+ type: childResumedEventType(action.runId),
521
+ timeout: action.timeoutMs,
522
+ })
523
+ .then(
524
+ () => {
525
+ if (!settled) armResumed(n + 1);
526
+ },
527
+ () => {},
528
+ );
529
+ };
530
+ armResumed(1);
531
+ });
417
532
  }
418
533
 
419
534
  async function perform(
@@ -494,6 +609,24 @@ async function perform(
494
609
  }
495
610
  return { type: "wait-checks", step: action.step, outcome };
496
611
  }
612
+ case "checks":
613
+ // The round's checks step (record 0055): the bot reads the check runs at
614
+ // the reviewed head with the merge door's own reading — or, on a retry
615
+ // ask, re-runs the named failed checks' jobs first.
616
+ return checksReturn(
617
+ action.step,
618
+ answerOf(
619
+ "checks",
620
+ await step.do(action.step, STEP_CONFIG, () =>
621
+ call(bot, "checks", {
622
+ ...tag,
623
+ prNumber: action.prNumber,
624
+ headSha: action.headSha,
625
+ ...(action.retry !== undefined ? { retry: action.retry } : {}),
626
+ }),
627
+ ),
628
+ ),
629
+ );
497
630
  case "merge":
498
631
  return mergeReturn(
499
632
  action.step,
@@ -549,6 +682,8 @@ async function runUnit(
549
682
  addressSeveritySource: plan.addressSeveritySource,
550
683
  grant: plan.grant,
551
684
  grantSource: plan.grantSource,
685
+ verbosity: plan.verbosity,
686
+ idleDays: plan.idleDays,
552
687
  generated: plan.generated,
553
688
  ...(plan.runPageBase !== undefined ? { runPageBase: plan.runPageBase } : {}),
554
689
  ...(resume !== undefined ? { resume } : {}),
@@ -600,6 +735,11 @@ async function runUnit(
600
735
  // The checks at the approved head (record 0055): the report's
601
736
  // headline is a claim about them, never "merge-ready" over a red one.
602
737
  ...(check.pr.checks !== undefined ? { checks: check.pr.checks } : {}),
738
+ // The ready state beside them (agent-ship item 9): a conflicting
739
+ // head, or one carrying an unsquashed fix-up commit, is reported
740
+ // approved-but-not-merge-ready, never "merge-ready".
741
+ ...(check.pr.mergeableState !== undefined ? { mergeableState: check.pr.mergeableState } : {}),
742
+ ...(check.pr.fixupCommits !== undefined ? { fixupCommits: check.pr.fixupCommits } : {}),
603
743
  };
604
744
  } catch {
605
745
  // the report simply omits the fact
@@ -610,13 +750,34 @@ async function runUnit(
610
750
  // ending (agent-ship item 14).
611
751
  const body = {
612
752
  ...tag,
613
- ending: { kind: note.ending.kind, report: renderUnitReport(state, endFacts) },
753
+ // Two copies (routing-and-config item 28): the full report for the
754
+ // row and the board, and the thread's at the request's verbosity.
755
+ ending: {
756
+ kind: note.ending.kind,
757
+ report: renderUnitReport(state, endFacts),
758
+ threadReport: renderUnitReport(state, endFacts, state.input.verbosity ?? DEFAULT_VERBOSITY),
759
+ // An idle ending carries its continuation facts (record 0051): the
760
+ // bot writes them on the row's `idle` in place of an ending, with
761
+ // the coding run id it already receives below.
762
+ ...(note.ending.kind === "idle"
763
+ ? {
764
+ why: note.ending.why,
765
+ renewalsLeft: note.ending.renewalsLeft,
766
+ ...(note.ending.from !== undefined ? { from: note.ending.from } : {}),
767
+ spendUsd: note.ending.spendUsd,
768
+ ...(note.ending.handoff !== undefined ? { handoff: note.ending.handoff } : {}),
769
+ }
770
+ : {}),
771
+ },
614
772
  ...(state.pr !== undefined ? { pr: state.pr } : {}),
615
773
  // A review_pending ending names the child's own last push so the next
616
- // attempt's pre-check can start at the review round (the row's lastPush).
774
+ // attempt's pre-check can start at the review round (the row's lastPush)
775
+ // — an idled one the same, off the idle's `from` (record 0051).
617
776
  ...(note.ending.kind === "review_pending" && note.ending.headSha !== undefined
618
777
  ? { headSha: note.ending.headSha }
619
- : {}),
778
+ : note.ending.kind === "idle" && note.ending.why === "review_pending" && note.ending.from !== undefined
779
+ ? { headSha: note.ending.from }
780
+ : {}),
620
781
  ...(state.lastCodingRunId !== undefined ? { codingRunId: state.lastCodingRunId } : {}),
621
782
  // A continued ending is a segment's end, not the unit's: the bot
622
783
  // writes the renewal as a row keyed by the next segment's index
@@ -644,12 +805,9 @@ function blockedReport(unit: string, dep: string, depEnding: string): string {
644
805
  return `⛔ Blocked: ${unit} waits on ${dep}, which ended ${depEnding}${person ? " — a person's merge" : ""}. Re-issue the plan naming the remaining units once it is ${person ? "merged" : "resolved"}.`;
645
806
  }
646
807
 
647
- /** The plan: its units one at a time in dependency order, then the endings of the units it never reached. */
648
- async function walk(step: StepRunner, bot: CoordinatorBot, instanceId: string): Promise<PlanRunSummary> {
649
- const plan = readPlan(
650
- answerOf("plan", await step.do("plan", STEP_CONFIG, () => call(bot, "plan", { parentInstanceId: instanceId }))),
651
- );
652
- const graph: PlanGraph = {
808
+ /** The graph as one plan answer carries it: the instance's unit rows, in the plan's order. */
809
+ function graphOf(plan: PlanFacts, instanceId: string): PlanGraph {
810
+ return {
653
811
  planId: plan.planId ?? instanceId,
654
812
  units: plan.units.map((u) => ({
655
813
  id: u.unit,
@@ -659,6 +817,54 @@ async function walk(step: StepRunner, bot: CoordinatorBot, instanceId: string):
659
817
  dependsOn: u.dependsOn,
660
818
  })),
661
819
  };
820
+ }
821
+
822
+ /** The cursor over a freshly read selection (the orchestration-plane plan's
823
+ * re-read requirement): a surviving unit keeps its standing, a row appended
824
+ * mid-walk joins pending — walked after the current unit — and a row gone from the selection
825
+ * was merged by hand: it is walked as merged with nothing run and nothing
826
+ * told (`gone`), a dependency on it counted satisfied like any dependency
827
+ * outside the selection. `blocked` is derived state, so it is recomputed over
828
+ * the fresh graph rather than carried — a blocked unit whose failing
829
+ * dependency left the selection is in play again. */
830
+ function rereadCursor(cursor: PlanCursor, fresh: PlanGraph): { cursor: PlanCursor; gone: string[] } {
831
+ const gone = cursor.order.filter((id) => !fresh.units.some((u) => u.id === id));
832
+ const order = fresh.units.map((u) => u.id);
833
+ const status: Record<string, UnitStatus> = {};
834
+ for (const id of order) {
835
+ const prior = cursor.status[id];
836
+ status[id] = prior === undefined || prior === "blocked" ? "pending" : prior;
837
+ }
838
+ let changed = true;
839
+ while (changed) {
840
+ changed = false;
841
+ for (const u of fresh.units) {
842
+ if (status[u.id] !== "pending") continue;
843
+ if (u.dependsOn.some((d) => d in status && (status[d] === "failed" || status[d] === "blocked"))) {
844
+ status[u.id] = "blocked";
845
+ changed = true;
846
+ }
847
+ }
848
+ }
849
+ return { cursor: { order, status }, gone };
850
+ }
851
+
852
+ /** The plan: its units one at a time in dependency order, then the endings of the units it never reached. */
853
+ async function walk(step: StepRunner, bot: CoordinatorBot, instanceId: string): Promise<PlanRunSummary> {
854
+ // The selection is read at every unit boundary (the orchestration-plane plan): the first read opens
855
+ // the cursor, and each boundary's — its own durable step, `plan/<n>`, so a
856
+ // replay meets the same read — rebuilds it, so a later bot can append a unit
857
+ // to a live instance or drop one merged by hand without killing it.
858
+ let reads = 0;
859
+ const readSelection = async (): Promise<PlanFacts> => {
860
+ reads += 1;
861
+ const name = reads === 1 ? "plan" : `plan/${reads}`;
862
+ return readPlan(
863
+ answerOf("plan", await step.do(name, STEP_CONFIG, () => call(bot, "plan", { parentInstanceId: instanceId }))),
864
+ );
865
+ };
866
+ let plan = await readSelection();
867
+ let graph = graphOf(plan, instanceId);
662
868
  let cursor = openPlanCursor(graph);
663
869
  const endings: Record<string, string> = {};
664
870
  for (;;) {
@@ -685,7 +891,16 @@ async function walk(step: StepRunner, bot: CoordinatorBot, instanceId: string):
685
891
  endings[next] = ending.kind;
686
892
  // A unit is done for its dependents when the base carries its scope: the
687
893
  // runner's merge, or a scope that had already landed before the attempt.
894
+ // An `idle` ending settles the unit `failed` for now: nothing waits yet —
895
+ // the indexed wait and the wake land with the fifth unit of record 0051's
896
+ // plan — so the walk is unchanged until then and the flag ships at zero.
688
897
  cursor = settleUnit(graph, cursor, next, isSettledDone(ending.kind) ? "done" : "failed");
898
+ // The unit boundary's re-read (the orchestration-plane plan): the fresh rows are the selection now.
899
+ plan = await readSelection();
900
+ graph = graphOf(plan, instanceId);
901
+ const reread = rereadCursor(cursor, graph);
902
+ cursor = reread.cursor;
903
+ for (const id of reread.gone) endings[id] ??= "merged";
689
904
  }
690
905
  // Blocked units, in the plan's order: each told its own ending, so the rows
691
906
  // and the summary say why it never ran. Every blocked unit's ending is known
@@ -19,11 +19,61 @@
19
19
  * (docs/reference/specs/run-history.md item 39); this is the wait for the rest. */
20
20
  export const DRAIN_DEADLINE_MS = 15 * 60_000;
21
21
 
22
+ /** One run holding a drain: the registry-active run's id and why it holds.
23
+ * The drain is held by the RUN REGISTRY's live rows, not by the dispatcher's
24
+ * in-flight count — the two can disagree (a registry row whose dispatcher-side
25
+ * run is gone still holds the drain for its full deadline) — so the lines an
26
+ * operator reads name these rows, never the count from the other ledger. */
27
+ export interface HeldRun {
28
+ id: string;
29
+ why: string;
30
+ }
31
+
32
+ /** Why a registry-active run holds the drain: the handoff (run-history item 39)
33
+ * did not mark it for the next generation, so this process must wait for it. */
34
+ export const HELD_NOT_HANDED_OFF = "not handed off";
35
+
36
+ /** One `id (why)` per held run, comma-separated — shared by the drain's hold
37
+ * line here and the deploy CLI's still-draining line (src/deploy/liveGate.ts). */
38
+ export function heldRunsText(held: readonly HeldRun[]): string {
39
+ return held.map((r) => `${r.id} (${r.why})`).join(", ");
40
+ }
41
+
42
+ /** The drain's hold line (slack-channel.md item 8): what actually holds the
43
+ * exit, by run id and reason — printed once the handoff has settled who stays. */
44
+ export function drainHoldLine(held: readonly HeldRun[]): string {
45
+ return `[drain] holding for ${held.length} registry-active run(s): ${heldRunsText(held)}`;
46
+ }
47
+
22
48
  /** The handoff's own budget (plan D8): after every resumable run is marked
23
49
  * `handoff`, the drain waits this long for pending history writes and
24
50
  * reflections, then exits — the next generation takes the runs. */
25
51
  export const HANDOFF_BUDGET_MS = 6_000;
26
52
 
53
+ /**
54
+ * The drain's wait bound, re-read on every poll of the drain loop
55
+ * (`src/index.ts`). While a run still holds the drain (registry-active, not
56
+ * handed off) the bound is the full DRAIN_DEADLINE_MS from the signal — the
57
+ * deadline is the bound for a run that will not end, never the schedule. The
58
+ * moment the held count reaches zero the bound collapses to HANDOFF_BUDGET_MS
59
+ * from that instant (never past the full deadline) — the same grace a drain
60
+ * that started with nothing held gets — so pending reflections and history
61
+ * writes, including the steady stream a handed-off run still executing here
62
+ * produces, get seconds to settle, not the deploy's remaining minutes. Once
63
+ * collapsed the bound never grows back: the socket is closed, so no new run
64
+ * can arrive to hold the drain again.
65
+ */
66
+ export function createDrainDeadline(drainStartedAt: number): (now: number, runsHeld: number) => number {
67
+ const full = drainStartedAt + DRAIN_DEADLINE_MS;
68
+ let collapsed: number | undefined;
69
+ return (now, runsHeld) => {
70
+ if (collapsed !== undefined) return collapsed;
71
+ if (runsHeld > 0) return full;
72
+ collapsed = Math.min(full, now + HANDOFF_BUDGET_MS);
73
+ return collapsed;
74
+ };
75
+ }
76
+
27
77
  /** Time budgeted for the replacement container to boot and reach Socket Mode
28
78
  * `connected` (image pull + Node start + Bolt handshake), when the catch-up
29
79
  * scan runs. */
@@ -59,15 +59,25 @@ export function planEviction(active: MemoryRecord[], cap: number): MemoryRecord[
59
59
  .slice(0, excess);
60
60
  }
61
61
 
62
- /** What a store must do for one candidate. `dedup`: bump `target.useCount`,
63
- * insert nothing. `insert`: append `record` (already minted) and, when
64
- * `supersede` is set, flip that record to `superseded`. */
62
+ /** What a store must do for one candidate. `restate`: bump `target.useCount`,
63
+ * set its `lastUsedAt` to the store's now and its confidence to `confidence`
64
+ * when the plan carries one (the higher of the two sides), insert nothing.
65
+ * `dedup`: bump `target.useCount`, insert nothing. `insert`: append `record`
66
+ * (already minted) and, when `supersede` is set, flip that record to
67
+ * `superseded`. */
65
68
  export type WritePlan =
66
- { action: "dedup"; target: MemoryRecord } | { action: "insert"; record: MemoryRecord; supersede?: MemoryRecord };
69
+ | { action: "restate"; target: MemoryRecord; confidence?: number }
70
+ | { action: "dedup"; target: MemoryRecord }
71
+ | { action: "insert"; record: MemoryRecord; supersede?: MemoryRecord };
67
72
 
68
73
  /**
69
74
  * Decide how one candidate lands among a scope's ACTIVE records (docs/reference/specs/
70
75
  * memory.md §8):
76
+ * - **Restate target** = the active same-scope record whose id equals
77
+ * `cand.restates`: the fact re-teaches a shown record, so that record is
78
+ * refreshed — usage bumped, confidence the higher of the two — and nothing
79
+ * is inserted, whatever the candidate's wording. A missing or non-active
80
+ * target falls through to the rules below (today's dedup-or-insert).
71
81
  * - **Supersede target** = the active same-scope record whose id equals
72
82
  * `cand.supersedes`; an unknown/foreign id resolves to nothing (the new
73
83
  * record still lands, superseding nothing).
@@ -86,6 +96,17 @@ export function planWrite(
86
96
  cand: MemoryCandidate,
87
97
  mint: (cand: MemoryCandidate) => MemoryRecord,
88
98
  ): WritePlan {
99
+ if (cand.restates) {
100
+ const restated = active.find((r) => r.status === "active" && r.id === cand.restates);
101
+ if (restated) {
102
+ const confidences = [restated.confidence, cand.confidence].filter((c): c is number => c !== undefined);
103
+ return {
104
+ action: "restate",
105
+ target: restated,
106
+ ...(confidences.length > 0 ? { confidence: Math.max(...confidences) } : {}),
107
+ };
108
+ }
109
+ }
89
110
  const norm = normalizeText(cand.text);
90
111
  const target = cand.supersedes ? active.find((r) => r.status === "active" && r.id === cand.supersedes) : undefined;
91
112
  const dedupPool = cand.supersedes ? (target ? [target] : []) : active.filter((r) => r.status === "active");
@@ -94,6 +115,104 @@ export function planWrite(
94
115
  return { action: "insert", record: mint(cand), ...(target ? { supersede: target } : {}) };
95
116
  }
96
117
 
118
+ // ---- The write gate (docs/reference/specs/memory.md item 13) -----------------
119
+ // Status — the state of one pull request at one moment — and change
120
+ // descriptions — what one change did, which the spec and the diff already say —
121
+ // must never become facts: the prompt has asked for that since the write path
122
+ // shipped, and a rule a model is asked to follow is a rule it follows on
123
+ // average. The gate is therefore code, pure and total, and lives HERE in the
124
+ // shared engine so the bot's write path and the Worker's sweep run the exact
125
+ // same rule. A summary is never gated (episodic by definition).
126
+
127
+ /** A delivery predicate: words that say a change LANDED — one moment's news,
128
+ * never a lesson. Shared by the `delivery` marker and the reference marker's
129
+ * same-clause test. The auxiliary may sit up to two words from the participle
130
+ * ("is fixed and pushed"). */
131
+ const DELIVERY_PREDICATE =
132
+ /\b(?:(?:was|were|is|are)\s+(?:\w+\s+){0,2}?(?:pushed|merged|approved)|all\s+green|lgtm|ready\s+for\s+review|awaits?\s+ci|is\s+complete)\b/i;
133
+
134
+ /** A pull-request / issue / unit reference in subject position: the fact opens
135
+ * with the noun and a number, so the reference is what the fact is ABOUT. */
136
+ const REFERENCE_SUBJECT = /^\s*(?:pr|pull\s+request|issue|unit)s?\s*#?\d+\b/i;
137
+
138
+ /** A reference elsewhere (`#n`, `pull/n`, `issues/n`) rejects only beside a
139
+ * delivery predicate in the same clause — a citation inside a lesson ("the
140
+ * staged rebuild (issue 170) must budget the swap") is not status. */
141
+ const REFERENCE_IN_CLAUSE = /#\d+|\b(?:pull|issues)\/\d+/i;
142
+
143
+ /** A commit sha: 7–40 hex chars with at least one digit AND one letter, bounded
144
+ * by non-alphanumerics — the letter keeps a timestamp or a plain count out,
145
+ * the digit keeps "defaced" and "accede" out. An all-digit or all-letter sha
146
+ * is missed and accepted as the cost. */
147
+ const COMMIT_SHA = /(?<![a-z0-9])(?=[0-9a-f]*\d)(?=[0-9a-f]*[a-f])[0-9a-f]{7,40}(?![a-z0-9])/i;
148
+
149
+ /** A run id (`run` + 8 hex chars) or a branch by its path-like name. */
150
+ const RUN_OR_BRANCH = /\brun\s+[0-9a-f]{8}\b|\bbranch\s+\S*\/\S+/i;
151
+
152
+ /** `now` + a present-tense verb (one intervening word allowed): what a change
153
+ * "now does" is a change description, not a lesson. */
154
+ const NOW_VERB =
155
+ /\bnow\s+(?:\w+\s+)?(?:documents|preserves|displays|includes|carries|has|is|supports|shows|maps|controls|applies|uses)\b/i;
156
+
157
+ /** A passive change participle: "was implemented", "has been fixed", … */
158
+ const CHANGE_PARTICIPLE =
159
+ /\b(?:was|were|has\s+been|have\s+been)\s+(?:implemented|added|updated|fixed|documented|removed|renamed|introduced|extended)\b/i;
160
+
161
+ /** A plan-unit reference. */
162
+ const PLAN_UNIT = /\bunit\s+u?\d+\b/i;
163
+
164
+ /** "spec row" / "spec rows": what a spec row documents is the spec's to say. */
165
+ const SPEC_ROW = /\bspec\s+rows?\b/i;
166
+
167
+ /** The words that make a nearby number a test/check count… */
168
+ const COUNT_NOUNS = new Set(["test", "tests", "checks", "rows"]);
169
+ /** …and the outcome words that make that count status. */
170
+ const COUNT_OUTCOME = /\b(?:pass(?:es|ing|ed)?|green|fail(?:s|ing|ed)?)\b/i;
171
+
172
+ /** A clause: the unit within which the reference and count markers look for
173
+ * their second half. */
174
+ function clausesOf(text: string): string[] {
175
+ return text.split(/[;.!?\n—]+/);
176
+ }
177
+
178
+ /** A number within three words of a count noun, with an outcome word in the
179
+ * same clause — in either order ("all 12 tests passing", "green across 12
180
+ * checks"). */
181
+ function hasCountMarker(clause: string): boolean {
182
+ if (!COUNT_OUTCOME.test(clause)) return false;
183
+ const words = clause
184
+ .toLowerCase()
185
+ .split(/[^a-z0-9]+/)
186
+ .filter(Boolean);
187
+ return words.some(
188
+ (w, i) => /^\d+$/.test(w) && words.slice(Math.max(0, i - 3), i + 4).some((neighbour) => COUNT_NOUNS.has(neighbour)),
189
+ );
190
+ }
191
+
192
+ /** The status and change-description markers a fact text carries, by name —
193
+ * a table of named patterns, each firing independently. A non-empty answer
194
+ * rejects the fact (`parseReflection`) and, later, sweeps the stored row; the
195
+ * names are safe to log (never the text). Pure and total: never throws, and
196
+ * empty text carries no markers (it is rejected upstream as empty). */
197
+ export function rejectionMarkers(text: string): string[] {
198
+ const clauses = clausesOf(text);
199
+ const table: Array<[name: string, hit: boolean]> = [
200
+ [
201
+ "reference",
202
+ REFERENCE_SUBJECT.test(text) || clauses.some((c) => REFERENCE_IN_CLAUSE.test(c) && DELIVERY_PREDICATE.test(c)),
203
+ ],
204
+ ["sha", COMMIT_SHA.test(text)],
205
+ ["count", clauses.some(hasCountMarker)],
206
+ ["delivery", DELIVERY_PREDICATE.test(text)],
207
+ ["identifier", RUN_OR_BRANCH.test(text)],
208
+ ["now", NOW_VERB.test(text)],
209
+ ["changed", CHANGE_PARTICIPLE.test(text)],
210
+ ["unit", PLAN_UNIT.test(text)],
211
+ ["spec-row", SPEC_ROW.test(text)],
212
+ ];
213
+ return table.filter(([, hit]) => hit).map(([name]) => name);
214
+ }
215
+
97
216
  /** Build the record a store persists for a candidate. Ids are namespaced per
98
217
  * AGENTS.md invariant 4 (`mem:<scopeKey>:<seq>`); keywords default to the
99
218
  * text's tokens so keyword retrieval always has something to hit. */
@@ -20,10 +20,18 @@ export const DEFAULT_WEIGHTS: ScoreWeights = { keyword: 0.7, recency: 0.3 };
20
20
  * sweeper job (decay lives in the score). */
21
21
  export const RECENCY_TAU_MS = 7 * 24 * 60 * 60 * 1000;
22
22
 
23
- /** Default read budget: at most 8 records / ~800 tokens injected, regardless of
24
- * store size — context never bloats. */
25
- export const DEFAULT_MEMORY_LIMIT = 8;
26
- export const DEFAULT_MEMORY_TOKENS = 800;
23
+ /** Default read budget: at most 32 records / ~3000 tokens injected, regardless
24
+ * of store size — context never bloats. Raised from 8/800 with the repository
25
+ * window (docs/decisions/0061-…): the window's 24 facts plus the keyword hits
26
+ * must fit in one pool under one budget. */
27
+ export const DEFAULT_MEMORY_LIMIT = 32;
28
+ export const DEFAULT_MEMORY_TOKENS = 3000;
29
+
30
+ /** Default size of the repository window (`memory.repoWindow`): the newest
31
+ * facts of the run's bound repository, rendered ahead of the keyword hits.
32
+ * `0` disables the window. At ~320 chars per repository fact, 24 render in
33
+ * about 2000 tokens — the rest of the budget is the hits'. */
34
+ export const DEFAULT_REPO_WINDOW = 24;
27
35
 
28
36
  export interface MemoryBudget {
29
37
  maxRecords: number;