From cbef64c3e7b81e4e8a102035f6c15371a18777b5 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Sun, 1 Mar 2026 19:10:16 +0200 Subject: [PATCH 1/6] feat(memory): structured decisions, progress tracking, pre-compact snapshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ## Progress section (Done/Remaining/Blockers) to working memory template - Require date and status on ## Decisions entries (YYYY-MM-DD [ACTIVE|SUPERSEDED]) - Raise line limit 100 → 120 to accommodate new section - Cap patterns.md at 40 entries with consolidation instruction - Snapshot WORKING-MEMORY.md into pre-compact backup JSON - Inject pre-compact snapshot in SessionStart when fresher than working memory - Backward compatible: old backups without memory_snapshot field gracefully ignored --- CLAUDE.md | 2 +- docs/reference/file-organization.md | 4 ++-- scripts/hooks/background-memory-update.sh | 17 +++++++++---- scripts/hooks/pre-compact-memory.sh | 8 +++++++ scripts/hooks/session-start-memory.sh | 29 +++++++++++++++++++++++ 5 files changed, 52 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0151279..4a32b21 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,7 +30,7 @@ Commands with Teams Variant ship as `{name}.md` (parallel subagents) and `{name} **Build-time asset distribution**: Skills and agents are stored once in `shared/skills/` and `shared/agents/`, then copied to each plugin at build time based on `plugin.json` manifests. This eliminates duplication in git. -**Working Memory**: Three shell-script hooks (`scripts/hooks/`) provide automatic session continuity. Stop hook → spawns a background `claude -p --resume` process that asynchronously updates `.docs/WORKING-MEMORY.md` (throttled: skips if updated <2min ago; concurrent sessions serialize via mkdir-based lock). SessionStart hook → injects previous memory + git state as `additionalContext` on `/clear`, startup, or compact (warns if >1h stale). PreCompact hook → saves git state backup + bootstraps minimal WORKING-MEMORY.md if none exists. Zero-ceremony context preservation. +**Working Memory**: Three shell-script hooks (`scripts/hooks/`) provide automatic session continuity. Stop hook → spawns a background `claude -p --resume` process that asynchronously updates `.docs/WORKING-MEMORY.md` with structured sections (`## Now`, `## Progress`, `## Decisions`, `## Modified Files`, `## Context`, `## Session Log`; throttled: skips if updated <2min ago; concurrent sessions serialize via mkdir-based lock). SessionStart hook → injects previous memory + git state as `additionalContext` on `/clear`, startup, or compact (warns if >1h stale; injects pre-compact memory snapshot when compaction happened mid-session). PreCompact hook → saves git state + WORKING-MEMORY.md snapshot + bootstraps minimal WORKING-MEMORY.md if none exists. Zero-ceremony context preservation. ## Project Structure diff --git a/docs/reference/file-organization.md b/docs/reference/file-organization.md index 1f63f8e..545ab96 100644 --- a/docs/reference/file-organization.md +++ b/docs/reference/file-organization.md @@ -140,8 +140,8 @@ Three hooks in `scripts/hooks/` provide automatic session continuity: | Hook | Event | File | Purpose | |------|-------|------|---------| | `stop-update-memory.sh` | Stop | `.docs/WORKING-MEMORY.md` | Throttled (skips if <2min fresh). Slim instruction after first write. | -| `session-start-memory.sh` | SessionStart | reads WORKING-MEMORY.md | Injects previous memory + git state as `additionalContext`. Warns if >1h stale. | -| `pre-compact-memory.sh` | PreCompact | `.docs/working-memory-backup.json` | Saves git state snapshot. Bootstraps minimal WORKING-MEMORY.md if none exists. | +| `session-start-memory.sh` | SessionStart | reads WORKING-MEMORY.md | Injects previous memory + git state as `additionalContext`. Warns if >1h stale. Injects pre-compact snapshot when compaction occurred mid-session. | +| `pre-compact-memory.sh` | PreCompact | `.docs/working-memory-backup.json` | Saves git state + WORKING-MEMORY.md snapshot. Bootstraps minimal WORKING-MEMORY.md if none exists. | **Flow**: Claude responds → Stop hook checks mtime (skips if <2min fresh) → blocks with instruction → Claude writes WORKING-MEMORY.md silently → `stop_hook_active=true` → allows stop. On `/clear` or new session → SessionStart injects memory as `additionalContext` (system context, not user-visible) with staleness warning if >1h old. diff --git a/scripts/hooks/background-memory-update.sh b/scripts/hooks/background-memory-update.sh index 2fa3951..51004da 100755 --- a/scripts/hooks/background-memory-update.sh +++ b/scripts/hooks/background-memory-update.sh @@ -109,7 +109,7 @@ if [ -f "$PATTERNS_FILE" ]; then EXISTING_PATTERNS=$(cat "$PATTERNS_FILE") PATTERNS_INSTRUCTION=" -Also update $PATTERNS_FILE by APPENDING any new recurring patterns discovered during this session. Do NOT overwrite existing entries — only add new ones. Skip if no new patterns were observed. Format each entry as: - **Pattern name**: Brief description (discovered: YYYY-MM-DD) +Also update $PATTERNS_FILE by APPENDING any new recurring patterns discovered during this session. Do NOT overwrite existing entries — only add new ones. Skip if no new patterns were observed. Format each entry as: - **Pattern name**: Brief description (discovered: YYYY-MM-DD). Keep patterns.md under 40 entries. When approaching the limit, consolidate related patterns into broader entries rather than adding duplicates. Existing patterns: $EXISTING_PATTERNS" @@ -119,7 +119,11 @@ else If recurring patterns were observed during this session (coding conventions, architectural decisions, team preferences, tooling quirks), create $PATTERNS_FILE with entries formatted as: - **Pattern name**: Brief description (discovered: YYYY-MM-DD). Only create this file if genuine patterns were observed — do not fabricate entries." fi -INSTRUCTION="Update the file $MEMORY_FILE with working memory from this session. The file already has content — possibly from a concurrent session that just wrote it moments ago. Merge this session's context with the existing content to produce a single unified working memory snapshot. Both this session and the existing content represent fresh, concurrent work — integrate both fully. Working memory captures what's active now, not a changelog. Deduplicate overlapping information. Keep under 100 lines total. Use the same structure: ## Now, ## Decisions, ## Modified Files, ## Context, ## Session Log.${PATTERNS_INSTRUCTION} +INSTRUCTION="Update the file $MEMORY_FILE with working memory from this session. The file already has content — possibly from a concurrent session that just wrote it moments ago. Merge this session's context with the existing content to produce a single unified working memory snapshot. Both this session and the existing content represent fresh, concurrent work — integrate both fully. Working memory captures what's active now, not a changelog. Deduplicate overlapping information. Keep under 120 lines total. Use the same structure: ## Now, ## Progress, ## Decisions, ## Modified Files, ## Context, ## Session Log. + +## Progress tracks Done (completed items), Remaining (next steps), and Blockers (if any). Keep each sub-list to 1-3 items. This section reflects current work state, not historical logs. + +## Decisions entries must include date and status. Format: - **[Decision]** — [rationale] (YYYY-MM-DD) [ACTIVE|SUPERSEDED]. Mark superseded decisions rather than deleting them.${PATTERNS_INSTRUCTION} Existing content: $EXISTING_MEMORY" @@ -130,7 +134,7 @@ else EXISTING_PATTERNS=$(cat "$PATTERNS_FILE") PATTERNS_INSTRUCTION=" -Also update $PATTERNS_FILE by APPENDING any new recurring patterns discovered during this session. Do NOT overwrite existing entries — only add new ones. Skip if no new patterns were observed. Format each entry as: - **Pattern name**: Brief description (discovered: YYYY-MM-DD) +Also update $PATTERNS_FILE by APPENDING any new recurring patterns discovered during this session. Do NOT overwrite existing entries — only add new ones. Skip if no new patterns were observed. Format each entry as: - **Pattern name**: Brief description (discovered: YYYY-MM-DD). Keep patterns.md under 40 entries. When approaching the limit, consolidate related patterns into broader entries rather than adding duplicates. Existing patterns: $EXISTING_PATTERNS" @@ -140,15 +144,18 @@ $EXISTING_PATTERNS" If recurring patterns were observed during this session (coding conventions, architectural decisions, team preferences, tooling quirks), create $PATTERNS_FILE with entries formatted as: - **Pattern name**: Brief description (discovered: YYYY-MM-DD). Only create this file if genuine patterns were observed — do not fabricate entries." fi - INSTRUCTION="Create the file $MEMORY_FILE with working memory from this session. Keep under 100 lines. Use this structure: + INSTRUCTION="Create the file $MEMORY_FILE with working memory from this session. Keep under 120 lines. Use this structure: # Working Memory ## Now +## Progress + + ## Decisions - + ## Modified Files diff --git a/scripts/hooks/pre-compact-memory.sh b/scripts/hooks/pre-compact-memory.sh index 4bafe4e..eca00b2 100644 --- a/scripts/hooks/pre-compact-memory.sh +++ b/scripts/hooks/pre-compact-memory.sh @@ -39,6 +39,12 @@ if cd "$CWD" 2>/dev/null && git rev-parse --git-dir >/dev/null 2>&1; then GIT_DIFF_STAT=$(git diff --stat HEAD 2>/dev/null || echo "") fi +# Snapshot current WORKING-MEMORY.md (preserves session context through compaction) +MEMORY_SNAPSHOT="" +if [ -f "$CWD/.docs/WORKING-MEMORY.md" ]; then + MEMORY_SNAPSHOT=$(cat "$CWD/.docs/WORKING-MEMORY.md") +fi + # Write backup JSON jq -n \ --arg ts "$TIMESTAMP" \ @@ -46,9 +52,11 @@ jq -n \ --arg status "$GIT_STATUS" \ --arg log "$GIT_LOG" \ --arg diff "$GIT_DIFF_STAT" \ + --arg memory "$MEMORY_SNAPSHOT" \ '{ timestamp: $ts, trigger: "pre-compact", + memory_snapshot: $memory, git: { branch: $branch, status: $status, diff --git a/scripts/hooks/session-start-memory.sh b/scripts/hooks/session-start-memory.sh index e9facec..16c76c5 100644 --- a/scripts/hooks/session-start-memory.sh +++ b/scripts/hooks/session-start-memory.sh @@ -47,6 +47,30 @@ fi NOW=$(date +%s) AGE=$(( NOW - FILE_MTIME )) +# Check for pre-compact memory snapshot (compaction recovery) +BACKUP_FILE="$CWD/.docs/working-memory-backup.json" +COMPACT_NOTE="" +if [ -f "$BACKUP_FILE" ]; then + BACKUP_MEMORY=$(jq -r '.memory_snapshot // ""' "$BACKUP_FILE" 2>/dev/null) + if [ -n "$BACKUP_MEMORY" ]; then + BACKUP_TS=$(jq -r '.timestamp // ""' "$BACKUP_FILE" 2>/dev/null) + BACKUP_EPOCH=0 + if [ -n "$BACKUP_TS" ]; then + BACKUP_EPOCH=$(date -j -f "%Y-%m-%dT%H:%M:%SZ" "$BACKUP_TS" +%s 2>/dev/null \ + || date -d "$BACKUP_TS" +%s 2>/dev/null \ + || echo "0") + fi + if [ "$BACKUP_EPOCH" -gt "$FILE_MTIME" ]; then + COMPACT_NOTE=" +--- PRE-COMPACT SNAPSHOT ($BACKUP_TS) --- +Context was compacted. This snapshot may contain decisions or progress not yet in working memory. + +$BACKUP_MEMORY +" + fi + fi +fi + STALE_WARNING="" if [ "$AGE" -gt 3600 ]; then HOURS=$(( AGE / 3600 )) @@ -93,6 +117,11 @@ Uncommitted changes: ${GIT_STATUS}" fi +if [ -n "$COMPACT_NOTE" ]; then + CONTEXT="${CONTEXT} +${COMPACT_NOTE}" +fi + # Output as additionalContext JSON envelope (Claude sees it as system context, not user-visible) jq -n --arg ctx "$CONTEXT" '{ "hookSpecificOutput": { From df722586e4c15feaa7e421719634d636e6381604 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Sun, 1 Mar 2026 23:27:13 +0200 Subject: [PATCH 2/6] feat(memory): toggleable working memory hooks + .docs/ dependency guarantee Add `devflow memory --enable/--disable/--status` CLI command and `devflow init --memory/--no-memory` flags for user-controlled memory hook management. Memory defaults to enabled (foundational feature). Three hooks managed as a group (Stop, SessionStart, PreCompact) with partial-state detection (e.g., "enabled (2/3 hooks)"). Uninstall removes memory hooks before blanket hook prompt. Fix: ensure .docs/ exists when memory is enabled during init, since all 3 hooks silently exit without it. Previously user-scope installs never got .docs/, making memory hooks no-ops. 25 new tests (22 in memory.test.ts + 3 re-export tests). 162/162 pass. --- CLAUDE.md | 2 +- docs/reference/file-organization.md | 3 +- src/cli/cli.ts | 4 +- src/cli/commands/init.ts | 43 ++++++ src/cli/commands/memory.ts | 227 +++++++++++++++++++++++++++ src/cli/commands/uninstall.ts | 11 ++ tests/init-logic.test.ts | 17 ++ tests/memory.test.ts | 232 ++++++++++++++++++++++++++++ 8 files changed, 536 insertions(+), 3 deletions(-) create mode 100644 src/cli/commands/memory.ts create mode 100644 tests/memory.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 4a32b21..bc6e1d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,7 +30,7 @@ Commands with Teams Variant ship as `{name}.md` (parallel subagents) and `{name} **Build-time asset distribution**: Skills and agents are stored once in `shared/skills/` and `shared/agents/`, then copied to each plugin at build time based on `plugin.json` manifests. This eliminates duplication in git. -**Working Memory**: Three shell-script hooks (`scripts/hooks/`) provide automatic session continuity. Stop hook → spawns a background `claude -p --resume` process that asynchronously updates `.docs/WORKING-MEMORY.md` with structured sections (`## Now`, `## Progress`, `## Decisions`, `## Modified Files`, `## Context`, `## Session Log`; throttled: skips if updated <2min ago; concurrent sessions serialize via mkdir-based lock). SessionStart hook → injects previous memory + git state as `additionalContext` on `/clear`, startup, or compact (warns if >1h stale; injects pre-compact memory snapshot when compaction happened mid-session). PreCompact hook → saves git state + WORKING-MEMORY.md snapshot + bootstraps minimal WORKING-MEMORY.md if none exists. Zero-ceremony context preservation. +**Working Memory**: Three shell-script hooks (`scripts/hooks/`) provide automatic session continuity. Toggleable via `devflow memory --enable/--disable/--status` or `devflow init --memory/--no-memory`. Stop hook → spawns a background `claude -p --resume` process that asynchronously updates `.docs/WORKING-MEMORY.md` with structured sections (`## Now`, `## Progress`, `## Decisions`, `## Modified Files`, `## Context`, `## Session Log`; throttled: skips if updated <2min ago; concurrent sessions serialize via mkdir-based lock). SessionStart hook → injects previous memory + git state as `additionalContext` on `/clear`, startup, or compact (warns if >1h stale; injects pre-compact memory snapshot when compaction happened mid-session). PreCompact hook → saves git state + WORKING-MEMORY.md snapshot + bootstraps minimal WORKING-MEMORY.md if none exists. Zero-ceremony context preservation. ## Project Structure diff --git a/docs/reference/file-organization.md b/docs/reference/file-organization.md index 545ab96..bf5c349 100644 --- a/docs/reference/file-organization.md +++ b/docs/reference/file-organization.md @@ -49,6 +49,7 @@ devflow/ ├── commands/ │ ├── init.ts │ ├── list.ts + │ ├── memory.ts │ └── uninstall.ts └── cli.ts ``` @@ -135,7 +136,7 @@ Included settings: ## Working Memory Hooks -Three hooks in `scripts/hooks/` provide automatic session continuity: +Three hooks in `scripts/hooks/` provide automatic session continuity. Toggleable via `devflow memory --enable/--disable/--status` or `devflow init --memory/--no-memory`: | Hook | Event | File | Purpose | |------|-------|------|---------| diff --git a/src/cli/cli.ts b/src/cli/cli.ts index aa580d5..adbc04e 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -8,6 +8,7 @@ import { initCommand } from './commands/init.js'; import { uninstallCommand } from './commands/uninstall.js'; import { listCommand } from './commands/list.js'; import { ambientCommand } from './commands/ambient.js'; +import { memoryCommand } from './commands/memory.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -24,13 +25,14 @@ program .description('Agentic Development Toolkit for Claude Code\n\nEnhance your AI-assisted development with intelligent commands and workflows.') .version(packageJson.version, '-v, --version', 'Display version number') .helpOption('-h, --help', 'Display help information') - .addHelpText('after', '\nExamples:\n $ devflow init Install all DevFlow plugins\n $ devflow init --plugin=implement Install specific plugin\n $ devflow init --plugin=implement,review Install multiple plugins\n $ devflow list List available plugins\n $ devflow ambient --enable Enable always-on ambient mode\n $ devflow uninstall Remove DevFlow from Claude Code\n $ devflow --version Show version\n $ devflow --help Show help\n\nDocumentation:\n https://github.com/dean0x/devflow#readme'); + .addHelpText('after', '\nExamples:\n $ devflow init Install all DevFlow plugins\n $ devflow init --plugin=implement Install specific plugin\n $ devflow init --plugin=implement,review Install multiple plugins\n $ devflow list List available plugins\n $ devflow ambient --enable Enable always-on ambient mode\n $ devflow memory --status Check working memory state\n $ devflow uninstall Remove DevFlow from Claude Code\n $ devflow --version Show version\n $ devflow --help Show help\n\nDocumentation:\n https://github.com/dean0x/devflow#readme'); // Register commands program.addCommand(initCommand); program.addCommand(uninstallCommand); program.addCommand(listCommand); program.addCommand(ambientCommand); +program.addCommand(memoryCommand); // Handle no command program.action(() => { diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index 4d5d3aa..55ee777 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -21,10 +21,12 @@ import { DEVFLOW_PLUGINS, LEGACY_SKILL_NAMES, LEGACY_COMMAND_NAMES, buildAssetMa import { detectPlatform, detectShell, getProfilePath, getSafeDeleteInfo, hasSafeDelete } from '../utils/safe-delete.js'; import { generateSafeDeleteBlock, isAlreadyInstalled, installToProfile } from '../utils/safe-delete-install.js'; import { addAmbientHook, removeAmbientHook, hasAmbientHook } from './ambient.js'; +import { addMemoryHooks, removeMemoryHooks, hasMemoryHooks } from './memory.js'; // Re-export pure functions for tests (canonical source is post-install.ts) export { substituteSettingsTemplate, computeGitignoreAppend, applyTeamsConfig, stripTeamsConfig, mergeDenyList } from '../utils/post-install.js'; export { addAmbientHook, removeAmbientHook, hasAmbientHook } from './ambient.js'; +export { addMemoryHooks, removeMemoryHooks, hasMemoryHooks } from './memory.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -90,6 +92,7 @@ interface InitOptions { plugin?: string; teams?: boolean; ambient?: boolean; + memory?: boolean; } export const initCommand = new Command('init') @@ -101,6 +104,8 @@ export const initCommand = new Command('init') .option('--no-teams', 'Disable Agent Teams (use parallel subagents instead)') .option('--ambient', 'Enable ambient mode (always-on proportional quality enforcement)') .option('--no-ambient', 'Disable ambient mode') + .option('--memory', 'Enable working memory (session context preservation)') + .option('--no-memory', 'Disable working memory hooks') .action(async (options: InitOptions) => { // Get package version const packageJsonPath = path.resolve(__dirname, '../../package.json'); @@ -222,6 +227,24 @@ export const initCommand = new Command('init') ambientEnabled = ambientChoice; } + // Working memory selection (defaults ON — foundational, unlike ambient's false) + let memoryEnabled: boolean; + if (options.memory !== undefined) { + memoryEnabled = options.memory; + } else if (!process.stdin.isTTY) { + memoryEnabled = true; + } else { + const memoryChoice = await p.confirm({ + message: 'Enable working memory? (automatic session context preservation)', + initialValue: true, + }); + if (p.isCancel(memoryChoice)) { + p.cancel('Installation cancelled.'); + process.exit(0); + } + memoryEnabled = memoryChoice; + } + // Security deny list placement (user scope + TTY only) let securityMode: SecurityMode = 'user'; if (scope === 'user' && process.stdin.isTTY) { @@ -413,6 +436,26 @@ export const initCommand = new Command('init') } } catch { /* settings.json may not exist yet */ } } + + // Manage memory hooks based on user choice + const settingsPath = path.join(claudeDir, 'settings.json'); + try { + const content = await fs.readFile(settingsPath, 'utf-8'); + const updated = memoryEnabled + ? addMemoryHooks(content, devflowDir) + : removeMemoryHooks(content); + if (updated !== content) { + await fs.writeFile(settingsPath, updated, 'utf-8'); + if (verbose) { + p.log.info(`Working memory ${memoryEnabled ? 'enabled' : 'disabled'}`); + } + } + } catch { /* settings.json may not exist yet */ } + + // Ensure .docs/ exists when memory is enabled (hooks are no-ops without it) + if (memoryEnabled && gitRoot) { + await createDocsStructure(verbose); + } } const fileExtras = selectedExtras.filter(e => e !== 'settings' && e !== 'safe-delete'); diff --git a/src/cli/commands/memory.ts b/src/cli/commands/memory.ts new file mode 100644 index 0000000..edf8a68 --- /dev/null +++ b/src/cli/commands/memory.ts @@ -0,0 +1,227 @@ +import { Command } from 'commander'; +import { promises as fs } from 'fs'; +import * as path from 'path'; +import * as p from '@clack/prompts'; +import color from 'picocolors'; +import { getClaudeDirectory, getDevFlowDirectory } from '../utils/paths.js'; + +/** + * The hook entry structure used by Claude Code settings.json. + */ +interface HookEntry { + type: string; + command: string; + timeout?: number; +} + +interface HookMatcher { + hooks: HookEntry[]; +} + +interface Settings { + hooks?: Record; + [key: string]: unknown; +} + +/** + * Map of hook event type → filename marker for the 3 memory hooks. + */ +const MEMORY_HOOK_CONFIG: Record = { + Stop: 'stop-update-memory.sh', + SessionStart: 'session-start-memory.sh', + PreCompact: 'pre-compact-memory.sh', +}; + +/** + * Add all 3 memory hooks (Stop, SessionStart, PreCompact) to settings JSON. + * Idempotent — skips hooks that already exist. Returns unchanged JSON if all 3 present. + */ +export function addMemoryHooks(settingsJson: string, devflowDir: string): string { + const settings: Settings = JSON.parse(settingsJson); + + if (hasMemoryHooks(settingsJson)) { + return settingsJson; + } + + if (!settings.hooks) { + settings.hooks = {}; + } + + let changed = false; + + for (const [hookType, marker] of Object.entries(MEMORY_HOOK_CONFIG)) { + const existing = settings.hooks[hookType] ?? []; + const alreadyPresent = existing.some((matcher) => + matcher.hooks.some((h) => h.command.includes(marker)), + ); + + if (!alreadyPresent) { + const hookCommand = path.join(devflowDir, 'scripts', 'hooks', marker); + const newEntry: HookMatcher = { + hooks: [ + { + type: 'command', + command: hookCommand, + timeout: 10, + }, + ], + }; + + if (!settings.hooks[hookType]) { + settings.hooks[hookType] = []; + } + + settings.hooks[hookType].push(newEntry); + changed = true; + } + } + + if (!changed) { + return settingsJson; + } + + return JSON.stringify(settings, null, 2) + '\n'; +} + +/** + * Remove all memory hooks (Stop, SessionStart, PreCompact) from settings JSON. + * Idempotent — returns unchanged JSON if no memory hooks present. + * Preserves non-memory hooks. Cleans empty arrays/objects. + */ +export function removeMemoryHooks(settingsJson: string): string { + const settings: Settings = JSON.parse(settingsJson); + + if (!settings.hooks) { + return settingsJson; + } + + let changed = false; + + for (const [hookType, marker] of Object.entries(MEMORY_HOOK_CONFIG)) { + if (!settings.hooks[hookType]) { + continue; + } + + const before = settings.hooks[hookType].length; + settings.hooks[hookType] = settings.hooks[hookType].filter( + (matcher) => !matcher.hooks.some((h) => h.command.includes(marker)), + ); + + if (settings.hooks[hookType].length !== before) { + changed = true; + } + + if (settings.hooks[hookType].length === 0) { + delete settings.hooks[hookType]; + } + } + + if (settings.hooks && Object.keys(settings.hooks).length === 0) { + delete settings.hooks; + } + + if (!changed) { + return settingsJson; + } + + return JSON.stringify(settings, null, 2) + '\n'; +} + +/** + * Check if ALL 3 memory hooks are registered in settings JSON. + */ +export function hasMemoryHooks(settingsJson: string): boolean { + return countMemoryHooks(settingsJson) === 3; +} + +/** + * Count how many of the 3 memory hooks are present (0-3). + */ +export function countMemoryHooks(settingsJson: string): number { + const settings: Settings = JSON.parse(settingsJson); + + if (!settings.hooks) { + return 0; + } + + let count = 0; + + for (const [hookType, marker] of Object.entries(MEMORY_HOOK_CONFIG)) { + const matchers = settings.hooks[hookType] ?? []; + if (matchers.some((matcher) => matcher.hooks.some((h) => h.command.includes(marker)))) { + count++; + } + } + + return count; +} + +export const memoryCommand = new Command('memory') + .description('Enable or disable working memory (session context preservation)') + .option('--enable', 'Add Stop/SessionStart/PreCompact hooks') + .option('--disable', 'Remove memory hooks') + .option('--status', 'Show current state') + .action(async (options) => { + const hasFlag = options.enable || options.disable || options.status; + if (!hasFlag) { + p.intro(color.bgCyan(color.white(' Working Memory '))); + p.note( + `${color.cyan('devflow memory --enable')} Add memory hooks\n` + + `${color.cyan('devflow memory --disable')} Remove memory hooks\n` + + `${color.cyan('devflow memory --status')} Check current state`, + 'Usage', + ); + p.outro(color.dim('Memory hooks provide automatic session context preservation')); + return; + } + + const claudeDir = getClaudeDirectory(); + const settingsPath = path.join(claudeDir, 'settings.json'); + + let settingsContent: string; + try { + settingsContent = await fs.readFile(settingsPath, 'utf-8'); + } catch { + if (options.status) { + p.log.info('Working memory: disabled (no settings.json found)'); + return; + } + // Create minimal settings.json + settingsContent = '{}'; + } + + if (options.status) { + const count = countMemoryHooks(settingsContent); + if (count === 3) { + p.log.info(`Working memory: ${color.green('enabled')} (3/3 hooks)`); + } else if (count === 0) { + p.log.info(`Working memory: ${color.dim('disabled')}`); + } else { + p.log.info(`Working memory: ${color.yellow(`partial (${count}/3 hooks)`)} — run --enable to fix`); + } + return; + } + + const devflowDir = getDevFlowDirectory(); + + if (options.enable) { + const updated = addMemoryHooks(settingsContent, devflowDir); + if (updated === settingsContent) { + p.log.info('Working memory already enabled'); + return; + } + await fs.writeFile(settingsPath, updated, 'utf-8'); + p.log.success('Working memory enabled — Stop/SessionStart/PreCompact hooks registered'); + p.log.info(color.dim('Session context will be automatically preserved across conversations')); + } + + if (options.disable) { + const updated = removeMemoryHooks(settingsContent); + if (updated === settingsContent) { + p.log.info('Working memory already disabled'); + return; + } + await fs.writeFile(settingsPath, updated, 'utf-8'); + p.log.success('Working memory disabled — hooks removed'); + } + }); diff --git a/src/cli/commands/uninstall.ts b/src/cli/commands/uninstall.ts index 7960275..6f65546 100644 --- a/src/cli/commands/uninstall.ts +++ b/src/cli/commands/uninstall.ts @@ -10,6 +10,7 @@ import { getGitRoot } from '../utils/git.js'; import { isClaudeCliAvailable } from '../utils/cli.js'; import { DEVFLOW_PLUGINS, getAllSkillNames, LEGACY_SKILL_NAMES, type PluginDefinition } from '../plugins.js'; import { removeAmbientHook } from './ambient.js'; +import { removeMemoryHooks } from './memory.js'; import { detectShell, getProfilePath } from '../utils/safe-delete.js'; import { isAlreadyInstalled, removeFromProfile } from '../utils/safe-delete-install.js'; import { removeManagedSettings } from '../utils/post-install.js'; @@ -308,6 +309,16 @@ export const uninstallCommand = new Command('uninstall') } } + // Always remove memory hooks on full uninstall (idempotent) + const withoutMemory = removeMemoryHooks(settingsContent); + if (withoutMemory !== settingsContent) { + await fs.writeFile(settingsPath, withoutMemory, 'utf-8'); + settingsContent = withoutMemory; + if (verbose) { + p.log.success(`Memory hooks removed from settings.json (${scope})`); + } + } + const settings = JSON.parse(settingsContent); if (settings.hooks) { diff --git a/tests/init-logic.test.ts b/tests/init-logic.test.ts index 500e771..d098e23 100644 --- a/tests/init-logic.test.ts +++ b/tests/init-logic.test.ts @@ -13,6 +13,9 @@ import { addAmbientHook, removeAmbientHook, hasAmbientHook, + addMemoryHooks, + removeMemoryHooks, + hasMemoryHooks, } from '../src/cli/commands/init.js'; import { getManagedSettingsPath } from '../src/cli/utils/paths.js'; import { installViaFileCopy, type Spinner } from '../src/cli/utils/installer.js'; @@ -336,6 +339,20 @@ describe('ambient hook re-exports from init', () => { }); }); +describe('memory hook re-exports from init', () => { + it('re-exports addMemoryHooks from memory.ts', () => { + expect(typeof addMemoryHooks).toBe('function'); + }); + + it('re-exports removeMemoryHooks from memory.ts', () => { + expect(typeof removeMemoryHooks).toBe('function'); + }); + + it('re-exports hasMemoryHooks from memory.ts', () => { + expect(typeof hasMemoryHooks).toBe('function'); + }); +}); + describe('installViaFileCopy cleanup (isPartialInstall)', () => { let tmpDir: string; let claudeDir: string; diff --git a/tests/memory.test.ts b/tests/memory.test.ts new file mode 100644 index 0000000..41d78d1 --- /dev/null +++ b/tests/memory.test.ts @@ -0,0 +1,232 @@ +import { describe, it, expect } from 'vitest'; +import { addMemoryHooks, removeMemoryHooks, hasMemoryHooks, countMemoryHooks } from '../src/cli/commands/memory.js'; + +describe('addMemoryHooks', () => { + it('adds all 3 hook types to empty settings', () => { + const result = addMemoryHooks('{}', '/home/user/.devflow'); + const settings = JSON.parse(result); + + 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'); + }); + + it('preserves existing hooks (UserPromptSubmit/ambient untouched)', () => { + const input = JSON.stringify({ + hooks: { + UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'ambient-prompt.sh' }] }], + }, + }); + const result = addMemoryHooks(input, '/home/user/.devflow'); + const settings = JSON.parse(result); + + expect(settings.hooks.UserPromptSubmit).toHaveLength(1); + expect(settings.hooks.UserPromptSubmit[0].hooks[0].command).toBe('ambient-prompt.sh'); + expect(settings.hooks.Stop).toHaveLength(1); + expect(settings.hooks.SessionStart).toHaveLength(1); + expect(settings.hooks.PreCompact).toHaveLength(1); + }); + + it('is idempotent — calling twice returns identical JSON', () => { + const first = addMemoryHooks('{}', '/home/user/.devflow'); + const second = addMemoryHooks(first, '/home/user/.devflow'); + + expect(second).toBe(first); + }); + + 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 }] }], + }, + }); + const result = addMemoryHooks(input, '/home/user/.devflow'); + const settings = JSON.parse(result); + + // Existing hooks preserved + expect(settings.hooks.Stop).toHaveLength(1); + 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'); + }); + + it('creates hooks object if missing', () => { + const input = JSON.stringify({ statusLine: { type: 'command' } }); + const result = addMemoryHooks(input, '/home/user/.devflow'); + const settings = JSON.parse(result); + + expect(settings.hooks).toBeDefined(); + expect(settings.hooks.Stop).toHaveLength(1); + }); + + it('uses correct devflowDir path in command', () => { + 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'); + }); + + it('preserves other settings (statusLine, env)', () => { + const input = JSON.stringify({ + statusLine: { type: 'command', command: 'statusline.sh' }, + env: { SOME_VAR: '1' }, + }); + const result = addMemoryHooks(input, '/home/user/.devflow'); + const settings = JSON.parse(result); + + expect(settings.statusLine.command).toBe('statusline.sh'); + expect(settings.env.SOME_VAR).toBe('1'); + expect(settings.hooks.Stop).toHaveLength(1); + }); + + it('sets timeout to 10 for all hooks', () => { + const result = addMemoryHooks('{}', '/home/user/.devflow'); + const settings = JSON.parse(result); + + expect(settings.hooks.Stop[0].hooks[0].timeout).toBe(10); + expect(settings.hooks.SessionStart[0].hooks[0].timeout).toBe(10); + expect(settings.hooks.PreCompact[0].hooks[0].timeout).toBe(10); + }); +}); + +describe('removeMemoryHooks', () => { + it('removes all 3 hook types', () => { + const withHooks = addMemoryHooks('{}', '/home/user/.devflow'); + const result = removeMemoryHooks(withHooks); + const settings = JSON.parse(result); + + expect(settings.hooks).toBeUndefined(); + }); + + it('preserves other hooks (UserPromptSubmit)', () => { + 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' }] }], + }, + }); + const result = removeMemoryHooks(input); + const settings = JSON.parse(result); + + expect(settings.hooks.UserPromptSubmit).toHaveLength(1); + expect(settings.hooks.Stop).toBeUndefined(); + expect(settings.hooks.SessionStart).toBeUndefined(); + expect(settings.hooks.PreCompact).toBeUndefined(); + }); + + it('is idempotent — safe to call when not present', () => { + const input = JSON.stringify({ + hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'other.sh' }] }] }, + }); + const result = removeMemoryHooks(input); + + expect(result).toBe(input); + }); + + it('cleans empty hook type arrays', () => { + const input = JSON.stringify({ + hooks: { + Stop: [{ hooks: [{ type: 'command', command: '/path/stop-update-memory.sh' }] }], + }, + }); + const result = removeMemoryHooks(input); + const settings = JSON.parse(result); + + expect(settings.hooks).toBeUndefined(); + }); + + it('cleans empty hooks object when all arrays removed', () => { + const withHooks = addMemoryHooks('{}', '/home/user/.devflow'); + const result = removeMemoryHooks(withHooks); + const settings = JSON.parse(result); + + expect(settings.hooks).toBeUndefined(); + }); + + it('removes only the hooks that exist (partial)', () => { + const input = JSON.stringify({ + hooks: { + Stop: [{ hooks: [{ type: 'command', command: '/path/stop-update-memory.sh' }] }], + // SessionStart and PreCompact already missing + }, + }); + const result = removeMemoryHooks(input); + const settings = JSON.parse(result); + + expect(settings.hooks).toBeUndefined(); + }); + + it('preserves other settings', () => { + 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' }] }], + }, + }); + const result = removeMemoryHooks(input); + const settings = JSON.parse(result); + + expect(settings.statusLine).toEqual({ type: 'command' }); + }); +}); + +describe('hasMemoryHooks', () => { + it('returns true when all 3 present', () => { + const withHooks = addMemoryHooks('{}', '/home/user/.devflow'); + expect(hasMemoryHooks(withHooks)).toBe(true); + }); + + it('returns false when none present', () => { + expect(hasMemoryHooks('{}')).toBe(false); + }); + + 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' }] }], + }, + }); + expect(hasMemoryHooks(input)).toBe(false); + }); + + it('returns false for non-memory hooks only', () => { + const input = JSON.stringify({ + hooks: { + UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'ambient-prompt.sh' }] }], + }, + }); + expect(hasMemoryHooks(input)).toBe(false); + }); +}); + +describe('countMemoryHooks', () => { + it('returns 3 when all present', () => { + const withHooks = addMemoryHooks('{}', '/home/user/.devflow'); + expect(countMemoryHooks(withHooks)).toBe(3); + }); + + it('returns 0 when none present', () => { + expect(countMemoryHooks('{}')).toBe(0); + }); + + 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' }] }], + }, + }); + expect(countMemoryHooks(input)).toBe(2); + }); +}); From 07e68dc25a87cf06e921b9b7ac543ab99b1fcfd5 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Mon, 2 Mar 2026 00:31:12 +0200 Subject: [PATCH 3/6] =?UTF-8?q?docs:=20clarify=20ambient=20mode=20descript?= =?UTF-8?q?ion=20=E2=80=94=20"auto-loads=20relevant=20skills"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace vague "proportional quality enforcement" with concrete description: "auto-loads relevant skills based on each prompt". More intuitive for users encountering the feature for the first time via `devflow list`. Updated across: CLI option/prompt, plugin descriptions, README, CLAUDE.md, marketplace.json, ambient plugin files, skill description, and hook script. --- .claude-plugin/marketplace.json | 2 +- CLAUDE.md | 2 +- README.md | 2 +- plugins/devflow-ambient/.claude-plugin/plugin.json | 2 +- plugins/devflow-ambient/README.md | 2 +- plugins/devflow-ambient/commands/ambient.md | 6 +++--- scripts/hooks/ambient-prompt.sh | 2 +- shared/skills/ambient-router/SKILL.md | 8 ++++---- src/cli/commands/ambient.ts | 2 +- src/cli/commands/init.ts | 4 ++-- src/cli/plugins.ts | 2 +- 11 files changed, 17 insertions(+), 17 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index a7705c5..6724f00 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -59,7 +59,7 @@ { "name": "devflow-ambient", "source": "./plugins/devflow-ambient", - "description": "Ambient mode — proportional quality enforcement without explicit commands", + "description": "Ambient mode — auto-loads relevant skills for every prompt", "version": "1.0.0", "keywords": ["ambient", "routing", "quality", "tdd"] }, diff --git a/CLAUDE.md b/CLAUDE.md index bc6e1d5..437440a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ Plugin marketplace with 9 self-contained plugins, each following the Claude plug | `devflow-resolve` | Review issue resolution | Optional | | `devflow-debug` | Competing hypothesis debugging | Optional | | `devflow-self-review` | Self-review (Simplifier + Scrutinizer) | No | -| `devflow-ambient` | Ambient mode — proportional quality enforcement | No | +| `devflow-ambient` | Ambient mode — auto-loads relevant skills based on each prompt | No | | `devflow-core-skills` | Auto-activating quality enforcement | No | | `devflow-audit-claude` | Audit CLAUDE.md files (optional) | No | diff --git a/README.md b/README.md index 9f8a1cb..537a73b 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Then in Claude Code: | `devflow-resolve` | `/resolve` | Process review issues — fix or defer to tech debt | | `devflow-debug` | `/debug` | Parallel hypothesis debugging | | `devflow-self-review` | `/self-review` | Self-review workflow (Simplifier + Scrutinizer) | -| `devflow-ambient` | `/ambient` | Ambient mode — proportional quality enforcement | +| `devflow-ambient` | `/ambient` | Ambient mode — auto-loads relevant skills based on each prompt | | `devflow-core-skills` | (auto) | Auto-activating quality enforcement skills | ## Command Details diff --git a/plugins/devflow-ambient/.claude-plugin/plugin.json b/plugins/devflow-ambient/.claude-plugin/plugin.json index 9e2c237..dc50e9f 100644 --- a/plugins/devflow-ambient/.claude-plugin/plugin.json +++ b/plugins/devflow-ambient/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "devflow-ambient", - "description": "Ambient mode — proportional quality enforcement without explicit commands", + "description": "Ambient mode — auto-loads relevant skills for every prompt", "version": "1.0.0", "agents": [], "skills": ["ambient-router"] diff --git a/plugins/devflow-ambient/README.md b/plugins/devflow-ambient/README.md index a398ad8..28da21d 100644 --- a/plugins/devflow-ambient/README.md +++ b/plugins/devflow-ambient/README.md @@ -1,6 +1,6 @@ # devflow-ambient -Ambient mode — proportional quality enforcement without explicit command invocation. +Ambient mode — auto-loads relevant skills based on each prompt, no explicit commands needed. ## Command diff --git a/plugins/devflow-ambient/commands/ambient.md b/plugins/devflow-ambient/commands/ambient.md index 8db3d9d..791274f 100644 --- a/plugins/devflow-ambient/commands/ambient.md +++ b/plugins/devflow-ambient/commands/ambient.md @@ -1,10 +1,10 @@ --- -description: Ambient mode — classify intent and apply proportional quality enforcement to any prompt +description: Ambient mode — classify intent and auto-load relevant skills for any prompt --- # Ambient Command -Classify user intent and apply proportional quality enforcement. No agents spawned — enhances the main session only. +Classify user intent and auto-load relevant skills. No agents spawned — enhances the main session only. ## Usage @@ -32,7 +32,7 @@ If no arguments provided, output: ``` ## Ambient Mode -Classify intent and apply proportional quality enforcement. +Classify intent and auto-load relevant skills. Usage: /ambient diff --git a/scripts/hooks/ambient-prompt.sh b/scripts/hooks/ambient-prompt.sh index 9c8889f..69808ae 100755 --- a/scripts/hooks/ambient-prompt.sh +++ b/scripts/hooks/ambient-prompt.sh @@ -2,7 +2,7 @@ # Ambient Mode: UserPromptSubmit Hook # Injects a classification preamble before every user prompt so Claude applies -# proportional quality enforcement via the ambient-router skill. +# relevant skill loading via the ambient-router skill. # Zero file I/O beyond stdin — static injection only. set -euo pipefail diff --git a/shared/skills/ambient-router/SKILL.md b/shared/skills/ambient-router/SKILL.md index c121be5..90c727f 100644 --- a/shared/skills/ambient-router/SKILL.md +++ b/shared/skills/ambient-router/SKILL.md @@ -1,16 +1,16 @@ --- name: ambient-router description: >- - Classify user intent and response depth for ambient mode. Routes to proportional - quality enforcement without explicit command invocation. Used by /ambient command - and always-on UserPromptSubmit hook. + Classify user intent and response depth for ambient mode. Auto-loads relevant + skills without explicit command invocation. Used by /ambient command and + always-on UserPromptSubmit hook. user-invocable: false allowed-tools: Read, Grep, Glob --- # Ambient Router -Classify user intent and select proportional quality enforcement. Zero overhead for simple requests, skill injection for substantive work, workflow nudges for complex tasks. +Classify user intent and auto-load relevant skills. Zero overhead for simple requests, skill injection for substantive work, workflow nudges for complex tasks. ## Iron Law diff --git a/src/cli/commands/ambient.ts b/src/cli/commands/ambient.ts index c68db9d..492ae27 100644 --- a/src/cli/commands/ambient.ts +++ b/src/cli/commands/ambient.ts @@ -167,7 +167,7 @@ export const ambientCommand = new Command('ambient') } await fs.writeFile(settingsPath, updated, 'utf-8'); p.log.success('Ambient mode enabled — UserPromptSubmit hook registered'); - p.log.info(color.dim('Every prompt will now be classified for proportional quality enforcement')); + p.log.info(color.dim('Relevant skills will now auto-load based on each prompt')); } if (options.disable) { diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index 55ee777..5bd5dd3 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -102,7 +102,7 @@ export const initCommand = new Command('init') .option('--plugin ', 'Install specific plugin(s), comma-separated (e.g., implement,code-review)') .option('--teams', 'Enable Agent Teams (peer debate, adversarial review)') .option('--no-teams', 'Disable Agent Teams (use parallel subagents instead)') - .option('--ambient', 'Enable ambient mode (always-on proportional quality enforcement)') + .option('--ambient', 'Enable ambient mode (auto-loads relevant skills for every prompt)') .option('--no-ambient', 'Disable ambient mode') .option('--memory', 'Enable working memory (session context preservation)') .option('--no-memory', 'Disable working memory hooks') @@ -217,7 +217,7 @@ export const initCommand = new Command('init') ambientEnabled = false; } else { const ambientChoice = await p.confirm({ - message: 'Enable ambient mode? (proportional quality enforcement on every prompt)', + message: 'Enable ambient mode? (auto-loads relevant skills based on each prompt)', initialValue: false, }); if (p.isCancel(ambientChoice)) { diff --git a/src/cli/plugins.ts b/src/cli/plugins.ts index 8f4c4b3..dc09850 100644 --- a/src/cli/plugins.ts +++ b/src/cli/plugins.ts @@ -70,7 +70,7 @@ export const DEVFLOW_PLUGINS: PluginDefinition[] = [ }, { name: 'devflow-ambient', - description: 'Ambient mode — proportional quality enforcement', + description: 'Ambient mode — auto-loads relevant skills based on each prompt', commands: ['/ambient'], agents: [], skills: ['ambient-router'], From 8c1b51271413606b36149f3d322bbaa858085d31 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Tue, 3 Mar 2026 16:16:00 +0200 Subject: [PATCH 4/6] =?UTF-8?q?refactor(memory):=20migrate=20.docs/=20?= =?UTF-8?q?=E2=86=92=20.memory/=20+=20tighten=20background=20agent=20permi?= =?UTF-8?q?ssions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move memory files to dedicated .memory/ directory, separating memory lifecycle from documentation artifacts. Replace --dangerously-skip-permissions with --tools "Write" --allowedTools restricted to exactly two files. - Hook scripts: all 4 updated (.docs/ → .memory/, patterns.md → PROJECT-PATTERNS.md) - CLI: add createMemoryDir() + migrateMemoryFiles() with per-project migration - CLI: init/memory --enable create .memory/ and migrate existing files - CLI: uninstall adds .memory/ cleanup (respects --keep-docs) - CLI: .gitignore now includes .memory/ - Tests: 6 new migration tests (168/168 passing) - Docs: CLAUDE.md, README, file-organization, docs-framework updated --- CLAUDE.md | 21 +++-- README.md | 13 +-- docs/reference/file-organization.md | 6 +- scripts/hooks/background-memory-update.sh | 13 +-- scripts/hooks/pre-compact-memory.sh | 10 +-- scripts/hooks/session-start-memory.sh | 10 +-- scripts/hooks/stop-update-memory.sh | 12 +-- shared/skills/docs-framework/SKILL.md | 16 ++-- src/cli/commands/init.ts | 9 +- src/cli/commands/memory.ts | 3 + src/cli/commands/uninstall.ts | 43 ++++++++- src/cli/utils/post-install.ts | 89 ++++++++++++++++++- tests/memory.test.ts | 103 +++++++++++++++++++++- 13 files changed, 297 insertions(+), 51 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 437440a..c443d00 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,7 +30,7 @@ Commands with Teams Variant ship as `{name}.md` (parallel subagents) and `{name} **Build-time asset distribution**: Skills and agents are stored once in `shared/skills/` and `shared/agents/`, then copied to each plugin at build time based on `plugin.json` manifests. This eliminates duplication in git. -**Working Memory**: Three shell-script hooks (`scripts/hooks/`) provide automatic session continuity. Toggleable via `devflow memory --enable/--disable/--status` or `devflow init --memory/--no-memory`. Stop hook → spawns a background `claude -p --resume` process that asynchronously updates `.docs/WORKING-MEMORY.md` with structured sections (`## Now`, `## Progress`, `## Decisions`, `## Modified Files`, `## Context`, `## Session Log`; throttled: skips if updated <2min ago; concurrent sessions serialize via mkdir-based lock). SessionStart hook → injects previous memory + git state as `additionalContext` on `/clear`, startup, or compact (warns if >1h stale; injects pre-compact memory snapshot when compaction happened mid-session). PreCompact hook → saves git state + WORKING-MEMORY.md snapshot + bootstraps minimal WORKING-MEMORY.md if none exists. Zero-ceremony context preservation. +**Working Memory**: Three shell-script hooks (`scripts/hooks/`) provide automatic session continuity. Toggleable via `devflow memory --enable/--disable/--status` or `devflow init --memory/--no-memory`. Stop hook → spawns a background `claude -p --resume` process that asynchronously updates `.memory/WORKING-MEMORY.md` with structured sections (`## Now`, `## Progress`, `## Decisions`, `## Modified Files`, `## Context`, `## Session Log`; throttled: skips if updated <2min ago; concurrent sessions serialize via mkdir-based lock; restricted to Write tool on two specific files via `--tools`/`--allowedTools`). SessionStart hook → injects previous memory + git state as `additionalContext` on `/clear`, startup, or compact (warns if >1h stale; injects pre-compact memory snapshot when compaction happened mid-session). PreCompact hook → saves git state + WORKING-MEMORY.md snapshot + bootstraps minimal WORKING-MEMORY.md if none exists. Zero-ceremony context preservation. ## Project Structure @@ -43,7 +43,9 @@ devflow/ ├── scripts/ # Helper scripts (statusline, docs-helpers) │ └── hooks/ # Working Memory + ambient hooks (stop, session-start, pre-compact, ambient-prompt) ├── src/cli/ # TypeScript CLI (init, list, uninstall, ambient) -└── .claude-plugin/ # Marketplace registry +├── .claude-plugin/ # Marketplace registry +├── .docs/ # Project docs (reviews, design) — per-project +└── .memory/ # Working memory files — per-project ``` **Install paths**: Commands → `~/.claude/commands/devflow/`, Agents → `~/.claude/agents/devflow/`, Skills → `~/.claude/skills/` (flat), Scripts → `~/.devflow/scripts/` @@ -75,14 +77,21 @@ All generated docs live under `.docs/` in the project root: ``` .docs/ ├── reviews/{branch-slug}/ # Review reports per branch -├── design/ # Implementation plans -├── WORKING-MEMORY.md # Auto-maintained by Stop hook (overwritten each response) -└── working-memory-backup.json # Pre-compact git state snapshot +└── design/ # Implementation plans +``` + +Working memory files live in a dedicated `.memory/` directory: + +``` +.memory/ +├── WORKING-MEMORY.md # Auto-maintained by Stop hook (overwritten each session) +├── PROJECT-PATTERNS.md # Accumulated patterns (merged, not overwritten) +└── backup.json # Pre-compact git state snapshot ``` **Naming conventions**: Timestamps as `YYYY-MM-DD_HHMM`, branch slugs replace `/` with `-`, topic slugs are lowercase-dashes. Use `.devflow/scripts/docs-helpers.sh` for consistent naming. -**Persisting agents**: Reviewer → `.docs/reviews/`, Synthesizer → `.docs/reviews/` (review mode), Working Memory → `.docs/WORKING-MEMORY.md` (automatic) +**Persisting agents**: Reviewer → `.docs/reviews/`, Synthesizer → `.docs/reviews/` (review mode), Working Memory → `.memory/WORKING-MEMORY.md` (automatic) ## Agent & Command Roster diff --git a/README.md b/README.md index 537a73b..99725df 100644 --- a/README.md +++ b/README.md @@ -162,22 +162,25 @@ Three shell hooks run behind the scenes: | Hook | When | What | |------|------|------| -| **Stop** | After each response | Updates `.docs/WORKING-MEMORY.md` with current focus, decisions, and progress. Throttled — skips if updated <2 min ago. | +| **Stop** | After each response | Updates `.memory/WORKING-MEMORY.md` with current focus, decisions, and progress. Throttled — skips if updated <2 min ago. | | **SessionStart** | On startup, `/clear`, resume, compaction | Injects previous working memory + fresh git state as system context. Warns if memory is >1h stale. | | **PreCompact** | Before context compaction | Backs up git state to JSON. Bootstraps a minimal working memory from git if none exists yet. | -Working memory is **per-project** — scoped to each repo's `.docs/` directory. Multiple sessions across different repos don't interfere. +Working memory is **per-project** — scoped to each repo's `.memory/` directory. Multiple sessions across different repos don't interfere. ## Documentation Structure -DevFlow creates project documentation in `.docs/`: +DevFlow creates project documentation in `.docs/` and working memory in `.memory/`: ``` .docs/ ├── reviews/{branch}/ # Review reports per branch -├── design/ # Implementation plans +└── design/ # Implementation plans + +.memory/ ├── WORKING-MEMORY.md # Auto-maintained by Stop hook -└── working-memory-backup.json # Pre-compact git state snapshot +├── PROJECT-PATTERNS.md # Accumulated patterns across sessions +└── backup.json # Pre-compact git state snapshot ``` ## Workflow Examples diff --git a/docs/reference/file-organization.md b/docs/reference/file-organization.md index bf5c349..fd85ff2 100644 --- a/docs/reference/file-organization.md +++ b/docs/reference/file-organization.md @@ -140,13 +140,13 @@ Three hooks in `scripts/hooks/` provide automatic session continuity. Toggleable | Hook | Event | File | Purpose | |------|-------|------|---------| -| `stop-update-memory.sh` | Stop | `.docs/WORKING-MEMORY.md` | Throttled (skips if <2min fresh). Slim instruction after first write. | +| `stop-update-memory.sh` | Stop | `.memory/WORKING-MEMORY.md` | Throttled (skips if <2min fresh). Slim instruction after first write. | | `session-start-memory.sh` | SessionStart | reads WORKING-MEMORY.md | Injects previous memory + git state as `additionalContext`. Warns if >1h stale. Injects pre-compact snapshot when compaction occurred mid-session. | -| `pre-compact-memory.sh` | PreCompact | `.docs/working-memory-backup.json` | Saves git state + WORKING-MEMORY.md snapshot. Bootstraps minimal WORKING-MEMORY.md if none exists. | +| `pre-compact-memory.sh` | PreCompact | `.memory/backup.json` | Saves git state + WORKING-MEMORY.md snapshot. Bootstraps minimal WORKING-MEMORY.md if none exists. | **Flow**: Claude responds → Stop hook checks mtime (skips if <2min fresh) → blocks with instruction → Claude writes WORKING-MEMORY.md silently → `stop_hook_active=true` → allows stop. On `/clear` or new session → SessionStart injects memory as `additionalContext` (system context, not user-visible) with staleness warning if >1h old. -All hooks are no-ops in projects without `.docs/` (non-DevFlow projects). +All hooks are no-ops in projects without `.memory/` (non-DevFlow projects or memory not enabled). ## Statusline Script diff --git a/scripts/hooks/background-memory-update.sh b/scripts/hooks/background-memory-update.sh index 51004da..15c9a36 100755 --- a/scripts/hooks/background-memory-update.sh +++ b/scripts/hooks/background-memory-update.sh @@ -2,7 +2,7 @@ # Background Working Memory Updater # Called by stop-update-memory.sh as a detached background process. -# Resumes the parent session headlessly to update .docs/WORKING-MEMORY.md. +# Resumes the parent session headlessly to update .memory/WORKING-MEMORY.md. # On failure: logs error, does nothing (no fallback). set -euo pipefail @@ -12,8 +12,8 @@ SESSION_ID="$2" MEMORY_FILE="$3" CLAUDE_BIN="$4" -LOG_FILE="$CWD/.docs/.working-memory-update.log" -LOCK_DIR="$CWD/.docs/.working-memory.lock" +LOG_FILE="$CWD/.memory/.working-memory-update.log" +LOCK_DIR="$CWD/.memory/.working-memory.lock" # --- Logging --- @@ -103,7 +103,7 @@ fi # Build instruction if [ -n "$EXISTING_MEMORY" ]; then PATTERNS_INSTRUCTION="" -PATTERNS_FILE="$CWD/.docs/patterns.md" +PATTERNS_FILE="$CWD/.memory/PROJECT-PATTERNS.md" EXISTING_PATTERNS="" if [ -f "$PATTERNS_FILE" ]; then EXISTING_PATTERNS=$(cat "$PATTERNS_FILE") @@ -129,7 +129,7 @@ Existing content: $EXISTING_MEMORY" else PATTERNS_INSTRUCTION="" - PATTERNS_FILE="$CWD/.docs/patterns.md" + PATTERNS_FILE="$CWD/.memory/PROJECT-PATTERNS.md" if [ -f "$PATTERNS_FILE" ]; then EXISTING_PATTERNS=$(cat "$PATTERNS_FILE") PATTERNS_INSTRUCTION=" @@ -178,7 +178,8 @@ TIMEOUT=120 # Normal runtime 30-60s; 2x margin DEVFLOW_BG_UPDATER=1 env -u CLAUDECODE "$CLAUDE_BIN" -p \ --resume "$SESSION_ID" \ --model haiku \ - --dangerously-skip-permissions \ + --tools "Write" \ + --allowedTools "Write($CWD/.memory/WORKING-MEMORY.md)" "Write($CWD/.memory/PROJECT-PATTERNS.md)" \ --no-session-persistence \ --output-format text \ "$INSTRUCTION" \ diff --git a/scripts/hooks/pre-compact-memory.sh b/scripts/hooks/pre-compact-memory.sh index eca00b2..e6d285e 100644 --- a/scripts/hooks/pre-compact-memory.sh +++ b/scripts/hooks/pre-compact-memory.sh @@ -19,11 +19,11 @@ if [ -z "$CWD" ]; then fi # Only activate in DevFlow-initialized projects -if [ ! -d "$CWD/.docs" ]; then +if [ ! -d "$CWD/.memory" ]; then exit 0 fi -BACKUP_FILE="$CWD/.docs/working-memory-backup.json" +BACKUP_FILE="$CWD/.memory/backup.json" # Capture git state GIT_BRANCH="" @@ -41,8 +41,8 @@ fi # Snapshot current WORKING-MEMORY.md (preserves session context through compaction) MEMORY_SNAPSHOT="" -if [ -f "$CWD/.docs/WORKING-MEMORY.md" ]; then - MEMORY_SNAPSHOT=$(cat "$CWD/.docs/WORKING-MEMORY.md") +if [ -f "$CWD/.memory/WORKING-MEMORY.md" ]; then + MEMORY_SNAPSHOT=$(cat "$CWD/.memory/WORKING-MEMORY.md") fi # Write backup JSON @@ -67,7 +67,7 @@ jq -n \ # Bootstrap minimal WORKING-MEMORY.md if none exists yet # This ensures SessionStart has context to inject after compaction -MEMORY_FILE="$CWD/.docs/WORKING-MEMORY.md" +MEMORY_FILE="$CWD/.memory/WORKING-MEMORY.md" if [ ! -f "$MEMORY_FILE" ] && [ -n "$GIT_BRANCH" ]; then { echo "# Working Memory" diff --git a/scripts/hooks/session-start-memory.sh b/scripts/hooks/session-start-memory.sh index 16c76c5..7f752c6 100644 --- a/scripts/hooks/session-start-memory.sh +++ b/scripts/hooks/session-start-memory.sh @@ -1,7 +1,7 @@ #!/bin/bash # Working Memory: SessionStart Hook -# Reads .docs/WORKING-MEMORY.md and injects it as additionalContext for the new session. +# Reads .memory/WORKING-MEMORY.md and injects it as additionalContext for the new session. # 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. @@ -18,11 +18,11 @@ if [ -z "$CWD" ]; then fi # Only activate in DevFlow-initialized projects -if [ ! -d "$CWD/.docs" ]; then +if [ ! -d "$CWD/.memory" ]; then exit 0 fi -MEMORY_FILE="$CWD/.docs/WORKING-MEMORY.md" +MEMORY_FILE="$CWD/.memory/WORKING-MEMORY.md" # No memory file = nothing to restore (fresh project or first session) if [ ! -f "$MEMORY_FILE" ]; then @@ -32,7 +32,7 @@ fi MEMORY_CONTENT=$(cat "$MEMORY_FILE") # Read accumulated patterns if they exist -PATTERNS_FILE="$CWD/.docs/patterns.md" +PATTERNS_FILE="$CWD/.memory/PROJECT-PATTERNS.md" PATTERNS_CONTENT="" if [ -f "$PATTERNS_FILE" ]; then PATTERNS_CONTENT=$(cat "$PATTERNS_FILE") @@ -48,7 +48,7 @@ NOW=$(date +%s) AGE=$(( NOW - FILE_MTIME )) # Check for pre-compact memory snapshot (compaction recovery) -BACKUP_FILE="$CWD/.docs/working-memory-backup.json" +BACKUP_FILE="$CWD/.memory/backup.json" COMPACT_NOTE="" if [ -f "$BACKUP_FILE" ]; then BACKUP_MEMORY=$(jq -r '.memory_snapshot // ""' "$BACKUP_FILE" 2>/dev/null) diff --git a/scripts/hooks/stop-update-memory.sh b/scripts/hooks/stop-update-memory.sh index 810f355..4bf7fea 100755 --- a/scripts/hooks/stop-update-memory.sh +++ b/scripts/hooks/stop-update-memory.sh @@ -1,7 +1,7 @@ #!/bin/bash # Working Memory: Stop Hook -# Spawns a background process to update .docs/WORKING-MEMORY.md asynchronously. +# Spawns a background process to update .memory/WORKING-MEMORY.md asynchronously. # The session ends immediately — no visible edit in the TUI. # On failure: does nothing (stale memory is better than fake data). @@ -16,21 +16,21 @@ if ! command -v jq &>/dev/null; then exit 0; fi INPUT=$(cat) -# Only activate in projects with .docs/ directory (DevFlow-initialized projects) +# Only activate in projects with .memory/ directory (DevFlow-initialized projects) CWD=$(echo "$INPUT" | jq -r '.cwd // ""' 2>/dev/null) -if [ -z "$CWD" ] || [ ! -d "$CWD/.docs" ]; then +if [ -z "$CWD" ] || [ ! -d "$CWD/.memory" ]; then exit 0 fi # Logging (shared log file with background updater; [stop-hook] prefix distinguishes) -MEMORY_FILE="$CWD/.docs/WORKING-MEMORY.md" -LOG_FILE="$CWD/.docs/.working-memory-update.log" +MEMORY_FILE="$CWD/.memory/WORKING-MEMORY.md" +LOG_FILE="$CWD/.memory/.working-memory-update.log" log() { echo "[$(date -u '+%Y-%m-%dT%H:%M:%SZ')] [stop-hook] $1" >> "$LOG_FILE"; } # Throttle: skip if stop hook was triggered within the last 2 minutes # Uses a marker file touched BEFORE spawning the updater — prevents race condition # where multiple hooks see stale WORKING-MEMORY.md mtime and all bypass throttle. -TRIGGER_MARKER="$CWD/.docs/.working-memory-last-trigger" +TRIGGER_MARKER="$CWD/.memory/.working-memory-last-trigger" if [ -f "$TRIGGER_MARKER" ]; then if stat --version &>/dev/null 2>&1; then MARKER_MTIME=$(stat -c %Y "$TRIGGER_MARKER") diff --git a/shared/skills/docs-framework/SKILL.md b/shared/skills/docs-framework/SKILL.md index 592fe9a..4dbba0a 100644 --- a/shared/skills/docs-framework/SKILL.md +++ b/shared/skills/docs-framework/SKILL.md @@ -32,10 +32,14 @@ All generated documentation lives under `.docs/` in the project root: │ ├── {timestamp}.md │ ├── compact/{timestamp}.md │ └── INDEX.md -├── swarm/ # Swarm operation state -│ ├── state.json -│ └── plans/ -└── WORKING-MEMORY.md # Auto-maintained by Stop hook (overwritten) +└── swarm/ # Swarm operation state + ├── state.json + └── plans/ + +.memory/ +├── WORKING-MEMORY.md # Auto-maintained by Stop hook (overwritten) +├── PROJECT-PATTERNS.md # Accumulated patterns (merged across sessions) +└── backup.json # Pre-compact git state snapshot ``` --- @@ -92,7 +96,7 @@ source .devflow/scripts/docs-helpers.sh 2>/dev/null || { | Agent | Output Location | Behavior | |-------|-----------------|----------| | Reviewer | `.docs/reviews/{branch-slug}/{type}-report.{timestamp}.md` | Creates new | -| Working Memory | `.docs/WORKING-MEMORY.md` | Overwrites (auto-maintained by Stop hook) | +| Working Memory | `.memory/WORKING-MEMORY.md` | Overwrites (auto-maintained by Stop hook) | ### Agents That Don't Persist @@ -120,7 +124,7 @@ When creating or modifying persisting agents: This framework is used by: - **Review agents**: Creates review reports -- **Working Memory hooks**: Auto-maintains `.docs/WORKING-MEMORY.md` +- **Working Memory hooks**: Auto-maintains `.memory/WORKING-MEMORY.md` All persisting agents should load this skill to ensure consistent documentation. diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index 5bd5dd3..ff74e07 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -15,6 +15,8 @@ import { installClaudeignore, updateGitignore, createDocsStructure, + createMemoryDir, + migrateMemoryFiles, type SecurityMode, } from '../utils/post-install.js'; import { DEVFLOW_PLUGINS, LEGACY_SKILL_NAMES, LEGACY_COMMAND_NAMES, buildAssetMaps, type PluginDefinition } from '../plugins.js'; @@ -452,9 +454,10 @@ export const initCommand = new Command('init') } } catch { /* settings.json may not exist yet */ } - // Ensure .docs/ exists when memory is enabled (hooks are no-ops without it) - if (memoryEnabled && gitRoot) { - await createDocsStructure(verbose); + // Ensure .memory/ exists when memory is enabled (hooks are no-ops without it) + if (memoryEnabled) { + await createMemoryDir(verbose); + await migrateMemoryFiles(verbose); } } diff --git a/src/cli/commands/memory.ts b/src/cli/commands/memory.ts index edf8a68..b4c186d 100644 --- a/src/cli/commands/memory.ts +++ b/src/cli/commands/memory.ts @@ -4,6 +4,7 @@ import * as path from 'path'; import * as p from '@clack/prompts'; import color from 'picocolors'; import { getClaudeDirectory, getDevFlowDirectory } from '../utils/paths.js'; +import { createMemoryDir, migrateMemoryFiles } from '../utils/post-install.js'; /** * The hook entry structure used by Claude Code settings.json. @@ -211,6 +212,8 @@ export const memoryCommand = new Command('memory') return; } await fs.writeFile(settingsPath, updated, 'utf-8'); + await createMemoryDir(false); + await migrateMemoryFiles(true); p.log.success('Working memory enabled — Stop/SessionStart/PreCompact hooks registered'); p.log.info(color.dim('Session context will be automatically preserved across conversations')); } diff --git a/src/cli/commands/uninstall.ts b/src/cli/commands/uninstall.ts index 6f65546..7d83bc5 100644 --- a/src/cli/commands/uninstall.ts +++ b/src/cli/commands/uninstall.ts @@ -263,7 +263,42 @@ export const uninstallCommand = new Command('uninstall') } } - // 2. .claudeignore + // 2. .memory/ directory + const memoryDir = path.join(process.cwd(), '.memory'); + let memoryExist = false; + try { + await fs.access(memoryDir); + memoryExist = true; + } catch { /* .memory doesn't exist */ } + + if (memoryExist) { + let shouldRemoveMemory = false; + + if (options.keepDocs) { + shouldRemoveMemory = false; + } else if (process.stdin.isTTY) { + const removeMemory = await p.confirm({ + message: '.memory/ directory found. Remove working memory files?', + initialValue: false, + }); + + if (p.isCancel(removeMemory)) { + p.cancel('Uninstall cancelled.'); + process.exit(0); + } + + shouldRemoveMemory = removeMemory; + } + + if (shouldRemoveMemory) { + await fs.rm(memoryDir, { recursive: true, force: true }); + p.log.success('.memory/ removed'); + } else { + p.log.info('.memory/ preserved'); + } + } + + // 4. .claudeignore const claudeignorePath = gitRoot ? path.join(gitRoot, '.claudeignore') : path.join(process.cwd(), '.claudeignore'); @@ -292,7 +327,7 @@ export const uninstallCommand = new Command('uninstall') } } - // 3. settings.json (DevFlow hooks) + // 5. settings.json (DevFlow hooks) for (const scope of scopesToUninstall) { try { const paths = await getInstallationPaths(scope); @@ -344,7 +379,7 @@ export const uninstallCommand = new Command('uninstall') } } - // 4. Managed settings (security deny list) + // 6. Managed settings (security deny list) let managedSettingsExist = false; try { const managedPath = getManagedSettingsPath(); @@ -373,7 +408,7 @@ export const uninstallCommand = new Command('uninstall') } } - // 5. Safe-delete shell function + // 7. Safe-delete shell function const shell = detectShell(); const profilePath = getProfilePath(shell); if (profilePath && await isAlreadyInstalled(profilePath)) { diff --git a/src/cli/utils/post-install.ts b/src/cli/utils/post-install.ts index 0efdea7..71fa5c4 100644 --- a/src/cli/utils/post-install.ts +++ b/src/cli/utils/post-install.ts @@ -436,7 +436,7 @@ export async function updateGitignore( ): Promise { try { const gitignorePath = path.join(gitRoot, '.gitignore'); - const entriesToAdd = ['.claude/', '.devflow/']; + const entriesToAdd = ['.claude/', '.devflow/', '.memory/']; let gitignoreContent = ''; try { @@ -477,3 +477,90 @@ export async function createDocsStructure(verbose: boolean): Promise { } } catch { /* may already exist */ } } + +/** + * Create .memory/ directory for working memory files. + * Separate from .docs/ which is for reviews/releases. + */ +export async function createMemoryDir(verbose: boolean, cwd?: string): Promise { + const memoryDir = path.join(cwd ?? process.cwd(), '.memory'); + + try { + await fs.mkdir(memoryDir, { recursive: true }); + if (verbose) { + p.log.success('.memory/ directory ready'); + } + } catch { /* may already exist */ } +} + +/** + * Migrate memory files from .docs/ to .memory/. + * One-time migration for existing users. Skips if destination exists (no clobber). + * Also cleans up ephemeral files from .docs/. + * Returns count of migrated files. + */ +export async function migrateMemoryFiles(verbose: boolean, cwd?: string): Promise { + const root = cwd ?? process.cwd(); + const docsDir = path.join(root, '.docs'); + const memoryDir = path.join(root, '.memory'); + + const migrations: Array<{ src: string; dest: string }> = [ + { src: path.join(docsDir, 'WORKING-MEMORY.md'), dest: path.join(memoryDir, 'WORKING-MEMORY.md') }, + { src: path.join(docsDir, 'patterns.md'), dest: path.join(memoryDir, 'PROJECT-PATTERNS.md') }, + { src: path.join(docsDir, 'working-memory-backup.json'), dest: path.join(memoryDir, 'backup.json') }, + ]; + + let migrated = 0; + + for (const { src, dest } of migrations) { + try { + await fs.access(src); + } catch { + continue; // Source doesn't exist + } + + try { + await fs.access(dest); + continue; // Destination already exists — no clobber + } catch { + // Destination doesn't exist — proceed with migration + } + + try { + await fs.rename(src, dest); + migrated++; + } catch { + // Cross-device or permission error — try copy+delete + try { + await fs.copyFile(src, dest); + await fs.rm(src, { force: true }); + migrated++; + } catch { + // Migration failed for this file — skip silently + } + } + } + + // Clean up ephemeral files from .docs/ + const ephemeralFiles = [ + path.join(docsDir, '.working-memory-update.log'), + path.join(docsDir, '.working-memory-last-trigger'), + ]; + + for (const file of ephemeralFiles) { + try { + await fs.rm(file, { force: true }); + } catch { /* doesn't exist or can't remove */ } + } + + // Clean up lock directory + try { + await fs.rmdir(path.join(docsDir, '.working-memory.lock')); + } catch { /* doesn't exist or not empty */ } + + if (migrated > 0 && verbose) { + p.log.success(`Migrated ${migrated} memory file(s) from .docs/ to .memory/`); + } + + return migrated; +} diff --git a/tests/memory.test.ts b/tests/memory.test.ts index 41d78d1..d537eaa 100644 --- a/tests/memory.test.ts +++ b/tests/memory.test.ts @@ -1,5 +1,9 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'fs'; +import * as path from 'path'; +import * as os from 'os'; import { addMemoryHooks, removeMemoryHooks, hasMemoryHooks, countMemoryHooks } from '../src/cli/commands/memory.js'; +import { createMemoryDir, migrateMemoryFiles } from '../src/cli/utils/post-install.js'; describe('addMemoryHooks', () => { it('adds all 3 hook types to empty settings', () => { @@ -230,3 +234,100 @@ describe('countMemoryHooks', () => { expect(countMemoryHooks(input)).toBe(2); }); }); + +describe('createMemoryDir', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'devflow-test-')); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it('creates .memory/ directory', async () => { + await createMemoryDir(false, tmpDir); + const stat = await fs.stat(path.join(tmpDir, '.memory')); + expect(stat.isDirectory()).toBe(true); + }); + + it('is idempotent — calling twice succeeds without error', async () => { + await createMemoryDir(false, tmpDir); + await createMemoryDir(false, tmpDir); + const stat = await fs.stat(path.join(tmpDir, '.memory')); + expect(stat.isDirectory()).toBe(true); + }); +}); + +describe('migrateMemoryFiles', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'devflow-test-')); + await fs.mkdir(path.join(tmpDir, '.memory'), { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it('returns 0 on fresh install (no .docs/ files)', async () => { + const count = await migrateMemoryFiles(false, tmpDir); + expect(count).toBe(0); + }); + + it('migrates all 3 files from .docs/ to .memory/', async () => { + const docsDir = path.join(tmpDir, '.docs'); + await fs.mkdir(docsDir, { recursive: true }); + await fs.writeFile(path.join(docsDir, 'WORKING-MEMORY.md'), '# Working Memory'); + await fs.writeFile(path.join(docsDir, 'patterns.md'), '# Patterns'); + await fs.writeFile(path.join(docsDir, 'working-memory-backup.json'), '{}'); + + const count = await migrateMemoryFiles(false, tmpDir); + expect(count).toBe(3); + + // Verify destinations exist + const wm = await fs.readFile(path.join(tmpDir, '.memory', 'WORKING-MEMORY.md'), 'utf-8'); + expect(wm).toBe('# Working Memory'); + + const patterns = await fs.readFile(path.join(tmpDir, '.memory', 'PROJECT-PATTERNS.md'), 'utf-8'); + expect(patterns).toBe('# Patterns'); + + const backup = await fs.readFile(path.join(tmpDir, '.memory', 'backup.json'), 'utf-8'); + expect(backup).toBe('{}'); + + // Verify sources removed + await expect(fs.access(path.join(docsDir, 'WORKING-MEMORY.md'))).rejects.toThrow(); + await expect(fs.access(path.join(docsDir, 'patterns.md'))).rejects.toThrow(); + await expect(fs.access(path.join(docsDir, 'working-memory-backup.json'))).rejects.toThrow(); + }); + + it('skips migration when destination already exists (no clobber)', async () => { + const docsDir = path.join(tmpDir, '.docs'); + await fs.mkdir(docsDir, { recursive: true }); + await fs.writeFile(path.join(docsDir, 'WORKING-MEMORY.md'), 'old content'); + await fs.writeFile(path.join(tmpDir, '.memory', 'WORKING-MEMORY.md'), 'existing content'); + + const count = await migrateMemoryFiles(false, tmpDir); + expect(count).toBe(0); + + // Existing content preserved + const content = await fs.readFile(path.join(tmpDir, '.memory', 'WORKING-MEMORY.md'), 'utf-8'); + expect(content).toBe('existing content'); + }); + + it('cleans up ephemeral files from .docs/', async () => { + const docsDir = path.join(tmpDir, '.docs'); + await fs.mkdir(docsDir, { recursive: true }); + await fs.writeFile(path.join(docsDir, '.working-memory-update.log'), 'log content'); + await fs.writeFile(path.join(docsDir, '.working-memory-last-trigger'), ''); + await fs.mkdir(path.join(docsDir, '.working-memory.lock'), { recursive: true }); + + await migrateMemoryFiles(false, tmpDir); + + await expect(fs.access(path.join(docsDir, '.working-memory-update.log'))).rejects.toThrow(); + await expect(fs.access(path.join(docsDir, '.working-memory-last-trigger'))).rejects.toThrow(); + await expect(fs.access(path.join(docsDir, '.working-memory.lock'))).rejects.toThrow(); + }); +}); From 1a7e6365d3c397942710a284911b5f32b3726695 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Tue, 3 Mar 2026 21:47:53 +0200 Subject: [PATCH 5/6] fix(memory): auto-create .memory/ on first hook run Stop and PreCompact hooks now mkdir -p .memory/ instead of bailing when the directory doesn't exist. SessionStart already guards on the memory file itself. Removes the need to run devflow init per-project. --- docs/reference/file-organization.md | 2 +- scripts/hooks/pre-compact-memory.sh | 6 ++---- scripts/hooks/session-start-memory.sh | 5 ----- scripts/hooks/stop-update-memory.sh | 7 +++++-- 4 files changed, 8 insertions(+), 12 deletions(-) diff --git a/docs/reference/file-organization.md b/docs/reference/file-organization.md index fd85ff2..a91a46f 100644 --- a/docs/reference/file-organization.md +++ b/docs/reference/file-organization.md @@ -146,7 +146,7 @@ Three hooks in `scripts/hooks/` provide automatic session continuity. Toggleable **Flow**: Claude responds → Stop hook checks mtime (skips if <2min fresh) → blocks with instruction → Claude writes WORKING-MEMORY.md silently → `stop_hook_active=true` → allows stop. On `/clear` or new session → SessionStart injects memory as `additionalContext` (system context, not user-visible) with staleness warning if >1h old. -All hooks are no-ops in projects without `.memory/` (non-DevFlow projects or memory not enabled). +Hooks auto-create `.memory/` on first run — no manual setup needed per project. ## Statusline Script diff --git a/scripts/hooks/pre-compact-memory.sh b/scripts/hooks/pre-compact-memory.sh index e6d285e..ea3e03a 100644 --- a/scripts/hooks/pre-compact-memory.sh +++ b/scripts/hooks/pre-compact-memory.sh @@ -18,10 +18,8 @@ if [ -z "$CWD" ]; then exit 0 fi -# Only activate in DevFlow-initialized projects -if [ ! -d "$CWD/.memory" ]; then - exit 0 -fi +# Auto-create .memory/ if it doesn't exist +mkdir -p "$CWD/.memory" 2>/dev/null || exit 0 BACKUP_FILE="$CWD/.memory/backup.json" diff --git a/scripts/hooks/session-start-memory.sh b/scripts/hooks/session-start-memory.sh index 7f752c6..50529ad 100644 --- a/scripts/hooks/session-start-memory.sh +++ b/scripts/hooks/session-start-memory.sh @@ -17,11 +17,6 @@ if [ -z "$CWD" ]; then exit 0 fi -# Only activate in DevFlow-initialized projects -if [ ! -d "$CWD/.memory" ]; then - exit 0 -fi - MEMORY_FILE="$CWD/.memory/WORKING-MEMORY.md" # No memory file = nothing to restore (fresh project or first session) diff --git a/scripts/hooks/stop-update-memory.sh b/scripts/hooks/stop-update-memory.sh index 4bf7fea..e30ef53 100755 --- a/scripts/hooks/stop-update-memory.sh +++ b/scripts/hooks/stop-update-memory.sh @@ -16,12 +16,15 @@ if ! command -v jq &>/dev/null; then exit 0; fi INPUT=$(cat) -# Only activate in projects with .memory/ directory (DevFlow-initialized projects) +# Resolve project directory — bail if missing CWD=$(echo "$INPUT" | jq -r '.cwd // ""' 2>/dev/null) -if [ -z "$CWD" ] || [ ! -d "$CWD/.memory" ]; then +if [ -z "$CWD" ]; then exit 0 fi +# Auto-create .memory/ if it doesn't exist (bootstraps on first session) +mkdir -p "$CWD/.memory" 2>/dev/null || exit 0 + # Logging (shared log file with background updater; [stop-hook] prefix distinguishes) MEMORY_FILE="$CWD/.memory/WORKING-MEMORY.md" LOG_FILE="$CWD/.memory/.working-memory-update.log" From 56902c13bd14f532ce2d04e3d1b3a6e46d83daa8 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Wed, 4 Mar 2026 00:20:29 +0200 Subject: [PATCH 6/6] feat(memory): auto-add .memory/ and .docs/ to .gitignore on first hook run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract shared ensure-memory-gitignore.sh helper sourced by stop and pre-compact hooks. Uses a marker file (.memory/.gitignore-configured) to gate a single stat check per session — skips entirely after first run. Also adds .docs/ to the CLI's updateGitignore() entries. --- scripts/hooks/ensure-memory-gitignore.sh | 17 +++++++++++++++++ scripts/hooks/pre-compact-memory.sh | 4 ++-- scripts/hooks/stop-update-memory.sh | 5 +++-- src/cli/utils/post-install.ts | 2 +- 4 files changed, 23 insertions(+), 5 deletions(-) create mode 100755 scripts/hooks/ensure-memory-gitignore.sh diff --git a/scripts/hooks/ensure-memory-gitignore.sh b/scripts/hooks/ensure-memory-gitignore.sh new file mode 100755 index 0000000..a7c9077 --- /dev/null +++ b/scripts/hooks/ensure-memory-gitignore.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# Ensures .memory/ exists and .gitignore entries are configured. +# Called from stop and pre-compact hooks. Idempotent, ~1μs after first run. +# Usage: source ensure-memory-gitignore.sh "$CWD" + +_MEMORY_DIR="$1/.memory" + +# Create .memory/ if needed +mkdir -p "$_MEMORY_DIR" 2>/dev/null || return 1 + +# One-time .gitignore setup (marker prevents repeated checks) +if [ ! -f "$_MEMORY_DIR/.gitignore-configured" ] && [ -e "$1/.git" ]; then + for _entry in ".memory/" ".docs/"; do + grep -qxF "$_entry" "$1/.gitignore" 2>/dev/null || echo "$_entry" >> "$1/.gitignore" + done + touch "$_MEMORY_DIR/.gitignore-configured" +fi diff --git a/scripts/hooks/pre-compact-memory.sh b/scripts/hooks/pre-compact-memory.sh index ea3e03a..7653b85 100644 --- a/scripts/hooks/pre-compact-memory.sh +++ b/scripts/hooks/pre-compact-memory.sh @@ -18,8 +18,8 @@ if [ -z "$CWD" ]; then exit 0 fi -# Auto-create .memory/ if it doesn't exist -mkdir -p "$CWD/.memory" 2>/dev/null || exit 0 +# Auto-create .memory/ and ensure .gitignore entries (idempotent after first run) +source "$(cd "$(dirname "$0")" && pwd)/ensure-memory-gitignore.sh" "$CWD" || exit 0 BACKUP_FILE="$CWD/.memory/backup.json" diff --git a/scripts/hooks/stop-update-memory.sh b/scripts/hooks/stop-update-memory.sh index e30ef53..d54298d 100755 --- a/scripts/hooks/stop-update-memory.sh +++ b/scripts/hooks/stop-update-memory.sh @@ -22,8 +22,9 @@ if [ -z "$CWD" ]; then exit 0 fi -# Auto-create .memory/ if it doesn't exist (bootstraps on first session) -mkdir -p "$CWD/.memory" 2>/dev/null || exit 0 +# Auto-create .memory/ and ensure .gitignore entries (idempotent after first run) +SCRIPT_DIR_EARLY="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR_EARLY/ensure-memory-gitignore.sh" "$CWD" || exit 0 # Logging (shared log file with background updater; [stop-hook] prefix distinguishes) MEMORY_FILE="$CWD/.memory/WORKING-MEMORY.md" diff --git a/src/cli/utils/post-install.ts b/src/cli/utils/post-install.ts index 71fa5c4..8541da7 100644 --- a/src/cli/utils/post-install.ts +++ b/src/cli/utils/post-install.ts @@ -436,7 +436,7 @@ export async function updateGitignore( ): Promise { try { const gitignorePath = path.join(gitRoot, '.gitignore'); - const entriesToAdd = ['.claude/', '.devflow/', '.memory/']; + const entriesToAdd = ['.claude/', '.devflow/', '.memory/', '.docs/']; let gitignoreContent = ''; try {