From 8d4e16bf8e570e86fac7faa7498e3de409c4d28a Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Fri, 1 May 2026 05:50:10 +0200 Subject: [PATCH] =?UTF-8?q?feat(graceful-handoff):=202.0=20=E2=80=94=20JSO?= =?UTF-8?q?N=20pipeline=20script=20with=20idempotency=20and=20confirm-on-c?= =?UTF-8?q?ommit=20[skip-docs]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 2 of v2.0 plan. Deterministic Node script that classifies handoff type, renders artifact, and orchestrates commit/push with explicit confirmation. Handles detached HEAD, no-upstream, and idempotency (60s cooldown on clean tree). 10 tests cover dry-run, --auto path, interactive y/n, idempotency, robustness edge cases. --- .../scripts/handoff-pipeline.mjs | 373 ++++++++++++++++++ .../tests/scripts/handoff-pipeline.test.mjs | 158 ++++++++ 2 files changed, 531 insertions(+) create mode 100644 plugins/graceful-handoff/scripts/handoff-pipeline.mjs create mode 100644 plugins/graceful-handoff/tests/scripts/handoff-pipeline.test.mjs diff --git a/plugins/graceful-handoff/scripts/handoff-pipeline.mjs b/plugins/graceful-handoff/scripts/handoff-pipeline.mjs new file mode 100644 index 0000000..08ad9fb --- /dev/null +++ b/plugins/graceful-handoff/scripts/handoff-pipeline.mjs @@ -0,0 +1,373 @@ +#!/usr/bin/env node +// handoff-pipeline.mjs — Deterministic JSON pipeline for graceful-handoff v2.0. +// +// Detects handoff type, classifies state, writes NEXT-SESSION artifact, optionally +// commits and pushes. Returns structured JSON to stdout. Designed to be called both +// by the SKILL.md (interactive) and the Stop hook (auto-execute). +// +// Usage: +// node handoff-pipeline.mjs [topic-slug] [--dry-run] [--no-commit] [--no-push] +// [--auto] [--non-interactive] [--json] +// +// Output (JSON to stdout): +// { +// "handoff_type": "multi-sesjon | plugin-arbeid | enkelt-oppgave", +// "write_dir": "/abs/path", +// "artifact_path": "/abs/path/NEXT-SESSION-...", +// "next_steps": [...], +// "git_status": { branch, dirty, ahead }, +// "commit_message": "...", +// "actions_taken": [...], +// "errors": [...] +// } +// +// Exit codes: 0 = success (even if errors[] non-empty); 1 = unrecoverable internal error. + +import { execSync, execFileSync, spawnSync } from 'node:child_process'; +import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync, readdirSync } from 'node:fs'; +import { dirname, join, basename, resolve } from 'node:path'; +import { createInterface } from 'node:readline'; + +// ---------- Argument parsing ---------- + +function parseArgs(argv) { + const args = { + slug: null, + dryRun: false, + noCommit: false, + noPush: false, + auto: false, + nonInteractive: false, + json: true, + }; + for (const a of argv) { + if (a === '--dry-run') args.dryRun = true; + else if (a === '--no-commit') args.noCommit = true; + else if (a === '--no-push') args.noPush = true; + else if (a === '--auto') args.auto = true; + else if (a === '--non-interactive') args.nonInteractive = true; + else if (a === '--json') args.json = true; + else if (!a.startsWith('--') && !args.slug) args.slug = a; + } + return args; +} + +// ---------- Git helpers ---------- + +function gitOk(cmd, opts = {}) { + try { + return execSync(cmd, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], ...opts }).trim(); + } catch { + return null; + } +} + +function gitStatus() { + const branch = gitOk('git branch --show-current') || gitOk('git rev-parse --abbrev-ref HEAD'); + const porcelain = gitOk('git status --porcelain') || ''; + const dirty = porcelain.length > 0; + let ahead = 0; + const upstream = gitOk('git rev-parse --abbrev-ref @{u} 2>/dev/null'); + if (upstream) { + const counts = gitOk(`git rev-list --left-right --count ${upstream}...HEAD`); + if (counts) ahead = parseInt(counts.split(/\s+/)[1] || '0', 10); + } + const detached = !branch || branch === 'HEAD'; + return { branch, dirty, ahead, upstream, detached, porcelain }; +} + +// ---------- Plugin-root detection ---------- + +function findPluginRoot(startDir) { + let cur = startDir; + for (let i = 0; i < 5; i++) { + if (existsSync(join(cur, '.claude-plugin', 'plugin.json'))) return cur; + const parent = dirname(cur); + if (parent === cur) break; + cur = parent; + } + return null; +} + +// ---------- Multi-session detection ---------- + +function findActiveProject(cwd) { + // Look for .claude/projects/*/progress.json that is not completed + try { + const out = execSync( + `find . -maxdepth 5 -path '*/.claude/projects/*/progress.json' 2>/dev/null | sort -r`, + { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] } + ).trim().split('\n').filter(Boolean); + for (const rel of out) { + const abs = resolve(cwd, rel); + try { + const data = JSON.parse(readFileSync(abs, 'utf-8')); + if (data.status && data.status !== 'completed' && data.status !== 'failed') { + return { progressPath: abs, projectDir: dirname(abs), status: data.status }; + } + } catch { /* skip malformed */ } + } + } catch { /* find failed */ } + return null; +} + +// ---------- Classification ---------- + +function classifyHandoff(cwd) { + const project = findActiveProject(cwd); + if (project) return { type: 'multi-sesjon', writeDir: project.projectDir, projectDir: project.projectDir }; + + const pluginRoot = findPluginRoot(cwd); + if (pluginRoot) return { type: 'plugin-arbeid', writeDir: pluginRoot, pluginRoot }; + + return { type: 'enkelt-oppgave', writeDir: cwd }; +} + +// ---------- Commit-message generation ---------- + +function generateCommitMessage(status) { + const files = status.porcelain.split('\n').filter(Boolean).map(line => line.slice(3)); + const tests = files.filter(f => f.includes('/tests/') || f.endsWith('.test.mjs') || f.endsWith('.test.js')).length; + const docs = files.filter(f => /\.(md|mdx)$/i.test(f) && !f.includes('/tests/')).length; + const code = files.length - tests - docs; + + let type = 'chore'; + if (code > 0 && code >= tests + docs) type = 'feat'; + else if (tests > 0 && tests >= code) type = 'test'; + else if (docs > 0 && docs >= code) type = 'docs'; + + // Scope = plugin name if all files in single plugin + const pluginMatch = files + .map(f => f.match(/^plugins\/([^/]+)/)) + .filter(Boolean) + .map(m => m[1]); + const uniquePlugins = [...new Set(pluginMatch)]; + const scope = uniquePlugins.length === 1 ? uniquePlugins[0] : ''; + + const subject = `wip: pågående arbeid (${files.length} fil${files.length === 1 ? '' : 'er'})`; + return scope ? `${type}(${scope}): ${subject}` : `${type}: ${subject}`; +} + +// ---------- Artifact rendering ---------- + +function renderArtifact(state, classification) { + const today = new Date().toISOString().slice(0, 10); + const branch = state.git.branch || 'HEAD'; + const lastCommits = (gitOk('git log --oneline -5') || '').split('\n').filter(Boolean); + + const lines = []; + lines.push(`# NEXT-SESSION-PROMPT — ${basename(classification.writeDir)} ${today}`); + lines.push(''); + lines.push('## Hvorfor dette eksisterer'); + lines.push(''); + lines.push(`Sesjons-handoff produsert av graceful-handoff v2.0 ${state.auto ? '(auto-trigget av Stop hook)' : '(manuell trigger)'}.`); + lines.push(`Type: \`${classification.type}\`. Branch: \`${branch}\`.`); + if (state.git.dirty) lines.push('Hadde ucommitted endringer ved handoff-tidspunkt.'); + lines.push(''); + lines.push('## Status ved sesjonshåndoff'); + lines.push(''); + lines.push('### ✅ Ferdig'); + lines.push(''); + if (lastCommits.length === 0) lines.push('- Ingen commits funnet.'); + else for (const c of lastCommits) lines.push(`- \`${c}\``); + lines.push(''); + lines.push('### ⏳ Ikke startet / delvis'); + lines.push(''); + lines.push('- Fyll inn av neste sesjon (graceful-handoff v2.0 pipeline genererer ikke dette automatisk; bruk manuell trigger for spesifikk plan-progresjon).'); + lines.push(''); + lines.push('### ⚠️ Brutt / kjent risiko'); + lines.push(''); + lines.push(state.git.dirty ? '- Uncommitted endringer ved handoff-tidspunkt — sjekk `git status`.' : '- Ingen kjente broken tester ved handoff.'); + lines.push(''); + lines.push('## Slik fortsetter du'); + lines.push(''); + lines.push(`1. \`cd ${classification.writeDir}\``); + lines.push(`2. \`cat ${state.artifactName}\` — les denne filen igjen`); + lines.push('3. `git log --oneline -5` og `git status`'); + lines.push('4. Fortsett fra siste pågående arbeid'); + lines.push(''); + lines.push('## Push-policy'); + lines.push(''); + lines.push('- Direkte push til `main` på Forgejo er pre-autorisert'); + lines.push('- Aldri GitHub — kun Forgejo (`git.fromaitochitta.com`)'); + lines.push(''); + lines.push('## Verifiseringskommandoer'); + lines.push(''); + lines.push('```bash'); + lines.push('git log --oneline -5'); + lines.push('git status'); + lines.push('```'); + lines.push(''); + lines.push('## Husk'); + lines.push(''); + lines.push('- Opus 4.7 fyller kontekst raskt — auto-trigger ved estimert 70% er enabled i graceful-handoff v2.0'); + lines.push('- Push gjenstår hvis dette var auto-handoff (Stop hook bruker `--no-push`)'); + lines.push(''); + return lines.join('\n'); +} + +// ---------- Main ---------- + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const cwd = process.cwd(); + const errors = []; + const actionsTaken = []; + + // Validate flag combos + if (args.nonInteractive && !args.auto && !args.dryRun && !args.noCommit) { + errors.push('--non-interactive uten --auto er ikke gyldig (commit-bekreftelse må enten være interaktiv, auto-godkjent, eller skipped via --no-commit)'); + output({ args, cwd, classification: null, errors, actionsTaken }); + return; + } + + // 1. Get git state + const git = gitStatus(); + if (!git.branch && !args.dryRun) { + errors.push('Kunne ikke detektere git-state — er denne mappen et git-repo?'); + output({ args, cwd, classification: null, git, errors, actionsTaken }); + return; + } + + // 2. Classify handoff + const classification = classifyHandoff(cwd); + + // 3. Determine artifact path + const artifactName = args.slug ? `NEXT-SESSION-${args.slug}.local.md` : 'NEXT-SESSION-PROMPT.local.md'; + const artifactPath = join(classification.writeDir, artifactName); + + // 4. Idempotency check: if artifact exists and was modified < 60s ago, and no new git changes, no-op + if (!args.dryRun && existsSync(artifactPath) && !git.dirty) { + try { + const stat = statSync(artifactPath); + const ageMs = Date.now() - stat.mtimeMs; + if (ageMs < 60_000) { + output({ + args, cwd, classification, git, + artifactPath, commitMessage: '', + errors, actionsTaken: ['idempotent-no-op (recent artifact, clean tree)'], + nextSteps: nextStepsFor(classification, artifactName), + }); + return; + } + } catch { /* statSync failed; proceed */ } + } + + // 5. Generate commit message + const commitMessage = git.dirty ? generateCommitMessage(git) : ''; + + // 6. Build state for rendering + const state = { + git, + auto: args.auto, + artifactName, + }; + + // 7. Write artifact + const artifactContent = renderArtifact(state, classification); + if (!args.dryRun) { + try { + mkdirSync(classification.writeDir, { recursive: true }); + writeFileSync(artifactPath, artifactContent, 'utf-8'); + actionsTaken.push(`wrote-artifact: ${artifactPath}`); + } catch (e) { + errors.push(`artifact write failed: ${e.message}`); + } + } else { + actionsTaken.push(`dry-run: would write artifact to ${artifactPath}`); + } + + // 8. Commit (unless --no-commit / --dry-run / nothing to commit) + if (!args.dryRun && !args.noCommit && (git.dirty || existsSync(artifactPath))) { + // Check robustness: detached HEAD, no remote + if (git.detached) { + errors.push('detached HEAD — skipping commit (no branch to commit on)'); + } else { + // Confirmation gate + let proceed = false; + if (args.auto) { + proceed = true; + } else if (args.nonInteractive) { + errors.push('non-interactive without --auto cannot confirm commit'); + } else { + // Interactive: print message to stderr, read y/n from stdin + process.stderr.write(`\nCommit-melding:\n---\n${commitMessage}\n---\nFortsett med commit? (y/n): `); + proceed = await readYesNo(); + } + if (proceed) { + try { + // Stage all and run git commit (pre-commit hooks respected; never --no-verify). + execSync('git add -A', { cwd, stdio: ['ignore', 'pipe', 'pipe'] }); + execFileSync('git', ['commit', '-m', commitMessage], { cwd, stdio: ['ignore', 'pipe', 'pipe'] }); + actionsTaken.push('committed'); + } catch (e) { + errors.push(`commit failed: ${(e.stderr || e.message || '').toString().slice(0, 200)}`); + } + } else { + actionsTaken.push('commit-cancelled-by-user'); + } + } + } + + // 9. Push (unless --no-push / --dry-run / no commit happened) + if (!args.dryRun && !args.noPush && actionsTaken.includes('committed')) { + if (!git.upstream) { + errors.push('no upstream branch — skipping push (set with: git push -u origin )'); + } else { + try { + execFileSync('git', ['push', 'origin', git.branch], { cwd, stdio: ['ignore', 'pipe', 'pipe'] }); + actionsTaken.push('pushed'); + } catch (e) { + errors.push(`push failed: ${(e.stderr || e.message || '').toString().slice(0, 200)}`); + } + } + } + + output({ + args, cwd, classification, git, + artifactPath, commitMessage, + errors, actionsTaken, + nextSteps: nextStepsFor(classification, artifactName), + }); +} + +function nextStepsFor(classification, artifactName) { + return [ + `cd ${classification.writeDir}`, + `cat ${artifactName}`, + 'git log --oneline -5', + 'git status', + 'Fortsett fra siste pågående arbeid (se artefakt-fil).', + ]; +} + +function output({ args, cwd, classification, git, artifactPath, commitMessage, errors, actionsTaken, nextSteps }) { + const result = { + handoff_type: classification?.type || 'unknown', + write_dir: classification?.writeDir || cwd, + artifact_path: artifactPath || null, + next_steps: nextSteps || [], + git_status: git ? { branch: git.branch, dirty: git.dirty, ahead: git.ahead, detached: git.detached } : null, + commit_message: commitMessage || '', + actions_taken: actionsTaken, + errors, + args: { dryRun: args.dryRun, noCommit: args.noCommit, noPush: args.noPush, auto: args.auto, slug: args.slug }, + }; + process.stdout.write(JSON.stringify(result, null, 2) + '\n'); +} + +function readYesNo() { + return new Promise((resolveP) => { + const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: false }); + rl.question('', (answer) => { + rl.close(); + const normalized = (answer || '').trim().toLowerCase(); + resolveP(normalized === 'y' || normalized === 'yes' || normalized === 'ja' || normalized === 'j'); + }); + }); +} + +main().catch((e) => { + process.stderr.write(`pipeline-fatal: ${e.message}\n${e.stack}\n`); + process.exit(1); +}); diff --git a/plugins/graceful-handoff/tests/scripts/handoff-pipeline.test.mjs b/plugins/graceful-handoff/tests/scripts/handoff-pipeline.test.mjs new file mode 100644 index 0000000..12e8cff --- /dev/null +++ b/plugins/graceful-handoff/tests/scripts/handoff-pipeline.test.mjs @@ -0,0 +1,158 @@ +// handoff-pipeline.test.mjs — Tests for scripts/handoff-pipeline.mjs. + +import { test } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { execFileSync, spawn } from 'node:child_process'; +import { existsSync, mkdirSync, writeFileSync, rmSync, mkdtempSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { tmpdir } from 'node:os'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SCRIPT = join(__dirname, '..', '..', 'scripts', 'handoff-pipeline.mjs'); + +function makeTempRepo() { + const dir = mkdtempSync(join(tmpdir(), 'gh-pipeline-')); + execFileSync('git', ['init', '-q'], { cwd: dir }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: dir }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir }); + // Initial commit so HEAD exists + writeFileSync(join(dir, 'README.md'), '# test\n', 'utf-8'); + execFileSync('git', ['add', '.'], { cwd: dir }); + execFileSync('git', ['commit', '-q', '-m', 'init'], { cwd: dir }); + return dir; +} + +function runPipeline(repo, args = [], { stdin = '' } = {}) { + return new Promise((resolveP) => { + const child = spawn('node', [SCRIPT, ...args], { cwd: repo, stdio: ['pipe', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (d) => (stdout += d.toString())); + child.stderr.on('data', (d) => (stderr += d.toString())); + child.on('close', (code) => resolveP({ code, stdout, stderr })); + if (stdin) child.stdin.write(stdin); + child.stdin.end(); + }); +} + +test('--dry-run returns valid JSON with required keys', async () => { + const repo = makeTempRepo(); + const result = await runPipeline(repo, ['--dry-run']); + assert.equal(result.code, 0, `non-zero exit: ${result.stderr}`); + const json = JSON.parse(result.stdout); + assert.ok(json.handoff_type, 'handoff_type missing'); + assert.ok(json.write_dir, 'write_dir missing'); + assert.ok(Array.isArray(json.next_steps), 'next_steps missing'); + assert.ok(Array.isArray(json.actions_taken), 'actions_taken missing'); + assert.ok(Array.isArray(json.errors), 'errors missing'); + assert.ok(json.git_status, 'git_status missing'); + rmSync(repo, { recursive: true, force: true }); +}); + +test('--dry-run is idempotent (two runs produce same JSON shape)', async () => { + const repo = makeTempRepo(); + const a = await runPipeline(repo, ['--dry-run']); + const b = await runPipeline(repo, ['--dry-run']); + const aJson = JSON.parse(a.stdout); + const bJson = JSON.parse(b.stdout); + assert.equal(aJson.handoff_type, bJson.handoff_type); + assert.equal(aJson.write_dir, bJson.write_dir); + assert.deepEqual(aJson.next_steps, bJson.next_steps); + rmSync(repo, { recursive: true, force: true }); +}); + +test('--non-interactive without --auto is invalid', async () => { + const repo = makeTempRepo(); + // Add dirty state so commit phase would activate + writeFileSync(join(repo, 'foo.txt'), 'change\n'); + const result = await runPipeline(repo, ['--non-interactive']); + assert.equal(result.code, 0); // pipeline always exits 0 on logical errors + const json = JSON.parse(result.stdout); + assert.ok(json.errors.some(e => /non-interactive/i.test(e)), `expected non-interactive error, got: ${JSON.stringify(json.errors)}`); + rmSync(repo, { recursive: true, force: true }); +}); + +test('--auto on dirty repo writes artifact and commits without prompting', async () => { + const repo = makeTempRepo(); + writeFileSync(join(repo, 'foo.txt'), 'change\n'); + // No upstream — push will be skipped via no-upstream error, but commit should succeed + const result = await runPipeline(repo, ['--auto', '--non-interactive', '--no-push']); + assert.equal(result.code, 0); + const json = JSON.parse(result.stdout); + assert.ok(json.actions_taken.some(a => a.startsWith('wrote-artifact')), `expected wrote-artifact, got: ${JSON.stringify(json.actions_taken)}`); + assert.ok(json.actions_taken.includes('committed'), `expected committed, got: ${JSON.stringify(json.actions_taken)}`); + // Verify artifact file actually exists on disk + assert.ok(existsSync(json.artifact_path), `artifact path ${json.artifact_path} should exist`); + rmSync(repo, { recursive: true, force: true }); +}); + +test('--no-commit skips git operations even when dirty', async () => { + const repo = makeTempRepo(); + writeFileSync(join(repo, 'foo.txt'), 'change\n'); + const result = await runPipeline(repo, ['--no-commit', '--auto']); + const json = JSON.parse(result.stdout); + assert.ok(!json.actions_taken.includes('committed'), 'should not commit with --no-commit'); + assert.ok(!json.actions_taken.includes('pushed'), 'should not push without commit'); + rmSync(repo, { recursive: true, force: true }); +}); + +test('idempotency: second --auto run on clean state with recent artifact is no-op', async () => { + const repo = makeTempRepo(); + writeFileSync(join(repo, 'foo.txt'), 'change\n'); + // First run: dirty, commits + await runPipeline(repo, ['--auto', '--non-interactive', '--no-push']); + // Second run: clean tree, recent artifact exists → idempotent no-op + const result = await runPipeline(repo, ['--auto', '--non-interactive', '--no-push']); + const json = JSON.parse(result.stdout); + assert.ok( + json.actions_taken.some(a => a.includes('idempotent')), + `expected idempotent no-op, got: ${JSON.stringify(json.actions_taken)}` + ); + rmSync(repo, { recursive: true, force: true }); +}); + +test('detached HEAD is detected and reported (no commit attempted)', async () => { + const repo = makeTempRepo(); + // Detach HEAD + const sha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf-8' }).trim(); + execFileSync('git', ['checkout', '-q', sha], { cwd: repo }); + writeFileSync(join(repo, 'foo.txt'), 'change\n'); + const result = await runPipeline(repo, ['--auto', '--non-interactive', '--no-push']); + const json = JSON.parse(result.stdout); + assert.ok(json.errors.some(e => /detached HEAD/i.test(e)), `expected detached HEAD error, got: ${JSON.stringify(json.errors)}`); + assert.ok(!json.actions_taken.includes('committed'), 'should not commit on detached HEAD'); + rmSync(repo, { recursive: true, force: true }); +}); + +test('no-upstream branch is detected on push attempt', async () => { + const repo = makeTempRepo(); + writeFileSync(join(repo, 'foo.txt'), 'change\n'); + // No remote/upstream — pipeline tries to push, gets no-upstream error + const result = await runPipeline(repo, ['--auto', '--non-interactive']); + const json = JSON.parse(result.stdout); + assert.ok(json.errors.some(e => /upstream/i.test(e)), `expected upstream error, got: ${JSON.stringify(json.errors)}`); + rmSync(repo, { recursive: true, force: true }); +}); + +test('interactive: stdin "n" cancels commit', async () => { + const repo = makeTempRepo(); + writeFileSync(join(repo, 'foo.txt'), 'change\n'); + const result = await runPipeline(repo, [], { stdin: 'n\n' }); + const json = JSON.parse(result.stdout); + assert.ok( + json.actions_taken.some(a => /cancelled/i.test(a)), + `expected commit-cancelled-by-user, got: ${JSON.stringify(json.actions_taken)}` + ); + assert.ok(!json.actions_taken.includes('committed'), 'should not commit when user says n'); + rmSync(repo, { recursive: true, force: true }); +}); + +test('interactive: stdin "y" confirms commit', async () => { + const repo = makeTempRepo(); + writeFileSync(join(repo, 'foo.txt'), 'change\n'); + const result = await runPipeline(repo, ['--no-push'], { stdin: 'y\n' }); + const json = JSON.parse(result.stdout); + assert.ok(json.actions_taken.includes('committed'), `expected committed, got: ${JSON.stringify(json.actions_taken)}`); + rmSync(repo, { recursive: true, force: true }); +});