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
14 changes: 14 additions & 0 deletions .agents/memory/INBOX.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Lessons inbox

Raw notes from finished work. Append only; never read this file before a task. The
`review-memory` skill reads it, promotes what repeats into [LESSONS.md](LESSONS.md), drops the
rest, and empties it.

One note per PR that hit friction, four lines:

```
- YYYY-MM-DD #PR skill: <which skill was running>
What went wrong: <one sentence>
Would have prevented it: <the rule, one sentence>
Cost: <review round, e2e rerun, blocked, wrong merge>
```
36 changes: 36 additions & 0 deletions .agents/memory/LESSONS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Lessons

Curated memory for agents working on this repo. Read only the section named after the skill
you are running, plus General. Written only by the `review-memory` skill; everything else
goes to [INBOX.md](INBOX.md) first.

Entry format, two lines:

```
- YYYY-MM-DD (#PR, #PR) <the rule, one sentence>
Evidence: <what happened without it, one sentence>
```

Caps: 10 entries per section, 40 in total. Over the cap, the next review merges or drops.

## General

## architecture

## implement-issue

## review-pr

## triage-issue

## design-feature

## file-issue

## e2e-device

## writing-user-docs

## cut-release

## work-issue
25 changes: 25 additions & 0 deletions .agents/scripts/worktree-remove.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/usr/bin/env sh
# Removes a worktree made by worktree.sh. The branch is kept: a subagent's work lives on it
# and may already be pushed; deleting branches is a human's call.
#
# .agents/scripts/worktree-remove.sh <path>
# echo '{"worktree_path":"<path>"}' | .agents/scripts/worktree-remove.sh
#
# The second form is Claude Code's WorktreeRemove hook (see .claude/settings.json).
set -eu

if [ $# -eq 0 ]; then
path=$(python3 -c 'import json, sys; print(json.load(sys.stdin)["worktree_path"])')
else
path=$1
fi

main_root=$(dirname "$(git rev-parse --path-format=absolute --git-common-dir)")
case "$path" in
"$main_root"/.worktrees/*) ;;
*) echo "refusing to remove $path: not under $main_root/.worktrees" >&2; exit 1 ;;
esac

# --force: the worktree holds cloned node_modules and build output, which git counts as
# untracked-but-ignored and would otherwise refuse to remove.
git -C "$main_root" worktree remove --force "$path"
71 changes: 71 additions & 0 deletions .agents/scripts/worktree.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
#!/usr/bin/env sh
# Creates a git worktree for a branch and makes it buildable in seconds.
#
# .agents/scripts/worktree.sh <branch> [base] base defaults to origin/main
# echo '{"name":"<branch>"}' | .agents/scripts/worktree.sh
#
# The second form is what Claude Code's WorktreeCreate hook uses (see .claude/settings.json):
# the hook passes JSON on stdin and reads the last stdout line as the worktree path. Every
# other message goes to stderr for that reason.
#
# Every node_modules tree of the main checkout is cloned into the worktree with APFS
# clonefile(2): one call per tree, no data copied, blocks shared copy-on-write. pnpm then
# relinks the workspace offline, which takes about a second because everything is already in
# place. On a filesystem without clonefile (Linux) the clone is skipped and pnpm links the
# tree from its store offline instead; nothing is downloaded either way.
#
# An existing local or remote branch is checked out as is; a new one is created from base.
# Worktrees live under <main checkout>/.worktrees/<branch>, which is gitignored. Remove one
# with .agents/scripts/worktree-remove.sh or `git worktree remove .worktrees/<branch>`.
set -eu

if [ $# -eq 0 ]; then
branch=$(python3 -c 'import json, sys; print(json.load(sys.stdin)["name"])')
base=origin/main
else
branch=$1
base=${2:-origin/main}
fi

main_root=$(dirname "$(git rev-parse --path-format=absolute --git-common-dir)")
dir="$main_root/.worktrees/$branch"

if [ -e "$dir" ]; then
echo "$dir already exists" >&2
exit 1
fi

git -C "$main_root" fetch -q origin
if git -C "$main_root" show-ref --verify --quiet "refs/heads/$branch"; then
git -C "$main_root" worktree add -q "$dir" "$branch"
elif git -C "$main_root" ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then
git -C "$main_root" worktree add -q --track -b "$branch" "$dir" "origin/$branch"
else
git -C "$main_root" worktree add -q -b "$branch" "$dir" "$base"
fi

if [ "$(uname)" = Darwin ]; then
python3 - "$main_root" "$dir" <<'EOF' >&2
import ctypes, os, sys
src_root, dst_root = sys.argv[1], sys.argv[2]
libc = ctypes.CDLL("libSystem.dylib", use_errno=True)
for parent, names, _ in os.walk(src_root):
rel = os.path.relpath(parent, src_root)
# Do not descend into other worktrees, git internals, or into a node_modules once cloned.
names[:] = [n for n in names if n not in (".worktrees", ".claude", ".git")]
if "node_modules" in names:
names.remove("node_modules")
src = os.path.join(parent, "node_modules")
dst = os.path.join(dst_root, "node_modules") if rel == "." else os.path.join(dst_root, rel, "node_modules")
os.makedirs(os.path.dirname(dst), exist_ok=True)
if libc.clonefile(src.encode(), dst.encode(), 0) != 0:
err = ctypes.get_errno()
sys.exit(f"clonefile {src}: {os.strerror(err)}")
EOF
fi

# --offline: every package is already in place (Darwin) or in the store (elsewhere). If this
# fails, the lockfile changed on the branch; run `pnpm install --frozen-lockfile` there once.
(cd "$dir" && pnpm install --frozen-lockfile --offline --ignore-scripts >/dev/null 2>&1)

echo "$dir"
75 changes: 75 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
{
"permissions": {
"allow": [
"Bash(pnpm *)",
"Bash(node *)",
"Bash(npx *)",
"Bash(jq *)",
"Bash(git status*)",
"Bash(git diff*)",
"Bash(git log*)",
"Bash(git show*)",
"Bash(git branch*)",
"Bash(git fetch*)",
"Bash(git switch *)",
"Bash(git checkout *)",
"Bash(git add *)",
"Bash(git commit *)",
"Bash(git push origin *)",
"Bash(git push -u origin *)",
"Bash(gh issue view *)",
"Bash(gh issue list *)",
"Bash(gh issue create *)",
"Bash(gh issue comment *)",
"Bash(gh issue edit *)",
"Bash(gh pr view *)",
"Bash(gh pr list *)",
"Bash(gh pr diff *)",
"Bash(gh pr checks *)",
"Bash(gh pr create *)",
"Bash(gh pr edit *)",
"Bash(gh pr comment *)",
"Bash(gh pr review *)",
"Bash(gh pr ready *)",
"Bash(gh run list *)",
"Bash(gh run view *)",
"Bash(gh label list *)",
"Bash(gh pr merge --squash --delete-branch memory/*)",
"Bash(xcrun simctl *)",
"Bash(xcodebuild *)",
"Bash(adb *)",
"Bash(emulator *)"
],
"deny": [
"Bash(git push --force*)",
"Bash(git push -f *)",
"Bash(git reset --hard*)",
"Bash(git branch -D *)",
"Bash(gh release *)",
"Bash(gh repo delete *)",
"Bash(npm publish*)"
]
},
"hooks": {
"WorktreeCreate": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.agents/scripts/worktree.sh"
}
]
}
],
"WorktreeRemove": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.agents/scripts/worktree-remove.sh"
}
]
}
]
}
}
118 changes: 118 additions & 0 deletions .claude/skills/architecture/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
---
name: architecture
description: How code in this repo is structured - modules with a public API, calls for immediate effects and bus events for side effects, ports and adapters for everything outside the process, and the simplification checklist. Load before designing or writing any non-trivial code, and when reviewing for structure.
---

# Architecture rules

The compressed version is in `AGENTS.md`. This file is the reasoning and the examples, so
you can apply the rules to a case they do not name.

Read the `architecture` section of `.agents/memory/LESSONS.md` before starting, plus General.

## Modules

A module is a directory. Its `index.ts` exports the public API; everything else in the
directory is internal. Nothing outside the directory imports a non-index file, and lint
enforces that once the boundary rule lands.

Design the public API before the internals. Ask what the caller needs to know to use the
module correctly; that and nothing more goes in `index.ts`. If two modules need each other's
internals, they are one module or the boundary is in the wrong place.

## Calls and events

Two ways for modules to interact. Pick by who needs the result.

- **The caller needs the result now**: call the other module's public API. `sessions.claim(link)`
returns the session or throws. The caller depends on the callee.
- **Something happened and others may care**: emit a domain event on the event bus
(`packages/appduct/src/daemon/event-bus.ts`). `session_claimed` is emitted; audit, the CLI
event stream and the MCP server each subscribe. The emitter does not import the listeners
and does not break if none exist.

Rules that follow: a module never emits an event and then waits for a listener to do
something it needs. An event carries facts about what happened, past tense, not instructions.
If you find yourself emitting `should_write_audit`, that is a call.

## Ports and adapters

"Outside the process" means: filesystem, child processes, network (sockets, TLS, HTTP),
clock and timers, environment variables, randomness, and the OS (home directory, platform).
Anything the process cannot make deterministic.

Each such dependency is a **port**: a small interface owned by the module that needs it,
named for the capability, not the technology.

```ts
// packages/appduct/src/daemon/state-dir/ports.ts
export type Filesystem = {
readFile(path: string): Promise<string | undefined>;
writeFile(path: string, contents: string, mode?: number): Promise<void>;
ensureDir(path: string, mode?: number): Promise<void>;
};
```

Each port ships two **adapters**, side by side in source, never under `__tests__`:

- `node-filesystem.ts`: wraps `node:fs`. The only file in the module allowed to import it.
- `memory-filesystem.ts`: an in-memory fake with the same interface, plus whatever a test
needs to inspect it (`files()`, `modes()`). Tests across the repo reuse it.

Modules receive ports as constructor or factory arguments. Only a **composition root**
constructs real adapters: the CLI entry (`packages/appduct/src/cli/...`) and the daemon entry
(`packages/appduct/src/daemon/daemon.ts`). Nothing else writes `new NodeFilesystem()`.

Why this matters here: tests then never touch the real home directory, the real socket path
or the real clock, and a test that needs to mock I/O is the signal that a port is missing.
`vi.mock` of `node:*` or of a repo module is banned for that reason.

Existing code predates this rule. Convert a file when you substantively touch it. Do not
open a PR whose only purpose is converting files you were not otherwise changing.

There is already a `Clock` type in `packages/appduct/src/cli/types.ts` and several `*Deps`
types in `packages/appduct/src/mcp/`. Extend those rather than inventing parallel ones.

## Native code (Swift and Kotlin)

Same rules, different spelling:

- **Module** is a Swift target or a Kotlin package. The public API is what is `public`;
everything else is `internal` and nothing outside reaches it. No `@testable import` to get
at internals; test through the public surface.
- **Ports** are a `protocol` or `interface` named for the capability (`Clock`, `Transport`,
`KeyStore`). The real adapter and the in-memory fake sit next to each other in source, not
under the test target, so every test can reuse the fake. The `Real/` directories under
`packages/native/ios/Sources/AppductCore` are where real adapters live today.
- **Side effects** flow through the existing callback and event surface the core already
exposes to the SDK entry points; a component never reads or writes another component's
state directly.
- **Shared behaviour** between the three SDKs is specified once, in
`packages/native/fixtures`. A change to a wire or descriptor shape updates the fixture
first, then each SDK until its conformance test is green.

## Simplification checklist

Run this against your own diff before opening a PR. The review-pr skill runs it too, and a
miss is a should-fix finding, not a nit.

- **Two callers.** An abstraction (helper, base class, generic, shared type) needs two concrete
callers in the tree or in this PR. One caller: inline it.
- **Impossible states.** No branch for a state the types or an invariant already exclude. If
it cannot happen, assert and throw with a message; do not handle it.
- **Single-value knobs.** No option, flag or parameter that every caller passes the same value
to. Hard-code it.
- **Speculative surface.** No interface with one implementation, no plugin hook nobody calls,
no generic parameter that is always the same type, no "for later" export.
- **Delete before generalise.** If an existing abstraction makes the change awkward, removing
the abstraction is usually the fix.
- **Size sanity.** Implementation over roughly three times the size of the tests it satisfies
needs a sentence in the PR saying why.

## Before you open the PR

- Every new directory has an `index.ts` and nothing outside it imports past that.
- Every new reach outside the process goes through a port with both adapters.
- Each item in the checklist above is either satisfied or explained in the PR.
- Public API changes that alter behaviour are reflected in `docs/ARCHITECTURE.md` when that
document describes the surface you changed.
Loading
Loading