From 79be0e8d3a74d2653a196f79531d1922e2d0e38b Mon Sep 17 00:00:00 2001 From: grobomo Date: Sat, 4 Apr 2026 14:28:02 -0500 Subject: [PATCH 1/3] T001: Spec and workflow for adding workflows to hook-runner --- scripts/test/test-T001-workflow-spec.sh | 34 +++++++++ specs/005-add-workflows-to-hookrunner/spec.md | 74 +++++++++++++++++++ .../005-add-workflows-to-hookrunner/tasks.md | 25 +++++++ workflows/005-add-workflows-to-hookrunner.yml | 58 +++++++++++++++ 4 files changed, 191 insertions(+) create mode 100644 scripts/test/test-T001-workflow-spec.sh create mode 100644 specs/005-add-workflows-to-hookrunner/spec.md create mode 100644 specs/005-add-workflows-to-hookrunner/tasks.md create mode 100644 workflows/005-add-workflows-to-hookrunner.yml 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/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: [] From 821d3546787f924093198dfeae5013a0c693bd34 Mon Sep 17 00:00:00 2001 From: grobomo Date: Sat, 4 Apr 2026 14:31:15 -0500 Subject: [PATCH 2/3] T002: Port workflow engine to hook-runner --- scripts/test/test-T002-hookrunner-engine.sh | 141 ++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 scripts/test/test-T002-hookrunner-engine.sh 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 From 87f2482c724e6d6f49dbfe4d542dac99189935b9 Mon Sep 17 00:00:00 2001 From: grobomo Date: Sat, 4 Apr 2026 14:33:42 -0500 Subject: [PATCH 3/3] T003: Add workflow-gate PreToolUse module to hook-runner --- scripts/test/test-T003-hookrunner-gate.sh | 148 ++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 scripts/test/test-T003-hookrunner-gate.sh 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