@sjawhar/claude-envoy 0.0.0 → 1.1.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 (113) hide show
  1. package/.claude-plugin/plugin.json +6 -0
  2. package/.mcp.json +11 -0
  3. package/.omp-plugin/plugin.json +6 -0
  4. package/AGENTS.md +343 -0
  5. package/README.md +639 -0
  6. package/bin/dispatch +6 -0
  7. package/bin/envoy-channel.ts +9 -0
  8. package/bin/envoy-send.ts +33 -0
  9. package/dist/THIRD_PARTY_NOTICES +816 -0
  10. package/dist/dispatch.js +27707 -0
  11. package/dist/envoy-channel.js +57695 -0
  12. package/dist/session-hook.js +26328 -0
  13. package/dist/session-title-hook.js +116 -0
  14. package/hooks/hooks.json +60 -0
  15. package/hooks/session-hook.ts +512 -0
  16. package/hooks/session-title-hook.ts +46 -0
  17. package/legion/.claude-plugin/plugin.json +4 -0
  18. package/legion/agents/deep-worker.md +68 -0
  19. package/legion/agents/oracle.md +35 -0
  20. package/legion/agents/plan-gap-analyst.md +65 -0
  21. package/legion/agents/plan-reviewer.md +104 -0
  22. package/legion/agents/thermonuclear-code-quality.md +30 -0
  23. package/legion/agents/thermonuclear-deep-review.md +30 -0
  24. package/legion/hooks/hooks.json +89 -0
  25. package/legion/skills/ce-simplify-code/LICENSE +21 -0
  26. package/legion/skills/ce-simplify-code/SKILL.md +66 -0
  27. package/legion/skills/ce-simplify-code/references/personas/code-quality-reviewer.md +17 -0
  28. package/legion/skills/ce-simplify-code/references/personas/code-reuse-reviewer.md +7 -0
  29. package/legion/skills/ce-simplify-code/references/personas/efficiency-reviewer.md +11 -0
  30. package/legion/skills/leading-a-work-stream/SKILL.md +96 -0
  31. package/legion/skills/leading-a-work-stream/references/daily-plan.md +8 -0
  32. package/legion/skills/leading-a-work-stream/references/decision-rights.md +6 -0
  33. package/legion/skills/leading-a-work-stream/references/direction-and-coaching.md +13 -0
  34. package/legion/skills/leading-a-work-stream/references/dispatch-upkeep.md +8 -0
  35. package/legion/skills/leading-a-work-stream/references/sources.md +9 -0
  36. package/legion/skills/leading-a-work-stream/references/speed-tick.md +44 -0
  37. package/legion/skills/leading-a-work-stream/references/user-quotes.md +7 -0
  38. package/legion/skills/legion-architect/SKILL.md +86 -0
  39. package/legion/skills/legion-architect/references/acceptance.md +208 -0
  40. package/legion/skills/legion-architect/references/decomposition.md +129 -0
  41. package/legion/skills/legion-architect/references/design-gate.md +206 -0
  42. package/legion/skills/legion-architect/references/merge-notices.md +91 -0
  43. package/legion/skills/legion-architect/references/wake-routing.md +41 -0
  44. package/legion/skills/legion-controller/SKILL.md +151 -0
  45. package/legion/skills/legion-controller/references/admission.md +22 -0
  46. package/legion/skills/legion-controller/references/backlog-stewardship.md +101 -0
  47. package/legion/skills/legion-controller/references/daily-report.md +75 -0
  48. package/legion/skills/legion-controller/references/escalation.md +82 -0
  49. package/legion/skills/legion-controller/references/finding-asks.md +181 -0
  50. package/legion/skills/legion-controller/references/parked-decisions.md +198 -0
  51. package/legion/skills/legion-controller/references/session.md +100 -0
  52. package/legion/skills/legion-controller/references/slack-mentions.md +154 -0
  53. package/legion/skills/legion-controller/references/triage.md +96 -0
  54. package/legion/skills/legion-controller/references/wakes.md +132 -0
  55. package/legion/skills/legion-oracle/SKILL.md +48 -0
  56. package/legion/skills/legion-retro/SKILL.md +112 -0
  57. package/legion/skills/legion-retro/references/learnings.md +60 -0
  58. package/legion/skills/legion-retro/references/pr-body-content.md +56 -0
  59. package/legion/skills/legion-worker/SKILL.md +69 -0
  60. package/legion/skills/legion-worker/references/cleanup-deletion.md +22 -0
  61. package/legion/skills/legion-worker/references/conflicts-and-rewrites.md +164 -0
  62. package/legion/skills/legion-worker/references/default-branch-check.md +53 -0
  63. package/legion/skills/legion-worker/references/findings.md +199 -0
  64. package/legion/skills/legion-worker/references/merge-gate.md +353 -0
  65. package/legion/skills/legion-worker/references/pr-body.md +218 -0
  66. package/legion/skills/legion-worker/references/review-threads.md +77 -0
  67. package/legion/skills/legion-worker/references/systematic-rename.md +19 -0
  68. package/legion/skills/legion-worker/references/tools.md +708 -0
  69. package/legion/skills/thermonuclear-code-quality/LICENSE +21 -0
  70. package/legion/skills/thermonuclear-code-quality/SKILL.md +60 -0
  71. package/legion/skills/thermonuclear-deep-review/LICENSE +21 -0
  72. package/legion/skills/thermonuclear-deep-review/SKILL.md +80 -0
  73. package/package.json +49 -1
  74. package/scripts/build-inputs.txt +84 -0
  75. package/scripts/build.ts +415 -0
  76. package/scripts/check-packed-skills.sh +50 -0
  77. package/scripts/pack-skills.sh +146 -0
  78. package/scripts/smoke-channel.sh +178 -0
  79. package/scripts/smoke-clear-rebind.sh +164 -0
  80. package/scripts/smoke-session-title.sh +245 -0
  81. package/scripts/verify-build.sh +88 -0
  82. package/skills/agent-secrets/SKILL.md +180 -0
  83. package/skills/asd-ste100/LICENSE +21 -0
  84. package/skills/asd-ste100/SKILL.md +140 -0
  85. package/skills/asd-ste100/examples/before-after.md +92 -0
  86. package/skills/asd-ste100/references/writing-rules.md +59 -0
  87. package/skills/dispatch/SKILL.md +89 -0
  88. package/skills/dispatch-first/SKILL.md +46 -0
  89. package/skills/dispatch-writing-specs/SKILL.md +80 -0
  90. package/skills/envoy/SKILL.md +82 -0
  91. package/skills/envoy/references/check-settlement.md +62 -0
  92. package/skills/envoy/references/deliveries.md +67 -0
  93. package/skills/envoy/references/legion.md +14 -0
  94. package/skills/envoy/references/roles.md +12 -0
  95. package/skills/envoy/references/sources.md +41 -0
  96. package/skills/outside-message/SKILL.md +111 -0
  97. package/skills/plain-words/SKILL.md +88 -0
  98. package/src/channel-forwarder.ts +262 -0
  99. package/src/claude-channel.ts +705 -0
  100. package/src/claude-session.ts +38 -0
  101. package/src/envoy-channel-server.ts +2115 -0
  102. package/src/envoy-client.ts +26 -0
  103. package/src/envoy-off.ts +62 -0
  104. package/src/legion-claim.ts +211 -0
  105. package/src/legion-inbox.ts +110 -0
  106. package/src/legion-phase-hook.ts +554 -0
  107. package/src/legion-tool.ts +69 -0
  108. package/src/replay-state.ts +97 -0
  109. package/src/send-arguments.ts +13 -0
  110. package/src/session-identity.ts +165 -0
  111. package/src/session-state.ts +142 -0
  112. package/src/session-title.ts +251 -0
  113. package/src/subscription-topics.ts +16 -0
@@ -0,0 +1,415 @@
1
+ // Bundle the plugin's executables into self-contained files the Claude Code
2
+ // plugin cache can run: the cache holds the published package's tree with no
3
+ // node_modules, so every dependency (workspace packages included) is inlined
4
+ // here, with `THIRD_PARTY_NOTICES` beside the bundles holding the license of
5
+ // every third-party package they inline. The same build generates `legion/`, the
6
+ // Legion plugin a Claude Code pane loads beside this one (`buildLegionPlugin`).
7
+ //
8
+ // `dist/` and `legion/` are generated, never committed: release.yaml's
9
+ // claude_envoy_build builds them with scripts/verify-build.sh and packs them
10
+ // into the tarball it publishes to npm, and no mode writes them into the
11
+ // package. The one mode builds only on the Bun the repo-root `.bun-version`
12
+ // pins, because bundler output depends on the exact Bun build (LEGION-568):
13
+ // --out <dir> make <dir>, a new or empty directory outside the repository, a
14
+ // runnable copy of the plugin built from this tree.
15
+ //
16
+ // scripts/build-inputs.txt must name every file this build reads, since
17
+ // release.yaml's $CLAUDE_ENVOY_VERSION_ARGS covers that list and a build input missing from it is
18
+ // one no release bumps a version for. tests/build-inputs.test.ts checks it against what
19
+ // buildBundles and buildLegionPlugin report reading; a file this script reads by path or
20
+ // imports from outside the listed packages (.bun-version, scripts/third-party-notices.ts, the
21
+ // scripts it runs) is listed by hand.
22
+ import { cp, mkdir, readdir, readFile, realpath, writeFile } from "node:fs/promises"
23
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"
24
+ import { thirdPartyNotices } from "../../../scripts/third-party-notices"
25
+ import { PHASE_HOOK_TOOL_NAME } from "../src/legion-phase-hook"
26
+
27
+ const packageRoot = resolve(import.meta.dir, "..")
28
+ const repoRoot = resolve(packageRoot, "..", "..")
29
+ /** Read once, here, rather than wherever it is needed: every caller below compares against the
30
+ * same string for the same process, and `assertPinnedBun`'s test already covers the comparison
31
+ * itself without needing a real file on disk. */
32
+ const pinnedBunVersion = (await readFile(join(repoRoot, ".bun-version"), "utf8")).trim()
33
+
34
+ /** Output name -> source entrypoint, relative to the package root. `dispatch` is the `dispatch`
35
+ * CLI that `bin/dispatch` runs, built from the shared library's entry. `session-title-hook` is a
36
+ * bundle of its own rather than a mode of `session-hook`, because Claude Code blocks the user's
37
+ * prompt on it: an ESM entrypoint evaluates its whole module graph before it reads `argv`, so
38
+ * sharing one would make every prompt pay for zod and the Dispatch client. */
39
+ export const BUNDLE_ENTRYPOINTS = {
40
+ "envoy-channel": "bin/envoy-channel.ts",
41
+ "session-hook": "hooks/session-hook.ts",
42
+ "session-title-hook": "hooks/session-title-hook.ts",
43
+ dispatch: "../envoy-client/bin/dispatch.ts",
44
+ } as const
45
+
46
+ /** Oh My Pi's tool names, as pi-legion's agents list them, to Claude Code's. A tool this Claude
47
+ * Code build lacks maps to nothing: it has no Glob or Grep tool (an agent searches with `rg` or
48
+ * `fd` through Bash where it has Bash), and its own task list stands in for `todo`. A name in
49
+ * neither list is refused, so a new tool is mapped on purpose. */
50
+ export const CLAUDE_TOOL_NAMES: Readonly<Record<string, string | null>> = {
51
+ read: "Read",
52
+ bash: "Bash",
53
+ edit: "Edit",
54
+ write: "Write",
55
+ glob: null,
56
+ grep: null,
57
+ todo: null,
58
+ }
59
+
60
+ /** One pi-legion agent file in Claude Code's agent format. The frontmatter keeps `name`,
61
+ * `description` and `color`; `tools` is mapped through `CLAUDE_TOOL_NAMES`; `autoloadSkills`
62
+ * becomes `skills`, each the plugin's namespaced `legion:<skill>`; `model` (an Oh My Pi model
63
+ * role, which Claude Code does not read, so the agent runs on the pane's model) and every
64
+ * comment are dropped. The body is kept as it is. No shipped agent declares `blocking`
65
+ * (`packages/pi-legion/src/shipped-agents.test.ts`), so nothing here reads it. */
66
+ export function claudeAgent(file: string, source: string): string {
67
+ const match = /^---\n([\s\S]*?)\n---\n/.exec(source)
68
+ if (match === null) throw new Error(`${file} has no frontmatter`)
69
+ const lines: string[] = []
70
+ let dropping = false
71
+ for (const line of (match[1] as string).split("\n")) {
72
+ if (line.startsWith("#")) continue
73
+ const key = /^([A-Za-z]+):/.exec(line)?.[1]
74
+ if (key === undefined) {
75
+ if (!dropping) lines.push(line)
76
+ continue
77
+ }
78
+ const value = line.slice(key.length + 1).trim()
79
+ dropping = key === "model"
80
+ if (dropping) continue
81
+ if (key === "tools") {
82
+ const tools = value.split(",").map((name) => {
83
+ const tool = name.trim()
84
+ const mapped = CLAUDE_TOOL_NAMES[tool]
85
+ if (mapped === undefined) {
86
+ throw new Error(`${file} lists the tool ${tool}, which CLAUDE_TOOL_NAMES does not map`)
87
+ }
88
+ return mapped
89
+ })
90
+ const claude = tools.filter((tool): tool is string => tool !== null)
91
+ if (claude.length === 0) throw new Error(`${file} keeps no tool Claude Code has`)
92
+ lines.push(`tools: ${claude.join(", ")}`)
93
+ } else if (key === "autoloadSkills") {
94
+ const skills = /^\[(.*)\]$/.exec(value)?.[1]
95
+ if (skills === undefined) throw new Error(`${file}'s autoloadSkills is not a flow list`)
96
+ const names = skills.split(",").map((skill) => `legion:${skill.trim()}`)
97
+ lines.push(`skills: [${names.join(", ")}]`)
98
+ } else {
99
+ lines.push(line)
100
+ }
101
+ }
102
+ return `---\n${lines.join("\n")}\n---\n${source.slice(match[0].length)}`
103
+ }
104
+
105
+ /** The phase reminder's hook entries (INFRA-220): the events Claude Code fires at a phase
106
+ * worker, each an `mcp_tool` hook on the pane's own Legion MCP server, which `claude` registers
107
+ * as the bare name `legion` (packages/daemon/internal/runtime/tmux/claude.go), and on the tool
108
+ * `legion_phase_hook` that server offers (packages/claude-envoy/src/legion-phase-hook.ts).
109
+ * Claude Code substitutes each `${…}` as a string, which the endpoint parses.
110
+ *
111
+ * They belong to the Legion plugin alone, never to `hooks/hooks.json`: `@sjawhar/claude-envoy`
112
+ * is also installed as a plain Claude Code plugin, where `--mcp-config` registers no `legion`
113
+ * server and every one of these would be a hook error on every turn. A Legion pane loads both
114
+ * plugin directories, so its worker still gets them. */
115
+ // biome-ignore-start lint/suspicious/noTemplateCurlyInString: Claude Code substitutes each `${…}`
116
+ // into the hook's input before it calls the tool; they are its syntax, not this file's.
117
+ const LEGION_HOOKS: readonly {
118
+ readonly event: string
119
+ readonly input: Readonly<Record<string, string>>
120
+ readonly timeout: number
121
+ }[] = [
122
+ {
123
+ event: "Stop",
124
+ input: {
125
+ event: "Stop",
126
+ lastAssistantMessage: "${last_assistant_message}",
127
+ stopHookActive: "${stop_hook_active}",
128
+ // The session's own background tasks, each `{ id, type, status, description, command }`,
129
+ // rendered as JSON text: a turn that ends with one still running is a worker waiting to be
130
+ // woken by it rather than a stalled one, and earns no reminder.
131
+ backgroundTasks: "${background_tasks}",
132
+ },
133
+ // The `Stop` answer waits on the reminder's record check, which bounds its own daemon read at
134
+ // 5 s (RECORD_READ_BOUND_MS, packages/pi-shared/src/phase-reminder.ts); the others answer from
135
+ // memory and the state file alone.
136
+ timeout: 30,
137
+ },
138
+ {
139
+ event: "StopFailure",
140
+ input: { event: "StopFailure", lastAssistantMessage: "${last_assistant_message}" },
141
+ timeout: 10,
142
+ },
143
+ {
144
+ event: "PostToolUse",
145
+ input: { event: "PostToolUse", toolName: "${tool_name}", agentId: "${agent_id}" },
146
+ timeout: 10,
147
+ },
148
+ {
149
+ event: "PostToolUseFailure",
150
+ input: { event: "PostToolUseFailure", toolName: "${tool_name}", agentId: "${agent_id}" },
151
+ timeout: 10,
152
+ },
153
+ {
154
+ event: "UserPromptSubmit",
155
+ input: { event: "UserPromptSubmit", prompt: "${prompt}" },
156
+ timeout: 10,
157
+ },
158
+ ]
159
+ // biome-ignore-end lint/suspicious/noTemplateCurlyInString: the hook inputs end here.
160
+
161
+ /** `legion/`, the Legion plugin a Claude Code pane loads with `--plugin-dir` beside this one
162
+ * (packages/daemon/internal/runtime/tmux/claude.go): its manifest, named `legion`, so Claude Code
163
+ * names its skills and agents `legion:<name>`; the skills pi-legion ships, staged by the one script
164
+ * that partitions them (scripts/pi-plugin-prepack.sh --stage-skills); pi-legion's task agents
165
+ * in Claude Code's format (`claudeAgent`); and the phase reminder's hooks (`LEGION_HOOKS`).
166
+ * Generated, never edited by hand.
167
+ *
168
+ * Returns, as `buildBundles` does, the absolute path of every repository file it read: each agent
169
+ * file, and the source each staged skill file was copied from. tests/build-inputs.test.ts checks
170
+ * the input list against it, so a source added here is one the list must name rather than one the
171
+ * test can miss. Callers that only want the output ignore the result. */
172
+ export async function buildLegionPlugin(outdir: string): Promise<string[]> {
173
+ await mkdir(join(outdir, ".claude-plugin"), { recursive: true })
174
+ await writeFile(
175
+ join(outdir, ".claude-plugin", "plugin.json"),
176
+ `${JSON.stringify(
177
+ {
178
+ name: "legion",
179
+ description:
180
+ "Legion's skills and task agents for a phase worker running on Claude Code, generated from the repository's skills/ and packages/pi-legion/agents by packages/claude-envoy/scripts/build.ts.",
181
+ },
182
+ null,
183
+ 2,
184
+ )}\n`,
185
+ )
186
+ const stagedSkills = join(outdir, "skills")
187
+ const staged = Bun.spawnSync([
188
+ "bash",
189
+ join(repoRoot, "scripts", "pi-plugin-prepack.sh"),
190
+ "--stage-skills",
191
+ "@sjawhar/pi-legion",
192
+ stagedSkills,
193
+ ])
194
+ if (staged.exitCode !== 0) {
195
+ throw new Error(`staging pi-legion's skills failed: ${staged.stderr.toString().trim()}`)
196
+ }
197
+ // Each staged file is a copy of skills/<the same path>, since stage_skills copies whole skill
198
+ // directories (`cp -R "$root/skills/$skill" "$dest/$skill"`), so the file read is that source and
199
+ // not the copy written here. A source that is not there means this mapping, not a skill, is wrong.
200
+ const reads: string[] = []
201
+ for (const entry of await readdir(stagedSkills, { recursive: true, withFileTypes: true })) {
202
+ if (entry.isDirectory()) continue
203
+ const relativePath = relative(stagedSkills, join(entry.parentPath, entry.name))
204
+ const source = join(repoRoot, "skills", relativePath)
205
+ if (!(await Bun.file(source).exists())) {
206
+ throw new Error(`staged skill file ${relativePath} does not come from ${source}`)
207
+ }
208
+ reads.push(source)
209
+ }
210
+ const agents = join(repoRoot, "packages", "pi-legion", "agents")
211
+ await mkdir(join(outdir, "agents"))
212
+ for (const name of (await readdir(agents)).filter((file) => file.endsWith(".md")).sort()) {
213
+ const source = await readFile(join(agents, name), "utf8")
214
+ await writeFile(join(outdir, "agents", name), claudeAgent(name, source))
215
+ reads.push(join(agents, name))
216
+ }
217
+ await mkdir(join(outdir, "hooks"))
218
+ await writeFile(
219
+ join(outdir, "hooks", "hooks.json"),
220
+ `${JSON.stringify(
221
+ {
222
+ description:
223
+ "Legion: tell a phase worker whose turn ends with its phase still open, through the legion_phase_hook tool on the pane's own Legion MCP server.",
224
+ hooks: Object.fromEntries(
225
+ LEGION_HOOKS.map(({ event, input, timeout }) => [
226
+ event,
227
+ [
228
+ {
229
+ hooks: [
230
+ {
231
+ type: "mcp_tool",
232
+ server: "legion",
233
+ tool: PHASE_HOOK_TOOL_NAME,
234
+ input,
235
+ timeout,
236
+ },
237
+ ],
238
+ },
239
+ ],
240
+ ]),
241
+ ),
242
+ },
243
+ null,
244
+ 2,
245
+ )}\n`,
246
+ )
247
+ return reads
248
+ }
249
+
250
+ /**
251
+ * `bun run verify-build` spawns a new "bun" process to run this file: package.json's script text
252
+ * is a shell command, and Bun's own `run` resolves the "bun" in it from PATH rather than reusing
253
+ * whichever binary the caller invoked `bun run` with. A devbox whose
254
+ * default Bun (a version manager's active version, distinct from the one the caller meant to
255
+ * invoke) differs from the repository's pin then silently bundles with that other build instead —
256
+ * and the bundler's literal output depends on the exact Bun build (LEGION-568): confirmed directly
257
+ * between a devbox and the CI runner, and between two Bun releases on one machine, same source,
258
+ * same lockfile. This refuses rather than publish whatever that other build produced. Exported so
259
+ * the test can check the message without installing a second real Bun.
260
+ */
261
+ export function assertPinnedBun(running: string, pinned: string): void {
262
+ if (running === pinned) return
263
+ throw new Error(
264
+ `refusing to build under Bun ${running}: .bun-version pins ${pinned}, and the bundler's ` +
265
+ `output depends on the exact Bun build (LEGION-568). Invoke the pinned binary's own path ` +
266
+ `directly — not "bun run", which resolves "bun" from PATH — when a version manager's ` +
267
+ "default differs from the pin.",
268
+ )
269
+ }
270
+
271
+ /** The bundles and their notices, built into `outdir`; returns the absolute path of every file the
272
+ * bundles read (the metafile's inputs), which tests/build-inputs.test.ts checks the input list
273
+ * against. Bun writes each inlined module's path into the bundle relative to the working
274
+ * directory (`// ../envoy-client/bin/dispatch.ts` from the package, `// packages/envoy-client/...`
275
+ * from the repository root), so the build runs from the package root whatever the caller's
276
+ * directory: the published bytes and every check of them then agree. */
277
+ export async function buildBundles(outdir: string): Promise<string[]> {
278
+ assertPinnedBun(Bun.version, pinnedBunVersion)
279
+ const target = resolve(outdir)
280
+ const callerDirectory = process.cwd()
281
+ process.chdir(packageRoot)
282
+ try {
283
+ return await bundleFromPackageRoot(target)
284
+ } finally {
285
+ process.chdir(callerDirectory)
286
+ }
287
+ }
288
+
289
+ async function bundleFromPackageRoot(outdir: string): Promise<string[]> {
290
+ const result = await Bun.build({
291
+ entrypoints: Object.values(BUNDLE_ENTRYPOINTS).map((entry) => join(packageRoot, entry)),
292
+ outdir,
293
+ target: "bun",
294
+ // Minification is off entirely, not case by case: disabling whitespace/identifier/syntax
295
+ // minification individually (`{whitespace: false, identifiers: false, syntax: false}`) still
296
+ // routes through Bun 1.3.14's minifying code-generation path, which picks a non-deterministic
297
+ // CJS/ESM interop check on repeated builds of this exact module graph (observed directly: ten
298
+ // rebuilds of one committed checkout, eight of them disagreeing byte for byte with each
299
+ // other) — on top of the syntax minifier's own separate bug, truncating constant-folded
300
+ // multi-operand string concatenation in CI builds, that made `syntax: false` necessary before
301
+ // this. A plain `false` bypasses that whole path and reproducible across repeated builds was
302
+ // the same ten rebuilds, now agreeing every time. Whitespace stays readable as a side effect,
303
+ // so unrelated source changes still retain distinct bundle lines and merge cleanly.
304
+ //
305
+ // This is a separate, compounding cause from `assertPinnedBun` above: that guard stops a
306
+ // wrong Bun *version* from running at all, on the identical pinned version this fixes a
307
+ // non-determinism *within* one Bun build's own bundler. Either one alone could make
308
+ // verify-build.sh's two-build comparison fail unpredictably; both were real.
309
+ minify: false,
310
+ sourcemap: "none",
311
+ naming: "[name].[ext]",
312
+ metafile: true,
313
+ })
314
+ if (!result.success) {
315
+ throw new AggregateError(result.logs, "bundle build failed")
316
+ }
317
+ if (!result.metafile) throw new Error("Bun.build returned no metafile")
318
+ // The metafile's keys are relative to the working directory, the package root.
319
+ const inputs = Object.keys(result.metafile.inputs)
320
+ await writeFile(join(outdir, "THIRD_PARTY_NOTICES"), await thirdPartyNotices(inputs, packageRoot))
321
+ return inputs.map((input) => resolve(packageRoot, input))
322
+ }
323
+
324
+ /** What `--out` copies from the package beside the built `dist/` and `legion/`: the plugin's
325
+ * manifests, its MCP and hook configuration, and the shim and skills they reach. */
326
+ const PLUGIN_SOURCES = [
327
+ ".claude-plugin",
328
+ ".mcp.json",
329
+ ".omp-plugin",
330
+ "bin",
331
+ "hooks",
332
+ "package.json",
333
+ "skills",
334
+ ] as const
335
+
336
+ /** `path`, absolute, with the symlinks of its deepest existing ancestor resolved and the missing
337
+ * rest joined on, so a directory `--out` has yet to create compares like one that exists. */
338
+ async function resolvedPath(path: string): Promise<string> {
339
+ let existing = resolve(path)
340
+ const missing: string[] = []
341
+ for (;;) {
342
+ try {
343
+ return join(await realpath(existing), ...missing)
344
+ } catch (error) {
345
+ const parent = dirname(existing)
346
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT" || parent === existing) throw error
347
+ missing.unshift(basename(existing))
348
+ existing = parent
349
+ }
350
+ }
351
+ }
352
+
353
+ /** Why `--out` may not write `out`, or null when it may: inside the repository its files would be
354
+ * ones a pull request could commit, and an existing file there would be mixed with the build. */
355
+ async function outRefusal(out: string): Promise<string | null> {
356
+ const [target, root] = await Promise.all([resolvedPath(out), realpath(repoRoot)])
357
+ const fromRoot = relative(root, target)
358
+ if (fromRoot !== ".." && !fromRoot.startsWith(`..${sep}`) && !isAbsolute(fromRoot)) {
359
+ return `${out} is inside the repository (${root}); a pull request never commits a build`
360
+ }
361
+ try {
362
+ if ((await readdir(target)).length > 0) return `${out} exists and is not empty`
363
+ } catch (error) {
364
+ const code = (error as NodeJS.ErrnoException).code
365
+ if (code === "ENOTDIR") return `${out} exists and is not a directory`
366
+ if (code !== "ENOENT") throw error
367
+ }
368
+ return null
369
+ }
370
+
371
+ /** `out` as a runnable plugin directory: the package's plugin files, with each `skills/<name>`
372
+ * link to the repository's skill copied as the files it points at (file modes kept, so
373
+ * `bin/dispatch` stays executable), then a fresh `dist/` and `legion/`. */
374
+ async function buildOut(out: string): Promise<void> {
375
+ await mkdir(out, { recursive: true })
376
+ for (const source of PLUGIN_SOURCES) {
377
+ await cp(join(packageRoot, source), join(out, source), { recursive: true, dereference: true })
378
+ }
379
+ await buildBundles(join(out, "dist"))
380
+ await buildLegionPlugin(join(out, "legion"))
381
+ }
382
+
383
+ const USAGE =
384
+ "usage: bun scripts/build.ts --out <dir>. dist/ and legion/ are generated, never committed: " +
385
+ "release.yaml's claude_envoy_build builds them and packs them into the tarball it publishes " +
386
+ "to npm."
387
+
388
+ type Mode = { readonly kind: "out"; readonly dir: string }
389
+
390
+ /** The one mode `args` names, or null for none, more than one, an unknown argument, or `--out`
391
+ * with no directory after it. */
392
+ function parseMode(args: readonly string[]): Mode | null {
393
+ const [first, second, ...rest] = args
394
+ if (rest.length > 0 || second === undefined) return null
395
+ if (first === "--out" && !second.startsWith("--")) return { kind: "out", dir: second }
396
+ return null
397
+ }
398
+
399
+ if (import.meta.main) {
400
+ // Checked before anything else touches the filesystem: --out's copy costs real work for a
401
+ // build this process was always going to refuse.
402
+ assertPinnedBun(Bun.version, pinnedBunVersion)
403
+ const mode = parseMode(process.argv.slice(2))
404
+ if (mode === null) {
405
+ process.stderr.write(`${USAGE}\n`)
406
+ process.exit(2)
407
+ }
408
+ const refusal = await outRefusal(mode.dir)
409
+ if (refusal !== null) {
410
+ process.stderr.write(`refusing --out: ${refusal}\n`)
411
+ process.exit(1)
412
+ }
413
+ await buildOut(mode.dir)
414
+ process.stdout.write(`built the Claude Envoy plugin into ${resolve(mode.dir)}\n`)
415
+ }
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env bash
2
+ # check-packed-skills.sh <tgz> — the packed plugin carries its skills as real files.
3
+ #
4
+ # release.yaml's claude_envoy_build runs this over the tarball it hands the publish job, beside
5
+ # the manifest checks of the same step, and tests/packed-skills.test.ts runs it over tarballs it
6
+ # packs itself. What it refuses is what the first tarball of this package held: no
7
+ # package/skills member at all, because skills/<name> is a link into the repository's skills/ and
8
+ # `bun pm pack` packs a link as nothing (scripts/pack-skills.sh, the prepack that resolves them).
9
+ # An install of such a tarball loses every skill, and the dispatch-first hook hooks/hooks.json
10
+ # runs on every session and subagent throws on the SKILL.md it reads under the plugin root.
11
+ #
12
+ # Three rules over the tarball's own listing, and nothing read off the checkout:
13
+ # 1. package/skills/dispatch-first/SKILL.md is there, the file that hook reads;
14
+ # 2. no member under package/skills is a link, which an install would leave dangling;
15
+ # 3. every package/skills/<name> holds a SKILL.md, so a half-staged pack is refused too.
16
+ set -euo pipefail
17
+
18
+ [ $# -eq 1 ] || {
19
+ echo "::error::check-packed-skills.sh: usage: check-packed-skills.sh <tgz>" >&2
20
+ exit 2
21
+ }
22
+ tar_file=$1
23
+
24
+ refuse() {
25
+ echo "::error::$*" >&2
26
+ exit 1
27
+ }
28
+
29
+ # `tar -tvzf` so the listing carries each member's type: a link line starts with 'l' (symbolic) or
30
+ # 'h' (hard), where a regular file's starts with '-'.
31
+ listing=$(tar -tvzf "$tar_file")
32
+ members=$(tar -tzf "$tar_file")
33
+
34
+ if ! printf '%s\n' "$members" | grep -qxF package/skills/dispatch-first/SKILL.md; then
35
+ refuse "$tar_file holds no package/skills/dispatch-first/SKILL.md, which the dispatch-first hook reads on every session: pack it with the prepack that resolves skills/<name> (scripts/pack-skills.sh)"
36
+ fi
37
+
38
+ links=$(printf '%s\n' "$listing" | grep -E '^[lh].* package/skills/' || true)
39
+ if [ -n "$links" ]; then
40
+ refuse "$tar_file packs a link under package/skills, which an install would leave dangling: ${links//$'\n'/; }"
41
+ fi
42
+
43
+ names=$(printf '%s\n' "$members" | sed -n 's|^package/skills/\([^/]*\)/.*|\1|p' | sort -u)
44
+ while IFS= read -r name; do
45
+ if ! printf '%s\n' "$members" | grep -qxF "package/skills/$name/SKILL.md"; then
46
+ refuse "$tar_file packs package/skills/$name with no SKILL.md, so the skill it ships is incomplete"
47
+ fi
48
+ done <<< "$names"
49
+
50
+ echo "check-packed-skills.sh: $tar_file carries $(printf '%s\n' "$names" | wc -l | tr -d ' ') skills as real files"
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env bash
2
+ # The `prepack` and `postpack` of @sjawhar/claude-envoy, and the one place that makes the packed
3
+ # tarball carry the plugin's skills.
4
+ #
5
+ # skills/<name> is a symlink into the repository's skills/, and those eight links are the
6
+ # statement of which skills this plugin ships. `bun pm pack` packs a link as nothing at all —
7
+ # measured on bun 1.4.4: with `skills/**` in `files` and the links in place, the tarball held no
8
+ # package/skills member whatever — so an install got no skill, and the SessionStart and
9
+ # SubagentStart hook hooks/hooks.json runs on every session, `session-hook.js dispatch-first`,
10
+ # threw on the SKILL.md it reads under the plugin root. prepack replaces each link with the
11
+ # directory it points at, which `files` then packs as real files; postpack puts the links back, so
12
+ # the checkout is as the pack found it.
13
+ #
14
+ # This is what the two Oh My Pi plugins do (scripts/pi-plugin-prepack.sh, their prepack, stages
15
+ # each one's skills into dist/skills and their postpack removes them again). The difference is
16
+ # that this package's links are tracked files, not a staging directory, so postpack restores them
17
+ # rather than deleting them, and the list of skills is read off the links rather than written out
18
+ # a second time here.
19
+ #
20
+ # Where no postpack runs — bun runs none when a pack fails — `--restore` puts the links back.
21
+ #
22
+ # npm and bun both run these with the package root as the working directory, as that script's
23
+ # header says, so the package is $PWD and the repository $PWD/../..:
24
+ #
25
+ # scripts/pack-skills.sh as prepack: each skills/<name> link -> the skill's files
26
+ # scripts/pack-skills.sh --restore as postpack: each skills/<name> directory -> the link
27
+ set -euo pipefail
28
+
29
+ me=pack-skills
30
+
31
+ refuse() {
32
+ echo "$me: $*" >&2
33
+ exit 1
34
+ }
35
+
36
+ # link_back <name>...: make each skills/<name> the tracked link again, whatever is there now.
37
+ # Each link is rebuilt from the name alone, so a restore needs nothing a prepack left behind and
38
+ # a postpack that runs without a prepack writes the same links the checkout already holds.
39
+ #
40
+ # It rm -rf's each entry and judges none, so only a caller that has already judged them may call
41
+ # it: the swap loop's EXIT trap below, which puts back the links that loop itself replaced — the
42
+ # half-written cp -R among them, which a check here would keep and leave the working copy broken
43
+ # — and --restore, which judges every entry before it calls this at all.
44
+ link_back() {
45
+ local name
46
+ for name in "$@"; do
47
+ rm -rf "skills/$name"
48
+ ln -s "../../../skills/$name" "skills/$name"
49
+ done
50
+ }
51
+
52
+ # staged_copy <name>: true where skills/<name> is the repository's own skill copied into place,
53
+ # which a pack staged and which holds nothing of its own, so replacing it deletes no work. The
54
+ # directory under $repo_skills must be there for the comparison to say anything, so a missing one
55
+ # is refused for what it is rather than left to `diff`, which exits 2 over it as it does over a
56
+ # difference.
57
+ staged_copy() {
58
+ [ -d "$repo_skills/$1" ] || refuse "skills/$1 names no directory under $repo_skills"
59
+ diff -r -q "$repo_skills/$1" "skills/$1" > /dev/null 2>&1
60
+ }
61
+
62
+ restore=false
63
+ if [ "${1:-}" = --restore ]; then
64
+ restore=true
65
+ shift
66
+ fi
67
+ [ $# -eq 0 ] || refuse "usage: $me [--restore], from the package root, as its prepack and postpack"
68
+
69
+ [ -f package.json ] && [ -d skills ] || refuse \
70
+ "no package.json or no skills/ in $PWD; run from the package root, as npm and bun run prepack"
71
+ repo_skills=$(cd ../../skills && pwd -P)
72
+
73
+ shopt -s nullglob
74
+ entries=(skills/*)
75
+ [ ${#entries[@]} -gt 0 ] || refuse "skills/ is empty; the links in it are what this package ships"
76
+ names=("${entries[@]#skills/}")
77
+
78
+ if $restore; then
79
+ # link_back rm -rf's every entry, so every entry is judged here, before the first deletion: a
80
+ # directory a developer wrote themselves is their own work, which this script deletes none of,
81
+ # and a refusal partway through the rebuild would have deleted the entries before it already.
82
+ # A link is safe whatever it points at, since it holds nothing of its own.
83
+ own=()
84
+ for name in "${names[@]}"; do
85
+ [ -L "skills/$name" ] || staged_copy "$name" || own+=("$name")
86
+ done
87
+ if [ ${#own[@]} -gt 0 ]; then
88
+ for name in "${own[@]}"; do
89
+ echo "$me: skills/$name is neither a link nor a staged copy of $repo_skills/$name, so it holds work of its own: move it out of skills/ and then run 'ln -s ../../../skills/$name skills/$name', which writes its tracked link back" >&2
90
+ done
91
+ refuse \
92
+ "--restore writes the tracked link over every entry of skills/, so it would delete the work named above and has written none of them: a name moved out of skills/ is out of the list this script reads, which is why each one above names the link to write by hand; then bash scripts/pack-skills.sh --restore puts back the entries that are left"
93
+ fi
94
+ link_back "${names[@]}"
95
+ exit 0
96
+ fi
97
+
98
+ # Every entry's conditions, all of them before the first swap below: a refusal on a later entry
99
+ # would otherwise leave the entries before it staged as copied directories. Every entry that is
100
+ # no link is classified here too, and only then refused, because --restore rebuilds every entry:
101
+ # it is advice only where each of them is a staged copy of the repository's skill and holds
102
+ # nothing of its own. A directory a developer wrote themselves is their own work, which --restore
103
+ # would delete, so one of those anywhere in the list is refused on its own terms and the refusal
104
+ # names no --restore at all.
105
+ staged=()
106
+ own=()
107
+ for name in "${names[@]}"; do
108
+ if [ ! -L "skills/$name" ]; then
109
+ if staged_copy "$name"; then
110
+ staged+=("$name")
111
+ else
112
+ own+=("$name")
113
+ fi
114
+ continue
115
+ fi
116
+ target=$(readlink "skills/$name")
117
+ [ "$target" = "../../../skills/$name" ] || refuse \
118
+ "skills/$name points at $target, not ../../../skills/$name: this package ships the repository's skills"
119
+ [ -d "$repo_skills/$name" ] || refuse "skills/$name names no directory under $repo_skills"
120
+ done
121
+ if [ ${#own[@]} -gt 0 ]; then
122
+ for name in "${own[@]}"; do
123
+ echo "$me: skills/$name is neither a link nor a staged copy of $repo_skills/$name: it must be the link ../../../skills/$name, which is how this package names the skills it ships" >&2
124
+ done
125
+ exit 1
126
+ fi
127
+ if [ ${#staged[@]} -gt 0 ]; then
128
+ for name in "${staged[@]}"; do
129
+ echo "$me: skills/$name is a staged copy of $repo_skills/$name rather than a link: an earlier pack staged it and its postpack never put the link back" >&2
130
+ done
131
+ refuse "bash scripts/pack-skills.sh --restore puts those links back"
132
+ fi
133
+
134
+ # The swap, under a trap that puts back every link it has replaced. What is left to fail here is
135
+ # the filesystem rather than an entry, and a failure between the first swap and the last would
136
+ # leave tracked links as copied directories. The trap is disarmed the moment the loop finishes,
137
+ # and nowhere earlier: the pack this prepack runs for needs the real directories, and the
138
+ # postpack puts the links back after it.
139
+ swapped=()
140
+ trap '[ ${#swapped[@]} -eq 0 ] || link_back "${swapped[@]}"' EXIT
141
+ for name in "${names[@]}"; do
142
+ rm "skills/$name"
143
+ swapped+=("$name")
144
+ cp -R "$repo_skills/$name" "skills/$name"
145
+ done
146
+ trap - EXIT