diff --git a/.claude/hooks/log-request.sh b/.claude/hooks/log-request.sh
new file mode 100755
index 0000000..477d49e
--- /dev/null
+++ b/.claude/hooks/log-request.sh
@@ -0,0 +1,54 @@
+#!/usr/bin/env bash
+# Claude Code — UserPromptSubmit hook
+#
+# Logs each new user prompt to .claude/request-log.jsonl (git-tracked).
+# Runs async so it never blocks Claude's response.
+#
+# Input (stdin): JSON with fields: session_id, cwd, prompt, is_continuation
+
+set -euo pipefail
+
+INPUT=$(cat)
+
+PROMPT=$(echo "$INPUT" | jq -r '.prompt // ""')
+SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // ""')
+CWD=$(echo "$INPUT" | jq -r '.cwd // "$PWD"')
+IS_CONTINUATION=$(echo "$INPUT" | jq -r '.is_continuation // false')
+
+LOG_FILE="${CWD}/.claude/request-log.jsonl"
+
+# Skip: continuation turns, trivially short prompts, no log file dir
+if [ "$IS_CONTINUATION" = "true" ]; then
+ exit 0
+fi
+
+if [ "${#PROMPT}" -lt 5 ]; then
+ exit 0
+fi
+
+if [ ! -d "${CWD}/.claude" ]; then
+ exit 0
+fi
+
+TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
+REQ_ID="req_$(date +%Y%m%d_%H%M%S)_$(LC_ALL=C tr -dc 'a-z0-9' < /dev/urandom | head -c 5)"
+BRANCH=$(git -C "$CWD" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
+
+jq -n \
+ --arg id "$REQ_ID" \
+ --arg ts "$TIMESTAMP" \
+ --arg prompt "$PROMPT" \
+ --arg session "$SESSION_ID" \
+ --arg branch "$BRANCH" \
+ '{
+ id: $id,
+ timestamp: $ts,
+ prompt: $prompt,
+ session: $session,
+ branch: $branch,
+ pr_number: null,
+ pr_url: null,
+ status: "pending"
+ }' >> "$LOG_FILE"
+
+exit 0
diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh
new file mode 100755
index 0000000..b1d220b
--- /dev/null
+++ b/.claude/hooks/session-start.sh
@@ -0,0 +1,39 @@
+#!/usr/bin/env bash
+# Claude Code — SessionStart hook
+#
+# Injects recent request history into Claude's context at session start.
+# stdout is appended to Claude's context window.
+#
+# Input (stdin): JSON with fields: session_id, cwd, session_start_source
+
+set -euo pipefail
+
+INPUT=$(cat)
+CWD=$(echo "$INPUT" | jq -r '.cwd // "$PWD"')
+
+LOG_FILE="${CWD}/.claude/request-log.jsonl"
+
+if [ ! -f "$LOG_FILE" ] || [ ! -s "$LOG_FILE" ]; then
+ exit 0
+fi
+
+TOTAL=$(wc -l < "$LOG_FILE" | tr -d ' ')
+OPEN=$(jq -r 'select(.status == "open")' "$LOG_FILE" 2>/dev/null | jq -s 'length')
+
+echo "=== Forgejo Workflow: Session Context ==="
+echo "Request log: .claude/request-log.jsonl ($TOTAL total, $OPEN open PRs)"
+echo ""
+
+# Last 5 entries
+echo "Recent requests:"
+tail -5 "$LOG_FILE" | jq -r '
+ " [\(.timestamp[0:16])] \(.prompt | gsub("\n"; " ") | .[0:72])" +
+ "\n branch: \(.branch)" +
+ (if .pr_number then " | PR #\(.pr_number) (\(.status))" else " | no PR yet" end)
+' 2>/dev/null || true
+
+echo ""
+echo "Run '.claude/scripts/show-history.sh' for full history."
+echo "========================================="
+
+exit 0
diff --git a/.claude/request-log.jsonl b/.claude/request-log.jsonl
new file mode 100644
index 0000000..e69de29
diff --git a/.claude/scripts/create-pr.sh b/.claude/scripts/create-pr.sh
new file mode 100755
index 0000000..0de184e
--- /dev/null
+++ b/.claude/scripts/create-pr.sh
@@ -0,0 +1,100 @@
+#!/usr/bin/env bash
+# Create a PR on Forgejo via tea and record it in the request log.
+#
+# Usage:
+# .claude/scripts/create-pr.sh
[body] [base]
+#
+# Environment:
+# FORGEJO_BASE_BRANCH — override default base branch (default: main)
+
+set -euo pipefail
+
+TITLE="${1:-}"
+BODY="${2:-}"
+BASE="${3:-${FORGEJO_BASE_BRANCH:-main}}"
+LOG_FILE=".claude/request-log.jsonl"
+
+if [ -z "$TITLE" ]; then
+ echo "Usage: create-pr.sh [body] [base-branch]" >&2
+ exit 1
+fi
+
+if ! command -v tea &>/dev/null; then
+ cat >&2 <<'MSG'
+Error: 'tea' CLI not found.
+
+Install options:
+ nix develop path:~/.claude/workflows/forgejo # recommended
+ brew install gitea/tap/tea # macOS
+ go install gitea.com/gitea/tea@latest # Go
+
+Then configure: tea login add
+MSG
+ exit 1
+fi
+
+CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
+
+if [ "$CURRENT_BRANCH" = "$BASE" ]; then
+ echo "Error: currently on base branch '$BASE'. Create a feature branch first." >&2
+ exit 1
+fi
+
+echo "Creating PR: '$TITLE'"
+echo " head: $CURRENT_BRANCH → base: $BASE"
+echo ""
+
+# Build tea args
+TEA_ARGS=(pr create
+ --title "$TITLE"
+ --head "$CURRENT_BRANCH"
+ --base "$BASE"
+)
+
+if [ -n "$BODY" ]; then
+ TEA_ARGS+=(--description "$BODY")
+fi
+
+PR_OUTPUT=$(tea "${TEA_ARGS[@]}" 2>&1)
+echo "$PR_OUTPUT"
+
+# Extract URL — tea prints something like: "Created pull request #42"
+# and/or a URL line
+PR_URL=$(echo "$PR_OUTPUT" | grep -oE 'https?://[^[:space:]]+/pulls/[0-9]+' | head -1 || true)
+PR_NUMBER=$(echo "$PR_URL" | grep -oE '[0-9]+$' || true)
+
+if [ -z "$PR_NUMBER" ]; then
+ # Fallback: parse "Created pull request #N"
+ PR_NUMBER=$(echo "$PR_OUTPUT" | grep -oE '#[0-9]+' | grep -oE '[0-9]+' | head -1 || true)
+fi
+
+# Update most recent pending entry in the log
+if [ -n "$PR_NUMBER" ] && [ -f "$LOG_FILE" ] && [ -s "$LOG_FILE" ]; then
+ TMPFILE=$(mktemp)
+
+ # Find and update the last entry that matches current branch & is pending
+ awk -v branch="$CURRENT_BRANCH" -v pr_num="$PR_NUMBER" -v pr_url="$PR_URL" '
+ BEGIN { updated = 0 }
+ {
+ lines[NR] = $0
+ }
+ END {
+ # Walk backwards to find last matching entry
+ for (i = NR; i >= 1; i--) {
+ if (!updated && lines[i] ~ ("\"branch\":\"" branch "\"") && lines[i] ~ "\"status\":\"pending\"") {
+ # Use a simple substitution — jq would be cleaner but we avoid a subshell per line
+ cmd = "echo " lines[i] " | jq --arg pn \"" pr_num "\" --arg pu \"" pr_url "\" \".pr_number = ($pn | tonumber) | .pr_url = $pu | .status = \\\"open\\\"\""
+ cmd | getline updated_line
+ close(cmd)
+ lines[i] = updated_line
+ updated = 1
+ }
+ }
+ for (i = 1; i <= NR; i++) print lines[i]
+ }
+ ' "$LOG_FILE" > "$TMPFILE"
+
+ mv "$TMPFILE" "$LOG_FILE"
+ echo ""
+ echo "Request log updated with PR #$PR_NUMBER"
+fi
diff --git a/.claude/scripts/show-history.sh b/.claude/scripts/show-history.sh
new file mode 100755
index 0000000..5191d63
--- /dev/null
+++ b/.claude/scripts/show-history.sh
@@ -0,0 +1,84 @@
+#!/usr/bin/env bash
+# Display the Claude request history for this project.
+#
+# Usage:
+# .claude/scripts/show-history.sh [--filter open|merged|pending] [--last N] [--json]
+
+set -euo pipefail
+
+LOG_FILE=".claude/request-log.jsonl"
+FILTER=""
+LAST=0
+JSON_MODE=false
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --filter) FILTER="$2"; shift 2 ;;
+ --last) LAST="$2"; shift 2 ;;
+ --json) JSON_MODE=true; shift ;;
+ -*) echo "Unknown flag: $1" >&2; exit 1 ;;
+ *) LOG_FILE="$1"; shift ;;
+ esac
+done
+
+if [ ! -f "$LOG_FILE" ]; then
+ echo "No request log found at $LOG_FILE"
+ exit 0
+fi
+
+if ! command -v jq &>/dev/null; then
+ echo "Error: jq not found. Run: nix develop path:~/.claude/workflows/forgejo" >&2
+ exit 1
+fi
+
+TOTAL=$(wc -l < "$LOG_FILE" | tr -d ' ')
+
+if [ "$TOTAL" -eq 0 ]; then
+ echo "No requests logged yet."
+ exit 0
+fi
+
+# Build jq filter
+JQ_SELECT='.'
+if [ -n "$FILTER" ]; then
+ JQ_SELECT="select(.status == \"$FILTER\")"
+fi
+
+CONTENT=$(jq -r "$JQ_SELECT" "$LOG_FILE" 2>/dev/null | jq -s '.')
+
+if [ "$LAST" -gt 0 ]; then
+ CONTENT=$(echo "$CONTENT" | jq ".[-${LAST}:]")
+fi
+
+if [ "$JSON_MODE" = true ]; then
+ echo "$CONTENT"
+ exit 0
+fi
+
+COUNT=$(echo "$CONTENT" | jq 'length')
+
+echo "━━━ Request History ($LOG_FILE) ━━━"
+echo "Total: $TOTAL entries | Showing: $COUNT${FILTER:+ ($FILTER)}"
+echo ""
+
+echo "$CONTENT" | jq -r '.[] |
+ "[\(.timestamp[0:16])] \(.id)",
+ " Prompt: \(.prompt | gsub("\n";" ") | .[0:80])\(if (.prompt | length) > 80 then "…" else "" end)",
+ " Branch: \(.branch)",
+ (if .pr_number then
+ " PR: #\(.pr_number) [\(.status)] \(.pr_url // "")"
+ else
+ " PR: none yet (status: \(.status))"
+ end),
+ ""
+'
+
+# Summary stats
+OPEN=$(jq -r 'select(.status == "open")' "$LOG_FILE" | jq -s 'length')
+MERGED=$(jq -r 'select(.status == "merged")' "$LOG_FILE" | jq -s 'length')
+PENDING=$(jq -r 'select(.status == "pending")' "$LOG_FILE" | jq -s 'length')
+
+echo "━━━ Summary ━━━"
+echo " Open PRs: $OPEN"
+echo " Merged: $MERGED"
+echo " No PR yet: $PENDING"
diff --git a/.claude/scripts/update-entry.sh b/.claude/scripts/update-entry.sh
new file mode 100755
index 0000000..c4ad64c
--- /dev/null
+++ b/.claude/scripts/update-entry.sh
@@ -0,0 +1,66 @@
+#!/usr/bin/env bash
+# Update a specific log entry (e.g. mark a PR as merged).
+#
+# Usage:
+# .claude/scripts/update-entry.sh --status merged
+# .claude/scripts/update-entry.sh --pr-number 42 --pr-url https://...
+
+set -euo pipefail
+
+REQ_ID="${1:-}"
+LOG_FILE=".claude/request-log.jsonl"
+NEW_STATUS=""
+NEW_PR_NUMBER=""
+NEW_PR_URL=""
+
+shift || true
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --status) NEW_STATUS="$2"; shift 2 ;;
+ --pr-number) NEW_PR_NUMBER="$2"; shift 2 ;;
+ --pr-url) NEW_PR_URL="$2"; shift 2 ;;
+ *) echo "Unknown arg: $1" >&2; exit 1 ;;
+ esac
+done
+
+if [ -z "$REQ_ID" ]; then
+ echo "Usage: update-entry.sh [--status STATUS] [--pr-number N] [--pr-url URL]" >&2
+ exit 1
+fi
+
+if [ ! -f "$LOG_FILE" ]; then
+ echo "Log file not found: $LOG_FILE" >&2
+ exit 1
+fi
+
+TMPFILE=$(mktemp)
+
+JQ_ARGS=()
+JQ_FILTER='.'
+
+if [ -n "$NEW_STATUS" ]; then
+ JQ_ARGS+=(--arg status "$NEW_STATUS")
+ JQ_FILTER="$JQ_FILTER | .status = \$status"
+fi
+
+if [ -n "$NEW_PR_NUMBER" ]; then
+ JQ_ARGS+=(--argjson pr_num "$NEW_PR_NUMBER")
+ JQ_FILTER="$JQ_FILTER | .pr_number = \$pr_num"
+fi
+
+if [ -n "$NEW_PR_URL" ]; then
+ JQ_ARGS+=(--arg pr_url "$NEW_PR_URL")
+ JQ_FILTER="$JQ_FILTER | .pr_url = \$pr_url"
+fi
+
+while IFS= read -r line; do
+ ID=$(echo "$line" | jq -r '.id // ""')
+ if [ "$ID" = "$REQ_ID" ]; then
+ echo "$line" | jq "${JQ_ARGS[@]}" "$JQ_FILTER"
+ else
+ echo "$line"
+ fi
+done < "$LOG_FILE" > "$TMPFILE"
+
+mv "$TMPFILE" "$LOG_FILE"
+echo "Updated entry $REQ_ID"
diff --git a/.claude/settings.json b/.claude/settings.json
new file mode 100644
index 0000000..5ff1966
--- /dev/null
+++ b/.claude/settings.json
@@ -0,0 +1,29 @@
+{
+ "hooks": {
+ "UserPromptSubmit": [
+ {
+ "matcher": "",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "bash \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/log-request.sh",
+ "timeout": 10,
+ "async": true
+ }
+ ]
+ }
+ ],
+ "SessionStart": [
+ {
+ "matcher": "",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "bash \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/session-start.sh",
+ "timeout": 10
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/.gitignore b/.gitignore
index 0982eca..1765c9a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,3 +9,9 @@ ios/
*.local
.DS_Store
.claude/worktrees/
+e2e/test-results/
+e2e/report/
+e2e/.features-gen/
+
+# Claude Code local settings (secrets, personal config)
+.claude/settings.local.json
diff --git a/CLAUDE.md b/CLAUDE.md
index 31bbc4e..815051c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -798,3 +798,123 @@ function getItemsInContainer(containerId: string): Item[] {
return allItems.filter(i => i.storageContainerId === containerId);
}
```
+
+---
+
+## Forgejo PR Workflow
+
+Every substantive session is tracked in `.claude/request-log.jsonl` and backed by a Forgejo PR.
+
+### When to create a branch + PR
+
+Create a branch and PR for:
+- New features (`feat/`)
+- Bug fixes (`fix/`)
+- Refactors (`refactor/`)
+- Dependency or config changes (`chore/`)
+
+Skip branching/PR for:
+- Questions, explanations, code reading
+- Trivial one-liner fixes that are best committed directly to the current branch
+
+### Branch naming
+
+```
+feat/YYYYMMDD-short-kebab-description
+fix/YYYYMMDD-short-kebab-description
+chore/YYYYMMDD-short-kebab-description
+refactor/YYYYMMDD-short-kebab-description
+```
+
+Check the current branch first:
+```bash
+git rev-parse --abbrev-ref HEAD
+```
+
+If already on a feature branch from this session, continue on it. Only create a new branch for a new distinct task.
+
+### Workflow for each task
+
+1. **Create branch** (if needed):
+ ```bash
+ git checkout -b feat/$(date +%Y%m%d)-short-description
+ ```
+
+2. **Do the work** — write code, run tests, iterate.
+
+3. **Commit** with conventional commit messages:
+ ```bash
+ git add
+ git commit -m "feat: short description"
+ ```
+
+4. **Push**:
+ ```bash
+ git push -u origin HEAD
+ ```
+
+5. **Create PR**:
+ ```bash
+ bash .claude/scripts/create-pr.sh \
+ "feat: short description" \
+ "$(cat <<'EOF'
+ ## Summary
+
+ - What was done and why
+
+ ## Test plan
+
+ - [ ] Manual test steps
+
+ 🤖 Generated with Claude Code
+ EOF
+ )"
+ ```
+
+ This also updates `.claude/request-log.jsonl` with the PR number.
+
+6. **Commit the updated request log** (as part of the PR or separately):
+ ```bash
+ git add .claude/request-log.jsonl
+ git commit -m "chore: log request #"
+ ```
+
+### Show history
+
+When the user asks for history, recent work, or what's been done:
+
+```bash
+bash .claude/scripts/show-history.sh
+bash .claude/scripts/show-history.sh --filter open
+bash .claude/scripts/show-history.sh --last 10
+```
+
+### tea CLI quick reference
+
+```bash
+tea pr list # list open PRs
+tea pr view # view a PR
+tea pr merge # merge a PR
+tea issue create # create an issue
+tea repo info # show repo details
+tea login list # show configured Forgejo instances
+```
+
+### Request log format
+
+`.claude/request-log.jsonl` — one JSON object per line, git-tracked:
+
+```json
+{
+ "id": "req_20260226_143012_a3f9b",
+ "timestamp": "2026-02-26T14:30:12Z",
+ "prompt": "Add dark mode support",
+ "session": "abc123",
+ "branch": "feat/20260226-dark-mode",
+ "pr_number": 42,
+ "pr_url": "https://forge.example.com/user/repo/pulls/42",
+ "status": "open"
+}
+```
+
+Possible statuses: `pending` (no PR yet), `open`, `merged`, `closed`.