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
15 changes: 15 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PROJECT_DIR:-.}/.trail/hook.cjs\" session-start",
"timeout": 8000
}
]
}
]
}
}
37 changes: 37 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Paths that feed coding agents code or instructions: Trail injects .trail/
# learnings at session start and .trail/hook.cjs imports trail from
# node_modules/ or dist/claude/; Claude Code loads the rest. Review like code.
# No trailing slash, so a symlink with the same name is covered too.
# Enforced for outside contributors by the guard / trail-owner-only CI job.
/.trail @kjmikkel
/node_modules @kjmikkel
/dist/claude @kjmikkel
.claude @kjmikkel
CLAUDE.md @kjmikkel
CLAUDE.local.md @kjmikkel
AGENTS.md @kjmikkel
.mcp.json @kjmikkel
# cmd.exe runs these from the current directory before PATH (npm.cmd, node.exe),
# and a session can start in any subdirectory, so they are owned at every depth.
# /package.json: a root package named @trailhq/trail-code redirects hook.cjs.
/package.json @kjmikkel
*.com @kjmikkel
*.exe @kjmikkel
*.bat @kjmikkel
*.cmd @kjmikkel
*.vbs @kjmikkel
*.vbe @kjmikkel
*.js @kjmikkel
*.jse @kjmikkel
*.wsf @kjmikkel
*.wsh @kjmikkel
*.msc @kjmikkel
*.cpl @kjmikkel
# The names hook.cjs runs, with any extension (PATHEXT varies per machine).
npm* @kjmikkel
node.* @kjmikkel
node @kjmikkel
cmd @kjmikkel
cmd.* @kjmikkel
# A submodule's files are not in the PR diff.
.gitmodules @kjmikkel
52 changes: 52 additions & 0 deletions .github/scripts/trail-guard.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Fail when someone other than the repo owner changes a path that feeds coding
# agents code or instructions: Trail injects .trail/learnings into context at
# session start, and .trail/hook.cjs imports trail from <repo>/node_modules or
# <repo>/dist/claude; Claude Code also loads .claude/, CLAUDE.md, AGENTS.md
# and .mcp.json.
# Usage: <changed file paths, one per line> | trail-guard.sh <pr-author> <repo-owner>
set -euo pipefail
author=$1
owner=$2

# (/|$): a symlink or submodule named node_modules, .claude or dist is one path
# with no slash, and node and Claude follow it.
# ^package.json: hook.cjs resolves @trailhq/trail-code from the repo root, so a
# root package.json with that name resolves to itself, and its "exports" can
# point the hook at hooks.js in any unguarded directory.
# .gitmodules: a submodule's files never appear in the PR's file list, so it
# could carry anything above unseen.
protected='^\.trail(/|$)|^node_modules(/|$)|^dist$|^dist/claude(/|$)|(^|/)\.claude(/|$)|(^|/)claude(\.local)?\.md$|(^|/)agents\.md$|(^|/)\.mcp\.json$|^package\.json$|(^|/)\.gitmodules$'
short_name='(^|/)[^/]*~[0-9][^/]*(/|$)' # 8.3 alias such as TRAIL~1, which Windows may resolve to .trail
# .trail/hook.cjs runs `npm root -g` through cmd.exe, which tries the current
# directory first with every PATHEXT extension: the default list, plus .cpl,
# which some machines add. That directory is wherever the session was started,
# not necessarily the root, so any depth. PATHEXT varies per machine (Python
# adds .py), so the names the hook runs (npm, node, cmd) are blocked with any
# extension, and the common executable extensions with any name.
any_exec='(^|/)[^/]+\.(com|exe|bat|cmd|vbs|vbe|js|jse|wsf|wsh|msc|cpl)$|(^|/)(npm|node|cmd)(\.[^/]*)?$'

# Fold look-alikes a filesystem may treat as the same name: compatibility forms
# (fullwidth letters), case (APFS folds long s to s, Windows upper-cases dotless
# i to I), and marks and zero-width characters (HFS+ ignores the latter).
fold='
import sys, unicodedata as u
for line in sys.stdin.buffer.read().decode("utf-8", "replace").splitlines():
s = "".join(c for c in u.normalize("NFKD", line) if u.category(c) not in ("Mn", "Cf"))
sys.stdout.buffer.write((s.casefold().replace("ı", "i") + "\n").encode())
'

# Match each path as a Windows checkout sees it: folded as above, with trailing
# dots and spaces dropped from every component.
paths=$(cat)
folded=$(printf '%s\n' "$paths" | python3 -I -c "$fold" | sed -E 's#[. ]+(/|$)#\1#g')
touched=$(while IFS= read -r f && IFS= read -r n <&3; do
if printf '%s\n' "$n" | grep -qE "$protected|$short_name|$any_exec"; then printf '%s\n' "$f"; fi
done < <(printf '%s\n' "$paths") 3< <(printf '%s\n' "$folded"))

if [ -n "$touched" ] && [ "$author" != "$owner" ]; then
echo "::error::Only @$owner may change agent context and code-loading paths. This PR by @$author touches:"
echo "$touched"
exit 1
fi
echo "ok: no protected paths changed, or changed by the owner"
88 changes: 88 additions & 0 deletions .github/scripts/trail-guard.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
# Tests for trail-guard.sh. Run: bash .github/scripts/trail-guard.test.sh
set -u
guard="$(dirname "$0")/trail-guard.sh"
failures=0

expect() { # expect <pass|fail> <description> <author> <owner> <changed files...>
local want=$1 desc=$2 author=$3 owner=$4; shift 4
if printf '%s\n' "$@" | bash "$guard" "$author" "$owner" >/dev/null 2>&1; then got=pass; else got=fail; fi
if [ "$got" = "$want" ]; then echo "ok $desc"; else echo "FAIL $desc (wanted $want, got $got)"; failures=$((failures + 1)); fi
}

expect pass "owner may change .trail/" kjmikkel kjmikkel .trail/learnings/a.md
expect pass "owner may change agent config" kjmikkel kjmikkel CLAUDE.md .claude/settings.json
expect pass "others may change files outside" someone kjmikkel DialogEditor.Core/Models/Conversation.cs README.md
expect pass "others may touch a look-alike path" someone kjmikkel docs/.trail/x.md .trailer DialogEditor.Avalonia/dist/x.json
expect pass "no changed files" someone kjmikkel ""
expect fail "others may not add a learning" someone kjmikkel README.md .trail/learnings/a.md
expect fail "others may not change the hook" someone kjmikkel .trail/hook.cjs
expect fail "a look-alike login is not the owner" KJMIKKEL-fake kjmikkel .trail/hook.cjs

# hook.cjs imports trail from <repo>/node_modules and, last, <repo>/dist/claude
expect fail "others may not plant a trail package" someone kjmikkel node_modules/@trailhq/trail-code/package.json
expect fail "others may not plant dist/claude" someone kjmikkel dist/claude/hooks.js

# Windows checkouts are case-insensitive and drop trailing dots and spaces
expect fail "upper-case .TRAIL/" someone kjmikkel .TRAIL/learnings/a.md
expect fail "trailing dot .trail./" someone kjmikkel .trail./learnings/a.md
expect fail "trailing space '.trail /'" someone kjmikkel ".trail /learnings/a.md"
expect fail "8.3 short name TRAIL~1/" someone kjmikkel TRAIL~1/learnings/a.md

# other files Claude Code loads as instructions, hooks or MCP servers
expect fail "others may not change .claude/" someone kjmikkel .claude/settings.json
expect fail "others may not change nested .claude/" someone kjmikkel DialogEditor.Core/.claude/settings.json
expect fail "others may not change CLAUDE.md" someone kjmikkel DialogEditor.Core/CLAUDE.md
expect fail "others may not change CLAUDE.local.md" someone kjmikkel CLAUDE.local.md
expect fail "others may not change AGENTS.md" someone kjmikkel AGENTS.md
expect fail "others may not change .mcp.json" someone kjmikkel .mcp.json
expect fail "others may not change an agent definition" someone kjmikkel .claude/agents/sonnet-implementer.md
expect fail "others may not change a project skill" someone kjmikkel .claude/skills/running-the-app/SKILL.md
expect pass "others may change root build.ps1" someone kjmikkel build.ps1

# a symlink or submodule is one path with no trailing slash; node and Claude follow it
expect fail "symlink named node_modules" someone kjmikkel node_modules
expect fail "symlink named .trail" someone kjmikkel .trail
expect fail "symlink named dist" someone kjmikkel dist
expect fail "symlink named dist/claude" someone kjmikkel dist/claude
expect fail "symlink named .claude" someone kjmikkel .claude
expect fail "nested symlink named .claude" someone kjmikkel DialogEditor.Core/.claude
expect pass "others may change a dist/ file" someone kjmikkel dist/other/x.json

# case-insensitive APFS case-folds (long s to s) and HFS+ ignores zero-width characters
expect fail "long s in .mcp.json" someone kjmikkel $'.mcp.j\xc5\xbfon'
expect fail "long s in agents.md" someone kjmikkel $'agent\xc5\xbf.md'
expect fail "zero-width non-joiner in .trail/" someone kjmikkel $'.tr\xe2\x80\x8cail/learnings/a.md'
expect fail "dotless i in .trail/" someone kjmikkel $'.tra\xc4\xb1l/learnings/a.md'
expect fail "fullwidth C in CLAUDE.md" someone kjmikkel $'\xef\xbc\xa3LAUDE.md'
expect pass "others may add non-ASCII elsewhere" someone kjmikkel $'docs/Ondra\xe2\x80\x99s notes.md'

# .trail/hook.cjs runs npm through cmd.exe, which looks in the current directory first,
# and the hook's current directory can be any subdirectory a session was started in
expect fail "planted npm.cmd at the root" someone kjmikkel npm.cmd
expect fail "planted node.EXE at the root" someone kjmikkel node.EXE
expect fail "planted npm.bat. at the root" someone kjmikkel "npm.bat."
expect fail "planted git.js at the root" someone kjmikkel git.js
expect fail "planted npm.cmd in a subdirectory" someone kjmikkel tools/ui-automation/npm.cmd
expect fail "changed a nested .exe" someone kjmikkel DialogEditor.Avalonia/tools/vgmstream-cli.exe
expect pass "owner may change a nested .exe" kjmikkel kjmikkel DialogEditor.Avalonia/tools/vgmstream-cli.exe
expect fail "planted npm.cpl (user PATHEXT)" someone kjmikkel tools/npm.cpl
expect fail "planted npm.py (any PATHEXT entry)" someone kjmikkel npm.py
expect fail "planted nested NODE.pyw" someone kjmikkel tools/NODE.pyw
expect fail "planted extensionless cmd" someone kjmikkel tools/cmd
expect pass "others may add npm-notes.md" someone kjmikkel docs/npm-notes.md
expect pass "owner may add a root script" kjmikkel kjmikkel setup.cmd

# a submodule's files never show up in the PR's file list, so it could carry any of the above
expect fail "others may not add a submodule" someone kjmikkel .gitmodules
expect fail "others may not add a nested .gitmodules" someone kjmikkel tools/.gitmodules
expect pass "owner may add a submodule" kjmikkel kjmikkel .gitmodules
expect pass "others may change a nested .ps1" someone kjmikkel tools/ui-automation/DriveApp.ps1

# hook.cjs resolves @trailhq/trail-code from the repo root: a root package.json with that name
# self-references, and its "exports" can point the hook at any unguarded directory
expect fail "others may not add a root package.json" someone kjmikkel package.json
expect fail "upper-case root PACKAGE.JSON" someone kjmikkel PACKAGE.JSON
expect pass "others may add a nested package.json" someone kjmikkel tools/web/package.json

[ "$failures" -eq 0 ] && echo "all passed" || { echo "$failures failed"; exit 1; }
37 changes: 37 additions & 0 deletions .github/workflows/guard.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: guard

# pull_request_target runs this file and the guard script as they are on the base
# branch, so a PR cannot edit its own guard. It never checks out or runs PR code:
# the changed files come from the API.
on:
pull_request_target:

permissions:
contents: read
pull-requests: read

jobs:
trail-owner-only:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: bash .github/scripts/trail-guard.test.sh
- name: Only the owner may change agent context
shell: bash # -eo pipefail: a failed API call must fail the job, not pass an empty list
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR: ${{ github.event.pull_request.number }}
AUTHOR: ${{ github.event.pull_request.user.login }}
OWNER: ${{ github.repository_owner }}
run: |
gh api "repos/$REPO/pulls/$PR/files" --paginate > files.json
# The files API stops at 3,000; fail closed rather than check a partial list.
expected=$(gh api "repos/$REPO/pulls/$PR" --jq .changed_files)
listed=$(jq -s 'add | length' files.json)
if [ "$listed" != "$expected" ]; then # string compare: a non-number fails closed too
echo "::error::Listed $listed of $expected changed files; cannot check the rest."
exit 1
fi
jq -rs 'add | .[] | .filename, (.previous_filename // empty)' files.json \
| bash .github/scripts/trail-guard.sh "$AUTHOR" "$OWNER"
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,10 @@ CoverageReport/

# Palette golden test — transient artifact; only the .approved.txt is committed
palette-golden.received.txt

# Per-machine Claude Code settings (permissions allowlist, trail statusline)
.claude/settings.local.json
# Cache left by trail's old name (graft); trail keeps its cache in .trail/cache/
graft/
# Python bytecode (tools/catalogue-gen)
__pycache__/
2 changes: 2 additions & 0 deletions .trail/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
statusline.cjs
cache/
16 changes: 16 additions & 0 deletions .trail/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# .trail

What coding sessions on this repo worked out, kept next to the code so the
next session, yours or a teammate's, starts from it instead of digging again.

- learnings/ one file per session that took real digging: what was decided,
what was tried and ruled out, and what to watch out for, plus
what it took to work out
- hook.cjs what Claude Code runs at session start (`.claude/settings.json`).
With trail installed it hands over to trail. Without it, it
shows Claude these learnings and offers to set trail up.

Commit this folder and `.claude/settings.json` once. After that, commit new
learnings with the change they came from. Learnings are short summaries, never
transcripts, and plain markdown without trail installed. What
`.trail/.gitignore` lists stays on each machine.
Loading
Loading