Domaine QA team plugin for Claude Code: the preflight skill (Jira ticket, PR and store facts, a storefront unlocked on the theme under test, Steps to Test…

qa is the Domaine QA team plugin for Claude Code. It holds the QA engineer's side of a ticket: the preflight that reads the ticket and its PR, unlocks the storefront, proves which theme is under test, pre-runs the Steps to Test at desktop and mobile with screenshots, and writes a brief in Domaine's Jira house style — plus the read-only store-access posture a preflight works under.
qa builds on base and requires it: the Jira reader and writer, the task workspace, the QA store registry (<base root>/scripts/qa-stores.cjs), the shared references and the chrome-devtools MCP server are base's (plugins/base/README.md). base requires slim, so qa runs with slim too.
Current release: qa v0.1.0.
"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 qa@domaine
/reload-plugins
/base-doctor and /qa-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,
"qa@domaine": true,
"fnd@domaine": false
}
}
The team plugins — fe, qa, be and pm — co-install: each needs only base, none needs another, so a person who tests and builds installs both qa and fe beside the same base.
To move from fnd, run /plugin uninstall fnd@domaine and install the set above. The QA store registry stays where it was (~/.config/domaine/qa-stores.json): <base root>/scripts/qa-stores.cjs reads the same file.
Invoked by its qualified name. It hands off to base by base's qualified names (base:jira-reader, base:jira-writer); a hand-off is an offer at the end of the run, never an automatic start.
| Skill | Does | Uses / hands off to |
|---|---|---|
/qa:preflight <KEY> [<KEY> ...] | preflights tickets for hands-on QA: ticket, PR and store facts, the theme under test asked for and proved on the storefront, Steps to Test and the AC pre-run at desktop and mobile with screenshots, a two-block brief (house style for Jira, preflight notes for the engineer); posts Block 1 as a Jira comment only on a yes per key | base:jira-reader, base's chrome-devtools MCP, local gh / git, <base root>/scripts/qa-stores.cjs; base:jira-writer after approval → the engineer's hands-on pass, or back to the developer |
The skill carries its own skills/preflight/REFERENCE.md: the registry commands, PR discovery, the theme question and page URLs, the unlock and the deployed gate, the rows from Steps to Test, stand-in fixtures, the evidence rules, the brief template and the Jira comment rules.
qa ships no reference of its own. The skill cites base's by their path under base's root (<base root>/…, the session's base plugin root: line):
| Reference (base's) | Read for |
|---|---|
references/task-workspace.md | the .claude/tasks/<KEY>/ workspace read first, and the progress close-out |
references/steps-to-test-format.md | the numbered Steps to Test shape the rows are read from, and its fixtures rule |
references/break-it-qa.md | the break-it rows, non-destructive only; not-executable: access for the rest |
references/jira-adf-write.md | the opt-in Jira comment through base:jira-writer |
qa 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 qa:<name> and the session scope:
| Name | Holds |
|---|---|
root | qa plugin root: <path>, the directory qa's skill and doctor start from |
store-access | the QA posture while /qa:preflight runs or the person says the session is hands-on QA (a team plugin's own QA flow follows its own store-access section): the storefront only — no Admin API write, no theme or theme-settings write, no publish, duplicate or preview-theme creation; storefront-session actions (cart, quantity, a named discount code, checkout to the payment step) in scope. Whatever the scope, a storefront password comes only from <base root>/scripts/qa-stores.cjs get and is used only as the browser fill value — never in a file, a workspace note, a Jira comment, a screenshot or another Bash line than the registering set, never restated |
The text never changes within a session, so the prompt cache holds.
Subagents get qa's share as added context at their start (Claude Code's SubagentStart): 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 qa; base's reviewers (base:change-reviewer, base:bug-hunter) get the root line; every other agent gets the root line and the store-access posture.
base required: at a session start qa looks for base's skills in the command list. Without them it shows one toast and writes one install line: qa: needs the base plugin — claude plugin install base@domaine.
/qa-doctor checks qa's side of the install and prints one PASS / FAIL / SKIP / WARN row per check, the counts, and the last 10 qa.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 qa, base in its dependencies |
scripts | every file under scripts/ resolves; a .sh keeps its exec bit, a .cjs parses |
base | base installed (user scope or this project) and enabled — else claude plugin install base@domaine |
registry | the QA store registry through <base root>/scripts/qa-stores.cjs (path, then list --json): its file and a store count — never a store, a password or the registry's error text; no file yet skips (qa-stores.cjs set … makes one); a corrupt file fails |
gh | gh --version answers; absent or failing only warns (PR facts then come from the ticket's links) |
chrome-devtools | base's manifest declares the chrome-devtools-mcp server the browser phases drive |
event-log | this session's qa.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 eight rows come from scripts/doctor.cjs, which also runs by hand: node <qa 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 base FAIL in a session that loaded base anyway (a --plugin-dir load has no install record) reads as a WARN. One doctor line goes to qa.events per run.
qa writes its lines to $HOME/.claude/domaine/log/<session-id>/qa.jsonl under the same contract as every Domaine plugin (plugins/base/README.md):
{"ts":"…","plugin":"qa","version":"<qa's version>","session":"<id>","kind":"doctor","agent":"main","text":"9 passed, 0 failed, 1 skipped"}.start (qa <version>, first in every session's file, once), install (base is not loaded), doctor (a /qa-doctor run's counts). The same lines fill qa.events (oldest first, at most 200).qa: event log not written: <reason>.QA_EVENT_LOG=0 stops the file and the qa.events lines alike.Every switch qa reads has a row here; set it in ~/.claude/settings.json → env.
| Variable | Default | Effect |
|---|---|---|
QA_EVENT_LOG | on | 0 keeps qa.events empty and writes no qa.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, registry and chrome-devtools rows read |
claude plugin validate --strict plugins/qa and claude plugin test plugins/qa (the kit tests in plugins/qa/hooks/mods/tests/), both run by tests/mods-sim.sh with every other plugin (local only: CI has no claude).tests/qa-doctor-sim.sh — scripts/doctor.cjs's rows on planted installs, base's real registry script against a sandbox home.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 /qa:preflight reads.How the pieces fit: ARCHITECTURE.md.
MIT, as the repository (LICENSE).
hooks/mods/register.ts 12 lines1// qa hooks module (Claude Code only): the QA team's root line, store-access posture and doctor beside base.
2// qa writes only qa.* atoms. Each feature file declares its own atoms and keeps its `$` code to itself: the
3// 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}
12hooks/mods/doctor.ts 183 lines1// /qa-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 qa.events.
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, On } from 'claude-code'
5import type { QaEvent } 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: 'qa-doctor',
12 description: 'Check the qa install: node, manifest, scripts, base, the QA store registry, gh, chrome-devtools, 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: 'qa', key: 'armed' } as const, null)
22const events = atom({ plugin: 'qa', key: 'events' } as const, [] as QaEvent[])
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: `qa ${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 as
104 * 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 `qa 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('QA_EVENT_LOG')) === '0') return ['qa events: off (QA_EVENT_LOG=0)']
142 const list = await read($, events)
143 if (!list.length) return ['qa events: none yet']
144 const now = await $.clock.now()
145 const shown = list.slice(-TAIL)
146 return [
147 `qa 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('QA_EVENT_LOG')) === '0') return
155 const ev: QaEvent = { 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 101 lines1// qa's session: the start line, the base check and the conventions — system-prompt sections for the main
2// session, added context for subagents.
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, On, PromptComposeSection } from 'claude-code'
5import type { QaEvent, QaEventKind } from '../../types'
6import { BASE_AGENT, NO_QA_AGENT, STORE_ACCESS, 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: 'qa', key: 'events' } as const, [] as QaEvent[])
13const started = atom({ plugin: 'qa', 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: QaEventKind, text: string): Promise<void> {
31 try {
32 if ((await $.env.get('QA_EVENT_LOG')) === '0') return
33 const ev: QaEvent = { 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 sections, each `qa:<name>`: fixed text, so every render repeats the last one byte for byte (the prompt cache). */
58export function sections(root: string): PromptComposeSection[] {
59 const parts: [string, string][] = [
60 ['root', rootLine(root)],
61 ['store-access', STORE_ACCESS],
62 ]
63 return parts.map(([name, text]) => ({ id: `qa:${name}`, text, scope: 'session' }))
64}
65
66/** null for base's readers and writer and Claude Code's helpers; the root for base's reviewers; root and store access for the rest. */
67export function subagentContext(root: string, agentType: string): string | null {
68 if (NO_QA_AGENT.test(agentType)) return null
69 return BASE_AGENT.test(agentType) ? rootLine(root) : `${rootLine(root)}\n\n${STORE_ACCESS}`
70}
71
72export function registerSession(on: On): void {
73 // The engine allows one unmatched hook per event per plugin; this matcher takes every session.
74 on('session.start', { cwd: /^/ }, async ($, e, next) => {
75 try {
76 const sid = String(await $.session.id())
77 if ((await read($, started)) !== sid) {
78 await update($, started, () => sid)
79 await logEvent($, 'start', `qa ${await version($)}`)
80 if (!(await baseLoaded($))) {
81 await logEvent($, 'install', BASE_MISSING)
82 $.ui.toast(`qa: ${BASE_MISSING}`)
83 }
84 }
85 } catch {}
86 return next(e)
87 })
88
89 on('prompt.compose', async ($, e, next) => {
90 const r = await next(e)
91 const taken = new Set(r.sections.map(s => s.id))
92 return { sections: [...r.sections, ...sections($.plugin.root).filter(s => !taken.has(s.id))] }
93 })
94
95 on('classic.SubagentStart', async ($, e, next) => {
96 const r = await next(e)
97 const ctx = subagentContext($.plugin.root, e.agent_type ?? '')
98 return ctx ? { ...r, additionalContext: [...(r.additionalContext ?? []), ctx] } : r
99 })
100}
101hooks/mods/events.ts 157 lines1// qa's event list and its file on disk, `<log dir>/<session-id>/qa.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 qa.events
3// and then hands it to `logLine` with a `Disk` it built, as the validator follows `$` only within one file.
4// qa never sweeps old session directories: base owns that.
5import type { QaEvent } 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 QaEvent[], ev: QaEvent): QaEvent[] {
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 qa.jsonl line; `plugin` is qa's own name, never taken from another plugin's state. */
33export function fileLine(ev: QaEvent, version: string, session: string): string {
34 return JSON.stringify({ ts: new Date(ev.atMs).toISOString(), plugin: 'qa', 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 qa.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: QaEvent, 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: `qa ${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}/qa.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 qa.jsonl with `ev` appended (`$.fs.write` has no append), after the line went to
125 * qa.events. Never throws; one toast per session when a write fails.
126 */
127export async function logLine(disk: Disk, ev: QaEvent): 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(`qa: event log not written: ${reason}`)
154 } catch {}
155 }
156}
157hooks/mods/conventions/text.ts 25 lines1// qa's conventions, the text the main session's system prompt and the subagents read. Pure: `<base root>`
2// stays literal, as the session's `base plugin root:` line names it.
3
4export const rootLine = (root: string) => `qa plugin root: ${root}`
5
6export const STORE_ACCESS = `## qa convention — store access while a QA preflight runs
7
8While \`/qa:preflight\` runs, or when the person says this is a hands-on QA session, the run works on
9the storefront only: no Admin API write, no theme or theme-settings write, no publish, duplicate or
10preview-theme creation. Storefront-session actions — add to cart, change a quantity, apply a discount
11code the ticket names, advance checkout to the payment step — are in scope; any other write is
12reported, not performed. A team plugin's own QA flow follows its own store-access section.
13
14A storefront password comes only from \`node <base root>/scripts/qa-stores.cjs get <store>\`
15(\`<base root>\` is the path on base's \`base plugin root:\` line) and is used only as the browser fill
16value: never written to a file, a workspace note, a Jira comment or a screenshot, never on another
17Bash line than the \`qa-stores.cjs set\` that registers a store, and never restated in chat. The
18registry file itself is never read directly.`
19
20/** base's readers and writer, and Claude Code's own helpers: no qa context at all. */
21export const NO_QA_AGENT = /(^|:)(jira-reader|jira-writer|figma-reader|doc-reader)$|^(claude-code-guide|statusline-setup)$/
22
23/** base's own agents (the reviewers): the root line, not the store-access posture. */
24export const BASE_AGENT = /^base:/
25types/index.d.ts 26 lines1// qa'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` qa's version at session start, `install` base is not
7 * loaded, `doctor` the counts of a /qa-doctor run.
8 */
9export type QaEventKind = 'start' | 'install' | 'doctor'
10
11/** atMs = $.clock.now() when written; text is one line. */
12export type QaEvent = { atMs: number; kind: QaEventKind; text: string }
13
14declare module 'claude-code' {
15 interface PluginState {
16 qa: {
17 /** Oldest first, at most 200; any plugin reads, qa writes. Stays [] under QA_EVENT_LOG=0. */
18 events: QaEvent[]
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 /qa-doctor command is registered. */
22 armed: string | null
23 }
24 }
25}
26