diff --git a/.agents/README.md b/.agents/README.md new file mode 100644 index 000000000..4a9ef6f76 --- /dev/null +++ b/.agents/README.md @@ -0,0 +1,31 @@ +# .agents + +Cross-tool source of truth for Cuttle's agentic infrastructure. All AI assistants load from here via symlinks. + +## What lives here + +``` +skills/ # ctl-* skill prompts, each in their own SKILL.md +agents/ # ctl-* subagent definitions +docs/ # detailed reference docs (skills index into these) +tools/ # shared Node.js utilities called by hooks +``` + +## How it loads + +| Tool | Loads via | +|------|-----------| +| Claude Code | `.claude/skills → .agents/skills`, `.claude/agents → .agents/agents` | +| Gemini CLI | `.gemini/skills → .agents/skills`, `.gemini/agents → .agents/agents` | + +`CLAUDE.md → @AGENTS.md` auto-loads the top-level discovery and safety rules every session. + +## Skill namespace + +All Cuttle skills use the `ctl-` prefix. See `docs/skill-conventions.md` for naming rules. + +## Adding a skill + +1. Create `skills/ctl-/SKILL.md` with the required frontmatter. +2. Keep the file under ~500 lines. +3. If the skill references detailed examples, put those in `docs/` and link from the skill. diff --git a/.agents/agents/ctl-architecture-reviewer.md b/.agents/agents/ctl-architecture-reviewer.md new file mode 100644 index 000000000..6816bc4fe --- /dev/null +++ b/.agents/agents/ctl-architecture-reviewer.md @@ -0,0 +1,75 @@ +--- +name: ctl-architecture-reviewer +description: "Architecture reviewer subagent for Cuttle. Reviews module boundaries, race conditions, store mutation hygiene, and structural patterns. Dispatched by ctl-code-review. Returns a structured findings report." +model: inherit +allowed-tools: Read, Grep, Glob, Bash +--- + +# Architecture Reviewer + +Focused architecture review for Cuttle changes. Called by `ctl-code-review`. + +## Scope + +- Module boundaries (controllers, helpers, stores, components don't bleed into each other's domains) +- Race conditions in async controller flows +- Pinia store mutation hygiene +- Socket subscription lifecycle (rooms joined/left correctly) +- Known structural risk areas + +## Input + +Receive the diff or changed file list from `ctl-code-review`. Read the changed files fully before reviewing. + +## Known risk areas to check + +### Double API calls / playOneOff pattern + +Controller actions that trigger game state changes must lock the game before reading and unlock after. Check: +- Is `sails.helpers.lockGame` called before any state read? +- Is `sails.helpers.unlockGame` called in both the success and catch paths? +- Is there any early return that bypasses unlock? + +### rematch.js module-scope state + +`api/controllers/game/rematch.js` initializes `game` as `let game` in the outer try block. This is intentional — it allows the catch block to unlock even if assignment failed. Do not flag this pattern as a bug. + +### Store mutations outside actions + +Pinia state should only be mutated inside store actions (functions returned from `defineStore`). Flag direct mutations via `store.property = value` from outside the store definition. + +### Array mutation safety + +`splice(-1, 1)` removes the last element. Verify indices are intentional. Look for off-by-one errors in card array manipulations. + +### Async consistency + +If a controller calls multiple `await` expressions, verify the game lock is held for the entire sequence. + +## Review checklist + +- [ ] Lock/unlock pattern correct in modified controllers +- [ ] No early returns that bypass unlock +- [ ] Store state mutated only via actions +- [ ] Socket room subscriptions balanced (join/leave) +- [ ] No module-scope mutable state introduced +- [ ] Array operations use correct indices + +## Output format + +``` +## Architecture Review + +### Verdict: [PASS / REQUEST CHANGES / DISCUSS] + +### Findings +| Severity | File:Line | Issue | Recommendation | +|----------|-----------|-------|----------------| +| Block | api/controllers/game/foo.js:42 | unlock bypassed on early return | add unlock before return | +| Suggest | src/stores/game.js:120 | direct mutation outside action | move to store action | + +### Clean areas +- [list files with no findings] +``` + +Return this report to `ctl-code-review`. diff --git a/.agents/agents/ctl-docs-reviewer.md b/.agents/agents/ctl-docs-reviewer.md new file mode 100644 index 000000000..87d57247d --- /dev/null +++ b/.agents/agents/ctl-docs-reviewer.md @@ -0,0 +1,90 @@ +--- +name: ctl-docs-reviewer +description: "Docs reviewer subagent for Cuttle. Reviews drift between docs/*.md and code, broken links, and AGENTS.md/CLAUDE.md consistency. Dispatched by ctl-code-review. Returns a structured findings report." +model: inherit +allowed-tools: Read, Grep, Glob, Bash +--- + +# Docs Reviewer + +Focused documentation review for Cuttle changes. Called by `ctl-code-review`. + +## Scope + +- Drift between `docs/*.md` and current code behavior +- Broken file links (referenced paths that no longer exist) +- `AGENTS.md` / `CLAUDE.md` / `GEMINI.md` consistency +- `.agents/docs/` accuracy against current codebase +- New behavior introduced without doc update + +## Input + +Receive the diff or changed file list from `ctl-code-review`. Read changed files and any docs that reference the changed areas. + +## Checks + +### 1. New behavior without doc update + +If changed files introduce new patterns (new helper signature, new move type, new Cypress command), check whether the relevant doc has been updated: + +```bash +grep -r "helperName\|newPattern" docs/ .agents/docs/ --include="*.md" +``` + +If the pattern is referenced nowhere in docs, flag it as undocumented. + +### 2. Broken file links + +```bash +# Find all markdown links +grep -roh '\[.*\]([^)]*\.js\|[^)]*\.vue\|[^)]*\.md)' docs/ .agents/docs/ AGENTS.md +``` + +For each linked path, verify it exists: +```bash +ls +``` + +### 3. AGENTS.md / CLAUDE.md consistency + +- `CLAUDE.md` must contain only `@AGENTS.md` — do not add content directly. +- `GEMINI.md` must contain only `@AGENTS.md`. +- Any changes to AGENTS.md must not contradict `.agents/docs/` content. + +```bash +cat CLAUDE.md +cat GEMINI.md +``` + +### 4. `.agents/docs/` accuracy + +If a changed controller, helper, or store deviates from the documented pattern in `.agents/docs/sails-patterns.md` or `.agents/docs/vue-patterns.md`, flag the discrepancy. + +### 5. Game rules doc vs implementation + +If `docs/game-rules.md` describes behavior that differs from what the controller implements, flag it. The implementation is authoritative; the docs should match. + +```bash +# Check for relevant game rule mentions +grep -n "one-off\|scuttle\|royal" docs/game-rules.md +``` + +## Output format + +``` +## Docs Review + +### Verdict: [PASS / REQUEST CHANGES / SUGGEST] + +### Findings +| Severity | Location | Issue | Recommendation | +|----------|----------|-------|----------------| +| Request | docs/CONTRIBUTING.md:88 | references cy.setupGameAsP0() but signature changed | update docs | +| Suggest | .agents/docs/sails-patterns.md | new helper added but not documented | add to patterns doc | +| Block | AGENTS.md:45 | broken link to api/helpers/old-helper.js | update or remove link | + +### Clean areas +- [list docs with no findings] +``` + +Return this report to `ctl-code-review`. diff --git a/.agents/agents/ctl-performance-reviewer.md b/.agents/agents/ctl-performance-reviewer.md new file mode 100644 index 000000000..0cd2c2efe --- /dev/null +++ b/.agents/agents/ctl-performance-reviewer.md @@ -0,0 +1,100 @@ +--- +name: ctl-performance-reviewer +description: "Performance reviewer subagent for Cuttle. Reviews socket handler cleanup, memory leaks, Pinia store reactivity, and Vite build impact. Dispatched by ctl-code-review. Returns a structured findings report." +model: inherit +allowed-tools: Read, Grep, Glob, Bash +--- + +# Performance Reviewer + +Focused performance review for Cuttle changes. Called by `ctl-code-review`. + +## Scope + +- Socket listener cleanup (listeners added in setup must be removed in teardown) +- Memory leaks in Vue components and Pinia stores +- Pinia store reactivity (unnecessary re-renders from broad reactive state) +- Vite build impact (large static imports, unoptimized assets) +- Inefficient database queries (N+1, missing `populate`) + +## Input + +Receive the diff or changed file list from `ctl-code-review`. Read changed files. + +## Checks + +### 1. Socket listener cleanup + +Vue components that add socket listeners must remove them on unmount: + +```js +// Good +onMounted(() => { io.socket.on('game', handler); }); +onUnmounted(() => { io.socket.off('game', handler); }); + +// Flag: listener added but never removed +``` + +```bash +grep -n "io.socket.on\|io.socket.off" +``` + +### 2. Pinia store — no leaked timers/intervals + +```bash +grep -n "setInterval\|setTimeout" --include="*.js" +``` + +Timers inside stores or composables must be cleared on component teardown or store cleanup. + +### 3. Pinia reactivity granularity + +Large `ref({})` objects cause broad re-renders when any nested property changes. Prefer individual `ref()` values for frequently-updated fields. + +```bash +grep -n "ref({" src/stores/*.js +``` + +Flag large object refs in hot paths (game state, hand, points). + +### 4. Database query efficiency + +In Sails controllers, `populate` calls are expensive. Flag: +- Unnecessary `populate` on fields not used in the response +- Missing `populate` that causes N+1 (accessing `game.p0.username` without populating `p0`) + +```bash +grep -n "\.populate\|\.find\|\.findOne" --include="*.js" +``` + +### 5. Vue component re-render scope + +Components that subscribe to large portions of the game store will re-render on any game event. Flag components that import the entire `useGameStore` but only use one or two fields. + +### 6. Vite build impact + +```bash +# Check for large static imports +grep -n "import.*from.*node_modules" +``` + +Flag imports of large libraries where a smaller alternative exists. + +## Output format + +``` +## Performance Review + +### Verdict: [PASS / REQUEST CHANGES / SUGGEST] + +### Findings +| Severity | File:Line | Issue | Recommendation | +|----------|-----------|-------|----------------| +| Request | src/plugins/sockets/inGameEvents.js:88 | socket.on without socket.off | add cleanup in onUnmounted | +| Suggest | src/stores/game.js:95 | large object ref, re-renders broadly | split into granular refs | + +### Clean areas +- [list files with no findings] +``` + +Return this report to `ctl-code-review`. diff --git a/.agents/agents/ctl-security-reviewer.md b/.agents/agents/ctl-security-reviewer.md new file mode 100644 index 000000000..29bab8cae --- /dev/null +++ b/.agents/agents/ctl-security-reviewer.md @@ -0,0 +1,98 @@ +--- +name: ctl-security-reviewer +description: "Security reviewer subagent for Cuttle. Reviews CSRF config, session handling, policy chain, XSS vectors, OAuth flow, and hardcoded secrets. Dispatched by ctl-code-review. Returns a structured findings report." +model: inherit +allowed-tools: Read, Grep, Glob, Bash +--- + +# Security Reviewer + +Focused security review for Cuttle changes. Called by `ctl-code-review`. + +## Scope + +- Authentication and session validation on game-mutating routes +- Policy chain integrity (`config/policies.js`) +- Hardcoded secrets or credentials +- XSS vectors (`v-html` usage, `innerHTML`, unescaped user content) +- `rejectUnauthorized: false` in TLS/HTTPS config +- OAuth flow correctness +- User input validation at API boundaries + +## Input + +Receive the diff or changed file list from `ctl-code-review`. Read changed files and any touched policy/route files. + +## Checks + +### 1. Session validation + +Every game-mutating controller must: +- Read user ID from `req.session.usr` — never from `req.body` or `req.params` +- Validate the user is a player in the game before mutating + +```bash +# Check policy chain for modified routes +grep -n "changed-route-pattern" config/routes.js +grep -n "route-action" config/policies.js +``` + +### 2. Policy chain completeness + +```bash +cat config/policies.js +``` + +Verify that new routes are listed in `config/policies.js` with at least `isLoggedIn`. Unlisted routes default to open access. + +### 3. Hardcoded secrets + +```bash +grep -r "password\|secret\|token\|key\|api_key" --include="*.{js,vue}" -i +``` + +Flag any string literals that look like credentials. Sails config should be used instead. + +### 4. XSS vectors + +```bash +grep -r "v-html\|innerHTML\|dangerouslySetInner" --include="*.vue" +``` + +`v-html` is blocked for user-supplied content. Flag any new `v-html` binding on data from the API or user input. + +### 5. TLS configuration + +```bash +grep -r "rejectUnauthorized" config/ --include="*.js" +``` + +`rejectUnauthorized: false` must not appear in production config. + +### 6. CSRF + +Sails.js provides CSRF protection via its built-in middleware. Check that new form-like POST endpoints are not accidentally excluded from CSRF policy. + +### 7. Socket room authorization + +Players should only be subscribed to their own perspective room (`game__p0` or `game__p1`). Verify that `addRoomMembersToRooms` calls correctly map p0→p1 and p1→p0 on rematch (perspectives switch). + +## Output format + +``` +## Security Review + +### Verdict: [PASS / REQUEST CHANGES / BLOCK] + +### Findings +| Severity | File:Line | Issue | Recommendation | +|----------|-----------|-------|----------------| +| Block | api/controllers/game/foo.js:15 | userId from req.body | use req.session.usr | +| Block | config/policies.js | new route missing isLoggedIn | add to policy chain | +| Request | src/components/Chat.vue:44 | v-html on user message | use text interpolation | + +### Clean areas +- [list files with no findings] +``` + +Return this report to `ctl-code-review`. diff --git a/.agents/docs/commit-format.md b/.agents/docs/commit-format.md new file mode 100644 index 000000000..5d382d1e1 --- /dev/null +++ b/.agents/docs/commit-format.md @@ -0,0 +1,56 @@ +# Commit Format + +Cuttle uses [Conventional Commits](https://www.conventionalcommits.org/) with file/area-based scopes. + +## Format + +``` +(): +``` + +- Subject: lowercase, imperative, no period. +- Scope: the file name (no extension) or the feature area affected. +- Keep subject under 72 characters. + +## Types + +| Type | When | +|------|------| +| `feat` | New user-facing feature | +| `fix` | Bug fix | +| `test` | Adding or updating tests | +| `style` | Visual/style change, no logic | +| `refactor` | Code restructure, no behavior change | +| `chore` | Build, deps, tooling, agents infrastructure | +| `docs` | Documentation only | + +## Scopes mined from git log + +Common scopes extracted from recent history: + +| Scope | Area | +|-------|------| +| `GameView` | `src/routes/game/GameView.vue` | +| `rematch.js` | `api/controllers/game/rematch.js` | +| `sockets` | Socket event handling | +| `lock-game` | `api/helpers/lock-game.js` | +| `announcementData` | `src/routes/home/components/announcementDialog/data/announcementData.js` | +| `deps` | Dependency updates | +| `agents` | `.agents/` infrastructure | + +For new files, use the file name without extension. For features that span multiple files, use the area name. + +## Rules + +- Never bump `package.json` version in a commit — CI handles this automatically via version labels. +- Do not use `git add -A` or `git add .` — stage specific files. +- Do not use `--no-verify`. + +## Examples + +``` +feat(GameView): add scuttle animation for seven card plays +fix(rematch.js): unlock game on forbidden error path +test(basicMoves.spec.js): add draw-past-limit edge case +chore(agents): add ctl-discover skill +``` diff --git a/.agents/docs/doc-format.md b/.agents/docs/doc-format.md new file mode 100644 index 000000000..eecaa9c55 --- /dev/null +++ b/.agents/docs/doc-format.md @@ -0,0 +1,40 @@ +# Doc Format + +House style for all docs in this repo, including `docs/*.md`, `.agents/docs/*.md`, and skill files. + +## Voice + +- Imperative, factual, dry. No marketing language. +- State what something does, not how great it is. +- Omit filler phrases: "Note that", "It is worth mentioning", "Please keep in mind". + +## Structure + +- `#` — document title (one per file). +- `##` — major section. +- `###` — sub-section. +- No level deeper than `###` in most docs; use a list instead. + +## Lists + +- Flat lists for 3+ parallel items. +- Use a table when items have 2+ attributes. +- Keep bullets to one line when possible. + +## Code + +- Inline code for: file paths, variable names, command fragments, JSON keys. +- Fenced blocks for: commands the reader runs, file snippets, API responses. +- Name the language on every fenced block. + +## Cross-references + +- Link to files by repo-relative path: `[game-domain.md](game-domain.md)`. +- Link to skills by name: `ctl-discover`. +- Do not link to line numbers — they rot. + +## What to omit + +- Do not document uncertainty ("may", "might", "could be"). Verify first, then document the fact. +- Do not annotate intent ("this is needed because...") — that belongs in commit messages or PRs. +- Do not repeat information already in `AGENTS.md`; link to it. diff --git a/.agents/docs/game-domain.md b/.agents/docs/game-domain.md new file mode 100644 index 000000000..8c48b7898 --- /dev/null +++ b/.agents/docs/game-domain.md @@ -0,0 +1,98 @@ +# Game Domain Glossary + +Canonical vocabulary for Cuttle. Use these terms in code, tests, and comments. See `docs/game-rules.md` for full rules. + +## Players + +| Term | Meaning | +|------|---------| +| `p0` | Player 0 — the player who was dealt 5 cards and goes first | +| `p1` | Player 1 — the player who was dealt 6 cards | +| `pNum` | Player number: `0` or `1` | + +In code, player objects are stored on `game.p0` and `game.p1`. The `pNum` is used in move payloads and socket events. + +## Cards + +### Ranks (integers 1–13) + +| Rank | Name | Role | +|------|------|------| +| 1 | Ace | One-off: scrap all point cards | +| 2 | Two | One-off: counter a one-off OR scrap a royal/glasses eight | +| 3 | Three | One-off: take one card from scrap pile | +| 4 | Four | One-off: opponent discards two cards | +| 5 | Five | One-off: discard one, draw up to three | +| 6 | Six | One-off: scrap all royals and glasses eights | +| 7 | Seven | One-off: reveal top two deck cards, play one immediately | +| 8 | Eight | Royal: "Glasses Eight" — opponent plays with hand revealed | +| 9 | Nine | One-off: return opponent's field card to their hand (frozen one turn) | +| 10 | Ten | Points only | +| 11 | Jack | Royal: steal an opponent's point card | +| 12 | Queen | Royal: protect your other cards from targeting | +| 13 | King | Royal: reduce points-to-win threshold | + +### Suits (integers 0–3) + +| Suit | Integer | +|------|---------| +| Clubs | 0 (weakest for scuttle tiebreak) | +| Diamonds | 1 | +| Hearts | 2 | +| Spades | 3 (strongest) | + +### Card representation in code + +```js +// In game store / API responses +{ rank: 7, suit: 0 } // 7 of Clubs + +// In GameCard class (src/stores/game.js) +card.name // "7♣️" + +// In Cypress selectors +'[data-player-hand-card=7_0]' // rank_suit +``` + +## Locations + +| Term | Meaning | Selector location | +|------|---------|-------------------| +| `hand` | Cards in a player's hand | `data-player-hand-card` / `data-opponent-hand-card` | +| `points` | Cards played for points | `data-player-point-card` / `data-opponent-point-card` | +| `faceCards` | Royals and glasses eights in play | `data-player-face-card` / `data-opponent-face-card` | +| `scrap` | Scrapped cards pile | — | + +## Actions + +| Term | API slug | When | +|------|----------|------| +| Draw | `draw` | Take top deck card | +| Play for points | `points` | Play Ace–Ten to own field | +| Scuttle | `scuttle` | Capture opponent's lower-ranked point card | +| Play royal/glasses eight | `faceCard` | Play J/Q/K/8 to own field | +| Play one-off | `untargetedOneOff` or `targetedOneOff` | Play A–7 or 9 to scrap pile | +| Jack (steal) | `jack` | Play Jack onto opponent's point card | +| Counter | `counter` | Play Two in response to opponent's one-off | +| Resolve | `resolve` | Resolve a stacked counter chain | + +## Win conditions + +- Default: 21+ points wins. +- With Kings: 1 King → 14pts, 2 Kings → 10pts, 3 Kings → 5pts, 4 Kings → 0pts (instant win on play). +- Three consecutive passes → stalemate. + +## Socket events + +Game state is broadcast per-player via `sails.helpers.broadcastGameEvent(gameId, payload)` or `sails.helpers.gameStates.publishGameState()`. The `change` field in the payload identifies the event type (e.g., `'rematch'`, `'newGameForRematch'`). + +## Key files + +| File | Purpose | +|------|---------| +| `utils/MoveType.json` | Enum of all move type strings | +| `utils/GameStatus.json` | Enum: `CREATED`, `STARTED`, `FINISHED` | +| `utils/GamePhase.json` | Enum for game phase tracking | +| `src/stores/game.js` | Pinia store: full game state, socket event handlers | +| `api/helpers/broadcast-game-event.js` | Broadcasts payload to all game rooms | +| `docs/game-rules.md` | Complete rules for human reference | diff --git a/.agents/docs/github.md b/.agents/docs/github.md new file mode 100644 index 000000000..87d8cd5f2 --- /dev/null +++ b/.agents/docs/github.md @@ -0,0 +1,55 @@ +# GitHub Workflow + +## Branch naming + +From `docs/CONTRIBUTING.md`: + +- Features: `feature/[issue-number-or-description]` +- Bug fixes: `bug/[issue-number-or-description]` + +## Pull requests + +### Version label (required) + +Every PR merged to `main` must have exactly one version label. The label drives the automated version bump via GitHub Actions (`bump-version.yml`): + +| Label | Effect | +|-------|--------| +| `patch-version` | `x.y.Z → x.y.Z+1` | +| `minor-version` | `x.y.z → x.Y+1.0` | +| `major-version` | `x.y.z → X+1.0.0` | + +The label is applied by the core team, not the contributor. However, the PR author should suggest the appropriate level in the PR description. + +Do not manually bump `package.json` — CI commits the bump after the PR merges. + +### PR template + +Fill the template completely. Link the PR to its issue with `Closes #N` in the body. + +### Draft PRs + +CI checks are skipped for draft PRs (the `draft` job exits 1 immediately). Convert to "Ready for review" only when lint and unit tests pass locally. + +### Automated version flow + +1. PR merges to `main` → "Prepare Version Bump" workflow fires. +2. "Prepare Version Bump" reads the version label, writes `version.txt` artifact. +3. "Bump Version" workflow reads artifact, runs `npm version `, commits, tags, pushes to `main`, creates GitHub release. + +## Labels + +Version labels: `patch-version`, `minor-version`, `major-version` + +Other common labels are for categorization (`bug`, `enhancement`, `documentation`). These do not affect versioning. + +## `gh` CLI usage + +All GitHub operations use the `gh` CLI. Do not add Octokit or other GitHub libraries. + +```bash +gh pr create --title "..." --body "..." +gh pr list --state open +gh pr view +gh label list +``` diff --git a/.agents/docs/sails-patterns.md b/.agents/docs/sails-patterns.md new file mode 100644 index 000000000..d34bf5be2 --- /dev/null +++ b/.agents/docs/sails-patterns.md @@ -0,0 +1,168 @@ +# Sails.js Patterns + +Detailed reference for backend patterns in Cuttle. See `ctl-sails-patterns` skill for quick lookups. + +## Controller actions + +File: `api/controllers//.js` + +```js +module.exports = async function (req, res) { + let game; + try { + const { usr: userId } = req.session; + let { gameId } = req.params; + gameId = Number(gameId); + const { someField } = req.body; + + game = await sails.helpers.lockGame(gameId); + + // ... business logic ... + + await sails.helpers.unlockGame(game.lock); + return res.ok({ result }); + } catch (err) { + try { + await sails.helpers.unlockGame(game.lock); + } catch (_) {} + + const message = err.raw?.message ?? err; + switch (err?.code) { + case CustomErrorType.FORBIDDEN: + return res.forbidden({ message }); + default: + return res.serverError({ message }); + } + } +}; +``` + +Canonical example: `api/controllers/game/rematch.js` + +## Helpers + +File: `api/helpers/.js` + +```js +module.exports = { + friendlyName: 'Helper name', + description: 'What it does', + inputs: { + gameId: { type: 'number', required: true }, + payload: { type: 'ref', required: true }, + }, + sync: true, // omit for async helpers + fn: ({ gameId, payload }, exits) => { + try { + // ... logic ... + return exits.success(result); + } catch (err) { + return exits.error(err); + } + }, +}; +``` + +Canonical example: `api/helpers/broadcast-game-event.js` + +### Calling helpers + +Cuttle uses positional calling (not `.with()`): + +```js +// Positional (project convention) +sails.helpers.broadcastGameEvent(gameId, payload); +await sails.helpers.lockGame(oldGameId); + +// Named (.with()) — valid but not used in this codebase +await sails.helpers.lockGame.with({ gameId: oldGameId }); +``` + +For async helpers, `await` the call. For `sync: true` helpers, no `await`. + +## Policies + +File: `api/policies/.js` + +```js +module.exports = function (req, res, next) { + const { session } = req; + const userIsValid = session.usr && typeof session.usr === 'number'; + if (session.loggedIn && userIsValid) { + return next(); + } + return res.status(401).json({ message: 'Please log in and try again' }); +}; +``` + +Canonical example: `api/policies/isLoggedIn.js` + +Policy chains are configured in `config/policies.js`. Policies run in order before the controller. + +## Models + +File: `api/models/.js` + +```js +module.exports = { + attributes: { + name: { type: 'string', required: true }, + status: { type: 'number', defaultsTo: 0 }, + p0: { model: 'user' }, + p1: { model: 'user' }, + }, + // Lifecycle callbacks (optional) + beforeCreate(values, proceed) { return proceed(); }, +}; +``` + +## Socket broadcasting + +Two patterns: + +**Symmetric broadcast** (same payload to all rooms): +```js +sails.helpers.broadcastGameEvent(gameId, payload); +// broadcasts to: game__p0, game__p1, game__spectator +``` + +**Asymmetric broadcast** (different view per player, hides hidden info): +```js +const { p0State, p1State, spectatorState } = await sails.helpers.gameStates.createSocketEvents(game, fullGame); +sails.sockets.broadcast(`game_${gameId}_p0`, 'game', p0State); +sails.sockets.broadcast(`game_${gameId}_p1`, 'game', p1State); +sails.sockets.broadcast(`game_${gameId}_spectator`, 'game', spectatorState); +``` + +## Routes + +`config/routes.js` maps HTTP verbs + paths to controller actions: + +```js +'POST /api/game/:gameId/move': { action: 'game/play-move' }, +'GET /api/game/:gameId': { action: 'game/get-game' }, +``` + +## Error types + +| Class | File | HTTP code | +|-------|------|-----------| +| `ForbiddenError` | `api/errors/forbiddenError.js` | 403 | +| `CustomErrorType` | `api/errors/customErrorType.js` | varies | + +```js +const ForbiddenError = require('../../errors/forbiddenError'); +throw new ForbiddenError('You are not a player in this game!'); +``` + +## Locking pattern + +Games use an advisory lock to prevent concurrent mutations: + +```js +game = await sails.helpers.lockGame(gameId); +// ... make changes ... +await sails.helpers.unlockGame(game.lock); +``` + +Always unlock in the `catch` block too (swallow the unlock error, then handle the original error). diff --git a/.agents/docs/skill-conventions.md b/.agents/docs/skill-conventions.md new file mode 100644 index 000000000..66b45bd2e --- /dev/null +++ b/.agents/docs/skill-conventions.md @@ -0,0 +1,41 @@ +# Skill Conventions + +## Naming + +- Prefix: `ctl-` for all Cuttle skills. +- Kebab-case after the prefix: `ctl-game-domain`, `ctl-sails-patterns`. +- Verb-first for workflow skills: `ctl-git-commit`, `ctl-plan-work`. +- Noun-first for reference/lookup skills: `ctl-game-domain`, `ctl-test-patterns`. + +## File structure + +Each skill lives at `skills/ctl-/SKILL.md`. + +Required frontmatter: + +```yaml +--- +name: ctl- +description: "" +model: inherit +allowed-tools: Read, Grep, Glob, Bash +--- +``` + +- `description` is what the dispatcher reads to decide whether to route here. Start with the trigger phrase, not a generic label. +- `model: inherit` defers to the session model. Override only when the skill requires a specific capability. +- `allowed-tools` — list only what the skill actually needs. + +## Size cap + +~500 lines per SKILL.md. If a skill grows past this, move detailed examples to `docs/` and link from the skill with a one-liner. + +## Trigger writing + +Good: `"Invoke on 'where does X live', 'find existing pattern', 'how do I discover'. Glob/grep recipes for finding code by area."` + +Bad: `"Helps with discovery"` — not actionable for a dispatcher. + +## Subagent definitions + +Reviewer subagents live in `agents/ctl-.md`. Use the same frontmatter shape. Reviewers always output a structured report: verdict, evidence, line references, recommended action. diff --git a/.agents/docs/testing.md b/.agents/docs/testing.md new file mode 100644 index 000000000..7bf7204d4 --- /dev/null +++ b/.agents/docs/testing.md @@ -0,0 +1,94 @@ +# Testing + +Cuttle uses two test frameworks: + +- **Cypress** for end-to-end (E2E) tests in `tests/e2e/` +- **Vitest** for unit tests in `tests/unit/` + +## Vitest — dual config + +| Script | Config | Scope | +|--------|--------|-------| +| `npm run test:unit:client` | `vitest.config.mjs` | `tests/unit/**/*.spec.{js,ts}` (excludes sails) | +| `npm run test:unit:sails` | `tests/unit/vitest-sails.config.mjs` | `tests/unit/specs/sails/**/*` | +| `npm run test:unit` | runs both above | full unit suite | +| `npm run test:agents` | `.agents/tools/vitest.config.mjs` | `.agents/tools/__tests__/**/*.spec.mjs` | + +Sails tests require a running Sails instance (setup in `tests/unit/setup-sails.vitest.js`). They run sequentially (`maxWorkers: 1, isolate: false`). + +Client tests use `environment: 'node'` and can run in parallel. + +## Cypress — E2E helpers + +Helpers are in `tests/e2e/support/helpers.js` and `commands.js`. + +### `cy.setupGameAsP0()` / `cy.setupGameAsP1()` + +Runs in `beforeEach`. Automatically: +1. Signs up two accounts. +2. Creates and joins a game as both players. +3. Readies both players and waits for the game to load. + +### `cy.loadGameFixture(fixture)` + +Loads a specific game state. All fields are optional. + +```js +cy.loadGameFixture({ + p0Hand: [Card.ACE_OF_CLUBS, Card.SEVEN_OF_CLUBS], + p0Points: [Card.TWO_OF_CLUBS], + p0FaceCards: [Card.KING_OF_SPADES], + p1Hand: [Card.TEN_OF_CLUBS], + p1Points: [Card.SIX_OF_HEARTS], + p1FaceCards: [Card.QUEEN_OF_HEARTS], + topCard: Card.FIVE_OF_DIAMONDS, + secondCard: Card.EIGHT_OF_SPADES, + scrap: [Card.TWO_OF_HEARTS], +}); +``` + +### `assertGameState(playerNum, fixture)` + +Asserts the full game state from a player's perspective. `playerNum` is `0` or `1`. + +```js +assertGameState(0, { + p0Hand: [Card.ACE_OF_CLUBS], + p0Points: [Card.TWO_OF_CLUBS, Card.SEVEN_OF_CLUBS], + ... +}); +``` + +## Card data selectors + +Format: `data---card=_` + +- ``: `player` (the current player) or `opponent` +- ``: `hand`, `point`, `face-card` +- ``: integer 1–13 +- ``: integer 0–3 (Clubs=0, Diamonds=1, Hearts=2, Spades=3) + +```js +cy.get('[data-player-hand-card=7_0]').click(); // 7 of Clubs in hand +cy.get('[data-move-choice=scuttle]').click(); // choose scuttle +cy.get('[data-opponent-point-card=6_2]').click(); // opponent's 6 of Hearts +``` + +## Move data selectors + +Format: `data-move-choice=` + +Move names match the API slug: `points`, `scuttle`, `untargetedOneOff`, `targetedOneOff`, `jack`, `faceCard`, `draw`. + +## Test structure + +E2E specs are in `tests/e2e/specs/`: + +- `in-game/` — in-game moves and interactions +- `out-of-game/` — lobby, stats, profile +- `playground/` — development/debug specs + +Unit specs are in `tests/unit/specs/`: + +- `*.spec.js` — client-side (Vue, Pinia, utilities) +- `sails/` — server-side Sails.js logic diff --git a/.agents/docs/vue-patterns.md b/.agents/docs/vue-patterns.md new file mode 100644 index 000000000..f4295bae3 --- /dev/null +++ b/.agents/docs/vue-patterns.md @@ -0,0 +1,135 @@ +# Vue Patterns + +Detailed reference for frontend patterns in Cuttle. See `ctl-vue-patterns` skill for quick lookups. + +## Component structure + +Cuttle uses Vue 3 with ` + + +``` + +## File locations + +| Type | Path | +|------|------| +| Route entry point | `src/routes//View.vue` | +| Page-specific components | `src/routes//components/` | +| Shared components | `src/components/` | +| Stores | `src/stores/` | +| Router | `src/router.js` | + +## Pinia stores + +All stores use the composition API form (not options API): + +```js +import { ref, computed } from 'vue'; +import { defineStore } from 'pinia'; + +export const useGameStore = defineStore('game', () => { + // State + const id = ref(null); + const status = ref(null); + + // Getters + const isStarted = computed(() => status.value === GameStatus.STARTED); + + // Actions + function setGame(gameData) { + id.value = gameData.id; + status.value = gameData.status; + } + + return { id, status, isStarted, setGame }; +}); +``` + +Canonical example: `src/stores/game.js` + +### Store discovery + +```bash +grep -r "defineStore" src/stores/ +``` + +## Imports and aliases + +| Alias | Resolves to | +|-------|-------------| +| `@` | `src/` | +| `_` | project root | + +```js +import { useGameStore } from '@/stores/game'; +import MoveType from '../../utils/MoveType.json'; // relative from src/ +import { version } from '_/package.json'; // from root +``` + +## Vuetify usage + +- Use Vuetify components (`v-btn`, `v-dialog`, `v-card`, `v-list`, etc.) for all UI. +- Do not write raw HTML buttons or modals. +- Look at `src/components/BaseDialog.vue` for dialog patterns. +- Styles use `sass/variables.scss` for design tokens. + +## Internationalization + +Cuttle uses `vue-i18n`. All user-visible strings go through `$t('key')`: + +```vue + +``` + +Translation files are in `src/i18n/`. + +## Socket connection + +The Sails socket client is initialized in `src/plugins/sails.js` and imported as `io`: + +```js +import { io } from '@/plugins/sails.js'; +``` + +In-game socket events are handled in `src/plugins/sockets/inGameEvents.js`. + +## Router navigation + +```js +import { useRouter } from 'vue-router'; +const router = useRouter(); +router.push({ name: 'game', params: { gameId } }); +``` + +Route names are defined in `src/router.js`. + +## Key constants + +| File | Contents | +|------|---------| +| `utils/MoveType.json` | Move type strings | +| `utils/GameStatus.json` | Game status enums | +| `utils/GamePhase.json` | Game phase enums | +| `utils/local-storage-utils.js` | LocalStorage key constants | diff --git a/.agents/skills/ctl-code-review/SKILL.md b/.agents/skills/ctl-code-review/SKILL.md new file mode 100644 index 000000000..540cec403 --- /dev/null +++ b/.agents/skills/ctl-code-review/SKILL.md @@ -0,0 +1,102 @@ +--- +name: ctl-code-review +description: "Review code changes against Cuttle's discover-plan-execute workflow, safety rules, and AGENTS.md conventions. Use on 'review my changes', 'check this diff', 'review this PR', '/ctl-code-review'. Dispatches to subagents for security, architecture, and performance." +model: inherit +allowed-tools: Read, Grep, Glob, Bash, Agent +--- + +# Code Review + +Compliance review for Cuttle. Checks AGENTS.md rules, then dispatches to specialist subagents. + +## Input + +Run against staged diff, a branch, or a PR: + +```bash +# Current branch vs main +git diff main..HEAD + +# Specific PR +gh pr diff +``` + +## Phase 1: Automated checks + +Before reviewing code, verify the basics passed: + +```bash +npm run lint +npm run test:unit +``` + +If either fails, report which checks failed. Do not proceed with review until they're green. + +## Phase 2: AGENTS.md compliance + +Check against AGENTS.md: + +1. **Discovery first** — Were patterns discovered before code was written? Look for evidence of glob/grep discovery in the PR description or commit messages. +2. **Safety rules** — No hardcoded secrets. Session validation present on game-mutating routes. Policies applied (`config/policies.js`). +3. **Pattern consistency** — Controller follows the lock/unlock/catch pattern. Helper follows the `inputs`/`fn`/`exits` structure. Vue SFC uses ` + + +``` + +Always use ` + +``` + +Do not hardcode English strings in templates. + +## Vuetify components + +Use Vuetify for all UI elements. Do not write raw HTML buttons/modals/inputs. + +Common components: `v-btn`, `v-card`, `v-dialog`, `v-list`, `v-list-item`, `v-icon`, `v-text-field`, `v-select`, `v-snackbar`, `v-tooltip`. + +## Router navigation + +```js +import { useRouter, useRoute } from 'vue-router'; +const router = useRouter(); +const route = useRoute(); + +// Navigate +router.push({ name: 'game', params: { gameId: id } }); + +// Read params +const gameId = route.params.gameId; +``` + +Route names are in `src/router.js`. + +## Socket connection + +```js +import { io } from '@/plugins/sails.js'; +// io is already configured — call methods directly +io.socket.on('game', (data) => { /* handle */ }); +io.socket.get('/api/game', (body, res) => { /* handle */ }); +``` + +In-game events are handled in `src/plugins/sockets/inGameEvents.js`. + +## Security rules + +- Never use `v-html` with user-supplied content. +- Never expose server-only data (session secrets, DB credentials) in frontend code. + +## Full reference + +`.agents/docs/vue-patterns.md` diff --git a/.agents/tools/__tests__/git-guardrails.spec.mjs b/.agents/tools/__tests__/git-guardrails.spec.mjs new file mode 100644 index 000000000..246c105de --- /dev/null +++ b/.agents/tools/__tests__/git-guardrails.spec.mjs @@ -0,0 +1,131 @@ +import { describe, it, expect } from 'vitest'; +import { checkDestructive, isCommitOnMain, isPushOnMain, evaluate } from '../git-guardrails.mjs'; + +describe('git-guardrails', () => { + describe('checkDestructive', () => { + it('allows a clean commit command', () => { + expect(checkDestructive('git commit -m "fix: thing"')).toBeUndefined(); + }); + + it('allows git log', () => { + expect(checkDestructive('git log --oneline -10')).toBeUndefined(); + }); + + it('allows git status', () => { + expect(checkDestructive('git status --porcelain')).toBeUndefined(); + }); + + it('blocks git reset --hard', () => { + expect(checkDestructive('git reset --hard HEAD')).toMatch(/reset --hard/); + }); + + it('blocks git reset --hard in a chain', () => { + expect(checkDestructive('git fetch origin && git reset --hard origin/main')).toMatch(/reset --hard/); + }); + + it('blocks git clean -f', () => { + expect(checkDestructive('git clean -f')).toMatch(/clean/); + }); + + it('blocks git clean --force', () => { + expect(checkDestructive('git clean --force')).toMatch(/clean/); + }); + + it('allows git clean -n (dry run)', () => { + expect(checkDestructive('git clean -n')).toBeUndefined(); + }); + + it('blocks git push --force', () => { + expect(checkDestructive('git push --force')).toMatch(/force/); + }); + + it('blocks git push -f', () => { + expect(checkDestructive('git push origin main -f')).toMatch(/force/); + }); + + it('allows git push --force-with-lease', () => { + expect(checkDestructive('git push --force-with-lease')).toBeUndefined(); + }); + + it('blocks direct push to main', () => { + expect(checkDestructive('git push origin main')).toMatch(/main/); + }); + + it('allows push to feature branch', () => { + expect(checkDestructive('git push origin feat/my-feature')).toBeUndefined(); + }); + + it('blocks git checkout .', () => { + expect(checkDestructive('git checkout .')).toMatch(/checkout/); + }); + + it('blocks git checkout main', () => { + expect(checkDestructive('git checkout main')).toMatch(/main/); + }); + + it('allows non-git bash commands', () => { + expect(checkDestructive('npm run lint')).toBeUndefined(); + expect(checkDestructive('ls -la')).toBeUndefined(); + expect(checkDestructive('find . -name "*.js"')).toBeUndefined(); + }); + }); + + describe('isCommitOnMain', () => { + it('returns true for commit on main', () => { + expect(isCommitOnMain('git commit -m "fix: thing"', 'main')).toBe(true); + }); + + it('returns false for commit on feature branch', () => { + expect(isCommitOnMain('git commit -m "fix: thing"', 'feat/my-feature')).toBe(false); + }); + + it('returns false for non-commit command on main', () => { + expect(isCommitOnMain('git log --oneline', 'main')).toBe(false); + }); + + it('returns false for git -C repo commit (submodule-style)', () => { + expect(isCommitOnMain('git -C subdir commit -m "msg"', 'main')).toBe(false); + }); + }); + + describe('isPushOnMain', () => { + it('returns true for push on main', () => { + expect(isPushOnMain('git push origin feat/branch', 'main')).toBe(true); + }); + + it('returns false for push on feature branch', () => { + expect(isPushOnMain('git push origin feat/branch', 'feat/branch')).toBe(false); + }); + }); + + describe('evaluate', () => { + it('allows clean commit on feature branch', () => { + expect(evaluate('git commit -m "fix: thing"', 'feat/my-feature')).toBeUndefined(); + }); + + it('blocks commit on main', () => { + expect(evaluate('git commit -m "fix: thing"', 'main')).toMatch(/main/); + }); + + it('blocks push from main', () => { + expect(evaluate('git push origin feat/branch', 'main')).toMatch(/main/); + }); + + it('blocks destructive commands regardless of branch', () => { + expect(evaluate('git reset --hard HEAD', 'feat/my-feature')).toMatch(/reset --hard/); + }); + + it('denies empty/null command', () => { + expect(evaluate('', 'feat/my-feature')).toMatch(/unable to parse/i); + }); + + it('denies whitespace-only command', () => { + expect(evaluate(' ', 'feat/my-feature')).toMatch(/unable to parse/i); + }); + + it('allows non-git commands on main', () => { + expect(evaluate('npm run lint', 'main')).toBeUndefined(); + expect(evaluate('ls -la', 'main')).toBeUndefined(); + }); + }); +}); diff --git a/.agents/tools/git-guardrails.mjs b/.agents/tools/git-guardrails.mjs new file mode 100644 index 000000000..9a5f3c069 --- /dev/null +++ b/.agents/tools/git-guardrails.mjs @@ -0,0 +1,120 @@ +#!/usr/bin/env node +/** + * PreToolUse hook: git guardrails. + * Blocks destructive git commands and commits/pushes on main. + * + * Called via .claude/hooks/git-guardrails.sh shim. + * Logic is exported so .agents/tools/__tests__/git-guardrails.spec.mjs can test it directly. + */ + +import { execSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const deny = (reason) => + JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: reason, + }, + }); + +const CHAIN_SEP = /\s*(?:&&|\|\||;|\|)\s*/; + +const unwrapShell = (cmd) => { + const m = cmd.match(/^(?:bash|sh|zsh)\s+-c\s+(['"])([\s\S]*)\1\s*$/); + return m ? m[2] : cmd; +}; + +const segments = (cmd) => + unwrapShell(cmd) + .split(CHAIN_SEP) + .map((s) => s.trim()) + .filter(Boolean); + +export const RULES = [ + { + test: (seg) => /^git\s+reset(?:\s|$)/.test(seg) && /\s--hard(?:\s|$)/.test(seg), + reason: 'git reset --hard discards all uncommitted changes permanently. Use git stash to preserve them.', + }, + { + test: (seg) => + /^git\s+clean(?:\s|$)/.test(seg) && (/\s-[a-zA-Z]*f/.test(seg) || /--force/.test(seg)), + reason: 'git clean -f permanently deletes untracked files. List them with git clean -n first.', + }, + { + test: (seg) => + /^git\s+push(?:\s|$)/.test(seg) && + /(?:\s|^)(-f|--force)(?:\s|$)/.test(seg) && + !/--force-with-lease/.test(seg), + reason: 'git push --force overwrites remote history. Use --force-with-lease instead.', + }, + { + test: (seg) => /^git\s+push(?:\s|$)/.test(seg) && /(?:[:\s])main(?:\s|$)/.test(seg), + reason: 'Direct push to main is blocked. Open a PR instead.', + }, + { + test: (seg) => /^git\s+checkout\s+(?:--\s+)?\.(?:\s|$|["'\\])/.test(seg), + reason: 'git checkout . discards all unstaged changes. Restore specific files instead.', + }, + { + test: (seg) => /^git\s+checkout\s+main(?:\s|$)/.test(seg), + reason: 'Use `git fetch origin` and branch off `origin/main` directly. The user updates local main themselves.', + }, +]; + +export const checkDestructive = (command) => { + const segs = segments(command); + for (const rule of RULES) { + if (segs.some(rule.test)) { return rule.reason; } + } +}; + +const isBareCommit = (seg) => + /^git\s+commit(?:\s|$)/.test(seg) && !/^git\s+-C\s+\S+\s+commit(?:\s|$)/.test(seg); + +const isBarePush = (seg) => + /^git\s+push(?:\s|$)/.test(seg) && !/^git\s+-C\s+\S+\s+push(?:\s|$)/.test(seg); + +export const isCommitOnMain = (command, branch) => + branch === 'main' && segments(command).some(isBareCommit); + +export const isPushOnMain = (command, branch) => + branch === 'main' && segments(command).some(isBarePush); + +export function evaluate(command, branch) { + if (typeof command !== 'string' || !command.trim()) { + return 'Unable to parse command from tool input; denying for safety.'; + } + + const reason = checkDestructive(command); + if (reason) { return reason; } + + if (isCommitOnMain(command, branch)) { + return 'Cannot commit directly on main. Create a feature branch first: git switch -c feat/your-branch'; + } + + if (isPushOnMain(command, branch)) { + return 'Direct push from main is blocked. Push from a feature branch and open a PR.'; + } +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + try { + const { tool_input: { command } = {} } = JSON.parse(readFileSync(0, 'utf8')); + const segs = segments(command || ''); + const needsBranch = segs.some( + (s) => /^git\s+commit(?:\s|$)/.test(s) || /^git\s+push(?:\s|$)/.test(s), + ); + const branch = needsBranch + ? execSync('git branch --show-current', { encoding: 'utf8' }).trim() + : null; + const reason = evaluate(command, branch); + if (reason) { + process.stdout.write(deny(reason)); + } + } catch (e) { + process.stderr.write(`[git-guardrails] ${e.message}\n`); + } +} diff --git a/.agents/tools/vitest.config.mjs b/.agents/tools/vitest.config.mjs new file mode 100644 index 000000000..05c83cc9a --- /dev/null +++ b/.agents/tools/vitest.config.mjs @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: [ '.agents/tools/__tests__/**/*.spec.mjs' ], + environment: 'node', + globals: true, + }, +}); diff --git a/.claude/agents b/.claude/agents new file mode 120000 index 000000000..4c8a5fc93 --- /dev/null +++ b/.claude/agents @@ -0,0 +1 @@ +../.agents/agents \ No newline at end of file diff --git a/.claude/hooks/git-guardrails.sh b/.claude/hooks/git-guardrails.sh new file mode 100755 index 000000000..d1f3d5738 --- /dev/null +++ b/.claude/hooks/git-guardrails.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Thin shim — delegates to .agents/tools/git-guardrails.mjs. +# Resolves repo root via git so CWD doesn't matter. +REPO_ROOT="$(git -C "$CLAUDE_PROJECT_DIR" rev-parse --show-toplevel 2>/dev/null || echo "$CLAUDE_PROJECT_DIR")" +exec node "$REPO_ROOT/.agents/tools/git-guardrails.mjs" diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..14927866c --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,76 @@ +{ + "permissions": { + "allow": [ + "Bash(find *)", + "Bash(ls *)", + "Bash(ls)", + "Bash(grep *)", + "Bash(cat *)", + "Bash(wc *)", + "Bash(echo *)", + "Bash(node -e *)", + "Bash(node --version*)", + "Bash(git status*)", + "Bash(git log*)", + "Bash(git diff*)", + "Bash(git show*)", + "Bash(git branch*)", + "Bash(git rev-parse*)", + "Bash(git remote*)", + "Bash(git fetch*)", + "Bash(git stash list*)", + "Bash(git switch *)", + "Bash(git checkout -b *)", + "Bash(git add *)", + "Bash(git commit *)", + "Bash(npm run lint)", + "Bash(npm run lint:*)", + "Bash(npm run test:unit*)", + "Bash(npm run test:agents*)", + "Bash(npm run build*)", + "Bash(npm run start:*)", + "Bash(npm run:*)", + "Bash(npm view *)", + "Bash(npm info *)", + "Bash(gh pr list*)", + "Bash(gh pr view*)", + "Bash(gh pr diff*)", + "Bash(gh pr checks*)", + "Bash(gh pr create*)", + "Bash(gh issue list*)", + "Bash(gh issue view*)", + "Bash(gh run list*)", + "Bash(gh run view*)", + "Bash(gh label list*)", + "Bash(gh repo view*)", + "Bash(gh api *)", + "Bash(gh status*)", + "WebSearch", + "WebFetch(domain:vitejs.dev)", + "WebFetch(domain:vite.dev)", + "WebFetch(domain:vitest.dev)", + "WebFetch(domain:docs.cypress.io)", + "WebFetch(domain:devtools.vuejs.org)", + "WebFetch(domain:sailsjs.com)", + "WebFetch(domain:vuejs.org)", + "WebFetch(domain:pinia.vuejs.org)", + "WebFetch(domain:github.com)", + "WebFetch(domain:nodejs.org)", + "WebFetch(domain:developer.mozilla.org)" + ], + "deny": [] + }, + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/git-guardrails.sh\"" + } + ] + } + ] + } +} diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 000000000..2b7a412b8 --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 000000000..d29503652 --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,8 @@ +project_doc_fallback_filenames = ["AGENTS.md"] +project_doc_max_bytes = 65536 + +[features] +hooks = false + +[shell_environment_policy] +inherit = "core" diff --git a/.gemini/agents b/.gemini/agents new file mode 120000 index 000000000..4c8a5fc93 --- /dev/null +++ b/.gemini/agents @@ -0,0 +1 @@ +../.agents/agents \ No newline at end of file diff --git a/.gemini/settings.json b/.gemini/settings.json new file mode 100644 index 000000000..1e10384ff --- /dev/null +++ b/.gemini/settings.json @@ -0,0 +1,26 @@ +{ + "context": { + "fileName": ["AGENTS.md"] + }, + "experimental": { + "enableAgents": true + }, + "general": { + "plan": { + "enabled": true + } + }, + "hooks": { + "BeforeTool": [ + { + "matcher": "^run_shell_command$", + "hooks": [ + { + "type": "command", + "command": "node \"$GEMINI_PROJECT_DIR/.agents/tools/git-guardrails.mjs\"" + } + ] + } + ] + } +} diff --git a/.gemini/skills b/.gemini/skills new file mode 120000 index 000000000..2b7a412b8 --- /dev/null +++ b/.gemini/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5e3ad3bf1..7d34794da 100644 --- a/.gitignore +++ b/.gitignore @@ -150,7 +150,11 @@ storybook-static # # Files generated by AI assistants ################################################ -.claude* +# Personal/local AI settings (not committed) +.claude/settings.local.json .cursor* -.gemini* +# Track .gemini and .codex config but not local overrides +.gemini/settings.local.json +.gemini-clipboard/ +.codex/config.local.toml diff --git a/package.json b/package.json index 746ea2547..cab6ca64f 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "test:unit:client": "NODE_ENV=development vitest run", "test:unit:sails": "NODE_ENV=development vitest run --config tests/unit/vitest-sails.config.mjs", "test:unit": "npm run test:unit:client && npm run test:unit:sails", + "test:agents": "vitest run --config .agents/tools/vitest.config.mjs", "version:patch": "npm version patch", "version:minor": "npm version minor", "version:major": "npm version major",