mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-26 18:35:05 +00:00
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:
3 files changed
+152
-12
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
Reference in new issue
Block a user