@oneie/claude 0.8.0 → 0.10.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 (230) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/abm-strategist.md +67 -1
  3. package/agents/ads-meta.md +67 -1
  4. package/agents/analyst.md +67 -1
  5. package/agents/animator.md +108 -0
  6. package/agents/architect.md +269 -20
  7. package/agents/brand-guardian.md +67 -1
  8. package/agents/brand-strategist.md +67 -1
  9. package/agents/campaign-content.md +67 -1
  10. package/agents/campaign-email.md +67 -1
  11. package/agents/campaign-sms.md +67 -1
  12. package/agents/campaign-social.md +67 -1
  13. package/agents/cco.md +83 -2
  14. package/agents/ceo.md +108 -11
  15. package/agents/chairman.md +197 -0
  16. package/agents/cmo.md +82 -2
  17. package/agents/community-greeter.md +67 -1
  18. package/agents/community-moderator.md +67 -1
  19. package/agents/compliance.md +67 -1
  20. package/agents/copywriter.md +67 -1
  21. package/agents/creative-strategist.md +67 -1
  22. package/agents/cro.md +81 -1
  23. package/agents/cto.md +266 -28
  24. package/agents/customer-interviewer.md +67 -1
  25. package/agents/customer-researcher.md +67 -1
  26. package/agents/customer-success-manager.md +67 -1
  27. package/agents/customer-trainer.md +67 -1
  28. package/agents/cxo.md +82 -1
  29. package/agents/demand-creator.md +67 -1
  30. package/agents/demo-mover.md +67 -1
  31. package/agents/demo-specialist.md +67 -1
  32. package/agents/demo-thai-family-law.md +67 -1
  33. package/agents/designer.md +67 -1
  34. package/agents/discovery-caller.md +67 -1
  35. package/agents/doctor.md +269 -0
  36. package/agents/educate-coach.md +67 -1
  37. package/agents/elevate-tutor.md +67 -1
  38. package/agents/email-lifecycle-marketer.md +67 -1
  39. package/agents/engage-specialist.md +67 -1
  40. package/agents/events-coordinator.md +67 -1
  41. package/agents/foundation-builder.md +67 -1
  42. package/agents/funnel-architect.md +67 -1
  43. package/agents/gift-creator.md +67 -1
  44. package/agents/google-ads.md +67 -1
  45. package/agents/guide.md +67 -1
  46. package/agents/helpdesk-dispatcher.md +67 -1
  47. package/agents/hook-specialist.md +67 -1
  48. package/agents/identify-optimizer.md +67 -1
  49. package/agents/implementer.md +313 -45
  50. package/agents/incident-commander.md +67 -1
  51. package/agents/insights-lead.md +87 -1
  52. package/agents/journey-runner.md +67 -1
  53. package/agents/linkedin-ads.md +67 -1
  54. package/agents/live-sales-chat.md +67 -1
  55. package/agents/market-researcher.md +67 -1
  56. package/agents/media-buyer.md +67 -1
  57. package/agents/memory-keeper.md +195 -0
  58. package/agents/movers-customer-researcher.md +67 -1
  59. package/agents/movers-foundation-builder.md +67 -1
  60. package/agents/movers-market-researcher.md +67 -1
  61. package/agents/movers-pricing-strategist.md +67 -1
  62. package/agents/nurture-architect.md +67 -1
  63. package/agents/offer-architect.md +67 -1
  64. package/agents/onboarder.md +67 -1
  65. package/agents/onboarding-specialist.md +67 -1
  66. package/agents/operations-dashboard.md +87 -1
  67. package/agents/perf-engineer.md +333 -37
  68. package/agents/playbook-writer.md +67 -1
  69. package/agents/plg-strategist.md +67 -1
  70. package/agents/positioning-architect.md +67 -1
  71. package/agents/press-officer.md +67 -1
  72. package/agents/pricing-strategist.md +67 -1
  73. package/agents/privacy-officer.md +67 -1
  74. package/agents/referral-manager.md +67 -1
  75. package/agents/refine-analyst.md +67 -1
  76. package/agents/release-manager.md +446 -39
  77. package/agents/renewals-upsell-rep.md +67 -1
  78. package/agents/review-engineer.md +319 -45
  79. package/agents/rewards-steward.md +67 -1
  80. package/agents/sales-call-coach.md +67 -1
  81. package/agents/sales-closer.md +67 -1
  82. package/agents/security-auditor.md +343 -48
  83. package/agents/sell-closer.md +67 -1
  84. package/agents/share-amplifier.md +67 -1
  85. package/agents/social-media-manager.md +67 -1
  86. package/agents/storyteller.md +301 -0
  87. package/agents/strategist.md +67 -1
  88. package/agents/strategy-aligner.md +67 -1
  89. package/agents/support-agent.md +67 -1
  90. package/agents/tagger.md +327 -0
  91. package/agents/tech-writer.md +195 -22
  92. package/agents/test-engineer.md +398 -29
  93. package/agents/tiktok-ads.md +67 -1
  94. package/agents/tracking-engineer.md +67 -1
  95. package/agents/trailkeeper.md +181 -0
  96. package/agents/upsell-strategist.md +67 -1
  97. package/agents/voice.md +67 -1
  98. package/agents/w1-recon.md +1 -1
  99. package/agents/w2-decide.md +1 -1
  100. package/agents/w3-edit.md +8 -2
  101. package/agents/w4-verify.md +13 -0
  102. package/agents/workflow-optimiser.md +81 -1
  103. package/commands/close.md +916 -160
  104. package/commands/deploy.md +102 -724
  105. package/commands/do.md +58 -2
  106. package/commands/sweep.md +159 -0
  107. package/commands/tasks.md +222 -0
  108. package/hooks/scripts/dev-only.sh +135 -0
  109. package/hooks/scripts/git-add-guard.sh +37 -2
  110. package/hooks/scripts/session-start.sh +32 -4
  111. package/package.json +1 -1
  112. package/rules/scripts.md +85 -0
  113. package/scripts/CLAUDE.md +315 -0
  114. package/scripts/ad-copy-lint.sh +656 -0
  115. package/scripts/agent-actor-parity.sh +129 -0
  116. package/scripts/blocks-manifest-cached.sh +100 -0
  117. package/scripts/chat-context-check.sh +89 -0
  118. package/scripts/chrome.mjs +18 -0
  119. package/scripts/close-metrics.sh +587 -0
  120. package/scripts/close-owner.sh +326 -0
  121. package/scripts/db-sync-lock-check.sh +116 -0
  122. package/scripts/deploy-emit.sh +311 -0
  123. package/scripts/deploy-gate-check.sh +155 -0
  124. package/scripts/deploy-ready.sh +78 -0
  125. package/scripts/deploy-record.sh +605 -0
  126. package/scripts/deploy-schema-check.sh +58 -0
  127. package/scripts/deploy.sh +393 -243
  128. package/scripts/do-auto.sh +127 -26
  129. package/scripts/do-board.sh +429 -0
  130. package/scripts/do-close.sh +1184 -0
  131. package/scripts/do-consumer-sweep.sh +18 -1
  132. package/scripts/do-decide.sh +476 -0
  133. package/scripts/do-fleet.sh +8 -2
  134. package/scripts/do-plan-json.mjs +110 -12
  135. package/scripts/do-prove-selftest.sh +108 -0
  136. package/scripts/do-prove.sh +86 -10
  137. package/scripts/do-rank.py +200 -3
  138. package/scripts/do-reconcile.sh +73 -12
  139. package/scripts/do-signal.sh +101 -23
  140. package/scripts/do-smoke.sh +18 -1
  141. package/scripts/do-w4-gates.sh +11 -1
  142. package/scripts/do-world-check.sh +153 -0
  143. package/scripts/download-stats.sh +172 -0
  144. package/scripts/factory-brief-check.sh +330 -0
  145. package/scripts/factory-check.sh +18 -1
  146. package/scripts/factory-close-check.sh +257 -0
  147. package/scripts/factory-emit.sh +211 -0
  148. package/scripts/factory-executor-check.mjs +353 -0
  149. package/scripts/factory-peak.sh +301 -0
  150. package/scripts/factory-repo.sh +71 -0
  151. package/scripts/factory-review-check.mjs +61 -0
  152. package/scripts/factory-tasks-check.sh +18 -1
  153. package/scripts/fixtures/factory-brief-real.md +44 -0
  154. package/scripts/flywheel-outcome.sh +63 -0
  155. package/scripts/gate-reaper-check.sh +98 -0
  156. package/scripts/gate-reaper.sh +9 -0
  157. package/scripts/gate-watchdog.sh +619 -0
  158. package/scripts/gc-content-check.sh +142 -0
  159. package/scripts/gh-traffic-capture.sh +153 -0
  160. package/scripts/govern-order-check.sh +202 -0
  161. package/scripts/governor-doors-check.sh +86 -5
  162. package/scripts/health.sh +448 -0
  163. package/scripts/id-inventory.mjs +418 -0
  164. package/scripts/incident.sh +212 -0
  165. package/scripts/land.sh +755 -45
  166. package/scripts/lib/gc-finished.sh +77 -0
  167. package/scripts/livekit-ratchet.sh +18 -1
  168. package/scripts/machine-check.sh +1 -1
  169. package/scripts/memory-index-budget.sh +79 -0
  170. package/scripts/npm-downloads.sh +109 -0
  171. package/scripts/one-agents.mjs +204 -8
  172. package/scripts/one-resume.sh +31 -3
  173. package/scripts/pr-body.sh +335 -0
  174. package/scripts/preview-fd-check.sh +289 -0
  175. package/scripts/redirect-lint.sh +169 -0
  176. package/scripts/release.sh +40 -6
  177. package/scripts/resume-lost-sessions.sh +68 -0
  178. package/scripts/shoot-pages.mjs +140 -0
  179. package/scripts/signal-meta-backfill.ts +451 -0
  180. package/scripts/signal-watch.sh +63 -6
  181. package/scripts/speed-cache-check.sh +12 -2
  182. package/scripts/sweep.sh +426 -0
  183. package/scripts/task-titles-dump.ts +101 -0
  184. package/scripts/test-cached.sh +47 -10
  185. package/scripts/test-lanes.sh +14 -0
  186. package/scripts/thread-name-backfill.ts +215 -0
  187. package/scripts/triage-shape-check.sh +149 -0
  188. package/scripts/tsc-cached.sh +155 -8
  189. package/scripts/typedb-flake-check.sh +3 -1
  190. package/scripts/urls-lint.sh +8 -0
  191. package/scripts/verify-board-doors.sh +80 -0
  192. package/scripts/verify-fast.sh +159 -6
  193. package/scripts/worktree-up.sh +21 -3
  194. package/skills/astro/SKILL.md +9 -3
  195. package/skills/astro/optimize-performance.md +3 -2
  196. package/skills/cloudflare/SKILL.md +3 -2
  197. package/skills/cloudflare-security-audit/AI-AND-LLM.md +83 -0
  198. package/skills/cloudflare-security-audit/ATTACK-CLASSES.md +130 -0
  199. package/skills/cloudflare-security-audit/CLIENT-SIDE.md +83 -0
  200. package/skills/cloudflare-security-audit/CLOUD-AND-DEPLOYMENT.md +86 -0
  201. package/skills/cloudflare-security-audit/DATA-ISOLATION-AND-LIFECYCLE.md +84 -0
  202. package/skills/cloudflare-security-audit/DESKTOP-MOBILE-AND-LOCAL-IPC.md +89 -0
  203. package/skills/cloudflare-security-audit/HUNTING.md +251 -0
  204. package/skills/cloudflare-security-audit/LICENSE +21 -0
  205. package/skills/cloudflare-security-audit/MEMORY-SAFETY-AND-BINARY.md +101 -0
  206. package/skills/cloudflare-security-audit/PROTOCOLS-RPC-AND-MESSAGING.md +81 -0
  207. package/skills/cloudflare-security-audit/PROVENANCE.md +78 -0
  208. package/skills/cloudflare-security-audit/RECONNAISSANCE.md +156 -0
  209. package/skills/cloudflare-security-audit/RESOURCE-EXHAUSTION-AND-AVAILABILITY.md +78 -0
  210. package/skills/cloudflare-security-audit/SKILL.md +192 -0
  211. package/skills/cloudflare-security-audit/SUPPLY-CHAIN-AND-RELEASE.md +73 -0
  212. package/skills/cloudflare-security-audit/VALIDATION-AND-REPORTING.md +186 -0
  213. package/skills/cloudflare-security-audit/WEB-PROTOCOL-AND-AUTH.md +105 -0
  214. package/skills/cloudflare-security-audit/report-schema.json +461 -0
  215. package/skills/cloudflare-security-audit/validate-coverage-ledger.cjs +872 -0
  216. package/skills/cloudflare-security-audit/validate-coverage-ledger.test.cjs +740 -0
  217. package/skills/cloudflare-security-audit/validate-findings.cjs +773 -0
  218. package/skills/cloudflare-security-audit/validate-findings.test.cjs +652 -0
  219. package/skills/deploy/REFERENCE.md +713 -0
  220. package/skills/deploy/SKILL.md +140 -0
  221. package/skills/fleet-audit/SKILL.md +58 -0
  222. package/skills/meeting/SKILL.md +220 -0
  223. package/skills/planning/SKILL.md +256 -0
  224. package/skills/shadcn/SKILL.md +1 -1
  225. package/skills/typedb/SKILL.md +7 -0
  226. package/skills/voice/SKILL.md +94 -6
  227. package/skills/voice/corpus-check.sh +87 -0
  228. package/templates/template-agent.md +7 -1
  229. package/templates/template-feature.md +9 -0
  230. package/templates/template-todo.md +29 -0
package/scripts/deploy.sh CHANGED
@@ -38,18 +38,14 @@
38
38
  # · CLOUDFLARE_API_TOKEN is unset; auth is GLOBAL_API_KEY + EMAIL
39
39
  # · health-check custom domains only (*.workers.dev is blocked on this net)
40
40
  # · sync is cron-only: a clean `wrangler deploy` IS its health signal
41
+ # · a fast pass is NEVER reported as a full pass, and `dev` never touches prod
42
+ # · dev.one.ie shares PRODUCTION's D1 and KV — ship dev freely, treat its DATA
43
+ # as production incident:deploy-two-tiers
41
44
  #
42
- # THE TWO TIERS one command, two destinations:
43
- # dev → https://dev.one.ie (worker `one-dev`). Agents finish loops here.
44
- # MINIMUM gate: the FAST lane. No approval prompt, no migrations, no
45
- # five-service fan-out. Delegates to deploy-dev.sh, which is the
46
- # authority for the dev procedure.
47
- # prod → https://one.ie and the other four services. FULL gate always
48
- # (FULL_VERIFY=1), approval prompt unless --yes.
49
- # A fast pass is NEVER reported as a full pass, and `dev` never touches prod.
50
- # dev.one.ie shares PRODUCTION's D1 and KV byte-for-byte — only the cron strip
51
- # differs. So a dev deploy is NOT a sandbox for data: it reads and writes the
52
- # same rows one.ie does. Ship dev freely; treat its DATA as production.
45
+ # ACCOUNTS why each gate is shaped the way it is (dates, shas, what it cost):
46
+ # bash .claude/scripts/incident.sh --for .claude/scripts/deploy.sh
47
+ # What a gate MEANS: .claude/commands/deploy.md · its traps:
48
+ # .claude/skills/deploy/REFERENCE.md
53
49
 
54
50
  set -uo pipefail
55
51
 
@@ -69,10 +65,8 @@ cd "$ROOT" || exit 1
69
65
  MODE="full"
70
66
  SKIP_TESTS=0 SKIP_TYPECHECK=0 SKIP_BUILD=0 SKIP_MIGRATIONS=0 SKIP_HEALTH=0
71
67
  ASSUME_YES="${DEPLOY_YES:-0}" DRY=0 ALLOW_DIRTY=0 ONLY_CHANGED=0 GATES_ONLY=0 CHECK_CREDS=0
72
- # A RED suite whose every failure is the shared TypeDB Cloud cluster refusing to
73
- # answer is waivable — nothing was disproven. Classified by signature, never by
74
- # filename, by typedb-flake-check.sh, which refuses the tasks:claim not_found
75
- # privilege boundary outright. --no-typedb-flake-waiver restores the hard stop.
68
+ # A red suite that is only the shared TypeDB cluster refusing to answer is
69
+ # waivable — classified by SIGNATURE, never by filename. incident:waived-pass-is-a-fact
76
70
  TYPEDB_FLAKE_WAIVER="${DEPLOY_ALLOW_TYPEDB_FLAKE:-1}" TESTS_WAIVED=0
77
71
 
78
72
  for a in "$@"; do
@@ -98,14 +92,11 @@ done
98
92
  [[ "$MODE" == channels ]] && MODE=agents
99
93
 
100
94
  # ── dev tier ─────────────────────────────────────────────────────────────────
101
- # `./deploy dev` is a different destination with a different gate, so it exits
102
- # here rather than threading a flag through 700 lines of production pipeline.
103
- # deploy-dev.sh owns the dev procedure; this is only the door to it.
95
+ # A different destination with a different gate is a different procedure.
96
+ # deploy-dev.sh owns it; this is only the door. incident:deploy-two-tiers
104
97
  if [[ "$MODE" == dev ]]; then
105
- # --skip-tests is the only production flag that carries over: it is the dev
106
- # script's DEV_SKIP_GATE. `(( SKIP_TESTS ))` and not `${SKIP_TESTS:+...}` —
107
- # the latter expands on the literal string "0" and would silently skip the
108
- # gate on every dev deploy.
98
+ # `(( SKIP_TESTS ))`, never `${SKIP_TESTS:+...}` the latter expands on the
99
+ # literal "0" and skips the gate every time. incident:deploy-two-tiers
109
100
  dev_gate=0; (( SKIP_TESTS )) && dev_gate=1
110
101
  if (( DRY )); then
111
102
  echo "[dry] DEV_SKIP_GATE=$dev_gate bash .claude/scripts/deploy-dev.sh"
@@ -148,10 +139,8 @@ case "$MODE" in
148
139
  pay) want_pay=1 ;;
149
140
  esac
150
141
 
151
- # --changed: a service whose tree has no new commits since its last successful
152
- # deploy has nothing to ship. The marker is written per service AFTER a clean
153
- # deploy, so an interrupted run never marks anything shipped. Secrets/bindings
154
- # change outside git — use --all (the default) when that's what you changed.
142
+ # --changed skips a service with no new commits since its last CLEAN deploy.
143
+ # Secrets and bindings change outside git use --all. incident:changed-marker
155
144
  svc_paths() { case "$1" in
156
145
  astro) echo "one.ie/web packages" ;;
157
146
  gateway) echo "api" ;;
@@ -203,40 +192,11 @@ say "deploy · mode=$MODE · $(date '+%Y-%m-%d %H:%M:%S')"
203
192
  say "log: $LOG"
204
193
 
205
194
  # ── Cloudflare credential resolution ────────────────────────────────────────
206
- # WHY THIS IS A LADDER AND NOT A LOOKUP (measured 2026-08-19, cost one red
207
- # deploy and one wrong diagnosis):
208
- #
209
- # There were THREE credentials reachable from one `./deploy`, and nothing chose
210
- # between them each of the five services silently got whichever one its own
211
- # cwd happened to surface:
212
- #
213
- # 1. a STALE 37-char global key in the ambient env (injected by
214
- # ~/.claude/settings.json `env`, so it exists in Claude Code sessions and
215
- # NOT in a plain terminal — which is why it was invisible for so long).
216
- # /user → 403. Dead.
217
- # 2. the GOOD 52-char key in one.ie/web/.env + .env.local. /user → 200.
218
- # wrangler 4.x auto-loads `.env` from the CWD, so this one is only
219
- # reachable from one.ie/web — the single directory of the five that has it.
220
- # 3. the `wrangler login` OAuth session, used when neither of the above is in
221
- # scope.
222
- #
223
- # Process env BEATS a per-directory `.env`. So the stale key shadowed the good
224
- # one everywhere, Step 4's whoami probe failed, its `unset` cleared the ambient
225
- # key, and then: Step 6.5 in one.ie/web picked up (2) from `.env` and passed,
226
- # while Step 6.6 in channels — which has no `.env` — fell through to (3) and
227
- # died `code: 7403`. A deploy must never be able to use two different
228
- # credentials in two consecutive steps.
229
- #
230
- # The rules that follow from that:
231
- # · RESOLVE one credential explicitly, from an ordered ladder of sources.
232
- # · Test it with curl against the API — ground truth — never `wrangler
233
- # whoami` in one directory, which answers about a credential the other four
234
- # services will not use.
235
- # · EXPORT the winner so all five run() subshells share it; that also
236
- # neutralises the per-dir `.env` divergence without editing any `.env`.
237
- # · NAME the source in the log (sha8 + length, never bytes).
238
- # · PROVE all five services agree on the account id before shipping.
239
- # · Run all of this BEFORE the 20-minute gates, not after them.
195
+ # A LADDER, not a lookup. Resolve ONE credential from an ordered list, prove it
196
+ # with curl against the API (never `wrangler whoami`, which answers about one
197
+ # directory), EXPORT the winner so all five subshells share it, name the source
198
+ # as sha8+length (never bytes), and prove all five agree on the account id —
199
+ # all of it BEFORE the 20-minute gates. incident:cf-credential-ladder
240
200
  CF_AUTH="" CF_AUTH_SOURCE="" CF_ACCOUNT=""
241
201
 
242
202
  # _cf_probe <email> <key> — 200 from /user means these exact bytes authenticate.
@@ -300,10 +260,8 @@ _cf_resolve() {
300
260
  return 1
301
261
  }
302
262
 
303
- # _cf_agree — every service must be deploying to the SAME account. This is the
304
- # check that would have caught 6.5-vs-6.6 before either ran: one.ie/web
305
- # answering about a different credential than channels IS the bug, and it is
306
- # only visible by asking each directory separately.
263
+ # _cf_agree — every service must deploy to the SAME account, and that is only
264
+ # visible by asking each directory separately. incident:cf-credential-ladder
307
265
  _cf_agree() {
308
266
  (( DRY )) && return 0
309
267
  local d id first="" bad=0
@@ -323,10 +281,8 @@ _cf_agree() {
323
281
  return 0
324
282
  }
325
283
 
326
- # --check-creds — the red half. A gate that has never been seen to FAIL is not
327
- # known to work (repo canon: presence greps are not proof). This plants a key
328
- # that cannot authenticate and asserts the ladder REJECTS it and falls through,
329
- # then asserts the five services agree. Ships nothing.
284
+ # --check-creds — the RED half: a gate never seen to fail is not known to work.
285
+ # Plants a key that cannot authenticate. incident:check-creds-red-half
330
286
  if (( CHECK_CREDS )); then
331
287
  step "credential ladder — self-test"
332
288
  fails=0
@@ -366,6 +322,59 @@ if (( CHECK_CREDS )); then
366
322
  say "log: $LOG"; exit 0
367
323
  fi
368
324
 
325
+ # ── Step 0.1 — the box (BEFORE everything, because everything below is a gate) ──
326
+ # Same reasoning as Step 0.4 one step further back: the cheapest check runs
327
+ # first. health.sh --box costs ~0.2s and owns every number in it (this reads,
328
+ # never restates). The verdict vocabulary is HEALTHY | DEGRADED | UNHEALTHY, and
329
+ # its own --self-test proves each threshold bites.
330
+ #
331
+ # WHY A PAGING BOX IS A DEPLOY CONCERN. A deploy runs five heavy gates. On a box
332
+ # under swap pressure each one runs long, and a slow gate is indistinguishable
333
+ # from a red one at the wall clock — measured 2026-09-05, an 8-fork vitest run
334
+ # showed a >20-minute wall clock that was governor QUEUEING, not runtime. A good
335
+ # tree then gets diagnosed as broken code, which is the most expensive wrong
336
+ # answer this script can give.
337
+ #
338
+ # DEGRADED never refuses: it means headroom of one gate, or orphans that survived
339
+ # a reap, and one gate is what a deploy needs. Only UNHEALTHY stops, and it names
340
+ # the agent that can fix it rather than telling you to wait.
341
+ step "Step 0.1 — box"
342
+ if [ "${DEPLOY_SKIP_DOCTOR:-0}" = "1" ]; then
343
+ say " skipped (DEPLOY_SKIP_DOCTOR=1)"
344
+ elif [ ! -x "$ROOT/.claude/scripts/health.sh" ] && [ ! -f "$ROOT/.claude/scripts/health.sh" ]; then
345
+ say " health.sh absent — box unmeasured (never read this as healthy)"
346
+ else
347
+ _box_json="$( bash "$ROOT/.claude/scripts/health.sh" --box --json 2>/dev/null )"
348
+ _box_v="$(printf '%s' "$_box_json" | sed -n 's/.*"verdict":"\([A-Z]*\)".*/\1/p')"
349
+ _box_why="$(printf '%s' "$_box_json" | sed -n 's/.*"why":"\([^"]*\)".*/\1/p')"
350
+ _box_orph="$(printf '%s' "$_box_json" | sed -n 's/.*"orphans":\([0-9]*\).*/\1/p')"
351
+ case "${_box_v:-unknown}" in
352
+ HEALTHY) ok "box HEALTHY" ;;
353
+ DEGRADED) say " box DEGRADED — ${_box_why:-?} (proceeding: a deploy needs one gate)" ;;
354
+ UNHEALTHY)
355
+ # Reap first, but only when there is something to reap: with orphans=0 the
356
+ # verdict is about memory or headroom and no reaper can move it. Running one
357
+ # anyway is the ritual that makes a refusal look like it tried something.
358
+ if [ "${_box_orph:-0}" != "0" ]; then
359
+ bash "$ROOT/.claude/scripts/gate-reaper.sh" --once 2>/dev/null | sed 's/^/ /'
360
+ _box_json="$( bash "$ROOT/.claude/scripts/health.sh" --box --json 2>/dev/null )"
361
+ _box_v="$(printf '%s' "$_box_json" | sed -n 's/.*"verdict":"\([A-Z]*\)".*/\1/p')"
362
+ _box_why="$(printf '%s' "$_box_json" | sed -n 's/.*"why":"\([^"]*\)".*/\1/p')"
363
+ say " box after reap: ${_box_v:-unknown}"
364
+ fi
365
+ if [ "${_box_v:-unknown}" = "UNHEALTHY" ] && (( ! DRY )); then
366
+ say " ${_box_why:-the box is saturated}"
367
+ say " Hand the box to the doctor, then deploy:"
368
+ say " Agent({ subagent_type: \"doctor\", model: \"opus\","
369
+ say " prompt: \"health.sh says: ${_box_why:-saturated}. Reclaim what nothing is coming back for, then report.\" })"
370
+ say " Override (the gates will be slow, not wrong): DEPLOY_SKIP_DOCTOR=1 ./deploy"
371
+ die "box is UNHEALTHY — refusing to start five gates on it"
372
+ fi
373
+ say " box UNHEALTHY — ${_box_why:-?} (dry run: reporting, refusing nothing)" ;;
374
+ *) say " box verdict unreadable — unmeasured, which is never a pass" ;;
375
+ esac
376
+ fi
377
+
369
378
  # ── Step 0.4 — credentials (BEFORE the slow gates) ──────────────────────────
370
379
  # It was after them: on 2026-08-18 a bad credential was discovered only at Step
371
380
  # 6.5, having already spent the full test suite and a 2m14s production build.
@@ -378,14 +387,8 @@ say " source: $CF_AUTH_SOURCE"
378
387
 
379
388
  # ── Step 0 — tree state (before the slow gates: a dirty tree should fail fast)
380
389
  step "Step 0 — tree"
381
- # Same reasoning as the dirty-tree check, applied to the APPROVAL gate. That
382
- # prompt lives at Step 6, AFTER the gate battery, so a launch that can never
383
- # answer it pays for every typecheck, the whole suite and the astro build and
384
- # only then dies at `aborted at approval`. Measured 2026-09-04: one nohup'd
385
- # deploy burned a 189s green cycle exactly that way, and the box was contended
386
- # enough at the time that the preceding cycle had cost 5508s.
387
- #
388
- # stdin not a TTY means nobody can type "yes". Refuse here, where it is free.
390
+ # stdin not a TTY means nobody can type "yes" at Step 6. Refuse here, where it
391
+ # is free, not after the whole gate battery. incident:approval-needs-a-tty
389
392
  if (( ! ASSUME_YES )) && (( ! DRY )) && [[ "$(git rev-parse --abbrev-ref HEAD)" == "main" ]] && [[ ! -t 0 ]]; then
390
393
  die "non-interactive (stdin is not a TTY) and the Step 6 approval prompt cannot be answered. Pass --yes / -y, or set DEPLOY_YES=1."
391
394
  fi
@@ -404,23 +407,52 @@ else
404
407
  ok "clean tree"
405
408
  fi
406
409
 
407
- # ── Steps 1+3the slow gates, all at once ─────────────────────────────────
408
- # typecheck ×5, vitest and the astro build share no STATE the only thing they
409
- # share is the SDK's dist/, built first if missing. Running them concurrently
410
- # makes W0+build cost max() instead of sum(). Nothing here has a side effect
411
- # outside the working tree, so a red gate still stops the deploy before
412
- # anything ships.
410
+ # ── Step 0.5generated artifacts (cheap, before the slow gates) ─────────
411
+ # One cheap deterministic check, run where failing is free. It exits non-zero on
412
+ # drift AND on failing to run, and both stop the deploy.
413
413
  #
414
- # They are NOT independent in COST, and the comment here used to claim they
415
- # were. This box has 10 cores / 4 performance cores; the block above spawns 5
416
- # tsc, an 8 GiB-heap astro build, and a vitest that forks its own worker pool.
417
- # Anything inside the suite that shells out to a heavy child competes with all
418
- # of it measured 2026-08-08, tests/integration/reconcile.test.ts (which runs
419
- # a full-repo tsc of its own) went 53s standalone 376s here and blew its
420
- # budget, failing the PASSING direction and blocking a deploy over nothing.
421
- # Test budgets in this repo must therefore be sized for a STARVED run, not a
422
- # quiet one. If a gate ever needs the box to itself, take it out of this block
423
- # rather than widening every timeout downstream.
414
+ # THE DRY BRANCH IS EXPLICIT: `run` returns 0 under --dry-run, so
415
+ # `if run ...; then ok` would print GREEN for a check that never executed — an
416
+ # unrun gate reported as a pass. incident:blocks-manifest-unrun
417
+ # ── Step 0.5a deps present (0.05s, and it buys ~290s) ─────────────────────
418
+ # Every service this run will typecheck must have a node_modules. A missing one
419
+ # does not read as missing: tsc reports `TS2688: Cannot find type definition file
420
+ # for '@cloudflare/workers-types'` a TypeScript error naming TypeScript and
421
+ # it surfaces at the END of the gate fan-out, ~290s in, on code that is fine.
422
+ # Measured 2026-09-17 at the ship gate: api and sync red in all four worktrees at
423
+ # once, because worktree-up.sh's LINKS list carried neither. That list is fixed;
424
+ # this check is the half that cannot rot, because it holds for a tree materialised
425
+ # any other way.
426
+ step "Step 0.5a — deps"
427
+ _deps_missing=()
428
+ for _svc in "${SERVICES_TYPECHECK[@]}"; do
429
+ [ -e "$ROOT/$_svc/node_modules" ] || _deps_missing+=("$_svc")
430
+ done
431
+ if (( ${#_deps_missing[@]} )); then
432
+ for _svc in "${_deps_missing[@]}"; do say " ✗ $_svc/node_modules missing"; done
433
+ say " A worktree is not \`git worktree add\` — that command omits every gitignored path."
434
+ say " Link them: bash .claude/scripts/worktree-up.sh <name> --no-dev"
435
+ say " Or by hand: ln -sfn <main-tree>/$'{'svc'}'/node_modules \$svc/node_modules"
436
+ die "${#_deps_missing[@]} service(s) cannot typecheck — refusing at t≈7s rather than t≈290s"
437
+ fi
438
+ ok "${#SERVICES_TYPECHECK[@]}/${#SERVICES_TYPECHECK[@]} services have deps"
439
+
440
+ step "Step 0.5 — generated artifacts"
441
+ if (( DRY )); then
442
+ say " [dry] node .claude/scripts/blocks-manifest.mjs --check"
443
+ elif run "blocks-manifest --check" . node "$ROOT/.claude/scripts/blocks-manifest.mjs" --check; then
444
+ ok "block manifest: no drift"
445
+ else
446
+ tail -5 "$LOG" | sed 's/^/ /'
447
+ die "block manifest DRIFT (or --check could not run) — regenerate with 'node .claude/scripts/blocks-manifest.mjs' and commit all three artifacts"
448
+ fi
449
+
450
+ # ── Steps 1+3 — the slow gates, all at once ─────────────────────────────────
451
+ # They share no STATE (only the SDK's dist/, built first), so concurrency makes
452
+ # W0+build cost max() instead of sum(). They are NOT independent in COST:
453
+ # TEST BUDGETS HERE MUST BE SIZED FOR A STARVED RUN, not a quiet one, and a gate
454
+ # that needs the box to itself leaves this block rather than widening every
455
+ # timeout downstream. incident:gates-not-independent-in-cost
424
456
 
425
457
  # web + channels resolve @oneie/sdk types out of packages/sdk/dist — an unbuilt
426
458
  # dist fakes a wall of TS2307, so this one is a real dependency, done serially.
@@ -431,33 +463,15 @@ if ! (( SKIP_TYPECHECK )) && [[ ! -d packages/sdk/dist ]]; then
431
463
  fi
432
464
 
433
465
  # ── heavy-gate concurrency, priced in MEMORY not cores ──────────────────────
434
- # The five typechecks are cheap (~1s each, tree-fingerprint cached). The other
435
- # two are not: the astro build runs with --max-old-space-size=8192 and vitest
436
- # runs a driver plus 4 forks (~6GB). Both at once wants ~14GB, and this box has
437
- # 24GB with editors and sessions already resident.
438
- #
439
- # Cores were never the binding constraint, so price the gates in memory. What
440
- # this DOES buy: two gates that want ~14GB no longer overlap on a 24GB box.
441
- #
442
- # What it does NOT buy, and an earlier version of this comment wrongly claimed:
443
- # it is not the fix for the vitest gate HANGING. That was reproduced with the
444
- # gates serialised, the box at 41% free, no swap thrash and no sockets held —
445
- # see .claude/commands/deploy.md "The vitest gate hangs". Serialising addresses
446
- # a slow gate; the bound below is what addresses a parked one.
447
- #
448
- # Same reasoning as lib/govern.sh gate_headroom, but priced for THESE two gates
449
- # rather than a generic ~2GB cycle. A probe that cannot read memory returns a
450
- # large number — a broken sensor must never silently serialise the pipeline.
466
+ # Cores were never the binding constraint. Serialising addresses a SLOW gate; it
467
+ # is NOT the fix for the vitest gate hanging the bound below is. A probe that
468
+ # cannot read memory returns a large number: a broken sensor must never silently
469
+ # serialise the pipeline. incident:heavy-gates-priced-in-memory
451
470
  HEAVY_NEED_GB="${DEPLOY_HEAVY_NEED_GB:-14}" # build 8 + vitest ~6
452
471
  # shellcheck source=lib/govern.sh
453
472
  . "$ROOT/.claude/scripts/lib/govern.sh"
454
- # This used to be `hw.memsize * memory_pressure "free percentage" / 100`, which
455
- # is the defect lib/govern.sh documents at length: that percentage counts file
456
- # cache, purgeable and compressed pages as free. Measured 2026-09-03 it read 74%
457
- # on a box with 1.3% actually free and 9.4 GB in swap — so this gate overlapped
458
- # two ~7 GB gates on a machine that had ~3 GB. gate_mem_avail_mb is the honest
459
- # quantity. Unreadable probe still returns 999: a broken sensor must never
460
- # silently serialise the pipeline.
473
+ # gate_mem_avail_mb, never memory_pressure's "free percentage" that counts
474
+ # cache and compressed pages as free. incident:heavy-gates-priced-in-memory
461
475
  heavy_free_gb() {
462
476
  local avail_mb
463
477
  avail_mb="$(gate_mem_avail_mb 2>/dev/null || echo "")"
@@ -477,19 +491,63 @@ else
477
491
  say " (overlapping them pages; measured 1145s vs 176s — see deploy.sh)"
478
492
  fi
479
493
 
494
+ # ── the run, streamed into the world ────────────────────────────────────────
495
+ # THREE RULES, all about not making the telemetry load-bearing: it can never FAIL
496
+ # the deploy (backgrounded, `|| true`) · it can never SLOW the deploy (`&`) ·
497
+ # --dry-run emits NOTHING. A dropped emit degrades to a GAP in the trace, never
498
+ # to a lie. incident:deploy-emit-never-load-bearing
499
+ # An `if`, not `$( case ... esac )`: bash 3.2 closes `$(` at the FIRST `)`, and a
500
+ # case pattern ends in one — it wrote a literal into deploy-runs.json, which is
501
+ # the one degradation the rules above forbid. incident:deploy-emit-never-load-bearing
502
+ if [ "$MODE" = dev ]; then EMIT_TARGET="dev.one.ie"; else EMIT_TARGET="one.ie"; fi
503
+ EMIT_ENV="$( [[ "$MODE" == dev ]] && echo dev || echo prod )"
504
+
505
+ # Anything unmapped emits NOTHING rather than inventing a stage — better absent
506
+ # than mislabelled. incident:deploy-emit-never-load-bearing
507
+ _spine_stage() {
508
+ case "$1" in
509
+ tsc-*) echo typecheck ;;
510
+ vitest) echo tests ;;
511
+ build) echo build ;;
512
+ *) echo "" ;;
513
+ esac
514
+ }
515
+
516
+ # The verdict CLOSES the run, and is a field of its own — --gates-only is a
517
+ # complete run that never reaches ship. incident:deploy-verdict-closes-the-run
518
+ _emit_verdict() {
519
+ local v="$1"
520
+ _emit --verdict "$v" \
521
+ --detail "shipped=${TARGETS[*]:-none}" \
522
+ --detail "wallSec=$(( SECONDS ))" \
523
+ --detail "mode=${MODE}" \
524
+ --detail "heavyParallel=$( (( ${HEAVY_PARALLEL:-0} )) && echo true || echo false )" \
525
+ --detail "memFreeGb=${HEAVY_FREE_GB:-0}"
526
+ # Give the backgrounded emits a moment to land. Bounded and ignored: the run
527
+ # is closing either way, and a deploy must not wait on its own telemetry.
528
+ wait -n 2>/dev/null || true
529
+ }
530
+
531
+ # --slug is load-bearing: without it prod answers `forbidden` to every frame.
532
+ # incident:ask-slug-load-bearing
533
+ _emit() { # ...deploy-emit.sh args
534
+ [[ -x "$ROOT/.claude/scripts/deploy-emit.sh" ]] || return 0
535
+ (( DRY )) && return 0
536
+ ( bash "$ROOT/.claude/scripts/deploy-emit.sh" \
537
+ --run "$STAMP" --target "$EMIT_TARGET" --env "$EMIT_ENV" \
538
+ --slug "${DEPLOY_SLUG:-one}" \
539
+ --door "deploy${MODE:+ $MODE}" \
540
+ --sha "$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo '')" \
541
+ "$@" >/dev/null 2>&1 || true ) &
542
+ return 0
543
+ }
544
+
480
545
  GATE_NAMES=() GATE_PIDS=() GATE_LOGS=() GATE_RC=() GATE_T=() GATE_DUR=()
481
546
  GATE_BOUND="${DEPLOY_GATE_TIMEOUT:-900}" # healthy vitest 176s, build 174s
482
- # Typechecks were run bare on the claim that they are "cached ~1s each". They
483
- # are NOT: no tsconfig in this repo sets `incremental`/`tsBuildInfoFile`, so
484
- # every one is a full check. Measured 2026-08-23: a single one held 1.5GB for
485
- # 12m, and five run in parallel — ~7.5GB of fan-out that the HEAVY_NEED_GB test
486
- # below never covered, because it prices only vitest+build. On a cold worktree
487
- # that is the whole melt (load 52, swap 20GB).
488
- #
489
- # So price them the same way: if the box cannot hold the fan-out, route them
490
- # through the semaphore. deploy.sh holds no slot itself, so the nested calls
491
- # really do queue (gate-run.sh is re-entrant — this would be cosmetic if an
492
- # outer slot were held). A warm, healthy box still runs them bare and parallel.
547
+ # Typechecks are NOT "cached ~1s each" no tsconfig sets `incremental`, so five
548
+ # full checks fan out to ~7.5GB that HEAVY_NEED_GB never priced. If the box
549
+ # cannot hold the fan-out, route them through the semaphore; a warm box runs them
550
+ # bare and parallel. incident:tsc-not-cached-on-ship
493
551
  TSC_EACH_GB="${DEPLOY_TSC_EACH_GB:-2}"
494
552
  TSC_NEED_GB=$(( TSC_EACH_GB * ${#SERVICES_TYPECHECK[@]} ))
495
553
  if (( HEAVY_FREE_GB >= TSC_NEED_GB )); then TSC_GOVERNED=0; else TSC_GOVERNED=1; fi
@@ -502,6 +560,10 @@ gate_start() { # <name> <dir> <cmd...>
502
560
  ( cd "$ROOT/$dir" && unset CLOUDFLARE_API_TOKEN && "$@" ) >"$out" 2>&1 &
503
561
  GATE_NAMES+=("$name"); GATE_PIDS+=("$!"); GATE_LOGS+=("$out"); GATE_RC+=(-1)
504
562
  GATE_T+=("$SECONDS"); GATE_DUR+=(-1)
563
+ # The `start` frame is what makes a run render as IN PROGRESS rather than
564
+ # appearing whole at the end. Emitted per LAUNCH, so a serialised gate's
565
+ # start is the moment it really started, not the moment the batch did.
566
+ { st="$(_spine_stage "$name")"; [[ -n "$st" ]] && _emit --stage "$st" --status start; } || true
505
567
  say " → $name (pid $!)"
506
568
  }
507
569
  # Heavy gates run under gate-run.sh for its BOUND and its process-group REAP:
@@ -516,12 +578,15 @@ gate_start_governed() { # <name> <dir> <cmd...>
516
578
  exec "$ROOT/.claude/scripts/gate-run.sh" "deploy-$name" -- "$@" ) >"$out" 2>&1 &
517
579
  GATE_NAMES+=("$name"); GATE_PIDS+=("$!"); GATE_LOGS+=("$out"); GATE_RC+=(-1)
518
580
  GATE_T+=("$SECONDS"); GATE_DUR+=(-1)
581
+ # The `start` frame is what makes a run render as IN PROGRESS rather than
582
+ # appearing whole at the end. Emitted per LAUNCH, so a serialised gate's
583
+ # start is the moment it really started, not the moment the batch did.
584
+ { st="$(_spine_stage "$name")"; [[ -n "$st" ]] && _emit --stage "$st" --status start; } || true
519
585
  say " → $name (pid $!, bound ${GATE_BOUND}s)"
520
586
  }
521
- # gate_start_heavy — same, but when memory says the two heavy gates cannot
522
- # overlap, block until the previous heavy gate has finished. Its exit status is
523
- # recorded in GATE_RC so the collection loop below never waits on a reaped pid
524
- # (a second `wait` on the same child returns 127 and would read as a failure).
587
+ # gate_start_heavy — blocks until the previous heavy gate finished when memory
588
+ # says they cannot overlap, and records its status in GATE_RC so the collection
589
+ # loop never waits on a reaped pid. incident:gate-start-heavy-wait-127
525
590
  HEAVY_IDX=()
526
591
  gate_start_heavy() {
527
592
  if (( ! DRY )) && (( ! HEAVY_PARALLEL )) && (( ${#HEAVY_IDX[@]} )); then
@@ -541,15 +606,20 @@ if ! (( SKIP_TYPECHECK )); then
541
606
  else
542
607
  say " memory: ${HEAVY_FREE_GB}GB free >= ${TSC_NEED_GB}GB needed — typechecks bare (parallel)"
543
608
  fi
544
- # NOT routed through tsc-cached.sh, and that is a measurement, not an oversight.
545
- # Measured 2026-09-01 on a warm box: one.ie/web 4s · api 0s · sync 0s ·
546
- # channels 2s · pay/backend 2s -- 8s serial, ~4s of wall-clock in parallel, and
547
- # they finish inside the vitest gate's shadow anyway, so caching them buys zero
548
- # seconds off the deploy. It would also COST something: tsc-cached takes the
549
- # per-folder `tsc-<folder>` gate_lock that do-reconcile.sh takes, so a /do cycle
550
- # typechecking one.ie/web would make this gate queue up to TSC_CACHE_WAIT (900s)
551
- # and then exit 1 -- a red ship gate caused by lock contention rather than by
552
- # code. Four seconds is not worth that trade.
609
+ # NOT routed through tsc-cached.sh, and that is a measurement: it would buy
610
+ # zero seconds and could turn a ship gate red on LOCK CONTENTION rather than on
611
+ # code. incident:tsc-not-cached-on-ship
612
+ # CONTENT TYPES FIRST, and worth its four seconds. Raw `tsc --noEmit` does not
613
+ # sync, `DataEntryMap` is generated into the gitignored .astro/, and a TS2344
614
+ # on a collection key is an ASTRO fault wearing a TypeScript error. Must run
615
+ # BEFORE the fan-out, or a sync races the tscs already reading those types.
616
+ # incident:astro-content-sync-before-tsc
617
+ if (( want_astro )) && [[ -d one.ie/web ]]; then
618
+ ( cd one.ie/web && NODE_OPTIONS=--max-old-space-size=4096 \
619
+ ./node_modules/.bin/astro sync >/dev/null 2>&1 ) \
620
+ && ok "content types synced" \
621
+ || warn "astro sync failed — a new content collection will read as a tsc error"
622
+ fi
553
623
  for svc in "${SERVICES_TYPECHECK[@]}"; do
554
624
  if (( TSC_GOVERNED )); then
555
625
  gate_start_governed "tsc-$(echo "$svc" | tr / -)" "$svc" bunx tsc --noEmit
@@ -559,25 +629,75 @@ if ! (( SKIP_TYPECHECK )); then
559
629
  done
560
630
  fi
561
631
  # the suite lives in one.ie/web and gates that worker
562
- # The vitest gate hangs when stdout is not a TTY (fork spawned, never given
563
- # work — see deploy.md). `script -qeF` allocates a pty and flushes so the
564
- # runner matches a green standalone terminal; `--reporter=dot` is the other
565
- # half of that unstick (default reporter still hung under script).
566
- # test-cached.sh keys a PASS on the exact inputs -- args + selected test files +
567
- # `git diff HEAD` by CONTENT + untracked contents + HEAD. deploy refuses a dirty
568
- # tree, so on the normal path the key collapses to (args, HEAD, test-file set):
569
- # a full suite already proven green at this exact commit is not run twice. Only
570
- # passes are memoised, so a red suite re-runs every time until it is green.
632
+ # The vitest gate hangs on a non-TTY stdout; `script -qeF` + `--reporter=dot` is
633
+ # the unstick. incident:vitest-pty-and-memo-probe
634
+ # TEST_CACHE_PTY=1 is not optional (this gate's stdout is a log file), the argv
635
+ # lives in test-full.sh so /close and deploy share one stamp, and the memo is
636
+ # PROBED BEFORE A SLOT IS TAKEN a hit needs no memory, no forks and no slot.
637
+ # incident:vitest-pty-and-memo-probe
638
+ # incident:memo-hit-is-not-all-pass
571
639
  #
572
- # TEST_CACHE_PTY=1 is not optional here: this gate's stdout is a log file, and the
573
- # runner hangs on a non-TTY stdout (a fork is spawned and never given work). The
574
- # wrapper is the same `script -qeF /dev/null` that fixed it, moved inside the memo
575
- # so the cached and uncached paths run byte-identical commands.
576
- # The vitest ARGS used to live here, spelled out. They now live in test-full.sh,
577
- # which /close reaches too -- test-cached keys on argv, so two spellings of the
578
- # same suite could never share a stamp and a warm tree paid it twice.
579
- (( SKIP_TESTS || ! want_astro )) || gate_start_heavy vitest one.ie/web \
580
- env TEST_CACHE_PTY=1 bash "$ROOT/.claude/scripts/test-full.sh"
640
+ # >>> vitest-memo-probe (extracted VERBATIM by deploy-gate-check.sh keep self-contained)
641
+ # _memo_probe "is EVERY lane already a green stamp for this exact tree?" Runs
642
+ # nothing, computes nothing, writes nothing.
643
+ # 0 = every lane HIT (stdout: one `already PASSED <ts>` line per lane)
644
+ # 1 = at least one lane MISSES · 2 = cannot answer
645
+ # Anything but 0 takes the slot and runs the gate: an unrun check is not a pass.
646
+ # It asks per LANE, never `TEST_CACHE_PROBE=1 test-full.sh` that would skip the
647
+ # whole gate on a pool-only hit. incident:vitest-pty-and-memo-probe
648
+ _memo_probe() {
649
+ local cache="${TEST_CACHE_DIR:-${TMPDIR:-/tmp}/one-test-cache}"
650
+ [[ "${TEST_CACHE_DISABLE:-0}" == "1" ]] && return 2
651
+ local keys
652
+ keys="$( TEST_CACHE_KEY_ONLY=1 bash "$ROOT/.claude/scripts/test-full.sh" 2>/dev/null \
653
+ | grep -Eo '^[0-9a-f]{64}$' )"
654
+ [[ -n "$keys" ]] || return 2 # no key ⇒ no answer ⇒ run the gate
655
+ local k f hit n=0
656
+ while IFS= read -r k; do
657
+ [[ -n "$k" ]] || continue
658
+ n=$((n+1)); hit=""
659
+ for f in "$cache"/*"$k".pass; do
660
+ if [[ -f "$f" ]]; then hit="$f"; break; fi
661
+ done
662
+ [[ -n "$hit" ]] || return 1 # one miss is a miss: the gate runs
663
+ printf ' lane %s — identical inputs already PASSED %s\n' \
664
+ "${k:0:12}" "$(cat "$hit" 2>/dev/null)"
665
+ done <<< "$keys"
666
+ (( n )) || return 2
667
+ return 0
668
+ }
669
+ if (( SKIP_TESTS || ! want_astro )); then
670
+ : # --skip-tests keeps its own meaning
671
+ else
672
+ _mp_t0=$SECONDS
673
+ _mp_out="$(_memo_probe)"; _mp_rc=$?
674
+ _mp_dur=$(( SECONDS - _mp_t0 ))
675
+ if (( _mp_rc == 0 )) && (( DRY )); then
676
+ say " [dry] vitest gate would be REUSED from the memo (no slot taken)"
677
+ elif (( _mp_rc == 0 )); then
678
+ # NOT via gate_start_heavy and not in HEAVY_IDX: a gate with no process is
679
+ # not a heavy gate, and `wait 0` on a non-child exits 127.
680
+ # incident:gate-start-heavy-wait-127
681
+ _mp_log="$LOG_DIR/vitest-$STAMP.log"
682
+ { echo "[deploy] vitest gate SKIPPED by the memo probe — every lane of the full suite"
683
+ echo "[deploy] is already a green stamp for this exact tree. No governor slot taken."
684
+ printf '%s\n' "$_mp_out"
685
+ echo "[deploy] probe cost ${_mp_dur}s"
686
+ } > "$_mp_log"
687
+ GATE_NAMES+=(vitest); GATE_PIDS+=(0); GATE_LOGS+=("$_mp_log"); GATE_RC+=(0)
688
+ GATE_T+=("$SECONDS"); GATE_DUR+=("$_mp_dur")
689
+ { st="$(_spine_stage vitest)"; [[ -n "$st" ]] && _emit --stage "$st" --status start; } || true
690
+ say " → vitest (memo HIT in ${_mp_dur}s — no slot taken)"
691
+ else
692
+ case "$_mp_rc" in
693
+ 1) say " memo probe: MISS — no green stamp for at least one lane of this tree" ;;
694
+ *) say " memo probe: could not answer — running the gate (an unrun check is not a pass)" ;;
695
+ esac
696
+ gate_start_heavy vitest one.ie/web \
697
+ env TYPEDB_LANE_NONBLOCKING=1 TEST_CACHE_PTY=1 bash "$ROOT/.claude/scripts/test-full.sh"
698
+ fi
699
+ fi
700
+ # <<< vitest-memo-probe
581
701
  if (( want_astro )); then
582
702
  if (( SKIP_BUILD )); then
583
703
  [[ -d one.ie/web/dist/server ]] || die "--skip-build but one.ie/web/dist/server/ does not exist"
@@ -595,6 +715,11 @@ for i in $(seq 0 $(( ${#GATE_NAMES[@]} - 1 )) ); do
595
715
  elif wait "${GATE_PIDS[$i]}"; then rc=0; else rc=1; fi
596
716
  GATE_RC[$i]=$rc # record it: the deploy receipt below reads results, not just timings
597
717
  (( ${GATE_DUR[$i]} < 0 )) && GATE_DUR[$i]=$(( SECONDS - GATE_T[$i] ))
718
+ # …and onto the stream. ONE hook for the three gates that are ~95% of the wall
719
+ # clock. Deferred past the `case` for `tests`, whose reused/waived facts are not
720
+ # known at this line. incident:deploy-emit-never-load-bearing
721
+ _emit_stage="$(_spine_stage "$name")"
722
+ _emit_dur=$(( GATE_DUR[i] < 0 ? 0 : GATE_DUR[i] ))
598
723
  cat "$glog" >>"$LOG"
599
724
  case "$name" in
600
725
  tsc-*)
@@ -602,10 +727,8 @@ for i in $(seq 0 $(( ${#GATE_NAMES[@]} - 1 )) ); do
602
727
  if (( rc )); then bad "${name#tsc-} typecheck FAILED"; gate_fail+=("$name")
603
728
  else tc_pass=$((tc_pass+1)); fi ;;
604
729
  vitest)
605
- # The suite now runs as TWO lanes, so the log carries TWO "Tests N passed"
606
- # lines and `tail -1` reported only the last one — the 19-file typedb lane.
607
- # A deploy that ran 10940 tests said "143 passed", which understates the
608
- # gate by two orders of magnitude and reads like a partial run. Sum them.
730
+ # TWO lanes means TWO "Tests N passed" lines — SUM them; `tail -1` reported
731
+ # 143 for a 10940-test run. incident:two-lanes-tail-1-understates
609
732
  TESTS_REPORT="$(awk '
610
733
  /Tests +[0-9]+ (passed|failed)/ {
611
734
  for (i = 1; i <= NF; i++) {
@@ -614,10 +737,8 @@ for i in $(seq 0 $(( ${#GATE_NAMES[@]} - 1 )) ); do
614
737
  }
615
738
  n++
616
739
  }
617
- # vitest reports UNHANDLED errors on their own line "Errors 45 errors" —
618
- # and exits non-zero on them with ZERO failed tests. A verdict that reads
619
- # only the Tests line printed "11327 passed" for a red run and the flake
620
- # checker called it GREEN (measured 2026-09-05). Read the Errors line too.
740
+ # Read the Errors line too: vitest exits non-zero on UNHANDLED errors
741
+ # with ZERO failed tests. incident:two-lanes-tail-1-understates
621
742
  /Errors +[0-9]+ errors?/ { for (i = 1; i <= NF; i++) if ($i ~ /^errors?$/) e += $(i-1) + 0 }
622
743
  END {
623
744
  if (n == 0) exit
@@ -648,12 +769,14 @@ for i in $(seq 0 $(( ${#GATE_NAMES[@]} - 1 )) ); do
648
769
  else
649
770
  gate_fail+=(vitest)
650
771
  fi
772
+ elif grep -q 'SKIPPED by the memo probe' "$glog"; then
773
+ # A reused pass is never reported as "all pass", and the stamp's own
774
+ # timestamp rides along. incident:memo-hit-is-not-all-pass
775
+ TESTS_REPORT="full suite REUSED — identical inputs already passed (memo probe, no slot taken: $(grep -o 'already PASSED.*' "$glog" | tail -1))"
776
+ ok "$TESTS_REPORT"
651
777
  elif grep -q 'cache HIT' "$glog"; then
652
- # Say WHICH it was. A reused pass and a fresh run are both green, but
653
- # reporting a memo hit as "all pass" is the same class of dishonesty as
654
- # reporting a fast lane as a full one -- the operator cannot tell whether
655
- # 10,780 tests just executed or whether an earlier identical tree's
656
- # result was replayed. Both are sound; only one of them took 20 minutes.
778
+ # Say WHICH it was both are sound, only one took 20 minutes.
779
+ # incident:memo-hit-is-not-all-pass
657
780
  TESTS_REPORT="full suite REUSED — identical inputs already passed ($(grep -o 'already PASSED.*' "$glog" | tail -1))"
658
781
  ok "$TESTS_REPORT"
659
782
  else
@@ -663,6 +786,27 @@ for i in $(seq 0 $(( ${#GATE_NAMES[@]} - 1 )) ); do
663
786
  if (( rc )); then bad "astro build FAILED"; gate_fail+=(build)
664
787
  else BUILD_REPORT="ok"; ok "astro build clean"; fi ;;
665
788
  esac
789
+ # AFTER the case: `tests` only learns reused/waived inside it, and those two
790
+ # booleans are the whole reason this stream exists. A waived pass is a FACT on
791
+ # the run. incident:waived-pass-is-a-fact
792
+ if [[ -n "$_emit_stage" ]]; then
793
+ _emit_args=(--stage "$_emit_stage" --wall-ms "$(( _emit_dur * 1000 ))")
794
+ if (( rc )) && ! { [[ "$_emit_stage" == tests ]] && (( TESTS_WAIVED )); }; then
795
+ _emit_args+=(--status fail --reason "${name}")
796
+ else
797
+ _emit_args+=(--status ok)
798
+ fi
799
+ case "$_emit_stage" in
800
+ tests)
801
+ _emit_args+=(--detail "reused=$( [[ "$TESTS_REPORT" == *REUSED* ]] && echo true || echo false )")
802
+ _emit_args+=(--detail "waived=$( (( TESTS_WAIVED )) && echo true || echo false )") ;;
803
+ typecheck)
804
+ # Which of the five, not just how many — a reader chasing a red gate
805
+ # should not have to open the log to learn the service.
806
+ _emit_args+=(--detail "service=${name#tsc-}") ;;
807
+ esac
808
+ _emit "${_emit_args[@]}"
809
+ fi
666
810
  done
667
811
  (( tc_total )) && {
668
812
  TYPECHECK_REPORT="$tc_pass/$tc_total services clean"
@@ -670,34 +814,29 @@ done
670
814
  }
671
815
  if (( ${#GATE_NAMES[@]} )); then
672
816
  GATE_WALL="$((SECONDS - GATE_T0))s"
673
- # Per-gate, not just the aggregate. The gates overlap, so the total is max()
674
- # over the parallel set and sum() over the serialised heavies -- without the
675
- # breakdown there is no way to tell which one is actually the critical path,
676
- # and "make the deploy faster" turns into caching whatever finishes inside
677
- # vitest's shadow for zero seconds.
817
+ # Per-gate, not just the aggregate without the breakdown "make the deploy
818
+ # faster" caches whatever finishes inside vitest's shadow for zero seconds.
819
+ # incident:deploy-verdict-closes-the-run
678
820
  for i in $(seq 0 $(( ${#GATE_NAMES[@]} - 1 )) ); do
679
821
  say " ${GATE_NAMES[$i]}: ${GATE_DUR[$i]}s"
680
822
  done
681
823
  say " gates wall-clock: $GATE_WALL"
824
+ # Repeat the branch HERE, next to the durations it explains. Without it a
825
+ # reader comparing two runs cannot tell a slow gate from a serialised one.
826
+ if (( HEAVY_PARALLEL )); then
827
+ say " heavy gates: OVERLAPPED (${HEAVY_FREE_GB}GB free >= ${HEAVY_NEED_GB}GB)"
828
+ else
829
+ say " heavy gates: SERIALISED (${HEAVY_FREE_GB}GB free < ${HEAVY_NEED_GB}GB) — the 6.5x branch"
830
+ fi
682
831
  [[ "$BUILD_REPORT" == ok ]] && BUILD_REPORT="$GATE_WALL (overlapped)"
683
832
  fi
684
833
  (( ${#gate_fail[@]} )) && die "gates red: ${gate_fail[*]} — nothing deployed"
685
834
 
686
835
  # ── deferred-pin debt ───────────────────────────────────────────────────────
687
- # verify-fast.sh may DEFER its pinned integration suites mid-plan and record the
688
- # debt in a machine-keyed ledger. That debt is meant to come due at the close
689
- # gate. deploy is the LAST place it can come due, and until now it could not:
690
- # deploy sets FULL_VERIFY=1 but invokes vitest directly, so it never reached the
691
- # branch in verify-fast.sh that settles the ledger. A production deploy could
692
- # therefore ship over pins that no run had ever executed.
693
- #
694
- # Two rules, both one-directional:
695
- # · a GREEN full suite PAYS the debt. The suite is a superset of every pin, so
696
- # reaching here green is the payment -- same reasoning verify-fast uses.
697
- # · anything else REFUSES. A skipped suite does not pay a debt; neither does a
698
- # red one (we never get here) nor a `--gates-only` run that skipped tests.
699
- # An unpaid debt with no run to settle it is an unrun gate, and an unrun gate
700
- # is not a passing gate.
836
+ # deploy is the LAST place verify-fast's deferred pins can come due. Two rules,
837
+ # both one-directional: a GREEN full suite PAYS the debt (the suite is a superset
838
+ # of every pin) · anything else REFUSES. An unpaid debt with no run to settle it
839
+ # is an unrun gate. incident:deferred-pin-debt
701
840
  DEBT_FILE="${VERIFY_FAST_DEBT_FILE:-${TMPDIR:-/tmp}/one-verify-fast-debt}"
702
841
  if [[ -s "$DEBT_FILE" ]]; then
703
842
  step "Deferred-pin debt"
@@ -718,6 +857,10 @@ fi
718
857
  if (( GATES_ONLY )); then
719
858
  say ""; ok "gates green in ${GATE_WALL:-0s} — nothing shipped (--gates-only)"
720
859
  say " Typecheck: $TYPECHECK_REPORT"; say " Tests: $TESTS_REPORT"; say " Log: $LOG"
860
+ # CLOSE THE RUN — the --gates-only exit, which every release.sh promote takes.
861
+ # Without this line the run sits `open` for ever.
862
+ # incident:deploy-verdict-closes-the-run
863
+ _emit_verdict green
721
864
  exit 0
722
865
  fi
723
866
 
@@ -782,15 +925,9 @@ if (( want_astro )) && ! (( SKIP_MIGRATIONS )); then
782
925
  fi
783
926
 
784
927
  # ── Step 6.6 — D1 migrations (channels) ─────────────────────────────────────
785
- # A SECOND database. The step above runs in one.ie/web, where `DB` is
786
- # `one-owners`; channels binds `DB` to `claw` and keeps its own
787
- # channels/migrations/. Nothing applied that directory: the step above never
788
- # enters it, and channels' own `bun run migrate` was pinned to 0001_init.sql.
789
- # So 0010_action_bindings.sql shipped with the C6 worker code and its table was
790
- # never created — and because bindAction's INSERT was fire-and-forget, every
791
- # button binding failed silently, leaving taps that resolve to nothing. The
792
- # promise's accept (`ls channels/migrations/*action*.sql`) proved the FILE
793
- # existed, never the table. Same rule as above: schema before code.
928
+ # A SECOND database: channels binds `DB` to `claw` and keeps its own migrations,
929
+ # and nothing applied that directory. Same rule as 6.5: schema before code.
930
+ # incident:channels-second-d1
794
931
  MIG_CHAN_REPORT="skipped"
795
932
  if (( want_agents )) && ! (( SKIP_MIGRATIONS )); then
796
933
  step "Step 6.6 — D1 migrations (channels DB, remote)"
@@ -892,20 +1029,11 @@ else
892
1029
  (( want_sync )) && HEALTH_REPORT="$HEALTH_REPORT + sync deploy-confirmed"
893
1030
 
894
1031
  # ── deploy receipt ────────────────────────────────────────────────────────
895
- # The last deploy's gate timings and per-worker outcome, read by the operator
896
- # deploy dashboard at /u/one/deploy. Written HERE because health is the last
897
- # fact it needs. Three honesty rules the data itself forces:
898
- #
899
- # * gates in this file are only ever GREEN — `die "gates red"` above exits
900
- # long before Step 8, so a red gate never reaches a receipt. The page says
901
- # that out loud rather than letting "all pass" imply a full audit.
902
- # * `healthy` is null for sync. It is never probed (cron-only — the line two
903
- # above), and a worker nobody asked must not render healthy OR unhealthy.
904
- # * `deployed` comes from BG_NAMES — what actually uploaded — never from
905
- # TARGETS, which is only the intent.
906
- #
907
- # Wrapped in `{ … } || true` so a failed write can never fail a deploy that
908
- # has already shipped.
1032
+ # Three honesty rules the data itself forces: gates here are only ever GREEN (a
1033
+ # red one died long before Step 8) · `healthy` is NULL for sync, which is never
1034
+ # probed · `deployed` is BG_NAMES (what uploaded), never TARGETS (the intent).
1035
+ # `|| true` so a failed write cannot fail a shipped deploy.
1036
+ # incident:deploy-receipt-honesty
909
1037
  _receipt="$ROOT/one.ie/web/src/lib/generated/deploy-receipt.json"
910
1038
  {
911
1039
  mkdir -p "$(dirname "$_receipt")"
@@ -930,10 +1058,8 @@ else
930
1058
  (( ${#BG_NAMES[@]} )) || break
931
1059
  _n="${BG_NAMES[$i]}"
932
1060
  if [[ "$_n" == sync ]]; then _h=null # never probed
933
- # `${hfail[*]-}` not `${hfail[*]}`: under `set -u` bash 3.2 treats an EMPTY
934
- # array as unbound, and hfail is empty exactly when every probe passed —
935
- # so a perfect deploy died here (line 933, 2026-09-05), truncated its own
936
- # receipt mid-JSON and reported exit 1. Same trap test-lanes.sh already paid.
1061
+ # `${hfail[*]-}` not `${hfail[*]}` — bash 3.2 under `set -u` treats an
1062
+ # EMPTY array as unbound. incident:empty-array-unbound-bash32
937
1063
  elif [[ " ${hfail[*]-} " == *" $_n "* ]]; then _h=false
938
1064
  else _h=true; fi
939
1065
  printf '%s\n {"name": "%s", "deployed": true, "healthy": %s}' "$_sep" "$_n" "$_h"
@@ -947,17 +1073,9 @@ fi
947
1073
 
948
1074
  # ── Report ──────────────────────────────────────────────────────────────────
949
1075
  step "Step 9 — speed (advisory)"
950
- # Post-deploy Lighthouse reading of the LIVE site. ADVISORY: it reports, it
951
- # does not block a deploy that already shipped. The ENFORCED ratchet runs
952
- # pre-deploy against the local production build (`bun run speed` in one.ie/web),
953
- # because live-site numbers swing ~30 points on network jitter and cannot gate.
954
- #
955
- # Placed here, serial and after the parallel deploys, deliberately: Lighthouse
956
- # drives a full Chrome and would fight the Step 1+3 gate fan-out and the machine
957
- # governor's slot cap if it ran alongside them.
958
- #
959
- # Exit 2 = INCONCLUSIVE (could not measure). It is reported as such and NEVER
960
- # as a pass — an unrun check is not a green check.
1076
+ # Post-deploy Lighthouse on the LIVE site. ADVISORY the ENFORCED ratchet is
1077
+ # `bun run speed` pre-deploy against the local build. Exit 2 = INCONCLUSIVE and
1078
+ # is never a pass. incident:speed-step-detached
961
1079
  if (( DRY )); then
962
1080
  echo " (dry run — speed check skipped)" | tee -a "$LOG"
963
1081
  elif [[ "${SKIP_SPEED:-0}" == "1" ]]; then
@@ -965,13 +1083,9 @@ elif [[ "${SKIP_SPEED:-0}" == "1" ]]; then
965
1083
  else
966
1084
  sp_out="$LOG_DIR/speed-$STAMP.log"
967
1085
  sp_cmd=(node "$(dirname "${BASH_SOURCE[0]}")/speed-check.mjs" --prod --runs 3)
968
- # The verdict, from the exit code. One function, used by both the inline and
969
- # the detached path, so the two can never phrase the same code differently.
970
- # NOTE: bad() only PRINTS in this script (die() is what exits). That is
971
- # correct here — Step 9 runs AFTER the deploy has shipped, so failing the
972
- # pipeline could not un-ship it. This step reports; it does not block. The
973
- # blocking gate is `bun run speed` pre-deploy against the local build.
974
- # warn() is used so a red x is never mistaken for a blocked deploy.
1086
+ # One verdict function for both the inline and the detached path. bad() only
1087
+ # PRINTS here: Step 9 runs after the ship, so it reports and does not block.
1088
+ # incident:speed-step-detached
975
1089
  speed_verdict() {
976
1090
  grep -E 'perf=|faster|SLOWER|performance:|advisory only' "$sp_out" | sed 's/^/ /' | tee -a "$LOG" || true
977
1091
  case "$1" in
@@ -993,14 +1107,9 @@ else
993
1107
  run_bounded "${SPEED_TIMEOUT:-600}" "${sp_cmd[@]}" >"$sp_out" 2>&1
994
1108
  speed_verdict $?
995
1109
  else
996
- # DETACHED, by default. Measured 2026-09-05 on deploy-20260904-173517: the
997
- # pipeline was 227s wall-clock, and 166s of it was THIS step — three
998
- # Lighthouse runs over six routes against a site that had already shipped
999
- # and already passed health at +61s. The number is advisory by design, so
1000
- # nothing downstream waits on it; the only thing that waited was the
1001
- # operator. The verdict is appended to $LOG when it lands, in the same
1002
- # words the inline path uses, and the process is bounded so a hung Chrome
1003
- # cannot outlive the deploy by more than SPEED_TIMEOUT.
1110
+ # DETACHED by default it was 166s of a 227s pipeline, after the site had
1111
+ # already shipped and passed health. Bounded so a hung Chrome cannot outlive
1112
+ # the deploy. incident:speed-step-detached
1004
1113
  (
1005
1114
  run_bounded "${SPEED_TIMEOUT:-600}" "${sp_cmd[@]}" >"$sp_out" 2>&1
1006
1115
  rc=$?
@@ -1027,11 +1136,52 @@ say " Health: $HEALTH_REPORT"
1027
1136
  [[ -n "${GZIP_HINT:-}" ]] && say " Bundle: $GZIP_HINT"
1028
1137
  say " Log: $LOG"
1029
1138
 
1139
+ # The record the /deploy page renders, on BOTH exits — a record holding only
1140
+ # green runs cannot be read for a trend. Guarded: a recorder fault must never
1141
+ # turn a good deploy into a bad exit. incident:deploy-record-trunk-leg
1142
+ _record_run() {
1143
+ local verdict="$1"
1144
+ [[ -x "$ROOT/.claude/scripts/deploy-record.sh" ]] || return 0
1145
+ (( DRY )) && return 0
1146
+ local -a args=(--door "deploy${MODE:+ $MODE}" --target "${TARGETS[*]:-—}"
1147
+ --branch "$(git -C "$ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo '')"
1148
+ --sha "$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo '')"
1149
+ --verdict "$verdict" --wall "$(( SECONDS ))"
1150
+ --note "heavy=$( (( ${HEAVY_PARALLEL:-0} )) && echo parallel || echo serial ) memFreeGb=${HEAVY_FREE_GB:-?} needGb=${HEAVY_NEED_GB:-?}")
1151
+ local i st
1152
+ for i in "${!GATE_NAMES[@]}"; do
1153
+ # -1 means the gate never reported. It is recorded as `unrun`, never as a
1154
+ # pass and never as a zero — the page draws it as an outline for that reason.
1155
+ if [[ "${GATE_RC[$i]}" == 0 ]]; then st=pass
1156
+ elif [[ "${GATE_RC[$i]}" == -1 ]]; then st=unrun
1157
+ else st=fail; fi
1158
+ args+=(--phase "${GATE_NAMES[$i]}:$(( GATE_T[i] - GATE_T0 )):$(( GATE_DUR[i] < 0 ? 0 : GATE_DUR[i] )):gate:$st")
1159
+ done
1160
+ bash "$ROOT/.claude/scripts/deploy-record.sh" "${args[@]}" >/dev/null 2>&1 || true
1161
+ # THE TRUNK LEG. The row lands in the PRIMARY worktree and NOTHING commits it,
1162
+ # so /deploy never renders it until someone does. Deliberately manual, and
1163
+ # printed at the moment it becomes true. incident:deploy-record-trunk-leg
1164
+ local pend; pend="$(bash "$ROOT/.claude/scripts/deploy-record.sh" --pending 2>/dev/null)" || true
1165
+ [[ -n "$pend" ]] && say "$pend"
1166
+ return 0
1167
+ }
1168
+
1030
1169
  if (( ${#hfail[@]} )); then
1170
+ _emit --stage health --status fail --reason "unhealthy: ${hfail[*]}"
1171
+ _emit_verdict red
1172
+ _record_run degraded
1173
+ # A degraded ship is an outcome. Close it as one.
1174
+ # --only dims,feedback: `deploy` is a LABEL, not a plan slug — the task leg
1175
+ # would close a plan named deploy. incident:deploy-record-trunk-leg
1176
+ bash "$(dirname "${BASH_SOURCE[0]}")/do-close.sh" deploy --status failed --only dims,feedback || true
1031
1177
  say ""
1032
1178
  bad "degraded — unhealthy: ${hfail[*]}"
1033
1179
  say " rollback: cd one.ie/web && bunx wrangler rollback --name one-prod"
1034
1180
  exit 1
1035
1181
  fi
1182
+ _emit --stage health --status ok --detail "probes=${#TARGETS[@]}"
1183
+ _emit_verdict green
1184
+ _record_run green
1185
+ bash "$(dirname "${BASH_SOURCE[0]}")/do-close.sh" deploy --only dims,feedback || true
1036
1186
  say ""
1037
1187
  ok "deploy:success"