Repo-tracked guard for the Personas checkout: denies shell commands this machine already paid for (shared-checkout git sweeps, bare cargo test, whole vitest…

A local-first desktop app for building AI agents, connecting them to your tools, running them on triggers, and putting teams of them to work on real software projects. Credentials are encrypted, state lives in SQLite on your machine, and nothing leaves it unless you send it.
Built with Tauri 2 (Rust) and React 19 (TypeScript).
Jump to: What it does · Tour of the app · How a run works · Quickstart · Docs · Contributing · Changelog
Personas is built around one loop. You define agents (personas), give them connections to the services they need, let triggers run them, and watch and govern what they do. On top of that loop sit three larger things: a way to point teams of agents at your own codebases, three built-in Companions that look after the app itself, and plugins for adjacent jobs.
| If you want to… | Go to | Short version |
|---|---|---|
| Create an agent and tune its prompt, tools and model | Agents | Describe it in plain language; the app builds the spec |
| Give agents access to Slack, GitHub, a database… | Connections | Encrypted vault, 130+ built-in connectors |
| Run agents on a schedule, a webhook or an event | Events & Schedules | Ten ways a run can start, plus chains between agents |
| See what agents did, what it cost, and what needs you | Overview | Dashboard, approvals, incidents, memory |
| Point agents at your repos and steer them by goals and KPIs | Projects | Context map, goals, KPIs, task runner, fleet |
| Get help running all of the above | Companions | Athena, Overseer, Curator |
| Extend it | Plugins | Dev Tools, Obsidian Brain, Drive, Twin |
Why local-first? Your personas, prompts and credentials never leave your machine unless you explicitly send them. Outbound traffic goes only to the AI providers and third-party services you configure. No cloud account is required.
The sidebar has two levels: an icon rail of top-level sections and a panel of grouped pages inside each section. The sections below follow the rail from top to bottom, regrouped by the job each one does. Which sections you see depends on your interface tier and on whether you run a dev build.
Home is the entry point: a cockpit for what needs attention, a learning area, and guided tours that walk through first-run setup (first agent, first credential, first execution). Simple Mode is a reduced interface for non-technical users; see tiers.
Docs: home · onboarding · simple mode
Agents is where personas are created and edited. You describe what you want and the build session asks clarifying questions, then resolves a spec you can refine.
Docs: personas · templates · recipes · operations hub
Connections is the credential vault and everything around it.
Docs: connections · integrations
Events is the routing layer. Schedules (a calendar overlay from the title bar) holds cron jobs.
localhost:9420), an event on the bus, a polling check, a chain from another persona, a file watcher, the clipboard, app focus, or a composite of theseDocs: events & triggers · schedules · execution entry points · automation tools
Overview answers "what are my agents doing, and what needs me?"
| Group | Pages |
|---|---|
| Monitoring | Dashboard (Mission control: vitals, status monitor, leaderboard, self-healing), Activity (every execution with model, tokens and cost), Events |
| Operations | Approvals (manual-review queue), Incidents (one triage inbox over seven failure streams), Observability (alerts, health issues, trends, IPC performance), Messages |
| Memory | Memories (what agents remember, with dispute and recall controls) and Graph |
Also here: the Director, a built-in coach that scores each persona and suggests improvements, and the Persona Monitor, a full-screen grid of the whole fleet.
The runtime protects itself: per-run, daily and monthly budget limits, circuit breakers that disable an unhealthy provider, and a self-healing engine that detects and recovers from transient failures. Execution traces record spans, durations and cost.
Docs: overview · execution · persona monitor · Director
Projects turns the app from "run some agents" into "run a team on my repository." Register a codebase, map it, define what success means, and let agents work toward it.
| Group | What it is |
|---|---|
| Teams | Assemble a team from a preset, give it a goal, decompose, assign and run. Moderated multi-persona deliberations end in an approved assignment |
| Goals / KPIs | KPIs define outcomes; a KPI off its critical line derives goals; goals are what teams advance |
| Development | Lifecycle (each project's practice: Solo or Team), Factory (project readiness and KPI matrix), Contest (run several model seats on one brief and keep the winner), Mastermind (portfolio view across projects), Features, and Studio (an app builder, dev builds only) |
| Browser | Lets Athena and your personas open and control allow-listed web pages, with each write passing the approval orb |
The supporting tooling lives in Plugins → Dev Tools: Context Map (scan a repo into groups and contexts), Task Runner, Fleet (observe and steer many Claude Code CLI sessions in one window; dev builds), Workspaces (group projects into an org), and Skills. A Notepad (footer toggle) turns notes into dispatched work or goals.
An App Master is the accountable agent for one registered project. It can be adopted headlessly and driven from a terminal; see Headless bridge.
Docs: Teams & orchestration · Dev Tools · notepad · browser
Companions are three built-in agents, distinct from the personas you create. Each has its own pages and an on/off switch.
| Companion | Role |
|---|---|
| Athena | The assistant. Plans, builds, remembers and answers across the whole app, by chat or voice, with an orb overlay and guided walkthroughs |
| Overseer | Keeper of the fleet. Keeps starred agents running without error and worth what they cost; audits each wave of work. Needs at least one starred agent |
| Curator | Keeper of the knowledge registry. Keeps a mapped registry current and applied in the projects that subscribe to it. Needs a workspace with a registry |
Docs: companions · Athena · Curator
Enable plugins from Plugins. Each adds a group of pages to the sidebar.
| Plugin | What it gives you |
|---|---|
| Dev Tools | The codebase tooling described in section 6 |
| Obsidian Brain | A two-way bridge between agent memory and an Obsidian vault, as plain markdown, optionally backed up to your own Google Drive |
| Drive | A sandboxed local file manager for what agents, OCR, signing and exports produce |
| Twin | A digital identity (bio, per-channel tone, voice, curated memory) that your agents adopt so they speak as you |
Also in the tree: a built-in Scraper that emits change events onto the bus (dev builds), GitLab CI/CD export and deployment, and the Cloud orchestrator for pushing personas to a remote runtime.
Docs: plugins · Twin · Drive · Obsidian Brain · deployment · GitLab
Settings groups into General (account, appearance, data portability, radio, notifications), Connect (API keys for inbound MCP/HTTP access, paired devices, network sharing) and LLM (engine, custom models, limits). Data export and import can carry personas, connectors, twins and Athena's memory between machines; Sharing covers bundles, personas:// deep links and peer-to-peer transfer (needs a full build).
The desktop shell adds a system tray with scheduler pause/resume, native notifications, window-state persistence, a single-instance lock with deep-link routing, an in-footer Radio, and an auto-updater via GitHub Releases.
Docs: settings · sharing · radio · navigation
One codebase, three audiences. Each tier is a strict superset of the one before.
| Tier | Label in the app | Audience | Adds |
|---|---|---|---|
starter | Simple | Non-technical users | Agents, connections, messages, templates |
team (default) | Power | Teams and enterprises | Events, projects and teams, deployment, analytics, scheduling, databases, plugins, companions |
builder | (dev builds) | Developers | Dev-only surfaces, raw JSON editing |
Switch at runtime in Settings → Appearance → Interface Mode. Builder is a compile-time gate with no runtime path to it. To build a tier-locked bundle, see Build tiers.
You define a persona A trigger fires The provider returns
(prompt + tools + creds) (cron / webhook / event / structured output
| chain / file / clipboard) |
v | v
+--------------+ +------v-------+ +-------------+
| SQLite DB | ------------> | Rust engine | -------> | Post-process|
| (encrypted | load | (Tokio async)| parse | (healing, |
| credentials)| | spawns CLI | | chain, |
+--------------+ +--------------+ | notify) |
| +-------------+
Credentials injected
as env vars, scrubbed
after the run
| Data | Where it goes | Encryption |
|---|---|---|
| Persona prompts and config | Local SQLite | Plaintext (not secrets) |
| Credential values | Local SQLite | AES-256-GCM (key in OS keyring) |
| Execution output | Local SQLite + log files | Plaintext |
| AI provider requests | Provider API (HTTPS) | TLS in transit |
| Connector API calls | Third-party service (HTTPS) | TLS in transit |
| Error reports (opt-in) | Sentry | PII stripped before send |
| Layer | Technology |
|---|---|
| Desktop runtime | Tauri 2 |
| Backend | Rust, Tokio, SQLite (r2d2 pool) |
| Frontend | React 19, TypeScript 6, Vite 8 |
| Styling | Tailwind CSS 4 |
| State | Zustand 5 |
| Animation | Framer Motion |
| Visualization | Recharts, React Flow (XYFlow) |
| Encryption | AES-GCM, OS keyring |
| Networking | Reqwest (rustls-tls) |
| Dependency | Version | Check command |
|---|---|---|
| Node.js | >= 20 | node --version |
| Rust | via rustup; the repo pins its toolchain in rust-toolchain.toml | rustc --version |
| WebView2 Runtime | bundled with Windows 10+ | none |
| C++ Build Tools | MSVC (Visual Studio) | none |
winget install OpenJS.NodeJS.LTS
cargo and rustc are on your PATH: winget install Rustlang.Rustup
rustc --version
cargo --version
ring crypto crate): winget install LLVM.LLVM
Make sure C:\Program Files\LLVM\bin is on your PATH, and restart your terminal.
cl.exe, INCLUDE and LIB are set, or load the environment into the current session: & "C:\Program Files\Microsoft Visual Studio\2022\Community\Common7\Tools\Launch-VsDevShell.ps1" -Arch arm64
xcode-select --install
brew install node
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Restart your terminal, then verify with node --version && rustc --version && cargo --version.
sudo apt update
sudo apt install -y libwebkit2gtk-4.1-dev build-essential curl wget file \
libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev
# Node.js via NodeSource
curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -
sudo apt install -y nodejs
# Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source "$HOME/.cargo/env"
npm install # 1. frontend dependencies
npm run tauri:dev:lite # 2. daily-driver dev mode (desktop features, no ML/P2P)
On Windows, the helper script avoids the usual startup problems (clang missing from PATH, a stale app process, a stale Vite process):
.\scripts\desktop-dev.ps1 -Restart # run the app
.\scripts\desktop-dev.ps1 -CheckOnly # preflight only
The first run compiles all Rust dependencies, which takes several minutes. Later builds are incremental.
Lite or full? Use tauri:dev:lite for almost everything: UI, IPC wiring, schema, triggers, recipes, observability. Switch to npm run tauri:dev (full) only when you are working on the vector knowledge base, embeddings, ONNX inference, or P2P, which are compiled only in the full build.
To build a tier-locked variant (higher-tier features are tree-shaken from the bundle):
npm run build:starter # Simple tier only
npm run build:team # Simple + Power
npm run build:builder # everything (default)
For desktop installers:
VITE_APP_TIER=starter npm run tauri build # Simple installer
VITE_APP_TIER=team npm run tauri build # Power installer
npm run tauri build # Builder installer (default)
When VITE_APP_TIER is unset, the build includes every tier and users switch at runtime.
| Command | What it does |
|---|---|
npm run dev | Vite dev server only (port 1420), frontend without Tauri |
npm run tauri:dev / :lite | Tauri dev mode, full or lite feature set |
npm run tauri:dev:test | Lite dev mode plus the test-automation HTTP server (:17320) and DevInspector |
npm run build | TypeScript check + Vite production build |
npm run tauri:build | Full installer (all targets, desktop-full) |
npm run tauri:build:lite | NSIS-only installer with desktop features |
npm run tauri:build:stable | NSIS + MSI installer for a Windows release |
npm run check | The full pre-push gate chain: project checks, type check, ESLint and the golden-path census |
npm run gate | Fast per-commit check (tsc, changed-file ESLint, census) from a warm local daemon |
npm run test | Vitest |
npm run test:rust | Rust unit tests (use this on Windows rather than raw cargo test) |
npm run check:tiers | Compile-check all three frontend tiers |
npm run analyze / check:budget | Bundle treemap / chunk-size budget |
npm run clean:rust / clean:ort | Full Rust wipe / surgical ONNX Runtime cache clean |
Build internals (ARM64 vs x64 on Windows, the codegen pipeline, profiles, ONNX bundling) are in docs/development/build.md; Android setup is in docs/development/android-build.md.
A dev-only overlay for grabbing a component's src/.../File.tsx:line and pasting it into an AI coding CLI. Off by default and never present in production builds.
npm run tauri:dev:test # full app + test bridge, source mapping on
npm run dev:inspect # frontend only, faster iteration
In the app press ; then i to arm it. Hover highlights an element and pins a path chip; click copies the call-site path, Alt+click copies the element, Esc exits. See docs/development/dev-inspector.md.
The running app exposes a loopback dev-tools bridge for creating projects, scanning codebases, adopting an App Master and writing results back, with no GUI. The full route reference is in docs/development/headless-bridge.md.
personas/
├── src/ # Frontend (React + TypeScript)
│ ├── api/ # Tauri IPC bridge (typed command wrappers)
│ ├── features/ # One folder per product surface
│ │ ├── agents/ personas/ # persona builder and editor
│ │ ├── templates/ # templates, recipes, design reviews
│ │ ├── vault/ # credentials and connectors (the "Connections" section)
│ │ ├── triggers/ schedules/# events, webhooks, cron
│ │ ├── overview/ # observability, approvals, incidents, memory
│ │ ├── teams/ # projects, teams, goals, KPIs, factory, contest
│ │ ├── companions/ # Athena, Overseer, Curator
│ │ ├── plugins/ # dev-tools, obsidian-brain, drive, twin, fleet, gitlab, radio
│ │ ├── browser/ studio/ notepad/ scraper/ cloud/ fleet/
│ │ └── home/ onboarding/ settings/ shared/
│ ├── i18n/ # Localization (locales, codegen, useTranslation)
│ ├── lib/ # Business logic; bindings/ holds Rust-generated TS types
│ ├── stores/ # Zustand state (slice pattern)
│ └── styles/ # Global CSS and themes
│
├── src-tauri/ # Backend (Rust)
│ ├── src/commands/ # Tauri command handlers (the IPC surface)
│ ├── src/engine/ # Execution engine, scheduler, healing, event bus
│ ├── db/ # SQLite schema, migrations, repos (extracted crate)
│ └── tauri.conf.json
│
├── docs/ # Feature, architecture, development docs
├── scripts/ # Codegen, census, i18n, templates, connector catalog
├── cloud-worker/ supabase/ sdk/ evals/ tests/
└── package.json
The system map, with the runtime layers and which command module owns what, is docs/architecture/overview.md and docs/architecture/codebase-map.md.
The app ships in 14 languages: English (source of truth) plus Arabic, Bengali, Czech, German, Spanish, French, Hindi, Indonesian, Japanese, Korean, Russian, Vietnamese and Simplified Chinese.
src/i18n/locales/en.json is the only file you edit for new strings. Each section of every locale is lazy-loaded as its own chunk, and a missing key falls back to English at runtime.src/i18n/section-locales/ and src/i18n/generated/types.ts are generated on predev / prebuild. Do not edit them by hand.hooks/register.ts 104 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { nudge, owedDocs, relativeTo } from './docsync.mjs'
4import { helpTargets, verdict } from './rules.mjs'
5
6type DocEntry = { doc: string; sourceGlobs?: string[]; onboardingFlows?: string[]; marketingModule?: string }
7
8type SyncState = {
9 owedBy: Map<string, ReturnType<typeof owedDocs>[number]> // doc -> its entry, source edited
10 docTouched: Set<string>
11 toasted: Set<string>
12 told: Set<string>
13 entries: DocEntry[] | null
14}
15
16// Module state is enough: a hot reload only forgets what was already toasted, and the owed set
17// is rebuilt by the next edit.
18async function note($: EngineInterface, state: SyncState, filePath: string | undefined) {
19 if (!filePath) return
20 const cwd = await $.session.cwd()
21 const rel = relativeTo(cwd, filePath)
22 if (rel === null) return
23 if (rel.startsWith('docs/features/') || rel.startsWith('src/features/onboarding/')) state.docTouched.add(rel)
24 if (state.entries === null) {
25 const raw = await $.fs.read(`${cwd}/scripts/docs/feature-doc-map.json`).catch(() => '')
26 try {
27 // Parsed JSON at a data boundary: the map is this repo's own file; shape checked by use (entries array or empty).
28 const parsed = JSON.parse(typeof raw === 'string' ? raw : '{}')
29 state.entries = Array.isArray(parsed.entries) ? (parsed.entries as DocEntry[]) : []
30 } catch {
31 state.entries = []
32 }
33 }
34 for (const o of owedDocs(rel, state.entries)) state.owedBy.set(o.doc, o)
35}
36
37const stillOwed = (state: SyncState) => [...state.owedBy.values()].filter(o => !state.docTouched.has(o.doc))
38
39export const register: Register = on => {
40 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
41 const cwd = await $.session.cwd()
42 // A primary checkout has a .git DIRECTORY; in a worktree .git is a file.
43 const git = await $.fs.stat(`${cwd}/.git`).catch(() => null)
44 // Read the target before judging it: a script that handles --help is safe to ask.
45 const helpAware = new Set<string>()
46 for (const { script, path } of helpTargets(e.command)) {
47 const at = /^[A-Za-z]:[\\/]/.test(path) ? path : `${cwd}/${path}`
48 const source = await $.fs.read(at).catch(() => '')
49 if (typeof source === 'string' && source.includes('--help')) helpAware.add(script)
50 }
51 const denied = verdict(e.command, {
52 primaryCheckout: git?.kind === 'dir',
53 // The engine reports a Windows cwd with backslashes (C:\Users\...); the tests' forward slashes hid that.
54 windows: /^[A-Za-z]:[\\/]/.test(cwd),
55 helpAware,
56 })
57 if (denied === null) return next(e)
58
59 $.ui.status(`dev-law: denied ${denied.rule}`)
60
61 return { deny: `${$.plugin.name} (${denied.rule}): ${denied.reason}` }
62 // Deliberately fail OPEN: a bug in a rule must not brick every shell call. The
63 // permissions.deny list in .claude/settings.json is the hard floor beneath this.
64 }).catch(($, e, next) => next(e))
65
66 const state: SyncState = { owedBy: new Map(), docTouched: new Set(), toasted: new Set(), told: new Set(), entries: null }
67
68 for (const tool of ['Edit', 'Write'] as const) {
69 on('tool.call', { tool }, async ($, e, next) => {
70 const result = await next(e)
71 await note($, state, e.file_path)
72 return result
73 }).catch(($, e, next) => next(e))
74 }
75
76 // The operator hears it once per doc at the end of the turn that left it stale.
77 on('turn.complete', async ($, e, next) => {
78 const fresh = stillOwed(state).filter(o => !state.toasted.has(o.doc))
79 if (fresh.length > 0) {
80 for (const o of fresh) state.toasted.add(o.doc)
81 $.ui.toast(nudge(fresh))
82 }
83 return next(e)
84 }).catch(($, e, next) => next(e))
85
86 // The model hears it in the Edit/Write result itself, once per doc, right after the edit that made the
87 // doc stale. Measured 2026-10-07: a session.append row at turn end and a prompt.compose section never
88 // reached the model; a rewritten tool result does (result-economy relies on the same door).
89 on('session.append', { door: 'tool-result' }, async ($, e, next) => {
90 if (e.origin.kind !== 'tool' || !['Edit', 'Write'].includes(String(e.origin.tool))) return next(e)
91 const fresh = stillOwed(state).filter(o => !state.told.has(o.doc))
92 if (fresh.length === 0) return next(e)
93 for (const o of fresh) state.told.add(o.doc)
94 const text = nudge(fresh)
95 const content = e.message.content.map(block => {
96 if (block.type !== 'tool_result') return block
97 if (typeof block.content === 'string') return { ...block, content: `${block.content}\n\n${text}` }
98 if (Array.isArray(block.content)) return { ...block, content: [...block.content, { type: 'text' as const, text }] }
99 return block
100 })
101 return next({ ...e, message: { ...e.message, content } })
102 }).catch(($, e, next) => next(e))
103}
104hooks/docsync.mjs 67 lines1// Pure doc-sync matching for dev-law: a repo-relative source path in, the feature
2// docs it owes an update to out. Reads the same map the (never-firing) Stop hook
3// read - scripts/docs/feature-doc-map.json - so the two cannot disagree on ownership.
4// Skip patterns mirror scripts/docs/check-doc-sync.mjs SKIP_PATTERNS.
5
6const SKIP = [
7 /\.test\.[tj]sx?$/,
8 /\.spec\.[tj]sx?$/,
9 /__tests__\//,
10 /\/bindings\//,
11 /\/generated\//,
12 /\.generated\.(ts|tsx|mjs|js|cjs)$/,
13 /^src\/i18n\//,
14 /^docs\//,
15 /^scripts\/templates\//,
16 /^scripts\/connectors\//,
17 /^src-tauri\/db\/src\/migrations\//,
18]
19
20// `**/` is any directories (or none), a trailing `**` anything, `*` one path segment.
21export function globToRe(glob) {
22 const re = glob
23 .replace(/[.+^${}()|[\]\\]/g, '\\$&')
24 .replace(/\*\*\//g, '\u0000')
25 .replace(/\*\*/g, '\u0001')
26 .replace(/\*/g, '[^/]*')
27 .replace(/\u0000/g, '(?:.*/)?')
28 .replace(/\u0001/g, '.*')
29 return new RegExp(`^${re}$`)
30}
31
32export const isSkipped = rel => SKIP.some(re => re.test(rel))
33
34/** Repo-relative, forward-slash path, or null when the file is outside the repo. */
35export function relativeTo(cwd, file) {
36 const norm = p => p.replace(/\\/g, '/').replace(/^\/([a-zA-Z])\//, (_, d) => `${d.toUpperCase()}:/`)
37 const root = norm(cwd).replace(/\/$/, '')
38 const f = norm(file)
39 if (f.toLowerCase().startsWith(`${root.toLowerCase()}/`)) return f.slice(root.length + 1)
40 return /^[A-Za-z]:\/|^\//.test(f) ? null : f
41}
42
43/**
44 * @param {string} rel repo-relative path of an edited file
45 * @param {{ doc: string, sourceGlobs?: string[], onboardingFlows?: string[], marketingModule?: string }[]} entries
46 * @returns {{ doc: string, onboardingFlows: string[], marketingModule: string | null }[]}
47 */
48export function owedDocs(rel, entries) {
49 if (isSkipped(rel)) return []
50 const hit = []
51 for (const e of entries) {
52 if ((e.sourceGlobs ?? []).some(g => globToRe(g).test(rel))) {
53 hit.push({ doc: e.doc, onboardingFlows: e.onboardingFlows ?? [], marketingModule: e.marketingModule ?? null })
54 }
55 }
56 return hit
57}
58
59/** One line for the operator and the model; names each stale doc once. */
60export function nudge(owed) {
61 const parts = owed.map(o => {
62 const extra = [...(o.onboardingFlows.length ? [`tour ${o.onboardingFlows.join('/')}`] : []), ...(o.marketingModule ? [`marketing ${o.marketingModule}`] : [])]
63 return extra.length ? `${o.doc} (+ ${extra.join(', ')})` : o.doc
64 })
65 return `doc-sync: this session edited source owned by ${parts.join('; ')} and has not touched the doc. Update it in the same change, or say why the edit is internal-only.`
66}
67hooks/rules.mjs 215 lines1// Pure rules for dev-law (adopted from the user-level fleet-guard, 2026-10-07): one shell command in, a denial or null out.
2// Every rule is a failure this machine already paid for, named after the
3// memory file that records it, so a denial says where its reason lives.
4// No engine access here: the hooks module and the replay both import this.
5
6/**
7 * @typedef {{
8 * primaryCheckout: boolean,
9 * windows: boolean,
10 * helpAware?: ReadonlySet<string>,
11 * }} GuardContext
12 * @typedef {{ rule: string, reason: string }} GuardDenial
13 */
14
15// Heredoc bodies and quoted strings are data, not commands: a commit message
16// that says "git checkout -b" must not read as one. Quotes keep their marks.
17export function scrub(command) {
18 let out = command.replace(/<<-?\s*(['"]?)(\w+)\1[^\n]*\n[\s\S]*?\n\s*\2\s*(?=\n|$)/g, '<<HEREDOC')
19 out = out.replace(/'[^'\n]*'/g, "''").replace(/"(?:[^"\\\n]|\\.)*"/g, '""')
20 return out
21}
22
23const SEGMENT = /\s*(?:&&|\|\||;|\n|\|)\s*/
24const words = segment => segment.trim().split(/\s+/).filter(Boolean)
25
26function gitArgs(segment) {
27 const w = words(segment)
28 if (w[0] !== 'git') return null
29 // `git -C <dir>` points elsewhere; the shared-checkout rules cannot resolve it.
30 if (w[1] === '-C') return { args: w.slice(3), elsewhere: true }
31 return { args: w.slice(1), elsewhere: false }
32}
33
34// Arguments after the subcommand that are neither flags nor the flag values git takes.
35const paths = args => {
36 const at = args.indexOf('--')
37 if (at >= 0) return args.slice(at + 1)
38 return args.filter(a => !a.startsWith('-'))
39}
40
41// MSYS spells C:/x as /c/x; the engine's fs reads the Windows spelling.
42const native = dir => dir.replace(/^\/([a-zA-Z])\//, (_, d) => `${d.toUpperCase()}:/`)
43
44/**
45 * Registry scripts a command asks for --help, for the caller to read before
46 * judging: `script` as the command names it, `path` resolved through any
47 * `cd <dir>` ahead of it (relative to the session's cwd when there is none).
48 * @returns {{ script: string, path: string }[]}
49 */
50export function helpTargets(command) {
51 const found = []
52 let dir = ''
53 for (const seg of scrub(command).split(SEGMENT)) {
54 const cd = seg.match(/^\s*cd\s+(\S+)/)
55 if (cd) { dir = native(cd[1]); continue }
56 const m = seg.match(/\bnode\s+((?:\S*\/)?scripts\/[\w.-]+\.mjs)\b.*\s(?:--help|-h)\b/)
57 if (m) found.push({ script: m[1], path: dir ? `${dir}/${m[1]}` : m[1] })
58 }
59 return found
60}
61
62const RULES = [
63 {
64 rule: 'stage-by-name',
65 memory: 'pathspec-from-git-status-sweeps-siblings',
66 test: (seg, ctx) => {
67 if (!ctx.primaryCheckout) return false
68 const g = gitArgs(seg)
69 if (!g || g.elsewhere || g.args[0] !== 'add') return false
70 const rest = g.args.slice(1)
71 const sweeping = rest.some(a => a === '-A' || a === '--all' || a === '-u' || a === '--update')
72 const named = paths(rest)
73 // `git add -A -- app/x` is scoped by its pathspec; a bare sweep or `.` is not.
74 return named.includes('.') || (sweeping && named.length === 0)
75 },
76 reason: 'git add -A/./-u with no pathspec stages sibling sessions\' WIP in a shared checkout; stage your own files by name (git add -A -- <dir> is fine)',
77 },
78 {
79 rule: 'no-branch-switch',
80 memory: 'librarian-shared-checkout-pr-flow',
81 test: (seg, ctx) => {
82 if (!ctx.primaryCheckout) return false
83 const g = gitArgs(seg)
84 if (!g || g.elsewhere) return false
85 const [sub, ...rest] = g.args
86 if (sub === 'switch') return !rest.includes('--help')
87 if (sub !== 'checkout') return false
88 // A path restore, or a merge side taken for a path, is not a switch.
89 if (rest.includes('--') || rest.includes('--theirs') || rest.includes('--ours') || rest.includes('-p')) return false
90 if (rest.some(f => f === '-b' || f === '-B' || f === '--orphan' || f === '--detach')) return true
91 // Without `--` git decides between a branch and a path; both are unsafe here.
92 // `git checkout .` is the discard rule's, below.
93 const named = rest.filter(a => !a.startsWith('-'))
94 return named.length >= 1 && !named.includes('.')
95 },
96 reason: 'switching the branch moves every sibling session\'s working tree; take a short-path worktree instead (git worktree add C:/t/<id> -b <branch>). To restore a file, spell it git checkout -- <path>',
97 },
98 {
99 rule: 'no-shared-discard',
100 memory: 'librarian-shared-checkout-pr-flow',
101 test: (seg, ctx) => {
102 if (!ctx.primaryCheckout) return false
103 const g = gitArgs(seg)
104 if (!g || g.elsewhere) return false
105 const [sub, ...rest] = g.args
106 if (sub === 'stash') {
107 if (['list', 'show'].includes(rest[0])) return false
108 // A stash scoped to your own paths leaves the siblings' alone.
109 return !(rest[0] === 'push' && rest.includes('--') && paths(rest).length > 0)
110 }
111 if (sub === 'reset') return rest.includes('--hard')
112 if (sub === 'checkout' || sub === 'restore') return paths(rest).includes('.') && !rest.includes('--staged')
113 if (sub === 'clean') return rest.some(a => /^-[a-zA-Z]*f/.test(a))
114 return false
115 },
116 reason: 'stash, reset --hard, checkout ., restore . and clean -f act on every session\'s uncommitted work in this checkout, not only yours (git stash push -- <your paths> is fine)',
117 },
118 {
119 rule: 'no-help-on-registry-script',
120 memory: 'librarian-shared-checkout-pr-flow',
121 test: (seg, ctx) => {
122 const m = seg.match(/\bnode\s+((?:\S*\/)?scripts\/[\w.-]+\.mjs)\b.*\s(?:--help|-h)\b/)
123 // A script whose source handles --help prints usage; only the others execute.
124 return m !== null && !(ctx.helpAware?.has(m[1]) ?? false)
125 },
126 reason: 'this registry script does not handle --help and EXECUTES instead; grep the script\'s argv handling to learn its flags',
127 },
128 {
129 rule: 'no-bare-cargo-test',
130 memory: 'cargo-test-comctl32-manifest',
131 test: (seg, ctx) => {
132 if (!ctx.windows) return false
133 const w = words(seg)
134 return w[0] === 'cargo' && w[1] === 'test' && !w.includes('--no-run')
135 },
136 reason: 'bare cargo test dies in the Windows loader (exit 127, no output); use npm run test:rust, or npm run test:rust -- export_bindings for bindings',
137 },
138 {
139 rule: 'no-whole-vitest',
140 memory: 'vitest-whole-run-never-terminates',
141 test: seg => {
142 const w = words(seg)
143 const i = w.findIndex(a => a === 'vitest' || (a === 'test' && w[0] === 'npm'))
144 if (i < 0 || (w[0] !== 'npx' && w[0] !== 'npm' && w[0] !== 'vitest')) return false
145 const rest = w.slice(i + 1).filter(a => a !== 'run' && a !== '--')
146 if (rest.some(a => a.startsWith('--shard') || a === '-t' || a === '--changed')) return false
147 // A named file or directory scopes the run; flags alone do not.
148 return !rest.some(a => !a.startsWith('-'))
149 },
150 reason: 'an unscoped vitest run never terminates here (one test kills its worker); name a file or shard it: npx vitest run --reporter=dot --shard=1/4',
151 },
152 {
153 rule: 'no-blind-node-kill',
154 memory: 'feedback_no_blind_process_kill',
155 test: seg => /(?:taskkill\b.*\/im\s+node|pkill\s+(?:-\w+\s+)?node|killall\s+node|stop-process\s+-name\s+node)/i.test(seg),
156 reason: 'killing every node process takes down sibling sessions, the gate daemon and the dev server; find the PID of YOUR process (netstat -ano | findstr :<port>) and kill that one',
157 },
158 {
159 rule: 'grep-i-multi-e',
160 memory: 'bash-grep-multiple-e-returns-zero',
161 test: (seg, ctx) => {
162 if (!ctx.windows) return false
163 const w = words(seg)
164 if (w[0] !== 'grep') return false
165 const hasI = w.some(a => /^-[a-zA-Z]*i[a-zA-Z]*$/.test(a) && a !== '-e')
166 return hasI && w.filter(a => a === '-e').length >= 2
167 },
168 reason: 'MSYS grep with -i and two or more -e can abort (exit 134) and read as zero matches; use one pattern: grep -iE \'a|b\'',
169 },
170 {
171 rule: 'no-ln-s-on-windows',
172 memory: 'bash-ln-s-copies-on-windows',
173 test: (seg, ctx) => ctx.windows && /^ln\s+-\w*s\w*\b/.test(seg.trim()),
174 reason: 'MSYS ln -s COPIES the target (a node_modules copy is gigabytes); use cmd //c mklink /J <link> <target> or scripts/link-registry.mjs',
175 },
176 {
177 rule: 'no-backticks-in-double-quotes',
178 memory: 'bash-backticks-substituted-in-py-c',
179 raw: true,
180 // An escaped \` is literal and safe; only a bare pair inside "..." substitutes.
181 test: cmd => /(?:^|[;&|]\s*)(?:git\s+commit\b|node\s+-e\b|py(?:thon)?3?\s+-c\b)/.test(cmd.trim()) && /\s(?:-m|-e|-c)\s+"(?:[^"\\`]|\\.)*(?<!\\)`[^"`]*(?<!\\)`(?:[^"\\]|\\.)*"/.test(cmd),
182 reason: 'backticks inside a double-quoted argument are command substitution; a Markdown code span becomes an empty string. Put the text in a file (git commit -F <file>)',
183 },
184 {
185 rule: 'pipe-masks-check',
186 memory: 'pipe-masks-check-exit-code',
187 whole: true,
188 test: cmd => /--check\b[^|;&\n]*\|(?!\|)[^;&\n]*\|\|/.test(cmd),
189 reason: '`cmd --check | tail || fallback` never runs the fallback (the pipe returns tail\'s status); drop the pipe or use set -o pipefail',
190 },
191]
192
193/**
194 * @param {string} command
195 * @param {GuardContext} ctx
196 * @returns {GuardDenial | null}
197 */
198export function verdict(command, ctx) {
199 const clean = scrub(command)
200 for (const r of RULES) {
201 const deny = { rule: r.rule, reason: `${r.reason} [memory: ${r.memory}]` }
202 if (r.raw) { if (r.test(command, ctx)) return deny; continue }
203 if (r.whole) { if (r.test(clean, ctx)) return deny; continue }
204 let here = ctx
205 for (const seg of clean.split(SEGMENT)) {
206 // `cd elsewhere && git ...` leaves this checkout: the shared-checkout rules stand down.
207 if (/^\s*cd\s/.test(seg)) { here = { ...here, primaryCheckout: false }; continue }
208 if (seg && r.test(seg, here)) return deny
209 }
210 }
211 return null
212}
213
214export const ruleNames = RULES.map(r => r.rule)
215