diff --git a/.gitignore b/.gitignore index e0a2660..26ec0e5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ *.bak *.bak-* node_modules/ +.shift/ diff --git a/README.md b/README.md index 5c7576c..eea0110 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,12 @@ Transparency isn't a feature bolted on the side; for agentic coding it's the who | Module | What it is | Targets | |---|---|---| | [**code-status-bar**](./code-status-bar) | A status line that shows usage limits, cost, context health, and git/worktree state at a glance | Claude Code (via [ccstatusline](https://github.com/sirmalloc/ccstatusline)) | +| [**shift**](./shift) | An autonomous work-queue runner: pre-load bins of work, leave, and it keeps the agent grinding through them — past natural stop points and across rate-limit resets — leaving every decision logged and every change a reviewable commit | Claude Code (Stop hook + headless `-p`) | > **New here? Start with the [Code Status Bar](./code-status-bar).** It installs as a portable, zero-dependency default, or an [opt-in colored variant](./code-status-bar#color--static-by-default-status-driven-by-opt-in) that recolors the usage bars **green → yellow → red** as you approach each limit — so you *feel* a wall coming before you read a single number. You could build it by hand in ccstatusline's editor; this is that setup already done — one command, no configuration, and still fully editable. +> **Going heads-down?** [**shift**](./shift) turns an unattended run — the *least* transparent mode there is — into an honest paper trail: you trade real-time steering for a `shift/` branch, a decision log, and a "here's what I did and what needs you" summary. One command wires the hook; the safety model keeps the work on a branch and off your remotes. + More to come. Each module is self-contained, declares which agent it targets, and explains *why* every piece earns its place — because justifying the real estate is part of the philosophy. ## License diff --git a/code-status-bar/install.sh b/code-status-bar/install.sh index 12e2b87..b023a2d 100755 --- a/code-status-bar/install.sh +++ b/code-status-bar/install.sh @@ -12,17 +12,47 @@ DEST="$CONFIG_DIR/settings.json" COLORED=0 [ "${1:-}" = "--colored" ] && COLORED=1 +if [ "$COLORED" -eq 1 ] && ! command -v node >/dev/null 2>&1; then + echo "Error: --colored needs Node on your PATH (the helper runs via node)." >&2 + echo "Install Node, or use the default (no-flag) config." >&2 + exit 1 +fi + mkdir -p "$CONFIG_DIR" + +# Only treat the script's directory as a real clone if it actually contains this +# module (both files present). When run via `curl | bash`, BASH_SOURCE is unset and +# this stays 0, so we always download instead of copying a stray local file. SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd || echo "")" +LOCAL_OK=0 +if [ -n "$SCRIPT_DIR" ] && [ -f "$SCRIPT_DIR/install.sh" ] && [ -f "$SCRIPT_DIR/settings.json" ]; then + LOCAL_OK=1 +fi -# fetch — prefer a local clone, fall back to download. +# fetch : download (or copy from a verified clone) to a +# temp file, validate, then move into place — so a failed fetch never leaves a broken +# or empty config at the destination. fetch() { - local rel="$1" out="$2" - if [ -n "$SCRIPT_DIR" ] && [ -f "$SCRIPT_DIR/$rel" ]; then - cp "$SCRIPT_DIR/$rel" "$out" + local rel="$1" out="$2" tmp + tmp="$(mktemp)" + if [ "$LOCAL_OK" -eq 1 ] && [ -f "$SCRIPT_DIR/$rel" ]; then + cp "$SCRIPT_DIR/$rel" "$tmp" else - curl -fsSL "$REPO_RAW/$rel" -o "$out" + curl -fsSL "$REPO_RAW/$rel" -o "$tmp" + fi + if [ ! -s "$tmp" ]; then + echo "Error: fetched '$rel' is empty; aborting (your existing config is untouched)." >&2 + rm -f "$tmp"; exit 1 fi + case "$rel" in + *.json) + if command -v node >/dev/null 2>&1; then + node -e 'JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"))' "$tmp" 2>/dev/null \ + || { echo "Error: fetched '$rel' is not valid JSON; aborting." >&2; rm -f "$tmp"; exit 1; } + fi + ;; + esac + mv "$tmp" "$out" } if [ -f "$DEST" ]; then @@ -37,7 +67,6 @@ if [ "$COLORED" -eq 1 ]; then fetch "settings.colored.json" "$DEST" echo "Installed COLORED variant -> $DEST" echo "Helper script -> $SCRIPTS_DIR/usage-bar.cjs" - echo "(needs Node on your PATH at render time — ccstatusline already provides it)" else fetch "settings.json" "$DEST" echo "Installed -> $DEST" diff --git a/code-status-bar/package.json b/code-status-bar/package.json new file mode 100644 index 0000000..8df5cd1 --- /dev/null +++ b/code-status-bar/package.json @@ -0,0 +1,8 @@ +{ + "name": "code-status-bar", + "version": "0.1.0", + "private": true, + "description": "Usage-limit-aware Claude Code status bar (Agentic Workflow Toolkit module 1)", + "engines": { "node": ">=18" }, + "scripts": { "test": "node --test" } +} diff --git a/code-status-bar/test/usage-bar.test.cjs b/code-status-bar/test/usage-bar.test.cjs new file mode 100644 index 0000000..40e7284 --- /dev/null +++ b/code-status-bar/test/usage-bar.test.cjs @@ -0,0 +1,73 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert'); +const cp = require('node:child_process'); +const path = require('node:path'); + +const SCRIPT = path.resolve(__dirname, '..', 'scripts', 'usage-bar.cjs'); +const ESC = '\x1b'; +const YELLOW = `${ESC}[38;2;252;233;79m`; +const GREEN = `${ESC}[38;2;138;226;52m`; +const RED = `${ESC}[38;2;239;41;41m`; + +function run(args, payload) { + return cp.execFileSync('node', [SCRIPT, ...args], { + input: payload === undefined ? '' : JSON.stringify(payload), + encoding: 'utf8' + }); +} + +function rl(over) { + const now = Math.floor(Date.now() / 1000); + return { + rate_limits: Object.assign({ + five_hour: { used_percentage: 72, resets_at: now + 7200 }, + seven_day: { used_percentage: 41, resets_at: now + 432000 }, + seven_day_opus: { used_percentage: 88, resets_at: now + 432000 } + }, over || {}) + }; +} + +test('session at 72% renders bold yellow with label and percent', () => { + const out = run(['session'], rl()); + assert.ok(out.includes(YELLOW), 'expected yellow'); + assert.ok(out.includes('Session: '), 'expected label'); + assert.ok(out.includes('72.0%'), 'expected percent'); + assert.ok(out.startsWith(`${ESC}[1m`), 'expected bold prefix'); +}); + +test('weekly at 41% is green, opus at 88% is red', () => { + assert.ok(run(['weekly'], rl()).includes(GREEN)); + assert.ok(run(['opus'], rl()).includes(RED)); +}); + +test('multiple limits are joined with a separator', () => { + const out = run(['weekly', 'opus'], rl()); + assert.ok(out.includes('Weekly: ')); + assert.ok(out.includes('Weekly Opus: ')); + assert.ok(out.includes(' | ')); +}); + +test('absent data renders nothing so the widget collapses', () => { + assert.equal(run(['session'], {}), ''); + assert.equal(run(['session']), ''); + assert.equal(run(['session'], rl({ five_hour: undefined })), ''); +}); + +test('non-numeric percentage renders nothing', () => { + assert.equal(run(['session'], rl({ five_hour: { used_percentage: 'oops', resets_at: 0 } })), ''); +}); + +test('thresholds: 50 -> yellow, just under -> green; 85 -> red, just under -> yellow', () => { + const at = (p) => run(['session'], rl({ + five_hour: { used_percentage: p, resets_at: Math.floor(Date.now() / 1000) + 1 } + })); + assert.ok(at(50).includes(YELLOW), '50 should be yellow'); + assert.ok(at(49.9).includes(GREEN), '49.9 should be green'); + assert.ok(at(85).includes(RED), '85 should be red'); + assert.ok(at(84.9).includes(YELLOW), '84.9 should be yellow'); +}); + +test('unknown limit name renders nothing', () => { + assert.equal(run(['bogus'], rl()), ''); +}); diff --git a/shift/PLAN.md b/shift/PLAN.md index 5eddb56..387f464 100644 --- a/shift/PLAN.md +++ b/shift/PLAN.md @@ -1018,3 +1018,28 @@ Re-run with `maxIterations: 1`; confirm the run ends on "max iterations" with pe - **Testing strategy (unit pure modules + integration hook/CLI + manual smoke + dry-run):** Tasks 1–7, 9. ✔ - **No third-party deps:** all `node:` built-ins. ✔ - **Known gaps (deferred, documented):** usage-cap data source and rate-limit termination signature → v2 (SPEC §9). Mid-bin early-stop accepted in v1, reviewer-caught; verify pass → v3. + +--- + +## Implementation notes (as-built deviations) + +Built on branch `shift-v1`. The draft code blocks above are the design intent; these corrections were applied during implementation: + +- **`state.cjs` — carry `text` through the merge, strip it on save.** `mergeDiscovered` copies each bin's freshly-read `text` into the in-memory bin (the brief needs the body); `saveState` strips `text` before writing so `state.json` stays lean. Without this the fed-back brief had the instructions but not the task body — caught by the hook integration test, not the unit tests. +- **Review fix #2 — `shift-stop.cjs` resolves the repo from the hook payload's `cwd`** (`input.cwd || process.cwd()`); a hook's process cwd isn't guaranteed to be the project root. Has a dedicated test. +- **Review fix #3 — summary surfaces logged `Needs you:` lines**, not just blocked bins; `brief.cjs` documents the `Needs you: ` convention. Has a test. +- **Security — `bin/shift` uses `execFileSync('git', [...args])`** (argument array, no shell) for branch ops, so a config-supplied branch name can't inject shell metacharacters; added `git checkout` fallbacks for Git < 2.23. +- **`package.json` — `"test": "node --test"`** (Node ≥18 auto-discovery; a bare `test/` arg isn't accepted) and `"engines": { "node": ">=18" }`. + +All 28 `shift` tests + 7 `code-status-bar` tests pass; `install.sh` verified end-to-end. + +--- + +## v2 + v3 (built on the same branch) + +Added after v1, same TDD discipline (52 `shift` tests total). See SPEC §13 for the design decisions. + +- **v3 verify gate** — `lib/verify.cjs` (injectable exec) + a gate in the Stop hook: a bin passes only if `verify.command` exits 0; failures re-feed the bin with the output up to `verify.maxAttempts`, then block it. Tests: `verify.test.cjs` + hook gate cases. +- **v2 usage cap** — `lib/usage.cjs` caches the hook payload's `rate_limits` to `.shift/usage.json`; `evaluateBounds` gains a `usagePercent` arg (cap on weekly %); the hook reads it from the payload and degrades gracefully when absent. Tests: `usage.test.cjs` + bounds/decision/hook cases. +- **v2 headless runner** — `lib/outcome.cjs` (classify a spawn: completed / rate_limited / error, inferring rate-limit from cached usage since the exit signature is undocumented) + `lib/run-loop.cjs` (pure outer loop with injected effects: bounds, max-resumes backstop, wait-until-reset auto-resume) + `bin/shift run` (thin real-effects wiring). Tests: `outcome.test.cjs`, `run-loop.test.cjs`. +- **Security** — `lib/verify.cjs` uses `spawnSync(command, { shell: true })` with the whole user-config command (not interpolated); documented inline. diff --git a/shift/README.md b/shift/README.md new file mode 100644 index 0000000..8bb45c0 --- /dev/null +++ b/shift/README.md @@ -0,0 +1,111 @@ +# shift + +Autonomous work-queue runner for **Claude Code** — module 2 of the [Agentic Workflow Toolkit](../). Pre-load bins of work, leave, and `shift` keeps Claude working through them past natural stop points, using its best judgment, until the queue is empty or a bound is hit — surviving the 5-hour rate-limit wall by waiting for the window to reopen. You review the output at the end. + +See [SPEC.md](./SPEC.md) and [PLAN.md](./PLAN.md) for the design. + +## How it works + +You drop work into source folders — hand-written briefs and/or plugin-generated plans (e.g. Superpowers' plans dir). `shift start` discovers them, records a run in `.shift/`, and creates a `shift/` branch. Then: + +- **Keep-going engine (Stop hook).** Each time the agent would stop, the hook marks the finished bin done, picks the next pending one, and feeds it back as the next instruction — so the session keeps working. When the queue drains (or a bound trips, or the kill switch is set) it lets the session stop and writes `.shift/summary.md`. +- **Verify gate.** If you set a `verify.command`, each bin must pass it (e.g. `npm test`) before it counts as done; failures re-feed the bin with the output (up to `maxAttempts`), then mark it blocked. This catches "looked done but wasn't." +- **All-day runner (`shift run`).** A headless outer loop that spawns Claude, lets the engine grind, and — when a spawn dies on the rate-limit wall — waits until the window resets and resumes. Bounded by wall-clock, max iterations, a usage cap, and a resume backstop. + +The hook is safe to register globally: it no-ops in any repo that isn't an active `shift` run, and resolves the repo from the hook payload's `cwd`. + +## Safety model + +Full best-judgment autonomy on reversible, in-worktree work. By default it will **not** push, publish, send externally, or delete outside the worktree — it does the preparable part and records a `Needs you:` line, which the summary collects. All work lands on the `shift/` branch, so review is a clean diff. Every decision is logged. Hard stops: time box, max iterations, usage cap, kill switch (`shift stop`). + +## Install + +1. Clone the toolkit (the hook runs from these files by absolute path, so it installs locally — no `curl | bash`). +2. Wire the Stop hook into `~/.claude/settings.json` — one command, idempotent: + +```bash +bash shift/install.sh +``` + +It merges the entry below (safe globally — the hook no-ops in any repo without an active `.shift/` run), backs up any existing settings first, and never duplicates on re-run — re-running after a `git pull` or a repo move just updates the path: + +```json +{ "hooks": { "Stop": [ + { "matcher": "", "hooks": [ + { "type": "command", "command": "node /ABSOLUTE/PATH/TO/shift/hooks/shift-stop.cjs" } + ] } +] } } +``` + +> **Hook contract (verified against the [Claude Code hooks docs](https://code.claude.com/docs/en/hooks)).** The Stop hook returns `{"decision":"block","reason":…}` to keep the session going — the `reason` becomes the next instruction — and omits `decision` (or exits 0) to allow the stop. The usage cap and `shift run` auto-resume read the hook payload's `rate_limits` when present and **skip cleanly when it's absent** (e.g. non-Pro/Max), so the engine never depends on it. + +3. (Optional) put `shift/bin/shift` on your PATH — the installer prints the `ln -s` command. + +## Use + +```bash +cd your-repo +mkdir queue && $EDITOR queue/01-first-task.md # one brief per file +shift start --dry-run # preview the queue, branch, bounds +shift start # init run + create shift/ branch +``` + +Then either: + +- **Interactive:** open Claude Code in the repo and say *"begin the shift"* — the Stop hook drives it while you're away (within this session). +- **All-day / unattended:** `shift run` — the headless loop drives Claude, survives rate-limit resets, and stops on a bound. + +```bash +shift status # progress anytime +shift stop # stop cleanly after the current bin +``` + +When it ends, read `.shift/summary.md` (bins done/blocked + a "Needs you" section) and review the `shift/` branch. + +## Configure (`.shift/config.json`) + +```json +{ + "sources": [ + { "path": "queue", "kind": "briefs" }, + { "path": "docs/superpowers/plans", "kind": "plans" } + ], + "bounds": { + "maxHours": 4, + "maxIterations": 30, + "maxResumes": 12, + "spawnTimeoutMinutes": 30, + "usageCapPercent": 90, + "autoResumeOnReset": true + }, + "definitionOfDone": "Builds and tests pass; work committed on the run branch.", + "verify": { "command": "npm test", "maxAttempts": 2 }, + "permissionMode": "acceptEdits", + "git": { "branch": "shift/{date}", "allowPush": false, "allowOutwardActions": false } +} +``` + +- **`usageCapPercent`** — stop when weekly usage reaches this (read from the hook payload's `rate_limits`; skipped when that data is absent, e.g. non-Pro/Max). +- **`autoResumeOnReset`** — on a rate-limit wall, `shift run` waits for the 5-hour window to reopen and resumes (never past the time box). If the cached reset time is stale/in the past it stops cleanly rather than busy-spinning. +- **`maxResumes`** — the runner's own backstop on the number of `claude` spawns (independent of the hook-maintained `maxIterations`/`maxHours`). +- **`spawnTimeoutMinutes`** — hard per-spawn wall: a wedged `claude` is killed (SIGTERM) so it can't hang the runner. Default 30. +- **`verify.command`** — per-bin acceptance gate; `null` disables it. + +> A headless `shift run` grades success on `.shift/summary.md` (written only when the engine finalizes), not on the exit line: a `claude -p` that exits without finalizing is reported as *"no summary written — did NOT finalize"* with a hint to check the hook wiring, never as a false success. + +### Permissions for unattended runs + +`shift run` invokes `claude -p --permission-mode `. `acceptEdits` (the default) auto-approves file edits but **other tools (e.g. Bash) can still prompt — and a headless run can't answer prompts.** For real unattended work that runs tests/commands, either: + +- pre-allow the tools the work needs via `permissions.allow` in your Claude settings and set `"permissionMode": "dontAsk"`, or +- set `"permissionMode": "bypassPermissions"` (broadest; rely on the branch-only / no-push safety model and bounds). + +Pick the narrowest mode that lets the work actually proceed. + +## Develop + +```bash +cd shift && npm test # node --test, zero dependencies +``` + +Pure logic lives in `lib/` (discovery, state, bounds, brief, decision, verify, usage, outcome, run-loop) and is unit-tested; `hooks/shift-stop.cjs` (the keep-going engine) and the `shift run` loop are integration-tested by driving them with injected effects / crafted hook input. diff --git a/shift/SPEC.md b/shift/SPEC.md index b250662..229c6ac 100644 --- a/shift/SPEC.md +++ b/shift/SPEC.md @@ -259,3 +259,27 @@ shift/ └─ examples/ └─ queue/ # sample bins ``` + +--- + +## 13. Implementation status (as built — v1 + v2 + v3) + +All three phases are implemented on branch `shift-v1`. Notable as-built decisions: + +- **Rate-limit detection without the undocumented exit signature (resolves §9.2).** Research confirmed the headless rate-limit termination signature is undocumented, but the **Stop hook payload includes `rate_limits`**. So the engine caches the latest reset/usage to `.shift/usage.json`, and `lib/outcome.cjs` classifies a non-finalized, non-zero spawn as `rate_limited` by **inference** — near-limit cached usage (≥95%) + a future reset — with config-overridable stderr patterns as a fallback. No dependency on an exact exit code/message. +- **Usage cap source (resolves §9.1).** Enforced from the hook payload's `rate_limits.seven_day.used_percentage`; absent data (non-Pro/Max, pre-first-response) degrades to "cap skipped," never an error. +- **Verify gate (v3, resolves §9.3).** `verify.command` runs per bin; failures re-feed the bin with the output up to `maxAttempts`, then block it — so "looked done but wasn't" is caught, not silently accepted. +- **Permissions.** `shift run` uses `--permission-mode` (default `acceptEdits`). Truly unattended work that runs commands typically needs `dontAsk` + a `permissions.allow` list, or `bypassPermissions` — documented in the README; the branch-only/no-push model and bounds are the backstop. The runner now **warns** at startup when `permissionMode` would prompt on Bash (a headless run can't answer), since that combination otherwise exits without finalizing. + +**New modules beyond §12:** `lib/verify.cjs`, `lib/usage.cjs`, `lib/outcome.cjs`, `lib/run-loop.cjs`, `lib/install.cjs`; `bin/shift` gains `run`; `install.sh` wires the Stop hook. + +### Smoke validation + post-smoke hardening (2026-06-15) + +A real bounded `shift run` smoke (2 commit-a-file bins, `bypassPermissions`) **empirically resolved the open question behind §9.2**: headless `claude -p` **does** honor the Stop hook's `{"decision":"block"}` and continues the session warm — both bins were completed and committed within a single spawn. A pre-flight audit of the (previously untested) runner path then drove four fixes: + +- **No false-green.** `classifyOutcome` only returns `completed` when the engine actually finalized (`summary.md` written). A `claude -p` that exits 0 without finalizing is `incomplete` — the runner **resumes** if the queue advanced, else **stops with a "is the Stop hook wired?" diagnostic** instead of reporting success. `shift run` grades on `summary.md`, not the exit line. +- **Stale-reset guard.** Auto-resume stops cleanly when the cached reset time is already in the past (previously a `maxResumes`-bounded busy-spin). +- **Per-spawn timeout.** `spawnTimeoutMinutes` (default 30) kills a wedged `claude` so a blocking `spawnSync` can't hang the runner; launch failures (`claude` not on PATH) and kills are now surfaced, not swallowed. *Known limitation:* the timeout SIGTERMs the `claude` process only, not any tool-subprocess grandchildren it spawned (an inherent `spawnSync` behavior) — a wedged grandchild can outlive the kill; a detached-process-group reap is a future improvement. +- **Hook-install is required for `shift run`** and `install.sh` automates it (the bin's task text reaches the agent only via the Stop-hook block). + +**Tests:** 63 in `shift` (pure unit + hook/CLI/run-loop/install integration), all green. diff --git a/shift/bin/shift b/shift/bin/shift new file mode 100755 index 0000000..2c5f557 --- /dev/null +++ b/shift/bin/shift @@ -0,0 +1,160 @@ +#!/usr/bin/env node +'use strict'; +const fs = require('node:fs'); +const path = require('node:path'); +const cp = require('node:child_process'); +const { discoverBins } = require('../lib/discovery.cjs'); +const { initState, saveState, loadState, mergeDiscovered } = require('../lib/state.cjs'); + +function isoStamp(d) { return d.toISOString().replace(/[:.]/g, '-').slice(0, 19); } +function dateStr(d) { return d.toISOString().slice(0, 10); } + +const DEFAULT_CONFIG = { + sources: [{ path: 'queue', kind: 'briefs' }], + bounds: { + maxHours: 2, + maxIterations: 20, + maxResumes: 12, + spawnTimeoutMinutes: 30, + usageCapPercent: 90, + autoResumeOnReset: true + }, + definitionOfDone: 'Builds and tests pass; work committed on the run branch.', + verify: { command: null, maxAttempts: 2 }, + permissionMode: 'acceptEdits', + git: { branch: 'shift/{date}', allowPush: false, allowOutwardActions: false } +}; + +function ensureBranch(cwd, branch) { + // execFileSync with an argument array — no shell, so a branch name from config + // can't inject shell metacharacters. + for (const args of [ + ['switch', '-c', branch], ['switch', branch], + ['checkout', '-b', branch], ['checkout', branch] + ]) { + try { cp.execFileSync('git', args, { cwd, stdio: 'ignore' }); return true; } catch { /* try next */ } + } + return false; +} + +function cmdStart(args) { + const cwd = process.cwd(); + const dir = path.join(cwd, '.shift'); + const now = new Date(); + const dryRun = args.includes('--dry-run'); + + let config = DEFAULT_CONFIG; + const cfgFile = path.join(dir, 'config.json'); + if (fs.existsSync(cfgFile)) { + config = { ...DEFAULT_CONFIG, ...JSON.parse(fs.readFileSync(cfgFile, 'utf8')) }; + } + const branch = (config.git.branch || 'shift/{date}').replace('{date}', dateStr(now)); + const discovered = discoverBins(config.sources, cwd); + + if (dryRun) { + console.log('shift dry-run'); + console.log(`branch: ${branch}`); + console.log(`bounds: ${JSON.stringify(config.bounds)}`); + console.log(`queue (${discovered.length}):`); + discovered.forEach((b, i) => console.log(` ${i + 1}. ${b.id} [${b.kind}]`)); + return; + } + + fs.mkdirSync(dir, { recursive: true }); + if (fs.existsSync(path.join(dir, 'STOP'))) fs.unlinkSync(path.join(dir, 'STOP')); + fs.writeFileSync(cfgFile, JSON.stringify(config, null, 2)); + let state = initState({ runId: isoStamp(now), startedAt: now.toISOString(), branch }); + state = mergeDiscovered(state, discovered); + saveState(dir, state); + fs.writeFileSync(path.join(dir, 'log.md'), `# shift log — ${state.runId}\n`); + + if (!ensureBranch(cwd, branch)) { + console.log(`warning: could not create/switch to branch ${branch} (is this a git repo?)`); + } + + console.log(`shift started: ${discovered.length} bins on branch ${branch}`); + console.log('Now open Claude Code in this repo and say: "begin the shift".'); +} + +function cmdStatus() { + const state = loadState(path.join(process.cwd(), '.shift')); + const c = s => state.bins.filter(b => b.status === s).length; + console.log(`run ${state.runId} · branch ${state.branch} · iter ${state.iterations}`); + console.log(`bins: ${c('done')} done · ${c('blocked')} blocked · ${c('pending')} pending`); +} + +function cmdStop() { + const dir = path.join(process.cwd(), '.shift'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'STOP'), ''); + console.log('shift will stop cleanly after the current bin.'); +} + +// v2: headless outer loop — keeps spawning claude until the engine finalizes, +// a bound trips, or (on a rate-limit wall) it waits for the window to reopen. +async function cmdRun() { + const cwd = process.cwd(); + const dir = path.join(cwd, '.shift'); + if (!fs.existsSync(path.join(dir, 'state.json'))) { + console.log('No active run. Run `shift start` first.'); + process.exit(1); + } + const config = JSON.parse(fs.readFileSync(path.join(dir, 'config.json'), 'utf8')); + const mode = config.permissionMode || 'acceptEdits'; + const { runLoop } = require('../lib/run-loop.cjs'); + const { readUsageCache } = require('../lib/usage.cjs'); + + // A headless `-p` run cannot answer permission prompts. Only bypassPermissions/dontAsk + // auto-approve tool calls like Bash(git commit) — anything else stalls or denies on the + // first command the work needs (the engine then exits without finalizing). + if (!['bypassPermissions', 'dontAsk'].includes(mode)) { + console.log(`[shift] warning: permissionMode "${mode}" prompts on tools like Bash, which a headless run can't answer.`); + console.log('[shift] set "permissionMode":"dontAsk" (+ permissions.allow) or "bypassPermissions" in .shift/config.json.'); + } + + // Hard per-spawn timeout so a wedged `claude` can't hang the runner forever + // (spawnSync is blocking; the loop's time bounds can't interrupt it). + const spawnTimeoutMs = (((config.bounds && config.bounds.spawnTimeoutMinutes) || 30)) * 60_000; + + // Clear any stale summary so finalized() reflects THIS run. + try { fs.unlinkSync(path.join(dir, 'summary.md')); } catch { /* none */ } + + let first = true; + const effects = { + now: () => Date.now(), + loadState: () => loadState(dir), + readUsage: () => readUsageCache(dir), + log: (m) => console.log(`[shift] ${m}`), + finalized: () => fs.existsSync(path.join(dir, 'summary.md')), + sleepUntil: (ms) => new Promise(r => setTimeout(r, Math.max(0, ms - Date.now()))), + spawn: () => { + const args = ['-p', '--permission-mode', mode]; + if (first) { args.push('begin the shift'); first = false; } + else { args.push('--continue', 'continue the shift'); } + const res = cp.spawnSync('claude', args, { + cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], + maxBuffer: 64 * 1024 * 1024, timeout: spawnTimeoutMs, killSignal: 'SIGTERM' + }); + // Surface launch failures (claude not on PATH → ENOENT) and timeouts/kills — + // otherwise they classify as a bare 'error' with no diagnostics. + if (res && res.error) console.log(`[shift] spawn failed to run claude: ${res.error.message}`); + if (res && res.signal) console.log(`[shift] spawn killed by signal ${res.signal} (timeout ${spawnTimeoutMs / 60000}min?)`); + return res; + } + }; + + const result = await runLoop({ config, effects }); + console.log(`[shift] stopped: ${result.reason} (after ${result.spawns} spawn(s))`); + if (effects.finalized()) { + console.log(`[shift] review: ${path.join(dir, 'summary.md')}`); + } else { + console.log('[shift] no summary written — the run did NOT finalize; see the [shift] lines above. Nothing was committed by the engine.'); + } +} + +const [, , sub, ...rest] = process.argv; +if (sub === 'start') cmdStart(rest); +else if (sub === 'status') cmdStatus(); +else if (sub === 'stop') cmdStop(); +else if (sub === 'run') cmdRun().catch(e => { console.error(e); process.exit(1); }); +else { console.log('usage: shift [--dry-run]'); process.exit(1); } diff --git a/shift/examples/queue/00-hello.md b/shift/examples/queue/00-hello.md new file mode 100644 index 0000000..ecfed01 --- /dev/null +++ b/shift/examples/queue/00-hello.md @@ -0,0 +1,6 @@ +# Add a project HELLO file + +Create a file `HELLO.md` at the repo root containing one sentence describing +what this repository is. Commit it. + +Definition of done: `HELLO.md` exists and is committed on the run branch. diff --git a/shift/hooks/shift-stop.cjs b/shift/hooks/shift-stop.cjs new file mode 100755 index 0000000..16358a4 --- /dev/null +++ b/shift/hooks/shift-stop.cjs @@ -0,0 +1,127 @@ +#!/usr/bin/env node +'use strict'; +const fs = require('node:fs'); +const path = require('node:path'); +const { discoverBins } = require('../lib/discovery.cjs'); +const { loadState, saveState, mergeDiscovered, setBinStatus } = require('../lib/state.cjs'); +const { decide } = require('../lib/decision.cjs'); +const { runVerify } = require('../lib/verify.cjs'); +const { writeUsageCache } = require('../lib/usage.cjs'); + +function readStdin() { try { return fs.readFileSync(0, 'utf8'); } catch { return ''; } } + +function readBlocked(dir) { + try { + return fs.readFileSync(path.join(dir, 'blocked.jsonl'), 'utf8') + .split('\n').filter(Boolean) + .map(l => { try { return JSON.parse(l); } catch { return null; } }) + .filter(Boolean); + } catch { return []; } +} + +function readNeedsYou(dir) { + try { + return fs.readFileSync(path.join(dir, 'log.md'), 'utf8') + .split('\n').map(l => l.match(/^Needs you:\s*(.+)$/)).filter(Boolean).map(m => m[1].trim()); + } catch { return []; } +} + +function tail(s, n) { + if (typeof s !== 'string') return ''; + return s.length > n ? s.slice(s.length - n) : s; +} + +function writeSummary(dir, state, reason, now) { + const done = state.bins.filter(b => b.status === 'done').length; + const blocked = state.bins.filter(b => b.status === 'blocked'); + const pending = state.bins.filter(b => b.status === 'pending').length; + const mins = Math.round((now - Date.parse(state.startedAt)) / 60000); + const items = [ + ...blocked.map(b => `- ${b.id}: ${b.note || 'blocked'}`), + ...readNeedsYou(dir).map(n => `- ${n}`) + ]; + const lines = [ + `# shift summary — ${state.runId}`, '', + `Ended: ${reason}`, + `Duration: ${mins} min · Iterations: ${state.iterations}`, + `Branch: ${state.branch}`, + `Bins: ${done} done · ${blocked.length} blocked · ${pending} pending`, '', + '## Needs you', + ...(items.length ? items : ['- (nothing flagged)']) + ]; + fs.writeFileSync(path.join(dir, 'summary.md'), lines.join('\n') + '\n'); +} + +function main() { + let input = {}; + try { input = JSON.parse(readStdin() || '{}'); } catch { input = {}; } + + // Resolve the repo from the hook payload's cwd (the hook's process cwd is not + // guaranteed to be the project root); fall back to process.cwd(). + const cwd = (input && typeof input.cwd === 'string' && input.cwd) ? input.cwd : process.cwd(); + const dir = path.join(cwd, '.shift'); + if (!fs.existsSync(path.join(dir, 'state.json'))) { process.stdout.write('{}'); return; } + + const config = JSON.parse(fs.readFileSync(path.join(dir, 'config.json'), 'utf8')); + const now = Date.now(); + const killSwitch = fs.existsSync(path.join(dir, 'STOP')); + + // Capture rate limits from the hook payload: enforce the usage cap and cache + // reset times for the headless runner. Absent on non-Pro/Max or pre-first-response. + const usagePercent = writeUsageCache(dir, input.rate_limits, Math.floor(now / 1000)); + + // Re-discover (fresh text + new files) and carry over status/attempts. + let state = mergeDiscovered(loadState(dir), discoverBins(config.sources, cwd)); + + const prevBinId = state.currentBinId; + const verifyCmd = config.verify && config.verify.command; + const maxAttempts = (config.verify && config.verify.maxAttempts) || 2; + let retryFeedback = null; + + // Attribute the just-finished work to the current bin (blocked / verify gate / done). + if (prevBinId) { + const blocked = readBlocked(dir).find(x => x.id === prevBinId); + if (blocked) { + state = setBinStatus(state, prevBinId, { status: 'blocked', note: blocked.note }); + } else if (verifyCmd) { + const v = runVerify(verifyCmd, cwd); + if (v.ok) { + state = setBinStatus(state, prevBinId, { status: 'done', finishedAt: new Date(now).toISOString() }); + } else { + const bin = state.bins.find(b => b.id === prevBinId) || {}; + const attempts = (bin.attempts || 0) + 1; + if (attempts < maxAttempts) { + state = setBinStatus(state, prevBinId, { attempts }); // stays pending → re-blocked below + retryFeedback = `Your previous attempt failed verification (\`${verifyCmd}\`). Fix it and make it pass. Output (tail):\n${tail(v.output, 2000)}`; + } else { + state = setBinStatus(state, prevBinId, { status: 'blocked', attempts, note: `failed verification after ${attempts} attempts` }); + } + } + } else { + state = setBinStatus(state, prevBinId, { status: 'done', finishedAt: new Date(now).toISOString() }); + } + } + + const result = decide({ + bins: state.bins, state, config, now, usagePercent, + stopHookActive: !!input.stop_hook_active, killSwitch + }); + + if (result.action === 'block') { + let reason = result.reason; + if (retryFeedback && result.nextBinId === prevBinId) reason += `\n\n${retryFeedback}`; + state.iterations += 1; + state.currentBinId = result.nextBinId; + saveState(dir, state); + fs.appendFileSync(path.join(dir, 'log.md'), + `\n## ${new Date(now).toISOString()} — work ${result.nextBinId} (iter ${state.iterations})\n`); + process.stdout.write(JSON.stringify({ decision: 'block', reason })); + } else { + state.currentBinId = null; + saveState(dir, state); + writeSummary(dir, state, result.reason, now); + process.stdout.write('{}'); + } +} + +main(); diff --git a/shift/install.sh b/shift/install.sh new file mode 100755 index 0000000..eb291d3 --- /dev/null +++ b/shift/install.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# shift installer — Agentic Workflow Toolkit (module 2) +# Wires shift's Stop hook into ~/.claude/settings.json, idempotently. +# +# Unlike the status-bar installer, this one is LOCAL-ONLY: the hook entry points at +# this clone's hooks/shift-stop.cjs by absolute path, so it must run from the files +# on disk (no curl | bash). Re-running after `git pull` (or after moving the repo) +# updates the path in place — it never duplicates the hook. +set -euo pipefail + +if ! command -v node >/dev/null 2>&1; then + echo "Error: shift needs Node on your PATH (the hook + this installer run via node)." >&2 + exit 1 +fi + +# Resolve this script's directory; the hook lives next to it under hooks/. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd || echo "")" +HOOK="$SCRIPT_DIR/hooks/shift-stop.cjs" +MERGER="$SCRIPT_DIR/lib/install.cjs" +if [ -z "$SCRIPT_DIR" ] || [ ! -f "$HOOK" ] || [ ! -f "$MERGER" ]; then + echo "Error: run this from a shift clone — couldn't find hooks/shift-stop.cjs next to install.sh." >&2 + echo " (clone the toolkit, then: bash shift/install.sh)" >&2 + exit 1 +fi + +COMMAND="node $HOOK" +SETTINGS_DIR="$HOME/.claude" +DEST="$SETTINGS_DIR/settings.json" +mkdir -p "$SETTINGS_DIR" + +# Compute the merged settings into a temp file via the unit-tested merger, then +# move it into place — a failed merge never leaves a broken settings.json behind. +TMP="$(mktemp)" +ACTION="$(node -e ' + const fs = require("node:fs"); + const { mergeStopHook } = require(process.argv[1]); + const dest = process.argv[2], command = process.argv[3], tmp = process.argv[4]; + let settings = {}; + if (fs.existsSync(dest)) { + const raw = fs.readFileSync(dest, "utf8").trim(); + if (raw) { + try { settings = JSON.parse(raw); } + catch { console.error("Error: " + dest + " is not valid JSON; fix or move it, then re-run."); process.exit(2); } + } + } + const r = mergeStopHook(settings, command); + fs.writeFileSync(tmp, JSON.stringify(r.settings, null, 2) + "\n"); + process.stdout.write(r.action); +' "$MERGER" "$DEST" "$COMMAND" "$TMP")" || { rm -f "$TMP"; exit 1; } + +if [ ! -s "$TMP" ]; then + echo "Error: merge produced an empty file; aborting (your settings are untouched)." >&2 + rm -f "$TMP"; exit 1 +fi + +if [ "$ACTION" = "unchanged" ]; then + echo "Already wired: shift Stop hook is present in $DEST (no change)." + rm -f "$TMP" +else + if [ -f "$DEST" ]; then + BAK="$DEST.bak-$(date +%Y%m%d-%H%M%S)" + cp "$DEST" "$BAK" + echo "Backed up existing settings -> $BAK" + fi + mv "$TMP" "$DEST" + case "$ACTION" in + added) echo "Installed: shift Stop hook -> $DEST" ;; + updated) echo "Updated: shift Stop hook path -> $DEST" ;; + *) echo "Wrote: $DEST ($ACTION)" ;; + esac +fi + +echo " hook: $COMMAND" +echo +echo "Safe globally — the hook no-ops in any repo without an active .shift/ run." +echo "Next: cd into a repo, add briefs under queue/, then: ${SCRIPT_DIR}/bin/shift start" +echo "(optional) put it on PATH: ln -s ${SCRIPT_DIR}/bin/shift /usr/local/bin/shift" +echo +echo "To remove later, delete the shift Stop entry from $DEST (restore a .bak-* backup)." diff --git a/shift/lib/bounds.cjs b/shift/lib/bounds.cjs new file mode 100644 index 0000000..4040f42 --- /dev/null +++ b/shift/lib/bounds.cjs @@ -0,0 +1,22 @@ +'use strict'; + +// now: epoch ms. usagePercent: latest weekly usage % (or undefined/null if unknown). +// Returns null (continue) or { reason } (terminate the run). +function evaluateBounds(state, config, now, usagePercent) { + const b = (config && config.bounds) || {}; + if (typeof b.maxIterations === 'number' && state.iterations >= b.maxIterations) { + return { reason: `max iterations (${b.maxIterations}) reached` }; + } + if (typeof b.usageCapPercent === 'number' && typeof usagePercent === 'number' + && usagePercent >= b.usageCapPercent) { + return { reason: `usage cap (${b.usageCapPercent}%) reached at ${usagePercent}%` }; + } + if (typeof b.maxHours === 'number') { + if (now - Date.parse(state.startedAt) >= b.maxHours * 3_600_000) { + return { reason: `time box (${b.maxHours}h) reached` }; + } + } + return null; +} + +module.exports = { evaluateBounds }; diff --git a/shift/lib/brief.cjs b/shift/lib/brief.cjs new file mode 100644 index 0000000..7a68aa2 --- /dev/null +++ b/shift/lib/brief.cjs @@ -0,0 +1,27 @@ +'use strict'; + +// Render the unattended instruction + bin text fed back to the agent on `block`. +function renderBrief(bin, config) { + const dod = (config && config.definitionOfDone) || 'Complete the task and commit your work.'; + const git = (config && config.git) || {}; + const forbidden = []; + if (!git.allowPush) forbidden.push('push to any remote'); + if (!git.allowOutwardActions) forbidden.push('publish, send to external services, or delete files outside the working tree'); + const guard = forbidden.length + ? `Do NOT ${forbidden.join(', or ')}; if the work needs one, treat it as a "Needs you" item (below) and continue with the rest.` + : ''; + return [ + 'You are running unattended under `shift`. Complete the brief below end-to-end using your best judgment.', + 'Do NOT ask questions — if you would normally ask, decide and record the decision in .shift/log.md.', + `Definition of done: ${dod}`, + 'When finished, commit your work on the current branch.', + 'Flag anything that needs the human (a deferred decision, an action you could not take) by appending a line to .shift/log.md as: "Needs you: " — these surface in the run summary.', + 'If a true blocker stops you from finishing this bin, append one line to .shift/blocked.jsonl: {"id":"","note":""} then stop.', + guard, + '', + `--- BIN: ${bin.id} ---`, + bin.text + ].filter(Boolean).join('\n'); +} + +module.exports = { renderBrief }; diff --git a/shift/lib/decision.cjs b/shift/lib/decision.cjs new file mode 100644 index 0000000..e6a985a --- /dev/null +++ b/shift/lib/decision.cjs @@ -0,0 +1,18 @@ +'use strict'; +const { evaluateBounds } = require('./bounds.cjs'); +const { firstPending } = require('./state.cjs'); +const { renderBrief } = require('./brief.cjs'); + +// ctx: { bins, state, config, now, usagePercent, stopHookActive, killSwitch } +// returns { action:'allow', reason } | { action:'block', reason, nextBinId } +function decide(ctx) { + const { bins, state, config, now, usagePercent, killSwitch } = ctx; + if (killSwitch) return { action: 'allow', reason: 'kill switch (.shift/STOP) present' }; + const bound = evaluateBounds(state, config, now, usagePercent); + if (bound) return { action: 'allow', reason: bound.reason }; + const next = firstPending(bins); + if (!next) return { action: 'allow', reason: 'queue empty' }; + return { action: 'block', reason: renderBrief(next, config), nextBinId: next.id }; +} + +module.exports = { decide }; diff --git a/shift/lib/discovery.cjs b/shift/lib/discovery.cjs new file mode 100644 index 0000000..49931d5 --- /dev/null +++ b/shift/lib/discovery.cjs @@ -0,0 +1,35 @@ +'use strict'; +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); + +function hashText(text) { + return crypto.createHash('sha256').update(text).digest('hex').slice(0, 12); +} + +function listMarkdown(dirAbs) { + let entries; + try { entries = fs.readdirSync(dirAbs, { withFileTypes: true }); } + catch { return []; } + return entries.filter(e => e.isFile() && e.name.endsWith('.md')).map(e => e.name).sort(); +} + +// sources: [{ path, kind }]. cwd: repo root. Returns ordered bins (source then filename). +function discoverBins(sources, cwd) { + const bins = []; + for (const source of sources) { + const dirAbs = path.resolve(cwd, source.path); + for (const name of listMarkdown(dirAbs)) { + const text = fs.readFileSync(path.join(dirAbs, name), 'utf8'); + bins.push({ + id: path.posix.join(source.path, name), + hash: hashText(text), + kind: source.kind || 'briefs', + text + }); + } + } + return bins; +} + +module.exports = { discoverBins, hashText }; diff --git a/shift/lib/install.cjs b/shift/lib/install.cjs new file mode 100644 index 0000000..993e427 --- /dev/null +++ b/shift/lib/install.cjs @@ -0,0 +1,47 @@ +'use strict'; +// Pure logic for wiring shift's Stop hook into a Claude Code settings object. +// The I/O (read/back-up/validate/write ~/.claude/settings.json) lives in install.sh; +// this stays a pure function so it can be unit-tested without touching the filesystem. + +// A command string belongs to shift if it invokes our Stop hook script. +function isShiftCommand(command) { + return typeof command === 'string' && command.includes('shift-stop.cjs'); +} + +function makeGroup(command) { + return { matcher: '', hooks: [{ type: 'command', command }] }; +} + +// mergeStopHook(settings, command) -> { settings, changed, action } +// action: 'added' (no prior shift hook) | 'updated' (path changed) | 'unchanged' (already wired). +// Never mutates the input; returns a fresh deep-ish copy of the parts it touches. +function mergeStopHook(settings, command) { + const next = { ...(settings || {}) }; + const hooks = { ...(next.hooks || {}) }; + const stop = Array.isArray(hooks.Stop) ? hooks.Stop.map(g => ({ ...g })) : []; + + // Find an existing group that already points at shift's hook. + const idx = stop.findIndex(g => + Array.isArray(g.hooks) && g.hooks.some(h => isShiftCommand(h && h.command))); + + let action; + if (idx === -1) { + stop.push(makeGroup(command)); + action = 'added'; + } else { + const current = stop[idx].hooks.find(h => isShiftCommand(h && h.command)); + if (current.command === command) { + action = 'unchanged'; + } else { + // Repo moved: rewrite that group to the canonical single shift command. + stop[idx] = makeGroup(command); + action = 'updated'; + } + } + + hooks.Stop = stop; + next.hooks = hooks; + return { settings: next, changed: action !== 'unchanged', action }; +} + +module.exports = { mergeStopHook, isShiftCommand }; diff --git a/shift/lib/outcome.cjs b/shift/lib/outcome.cjs new file mode 100644 index 0000000..9638983 --- /dev/null +++ b/shift/lib/outcome.cjs @@ -0,0 +1,33 @@ +'use strict'; + +// The rate-limit termination signature of a headless `claude -p` run is not +// documented, so we classify defensively: prefer inference from cached usage +// (near-limit + a future reset), then fall back to stderr patterns. +const DEFAULT_PATTERNS = [/rate.?limit/i, /usage limit/i, /quota/i, /\b429\b/]; +const NEAR_LIMIT_PERCENT = 95; + +// ctx: { finalized, code, stderr, usage, now (ms), patterns? } +// returns 'completed' | 'incomplete' | 'rate_limited' | 'error' +function classifyOutcome(ctx) { + const { finalized, code, stderr, usage, now, patterns } = ctx; + if (finalized) return 'completed'; // the engine wrote summary.md → run is done + // A clean exit WITHOUT finalize is NOT success: claude stopped but the engine never + // wrote summary.md (hook not wired, or a partial stop). Caller resumes or stops — it + // must never be reported as 'completed' (that was a silent false-green). + if (code === 0) return 'incomplete'; + + const nowSec = (typeof now === 'number' ? now : Date.now()) / 1000; + const resetFuture = usage && typeof usage.sessionResetAt === 'number' && usage.sessionResetAt > nowSec; + const nearLimit = usage && ( + (typeof usage.sessionUsedPercent === 'number' && usage.sessionUsedPercent >= NEAR_LIMIT_PERCENT) || + (typeof usage.weeklyPercent === 'number' && usage.weeklyPercent >= NEAR_LIMIT_PERCENT) + ); + if (resetFuture && nearLimit) return 'rate_limited'; + + const pats = patterns || DEFAULT_PATTERNS; + if (typeof stderr === 'string' && pats.some(p => p.test(stderr))) return 'rate_limited'; + + return 'error'; +} + +module.exports = { classifyOutcome, DEFAULT_PATTERNS, NEAR_LIMIT_PERCENT }; diff --git a/shift/lib/run-loop.cjs b/shift/lib/run-loop.cjs new file mode 100644 index 0000000..8703b92 --- /dev/null +++ b/shift/lib/run-loop.cjs @@ -0,0 +1,81 @@ +'use strict'; +const { evaluateBounds } = require('./bounds.cjs'); +const { classifyOutcome } = require('./outcome.cjs'); + +const RESET_BUFFER_MS = 60_000; + +// The headless outer loop (v2). All side effects are injected so the loop is +// fully testable without a real `claude` or real sleeping. +// +// effects: { +// now(): ms, loadState(): state, readUsage(): usageCache|null, log(msg), +// finalized(): bool, // did the engine write summary.md this run? +// spawn(n): { status, stderr }, // run claude once (n = 1-based spawn count) +// sleepUntil(ms): Promise +// } +// Returns { reason, spawns }. +async function runLoop({ config, effects }) { + const bounds = (config && config.bounds) || {}; + const maxResumes = typeof bounds.maxResumes === 'number' ? bounds.maxResumes : 12; + let spawns = 0; + let lastOutcome = null; + + for (;;) { + const state = effects.loadState(); + const now = effects.now(); + const usage = effects.readUsage(); + + const bound = evaluateBounds(state, config, now, usage ? usage.weeklyPercent : undefined); + if (bound) return { reason: bound.reason, spawns }; + if (spawns >= maxResumes) return { reason: `max resumes (${maxResumes}) reached`, spawns }; + + if (lastOutcome === 'completed') return { reason: 'run finalized by the engine', spawns }; + if (lastOutcome === 'error') return { reason: 'run errored — stopping (see output)', spawns }; + + if (lastOutcome === 'rate_limited') { + if (!bounds.autoResumeOnReset) return { reason: 'rate limited; auto-resume disabled', spawns }; + const resetAt = usage && typeof usage.sessionResetAt === 'number' ? usage.sessionResetAt * 1000 : null; + if (!resetAt) return { reason: 'rate limited but no reset time available — stopping', spawns }; + const until = resetAt + RESET_BUFFER_MS; + // The cached reset time is only refreshed by the Stop hook; a wall that kills the + // session before any hook fires leaves it stale. If it's already in the past, + // sleepUntil(past) returns instantly and we'd re-spawn in a tight loop — stop instead. + if (until <= now) return { reason: 'rate limited but the reset window is stale/past — stopping', spawns }; + if (typeof bounds.maxHours === 'number') { + const deadline = Date.parse(state.startedAt) + bounds.maxHours * 3_600_000; + if (until >= deadline) return { reason: 'rate limited; reset is past the time box — stopping', spawns }; + } + effects.log(`rate limited — waiting until ${new Date(until).toISOString()}`); + await effects.sleepUntil(until); + lastOutcome = null; + continue; + } + + const iterBefore = (state && typeof state.iterations === 'number') ? state.iterations : 0; + spawns += 1; + effects.log(`spawn #${spawns}: running claude`); + const res = effects.spawn(spawns); + const outcome = classifyOutcome({ + finalized: effects.finalized(), + code: res ? res.status : 1, + stderr: res ? res.stderr : '', + usage: effects.readUsage(), + now: effects.now() + }); + + // 'incomplete' = claude exited cleanly but the engine never finalized. If it advanced + // the queue (partial progress), resume to finish it; if it advanced nothing, resuming + // won't help — stop with a diagnostic rather than spin or report a false-green. + if (outcome === 'incomplete') { + const after = effects.loadState(); + const iterAfter = (after && typeof after.iterations === 'number') ? after.iterations : iterBefore; + if (iterAfter <= iterBefore) { + return { reason: 'claude exited without finalizing and made no progress — is the Stop hook wired? (nothing committed)', spawns }; + } + effects.log('claude exited mid-queue with progress — resuming'); + } + lastOutcome = outcome; + } +} + +module.exports = { runLoop, RESET_BUFFER_MS }; diff --git a/shift/lib/state.cjs b/shift/lib/state.cjs new file mode 100644 index 0000000..9d10a99 --- /dev/null +++ b/shift/lib/state.cjs @@ -0,0 +1,42 @@ +'use strict'; +const fs = require('node:fs'); +const path = require('node:path'); + +function statePath(dir) { return path.join(dir, 'state.json'); } + +function loadState(dir) { return JSON.parse(fs.readFileSync(statePath(dir), 'utf8')); } + +function saveState(dir, state) { + fs.mkdirSync(dir, { recursive: true }); + // Persist lean: the bin `text` is re-read from disk on each discovery pass, so + // keep it out of state.json (avoids bloating state with full brief/plan bodies). + const lean = { ...state, bins: state.bins.map(({ text, ...b }) => b) }; + fs.writeFileSync(statePath(dir), JSON.stringify(lean, null, 2)); +} + +function initState({ runId, startedAt, branch }) { + return { runId, startedAt, iterations: 0, branch, currentBinId: null, bins: [] }; +} + +// Merge freshly discovered bins into state, carrying over status by id+hash. +// New or content-changed files appear as 'pending'. +function mergeDiscovered(state, discovered) { + const prev = new Map(state.bins.map(b => [b.id + '@' + b.hash, b])); + const bins = discovered.map(d => { + const carried = prev.get(d.id + '@' + d.hash); + // Always carry the freshly-read `text` (needed to render the brief); status + // comes from the prior run if this id+hash was already seen. + return carried + ? { ...carried, kind: d.kind, text: d.text } + : { id: d.id, hash: d.hash, kind: d.kind, status: 'pending', text: d.text }; + }); + return { ...state, bins }; +} + +function firstPending(bins) { return bins.find(b => b.status === 'pending') || null; } + +function setBinStatus(state, id, patch) { + return { ...state, bins: state.bins.map(b => (b.id === id ? { ...b, ...patch } : b)) }; +} + +module.exports = { statePath, loadState, saveState, initState, mergeDiscovered, firstPending, setBinStatus }; diff --git a/shift/lib/usage.cjs b/shift/lib/usage.cjs new file mode 100644 index 0000000..77af349 --- /dev/null +++ b/shift/lib/usage.cjs @@ -0,0 +1,35 @@ +'use strict'; +const fs = require('node:fs'); +const path = require('node:path'); + +function cachePath(dir) { return path.join(dir, 'usage.json'); } + +function num(v) { return (typeof v === 'number' && Number.isFinite(v)) ? v : null; } + +// Cache the rate-limit data from a hook payload so the headless runner can read +// the reset time and current usage between spawns. Returns the weekly % (or null). +// Absent/partial rate_limits degrade to null and write nothing. +function writeUsageCache(dir, rateLimits, nowSec) { + if (!rateLimits || typeof rateLimits !== 'object') return null; + const fh = rateLimits.five_hour || {}; + const sd = rateLimits.seven_day || {}; + const cache = { + weeklyPercent: num(sd.used_percentage), + sessionUsedPercent: num(fh.used_percentage), + sessionResetAt: num(fh.resets_at), + weeklyResetAt: num(sd.resets_at), + capturedAt: typeof nowSec === 'number' ? nowSec : null + }; + try { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(cachePath(dir), JSON.stringify(cache, null, 2)); + } catch { /* best-effort */ } + return cache.weeklyPercent; +} + +function readUsageCache(dir) { + try { return JSON.parse(fs.readFileSync(cachePath(dir), 'utf8')); } + catch { return null; } +} + +module.exports = { writeUsageCache, readUsageCache }; diff --git a/shift/lib/verify.cjs b/shift/lib/verify.cjs new file mode 100644 index 0000000..60b22a9 --- /dev/null +++ b/shift/lib/verify.cjs @@ -0,0 +1,23 @@ +'use strict'; +const cp = require('node:child_process'); + +// Run a per-bin verification command in `cwd`. `exec` is injectable for tests. +// Returns { ok: boolean, output: string }. A null/empty command is a pass. +function runVerify(command, cwd, exec) { + if (!command) return { ok: true, output: '' }; + return (exec || defaultExec)(command, cwd); +} + +function defaultExec(command, cwd) { + // shell:true is intentional — `command` is the user's own config value (e.g. + // "npm test && npm run build") and is passed as a whole, not interpolated into + // a larger string. It is never built from untrusted input. + const r = cp.spawnSync(command, { + cwd, shell: true, encoding: 'utf8', + timeout: 10 * 60 * 1000, + maxBuffer: 10 * 1024 * 1024 + }); + return { ok: r.status === 0, output: `${r.stdout || ''}${r.stderr || ''}` }; +} + +module.exports = { runVerify }; diff --git a/shift/package.json b/shift/package.json new file mode 100644 index 0000000..52fabb4 --- /dev/null +++ b/shift/package.json @@ -0,0 +1,9 @@ +{ + "name": "shift", + "version": "0.1.0", + "private": true, + "description": "Autonomous work-queue runner for Claude Code (Agentic Workflow Toolkit module 2)", + "bin": { "shift": "bin/shift" }, + "engines": { "node": ">=18" }, + "scripts": { "test": "node --test" } +} diff --git a/shift/test/bounds.test.cjs b/shift/test/bounds.test.cjs new file mode 100644 index 0000000..78a5f5b --- /dev/null +++ b/shift/test/bounds.test.cjs @@ -0,0 +1,38 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert'); +const { evaluateBounds } = require('../lib/bounds.cjs'); + +const base = { startedAt: '2026-06-13T00:00:00Z', iterations: 0 }; +const t0 = Date.parse(base.startedAt); + +test('returns null when within bounds', () => { + const cfg = { bounds: { maxHours: 2, maxIterations: 10 } }; + assert.equal(evaluateBounds(base, cfg, t0 + 60_000), null); +}); + +test('terminates on max iterations', () => { + const cfg = { bounds: { maxHours: 2, maxIterations: 5 } }; + assert.match(evaluateBounds({ ...base, iterations: 5 }, cfg, t0 + 1000).reason, /max iterations/); +}); + +test('terminates on time box', () => { + const cfg = { bounds: { maxHours: 1, maxIterations: 100 } }; + assert.match(evaluateBounds(base, cfg, t0 + 3_600_001).reason, /time box/); +}); + +test('iterations checked before time', () => { + const cfg = { bounds: { maxHours: 1, maxIterations: 1 } }; + assert.match(evaluateBounds({ ...base, iterations: 1 }, cfg, t0 + 3_600_001).reason, /max iterations/); +}); + +test('terminates on usage cap when usage is known', () => { + const cfg = { bounds: { maxHours: 8, usageCapPercent: 90 } }; + assert.match(evaluateBounds(base, cfg, t0 + 1000, 92).reason, /usage cap/); +}); + +test('usage cap is ignored when usage is unknown', () => { + const cfg = { bounds: { maxHours: 8, usageCapPercent: 90 } }; + assert.equal(evaluateBounds(base, cfg, t0 + 1000, undefined), null); + assert.equal(evaluateBounds(base, cfg, t0 + 1000, null), null); +}); diff --git a/shift/test/brief.test.cjs b/shift/test/brief.test.cjs new file mode 100644 index 0000000..2212e4e --- /dev/null +++ b/shift/test/brief.test.cjs @@ -0,0 +1,31 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert'); +const { renderBrief } = require('../lib/brief.cjs'); + +const bin = { id: 'queue/01.md', text: 'Do the thing.' }; + +test('includes the bin text, id, and definition of done', () => { + const out = renderBrief(bin, { definitionOfDone: 'tests pass', git: {} }); + assert.match(out, /Do the thing\./); + assert.match(out, /queue\/01\.md/); + assert.match(out, /tests pass/); +}); + +test('forbids push and outward actions by default', () => { + const out = renderBrief(bin, { git: { allowPush: false, allowOutwardActions: false } }); + assert.match(out, /Do NOT/); + assert.match(out, /push to any remote/); +}); + +test('omits the forbid-guard when everything is allowed', () => { + const out = renderBrief(bin, { git: { allowPush: true, allowOutwardActions: true } }); + assert.doesNotMatch(out, /Do NOT push/); +}); + +test('always explains decision logging, the Needs-you convention, and blocker flagging', () => { + const out = renderBrief(bin, { git: {} }); + assert.match(out, /\.shift\/log\.md/); + assert.match(out, /Needs you:/); + assert.match(out, /blocked\.jsonl/); +}); diff --git a/shift/test/cli.test.cjs b/shift/test/cli.test.cjs new file mode 100644 index 0000000..62dbad1 --- /dev/null +++ b/shift/test/cli.test.cjs @@ -0,0 +1,49 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const cp = require('node:child_process'); + +const CLI = path.resolve(__dirname, '..', 'bin', 'shift'); + +function repoWithQueue() { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'shift-cli-')); + cp.execSync('git init -q', { cwd }); + cp.execSync('git config user.email t@t.co', { cwd }); + cp.execSync('git config user.name t', { cwd }); + cp.execSync('git commit -q --allow-empty -m init', { cwd }); + fs.mkdirSync(path.join(cwd, 'queue'), { recursive: true }); + fs.writeFileSync(path.join(cwd, 'queue', '01.md'), 'bin one'); + return cwd; +} + +function run(cwd, args) { + return cp.execFileSync('node', [CLI, ...args], { cwd, encoding: 'utf8' }); +} + +test('--dry-run lists the queue and writes nothing', () => { + const cwd = repoWithQueue(); + const out = run(cwd, ['start', '--dry-run']); + assert.match(out, /queue\/01\.md/); + assert.ok(!fs.existsSync(path.join(cwd, '.shift', 'state.json'))); +}); + +test('start writes config + state and creates the run branch', () => { + const cwd = repoWithQueue(); + run(cwd, ['start']); + assert.ok(fs.existsSync(path.join(cwd, '.shift', 'state.json'))); + assert.ok(fs.existsSync(path.join(cwd, '.shift', 'config.json'))); + const branch = cp.execSync('git branch --show-current', { cwd, encoding: 'utf8' }).trim(); + assert.match(branch, /^shift\//); + const state = JSON.parse(fs.readFileSync(path.join(cwd, '.shift', 'state.json'), 'utf8')); + assert.equal(state.bins.length, 1); +}); + +test('stop creates the kill switch', () => { + const cwd = repoWithQueue(); + run(cwd, ['start']); + run(cwd, ['stop']); + assert.ok(fs.existsSync(path.join(cwd, '.shift', 'STOP'))); +}); diff --git a/shift/test/decision.test.cjs b/shift/test/decision.test.cjs new file mode 100644 index 0000000..9619b70 --- /dev/null +++ b/shift/test/decision.test.cjs @@ -0,0 +1,46 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert'); +const { decide } = require('../lib/decision.cjs'); + +const cfg = { bounds: { maxHours: 2, maxIterations: 10 }, definitionOfDone: 'done', git: {} }; +const state = { startedAt: '2026-06-13T00:00:00Z', iterations: 0, currentBinId: null }; +const t0 = Date.parse(state.startedAt) + 1000; + +test('blocks with the first pending bin', () => { + const bins = [{ id: 'a', status: 'done' }, { id: 'b', status: 'pending', text: 'work b' }]; + const r = decide({ bins, state, config: cfg, now: t0, stopHookActive: false, killSwitch: false }); + assert.equal(r.action, 'block'); + assert.equal(r.nextBinId, 'b'); + assert.match(r.reason, /work b/); +}); + +test('allows stop when queue empty', () => { + const bins = [{ id: 'a', status: 'done' }]; + const r = decide({ bins, state, config: cfg, now: t0, stopHookActive: false, killSwitch: false }); + assert.equal(r.action, 'allow'); + assert.match(r.reason, /queue empty/); +}); + +test('kill switch allows stop even with pending work', () => { + const bins = [{ id: 'b', status: 'pending', text: 'x' }]; + const r = decide({ bins, state, config: cfg, now: t0, stopHookActive: false, killSwitch: true }); + assert.equal(r.action, 'allow'); + assert.match(r.reason, /kill switch/); +}); + +test('a bound (time box) allows stop even with pending work', () => { + const bins = [{ id: 'b', status: 'pending', text: 'x' }]; + const late = Date.parse(state.startedAt) + 3 * 3_600_000; + const r = decide({ bins, state, config: cfg, now: late, stopHookActive: false, killSwitch: false }); + assert.equal(r.action, 'allow'); + assert.match(r.reason, /time box/); +}); + +test('usage cap allows stop even with pending work', () => { + const bins = [{ id: 'b', status: 'pending', text: 'x' }]; + const capCfg = { bounds: { maxHours: 8, usageCapPercent: 90 }, definitionOfDone: 'd', git: {} }; + const r = decide({ bins, state, config: capCfg, now: t0, usagePercent: 95, killSwitch: false }); + assert.equal(r.action, 'allow'); + assert.match(r.reason, /usage cap/); +}); diff --git a/shift/test/discovery.test.cjs b/shift/test/discovery.test.cjs new file mode 100644 index 0000000..3dfad7c --- /dev/null +++ b/shift/test/discovery.test.cjs @@ -0,0 +1,37 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { discoverBins, hashText } = require('../lib/discovery.cjs'); + +function tmpRepo() { + const d = fs.mkdtempSync(path.join(os.tmpdir(), 'shift-disc-')); + fs.mkdirSync(path.join(d, 'queue'), { recursive: true }); + fs.mkdirSync(path.join(d, 'plans'), { recursive: true }); + fs.writeFileSync(path.join(d, 'queue', '02-b.md'), 'second'); + fs.writeFileSync(path.join(d, 'queue', '01-a.md'), 'first'); + fs.writeFileSync(path.join(d, 'queue', 'notes.txt'), 'ignored'); + fs.writeFileSync(path.join(d, 'plans', 'p1.md'), 'plan one'); + return d; +} + +test('discovers .md files, ordered by source then filename', () => { + const cwd = tmpRepo(); + const bins = discoverBins([{ path: 'queue', kind: 'briefs' }, { path: 'plans', kind: 'plans' }], cwd); + assert.deepEqual(bins.map(b => b.id), ['queue/01-a.md', 'queue/02-b.md', 'plans/p1.md']); + assert.equal(bins[0].kind, 'briefs'); + assert.equal(bins[2].kind, 'plans'); + assert.equal(bins[0].text, 'first'); +}); + +test('hash is stable for same content, differs for different content', () => { + assert.equal(hashText('x'), hashText('x')); + assert.notEqual(hashText('x'), hashText('y')); +}); + +test('missing source folder yields no bins (no throw)', () => { + const cwd = tmpRepo(); + assert.deepEqual(discoverBins([{ path: 'does-not-exist', kind: 'briefs' }], cwd), []); +}); diff --git a/shift/test/hook.test.cjs b/shift/test/hook.test.cjs new file mode 100644 index 0000000..ad2c603 --- /dev/null +++ b/shift/test/hook.test.cjs @@ -0,0 +1,142 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const cp = require('node:child_process'); + +const HOOK = path.resolve(__dirname, '..', 'hooks', 'shift-stop.cjs'); + +function setupRun(configOverride) { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'shift-hook-')); + fs.mkdirSync(path.join(cwd, 'queue'), { recursive: true }); + fs.writeFileSync(path.join(cwd, 'queue', '01.md'), 'bin one'); + fs.writeFileSync(path.join(cwd, 'queue', '02.md'), 'bin two'); + const dir = path.join(cwd, '.shift'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'config.json'), JSON.stringify(Object.assign({ + sources: [{ path: 'queue', kind: 'briefs' }], + bounds: { maxHours: 24, maxIterations: 10 }, + definitionOfDone: 'done', git: {} + }, configOverride || {}))); + fs.writeFileSync(path.join(dir, 'state.json'), JSON.stringify({ + runId: 'r', startedAt: new Date().toISOString(), iterations: 0, + branch: 'shift/x', currentBinId: null, bins: [] + })); + fs.writeFileSync(path.join(dir, 'log.md'), '# log\n'); + return { cwd, dir }; +} + +function runHook(cwd, input) { + const out = cp.execFileSync('node', [HOOK], { cwd, input: JSON.stringify(input), encoding: 'utf8' }); + return JSON.parse(out || '{}'); +} + +test('no-ops (allows stop) when no .shift/state.json exists', () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'shift-none-')); + assert.deepEqual(runHook(cwd, { stop_hook_active: false }), {}); +}); + +test('first stop blocks bin 1; second marks it done + blocks bin 2; third drains -> allow + summary', () => { + const { cwd, dir } = setupRun(); + const r1 = runHook(cwd, { stop_hook_active: false }); + assert.equal(r1.decision, 'block'); + assert.match(r1.reason, /bin one/); + + const r2 = runHook(cwd, { stop_hook_active: true }); + assert.equal(r2.decision, 'block'); + assert.match(r2.reason, /bin two/); + const s2 = JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8')); + assert.equal(s2.bins.find(b => b.id === 'queue/01.md').status, 'done'); + + const r3 = runHook(cwd, { stop_hook_active: true }); + assert.deepEqual(r3, {}); + assert.ok(fs.existsSync(path.join(dir, 'summary.md'))); + assert.match(fs.readFileSync(path.join(dir, 'summary.md'), 'utf8'), /queue empty/); +}); + +test('blocked.jsonl marks the current bin blocked and surfaces it in the summary', () => { + const { cwd, dir } = setupRun(); + runHook(cwd, { stop_hook_active: false }); + fs.writeFileSync(path.join(dir, 'blocked.jsonl'), JSON.stringify({ id: 'queue/01.md', note: 'needs key' }) + '\n'); + runHook(cwd, { stop_hook_active: true }); + runHook(cwd, { stop_hook_active: true }); + assert.match(fs.readFileSync(path.join(dir, 'summary.md'), 'utf8'), /needs key/); +}); + +test('logged "Needs you:" lines surface in the summary', () => { + const { cwd, dir } = setupRun(); + runHook(cwd, { stop_hook_active: false }); + fs.appendFileSync(path.join(dir, 'log.md'), '\nNeeds you: push the release tag\n'); + runHook(cwd, { stop_hook_active: true }); + runHook(cwd, { stop_hook_active: true }); + assert.match(fs.readFileSync(path.join(dir, 'summary.md'), 'utf8'), /push the release tag/); +}); + +test('kill switch ends the run immediately', () => { + const { cwd, dir } = setupRun(); + fs.writeFileSync(path.join(dir, 'STOP'), ''); + assert.deepEqual(runHook(cwd, { stop_hook_active: false }), {}); + assert.match(fs.readFileSync(path.join(dir, 'summary.md'), 'utf8'), /kill switch/); +}); + +test('resolves .shift from the hook payload cwd, not the process cwd', () => { + const { cwd } = setupRun(); + const neutral = fs.mkdtempSync(path.join(os.tmpdir(), 'shift-neutral-')); + const out = cp.execFileSync('node', [HOOK], { + cwd: neutral, + input: JSON.stringify({ stop_hook_active: false, cwd }), + encoding: 'utf8' + }); + const r = JSON.parse(out || '{}'); + assert.equal(r.decision, 'block'); + assert.match(r.reason, /bin one/); +}); + +// ---- v3: verify gate ---- + +test('verify gate (passing) marks bins done and drains', () => { + const { cwd, dir } = setupRun({ verify: { command: 'true', maxAttempts: 2 } }); + runHook(cwd, { stop_hook_active: false }); // start bin 1 + runHook(cwd, { stop_hook_active: true }); // verify passes -> bin1 done, start bin2 + const s = JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8')); + assert.equal(s.bins.find(b => b.id === 'queue/01.md').status, 'done'); +}); + +test('verify gate (failing) re-blocks the same bin with feedback, then blocks after maxAttempts', () => { + const { cwd, dir } = setupRun({ verify: { command: 'false', maxAttempts: 2 } }); + runHook(cwd, { stop_hook_active: false }); // start bin 1 + const r1 = runHook(cwd, { stop_hook_active: true }); // verify fails, attempt 1 < 2 -> retry SAME bin + assert.equal(r1.decision, 'block'); + assert.match(r1.reason, /failed verification/); + assert.match(r1.reason, /bin one/); + let s = JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8')); + assert.equal(s.bins.find(b => b.id === 'queue/01.md').status, 'pending'); + assert.equal(s.bins.find(b => b.id === 'queue/01.md').attempts, 1); + + const r2 = runHook(cwd, { stop_hook_active: true }); // verify fails again, attempt 2 == max -> blocked, move on + assert.equal(r2.decision, 'block'); + assert.match(r2.reason, /bin two/); + s = JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8')); + assert.equal(s.bins.find(b => b.id === 'queue/01.md').status, 'blocked'); +}); + +// ---- v2: usage cap + cache ---- + +test('usage cap from the hook payload ends the run and caches usage', () => { + const { cwd, dir } = setupRun({ bounds: { maxHours: 24, maxIterations: 10, usageCapPercent: 90 } }); + const reset = Math.floor(Date.now() / 1000) + 3600; + const r = runHook(cwd, { + stop_hook_active: false, + rate_limits: { + five_hour: { used_percentage: 30, resets_at: reset }, + seven_day: { used_percentage: 95, resets_at: reset } + } + }); + assert.deepEqual(r, {}); + assert.match(fs.readFileSync(path.join(dir, 'summary.md'), 'utf8'), /usage cap/); + const usage = JSON.parse(fs.readFileSync(path.join(dir, 'usage.json'), 'utf8')); + assert.equal(usage.weeklyPercent, 95); + assert.equal(usage.sessionResetAt, reset); +}); diff --git a/shift/test/install.test.cjs b/shift/test/install.test.cjs new file mode 100644 index 0000000..a8a1352 --- /dev/null +++ b/shift/test/install.test.cjs @@ -0,0 +1,102 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const cp = require('node:child_process'); +const { mergeStopHook } = require('../lib/install.cjs'); + +const CMD = 'node /abs/path/to/shift/hooks/shift-stop.cjs'; +const INSTALL = path.resolve(__dirname, '..', 'install.sh'); +const HOOK = path.resolve(__dirname, '..', 'hooks', 'shift-stop.cjs'); + +function runInstall(home) { + return cp.execFileSync('bash', [INSTALL], { + env: { ...process.env, HOME: home }, encoding: 'utf8' + }); +} +function readSettings(home) { + return JSON.parse(fs.readFileSync(path.join(home, '.claude', 'settings.json'), 'utf8')); +} + +test('adds the Stop hook to empty settings', () => { + const r = mergeStopHook({}, CMD); + assert.equal(r.action, 'added'); + assert.equal(r.changed, true); + const groups = r.settings.hooks.Stop; + assert.equal(groups.length, 1); + assert.deepEqual(groups[0], { matcher: '', hooks: [{ type: 'command', command: CMD }] }); +}); + +test('is idempotent — same command twice does not duplicate', () => { + const once = mergeStopHook({}, CMD).settings; + const twice = mergeStopHook(once, CMD); + assert.equal(twice.action, 'unchanged'); + assert.equal(twice.changed, false); + assert.equal(twice.settings.hooks.Stop.length, 1); +}); + +test('preserves unrelated hooks and existing Stop groups', () => { + const existing = { + statusLine: { type: 'command', command: 'x' }, + hooks: { + PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'guard' }] }], + Stop: [{ matcher: '', hooks: [{ type: 'command', command: 'other-stop-hook' }] }] + } + }; + const r = mergeStopHook(existing, CMD); + assert.equal(r.action, 'added'); + // unrelated settings + hooks untouched + assert.deepEqual(r.settings.statusLine, { type: 'command', command: 'x' }); + assert.equal(r.settings.hooks.PreToolUse.length, 1); + // shift appended, the foreign Stop group kept + assert.equal(r.settings.hooks.Stop.length, 2); + assert.equal(r.settings.hooks.Stop[0].hooks[0].command, 'other-stop-hook'); + assert.equal(r.settings.hooks.Stop[1].hooks[0].command, CMD); +}); + +test('updates the path when the shift hook moved', () => { + const old = mergeStopHook({}, 'node /old/path/shift/hooks/shift-stop.cjs').settings; + const r = mergeStopHook(old, CMD); + assert.equal(r.action, 'updated'); + assert.equal(r.changed, true); + assert.equal(r.settings.hooks.Stop.length, 1); + assert.equal(r.settings.hooks.Stop[0].hooks[0].command, CMD); +}); + +test('does not mutate the input settings object', () => { + const input = { hooks: { Stop: [] } }; + const snapshot = JSON.stringify(input); + mergeStopHook(input, CMD); + assert.equal(JSON.stringify(input), snapshot); +}); + +test('install.sh wires the hook into a fresh ~/.claude/settings.json', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'shift-inst-')); + const out = runInstall(home); + assert.match(out, /Installed: shift Stop hook/); + const s = readSettings(home); + assert.equal(s.hooks.Stop.length, 1); + assert.equal(s.hooks.Stop[0].hooks[0].command, `node ${HOOK}`); +}); + +test('install.sh is idempotent and preserves existing settings + backs up', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'shift-inst-')); + const claude = path.join(home, '.claude'); + fs.mkdirSync(claude, { recursive: true }); + fs.writeFileSync(path.join(claude, 'settings.json'), + JSON.stringify({ statusLine: { type: 'command', command: 'x' } }, null, 2)); + + const out1 = runInstall(home); + assert.match(out1, /Backed up existing settings/); + const s1 = readSettings(home); + assert.deepEqual(s1.statusLine, { type: 'command', command: 'x' }); // preserved + assert.equal(s1.hooks.Stop.length, 1); + + const out2 = runInstall(home); + assert.match(out2, /Already wired/); + assert.equal(readSettings(home).hooks.Stop.length, 1); // no duplicate + const baks = fs.readdirSync(claude).filter(f => f.startsWith('settings.json.bak-')); + assert.equal(baks.length, 1); // unchanged run made no second backup +}); diff --git a/shift/test/outcome.test.cjs b/shift/test/outcome.test.cjs new file mode 100644 index 0000000..0f6cd84 --- /dev/null +++ b/shift/test/outcome.test.cjs @@ -0,0 +1,40 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert'); +const { classifyOutcome } = require('../lib/outcome.cjs'); + +const nowMs = 1_000_000_000_000; +const nowSec = nowMs / 1000; + +test('finalized run is completed', () => { + assert.equal(classifyOutcome({ finalized: true, code: 1, now: nowMs }), 'completed'); +}); + +test('finalized wins even on a clean exit', () => { + assert.equal(classifyOutcome({ finalized: true, code: 0, now: nowMs }), 'completed'); +}); + +test('clean exit (code 0) WITHOUT finalize is incomplete, not completed', () => { + // The engine writes summary.md (finalized) on a real drain; a code-0 exit without + // it means claude stopped without the engine finalizing (e.g. hook not wired, or a + // partial stop). That must NOT read as success — it is 'incomplete' (resume/stop). + assert.equal(classifyOutcome({ finalized: false, code: 0, now: nowMs }), 'incomplete'); +}); + +test('nonzero + near-limit usage + future reset is rate_limited', () => { + const usage = { sessionUsedPercent: 99, weeklyPercent: 50, sessionResetAt: nowSec + 3600 }; + assert.equal(classifyOutcome({ finalized: false, code: 1, usage, now: nowMs }), 'rate_limited'); +}); + +test('nonzero + rate-limit stderr is rate_limited', () => { + assert.equal(classifyOutcome({ finalized: false, code: 1, stderr: 'Error: rate limit exceeded', now: nowMs }), 'rate_limited'); +}); + +test('nonzero with no signal is error', () => { + assert.equal(classifyOutcome({ finalized: false, code: 1, stderr: 'boom', now: nowMs }), 'error'); +}); + +test('near-limit but reset already past is NOT rate_limited (no future window)', () => { + const usage = { sessionUsedPercent: 99, sessionResetAt: nowSec - 10 }; + assert.equal(classifyOutcome({ finalized: false, code: 1, usage, stderr: 'boom', now: nowMs }), 'error'); +}); diff --git a/shift/test/run-loop.test.cjs b/shift/test/run-loop.test.cjs new file mode 100644 index 0000000..e06cae2 --- /dev/null +++ b/shift/test/run-loop.test.cjs @@ -0,0 +1,130 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert'); +const { runLoop } = require('../lib/run-loop.cjs'); + +function makeEffects({ spawns, usage, bounds }) { + const state = { startedAt: new Date(Date.now()).toISOString(), iterations: 0 }; + let i = 0; + let finalized = false; + const calls = { sleepUntil: [], spawns: 0 }; + const effects = { + now: () => Date.now(), + loadState: () => state, + readUsage: () => usage, + log: () => {}, + finalized: () => finalized, + sleepUntil: (ms) => { calls.sleepUntil.push(ms); return Promise.resolve(); }, + spawn: () => { + calls.spawns += 1; + const s = spawns[i++] || { result: { status: 1, stderr: '' }, finalize: false }; + finalized = s.finalize; + if (typeof s.iterations === 'number') state.iterations = s.iterations; // simulate engine progress + return s.result; + } + }; + return { effects, calls, config: { bounds: bounds || { maxHours: 8, maxResumes: 12, autoResumeOnReset: true } } }; +} + +test('a single finalizing spawn completes the run', async () => { + const { effects, calls, config } = makeEffects({ + spawns: [{ result: { status: 0 }, finalize: true }], + usage: null + }); + const r = await runLoop({ config, effects }); + assert.match(r.reason, /finalized/); + assert.equal(r.spawns, 1); + assert.equal(calls.sleepUntil.length, 0); +}); + +test('rate-limited spawn waits for reset, then resumes and finishes', async () => { + const usage = { weeklyPercent: 50, sessionUsedPercent: 99, sessionResetAt: Math.floor(Date.now() / 1000) + 3600 }; + const { effects, calls, config } = makeEffects({ + spawns: [ + { result: { status: 1, stderr: '' }, finalize: false }, // rate-limited (inferred from usage) + { result: { status: 0 }, finalize: true } // resumes, finalizes + ], + usage + }); + const r = await runLoop({ config, effects }); + assert.match(r.reason, /finalized/); + assert.equal(r.spawns, 2); + assert.equal(calls.sleepUntil.length, 1, 'should have waited once'); +}); + +test('rate-limited with auto-resume disabled stops', async () => { + const usage = { weeklyPercent: 50, sessionUsedPercent: 99, sessionResetAt: Math.floor(Date.now() / 1000) + 3600 }; + const { effects, config } = makeEffects({ + spawns: [{ result: { status: 1, stderr: '' }, finalize: false }], + usage, + bounds: { maxHours: 8, maxResumes: 12, autoResumeOnReset: false } + }); + const r = await runLoop({ config, effects }); + assert.match(r.reason, /auto-resume disabled/); + assert.equal(r.spawns, 1); +}); + +test('usage cap stops before any spawn', async () => { + const { effects, calls, config } = makeEffects({ + spawns: [{ result: { status: 0 }, finalize: true }], + usage: { weeklyPercent: 95 }, + bounds: { maxHours: 8, usageCapPercent: 90, autoResumeOnReset: true } + }); + const r = await runLoop({ config, effects }); + assert.match(r.reason, /usage cap/); + assert.equal(calls.spawns, 0); +}); + +test('maxResumes acts as a runaway backstop', async () => { + const { effects, config } = makeEffects({ + spawns: [{ result: { status: 0 }, finalize: true }], + usage: null, + bounds: { maxHours: 8, maxResumes: 0, autoResumeOnReset: true } + }); + const r = await runLoop({ config, effects }); + assert.match(r.reason, /max resumes/); + assert.equal(r.spawns, 0); +}); + +test('incomplete spawn WITH progress resumes and finishes', async () => { + // spawn 1: clean exit, no finalize, but the engine advanced iterations (partial work); + // spawn 2: resumes and finalizes. + const { effects, calls, config } = makeEffects({ + spawns: [ + { result: { status: 0 }, finalize: false, iterations: 1 }, // progress, not done + { result: { status: 0 }, finalize: true, iterations: 2 } // resume → drain + ], + usage: null + }); + const r = await runLoop({ config, effects }); + assert.match(r.reason, /finalized/); + assert.equal(calls.spawns, 2); +}); + +test('incomplete spawn WITHOUT progress stops with a hook-wiring diagnostic (no false-green)', async () => { + // claude exits 0 but the engine never advanced (e.g. Stop hook not wired). Must NOT + // report success, and must NOT keep re-spawning pointlessly. + const { effects, calls, config } = makeEffects({ + spawns: [{ result: { status: 0 }, finalize: false }], // iterations stays 0 + usage: null + }); + const r = await runLoop({ config, effects }); + assert.doesNotMatch(r.reason, /finalized/); + assert.match(r.reason, /no progress|hook/i); + assert.equal(calls.spawns, 1, 'must not spin'); +}); + +test('rate-limited with a stale/past reset stops instead of busy-spinning', async () => { + // Reset time is already in the past (stale cache). sleepUntil(past) would return + // instantly and re-spawn forever (bounded only by maxResumes) — guard must stop. + const usage = { weeklyPercent: 50, sessionUsedPercent: 99, sessionResetAt: Math.floor(Date.now() / 1000) - 600 }; + const { effects, calls, config } = makeEffects({ + spawns: [{ result: { status: 1, stderr: 'Error: rate limit exceeded' }, finalize: false }], + usage, + bounds: { maxHours: 8, maxResumes: 12, autoResumeOnReset: true } + }); + const r = await runLoop({ config, effects }); + assert.match(r.reason, /stale|past|reset/i); + assert.equal(calls.spawns, 1); + assert.equal(calls.sleepUntil.length, 0, 'must not sleep on a past reset'); +}); diff --git a/shift/test/state.test.cjs b/shift/test/state.test.cjs new file mode 100644 index 0000000..e123620 --- /dev/null +++ b/shift/test/state.test.cjs @@ -0,0 +1,49 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { initState, saveState, loadState, mergeDiscovered, firstPending, setBinStatus } = require('../lib/state.cjs'); + +test('init + save + load round-trips', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'shift-state-')); + const s = initState({ runId: 'r1', startedAt: '2026-06-13T00:00:00Z', branch: 'shift/x' }); + assert.equal(s.iterations, 0); + assert.equal(s.currentBinId, null); + saveState(dir, s); + assert.deepEqual(loadState(dir), s); +}); + +test('mergeDiscovered carries status by id+hash, new files are pending', () => { + let s = initState({ runId: 'r', startedAt: '2026-06-13T00:00:00Z', branch: 'b' }); + s = mergeDiscovered(s, [{ id: 'queue/a.md', hash: 'h1', kind: 'briefs' }]); + assert.equal(s.bins[0].status, 'pending'); + s = setBinStatus(s, 'queue/a.md', { status: 'done' }); + s = mergeDiscovered(s, [ + { id: 'queue/a.md', hash: 'h1', kind: 'briefs' }, + { id: 'queue/b.md', hash: 'h2', kind: 'briefs' } + ]); + assert.equal(s.bins.find(b => b.id === 'queue/a.md').status, 'done'); + assert.equal(s.bins.find(b => b.id === 'queue/b.md').status, 'pending'); +}); + +test('edited file (new hash) becomes pending again', () => { + let s = initState({ runId: 'r', startedAt: 't', branch: 'b' }); + s = mergeDiscovered(s, [{ id: 'q/a.md', hash: 'h1', kind: 'briefs' }]); + s = setBinStatus(s, 'q/a.md', { status: 'done' }); + s = mergeDiscovered(s, [{ id: 'q/a.md', hash: 'h2', kind: 'briefs' }]); + assert.equal(s.bins[0].status, 'pending'); +}); + +test('firstPending returns first pending or null', () => { + let s = initState({ runId: 'r', startedAt: 't', branch: 'b' }); + s = mergeDiscovered(s, [ + { id: 'a', hash: '1', kind: 'briefs' }, + { id: 'b', hash: '2', kind: 'briefs' } + ]); + s = setBinStatus(s, 'a', { status: 'done' }); + assert.equal(firstPending(s.bins).id, 'b'); + s = setBinStatus(s, 'b', { status: 'done' }); + assert.equal(firstPending(s.bins), null); +}); diff --git a/shift/test/usage.test.cjs b/shift/test/usage.test.cjs new file mode 100644 index 0000000..66c3f8d --- /dev/null +++ b/shift/test/usage.test.cjs @@ -0,0 +1,40 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { writeUsageCache, readUsageCache } = require('../lib/usage.cjs'); + +function tmp() { return fs.mkdtempSync(path.join(os.tmpdir(), 'shift-usage-')); } + +test('write + read round-trips the full rate-limit payload', () => { + const dir = tmp(); + const weekly = writeUsageCache(dir, { + five_hour: { used_percentage: 72, resets_at: 1000 }, + seven_day: { used_percentage: 41, resets_at: 2000 } + }, 123); + assert.equal(weekly, 41); + assert.deepEqual(readUsageCache(dir), { + weeklyPercent: 41, sessionUsedPercent: 72, sessionResetAt: 1000, weeklyResetAt: 2000, capturedAt: 123 + }); +}); + +test('absent rate_limits returns null and writes nothing', () => { + const dir = tmp(); + assert.equal(writeUsageCache(dir, undefined, 1), null); + assert.equal(readUsageCache(dir), null); +}); + +test('partial windows degrade to null fields', () => { + const dir = tmp(); + const weekly = writeUsageCache(dir, { five_hour: { used_percentage: 60, resets_at: 5 } }, 9); + assert.equal(weekly, null); + const c = readUsageCache(dir); + assert.equal(c.sessionUsedPercent, 60); + assert.equal(c.weeklyPercent, null); +}); + +test('readUsageCache returns null when no cache exists', () => { + assert.equal(readUsageCache(tmp()), null); +}); diff --git a/shift/test/verify.test.cjs b/shift/test/verify.test.cjs new file mode 100644 index 0000000..09a4a90 --- /dev/null +++ b/shift/test/verify.test.cjs @@ -0,0 +1,25 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert'); +const os = require('node:os'); +const { runVerify } = require('../lib/verify.cjs'); + +test('a null/empty command is a pass', () => { + assert.deepEqual(runVerify(null, '.'), { ok: true, output: '' }); + assert.deepEqual(runVerify('', '.'), { ok: true, output: '' }); +}); + +test('uses the injected exec and returns its result', () => { + const fake = (cmd, cwd) => ({ ok: false, output: `ran ${cmd} in ${cwd}` }); + const r = runVerify('npm test', '/repo', fake); + assert.equal(r.ok, false); + assert.match(r.output, /ran npm test in \/repo/); +}); + +test('default exec: zero exit passes, non-zero fails, output captured', () => { + assert.equal(runVerify('true', os.tmpdir()).ok, true); + assert.equal(runVerify('false', os.tmpdir()).ok, false); + const r = runVerify('echo hi', os.tmpdir()); + assert.equal(r.ok, true); + assert.match(r.output, /hi/); +});