#!/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 { // CRITICAL: never `git add -A` — that scoops up unrelated work-in-progress. // Stage ONLY the handoff artifact + optional REMEMBER.md/TODO.md if present. // Other dirty files stay in working tree for the user. const stageList = [artifactPath]; for (const candidate of ['REMEMBER.md', 'TODO.md']) { const p = join(classification.writeDir, candidate); if (existsSync(p)) stageList.push(p); } execFileSync('git', ['add', '--', ...stageList], { cwd, stdio: ['ignore', 'pipe', 'pipe'] }); // git commit with -- pathspec limits commit to those paths from index. execFileSync('git', ['commit', '-m', commitMessage, '--', ...stageList], { 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); });