Skip to content

Commit 9d59544

Browse files
Merge PR #4: R6 — reconcile dev↔main, ship Zig binaries, retire JS
feat(zig): R6 — reconcile dev↔main, ship Zig binaries, retire independent JS
2 parents b8c89fa + ab54c33 commit 9d59544

21 files changed

Lines changed: 964 additions & 618 deletions
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
name: Release binaries
2+
3+
# On a version tag (v*), cross-compile the ponytail Zig runtime binaries for
4+
# every supported platform, generate SHA-256 checksums, and attach them to the
5+
# GitHub Release. install.sh downloads the right per-platform archive and
6+
# SHA-256-verifies it (R6.2) — no Node, no Zig toolchain required by end users.
7+
#
8+
# Mirrors caveman's release-binaries.yml, minus the caveman/ponytail tool matrix
9+
# (ponytail's build only produces the `ponytail` tool) and minus the `*-claw`
10+
# exclude (ponytail has no claw test binary).
11+
#
12+
# Windows is deferred (R6-Windows: stdio/subprocess/argv libc → std.Io). The PS1
13+
# shim covers Windows until then, so this workflow ships macOS + Linux only.
14+
15+
on:
16+
push:
17+
tags: ["v*"]
18+
workflow_dispatch:
19+
inputs:
20+
tag:
21+
description: "Tag to build (for manual re-runs), e.g. v1.2.3"
22+
required: true
23+
24+
# No workflow-level write scope — each job requests only the permissions it needs
25+
# (least privilege). The build and checksums jobs both opt into contents: write
26+
# locally because they attach assets to the Release.
27+
permissions:
28+
contents: read
29+
30+
jobs:
31+
build:
32+
runs-on: [self-hosted, linux]
33+
permissions:
34+
contents: write # this job attaches assets to the Release
35+
strategy:
36+
fail-fast: false
37+
matrix:
38+
target:
39+
- { triple: aarch64-macos, name: macos-arm64 }
40+
- { triple: x86_64-macos, name: macos-x64 }
41+
- { triple: x86_64-linux-gnu, name: linux-x64 }
42+
- { triple: aarch64-linux-gnu, name: linux-arm64 }
43+
steps:
44+
- uses: actions/checkout@v4
45+
46+
# Resolve + validate the tag once, before any use. A manual workflow_dispatch
47+
# `tag` input is attacker-influenced free text that flows into shell commands
48+
# and the gh CLI; constrain it to the v<semver> shape this workflow ships so a
49+
# crafted value (spaces, $(...), ;, ../) can't smuggle anything downstream.
50+
- name: Resolve and validate release tag
51+
id: tag
52+
env:
53+
RAW_TAG: ${{ github.event.inputs.tag || github.ref_name }}
54+
run: |
55+
set -euo pipefail
56+
if [[ ! "$RAW_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$ ]]; then
57+
echo "::error::Refusing to build: tag '$RAW_TAG' is not a v<semver> tag" >&2
58+
exit 1
59+
fi
60+
echo "tag=$RAW_TAG" >> "$GITHUB_OUTPUT"
61+
62+
- name: Cross-compile ponytail for ${{ matrix.target.triple }}
63+
run: |
64+
nix develop --command bash -c "cd zig && zig build -Dtool=ponytail -Dtarget=${{ matrix.target.triple }} -Doptimize=ReleaseSafe"
65+
66+
- name: Package archive + checksum
67+
id: pkg
68+
env:
69+
PLAT: ${{ matrix.target.name }}
70+
run: |
71+
set -euo pipefail
72+
stage="dist/ponytail-$PLAT"
73+
mkdir -p "$stage"
74+
75+
# The full set of runtime binaries this release must ship. If the build
76+
# silently dropped one (e.g. a build.zig regression), packaging an
77+
# incomplete archive would ship a broken release that install.sh / the
78+
# launcher can't resolve — so assert every expected binary is present
79+
# BEFORE we tar anything.
80+
expected=(ponytail-activate ponytail-hook ponytail-instructions ponytail-mcp ponytail-statusline)
81+
for name in "${expected[@]}"; do
82+
if [[ ! -x "zig/zig-out/bin/$name" ]]; then
83+
echo "::error::Missing expected runtime binary: zig/zig-out/bin/$name" >&2
84+
exit 1
85+
fi
86+
done
87+
88+
# Ship the runtime binaries only; defensively skip any .pdb.
89+
for f in zig/zig-out/bin/ponytail-*; do
90+
base="$(basename "$f")"
91+
case "$base" in *.pdb) continue;; esac
92+
cp "$f" "$stage/"
93+
done
94+
95+
archive="ponytail-$PLAT.tar.gz"
96+
tar -C "$stage" -czf "dist/$archive" .
97+
( cd dist && sha256sum "$archive" > "$archive.sha256" )
98+
echo "archive=dist/$archive" >> "$GITHUB_OUTPUT"
99+
echo "checksum=dist/$archive.sha256" >> "$GITHUB_OUTPUT"
100+
101+
- name: Upload to Release
102+
uses: softprops/action-gh-release@v2
103+
with:
104+
tag_name: ${{ steps.tag.outputs.tag }}
105+
files: |
106+
${{ steps.pkg.outputs.archive }}
107+
${{ steps.pkg.outputs.checksum }}
108+
fail_on_unmatched_files: true
109+
110+
# Aggregate a single SHA256SUMS manifest across all archives (install.sh reads
111+
# the per-archive .sha256 sidecar; this manifest is for humans + auditing).
112+
checksums:
113+
needs: build
114+
runs-on: [self-hosted, linux]
115+
permissions:
116+
contents: write # this job uploads the aggregate manifest to the Release
117+
steps:
118+
- uses: actions/checkout@v4
119+
120+
- name: Resolve and validate release tag
121+
id: tag
122+
env:
123+
RAW_TAG: ${{ github.event.inputs.tag || github.ref_name }}
124+
run: |
125+
set -euo pipefail
126+
if [[ ! "$RAW_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$ ]]; then
127+
echo "::error::Refusing to publish: tag '$RAW_TAG' is not a v<semver> tag" >&2
128+
exit 1
129+
fi
130+
echo "tag=$RAW_TAG" >> "$GITHUB_OUTPUT"
131+
132+
- name: Collect per-archive checksums into SHA256SUMS
133+
env:
134+
TAG: ${{ steps.tag.outputs.tag }}
135+
GH_TOKEN: ${{ github.token }}
136+
REPO: ${{ github.repository }}
137+
run: |
138+
set -euo pipefail
139+
mkdir -p dist
140+
gh release download "$TAG" --pattern '*.sha256' --dir dist --repo "$REPO"
141+
142+
# Refuse to publish an empty manifest: if no per-archive .sha256 sidecars
143+
# were downloaded, the build job produced nothing and uploading an empty
144+
# SHA256SUMS would advertise a release with no verifiable artifacts.
145+
shopt -s nullglob
146+
sidecars=(dist/*.sha256)
147+
if (( ${#sidecars[@]} == 0 )); then
148+
echo "::error::No per-archive .sha256 sidecars found for $TAG — refusing to publish an empty SHA256SUMS" >&2
149+
exit 1
150+
fi
151+
152+
cat "${sidecars[@]}" > dist/SHA256SUMS
153+
if [[ ! -s dist/SHA256SUMS ]]; then
154+
echo "::error::SHA256SUMS is empty after aggregation — refusing to publish" >&2
155+
exit 1
156+
fi
157+
158+
gh release upload "$TAG" dist/SHA256SUMS --clobber --repo "$REPO"

‎benchmarks/robustness-audit.js‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,17 @@ const fs = require('fs');
88
const os = require('os');
99
const path = require('path');
1010

11+
// ponytail: probe once at load; mirrors correctness.js
12+
let pythonCmd;
13+
function python() {
14+
if (pythonCmd) return pythonCmd;
15+
for (const cmd of ['python3', 'python']) {
16+
try { execSync(`${cmd} -c "import sys"`, { stdio: 'pipe' }); pythonCmd = cmd; return pythonCmd; }
17+
catch (_) {}
18+
}
19+
return pythonCmd = 'python3';
20+
}
21+
1122
const N = Number(process.env.AUDIT_N) || 20;
1223
const MODEL = process.env.AUDIT_MODEL || 'gpt-5.4-mini';
1324
const ROOT = path.join(__dirname, '..');
@@ -136,7 +147,7 @@ for args, expected in cases:
136147
print('PASS')`;
137148
const f = path.join(os.tmpdir(), `audit-${process.pid}-${Math.random().toString(36).slice(2)}.py`);
138149
fs.writeFileSync(f, harness);
139-
try { execSync(`python3 "${f}"`, { timeout: 10000, encoding: 'utf8', stdio: 'pipe' }); return true; }
150+
try { execSync(`${python()} "${f}"`, { timeout: 10000, encoding: 'utf8', stdio: 'pipe' }); return true; }
140151
catch (e) { return false; }
141152
finally { try { fs.unlinkSync(f); } catch (_) {} }
142153
}

‎bin/ponytail-launch‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
#!/usr/bin/env bash
2+
# ponytail-launch — resolve a ponytail Zig binary and exec it (passing stdin + args).
3+
#
4+
# Used by the Claude Code plugin (.claude-plugin/plugin.json) so the plugin can run
5+
# the native Zig hooks without bundling per-platform blobs: it finds an already-
6+
# installed binary, or a local build, or downloads+verifies the per-platform release
7+
# archive on first use (cached under $PONYTAIL_CACHE), then execs the requested binary.
8+
#
9+
# Usage: ponytail-launch <binary-name> [args...] (stdin is forwarded)
10+
# e.g. ponytail-launch ponytail-activate
11+
# ponytail-launch ponytail-hook
12+
#
13+
# Resolution order:
14+
# 1. $CLAUDE_CONFIG_DIR/hooks/<name> (standalone install dropped it here)
15+
# 2. <plugin-root>/zig/zig-out/bin/<name> (local clone / dev build)
16+
# 3. $PONYTAIL_CACHE/<name> (previously downloaded)
17+
# 4. download ponytail-<plat>.tar.gz from the latest Release → SHA-256 verify →
18+
# extract into $PONYTAIL_CACHE → use it
19+
#
20+
# Pure-Zig runtime: this launcher is host-automation shell, not application logic.
21+
set -uo pipefail
22+
23+
REPO="EugOT/ponytail"
24+
NAME="${1:-}"
25+
[ -n "$NAME" ] || { echo "ponytail-launch: missing binary name" >&2; exit 2; }
26+
shift || true
27+
28+
here="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd)" || here=""
29+
CACHE="${PONYTAIL_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/ponytail/bin}"
30+
31+
# exec the binary at $1 with all remaining launcher args ($@, already shifted past
32+
# the binary name) if it exists and is executable; otherwise return so the next
33+
# resolution step runs. stdin is inherited by exec, so hooks still read it.
34+
try_exec() { local b="$1"; shift; [ -x "$b" ] && exec "$b" "$@"; }
35+
36+
# 1. standalone-installed hooks dir
37+
CFG="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
38+
try_exec "$CFG/hooks/$NAME" "$@"
39+
40+
# 2. local clone / dev build (launcher lives at bin/, zig/ is a sibling at repo root)
41+
[ -n "$here" ] && try_exec "$here/../zig/zig-out/bin/$NAME" "$@"
42+
43+
# 3. previously downloaded
44+
try_exec "$CACHE/$NAME" "$@"
45+
46+
# 4. download + verify + cache
47+
detect_platform() {
48+
local os arch; os="$(uname -s)"; arch="$(uname -m)"
49+
case "$os" in Darwin) os=macos ;; Linux) os=linux ;; *) echo "ponytail-launch: unsupported OS $os (Windows uses the PowerShell shim)" >&2; exit 1 ;; esac
50+
case "$arch" in arm64|aarch64) arch=arm64 ;; x86_64|amd64) arch=x64 ;; *) echo "ponytail-launch: unsupported arch $arch" >&2; exit 1 ;; esac
51+
printf '%s-%s' "$os" "$arch"
52+
}
53+
sha256_of() {
54+
if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}'
55+
elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}'
56+
else echo "ponytail-launch: no sha256 tool" >&2; exit 1; fi
57+
}
58+
59+
command -v curl >/dev/null 2>&1 || { echo "ponytail-launch: curl required to fetch binaries" >&2; exit 1; }
60+
plat="$(detect_platform)"
61+
archive="ponytail-$plat.tar.gz"
62+
base="https://github.com/$REPO/releases/latest/download"
63+
tmp="$(mktemp -d "${TMPDIR:-/tmp}/ponytail-launch.XXXXXX")"
64+
trap 'rm -rf "$tmp"' EXIT
65+
66+
curl -fsSL "$base/$archive" -o "$tmp/$archive" || { echo "ponytail-launch: download failed ($archive)" >&2; exit 1; }
67+
curl -fsSL "$base/$archive.sha256" -o "$tmp/$archive.sha256" || { echo "ponytail-launch: checksum download failed" >&2; exit 1; }
68+
want="$(awk '{print $1}' "$tmp/$archive.sha256")"; got="$(sha256_of "$tmp/$archive")"
69+
[ "$want" = "$got" ] || { echo "ponytail-launch: SHA-256 mismatch — refusing" >&2; exit 1; }
70+
71+
mkdir -p "$CACHE"
72+
tar -C "$CACHE" -xzf "$tmp/$archive" || { echo "ponytail-launch: extract failed" >&2; exit 1; }
73+
chmod +x "$CACHE"/ponytail-* 2>/dev/null || true
74+
try_exec "$CACHE/$NAME" "$@"
75+
76+
echo "ponytail-launch: could not resolve '$NAME' (not in archive?)" >&2
77+
exit 1

‎bin/ponytail-launch.ps1‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# ponytail-launch.ps1 — Windows/PowerShell sibling of bin/ponytail-launch.
2+
#
3+
# The Claude Code plugin (.claude-plugin/plugin.json → hooks/claude-codex-hooks.json)
4+
# runs the lifecycle hooks through a launcher so it never bundles per-platform
5+
# blobs. On POSIX that launcher is the bash bin/ponytail-launch; PowerShell can't
6+
# run a bash script, so this is the Windows entry point the `commandWindows` hook
7+
# field invokes. It resolves an already-installed ponytail-* binary (standalone
8+
# install → local dev build → cache), forwarding stdin and args.
9+
#
10+
# Usage: ponytail-launch.ps1 <binary-name> [args...] (stdin is forwarded)
11+
# e.g. ponytail-launch.ps1 ponytail-activate
12+
# ponytail-launch.ps1 ponytail-hook
13+
#
14+
# Resolution order (mirrors the bash launcher):
15+
# 1. $env:CLAUDE_CONFIG_DIR\hooks\<name>(.exe) (standalone install)
16+
# 2. <repo-root>\zig\zig-out\bin\<name>(.exe) (local clone / dev build)
17+
# 3. $PONYTAIL_CACHE\<name>(.exe) (previously downloaded)
18+
#
19+
# Windows native Zig binaries are still pending (R6-Windows): the cross-compile in
20+
# .github/workflows/release-binaries.yml ships macOS + Linux only. Until a Windows
21+
# archive ships there is no step-4 download here — if no binary resolves, this exits
22+
# 0 (silent no-op) so a missing Windows binary never blocks SessionStart / prompt
23+
# submission, exactly like the POSIX hooks' silent-fail contract.
24+
25+
[CmdletBinding()]
26+
param(
27+
[Parameter(Mandatory = $true, Position = 0)]
28+
[string]$Name,
29+
[Parameter(ValueFromRemainingArguments = $true)]
30+
[string[]]$Rest
31+
)
32+
33+
$ErrorActionPreference = "Stop"
34+
35+
# Try to run $exe (with the .exe sibling on Windows) forwarding stdin + $Rest. On
36+
# success the script exits with the child's exit code; otherwise returns to let the
37+
# next resolution step run.
38+
function Try-Run([string]$exe) {
39+
foreach ($cand in @($exe, "$exe.exe")) {
40+
if (Test-Path -LiteralPath $cand -PathType Leaf) {
41+
& $cand @Rest
42+
exit $LASTEXITCODE
43+
}
44+
}
45+
}
46+
47+
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
48+
49+
# 1. standalone-installed hooks dir
50+
$cfg = if ($env:CLAUDE_CONFIG_DIR) { $env:CLAUDE_CONFIG_DIR } else { Join-Path $HOME ".claude" }
51+
Try-Run (Join-Path (Join-Path $cfg "hooks") $Name)
52+
53+
# 2. local clone / dev build (launcher lives at bin\, zig\ is a sibling at repo root)
54+
Try-Run (Join-Path (Join-Path (Join-Path (Join-Path (Split-Path -Parent $here) "zig") "zig-out") "bin") $Name)
55+
56+
# 3. previously downloaded cache
57+
$cache = if ($env:PONYTAIL_CACHE) {
58+
$env:PONYTAIL_CACHE
59+
} elseif ($env:XDG_CACHE_HOME) {
60+
Join-Path (Join-Path $env:XDG_CACHE_HOME "ponytail") "bin"
61+
} else {
62+
Join-Path (Join-Path (Join-Path $HOME ".cache") "ponytail") "bin"
63+
}
64+
Try-Run (Join-Path $cache $Name)
65+
66+
# No Windows binary yet (R6-Windows). Silent no-op so the hook never blocks.
67+
exit 0

‎hooks/claude-codex-hooks.json‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
66
"hooks": [
77
{
88
"type": "command",
9-
"command": "command -v node >/dev/null 2>&1 && node \"${CLAUDE_PLUGIN_ROOT}/hooks/ponytail-activate.js\" || exit 0",
10-
"commandWindows": "if (Get-Command node -ErrorAction SilentlyContinue) { node \"$env:CLAUDE_PLUGIN_ROOT\\hooks\\ponytail-activate.js\" }",
11-
"timeout": 5,
9+
"command": "\"${CLAUDE_PLUGIN_ROOT}/bin/ponytail-launch\" ponytail-activate",
10+
"commandWindows": "pwsh -NoProfile -ExecutionPolicy Bypass -File \"$env:CLAUDE_PLUGIN_ROOT\\bin\\ponytail-launch.ps1\" ponytail-activate",
11+
"timeout": 30,
1212
"statusMessage": "Loading ponytail mode..."
1313
}
1414
]
@@ -19,9 +19,9 @@
1919
"hooks": [
2020
{
2121
"type": "command",
22-
"command": "command -v node >/dev/null 2>&1 && node \"${CLAUDE_PLUGIN_ROOT}/hooks/ponytail-mode-tracker.js\" || exit 0",
23-
"commandWindows": "if (Get-Command node -ErrorAction SilentlyContinue) { node \"$env:CLAUDE_PLUGIN_ROOT\\hooks\\ponytail-mode-tracker.js\" }",
24-
"timeout": 5,
22+
"command": "\"${CLAUDE_PLUGIN_ROOT}/bin/ponytail-launch\" ponytail-hook",
23+
"commandWindows": "pwsh -NoProfile -ExecutionPolicy Bypass -File \"$env:CLAUDE_PLUGIN_ROOT\\bin\\ponytail-launch.ps1\" ponytail-hook",
24+
"timeout": 30,
2525
"statusMessage": "Tracking ponytail mode..."
2626
}
2727
]

‎hooks/copilot-hooks.json‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,15 @@
44
"sessionStart": [
55
{
66
"type": "command",
7-
"bash": "node \"${PLUGIN_ROOT}/hooks/ponytail-activate.js\"",
8-
"powershell": "node \"${PLUGIN_ROOT}\\hooks\\ponytail-activate.js\"",
9-
"timeoutSec": 5
7+
"bash": "\"${PLUGIN_ROOT}/bin/ponytail-launch\" ponytail-activate",
8+
"timeoutSec": 30
109
}
1110
],
1211
"userPromptSubmitted": [
1312
{
1413
"type": "command",
15-
"bash": "node \"${PLUGIN_ROOT}/hooks/ponytail-mode-tracker.js\"",
16-
"powershell": "node \"${PLUGIN_ROOT}\\hooks\\ponytail-mode-tracker.js\"",
17-
"timeoutSec": 5
14+
"bash": "\"${PLUGIN_ROOT}/bin/ponytail-launch\" ponytail-hook",
15+
"timeoutSec": 30
1816
}
1917
]
2018
}

0 commit comments

Comments
 (0)