From d977f4a795041e28a20d1ad80404aa0cf9c60d82 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Mon, 6 Apr 2026 09:47:30 +0300 Subject: [PATCH 1/2] fix: address self-review issues - Extract magic number 0.33 into INITIAL_CONFIDENCE constant in json-helper.cjs - Fix inaccurate SYNC comment in learn.ts (batch_size is not in background-learning) - Add tests for stripLeadingFrontmatter via create-artifacts (duplicate frontmatter bug fix) - Add tests for cleanSelfLearningArtifacts (new learning-cleanup.ts utility) --- scripts/hooks/json-helper.cjs | 33 +++++--- src/cli/commands/learn.ts | 138 +++++++++++++++++++++++++++++++++- tests/learn.test.ts | 129 +++++++++++++++++++++++++++++++ tests/shell-hooks.test.ts | 86 +++++++++++++++++++-- 4 files changed, 369 insertions(+), 17 deletions(-) diff --git a/scripts/hooks/json-helper.cjs b/scripts/hooks/json-helper.cjs index e24cbfb9..8da9f5ef 100755 --- a/scripts/hooks/json-helper.cjs +++ b/scripts/hooks/json-helper.cjs @@ -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 @@ -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 { @@ -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, @@ -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++; } } @@ -544,7 +557,7 @@ try { `# devflow-learning: auto-generated (${artDate}, confidence: ${conf}, obs: ${obsN})`, '---', '', - art.content || '', + stripLeadingFrontmatter(art.content || ''), '', ].join('\n'); } else { @@ -557,7 +570,7 @@ try { `# devflow-learning: auto-generated (${artDate}, confidence: ${conf}, obs: ${obsN})`, '---', '', - art.content || '', + stripLeadingFrontmatter(art.content || ''), '', ].join('\n'); } diff --git a/src/cli/commands/learn.ts b/src/cli/commands/learn.ts index c269dbb6..6f81cba2 100644 --- a/src/cli/commands/learn.ts +++ b/src/cli/commands/learn.ts @@ -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). @@ -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; @@ -299,6 +301,7 @@ interface LearnOptions { list?: boolean; configure?: boolean; clear?: boolean; + reset?: boolean; purge?: boolean; } @@ -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( @@ -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', ); @@ -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; + 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 { diff --git a/tests/learn.test.ts b/tests/learn.test.ts index 02df8029..b49eff21 100644 --- a/tests/learn.test.ts +++ b/tests/learn.test.ts @@ -1,4 +1,7 @@ import { describe, it, expect } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; import { addLearningHook, removeLearningHook, @@ -11,6 +14,7 @@ import { applyConfigLayer, type LearningObservation, } from '../src/cli/commands/learn.js'; +import { cleanSelfLearningArtifacts, AUTO_GENERATED_MARKER } from '../src/cli/utils/learning-cleanup.js'; describe('addLearningHook', () => { it('adds hook to empty settings', () => { @@ -623,3 +627,128 @@ describe('applyConfigLayer — immutability', () => { expect(result.batch_size).toBe(3); }); }); + +describe('cleanSelfLearningArtifacts', () => { + function makeTmpClaudeDir(): string { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'devflow-clean-test-')); + fs.mkdirSync(path.join(tmpDir, 'skills'), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, 'commands', 'self-learning'), { recursive: true }); + return tmpDir; + } + + it('removes skills with auto-generated marker', async () => { + const claudeDir = makeTmpClaudeDir(); + try { + const skillDir = path.join(claudeDir, 'skills', 'test-skill'); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), [ + '---', + 'name: self-learning:test-skill', + `# ${AUTO_GENERATED_MARKER} (2026-04-01, confidence: 0.95, obs: 5)`, + '---', + '', + '# Test Skill', + ].join('\n')); + + const result = await cleanSelfLearningArtifacts(claudeDir); + expect(result.removed).toBe(1); + expect(fs.existsSync(skillDir)).toBe(false); + } finally { + fs.rmSync(claudeDir, { recursive: true, force: true }); + } + }); + + it('removes commands with auto-generated marker', async () => { + const claudeDir = makeTmpClaudeDir(); + try { + const cmdFile = path.join(claudeDir, 'commands', 'self-learning', 'deploy.md'); + fs.writeFileSync(cmdFile, [ + '---', + 'description: "Deploy workflow"', + `# ${AUTO_GENERATED_MARKER} (2026-04-01, confidence: 0.95, obs: 5)`, + '---', + '', + '# Deploy', + ].join('\n')); + + const result = await cleanSelfLearningArtifacts(claudeDir); + expect(result.removed).toBe(1); + expect(fs.existsSync(cmdFile)).toBe(false); + } finally { + fs.rmSync(claudeDir, { recursive: true, force: true }); + } + }); + + it('preserves devflow-namespaced skills', async () => { + const claudeDir = makeTmpClaudeDir(); + try { + const skillDir = path.join(claudeDir, 'skills', 'devflow:quality-gates'); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# Quality Gates'); + + const result = await cleanSelfLearningArtifacts(claudeDir); + expect(result.removed).toBe(0); + expect(fs.existsSync(skillDir)).toBe(true); + } finally { + fs.rmSync(claudeDir, { recursive: true, force: true }); + } + }); + + it('preserves skills without auto-generated marker', async () => { + const claudeDir = makeTmpClaudeDir(); + try { + const skillDir = path.join(claudeDir, 'skills', 'user-skill'); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '---\nname: user-skill\n---\n# User Skill'); + + const result = await cleanSelfLearningArtifacts(claudeDir); + expect(result.removed).toBe(0); + expect(fs.existsSync(skillDir)).toBe(true); + } finally { + fs.rmSync(claudeDir, { recursive: true, force: true }); + } + }); + + it('removes empty self-learning commands dir', async () => { + const claudeDir = makeTmpClaudeDir(); + try { + const cmdFile = path.join(claudeDir, 'commands', 'self-learning', 'test.md'); + fs.writeFileSync(cmdFile, `---\n# ${AUTO_GENERATED_MARKER}\n---\nTest`); + + await cleanSelfLearningArtifacts(claudeDir); + const selfLearningDir = path.join(claudeDir, 'commands', 'self-learning'); + expect(fs.existsSync(selfLearningDir)).toBe(false); + } finally { + fs.rmSync(claudeDir, { recursive: true, force: true }); + } + }); + + it('handles missing directories gracefully', async () => { + const claudeDir = path.join(os.tmpdir(), `devflow-nonexistent-${Date.now()}`); + const result = await cleanSelfLearningArtifacts(claudeDir); + expect(result.removed).toBe(0); + expect(result.paths).toEqual([]); + }); + + it('returns paths of all removed artifacts', async () => { + const claudeDir = makeTmpClaudeDir(); + try { + // Create a skill + const skillDir = path.join(claudeDir, 'skills', 'learned-skill'); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), `---\n# ${AUTO_GENERATED_MARKER}\n---\nSkill`); + + // Create a command + const cmdFile = path.join(claudeDir, 'commands', 'self-learning', 'learned-cmd.md'); + fs.writeFileSync(cmdFile, `---\n# ${AUTO_GENERATED_MARKER}\n---\nCmd`); + + const result = await cleanSelfLearningArtifacts(claudeDir); + expect(result.removed).toBe(2); + expect(result.paths).toHaveLength(2); + expect(result.paths.some(p => p.includes('learned-skill'))).toBe(true); + expect(result.paths.some(p => p.includes('learned-cmd'))).toBe(true); + } finally { + fs.rmSync(claudeDir, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/shell-hooks.test.ts b/tests/shell-hooks.test.ts index 13f496d3..3fc26cc5 100644 --- a/tests/shell-hooks.test.ts +++ b/tests/shell-hooks.test.ts @@ -501,7 +501,7 @@ describe('json-helper.cjs process-observations', () => { const entry = JSON.parse(fs.readFileSync(logFile, 'utf8').trim()); expect(entry.observations).toBe(2); - expect(entry.confidence).toBe(0.66); + expect(entry.confidence).toBe(0.40); expect(entry.evidence).toContain('old evidence'); expect(entry.evidence).toContain('new evidence'); } finally { @@ -582,7 +582,7 @@ describe('json-helper.cjs process-observations', () => { try { fs.writeFileSync(logFile, JSON.stringify({ id: 'obs_abc123', type: 'workflow', pattern: 'test', - confidence: 0.66, observations: 2, + confidence: 0.80, observations: 4, first_seen: '2026-03-20T00:00:00Z', last_seen: '2026-03-20T00:00:00Z', status: 'observing', evidence: [], details: '', }) + '\n'); @@ -608,11 +608,11 @@ describe('json-helper.cjs process-observations', () => { const responseFile = path.join(tmpDir, 'response.json'); const logFile = path.join(tmpDir, 'learning.jsonl'); try { - const twoDaysAgo = new Date(Date.now() - 2 * 86400000).toISOString(); + const eightDaysAgo = new Date(Date.now() - 8 * 86400000).toISOString(); fs.writeFileSync(logFile, JSON.stringify({ id: 'obs_abc123', type: 'workflow', pattern: 'test', - confidence: 0.66, observations: 2, - first_seen: twoDaysAgo, last_seen: twoDaysAgo, + confidence: 0.80, observations: 4, + first_seen: eightDaysAgo, last_seen: eightDaysAgo, status: 'observing', evidence: [], details: '', }) + '\n'); @@ -1093,6 +1093,82 @@ describe('session-end-learning structure', () => { }); }); +describe('json-helper.cjs create-artifacts frontmatter stripping', () => { + it('strips model-generated YAML frontmatter from artifact content', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'devflow-test-')); + const responseFile = path.join(tmpDir, 'response.json'); + const logFile = path.join(tmpDir, 'learning.jsonl'); + try { + fs.writeFileSync(logFile, JSON.stringify({ + id: 'obs_abc123', type: 'workflow', pattern: 'test', + confidence: 0.95, observations: 3, status: 'ready', + first_seen: '2026-03-20T00:00:00Z', last_seen: '2026-03-22T00:00:00Z', + evidence: [], details: '', + }) + '\n'); + + // Model incorrectly includes frontmatter in content + const contentWithFrontmatter = '---\ndescription: "model added this"\n---\n\n# Real Content\nActual body.'; + fs.writeFileSync(responseFile, JSON.stringify({ + artifacts: [{ + observation_id: 'obs_abc123', type: 'command', + name: 'strip-test', description: 'Test stripping', + content: contentWithFrontmatter, + }], + })); + + execSync( + `node "${JSON_HELPER}" create-artifacts "${responseFile}" "${logFile}" "${tmpDir}"`, + { stdio: ['pipe', 'pipe', 'pipe'] }, + ); + + const artPath = path.join(tmpDir, '.claude', 'commands', 'self-learning', 'strip-test.md'); + const content = fs.readFileSync(artPath, 'utf8'); + // Should have system-generated frontmatter, NOT the model's frontmatter + expect(content).toContain('description: "Test stripping"'); + expect(content).toContain('devflow-learning: auto-generated'); + // Model's frontmatter should be stripped, leaving only the body + expect(content).toContain('# Real Content'); + expect(content).not.toContain('model added this'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('preserves content without frontmatter unchanged', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'devflow-test-')); + const responseFile = path.join(tmpDir, 'response.json'); + const logFile = path.join(tmpDir, 'learning.jsonl'); + try { + fs.writeFileSync(logFile, JSON.stringify({ + id: 'obs_abc123', type: 'workflow', pattern: 'test', + confidence: 0.95, observations: 3, status: 'ready', + first_seen: '2026-03-20T00:00:00Z', last_seen: '2026-03-22T00:00:00Z', + evidence: [], details: '', + }) + '\n'); + + fs.writeFileSync(responseFile, JSON.stringify({ + artifacts: [{ + observation_id: 'obs_abc123', type: 'command', + name: 'no-strip-test', description: 'No stripping needed', + content: '# Clean Content\nNo frontmatter here.', + }], + })); + + execSync( + `node "${JSON_HELPER}" create-artifacts "${responseFile}" "${logFile}" "${tmpDir}"`, + { stdio: ['pipe', 'pipe', 'pipe'] }, + ); + + const artPath = path.join(tmpDir, '.claude', 'commands', 'self-learning', 'no-strip-test.md'); + const content = fs.readFileSync(artPath, 'utf8'); + expect(content).toContain('# Clean Content'); + expect(content).toContain('No frontmatter here.'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); + describe('json-parse wrapper', () => { it('can be sourced and provides function definitions', () => { const result = execSync( From 15b77b600669ed745666d7c359da1bef19c119d9 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Mon, 6 Apr 2026 10:00:55 +0300 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20self-learning=20system=20=E2=80=94?= =?UTF-8?q?=20thresholds,=20single=20toggle,=20frontmatter,=20--reset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Raise promotion thresholds: 5 observations + 7-day spread (was 3/24h) - Single on/off toggle: hook presence in settings.json only (no learning.json enabled field) - Fix duplicate frontmatter: stripLeadingFrontmatter + no-frontmatter prompt instruction - Add `devflow learn --reset` for artifact + log + state cleanup - Add quality rules to background-learning prompt - Update CLAUDE.md to reflect new thresholds and mechanism - Remove existing self-learning artifacts from this project - New shared utility: learning-cleanup.ts --- CLAUDE.md | 4 +- scripts/hooks/background-learning | 28 ++++++----- scripts/hooks/session-end-learning | 20 ++++---- src/cli/utils/learning-cleanup.ts | 75 ++++++++++++++++++++++++++++++ 4 files changed, 103 insertions(+), 24 deletions(-) create mode 100644 src/cli/utils/learning-cleanup.ts diff --git a/CLAUDE.md b/CLAUDE.md index 27d22a71..e903fac5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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[]`. @@ -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 diff --git a/scripts/hooks/background-learning b/scripts/hooks/background-learning index 95d78a9e..446033ee 100755 --- a/scripts/hooks/background-learning +++ b/scripts/hooks/background-learning @@ -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 @@ -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} @@ -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 === @@ -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. diff --git a/scripts/hooks/session-end-learning b/scripts/hooks/session-end-learning index ad2b6107..6846e05b 100755 --- a/scripts/hooks/session-end-learning +++ b/scripts/hooks/session-end-learning @@ -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" diff --git a/src/cli/utils/learning-cleanup.ts b/src/cli/utils/learning-cleanup.ts new file mode 100644 index 00000000..2eb73aad --- /dev/null +++ b/src/cli/utils/learning-cleanup.ts @@ -0,0 +1,75 @@ +import { promises as fs } from 'fs'; +import * as path from 'path'; + +export const AUTO_GENERATED_MARKER = 'devflow-learning: auto-generated'; + +/** + * Scan a file for the auto-generated marker in its first 10 lines. + */ +async function hasAutoGeneratedMarker(filePath: string): Promise { + try { + const content = await fs.readFile(filePath, 'utf-8'); + const head = content.split('\n').slice(0, 10).join('\n'); + return head.includes(AUTO_GENERATED_MARKER); + } catch { + return false; + } +} + +/** + * Remove all self-learning artifacts (skills and commands) from the Claude directory. + * + * Detection uses the `devflow-learning: auto-generated` marker in file frontmatter. + * Only removes artifacts that have this marker — safe, no false positives. + * + * Returns the count and paths of removed artifacts. + */ +export async function cleanSelfLearningArtifacts( + claudeDir: string, +): Promise<{ removed: number; paths: string[] }> { + const removed: string[] = []; + + // Scan skills directory for non-prefixed dirs (self-learning skills don't have devflow: prefix) + const skillsDir = path.join(claudeDir, 'skills'); + try { + const skillEntries = await fs.readdir(skillsDir, { withFileTypes: true }); + for (const entry of skillEntries) { + if (!entry.isDirectory()) continue; + // Skip devflow-namespaced skills — those are managed by the installer + if (entry.name.startsWith('devflow:')) continue; + + const skillFile = path.join(skillsDir, entry.name, 'SKILL.md'); + if (await hasAutoGeneratedMarker(skillFile)) { + await fs.rm(path.join(skillsDir, entry.name), { recursive: true }); + removed.push(path.join(skillsDir, entry.name)); + } + } + } catch { + // skills dir doesn't exist — nothing to clean + } + + // Scan self-learning commands directory + const commandsDir = path.join(claudeDir, 'commands', 'self-learning'); + try { + const cmdEntries = await fs.readdir(commandsDir, { withFileTypes: true }); + for (const entry of cmdEntries) { + if (!entry.isFile() || !entry.name.endsWith('.md')) continue; + + const cmdFile = path.join(commandsDir, entry.name); + if (await hasAutoGeneratedMarker(cmdFile)) { + await fs.unlink(cmdFile); + removed.push(cmdFile); + } + } + + // Remove empty self-learning dir + const remaining = await fs.readdir(commandsDir); + if (remaining.length === 0) { + await fs.rmdir(commandsDir); + } + } catch { + // commands/self-learning dir doesn't exist — nothing to clean + } + + return { removed: removed.length, paths: removed }; +}