SLOPSHOPPER

dev-law

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

newguardtoaststatus
v0.1.0MITupdated 2026-10-07xkazm04/personas/.claude/mods/dev-law
A shopper browsing a rack in a slop shop
README

Personas Desktop

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.

CI Release License: MIT Platforms Tauri 2 React 19 PRs Welcome

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


What it does

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 toShort version
Create an agent and tune its prompt, tools and modelAgentsDescribe it in plain language; the app builds the spec
Give agents access to Slack, GitHub, a database…ConnectionsEncrypted vault, 130+ built-in connectors
Run agents on a schedule, a webhook or an eventEvents & SchedulesTen ways a run can start, plus chains between agents
See what agents did, what it cost, and what needs youOverviewDashboard, approvals, incidents, memory
Point agents at your repos and steer them by goals and KPIsProjectsContext map, goals, KPIs, task runner, fleet
Get help running all of the aboveCompanionsAthena, Overseer, Curator
Extend itPluginsDev 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.


Tour of the app

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.

1. Start here

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

2. Build agents

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.

  • Persona editor with system prompt, tools, behavioural rules, model and reasoning effort, and a version history with diffs
  • Templates (under Connections → Templates): adopt a ready-made agent from the catalog, import one from n8n, or start from a generated draft
  • Recipes: reusable, parameterized capabilities you adopt onto a persona
  • Presets: pre-assembled teams of agents for a common job
  • Lab for testing a persona against scenarios, with quality scoring and prompt benchmarking
  • Chat tab as an operations hub: run, check health, edit prompts and assign tools without leaving the conversation

Docs: personas · templates · recipes · operations hub

3. Connect services

Connections is the credential vault and everything around it.

  • AES-256-GCM encryption at rest, with the key held in the OS keyring (Windows Credential Manager, macOS Keychain, Linux Secret Service)
  • 130+ built-in connectors (Slack, GitHub, Linear, Discord, Jira, Notion, Airtable, PostgreSQL, MongoDB, Stripe, Vercel and more), each with credential health checks and guided setup
  • Catalog, Databases, a Dependencies graph of what uses which credential, and a Broker
  • Credentials are handed to an execution as environment variables and scrubbed afterwards, never passed as CLI arguments

Docs: connections · integrations

4. Automate and trigger

Events is the routing layer. Schedules (a calendar overlay from the title bar) holds cron jobs.

  • A run can start from a manual click, a cron schedule, an inbound webhook (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 these
  • Chain Studio wires personas together by the signals they emit and listen for
  • Live Stream, speed limits (rate limits), dead-letter queue and a test panel for maintaining the event flow
  • Marketplace for shared events; automation tools (n8n, GitHub Actions) for outbound steps

Docs: events & triggers · schedules · execution entry points · automation tools

5. Observe and govern

Overview answers "what are my agents doing, and what needs me?"

GroupPages
MonitoringDashboard (Mission control: vitals, status monitor, leaderboard, self-healing), Activity (every execution with model, tokens and cost), Events
OperationsApprovals (manual-review queue), Incidents (one triage inbox over seven failure streams), Observability (alerts, health issues, trends, IPC performance), Messages
MemoryMemories (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

6. Ship software with agents

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.

GroupWhat it is
TeamsAssemble a team from a preset, give it a goal, decompose, assign and run. Moderated multi-persona deliberations end in an approved assignment
Goals / KPIsKPIs define outcomes; a KPI off its critical line derives goals; goals are what teams advance
DevelopmentLifecycle (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)
BrowserLets 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

7. Companions: the app's own agents

Companions are three built-in agents, distinct from the personas you create. Each has its own pages and an on/off switch.

CompanionRole
AthenaThe assistant. Plans, builds, remembers and answers across the whole app, by chat or voice, with an orb overlay and guided walkthroughs
OverseerKeeper 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
CuratorKeeper 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

8. Plugins

Enable plugins from Plugins. Each adds a group of pages to the sidebar.

PluginWhat it gives you
Dev ToolsThe codebase tooling described in section 6
Obsidian BrainA two-way bridge between agent memory and an Obsidian vault, as plain markdown, optionally backed up to your own Google Drive
DriveA sandboxed local file manager for what agents, OCR, signing and exports produce
TwinA 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

9. Settings and the desktop shell

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

Interface tiers

One codebase, three audiences. Each tier is a strict superset of the one before.

TierLabel in the appAudienceAdds
starterSimpleNon-technical usersAgents, connections, messages, templates
team (default)PowerTeams and enterprisesEvents, projects and teams, deployment, analytics, scheduling, databases, plugins, companions
builder(dev builds)DevelopersDev-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.


How a run works

 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
  1. Define. Create a persona, pick a model, assign tools, attach vault credentials.
  2. Trigger. Start it by hand, on a schedule, from a webhook or event, or from another persona.
  3. Execute. The Rust engine spawns the Claude Code CLI as a subprocess, streams its output, counts tokens and cost, and writes a trace. Custom or local model endpoints can be configured under Settings → Custom Models (dev builds).
  4. Observe. Executions land in Overview; approvals and incidents queue for you; healing retries what it safely can.
  5. Deploy (optional). Push personas to the cloud orchestrator, GitHub Actions, GitLab CI/CD, or n8n.

Data flow and privacy

DataWhere it goesEncryption
Persona prompts and configLocal SQLitePlaintext (not secrets)
Credential valuesLocal SQLiteAES-256-GCM (key in OS keyring)
Execution outputLocal SQLite + log filesPlaintext
AI provider requestsProvider API (HTTPS)TLS in transit
Connector API callsThird-party service (HTTPS)TLS in transit
Error reports (opt-in)SentryPII stripped before send

Tech stack

LayerTechnology
Desktop runtimeTauri 2
BackendRust, Tokio, SQLite (r2d2 pool)
FrontendReact 19, TypeScript 6, Vite 8
StylingTailwind CSS 4
StateZustand 5
AnimationFramer Motion
VisualizationRecharts, React Flow (XYFlow)
EncryptionAES-GCM, OS keyring
NetworkingReqwest (rustls-tls)

Prerequisites

DependencyVersionCheck command
Node.js>= 20node --version
Rustvia rustup; the repo pins its toolchain in rust-toolchain.tomlrustc --version
WebView2 Runtimebundled with Windows 10+none
C++ Build ToolsMSVC (Visual Studio)none

Windows setup

  1. Install Node.js:
   winget install OpenJS.NodeJS.LTS
  1. Install Rust via rustup, then restart your terminal so cargo and rustc are on your PATH:
   winget install Rustlang.Rustup
   rustc --version
   cargo --version
  1. Install Visual Studio C++ Build Tools: download Visual Studio Build Tools and select the "Desktop development with C++" workload. It provides the MSVC compiler and Windows SDK that Tauri needs.
  1. Install LLVM/Clang (required on Windows ARM64 for the ring crypto crate):
   winget install LLVM.LLVM

Make sure C:\Program Files\LLVM\bin is on your PATH, and restart your terminal.

  1. Run from a Developer shell. Launch your terminal from "Developer PowerShell for VS" so 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

macOS setup

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.

Linux setup (Debian/Ubuntu)

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"

Getting started

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.

Build tiers

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.

Common scripts

CommandWhat it does
npm run devVite dev server only (port 1420), frontend without Tauri
npm run tauri:dev / :liteTauri dev mode, full or lite feature set
npm run tauri:dev:testLite dev mode plus the test-automation HTTP server (:17320) and DevInspector
npm run buildTypeScript check + Vite production build
npm run tauri:buildFull installer (all targets, desktop-full)
npm run tauri:build:liteNSIS-only installer with desktop features
npm run tauri:build:stableNSIS + MSI installer for a Windows release
npm run checkThe full pre-push gate chain: project checks, type check, ESLint and the golden-path census
npm run gateFast per-commit check (tsc, changed-file ESLint, census) from a warm local daemon
npm run testVitest
npm run test:rustRust unit tests (use this on Windows rather than raw cargo test)
npm run check:tiersCompile-check all three frontend tiers
npm run analyze / check:budgetBundle treemap / chunk-size budget
npm run clean:rust / clean:ortFull 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.

DevInspector: click a component, copy its source path

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.

Driving the app from a terminal

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.


Repository layout

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.


Internationalization (i18n)

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.
Source 3 files
hooks/register.ts 104 lines
1import 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}
104
hooks/docsync.mjs 67 lines
1// 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}
67
hooks/rules.mjs 215 lines
1// 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