@coreplane/switchboard 1.241.0 → 1.242.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 (135) hide show
  1. package/dist/assets/config/config.example.yaml +25 -12
  2. package/dist/assets/deploy/cloudflare-resident/worker.ts +197 -15
  3. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +38 -5
  4. package/dist/assets/package-lock.json +3 -3
  5. package/dist/assets/package.json +1 -1
  6. package/dist/assets/project.json +2 -2
  7. package/dist/assets/source.json +3 -3
  8. package/dist/assets/src/agents/registry.ts +31 -14
  9. package/dist/assets/src/core/authz/actor.ts +12 -9
  10. package/dist/assets/src/core/authz/authorize.ts +17 -4
  11. package/dist/assets/src/core/authz/types.ts +12 -0
  12. package/dist/assets/src/core/coordinator/contract.ts +3 -1
  13. package/dist/assets/src/core/costs.ts +12 -4
  14. package/dist/assets/src/core/harness/scope.ts +21 -0
  15. package/dist/assets/src/core/runEvents.ts +20 -1
  16. package/dist/assets/src/core/runLedger/types.ts +2 -0
  17. package/dist/assets/src/core/runRecord.ts +4 -0
  18. package/dist/assets/src/core/ship/contract.ts +4 -2
  19. package/dist/assets/src/execution/residentAutoRebuild.ts +4 -2
  20. package/dist/assets/src/execution/residentHead.ts +23 -10
  21. package/dist/assets/src/execution/residentInfraStreak.ts +89 -0
  22. package/dist/assets/src/execution/residentRefresh.ts +15 -1
  23. package/dist/assets/src/execution/residentSteps.ts +1 -0
  24. package/dist/assets/src/execution/sandboxErrors.ts +6 -0
  25. package/dist/assets/src/execution/seedPlan.ts +17 -0
  26. package/dist/assets/web/dist/.vite/manifest.json +419 -425
  27. package/dist/assets/web/dist/assets/{AppShell-DEy5jRuS.js → AppShell-IDMGG6yi.js} +1 -1
  28. package/dist/assets/web/dist/assets/{CostsPage-BuHD-Bv7.js → CostsPage-CtmKhhOF.js} +1 -1
  29. package/dist/assets/web/dist/assets/{DeliveryPage-BGpPr5xr.js → DeliveryPage-n1tz5I_v.js} +1 -1
  30. package/dist/assets/web/dist/assets/HomePage-QtH1EwYF.js +2 -0
  31. package/dist/assets/web/dist/assets/{NotFoundPage-DtmQM2gD.js → NotFoundPage-C97EhJcQ.js} +1 -1
  32. package/dist/assets/web/dist/assets/{PendingTurnRow-CrEAWx5b.js → PendingTurnRow-BZA_vQt3.js} +1 -1
  33. package/dist/assets/web/dist/assets/{ResidentDetailPage-C-48fxat.js → ResidentDetailPage-DTMBgnIW.js} +1 -1
  34. package/dist/assets/web/dist/assets/{ResidentsIndexPage-CBBIjPOa.js → ResidentsIndexPage-CaDvXhzJ.js} +1 -1
  35. package/dist/assets/web/dist/assets/RunFoldRow-mgyLW0oV.js +1 -0
  36. package/dist/assets/web/dist/assets/RunRoutePage-DypJYMQa.js +6 -0
  37. package/dist/assets/web/dist/assets/RunsIndexPage-DYraPoWD.js +1 -0
  38. package/dist/assets/web/dist/assets/{RunsTabs-CtNGNnhN.js → RunsTabs-BUfdk0lH.js} +1 -1
  39. package/dist/assets/web/dist/assets/{ScheduledPage-Ci1Ygqnc.js → ScheduledPage-DXD2gLJk.js} +1 -1
  40. package/dist/assets/web/dist/assets/SettingsPage-BIGio8Y0.js +1 -0
  41. package/dist/assets/web/dist/assets/{StatusDot-DPWE1JmO.js → StatusDot-ELoXHlFt.js} +1 -1
  42. package/dist/assets/web/dist/assets/{Tooltip-DKhSRH9t.js → Tooltip-BoeFwYP2.js} +1 -1
  43. package/dist/assets/web/dist/assets/UnitRoutePage-jhCrWW3i.js +1 -0
  44. package/dist/assets/web/dist/assets/{angular-html-DgSK1qvr.js → angular-html-oBNfPJR0.js} +1 -1
  45. package/dist/assets/web/dist/assets/{angular-ts-D3gNdiSG.js → angular-ts-BvNwsyWA.js} +1 -1
  46. package/dist/assets/web/dist/assets/{apl-CkHCYM8I.js → apl-CNUdRlYf.js} +1 -1
  47. package/dist/assets/web/dist/assets/{astro-DLm45axt.js → astro-Zb0NriSe.js} +1 -1
  48. package/dist/assets/web/dist/assets/{blade-BKa-VE-c.js → blade-qPRVheqq.js} +1 -1
  49. package/dist/assets/web/dist/assets/{c-C8NCNWai.js → c-D8Awx4YO.js} +1 -1
  50. package/dist/assets/web/dist/assets/{chapel-DqDQ7IKH.js → chapel-Bt72Mhsx.js} +1 -1
  51. package/dist/assets/web/dist/assets/{cobol-j4vCD6AJ.js → cobol-BOBacexg.js} +1 -1
  52. package/dist/assets/web/dist/assets/{coffee-C6oDVH-0.js → coffee-E4u0liHW.js} +1 -1
  53. package/dist/assets/web/dist/assets/{cpp-Bd3A1Rtc.js → cpp-q2sLNlul.js} +1 -1
  54. package/dist/assets/web/dist/assets/{crystal-CUOgOw0d.js → crystal-DyWqUnlb.js} +1 -1
  55. package/dist/assets/web/dist/assets/{css-RYljyv7G.js → css-CQY0hFsD.js} +1 -1
  56. package/dist/assets/web/dist/assets/{dist-BnJXKHCY.js → dist-BU5UivXC.js} +2 -2
  57. package/dist/assets/web/dist/assets/durationTone-BobbycC-.js +1 -0
  58. package/dist/assets/web/dist/assets/{edge-BJhNdQDc.js → edge-C1MwhJkX.js} +1 -1
  59. package/dist/assets/web/dist/assets/{elixir-g1AWOAfT.js → elixir-Bb3YbHfn.js} +1 -1
  60. package/dist/assets/web/dist/assets/{elm-CER5e4Dz.js → elm-DAN9IGQw.js} +1 -1
  61. package/dist/assets/web/dist/assets/{erb-D5aOmMQf.js → erb-BEB8Xlsj.js} +1 -1
  62. package/dist/assets/web/dist/assets/{git-rebase-D6rfV8jp.js → git-rebase-tqpjRfxO.js} +1 -1
  63. package/dist/assets/web/dist/assets/{glimmer-js-BvDH3-mC.js → glimmer-js-Ccbo65zR.js} +1 -1
  64. package/dist/assets/web/dist/assets/{glimmer-ts-DChtNs9V.js → glimmer-ts-B6WBVMpE.js} +1 -1
  65. package/dist/assets/web/dist/assets/{glsl-BHlsWrxf.js → glsl-tRec3Fcu.js} +1 -1
  66. package/dist/assets/web/dist/assets/{graphql-BsaBHb53.js → graphql-P8kbxT4F.js} +1 -1
  67. package/dist/assets/web/dist/assets/{hack-0lbDosCd.js → hack-H9Zhkagy.js} +1 -1
  68. package/dist/assets/web/dist/assets/{haml-BKtco2vr.js → haml-DtEnpn7Z.js} +1 -1
  69. package/dist/assets/web/dist/assets/{handlebars-BE_cj04z.js → handlebars-DkgPfoAz.js} +1 -1
  70. package/dist/assets/web/dist/assets/{html-BNcr8EVd.js → html-D30RXpIs.js} +1 -1
  71. package/dist/assets/web/dist/assets/{html-derivative-BsJt2Kei.js → html-derivative-DwozLrEx.js} +1 -1
  72. package/dist/assets/web/dist/assets/{http-BR5P8ER_.js → http-DXuzBAPm.js} +1 -1
  73. package/dist/assets/web/dist/assets/{hurl-CtHalpWS.js → hurl-d1UUJIt_.js} +1 -1
  74. package/dist/assets/web/dist/assets/{java-BXVdhN11.js → java-DL0gWf34.js} +1 -1
  75. package/dist/assets/web/dist/assets/{javascript-CyoShgNQ.js → javascript-Cl7vavnS.js} +1 -1
  76. package/dist/assets/web/dist/assets/{jinja-BQYZzHex.js → jinja-CfQOWMX9.js} +1 -1
  77. package/dist/assets/web/dist/assets/{jison-DqrUa1Eq.js → jison-KdYirlqm.js} +1 -1
  78. package/dist/assets/web/dist/assets/{json-CQiTF9Jj.js → json-C9cDQ-Qj.js} +1 -1
  79. package/dist/assets/web/dist/assets/{jsx-Y16MkCYQ.js → jsx-CRx5NItd.js} +1 -1
  80. package/dist/assets/web/dist/assets/{julia-zn16YONe.js → julia-CmsQsZQl.js} +1 -1
  81. package/dist/assets/web/dist/assets/{just-DcVeN_MN.js → just-CsM3Q8TE.js} +1 -1
  82. package/dist/assets/web/dist/assets/{latex-BNNoF-WP.js → latex-BXCh5YRX.js} +1 -1
  83. package/dist/assets/web/dist/assets/{liquid-DAi6qHzn.js → liquid-BF2vwK8p.js} +1 -1
  84. package/dist/assets/web/dist/assets/{lua-BswS0axw.js → lua-DcMBATrl.js} +1 -1
  85. package/dist/assets/web/dist/assets/main-B2fX10aW.css +1 -0
  86. package/dist/assets/web/dist/assets/{main-B3is4sk6.js → main-D4EA1g6n.js} +2 -2
  87. package/dist/assets/web/dist/assets/{marko-C-4hkmQZ.js → marko-88MndKvG.js} +1 -1
  88. package/dist/assets/web/dist/assets/{mdc-0ZaHUkS7.js → mdc-DJ4kVAd8.js} +1 -1
  89. package/dist/assets/web/dist/assets/{nginx-DBDg5tOR.js → nginx-CP6mRgtV.js} +1 -1
  90. package/dist/assets/web/dist/assets/{nim-BVMz49Yw.js → nim-QQfj3fpF.js} +1 -1
  91. package/dist/assets/web/dist/assets/{org-1hO_K6ya.js → org-Bke3eBzc.js} +1 -1
  92. package/dist/assets/web/dist/assets/{perl-CVRQLvVk.js → perl-Bkh0N7Kz.js} +1 -1
  93. package/dist/assets/web/dist/assets/{php-K9nCrB9n.js → php-Dnv1Piya.js} +1 -1
  94. package/dist/assets/web/dist/assets/{pug-CTxOcmO3.js → pug-D6peFFI7.js} +1 -1
  95. package/dist/assets/web/dist/assets/{qml-CmCkfG5q.js → qml-D8PEs-C-.js} +1 -1
  96. package/dist/assets/web/dist/assets/{r-pP47Xn1X.js → r-B1EL9b_j.js} +1 -1
  97. package/dist/assets/web/dist/assets/{razor-BpW6r3tC.js → razor-BsMTIh2b.js} +1 -1
  98. package/dist/assets/web/dist/assets/{regexp-CINgdY4N.js → regexp-HhvC8spD.js} +1 -1
  99. package/dist/assets/web/dist/assets/{rst-BZIUEq2Q.js → rst-D5paAxpg.js} +1 -1
  100. package/dist/assets/web/dist/assets/{ruby-nzAOxz6r.js → ruby-BrQwhLrl.js} +1 -1
  101. package/dist/assets/web/dist/assets/{sas-QIS1bFth.js → sas-afot2B1o.js} +1 -1
  102. package/dist/assets/web/dist/assets/{scss-BuhBBVNt.js → scss-Efm-mwuG.js} +1 -1
  103. package/dist/assets/web/dist/assets/{shellscript-CQc1vXbk.js → shellscript-BK0Vv5fT.js} +1 -1
  104. package/dist/assets/web/dist/assets/{shellsession-CoubCAUv.js → shellsession-BGqlMC7N.js} +1 -1
  105. package/dist/assets/web/dist/assets/{soy-CPRzlder.js → soy-M0b4UwGM.js} +1 -1
  106. package/dist/assets/web/dist/assets/{sql-W9krb8-9.js → sql-sxe6IE9j.js} +1 -1
  107. package/dist/assets/web/dist/assets/sseReplay-g7ml86LM.js +9 -0
  108. package/dist/assets/web/dist/assets/{stata-CuISJEC0.js → stata-nPF_ddLP.js} +1 -1
  109. package/dist/assets/web/dist/assets/{surrealql-CSet7584.js → surrealql-D_GrC6u7.js} +1 -1
  110. package/dist/assets/web/dist/assets/{svelte-ChXTSYwK.js → svelte-DwL1AtNP.js} +1 -1
  111. package/dist/assets/web/dist/assets/{templ-Bm55v62k.js → templ-7s7LTDkc.js} +1 -1
  112. package/dist/assets/web/dist/assets/{tex-CICMX8Gj.js → tex-Bx-5fMxe.js} +1 -1
  113. package/dist/assets/web/dist/assets/{ts-tags-CA1UzWyB.js → ts-tags-DUMJnke_.js} +1 -1
  114. package/dist/assets/web/dist/assets/{tsx-Dy04HNbv.js → tsx-BN8biPDe.js} +1 -1
  115. package/dist/assets/web/dist/assets/{twig-BBHsVnVD.js → twig-8nIu84TN.js} +1 -1
  116. package/dist/assets/web/dist/assets/{typescript-Dvc-wVBT.js → typescript-BooSPq_S.js} +1 -1
  117. package/dist/assets/web/dist/assets/{typst-Dvlqx3_q.js → typst-CTBiBsem.js} +1 -1
  118. package/dist/assets/web/dist/assets/{vue-zWi1MNU2.js → vue-C6Ft4Lea.js} +1 -1
  119. package/dist/assets/web/dist/assets/{vue-html-D4YxT3An.js → vue-html-Ba36dD5D.js} +1 -1
  120. package/dist/assets/web/dist/assets/{vue-vine-0YE7uQlH.js → vue-vine-NkFexVo2.js} +1 -1
  121. package/dist/assets/web/dist/assets/{xml-gm-iksZB.js → xml-C_THnHXZ.js} +1 -1
  122. package/dist/assets/web/dist/assets/{xsl-D_W5BcrY.js → xsl-D8G5xqjY.js} +1 -1
  123. package/dist/assets/web/dist/assets/{yaml-CHmZ21wZ.js → yaml-jAMIJzge.js} +1 -1
  124. package/dist/cli.js +1390 -484
  125. package/package.json +1 -1
  126. package/dist/assets/web/dist/assets/HomePage-dKBvWh-E.js +0 -2
  127. package/dist/assets/web/dist/assets/RunFoldRow-B6vxjcBI.js +0 -1
  128. package/dist/assets/web/dist/assets/RunRoutePage-CQjd3Cz-.js +0 -6
  129. package/dist/assets/web/dist/assets/RunsIndexPage-Cy9G6D0p.js +0 -1
  130. package/dist/assets/web/dist/assets/SettingsPage-DxmJvAmT.js +0 -1
  131. package/dist/assets/web/dist/assets/SlackMark-VDNs7Vjh.js +0 -1
  132. package/dist/assets/web/dist/assets/UnitRoutePage-DEN5bSQf.js +0 -1
  133. package/dist/assets/web/dist/assets/durationTone-DXG-3R7_.js +0 -1
  134. package/dist/assets/web/dist/assets/main-DP_zSemY.css +0 -1
  135. package/dist/assets/web/dist/assets/sseReplay-yji8a1wM.js +0 -9
@@ -223,6 +223,14 @@ export const FENCED_CONTENT_RULE =
223
223
  const NOTEPAD = `YOUR NOTES AND YOUR REACH BACK. This thread's conversation outlives your context window and this run: every turn — yours, the person's, every tool call and its output, from this run and the runs before it in this thread — is kept in a log you can search with the \`recall\` tool (words → the matching turns with their numbers; a turn number → that turn whole). When something you need is no longer in front of you, recall it instead of redoing the work or guessing.
224
224
  Keep notes with the \`notes\` tool: one short document, replaced whole each time, at most 8 KiB — decisions and their reasons, the names of things you found (files, tests, commits, the head your tests were green at), what is not yet proven. They are the one thing sure to survive a compaction and to reach the next run in this thread: they ride your system prompt at its start and come back to you right after a compaction. A person reads them too, on the run's page, so write them as a document and never as one paragraph: Markdown, a \`##\` heading per section — \`Done\`, \`In progress\`, \`Next\`, \`Facts\` (names, ids, heads, the reasons behind decisions), leaving out a section with nothing in it — one bullet per item, one line per bullet, no prose walls. Write them when you decide something worth keeping, not only at the end.`;
225
225
 
226
+ // Every coding prompt carries this verbatim (docs/reference/specs/agent-coding.md
227
+ // item 13): the order of checks and the push. Three plan children died at their
228
+ // budget in one evening with finished work unpushed because each ran the
229
+ // project's most expensive checks first; the rule is the runner's to hold, not
230
+ // a line every requester remembers to paste. Stack-agnostic on purpose — the
231
+ // classes are by duration, the project's own scripts and CI say which is which.
232
+ export const CHECKS_BY_COST = `CHECKS BY COST — push before the expensive ones. Every check you might run has a cost class: seconds (a formatter or a linter on the files you touched, one test file, a docs, link or spec check, the typecheck of one package) or minutes (the whole test suite, a build, a dependency install, an end-to-end or full verification). Know a command's class before you run it — from the project's own scripts and CI configuration, from how long it took last time, or by the class above when you have nothing better. Prove each change with the cheapest check that can prove it, matched to the change's scope: a documentation change gets the documentation checks, one module gets its own tests, a shared type gets the typecheck. As soon as the change exists and those checks pass, commit and push — the pushed branch is the deliverable, and an unpushed tree does not survive the run's end. Only then run the expensive checks, once, and fix forward with further commits and pushes. Never start an operation whose expected duration does not fit the time you have left minus what a commit, a push and the description need: push what there is and say plainly what is unverified instead. The description's validation names exactly what ran; what did not run is CI's to gate, and you say so.`;
233
+
226
234
  const CODING_SYSTEM = `You are Switchboard's coding agent, operating from a Slack request.
227
235
 
228
236
  You work inside a dedicated workspace directory with bash, read_file, and write_file tools. ${SANDBOX_TOOLCHAIN}
@@ -237,10 +245,13 @@ Workflow for shipping a PR:
237
245
  1. Clone the repo into the workspace if it's not already there (use gh or git; both are authenticated on this host). Orient with a few BATCHED commands (tree + the relevant files in one call), not file-by-file exploration.
238
246
  2. Create a branch with a descriptive name.
239
247
  3. Implement the change. Match the surrounding code's style and conventions.
240
- 4. Run the project's tests/linters if they exist and are quick enough to run.
241
- 5. Commit with a clear message and push the branch.
242
- 6. Call the submit_pr_description tool with the typed description object (content contract below) — every time, bringing forward the context you gained while implementing. Switchboard renders the PR body from your object at the pushed head and opens (or updates) the pull request itself: do NOT open a PR yourself, with \`gh\` or any API call.
243
- 7. Report back with a short summary of what you did, including anything you skipped or couldn't verify; Switchboard adds the PR link when it opens the PR.
248
+ 4. Prove the change with the cheapest checks that can (CHECKS BY COST below): the linter and the tests nearest the files you touched, the documentation checks for a documentation change.
249
+ 5. Commit with a clear message and push the branch — before any full suite, build or full verification.
250
+ 6. Then, if the budget allows, run the project's expensive checks once and fix forward with further commits and pushes.
251
+ 7. Call the submit_pr_description tool with the typed description object (content contract below) — every time, bringing forward the context you gained while implementing. Switchboard renders the PR body from your object at the pushed head and opens (or updates) the pull request itself: do NOT open a PR yourself, with \`gh\` or any API call.
252
+ 8. Report back with a short summary of what you did, including anything you skipped or couldn't verify; Switchboard adds the PR link when it opens the PR.
253
+
254
+ ${CHECKS_BY_COST}
244
255
 
245
256
  ${NEVER_MERGE}
246
257
 
@@ -279,11 +290,14 @@ Environment notes:
279
290
  Workflow for shipping a change:
280
291
  1. Create a branch with a descriptive name off the bound branch.
281
292
  2. Implement the change. Match the surrounding code's style and conventions.
282
- 3. Run the project's tests/linters if they exist and are quick enough to run (dependencies are already present).
283
- 4. Commit with a clear message and push the branch with \`git push -u origin <branch>\`.
284
- 5. Call the \`diff_digest\` tool to get a distilled summary of your change — per-file churn, totals, and risky-file flags. It is a distilled summary, not the raw diff: use it to shape the description you submit next — which files the Tour must walk, what belongs in risks.
285
- 6. Call the submit_pr_description tool with the typed description object (content contract below) — every time. Switchboard renders the PR body from your object at the pushed head and opens (or updates) the pull request itself: do NOT open a PR yourself, with any API call.
286
- 7. Report back with a short summary of what you did, including anything you skipped or couldn't verify; Switchboard adds the PR link when it opens the PR.
293
+ 3. Prove the change with the cheapest checks that can (CHECKS BY COST below): the linter and the tests nearest the files you touched, the documentation checks for a documentation change (dependencies are already present).
294
+ 4. Commit with a clear message and push the branch with \`git push -u origin <branch>\` — before any full suite, build or full verification.
295
+ 5. Then, if the budget allows, run the project's expensive checks once and fix forward with further commits and pushes.
296
+ 6. Call the \`diff_digest\` tool to get a distilled summary of your change — per-file churn, totals, and risky-file flags. It is a distilled summary, not the raw diff: use it to shape the description you submit next — which files the Tour must walk, what belongs in risks.
297
+ 7. Call the submit_pr_description tool with the typed description object (content contract below) — every time. Switchboard renders the PR body from your object at the pushed head and opens (or updates) the pull request itself: do NOT open a PR yourself, with any API call.
298
+ 8. Report back with a short summary of what you did, including anything you skipped or couldn't verify; Switchboard adds the PR link when it opens the PR.
299
+
300
+ ${CHECKS_BY_COST}
287
301
 
288
302
  ${NEVER_MERGE}
289
303
 
@@ -318,11 +332,14 @@ THE REPOSITORY IS ALREADY CLONED at \`/workspace/checkout\` — seeded from the
318
332
  Workflow for shipping a change:
319
333
  1. Create a branch with a descriptive name off the current branch.
320
334
  2. Implement the change. Match the surrounding code's style and conventions.
321
- 3. Run the project's tests/linters if they exist and are quick enough to run (dependencies are already present).
322
- 4. Commit with a clear message and push the branch with \`git push -u origin <branch>\`.
323
- 5. Call the \`diff_digest\` tool to get a distilled summary of your change — per-file churn, totals, and risky-file flags. It is a distilled summary, not the raw diff: use it to shape the description you submit next — which files the Tour must walk, what belongs in risks.
324
- 6. Call the submit_pr_description tool with the typed description object (content contract below) — every time. Switchboard renders the PR body from your object at the pushed head and opens (or updates) the pull request itself: do NOT open a PR yourself, with \`gh\` or any API call.
325
- 7. Report back with a short summary of what you did, including anything you skipped or couldn't verify; Switchboard adds the PR link when it opens the PR.
335
+ 3. Prove the change with the cheapest checks that can (CHECKS BY COST below): the linter and the tests nearest the files you touched, the documentation checks for a documentation change (dependencies are already present).
336
+ 4. Commit with a clear message and push the branch with \`git push -u origin <branch>\` — before any full suite, build or full verification.
337
+ 5. Then, if the budget allows, run the project's expensive checks once and fix forward with further commits and pushes.
338
+ 6. Call the \`diff_digest\` tool to get a distilled summary of your change — per-file churn, totals, and risky-file flags. It is a distilled summary, not the raw diff: use it to shape the description you submit next — which files the Tour must walk, what belongs in risks.
339
+ 7. Call the submit_pr_description tool with the typed description object (content contract below) — every time. Switchboard renders the PR body from your object at the pushed head and opens (or updates) the pull request itself: do NOT open a PR yourself, with \`gh\` or any API call.
340
+ 8. Report back with a short summary of what you did, including anything you skipped or couldn't verify; Switchboard adds the PR link when it opens the PR.
341
+
342
+ ${CHECKS_BY_COST}
326
343
 
327
344
  ${NEVER_MERGE}
328
345
 
@@ -83,15 +83,18 @@ interface BoundFields {
83
83
  userName?: string;
84
84
  }
85
85
 
86
- /** The actor id whose grants govern a chat message: the credential that
87
- * authenticated it when it was bound to a person (`authenticatedAs`), else
88
- * the sender. Every `canRunAgent` / `canUseRepo` / `canManageRepos` /
89
- * `canEditChannelConfig` question in the dispatch path asks about THIS id,
90
- * never `msg.userId`: naming the person on a run must not lend the run the
91
- * person's grants (authorization.md item 15). A relayed message (`postedBy`)
92
- * is out of scope here — its gates are item 14's. */
93
- export function grantsSubject(msg: { userId: string; authenticatedAs?: string }): string {
94
- return msg.authenticatedAs ?? msg.userId;
86
+ /** The actor every gate in the dispatch path decides on: `resolveChatActor`
87
+ * over the message, with config's grants lookup. Every `canRunAgent` /
88
+ * `canUseRepo` / `canManageRepos` / `canEditChannelConfig` question asks
89
+ * about THIS actor, never `msg.userId`: a relayed message (`postedBy`,
90
+ * authorization.md item 14) decides on the app ∩ the person, a bound
91
+ * credential (`authenticatedAs`, item 15) on the credential alone — naming
92
+ * a person on a run never lends the run the person's grants. */
93
+ export function chatActorOf(
94
+ config: { grantsFor: GrantsLookup },
95
+ msg: { userId: string; channelId: string; threadKey: string; postedBy?: string } & BoundFields,
96
+ ): Actor {
97
+ return resolveChatActor(msg, (id) => config.grantsFor(id));
95
98
  }
96
99
 
97
100
  const CHAT_SURFACES: Readonly<Record<string, ActorSurface>> = {
@@ -63,6 +63,14 @@ export function principalOf(actor: Actor): Actor {
63
63
  return current;
64
64
  }
65
65
 
66
+ /** The channels the decision's person is in: the root principal's
67
+ * `memberOf`, as `selfIdsOf` reads its `self` — a fact from the channel
68
+ * directory, never a grant; absent → the empty set. */
69
+ export function memberChannelsOf(actor: Actor): ReadonlySet<string> {
70
+ return principalOf(actor).memberOf ?? EMPTY_SET;
71
+ }
72
+ const EMPTY_SET: ReadonlySet<string> = new Set();
73
+
66
74
  /** The ids that mean "me" for a decision (record 0042): the root principal's
67
75
  * `self` when it carries one — its own id and the person a dashboard session
68
76
  * is linked to — else its id alone. Never read by a grant check. */
@@ -117,6 +125,7 @@ export function evaluateCondition(
117
125
  grants: Grants,
118
126
  selfIds: readonly string[],
119
127
  attributes: ResourceAttributes,
128
+ memberOf: ReadonlySet<string> = EMPTY_SET,
120
129
  ): boolean {
121
130
  switch (condition.kind) {
122
131
  case "has-grant": {
@@ -124,11 +133,13 @@ export function evaluateCondition(
124
133
  return grant !== undefined && hasAction(grants.actions, grant);
125
134
  }
126
135
  case "member-of":
127
- // Granted the channel, or the channel is public (a run's stamped
136
+ // Granted the channel, in the channel (the directory's fact on the
137
+ // actor), or the channel is public (a run's stamped
128
138
  // visibility). `unknown` — no stamp, a directory failure — is never
129
139
  // public: fail-closed.
130
140
  return (
131
- (attributes.channelId !== undefined && holds(grants.channels, attributes.channelId)) ||
141
+ (attributes.channelId !== undefined &&
142
+ (holds(grants.channels, attributes.channelId) || memberOf.has(attributes.channelId))) ||
132
143
  attributes.channelVisibility === "public"
133
144
  );
134
145
  case "is-self":
@@ -154,7 +165,8 @@ export function evaluateRule(rule: Rule, actor: Actor, resource: Resource): bool
154
165
  if (rule.originVisibility && !rule.originVisibility.includes(attributes.visibility)) return false;
155
166
  const grants = effectiveGrants(actor);
156
167
  const selfIds = selfIdsOf(actor);
157
- return rule.when.every((condition) => evaluateCondition(condition, grants, selfIds, attributes));
168
+ const memberOf = memberChannelsOf(actor);
169
+ return rule.when.every((condition) => evaluateCondition(condition, grants, selfIds, attributes, memberOf));
158
170
  }
159
171
 
160
172
  /** `authorize` over an explicit (validated) table. Tests use it to drive
@@ -173,9 +185,10 @@ export function authorizeWith(rules: readonly Rule[], actor: Actor, action: Acti
173
185
  if (forOrigin.length === 0) return deny("origin-visibility");
174
186
  const grants = effectiveGrants(actor);
175
187
  const selfIds = selfIdsOf(actor);
188
+ const memberOf = memberChannelsOf(actor);
176
189
  let reason: DenyReason | undefined;
177
190
  for (const rule of forOrigin) {
178
- const failed = rule.when.find((condition) => !evaluateCondition(condition, grants, selfIds, attributes));
191
+ const failed = rule.when.find((condition) => !evaluateCondition(condition, grants, selfIds, attributes, memberOf));
179
192
  if (!failed) return ALLOW;
180
193
  reason ??= FAILURE_REASON[failed.kind];
181
194
  }
@@ -57,6 +57,15 @@ export interface Actor {
57
57
  readonly self?: readonly string[];
58
58
  /** The linked person, for display and the audit line (`asUser`); absent when unlinked. */
59
59
  readonly asUser?: { readonly id: string; readonly name?: string };
60
+ /**
61
+ * The channels the platform says the actor's person is in: the
62
+ * channel directory's `channelsOf`, resolved once with the actor — a fact
63
+ * about the person, never a grant from config. `member-of` reads it beside
64
+ * `grants.channels`, so a private channel's runs and config open to the
65
+ * people in it. Absent (no directory, a lookup failure, an unlinked session)
66
+ * → nothing: fail-closed, exactly today's behaviour.
67
+ */
68
+ readonly memberOf?: ReadonlySet<string>;
60
69
  }
61
70
 
62
71
  /** `<group>:<read|write|exec>` plus the non-command actions. A plain
@@ -184,4 +193,7 @@ export type Predicate =
184
193
  export interface ChannelDirectory {
185
194
  info(channelId: string): Promise<{ visibility: ChannelVisibility }>;
186
195
  isMember(actorId: string, channelId: string): Promise<boolean | "unknown">;
196
+ /** Every channel the actor is in, platform-namespaced — what the resolver puts on
197
+ * `Actor.memberOf`; `unknown` when the adapter cannot say (fail-closed). */
198
+ channelsOf(actorId: string): Promise<ReadonlySet<string> | "unknown">;
187
199
  }
@@ -150,6 +150,8 @@ export interface CoordinatorInstance {
150
150
  /** The bound credential behind the person (authorization.md item 15), when
151
151
  * there was one: every child is authorized under ITS grants, as the request was. */
152
152
  authenticatedAs?: string;
153
+ /** The app that relayed the request for the person (authorization.md item 14), when one did: every child is authorized under app ∩ person, as the request was. */
154
+ postedBy?: string;
153
155
  channelId: string;
154
156
  channelName?: string;
155
157
  /** The requesting thread: where the card lives and where a generated plan's
@@ -260,7 +262,7 @@ export function isCoordinatorInstance(v: unknown): v is CoordinatorInstance {
260
262
  if (r.kind !== "ship") return false;
261
263
  if (!isText(r.userId) || !isText(r.channelId) || !isText(r.threadKey)) return false;
262
264
  if (!isOptionalText(r.userName) || !isOptionalText(r.channelName) || !isOptionalText(r.sourceUrl)) return false;
263
- if (!isOptionalText(r.authenticatedAs)) return false;
265
+ if (!isOptionalText(r.authenticatedAs) || !isOptionalText(r.postedBy)) return false;
264
266
  if (typeof r.repo !== "string" || !REPO_SLUG.test(r.repo)) return false;
265
267
  if (!isText(r.branch) || !isOptionalText(r.base)) return false;
266
268
  if (!isFinite(r.createdAt)) return false;
@@ -72,6 +72,9 @@ export interface CostsConfig {
72
72
  snapshot: {
73
73
  /** Hours between two reads of the billing sources. */
74
74
  everyHours: number;
75
+ /** The platform-namespaced channel (`slack:C…`) told when takes keep failing and when they
76
+ * land again (`ALERT_AFTER_FAILURES` in a row); absent → the status alone says so. */
77
+ alertChannel?: string;
75
78
  };
76
79
  }
77
80
 
@@ -82,13 +85,15 @@ const DEFAULT_ANTHROPIC_ADMIN_ENV = "ANTHROPIC_ADMIN_KEY";
82
85
  * default — both sources bucket by UTC day, and the page is read about as often. */
83
86
  export const COSTS_SNAPSHOT_EVERY_HOURS = Object.freeze({ default: 24, min: 1, max: 168 });
84
87
 
85
- /** `costs.snapshot`: absent → the default interval; a value outside the bounds or not a whole number throws by name. */
88
+ /** `costs.snapshot`: absent → the default interval and no alert channel; a value outside the
89
+ * bounds or not a whole number throws by name; `alertChannel`, when given, is a platform-namespaced id. */
86
90
  function snapshotConfig(raw: unknown): CostsConfig["snapshot"] {
87
91
  if (raw === undefined) return { everyHours: COSTS_SNAPSHOT_EVERY_HOURS.default };
88
92
  if (typeof raw !== "object" || raw === null || Array.isArray(raw))
89
93
  throw new Error("costs.snapshot must be a mapping");
90
- const every = (raw as Record<string, unknown>).everyHours;
91
- if (every === undefined) return { everyHours: COSTS_SNAPSHOT_EVERY_HOURS.default };
94
+ const r = raw as Record<string, unknown>;
95
+ // `undefined` alone defaults: a bare `everyHours:` key (null) is a malformed value, refused below by name.
96
+ const every = r.everyHours === undefined ? COSTS_SNAPSHOT_EVERY_HOURS.default : r.everyHours;
92
97
  if (
93
98
  typeof every !== "number" ||
94
99
  !Number.isInteger(every) ||
@@ -98,7 +103,10 @@ function snapshotConfig(raw: unknown): CostsConfig["snapshot"] {
98
103
  throw new Error(
99
104
  `costs.snapshot.everyHours must be a whole number of hours between ${COSTS_SNAPSHOT_EVERY_HOURS.min} and ${COSTS_SNAPSHOT_EVERY_HOURS.max}`,
100
105
  );
101
- return { everyHours: every };
106
+ if (r.alertChannel === undefined) return { everyHours: every };
107
+ if (typeof r.alertChannel !== "string" || !/^[a-z]+:.+$/.test(r.alertChannel))
108
+ throw new Error("costs.snapshot.alertChannel must be a platform-namespaced channel id (`slack:C…`)");
109
+ return { everyHours: every, alertChannel: r.alertChannel };
102
110
  }
103
111
 
104
112
  function labelMap(raw: unknown, what: string): Record<string, string> {
@@ -0,0 +1,21 @@
1
+ // Where a preset's harness word is set (docs/reference/specs/harness.md item
2
+ // 8), in a module with no imports of its own: the run record (`run_meta.harnessScope`)
3
+ // and the timeline fold read it, and both are compiled into the Workers' and
4
+ // the dashboard's programs, where the roster's neighbours — the harness
5
+ // objects, their Node-only process code — must not follow. The roster
6
+ // re-exports these names, so every reader inside the bot can still take them
7
+ // from there.
8
+
9
+ /** The scopes a word is set at, most specific first — the order the resolution
10
+ * walks the layers: the requester's own scope, the channel's, then the
11
+ * deployment's top-level `harness` block, which is the defaults layer under
12
+ * its one spelling. What `run_meta.harnessScope` and the config block name, so
13
+ * a reader of a run on OpenCode never guesses whose word put it there. */
14
+ export const HARNESS_SCOPES = ["user", "channel", "defaults"] as const;
15
+ export type HarnessScope = (typeof HARNESS_SCOPES)[number];
16
+
17
+ /** Whether a value is one of the scopes a word is set at: the timeline's test
18
+ * for the field a record carries. */
19
+ export function isHarnessScope(value: unknown): value is HarnessScope {
20
+ return typeof value === "string" && (HARNESS_SCOPES as readonly string[]).includes(value);
21
+ }
@@ -2,6 +2,7 @@
2
2
  // the node-free contract the memory Worker and web app compile with their own
3
3
  // tsconfigs — importing prDescription.ts would drag zod into those graphs.
4
4
  import type { PrDescription, RenderedTourStep } from "./prDescriptionTypes.js";
5
+ import type { HarnessScope } from "./harness/scope.js";
5
6
 
6
7
  /** The `pr_description` review artifact minus the event envelope
7
8
  * (docs/reference/specs/reading-diff.md item 7). */
@@ -97,6 +98,11 @@ export type RunNoteKind =
97
98
  /** The PR head moved while a review ran and the same run is re-reviewing at
98
99
  * the new head (agent-review.md item 12). Published by the dispatcher. */
99
100
  | "head_moved"
101
+ /** The run loop threw and the run finishes `failed`: the summary is the
102
+ * error's message, redacted and capped, so the run page says why a failed
103
+ * run failed even when the reply is never delivered (run-history.md).
104
+ * Published by the run loop's catch, before the finish. */
105
+ | "run_failed"
100
106
  /** An MCP server configured for this agent did not answer discovery
101
107
  * (docs/reference/specs/mcp-tools.md item 8); the run proceeds without its tools. One
102
108
  * note per server, published by the dispatcher before the first turn. */
@@ -187,6 +193,11 @@ export type RunNoteKind =
187
193
  * item 7): the summary names the tool and the rule; the model read the same
188
194
  * reason as the tool's result. Published by the bot's authorize route. */
189
195
  | "tool_refused"
196
+ /** A ship coding child's budget ended with work still in the tree: the run
197
+ * loop committed and pushed it to the unit's branch (or says plainly that
198
+ * there was nothing to push), so a re-issue starts from the partial work
199
+ * (docs/reference/specs/agent-ship.md item 8). */
200
+ | "budget_salvage"
190
201
  /** The native loop's stuck-loop guard: the same tool call failed identically
191
202
  * six times in a row and the run was forced into its write-up. Written by
192
203
  * no loop since that loop's deletion; a record from before it may carry it. */
@@ -205,6 +216,7 @@ export const RUN_NOTE_KINDS = [
205
216
  "stopped",
206
217
  "spans_dropped",
207
218
  "head_moved",
219
+ "run_failed",
208
220
  "mcp_unavailable",
209
221
  "follow_up",
210
222
  "resumed",
@@ -220,6 +232,7 @@ export const RUN_NOTE_KINDS = [
220
232
  "harness_error",
221
233
  "policy_refusal",
222
234
  "tool_refused",
235
+ "budget_salvage",
223
236
  "stuck_loop",
224
237
  ] as const satisfies readonly RunNoteKind[];
225
238
  type _EveryKindListed = [RunNoteKind] extends [(typeof RUN_NOTE_KINDS)[number]] ? true : never;
@@ -519,9 +532,15 @@ export type RunEvent =
519
532
  /** The request's trace id (docs/reference/specs/tracing.md), once the root exists. */
520
533
  traceId?: string;
521
534
  /** The harness the run is driven by (`Harness.name`; docs/reference/specs/harness.md
522
- * item 8): `pi` today. Absent on a command run, which starts no process,
535
+ * items 8 and 10): the word the scopes resolved for the preset, `pi`
536
+ * when none named it. Absent on a command run, which starts no process,
523
537
  * and on a record written before the seam existed. Additive. */
524
538
  harness?: string;
539
+ /** Whose word put the run on that harness (item 10): the requester's own
540
+ * scope, the channel's, or the deployment's top-level block. Absent when
541
+ * no scope named the preset — the roster's default — and on records
542
+ * written before the word was a scope setting. */
543
+ harnessScope?: HarnessScope;
525
544
  effort?: string;
526
545
  repo?: string;
527
546
  ref?: string;
@@ -56,6 +56,8 @@ export interface LiveRunMeta {
56
56
  /** The bound credential behind the person (authorization.md item 15): a
57
57
  * resume or restart dispatches under ITS grants again, never the person's. */
58
58
  authenticatedAs?: string;
59
+ /** The app that relayed the request for the person (authorization.md item 14): a resume or restart keeps app ∩ person at the gates. */
60
+ postedBy?: string;
59
61
  effort?: string;
60
62
  ref?: string;
61
63
  headSha?: string;
@@ -109,6 +109,9 @@ export interface RunRecord {
109
109
  receivedAt?: number;
110
110
  sealedAt?: number;
111
111
  replyOk?: boolean;
112
+ /** Why a `replyOk: false` reply was not delivered (e.g. "no channel to deliver
113
+ * to", run-history.md item 38). Absent when the reply was delivered or none was made. */
114
+ replyNote?: string;
112
115
  stepCount?: number;
113
116
  schema?: number;
114
117
  status: RunStatus;
@@ -783,6 +786,7 @@ export function isRunRecord(v: unknown): v is RunRecord {
783
786
  if (r[key] !== undefined && !isFiniteNumber(r[key])) return false;
784
787
  }
785
788
  if (r.replyOk !== undefined && typeof r.replyOk !== "boolean") return false;
789
+ if (r.replyNote !== undefined && typeof r.replyNote !== "string") return false;
786
790
  for (const key of ["stepCount", "schema"] as const) {
787
791
  if (r[key] !== undefined && (!isFiniteNumber(r[key]) || !Number.isInteger(r[key]) || (r[key] as number) < 0))
788
792
  return false;
@@ -428,8 +428,10 @@ function renderFirstInstruction(rebase: ChildContract["rebase"]): string {
428
428
  return (
429
429
  `Rebase ${branch} onto ${onto} before any other work — the parent unit has merged and the base has moved; ` +
430
430
  `the only writes are your own on that branch. A conflict ends the unit: report it as the handoff and stop. ` +
431
- `Right before the push, fetch ${onto} again and rebase once more if it moved during verify, ` +
432
- `so the pull request is not born conflicting.`
431
+ `Push the branch as soon as the change exists and its cheapest proving checks pass — before the project's ` +
432
+ `full verification, which runs after that push with any fix as a further commit; an unpushed tree does not ` +
433
+ `survive the run's end. Right before each push, fetch ${onto} again and rebase once more if it moved while ` +
434
+ `you worked, so the pull request is not born conflicting.`
433
435
  );
434
436
  }
435
437
 
@@ -9,8 +9,10 @@
9
9
  * cycle, and these say the snapshot (or the container that would restore it)
10
10
  * cannot be used — never a provision failure, which would loop against the
11
11
  * same broken build. `runtime-unreachable` is item 64's last rung: a recreated
12
- * container that did not answer either. */
13
- export const REHYDRATION_FAILURE_RE = /^(r2-restore-failed|snapshot-stamp-mismatch|no-snapshot|runtime-unreachable)/;
12
+ * container that did not answer either; `infra-streak` is item 67's: a
13
+ * recreated container whose cycles kept failing in the resident's own steps. */
14
+ export const REHYDRATION_FAILURE_RE =
15
+ /^(r2-restore-failed|snapshot-stamp-mismatch|no-snapshot|runtime-unreachable|infra-streak)/;
14
16
 
15
17
  /** How many auto-rebuilds one resident gets inside one window. A rebuild is a
16
18
  * clone, an install and a build (minutes to half an hour) that holds the cap
@@ -67,10 +67,9 @@ export type FetchReason = "missing-ref" | "stale-tip" | "returnable-ref";
67
67
  * lost and a ref a person named that the thread never pushed has no way
68
68
  * back, so neither pays this fetch: the refresh cycle is their freshness.
69
69
  * - otherwise null.
70
- * A fetch that STILL leaves the tip elsewhere (a push racing this attach, or
71
- * a force-push) is not this function's concern: the attach proceeds on the
72
- * fetched tip and reports it, and the reviewed-head guard decides what a
73
- * review of it may do. */
70
+ * A fetch that STILL leaves the tip elsewhere (a push racing this attach, a
71
+ * force-push, or a fetch that failed) is `attachTarget`'s concern: the attach
72
+ * is refused `stale-tip` rather than run at a commit nobody asked for. */
74
73
  export function mirrorFetchReason(input: {
75
74
  refExists: boolean;
76
75
  mirrorSha?: string;
@@ -85,8 +84,16 @@ export function mirrorFetchReason(input: {
85
84
 
86
85
  /** What the attach checks out, once the mirror is as fresh as it will get. */
87
86
  export type AttachTarget =
88
- /** The bound ref is in the mirror: clone its tip, as always. */
87
+ /** The bound ref is in the mirror, and its tip is the commit the caller
88
+ * named (or the caller named none): clone its tip, as always. */
89
89
  | { kind: "ref" }
90
+ /** The bound ref is in the mirror but its tip is NOT the commit the caller
91
+ * named, even after the fetch — the mirror is behind it (the fetch failed
92
+ * or was skipped) or ahead of it (a push raced the attach). Refused: a run
93
+ * executes at the sha it asked for, or not on this resident; the caller
94
+ * falls back cold at the requested commit. `tip` is null when the tip
95
+ * could not be read — never assumed fresh. */
96
+ | { kind: "stale-tip"; tip: string | null; want: string }
90
97
  /** The ref is gone but the commit the caller expects is in the mirror — a
91
98
  * merged PR's branch was deleted while `refs/pull/N/head` (a `--mirror`
92
99
  * clone carries every ref) still holds its head: check that commit out,
@@ -96,16 +103,22 @@ export type AttachTarget =
96
103
  /** Neither: the attach is refused as `unknown-ref`. */
97
104
  | { kind: "unknown-ref" };
98
105
 
99
- /** The attach target after the fetch (item 51). The ref wins whenever it
100
- * exists — a detached tree is only for a ref that is gone; a caller that
101
- * named no commit, or whose commit the mirror does not hold either, gets the
102
- * refusal it always got. */
106
+ /** The attach target after the fetch (item 51). A ref that exists is cloned
107
+ * at its tip when the caller named no commit or the tip is that commit;
108
+ * a tip that is any other commit is `stale-tip`, refused. A detached tree is
109
+ * only for a ref that is gone; a caller that named no commit, or whose commit
110
+ * the mirror does not hold either, gets the refusal it always got. */
103
111
  export function attachTarget(input: {
104
112
  refExists: boolean;
105
113
  wantSha: string | null;
106
114
  commitInMirror: boolean;
115
+ /** The ref's tip in the mirror after the fetch; null or absent when it could not be read. */
116
+ tipSha?: string | null;
107
117
  }): AttachTarget {
108
- if (input.refExists) return { kind: "ref" };
118
+ if (input.refExists) {
119
+ if (input.wantSha === null || input.tipSha === input.wantSha) return { kind: "ref" };
120
+ return { kind: "stale-tip", tip: input.tipSha ?? null, want: input.wantSha };
121
+ }
109
122
  if (input.wantSha !== null && input.commitInMirror) return { kind: "sha", sha: input.wantSha };
110
123
  return { kind: "unknown-ref" };
111
124
  }
@@ -0,0 +1,89 @@
1
+ // A refresh cycle that keeps failing in the resident's OWN steps heals itself
2
+ // (docs/reference/specs/resident-repos.md item 67). Pure: the Worker's
3
+ // `refreshFailed` bumps the row and acts on the rung; the cycle's gate asks
4
+ // `parksOnRepeat` before it counts a degraded reason toward the park streak.
5
+ //
6
+ // The split this module draws: a step that runs the repository's own command
7
+ // (the onboard-time command table — install, build, test) failing is evidence
8
+ // about the repository, and the right response is to park and wait for the
9
+ // head to be fixed, which is what the park streak does. Every other step is
10
+ // the resident's machinery — git against the mirror, the probes, the markers,
11
+ // the restores — and a failure there that repeats is the container or the
12
+ // disk gone wrong, which a rebuild does fix. Item 64 already climbs this
13
+ // ladder for one signature (the control port that never answers); this is
14
+ // the same ladder for every other resident-step failure.
15
+
16
+ import { STALE_SWEEP_SUFFIX } from "./residentSteps.js";
17
+
18
+ /** The steps that run the repository's own commands. */
19
+ export const REPO_COMMAND_STEPS: ReadonlySet<string> = new Set(["deps-install", "build", "test"]);
20
+
21
+ /** Whether a failed step ran the repository's command (evidence about the
22
+ * repo) rather than the resident's own machinery. The stale-process sweep
23
+ * that precedes a repo step is ours. */
24
+ export function isRepoCommandStep(step: string): boolean {
25
+ if (step.endsWith(STALE_SWEEP_SUFFIX)) return false;
26
+ return REPO_COMMAND_STEPS.has(step);
27
+ }
28
+
29
+ /** The step named by a `<step>-failed:` reason (`classifyRefreshFailure`'s
30
+ * plain-failure shape), or null for every other reason. */
31
+ export function stepOfFailedReason(reason: string): string | null {
32
+ const m = /^([a-z][a-z0-9-]*)-failed:/.exec(reason);
33
+ return m ? m[1] : null;
34
+ }
35
+
36
+ /** Whether a repeated degraded reason should PARK the resident (the 6-hour
37
+ * cadence, `DEGRADED_PARK_AFTER_CYCLES`): only evidence about the repository
38
+ * or about GitHub — a repo-command step's failure, or a reason that names no
39
+ * step at all (`github-unreachable`). A resident-step failure never parks:
40
+ * the ladder below is its escalation, and parking would only slow it. */
41
+ export function parksOnRepeat(reason: string): boolean {
42
+ const step = stepOfFailedReason(reason);
43
+ return step === null || isRepoCommandStep(step);
44
+ }
45
+
46
+ /** The ladder's rungs: consecutive cycles failed in resident steps. */
47
+ export const INFRA_STREAK_RECREATE_AT = 3;
48
+ export const INFRA_STREAK_DOWN_AT = 5;
49
+
50
+ export type InfraStreakRung = "count" | "recreate" | "down";
51
+
52
+ /** Count 1–2: record and let the next cycle try. Count 3: destroy the
53
+ * container, snapshots kept — the next cycle restores from the snapshot onto
54
+ * a fresh disk (item 64's rung 3). Count 4: the recreated container's one
55
+ * cycle of its own. Count ≥ 5: a fresh container failed the same way; `down`
56
+ * with a rehydration-flavored reason, so item 36's transition rebuild is the
57
+ * exit. */
58
+ export function infraStreakRung(count: number): InfraStreakRung {
59
+ if (!Number.isFinite(count) || count < 1) {
60
+ throw new RangeError(`infra-streak rung needs a count of at least 1, got ${count}`);
61
+ }
62
+ if (count >= INFRA_STREAK_DOWN_AT) return "down";
63
+ if (count === INFRA_STREAK_RECREATE_AT) return "recreate";
64
+ return "count";
65
+ }
66
+
67
+ /** The persisted row (`resident:infraStreak`): the last failing step, the
68
+ * consecutive count and the span. */
69
+ export interface InfraStreakRow {
70
+ step: string;
71
+ count: number;
72
+ firstAt: string;
73
+ lastAt: string;
74
+ }
75
+
76
+ const INFRA_STREAK_ACTION: Readonly<Record<Exclude<InfraStreakRung, "count">, string>> = {
77
+ recreate: "the container was destroyed, snapshots kept; the next cycle restores from the snapshot",
78
+ down: "a recreated container failed the same way; rebuilding",
79
+ };
80
+
81
+ /** The reason a rung records: `infra-streak:` so the gate, the serviceability
82
+ * check and item 36's eligibility all read it as the resident's, never the repo's. */
83
+ export function infraStreakReason(row: InfraStreakRow, rung: Exclude<InfraStreakRung, "count">): string {
84
+ return `infra-streak: ${row.count} consecutive cycles failed in the resident's own steps (last: ${row.step}, since ${row.firstAt}) — ${INFRA_STREAK_ACTION[rung]}`;
85
+ }
86
+
87
+ export function isInfraStreakReason(reason: string): boolean {
88
+ return /^infra-streak: /.test(reason);
89
+ }
@@ -132,6 +132,10 @@ export interface RefreshFailure {
132
132
  * with the attempt count (the ladder below decides what the resident does
133
133
  * about it); real failures keep `<step>-failed:`. */
134
134
  reason: string;
135
+ /** The step that failed, as the caller named it (`refresh` for a failure
136
+ * between steps): item 67 reads whether it ran the repository's own command
137
+ * or the resident's machinery. */
138
+ step: string;
135
139
  interrupted: boolean;
136
140
  diskFull: boolean;
137
141
  runtimeUnreachable: boolean;
@@ -234,6 +238,7 @@ export function classifyRefreshFailure(input: {
234
238
  const { step, message } = input;
235
239
  if (input.runtimeUnreachable) {
236
240
  return {
241
+ step,
237
242
  interrupted: false,
238
243
  diskFull: false,
239
244
  runtimeUnreachable: true,
@@ -242,6 +247,7 @@ export function classifyRefreshFailure(input: {
242
247
  }
243
248
  if (isDiskFullMessage(message)) {
244
249
  return {
250
+ step,
245
251
  interrupted: false,
246
252
  diskFull: true,
247
253
  runtimeUnreachable: false,
@@ -251,6 +257,7 @@ export function classifyRefreshFailure(input: {
251
257
  const timedOut = /\(timed out\)/.test(message);
252
258
  if (!timedOut && (INTERRUPTION_SIGNATURE.test(message) || RUNTIME_REPLACEMENT_WORDING.test(message))) {
253
259
  return {
260
+ step,
254
261
  interrupted: true,
255
262
  diskFull: false,
256
263
  runtimeUnreachable: false,
@@ -259,13 +266,20 @@ export function classifyRefreshFailure(input: {
259
266
  }
260
267
  if (input.freeKiB !== undefined && input.freeKiB !== null && input.freeKiB < DISK_FULL_FREE_KIB) {
261
268
  return {
269
+ step,
262
270
  interrupted: false,
263
271
  diskFull: true,
264
272
  runtimeUnreachable: false,
265
273
  reason: diskFullReason({ step, message, freeKiB: input.freeKiB }),
266
274
  };
267
275
  }
268
- return { interrupted: false, diskFull: false, runtimeUnreachable: false, reason: `${step}-failed: ${message}` };
276
+ return {
277
+ step,
278
+ interrupted: false,
279
+ diskFull: false,
280
+ runtimeUnreachable: false,
281
+ reason: `${step}-failed: ${message}`,
282
+ };
269
283
  }
270
284
 
271
285
  // -- the runtime that never answers --------------------------------------------
@@ -49,6 +49,7 @@ export const RESIDENT_STEP_LABELS = {
49
49
  "worktree-clean": "cleaning the worktree",
50
50
  "clean-workspace": "cleaning the workspace",
51
51
  "clean-before-restore": "cleaning before the restore",
52
+ "break-mirror": "removing the mirror's objects (fault injection)",
52
53
  "unmount-restores": "unmounting earlier restores",
53
54
  "mirror-restore-extract": "extracting the mirror snapshot",
54
55
  "checkout-restore-extract": "extracting the checkout snapshot",