diff --git a/.claude/workflows/release.js b/.claude/workflows/release.js index 058db69..efb70c8 100644 --- a/.claude/workflows/release.js +++ b/.claude/workflows/release.js @@ -8,6 +8,7 @@ export const meta = { { title: 'Verify' }, { title: 'Generate' }, { title: 'Finalize' }, + { title: 'Cleanup' }, ], } @@ -87,6 +88,94 @@ const VERIFY_RESULT_SCHEMA = { required: ['allPass', 'failures', 'summary'], } +const WORKTREE_STATUS_SCHEMA = { + type: 'object', + properties: { + stale: { + type: 'array', + items: { + type: 'object', + properties: { + path: { type: 'string' }, + branch: { type: 'string' }, + }, + required: ['path', 'branch'], + }, + }, + active: { + type: 'array', + items: { + type: 'object', + properties: { + path: { type: 'string' }, + branch: { type: 'string' }, + }, + required: ['path', 'branch'], + }, + }, + }, + required: ['stale', 'active'], +} + +const PLAN_STATE_SCHEMA = { + type: 'object', + properties: { + tickedTasks: { type: 'number' }, + totalTasks: { type: 'number' }, + gitEvidence: { type: 'array', items: { type: 'string' } }, + }, + required: ['tickedTasks', 'totalTasks', 'gitEvidence'], +} + +const BRANCH_CHECK_SCHEMA = { + type: 'object', + properties: { + collisions: { type: 'array', items: { type: 'string' } }, + }, + required: ['collisions'], +} + +const VERSION_CHECK_SCHEMA = { + type: 'object', + properties: { + consistent: { type: 'boolean' }, + versions: { type: 'array', items: { type: 'string' } }, + conflicts: { type: 'array', items: { type: 'string' } }, + tagExists: { type: 'boolean' }, + }, + required: ['consistent', 'versions', 'conflicts', 'tagExists'], +} + +const CLEANUP_RESULT_SCHEMA = { + type: 'object', + properties: { + removed: { + type: 'array', + items: { + type: 'object', + properties: { + path: { type: 'string' }, + branch: { type: 'string' }, + }, + required: ['path', 'branch'], + }, + }, + kept: { + type: 'array', + items: { + type: 'object', + properties: { + path: { type: 'string' }, + branch: { type: 'string' }, + reason: { type: 'string' }, + }, + required: ['path', 'branch', 'reason'], + }, + }, + }, + required: ['removed', 'kept'], +} + // ── Helpers ─────────────────────────────────────────────────────────────────── const REPO = '/home/alee/Sources/relicario' @@ -103,6 +192,94 @@ const mode = (args && args.mode) || 'single' const release = args && args.release const context = args && args.context +// ── ACTION: preflight ───────────────────────────────────────────────────────── + +if (action === 'preflight') { + if (!release) throw new Error('args.release is required for action=preflight') + + const [worktrees, baseline, planState, branches] = await parallel([ + + () => agent( + `Run: git -C ${REPO} worktree list\n` + + `Parse the output. For each worktree listed, extract its path and branch.\n` + + `Skip the main checkout at ${REPO} itself.\n` + + `Then run: git -C ${REPO} branch --merged main\n` + + `A worktree is stale if its branch appears in the merged list. Otherwise it is active.\n` + + `Return stale (merged worktrees) and active (unmerged worktrees), each as an array of {path, branch}.`, + { schema: WORKTREE_STATUS_SCHEMA, label: 'worktree-scan', phase: 'Discover' } + ), + + () => agent( + `cd ${REPO} and run each of these commands, capturing the last 5 lines of output:\n` + + ` cargo test --quiet 2>&1 | tail -5\n` + + ` pnpm --filter extension test --run 2>&1 | tail -5\n` + + `Report allPass=true only if both commands exit with code 0. ` + + `List any failures with their error messages. Provide a one-line summary.`, + { schema: VERIFY_RESULT_SCHEMA, label: 'baseline-green', phase: 'Discover' } + ), + + () => agent( + `Run: git -C ${REPO} log --oneline --all --grep="${release}" | head -20\n` + + `Capture the output as gitEvidence.\n` + + `Then scan ${REPO}/docs/superpowers/plans/ for any files whose filename contains "${release}".\n` + + `For each matching file, count lines matching "- \\[x\\]" (ticked) and "- \\[ \\]" (unticked).\n` + + `Sum across all matching files. Return tickedTasks, totalTasks, and gitEvidence (the git log lines).`, + { schema: PLAN_STATE_SCHEMA, label: 'plan-state', phase: 'Discover' } + ), + + () => agent( + `Run: git -C ${REPO} branch --all\n` + + `Return any branch names (local or remote) that contain the string "${release}" as collisions.`, + { schema: BRANCH_CHECK_SCHEMA, label: 'branch-collision', phase: 'Discover' } + ), + + ]) + + const issues = [] + + if (worktrees.stale.length > 0) { + issues.push('orphaned-worktrees') + log(`WARN [worktree-scan]: ${worktrees.stale.length} stale worktree(s) found — run action=cleanup to remove them`) + for (const w of worktrees.stale) { + log(` stale: ${w.path} (${w.branch})`) + } + } else { + log(`[worktree-scan]: clean`) + } + + if (!baseline.allPass) { + issues.push('baseline-failing') + log(`FAIL [baseline-green]: ${baseline.failures.length} failure(s): ${baseline.failures.join(' | ')}`) + } else { + log(`[baseline-green]: green`) + } + + if (planState.tickedTasks > 0) { + issues.push('plan-partially-done') + log(`WARN [plan-state]: ${planState.tickedTasks}/${planState.totalTasks} tasks already ticked`) + for (const e of planState.gitEvidence) { + log(` evidence: ${e}`) + } + } else { + log(`[plan-state]: clean slate (0 ticked tasks)`) + } + + if (branches.collisions.length > 0) { + issues.push('branch-collision') + log(`WARN [branch-collision]: branches already exist for release label "${release}": ${branches.collisions.join(', ')}`) + } else { + log(`[branch-collision]: no collisions`) + } + + if (issues.length === 0) { + log(`Preflight PASS`) + } else { + log(`Preflight has ${issues.length} warning(s): ${issues.join(', ')}`) + } + + return { status: issues.length === 0 ? 'pass' : 'warn', issues, worktrees, baseline, planState, branches } +} + // ── ACTION: develop ─────────────────────────────────────────────────────────── if (action === 'develop') { @@ -156,6 +333,16 @@ if (action === 'develop') { ) ) + // ── Advisory: checkbox hygiene ─────────────────────────────────────────── + + await agent( + `Read each of these plan files from ${REPO}:\n${manifest.plans.map(p => ' ' + p).join('\n')}\n\n` + + `Count any lines still matching "- [ ]" (unticked checkboxes). ` + + `Log each unticked item with its file and line text. ` + + `This is advisory only — report findings but do not block or fail.`, + { label: 'checkbox-check', phase: 'Verify' } + ) + phase('Verify') const verifyResult = await agent( `Run the full Relicario test suite from ${REPO}. IMPORTANT: cd ${REPO} first.\n` + @@ -173,6 +360,21 @@ if (action === 'develop') { return { status: 'verify-failed', failures: verifyResult.failures, summary: verifyResult.summary } } + // ── Advisory: debug artifact scan ──────────────────────────────────────── + + await agent( + `Run the following command from ${REPO}:\n` + + ` git -C ${REPO} diff $(git -C ${REPO} describe --tags --abbrev=0)..HEAD\n\n` + + `Examine lines beginning with "+" (additions) in the diff output.\n` + + `Report any occurrences of:\n` + + ` - dbg!( in Rust files (warn)\n` + + ` - console.log( in TypeScript files (warn)\n` + + ` - TODO or FIXME anywhere (warn)\n` + + ` - .unwrap() in Rust files (advisory note only, not a hard warn)\n` + + `Log each finding with its file and line. This is advisory only — do not block.`, + { label: 'debug-artifact-scan', phase: 'Finalize' } + ) + phase('Finalize') await agent( `Update ${REPO}/STATUS.md to reflect the ${release} work that just completed.\n` + @@ -257,15 +459,45 @@ if (action === 'develop') { ]) - log(`Generated ${assignment.devCount + 1} prompt files in ${COORD_DIR}/`) - log(``) - log(`Next steps:`) - log(` 1. cd ${REPO}/tools/relay && ./start.sh`) - log(` 2. Open ${assignment.devCount + 1} terminal windows`) - log(` 3. PM terminal → paste ${COORD_DIR}/${release}-pm-prompt.md`) - assignment.devs.forEach(dev => { - log(` Dev-${dev.letter} terminal → paste ${COORD_DIR}/${release}-dev-${dev.letter.toLowerCase()}-prompt.md`) - }) + // Check relay, start if needed + await agent( + `Check if the relay server is running on localhost:7331 by running: ` + + `curl -sf http://127.0.0.1:7331/sse --max-time 2 > /dev/null 2>&1 && echo running || echo stopped\n\n` + + `If the output is "stopped", start it: ` + + `nohup npx tsx ${REPO}/tools/relay/server.ts > /tmp/relay-${release}.log 2>&1 &\n` + + `Then poll curl -sf http://127.0.0.1:7331/sse --max-time 1 once per second for up to 10s. ` + + `Report "relay ready" or "relay failed to start (check /tmp/relay-${release}.log)".`, + { label: 'relay-check', phase: 'Generate' } + ) + + await agent( + `Write a bash launch script to ${REPO}/${COORD_DIR}/${release}-launch.sh.\n\n` + + `Header comment: # Auto-generated by release workflow — ${release}\n` + + `set -e\n\n` + + `Section 1 — Relay health check and auto-start:\n` + + ` if curl -sf http://127.0.0.1:7331/sse --max-time 2 > /dev/null 2>&1; then\n` + + ` echo "[relay] already running"\n` + + ` else\n` + + ` echo "[relay] starting..." && nohup npx tsx ${REPO}/tools/relay/server.ts > /tmp/relay-${release}.log 2>&1 &\n` + + ` for i in $(seq 1 10); do sleep 1; curl -sf http://127.0.0.1:7331/sse --max-time 1 > /dev/null 2>&1 && echo "[relay] ready" && break || true; [ $i -eq 10 ] && echo "[relay] ERROR — check /tmp/relay-${release}.log" && exit 1; done\n` + + ` fi\n\n` + + `Section 2 — tmux session. Session name is the release label.\n` + + ` If tmux session already exists, attach and exit.\n` + + ` Otherwise create a new session, then for each role (pm + each dev letter) create a named window\n` + + ` that runs: claude\n` + + ` After creating windows, print a prompt-paste cheatsheet showing which file to paste in each window.\n` + + ` Then attach to the session.\n\n` + + `Devs: ${assignment.devs.map(d => 'Dev-' + d.letter).join(', ')}\n` + + `Prompt files in ${COORD_DIR}:\n` + + ` PM: ${release}-pm-prompt.md\n` + + assignment.devs.map(d => ` Dev-${d.letter}: ${release}-dev-${d.letter.toLowerCase()}-prompt.md`).join('\\n') + '\\n\\n' + + `After writing the file, run: chmod +x ${REPO}/${COORD_DIR}/${release}-launch.sh`, + { label: 'gen-launch-script', phase: 'Generate' } + ) + + log(`Prompts + launch script ready in ${COORD_DIR}/`) + log(`Run: ${REPO}/${COORD_DIR}/${release}-launch.sh`) + log(`(starts relay if needed, opens tmux session, prompts you which file to paste in each window)`) return { status: 'prompts-ready', devCount: assignment.devCount, coordDir: COORD_DIR } } @@ -353,6 +585,46 @@ if (action === 'release') { return { status: 'blocked', failures: verifyResult.failures } } + // ── Version + tag checks ───────────────────────────────────────────────── + + const versionCheck = await agent( + `Read ${REPO}/Cargo.toml and all files matching ${REPO}/crates/*/Cargo.toml.\n` + + `For each file, extract the version field from the [package] section.\n` + + `Check whether all extracted versions are identical.\n` + + `Then run: git -C ${REPO} tag -l "${release}"\n` + + `Set tagExists=true if the output is non-empty (the tag already exists), false otherwise.\n` + + `Return consistent (true if all versions match), versions (list of all extracted version strings), ` + + `conflicts (list of "file: version" strings for any that differ from the majority), and tagExists.`, + { schema: VERSION_CHECK_SCHEMA, label: 'version-tag-check', phase: 'Finalize' } + ) + + if (!versionCheck.consistent) { + log(`FAIL [version-tag-check]: version mismatch across crates — ${versionCheck.conflicts.join(' | ')}`) + return { status: 'blocked', reason: 'version-mismatch' } + } + + if (versionCheck.tagExists) { + log(`FAIL [version-tag-check]: tag "${release}" already exists — cannot re-tag`) + return { status: 'blocked', reason: 'tag-exists' } + } + + log(`[version-tag-check]: Versions consistent (${versionCheck.versions[0]}), tag available`) + + // ── Advisory: debug artifact scan ────────────────────────────────────────── + + await agent( + `Run the following command from ${REPO}:\n` + + ` git -C ${REPO} diff $(git -C ${REPO} describe --tags --abbrev=0)..HEAD\n\n` + + `Examine lines beginning with "+" (additions) in the diff output.\n` + + `Report any occurrences of:\n` + + ` - dbg!( in Rust files (warn)\n` + + ` - console.log( in TypeScript files (warn)\n` + + ` - TODO or FIXME anywhere (warn)\n` + + ` - .unwrap() in Rust files (advisory note only, not a hard warn)\n` + + `Log each finding with its file and line. This is advisory only — do not block.`, + { label: 'debug-artifact-scan', phase: 'Finalize' } + ) + phase('Finalize') await agent( `Cut the ${release} release for Relicario at ${REPO}. IMPORTANT: cd ${REPO} first.\n\n` + @@ -371,5 +643,36 @@ if (action === 'release') { return { status: 'tagged', release, note: 'Confirm and push manually.' } } -log(`Unknown action: "${action}". Valid: develop, debug, verify, release`) +// ── ACTION: cleanup ─────────────────────────────────────────────────────────── + +if (action === 'cleanup') { + phase('Cleanup') + + const result = await agent( + `Run: git -C ${REPO} worktree list\n` + + `Run: git -C ${REPO} branch --merged main\n\n` + + `For each worktree listed (skip the main checkout at ${REPO} itself):\n` + + ` - If its branch appears in the merged list:\n` + + ` Run: git -C ${REPO} worktree remove --force \n` + + ` Run: git -C ${REPO} branch -d (lowercase -d only, never -D)\n` + + ` Add to removed: [{path, branch}]\n` + + ` - If its branch does NOT appear in the merged list:\n` + + ` Add to kept: [{path, branch, reason: "unmerged"}]\n\n` + + `Return removed (worktrees that were deleted) and kept (worktrees that were left in place).`, + { schema: CLEANUP_RESULT_SCHEMA, label: 'cleanup', phase: 'Cleanup' } + ) + + log(`Cleanup removed ${result.removed.length} worktree(s):`) + for (const w of result.removed) { + log(` removed: ${w.path} (${w.branch})`) + } + log(`Cleanup kept ${result.kept.length} worktree(s):`) + for (const w of result.kept) { + log(` kept: ${w.path} (${w.branch}) — ${w.reason}`) + } + + return { status: 'done', removed: result.removed.length, kept: result.kept.length } +} + +log(`Unknown action: "${action}". Valid: develop, debug, verify, release, preflight, cleanup`) return { status: 'error', action } diff --git a/CLAUDE.md b/CLAUDE.md index 2e2760d..535dde8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -154,3 +154,7 @@ Four rules to prevent the kind of drift the 2026-05-30 audits found: 4. **Plan-state hygiene.** Plan checkboxes and `STATUS.md`/`ROADMAP.md` must reflect what's actually shipped. Two halves: - **Ship side:** when a commit lands work that maps to a plan task, tick that plan's checkboxes in the same commit (or the immediately-following docs commit). Same for `STATUS.md` — the "Up next" list does not get to lag the actual state of `main` by weeks. - **Execute side:** before starting execution of a plan whose checkboxes are all unchecked, spot-check git log (`git log --oneline --all --grep `) or grep for a distinctive symbol/file the plan would create. A plan whose work already merged is the worst kind of plan to re-execute. The 2026-05-30 status-audit found Phase 2B, v0.5.1 Streams A/B/C, and 1C-γ all stealth-shipped two-to-three weeks earlier because nobody ran this check. + +5. **Pre-flight before develop.** Before running `action:"develop"` on any release, run `action:"preflight"` first. If preflight reports FAIL (baseline not green or version mismatch), fix the failure before proceeding. WARN results (orphaned worktrees, partially-done plan) require a judgement call — acknowledge them explicitly before proceeding. + +6. **Cleanup after every lift.** Once all PRs for a release are merged into main, run `Workflow({name:"release", args:{action:"cleanup"}})` to remove the lift's worktrees and feature branches. Stale worktrees accumulate silently and create confusion for the next lift's branch-collision check.