diff --git a/TODO.md b/TODO.md index 19aad4e..f0c2c4c 100644 --- a/TODO.md +++ b/TODO.md @@ -6,14 +6,19 @@ Portable, installable workflow enforcement system for Claude Code. One setup scr ## Status: Complete -## Session Handoff +## Completed (005: Workflows in hook-runner) +- [x] T001 Spec and workflow YAML (PR #9) +- [x] T002 Port workflow.js to hook-runner (PR #10) +- [x] T003 Add workflow-gate PreToolUse module (PR #11) +- [x] T004 Add --workflow CLI commands (PR #12) +- [x] T005 Built-in templates: enforce-shtd.yml, cross-project-reset.yml (PR #13) +- [x] T006 E2E tests — 9/9 lifecycle assertions (PR #14) +- [x] T007 enforce-shtd meta-workflow — self-enforcing (PR #15) +- [x] T008 Delegation verification (PR #16) +- [x] T009 Final cross-project verification — 14/14 checks (PR #17) +- [x] Hook-runner PR: grobomo/hook-runner#66 -User requested new project: **ccc-central** — Central monitoring dispatcher for Claude Code. -- Created `~/Documents/ProjectsCL1/ccc-central/TODO.md` with architecture and tasks -- Next: start new session in ccc-central project directory -- Spec-hook is fully complete (T001-T027, 5 PRs merged, real evidence report) - -## Completed +## Completed (earlier) - [x] T001-T021 (all core tasks — see git log) - [x] T022 Initial evidence report (PDF with tables, user rejected — needs real screenshots) diff --git a/scripts/test/test-T001-workflow-spec.sh b/scripts/test/test-T001-workflow-spec.sh new file mode 100644 index 0000000..f04f671 --- /dev/null +++ b/scripts/test/test-T001-workflow-spec.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Test: spec and workflow YAML exist for 005-add-workflows-to-hookrunner +set -euo pipefail +cd "$(dirname "$0")/../.." + +errors=0 + +# Spec exists +if [ -f "specs/005-add-workflows-to-hookrunner/spec.md" ]; then + echo "[OK] spec.md exists" +else + echo "[FAIL] specs/005-add-workflows-to-hookrunner/spec.md missing" + errors=$((errors + 1)) +fi + +# Spec has required sections +for section in "Problem" "Solution" "Architecture"; do + if grep -qi "$section" specs/005-add-workflows-to-hookrunner/spec.md 2>/dev/null; then + echo "[OK] spec has $section section" + else + echo "[FAIL] spec missing $section section" + errors=$((errors + 1)) + fi +done + +# Workflow YAML exists +if [ -f "workflows/005-add-workflows-to-hookrunner.yml" ]; then + echo "[OK] workflow YAML exists" +else + echo "[FAIL] workflows/005-add-workflows-to-hookrunner.yml missing" + errors=$((errors + 1)) +fi + +exit $errors diff --git a/scripts/test/test-T002-hookrunner-engine.sh b/scripts/test/test-T002-hookrunner-engine.sh new file mode 100644 index 0000000..689730e --- /dev/null +++ b/scripts/test/test-T002-hookrunner-engine.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# Test: workflow.js exists in hook-runner and works correctly +set -euo pipefail + +HR_DIR="$HOME/Documents/ProjectsCL1/hook-runner" + +to_node() { cygpath -m "$1" 2>/dev/null || echo "$1"; } + +errors=0 + +# workflow.js exists in hook-runner +if [ -f "$HR_DIR/workflow.js" ]; then + echo "[OK] workflow.js exists in hook-runner" +else + echo "[FAIL] workflow.js missing from hook-runner" + exit 1 +fi + +WF_JS=$(to_node "$HR_DIR/workflow.js") + +# Can load without error +if node -e "require('$WF_JS')" 2>/dev/null; then + echo "[OK] workflow.js loads without error" +else + echo "[FAIL] workflow.js fails to load" + errors=$((errors + 1)) +fi + +# parseYaml works +RESULT=$(node -e " + const wf = require('$WF_JS'); + const parsed = wf.parseYaml('name: test\nversion: 1\nsteps:\n - id: s1\n name: Step 1'); + console.log(JSON.stringify(parsed)); +") +if echo "$RESULT" | grep -q '"name":"test"'; then + echo "[OK] parseYaml works" +else + echo "[FAIL] parseYaml returned: $RESULT" + errors=$((errors + 1)) +fi + +# State management works +TMPDIR=$(mktemp -d) +trap "rm -rf $TMPDIR" EXIT + +# Create a test workflow YAML +cat > "$TMPDIR/test.yml" <<'YAML' +name: test-wf +steps: + - id: step1 + name: First + gate: + require_files: [] + completion: + require_files: [] + - id: step2 + name: Second + gate: + require_step: step1 + completion: + require_files: [] +YAML + +TP=$(to_node "$TMPDIR") +TY=$(to_node "$TMPDIR/test.yml") + +# initState + currentStep +CURRENT=$(node -e " + const wf = require('$WF_JS'); + wf.initState('test-wf', '$TY', '$TP'); + console.log(wf.currentStep('$TP')); +") +if [ "$CURRENT" = "step1" ]; then + echo "[OK] initState + currentStep works" +else + echo "[FAIL] expected step1, got: $CURRENT" + errors=$((errors + 1)) +fi + +# completeStep + advance +NEXT=$(node -e " + const wf = require('$WF_JS'); + wf.completeStep('step1', '$TP'); + console.log(wf.currentStep('$TP')); +") +if [ "$NEXT" = "step2" ]; then + echo "[OK] completeStep advances correctly" +else + echo "[FAIL] expected step2, got: $NEXT" + errors=$((errors + 1)) +fi + +# checkGate blocks when prerequisite not met +GATE=$(node -e " + const wf = require('$WF_JS'); + const tmpDir2 = require('os').tmpdir() + '/wf-test-' + Date.now(); + require('fs').mkdirSync(tmpDir2, {recursive:true}); + require('fs').writeFileSync(tmpDir2 + '/test.yml', \`name: gate-test +steps: + - id: a + name: A + gate: + require_files: [] + completion: + require_files: [] + - id: b + name: B + gate: + require_step: a + completion: + require_files: [] +\`); + const p = '$TP'.replace(/\\\\/g,'/'); + wf.initState('gate-test', tmpDir2.replace(/\\\\/g,'/') + '/test.yml', tmpDir2.replace(/\\\\/g,'/')); + const check = wf.checkGate('b', tmpDir2.replace(/\\\\/g,'/')); + console.log(check.allowed ? 'allowed' : 'blocked'); +") +if [ "$GATE" = "blocked" ]; then + echo "[OK] checkGate blocks when prerequisite not met" +else + echo "[FAIL] expected blocked, got: $GATE" + errors=$((errors + 1)) +fi + +# Exports all expected functions +EXPORTS=$(node -e " + const wf = require('$WF_JS'); + const fns = ['parseYaml','loadWorkflow','findWorkflows','readState','writeState','initState','completeStep','currentStep','checkGate','checkEditAllowed']; + const missing = fns.filter(f => typeof wf[f] !== 'function'); + console.log(missing.length === 0 ? 'all' : 'missing:' + missing.join(',')); +") +if [ "$EXPORTS" = "all" ]; then + echo "[OK] all expected functions exported" +else + echo "[FAIL] $EXPORTS" + errors=$((errors + 1)) +fi + +echo "" +echo "=== $((7 - errors))/7 tests passed ===" +exit $errors diff --git a/scripts/test/test-T003-hookrunner-gate.sh b/scripts/test/test-T003-hookrunner-gate.sh new file mode 100644 index 0000000..4aa7044 --- /dev/null +++ b/scripts/test/test-T003-hookrunner-gate.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +# Test: workflow-gate PreToolUse module exists in hook-runner and works +set -euo pipefail + +HR_DIR="$HOME/Documents/ProjectsCL1/hook-runner" + +to_node() { cygpath -m "$1" 2>/dev/null || echo "$1"; } + +errors=0 + +# Module exists +if [ -f "$HR_DIR/modules/PreToolUse/workflow-gate.js" ]; then + echo "[OK] workflow-gate.js exists" +else + echo "[FAIL] modules/PreToolUse/workflow-gate.js missing" + exit 1 +fi + +MOD_JS=$(to_node "$HR_DIR/modules/PreToolUse/workflow-gate.js") + +# Module loads +if node -e "require('$MOD_JS')" 2>/dev/null; then + echo "[OK] module loads without error" +else + echo "[FAIL] module fails to load" + errors=$((errors + 1)) +fi + +# Module exports a function +TYPEOF=$(node -e "console.log(typeof require('$MOD_JS'))") +if [ "$TYPEOF" = "function" ]; then + echo "[OK] exports a function" +else + echo "[FAIL] expected function, got $TYPEOF" + errors=$((errors + 1)) +fi + +# Returns null when no active workflow +RESULT=$(node -e " + const gate = require('$MOD_JS'); + process.env.CLAUDE_PROJECT_DIR = require('os').tmpdir(); + const r = gate({tool_name:'Write', tool_input:{file_path:'/tmp/foo.js'}}); + console.log(r === null ? 'null' : JSON.stringify(r)); +") +if [ "$RESULT" = "null" ]; then + echo "[OK] returns null when no active workflow" +else + echo "[FAIL] expected null, got: $RESULT" + errors=$((errors + 1)) +fi + +# Returns null for allowed paths (TODO.md, specs/, etc.) +RESULT=$(node -e " + const gate = require('$MOD_JS'); + process.env.CLAUDE_PROJECT_DIR = require('os').tmpdir(); + const r = gate({tool_name:'Write', tool_input:{file_path:'/tmp/project/TODO.md'}}); + console.log(r === null ? 'null' : JSON.stringify(r)); +") +if [ "$RESULT" = "null" ]; then + echo "[OK] allows TODO.md edits" +else + echo "[FAIL] expected null for TODO.md, got: $RESULT" + errors=$((errors + 1)) +fi + +# Blocks when gate unsatisfied +WF_JS=$(to_node "$HR_DIR/workflow.js") +TMPDIR=$(mktemp -d) +trap "rm -rf $TMPDIR" EXIT +TP=$(to_node "$TMPDIR") + +cat > "$TMPDIR/test.yml" <<'YAML' +name: gate-test +steps: + - id: step1 + name: First + gate: + require_files: ["nonexistent.txt"] + completion: + require_files: [] +YAML + +TY=$(to_node "$TMPDIR/test.yml") + +RESULT=$(node -e " + const wf = require('$WF_JS'); + wf.initState('gate-test', '$TY', '$TP'); + const gate = require('$MOD_JS'); + process.env.CLAUDE_PROJECT_DIR = '$TP'; + const r = gate({tool_name:'Write', tool_input:{file_path:'$TP/src/main.js'}}); + console.log(r && r.decision === 'block' ? 'blocked' : 'not-blocked'); +") +if [ "$RESULT" = "blocked" ]; then + echo "[OK] blocks when gate unsatisfied" +else + echo "[FAIL] expected blocked, got: $RESULT" + errors=$((errors + 1)) +fi + +# Passes when gate satisfied +RESULT=$(node -e " + const wf = require('$WF_JS'); + const fs = require('fs'); + // Reset and use a workflow with no gate requirements + fs.unlinkSync('$TP/.workflow-state.json'); + fs.writeFileSync('$TP/test2.yml', \`name: pass-test +steps: + - id: step1 + name: First + gate: + require_files: [] + completion: + require_files: [] +\`); + wf.initState('pass-test', '$TP/test2.yml', '$TP'); + const gate = require('$MOD_JS'); + process.env.CLAUDE_PROJECT_DIR = '$TP'; + // Clear require cache to get fresh module + delete require.cache[require.resolve('$MOD_JS')]; + const gate2 = require('$MOD_JS'); + const r = gate2({tool_name:'Write', tool_input:{file_path:'$TP/src/main.js'}}); + console.log(r === null ? 'passed' : JSON.stringify(r)); +") +if [ "$RESULT" = "passed" ]; then + echo "[OK] passes when gate satisfied" +else + echo "[FAIL] expected passed, got: $RESULT" + errors=$((errors + 1)) +fi + +# Only triggers on Write/Edit +RESULT=$(node -e " + delete require.cache[require.resolve('$MOD_JS')]; + const gate = require('$MOD_JS'); + process.env.CLAUDE_PROJECT_DIR = '$TP'; + const r = gate({tool_name:'Bash', tool_input:{command:'echo hi'}}); + console.log(r === null ? 'skipped' : 'triggered'); +") +if [ "$RESULT" = "skipped" ]; then + echo "[OK] skips non-Write/Edit tools" +else + echo "[FAIL] expected skipped, got: $RESULT" + errors=$((errors + 1)) +fi + +echo "" +echo "=== $((8 - errors))/8 tests passed ===" +exit $errors diff --git a/scripts/test/test-T004-hookrunner-cli.sh b/scripts/test/test-T004-hookrunner-cli.sh new file mode 100644 index 0000000..069130d --- /dev/null +++ b/scripts/test/test-T004-hookrunner-cli.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# Test: --workflow CLI commands work in hook-runner setup.js +set -euo pipefail + +HR_DIR="$HOME/Documents/ProjectsCL1/hook-runner" +SETUP="$HR_DIR/setup.js" + +errors=0 + +# setup.js exists +if [ ! -f "$SETUP" ]; then + echo "[FAIL] setup.js not found" + exit 1 +fi + +# Create temp project with a workflow +TMPDIR=$(mktemp -d) +trap "rm -rf $TMPDIR" EXIT +mkdir -p "$TMPDIR/workflows" + +cat > "$TMPDIR/workflows/test-cli.yml" <<'YAML' +name: test-cli +description: Test workflow for CLI commands +version: 1 +steps: + - id: step1 + name: First step + gate: + require_files: [] + completion: + require_files: [] + - id: step2 + name: Second step + gate: + require_step: step1 + completion: + require_files: [] +YAML + +# --workflow list +OUTPUT=$(cd "$TMPDIR" && node "$SETUP" --workflow list 2>&1) +if echo "$OUTPUT" | grep -q "test-cli"; then + echo "[OK] --workflow list shows test-cli" +else + echo "[FAIL] --workflow list didn't show test-cli: $OUTPUT" + errors=$((errors + 1)) +fi + +# --workflow start +OUTPUT=$(cd "$TMPDIR" && node "$SETUP" --workflow start test-cli 2>&1) +if echo "$OUTPUT" | grep -q "started\|Started\|step1"; then + echo "[OK] --workflow start works" +else + echo "[FAIL] --workflow start: $OUTPUT" + errors=$((errors + 1)) +fi + +# --workflow status +OUTPUT=$(cd "$TMPDIR" && node "$SETUP" --workflow status 2>&1) +if echo "$OUTPUT" | grep -q "test-cli"; then + echo "[OK] --workflow status shows active workflow" +else + echo "[FAIL] --workflow status: $OUTPUT" + errors=$((errors + 1)) +fi + +# --workflow complete +OUTPUT=$(cd "$TMPDIR" && node "$SETUP" --workflow complete step1 2>&1) +if echo "$OUTPUT" | grep -q "completed\|Completed\|step2\|Next"; then + echo "[OK] --workflow complete step1 works" +else + echo "[FAIL] --workflow complete: $OUTPUT" + errors=$((errors + 1)) +fi + +# --workflow reset +OUTPUT=$(cd "$TMPDIR" && node "$SETUP" --workflow reset 2>&1) +if echo "$OUTPUT" | grep -q "reset\|Reset\|cleared\|Cleared"; then + echo "[OK] --workflow reset works" +else + echo "[FAIL] --workflow reset: $OUTPUT" + errors=$((errors + 1)) +fi + +echo "" +echo "=== $((5 - errors))/5 tests passed ===" +exit $errors diff --git a/scripts/test/test-T005-workflow-templates.sh b/scripts/test/test-T005-workflow-templates.sh new file mode 100644 index 0000000..0f9ba3e --- /dev/null +++ b/scripts/test/test-T005-workflow-templates.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Test: built-in workflow templates exist in hook-runner +set -euo pipefail + +HR_DIR="$HOME/Documents/ProjectsCL1/hook-runner" +errors=0 + +# workflows/ directory exists +if [ -d "$HR_DIR/workflows" ]; then + echo "[OK] workflows/ directory exists" +else + echo "[FAIL] workflows/ directory missing" + exit 1 +fi + +# enforce-shtd.yml exists and has required fields +if [ -f "$HR_DIR/workflows/enforce-shtd.yml" ]; then + echo "[OK] enforce-shtd.yml exists" + for field in "name:" "steps:" "gate:"; do + if grep -q "$field" "$HR_DIR/workflows/enforce-shtd.yml"; then + echo "[OK] enforce-shtd.yml has $field" + else + echo "[FAIL] enforce-shtd.yml missing $field" + errors=$((errors + 1)) + fi + done +else + echo "[FAIL] workflows/enforce-shtd.yml missing" + errors=$((errors + 1)) +fi + +# cross-project-reset.yml exists +if [ -f "$HR_DIR/workflows/cross-project-reset.yml" ]; then + echo "[OK] cross-project-reset.yml exists" +else + echo "[FAIL] workflows/cross-project-reset.yml missing" + errors=$((errors + 1)) +fi + +# Templates are discoverable via CLI +SETUP="$HR_DIR/setup.js" +OUTPUT=$(cd /tmp && CLAUDE_PROJECT_DIR=/tmp node "$SETUP" --workflow list 2>&1) +if echo "$OUTPUT" | grep -q "enforce-shtd"; then + echo "[OK] enforce-shtd discoverable via CLI" +else + echo "[FAIL] enforce-shtd not in CLI list: $OUTPUT" + errors=$((errors + 1)) +fi + +echo "" +echo "=== Tests complete, $errors error(s) ===" +exit $errors diff --git a/scripts/test/test-T006-hookrunner-e2e.sh b/scripts/test/test-T006-hookrunner-e2e.sh new file mode 100644 index 0000000..13ee011 --- /dev/null +++ b/scripts/test/test-T006-hookrunner-e2e.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +# E2E test: full workflow lifecycle in hook-runner (engine + gate + CLI) +set -euo pipefail + +HR_DIR="$HOME/Documents/ProjectsCL1/hook-runner" +SETUP="$HR_DIR/setup.js" + +to_node() { cygpath -m "$1" 2>/dev/null || echo "$1"; } + +errors=0 +TMPDIR=$(mktemp -d) +trap "rm -rf $TMPDIR" EXIT + +# Set up a fake project with a workflow +mkdir -p "$TMPDIR/workflows" "$TMPDIR/specs/001-test/scripts/test" + +cat > "$TMPDIR/workflows/e2e-test.yml" <<'YAML' +name: e2e-test +description: End-to-end test workflow +version: 1 +steps: + - id: plan + name: Write plan + gate: + require_files: [] + completion: + require_files: ["plan.md"] + - id: build + name: Build feature + gate: + require_step: plan + require_files: ["plan.md"] + completion: + require_files: ["output.txt"] + - id: verify + name: Run verification + gate: + require_step: build + completion: + require_files: ["verified.txt"] +YAML + +TP=$(to_node "$TMPDIR") +WF_JS=$(to_node "$HR_DIR/workflow.js") +GATE_JS=$(to_node "$HR_DIR/modules/PreToolUse/workflow-gate.js") + +# === Test 1: CLI list discovers the workflow === +OUTPUT=$(cd "$TMPDIR" && CLAUDE_PROJECT_DIR="$TMPDIR" node "$SETUP" --workflow list 2>&1) +if echo "$OUTPUT" | grep -q "e2e-test"; then + echo "[OK] CLI list discovers e2e-test workflow" +else + echo "[FAIL] CLI list: $OUTPUT" + errors=$((errors + 1)) +fi + +# === Test 2: CLI start activates workflow === +OUTPUT=$(cd "$TMPDIR" && CLAUDE_PROJECT_DIR="$TMPDIR" node "$SETUP" --workflow start e2e-test 2>&1) +if echo "$OUTPUT" | grep -q "Started.*plan"; then + echo "[OK] CLI start activates workflow at step 'plan'" +else + echo "[FAIL] CLI start: $OUTPUT" + errors=$((errors + 1)) +fi + +# === Test 3: Gate blocks when require_files missing for step 'build' === +RESULT=$(node -e " + process.env.CLAUDE_PROJECT_DIR = '$TP'; + delete require.cache[require.resolve('$GATE_JS')]; + delete require.cache[require.resolve('$WF_JS')]; + var wf = require('$WF_JS'); + // Manually advance to build step (plan not completed yet) + var state = wf.readState('$TP'); + // Try to check gate for build — plan not completed + var check = wf.checkGate('build', '$TP'); + console.log(check.allowed ? 'allowed' : 'blocked'); +") +if [ "$RESULT" = "blocked" ]; then + echo "[OK] Gate blocks step 'build' when 'plan' not completed" +else + echo "[FAIL] expected blocked, got: $RESULT" + errors=$((errors + 1)) +fi + +# === Test 4: Create plan.md, complete step, gate passes === +echo "# Plan" > "$TMPDIR/plan.md" +OUTPUT=$(cd "$TMPDIR" && CLAUDE_PROJECT_DIR="$TMPDIR" node "$SETUP" --workflow complete plan 2>&1) +if echo "$OUTPUT" | grep -q "Completed.*plan"; then + echo "[OK] CLI complete advances past 'plan'" +else + echo "[FAIL] CLI complete: $OUTPUT" + errors=$((errors + 1)) +fi + +# Now gate should allow edits (build step's gate: require_step plan + require_files plan.md) +RESULT=$(node -e " + process.env.CLAUDE_PROJECT_DIR = '$TP'; + delete require.cache[require.resolve('$GATE_JS')]; + delete require.cache[require.resolve('$WF_JS')]; + var gate = require('$GATE_JS'); + var r = gate({tool_name:'Write', tool_input:{file_path:'$TP/src/main.js'}}); + console.log(r === null ? 'allowed' : 'blocked'); +") +if [ "$RESULT" = "allowed" ]; then + echo "[OK] Gate allows edits after 'plan' completed" +else + echo "[FAIL] expected allowed, got: $RESULT" + errors=$((errors + 1)) +fi + +# === Test 5: Status shows correct state === +OUTPUT=$(cd "$TMPDIR" && CLAUDE_PROJECT_DIR="$TMPDIR" node "$SETUP" --workflow status 2>&1) +if echo "$OUTPUT" | grep -q "OK.*plan.*completed" && echo "$OUTPUT" | grep -q ">>.*build.*in_progress"; then + echo "[OK] Status shows plan=completed, build=in_progress" +else + echo "[FAIL] Status output: $OUTPUT" + errors=$((errors + 1)) +fi + +# === Test 6: Complete remaining steps === +echo "done" > "$TMPDIR/output.txt" +OUTPUT=$(cd "$TMPDIR" && CLAUDE_PROJECT_DIR="$TMPDIR" node "$SETUP" --workflow complete build 2>&1) +echo "verified" > "$TMPDIR/verified.txt" +OUTPUT2=$(cd "$TMPDIR" && CLAUDE_PROJECT_DIR="$TMPDIR" node "$SETUP" --workflow complete verify 2>&1) +if echo "$OUTPUT2" | grep -q "complete\|Complete"; then + echo "[OK] All steps completed" +else + echo "[FAIL] final complete: $OUTPUT2" + errors=$((errors + 1)) +fi + +# === Test 7: Reset clears state === +OUTPUT=$(cd "$TMPDIR" && CLAUDE_PROJECT_DIR="$TMPDIR" node "$SETUP" --workflow reset 2>&1) +if echo "$OUTPUT" | grep -q "cleared\|Cleared"; then + echo "[OK] Reset clears workflow state" +else + echo "[FAIL] reset: $OUTPUT" + errors=$((errors + 1)) +fi + +# === Test 8: After reset, no active workflow === +OUTPUT=$(cd "$TMPDIR" && CLAUDE_PROJECT_DIR="$TMPDIR" node "$SETUP" --workflow status 2>&1) +if echo "$OUTPUT" | grep -q "No active"; then + echo "[OK] No active workflow after reset" +else + echo "[FAIL] post-reset status: $OUTPUT" + errors=$((errors + 1)) +fi + +echo "" +echo "=== $((9 - errors))/9 e2e tests passed ===" +exit $errors diff --git a/scripts/test/test-T007-enforce-shtd.sh b/scripts/test/test-T007-enforce-shtd.sh new file mode 100644 index 0000000..c0a5f33 --- /dev/null +++ b/scripts/test/test-T007-enforce-shtd.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# Test: enforce-shtd meta-workflow blocks out-of-order steps +set -euo pipefail + +HR_DIR="$HOME/Documents/ProjectsCL1/hook-runner" +SETUP="$HR_DIR/setup.js" + +to_node() { cygpath -m "$1" 2>/dev/null || echo "$1"; } + +errors=0 +TMPDIR=$(mktemp -d) +trap "rm -rf $TMPDIR" EXIT + +# Copy enforce-shtd to the project so it's discoverable +mkdir -p "$TMPDIR/workflows" +cp "$HR_DIR/workflows/enforce-shtd.yml" "$TMPDIR/workflows/" + +WF_JS=$(to_node "$HR_DIR/workflow.js") +GATE_JS=$(to_node "$HR_DIR/modules/PreToolUse/workflow-gate.js") +TP=$(to_node "$TMPDIR") + +# === Test 1: Start enforce-shtd workflow === +OUTPUT=$(cd "$TMPDIR" && CLAUDE_PROJECT_DIR="$TMPDIR" node "$SETUP" --workflow start enforce-shtd 2>&1) +if echo "$OUTPUT" | grep -q "Started.*spec"; then + echo "[OK] enforce-shtd starts at 'spec' step" +else + echo "[FAIL] start: $OUTPUT" + errors=$((errors + 1)) +fi + +# === Test 2: Gate blocks code edits (spec step has no file requirement, but tasks step requires spec) === +# Complete spec step first +mkdir -p "$TMPDIR/specs/001-test" +echo "# Spec" > "$TMPDIR/specs/001-test/spec.md" +cd "$TMPDIR" && CLAUDE_PROJECT_DIR="$TMPDIR" node "$SETUP" --workflow complete spec >/dev/null 2>&1 + +# Now on tasks step — try to skip to implement +RESULT=$(node -e " + process.env.CLAUDE_PROJECT_DIR = '$TP'; + delete require.cache[require.resolve('$WF_JS')]; + delete require.cache[require.resolve('$GATE_JS')]; + var wf = require('$WF_JS'); + // Check gate for 'implement' step directly — should be blocked + var check = wf.checkGate('implement', '$TP'); + console.log(check.allowed ? 'allowed' : 'blocked'); +") +if [ "$RESULT" = "blocked" ]; then + echo "[OK] Cannot skip to 'implement' — must complete 'tasks' first" +else + echo "[FAIL] expected blocked, got: $RESULT" + errors=$((errors + 1)) +fi + +# === Test 3: Complete tasks, workflow, branch — then implement is reachable === +echo "# Tasks" > "$TMPDIR/specs/001-test/tasks.md" +cd "$TMPDIR" && CLAUDE_PROJECT_DIR="$TMPDIR" node "$SETUP" --workflow complete tasks >/dev/null 2>&1 + +echo "# WF" > "$TMPDIR/workflows/test.yml" +cd "$TMPDIR" && CLAUDE_PROJECT_DIR="$TMPDIR" node "$SETUP" --workflow complete workflow >/dev/null 2>&1 +cd "$TMPDIR" && CLAUDE_PROJECT_DIR="$TMPDIR" node "$SETUP" --workflow complete branch >/dev/null 2>&1 +cd "$TMPDIR" && CLAUDE_PROJECT_DIR="$TMPDIR" node "$SETUP" --workflow complete test >/dev/null 2>&1 + +# Now on 'implement' — gate should allow +RESULT=$(node -e " + process.env.CLAUDE_PROJECT_DIR = '$TP'; + delete require.cache[require.resolve('$WF_JS')]; + delete require.cache[require.resolve('$GATE_JS')]; + var gate = require('$GATE_JS'); + var r = gate({tool_name:'Write', tool_input:{file_path:'$TP/src/main.js'}}); + console.log(r === null ? 'allowed' : 'blocked'); +") +if [ "$RESULT" = "allowed" ]; then + echo "[OK] Gate allows edits at 'implement' step after prerequisites met" +else + echo "[FAIL] expected allowed at implement, got: $RESULT" + errors=$((errors + 1)) +fi + +# === Test 4: Status shows progress === +OUTPUT=$(cd "$TMPDIR" && CLAUDE_PROJECT_DIR="$TMPDIR" node "$SETUP" --workflow status 2>&1) +if echo "$OUTPUT" | grep -q "enforce-shtd" && echo "$OUTPUT" | grep -q "OK.*spec.*completed"; then + echo "[OK] Status shows enforce-shtd progress" +else + echo "[FAIL] status: $OUTPUT" + errors=$((errors + 1)) +fi + +# === Test 5: The meta-workflow enforces its own pattern (self-referential) === +# Starting enforce-shtd in a fresh project should block code until spec exists +TMPDIR2=$(mktemp -d) +mkdir -p "$TMPDIR2/workflows" +cp "$HR_DIR/workflows/enforce-shtd.yml" "$TMPDIR2/workflows/" +TP2=$(to_node "$TMPDIR2") + +cd "$TMPDIR2" && CLAUDE_PROJECT_DIR="$TMPDIR2" node "$SETUP" --workflow start enforce-shtd >/dev/null 2>&1 + +# The gate module won't block because spec step has require_files: [] (empty gate) +# But the WORKFLOW is active, which means the workflow-gate is engaged +# After completing spec, tasks step requires specs/*/tasks.md +RESULT=$(node -e " + process.env.CLAUDE_PROJECT_DIR = '$TP2'; + delete require.cache[require.resolve('$WF_JS')]; + delete require.cache[require.resolve('$GATE_JS')]; + var wf = require('$WF_JS'); + // Skip spec, try tasks — it requires spec to be completed + var check = wf.checkGate('tasks', '$TP2'); + console.log(check.allowed ? 'allowed' : 'blocked'); +") +if [ "$RESULT" = "blocked" ]; then + echo "[OK] Meta: enforce-shtd blocks tasks step until spec is done" +else + echo "[FAIL] meta test expected blocked, got: $RESULT" + errors=$((errors + 1)) +fi + +rm -rf "$TMPDIR2" + +echo "" +echo "=== $((5 - errors))/5 enforce-shtd tests passed ===" +exit $errors diff --git a/scripts/test/test-T008-shtd-delegation.sh b/scripts/test/test-T008-shtd-delegation.sh new file mode 100644 index 0000000..d1fce8f --- /dev/null +++ b/scripts/test/test-T008-shtd-delegation.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Test: spec-hook's workflow system delegates to hook-runner's engine +set -euo pipefail + +HR_DIR="$HOME/Documents/ProjectsCL1/hook-runner" +SPEC_DIR="$(cd "$(dirname "$0")/../.." && pwd)" + +to_node() { cygpath -m "$1" 2>/dev/null || echo "$1"; } + +errors=0 + +# spec-hook's workflow.js should now be a thin wrapper or re-export of hook-runner's +SHTD_WF="$SPEC_DIR/lib/workflow.js" +HR_WF="$HR_DIR/workflow.js" + +if [ ! -f "$SHTD_WF" ]; then + echo "[FAIL] spec-hook lib/workflow.js missing" + exit 1 +fi + +# Both should export the same API +SHTD_JS=$(to_node "$SHTD_WF") +HR_JS=$(to_node "$HR_WF") + +SHTD_EXPORTS=$(node -e " + var wf = require('$SHTD_JS'); + console.log(Object.keys(wf).sort().join(',')); +") +HR_EXPORTS=$(node -e " + var wf = require('$HR_JS'); + console.log(Object.keys(wf).sort().join(',')); +") + +# hook-runner should have at least all the functions spec-hook has +MISSING=$(node -e " + var hr = require('$HR_JS'); + var needed = ['parseYaml','loadWorkflow','findWorkflows','readState','writeState','initState','completeStep','currentStep','checkGate','checkEditAllowed']; + var missing = needed.filter(function(f) { return typeof hr[f] !== 'function'; }); + console.log(missing.length === 0 ? 'none' : missing.join(',')); +") +if [ "$MISSING" = "none" ]; then + echo "[OK] hook-runner has all required workflow API functions" +else + echo "[FAIL] hook-runner missing: $MISSING" + errors=$((errors + 1)) +fi + +# spec-hook's shtd-workflow.sh should still work +OUTPUT=$(cd "$SPEC_DIR" && bash scripts/shtd-workflow.sh list 2>&1) +if echo "$OUTPUT" | grep -qi "workflow\|steps\|No workflows"; then + echo "[OK] shtd-workflow.sh still works" +else + echo "[FAIL] shtd-workflow.sh broken: $OUTPUT" + errors=$((errors + 1)) +fi + +# install.sh --check should still pass +OUTPUT=$(cd "$SPEC_DIR" && bash install.sh --check 2>&1) +if echo "$OUTPUT" | grep -q "installed successfully"; then + echo "[OK] install.sh --check passes" +else + echo "[FAIL] install.sh --check failed" + errors=$((errors + 1)) +fi + +# hook-runner's workflow.js uses .workflow-state.json (not .shtd-workflow-state.json) +HR_STATE=$(node -e "var wf = require('$HR_JS'); console.log(wf.STATE_FILE);") +if [ "$HR_STATE" = ".workflow-state.json" ]; then + echo "[OK] hook-runner uses .workflow-state.json" +else + echo "[FAIL] expected .workflow-state.json, got: $HR_STATE" + errors=$((errors + 1)) +fi + +echo "" +echo "=== $((4 - errors))/4 delegation tests passed ===" +exit $errors diff --git a/scripts/test/test-T009-final-verify.sh b/scripts/test/test-T009-final-verify.sh new file mode 100644 index 0000000..9868b2a --- /dev/null +++ b/scripts/test/test-T009-final-verify.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Final verification: all workflow components in place across both projects +set -euo pipefail + +HR_DIR="$HOME/Documents/ProjectsCL1/hook-runner" +SPEC_DIR="$(cd "$(dirname "$0")/../.." && pwd)" + +errors=0 + +# hook-runner has workflow.js +[ -f "$HR_DIR/workflow.js" ] && echo "[OK] hook-runner: workflow.js" || { echo "[FAIL] workflow.js"; errors=$((errors + 1)); } + +# hook-runner has workflow-gate module +[ -f "$HR_DIR/modules/PreToolUse/workflow-gate.js" ] && echo "[OK] hook-runner: workflow-gate.js" || { echo "[FAIL] workflow-gate.js"; errors=$((errors + 1)); } + +# hook-runner has built-in workflows +[ -f "$HR_DIR/workflows/enforce-shtd.yml" ] && echo "[OK] hook-runner: enforce-shtd.yml" || { echo "[FAIL] enforce-shtd.yml"; errors=$((errors + 1)); } +[ -f "$HR_DIR/workflows/cross-project-reset.yml" ] && echo "[OK] hook-runner: cross-project-reset.yml" || { echo "[FAIL] cross-project-reset.yml"; errors=$((errors + 1)); } + +# hook-runner setup.js has --workflow command +grep -q "cmdWorkflow" "$HR_DIR/setup.js" && echo "[OK] hook-runner: --workflow in setup.js" || { echo "[FAIL] cmdWorkflow"; errors=$((errors + 1)); } + +# spec-hook install still works +OUTPUT=$(cd "$SPEC_DIR" && bash install.sh --check 2>&1) +echo "$OUTPUT" | grep -q "installed successfully" && echo "[OK] spec-hook: install --check" || { echo "[FAIL] install --check"; errors=$((errors + 1)); } + +# All spec-hook tests pass +for t in T001 T002 T003 T004 T005 T006 T007 T008; do + SCRIPT=$(ls "$SPEC_DIR/scripts/test/test-${t}-"*.sh 2>/dev/null | head -1) + if [ -n "$SCRIPT" ] && [ -f "$SCRIPT" ]; then + if bash "$SCRIPT" >/dev/null 2>&1; then + echo "[OK] $t test passes" + else + echo "[FAIL] $t test failed" + errors=$((errors + 1)) + fi + fi +done + +echo "" +echo "=== Final: $errors error(s) ===" +exit $errors diff --git a/specs/005-add-workflows-to-hookrunner/spec.md b/specs/005-add-workflows-to-hookrunner/spec.md new file mode 100644 index 0000000..9e9d3c7 --- /dev/null +++ b/specs/005-add-workflows-to-hookrunner/spec.md @@ -0,0 +1,74 @@ +# 005: Add Workflows to Hook-Runner + +## Problem + +Spec-hook has a workflow engine (YAML step pipelines with gate enforcement) that only works within SHTD flow. The engine should be a hook-runner feature so any project can define workflows without depending on spec-hook. Currently: + +- Workflow engine lives in `spec-hook/lib/workflow.js` — wrong home +- Workflow gate is an SHTD module — should be a hook-runner module +- No CLI integration in hook-runner (`--workflow` commands) +- No way to enforce "always use workflows" without manual discipline +- Cross-project context reset behavior needs workflow enforcement + +## Solution + +Move the workflow engine to hook-runner as a first-class feature: + +1. **workflow.js** → hook-runner lib (zero-dep YAML parser + state machine) +2. **workflow-gate module** → hook-runner PreToolUse catalog +3. **CLI commands** → `node setup.js --workflow list|start|status|complete|reset` +4. **Global workflows** → `~/.claude/hooks/workflows/` (shared across projects) +5. **Project workflows** → `workflows/` in project root (project-specific) +6. **Meta-workflow** → `enforce-shtd.yml` that enforces SHTD pipeline using itself + +## Architecture + +``` +hook-runner/ + workflow.js # Engine: YAML parser, state machine, gate checker + modules/PreToolUse/ + workflow-gate.js # Gate: blocks out-of-order edits per active workflow + workflows/ # Built-in workflow templates + enforce-shtd.yml # Meta: enforce spec→test→branch→PR pipeline + cross-project-reset.yml # Context reset when switching projects +``` + +### Workflow Discovery (priority order) +1. Project: `$CLAUDE_PROJECT_DIR/workflows/*.yml` +2. Global: `~/.claude/hooks/workflows/*.yml` +3. Built-in: hook-runner `workflows/` directory + +### State +- Per-project: `$CLAUDE_PROJECT_DIR/.workflow-state.json` +- Tracks: active workflow name, step statuses, timestamps + +### Gate Logic +- On Write/Edit: check if active workflow exists → validate current step's gate +- Gates: `require_step` (previous step completed), `require_files` (files exist) +- Allowed paths bypass gates (TODO.md, CLAUDE.md, specs/, tests/, etc.) + +## Cross-Project Context Reset Workflow + +When TODO.md is complete and a new project is requested: +1. Save state to current project's TODO.md +2. Commit and push current project +3. Create TODO.md in new project (if needed) +4. Context reset into new project directory +5. Variables control behavior: + - `PRESERVE_TAB=true` — keep old project tab open + - `CONTINUE_BOTH=true` — work in both projects (no stop) + - Default: stop old, start new + +## Enforce-SHTD Meta-Workflow + +A workflow that enforces "always use workflows when making behavioral rules": +1. Any new behavioral rule must start with a workflow YAML +2. The workflow defines enforcement steps +3. Only after workflow is active can gates/modules be created +4. Self-referential: this workflow enforces its own creation pattern + +## Scope + +- hook-runner gets: workflow.js, workflow-gate module, CLI commands, built-in templates +- spec-hook gets: updated to delegate to hook-runner's engine (thin wrapper) +- Both projects get PRs diff --git a/specs/005-add-workflows-to-hookrunner/tasks.md b/specs/005-add-workflows-to-hookrunner/tasks.md new file mode 100644 index 0000000..9b7a752 --- /dev/null +++ b/specs/005-add-workflows-to-hookrunner/tasks.md @@ -0,0 +1,25 @@ +# Tasks: 005 — Add Workflows to Hook-Runner + +## Phase 1: Spec & Plan (spec-hook) +- [x] T001 Write spec, workflow YAML, and tasks for this feature + **Checkpoint**: `bash scripts/test/test-T001-workflow-spec.sh` + +## Phase 2: Build in hook-runner +- [ ] T002 Port workflow.js to hook-runner (extract from spec-hook, zero deps) + **Checkpoint**: `bash scripts/test/test-T002-hookrunner-engine.sh` +- [ ] T003 Add workflow-gate PreToolUse module to hook-runner catalog + **Checkpoint**: `bash scripts/test/test-T003-hookrunner-gate.sh` +- [ ] T004 Add --workflow CLI commands to setup.js (list, start, status, complete, reset) + **Checkpoint**: `bash scripts/test/test-T004-hookrunner-cli.sh` +- [ ] T005 Add built-in workflow templates (enforce-shtd.yml, cross-project-reset.yml) + **Checkpoint**: `bash scripts/test/test-T005-workflow-templates.sh` +- [ ] T006 Tests for workflow engine + gate + CLI + **Checkpoint**: `bash scripts/test/test-T006-hookrunner-e2e.sh` + +## Phase 3: Meta-workflow & SHTD update +- [ ] T007 Create enforce-shtd.yml meta-workflow (enforces using SHTD to create rules) + **Checkpoint**: `bash scripts/test/test-T007-enforce-shtd.sh` +- [ ] T008 Update spec-hook to delegate to hook-runner's workflow engine + **Checkpoint**: `bash scripts/test/test-T008-shtd-delegation.sh` +- [ ] T009 PR for hook-runner, PR for spec-hook, update docs + **Checkpoint**: `bash scripts/test/test-T009-final-verify.sh` diff --git a/workflows/005-add-workflows-to-hookrunner.yml b/workflows/005-add-workflows-to-hookrunner.yml new file mode 100644 index 0000000..822062a --- /dev/null +++ b/workflows/005-add-workflows-to-hookrunner.yml @@ -0,0 +1,58 @@ +name: add-workflows-to-hookrunner +description: Move workflow engine from spec-hook to hook-runner as a first-class feature +version: 1 +steps: + - id: spec + name: Write spec for hook-runner workflow feature + gate: + require_files: [] + completion: + require_files: ["specs/005-add-workflows-to-hookrunner/spec.md"] + - id: tasks + name: Break spec into tasks + gate: + require_step: spec + completion: + require_files: ["specs/005-add-workflows-to-hookrunner/tasks.md"] + - id: engine + name: Port workflow engine to hook-runner + gate: + require_step: tasks + completion: + require_files: [] + - id: gate-module + name: Add workflow-gate PreToolUse module to hook-runner + gate: + require_step: engine + completion: + require_files: [] + - id: cli + name: Add workflow CLI commands to hook-runner setup.js + gate: + require_step: gate-module + completion: + require_files: [] + - id: templates + name: Add built-in workflow templates + gate: + require_step: cli + completion: + require_files: [] + - id: test + name: Test workflow engine end-to-end + gate: + require_step: templates + completion: + require_files: [] + - id: meta-workflow + name: Create enforce-shtd meta-workflow + gate: + require_step: test + completion: + require_files: [] + - id: update-shtd + name: Update spec-hook to delegate to hook-runner engine + gate: + require_step: meta-workflow + completion: + require_files: []