eagle-mem 2.0.7 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/eagle-mem CHANGED
@@ -27,6 +27,9 @@ case "$command" in
27
27
  refresh) bash "$SCRIPTS_DIR/refresh.sh" "$@" ;;
28
28
  prune) bash "$SCRIPTS_DIR/prune.sh" "$@" ;;
29
29
  memories) bash "$SCRIPTS_DIR/memories.sh" "$@" ;;
30
+ config) bash "$SCRIPTS_DIR/config.sh" "$@" ;;
31
+ curate) bash "$SCRIPTS_DIR/curate.sh" "$@" ;;
32
+ feature) bash "$SCRIPTS_DIR/feature.sh" "$@" ;;
30
33
  help|--help|-h)
31
34
  bash "$SCRIPTS_DIR/help.sh" ;;
32
35
  version|--version|-v|-V)
@@ -0,0 +1,69 @@
1
+ -- Migration 013: Feature graph for deployment regression prevention
2
+ -- Features are persistent entities with lifecycle, dependencies, files, and smoke tests.
3
+ -- Auto-discovered by curator from accumulated session data (tasks, key_files, decisions).
4
+
5
+ CREATE TABLE IF NOT EXISTS features (
6
+ id INTEGER PRIMARY KEY,
7
+ project TEXT NOT NULL,
8
+ name TEXT NOT NULL,
9
+ description TEXT,
10
+ status TEXT DEFAULT 'active',
11
+ last_verified_at TIMESTAMP,
12
+ last_verified_notes TEXT,
13
+ created_at TIMESTAMP DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
14
+ updated_at TIMESTAMP DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
15
+ UNIQUE(project, name)
16
+ );
17
+
18
+ CREATE TABLE IF NOT EXISTS feature_dependencies (
19
+ id INTEGER PRIMARY KEY,
20
+ feature_id INTEGER NOT NULL,
21
+ kind TEXT NOT NULL,
22
+ target TEXT NOT NULL,
23
+ name TEXT NOT NULL,
24
+ notes TEXT,
25
+ FOREIGN KEY (feature_id) REFERENCES features(id) ON DELETE CASCADE,
26
+ UNIQUE(feature_id, kind, target, name)
27
+ );
28
+
29
+ CREATE TABLE IF NOT EXISTS feature_files (
30
+ id INTEGER PRIMARY KEY,
31
+ feature_id INTEGER NOT NULL,
32
+ file_path TEXT NOT NULL,
33
+ role TEXT,
34
+ FOREIGN KEY (feature_id) REFERENCES features(id) ON DELETE CASCADE,
35
+ UNIQUE(feature_id, file_path)
36
+ );
37
+
38
+ CREATE TABLE IF NOT EXISTS feature_smoke_tests (
39
+ id INTEGER PRIMARY KEY,
40
+ feature_id INTEGER NOT NULL,
41
+ command TEXT NOT NULL,
42
+ description TEXT,
43
+ FOREIGN KEY (feature_id) REFERENCES features(id) ON DELETE CASCADE
44
+ );
45
+
46
+ -- FTS5 for feature search
47
+ CREATE VIRTUAL TABLE IF NOT EXISTS features_fts USING fts5(
48
+ name,
49
+ description,
50
+ content='features',
51
+ content_rowid='id'
52
+ );
53
+
54
+ CREATE TRIGGER IF NOT EXISTS features_ai AFTER INSERT ON features BEGIN
55
+ INSERT INTO features_fts(rowid, name, description)
56
+ VALUES (new.id, new.name, new.description);
57
+ END;
58
+
59
+ CREATE TRIGGER IF NOT EXISTS features_ad AFTER DELETE ON features BEGIN
60
+ INSERT INTO features_fts(features_fts, rowid, name, description)
61
+ VALUES ('delete', old.id, old.name, old.description);
62
+ END;
63
+
64
+ CREATE TRIGGER IF NOT EXISTS features_au AFTER UPDATE ON features BEGIN
65
+ INSERT INTO features_fts(features_fts, rowid, name, description)
66
+ VALUES ('delete', old.id, old.name, old.description);
67
+ INSERT INTO features_fts(rowid, name, description)
68
+ VALUES (new.id, new.name, new.description);
69
+ END;
@@ -0,0 +1,25 @@
1
+ -- Migration 014: Command intelligence — output size tracking + command rules
2
+ -- Adds output metrics to observations for adaptive command filtering.
3
+
4
+ ALTER TABLE observations ADD COLUMN output_bytes INTEGER;
5
+ ALTER TABLE observations ADD COLUMN output_lines INTEGER;
6
+ ALTER TABLE observations ADD COLUMN command_category TEXT;
7
+
8
+ -- Command rules table — populated by curator, consumed by PreToolUse hook
9
+ CREATE TABLE IF NOT EXISTS command_rules (
10
+ id INTEGER PRIMARY KEY,
11
+ project TEXT,
12
+ pattern TEXT NOT NULL,
13
+ strategy TEXT NOT NULL DEFAULT 'summary',
14
+ max_lines INTEGER,
15
+ reason TEXT,
16
+ times_seen INTEGER DEFAULT 0,
17
+ avg_output_bytes INTEGER DEFAULT 0,
18
+ enabled INTEGER DEFAULT 1,
19
+ created_at TIMESTAMP DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
20
+ updated_at TIMESTAMP DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
21
+ UNIQUE(project, pattern)
22
+ );
23
+
24
+ CREATE INDEX IF NOT EXISTS idx_command_rules_pattern ON command_rules(pattern);
25
+ CREATE INDEX IF NOT EXISTS idx_observations_category ON observations(command_category);
@@ -34,6 +34,9 @@ project=$(eagle_project_from_cwd "$cwd")
34
34
  files_read="[]"
35
35
  files_modified="[]"
36
36
  tool_summary=""
37
+ output_bytes=""
38
+ output_lines=""
39
+ command_category=""
37
40
 
38
41
  case "$tool_name" in
39
42
  Read)
@@ -53,24 +56,34 @@ case "$tool_name" in
53
56
  ;;
54
57
  Bash)
55
58
  cmd=$(echo "$input" | jq -r '.tool_input.command // empty' | cut -c1-200)
56
- # Redact common secret patterns before storing
57
- cmd=$(echo "$cmd" | sed -E \
58
- -e 's/(Bearer )[^ ]*/\1[REDACTED]/gi' \
59
- -e 's/(api[_-]?key[= :])[^ ]*/\1[REDACTED]/gi' \
60
- -e 's/(password[= :])[^ ]*/\1[REDACTED]/gi' \
61
- -e 's/(secret[= :])[^ ]*/\1[REDACTED]/gi' \
62
- -e 's/(token[= :])[^ ]*/\1[REDACTED]/gi' \
63
- -e 's/(Authorization: )[^ ]*/\1[REDACTED]/gi' \
64
- -e 's/sk_live_[A-Za-z0-9]+/[REDACTED]/g' \
65
- -e 's/sk_test_[A-Za-z0-9]+/[REDACTED]/g' \
66
- -e 's/AKIA[A-Z0-9]{16}/[REDACTED]/g' \
67
- -e 's/ghp_[A-Za-z0-9]{36}/[REDACTED]/g' \
68
- -e 's/gho_[A-Za-z0-9]{36}/[REDACTED]/g' \
69
- -e 's/sk-ant-[A-Za-z0-9_-]+/[REDACTED]/g' \
70
- -e 's/sk-[A-Za-z0-9]{20,}/[REDACTED]/g' \
71
- -e 's/(ANTHROPIC_API_KEY[= :])[^ ]*/\1[REDACTED]/g' \
72
- -e 's/(OPENAI_API_KEY[= :])[^ ]*/\1[REDACTED]/g')
59
+ cmd=$(echo "$cmd" | eagle_redact)
73
60
  tool_summary="Bash: $cmd"
61
+
62
+ # Output metrics
63
+ tool_output=$(echo "$input" | jq -r '.tool_result.stdout // empty' 2>/dev/null)
64
+ if [ -n "$tool_output" ]; then
65
+ output_bytes=${#tool_output}
66
+ output_lines=$(echo "$tool_output" | wc -l | tr -d ' ')
67
+ fi
68
+
69
+ # Command category extraction
70
+ first_word=$(echo "$cmd" | awk '{print $1}' | sed 's|.*/||')
71
+ case "$first_word" in
72
+ git|gh) command_category="git" ;;
73
+ npm|npx|pnpm|yarn|bun) command_category="js" ;;
74
+ pip|pip3|python|python3|uv) command_category="python" ;;
75
+ cargo|rustc) command_category="rust" ;;
76
+ go) command_category="go" ;;
77
+ docker|docker-compose|podman) command_category="docker" ;;
78
+ kubectl|helm|k9s) command_category="k8s" ;;
79
+ aws|gcloud|az) command_category="cloud" ;;
80
+ make|cmake|ninja) command_category="build" ;;
81
+ grep|find|ls|cat|head|tail|wc|sort|sed|awk) command_category="files" ;;
82
+ curl|wget|http) command_category="http" ;;
83
+ *test*|jest|pytest|vitest|mocha) command_category="test" ;;
84
+ *lint*|eslint|ruff|golangci-lint) command_category="lint" ;;
85
+ *) command_category="other" ;;
86
+ esac
74
87
  ;;
75
88
  TaskCreate|TaskUpdate)
76
89
  task_subject=$(echo "$input" | jq -r '.tool_input.subject // empty')
@@ -158,6 +171,72 @@ case "$tool_name" in
158
171
  ;;
159
172
  esac
160
173
 
161
- eagle_insert_observation "$session_id" "$project" "$tool_name" "$tool_summary" "$files_read" "$files_modified"
174
+ # ─── Decision + feature surfacing on Read ──────────────────
175
+ # When Claude reads a file, surface past decisions and feature pipeline context.
176
+ case "$tool_name" in
177
+ Read)
178
+ if [ -n "$fp" ]; then
179
+ fname=$(basename "$fp")
180
+ fname_stem="${fname%.*}"
181
+ read_context=""
182
+ case "$fp" in
183
+ "$HOME/.claude/"*) ;; # skip Claude config files
184
+ *)
185
+ p_esc=$(eagle_sql_escape "$project")
186
+
187
+ # Decision history from summaries
188
+ if [ ${#fname_stem} -ge 3 ]; then
189
+ fts_query=$(eagle_fts_sanitize "$fname_stem")
190
+ if [ -n "$fts_query" ]; then
191
+ fts_esc=$(eagle_sql_escape "$fts_query")
192
+ decision_hit=$(eagle_db "SELECT s.decisions
193
+ FROM summaries s
194
+ JOIN summaries_fts f ON f.rowid = s.id
195
+ WHERE summaries_fts MATCH '$fts_esc'
196
+ AND s.project = '$p_esc'
197
+ AND s.decisions IS NOT NULL
198
+ AND s.decisions != ''
199
+ ORDER BY s.created_at DESC
200
+ LIMIT 1;")
201
+ if [ -n "$decision_hit" ]; then
202
+ read_context+="Eagle Mem decision history for '${fname}': ${decision_hit} — Do not revert without explicit user request. "
203
+ fi
204
+ fi
205
+ fi
206
+
207
+ # Feature pipeline context
208
+ feature_hit=$(eagle_find_features_for_file "$project" "$fp")
209
+ if [ -n "$feature_hit" ]; then
210
+ while IFS='|' read -r feat_name feat_desc feat_verified _role feat_deps feat_other_files feat_smoke; do
211
+ [ -z "$feat_name" ] && continue
212
+ read_context+="Eagle Mem: '${fname}' is part of feature '${feat_name}'"
213
+ [ -n "$feat_desc" ] && read_context+=" ($feat_desc)"
214
+ read_context+="."
215
+ if [ -n "$feat_verified" ]; then
216
+ read_context+=" Last verified: ${feat_verified}."
217
+ fi
218
+ if [ -n "$feat_deps" ]; then
219
+ read_context+=" Dependencies: ${feat_deps}."
220
+ fi
221
+ if [ -n "$feat_other_files" ]; then
222
+ read_context+=" Other files in pipeline: ${feat_other_files}."
223
+ fi
224
+ if [ -n "$feat_smoke" ]; then
225
+ read_context+=" Smoke tests: ${feat_smoke}."
226
+ fi
227
+ read_context+=" Changes require re-testing after deploy. "
228
+ done <<< "$feature_hit"
229
+ fi
230
+
231
+ if [ -n "$read_context" ]; then
232
+ jq -nc --arg ctx "$read_context" '{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":$ctx}}'
233
+ fi
234
+ ;;
235
+ esac
236
+ fi
237
+ ;;
238
+ esac
239
+
240
+ eagle_insert_observation "$session_id" "$project" "$tool_name" "$tool_summary" "$files_read" "$files_modified" "$output_bytes" "$output_lines" "$command_category"
162
241
 
163
242
  exit 0
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env bash
2
+ # ═══════════════════════════════════════════════════════════
3
+ # Eagle Mem — PreToolUse hook
4
+ # Fires before every Bash tool use
5
+ # 1. Surfaces feature verification checklists before git push
6
+ # 2. Applies learned command filtering rules (RTK-style adaptive)
7
+ # ═══════════════════════════════════════════════════════════
8
+ set +e
9
+
10
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
11
+ LIB_DIR="$SCRIPT_DIR/../lib"
12
+
13
+ . "$LIB_DIR/common.sh"
14
+ . "$LIB_DIR/db.sh"
15
+
16
+ input=$(eagle_read_stdin)
17
+ [ -z "$input" ] && exit 0
18
+
19
+ tool_name=$(echo "$input" | jq -r '.tool_name // empty')
20
+ [ "$tool_name" != "Bash" ] && exit 0
21
+
22
+ [ ! -f "$EAGLE_MEM_DB" ] && exit 0
23
+
24
+ cmd=$(echo "$input" | jq -r '.tool_input.command // empty')
25
+ [ -z "$cmd" ] && exit 0
26
+
27
+ session_id=$(echo "$input" | jq -r '.session_id // empty')
28
+ cwd=$(echo "$input" | jq -r '.cwd // empty')
29
+ project=$(eagle_project_from_cwd "$cwd")
30
+ p_esc=$(eagle_sql_escape "$project")
31
+
32
+ context=""
33
+
34
+ # ─── Feature verification on git push ─────────────────────
35
+
36
+ case "$cmd" in
37
+ *"git push"*|*"gh pr create"*)
38
+ has_features=$(eagle_db "SELECT COUNT(*) FROM features WHERE project = '$p_esc' AND status = 'active';")
39
+ if [ "${has_features:-0}" -gt 0 ]; then
40
+ changed_files=""
41
+ if [ -n "$cwd" ] && [ -d "$cwd" ]; then
42
+ changed_files=$(git -C "$cwd" diff --name-only HEAD 2>/dev/null)
43
+ [ -z "$changed_files" ] && changed_files=$(git -C "$cwd" diff --cached --name-only 2>/dev/null)
44
+ fi
45
+
46
+ if [ -n "$changed_files" ]; then
47
+ seen_features=""
48
+ while IFS= read -r changed_file; do
49
+ [ -z "$changed_file" ] && continue
50
+ fname=$(basename "$changed_file")
51
+ fname_esc=$(eagle_sql_escape "$fname")
52
+
53
+ feature_hits=$(eagle_db "SELECT DISTINCT f.name,
54
+ (SELECT GROUP_CONCAT(fst.command, '; ')
55
+ FROM feature_smoke_tests fst WHERE fst.feature_id = f.id) as smoke,
56
+ (SELECT GROUP_CONCAT(fd.target || ':' || fd.name, ', ')
57
+ FROM feature_dependencies fd WHERE fd.feature_id = f.id) as deps,
58
+ f.last_verified_at
59
+ FROM features f
60
+ JOIN feature_files ff ON ff.feature_id = f.id
61
+ WHERE f.project = '$p_esc'
62
+ AND f.status = 'active'
63
+ AND (ff.file_path LIKE '%$fname_esc' OR ff.file_path LIKE '%$fname_esc%');")
64
+
65
+ while IFS='|' read -r feat_name feat_smoke feat_deps feat_verified; do
66
+ [ -z "$feat_name" ] && continue
67
+ case "$seen_features" in *"|$feat_name|"*) continue ;; esac
68
+ seen_features+="|$feat_name|"
69
+
70
+ context+=" - $feat_name"
71
+ [ -n "$feat_smoke" ] && context+=" | smoke: $feat_smoke"
72
+ [ -n "$feat_deps" ] && context+=" | deps: $feat_deps"
73
+ if [ -n "$feat_verified" ]; then
74
+ context+=" | last verified: $feat_verified"
75
+ else
76
+ context+=" | never verified"
77
+ fi
78
+ context+=$'\n'
79
+ done <<< "$feature_hits"
80
+ done <<< "$changed_files"
81
+
82
+ if [ -n "$context" ]; then
83
+ context="Eagle Mem: This push affects the following features. After deploy, verify each works and run 'eagle-mem feature verify <name>'.
84
+ ${context}"
85
+ fi
86
+ fi
87
+ fi
88
+ ;;
89
+ esac
90
+
91
+ # ─── Command output filtering (learned rules) ─────────────
92
+
93
+ # Extract the base command for rule matching
94
+ base_cmd=$(echo "$cmd" | awk '{print $1}' | sed 's|.*/||')
95
+ cmd_esc=$(eagle_sql_escape "$base_cmd")
96
+
97
+ rule=$(eagle_db "SELECT strategy, max_lines, reason
98
+ FROM command_rules
99
+ WHERE enabled = 1
100
+ AND (project = '$p_esc' OR project IS NULL)
101
+ AND ('$cmd_esc' LIKE pattern OR '$cmd_esc' = pattern)
102
+ ORDER BY
103
+ CASE WHEN project IS NOT NULL THEN 0 ELSE 1 END
104
+ LIMIT 1;")
105
+
106
+ if [ -n "$rule" ]; then
107
+ IFS='|' read -r strategy max_lines reason <<< "$rule"
108
+ case "$strategy" in
109
+ summary)
110
+ context+="Eagle Mem command hint: '${base_cmd}' output is typically noisy (${reason}). Consider piping through 'tail -5' or checking exit code only."
111
+ ;;
112
+ truncate)
113
+ if [ -n "$max_lines" ] && [ "$max_lines" -gt 0 ] 2>/dev/null; then
114
+ context+="Eagle Mem command hint: '${base_cmd}' produces long output (${reason}). Consider: ${cmd} | head -${max_lines}"
115
+ fi
116
+ ;;
117
+ esac
118
+ fi
119
+
120
+ [ -z "$context" ] && exit 0
121
+
122
+ jq -nc --arg ctx "$context" '{"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":$ctx}}'
123
+
124
+ exit 0
@@ -268,6 +268,10 @@ $eagle_banner
268
268
 
269
269
  This gives the user visibility into the full context Eagle Mem loaded for this session.
270
270
 
271
+ ANTI-REGRESSION: When Eagle Mem surfaces decision history about a file you are reading (via PostToolUse context), those decisions were made deliberately in past sessions. Do NOT revert or change the implementation approach without explicit user request. If you believe a past decision should change, state why and ask the user before proceeding. This prevents the common regression where Claude 'improves' code back to an older approach that was already rejected.
272
+
273
+ SECRET SAFETY: Never include raw API keys, tokens, passwords, or secrets in eagle-summary fields or any text that Eagle Mem stores. Reference secrets by name (e.g., 'the Stripe API key', 'GOOGLE_APPLICATION_CREDENTIALS_JSON') not by value. Eagle Mem redacts common patterns automatically, but prevention is better than redaction.
274
+
271
275
  MEMORY FRESHNESS: The memories above include age indicators. If you make a change (edit a file, update a config, change a pattern) that contradicts what a loaded memory says, you MUST update that memory file immediately. Read the memory file, edit it to reflect the new reality, and the PostToolUse hook will sync the update to Eagle Mem. Stale memories mislead future sessions — keeping them current is as important as writing good code.
272
276
 
273
277
  === EAGLE MEM — SESSION SUMMARY (MANDATORY) ===
package/hooks/stop.sh CHANGED
@@ -140,6 +140,17 @@ if [ -z "$request" ] && [ -n "$transcript_path" ] && [ -f "$transcript_path" ];
140
140
  fi
141
141
  fi
142
142
 
143
+ # ─── Redact secrets from all text fields before storage ────
144
+
145
+ request=$(echo "$request" | eagle_redact)
146
+ investigated=$(echo "$investigated" | eagle_redact)
147
+ learned=$(echo "$learned" | eagle_redact)
148
+ completed=$(echo "$completed" | eagle_redact)
149
+ next_steps=$(echo "$next_steps" | eagle_redact)
150
+ decisions=$(echo "$decisions" | eagle_redact)
151
+ gotchas=$(echo "$gotchas" | eagle_redact)
152
+ key_files=$(echo "$key_files" | eagle_redact)
153
+
143
154
  # ─── Write to database ─────────────────────────────────────
144
155
 
145
156
  if [ -n "$request" ] || [ -n "$completed" ] || [ -n "$learned" ]; then
package/lib/common.sh CHANGED
@@ -59,6 +59,28 @@ eagle_read_stdin() {
59
59
  echo "$input"
60
60
  }
61
61
 
62
+ # Redact secrets from text before storage.
63
+ # Covers: Bearer tokens, API keys, passwords, secrets, tokens,
64
+ # Stripe/AWS/GitHub/Anthropic/OpenAI key patterns, named env vars.
65
+ eagle_redact() {
66
+ sed -E \
67
+ -e 's/(Bearer )[^ ]*/\1[REDACTED]/gi' \
68
+ -e 's/(api[_-]?key[= :])[^ ]*/\1[REDACTED]/gi' \
69
+ -e 's/(password[= :])[^ ]*/\1[REDACTED]/gi' \
70
+ -e 's/(secret[= :])[^ ]*/\1[REDACTED]/gi' \
71
+ -e 's/(token[= :])[^ ]*/\1[REDACTED]/gi' \
72
+ -e 's/(Authorization: )[^ ]*/\1[REDACTED]/gi' \
73
+ -e 's/sk_live_[A-Za-z0-9]+/[REDACTED]/g' \
74
+ -e 's/sk_test_[A-Za-z0-9]+/[REDACTED]/g' \
75
+ -e 's/AKIA[A-Z0-9]{16}/[REDACTED]/g' \
76
+ -e 's/ghp_[A-Za-z0-9]{36}/[REDACTED]/g' \
77
+ -e 's/gho_[A-Za-z0-9]{36}/[REDACTED]/g' \
78
+ -e 's/sk-ant-[A-Za-z0-9_-]+/[REDACTED]/g' \
79
+ -e 's/sk-[A-Za-z0-9]{20,}/[REDACTED]/g' \
80
+ -e 's/(ANTHROPIC_API_KEY[= :])[^ ]*/\1[REDACTED]/g' \
81
+ -e 's/(OPENAI_API_KEY[= :])[^ ]*/\1[REDACTED]/g'
82
+ }
83
+
62
84
  # Collect project files into a destination file.
63
85
  # Uses git ls-files when available, falls back to find with common exclusions.
64
86
  # Usage: eagle_collect_files <target_dir> <output_file>
package/lib/db.sh CHANGED
@@ -62,9 +62,19 @@ eagle_insert_observation() {
62
62
  local tool_input_summary; tool_input_summary=$(eagle_sql_escape "$4")
63
63
  local files_read; files_read=$(eagle_sql_escape "$5")
64
64
  local files_modified; files_modified=$(eagle_sql_escape "$6")
65
+ local output_bytes="${7:-}"
66
+ local output_lines="${8:-}"
67
+ local command_category; command_category=$(eagle_sql_escape "${9:-}")
68
+
69
+ local extra_cols=""
70
+ local extra_vals=""
71
+ if [ -n "$output_bytes" ]; then
72
+ extra_cols=", output_bytes, output_lines, command_category"
73
+ extra_vals=", $(eagle_sql_int "$output_bytes"), $(eagle_sql_int "$output_lines"), '$command_category'"
74
+ fi
65
75
 
66
- eagle_db "INSERT INTO observations (session_id, project, tool_name, tool_input_summary, files_read, files_modified)
67
- SELECT '$session_id', '$project', '$tool_name', '$tool_input_summary', '$files_read', '$files_modified'
76
+ eagle_db "INSERT INTO observations (session_id, project, tool_name, tool_input_summary, files_read, files_modified${extra_cols})
77
+ SELECT '$session_id', '$project', '$tool_name', '$tool_input_summary', '$files_read', '$files_modified'${extra_vals}
68
78
  WHERE NOT EXISTS (
69
79
  SELECT 1 FROM observations
70
80
  WHERE session_id = '$session_id'
@@ -565,3 +575,135 @@ COMMIT;"
565
575
  fi
566
576
  echo "$removed"
567
577
  }
578
+
579
+ # ─── Feature graph helpers ─────��───────────────────────────
580
+
581
+ eagle_upsert_feature() {
582
+ local project; project=$(eagle_sql_escape "$1")
583
+ local name; name=$(eagle_sql_escape "$2")
584
+ local description; description=$(eagle_sql_escape "${3:-}")
585
+
586
+ eagle_db "INSERT INTO features (project, name, description)
587
+ VALUES ('$project', '$name', '$description')
588
+ ON CONFLICT(project, name) DO UPDATE SET
589
+ description = COALESCE(NULLIF('$description', ''), features.description),
590
+ updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now');"
591
+ }
592
+
593
+ eagle_add_feature_dependency() {
594
+ local feature_id; feature_id=$(eagle_sql_int "$1")
595
+ local kind; kind=$(eagle_sql_escape "$2")
596
+ local target; target=$(eagle_sql_escape "$3")
597
+ local name; name=$(eagle_sql_escape "$4")
598
+ local notes; notes=$(eagle_sql_escape "${5:-}")
599
+
600
+ eagle_db "INSERT OR IGNORE INTO feature_dependencies (feature_id, kind, target, name, notes)
601
+ VALUES ($feature_id, '$kind', '$target', '$name', '$notes');"
602
+ }
603
+
604
+ eagle_add_feature_file() {
605
+ local feature_id; feature_id=$(eagle_sql_int "$1")
606
+ local file_path; file_path=$(eagle_sql_escape "$2")
607
+ local role; role=$(eagle_sql_escape "${3:-}")
608
+
609
+ eagle_db "INSERT OR IGNORE INTO feature_files (feature_id, file_path, role)
610
+ VALUES ($feature_id, '$file_path', '$role');"
611
+ }
612
+
613
+ eagle_add_feature_smoke_test() {
614
+ local feature_id; feature_id=$(eagle_sql_int "$1")
615
+ local command; command=$(eagle_sql_escape "$2")
616
+ local description; description=$(eagle_sql_escape "${3:-}")
617
+
618
+ eagle_db "INSERT INTO feature_smoke_tests (feature_id, command, description)
619
+ VALUES ($feature_id, '$command', '$description');"
620
+ }
621
+
622
+ eagle_verify_feature() {
623
+ local project; project=$(eagle_sql_escape "$1")
624
+ local name; name=$(eagle_sql_escape "$2")
625
+ local notes; notes=$(eagle_sql_escape "${3:-}")
626
+
627
+ eagle_db "UPDATE features SET
628
+ last_verified_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
629
+ last_verified_notes = '$notes',
630
+ updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
631
+ WHERE project = '$project' AND name = '$name';"
632
+ }
633
+
634
+ eagle_get_feature_id() {
635
+ local project; project=$(eagle_sql_escape "$1")
636
+ local name; name=$(eagle_sql_escape "$2")
637
+ eagle_db "SELECT id FROM features WHERE project = '$project' AND name = '$name';"
638
+ }
639
+
640
+ eagle_list_features() {
641
+ local project; project=$(eagle_sql_escape "$1")
642
+ local limit; limit=$(eagle_sql_int "${2:-20}")
643
+
644
+ eagle_db "SELECT f.name, f.description, f.status, f.last_verified_at,
645
+ (SELECT COUNT(*) FROM feature_dependencies WHERE feature_id = f.id) as dep_count,
646
+ (SELECT COUNT(*) FROM feature_files WHERE feature_id = f.id) as file_count,
647
+ (SELECT COUNT(*) FROM feature_smoke_tests WHERE feature_id = f.id) as test_count
648
+ FROM features f
649
+ WHERE f.project = '$project' AND f.status = 'active'
650
+ ORDER BY f.updated_at DESC
651
+ LIMIT $limit;"
652
+ }
653
+
654
+ eagle_show_feature() {
655
+ local project; project=$(eagle_sql_escape "$1")
656
+ local name; name=$(eagle_sql_escape "$2")
657
+
658
+ local feature_id
659
+ feature_id=$(eagle_get_feature_id "$1" "$2")
660
+ [ -z "$feature_id" ] && return 1
661
+
662
+ echo "=== Feature: $2 ==="
663
+ eagle_db "SELECT name, description, status, last_verified_at, last_verified_notes
664
+ FROM features WHERE id = $feature_id;"
665
+
666
+ local deps
667
+ deps=$(eagle_db "SELECT kind, target, name, notes FROM feature_dependencies WHERE feature_id = $feature_id;")
668
+ if [ -n "$deps" ]; then
669
+ echo "--- Dependencies ---"
670
+ echo "$deps"
671
+ fi
672
+
673
+ local files
674
+ files=$(eagle_db "SELECT file_path, role FROM feature_files WHERE feature_id = $feature_id;")
675
+ if [ -n "$files" ]; then
676
+ echo "--- Files ---"
677
+ echo "$files"
678
+ fi
679
+
680
+ local tests
681
+ tests=$(eagle_db "SELECT command, description FROM feature_smoke_tests WHERE feature_id = $feature_id;")
682
+ if [ -n "$tests" ]; then
683
+ echo "--- Smoke Tests ---"
684
+ echo "$tests"
685
+ fi
686
+ }
687
+
688
+ eagle_find_features_for_file() {
689
+ local project; project=$(eagle_sql_escape "$1")
690
+ local file_path="$2"
691
+ local fname; fname=$(basename "$file_path")
692
+ local fname_esc; fname_esc=$(eagle_sql_escape "$fname")
693
+
694
+ eagle_db "SELECT f.name, f.description, f.last_verified_at,
695
+ ff.role,
696
+ (SELECT GROUP_CONCAT(fd.target || ':' || fd.name, ', ')
697
+ FROM feature_dependencies fd WHERE fd.feature_id = f.id) as deps,
698
+ (SELECT GROUP_CONCAT(ff2.file_path, ', ')
699
+ FROM feature_files ff2 WHERE ff2.feature_id = f.id AND ff2.file_path != ff.file_path) as other_files,
700
+ (SELECT GROUP_CONCAT(fst.command, ', ')
701
+ FROM feature_smoke_tests fst WHERE fst.feature_id = f.id) as smoke_tests
702
+ FROM features f
703
+ JOIN feature_files ff ON ff.feature_id = f.id
704
+ WHERE f.project = '$project'
705
+ AND f.status = 'active'
706
+ AND (ff.file_path LIKE '%$fname_esc' OR ff.file_path LIKE '%$fname_esc%')
707
+ ORDER BY f.updated_at DESC
708
+ LIMIT 3;"
709
+ }