Skip to content
Testing
Hook

Hooks

What accessibility-agents runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.

From plugin
accessibility-agents
414108 skills2 hooks
Install
> /plugin marketplace add Community-Access/accessibility-agents
> /plugin install accessibility-agents@community-access

Ships with accessibility-agents. Installing the plugin gets these hooks.

Where it lives

  • hooks/guard.mjsGitHub
    Read the script
    #!/usr/bin/env node
    /**
     * guard.mjs - the accessibility enforcement gate, for every client.
     *
     * One script, four behaviours, four client manifests that map their own event
     * names onto it. Previously each client had its own script in its own language
     * with its own subtly different marker logic, which is why a lead dispatch
     * recognised on one client was missed on another.
     *
     * ## The behaviours
     *
     *   detect   Inject the delegation reminder. Once per session, not per prompt.
     *   gate     Refuse an edit to a user-facing file until the lead has completed.
     *   mark     Record that the lead finished, which opens the gate.
     *   persist  Save phase and findings path before compaction; restore after.
     *   finalize Refuse to end a turn that edited UI without a completed review.
     *
     * ## Why once per session
     *
     * The old hook injected roughly 570 tokens of reminder on every prompt. Over a
     * forty-turn session that is 22,000 tokens spent repeating something the model
     * read the first time. It now injects once, capped at sixty words, and the
     * marker file records that it did.
     *
     * ## Why mark on completion, not on launch
     *
     * The gate used to open when the lead was dispatched. A cancelled or failed
     * lead therefore unlocked every UI edit for the rest of the session. It now
     * opens only when the lead reports completion.
     *
     * Exit codes follow the common hook convention: 0 proceed, 2 block.
     * Structured decisions are emitted as JSON on stdout where the client reads it.
     *
     * Usage (from a client manifest):
     *   node hooks/guard.mjs --behaviour detect|gate|mark|persist|finalize --client claude|codex|copilot|gemini
     */
    
    import fs from 'node:fs';
    import os from 'node:os';
    import path from 'node:path';
    import { fileURLToPath } from 'node:url';
    
    // --------------------------------------------------------------- parameters
    
    /** Files whose change a person sees and operates. */
    const UI_PATTERN = /\.(html?|jsx|tsx|vue|svelte|astro|css|scss|sass|less|ejs|hbs|handlebars|erb|leaf|jinja2?|twig|blade\.php|razor|cshtml)$/i;
    
    /**
     * Paths that look like UI but are not a user's interface: dependencies, build
     * output, coverage, tests and stories.
     *
     * Each directory pattern matches at the start of the path as well as after a
     * separator. Requiring a leading separator let `dist/main.css` through, which
     * is the common shape of a relative path from a repository root.
     */
    const SEP = '(?:^|[\\\\/])';
    const EXEMPT = [
      new RegExp(`${SEP}node_modules[\\\\/]`),
      new RegExp(`${SEP}dist[\\\\/]`),
      new RegExp(`${SEP}build[\\\\/]`),
      new RegExp(`${SEP}out[\\\\/]`),
      new RegExp(`${SEP}coverage[\\\\/]`),
      new RegExp(`${SEP}vendor[\\\\/]`),
      new RegExp(`${SEP}\\.git[\\\\/]`),
      new RegExp(`${SEP}__(?:tests?|mocks?|snapshots?)__[\\\\/]`),
      /\.(test|spec|stories)\.[jt]sx?$/i,
      /\.min\.(css|js)$/i,
    ];
    
    /** Sixty words. Anything longer is repeating what AGENTS.md already says. */
    export const REMINDER =
      'This project enforces WCAG 2.2 AA. Before editing HTML, JSX, TSX, Vue, Svelte, CSS or a template, ' +
      'dispatch the accessibility-lead skill and let it finish. Office and PDF files go to ' +
      'document-accessibility-wizard, markdown to markdown-a11y-assistant. Edits to user-facing files are ' +
      'blocked until the lead completes. The full contract is in AGENTS.md.';
    
    /** Forty words. A refusal has to say what to do next, and nothing else. */
    export const REFUSAL =
      'Accessibility review required before editing this file. Dispatch the accessibility-lead skill, let ' +
      'it finish, then retry the edit. This gate is described in AGENTS.md.';
    
    const LEAD_SKILLS = new Set([
      'accessibility-lead',
      'accessibility-agents:accessibility-lead',
      'web-accessibility-wizard',
      'accessibility-agents:web-accessibility-wizard',
    ]);
    
    // ------------------------------------------------------------------- session
    
    /**
     * Markers live in one directory per session so a parallel session cannot open
     * another session's gate. Codex reports child sessions and parent threads under
     * different identifiers, so every identifier a payload carries is treated as an
     * alias of the same session; that mismatch is what previously made a valid lead
     * dispatch invisible to the edit gate.
     */
    function sessionIds(payload) {
      const candidates = [
        payload.session_id,
        payload.sessionId,
        payload.parent_session_id,
        payload.parentSessionId,
        payload.thread_id,
        payload.threadId,
        payload.conversation_id,
        payload.conversationId,
        process.env.CLAUDE_SESSION_ID,
        process.env.CODEX_SESSION_ID,
        process.env.COPILOT_SESSION_ID,
      ].filter((v) => typeof v === 'string' && v.length);
      return candidates.length ? [...new Set(candidates)] : ['default'];
    }
    
    function markerDir() {
      const base = process.env.A11Y_GUARD_DIR || path.join(os.tmpdir(), 'a11y-agents-guard');
      fs.mkdirSync(base, { recursive: true });
      return base;
    }
    
    function markerPath(id, name) {
      return path.join(markerDir(), `${sanitise(id)}.${name}`);
    }
    
    function sanitise(id) {
      return String(id).replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 96);
    }
    
    function hasMarker(ids, name) {
      return ids.some((id) => fs.existsSync(markerPath(id, name)));
    }
    
    function setMarker(ids, name, contents = '') {
      for (const id of ids) {
        try {
          fs.writeFileSync(markerPath(id, name), contents || new Date().toISOString(), 'utf8');
        } catch {
          // A guard that cannot write a marker must not take the session down with
          // it. The gate stays closed, which is the safe direction.
        }
      }
    }
    
    function readMarker(ids, name) {
      for (const id of ids) {
        try {
          return fs.readFileSync(markerPath(id, name), 'utf8');
        } catch {
          /* try the next alias */
        }
      }
      return null;
    }
    
    // --------------------------------------------------------------------- input
    
    async function readPayload() {
      const chunks = [];
      for await (const chunk of process.stdin) chunks.push(chunk);
      const text = Buffer.concat(chunks).toString('utf8').trim();
     
  • hooks/guard.test.mjsGitHub
    Read the script
    /**
     * guard.test.mjs - the enforcement gate, tested.
     *
     * This script is the only thing standing between a model and an unreviewed
     * edit to a user's interface. Every test below is a way it could fail open,
     * fail closed on the wrong thing, or cost tokens it should not.
     *
     * Run: node --test hooks/guard.test.mjs
     */
    
    import { test, describe, beforeEach } from 'node:test';
    import assert from 'node:assert/strict';
    import { spawnSync } from 'node:child_process';
    import fs from 'node:fs';
    import os from 'node:os';
    import path from 'node:path';
    import { fileURLToPath } from 'node:url';
    
    const HERE = path.dirname(fileURLToPath(import.meta.url));
    const GUARD = path.join(HERE, 'guard.mjs');
    
    let scratch;
    
    function run(behaviour, payload) {
      const result = spawnSync(process.execPath, [GUARD, '--behaviour', behaviour, '--client', 'claude'], {
        input: JSON.stringify(payload),
        encoding: 'utf8',
        env: { ...process.env, A11Y_GUARD_DIR: scratch },
      });
      let json = null;
      try {
        json = result.stdout.trim() ? JSON.parse(result.stdout) : null;
      } catch {
        json = null;
      }
      return { code: result.status, stdout: result.stdout, json };
    }
    
    const SESSION = { session_id: 'test-session' };
    const edit = (file, extra = {}) => ({ ...SESSION, ...extra, tool_input: { file_path: file } });
    
    beforeEach(() => {
      scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'guard-test-'));
    });
    
    describe('detect', () => {
      test('injects the contract once, then stays quiet', () => {
        const first = run('detect', SESSION);
        assert.equal(first.code, 0);
        assert.ok(first.json, 'first prompt should inject the contract');
        assert.match(first.json.additionalContext, /WCAG 2\.2 AA/);
    
        const second = run('detect', SESSION);
        assert.equal(second.stdout.trim(), '', 'a second prompt must not repeat the contract');
      });
    
      test('the reminder stays short enough to be worth repeating never', () => {
        const { json } = run('detect', SESSION);
        const words = json.additionalContext.trim().split(/\s+/).length;
        assert.ok(words <= 70, `reminder is ${words} words; it is injected into every session and must stay brief`);
      });
    });
    
    describe('gate', () => {
      test('blocks an edit to a user-facing file before review', () => {
        const result = run('gate', edit('src/components/Modal.tsx'));
        assert.equal(result.code, 2, 'an unreviewed UI edit must be refused');
        assert.equal(result.json.hookSpecificOutput.permissionDecision, 'deny');
        assert.match(result.json.hookSpecificOutput.permissionDecisionReason, /accessibility-lead/i);
      });
    
      test('the refusal says what to do next and nothing else', () => {
        const { json } = run('gate', edit('src/App.vue'));
        const words = json.hookSpecificOutput.permissionDecisionReason.trim().split(/\s+/).length;
        assert.ok(words <= 45, `refusal is ${words} words; it must name the next action and stop`);
      });
    
      test('allows the edit once the lead has completed', () => {
        run('mark', { ...SESSION, subagent_type: 'accessibility-lead' });
        const result = run('gate', edit('src/components/Modal.tsx'));
        assert.equal(result.code, 0, 'the gate must open after a completed review');
      });
    
      test('ignores files that are not user-facing', () => {
        for (const file of ['server/db.ts', 'scripts/build.mjs', 'README.md', 'package.json']) {
          assert.equal(run('gate', edit(file)).code, 0, `${file} is not an interface and must not be gated`);
        }
      });
    
      test('ignores build output, tests and dependencies that merely look like UI', () => {
        for (const file of [
          'node_modules/react/index.css',
          'dist/main.css',
          'build/app.html',
          'src/Modal.test.tsx',
          'src/Button.stories.tsx',
          'coverage/index.html',
        ]) {
          assert.equal(run('gate', edit(file)).code, 0, `${file} is not a user's interface and must not be gated`);
        }
      });
    
      test('gates every user-facing extension, not only the common ones', () => {
        for (const file of [
          'a.html', 'a.jsx', 'a.tsx', 'a.vue', 'a.svelte', 'a.astro',
          'a.css', 'a.scss', 'a.ejs', 'a.hbs', 'a.erb', 'a.leaf', 'a.twig', 'a.cshtml',
        ]) {
          assert.equal(run('gate', edit(file)).code, 2, `${file} is user-facing and must be gated`);
        }
      });
    
      test('a client that names the path differently is still gated', () => {
        for (const key of ['file_path', 'filePath', 'path', 'target_file']) {
          const payload = { ...SESSION, tool_input: { [key]: 'src/Page.tsx' } };
          assert.equal(run('gate', payload).code, 2, `a payload using ${key} must still be gated`);
        }
      });
    });
    
    describe('mark', () => {
      test('a completed lead opens the gate', () => {
        run('mark', { ...SESSION, subagent_type: 'accessibility-lead' });
        assert.equal(run('gate', edit('src/X.tsx')).code, 0);
      });
    
      test('a plugin-qualified name is recognised', () => {
        run('mark', { ...SESSION, subagent_type: 'accessibility-agents:accessibility-lead' });
        assert.equal(run('gate', edit('src/X.tsx')).code, 0);
      });
    
      test('an unrelated subagent does not open the gate', () => {
        run('mark', { ...SESSION, subagent_type: 'python-specialist' });
        assert.equal(run('gate', edit('src/X.tsx')).code, 2, 'only the lead may open the gate');
      });
    
      test('a session id alias still opens the gate for the same session', () => {
        // Codex reports child sessions and parent threads under different ids.
        // Treating them as separate sessions is what previously lost a valid review.
        run('mark', { session_id: 'child-1', parent_session_id: 'parent-1', subagent_type: 'accessibility-lead' });
        const result = run('gate', { session_id: 'parent-1', tool_input: { file_path: 'src/X.tsx' } });
        assert.equal(result.code, 0, 'a review recorded under a child session must count for its parent');
      });
    
      test('one session cannot open the gate of another', () => {
        run('mark', { session_id: 'session-a', subagent_type: 'accessibility-lead' });
        const other = run('gate', { session_id: 'session-b', tool_

Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.

Ships withaccessibility-agents

WCAG 2.2 AA enforcement for agentic coding, as a set of Agent Skills. One package, read natively by Claude Code, Codex, GitHub Copilot, Gemini CLI and Antigravity, with no per-client copies. Models forget accessibility while generating code.

Get the whole plugin
Stats
414
Stars
46
Forks
Active
Maintenance
JavaScript
Language
MIT
License
12h ago
Last commit
7mo ago
Created

Repo: Community-Access/accessibility-agents