Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions .github/skill-tests/harness.sh
Original file line number Diff line number Diff line change
Expand Up @@ -89,14 +89,29 @@ make_test_repo() {
echo "$dir"
}

# Create a bare "remote" repo and add it as origin
# Create a bare "remote" repo and add it as origin.
#
# Safety: if `git remote add` collides (because a previous `make_test_repo`
# call cd'd to a temp dir that was itself inside a real git repo, or because
# cd failed and we're still in the parent worktree), the silent failure
# would historically leave `$name` pointing at the parent repo's real
# remote. The subsequent `git push origin master` would then push test
# commits to that real remote (e.g. GitHub). We refuse to proceed in that
# case — better a loud abort than a quiet master-clobber.
add_test_remote() {
local name="${1:-origin}"
local remote_dir
remote_dir=$(mktemp -d "${TMPDIR:-/tmp}/skill-remote-XXXXXX")
_TMPDIRS+=("$remote_dir")
git init -q --bare "$remote_dir"
git remote add "$name" "$remote_dir" 2>/dev/null || true
if ! git remote add "$name" "$remote_dir" 2>/dev/null; then
local actual
actual=$(git remote get-url "$name" 2>/dev/null || echo "")
if [ "$actual" != "$remote_dir" ]; then
echo "FATAL: harness setup error — remote '$name' points at '$actual' (expected temp bare at '$remote_dir'). Refusing to push (this would clobber a real remote)." >&2
exit 1
fi
fi
git push -q "$name" HEAD:master 2>/dev/null || true
echo "$remote_dir"
}
Expand Down
258 changes: 258 additions & 0 deletions .github/skill-tests/test_session_capture.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
#!/usr/bin/env bash
source "$(dirname "$0")/harness.sh"

# Hook lives outside agents/skills, so SCRIPTS_DIR (which points at
# agents/skills) doesn't help — derive the hook path from the test file.
HOOK="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../agents/hooks" && pwd)/session-end-capture.sh"

# Build an isolated test environment:
# - fresh git repo as cwd
# - bare "remote" so origin/master exists
# - synthetic transcript JSONL
# - fake argus(1) on PATH that returns the temp vault path
# - HOME redirected to keep ~/.dots/sys writes contained
# Returns: prints VAULT path on stdout
_setup_session_env() {
# make_test_repo / add_test_remote rely on `cd` taking effect in the
# caller's shell — so they must be called WITHOUT `$()` wrapping.
# Capture the resulting cwd via pwd after the cd lands.
make_test_repo >/dev/null
add_test_remote "origin" >/dev/null
local repo
repo=$(pwd)

# SAFETY: refuse to proceed if cd-to-temp-dir didn't take effect. Without
# this guard, downstream `git push -q origin master` runs against the
# caller's real remote (`add_test_remote` silently leaves the existing
# `origin` URL when `git remote add` collides). A previous version of this
# test pushed junk commits to GitHub master before the bug was caught.
case "$repo" in
*"/skill-test-"*) ;;
*)
echo "FATAL: test setup did not cd into a temp dir (cwd=$repo). Aborting to avoid pushing to a real remote." >&2
exit 1
;;
esac
# Belt-and-suspenders: explicitly point origin at the temp bare repo
# regardless of whatever `add_test_remote` did. If `git remote set-url`
# fails (e.g. cwd is not a git repo at all), bail.
local temp_remote
temp_remote=$(git remote get-url origin 2>/dev/null)
case "$temp_remote" in
*"/skill-remote-"*) ;;
*)
echo "FATAL: origin URL is not a temp bare repo (got: $temp_remote). Aborting." >&2
exit 1
;;
esac

# Author identity is required by the hook (filters commits by author).
git config user.email "test@test.com"

# Make a session-eligible commit on master so origin/master is non-empty
# and the hook's merge-base check has a target. Push it so origin has it.
# Ensure local branch is `master` regardless of the host's `init.defaultBranch`
# so the hook's `origin/master` lookup hits.
git branch -M master 2>/dev/null
echo "seed" > seed.txt
git add seed.txt
git commit -q -m "seed commit"
git push -q origin master

# Vault dir under the temp tree so cleanup happens automatically.
local vault="$repo/.test-vault"
mkdir -p "$vault/memory"
_TEST_VAULT="$vault"

# Transcript: timestamp captured AFTER seed setup so the seed isn't picked
# up as a session commit. The 1-second sleep ensures the seed commit's
# committer date is strictly LESS than this timestamp (git's --since uses
# second-level granularity, so same-second commits are inclusive).
sleep 1
local ts
ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
local transcript="$repo/.test-transcript.jsonl"
printf '{"type":"user","timestamp":"%s","message":{"role":"user","content":"fix the bug"}}\n' "$ts" \
> "$transcript"
_TEST_TRANSCRIPT="$transcript"
_TEST_TS="$ts"

# Stub argus(1) with a tiny shell wrapper that satisfies the hook's only
# call: `argus kb status`. Place it on PATH ahead of the real argus.
local stub_dir="$repo/.test-bin"
mkdir -p "$stub_dir"
cat > "$stub_dir/argus" <<EOF
#!/usr/bin/env bash
if [ "\$1" = "kb" ] && [ "\$2" = "status" ]; then
echo "Vault : $vault"
exit 0
fi
exit 0
EOF
chmod +x "$stub_dir/argus"
_TEST_PATH="$stub_dir:$PATH"

# Redirect HOME so ~/.dots/sys/kb-changes lands in the temp tree.
_TEST_HOME="$repo/.test-home"
mkdir -p "$_TEST_HOME"

echo "$vault"
}

# Build the JSON the hook reads on stdin.
_session_input() {
local session_id="$1"
local cwd="$2"
jq -nc \
--arg sid "$session_id" \
--arg cwd "$cwd" \
--arg tp "$_TEST_TRANSCRIPT" \
'{session_id:$sid,cwd:$cwd,transcript_path:$tp,hook_event_name:"SessionEnd"}'
}

# Find the inbox file created by the hook (slug is dynamic on repo basename).
_find_inbox_file() {
local vault="$1"
find "$vault/memory/inbox" -name '*.md' 2>/dev/null | head -1
}

test_skips_session_with_no_commits() {
_setup_session_env >/dev/null
local cwd
cwd=$(pwd)

# No new commits past the seed (seed is the session-start ancestor); the
# hook should bail before touching the inbox.
local input
input=$(_session_input "test-skip-1" "$cwd")

HOME="$_TEST_HOME" PATH="$_TEST_PATH" bash "$HOOK" <<< "$input" || true

local file
file=$(_find_inbox_file "$_TEST_VAULT")
assert_eq "$file" "" "no inbox file should be created when no commits in session"
}

test_skips_session_outside_git_repo() {
local non_repo
non_repo=$(mktemp -d "${TMPDIR:-/tmp}/skill-test-XXXXXX")
_TMPDIRS+=("$non_repo")

# Set up a vault and stub argus, but point cwd at a non-repo dir.
local vault="$non_repo/.test-vault"
mkdir -p "$vault/memory"
local stub_dir="$non_repo/.test-bin"
mkdir -p "$stub_dir"
cat > "$stub_dir/argus" <<EOF
#!/usr/bin/env bash
if [ "\$1" = "kb" ] && [ "\$2" = "status" ]; then
echo "Vault : $vault"
fi
EOF
chmod +x "$stub_dir/argus"

local transcript="$non_repo/transcript.jsonl"
printf '%s\n' '{"type":"user","timestamp":"1970-01-01T00:00:00Z","message":{"role":"user","content":"hi"}}' > "$transcript"

local input
input=$(jq -nc --arg sid "no-repo" --arg cwd "$non_repo" --arg tp "$transcript" \
'{session_id:$sid,cwd:$cwd,transcript_path:$tp}')

HOME="$non_repo/.home" PATH="$stub_dir:$PATH" bash "$HOOK" <<< "$input" || true

local file
file=$(find "$vault/memory/inbox" -name '*.md' 2>/dev/null | head -1)
assert_eq "$file" "" "no inbox file when cwd is not a git repo"
}

test_captures_with_commit_merged_to_master() {
_setup_session_env >/dev/null
local cwd
cwd=$(pwd)

# Sleep 1s so the new commit is strictly after the transcript timestamp.
# `git log --since` uses second-level granularity and treats same-second
# commits as inclusive, so without the sleep, the seed commit could be
# picked up alongside this test's commit. Don't remove this sleep without
# also reworking the timestamp strategy. (Same applies to the other
# `sleep 1` calls in this file.)
sleep 1
echo "feature" > feature.txt
git add feature.txt
git commit -q -m "ship feature foo"
git push -q origin master

local input
input=$(_session_input "merged-session" "$cwd")

HOME="$_TEST_HOME" PATH="$_TEST_PATH" bash "$HOOK" <<< "$input" || true

local file
file=$(_find_inbox_file "$_TEST_VAULT")
assert_match "$file" '\.md$' "inbox file should be created"

if [ -n "$file" ]; then
local body
body=$(cat "$file")
assert_contains "$body" "high-value" "merged commit should tag high-value"
assert_contains "$body" "commit-merged" "merged commit should tag commit-merged"
assert_contains "$body" "ship feature foo" "commit subject should appear in body"
assert_contains "$body" "[merged]" "commit status should be merged"
assert_contains "$body" "fix the bug" "user intent from transcript should be captured"
assert_contains "$body" "feature.txt" "files touched should appear"
assert_not_contains "$body" "work-in-progress" "merged commit should not tag work-in-progress"
fi
}

test_captures_wip_commit_not_on_master() {
_setup_session_env >/dev/null
local cwd
cwd=$(pwd)

# Branch off master and commit there so the commit is NOT on origin/master.
sleep 1
git checkout -q -b wip-branch
echo "wip" > wip.txt
git add wip.txt
git commit -q -m "draft work in progress"

local input
input=$(_session_input "wip-session" "$cwd")

HOME="$_TEST_HOME" PATH="$_TEST_PATH" bash "$HOOK" <<< "$input" || true

local file
file=$(_find_inbox_file "$_TEST_VAULT")
assert_match "$file" '\.md$' "inbox file should be created for wip commits"

if [ -n "$file" ]; then
local body
body=$(cat "$file")
assert_contains "$body" "work-in-progress" "unmerged commit should tag work-in-progress"
assert_not_contains "$body" "high-value" "unmerged commit should not be high-value"
assert_not_contains "$body" "commit-merged" "unmerged commit should not be commit-merged"
assert_contains "$body" "[wip]" "commit status should be wip"
fi
}

test_logs_change_to_kb_changes_file() {
_setup_session_env >/dev/null
local cwd
cwd=$(pwd)

sleep 1
echo "ship" > ship.txt
git add ship.txt
git commit -q -m "another ship"
git push -q origin master

local input
input=$(_session_input "logged-session" "$cwd")

HOME="$_TEST_HOME" PATH="$_TEST_PATH" bash "$HOOK" <<< "$input" || true

local log="$_TEST_HOME/.dots/sys/kb-changes/changes.jsonl"
assert_match "$(cat "$log" 2>/dev/null || echo "")" "session-end-capture" "kb-changes log should record the capture"
}

run_tests
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ dots docker stop-all # Stop all Docker containers

| Component | What it installs |
|-----------|------------------|
| `agents` | Agent skills, custom agents, hooks, and status line (symlinks `agents/skills/` → `~/.claude/skills/` + `~/.agents/skills/`, `agents/custom/` → `~/.claude/agents/`, registers hooks and status line in `~/.claude/settings.json`) |
| `agents` | Agent skills, custom agents, hooks, and status line (symlinks `agents/skills/` → `~/.claude/skills/` + `~/.agents/skills/`, `agents/custom/` → `~/.claude/agents/`, registers SessionStart/SessionEnd/PostToolUse hooks and status line in `~/.claude/settings.json`) |
| `bin` | Custom shell scripts and Go utilities to `~/bin` |
| `git` | `.gitconfig`, `.gitignore_global`, git extensions |
| `home` | Dotfiles symlinked to `~/` (`.zshrc`, `.vimrc`, `.tmux.conf`, `.gitconfig`, etc.) |
Expand Down Expand Up @@ -99,7 +99,7 @@ Dots includes 54 reusable slash-command skills for AI coding agents, following t
| `/standup` | Daily standup summary from git activity |
| `/pdf` | Export conversation content to styled PDF |
| `/knowledge` | Initialize or update a project knowledge base |
| `/dream` | Audit and fix knowledge base hygiene — frontmatter, sizing, naming |
| `/dream` | Ingest meetings + session captures into the inbox, synthesize raw notes into existing topical docs, and audit KB hygiene — frontmatter, sizing, naming |
| `/retro` | Structured retrospective or post-incident review |
| `/logo` | Logo generation |
| `/improve` | Improve skills, capture context and knowledge |
Expand Down
Loading
Loading