snapshot of https://github.com/marmelab/atomic-crm.git (main, 2026-09-18; blobs > 131072B omitted)
Some checks failed
✅ Check / 🔬 ESLint (push) Failing after 21s
✅ Check / 🏷️ Typecheck (push) Failing after 11s
✅ Check / 🔎 Test (push) Failing after 11s
✅ Check / e2e-test (push) Failing after 11s
✅ Check / 🔨 Build (push) Failing after 11s
🚀 Deploy / 🚀 Deploy (doc) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (demo) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (supabase) (push) Failing after 18s
Some checks failed
✅ Check / 🔬 ESLint (push) Failing after 21s
✅ Check / 🏷️ Typecheck (push) Failing after 11s
✅ Check / 🔎 Test (push) Failing after 11s
✅ Check / e2e-test (push) Failing after 11s
✅ Check / 🔨 Build (push) Failing after 11s
🚀 Deploy / 🚀 Deploy (doc) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (demo) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (supabase) (push) Failing after 18s
This commit is contained in:
168
.claude/scripts/apply-migrations.mjs
Executable file
168
.claude/scripts/apply-migrations.mjs
Executable file
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env node
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// apply-migrations — Apply committed migrations to the local
|
||||
// Supabase instance.
|
||||
//
|
||||
// Usage:
|
||||
// node apply-migrations.mjs
|
||||
//
|
||||
// Behaviour:
|
||||
// Migrations are already committed to supabase/migrations/ on
|
||||
// the base branch (written by the deploy-time migration round and
|
||||
// merged by the merger) before this script runs. There is nothing
|
||||
// to promote — this script only applies what is already there.
|
||||
//
|
||||
// If Supabase is not running: start it (initial start applies
|
||||
// every migration in supabase/migrations/).
|
||||
// If Supabase is already running: run `npx supabase migration up`.
|
||||
//
|
||||
// After applying, the PostgREST schema cache is reloaded so
|
||||
// new columns/tables are visible immediately.
|
||||
//
|
||||
// Portability (works on both crm-builder and a bare AtomicCRM checkout):
|
||||
// APP_DIR — repo root. Defaults to $CLAUDE_PROJECT_DIR, then /app.
|
||||
// Migrations are always applied regardless of the app's data mode:
|
||||
// crm-builder runs in demo (FakeRest) by default at migration time,
|
||||
// but the committed migrations must still land in local Supabase so
|
||||
// they are ready when the app is switched to live data. The demo|full
|
||||
// MODE distinction belongs only to the live-switch step, not here.
|
||||
// Supabase is driven via the `supabase` CLI, which manages its own
|
||||
// Docker containers — we do NOT gate on /var/run/docker.sock so the
|
||||
// same script works wherever the CLI is available (e.g. `make`).
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
|
||||
// Resolve the repo root robustly. Prefer an explicit override / the env var, then
|
||||
// the git toplevel — works on any surface (local, devcontainer, CRM Builder)
|
||||
// without relying on CLAUDE_PROJECT_DIR being EXPORTED to this node process. The
|
||||
// old "/app" hardcoded fallback was a CRM-Builder-ism: on a bare checkout where the
|
||||
// env var wasn't exported, it chdir'd into a non-writable /app → EACCES on mkdir.
|
||||
// git is only spawned as the fallback — when an env var is set (the common harness
|
||||
// case) we never shell out.
|
||||
const gitToplevel = () => {
|
||||
const r = spawnSync("git", ["rev-parse", "--show-toplevel"], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
return (r.status === 0 && r.stdout.trim()) || "";
|
||||
};
|
||||
const APP_DIR =
|
||||
process.env.APP_DIR ||
|
||||
process.env.CLAUDE_PROJECT_DIR ||
|
||||
gitToplevel() ||
|
||||
process.cwd();
|
||||
|
||||
const GREEN = "\x1b[0;32m";
|
||||
const YELLOW = "\x1b[1;33m";
|
||||
const RED = "\x1b[0;31m";
|
||||
const BOLD = "\x1b[1m";
|
||||
const NC = "\x1b[0m";
|
||||
|
||||
const log = (msg) => process.stdout.write(`${msg}\n`);
|
||||
const err = (msg) => process.stderr.write(`${msg}\n`);
|
||||
|
||||
process.chdir(APP_DIR);
|
||||
mkdirSync("supabase/migrations", { recursive: true });
|
||||
|
||||
// Run a command, capturing combined stdout+stderr as text.
|
||||
function run(cmd, args) {
|
||||
const res = spawnSync(cmd, args, { encoding: "utf8" });
|
||||
const out = (res.stdout || "") + (res.stderr || "");
|
||||
return { out, code: res.status ?? (res.error ? 1 : 0) };
|
||||
}
|
||||
|
||||
async function isSupabaseUp() {
|
||||
try {
|
||||
await fetch("http://localhost:54321", {
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function reloadSchemaCache() {
|
||||
const { code } = run("npx", [
|
||||
"supabase",
|
||||
"db",
|
||||
"query",
|
||||
"--local",
|
||||
"SELECT pg_notify('pgrst', 'reload schema');",
|
||||
]);
|
||||
if (code === 0) {
|
||||
log(`${GREEN}PostgREST schema cache reloaded.${NC}`);
|
||||
} else {
|
||||
log(`${YELLOW}Could not reload PostgREST schema cache (non-fatal).${NC}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (await isSupabaseUp()) {
|
||||
// ── Apply migrations to running Supabase ──
|
||||
log(`${BOLD}Applying pending migrations to running Supabase...${NC}`);
|
||||
let { out, code } = run("npx", ["supabase", "migration", "up", "--local"]);
|
||||
|
||||
if (code !== 0) {
|
||||
// Auto-repair phantom versions that are recorded in Supabase but absent from
|
||||
// the local migrations/ directory (happens when a prior session's worktree was
|
||||
// cleaned up after applying the migration but before it landed in git).
|
||||
if (
|
||||
out.includes(
|
||||
"Remote migration versions not found in local migrations directory",
|
||||
)
|
||||
) {
|
||||
const phantomVersions = [...new Set(out.match(/\d{14}/g) ?? [])];
|
||||
if (phantomVersions.length > 0) {
|
||||
log(
|
||||
`${YELLOW}Auto-repairing phantom migration versions: ${phantomVersions.join(" ")}${NC}`,
|
||||
);
|
||||
run("npx", [
|
||||
"supabase",
|
||||
"migration",
|
||||
"repair",
|
||||
"--local",
|
||||
"--status",
|
||||
"reverted",
|
||||
...phantomVersions,
|
||||
]);
|
||||
({ out, code } = run("npx", [
|
||||
"supabase",
|
||||
"migration",
|
||||
"up",
|
||||
"--local",
|
||||
]));
|
||||
}
|
||||
}
|
||||
if (code !== 0) {
|
||||
err(out);
|
||||
err(`${RED}Migration up failed.${NC}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
log(`${GREEN}Migrations applied.${NC}`);
|
||||
reloadSchemaCache();
|
||||
} else {
|
||||
// ── Initial start applies all migrations ──
|
||||
log(
|
||||
`${BOLD}Starting Supabase (initial start applies all migrations)...${NC}`,
|
||||
);
|
||||
log(`${YELLOW}First run can take up to ~2 min to pull images.${NC}`);
|
||||
const { out } = run("npx", ["supabase", "start"]);
|
||||
for (const line of out.split("\n")) {
|
||||
if (/✓|✗|Error|Started|API URL/.test(line)) log(line);
|
||||
}
|
||||
|
||||
log(`${BOLD}Waiting for Supabase API (localhost:54321)...${NC}`);
|
||||
let retries = 120;
|
||||
while (!(await isSupabaseUp())) {
|
||||
retries -= 1;
|
||||
if (retries <= 0) {
|
||||
err(`${RED}Supabase did not respond after 120s.${NC}`);
|
||||
process.exit(1);
|
||||
}
|
||||
await sleep(1000);
|
||||
}
|
||||
log(`${GREEN}Supabase ready and all migrations applied.${NC}`);
|
||||
reloadSchemaCache();
|
||||
}
|
||||
86
.claude/scripts/check-config-sync.mjs
Executable file
86
.claude/scripts/check-config-sync.mjs
Executable file
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env node
|
||||
// Verify that every agent-role referenced by a settings.json SubagentStop matcher
|
||||
// is a declared role in harness.config.json (config.roles). Claude Code requires
|
||||
// settings.json matchers to be literal strings (they cannot be computed from the
|
||||
// config), so this check is the bridge that keeps the two in sync: rename a role
|
||||
// in config AND settings -> the matcher token still resolves, pass; rename it in
|
||||
// config only -> the stale settings token is not a config role, fail.
|
||||
//
|
||||
// Usage: node .claude/scripts/check-config-sync.mjs [--app <repo>]
|
||||
// Exit 0 = in sync; exit 1 = drift (prints the offending tokens).
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { loadConfig, roleNames } from "../hooks/lib/config.mjs";
|
||||
|
||||
function resolveRepo(argv) {
|
||||
const flag = argv.indexOf("--app");
|
||||
if (flag !== -1 && argv[flag + 1]) return argv[flag + 1];
|
||||
return process.env.APP_DIR || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
||||
}
|
||||
|
||||
// SubagentStop matchers are role tokens (possibly `a|b`); PreToolUse/PostToolUse
|
||||
// matchers are tool names (Bash, Agent, Write|Edit) and are NOT roles, so only
|
||||
// SubagentStop is checked here.
|
||||
function settingsRoleTokens(settings) {
|
||||
const entries = settings.hooks?.SubagentStop ?? [];
|
||||
const tokens = new Set();
|
||||
for (const entry of entries) {
|
||||
const matcher = entry.matcher;
|
||||
if (!matcher || typeof matcher !== "string") continue;
|
||||
for (const t of matcher
|
||||
.split("|")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)) {
|
||||
tokens.add(t);
|
||||
}
|
||||
}
|
||||
return [...tokens];
|
||||
}
|
||||
|
||||
export function checkConfigSync(repo) {
|
||||
const settingsPath = join(repo, ".claude", "settings.json");
|
||||
if (!existsSync(settingsPath)) {
|
||||
return { ok: false, error: `settings.json not found at ${settingsPath}` };
|
||||
}
|
||||
let settings;
|
||||
try {
|
||||
settings = JSON.parse(readFileSync(settingsPath, "utf8"));
|
||||
} catch (e) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `settings.json is not valid JSON: ${e.message}`,
|
||||
};
|
||||
}
|
||||
let roles;
|
||||
try {
|
||||
roles = new Set(roleNames(loadConfig(repo)));
|
||||
} catch (e) {
|
||||
return { ok: false, error: e.message };
|
||||
}
|
||||
const tokens = settingsRoleTokens(settings);
|
||||
const missing = tokens.filter((t) => !roles.has(t));
|
||||
return { ok: missing.length === 0, missing, tokens, roles: [...roles] };
|
||||
}
|
||||
|
||||
// Run as CLI when invoked directly (not when imported by a test).
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const repo = resolveRepo(process.argv.slice(2));
|
||||
const r = checkConfigSync(repo);
|
||||
if (r.error) {
|
||||
console.error(`check-config-sync: ${r.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!r.ok) {
|
||||
console.error(
|
||||
`check-config-sync: settings.json SubagentStop matcher(s) reference roles not in harness.config.json: ${r.missing.join(", ")}.\n` +
|
||||
`Declared roles: ${r.roles.join(", ")}.\n` +
|
||||
`Rename the role in BOTH files, or add it to config.roles.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(
|
||||
`check-config-sync: OK (${r.tokens.length} role matcher(s) all declared)\n`,
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
155
.claude/scripts/e2e-smoke.sh
Executable file
155
.claude/scripts/e2e-smoke.sh
Executable file
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env bash
|
||||
# e2e-smoke.sh - run the Playwright e2e suite against an ISOLATED, disposable Supabase
|
||||
# instance so multiple harness sessions can run it in parallel without colliding.
|
||||
# Guaranteed teardown via `trap ... EXIT`.
|
||||
#
|
||||
# Isolation per run: a leased SLOT (0..K-1) gives offset = slot*20, applied to the
|
||||
# project_id, every Supabase port, and the app port + auth URLs, in a throwaway workdir.
|
||||
# The flock lease also CAPS concurrency (K stacks max) so we never OOM the host.
|
||||
#
|
||||
# Exit codes: 0 = suite passed OR gracefully skipped (no slot / low RAM / cannot start);
|
||||
# 1 = the e2e suite ran and FAILED. Skips are exit 0 by design (the caller
|
||||
# treats a skip as "not run here, run it later", not as a failure).
|
||||
#
|
||||
# Env: E2E_SMOKE_SLOTS (default 5), E2E_SMOKE_MIN_MB (default 2500), E2E_SMOKE_DRY=1
|
||||
# (resolve slot/ports/config and print them, then exit WITHOUT Docker - for tests).
|
||||
set -uo pipefail
|
||||
|
||||
REPO="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
|
||||
# Where to read the app + specs + schema FROM. A feature-smoke must run the INTEGRATED
|
||||
# session code, not the base-branch checkout ($REPO) - so the orchestrator passes
|
||||
# E2E_SMOKE_SRC=<WORKTREE_BASE>/_session (the session worktree, on session/<short>, with
|
||||
# node_modules already provisioned). Default $REPO = a base-branch baseline check.
|
||||
SRC="${E2E_SMOKE_SRC:-$REPO}"
|
||||
SLOTS="${E2E_SMOKE_SLOTS:-5}"
|
||||
MIN_MB="${E2E_SMOKE_MIN_MB:-2500}"
|
||||
DRY="${E2E_SMOKE_DRY:-0}"
|
||||
SLOT_LOCK_DIR="/tmp/atomic-crm-e2e-slots"
|
||||
CONFIG_SRC="$SRC/supabase/config.e2e.toml"
|
||||
mkdir -p "$SLOT_LOCK_DIR"
|
||||
|
||||
skip() { echo "SKIP: $*"; exit 0; } # graceful: not run here, not a failure
|
||||
|
||||
# --- memory preflight -------------------------------------------------------
|
||||
# Each Supabase stack is ~2-3 GB. Don't attempt a boot the host can't hold; skip
|
||||
# gracefully so the caller defers to a human `make test-e2e` instead of OOM-ing.
|
||||
avail_mb="$(free -m 2>/dev/null | awk '/^Mem:/{print $7}')"
|
||||
if [ "$DRY" != "1" ] && [ -n "$avail_mb" ] && [ "$avail_mb" -lt "$MIN_MB" ]; then
|
||||
skip "only ${avail_mb}MB free, need ~${MIN_MB}MB for a Supabase e2e stack; run 'make test-e2e' locally."
|
||||
fi
|
||||
|
||||
# --- lease a slot (flock; also the concurrency cap) -------------------------
|
||||
slot=""; SLOT_FD=""
|
||||
for i in $(seq 0 $((SLOTS - 1))); do
|
||||
exec {fd}>"$SLOT_LOCK_DIR/slot-$i.lock"
|
||||
if flock -n "$fd"; then slot="$i"; SLOT_FD="$fd"; break; fi
|
||||
exec {fd}>&-
|
||||
done
|
||||
[ -z "$slot" ] && skip "all $SLOTS e2e slots busy; try again later."
|
||||
|
||||
offset=$((slot * 20))
|
||||
project="atomic-crm-e2e-$slot"
|
||||
api_port=$((54341 + offset))
|
||||
app_port=$((5175 + slot))
|
||||
workroot="$(mktemp -d)"
|
||||
workdir="$workroot/e2e" # supabase --workdir
|
||||
mkdir -p "$workdir/supabase"
|
||||
|
||||
# --- guaranteed teardown ----------------------------------------------------
|
||||
APP_PID=""
|
||||
cleanup() {
|
||||
local code=$?
|
||||
[ -n "$APP_PID" ] && kill "$APP_PID" 2>/dev/null || true
|
||||
[ "$DRY" != "1" ] && npx supabase stop --workdir "$workdir" --no-backup >/dev/null 2>&1 || true
|
||||
[ -n "$SLOT_FD" ] && flock -u "$SLOT_FD" 2>/dev/null || true
|
||||
rm -rf "$workroot" 2>/dev/null || true
|
||||
return $code
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
# --- render the per-slot config ---------------------------------------------
|
||||
# Offset every Supabase port (`port =`, `shadow_port =`, `vector_port =`) by `offset`,
|
||||
# set a unique project_id (distinct Docker containers), and point the auth URLs at the
|
||||
# per-slot app port. perl `/e` evaluates the numeric replacement.
|
||||
[ -f "$CONFIG_SRC" ] || skip "no $CONFIG_SRC"
|
||||
perl -pe "s/^(project_id\s*=\s*).*/\$1\"$project\"/;
|
||||
s/((?:^|_)port\s*=\s*)(\d+)/\$1.(\$2+$offset)/e;
|
||||
s/5175/$app_port/g" "$CONFIG_SRC" > "$workdir/supabase/config.toml"
|
||||
|
||||
# Supabase needs the schema/migrations/seed to build the DB on first start.
|
||||
for d in migrations schemas functions templates; do
|
||||
[ -d "$SRC/supabase/$d" ] && cp -r "$SRC/supabase/$d" "$workdir/supabase/$d"
|
||||
done
|
||||
for f in seed.sql signing_keys.json; do
|
||||
[ -f "$SRC/supabase/$f" ] && cp "$SRC/supabase/$f" "$workdir/supabase/$f"
|
||||
done
|
||||
|
||||
if [ "$DRY" = "1" ]; then
|
||||
echo "DRY-RUN slot=$slot offset=$offset project=$project api_port=$api_port app_port=$app_port workdir=$workdir"
|
||||
echo "config.project_id=$(grep -E '^project_id' "$workdir/supabase/config.toml")"
|
||||
echo "config.api_port=$(awk '/^\[api\]/{a=1} a&&/^port =/{print $3; exit}' "$workdir/supabase/config.toml")"
|
||||
echo "config.site_url=$(grep -E '^site_url' "$workdir/supabase/config.toml" | head -1)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- reap a previously killed run of this slot -----------------------------
|
||||
# The trap cleans up normal exits (EXIT/INT/TERM); a SIGKILL would orphan this slot's
|
||||
# containers. Before starting fresh, remove any lingering ones for this project_id.
|
||||
docker ps -aq --filter "name=${project}" 2>/dev/null | xargs -r docker rm -f >/dev/null 2>&1 || true
|
||||
|
||||
# --- start the isolated stack ----------------------------------------------
|
||||
echo "e2e-smoke: starting isolated Supabase (slot $slot, api :$api_port, project $project)..."
|
||||
if ! npx supabase start --workdir "$workdir" >/"$workroot/supabase.log" 2>&1; then
|
||||
cat "$workroot/supabase.log" >&2 || true
|
||||
skip "isolated Supabase failed to start (see log); deferring e2e to a human run."
|
||||
fi
|
||||
SERVICE_ROLE_KEY="$(npx supabase status --workdir "$workdir" -o env 2>/dev/null | sed -nE 's/^SERVICE_ROLE_KEY="?([^"]+)"?/\1/p')"
|
||||
ANON_KEY="$(npx supabase status --workdir "$workdir" -o env 2>/dev/null | sed -nE 's/^ANON_KEY="?([^"]+)"?/\1/p')"
|
||||
export VITE_SUPABASE_URL="http://127.0.0.1:$api_port"
|
||||
export VITE_SUPABASE_ANON_KEY="$ANON_KEY"
|
||||
export SERVICE_ROLE_KEY
|
||||
|
||||
# --- materialize the session schema the deploy-time migration will carry --------
|
||||
# Ticket developers never write migrations, so a schema-touching session has
|
||||
# supabase/schemas/ ahead of supabase/migrations/. The CLI builds the DB from
|
||||
# migrations/ only, so without this the isolated stack lacks the new columns and
|
||||
# schema-exercising specs 400 against PostgREST - a false FAIL. When the
|
||||
# session changed supabase/schemas/, generate a THROWAWAY migration from the delta
|
||||
# INTO THE WORKDIR (never $SRC) and apply it. Best-effort: on any failure SKIP the
|
||||
# leg rather than emit a false FAIL (the real migration lands in the deploy round).
|
||||
schema_changed=0
|
||||
branch="$(git -C "$SRC" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")"
|
||||
case "$branch" in
|
||||
session/*)
|
||||
base="session-base/${branch#session/}"
|
||||
if git -C "$SRC" rev-parse --verify -q "$base" >/dev/null 2>&1 \
|
||||
&& git -C "$SRC" diff --name-only "$base"...HEAD 2>/dev/null | grep -q '^supabase/schemas/'; then
|
||||
schema_changed=1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
if [ "$schema_changed" = "1" ] && [ -d "$workdir/supabase/schemas" ]; then
|
||||
echo "e2e-smoke: session changed supabase/schemas/, materializing a throwaway migration..."
|
||||
if npx supabase db diff --workdir "$workdir" --local -f _e2e_throwaway >"$workroot/dbdiff.log" 2>&1; then
|
||||
if ls "$workdir"/supabase/migrations/*_e2e_throwaway.sql >/dev/null 2>&1; then
|
||||
npx supabase migration up --workdir "$workdir" --local >"$workroot/migup.log" 2>&1 \
|
||||
|| { cat "$workroot/migup.log" >&2; skip "schema pending migration round (throwaway apply failed); deferring the Supabase e2e leg to the deploy-time migration."; }
|
||||
fi
|
||||
else
|
||||
cat "$workroot/dbdiff.log" >&2
|
||||
skip "schema pending migration round (throwaway diff failed); deferring the Supabase e2e leg to the deploy-time migration."
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- serve the app (dev server reads env at start, so no per-slot build) ----
|
||||
( cd "$SRC" && VITE_SUPABASE_URL="$VITE_SUPABASE_URL" npx vite --port "$app_port" --strictPort --mode e2e >"$workroot/app.log" 2>&1 ) &
|
||||
APP_PID=$!
|
||||
npx wait-on -t 120000 "http-get://127.0.0.1:$api_port/auth/v1/health" "http-get://127.0.0.1:$app_port" >/dev/null 2>&1 \
|
||||
|| skip "isolated stack did not become ready in time."
|
||||
|
||||
# --- run the suite ----------------------------------------------------------
|
||||
echo "e2e-smoke: running Playwright against the isolated stack..."
|
||||
( cd "$SRC" && CI=true PLAYWRIGHT_BASE_URL="http://127.0.0.1:$app_port" npx playwright test )
|
||||
result=$?
|
||||
echo "e2e-smoke: suite exit=$result (slot $slot)"
|
||||
exit $result
|
||||
682
.claude/scripts/harness-monitor.mjs
Executable file
682
.claude/scripts/harness-monitor.mjs
Executable file
@@ -0,0 +1,682 @@
|
||||
#!/usr/bin/env node
|
||||
//
|
||||
// Harness monitor — a terminal view of an agent-workflow session.
|
||||
//
|
||||
// Gather informations from three places, all keyed by the session id:
|
||||
//
|
||||
// 1. hooks.log <TMP_ROOT>/<sanitized-repo>/<id>/hooks.log
|
||||
// 2. subagent transcripts ~/.claude/projects/<slug>/<id>/subagents/*.jsonl
|
||||
// 3. tickets <CHAT_SESSION_DIR or TICKETS_DIR>/TASK-*.json
|
||||
//
|
||||
// Usage:
|
||||
// .claude/scripts/harness-monitor.mjs # latest session, one-shot summary
|
||||
// .claude/scripts/harness-monitor.mjs --watch # latest session, live (re-renders)
|
||||
// .claude/scripts/harness-monitor.mjs --session <id> # a specific session id
|
||||
// .claude/scripts/harness-monitor.mjs --list # list known sessions, newest first
|
||||
|
||||
import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
|
||||
import { join, basename } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
// ─── paths & session discovery ──────────────────────────────────────────────
|
||||
|
||||
const REPO = (() => {
|
||||
if (process.env.CLAUDE_PROJECT_DIR) return process.env.CLAUDE_PROJECT_DIR;
|
||||
try {
|
||||
return execSync("git rev-parse --show-toplevel", {
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
} catch {
|
||||
return process.cwd();
|
||||
}
|
||||
})();
|
||||
|
||||
const TMP_ROOT = process.env.CRM_TMP_ROOT || "/tmp";
|
||||
const sanitize = (p) => String(p).replace(/\//g, "_");
|
||||
const WORKTREE_ROOT = join(TMP_ROOT, sanitize(REPO)); // /tmp/_workspaces_atomic-crm
|
||||
const CLAUDE_PROJ = join(
|
||||
homedir(),
|
||||
".claude",
|
||||
"projects",
|
||||
// Claude Code names the project dir by replacing EVERY non-alphanumeric char
|
||||
// (slashes AND dots, e.g. ".code-workspace") with "-". Replacing only "/" broke
|
||||
// discovery for any repo path containing a dot → "no subagent transcripts found".
|
||||
REPO.replace(/[^a-zA-Z0-9]/g, "-"),
|
||||
);
|
||||
// A session id is a worktree-base subdir that has a hooks.log. Test fixtures
|
||||
// (td-1, td-2, …) are skipped — they are not real sessions.
|
||||
function listSessions() {
|
||||
if (!existsSync(WORKTREE_ROOT)) return [];
|
||||
return readdirSync(WORKTREE_ROOT)
|
||||
.filter((name) => !/^td-/.test(name))
|
||||
.map((id) => ({ id, log: join(WORKTREE_ROOT, id, "hooks.log") }))
|
||||
.filter((s) => existsSync(s.log))
|
||||
.map((s) => ({ ...s, mtime: statSync(s.log).mtimeMs }))
|
||||
.sort((a, b) => b.mtime - a.mtime);
|
||||
}
|
||||
|
||||
function sessionPaths(id) {
|
||||
const base = join(WORKTREE_ROOT, id);
|
||||
return {
|
||||
id,
|
||||
base,
|
||||
hooksLog: join(base, "hooks.log"),
|
||||
mainTranscript: join(CLAUDE_PROJ, `${id}.jsonl`),
|
||||
subagentsDir: join(CLAUDE_PROJ, id, "subagents"),
|
||||
// Tickets land in the session dir itself (TICKETS_DIR = session_dir = base);
|
||||
// <base>/tickets is the legacy fallback layout.
|
||||
ticketDirs: [base, join(base, "tickets")],
|
||||
};
|
||||
}
|
||||
|
||||
// A worktree path → a short, human label. `simple` is the simple-developer's
|
||||
// worktree; `_session` is the shared integration worktree the merger merges
|
||||
// into before promoting to the base branch.
|
||||
function wtLabel(path) {
|
||||
if (!path) return "";
|
||||
const b = basename(path);
|
||||
if (b === "_session") return "_session (integration)";
|
||||
if (b === "simple") return "simple (dev worktree)";
|
||||
return b; // TASK-XXX
|
||||
}
|
||||
|
||||
// ─── colour helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
const COLOR = process.stdout.isTTY && !process.env.NO_COLOR;
|
||||
const c = (code, s) => (COLOR ? `\x1b[${code}m${s}\x1b[0m` : s);
|
||||
const dim = (s) => c("2", s);
|
||||
const bold = (s) => c("1", s);
|
||||
const red = (s) => c("31", s);
|
||||
const green = (s) => c("32", s);
|
||||
const yellow = (s) => c("33", s);
|
||||
const blue = (s) => c("36", s);
|
||||
const mag = (s) => c("35", s);
|
||||
|
||||
const hhmmss = (iso) => {
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? iso : d.toISOString().slice(11, 19);
|
||||
};
|
||||
|
||||
// ─── hooks.log parsing ──────────────────────────────────────────────────────
|
||||
// Line shape: `[<iso-ts>] [<hook>] <rest...>`. Lines without that prefix are
|
||||
// continuation output (e.g. a multi-line validation failure) and attach to the
|
||||
// previous event as detail.
|
||||
|
||||
const LINE_RE = /^\[([^\]]+)\]\s+\[([^\]]+)\]\s+(.*)$/;
|
||||
|
||||
function parseHooksLog(path) {
|
||||
if (!existsSync(path)) return [];
|
||||
const events = [];
|
||||
for (const line of readFileSync(path, "utf8").split("\n")) {
|
||||
if (!line) continue;
|
||||
const m = line.match(LINE_RE);
|
||||
if (!m) {
|
||||
if (events.length) events[events.length - 1].detail.push(line);
|
||||
continue;
|
||||
}
|
||||
const [, ts, hook, rest] = m;
|
||||
const fields = {};
|
||||
for (const f of rest.matchAll(/(\w+)=(\S+)/g)) fields[f[1]] = f[2];
|
||||
const state = rest.split(/\s+/)[0];
|
||||
events.push({ ts, hook, state, rest, fields, detail: [] });
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
// ─── subagent transcripts ───────────────────────────────────────────────────
|
||||
|
||||
function readJsonl(path) {
|
||||
if (!existsSync(path)) return [];
|
||||
const out = [];
|
||||
for (const line of readFileSync(path, "utf8").split("\n")) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
out.push(JSON.parse(line));
|
||||
} catch {
|
||||
/* skip malformed line */
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Claude Code records a failed hook command as an `attachment` whose inner type
|
||||
// is hook_(non_)blocking_error.
|
||||
function hookErrorsFrom(records) {
|
||||
const errs = [];
|
||||
for (const rec of records) {
|
||||
const a = rec.attachment;
|
||||
if (!a || !/hook_(non_)?blocking_error/.test(a.type || "")) continue;
|
||||
errs.push({
|
||||
ts: rec.timestamp,
|
||||
hookName: a.hookName || a.hookEvent || "hook",
|
||||
stderr: (a.stderr || "").split("\n")[0].slice(0, 120),
|
||||
command: (a.command || "").slice(0, 80),
|
||||
exitCode: a.exitCode,
|
||||
blocking: !/non_blocking/.test(a.type || ""),
|
||||
});
|
||||
}
|
||||
return errs;
|
||||
}
|
||||
|
||||
function readAgents(dir) {
|
||||
if (!existsSync(dir)) return [];
|
||||
const agents = [];
|
||||
for (const file of readdirSync(dir)) {
|
||||
if (!file.endsWith(".jsonl")) continue;
|
||||
const agentId = basename(file, ".jsonl");
|
||||
const meta = (() => {
|
||||
const p = join(dir, `${agentId}.meta.json`);
|
||||
try {
|
||||
return JSON.parse(readFileSync(p, "utf8"));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
})();
|
||||
|
||||
const records = readJsonl(join(dir, file));
|
||||
const toolCounts = {};
|
||||
const messages = []; // SendMessage payloads, timestamped
|
||||
let lastText = "";
|
||||
let firstTs = null;
|
||||
let lastTs = null;
|
||||
for (const rec of records) {
|
||||
if (rec.timestamp) {
|
||||
if (!firstTs) firstTs = rec.timestamp;
|
||||
lastTs = rec.timestamp;
|
||||
}
|
||||
const content = rec.message?.content;
|
||||
if (!Array.isArray(content)) continue;
|
||||
for (const block of content) {
|
||||
if (block.type === "tool_use") {
|
||||
toolCounts[block.name] = (toolCounts[block.name] || 0) + 1;
|
||||
if (block.name === "SendMessage") {
|
||||
const to = block.input?.to ?? "?";
|
||||
const msg = block.input?.message;
|
||||
messages.push({
|
||||
to,
|
||||
text: typeof msg === "string" ? msg : JSON.stringify(msg),
|
||||
ts: rec.timestamp,
|
||||
});
|
||||
}
|
||||
} else if (block.type === "text" && block.text.trim()) {
|
||||
lastText = block.text.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
agents.push({
|
||||
agentId,
|
||||
type: meta.agentType || "?",
|
||||
description: meta.description || "",
|
||||
toolCounts,
|
||||
messages,
|
||||
lastText,
|
||||
firstTs,
|
||||
lastTs,
|
||||
hookErrors: hookErrorsFrom(records),
|
||||
verdict: classifyVerdict(lastText),
|
||||
});
|
||||
}
|
||||
// Chronological by last activity so repeated dispatches of the same agent
|
||||
// (retries / resumes) read top-to-bottom in the order they ran.
|
||||
return agents.sort((a, b) =>
|
||||
String(a.lastTs || "").localeCompare(String(b.lastTs || "")),
|
||||
);
|
||||
}
|
||||
|
||||
// The orchestrator is the main session transcript, NOT a subagent.
|
||||
function readOrchestrator(path) {
|
||||
const records = readJsonl(path);
|
||||
if (!records.length) return null;
|
||||
let lastText = "";
|
||||
let lastTs = null;
|
||||
const dispatches = []; // { ts, type } — when the orchestrator spawned each agent
|
||||
for (const rec of records) {
|
||||
if (rec.timestamp) lastTs = rec.timestamp;
|
||||
const content = rec.message?.content;
|
||||
if (!Array.isArray(content)) continue;
|
||||
for (const block of content) {
|
||||
if (
|
||||
block.type === "tool_use" &&
|
||||
(block.name === "Agent" || block.name === "Task")
|
||||
)
|
||||
dispatches.push({
|
||||
ts: rec.timestamp,
|
||||
type:
|
||||
block.input?.subagent_type || block.input?.description || "agent",
|
||||
});
|
||||
else if (block.type === "text" && block.text.trim())
|
||||
lastText = block.text.trim();
|
||||
}
|
||||
}
|
||||
return { lastText, lastTs, dispatches, hookErrors: hookErrorsFrom(records) };
|
||||
}
|
||||
|
||||
// Merge every timestamped signal — orchestrator spawns, agent SendMessages,
|
||||
// agent completions, and the key hook milestones — into one chronological
|
||||
// stream. This is the "what happened, in order" view: dev → merger, etc.
|
||||
function buildEventTimeline(orchestrator, agents, events) {
|
||||
const items = [];
|
||||
const push = (ts, color, icon, text) =>
|
||||
ts && items.push({ ts, color, icon, text });
|
||||
|
||||
for (const d of orchestrator?.dispatches ?? [])
|
||||
push(d.ts, blue, "▶", `orchestrator spawned ${bold(d.type)}`);
|
||||
|
||||
for (const a of agents) {
|
||||
for (const m of a.messages)
|
||||
push(
|
||||
m.ts,
|
||||
mag,
|
||||
"→",
|
||||
`${a.type} → ${bold(m.to)}: ${m.text.split("\n")[0].slice(0, 80)}`,
|
||||
);
|
||||
const mark =
|
||||
a.verdict.kind === "done"
|
||||
? green("✓")
|
||||
: a.verdict.kind === "fail"
|
||||
? red("✗")
|
||||
: dim("■");
|
||||
const vcolor =
|
||||
a.verdict.kind === "fail" ? red : a.verdict.kind === "done" ? green : dim;
|
||||
push(
|
||||
a.lastTs,
|
||||
vcolor,
|
||||
mark,
|
||||
`${a.type} finished — ${a.verdict.label.slice(0, 80)}`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const e of events) {
|
||||
if (e.hook === "setup-worktree" && e.state === "CREATED")
|
||||
push(e.ts, blue, "⊕", `worktree created ${dim(wtLabel(e.fields.path))}`);
|
||||
else if (e.hook === "validate" && e.state === "OK")
|
||||
push(e.ts, green, "✓", `validation OK ${dim(wtLabel(e.fields.wt))}`);
|
||||
else if (e.hook === "validate" && e.state === "FAIL")
|
||||
push(
|
||||
e.ts,
|
||||
red,
|
||||
"✗",
|
||||
`validation FAIL ${e.fields.step || ""}${e.detail.length ? dim(" — " + e.detail.join(" ").slice(0, 60)) : ""}`,
|
||||
);
|
||||
else if (e.hook === "circuit-breaker" && /^BLOCK/.test(e.rest))
|
||||
push(e.ts, red, "⛔", `circuit-breaker BLOCK (count=${e.fields.count})`);
|
||||
}
|
||||
|
||||
if (orchestrator?.lastTs)
|
||||
push(
|
||||
orchestrator.lastTs,
|
||||
dim,
|
||||
"▣",
|
||||
`orchestrator last said: ${orchestrator.lastText.split("\n").pop().slice(0, 80)}`,
|
||||
);
|
||||
|
||||
return items.sort((a, b) => String(a.ts).localeCompare(String(b.ts)));
|
||||
}
|
||||
|
||||
// Synthesize a single completion verdict for the whole session from the
|
||||
// strongest available signal: the merger's DONE line, then a clean
|
||||
// no-dirty-worktree finish, else still-running / unknown.
|
||||
function sessionStatus(agents, events) {
|
||||
const merger = agents.find((a) => a.type === "merger");
|
||||
if (merger) {
|
||||
const m = merger.lastText.match(/DONE:\s*commit=(\S+)/i);
|
||||
if (m)
|
||||
return green(
|
||||
`✓ COMPLETE — merger promoted to main (commit ${m[1].replace(/[.,]$/, "")})`,
|
||||
);
|
||||
if (/promotion merged|merged .*into main/i.test(merger.lastText))
|
||||
return green("✓ COMPLETE — merger reported the session merged into main");
|
||||
if (merger.verdict.kind === "fail")
|
||||
return red(`✗ merger FAILED — ${merger.verdict.label.slice(0, 120)}`);
|
||||
}
|
||||
if (agents.some((a) => a.verdict.kind === "fail"))
|
||||
return red("✗ FAILED — an agent ended in failure (see DIAGNOSIS)");
|
||||
const lastValidate = [...events]
|
||||
.reverse()
|
||||
.find((e) => e.hook === "validate" && e.state === "ACCEPT");
|
||||
if (lastValidate && /no_dirty_worktree/.test(lastValidate.rest))
|
||||
return yellow(
|
||||
"… likely done — last validation found nothing left to do, but no merger DONE was seen",
|
||||
);
|
||||
return yellow("… in progress / outcome unclear");
|
||||
}
|
||||
|
||||
// The structured-text contract: agents end with DONE / FAILED / BLOCKED, or a
|
||||
// reviewer verdict.
|
||||
function classifyVerdict(text) {
|
||||
if (!text) return { kind: "running", label: "running / no final message" };
|
||||
const head =
|
||||
text
|
||||
.split("\n")
|
||||
.find((l) => l.trim())
|
||||
?.slice(0, 200) ?? "";
|
||||
if (/^FAILED\b|permanently deadlocked|\bBLOCKED\b/im.test(text))
|
||||
return { kind: "fail", label: head };
|
||||
if (
|
||||
/^DONE:|merged\s+TASK|merged successfully|promotion merged|completed successfully|^Verdict:\s*(APPROVED|GREEN)/im.test(
|
||||
text,
|
||||
)
|
||||
)
|
||||
return { kind: "done", label: head };
|
||||
return { kind: "info", label: head };
|
||||
}
|
||||
|
||||
// ─── tickets ────────────────────────────────────────────────────────────────
|
||||
|
||||
function readTickets(dirs) {
|
||||
for (const dir of dirs) {
|
||||
if (!existsSync(dir)) continue;
|
||||
const files = readdirSync(dir).filter((f) => /^TASK-\d+\.json$/.test(f));
|
||||
if (!files.length) continue;
|
||||
return files.sort().map((f) => {
|
||||
try {
|
||||
const t = JSON.parse(readFileSync(join(dir, f), "utf8"));
|
||||
return {
|
||||
id: t.id || basename(f, ".json"),
|
||||
status: t.status || "?",
|
||||
title: t.title || t.description || "",
|
||||
};
|
||||
} catch {
|
||||
return { id: basename(f, ".json"), status: "unreadable", title: "" };
|
||||
}
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// ─── diagnosis ──────────────────────────────────────────────────────────────
|
||||
// Turn the raw event stream into named pathologies.
|
||||
|
||||
function diagnose(events, agents, hookErrors = []) {
|
||||
const findings = [];
|
||||
|
||||
// 0. Failed hook commands (from the transcript, not hooks.log). Aggregate by
|
||||
// hook + first error line so a hook that fires on every tool use collapses
|
||||
// into one finding with a count.
|
||||
const byErr = new Map();
|
||||
for (const h of hookErrors) {
|
||||
const key = `${h.hookName} → ${h.stderr}`;
|
||||
const row = byErr.get(key) ?? { ...h, count: 0 };
|
||||
row.count++;
|
||||
byErr.set(key, row);
|
||||
}
|
||||
for (const h of byErr.values()) {
|
||||
const sev = h.blocking ? red("✗") : yellow("⚠");
|
||||
const cmd = h.command ? dim(` [${h.command}]`) : "";
|
||||
findings.push(
|
||||
`${sev} ${h.blocking ? "BLOCKING " : ""}hook error ×${h.count}: ${h.hookName} — ${h.stderr} (exit ${h.exitCode})${cmd}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 1. A validation step that keeps failing on the same worktree.
|
||||
const validFails = events.filter(
|
||||
(e) => e.hook === "validate" && e.state === "FAIL" && e.fields.step,
|
||||
);
|
||||
const byStep = {};
|
||||
for (const e of validFails)
|
||||
byStep[e.fields.step] = (byStep[e.fields.step] || 0) + 1;
|
||||
for (const [step, n] of Object.entries(byStep)) {
|
||||
if (n >= 2)
|
||||
findings.push(
|
||||
red(
|
||||
`✗ validation never passes: '${step}' failed ${n}× — the flow cannot complete until this is green.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Circuit-breaker blocks → an agent is re-submitting an identical action.
|
||||
const blocks = events.filter(
|
||||
(e) => e.hook === "circuit-breaker" && /^BLOCK/.test(e.rest),
|
||||
);
|
||||
if (blocks.length) {
|
||||
const maxCount = Math.max(
|
||||
...blocks.map((b) => Number(b.fields.count) || 0),
|
||||
);
|
||||
findings.push(
|
||||
red(
|
||||
`✗ retry loop: circuit-breaker BLOCKED ${blocks.length}× (up to ${maxCount} identical attempts) — the agent is stuck repeating the same call.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 3. bash-guard blocks → agent fighting the validation/permission rules.
|
||||
const guard = events.filter(
|
||||
(e) => e.hook === "bash-guard" && /BLOCK/.test(e.rest),
|
||||
);
|
||||
if (guard.length >= 3)
|
||||
findings.push(
|
||||
yellow(
|
||||
`⚠ ${guard.length} bash-guard blocks — the agent tried to run forbidden commands (often a sign it's flailing).`,
|
||||
),
|
||||
);
|
||||
|
||||
// 4. Agents that reported failure
|
||||
const seen = new Set();
|
||||
for (const a of agents) {
|
||||
const signals = [
|
||||
a.verdict.kind === "fail" ? a.verdict.label : "",
|
||||
...a.messages.map((m) => m.text),
|
||||
];
|
||||
for (const s of signals) {
|
||||
const head = s.split("\n")[0];
|
||||
if (
|
||||
!/FAILED|BLOCKED|worktree not found|cannot create worktree/i.test(head)
|
||||
)
|
||||
continue;
|
||||
const key = `${a.type}:${head.slice(0, 60)}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
findings.push(red(`✗ ${a.type}: ${head.slice(0, 140)}`));
|
||||
}
|
||||
}
|
||||
|
||||
// 4b. A TASK developer ran but its worktree was never created.
|
||||
const createdTasks = new Set(
|
||||
events
|
||||
.filter(
|
||||
(e) =>
|
||||
e.hook === "setup-worktree" &&
|
||||
/CREATED/.test(e.rest) &&
|
||||
e.fields.branch,
|
||||
)
|
||||
.map((e) => (e.fields.branch.match(/TASK-\d+/) || [])[0])
|
||||
.filter(Boolean),
|
||||
);
|
||||
for (const a of agents) {
|
||||
const task = (a.type.match(/TASK-\d+/) || [])[0];
|
||||
if (task && !createdTasks.has(task) && /developer/.test(a.type))
|
||||
findings.push(
|
||||
red(
|
||||
`✗ ${task}: worktree never created (no setup-worktree CREATED entry) — the developer has nowhere to work.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Worktree setup failure.
|
||||
if (
|
||||
events.some(
|
||||
(e) => e.hook === "setup-worktree" && /FAILED|EXIT=2/.test(e.rest),
|
||||
)
|
||||
)
|
||||
findings.push(
|
||||
red("✗ setup-worktree failed — no usable worktree was created."),
|
||||
);
|
||||
|
||||
if (!findings.length) findings.push(green("✓ no known pathology detected."));
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ─── rendering ──────────────────────────────────────────────────────────────
|
||||
|
||||
function render(id, limit = 0) {
|
||||
const p = sessionPaths(id);
|
||||
const events = parseHooksLog(p.hooksLog);
|
||||
const agents = readAgents(p.subagentsDir);
|
||||
const orchestrator = readOrchestrator(p.mainTranscript);
|
||||
const tickets = readTickets(p.ticketDirs);
|
||||
const out = [];
|
||||
const line = (s = "") => out.push(s);
|
||||
|
||||
line(bold(blue(`━━━ harness session ${id} ━━━`)));
|
||||
line(dim(`repo: ${REPO}`));
|
||||
line(dim(`base: ${p.base}`));
|
||||
if (existsSync(p.hooksLog))
|
||||
line(
|
||||
dim(
|
||||
`hooks.log: ${events.length} events, updated ${hhmmss(new Date(statSync(p.hooksLog).mtime).toISOString())}`,
|
||||
),
|
||||
);
|
||||
else line(red("hooks.log: missing"));
|
||||
line();
|
||||
|
||||
// Headline: did the session complete?
|
||||
line(bold("STATUS ") + sessionStatus(agents, events));
|
||||
if (orchestrator)
|
||||
line(
|
||||
` ${dim("orchestrator last said:")} ${orchestrator.lastText.split("\n").pop().slice(0, 140) || dim("(none)")}`,
|
||||
);
|
||||
else line(` ${dim("orchestrator: main transcript not found")}`);
|
||||
line();
|
||||
|
||||
// Agents
|
||||
line(bold("AGENTS"));
|
||||
if (!agents.length) line(dim(" (no subagent transcripts found)"));
|
||||
for (const a of agents) {
|
||||
const mark =
|
||||
a.verdict.kind === "done"
|
||||
? green("✓")
|
||||
: a.verdict.kind === "fail"
|
||||
? red("✗")
|
||||
: yellow("…");
|
||||
const tools = Object.entries(a.toolCounts)
|
||||
.map(([n, k]) => `${n}:${k}`)
|
||||
.join(" ");
|
||||
line(` ${mark} ${bold(a.type)} ${dim(a.description)}`);
|
||||
if (tools) line(` ${dim("tools")} ${tools}`);
|
||||
for (const m of a.messages)
|
||||
line(` ${mag("→ " + m.to)} ${m.text.split("\n")[0].slice(0, 100)}`);
|
||||
if (a.verdict.label)
|
||||
line(
|
||||
` ${a.verdict.kind === "fail" ? red(a.verdict.label) : dim(a.verdict.label)}`,
|
||||
);
|
||||
}
|
||||
line();
|
||||
|
||||
// Tickets
|
||||
if (tickets.length) {
|
||||
line(bold("TICKETS"));
|
||||
for (const t of tickets) {
|
||||
const cl =
|
||||
t.status === "merged"
|
||||
? green
|
||||
: t.status === "in_progress"
|
||||
? yellow
|
||||
: dim;
|
||||
line(` ${cl(t.status.padEnd(12))} ${t.id} ${dim(t.title.slice(0, 80))}`);
|
||||
}
|
||||
line();
|
||||
}
|
||||
|
||||
// Unified event timeline: orchestrator spawns + agent messages + completions
|
||||
// + key hook milestones, in chronological order. Consecutive identical rows
|
||||
// (e.g. the same validation failing 40×) are run-length collapsed to `×N`.
|
||||
let timeline = buildEventTimeline(orchestrator, agents, events);
|
||||
const total = timeline.length;
|
||||
if (limit && total > limit) timeline = timeline.slice(total - limit);
|
||||
line(
|
||||
bold("EVENT TIMELINE") +
|
||||
dim(
|
||||
` (chronological${limit && total > limit ? `, last ${limit} of ${total}` : ""})`,
|
||||
),
|
||||
);
|
||||
let i = 0;
|
||||
while (i < timeline.length) {
|
||||
let j = i;
|
||||
while (j + 1 < timeline.length && timeline[j + 1].text === timeline[i].text)
|
||||
j++;
|
||||
const n = j - i + 1;
|
||||
const ev = timeline[i];
|
||||
const span = n > 1 ? dim(` ×${n} (until ${hhmmss(timeline[j].ts)})`) : "";
|
||||
line(` ${dim(hhmmss(ev.ts))} ${ev.color(ev.icon)} ${ev.text}${span}`);
|
||||
i = j + 1;
|
||||
}
|
||||
line();
|
||||
|
||||
// Diagnosis — include hook-command failures gathered from every transcript
|
||||
// (orchestrator + each subagent), which never appear in hooks.log.
|
||||
const hookErrors = [
|
||||
...(orchestrator?.hookErrors ?? []),
|
||||
...agents.flatMap((a) => a.hookErrors ?? []),
|
||||
];
|
||||
line(bold("DIAGNOSIS"));
|
||||
for (const f of diagnose(events, agents, hookErrors)) line(` ${f}`);
|
||||
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
// ─── main ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const has = (f) => args.includes(f);
|
||||
const valueOf = (f) => {
|
||||
const i = args.indexOf(f);
|
||||
return i >= 0 ? args[i + 1] : null;
|
||||
};
|
||||
|
||||
// This is a CLI tool: its whole job is to write a report to stdout. ESLint's
|
||||
// no-console rule only permits warn/error, so write to stdout directly.
|
||||
const print = (s = "") => process.stdout.write(s + "\n");
|
||||
|
||||
if (has("--list")) {
|
||||
const sessions = listSessions();
|
||||
if (!sessions.length) {
|
||||
print("No sessions found under " + WORKTREE_ROOT);
|
||||
process.exit(0);
|
||||
}
|
||||
print(bold("Sessions (newest first):"));
|
||||
for (const s of sessions)
|
||||
print(
|
||||
` ${s.id} ${dim(new Date(s.mtime).toISOString().slice(0, 19).replace("T", " "))}`,
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const sessionId = valueOf("--session") || listSessions()[0]?.id;
|
||||
if (!sessionId) {
|
||||
console.error(
|
||||
red(
|
||||
`No session found under ${WORKTREE_ROOT}. Start a harness session first (ask Claude Code for a change).`,
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (has("--watch")) {
|
||||
// Use the terminal's alternate screen buffer (like top/htop/less): the view
|
||||
// redraws in place and never pollutes the scrollback. We restore the normal
|
||||
// screen on exit. The timeline is capped to the visible height so the newest
|
||||
// events are always on screen instead of scrolling off the top.
|
||||
const enter = () => process.stdout.write("\x1b[?1049h\x1b[?25l");
|
||||
const leave = () => process.stdout.write("\x1b[?1049l\x1b[?25h");
|
||||
const tick = () => {
|
||||
const limit = Math.max(8, (process.stdout.rows || 40) - 18); // leave room for header/agents
|
||||
process.stdout.write("\x1b[H\x1b[J"); // home + clear to end
|
||||
process.stdout.write(render(sessionId, limit));
|
||||
process.stdout.write(
|
||||
"\n" + dim(`watching ${sessionId} — refresh 1.5s — Ctrl-C to quit`),
|
||||
);
|
||||
};
|
||||
enter();
|
||||
tick();
|
||||
const iv = setInterval(tick, 1500);
|
||||
const quit = () => {
|
||||
clearInterval(iv);
|
||||
leave();
|
||||
process.exit(0);
|
||||
};
|
||||
process.on("SIGINT", quit);
|
||||
process.on("SIGTERM", quit);
|
||||
} else {
|
||||
print(render(sessionId));
|
||||
}
|
||||
290
.claude/scripts/harness-revert.mjs
Executable file
290
.claude/scripts/harness-revert.mjs
Executable file
@@ -0,0 +1,290 @@
|
||||
#!/usr/bin/env node
|
||||
//
|
||||
// Per-session revert for the agent harness.
|
||||
//
|
||||
// Undoes ONE harness session's promoted work and removes its artifacts, leaving
|
||||
// every other (possibly parallel) session untouched. Replaces a global hard-reset:
|
||||
// instead of resetting the branch to a single fork anchor (which would wipe a
|
||||
// parallel session that landed on the same branch), it reverts only THIS session's
|
||||
// promotion merge commits, keyed by SESSION_SHORT_ID.
|
||||
//
|
||||
// Driven by the `/harness-revert` slash command (portable across CLI / VS Code
|
||||
// extension / desktop), which resolves the short id and confirms before calling
|
||||
// this. It can also be run directly.
|
||||
//
|
||||
// How a session lands on the base branch: the merger promotes with
|
||||
// git merge --no-ff session/<short> -m "merge(session): <short>"
|
||||
// (see .claude/agents/merger.md, Stage B). So this session's footprint on the base
|
||||
// branch is exactly the merge commits whose subject is "merge(session): <short>".
|
||||
//
|
||||
// Usage:
|
||||
// .claude/scripts/harness-revert.mjs <SESSION_SHORT_ID> # revert + clean up
|
||||
// .claude/scripts/harness-revert.mjs <SESSION_SHORT_ID> --dry-run # print, change nothing
|
||||
//
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, readdirSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const DRY_RUN = argv.includes("--dry-run") || argv.includes("-n");
|
||||
const HARD = argv.includes("--hard");
|
||||
const FORCE = argv.includes("--force");
|
||||
const SHORT = argv.find((a) => !a.startsWith("-"));
|
||||
|
||||
const log = (msg) => process.stdout.write(`${msg}\n`);
|
||||
const err = (msg) => process.stderr.write(`${msg}\n`);
|
||||
|
||||
if (!SHORT) {
|
||||
err(
|
||||
"usage: harness-revert.mjs <SESSION_SHORT_ID> [--hard] [--force] [--dry-run]",
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// Run git, capturing output. Returns { code, out, err }.
|
||||
function git(args) {
|
||||
const res = spawnSync("git", args, { encoding: "utf8" });
|
||||
return {
|
||||
code: res.status ?? (res.error ? 1 : 0),
|
||||
out: (res.stdout || "").trim(),
|
||||
err: (res.stderr || "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
// Run a mutating git command, honouring --dry-run.
|
||||
function gitRun(args) {
|
||||
if (DRY_RUN) {
|
||||
log(`[dry-run] git ${args.join(" ")}`);
|
||||
return { code: 0, out: "", err: "" };
|
||||
}
|
||||
return git(args);
|
||||
}
|
||||
|
||||
// Repo root (authoritative via git; falls back to env/cwd).
|
||||
const top = git(["rev-parse", "--show-toplevel"]);
|
||||
const REPO_ROOT =
|
||||
(top.code === 0 && top.out) ||
|
||||
process.env.CLAUDE_PROJECT_DIR ||
|
||||
process.cwd();
|
||||
process.chdir(REPO_ROOT);
|
||||
|
||||
const CURRENT_BRANCH = git(["rev-parse", "--abbrev-ref", "HEAD"]).out;
|
||||
const BASE_BRANCH = git([
|
||||
"config",
|
||||
"--local",
|
||||
"--get",
|
||||
`sessionbase.${SHORT}.branch`,
|
||||
]).out;
|
||||
|
||||
log(`==> Session: ${SHORT}`);
|
||||
log(`==> Base branch: ${BASE_BRANCH || "(unknown — cleanup only)"}`);
|
||||
if (DRY_RUN) log("==> Mode: DRY-RUN (no changes)");
|
||||
|
||||
if (HARD)
|
||||
log("==> Mode: HARD (reset to fork — drops the session's commits)");
|
||||
|
||||
// Guard: undoing commits requires being ON the base branch the session promoted to.
|
||||
const requireOnBase = () => {
|
||||
if (BASE_BRANCH && CURRENT_BRANCH !== BASE_BRANCH) {
|
||||
err(
|
||||
`!! You are on '${CURRENT_BRANCH}' but session ${SHORT} promoted onto '${BASE_BRANCH}'.`,
|
||||
);
|
||||
err(` Switch to it first: git checkout ${BASE_BRANCH}`);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
// Never let an undo clobber uncommitted work.
|
||||
const stashDirty = () => {
|
||||
if (git(["status", "--porcelain", "--untracked-files=no"]).out) {
|
||||
log("==> Stashing uncommitted changes (recover with: git stash pop)");
|
||||
gitRun(["stash", "push", "-m", `harness-revert ${SHORT} safety stash`]);
|
||||
}
|
||||
};
|
||||
|
||||
// Undo the session's promoted work. Two strategies:
|
||||
// default → `git revert` the promotion merge commit(s): keeps history, adds a
|
||||
// revert commit, SAFE even if other sessions landed on the same branch
|
||||
// afterwards (it only backs out THIS session's changes).
|
||||
// --hard → `git reset --hard` to the session's fork point (session-base/<short>):
|
||||
// drops the session's commits ENTIRELY (clean history — no merge, no
|
||||
// revert commit), but ALSO drops anything else committed after the fork.
|
||||
// Use it when this session is the only thing on the branch (e.g. demos).
|
||||
if (HARD) {
|
||||
// Resolve the fork anchor NOW, before the cleanup section deletes it below.
|
||||
const anchor = `session-base/${SHORT}`;
|
||||
const fork = git(["rev-parse", "--verify", `refs/heads/${anchor}`]);
|
||||
if (fork.code !== 0 || !fork.out) {
|
||||
err(
|
||||
`!! No fork anchor '${anchor}' — can't hard-reset. Run without --hard to revert instead.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const forkSha = fork.out;
|
||||
requireOnBase();
|
||||
if (git(["merge-base", "--is-ancestor", forkSha, "HEAD"]).code !== 0) {
|
||||
err(
|
||||
`!! ${anchor} (${forkSha.slice(0, 12)}) is not an ancestor of HEAD — history diverged, refusing to hard-reset. Use the default (revert) mode.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
// Safety: `reset --hard` drops EVERYTHING in forkSha..HEAD, not just the
|
||||
// session. The session's own footprint here is its promotion merge(s) plus the
|
||||
// commits reachable from session/<short>; anything else is UNRELATED work (e.g.
|
||||
// commits you made on the base branch after the session forked) that --hard
|
||||
// would silently destroy. Refuse when such collateral exists, unless --force.
|
||||
const sessionRef = `session/${SHORT}`;
|
||||
const hasSessionBranch =
|
||||
git(["show-ref", "--verify", "--quiet", `refs/heads/${sessionRef}`])
|
||||
.code === 0;
|
||||
const collateral = git(["rev-list", `${forkSha}..HEAD`])
|
||||
.out.split("\n")
|
||||
.filter(Boolean)
|
||||
.filter((sha) => {
|
||||
if (
|
||||
git(["show", "-s", "--format=%s", sha]).out ===
|
||||
`merge(session): ${SHORT}`
|
||||
)
|
||||
return false; // this session's promotion merge
|
||||
if (
|
||||
hasSessionBranch &&
|
||||
git(["merge-base", "--is-ancestor", sha, sessionRef]).code === 0
|
||||
)
|
||||
return false; // brought in by the session
|
||||
return true; // unrelated commit
|
||||
});
|
||||
if (collateral.length && !FORCE) {
|
||||
err(
|
||||
`!! reset --hard to ${anchor} would ALSO drop ${collateral.length} commit(s) NOT part of session ${SHORT}:`,
|
||||
);
|
||||
for (const sha of collateral.slice(0, 20))
|
||||
err(` ${git(["show", "-s", "--format=%h %s", sha]).out}`);
|
||||
if (collateral.length > 20)
|
||||
err(` ... and ${collateral.length - 20} more`);
|
||||
err(
|
||||
` These would be permanently removed from ${CURRENT_BRANCH}. Use the default (revert) mode (drop --hard) - it never touches unrelated commits - or re-run with --force if you really mean to reset to the fork point.`,
|
||||
);
|
||||
if (!DRY_RUN) process.exit(1);
|
||||
}
|
||||
stashDirty();
|
||||
log(
|
||||
`==> Hard-resetting ${CURRENT_BRANCH} to ${anchor} (${forkSha.slice(0, 12)}) — the session disappears from history`,
|
||||
);
|
||||
const r = gitRun(["reset", "--hard", forkSha]);
|
||||
if (r.code !== 0) {
|
||||
err(`!! reset --hard failed: ${r.err}`);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
// Find this session's promotion merge commits on the base branch (newest first,
|
||||
// so reverting in order unwinds the stack cleanly).
|
||||
let merges = [];
|
||||
if (
|
||||
BASE_BRANCH &&
|
||||
git(["show-ref", "--verify", "--quiet", `refs/heads/${BASE_BRANCH}`])
|
||||
.code === 0
|
||||
) {
|
||||
const subject = `merge(session): ${SHORT}`;
|
||||
merges = git(["log", "--merges", "--format=%H%x09%s", BASE_BRANCH])
|
||||
.out.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => line.split("\t"))
|
||||
.filter(([, subj]) => subj === subject)
|
||||
.map(([sha]) => sha);
|
||||
}
|
||||
if (merges.length === 0) {
|
||||
log(
|
||||
`==> No promotion merge found for session ${SHORT} — nothing to revert (cleanup only).`,
|
||||
);
|
||||
} else {
|
||||
requireOnBase();
|
||||
stashDirty();
|
||||
// All-or-nothing: capture HEAD before the first revert so that if a later
|
||||
// merge conflicts we can roll the already-applied reverts back. Otherwise a
|
||||
// partial run leaves some reverts committed while the original merges still
|
||||
// match the subject filter, and a naive "resolve and re-run" would re-revert
|
||||
// the already-reverted merges (revert-of-revert re-applies the session's
|
||||
// changes). Resetting to preRevertHead keeps re-run idempotent.
|
||||
const preRevertHead = DRY_RUN ? "" : git(["rev-parse", "HEAD"]).out;
|
||||
for (const sha of merges) {
|
||||
log(`==> Reverting promotion merge ${sha.slice(0, 12)}`);
|
||||
const r = gitRun(["revert", "--no-edit", "-m", "1", sha]);
|
||||
if (r.code !== 0) {
|
||||
gitRun(["revert", "--abort"]);
|
||||
if (!DRY_RUN && preRevertHead)
|
||||
gitRun(["reset", "--hard", preRevertHead]);
|
||||
err(
|
||||
`!! Revert hit a conflict on ${sha.slice(0, 12)} (another session may have touched the same files).`,
|
||||
);
|
||||
err(
|
||||
" Rolled back to the pre-revert state — nothing was reverted or removed. Resolve the conflict source, then re-run.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Remove this session's worktrees (matched by their checked-out branch, since
|
||||
// the worktree path carries the full session id we don't have here).
|
||||
const worktrees = [];
|
||||
{
|
||||
const lines = git(["worktree", "list", "--porcelain"]).out.split("\n");
|
||||
let wt = "";
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("worktree ")) wt = line.slice("worktree ".length);
|
||||
else if (line.startsWith("branch ")) {
|
||||
const br = line.slice("branch refs/heads/".length);
|
||||
if (br === `session/${SHORT}` || br.startsWith(`${SHORT}/`))
|
||||
worktrees.push(wt);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const wt of worktrees) {
|
||||
log(`==> Removing worktree ${wt}`);
|
||||
gitRun(["worktree", "remove", "--force", wt]);
|
||||
}
|
||||
gitRun(["worktree", "prune"]);
|
||||
|
||||
// 4. Delete this session's branches (task/simple, integration, fork anchor).
|
||||
const branches = git([
|
||||
"for-each-ref",
|
||||
"--format=%(refname:short)",
|
||||
`refs/heads/${SHORT}/*`,
|
||||
`refs/heads/session/${SHORT}`,
|
||||
`refs/heads/session-base/${SHORT}`,
|
||||
])
|
||||
.out.split("\n")
|
||||
.filter(Boolean);
|
||||
for (const br of branches) {
|
||||
if (br === CURRENT_BRANCH) {
|
||||
log(` ! skip current branch: ${br}`);
|
||||
continue;
|
||||
}
|
||||
log(`==> Deleting branch ${br}`);
|
||||
gitRun(["branch", "-D", br]);
|
||||
}
|
||||
|
||||
// 5. Remove the fork-base config subsection.
|
||||
log(`==> Removing config sessionbase.${SHORT}`);
|
||||
gitRun(["config", "--local", "--remove-section", `sessionbase.${SHORT}`]);
|
||||
|
||||
// 6. Remove this session's /tmp dir(s). Worktree base is /tmp/<slug>/<full-id>;
|
||||
// the full id is <short>-<rest>, so match on the short id at the UUID-segment
|
||||
// boundary (exact, or followed by "-"). A bare startsWith(SHORT) would also
|
||||
// swallow another session whose id merely shares the leading hex block.
|
||||
const slug = REPO_ROOT.replace(/\//g, "_");
|
||||
const tmpBase = `/tmp/${slug}`;
|
||||
if (existsSync(tmpBase)) {
|
||||
for (const name of readdirSync(tmpBase)) {
|
||||
if (name !== SHORT && !name.startsWith(`${SHORT}-`)) continue;
|
||||
const dir = join(tmpBase, name);
|
||||
log(`==> Removing session dir ${dir}`);
|
||||
if (DRY_RUN) log(`[dry-run] rm -rf ${dir}`);
|
||||
else rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: .promote.lock is intentionally left in place — it is shared across
|
||||
// sessions and removing it could break a concurrent promotion's mutual exclusion.
|
||||
|
||||
log("==> Done.");
|
||||
74
.claude/scripts/harness-statusline.mjs
Executable file
74
.claude/scripts/harness-statusline.mjs
Executable file
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env node
|
||||
// Claude Code statusLine command: one compact line summarizing the active
|
||||
// #technical-harness run, read from <REPO>/.harness/<short>/status.json (written
|
||||
// by the render-status SubagentStop hook). Prints nothing when no harness run is
|
||||
// active, so it is safe to leave configured. The statusLine payload JSON arrives
|
||||
// on stdin; we need session_id to find the right session's board.
|
||||
//
|
||||
// NOT wired into the committed settings.json on purpose: statusLine is a personal
|
||||
// choice and a project-level one would override your own global statusline (same
|
||||
// reasoning as the opt-in concise-dev output style in AGENTS.md). To use it, add
|
||||
// to your own .claude/settings.local.json:
|
||||
//
|
||||
// "statusLine": {
|
||||
// "type": "command",
|
||||
// "command": "$CLAUDE_PROJECT_DIR/.claude/scripts/harness-statusline.mjs"
|
||||
// }
|
||||
//
|
||||
// To KEEP your existing statusline as well, point HARNESS_STATUSLINE_BASE at its
|
||||
// command: this script runs it (same stdin), then appends the harness segment.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
import { REPO } from "../hooks/lib/paths.mjs";
|
||||
|
||||
let raw = "";
|
||||
try {
|
||||
raw = readFileSync(0, "utf8");
|
||||
} catch {
|
||||
raw = "";
|
||||
}
|
||||
let payload = {};
|
||||
try {
|
||||
payload = JSON.parse(raw);
|
||||
} catch {
|
||||
payload = {};
|
||||
}
|
||||
|
||||
const sessionId =
|
||||
payload.session_id || process.env.CLAUDE_CODE_SESSION_ID || "";
|
||||
const short = sessionId.split("-")[0];
|
||||
|
||||
// Optional: compose with an existing statusline command.
|
||||
let prefix = "";
|
||||
const base = process.env.HARNESS_STATUSLINE_BASE;
|
||||
if (base) {
|
||||
try {
|
||||
prefix = execSync(base, { input: raw, encoding: "utf8" }).trim();
|
||||
} catch {
|
||||
prefix = "";
|
||||
}
|
||||
}
|
||||
|
||||
function harnessSegment(shortId) {
|
||||
if (!shortId) return "";
|
||||
let s;
|
||||
try {
|
||||
s = JSON.parse(
|
||||
readFileSync(join(REPO, ".harness", shortId, "status.json"), "utf8"),
|
||||
);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
const t = s.tickets || {};
|
||||
const parts = [`⚙ ${t.merged ?? 0}/${t.total ?? 0}`];
|
||||
const active = (s.active || []).map((a) => `▸${a.task}`).join(" ");
|
||||
if (active) parts.push(active);
|
||||
if (s.last) parts.push(String(s.last).slice(0, 40));
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
const out = [prefix, harnessSegment(short)].filter(Boolean).join(" ");
|
||||
// A CLI status command whose job is to print one line to stdout.
|
||||
if (out) process.stdout.write(out + "\n");
|
||||
160
.claude/scripts/pending-deploys.mjs
Executable file
160
.claude/scripts/pending-deploys.mjs
Executable file
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env node
|
||||
// pending-deploys: decide whether the session has deploy-relevant changes not
|
||||
// yet covered by the migrations dir.
|
||||
//
|
||||
// pending-deploys --app <APP_DIR> --session <SESSION_SHORT>
|
||||
//
|
||||
// Prints a non-empty marker (the changed deploy-relevant paths) when a deploy is
|
||||
// worth offering; prints nothing otherwise.
|
||||
//
|
||||
// Exit codes:
|
||||
// 0 - decision made (stdout empty = nothing to deploy, non-empty = paths).
|
||||
// 3 - could NOT decide: --session missing, or the passed <SESSION_SHORT>
|
||||
// has session-base siblings but no refs of its own (likely a mismatch).
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
loadConfig,
|
||||
isDeployEnabled,
|
||||
deployGlobs,
|
||||
} from "../hooks/lib/config.mjs";
|
||||
|
||||
// Convert a deploy-relevant glob (config.deploy.relevantGlobs) into an anchored
|
||||
// regex source. `**/` -> optional dir prefix, `**` -> any, `*` -> non-slash. The
|
||||
// git diff paths are repo-relative, so anchor at the start.
|
||||
export function globToRegexSource(glob) {
|
||||
const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
||||
// Placeholder the glob operators FIRST, expand `*` last, then swap the
|
||||
// placeholders in. Otherwise the `.*` inserted for `**` would be re-mangled by
|
||||
// the single-`*` -> `[^/]*` pass.
|
||||
const body = escaped
|
||||
.replace(/\*\*\//g, "\0DS\0")
|
||||
.replace(/\*\*/g, "\0D\0")
|
||||
.replace(/\*/g, "[^/]*")
|
||||
.replace(/\0DS\0/g, "(?:.*/)?")
|
||||
.replace(/\0D\0/g, ".*");
|
||||
return `^${body}`;
|
||||
}
|
||||
|
||||
// The single deploy-relevance matcher, built from config.deploy.relevantGlobs
|
||||
// (one definition, shared with the orchestrator's SIMPLE-review gate and
|
||||
// the planner's schema wording). Empty globs -> matches nothing.
|
||||
export function relevanceRegex(globs) {
|
||||
if (!globs.length) return /a^/; // never matches
|
||||
return new RegExp(globs.map(globToRegexSource).join("|"));
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const get = (flag, def) => {
|
||||
const i = args.indexOf(flag);
|
||||
return i >= 0 ? args[i + 1] : def;
|
||||
};
|
||||
const APP = get("--app", process.env.CLAUDE_PROJECT_DIR || "/app");
|
||||
const SESSION = get("--session", "");
|
||||
|
||||
// Pluggable deploy phase: the migration round exists ONLY if this
|
||||
// project configures a deploy adapter. With no config.deploy, there is nothing
|
||||
// to deploy, so exit 0 (empty) and the orchestrator terminates at promotion
|
||||
// with no PD state.
|
||||
let cfg;
|
||||
try {
|
||||
cfg = loadConfig(APP);
|
||||
} catch (e) {
|
||||
process.stderr.write(`pending-deploys: ${e.message}\n`);
|
||||
process.exit(3);
|
||||
}
|
||||
if (!isDeployEnabled(cfg)) process.exit(0);
|
||||
|
||||
if (!SESSION) {
|
||||
// Couldn't decide - exit 3, never 0 (0 + empty stdout reads as "nothing to
|
||||
// deploy", which would silently skip a real pending deploy).
|
||||
process.stderr.write("--session <SESSION_SHORT> required\n");
|
||||
process.exit(3);
|
||||
}
|
||||
|
||||
const refExists = (ref) => {
|
||||
try {
|
||||
execFileSync("git", ["-C", APP, "show-ref", "--verify", "--quiet", ref], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Guard against the SESSION_SHORT_ID mismatch. The session topology
|
||||
// (session-base/<id> + session/<id>) is created at session start, so missing
|
||||
// refs for the requested id are suspicious. Distinguish two cases:
|
||||
// - NO session-base/* branch exists at all -> no topology (e.g. hooks never
|
||||
// ran, or a non-session context) -> genuinely nothing to deploy -> exit 0.
|
||||
// - session-base/* branches exist but NOT for <SESSION> -> the caller passed
|
||||
// the wrong short id -> fail loudly so the deploy isn't silently skipped.
|
||||
if (
|
||||
!refExists(`refs/heads/session-base/${SESSION}`) ||
|
||||
!refExists(`refs/heads/session/${SESSION}`)
|
||||
) {
|
||||
let bases = "";
|
||||
try {
|
||||
bases = execFileSync(
|
||||
"git",
|
||||
[
|
||||
"-C",
|
||||
APP,
|
||||
"for-each-ref",
|
||||
"--format=%(refname:short)",
|
||||
"refs/heads/session-base/",
|
||||
],
|
||||
{ stdio: ["pipe", "pipe", "pipe"] },
|
||||
)
|
||||
.toString()
|
||||
.trim();
|
||||
} catch {
|
||||
/* not a git repo / no refs - treated as no topology below */
|
||||
}
|
||||
if (bases) {
|
||||
process.stderr.write(
|
||||
`pending-deploys: no session refs for '${SESSION}', but found: ${bases.split("\n").join(", ")}. ` +
|
||||
`Likely a SESSION_SHORT_ID mismatch - refusing to report "nothing to deploy".\n`,
|
||||
);
|
||||
process.exit(3);
|
||||
}
|
||||
process.exit(0); // no session topology anywhere -> nothing to deploy
|
||||
}
|
||||
|
||||
// Deploy-relevance from config.deploy.relevantGlobs: one definition of
|
||||
// "deploy-relevant paths", not a hardcoded regex. A real migration requires a
|
||||
// change under these globs (the DDL source of truth), so this is both
|
||||
// necessary and sufficient to decide whether to offer the migration round.
|
||||
const SCHEMA_RE = relevanceRegex(deployGlobs(cfg));
|
||||
|
||||
let changed = "";
|
||||
try {
|
||||
changed = execFileSync(
|
||||
"git",
|
||||
[
|
||||
"-C",
|
||||
APP,
|
||||
"diff",
|
||||
"--name-only",
|
||||
`session-base/${SESSION}..session/${SESSION}`,
|
||||
],
|
||||
{ stdio: ["pipe", "pipe", "pipe"] },
|
||||
).toString();
|
||||
} catch (e) {
|
||||
process.stderr.write(
|
||||
`pending-deploys: git diff failed for '${SESSION}': ${e.message}\n`,
|
||||
);
|
||||
process.exit(3);
|
||||
}
|
||||
|
||||
const relevant = changed
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.filter((p) => SCHEMA_RE.test(p));
|
||||
// (Idempotency against already-applied schema is enforced later by the
|
||||
// migration round, which cross-checks the migrations dir and may no-op.)
|
||||
if (relevant.length) process.stdout.write(`${relevant.join("\n")}\n`);
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) main();
|
||||
24
.claude/scripts/playwright-mcp.sh
Executable file
24
.claude/scripts/playwright-mcp.sh
Executable file
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
# Launch @playwright/mcp against the repo's BUNDLED Chromium (the one
|
||||
# `make install-playwright-browsers` provisions and the Playwright e2e suite already
|
||||
# uses), instead of the MCP's default `chrome` channel (/opt/google/chrome/chrome),
|
||||
# which provisioning never installs. Without this the feature-smoke reviewer's first
|
||||
# navigate fails with "Chromium distribution 'chrome' is not found" and only works if
|
||||
# someone symlinks the bundled binary by hand.
|
||||
#
|
||||
# The bundled path is version-pinned (chromium-<rev>), so resolve it dynamically via
|
||||
# playwright-core rather than hardcoding it in .mcp.json. If resolution fails (e.g.
|
||||
# playwright-core absent), fall back to the MCP default so behavior is no worse than before.
|
||||
set -euo pipefail
|
||||
|
||||
REPO="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
cd "$REPO"
|
||||
|
||||
EXE="$(node -e "console.log(require('playwright-core').chromium.executablePath())" 2>/dev/null || true)"
|
||||
|
||||
ARGS=(node_modules/@playwright/mcp/cli.js --headless --isolated)
|
||||
if [ -n "$EXE" ] && [ -f "$EXE" ]; then
|
||||
ARGS+=(--executable-path "$EXE")
|
||||
fi
|
||||
|
||||
exec node "${ARGS[@]}" "$@"
|
||||
73
.claude/scripts/test/harness-revert.test.mjs
Normal file
73
.claude/scripts/test/harness-revert.test.mjs
Normal file
@@ -0,0 +1,73 @@
|
||||
// Tests for harness-revert.mjs --hard collateral guard: `reset --hard` to the
|
||||
// session fork point must NOT silently drop commits that are not part of the
|
||||
// session (e.g. the user's own commits made on the base branch after the fork).
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const SCRIPT = join(HERE, "..", "harness-revert.mjs");
|
||||
const SHORT = "beef1234";
|
||||
|
||||
let TMP = null;
|
||||
afterEach(() => {
|
||||
if (TMP) rmSync(TMP, { recursive: true, force: true });
|
||||
TMP = null;
|
||||
});
|
||||
|
||||
// A repo forked at `base`, a session branch with one change, and one UNRELATED
|
||||
// commit on main made AFTER the fork (the collateral --hard would drop).
|
||||
const setup = () => {
|
||||
TMP = mkdtempSync(join(tmpdir(), "harness-revert-test-"));
|
||||
const app = join(TMP, "app");
|
||||
const g = (...a) => spawnSync("git", ["-C", app, ...a], { encoding: "utf8" });
|
||||
spawnSync("git", ["init", "-q", "-b", "main", app]);
|
||||
g("config", "user.email", "t@t.t");
|
||||
g("config", "user.name", "t");
|
||||
writeFileSync(join(app, "file.txt"), "base\n");
|
||||
g("add", ".");
|
||||
g("commit", "-qm", "base");
|
||||
const forkSha = g("rev-parse", "HEAD").stdout.trim();
|
||||
g("branch", `session-base/${SHORT}`);
|
||||
g("branch", `session/${SHORT}`);
|
||||
// session work lands on the session branch (never merged to main here)
|
||||
g("checkout", "-q", `session/${SHORT}`);
|
||||
writeFileSync(join(app, "file.txt"), "session change\n");
|
||||
g("add", ".");
|
||||
g("commit", "-qm", "session work");
|
||||
g("checkout", "-q", "main");
|
||||
// the user's unrelated commit on main, AFTER the fork
|
||||
writeFileSync(join(app, "unrelated.txt"), "my own work\n");
|
||||
g("add", ".");
|
||||
g("commit", "-qm", "unrelated commit");
|
||||
g("config", "--local", `sessionbase.${SHORT}.branch`, "main");
|
||||
|
||||
const head = () => g("rev-parse", "HEAD").stdout.trim();
|
||||
const run = (...args) =>
|
||||
spawnSync("node", [SCRIPT, SHORT, ...args], { cwd: app, encoding: "utf8" });
|
||||
return { app, forkSha, head, run };
|
||||
};
|
||||
|
||||
describe("harness-revert --hard collateral guard", () => {
|
||||
test("refuses --hard when it would drop non-session commits", () => {
|
||||
const { head, run } = setup();
|
||||
const before = head();
|
||||
const r = run("--hard");
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr).toMatch(/NOT part of session/);
|
||||
expect(r.stderr).toContain("unrelated commit");
|
||||
// HEAD untouched — nothing was reset.
|
||||
expect(head()).toBe(before);
|
||||
});
|
||||
|
||||
test("--force lets --hard reset to the fork point", () => {
|
||||
const { forkSha, head, run } = setup();
|
||||
const r = run("--hard", "--force");
|
||||
expect(r.status).toBe(0);
|
||||
expect(head()).toBe(forkSha);
|
||||
});
|
||||
});
|
||||
171
.claude/scripts/test/pending-deploys.test.mjs
Normal file
171
.claude/scripts/test/pending-deploys.test.mjs
Normal file
@@ -0,0 +1,171 @@
|
||||
// Tests for .claude/scripts/pending-deploys.mjs: decides whether a session has
|
||||
// deploy-relevant changes worth a migration round. Config-driven:
|
||||
// the round exists iff config.deploy is present, and relevance comes from
|
||||
// config.deploy.relevantGlobs (one definition, unified with the orchestrator's
|
||||
// SIMPLE-review gate).
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { globToRegexSource, relevanceRegex } from "../pending-deploys.mjs";
|
||||
|
||||
const SCRIPT = new URL("../pending-deploys.mjs", import.meta.url).pathname;
|
||||
|
||||
function git(cwd, ...args) {
|
||||
return execFileSync("git", args, { cwd }).toString();
|
||||
}
|
||||
|
||||
// A repo with a Supabase deploy adapter configured (so the deploy gate is open),
|
||||
// a declarative schema file, and the session topology branches.
|
||||
function setupRepo({ deploy = true } = {}) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "pd-"));
|
||||
git(dir, "init", "-q", "-b", "main");
|
||||
git(dir, "config", "user.email", "t@t.t");
|
||||
git(dir, "config", "user.name", "t");
|
||||
mkdirSync(join(dir, "src"), { recursive: true });
|
||||
mkdirSync(join(dir, "supabase/schemas"), { recursive: true });
|
||||
mkdirSync(join(dir, "supabase/migrations"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(dir, "src/types.ts"),
|
||||
"export type Contact = { id: string };\n",
|
||||
);
|
||||
writeFileSync(
|
||||
join(dir, "supabase/schemas/01_tables.sql"),
|
||||
"create table contacts (id uuid primary key);\n",
|
||||
);
|
||||
const config = { validation: { steps: [] }, roles: {} };
|
||||
if (deploy)
|
||||
config.deploy = { adapter: "supabase", relevantGlobs: ["supabase/**"] };
|
||||
writeFileSync(join(dir, "harness.config.json"), JSON.stringify(config));
|
||||
git(dir, "add", ".");
|
||||
git(dir, "commit", "-qm", "seed");
|
||||
git(dir, "branch", "session-base/ab12cd34", "main");
|
||||
git(dir, "branch", "session/ab12cd34", "main");
|
||||
return dir;
|
||||
}
|
||||
|
||||
const run = (dir, ...args) =>
|
||||
execFileSync("node", [SCRIPT, "--app", dir, ...args])
|
||||
.toString()
|
||||
.trim();
|
||||
|
||||
describe("pending-deploys relevance", () => {
|
||||
test("globToRegexSource anchors and expands ** / *", () => {
|
||||
expect(globToRegexSource("supabase/**")).toBe("^supabase/.*");
|
||||
expect(globToRegexSource("**/types.ts")).toBe("^(?:.*/)?types\\.ts");
|
||||
});
|
||||
|
||||
test("relevanceRegex matches configured paths, not others", () => {
|
||||
const re = relevanceRegex(["supabase/**"]);
|
||||
expect(re.test("supabase/schemas/01_tables.sql")).toBe(true);
|
||||
expect(re.test("src/types.ts")).toBe(false);
|
||||
expect(relevanceRegex([]).test("supabase/schemas/x.sql")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pending-deploys", () => {
|
||||
test("empty when session branch made no deploy-relevant change", () => {
|
||||
const dir = setupRepo();
|
||||
expect(run(dir, "--session", "ab12cd34")).toBe("");
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("non-empty when the session branch changes the declarative schema", () => {
|
||||
const dir = setupRepo();
|
||||
git(dir, "checkout", "-q", "session/ab12cd34");
|
||||
writeFileSync(
|
||||
join(dir, "supabase/schemas/01_tables.sql"),
|
||||
"create table contacts (id uuid primary key, priority int);\n",
|
||||
);
|
||||
git(dir, "add", ".");
|
||||
git(dir, "commit", "-qm", "add priority column");
|
||||
git(dir, "checkout", "-q", "main");
|
||||
expect(run(dir, "--session", "ab12cd34")).not.toBe("");
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("a TS-only change (no schema file) is NOT deploy-relevant (unified definition)", () => {
|
||||
const dir = setupRepo();
|
||||
git(dir, "checkout", "-q", "session/ab12cd34");
|
||||
writeFileSync(
|
||||
join(dir, "src/types.ts"),
|
||||
"export type Contact = { id: string; priority: number };\n",
|
||||
);
|
||||
git(dir, "add", ".");
|
||||
git(dir, "commit", "-qm", "add priority to type only");
|
||||
git(dir, "checkout", "-q", "main");
|
||||
expect(run(dir, "--session", "ab12cd34")).toBe("");
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("no config.deploy -> exit 0 empty (deploy phase not configured)", () => {
|
||||
const dir = setupRepo({ deploy: false });
|
||||
git(dir, "checkout", "-q", "session/ab12cd34");
|
||||
writeFileSync(
|
||||
join(dir, "supabase/schemas/01_tables.sql"),
|
||||
"create table contacts (id uuid primary key, x int);\n",
|
||||
);
|
||||
git(dir, "add", ".");
|
||||
git(dir, "commit", "-qm", "schema change but no deploy adapter");
|
||||
git(dir, "checkout", "-q", "main");
|
||||
expect(run(dir, "--session", "ab12cd34")).toBe("");
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("empty and exit 0 when NO session topology exists at all (hooks never ran)", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "pd-"));
|
||||
git(dir, "init", "-q", "-b", "main");
|
||||
git(dir, "config", "user.email", "t@t.t");
|
||||
git(dir, "config", "user.name", "t");
|
||||
writeFileSync(join(dir, "x.ts"), "export const x = 1;\n");
|
||||
writeFileSync(
|
||||
join(dir, "harness.config.json"),
|
||||
JSON.stringify({
|
||||
validation: { steps: [] },
|
||||
roles: {},
|
||||
deploy: { adapter: "supabase", relevantGlobs: ["supabase/**"] },
|
||||
}),
|
||||
);
|
||||
git(dir, "add", ".");
|
||||
git(dir, "commit", "-qm", "seed");
|
||||
expect(run(dir, "--session", "xx99yy00")).toBe("");
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("exits 3 (could not decide) when --session is missing", () => {
|
||||
const dir = setupRepo();
|
||||
let code = 0,
|
||||
stderr = "";
|
||||
try {
|
||||
execFileSync("node", [SCRIPT, "--app", dir], {
|
||||
encoding: "utf8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
} catch (e) {
|
||||
code = e.status;
|
||||
stderr = (e.stderr || "").toString();
|
||||
}
|
||||
expect(code).toBe(3);
|
||||
expect(stderr).toMatch(/session/i);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("fails loudly (exit 3 + stderr) on SESSION_SHORT_ID mismatch", () => {
|
||||
const dir = setupRepo();
|
||||
let code = 0,
|
||||
stderr = "";
|
||||
try {
|
||||
execFileSync("node", [SCRIPT, "--app", dir, "--session", "xx99yy00"], {
|
||||
encoding: "utf8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
} catch (e) {
|
||||
code = e.status;
|
||||
stderr = (e.stderr || "").toString();
|
||||
}
|
||||
expect(code).toBe(3);
|
||||
expect(stderr).toMatch(/mismatch/i);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user