diff --git a/manifests/context-packs/skill-triggers@1.json b/manifests/context-packs/skill-triggers@1.json index a49a8c824..0f9097e61 100644 --- a/manifests/context-packs/skill-triggers@1.json +++ b/manifests/context-packs/skill-triggers@1.json @@ -10,7 +10,7 @@ "id": "hand-seeded", "source": "manual-curation-pending-regeneration" }, - "registryDigest": "498caee2738006d64530e9be34883111cf67742d8c4389f39d61299e1a1c4400", + "registryDigest": "a1d627151ed8ea13ccae122c3070c55e18874b6c10e32eee1deba56348acb478", "schemaVersion": 1, "triggers": { "skill:agent-runtime-gateway-smoke-test": [ diff --git a/skills/delivery-gate/SKILL.md b/skills/delivery-gate/SKILL.md index 940e26160..e4dff3963 100644 --- a/skills/delivery-gate/SKILL.md +++ b/skills/delivery-gate/SKILL.md @@ -21,11 +21,14 @@ This is the same pattern as CI pipeline gates — automated, deterministic check | Check | Mechanism | On Hit | |-------|-----------|--------| | Rationalization patterns | Regex on transcript tail | **Warning only** (never blocks) | -| Stale learning libraries | mtime on 5 configurable paths | Warning if some stale; **Block** if >=3 stale OR growth-log stale + complex task | -| Disk space < 50GB | `shutil.disk_usage` | Warning | -| Disk space < 15GB | `shutil.disk_usage` | **Block** (exit 2) | +| Stale learning libraries | mtime on 5 configurable paths | Warning if some stale; **Block** on a complex task if >=3 stale OR growth-log stale | +| Disk space < 50GB | `shutil.disk_usage` | Reminder | +| Disk space < 30GB | `shutil.disk_usage` | Warning | +| Disk space < 15GB | `shutil.disk_usage` | **Block** (exit 2), on every Stop | -Rationalization detection warns about patterns like "skip tests for now" and "pre-existing bug" — surface signals that thinking may have been cut short. It never blocks on its own, because regex heuristics can false-positive. The blocking conditions are: disk critical, `>=3 learning libs stale`, OR `growth-log` specifically stale (all require complex task >=3 edits). +Rationalization detection warns about patterns like "skip tests for now" and "pre-existing bug" — surface signals that thinking may have been cut short. It never blocks on its own, because regex heuristics can false-positive. The blocking conditions are: disk critical (any session), or, on a complex task (>=3 Edit/Write calls), `>=3 learning libs stale` OR `growth-log` specifically stale. + +The hook blocks a given Stop at most once. When Claude Code re-runs it with `stop_hook_active: true` (Claude already continued once because of this hook), it exits 0, so a condition the agent cannot fix (such as low disk space) cannot trap the session in an endless loop. ## Why @@ -50,7 +53,7 @@ Add to `~/.claude/settings.json`: "hooks": [{ "type": "command", "command": "python3 ~/.claude/scripts/quality-gate.py", - "timeout": 5000 + "timeout": 5 }] }] } @@ -59,7 +62,7 @@ Add to `~/.claude/settings.json`: ## Learning Libraries -Create these files in your project's memory directory. The hook checks if at least one was updated today: +Create these files in your project's memory directory (`~/.claude/projects//memory/`, where every non-alphanumeric character of the project path becomes `-`). The hook checks which of them were updated today: ``` memory/ @@ -81,7 +84,8 @@ Edit `quality-gate.py`: | `RATIONALIZE` | 4 patterns | Regex patterns for rationalization detection | | `LIBS` | 5 libraries | Files/dirs to check for today's updates | | `COMPLEX_THRESHOLD` | 3 | Edit/Write calls to classify as complex | -| `DISK_WARN_GB` | 50 | Warn below this | +| `DISK_REMIND_GB` | 50 | Remind below this | +| `DISK_WARN_GB` | 30 | Warn below this | | `DISK_CRIT_GB` | 15 | Block below this | ## Examples @@ -93,24 +97,24 @@ edit_count=1 (< 3, not complex) → exit 0 **Complex task, learning captured — allowed:** ``` -edit_count=5 (complex) → checks LIBS → growth-log updated today → exit 0 +edit_count=5 (complex) → checks LIBS → growth-log + 2 others updated today (2 stale) → exit 0 ``` **Complex task, no learning — BLOCKED:** ``` edit_count=4 (complex) → checks LIBS → all 5 stale → exit 2 -stderr: "Blocked: complex task completed but no learning captured today." +stderr: "Blocked: complex task but >=3 learning libs stale." ``` **Low disk space — BLOCKED:** ``` disk_free=12GB < 15GB critical → exit 2 -stderr: "Blocked: disk space at 12GB (threshold: 15GB)." +stderr: "Blocked: disk space at 12GB (<15GB). Free space before continuing." ``` ## Limitations -The hook enforces the **habit** of touching learning libraries, not the **quality** of what was recorded. If `output-index.md` is updated but `growth-log` is skipped, the hook passes (1 of 5 libraries touched). This is by design: mechanical gates check machine-verifiable facts. For content quality verification, pair with `self-audit`. +The hook enforces the **habit** of touching learning libraries, not the **quality** of what was recorded. On a complex task it passes only when `growth-log` was updated today and at most 2 libraries are stale; updating `output-index.md` alone still blocks. This is by design: mechanical gates check machine-verifiable facts. For content quality verification, pair with `self-audit`. ## Compatibility diff --git a/skills/delivery-gate/hooks/quality-gate.py b/skills/delivery-gate/hooks/quality-gate.py index 1e78b3d05..90d3b96b6 100644 --- a/skills/delivery-gate/hooks/quality-gate.py +++ b/skills/delivery-gate/hooks/quality-gate.py @@ -55,7 +55,9 @@ def get_project_memory_dir() -> Optional[str]: Returns None if no memory directory exists for this project. Does NOT fall back to other projects (privacy boundary).""" cwd = os.environ.get('CLAUDE_PROJECT_DIR', os.getcwd()) - safe = cwd.replace(':', '-').replace('\\', '-').replace('/', '-') + # Claude Code names project dirs by replacing every non-alphanumeric + # character (including spaces) with '-'. + safe = re.sub(r'[^A-Za-z0-9]', '-', cwd) mem = os.path.expanduser(f'~/.claude/projects/{safe}/memory') log.info('Looking for memory dir: cwd=%s -> %s', cwd, mem) if os.path.isdir(mem): @@ -136,6 +138,9 @@ def main() -> None: transcript = raw try: payload = json.loads(raw) + if isinstance(payload, dict) and payload.get('stop_hook_active') is True: + # This Stop was already blocked once; blocking again would loop forever. + sys.exit(0) if isinstance(payload, dict) and 'transcript_path' in payload: tp = os.path.expanduser(payload['transcript_path']) if os.path.exists(tp): diff --git a/tests/skills/delivery-gate-quality-gate.test.js b/tests/skills/delivery-gate-quality-gate.test.js new file mode 100644 index 000000000..ed55124e6 --- /dev/null +++ b/tests/skills/delivery-gate-quality-gate.test.js @@ -0,0 +1,131 @@ +/** + * Subprocess tests for skills/delivery-gate/hooks/quality-gate.py. + * + * Run with: node tests/skills/delivery-gate-quality-gate.test.js + */ + +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const SCRIPT = path.join(__dirname, '..', '..', 'skills', 'delivery-gate', 'hooks', 'quality-gate.py'); + +function findPython() { + const candidates = [ + { command: process.env.PYTHON, args: [] }, + { command: 'python3', args: [] }, + { command: 'python', args: [] }, + { command: 'py', args: ['-3'] }, + ].filter(candidate => candidate.command); + + for (const candidate of candidates) { + const result = spawnSync(candidate.command, [...candidate.args, '--version'], { + encoding: 'utf8', + timeout: 5000, + }); + if (result.status === 0) { + return candidate; + } + } + return null; +} + +const PYTHON = findPython(); + +// Load the hyphenated hook file as a module and pin the disk check (free GB in +// argv[2]), so the result does not depend on the test machine's free space. +const DRIVER = [ + 'import importlib.util, sys', + 'spec = importlib.util.spec_from_file_location("quality_gate", sys.argv[1])', + 'm = importlib.util.module_from_spec(spec)', + 'spec.loader.exec_module(m)', + 'm.check_disk = lambda: int(sys.argv[2])', + 'm.main()', +].join('\n'); + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (err) { + console.log(` ✗ ${name}`); + console.log(` Error: ${err.message}`); + return false; + } +} + +function setup() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'delivery-gate-')); + const home = path.join(root, 'home'); + const project = path.join(root, 'My Project_v1.2'); + fs.mkdirSync(project, { recursive: true }); + + // Claude Code maps every non-alphanumeric character to '-'. + const encoded = project.replace(/[^A-Za-z0-9]/g, '-'); + const memDir = path.join(home, '.claude', 'projects', encoded, 'memory'); + fs.mkdirSync(memDir, { recursive: true }); // present, but no learning libs + + const transcript = path.join(root, 'transcript.jsonl'); + fs.writeFileSync(transcript, [1, 2, 3] + .map(i => JSON.stringify({ type: 'tool_use', name: 'Edit', input: { file_path: `f${i}.js` } })) + .join('\n'), 'utf8'); + + return { root, home, project, memDir, transcript }; +} + +function runHook(ctx, payload, diskFreeGb = 100) { + return spawnSync(PYTHON.command, [...PYTHON.args, '-c', DRIVER, SCRIPT, String(diskFreeGb)], { + input: JSON.stringify({ transcript_path: ctx.transcript, ...payload }), + encoding: 'utf8', + timeout: 30000, + env: { ...process.env, HOME: ctx.home, USERPROFILE: ctx.home, CLAUDE_PROJECT_DIR: ctx.project }, + }); +} + +function runTests() { + console.log('\n=== Testing delivery-gate quality-gate.py ===\n'); + + if (!PYTHON) { + console.log(' - skipped: python not found'); + console.log('\nPassed: 0'); + console.log('Failed: 0'); + return; + } + + let passed = 0; + let failed = 0; + const ctx = setup(); + + try { + if (test('finds the memory dir of a project path with spaces and punctuation', () => { + const result = runHook(ctx, {}); + assert.strictEqual(result.status, 2, `expected block, got ${result.status}: ${result.stderr}`); + assert.match(result.stderr, /learning libs stale/); + assert.ok(!result.stderr.includes('No project memory directory found'), result.stderr); + })) passed++; else failed++; + + if (test('blocks on critical disk space when stop_hook_active is absent', () => { + const result = runHook(ctx, {}, 1); + assert.strictEqual(result.status, 2, `expected block, got ${result.status}: ${result.stderr}`); + assert.match(result.stderr, /Blocked: disk space/); + })) passed++; else failed++; + + if (test('does not block again when stop_hook_active is true', () => { + const result = runHook(ctx, { stop_hook_active: true }, 1); + assert.strictEqual(result.status, 0, `expected exit 0, got ${result.status}: ${result.stderr}`); + })) passed++; else failed++; + } finally { + fs.rmSync(ctx.root, { recursive: true, force: true }); + } + + console.log(`\nPassed: ${passed}`); + console.log(`Failed: ${failed}`); + process.exitCode = failed > 0 ? 1 : 0; +} + +runTests();