Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion scripts/hooks/ambient-prompt.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
# relevant skill loading via the ambient-router skill.
# Zero file I/O beyond stdin — static injection only.

set -euo pipefail
set -e

# jq is required to parse hook input JSON — silently no-op if missing
if ! command -v jq &>/dev/null; then exit 0; fi
Expand Down
2 changes: 1 addition & 1 deletion scripts/hooks/background-memory-update.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
# Resumes the parent session headlessly to update .memory/WORKING-MEMORY.md.
# On failure: logs error, does nothing (no fallback).

set -euo pipefail
set -e

CWD="$1"
SESSION_ID="$2"
Expand Down
2 changes: 1 addition & 1 deletion scripts/hooks/pre-compact-memory.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
# has something to inject after compaction.
# PreCompact hooks cannot block compaction — this is informational only.

set -euo pipefail
set -e

# jq is required to parse hook input JSON — silently no-op if missing
if ! command -v jq &>/dev/null; then exit 0; fi
Expand Down
15 changes: 15 additions & 0 deletions scripts/hooks/run-hook
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
: << 'CMDBLOCK'
@echo off
setlocal enabledelayedexpansion
set "SCRIPT_DIR=%~dp0"
set "HOOK_NAME=%~1"
shift
where bash >nul 2>&1 && (
bash "%SCRIPT_DIR%%HOOK_NAME%.sh" %* & exit /b !errorlevel!
)
exit /b 0
CMDBLOCK
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
HOOK_NAME="$1"
shift
exec bash "$SCRIPT_DIR/${HOOK_NAME}.sh" "$@"
2 changes: 1 addition & 1 deletion scripts/hooks/session-start-memory.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
# Also captures fresh git state so Claude knows what's changed since the memory was written.
# Adds staleness warning if memory is >1 hour old.

set -euo pipefail
set -e

# jq is required to parse hook input JSON — silently no-op if missing
if ! command -v jq &>/dev/null; then exit 0; fi
Expand Down
2 changes: 1 addition & 1 deletion scripts/hooks/stop-update-memory.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
# The session ends immediately — no visible edit in the TUI.
# On failure: does nothing (stale memory is better than fake data).

set -euo pipefail
set -e

# Break feedback loop: background updater's headless session triggers stop hook on exit.
# DEVFLOW_BG_UPDATER is set by background-memory-update.sh before invoking claude.
Expand Down
4 changes: 2 additions & 2 deletions src/cli/commands/ambient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ interface Settings {
[key: string]: unknown;
}

const AMBIENT_HOOK_MARKER = 'ambient-prompt.sh';
const AMBIENT_HOOK_MARKER = 'ambient-prompt';

/**
* Add the ambient UserPromptSubmit hook to settings JSON.
Expand All @@ -40,7 +40,7 @@ export function addAmbientHook(settingsJson: string, devflowDir: string): string
settings.hooks = {};
}

const hookCommand = path.join(devflowDir, 'scripts', 'hooks', AMBIENT_HOOK_MARKER);
const hookCommand = path.join(devflowDir, 'scripts', 'hooks', 'run-hook') + ' ambient-prompt';

const newEntry: HookMatcher = {
hooks: [
Expand Down
8 changes: 4 additions & 4 deletions src/cli/commands/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ interface Settings {
* Map of hook event type → filename marker for the 3 memory hooks.
*/
const MEMORY_HOOK_CONFIG: Record<string, string> = {
Stop: 'stop-update-memory.sh',
SessionStart: 'session-start-memory.sh',
PreCompact: 'pre-compact-memory.sh',
Stop: 'stop-update-memory',
SessionStart: 'session-start-memory',
PreCompact: 'pre-compact-memory',
};

/**
Expand All @@ -57,7 +57,7 @@ export function addMemoryHooks(settingsJson: string, devflowDir: string): string
);

if (!alreadyPresent) {
const hookCommand = path.join(devflowDir, 'scripts', 'hooks', marker);
const hookCommand = path.join(devflowDir, 'scripts', 'hooks', 'run-hook') + ` ${marker}`;
const newEntry: HookMatcher = {
hooks: [
{
Expand Down
4 changes: 3 additions & 1 deletion src/cli/utils/installer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,9 @@ export async function installViaFileCopy(options: FileCopyOptions): Promise<void
try {
await fs.mkdir(scriptsTarget, { recursive: true });
await copyDirectory(scriptsSource, scriptsTarget);
await chmodRecursive(scriptsTarget, 0o755);
if (process.platform !== 'win32') {
await chmodRecursive(scriptsTarget, 0o755);
}
} catch { /* scripts may not exist */ }

spinner.stop('Components installed via file copy');
Expand Down
19 changes: 10 additions & 9 deletions tests/ambient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ describe('addAmbientHook', () => {
const settings = JSON.parse(result);

expect(settings.hooks.UserPromptSubmit).toHaveLength(1);
expect(settings.hooks.UserPromptSubmit[0].hooks[0].command).toContain('ambient-prompt.sh');
expect(settings.hooks.UserPromptSubmit[0].hooks[0].command).toContain('ambient-prompt');
expect(settings.hooks.UserPromptSubmit[0].hooks[0].timeout).toBe(5);
});

Expand Down Expand Up @@ -35,7 +35,7 @@ describe('addAmbientHook', () => {

expect(settings.hooks.UserPromptSubmit).toHaveLength(2);
expect(settings.hooks.UserPromptSubmit[0].hooks[0].command).toBe('other-hook.sh');
expect(settings.hooks.UserPromptSubmit[1].hooks[0].command).toContain('ambient-prompt.sh');
expect(settings.hooks.UserPromptSubmit[1].hooks[0].command).toContain('ambient-prompt');
});

it('is idempotent — does not add duplicate hooks', () => {
Expand All @@ -58,12 +58,13 @@ describe('addAmbientHook', () => {
expect(settings.hooks.UserPromptSubmit).toHaveLength(1);
});

it('uses correct devflowDir path in command', () => {
it('uses correct devflowDir path in command via run-hook wrapper', () => {
const result = addAmbientHook('{}', '/custom/path/.devflow');
const settings = JSON.parse(result);
const command = settings.hooks.UserPromptSubmit[0].hooks[0].command;

expect(command).toContain('/custom/path/.devflow/scripts/hooks/ambient-prompt.sh');
expect(command).toContain('/custom/path/.devflow/scripts/hooks/run-hook');
expect(command).toContain('ambient-prompt');
});
});

Expand All @@ -81,7 +82,7 @@ describe('removeAmbientHook', () => {
hooks: {
UserPromptSubmit: [
{ hooks: [{ type: 'command', command: 'other-hook.sh' }] },
{ hooks: [{ type: 'command', command: '/path/to/ambient-prompt.sh' }] },
{ hooks: [{ type: 'command', command: '/path/to/ambient-prompt' }] },
],
},
});
Expand All @@ -96,7 +97,7 @@ describe('removeAmbientHook', () => {
const input = JSON.stringify({
hooks: {
UserPromptSubmit: [
{ hooks: [{ type: 'command', command: '/path/to/ambient-prompt.sh' }] },
{ hooks: [{ type: 'command', command: '/path/to/ambient-prompt' }] },
],
},
});
Expand All @@ -111,7 +112,7 @@ describe('removeAmbientHook', () => {
hooks: {
Stop: [{ hooks: [{ type: 'command', command: 'stop.sh' }] }],
UserPromptSubmit: [
{ hooks: [{ type: 'command', command: '/path/to/ambient-prompt.sh' }] },
{ hooks: [{ type: 'command', command: '/path/to/ambient-prompt' }] },
],
},
});
Expand All @@ -134,7 +135,7 @@ describe('removeAmbientHook', () => {
statusLine: { type: 'command' },
hooks: {
UserPromptSubmit: [
{ hooks: [{ type: 'command', command: '/path/to/ambient-prompt.sh' }] },
{ hooks: [{ type: 'command', command: '/path/to/ambient-prompt' }] },
],
},
});
Expand Down Expand Up @@ -171,7 +172,7 @@ describe('hasAmbientHook', () => {
hooks: {
UserPromptSubmit: [
{ hooks: [{ type: 'command', command: 'other-hook.sh' }] },
{ hooks: [{ type: 'command', command: '/path/to/ambient-prompt.sh' }] },
{ hooks: [{ type: 'command', command: '/path/to/ambient-prompt' }] },
],
},
});
Expand Down
45 changes: 24 additions & 21 deletions tests/memory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ describe('addMemoryHooks', () => {
expect(settings.hooks.Stop).toHaveLength(1);
expect(settings.hooks.SessionStart).toHaveLength(1);
expect(settings.hooks.PreCompact).toHaveLength(1);
expect(settings.hooks.Stop[0].hooks[0].command).toContain('stop-update-memory.sh');
expect(settings.hooks.SessionStart[0].hooks[0].command).toContain('session-start-memory.sh');
expect(settings.hooks.PreCompact[0].hooks[0].command).toContain('pre-compact-memory.sh');
expect(settings.hooks.Stop[0].hooks[0].command).toContain('stop-update-memory');
expect(settings.hooks.SessionStart[0].hooks[0].command).toContain('session-start-memory');
expect(settings.hooks.PreCompact[0].hooks[0].command).toContain('pre-compact-memory');
});

it('preserves existing hooks (UserPromptSubmit/ambient untouched)', () => {
Expand Down Expand Up @@ -44,8 +44,8 @@ describe('addMemoryHooks', () => {
it('adds only missing hooks when partial state (1 hook missing)', () => {
const input = JSON.stringify({
hooks: {
Stop: [{ hooks: [{ type: 'command', command: '/path/stop-update-memory.sh', timeout: 10 }] }],
SessionStart: [{ hooks: [{ type: 'command', command: '/path/session-start-memory.sh', timeout: 10 }] }],
Stop: [{ hooks: [{ type: 'command', command: '/path/stop-update-memory', timeout: 10 }] }],
SessionStart: [{ hooks: [{ type: 'command', command: '/path/session-start-memory', timeout: 10 }] }],
},
});
const result = addMemoryHooks(input, '/home/user/.devflow');
Expand All @@ -56,7 +56,7 @@ describe('addMemoryHooks', () => {
expect(settings.hooks.SessionStart).toHaveLength(1);
// Missing hook added
expect(settings.hooks.PreCompact).toHaveLength(1);
expect(settings.hooks.PreCompact[0].hooks[0].command).toContain('pre-compact-memory.sh');
expect(settings.hooks.PreCompact[0].hooks[0].command).toContain('pre-compact-memory');
});

it('creates hooks object if missing', () => {
Expand All @@ -68,13 +68,16 @@ describe('addMemoryHooks', () => {
expect(settings.hooks.Stop).toHaveLength(1);
});

it('uses correct devflowDir path in command', () => {
it('uses correct devflowDir path in command via run-hook wrapper', () => {
const result = addMemoryHooks('{}', '/custom/path/.devflow');
const settings = JSON.parse(result);

expect(settings.hooks.Stop[0].hooks[0].command).toContain('/custom/path/.devflow/scripts/hooks/stop-update-memory.sh');
expect(settings.hooks.SessionStart[0].hooks[0].command).toContain('/custom/path/.devflow/scripts/hooks/session-start-memory.sh');
expect(settings.hooks.PreCompact[0].hooks[0].command).toContain('/custom/path/.devflow/scripts/hooks/pre-compact-memory.sh');
expect(settings.hooks.Stop[0].hooks[0].command).toContain('/custom/path/.devflow/scripts/hooks/run-hook');
expect(settings.hooks.Stop[0].hooks[0].command).toContain('stop-update-memory');
expect(settings.hooks.SessionStart[0].hooks[0].command).toContain('run-hook');
expect(settings.hooks.SessionStart[0].hooks[0].command).toContain('session-start-memory');
expect(settings.hooks.PreCompact[0].hooks[0].command).toContain('run-hook');
expect(settings.hooks.PreCompact[0].hooks[0].command).toContain('pre-compact-memory');
});

it('preserves other settings (statusLine, env)', () => {
Expand Down Expand Up @@ -113,9 +116,9 @@ describe('removeMemoryHooks', () => {
const input = JSON.stringify({
hooks: {
UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'ambient-prompt.sh' }] }],
Stop: [{ hooks: [{ type: 'command', command: '/path/stop-update-memory.sh' }] }],
SessionStart: [{ hooks: [{ type: 'command', command: '/path/session-start-memory.sh' }] }],
PreCompact: [{ hooks: [{ type: 'command', command: '/path/pre-compact-memory.sh' }] }],
Stop: [{ hooks: [{ type: 'command', command: '/path/stop-update-memory' }] }],
SessionStart: [{ hooks: [{ type: 'command', command: '/path/session-start-memory' }] }],
PreCompact: [{ hooks: [{ type: 'command', command: '/path/pre-compact-memory' }] }],
},
});
const result = removeMemoryHooks(input);
Expand All @@ -139,7 +142,7 @@ describe('removeMemoryHooks', () => {
it('cleans empty hook type arrays', () => {
const input = JSON.stringify({
hooks: {
Stop: [{ hooks: [{ type: 'command', command: '/path/stop-update-memory.sh' }] }],
Stop: [{ hooks: [{ type: 'command', command: '/path/stop-update-memory' }] }],
},
});
const result = removeMemoryHooks(input);
Expand All @@ -159,7 +162,7 @@ describe('removeMemoryHooks', () => {
it('removes only the hooks that exist (partial)', () => {
const input = JSON.stringify({
hooks: {
Stop: [{ hooks: [{ type: 'command', command: '/path/stop-update-memory.sh' }] }],
Stop: [{ hooks: [{ type: 'command', command: '/path/stop-update-memory' }] }],
// SessionStart and PreCompact already missing
},
});
Expand All @@ -173,9 +176,9 @@ describe('removeMemoryHooks', () => {
const input = JSON.stringify({
statusLine: { type: 'command' },
hooks: {
Stop: [{ hooks: [{ type: 'command', command: '/path/stop-update-memory.sh' }] }],
SessionStart: [{ hooks: [{ type: 'command', command: '/path/session-start-memory.sh' }] }],
PreCompact: [{ hooks: [{ type: 'command', command: '/path/pre-compact-memory.sh' }] }],
Stop: [{ hooks: [{ type: 'command', command: '/path/stop-update-memory' }] }],
SessionStart: [{ hooks: [{ type: 'command', command: '/path/session-start-memory' }] }],
PreCompact: [{ hooks: [{ type: 'command', command: '/path/pre-compact-memory' }] }],
},
});
const result = removeMemoryHooks(input);
Expand All @@ -198,7 +201,7 @@ describe('hasMemoryHooks', () => {
it('returns false when partial (1 or 2 of 3)', () => {
const input = JSON.stringify({
hooks: {
Stop: [{ hooks: [{ type: 'command', command: '/path/stop-update-memory.sh' }] }],
Stop: [{ hooks: [{ type: 'command', command: '/path/stop-update-memory' }] }],
},
});
expect(hasMemoryHooks(input)).toBe(false);
Expand Down Expand Up @@ -227,8 +230,8 @@ describe('countMemoryHooks', () => {
it('returns correct partial count', () => {
const input = JSON.stringify({
hooks: {
Stop: [{ hooks: [{ type: 'command', command: '/path/stop-update-memory.sh' }] }],
SessionStart: [{ hooks: [{ type: 'command', command: '/path/session-start-memory.sh' }] }],
Stop: [{ hooks: [{ type: 'command', command: '/path/stop-update-memory' }] }],
SessionStart: [{ hooks: [{ type: 'command', command: '/path/session-start-memory' }] }],
},
});
expect(countMemoryHooks(input)).toBe(2);
Expand Down