mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-03-30 13:43:26 +08:00
feat: deliver v1.8.0 harness reliability and parity updates
This commit is contained in:
12
scripts/hooks/check-hook-enabled.js
Executable file
12
scripts/hooks/check-hook-enabled.js
Executable file
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const { isHookEnabled } = require('../lib/hook-flags');
|
||||
|
||||
const [, , hookId, profilesCsv] = process.argv;
|
||||
if (!hookId) {
|
||||
process.stdout.write('yes');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.stdout.write(isHookEnabled(hookId, { profiles: profilesCsv }) ? 'yes' : 'no');
|
||||
78
scripts/hooks/cost-tracker.js
Executable file
78
scripts/hooks/cost-tracker.js
Executable file
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Cost Tracker Hook
|
||||
*
|
||||
* Appends lightweight session usage metrics to ~/.claude/metrics/costs.jsonl.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const {
|
||||
ensureDir,
|
||||
appendFile,
|
||||
getClaudeDir,
|
||||
} = require('../lib/utils');
|
||||
|
||||
const MAX_STDIN = 1024 * 1024;
|
||||
let raw = '';
|
||||
|
||||
function toNumber(value) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function estimateCost(model, inputTokens, outputTokens) {
|
||||
// Approximate per-1M-token blended rates. Conservative defaults.
|
||||
const table = {
|
||||
'haiku': { in: 0.8, out: 4.0 },
|
||||
'sonnet': { in: 3.0, out: 15.0 },
|
||||
'opus': { in: 15.0, out: 75.0 },
|
||||
};
|
||||
|
||||
const normalized = String(model || '').toLowerCase();
|
||||
let rates = table.sonnet;
|
||||
if (normalized.includes('haiku')) rates = table.haiku;
|
||||
if (normalized.includes('opus')) rates = table.opus;
|
||||
|
||||
const cost = (inputTokens / 1_000_000) * rates.in + (outputTokens / 1_000_000) * rates.out;
|
||||
return Math.round(cost * 1e6) / 1e6;
|
||||
}
|
||||
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => {
|
||||
if (raw.length < MAX_STDIN) {
|
||||
const remaining = MAX_STDIN - raw.length;
|
||||
raw += chunk.substring(0, remaining);
|
||||
}
|
||||
});
|
||||
|
||||
process.stdin.on('end', () => {
|
||||
try {
|
||||
const input = raw.trim() ? JSON.parse(raw) : {};
|
||||
const usage = input.usage || input.token_usage || {};
|
||||
const inputTokens = toNumber(usage.input_tokens || usage.prompt_tokens || 0);
|
||||
const outputTokens = toNumber(usage.output_tokens || usage.completion_tokens || 0);
|
||||
|
||||
const model = String(input.model || input._cursor?.model || process.env.CLAUDE_MODEL || 'unknown');
|
||||
const sessionId = String(process.env.CLAUDE_SESSION_ID || 'default');
|
||||
|
||||
const metricsDir = path.join(getClaudeDir(), 'metrics');
|
||||
ensureDir(metricsDir);
|
||||
|
||||
const row = {
|
||||
timestamp: new Date().toISOString(),
|
||||
session_id: sessionId,
|
||||
model,
|
||||
input_tokens: inputTokens,
|
||||
output_tokens: outputTokens,
|
||||
estimated_cost_usd: estimateCost(model, inputTokens, outputTokens),
|
||||
};
|
||||
|
||||
appendFile(path.join(metricsDir, 'costs.jsonl'), `${JSON.stringify(row)}\n`);
|
||||
} catch {
|
||||
// Keep hook non-blocking.
|
||||
}
|
||||
|
||||
process.stdout.write(raw);
|
||||
});
|
||||
@@ -1,28 +1,63 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Doc file warning hook (PreToolUse - Write)
|
||||
* Warns about non-standard documentation files.
|
||||
* Exit code 0 always (warns only, never blocks).
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
|
||||
const MAX_STDIN = 1024 * 1024;
|
||||
let data = '';
|
||||
process.stdin.on('data', c => (data += c));
|
||||
|
||||
function isAllowedDocPath(filePath) {
|
||||
const normalized = filePath.replace(/\\/g, '/');
|
||||
const basename = path.basename(filePath);
|
||||
|
||||
if (!/\.(md|txt)$/i.test(filePath)) return true;
|
||||
|
||||
if (/^(README|CLAUDE|AGENTS|CONTRIBUTING|CHANGELOG|LICENSE|SKILL|MEMORY|WORKLOG)\.md$/i.test(basename)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (/\.claude\/(commands|plans|projects)\//.test(normalized)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (/(^|\/)(docs|skills|\.history|memory)\//.test(normalized)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (/\.plan\.md$/i.test(basename)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', c => {
|
||||
if (data.length < MAX_STDIN) {
|
||||
const remaining = MAX_STDIN - data.length;
|
||||
data += c.substring(0, remaining);
|
||||
}
|
||||
});
|
||||
|
||||
process.stdin.on('end', () => {
|
||||
try {
|
||||
const input = JSON.parse(data);
|
||||
const filePath = input.tool_input?.file_path || '';
|
||||
const filePath = String(input.tool_input?.file_path || '');
|
||||
|
||||
if (
|
||||
/\.(md|txt)$/.test(filePath) &&
|
||||
!/(README|CLAUDE|AGENTS|CONTRIBUTING|CHANGELOG|LICENSE|SKILL)\.md$/i.test(filePath) &&
|
||||
!/\.claude[/\\]plans[/\\]/.test(filePath) &&
|
||||
!/(^|[/\\])(docs|skills|\.history)[/\\]/.test(filePath)
|
||||
) {
|
||||
if (filePath && !isAllowedDocPath(filePath)) {
|
||||
console.error('[Hook] WARNING: Non-standard documentation file detected');
|
||||
console.error('[Hook] File: ' + filePath);
|
||||
console.error(`[Hook] File: ${filePath}`);
|
||||
console.error('[Hook] Consider consolidating into README.md or docs/ directory');
|
||||
}
|
||||
} catch {
|
||||
/* ignore parse errors */
|
||||
// ignore parse errors
|
||||
}
|
||||
console.log(data);
|
||||
|
||||
process.stdout.write(data);
|
||||
});
|
||||
|
||||
27
scripts/hooks/post-bash-build-complete.js
Executable file
27
scripts/hooks/post-bash-build-complete.js
Executable file
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const MAX_STDIN = 1024 * 1024;
|
||||
let raw = '';
|
||||
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => {
|
||||
if (raw.length < MAX_STDIN) {
|
||||
const remaining = MAX_STDIN - raw.length;
|
||||
raw += chunk.substring(0, remaining);
|
||||
}
|
||||
});
|
||||
|
||||
process.stdin.on('end', () => {
|
||||
try {
|
||||
const input = JSON.parse(raw);
|
||||
const cmd = String(input.tool_input?.command || '');
|
||||
if (/(npm run build|pnpm build|yarn build)/.test(cmd)) {
|
||||
console.error('[Hook] Build completed - async analysis running in background');
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors and pass through
|
||||
}
|
||||
|
||||
process.stdout.write(raw);
|
||||
});
|
||||
36
scripts/hooks/post-bash-pr-created.js
Executable file
36
scripts/hooks/post-bash-pr-created.js
Executable file
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const MAX_STDIN = 1024 * 1024;
|
||||
let raw = '';
|
||||
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => {
|
||||
if (raw.length < MAX_STDIN) {
|
||||
const remaining = MAX_STDIN - raw.length;
|
||||
raw += chunk.substring(0, remaining);
|
||||
}
|
||||
});
|
||||
|
||||
process.stdin.on('end', () => {
|
||||
try {
|
||||
const input = JSON.parse(raw);
|
||||
const cmd = String(input.tool_input?.command || '');
|
||||
|
||||
if (/\bgh\s+pr\s+create\b/.test(cmd)) {
|
||||
const out = String(input.tool_output?.output || '');
|
||||
const match = out.match(/https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+/);
|
||||
if (match) {
|
||||
const prUrl = match[0];
|
||||
const repo = prUrl.replace(/https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/\d+/, '$1');
|
||||
const prNum = prUrl.replace(/.+\/pull\/(\d+)/, '$1');
|
||||
console.error(`[Hook] PR created: ${prUrl}`);
|
||||
console.error(`[Hook] To review: gh pr review ${prNum} --repo ${repo}`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors and pass through
|
||||
}
|
||||
|
||||
process.stdout.write(raw);
|
||||
});
|
||||
73
scripts/hooks/pre-bash-dev-server-block.js
Executable file
73
scripts/hooks/pre-bash-dev-server-block.js
Executable file
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const MAX_STDIN = 1024 * 1024;
|
||||
|
||||
function splitShellSegments(command) {
|
||||
const segments = [];
|
||||
let current = '';
|
||||
let quote = null;
|
||||
|
||||
for (let i = 0; i < command.length; i++) {
|
||||
const ch = command[i];
|
||||
if (quote) {
|
||||
if (ch === quote) quote = null;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '"' || ch === "'") {
|
||||
quote = ch;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
|
||||
const next = command[i + 1] || '';
|
||||
if (ch === ';' || (ch === '&' && next === '&') || (ch === '|' && next === '|') || (ch === '&' && next !== '&')) {
|
||||
if (current.trim()) segments.push(current.trim());
|
||||
current = '';
|
||||
if ((ch === '&' && next === '&') || (ch === '|' && next === '|')) i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
current += ch;
|
||||
}
|
||||
|
||||
if (current.trim()) segments.push(current.trim());
|
||||
return segments;
|
||||
}
|
||||
|
||||
let raw = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => {
|
||||
if (raw.length < MAX_STDIN) {
|
||||
const remaining = MAX_STDIN - raw.length;
|
||||
raw += chunk.substring(0, remaining);
|
||||
}
|
||||
});
|
||||
|
||||
process.stdin.on('end', () => {
|
||||
try {
|
||||
const input = JSON.parse(raw);
|
||||
const cmd = String(input.tool_input?.command || '');
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
const segments = splitShellSegments(cmd);
|
||||
const tmuxLauncher = /^\s*tmux\s+(new|new-session|new-window|split-window)\b/;
|
||||
const devPattern = /\b(npm\s+run\s+dev|pnpm(?:\s+run)?\s+dev|yarn\s+dev|bun\s+run\s+dev)\b/;
|
||||
|
||||
const hasBlockedDev = segments.some(segment => devPattern.test(segment) && !tmuxLauncher.test(segment));
|
||||
|
||||
if (hasBlockedDev) {
|
||||
console.error('[Hook] BLOCKED: Dev server must run in tmux for log access');
|
||||
console.error('[Hook] Use: tmux new-session -d -s dev "npm run dev"');
|
||||
console.error('[Hook] Then: tmux attach -t dev');
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors and pass through
|
||||
}
|
||||
|
||||
process.stdout.write(raw);
|
||||
});
|
||||
28
scripts/hooks/pre-bash-git-push-reminder.js
Executable file
28
scripts/hooks/pre-bash-git-push-reminder.js
Executable file
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const MAX_STDIN = 1024 * 1024;
|
||||
let raw = '';
|
||||
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => {
|
||||
if (raw.length < MAX_STDIN) {
|
||||
const remaining = MAX_STDIN - raw.length;
|
||||
raw += chunk.substring(0, remaining);
|
||||
}
|
||||
});
|
||||
|
||||
process.stdin.on('end', () => {
|
||||
try {
|
||||
const input = JSON.parse(raw);
|
||||
const cmd = String(input.tool_input?.command || '');
|
||||
if (/\bgit\s+push\b/.test(cmd)) {
|
||||
console.error('[Hook] Review changes before push...');
|
||||
console.error('[Hook] Continuing with push (remove this hook to add interactive review)');
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors and pass through
|
||||
}
|
||||
|
||||
process.stdout.write(raw);
|
||||
});
|
||||
33
scripts/hooks/pre-bash-tmux-reminder.js
Executable file
33
scripts/hooks/pre-bash-tmux-reminder.js
Executable file
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const MAX_STDIN = 1024 * 1024;
|
||||
let raw = '';
|
||||
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => {
|
||||
if (raw.length < MAX_STDIN) {
|
||||
const remaining = MAX_STDIN - raw.length;
|
||||
raw += chunk.substring(0, remaining);
|
||||
}
|
||||
});
|
||||
|
||||
process.stdin.on('end', () => {
|
||||
try {
|
||||
const input = JSON.parse(raw);
|
||||
const cmd = String(input.tool_input?.command || '');
|
||||
|
||||
if (
|
||||
process.platform !== 'win32' &&
|
||||
!process.env.TMUX &&
|
||||
/(npm (install|test)|pnpm (install|test)|yarn (install|test)?|bun (install|test)|cargo build|make\b|docker\b|pytest|vitest|playwright)/.test(cmd)
|
||||
) {
|
||||
console.error('[Hook] Consider running in tmux for session persistence');
|
||||
console.error('[Hook] tmux new -s dev | tmux attach -t dev');
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors and pass through
|
||||
}
|
||||
|
||||
process.stdout.write(raw);
|
||||
});
|
||||
@@ -1,61 +1,9 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* PreToolUse Hook: Warn about non-standard documentation files
|
||||
*
|
||||
* Cross-platform (Windows, macOS, Linux)
|
||||
*
|
||||
* Runs before Write tool use. If the file is a .md or .txt file that isn't
|
||||
* a standard documentation file (README, CLAUDE, AGENTS, etc.) or in an
|
||||
* expected directory (docs/, skills/, .claude/plans/), warns the user.
|
||||
*
|
||||
* Exit code 0 — warn only, does not block.
|
||||
* Backward-compatible doc warning hook entrypoint.
|
||||
* Kept for consumers that still reference pre-write-doc-warn.js directly.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
'use strict';
|
||||
|
||||
const MAX_STDIN = 1024 * 1024; // 1MB limit
|
||||
let data = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
|
||||
process.stdin.on('data', chunk => {
|
||||
if (data.length < MAX_STDIN) {
|
||||
const remaining = MAX_STDIN - data.length;
|
||||
data += chunk.length > remaining ? chunk.slice(0, remaining) : chunk;
|
||||
}
|
||||
});
|
||||
|
||||
process.stdin.on('end', () => {
|
||||
try {
|
||||
const input = JSON.parse(data);
|
||||
const filePath = input.tool_input?.file_path || '';
|
||||
|
||||
// Only check .md and .txt files
|
||||
if (!/\.(md|txt)$/.test(filePath)) {
|
||||
process.stdout.write(data);
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow standard documentation files
|
||||
const basename = path.basename(filePath);
|
||||
if (/^(README|CLAUDE|AGENTS|CONTRIBUTING|CHANGELOG|LICENSE|SKILL)\.md$/i.test(basename)) {
|
||||
process.stdout.write(data);
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow files in .claude/plans/, docs/, and skills/ directories
|
||||
const normalized = filePath.replace(/\\/g, '/');
|
||||
if (/\.claude\/plans\//.test(normalized) || /(^|\/)(docs|skills)\//.test(normalized)) {
|
||||
process.stdout.write(data);
|
||||
return;
|
||||
}
|
||||
|
||||
// Warn about non-standard documentation files
|
||||
console.error('[Hook] WARNING: Non-standard documentation file detected');
|
||||
console.error('[Hook] File: ' + filePath);
|
||||
console.error('[Hook] Consider consolidating into README.md or docs/ directory');
|
||||
} catch {
|
||||
// Parse error — pass through
|
||||
}
|
||||
|
||||
process.stdout.write(data);
|
||||
});
|
||||
require('./doc-file-warning.js');
|
||||
|
||||
98
scripts/hooks/quality-gate.js
Executable file
98
scripts/hooks/quality-gate.js
Executable file
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Quality Gate Hook
|
||||
*
|
||||
* Runs lightweight quality checks after file edits.
|
||||
* - Targets one file when file_path is provided
|
||||
* - Falls back to no-op when language/tooling is unavailable
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const MAX_STDIN = 1024 * 1024;
|
||||
let raw = '';
|
||||
|
||||
function run(command, args, cwd = process.cwd()) {
|
||||
return spawnSync(command, args, {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
env: process.env,
|
||||
});
|
||||
}
|
||||
|
||||
function log(msg) {
|
||||
process.stderr.write(`${msg}\n`);
|
||||
}
|
||||
|
||||
function maybeRunQualityGate(filePath) {
|
||||
if (!filePath || !fs.existsSync(filePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const fix = String(process.env.ECC_QUALITY_GATE_FIX || '').toLowerCase() === 'true';
|
||||
const strict = String(process.env.ECC_QUALITY_GATE_STRICT || '').toLowerCase() === 'true';
|
||||
|
||||
if (['.ts', '.tsx', '.js', '.jsx', '.json', '.md'].includes(ext)) {
|
||||
// Prefer biome if present
|
||||
if (fs.existsSync(path.join(process.cwd(), 'biome.json')) || fs.existsSync(path.join(process.cwd(), 'biome.jsonc'))) {
|
||||
const args = ['biome', 'check', filePath];
|
||||
if (fix) args.push('--write');
|
||||
const result = run('npx', args);
|
||||
if (result.status !== 0 && strict) {
|
||||
log(`[QualityGate] Biome check failed for ${filePath}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback to prettier when installed
|
||||
const prettierArgs = ['prettier', '--check', filePath];
|
||||
if (fix) {
|
||||
prettierArgs[1] = '--write';
|
||||
}
|
||||
const prettier = run('npx', prettierArgs);
|
||||
if (prettier.status !== 0 && strict) {
|
||||
log(`[QualityGate] Prettier check failed for ${filePath}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ext === '.go' && fix) {
|
||||
run('gofmt', ['-w', filePath]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ext === '.py') {
|
||||
const args = ['format'];
|
||||
if (!fix) args.push('--check');
|
||||
args.push(filePath);
|
||||
const r = run('ruff', args);
|
||||
if (r.status !== 0 && strict) {
|
||||
log(`[QualityGate] Ruff check failed for ${filePath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => {
|
||||
if (raw.length < MAX_STDIN) {
|
||||
const remaining = MAX_STDIN - raw.length;
|
||||
raw += chunk.substring(0, remaining);
|
||||
}
|
||||
});
|
||||
|
||||
process.stdin.on('end', () => {
|
||||
try {
|
||||
const input = JSON.parse(raw);
|
||||
const filePath = String(input.tool_input?.file_path || '');
|
||||
maybeRunQualityGate(filePath);
|
||||
} catch {
|
||||
// Ignore parse errors.
|
||||
}
|
||||
|
||||
process.stdout.write(raw);
|
||||
});
|
||||
30
scripts/hooks/run-with-flags-shell.sh
Executable file
30
scripts/hooks/run-with-flags-shell.sh
Executable file
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
HOOK_ID="${1:-}"
|
||||
REL_SCRIPT_PATH="${2:-}"
|
||||
PROFILES_CSV="${3:-standard,strict}"
|
||||
|
||||
# Preserve stdin for passthrough or script execution
|
||||
INPUT="$(cat)"
|
||||
|
||||
if [[ -z "$HOOK_ID" || -z "$REL_SCRIPT_PATH" ]]; then
|
||||
printf '%s' "$INPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Ask Node helper if this hook is enabled
|
||||
ENABLED="$(node "${CLAUDE_PLUGIN_ROOT}/scripts/hooks/check-hook-enabled.js" "$HOOK_ID" "$PROFILES_CSV" 2>/dev/null || echo yes)"
|
||||
if [[ "$ENABLED" != "yes" ]]; then
|
||||
printf '%s' "$INPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
SCRIPT_PATH="${CLAUDE_PLUGIN_ROOT}/${REL_SCRIPT_PATH}"
|
||||
if [[ ! -f "$SCRIPT_PATH" ]]; then
|
||||
echo "[Hook] Script not found for ${HOOK_ID}: ${SCRIPT_PATH}" >&2
|
||||
printf '%s' "$INPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
printf '%s' "$INPUT" | "$SCRIPT_PATH"
|
||||
80
scripts/hooks/run-with-flags.js
Executable file
80
scripts/hooks/run-with-flags.js
Executable file
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Executes a hook script only when enabled by ECC hook profile flags.
|
||||
*
|
||||
* Usage:
|
||||
* node run-with-flags.js <hookId> <scriptRelativePath> [profilesCsv]
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
const { isHookEnabled } = require('../lib/hook-flags');
|
||||
|
||||
const MAX_STDIN = 1024 * 1024;
|
||||
|
||||
function readStdinRaw() {
|
||||
return new Promise(resolve => {
|
||||
let raw = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => {
|
||||
if (raw.length < MAX_STDIN) {
|
||||
const remaining = MAX_STDIN - raw.length;
|
||||
raw += chunk.substring(0, remaining);
|
||||
}
|
||||
});
|
||||
process.stdin.on('end', () => resolve(raw));
|
||||
process.stdin.on('error', () => resolve(raw));
|
||||
});
|
||||
}
|
||||
|
||||
function getPluginRoot() {
|
||||
if (process.env.CLAUDE_PLUGIN_ROOT && process.env.CLAUDE_PLUGIN_ROOT.trim()) {
|
||||
return process.env.CLAUDE_PLUGIN_ROOT;
|
||||
}
|
||||
return path.resolve(__dirname, '..', '..');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [, , hookId, relScriptPath, profilesCsv] = process.argv;
|
||||
const raw = await readStdinRaw();
|
||||
|
||||
if (!hookId || !relScriptPath) {
|
||||
process.stdout.write(raw);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!isHookEnabled(hookId, { profiles: profilesCsv })) {
|
||||
process.stdout.write(raw);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const pluginRoot = getPluginRoot();
|
||||
const scriptPath = path.join(pluginRoot, relScriptPath);
|
||||
|
||||
if (!fs.existsSync(scriptPath)) {
|
||||
process.stderr.write(`[Hook] Script not found for ${hookId}: ${scriptPath}\n`);
|
||||
process.stdout.write(raw);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const result = spawnSync('node', [scriptPath], {
|
||||
input: raw,
|
||||
encoding: 'utf8',
|
||||
env: process.env,
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
if (result.stdout) process.stdout.write(result.stdout);
|
||||
if (result.stderr) process.stderr.write(result.stderr);
|
||||
|
||||
const code = Number.isInteger(result.status) ? result.status : 0;
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
process.stderr.write(`[Hook] run-with-flags error: ${err.message}\n`);
|
||||
process.exit(0);
|
||||
});
|
||||
15
scripts/hooks/session-end-marker.js
Executable file
15
scripts/hooks/session-end-marker.js
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const MAX_STDIN = 1024 * 1024;
|
||||
let raw = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => {
|
||||
if (raw.length < MAX_STDIN) {
|
||||
const remaining = MAX_STDIN - raw.length;
|
||||
raw += chunk.substring(0, remaining);
|
||||
}
|
||||
});
|
||||
process.stdin.on('end', () => {
|
||||
process.stdout.write(raw);
|
||||
});
|
||||
@@ -1,12 +1,12 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Stop Hook (Session End) - Persist learnings when session ends
|
||||
* Stop Hook (Session End) - Persist learnings during active sessions
|
||||
*
|
||||
* Cross-platform (Windows, macOS, Linux)
|
||||
*
|
||||
* Runs when Claude session ends. Extracts a meaningful summary from
|
||||
* the session transcript (via stdin JSON transcript_path) and saves it
|
||||
* to a session file for cross-session continuity.
|
||||
* Runs on Stop events (after each response). Extracts a meaningful summary
|
||||
* from the session transcript (via stdin JSON transcript_path) and updates a
|
||||
* session file for cross-session continuity.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
@@ -23,6 +23,9 @@ const {
|
||||
log
|
||||
} = require('../lib/utils');
|
||||
|
||||
const SUMMARY_START_MARKER = '<!-- ECC:SUMMARY:START -->';
|
||||
const SUMMARY_END_MARKER = '<!-- ECC:SUMMARY:END -->';
|
||||
|
||||
/**
|
||||
* Extract a meaningful summary from the session transcript.
|
||||
* Reads the JSONL transcript and pulls out key information:
|
||||
@@ -167,16 +170,28 @@ async function main() {
|
||||
log(`[SessionEnd] Failed to update timestamp in ${sessionFile}`);
|
||||
}
|
||||
|
||||
// If we have a new summary, update the session file content
|
||||
// If we have a new summary, update only the generated summary block.
|
||||
// This keeps repeated Stop invocations idempotent and preserves
|
||||
// user-authored sections in the same session file.
|
||||
if (summary) {
|
||||
const existing = readFile(sessionFile);
|
||||
if (existing) {
|
||||
// Use a flexible regex that matches both "## Session Summary" and "## Current State"
|
||||
// Match to end-of-string to avoid duplicate ### Stats sections
|
||||
const updatedContent = existing.replace(
|
||||
/## (?:Session Summary|Current State)[\s\S]*?$/ ,
|
||||
buildSummarySection(summary).trim() + '\n'
|
||||
);
|
||||
const summaryBlock = buildSummaryBlock(summary);
|
||||
let updatedContent = existing;
|
||||
|
||||
if (existing.includes(SUMMARY_START_MARKER) && existing.includes(SUMMARY_END_MARKER)) {
|
||||
updatedContent = existing.replace(
|
||||
new RegExp(`${escapeRegExp(SUMMARY_START_MARKER)}[\\s\\S]*?${escapeRegExp(SUMMARY_END_MARKER)}`),
|
||||
summaryBlock
|
||||
);
|
||||
} else {
|
||||
// Migration path for files created before summary markers existed.
|
||||
updatedContent = existing.replace(
|
||||
/## (?:Session Summary|Current State)[\s\S]*?$/,
|
||||
`${summaryBlock}\n\n### Notes for Next Session\n-\n\n### Context to Load\n\`\`\`\n[relevant files]\n\`\`\`\n`
|
||||
);
|
||||
}
|
||||
|
||||
writeFile(sessionFile, updatedContent);
|
||||
}
|
||||
}
|
||||
@@ -185,7 +200,7 @@ async function main() {
|
||||
} else {
|
||||
// Create new session file
|
||||
const summarySection = summary
|
||||
? buildSummarySection(summary)
|
||||
? `${buildSummaryBlock(summary)}\n\n### Notes for Next Session\n-\n\n### Context to Load\n\`\`\`\n[relevant files]\n\`\`\``
|
||||
: `## Current State\n\n[Session context goes here]\n\n### Completed\n- [ ]\n\n### In Progress\n- [ ]\n\n### Notes for Next Session\n-\n\n### Context to Load\n\`\`\`\n[relevant files]\n\`\`\``;
|
||||
|
||||
const template = `# Session: ${today}
|
||||
@@ -234,3 +249,10 @@ function buildSummarySection(summary) {
|
||||
return section;
|
||||
}
|
||||
|
||||
function buildSummaryBlock(summary) {
|
||||
return `${SUMMARY_START_MARKER}\n${buildSummarySection(summary).trim()}\n${SUMMARY_END_MARKER}`;
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user