@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,436 @@
1
+ #!/usr/bin/env bash
2
+ # do-derive-check.sh — the five checks that hold the agent-native promise.
3
+ #
4
+ # Contract: text/agent-native-plan.md § 3.5. Six of the promise's eight proof
5
+ # legs are this script, so that section is the specification and this file is
6
+ # the implementation of it.
7
+ #
8
+ # contract 60+ receivers advertised to MCP
9
+ # mcp the derived tool list is computed, complete, and schema-faithful
10
+ # binder an unauthorised caller is stopped BY THE WRAPPER, affordance kept
11
+ # routes no new JSON endpoint duplicates a receiver reachable by name
12
+ # counts no hand-written tool ships beside its derived twin
13
+ #
14
+ # THREE EXIT CODES, NOT TWO:
15
+ # 0 ok delivered and behaving
16
+ # 1 RED genuinely missing or wrong
17
+ # 3 CANNOT RUN the check could not reach its evidence
18
+ #
19
+ # Red and cannot-run send a person to opposite places. `contract` and `mcp`
20
+ # import TypeScript through bun; an import that fails because a package is
21
+ # unbuilt is CANNOT RUN, not deliverable-missing — the difference between "go
22
+ # build the SDK" and "go re-scope the work".
23
+ #
24
+ # Every threshold is env-overridable. That is not a convenience: --self-test
25
+ # drives each check to red by moving its threshold against the real tree, which
26
+ # proves the comparison is wired rather than that a fixture can be written.
27
+ #
28
+ # monorepo-only
29
+ set -uo pipefail
30
+
31
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
32
+
33
+ MIN_MCP_FLAGGED="${DERIVE_MIN_MCP_FLAGGED:-60}"
34
+ MIN_DERIVED_TOOLS="${DERIVE_MIN_DERIVED_TOOLS:-60}"
35
+ MAX_HANDWRITTEN_MCP="${DERIVE_MAX_HANDWRITTEN_MCP:-95}" # strictly below
36
+ MAX_CURATED_CHAT="${DERIVE_MAX_CURATED_CHAT:-125}" # at or below
37
+ API_DIR="${DERIVE_API_DIR:-$ROOT/one.ie/web/src/pages/api}"
38
+ MCP_TOOLS_DIR="${DERIVE_MCP_TOOLS_DIR:-$ROOT/packages/mcp/src/tools}"
39
+ CHAT_TOOLS_DIR="${DERIVE_CHAT_TOOLS_DIR:-$ROOT/channels/src/tools}"
40
+ ALLOWLIST="${DERIVE_ROUTE_ALLOWLIST:-$ROOT/.claude/derive-route-allowlist.txt}"
41
+
42
+ say() { printf '[derive-check] %s\n' "$*"; }
43
+ red() { printf '[derive-check] RED — %s\n' "$*" >&2; return 1; }
44
+ cant() { printf '[derive-check] CANNOT RUN — %s\n' "$*" >&2; return 3; }
45
+
46
+ need_bun() { command -v bun >/dev/null || return 3; return 0; }
47
+
48
+ # ── contract ─────────────────────────────────────────────────────────────────
49
+ # The Receiver contract carries surfaces.mcp, populated.
50
+ check_contract() {
51
+ need_bun || { cant "bun is not on PATH"; return 3; }
52
+ local src="$ROOT/packages/sdk/src/receivers.ts"
53
+ [ -f "$src" ] || { cant "receivers.ts absent at $src"; return 3; }
54
+
55
+ local out rc
56
+ out="$(cd "$ROOT" && bun -e "
57
+ import('./packages/sdk/src/receivers.ts')
58
+ .then(m => {
59
+ const R = Object.values(m.RECEIVERS);
60
+ const flagged = R.filter(r => r.surfaces && r.surfaces.mcp);
61
+ const named = flagged.filter(r => typeof r.surfaces.mcp === 'object' && r.surfaces.mcp.name);
62
+ const dupes = named.length - new Set(named.map(r => r.surfaces.mcp.name)).size;
63
+ console.log(JSON.stringify({ total: R.length, flagged: flagged.length, named: named.length, dupes }));
64
+ })
65
+ .catch(e => { console.error('IMPORT ' + e); process.exit(3); });
66
+ " 2>&1)"; rc=$?
67
+
68
+ [ "$rc" -eq 3 ] && { cant "receivers.ts did not import: ${out:0:160}"; return 3; }
69
+ [ "$rc" -ne 0 ] && { cant "bun exited $rc: ${out:0:160}"; return 3; }
70
+
71
+ local flagged named dupes total
72
+ flagged=$(printf '%s' "$out" | grep -o '"flagged":[0-9]*' | cut -d: -f2)
73
+ named=$(printf '%s' "$out" | grep -o '"named":[0-9]*' | cut -d: -f2)
74
+ dupes=$(printf '%s' "$out" | grep -o '"dupes":[0-9]*' | cut -d: -f2)
75
+ total=$(printf '%s' "$out" | grep -o '"total":[0-9]*' | cut -d: -f2)
76
+ [ -z "$flagged" ] && { cant "could not parse the receiver census: ${out:0:160}"; return 3; }
77
+
78
+ # A duplicate declared name would silently shadow one tool with another.
79
+ [ "${dupes:-0}" -gt 0 ] && { red "$dupes duplicate declared MCP tool name(s)"; return 1; }
80
+ [ "$flagged" -lt "$MIN_MCP_FLAGGED" ] && {
81
+ red "surfaces.mcp on $flagged receivers, need >= $MIN_MCP_FLAGGED"; return 1; }
82
+
83
+ say "contract ok — $flagged/$total flagged ($named carry an existing tool name)"
84
+ return 0
85
+ }
86
+
87
+ # ── mcp ──────────────────────────────────────────────────────────────────────
88
+ # The tool list is COMPUTED from the registry, complete, and schema-faithful.
89
+ check_mcp() {
90
+ need_bun || { cant "bun is not on PATH"; return 3; }
91
+ local src="$ROOT/packages/mcp/src/tools/from-registry.ts"
92
+ local dist="$ROOT/packages/mcp/dist/index.js"
93
+ [ -f "$src" ] || { red "from-registry.ts absent — the derivation was never written"; return 1; }
94
+ [ -f "$dist" ] || { cant "packages/mcp is unbuilt (no dist/index.js) — run bun run build"; return 3; }
95
+
96
+ local out rc
97
+ out="$(cd "$ROOT/packages/mcp" && bun -e "
98
+ import('./dist/index.js')
99
+ .then(async m => {
100
+ const tools = m.mcpToolsFromRegistry();
101
+ const meta = await import('@oneie/sdk/meta');
102
+ const { RECEIVERS } = await import('@oneie/sdk/receivers');
103
+ // Schema fidelity: a derived tool must advertise ITS OWN receiver's request
104
+ // schema. A tool carrying the wrong schema passes every count and lies to
105
+ // every caller, so this is checked per tool, not sampled.
106
+ let wrong = 0;
107
+ for (const r of Object.values(RECEIVERS)) {
108
+ if (!r.surfaces || !r.surfaces.mcp) continue;
109
+ const want = m.toolNameFor(r.receiver, r.surfaces.mcp);
110
+ const t = tools.find(x => x.name === want);
111
+ if (!t) { wrong++; continue; }
112
+ const expect = JSON.stringify(meta.metaSchema(r.receiver).request);
113
+ if (JSON.stringify(t.inputSchema) !== expect) wrong++;
114
+ }
115
+ const noSchema = tools.filter(t => !t.inputSchema || typeof t.inputSchema !== 'object').length;
116
+ console.log(JSON.stringify({ derived: tools.length, unconvertible: m.UNCONVERTIBLE.length, wrong, noSchema }));
117
+ })
118
+ .catch(e => { console.error('IMPORT ' + e); process.exit(3); });
119
+ " 2>&1)"; rc=$?
120
+
121
+ [ "$rc" -eq 3 ] && { cant "mcp dist did not import: ${out:0:160}"; return 3; }
122
+ [ "$rc" -ne 0 ] && { cant "bun exited $rc: ${out:0:160}"; return 3; }
123
+
124
+ local derived unconv wrong noschema
125
+ derived=$(printf '%s' "$out" | grep -o '"derived":[0-9]*' | cut -d: -f2)
126
+ unconv=$(printf '%s' "$out" | grep -o '"unconvertible":[0-9]*' | cut -d: -f2)
127
+ wrong=$(printf '%s' "$out" | grep -o '"wrong":[0-9]*' | cut -d: -f2)
128
+ noschema=$(printf '%s' "$out" | grep -o '"noSchema":[0-9]*' | cut -d: -f2)
129
+ [ -z "$derived" ] && { cant "could not parse the derivation census: ${out:0:160}"; return 3; }
130
+
131
+ [ "${unconv:-0}" -gt 0 ] && { red "$unconv receiver schema(s) could not convert to JSON Schema"; return 1; }
132
+ [ "${noschema:-0}" -gt 0 ] && { red "$noschema derived tool(s) carry no inputSchema"; return 1; }
133
+ [ "${wrong:-0}" -gt 0 ] && { red "$wrong derived tool(s) missing or advertising the wrong receiver's schema"; return 1; }
134
+ [ "$derived" -lt "$MIN_DERIVED_TOOLS" ] && {
135
+ red "$derived derived tools, need >= $MIN_DERIVED_TOOLS"; return 1; }
136
+
137
+ say "mcp ok — $derived derived tools, every schema matches its receiver"
138
+ return 0
139
+ }
140
+
141
+ # ── binder ───────────────────────────────────────────────────────────────────
142
+ # Behavioural first, structural second. Counting handler tables is true the
143
+ # moment everything routes through one function whether or not that function
144
+ # ever consults the policy — so the count is the WEAKER half and runs last.
145
+ check_binder() {
146
+ need_bun || { cant "bun is not on PATH"; return 3; }
147
+ local f="$ROOT/one.ie/web/src/lib/bind-receiver.ts"
148
+ [ -f "$f" ] || { red "bind-receiver.ts absent — the wrapper was never written"; return 1; }
149
+
150
+ local out rc
151
+ out="$(cd "$ROOT/one.ie/web" && bun -e "
152
+ import('./src/lib/bind-receiver.ts')
153
+ .then(async m => {
154
+ const fail = [];
155
+
156
+ // A1 — a tenant-label receiver, called by a caller holding nothing, is
157
+ // rejected BY THE WRAPPER and the handler never runs.
158
+ // Assert the OUTCOME (refused, handler never reached), not the mechanism.
159
+ // The floor returns a refusal object rather than throwing, because that
160
+ // is how every handler in the codebase signals refusal and what the ask
161
+ // route already reads. A check that demanded a throw would fail a
162
+ // correct implementation.
163
+ let ran = false;
164
+ const bound = m.bindReceiver('world:create-group', async () => { ran = true; return { ok: true }; });
165
+ let refused = false;
166
+ try {
167
+ const r = await bound({ name: 'x', slug: 'y' }, {}, {});
168
+ refused = !!(r && typeof r === 'object' && (r.forbidden === true || r.ok === false));
169
+ } catch { refused = true; }
170
+ if (!refused) fail.push('A1: unauthorised caller was NOT refused');
171
+ if (ran) fail.push('A1: handler RAN despite the auth gate — the wrapper consulted the policy and ignored it');
172
+
173
+ // A2 — the error-as-affordance body still exists and still teaches.
174
+ // Checked at its OWNER, receiver-envelope, not at the binder: the
175
+ // binder's request check is warn-only (the declared schemas had
176
+ // drifted from real in-process callers), while /api/ask and
177
+ // /api/signal still reject with this exact body. Asserting it on
178
+ // the binder would assert a contract this cycle deliberately does
179
+ // not ship; asserting it here checks the one that does.
180
+ const env = await import('./src/lib/receiver-envelope.ts');
181
+ const v = env.validateReceiver('world:create-group', {});
182
+ if (v.ok) {
183
+ fail.push('A2: an invalid request validated clean — the affordance gate is gone');
184
+ } else {
185
+ const body = v.body || {};
186
+ if (!('hint' in body) || !('expected' in body) || !('error' in body)) {
187
+ fail.push('A2: affordance body lost a teaching field: ' + JSON.stringify(body).slice(0, 120));
188
+ }
189
+ }
190
+ // A2b — and the binder must still NOTICE the mismatch, or warn-only has
191
+ // silently become no-op.
192
+ const seenReq = [];
193
+ const rw = console.warn;
194
+ console.warn = (...a) => seenReq.push(a.join(' '));
195
+ const b2 = m.bindReceiver('world:create-group', async () => ({ ok: true }));
196
+ await b2({}, {}, { ownerSlug: 'someone' }).catch(() => {});
197
+ console.warn = rw;
198
+ if (!seenReq.some(s => /request-mismatch/.test(s))) {
199
+ fail.push('A2b: an invalid request produced no request-mismatch warning — warn-only became no-op');
200
+ }
201
+
202
+ // A3 — a declared response is actually consulted. WARN mode counts, but
203
+ // the mismatch must be OBSERVABLE, not swallowed.
204
+ const seen = [];
205
+ const realWarn = console.warn;
206
+ console.warn = (...a) => seen.push(a.join(' '));
207
+ const b3 = m.bindReceiver('world:create-group', async () => ({ nonsense: true }));
208
+ await b3({ name: 'x', slug: 'y' }, {}, { ownerSlug: 'someone', staff: true }).catch(() => {});
209
+ console.warn = realWarn;
210
+ if (!seen.some(s => /response-mismatch/.test(s))) {
211
+ fail.push('A3: a handler returning the wrong shape produced no response-mismatch warning');
212
+ }
213
+
214
+ // A4 — an unlisted auth label must never fall OPEN.
215
+ if (m.authClassFor('a-label-that-does-not-exist') === 'open') {
216
+ fail.push('A4: an unknown auth label resolves to open');
217
+ }
218
+
219
+ console.log(JSON.stringify({ fail }));
220
+ })
221
+ .catch(e => { console.error('IMPORT ' + e); process.exit(3); });
222
+ " 2>&1)"; rc=$?
223
+
224
+ [ "$rc" -eq 3 ] && { cant "bind-receiver.ts did not import: ${out:0:200}"; return 3; }
225
+ [ "$rc" -ne 0 ] && { cant "bun exited $rc: ${out:0:200}"; return 3; }
226
+
227
+ local fails
228
+ fails="$(printf '%s' "$out" | sed -n 's/.*"fail":\[\(.*\)\]}.*/\1/p')"
229
+ [ -n "$fails" ] && [ "$fails" != "" ] && { red "binder behaviour: $fails"; return 1; }
230
+
231
+ # Structural, and deliberately last: a raw handler table that never mentions
232
+ # bindReceiver is a dispatcher that has to remember.
233
+ local raw=0 f2
234
+ while IFS= read -r f2; do
235
+ grep -q "bindReceiver" "$f2" || { say " raw handler table: ${f2#$ROOT/}"; raw=$((raw + 1)); }
236
+ done < <(grep -rlE '^const (HANDLERS|RESOLVERS)' "$ROOT/one.ie/web/src/lib" --include='*.ts' 2>/dev/null || true)
237
+ [ "$raw" -gt 0 ] && { red "$raw handler table(s) bind outside bindReceiver"; return 1; }
238
+
239
+ say "binder ok — rejects before the handler, keeps the affordance, checks the response, 0 raw tables"
240
+ return 0
241
+ }
242
+
243
+ # ── routes ───────────────────────────────────────────────────────────────────
244
+ # A new JSON endpoint duplicating a receiver reachable by name. NOT a cap on
245
+ # routes: auth callbacks, webhooks, uploads and non-JSON responses are exempt,
246
+ # and today's tree is grandfathered in an allowlist that may shrink, never grow.
247
+ EXEMPT_RE='(^|/)(auth|webhook|webhooks|upload|uploads|oauth|callback|stripe|sse|stream|rss|sitemap|feed|og|image|images|avatar|media|export|cron|health)(/|$)'
248
+
249
+ check_routes() {
250
+ [ -d "$API_DIR" ] || { cant "api dir absent at $API_DIR"; return 3; }
251
+ [ -f "$ALLOWLIST" ] || { cant "route allowlist absent at $ALLOWLIST — freeze it first (--freeze-routes)"; return 3; }
252
+ need_bun || { cant "bun is not on PATH"; return 3; }
253
+
254
+ local receivers
255
+ receivers="$(cd "$ROOT" && bun -e "
256
+ import('./packages/sdk/src/receivers.ts')
257
+ .then(m => console.log(Object.keys(m.RECEIVERS).join('\n')))
258
+ .catch(() => process.exit(3));
259
+ " 2>/dev/null)" || { cant "could not read the receiver list"; return 3; }
260
+ [ -z "$receivers" ] && { cant "receiver list came back empty"; return 3; }
261
+
262
+ # noun set: the second half of every receiver name, singularised
263
+ local nouns
264
+ nouns="$(printf '%s\n' "$receivers" | sed 's/^[a-z-]*://' | sed 's/s$//' | sort -u)"
265
+
266
+ local offenders=0 rel key
267
+ while IFS= read -r f; do
268
+ rel="${f#$API_DIR/}"
269
+ printf '%s\n' "$rel" | grep -qE "$EXEMPT_RE" && continue
270
+ grep -qF -- "$rel" "$ALLOWLIST" && continue
271
+ # op key: path minus dynamic segments and extension, singularised
272
+ key="$(printf '%s' "$rel" | sed 's/\.ts$//' | sed 's/\[[^]]*\]//g' | tr '/' ' ' | tr -s ' ')"
273
+ for n in $key; do
274
+ n="$(printf '%s' "$n" | sed 's/s$//')"
275
+ [ -z "$n" ] && continue
276
+ if printf '%s\n' "$nouns" | grep -qx -- "$n"; then
277
+ say " twin: $rel duplicates a receiver reachable by name (noun '$n')"
278
+ offenders=$((offenders + 1))
279
+ break
280
+ fi
281
+ done
282
+ done < <(find "$API_DIR" -name '*.ts' -type f 2>/dev/null)
283
+
284
+ [ "$offenders" -gt 0 ] && {
285
+ red "$offenders new route(s) duplicate a by-name receiver — use the door, or add an exempt kind"; return 1; }
286
+
287
+ say "routes ok — no new twin against $(wc -l < "$ALLOWLIST" | tr -d ' ') grandfathered entries"
288
+ return 0
289
+ }
290
+
291
+ freeze_routes() {
292
+ [ -d "$API_DIR" ] || { cant "api dir absent at $API_DIR"; return 3; }
293
+ find "$API_DIR" -name '*.ts' -type f | sed "s|^$API_DIR/||" | sort > "$ALLOWLIST"
294
+ say "froze $(wc -l < "$ALLOWLIST" | tr -d ' ') route(s) into ${ALLOWLIST#$ROOT/} — this list may shrink, never grow"
295
+ }
296
+
297
+ # ── counts ───────────────────────────────────────────────────────────────────
298
+ # The deletion, measured. The load-bearing assertion is COEXISTENCE, not the
299
+ # number: "strictly below 95" alone is satisfied by deleting one tool.
300
+ check_counts() {
301
+ [ -d "$MCP_TOOLS_DIR" ] || { cant "mcp tools dir absent at $MCP_TOOLS_DIR"; return 3; }
302
+ need_bun || { cant "bun is not on PATH"; return 3; }
303
+
304
+ local flagged
305
+ flagged="$(cd "$ROOT" && bun -e "
306
+ import('./packages/sdk/src/receivers.ts')
307
+ .then(m => {
308
+ for (const r of Object.values(m.RECEIVERS)) if (r.surfaces && r.surfaces.mcp) console.log(r.receiver);
309
+ })
310
+ .catch(() => process.exit(3));
311
+ " 2>/dev/null)" || { cant "could not read the flagged receivers"; return 3; }
312
+
313
+ # Coexistence: a flagged receiver must not still have a SINGLE-RECEIVER
314
+ # hand-written wrapper. Scoped to single-receiver tools on purpose — a
315
+ # surviving multi-receiver recipe legitimately names a flagged receiver among
316
+ # several, and the pragma covers anything this still catches wrongly.
317
+ local coexist=0 r hits
318
+ while IFS= read -r r; do
319
+ [ -z "$r" ] && continue
320
+ while IFS= read -r tf; do
321
+ grep -qF -- 'derive:allow-handwritten' "$tf" && continue
322
+ # a module naming exactly ONE distinct receiver is a single-receiver wrapper
323
+ hits="$(grep -ohE '"[a-z][a-z-]*:[a-z][a-z-]*"' "$tf" 2>/dev/null | sort -u | wc -l | tr -d ' ')"
324
+ [ "${hits:-0}" -eq 1 ] || continue
325
+ say " coexists: $r still has a hand-written wrapper in ${tf#$ROOT/}"
326
+ coexist=$((coexist + 1))
327
+ done < <(grep -rlF -- "\"$r\"" "$MCP_TOOLS_DIR" --include='*.ts' 2>/dev/null || true)
328
+ done <<< "$flagged"
329
+
330
+ [ "$coexist" -gt 0 ] && { red "$coexist hand-written tool(s) ship beside a derived twin"; return 1; }
331
+
332
+ # from-registry.ts is the DERIVATION, not a hand-written tool. Counting it
333
+ # inflated the census by one and would have let the file that deletes
334
+ # hand-written tools count as one itself.
335
+ local hw cur
336
+ hw="$(grep -h 'name: "' $(ls "$MCP_TOOLS_DIR"/*.ts 2>/dev/null | grep -v '/from-registry\.ts$') 2>/dev/null | wc -l | tr -d ' ')"
337
+ [ "$hw" -ge "$MAX_HANDWRITTEN_MCP" ] && {
338
+ red "$hw hand-written MCP tool definitions, must be strictly below $MAX_HANDWRITTEN_MCP"; return 1; }
339
+
340
+ if [ -d "$CHAT_TOOLS_DIR" ]; then
341
+ cur="$(grep -rho 'defineWorkspaceTool(' "$CHAT_TOOLS_DIR"/*.ts 2>/dev/null | wc -l | tr -d ' ')"
342
+ [ "${cur:-0}" -gt "$MAX_CURATED_CHAT" ] && {
343
+ red "$cur curated chat tools, ceiling is $MAX_CURATED_CHAT"; return 1; }
344
+ else
345
+ cur="n/a"
346
+ fi
347
+
348
+ say "counts ok — no coexistence, $hw hand-written MCP defs (< $MAX_HANDWRITTEN_MCP), $cur curated chat tools"
349
+ return 0
350
+ }
351
+
352
+ # ── self-test ────────────────────────────────────────────────────────────────
353
+ # Each check is driven to RED against the real tree by moving its own threshold
354
+ # or pointing it at a fixture carrying the defect. A checker that has only ever
355
+ # run against a healthy tree proves nothing about what it would catch.
356
+ self_test() {
357
+ local fails=0 rc tmp
358
+ tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' RETURN
359
+
360
+ expect_red() { # expect_red <label> <cmd...>
361
+ local label="$1"; shift
362
+ "$@" >/dev/null 2>&1; rc=$?
363
+ if [ "$rc" -eq 1 ]; then printf ' ok %s -> RED\n' "$label"
364
+ else printf ' FAIL %s -> expected 1, got %s\n' "$label" "$rc"; fails=$((fails + 1)); fi
365
+ }
366
+ expect_cant() { # expect_cant <label> <cmd...>
367
+ local label="$1"; shift
368
+ "$@" >/dev/null 2>&1; rc=$?
369
+ if [ "$rc" -eq 3 ]; then printf ' ok %s -> CANNOT RUN\n' "$label"
370
+ else printf ' FAIL %s -> expected 3, got %s\n' "$label" "$rc"; fails=$((fails + 1)); fi
371
+ }
372
+
373
+ # contract: an unreachable floor must go red, not pass
374
+ expect_red "contract (floor 99999)" env DERIVE_MIN_MCP_FLAGGED=99999 bash "$0" contract
375
+ # mcp: same
376
+ expect_red "mcp (floor 99999)" env DERIVE_MIN_DERIVED_TOOLS=99999 bash "$0" mcp
377
+ # counts: a ceiling of 0 hand-written defs must go red while any survive
378
+ expect_red "counts (max-handwritten 1)" env DERIVE_MAX_HANDWRITTEN_MCP=1 bash "$0" counts
379
+ # routes: a fixture tree carrying a twin of a real receiver
380
+ mkdir -p "$tmp/api/groups"
381
+ : > "$tmp/api/groups/create.ts"
382
+ : > "$tmp/allow.txt"
383
+ expect_red "routes (fixture twin)" env DERIVE_API_DIR="$tmp/api" DERIVE_ROUTE_ALLOWLIST="$tmp/allow.txt" bash "$0" routes
384
+ # routes: the same twin, allowlisted, must pass — the ratchet grandfathers
385
+ printf 'groups/create.ts\n' > "$tmp/allow.txt"
386
+ env DERIVE_API_DIR="$tmp/api" DERIVE_ROUTE_ALLOWLIST="$tmp/allow.txt" bash "$0" routes >/dev/null 2>&1
387
+ rc=$?
388
+ if [ "$rc" -eq 0 ]; then printf ' ok routes (allowlisted) -> ok\n'
389
+ else printf ' FAIL routes (allowlisted) -> expected 0, got %s\n' "$rc"; fails=$((fails + 1)); fi
390
+ # cannot-run is distinguishable from red
391
+ expect_cant "routes (missing allowlist)" env DERIVE_API_DIR="$tmp/api" DERIVE_ROUTE_ALLOWLIST="$tmp/nope.txt" bash "$0" routes
392
+ expect_cant "counts (missing tools dir)" env DERIVE_MCP_TOOLS_DIR="$tmp/nope" bash "$0" counts
393
+
394
+ # binder: the defining fixture — a wrapper that consults the policy and calls
395
+ # the handler anyway passes every structural count and must fail A1.
396
+ cat > "$tmp/leaky.mjs" <<'EOF'
397
+ const fail = [];
398
+ let ran = false;
399
+ const leaky = (receiver, handler) => async (d, e, c) => {
400
+ const authed = c && (c.staff === true || c.ownerSlug); // consulted...
401
+ if (!authed) console.warn('would reject'); // ...and ignored
402
+ return handler(d, e, c);
403
+ };
404
+ const bound = leaky('world:create-group', async () => { ran = true; return { ok: true }; });
405
+ let threw = false;
406
+ try { await bound({}, {}, {}); } catch { threw = true; }
407
+ if (!threw) fail.push('A1: unauthorised caller was NOT rejected');
408
+ if (ran) fail.push('A1: handler RAN despite the auth gate');
409
+ process.exit(fail.length ? 1 : 0);
410
+ EOF
411
+ node "$tmp/leaky.mjs" >/dev/null 2>&1; rc=$?
412
+ if [ "$rc" -eq 1 ]; then printf ' ok binder (leaky-wrapper fixture) -> RED\n'
413
+ else printf ' FAIL binder (leaky-wrapper fixture) -> expected 1, got %s\n' "$rc"; fails=$((fails + 1)); fi
414
+
415
+ if [ "$fails" -eq 0 ]; then say "self-test PASS — every check can go red, and red is distinguishable from cannot-run"; return 0; fi
416
+ printf '[derive-check] self-test FAILED (%s)\n' "$fails" >&2
417
+ return 1
418
+ }
419
+
420
+ # ── main ─────────────────────────────────────────────────────────────────────
421
+ case "${1:-}" in
422
+ contract) check_contract ;;
423
+ mcp) check_mcp ;;
424
+ binder) check_binder ;;
425
+ routes) check_routes ;;
426
+ counts) check_counts ;;
427
+ --freeze-routes) freeze_routes ;;
428
+ --self-test) self_test ;;
429
+ all|"")
430
+ rc=0
431
+ for c in contract mcp binder routes counts; do
432
+ bash "$0" "$c" || rc=$?
433
+ done
434
+ exit "$rc" ;;
435
+ *) printf 'usage: %s {contract|mcp|binder|routes|counts|all|--self-test|--freeze-routes}\n' "$(basename "$0")" >&2; exit 2 ;;
436
+ esac
@@ -33,7 +33,7 @@ WT_BASE="$ROOT/.do-worktrees"
33
33
 
34
34
  # Worktree branches cut from trunk, never HEAD — a shared tree parked on a
35
35
  # feature branch must not seed fleet plans with its commits. DO_BASE_REF overrides.
36
- BASE_REF="${DO_BASE_REF:-main}"
36
+ BASE_REF="${DO_BASE_REF:-dev}"
37
37
  git -C "$ROOT" show-ref --verify --quiet "refs/heads/$BASE_REF" || BASE_REF=HEAD
38
38
 
39
39
  GO=false
@@ -57,9 +57,33 @@ done
57
57
  # Cap slots by (cores - 2), never exceed --slots.
58
58
  CORES=$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 4)
59
59
  CORE_CAP=$(( CORES - 2 )); [ "$CORE_CAP" -lt 1 ] && CORE_CAP=1
60
- [ "$SLOTS" -gt "$CORE_CAP" ] && SLOTS="$CORE_CAP"
60
+ BIND="--slots"
61
+ [ "$SLOTS" -gt "$CORE_CAP" ] && { SLOTS="$CORE_CAP"; BIND="cores"; }
61
62
 
62
- echo "[do-fleet] ranking(top $TOP candidates, $SLOTS slots, $( $GO && echo LIVE || echo DRY-RUN ))"
63
+ #and by MEMORY, which is the constraint that actually bites.
64
+ #
65
+ # Cores were never the limit. On a 10-core/24GB box `cores - 2` authorised 8
66
+ # concurrent worktrees; 8 cycles is ~16GB of gates before the editors, the
67
+ # sessions and the OS get a byte. The box reached load 57 and filled 16GB of
68
+ # swap with fewer than that. gate_headroom prices a cycle in GB and divides
69
+ # what is actually free, so the fleet shrinks on a loaded box and opens back
70
+ # up on an idle one. It only ever LOWERS the count — --slots and the core cap
71
+ # still hold.
72
+ # shellcheck source=lib/govern.sh
73
+ . "$ROOT/.claude/scripts/lib/govern.sh"
74
+ MEM_CAP=$(gate_headroom)
75
+ [ "$SLOTS" -gt "$MEM_CAP" ] && { SLOTS="$MEM_CAP"; BIND="memory"; }
76
+
77
+ # A worktree cut before the governor landed runs the OLD unbounded gates, so
78
+ # the cap above is the only thing protecting the box from it. Say so — a silent
79
+ # stale worktree is how this problem returns.
80
+ for _wt in "$WT_BASE"/*/; do
81
+ [ -f "$_wt/.claude/scripts/lib/govern.sh" ] && continue
82
+ [ -d "$_wt/.claude/scripts" ] || continue
83
+ echo "[do-fleet] ⚠ $(basename "$_wt") predates the governor — its gates are unbounded; rebase it onto $BASE_REF"
84
+ done
85
+
86
+ echo "[do-fleet] ranking… (top $TOP candidates, $SLOTS slots — bound by $BIND; cores≤$CORE_CAP mem≤$MEM_CAP, $( $GO && echo LIVE || echo DRY-RUN ))"
63
87
 
64
88
  # Load ranked queue into an array (bash 3.2 compatible — no mapfile).
65
89
  QUEUE=()
@@ -89,47 +113,82 @@ link_deps() {
89
113
  done
90
114
  }
91
115
 
92
- # Dynamic file-lock registry: "dir<TAB>slug" lines in a temp file.
93
- LOCK_FILE="$(mktemp)"
94
- trap 'rm -f "$LOCK_FILE"' EXIT
95
-
96
- claim_dirs() { # claim_dirs <slug>
97
- local n=0
116
+ # Region-claim registry MACHINE-WIDE, in $GOVERN_DIR/claims (lib/govern.sh).
117
+ #
118
+ # This used to be `LOCK_FILE="$(mktemp)"`: a map private to one fleet process.
119
+ # Nine concurrent fleet invocations held nine private maps, so two fleets could
120
+ # each believe a region was free and both take it — the duplicate-selection
121
+ # collision this registry exists to remove. mkdir(2) is the arbiter; a claim
122
+ # evaporates on its own when the owner dies (kill -0) or its lease expires.
123
+ trap 'claim_release_all' EXIT
124
+
125
+ claim_dirs() { # claim_dirs <slug> → 0 = all regions claimed, 1 = lost a race
126
+ local slug="$1" n=0 d
127
+ CLAIM_TAKEN=()
98
128
  while IFS= read -r d; do
99
- [ -n "$d" ] && { printf '%s\t%s\n' "$d" "$1" >> "$LOCK_FILE"; n=$(( n + 1 )); }
100
- done < <(deliverable_dirs "$1")
129
+ [ -n "$d" ] || continue
130
+ n=$(( n + 1 ))
131
+ if claim_take "$d" "$slug"; then
132
+ CLAIM_TAKEN+=("$d")
133
+ else
134
+ # Another fleet won this region between has_conflict and here. Give back
135
+ # everything already taken for this slug — a half-claimed plan would hold
136
+ # ground it never builds, and only the TTL would ever free it.
137
+ if [ "${#CLAIM_TAKEN[@]}" -gt 0 ]; then
138
+ for d in "${CLAIM_TAKEN[@]}"; do claim_release "$d"; done
139
+ fi
140
+ echo " ✗ $slug — lost the race for a region to another fleet, deferring" >&2
141
+ return 1
142
+ fi
143
+ done < <(deliverable_dirs "$slug")
101
144
  # An unscoped plan (no deliverables:) declares zero dirs — the conflict filter
102
145
  # would treat it as disjoint from EVERYTHING and parallelize it blindly, which
103
146
  # clobbers shared trees (one.ie/web). Claim the __ANY__ sentinel so it conflicts
104
147
  # with any other plan: it only ever runs as the sole slot. Declare deliverables
105
148
  # to make it fleet-safe (see do-rank.py UNSCOPED warning).
106
- [ "$n" -eq 0 ] && printf '%s\t%s\n' "__ANY__" "$1" >> "$LOCK_FILE"
149
+ if [ "$n" -eq 0 ]; then
150
+ claim_take "__ANY__" "$slug" || { echo " ✗ $slug — __ANY__ taken, deferring" >&2; return 1; }
151
+ fi
152
+ return 0
107
153
  }
108
154
 
109
155
  free_dirs() { # free_dirs <slug>
110
- local tmp; tmp="$(mktemp)"
111
- grep -v " $1$" "$LOCK_FILE" > "$tmp" 2>/dev/null || true
112
- mv "$tmp" "$LOCK_FILE"
156
+ local d
157
+ while IFS= read -r d; do
158
+ [ -n "$d" ] && claim_release "$d"
159
+ done < <(deliverable_dirs "$1")
160
+ # Only the slug that took the sentinel may give it back. Every claim in this
161
+ # process shares one pid, so an unguarded release here would let a finishing
162
+ # SCOPED plan hand back a live UNSCOPED plan's sole-slot guarantee.
163
+ [ "$(claim_owner "__ANY__" | awk '{print $1}')" = "$1" ] && claim_release "__ANY__"
164
+ return 0
113
165
  }
114
166
 
115
167
  has_conflict() { # has_conflict <slug> → exit 0 if conflict
168
+ # Every read below goes through claim_owner/claim_list, which reap a dead or
169
+ # expired owner before answering — so a fleet that was killed mid-plan never
170
+ # keeps a region shut.
171
+ local live_owner
116
172
  # A live unscoped plan holds the __ANY__ sentinel → nothing may share with it.
117
- any_owner=$(awk -F'\t' '$1=="__ANY__"{print $2; exit}' "$LOCK_FILE" 2>/dev/null || true)
173
+ any_owner=$(claim_owner "__ANY__" | awk '{print $1}')
118
174
  if [ -n "$any_owner" ] && [ "$any_owner" != "$1" ]; then
119
175
  echo " ✗ $1 — unscoped plan $any_owner holds the fleet (sole slot), deferring"
120
176
  return 0
121
177
  fi
122
178
  # This plan is itself unscoped (no deliverable dirs) → only runs alone.
123
179
  ndirs=$(deliverable_dirs "$1" | grep -c . || true)
124
- if [ "$ndirs" -eq 0 ] && [ -s "$LOCK_FILE" ]; then
125
- echo "$1 unscoped (no deliverables:), defers while any plan is live"
126
- return 0
180
+ if [ "$ndirs" -eq 0 ]; then
181
+ live_owner=$(claim_list | awk -F'\t' -v s="$1" '$2!=s{print $2; exit}')
182
+ if [ -n "$live_owner" ]; then
183
+ echo " ✗ $1 — unscoped (no deliverables:), defers while $live_owner is live"
184
+ return 0
185
+ fi
127
186
  fi
128
187
  while IFS= read -r d; do
129
188
  [ -z "$d" ] && continue
130
- owner=$(awk -F'\t' -v k="$d" '$1==k{print $2; exit}' "$LOCK_FILE" 2>/dev/null || true)
131
- if [ -n "$owner" ]; then
132
- echo " ✗ $1 — dir $d locked by $owner, deferring"
189
+ owner=$(claim_owner "$d" | awk '{print $1}')
190
+ if [ -n "$owner" ] && [ "$owner" != "$1" ]; then
191
+ echo " ✗ $1 — dir $d claimed by $owner, deferring"
133
192
  return 0
134
193
  fi
135
194
  done < <(deliverable_dirs "$1")
@@ -175,16 +234,22 @@ claim_task() { # claim_task <slug> → 0 ok to launch, 1 already claimed elsewh
175
234
  esac
176
235
  }
177
236
 
178
- launch_plan() { # launch_plan <slug> → echoes PID
237
+ launch_plan() { # launch_plan <slug> → echoes PID, or nothing if the claim was lost
179
238
  local slug="$1"
180
239
  local wt="$WT_BASE/$slug"
240
+ # Claim the ground FIRST. has_conflict only read the registry; between that
241
+ # read and now another fleet may have deposited. mkdir decides, and a loser
242
+ # reroutes to the next candidate rather than waiting.
243
+ claim_dirs "$slug" || return 1
181
244
  if [ ! -d "$wt" ]; then
182
245
  git -C "$ROOT" worktree add -b "do/$slug" "$wt" "$BASE_REF" >/dev/null 2>&1 \
183
246
  || git -C "$ROOT" worktree add "$wt" "do/$slug" >/dev/null 2>&1 || true
184
247
  fi
185
- link_deps "$wt"
186
- claim_dirs "$slug"
187
- echo " launching $slug (log: $wt/.fleet.log)"
248
+ # stdout of this function IS the pid — fill_slots captures it. Chatter must go
249
+ # to stderr or the "pid" is a multi-line string and every kill -0 on it fails,
250
+ # which reads as "slot finished" the instant it launches.
251
+ link_deps "$wt" >&2
252
+ echo " ▶ launching $slug (log: $wt/.fleet.log)" >&2
188
253
  # Direct exec (no subshell wrapper) so $! is the do-auto.sh bash process itself.
189
254
  # A ( cmd ) & subshell forks cmd and exits immediately — the fleet would track the
190
255
  # subshell PID, which dies before cmd finishes, misreporting the slot as done.
@@ -196,7 +261,15 @@ launch_plan() { # launch_plan <slug> → echoes PID
196
261
  if ! $GO; then
197
262
  echo ""
198
263
  echo " DRY-RUN — first $SLOTS disjoint plans from queue:"
199
- CLAIMED_DRY=""
264
+ # Seed the preview from the LIVE machine-wide registry (read-only — a dry run
265
+ # never deposits). Otherwise the preview says "✓ foo" for a region another
266
+ # fleet is already inside, which is exactly the lie the registry removes.
267
+ # claim_list emits region<TAB>slug<TAB>pid and reaps dead owners as it walks,
268
+ # so the preview inherits evaporation for free.
269
+ CLAIMED_DRY="$(claim_list)"
270
+ # Keep "" meaning "nothing claimed" — the unscoped-plan check below tests -n.
271
+ if [ -n "$CLAIMED_DRY" ]; then CLAIMED_DRY="$CLAIMED_DRY
272
+ "; fi
200
273
  COUNT=0
201
274
  for slug in "${QUEUE[@]}"; do
202
275
  [ "$COUNT" -ge "$SLOTS" ] && break
@@ -309,7 +382,12 @@ fill_slots() {
309
382
  DONE_SLUGS+=("$NEXT_SLUG")
310
383
  continue
311
384
  fi
312
- local pid; pid=$(launch_plan "$NEXT_SLUG")
385
+ local pid=""
386
+ # A lost region race is not an error — reroute to the next candidate.
387
+ if ! pid=$(launch_plan "$NEXT_SLUG") || [ -z "$pid" ]; then
388
+ DONE_SLUGS+=("$NEXT_SLUG")
389
+ continue
390
+ fi
313
391
  SLOT_PIDS+=("$pid")
314
392
  SLOT_SLUGS+=("$NEXT_SLUG")
315
393
  SLOT_START+=("$(date +%s)")