SLOPSHOPPER

QA

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…

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

qa

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.

Status

  • Claude Code only: qa is a plugin of one skill and a hooks module (mods). It ships no adapter for another host.
  • 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 the team plugins.

Install

/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.

Skills

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.

SkillDoesUses / 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 keybase: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.

References

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.mdthe .claude/tasks/<KEY>/ workspace read first, and the progress close-out
references/steps-to-test-format.mdthe numbered Steps to Test shape the rows are read from, and its fixtures rule
references/break-it-qa.mdthe break-it rows, non-destructive only; not-executable: access for the rest
references/jira-adf-write.mdthe opt-in Jira comment through base:jira-writer

Conventions

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:

NameHolds
rootqa plugin root: <path>, the directory qa's skill and doctor start from
store-accessthe 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.

Doctor

/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).

RowChecks
nodeNode 18 or newer
manifestthe manifest's version, the name qa, base in its dependencies
scriptsevery file under scripts/ resolves; a .sh keeps its exec bit, a .cjs parses
basebase installed (user scope or this project) and enabled — else claude plugin install base@domaine
registrythe 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
ghgh --version answers; absent or failing only warns (PR facts then come from the ticket's links)
chrome-devtoolsbase's manifest declares the chrome-devtools-mcp server the browser phases drive
event-logthis 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-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 <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.

Event log on disk

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):

  • Line: {"ts":"…","plugin":"qa","version":"<qa's version>","session":"<id>","kind":"doctor","agent":"main","text":"9 passed, 0 failed, 1 skipped"}.
  • qa's lines: 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).
  • 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 qa: event log not written: <reason>.
  • Off: QA_EVENT_LOG=0 stops the file and the qa.events lines alike.
  • Clean-up: qa never deletes; base sweeps old session folders.

Environment switches

Every switch qa reads has a row here; set it in ~/.claude/settings.json → env.

VariableDefaultEffect
QA_EVENT_LOGon0 keeps qa.events empty and writes no qa.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, registry and chrome-devtools rows read

Tests

  • 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.

Licence

MIT, as the repository (LICENSE).

Source 6 files
hooks/mods/register.ts 12 lines
1// 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}
12
hooks/mods/doctor.ts 183 lines
1// /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}
183
hooks/mods/session.ts 101 lines
1// 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}
101
hooks/mods/events.ts 157 lines
1// 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}
157
hooks/mods/conventions/text.ts 25 lines
1// 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:/
25
types/index.d.ts 26 lines
1// 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