feat(graceful-handoff)!: integrate with STATE.md continuity system (v3.0.0)
BREAKING: replace the NEXT-SESSION artifact + 3 hooks with a STATE.md-centric, skill-only design. /graceful-handoff now overwrites the nearest STATE.md (with the mandatory 👉 NESTE block) instead of writing a separate handover file. - Remove hooks/ entirely: Stop auto-trigger (operator choice), SessionStart loader (redundant with global session-start.sh), statusLine hint (dead — user settings win). - Invert the pipeline: the model writes STATE.md (only it has the context for 👉 NESTE); handoff-pipeline.mjs becomes a slim deterministic helper (--plan / --commit / --dry-run). - Remote-aware policy: STATE.md tracked on private remotes, local-only (gitignored) on public/open mirrors. Authoritative signal: git check-ignore STATE.md. - SKILL.md rewritten as the Session-Slutt ritual; dropped the Sonnet model pin. - Docs (README, CLAUDE.md, CHANGELOG), plugin.json 2.1.0→3.0.0, .gitignore cleanup. - Tests rewritten for --plan/--commit; no-`git add -A` regression preserved. 30/30 green. Release-cut (tag v3.0.0 + catalog ref bump) pending — separate gated action. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019SiKr4c6GAzQH5n6E6f5NA
This commit is contained in:
parent
a6f3ad4e93
commit
2c4e5e425b
18 changed files with 694 additions and 1656 deletions
|
|
@ -1,184 +1,202 @@
|
|||
// handoff-pipeline.test.mjs — Tests for scripts/handoff-pipeline.mjs.
|
||||
// handoff-pipeline.test.mjs — Tests for scripts/handoff-pipeline.mjs (v3.0 STATE helper).
|
||||
|
||||
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 { existsSync, writeFileSync, rmSync, mkdtempSync, realpathSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { fileURLToPath, pathToFileURL } 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-'));
|
||||
function makeTempRepo({ remote = null, gitignoreState = false } = {}) {
|
||||
// realpath: on macOS tmpdir() is a /var → /private/var symlink, but
|
||||
// `git rev-parse --show-toplevel` returns the canonical path. Canonicalise
|
||||
// here so derived expected paths match the script's git-resolved output.
|
||||
const dir = realpathSync(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
|
||||
if (remote) execFileSync('git', ['remote', 'add', 'origin', remote], { cwd: dir });
|
||||
writeFileSync(join(dir, 'README.md'), '# test\n', 'utf-8');
|
||||
if (gitignoreState) writeFileSync(join(dir, '.gitignore'), 'STATE.md\n', 'utf-8');
|
||||
execFileSync('git', ['add', '.'], { cwd: dir });
|
||||
execFileSync('git', ['commit', '-q', '-m', 'init'], { cwd: dir });
|
||||
return dir;
|
||||
}
|
||||
|
||||
function runPipeline(repo, args = [], { stdin = '' } = {}) {
|
||||
function runPipeline(cwd, args = []) {
|
||||
return new Promise((resolveP) => {
|
||||
const child = spawn('node', [SCRIPT, ...args], { cwd: repo, stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
const child = spawn('node', [SCRIPT, ...args], { cwd, stdio: ['ignore', '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 () => {
|
||||
// ---------- classifyRemote (unit) ----------
|
||||
|
||||
test('classifyRemote: none / public / private', async () => {
|
||||
const { classifyRemote } = await import(pathToFileURL(SCRIPT).href);
|
||||
assert.equal(classifyRemote(null), 'none');
|
||||
assert.equal(classifyRemote(''), 'none');
|
||||
assert.equal(classifyRemote('https://github.com/foo/bar.git'), 'public');
|
||||
assert.equal(classifyRemote('ssh://git@git.fromaitochitta.com/open/graceful-handoff.git'), 'public');
|
||||
assert.equal(classifyRemote('ssh://git@git.fromaitochitta.com/ktg/secret.git'), 'private');
|
||||
});
|
||||
|
||||
// ---------- --plan ----------
|
||||
|
||||
test('--plan returns JSON with required keys', async () => {
|
||||
const repo = makeTempRepo();
|
||||
const result = await runPipeline(repo, ['--dry-run']);
|
||||
const result = await runPipeline(repo, ['--plan']);
|
||||
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');
|
||||
for (const k of ['state_path', 'state_exists', 'state_gitignored', 'remote_class',
|
||||
'should_be_local_only', 'should_commit_state', 'git_status', 'dirty_files',
|
||||
'recent_commits', 'line_budget', 'errors']) {
|
||||
assert.ok(k in json, `missing key: ${k}`);
|
||||
}
|
||||
assert.ok(json.git_status.branch, 'branch missing');
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('--dry-run is idempotent (two runs produce same JSON shape)', async () => {
|
||||
test('--plan (default mode) targets <repoRoot>/STATE.md when none exists', 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 result = await runPipeline(repo, []); // default = plan
|
||||
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)}`);
|
||||
assert.equal(json.state_exists, false);
|
||||
assert.equal(json.state_path, join(repo, 'STATE.md'));
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('--auto on dirty repo writes artifact and commits without prompting', async () => {
|
||||
test('--plan resolves nearest existing STATE.md (subdir wins over root)', 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 sub = join(repo, 'plugins', 'x');
|
||||
execFileSync('mkdir', ['-p', sub]);
|
||||
writeFileSync(join(repo, 'STATE.md'), '# root\n');
|
||||
writeFileSync(join(sub, 'STATE.md'), '# sub\n');
|
||||
const result = await runPipeline(sub, ['--plan']);
|
||||
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`);
|
||||
assert.equal(json.state_path, join(sub, 'STATE.md'));
|
||||
assert.equal(json.state_exists, true);
|
||||
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']);
|
||||
test('--plan: public open/ remote → should_be_local_only, leak_warning when STATE not gitignored', async () => {
|
||||
const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/open/foo.git' });
|
||||
const result = await runPipeline(repo, ['--plan']);
|
||||
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');
|
||||
assert.equal(json.remote_class, 'public');
|
||||
assert.equal(json.should_be_local_only, true);
|
||||
assert.ok(json.leak_warning, 'expected leak_warning when public remote and STATE not gitignored');
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('idempotency: second --auto run on clean tree with recent artifact is no-op', async () => {
|
||||
const repo = makeTempRepo();
|
||||
writeFileSync(join(repo, 'foo.txt'), 'change\n');
|
||||
// First run: dirty, writes artifact and commits ONLY the artifact (not foo.txt)
|
||||
await runPipeline(repo, ['--auto', '--non-interactive', '--no-push']);
|
||||
// Clean up the unrelated dirty file so second run sees a CLEAN tree.
|
||||
// The pipeline must NEVER auto-stage user's other dirty files (CLAUDE.md
|
||||
// anti-pattern) — the test explicitly removes it to isolate idempotency.
|
||||
rmSync(join(repo, 'foo.txt'));
|
||||
// Second run: clean tree, recent artifact exists → idempotent no-op
|
||||
const result = await runPipeline(repo, ['--auto', '--non-interactive', '--no-push']);
|
||||
test('--plan: public remote + STATE gitignored → no leak_warning, should_commit_state false', async () => {
|
||||
const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/open/foo.git', gitignoreState: true });
|
||||
const result = await runPipeline(repo, ['--plan']);
|
||||
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)}`
|
||||
);
|
||||
assert.equal(json.remote_class, 'public');
|
||||
assert.equal(json.state_gitignored, true);
|
||||
assert.equal(json.should_commit_state, false);
|
||||
assert.equal(json.leak_warning, null);
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('pipeline never stages unrelated dirty files (no git add -A regression)', async () => {
|
||||
const repo = makeTempRepo();
|
||||
// Two unrelated dirty files — pipeline should NOT commit them
|
||||
test('--plan: private remote → should_commit_state true, not local-only', async () => {
|
||||
const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/ktg/secret.git' });
|
||||
const result = await runPipeline(repo, ['--plan']);
|
||||
const json = JSON.parse(result.stdout);
|
||||
assert.equal(json.remote_class, 'private');
|
||||
assert.equal(json.should_be_local_only, false);
|
||||
assert.equal(json.should_commit_state, true);
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ---------- --commit ----------
|
||||
|
||||
test('--commit on private repo stages and commits ONLY STATE.md', async () => {
|
||||
const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/ktg/secret.git' });
|
||||
writeFileSync(join(repo, 'STATE.md'), '# STATE\n');
|
||||
writeFileSync(join(repo, 'unrelated.txt'), 'user work\n');
|
||||
const result = await runPipeline(repo, ['--commit']);
|
||||
const json = JSON.parse(result.stdout);
|
||||
assert.ok(json.actions_taken.includes('committed'), `expected committed, got ${JSON.stringify(json.actions_taken)}`);
|
||||
const head = execFileSync('git', ['show', '--name-only', '--pretty=', 'HEAD'], { cwd: repo, encoding: 'utf-8' })
|
||||
.trim().split('\n').filter(Boolean);
|
||||
assert.deepEqual(head, ['STATE.md'], `HEAD should contain only STATE.md, got ${JSON.stringify(head)}`);
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('--commit never stages unrelated dirty files (no git add -A regression)', async () => {
|
||||
const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/ktg/secret.git' });
|
||||
writeFileSync(join(repo, 'STATE.md'), '# STATE\n');
|
||||
writeFileSync(join(repo, 'unrelated-1.txt'), 'user work\n');
|
||||
writeFileSync(join(repo, 'unrelated-2.md'), '# user notes\n');
|
||||
await runPipeline(repo, ['--auto', '--non-interactive', '--no-push']);
|
||||
// After commit, unrelated files must STILL be in working tree (not committed)
|
||||
const { execFileSync } = await import('node:child_process');
|
||||
const lastCommit = execFileSync('git', ['show', '--name-only', '--pretty=', 'HEAD'], {
|
||||
cwd: repo, encoding: 'utf-8',
|
||||
}).trim().split('\n').filter(Boolean);
|
||||
assert.ok(!lastCommit.includes('unrelated-1.txt'), `unrelated-1.txt should NOT be in HEAD commit, got: ${lastCommit}`);
|
||||
assert.ok(!lastCommit.includes('unrelated-2.md'), `unrelated-2.md should NOT be in HEAD commit, got: ${lastCommit}`);
|
||||
// The artifact SHOULD be in HEAD
|
||||
assert.ok(lastCommit.some(f => f.includes('NEXT-SESSION')), `artifact should be in HEAD, got: ${lastCommit}`);
|
||||
// unrelated files still untracked
|
||||
writeFileSync(join(repo, 'unrelated-2.md'), '# notes\n');
|
||||
await runPipeline(repo, ['--commit']);
|
||||
const status = execFileSync('git', ['status', '--porcelain'], { cwd: repo, encoding: 'utf-8' });
|
||||
assert.match(status, /unrelated-1\.txt/);
|
||||
assert.match(status, /unrelated-2\.md/);
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('detached HEAD is detected and reported (no commit attempted)', async () => {
|
||||
const repo = makeTempRepo();
|
||||
// Detach HEAD
|
||||
test('--commit with --also includes the explicit related path', async () => {
|
||||
const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/ktg/secret.git' });
|
||||
writeFileSync(join(repo, 'STATE.md'), '# STATE\n');
|
||||
writeFileSync(join(repo, 'code.mjs'), 'export const x = 1;\n');
|
||||
writeFileSync(join(repo, 'untouched.txt'), 'leave me\n');
|
||||
await runPipeline(repo, ['--commit', '--also', 'code.mjs']);
|
||||
const head = execFileSync('git', ['show', '--name-only', '--pretty=', 'HEAD'], { cwd: repo, encoding: 'utf-8' })
|
||||
.trim().split('\n').filter(Boolean).sort();
|
||||
assert.deepEqual(head, ['STATE.md', 'code.mjs']);
|
||||
const status = execFileSync('git', ['status', '--porcelain'], { cwd: repo, encoding: 'utf-8' });
|
||||
assert.match(status, /untouched\.txt/);
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('--commit on public repo does NOT commit gitignored STATE.md', async () => {
|
||||
const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/open/foo.git', gitignoreState: true });
|
||||
writeFileSync(join(repo, 'STATE.md'), '# STATE local-only\n');
|
||||
const result = await runPipeline(repo, ['--commit']);
|
||||
const json = JSON.parse(result.stdout);
|
||||
assert.ok(
|
||||
json.actions_taken.some(a => /local-only-skipped|intet-å-committe/.test(a)),
|
||||
`expected local-only skip, got ${JSON.stringify(json.actions_taken)}`
|
||||
);
|
||||
assert.ok(!json.actions_taken.includes('committed'), 'should not commit gitignored STATE.md');
|
||||
// STATE.md stays untracked/ignored, working tree otherwise clean
|
||||
const tracked = execFileSync('git', ['ls-files', 'STATE.md'], { cwd: repo, encoding: 'utf-8' }).trim();
|
||||
assert.equal(tracked, '', 'STATE.md must not become tracked on a public repo');
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('--commit on detached HEAD is detected (no commit attempted)', async () => {
|
||||
const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/ktg/secret.git' });
|
||||
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']);
|
||||
writeFileSync(join(repo, 'STATE.md'), '# STATE\n');
|
||||
const result = await runPipeline(repo, ['--commit']);
|
||||
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.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 });
|
||||
});
|
||||
// ---------- --dry-run ----------
|
||||
|
||||
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' });
|
||||
test('--dry-run writes nothing and creates no commit', async () => {
|
||||
const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/ktg/secret.git' });
|
||||
const before = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf-8' }).trim();
|
||||
const result = await runPipeline(repo, ['--dry-run']);
|
||||
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)}`);
|
||||
assert.equal(json.mode, 'dry-run');
|
||||
assert.ok(!existsSync(join(repo, 'STATE.md')), 'dry-run must not write STATE.md');
|
||||
const after = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf-8' }).trim();
|
||||
assert.equal(before, after, 'dry-run must not create a commit');
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue