session+skills: fix sqlite lock contention; add /save-discoveries builtin

ober

a7320ba6572253479bc2940989db1b37d9c50ed1

diff --git a/build-binary.ss b/build-binary.ss
index e7bc659..96a7f2f 100644
--- a/build-binary.ss
+++ b/build-binary.ss
@@ -109,6 +109,8 @@
     "lib/jcode/core/agent"
     "lib/jcode/core/plugin"
     "lib/jcode/core/debug-repl"
+    "lib/jcode/core/skill"
+    "lib/jcode/core/builtin-skills"
     "lib/jcode/provider/provider"
     "lib/jcode/tool/registry"
     "lib/jcode/tool/file"
diff --git a/src/jcode/core/builtin-skills.ss b/src/jcode/core/builtin-skills.ss
new file mode 100644
index 0000000..790003e
--- /dev/null
+++ b/src/jcode/core/builtin-skills.ss
@@ -0,0 +1,114 @@
+;;; Built-in jcode skills.
+;;;
+;;; Skills here are compiled into the binary so /save-discoveries (and
+;;; siblings) work out of the box without needing a ~/.claude/skills tree
+;;; on disk. jcode's primary purpose is jerboa development, so the jerboa-mcp
+;;; cookbook/security/feature workflow is a first-class slash command.
+;;;
+;;; To add a new builtin skill: add an entry to *builtin-skills* with the
+;;; skill name and the full prompt text the agent should execute.
+
+(export builtin-skill
+        builtin-skill-list)
+
+(import :std/sort)
+
+(def *save-discoveries-prompt*
+  (string-append
+"Review what was learned during this session and save it in three ways.\n"
+"\n"
+"All discoveries are written to the jerboa-mcp repository at\n"
+"~/mine/jerboa-mcp via the jerboa MCP tools (jerboa_howto_add,\n"
+"jerboa_suggest_feature, jerboa_security_pattern_add). Pass NO\n"
+"cookbook_path / features_path / rules_path arguments — let them default\n"
+"to the global jerboa-mcp paths.\n"
+"\n"
+"## Step 1: Save Cookbook Recipes\n"
+"\n"
+"For each non-trivial Jerboa pattern, workaround, or API discovery made\n"
+"during this session:\n"
+"\n"
+"1. Call `jerboa_howto` with relevant keywords to check if a recipe already exists.\n"
+"2. If not found, call `jerboa_howto_add` with:\n"
+"   - `id`: kebab-case identifier\n"
+"   - `title`: human-readable title\n"
+"   - `tags`: 4-6 search keywords (module name, task, alternative phrasings)\n"
+"   - `imports`: all required imports (use `[]` if none)\n"
+"   - `code`: complete, copy-pasteable working example\n"
+"   - `notes`: gotchas, alternatives, or non-obvious details\n"
+"\n"
+"Skip trivial one-liners and project-specific business logic.\n"
+"\n"
+"## Step 2: Suggest Tooling Improvements\n"
+"\n"
+"For each workflow friction point or missing tool capability noticed\n"
+"during this session:\n"
+"\n"
+"1. Call `jerboa_list_features` with relevant keywords to check for duplicates.\n"
+"2. If a matching feature already exists, call `jerboa_vote_feature` with its `id` to upvote it.\n"
+"3. If not found, call `jerboa_suggest_feature` with:\n"
+"   - `id`: kebab-case identifier\n"
+"   - `title`: short description\n"
+"   - `description`: detailed explanation\n"
+"   - `impact`: `high` | `medium` | `low`\n"
+"   - `tags`: 3-5 search keywords\n"
+"   - `use_case`: when this would be useful\n"
+"   - `example_scenario`: concrete example of the problem\n"
+"   - `estimated_token_reduction`: e.g. \"~500 tokens per invocation\", \"eliminates 3 tool calls\"\n"
+"\n"
+"## Step 3: Add Security Patterns\n"
+"\n"
+"For each new vulnerability pattern, unsafe coding practice, or FFI misuse\n"
+"discovered during this session:\n"
+"\n"
+"1. Run `jerboa_security_scan` on relevant files to check if the pattern is already detected.\n"
+"2. If not already covered, call `jerboa_security_pattern_add` with:\n"
+"   - `id`: kebab-case identifier (e.g. \"shell-injection-format-string\")\n"
+"   - `title`: human-readable title\n"
+"   - `severity`: `critical` | `high` | `medium` | `low`\n"
+"   - `scope`: `scheme` (.ss/.sls), `c-shim` (.c/.h), or `ffi-boundary` (FFI declarations)\n"
+"   - `pattern`: regex to detect the vulnerability in source lines\n"
+"   - `message`: explanation of the vulnerability\n"
+"   - `remediation`: how to fix the issue\n"
+"   - Optional `tags`: search keywords for discoverability\n"
+"   - Optional `related_recipe`: cookbook recipe id with the safe alternative\n"
+"\n"
+"Skip patterns that are too project-specific to generalize.\n"
+"\n"
+"## What to Look For\n"
+"\n"
+"Recipes to save:\n"
+"- Correct imports/function names that required experimentation\n"
+"- Workarounds for Jerboa quirks or undocumented behavior\n"
+"- Multi-step patterns combining standard library functions\n"
+"- Arity or signature discoveries that weren't obvious\n"
+"- Module path conventions (e.g. `(std text json)` not `:std/text/json`)\n"
+"\n"
+"Features to suggest or vote for:\n"
+"- Multiple sequential tool calls that could be a single tool\n"
+"- Missing tool parameters or modes\n"
+"- Workflows that forced fallback to `jerboa_eval` or bash\n"
+"- Repeated cross-session patterns that could be automated\n"
+"- If the friction matches an existing feature suggestion, vote for it instead of creating a duplicate\n"
+"\n"
+"Security patterns to add:\n"
+"- New vulnerability patterns found during code review or debugging\n"
+"- Unsafe FFI calling conventions (missing null checks, type mismatches with `foreign-procedure`)\n"
+"- Shell injection vectors via string concatenation or `open-process-ports`\n"
+"- Resource leaks (ports, fds, mutexes) without `unwind-protect` / `dynamic-wind`\n"
+"- Unsafe C shim patterns (buffer overflows, static globals, missing error checks)\n"
+"\n"
+"Report what was saved, suggested, voted for, and security patterns added when done.\n"))
+
+(def *builtin-skills*
+  (list
+    (cons "save-discoveries" *save-discoveries-prompt*)))
+
+(def (builtin-skill name)
+  "Return the prompt text for the builtin skill NAME, or #f if not builtin."
+  (let ((entry (assoc name *builtin-skills*)))
+    (and entry (cdr entry))))
+
+(def (builtin-skill-list)
+  "Return the names of all builtin skills, sorted."
+  (list-sort string<? (map car *builtin-skills*)))
diff --git a/src/jcode/core/session.ss b/src/jcode/core/session.ss
index d70dc2d..85836fb 100644
--- a/src/jcode/core/session.ss
+++ b/src/jcode/core/session.ss
@@ -27,10 +27,19 @@
   (path-join (jcode-home) "sessions.db"))
 
 (def (open-db)
-  (sqlite-open (db-path)))
+  ;; Each connection gets a 5s busy timeout so concurrent green-thread writers
+  ;; (parallel tool calls + streaming response) wait for the lock instead of
+  ;; failing immediately with "database is locked".
+  (let ((db (sqlite-open (db-path))))
+    (sqlite-exec db "PRAGMA busy_timeout = 5000")
+    db))
 
 (def (session-init-db)
   (let ((db (open-db)))
+    ;; WAL allows concurrent readers + one writer, much friendlier to the
+    ;; agent's green-thread mix than the default rollback journal. Persists
+    ;; in the DB file, so this only needs to run once.
+    (sqlite-exec db "PRAGMA journal_mode = WAL")
     (sqlite-exec db
       "CREATE TABLE IF NOT EXISTS sessions (
          id TEXT PRIMARY KEY,
diff --git a/src/jcode/core/skill.ss b/src/jcode/core/skill.ss
new file mode 100644
index 0000000..d8c0a9b
--- /dev/null
+++ b/src/jcode/core/skill.ss
@@ -0,0 +1,87 @@
+;;; jcode skill loader — reads Claude-Code-compatible SKILL.md files so users
+;;; can run /<skill-name> in jcode the same way they would in Claude Code.
+;;;
+;;; Search paths (first hit wins):
+;;;   <cwd>/.claude/skills/<name>/SKILL.md     project-local
+;;;   <cwd>/.jcode/skills/<name>/SKILL.md      project-local jcode-only
+;;;   ~/.claude/skills/<name>/SKILL.md         global Claude Code skills
+;;;   ~/.jcode/skills/<name>/SKILL.md          global jcode-only skills
+;;;
+;;; A skill is just a markdown file with optional YAML frontmatter. The
+;;; loader strips the frontmatter and returns the body, which the UI layer
+;;; injects into the agent loop as if the user typed it.
+
+(export skill-load
+        skill-list
+        skill-search-paths)
+
+(import :std/os/path
+        :std/misc/string
+        ./config)
+
+(def (skill-search-paths)
+  (let ((home (getenv "HOME"))
+        (cwd  (current-directory)))
+    (list
+      (path-join cwd  ".claude" "skills")
+      (path-join cwd  ".jcode"  "skills")
+      (path-join home ".claude" "skills")
+      (path-join home ".jcode"  "skills"))))
+
+(def (skill-load name)
+  "Return the body of <name>/SKILL.md (frontmatter stripped) or #f if not found."
+  (let loop ((dirs (skill-search-paths)))
+    (cond
+      ((null? dirs) #f)
+      (else
+        (let ((path (path-join (car dirs) name "SKILL.md")))
+          (if (file-exists? path)
+            (strip-frontmatter (read-file path))
+            (loop (cdr dirs))))))))
+
+(def (skill-list)
+  "Return a sorted, de-duplicated list of available skill names across all search paths."
+  (let ((seen (make-hash-table)))
+    (for-each
+      (lambda (dir)
+        (when (file-exists? dir)
+          (for-each
+            (lambda (entry)
+              (let ((skill-md (path-join dir entry "SKILL.md")))
+                (when (and (not (hash-ref seen entry #f))
+                           (file-exists? skill-md))
+                  (hash-put! seen entry #t))))
+            (safe-directory-list dir))))
+      (skill-search-paths))
+    (list-sort string<? (hash-keys seen))))
+
+(def (read-file path)
+  (call-with-input-file path
+    (lambda (p)
+      (let loop ((acc '()))
+        (let ((line (get-line p)))
+          (if (eof-object? line)
+            (string-join (reverse acc) "\n")
+            (loop (cons line acc))))))))
+
+(def (strip-frontmatter text)
+  "If TEXT begins with a `---` line, drop everything up to and including the
+   matching closing `---`. Otherwise return TEXT unchanged."
+  (let ((lines (string-split text #\newline)))
+    (cond
+      ((or (null? lines) (not (string=? (car lines) "---")))
+       text)
+      (else
+        (let loop ((rest (cdr lines)))
+          (cond
+            ((null? rest) text)  ; unterminated frontmatter: bail, keep original
+            ((string=? (car rest) "---")
+             (string-join (cdr rest) "\n"))
+            (else (loop (cdr rest)))))))))
+
+(def (safe-directory-list dir)
+  "List entries in DIR (no . or ..). Returns '() if DIR is unreadable."
+  (guard (e [#t '()])
+    (filter (lambda (name)
+              (not (or (string=? name ".") (string=? name ".."))))
+            (directory-list dir))))
diff --git a/src/jcode/ui/cli.ss b/src/jcode/ui/cli.ss
index a849932..8e00e7f 100644
--- a/src/jcode/ui/cli.ss
+++ b/src/jcode/ui/cli.ss
@@ -11,6 +11,8 @@
         :jcode/core/message
         :jcode/core/agent
         :jcode/core/debug-repl
+        :jcode/core/skill
+        :jcode/core/builtin-skills
         :jcode/tool/registry
         :jcode/tool/file
         :jcode/tool/bash
@@ -289,8 +291,35 @@ EXAMPLES:
            (for-each (lambda (p) (printf "  ~a~n" p)) plugins))))
       ((or (equal? cmd "quit") (equal? cmd "exit"))
        (printf "Goodbye!~n") (exit 0))
+      ((equal? cmd "skills")
+       (let* ((builtins (builtin-skill-list))
+              (file-skills (filter (lambda (n) (not (member n builtins)))
+                                   (skill-list))))
+         (printf "Built-in skills:~n")
+         (if (null? builtins)
+           (printf "  (none)~n")
+           (for-each (lambda (n) (printf "  ~a~n" n)) builtins))
+         (printf "~nUser skills:~n")
+         (if (null? file-skills)
+           (printf "  (none)~n")
+           (for-each (lambda (n) (printf "  ~a~n" n)) file-skills))))
       (#t
-       (printf "Unknown command: /~a~n" cmd)))))
+       ;; Slash dispatch: builtins win over file-based skills.
+       (let* ((space-pos (string-index cmd #\space))
+              (skill-name (if space-pos (substring cmd 0 space-pos) cmd))
+              (skill-args (if space-pos
+                            (string-trim (substring cmd (+ space-pos 1) (string-length cmd)))
+                            ""))
+              (body (or (builtin-skill skill-name)
+                        (skill-load skill-name))))
+         (cond
+           (body
+            (let ((prompt (if (string=? skill-args "")
+                            body
+                            (string-append body "\n\nArguments: " skill-args))))
+              (agent-run session-id prompt)))
+           (else
+            (printf "Unknown command: /~a~n" cmd))))))))
 
 ;; --- ESC interrupt support ---
 
diff --git a/src/jcode/ui/tui.ss b/src/jcode/ui/tui.ss
index 796e559..feb85f7 100644
--- a/src/jcode/ui/tui.ss
+++ b/src/jcode/ui/tui.ss
@@ -20,6 +20,8 @@
         :jcode/core/models
         :jcode/core/agent
         :jcode/core/debug-repl
+        :jcode/core/skill
+        :jcode/core/builtin-skills
         :jcode/provider/provider
         :jcode/core/session
         :jcode/core/log
@@ -548,8 +550,38 @@
                  (string-append
                    (format "Search: \"~a\" (~a matches)\n" term (length results))
                    (string-join lines "\n"))))))))
+      ((equal? cmd "skills")
+       (let* ((builtins (builtin-skill-list))
+              (file-skills (filter (lambda (n) (not (member n builtins)))
+                                   (skill-list))))
+         (add-message! state
+           (msg-block-system
+             (string-append
+               "Built-in skills:\n  "
+               (if (null? builtins) "(none)" (string-join builtins "\n  "))
+               "\n\nUser skills (~/.claude/skills, ~/.jcode/skills, .claude/skills, .jcode/skills):\n  "
+               (if (null? file-skills) "(none)" (string-join file-skills "\n  ")))))))
       (#t
-       (add-message! state (msg-block-system (format "Unknown command: /~a" cmd)))))))
+       ;; Slash dispatch: /<name> [args] runs a skill. Builtins win over
+       ;; file-based skills (so jcode's jerboa-mcp workflow is always
+       ;; available even without ~/.claude/skills on disk).
+       (let* ((space-pos (string-index cmd #\space))
+              (skill-name (if space-pos (substring cmd 0 space-pos) cmd))
+              (skill-args (if space-pos
+                            (string-trim (substring cmd (+ space-pos 1) (string-length cmd)))
+                            ""))
+              (body (or (builtin-skill skill-name)
+                        (skill-load skill-name))))
+         (cond
+           (body
+            (let ((prompt (if (string=? skill-args "")
+                            body
+                            (string-append body "\n\nArguments: " skill-args))))
+              (add-message! state (msg-block-user (format "/~a" cmd)))
+              (app-state-scroll-offset-set! state 0)
+              (run-agent! state prompt)))
+           (else
+            (add-message! state (msg-block-system (format "Unknown command: /~a" cmd))))))))))
 
 ;; ---- Provider / model switching ----