@oneie/claude 0.7.0 → 0.9.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 (263) hide show
  1. package/agents/abm-strategist.md +89 -0
  2. package/agents/ads-meta.md +97 -0
  3. package/agents/analyst.md +173 -0
  4. package/agents/architect.md +280 -0
  5. package/agents/brand-guardian.md +88 -0
  6. package/agents/brand-strategist.md +92 -0
  7. package/agents/campaign-content.md +90 -0
  8. package/agents/campaign-email.md +88 -0
  9. package/agents/campaign-sms.md +87 -0
  10. package/agents/campaign-social.md +87 -0
  11. package/agents/cco.md +99 -0
  12. package/agents/ceo.md +106 -0
  13. package/agents/chairman.md +120 -0
  14. package/agents/cmo.md +101 -0
  15. package/agents/community-greeter.md +89 -0
  16. package/agents/community-moderator.md +92 -0
  17. package/agents/compliance.md +92 -0
  18. package/agents/copywriter.md +100 -0
  19. package/agents/creative-strategist.md +95 -0
  20. package/agents/cro.md +98 -0
  21. package/agents/cto.md +267 -0
  22. package/agents/customer-interviewer.md +93 -0
  23. package/agents/customer-researcher.md +89 -0
  24. package/agents/customer-success-manager.md +90 -0
  25. package/agents/customer-trainer.md +90 -0
  26. package/agents/cxo.md +100 -0
  27. package/agents/demand-creator.md +89 -0
  28. package/agents/demo-mover.md +83 -0
  29. package/agents/demo-specialist.md +89 -0
  30. package/agents/demo-thai-family-law.md +87 -0
  31. package/agents/designer.md +112 -0
  32. package/agents/discovery-caller.md +98 -0
  33. package/agents/doctor.md +139 -0
  34. package/agents/educate-coach.md +100 -0
  35. package/agents/elevate-tutor.md +83 -0
  36. package/agents/email-lifecycle-marketer.md +91 -0
  37. package/agents/engage-specialist.md +99 -0
  38. package/agents/events-coordinator.md +90 -0
  39. package/agents/foundation-builder.md +94 -0
  40. package/agents/funnel-architect.md +89 -0
  41. package/agents/gift-creator.md +100 -0
  42. package/agents/google-ads.md +103 -0
  43. package/agents/guide.md +292 -0
  44. package/agents/helpdesk-dispatcher.md +88 -0
  45. package/agents/hook-specialist.md +91 -0
  46. package/agents/identify-optimizer.md +101 -0
  47. package/agents/implementer.md +305 -0
  48. package/agents/incident-commander.md +120 -0
  49. package/agents/insights-lead.md +93 -0
  50. package/agents/journey-runner.md +113 -0
  51. package/agents/linkedin-ads.md +123 -0
  52. package/agents/live-sales-chat.md +90 -0
  53. package/agents/market-researcher.md +88 -0
  54. package/agents/media-buyer.md +96 -0
  55. package/agents/movers-customer-researcher.md +95 -0
  56. package/agents/movers-foundation-builder.md +96 -0
  57. package/agents/movers-market-researcher.md +97 -0
  58. package/agents/movers-pricing-strategist.md +171 -0
  59. package/agents/nurture-architect.md +99 -0
  60. package/agents/offer-architect.md +92 -0
  61. package/agents/onboarder.md +222 -0
  62. package/agents/onboarding-specialist.md +92 -0
  63. package/agents/operations-dashboard.md +98 -0
  64. package/agents/perf-engineer.md +348 -0
  65. package/agents/playbook-writer.md +71 -0
  66. package/agents/plg-strategist.md +91 -0
  67. package/agents/positioning-architect.md +88 -0
  68. package/agents/press-officer.md +89 -0
  69. package/agents/pricing-strategist.md +94 -0
  70. package/agents/privacy-officer.md +92 -0
  71. package/agents/referral-manager.md +91 -0
  72. package/agents/refine-analyst.md +102 -0
  73. package/agents/release-manager.md +261 -0
  74. package/agents/renewals-upsell-rep.md +90 -0
  75. package/agents/review-engineer.md +327 -0
  76. package/agents/rewards-steward.md +82 -0
  77. package/agents/sales-call-coach.md +94 -0
  78. package/agents/sales-closer.md +98 -0
  79. package/agents/security-auditor.md +313 -0
  80. package/agents/sell-closer.md +101 -0
  81. package/agents/share-amplifier.md +99 -0
  82. package/agents/social-media-manager.md +92 -0
  83. package/agents/storyteller.md +250 -0
  84. package/agents/strategist.md +83 -0
  85. package/agents/strategy-aligner.md +94 -0
  86. package/agents/support-agent.md +90 -0
  87. package/agents/tagger.md +245 -0
  88. package/agents/tech-writer.md +216 -0
  89. package/agents/test-engineer.md +243 -0
  90. package/agents/tiktok-ads.md +105 -0
  91. package/agents/tracking-engineer.md +92 -0
  92. package/agents/upsell-strategist.md +101 -0
  93. package/agents/voice.md +85 -0
  94. package/agents/w4-tools.md +153 -0
  95. package/agents/w4-verify.md +20 -138
  96. package/agents/workflow-optimiser.md +84 -0
  97. package/commands/close.md +814 -160
  98. package/commands/create.md +2 -2
  99. package/commands/deploy.md +554 -175
  100. package/commands/do.md +119 -109
  101. package/commands/fast.md +67 -0
  102. package/commands/improve.md +2 -2
  103. package/commands/one.md +418 -0
  104. package/commands/see.md +1 -1
  105. package/commands/sync.md +1 -1
  106. package/commands/tasks.md +222 -0
  107. package/commands/vespio.md +110 -0
  108. package/commands/vespio.remote.md +227 -0
  109. package/hooks/hooks.json +25 -79
  110. package/hooks/lib/governor-escape-match.sh +88 -0
  111. package/hooks/lib/hook.sh +4 -13
  112. package/hooks/lib/signal.sh +9 -2
  113. package/hooks/scripts/branch-pin.sh +6 -0
  114. package/hooks/scripts/config-protect.sh +6 -0
  115. package/hooks/scripts/do-outcome-gate.sh +19 -2
  116. package/hooks/scripts/git-add-guard.sh +43 -2
  117. package/hooks/scripts/governor-escape.sh +96 -0
  118. package/hooks/scripts/load-guard.sh +127 -0
  119. package/hooks/scripts/post-edit-check.sh +36 -29
  120. package/hooks/scripts/session-start.sh +34 -80
  121. package/hooks/scripts/task-complete-verify.sh +46 -40
  122. package/package.json +1 -1
  123. package/rules/documentation.md +9 -0
  124. package/scripts/ad-copy-lint.sh +656 -0
  125. package/scripts/agentverse-audit.sh +256 -0
  126. package/scripts/asi-walk.sh +435 -0
  127. package/scripts/astro-build-cached.sh +143 -0
  128. package/scripts/blocks-manifest-cached.sh +100 -0
  129. package/scripts/blocks-manifest.mjs +497 -0
  130. package/scripts/blocks-ratchet.sh +116 -0
  131. package/scripts/blocks-render-probe.mjs +529 -0
  132. package/scripts/blocks-usage.mjs +334 -0
  133. package/scripts/chat-context-check.sh +89 -0
  134. package/scripts/close-metrics.sh +558 -0
  135. package/scripts/close-owner.sh +268 -0
  136. package/scripts/db-sync-lock-check.sh +116 -0
  137. package/scripts/deploy-dev.sh +35 -0
  138. package/scripts/deploy-emit.sh +298 -0
  139. package/scripts/deploy-gate-check.sh +128 -0
  140. package/scripts/deploy-ready.sh +78 -0
  141. package/scripts/deploy-record.sh +605 -0
  142. package/scripts/deploy.sh +1273 -0
  143. package/scripts/do-auto.sh +598 -48
  144. package/scripts/do-brief.sh +113 -0
  145. package/scripts/do-close.sh +1137 -0
  146. package/scripts/do-commit.sh +75 -0
  147. package/scripts/do-consumer-sweep.sh +18 -1
  148. package/scripts/do-cycle-shape-check.sh +160 -0
  149. package/scripts/do-decide.sh +476 -0
  150. package/scripts/do-derive-check.sh +436 -0
  151. package/scripts/do-fleet.sh +106 -28
  152. package/scripts/do-folder.sh +10 -1
  153. package/scripts/do-next.sh +106 -0
  154. package/scripts/do-orchestrate.sh +17 -5
  155. package/scripts/do-plan-json.mjs +201 -0
  156. package/scripts/do-plan-json.sh +8 -0
  157. package/scripts/do-preflight.sh +117 -0
  158. package/scripts/do-project.sh +157 -0
  159. package/scripts/do-prove-selftest.sh +108 -0
  160. package/scripts/do-prove.sh +295 -23
  161. package/scripts/do-rank.py +31 -1
  162. package/scripts/do-recon-cache.sh +7 -1
  163. package/scripts/do-recon-pack.sh +196 -0
  164. package/scripts/do-reconcile.sh +121 -4
  165. package/scripts/do-signal.sh +280 -23
  166. package/scripts/do-smoke.sh +18 -1
  167. package/scripts/do-test-gate.sh +80 -0
  168. package/scripts/do-tick.sh +102 -0
  169. package/scripts/do-tier.sh +6 -0
  170. package/scripts/do-triage.sh +182 -0
  171. package/scripts/do-ui-gate.sh +1 -1
  172. package/scripts/do-w4-gates.sh +451 -0
  173. package/scripts/do-walk.sh +12 -1
  174. package/scripts/env-sync.sh +173 -0
  175. package/scripts/factory-brief-check.sh +330 -0
  176. package/scripts/factory-check.sh +68 -14
  177. package/scripts/factory-close-check.sh +257 -0
  178. package/scripts/factory-emit.sh +211 -0
  179. package/scripts/factory-executor-check.mjs +353 -0
  180. package/scripts/factory-peak.sh +301 -0
  181. package/scripts/factory-repo.sh +118 -3
  182. package/scripts/factory-review-check.mjs +61 -0
  183. package/scripts/factory-ship.sh +61 -0
  184. package/scripts/factory-tasks-check.sh +18 -1
  185. package/scripts/factory-turn.sh +326 -0
  186. package/scripts/factory-walk.sh +396 -0
  187. package/scripts/factory-width.sh +57 -0
  188. package/scripts/fade-toxic.sh +4 -3
  189. package/scripts/fixtures/factory-brief-real.md +44 -0
  190. package/scripts/fixtures/triage-dupe.md +5 -0
  191. package/scripts/fleet-manifest.mjs +108 -0
  192. package/scripts/fleet-status.sh +110 -0
  193. package/scripts/full-suite-paths-check.sh +144 -0
  194. package/scripts/gate-reaper-check.sh +98 -0
  195. package/scripts/gate-reaper.sh +125 -0
  196. package/scripts/gate-run.sh +73 -0
  197. package/scripts/gc-content-check.sh +140 -0
  198. package/scripts/gen-dev-config.py +20 -0
  199. package/scripts/govern-bound-check.sh +60 -0
  200. package/scripts/govern-claims-check.sh +233 -0
  201. package/scripts/govern-mem-check.sh +290 -0
  202. package/scripts/governor-doors-check.sh +362 -0
  203. package/scripts/governor-escape-check.sh +171 -0
  204. package/scripts/health.sh +413 -0
  205. package/scripts/id-inventory.mjs +418 -0
  206. package/scripts/land.sh +551 -0
  207. package/scripts/lib/gc-finished.sh +77 -0
  208. package/scripts/lib/govern.sh +361 -0
  209. package/scripts/lib/govern.ts +756 -0
  210. package/scripts/lighthouse-run.sh +187 -0
  211. package/scripts/livekit-live-check.sh +61 -0
  212. package/scripts/livekit-ratchet.sh +46 -0
  213. package/scripts/load-guard-check.sh +49 -0
  214. package/scripts/machine-check.sh +102 -0
  215. package/scripts/machine-watch.sh +177 -0
  216. package/scripts/one-agents.mjs +415 -0
  217. package/scripts/one-resume.sh +78 -0
  218. package/scripts/orphan-baseline.json +182 -0
  219. package/scripts/orphan-modules.mjs +179 -0
  220. package/scripts/pr-body.sh +335 -0
  221. package/scripts/preview-fd-check.sh +289 -0
  222. package/scripts/promise-manifest.mjs +24 -1
  223. package/scripts/release.sh +322 -0
  224. package/scripts/roles-check.sh +946 -0
  225. package/scripts/sdk-build-cached.sh +64 -0
  226. package/scripts/signal-watch.sh +241 -0
  227. package/scripts/skills-publish.sh +94 -0
  228. package/scripts/speed-cache-check.sh +173 -0
  229. package/scripts/speed-check.mjs +907 -0
  230. package/scripts/speed-parity-check.sh +648 -0
  231. package/scripts/speed-waterfall-check.sh +355 -0
  232. package/scripts/substrate-env-parity.mjs +156 -0
  233. package/scripts/tasks-claim-race.mjs +108 -0
  234. package/scripts/tasks-loop.sh +185 -0
  235. package/scripts/test-cached.sh +255 -0
  236. package/scripts/test-full.sh +87 -0
  237. package/scripts/test-honesty.mjs +137 -0
  238. package/scripts/test-lanes.sh +166 -0
  239. package/scripts/test-speed.sh +94 -0
  240. package/scripts/triage-shape-check.sh +149 -0
  241. package/scripts/tsc-cached.sh +179 -0
  242. package/scripts/typedb-flake-check.sh +197 -0
  243. package/scripts/urls-lint.sh +15 -0
  244. package/scripts/verify-fast.sh +445 -0
  245. package/scripts/vespio-sync.sh +149 -0
  246. package/scripts/wf-check.mjs +104 -1
  247. package/scripts/worktree-preview.sh +879 -0
  248. package/scripts/worktree-up.sh +208 -0
  249. package/skills/livekit-agents/SKILL.md +285 -0
  250. package/skills/livekit-agents/references/freshness-rules.md +168 -0
  251. package/skills/shadcn/SKILL.md +1 -1
  252. package/skills/signal/SKILL.md +0 -1
  253. package/skills/voice/SKILL.md +94 -6
  254. package/skills/voice/corpus-check.sh +87 -0
  255. package/hooks/scripts/compact-hint.sh +0 -35
  256. package/hooks/scripts/gate-guard.sh +0 -83
  257. package/hooks/scripts/read-tracker.sh +0 -26
  258. package/hooks/scripts/session-end-verify.sh +0 -51
  259. package/hooks/scripts/stop-reflect.sh +0 -140
  260. package/hooks/scripts/sync-priority-todo.sh +0 -57
  261. package/hooks/scripts/sync-todo-docs.sh +0 -46
  262. package/hooks/scripts/tool-signal.sh +0 -48
  263. package/scripts/do-tasks-bridge.py +0 -366
@@ -0,0 +1,879 @@
1
+ #!/usr/bin/env bash
2
+ # worktree-preview.sh — a task's PRIVATE dev server, so the work closes with a link.
3
+ #
4
+ # manifest: needs-env
5
+ # It names `one.ie/web` (the app whose dev server is previewed) and reads that
6
+ # app's gitignored `.env`. Both degrade: PREVIEW_APP_DIR retargets the app and
7
+ # a missing app dir is a clean refusal, not a crash. Same bucket, same reason,
8
+ # as do-prove.sh — which also names one.ie/web and reads credentials.
9
+ #
10
+ # USAGE
11
+ # worktree-preview.sh up <slug> [--route /path] [--base main] [--port N]
12
+ # worktree-preview.sh down <slug>
13
+ # worktree-preview.sh check <slug|--dir PATH> the isolation gate
14
+ # worktree-preview.sh list
15
+ # worktree-preview.sh --self-test two servers, concurrent
16
+ #
17
+ # WHY EVERY LINE OF THIS EXISTS — all measured 2026-09-03 on a 10-core/24GB Mac.
18
+ #
19
+ # 1. `astro dev` DOES boot in a git worktree. The recorded "astro fails in a
20
+ # worktree" finding is about `astro build` (missing prerender entry), not
21
+ # `dev`: astro v6.3.7 ready in 7345 ms · GET /factory -> 200 on :4325.
22
+ #
23
+ # 2. SYMLINKING `node_modules` ITSELF IS FATAL AT TWO SERVERS. Vite WRITES to
24
+ # node_modules/.vite. Two worktree servers sharing one cache killed each
25
+ # other: 4325 -> 000 (process dead), 4326 -> 500, both reporting
26
+ # "The file does not exist at .../node_modules/.vite/deps_ssr/….js?v=<hash>"
27
+ # with TWO DIFFERENT ?v= hashes. That is the desync /kill documents.
28
+ # `.dev-stable` gets away with a whole-dir symlink because it is ONE tree.
29
+ # A factory fans out N. So: symlink the CONTENTS, keep the caches local.
30
+ #
31
+ # 3. A COLD .vite FLAPS. Three consecutive requests to a fresh worktree gave
32
+ # 500, 000, 200 while the dep optimiser churned, then settled and stayed.
33
+ # A preview handed over during the flap shows a human a 500 for work that
34
+ # is fine — hence warm-to-N-CONSECUTIVE-200s, never a single probe.
35
+ #
36
+ # 4. `.env` must be COPIED IN. `git worktree add` does not carry a gitignored
37
+ # file, and the boot log's "Using secrets defined in .env" is load-bearing.
38
+ #
39
+ # 5. ALLOCATE PORTS, NEVER SWEEP. /kill's sweep of 4321-4329 also kills
40
+ # neighbour projects (it names apps/one/site on 4322). 4321 is the human's.
41
+ # Teardown kills THIS port only, by recorded pid and by that one port.
42
+ #
43
+ # 6. NEVER `bun install` INSIDE A WORKTREE — it re-points MAIN's
44
+ # node_modules/@oneie/sdk at the worktree's dist-less SDK and breaks the
45
+ # tree you did not touch. This script never installs. Not once.
46
+ #
47
+ # Cost note the governor does not know about: ~128M of .vite PER worktree.
48
+ # gate_headroom prices a cycle at 2GB of MEMORY; preview disk is on top.
49
+ #
50
+ set -uo pipefail
51
+
52
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
53
+ LIB="$SCRIPT_DIR/lib/govern.sh"
54
+ # shellcheck source=/dev/null
55
+ [ -f "$LIB" ] && . "$LIB"
56
+
57
+ # The source tree is the one that HAS node_modules and .env: the main checkout.
58
+ # From any linked worktree, that is the parent of the common git dir.
59
+ _common_git="$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)"
60
+ DEFAULT_SOURCE="${_common_git:+$(dirname "$_common_git")}"
61
+ SOURCE_ROOT="${PREVIEW_SOURCE:-${DEFAULT_SOURCE:-$(cd "$SCRIPT_DIR/../.." && pwd)}}"
62
+ APP_DIR="${PREVIEW_APP_DIR:-one.ie/web}"
63
+ WT_DIR="${PREVIEW_WT_DIR:-$SOURCE_ROOT/.do-worktrees}"
64
+ BASE_REF="${DO_BASE_REF:-main}"
65
+
66
+ PORT_MIN="${PREVIEW_PORT_MIN:-4322}" # 4321 belongs to the human
67
+ PORT_MAX="${PREVIEW_PORT_MAX:-4329}" # /restart reserves 4321-4329 for astro
68
+ WARM_TIMEOUT="${PREVIEW_WARM_TIMEOUT:-180}"
69
+ WARM_STREAK="${PREVIEW_WARM_STREAK:-3}" # 3 CONSECUTIVE 200s — see note 3
70
+ BOOT_TIMEOUT="${PREVIEW_BOOT_TIMEOUT:-60}"
71
+
72
+ # node_modules entries that MUST be real local dirs, because something writes
73
+ # to them. Sharing any one of these across two servers is defect #2.
74
+ LOCAL_CACHE_DIRS=(.vite .vite-temp .astro .cache .mf)
75
+
76
+ say() { printf '%s\n' "$*" >&2; }
77
+ die() { printf 'worktree-preview: %s\n' "$*" >&2; exit 2; }
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # node_modules — symlink the CONTENTS, keep the writable caches local.
81
+ # ---------------------------------------------------------------------------
82
+ link_node_modules() {
83
+ local src="$1" dst="$2" e n=0
84
+ [ -d "$src" ] || die "no node_modules at $src — run bun install in the SOURCE tree, never in a worktree"
85
+ if [ -L "$dst" ]; then
86
+ say "worktree-preview: replacing whole-dir node_modules symlink (defect #2) with per-entry links"
87
+ rm -f "$dst"
88
+ fi
89
+ mkdir -p "$dst"
90
+ while IFS= read -r e; do
91
+ case " ${LOCAL_CACHE_DIRS[*]} " in
92
+ *" $e "*) [ -L "$dst/$e" ] && rm -f "$dst/$e"; mkdir -p "$dst/$e"; continue ;;
93
+ esac
94
+ [ -e "$dst/$e" ] || [ -L "$dst/$e" ] || { ln -s "$src/$e" "$dst/$e" && n=$((n + 1)); }
95
+ done < <(ls -A "$src")
96
+ # The caches must exist even if the source tree never made them.
97
+ for e in "${LOCAL_CACHE_DIRS[@]}"; do mkdir -p "$dst/$e"; done
98
+ echo "$n"
99
+ }
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # check — THE GATE. It must be able to go RED, and --self-test proves it does.
103
+ # Last line is the verdict; an exit code is not the gate result.
104
+ # ---------------------------------------------------------------------------
105
+ check_isolation() {
106
+ # NOT `local app="$1" nm="$app/node_modules"` — bash expands every word of a
107
+ # `local` line BEFORE it runs, so `$app` is unbound there and `set -u` kills
108
+ # the whole function. Caught by --self-test: BOTH the green and the red case
109
+ # then "passed", one of them for entirely the wrong reason.
110
+ local app="$1"
111
+ local nm="$app/node_modules" bad=0 e linked
112
+
113
+ if [ ! -e "$nm" ]; then
114
+ echo "RED node_modules missing at $nm"
115
+ echo "worktree-preview check: RED (1 fault)"; return 1
116
+ fi
117
+ if [ -L "$nm" ]; then
118
+ echo "RED node_modules is a SYMLINK -> $(readlink "$nm")"
119
+ echo " Vite WRITES to node_modules/.vite. Two worktree servers sharing"
120
+ echo " one cache desync on the ?v= hash and kill each other (000/500)."
121
+ echo " Fix: symlink the CONTENTS; keep ${LOCAL_CACHE_DIRS[*]} local."
122
+ bad=$((bad + 1))
123
+ else
124
+ for e in "${LOCAL_CACHE_DIRS[@]}"; do
125
+ if [ -L "$nm/$e" ]; then
126
+ echo "RED node_modules/$e is a SYMLINK -> $(readlink "$nm/$e") (shared write cache)"
127
+ bad=$((bad + 1))
128
+ elif [ ! -d "$nm/$e" ]; then
129
+ echo "RED node_modules/$e is not a local directory"
130
+ bad=$((bad + 1))
131
+ fi
132
+ done
133
+ [ -e "$nm/.bin" ] || { echo "RED node_modules/.bin missing — astro is unreachable"; bad=$((bad + 1)); }
134
+ linked=$(find "$nm" -maxdepth 1 -type l 2>/dev/null | wc -l | tr -d ' ')
135
+ [ "${linked:-0}" -ge 100 ] || { echo "RED only ${linked:-0} linked entries — node_modules looks unpopulated"; bad=$((bad + 1)); }
136
+ fi
137
+
138
+ [ -f "$app/.env" ] || { echo "RED .env not copied in (git worktree add does not carry a gitignored file)"; bad=$((bad + 1)); }
139
+
140
+ if [ "$bad" -eq 0 ]; then
141
+ echo "worktree-preview check: GREEN ($(find "$nm" -maxdepth 1 -type l | wc -l | tr -d ' ') linked, ${#LOCAL_CACHE_DIRS[@]} local caches, .env present)"
142
+ return 0
143
+ fi
144
+ echo "worktree-preview check: RED ($bad fault$([ "$bad" -gt 1 ] && echo s))"
145
+ return 1
146
+ }
147
+
148
+ # ---------------------------------------------------------------------------
149
+ # Ports — ALLOCATE by probing, never sweep. Two independent guards:
150
+ # 1. nothing is LISTENing there right now
151
+ # 2. an atomic mkdir(2) reservation wins the race against every other caller
152
+ # 3. after boot, the SERVER's own log must name the port we asked for
153
+ #
154
+ # DO NOT use govern.sh's claim_take here, and this is measured, not stylistic.
155
+ # claim_take records the owner as `${GOVERN_CLAIM_PID:-$$}` and treats a
156
+ # re-claim by the same pid as idempotent success. macOS ships **bash 3.2.57**,
157
+ # which has NO `BASHPID` — inside `( … ) &` the variable `$$` is still the
158
+ # PARENT's pid. So two concurrent previews forked from one shell present the
159
+ # SAME owner, both take the "you already hold it" branch, and both are handed
160
+ # the same port. Measured 2026-09-03 by --self-test C:
161
+ #
162
+ # worktree-preview up: GREEN slug=preview-selftest-a port=4322 …
163
+ # worktree-preview up: GREEN slug=preview-selftest-b port=4322 …
164
+ #
165
+ # Both then probed 4322 and both saw 200 — from ONE server. A green that
166
+ # means nothing. mkdir(2) does not consult $$, so it arbitrates correctly.
167
+ # (The same trap applies to any caller that forks claim_take into subshells.)
168
+ # ---------------------------------------------------------------------------
169
+ # `${PORT_LOCK_DIR:-…}`, not a bare assignment. An unconditional assignment here
170
+ # SILENTLY IGNORED an inherited PORT_LOCK_DIR, so --self-test D's "isolated" lock
171
+ # dir was never isolated: its racers used the real machine-wide one and inherited
172
+ # a reservation left by the previous run, inside the boot grace. 12 racers, 0
173
+ # winners — a RED that looked like the arbiter failing when the arbiter was fine.
174
+ PORT_LOCK_DIR="${PORT_LOCK_DIR:-${GOVERN_DIR:-${TMPDIR:-/tmp}/one-govern}/preview-ports}"
175
+ PORT_BOOT_GRACE="${PREVIEW_PORT_GRACE:-300}" # a reservation with no pid yet
176
+
177
+ port_is_free() { ! lsof -nP -iTCP:"$1" -sTCP:LISTEN >/dev/null 2>&1; }
178
+
179
+ _port_stamp() { printf 'slug=%s\nstarted=%s\n' "$1" "$(date +%s)" > "$2/meta"; }
180
+
181
+ # reserve_port <port> <slug> — 0 if it is ours, 1 if someone live holds it.
182
+ reserve_port() {
183
+ # Same trap as check_isolation: a `local` line is fully word-expanded BEFORE
184
+ # it runs, so `d="$PORT_LOCK_DIR/$p"` on the same line sees an unbound $p and
185
+ # `set -u` kills the function. Caught by --self-test D: "12 racers produced 0
186
+ # winners". Split every self-referencing local.
187
+ local p="$1" slug="$2"
188
+ local d="$PORT_LOCK_DIR/$p" owner started age dead=1
189
+ mkdir -p "$PORT_LOCK_DIR" 2>/dev/null
190
+ if mkdir "$d" 2>/dev/null; then _port_stamp "$slug" "$d"; return 0; fi
191
+
192
+ # Held. Evaporate it only if its server is gone AND the port is really free.
193
+ owner="$(sed -n 's/^pid=//p' "$d/meta" 2>/dev/null | head -1)"
194
+ started="$(sed -n 's/^started=//p' "$d/meta" 2>/dev/null | head -1)"
195
+ if [ -n "$owner" ]; then
196
+ kill -0 "$owner" 2>/dev/null && dead=0
197
+ [ "$dead" = 0 ] && return 1
198
+ else
199
+ # No pid yet: the holder is mid-boot. Give it a grace window, then reap.
200
+ age=$(( $(date +%s) - ${started:-0} ))
201
+ [ "$age" -lt "$PORT_BOOT_GRACE" ] && return 1
202
+ fi
203
+ port_is_free "$p" || return 1
204
+ rm -rf "$d" 2>/dev/null
205
+ mkdir "$d" 2>/dev/null || return 1
206
+ _port_stamp "$slug" "$d"; return 0
207
+ }
208
+
209
+ port_release() { rm -rf "${PORT_LOCK_DIR:?}/${1:?}" 2>/dev/null; return 0; }
210
+
211
+ # boot_lock — machine-wide, held only while ONE astro dev is coming up. See the
212
+ # EADDRINUSE note in cmd_up. mkdir(2) again, and again NOT claim_take: this is
213
+ # taken from inside `( … ) &` subshells, where $$ collides. Stale locks reap by
214
+ # pid death or age; a lock nobody can acquire must never be able to wedge the
215
+ # factory, so the wait is bounded and expiry just takes the ground.
216
+ BOOT_LOCK_WAIT="${PREVIEW_BOOT_LOCK_WAIT:-240}"
217
+ BOOT_TOKEN=""
218
+ boot_lock_take() {
219
+ local d="$PORT_LOCK_DIR/.boot"
220
+ local waited=0 owner started
221
+ # $$ is NOT unique across subshells on bash 3.2, so ownership is a random
222
+ # token; the pid is recorded only so a dead holder can be reaped.
223
+ BOOT_TOKEN="$$-$RANDOM-$RANDOM"
224
+ mkdir -p "$PORT_LOCK_DIR" 2>/dev/null
225
+ while [ "$waited" -lt "$BOOT_LOCK_WAIT" ]; do
226
+ if mkdir "$d" 2>/dev/null; then
227
+ printf 'token=%s\npid=%s\nstarted=%s\n' "$BOOT_TOKEN" "$$" "$(date +%s)" > "$d/meta"
228
+ return 0
229
+ fi
230
+ owner="$(sed -n 's/^pid=//p' "$d/meta" 2>/dev/null | head -1)"
231
+ started="$(sed -n 's/^started=//p' "$d/meta" 2>/dev/null | head -1)"
232
+ if { [ -n "$owner" ] && ! kill -0 "$owner" 2>/dev/null; } \
233
+ || [ $(( $(date +%s) - ${started:-0} )) -gt "$BOOT_LOCK_WAIT" ]; then
234
+ rm -rf "$d" 2>/dev/null; continue
235
+ fi
236
+ [ "$waited" = 0 ] && say "worktree-preview: another preview is booting — queueing (this is a WAIT, not a hang)"
237
+ sleep 1; waited=$((waited + 1))
238
+ done
239
+ BOOT_TOKEN=""
240
+ say "worktree-preview: boot lock not acquired in ${BOOT_LOCK_WAIT}s — booting anyway (inspector port may race)"
241
+ return 0
242
+ }
243
+ boot_lock_release() {
244
+ local d="$PORT_LOCK_DIR/.boot"
245
+ [ -n "$BOOT_TOKEN" ] || return 0
246
+ [ "$(sed -n 's/^token=//p' "$d/meta" 2>/dev/null | head -1)" = "$BOOT_TOKEN" ] \
247
+ && rm -rf "$d" 2>/dev/null
248
+ BOOT_TOKEN=""
249
+ return 0
250
+ }
251
+ port_adopt() { [ -d "$PORT_LOCK_DIR/$1" ] && echo "pid=$2" >> "$PORT_LOCK_DIR/$1/meta"; return 0; }
252
+
253
+ alloc_port() {
254
+ local slug="$1" p
255
+ for ((p = PORT_MIN; p <= PORT_MAX; p++)); do
256
+ port_is_free "$p" || continue
257
+ reserve_port "$p" "$slug" || continue
258
+ echo "$p"; return 0
259
+ done
260
+ return 1
261
+ }
262
+
263
+ # log_banner_port <astro.log> — the port astro ACTUALLY bound, or empty.
264
+ #
265
+ # It must match every host form astro prints, and this is measured, not guessed:
266
+ # with `--host 127.0.0.1` the banner reads
267
+ #
268
+ # ┃ Local http://127.0.0.1:4323/
269
+ #
270
+ # NOT `localhost:`. The first version of this grep only matched `localhost:[0-9]+`,
271
+ # so it never matched anything — the port-contention check silently never fired
272
+ # AND every healthy boot burned the full BOOT_TIMEOUT waiting for a line that
273
+ # could not appear. A dead assertion that also costs 60s a boot.
274
+ log_banner_port() {
275
+ grep -oE '(localhost|127\.0\.0\.1|\[::1\]|0\.0\.0\.0):[0-9]+' "$1" 2>/dev/null \
276
+ | head -1 | sed 's/.*://'
277
+ }
278
+
279
+ # ---------------------------------------------------------------------------
280
+ # warm — the whole point of this function is that it WAITS.
281
+ # A cold .vite gave 500, 000, 200 on three consecutive requests. One 200 proves
282
+ # nothing; N CONSECUTIVE 200s proves the optimiser settled. No -L: a 302 to
283
+ # /signin is NOT a 200 on the route asked for (the do-prove landing rule).
284
+ # Prints "probes=<n> streak_broken=<n> elapsed=<s>" so a caller can see it waited.
285
+ # ---------------------------------------------------------------------------
286
+ warm_route() {
287
+ local url="$1" timeout="${2:-$WARM_TIMEOUT}" want="${3:-$WARM_STREAK}"
288
+ local t0 deadline streak=0 probes=0 broken=0 code first=""
289
+ t0=$(date +%s); deadline=$((t0 + timeout))
290
+ while [ "$(date +%s)" -lt "$deadline" ]; do
291
+ # `curl … || echo 000` CONCATENATES: on a refused connection curl already
292
+ # printed 000 and then failed, so the variable held "000000" and no branch
293
+ # matched. Normalise instead of appending.
294
+ code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$url" 2>/dev/null)"
295
+ case "$code" in ''|*[!0-9]*) code=000 ;; esac
296
+ [ ${#code} -ne 3 ] && code=000
297
+ probes=$((probes + 1)); [ -z "$first" ] && first="$code"
298
+ if [ "$code" = "200" ]; then
299
+ streak=$((streak + 1))
300
+ if [ "$streak" -ge "$want" ]; then
301
+ echo "probes=$probes first=$first streak_broken=$broken elapsed=$(( $(date +%s) - t0 ))s verdict=GREEN"
302
+ return 0
303
+ fi
304
+ else
305
+ [ "$streak" -gt 0 ] && broken=$((broken + 1))
306
+ streak=0
307
+ fi
308
+ sleep 1
309
+ done
310
+ echo "probes=$probes first=$first streak_broken=$broken elapsed=$(( $(date +%s) - t0 ))s last=$code verdict=RED"
311
+ return 1
312
+ }
313
+
314
+ # ---------------------------------------------------------------------------
315
+ # up
316
+ # ---------------------------------------------------------------------------
317
+ cmd_up() {
318
+ local slug="" route="/factory" port="" base="$BASE_REF"
319
+ slug="${1:-}"; shift || true
320
+ [ -n "$slug" ] || die "up needs a slug"
321
+ while [ $# -gt 0 ]; do
322
+ case "$1" in
323
+ --route) route="$2"; shift 2 ;;
324
+ --port) port="$2"; shift 2 ;;
325
+ --base) base="$2"; shift 2 ;;
326
+ *) die "unknown flag: $1" ;;
327
+ esac
328
+ done
329
+
330
+ local src_app="$SOURCE_ROOT/$APP_DIR"
331
+ [ -d "$src_app" ] || die "no app at $src_app (set PREVIEW_APP_DIR)"
332
+ local wt="$WT_DIR/$slug" app="$WT_DIR/$slug/$APP_DIR"
333
+
334
+ # 1. worktree — attach if it is already there, never clobber
335
+ if [ -d "$wt" ]; then
336
+ say "worktree-preview: reusing worktree $wt"
337
+ else
338
+ mkdir -p "$WT_DIR"
339
+ if git -C "$SOURCE_ROOT" show-ref --verify --quiet "refs/heads/feat/$slug"; then
340
+ git -C "$SOURCE_ROOT" worktree add "$wt" "feat/$slug" >&2 || die "worktree add failed"
341
+ else
342
+ git -C "$SOURCE_ROOT" worktree add -b "feat/$slug" "$wt" "$base" >&2 || die "worktree add failed"
343
+ fi
344
+ # OWNERSHIP. `down` may only remove a worktree THIS script created. Two agents
345
+ # on 2026-09-05 pointed PREVIEW_WT_DIR at their own checkout so `up` would
346
+ # serve it, and `down` then removed the checkout with every uncommitted file
347
+ # in it. The marker is written only on the create path, so an attached
348
+ # (pre-existing) tree is never ours to delete.
349
+ mkdir -p "$wt/$APP_DIR/.preview" && : > "$wt/$APP_DIR/.preview/owned"
350
+ fi
351
+ [ -d "$app" ] || die "worktree has no $APP_DIR"
352
+
353
+ # 2. node_modules by CONTENTS + local caches. NEVER an install.
354
+ local n; n="$(link_node_modules "$src_app/node_modules" "$app/node_modules")"
355
+ say "worktree-preview: linked $n entries, ${#LOCAL_CACHE_DIRS[@]} local caches (no install)"
356
+
357
+ # 3. .env — git worktree add does not carry a gitignored file, and the boot
358
+ # log's "Using secrets defined in .env" is load-bearing. Indirected through
359
+ # ONE_ENV_FILE (DO_ENV_FILE alias) like every other credential-reading
360
+ # script here, so a collaborator tree can point it elsewhere. We never read
361
+ # a VALUE out of it — we copy the file — but the path is still theirs.
362
+ local env_src="${ONE_ENV_FILE:-${DO_ENV_FILE:-$src_app/.env}}"
363
+ [ -f "$env_src" ] || die "no .env at $env_src (set ONE_ENV_FILE)"
364
+ cp "$env_src" "$app/.env"
365
+ [ -f "$src_app/.dev.vars" ] && cp "$src_app/.dev.vars" "$app/.dev.vars"
366
+
367
+ # 4. the gate, BEFORE we boot anything
368
+ if ! check_isolation "$app"; then
369
+ die "isolation check RED — refusing to boot (see above)"
370
+ fi
371
+
372
+ # 5. port
373
+ if [ -n "$port" ]; then
374
+ port_is_free "$port" && reserve_port "$port" "$slug" || die "port $port is already taken"
375
+ else
376
+ port="$(alloc_port "$slug")" || die "no free port in $PORT_MIN-$PORT_MAX (cap is 8 minus whatever a neighbour holds)"
377
+ fi
378
+
379
+ # 6. boot, detached into its own process group so a gate-run group-kill or a
380
+ # dying parent shell does not take the preview with it.
381
+ #
382
+ # SERIALISE THE BOOT — the servers stay concurrent, only their STARTS queue.
383
+ # Measured 2026-09-03 by --self-test C, second defect it caught: `astro dev`
384
+ # here also opens an INSPECTOR socket (miniflare/workerd, default 9229). Two
385
+ # previews launched at the same instant both read 9229 as busy, both fell back
386
+ # to 9231, and one DIED:
387
+ #
388
+ # [WARN] [vite] Default inspector port 9229 not available, using 9231 instead
389
+ # Error: listen EADDRINUSE: address already in use 127.0.0.1:9231
390
+ #
391
+ # That port is not settable from the CLI, and its own fallback scan is only
392
+ # unsafe when raced. Holding a machine-wide boot lock until the banner appears
393
+ # lets the second launch see 9231 taken and step to 9233. It costs one boot of
394
+ # latency and removes a crash that reads as "the preview just never came up".
395
+ local state="$app/.preview"; mkdir -p "$state"
396
+ local log="$state/astro.log"; : > "$log"
397
+ local astro="$app/node_modules/.bin/astro"
398
+ [ -x "$astro" ] || astro="npx astro"
399
+ local pid
400
+ boot_lock_take
401
+ if command -v perl >/dev/null 2>&1; then
402
+ ( cd "$app" && perl -e 'setsid; exec @ARGV' -- sh -c "exec $astro dev --port $port --host 127.0.0.1" >>"$log" 2>&1 & echo $! > "$state/pid" )
403
+ else
404
+ ( cd "$app" && nohup sh -c "exec $astro dev --port $port --host 127.0.0.1" >>"$log" 2>&1 & echo $! > "$state/pid" )
405
+ fi
406
+ pid="$(cat "$state/pid" 2>/dev/null)"
407
+ echo "$port" > "$state/port"
408
+ # The reservation now tracks the SERVER's lifetime, so a dead server frees its
409
+ # port with no human in the loop — the leaked `preview:` tag the spec worries
410
+ # about. This is where a real pid finally exists, hence adopt-after-boot.
411
+ port_adopt "$port" "$pid"
412
+
413
+ local url="http://localhost:$port$route"
414
+ echo "$url" > "$state/url"
415
+
416
+ # 6b. THE SERVER MUST HAVE THE PORT WE ASKED FOR, AND MUST STILL BE ALIVE.
417
+ # `astro dev --port N` is NOT strict: on a busy port Vite increments and boots
418
+ # somewhere else, printing the port it actually took. Two previews handed the
419
+ # same number would then both probe N, both get 200 from ONE server, and the
420
+ # two-server test would go green while proving nothing. Read the boot banner —
421
+ # and watch for the process dying, so an EADDRINUSE is a 10s honest RED rather
422
+ # than a 300s warm timeout that looks like slowness.
423
+ local waited=0 banner="" dead=0
424
+ while [ "$waited" -lt "$BOOT_TIMEOUT" ]; do
425
+ banner="$(log_banner_port "$log")"
426
+ [ -n "$banner" ] && break
427
+ if [ -n "$pid" ] && ! kill -0 "$pid" 2>/dev/null; then dead=1; break; fi
428
+ sleep 1; waited=$((waited + 1))
429
+ done
430
+ boot_lock_release
431
+ if [ "$dead" = 1 ]; then
432
+ say "--- last 25 lines of $log ---"; tail -25 "$log" >&2
433
+ cmd_down "$slug" --keep-worktree >&2 || true
434
+ echo "worktree-preview up: RED slug=$slug port=$port — the server DIED during boot (see log above); port freed, worktree kept at $wt/$APP_DIR/.preview/astro.log.failed"
435
+ return 1
436
+ fi
437
+ if [ -n "$banner" ] && [ "$banner" != "$port" ]; then
438
+ say "worktree-preview: astro bound port $banner, not $port — the port was contended"
439
+ cmd_down "$slug" --keep-worktree >&2 || true
440
+ echo "worktree-preview up: RED slug=$slug — asked for $port, astro bound $banner (port contention; reservation released)"
441
+ return 1
442
+ fi
443
+
444
+ # 7. WARM — do not hand over a URL during the flap.
445
+ say "worktree-preview: warming $url (need $WARM_STREAK consecutive 200s)…"
446
+ local w rc
447
+ w="$(warm_route "$url" "$WARM_TIMEOUT" "$WARM_STREAK")"; rc=$?
448
+ say "worktree-preview: warm $w"
449
+ if [ $rc -ne 0 ]; then
450
+ say "--- last 25 lines of $log ---"; tail -25 "$log" >&2
451
+ # Free the port and kill the server we booted. A RED that leaves a server
452
+ # running and a port claimed makes the next attempt allocate a SECOND port
453
+ # and leaves 128M of .vite behind; the tree is kept for forensics.
454
+ cmd_down "$slug" --keep-worktree >&2 || true
455
+ echo "worktree-preview up: RED slug=$slug port=$port url=$url ($w) — server killed, port freed, worktree kept at $wt"
456
+ return 1
457
+ fi
458
+
459
+ # Record it the way the spec asks the task to carry it.
460
+ echo "tag preview:$port"
461
+ echo "note $url"
462
+ echo "worktree-preview up: GREEN slug=$slug port=$port pid=$pid url=$url ($w)"
463
+ return 0
464
+ }
465
+
466
+ # ---------------------------------------------------------------------------
467
+ # down — frees THIS port. Never sweeps the range: /kill's sweep also kills a
468
+ # neighbour project's dev server (it names apps/one/site on 4322).
469
+ # ---------------------------------------------------------------------------
470
+ cmd_down() {
471
+ local slug="${1:-}" keep=0
472
+ [ -n "$slug" ] || die "down needs a slug"
473
+ shift || true
474
+ [ "${1:-}" = "--keep-worktree" ] && keep=1
475
+ local wt="$WT_DIR/$slug" app="$WT_DIR/$slug/$APP_DIR" state="$WT_DIR/$slug/$APP_DIR/.preview"
476
+ local port="" pid=""
477
+ [ -f "$state/port" ] && port="$(cat "$state/port")"
478
+ [ -f "$state/pid" ] && pid="$(cat "$state/pid")"
479
+
480
+ if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
481
+ kill -TERM -- "-$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null || true
482
+ sleep 1
483
+ kill -0 "$pid" 2>/dev/null && { kill -9 -- "-$pid" 2>/dev/null || kill -9 "$pid" 2>/dev/null || true; }
484
+ fi
485
+ if [ -n "$port" ]; then
486
+ local victim
487
+ while IFS= read -r victim; do
488
+ [ -n "$victim" ] && kill -9 "$victim" 2>/dev/null
489
+ done < <(lsof -ti:"$port" 2>/dev/null)
490
+ port_release "$port"
491
+ fi
492
+ # Keeping the worktree "for forensics" while deleting the ONLY artifact that
493
+ # says why it failed is not forensics. On the --keep-worktree path preserve
494
+ # astro.log; drop only the live-state files. (Measured: when a preview died on
495
+ # EADDRINUSE the log was gone and the failure had to be reconstructed from the
496
+ # caller's captured stderr.)
497
+ # Read the ownership marker BEFORE the state dir is cleared — it lives there.
498
+ local owned=0; [ -f "$state/owned" ] && owned=1
499
+ if [ "$keep" -eq 1 ] && [ -f "$state/astro.log" ]; then
500
+ mv "$state/astro.log" "$state/astro.log.failed" 2>/dev/null || true
501
+ rm -f "$state/pid" "$state/port" "$state/url" 2>/dev/null || true
502
+ else
503
+ rm -rf "$state" 2>/dev/null || true
504
+ fi
505
+
506
+ if [ "$keep" -eq 0 ] && [ -d "$wt" ]; then
507
+ if [ "$owned" -eq 1 ]; then
508
+ git -C "$SOURCE_ROOT" worktree remove --force "$wt" >/dev/null 2>&1 || rm -rf "$wt"
509
+ git -C "$SOURCE_ROOT" worktree prune >/dev/null 2>&1 || true
510
+ else
511
+ say "worktree-preview down: worktree KEPT at $wt — not created by this script (no .preview/owned marker), so it is not ours to remove"
512
+ fi
513
+ fi
514
+ local still="bound"; [ -z "$port" ] && still="n/a"
515
+ [ -n "$port" ] && { port_is_free "$port" && still="free"; }
516
+ echo "worktree-preview down: GREEN slug=$slug port=${port:-none} port_now=$still worktree=$([ -d "$wt" ] && echo kept || echo removed)"
517
+ }
518
+
519
+ cmd_list() {
520
+ local wt slug d port url alive
521
+ printf '%-28s %-6s %-6s %s\n' SLUG PORT STATE URL
522
+ for wt in "$WT_DIR"/*; do
523
+ [ -d "$wt" ] || continue
524
+ d="$wt/$APP_DIR/.preview"; [ -d "$d" ] || continue
525
+ slug="$(basename "$wt")"
526
+ port="$(cat "$d/port" 2>/dev/null)"; url="$(cat "$d/url" 2>/dev/null)"
527
+ alive=down; [ -n "$port" ] && { port_is_free "$port" || alive=up; }
528
+ printf '%-28s %-6s %-6s %s\n' "$slug" "${port:-?}" "$alive" "${url:-?}"
529
+ done
530
+ }
531
+
532
+ # ---------------------------------------------------------------------------
533
+ # --self-test
534
+ #
535
+ # A. the warm loop actually WAITS (stub servers, no astro)
536
+ # B. the isolation gate goes RED on the shared-cache arrangement
537
+ # C. TWO previews, concurrent, both 200 (the whole point)
538
+ #
539
+ # A one-server test is exactly what missed the defect the first time.
540
+ # ---------------------------------------------------------------------------
541
+ ST_FAIL=0
542
+ st_ok() { printf ' ok %s\n' "$*"; }
543
+ st_fail() { printf ' FAIL %s\n' "$*" >&2; ST_FAIL=$((ST_FAIL + 1)); }
544
+ st_head() { printf '\n== %s\n' "$*"; }
545
+
546
+ # A stub HTTP server whose status codes are scripted: "<prefix>|<cycle>".
547
+ # "500,500|200" -> 500, 500, then 200 forever (a cold cache settling)
548
+ # "|200,500" -> 200,500,200,500 forever (a flap that never settles)
549
+ # Kill a stub without bash's "Terminated: 15" job notification landing on stdout.
550
+ st_stub_kill() {
551
+ [ -n "${1:-}" ] || return 0
552
+ kill "$1" 2>/dev/null
553
+ wait "$1" 2>/dev/null
554
+ return 0
555
+ }
556
+
557
+ st_stub() {
558
+ local port="$1" pattern="$2" pidfile="$3"
559
+ node -e '
560
+ const http = require("http");
561
+ const [pre, cyc] = process.argv[2].split("|");
562
+ const prefix = pre ? pre.split(",").map(Number) : [];
563
+ const cycle = cyc ? cyc.split(",").map(Number) : [200];
564
+ let i = 0;
565
+ http.createServer((req, res) => {
566
+ const c = i < prefix.length ? prefix[i] : cycle[(i - prefix.length) % cycle.length];
567
+ i++;
568
+ res.writeHead(c, { "content-type": "text/plain" });
569
+ res.end(String(c));
570
+ }).listen(parseInt(process.argv[1]), "127.0.0.1");
571
+ ' "$port" "$pattern" &
572
+ echo $! > "$pidfile"
573
+ local t=0
574
+ while [ $t -lt 60 ]; do port_is_free "$port" || return 0; sleep 0.1; t=$((t + 1)); done
575
+ return 1
576
+ }
577
+
578
+ self_test() {
579
+ local tmp; tmp="$(mktemp -d "${TMPDIR:-/tmp}/wtpreview-selftest.XXXXXX")"
580
+ trap 'rm -rf "$tmp"' RETURN
581
+
582
+ # ---------------------------------------------------------------- A. warm
583
+ st_head "A — the warm loop WAITS (a cold .vite gave 500, 000, 200)"
584
+ local sp=4399 out rc naive
585
+ st_stub "$sp" "500,500|200" "$tmp/stub1.pid" || { st_fail "stub server did not bind"; return 1; }
586
+ naive="$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:$sp/")"
587
+ [ "$naive" = "500" ] && st_ok "a naive SINGLE probe sees $naive — this is what a human would have been handed" \
588
+ || st_fail "expected the first probe to be 500, got $naive"
589
+ out="$(warm_route "http://127.0.0.1:$sp/" 30 3)"; rc=$?
590
+ if [ $rc -eq 0 ] && grep -q 'verdict=GREEN' <<<"$out"; then
591
+ st_ok "warm_route waited through the flap and returned GREEN: $out"
592
+ # It CANNOT have returned on the first probe: the stub served 500 first.
593
+ # >= streak+1 probes is the arithmetic proof that it waited.
594
+ grep -qE 'probes=([4-9]|[1-9][0-9])' <<<"$out" && grep -q 'first=500' <<<"$out" \
595
+ && st_ok "it needed >=4 probes and its FIRST was 500 — it waited, it did not get lucky" \
596
+ || st_fail "warm returned too early: $out"
597
+ else
598
+ st_fail "warm_route should have settled: $out"
599
+ fi
600
+ st_stub_kill "$(cat "$tmp/stub1.pid")"
601
+
602
+ st_head "A(red) — a route that NEVER settles must NOT pass"
603
+ sp=4398
604
+ st_stub "$sp" "|200,500" "$tmp/stub2.pid" || { st_fail "stub server did not bind"; return 1; }
605
+ out="$(warm_route "http://127.0.0.1:$sp/" 8 3)"; rc=$?
606
+ if [ $rc -ne 0 ] && grep -q 'verdict=RED' <<<"$out"; then
607
+ st_ok "RED as required — a lucky 200 in an alternating flap does not pass: $out"
608
+ grep -q 'streak_broken=[1-9]' <<<"$out" && st_ok "and it SAW the streak break (streak_broken>0)" \
609
+ || st_fail "expected streak_broken>0: $out"
610
+ else
611
+ st_fail "flapping route passed the warm gate — the streak rule is not enforced: $out"
612
+ fi
613
+ st_stub_kill "$(cat "$tmp/stub2.pid")"
614
+
615
+ # ------------------------------------------------------- B. isolation gate
616
+ st_head "B — the isolation gate goes RED on the shared-cache arrangement"
617
+ local shared="$tmp/shared_nm"; mkdir -p "$shared/.bin" "$shared/.vite"
618
+ local i; for i in $(seq 1 120); do mkdir -p "$shared/pkg$i"; done
619
+
620
+ local good="$tmp/good"; mkdir -p "$good"; : > "$good/.env"
621
+ link_node_modules "$shared" "$good/node_modules" >/dev/null
622
+ if out="$(check_isolation "$good")"; then
623
+ st_ok "contents-linked tree is GREEN: $(tail -1 <<<"$out")"
624
+ else
625
+ st_fail "the GOOD arrangement went red:"; sed 's/^/ /' <<<"$out" >&2
626
+ fi
627
+
628
+ local bad="$tmp/bad"; mkdir -p "$bad"; : > "$bad/.env"
629
+ ln -s "$shared" "$bad/node_modules" # the FATAL arrangement, forced
630
+ if out="$(check_isolation "$bad")"; then
631
+ st_fail "shared-cache arrangement passed the gate — the checker cannot go red"
632
+ else
633
+ st_ok "RED as required. Verbatim:"; sed 's/^/ | /' <<<"$out"
634
+ fi
635
+
636
+ local half="$tmp/half"; mkdir -p "$half"; : > "$half/.env"
637
+ link_node_modules "$shared" "$half/node_modules" >/dev/null
638
+ rm -rf "$half/node_modules/.vite"; ln -s "$shared/.vite" "$half/node_modules/.vite"
639
+ if check_isolation "$half" >/dev/null; then
640
+ st_fail "a shared .vite INSIDE a real node_modules passed — the subtler half is unguarded"
641
+ else
642
+ st_ok "RED on a shared .vite inside an otherwise-local node_modules"
643
+ fi
644
+
645
+ # ------------------------------------------------- C. TWO REAL PREVIEWS
646
+ if [ "${PREVIEW_SELFTEST_SERVERS:-1}" != "1" ]; then
647
+ st_head "C — SKIPPED (PREVIEW_SELFTEST_SERVERS=0). This is the whole point; do not skip it in CI."
648
+ else
649
+ st_head "C — two previews, concurrent, both 200 (a one-server test missed this defect)"
650
+ local A=preview-selftest-a B=preview-selftest-b
651
+ # WT_DIR is computed ONCE at load; a `PREVIEW_WT_DIR=… cmd_up` prefix sets
652
+ # the env var but not the already-assigned shell variable, so the first run
653
+ # of this test silently built its worktrees in the SHARED .do-worktrees.
654
+ # Assign the real variable.
655
+ local saved_wt="$WT_DIR"; WT_DIR="$tmp/wt"
656
+ cmd_down "$A" >/dev/null 2>&1 || true
657
+ cmd_down "$B" >/dev/null 2>&1 || true
658
+ git -C "$SOURCE_ROOT" branch -D "feat/$A" >/dev/null 2>&1 || true
659
+ git -C "$SOURCE_ROOT" branch -D "feat/$B" >/dev/null 2>&1 || true
660
+ git -C "$SOURCE_ROOT" worktree prune >/dev/null 2>&1 || true
661
+
662
+ local base="${PREVIEW_SELFTEST_BASE:-$(git rev-parse HEAD)}"
663
+ local ra rb
664
+ ( cmd_up "$A" --route "${PREVIEW_SELFTEST_ROUTE:-/factory}" --base "$base" > "$tmp/a.out" 2>"$tmp/a.err" ) &
665
+ local pa=$!
666
+ ( cmd_up "$B" --route "${PREVIEW_SELFTEST_ROUTE:-/factory}" --base "$base" > "$tmp/b.out" 2>"$tmp/b.err" ) &
667
+ local pb=$!
668
+ wait $pa; ra=$?
669
+ wait $pb; rb=$?
670
+
671
+ printf ' --- A ---\n'; sed 's/^/ | /' "$tmp/a.out"
672
+ printf ' --- B ---\n'; sed 's/^/ | /' "$tmp/b.out"
673
+ [ $ra -eq 0 ] && st_ok "preview A: $(tail -1 "$tmp/a.out")" || { st_fail "preview A did not come up"; tail -20 "$tmp/a.err" | sed 's/^/ /' >&2; }
674
+ [ $rb -eq 0 ] && st_ok "preview B: $(tail -1 "$tmp/b.out")" || { st_fail "preview B did not come up"; tail -20 "$tmp/b.err" | sed 's/^/ /' >&2; }
675
+
676
+ local pA pB
677
+ pA="$(cat "$tmp/wt/$A/$APP_DIR/.preview/port" 2>/dev/null)"
678
+ pB="$(cat "$tmp/wt/$B/$APP_DIR/.preview/port" 2>/dev/null)"
679
+ [ -n "$pA" ] && [ -n "$pB" ] && [ "$pA" != "$pB" ] && st_ok "distinct ports: A=$pA B=$pB (allocated, not swept)" \
680
+ || st_fail "ports collided or were not recorded: A=${pA:-none} B=${pB:-none}"
681
+
682
+ # The real defect was a SHARED .vite. Prove each server wrote its OWN.
683
+ # `find` FOLLOWS a symlinked .vite into main's populated cache, so a
684
+ # non-zero count alone is satisfied by the very arrangement under test —
685
+ # assert the dirs are real AND that the two caches are not the same inode.
686
+ local nmA="$tmp/wt/$A/$APP_DIR/node_modules" nmB="$tmp/wt/$B/$APP_DIR/node_modules"
687
+ local vA vB iA iB
688
+ vA="$(find "$nmA/.vite" -type f 2>/dev/null | wc -l | tr -d ' ')"
689
+ vB="$(find "$nmB/.vite" -type f 2>/dev/null | wc -l | tr -d ' ')"
690
+ iA="$(stat -f '%d:%i' "$nmA/.vite" 2>/dev/null)"
691
+ iB="$(stat -f '%d:%i' "$nmB/.vite" 2>/dev/null)"
692
+ if [ ! -L "$nmA/.vite" ] && [ ! -L "$nmB/.vite" ] && [ -n "$iA" ] && [ "$iA" != "$iB" ] \
693
+ && [ "${vA:-0}" -gt 0 ] && [ "${vB:-0}" -gt 0 ]; then
694
+ st_ok "each server wrote its OWN .vite — distinct inodes ($iA vs $iB), A=$vA files, B=$vB files"
695
+ else
696
+ st_fail "the two .vite caches are not independent: symlinkA=$([ -L "$nmA/.vite" ] && echo yes || echo no) symlinkB=$([ -L "$nmB/.vite" ] && echo yes || echo no) inodes=$iA/$iB files=${vA:-0}/${vB:-0}"
697
+ fi
698
+
699
+ # The worktree keeps its own .wrangler — local D1/KV/R2 per tree, so a
700
+ # preview cannot corrupt the human's database. Observed, then asserted.
701
+ local wA="$tmp/wt/$A/$APP_DIR/.wrangler" wB="$tmp/wt/$B/$APP_DIR/.wrangler"
702
+ if [ -d "$wA" ] && [ ! -L "$wA" ] && [ -d "$wB" ] && [ ! -L "$wB" ]; then
703
+ st_ok "each worktree has its OWN real .wrangler (local D1/KV/R2 is per-tree)"
704
+ elif [ ! -e "$wA" ] && [ ! -e "$wB" ]; then
705
+ st_ok "no .wrangler in either worktree — astro dev never created one, so there is nothing shared to corrupt"
706
+ else
707
+ st_fail ".wrangler is not per-tree: A=$([ -L "$wA" ] && echo symlink || ([ -d "$wA" ] && echo dir || echo absent)) B=$([ -L "$wB" ] && echo symlink || ([ -d "$wB" ] && echo dir || echo absent))"
708
+ fi
709
+
710
+ # Both still serving AFTER the other has been up. The measured desync
711
+ # appeared on the SECOND and THIRD request while both were serving
712
+ # (4325 -> 000, 4326 -> 500), so probe INTERLEAVED, three times each —
713
+ # one probe apiece only proves each answered once after boot.
714
+ local uA uB k cA cB okA=1 okB=1 seqA="" seqB=""
715
+ uA="$(cat "$tmp/wt/$A/$APP_DIR/.preview/url" 2>/dev/null)"
716
+ uB="$(cat "$tmp/wt/$B/$APP_DIR/.preview/url" 2>/dev/null)"
717
+ for k in 1 2 3; do
718
+ cA="$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$uA" 2>/dev/null)"; [ ${#cA} -ne 3 ] && cA=000
719
+ cB="$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$uB" 2>/dev/null)"; [ ${#cB} -ne 3 ] && cB=000
720
+ seqA="$seqA $cA"; seqB="$seqB $cB"
721
+ [ "$cA" = "200" ] || okA=0
722
+ [ "$cB" = "200" ] || okB=0
723
+ done
724
+ [ "$okA" = 1 ] && [ "$okB" = 1 ] \
725
+ && st_ok "both STILL 200 under interleaved traffic — A:$seqA B:$seqB" \
726
+ || st_fail "concurrent servers interfered — A:$seqA B:$seqB (the shared-cache signature is 000 and 500)"
727
+
728
+ cmd_down "$A" | sed 's/^/ /'
729
+ cmd_down "$B" | sed 's/^/ /'
730
+ [ -n "$pA" ] && { port_is_free "$pA" && st_ok "teardown freed port $pA" || st_fail "port $pA still bound after down"; }
731
+ [ -n "$pB" ] && { port_is_free "$pB" && st_ok "teardown freed port $pB" || st_fail "port $pB still bound after down"; }
732
+ git -C "$SOURCE_ROOT" branch -D "feat/$A" >/dev/null 2>&1 || true
733
+ git -C "$SOURCE_ROOT" branch -D "feat/$B" >/dev/null 2>&1 || true
734
+ git -C "$SOURCE_ROOT" worktree prune >/dev/null 2>&1 || true
735
+ port_release "${pA:-0}"; port_release "${pB:-0}"
736
+ WT_DIR="$saved_wt"
737
+ fi
738
+
739
+ # ----------------------------------------------- D. the port arbiter
740
+ # This section exists because C caught the real thing: both previews were
741
+ # handed 4322. Guard the fix, and keep the reason for it honest.
742
+ st_head "D — the port arbiter gives exactly one winner"
743
+ local saved_lock="$PORT_LOCK_DIR"
744
+ PORT_LOCK_DIR="$tmp/ports"; export PORT_LOCK_DIR
745
+ local i wins
746
+ : > "$tmp/race.out"
747
+ for i in $(seq 1 12); do
748
+ ( PORT_LOCK_DIR="$tmp/ports" bash "$0" __reserve 65000 "racer$i" >/dev/null 2>&1 \
749
+ && echo win >> "$tmp/race.out" ) &
750
+ done
751
+ wait
752
+ wins="$(grep -c win "$tmp/race.out" 2>/dev/null | tr -d ' ')"
753
+ # Assert the ISOLATION before the count. A racer that wrote to the real
754
+ # machine-wide dir would be racing whatever the last run left there, and a
755
+ # 0-winner result would look like a broken arbiter instead of a leaky test.
756
+ [ -d "$tmp/ports/65000" ] \
757
+ && st_ok "the racers used the ISOLATED lock dir (PORT_LOCK_DIR was honoured, not overwritten)" \
758
+ || st_fail "no reservation under $tmp/ports — PORT_LOCK_DIR was ignored, so this race was run against shared state"
759
+ [ "${wins:-0}" = "1" ] && st_ok "12 independent racers, exactly $wins winner (mkdir(2) is the arbiter)" \
760
+ || st_fail "12 racers produced ${wins:-0} winners — the port arbiter does not exclude"
761
+
762
+ st_head "D(red) — WHY claim_take is not used here: it double-grants across subshells"
763
+ if declare -F claim_take >/dev/null 2>&1; then
764
+ local before after
765
+ GOVERN_CLAIMS_DIR="$tmp/claims"; export GOVERN_CLAIMS_DIR
766
+ mkdir -p "$GOVERN_CLAIMS_DIR"
767
+ : > "$tmp/claim.out"
768
+ ( claim_take "wtpreview-selftest-region" one >/dev/null 2>&1 && echo win >> "$tmp/claim.out" ) &
769
+ ( claim_take "wtpreview-selftest-region" two >/dev/null 2>&1 && echo win >> "$tmp/claim.out" ) &
770
+ wait
771
+ after="$(grep -c win "$tmp/claim.out" 2>/dev/null | tr -d ' ')"
772
+ if [ "${after:-0}" -ge 2 ]; then
773
+ st_ok "confirmed: claim_take granted the SAME region to $after subshells of one shell."
774
+ st_ok " bash $BASH_VERSION has no BASHPID, so \$\$ is identical in both — this is the"
775
+ st_ok " measured cause of 'A port=4322 / B port=4322'. Hence reserve_port."
776
+ else
777
+ st_fail "claim_take no longer double-grants ($after winner) — the note above reserve_port is STALE, re-check whether govern.sh can arbitrate here"
778
+ fi
779
+ unset GOVERN_CLAIMS_DIR
780
+ else
781
+ st_fail "govern.sh not loaded — cannot demonstrate the claim_take trap"
782
+ fi
783
+ PORT_LOCK_DIR="$saved_lock"
784
+
785
+ # ------------------------------------- E. the boot banner is READ, not assumed
786
+ # A check that cannot match anything is decoration. This one shipped dead: the
787
+ # grep looked for `localhost:` and astro prints `127.0.0.1:` under --host.
788
+ st_head "E — log_banner_port reads the port astro ACTUALLY bound"
789
+ local L="$tmp/banner.log" got
790
+ printf '%s\n' '18:03:40 [WARN] [vite] Default inspector port 9229 not available, using 9231 instead' \
791
+ '┃ Local http://127.0.0.1:4323/' > "$L"
792
+ got="$(log_banner_port "$L")"
793
+ [ "$got" = "4323" ] && st_ok "the REAL banner form (--host 127.0.0.1) parses to $got" \
794
+ || st_fail "the real banner form parsed to '${got:-empty}', expected 4323 — the check is dead again"
795
+
796
+ printf '%s\n' '┃ Local http://localhost:4327/' > "$L"
797
+ got="$(log_banner_port "$L")"
798
+ [ "$got" = "4327" ] && st_ok "the localhost form parses to $got" \
799
+ || st_fail "localhost form parsed to '${got:-empty}'"
800
+
801
+ st_head "E(red) — a server that took a DIFFERENT port must be caught"
802
+ printf '%s\n' '┃ Local http://127.0.0.1:4399/' > "$L"
803
+ got="$(log_banner_port "$L")"
804
+ if [ -n "$got" ] && [ "$got" != "4322" ]; then
805
+ st_ok "RED as required: asked for 4322, the log says the server bound $got — cmd_up refuses and tears down"
806
+ else
807
+ st_fail "a mismatched banner was not detected (parsed '${got:-empty}') — a second preview could silently probe the first server's port"
808
+ fi
809
+ printf '%s\n' 'no banner here at all' > "$L"
810
+ [ -z "$(log_banner_port "$L")" ] && st_ok "a log with no banner yields empty, so the boot loop keeps waiting rather than passing" \
811
+ || st_fail "a bannerless log produced a port"
812
+
813
+ # ------------------------------- F. the RED path's side effects are REAL
814
+ # cmd_up's failure line promises three things: the server is killed, the port
815
+ # is freed, and the worktree is kept WITH its log. Those were only ever
816
+ # verified by reading the code — and one of them was false: `rm -rf "$state"`
817
+ # deleted the very astro.log the message called forensics. Drive it.
818
+ st_head "F — a RED teardown frees the port and KEEPS the log"
819
+ local fslug=preview-selftest-red fwt="$tmp/redwt" fapp
820
+ fapp="$fwt/$fslug/$APP_DIR"
821
+ mkdir -p "$fapp/.preview"
822
+ printf 'Error: listen EADDRINUSE: address already in use 127.0.0.1:9231\n' > "$fapp/.preview/astro.log"
823
+ echo 999999 > "$fapp/.preview/pid" # a pid that is not alive
824
+ echo 65001 > "$fapp/.preview/port"
825
+ echo "http://localhost:65001/x" > "$fapp/.preview/url"
826
+ PORT_LOCK_DIR="$tmp/ports"; export PORT_LOCK_DIR
827
+ reserve_port 65001 "$fslug" >/dev/null 2>&1
828
+ [ -d "$tmp/ports/65001" ] && st_ok "reservation for 65001 exists before teardown" \
829
+ || st_fail "could not set up the reservation fixture"
830
+
831
+ local saved2="$WT_DIR"; WT_DIR="$fwt"
832
+ cmd_down "$fslug" --keep-worktree > "$tmp/red-down.out" 2>&1
833
+ WT_DIR="$saved2"
834
+
835
+ [ -f "$fapp/.preview/astro.log.failed" ] \
836
+ && st_ok "the log SURVIVED as astro.log.failed — the RED is actually diagnosable" \
837
+ || st_fail "astro.log.failed is missing: a RED that keeps the worktree but deletes the log is not forensics"
838
+ grep -q EADDRINUSE "$fapp/.preview/astro.log.failed" 2>/dev/null \
839
+ && st_ok "and it still carries the failure reason verbatim" \
840
+ || st_fail "the kept log lost its content"
841
+ { [ ! -f "$fapp/.preview/pid" ] && [ ! -f "$fapp/.preview/port" ] && [ ! -f "$fapp/.preview/url" ]; } \
842
+ && st_ok "the live-state files (pid/port/url) are gone, so nothing reads a dead preview as up" \
843
+ || st_fail "stale live-state survived the teardown"
844
+ [ ! -d "$tmp/ports/65001" ] \
845
+ && st_ok "the port reservation was released — the next allocator can take 65001" \
846
+ || st_fail "the port reservation LEAKED; the range would shrink by one every failure"
847
+ [ -d "$fwt/$fslug" ] && st_ok "the worktree itself was kept, as the message claims" \
848
+ || st_fail "--keep-worktree removed the tree"
849
+ PORT_LOCK_DIR="$saved_lock"
850
+
851
+ printf '\n'
852
+ if [ "$ST_FAIL" -eq 0 ]; then
853
+ echo "worktree-preview --self-test: GREEN (0 failures)"; return 0
854
+ fi
855
+ echo "worktree-preview --self-test: RED ($ST_FAIL failures)"; return 1
856
+ }
857
+
858
+ # ---------------------------------------------------------------------------
859
+ case "${1:---help}" in
860
+ up) shift; cmd_up "$@" ;;
861
+ down) shift; cmd_down "$@" ;;
862
+ list) shift; cmd_list "$@" ;;
863
+ check)
864
+ shift
865
+ if [ "${1:-}" = "--dir" ]; then check_isolation "$2";
866
+ else check_isolation "$WT_DIR/${1:?check needs a slug}/$APP_DIR"; fi ;;
867
+ --self-test)
868
+ # A heavy gate goes through the governor. gate-run.sh exports
869
+ # GOVERN_IN_GATE=1, so this re-exec cannot recurse.
870
+ if [ "${GOVERN_IN_GATE:-0}" != "1" ] && [ -x "$SCRIPT_DIR/gate-run.sh" ]; then
871
+ exec "$SCRIPT_DIR/gate-run.sh" worktree-preview-selftest -- "$0" --self-test
872
+ fi
873
+ self_test ;;
874
+ __reserve) # internal — one fresh process taking one port, for --self-test D
875
+ shift; reserve_port "${1:?port}" "${2:-racer}" ;;
876
+ -h|--help|help)
877
+ sed -n '2,60p' "$0" | sed 's/^# \{0,1\}//' ;;
878
+ *) die "unknown command: $1" ;;
879
+ esac