@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/land.sh CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bash
2
- # land.sh — a finished fleet branch to dev.one.ie in one command.
2
+ # land.sh — a finished branch into `dev` in one command. `--to main` overrides.
3
3
  #
4
4
  # bash .claude/scripts/land.sh feat/campaign-one
5
5
  # bash .claude/scripts/land.sh feat/campaign-one --deploy # also ship dev
@@ -12,9 +12,9 @@
12
12
  # was being done by hand, once per fleet, and it is the slow part.
13
13
  #
14
14
  # THE ORDER IS THE POINT:
15
- # 1 merge main INTO the branch, in the branch's own worktree (cheap, isolated)
15
+ # 1 merge $TRUNK (dev) INTO the branch, in its own worktree (cheap, isolated)
16
16
  # 2 run the FAST gate there (the dev-tier gate)
17
- # 3 only then fast-forward main (main never breaks)
17
+ # 3 only then fast-forward $TRUNK (dev never breaks)
18
18
  # 4 optionally ./deploy dev (fast lane again)
19
19
  # Merging main into the branch first means a conflict or a red gate is discovered
20
20
  # on the BRANCH, where it costs nothing, instead of on main, where every other
@@ -22,22 +22,144 @@
22
22
  #
23
23
  # What it will NOT do: touch production. Promotion to one.ie is `./deploy`, a
24
24
  # full gate and a human decision. A fast pass is never reported as a full pass.
25
+ #
26
+ # --- A RED ON THE INTEGRATED TREE HAS THREE OWNERS ---------------------------
27
+ # Step 2 gates the branch WITH dev merged in, so a red there can belong to:
28
+ #
29
+ # the branch — red on its own, dev innocent
30
+ # dev — dev was already red; every branch landing now inherits it
31
+ # the seam — green alone, green on dev, red TOGETHER (a signature dev
32
+ # changed, a call the branch added; each side typechecks)
33
+ # (and a fourth that is nobody's: the ENVIRONMENT — the substrate door the
34
+ # real-TypeDB suites need was down at gate time, and eight of those suites
35
+ # refuse to skip by design)
36
+ #
37
+ # Until 2026-09-12 every one of these printed the same line — "fast gate RED on
38
+ # <branch> — not landing it" — which is the wrong owner three ways out of four,
39
+ # and a wrong diagnosis is worse than none because it sends the executor to fix
40
+ # the wrong tree. Measured that day on feat/agent-page: the first red WAS the
41
+ # seam (TS2559 — dev changed authorizeWorkspace's env shape, the branch's
42
+ # overview.ts passed the old one; both green alone), the executor fixed it, and
43
+ # the NEXT red — same line, same blame — was `fixture write failed (transport:
44
+ # fetch failed)` against a local gateway that was not answering; the same suite
45
+ # passed 4/4 five minutes later. Two reds, two owners, one message.
46
+ #
47
+ # So a red is DIAGNOSED, deterministically, from three cheap facts:
48
+ # 1. the branch's own typecheck BEFORE the merge — a memo lookup
49
+ # (`tsc-cached.sh --probe`; verify:fast stamps it on every edit), never a
50
+ # compute: `green` or `unknown`
51
+ # 2. dev's own typecheck at the sha merged in — the same lookup on dev's tree
52
+ # 3. the gate's own output: `error TS` lines, transport failures, assertion
53
+ # failures, and WHICH SIDE of the merge touched each failing file
54
+ # and the record (`deploy-runs.json`) carries the verdict as its note, so the
55
+ # /deploy page shows why, not just that.
56
+ #
57
+ # --- THE OTHER DOOR: --pr ----------------------------------------------------
58
+ # bash .claude/scripts/land.sh feat/x --pr # open/update a PR, do not touch main
59
+ #
60
+ # `--pr` swaps step 3 for a pull request, and it also SKIPS STEP 1 — deliberately,
61
+ # because the two orders answer different questions. Landing merges main into the
62
+ # branch first so a conflict is discovered where it costs nothing; a PR wants the
63
+ # branch as the author wrote it, so GitHub computes the merge and the diff a
64
+ # reviewer reads is what this branch added and nothing else. Merging main in
65
+ # first would put trunk's own commits in the PR and make the branch
66
+ # non-fast-forwardable at the same time.
67
+ #
68
+ # So the gate under `--pr` proves LESS than the gate under a land: it says the
69
+ # branch is green on its own, not that it survives the trunk. The body says so,
70
+ # and reports how far trunk has moved plus whether the merge is clean
71
+ # (`git merge-tree`, which computes without mutating anything).
72
+ #
73
+ # --- --pr --deploy: run it before you propose it ------------------------------
74
+ # bash .claude/scripts/land.sh feat/x --pr --deploy --probe /pricing
75
+ #
76
+ # The full workflow, in one command and in this order:
77
+ #
78
+ # gate (fast lane, in the branch's worktree)
79
+ # → ship THAT WORKTREE to dev.one.ie
80
+ # → prove the routes against dev, with the landing rule
81
+ # → open/update the PR, carrying the dev URL and the probe verdict
82
+ #
83
+ # The pair used to be refused, and the refusal was right while `--deploy` meant
84
+ # "ship main after the fast-forward" — a PR lands nothing, so there was nothing
85
+ # to ship. What makes it work now is a property of `deploy-dev.sh`, not a flag:
86
+ # it derives its own ROOT from `${BASH_SOURCE[0]}/../..`, so invoking THE
87
+ # WORKTREE'S COPY ships the worktree's tree. `$wt/.claude/scripts/deploy-dev.sh`
88
+ # puts the BRANCH on dev.one.ie; `$ROOT`'s copy would have put main there and
89
+ # called it the branch.
90
+ #
91
+ # The probe is `do-prove.sh` with both its bases pinned to dev, never curl.
92
+ # do-prove carries the LANDING RULE — a route counts only if the run ENDED on
93
+ # the path asked for — and deploy-dev.sh's own check is `curl / -> 200`, which
94
+ # a redirect to /signin satisfies. Both bases are pinned because do-prove falls
95
+ # back to PROVE_PROD_URL (https://one.ie by default) when its dev base does not
96
+ # answer: an unreachable dev would otherwise quietly prove PRODUCTION and report
97
+ # it as the branch passing. And `PROVE: skipped` exits 0, so the route COUNT is
98
+ # read, not the exit code — an unrun probe is not a pass.
99
+ #
100
+ # dev.one.ie IS ONE SLOT, and it reads and writes production's rows. So this door
101
+ # takes exactly one branch: two branches would mean the second overwrites the
102
+ # first while the first is still being "tested". It is also why nothing here
103
+ # touches production — promotion to one.ie is `./deploy`, a full gate, a human.
104
+ #
105
+ # The body is `pr-body.sh` — every number derived from git at PR time. Re-running
106
+ # is the normal case, not the edge case: an existing open PR is UPDATED, never
107
+ # duplicated.
25
108
  set -uo pipefail
26
109
  ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
27
110
  cd "$ROOT" || exit 1
28
111
 
29
- DEPLOY=0 DRY=0 QUICK=0 BRANCHES=()
112
+ # TRUNK is what a branch LANDS INTO; PR_BASE is what `dev` is RELEASED into.
113
+ # They are deliberately different: branches integrate on dev and main receives a
114
+ # pull request (root CLAUDE.md § the loop). Before 2026-09-06 both were `main`,
115
+ # so nothing ever advanced dev and it fell behind by construction — 5 commits one
116
+ # way, 8 the other, undetected until someone asked.
117
+ TRUNK="${LAND_TRUNK:-dev}"
118
+ DEPLOY=0 DRY=0 QUICK=0 PR=0 SELF_TEST=0 PR_BASE="main" BRANCHES=() PROBES=()
119
+ INTEGRATE=0 INTEGRATE_FULL=0
30
120
  for a in "$@"; do
31
121
  case "$a" in
122
+ --self-test) SELF_TEST=1 ;;
32
123
  --deploy) DEPLOY=1 ;;
124
+ --integrate) INTEGRATE=1 ;;
125
+ --full) INTEGRATE_FULL=1 ;;
33
126
  --quick) QUICK=1 ;;
127
+ --pr) PR=1 ;;
128
+ --to) LAND_TRUNK_ARG=1 ;;
129
+ --probe) WANT_PROBE=1 ;;
34
130
  -n|--dry-run) DRY=1 ;;
35
- -h|--help) sed -n '2,30p' "${BASH_SOURCE[0]}"; exit 0 ;;
131
+ -h|--help) sed -n '2,80p' "${BASH_SOURCE[0]}"; exit 0 ;;
36
132
  -*) echo "land.sh: unknown flag '$a'" >&2; exit 2 ;;
37
- *) BRANCHES+=("$a") ;;
133
+ *) if [[ "${WANT_PROBE:-0}" == 1 ]]; then PROBES+=("$a"); WANT_PROBE=0;
134
+ elif [[ "${LAND_TRUNK_ARG:-0}" == 1 ]]; then TRUNK="$a"; LAND_TRUNK_ARG=0;
135
+ else BRANCHES+=("$a"); fi ;;
38
136
  esac
39
137
  done
40
- (( ${#BRANCHES[@]} )) || { echo "land.sh: name at least one branch" >&2; exit 2; }
138
+ [[ "${WANT_PROBE:-0}" == 1 ]] && { echo "land.sh: --probe needs a route" >&2; exit 2; }
139
+ (( ${#BRANCHES[@]} || SELF_TEST )) || { echo "land.sh: name at least one branch" >&2; exit 2; }
140
+ # --integrate advances $TRUNK; --pr deliberately advances nothing. Asking for
141
+ # both is asking for two different answers to "where does this work go".
142
+ if (( INTEGRATE && PR )); then
143
+ echo "land.sh: --integrate and --pr are opposite doors — --integrate advances $TRUNK, --pr advances nothing" >&2
144
+ exit 2
145
+ fi
146
+ if (( INTEGRATE && QUICK )); then
147
+ echo "land.sh: --quick is a per-branch concession; --integrate already costs ONE gate — run it properly" >&2
148
+ exit 2
149
+ fi
150
+ # --pr --deploy is the full workflow (gate -> dev -> probe -> PR) and it ships
151
+ # the BRANCH's worktree, not main. dev.one.ie is one slot, so it takes one branch:
152
+ # with two, the second overwrites the first while the first is being probed.
153
+ if (( PR && DEPLOY )) && (( ${#BRANCHES[@]} > 1 )); then
154
+ echo "land.sh: --pr --deploy ships one branch to dev.one.ie — name one, not ${#BRANCHES[@]}" >&2
155
+ exit 2
156
+ fi
157
+ # --probe only means anything when something was shipped to probe.
158
+ if (( ${#PROBES[@]} )) && ! (( PR && DEPLOY )); then
159
+ echo "land.sh: --probe needs --pr --deploy (there is nothing on dev to probe otherwise)" >&2
160
+ exit 2
161
+ fi
162
+ (( PR )) && ! command -v gh >/dev/null 2>&1 && { echo "land.sh: --pr needs the gh CLI" >&2; exit 2; }
41
163
 
42
164
  GRN=$'\033[32m'; RED=$'\033[31m'; YEL=$'\033[33m'; DIM=$'\033[2m'; OFF=$'\033[0m'
43
165
  [[ -t 1 ]] || { GRN=""; RED=""; YEL=""; DIM=""; OFF=""; }
@@ -55,10 +177,287 @@ worktree_of() {
55
177
  | head -1
56
178
  }
57
179
 
58
- LANDED=() FAILED=() LANDED_QUICK=0
180
+ LANDED=() FAILED=() LANDED_QUICK=0 PRS=() DEV_NOTE="" DEV_URL="https://dev.one.ie"
181
+ BR_T0=0 GATE_T=0 DEV_T=0 PROBE_T=0 PR_T=0; PH=()
182
+ # Where $TRUNK is checked out, if anywhere (dev IS, on this box — see step 3).
183
+ TRUNK_WT="$(worktree_of "$TRUNK")"
184
+
185
+ # Per-branch facts the diagnosis reads. Reset at the top of every branch.
186
+ pre_sha="" dev_sha="" PRE_TSC="unknown" DEV_TSC="unknown" GW_URL="" GW_HTTP=""
187
+ DEV_FILES="" BR_FILES="" DIAG_NOTE=""
188
+
189
+ # The memo lookup — `green` when this exact tree has a recorded typecheck PASS,
190
+ # `unknown` otherwise (never checked, or checked and red: a red is never
191
+ # stamped). Never computes: TSC_CACHE_ROOT points MAIN's tsc-cached at another
192
+ # checkout, and the key is content, so a stamp made in the branch's worktree by
193
+ # its own verify:fast is found here.
194
+ _tsc_memo() { # <tree>
195
+ TSC_CACHE_ROOT="$1" bash "$ROOT/.claude/scripts/tsc-cached.sh" --probe one.ie/web 2>/dev/null || echo unknown
196
+ }
197
+
198
+ # Which side of the merge touched a repo-relative path. DEV_FILES is what the
199
+ # merge brought in (pre-merge sha..HEAD); BR_FILES is what the branch added
200
+ # (dev sha..HEAD). Both computed by git after step 1, so this cannot drift.
201
+ _side() { # <repo-relative path>
202
+ local d=0 b=0
203
+ [[ -n "$DEV_FILES" ]] && grep -qxF "$1" <<<"$DEV_FILES" && d=1
204
+ [[ -n "$BR_FILES" ]] && grep -qxF "$1" <<<"$BR_FILES" && b=1
205
+ case "$d$b" in
206
+ 11) echo "both sides touched it" ;;
207
+ 10) echo "$TRUNK's change" ;;
208
+ 01) echo "the branch's file" ;;
209
+ *) echo "touched by neither side" ;;
210
+ esac
211
+ }
212
+
213
+ # The diagnosis. Reads the gate's own output plus the two memo facts; prints the
214
+ # block and sets DIAG_NOTE (one line, for the record). It never changes the
215
+ # verdict — the gate is red and the branch is not landing; this says WHOSE red.
216
+ _land_diagnose() { # <branch> <gate-log>
217
+ local br="$1" log="$2" ts_n env_n assert_n fails f
218
+ ts_n=$(grep -c 'error TS' "$log" 2>/dev/null || true); ts_n=${ts_n:-0}
219
+ env_n=$(grep -cE 'transport: fetch failed|fixture write failed|ECONNREFUSED|unreachable \(' "$log" 2>/dev/null || true); env_n=${env_n:-0}
220
+ assert_n=$(grep -cE 'AssertionError|expected .* to (be|equal|have|contain|match|throw|deep)' "$log" 2>/dev/null || true); assert_n=${assert_n:-0}
221
+ fails="$(grep -oE 'FAIL +[^ ]+\.test\.[a-z]+' "$log" 2>/dev/null | awk '{print $2}' | sort -u || true)"
222
+ say ""
223
+ say " ── why the gate is red ──"
224
+ if (( ts_n > 0 )); then
225
+ say " typecheck: $ts_n error(s) on the INTEGRATED tree"
226
+ say " branch alone @${pre_sha:-?} : $PRE_TSC"
227
+ say " $TRUNK alone @${dev_sha:-?} : $DEV_TSC"
228
+ say " integrated : $ts_n error(s)"
229
+ grep 'error TS' "$log" | head -3 | sed 's/^/ /'
230
+ for f in $(sed -nE 's/^([^(]+)\([0-9]+,[0-9]+\): error TS.*/\1/p' "$log" | sort -u | head -5); do
231
+ say " $f — $(_side "one.ie/web/$f")"
232
+ done
233
+ if [[ "$DEV_TSC" == unknown* ]]; then
234
+ warn "$TRUNK's own typecheck is NOT on record at ${dev_sha:-?} — check $TRUNK before blaming $br:"
235
+ say " TSC_CACHE_ROOT=${TRUNK_WT:-<$TRUNK worktree>} bash .claude/scripts/tsc-cached.sh one.ie/web"
236
+ DIAG_NOTE="typecheck red on the integrated tree; $TRUNK unverified at ${dev_sha:-?}"
237
+ elif [[ "$PRE_TSC" == green ]]; then
238
+ warn "INTEGRATION defect — green alone, green on $TRUNK, red together."
239
+ warn " $TRUNK brought $(git -C "$wt" rev-list --count "${pre_sha:-HEAD}..HEAD" 2>/dev/null || echo '?') commit(s) touching $(grep -c . <<<"$DEV_FILES" || true) file(s); the error lives in the seam."
240
+ warn " Fix on $br (in $wt), then re-run: bash .claude/scripts/land.sh $br"
241
+ DIAG_NOTE="integration defect: $ts_n tsc error(s) only when $br and $TRUNK meet"
242
+ else
243
+ warn "$br was NOT typechecked as committed before this land — its own share is unknown."
244
+ warn " Run verify:fast in $wt first; a memo of that pass is what lets the next land attribute a red."
245
+ DIAG_NOTE="typecheck red; $br unverified alone"
246
+ fi
247
+ return 0
248
+ fi
249
+ if (( env_n > 0 && assert_n == 0 )); then
250
+ say " ENVIRONMENT: $env_n transport failure(s), 0 assertion failures"
251
+ [[ -n "$fails" ]] && printf '%s\n' "$fails" | sed 's/^/ /'
252
+ if [[ "$GW_HTTP" == "000" ]]; then
253
+ warn "the substrate door $GW_URL was DOWN at pre-flight and the gate could not reach it."
254
+ else
255
+ warn "the substrate door $GW_URL answered at pre-flight (HTTP $GW_HTTP) and dropped during the run — a wrangler reload, or a flip of .env under a running gate."
256
+ fi
257
+ warn " These suites refuse to skip by design (real TypeDB or red). This is NOT evidence against $br."
258
+ warn " Bring the gateway back (or point GATEWAY_URL at a live one; \`typedb-env.sh\` flips .env) and re-run: bash .claude/scripts/land.sh $br"
259
+ DIAG_NOTE="environment: substrate $GW_URL unreachable ($env_n transport failure(s), 0 assertions)"
260
+ return 0
261
+ fi
262
+ say " tests: $assert_n assertion failure(s)"
263
+ for f in $fails; do say " $f — $(_side "one.ie/web/$f")"; done
264
+ [[ -z "$fails" ]] && say " (no FAIL line found — read the gate output above)"
265
+ DIAG_NOTE="test red: $(printf '%s' "$fails" | tr '\n' ' ')"
266
+ return 0
267
+ }
268
+
269
+ # The record the /deploy page renders. A RED run is the one most worth a timing —
270
+ # a record holding only successes cannot be read for a trend, and the phase that
271
+ # refused is exactly what a reader wants to see. It can never change an exit
272
+ # status: the whole call is `|| true`.
273
+ _land_record() { # <branch> <verdict> [note]
274
+ [[ -x "$ROOT/.claude/scripts/deploy-record.sh" ]] || return 0
275
+ (( DRY )) && return 0
276
+ local door="land"
277
+ (( PR )) && door="$door --pr"
278
+ (( DEPLOY )) && door="$door --deploy"
279
+ local target="$TRUNK"
280
+ (( PR && DEPLOY )) && target="$DEV_URL"
281
+ local -a args=(--door "$door" --branch "$1" --verdict "$2" --target "$target"
282
+ --sha "$(git -C "$ROOT" rev-parse --short "$1" 2>/dev/null || echo '')"
283
+ --wall "$(( SECONDS - BR_T0 ))")
284
+ [[ -n "${3:-}" ]] && args+=(--note "$3")
285
+ local p; for p in ${PH[@]+"${PH[@]}"}; do args+=(--phase "$p"); done
286
+ bash "$ROOT/.claude/scripts/deploy-record.sh" "${args[@]}" >/dev/null 2>&1 || true
287
+ # The same nudge deploy.sh prints. This door is where it is cheapest to act
288
+ # on: land runs many times a day, the operator already has the worktrees open,
289
+ # and once the rows are committed on one the message degrades to the very
290
+ # command being typed here — `land.sh <branch>`. Note ROOT is MAIN's tree (run
291
+ # MAIN's copy), so --land itself is refused here; the message says where to go.
292
+ # Never fatal — `|| true`, like the record call above it.
293
+ local pend; pend="$(bash "$ROOT/.claude/scripts/deploy-record.sh" --pending 2>/dev/null)" || true
294
+ [[ -n "$pend" ]] && say "$pend"
295
+ return 0
296
+ }
297
+
298
+ # --self-test — the diagnosis driven with synthetic gate output, including the
299
+ # RED half: a log that carries BOTH a transport failure and an assertion must
300
+ # not be excused as environment. No gate runs, no branch is touched.
301
+ if (( SELF_TEST )); then
302
+ fails=0; _log="$(mktemp)"; wt="$ROOT"
303
+ _case() { # <name> <expected DIAG_NOTE prefix> <expected substring in output>
304
+ # Not `out="$(…)"`: a command substitution is a subshell, and DIAG_NOTE set
305
+ # inside it never reaches this shell — the first cut of this checker failed
306
+ # all seven cases with an empty note for exactly that reason.
307
+ local out; _land_diagnose feat/probe "$_log" > "$_log.out" 2>&1; out="$(cat "$_log.out")"; rm -f "$_log.out"
308
+ if [[ "$DIAG_NOTE" == "$2"* ]] && [[ "$out" == *"$3"* ]]; then echo "ok: $1 → ${DIAG_NOTE%%:*}"
309
+ else echo "FAIL: $1 — note '$DIAG_NOTE', wanted '$2*' and output containing '$3'"; fails=$((fails+1)); fi
310
+ }
311
+ pre_sha=aaaaaaa dev_sha=bbbbbbb
312
+ DEV_FILES="one.ie/web/src/lib/authorize.ts"; BR_FILES="one.ie/web/src/pages/api/agents/[id]/overview.ts"
313
+ printf '%s\n' 'src/pages/api/agents/[id]/overview.ts(59,44): error TS2559: Type X has no properties in common with type Y.' \
314
+ '[verify-fast] typecheck FAILED — 1 error(s) in one.ie/web' > "$_log"
315
+ PRE_TSC=green DEV_TSC=green; _case "tsc red, both green alone" "integration defect" "the branch's file"
316
+ PRE_TSC=green DEV_TSC=unknown; _case "tsc red, dev not on record" "typecheck red on the integrated tree; dev unverified" "check dev before blaming"
317
+ PRE_TSC=unknown DEV_TSC=green; _case "tsc red, branch never checked" "typecheck red; feat/probe unverified" "NOT typechecked as committed"
318
+ printf '%s\n' 'typedbQuery transport failure: fetch failed' \
319
+ ' FAIL tests/tasks-bulk.test.ts > tasks:bulk — N rows (real TypeDB)' \
320
+ 'Error: fixture write failed (status=0 error=transport: fetch failed) — nothing below proves anything.' > "$_log"
321
+ GW_URL=http://127.0.0.1:8790 GW_HTTP=000; _case "transport red, door down" "environment:" "was DOWN at pre-flight"
322
+ GW_HTTP=404; _case "transport red, door dropped" "environment:" "dropped during the run"
323
+ printf '%s\n' ' FAIL tests/unit/foo.test.ts > foo' 'AssertionError: expected 1 to be 2' > "$_log"
324
+ _case "assertion red" "test red: tests/unit/foo.test.ts" "1 assertion failure"
325
+ # RED PROOF: a transport line next to a real assertion is a test red, not an excuse.
326
+ printf '%s\n' 'typedbQuery transport failure: fetch failed' ' FAIL tests/unit/foo.test.ts > foo' 'AssertionError: expected 1 to be 2' > "$_log"
327
+ _case "transport + assertion" "test red:" "assertion failure"
328
+ rm -f "$_log"
329
+ [[ "$fails" -eq 0 ]] && { echo "land.sh: self-test PASS"; exit 0; }
330
+ echo "land.sh: self-test FAILED ($fails)"; exit 1
331
+ fi
332
+
333
+ # ── THE TRUNK LOCK ───────────────────────────────────────────────────────────
334
+ # Three land.sh runs raced on `dev` on 2026-09-13 and NOTHING landed: 0 of 14
335
+ # branches in 32 minutes with every gate GREEN. The shape is a livelock, not a
336
+ # slow path — each ~20-minute gate outlived the next session's fast-forward, so
337
+ # the ff was refused as "$TRUNK genuinely moved", and the retry inherits exactly
338
+ # the same race. More sessions made it strictly worse.
339
+ #
340
+ # The fix is not to forbid concurrency, it is to SERIALISE the trunk: one lander
341
+ # owns $TRUNK at a time and the others queue behind it, so a second session
342
+ # lands AFTER the first instead of invalidating it. The primitive already
343
+ # existed (gate_lock, lib/govern.sh) — land.sh simply never took it.
344
+ #
345
+ # Not held under --pr (a PR never advances $TRUNK, so there is nothing to race)
346
+ # and not under --dry-run. LAND_LOCK_WAIT bounds the queue; the default is
347
+ # generous because waiting is the CHEAP outcome here — the expensive one is a
348
+ # gate that runs for 20 minutes and then cannot land.
349
+ if (( ! PR && ! DRY )); then
350
+ # shellcheck source=lib/govern.sh
351
+ . "$ROOT/.claude/scripts/lib/govern.sh"
352
+ if ! gate_lock "land-$TRUNK" "${LAND_LOCK_WAIT:-3600}"; then
353
+ echo "land.sh: another land into $TRUNK still holds the lock after ${LAND_LOCK_WAIT:-3600}s." >&2
354
+ echo "land.sh: NOT racing it — a concurrent land is how 0 of 14 branches landed on 2026-09-13." >&2
355
+ exit 1
356
+ fi
357
+ trap 'gate_unlock "land-'"$TRUNK"'"' EXIT
358
+ fi
359
+
360
+ # ── --integrate: N branches, ONE gate ────────────────────────────────────────
361
+ # The default path gates each branch in its own worktree: N branches cost N
362
+ # gates. On a box whose governor funds ONE slot (the effective cap is
363
+ # min(GOVERN_MAX_GATES, what memory funds at ~2300MB each) — measured cap 1 on
364
+ # 2026-09-13 with 2GB free), those gates are strictly serial, so 14 branches is
365
+ # ~14 × 20min ≈ 5 hours of wall clock to prove a tree nobody will ever ship.
366
+ #
367
+ # What actually ships is the INTEGRATED tree, and root CLAUDE.md already says
368
+ # so: "one gate on the integrated tree also costs one gate, not N." This door
369
+ # implements that sentence — merge every named branch into $TRUNK's worktree,
370
+ # then gate ONCE. 14× less gate, and it is a STRICTLY STRONGER proof than the
371
+ # per-branch lane, which is green on each branch and blind to the defect that
372
+ # only exists once they are combined (measured twice on 2026-09-13: five inbox
373
+ # suites, then two stale stubs that reached main and blocked the release).
374
+ #
375
+ # The trade, stated plainly: a red here does not name its branch. That is what
376
+ # --no-ff buys back — every branch is one identifiable merge commit, so
377
+ # `git revert -m 1 <sha>` backs out a single suspect without disturbing the
378
+ # rest. A conflicting branch is SET ASIDE and named, never aborted wholesale:
379
+ # one conflict must not cost the other thirteen merges.
380
+ if (( INTEGRATE )); then
381
+ step "integrate ${#BRANCHES[@]} branch(es) into $TRUNK — one gate, not ${#BRANCHES[@]}"
382
+
383
+ TRUNK_WT="$(git worktree list --porcelain \
384
+ | awk -v b="refs/heads/$TRUNK" '/^worktree /{w=$2} $0=="branch "b{print w; exit}')"
385
+ [[ -n "$TRUNK_WT" ]] || { bad "$TRUNK is not checked out in any worktree — cannot integrate"; exit 2; }
386
+ say " worktree: $TRUNK_WT"
387
+
388
+ # A dirty trunk is the same refusal the ff path makes, and for the same
389
+ # reason: a gate on a tree carrying someone's half-typed file measures that
390
+ # file, not these branches.
391
+ if [[ -n "$(git -C "$TRUNK_WT" status --porcelain --untracked-files=no)" ]]; then
392
+ bad "$TRUNK's worktree has uncommitted TRACKED changes — refusing to integrate onto it"
393
+ git -C "$TRUNK_WT" status --short --untracked-files=no | sed 's/^/ /'
394
+ exit 2
395
+ fi
396
+
397
+ MERGED=() CONFLICTED=() ALREADY=()
398
+ for br in "${BRANCHES[@]}"; do
399
+ git show-ref --verify --quiet "refs/heads/$br" || { warn "no such branch: $br"; FAILED+=("$br: missing"); continue; }
400
+ if [[ -z "$(git -C "$TRUNK_WT" rev-list "$TRUNK..$br")" ]]; then
401
+ ok "$br — already in $TRUNK, nothing to merge"; ALREADY+=("$br"); continue
402
+ fi
403
+ if (( DRY )); then say " [dry] (cd $TRUNK_WT && git merge --no-ff $br)"; MERGED+=("$br"); continue; fi
404
+ if git -C "$TRUNK_WT" merge --no-ff --no-edit "$br" >/dev/null 2>&1; then
405
+ ok "merged $br"; MERGED+=("$br")
406
+ else
407
+ git -C "$TRUNK_WT" merge --abort 2>/dev/null || true
408
+ warn "CONFLICT: $br — set aside, $TRUNK untouched by it"
409
+ CONFLICTED+=("$br"); FAILED+=("$br: conflict")
410
+ fi
411
+ done
412
+
413
+ say ""
414
+ say " merged=${#MERGED[@]} already-in=${#ALREADY[@]} conflicted=${#CONFLICTED[@]}"
415
+ (( ${#CONFLICTED[@]} )) && { say " resolve by hand, then land individually:"; printf ' %s\n' "${CONFLICTED[@]}"; }
416
+
417
+ if (( ! ${#MERGED[@]} )); then
418
+ say " nothing new merged — no gate to run"
419
+ exit $(( ${#CONFLICTED[@]} ? 1 : 0 ))
420
+ fi
421
+
422
+ # THE one gate. Run from the PACKAGE dir, never the worktree root: pointed at
423
+ # the root, verify-fast resolves folder=<worktree>, finds 0 changed files,
424
+ # falls back to the full suite, and that fallback dies on a missing "test"
425
+ # script — measured 2026-09-13, and it exited 0 while running no tests at all.
426
+ # And NEVER pipe it: `bun run verify | tail` reports tail's status, which
427
+ # turned a RED lane (`test-lanes: FAILED`) into a reported pass twice in one
428
+ # night. The subshell form below keeps $? honest.
429
+ #
430
+ # A BRANCH CAN MOVE UNDER YOU between the merge and the gate — measured the
431
+ # same night: the fix that unblocked the release was pushed to a branch after
432
+ # this door had already merged it, so $TRUNK carried the merge commit but not
433
+ # the fix. `git merge-base --is-ancestor <br> $TRUNK` is the check; a merge
434
+ # commit's presence proves nothing about a branch's current contents.
435
+ if (( DRY )); then
436
+ (( INTEGRATE_FULL )) && say " [dry] (cd $TRUNK_WT/one.ie/web && FULL_VERIFY=1 bun run verify)" \
437
+ || say " [dry] (cd $TRUNK_WT/one.ie/web && bun run verify:fast)"
438
+ else
439
+ if (( INTEGRATE_FULL )); then
440
+ say " gate: FULL lane on the integrated tree"
441
+ _gate() ( cd "$TRUNK_WT/one.ie/web" && FULL_VERIFY=1 bash "$ROOT/.claude/scripts/gate-run.sh" verify -- bun run verify )
442
+ else
443
+ say " gate: FAST lane on the integrated tree (not a full pass)"
444
+ _gate() ( cd "$TRUNK_WT/one.ie/web" && bash "$ROOT/.claude/scripts/gate-run.sh" verify-fast -- bun run verify:fast )
445
+ fi
446
+ if _gate; then
447
+ ok "$TRUNK is GREEN with ${#MERGED[@]} branch(es) integrated"
448
+ else
449
+ bad "$TRUNK is RED with ${#MERGED[@]} branch(es) integrated — the merges are LOCAL, nothing was pushed"
450
+ say " back one branch out with: git -C $TRUNK_WT log --merges --oneline -${#MERGED[@]}"
451
+ say " git -C $TRUNK_WT revert -m 1 <merge-sha>"
452
+ exit 1
453
+ fi
454
+ fi
455
+ exit $(( ${#CONFLICTED[@]} ? 1 : 0 ))
456
+ fi
59
457
 
60
458
  for br in "${BRANCHES[@]}"; do
61
459
  step "land $br"
460
+ BR_T0=$SECONDS; PH=(); GATE_T=0; DEV_T=0; PROBE_T=0; PR_T=0
62
461
 
63
462
  git show-ref --verify --quiet "refs/heads/$br" || { bad "no such branch"; FAILED+=("$br: missing"); continue; }
64
463
 
@@ -71,39 +470,83 @@ for br in "${BRANCHES[@]}"; do
71
470
  fi
72
471
  fi
73
472
  say " worktree: $wt"
473
+ pre_sha="" dev_sha="" PRE_TSC="unknown" DEV_TSC="unknown" GW_URL="" GW_HTTP=""
474
+ DEV_FILES="" BR_FILES="" DIAG_NOTE=""
475
+
476
+ # 0.9 — what the branch knows about ITSELF before the trunk touches it. A
477
+ # memo lookup, never a compute: every verify:fast in this worktree stamped
478
+ # its typecheck, so a branch gated the way the canon says is `green` here for
479
+ # free. Read again by _land_diagnose if step 2 goes red.
480
+ pre_sha="$(git -C "$wt" rev-parse --short HEAD 2>/dev/null || echo '')"
481
+ dev_sha="$(git rev-parse --short "$TRUNK" 2>/dev/null || echo '')"
482
+ if ! (( DRY )); then
483
+ PRE_TSC="$(_tsc_memo "$wt")"
484
+ if [[ -n "$TRUNK_WT" ]]; then
485
+ DEV_TSC="$(_tsc_memo "$TRUNK_WT")"
486
+ # The memo reflects the worktree's BYTES; uncommitted edits there are in
487
+ # the key. Say so rather than let a neighbour's half-typed file read as dev.
488
+ if [[ -n "$(git -C "$TRUNK_WT" status --porcelain --untracked-files=no 2>/dev/null)" ]]; then
489
+ DEV_TSC="$DEV_TSC (worktree has uncommitted edits)"
490
+ fi
491
+ fi
492
+ say " memo: branch alone @$pre_sha typecheck $PRE_TSC · $TRUNK @$dev_sha typecheck $DEV_TSC"
493
+ fi
74
494
 
75
495
  # 1 — main INTO the branch. A conflict surfaces here, not on main.
76
- if (( DRY )); then say " [dry] (cd $wt && git merge main)"
496
+ # Skipped under --pr: a reviewer must see what this branch added, not what
497
+ # trunk did while it built, and a branch with main merged in is no longer
498
+ # fast-forwardable. `pr-body.sh` reports mergeability without mutating.
499
+ if (( PR )); then say " ${DIM}step 1 skipped — a PR shows the branch as written${OFF}"
500
+ elif (( DRY )); then say " [dry] (cd $wt && git merge $TRUNK)"
77
501
  else
78
- if ! ( cd "$wt" && git merge --no-edit main >/dev/null 2>&1 ); then
79
- bad "merge conflict with main — resolve in $wt"
502
+ if ! ( cd "$wt" && git merge --no-edit "$TRUNK" >/dev/null 2>&1 ); then
503
+ bad "merge conflict with $TRUNK — resolve in $wt"
80
504
  ( cd "$wt" && git merge --abort >/dev/null 2>&1 )
81
- FAILED+=("$br: conflict"); continue
505
+ FAILED+=("$br: conflict")
506
+ _land_record "$br" red; continue
82
507
  fi
83
- ok "merged main into $br"
508
+ ok "merged $TRUNK into $br"
509
+ # What each side contributed, by git — read by _side in the diagnosis.
510
+ DEV_FILES="$(git -C "$wt" diff --name-only "$pre_sha" HEAD 2>/dev/null || true)"
511
+ BR_FILES="$(git -C "$wt" diff --name-only "$dev_sha" HEAD 2>/dev/null || true)"
512
+ fi
513
+ if (( PR )) && ! (( DRY )); then
514
+ BR_FILES="$(git -C "$wt" diff --name-only "$(git merge-base "$TRUNK" "$br" 2>/dev/null || echo "$TRUNK")" HEAD 2>/dev/null || true)"
84
515
  fi
85
516
 
86
- # 1.5 — the worktree SDK trap. A fleet worktree has no packages/sdk/node_modules,
87
- # so `verify:fast`'s first step (sdk build) dies on missing @noble/@scure types
88
- # reported as a RED gate when nothing is wrong with the code. `bun install` here
89
- # is NOT the fix: it re-points main's one.ie/web/node_modules/@oneie/sdk at this
517
+ # 1.5 — the worktree node_modules trap. A fresh worktree has none, so every
518
+ # gate that resolves through a service package dies on an import and reports a
519
+ # RED gate when nothing is wrong with the code. `bun install` here is NOT the
520
+ # fix: it re-points main's one.ie/web/node_modules/@oneie/sdk at this
90
521
  # worktree's dist-less SDK and breaks the shared tree. A symlink to main's
91
522
  # already-installed deps is read-only reuse and costs nothing.
92
- if [[ -d "$wt/packages/sdk" && ! -e "$wt/packages/sdk/node_modules" && -d "$ROOT/packages/sdk/node_modules" ]]; then
93
- if (( DRY )); then say " [dry] ln -s $ROOT/packages/sdk/node_modules $wt/packages/sdk/node_modules"
94
- else ln -s "$ROOT/packages/sdk/node_modules" "$wt/packages/sdk/node_modules" && ok "linked sdk node_modules (no bun install — that clobbers main)"
523
+ #
524
+ # ENUMERATED FROM THE TREE, not from a list. This started as two hardcoded
525
+ # directories packages/sdk, then one.ie/web each added after somebody's
526
+ # branch went falsely red, and on 2026-09-05 it went red a third time for
527
+ # `channels/` and `pay/backend/`: four suites under one.ie/web import
528
+ # `channels/src/tools/pages.ts`, which imports `zod`, which a worktree without
529
+ # channels/node_modules cannot resolve. The gate said `fast gate RED` and the
530
+ # branch it was blaming contained nothing but shell scripts. Asking git for
531
+ # every tracked package.json learns the next service without being told.
532
+ while IFS= read -r pj; do
533
+ d="$(dirname "$pj")"
534
+ [[ "$d" == "." ]] && continue
535
+ [[ -d "$ROOT/$d/node_modules" ]] || continue
536
+ [[ -e "$wt/$d/node_modules" ]] && continue
537
+ if (( DRY )); then say " [dry] ln -s $ROOT/$d/node_modules $wt/$d/node_modules"
538
+ else
539
+ mkdir -p "$wt/$d"
540
+ ln -s "$ROOT/$d/node_modules" "$wt/$d/node_modules" && ok "linked $d/node_modules (no bun install — that clobbers main)"
95
541
  fi
96
- fi
542
+ done < <(git -C "$ROOT" ls-files -- '*package.json' ':!:**/node_modules/**' 2>/dev/null || true)
97
543
 
98
- # 1.55 — web node_modules. A fresh land worktree has none; tsc then fails
99
- # `File 'astro/tsconfigs/strict' not found`. Same rule as sdk: symlink main.
544
+ # 1.55 — one.ie/web is the one that can be present but INCOMPLETE (a partial
545
+ # install leaves the directory there, so the `-e` test above passes and tsc
546
+ # still fails `File 'astro/tsconfigs/strict' not found`). Repair it by hand.
100
547
  if [[ -d "$ROOT/one.ie/web/node_modules/astro/tsconfigs" ]]; then
101
548
  webnm="$wt/one.ie/web/node_modules"
102
- if [[ ! -e "$webnm" ]]; then
103
- if (( DRY )); then say " [dry] ln -s $ROOT/one.ie/web/node_modules $webnm"
104
- else ln -s "$ROOT/one.ie/web/node_modules" "$webnm" && ok "linked web node_modules"
105
- fi
106
- elif [[ ! -e "$webnm/astro/tsconfigs/strict.json" ]]; then
549
+ if [[ -e "$webnm" && ! -e "$webnm/astro/tsconfigs/strict.json" ]]; then
107
550
  if (( DRY )); then say " [dry] replace incomplete $webnm with main's"
108
551
  else
109
552
  rm -rf "$webnm"
@@ -120,6 +563,34 @@ for br in "${BRANCHES[@]}"; do
120
563
  fi
121
564
  fi
122
565
 
566
+ # 1.57 — src/lib/generated/ is the same trap one directory over, and it bit a
567
+ # fresh land worktree on 2026-09-05: `page-modified.json` is written by
568
+ # `gen:page-modified` (a step of `verify`, never of a bare checkout), so tsc
569
+ # stopped at TS2307 and the branch read RED with nothing wrong in it.
570
+ #
571
+ # Linking the DIRECTORY does not work and the first attempt at this proved it:
572
+ # `src/lib/generated/` is tracked (it holds `substrate-client.ts`), so the
573
+ # worktree already has the directory and only the gitignored files inside it
574
+ # are missing. `! -e` on the directory is therefore always false, the link
575
+ # never fires, and the gate stays red for a reason nothing in the output names.
576
+ #
577
+ # So ask git which files it IGNORES under that path and link those. That still
578
+ # learns a new generator without this script being told about it — the failure
579
+ # mode of the enumerated approach (1.55, 1.56, each added one file at a time
580
+ # after somebody's branch went falsely red) is what this avoids.
581
+ gendir="one.ie/web/src/lib/generated"
582
+ if [[ -d "$ROOT/$gendir" ]]; then
583
+ while IFS= read -r rel; do
584
+ [[ -n "$rel" ]] || continue
585
+ [[ -e "$wt/$rel" ]] && continue
586
+ if (( DRY )); then say " [dry] ln -s $rel"
587
+ else
588
+ mkdir -p "$(dirname "$wt/$rel")"
589
+ ln -s "$ROOT/$rel" "$wt/$rel" && ok "linked ${rel#one.ie/web/src/lib/} (gen:* output a checkout never has)"
590
+ fi
591
+ done < <(git -C "$ROOT" ls-files --others --ignored --exclude-standard -- "$gendir" 2>/dev/null || true)
592
+ fi
593
+
123
594
  # 1.6 — the same gap, one layer up: a worktree has no one.ie/web/.env, so
124
595
  # GATEWAY_API_KEY is unset and every real-substrate suite SKIPS. A skipped suite
125
596
  # is NOT a passing suite — the gate would go green having never run the substrate
@@ -132,7 +603,29 @@ for br in "${BRANCHES[@]}"; do
132
603
  fi
133
604
  done
134
605
 
135
- # 2 — the fast gate, in the branch's worktree, through the governor.
606
+ # 1.7 — the substrate door the real-TypeDB suites will use, resolved the way
607
+ # tests/_env-file.ts resolves it: the shell wins, then one.ie/web/.env, then
608
+ # api.one.ie. Eight suites (tasks-bulk, tasks-depend-cycle, …) refuse to skip
609
+ # by design — real TypeDB or red — so a gateway that is down at gate time
610
+ # reads as a red gate on an innocent branch. One curl, 4s cap; any HTTP
611
+ # answer (401/404 included) proves the door is there — only a transport
612
+ # failure means unreachable, the exact line substrate.ts draws at status 0.
613
+ if ! (( DRY )); then
614
+ GW_URL="${GATEWAY_URL:-$(sed -n 's/^GATEWAY_URL=//p' "$wt/one.ie/web/.env" 2>/dev/null | tail -1 | tr -d "\"'" | tr -d ' ')}"
615
+ GW_URL="${GW_URL:-https://api.one.ie}"
616
+ GW_HTTP="$(curl -s -m 4 -o /dev/null -w '%{http_code}' -X POST "$GW_URL/typedb/query" \
617
+ -H 'content-type: application/json' -d '{}' 2>/dev/null || echo 000)"
618
+ [[ -n "$GW_HTTP" ]] || GW_HTTP=000
619
+ if [[ "$GW_HTTP" == "000" ]]; then
620
+ warn "substrate door $GW_URL is UNREACHABLE — real-TypeDB suites will skip or go red; a red of that shape is named as ENVIRONMENT below, never as $br's"
621
+ else
622
+ ok "substrate door $GW_URL answers (HTTP $GW_HTTP)"
623
+ fi
624
+ fi
625
+
626
+ # 2 — the fast gate, in the branch's worktree, through the governor. Output is
627
+ # captured (tee) so a red can be diagnosed from what the gate actually said;
628
+ # the exit code is read from PIPESTATUS, never from tee's.
136
629
  if (( DRY )); then
137
630
  (( QUICK )) && say " [dry] (cd $wt/one.ie/web && npx tsc --noEmit) # --quick" \
138
631
  || say " [dry] (cd $wt/one.ie/web && bun run verify:fast)"
@@ -145,37 +638,236 @@ for br in "${BRANCHES[@]}"; do
145
638
  # that import nothing from what they guard. Use it to move a queue when the
146
639
  # box is saturated; never as the last gate before production.
147
640
  warn "gate: TYPECHECK ONLY (--quick) — weaker than the fast lane, never a full pass"
148
- if ! ( cd "$wt/one.ie/web" && bash "$ROOT/.claude/scripts/gate-run.sh" quicktsc -- npx tsc --noEmit ); then
149
- bad "typecheck RED on $br not landing it"
150
- FAILED+=("$br: tsc red"); continue
641
+ gate_log="$(mktemp)"
642
+ ( cd "$wt/one.ie/web" && bash "$ROOT/.claude/scripts/gate-run.sh" quicktsc -- npx tsc --noEmit ) 2>&1 | tee "$gate_log"
643
+ gate_rc=${PIPESTATUS[0]}
644
+ if [[ "$gate_rc" -ne 0 ]]; then
645
+ bad "typecheck RED on the integrated tree ($br + $TRUNK) — not landing it"
646
+ _land_diagnose "$br" "$gate_log"; rm -f "$gate_log"
647
+ FAILED+=("$br: tsc red")
648
+ PH+=("tsc only:0:$(( SECONDS - BR_T0 )):gate:fail")
649
+ _land_record "$br" red "$DIAG_NOTE"; continue
151
650
  fi
651
+ rm -f "$gate_log"
152
652
  ok "typecheck green (tests NOT run)"
153
653
  LANDED_QUICK=1
154
654
  else
155
655
  say " gate: FAST LANE (not a full pass)"
156
- if ! ( cd "$wt/one.ie/web" && bun run verify:fast ); then
157
- bad "fast gate RED on $br not landing it"
158
- FAILED+=("$br: gate red"); continue
656
+ gate_log="$(mktemp)"
657
+ ( cd "$wt/one.ie/web" && bun run verify:fast ) 2>&1 | tee "$gate_log"
658
+ gate_rc=${PIPESTATUS[0]}
659
+ if [[ "$gate_rc" -ne 0 ]]; then
660
+ bad "fast gate RED on the integrated tree ($br + $TRUNK) — not landing it"
661
+ _land_diagnose "$br" "$gate_log"; rm -f "$gate_log"
662
+ FAILED+=("$br: gate red")
663
+ PH+=("fast gate:0:$(( SECONDS - BR_T0 )):gate:fail")
664
+ _land_record "$br" red "$DIAG_NOTE"; continue
159
665
  fi
666
+ rm -f "$gate_log"
160
667
  ok "fast gate green"
161
668
  fi
669
+ GATE_T=$(( SECONDS - BR_T0 ))
670
+ PH+=("$( (( QUICK )) && printf 'tsc only' || printf 'fast gate' ):0:$GATE_T:gate:pass")
671
+ fi
672
+
673
+ # 2.5 — run it before proposing it. Only under `--pr --deploy`: ship THIS
674
+ # WORKTREE to dev.one.ie, then prove the routes there. A PR whose branch has
675
+ # never been executed is a diff, not a proposal.
676
+ if (( PR && DEPLOY )); then
677
+ step "dev.one.ie ← $br"
678
+ if (( DRY )); then
679
+ say " [dry] (cd $wt && DEV_SKIP_GATE=$(( QUICK ? 0 : 1 )) bash $wt/.claude/scripts/deploy-dev.sh)"
680
+ dry_probe=""
681
+ for r in ${PROBES[@]+"${PROBES[@]}"}; do dry_probe="$dry_probe --route $r"; done
682
+ [[ -n "$dry_probe" ]] || dry_probe=" --route /"
683
+ say " [dry] PROVE_BASE_URL=$DEV_URL PROVE_PROD_URL=$DEV_URL do-prove.sh$dry_probe"
684
+ DEV_NOTE="$DEV_URL — not run (dry run)"
685
+ else
686
+ # The worktree's OWN copy. $ROOT's would ship main and call it the branch.
687
+ # The gate is skipped here because step 2 just ran it in this same tree —
688
+ # except under --quick, where step 2 ran tsc only and is NOT the fast lane,
689
+ # so deploy-dev.sh must run its own before anything reaches dev.
690
+ if ! ( cd "$wt" && DEV_SKIP_GATE=$(( QUICK ? 0 : 1 )) bash "$wt/.claude/scripts/deploy-dev.sh" ); then
691
+ bad "dev deploy failed for $br — not opening a PR for code that will not ship"
692
+ FAILED+=("$br: dev deploy")
693
+ _land_record "$br" red; continue
694
+ fi
695
+ ok "shipped $br to $DEV_URL"
696
+ DEV_T=$(( SECONDS - BR_T0 - GATE_T ))
697
+ PH+=("ship dev:$GATE_T:$DEV_T:ship:pass")
698
+
699
+ # The probe. Both bases pinned to dev: do-prove falls back to
700
+ # PROVE_PROD_URL when its dev base is silent, and an unreachable dev would
701
+ # otherwise prove https://one.ie and report it as this branch passing.
702
+ probe_args=()
703
+ for r in ${PROBES[@]+"${PROBES[@]}"}; do probe_args+=(--route "$r"); done
704
+ (( ${#probe_args[@]} )) || probe_args=(--route /)
705
+ say " probe: ${probe_args[*]} (landing rule, signed-out)"
706
+ probe_out="$( cd "$wt" && PROVE_BASE_URL="$DEV_URL" PROVE_PROD_URL="$DEV_URL" \
707
+ bash "$wt/.claude/scripts/do-prove.sh" "${probe_args[@]}" 2>&1 )"
708
+ printf '%s\n' "$probe_out" | sed 's/^/ /'
709
+ # Read the ROUTE COUNT, not the exit code: `PROVE: skipped (no reachable
710
+ # environment)` exits 0, and an unrun probe is not a pass.
711
+ proven="$(printf '%s' "$probe_out" | sed -n 's/^PROVE: pass (\([0-9][0-9]*\) route.*/\1/p' | tail -1)"
712
+ if [[ -z "$proven" || "$proven" -lt 1 ]]; then
713
+ bad "probe did not prove a single route on dev — not opening a PR"
714
+ FAILED+=("$br: probe")
715
+ _land_record "$br" red; continue
716
+ fi
717
+ ok "proved $proven route(s) on $DEV_URL"
718
+ PROBE_T=$(( SECONDS - BR_T0 - GATE_T - DEV_T ))
719
+ PH+=("probe dev:$(( GATE_T + DEV_T )):$PROBE_T:probe:pass")
720
+ DEV_NOTE="$DEV_URL — $proven route(s) proven signed-out (landing rule); dev shares production's data"
721
+ fi
722
+ fi
723
+
724
+ # 3 — the door. Either main fast-forwards to the branch, or the branch becomes
725
+ # a pull request. Never both: a PR that has already been merged into main is a
726
+ # PR with nothing to review.
727
+ if (( PR )); then
728
+ # The gate label is PASSED to pr-body.sh rather than inferred by it. That
729
+ # script runs no gate and must never imply one; what actually ran is known
730
+ # only here, and --quick has to say out loud that it is weaker.
731
+ gate_label="fast lane (green) — branch only, trunk not merged in"
732
+ (( QUICK )) && gate_label="typecheck only (--quick) — NOT the fast lane, never a full pass"
733
+ (( DRY )) && gate_label="not run (dry run)"
734
+
735
+ if (( DRY )); then
736
+ say " [dry] git push -u origin $br"
737
+ say " [dry] gh pr create --base $PR_BASE --head $br --title \"$(bash "$ROOT/.claude/scripts/pr-body.sh" "$br" --base "$PR_BASE" --title 2>/dev/null)\""
738
+ say " [dry] (body from pr-body.sh — $(bash "$ROOT/.claude/scripts/pr-body.sh" "$br" --base "$PR_BASE" 2>/dev/null | wc -l | tr -d ' ') lines${DEV_NOTE:+, + dev line})"
739
+ PRS+=("$br: [dry]")
740
+ else
741
+ if ! git push -u origin "$br" >/dev/null 2>&1; then
742
+ bad "could not push $br to origin"
743
+ FAILED+=("$br: push"); continue
744
+ fi
745
+ ok "pushed $br"
746
+
747
+ body_file="$(mktemp)"
748
+ # --dev is passed only when something was actually shipped and probed.
749
+ # pr-body.sh renders no line without it, which is the honest default: a PR
750
+ # silent about dev is a PR whose branch was never run.
751
+ dev_args=()
752
+ [[ -n "$DEV_NOTE" ]] && dev_args=(--dev "$DEV_NOTE")
753
+ bash "$ROOT/.claude/scripts/pr-body.sh" "$br" --base "$PR_BASE" --gate "$gate_label" \
754
+ ${dev_args[@]+"${dev_args[@]}"} > "$body_file" 2>/dev/null
755
+ title="$(bash "$ROOT/.claude/scripts/pr-body.sh" "$br" --base "$PR_BASE" --title 2>/dev/null)"
756
+ [ -s "$body_file" ] || { bad "pr-body.sh produced nothing for $br"; rm -f "$body_file"; FAILED+=("$br: body"); continue; }
757
+
758
+ # Idempotent by design. A re-run after "main moved", a second cycle on the
759
+ # same branch, or simply landing twice must UPDATE the open PR — creating
760
+ # is what fails when one already exists, so ask first.
761
+ existing="$(gh pr list --head "$br" --base "$PR_BASE" --state open --json number \
762
+ --jq '.[0].number' 2>/dev/null || true)"
763
+ if [ -n "$existing" ]; then
764
+ if gh pr edit "$existing" --title "$title" --body-file "$body_file" >/dev/null 2>&1; then
765
+ url="$(gh pr view "$existing" --json url --jq .url 2>/dev/null || echo "#$existing")"
766
+ ok "updated PR $url"
767
+ PRS+=("$br: $url (updated)")
768
+ else
769
+ bad "could not update PR #$existing for $br"
770
+ FAILED+=("$br: pr edit")
771
+ fi
772
+ else
773
+ if url="$(gh pr create --base "$PR_BASE" --head "$br" --title "$title" --body-file "$body_file" 2>&1 | tail -1)"; then
774
+ ok "opened PR $url"
775
+ PRS+=("$br: $url")
776
+ else
777
+ bad "could not open a PR for $br: $url"
778
+ FAILED+=("$br: pr create")
779
+ fi
780
+ fi
781
+ rm -f "$body_file"
782
+ fi
783
+ # The PR path returns here, BEFORE the `LANDED+=` record below — so without
784
+ # this line a green `--pr` run recorded nothing and the page showed only the
785
+ # runs that failed. Measured on the first green run of this door: PR #35
786
+ # opened, 768s of real work, and the record stayed empty.
787
+ PR_T=$(( SECONDS - BR_T0 - GATE_T - DEV_T - PROBE_T ))
788
+ PH+=("open PR:$(( GATE_T + DEV_T + PROBE_T )):$PR_T:pr:pass")
789
+ _land_record "$br" green
790
+ continue
162
791
  fi
163
792
 
164
- # 3 fast-forward main only. A non-ff means main moved under us: re-run.
165
- if (( DRY )); then say " [dry] git merge --ff-only $br"
793
+ if (( DRY )); then say " [dry] advance $TRUNK to $br (in its worktree if it has one, else git fetch .)"
166
794
  else
167
- if ! git merge --ff-only "$br" >/dev/null 2>&1; then
168
- warn "main moved — re-run land.sh for $br"
169
- FAILED+=("$br: main moved"); continue
795
+ # Advance $TRUNK WITHOUT MOVING HEAD in the shared tree. Switching HEAD here
796
+ # is what `hook:branch-pin` refuses and the canon forbids: concurrent
797
+ # sessions get pulled onto each other's branches. Two ways to avoid it, and
798
+ # which applies is decided by git, not by taste:
799
+ # * $TRUNK is checked out in a worktree -> `fetch .` REFUSES it outright
800
+ # ("refusing to fetch into branch ... checked out at ..."), so
801
+ # fast-forward inside that worktree instead.
802
+ # * $TRUNK is checked out nowhere -> there is no worktree to run in, so
803
+ # move the ref with `fetch .`, which refuses a non-fast-forward exactly
804
+ # as `merge --ff-only` does.
805
+ # Measured 2026-09-06: dev IS checked out at .claude/worktrees/dev on this
806
+ # box, so the fetch-only version of this failed on its first real use.
807
+ # TRUNK_WT was resolved once, before the loop (worktree_of "$TRUNK").
808
+ # CAPTURE the error, never swallow it. Until 2026-09-09 both forms ended in
809
+ # `>/dev/null 2>&1` and EVERY failure printed "$TRUNK moved", which is the
810
+ # wrong diagnosis most of the time on this box: $TRUNK is checked out in a
811
+ # worktree OTHER SESSIONS EDIT, so a fast-forward is refused whenever one of
812
+ # them holds an uncommitted file this branch also touches. Measured that day
813
+ # — three consecutive lands reported "dev moved" while `dev` was 0 commits
814
+ # behind the branch; the real error was only visible by running the merge by
815
+ # hand:
816
+ # error: Your local changes to the following files would be overwritten
817
+ # by merge: one.ie/web/src/data/deploy-runs.json
818
+ # "moved" sends you to re-run (which fails identically, forever); the truth
819
+ # sends you to one named file. A wrong diagnosis costs more than no
820
+ # diagnosis, because it is actionable in the wrong direction.
821
+ if [[ -n "$TRUNK_WT" ]]; then
822
+ _adv() { git -C "$TRUNK_WT" merge --ff-only "$br" 2>&1; }
823
+ else
824
+ _adv() { git fetch . "$br:$TRUNK" 2>&1; }
170
825
  fi
171
- ok "main fast-forwarded to $br"
826
+ if ! adv_err="$(_adv)"; then
827
+ # Three causes, and they need three different next moves.
828
+ if [[ "$adv_err" == *"would be overwritten by merge"* || "$adv_err" == *"local changes"* ]]; then
829
+ # Name the files. `git` already listed them; pass them straight through
830
+ # rather than re-deriving, so this cannot drift from what git checked.
831
+ dirty="$(printf '%s\n' "$adv_err" | sed -n 's/^\t//p' | tr '\n' ' ')"
832
+ warn "$TRUNK's worktree ($TRUNK_WT) has UNCOMMITTED changes to: ${dirty:-<see below>}"
833
+ warn " $TRUNK did NOT move — the fast-forward was refused by a dirty tree."
834
+ warn " Another session is likely mid-edit there. Do not blanket-restore:"
835
+ warn " check whether those files' contents are already in $br, and clear"
836
+ warn " only the paths that are (git checkout -- <path>), never -A."
837
+ FAILED+=("$br: $TRUNK worktree dirty"); continue
838
+ fi
839
+ if [[ "$adv_err" == *"non-fast-forward"* || "$adv_err" == *"Not possible to fast-forward"* || "$adv_err" == *"diverge"* ]]; then
840
+ warn "$TRUNK genuinely moved — merge it in and re-run land.sh for $br"
841
+ FAILED+=("$br: $TRUNK moved"); continue
842
+ fi
843
+ warn "could not advance $TRUNK — git said:"
844
+ printf '%s\n' "$adv_err" | sed 's/^/ /' >&2
845
+ FAILED+=("$br: advance failed"); continue
846
+ fi
847
+ # READ THE REF BACK. A successful --ff-only is not proof the branch is IN
848
+ # main a moment later: several sessions land into one shared tree, and on
849
+ # 2026-09-05 two of these printed "main fast-forwarded to feat/deploy-page"
850
+ # while main's reflog recorded a NEIGHBOUR's branch name at the very sha
851
+ # this script had just reported. Twelve commits sat outside main for an hour
852
+ # and were reported as landed, twice, because the script trusted its own
853
+ # exit code over the ref. `git merge` succeeding is a claim; the branch
854
+ # being an ancestor of main is the fact.
855
+ if ! git merge-base --is-ancestor "$br" "$TRUNK"; then
856
+ bad "$TRUNK does NOT contain $br after a clean fast-forward — a concurrent land overwrote it"
857
+ say " $TRUNK is now $(git rev-parse --short "$TRUNK"); $br is $(git rev-parse --short "$br")"
858
+ FAILED+=("$br: ff did not hold"); _land_record "$br" red; continue
859
+ fi
860
+ ok "$TRUNK fast-forwarded to $br (verified: $TRUNK contains $(git rev-parse --short "$br"))"
172
861
  fi
173
862
 
174
863
  LANDED+=("$br")
864
+ _land_record "$br" green
175
865
  done
176
866
 
177
- # 4 — one dev deploy for the whole batch, not one per branch.
178
- if (( DEPLOY )) && (( ${#LANDED[@]} )); then
867
+ # 4 — one dev deploy for the whole batch, not one per branch. Not under --pr:
868
+ # that path already shipped the branch's own worktree in step 2.5, and this would
869
+ # overwrite it with main — the tree the PR is NOT proposing.
870
+ if (( DEPLOY )) && ! (( PR )) && (( ${#LANDED[@]} )); then
179
871
  step "deploy dev"
180
872
  if (( DRY )); then say " [dry] bash .claude/scripts/deploy.sh dev"
181
873
  else
@@ -185,9 +877,27 @@ fi
185
877
 
186
878
  step "report"
187
879
  (( ${#LANDED[@]} )) && ok "landed: ${LANDED[*]}"
880
+ if (( ${#PRS[@]} )); then
881
+ for p in "${PRS[@]}"; do ok "pr: $p"; done
882
+ fi
188
883
  (( LANDED_QUICK )) && warn "typecheck-only gate was used — run the full suite before ./deploy to production"
189
884
  (( ${#FAILED[@]} )) && bad "failed: ${FAILED[*]}"
190
- (( ${#FAILED[@]} )) && exit 1
885
+ # --only dims,feedback is deliberate. A BRANCH NAME is not a plan slug: without
886
+ # --tid the task leg resolves by the `slug:` tag and would close whatever plan
887
+ # happens to share the name. And land runs the FAST lane, so the receipt gate is
888
+ # unrun on every land — filing that would fire a row on every land forever, with
889
+ # nothing anyone can do, which is the EXPIRED class deploy-record.sh names. The
890
+ # pheromone is the part that is true here.
891
+ # Close the loop on BOTH exits — a red land is an outcome, not an absence of one.
892
+ # `|| true` on purpose: a logging debt must never change what the merge door did.
893
+ (( ${#FAILED[@]} )) && { bash "$(dirname "${BASH_SOURCE[0]}")/do-close.sh" "${BRANCHES[0]##*/}" --status failed --only dims,feedback || true; exit 1; }
191
894
  say ""
192
- say " dev only. Promote to production with: ./deploy (full gate, human decision)"
895
+ if (( PR )); then
896
+ say " Nothing landed — these are pull requests. main is untouched."
897
+ say " The gate that ran proves the branch is green ON ITS OWN, not that it survives the trunk."
898
+ [[ -n "$DEV_NOTE" ]] && say " Running on dev: $DEV_URL — dev shares production's DATA. Merge the PR, then ./deploy."
899
+ else
900
+ say " dev only. Promote to production with: ./deploy (full gate, human decision)"
901
+ fi
902
+ bash "$(dirname "${BASH_SOURCE[0]}")/do-close.sh" "${BRANCHES[0]##*/}" --only dims,feedback || true
193
903
  exit 0