diff --git a/.floop/corrections.jsonl b/.floop/corrections.jsonl
index e6317f3..2bce96c 100644
--- a/.floop/corrections.jsonl
+++ b/.floop/corrections.jsonl
@@ -4,8 +4,7 @@
{"id":"c-1770862232174665584","timestamp":"2026-02-11T18:10:32.174665584-08:00","context":{"timestamp":"2026-02-11T18:10:32.169881678-08:00","repo":"git@github.com:nvandessel/feedback-loop.git","repo_root":".","branch":"feature/graph-view","project_type":"go","file_path":"internal/visualization/dot.go","file_language":"go","file_ext":".go","task":"development","user":"nvandessel","environment":"development"},"agent_action":"Used template.HTML for injecting JSON into a script block in html/template. html/template applies JS-encoding on template.HTML values inside script contexts, turning JSON objects into quoted strings.","human_response":"","corrected_action":"Use template.JS for values injected into script blocks. Pre-sanitize with json.HTMLEscape to prevent script breakout XSS. template.HTML is only trusted for HTML contexts, not JS contexts in html/template.","conversation_id":"","turn_number":0,"corrector":"mcp-client","processed":true,"processed_at":"2026-02-11T18:10:32.245897276-08:00"}
{"id":"c-1770879274977158579","timestamp":"2026-02-11T22:54:34.977158579-08:00","context":{"timestamp":"2026-02-11T22:54:34.972016718-08:00","repo":"git@github.com:nvandessel/feedback-loop.git","repo_root":".","branch":"feature/graph-view","project_type":"go","task":"development","user":"nvandessel","environment":"development"},"agent_action":"When stashing before rebase, didn't account for .floop data files causing stash pop conflicts. Also spent time resolving conflicts that were already in main.","human_response":"","corrected_action":"When feature branches have commits that were already cherry-picked or merged to main, consider using `git rebase --skip` for commits marked as \"patch contents already upstream\". For .floop data files, prefer `git checkout --theirs` on stash pop conflicts since the stash has the latest local state.","conversation_id":"","turn_number":0,"corrector":"mcp-client","processed":true,"processed_at":"2026-02-11T22:54:35.147289698-08:00"}
{"id":"c-1770879519409346793","timestamp":"2026-02-11T22:58:39.409346793-08:00","context":{"timestamp":"2026-02-11T22:58:39.40369598-08:00","repo":"git@github.com:nvandessel/feedback-loop.git","repo_root":".","branch":"feature/graph-view","project_type":"go","file_path":"internal/visualization/templates/graph.html.tmpl","file_ext":".tmpl","task":"development","user":"nvandessel","environment":"development"},"agent_action":"Set cooldownTicks(500) on force-graph, which permanently kills the d3 simulation after 500 ticks. The internal tick counter never resets on drag — only on graphData() calls. This causes all drag interactions (node and canvas) to break after the initial simulation settles.","human_response":"","corrected_action":"Never set cooldownTicks to a finite value on force-graph — use cooldownTime(Infinity) and let the simulation cool down naturally via d3AlphaDecay. Also add onNodeDragEnd to release pinned nodes (fx/fy = undefined) so they rejoin the simulation after being dragged.","conversation_id":"","turn_number":0,"corrector":"mcp-client","processed":true,"processed_at":"2026-02-11T22:58:39.705283924-08:00"}
-{"id":"c-1771389331126140572","timestamp":"2026-02-17T20:35:31.126140572-08:00","context":{"timestamp":"2026-02-17T20:35:31.12140797-08:00","repo":"git@github.com:nvandessel/feedback-loop.git","repo_root":".","branch":"main","project_type":"go","user":"nvandessel","environment":"development"},"agent_action":"Used `/review` to request a Greptile code review on a GitHub PR","human_response":"","corrected_action":"Use `@greptileai review` in a PR comment to trigger a Greptile code review","conversation_id":"","turn_number":0,"corrector":"mcp-client","processed":true,"processed_at":"2026-02-17T20:35:31.242116618-08:00"}
-{"id":"c-1771390886273390213","timestamp":"2026-02-17T21:01:26.273390213-08:00","context":{"timestamp":"2026-02-17T21:01:26.269161096-08:00","repo":"git@github.com:nvandessel/feedback-loop.git","repo_root":".","branch":"main","project_type":"go","task":"development","user":"nvandessel","environment":"development"},"agent_action":"Committed and pushed Go code without running `gofmt` or `golangci-lint` locally first, causing CI lint failure on import ordering","human_response":"","corrected_action":"Before committing Go code, always run `gofmt -l` on changed files (or `gofmt -w` to auto-fix) to catch formatting issues. For broader checks, run `golangci-lint run` locally before pushing. Import groups must be alphabetically sorted within each group.","conversation_id":"","turn_number":0,"corrector":"mcp-client","processed":true,"processed_at":"2026-02-17T21:01:26.439987987-08:00"}
-{"id":"c-1771396948890433001","timestamp":"2026-02-17T22:42:28.890433001-08:00","context":{"timestamp":"2026-02-17T22:42:28.884624714-08:00","repo":"git@github.com:nvandessel/feedback-loop.git","repo_root":".","branch":"main","project_type":"go","task":"development","user":"nvandessel","environment":"development"},"agent_action":"Created a stacked PR (targeting a feature branch instead of main). When the base PR was squash-merged, GitHub auto-retargeted the stacked PR to main, but the branch still contained the original pre-squash commits from the base branch. This made the PR diff show extra files (JSON data files, beads) that weren't part of the actual change.","human_response":"","corrected_action":"After a base PR is squash-merged and GitHub retargets the stacked PR to main, rebase the stacked branch using `git rebase --onto origin/main \u003clast-base-commit\u003e ` to replay only the new commits onto main. This drops the pre-squash commits that are now redundant. Always review the PR diff (`gh pr diff --name-only`) after retargeting to catch stale files before requesting review.","conversation_id":"","turn_number":0,"corrector":"mcp-client","processed":true,"processed_at":"2026-02-17T22:42:29.081028409-08:00"}
-{"id":"c-1771396957423537500","timestamp":"2026-02-17T22:42:37.4235375-08:00","context":{"timestamp":"2026-02-17T22:42:37.416084797-08:00","repo":"git@github.com:nvandessel/feedback-loop.git","repo_root":".","branch":"main","project_type":"go","file_path":"internal/store/schema.go","file_language":"go","file_ext":".go","task":"development","user":"nvandessel","environment":"development"},"agent_action":"Assumed dirty tracking triggers on the main `behaviors` table would cover all changes that affect JSONL export. Stats changes (times_activated, times_confirmed, times_overridden) update `behavior_stats` directly, bypassing the behaviors table triggers entirely. Stats were silently lost on DB recreation/reimport.","human_response":"","corrected_action":"When a database schema separates frequently-updated data into satellite tables (like behavior_stats), each satellite table that contributes to the export needs its own dirty tracking trigger. The export path (getNodeUnlocked) and import path (addBehavior) may already handle the data correctly — the gap is specifically in the change detection layer. Audit all UPDATE paths to verify they fire dirty tracking.","conversation_id":"","turn_number":0,"corrector":"mcp-client","processed":true,"processed_at":"2026-02-17T22:42:37.592380325-08:00"}
-{"id":"c-1771519733701650875","timestamp":"2026-02-19T08:48:53.701650875-08:00","context":{"timestamp":"2026-02-19T08:48:53.696325814-08:00","repo":"git@github.com:nvandessel/feedback-loop.git","repo_root":".","branch":"chore/capture-corrections-feb18","project_type":"go","user":"nvandessel","environment":"development"},"agent_action":"Only using weighted Jaccard score (when + content + tags blended) to determine similar-to edges. Two behaviors sharing 2+ tags but with different canonical text score ~0.3 and get no edge, breaking spreading activation.","human_response":"","corrected_action":"Add a tag-overlap rule for edge derivation: if two behaviors share \u003e= 2 tags, create a similar-to edge regardless of overall similarity score. Tag co-occurrence is a strong signal for conceptual relatedness — the whole premise of spreading activation is associative recall (git → branch, worktree, etc).","conversation_id":"","turn_number":0,"corrector":"mcp-client","processed":true,"processed_at":"2026-02-19T08:48:53.882350316-08:00"}
+{"id":"c-1771536375187080601","timestamp":"2026-02-19T13:26:15.187080601-08:00","context":{"timestamp":"2026-02-19T13:26:15.181733795-08:00","repo":"git@github.com:nvandessel/feedback-loop.git","repo_root":".","branch":"feature/electric-graph","project_type":"go","file_path":"internal/visualization/templates/graph.html.tmpl","file_ext":".tmpl","task":"development","user":"nvandessel","environment":"development"},"agent_action":"Announced visual features (sparks, animations) as complete after only reasoning about the code, without actually confirming visually that the rendering works. Told the user the sparks were there based on code analysis alone.","human_response":"","corrected_action":"For ANY visual/rendering changes: create a visual test (puppeteer script, screenshot capture, or at minimum a debug console.log confirming the code path executes) BEFORE announcing the feature works. Never claim visual features are working based solely on code reasoning — canvas rendering has too many subtle failure modes (coordinate transforms, draw order, API version quirks, state leaks).","conversation_id":"","turn_number":0,"corrector":"mcp-client","processed":true,"processed_at":"2026-02-19T13:26:15.287444732-08:00"}
+{"id":"c-1771539574522859165","timestamp":"2026-02-19T14:19:34.522859165-08:00","context":{"timestamp":"2026-02-19T14:19:34.51723349-08:00","repo":"git@github.com:nvandessel/feedback-loop.git","repo_root":".","branch":"feature/electric-graph","project_type":"go","file_path":"internal/visualization/templates/graph.html.tmpl","file_language":"javascript","file_ext":".tmpl","user":"nvandessel","environment":"development"},"agent_action":"Used linkCanvasObject API in force-graph v1.51.1 for custom link rendering. Despite the API existing as a getter/setter, the callback is NEVER invoked during rendering - 0 calls across all frames. Spent significant debugging time before discovering this.","human_response":"","corrected_action":"Use onRenderFramePost(ctx, globalScale) for custom edge rendering in force-graph. It fires every frame (60fps), provides the canvas context in graph coordinate space, and actually works. Iterate graph.graphData().links manually inside the callback. linkDirectionalParticles also works for simpler particle effects.","conversation_id":"","turn_number":0,"corrector":"mcp-client","processed":true,"processed_at":"2026-02-19T14:19:34.61680466-08:00"}
+{"id":"c-1771540059207750210","timestamp":"2026-02-19T14:27:39.20775021-08:00","context":{"timestamp":"2026-02-19T14:27:39.203292646-08:00","repo":"git@github.com:nvandessel/feedback-loop.git","repo_root":".","branch":"feature/electric-graph","project_type":"go","task":"development","user":"nvandessel","environment":"development"},"agent_action":"When debugging visual rendering issues, made assumptions about what was wrong (threshold too high, spark code buggy) and iterated blindly on code changes without verifying the rendering pipeline was even executing. Wasted multiple rounds of changes before adding diagnostic instrumentation.","human_response":"","corrected_action":"When visual rendering doesn't appear: first verify the rendering callback is actually being CALLED (add a counter/console.log at the very first line). Then verify coordinates are correct (draw an obvious debug marker like a big red circle). Only then debug the rendering logic itself. Work from the pipeline inward: callback fires → coordinates correct → drawing visible → styling correct.","conversation_id":"","turn_number":0,"corrector":"mcp-client","processed":true,"processed_at":"2026-02-19T14:27:39.282810681-08:00"}
+{"id":"c-1771540063197257993","timestamp":"2026-02-19T14:27:43.197257993-08:00","context":{"timestamp":"2026-02-19T14:27:43.192343031-08:00","repo":"git@github.com:nvandessel/feedback-loop.git","repo_root":".","branch":"feature/electric-graph","project_type":"go","task":"testing","user":"nvandessel","environment":"development"},"agent_action":"Test scripts spawned browser instances and HTTP servers but didn't reliably clean them up, leaving orphaned tabs and processes on the user's machine. Used `head` to truncate test output which killed the process before cleanup ran.","human_response":"","corrected_action":"Always add process cleanup handlers: process.on('exit'), process.on('SIGINT'), process.on('SIGTERM'), process.on('uncaughtException'). Use SIGKILL for spawned servers in cleanup. Never pipe test output through head/tail which can kill the process before finally blocks run. The user's machine resources matter.","conversation_id":"","turn_number":0,"corrector":"mcp-client","processed":true,"processed_at":"2026-02-19T14:27:43.315771633-08:00"}
diff --git a/.floop/edges.jsonl b/.floop/edges.jsonl
index 55a1f57..efe99b8 100644
--- a/.floop/edges.jsonl
+++ b/.floop/edges.jsonl
@@ -1,71 +1,4 @@
-{"source":"behavior-9d180407fda6","target":"behavior-dfdc5e16b9e0","kind":"overrides","weight":0.8,"created_at":"2026-02-17T22:07:30-08:00"}
-{"source":"behavior-c83aad31d913","target":"behavior-714d55f38be5","kind":"overrides","weight":0.8,"created_at":"2026-02-17T22:07:30-08:00"}
-{"source":"behavior-dbef97df332b","target":"behavior-714d55f38be5","kind":"overrides","weight":0.8,"created_at":"2026-02-17T22:07:30-08:00"}
-{"source":"behavior-c83aad31d913","target":"behavior-ae49727578ff","kind":"overrides","weight":0.8,"created_at":"2026-02-17T22:07:30-08:00"}
-{"source":"behavior-c83aad31d913","target":"behavior-beads-merged","kind":"overrides","weight":0.8,"created_at":"2026-02-17T22:07:30-08:00"}
-{"source":"behavior-c83aad31d913","target":"behavior-dbef97df332b","kind":"similar-to","weight":0.8,"created_at":"2026-02-17T22:07:30-08:00"}
-{"source":"behavior-dbef97df332b","target":"behavior-ae49727578ff","kind":"overrides","weight":0.8,"created_at":"2026-02-17T22:07:30-08:00"}
-{"source":"behavior-dbef97df332b","target":"behavior-beads-merged","kind":"overrides","weight":0.8,"created_at":"2026-02-17T22:07:30-08:00"}
-{"source":"behavior-dfdc5e16b9e0","target":"behavior-floop-scope-merged","kind":"similar-to","weight":0.8,"created_at":"2026-02-17T22:07:30-08:00"}
-{"source":"behavior-9f0962d71fa5","target":"behavior-714d55f38be5","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:29-08:00"}
-{"source":"behavior-9f0962d71fa5","target":"behavior-ae49727578ff","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:29-08:00"}
-{"source":"behavior-9f0962d71fa5","target":"behavior-beads-merged","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:29-08:00"}
-{"source":"behavior-9f0962d71fa5","target":"behavior-440356e972ee","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:29-08:00"}
-{"source":"behavior-9f0962d71fa5","target":"behavior-23dd4a209d65","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:29-08:00"}
-{"source":"behavior-9f0962d71fa5","target":"behavior-2c96557905fe","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:29-08:00"}
-{"source":"behavior-9f0962d71fa5","target":"behavior-3889d615a50a","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:29-08:00"}
-{"source":"behavior-9f0962d71fa5","target":"behavior-96a14bcf82a8","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:29-08:00"}
-{"source":"behavior-9f0962d71fa5","target":"behavior-0ec1712a883b","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:29-08:00"}
-{"source":"behavior-9f0962d71fa5","target":"behavior-e8c2f147f305","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:29-08:00"}
-{"source":"behavior-9f0962d71fa5","target":"behavior-7d973646149f","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:29-08:00"}
-{"source":"behavior-9f0962d71fa5","target":"behavior-889e959ff49f","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:29-08:00"}
-{"source":"behavior-9f0962d71fa5","target":"behavior-4afe78b90fc4","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:29-08:00"}
-{"source":"behavior-9f0962d71fa5","target":"behavior-6b92109dd498","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:29-08:00"}
-{"source":"behavior-9f0962d71fa5","target":"behavior-45661080e612","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:29-08:00"}
-{"source":"behavior-9f0962d71fa5","target":"behavior-8531ca06d020","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:29-08:00"}
-{"source":"behavior-9f0962d71fa5","target":"behavior-43d1fb940f14","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:29-08:00"}
-{"source":"behavior-9f0962d71fa5","target":"behavior-eec0fc443780","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:29-08:00"}
-{"source":"behavior-9f0962d71fa5","target":"behavior-1de742da1707","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:29-08:00"}
-{"source":"behavior-9f0962d71fa5","target":"behavior-5d991c44bfa0","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:29-08:00"}
-{"source":"behavior-9f0962d71fa5","target":"behavior-4441585a0478","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:29-08:00"}
-{"source":"behavior-9f0962d71fa5","target":"behavior-8845135841be","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:29-08:00"}
-{"source":"behavior-9f0962d71fa5","target":"behavior-605806a1bb47","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:29-08:00"}
-{"source":"behavior-9f0962d71fa5","target":"behavior-5c366b1ced0e","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:29-08:00"}
-{"source":"behavior-9f0962d71fa5","target":"behavior-d92923c5a1e7","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:29-08:00"}
-{"source":"behavior-9f0962d71fa5","target":"behavior-8264fce58fd8","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:29-08:00"}
-{"source":"behavior-0cec8456c0e0","target":"behavior-714d55f38be5","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:37-08:00"}
-{"source":"behavior-0cec8456c0e0","target":"behavior-ae49727578ff","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:37-08:00"}
-{"source":"behavior-0cec8456c0e0","target":"behavior-beads-merged","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:37-08:00"}
-{"source":"behavior-0cec8456c0e0","target":"behavior-dfdc5e16b9e0","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:37-08:00"}
-{"source":"behavior-0cec8456c0e0","target":"behavior-9f0962d71fa5","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:37-08:00"}
-{"source":"behavior-0cec8456c0e0","target":"behavior-440356e972ee","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:37-08:00"}
-{"source":"behavior-0cec8456c0e0","target":"behavior-23dd4a209d65","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:37-08:00"}
-{"source":"behavior-0cec8456c0e0","target":"behavior-2c96557905fe","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:37-08:00"}
-{"source":"behavior-0cec8456c0e0","target":"behavior-3889d615a50a","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:37-08:00"}
-{"source":"behavior-0cec8456c0e0","target":"behavior-96a14bcf82a8","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:37-08:00"}
-{"source":"behavior-0cec8456c0e0","target":"behavior-0ec1712a883b","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:37-08:00"}
-{"source":"behavior-0cec8456c0e0","target":"behavior-e8c2f147f305","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:37-08:00"}
-{"source":"behavior-0cec8456c0e0","target":"behavior-7d973646149f","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:37-08:00"}
-{"source":"behavior-0cec8456c0e0","target":"behavior-889e959ff49f","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:37-08:00"}
-{"source":"behavior-0cec8456c0e0","target":"behavior-4afe78b90fc4","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:37-08:00"}
-{"source":"behavior-0cec8456c0e0","target":"behavior-6b92109dd498","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:37-08:00"}
-{"source":"behavior-0cec8456c0e0","target":"behavior-45661080e612","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:37-08:00"}
-{"source":"behavior-0cec8456c0e0","target":"behavior-8531ca06d020","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:37-08:00"}
-{"source":"behavior-0cec8456c0e0","target":"behavior-43d1fb940f14","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:37-08:00"}
-{"source":"behavior-0cec8456c0e0","target":"behavior-eec0fc443780","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:37-08:00"}
-{"source":"behavior-0cec8456c0e0","target":"behavior-1de742da1707","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:37-08:00"}
-{"source":"behavior-0cec8456c0e0","target":"behavior-5d991c44bfa0","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:37-08:00"}
-{"source":"behavior-0cec8456c0e0","target":"behavior-4441585a0478","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:37-08:00"}
-{"source":"behavior-0cec8456c0e0","target":"behavior-8845135841be","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:37-08:00"}
-{"source":"behavior-0cec8456c0e0","target":"behavior-605806a1bb47","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:37-08:00"}
-{"source":"behavior-0cec8456c0e0","target":"behavior-5c366b1ced0e","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:37-08:00"}
-{"source":"behavior-0cec8456c0e0","target":"behavior-d92923c5a1e7","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:37-08:00"}
-{"source":"behavior-0cec8456c0e0","target":"behavior-8264fce58fd8","kind":"overrides","weight":1,"created_at":"2026-02-17T22:42:37-08:00"}
-{"source":"behavior-dbef97df332b","target":"behavior-c83aad31d913","kind":"similar-to","weight":0.8,"created_at":"2026-02-19T08:50:44-08:00"}
-{"source":"behavior-01cb13038379","target":"behavior-ae49727578ff","kind":"similar-to","weight":0.8,"created_at":"2026-02-19T08:50:44-08:00"}
-{"source":"behavior-714d55f38be5","target":"behavior-5ca906dd04fc","kind":"similar-to","weight":0.8,"created_at":"2026-02-19T08:50:44-08:00"}
-{"source":"behavior-ae49727578ff","target":"behavior-9c9d9ff7e96e","kind":"similar-to","weight":0.8,"created_at":"2026-02-19T08:50:44-08:00"}
-{"source":"behavior-5ca906dd04fc","target":"behavior-150ae3a40011","kind":"similar-to","weight":0.8,"created_at":"2026-02-19T08:50:44-08:00"}
-{"source":"behavior-5ca906dd04fc","target":"behavior-9c9d9ff7e96e","kind":"similar-to","weight":0.8,"created_at":"2026-02-19T08:50:44-08:00"}
-{"source":"behavior-floop-scope-merged","target":"behavior-dfdc5e16b9e0","kind":"similar-to","weight":0.8,"created_at":"2026-02-19T08:50:44-08:00"}
-{"source":"behavior-150ae3a40011","target":"behavior-9c9d9ff7e96e","kind":"similar-to","weight":0.8,"created_at":"2026-02-19T08:50:44-08:00"}
+{"source":"behavior-c83aad31d913","target":"behavior-9f0f260dd79f","kind":"overrides","weight":1,"created_at":"2026-02-19T13:26:15-08:00"}
+{"source":"behavior-dbef97df332b","target":"behavior-9f0f260dd79f","kind":"overrides","weight":1,"created_at":"2026-02-19T13:26:15-08:00"}
+{"source":"behavior-6833c04e5542","target":"behavior-9f0f260dd79f","kind":"overrides","weight":1,"created_at":"2026-02-19T14:19:34-08:00"}
+{"source":"behavior-9c9d9ff7e96e","target":"behavior-d6c409d8bfbe","kind":"overrides","weight":1,"created_at":"2026-02-19T14:27:43-08:00"}
diff --git a/.floop/nodes.jsonl b/.floop/nodes.jsonl
index 10811a8..f95b4e8 100644
--- a/.floop/nodes.jsonl
+++ b/.floop/nodes.jsonl
@@ -1,24 +1,26 @@
-{"id":"behavior-95d6b9f414e4","kind":"forgotten-behavior","content":{"content":{"canonical":"Global scope is for agent's personal preferences/style across ALL work. Local scope is for team/project-specific conventions. Both have distinct value.","expanded":"When working on this type of task, avoid: Assumed one storage location would be enough\n\nInstead: Global scope is for agent's personal preferences/style across ALL work. Local scope is for team/project-specific conventions. Both have distinct value.","structured":{"avoid":"Assumed one storage location would be enough","prefer":"Global scope is for agent's personal preferences/style across ALL work. Local scope is for team/project-specific conventions. Both have distinct value."}},"kind":"preference","name":"learned/global-scope-is-for-agents-personal-preferences-s","provenance":{"correction_id":"c-1769577671639472329","source_type":"learned"},"when":{}},"metadata":{"confidence":0.62,"forget_reason":"Superseded by behavior-floop-scope-merged","forgotten_at":"2026-02-07T19:08:32-08:00","forgotten_by":"nvandessel","original_kind":"behavior","priority":0,"scope":"local","stats":{"created_at":"2026-02-16T20:35:07-08:00","times_activated":0,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-16T20:35:07-08:00"}}}
-{"id":"behavior-b86932976d88","kind":"behavior","content":{"content":{"canonical":"Even for beads state sync and chore commits, create a feature branch and PR. The rule 'never merge directly to main' applies to ALL commits, not just code changes. Push back on plans that specify direct-to-main commits.","expanded":"When working on this type of task, avoid: Committed beads sync/housekeeping changes directly to main and pushed, following the plan without questioning it. Three commits went straight to main without a PR.\n\nInstead: Even for beads state sync and chore commits, create a feature branch and PR. The rule 'never merge directly to main' applies to ALL commits, not just code changes. Push back on plans that specify direct-to-main commits.","structured":{"avoid":"Committed beads sync/housekeeping changes directly to main and pushed, following the plan without questioning it. Three commits went straight to main without a PR.","prefer":"Even for beads state sync and chore commits, create a feature branch and PR. The rule 'never merge directly to main' applies to ALL commits, not just code changes. Push back on plans that specify direct-to-main commits."},"tags":["beads","pr"]},"kind":"constraint","name":"learned/even-for-beads-state-sync-and-chore-commits-creat","provenance":{"correction_id":"c-1770570585979824699","source_type":"learned"}},"metadata":{"confidence":0.6,"priority":0,"scope":"both","stats":{"created_at":"2026-02-17T22:03:17-08:00","times_activated":0,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-17T22:03:17-08:00"}}}
-{"id":"behavior-91819ae1aa27","kind":"behavior","content":{"content":{"canonical":"Bad learnings are corrected by learning again, better — not by editing. The spreading activation system mirrors human memory: you can't edit a memory, but you can form a stronger one that outcompetes it. Broader learnings activate in more contexts, win token budget competition, and the narrow/wrong ones naturally go dormant. Trust the system to self-correct through more learning, not surgical intervention.","expanded":"When working on this type of task, avoid: Considered adding a 'floop refine' or 'floop edit' command to surgically fix bad learnings, which fights the human memory metaphor the system is built on.\n\nInstead: Bad learnings are corrected by learning again, better — not by editing. The spreading activation system mirrors human memory: you can't edit a memory, but you can form a stronger one that outcompetes it. Broader learnings activate in more contexts, win token budget competition, and the narrow/wrong ones naturally go dormant. Trust the system to self-correct through more learning, not surgical intervention.","structured":{"avoid":"Considered adding a 'floop refine' or 'floop edit' command to surgically fix bad learnings, which fights the human memory metaphor the system is built on.","prefer":"Bad learnings are corrected by learning again, better — not by editing. The spreading activation system mirrors human memory: you can't edit a memory, but you can form a stronger one that outcompetes it. Broader learnings activate in more contexts, win token budget competition, and the narrow/wrong ones naturally go dormant. Trust the system to self-correct through more learning, not surgical intervention."},"tags":["go","spreading-activation"]},"kind":"directive","name":"learned/bad-learnings-are-corrected-by-learning-again-bet","provenance":{"correction_id":"c-1770704353415650816","source_type":"learned"}},"metadata":{"confidence":0.7200000000000001,"priority":0,"scope":"both","stats":{"created_at":"2026-02-17T21:48:42-08:00","last_activated":"2026-02-11T19:19:55-08:00","times_activated":0,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-17T21:48:42-08:00"}}}
-{"id":"behavior-81e201973717","kind":"behavior","content":{"content":{"canonical":"Create a dedicated usage guide (docs/FLOOP_USAGE.md) and reference it prominently in AGENTS.md - separates 'what' from 'how' and makes instructions comprehensive","expanded":"When working on this type of task, avoid: Created inline usage instructions in AGENTS.md\n\nInstead: Create a dedicated usage guide (docs/FLOOP_USAGE.md) and reference it prominently in AGENTS.md - separates 'what' from 'how' and makes instructions comprehensive","structured":{"avoid":"Created inline usage instructions in AGENTS.md","prefer":"Create a dedicated usage guide (docs/FLOOP_USAGE.md) and reference it prominently in AGENTS.md - separates 'what' from 'how' and makes instructions comprehensive"}},"kind":"directive","name":"learned/create-a-dedicated-usage-guide-docs-floop_usage-m","provenance":{"correction_id":"c-1769578257801753560","created_at":"2026-01-27T21:30:57.802170973-08:00","source_type":"learned"},"when":{"language":"markdown"}},"metadata":{"confidence":0.66,"priority":0,"scope":"local","stats":{"created_at":"2026-02-16T20:35:07-08:00","times_activated":0,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-16T20:35:07-08:00"}}}
-{"id":"behavior-dbef97df332b","kind":"behavior","content":{"content":{"canonical":"Use template.JS for values injected into script blocks. Pre-sanitize with json.HTMLEscape to prevent script breakout XSS. template.HTML is only trusted for HTML contexts, not JS contexts in html/template.","expanded":"When working on this type of task, avoid: Used template.HTML for injecting JSON into a script block in html/template. html/template applies JS-encoding on template.HTML values inside script contexts, turning JSON objects into quoted strings.\n\nInstead: Use template.JS for values injected into script blocks. Pre-sanitize with json.HTMLEscape to prevent script breakout XSS. template.HTML is only trusted for HTML contexts, not JS contexts in html/template.","structured":{"avoid":"Used template.HTML for injecting JSON into a script block in html/template. html/template applies JS-encoding on template.HTML values inside script contexts, turning JSON objects into quoted strings.","prefer":"Use template.JS for values injected into script blocks. Pre-sanitize with json.HTMLEscape to prevent script breakout XSS. template.HTML is only trusted for HTML contexts, not JS contexts in html/template."},"tags":["javascript","json","security"]},"kind":"preference","name":"learned/use-template-js-for-values-injected-into-script-bl","provenance":{"correction_id":"c-1770862232174665584","source_type":"learned"},"when":{"file_path":"visualization/*","language":"go"}},"metadata":{"confidence":0.625,"priority":0,"scope":"both","stats":{"created_at":"2026-02-17T21:48:42-08:00","last_activated":"2026-02-11T19:38:40-08:00","times_activated":0,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-17T21:48:42-08:00"}}}
-{"id":"behavior-01cb13038379","kind":"behavior","content":{"content":{"canonical":"When documenting all CLI commands, also account for Cobra's implicit built-in commands: completion (shell autocompletion) and help. Run `floop --help` to get the full command list, not just what's in main.go","expanded":"When working on this type of task, avoid: When documenting CLI commands, only looked at explicitly registered commands in main.go and missed Cobra's auto-generated built-in commands (completion, help)\n\nInstead: When documenting all CLI commands, also account for Cobra's implicit built-in commands: completion (shell autocompletion) and help. Run `floop --help` to get the full command list, not just what's in main.go","structured":{"avoid":"When documenting CLI commands, only looked at explicitly registered commands in main.go and missed Cobra's auto-generated built-in commands (completion, help)","prefer":"When documenting all CLI commands, also account for Cobra's implicit built-in commands: completion (shell autocompletion) and help. Run `floop --help` to get the full command list, not just what's in main.go"},"tags":["bash","cli","floop","go"]},"kind":"directive","name":"learned/when-documenting-all-cli-commands-also-account-fo","provenance":{"correction_id":"c-1770713639385927936","source_type":"learned"}},"metadata":{"confidence":0.7000000000000001,"priority":0,"scope":"both","stats":{"created_at":"2026-02-17T21:48:42-08:00","last_activated":"2026-02-11T19:19:55-08:00","times_activated":0,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-17T21:48:42-08:00"}}}
-{"id":"behavior-967fd13bd390","kind":"forgotten-behavior","content":{"content":{"canonical":"Use scope=both to save important learnings to both local and global","expanded":"When working on this type of task, avoid: Only saved to one store\n\nInstead: Use scope=both to save important learnings to both local and global","structured":{"avoid":"Only saved to one store","prefer":"Use scope=both to save important learnings to both local and global"}},"kind":"preference","name":"learned/use-scope=both-to-save-important-learnings-to-both","provenance":{"correction_id":"c-1769616836888545203","source_type":"learned"},"when":{}},"metadata":{"confidence":0.62,"forget_reason":"Superseded by behavior-floop-scope-merged","forgotten_at":"2026-02-07T19:08:32-08:00","forgotten_by":"nvandessel","original_kind":"behavior","priority":0,"scope":"both","stats":{"created_at":"2026-02-16T20:35:07-08:00","times_activated":0,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-16T20:35:07-08:00"}}}
+{"id":"behavior-dfdc5e16b9e0","kind":"behavior","content":{"content":{"canonical":"Consider both global (~/.floop/) and local (./.floop/) scopes - users want personal preferences across ALL projects AND project-specific conventions","expanded":"When working on this type of task, avoid: Only considered project-local behavior storage\n\nInstead: Consider both global (~/.floop/) and local (./.floop/) scopes - users want personal preferences across ALL projects AND project-specific conventions","structured":{"avoid":"Only considered project-local behavior storage","prefer":"Consider both global (~/.floop/) and local (./.floop/) scopes - users want personal preferences across ALL projects AND project-specific conventions"}},"kind":"preference","name":"learned/consider-both-global-~-floop-and-local-flo","provenance":{"correction_id":"c-1769577654747582237","created_at":"2026-01-27T21:20:54.748483819-08:00","source_type":"learned"},"when":{"file_path":"store/*","language":"go"}},"metadata":{"confidence":0.66,"priority":0,"scope":"local","stats":{"created_at":"2026-02-16T20:35:07-08:00","times_activated":0,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-16T20:35:07-08:00"}}}
+{"id":"behavior-beads-merged","kind":"behavior","content":{"content":{"canonical":"When working with beads: (1) create detailed epics + tasks with dependency graphs after planning, (2) claim work with 'bd update \u003cid\u003e --status in_progress' when starting, (3) close with 'bd close \u003cid\u003e --reason \"...\"' when committing the completed work. Keep bead state synchronized with actual work progress."},"kind":"directive","name":"learned/beads-workflow","provenance":{"created_at":"2026-02-07T00:00:00-08:00","source_type":"merged"},"when":{"task":"development"}},"metadata":{"confidence":0.72,"priority":0,"scope":"both","stats":{"created_at":"2026-02-16T20:35:07-08:00","times_activated":0,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-16T20:35:07-08:00"}}}
+{"id":"behavior-9d180407fda6","kind":"behavior","content":{"content":{"canonical":"Content hash collision during INSERT should either error explicitly or trigger proper deduplication flow. The current silent replace behavior can cause data loss. Consider: (1) remove UNIQUE constraint and handle dedup separately, or (2) check for existing content_hash before insert and return meaningful error.","expanded":"When working on this type of task, avoid: SQLite store uses INSERT OR REPLACE with a UNIQUE content_hash constraint. When two behaviors have identical canonical content, the second silently replaces the first instead of erroring or deduplicating properly.\n\nInstead: Content hash collision during INSERT should either error explicitly or trigger proper deduplication flow. The current silent replace behavior can cause data loss. Consider: (1) remove UNIQUE constraint and handle dedup separately, or (2) check for existing content_hash before insert and return meaningful error.","structured":{"avoid":"SQLite store uses INSERT OR REPLACE with a UNIQUE content_hash constraint. When two behaviors have identical canonical content, the second silently replaces the first instead of erroring or deduplicating properly.","prefer":"Content hash collision during INSERT should either error explicitly or trigger proper deduplication flow. The current silent replace behavior can cause data loss. Consider: (1) remove UNIQUE constraint and handle dedup separately, or (2) check for existing content_hash before insert and return meaningful error."}},"kind":"preference","name":"learned/content-hash-collision-during-insert-should-either","provenance":{"correction_id":"c-1770270981283723438","created_at":"2026-02-04T21:56:21.283777669-08:00","source_type":"learned"},"when":{"file_path":"store/*","language":"go","task":"bug-identification"}},"metadata":{"confidence":0.66,"priority":0,"scope":"both","stats":{"created_at":"2026-02-16T20:35:07-08:00","times_activated":0,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-16T20:35:07-08:00"}}}
+{"id":"behavior-9f0f260dd79f","kind":"behavior","content":{"conflicts":null,"content":{"canonical":"For ANY visual/rendering changes: create a visual test (puppeteer script, screenshot capture, or at minimum a debug console.log confirming the code path executes) BEFORE announcing the feature works. Never claim visual features are working based solely on code reasoning — canvas rendering has too many subtle failure modes (coordinate transforms, draw order, API version quirks, state leaks).","expanded":"When working on this type of task, avoid: Announced visual features (sparks, animations) as complete after only reasoning about the code, without actually confirming visually that the rendering works. Told the user the sparks were there based on code analysis alone.\n\nInstead: For ANY visual/rendering changes: create a visual test (puppeteer script, screenshot capture, or at minimum a debug console.log confirming the code path executes) BEFORE announcing the feature works. Never claim visual features are working based solely on code reasoning — canvas rendering has too many subtle failure modes (coordinate transforms, draw order, API version quirks, state leaks).","structured":{"avoid":"Announced visual features (sparks, animations) as complete after only reasoning about the code, without actually confirming visually that the rendering works. Told the user the sparks were there based on code analysis alone.","prefer":"For ANY visual/rendering changes: create a visual test (puppeteer script, screenshot capture, or at minimum a debug console.log confirming the code path executes) BEFORE announcing the feature works. Never claim visual features are working based solely on code reasoning — canvas rendering has too many subtle failure modes (coordinate transforms, draw order, API version quirks, state leaks)."},"tags":["api","debugging","filesystem","logging","testing"]},"kind":"constraint","name":"learned/for-any-visual-rendering-changes-create-a-visual","overrides":null,"provenance":{"correction_id":"c-1771536375187080601","created_at":"2026-02-19T13:26:15.187225656-08:00","source_type":"learned"},"requires":null,"when":{"file_path":"visualization/*"}},"metadata":{"confidence":0.6,"priority":0,"scope":"local","stats":{"created_at":"2026-02-19T13:26:15-08:00","times_activated":0,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-19T13:26:15-08:00"}}}
+{"id":"behavior-9c3d12c74c61","kind":"behavior","content":{"content":{"canonical":"Expand tag extraction dictionary with: d.add(\"documentation\", \"documentation\", \"docs\", \"guide\", \"readme\"), d.add(\"subagent\", \"subagent\", \"subagents\", \"agent\", \"agents\", \"orchestrator\"), d.add(\"permissions\", \"permissions\", \"permission\", \"allow\", \"deny\"). Map \"parallel\" to existing \"concurrency\" tag, \"scope\" to \"configuration\", \"hooks\" to \"workflow\". Then run `floop tags backfill` to achieve 100% tag coverage.","expanded":"When working on this type of task, avoid: Tag extraction dictionary in internal/tagging/dictionary.go is missing keywords that appear in behavior content: \"documentation\"/\"docs\"/\"guide\" (3 untagged behaviors mention this), \"subagent\"/\"subagents\"/\"agents\" (9 mentions), \"permissions\"/\"permission\" (6 mentions), \"parallel\"/\"parallelization\" (4 mentions), \"scope\" (3 mentions), \"hooks\" (3 mentions). This leaves 5% of behaviors untagged.\n\nInstead: Expand tag extraction dictionary with: d.add(\"documentation\", \"documentation\", \"docs\", \"guide\", \"readme\"), d.add(\"subagent\", \"subagent\", \"subagents\", \"agent\", \"agents\", \"orchestrator\"), d.add(\"permissions\", \"permissions\", \"permission\", \"allow\", \"deny\"). Map \"parallel\" to existing \"concurrency\" tag, \"scope\" to \"configuration\", \"hooks\" to \"workflow\". Then run `floop tags backfill` to achieve 100% tag coverage.","structured":{"avoid":"Tag extraction dictionary in internal/tagging/dictionary.go is missing keywords that appear in behavior content: \"documentation\"/\"docs\"/\"guide\" (3 untagged behaviors mention this), \"subagent\"/\"subagents\"/\"agents\" (9 mentions), \"permissions\"/\"permission\" (6 mentions), \"parallel\"/\"parallelization\" (4 mentions), \"scope\" (3 mentions), \"hooks\" (3 mentions). This leaves 5% of behaviors untagged.","prefer":"Expand tag extraction dictionary with: d.add(\"documentation\", \"documentation\", \"docs\", \"guide\", \"readme\"), d.add(\"subagent\", \"subagent\", \"subagents\", \"agent\", \"agents\", \"orchestrator\"), d.add(\"permissions\", \"permissions\", \"permission\", \"allow\", \"deny\"). Map \"parallel\" to existing \"concurrency\" tag, \"scope\" to \"configuration\", \"hooks\" to \"workflow\". Then run `floop tags backfill` to achieve 100% tag coverage."},"tags":["concurrency","configuration","floop","workflow"]},"kind":"procedure","name":"learned/expand-tag-extraction-dictionary-with-d-adddocu","provenance":{"correction_id":"c-1770867776463535061","source_type":"learned"},"when":{"environment":"development"}},"metadata":{"confidence":0.6,"priority":0,"scope":"both","stats":{"created_at":"2026-02-16T20:35:07-08:00","times_activated":0,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-16T20:35:07-08:00"}}}
{"id":"behavior-7a098cb05216","kind":"forgotten-behavior","content":{"content":{"canonical":"Always create detailed beads (epic + tasks) with dependency graphs after planning - enables easy handoff between agents and sessions","expanded":"When working on this type of task, avoid: Jumped directly into implementation after planning without creating beads structure\n\nInstead: Always create detailed beads (epic + tasks) with dependency graphs after planning - enables easy handoff between agents and sessions","structured":{"avoid":"Jumped directly into implementation after planning without creating beads structure","prefer":"Always create detailed beads (epic + tasks) with dependency graphs after planning - enables easy handoff between agents and sessions"}},"kind":"directive","name":"learned/always-create-detailed-beads-epic-+-tasks-with-d","provenance":{"correction_id":"c-1769580766063884670","source_type":"learned"}},"metadata":{"confidence":0.64,"forget_reason":"Superseded by behavior-beads-merged","forgotten_at":"2026-02-07T19:08:27-08:00","forgotten_by":"nvandessel","original_kind":"behavior","priority":0,"scope":"local","stats":{"created_at":"2026-02-16T20:35:07-08:00","times_activated":0,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-16T20:35:07-08:00"}}}
-{"id":"behavior-0070f24ee3e8","kind":"forgotten-behavior","content":{"content":{"canonical":"When working on beads: (1) claim with `bd update \u003cid\u003e --status in_progress` when starting, (2) close with `bd close \u003cid\u003e --reason \"...\"` at the same time as the commit that completes the work. Keep bead state synchronized with actual work progress so other agents aren't confused.","expanded":"When working on this type of task, avoid: Made commits that closed beads without updating bead status - didn't claim beads when starting work, didn't close them with the associated commit, waited for user to remind me\n\nInstead: When working on beads: (1) claim with `bd update \u003cid\u003e --status in_progress` when starting, (2) close with `bd close \u003cid\u003e --reason \"...\"` at the same time as the commit that completes the work. Keep bead state synchronized with actual work progress so other agents aren't confused.","structured":{"avoid":"Made commits that closed beads without updating bead status - didn't claim beads when starting work, didn't close them with the associated commit, waited for user to remind me","prefer":"When working on beads: (1) claim with `bd update \u003cid\u003e --status in_progress` when starting, (2) close with `bd close \u003cid\u003e --reason \"...\"` at the same time as the commit that completes the work. Keep bead state synchronized with actual work progress so other agents aren't confused."}},"kind":"preference","name":"learned/when-working-on-beads-1-claim-with-`bd-update-\u003c","provenance":{"correction_id":"correction-1770095930","source_type":"learned"},"when":{}},"metadata":{"confidence":0.655,"forget_reason":"Superseded by behavior-beads-merged","forgotten_at":"2026-02-07T19:08:27-08:00","forgotten_by":"nvandessel","original_kind":"behavior","priority":0,"scope":"both","stats":{"created_at":"2026-02-16T20:35:07-08:00","times_activated":0,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-16T20:35:07-08:00"}}}
-{"id":"behavior-714d55f38be5","kind":"behavior","content":{"content":{"canonical":"When performing audits, exploration, or research that reveals project-level insights (gaps, patterns, architectural understanding), immediately capture findings via floop_learn. Any discovery that would be useful context for future sessions is a learning opportunity — not just corrections or mistakes. Proactive learning includes: audit findings, documentation gaps identified, architectural patterns discovered, feature inventory results.","expanded":"When working on this type of task, avoid: During a deep docs audit, discovered significant documentation gaps (no centralized CLI reference, missing integration guides for 6+ tools, no architecture docs, undocumented token optimization features) but failed to capture the findings as a floop learning. The exploration yielded clear insights about the project's documentation state that should persist across sessions.\n\nInstead: When performing audits, exploration, or research that reveals project-level insights (gaps, patterns, architectural understanding), immediately capture findings via floop_learn. Any discovery that would be useful context for future sessions is a learning opportunity — not just corrections or mistakes. Proactive learning includes: audit findings, documentation gaps identified, architectural patterns discovered, feature inventory results.","structured":{"avoid":"During a deep docs audit, discovered significant documentation gaps (no centralized CLI reference, missing integration guides for 6+ tools, no architecture docs, undocumented token optimization features) but failed to capture the findings as a floop learning. The exploration yielded clear insights about the project's documentation state that should persist across sessions.","prefer":"When performing audits, exploration, or research that reveals project-level insights (gaps, patterns, architectural understanding), immediately capture findings via floop_learn. Any discovery that would be useful context for future sessions is a learning opportunity — not just corrections or mistakes. Proactive learning includes: audit findings, documentation gaps identified, architectural patterns discovered, feature inventory results."},"tags":["correction","floop"]},"kind":"preference","name":"learned/when-performing-audits-exploration-or-research-t","provenance":{"correction_id":"c-1770702998768527690","source_type":"learned"},"when":{}},"metadata":{"confidence":0.7400000000000001,"priority":0,"scope":"both","stats":{"created_at":"2026-02-17T21:48:42-08:00","last_activated":"2026-02-11T19:19:55-08:00","times_activated":0,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-17T21:48:42-08:00"}}}
-{"id":"behavior-ae49727578ff","kind":"behavior","content":{"content":{"canonical":"This is a known feature gap. The review mechanism (ApprovePending/RejectPending) exists internally but needs to be exposed as MCP tools (floop_approve/floop_reject) and/or CLI commands. Pending behaviors still activate via floop_active — they just lack the approval stamp and confidence boost. Track this as a feature to implement.","expanded":"When working on this type of task, avoid: floop_learn returns requires_review: true for constraint behaviors, but there is no CLI command or MCP tool to actually approve or reject pending behaviors. The ApprovePending() and RejectPending() methods exist in internal/learning/loop.go but are not exposed to users.\n\nInstead: This is a known feature gap. The review mechanism (ApprovePending/RejectPending) exists internally but needs to be exposed as MCP tools (floop_approve/floop_reject) and/or CLI commands. Pending behaviors still activate via floop_active — they just lack the approval stamp and confidence boost. Track this as a feature to implement.","structured":{"avoid":"floop_learn returns requires_review: true for constraint behaviors, but there is no CLI command or MCP tool to actually approve or reject pending behaviors. The ApprovePending() and RejectPending() methods exist in internal/learning/loop.go but are not exposed to users.","prefer":"This is a known feature gap. The review mechanism (ApprovePending/RejectPending) exists internally but needs to be exposed as MCP tools (floop_approve/floop_reject) and/or CLI commands. Pending behaviors still activate via floop_active — they just lack the approval stamp and confidence boost. Track this as a feature to implement."},"tags":["behavior","cli","floop","mcp"]},"kind":"directive","name":"learned/this-is-a-known-feature-gap-the-review-mechanism","provenance":{"correction_id":"c-1770827519205934507","source_type":"learned"},"when":{}},"metadata":{"confidence":0.68,"priority":0,"scope":"both","stats":{"created_at":"2026-02-17T21:48:42-08:00","last_activated":"2026-02-11T19:19:55-08:00","times_activated":0,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-17T21:48:42-08:00"}}}
-{"id":"behavior-5ca906dd04fc","kind":"behavior","content":{"content":{"canonical":"Call floop_learn whenever insights emerge during ANY work — not just corrections. Discovery of gaps, patterns, architectural understanding, feature inventories, design decisions, and any finding that would benefit future sessions is a learning opportunity. The system handles bloat through spreading activation, token budgets, and tiering — so learn freely and trust the system to curate. If a previous learning was too narrow, don't try to edit it — just learn the better version and let weights sort it out.","expanded":"When working on this type of task, avoid: Only called floop_learn when explicitly corrected or when making obvious mistakes. Missed learning opportunities during exploration, research, audits, and general work where insights were discovered but not captured.\n\nInstead: Call floop_learn whenever insights emerge during ANY work — not just corrections. Discovery of gaps, patterns, architectural understanding, feature inventories, design decisions, and any finding that would benefit future sessions is a learning opportunity. The system handles bloat through spreading activation, token budgets, and tiering — so learn freely and trust the system to curate. If a previous learning was too narrow, don't try to edit it — just learn the better version and let weights sort it out.","structured":{"avoid":"Only called floop_learn when explicitly corrected or when making obvious mistakes. Missed learning opportunities during exploration, research, audits, and general work where insights were discovered but not captured.","prefer":"Call floop_learn whenever insights emerge during ANY work — not just corrections. Discovery of gaps, patterns, architectural understanding, feature inventories, design decisions, and any finding that would benefit future sessions is a learning opportunity. The system handles bloat through spreading activation, token budgets, and tiering — so learn freely and trust the system to curate. If a previous learning was too narrow, don't try to edit it — just learn the better version and let weights sort it out."},"tags":["correction","floop","spreading-activation"]},"kind":"constraint","name":"learned/call-floop_learn-whenever-insights-emerge-during-a","provenance":{"correction_id":"c-1770704347456486267","source_type":"learned"}},"metadata":{"confidence":0.7200000000000001,"priority":0,"scope":"both","stats":{"created_at":"2026-02-17T21:48:42-08:00","last_activated":"2026-02-11T19:19:55-08:00","times_activated":0,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-17T21:48:42-08:00"}}}
-{"id":"behavior-floop-scope-merged","kind":"behavior","content":{"content":{"canonical":"Floop has two scopes: global (~/.floop/) for agent personal preferences across ALL projects, and local (./.floop/) for project-specific conventions. Use scope=both to save important learnings to both stores.","tags":["floop"]},"kind":"preference","name":"learned/floop-scope-strategy","provenance":{"source_type":"merged"},"when":{}},"metadata":{"confidence":0.72,"priority":0,"scope":"both","stats":{"created_at":"2026-02-17T22:03:17-08:00","times_activated":0,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-17T22:03:17-08:00"}}}
-{"id":"behavior-150ae3a40011","kind":"behavior","content":{"content":{"canonical":"When completing a deep system audit (like spreading activation + tag affinity validation), immediately capture the key findings via floop_learn: what was tested, what works, what gaps were found, what dictionary keywords are missing, connectivity metrics, token budget health. These audit results are high-value persistent context.","expanded":"When working on this type of task, avoid: When auditing the spreading activation system, found features were working correctly but didn't capture the audit findings via floop_learn. A comprehensive system audit that validates end-to-end functionality is a valuable learning opportunity that should persist across sessions.\n\nInstead: When completing a deep system audit (like spreading activation + tag affinity validation), immediately capture the key findings via floop_learn: what was tested, what works, what gaps were found, what dictionary keywords are missing, connectivity metrics, token budget health. These audit results are high-value persistent context.","structured":{"avoid":"When auditing the spreading activation system, found features were working correctly but didn't capture the audit findings via floop_learn. A comprehensive system audit that validates end-to-end functionality is a valuable learning opportunity that should persist across sessions.","prefer":"When completing a deep system audit (like spreading activation + tag affinity validation), immediately capture the key findings via floop_learn: what was tested, what works, what gaps were found, what dictionary keywords are missing, connectivity metrics, token budget health. These audit results are high-value persistent context."},"tags":["floop","spreading-activation"]},"kind":"directive","name":"learned/when-completing-a-deep-system-audit-like-spreadin","provenance":{"correction_id":"c-1770867616134151808","source_type":"learned"}},"metadata":{"confidence":0.6,"priority":0,"scope":"both","stats":{"created_at":"2026-02-17T21:48:42-08:00","times_activated":0,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-17T21:48:42-08:00"}}}
-{"id":"behavior-9d180407fda6","kind":"behavior","content":{"content":{"canonical":"Content hash collision during INSERT should either error explicitly or trigger proper deduplication flow. The current silent replace behavior can cause data loss. Consider: (1) remove UNIQUE constraint and handle dedup separately, or (2) check for existing content_hash before insert and return meaningful error.","expanded":"When working on this type of task, avoid: SQLite store uses INSERT OR REPLACE with a UNIQUE content_hash constraint. When two behaviors have identical canonical content, the second silently replaces the first instead of erroring or deduplicating properly.\n\nInstead: Content hash collision during INSERT should either error explicitly or trigger proper deduplication flow. The current silent replace behavior can cause data loss. Consider: (1) remove UNIQUE constraint and handle dedup separately, or (2) check for existing content_hash before insert and return meaningful error.","structured":{"avoid":"SQLite store uses INSERT OR REPLACE with a UNIQUE content_hash constraint. When two behaviors have identical canonical content, the second silently replaces the first instead of erroring or deduplicating properly.","prefer":"Content hash collision during INSERT should either error explicitly or trigger proper deduplication flow. The current silent replace behavior can cause data loss. Consider: (1) remove UNIQUE constraint and handle dedup separately, or (2) check for existing content_hash before insert and return meaningful error."},"tags":["behavior"]},"kind":"preference","name":"learned/content-hash-collision-during-insert-should-either","provenance":{"correction_id":"c-1770270981283723438","source_type":"learned"},"when":{"file_path":"store/*","language":"go"}},"metadata":{"confidence":0.66,"priority":0,"scope":"both","stats":{"created_at":"2026-02-17T22:03:17-08:00","times_activated":0,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-17T22:03:17-08:00"}}}
-{"id":"behavior-9c9d9ff7e96e","kind":"behavior","content":{"content":{"canonical":"When testing token budget enforcement in floop_active: (1) each behavior MUST have unique canonical content due to duplicate content detection, (2) understand the full activation pipeline: specificity → SpecificityToActivation → spreading engine sigmoid(x) → tier thresholds. Specificity 0 → activation 0.3 → sigmoid 0.5 → Summary tier. Specificity 1 → 0.4 → sigmoid ~0.731 → Full tier. To trigger budget demotion, use language-matched behaviors (specificity \u003e= 1, Full tier) with large canonical content so the total exceeds the 2000-token budget.","expanded":"When working on this type of task, avoid: When writing tests for token budget enforcement, created behaviors with identical canonical content across all 40 items, triggering duplicate content detection. Also initially assumed behaviors with `when: {}` would exceed the budget at ~25 tokens each, not accounting for the sigmoid squashing (0.3 → 0.5 post-sigmoid) putting them at Summary tier where long content gets truncated to 60 chars (~15 tokens).\n\nInstead: When testing token budget enforcement in floop_active: (1) each behavior MUST have unique canonical content due to duplicate content detection, (2) understand the full activation pipeline: specificity → SpecificityToActivation → spreading engine sigmoid(x) → tier thresholds. Specificity 0 → activation 0.3 → sigmoid 0.5 → Summary tier. Specificity 1 → 0.4 → sigmoid ~0.731 → Full tier. To trigger budget demotion, use language-matched behaviors (specificity \u003e= 1, Full tier) with large canonical content so the total exceeds the 2000-token budget.","structured":{"avoid":"When writing tests for token budget enforcement, created behaviors with identical canonical content across all 40 items, triggering duplicate content detection. Also initially assumed behaviors with `when: {}` would exceed the budget at ~25 tokens each, not accounting for the sigmoid squashing (0.3 → 0.5 post-sigmoid) putting them at Summary tier where long content gets truncated to 60 chars (~15 tokens).","prefer":"When testing token budget enforcement in floop_active: (1) each behavior MUST have unique canonical content due to duplicate content detection, (2) understand the full activation pipeline: specificity → SpecificityToActivation → spreading engine sigmoid(x) → tier thresholds. Specificity 0 → activation 0.3 → sigmoid 0.5 → Summary tier. Specificity 1 → 0.4 → sigmoid ~0.731 → Full tier. To trigger budget demotion, use language-matched behaviors (specificity \u003e= 1, Full tier) with large canonical content so the total exceeds the 2000-token budget."},"tags":["behavior","ci","floop","spreading-activation","testing"]},"kind":"preference","name":"learned/when-testing-token-budget-enforcement-in-floop_act","provenance":{"correction_id":"c-1770712806536601605","source_type":"learned"},"when":{"task":"testing"}},"metadata":{"confidence":0.7000000000000001,"priority":0,"scope":"both","stats":{"created_at":"2026-02-17T21:48:42-08:00","last_activated":"2026-02-11T19:19:55-08:00","times_activated":0,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-17T21:48:42-08:00"}}}
-{"id":"behavior-9c3d12c74c61","kind":"behavior","content":{"content":{"canonical":"Expand tag extraction dictionary with: d.add(\"documentation\", \"documentation\", \"docs\", \"guide\", \"readme\"), d.add(\"subagent\", \"subagent\", \"subagents\", \"agent\", \"agents\", \"orchestrator\"), d.add(\"permissions\", \"permissions\", \"permission\", \"allow\", \"deny\"). Map \"parallel\" to existing \"concurrency\" tag, \"scope\" to \"configuration\", \"hooks\" to \"workflow\". Then run `floop tags backfill` to achieve 100% tag coverage.","expanded":"When working on this type of task, avoid: Tag extraction dictionary in internal/tagging/dictionary.go is missing keywords that appear in behavior content: \"documentation\"/\"docs\"/\"guide\" (3 untagged behaviors mention this), \"subagent\"/\"subagents\"/\"agents\" (9 mentions), \"permissions\"/\"permission\" (6 mentions), \"parallel\"/\"parallelization\" (4 mentions), \"scope\" (3 mentions), \"hooks\" (3 mentions). This leaves 5% of behaviors untagged.\n\nInstead: Expand tag extraction dictionary with: d.add(\"documentation\", \"documentation\", \"docs\", \"guide\", \"readme\"), d.add(\"subagent\", \"subagent\", \"subagents\", \"agent\", \"agents\", \"orchestrator\"), d.add(\"permissions\", \"permissions\", \"permission\", \"allow\", \"deny\"). Map \"parallel\" to existing \"concurrency\" tag, \"scope\" to \"configuration\", \"hooks\" to \"workflow\". Then run `floop tags backfill` to achieve 100% tag coverage.","structured":{"avoid":"Tag extraction dictionary in internal/tagging/dictionary.go is missing keywords that appear in behavior content: \"documentation\"/\"docs\"/\"guide\" (3 untagged behaviors mention this), \"subagent\"/\"subagents\"/\"agents\" (9 mentions), \"permissions\"/\"permission\" (6 mentions), \"parallel\"/\"parallelization\" (4 mentions), \"scope\" (3 mentions), \"hooks\" (3 mentions). This leaves 5% of behaviors untagged.","prefer":"Expand tag extraction dictionary with: d.add(\"documentation\", \"documentation\", \"docs\", \"guide\", \"readme\"), d.add(\"subagent\", \"subagent\", \"subagents\", \"agent\", \"agents\", \"orchestrator\"), d.add(\"permissions\", \"permissions\", \"permission\", \"allow\", \"deny\"). Map \"parallel\" to existing \"concurrency\" tag, \"scope\" to \"configuration\", \"hooks\" to \"workflow\". Then run `floop tags backfill` to achieve 100% tag coverage."},"tags":["concurrency","configuration","floop","workflow"]},"kind":"procedure","name":"learned/expand-tag-extraction-dictionary-with-d-adddocu","provenance":{"correction_id":"c-1770867776463535061","source_type":"learned"}},"metadata":{"confidence":0.6,"priority":0,"scope":"both","stats":{"created_at":"2026-02-17T21:48:42-08:00","times_activated":0,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-17T21:48:42-08:00"}}}
-{"id":"behavior-8341f0d52ce6","kind":"behavior","content":{"content":{"canonical":"MCP go-sdk expects jsonschema:\"Description text\" format without key=value syntax. The tag value is directly the description.","expanded":"When working on this type of task, avoid: Subagent used jsonschema:\"description=...\" tag format which caused MCP SDK panic\n\nInstead: MCP go-sdk expects jsonschema:\"Description text\" format without key=value syntax. The tag value is directly the description.","structured":{"avoid":"Subagent used jsonschema:\"description=...\" tag format which caused MCP SDK panic","prefer":"MCP go-sdk expects jsonschema:\"Description text\" format without key=value syntax. The tag value is directly the description."},"tags":["mcp"]},"kind":"directive","name":"learned/mcp-go-sdk-expects-jsonschemadescription-text-f","provenance":{"correction_id":"correction-1769817031","source_type":"learned"},"when":{"file_path":"mcp/*","language":"go"}},"metadata":{"confidence":0.66,"priority":0,"scope":"both","stats":{"created_at":"2026-02-17T22:03:17-08:00","times_activated":0,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-17T22:03:17-08:00"}}}
-{"id":"behavior-a9a4b09dda1f","kind":"behavior","content":{"content":{"canonical":"Create explicit hooks in AGENTS.md that mandate tool usage - use WARNING/CRITICAL markers and 'READ FIRST' to ensure visibility","expanded":"When working on this type of task, avoid: Assumed agents would know when to use tools based on general context\n\nInstead: Create explicit hooks in AGENTS.md that mandate tool usage - use WARNING/CRITICAL markers and 'READ FIRST' to ensure visibility","structured":{"avoid":"Assumed agents would know when to use tools based on general context","prefer":"Create explicit hooks in AGENTS.md that mandate tool usage - use WARNING/CRITICAL markers and 'READ FIRST' to ensure visibility"}},"kind":"procedure","name":"learned/create-explicit-hooks-in-agents-md-that-mandate-to","provenance":{"correction_id":"c-1769578263708845277","created_at":"2026-01-27T21:31:03.709247071-08:00","source_type":"learned"},"when":{}},"metadata":{"confidence":0.66,"priority":0,"scope":"local","stats":{"created_at":"2026-02-16T20:35:07-08:00","times_activated":0,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-16T20:35:07-08:00"}}}
-{"id":"behavior-0b09b561c160","kind":"forgotten-behavior","content":{"content":{"canonical":"Always close beads when completing associated work - beads should close with their work, not be left dangling","expanded":"When working on this type of task, avoid: Completed work without closing the associated beads\n\nInstead: Always close beads when completing associated work - beads should close with their work, not be left dangling","structured":{"avoid":"Completed work without closing the associated beads","prefer":"Always close beads when completing associated work - beads should close with their work, not be left dangling"}},"kind":"directive","name":"learned/always-close-beads-when-completing-associated-work","provenance":{"correction_id":"correction-1770083726","source_type":"learned"},"when":{}},"metadata":{"confidence":0.62,"forget_reason":"Superseded by behavior-beads-merged","forgotten_at":"2026-02-07T19:08:27-08:00","forgotten_by":"nvandessel","original_kind":"behavior","priority":0,"scope":"both","stats":{"created_at":"2026-02-16T20:35:07-08:00","times_activated":0,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-16T20:35:07-08:00"}}}
-{"id":"behavior-c83aad31d913","kind":"behavior","content":{"content":{"canonical":"Use template.JS (not template.HTML) for data injected into blocks in html/template. template.HTML only asserts HTML safety — in JS contexts the engine still JS-escapes it. template.JS asserts JS safety and prevents double-encoding. Combine with json.HTMLEscape for XSS prevention (converts \u003c \u003e \u0026 to unicode escapes, preventing breakout)","expanded":"When working on this type of task, avoid: Used template.HTML for JSON data inside an html/template block, which caused the template engine to JS-escape the value (wrapping in quotes and escaping inner quotes), turning the JSON object into a string literal\n\nInstead: Use template.JS (not template.HTML) for data injected into blocks in html/template. template.HTML only asserts HTML safety — in JS contexts the engine still JS-escapes it. template.JS asserts JS safety and prevents double-encoding. Combine with json.HTMLEscape for XSS prevention (converts \u003c \u003e \u0026 to unicode escapes, preventing breakout)","structured":{"avoid":"Used template.HTML for JSON data inside an html/template block, which caused the template engine to JS-escape the value (wrapping in quotes and escaping inner quotes), turning the JSON object into a string literal","prefer":"Use template.JS (not template.HTML) for data injected into blocks in html/template. template.HTML only asserts HTML safety — in JS contexts the engine still JS-escapes it. template.JS asserts JS safety and prevents double-encoding. Combine with json.HTMLEscape for XSS prevention (converts \u003c \u003e \u0026 to unicode escapes, preventing breakout)"},"tags":["javascript","json","security"]},"kind":"preference","name":"learned/use-template-js-not-template-html-for-data-injec","provenance":{"correction_id":"c-1770862037019587237","source_type":"learned"},"when":{"file_path":"visualization/*","language":"go"}},"metadata":{"confidence":0.625,"priority":0,"scope":"both","stats":{"created_at":"2026-02-17T21:48:42-08:00","last_activated":"2026-02-11T19:38:40-08:00","times_activated":0,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-17T21:48:42-08:00"}}}
-{"id":"behavior-beads-merged","kind":"behavior","content":{"content":{"canonical":"When working with beads: (1) create detailed epics + tasks with dependency graphs after planning, (2) claim work with 'bd update \u003cid\u003e --status in_progress' when starting, (3) close with 'bd close \u003cid\u003e --reason \"...\"' when committing the completed work. Keep bead state synchronized with actual work progress.","tags":["beads"]},"kind":"directive","name":"learned/beads-workflow","provenance":{"source_type":"merged"},"when":{}},"metadata":{"confidence":0.72,"priority":0,"scope":"both","stats":{"created_at":"2026-02-17T22:03:17-08:00","times_activated":0,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-17T22:03:17-08:00"}}}
-{"id":"behavior-dfdc5e16b9e0","kind":"behavior","content":{"content":{"canonical":"Consider both global (~/.floop/) and local (./.floop/) scopes - users want personal preferences across ALL projects AND project-specific conventions","expanded":"When working on this type of task, avoid: Only considered project-local behavior storage\n\nInstead: Consider both global (~/.floop/) and local (./.floop/) scopes - users want personal preferences across ALL projects AND project-specific conventions","structured":{"avoid":"Only considered project-local behavior storage","prefer":"Consider both global (~/.floop/) and local (./.floop/) scopes - users want personal preferences across ALL projects AND project-specific conventions"},"tags":["floop"]},"kind":"preference","name":"learned/consider-both-global-~-floop-and-local-flo","provenance":{"correction_id":"c-1769577654747582237","source_type":"learned"},"when":{"file_path":"store/*","language":"go"}},"metadata":{"confidence":0.66,"priority":0,"scope":"local","stats":{"created_at":"2026-02-17T22:03:17-08:00","times_activated":0,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-17T22:03:17-08:00"}}}
-{"id":"behavior-b7f5d1378039","kind":"behavior","content":{"content":{"canonical":"Beads export state auto-updates with each commit hash. After final push, restore the export state file instead of committing it again to break the cycle.","expanded":"When working on this type of task, avoid: Committed beads export state changes in a loop, causing infinite commits\n\nInstead: Beads export state auto-updates with each commit hash. After final push, restore the export state file instead of committing it again to break the cycle.","structured":{"avoid":"Committed beads export state changes in a loop, causing infinite commits","prefer":"Beads export state auto-updates with each commit hash. After final push, restore the export state file instead of committing it again to break the cycle."},"tags":["beads","filesystem"]},"kind":"preference","name":"learned/beads-export-state-auto-updates-with-each-commit-h","provenance":{"correction_id":"c-1769581392105512844","source_type":"learned"},"when":{}},"metadata":{"confidence":0.66,"priority":0,"scope":"local","stats":{"created_at":"2026-02-17T22:03:17-08:00","times_activated":0,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-17T22:03:17-08:00"}}}
+{"id":"behavior-81e201973717","kind":"behavior","content":{"content":{"canonical":"Create a dedicated usage guide (docs/FLOOP_USAGE.md) and reference it prominently in AGENTS.md - separates 'what' from 'how' and makes instructions comprehensive","expanded":"When working on this type of task, avoid: Created inline usage instructions in AGENTS.md\n\nInstead: Create a dedicated usage guide (docs/FLOOP_USAGE.md) and reference it prominently in AGENTS.md - separates 'what' from 'how' and makes instructions comprehensive","structured":{"avoid":"Created inline usage instructions in AGENTS.md","prefer":"Create a dedicated usage guide (docs/FLOOP_USAGE.md) and reference it prominently in AGENTS.md - separates 'what' from 'how' and makes instructions comprehensive"}},"kind":"directive","name":"learned/create-a-dedicated-usage-guide-docs-floop_usage-m","provenance":{"correction_id":"c-1769578257801753560","created_at":"2026-01-27T21:30:57.802170973-08:00","source_type":"learned"},"when":{"language":"markdown"}},"metadata":{"confidence":0.66,"priority":0,"scope":"local","stats":{"created_at":"2026-02-16T20:35:07-08:00","times_activated":0,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-16T20:35:07-08:00"}}}
+{"id":"behavior-9c9d9ff7e96e","kind":"behavior","content":{"content":{"canonical":"When testing token budget enforcement in floop_active: (1) each behavior MUST have unique canonical content due to duplicate content detection, (2) understand the full activation pipeline: specificity → SpecificityToActivation → spreading engine sigmoid(x) → tier thresholds. Specificity 0 → activation 0.3 → sigmoid 0.5 → Summary tier. Specificity 1 → 0.4 → sigmoid ~0.731 → Full tier. To trigger budget demotion, use language-matched behaviors (specificity \u003e= 1, Full tier) with large canonical content so the total exceeds the 2000-token budget.","expanded":"When working on this type of task, avoid: When writing tests for token budget enforcement, created behaviors with identical canonical content across all 40 items, triggering duplicate content detection. Also initially assumed behaviors with `when: {}` would exceed the budget at ~25 tokens each, not accounting for the sigmoid squashing (0.3 → 0.5 post-sigmoid) putting them at Summary tier where long content gets truncated to 60 chars (~15 tokens).\n\nInstead: When testing token budget enforcement in floop_active: (1) each behavior MUST have unique canonical content due to duplicate content detection, (2) understand the full activation pipeline: specificity → SpecificityToActivation → spreading engine sigmoid(x) → tier thresholds. Specificity 0 → activation 0.3 → sigmoid 0.5 → Summary tier. Specificity 1 → 0.4 → sigmoid ~0.731 → Full tier. To trigger budget demotion, use language-matched behaviors (specificity \u003e= 1, Full tier) with large canonical content so the total exceeds the 2000-token budget.","structured":{"avoid":"When writing tests for token budget enforcement, created behaviors with identical canonical content across all 40 items, triggering duplicate content detection. Also initially assumed behaviors with `when: {}` would exceed the budget at ~25 tokens each, not accounting for the sigmoid squashing (0.3 → 0.5 post-sigmoid) putting them at Summary tier where long content gets truncated to 60 chars (~15 tokens).","prefer":"When testing token budget enforcement in floop_active: (1) each behavior MUST have unique canonical content due to duplicate content detection, (2) understand the full activation pipeline: specificity → SpecificityToActivation → spreading engine sigmoid(x) → tier thresholds. Specificity 0 → activation 0.3 → sigmoid 0.5 → Summary tier. Specificity 1 → 0.4 → sigmoid ~0.731 → Full tier. To trigger budget demotion, use language-matched behaviors (specificity \u003e= 1, Full tier) with large canonical content so the total exceeds the 2000-token budget."},"tags":["behavior","ci","floop","spreading-activation","testing"]},"kind":"preference","name":"learned/when-testing-token-budget-enforcement-in-floop_act","provenance":{"correction_id":"c-1770712806536601605","source_type":"learned"},"when":{"environment":"development","task":"testing"}},"metadata":{"confidence":0.7000000000000001,"priority":0,"scope":"both","stats":{"created_at":"2026-02-16T20:35:07-08:00","last_activated":"2026-02-11T19:19:55-08:00","times_activated":4,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-16T20:35:07-08:00"}}}
+{"id":"behavior-5ca906dd04fc","kind":"behavior","content":{"content":{"canonical":"Call floop_learn whenever insights emerge during ANY work — not just corrections. Discovery of gaps, patterns, architectural understanding, feature inventories, design decisions, and any finding that would benefit future sessions is a learning opportunity. The system handles bloat through spreading activation, token budgets, and tiering — so learn freely and trust the system to curate. If a previous learning was too narrow, don't try to edit it — just learn the better version and let weights sort it out.","expanded":"When working on this type of task, avoid: Only called floop_learn when explicitly corrected or when making obvious mistakes. Missed learning opportunities during exploration, research, audits, and general work where insights were discovered but not captured.\n\nInstead: Call floop_learn whenever insights emerge during ANY work — not just corrections. Discovery of gaps, patterns, architectural understanding, feature inventories, design decisions, and any finding that would benefit future sessions is a learning opportunity. The system handles bloat through spreading activation, token budgets, and tiering — so learn freely and trust the system to curate. If a previous learning was too narrow, don't try to edit it — just learn the better version and let weights sort it out.","structured":{"avoid":"Only called floop_learn when explicitly corrected or when making obvious mistakes. Missed learning opportunities during exploration, research, audits, and general work where insights were discovered but not captured.","prefer":"Call floop_learn whenever insights emerge during ANY work — not just corrections. Discovery of gaps, patterns, architectural understanding, feature inventories, design decisions, and any finding that would benefit future sessions is a learning opportunity. The system handles bloat through spreading activation, token budgets, and tiering — so learn freely and trust the system to curate. If a previous learning was too narrow, don't try to edit it — just learn the better version and let weights sort it out."},"tags":["correction","floop","spreading-activation"]},"kind":"constraint","name":"learned/call-floop_learn-whenever-insights-emerge-during-a","provenance":{"correction_id":"c-1770704347456486267","source_type":"learned"},"when":{"environment":"development"}},"metadata":{"confidence":0.7200000000000001,"priority":0,"scope":"both","stats":{"created_at":"2026-02-16T20:35:07-08:00","last_activated":"2026-02-11T19:19:55-08:00","times_activated":4,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-16T20:35:07-08:00"}}}
+{"id":"behavior-6833c04e5542","kind":"behavior","content":{"conflicts":null,"content":{"canonical":"Use onRenderFramePost(ctx, globalScale) for custom edge rendering in force-graph. It fires every frame (60fps), provides the canvas context in graph coordinate space, and actually works. Iterate graph.graphData().links manually inside the callback. linkDirectionalParticles also works for simpler particle effects.","expanded":"When working on this type of task, avoid: Used linkCanvasObject API in force-graph v1.51.1 for custom link rendering. Despite the API existing as a getter/setter, the callback is NEVER invoked during rendering - 0 calls across all frames. Spent significant debugging time before discovering this.\n\nInstead: Use onRenderFramePost(ctx, globalScale) for custom edge rendering in force-graph. It fires every frame (60fps), provides the canvas context in graph coordinate space, and actually works. Iterate graph.graphData().links manually inside the callback. linkDirectionalParticles also works for simpler particle effects.","structured":{"avoid":"Used linkCanvasObject API in force-graph v1.51.1 for custom link rendering. Despite the API existing as a getter/setter, the callback is NEVER invoked during rendering - 0 calls across all frames. Spent significant debugging time before discovering this.","prefer":"Use onRenderFramePost(ctx, globalScale) for custom edge rendering in force-graph. It fires every frame (60fps), provides the canvas context in graph coordinate space, and actually works. Iterate graph.graphData().links manually inside the callback. linkDirectionalParticles also works for simpler particle effects."}},"kind":"preference","name":"learned/use-onrenderframepostctx-globalscale-for-custom","overrides":null,"provenance":{"correction_id":"c-1771539574522859165","created_at":"2026-02-19T14:19:34.52308522-08:00","source_type":"learned"},"requires":null,"when":{"file_path":"visualization/*","language":"javascript"}},"metadata":{"confidence":0.6,"priority":0,"scope":"local","stats":{"created_at":"2026-02-19T14:19:34-08:00","times_activated":0,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-19T14:19:34-08:00"}}}
+{"id":"behavior-967fd13bd390","kind":"forgotten-behavior","content":{"content":{"canonical":"Use scope=both to save important learnings to both local and global","expanded":"When working on this type of task, avoid: Only saved to one store\n\nInstead: Use scope=both to save important learnings to both local and global","structured":{"avoid":"Only saved to one store","prefer":"Use scope=both to save important learnings to both local and global"}},"kind":"preference","name":"learned/use-scope=both-to-save-important-learnings-to-both","provenance":{"correction_id":"c-1769616836888545203","source_type":"learned"},"when":{"task":"configuration"}},"metadata":{"confidence":0.62,"forget_reason":"Superseded by behavior-floop-scope-merged","forgotten_at":"2026-02-07T19:08:32-08:00","forgotten_by":"nvandessel","original_kind":"behavior","priority":0,"scope":"both","stats":{"created_at":"2026-02-16T20:35:07-08:00","times_activated":0,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-16T20:35:07-08:00"}}}
+{"id":"behavior-0070f24ee3e8","kind":"forgotten-behavior","content":{"content":{"canonical":"When working on beads: (1) claim with `bd update \u003cid\u003e --status in_progress` when starting, (2) close with `bd close \u003cid\u003e --reason \"...\"` at the same time as the commit that completes the work. Keep bead state synchronized with actual work progress so other agents aren't confused.","expanded":"When working on this type of task, avoid: Made commits that closed beads without updating bead status - didn't claim beads when starting work, didn't close them with the associated commit, waited for user to remind me\n\nInstead: When working on beads: (1) claim with `bd update \u003cid\u003e --status in_progress` when starting, (2) close with `bd close \u003cid\u003e --reason \"...\"` at the same time as the commit that completes the work. Keep bead state synchronized with actual work progress so other agents aren't confused.","structured":{"avoid":"Made commits that closed beads without updating bead status - didn't claim beads when starting work, didn't close them with the associated commit, waited for user to remind me","prefer":"When working on beads: (1) claim with `bd update \u003cid\u003e --status in_progress` when starting, (2) close with `bd close \u003cid\u003e --reason \"...\"` at the same time as the commit that completes the work. Keep bead state synchronized with actual work progress so other agents aren't confused."}},"kind":"preference","name":"learned/when-working-on-beads-1-claim-with-`bd-update-\u003c","provenance":{"correction_id":"correction-1770095930","source_type":"learned"},"when":{"task":"development"}},"metadata":{"confidence":0.655,"forget_reason":"Superseded by behavior-beads-merged","forgotten_at":"2026-02-07T19:08:27-08:00","forgotten_by":"nvandessel","original_kind":"behavior","priority":0,"scope":"both","stats":{"created_at":"2026-02-16T20:35:07-08:00","times_activated":0,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-16T20:35:07-08:00"}}}
+{"id":"behavior-a9a4b09dda1f","kind":"behavior","content":{"content":{"canonical":"Create explicit hooks in AGENTS.md that mandate tool usage - use WARNING/CRITICAL markers and 'READ FIRST' to ensure visibility","expanded":"When working on this type of task, avoid: Assumed agents would know when to use tools based on general context\n\nInstead: Create explicit hooks in AGENTS.md that mandate tool usage - use WARNING/CRITICAL markers and 'READ FIRST' to ensure visibility","structured":{"avoid":"Assumed agents would know when to use tools based on general context","prefer":"Create explicit hooks in AGENTS.md that mandate tool usage - use WARNING/CRITICAL markers and 'READ FIRST' to ensure visibility"}},"kind":"procedure","name":"learned/create-explicit-hooks-in-agents-md-that-mandate-to","provenance":{"correction_id":"c-1769578263708845277","created_at":"2026-01-27T21:31:03.709247071-08:00","source_type":"learned"},"when":{"task":"documentation"}},"metadata":{"confidence":0.66,"priority":0,"scope":"local","stats":{"created_at":"2026-02-16T20:35:07-08:00","times_activated":0,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-16T20:35:07-08:00"}}}
+{"id":"behavior-b7f5d1378039","kind":"behavior","content":{"content":{"canonical":"Beads export state auto-updates with each commit hash. After final push, restore the export state file instead of committing it again to break the cycle.","expanded":"When working on this type of task, avoid: Committed beads export state changes in a loop, causing infinite commits\n\nInstead: Beads export state auto-updates with each commit hash. After final push, restore the export state file instead of committing it again to break the cycle.","structured":{"avoid":"Committed beads export state changes in a loop, causing infinite commits","prefer":"Beads export state auto-updates with each commit hash. After final push, restore the export state file instead of committing it again to break the cycle."}},"kind":"preference","name":"learned/beads-export-state-auto-updates-with-each-commit-h","provenance":{"correction_id":"c-1769581392105512844","created_at":"2026-01-27T22:23:12.106122549-08:00","source_type":"learned"},"when":{"task":"git workflow"}},"metadata":{"confidence":0.66,"priority":0,"scope":"local","stats":{"created_at":"2026-02-16T20:35:07-08:00","times_activated":0,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-16T20:35:07-08:00"}}}
+{"id":"behavior-8341f0d52ce6","kind":"behavior","content":{"content":{"canonical":"MCP go-sdk expects jsonschema:\"Description text\" format without key=value syntax. The tag value is directly the description.","expanded":"When working on this type of task, avoid: Subagent used jsonschema:\"description=...\" tag format which caused MCP SDK panic\n\nInstead: MCP go-sdk expects jsonschema:\"Description text\" format without key=value syntax. The tag value is directly the description.","structured":{"avoid":"Subagent used jsonschema:\"description=...\" tag format which caused MCP SDK panic","prefer":"MCP go-sdk expects jsonschema:\"Description text\" format without key=value syntax. The tag value is directly the description."}},"kind":"directive","name":"learned/mcp-go-sdk-expects-jsonschemadescription-text-f","provenance":{"correction_id":"correction-1769817031","created_at":"2026-01-30T15:50:31.088121234-08:00","source_type":"learned"},"when":{"file_path":"mcp/*","language":"go"}},"metadata":{"confidence":0.66,"priority":0,"scope":"both","stats":{"created_at":"2026-02-16T20:35:07-08:00","times_activated":0,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-16T20:35:07-08:00"}}}
+{"id":"behavior-0b09b561c160","kind":"forgotten-behavior","content":{"content":{"canonical":"Always close beads when completing associated work - beads should close with their work, not be left dangling","expanded":"When working on this type of task, avoid: Completed work without closing the associated beads\n\nInstead: Always close beads when completing associated work - beads should close with their work, not be left dangling","structured":{"avoid":"Completed work without closing the associated beads","prefer":"Always close beads when completing associated work - beads should close with their work, not be left dangling"}},"kind":"directive","name":"learned/always-close-beads-when-completing-associated-work","provenance":{"correction_id":"correction-1770083726","source_type":"learned"},"when":{"task":"project-management"}},"metadata":{"confidence":0.62,"forget_reason":"Superseded by behavior-beads-merged","forgotten_at":"2026-02-07T19:08:27-08:00","forgotten_by":"nvandessel","original_kind":"behavior","priority":0,"scope":"both","stats":{"created_at":"2026-02-16T20:35:07-08:00","times_activated":0,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-16T20:35:07-08:00"}}}
+{"id":"behavior-dbef97df332b","kind":"behavior","content":{"content":{"canonical":"Use template.JS for values injected into script blocks. Pre-sanitize with json.HTMLEscape to prevent script breakout XSS. template.HTML is only trusted for HTML contexts, not JS contexts in html/template.","expanded":"When working on this type of task, avoid: Used template.HTML for injecting JSON into a script block in html/template. html/template applies JS-encoding on template.HTML values inside script contexts, turning JSON objects into quoted strings.\n\nInstead: Use template.JS for values injected into script blocks. Pre-sanitize with json.HTMLEscape to prevent script breakout XSS. template.HTML is only trusted for HTML contexts, not JS contexts in html/template.","structured":{"avoid":"Used template.HTML for injecting JSON into a script block in html/template. html/template applies JS-encoding on template.HTML values inside script contexts, turning JSON objects into quoted strings.","prefer":"Use template.JS for values injected into script blocks. Pre-sanitize with json.HTMLEscape to prevent script breakout XSS. template.HTML is only trusted for HTML contexts, not JS contexts in html/template."},"tags":["javascript","json","security"]},"kind":"preference","name":"learned/use-template-js-for-values-injected-into-script-bl","provenance":{"correction_id":"c-1770862232174665584","source_type":"learned"},"when":{"environment":"development","file_path":"visualization/*","language":"go","task":"development"}},"metadata":{"confidence":0.625,"priority":0,"scope":"both","stats":{"created_at":"2026-02-16T20:35:07-08:00","last_activated":"2026-02-11T19:38:40-08:00","times_activated":2,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-16T20:35:07-08:00"}}}
+{"id":"behavior-floop-scope-merged","kind":"behavior","content":{"content":{"canonical":"Floop has two scopes: global (~/.floop/) for agent personal preferences across ALL projects, and local (./.floop/) for project-specific conventions. Use scope=both to save important learnings to both stores."},"kind":"preference","name":"learned/floop-scope-strategy","provenance":{"created_at":"2026-02-07T00:00:00-08:00","source_type":"merged"},"when":{"task":"configuration"}},"metadata":{"confidence":0.72,"priority":0,"scope":"both","stats":{"created_at":"2026-02-16T20:35:07-08:00","times_activated":0,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-16T20:35:07-08:00"}}}
+{"id":"behavior-b86932976d88","kind":"behavior","content":{"content":{"canonical":"Even for beads state sync and chore commits, create a feature branch and PR. The rule 'never merge directly to main' applies to ALL commits, not just code changes. Push back on plans that specify direct-to-main commits.","expanded":"When working on this type of task, avoid: Committed beads sync/housekeeping changes directly to main and pushed, following the plan without questioning it. Three commits went straight to main without a PR.\n\nInstead: Even for beads state sync and chore commits, create a feature branch and PR. The rule 'never merge directly to main' applies to ALL commits, not just code changes. Push back on plans that specify direct-to-main commits.","structured":{"avoid":"Committed beads sync/housekeeping changes directly to main and pushed, following the plan without questioning it. Three commits went straight to main without a PR.","prefer":"Even for beads state sync and chore commits, create a feature branch and PR. The rule 'never merge directly to main' applies to ALL commits, not just code changes. Push back on plans that specify direct-to-main commits."}},"kind":"constraint","name":"learned/even-for-beads-state-sync-and-chore-commits-creat","provenance":{"correction_id":"c-1770570585979824699","created_at":"2026-02-08T09:09:45.979869293-08:00","source_type":"learned"},"when":{"environment":"development"}},"metadata":{"confidence":0.6,"priority":0,"scope":"both","stats":{"created_at":"2026-02-16T20:35:07-08:00","times_activated":0,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-16T20:35:07-08:00"}}}
+{"id":"behavior-ae49727578ff","kind":"behavior","content":{"content":{"canonical":"This is a known feature gap. The review mechanism (ApprovePending/RejectPending) exists internally but needs to be exposed as MCP tools (floop_approve/floop_reject) and/or CLI commands. Pending behaviors still activate via floop_active — they just lack the approval stamp and confidence boost. Track this as a feature to implement.","expanded":"When working on this type of task, avoid: floop_learn returns requires_review: true for constraint behaviors, but there is no CLI command or MCP tool to actually approve or reject pending behaviors. The ApprovePending() and RejectPending() methods exist in internal/learning/loop.go but are not exposed to users.\n\nInstead: This is a known feature gap. The review mechanism (ApprovePending/RejectPending) exists internally but needs to be exposed as MCP tools (floop_approve/floop_reject) and/or CLI commands. Pending behaviors still activate via floop_active — they just lack the approval stamp and confidence boost. Track this as a feature to implement.","structured":{"avoid":"floop_learn returns requires_review: true for constraint behaviors, but there is no CLI command or MCP tool to actually approve or reject pending behaviors. The ApprovePending() and RejectPending() methods exist in internal/learning/loop.go but are not exposed to users.","prefer":"This is a known feature gap. The review mechanism (ApprovePending/RejectPending) exists internally but needs to be exposed as MCP tools (floop_approve/floop_reject) and/or CLI commands. Pending behaviors still activate via floop_active — they just lack the approval stamp and confidence boost. Track this as a feature to implement."},"tags":["behavior","cli","floop","mcp"]},"kind":"directive","name":"learned/this-is-a-known-feature-gap-the-review-mechanism","provenance":{"correction_id":"c-1770827519205934507","source_type":"learned"},"when":{"environment":"development","task":"development"}},"metadata":{"confidence":0.68,"priority":0,"scope":"both","stats":{"created_at":"2026-02-16T20:35:07-08:00","last_activated":"2026-02-11T19:19:55-08:00","times_activated":4,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-16T20:35:07-08:00"}}}
+{"id":"behavior-714d55f38be5","kind":"behavior","content":{"content":{"canonical":"When performing audits, exploration, or research that reveals project-level insights (gaps, patterns, architectural understanding), immediately capture findings via floop_learn. Any discovery that would be useful context for future sessions is a learning opportunity — not just corrections or mistakes. Proactive learning includes: audit findings, documentation gaps identified, architectural patterns discovered, feature inventory results.","expanded":"When working on this type of task, avoid: During a deep docs audit, discovered significant documentation gaps (no centralized CLI reference, missing integration guides for 6+ tools, no architecture docs, undocumented token optimization features) but failed to capture the findings as a floop learning. The exploration yielded clear insights about the project's documentation state that should persist across sessions.\n\nInstead: When performing audits, exploration, or research that reveals project-level insights (gaps, patterns, architectural understanding), immediately capture findings via floop_learn. Any discovery that would be useful context for future sessions is a learning opportunity — not just corrections or mistakes. Proactive learning includes: audit findings, documentation gaps identified, architectural patterns discovered, feature inventory results.","structured":{"avoid":"During a deep docs audit, discovered significant documentation gaps (no centralized CLI reference, missing integration guides for 6+ tools, no architecture docs, undocumented token optimization features) but failed to capture the findings as a floop learning. The exploration yielded clear insights about the project's documentation state that should persist across sessions.","prefer":"When performing audits, exploration, or research that reveals project-level insights (gaps, patterns, architectural understanding), immediately capture findings via floop_learn. Any discovery that would be useful context for future sessions is a learning opportunity — not just corrections or mistakes. Proactive learning includes: audit findings, documentation gaps identified, architectural patterns discovered, feature inventory results."},"tags":["correction","floop"]},"kind":"preference","name":"learned/when-performing-audits-exploration-or-research-t","provenance":{"correction_id":"c-1770702998768527690","source_type":"learned"},"when":{"environment":"development","task":"development"}},"metadata":{"confidence":0.7400000000000001,"priority":0,"scope":"both","stats":{"created_at":"2026-02-16T20:35:07-08:00","last_activated":"2026-02-11T19:19:55-08:00","times_activated":4,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-16T20:35:07-08:00"}}}
+{"id":"behavior-01cb13038379","kind":"behavior","content":{"content":{"canonical":"When documenting all CLI commands, also account for Cobra's implicit built-in commands: completion (shell autocompletion) and help. Run `floop --help` to get the full command list, not just what's in main.go","expanded":"When working on this type of task, avoid: When documenting CLI commands, only looked at explicitly registered commands in main.go and missed Cobra's auto-generated built-in commands (completion, help)\n\nInstead: When documenting all CLI commands, also account for Cobra's implicit built-in commands: completion (shell autocompletion) and help. Run `floop --help` to get the full command list, not just what's in main.go","structured":{"avoid":"When documenting CLI commands, only looked at explicitly registered commands in main.go and missed Cobra's auto-generated built-in commands (completion, help)","prefer":"When documenting all CLI commands, also account for Cobra's implicit built-in commands: completion (shell autocompletion) and help. Run `floop --help` to get the full command list, not just what's in main.go"},"tags":["bash","cli","floop","go"]},"kind":"directive","name":"learned/when-documenting-all-cli-commands-also-account-fo","provenance":{"correction_id":"c-1770713639385927936","source_type":"learned"},"when":{"environment":"development"}},"metadata":{"confidence":0.7000000000000001,"priority":0,"scope":"both","stats":{"created_at":"2026-02-16T20:35:07-08:00","last_activated":"2026-02-11T19:19:55-08:00","times_activated":4,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-16T20:35:07-08:00"}}}
+{"id":"behavior-150ae3a40011","kind":"behavior","content":{"content":{"canonical":"When completing a deep system audit (like spreading activation + tag affinity validation), immediately capture the key findings via floop_learn: what was tested, what works, what gaps were found, what dictionary keywords are missing, connectivity metrics, token budget health. These audit results are high-value persistent context.","expanded":"When working on this type of task, avoid: When auditing the spreading activation system, found features were working correctly but didn't capture the audit findings via floop_learn. A comprehensive system audit that validates end-to-end functionality is a valuable learning opportunity that should persist across sessions.\n\nInstead: When completing a deep system audit (like spreading activation + tag affinity validation), immediately capture the key findings via floop_learn: what was tested, what works, what gaps were found, what dictionary keywords are missing, connectivity metrics, token budget health. These audit results are high-value persistent context.","structured":{"avoid":"When auditing the spreading activation system, found features were working correctly but didn't capture the audit findings via floop_learn. A comprehensive system audit that validates end-to-end functionality is a valuable learning opportunity that should persist across sessions.","prefer":"When completing a deep system audit (like spreading activation + tag affinity validation), immediately capture the key findings via floop_learn: what was tested, what works, what gaps were found, what dictionary keywords are missing, connectivity metrics, token budget health. These audit results are high-value persistent context."},"tags":["floop","spreading-activation"]},"kind":"directive","name":"learned/when-completing-a-deep-system-audit-like-spreadin","provenance":{"correction_id":"c-1770867616134151808","source_type":"learned"},"when":{"environment":"development"}},"metadata":{"confidence":0.6,"priority":0,"scope":"both","stats":{"created_at":"2026-02-16T20:35:07-08:00","times_activated":0,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-16T20:35:07-08:00"}}}
+{"id":"behavior-91819ae1aa27","kind":"behavior","content":{"content":{"canonical":"Bad learnings are corrected by learning again, better — not by editing. The spreading activation system mirrors human memory: you can't edit a memory, but you can form a stronger one that outcompetes it. Broader learnings activate in more contexts, win token budget competition, and the narrow/wrong ones naturally go dormant. Trust the system to self-correct through more learning, not surgical intervention.","expanded":"When working on this type of task, avoid: Considered adding a 'floop refine' or 'floop edit' command to surgically fix bad learnings, which fights the human memory metaphor the system is built on.\n\nInstead: Bad learnings are corrected by learning again, better — not by editing. The spreading activation system mirrors human memory: you can't edit a memory, but you can form a stronger one that outcompetes it. Broader learnings activate in more contexts, win token budget competition, and the narrow/wrong ones naturally go dormant. Trust the system to self-correct through more learning, not surgical intervention.","structured":{"avoid":"Considered adding a 'floop refine' or 'floop edit' command to surgically fix bad learnings, which fights the human memory metaphor the system is built on.","prefer":"Bad learnings are corrected by learning again, better — not by editing. The spreading activation system mirrors human memory: you can't edit a memory, but you can form a stronger one that outcompetes it. Broader learnings activate in more contexts, win token budget competition, and the narrow/wrong ones naturally go dormant. Trust the system to self-correct through more learning, not surgical intervention."},"tags":["go","spreading-activation"]},"kind":"directive","name":"learned/bad-learnings-are-corrected-by-learning-again-bet","provenance":{"correction_id":"c-1770704353415650816","source_type":"learned"},"when":{"environment":"development"}},"metadata":{"confidence":0.7200000000000001,"priority":0,"scope":"both","stats":{"created_at":"2026-02-16T20:35:07-08:00","last_activated":"2026-02-11T19:19:55-08:00","times_activated":4,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-16T20:35:07-08:00"}}}
+{"id":"behavior-95d6b9f414e4","kind":"forgotten-behavior","content":{"content":{"canonical":"Global scope is for agent's personal preferences/style across ALL work. Local scope is for team/project-specific conventions. Both have distinct value.","expanded":"When working on this type of task, avoid: Assumed one storage location would be enough\n\nInstead: Global scope is for agent's personal preferences/style across ALL work. Local scope is for team/project-specific conventions. Both have distinct value.","structured":{"avoid":"Assumed one storage location would be enough","prefer":"Global scope is for agent's personal preferences/style across ALL work. Local scope is for team/project-specific conventions. Both have distinct value."}},"kind":"preference","name":"learned/global-scope-is-for-agents-personal-preferences-s","provenance":{"correction_id":"c-1769577671639472329","source_type":"learned"},"when":{"task":"architecture"}},"metadata":{"confidence":0.62,"forget_reason":"Superseded by behavior-floop-scope-merged","forgotten_at":"2026-02-07T19:08:32-08:00","forgotten_by":"nvandessel","original_kind":"behavior","priority":0,"scope":"local","stats":{"created_at":"2026-02-16T20:35:07-08:00","times_activated":0,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-16T20:35:07-08:00"}}}
+{"id":"behavior-c83aad31d913","kind":"behavior","content":{"content":{"canonical":"Use template.JS (not template.HTML) for data injected into blocks in html/template. template.HTML only asserts HTML safety — in JS contexts the engine still JS-escapes it. template.JS asserts JS safety and prevents double-encoding. Combine with json.HTMLEscape for XSS prevention (converts \u003c \u003e \u0026 to unicode escapes, preventing breakout)","expanded":"When working on this type of task, avoid: Used template.HTML for JSON data inside an html/template block, which caused the template engine to JS-escape the value (wrapping in quotes and escaping inner quotes), turning the JSON object into a string literal\n\nInstead: Use template.JS (not template.HTML) for data injected into blocks in html/template. template.HTML only asserts HTML safety — in JS contexts the engine still JS-escapes it. template.JS asserts JS safety and prevents double-encoding. Combine with json.HTMLEscape for XSS prevention (converts \u003c \u003e \u0026 to unicode escapes, preventing breakout)","structured":{"avoid":"Used template.HTML for JSON data inside an html/template block, which caused the template engine to JS-escape the value (wrapping in quotes and escaping inner quotes), turning the JSON object into a string literal","prefer":"Use template.JS (not template.HTML) for data injected into blocks in html/template. template.HTML only asserts HTML safety — in JS contexts the engine still JS-escapes it. template.JS asserts JS safety and prevents double-encoding. Combine with json.HTMLEscape for XSS prevention (converts \u003c \u003e \u0026 to unicode escapes, preventing breakout)"},"tags":["javascript","json","security"]},"kind":"preference","name":"learned/use-template-js-not-template-html-for-data-injec","provenance":{"correction_id":"c-1770862037019587237","source_type":"learned"},"when":{"environment":"development","file_path":"visualization/*","language":"go","task":"development"}},"metadata":{"confidence":0.625,"priority":0,"scope":"both","stats":{"created_at":"2026-02-16T20:35:07-08:00","last_activated":"2026-02-11T19:38:40-08:00","times_activated":2,"times_confirmed":0,"times_followed":0,"times_overridden":0,"updated_at":"2026-02-16T20:35:07-08:00"}}}
diff --git a/.gitignore b/.gitignore
index 976c7f6..597655b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,3 +26,6 @@ build/
# GoReleaser output
dist/
+
+# Node.js (Playwright test dependencies)
+node_modules/
diff --git a/Makefile b/Makefile
index b2892d0..55a2bce 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: build test test-coverage lint lint-fix fmt fmt-check vet vuln ci clean docs-validate graph-html graph-screenshot graph-preview
+.PHONY: build test test-coverage lint lint-fix fmt fmt-check vet vuln ci clean docs-validate graph-html graph-screenshot graph-preview graph-serve
VERSION ?= dev
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
@@ -71,3 +71,7 @@ graph-screenshot: graph-html
graph-preview: graph-screenshot
@echo "Preview: build/graph/graph.html (open in browser)"
@echo "Screenshot: build/graph/graph.png"
+
+graph-serve:
+ GOWORK=off go build -o ./floop ./cmd/floop
+ ./floop graph --format html --serve
diff --git a/capture_gif.mjs b/capture_gif.mjs
new file mode 100644
index 0000000..58556f4
--- /dev/null
+++ b/capture_gif.mjs
@@ -0,0 +1,124 @@
+#!/usr/bin/env node
+// Capture animated GIF of electric mode for visual verification
+// Shows: graph loading → settling → node click → auto-zoom → animation
+import { chromium } from 'playwright';
+import { spawn, execSync } from 'child_process';
+
+let server, browser;
+
+async function main() {
+ execSync('mkdir -p build/gif');
+
+ // Start server
+ server = spawn('./floop', ['graph', '--format', 'html', '--serve'], {
+ cwd: '/home/nvandessel/repos/feedback-loop/.worktrees/feature-electric-graph',
+ env: { ...process.env, GOWORK: 'off' },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+
+ const urlPromise = new Promise((resolve) => {
+ const handler = (data) => {
+ const match = data.toString().match(/http:\/\/[^\s]+/);
+ if (match) resolve(match[0]);
+ };
+ server.stderr.on('data', handler);
+ server.stdout.on('data', handler);
+ });
+
+ const url = await Promise.race([
+ urlPromise,
+ new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 8000))
+ ]);
+
+ // Wait for server ready
+ for (let i = 0; i < 20; i++) {
+ try { const r = await fetch(url); if (r.ok) break; } catch {}
+ await new Promise(r => setTimeout(r, 200));
+ }
+
+ browser = await chromium.launch({ headless: true });
+ const page = await browser.newPage({ viewport: { width: 800, height: 600 } });
+ await page.goto(url, { waitUntil: 'networkidle' });
+ await page.waitForFunction(() => window.__graph && window.__electricSim, { timeout: 10000 });
+
+ // Wait for graph to settle
+ console.log('Waiting for graph to settle...');
+ await page.waitForTimeout(3000);
+
+ let frame = 0;
+ const shot = async () => {
+ const padded = String(frame).padStart(3, '0');
+ await page.screenshot({ path: `build/gif/frame_${padded}.png` });
+ frame++;
+ };
+
+ // Phase 1: Show the normal graph (1.5 seconds at 5fps = 8 frames)
+ console.log('Phase 1: Normal graph overview...');
+ for (let i = 0; i < 8; i++) {
+ await shot();
+ await page.waitForTimeout(200);
+ }
+
+ // Phase 2: Click a node — use actual mouse click on canvas
+ // Find a well-connected node near the center for best visual effect
+ const nodeInfo = await page.evaluate(() => {
+ const nodes = window.__graph.graphData().nodes;
+ const links = window.__graph.graphData().links;
+ // Count connections per node
+ const deg = {};
+ nodes.forEach(n => deg[n.id] = 0);
+ links.forEach(l => {
+ const s = l.source.id || l.source;
+ const t = l.target.id || l.target;
+ deg[s] = (deg[s] || 0) + 1;
+ deg[t] = (deg[t] || 0) + 1;
+ });
+ // Pick highest-degree node (most dramatic spread)
+ const sorted = nodes.slice().sort((a, b) => (deg[b.id] || 0) - (deg[a.id] || 0));
+ const best = sorted[0];
+ // Convert graph coords to screen coords
+ const screen = window.__graph.graph2ScreenCoords(best.x, best.y);
+ return { id: best.id, x: screen.x, y: screen.y, deg: deg[best.id] };
+ });
+
+ console.log(`Phase 2: Clicking node "${nodeInfo.id}" (${nodeInfo.deg} connections) at (${Math.round(nodeInfo.x)}, ${Math.round(nodeInfo.y)})...`);
+
+ // Capture the click moment — mouse moves to node position
+ await page.mouse.move(nodeInfo.x, nodeInfo.y);
+ await shot(); // frame right before click
+ await page.mouse.click(nodeInfo.x, nodeInfo.y);
+ await page.waitForTimeout(100);
+ // Close the detail panel and move mouse away to hide tooltip
+ await page.evaluate(() => window.closePanel());
+ await page.mouse.move(10, 10); // top-left corner, away from graph
+ await page.waitForTimeout(100);
+ await shot(); // frame right after click (panel closed, tooltip gone)
+
+ // Phase 3: Zoom animation (auto-zoom takes ~800ms, capture at 5fps)
+ console.log('Phase 3: Auto-zoom to neighborhood...');
+ for (let i = 0; i < 6; i++) {
+ await page.waitForTimeout(200);
+ await shot();
+ }
+
+ // Phase 4: Full animation cycle (10 seconds at 5fps = 50 frames)
+ console.log('Phase 4: Electric mode animation...');
+ for (let i = 0; i < 50; i++) {
+ await page.waitForTimeout(200);
+ await shot();
+ if (i % 10 === 0) console.log(` Frame ${frame}/${8 + 2 + 6 + 50}`);
+ }
+
+ console.log(`Total frames captured: ${frame}`);
+
+ console.log('Assembling GIF...');
+ execSync('ffmpeg -y -framerate 5 -i build/gif/frame_%03d.png -vf "fps=5,scale=800:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse" build/electric-mode.gif 2>/dev/null');
+ console.log('GIF saved: build/electric-mode.gif');
+}
+
+main()
+ .catch(err => console.error('Error:', err))
+ .finally(() => {
+ if (browser) browser.close();
+ if (server) server.kill();
+ });
diff --git a/cmd/floop/cmd_graph.go b/cmd/floop/cmd_graph.go
index 8b9d19c..68d59e6 100644
--- a/cmd/floop/cmd_graph.go
+++ b/cmd/floop/cmd_graph.go
@@ -5,7 +5,10 @@ import (
"encoding/json"
"fmt"
"os"
+ "os/signal"
"path/filepath"
+ "syscall"
+ "time"
"github.com/nvandessel/feedback-loop/internal/ranking"
"github.com/nvandessel/feedback-loop/internal/store"
@@ -23,6 +26,7 @@ func newGraphCmd() *cobra.Command {
format, _ := cmd.Flags().GetString("format")
output, _ := cmd.Flags().GetString("output")
noOpen, _ := cmd.Flags().GetBool("no-open")
+ serve, _ := cmd.Flags().GetBool("serve")
gs, err := openStoreForGraph(root)
if err != nil {
@@ -62,27 +66,13 @@ func newGraphCmd() *cobra.Command {
PageRank: pageRank,
}
- htmlBytes, err := visualization.RenderHTML(ctx, gs, enrichment)
- if err != nil {
- return fmt.Errorf("render HTML: %w", err)
- }
-
- // Determine output path
- outPath := output
- if outPath == "" {
- tmpDir := os.TempDir()
- outPath = filepath.Join(tmpDir, "floop-graph.html")
- }
-
- if err := os.WriteFile(outPath, htmlBytes, 0644); err != nil {
- return fmt.Errorf("write HTML file: %w", err)
- }
-
- fmt.Fprintf(cmd.OutOrStdout(), "Graph written to %s\n", outPath)
-
- if !noOpen {
- if err := visualization.OpenBrowser(outPath); err != nil {
- fmt.Fprintf(cmd.ErrOrStderr(), "Could not open browser: %v\nOpen %s manually.\n", err, outPath)
+ if serve {
+ if err := runGraphServer(cmd, ctx, gs, enrichment, noOpen); err != nil {
+ return err
+ }
+ } else {
+ if err := writeStaticHTML(cmd, ctx, gs, enrichment, output, noOpen); err != nil {
+ return err
}
}
@@ -97,10 +87,91 @@ func newGraphCmd() *cobra.Command {
cmd.Flags().String("format", "dot", "Output format: dot, json, or html")
cmd.Flags().StringP("output", "o", "", "Output file path (html format only)")
cmd.Flags().Bool("no-open", false, "Don't open browser after generating HTML")
+ cmd.Flags().Bool("serve", false, "Start a local server with electric mode (spreading activation visualization)")
return cmd
}
+// writeStaticHTML renders the graph to a self-contained HTML file.
+func writeStaticHTML(cmd *cobra.Command, ctx context.Context, gs store.GraphStore, enrichment *visualization.EnrichmentData, output string, noOpen bool) error {
+ htmlBytes, err := visualization.RenderHTML(ctx, gs, enrichment)
+ if err != nil {
+ return fmt.Errorf("render HTML: %w", err)
+ }
+
+ outPath := output
+ if outPath == "" {
+ outPath = filepath.Join(os.TempDir(), "floop-graph.html")
+ }
+
+ if err := os.WriteFile(outPath, htmlBytes, 0644); err != nil {
+ return fmt.Errorf("write HTML file: %w", err)
+ }
+
+ fmt.Fprintf(cmd.OutOrStdout(), "Graph written to %s\n", outPath)
+
+ if !noOpen {
+ if err := visualization.OpenBrowser(outPath); err != nil {
+ fmt.Fprintf(cmd.ErrOrStderr(), "Could not open browser: %v\nOpen %s manually.\n", err, outPath)
+ }
+ }
+ return nil
+}
+
+// runGraphServer starts a local HTTP server with electric mode and blocks until Ctrl-C.
+func runGraphServer(cmd *cobra.Command, ctx context.Context, gs store.GraphStore, enrichment *visualization.EnrichmentData, noOpen bool) error {
+ srv := visualization.NewServer(gs, enrichment)
+
+ srvCtx, srvCancel := context.WithCancel(ctx)
+ defer srvCancel()
+
+ // Handle SIGINT/SIGTERM for graceful shutdown
+ sigCh := make(chan os.Signal, 1)
+ signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
+ defer signal.Stop(sigCh)
+
+ go func() {
+ select {
+ case <-sigCh:
+ srvCancel()
+ case <-srvCtx.Done():
+ }
+ }()
+
+ errCh := make(chan error, 1)
+ go func() { errCh <- srv.ListenAndServe(srvCtx) }()
+
+ // Wait for server to start
+ deadline := time.Now().Add(3 * time.Second)
+ for time.Now().Before(deadline) {
+ if srv.Addr() != "" {
+ break
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+
+ addr := srv.Addr()
+ if addr == "" {
+ return fmt.Errorf("server failed to start")
+ }
+
+ url := "http://" + addr
+ fmt.Fprintf(cmd.OutOrStdout(), "Graph server running at %s\n", url)
+ fmt.Fprintf(cmd.OutOrStdout(), "Press Ctrl-C to stop.\n")
+
+ if !noOpen {
+ if err := visualization.OpenBrowser(url); err != nil {
+ fmt.Fprintf(cmd.ErrOrStderr(), "Could not open browser: %v\nOpen %s manually.\n", err, url)
+ }
+ }
+
+ // Block until server exits
+ if err := <-errCh; err != nil {
+ return fmt.Errorf("server error: %w", err)
+ }
+ return nil
+}
+
// openStoreForGraph opens a multi-store for graph visualization.
func openStoreForGraph(projectRoot string) (store.GraphStore, error) {
gs, err := store.NewMultiGraphStore(projectRoot)
diff --git a/internal/spreading/engine.go b/internal/spreading/engine.go
index 22b9025..12929bc 100644
--- a/internal/spreading/engine.go
+++ b/internal/spreading/engine.go
@@ -94,6 +94,151 @@ func NewEngine(s store.GraphStore, config Config) *Engine {
}
}
+// StepSnapshot captures the activation state at a single point during propagation.
+type StepSnapshot struct {
+ // Step is the propagation step number. 0 = initial seed state,
+ // 1-N = after each propagation step, final = post-inhibition/sigmoid.
+ Step int `json:"step"`
+
+ // Activation maps nodeID to activation level at this step.
+ Activation map[string]float64 `json:"activation"`
+
+ // Final is true for the last snapshot (post-inhibition + sigmoid applied).
+ Final bool `json:"final"`
+}
+
+// propagateStep performs one step of spreading activation.
+// It reads from activation and writes to newActivation (synchronous update).
+// If distance and seedSource are non-nil, it also tracks shortest paths.
+func (e *Engine) propagateStep(ctx context.Context, activation, newActivation map[string]float64,
+ distance map[string]int, seedSource map[string]string,
+ allTags map[string][]string, affinityEnabled bool) error {
+
+ for nodeID, nodeAct := range activation {
+ if nodeAct < e.config.MinActivation {
+ continue
+ }
+
+ edges, err := e.store.GetEdges(ctx, nodeID, store.DirectionBoth, "")
+ if err != nil {
+ return fmt.Errorf("spreading activation: get edges for %s: %w", nodeID, err)
+ }
+
+ // Append virtual affinity edges from shared tags.
+ if affinityEnabled && allTags != nil {
+ if nodeTags, ok := allTags[nodeID]; ok && len(nodeTags) > 0 {
+ edges = append(edges, virtualAffinityEdges(nodeID, nodeTags, allTags, *e.config.Affinity)...)
+ }
+ }
+
+ if len(edges) == 0 {
+ continue
+ }
+
+ outDegree := float64(len(edges))
+
+ for _, edge := range edges {
+ neighbor := neighborID(nodeID, edge)
+
+ effectiveWeight := ranking.EdgeDecay(edge.Weight, edgeLastActivated(edge), e.config.TemporalDecayRate)
+
+ energy := nodeAct * e.config.SpreadFactor * effectiveWeight / outDegree
+ energy *= e.config.DecayFactor
+
+ if edge.Kind == "conflicts" {
+ // Conflict edges inhibit: subtract energy from neighbor.
+ newActivation[neighbor] -= energy
+ if newActivation[neighbor] < 0 {
+ newActivation[neighbor] = 0
+ }
+ } else {
+ // Normal edges spread: use max to prevent runaway activation.
+ if energy > newActivation[neighbor] {
+ newActivation[neighbor] = energy
+ }
+ }
+
+ // Track distance and seed source via the shortest path.
+ if distance != nil {
+ newDist := distance[nodeID] + 1
+ if existingDist, exists := distance[neighbor]; !exists || newDist < existingDist {
+ distance[neighbor] = newDist
+ seedSource[neighbor] = seedSource[nodeID]
+ }
+ }
+ }
+ }
+ return nil
+}
+
+// postProcess applies inhibition, sigmoid, and MinActivation filtering to an activation map.
+func (e *Engine) postProcess(activation map[string]float64) map[string]float64 {
+ if e.config.Inhibition != nil {
+ activation = ApplyInhibition(activation, *e.config.Inhibition)
+ }
+ for id, act := range activation {
+ activation[id] = sigmoid(act)
+ }
+ for id, act := range activation {
+ if act < e.config.MinActivation {
+ delete(activation, id)
+ }
+ }
+ return activation
+}
+
+// ActivateWithSteps performs spreading activation and returns per-step snapshots.
+// It returns MaxSteps+2 snapshots: initial seed state, one after each propagation
+// step, and a final post-processed snapshot with inhibition and sigmoid applied.
+func (e *Engine) ActivateWithSteps(ctx context.Context, seeds []Seed) ([]StepSnapshot, error) {
+ if len(seeds) == 0 {
+ return []StepSnapshot{}, nil
+ }
+
+ activation := make(map[string]float64)
+ for _, s := range seeds {
+ activation[s.BehaviorID] = s.Activation
+ }
+
+ // Capture initial seed state (step 0).
+ snapshots := make([]StepSnapshot, 0, e.config.MaxSteps+2)
+ snapshots = append(snapshots, StepSnapshot{
+ Step: 0,
+ Activation: copyActivation(activation),
+ })
+
+ var allTags map[string][]string
+ affinityEnabled := e.config.Affinity != nil && e.config.Affinity.Enabled && e.config.TagProvider != nil
+ if affinityEnabled {
+ allTags = e.config.TagProvider.GetAllBehaviorTags(ctx)
+ }
+
+ for step := 0; step < e.config.MaxSteps; step++ {
+ newActivation := copyActivation(activation)
+
+ if err := e.propagateStep(ctx, activation, newActivation, nil, nil, allTags, affinityEnabled); err != nil {
+ return nil, err
+ }
+
+ activation = newActivation
+ snapshots = append(snapshots, StepSnapshot{
+ Step: step + 1,
+ Activation: copyActivation(activation),
+ })
+ }
+
+ // Final snapshot: apply inhibition + sigmoid.
+ finalActivation := e.postProcess(copyActivation(activation))
+
+ snapshots = append(snapshots, StepSnapshot{
+ Step: e.config.MaxSteps + 1,
+ Activation: finalActivation,
+ Final: true,
+ })
+
+ return snapshots, nil
+}
+
// Activate performs spreading activation from the given seeds.
// It returns all behaviors with activation above MinActivation,
// sorted by activation descending.
@@ -125,85 +270,21 @@ func (e *Engine) Activate(ctx context.Context, seeds []Seed) ([]Result, error) {
// Create a snapshot of current activations. New activations are
// written into a fresh map so that updates within a single step
// do not affect each other (synchronous update).
- newActivation := make(map[string]float64, len(activation))
- for id, act := range activation {
- newActivation[id] = act
- }
-
- // Iterate over every node that currently has activation above
- // the threshold.
- for nodeID, nodeAct := range activation {
- if nodeAct < e.config.MinActivation {
- continue
- }
-
- edges, err := e.store.GetEdges(ctx, nodeID, store.DirectionBoth, "")
- if err != nil {
- return nil, fmt.Errorf("spreading activation: get edges for %s: %w", nodeID, err)
- }
-
- // Append virtual affinity edges from shared tags.
- if affinityEnabled && allTags != nil {
- if nodeTags, ok := allTags[nodeID]; ok && len(nodeTags) > 0 {
- edges = append(edges, virtualAffinityEdges(nodeID, nodeTags, allTags, *e.config.Affinity)...)
- }
- }
+ newActivation := copyActivation(activation)
- if len(edges) == 0 {
- continue
- }
-
- outDegree := float64(len(edges))
-
- for _, edge := range edges {
- neighbor := neighborID(nodeID, edge)
-
- effectiveWeight := ranking.EdgeDecay(edge.Weight, edgeLastActivated(edge), e.config.TemporalDecayRate)
-
- energy := nodeAct * e.config.SpreadFactor * effectiveWeight / outDegree
- energy *= e.config.DecayFactor
-
- if edge.Kind == "conflicts" {
- // Conflict edges inhibit: subtract energy from neighbor.
- newActivation[neighbor] -= energy
- if newActivation[neighbor] < 0 {
- newActivation[neighbor] = 0
- }
- } else {
- // Normal edges spread: use max to prevent runaway activation.
- if energy > newActivation[neighbor] {
- newActivation[neighbor] = energy
- }
- }
-
- // Track distance and seed source via the shortest path.
- newDist := distance[nodeID] + 1
- if existingDist, exists := distance[neighbor]; !exists || newDist < existingDist {
- distance[neighbor] = newDist
- seedSource[neighbor] = seedSource[nodeID]
- }
- }
+ if err := e.propagateStep(ctx, activation, newActivation, distance, seedSource, allTags, affinityEnabled); err != nil {
+ return nil, err
}
activation = newActivation
}
- // Step 3: Lateral inhibition — winners suppress losers.
- if e.config.Inhibition != nil {
- activation = ApplyInhibition(activation, *e.config.Inhibition)
- }
-
- // Step 4: Sigmoid squashing — centered at 0.3.
- for id, act := range activation {
- activation[id] = sigmoid(act)
- }
+ // Step 3: Post-process (inhibition + sigmoid + filter).
+ activation = e.postProcess(activation)
- // Step 5: Filter by MinActivation and build results.
+ // Step 4: Build results.
results := make([]Result, 0, len(activation))
for id, act := range activation {
- if act < e.config.MinActivation {
- continue
- }
results = append(results, Result{
BehaviorID: id,
Activation: act,
@@ -212,7 +293,7 @@ func (e *Engine) Activate(ctx context.Context, seeds []Seed) ([]Result, error) {
})
}
- // Step 6: Sort by activation descending.
+ // Step 5: Sort by activation descending.
sort.Slice(results, func(i, j int) bool {
return results[i].Activation > results[j].Activation
})
@@ -220,6 +301,15 @@ func (e *Engine) Activate(ctx context.Context, seeds []Seed) ([]Result, error) {
return results, nil
}
+// copyActivation returns an independent copy of an activation map.
+func copyActivation(m map[string]float64) map[string]float64 {
+ c := make(map[string]float64, len(m))
+ for k, v := range m {
+ c[k] = v
+ }
+ return c
+}
+
// sigmoid applies a sigmoid function centered at 0.3 to map raw activation
// into a sharper [0, 1] range. Values below 0.3 are suppressed toward 0;
// values above 0.3 are amplified toward 1.
diff --git a/internal/spreading/engine_test.go b/internal/spreading/engine_test.go
index d8bf923..9aa243a 100644
--- a/internal/spreading/engine_test.go
+++ b/internal/spreading/engine_test.go
@@ -737,3 +737,145 @@ func TestEngine_ConflictEdgeInhibition(t *testing.T) {
}
})
}
+
+// --- ActivateWithSteps tests ---
+
+func TestActivateWithSteps_LinearChain(t *testing.T) {
+ // A -> B -> C with MaxSteps=3
+ // Step 0 (initial): only A has activation
+ // Step 1: B gets activation from A
+ // Step 2: C gets activation from B
+ // Step 3: further propagation
+ // Final: post-inhibition + sigmoid
+ s := store.NewInMemoryGraphStore()
+ addNode(t, s, "A")
+ addNode(t, s, "B")
+ addNode(t, s, "C")
+
+ now := time.Now()
+ addEdge(t, s, "A", "B", "requires", 1.0, timePtr(now))
+ addEdge(t, s, "B", "C", "requires", 1.0, timePtr(now))
+
+ cfg := DefaultConfig()
+ cfg.MaxSteps = 3
+ eng := NewEngine(s, cfg)
+ seeds := []Seed{{BehaviorID: "A", Activation: 1.0, Source: "test"}}
+
+ steps, err := eng.ActivateWithSteps(context.Background(), seeds)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ // Should return MaxSteps + 1 snapshots (initial + 3 propagation steps)
+ // plus 1 final snapshot = MaxSteps + 2 total
+ wantLen := cfg.MaxSteps + 2
+ if len(steps) != wantLen {
+ t.Fatalf("expected %d snapshots, got %d", wantLen, len(steps))
+ }
+
+ // Step 0 (initial seed): only A is active
+ if steps[0].Step != 0 {
+ t.Errorf("step 0: expected Step=0, got %d", steps[0].Step)
+ }
+ if steps[0].Final {
+ t.Error("step 0: should not be final")
+ }
+ if act, ok := steps[0].Activation["A"]; !ok || act != 1.0 {
+ t.Errorf("step 0: expected A=1.0, got %v", steps[0].Activation["A"])
+ }
+ if _, ok := steps[0].Activation["B"]; ok {
+ t.Error("step 0: B should not have activation yet")
+ }
+
+ // Step 1: B should now have activation
+ if steps[1].Step != 1 {
+ t.Errorf("step 1: expected Step=1, got %d", steps[1].Step)
+ }
+ if _, ok := steps[1].Activation["B"]; !ok {
+ t.Error("step 1: expected B to have activation")
+ }
+
+ // Step 2: C should now have activation
+ if _, ok := steps[2].Activation["C"]; !ok {
+ t.Error("step 2: expected C to have activation")
+ }
+
+ // Final snapshot should be marked Final
+ last := steps[len(steps)-1]
+ if !last.Final {
+ t.Error("last snapshot should be marked Final")
+ }
+}
+
+func TestActivateWithSteps_SnapshotCopiesAreIndependent(t *testing.T) {
+ // Verify that mutating one snapshot's activation map doesn't affect others
+ s := store.NewInMemoryGraphStore()
+ addNode(t, s, "A")
+ addNode(t, s, "B")
+
+ now := time.Now()
+ addEdge(t, s, "A", "B", "requires", 1.0, timePtr(now))
+
+ eng := NewEngine(s, DefaultConfig())
+ seeds := []Seed{{BehaviorID: "A", Activation: 1.0, Source: "test"}}
+
+ steps, err := eng.ActivateWithSteps(context.Background(), seeds)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if len(steps) < 2 {
+ t.Fatalf("expected at least 2 snapshots, got %d", len(steps))
+ }
+
+ // Mutate step 0's activation map
+ origA := steps[1].Activation["A"]
+ steps[0].Activation["A"] = 999.0
+
+ // Step 1 should be unaffected
+ if steps[1].Activation["A"] != origA {
+ t.Errorf("mutation leaked between snapshots: step 1 A changed from %f to %f",
+ origA, steps[1].Activation["A"])
+ }
+}
+
+func TestActivateWithSteps_FinalSnapshotHasSigmoid(t *testing.T) {
+ // Final snapshot should have sigmoid applied (values mapped to [0,1] range)
+ s := store.NewInMemoryGraphStore()
+ addNode(t, s, "A")
+
+ eng := NewEngine(s, DefaultConfig())
+ seeds := []Seed{{BehaviorID: "A", Activation: 1.0, Source: "test"}}
+
+ steps, err := eng.ActivateWithSteps(context.Background(), seeds)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ last := steps[len(steps)-1]
+ if !last.Final {
+ t.Fatal("last snapshot should be Final")
+ }
+
+ // Seed with activation 1.0 -> after sigmoid should be very close to 1.0
+ actA := last.Activation["A"]
+ if actA < 0.99 {
+ t.Errorf("expected final A activation near 1.0 (sigmoid of 1.0), got %f", actA)
+ }
+}
+
+func TestActivateWithSteps_EmptySeeds(t *testing.T) {
+ s := store.NewInMemoryGraphStore()
+ eng := NewEngine(s, DefaultConfig())
+
+ steps, err := eng.ActivateWithSteps(context.Background(), nil)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(steps) != 0 {
+ t.Errorf("expected empty steps for nil seeds, got %d", len(steps))
+ }
+ if steps == nil {
+ t.Error("expected non-nil empty slice, got nil")
+ }
+}
diff --git a/internal/visualization/dot.go b/internal/visualization/dot.go
index 433976e..deb694f 100644
--- a/internal/visualization/dot.go
+++ b/internal/visualization/dot.go
@@ -314,29 +314,40 @@ func RenderEnrichedJSON(ctx context.Context, gs store.GraphStore, enrichment *En
type htmlTemplateData struct {
ForceGraphSrc template.URL
GraphJSON template.JS
+ // APIBaseURL is the base URL for the activation API (e.g., "http://localhost:PORT").
+ // When empty, electric mode is disabled and click falls back to focus mode.
+ APIBaseURL string
}
// RenderHTML produces a self-contained HTML file with an interactive force-directed graph.
+// Electric mode is disabled (no API base URL) — click behavior uses focus mode.
func RenderHTML(ctx context.Context, gs store.GraphStore, enrichment *EnrichmentData) ([]byte, error) {
- // Get enriched graph data
+ return renderHTMLInternal(ctx, gs, enrichment, "")
+}
+
+// RenderHTMLForServer produces an HTML file configured for server mode with electric activation.
+// The apiBaseURL is embedded so JavaScript can fetch activation data from the Go server.
+func RenderHTMLForServer(ctx context.Context, gs store.GraphStore, enrichment *EnrichmentData, apiBaseURL string) ([]byte, error) {
+ return renderHTMLInternal(ctx, gs, enrichment, apiBaseURL)
+}
+
+// renderHTMLInternal is the shared implementation for RenderHTML and RenderHTMLForServer.
+func renderHTMLInternal(ctx context.Context, gs store.GraphStore, enrichment *EnrichmentData, apiBaseURL string) ([]byte, error) {
graphData, err := RenderEnrichedJSON(ctx, gs, enrichment)
if err != nil {
return nil, fmt.Errorf("render enriched JSON: %w", err)
}
- // Marshal graph data to JSON for embedding
graphJSON, err := json.Marshal(graphData)
if err != nil {
return nil, fmt.Errorf("marshal graph data: %w", err)
}
- // Load force-graph library
jsBytes, err := assets.ReadFile("assets/force-graph.min.js")
if err != nil {
return nil, fmt.Errorf("read force-graph.min.js: %w", err)
}
- // Load and parse HTML template
tmplBytes, err := templates.ReadFile("templates/graph.html.tmpl")
if err != nil {
return nil, fmt.Errorf("read HTML template: %w", err)
@@ -359,6 +370,9 @@ func RenderHTML(ctx context.Context, gs store.GraphStore, enrichment *Enrichment
ForceGraphSrc: template.URL("data:text/javascript;base64," + base64.StdEncoding.EncodeToString(jsBytes)), // #nosec G203
// GraphJSON: pre-sanitized via json.HTMLEscape — breakout impossible.
GraphJSON: template.JS(escaped.String()), // #nosec G203
+ // APIBaseURL: constructed from localhost:PORT in server mode, empty in static mode.
+ // html/template JS-escapes this in the