@sjawhar/claude-envoy 0.0.0 → 1.0.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.
- package/.claude-plugin/plugin.json +6 -0
- package/.mcp.json +11 -0
- package/.omp-plugin/plugin.json +6 -0
- package/AGENTS.md +343 -0
- package/README.md +639 -0
- package/bin/dispatch +6 -0
- package/bin/envoy-channel.ts +9 -0
- package/bin/envoy-send.ts +33 -0
- package/dist/THIRD_PARTY_NOTICES +816 -0
- package/dist/dispatch.js +27707 -0
- package/dist/envoy-channel.js +57695 -0
- package/dist/session-hook.js +26328 -0
- package/dist/session-title-hook.js +116 -0
- package/hooks/hooks.json +60 -0
- package/hooks/session-hook.ts +512 -0
- package/hooks/session-title-hook.ts +46 -0
- package/legion/.claude-plugin/plugin.json +4 -0
- package/legion/agents/deep-worker.md +68 -0
- package/legion/agents/oracle.md +35 -0
- package/legion/agents/plan-gap-analyst.md +65 -0
- package/legion/agents/plan-reviewer.md +104 -0
- package/legion/agents/thermonuclear-code-quality.md +30 -0
- package/legion/agents/thermonuclear-deep-review.md +30 -0
- package/legion/hooks/hooks.json +89 -0
- package/legion/skills/ce-simplify-code/LICENSE +21 -0
- package/legion/skills/ce-simplify-code/SKILL.md +66 -0
- package/legion/skills/ce-simplify-code/references/personas/code-quality-reviewer.md +17 -0
- package/legion/skills/ce-simplify-code/references/personas/code-reuse-reviewer.md +7 -0
- package/legion/skills/ce-simplify-code/references/personas/efficiency-reviewer.md +11 -0
- package/legion/skills/leading-a-work-stream/SKILL.md +96 -0
- package/legion/skills/leading-a-work-stream/references/daily-plan.md +8 -0
- package/legion/skills/leading-a-work-stream/references/decision-rights.md +6 -0
- package/legion/skills/leading-a-work-stream/references/direction-and-coaching.md +13 -0
- package/legion/skills/leading-a-work-stream/references/dispatch-upkeep.md +8 -0
- package/legion/skills/leading-a-work-stream/references/sources.md +9 -0
- package/legion/skills/leading-a-work-stream/references/speed-tick.md +44 -0
- package/legion/skills/leading-a-work-stream/references/user-quotes.md +7 -0
- package/legion/skills/legion-architect/SKILL.md +86 -0
- package/legion/skills/legion-architect/references/acceptance.md +208 -0
- package/legion/skills/legion-architect/references/decomposition.md +129 -0
- package/legion/skills/legion-architect/references/design-gate.md +206 -0
- package/legion/skills/legion-architect/references/merge-notices.md +91 -0
- package/legion/skills/legion-architect/references/wake-routing.md +41 -0
- package/legion/skills/legion-controller/SKILL.md +151 -0
- package/legion/skills/legion-controller/references/admission.md +22 -0
- package/legion/skills/legion-controller/references/backlog-stewardship.md +101 -0
- package/legion/skills/legion-controller/references/daily-report.md +75 -0
- package/legion/skills/legion-controller/references/escalation.md +82 -0
- package/legion/skills/legion-controller/references/finding-asks.md +181 -0
- package/legion/skills/legion-controller/references/parked-decisions.md +198 -0
- package/legion/skills/legion-controller/references/session.md +100 -0
- package/legion/skills/legion-controller/references/slack-mentions.md +154 -0
- package/legion/skills/legion-controller/references/triage.md +96 -0
- package/legion/skills/legion-controller/references/wakes.md +132 -0
- package/legion/skills/legion-oracle/SKILL.md +48 -0
- package/legion/skills/legion-retro/SKILL.md +112 -0
- package/legion/skills/legion-retro/references/learnings.md +60 -0
- package/legion/skills/legion-retro/references/pr-body-content.md +56 -0
- package/legion/skills/legion-worker/SKILL.md +69 -0
- package/legion/skills/legion-worker/references/cleanup-deletion.md +22 -0
- package/legion/skills/legion-worker/references/conflicts-and-rewrites.md +164 -0
- package/legion/skills/legion-worker/references/default-branch-check.md +53 -0
- package/legion/skills/legion-worker/references/findings.md +199 -0
- package/legion/skills/legion-worker/references/merge-gate.md +353 -0
- package/legion/skills/legion-worker/references/pr-body.md +218 -0
- package/legion/skills/legion-worker/references/review-threads.md +77 -0
- package/legion/skills/legion-worker/references/systematic-rename.md +19 -0
- package/legion/skills/legion-worker/references/tools.md +708 -0
- package/legion/skills/thermonuclear-code-quality/LICENSE +21 -0
- package/legion/skills/thermonuclear-code-quality/SKILL.md +60 -0
- package/legion/skills/thermonuclear-deep-review/LICENSE +21 -0
- package/legion/skills/thermonuclear-deep-review/SKILL.md +80 -0
- package/package.json +49 -1
- package/scripts/build-inputs.txt +84 -0
- package/scripts/build.ts +415 -0
- package/scripts/check-packed-skills.sh +50 -0
- package/scripts/pack-skills.sh +146 -0
- package/scripts/smoke-channel.sh +178 -0
- package/scripts/smoke-clear-rebind.sh +164 -0
- package/scripts/smoke-session-title.sh +245 -0
- package/scripts/verify-build.sh +88 -0
- package/skills/agent-secrets/SKILL.md +180 -0
- package/skills/asd-ste100/LICENSE +21 -0
- package/skills/asd-ste100/SKILL.md +140 -0
- package/skills/asd-ste100/examples/before-after.md +92 -0
- package/skills/asd-ste100/references/writing-rules.md +59 -0
- package/skills/dispatch/SKILL.md +89 -0
- package/skills/dispatch-first/SKILL.md +46 -0
- package/skills/dispatch-writing-specs/SKILL.md +80 -0
- package/skills/envoy/SKILL.md +82 -0
- package/skills/envoy/references/check-settlement.md +62 -0
- package/skills/envoy/references/deliveries.md +67 -0
- package/skills/envoy/references/legion.md +14 -0
- package/skills/envoy/references/roles.md +12 -0
- package/skills/envoy/references/sources.md +41 -0
- package/skills/outside-message/SKILL.md +111 -0
- package/skills/plain-words/SKILL.md +88 -0
- package/src/channel-forwarder.ts +262 -0
- package/src/claude-channel.ts +705 -0
- package/src/claude-session.ts +38 -0
- package/src/envoy-channel-server.ts +2115 -0
- package/src/envoy-client.ts +26 -0
- package/src/envoy-off.ts +62 -0
- package/src/legion-claim.ts +211 -0
- package/src/legion-inbox.ts +110 -0
- package/src/legion-phase-hook.ts +554 -0
- package/src/legion-tool.ts +69 -0
- package/src/replay-state.ts +97 -0
- package/src/send-arguments.ts +13 -0
- package/src/session-identity.ts +165 -0
- package/src/session-state.ts +142 -0
- package/src/session-title.ts +251 -0
- package/src/subscription-topics.ts +16 -0
package/scripts/build.ts
ADDED
|
@@ -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
|