fix(delivery-gate): honor stop_hook_active and match Claude Code project dir encoding

quality-gate.py never read stop_hook_active, so a condition the agent
cannot fix (disk below 15 GB, an unwritable growth-log) blocked every
Stop and kept the session generating turns. Exit 0 when Claude Code
reports that the Stop hook already forced a continuation.

The memory dir lookup only mapped '/', '\' and ':' to '-'. Claude Code
maps every non-alphanumeric character, so for any project path with a
space the memory dir was never found and enforcement silently became
warn-only.

Align SKILL.md with the code: disk thresholds (remind 50 / warn 30 /
block 15), the disk block applies to every session, the learning-lib
rule and its examples, stderr messages, and the hook timeout, which
Claude Code reads in seconds (5000 meant 83 minutes).

Signed-off-by: Wen <jwcastillo@gmail.com>
(cherry picked from commit a1385c4397)
This commit is contained in:
Wen authored and haelyra committed 2026-10-20 18:01:41 -04:00
1 parent daa22ec2ea
commit 936cf3d040
3 files changed
+152 -12

No files matched your search

+15 -11
View File
@@ -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/<encoded-project-path>/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
+6 -1
View File
@@ -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):
@@ -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();