Domaine backend team plugin for Claude Code: the Shopify app and extension skills (app scope with LOE, Shopify resources, platform limitations and…

be is the Domaine backend team plugin for Claude Code. It holds the Shopify app and extension work: scoping an app, extension or Function build with its APIs, limitations and level of effort, finding the right Shopify source for a question, and answering "can Shopify do X?" with the limitation, the workaround and what to tell the merchant.
The skills are imported from Domaine's domaine-skills-solutions repository (commit 3c96617, 2026-08-03) and adapted to Claude Code and base; update them here, not there.
be builds on base and requires it: the Jira and doc readers, the Jira writer, the task workspace and the shared MCP servers (Atlassian, Notion, the Shopify Dev MCP) are base's (plugins/base/README.md). base requires slim, so be runs with slim too.
Current release: be v0.1.0.
scripts/install.sh --plugin be exits 2."dependencies": ["base"] in its manifest), and slim through base. The engine does not install a dependency on its own: install all three./plugin marketplace add domaine-oleksandr-kever/claude-plugins
/plugin install slim@domaine
/plugin install band@domaine
/plugin install base@domaine
/plugin install be@domaine
/reload-plugins
/base-doctor and /be-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,
"be@domaine": true,
"fnd@domaine": false
}
}
The team plugins fe, qa, be and pm co-install beside base: add the ones your work needs, none of them requires another.
Invoked by their qualified names (/be:<skill>). They hand off to base's agents by base's qualified names (base:jira-reader, base:doc-reader, base:jira-writer). A write to Jira happens only after the user approved the text.
| Skill | Does | Uses / hands off to |
|---|---|---|
/be:app-scope | scopes a Shopify app or extension build: requirements, extension types, API scopes and webhooks, limitations, an LOE table by component, the scope document, next steps | base:jira-reader (a ticket or epic), base:doc-reader (a Confluence or Notion page, a web URL), base's Shopify Dev MCP server, /be:platform-limitations; after approval base's Atlassian MCP creates the drafted tickets, base:jira-writer fills a field of an existing one |
/be:shopify-resources | which Shopify source answers which question, in priority order: the Dev MCP, shopify.dev, the Help Center, the two communities | base's Shopify Dev MCP server, Atlassian and Notion servers through base:jira-reader / base:doc-reader; → /be:app-scope, /be:platform-limitations |
/be:platform-limitations | a limitation → workaround → what to tell the merchant answer for checkout, Functions, metafields, theme, API, integration and B2B limits, the limit checked against the current API version | its reference below, base's Shopify Dev MCP server |
The skills cite be's own files by their path under be's root (<be root>/…, the session's be plugin root: line) and base's by their path under base's root (<base root>/…).
| Reference | Read by | Holds |
|---|---|---|
skills/platform-limitations/references/limitation-workarounds.md | /be:platform-limitations | the limitation and workaround tables by area, and the escalation steps when a limit has no workaround |
<base root>/references/task-workspace.md (base's) | /be:app-scope | the task workspace a ticket's scope is saved to |
<base root>/references/jira-adf-write.md (base's) | /be:app-scope | how an approved ticket description is converted to ADF before it reaches Jira |
be adds one section to the main session's system prompt, after Claude Code's own and base's, with the id be:<name>: root, the line be plugin root: <path> the skills resolve <be root> from. It never changes within a session, so the prompt cache holds.
Subagents get the same line as added context at their start (Claude Code's SubagentStart), except 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, which get nothing from be.
base required: at a session start be looks for base's skills in the command list. Without them it shows one toast and writes one install line: be: needs the base plugin — claude plugin install base@domaine.
/be-doctor checks be's side of the install and prints one PASS / FAIL / SKIP / WARN row per check, the counts, and the last 10 be.events lines. /base-doctor checks base's side (slim, fnd, the MCP servers).
| Row | Checks |
|---|---|
node | Node 18 or newer |
manifest | the manifest's version, the name be, base in its dependencies |
scripts | every scripts/*.sh but a sourced _*.sh keeps its exec bit, every scripts/*.cjs parses |
base | base installed (user scope or this project) and enabled — else claude plugin install base@domaine |
shopify-dev-mcp | base's installed manifest declares the shopify-dev-mcp server the skills check API facts and limits against; skipped when base is not installed and enabled |
event-log | this session's be.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-live | what this session loaded: base's skills, slim's mcp__slim__view tool |
The first six rows come from scripts/doctor.cjs, which also runs by hand: node <be 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. A static base FAIL for a base the session loaded anyway (a --plugin-dir load) reads as a WARN in /be-doctor. One doctor line goes to be.events per run.
be writes its lines to $HOME/.claude/domaine/log/<session-id>/be.jsonl under the same contract as every Domaine plugin (plugins/base/README.md):
{"ts":"…","plugin":"be","version":"<be's version>","session":"<id>","kind":"doctor","agent":"main","text":"6 passed, 0 failed, 0 skipped"}.start (be <version>, first in every session's file, once), install (base is not loaded), doctor (a /be-doctor run's counts). The same lines fill be.events (oldest first, at most 200).be: event log not written: <reason>.BE_EVENT_LOG=0 stops the file and the be.events lines alike.Every switch be reads has a row here; set it in ~/.claude/settings.json → env.
| Variable | Default | Effect |
|---|---|---|
BE_EVENT_LOG | on | 0 keeps be.events empty and writes no be.jsonl |
DOMAINE_LOG_DIR | ~/.claude/domaine/log | Where 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 | ~/.claude | read, never set, by scripts/doctor.cjs: the Claude Code config directory whose plugins/installed_plugins.json and settings.json the base and shopify-dev-mcp rows read |
claude plugin validate --strict plugins/be and claude plugin test plugins/be (the kit tests in plugins/be/hooks/mods/tests/), both run by tests/mods-sim.sh with every other plugin (local only: CI has no claude).tests/be-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).How the pieces fit: ARCHITECTURE.md.
MIT, as the repository (LICENSE).
hooks/mods/register.ts 12 lines1// be hooks module (Claude Code only): the backend team's plugin root line and doctor beside base. be writes
2// only be.* atoms. Each feature file declares its own atoms and keeps its `$` code to itself: the validator
3// 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}
12hooks/mods/doctor.ts 183 lines1// /be-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 — then the tail of be.events.
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, On } from 'claude-code'
5import type { BeEvent } from '../../types'
6import { logDir, logLine, pushEvent } from './events.ts'
7import type { Disk } from './events.ts'
8import { BASE_MISSING } from './session.ts'
9
10export const COMMAND = {
11 name: 'be-doctor',
12 description: 'Check the be install: node, manifest, scripts, base, the Shopify Dev MCP server, slim, 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: 'be', key: 'armed' } as const, null)
22const events = atom({ plugin: 'be', key: 'events' } as const, [] as BeEvent[])
23
24type $ = EngineInterface
25
26/** events.ts's file writer reaches `$` through this: the validator follows `$` only within one file. */
27function diskOf($: $): Disk {
28 return {
29 session: () => $.session.id(),
30 home: () => $.env.get('HOME'),
31 override: () => $.env.get('DOMAINE_LOG_DIR'),
32 manifest: () => $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`),
33 read: path => $.fs.read(path),
34 write: (path, text) => $.fs.write(path, text),
35 toast: text => $.ui.toast(text),
36 }
37}
38
39/** The command on every session start (a reload or a re-enable drops it) and at the first prompt of a new session id. */
40async function arm($: $, start: boolean): Promise<void> {
41 try {
42 const sid = String(await $.session.id())
43 const fresh = (await read($, armed)) !== sid
44 if (!start && !fresh) return
45 await $.command.register(COMMAND).catch(() => undefined)
46 if (fresh) await update($, armed, () => sid)
47 } catch {}
48}
49
50const STATUSES = new Set(['PASS', 'FAIL', 'SKIP', 'WARN'])
51
52/** doctor.cjs --json rows, or null when its stdout is not that shape. */
53export function parseStatic(stdout: string): Row[] | null {
54 try {
55 const rows = (JSON.parse(stdout) as { rows?: unknown }).rows
56 if (!Array.isArray(rows)) return null
57 return rows.filter(
58 (r): r is Row => !!r && STATUSES.has(r.status) && typeof r.name === 'string' && typeof r.detail === 'string',
59 )
60 } catch {
61 return null
62 }
63}
64
65async function staticRows($: $): Promise<Row[]> {
66 const root = $.plugin.root
67 let r
68 try {
69 const argv = ['node', `${root}/scripts/doctor.cjs`, '--json', '--root', root, '--project', await $.session.root()]
70 const dir = logDir(await $.env.get('HOME'), await $.env.get('DOMAINE_LOG_DIR'), await $.session.id())
71 if (dir) argv.push('--log-dir', dir)
72 r = await $.process.run(argv, { timeoutMs: 60_000 })
73 } catch (err) {
74 const why = err instanceof Error ? err.message : String(err)
75 return [{ status: 'SKIP', name: 'static', detail: `scripts/doctor.cjs did not run (${why}): the static checks need node` }]
76 }
77 const rows = parseStatic(r.stdout)
78 if (rows?.length) return rows
79 const why = (r.stderr.trim().split('\n')[0] ?? '') || 'no rows on stdout'
80 return [{ status: 'FAIL', name: 'static', detail: `scripts/doctor.cjs exited ${r.exitCode}: ${why}` }]
81}
82
83async function liveRows($: $): Promise<Row[]> {
84 const rows: Row[] = []
85 try {
86 rows.push((await $.command.list()).some(c => c.plugin === 'base')
87 ? { status: 'PASS', name: 'base-live', detail: "base's skills are loaded" }
88 : { status: 'FAIL', name: 'base-live', detail: `be ${BASE_MISSING}` })
89 } catch {
90 rows.push({ status: 'SKIP', name: 'base-live', detail: 'the command list did not answer' })
91 }
92 try {
93 rows.push((await $.tool.list()).some(t => t.name === SLIM_VIEW)
94 ? { status: 'PASS', name: 'slim-live', detail: `${SLIM_VIEW} registered` }
95 : { status: 'FAIL', name: 'slim-live', detail: 'slim is not loaded — claude plugin install slim@domaine; base refuses its readers until it is' })
96 } catch {
97 rows.push({ status: 'SKIP', name: 'slim-live', detail: 'the tool list did not answer' })
98 }
99 return rows
100}
101
102/**
103 * A static `base` FAIL for a base that is loaded anyway (a `--plugin-dir` load has no install record) reads
104 * as a warning.
105 */
106export function reconcile(rows: Row[]): Row[] {
107 const live = rows.find(r => r.name === 'base-live')?.status === 'PASS'
108 return rows.map(r =>
109 live && r.name === 'base' && r.status === 'FAIL' && r.detail.startsWith('not installed')
110 ? { status: 'WARN', name: 'base', detail: 'not in installed_plugins.json, yet loaded this session (a --plugin-dir load?)' }
111 : r,
112 )
113}
114
115export function age(ms: number): string {
116 const s = Math.max(0, Math.round(ms / 1000))
117 if (s < 60) return `${s}s`
118 if (s < 3600) return `${Math.floor(s / 60)}m`
119 if (s < 48 * 3600) return `${Math.floor(s / 3600)}h`
120 return `${Math.floor(s / 86400)}d`
121}
122
123export function summary(rows: Row[]): string {
124 const n = { PASS: 0, FAIL: 0, SKIP: 0, WARN: 0 }
125 for (const r of rows) n[r.status]++
126 return `${n.PASS} passed, ${n.FAIL} failed, ${n.SKIP} skipped${n.WARN ? `, ${n.WARN} warned` : ''}`
127}
128
129export function render(root: string, rows: Row[], tail: string[]): string {
130 const width = rows.reduce((w, r) => Math.max(w, r.name.length), 0)
131 return [
132 `be doctor — plugin root: ${root}`,
133 ...rows.map(r => `${r.status} ${r.name.padEnd(width)} ${r.detail}`),
134 `doctor: ${summary(rows)}`,
135 '',
136 ...tail,
137 ].join('\n')
138}
139
140async function eventTail($: $): Promise<string[]> {
141 if ((await $.env.get('BE_EVENT_LOG')) === '0') return ['be events: off (BE_EVENT_LOG=0)']
142 const list = await read($, events)
143 if (!list.length) return ['be events: none yet']
144 const now = await $.clock.now()
145 const shown = list.slice(-TAIL)
146 return [
147 `be events (last ${shown.length} of ${list.length}, newest last):`,
148 ...shown.map(ev => ` ${age(now - ev.atMs).padStart(4)} ${ev.kind.padEnd(9)} ${ev.text}`),
149 ]
150}
151
152async function logDoctor($: $, text: string): Promise<void> {
153 try {
154 if ((await $.env.get('BE_EVENT_LOG')) === '0') return
155 const ev: BeEvent = { atMs: await $.clock.now(), kind: 'doctor', text }
156 await update($, events, l => pushEvent(l, ev))
157 await logLine(diskOf($), ev)
158 } catch {}
159}
160
161export function registerDoctor(on: On): void {
162 // A matcher apart from session.ts's start hook: one unmatched hook per event per plugin.
163 on('session.start', { cwd: /$/ }, async ($, e, next) => {
164 const r = await next(e)
165 await arm($, true)
166 return r
167 })
168
169 on('prompt.submit', async ($, e, next) => {
170 const r = await next(e)
171 await arm($, false)
172 return r
173 })
174
175 on('command.run', { command: COMMAND.name }, async $ => {
176 const [fixed, live] = await Promise.all([staticRows($), liveRows($)])
177 const rows = reconcile([...fixed, ...live])
178 const tail = await eventTail($)
179 await logDoctor($, summary(rows))
180 return { text: render($.plugin.root, rows, tail) }
181 })
182}
183hooks/mods/session.ts 96 lines1// be's session: the start line, the base check, and the plugin root line — a system-prompt section for the
2// main session, added context for subagents.
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, On, PromptComposeSection } from 'claude-code'
5import type { BeEvent, BeEventKind } from '../../types'
6import { NO_BE_AGENT, rootLine } from './conventions/text.ts'
7import { logLine, pushEvent } from './events.ts'
8import type { Disk } from './events.ts'
9
10export const BASE_MISSING = 'needs the base plugin — claude plugin install base@domaine'
11
12const events = atom({ plugin: 'be', key: 'events' } as const, [] as BeEvent[])
13const started = atom({ plugin: 'be', key: 'started' } as const, null)
14
15type $ = EngineInterface
16
17/** events.ts's file writer reaches `$` through this: the validator follows `$` only within one file. */
18function diskOf($: $): Disk {
19 return {
20 session: () => $.session.id(),
21 home: () => $.env.get('HOME'),
22 override: () => $.env.get('DOMAINE_LOG_DIR'),
23 manifest: () => $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`),
24 read: path => $.fs.read(path),
25 write: (path, text) => $.fs.write(path, text),
26 toast: text => $.ui.toast(text),
27 }
28}
29
30async function logEvent($: $, kind: BeEventKind, text: string): Promise<void> {
31 try {
32 if ((await $.env.get('BE_EVENT_LOG')) === '0') return
33 const ev: BeEvent = { atMs: await $.clock.now(), kind, text }
34 await update($, events, l => pushEvent(l, ev))
35 await logLine(diskOf($), ev)
36 } catch {}
37}
38
39async function version($: $): Promise<string> {
40 try {
41 const v = (JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`)) as { version?: unknown }).version
42 return typeof v === 'string' && v ? v : 'unknown'
43 } catch {
44 return 'unknown'
45 }
46}
47
48/** base's skills carry `plugin: 'base'` in the command list from its manifest on; a list that fails says nothing. */
49async function baseLoaded($: $): Promise<boolean> {
50 try {
51 return (await $.command.list()).some(c => c.plugin === 'base')
52 } catch {
53 return true
54 }
55}
56
57/** The one section, `be:root`: fixed per plugin root, so a render repeats the last one byte for byte (the prompt cache). */
58export function sections(root: string): PromptComposeSection[] {
59 return [{ id: 'be:root', text: rootLine(root), scope: 'session' }]
60}
61
62/** null for base's readers and writer and Claude Code's helpers; the root line for every other agent. */
63export function subagentContext(root: string, agentType: string): string | null {
64 return NO_BE_AGENT.test(agentType) ? null : rootLine(root)
65}
66
67export function registerSession(on: On): void {
68 // The engine allows one unmatched hook per event per plugin; this matcher takes every session.
69 on('session.start', { cwd: /^/ }, async ($, e, next) => {
70 try {
71 const sid = String(await $.session.id())
72 if ((await read($, started)) !== sid) {
73 await update($, started, () => sid)
74 await logEvent($, 'start', `be ${await version($)}`)
75 if (!(await baseLoaded($))) {
76 await logEvent($, 'install', BASE_MISSING)
77 $.ui.toast(`be: ${BASE_MISSING}`)
78 }
79 }
80 } catch {}
81 return next(e)
82 })
83
84 on('prompt.compose', async ($, e, next) => {
85 const r = await next(e)
86 const taken = new Set(r.sections.map(s => s.id))
87 return { sections: [...r.sections, ...sections($.plugin.root).filter(s => !taken.has(s.id))] }
88 })
89
90 on('classic.SubagentStart', async ($, e, next) => {
91 const r = await next(e)
92 const ctx = subagentContext($.plugin.root, e.agent_type ?? '')
93 return ctx ? { ...r, additionalContext: [...(r.additionalContext ?? []), ctx] } : r
94 })
95}
96hooks/mods/events.ts 157 lines1// be's event list and its file on disk, `<log dir>/<session-id>/be.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 be.events
3// and then hands it to `logLine` with a `Disk` it built, as the validator follows `$` only within one file.
4// be never sweeps old session directories: base owns that.
5import type { BeEvent } 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 BeEvent[], ev: BeEvent): BeEvent[] {
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 be.jsonl line; `plugin` is be's own name, never taken from another plugin's state. */
33export function fileLine(ev: BeEvent, version: string, session: string): string {
34 return JSON.stringify({ ts: new Date(ev.atMs).toISOString(), plugin: 'be', 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 be.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: BeEvent, 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: `be ${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}/be.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 be.jsonl with `ev` appended (`$.fs.write` has no append), after the line went to
125 * be.events. Never throws; one toast per session when a write fails.
126 */
127export async function logLine(disk: Disk, ev: BeEvent): 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(`be: event log not written: ${reason}`)
154 } catch {}
155 }
156}
157hooks/mods/conventions/text.ts 7 lines1// be's session text: the one line the main session's system prompt and the subagents read.
2
3export const rootLine = (root: string) => `be plugin root: ${root}`
4
5/** base's readers and writer, and Claude Code's own helpers: no be context at all. */
6export const NO_BE_AGENT = /(^|:)(jira-reader|jira-writer|figma-reader|doc-reader)$|^(claude-code-guide|statusline-setup)$/
7types/index.d.ts 26 lines1// be'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` be's version at session start, `install` base is not
7 * loaded, `doctor` the counts of a /be-doctor run.
8 */
9export type BeEventKind = 'start' | 'install' | 'doctor'
10
11/** atMs = $.clock.now() when written; text is one line. */
12export type BeEvent = { atMs: number; kind: BeEventKind; text: string }
13
14declare module 'claude-code' {
15 interface PluginState {
16 be: {
17 /** Oldest first, at most 200; any plugin reads, be writes. Stays [] under BE_EVENT_LOG=0. */
18 events: BeEvent[]
19 /** The session id whose start line was written and whose base check ran: a module reload does neither again. */
20 started: string | null
21 /** The session id whose /be-doctor command is registered. */
22 armed: string | null
23 }
24 }
25}
26