a11y-loop 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,555 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PreToolUse gate on `ExitPlanMode`.
4
+ *
5
+ * The skill can tell an agent to think about accessibility while planning. It
6
+ * cannot make it. This hook can: when a plan changes user interface work and
7
+ * says nothing about accessibility, the plan does not get approved, and Claude
8
+ * is handed the section it has to fill in before trying again.
9
+ *
10
+ * The gate checks that the question was asked. It cannot check that the answer
11
+ * is any good — that is what `a11y-loop audit` and a human are for.
12
+ *
13
+ * Three properties matter more than catching everything:
14
+ *
15
+ * 1. **It never breaks planning.** Empty stdin, bad JSON, an unreadable state
16
+ * file, a bug in here — every failure path allows and exits 0.
17
+ * 2. **It denies at most once per plan.** A hook that can deny the same plan
18
+ * twice can deny it forever. Plan text is hashed and a hash is only ever
19
+ * spent once; after that the same plan passes with the reminder demoted to
20
+ * `additionalContext`. There is a per-session cap on top of that, and an
21
+ * `A11Y_LOOP_PLAN_GATE=off` kill switch on top of that.
22
+ * 3. **It defers, it does not allow.** ExitPlanMode normally asks the user to
23
+ * approve the plan. Returning `"allow"` would suppress that prompt and
24
+ * auto-accept the plan — a side effect nobody asked this hook for. The
25
+ * non-deny paths return `"defer"`, which is the normal permission flow.
26
+ *
27
+ * A false deny costs the user a round trip on a plan that was fine. A miss
28
+ * costs nothing the skill was not already going to catch at generation time.
29
+ * So the UI detector demands real evidence before it fires.
30
+ */
31
+
32
+ import { createHash } from 'node:crypto';
33
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
34
+ import { tmpdir } from 'node:os';
35
+ import path from 'node:path';
36
+ import { pathToFileURL } from 'node:url';
37
+
38
+ /** Deny a given plan hash at most once, and a given session at most this often. */
39
+ const MAX_DENIES_PER_SESSION = 3;
40
+
41
+ /** Hashes and session counters older than this are pruned on the next write. */
42
+ const STATE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
43
+
44
+ /** Hard cap on retained hashes, so the file cannot grow without bound. */
45
+ const MAX_STATE_ENTRIES = 200;
46
+
47
+ /** Room to spare under the documented 10,000-character `additionalContext` cap. */
48
+ const MAX_REASON_CHARS = 8000;
49
+
50
+ const STATE_VERSION = 1;
51
+ const STATE_FILENAME = 'a11y-loop-plan-gate.json';
52
+
53
+ /** At least one STRONG signal, and this much total weight, before we call it UI. */
54
+ const UI_SCORE_THRESHOLD = 5;
55
+
56
+ /** How many of the seven items a plan needs before we treat it as having answered. */
57
+ const COVERAGE_THRESHOLD = 3;
58
+
59
+ /**
60
+ * Unmistakably user-interface vocabulary. Deliberately excludes accessibility
61
+ * words: whether a plan touches UI has to be established independently of
62
+ * whether it mentions accessibility, or a plan that says "accessibility" once in
63
+ * a backend context scores as UI and then gets denied for thin coverage.
64
+ */
65
+ const STRONG_UI = [
66
+ 'react', 'vue', 'svelte', 'angular', 'astro', 'next.js', 'nuxt', 'remix',
67
+ 'jsx', 'tsx', '.tsx', '.jsx', '.vue', '.svelte', '.astro', '.html', '.css',
68
+ 'tailwind', 'css', 'scss', 'sass', 'stylesheet', 'styled-components',
69
+ 'css module', 'html', 'dom', 'frontend', 'front-end', 'storybook', 'shadcn',
70
+ 'material ui', 'chakra', 'bootstrap', 'design system', 'design token',
71
+ 'dark mode', 'light mode', 'responsive', 'viewport', 'breakpoint',
72
+ 'media query', 'user interface', 'ui component', 'component library',
73
+ 'landing page', 'web page', 'webpage', 'modal', 'dialog', 'dropdown',
74
+ 'navbar', 'nav bar', 'sidebar', 'tooltip', 'carousel', 'accordion',
75
+ 'breadcrumb', 'hero section', 'button', 'checkbox', 'radio button',
76
+ 'onclick', 'click handler', 'hover', 'animation', 'typography',
77
+ 'color scheme', 'palette', 'wireframe', 'mockup',
78
+ ];
79
+
80
+ /** Words that lean UI but earn a living elsewhere — a database has tables too. */
81
+ const MEDIUM_UI = [
82
+ 'component', 'page', 'screen', 'form', 'menu', 'tab', 'table', 'chart',
83
+ 'dashboard', 'layout', 'style', 'color', 'colour', 'theme', 'widget', 'view',
84
+ 'panel', 'card', 'badge', 'toast', 'banner', 'spinner', 'skeleton', 'avatar',
85
+ 'grid', 'mobile', 'desktop', 'click', 'scroll', 'input', 'label',
86
+ 'placeholder', 'heading', 'font', 'icon', 'svg', 'image', 'link', 'header',
87
+ 'footer', 'toggle', 'switch', 'slider', 'pagination', 'filter', 'nav',
88
+ ];
89
+
90
+ /** Evidence the plan is somewhere else entirely. */
91
+ const NON_UI = [
92
+ 'cli', 'command-line', 'command line', 'database migration',
93
+ 'schema migration', 'sql', 'postgres', 'postgresql', 'mysql', 'sqlite',
94
+ 'mongodb', 'redis', 'backend', 'back-end', 'server-side', 'api endpoint',
95
+ 'rest api', 'graphql', 'cron', 'scheduler', 'parser', 'lexer', 'tokenizer',
96
+ 'compiler', 'infrastructure', 'terraform', 'kubernetes', 'docker', 'ci/cd',
97
+ 'github action', 'webhook', 'message queue', 'worker', 'daemon', 'stdout',
98
+ 'stderr', 'exit code', 'shell script', 'npm package', 'lockfile',
99
+ 'environment variable', 'telemetry', 'rate limit', 'indexing', 'changelog',
100
+ ];
101
+
102
+ const WEIGHTS = { strong: 3, medium: 1, nonUi: 2 };
103
+
104
+ /**
105
+ * The shape the gate asks a plan to arrive in. Pasted verbatim into the deny
106
+ * message so Claude does not have to reconstruct it from the skill.
107
+ */
108
+ export const ACCESSIBILITY_TEMPLATE = `### Accessibility
109
+ - **Target:** WCAG 2.2 Level AA — <rationale / jurisdiction>
110
+ - **Per-component criteria:** <component> → <SC list> + <keyboard contract source>
111
+ - **Foreclosing decisions:** <none reviewed | list + alternatives>
112
+ - **Color tokens:** <pairs verified with contrast --fix, light + dark>
113
+ - **Structure:** <heading outline / landmarks / focus order>
114
+ - **Verification:** <states needing --interact | where the audit gate sits>
115
+ - **Manual budget:** <what automation cannot judge here>`;
116
+
117
+ /**
118
+ * The seven items, each with a detector that only matches accessibility-specific
119
+ * language. A generic word like "test" or "color" must never light one of these
120
+ * up, or a plan with no accessibility content at all would score as covered.
121
+ */
122
+ export const COVERAGE_ITEMS = [
123
+ {
124
+ key: 'target',
125
+ label: 'Target',
126
+ detect: /wcag\s*2\.\d|level\s+(?:a{1,3})\b|conformance\s+(?:target|level)|en\s*301\s*549|section\s*508/i,
127
+ },
128
+ {
129
+ key: 'perComponent',
130
+ label: 'Per-component criteria',
131
+ detect: /per-component|\bsc\s*\d\.\d\.\d+|success criteri|acceptance criteri|keyboard contract|\bapg\b|aria authoring/i,
132
+ },
133
+ {
134
+ key: 'foreclosing',
135
+ label: 'Foreclosing decisions',
136
+ detect: /foreclos|rules? out|single[- ]pointer|keyboard alternative|accessible alternative|pointer alternative|no alternative/i,
137
+ },
138
+ {
139
+ key: 'colorTokens',
140
+ label: 'Color tokens',
141
+ detect: /contrast|4\.5\s*:\s*1|3\s*:\s*1|color token|colour token|oklch/i,
142
+ },
143
+ {
144
+ key: 'structure',
145
+ label: 'Structure',
146
+ detect: /heading (?:outline|order|structure|level)|landmark|focus order|reading order|tab order|semantic (?:html|structure|markup)|skip link/i,
147
+ },
148
+ {
149
+ key: 'verification',
150
+ label: 'Verification',
151
+ detect: /a11y-loop|axe-core|\baxe\b|--interact|re-audit|audit (?:gate|loop|pass)|accessibility (?:audit|test|check)|a11y (?:audit|test|check)/i,
152
+ },
153
+ {
154
+ key: 'manualBudget',
155
+ label: 'Manual budget',
156
+ detect: /manual (?:test|review|check|budget|pass)|screen[- ]reader test|\bnvda\b|voiceover|\bjaws\b|talkback|human (?:review|confirm|judge)|not automat|cannot (?:be )?(?:automat|judge)|assistive technolog/i,
157
+ },
158
+ ];
159
+
160
+ /**
161
+ * Product decisions that are cheap to change in a plan and expensive to change
162
+ * in shipped code. Each one is a real WCAG obligation, cited as the criterion
163
+ * rather than as a verdict.
164
+ */
165
+ export const FORECLOSING_RISKS = [
166
+ {
167
+ id: 'dragging',
168
+ detect: /drag(?:gable|ging|-and-drop| and drop| to reorder| to sort)?\b|sortable list|reorder by drag|kanban/i,
169
+ note: 'drag-based reordering → SC 2.5.7 Dragging Movements (Level AA, WCAG 2.2) asks for a single-pointer alternative. Name it now (move up/down buttons, a position field, a cut-and-paste move) or drop the pattern.',
170
+ },
171
+ {
172
+ id: 'hover',
173
+ detect: /hover (?:menu|card|panel|reveal|state|to)|on hover|reveal on hover|hover-?triggered|mega ?menu/i,
174
+ note: 'content that appears on hover → SC 1.4.13 Content on Hover or Focus (Level AA) requires it to be dismissible, hoverable and persistent. Decide the focus trigger and dismiss key here, not after the CSS exists.',
175
+ },
176
+ {
177
+ id: 'infiniteScroll',
178
+ detect: /infinite scroll|endless scroll|load more on scroll|auto-?load (?:more|as you scroll)|virtual(?:ised|ized) (?:list|scroll)/i,
179
+ note: 'infinite scroll → keyboard and screen-reader users can be stranded above content that only appends on scroll, and anything below the feed becomes unreachable. Relates to SC 2.4.3 Focus Order (Level A). Decide the paginated or "load more" button path now.',
180
+ },
181
+ {
182
+ id: 'canvas',
183
+ detect: /\bcanvas\b|webgl|\bd3\b|three\.js|pixi|chart\.js|render(?:ed|ing)? to (?:a )?canvas/i,
184
+ note: 'canvas or WebGL rendering → SC 1.1.1 Non-text Content (Level A). Pixels expose no accessible names or roles, so the text alternative (a data table, a summary, an SVG version) is part of the build, not a follow-up.',
185
+ },
186
+ {
187
+ id: 'timing',
188
+ detect: /countdown|time(?:-| )?out|session expir|auto-?(?:refresh|logout|dismiss)|expires in|\btimer\b|auto-?advanc/i,
189
+ note: 'a time limit → SC 2.2.1 Timing Adjustable (Level A) requires the user be able to turn it off, adjust it, or extend it. Decide which of the three you are offering.',
190
+ },
191
+ {
192
+ id: 'captcha',
193
+ detect: /captcha|recaptcha|hcaptcha|human verification|bot check|prove you'?re human/i,
194
+ note: 'CAPTCHA → SC 1.1.1 Non-text Content (Level A) needs alternatives in more than one modality, and SC 3.3.8 Accessible Authentication (Minimum) (Level AA, WCAG 2.2) rules out cognitive function tests in the auth path. A puzzle or transcription challenge fails 3.3.8.',
195
+ },
196
+ {
197
+ id: 'autoplay',
198
+ detect: /auto-?play|plays? automatically|auto-?rotat|auto-?scroll(?:ing)? carousel|background video|looping video/i,
199
+ note: 'autoplay → SC 1.4.2 Audio Control (Level A) for anything audible past 3 seconds, and SC 2.2.2 Pause, Stop, Hide (Level A) for anything moving past 5 seconds. Both need a control that is reachable before the moving thing.',
200
+ },
201
+ ];
202
+
203
+ /** Accessibility vocabulary at its broadest — used only to spot a section anchor. */
204
+ const A11Y_ANCHOR = /accessib|a11y|\bwcag\b|\baria\b|screen reader|keyboard (?:navigation|access|contract)|assistive/i;
205
+
206
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
207
+
208
+ /**
209
+ * Match a literal term on token boundaries. `\b` is no use here because half the
210
+ * terms start or end with `.`, `-` or `/` (`.tsx`, `front-end`, `ci/cd`).
211
+ *
212
+ * @param {string} term
213
+ * @returns {RegExp}
214
+ */
215
+ function termRegex(term) {
216
+ return new RegExp(`(?<![a-z0-9])${escapeRe(term)}(?![a-z0-9])`, 'i');
217
+ }
218
+
219
+ const COMPILED = {
220
+ strong: STRONG_UI.map((t) => [t, termRegex(t)]),
221
+ medium: MEDIUM_UI.map((t) => [t, termRegex(t)]),
222
+ nonUi: NON_UI.map((t) => [t, termRegex(t)]),
223
+ };
224
+
225
+ /**
226
+ * Decide whether a plan changes user interface work.
227
+ *
228
+ * Requires at least one unambiguous signal on top of the score, so that a
229
+ * backend plan mentioning "table" and "filter" and "view" cannot accumulate its
230
+ * way past the threshold.
231
+ *
232
+ * @param {string} text
233
+ * @returns {{touchesUi:boolean, uiScore:number, nonUiScore:number, strong:string[], medium:string[], nonUi:string[]}}
234
+ */
235
+ export function detectUi(text) {
236
+ const hit = (pairs) => pairs.filter(([, re]) => re.test(text)).map(([term]) => term);
237
+ const strong = hit(COMPILED.strong);
238
+ const medium = hit(COMPILED.medium);
239
+ const nonUi = hit(COMPILED.nonUi);
240
+
241
+ const uiScore = strong.length * WEIGHTS.strong + medium.length * WEIGHTS.medium;
242
+ const nonUiScore = nonUi.length * WEIGHTS.nonUi;
243
+
244
+ return {
245
+ touchesUi: strong.length > 0 && uiScore >= UI_SCORE_THRESHOLD && uiScore > nonUiScore,
246
+ uiScore,
247
+ nonUiScore,
248
+ strong,
249
+ medium,
250
+ nonUi,
251
+ };
252
+ }
253
+
254
+ /**
255
+ * Score a plan against the seven items. Tolerant by design: any heading spelling
256
+ * counts, partial coverage counts, and prose that never uses the template's
257
+ * wording still counts as long as it says something accessibility-specific.
258
+ *
259
+ * @param {string} text
260
+ * @returns {{hasSection:boolean, present:string[], missing:{key:string,label:string}[], covered:boolean}}
261
+ */
262
+ export function assessAccessibility(text) {
263
+ const hasSection = /^\s{0,3}(?:#{1,6}\s*|\*\*\s*|\d+[.)]\s*)?(?:accessibility|a11y|wcag)\b.{0,40}$/im.test(text);
264
+
265
+ const present = [];
266
+ const missing = [];
267
+ for (const item of COVERAGE_ITEMS) {
268
+ if (item.detect.test(text)) present.push(item.key);
269
+ else missing.push({ key: item.key, label: item.label });
270
+ }
271
+
272
+ return { hasSection, present, missing, covered: present.length >= COVERAGE_THRESHOLD };
273
+ }
274
+
275
+ /**
276
+ * @param {string} text
277
+ * @returns {{id:string, note:string}[]}
278
+ */
279
+ export function detectForeclosingRisks(text) {
280
+ return FORECLOSING_RISKS.filter((r) => r.detect.test(text)).map(({ id, note }) => ({ id, note }));
281
+ }
282
+
283
+ /**
284
+ * @param {string} text
285
+ * @returns {string} 64 hex characters
286
+ */
287
+ export function planHash(text) {
288
+ return createHash('sha256').update(text.trim().replace(/\s+/g, ' '), 'utf8').digest('hex');
289
+ }
290
+
291
+ /**
292
+ * @param {{missing:{label:string}[], hasSection:boolean, risks:{note:string}[], repeat:boolean}} opts
293
+ * @returns {string}
294
+ */
295
+ export function buildReason({ missing, hasSection, risks, repeat }) {
296
+ const lines = [];
297
+
298
+ lines.push(
299
+ repeat
300
+ ? 'a11y-loop plan gate (reminder only — this plan has already been through the gate once):'
301
+ : 'a11y-loop plan gate: this plan changes user interface work, and its accessibility content is thin or absent.',
302
+ );
303
+ lines.push('');
304
+ lines.push(
305
+ hasSection
306
+ ? 'There is an accessibility heading, but most of the seven decisions under it are unanswered.'
307
+ : 'There is no accessibility section in this plan.',
308
+ );
309
+ lines.push(
310
+ 'These are plan-time decisions because they are cheap now and a rewrite later. Add this section, fill each line from what this plan actually builds, and call ExitPlanMode again.',
311
+ );
312
+ lines.push('');
313
+ lines.push(ACCESSIBILITY_TEMPLATE);
314
+
315
+ if (missing.length) {
316
+ lines.push('');
317
+ lines.push(`Unanswered here: ${missing.map((m) => m.label).join(', ')}.`);
318
+ }
319
+
320
+ if (risks.length) {
321
+ lines.push('');
322
+ lines.push('Decisions already in this plan that are hard to walk back once built:');
323
+ for (const r of risks) lines.push(`- ${r.note}`);
324
+ }
325
+
326
+ lines.push('');
327
+ lines.push(
328
+ 'This gate checks that the question was asked. It is not a review of the answer and not a statement about the conformance of anything you build — `a11y-loop audit` and a human reviewer are still the verification step. Turn the gate off with A11Y_LOOP_PLAN_GATE=off.',
329
+ );
330
+
331
+ const reason = lines.join('\n');
332
+ return reason.length > MAX_REASON_CHARS ? `${reason.slice(0, MAX_REASON_CHARS - 3)}...` : reason;
333
+ }
334
+
335
+ /**
336
+ * The whole decision, with no I/O in it.
337
+ *
338
+ * @param {{plan:string, seenBefore:boolean, denyCount:number}} input
339
+ * @returns {{decision:'deny'|'defer', reason?:string, additionalContext?:string, spendHash:boolean, why:string}}
340
+ */
341
+ export function evaluate({ plan, seenBefore, denyCount }) {
342
+ const pass = (why) => ({ decision: 'defer', spendHash: false, why });
343
+
344
+ if (!plan || plan.trim().length < 40) return pass('plan-too-short');
345
+
346
+ const ui = detectUi(plan);
347
+ if (!ui.touchesUi) return pass('not-ui');
348
+
349
+ const coverage = assessAccessibility(plan);
350
+ if (coverage.covered) return pass('already-covered');
351
+
352
+ const risks = detectForeclosingRisks(plan);
353
+
354
+ // Everything below here would be a deny on a first sighting. Past the loop
355
+ // guard it becomes the same text, demoted to context Claude can read and
356
+ // ignore, because a gate that can fire twice on one plan is a trap.
357
+ if (seenBefore || denyCount >= MAX_DENIES_PER_SESSION) {
358
+ return {
359
+ decision: 'defer',
360
+ spendHash: false,
361
+ why: seenBefore ? 'repeat-plan' : 'session-cap',
362
+ additionalContext: buildReason({
363
+ missing: coverage.missing,
364
+ hasSection: coverage.hasSection,
365
+ risks,
366
+ repeat: true,
367
+ }),
368
+ };
369
+ }
370
+
371
+ return {
372
+ decision: 'deny',
373
+ spendHash: true,
374
+ why: 'ui-without-accessibility',
375
+ reason: buildReason({
376
+ missing: coverage.missing,
377
+ hasSection: coverage.hasSection,
378
+ risks,
379
+ repeat: false,
380
+ }),
381
+ };
382
+ }
383
+
384
+ /**
385
+ * `${CLAUDE_PLUGIN_DATA}` when Claude Code provides it, the temp directory when
386
+ * it does not. Never the project directory: ExitPlanMode hooks run with cwd set
387
+ * to the home directory (anthropics/claude-code#22343), so nothing here may
388
+ * depend on a relative path.
389
+ *
390
+ * @returns {string}
391
+ */
392
+ export function stateFilePath() {
393
+ const dir = process.env.CLAUDE_PLUGIN_DATA?.trim() || tmpdir();
394
+ return path.join(dir, STATE_FILENAME);
395
+ }
396
+
397
+ /** @returns {{version:number, plans:Record<string,number>, sessions:Record<string,{n:number,t:number}>}} */
398
+ function emptyState() {
399
+ return { version: STATE_VERSION, plans: {}, sessions: {} };
400
+ }
401
+
402
+ function loadState(file) {
403
+ try {
404
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
405
+ if (!parsed || parsed.version !== STATE_VERSION) return emptyState();
406
+ return {
407
+ version: STATE_VERSION,
408
+ plans: parsed.plans && typeof parsed.plans === 'object' ? parsed.plans : {},
409
+ sessions: parsed.sessions && typeof parsed.sessions === 'object' ? parsed.sessions : {},
410
+ };
411
+ } catch {
412
+ return emptyState();
413
+ }
414
+ }
415
+
416
+ /**
417
+ * Drop anything past its TTL, then trim the newest entries down to the cap.
418
+ *
419
+ * @param {ReturnType<typeof emptyState>} state
420
+ * @param {number} now
421
+ */
422
+ function pruneState(state, now) {
423
+ const fresh = Object.entries(state.plans).filter(([, t]) => Number.isFinite(t) && now - t < STATE_TTL_MS);
424
+ fresh.sort((a, b) => b[1] - a[1]);
425
+ state.plans = Object.fromEntries(fresh.slice(0, MAX_STATE_ENTRIES));
426
+
427
+ state.sessions = Object.fromEntries(
428
+ Object.entries(state.sessions)
429
+ .filter(([, v]) => v && Number.isFinite(v.t) && now - v.t < STATE_TTL_MS)
430
+ .slice(0, MAX_STATE_ENTRIES),
431
+ );
432
+ }
433
+
434
+ function saveState(file, state) {
435
+ try {
436
+ mkdirSync(path.dirname(file), { recursive: true });
437
+ writeFileSync(file, JSON.stringify(state), 'utf8');
438
+ } catch {
439
+ // A gate that cannot remember is still a gate; one that throws is not.
440
+ }
441
+ }
442
+
443
+ /**
444
+ * @param {NodeJS.ReadStream} stream
445
+ * @returns {Promise<string>}
446
+ */
447
+ function readStdin(stream) {
448
+ return new Promise((resolve) => {
449
+ if (stream.isTTY) {
450
+ resolve('');
451
+ return;
452
+ }
453
+ let data = '';
454
+ stream.setEncoding('utf8');
455
+ stream.on('data', (chunk) => {
456
+ data += chunk;
457
+ });
458
+ stream.on('end', () => resolve(data));
459
+ stream.on('error', () => resolve(''));
460
+ });
461
+ }
462
+
463
+ /**
464
+ * `plan` is optional on ExitPlanMode; `planFilePath` carries it instead when the
465
+ * plan was written to disk.
466
+ *
467
+ * @param {any} toolInput
468
+ * @returns {string}
469
+ */
470
+ function planTextFrom(toolInput) {
471
+ if (!toolInput || typeof toolInput !== 'object') return '';
472
+ if (typeof toolInput.plan === 'string' && toolInput.plan.trim()) return toolInput.plan;
473
+ if (typeof toolInput.planFilePath === 'string' && toolInput.planFilePath.trim()) {
474
+ try {
475
+ return readFileSync(toolInput.planFilePath, 'utf8');
476
+ } catch {
477
+ return '';
478
+ }
479
+ }
480
+ return '';
481
+ }
482
+
483
+ function killSwitchOn() {
484
+ const v = (process.env.A11Y_LOOP_PLAN_GATE ?? '').trim().toLowerCase();
485
+ return v === 'off' || v === '0' || v === 'false' || v === 'disabled';
486
+ }
487
+
488
+ /**
489
+ * Emit nothing and exit 0 — identical to a `defer`, and the quietest possible
490
+ * outcome for the overwhelmingly common case where the gate has no opinion.
491
+ */
492
+ function silent() {
493
+ process.exitCode = 0;
494
+ }
495
+
496
+ function emit(payload) {
497
+ process.stdout.write(
498
+ `${JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', ...payload } })}\n`,
499
+ );
500
+ process.exitCode = 0;
501
+ }
502
+
503
+ export async function main() {
504
+ try {
505
+ if (killSwitchOn()) return silent();
506
+
507
+ const raw = await readStdin(process.stdin);
508
+ if (!raw.trim()) return silent();
509
+
510
+ let input;
511
+ try {
512
+ input = JSON.parse(raw);
513
+ } catch {
514
+ return silent();
515
+ }
516
+
517
+ const plan = planTextFrom(input?.tool_input);
518
+ if (!plan.trim()) return silent();
519
+
520
+ const sessionId = typeof input?.session_id === 'string' ? input.session_id : 'unknown';
521
+ const file = stateFilePath();
522
+ const now = Date.now();
523
+ const state = loadState(file);
524
+ const hash = planHash(plan);
525
+
526
+ const verdict = evaluate({
527
+ plan,
528
+ seenBefore: Object.hasOwn(state.plans, hash),
529
+ denyCount: state.sessions[sessionId]?.n ?? 0,
530
+ });
531
+
532
+ if (verdict.spendHash) {
533
+ state.plans[hash] = now;
534
+ const seen = state.sessions[sessionId] ?? { n: 0, t: now };
535
+ state.sessions[sessionId] = { n: seen.n + 1, t: now };
536
+ pruneState(state, now);
537
+ saveState(file, state);
538
+ }
539
+
540
+ if (verdict.decision === 'deny') {
541
+ return emit({ permissionDecision: 'deny', permissionDecisionReason: verdict.reason });
542
+ }
543
+ if (verdict.additionalContext) {
544
+ return emit({ permissionDecision: 'defer', additionalContext: verdict.additionalContext });
545
+ }
546
+ return silent();
547
+ } catch {
548
+ // Whatever it was, it is not worth blocking a plan over.
549
+ return silent();
550
+ }
551
+ }
552
+
553
+ const invokedDirectly =
554
+ process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url;
555
+ if (invokedDirectly) await main();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "a11y-loop",
3
- "version": "0.1.1",
4
- "description": "Makes AI coding agents write accessible UI by default, then proves what it can prove with a real browser audit across the states they built — and tells you exactly what it could not check.",
3
+ "version": "0.2.0",
4
+ "description": "Makes AI coding agents decide accessibility while the work is still being planned and write accessible UI by default, then proves what it can prove with a real browser audit across the states they built — and tells you exactly what it could not check.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "a11y-loop": "src/cli.js"
@@ -23,6 +23,8 @@
23
23
  "ai-agents",
24
24
  "agent-skills",
25
25
  "claude-code",
26
+ "claude-code-plugin",
27
+ "plan-mode",
26
28
  "audit",
27
29
  "contrast"
28
30
  ],
@@ -39,6 +41,9 @@
39
41
  "files": [
40
42
  "src/",
41
43
  "skill/",
44
+ ".claude-plugin/",
45
+ "hooks/",
46
+ "commands/",
42
47
  "THIRD-PARTY-NOTICES.md"
43
48
  ],
44
49
  "dependencies": {