Skip to content
Merged
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
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Commands with Teams Variant ship as `{name}.md` (parallel subagents) and `{name}

**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 → reads last turn from session transcript (`~/.claude/projects/{encoded-cwd}/{session_id}.jsonl`), spawns background `claude -p --model haiku` to update `.memory/WORKING-MEMORY.md` with structured sections (`## Now`, `## Progress`, `## Decisions`, `## Modified Files`, `## Context`, `## Session Log`; throttled: skips if triggered <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.

**Self-Learning**: A SessionEnd hook (`session-end-learning`) accumulates session IDs and triggers a background `claude -p --model sonnet` every 3 sessions (5 at 15+ observations) to detect repeated workflows and procedural knowledge from batch transcripts. Observations accumulate in `.memory/learning-log.jsonl` with confidence scores, temporal decay, and daily run caps. When confidence thresholds are met (3 observations with 24h+ temporal spread for both workflow and procedural types), artifacts are auto-created as slash commands (`.claude/commands/self-learning/`) or skills (`.claude/skills/{slug}/`). Loaded artifacts are reinforced locally (no LLM) on each session end. Toggleable via `devflow learn --enable/--disable/--status` or `devflow init --learn/--no-learn`. Configurable model/throttle/caps/debug via `devflow learn --configure`. Debug logs stored at `~/.devflow/logs/{project-slug}/`. Use `devflow learn --purge` to remove invalid observations.
**Self-Learning**: A SessionEnd hook (`session-end-learning`) accumulates session IDs and triggers a background `claude -p --model sonnet` every 3 sessions (5 at 15+ observations) to detect repeated workflows and procedural knowledge from batch transcripts. Observations accumulate in `.memory/learning-log.jsonl` with confidence scores, temporal decay, and daily run caps. When confidence thresholds are met (5 observations with 7-day temporal spread for both workflow and procedural types), artifacts are auto-created as slash commands (`.claude/commands/self-learning/`) or skills (`.claude/skills/{slug}/`). Loaded artifacts are reinforced locally (no LLM) on each session end. Single toggle mechanism: hook presence in `settings.json` IS the enabled state — no `enabled` field in `learning.json`. Toggleable via `devflow learn --enable/--disable/--status` or `devflow init --learn/--no-learn`. Configurable model/throttle/caps/debug via `devflow learn --configure`. Use `devflow learn --reset` to remove all artifacts + log + transient state. Use `devflow learn --purge` to remove invalid observations. Debug logs stored at `~/.devflow/logs/{project-slug}/`.

**Claude Code Flags**: Typed registry (`src/cli/utils/flags.ts`) for managing Claude Code feature flags (env vars and top-level settings). Pure functions `applyFlags`/`stripFlags`/`getDefaultFlags` follow the `applyTeamsConfig`/`stripTeamsConfig` pattern. Initial flags: `tool-search`, `lsp`, `clear-context-on-plan` (default ON), `brief`, `disable-1m-context` (default OFF). Manageable via `devflow flags --enable/--disable/--status/--list`. Stored in manifest `features.flags: string[]`.

Expand Down Expand Up @@ -106,7 +106,7 @@ Working memory files live in a dedicated `.memory/` directory:
├── WORKING-MEMORY.md # Auto-maintained by Stop hook (overwritten each session)
├── backup.json # Pre-compact git state snapshot
├── learning-log.jsonl # Learning observations (JSONL, one entry per line)
├── learning.json # Project-level learning config (max runs, throttle, model, debug)
├── learning.json # Project-level learning config (max runs, throttle, model, debug — no enabled field)
├── .learning-runs-today # Daily run counter (date + count)
├── .learning-session-count # Session IDs pending batch (one per line)
├── .learning-batch-ids # Session IDs for current batch run
Expand Down
28 changes: 16 additions & 12 deletions scripts/hooks/background-learning
Original file line number Diff line number Diff line change
Expand Up @@ -261,12 +261,12 @@ $USER_MESSAGES
Detect two types of patterns:

1. WORKFLOW patterns: Multi-step sequences the user instructs repeatedly (e.g., \"squash merge PR, pull main, delete branch\"). These become slash commands.
- Required observations for artifact creation: 3 (seen across multiple sessions)
- Temporal spread requirement: first_seen and last_seen must be 24h+ apart
- Required observations for artifact creation: 5 (seen across multiple sessions)
- Temporal spread requirement: first_seen and last_seen must be 7 days apart

2. PROCEDURAL patterns: Knowledge about how to accomplish specific tasks (e.g., debugging hook failures, configuring specific tools). These become skills.
- Required observations for artifact creation: 3 (same as workflows)
- Temporal spread requirement: first_seen and last_seen must be 24h+ apart (same as workflows)
- Required observations for artifact creation: 5 (same as workflows)
- Temporal spread requirement: first_seen and last_seen must be 7 days apart (same as workflows)

Rules:
- If an existing observation matches a pattern from this session, report it with the SAME id so the count can be incremented
Expand All @@ -279,14 +279,10 @@ Rules:

# === SKILL TEMPLATE ===

SKILL TEMPLATE (required structure when creating skill artifacts):
IMPORTANT: Do NOT include YAML frontmatter (--- blocks) in artifact content.
The system adds frontmatter automatically. Only provide the markdown body.

---
name: self-learning:{slug}
description: \"This skill should be used when {specific trigger context}\"
user-invocable: false
allowed-tools: Read, Grep, Glob
---
SKILL TEMPLATE (required body structure when creating skill artifacts):

# {Title}

Expand All @@ -312,7 +308,7 @@ allowed-tools: Read, Grep, Glob
# === COMMAND TEMPLATE ===

COMMAND TEMPLATE (when creating command artifacts):
Standard markdown with description frontmatter.
Standard markdown body only. Do NOT include YAML frontmatter (--- blocks).

# === NAMING RULES ===

Expand All @@ -322,6 +318,14 @@ NAMING RULES:
- Do NOT include project-specific prefixes in the slug
- Keep slugs short and descriptive (2-3 words kebab-case)

# === QUALITY RULES ===

- Content must be actionable and specific. Avoid generic advice.
- Skills should be 30-80 lines of practical, concrete patterns.
- Do NOT include YAML frontmatter (--- blocks) in artifact content.
- Commands should have clear step-by-step instructions.
- Focus on project-specific patterns, not general best practices.

# === OUTPUT FORMAT ===

Output ONLY the JSON object. No markdown fences, no explanation.
Expand Down
33 changes: 23 additions & 10 deletions scripts/hooks/json-helper.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -81,14 +81,27 @@ function parseJsonl(file) {
const DECAY_FACTORS = [1.0, 0.90, 0.81, 0.73, 0.66, 0.59, 0.53];
const CONFIDENCE_FLOOR = 0.10;
const DECAY_PERIOD_DAYS = 30;
const REQUIRED_OBSERVATIONS = 3;
const TEMPORAL_SPREAD_SECS = 86400;
const REQUIRED_OBSERVATIONS = 5;
const TEMPORAL_SPREAD_SECS = 604800; // 7 days
const INITIAL_CONFIDENCE = 0.33; // seed value for first observation (higher than calculateConfidence(1) to reduce noise)

function learningLog(msg) {
const ts = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
process.stderr.write(`[${ts}] ${msg}\n`);
}

/**
* Strip leading YAML frontmatter from content that the model may have included
* despite being told not to. Belt-and-suspenders defense against duplicate frontmatter.
*/
function stripLeadingFrontmatter(text) {
if (!text) return '';
const trimmed = text.replace(/^\s*\n/, '');
if (!trimmed.startsWith('---')) return text;
const match = trimmed.match(/^---\s*\n[\s\S]*?\n---\s*\n?/);
return match ? trimmed.slice(match[0].length) : text;
}

function writeJsonlAtomic(file, entries) {
const tmp = file + '.tmp';
const content = entries.length > 0
Expand Down Expand Up @@ -262,17 +275,17 @@ try {

case 'array-length': {
const input = JSON.parse(readStdin());
const path = args[0];
const arr = getNestedField(input, path);
const dotPath = args[0];
const arr = getNestedField(input, dotPath);
console.log(Array.isArray(arr) ? arr.length : 0);
break;
}

case 'array-item': {
const input = JSON.parse(readStdin());
const path = args[0];
const dotPath = args[0];
const index = parseInt(args[1]);
const arr = getNestedField(input, path);
const arr = getNestedField(input, dotPath);
if (Array.isArray(arr) && index >= 0 && index < arr.length) {
console.log(JSON.stringify(arr[index]));
} else {
Expand Down Expand Up @@ -456,7 +469,7 @@ try {
id: obs.id,
type: obs.type,
pattern: obs.pattern,
confidence: 0.33,
confidence: INITIAL_CONFIDENCE,
observations: 1,
first_seen: nowIso,
last_seen: nowIso,
Expand All @@ -465,7 +478,7 @@ try {
details: obs.details || '',
};
logMap.set(obs.id, newEntry);
learningLog(`New observation ${obs.id}: type=${obs.type} confidence=0.33`);
learningLog(`New observation ${obs.id}: type=${obs.type} confidence=${INITIAL_CONFIDENCE}`);
created++;
}
}
Expand Down Expand Up @@ -544,7 +557,7 @@ try {
`# devflow-learning: auto-generated (${artDate}, confidence: ${conf}, obs: ${obsN})`,
'---',
'',
art.content || '',
stripLeadingFrontmatter(art.content || ''),
'',
].join('\n');
} else {
Expand All @@ -557,7 +570,7 @@ try {
`# devflow-learning: auto-generated (${artDate}, confidence: ${conf}, obs: ${obsN})`,
'---',
'',
art.content || '',
stripLeadingFrontmatter(art.content || ''),
'',
].join('\n');
}
Expand Down
20 changes: 10 additions & 10 deletions scripts/hooks/session-end-learning
Original file line number Diff line number Diff line change
Expand Up @@ -30,22 +30,22 @@ CWD=$(echo "$INPUT" | json_field "cwd" "")
MEMORY_DIR="$CWD/.memory"
[ ! -d "$MEMORY_DIR" ] && exit 0

# Learning config
# Learning config — hook presence in settings.json IS the enabled state, no dual check
LEARNING_CONFIG="$MEMORY_DIR/learning.json"
[ ! -f "$LEARNING_CONFIG" ] && exit 0
if [ -f "$LEARNING_CONFIG" ]; then
DEBUG=$(json_field "debug" "false" < "$LEARNING_CONFIG")
MAX_DAILY=$(json_field "max_daily_runs" "5" < "$LEARNING_CONFIG")
BATCH_SIZE=$(json_field "batch_size" "3" < "$LEARNING_CONFIG")
else
DEBUG="false"
MAX_DAILY=5
BATCH_SIZE=3
fi

# Resolve claude binary
CLAUDE_BIN=$(command -v claude 2>/dev/null || true)
[ -z "$CLAUDE_BIN" ] && exit 0

# --- Config ---
ENABLED=$(json_field "enabled" "false" < "$LEARNING_CONFIG")
[ "$ENABLED" != "true" ] && exit 0

DEBUG=$(json_field "debug" "false" < "$LEARNING_CONFIG")
MAX_DAILY=$(json_field "max_daily_runs" "5" < "$LEARNING_CONFIG")
BATCH_SIZE=$(json_field "batch_size" "3" < "$LEARNING_CONFIG")

# Log path (shared helper — consistent slug with background-learning)
source "$SCRIPT_DIR/log-paths"
LOG_FILE="$(devflow_log_dir "$CWD")/.learning-update.log"
Expand Down
138 changes: 136 additions & 2 deletions src/cli/commands/learn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as p from '@clack/prompts';
import color from 'picocolors';
import { getClaudeDirectory, getDevFlowDirectory } from '../utils/paths.js';
import type { HookMatcher, Settings } from '../utils/hooks.js';
import { cleanSelfLearningArtifacts, AUTO_GENERATED_MARKER } from '../utils/learning-cleanup.js';

/**
* Learning observation stored in learning-log.jsonl (one JSON object per line).
Expand Down Expand Up @@ -235,7 +236,8 @@ export function formatLearningStatus(observations: LearningObservation[], hookSt
* Skips fields with wrong types; swallows parse errors.
*/
// SYNC: Config loading duplicated in scripts/hooks/background-learning load_config()
// Synced fields: max_daily_runs, throttle_minutes, model, debug, batch_size
// Synced fields: max_daily_runs, throttle_minutes, model, debug
// Note: batch_size is loaded here and in session-end-learning, but not in background-learning
export function applyConfigLayer(config: LearningConfig, json: string): LearningConfig {
try {
const raw = JSON.parse(json) as Record<string, unknown>;
Expand Down Expand Up @@ -299,6 +301,7 @@ interface LearnOptions {
list?: boolean;
configure?: boolean;
clear?: boolean;
reset?: boolean;
purge?: boolean;
}

Expand All @@ -310,9 +313,10 @@ export const learnCommand = new Command('learn')
.option('--list', 'Show all observations sorted by confidence')
.option('--configure', 'Interactive configuration wizard')
.option('--clear', 'Reset learning log (removes all observations)')
.option('--reset', 'Remove all self-learning artifacts, log, and transient state')
.option('--purge', 'Remove invalid/corrupted entries from learning log')
.action(async (options: LearnOptions) => {
const hasFlag = options.enable || options.disable || options.status || options.list || options.configure || options.clear || options.purge;
const hasFlag = options.enable || options.disable || options.status || options.list || options.configure || options.clear || options.reset || options.purge;
if (!hasFlag) {
p.intro(color.bgYellow(color.black(' Self-Learning ')));
p.note(
Expand All @@ -322,6 +326,7 @@ export const learnCommand = new Command('learn')
`${color.cyan('devflow learn --list')} Show all observations\n` +
`${color.cyan('devflow learn --configure')} Configuration wizard\n` +
`${color.cyan('devflow learn --clear')} Reset learning log\n` +
`${color.cyan('devflow learn --reset')} Remove artifacts + log + state\n` +
`${color.cyan('devflow learn --purge')} Remove invalid entries`,
'Usage',
);
Expand Down Expand Up @@ -526,6 +531,135 @@ export const learnCommand = new Command('learn')
return;
}

// --- --reset ---
if (options.reset) {
const memoryDir = path.join(process.cwd(), '.memory');
const lockDir = path.join(memoryDir, '.learning.lock');

// Acquire lock to prevent conflict with running background-learning
try {
await fs.mkdir(lockDir);
} catch {
p.log.error('Learning system is currently running. Try again in a moment.');
return;
}

try {
// Inventory what will be removed (dry-run) before asking for confirmation
const { observations } = await readObservations(logPath);

// Count artifacts without removing them
const skillsDir = path.join(claudeDir, 'skills');
const commandsDir = path.join(claudeDir, 'commands', 'self-learning');
let skillCount = 0;
let cmdCount = 0;
try {
const skillEntries = await fs.readdir(skillsDir, { withFileTypes: true });
for (const entry of skillEntries) {
if (!entry.isDirectory() || entry.name.startsWith('devflow:')) continue;
const skillFile = path.join(skillsDir, entry.name, 'SKILL.md');
try {
const content = await fs.readFile(skillFile, 'utf-8');
if (content.split('\n').slice(0, 10).join('\n').includes(AUTO_GENERATED_MARKER)) {
skillCount++;
}
} catch { /* file doesn't exist */ }
}
} catch { /* skills dir doesn't exist */ }
try {
const cmdEntries = await fs.readdir(commandsDir, { withFileTypes: true });
for (const entry of cmdEntries) {
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
try {
const content = await fs.readFile(path.join(commandsDir, entry.name), 'utf-8');
if (content.split('\n').slice(0, 10).join('\n').includes(AUTO_GENERATED_MARKER)) {
cmdCount++;
}
} catch { /* file doesn't exist */ }
}
} catch { /* commands dir doesn't exist */ }

const transientFiles = [
'.learning-session-count',
'.learning-batch-ids',
'.learning-runs-today',
'.learning-notified-at',
];
let transientCount = 0;
for (const f of transientFiles) {
try {
await fs.access(path.join(memoryDir, f));
transientCount++;
} catch { /* doesn't exist */ }
}

if (skillCount === 0 && cmdCount === 0 && observations.length === 0 && transientCount === 0) {
p.log.info('Nothing to clean — no self-learning artifacts or state found.');
return;
}

// Build and show confirmation prompt
const lines: string[] = ['This will remove:'];
if (skillCount > 0) lines.push(` - ${skillCount} self-learning skill(s)`);
if (cmdCount > 0) lines.push(` - ${cmdCount} self-learning command(s)`);
if (observations.length > 0) lines.push(` - Learning log (${observations.length} observations)`);
if (transientCount > 0) lines.push(` - ${transientCount} transient state file(s)`);

if (process.stdin.isTTY) {
p.log.info(lines.join('\n'));
const confirm = await p.confirm({
message: 'Continue? This cannot be undone.',
initialValue: false,
});
if (p.isCancel(confirm) || !confirm) {
p.log.info('Reset cancelled.');
return;
}
}

// User confirmed — now actually remove everything
const artifactResult = await cleanSelfLearningArtifacts(claudeDir);

// Truncate learning log
try {
await fs.writeFile(logPath, '', 'utf-8');
} catch { /* file may not exist */ }

// Remove transient state files
for (const f of transientFiles) {
try {
await fs.unlink(path.join(memoryDir, f));
} catch { /* file may not exist */ }
}

// Remove stale `enabled` field from learning.json (migration)
const configPath = path.join(memoryDir, 'learning.json');
try {
const configContent = await fs.readFile(configPath, 'utf-8');
const config = JSON.parse(configContent) as Record<string, unknown>;
if ('enabled' in config) {
delete config.enabled;
if (Object.keys(config).length === 0) {
await fs.unlink(configPath);
} else {
await fs.writeFile(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
}
}
} catch { /* config may not exist */ }

const parts: string[] = [];
if (artifactResult.removed > 0) parts.push(`${artifactResult.removed} artifact(s)`);
if (observations.length > 0) parts.push('learning log');
if (transientCount > 0) parts.push('transient state');

p.log.success(`Reset complete — removed ${parts.join(', ')}.`);
} finally {
// Release lock
try { await fs.rmdir(lockDir); } catch { /* already cleaned */ }
}
return;
}

// --- --clear ---
if (options.clear) {
try {
Expand Down
Loading
Loading