From 6d619cc2c80e3154b4baf7116ace368d63812b06 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Wed, 25 Mar 2026 18:17:38 +0200 Subject: [PATCH 01/14] docs: add production-grade to title, composable plugin system to features --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0e742db..20a6587 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# DevFlow: The Most Advanced Agentic Development Toolkit +# DevFlow: The Most Advanced Agentic Toolkit for Generating Production-Grade Code [![npm version](https://img.shields.io/npm/v/devflow-kit)](https://www.npmjs.com/package/devflow-kit) [![CI](https://github.com/dean0x/devflow/actions/workflows/ci.yml/badge.svg)](https://github.com/dean0x/devflow/actions/workflows/ci.yml) @@ -36,6 +36,7 @@ Claude Code is powerful. DevFlow makes it extraordinary. - **Parallel debugging** with competing hypotheses investigated simultaneously - **35 quality skills** with 9 auto-activating core, 8 language/ecosystem, plus specialized review and orchestration skills - **Ambient mode** that classifies intent and loads proportional skill sets automatically +- **Fully composable plugin system** where every feature is a plugin. Install only what you need. No bloat, no take-it-or-leave-it bundles. ## Quick Start @@ -340,7 +341,7 @@ npx devflow-kit skills unshadow core-patterns | Tool | Role | What It Does | |------|------|-------------| | **[Skim](https://github.com/dean0x/skim)** | Context Optimization | Compresses code, test output, build output, and git output for optimal LLM reasoning | -| **DevFlow** | Quality Orchestration | 18 parallel reviewers, working memory, self-learning, production-grade lifecycle workflows | +| **DevFlow** | Quality Orchestration | 18 parallel reviewers, working memory, self-learning, composable plugin system | | **[Backbeat](https://github.com/dean0x/backbeat)** | Agent Orchestration | Orchestration at scale. Karpathy optimization loops, multi-agent pipelines, DAG dependencies, autoscaling | Skim optimizes what your AI sees. DevFlow enforces how it works. Backbeat scales everything across agents. No other stack covers all three. From 34dec754bfd329d77b6bb607dfc07f06b0030705 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Wed, 25 Mar 2026 18:52:33 +0200 Subject: [PATCH 02/14] docs: refine DevFlow title, update Skim positioning in ecosystem table --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 20a6587..c826351 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# DevFlow: The Most Advanced Agentic Toolkit for Generating Production-Grade Code +# DevFlow: The Most Advanced Agentic Development Toolkit for Production-Grade Code [![npm version](https://img.shields.io/npm/v/devflow-kit)](https://www.npmjs.com/package/devflow-kit) [![CI](https://github.com/dean0x/devflow/actions/workflows/ci.yml/badge.svg)](https://github.com/dean0x/devflow/actions/workflows/ci.yml) @@ -340,7 +340,7 @@ npx devflow-kit skills unshadow core-patterns | Tool | Role | What It Does | |------|------|-------------| -| **[Skim](https://github.com/dean0x/skim)** | Context Optimization | Compresses code, test output, build output, and git output for optimal LLM reasoning | +| **[Skim](https://github.com/dean0x/skim)** | Context Optimization | Code-aware AST parsing across 12 languages, command rewriting, test/build/git output compression | | **DevFlow** | Quality Orchestration | 18 parallel reviewers, working memory, self-learning, composable plugin system | | **[Backbeat](https://github.com/dean0x/backbeat)** | Agent Orchestration | Orchestration at scale. Karpathy optimization loops, multi-agent pipelines, DAG dependencies, autoscaling | From c39c3cf47106351b85595f7f82317dc3c052e544 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Wed, 25 Mar 2026 18:58:38 +0200 Subject: [PATCH 03/14] =?UTF-8?q?feat:=20learning=20system=20wave=202=20?= =?UTF-8?q?=E2=80=94=20SessionEnd=20batching,=20threshold=20hardening,=20n?= =?UTF-8?q?aming=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move learning from Stop → SessionEnd hook with 3-session batching (adaptive: 5-session batch at 15+ observations) - Raise procedural thresholds to 3 observations + 24h temporal spread (aligned with workflows; initial confidence 0.33 for both types) - Fix transcript extraction for string-typed message content - Eliminate empty-array loop noise in process_observations/create_artifacts - Add reinforcement mechanism: local grep updates last_seen for loaded self-learning artifacts on each session end (no LLM cost) - Improve skill template quality: Iron Law section, activation triggers, proper frontmatter with user-invocable/allowed-tools - Rename artifact paths: commands from learned/ → self-learning/, skills from learned-{slug}/ → {slug}/ - Add backwards-compatible legacy Stop hook cleanup in removeLearningHook - Deprecate stop-update-learning (stub that exits immediately) - Lower default max_daily_runs from 10 → 5 - Update tests (444 pass), docs, and CLI strings --- CLAUDE.md | 7 +- README.md | 8 +- scripts/hooks/background-learning | 184 +++++++++++++++++++------ scripts/hooks/json-helper.cjs | 14 +- scripts/hooks/json-parse | 10 +- scripts/hooks/session-end-learning | 209 +++++++++++++++++++++++++++++ scripts/hooks/session-start-memory | 6 +- scripts/hooks/stop-update-learning | 98 +------------- src/cli/commands/learn.ts | 76 +++++++---- tests/learn.test.ts | 110 ++++++++++----- tests/shell-hooks.test.ts | 5 +- 11 files changed, 507 insertions(+), 220 deletions(-) create mode 100755 scripts/hooks/session-end-learning diff --git a/CLAUDE.md b/CLAUDE.md index 11c103b..7d84196 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 Stop hook (`stop-update-learning`) spawns a background `claude -p --model sonnet` to detect repeated workflows and procedural knowledge from session transcripts. Observations accumulate in `.memory/learning-log.jsonl` with confidence scores, temporal decay, and daily run caps. When confidence thresholds are met (3 observations for workflows with 24h+ temporal spread, 2 for procedural), artifacts are auto-created as slash commands (`.claude/commands/learned/`) or skills (`.claude/skills/learned-*/`). 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 (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. ## Project Structure @@ -51,7 +51,7 @@ devflow/ ├── plugins/devflow-*/ # 17 plugins (8 core + 9 optional language/ecosystem) ├── docs/reference/ # Detailed reference documentation ├── scripts/ # Helper scripts (statusline, docs-helpers) -│ └── hooks/ # Working Memory + ambient + learning hooks (stop, session-start, pre-compact, ambient-prompt, stop-update-learning, background-learning) +│ └── hooks/ # Working Memory + ambient + learning hooks (stop, session-start, pre-compact, ambient-prompt, session-end-learning, background-learning) ├── src/cli/ # TypeScript CLI (init, list, uninstall, ambient, learn) ├── .claude-plugin/ # Marketplace registry ├── .docs/ # Project docs (reviews, design) — per-project @@ -99,7 +99,8 @@ Working memory files live in a dedicated `.memory/` directory: ├── learning-log.jsonl # Learning observations (JSONL, one entry per line) ├── learning.json # Project-level learning config (max runs, throttle, model, debug) ├── .learning-runs-today # Daily run counter (date + count) -├── .learning-last-trigger # Throttle marker (epoch timestamp) +├── .learning-session-count # Session IDs pending batch (one per line) +├── .learning-batch-ids # Session IDs for current batch run ├── .learning-notified-at # New artifact notification marker (epoch timestamp) └── knowledge/ ├── decisions.md # Architectural decisions (ADR-NNN, append-only) diff --git a/README.md b/README.md index c826351..af0586c 100644 --- a/README.md +++ b/README.md @@ -204,14 +204,14 @@ Working memory is **per-project** — scoped to each repo's `.memory/` directory DevFlow detects repeated workflows and procedural knowledge across your sessions and automatically creates slash commands and skills. -A background agent runs on session stop (same as Working Memory) and analyzes your session transcript for patterns. When a pattern is observed enough times (3 for workflows with 24h+ temporal spread, 2 for procedural knowledge), it creates an artifact: +A background agent runs on session end, batching every 3 sessions (5 at 15+ observations) to analyze transcripts for patterns. When a pattern is observed enough times (3 observations with 24h+ temporal spread for both types), it creates an artifact: -- **Workflow patterns** become slash commands at `.claude/commands/learned/` -- **Procedural patterns** become skills at `.claude/skills/learned-*/` +- **Workflow patterns** become slash commands at `.claude/commands/self-learning/` +- **Procedural patterns** become skills at `.claude/skills/{slug}/` | Command | Description | |---------|-------------| -| `devflow learn --enable` | Register the learning Stop hook | +| `devflow learn --enable` | Register the learning SessionEnd hook | | `devflow learn --disable` | Remove the learning hook | | `devflow learn --status` | Show learning status and observation counts | | `devflow learn --list` | Show all observations sorted by confidence | diff --git a/scripts/hooks/background-learning b/scripts/hooks/background-learning index 18b1d19..f74c70e 100755 --- a/scripts/hooks/background-learning +++ b/scripts/hooks/background-learning @@ -1,16 +1,16 @@ #!/bin/bash # Background Learning Agent -# Called by stop-update-learning as a detached background process. -# Reads user messages from the session transcript, then uses a fresh `claude -p` +# Called by session-end-learning as a detached background process. +# Reads user messages from session transcripts (batch mode), then uses a fresh `claude -p` # invocation with Sonnet to detect patterns and update learning-log.jsonl. # On failure: logs error, does nothing (missing patterns are better than fake data). set -e CWD="$1" -SESSION_ID="$2" -CLAUDE_BIN="$3" +MODE="${2:---batch}" +CLAUDE_BIN="${3:-claude}" # Source JSON parsing helpers (jq with node fallback) SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" @@ -92,13 +92,13 @@ load_config() { GLOBAL_CONFIG="$HOME/.devflow/learning.json" PROJECT_CONFIG="$CWD/.memory/learning.json" # Defaults - MAX_DAILY_RUNS=10 + MAX_DAILY_RUNS=5 THROTTLE_MINUTES=5 MODEL="sonnet" DEBUG="false" # Apply global if [ -f "$GLOBAL_CONFIG" ]; then - MAX_DAILY_RUNS=$(json_field_file "$GLOBAL_CONFIG" "max_daily_runs" "10") + MAX_DAILY_RUNS=$(json_field_file "$GLOBAL_CONFIG" "max_daily_runs" "5") THROTTLE_MINUTES=$(json_field_file "$GLOBAL_CONFIG" "throttle_minutes" "5") MODEL=$(json_field_file "$GLOBAL_CONFIG" "model" "sonnet") DEBUG=$(json_field_file "$GLOBAL_CONFIG" "debug" "false") @@ -182,6 +182,70 @@ extract_user_messages() { return 0 } +# --- Batch Transcript Extraction --- + +extract_batch_messages() { + local encoded_cwd + encoded_cwd=$(echo "$CWD" | sed 's|^/||' | tr '/' '-') + local projects_dir="$HOME/.claude/projects/-${encoded_cwd}" + local batch_file="$CWD/.memory/.learning-batch-ids" + + if [ ! -f "$batch_file" ]; then + log "No batch IDs file found" + return 1 + fi + + USER_MESSAGES="" + local session_count=0 + + while IFS= read -r sid; do + [ -z "$sid" ] && continue + local transcript="${projects_dir}/${sid}.jsonl" + if [ ! -f "$transcript" ]; then + log "Transcript not found for session $sid" + continue + fi + + local session_msgs + session_msgs=$(grep '"type":"user"' "$transcript" 2>/dev/null \ + | while IFS= read -r line; do echo "$line" | json_extract_messages; done \ + | grep -v '^$' || true) + + if [ -n "$session_msgs" ]; then + if [ -n "$USER_MESSAGES" ]; then + USER_MESSAGES="${USER_MESSAGES} +--- Session ${sid} --- +${session_msgs}" + else + USER_MESSAGES="--- Session ${sid} --- +${session_msgs}" + fi + session_count=$((session_count + 1)) + fi + done < "$batch_file" + + # Clean up batch file after reading + rm -f "$batch_file" + + # Truncate to 30,000 chars (multi-session) + if [ ${#USER_MESSAGES} -gt 30000 ]; then + USER_MESSAGES="${USER_MESSAGES:0:30000}... [truncated]" + fi + + if [ -z "$USER_MESSAGES" ]; then + log "No user text content found in batch transcripts" + return 1 + fi + + if [ ${#USER_MESSAGES} -lt 200 ]; then + log "Insufficient content for pattern detection (${#USER_MESSAGES} chars, min 200)" + return 1 + fi + + log "Extracted messages from $session_count session(s)" + return 0 +} + # --- Temporal Decay Pass --- apply_temporal_decay() { @@ -259,7 +323,7 @@ build_sonnet_prompt() { EXISTING OBSERVATIONS (for deduplication — reuse IDs for matching patterns): $EXISTING_OBS -USER MESSAGES FROM THIS SESSION: +USER MESSAGES FROM RECENT SESSIONS: $USER_MESSAGES Detect two types of patterns: @@ -269,8 +333,8 @@ Detect two types of patterns: - Temporal spread requirement: first_seen and last_seen must be 24h+ 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: 2 - - No temporal spread requirement + - Required observations for artifact creation: 3 (same as workflows) + - Temporal spread requirement: first_seen and last_seen must be 24h+ 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 @@ -281,6 +345,45 @@ Rules: - Only report patterns that are clearly distinct — do not create near-duplicate observations - If no patterns detected, return {\"observations\": [], \"artifacts\": []} +SKILL TEMPLATE (required structure when creating skill artifacts): + +--- +name: self-learning:{slug} +description: \"This skill should be used when {specific trigger context}\" +user-invocable: false +allowed-tools: Read, Grep, Glob +--- + +# {Title} + +{One-line summary.} + +## Iron Law + +> **{SINGLE RULE IN ALL CAPS}** +> +> {2-3 sentence core principle.} + +--- + +## When This Skill Activates + +- {Trigger condition 1} +- {Trigger condition 2} + +## {Pattern Section} + +{Practical patterns, rules, or procedures.} + +COMMAND TEMPLATE (when creating command artifacts): +Standard markdown with description frontmatter. + +NAMING RULES: +- Skill names: self-learning:{slug} (e.g., self-learning:debug-hooks) +- Skill descriptions MUST start with \"This skill should be used when...\" +- Do NOT include project-specific prefixes in the slug +- Keep slugs short and descriptive (2-3 words kebab-case) + Output ONLY the JSON object. No markdown fences, no explanation. { @@ -378,6 +481,10 @@ process_observations() { log "--- Processing response ---" OBS_COUNT=$(echo "$RESPONSE" | json_array_length "observations") + if [ "$OBS_COUNT" -le 0 ]; then + log "No observations in response" + return + fi NOW_ISO=$(date -u '+%Y-%m-%dT%H:%M:%SZ') for i in $(seq 0 $((OBS_COUNT - 1))); do @@ -425,19 +532,15 @@ process_observations() { fi MERGED_EVIDENCE=$(echo "[$OLD_EVIDENCE, $OBS_EVIDENCE]" | json_merge_evidence) - # Calculate confidence - if [ "$OBS_TYPE" = "workflow" ]; then - REQUIRED=3 - else - REQUIRED=2 - fi + # Calculate confidence (both types require 3 observations) + REQUIRED=3 CONF_RAW=$((NEW_COUNT * 100 / REQUIRED)) if [ "$CONF_RAW" -gt 95 ]; then CONF_RAW=95; fi CONF=$(echo "$CONF_RAW" | awk '{printf "%.2f", $1 / 100}') - # Check temporal spread for workflows + # Check temporal spread (applies to BOTH workflow and procedural) STATUS=$(echo "$EXISTING_LINE" | json_field "status" "") - if [ "$OBS_TYPE" = "workflow" ] && [ "$STATUS" != "created" ]; then + if [ "$STATUS" != "created" ]; then FIRST_EPOCH=$(date -j -f "%Y-%m-%dT%H:%M:%SZ" "$FIRST_SEEN" +%s 2>/dev/null \ || date -d "$FIRST_SEEN" +%s 2>/dev/null \ || echo "0") @@ -448,19 +551,15 @@ process_observations() { fi fi - # Determine status + # Determine status (temporal spread required for both types) ARTIFACT_PATH=$(echo "$EXISTING_LINE" | json_field "artifact_path" "") if [ "$STATUS" != "created" ] && [ "$CONF_RAW" -ge 70 ]; then - if [ "$OBS_TYPE" = "workflow" ]; then - FIRST_EPOCH=$(date -j -f "%Y-%m-%dT%H:%M:%SZ" "$FIRST_SEEN" +%s 2>/dev/null \ - || date -d "$FIRST_SEEN" +%s 2>/dev/null \ - || echo "0") - NOW_EPOCH=$(date +%s) - SPREAD=$((NOW_EPOCH - FIRST_EPOCH)) - if [ "$SPREAD" -ge 86400 ]; then - STATUS="ready" - fi - else + FIRST_EPOCH=$(date -j -f "%Y-%m-%dT%H:%M:%SZ" "$FIRST_SEEN" +%s 2>/dev/null \ + || date -d "$FIRST_SEEN" +%s 2>/dev/null \ + || echo "0") + NOW_EPOCH=$(date +%s) + SPREAD=$((NOW_EPOCH - FIRST_EPOCH)) + if [ "$SPREAD" -ge 86400 ]; then STATUS="ready" fi fi @@ -487,12 +586,8 @@ process_observations() { log "Updated observation $OBS_ID: count=$NEW_COUNT confidence=$CONF status=$STATUS" else - # New observation - if [ "$OBS_TYPE" = "workflow" ]; then - CONF="0.33" - else - CONF="0.50" - fi + # New observation (both types start at 0.33 = 1/3) + CONF="0.33" NEW_ENTRY=$(json_obs_construct \ --arg id "$OBS_ID" \ @@ -516,6 +611,9 @@ process_observations() { create_artifacts() { ART_COUNT=$(echo "$RESPONSE" | json_array_length "artifacts") + if [ "$ART_COUNT" -le 0 ]; then + return + fi for i in $(seq 0 $((ART_COUNT - 1))); do ART=$(echo "$RESPONSE" | json_array_item "artifacts" "$i") @@ -542,10 +640,10 @@ create_artifacts() { fi if [ "$ART_TYPE" = "command" ]; then - ART_DIR="$CWD/.claude/commands/learned" + ART_DIR="$CWD/.claude/commands/self-learning" ART_PATH="$ART_DIR/$ART_NAME.md" else - ART_DIR="$CWD/.claude/skills/learned-$ART_NAME" + ART_DIR="$CWD/.claude/skills/$ART_NAME" ART_PATH="$ART_DIR/SKILL.md" fi @@ -573,8 +671,10 @@ create_artifacts() { printf '%s\n' "$ART_CONTENT" >> "$ART_PATH" else printf '%s\n' "---" \ - "name: learned-$ART_NAME" \ + "name: self-learning:$ART_NAME" \ "description: \"$ART_DESC\"" \ + "user-invocable: false" \ + "allowed-tools: Read, Grep, Glob" \ "# devflow-learning: auto-generated ($ART_DATE, confidence: $ART_CONF, obs: $ART_OBS_N)" \ "---" \ "" > "$ART_PATH" @@ -601,14 +701,14 @@ create_artifacts() { # Wait for parent session to flush transcript sleep 3 -log "Starting learning analysis for session $SESSION_ID" +log "Starting learning analysis (mode: $MODE)" # Break stale locks from previous zombie processes break_stale_lock # Acquire lock if ! acquire_lock; then - log "Lock timeout after 90s — skipping for session $SESSION_ID" + log "Lock timeout after 90s — skipping" trap - EXIT exit 0 fi @@ -621,9 +721,9 @@ if ! check_daily_cap; then exit 0 fi -# Extract user messages +# Extract user messages (batch mode reads from .learning-batch-ids) USER_MESSAGES="" -if ! extract_user_messages; then +if ! extract_batch_messages; then log "No messages to analyze — skipping" exit 0 fi @@ -654,6 +754,6 @@ process_observations create_artifacts increment_daily_counter -log "Learning analysis complete for session $SESSION_ID" +log "Learning analysis complete (batch mode)" exit 0 diff --git a/scripts/hooks/json-helper.cjs b/scripts/hooks/json-helper.cjs index 6565e83..e8d5997 100755 --- a/scripts/hooks/json-helper.cjs +++ b/scripts/hooks/json-helper.cjs @@ -167,6 +167,10 @@ try { case 'extract-text-messages': { const input = JSON.parse(readStdin()); const content = input?.message?.content; + if (typeof content === 'string') { + console.log(content); + break; + } if (!Array.isArray(content)) { console.log(''); break; @@ -296,8 +300,8 @@ try { .filter(o => o.type === 'procedural') .slice(0, 5) .map(o => { - const match = o.artifact_path.match(/learned-([^/]+)/); - const name = match ? match[1] : ''; + const parts = o.artifact_path.split('/'); + const name = parts.length >= 2 ? parts[parts.length - 2] : ''; const conf = (Math.floor(o.confidence * 10) / 10).toString(); return { name, conf }; }); @@ -316,10 +320,10 @@ try { const messages = created.map(o => { if (o.type === 'workflow') { const name = o.artifact_path.split('/').pop().replace(/\.md$/, ''); - return `NEW: /learned/${name} command created from repeated workflow`; + return `NEW: /self-learning/${name} command created from repeated workflow`; } else { - const match = o.artifact_path.match(/learned-([^/]+)/); - const name = match ? match[1] : ''; + const parts = o.artifact_path.split('/'); + const name = parts.length >= 2 ? parts[parts.length - 2] : ''; return `NEW: ${name} skill created from procedural knowledge`; } }); diff --git a/scripts/hooks/json-parse b/scripts/hooks/json-parse index 0d86ad6..e0a7c12 100755 --- a/scripts/hooks/json-parse +++ b/scripts/hooks/json-parse @@ -174,7 +174,9 @@ json_array_item() { json_extract_messages() { if [ "$_HAS_JQ" = "true" ]; then jq -r 'if .message.content then - [.message.content[] | select(.type == "text") | .text] | join("\n") + if (.message.content | type) == "string" then .message.content + else [.message.content[] | select(.type == "text") | .text] | join("\n") + end else "" end' 2>/dev/null else node "$_JSON_HELPER" extract-text-messages @@ -247,7 +249,7 @@ json_learning_created() { conf: (.confidence * 10 | floor / 10 | tostring) }] | .[0:5], skills: [.[] | select(.type == "procedural") | { - name: (.artifact_path | capture("learned-(?[^/]+)") | .n // ""), + name: (.artifact_path | split("/") | .[-2]), conf: (.confidence * 10 | floor / 10 | tostring) }] | .[0:5] } @@ -265,9 +267,9 @@ json_learning_new() { [.[] | select(.status == "created" and .last_seen != null)] | map( if .type == "workflow" then - "NEW: /learned/\(.artifact_path | split("/") | last | rtrimstr(".md")) command created from repeated workflow" + "NEW: /self-learning/\(.artifact_path | split("/") | last | rtrimstr(".md")) command created from repeated workflow" else - "NEW: \(.artifact_path | capture("learned-(?[^/]+)") | .n // "") skill created from procedural knowledge" + "NEW: \(.artifact_path | split("/") | .[-2]) skill created from procedural knowledge" end ) | join("\n") ' "$file" 2>/dev/null diff --git a/scripts/hooks/session-end-learning b/scripts/hooks/session-end-learning new file mode 100755 index 0000000..29d0ba0 --- /dev/null +++ b/scripts/hooks/session-end-learning @@ -0,0 +1,209 @@ +#!/bin/bash +# SessionEnd hook: learning system trigger with 3-session batching +# Replaces stop-update-learning (which ran on every Stop) +# +# Flow: +# 1. Guard clauses (skip if background process, no JSON tools, etc.) +# 2. Session depth check (skip if < 3 user turns) +# 3. Reinforce loaded self-learning artifacts (local grep, no LLM) +# 4. Batch counting: accumulate session IDs, trigger background learner at batch size +# 5. Daily cap enforcement +# +set -euo pipefail + +CWD="${1:-}" +CLAUDE_BIN="${2:-claude}" + +# --- Guard clauses --- +[ -n "${BG_LEARNER:-}" ] && exit 0 +[ -n "${BG_UPDATER:-}" ] && exit 0 +[ -z "$CWD" ] && exit 0 + +MEMORY_DIR="$CWD/.memory" +[ ! -d "$MEMORY_DIR" ] && exit 0 + +# Learning config +LEARNING_CONFIG="$MEMORY_DIR/learning.json" +[ ! -f "$LEARNING_CONFIG" ] && exit 0 + +# Source json-parse library +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=json-parse +. "$SCRIPT_DIR/json-parse" + +# Check JSON tools available +if [ "$_HAS_JQ" != "true" ] && [ "$_HAS_NODE" != "true" ]; then + exit 0 +fi + +# --- 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") + +# Project slug for logs +PROJECT_SLUG=$(echo "$CWD" | sed 's|/|_|g; s|^_||') +LOG_DIR="$HOME/.devflow/logs/$PROJECT_SLUG" +mkdir -p "$LOG_DIR" +LOG_FILE="$LOG_DIR/.learning-update.log" + +log() { + if [ "$DEBUG" = "true" ]; then + echo "[$(date '+%H:%M:%S')] session-end-learning: $1" >> "$LOG_FILE" + fi +} + +log "SessionEnd hook triggered" + +# --- Find transcript --- +# Encode CWD for Claude's project path +ENCODED_CWD=$(echo "$CWD" | sed 's|/|-|g') +PROJECTS_DIR="$HOME/.claude/projects/$ENCODED_CWD" + +if [ ! -d "$PROJECTS_DIR" ]; then + log "No projects dir: $PROJECTS_DIR" + exit 0 +fi + +# Find the most recent session transcript +TRANSCRIPT=$(ls -t "$PROJECTS_DIR"/*.jsonl 2>/dev/null | head -1 || true) +if [ -z "$TRANSCRIPT" ] || [ ! -f "$TRANSCRIPT" ]; then + log "No transcript found" + exit 0 +fi + +SESSION_ID=$(basename "$TRANSCRIPT" .jsonl) +log "Session: $SESSION_ID" + +# --- Session depth check (min 3 user turns) --- +USER_TURNS=$(grep -c '"type":"user"' "$TRANSCRIPT" 2>/dev/null || echo "0") +if [ "$USER_TURNS" -lt 3 ]; then + log "Shallow session ($USER_TURNS turns), skipping" + exit 0 +fi +log "Session depth: $USER_TURNS turns" + +# --- Reinforcement: update last_seen for loaded self-learning artifacts --- +reinforce_loaded_artifacts() { + local learning_log="$MEMORY_DIR/learning-log.jsonl" + [ ! -f "$learning_log" ] && return + + # Grep transcript for self-learning skill/command references + local loaded + loaded=$(grep -oE 'self-learning[:/][a-z0-9-]+' "$TRANSCRIPT" 2>/dev/null | sort -u || true) + [ -z "$loaded" ] && return + + local now_iso + now_iso=$(date -u '+%Y-%m-%dT%H:%M:%SZ') + local updated=false + local temp_log="${learning_log}.tmp" + + while IFS= read -r line; do + local status artifact_path + status=$(echo "$line" | json_field "status" "") + artifact_path=$(echo "$line" | json_field "artifact_path" "") + + if [ "$status" = "created" ] && [ -n "$artifact_path" ]; then + # Extract slug from path + local slug + if echo "$artifact_path" | grep -q '/commands/' 2>/dev/null; then + # Command: .claude/commands/self-learning/{name}.md -> extract {name} + slug=$(basename "$artifact_path" .md) + else + # Skill: .claude/skills/{slug}/SKILL.md -> extract {slug} + slug=$(basename "$(dirname "$artifact_path")") + fi + + if echo "$loaded" | grep -qF "$slug" 2>/dev/null; then + if [ "$_HAS_JQ" = "true" ]; then + line=$(echo "$line" | jq -c --arg ts "$now_iso" '.last_seen = $ts') + else + # Node fallback: simple string replacement + if echo "$line" | grep -q '"last_seen"' 2>/dev/null; then + line=$(echo "$line" | sed "s/\"last_seen\":\"[^\"]*\"/\"last_seen\":\"$now_iso\"/") + else + line=$(echo "$line" | sed "s/}$/,\"last_seen\":\"$now_iso\"}/") + fi + fi + updated=true + log "Reinforced: $slug" + fi + fi + echo "$line" + done < "$learning_log" > "$temp_log" + + if [ "$updated" = "true" ]; then + mv "$temp_log" "$learning_log" + else + rm -f "$temp_log" + fi +} + +reinforce_loaded_artifacts + +# --- 3-session batching --- +SESSION_COUNT_FILE="$MEMORY_DIR/.learning-session-count" +BATCH_IDS_FILE="$MEMORY_DIR/.learning-batch-ids" + +# Adaptive batch size: 5 if >=15 observations, otherwise default +OBS_TOTAL=0 +if [ -f "$MEMORY_DIR/learning-log.jsonl" ]; then + OBS_TOTAL=$(wc -l < "$MEMORY_DIR/learning-log.jsonl" | tr -d ' ') +fi +if [ "$OBS_TOTAL" -ge 15 ]; then + BATCH_SIZE=5 + log "Adaptive batch size: 5 (${OBS_TOTAL} observations)" +fi + +# Append this session ID (deduplicate) +if [ -f "$SESSION_COUNT_FILE" ] && grep -qF "$SESSION_ID" "$SESSION_COUNT_FILE" 2>/dev/null; then + log "Session already counted, skipping" + exit 0 +fi + +echo "$SESSION_ID" >> "$SESSION_COUNT_FILE" +CURRENT_COUNT=$(wc -l < "$SESSION_COUNT_FILE" | tr -d ' ') +log "Session count: $CURRENT_COUNT / $BATCH_SIZE" + +if [ "$CURRENT_COUNT" -lt "$BATCH_SIZE" ]; then + log "Batch not full yet ($CURRENT_COUNT < $BATCH_SIZE), waiting" + exit 0 +fi + +# --- Batch is full: prepare to spawn background learner --- + +# Daily cap check +RUNS_FILE="$MEMORY_DIR/.learning-runs-today" +TODAY=$(date '+%Y-%m-%d') +RUNS_TODAY=0 +if [ -f "$RUNS_FILE" ]; then + RUNS_DATE=$(head -1 "$RUNS_FILE" 2>/dev/null || echo "") + if [ "$RUNS_DATE" = "$TODAY" ]; then + RUNS_TODAY=$(tail -1 "$RUNS_FILE" 2>/dev/null || echo "0") + fi +fi + +if [ "$RUNS_TODAY" -ge "$MAX_DAILY" ]; then + log "Daily cap reached ($RUNS_TODAY >= $MAX_DAILY), skipping" + exit 0 +fi + +# Write batch IDs file for background-learning to consume +cp "$SESSION_COUNT_FILE" "$BATCH_IDS_FILE" +# Reset session counter +rm -f "$SESSION_COUNT_FILE" + +# Update daily run count +echo "$TODAY" > "$RUNS_FILE" +echo "$((RUNS_TODAY + 1))" >> "$RUNS_FILE" + +log "Triggering batch learning (${CURRENT_COUNT} sessions)" + +# Spawn background learner +BG_LEARNER=1 nohup bash "$SCRIPT_DIR/background-learning" "$CWD" "--batch" "$CLAUDE_BIN" \ + >> "$LOG_FILE" 2>&1 & + +exit 0 diff --git a/scripts/hooks/session-start-memory b/scripts/hooks/session-start-memory index ead0a74..75eaf97 100644 --- a/scripts/hooks/session-start-memory +++ b/scripts/hooks/session-start-memory @@ -143,13 +143,13 @@ if [ -f "$LEARNING_LOG" ]; then LEARNED_SKILLS="" if [ -n "$LEARNED_JSON" ]; then if [ "$_HAS_JQ" = "true" ]; then - LEARNED_COMMANDS=$(echo "$LEARNED_JSON" | jq -r '.commands | map("/learned/\(.name) (\(.conf))") | join(", ")' 2>/dev/null) + LEARNED_COMMANDS=$(echo "$LEARNED_JSON" | jq -r '.commands | map("/self-learning/\(.name) (\(.conf))") | join(", ")' 2>/dev/null) LEARNED_SKILLS=$(echo "$LEARNED_JSON" | jq -r '.skills | map("\(.name) (\(.conf))") | join(", ")' 2>/dev/null) else # Node fallback: parse the JSON and format LEARNED_COMMANDS=$(echo "$LEARNED_JSON" | node -e " const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); - console.log(d.commands.map(c=>\"/learned/\"+c.name+\" (\"+c.conf+\")\").join(', ')); + console.log(d.commands.map(c=>\"/self-learning/\"+c.name+\" (\"+c.conf+\")\").join(', ')); " 2>/dev/null) LEARNED_SKILLS=$(echo "$LEARNED_JSON" | node -e " const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); @@ -169,7 +169,7 @@ Commands: $LEARNED_COMMANDS" Skills: $LEARNED_SKILLS" fi LEARNED_SECTION="$LEARNED_SECTION -Edit or delete: .claude/commands/learned/ and .claude/skills/" +Edit or delete: .claude/commands/self-learning/ and .claude/skills/" # Check for new artifacts since last notification (single jq -s pass) LAST_NOTIFIED=0 diff --git a/scripts/hooks/stop-update-learning b/scripts/hooks/stop-update-learning index 9c14f38..77a0dce 100755 --- a/scripts/hooks/stop-update-learning +++ b/scripts/hooks/stop-update-learning @@ -1,98 +1,4 @@ #!/bin/bash - -# Self-Learning: Stop Hook -# Spawns a background process to analyze session patterns asynchronously. -# The session ends immediately — no visible effect in the TUI. -# On failure: does nothing (missing patterns are better than fake data). - -set -e - -# Break feedback loop: background learner's headless session triggers stop hook on exit. -# DEVFLOW_BG_LEARNER is set by background-learning before invoking claude. -if [ "${DEVFLOW_BG_LEARNER:-}" = "1" ]; then exit 0; fi -# Also guard against memory updater triggering learning -if [ "${DEVFLOW_BG_UPDATER:-}" = "1" ]; then exit 0; fi - -# Resolve script directory once (used for json-parse, ensure-memory-gitignore, and learner) -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" - -# JSON parsing (jq with node fallback) — silently no-op if neither available -source "$SCRIPT_DIR/json-parse" -if [ "$_JSON_AVAILABLE" = "false" ]; then exit 0; fi - -INPUT=$(cat) - -# Resolve project directory — bail if missing -CWD=$(echo "$INPUT" | json_field "cwd" "") -if [ -z "$CWD" ]; then - exit 0 -fi - -# Auto-create .memory/ and ensure .gitignore entries (idempotent after first run) -source "$SCRIPT_DIR/ensure-memory-gitignore" "$CWD" || exit 0 - -# Logging -source "$SCRIPT_DIR/log-paths" -LOG_FILE="$(devflow_log_dir "$CWD")/.learning-update.log" -log() { echo "[$(date -u '+%Y-%m-%dT%H:%M:%SZ')] [stop-hook] $1" >> "$LOG_FILE"; } - -# Throttle: skip if triggered within configured throttle window -# Load throttle config -THROTTLE_MINUTES=5 -GLOBAL_CONFIG="$HOME/.devflow/learning.json" -PROJECT_CONFIG="$CWD/.memory/learning.json" -if [ -f "$PROJECT_CONFIG" ]; then - THROTTLE_MINUTES=$(json_field_file "$PROJECT_CONFIG" "throttle_minutes" "5") -elif [ -f "$GLOBAL_CONFIG" ]; then - THROTTLE_MINUTES=$(json_field_file "$GLOBAL_CONFIG" "throttle_minutes" "5") -fi -THROTTLE_SECONDS=$((THROTTLE_MINUTES * 60)) - -TRIGGER_MARKER="$CWD/.memory/.learning-last-trigger" -if [ -f "$TRIGGER_MARKER" ]; then - if stat --version &>/dev/null 2>&1; then - MARKER_MTIME=$(stat -c %Y "$TRIGGER_MARKER") - else - MARKER_MTIME=$(stat -f %m "$TRIGGER_MARKER") - fi - NOW=$(date +%s) - AGE=$(( NOW - MARKER_MTIME )) - if [ "$AGE" -lt "$THROTTLE_SECONDS" ]; then - log "Skipped: triggered ${AGE}s ago (throttle: ${THROTTLE_SECONDS}s)" - exit 0 - fi -fi - -# Resolve claude binary — if not found, skip (graceful degradation) -CLAUDE_BIN=$(command -v claude 2>/dev/null || true) -if [ -z "$CLAUDE_BIN" ]; then - log "Skipped: claude binary not found" - exit 0 -fi - -# Extract session ID from hook input -SESSION_ID=$(echo "$INPUT" | json_field "session_id" "") -if [ -z "$SESSION_ID" ]; then - log "Skipped: no session_id in hook input" - exit 0 -fi - -# Resolve the background learning script (same directory as this hook) -LEARNER="$SCRIPT_DIR/background-learning" -if [ ! -x "$LEARNER" ]; then - log "Skipped: learner not found/not executable at $LEARNER" - exit 0 -fi - -# Touch marker BEFORE spawning learner — prevents race with concurrent hooks -touch "$TRIGGER_MARKER" - -# Spawn background learner — detached, no effect on session exit -nohup "$LEARNER" "$CWD" "$SESSION_ID" "$CLAUDE_BIN" \ - /dev/null 2>&1 & -disown - -log "Spawned background learner: session=$SESSION_ID cwd=$CWD claude=$CLAUDE_BIN learner=$LEARNER" - -# Allow stop immediately (no JSON output = proceed) +# DEPRECATED: Learning moved from Stop -> SessionEnd hook. +# Run `devflow learn --disable && devflow learn --enable` to upgrade. exit 0 diff --git a/src/cli/commands/learn.ts b/src/cli/commands/learn.ts index 9aa6179..582125b 100644 --- a/src/cli/commands/learn.ts +++ b/src/cli/commands/learn.ts @@ -51,10 +51,11 @@ export function isLearningObservation(obj: unknown): obj is LearningObservation && typeof o.details === 'string'; } -const LEARNING_HOOK_MARKER = 'stop-update-learning'; +const LEARNING_HOOK_MARKER = 'session-end-learning'; +const LEGACY_HOOK_MARKER = 'stop-update-learning'; /** - * Add the learning Stop hook to settings JSON. + * Add the learning SessionEnd hook to settings JSON. * Idempotent — returns unchanged JSON if hook already exists. */ export function addLearningHook(settingsJson: string, devflowDir: string): string { @@ -68,7 +69,7 @@ export function addLearningHook(settingsJson: string, devflowDir: string): strin settings.hooks = {}; } - const hookCommand = path.join(devflowDir, 'scripts', 'hooks', 'run-hook') + ' stop-update-learning'; + const hookCommand = path.join(devflowDir, 'scripts', 'hooks', 'run-hook') + ' session-end-learning'; const newEntry: HookMatcher = { hooks: [ @@ -80,38 +81,55 @@ export function addLearningHook(settingsJson: string, devflowDir: string): strin ], }; - if (!settings.hooks.Stop) { - settings.hooks.Stop = []; + if (!settings.hooks.SessionEnd) { + settings.hooks.SessionEnd = []; } - settings.hooks.Stop.push(newEntry); + settings.hooks.SessionEnd.push(newEntry); return JSON.stringify(settings, null, 2) + '\n'; } /** - * Remove the learning Stop hook from settings JSON. + * Remove the learning hook from settings JSON. + * Checks BOTH SessionEnd (new) and Stop (legacy cleanup). * Idempotent — returns unchanged JSON if hook not present. - * Preserves other Stop hooks. Cleans empty arrays/objects. + * Preserves other hooks. Cleans empty arrays/objects. */ export function removeLearningHook(settingsJson: string): string { const settings: Settings = JSON.parse(settingsJson); - - if (!settings.hooks?.Stop) { - return settingsJson; + let changed = false; + + // Remove from SessionEnd (current) + if (settings.hooks?.SessionEnd) { + const before = settings.hooks.SessionEnd.length; + settings.hooks.SessionEnd = settings.hooks.SessionEnd.filter( + (matcher) => !matcher.hooks.some((h) => h.command.includes(LEARNING_HOOK_MARKER)), + ); + if (settings.hooks.SessionEnd.length < before) { + changed = true; + } + if (settings.hooks.SessionEnd.length === 0) { + delete settings.hooks.SessionEnd; + } } - const before = settings.hooks.Stop.length; - settings.hooks.Stop = settings.hooks.Stop.filter( - (matcher) => !matcher.hooks.some((h) => h.command.includes(LEARNING_HOOK_MARKER)), - ); - - if (settings.hooks.Stop.length === before) { - return settingsJson; + // Remove from Stop (legacy cleanup) + if (settings.hooks?.Stop) { + const before = settings.hooks.Stop.length; + settings.hooks.Stop = settings.hooks.Stop.filter( + (matcher) => !matcher.hooks.some((h) => h.command.includes(LEGACY_HOOK_MARKER)), + ); + if (settings.hooks.Stop.length < before) { + changed = true; + } + if (settings.hooks.Stop.length === 0) { + delete settings.hooks.Stop; + } } - if (settings.hooks.Stop.length === 0) { - delete settings.hooks.Stop; + if (!changed) { + return settingsJson; } if (settings.hooks && Object.keys(settings.hooks).length === 0) { @@ -127,11 +145,11 @@ export function removeLearningHook(settingsJson: string): string { export function hasLearningHook(settingsJson: string): boolean { const settings: Settings = JSON.parse(settingsJson); - if (!settings.hooks?.Stop) { + if (!settings.hooks?.SessionEnd) { return false; } - return settings.hooks.Stop.some((matcher) => + return settings.hooks.SessionEnd.some((matcher) => matcher.hooks.some((h) => h.command.includes(LEARNING_HOOK_MARKER)), ); } @@ -229,7 +247,7 @@ export function applyConfigLayer(config: LearningConfig, json: string): Learning */ export function loadLearningConfig(globalJson: string | null, projectJson: string | null): LearningConfig { let config: LearningConfig = { - max_daily_runs: 10, + max_daily_runs: 5, throttle_minutes: 5, model: 'sonnet', debug: false, @@ -253,7 +271,7 @@ interface LearnOptions { export const learnCommand = new Command('learn') .description('Enable or disable self-learning (workflow detection + auto-commands)') - .option('--enable', 'Register Stop hook for self-learning') + .option('--enable', 'Register SessionEnd hook for self-learning') .option('--disable', 'Remove self-learning hook') .option('--status', 'Show learning status and observation counts') .option('--list', 'Show all observations sorted by confidence') @@ -509,10 +527,12 @@ export const learnCommand = new Command('learn') let devflowDir: string; try { const settings: Settings = JSON.parse(settingsContent); - // Try to extract devflowDir from existing hooks (e.g., Stop hook path) + // Try to extract devflowDir from existing hooks (SessionEnd first, Stop fallback) + const sessionEndHook = settings.hooks?.SessionEnd?.[0]?.hooks?.[0]?.command; const stopHook = settings.hooks?.Stop?.[0]?.hooks?.[0]?.command; - if (stopHook) { - const hookBinary = stopHook.split(' ')[0]; + const hookCommand = sessionEndHook || stopHook; + if (hookCommand) { + const hookBinary = hookCommand.split(' ')[0]; devflowDir = path.resolve(hookBinary, '..', '..', '..'); } else { devflowDir = getDevFlowDirectory(); @@ -528,7 +548,7 @@ export const learnCommand = new Command('learn') return; } await fs.writeFile(settingsPath, updated, 'utf-8'); - p.log.success('Self-learning enabled — Stop hook registered'); + p.log.success('Self-learning enabled — SessionEnd hook registered'); p.log.info(color.dim('Repeated workflows will be detected and turned into slash commands')); } diff --git a/tests/learn.test.ts b/tests/learn.test.ts index 2331aa2..789a7e8 100644 --- a/tests/learn.test.ts +++ b/tests/learn.test.ts @@ -16,9 +16,9 @@ describe('addLearningHook', () => { const result = addLearningHook('{}', '/home/user/.devflow'); const settings = JSON.parse(result); - expect(settings.hooks.Stop).toHaveLength(1); - expect(settings.hooks.Stop[0].hooks[0].command).toContain('stop-update-learning'); - expect(settings.hooks.Stop[0].hooks[0].timeout).toBe(10); + expect(settings.hooks.SessionEnd).toHaveLength(1); + expect(settings.hooks.SessionEnd[0].hooks[0].command).toContain('session-end-learning'); + expect(settings.hooks.SessionEnd[0].hooks[0].timeout).toBe(10); }); it('adds alongside existing hooks (Stop hooks from memory)', () => { @@ -30,9 +30,9 @@ describe('addLearningHook', () => { const result = addLearningHook(input, '/home/user/.devflow'); const settings = JSON.parse(result); - expect(settings.hooks.Stop).toHaveLength(2); - expect(settings.hooks.Stop[0].hooks[0].command).toBe('stop-update-memory'); - expect(settings.hooks.Stop[1].hooks[0].command).toContain('stop-update-learning'); + expect(settings.hooks.Stop).toHaveLength(1); + expect(settings.hooks.SessionEnd).toHaveLength(1); + expect(settings.hooks.SessionEnd[0].hooks[0].command).toContain('session-end-learning'); }); it('is idempotent — does not add duplicate', () => { @@ -45,10 +45,10 @@ describe('addLearningHook', () => { it('uses correct path via run-hook wrapper', () => { const result = addLearningHook('{}', '/custom/path/.devflow'); const settings = JSON.parse(result); - const command = settings.hooks.Stop[0].hooks[0].command; + const command = settings.hooks.SessionEnd[0].hooks[0].command; expect(command).toContain('/custom/path/.devflow/scripts/hooks/run-hook'); - expect(command).toContain('stop-update-learning'); + expect(command).toContain('session-end-learning'); }); it('preserves other settings', () => { @@ -61,26 +61,26 @@ describe('addLearningHook', () => { expect(settings.statusLine.command).toBe('statusline.sh'); expect(settings.env.SOME_VAR).toBe('1'); - expect(settings.hooks.Stop).toHaveLength(1); + expect(settings.hooks.SessionEnd).toHaveLength(1); }); - it('adds alongside existing Stop hooks', () => { + it('adds alongside existing SessionEnd hooks', () => { const input = JSON.stringify({ hooks: { - Stop: [{ hooks: [{ type: 'command', command: 'other-stop.sh' }] }], + SessionEnd: [{ hooks: [{ type: 'command', command: 'other-session-end.sh' }] }], UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'ambient-prompt' }] }], }, }); const result = addLearningHook(input, '/home/user/.devflow'); const settings = JSON.parse(result); - expect(settings.hooks.Stop).toHaveLength(2); + expect(settings.hooks.SessionEnd).toHaveLength(2); expect(settings.hooks.UserPromptSubmit).toHaveLength(1); }); }); describe('removeLearningHook', () => { - it('removes learning hook', () => { + it('removes learning hook from SessionEnd', () => { const withHook = addLearningHook('{}', '/home/user/.devflow'); const result = removeLearningHook(withHook); const settings = JSON.parse(result); @@ -88,27 +88,27 @@ describe('removeLearningHook', () => { expect(settings.hooks).toBeUndefined(); }); - it('preserves memory Stop hooks', () => { + it('preserves other SessionEnd hooks', () => { const input = JSON.stringify({ hooks: { - Stop: [ - { hooks: [{ type: 'command', command: 'stop-update-memory' }] }, - { hooks: [{ type: 'command', command: '/path/to/stop-update-learning' }] }, + SessionEnd: [ + { hooks: [{ type: 'command', command: 'other-session-end-hook' }] }, + { hooks: [{ type: 'command', command: '/path/to/session-end-learning' }] }, ], }, }); const result = removeLearningHook(input); const settings = JSON.parse(result); - expect(settings.hooks.Stop).toHaveLength(1); - expect(settings.hooks.Stop[0].hooks[0].command).toBe('stop-update-memory'); + expect(settings.hooks.SessionEnd).toHaveLength(1); + expect(settings.hooks.SessionEnd[0].hooks[0].command).toBe('other-session-end-hook'); }); it('cleans empty hooks object when last hook removed', () => { const input = JSON.stringify({ hooks: { - Stop: [ - { hooks: [{ type: 'command', command: '/path/to/stop-update-learning' }] }, + SessionEnd: [ + { hooks: [{ type: 'command', command: '/path/to/session-end-learning' }] }, ], }, }); @@ -122,8 +122,8 @@ describe('removeLearningHook', () => { const input = JSON.stringify({ hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'ambient-prompt' }] }], - Stop: [ - { hooks: [{ type: 'command', command: '/path/to/stop-update-learning' }] }, + SessionEnd: [ + { hooks: [{ type: 'command', command: '/path/to/session-end-learning' }] }, ], }, }); @@ -131,7 +131,7 @@ describe('removeLearningHook', () => { const settings = JSON.parse(result); expect(settings.hooks.UserPromptSubmit).toHaveLength(1); - expect(settings.hooks.Stop).toBeUndefined(); + expect(settings.hooks.SessionEnd).toBeUndefined(); }); it('is idempotent', () => { @@ -144,10 +144,43 @@ describe('removeLearningHook', () => { expect(result).toBe(input); }); + + it('cleans up legacy Stop entries', () => { + const input = JSON.stringify({ + hooks: { + Stop: [ + { hooks: [{ type: 'command', command: 'stop-update-memory' }] }, + { hooks: [{ type: 'command', command: '/path/to/stop-update-learning' }] }, + ], + }, + }); + const result = removeLearningHook(input); + const settings = JSON.parse(result); + expect(settings.hooks.Stop).toHaveLength(1); + expect(settings.hooks.Stop[0].hooks[0].command).toBe('stop-update-memory'); + }); + + it('cleans both SessionEnd and legacy Stop', () => { + const input = JSON.stringify({ + hooks: { + SessionEnd: [ + { hooks: [{ type: 'command', command: '/path/session-end-learning' }] }, + ], + Stop: [ + { hooks: [{ type: 'command', command: 'stop-update-memory' }] }, + { hooks: [{ type: 'command', command: '/old/path/stop-update-learning' }] }, + ], + }, + }); + const result = removeLearningHook(input); + const settings = JSON.parse(result); + expect(settings.hooks.SessionEnd).toBeUndefined(); + expect(settings.hooks.Stop).toHaveLength(1); + }); }); describe('hasLearningHook', () => { - it('returns true when present', () => { + it('returns true when present on SessionEnd', () => { const withHook = addLearningHook('{}', '/home/user/.devflow'); expect(hasLearningHook(withHook)).toBe(true); }); @@ -156,27 +189,38 @@ describe('hasLearningHook', () => { expect(hasLearningHook('{}')).toBe(false); }); - it('returns false for non-learning Stop hooks', () => { + it('returns false for non-learning SessionEnd hooks', () => { const input = JSON.stringify({ hooks: { - Stop: [ - { hooks: [{ type: 'command', command: 'stop-update-memory' }] }, + SessionEnd: [ + { hooks: [{ type: 'command', command: 'some-other-hook' }] }, ], }, }); expect(hasLearningHook(input)).toBe(false); }); - it('returns true among other Stop hooks', () => { + it('returns true among other SessionEnd hooks', () => { + const input = JSON.stringify({ + hooks: { + SessionEnd: [ + { hooks: [{ type: 'command', command: 'some-other-hook' }] }, + { hooks: [{ type: 'command', command: '/path/to/session-end-learning' }] }, + ], + }, + }); + expect(hasLearningHook(input)).toBe(true); + }); + + it('returns false for legacy Stop hook only', () => { const input = JSON.stringify({ hooks: { Stop: [ - { hooks: [{ type: 'command', command: 'stop-update-memory' }] }, { hooks: [{ type: 'command', command: '/path/to/stop-update-learning' }] }, ], }, }); - expect(hasLearningHook(input)).toBe(true); + expect(hasLearningHook(input)).toBe(false); }); }); @@ -260,7 +304,7 @@ describe('formatLearningStatus', () => { describe('loadLearningConfig', () => { it('returns defaults when no config files', () => { const config = loadLearningConfig(null, null); - expect(config.max_daily_runs).toBe(10); + expect(config.max_daily_runs).toBe(5); expect(config.throttle_minutes).toBe(5); expect(config.model).toBe('sonnet'); expect(config.debug).toBe(false); @@ -285,7 +329,7 @@ describe('loadLearningConfig', () => { it('handles partial override (only some fields)', () => { const projectJson = JSON.stringify({ throttle_minutes: 15 }); const config = loadLearningConfig(null, projectJson); - expect(config.max_daily_runs).toBe(10); // default + expect(config.max_daily_runs).toBe(5); // default expect(config.throttle_minutes).toBe(15); // overridden expect(config.model).toBe('sonnet'); // default }); diff --git a/tests/shell-hooks.test.ts b/tests/shell-hooks.test.ts index 3bbf42b..32fc0c0 100644 --- a/tests/shell-hooks.test.ts +++ b/tests/shell-hooks.test.ts @@ -10,6 +10,7 @@ const JSON_HELPER = path.join(HOOKS_DIR, 'json-helper.cjs'); const HOOK_SCRIPTS = [ 'background-learning', + 'session-end-learning', 'stop-update-learning', 'background-memory-update', 'stop-update-memory', @@ -279,8 +280,8 @@ describe('json-helper.js operations', () => { try { fs.writeFileSync(file, [ - JSON.stringify({ id: 'obs_1', type: 'workflow', status: 'created', artifact_path: '/path/learned/deploy-flow.md', confidence: 0.95 }), - JSON.stringify({ id: 'obs_2', type: 'procedural', status: 'created', artifact_path: '/path/learned-debug-hooks/SKILL.md', confidence: 0.8 }), + JSON.stringify({ id: 'obs_1', type: 'workflow', status: 'created', artifact_path: '/path/self-learning/deploy-flow.md', confidence: 0.95 }), + JSON.stringify({ id: 'obs_2', type: 'procedural', status: 'created', artifact_path: '/path/debug-hooks/SKILL.md', confidence: 0.8 }), JSON.stringify({ id: 'obs_3', type: 'workflow', status: 'observing', confidence: 0.3 }), ].join('\n')); From 434f70c190efdee43072c50bd3279cb2125f1153 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Wed, 25 Mar 2026 19:07:27 +0200 Subject: [PATCH 04/14] =?UTF-8?q?refactor:=20simplify=20learning=20system?= =?UTF-8?q?=20=E2=80=94=20fix=20counter=20format,=20deduplicate=20hook=20r?= =?UTF-8?q?emoval,=20extract=20artifact=20name=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix daily cap counter format mismatch: session-end-learning wrote two-line format but background-learning reads tab-separated (same file) - Standardize env var naming: BG_LEARNER -> DEVFLOW_BG_LEARNER (matches existing DEVFLOW_BG_UPDATER convention used by memory hooks) - Use shared log-paths helper in session-end-learning (was computing its own slug with different separator, writing to different log directory) - Use json_update_field from json-parse library instead of inline jq/sed fallback for artifact reinforcement - Extract artifactName() helper in json-helper.cjs to deduplicate path parsing across learning-created and learning-new operations - Extract removeFromEvent() in learn.ts to deduplicate SessionEnd/Stop hook removal logic - Remove dead extract_user_messages() from background-learning (superseded by batch mode, referenced undefined SESSION_ID) --- scripts/hooks/background-learning | 35 ---------------------- scripts/hooks/json-helper.cjs | 48 +++++++++++++----------------- scripts/hooks/session-end-learning | 38 ++++++++--------------- src/cli/commands/learn.ts | 34 +++++++-------------- 4 files changed, 43 insertions(+), 112 deletions(-) diff --git a/scripts/hooks/background-learning b/scripts/hooks/background-learning index f74c70e..b628b81 100755 --- a/scripts/hooks/background-learning +++ b/scripts/hooks/background-learning @@ -147,41 +147,6 @@ decay_factor() { esac } -# --- Transcript Extraction --- - -extract_user_messages() { - local encoded_cwd - encoded_cwd=$(echo "$CWD" | sed 's|^/||' | tr '/' '-') - local transcript="$HOME/.claude/projects/-${encoded_cwd}/${SESSION_ID}.jsonl" - - if [ ! -f "$transcript" ]; then - log "Transcript not found at $transcript" - return 1 - fi - - # Extract ALL user text messages, skip tool_result blocks - USER_MESSAGES=$(grep '"type":"user"' "$transcript" 2>/dev/null \ - | while IFS= read -r line; do echo "$line" | json_extract_messages; done \ - | grep -v '^$') - - # Truncate to 12,000 chars - if [ ${#USER_MESSAGES} -gt 12000 ]; then - USER_MESSAGES="${USER_MESSAGES:0:12000}... [truncated]" - fi - - if [ -z "$USER_MESSAGES" ]; then - log "No user text content found in transcript" - return 1 - fi - - if [ ${#USER_MESSAGES} -lt 200 ]; then - log "Insufficient content for pattern detection (${#USER_MESSAGES} chars, min 200)" - return 1 - fi - - return 0 -} - # --- Batch Transcript Extraction --- extract_batch_messages() { diff --git a/scripts/hooks/json-helper.cjs b/scripts/hooks/json-helper.cjs index e8d5997..f5dda25 100755 --- a/scripts/hooks/json-helper.cjs +++ b/scripts/hooks/json-helper.cjs @@ -72,6 +72,15 @@ function parseJsonl(file) { }).filter(Boolean); } +/** Extract artifact display name from its file path. */ +function artifactName(obs) { + const parts = (obs.artifact_path || '').split('/'); + if (obs.type === 'workflow') { + return (parts.pop() || '').replace(/\.md$/, ''); + } + return parts.length >= 2 ? parts[parts.length - 2] : ''; +} + function parseArgs(argList) { const result = {}; const jsonArgs = {}; @@ -287,45 +296,28 @@ try { const created = parsed.filter(o => o.status === 'created' && o.artifact_path); - const commands = created - .filter(o => o.type === 'workflow') - .slice(0, 5) - .map(o => { - const name = o.artifact_path.split('/').pop().replace(/\.md$/, ''); - const conf = (Math.floor(o.confidence * 10) / 10).toString(); - return { name, conf }; - }); - - const skills = created - .filter(o => o.type === 'procedural') - .slice(0, 5) - .map(o => { - const parts = o.artifact_path.split('/'); - const name = parts.length >= 2 ? parts[parts.length - 2] : ''; - const conf = (Math.floor(o.confidence * 10) / 10).toString(); - return { name, conf }; - }); + const formatEntry = o => ({ + name: artifactName(o), + conf: (Math.floor(o.confidence * 10) / 10).toString(), + }); + + const commands = created.filter(o => o.type === 'workflow').slice(0, 5).map(formatEntry); + const skills = created.filter(o => o.type === 'procedural').slice(0, 5).map(formatEntry); console.log(JSON.stringify({ commands, skills })); break; } case 'learning-new': { - // Find new artifacts since epoch const file = args[0]; - // since_epoch argument unused in current implementation — always show created const parsed = parseJsonl(file); const created = parsed.filter(o => o.status === 'created' && o.last_seen); const messages = created.map(o => { - if (o.type === 'workflow') { - const name = o.artifact_path.split('/').pop().replace(/\.md$/, ''); - return `NEW: /self-learning/${name} command created from repeated workflow`; - } else { - const parts = o.artifact_path.split('/'); - const name = parts.length >= 2 ? parts[parts.length - 2] : ''; - return `NEW: ${name} skill created from procedural knowledge`; - } + const name = artifactName(o); + return o.type === 'workflow' + ? `NEW: /self-learning/${name} command created from repeated workflow` + : `NEW: ${name} skill created from procedural knowledge`; }); console.log(messages.join('\n')); diff --git a/scripts/hooks/session-end-learning b/scripts/hooks/session-end-learning index 29d0ba0..1c2de0f 100755 --- a/scripts/hooks/session-end-learning +++ b/scripts/hooks/session-end-learning @@ -14,9 +14,9 @@ set -euo pipefail CWD="${1:-}" CLAUDE_BIN="${2:-claude}" -# --- Guard clauses --- -[ -n "${BG_LEARNER:-}" ] && exit 0 -[ -n "${BG_UPDATER:-}" ] && exit 0 +# --- Guard clauses (break feedback loop from background claude sessions) --- +[ "${DEVFLOW_BG_LEARNER:-}" = "1" ] && exit 0 +[ "${DEVFLOW_BG_UPDATER:-}" = "1" ] && exit 0 [ -z "$CWD" ] && exit 0 MEMORY_DIR="$CWD/.memory" @@ -44,11 +44,9 @@ 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") -# Project slug for logs -PROJECT_SLUG=$(echo "$CWD" | sed 's|/|_|g; s|^_||') -LOG_DIR="$HOME/.devflow/logs/$PROJECT_SLUG" -mkdir -p "$LOG_DIR" -LOG_FILE="$LOG_DIR/.learning-update.log" +# Log path (shared helper — consistent slug with background-learning) +. "$SCRIPT_DIR/log-paths" +LOG_FILE="$(devflow_log_dir "$CWD")/.learning-update.log" log() { if [ "$DEBUG" = "true" ]; then @@ -118,16 +116,7 @@ reinforce_loaded_artifacts() { fi if echo "$loaded" | grep -qF "$slug" 2>/dev/null; then - if [ "$_HAS_JQ" = "true" ]; then - line=$(echo "$line" | jq -c --arg ts "$now_iso" '.last_seen = $ts') - else - # Node fallback: simple string replacement - if echo "$line" | grep -q '"last_seen"' 2>/dev/null; then - line=$(echo "$line" | sed "s/\"last_seen\":\"[^\"]*\"/\"last_seen\":\"$now_iso\"/") - else - line=$(echo "$line" | sed "s/}$/,\"last_seen\":\"$now_iso\"}/") - fi - fi + line=$(echo "$line" | json_update_field "last_seen" "$now_iso") updated=true log "Reinforced: $slug" fi @@ -175,14 +164,14 @@ fi # --- Batch is full: prepare to spawn background learner --- -# Daily cap check +# Daily cap check — tab-separated format: "DATE\tCOUNT" (matches background-learning) RUNS_FILE="$MEMORY_DIR/.learning-runs-today" TODAY=$(date '+%Y-%m-%d') RUNS_TODAY=0 if [ -f "$RUNS_FILE" ]; then - RUNS_DATE=$(head -1 "$RUNS_FILE" 2>/dev/null || echo "") + RUNS_DATE=$(cut -f1 "$RUNS_FILE") if [ "$RUNS_DATE" = "$TODAY" ]; then - RUNS_TODAY=$(tail -1 "$RUNS_FILE" 2>/dev/null || echo "0") + RUNS_TODAY=$(cut -f2 "$RUNS_FILE") fi fi @@ -196,14 +185,13 @@ cp "$SESSION_COUNT_FILE" "$BATCH_IDS_FILE" # Reset session counter rm -f "$SESSION_COUNT_FILE" -# Update daily run count -echo "$TODAY" > "$RUNS_FILE" -echo "$((RUNS_TODAY + 1))" >> "$RUNS_FILE" +# Update daily run count (tab-separated: "DATE\tCOUNT") +printf '%s\t%d\n' "$TODAY" "$((RUNS_TODAY + 1))" > "$RUNS_FILE" log "Triggering batch learning (${CURRENT_COUNT} sessions)" # Spawn background learner -BG_LEARNER=1 nohup bash "$SCRIPT_DIR/background-learning" "$CWD" "--batch" "$CLAUDE_BIN" \ +DEVFLOW_BG_LEARNER=1 nohup bash "$SCRIPT_DIR/background-learning" "$CWD" "--batch" "$CLAUDE_BIN" \ >> "$LOG_FILE" 2>&1 & exit 0 diff --git a/src/cli/commands/learn.ts b/src/cli/commands/learn.ts index 582125b..d0c1566 100644 --- a/src/cli/commands/learn.ts +++ b/src/cli/commands/learn.ts @@ -100,33 +100,19 @@ export function removeLearningHook(settingsJson: string): string { const settings: Settings = JSON.parse(settingsJson); let changed = false; - // Remove from SessionEnd (current) - if (settings.hooks?.SessionEnd) { - const before = settings.hooks.SessionEnd.length; - settings.hooks.SessionEnd = settings.hooks.SessionEnd.filter( - (matcher) => !matcher.hooks.some((h) => h.command.includes(LEARNING_HOOK_MARKER)), + function removeFromEvent(event: 'SessionEnd' | 'Stop', marker: string): void { + const matchers = settings.hooks?.[event]; + if (!matchers) return; + const before = matchers.length; + settings.hooks![event] = matchers.filter( + (m) => !m.hooks.some((h) => h.command.includes(marker)), ); - if (settings.hooks.SessionEnd.length < before) { - changed = true; - } - if (settings.hooks.SessionEnd.length === 0) { - delete settings.hooks.SessionEnd; - } + if (settings.hooks![event]!.length < before) changed = true; + if (settings.hooks![event]!.length === 0) delete settings.hooks![event]; } - // Remove from Stop (legacy cleanup) - if (settings.hooks?.Stop) { - const before = settings.hooks.Stop.length; - settings.hooks.Stop = settings.hooks.Stop.filter( - (matcher) => !matcher.hooks.some((h) => h.command.includes(LEGACY_HOOK_MARKER)), - ); - if (settings.hooks.Stop.length < before) { - changed = true; - } - if (settings.hooks.Stop.length === 0) { - delete settings.hooks.Stop; - } - } + removeFromEvent('SessionEnd', LEARNING_HOOK_MARKER); + removeFromEvent('Stop', LEGACY_HOOK_MARKER); if (!changed) { return settingsJson; From ea79f58fe8c485cb5b7bec3002ded7f7afd365d4 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Wed, 25 Mar 2026 19:18:06 +0200 Subject: [PATCH 05/14] fix: address self-review issues P0-Functionality: session-end-learning must read hook JSON from stdin (like all other hooks) instead of expecting positional args. Without this fix, CWD is always empty and the hook silently exits on every invocation, making the entire learning system non-functional. P1-Error Handling: add || true to json_field calls inside the reinforcement while-loop so a single malformed JSONL line does not crash the script under set -euo pipefail. P1-Functionality: extract session_id from hook JSON (preferred) with ls -t fallback, instead of relying solely on ls -t which could pick a different session's transcript under concurrent session endings. P1-Functionality: remove duplicate daily counter increment from background-learning (session-end-learning already increments before spawning), preventing the effective daily cap from being halved. P2-Consistency: fix configure wizard max_daily_runs default (10 -> 5) to match the new code default. P2-Naming: remove stale $SESSION_ID references from batch-mode log messages in background-learning. --- scripts/hooks/background-learning | 6 ++-- scripts/hooks/session-end-learning | 48 +++++++++++++++++------------- src/cli/commands/learn.ts | 4 +-- 3 files changed, 32 insertions(+), 26 deletions(-) diff --git a/scripts/hooks/background-learning b/scripts/hooks/background-learning index b628b81..5d45403 100755 --- a/scripts/hooks/background-learning +++ b/scripts/hooks/background-learning @@ -396,9 +396,9 @@ run_sonnet_analysis() { if ! wait "$CLAUDE_PID" 2>/dev/null; then EXIT_CODE=$? if [ "$EXIT_CODE" -gt 128 ]; then - log "Analysis timed out (killed after ${TIMEOUT}s) for session $SESSION_ID" + log "Analysis timed out (killed after ${TIMEOUT}s)" else - log "Analysis failed for session $SESSION_ID (exit code $EXIT_CODE)" + log "Analysis failed (exit code $EXIT_CODE)" fi rm -f "$RESPONSE_FILE" kill "$WATCHDOG_PID" 2>/dev/null || true @@ -718,7 +718,7 @@ fi process_observations create_artifacts -increment_daily_counter +# Note: daily counter already incremented by session-end-learning before spawning us log "Learning analysis complete (batch mode)" exit 0 diff --git a/scripts/hooks/session-end-learning b/scripts/hooks/session-end-learning index 1c2de0f..0c91a87 100755 --- a/scripts/hooks/session-end-learning +++ b/scripts/hooks/session-end-learning @@ -11,12 +11,20 @@ # set -euo pipefail -CWD="${1:-}" -CLAUDE_BIN="${2:-claude}" - # --- Guard clauses (break feedback loop from background claude sessions) --- [ "${DEVFLOW_BG_LEARNER:-}" = "1" ] && exit 0 [ "${DEVFLOW_BG_UPDATER:-}" = "1" ] && exit 0 + +# Source json-parse library (must be available before reading stdin JSON) +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=json-parse +. "$SCRIPT_DIR/json-parse" +if [ "$_JSON_AVAILABLE" = "false" ]; then exit 0; fi + +# Read hook input from stdin (Claude passes JSON with cwd, session_id, etc.) +INPUT=$(cat) + +CWD=$(echo "$INPUT" | json_field "cwd" "") [ -z "$CWD" ] && exit 0 MEMORY_DIR="$CWD/.memory" @@ -26,15 +34,9 @@ MEMORY_DIR="$CWD/.memory" LEARNING_CONFIG="$MEMORY_DIR/learning.json" [ ! -f "$LEARNING_CONFIG" ] && exit 0 -# Source json-parse library -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -# shellcheck source=json-parse -. "$SCRIPT_DIR/json-parse" - -# Check JSON tools available -if [ "$_HAS_JQ" != "true" ] && [ "$_HAS_NODE" != "true" ]; then - exit 0 -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") @@ -66,14 +68,18 @@ if [ ! -d "$PROJECTS_DIR" ]; then exit 0 fi -# Find the most recent session transcript -TRANSCRIPT=$(ls -t "$PROJECTS_DIR"/*.jsonl 2>/dev/null | head -1 || true) -if [ -z "$TRANSCRIPT" ] || [ ! -f "$TRANSCRIPT" ]; then - log "No transcript found" - exit 0 +# Extract session ID from hook JSON (preferred), fall back to most recent transcript +SESSION_ID=$(echo "$INPUT" | json_field "session_id" "") +if [ -n "$SESSION_ID" ] && [ -f "$PROJECTS_DIR/${SESSION_ID}.jsonl" ]; then + TRANSCRIPT="$PROJECTS_DIR/${SESSION_ID}.jsonl" +else + TRANSCRIPT=$(ls -t "$PROJECTS_DIR"/*.jsonl 2>/dev/null | head -1 || true) + if [ -z "$TRANSCRIPT" ] || [ ! -f "$TRANSCRIPT" ]; then + log "No transcript found" + exit 0 + fi + SESSION_ID=$(basename "$TRANSCRIPT" .jsonl) fi - -SESSION_ID=$(basename "$TRANSCRIPT" .jsonl) log "Session: $SESSION_ID" # --- Session depth check (min 3 user turns) --- @@ -101,8 +107,8 @@ reinforce_loaded_artifacts() { while IFS= read -r line; do local status artifact_path - status=$(echo "$line" | json_field "status" "") - artifact_path=$(echo "$line" | json_field "artifact_path" "") + status=$(echo "$line" | json_field "status" "" || true) + artifact_path=$(echo "$line" | json_field "artifact_path" "" || true) if [ "$status" = "created" ] && [ -n "$artifact_path" ]; then # Extract slug from path diff --git a/src/cli/commands/learn.ts b/src/cli/commands/learn.ts index d0c1566..8e7d049 100644 --- a/src/cli/commands/learn.ts +++ b/src/cli/commands/learn.ts @@ -366,8 +366,8 @@ export const learnCommand = new Command('learn') const maxRuns = await p.text({ message: 'Maximum background runs per day', - placeholder: '10', - defaultValue: '10', + placeholder: '5', + defaultValue: '5', validate: (v) => { const n = Number(v); if (isNaN(n) || n < 1 || n > 50) return 'Enter a number between 1 and 50'; From b7bcaa4ad5d9783eb3f410f1a0cbc53ae65dc5b6 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Wed, 25 Mar 2026 19:23:56 +0200 Subject: [PATCH 06/14] docs: fix README file tree for learning batch files Replace stale .learning-last-trigger reference with .learning-session-count and .learning-batch-ids to match CLAUDE.md and the new SessionEnd batching implementation. --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index af0586c..5b47513 100644 --- a/README.md +++ b/README.md @@ -236,7 +236,8 @@ DevFlow creates project documentation in `.docs/` and working memory in `.memory ├── learning-log.jsonl # Learning observations (JSONL) ├── learning.json # Project-level learning config ├── .learning-runs-today # Daily run counter -├── .learning-last-trigger # Throttle marker +├── .learning-session-count # Session IDs pending batch (one per line) +├── .learning-batch-ids # Session IDs for current batch run ├── .learning-notified-at # New artifact notification marker └── knowledge/ ├── decisions.md # Architectural decisions (ADR-NNN, append-only) From 1f56cf929c08c6fe1dc947f8b483d8aff162851c Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Wed, 25 Mar 2026 21:31:18 +0200 Subject: [PATCH 07/14] fix: resolve session-end-learning review issues - Replace set -euo pipefail with set -e (consistency with other hooks) - Change . to source for json-parse/log-paths sourcing (consistency) - Fix CWD encoding to match background-learning (strip leading slash) - Use ISO 8601 UTC timestamps in log() (consistency with other hooks) - Remove DEBUG guard from log() (align with unconditional logging in other hooks) - Validate session ID format before appending to batch file - Replace per-line subprocess spawning in reinforce_loaded_artifacts with single-pass jq/node operation - Replace non-atomic cp+rm with atomic mv for batch file handoff - Add disown after background process spawn (consistency with stop-update-memory) - Extract run_batch_check() function from top-level procedural code Co-Authored-By: Claude --- scripts/hooks/session-end-learning | 207 +++++++++++++++++------------ 1 file changed, 123 insertions(+), 84 deletions(-) diff --git a/scripts/hooks/session-end-learning b/scripts/hooks/session-end-learning index 0c91a87..ad2b610 100755 --- a/scripts/hooks/session-end-learning +++ b/scripts/hooks/session-end-learning @@ -9,7 +9,7 @@ # 4. Batch counting: accumulate session IDs, trigger background learner at batch size # 5. Daily cap enforcement # -set -euo pipefail +set -e # --- Guard clauses (break feedback loop from background claude sessions) --- [ "${DEVFLOW_BG_LEARNER:-}" = "1" ] && exit 0 @@ -18,7 +18,7 @@ set -euo pipefail # Source json-parse library (must be available before reading stdin JSON) SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" # shellcheck source=json-parse -. "$SCRIPT_DIR/json-parse" +source "$SCRIPT_DIR/json-parse" if [ "$_JSON_AVAILABLE" = "false" ]; then exit 0; fi # Read hook input from stdin (Claude passes JSON with cwd, session_id, etc.) @@ -47,21 +47,19 @@ 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) -. "$SCRIPT_DIR/log-paths" +source "$SCRIPT_DIR/log-paths" LOG_FILE="$(devflow_log_dir "$CWD")/.learning-update.log" log() { - if [ "$DEBUG" = "true" ]; then - echo "[$(date '+%H:%M:%S')] session-end-learning: $1" >> "$LOG_FILE" - fi + echo "[$(date -u '+%Y-%m-%dT%H:%M:%SZ')] session-end-learning: $1" >> "$LOG_FILE" } log "SessionEnd hook triggered" # --- Find transcript --- # Encode CWD for Claude's project path -ENCODED_CWD=$(echo "$CWD" | sed 's|/|-|g') -PROJECTS_DIR="$HOME/.claude/projects/$ENCODED_CWD" +ENCODED_CWD=$(echo "$CWD" | sed 's|^/||' | tr '/' '-') +PROJECTS_DIR="$HOME/.claude/projects/-${ENCODED_CWD}" if [ ! -d "$PROJECTS_DIR" ]; then log "No projects dir: $PROJECTS_DIR" @@ -102,102 +100,143 @@ reinforce_loaded_artifacts() { local now_iso now_iso=$(date -u '+%Y-%m-%dT%H:%M:%SZ') - local updated=false - local temp_log="${learning_log}.tmp" - while IFS= read -r line; do - local status artifact_path - status=$(echo "$line" | json_field "status" "" || true) - artifact_path=$(echo "$line" | json_field "artifact_path" "" || true) - - if [ "$status" = "created" ] && [ -n "$artifact_path" ]; then - # Extract slug from path - local slug - if echo "$artifact_path" | grep -q '/commands/' 2>/dev/null; then - # Command: .claude/commands/self-learning/{name}.md -> extract {name} - slug=$(basename "$artifact_path" .md) - else - # Skill: .claude/skills/{slug}/SKILL.md -> extract {slug} - slug=$(basename "$(dirname "$artifact_path")") - fi - - if echo "$loaded" | grep -qF "$slug" 2>/dev/null; then - line=$(echo "$line" | json_update_field "last_seen" "$now_iso") - updated=true - log "Reinforced: $slug" - fi - fi - echo "$line" - done < "$learning_log" > "$temp_log" + # Convert loaded slugs to a regex-safe pipe-delimited list for single-pass matching + local slugs_pattern + slugs_pattern=$(echo "$loaded" | sed 's|self-learning[:/]||' | paste -sd '|' -) + [ -z "$slugs_pattern" ] && return - if [ "$updated" = "true" ]; then - mv "$temp_log" "$learning_log" + # Single-pass: use jq if available, otherwise node + local temp_log="${learning_log}.tmp" + if [ "$_HAS_JQ" = "true" ]; then + local updated + updated=$(jq -c --arg now "$now_iso" --arg slugs "$slugs_pattern" ' + def extract_slug: + .artifact_path as $p | + if ($p | contains("/commands/")) then ($p | split("/") | last | rtrimstr(".md")) + else ($p | split("/") | .[-2]) + end; + if .status == "created" and .artifact_path != null and .artifact_path != "" then + extract_slug as $slug | + if ($slugs | split("|") | any(. == $slug)) then .last_seen = $now | . + {"_reinforced": true} + else . + end + else . + end + ' "$learning_log" 2>/dev/null) + + if echo "$updated" | grep -qF '"_reinforced":true' 2>/dev/null; then + # Strip the internal marker and write back + echo "$updated" | jq -c 'del(._reinforced)' > "$temp_log" 2>/dev/null + mv "$temp_log" "$learning_log" + # Log which slugs were reinforced + echo "$updated" | jq -r 'select(._reinforced == true) | .artifact_path' 2>/dev/null | while IFS= read -r path; do + log "Reinforced: $(basename "$(dirname "$path")")" + done + else + rm -f "$temp_log" + fi else - rm -f "$temp_log" + # Node fallback: process entire file at once + node -e " + const fs = require('fs'); + const lines = fs.readFileSync('/dev/stdin', 'utf8').trim().split('\n').filter(Boolean); + const slugs = process.argv[1].split('|'); + const now = process.argv[2]; + let updated = false; + const result = lines.map(line => { + try { + const obj = JSON.parse(line); + if (obj.status === 'created' && obj.artifact_path) { + const slug = obj.artifact_path.includes('/commands/') + ? obj.artifact_path.split('/').pop().replace(/\.md$/, '') + : obj.artifact_path.split('/').slice(-2, -1)[0]; + if (slugs.includes(slug)) { + obj.last_seen = now; + updated = true; + } + } + return JSON.stringify(obj); + } catch (e) { return line; } + }); + if (updated) { + fs.writeFileSync(process.argv[3], result.join('\n') + '\n'); + } + " "$slugs_pattern" "$now_iso" "$learning_log" < "$learning_log" fi } reinforce_loaded_artifacts # --- 3-session batching --- -SESSION_COUNT_FILE="$MEMORY_DIR/.learning-session-count" -BATCH_IDS_FILE="$MEMORY_DIR/.learning-batch-ids" -# Adaptive batch size: 5 if >=15 observations, otherwise default -OBS_TOTAL=0 -if [ -f "$MEMORY_DIR/learning-log.jsonl" ]; then - OBS_TOTAL=$(wc -l < "$MEMORY_DIR/learning-log.jsonl" | tr -d ' ') -fi -if [ "$OBS_TOTAL" -ge 15 ]; then - BATCH_SIZE=5 - log "Adaptive batch size: 5 (${OBS_TOTAL} observations)" -fi +run_batch_check() { + SESSION_COUNT_FILE="$MEMORY_DIR/.learning-session-count" + BATCH_IDS_FILE="$MEMORY_DIR/.learning-batch-ids" -# Append this session ID (deduplicate) -if [ -f "$SESSION_COUNT_FILE" ] && grep -qF "$SESSION_ID" "$SESSION_COUNT_FILE" 2>/dev/null; then - log "Session already counted, skipping" - exit 0 -fi + # Adaptive batch size: 5 if >=15 observations, otherwise default + OBS_TOTAL=0 + if [ -f "$MEMORY_DIR/learning-log.jsonl" ]; then + OBS_TOTAL=$(wc -l < "$MEMORY_DIR/learning-log.jsonl" | tr -d ' ') + fi + if [ "$OBS_TOTAL" -ge 15 ]; then + BATCH_SIZE=5 + log "Adaptive batch size: 5 (${OBS_TOTAL} observations)" + fi -echo "$SESSION_ID" >> "$SESSION_COUNT_FILE" -CURRENT_COUNT=$(wc -l < "$SESSION_COUNT_FILE" | tr -d ' ') -log "Session count: $CURRENT_COUNT / $BATCH_SIZE" + # Validate session ID format before appending + if ! echo "$SESSION_ID" | grep -qE '^[a-zA-Z0-9_-]+$'; then + log "Invalid session ID: $SESSION_ID" + return + fi -if [ "$CURRENT_COUNT" -lt "$BATCH_SIZE" ]; then - log "Batch not full yet ($CURRENT_COUNT < $BATCH_SIZE), waiting" - exit 0 -fi + # Append this session ID (deduplicate) + if [ -f "$SESSION_COUNT_FILE" ] && grep -qF "$SESSION_ID" "$SESSION_COUNT_FILE" 2>/dev/null; then + log "Session already counted, skipping" + return + fi -# --- Batch is full: prepare to spawn background learner --- + echo "$SESSION_ID" >> "$SESSION_COUNT_FILE" + CURRENT_COUNT=$(wc -l < "$SESSION_COUNT_FILE" | tr -d ' ') + log "Session count: $CURRENT_COUNT / $BATCH_SIZE" -# Daily cap check — tab-separated format: "DATE\tCOUNT" (matches background-learning) -RUNS_FILE="$MEMORY_DIR/.learning-runs-today" -TODAY=$(date '+%Y-%m-%d') -RUNS_TODAY=0 -if [ -f "$RUNS_FILE" ]; then - RUNS_DATE=$(cut -f1 "$RUNS_FILE") - if [ "$RUNS_DATE" = "$TODAY" ]; then - RUNS_TODAY=$(cut -f2 "$RUNS_FILE") + if [ "$CURRENT_COUNT" -lt "$BATCH_SIZE" ]; then + log "Batch not full yet ($CURRENT_COUNT < $BATCH_SIZE), waiting" + return fi -fi -if [ "$RUNS_TODAY" -ge "$MAX_DAILY" ]; then - log "Daily cap reached ($RUNS_TODAY >= $MAX_DAILY), skipping" - exit 0 -fi + # --- Batch is full: prepare to spawn background learner --- + + # Daily cap check — tab-separated format: "DATE\tCOUNT" (matches background-learning) + RUNS_FILE="$MEMORY_DIR/.learning-runs-today" + TODAY=$(date '+%Y-%m-%d') + RUNS_TODAY=0 + if [ -f "$RUNS_FILE" ]; then + RUNS_DATE=$(cut -f1 "$RUNS_FILE") + if [ "$RUNS_DATE" = "$TODAY" ]; then + RUNS_TODAY=$(cut -f2 "$RUNS_FILE") + fi + fi + + if [ "$RUNS_TODAY" -ge "$MAX_DAILY" ]; then + log "Daily cap reached ($RUNS_TODAY >= $MAX_DAILY), skipping" + return + fi + + # Atomically hand off batch IDs to background-learning (mv is atomic on same filesystem) + mv "$SESSION_COUNT_FILE" "$BATCH_IDS_FILE" -# Write batch IDs file for background-learning to consume -cp "$SESSION_COUNT_FILE" "$BATCH_IDS_FILE" -# Reset session counter -rm -f "$SESSION_COUNT_FILE" + # Update daily run count (tab-separated: "DATE\tCOUNT") + printf '%s\t%d\n' "$TODAY" "$((RUNS_TODAY + 1))" > "$RUNS_FILE" -# Update daily run count (tab-separated: "DATE\tCOUNT") -printf '%s\t%d\n' "$TODAY" "$((RUNS_TODAY + 1))" > "$RUNS_FILE" + log "Triggering batch learning (${CURRENT_COUNT} sessions)" -log "Triggering batch learning (${CURRENT_COUNT} sessions)" + # Spawn background learner + DEVFLOW_BG_LEARNER=1 nohup bash "$SCRIPT_DIR/background-learning" "$CWD" "--batch" "$CLAUDE_BIN" \ + >> "$LOG_FILE" 2>&1 & + disown +} -# Spawn background learner -DEVFLOW_BG_LEARNER=1 nohup bash "$SCRIPT_DIR/background-learning" "$CWD" "--batch" "$CLAUDE_BIN" \ - >> "$LOG_FILE" 2>&1 & +run_batch_check exit 0 From 83af508a67c3a32aaa60e7b31543b08c57f9050a Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Wed, 25 Mar 2026 21:33:56 +0200 Subject: [PATCH 08/14] =?UTF-8?q?fix:=20resolve=20learn.ts=20review=20issu?= =?UTF-8?q?es=20=E2=80=94=20legacy=20detection,=20self-upgrading=20enable,?= =?UTF-8?q?=20batch=5Fsize=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - hasLearningHook now returns 'current' | 'legacy' | false to detect pre-Wave-2 users with Stop hook containing stop-update-learning - addLearningHook is self-upgrading: calls removeLearningHook first to clean up legacy Stop hooks before adding SessionEnd hook - formatLearningStatus shows legacy upgrade instructions when detected - Added batch_size to LearningConfig interface, defaults (3), and applyConfigLayer; added to --configure wizard - Updated tests: 59 total including new legacy detection, self-upgrading enable, batch_size config, and type guard tests Co-Authored-By: Claude --- src/cli/commands/learn.ts | 76 +++++++++++++++++----- tests/learn.test.ts | 128 +++++++++++++++++++++++++++++++++----- 2 files changed, 174 insertions(+), 30 deletions(-) diff --git a/src/cli/commands/learn.ts b/src/cli/commands/learn.ts index 8e7d049..db2468a 100644 --- a/src/cli/commands/learn.ts +++ b/src/cli/commands/learn.ts @@ -31,6 +31,8 @@ export interface LearningConfig { throttle_minutes: number; model: string; debug: boolean; + /** Number of observations processed per learning run. Default 3, adaptive 5 at 15+ observations. */ + batch_size: number; } /** @@ -56,15 +58,20 @@ const LEGACY_HOOK_MARKER = 'stop-update-learning'; /** * Add the learning SessionEnd hook to settings JSON. - * Idempotent — returns unchanged JSON if hook already exists. + * Idempotent — returns unchanged JSON if current hook already exists. + * Self-upgrading — removes legacy Stop hook before adding SessionEnd hook. */ export function addLearningHook(settingsJson: string, devflowDir: string): string { - const settings: Settings = JSON.parse(settingsJson); + const hookState = hasLearningHook(settingsJson); - if (hasLearningHook(settingsJson)) { + if (hookState === 'current') { return settingsJson; } + // Remove any legacy Stop hooks before adding the new SessionEnd hook + const cleanedJson = removeLearningHook(settingsJson); + const settings: Settings = JSON.parse(cleanedJson); + if (!settings.hooks) { settings.hooks = {}; } @@ -127,17 +134,28 @@ export function removeLearningHook(settingsJson: string): string { /** * Check if the learning hook is registered in settings JSON. + * Returns 'current' for SessionEnd hook, 'legacy' for old Stop hook, or false if absent. */ -export function hasLearningHook(settingsJson: string): boolean { +export function hasLearningHook(settingsJson: string): 'current' | 'legacy' | false { const settings: Settings = JSON.parse(settingsJson); - if (!settings.hooks?.SessionEnd) { - return false; + const hasSessionEnd = settings.hooks?.SessionEnd?.some((matcher) => + matcher.hooks.some((h) => h.command.includes(LEARNING_HOOK_MARKER)), + ) ?? false; + + if (hasSessionEnd) { + return 'current'; } - return settings.hooks.SessionEnd.some((matcher) => - matcher.hooks.some((h) => h.command.includes(LEARNING_HOOK_MARKER)), - ); + const hasLegacyStop = settings.hooks?.Stop?.some((matcher) => + matcher.hooks.some((h) => h.command.includes(LEGACY_HOOK_MARKER)), + ) ?? false; + + if (hasLegacyStop) { + return 'legacy'; + } + + return false; } /** @@ -183,11 +201,16 @@ export function loadAndCountObservations(logContent: string): { /** * Format a human-readable status summary for learning state. + * hookState: 'current' (SessionEnd), 'legacy' (old Stop hook), or false (disabled). */ -export function formatLearningStatus(observations: LearningObservation[], hookEnabled: boolean): string { +export function formatLearningStatus(observations: LearningObservation[], hookState: 'current' | 'legacy' | false): string { const lines: string[] = []; - lines.push(`Self-learning: ${hookEnabled ? 'enabled' : 'disabled'}`); + if (hookState === 'legacy') { + lines.push('Self-learning: enabled (legacy — run `devflow learn --disable && devflow learn --enable` to upgrade)'); + } else { + lines.push(`Self-learning: ${hookState ? 'enabled' : 'disabled'}`); + } if (observations.length === 0) { lines.push('Observations: none'); @@ -212,7 +235,7 @@ export function formatLearningStatus(observations: LearningObservation[], hookEn * 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 +// Synced fields: max_daily_runs, throttle_minutes, model, debug, batch_size export function applyConfigLayer(config: LearningConfig, json: string): LearningConfig { try { const raw = JSON.parse(json) as Record; @@ -221,6 +244,7 @@ export function applyConfigLayer(config: LearningConfig, json: string): Learning throttle_minutes: typeof raw.throttle_minutes === 'number' ? raw.throttle_minutes : config.throttle_minutes, model: typeof raw.model === 'string' ? raw.model : config.model, debug: typeof raw.debug === 'boolean' ? raw.debug : config.debug, + batch_size: typeof raw.batch_size === 'number' ? raw.batch_size : config.batch_size, }; } catch { return { ...config }; @@ -237,6 +261,7 @@ export function loadLearningConfig(globalJson: string | null, projectJson: strin throttle_minutes: 5, model: 'sonnet', debug: false, + batch_size: 3, }; if (globalJson) config = applyConfigLayer(config, globalJson); @@ -298,7 +323,7 @@ export const learnCommand = new Command('learn') // --- --status --- if (options.status) { - const hookEnabled = hasLearningHook(settingsContent); + const hookState = hasLearningHook(settingsContent); const cwd = process.cwd(); const logPath = path.join(cwd, '.memory', 'learning-log.jsonl'); @@ -311,7 +336,7 @@ export const learnCommand = new Command('learn') // No log file yet } - const status = formatLearningStatus(observations, hookEnabled); + const status = formatLearningStatus(observations, hookState); p.log.info(status); if (invalidCount > 0) { p.log.warn(`Note: ${invalidCount} invalid entry(ies) found. Run 'devflow learn --purge' to clean.`); @@ -416,6 +441,21 @@ export const learnCommand = new Command('learn') return; } + const batchSize = await p.text({ + message: 'Observations per learning run (adaptive: 5 at 15+ observations)', + placeholder: '3', + defaultValue: '3', + validate: (v) => { + const n = Number(v); + if (isNaN(n) || n < 1 || n > 20) return 'Enter a number between 1 and 20'; + return undefined; + }, + }); + if (p.isCancel(batchSize)) { + p.cancel('Configuration cancelled.'); + return; + } + const scope = await p.select({ message: 'Configuration scope', options: [ @@ -433,6 +473,7 @@ export const learnCommand = new Command('learn') throttle_minutes: Number(throttle), model: String(model), debug: !!debugMode, + batch_size: Number(batchSize), }; const configJson = JSON.stringify(config, null, 2) + '\n'; @@ -528,13 +569,18 @@ export const learnCommand = new Command('learn') } if (options.enable) { + const priorState = hasLearningHook(settingsContent); const updated = addLearningHook(settingsContent, devflowDir); if (updated === settingsContent) { p.log.info('Self-learning already enabled'); return; } await fs.writeFile(settingsPath, updated, 'utf-8'); - p.log.success('Self-learning enabled — SessionEnd hook registered'); + if (priorState === 'legacy') { + p.log.success('Self-learning upgraded — legacy Stop hook replaced with SessionEnd hook'); + } else { + p.log.success('Self-learning enabled — SessionEnd hook registered'); + } p.log.info(color.dim('Repeated workflows will be detected and turned into slash commands')); } diff --git a/tests/learn.test.ts b/tests/learn.test.ts index 789a7e8..383efef 100644 --- a/tests/learn.test.ts +++ b/tests/learn.test.ts @@ -77,6 +77,46 @@ describe('addLearningHook', () => { expect(settings.hooks.SessionEnd).toHaveLength(2); expect(settings.hooks.UserPromptSubmit).toHaveLength(1); }); + + it('self-upgrades legacy Stop hook to SessionEnd', () => { + const input = JSON.stringify({ + hooks: { + Stop: [ + { hooks: [{ type: 'command', command: 'stop-update-memory' }] }, + { hooks: [{ type: 'command', command: '/old/path/stop-update-learning' }] }, + ], + }, + }); + const result = addLearningHook(input, '/home/user/.devflow'); + const settings = JSON.parse(result); + + // Legacy Stop learning hook removed, memory hook preserved + expect(settings.hooks.Stop).toHaveLength(1); + expect(settings.hooks.Stop[0].hooks[0].command).toBe('stop-update-memory'); + // New SessionEnd hook added + expect(settings.hooks.SessionEnd).toHaveLength(1); + expect(settings.hooks.SessionEnd[0].hooks[0].command).toContain('session-end-learning'); + }); + + it('self-upgrades legacy Stop hook and preserves other events', () => { + const input = JSON.stringify({ + hooks: { + Stop: [ + { hooks: [{ type: 'command', command: '/old/path/stop-update-learning' }] }, + ], + UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'ambient-prompt' }] }], + }, + }); + const result = addLearningHook(input, '/home/user/.devflow'); + const settings = JSON.parse(result); + + // Legacy Stop hook removed entirely (was the only Stop entry) + expect(settings.hooks.Stop).toBeUndefined(); + // New SessionEnd hook added + expect(settings.hooks.SessionEnd).toHaveLength(1); + // Other hooks preserved + expect(settings.hooks.UserPromptSubmit).toHaveLength(1); + }); }); describe('removeLearningHook', () => { @@ -180,9 +220,9 @@ describe('removeLearningHook', () => { }); describe('hasLearningHook', () => { - it('returns true when present on SessionEnd', () => { + it('returns current when present on SessionEnd', () => { const withHook = addLearningHook('{}', '/home/user/.devflow'); - expect(hasLearningHook(withHook)).toBe(true); + expect(hasLearningHook(withHook)).toBe('current'); }); it('returns false when absent', () => { @@ -200,7 +240,7 @@ describe('hasLearningHook', () => { expect(hasLearningHook(input)).toBe(false); }); - it('returns true among other SessionEnd hooks', () => { + it('returns current among other SessionEnd hooks', () => { const input = JSON.stringify({ hooks: { SessionEnd: [ @@ -209,10 +249,10 @@ describe('hasLearningHook', () => { ], }, }); - expect(hasLearningHook(input)).toBe(true); + expect(hasLearningHook(input)).toBe('current'); }); - it('returns false for legacy Stop hook only', () => { + it('returns legacy for Stop hook with stop-update-learning', () => { const input = JSON.stringify({ hooks: { Stop: [ @@ -220,6 +260,31 @@ describe('hasLearningHook', () => { ], }, }); + expect(hasLearningHook(input)).toBe('legacy'); + }); + + it('returns current when both SessionEnd and legacy Stop present', () => { + const input = JSON.stringify({ + hooks: { + SessionEnd: [ + { hooks: [{ type: 'command', command: '/path/to/session-end-learning' }] }, + ], + Stop: [ + { hooks: [{ type: 'command', command: '/path/to/stop-update-learning' }] }, + ], + }, + }); + expect(hasLearningHook(input)).toBe('current'); + }); + + it('returns false for non-learning Stop hooks', () => { + const input = JSON.stringify({ + hooks: { + Stop: [ + { hooks: [{ type: 'command', command: 'stop-update-memory' }] }, + ], + }, + }); expect(hasLearningHook(input)).toBe(false); }); }); @@ -263,9 +328,10 @@ describe('parseLearningLog', () => { }); describe('formatLearningStatus', () => { - it('shows enabled state', () => { - const result = formatLearningStatus([], true); + it('shows enabled state for current hook', () => { + const result = formatLearningStatus([], 'current'); expect(result).toContain('enabled'); + expect(result).not.toContain('legacy'); }); it('shows disabled state', () => { @@ -273,13 +339,19 @@ describe('formatLearningStatus', () => { expect(result).toContain('disabled'); }); + it('shows legacy upgrade message for legacy hook', () => { + const result = formatLearningStatus([], 'legacy'); + expect(result).toContain('legacy'); + expect(result).toContain('devflow learn --disable && devflow learn --enable'); + }); + it('shows observation counts', () => { const observations: LearningObservation[] = [ { id: 'obs_1', type: 'workflow', pattern: 'p1', confidence: 0.33, observations: 1, first_seen: 't', last_seen: 't', status: 'observing', evidence: [], details: 'd' }, { id: 'obs_2', type: 'procedural', pattern: 'p2', confidence: 0.50, observations: 1, first_seen: 't', last_seen: 't', status: 'observing', evidence: [], details: 'd' }, { id: 'obs_3', type: 'workflow', pattern: 'p3', confidence: 0.95, observations: 3, first_seen: 't', last_seen: 't', status: 'ready', evidence: [], details: 'd' }, ]; - const result = formatLearningStatus(observations, true); + const result = formatLearningStatus(observations, 'current'); expect(result).toContain('3 total'); expect(result).toContain('Workflows: 2'); expect(result).toContain('Procedural: 1'); @@ -290,13 +362,13 @@ describe('formatLearningStatus', () => { { id: 'obs_1', type: 'workflow', pattern: 'p1', confidence: 0.95, observations: 3, first_seen: 't', last_seen: 't', status: 'created', evidence: [], details: 'd', artifact_path: '/path' }, { id: 'obs_2', type: 'procedural', pattern: 'p2', confidence: 0.50, observations: 1, first_seen: 't', last_seen: 't', status: 'observing', evidence: [], details: 'd' }, ]; - const result = formatLearningStatus(observations, true); + const result = formatLearningStatus(observations, 'current'); expect(result).toContain('1 promoted'); expect(result).toContain('1 observing'); }); it('handles empty observations', () => { - const result = formatLearningStatus([], true); + const result = formatLearningStatus([], 'current'); expect(result).toContain('none'); }); }); @@ -308,6 +380,7 @@ describe('loadLearningConfig', () => { expect(config.throttle_minutes).toBe(5); expect(config.model).toBe('sonnet'); expect(config.debug).toBe(false); + expect(config.batch_size).toBe(3); }); it('loads global config', () => { @@ -316,6 +389,7 @@ describe('loadLearningConfig', () => { expect(config.max_daily_runs).toBe(20); expect(config.throttle_minutes).toBe(5); // default preserved expect(config.model).toBe('haiku'); + expect(config.batch_size).toBe(3); // default preserved }); it('project config overrides global', () => { @@ -333,6 +407,18 @@ describe('loadLearningConfig', () => { expect(config.throttle_minutes).toBe(15); // overridden expect(config.model).toBe('sonnet'); // default }); + + it('loads batch_size from config', () => { + const projectJson = JSON.stringify({ batch_size: 5 }); + const config = loadLearningConfig(null, projectJson); + expect(config.batch_size).toBe(5); + }); + + it('ignores non-numeric batch_size', () => { + const projectJson = JSON.stringify({ batch_size: 'large' }); + const config = loadLearningConfig(null, projectJson); + expect(config.batch_size).toBe(3); // default preserved + }); }); describe('isLearningObservation', () => { @@ -439,35 +525,47 @@ describe('parseLearningLog — type guard filtering', () => { describe('applyConfigLayer — immutability', () => { it('returns new object without mutating input', () => { - const original = { max_daily_runs: 10, throttle_minutes: 5, model: 'sonnet', debug: false }; + const original = { max_daily_runs: 10, throttle_minutes: 5, model: 'sonnet', debug: false, batch_size: 3 }; const result = applyConfigLayer(original, JSON.stringify({ max_daily_runs: 20 })); expect(result.max_daily_runs).toBe(20); expect(original.max_daily_runs).toBe(10); // not mutated }); it('returns copy on invalid JSON', () => { - const original = { max_daily_runs: 10, throttle_minutes: 5, model: 'sonnet', debug: false }; + const original = { max_daily_runs: 10, throttle_minutes: 5, model: 'sonnet', debug: false, batch_size: 3 }; const result = applyConfigLayer(original, 'not json'); expect(result).toEqual(original); expect(result).not.toBe(original); // different reference }); it('ignores wrong-typed fields', () => { - const original = { max_daily_runs: 10, throttle_minutes: 5, model: 'sonnet', debug: false }; + const original = { max_daily_runs: 10, throttle_minutes: 5, model: 'sonnet', debug: false, batch_size: 3 }; const result = applyConfigLayer(original, JSON.stringify({ max_daily_runs: 'lots', model: 42 })); expect(result.max_daily_runs).toBe(10); expect(result.model).toBe('sonnet'); }); it('applies debug field when boolean', () => { - const original = { max_daily_runs: 10, throttle_minutes: 5, model: 'sonnet', debug: false }; + const original = { max_daily_runs: 10, throttle_minutes: 5, model: 'sonnet', debug: false, batch_size: 3 }; const result = applyConfigLayer(original, JSON.stringify({ debug: true })); expect(result.debug).toBe(true); }); it('ignores debug field when non-boolean', () => { - const original = { max_daily_runs: 10, throttle_minutes: 5, model: 'sonnet', debug: false }; + const original = { max_daily_runs: 10, throttle_minutes: 5, model: 'sonnet', debug: false, batch_size: 3 }; const result = applyConfigLayer(original, JSON.stringify({ debug: 'yes' })); expect(result.debug).toBe(false); }); + + it('applies batch_size field when number', () => { + const original = { max_daily_runs: 10, throttle_minutes: 5, model: 'sonnet', debug: false, batch_size: 3 }; + const result = applyConfigLayer(original, JSON.stringify({ batch_size: 5 })); + expect(result.batch_size).toBe(5); + }); + + it('ignores batch_size field when non-number', () => { + const original = { max_daily_runs: 10, throttle_minutes: 5, model: 'sonnet', debug: false, batch_size: 3 }; + const result = applyConfigLayer(original, JSON.stringify({ batch_size: 'large' })); + expect(result.batch_size).toBe(3); + }); }); From e68458951ecbd48c086b4dffcecb504100278eb9 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Wed, 25 Mar 2026 21:44:30 +0200 Subject: [PATCH 09/14] fix: resolve background-learning review issues - Replace per-line subprocess spawning in extract_batch_messages with single-pass jq/node processing (issue #1) - Decompose process_observations into validate_observation, calculate_confidence, and check_temporal_spread helpers (issue #2) - Fix duplicate temporal spread calculation by computing epoch once in check_temporal_spread (issue #3) - Escape double quotes in ART_DESC for YAML frontmatter safety (issue #4) - Strengthen ART_NAME sanitization with strict kebab-case allowlist (issue #5) - Replace per-line subprocess in apply_temporal_decay with single-pass jq operation and node fallback (issue #6) - Replace per-line subprocess in create_artifacts status update with single-pass jq/node operation (issue #7) - Remove dead increment_daily_counter function (issue #8) - Extract write_command_artifact and write_skill_artifact helpers from create_artifacts (issue #9) - Change flat 30k char truncation to per-session 8k char cap for proportional session contribution (issue #10) - Add section comment markers to build_sonnet_prompt heredoc for navigability (issue #11) --- scripts/hooks/background-learning | 351 +++++++++++++++++++----------- 1 file changed, 223 insertions(+), 128 deletions(-) diff --git a/scripts/hooks/background-learning b/scripts/hooks/background-learning index 5d45403..0ec7ab1 100755 --- a/scripts/hooks/background-learning +++ b/scripts/hooks/background-learning @@ -128,15 +128,6 @@ check_daily_cap() { return 0 } -increment_daily_counter() { - TODAY=$(date +%Y-%m-%d) - COUNT=1 - if [ -f "$COUNTER_FILE" ] && [ "$(cut -f1 "$COUNTER_FILE")" = "$TODAY" ]; then - COUNT=$(( $(cut -f2 "$COUNTER_FILE") + 1 )) - fi - printf '%s\t%d\n' "$TODAY" "$COUNT" > "$COUNTER_FILE" -} - # --- Temporal Decay --- decay_factor() { @@ -171,12 +162,40 @@ extract_batch_messages() { continue fi + # Single-pass extraction: pipe all user-type lines through one jq/node process local session_msgs - session_msgs=$(grep '"type":"user"' "$transcript" 2>/dev/null \ - | while IFS= read -r line; do echo "$line" | json_extract_messages; done \ - | grep -v '^$' || true) + if [ "$_HAS_JQ" = "true" ]; then + session_msgs=$(grep '"type":"user"' "$transcript" 2>/dev/null \ + | jq -r 'if .message.content then + if (.message.content | type) == "string" then .message.content + else [.message.content[] | select(.type == "text") | .text] | join("\n") + end + else "" end' 2>/dev/null \ + | grep -v '^$' || true) + else + session_msgs=$(grep '"type":"user"' "$transcript" 2>/dev/null \ + | node -e " + const lines = require('fs').readFileSync('/dev/stdin','utf8').trim().split('\n'); + for (const line of lines) { + try { + const d = JSON.parse(line); + const c = d && d.message && d.message.content; + if (typeof c === 'string') { if (c) console.log(c); } + else if (Array.isArray(c)) { + const t = c.filter(x=>x.type==='text').map(x=>x.text).join('\n'); + if (t) console.log(t); + } + } catch {} + } + " 2>/dev/null \ + | grep -v '^$' || true) + fi if [ -n "$session_msgs" ]; then + # Per-session cap: 8,000 chars ensures each session contributes proportionally + if [ ${#session_msgs} -gt 8000 ]; then + session_msgs="${session_msgs:0:8000}... [truncated]" + fi if [ -n "$USER_MESSAGES" ]; then USER_MESSAGES="${USER_MESSAGES} --- Session ${sid} --- @@ -192,11 +211,6 @@ ${session_msgs}" # Clean up batch file after reading rm -f "$batch_file" - # Truncate to 30,000 chars (multi-session) - if [ ${#USER_MESSAGES} -gt 30000 ]; then - USER_MESSAGES="${USER_MESSAGES:0:30000}... [truncated]" - fi - if [ -z "$USER_MESSAGES" ]; then log "No user text content found in batch transcripts" return 1 @@ -218,39 +232,55 @@ apply_temporal_decay() { NOW_EPOCH=$(date +%s) TEMP_FILE="$LEARNING_LOG.tmp" - > "$TEMP_FILE" - while IFS= read -r line; do - if ! echo "$line" | json_valid; then continue; fi - - LAST_SEEN=$(echo "$line" | json_field "last_seen" "") - CONF=$(echo "$line" | json_field "confidence" "0") - - if [ -n "$LAST_SEEN" ]; then - LAST_EPOCH=$(date -j -f "%Y-%m-%dT%H:%M:%SZ" "$LAST_SEEN" +%s 2>/dev/null \ - || date -d "$LAST_SEEN" +%s 2>/dev/null \ - || echo "$NOW_EPOCH") - DAYS_ELAPSED=$(( (NOW_EPOCH - LAST_EPOCH) / 86400 )) - PERIODS=$(( DAYS_ELAPSED / 30 )) - - if [ "$PERIODS" -gt 0 ]; then - FACTOR=$(decay_factor "$PERIODS") - CONF_INT=$(echo "$CONF" | awk '{printf "%d", $1 * 100}') - NEW_CONF_INT=$(( CONF_INT * FACTOR / 100 )) - NEW_CONF=$(echo "$NEW_CONF_INT" | awk '{printf "%.2f", $1 / 100}') - - if [ "$NEW_CONF_INT" -lt 10 ]; then - log "Pruned observation (confidence: $NEW_CONF)" - continue - fi - - line=$(echo "$line" | json_update_field_json "confidence" "$NEW_CONF") - fi - fi - - echo "$line" >> "$TEMP_FILE" - done < "$LEARNING_LOG" - mv "$TEMP_FILE" "$LEARNING_LOG" + if [ "$_HAS_JQ" = "true" ]; then + # Single-pass jq: apply decay to all entries at once + jq -c --argjson now "$NOW_EPOCH" ' + def decay_factor(p): if p <= 0 then 1.0 elif p == 1 then 0.90 elif p == 2 then 0.81 + elif p == 3 then 0.73 elif p == 4 then 0.66 elif p == 5 then 0.59 else 0.53 end; + def parse_epoch: split("T")[0] | split("-") | map(tonumber) | + ((.[0] - 1970) * 365.25 * 86400 + (.[1] - 1) * 30.44 * 86400 + (.[2] - 1) * 86400) | floor; + if .last_seen and .last_seen != "" then + (.last_seen | parse_epoch) as $last_epoch | + ((($now - $last_epoch) / 86400) | floor) as $days | + (($days / 30) | floor) as $periods | + if $periods > 0 then + decay_factor($periods) as $factor | + ((.confidence * $factor * 100 | round) / 100) as $new_conf | + if ($new_conf * 100) < 10 then empty + else .confidence = $new_conf end + else . end + else . end + ' "$LEARNING_LOG" > "$TEMP_FILE" 2>/dev/null + mv "$TEMP_FILE" "$LEARNING_LOG" + else + # Node fallback: single-pass processing + node -e " + const fs = require('fs'); + const lines = fs.readFileSync('$LEARNING_LOG','utf8').trim().split('\n').filter(Boolean); + const now = $NOW_EPOCH; + const factors = [1.0, 0.90, 0.81, 0.73, 0.66, 0.59, 0.53]; + const results = []; + for (const line of lines) { + try { + const obj = JSON.parse(line); + if (obj.last_seen) { + const lastEpoch = Math.floor(new Date(obj.last_seen).getTime() / 1000); + const days = Math.floor((now - lastEpoch) / 86400); + const periods = Math.floor(days / 30); + if (periods > 0) { + const factor = periods < factors.length ? factors[periods] : 0.53; + const newConf = Math.round(obj.confidence * factor * 100) / 100; + if (newConf * 100 < 10) continue; + obj.confidence = newConf; + } + } + results.push(JSON.stringify(obj)); + } catch {} + } + fs.writeFileSync('$LEARNING_LOG', results.join('\n') + '\n'); + " 2>/dev/null + fi } # --- Entry Cap --- @@ -285,12 +315,16 @@ build_sonnet_prompt() { PROMPT="You are a pattern detection agent. Analyze the user's session messages to identify repeated workflows and procedural knowledge. +# === CONTEXT === + EXISTING OBSERVATIONS (for deduplication — reuse IDs for matching patterns): $EXISTING_OBS USER MESSAGES FROM RECENT SESSIONS: $USER_MESSAGES +# === OBSERVATION RULES === + 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. @@ -310,6 +344,8 @@ Rules: - Only report patterns that are clearly distinct — do not create near-duplicate observations - If no patterns detected, return {\"observations\": [], \"artifacts\": []} +# === SKILL TEMPLATE === + SKILL TEMPLATE (required structure when creating skill artifacts): --- @@ -340,15 +376,21 @@ allowed-tools: Read, Grep, Glob {Practical patterns, rules, or procedures.} +# === COMMAND TEMPLATE === + COMMAND TEMPLATE (when creating command artifacts): Standard markdown with description frontmatter. +# === NAMING RULES === + NAMING RULES: - Skill names: self-learning:{slug} (e.g., self-learning:debug-hooks) - Skill descriptions MUST start with \"This skill should be used when...\" - Do NOT include project-specific prefixes in the slug - Keep slugs short and descriptive (2-3 words kebab-case) +# === OUTPUT FORMAT === + Output ONLY the JSON object. No markdown fences, no explanation. { @@ -442,6 +484,75 @@ run_sonnet_analysis() { # --- Process Observations --- +# Validate observation fields. Sets OBS_ID, OBS_TYPE, OBS_PATTERN, OBS_EVIDENCE, OBS_DETAILS. +# Returns 1 if invalid (caller should skip). +validate_observation() { + local obs_json="$1" obs_index="$2" + + OBS_ID=$(echo "$obs_json" | json_field "id" "") + OBS_TYPE=$(echo "$obs_json" | json_field "type" "") + OBS_PATTERN=$(echo "$obs_json" | json_field "pattern" "") + OBS_DETAILS=$(echo "$obs_json" | json_field "details" "") + + # Evidence needs to stay as JSON array for merging later + if [ "$_HAS_JQ" = "true" ]; then + OBS_EVIDENCE=$(echo "$obs_json" | jq -c '.evidence' 2>/dev/null) + else + OBS_EVIDENCE=$(echo "$obs_json" | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));console.log(JSON.stringify(d.evidence||[]))" 2>/dev/null) + fi + + if [ -z "$OBS_ID" ] || [ -z "$OBS_TYPE" ] || [ -z "$OBS_PATTERN" ]; then + log "Skipping observation $obs_index: empty required field (id='$OBS_ID' type='$OBS_TYPE')" + return 1 + fi + if [ "$OBS_TYPE" != "workflow" ] && [ "$OBS_TYPE" != "procedural" ]; then + log "Skipping observation $obs_index: invalid type '$OBS_TYPE'" + return 1 + fi + case "$OBS_ID" in + obs_*) ;; + *) log "Skipping observation $obs_index: invalid id format '$OBS_ID'"; return 1 ;; + esac + + return 0 +} + +# Compute confidence score from observation count. +# Sets CONF_RAW (integer 0-95) and CONF (decimal string e.g. "0.66"). +calculate_confidence() { + local count="$1" + local required=3 + CONF_RAW=$((count * 100 / required)) + if [ "$CONF_RAW" -gt 95 ]; then CONF_RAW=95; fi + CONF=$(echo "$CONF_RAW" | awk '{printf "%.2f", $1 / 100}') +} + +# Check temporal spread and determine observation status. +# Computes epoch once and checks both "observing" and "ready" thresholds. +# Sets STATUS. +check_temporal_spread() { + local first_seen="$1" conf_raw="$2" current_status="$3" + + STATUS="$current_status" + if [ "$STATUS" = "created" ]; then return; fi + + if [ "$conf_raw" -lt 70 ]; then return; fi + + local first_epoch + first_epoch=$(date -j -f "%Y-%m-%dT%H:%M:%SZ" "$first_seen" +%s 2>/dev/null \ + || date -d "$first_seen" +%s 2>/dev/null \ + || echo "0") + local now_epoch + now_epoch=$(date +%s) + local spread=$((now_epoch - first_epoch)) + + if [ "$spread" -ge 86400 ]; then + STATUS="ready" + else + STATUS="observing" + fi +} + process_observations() { log "--- Processing response ---" @@ -453,31 +564,12 @@ process_observations() { NOW_ISO=$(date -u '+%Y-%m-%dT%H:%M:%SZ') for i in $(seq 0 $((OBS_COUNT - 1))); do + local OBS OBS=$(echo "$RESPONSE" | json_array_item "observations" "$i") - OBS_ID=$(echo "$OBS" | json_field "id" "") - OBS_TYPE=$(echo "$OBS" | json_field "type" "") - OBS_PATTERN=$(echo "$OBS" | json_field "pattern" "") - # Evidence needs to stay as JSON array for merging later - if [ "$_HAS_JQ" = "true" ]; then - OBS_EVIDENCE=$(echo "$OBS" | jq -c '.evidence' 2>/dev/null) - else - OBS_EVIDENCE=$(echo "$OBS" | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));console.log(JSON.stringify(d.evidence||[]))" 2>/dev/null) - fi - OBS_DETAILS=$(echo "$OBS" | json_field "details" "") - # Validate required fields - if [ -z "$OBS_ID" ] || [ -z "$OBS_TYPE" ] || [ -z "$OBS_PATTERN" ]; then - log "Skipping observation $i: empty required field (id='$OBS_ID' type='$OBS_TYPE')" + if ! validate_observation "$OBS" "$i"; then continue fi - if [ "$OBS_TYPE" != "workflow" ] && [ "$OBS_TYPE" != "procedural" ]; then - log "Skipping observation $i: invalid type '$OBS_TYPE'" - continue - fi - case "$OBS_ID" in - obs_*) ;; - *) log "Skipping observation $i: invalid id format '$OBS_ID'"; continue ;; - esac # Check if observation already exists EXISTING_LINE="" @@ -497,37 +589,13 @@ process_observations() { fi MERGED_EVIDENCE=$(echo "[$OLD_EVIDENCE, $OBS_EVIDENCE]" | json_merge_evidence) - # Calculate confidence (both types require 3 observations) - REQUIRED=3 - CONF_RAW=$((NEW_COUNT * 100 / REQUIRED)) - if [ "$CONF_RAW" -gt 95 ]; then CONF_RAW=95; fi - CONF=$(echo "$CONF_RAW" | awk '{printf "%.2f", $1 / 100}') - - # Check temporal spread (applies to BOTH workflow and procedural) - STATUS=$(echo "$EXISTING_LINE" | json_field "status" "") - if [ "$STATUS" != "created" ]; then - FIRST_EPOCH=$(date -j -f "%Y-%m-%dT%H:%M:%SZ" "$FIRST_SEEN" +%s 2>/dev/null \ - || date -d "$FIRST_SEEN" +%s 2>/dev/null \ - || echo "0") - NOW_EPOCH=$(date +%s) - SPREAD=$((NOW_EPOCH - FIRST_EPOCH)) - if [ "$SPREAD" -lt 86400 ] && [ "$CONF_RAW" -ge 70 ]; then - STATUS="observing" - fi - fi + calculate_confidence "$NEW_COUNT" - # Determine status (temporal spread required for both types) + local CURRENT_STATUS + CURRENT_STATUS=$(echo "$EXISTING_LINE" | json_field "status" "") ARTIFACT_PATH=$(echo "$EXISTING_LINE" | json_field "artifact_path" "") - if [ "$STATUS" != "created" ] && [ "$CONF_RAW" -ge 70 ]; then - FIRST_EPOCH=$(date -j -f "%Y-%m-%dT%H:%M:%SZ" "$FIRST_SEEN" +%s 2>/dev/null \ - || date -d "$FIRST_SEEN" +%s 2>/dev/null \ - || echo "0") - NOW_EPOCH=$(date +%s) - SPREAD=$((NOW_EPOCH - FIRST_EPOCH)) - if [ "$SPREAD" -ge 86400 ]; then - STATUS="ready" - fi - fi + + check_temporal_spread "$FIRST_SEEN" "$CONF_RAW" "$CURRENT_STATUS" # Build updated entry UPDATED=$(json_obs_construct_full \ @@ -574,6 +642,31 @@ process_observations() { # --- Create Artifacts --- +# Write a command artifact file with frontmatter. +write_command_artifact() { + local art_path="$1" art_desc="$2" art_content="$3" art_date="$4" art_conf="$5" art_obs_n="$6" + printf '%s\n' "---" \ + "description: \"$art_desc\"" \ + "# devflow-learning: auto-generated ($art_date, confidence: $art_conf, obs: $art_obs_n)" \ + "---" \ + "" > "$art_path" + printf '%s\n' "$art_content" >> "$art_path" +} + +# Write a skill artifact file with frontmatter. +write_skill_artifact() { + local art_path="$1" art_name="$2" art_desc="$3" art_content="$4" art_date="$5" art_conf="$6" art_obs_n="$7" + printf '%s\n' "---" \ + "name: self-learning:$art_name" \ + "description: \"$art_desc\"" \ + "user-invocable: false" \ + "allowed-tools: Read, Grep, Glob" \ + "# devflow-learning: auto-generated ($art_date, confidence: $art_conf, obs: $art_obs_n)" \ + "---" \ + "" > "$art_path" + printf '%s\n' "$art_content" >> "$art_path" +} + create_artifacts() { ART_COUNT=$(echo "$RESPONSE" | json_array_length "artifacts") if [ "$ART_COUNT" -le 0 ]; then @@ -588,13 +681,16 @@ create_artifacts() { ART_DESC=$(echo "$ART" | json_field "description" "") ART_CONTENT=$(echo "$ART" | json_field "content" "") - # Sanitize ART_NAME — prevent path traversal (model-generated input) - ART_NAME=$(echo "$ART_NAME" | tr -d '/' | sed 's/\.\.//g') + # Sanitize ART_NAME — strict kebab-case allowlist (model-generated input) + ART_NAME=$(echo "$ART_NAME" | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9-' | head -c 50) if [ -z "$ART_NAME" ]; then log "Skipping artifact with empty/invalid name" continue fi + # Escape double quotes in description for YAML frontmatter safety + ART_DESC=$(echo "$ART_DESC" | sed 's/"/\\"/g' | tr -d '\n') + # Check the observation's status — only create if ready if [ -f "$LEARNING_LOG" ]; then OBS_STATUS=$(grep -F "\"id\":\"$ART_OBS_ID\"" "$LEARNING_LOG" 2>/dev/null | json_field "status" "") @@ -625,37 +721,36 @@ create_artifacts() { ART_CONF=$(grep -F "\"id\":\"$ART_OBS_ID\"" "$LEARNING_LOG" 2>/dev/null | json_field "confidence" "0") ART_OBS_N=$(grep -F "\"id\":\"$ART_OBS_ID\"" "$LEARNING_LOG" 2>/dev/null | json_field "observations" "0") - # Write artifact with learning marker - # Uses printf %s to safely write model-generated content (no shell expansion) + # Write artifact with learning marker (printf %s prevents shell expansion) if [ "$ART_TYPE" = "command" ]; then - printf '%s\n' "---" \ - "description: \"$ART_DESC\"" \ - "# devflow-learning: auto-generated ($ART_DATE, confidence: $ART_CONF, obs: $ART_OBS_N)" \ - "---" \ - "" > "$ART_PATH" - printf '%s\n' "$ART_CONTENT" >> "$ART_PATH" + write_command_artifact "$ART_PATH" "$ART_DESC" "$ART_CONTENT" "$ART_DATE" "$ART_CONF" "$ART_OBS_N" else - printf '%s\n' "---" \ - "name: self-learning:$ART_NAME" \ - "description: \"$ART_DESC\"" \ - "user-invocable: false" \ - "allowed-tools: Read, Grep, Glob" \ - "# devflow-learning: auto-generated ($ART_DATE, confidence: $ART_CONF, obs: $ART_OBS_N)" \ - "---" \ - "" > "$ART_PATH" - printf '%s\n' "$ART_CONTENT" >> "$ART_PATH" + write_skill_artifact "$ART_PATH" "$ART_NAME" "$ART_DESC" "$ART_CONTENT" "$ART_DATE" "$ART_CONF" "$ART_OBS_N" fi - # Update observation status to "created" and record artifact path - TEMP_LOG="$LEARNING_LOG.tmp" - while IFS= read -r line; do - if echo "$line" | grep -qF "\"id\":\"$ART_OBS_ID\""; then - echo "$line" | json_update_field "status" "created" | json_update_field "artifact_path" "$ART_PATH" - else - echo "$line" - fi - done < "$LEARNING_LOG" > "$TEMP_LOG" - mv "$TEMP_LOG" "$LEARNING_LOG" + # Single-pass status update: update matching observation in learning log + if [ "$_HAS_JQ" = "true" ]; then + TEMP_LOG="$LEARNING_LOG.tmp" + jq -c --arg obs_id "$ART_OBS_ID" --arg art_path "$ART_PATH" ' + if .id == $obs_id then .status = "created" | .artifact_path = $art_path else . end + ' "$LEARNING_LOG" > "$TEMP_LOG" 2>/dev/null + mv "$TEMP_LOG" "$LEARNING_LOG" + else + TEMP_LOG="$LEARNING_LOG.tmp" + node -e " + const fs = require('fs'); + const lines = fs.readFileSync('$LEARNING_LOG','utf8').trim().split('\n').filter(Boolean); + const out = lines.map(l => { + try { + const o = JSON.parse(l); + if (o.id === '$ART_OBS_ID') { o.status = 'created'; o.artifact_path = '$ART_PATH'; } + return JSON.stringify(o); + } catch { return l; } + }); + fs.writeFileSync('$LEARNING_LOG.tmp', out.join('\n') + '\n'); + " 2>/dev/null + mv "$TEMP_LOG" "$LEARNING_LOG" + fi log "Created artifact: $ART_PATH" done From 5582f700dd8517517e400bdee4a47b5effd9f222 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Wed, 25 Mar 2026 21:48:14 +0200 Subject: [PATCH 10/14] test: add missing coverage for review findings - Add loadAndCountObservations tests (mixed valid/invalid, all-valid, empty input, invalidCount calculation) - Add extract-text-messages string content path test - Add learning-new operation test with self-learning prefix verification - Update learning-created fixture paths to production-realistic values - Add session-end-learning structural checks (syntax list, shebang, json-parse sourcing) --- tests/learn.test.ts | 54 +++++++++++++++++++++++++++++++++++++ tests/shell-hooks.test.ts | 57 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 109 insertions(+), 2 deletions(-) diff --git a/tests/learn.test.ts b/tests/learn.test.ts index 383efef..bb01425 100644 --- a/tests/learn.test.ts +++ b/tests/learn.test.ts @@ -4,6 +4,7 @@ import { removeLearningHook, hasLearningHook, parseLearningLog, + loadAndCountObservations, formatLearningStatus, loadLearningConfig, isLearningObservation, @@ -327,6 +328,59 @@ describe('parseLearningLog', () => { }); }); +describe('loadAndCountObservations', () => { + it('counts mixed valid and invalid lines', () => { + const valid = JSON.stringify({ + id: 'obs_1', type: 'workflow', pattern: 'p1', + confidence: 0.5, observations: 1, first_seen: 't', + last_seen: 't', status: 'observing', evidence: [], details: 'd', + }); + const invalid = 'not json at all'; + const incomplete = JSON.stringify({ id: 'obs_2', type: 'workflow' }); + const log = [valid, invalid, incomplete].join('\n'); + const result = loadAndCountObservations(log); + expect(result.observations).toHaveLength(1); + expect(result.observations[0].id).toBe('obs_1'); + expect(result.invalidCount).toBe(2); + }); + + it('returns zero invalid count for all-valid lines', () => { + const lines = [ + JSON.stringify({ + id: 'obs_1', type: 'workflow', pattern: 'p1', + confidence: 0.5, observations: 1, first_seen: 't', + last_seen: 't', status: 'observing', evidence: [], details: 'd', + }), + JSON.stringify({ + id: 'obs_2', type: 'procedural', pattern: 'p2', + confidence: 0.8, observations: 2, first_seen: 't', + last_seen: 't', status: 'ready', evidence: ['e1'], details: 'd2', + }), + ].join('\n'); + const result = loadAndCountObservations(lines); + expect(result.observations).toHaveLength(2); + expect(result.invalidCount).toBe(0); + }); + + it('handles empty input', () => { + const result = loadAndCountObservations(''); + expect(result.observations).toHaveLength(0); + expect(result.invalidCount).toBe(0); + }); + + it('calculates invalidCount as rawLines minus valid observations', () => { + const valid = JSON.stringify({ + id: 'obs_1', type: 'workflow', pattern: 'p1', + confidence: 0.5, observations: 1, first_seen: 't', + last_seen: 't', status: 'observing', evidence: [], details: 'd', + }); + const log = [valid, 'bad1', 'bad2', 'bad3'].join('\n'); + const result = loadAndCountObservations(log); + expect(result.observations).toHaveLength(1); + expect(result.invalidCount).toBe(3); + }); +}); + describe('formatLearningStatus', () => { it('shows enabled state for current hook', () => { const result = formatLearningStatus([], 'current'); diff --git a/tests/shell-hooks.test.ts b/tests/shell-hooks.test.ts index 32fc0c0..5623364 100644 --- a/tests/shell-hooks.test.ts +++ b/tests/shell-hooks.test.ts @@ -193,6 +193,19 @@ describe('json-helper.js operations', () => { expect(result).toBe('Hello world\nSecond message'); }); + it('extract-text-messages handles plain string content', () => { + const input = JSON.stringify({ + message: { + content: 'plain string message', + }, + }); + const result = execSync( + `echo '${input.replace(/'/g, "'\\''")}' | node "${JSON_HELPER}" extract-text-messages`, + { stdio: 'pipe' }, + ).toString().trim(); + expect(result).toBe('plain string message'); + }); + it('merge-evidence flattens, dedupes, and limits', () => { const input = JSON.stringify([['a', 'b', 'c'], ['b', 'c', 'd']]); const result = execSync( @@ -280,8 +293,8 @@ describe('json-helper.js operations', () => { try { fs.writeFileSync(file, [ - JSON.stringify({ id: 'obs_1', type: 'workflow', status: 'created', artifact_path: '/path/self-learning/deploy-flow.md', confidence: 0.95 }), - JSON.stringify({ id: 'obs_2', type: 'procedural', status: 'created', artifact_path: '/path/debug-hooks/SKILL.md', confidence: 0.8 }), + JSON.stringify({ id: 'obs_1', type: 'workflow', status: 'created', artifact_path: '/.claude/commands/self-learning/deploy-flow.md', confidence: 0.95 }), + JSON.stringify({ id: 'obs_2', type: 'procedural', status: 'created', artifact_path: '/.claude/skills/debug-hooks/SKILL.md', confidence: 0.8 }), JSON.stringify({ id: 'obs_3', type: 'workflow', status: 'observing', confidence: 0.3 }), ].join('\n')); @@ -298,6 +311,46 @@ describe('json-helper.js operations', () => { fs.rmSync(tmpDir, { recursive: true, force: true }); } }); + + it('learning-new outputs new artifact notifications with self-learning prefix', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'devflow-test-')); + const file = path.join(tmpDir, 'learning.jsonl'); + + try { + fs.writeFileSync(file, [ + JSON.stringify({ id: 'obs_1', type: 'workflow', status: 'created', artifact_path: '/.claude/commands/self-learning/deploy-flow.md', confidence: 0.95, last_seen: '2026-03-22T00:00:00Z' }), + JSON.stringify({ id: 'obs_2', type: 'procedural', status: 'created', artifact_path: '/.claude/skills/debug-hooks/SKILL.md', confidence: 0.8, last_seen: '2026-03-22T00:00:00Z' }), + JSON.stringify({ id: 'obs_3', type: 'workflow', status: 'observing', confidence: 0.3, last_seen: '2026-03-22T00:00:00Z' }), + ].join('\n')); + + const result = execSync( + `node "${JSON_HELPER}" learning-new "${file}" 0`, + { stdio: 'pipe' }, + ).toString().trim(); + const lines = result.split('\n'); + expect(lines).toHaveLength(2); + expect(lines[0]).toContain('self-learning/deploy-flow'); + expect(lines[0]).toContain('command created'); + expect(lines[1]).toContain('debug-hooks'); + expect(lines[1]).toContain('skill created'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); + +describe('session-end-learning structure', () => { + it('is included in bash -n syntax checks', () => { + expect(HOOK_SCRIPTS).toContain('session-end-learning'); + }); + + it('starts with bash shebang and sources json-parse', () => { + const scriptPath = path.join(HOOKS_DIR, 'session-end-learning'); + const content = fs.readFileSync(scriptPath, 'utf8'); + const lines = content.split('\n'); + expect(lines[0]).toBe('#!/bin/bash'); + expect(content).toContain('source "$SCRIPT_DIR/json-parse"'); + }); }); describe('json-parse wrapper', () => { From 5eeeda741266de7e6be1764346a09052e5186d1b Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Wed, 25 Mar 2026 21:52:49 +0200 Subject: [PATCH 11/14] refactor: extract duplicated observation-loading and warning patterns in learn.ts - Extract readObservations() to deduplicate try/catch + loadAndCountObservations pattern - Extract warnIfInvalid() to deduplicate invalidCount > 0 warning message - Hoist logPath computation once instead of repeating in 4 branches - Remove unnecessary String() and !! casts on already-typed prompt values --- src/cli/commands/learn.ts | 62 ++++++++++++++++++++------------------- 1 file changed, 32 insertions(+), 30 deletions(-) diff --git a/src/cli/commands/learn.ts b/src/cli/commands/learn.ts index db2468a..c269dbb 100644 --- a/src/cli/commands/learn.ts +++ b/src/cli/commands/learn.ts @@ -270,6 +270,28 @@ export function loadLearningConfig(globalJson: string | null, projectJson: strin return config; } +/** + * Read and parse observations from the learning log file. + * Returns empty results if the file does not exist. + */ +async function readObservations(logPath: string): Promise<{ observations: LearningObservation[]; invalidCount: number }> { + try { + const logContent = await fs.readFile(logPath, 'utf-8'); + return loadAndCountObservations(logContent); + } catch { + return { observations: [], invalidCount: 0 }; + } +} + +/** + * Warn the user if invalid entries were found in the learning log. + */ +function warnIfInvalid(invalidCount: number): void { + if (invalidCount > 0) { + p.log.warn(`Note: ${invalidCount} invalid entry(ies) found. Run 'devflow learn --purge' to clean.`); + } +} + interface LearnOptions { enable?: boolean; disable?: boolean; @@ -321,36 +343,24 @@ export const learnCommand = new Command('learn') settingsContent = '{}'; } + // Shared log path for --status, --list, --purge, --clear + const logPath = path.join(process.cwd(), '.memory', 'learning-log.jsonl'); + // --- --status --- if (options.status) { const hookState = hasLearningHook(settingsContent); - const cwd = process.cwd(); - const logPath = path.join(cwd, '.memory', 'learning-log.jsonl'); - - let observations: LearningObservation[] = []; - let invalidCount = 0; - try { - const logContent = await fs.readFile(logPath, 'utf-8'); - ({ observations, invalidCount } = loadAndCountObservations(logContent)); - } catch { - // No log file yet - } + const { observations, invalidCount } = await readObservations(logPath); const status = formatLearningStatus(observations, hookState); p.log.info(status); - if (invalidCount > 0) { - p.log.warn(`Note: ${invalidCount} invalid entry(ies) found. Run 'devflow learn --purge' to clean.`); - } + warnIfInvalid(invalidCount); return; } // --- --list --- if (options.list) { - const cwd = process.cwd(); - const logPath = path.join(cwd, '.memory', 'learning-log.jsonl'); - - let observations: LearningObservation[] = []; - let invalidCount = 0; + let observations: LearningObservation[]; + let invalidCount: number; try { const logContent = await fs.readFile(logPath, 'utf-8'); ({ observations, invalidCount } = loadAndCountObservations(logContent)); @@ -378,9 +388,7 @@ export const learnCommand = new Command('learn') `[${typeIcon}] ${color.cyan(obs.pattern)} (${conf}% | ${obs.observations}x | ${statusIcon})`, ); } - if (invalidCount > 0) { - p.log.warn(`Note: ${invalidCount} invalid entry(ies) found. Run 'devflow learn --purge' to clean.`); - } + warnIfInvalid(invalidCount); p.outro(color.dim(`${observations.length} observation(s) total`)); return; } @@ -471,8 +479,8 @@ export const learnCommand = new Command('learn') const config: LearningConfig = { max_daily_runs: Number(maxRuns), throttle_minutes: Number(throttle), - model: String(model), - debug: !!debugMode, + model: model as string, + debug: debugMode, batch_size: Number(batchSize), }; @@ -497,9 +505,6 @@ export const learnCommand = new Command('learn') // --- --purge --- if (options.purge) { - const cwd = process.cwd(); - const logPath = path.join(cwd, '.memory', 'learning-log.jsonl'); - let logContent: string; try { logContent = await fs.readFile(logPath, 'utf-8'); @@ -523,9 +528,6 @@ export const learnCommand = new Command('learn') // --- --clear --- if (options.clear) { - const cwd = process.cwd(); - const logPath = path.join(cwd, '.memory', 'learning-log.jsonl'); - try { await fs.access(logPath); } catch { From b5b1d8b9345696af90ef8766b72e8fd2e11d58af Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Wed, 25 Mar 2026 22:03:34 +0200 Subject: [PATCH 12/14] docs: update file-organization, CHANGELOG, CLAUDE.md for Wave 2 changes - file-organization.md: session-end-learning replaces stop-update-learning - CHANGELOG.md: add Wave 2 Changed + Fixed entries under [Unreleased] - CLAUDE.md: include deprecated stop-update-learning in hooks list --- CHANGELOG.md | 12 ++++++++++++ CLAUDE.md | 2 +- docs/reference/file-organization.md | 5 +++-- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9d8950..673d0c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Learning**: `devflow learn --purge` command to remove invalid entries from learning log - **Learning**: debug logging mode (`devflow learn --configure`) — logs to `~/.devflow/logs/` +### Changed +- **Learning**: Moved from Stop → SessionEnd hook with 3-session batching (adaptive: 5 at 15+ observations) +- **Learning**: Raised procedural thresholds from 2 to 3 observations with 24h+ temporal spread for both types +- **Learning**: Reduced default `max_daily_runs` from 10 to 5 +- **Learning**: Renamed artifact paths: `commands/learned/` → `commands/self-learning/`, `skills/learned-{name}/` → `skills/{name}/` +- **Learning**: Skill artifacts now include `user-invocable: false`, Iron Law section, and `self-learning:` name prefix + ### Fixed - **Learning**: reject observations with empty id/type/pattern fields (validation + auto-purge on migration) +- **Learning**: Handle string-typed `.message.content` in transcript extraction (was only handling arrays) +- **Learning**: Eliminate empty-array loop noise when Sonnet returns no observations +- **Learning**: Race condition in batch file handoff (atomic `mv` replaces `cp`+`rm`) +- **Learning**: `--enable` now auto-upgrades legacy Stop hook to SessionEnd +- **Learning**: `--status` detects legacy hook and shows upgrade instructions --- diff --git a/CLAUDE.md b/CLAUDE.md index 7d84196..b130cac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,7 @@ devflow/ ├── plugins/devflow-*/ # 17 plugins (8 core + 9 optional language/ecosystem) ├── docs/reference/ # Detailed reference documentation ├── scripts/ # Helper scripts (statusline, docs-helpers) -│ └── hooks/ # Working Memory + ambient + learning hooks (stop, session-start, pre-compact, ambient-prompt, session-end-learning, background-learning) +│ └── hooks/ # Working Memory + ambient + learning hooks (stop, session-start, pre-compact, ambient-prompt, session-end-learning, stop-update-learning [deprecated], background-learning) ├── src/cli/ # TypeScript CLI (init, list, uninstall, ambient, learn) ├── .claude-plugin/ # Marketplace registry ├── .docs/ # Project docs (reviews, design) — per-project diff --git a/docs/reference/file-organization.md b/docs/reference/file-organization.md index b2805e7..21bc418 100644 --- a/docs/reference/file-organization.md +++ b/docs/reference/file-organization.md @@ -47,7 +47,8 @@ devflow/ │ ├── session-start-memory # SessionStart hook: injects memory + git state │ ├── pre-compact-memory # PreCompact hook: saves git state backup │ ├── ambient-prompt # UserPromptSubmit hook: ambient skill injection -│ ├── stop-update-learning # Stop hook: triggers background learning +│ ├── session-end-learning # SessionEnd hook: batched learning trigger +│ ├── stop-update-learning # Stop hook: deprecated stub (upgrade via devflow learn) │ ├── background-learning # Background: pattern detection via Sonnet │ ├── json-helper.cjs # Node.js jq-equivalent operations │ └── json-parse # Shell wrapper: jq with node fallback @@ -154,7 +155,7 @@ Included settings: Three hooks in `scripts/hooks/` provide automatic session continuity. Toggleable via `devflow memory --enable/--disable/--status` or `devflow init --memory/--no-memory`. -A fourth hook (`stop-update-learning`) provides self-learning. Toggleable via `devflow learn --enable/--disable/--status` or `devflow init --learn/--no-learn`: +A fourth hook (`session-end-learning`) provides self-learning. Toggleable via `devflow learn --enable/--disable/--status` or `devflow init --learn/--no-learn`: | Hook | Event | File | Purpose | |------|-------|------|---------| From 52e4becaf9c1c020e358813f622048c6e1b45878 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Wed, 25 Mar 2026 23:47:29 +0200 Subject: [PATCH 13/14] fix: address self-review issues --- scripts/hooks/json-helper.cjs | 292 ++++++++++++++++++++++++++++++++-- 1 file changed, 282 insertions(+), 10 deletions(-) diff --git a/scripts/hooks/json-helper.cjs b/scripts/hooks/json-helper.cjs index f5dda25..e24cbfb 100755 --- a/scripts/hooks/json-helper.cjs +++ b/scripts/hooks/json-helper.cjs @@ -20,12 +20,15 @@ // slurp-cap Read JSONL, sort by field desc, output limit lines // array-length Get length of array at dotted path in stdin JSON // array-item Get item at index from array at path in stdin JSON -// obs-construct Build observation JSON from key=value pairs // session-output Build SessionStart output envelope // prompt-output Build UserPromptSubmit output envelope // backup-construct Build pre-compact backup JSON from --arg pairs // learning-created Extract created artifacts from learning log // learning-new Find new artifacts since epoch +// temporal-decay Apply temporal decay to learning log entries +// process-observations Merge model observations into learning log +// create-artifacts Create command/skill files from ready observations +// filter-observations [sort] [n] Filter valid observations, sort desc, limit 'use strict'; @@ -36,8 +39,10 @@ const op = process.argv[2]; const args = process.argv.slice(3); /** - * Resolve and validate a file path argument. Returns the resolved absolute path. - * Rejects paths containing '..' traversal sequences for defense-in-depth. + * Resolve a file path argument to an absolute path. + * Note: path.resolve() normalizes away '..' segments, so the includes check + * only catches the rare case of literal '..' in a directory name after resolution. + * Primary value is ensuring all file operations use absolute paths. */ function safePath(filePath) { const resolved = path.resolve(filePath); @@ -72,6 +77,38 @@ function parseJsonl(file) { }).filter(Boolean); } +// --- Learning system constants --- +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; + +function learningLog(msg) { + const ts = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'); + process.stderr.write(`[${ts}] ${msg}\n`); +} + +function writeJsonlAtomic(file, entries) { + const tmp = file + '.tmp'; + const content = entries.length > 0 + ? entries.map(e => JSON.stringify(e)).join('\n') + '\n' + : ''; + fs.writeFileSync(tmp, content); + fs.renameSync(tmp, file); +} + +function calculateConfidence(count) { + const raw = Math.floor(count * 100 / REQUIRED_OBSERVATIONS); + return Math.min(raw, 95) / 100; +} + +function mergeEvidence(oldEvidence, newEvidence) { + const flat = [...(oldEvidence || []), ...(newEvidence || [])]; + const unique = [...new Set(flat)]; + return unique.slice(0, 10); +} + /** Extract artifact display name from its file path. */ function artifactName(obs) { const parts = (obs.artifact_path || '').split('/'); @@ -244,13 +281,6 @@ try { break; } - case 'obs-construct': { - // Build an observation JSON from --arg/--argjson pairs - const data = parseArgs(args); - console.log(JSON.stringify(data)); - break; - } - case 'session-output': { const ctx = args[0]; console.log(JSON.stringify({ @@ -324,6 +354,248 @@ try { break; } + case 'temporal-decay': { + const file = safePath(args[0]); + if (!fs.existsSync(file)) { + console.log(JSON.stringify({ removed: 0, decayed: 0 })); + break; + } + const entries = parseJsonl(file); + const now = Date.now() / 1000; + let removed = 0; + let decayed = 0; + const results = []; + for (const entry of entries) { + if (entry.last_seen) { + const lastDate = new Date(entry.last_seen); + if (isNaN(lastDate.getTime())) { + learningLog(`Warning: invalid date in ${entry.id || 'unknown'}: ${entry.last_seen}`); + results.push(entry); + continue; + } + const lastEpoch = lastDate.getTime() / 1000; + const days = Math.floor((now - lastEpoch) / 86400); + const periods = Math.floor(days / DECAY_PERIOD_DAYS); + if (periods > 0) { + const factor = periods < DECAY_FACTORS.length + ? DECAY_FACTORS[periods] : DECAY_FACTORS[DECAY_FACTORS.length - 1]; + const newConf = Math.round(entry.confidence * factor * 100) / 100; + if (newConf < CONFIDENCE_FLOOR) { + removed++; + learningLog(`Removed ${entry.id || 'unknown'}: confidence ${newConf} below threshold`); + continue; + } + entry.confidence = newConf; + decayed++; + } + } + results.push(entry); + } + writeJsonlAtomic(file, results); + learningLog(`Temporal decay: removed=${removed}, decayed=${decayed}`); + console.log(JSON.stringify({ removed, decayed })); + break; + } + + case 'process-observations': { + const responseFile = safePath(args[0]); + const logFile = safePath(args[1]); + const response = JSON.parse(fs.readFileSync(responseFile, 'utf8')); + const observations = response.observations || []; + + let logEntries = []; + if (fs.existsSync(logFile)) { + logEntries = parseJsonl(logFile); + } + const logMap = new Map(logEntries.map(e => [e.id, e])); + const nowIso = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'); + let updated = 0, created = 0, skipped = 0; + + for (let i = 0; i < observations.length; i++) { + const obs = observations[i]; + if (!obs.id || !obs.type || !obs.pattern) { + learningLog(`Skipping observation ${i}: missing required field (id='${obs.id || ''}' type='${obs.type || ''}')`); + skipped++; + continue; + } + if (obs.type !== 'workflow' && obs.type !== 'procedural') { + learningLog(`Skipping observation ${i}: invalid type '${obs.type}'`); + skipped++; + continue; + } + if (!obs.id.startsWith('obs_')) { + learningLog(`Skipping observation ${i}: invalid id format '${obs.id}'`); + skipped++; + continue; + } + + const existing = logMap.get(obs.id); + if (existing) { + const newCount = (existing.observations || 0) + 1; + existing.observations = newCount; + existing.evidence = mergeEvidence(existing.evidence || [], obs.evidence || []); + existing.confidence = calculateConfidence(newCount); + existing.last_seen = nowIso; + if (obs.pattern) existing.pattern = obs.pattern; + if (obs.details) existing.details = obs.details; + + if (existing.status !== 'created') { + if (existing.confidence >= 0.70 && existing.first_seen) { + const firstDate = new Date(existing.first_seen); + if (!isNaN(firstDate.getTime())) { + const spread = Date.now() / 1000 - firstDate.getTime() / 1000; + existing.status = spread >= TEMPORAL_SPREAD_SECS ? 'ready' : 'observing'; + } + } + } + + learningLog(`Updated ${obs.id}: confidence ${existing.confidence}, status ${existing.status}`); + updated++; + } else { + const newEntry = { + id: obs.id, + type: obs.type, + pattern: obs.pattern, + confidence: 0.33, + observations: 1, + first_seen: nowIso, + last_seen: nowIso, + status: 'observing', + evidence: obs.evidence || [], + details: obs.details || '', + }; + logMap.set(obs.id, newEntry); + learningLog(`New observation ${obs.id}: type=${obs.type} confidence=0.33`); + created++; + } + } + + const logDir = path.dirname(logFile); + if (!fs.existsSync(logDir)) { + fs.mkdirSync(logDir, { recursive: true }); + } + writeJsonlAtomic(logFile, Array.from(logMap.values())); + console.log(JSON.stringify({ updated, created, skipped })); + break; + } + + case 'create-artifacts': { + const responseFile = safePath(args[0]); + const logFile = safePath(args[1]); + const baseDir = safePath(args[2]); + const response = JSON.parse(fs.readFileSync(responseFile, 'utf8')); + const artifacts = response.artifacts || []; + + if (artifacts.length === 0) { + console.log(JSON.stringify({ created: [], skipped: 0 })); + break; + } + + let logEntries = []; + if (fs.existsSync(logFile)) { + logEntries = parseJsonl(logFile); + } + const logMap = new Map(logEntries.map(e => [e.id, e])); + const createdPaths = []; + let skippedCount = 0; + const artDate = new Date().toISOString().slice(0, 10); + + for (const art of artifacts) { + let name = (art.name || '').toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 50); + if (!name) { + learningLog('Skipping artifact with empty/invalid name'); + skippedCount++; + continue; + } + + const obs = logMap.get(art.observation_id); + if (!obs || obs.status !== 'ready') { + learningLog(`Skipping artifact for ${art.observation_id} (status: ${obs ? obs.status : 'not found'}, need: ready)`); + skippedCount++; + continue; + } + + let artDir, artPath; + if (art.type === 'command') { + artDir = path.join(baseDir, '.claude', 'commands', 'self-learning'); + artPath = path.join(artDir, `${name}.md`); + } else { + artDir = path.join(baseDir, '.claude', 'skills', name); + artPath = path.join(artDir, 'SKILL.md'); + } + + if (fs.existsSync(artPath)) { + learningLog(`Artifact already exists at ${artPath} — skipping`); + skippedCount++; + continue; + } + + const desc = (art.description || '').replace(/"/g, '\\"'); + const conf = obs.confidence || 0; + const obsN = obs.observations || 0; + + fs.mkdirSync(artDir, { recursive: true }); + + let content; + if (art.type === 'command') { + content = [ + '---', + `description: "${desc}"`, + `# devflow-learning: auto-generated (${artDate}, confidence: ${conf}, obs: ${obsN})`, + '---', + '', + art.content || '', + '', + ].join('\n'); + } else { + content = [ + '---', + `name: self-learning:${name}`, + `description: "${desc}"`, + 'user-invocable: false', + 'allowed-tools: Read, Grep, Glob', + `# devflow-learning: auto-generated (${artDate}, confidence: ${conf}, obs: ${obsN})`, + '---', + '', + art.content || '', + '', + ].join('\n'); + } + + fs.writeFileSync(artPath, content); + obs.status = 'created'; + obs.artifact_path = artPath; + learningLog(`Created artifact: ${artPath}`); + createdPaths.push(artPath); + } + + if (createdPaths.length > 0) { + writeJsonlAtomic(logFile, Array.from(logMap.values())); + } + + console.log(JSON.stringify({ created: createdPaths, skipped: skippedCount })); + break; + } + + case 'filter-observations': { + const file = args[0]; + const sortField = args[1] || 'confidence'; + const limit = parseInt(args[2]) || 30; + if (!fs.existsSync(safePath(file))) { + console.log('[]'); + break; + } + const entries = parseJsonl(file); + const valid = entries.filter(e => + e.id && e.id.startsWith('obs_') && + (e.type === 'workflow' || e.type === 'procedural') && + e.pattern + ); + valid.sort((a, b) => (b[sortField] || 0) - (a[sortField] || 0)); + console.log(JSON.stringify(valid.slice(0, limit))); + break; + } + default: process.stderr.write(`json-helper: unknown operation "${op}"\n`); process.exit(1); From 38d6d82c51c141b5ecc808c28f4da162ab3b9312 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Thu, 26 Mar 2026 00:07:33 +0200 Subject: [PATCH 14/14] refactor: move JSON-heavy logic from background-learning to json-helper.cjs Move 4 operations (temporal-decay, process-observations, create-artifacts, filter-observations) from shell to Node, reducing background-learning from 819 to 496 lines. Remove 10 shell functions, 4 dead json-parse wrappers, and 1 dead json-helper.cjs operation. Add 27 new tests covering all paths. Addresses PF-004 (background hook god script). --- scripts/hooks/background-learning | 373 +------------- scripts/hooks/json-parse | 40 -- tests/shell-hooks.test.ts | 790 +++++++++++++++++++++++++++++- 3 files changed, 790 insertions(+), 413 deletions(-) diff --git a/scripts/hooks/background-learning b/scripts/hooks/background-learning index 0ec7ab1..800b476 100755 --- a/scripts/hooks/background-learning +++ b/scripts/hooks/background-learning @@ -20,6 +20,7 @@ source "$SCRIPT_DIR/log-paths" LOG_FILE="$(devflow_log_dir "$CWD")/.learning-update.log" LOCK_DIR="$CWD/.memory/.learning.lock" LEARNING_LOG="$CWD/.memory/learning-log.jsonl" +RESPONSE_FILE="$CWD/.memory/.learning-response.tmp" # --- Logging --- @@ -81,6 +82,8 @@ acquire_lock() { cleanup() { rmdir "$LOCK_DIR" 2>/dev/null || true + rm -f "$CWD/.memory/learning-log.jsonl.tmp" 2>/dev/null || true + rm -f "$RESPONSE_FILE" 2>/dev/null || true } trap cleanup EXIT @@ -128,16 +131,6 @@ check_daily_cap() { return 0 } -# --- Temporal Decay --- - -decay_factor() { - case $1 in - 0) echo "100";; 1) echo "90";; 2) echo "81";; - 3) echo "73";; 4) echo "66";; 5) echo "59";; - *) echo "53";; # floor for 6+ periods - esac -} - # --- Batch Transcript Extraction --- extract_batch_messages() { @@ -225,62 +218,13 @@ ${session_msgs}" return 0 } -# --- Temporal Decay Pass --- +# --- Temporal Decay --- apply_temporal_decay() { - if [ ! -f "$LEARNING_LOG" ]; then return; fi - - NOW_EPOCH=$(date +%s) - TEMP_FILE="$LEARNING_LOG.tmp" - - if [ "$_HAS_JQ" = "true" ]; then - # Single-pass jq: apply decay to all entries at once - jq -c --argjson now "$NOW_EPOCH" ' - def decay_factor(p): if p <= 0 then 1.0 elif p == 1 then 0.90 elif p == 2 then 0.81 - elif p == 3 then 0.73 elif p == 4 then 0.66 elif p == 5 then 0.59 else 0.53 end; - def parse_epoch: split("T")[0] | split("-") | map(tonumber) | - ((.[0] - 1970) * 365.25 * 86400 + (.[1] - 1) * 30.44 * 86400 + (.[2] - 1) * 86400) | floor; - if .last_seen and .last_seen != "" then - (.last_seen | parse_epoch) as $last_epoch | - ((($now - $last_epoch) / 86400) | floor) as $days | - (($days / 30) | floor) as $periods | - if $periods > 0 then - decay_factor($periods) as $factor | - ((.confidence * $factor * 100 | round) / 100) as $new_conf | - if ($new_conf * 100) < 10 then empty - else .confidence = $new_conf end - else . end - else . end - ' "$LEARNING_LOG" > "$TEMP_FILE" 2>/dev/null - mv "$TEMP_FILE" "$LEARNING_LOG" - else - # Node fallback: single-pass processing - node -e " - const fs = require('fs'); - const lines = fs.readFileSync('$LEARNING_LOG','utf8').trim().split('\n').filter(Boolean); - const now = $NOW_EPOCH; - const factors = [1.0, 0.90, 0.81, 0.73, 0.66, 0.59, 0.53]; - const results = []; - for (const line of lines) { - try { - const obj = JSON.parse(line); - if (obj.last_seen) { - const lastEpoch = Math.floor(new Date(obj.last_seen).getTime() / 1000); - const days = Math.floor((now - lastEpoch) / 86400); - const periods = Math.floor(days / 30); - if (periods > 0) { - const factor = periods < factors.length ? factors[periods] : 0.53; - const newConf = Math.round(obj.confidence * factor * 100) / 100; - if (newConf * 100 < 10) continue; - obj.confidence = newConf; - } - } - results.push(JSON.stringify(obj)); - } catch {} - } - fs.writeFileSync('$LEARNING_LOG', results.join('\n') + '\n'); - " 2>/dev/null - fi + [ ! -f "$LEARNING_LOG" ] && return + local result + result=$(node "$_JSON_HELPER" temporal-decay "$LEARNING_LOG" 2>> "$LOG_FILE") || return + [ "$DEBUG" = "true" ] && log "Decay: $result" } # --- Entry Cap --- @@ -299,19 +243,8 @@ cap_entries() { # --- Prompt Construction --- build_sonnet_prompt() { - EXISTING_OBS="" - if [ -f "$LEARNING_LOG" ]; then - EXISTING_OBS=$(json_slurp_sort "$LEARNING_LOG" "confidence" 30 || echo "[]") - fi - if [ -z "$EXISTING_OBS" ]; then - EXISTING_OBS="[]" - fi - # Filter out contaminated entries with empty required fields - if [ "$_HAS_JQ" = "true" ]; then - EXISTING_OBS=$(echo "$EXISTING_OBS" | jq -c '[.[] | select(.id != "" and (.id | startswith("obs_")) and (.type == "workflow" or .type == "procedural") and .pattern != "")]') - else - EXISTING_OBS=$(echo "$EXISTING_OBS" | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));console.log(JSON.stringify(d.filter(o=>o.id&&o.id.startsWith('obs_')&&(o.type==='workflow'||o.type==='procedural')&&o.pattern)))") - fi + EXISTING_OBS=$(node "$_JSON_HELPER" filter-observations "$LEARNING_LOG" confidence 30 2>> "$LOG_FILE" || echo "[]") + [ -z "$EXISTING_OBS" ] && EXISTING_OBS="[]" PROMPT="You are a pattern detection agent. Analyze the user's session messages to identify repeated workflows and procedural knowledge. @@ -421,7 +354,6 @@ run_sonnet_analysis() { log "--- Sending to claude -p --model $MODEL ---" TIMEOUT=180 - RESPONSE_FILE="$CWD/.memory/.learning-response.tmp" DEVFLOW_BG_UPDATER=1 DEVFLOW_BG_LEARNER=1 "$CLAUDE_BIN" -p \ --model "$MODEL" \ @@ -457,303 +389,45 @@ run_sonnet_analysis() { return 1 fi - RESPONSE=$(cat "$RESPONSE_FILE") - rm -f "$RESPONSE_FILE" - # Debug: log raw response if [ "$DEBUG" = "true" ]; then log "--- DEBUG: Raw model response ---" - log "$RESPONSE" + cat "$RESPONSE_FILE" >> "$LOG_FILE" log "--- DEBUG: End raw response ---" fi - # Strip markdown fences if present + # Read response, strip markdown fences, validate, write back for Node operations + local RESPONSE + RESPONSE=$(cat "$RESPONSE_FILE") RESPONSE=$(echo "$RESPONSE" | sed '1s/^```json$//' | sed '1s/^```$//' | sed '$s/^```$//') - # Validate JSON if ! echo "$RESPONSE" | json_valid; then log "Invalid JSON response from model — skipping" log "--- Raw response ---" log "$RESPONSE" log "--- End raw response ---" + rm -f "$RESPONSE_FILE" return 1 fi + echo "$RESPONSE" > "$RESPONSE_FILE" return 0 } # --- Process Observations --- -# Validate observation fields. Sets OBS_ID, OBS_TYPE, OBS_PATTERN, OBS_EVIDENCE, OBS_DETAILS. -# Returns 1 if invalid (caller should skip). -validate_observation() { - local obs_json="$1" obs_index="$2" - - OBS_ID=$(echo "$obs_json" | json_field "id" "") - OBS_TYPE=$(echo "$obs_json" | json_field "type" "") - OBS_PATTERN=$(echo "$obs_json" | json_field "pattern" "") - OBS_DETAILS=$(echo "$obs_json" | json_field "details" "") - - # Evidence needs to stay as JSON array for merging later - if [ "$_HAS_JQ" = "true" ]; then - OBS_EVIDENCE=$(echo "$obs_json" | jq -c '.evidence' 2>/dev/null) - else - OBS_EVIDENCE=$(echo "$obs_json" | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));console.log(JSON.stringify(d.evidence||[]))" 2>/dev/null) - fi - - if [ -z "$OBS_ID" ] || [ -z "$OBS_TYPE" ] || [ -z "$OBS_PATTERN" ]; then - log "Skipping observation $obs_index: empty required field (id='$OBS_ID' type='$OBS_TYPE')" - return 1 - fi - if [ "$OBS_TYPE" != "workflow" ] && [ "$OBS_TYPE" != "procedural" ]; then - log "Skipping observation $obs_index: invalid type '$OBS_TYPE'" - return 1 - fi - case "$OBS_ID" in - obs_*) ;; - *) log "Skipping observation $obs_index: invalid id format '$OBS_ID'"; return 1 ;; - esac - - return 0 -} - -# Compute confidence score from observation count. -# Sets CONF_RAW (integer 0-95) and CONF (decimal string e.g. "0.66"). -calculate_confidence() { - local count="$1" - local required=3 - CONF_RAW=$((count * 100 / required)) - if [ "$CONF_RAW" -gt 95 ]; then CONF_RAW=95; fi - CONF=$(echo "$CONF_RAW" | awk '{printf "%.2f", $1 / 100}') -} - -# Check temporal spread and determine observation status. -# Computes epoch once and checks both "observing" and "ready" thresholds. -# Sets STATUS. -check_temporal_spread() { - local first_seen="$1" conf_raw="$2" current_status="$3" - - STATUS="$current_status" - if [ "$STATUS" = "created" ]; then return; fi - - if [ "$conf_raw" -lt 70 ]; then return; fi - - local first_epoch - first_epoch=$(date -j -f "%Y-%m-%dT%H:%M:%SZ" "$first_seen" +%s 2>/dev/null \ - || date -d "$first_seen" +%s 2>/dev/null \ - || echo "0") - local now_epoch - now_epoch=$(date +%s) - local spread=$((now_epoch - first_epoch)) - - if [ "$spread" -ge 86400 ]; then - STATUS="ready" - else - STATUS="observing" - fi -} - process_observations() { - log "--- Processing response ---" - - OBS_COUNT=$(echo "$RESPONSE" | json_array_length "observations") - if [ "$OBS_COUNT" -le 0 ]; then - log "No observations in response" - return - fi - NOW_ISO=$(date -u '+%Y-%m-%dT%H:%M:%SZ') - - for i in $(seq 0 $((OBS_COUNT - 1))); do - local OBS - OBS=$(echo "$RESPONSE" | json_array_item "observations" "$i") - - if ! validate_observation "$OBS" "$i"; then - continue - fi - - # Check if observation already exists - EXISTING_LINE="" - if [ -f "$LEARNING_LOG" ]; then - EXISTING_LINE=$(grep -F "\"id\":\"$OBS_ID\"" "$LEARNING_LOG" 2>/dev/null | head -1) - fi - - if [ -n "$EXISTING_LINE" ]; then - # Update existing: increment count, update last_seen, merge evidence - OLD_COUNT=$(echo "$EXISTING_LINE" | json_field "observations" "0") - NEW_COUNT=$((OLD_COUNT + 1)) - FIRST_SEEN=$(echo "$EXISTING_LINE" | json_field "first_seen" "") - if [ "$_HAS_JQ" = "true" ]; then - OLD_EVIDENCE=$(echo "$EXISTING_LINE" | jq -c '.evidence' 2>/dev/null) - else - OLD_EVIDENCE=$(echo "$EXISTING_LINE" | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));console.log(JSON.stringify(d.evidence||[]))" 2>/dev/null) - fi - MERGED_EVIDENCE=$(echo "[$OLD_EVIDENCE, $OBS_EVIDENCE]" | json_merge_evidence) - - calculate_confidence "$NEW_COUNT" - - local CURRENT_STATUS - CURRENT_STATUS=$(echo "$EXISTING_LINE" | json_field "status" "") - ARTIFACT_PATH=$(echo "$EXISTING_LINE" | json_field "artifact_path" "") - - check_temporal_spread "$FIRST_SEEN" "$CONF_RAW" "$CURRENT_STATUS" - - # Build updated entry - UPDATED=$(json_obs_construct_full \ - --arg id "$OBS_ID" \ - --arg type "$OBS_TYPE" \ - --arg pattern "$OBS_PATTERN" \ - --argjson confidence "$CONF" \ - --argjson observations "$NEW_COUNT" \ - --arg first_seen "$FIRST_SEEN" \ - --arg last_seen "$NOW_ISO" \ - --arg status "$STATUS" \ - --argjson evidence "$MERGED_EVIDENCE" \ - --arg details "$OBS_DETAILS" \ - --arg artifact_path "$ARTIFACT_PATH") - - # Replace line in file - TEMP_LOG="$LEARNING_LOG.tmp" - grep -vF "\"id\":\"$OBS_ID\"" "$LEARNING_LOG" > "$TEMP_LOG" 2>/dev/null || true - echo "$UPDATED" >> "$TEMP_LOG" - mv "$TEMP_LOG" "$LEARNING_LOG" - - log "Updated observation $OBS_ID: count=$NEW_COUNT confidence=$CONF status=$STATUS" - else - # New observation (both types start at 0.33 = 1/3) - CONF="0.33" - - NEW_ENTRY=$(json_obs_construct \ - --arg id "$OBS_ID" \ - --arg type "$OBS_TYPE" \ - --arg pattern "$OBS_PATTERN" \ - --argjson confidence "$CONF" \ - --argjson observations 1 \ - --arg first_seen "$NOW_ISO" \ - --arg last_seen "$NOW_ISO" \ - --arg status "observing" \ - --argjson evidence "$OBS_EVIDENCE" \ - --arg details "$OBS_DETAILS") - - echo "$NEW_ENTRY" >> "$LEARNING_LOG" - log "New observation $OBS_ID: type=$OBS_TYPE confidence=$CONF" - fi - done + local result + result=$(node "$_JSON_HELPER" process-observations "$RESPONSE_FILE" "$LEARNING_LOG" 2>> "$LOG_FILE") || return + [ "$DEBUG" = "true" ] && log "Observations: $result" } # --- Create Artifacts --- -# Write a command artifact file with frontmatter. -write_command_artifact() { - local art_path="$1" art_desc="$2" art_content="$3" art_date="$4" art_conf="$5" art_obs_n="$6" - printf '%s\n' "---" \ - "description: \"$art_desc\"" \ - "# devflow-learning: auto-generated ($art_date, confidence: $art_conf, obs: $art_obs_n)" \ - "---" \ - "" > "$art_path" - printf '%s\n' "$art_content" >> "$art_path" -} - -# Write a skill artifact file with frontmatter. -write_skill_artifact() { - local art_path="$1" art_name="$2" art_desc="$3" art_content="$4" art_date="$5" art_conf="$6" art_obs_n="$7" - printf '%s\n' "---" \ - "name: self-learning:$art_name" \ - "description: \"$art_desc\"" \ - "user-invocable: false" \ - "allowed-tools: Read, Grep, Glob" \ - "# devflow-learning: auto-generated ($art_date, confidence: $art_conf, obs: $art_obs_n)" \ - "---" \ - "" > "$art_path" - printf '%s\n' "$art_content" >> "$art_path" -} - create_artifacts() { - ART_COUNT=$(echo "$RESPONSE" | json_array_length "artifacts") - if [ "$ART_COUNT" -le 0 ]; then - return - fi - - for i in $(seq 0 $((ART_COUNT - 1))); do - ART=$(echo "$RESPONSE" | json_array_item "artifacts" "$i") - ART_OBS_ID=$(echo "$ART" | json_field "observation_id" "") - ART_TYPE=$(echo "$ART" | json_field "type" "") - ART_NAME=$(echo "$ART" | json_field "name" "") - ART_DESC=$(echo "$ART" | json_field "description" "") - ART_CONTENT=$(echo "$ART" | json_field "content" "") - - # Sanitize ART_NAME — strict kebab-case allowlist (model-generated input) - ART_NAME=$(echo "$ART_NAME" | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9-' | head -c 50) - if [ -z "$ART_NAME" ]; then - log "Skipping artifact with empty/invalid name" - continue - fi - - # Escape double quotes in description for YAML frontmatter safety - ART_DESC=$(echo "$ART_DESC" | sed 's/"/\\"/g' | tr -d '\n') - - # Check the observation's status — only create if ready - if [ -f "$LEARNING_LOG" ]; then - OBS_STATUS=$(grep -F "\"id\":\"$ART_OBS_ID\"" "$LEARNING_LOG" 2>/dev/null | json_field "status" "") - if [ "$OBS_STATUS" != "ready" ]; then - log "Skipping artifact for $ART_OBS_ID (status: $OBS_STATUS, need: ready)" - continue - fi - fi - - if [ "$ART_TYPE" = "command" ]; then - ART_DIR="$CWD/.claude/commands/self-learning" - ART_PATH="$ART_DIR/$ART_NAME.md" - else - ART_DIR="$CWD/.claude/skills/$ART_NAME" - ART_PATH="$ART_DIR/SKILL.md" - fi - - # Never overwrite existing files (user customization preserved) - if [ -f "$ART_PATH" ]; then - log "Artifact already exists at $ART_PATH — skipping" - continue - fi - - mkdir -p "$ART_DIR" - - # Precompute metadata (safe — our own data, not model-generated) - ART_DATE=$(date +%Y-%m-%d) - ART_CONF=$(grep -F "\"id\":\"$ART_OBS_ID\"" "$LEARNING_LOG" 2>/dev/null | json_field "confidence" "0") - ART_OBS_N=$(grep -F "\"id\":\"$ART_OBS_ID\"" "$LEARNING_LOG" 2>/dev/null | json_field "observations" "0") - - # Write artifact with learning marker (printf %s prevents shell expansion) - if [ "$ART_TYPE" = "command" ]; then - write_command_artifact "$ART_PATH" "$ART_DESC" "$ART_CONTENT" "$ART_DATE" "$ART_CONF" "$ART_OBS_N" - else - write_skill_artifact "$ART_PATH" "$ART_NAME" "$ART_DESC" "$ART_CONTENT" "$ART_DATE" "$ART_CONF" "$ART_OBS_N" - fi - - # Single-pass status update: update matching observation in learning log - if [ "$_HAS_JQ" = "true" ]; then - TEMP_LOG="$LEARNING_LOG.tmp" - jq -c --arg obs_id "$ART_OBS_ID" --arg art_path "$ART_PATH" ' - if .id == $obs_id then .status = "created" | .artifact_path = $art_path else . end - ' "$LEARNING_LOG" > "$TEMP_LOG" 2>/dev/null - mv "$TEMP_LOG" "$LEARNING_LOG" - else - TEMP_LOG="$LEARNING_LOG.tmp" - node -e " - const fs = require('fs'); - const lines = fs.readFileSync('$LEARNING_LOG','utf8').trim().split('\n').filter(Boolean); - const out = lines.map(l => { - try { - const o = JSON.parse(l); - if (o.id === '$ART_OBS_ID') { o.status = 'created'; o.artifact_path = '$ART_PATH'; } - return JSON.stringify(o); - } catch { return l; } - }); - fs.writeFileSync('$LEARNING_LOG.tmp', out.join('\n') + '\n'); - " 2>/dev/null - mv "$TEMP_LOG" "$LEARNING_LOG" - fi - - log "Created artifact: $ART_PATH" - done + local result + result=$(node "$_JSON_HELPER" create-artifacts "$RESPONSE_FILE" "$LEARNING_LOG" "$CWD" 2>> "$LOG_FILE") || return + [ "$DEBUG" = "true" ] && log "Artifacts: $result" } # --- Main --- @@ -813,6 +487,9 @@ fi process_observations create_artifacts +# Clean up response file +rm -f "$RESPONSE_FILE" + # Note: daily counter already incremented by session-end-learning before spawning us log "Learning analysis complete (batch mode)" diff --git a/scripts/hooks/json-parse b/scripts/hooks/json-parse index e0a7c12..6ca91c3 100755 --- a/scripts/hooks/json-parse +++ b/scripts/hooks/json-parse @@ -124,17 +124,6 @@ json_update_field_json() { # --- Slurp operations --- -# Slurp JSONL file, sort by field desc, output as JSON array. -# Usage: json_slurp_sort "file.jsonl" "confidence" 30 -json_slurp_sort() { - local file="$1" field="$2" limit="${3:-30}" - if [ "$_HAS_JQ" = "true" ]; then - jq -s "sort_by(.$field) | reverse | .[0:$limit]" "$file" 2>/dev/null - else - node "$_JSON_HELPER" slurp-sort "$file" "$field" "$limit" - fi -} - # Slurp JSONL file, sort by field desc, output as JSONL (one line per entry). # Usage: json_slurp_cap "file.jsonl" "confidence" 100 json_slurp_cap() { @@ -183,17 +172,6 @@ json_extract_messages() { fi } -# --- Evidence merging --- - -# Merge two evidence arrays. Usage: echo '[[old], [new]]' | json_merge_evidence -json_merge_evidence() { - if [ "$_HAS_JQ" = "true" ]; then - jq -c 'flatten | unique | .[0:10]' 2>/dev/null - else - node "$_JSON_HELPER" merge-evidence - fi -} - # --- Hook output envelopes --- # Build SessionStart output. Usage: json_session_output "$CONTEXT" @@ -277,21 +255,3 @@ json_learning_new() { node "$_JSON_HELPER" learning-new "$file" "$since" fi } - -# Build observation JSON. Usage: json_obs_construct --arg id X --arg type Y ... -json_obs_construct() { - if [ "$_HAS_JQ" = "true" ]; then - jq -n -c "$@" '{id: $id, type: $type, pattern: $pattern, confidence: $confidence, observations: $observations, first_seen: $first_seen, last_seen: $last_seen, status: $status, evidence: $evidence, details: $details}' - else - node "$_JSON_HELPER" obs-construct "$@" - fi -} - -# Build observation JSON with artifact_path. Usage: json_obs_construct_full --arg id X ... -json_obs_construct_full() { - if [ "$_HAS_JQ" = "true" ]; then - jq -n -c "$@" '{id: $id, type: $type, pattern: $pattern, confidence: $confidence, observations: $observations, first_seen: $first_seen, last_seen: $last_seen, status: $status, evidence: $evidence, details: $details, artifact_path: $artifact_path}' - else - node "$_JSON_HELPER" obs-construct "$@" - fi -} diff --git a/tests/shell-hooks.test.ts b/tests/shell-hooks.test.ts index 5623364..5ec0479 100644 --- a/tests/shell-hooks.test.ts +++ b/tests/shell-hooks.test.ts @@ -36,31 +36,6 @@ describe('shell hook syntax checks', () => { }); describe('background-learning pure functions', () => { - it('decay_factor returns correct values for all periods', () => { - const expected: Record = { - '0': '100', '1': '90', '2': '81', - '3': '73', '4': '66', '5': '59', - '6': '53', '10': '53', '99': '53', - }; - - for (const [input, output] of Object.entries(expected)) { - const result = execSync( - `bash -c ' - decay_factor() { - case $1 in - 0) echo "100";; 1) echo "90";; 2) echo "81";; - 3) echo "73";; 4) echo "66";; 5) echo "59";; - *) echo "53";; - esac - } - decay_factor ${input} - '`, - { stdio: 'pipe' }, - ).toString().trim(); - expect(result).toBe(output); - } - }); - it('check_daily_cap respects counter file', () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'devflow-test-')); const counterFile = path.join(tmpDir, '.learning-runs-today'); @@ -339,6 +314,771 @@ describe('json-helper.js operations', () => { }); }); +describe('json-helper.cjs temporal-decay', () => { + it('applies decay to old entries', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'devflow-test-')); + const file = path.join(tmpDir, 'learning.jsonl'); + try { + const oldDate = new Date(Date.now() - 35 * 86400000).toISOString(); + fs.writeFileSync(file, JSON.stringify({ + id: 'obs_test1', confidence: 0.66, last_seen: oldDate, + }) + '\n'); + + const result = execSync( + `node "${JSON_HELPER}" temporal-decay "${file}"`, + { stdio: ['pipe', 'pipe', 'pipe'] }, + ).toString().trim(); + const counts = JSON.parse(result); + expect(counts.decayed).toBe(1); + + const updated = fs.readFileSync(file, 'utf8').trim(); + const entry = JSON.parse(updated); + expect(entry.confidence).toBeLessThan(0.66); + expect(entry.confidence).toBeGreaterThan(0); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('removes entries below threshold', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'devflow-test-')); + const file = path.join(tmpDir, 'learning.jsonl'); + try { + const oldDate = new Date(Date.now() - 200 * 86400000).toISOString(); + fs.writeFileSync(file, JSON.stringify({ + id: 'obs_test1', confidence: 0.15, last_seen: oldDate, + }) + '\n'); + + const result = execSync( + `node "${JSON_HELPER}" temporal-decay "${file}"`, + { stdio: ['pipe', 'pipe', 'pipe'] }, + ).toString().trim(); + const counts = JSON.parse(result); + expect(counts.removed).toBe(1); + + const content = fs.readFileSync(file, 'utf8').trim(); + expect(content).toBe(''); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('preserves fresh entries', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'devflow-test-')); + const file = path.join(tmpDir, 'learning.jsonl'); + try { + const recentDate = new Date().toISOString(); + fs.writeFileSync(file, JSON.stringify({ + id: 'obs_test1', confidence: 0.66, last_seen: recentDate, + }) + '\n'); + + const result = execSync( + `node "${JSON_HELPER}" temporal-decay "${file}"`, + { stdio: ['pipe', 'pipe', 'pipe'] }, + ).toString().trim(); + const counts = JSON.parse(result); + expect(counts.decayed).toBe(0); + expect(counts.removed).toBe(0); + + const entry = JSON.parse(fs.readFileSync(file, 'utf8').trim()); + expect(entry.confidence).toBe(0.66); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('handles missing file gracefully', () => { + const result = execSync( + `node "${JSON_HELPER}" temporal-decay "/tmp/nonexistent-devflow-test-${Date.now()}.jsonl"`, + { stdio: ['pipe', 'pipe', 'pipe'] }, + ).toString().trim(); + const counts = JSON.parse(result); + expect(counts.removed).toBe(0); + expect(counts.decayed).toBe(0); + }); + + it('handles empty file', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'devflow-test-')); + const file = path.join(tmpDir, 'learning.jsonl'); + try { + fs.writeFileSync(file, ''); + const result = execSync( + `node "${JSON_HELPER}" temporal-decay "${file}"`, + { stdio: ['pipe', 'pipe', 'pipe'] }, + ).toString().trim(); + const counts = JSON.parse(result); + expect(counts.removed).toBe(0); + expect(counts.decayed).toBe(0); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('returns correct counts for mixed entries', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'devflow-test-')); + const file = path.join(tmpDir, 'learning.jsonl'); + try { + const old35 = new Date(Date.now() - 35 * 86400000).toISOString(); + const old200 = new Date(Date.now() - 200 * 86400000).toISOString(); + const recent = new Date().toISOString(); + fs.writeFileSync(file, [ + JSON.stringify({ id: 'obs_a', confidence: 0.66, last_seen: old35 }), + JSON.stringify({ id: 'obs_b', confidence: 0.15, last_seen: old200 }), + JSON.stringify({ id: 'obs_c', confidence: 0.95, last_seen: recent }), + ].join('\n') + '\n'); + + const result = execSync( + `node "${JSON_HELPER}" temporal-decay "${file}"`, + { stdio: ['pipe', 'pipe', 'pipe'] }, + ).toString().trim(); + const counts = JSON.parse(result); + expect(counts.decayed).toBe(1); + expect(counts.removed).toBe(1); + + const lines = fs.readFileSync(file, 'utf8').trim().split('\n'); + expect(lines).toHaveLength(2); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); + +describe('json-helper.cjs process-observations', () => { + it('creates new observations', () => { + 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(responseFile, JSON.stringify({ + observations: [{ + id: 'obs_abc123', type: 'workflow', pattern: 'test pattern', + evidence: ['evidence1'], details: 'test details', + }], + })); + + const result = execSync( + `node "${JSON_HELPER}" process-observations "${responseFile}" "${logFile}"`, + { stdio: ['pipe', 'pipe', 'pipe'] }, + ).toString().trim(); + const counts = JSON.parse(result); + expect(counts.created).toBe(1); + expect(counts.updated).toBe(0); + + const entry = JSON.parse(fs.readFileSync(logFile, 'utf8').trim()); + expect(entry.id).toBe('obs_abc123'); + expect(entry.confidence).toBe(0.33); + expect(entry.status).toBe('observing'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('updates existing observations', () => { + 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: 'old pattern', + confidence: 0.33, observations: 1, + first_seen: '2026-03-20T00:00:00Z', last_seen: '2026-03-20T00:00:00Z', + status: 'observing', evidence: ['old evidence'], details: 'old', + }) + '\n'); + + fs.writeFileSync(responseFile, JSON.stringify({ + observations: [{ + id: 'obs_abc123', type: 'workflow', pattern: 'updated pattern', + evidence: ['new evidence'], details: 'updated', + }], + })); + + const result = execSync( + `node "${JSON_HELPER}" process-observations "${responseFile}" "${logFile}"`, + { stdio: ['pipe', 'pipe', 'pipe'] }, + ).toString().trim(); + const counts = JSON.parse(result); + expect(counts.updated).toBe(1); + + const entry = JSON.parse(fs.readFileSync(logFile, 'utf8').trim()); + expect(entry.observations).toBe(2); + expect(entry.confidence).toBe(0.66); + expect(entry.evidence).toContain('old evidence'); + expect(entry.evidence).toContain('new evidence'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('skips observations with missing fields', () => { + 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(responseFile, JSON.stringify({ + observations: [ + { id: 'obs_abc123', type: 'workflow' }, + ], + })); + + const result = execSync( + `node "${JSON_HELPER}" process-observations "${responseFile}" "${logFile}"`, + { stdio: ['pipe', 'pipe', 'pipe'] }, + ).toString().trim(); + const counts = JSON.parse(result); + expect(counts.skipped).toBe(1); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('skips observations with invalid type', () => { + 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(responseFile, JSON.stringify({ + observations: [ + { id: 'obs_abc123', type: 'invalid', pattern: 'test' }, + ], + })); + + const result = execSync( + `node "${JSON_HELPER}" process-observations "${responseFile}" "${logFile}"`, + { stdio: ['pipe', 'pipe', 'pipe'] }, + ).toString().trim(); + const counts = JSON.parse(result); + expect(counts.skipped).toBe(1); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('skips observations with invalid id format', () => { + 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(responseFile, JSON.stringify({ + observations: [ + { id: 'bad_id', type: 'workflow', pattern: 'test' }, + ], + })); + + const result = execSync( + `node "${JSON_HELPER}" process-observations "${responseFile}" "${logFile}"`, + { stdio: ['pipe', 'pipe', 'pipe'] }, + ).toString().trim(); + const counts = JSON.parse(result); + expect(counts.skipped).toBe(1); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('calculates confidence correctly from count', () => { + 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.66, observations: 2, + first_seen: '2026-03-20T00:00:00Z', last_seen: '2026-03-20T00:00:00Z', + status: 'observing', evidence: [], details: '', + }) + '\n'); + + fs.writeFileSync(responseFile, JSON.stringify({ + observations: [{ id: 'obs_abc123', type: 'workflow', pattern: 'test', evidence: [] }], + })); + + execSync( + `node "${JSON_HELPER}" process-observations "${responseFile}" "${logFile}"`, + { stdio: ['pipe', 'pipe', 'pipe'] }, + ); + + const entry = JSON.parse(fs.readFileSync(logFile, 'utf8').trim()); + expect(entry.confidence).toBe(0.95); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('sets ready on temporal spread', () => { + 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 { + const twoDaysAgo = new Date(Date.now() - 2 * 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, + status: 'observing', evidence: [], details: '', + }) + '\n'); + + fs.writeFileSync(responseFile, JSON.stringify({ + observations: [{ id: 'obs_abc123', type: 'workflow', pattern: 'test', evidence: [] }], + })); + + execSync( + `node "${JSON_HELPER}" process-observations "${responseFile}" "${logFile}"`, + { stdio: ['pipe', 'pipe', 'pipe'] }, + ); + + const entry = JSON.parse(fs.readFileSync(logFile, 'utf8').trim()); + expect(entry.status).toBe('ready'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('preserves created status', () => { + 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, + first_seen: '2026-03-20T00:00:00Z', last_seen: '2026-03-22T00:00:00Z', + status: 'created', evidence: [], details: '', + artifact_path: '/some/path.md', + }) + '\n'); + + fs.writeFileSync(responseFile, JSON.stringify({ + observations: [{ id: 'obs_abc123', type: 'workflow', pattern: 'test', evidence: ['new'] }], + })); + + execSync( + `node "${JSON_HELPER}" process-observations "${responseFile}" "${logFile}"`, + { stdio: ['pipe', 'pipe', 'pipe'] }, + ); + + const entry = JSON.parse(fs.readFileSync(logFile, 'utf8').trim()); + expect(entry.status).toBe('created'); + expect(entry.observations).toBe(4); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('handles missing log file by creating it', () => { + 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(responseFile, JSON.stringify({ + observations: [{ + id: 'obs_abc123', type: 'workflow', pattern: 'test', evidence: [], + }], + })); + + const result = execSync( + `node "${JSON_HELPER}" process-observations "${responseFile}" "${logFile}"`, + { stdio: ['pipe', 'pipe', 'pipe'] }, + ).toString().trim(); + const counts = JSON.parse(result); + expect(counts.created).toBe(1); + expect(fs.existsSync(logFile)).toBe(true); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('returns correct counts for mixed operations', () => { + 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_exist1', type: 'workflow', pattern: 'existing', + confidence: 0.33, observations: 1, + first_seen: '2026-03-20T00:00:00Z', last_seen: '2026-03-20T00:00:00Z', + status: 'observing', evidence: [], details: '', + }) + '\n'); + + fs.writeFileSync(responseFile, JSON.stringify({ + observations: [ + { id: 'obs_exist1', type: 'workflow', pattern: 'existing', evidence: [] }, + { id: 'obs_new001', type: 'procedural', pattern: 'new pattern', evidence: [] }, + { id: 'bad', type: 'workflow', pattern: 'test' }, + ], + })); + + const result = execSync( + `node "${JSON_HELPER}" process-observations "${responseFile}" "${logFile}"`, + { stdio: ['pipe', 'pipe', 'pipe'] }, + ).toString().trim(); + const counts = JSON.parse(result); + expect(counts.updated).toBe(1); + expect(counts.created).toBe(1); + expect(counts.skipped).toBe(1); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); + +describe('json-helper.cjs create-artifacts', () => { + it('creates command with correct frontmatter', () => { + 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: 'deploy flow', + 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: 'deploy-flow', description: 'Deploy workflow', + content: '# Deploy Flow\nDeploy the app.', + }], + })); + + const result = execSync( + `node "${JSON_HELPER}" create-artifacts "${responseFile}" "${logFile}" "${tmpDir}"`, + { stdio: ['pipe', 'pipe', 'pipe'] }, + ).toString().trim(); + const counts = JSON.parse(result); + expect(counts.created).toHaveLength(1); + + const artPath = path.join(tmpDir, '.claude', 'commands', 'self-learning', 'deploy-flow.md'); + expect(fs.existsSync(artPath)).toBe(true); + const content = fs.readFileSync(artPath, 'utf8'); + expect(content).toContain('description: "Deploy workflow"'); + expect(content).toContain('devflow-learning: auto-generated'); + expect(content).toContain('# Deploy Flow'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('creates skill with correct frontmatter', () => { + 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: 'procedural', pattern: 'debug hooks', + 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: 'skill', + name: 'debug-hooks', description: 'Debug hook issues', + content: '# Debug Hooks\nHow to debug hooks.', + }], + })); + + const result = execSync( + `node "${JSON_HELPER}" create-artifacts "${responseFile}" "${logFile}" "${tmpDir}"`, + { stdio: ['pipe', 'pipe', 'pipe'] }, + ).toString().trim(); + const counts = JSON.parse(result); + expect(counts.created).toHaveLength(1); + + const artPath = path.join(tmpDir, '.claude', 'skills', 'debug-hooks', 'SKILL.md'); + expect(fs.existsSync(artPath)).toBe(true); + const content = fs.readFileSync(artPath, 'utf8'); + expect(content).toContain('name: self-learning:debug-hooks'); + expect(content).toContain('user-invocable: false'); + expect(content).toContain('allowed-tools: Read, Grep, Glob'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('skips non-ready observations', () => { + 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.33, observations: 1, status: 'observing', + evidence: [], details: '', + }) + '\n'); + + fs.writeFileSync(responseFile, JSON.stringify({ + artifacts: [{ + observation_id: 'obs_abc123', type: 'command', + name: 'test-cmd', description: 'Test', content: 'Test', + }], + })); + + const result = execSync( + `node "${JSON_HELPER}" create-artifacts "${responseFile}" "${logFile}" "${tmpDir}"`, + { stdio: ['pipe', 'pipe', 'pipe'] }, + ).toString().trim(); + const counts = JSON.parse(result); + expect(counts.skipped).toBe(1); + expect(counts.created).toHaveLength(0); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('skips empty or invalid names', () => { + 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', + evidence: [], details: '', + }) + '\n'); + + fs.writeFileSync(responseFile, JSON.stringify({ + artifacts: [{ + observation_id: 'obs_abc123', type: 'command', + name: '!!!', description: 'Test', content: 'Test', + }], + })); + + const result = execSync( + `node "${JSON_HELPER}" create-artifacts "${responseFile}" "${logFile}" "${tmpDir}"`, + { stdio: ['pipe', 'pipe', 'pipe'] }, + ).toString().trim(); + const counts = JSON.parse(result); + expect(counts.skipped).toBe(1); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('never overwrites existing files', () => { + 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', + evidence: [], details: '', + }) + '\n'); + + const artDir = path.join(tmpDir, '.claude', 'commands', 'self-learning'); + fs.mkdirSync(artDir, { recursive: true }); + fs.writeFileSync(path.join(artDir, 'existing.md'), 'USER CONTENT'); + + fs.writeFileSync(responseFile, JSON.stringify({ + artifacts: [{ + observation_id: 'obs_abc123', type: 'command', + name: 'existing', description: 'Test', content: 'New content', + }], + })); + + const result = execSync( + `node "${JSON_HELPER}" create-artifacts "${responseFile}" "${logFile}" "${tmpDir}"`, + { stdio: ['pipe', 'pipe', 'pipe'] }, + ).toString().trim(); + const counts = JSON.parse(result); + expect(counts.skipped).toBe(1); + + const content = fs.readFileSync(path.join(artDir, 'existing.md'), 'utf8'); + expect(content).toBe('USER CONTENT'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('sanitizes artifact name', () => { + 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', + evidence: [], details: '', + }) + '\n'); + + fs.writeFileSync(responseFile, JSON.stringify({ + artifacts: [{ + observation_id: 'obs_abc123', type: 'command', + name: 'My Cool_Command!@#', description: 'Test', content: 'Test', + }], + })); + + const result = execSync( + `node "${JSON_HELPER}" create-artifacts "${responseFile}" "${logFile}" "${tmpDir}"`, + { stdio: ['pipe', 'pipe', 'pipe'] }, + ).toString().trim(); + const counts = JSON.parse(result); + expect(counts.created).toHaveLength(1); + expect(counts.created[0]).toContain('mycoolcommand'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('updates observation status in log', () => { + 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', + evidence: [], details: '', + }) + '\n'); + + fs.writeFileSync(responseFile, JSON.stringify({ + artifacts: [{ + observation_id: 'obs_abc123', type: 'command', + name: 'test-cmd', description: 'Test', content: 'Test', + }], + })); + + execSync( + `node "${JSON_HELPER}" create-artifacts "${responseFile}" "${logFile}" "${tmpDir}"`, + { stdio: ['pipe', 'pipe', 'pipe'] }, + ); + + const entry = JSON.parse(fs.readFileSync(logFile, 'utf8').trim()); + expect(entry.status).toBe('created'); + expect(entry.artifact_path).toContain('test-cmd.md'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('escapes description quotes', () => { + 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', + evidence: [], details: '', + }) + '\n'); + + fs.writeFileSync(responseFile, JSON.stringify({ + artifacts: [{ + observation_id: 'obs_abc123', type: 'command', + name: 'test-cmd', description: 'A "quoted" description', content: 'Test', + }], + })); + + execSync( + `node "${JSON_HELPER}" create-artifacts "${responseFile}" "${logFile}" "${tmpDir}"`, + { stdio: ['pipe', 'pipe', 'pipe'] }, + ); + + const artPath = path.join(tmpDir, '.claude', 'commands', 'self-learning', 'test-cmd.md'); + const content = fs.readFileSync(artPath, 'utf8'); + expect(content).toContain('description: "A \\"quoted\\" description"'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('returns created paths', () => { + 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: 'test1', + confidence: 0.95, observations: 3, status: 'ready', + evidence: [], details: '', + }), + JSON.stringify({ + id: 'obs_def456', type: 'procedural', pattern: 'test2', + confidence: 0.95, observations: 3, status: 'ready', + evidence: [], details: '', + }), + ].join('\n') + '\n'); + + fs.writeFileSync(responseFile, JSON.stringify({ + artifacts: [ + { observation_id: 'obs_abc123', type: 'command', name: 'cmd1', description: 'Cmd 1', content: 'C1' }, + { observation_id: 'obs_def456', type: 'skill', name: 'skill1', description: 'Skill 1', content: 'S1' }, + ], + })); + + const result = execSync( + `node "${JSON_HELPER}" create-artifacts "${responseFile}" "${logFile}" "${tmpDir}"`, + { stdio: ['pipe', 'pipe', 'pipe'] }, + ).toString().trim(); + const counts = JSON.parse(result); + expect(counts.created).toHaveLength(2); + expect(counts.created[0]).toContain('cmd1.md'); + expect(counts.created[1]).toContain('SKILL.md'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); + +describe('json-helper.cjs filter-observations', () => { + it('returns valid entries as sorted array', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'devflow-test-')); + const file = path.join(tmpDir, 'learning.jsonl'); + try { + fs.writeFileSync(file, [ + JSON.stringify({ id: 'obs_a', type: 'workflow', pattern: 'p1', confidence: 0.3 }), + JSON.stringify({ id: 'obs_b', type: 'procedural', pattern: 'p2', confidence: 0.9 }), + JSON.stringify({ id: 'obs_c', type: 'workflow', pattern: 'p3', confidence: 0.5 }), + ].join('\n') + '\n'); + + const result = execSync( + `node "${JSON_HELPER}" filter-observations "${file}" confidence 2`, + { stdio: ['pipe', 'pipe', 'pipe'] }, + ).toString().trim(); + const parsed = JSON.parse(result); + expect(parsed).toHaveLength(2); + expect(parsed[0].id).toBe('obs_b'); + expect(parsed[1].id).toBe('obs_c'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('filters out malformed entries', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'devflow-test-')); + const file = path.join(tmpDir, 'learning.jsonl'); + try { + fs.writeFileSync(file, [ + JSON.stringify({ id: 'obs_valid', type: 'workflow', pattern: 'valid', confidence: 0.5 }), + JSON.stringify({ id: 'bad_id', type: 'workflow', pattern: 'bad id' }), + JSON.stringify({ id: 'obs_notype', pattern: 'no type' }), + JSON.stringify({ id: 'obs_nopattern', type: 'workflow' }), + 'not json at all', + ].join('\n') + '\n'); + + const result = execSync( + `node "${JSON_HELPER}" filter-observations "${file}"`, + { stdio: ['pipe', 'pipe', 'pipe'] }, + ).toString().trim(); + const parsed = JSON.parse(result); + expect(parsed).toHaveLength(1); + expect(parsed[0].id).toBe('obs_valid'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('returns empty array for missing file', () => { + const result = execSync( + `node "${JSON_HELPER}" filter-observations "/tmp/nonexistent-devflow-test-${Date.now()}.jsonl"`, + { stdio: ['pipe', 'pipe', 'pipe'] }, + ).toString().trim(); + expect(result).toBe('[]'); + }); +}); + describe('session-end-learning structure', () => { it('is included in bash -n syntax checks', () => { expect(HOOK_SCRIPTS).toContain('session-end-learning');