diff --git a/README.md b/README.md index 9744986..86da12f 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,9 @@ settings. | Source | Destination | How to apply | |---|---|---| | [config.toml](codex/config.toml) | `~/.codex/config.toml` | Merge the listed keys into the existing file and `[agents]` table | -| [global-instructions.md](codex/global-instructions.md) | `~/.codex/AGENTS.md` | Add this section to the existing instructions | +| [global-instructions.md](codex/global-instructions.md) | `~/.codex/AGENTS.md` | Merge these shared instructions into the existing file | +| [optional-instructions.md](codex/optional-instructions.md) | `~/.codex/AGENTS.md` | Merge only the sections you want to enable | +| [optional.rules](codex/optional.rules) | `~/.codex/rules/default.rules` | Replace its path placeholder and merge only the rules you want to enable | | [junior.toml](codex/agents/junior.toml) | `~/.codex/agents/junior.toml` | Copy this rank file | | [engineer.toml](codex/agents/engineer.toml) | `~/.codex/agents/engineer.toml` | Copy this rank file | | [senior.toml](codex/agents/senior.toml) | `~/.codex/agents/senior.toml` | Copy this rank file | @@ -46,29 +48,28 @@ settings. To apply these defaults, back up the local files, merge or copy only these parts, and check that the TOML parses. Keep unrelated values and existing instructions. If `~/.codex/AGENTS.override.md` exists, Codex reads it instead of `AGENTS.md`; -merge the section into that active file, or deliberately retire the override -before using `AGENTS.md`. +merge these shared instructions into that active file, or deliberately retire +the override before using `AGENTS.md`. If a destination role already exists, review its differences before replacing it. No install script or whole-config symlink is needed for this first version. -The global instructions expect [orchestration](skills/orchestration/SKILL.md), -[goal-swarm](skills/goal-swarm/SKILL.md), and -[review-fix-loop](skills/review-fix-loop/SKILL.md), plus their referenced skills, -to be installed from this repo. The -[routing reference](skills/orchestration/references/agent-routing.md) explains role +Orchestration and review use the +[routing reference](skills/orchestration/references/agent-routing.md) for role selection and the host's spawn rules. Model and effort values live in config and agent files; the skills own delegation, review coverage, and rounds. These are defaults for local tasks using the same Codex home. Project config, explicit model choices, and custom roles can override them. Defining roles makes -them available; the global and skill instructions request their use. Start a +them available; the skills request their use. Start a new task after applying the files and check the effective settings. Existing tasks may retain their selections. Keep any installed links on a stable clone, not a temporary worktree. Sample checks after installation: +- Every nonempty review scope also receives a separate Gemini review. Native + reviewer counts below do not include Gemini. - A small behavior change uses one reviewer; substantial changes, multiple behaviors, shared contracts, risky logic, or work from several agents use two. Each reviewer reads the whole diff in every round. diff --git a/codex/global-instructions.md b/codex/global-instructions.md index 1dfadee..a263b44 100644 --- a/codex/global-instructions.md +++ b/codex/global-instructions.md @@ -1,18 +1,20 @@ -## Delegation and review +## Doing the work -Use `/orchestration` when deciding whether to delegate or coordinating workers. -For independent review, `/review-fix-loop` reads orchestration’s routing reference -directly and owns reviewer dispatch. -When the user explicitly requests goal-backed parallel work, use `/goal-swarm`. -Create goals only when the user explicitly requests them. +- I value clean, maintainable code and modern coding practices. Consult official documentation when needed. +- Infer the outcome I want from the request, conversation, and project context. Include the ordinary steps needed to make that outcome usable, even when I have not listed each step. Keep this within the requested scope. +- Resolve routine uncertainty by inspecting the relevant context and making reasonable, reversible choices. Ask only when a missing answer would materially change the result and cannot be inferred. Continue independent work while waiting. +- Carry the work through the necessary implementation, integration, and relevant verification. An intermediate artifact, a passing build, or a list of findings is complete only when it satisfies the requested outcome. Keep explanations concise without shortening the work. +- In performance work, measure the actual bottleneck before changing it. Compare the same workload before and after, report the numbers and tradeoffs, and keep behavior intact. -Use `/review-fix-loop` before handing off code changes that affect behavior, -including work by multiple agents, and docs that change agent behavior, such -as skill procedures or global instructions. It owns reviewer count, full-diff -coverage, and fresh rounds. For ordinary docs, comments, or formatting alone, -the parent reviews the change and runs relevant checks; an explicit review -request still invokes the skill. +## Verification -Follow orchestration’s routing reference for worker and reviewer selection, including when -the host requires explicit model settings. Keep model and effort values in -config and agent files. +- Verify the result I’ll actually use, and be clear about anything you haven’t tested. +- Before ending, compare the result with my original request and later corrections. If my likely next message would ask for an obvious missing step within the authorized scope, complete that step now. If something remains blocked, state exactly what is unfinished and what prevents completion. + +## Writing rules + +- Write docs, PR text, and messages in short, direct, active sentences. Use everyday words where they stay exact. Cut filler and stock phrases. Keep code and technical terms exact. + +## GitHub CLI + +Run `gh` outside the sandbox on the first try. Its token lives in macOS Keychain, so a sandboxed check can falsely report an invalid token. Use the saved command rules for approval, and keep the token out of config files and environment variables. diff --git a/codex/optional-instructions.md b/codex/optional-instructions.md new file mode 100644 index 0000000..d1e2318 --- /dev/null +++ b/codex/optional-instructions.md @@ -0,0 +1,11 @@ +# Optional Codex instructions + +These sections are inactive. Merge a section into the active global instruction +file only when you want its behavior. + +Replace `/absolute/path/to/SreeStack` in this file and `optional.rules` with the +path to the clone you keep outside temporary worktrees. + +## New Codex worktree preflight + +Before reading or editing project files in a new Codex-managed worktree based on local `main`, run the optional preflight command `node /absolute/path/to/SreeStack/codex/scripts/worktree-start.mjs` outside the sandbox. It fetches `origin` and moves the detached worktree to `origin/main` only when safe. Treat a result with `"continue": false` as a hard stop: leave the worktree unchanged and tell the user why. Skip this preflight when the user chose another base or resumed an existing worktree. diff --git a/codex/optional.rules b/codex/optional.rules new file mode 100644 index 0000000..45a2115 --- /dev/null +++ b/codex/optional.rules @@ -0,0 +1,12 @@ +# Replace /absolute/path/to/SreeStack before merging this block into an active rules file. +prefix_rule( + pattern=["node", "/absolute/path/to/SreeStack/codex/scripts/worktree-start.mjs"], + decision="allow", + justification="Sync a new Codex worktree to origin/main before work starts.", + match=[ + ["node", "/absolute/path/to/SreeStack/codex/scripts/worktree-start.mjs"], + ], + not_match=[ + ["node", "/absolute/path/to/SreeStack/codex/scripts/other.mjs"], + ], +) diff --git a/codex/scripts/worktree-start.mjs b/codex/scripts/worktree-start.mjs new file mode 100644 index 0000000..3475897 --- /dev/null +++ b/codex/scripts/worktree-start.mjs @@ -0,0 +1,231 @@ +#!/usr/bin/env node + +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const BASE_BRANCH = 'main'; +const REMOTE = 'origin'; +const HOOK_EVENT_NAME = 'SessionStart'; + +function readStdin() { + return new Promise((resolve) => { + if (process.stdin.isTTY) { + resolve(''); + return; + } + + let input = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', (chunk) => { + input += chunk; + }); + process.stdin.on('end', () => resolve(input)); + process.stdin.on('error', () => resolve('')); + }); +} + +function parseInput(text) { + if (!text.trim()) { + return {}; + } + + try { + return JSON.parse(text); + } catch { + return {}; + } +} + +function git(cwd, args) { + const result = spawnSync('git', args, { + cwd, + encoding: 'utf8', + env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 30_000, + }); + + return { + ok: !result.error && result.status === 0, + status: result.status ?? 1, + stdout: (result.stdout ?? '').trim(), + stderr: (result.stderr ?? result.error?.message ?? '').trim(), + }; +} + +function commit(cwd, ref) { + const result = git(cwd, ['rev-parse', '--verify', `${ref}^{commit}`]); + return result.ok ? result.stdout : null; +} + +function branch(cwd) { + const result = git(cwd, ['symbolic-ref', '--quiet', '--short', 'HEAD']); + return result.ok ? result.stdout : null; +} + +function isAncestor(cwd, ancestor, descendant) { + return git(cwd, ['merge-base', '--is-ancestor', ancestor, descendant]).status === 0; +} + +function short(value) { + return value?.slice(0, 12) ?? 'unknown'; +} + +function oneLine(value) { + return value.replace(/\s+/g, ' ').trim().slice(0, 500); +} + +function output({ continueSession, message, context }) { + const result = { + continue: continueSession, + hookSpecificOutput: { + hookEventName: HOOK_EVENT_NAME, + }, + }; + + if (!continueSession) { + result.stopReason = message; + } + if (message) { + result.systemMessage = message; + } + if (context) { + result.hookSpecificOutput.additionalContext = context; + } + + console.log(JSON.stringify(result)); +} + +function allow(context) { + output({ continueSession: true, context }); +} + +function block(message) { + output({ continueSession: false, message, context: message }); +} + +function isPrimaryCheckout(root) { + const gitDir = git(root, ['rev-parse', '--absolute-git-dir']); + const commonDir = git(root, ['rev-parse', '--git-common-dir']); + if (!gitDir.ok || !commonDir.ok) { + throw new Error('Could not find the Git worktree paths.'); + } + + return path.resolve(gitDir.stdout) === path.resolve(root, commonDir.stdout); +} + +async function main() { + const input = parseInput(await readStdin()); + if (input.source && input.source !== 'startup') { + return; + } + + const cwd = input.cwd || process.cwd(); + const rootResult = git(cwd, ['rev-parse', '--show-toplevel']); + if (!rootResult.ok) { + return; + } + + const root = rootResult.stdout; + const currentBranch = branch(root); + const primary = isPrimaryCheckout(root); + + if (primary) { + if (currentBranch === BASE_BRANCH) { + block( + 'Codex opened the primary checkout on main. Start this task in Worktree mode so the primary checkout stays clean.', + ); + } + return; + } + + if (currentBranch) { + return; + } + + const initialHead = commit(root, 'HEAD'); + const localMain = commit(root, `refs/heads/${BASE_BRANCH}`); + if (!initialHead || !localMain || initialHead !== localMain) { + return; + } + + const trackedState = git(root, ['status', '--porcelain', '--untracked-files=no']); + if (!trackedState.ok) { + block('Could not check the new worktree for tracked changes. The worktree was left unchanged.'); + return; + } + if (trackedState.stdout) { + block('The new main-based worktree has tracked or staged changes. The worktree was left unchanged.'); + return; + } + + const origin = git(root, ['remote', 'get-url', REMOTE]); + if (!origin.ok) { + allow('This repository has no origin remote. The new worktree uses local main.'); + return; + } + + const fetch = git(root, [ + 'fetch', + '--quiet', + '--no-prune', + '--no-tags', + '--no-recurse-submodules', + REMOTE, + `+refs/heads/${BASE_BRANCH}:refs/remotes/${REMOTE}/${BASE_BRANCH}`, + ]); + if (!fetch.ok) { + const detail = oneLine(fetch.stderr || fetch.stdout || 'unknown Git error'); + block(`Could not fetch origin/main: ${detail}. The worktree was left unchanged.`); + return; + } + + const remoteMain = commit(root, `refs/remotes/${REMOTE}/${BASE_BRANCH}`); + if (!remoteMain) { + block('The origin remote has no origin/main ref. The worktree was left unchanged.'); + return; + } + + if (initialHead === remoteMain) { + allow(`The new worktree already matches origin/main at ${short(remoteMain)}.`); + return; + } + + if (isAncestor(root, initialHead, remoteMain)) { + const move = git(root, [ + '-c', + 'core.hooksPath=/dev/null', + 'switch', + '--detach', + '--quiet', + '--no-overwrite-ignore', + '--no-recurse-submodules', + remoteMain, + ]); + if (!move.ok) { + const detail = oneLine(move.stderr || move.stdout || 'unknown Git error'); + block(`Could not move the new worktree to origin/main: ${detail}.`); + return; + } + + allow( + `Moved the new worktree from local main ${short(initialHead)} to origin/main ${short(remoteMain)}.`, + ); + return; + } + + if (isAncestor(root, remoteMain, initialHead)) { + block( + `Local main ${short(initialHead)} is ahead of origin/main ${short(remoteMain)}. The worktree was left unchanged. Push or reconcile main, then start a new worktree.`, + ); + return; + } + + block( + `Local main ${short(initialHead)} and origin/main ${short(remoteMain)} have split. The worktree was left unchanged. Reconcile main, then start a new worktree.`, + ); +} + +main().catch((error) => { + block(`The worktree preflight failed: ${oneLine(error.message)}.`); +}); diff --git a/codex/scripts/worktree-start.test.mjs b/codex/scripts/worktree-start.test.mjs new file mode 100644 index 0000000..4726738 --- /dev/null +++ b/codex/scripts/worktree-start.test.mjs @@ -0,0 +1,498 @@ +import assert from 'node:assert/strict'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test, { after } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const hook = path.join(path.dirname(fileURLToPath(import.meta.url)), 'worktree-start.mjs'); +const tempRoots = new Set(); + +after(() => { + for (const root of tempRoots) { + rmSync(root, { force: true, recursive: true }); + } +}); + +function git(cwd, ...args) { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + env: { ...process.env, GIT_CONFIG_NOSYSTEM: '1' }, + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); +} + +function initRepo() { + const root = mkdtempSync(path.join(tmpdir(), 'codex-worktree-hook-')); + tempRoots.add(root); + const repo = path.join(root, 'repo'); + mkdirSync(repo); + git(repo, 'init', '--initial-branch=main'); + git(repo, 'config', 'user.name', 'Codex Hook Test'); + git(repo, 'config', 'user.email', 'codex-hook@example.test'); + writeFileSync(path.join(repo, 'file.txt'), 'one\n'); + git(repo, 'add', 'file.txt'); + git(repo, 'commit', '-m', 'initial'); + return { root, repo }; +} + +function addOrigin(fixture) { + const remote = path.join(fixture.root, 'remote.git'); + git(fixture.root, 'init', '--bare', '--initial-branch=main', remote); + git(fixture.repo, 'remote', 'add', 'origin', remote); + git(fixture.repo, 'push', '-u', 'origin', 'main'); + return remote; +} + +function addDetachedWorktree(fixture, ref = 'main') { + const worktree = path.join(fixture.root, `worktree-${Math.random().toString(16).slice(2)}`); + git(fixture.repo, 'worktree', 'add', '--detach', worktree, ref); + return worktree; +} + +function commitFile(repo, text) { + writeFileSync(path.join(repo, 'file.txt'), `${text}\n`); + git(repo, 'add', 'file.txt'); + git(repo, 'commit', '-m', text); + return git(repo, 'rev-parse', 'HEAD'); +} + +function runHook(cwd, source = 'startup') { + const result = spawnSync('node', [hook], { + cwd, + encoding: 'utf8', + input: JSON.stringify({ cwd, hook_event_name: 'SessionStart', source }), + env: { ...process.env, GIT_CONFIG_NOSYSTEM: '1' }, + }); + assert.equal(result.status, 0, result.stderr); + return result.stdout.trim() ? JSON.parse(result.stdout) : null; +} + +test('moves a new detached main worktree to a newer origin/main', () => { + const fixture = initRepo(); + const remote = addOrigin(fixture); + const worktree = addDetachedWorktree(fixture); + const peer = path.join(fixture.root, 'peer'); + git(fixture.root, 'clone', remote, peer); + git(peer, 'config', 'user.name', 'Codex Hook Test'); + git(peer, 'config', 'user.email', 'codex-hook@example.test'); + const latest = commitFile(peer, 'remote-ahead'); + git(peer, 'push', 'origin', 'main'); + + const output = runHook(worktree); + + assert.equal(output.continue, true); + assert.equal(git(worktree, 'rev-parse', 'HEAD'), latest); + assert.match(output.hookSpecificOutput.additionalContext, /Moved the new worktree/); +}); + +test('allows a detached main worktree that already matches origin/main', () => { + const fixture = initRepo(); + addOrigin(fixture); + const worktree = addDetachedWorktree(fixture); + + const output = runHook(worktree); + + assert.equal(output.continue, true); + assert.match(output.hookSpecificOutput.additionalContext, /already matches/); +}); + +test('preserves unrelated remote refs while fetching origin/main', () => { + const fixture = initRepo(); + addOrigin(fixture); + git(fixture.repo, 'config', 'fetch.prune', 'true'); + git(fixture.repo, 'config', 'remote.origin.prune', 'true'); + const stale = git(fixture.repo, 'rev-parse', 'HEAD'); + git(fixture.repo, 'update-ref', 'refs/remotes/origin/stale', stale); + const worktree = addDetachedWorktree(fixture); + + const output = runHook(worktree); + + assert.equal(output.continue, true); + assert.equal(git(fixture.repo, 'rev-parse', 'refs/remotes/origin/stale'), stale); +}); + +test('uses local main when the repository has no origin', () => { + const fixture = initRepo(); + const worktree = addDetachedWorktree(fixture); + const before = git(worktree, 'rev-parse', 'HEAD'); + + const output = runHook(worktree); + + assert.equal(output.continue, true); + assert.equal(git(worktree, 'rev-parse', 'HEAD'), before); + assert.match(output.hookSpecificOutput.additionalContext, /no origin remote/); +}); + +test('blocks the primary checkout on main', () => { + const fixture = initRepo(); + + const output = runHook(fixture.repo); + + assert.equal(output.continue, false); + assert.match(output.stopReason, /Worktree mode/); +}); + +test('leaves the primary checkout on a feature branch alone', () => { + const fixture = initRepo(); + git(fixture.repo, 'switch', '-c', 'feature'); + + assert.equal(runHook(fixture.repo), null); +}); + +test('honors a detached worktree created from a feature branch', () => { + const fixture = initRepo(); + addOrigin(fixture); + git(fixture.repo, 'switch', '-c', 'feature'); + const featureHead = commitFile(fixture.repo, 'feature-work'); + git(fixture.repo, 'switch', 'main'); + const worktree = addDetachedWorktree(fixture, 'feature'); + + assert.equal(runHook(worktree), null); + assert.equal(git(worktree, 'rev-parse', 'HEAD'), featureHead); +}); + +test('leaves a branch-attached worktree alone', () => { + const fixture = initRepo(); + const worktree = path.join(fixture.root, 'feature-worktree'); + git(fixture.repo, 'worktree', 'add', '-b', 'feature', worktree, 'main'); + + assert.equal(runHook(worktree), null); +}); + +test('blocks a dirty detached main worktree', () => { + const fixture = initRepo(); + addOrigin(fixture); + const worktree = addDetachedWorktree(fixture); + writeFileSync(path.join(worktree, 'file.txt'), 'dirty\n'); + + const output = runHook(worktree); + + assert.equal(output.continue, false); + assert.match(output.stopReason, /tracked or staged changes/); +}); + +test('blocks when local main is ahead of origin/main', () => { + const fixture = initRepo(); + addOrigin(fixture); + commitFile(fixture.repo, 'local-ahead'); + const worktree = addDetachedWorktree(fixture); + + const output = runHook(worktree); + + assert.equal(output.continue, false); + assert.match(output.stopReason, /is ahead of origin\/main/); +}); + +test('blocks when local main and origin/main have split', () => { + const fixture = initRepo(); + const remote = addOrigin(fixture); + const peer = path.join(fixture.root, 'peer'); + git(fixture.root, 'clone', remote, peer); + git(peer, 'config', 'user.name', 'Codex Hook Test'); + git(peer, 'config', 'user.email', 'codex-hook@example.test'); + commitFile(peer, 'remote-change'); + git(peer, 'push', 'origin', 'main'); + commitFile(fixture.repo, 'local-change'); + const worktree = addDetachedWorktree(fixture); + + const output = runHook(worktree); + + assert.equal(output.continue, false); + assert.match(output.stopReason, /have split/); +}); + +test('updates origin/main after a safe force-push', () => { + const fixture = initRepo(); + const initial = git(fixture.repo, 'rev-parse', 'HEAD'); + const remote = addOrigin(fixture); + const worktree = addDetachedWorktree(fixture); + const peer = path.join(fixture.root, 'peer'); + git(fixture.root, 'clone', remote, peer); + git(peer, 'config', 'user.name', 'Codex Hook Test'); + git(peer, 'config', 'user.email', 'codex-hook@example.test'); + const replaced = commitFile(peer, 'replaced-remote-change'); + git(peer, 'push', 'origin', 'main'); + git(fixture.repo, 'fetch', 'origin'); + assert.equal(git(fixture.repo, 'rev-parse', 'refs/remotes/origin/main'), replaced); + git(peer, 'reset', '--hard', initial); + const replacement = commitFile(peer, 'replacement-remote-change'); + git(peer, 'push', '--force', 'origin', 'main'); + + const output = runHook(worktree); + + assert.equal(output.continue, true); + assert.equal(git(fixture.repo, 'rev-parse', 'refs/remotes/origin/main'), replacement); + assert.equal(git(worktree, 'rev-parse', 'HEAD'), replacement); +}); + +test('does not fetch tags while updating origin/main', () => { + const fixture = initRepo(); + const remote = addOrigin(fixture); + const worktree = addDetachedWorktree(fixture); + const peer = path.join(fixture.root, 'peer'); + git(fixture.root, 'clone', remote, peer); + git(peer, 'config', 'user.name', 'Codex Hook Test'); + git(peer, 'config', 'user.email', 'codex-hook@example.test'); + const latest = commitFile(peer, 'tagged-remote-change'); + git(peer, 'tag', 'fetched-by-accident'); + git(peer, 'push', 'origin', 'main'); + git(peer, 'push', 'origin', 'fetched-by-accident'); + assert.throws(() => git(fixture.repo, 'rev-parse', '--verify', 'refs/tags/fetched-by-accident')); + + const output = runHook(worktree); + + assert.equal(output.continue, true); + assert.equal(git(worktree, 'rev-parse', 'HEAD'), latest); + assert.throws(() => git(fixture.repo, 'rev-parse', '--verify', 'refs/tags/fetched-by-accident')); +}); + +test('blocks when fetch fails', () => { + const fixture = initRepo(); + addOrigin(fixture); + const worktree = addDetachedWorktree(fixture); + git(fixture.repo, 'remote', 'set-url', 'origin', path.join(fixture.root, 'missing.git')); + + const output = runHook(worktree); + + assert.equal(output.continue, false); + assert.match(output.stopReason, /Could not fetch origin\/main/); +}); + +test('blocks when origin has no main branch', () => { + const fixture = initRepo(); + const remote = path.join(fixture.root, 'remote.git'); + git(fixture.root, 'init', '--bare', '--initial-branch=master', remote); + git(fixture.repo, 'remote', 'add', 'origin', remote); + git(fixture.repo, 'push', 'origin', 'main:master'); + const worktree = addDetachedWorktree(fixture); + + const output = runHook(worktree); + + assert.equal(output.continue, false); + assert.match(output.stopReason, /Could not fetch origin\/main/); + assert.match(output.stopReason, /couldn't find remote ref refs\/heads\/main/); +}); + +test('allows untracked setup files while moving to origin/main', () => { + const fixture = initRepo(); + const remote = addOrigin(fixture); + const worktree = addDetachedWorktree(fixture); + writeFileSync(path.join(worktree, 'local.env'), 'local=true\n'); + const peer = path.join(fixture.root, 'peer'); + git(fixture.root, 'clone', remote, peer); + git(peer, 'config', 'user.name', 'Codex Hook Test'); + git(peer, 'config', 'user.email', 'codex-hook@example.test'); + const latest = commitFile(peer, 'remote-ahead'); + git(peer, 'push', 'origin', 'main'); + + const output = runHook(worktree); + + assert.equal(output.continue, true); + assert.equal(git(worktree, 'rev-parse', 'HEAD'), latest); +}); + +test('preserves an untracked file when origin/main starts tracking its path', () => { + const fixture = initRepo(); + const remote = addOrigin(fixture); + const worktree = addDetachedWorktree(fixture); + const before = git(worktree, 'rev-parse', 'HEAD'); + writeFileSync(path.join(worktree, 'local.env'), 'local setup\n'); + const peer = path.join(fixture.root, 'peer'); + git(fixture.root, 'clone', remote, peer); + git(peer, 'config', 'user.name', 'Codex Hook Test'); + git(peer, 'config', 'user.email', 'codex-hook@example.test'); + writeFileSync(path.join(peer, 'local.env'), 'remote setup\n'); + git(peer, 'add', 'local.env'); + git(peer, 'commit', '-m', 'track setup path'); + git(peer, 'push', 'origin', 'main'); + + const output = runHook(worktree); + + assert.equal(output.continue, false); + assert.equal(git(worktree, 'rev-parse', 'HEAD'), before); + assert.equal(readFileSync(path.join(worktree, 'local.env'), 'utf8'), 'local setup\n'); +}); + +test('does not run post-checkout hooks while moving the worktree', () => { + const fixture = initRepo(); + const remote = addOrigin(fixture); + const worktree = addDetachedWorktree(fixture); + writeFileSync(path.join(worktree, 'local.env'), 'keep me\n'); + const peer = path.join(fixture.root, 'peer'); + git(fixture.root, 'clone', remote, peer); + git(peer, 'config', 'user.name', 'Codex Hook Test'); + git(peer, 'config', 'user.email', 'codex-hook@example.test'); + commitFile(peer, 'remote-ahead'); + git(peer, 'push', 'origin', 'main'); + const hook = git(worktree, 'rev-parse', '--git-path', 'hooks/post-checkout'); + writeFileSync(hook, '#!/bin/sh\nprintf "changed by hook\\n" > "$PWD/local.env"\n'); + chmodSync(hook, 0o755); + + const output = runHook(worktree); + + assert.equal(output.continue, true); + assert.equal(readFileSync(path.join(worktree, 'local.env'), 'utf8'), 'keep me\n'); +}); + +test('preserves an unrelated nested Git checkout while moving the worktree', () => { + const fixture = initRepo(); + const remote = addOrigin(fixture); + const worktree = addDetachedWorktree(fixture); + const nested = path.join(worktree, 'nested'); + mkdirSync(nested); + git(nested, 'init', '--initial-branch=main'); + git(nested, 'config', 'user.name', 'Codex Hook Test'); + git(nested, 'config', 'user.email', 'codex-hook@example.test'); + writeFileSync(path.join(nested, 'local.txt'), 'keep me\n'); + git(nested, 'add', 'local.txt'); + git(nested, 'commit', '-m', 'nested local'); + const nestedHead = git(nested, 'rev-parse', 'HEAD'); + const peer = path.join(fixture.root, 'peer'); + git(fixture.root, 'clone', remote, peer); + git(peer, 'config', 'user.name', 'Codex Hook Test'); + git(peer, 'config', 'user.email', 'codex-hook@example.test'); + const latest = commitFile(peer, 'remote-ahead'); + git(peer, 'push', 'origin', 'main'); + + const output = runHook(worktree); + + assert.equal(output.continue, true); + assert.equal(git(worktree, 'rev-parse', 'HEAD'), latest); + assert.equal(git(nested, 'rev-parse', 'HEAD'), nestedHead); + assert.equal(readFileSync(path.join(nested, 'local.txt'), 'utf8'), 'keep me\n'); +}); + +test('preserves a nested Git checkout when origin/main collides with its file', () => { + const fixture = initRepo(); + const remote = addOrigin(fixture); + const worktree = addDetachedWorktree(fixture); + const before = git(worktree, 'rev-parse', 'HEAD'); + const nested = path.join(worktree, 'nested'); + mkdirSync(nested); + git(nested, 'init', '--initial-branch=main'); + git(nested, 'config', 'user.name', 'Codex Hook Test'); + git(nested, 'config', 'user.email', 'codex-hook@example.test'); + writeFileSync(path.join(nested, 'local.txt'), 'keep me\n'); + git(nested, 'add', 'local.txt'); + git(nested, 'commit', '-m', 'nested local'); + const nestedHead = git(nested, 'rev-parse', 'HEAD'); + const peer = path.join(fixture.root, 'peer'); + git(fixture.root, 'clone', remote, peer); + git(peer, 'config', 'user.name', 'Codex Hook Test'); + git(peer, 'config', 'user.email', 'codex-hook@example.test'); + mkdirSync(path.join(peer, 'nested')); + writeFileSync(path.join(peer, 'nested/local.txt'), 'remote content\n'); + git(peer, 'add', 'nested/local.txt'); + git(peer, 'commit', '-m', 'track nested path'); + git(peer, 'push', 'origin', 'main'); + + const output = runHook(worktree); + + assert.equal(output.continue, false); + assert.equal(git(worktree, 'rev-parse', 'HEAD'), before); + assert.equal(git(nested, 'rev-parse', 'HEAD'), nestedHead); + assert.equal(readFileSync(path.join(nested, 'local.txt'), 'utf8'), 'keep me\n'); +}); + +test('does not recurse into submodules while moving the worktree', () => { + const fixture = initRepo(); + const submodule = path.join(fixture.root, 'submodule'); + mkdirSync(submodule); + git(submodule, 'init', '--initial-branch=main'); + git(submodule, 'config', 'user.name', 'Codex Hook Test'); + git(submodule, 'config', 'user.email', 'codex-hook@example.test'); + writeFileSync(path.join(submodule, 'sub.txt'), 'one\n'); + git(submodule, 'add', 'sub.txt'); + git(submodule, 'commit', '-m', 'submodule-one'); + const firstSubmoduleHead = git(submodule, 'rev-parse', 'HEAD'); + git( + fixture.repo, + '-c', + 'protocol.file.allow=always', + 'submodule', + 'add', + submodule, + 'vendor/sub', + ); + git(fixture.repo, 'commit', '-am', 'add submodule'); + const remote = addOrigin(fixture); + const worktree = addDetachedWorktree(fixture); + git( + worktree, + '-c', + 'protocol.file.allow=always', + 'submodule', + 'update', + '--init', + ); + git(worktree, 'config', 'submodule.recurse', 'true'); + const worktreeSubmodule = path.join(worktree, 'vendor/sub'); + writeFileSync(path.join(worktreeSubmodule, 'local.txt'), 'keep me\n'); + writeFileSync(path.join(submodule, 'sub.txt'), 'two\n'); + git(submodule, 'add', 'sub.txt'); + git(submodule, 'commit', '-m', 'submodule-two'); + const secondSubmoduleHead = git(submodule, 'rev-parse', 'HEAD'); + const peer = path.join(fixture.root, 'peer'); + git(fixture.root, 'clone', remote, peer); + git(peer, 'config', 'user.name', 'Codex Hook Test'); + git(peer, 'config', 'user.email', 'codex-hook@example.test'); + git( + peer, + '-c', + 'protocol.file.allow=always', + 'submodule', + 'update', + '--init', + ); + const peerSubmodule = path.join(peer, 'vendor/sub'); + git(peerSubmodule, 'fetch', 'origin'); + git(peerSubmodule, 'checkout', secondSubmoduleHead); + git(peer, 'add', 'vendor/sub'); + git(peer, 'commit', '-m', 'update submodule'); + const latest = git(peer, 'rev-parse', 'HEAD'); + git(peer, 'push', 'origin', 'main'); + + const output = runHook(worktree); + + assert.equal(output.continue, true); + assert.equal(git(worktree, 'rev-parse', 'HEAD'), latest); + assert.equal(git(worktreeSubmodule, 'rev-parse', 'HEAD'), firstSubmoduleHead); + assert.equal(readFileSync(path.join(worktreeSubmodule, 'local.txt'), 'utf8'), 'keep me\n'); +}); + +test('does nothing for resume and non-Git starts', () => { + const fixture = initRepo(); + const plain = path.join(fixture.root, 'plain'); + mkdirSync(plain); + + assert.equal(runHook(fixture.repo, 'resume'), null); + assert.equal(runHook(plain), null); +}); + +test('preserves ignored setup files when origin/main starts tracking their path', () => { + const fixture = initRepo(); + writeFileSync(path.join(fixture.repo, '.gitignore'), 'local.env\n'); + git(fixture.repo, 'add', '.gitignore'); + git(fixture.repo, 'commit', '-m', 'ignore local setup'); + const remote = addOrigin(fixture); + const worktree = addDetachedWorktree(fixture); + const before = git(worktree, 'rev-parse', 'HEAD'); + writeFileSync(path.join(worktree, 'local.env'), 'local setup\n'); + const peer = path.join(fixture.root, 'peer'); + git(fixture.root, 'clone', remote, peer); + git(peer, 'config', 'user.name', 'Codex Hook Test'); + git(peer, 'config', 'user.email', 'codex-hook@example.test'); + writeFileSync(path.join(peer, 'local.env'), 'remote setup\n'); + git(peer, 'add', '--force', 'local.env'); + git(peer, 'commit', '-m', 'track setup path'); + git(peer, 'push', 'origin', 'main'); + + const output = runHook(worktree); + + assert.equal(output.continue, false); + assert.equal(git(worktree, 'rev-parse', 'HEAD'), before); + assert.equal(readFileSync(path.join(worktree, 'local.env'), 'utf8'), 'local setup\n'); +}); diff --git a/skills/orchestration/SKILL.md b/skills/orchestration/SKILL.md index df80f5f..d0395df 100644 --- a/skills/orchestration/SKILL.md +++ b/skills/orchestration/SKILL.md @@ -1,6 +1,6 @@ --- name: orchestration -description: Decide whether to delegate and coordinate agents for implementation, research, or verification. Use when choosing workers, splitting work, or integrating their results; independent review uses the routing reference under review-fix-loop. +description: Decide whether to delegate and coordinate agents for implementation, research, or verification. Use when choosing workers, splitting work, or integrating their results. --- # Orchestration diff --git a/skills/review-fix-loop/SKILL.md b/skills/review-fix-loop/SKILL.md index 662a43d..9caa350 100644 --- a/skills/review-fix-loop/SKILL.md +++ b/skills/review-fix-loop/SKILL.md @@ -1,6 +1,6 @@ --- name: review-fix-loop -description: Review a branch, PR, or local diff; fix accepted findings and verify before handoff or push. +description: Review a branch, PR, or local diff; fix accepted findings and verify before handoff or push. Use when the user asks to review project changes and before handing off code changes that affect behavior or docs that change agent behavior. --- # Review Fix Loop