SLOPSHOPPER

Frontend

Domaine frontend team plugin for Claude Code: the Shopify theme skills (ship, preview theme, steps to test, TA, breaking changes, translations, PR), the…

newcommandtoastpromptprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · fe
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ fe │ ⏺ Read(src/auth.ts) │ fe: needs the base plugin — claude plugin │ ⎿ Read 6 lines │ install base@domaine │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ ⎿ 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 › /fe-doctor ⎿ fe: fe doctor — plugin root: /plugins/fe ⎿ fe: FAIL static scripts/doctor.cjs exited 0: no rows on stdout ⎿ fe: FAIL base-live fe needs the base plugin — claude plugin install base@domaine ⎿ fe: FAIL slim-live slim is not loaded — claude plugin install slim@domaine; base refuses its readers until it is ⎿ fe: doctor: 0 passed, 3 failed, 0 skipped ⎿ fe: ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

fe

fe is the Domaine frontend team plugin for Claude Code. It holds the Shopify theme work: the skills that take a ticket from a Technical Approach to a pull request and a preview theme, the steps to test, the breaking-changes and translation tools, the fe:theme-explorer agent, the project profile (Foundation, another theme, or none) and the store-access rules.

fe builds on base and requires it: the Jira, Figma and doc readers, the Jira writer, the review agents, the task workspace, the commit and review skills, the guards and the shared MCP servers are base's (plugins/base/README.md). base requires slim, so fe runs with slim too.

Current release: fe v0.1.1.

Status

  • Claude Code only: fe is a plugin of skills, an agent, references, scripts and a hooks module (mods). It ships no adapter for another host, and scripts/install.sh --plugin fe exits 2.
  • Requires base ("dependencies": ["base"] in its manifest), and slim through base. The engine does not install a dependency on its own: install all three.
  • Never runs together with fnd — install fnd OR base plus fe.

Install

/plugin marketplace add domaine-oleksandr-kever/claude-plugins
/plugin install slim@domaine
/plugin install band@domaine
/plugin install base@domaine
/plugin install fe@domaine
/reload-plugins

/base-doctor and /fe-doctor then check the install (§ Doctor). The same set as settings, in ~/.claude/settings.json:

{
  "enabledPlugins": {
    "slim@domaine": true,
    "band@domaine": true,
    "base@domaine": true,
    "fe@domaine": true,
    "fnd@domaine": false
  }
}

The team plugins fe, qa, be and pm co-install: each needs only base, and none needs another.

To move from fnd, run /plugin uninstall fnd@domaine, install the set above, and rename every FND_ key fe reads to its FE_ name (FND_PROFILE, FND_GQL_PROBE_CACHE, FND_CPT_THROTTLE_WAITS, FND_CPT_OVERLAY_VERIFY, FND_CPT_OVERLAY_VERIFY_WAIT, FND_THEME_JSON_VERIFY, FND_THEME_JSON_VERIFY_WAIT) in .claude/domaine.env, ~/.config/domaine/env and ~/.claude/settings.json → env: fe does not read the old keys.

Skills

Invoked by their qualified names (/fe:<skill>). They hand off to base's skills and agents by base's qualified names (/base:commit, /base:pre-commit-review, base:jira-reader, …). A hand-off is an offer at the end of the run, never an automatic start; only /fe:ship runs the series by itself.

SkillDoesUses / hands off to
/fe:shipa ready ticket end to end: one interview, one plan and QA-checklist approval, then the series runs itself (implement, QA, review and commit, PR, Steps to Test)base:jira-reader, base:figma-reader, base:doc-reader, fe:theme-explorer; offers /base:worktree before it starts; a missing TA → /fe:write-technical-approach
/fe:write-technical-approachdrafts a Technical Approach from the ticket's Description and Acceptance Criteria, then writes the field after approvalbase:jira-reader, base:doc-reader, base:jira-writer → /fe:develop-feature-or-fix
/fe:develop-feature-or-fiximplements an approved Technical Approach with in-browser validationbase:jira-reader, base:figma-reader, base:doc-reader, fe:theme-explorer, base:bug-hunter → /fe:qa-feature-or-fix
/fe:qa-feature-or-fixstructured QA of a finished change against its ticket: checklist, browser checks, pass / fail reportbase:jira-reader, base:figma-reader, base:jira-writer → /base:pre-commit-review (with the profile word) once every blocking check passes
/fe:write-steps-to-testSteps to Test in Domaine's format, written to the field after approvalbase:jira-reader, base:doc-reader, base:jira-writer → /fe:create-pull-request when the branch has no PR
/fe:create-pull-requesta pull request with the Domaine description and the theme-preview tablebase:jira-reader, base:change-reviewer, base:bug-hunter, /fe:preview-theme's script → /fe:write-steps-to-test while the field is empty
/fe:preview-themecreates or refreshes an unpublished preview theme from the branch; in a new worktree, un-pins the copied store config firstscripts/create-preview-theme.sh, scripts/worktree-theme.sh
/fe:preflight-checkschecks the project, the tools and the dev server before work starts→ /fe:write-technical-approach or /fe:develop-feature-or-fix
/fe:fix-accessibility-issuefixes an accessibility issue in theme components→ /base:commit
/fe:get-breaking-changeslists the breaking changes merged since the last major version in breaking-changes.md→ /fe:fix-breaking-changes
/fe:fix-breaking-changesapplies the documented breaking changes to templates and settings dataits bundled script template; reads /fe:get-breaking-changes's report
/fe:update-translationstranslates storefront and schema strings into the theme's other languagesthe project's own update-translations.js (fe ships none)

The progress rows these skills tick in a ticket's progress.md are fe's progress-series section (§ Conventions).

A worktree: /base:worktree makes the checkout, copies .env and shopify.theme.toml (fe's worktree copy list: line) and records a dev port; then /fe:preview-theme in the worktree runs scripts/worktree-theme.sh, which un-pins the copied config once (a worktree never inherits the main checkout's session theme) and prints the dev-server line with that port. On the main checkout the script answers error=not_a_linked_worktree and the skill goes on without it. /fe:ship offers the same two steps before it starts.

fnd's smoke-test skill has no fe copy: /base-doctor and /fe-doctor check an install. The qa-preflight skill moves to the qa plugin (/qa:preflight).

Agent

AgentDoes
fe:theme-explorerread-only scout of the theme: which files, sections and snippets a change touches; the profile comes in its brief

References and scripts

The skills and the agent cite fe's own files by their path under fe's root (<fe root>/…, the session's fe plugin root: line) and base's by their path under base's root (<base root>/…); tests/team-refs-lint.sh checks that every cited path exists.

ReferenceRead byHolds
references/session-theme.md/fe:ship, /fe:preview-theme, /fe:create-pull-requestone preview theme per work stream: the gate, the pin into shopify.theme.toml, the worktree un-pin
references/preview-theme-errors.md/fe:preview-theme, /fe:create-pull-request, /fe:shipcreate-preview-theme.sh's error= outcomes and page deep-links
references/technical-approach-format.md/fe:write-technical-approachthe short TA format
references/research-pressure-test.md/fe:write-technical-approach, /fe:develop-feature-or-fix, /fe:shipcross-checking a draft plan against fresh external sources
references/metafield-metaobject-setup.md/fe:develop-feature-or-fix, /fe:qa-feature-or-fix, /fe:write-technical-approach, /fe:shipinspecting, creating, mocking and binding store metafields and metaobjects
references/store-auth-troubleshooting.mdthrough metafield-metaobject-setup.mdwhen shopify store auth fails, and the re-auth blurb
references/theme-customizer-state.md/fe:develop-feature-or-fix, /fe:qa-feature-or-fix, /fe:write-technical-approachreading and driving the theme editor's state through theme JSON
references/customizer-sandbox.mdthrough theme-customizer-state.mda disposable theme for a walk that would thrash the shared dev theme
references/preflight-checklist.md/fe:preflight-checks, /fe:develop-feature-or-fix, /fe:qa-feature-or-fixthe environment checklist
references/pipeline-mode.md, references/pipeline-phases.md/fe:shipthe run contract and the phase briefs
references/eslint-no-restricted-syntax.md/fe:develop-feature-or-fix, /fe:fix-accessibility-issue, /fe:shipFoundation only: state through data-* attributes, not classList / style.*
references/section-css-variables-pattern.md/fe:develop-feature-or-fix, /fe:shipFoundation only: a section that drives its blocks' sizes through CSS variables
ScriptRun byDoes
scripts/create-preview-theme.sh/fe:preview-theme, /fe:create-pull-request, /fe:shipbuilds and pushes an unpublished preview theme (create), re-pushes one (refresh), pins the session theme (pin)
scripts/session-theme.shsourced by create-preview-theme.sh, run by worktree-theme.shthe pin and un-pin grammar of shopify.theme.toml (# fe:session-theme, # fe:superseded; a pin fnd wrote is read too)
scripts/worktree-theme.sh/fe:preview-theme in a worktree, /fe:shipthe Shopify half of a new worktree: the one-time un-pin and the dev-server line
scripts/theme-json.shthe skills, through the store-access sectionreads and writes a theme's JSON files (templates/*.json, config/settings_data.json) with a read-back check
scripts/shopify-admin-gql.shthe skills, through the store-access sectionone Admin GraphQL call against the project's store
scripts/project-profile.shfe's hooks module, worktree-theme.sh, the doctor, the skills' fallbackprints foundation, theme or none
scripts/doctor.cjs/fe-doctor, or by handthe static install checks (§ Doctor)
scripts/_shopify-common.shsourced by the store scriptsthe shared Shopify CLI and config helpers

/fe:fix-breaking-changes copies its own skills/fix-breaking-changes/scripts/fix-breaking-changes.template.js into the project and removes it after the run. /fe:write-steps-to-test names the QA store registry, <base root>/scripts/qa-stores.cjs, only to say why it does not read it.

Conventions

fe adds its sections to the main session's system prompt after Claude Code's own and base's, in this order, each with the id fe:<name>:

NameHoldsWhen
rootfe plugin root: <path>, the directory fe's scripts and references start fromalways
profile`fe project profile: <foundation\theme\none>`always
comment-discipline-foundationLiquidDoc on every snippet param; src/entry/core/* is protected; the Liquid core is hand-synced from the foundation repo, so prefer a copyprofile foundation
store-accessthe two store runners, scripts/shopify-admin-gql.sh and scripts/theme-json.sh, with their paths under fe's root; never Read .env or shopify.theme.tomlthe project root holds shopify.theme.toml or .env
worktreeworktree copy list: shopify.theme.toml (the line /base:worktree reads), then /fe:preview-theme in the new worktreealways
progress-seriesthe rows of a ticket's progress.md in order — write-technical-approach, develop-feature-or-fix, qa-feature-or-fix, pre-commit-review, commit, write-steps-to-test, create-pull-request — and the skill that ticks each; a batch lists its tickets, then the same tailalways

The profile is decided once per session id (a /clear decides again): FE_PROFILE when it holds one of the three words, else scripts/project-profile.sh on the project root, which also reads FE_PROFILE from .claude/domaine.env or ~/.config/domaine/env and otherwise detects (snippets/@*.liquid, sections/core-*.liquid, blocks/core-*.liquid or src/entry/core/ ⇒ foundation; layout/theme.liquid ⇒ theme; else none). A probe that fails or times out (5 s) gives none and one profile line saying why. The sections never change within a session, so the prompt cache holds.

Subagents get fe's share as added context at their start (Claude Code's SubagentStart; the engine has no event for a subagent's system prompt): base's readers and writer (base:jira-reader, base:jira-writer, base:figma-reader, base:doc-reader) and Claude Code's claude-code-guide and statusline-setup get nothing from fe; the read-only agents (fe:theme-explorer, base:change-reviewer, base:bug-hunter, Explore, Plan) get the root, the profile and the Foundation section; every other agent also gets store access when it applies.

base required: at a session start fe looks for base's skills in the command list. Without them it shows one toast and writes one install line: fe: needs the base plugin — claude plugin install base@domaine.

Doctor

/fe-doctor checks fe's side of the install and prints one PASS / FAIL / SKIP / WARN row per check, the counts, and the last 10 fe.events lines. /base-doctor checks base's side (slim, fnd, the MCP servers).

RowChecks
nodeNode 18 or newer
manifestthe manifest's version, the name fe, base in its dependencies
scriptsevery scripts/*.sh but the sourced _*.sh keeps its exec bit and answers --help (project-profile.sh is probed by the profile row)
basebase installed (user scope or this project) and enabled — else claude plugin install base@domaine
shopify-clishopify version answers; absent or failing only warns (the store scripts need it, the rest of fe does not)
profilethe word and how it was decided: FE_PROFILE, project-profile.sh, or a fallback to none (warns); in a session, the session's own decision
store-configshopify.theme.toml and .env present at the project root — presence only, never a value; one missing warns; neither in a none project skips
event-logthis session's fe.jsonl: its line count and newest ts; no file yet passes (a /clear's new session has none before its first line)
base-live, slim-livewhat this session loaded: base's skills, slim's mcp__slim__view tool

The first eight rows come from scripts/doctor.cjs, which also runs by hand: node <fe plugin root>/scripts/doctor.cjs [--project <dir>] [--log-dir <dir>]; it exits 1 when a row fails. By hand its event-log row reads the newest session folder unless --log-dir names one. One doctor line goes to fe.events per run.

Event log on disk

fe writes its lines to $HOME/.claude/domaine/log/<session-id>/fe.jsonl under the same contract as every Domaine plugin (plugins/base/README.md):

  • Line: {"ts":"…","plugin":"fe","version":"<fe's version>","session":"<id>","kind":"profile","agent":"main","text":"theme (project-profile.sh)"}.
  • fe's lines: start (fe <version>, first in every session's file, once), install (base is not loaded), profile (the word and how it was decided), doctor (a /fe-doctor run's counts). The same lines fill fe.events (oldest first, at most 200).
  • Writing: the whole file is rewritten after every line, at most 2000 lines or 256 KB, oldest dropped first; a reload of the module goes on from the file of the same session. A write that fails never reaches the hook; the first failure in a session toasts fe: event log not written: <reason>.
  • Off: FE_EVENT_LOG=0 stops the file and the fe.events lines alike.
  • Clean-up: fe never deletes; base sweeps old session folders.

band's Log pane (/band-log) shows fe's lines beside the other plugins', fe in its plugin column.

Environment switches

Every switch fe reads has a row here; set it in ~/.claude/settings.json → env. The store scripts also read their switches from ~/.config/domaine/env, and the tuning ones (FE_PROFILE, FE_GQL_PROBE_CACHE, FE_CPT_THROTTLE_WAITS, FE_CPT_OVERLAY_VERIFY_WAIT, FE_THEME_JSON_VERIFY_WAIT, SHOPIFY_ADMIN_GQL_QUIET) from the nearest .claude/domaine.env too; the process environment wins.

VariableDefaultEffect
FE_PROFILEdetectedfoundation, theme or none (spaces around it trimmed) forces the project profile instead of detecting it; any other value is ignored and the profile is detected
FE_EVENT_LOGon0 keeps fe.events empty and writes no fe.jsonl
DOMAINE_LOG_DIR~/.claude/domaine/logWhere every Domaine plugin (slim, band, base, fe, qa, be, pm) writes its event log on disk: <dir>/<session-id>/<plugin>.jsonl, one JSON line per event. An absolute directory; the <session-id>/ folder is still made under it. Without it and without HOME (a cloud session) no file is written.
CLAUDE_CONFIG_DIR~/.clauderead, never set, by scripts/doctor.cjs: the Claude Code config directory whose plugins/installed_plugins.json and settings.json the base row reads
FE_GQL_PROBE_CACHE21600seconds shopify-admin-gql.sh reuses its shopify version probe and its "store execute is unavailable for this store" fact; 0 re-probes on every call (right after a shopify store auth)
FE_CPT_THROTTLE_WAITS20 60pauses, in seconds, between create-preview-theme.sh's push retries after Shopify answers Throttled; one retry per value, empty turns retrying off
FE_CPT_OVERLAY_VERIFY10 skips create-preview-theme.sh's overlay read-back (overlay=skipped); with it, each overlaid *.json the theme silently dropped prints warn=overlay_file_dropped
FE_CPT_OVERLAY_VERIFY_WAIT2seconds before the overlay read-back's one re-pull when a file comes back missing
FE_THEME_JSON_VERIFY10 skips theme-json.sh set's read-back verify (verified=skipped); with it, a write the theme does not serve exits 6 with error=not_applied
FE_THEME_JSON_VERIFY_WAIT2seconds theme-json.sh set waits before its one read-back retry
SHOPIFY_ADMIN_GQL_QUIEToffa non-0 value shortens shopify-admin-gql.sh's engine-fallback note to note=engine=token
TOML_PATHshopify.theme.tomlthe config create-preview-theme.sh, theme-json.sh and shopify-admin-gql.sh read (store =, the dev theme's theme =, the Theme Access token), and the file create-preview-theme.sh's pin rewrites — point it at a copy when the real config must not change. In a toml with [environments.*] blocks every value comes from one block: --env <name> (create-preview-theme.sh only), else SHOPIFY_FLAG_ENVIRONMENT, else dev, else development, else the top-level keys
SHOPIFY_CLI_THEME_TOKENunsetTheme Access token for the shopify CLI: create-preview-theme.sh's last resort after the toml's password = (and its only token for a store other than the toml's); theme-json.sh --engine themecli prefers it over the toml. Never printed
SHOPIFY_STOREunsetthe store theme-json.sh, shopify-admin-gql.sh and create-preview-theme.sh use when --store is not passed, ahead of the toml's store =. A store other than the toml's refuses create-preview-theme.sh create and the pin; refresh then pushes with SHOPIFY_CLI_THEME_TOKEN
SHOPIFY_FLAG_ENVIRONMENTunsetread, never set, by fe: the Shopify CLI's own environment selector, taken by the three store scripts as the default [environments.<name>] block of shopify.theme.toml; a name no block carries is error=env_not_found
SHOPIFY_ADMIN_TOKENunsetAdmin API access token for shopify-admin-gql.sh's token engine, ahead of the --env dotenv file
SHOPIFY_ADMIN_API_VERSION2026-04the Admin API version shopify-admin-gql.sh requests when --api-version is not passed

Tests

  • claude plugin validate --strict plugins/fe and claude plugin test plugins/fe (the kit tests in plugins/fe/hooks/mods/tests/), both run by tests/mods-sim.sh with every other plugin (local only: CI has no claude).
  • tests/fe-scripts-sim.sh — the store scripts, worktree-theme.sh and project-profile.sh against stub CLIs and synthetic configs.
  • tests/fe-doctor-sim.sh — scripts/doctor.cjs's rows on planted installs.
  • tests/team-refs-lint.sh — every qualified name and cited path resolves, no fnd name is left (the same checker for every team plugin on base).
  • tests/base-qa-stores-sim.sh — base's QA store registry, plugins/base/scripts/qa-stores.cjs, which /fe:write-steps-to-test names.

How the pieces fit: ARCHITECTURE.md.

Licence

MIT, as the repository (LICENSE).

Source 6 files
hooks/mods/register.ts 12 lines
1// fe hooks module (Claude Code only): the frontend team's conventions, project profile, store access and doctor
2// beside base. fe writes only fe.* atoms. Each feature file declares its own atoms and keeps its `$` code to
3// itself: the validator follows `$` only within one file.
4import type { Register } from 'claude-code'
5import { registerDoctor } from './doctor.ts'
6import { registerSession } from './session.ts'
7
8export const register: Register = (on) => {
9  registerSession(on)
10  registerDoctor(on)
11}
12
hooks/mods/doctor.ts 199 lines
1// /fe-doctor: runs scripts/doctor.cjs for what a node process sees and adds what only a session answers —
2// base loaded, slim's view tool registered, the profile this session decided — then the tail of fe.events.
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, On } from 'claude-code'
5import type { FeEvent } from '../../types'
6import { logDir, logLine, pushEvent } from './events.ts'
7import type { Disk } from './events.ts'
8import { BASE_MISSING, profileText } from './session.ts'
9
10export const COMMAND = {
11  name: 'fe-doctor',
12  description: 'Check the fe install: node, manifest, scripts, base, slim, Shopify CLI, profile, store config, event log',
13  immediate: true,
14} as const
15export const SLIM_VIEW = 'mcp__slim__view'
16const TAIL = 10
17
18type Status = 'PASS' | 'FAIL' | 'SKIP' | 'WARN'
19export type Row = { status: Status; name: string; detail: string }
20
21const armed = atom({ plugin: 'fe', key: 'armed' } as const, null)
22const events = atom({ plugin: 'fe', key: 'events' } as const, [] as FeEvent[])
23const profile = atom({ plugin: 'fe', key: 'profile' } as const, null)
24
25type $ = EngineInterface
26
27/** events.ts's file writer reaches `$` through this: the validator follows `$` only within one file. */
28function diskOf($: $): Disk {
29  return {
30    session: () => $.session.id(),
31    home: () => $.env.get('HOME'),
32    override: () => $.env.get('DOMAINE_LOG_DIR'),
33    manifest: () => $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`),
34    read: path => $.fs.read(path),
35    write: (path, text) => $.fs.write(path, text),
36    toast: text => $.ui.toast(text),
37  }
38}
39
40/** The command on every session start (a reload or a re-enable drops it) and at the first prompt of a new session id. */
41async function arm($: $, start: boolean): Promise<void> {
42  try {
43    const sid = String(await $.session.id())
44    const fresh = (await read($, armed)) !== sid
45    if (!start && !fresh) return
46    await $.command.register(COMMAND).catch(() => undefined)
47    if (fresh) await update($, armed, () => sid)
48  } catch {}
49}
50
51const STATUSES = new Set(['PASS', 'FAIL', 'SKIP', 'WARN'])
52
53/** doctor.cjs --json rows, or null when its stdout is not that shape. */
54export function parseStatic(stdout: string): Row[] | null {
55  try {
56    const rows = (JSON.parse(stdout) as { rows?: unknown }).rows
57    if (!Array.isArray(rows)) return null
58    return rows.filter(
59      (r): r is Row => !!r && STATUSES.has(r.status) && typeof r.name === 'string' && typeof r.detail === 'string',
60    )
61  } catch {
62    return null
63  }
64}
65
66async function staticRows($: $): Promise<Row[]> {
67  const root = $.plugin.root
68  let r
69  try {
70    const argv = ['node', `${root}/scripts/doctor.cjs`, '--json', '--root', root, '--project', await $.session.root()]
71    const dir = logDir(await $.env.get('HOME'), await $.env.get('DOMAINE_LOG_DIR'), await $.session.id())
72    if (dir) argv.push('--log-dir', dir)
73    r = await $.process.run(argv, { timeoutMs: 60_000 })
74  } catch (err) {
75    const why = err instanceof Error ? err.message : String(err)
76    return [{ status: 'SKIP', name: 'static', detail: `scripts/doctor.cjs did not run (${why}): the static checks need node` }]
77  }
78  const rows = parseStatic(r.stdout)
79  if (rows?.length) return rows
80  const why = (r.stderr.trim().split('\n')[0] ?? '') || 'no rows on stdout'
81  return [{ status: 'FAIL', name: 'static', detail: `scripts/doctor.cjs exited ${r.exitCode}: ${why}` }]
82}
83
84async function liveRows($: $): Promise<Row[]> {
85  const rows: Row[] = []
86  try {
87    rows.push((await $.command.list()).some(c => c.plugin === 'base')
88      ? { status: 'PASS', name: 'base-live', detail: "base's skills are loaded" }
89      : { status: 'FAIL', name: 'base-live', detail: `fe ${BASE_MISSING}` })
90  } catch {
91    rows.push({ status: 'SKIP', name: 'base-live', detail: 'the command list did not answer' })
92  }
93  try {
94    rows.push((await $.tool.list()).some(t => t.name === SLIM_VIEW)
95      ? { status: 'PASS', name: 'slim-live', detail: `${SLIM_VIEW} registered` }
96      : { status: 'FAIL', name: 'slim-live', detail: 'slim is not loaded — claude plugin install slim@domaine; base refuses its readers until it is' })
97  } catch {
98    rows.push({ status: 'SKIP', name: 'slim-live', detail: 'the tool list did not answer' })
99  }
100  return rows
101}
102
103/**
104 * The session's own profile replaces doctor.cjs's probe (the session decided it once, the prompt follows that
105 * one); a static `base` FAIL for a base that is loaded anyway (a `--plugin-dir` load has no install record)
106 * reads as a warning.
107 */
108export function reconcile(rows: Row[], session: { word: string; text: string; fallback: boolean } | null): Row[] {
109  const live = rows.find(r => r.name === 'base-live')?.status === 'PASS'
110  return rows.map(r => {
111    if (live && r.name === 'base' && r.status === 'FAIL' && r.detail.startsWith('not installed')) {
112      return { status: 'WARN', name: 'base', detail: 'not in installed_plugins.json, yet loaded this session (a --plugin-dir load?)' }
113    }
114    if (session && r.name === 'profile') {
115      return { status: session.fallback ? 'WARN' : 'PASS', name: 'profile', detail: `${session.text} — this session's` }
116    }
117    return r
118  })
119}
120
121export function age(ms: number): string {
122  const s = Math.max(0, Math.round(ms / 1000))
123  if (s < 60) return `${s}s`
124  if (s < 3600) return `${Math.floor(s / 60)}m`
125  if (s < 48 * 3600) return `${Math.floor(s / 3600)}h`
126  return `${Math.floor(s / 86400)}d`
127}
128
129export function summary(rows: Row[]): string {
130  const n = { PASS: 0, FAIL: 0, SKIP: 0, WARN: 0 }
131  for (const r of rows) n[r.status]++
132  return `${n.PASS} passed, ${n.FAIL} failed, ${n.SKIP} skipped${n.WARN ? `, ${n.WARN} warned` : ''}`
133}
134
135export function render(root: string, rows: Row[], tail: string[]): string {
136  const width = rows.reduce((w, r) => Math.max(w, r.name.length), 0)
137  return [
138    `fe doctor — plugin root: ${root}`,
139    ...rows.map(r => `${r.status}  ${r.name.padEnd(width)}  ${r.detail}`),
140    `doctor: ${summary(rows)}`,
141    '',
142    ...tail,
143  ].join('\n')
144}
145
146async function eventTail($: $): Promise<string[]> {
147  if ((await $.env.get('FE_EVENT_LOG')) === '0') return ['fe events: off (FE_EVENT_LOG=0)']
148  const list = await read($, events)
149  if (!list.length) return ['fe events: none yet']
150  const now = await $.clock.now()
151  const shown = list.slice(-TAIL)
152  return [
153    `fe events (last ${shown.length} of ${list.length}, newest last):`,
154    ...shown.map(ev => `  ${age(now - ev.atMs).padStart(4)}  ${ev.kind.padEnd(9)}  ${ev.text}`),
155  ]
156}
157
158async function logDoctor($: $, text: string): Promise<void> {
159  try {
160    if ((await $.env.get('FE_EVENT_LOG')) === '0') return
161    const ev: FeEvent = { atMs: await $.clock.now(), kind: 'doctor', text }
162    await update($, events, l => pushEvent(l, ev))
163    await logLine(diskOf($), ev)
164  } catch {}
165}
166
167async function sessionProfile($: $): Promise<{ word: string; text: string; fallback: boolean } | null> {
168  try {
169    const p = await read($, profile)
170    if (!p || p.session !== String(await $.session.id())) return null
171    return { word: p.word, text: profileText(p), fallback: p.via === 'fallback' }
172  } catch {
173    return null
174  }
175}
176
177export function registerDoctor(on: On): void {
178  // A matcher apart from session.ts's start hook: one unmatched hook per event per plugin.
179  on('session.start', { cwd: /$/ }, async ($, e, next) => {
180    const r = await next(e)
181    await arm($, true)
182    return r
183  })
184
185  on('prompt.submit', async ($, e, next) => {
186    const r = await next(e)
187    await arm($, false)
188    return r
189  })
190
191  on('command.run', { command: COMMAND.name }, async $ => {
192    const [fixed, live, mine] = await Promise.all([staticRows($), liveRows($), sessionProfile($)])
193    const rows = reconcile([...fixed, ...live], mine)
194    const tail = await eventTail($)
195    await logDoctor($, summary(rows))
196    return { text: render($.plugin.root, rows, tail) }
197  })
198}
199
hooks/mods/session.ts 183 lines
1// fe's session: the start line, the base check, the project profile (decided once per session id) and the
2// conventions that follow it — system-prompt sections for the main session, added context for subagents.
3// The profile lives here with both of its readers: the validator follows `$` only within one file.
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, On, PromptComposeSection } from 'claude-code'
6import type { FeEvent, FeEventKind, FeProfile, FeProfileInfo } from '../../types'
7import {
8  FOUNDATION,
9  NO_FE_AGENT,
10  PROGRESS_SERIES,
11  READ_ONLY_AGENT,
12  STORE_ACCESS,
13  WORKTREE,
14  profileLine,
15  rootLine,
16  withRoot,
17} from './conventions/text.ts'
18import { logLine, pushEvent } from './events.ts'
19import type { Disk } from './events.ts'
20
21export const BASE_MISSING = 'needs the base plugin — claude plugin install base@domaine'
22export const PROFILE_TIMEOUT_MS = 5_000
23/** The files the store runners read their store and credentials from, at the project root. */
24export const STORE_FILES = ['shopify.theme.toml', '.env'] as const
25
26const events = atom({ plugin: 'fe', key: 'events' } as const, [] as FeEvent[])
27const started = atom({ plugin: 'fe', key: 'started' } as const, null)
28const profile = atom({ plugin: 'fe', key: 'profile' } as const, null)
29
30type $ = EngineInterface
31
32/** events.ts's file writer reaches `$` through this: the validator follows `$` only within one file. */
33function diskOf($: $): Disk {
34  return {
35    session: () => $.session.id(),
36    home: () => $.env.get('HOME'),
37    override: () => $.env.get('DOMAINE_LOG_DIR'),
38    manifest: () => $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`),
39    read: path => $.fs.read(path),
40    write: (path, text) => $.fs.write(path, text),
41    toast: text => $.ui.toast(text),
42  }
43}
44
45async function logEvent($: $, kind: FeEventKind, text: string): Promise<void> {
46  try {
47    if ((await $.env.get('FE_EVENT_LOG')) === '0') return
48    const ev: FeEvent = { atMs: await $.clock.now(), kind, text }
49    await update($, events, l => pushEvent(l, ev))
50    await logLine(diskOf($), ev)
51  } catch {}
52}
53
54async function version($: $): Promise<string> {
55  try {
56    const v = (JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`)) as { version?: unknown }).version
57    return typeof v === 'string' && v ? v : 'unknown'
58  } catch {
59    return 'unknown'
60  }
61}
62
63/** base's skills carry `plugin: 'base'` in the command list from its manifest on; a list that fails says nothing. */
64async function baseLoaded($: $): Promise<boolean> {
65  try {
66    return (await $.command.list()).some(c => c.plugin === 'base')
67  } catch {
68    return true
69  }
70}
71
72export const isProfile = (w: string | undefined): w is FeProfile => w === 'foundation' || w === 'theme' || w === 'none'
73
74const oneLine = (s: string) => s.replace(/\s+/g, ' ').trim().slice(0, 120)
75
76/** `theme (project-profile.sh)`, `foundation (FE_PROFILE)`, `none (fallback: <why>)`: the profile line's text. */
77export function profileText(p: FeProfileInfo): string {
78  return p.via === 'fallback' ? `none (fallback: ${p.why ?? 'unknown'})` : `${p.word} (${p.via})`
79}
80
81async function decide($: $, session: string): Promise<FeProfileInfo> {
82  let root = ''
83  let store = false
84  try {
85    root = await $.session.root()
86    for (const f of STORE_FILES) if (await $.fs.exists(`${root}/${f}`)) store = true
87  } catch {}
88  const forced = (await $.env.get('FE_PROFILE').catch(() => undefined))?.trim()
89  if (isProfile(forced)) return { session, word: forced, via: 'FE_PROFILE', why: null, store }
90  let why: string
91  try {
92    const argv = ['bash', `${$.plugin.root}/scripts/project-profile.sh`]
93    if (root) argv.push(root)
94    const r = await $.process.run(argv, { timeoutMs: PROFILE_TIMEOUT_MS })
95    const word = r.stdout.trim()
96    if (r.exitCode === 0 && isProfile(word)) return { session, word, via: 'project-profile.sh', why: null, store }
97    why = `project-profile.sh exited ${r.exitCode}: ${oneLine(r.stderr.split('\n')[0] || word) || 'no answer'}`
98  } catch (err) {
99    why = `project-profile.sh did not run: ${oneLine(err instanceof Error ? err.message : String(err))}`
100  }
101  return { session, word: 'none', via: 'fallback', why, store }
102}
103
104// The decision in flight for one session id: compose, a subagent and the start never run the probe twice.
105let pending: { session: string; info: Promise<FeProfileInfo> } | null = null
106
107/** This session's profile: the atom when it holds this id, else decided now, stored and logged once. */
108async function profileOf($: $): Promise<FeProfileInfo> {
109  const session = String(await $.session.id())
110  const held = await read($, profile)
111  if (held?.session === session) return held
112  if (pending?.session !== session) {
113    const info = decide($, session).then(async p => {
114      await update($, profile, () => p)
115      await logEvent($, 'profile', profileText(p))
116      return p
117    })
118    pending = { session, info: info.catch(() => ({ session, word: 'none', via: 'fallback', why: 'not stored', store: false }) as FeProfileInfo) }
119  }
120  return pending.info
121}
122
123/**
124 * The sections in session order, each `fe:<name>`: the profile is fixed per session id, so a render repeats
125 * the last one byte for byte (the prompt cache).
126 */
127export async function sections($: $): Promise<PromptComposeSection[]> {
128  const root = $.plugin.root
129  const p = await profileOf($)
130  const parts: [string, string][] = [
131    ['root', rootLine(root)],
132    ['profile', profileLine(p.word)],
133  ]
134  if (p.word === 'foundation') parts.push(['comment-discipline-foundation', FOUNDATION])
135  if (p.store) parts.push(['store-access', withRoot(STORE_ACCESS, root)])
136  parts.push(['worktree', WORKTREE], ['progress-series', PROGRESS_SERIES])
137  return parts.map(([name, text]) => ({ id: `fe:${name}`, text, scope: 'session' }))
138}
139
140/** null for base's readers and writer; the root, profile and Foundation rules for the rest; store access for a code writer. */
141export async function subagentContext($: $, agentType: string): Promise<string | null> {
142  if (NO_FE_AGENT.test(agentType)) return null
143  const root = $.plugin.root
144  const p = await profileOf($)
145  const parts = [rootLine(root), profileLine(p.word)]
146  if (p.word === 'foundation') parts.push(FOUNDATION)
147  if (p.store && !READ_ONLY_AGENT.test(agentType)) parts.push(withRoot(STORE_ACCESS, root))
148  return parts.join('\n\n')
149}
150
151export function registerSession(on: On): void {
152  // The engine allows one unmatched hook per event per plugin; this matcher takes every session.
153  on('session.start', { cwd: /^/ }, async ($, e, next) => {
154    try {
155      const sid = String(await $.session.id())
156      if ((await read($, started)) !== sid) {
157        await update($, started, () => sid)
158        await logEvent($, 'start', `fe ${await version($)}`)
159        if (!(await baseLoaded($))) {
160          await logEvent($, 'install', BASE_MISSING)
161          $.ui.toast(`fe: ${BASE_MISSING}`)
162        }
163      }
164      // Unawaited: the probe never holds the start; the first compose waits on the same decision.
165      void profileOf($).catch(() => undefined)
166    } catch {}
167    return next(e)
168  })
169
170  on('prompt.compose', async ($, e, next) => {
171    const r = await next(e)
172    const ours = await sections($).catch(() => [])
173    const taken = new Set(r.sections.map(s => s.id))
174    return { sections: [...r.sections, ...ours.filter(s => !taken.has(s.id))] }
175  })
176
177  on('classic.SubagentStart', async ($, e, next) => {
178    const r = await next(e)
179    const ctx = await subagentContext($, e.agent_type ?? '').catch(() => null)
180    return ctx ? { ...r, additionalContext: [...(r.additionalContext ?? []), ctx] } : r
181  })
182}
183
hooks/mods/events.ts 157 lines
1// fe's event list and its file on disk, `<log dir>/<session-id>/fe.jsonl`, under the contract base, band and
2// slim write theirs by. No `$` here: each writer file keeps its own wrapper, which pushes the line to fe.events
3// and then hands it to `logLine` with a `Disk` it built, as the validator follows `$` only within one file.
4// fe never sweeps old session directories: base owns that.
5import type { FeEvent } from '../../types'
6
7export const EVENT_CAP = 200
8export const FILE_LINES = 2000
9export const FILE_BYTES = 256 * 1024
10
11/** Appends `ev`, oldest first, at most EVENT_CAP: past the cap the oldest line goes. */
12export function pushEvent(list: readonly FeEvent[], ev: FeEvent): FeEvent[] {
13  return list.length < EVENT_CAP ? [...list, ev] : [...list.slice(1), ev]
14}
15
16const trimSlash = (p: string) => p.replace(/\/+$/, '')
17
18/** A name the engine could not have made a session id of never becomes a path segment. */
19export function isSessionName(name: string): boolean {
20  return /^[\w.-]+$/.test(name) && !/^\.+$/.test(name)
21}
22
23/** `<DOMAINE_LOG_DIR>/<session>` when the override is absolute, else `$HOME/.claude/domaine/log/<session>`; null with neither. */
24export function logDir(home: string | undefined, override: string | undefined, session: string): string | null {
25  if (!isSessionName(session)) return null
26  const o = override?.trim() ?? ''
27  if (o.startsWith('/')) return `${trimSlash(o)}/${session}`
28  const h = home?.trim() ?? ''
29  return h.startsWith('/') ? `${trimSlash(h)}/.claude/domaine/log/${session}` : null
30}
31
32/** One fe.jsonl line; `plugin` is fe's own name, never taken from another plugin's state. */
33export function fileLine(ev: FeEvent, version: string, session: string): string {
34  return JSON.stringify({ ts: new Date(ev.atMs).toISOString(), plugin: 'fe', version, session, kind: ev.kind, agent: 'main', text: ev.text })
35}
36
37export function utf8Bytes(s: string): number {
38  let n = 0
39  for (const ch of s) {
40    const cp = ch.codePointAt(0) ?? 0
41    n += cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4
42  }
43  return n
44}
45
46/** Drops the oldest lines past FILE_LINES lines or FILE_BYTES bytes; the newest line always stays. */
47function trimFront(out: string[]): string[] {
48  let bytes = 0
49  for (const l of out) bytes += utf8Bytes(l) + 1
50  let cut = 0
51  while (out.length - cut > 1 && (out.length - cut > FILE_LINES || bytes > FILE_BYTES)) bytes -= utf8Bytes(out[cut++]!) + 1
52  return cut ? out.slice(cut) : out
53}
54
55/** Appends `line` within the cap. */
56export function capLines(lines: readonly string[], line: string): string[] {
57  return trimFront([...lines, line])
58}
59
60/** The lines of a fe.jsonl that belong to `session`, oldest first, within the cap: what a reload goes on from. */
61export function seedLines(text: string, session: string): string[] {
62  return trimFront(text.split('\n').filter(l => {
63    try {
64      return (JSON.parse(l) as { session?: unknown }).session === session
65    } catch {
66      return false
67    }
68  }))
69}
70
71const hasStart = (lines: readonly string[]) => lines.some(l => l.includes('"kind":"start"'))
72
73/**
74 * The file's lines after `ev`: the start line first in every session (a /clear's new id gets one before its
75 * first event, as no session.start announces it), and one start line per session.
76 */
77export function nextLines(lines: readonly string[], ev: FeEvent, version: string, session: string): string[] | null {
78  if (ev.kind === 'start') return hasStart(lines) ? null : capLines(lines, fileLine(ev, version, session))
79  const head = lines.length ? lines : [fileLine({ atMs: ev.atMs, kind: 'start', text: `fe ${version}` }, version, session)]
80  return capLines(head, fileLine(ev, version, session))
81}
82
83/** What the writer needs from `$`, built by each writer file's `diskOf`. */
84export type Disk = {
85  session: () => Promise<string>
86  home: () => Promise<string | undefined>
87  override: () => Promise<string | undefined>
88  manifest: () => Promise<string>
89  read: (path: string) => Promise<string>
90  write: (path: string, text: string) => Promise<void>
91  toast: (text: string) => void
92}
93
94type Sink = { path: string; lines: string[] }
95
96// Module-local, so a hot reload starts from the file: the lines already on disk for this session.
97let sink: Promise<Sink | null> | null = null
98let sinkSession = ''
99let version: Promise<string> | null = null
100let writes: Promise<void> = Promise.resolve()
101let toasted = ''
102
103async function readVersion(disk: Disk): Promise<string> {
104  try {
105    const v = (JSON.parse(await disk.manifest()) as { version?: unknown }).version
106    return typeof v === 'string' && v ? v : 'unknown'
107  } catch {
108    return 'unknown'
109  }
110}
111
112async function openSink(disk: Disk, session: string): Promise<Sink | null> {
113  const dir = logDir(await disk.home(), await disk.override(), session)
114  if (dir === null) return null
115  const path = `${dir}/fe.jsonl`
116  let lines: string[] = []
117  try {
118    lines = seedLines(await disk.read(path), session)
119  } catch {}
120  return { path, lines }
121}
122
123/**
124 * Rewrites this session's fe.jsonl with `ev` appended (`$.fs.write` has no append), after the line went to
125 * fe.events. Never throws; one toast per session when a write fails.
126 */
127export async function logLine(disk: Disk, ev: FeEvent): Promise<void> {
128  let session = ''
129  try {
130    session = await disk.session()
131    if (!sink || sinkSession !== session) {
132      sinkSession = session
133      sink = openSink(disk, session).catch(() => null)
134    }
135    version ??= readVersion(disk)
136    const v = await version
137    const s = await sink
138    if (!s) return
139    // Read, append and write inside one queue: two events in flight never build on the same snapshot.
140    const w = writes.then(async () => {
141      const lines = nextLines(s.lines, ev, v, session)
142      if (!lines) return
143      s.lines = lines
144      await disk.write(s.path, `${lines.join('\n')}\n`)
145    })
146    writes = w.catch(() => {})
147    await w
148  } catch (err) {
149    if (toasted === session) return
150    toasted = session
151    const reason = String((err as { message?: unknown } | null)?.message ?? err).replace(/\s+/g, ' ').slice(0, 120)
152    try {
153      disk.toast(`fe: event log not written: ${reason}`)
154    } catch {}
155  }
156}
157
hooks/mods/conventions/text.ts 65 lines
1// fe's conventions, the text the main session's system prompt and the code-writing subagents read. Pure:
2// `<fe root>` stands for the plugin root until `withRoot` fills it in.
3import type { FeProfile } from '../../../types'
4
5export const rootLine = (root: string) => `fe plugin root: ${root}`
6
7export const profileLine = (word: FeProfile) => `fe project profile: ${word}`
8
9/** `<fe root>` → the plugin's own directory. */
10export const withRoot = (text: string, root: string) => text.split('<fe root>').join(root)
11
12export const FOUNDATION = `## fe convention — LiquidDoc and core
13
14This checkout is detected as a Foundation theme (profile \`foundation\`). Every snippet ships a
15LiquidDoc \`{% doc %}\` block with a default on each param. The JS/TS core, \`src/entry/core/*\`, is
16protected: extend or compose it, never edit it in place. The Liquid core — \`snippets/@*\`,
17\`sections/core-*\`, \`blocks/core-*\` — may be edited, but every edit has to be hand-synced from the
18foundation repo, so prefer a copy under a new name.`
19
20export const STORE_ACCESS = `## fe capability — live store access, any time
21
22Two runners under \`<fe root>/scripts/\` — use them whenever real store state would answer a
23question; don't guess store state.
24
25- \`<fe root>/scripts/shopify-admin-gql.sh --query <file.graphql> [--operation <Name>] [--variables <json>
26  | --variables-file <file>] [--store <domain>] [--out <file>]\` (\`--help\` prints the full call
27  shape) — Admin GraphQL. Read-only queries are always fair game (big reads: \`--out\` + \`jq\`);
28  mutations follow \`<fe root>/references/metafield-metaobject-setup.md\`. Both paths: every query or
29  mutation you wrote clears a \`validate_graphql_codeblocks\` pass first (base's Shopify Dev MCP
30  server; needs a \`learn_shopify_api\` conversationId).
31- \`<fe root>/scripts/theme-json.sh themes|get|set\` — customizer state. \`themes\`/\`get\` freely, any
32  theme incl. live; \`set\` only per the snapshot → mutate → verify → restore protocol in
33  \`<fe root>/references/theme-customizer-state.md\` (live theme refused). Works without Admin
34  credentials via the Theme Access token.
35
36Auth is handled inside; on failure they print the setup fix to relay. Never \`Read\` \`.env\` or
37\`shopify.theme.toml\` — the runners consume secrets without exposing them.`
38
39/** The one line base's /base:worktree reads its copy list from, and the step that follows it. */
40export const WORKTREE = `worktree copy list: shopify.theme.toml
41After \`/base:worktree\` makes a new worktree, run \`/fe:preview-theme\` inside it: the copied \`shopify.theme.toml\` is unpinned there and the worktree gets a theme of its own.`
42
43export const PROGRESS_SERIES = `## fe convention — the progress series
44
45A ticket's \`progress.md\` lists fe's series, one row per step, in this order. The row text is the
46step name; the skill after it ticks the row:
47
481. \`write-technical-approach\` — \`/fe:write-technical-approach\`
492. \`develop-feature-or-fix\` — \`/fe:develop-feature-or-fix\`
503. \`qa-feature-or-fix\` — \`/fe:qa-feature-or-fix\`
514. \`pre-commit-review\` — \`/base:pre-commit-review\` (pass it the profile word above)
525. \`commit\` — \`/base:commit\`
536. \`write-steps-to-test\` — \`/fe:write-steps-to-test\`
547. \`create-pull-request\` — \`/fe:create-pull-request\`
55
56A batch (\`<work-id>\` = branch slug) lists one row per ticket, each ticked as its bug is fixed with
57its root cause, then the same tail from \`pre-commit-review\` to \`create-pull-request\`.
58\`/fe:ship\` runs the whole series and ticks the same rows.`
59
60/** base's readers and writer, and Claude Code's own helpers: no fe context at all. */
61export const NO_FE_AGENT = /(^|:)(jira-reader|jira-writer|figma-reader|doc-reader)$|^(claude-code-guide|statusline-setup)$/
62
63/** Agents that read the theme without writing it: the root, the profile and the Foundation rules, no store access. */
64export const READ_ONLY_AGENT = /(^|:)(theme-explorer|change-reviewer|bug-hunter)$|^(Explore|Plan)$/
65
types/index.d.ts 44 lines
1// fe's $.state contract. Values are JSON, so an absent value is null, never undefined. base's and slim's keys
2// come from the dependency contracts the engine lays beside the module (base, and slim through base); they
3// are never redeclared here.
4
5/**
6 * At most 9 characters each (band's kind cell). `start` fe's version at session start, `install` base is not
7 * loaded, `profile` the project profile fe decided and how, `doctor` the counts of a /fe-doctor run.
8 */
9export type FeEventKind = 'start' | 'install' | 'profile' | 'doctor'
10
11/** atMs = $.clock.now() when written; text is one line. */
12export type FeEvent = { atMs: number; kind: FeEventKind; text: string }
13
14/** The project profile fe's conventions follow; `none` outside a Shopify theme. */
15export type FeProfile = 'foundation' | 'theme' | 'none'
16
17/**
18 * The profile of one session's project. `via`: `FE_PROFILE` forced it, `project-profile.sh` answered it
19 * (detection or a domaine env file), `fallback` the script failed and fe took `none` (`why` says how).
20 * `store`: the project root holds `shopify.theme.toml` or `.env`, so the store-access section applies.
21 */
22export type FeProfileInfo = {
23  session: string
24  word: FeProfile
25  via: 'FE_PROFILE' | 'project-profile.sh' | 'fallback'
26  why: string | null
27  store: boolean
28}
29
30declare module 'claude-code' {
31  interface PluginState {
32    fe: {
33      /** Oldest first, at most 200; any plugin reads, fe writes. Stays [] under FE_EVENT_LOG=0. */
34      events: FeEvent[]
35      /** Decided once per session id; null before the first decision. */
36      profile: FeProfileInfo | null
37      /** The session id whose start line was written and whose base check ran: a module reload does neither again. */
38      started: string | null
39      /** The session id whose /fe-doctor command is registered. */
40      armed: string | null
41    }
42  }
43}
44