SLOPSHOPPER

grounded-engineering

Source-backed engineering practices and reviewed skill repositories, in a Claude Code pane.

newpanecommandtoast
★ 2v0.6.0MITupdated 2026-10-08madjagstudios/grounded-engineering/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · grounded-engineering
│ ┃ Grounded ✕ › fix the failing auth test and add an audit log call │ ┃ catalog missing or invalid: Error: ENOENT: │ ┃ no such file ⏺ Read(src/auth.ts) │ ┃ /plugins/grounded-engineering/catalog.json. ⎿ Read 6 lines │ ┃ Reinstall the plugin: /plugin install ⏺ Update(src/auth.ts) │ ┃ grounded-engineering@grounded-engineering ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /grounded-skills │ ⎿ grounded-engineering: Grounded Engineering opened on Skill repos │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Grounded
catalog missing or invalid: Error: ENOENT: no such file /plugins/grounded-engineering/catalog.json. Reinstall the plugin: /plugin install grounded-engineering@grounded-engineering
README

Grounded Engineering

Validate npm License: MIT Node Claude Code mod

Engineering practices drawn from mature open-source repositories, each tied to the source it came from, plus a shelf of reviewed third-party skill repositories. It runs as a mod inside Claude Code, a live pane installed as a plugin, and as a command-line tool.

The Grounded pane in Claude Code: it lists the practices that fit the open repository, and Adapt asks Claude to propose the change

Type /grounded and the pane reads the repository you have open. It shows the practices that fit what it finds (tests with no CI gate, agent instructions only Claude reads, no OS sandbox) and why each one applies. Adapt asks Claude to propose the smallest change that brings the practice into your repository; nothing is written until you approve it. Every card links to the sources it was drawn from, pinned to the revision that was read.

Install

Claude Code mod

Needs Claude Code 2.1.288 or later, in the terminal or the desktop app's Code tab. Mods install as plugins:

/plugin marketplace add madjagstudios/grounded-engineering
/plugin install grounded-engineering@grounded-engineering

Then type /grounded. In the terminal the pane also has keyboard shortcuts: 1 and 2 switch screens, d shows what it detected, and a runs the open card's main action.

Command-line tool

Needs Node.js 20 or later. It writes the same practices into AGENTS.md, CLAUDE.md or a neutral Markdown file; see Adopt a profile.

Skill repos

The Skill repos screen: Ponytail is featured at the top, and Explain install has Claude read the repository before anything is installed

The second screen lists sixteen third-party skill repositories, each read at a pinned commit and approved by a maintainer before it is listed. The shelf holds links and our own short notes only; credit and stars go to the authors. Explain install has Claude read the repository at that commit and list what it would add (hooks, scripts, network use, settings it changes) before quoting the author's own install steps. Claude installs nothing unless you agree, and you type any slash commands yourself. Authors who ask to be removed are delisted. The records live in research/skill-repos/.

Repository layout

research/       Source observations, pinned references, and category audits
practices/      Short, reusable engineering-practice cards
integrations/   Consumer-specific translation guidance for agent instruction files
plugin/         The Claude Code mod: pane, skills, and generated catalog
src/, bin/      The command-line tool
scripts/        Validation, catalog build, and source-drift tools

Adopt a profile

Adoption does not rewrite existing policy. preview only reads, and create stores a proposal under .grounded-engineering/proposals/<proposal-id>/ for you to review; nothing is written until apply --confirm.

# one-off, nothing installed
npx grounded-engineering adopt preview --profile ai-assisted --adapter claude

# or install the command
npm install -g grounded-engineering
grounded-engineering adopt create --profile ai-assisted --adapter claude
# Review proposal.yaml, plan.md, and diff.patch; complete local_decisions.
grounded-engineering adopt apply <proposal-id> --confirm
grounded-engineering check

Profiles:

  • baseline: the original eight cards on repository context, code quality, testing and verification. Its pack metadata stays at v0.2.0 so existing adopters stay green.
  • ai-assisted: all seventeen cards.

Adapters:

  • neutral (default) writes Markdown to docs/grounded-engineering.md if the repository has a docs/ folder, otherwise to GROUNDED_ENGINEERING.md.
  • codex writes to AGENTS.md. If AGENTS.override.md exists, Codex reads it instead, so the tool reports it and writes nothing.
  • claude writes to the repository-root CLAUDE.md. It reports .claude/CLAUDE.md, nested CLAUDE.md and CLAUDE.local.md files but does not edit them.

Every adapter writes only inside managed blocks keyed by card ID, and leaves everything outside them byte for byte. Apply writes the target and .grounded-engineering/manifest.yaml only after re-checking the proposal's preconditions. A repository can have one adapter target. Adding a second target, and choosing cards with --cards outside preview, are not supported yet.

grounded-engineering check reads the manifest and the target files and compares them with the pack bundled in the installed CLI. It exits 0 when clean, 1 on drift or a repository-state mismatch, and 2 on an invocation error. The write path is specified in the adopt apply policy; compatibility notes for each release are in CHANGELOG.md.

How sources are used

Cards paraphrase their sources and link to the revision and the file or heading they draw on. We quote only where a source's license allows it, and never copy vendor prompt files. Each card says whether its practice is observed, recommended, or validated in use.

A card does not change when its source does. npm run check:sources reports drift, and a card marked validated records which revision of each source it was checked against.

Contributing

Start with CONTRIBUTING.md and research/README.md. Keep source observations in research/, the practice in practices/, and tool-specific wording in integrations/; a change that mixes them is harder to review.

Grounded Engineering is built and maintained with AI agents.

License

Original repository content is available under the MIT License. Third-party sources remain under their own licenses and terms; links and attribution are recorded with the evidence.

Source 13 files
hooks/register.tsx 196 lines
1// Engine rules this file follows:
2// - State atoms are declared here, as top-level consts with a literal plugin and key.
3// - The engine does not follow `$` across an import, so every function that takes `$` is in this file.
4// - Each hook passed to on() is a function literal.
5// - A render never writes state.
6
7import type { Register } from 'claude-code'
8import { atom, read, update } from 'claude-code'
9import { loadCatalog, type Catalog, type Host } from './catalog'
10import { readAdoption, readSignals } from './signals'
11import type { GroundedScreen, GroundedSignals } from '../types'
12import { TERMINAL_PALETTE } from './theme'
13import { slimCatalog, type Ack, type PaneModel } from './model'
14import { paneScreen, linkLabel } from './screens'
15import { pressOf, validAct } from './client/apply'
16
17const PANE = 'grounded'
18// Prints one of the pane's links in the transcript, where the desktop opens it; hidden from the menu.
19const LINK_COMMAND = 'grounded-link'
20
21const screenState = atom({ plugin: 'grounded-engineering', key: 'screen' } as const, 'practices' as GroundedScreen)
22const selectedState = atom({ plugin: 'grounded-engineering', key: 'selected' } as const, null as string | null)
23const queryState = atom({ plugin: 'grounded-engineering', key: 'query' } as const, '')
24const categoryState = atom({ plugin: 'grounded-engineering', key: 'category' } as const, 'All')
25const tagState = atom({ plugin: 'grounded-engineering', key: 'tag' } as const, 'All')
26const sortState = atom({ plugin: 'grounded-engineering', key: 'sort' } as const, 'fit' as 'fit' | 'name')
27const showSignalsState = atom({ plugin: 'grounded-engineering', key: 'showSignals' } as const, false)
28const collapsedState = atom({ plugin: 'grounded-engineering', key: 'collapsed' } as const, { fits: false, all: false } as { fits: boolean; all: boolean })
29const signalsState = atom({ plugin: 'grounded-engineering', key: 'signals' } as const, null as GroundedSignals | null)
30const adoptionState = atom({ plugin: 'grounded-engineering', key: 'adoption' } as const, null as { profile: string | null; cards: string[] } | null)
31
32// The parsed catalog and the plugin root it was read from. A hot reload starts this module fresh, which empties it.
33let catalogCache: { root: string; catalog: Catalog } | null = null
34
35function hostOf($: any): Host {
36  return { fs: { read: (p) => $.fs.read(p).then(String), exists: (p) => $.fs.exists(p), list: (p) => $.fs.list(p) }, plugin: { root: $.plugin.root } }
37}
38
39async function refreshRepo($: any) {
40  const host = hostOf($)
41  const [signals, adoption] = await Promise.all([readSignals(host), readAdoption(host)])
42  await update($, signalsState, () => signals)
43  await update($, adoptionState, () => adoption)
44}
45
46async function catalogFor($: any): Promise<{ catalog: Catalog | null; error: string | null }> {
47  const host = hostOf($)
48  if (catalogCache?.root === host.plugin.root) return { catalog: catalogCache.catalog, error: null }
49  try {
50    const catalog = await loadCatalog(host)
51    catalogCache = { root: host.plugin.root, catalog }
52    return { catalog, error: null }
53  } catch (err) { return { catalog: null, error: (err as Error).message } }
54}
55
56// Acks for the 16 most recently posting clients. Evicting a live client would rerun its
57// unacknowledged presses; a redrawn Client gets a new cid, so 16 is far more than a session uses.
58const CLIENTS = 16
59const acks = new Map<string, Ack>()
60function touchAck(cid: string): Ack {
61  const ack = acks.get(cid) ?? { received: 0, done: 0 }
62  acks.delete(cid)
63  acks.set(cid, ack)
64  if (acks.size > CLIENTS) acks.delete(acks.keys().next().value!)
65  return ack
66}
67// Client presses run one after another, in the order they were made; see ui.message.
68let clientWork: Promise<unknown> = Promise.resolve()
69
70// Everything the pane draws, as plain data: the terminal draws it, the desktop Client receives it.
71async function paneModel($: any): Promise<PaneModel> {
72  const { catalog, error } = await catalogFor($)
73  const [screen, selected, query, category, tag, sort, showSignals, collapsed, storedSignals, storedAdoption] = await Promise.all([
74    read($, screenState), read($, selectedState), read($, queryState), read($, categoryState), read($, tagState), read($, sortState),
75    read($, showSignalsState), read($, collapsedState), read($, signalsState), read($, adoptionState),
76  ])
77  const host = hostOf($)
78  const [signals, adoption] = storedSignals !== null ? [storedSignals, storedAdoption] : await Promise.all([readSignals(host), readAdoption(host)])
79  return {
80    catalog: catalog ? slimCatalog(catalog) : null, error, screen, selected, query, category, tag, sort, showSignals, collapsed,
81    signals, adoption, acks: Object.fromEntries([...acks].map(([cid, a]) => [cid, { ...a }])),
82  }
83}
84
85// The plugin's skills run as commands, only from a press.
86async function runSkill($: any, skill: 'adapt' | 'explain', id: string) {
87  void $.command.run({ command: `grounded-engineering:${skill}`, args: id })
88    .catch(() => $.ui.toast(`Could not start /grounded-engineering:${skill} ${id}`))
89}
90
91// The transcript line for a link the pane draws, or null.
92async function linkLine($: any, url: unknown): Promise<string | null> {
93  const { catalog } = await catalogFor($)
94  if (!catalog || typeof url !== 'string') return null
95  const label = linkLabel(catalog, url)
96  return label ? `${label}: ${url}` : null
97}
98
99// The command is registered immediate, so it runs at once even mid-turn.
100async function showLink($: any, url: unknown) {
101  if (!(await linkLine($, url))) return
102  void $.command.run({ command: LINK_COMMAND, args: url })
103    .catch(() => $.ui.toast('Could not show the link'))
104}
105
106// Every action the pane can take, each returning a promise that resolves once its state is
107// written. The ui.message hook awaits these for a Client's presses.
108function actions($: any) {
109  return {
110    // Search belongs to the screen it was typed on, so a tab change clears it.
111    tab: (s: 'practices' | 'skills') => Promise.all([update($, screenState, () => s), update($, selectedState, () => null), update($, queryState, () => '')]),
112    select: (key: string) => update($, selectedState, (cur) => (cur === key ? null : key)),
113    search: (q: string) => update($, queryState, () => q),
114    category: (c: string) => update($, categoryState, () => c),
115    tag: (t: string) => update($, tagState, () => t),
116    sort: (v: 'fit' | 'name') => update($, sortState, () => v),
117    toggleSignals: () => update($, showSignalsState, (v) => !v),
118    toggleLane: (lane: 'fits' | 'all') => update($, collapsedState, (cur) => ({ ...cur, [lane]: !cur[lane] })),
119    adapt: (id: string) => runSkill($, 'adapt', id),
120    explain: (id: string) => runSkill($, 'explain', id),
121    link: (url: string) => showLink($, url),
122  }
123}
124
125function handlers($: any) {
126  const a = actions($) as Record<string, (...args: any[]) => Promise<unknown>>
127  const out: Record<string, (...args: any[]) => void> = {}
128  for (const name of Object.keys(a)) out[name] = (...args: any[]) => { void a[name]!(...args).catch(() => undefined) }
129  return out
130}
131
132async function openOn($: any, screen: 'practices' | 'skills', text: string) {
133  await update($, screenState, () => screen)
134  await update($, selectedState, () => null)
135  await update($, queryState, () => '')
136  await update($, collapsedState, () => ({ fits: false, all: false }))
137  // A request for a wider dock; a width the person dragged wins.
138  await $.ui.open({ id: PANE, title: 'Grounded', columns: 100 })
139  await refreshRepo($)
140  return { text }
141}
142
143export const register: Register = on => {
144  on('session.start', async ($, e, next) => {
145    await $.command.register({ name: 'grounded-skills', description: 'Open Grounded Engineering: reviewed skill repos' })
146    await $.command.register({ name: LINK_COMMAND, description: 'Print a Grounded Engineering link', argumentHint: '[url]', immediate: true })
147    // A pane restored with the session can render before any command has run. A render that
148    // runs before this finishes reads the repository itself.
149    void refreshRepo($).catch(() => undefined)
150    return next(e)
151  })
152
153  // /grounded is skills/grounded, answered here: the desktop slash menu lists a plugin's skills,
154  // not the commands session.start registers.
155  on('command.run', { command: 'grounded-engineering:grounded' }, async ($) => openOn($, 'practices', 'Grounded Engineering opened on Practices.'))
156  on('command.run', { command: 'grounded-skills' }, async ($) => openOn($, 'skills', 'Grounded Engineering opened on Skill repos.'))
157  on('command.run', { command: LINK_COMMAND }, async ($, e) => ({ text: (await linkLine($, e.args.trim())) ?? 'Not a link the Grounded pane shows.' }))
158  on('command.describe', { command: LINK_COMMAND }, async ($, e, next) => next({ ...e, isHidden: true }))
159  // The pane runs these two.
160  on('command.describe', { command: 'grounded-engineering:adapt' }, async ($, e, next) => next({ ...e, isHidden: true }))
161  on('command.describe', { command: 'grounded-engineering:explain' }, async ($, e, next) => next({ ...e, isHidden: true }))
162
163  // Runs each press the Client posts once, in order; one that is not a valid action is acknowledged, not run.
164  on('ui.message', async ($, e, next) => {
165    if (e.element !== 'grounded-app') return next(e)
166    const d = e.data as { kind?: string; acts?: unknown[] } | null
167    const posted = d?.kind === 'acts' && Array.isArray(d.acts) ? d.acts : []
168    const { catalog } = posted.length ? await catalogFor($) : { catalog: null }
169    const table = actions($) as Record<string, (...args: any[]) => Promise<unknown>>
170    let ran = false
171    for (const a of posted) {
172      const press = pressOf(a)
173      if (!press) continue
174      const ack = touchAck(press.cid)
175      if (press.seq <= ack.received) continue
176      ack.received = press.seq
177      const act = validAct(catalog, a) ? a : null
178      ran = true
179      clientWork = clientWork.then(() => (act ? table[act.name]!(...act.args) : undefined)).catch(() => undefined)
180        .then(() => { ack.done = press.seq })
181    }
182    if (ran) await clientWork
183    return { props: await paneModel($) }
184  })
185
186  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
187    const ui = $.ui.resolve(e)
188    // On desktop the app paints a Pane's own redraws only on the next input; a Client paints itself.
189    if (e.surface === 'desktop' && 'Client' in ui) {
190      const { Client } = ui
191      return <Client key="grounded-app" module="./client/app.tsx" props={await paneModel($)} width="100%" flexGrow={1} />
192    }
193    return paneScreen(ui, await paneModel($), handlers($) as any, TERMINAL_PALETTE, e.props.bodyColumns ?? 0, 1)
194  })
195}
196
hooks/catalog.ts 50 lines
1import type { FsEntry } from 'claude-code'
2
3// What the core reads through. Built from a hook's `$` by `hostOf` in register.tsx.
4export type Host = {
5  fs: { read(path: string): Promise<string>; exists(path: string): Promise<boolean>; list(path?: string): Promise<FsEntry[]> }
6  plugin: { root: string }
7}
8
9export type Source = { id: string; url: string }
10export type FitRule = { card: string; when: { all: string[]; any: string[]; none: string[] }; why: string }
11export type Practice = {
12  id: string; title: string; category: string; subcategory: string; pattern: string; rationale: string
13  agent_snippet: string | null; applicability: string[]; control_types: string[]; confidence: string
14  validation_status: string; source_ids: string[]; path: string; body: string
15}
16export type SkillRepo = {
17  id: string; name: string; repo: string; license: string; pinned_commit: string; reviewed_on: string
18  best_for: string[]; tags: string[]; summary: string; watch_out_for: string
19  install: string[]; install_note: string | null; featured: boolean
20}
21export type Catalog = {
22  catalog_version: number; package_version: string; repository: string
23  signals: { name: string; type: string; description: string }[]
24  categories: string[]; practices: Practice[]; skill_repos: SkillRepo[]; fit_rules: FitRule[]; sources: Source[]
25}
26
27export async function loadCatalog(host: Host): Promise<Catalog> {
28  try {
29    const catalog = JSON.parse(String(await host.fs.read(`${host.plugin.root}/catalog.json`))) as Catalog | null
30    if (catalog === null || typeof catalog !== 'object' || catalog.catalog_version !== 1) {
31      throw new Error('unexpected catalog_version or shape')
32    }
33    for (const field of ['practices', 'fit_rules', 'skill_repos', 'categories', 'signals', 'sources'] as const) {
34      if (!Array.isArray(catalog[field])) throw new Error(`${field} is not a list`)
35    }
36    // Card links are built on this URL, and the engine refuses the whole pane over one bad link.
37    if (typeof catalog.repository !== 'string' || !/^https:\/\/[^\s@]+$/.test(catalog.repository)) {
38      throw new Error('repository is not an https URL')
39    }
40    if (new URL(catalog.repository).href !== catalog.repository) throw new Error('repository is not in normal URL form')
41    return catalog
42  } catch (error) {
43    throw new Error(`catalog missing or invalid: ${String(error)}`)
44  }
45}
46
47export const cardUrl = (catalog: { repository: string; package_version: string }, practice: { path: string }) =>
48  `${catalog.repository}/blob/v${catalog.package_version}/${practice.path}`
49export const repoUrl = (repo: { repo: string }) => `https://github.com/${repo.repo}`
50
hooks/signals.ts 94 lines
1import type { Host } from './catalog'
2
3export const SIGNAL_NAMES = ['has_claude_md', 'has_agents_md', 'has_skills', 'has_large_skill_md', 'has_subagents', 'subagents_without_tool_limits', 'hooks_configured', 'sandbox_enabled', 'has_tests', 'has_ci', 'grounded_adopted', 'languages', 'test_framework'] as const
4
5export type Signals = {
6  has_claude_md: boolean; has_agents_md: boolean; has_skills: boolean; has_large_skill_md: boolean
7  has_subagents: boolean; subagents_without_tool_limits: boolean; hooks_configured: boolean
8  sandbox_enabled: boolean; has_tests: boolean; has_ci: boolean; grounded_adopted: boolean
9  languages: string[]; test_framework: string | null
10}
11
12export type Adoption = { profile: string | null; cards: string[] }
13
14type Fs = Host['fs']
15
16const exists = (fs: Fs, path: string) => fs.exists(path).catch(() => false)
17const readText = (fs: Fs, path: string) => fs.read(path).then(String, () => null)
18const list = (fs: Fs, path: string) => fs.list(path).catch(() => [])
19const readJson = async (fs: Fs, path: string) => {
20  const text = await readText(fs, path)
21  if (text === null) return null
22  try { return JSON.parse(text) } catch { return null }
23}
24
25export async function readSignals(host: Host): Promise<Signals> {
26  const fs = host.fs
27  const skillDirs = (await list(fs, '.claude/skills')).filter((e) => e.kind === 'dir').map((e) => e.name)
28  const skillTexts = (await Promise.all(skillDirs.map((d) => readText(fs, `.claude/skills/${d}/SKILL.md`)))).filter((t): t is string => t !== null)
29  const agentFiles = (await list(fs, '.claude/agents')).filter((e) => e.kind === 'file' && e.name.endsWith('.md'))
30  const agentTexts = (await Promise.all(agentFiles.map((e) => readText(fs, `.claude/agents/${e.name}`)))).filter((t): t is string => t !== null)
31  const settings = await readJson(fs, '.claude/settings.json')
32  const pkg = await readJson(fs, 'package.json')
33  const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) }
34  const workflows = (await list(fs, '.github/workflows')).filter((e) => /\.ya?ml$/.test(e.name))
35  const hasPython = (await exists(fs, 'pyproject.toml')) || (await exists(fs, 'requirements.txt'))
36  const hasGo = await exists(fs, 'go.mod')
37  const hasRust = await exists(fs, 'Cargo.toml')
38
39  let test_framework: string | null = null
40  if (deps.vitest) test_framework = 'vitest'
41  else if (deps.jest) test_framework = 'jest'
42  else if (/node --test/.test(String(pkg?.scripts?.test ?? ''))) test_framework = 'node-test'
43  else if (hasPython && ((await exists(fs, 'pytest.ini')) || (await exists(fs, 'conftest.py')) || /pytest/.test((await readText(fs, 'pyproject.toml')) ?? ''))) test_framework = 'pytest'
44  else if (hasGo) test_framework = 'go-test'
45  else if (hasRust) test_framework = 'cargo-test'
46
47  const languages: string[] = []
48  if (pkg) languages.push((await exists(fs, 'tsconfig.json')) || deps.typescript ? 'typescript' : 'javascript')
49  if (hasPython) languages.push('python')
50  if (hasGo) languages.push('go')
51  if (hasRust) languages.push('rust')
52
53  const hasTestDir = (await exists(fs, 'test')) || (await exists(fs, 'tests')) || (await exists(fs, '__tests__'))
54
55  return {
56    has_claude_md: (await exists(fs, 'CLAUDE.md')) || (await exists(fs, '.claude/CLAUDE.md')),
57    has_agents_md: await exists(fs, 'AGENTS.md'),
58    has_skills: skillTexts.length > 0,
59    has_large_skill_md: skillTexts.some((t) => t.replace(/\r?\n$/, '').split('\n').length > 500),
60    has_subagents: agentTexts.length > 0,
61    subagents_without_tool_limits: agentTexts.some((t) => !/^tools\s*:/m.test(frontmatter(t))),
62    hooks_configured: !!settings?.hooks && Object.keys(settings.hooks).length > 0,
63    sandbox_enabled: settings?.sandbox?.enabled === true,
64    has_tests: test_framework !== null || hasTestDir,
65    has_ci: workflows.length > 0 || (await exists(fs, '.gitlab-ci.yml')) || (await exists(fs, '.circleci/config.yml')),
66    grounded_adopted: await exists(fs, '.grounded-engineering/manifest.yaml'),
67    languages,
68    test_framework,
69  }
70}
71
72function frontmatter(text: string): string {
73  const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)
74  return m?.[1] ?? ''
75}
76
77export async function readAdoption(host: Host): Promise<Adoption | null> {
78  const text = await readText(host.fs, '.grounded-engineering/manifest.yaml')
79  if (text === null) return null
80  const profile = /^pack_id:\s*([\w-]+)/m.exec(text)?.[1] ?? null
81  // Card ids are the `id:` of each entry under the top-level `cards:`; an id named in a
82  // card's free text (a decision, a revisit trigger) is not an adopted card.
83  const cards = new Set<string>()
84  let inCards = false
85  for (const line of text.split(/\r?\n/)) {
86    if (/^\S/.test(line)) inCards = /^cards:/.test(line)
87    else if (inCards) {
88      const id = /^\s*(?:-\s+)?id:\s*["']?(GE-[A-Z]{2}-\d{3})["']?\s*$/.exec(line)?.[1]
89      if (id) cards.add(id)
90    }
91  }
92  return { profile, cards: [...cards].sort() }
93}
94
hooks/theme.ts 19 lines
1// Colours for the pane. Every value is a theme key, not a hex colour: the drawing surface
2// resolves keys for the app's light or dark mode, which a plugin cannot read.
3export type Tone = 'ok' | 'warn' | 'neutral' | 'accent' | 'failed'
4type Swatch = { bg?: string; edge: string; text: string }
5export type Palette = Record<Tone, Swatch> & { dim: string }
6
7export const PALETTE: Palette = {
8  ok: { bg: 'diffAddedDimmed', edge: 'success', text: 'success' },
9  warn: { bg: 'userMessageBackground', edge: 'warning', text: 'warning' },
10  neutral: { bg: 'userMessageBackground', edge: 'promptBorder', text: 'text' },
11  accent: { bg: 'background', edge: 'claude', text: 'claude' },
12  failed: { bg: 'diffRemovedDimmed', edge: 'error', text: 'error' },
13  dim: 'inactive',
14}
15
16// The terminal theme has no 'background' key for a surface fill; it draws one in solid cyan,
17// so an open card there keeps only its accent edge.
18export const TERMINAL_PALETTE: Palette = { ...PALETTE, accent: { edge: 'claude', text: 'claude' } }
19
hooks/model.ts 34 lines
1import type { Catalog, FitRule, Practice, SkillRepo } from './catalog'
2import type { Adoption, Signals } from './signals'
3
4export type SlimPractice = Omit<Practice, 'body'>
5export type SlimSkillRepo = Omit<SkillRepo, 'install' | 'install_note'>
6export type SlimCatalog = {
7  package_version: string; repository: string; categories: string[]
8  signals: { name: string; description: string }[]
9  practices: SlimPractice[]; skill_repos: SlimSkillRepo[]; fit_rules: FitRule[]
10}
11export type Screen = 'practices' | 'skills'
12// Per client: the highest `seq` received, and the highest whose action has finished. Posts carry
13// a client's presses in `seq` order, so these two numbers are enough.
14export type Ack = { received: number; done: number }
15export type Acks = Record<string, Ack>
16export type PaneModel = {
17  catalog: SlimCatalog | null; error: string | null
18  screen: Screen; selected: string | null; query: string; category: string; tag: string; sort: 'fit' | 'name'
19  showSignals: boolean; collapsed: { fits: boolean; all: boolean }
20  signals: Signals | null; adoption: Adoption | null
21  acks: Acks
22}
23
24// What the pane draws. Card bodies, the source list and install steps stay behind: the pane never
25// shows them, the skills read them from catalog.json, and Client props are size-bounded.
26export function slimCatalog(c: Catalog): SlimCatalog {
27  return {
28    package_version: c.package_version, repository: c.repository, categories: c.categories,
29    signals: c.signals.map(({ name, description }) => ({ name, description })),
30    practices: c.practices.map(({ body, ...rest }) => rest),
31    skill_repos: c.skill_repos.map(({ install, install_note, ...rest }) => rest), fit_rules: c.fit_rules,
32  }
33}
34
hooks/screens.tsx 256 lines
1// The pane's screens: pure functions of a plain-data model, the element table and handlers.
2// The terminal draws them directly; on desktop the Client module draws the same screens.
3import type { Palette } from './theme'
4import { tile, laneHeader, badge, chip, option, field, shorten, room } from './tiles'
5import { cardUrl, repoUrl } from './catalog'
6import { rankPractices, sortSkillRepos, matchesQuery } from './fit'
7import type { PaneModel, Screen, SlimCatalog, SlimPractice, SlimSkillRepo } from './model'
8
9export const HANDLER_NAMES = ['tab', 'select', 'search', 'category', 'tag', 'sort', 'toggleSignals', 'toggleLane', 'adapt', 'explain', 'link'] as const
10
11type Go = {
12  tab: (s: Screen) => void; select: (key: string) => void; search: (q: string) => void
13  category: (c: string) => void; tag: (t: string) => void; sort: (s: 'fit' | 'name') => void
14  toggleSignals: () => void; toggleLane: (lane: 'fits' | 'all') => void
15  adapt: (id: string) => void; explain: (id: string) => void
16  link: (url: string) => void
17}
18
19// The other lists the skills screen points to; the terminal draws each label with a ›.
20const MORE = [
21  { label: 'awesome-claude-skills', href: 'https://github.com/ComposioHQ/awesome-claude-skills' },
22  { label: 'awesome-claude-code-mods', href: 'https://github.com/karanb192/awesome-claude-code-mods' },
23]
24
25// Below this many columns the tabs take the whole header row and the title is left out.
26const NARROW = 60
27
28const PANE = 2 // pane padding
29const SYMBOL = 2 // a row's status symbol and its gap
30const OUTLINE = 4 // the outline and the label padding
31const TILE = 4 // a tile's border and padding
32const TERMINAL = 9 // the terminal's fixed overhead per row
33const TERMINAL_TAGS = 8 // the terminal's room for a repo row's tags
34// On desktop, titles are cut to one line; below WIDE columns the category, tags and badge give way.
35const WIDE = 70
36const outlined = (ui: any) => ui.outlinesTitles === true
37const tight = (ui: any, columns: number) => outlined(ui) && columns > 0 && columns < WIDE
38
39export function rowTitleRoom(ui: any, columns: number, scale: number, category: string): number {
40  if (!outlined(ui)) return room(columns, TERMINAL + category.length, scale)
41  return room(columns, PANE + SYMBOL + OUTLINE + (tight(ui, columns) ? 0 : category.length + 1), scale)
42}
43
44export function repoTitleRoom(ui: any, columns: number, scale: number, tags: string): number {
45  if (!outlined(ui)) return room(columns, TERMINAL + TERMINAL_TAGS, scale)
46  return room(columns, PANE + OUTLINE + (tight(ui, columns) ? 0 : tags.length + 1), scale)
47}
48
49export function tileTitleRoom(ui: any, columns: number, scale: number, badgeText: string): number {
50  return room(columns, PANE + TILE + OUTLINE + (tight(ui, columns) ? 0 : badgeText.length + 1), scale)
51}
52
53// The links the pane draws, each with what the transcript says before it.
54type LinkCatalog = { repository: string; package_version: string; practices: { id: string; path: string }[]; skill_repos: SlimSkillRepo[] }
55export function linkLabel(c: LinkCatalog, url: string): string | null {
56  const practice = c.practices.find((x) => cardUrl(c, x) === url)
57  if (practice) return `Evidence for ${practice.id}`
58  const repo = c.skill_repos.find((r) => repoUrl(r) === url)
59  if (repo) return `${repo.name} on GitHub`
60  if (url === c.repository) return 'Grounded Engineering on GitHub'
61  const more = MORE.find((l) => l.href === url)
62  return more ? more.label : null
63}
64
65const isValidated = (x: { validation_status: string }) => x.validation_status === 'validated'
66const byline = (r: SlimSkillRepo) => `${r.featured ? 'Featured · ' : ''}${r.repo.split('/')[0]} · ${r.license}`
67
68export function paneScreen(ui: any, m: PaneModel, go: Go, p: Palette, columns: number, scale: number) {
69  const { Box, Text, Button } = ui
70  if (!m.catalog) {
71    return (
72      <Box key="catalog-error" flexDirection="column">
73        <Text color={p.failed.text}>{`${m.error ?? 'catalog missing or invalid'}. Reinstall the plugin: /plugin install grounded-engineering@grounded-engineering`}</Text>
74      </Box>
75    )
76  }
77  const c = m.catalog
78  return (
79    <Box flexDirection="column" gap={1}>
80      <Box flexDirection="row" justifyContent="space-between" gap={1}>
81        {columns > 0 && columns < NARROW ? null : <Box flexGrow={1} flexShrink={1} minWidth={0}><Text bold wrap="truncate-end">Grounded Engineering</Text></Box>}
82        <Box flexDirection="row" gap={1} flexShrink={0}>
83          <Button key="tab-practices" hotkey="1" variant={m.screen === 'practices' ? 'primary' : 'secondary'} label={`Practices ${c.practices.length}`} onPress={() => go.tab('practices')} />
84          <Button key="tab-skills" hotkey="2" variant={m.screen === 'skills' ? 'primary' : 'secondary'} label={`Skill repos ${c.skill_repos.length}`} onPress={() => go.tab('skills')} />
85        </Box>
86      </Box>
87      {m.signals === null
88        ? <Text color={p.dim}>Reading this repository…</Text>
89        : m.screen === 'practices' ? practicesScreen(ui, m, c, go, p, columns, scale) : skillsScreen(ui, m, c, go, p, columns, scale)}
90    </Box>
91  )
92}
93
94function practicesScreen(ui: any, m: PaneModel, c: SlimCatalog, go: Go, p: Palette, columns: number, scale: number) {
95  const { Box, Text, Button, Link } = ui
96  const s = m.signals!
97  const all = rankPractices(c, s, m.adoption, Infinity)
98  const fits = all.slice(0, 3)
99  const adopted = new Set(m.adoption?.cards ?? [])
100  const visible = c.practices.filter((x) => (m.category === 'All' || x.category === m.category) && matchesQuery([x.title, x.pattern, x.id], m.query))
101  const gapText = all.length === 0 ? 'no gaps' : `${all.length} gap${all.length === 1 ? '' : 's'}`
102  const detected = [...s.languages, s.test_framework ?? (s.has_tests ? 'tests (framework unknown)' : 'no tests')].join(' · ')
103  return (
104    <Box flexDirection="column" gap={1}>
105      <Box flexDirection="row" justifyContent="space-between" gap={1}>
106        <Box flexDirection="row" gap={1} flexShrink={1} minWidth={0} flexWrap="wrap">
107          {chip(ui, p, all.length ? 'warn' : 'ok', gapText)}
108          {chip(ui, p, 'neutral', `Detected: ${detected}`)}
109          {m.adoption ? chip(ui, p, 'neutral', `Adopted: ${m.adoption.profile ?? 'custom'} · ${m.adoption.cards.length}`) : null}
110        </Box>
111        <Box flexShrink={0}><Button key="details" plain hotkey="d" label={m.showSignals ? 'Hide details' : 'Details ›'} onPress={() => go.toggleSignals()} /></Box>
112      </Box>
113      {m.showSignals ? (
114        <Box key="signals" flexDirection="column">
115          {c.signals.map((sig) => <Text color={p.dim} wrap="truncate-end">{`${sig.name}: ${JSON.stringify((s as Record<string, unknown>)[sig.name])}`}</Text>)}
116          <Link href={c.repository}>☆ Star Grounded Engineering</Link>
117        </Box>
118      ) : null}
119      {field(ui, p, 'search', 'Search practices', m.query, (v) => go.search(v))}
120      {laneHeader(ui, p, 'warn', 'fits', '✋ Fits this repo', fits.length, !m.collapsed.fits, () => go.toggleLane('fits'))}
121      {m.collapsed.fits ? null : fits.length === 0
122        ? <Text color={p.dim}>Your repo already covers the basics.</Text>
123        : <Box flexDirection="column" gap={1}>{fits.map((f) => practiceTile(ui, m, c, go, p, f.practice, `fit-${f.practice.id}`, 'Why here', f.why, adopted.has(f.practice.id), columns, scale, f.practice.agent_snippet ?? f.practice.pattern))}</Box>}
124      {laneHeader(ui, p, 'neutral', 'all', 'All practices', visible.length, !m.collapsed.all, () => go.toggleLane('all'))}
125      {m.collapsed.all ? null : (
126        <Box flexDirection="column" gap={1}>
127          <Box flexDirection="row" columnGap={2} rowGap={0} flexWrap="wrap">
128            {option(ui, p, 'opt-cat-All', 'All', m.category === 'All', () => go.category('All'))}
129            {c.categories.map((cat) => option(ui, p, `opt-cat-${cat}`, cat, m.category === cat, () => go.category(cat)))}
130          </Box>
131          {visible.length === 0 ? <Text color={p.dim}>No matches.</Text> : (
132            <Box flexDirection="column">
133              {visible.map((x) => m.selected === `all-${x.id}`
134                ? practiceTile(ui, m, c, go, p, x, `all-${x.id}`, 'In short', x.agent_snippet ?? x.pattern, adopted.has(x.id), columns, scale)
135                : practiceRow(ui, go, p, x, columns, scale))}
136            </Box>
137          )}
138        </Box>
139      )}
140    </Box>
141  )
142}
143
144function practiceRow(ui: any, go: Go, p: Palette, x: SlimPractice, columns: number, scale: number) {
145  const { Box, Text, Button } = ui
146  const tone = isValidated(x) ? 'ok' : 'warn'
147  const beside = !tight(ui, columns)
148  const budget = rowTitleRoom(ui, columns, scale, x.category)
149  return (
150    <Box key={`row-${x.id}`} flexDirection="row" gap={1}>
151      <Box flexShrink={0}><Text color={p[tone].text}>{isValidated(x) ? '✓' : '●'}</Text></Box>
152      <Box flexGrow={1} flexShrink={1} minWidth={0}>
153        <Button key={`open-all-${x.id}`} plain label={shorten(x.title, budget)} onPress={() => go.select(`all-${x.id}`)} />
154      </Box>
155      {beside ? <Box flexShrink={0}><Text color={p.dim} wrap="truncate-end">{x.category}</Text></Box> : null}
156    </Box>
157  )
158}
159
160// A tile's title and its badge: side by side, or on desktop below WIDE columns one under the other.
161function tileTitle(ui: any, key: string, label: string, onPress: () => void, badgeText: string, badgeNode: unknown, columns: number, scale: number) {
162  const { Box, Button } = ui
163  const under = tight(ui, columns)
164  const text = outlined(ui) ? shorten(label, tileTitleRoom(ui, columns, scale, badgeText)) : label
165  const title = (
166    <Box flexGrow={1} flexShrink={1} minWidth={0}>
167      <Button key={key} plain label={text} onPress={onPress} />
168    </Box>
169  )
170  return under
171    ? <Box flexDirection="column">{title}{badgeNode}</Box>
172    : <Box flexDirection="row" gap={1}>{title}{badgeNode}</Box>
173}
174
175function practiceTile(ui: any, m: PaneModel, c: SlimCatalog, go: Go, p: Palette, x: SlimPractice, key: string, calloutLabel: string, callout: string, isAdopted: boolean, columns: number, scale: number, summary: string | null = null) {
176  const { Box, Text, Button, Link } = ui
177  const isOpen = m.selected === key
178  const tone = isValidated(x) ? 'ok' : 'warn'
179  const badgeText = isValidated(x) ? '✓ Validated' : '● Needs review'
180  return tile(ui, p, isOpen ? 'accent' : 'neutral', `tile-${key}`, [
181    tileTitle(ui, `open-${key}`, x.title, () => go.select(key), badgeText, badge(ui, p, tone, badgeText), columns, scale),
182    isAdopted ? <Text color={p.dim}>Adopted through the CLI</Text> : null,
183    // What the practice is, before it opens; the opened tile shows its pattern and trade-off instead.
184    summary && !isOpen ? <Text color={p.dim}>{`In short: ${summary}`}</Text> : null,
185    <Text><Text bold>{`${calloutLabel}: `}</Text>{callout}</Text>,
186    isOpen ? <Text color={p.dim}>{x.pattern}</Text> : null,
187    isOpen ? <Text color={p.dim}><Text color={p.warn.text}>! </Text>{`Trade-off: ${x.rationale}`}</Text> : null,
188    isOpen ? <Text color={p.dim} wrap="truncate-end">{`${x.category} · ${x.source_ids.join(', ')} · ${x.id}`}</Text> : null,
189    isOpen ? (
190      <Box flexDirection="row" columnGap={2} rowGap={0} flexWrap="wrap">
191        {isAdopted ? null : <Button key={`primary-${key}`} variant="primary" hotkey="a" label="Adapt to this repo" onPress={() => go.adapt(x.id)} />}
192        <Link href={cardUrl(c, x)}>Evidence ›</Link>
193      </Box>
194    ) : null,
195  ])
196}
197
198function repoRow(ui: any, go: Go, p: Palette, r: SlimSkillRepo, columns: number, scale: number) {
199  const { Box, Text, Button } = ui
200  const tags = `${r.featured ? 'Featured · ' : ''}${r.tags.join(', ')}`
201  const beside = !tight(ui, columns)
202  const budget = repoTitleRoom(ui, columns, scale, tags)
203  return (
204    <Box key={`row-repo-${r.id}`} flexDirection="row" gap={1}>
205      <Box flexGrow={1} flexShrink={1} minWidth={0}>
206        <Button key={`open-repo-${r.id}`} plain label={shorten(r.name, budget)} onPress={() => go.select(`repo-${r.id}`)} />
207      </Box>
208      {beside ? <Box flexShrink={0}><Text color={p.dim} wrap="truncate-end">{tags}</Text></Box> : null}
209    </Box>
210  )
211}
212
213function skillsScreen(ui: any, m: PaneModel, c: SlimCatalog, go: Go, p: Palette, columns: number, scale: number) {
214  const { Box, Text, Button, Link } = ui
215  const tags = [...new Set(c.skill_repos.flatMap((r) => r.tags))].sort()
216  const repos = sortSkillRepos(c.skill_repos, m.signals!, m.sort)
217    .filter((r) => (m.tag === 'All' || r.tags.includes(m.tag)) && matchesQuery([r.name, r.repo, r.summary], m.query))
218  return (
219    <Box flexDirection="column" gap={1}>
220      {field(ui, p, 'search', 'Search skill repos', m.query, (v) => go.search(v))}
221      <Box flexDirection="row" justifyContent="space-between" gap={1}>
222        <Box flexShrink={1} minWidth={0}><Text bold wrap="truncate-end">{`Reviewed skill repos ${c.skill_repos.length}`}</Text></Box>
223        <Box flexDirection="row" gap={2} flexShrink={0}>
224          {option(ui, p, 'opt-sort-fit', 'Fit', m.sort === 'fit', () => go.sort('fit'))}
225          {option(ui, p, 'opt-sort-name', 'Name', m.sort === 'name', () => go.sort('name'))}
226        </Box>
227      </Box>
228      {tags.length ? (
229        <Box flexDirection="row" columnGap={2} rowGap={0} flexWrap="wrap">
230          {option(ui, p, 'opt-tag-All', 'All', m.tag === 'All', () => go.tag('All'))}
231          {tags.map((t) => option(ui, p, `opt-tag-${t}`, t, m.tag === t, () => go.tag(t)))}
232        </Box>
233      ) : null}
234      {c.skill_repos.length === 0 ? <Text color={p.dim}>No skill repos are listed yet.</Text>
235        : repos.length === 0 ? <Text color={p.dim}>No matches.</Text>
236        : (
237          <Box flexDirection="column" gap={1}>
238            {repos.map((r) => m.selected === `repo-${r.id}` ? tile(ui, p, 'accent', `tile-repo-${r.id}`, [
239              tileTitle(ui, `open-repo-${r.id}`, r.name, () => go.select(`repo-${r.id}`), byline(r), <Text color={p.dim} wrap="truncate-end">{byline(r)}</Text>, columns, scale),
240              <Text>{r.summary}</Text>,
241              <Text color={p.dim}><Text color={p.warn.text}>! </Text>{`Watch out: ${r.watch_out_for}`}</Text>,
242              <Text color={p.dim} wrap="truncate-end">{`Reviewed ${r.reviewed_on} · pinned at ${r.pinned_commit.slice(0, 7)}`}</Text>,
243              <Box flexDirection="row" columnGap={2} rowGap={0} flexWrap="wrap">
244                <Button key={`primary-repo-${r.id}`} variant="primary" hotkey="a" label="Explain install" onPress={() => go.explain(r.id)} />
245                <Link href={repoUrl(r)}>☆ Star on GitHub</Link>
246              </Box>,
247            ]) : repoRow(ui, go, p, r, columns, scale))}
248          </Box>
249        )}
250      <Text bold>Want more?</Text>
251      {MORE.map((l) => <Link href={l.href}>{`${l.label} ›`}</Link>)}
252      <Text color={p.dim}>Authors can ask to be removed.</Text>
253    </Box>
254  )
255}
256
hooks/client/apply.ts 59 lines
1import type { PaneModel } from '../model'
2
3// A press: the client id (`cid`), its sequence number within that client (`seq`, from 1), and
4// the action it asks for.
5export type Msg = { cid: string; seq: number; name: string; args: unknown[] }
6// Presses the Client shows at once, before the plugin answers.
7export const LOCAL_NAMES = new Set(['tab', 'select', 'search', 'category', 'tag', 'sort', 'toggleSignals', 'toggleLane'])
8
9// The model as it will be once this press lands; used only for presses not yet acknowledged.
10export function applyLocal(m: PaneModel, msg: Msg): PaneModel {
11  const [a] = msg.args as [any]
12  switch (msg.name) {
13    case 'tab': return { ...m, screen: a, selected: null, query: '' }
14    case 'select': return { ...m, selected: m.selected === a ? null : a }
15    case 'search': return { ...m, query: a }
16    case 'category': return { ...m, category: a }
17    case 'tag': return { ...m, tag: a }
18    case 'sort': return { ...m, sort: a }
19    case 'toggleSignals': return { ...m, showSignals: !m.showSignals }
20    case 'toggleLane': return { ...m, collapsed: { ...m.collapsed, [a]: !m.collapsed[a as 'fits' | 'all'] } }
21    default: return m
22  }
23}
24
25// A posted press is data from code, so it runs only when its name is one of the pane's actions
26// and its arguments are what the pane gives that action: the right count, the right types,
27// and for a skill the id of a card or repo in the catalog.
28type Known = { practices: { id: string }[]; skill_repos: { id: string }[] }
29const isString = (v: unknown): v is string => typeof v === 'string'
30const oneOf = (v: unknown, options: readonly string[]): boolean => isString(v) && options.includes(v)
31const ARGS: Record<string, (c: Known | null, a: unknown[]) => boolean> = {
32  tab: (_, a) => a.length === 1 && oneOf(a[0], ['practices', 'skills']),
33  select: (_, a) => a.length === 1 && isString(a[0]),
34  search: (_, a) => a.length === 1 && isString(a[0]),
35  category: (_, a) => a.length === 1 && isString(a[0]),
36  tag: (_, a) => a.length === 1 && isString(a[0]),
37  sort: (_, a) => a.length === 1 && oneOf(a[0], ['fit', 'name']),
38  toggleSignals: (_, a) => a.length === 0,
39  toggleLane: (_, a) => a.length === 1 && oneOf(a[0], ['fits', 'all']),
40  adapt: (c, a) => a.length === 1 && isString(a[0]) && !!c?.practices.some((x) => x.id === a[0]),
41  explain: (c, a) => a.length === 1 && isString(a[0]) && !!c?.skill_repos.some((x) => x.id === a[0]),
42  link: (_, a) => a.length === 1 && isString(a[0]),
43}
44// The `cid` and `seq` of a posted press, or null when either is malformed; such a press cannot
45// be acknowledged, so the plugin ignores it.
46const CID = /^[\w-]{1,40}$/
47export function pressOf(act: unknown): { cid: string; seq: number } | null {
48  if (!act || typeof act !== 'object') return null
49  const { cid, seq } = act as Record<string, unknown>
50  return isString(cid) && CID.test(cid) && Number.isSafeInteger(seq) && (seq as number) > 0 ? { cid, seq: seq as number } : null
51}
52export function validAct(catalog: Known | null, act: unknown): act is Msg {
53  if (!pressOf(act)) return false
54  const { name, args } = act as Record<string, unknown>
55  if (!isString(name) || !Array.isArray(args)) return false
56  const check = Object.prototype.hasOwnProperty.call(ARGS, name) ? ARGS[name] : undefined
57  return !!check && check(catalog, args)
58}
59
hooks/client/app.tsx 65 lines
1// The desktop pane: draws the shared screens from the model it is given and shows a press at
2// once; ./outbox says how presses reach the plugin.
3import { paneScreen, HANDLER_NAMES } from '../screens'
4import type { PaneModel } from '../model'
5import { PALETTE } from '../theme'
6import { ackOf, enqueue, nextPost, starting, unfinished, unseen, type PostState } from './outbox'
7import { applyLocal, LOCAL_NAMES, type Msg } from './apply'
8import { clientLook } from './look'
9
10const CHARS_PER_CELL = 1.25
11
12type Outbox = { cid: string; outbox: Msg[]; local: Msg[]; seq: number }
13type Local = { box: Outbox; n: number }
14
15export default function GroundedApp(model: PaneModel, surface: any) {
16  let created: Outbox | null = null
17  if (surface.state === undefined) {
18    // First call: start the timers. State is set from a timer or a press, never while drawing.
19    const box: Outbox = { cid: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`, outbox: [], local: [], seq: 0 }
20    created = box
21    // Every draw before the first tick starts its own timers. Only the box the state holds is in
22    // use; a timer holding another box stops itself and its twin.
23    let sent: PostState = { syncDue: false, sentKey: '', sinceSend: 0 }
24    const stopPoster = surface.every(60, () => {
25      if (surface.state !== undefined && (surface.state as Local).box !== box) { stopPoster(); stopSync(); return }
26      if (surface.state === undefined) surface.setState({ box, n: 0 })
27      const r = nextPost(box.outbox, sent)
28      sent = r.s
29      if (r.post) surface.post(r.post)
30    })
31    // Once a second a sync is due, which brings what changed outside the pane.
32    const stopSync = surface.every(1000, () => {
33      sent = { ...sent, syncDue: true }
34    })
35  }
36  const local = surface.state as Local | undefined
37  const box = local?.box ?? created
38  if (box) {
39    const ack = ackOf(model.acks, box.cid)
40    box.outbox = unseen(box.outbox, ack)
41    box.local = unfinished(box.local, ack)
42  }
43  const send = (name: string, args: unknown[]) => {
44    const b = (surface.state as Local | undefined)?.box ?? box
45    if (!b) return
46    b.seq += 1
47    const msg: Msg = { cid: b.cid, seq: b.seq, name, args }
48    b.outbox = enqueue(b.outbox, msg)
49    b.local = [...b.local, msg]
50    surface.setState({ box: b, n: b.seq }) // a distinct state per press, so the Client redraws
51  }
52  const go: Record<string, (...args: unknown[]) => void> = {}
53  for (const name of HANDLER_NAMES) go[name] = (...args: unknown[]) => send(name, args)
54  const waiting = box?.local ?? []
55  const shown = waiting.filter((m) => LOCAL_NAMES.has(m.name)).reduce(applyLocal, model)
56  const { Box, Text } = surface.elements
57  const columns = surface.columns ?? 0
58  return (
59    <Box flexDirection="column" gap={1}>
60      {starting(waiting) ? <Text color={PALETTE.dim}>Starting…</Text> : null}
61      {paneScreen(clientLook(surface.elements, PALETTE, go), shown, go as any, PALETTE, columns, CHARS_PER_CELL)}
62    </Box>
63  )
64}
65
hooks/tiles.tsx 71 lines
1// Building blocks for the pane: pure functions of the element table, a palette and data.
2import type { Palette, Tone } from './theme'
3
4type Ui = any
5
6// How many characters fit in the columns left after `overhead` cells of other things on the
7// row. scale is characters per cell: 1 on the terminal's monospace grid, about 1.25 on the
8// desktop, whose proportional font fits a little more. Zero columns means the width is unknown.
9export function room(columns: number, overhead: number, scale: number): number {
10  if (!columns) return 0
11  return Math.max(0, Math.floor((columns - overhead) * scale))
12}
13
14// One line, cut with an ellipsis when it would not fit; room 10 or less means do not cut.
15export function shorten(text: string, budget: number): string {
16  const line = text.split('\n').find((l) => l.trim()) ?? ''
17  if (budget <= 10 || line.length <= budget) return line
18  return `${line.slice(0, budget - 1).trimEnd()}…`
19}
20
21// No margin of its own: every column of tiles is spaced with gap={1}.
22export function tile(ui: Ui, p: Palette, tone: Tone, key: string, children: unknown[]) {
23  const { Box } = ui
24  const c = p[tone]
25  return (
26    <Box key={key} flexDirection="column" borderStyle="round" borderColor={c.edge} {...(c.bg ? { backgroundColor: c.bg } : {})} paddingX={1}>
27      {children}
28    </Box>
29  )
30}
31
32export function laneHeader(ui: Ui, p: Palette, tone: Tone, key: string, label: string, count: number, isOpen: boolean, onToggle: () => void) {
33  const { Box, Text, Button } = ui
34  return (
35    <Box key={key} flexDirection="row" justifyContent="space-between">
36      <Button key={`lane-${key}`} plain label={`${isOpen ? '▾' : '▸'} ${label}`} onPress={onToggle} />
37      <Text color={p[tone].text}>{String(count)}</Text>
38    </Box>
39  )
40}
41
42// A badge never shrinks: squeezed, it breaks into fragments.
43export function badge(ui: Ui, p: Palette, tone: Tone, text: string) {
44  const { Box, Text } = ui
45  return <Box flexShrink={0} alignSelf="flex-start"><Text color={p[tone].text} wrap="truncate-end">{text}</Text></Box>
46}
47
48export function chip(ui: Ui, p: Palette, tone: Tone, text: string) {
49  const { Text } = ui
50  return <Text color={p[tone].text} {...(p[tone].bg ? { backgroundColor: p[tone].bg } : {})} wrap="truncate-end">{` ${text} `}</Text>
51}
52
53// A picked-dot choice: a plain Button that reads as a radio option.
54export function option(ui: Ui, p: Palette, key: string, label: string, isOn: boolean, onPress: () => void) {
55  const { Button } = ui
56  return <Button key={key} plain dimColor={!isOn} label={`${isOn ? '●' : '○'} ${label}`} onPress={onPress} />
57}
58
59// A labelled text field drawn inside a box on every surface, so the terminal shows where to type.
60export function field(ui: Ui, p: Palette, key: string, label: string, value: string, onChange: (v: string) => void) {
61  const { Box, Text, Input } = ui
62  return (
63    <Box key={`${key}-box`} flexDirection="column">
64      <Text color={p.dim}>{label}</Text>
65      <Box borderStyle="round" borderColor={p.neutral.edge} paddingX={1}>
66        <Input key={key} value={value} onInput={(v: string) => onChange(v)} onSubmit={(v: string) => onChange(v)} />
67      </Box>
68    </Box>
69  )
70}
71
hooks/fit.ts 42 lines
1import type { FitRule, SkillRepo } from './catalog'
2import { SIGNAL_NAMES, type Adoption, type Signals } from './signals'
3
4type Bools = { [K in keyof Signals]: Signals[K] extends boolean ? K : never }[keyof Signals]
5
6// The pane lists the first `limit` fits; its gap count asks for them all (`Infinity`).
7export function rankPractices<P extends { id: string; validation_status: string }>(catalog: { practices: P[]; fit_rules: FitRule[] }, signals: Signals, adoption: Adoption | null, limit = 3): { practice: P; why: string }[] {
8  const on = (name: string) => signals[name as Bools] === true
9  const adopted = new Set(adoption?.cards ?? [])
10  const byId = new Map(catalog.practices.map((p) => [p.id, p]))
11  const picked = new Map<string, string>()
12  const known = new Set<string>(SIGNAL_NAMES)
13  for (const rule of catalog.fit_rules) {
14    // A rule naming a signal this plugin does not read cannot be judged, so it never fires.
15    if (![...rule.when.all, ...rule.when.any, ...rule.when.none].every((name) => known.has(name))) continue
16    const fires = rule.when.all.every(on) && (rule.when.any.length === 0 || rule.when.any.some(on)) && !rule.when.none.some(on)
17    if (fires && !picked.has(rule.card) && !adopted.has(rule.card)) picked.set(rule.card, rule.why)
18  }
19  return [...picked]
20    .map(([id, why]) => ({ practice: byId.get(id)!, why }))
21    .sort((a, b) => Number(b.practice.validation_status === 'validated') - Number(a.practice.validation_status === 'validated') || (a.practice.id < b.practice.id ? -1 : 1))
22    .slice(0, limit)
23}
24
25const TAG_SIGNALS = new Map<string, (s: Signals) => boolean>([
26  ['typescript', (s) => s.languages.includes('typescript')],
27  ['python', (s) => s.languages.includes('python')],
28  ['testing', (s) => s.has_tests],
29  ['devops', (s) => s.has_ci],
30])
31
32export function sortSkillRepos<R extends Pick<SkillRepo, 'name' | 'tags' | 'featured'>>(repos: R[], signals: Signals, mode: 'fit' | 'name'): R[] {
33  const score = (r: R) => r.tags.filter((t) => TAG_SIGNALS.get(t)?.(signals)).length
34  const fit = (a: R, b: R) => Number(b.featured) - Number(a.featured) || score(b) - score(a)
35  return [...repos].sort((a, b) => (mode === 'fit' ? fit(a, b) : 0) || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
36}
37
38export function matchesQuery(text: string[], query: string): boolean {
39  const q = query.trim().toLowerCase()
40  return q === '' || text.some((t) => t.toLowerCase().includes(q))
41}
42
hooks/client/outbox.ts 47 lines
1// The desktop Client's press queue: pure helpers with no Node imports, since the Client runs in the app's view.
2import type { Msg } from './apply'
3import type { Ack, Acks } from '../model'
4
5// Edits carry the whole value, so a newer one replaces an unsent older one.
6const EDITS = new Set(['search'])
7
8export function enqueue(outbox: Msg[], msg: Msg): Msg[] {
9  const kept = EDITS.has(msg.name) ? outbox.filter((m) => m.name !== msg.name) : outbox
10  return [...kept, msg]
11}
12
13export type PostState = { syncDue: boolean; sentKey: string; sinceSend: number }
14export type Post = { kind: 'acts'; acts: Msg[] } | { kind: 'sync' }
15
16// The post for this tick. The app keeps only the last post of a frame, and a busy or background
17// window can run several ticks in one frame, so a press posted on its own could be silently
18// replaced (a skill that never starts). Every post therefore carries all presses not yet
19// received, in order; the plugin skips any `seq` at or below the last it received.
20export const RESEND = 8
21export function nextPost(outbox: Msg[], s: PostState): { post: Post | null; s: PostState } {
22  const key = outbox.map((m) => m.seq).join(',')
23  if (outbox.length && (key !== s.sentKey || s.sinceSend >= RESEND)) {
24    return { post: { kind: 'acts', acts: outbox }, s: { syncDue: false, sentKey: key, sinceSend: 0 } }
25  }
26  if (!outbox.length && s.syncDue) return { post: { kind: 'sync' }, s: { syncDue: false, sentKey: '', sinceSend: 0 } }
27  return { post: null, s: { ...s, sinceSend: s.sinceSend + 1 } }
28}
29
30export function ackOf(acks: Acks | undefined, cid: string): Ack {
31  return acks?.[cid] ?? { received: 0, done: 0 }
32}
33
34// Still to post.
35export function unseen(outbox: Msg[], ack: Ack): Msg[] {
36  return outbox.filter((m) => m.seq > ack.received)
37}
38
39// Still to show.
40export function unfinished(local: Msg[], ack: Ack): Msg[] {
41  return local.filter((m) => m.seq > ack.done)
42}
43
44export function starting(local: Msg[]): boolean {
45  return local.some((m) => m.name === 'adapt' || m.name === 'explain')
46}
47
hooks/client/look.tsx 69 lines
1// How the desktop Client draws the pane's controls. The Client paints a Button with no
2// chrome, stretched across its row with the label centred, so these wrap the table's
3// Button (and Link) and the shared screens read there as they do in the terminal, which
4// keeps its own table untouched. A filled Box reads as highlighted text in the Client (it
5// fills only the text's own line), so a button is an outlined box sized to its label; the
6// label carries the padding, so every cell inside the outline is the Button.
7import type { Palette } from '../theme'
8
9// A short, stable name for an address, so a link's Button keeps its key across draws.
10function hashOf(text: string): string {
11  let h = 0x811c9dc5
12  for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 0x01000193)
13  return (h >>> 0).toString(36)
14}
15
16export function clientLook(el: any, p: Palette, go: { link?: (url: string) => void } = {}) {
17  const { Box, Button } = el
18  const outline = (button: unknown, primary = false) => (
19    <Box flexDirection="row" flexShrink={0}>
20      <Box borderStyle="round" borderColor={primary ? p.accent.edge : p.neutral.edge} {...(primary ? { backgroundColor: p.accent.bg } : {})}>
21        {button}
22      </Box>
23    </Box>
24  )
25  const LookButton = (props: any) => {
26    // A hotkey answers only in the terminal pane, and a dim label is not known to draw in
27    // the Client: the picked dot already marks which option is on.
28    const { children, hotkey: _hotkey, dimColor: _dimColor, ...rest } = props
29    const label = String(rest.label ?? (typeof children === 'string' ? children : ''))
30    const key = String(rest.key ?? '')
31    // A title that opens a tile is outlined like any action, but gives way to what sits
32    // beside it: the screens cut it to one line, and minWidth 0 lets it shrink, not wrap.
33    if (key.startsWith('open-')) {
34      delete rest.plain
35      return (
36        <Box flexDirection="row" flexShrink={1} minWidth={0}>
37          <Box borderStyle="round" borderColor={p.neutral.edge} flexShrink={1} minWidth={0}>
38            <Button {...rest} label={` ${label} `} />
39          </Box>
40        </Box>
41      )
42    }
43    // Text stays a plain control where its place says so: an option with its picked dot, a
44    // lane header with its arrow, the details toggle.
45    if (/^(opt-|lane-|details)/.test(key)) {
46      return <Box flexDirection="row" minWidth={0}><Button {...rest} plain label={label} /></Box>
47    }
48    delete rest.plain
49    return outline(<Button {...rest} label={` ${label} `} />, rest.variant === 'primary')
50  }
51  // The desktop refuses a Link (and a Markdown) inside a Client and unmounts the whole Client
52  // over it, and a Client has no way to open an address. So a link is an outlined button
53  // that asks the plugin to print the address in the transcript, where it opens; the plugin
54  // prints only addresses the pane itself draws. Its key comes from the address, numbered
55  // when the same address is drawn twice in one draw.
56  const drawn = new Map<string, number>()
57  const LookLink = ({ href, children, label }: any) => {
58    const own = [children].flat().filter((c) => typeof c === 'string').join('')
59    const text = String(own || label || href).replace(/ ›$/, '')
60    const base = `link-${hashOf(String(href))}`
61    const n = (drawn.get(base) ?? 0) + 1
62    drawn.set(base, n)
63    const key = n === 1 ? base : `${base}-${n}`
64    return outline(<Button key={key} label={` ${text} ↗ `} onPress={() => go.link?.(String(href))} />)
65  }
66  // outlinesTitles: titles are drawn outlined here, so the screens budget for the outline.
67  return { ...el, Button: LookButton, Link: LookLink, outlinesTitles: true }
68}
69