SLOPSHOPPER

masq

Stackable Claude Code persona profiles covering register, working conduct, and content policy, with variants, ordered composition, and persistent activation.

newbandcommandprocesstimer
v0.8.0MITupdated 2026-10-15mdn87/masq
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · masq
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ 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 › /masq ⎿ masq: Masq: command result unavailable. A change may have been saved; run /masq status before retrying. ⟨Claude Code's own drawing⟩ Masq: command result unavailable. A change may have been saved; run /masq status before retrying. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ Masq: command result unavailable. A change may have been saved; run /masq status before retrying.
README

Masq

Modular Agent Style and Qualities.

Masq is a Claude Code plugin for reusable, stackable persona profiles. A profile can supply a voice, tone, domain posture, workflow preference, or contextual behavior. Profiles can be activated independently, assigned a variant, reordered, combined, and turned off without editing the plugin runtime.

Masq is quiet by default. No profile is active until one is explicitly enabled.

The name is clipped from the courtly theatrical tradition of costumes, roles, spectacle, and deliberate performance. It fits the first Renfaire profile while remaining broad enough for technical, editorial, workflow, and domain personas.

Why Masq

A single global personality toggle cannot express combinations such as:

dean:default + audience:expert + conduct:strict

Masq keeps an ordered stack. Every profile contributes its non-conflicting traits. A later profile wins only a direct style conflict. Contextual profiles remain dormant outside their declared scope.

In the example above, Dean sets the register: point first, prose over structure. Audience sets how much gets glossed for an expert reader without touching the register. Conduct changes what a completion report must contain, and may never widen a permission or skip a check. Each profile owns one axis, so the three compose without fighting.

Commands

On Claude Code 2.1.287 or later, /masq status, /masq set plain dean, /masq move plain last, and /masq temp set caveman run immediately without a model turn. They use the same command core and state as the ordinary hooks. The band above the prompt shows the last captured stack. Changes apply at the next prompt hook; an already running response keeps its existing context. Preview remains /masq:persona preview because it needs a model response.

The native controls are optional. Disabling mods leaves the commands below and the existing context hooks available. Mod reload resets the display; status reads persistent state again. Clear/resume use the ordinary session hooks' existing temporary-state rules. A failed or timed-out mutation is never retried automatically: inspect status first.

Portable command

From this checkout, use Node 18+ with explicit store, project and session identity:

node src/cli.js --data-dir /absolute/masq-data --cwd /absolute/project --session-id SESSION --harness codex --json -- set plain dean
node src/cli.js --data-dir /absolute/masq-data --cwd /absolute/project --session-id SESSION --harness codex -- status

On Windows, use absolute Windows paths and quote paths with spaces. Omit --json for text. package.json also exposes the masq executable for local package use. This command manages profiles; integrating their context into another harness still requires that harness's supported adapter.

claude-code preserves the existing store layout. Other harnesses use <data-dir>/harnesses/<harness>; --shared-store explicitly shares persistent preferences and presets while temporary sessions retain a harness prefix. Status is read-only. The CLI does not mark a selection as already injected.

The Claude mod resolves the loaded plugin's exact registry identity for its data directory (masq-inline for a development checkout). It refuses ambiguous cached installations. MASQ_DATA_DIR and CLAUDE_PLUGIN_DATA remain explicit overrides. Ordinary CLI --data-dir always wins over inherited defaults.

The controls were tested on Windows with Claude Code 2.1.296 using native event and rendering tests plus an interactive terminal. Desktop drawing is covered by the event harness; a live Desktop installation and macOS launch remain separate qualification checks. Run npm test and, when Claude is installed, npm run test:mods.

Legacy Claude commands

Claude Code namespaces plugin skills with the plugin name:

/masq:persona status
/masq:persona list
/masq:persona doctor
/masq:persona preview [profile[:variant] ...]
/masq:persona on <profile[:variant]> [...]
/masq:persona off <profile> [...]
/masq:persona toggle <profile[:variant]> [...]
/masq:persona set <profile[:variant]> [...]
/masq:persona move <profile> first|last
/masq:persona clear
/masq:persona help

Scope and preset commands:

/masq:persona global <status|on|off|toggle|set|move|clear> [...]
/masq:persona project <status|on|off|toggle|set|move|clear|unset> [...]
/masq:persona temp <status|on|off|toggle|set|move|clear> [...]
/masq:persona preset list
/masq:persona preset export <name> [effective|global|project|temp]
/masq:persona preset import <name> [active|global|project|temp]
/masq:persona preset delete <name>

Shorthand activation:

/masq:persona renfaire
/masq:persona renfaire:full

Examples:

/masq:persona on dean
/masq:persona on audience:expert
/masq:persona set dean:default audience:expert conduct:strict
/masq:persona move conduct last
/masq:persona off dean
/masq:persona clear
/masq:persona project set plain:strict caveman:lite
/masq:persona temp on renfaire:courtly
/masq:persona preset export concise effective
/masq:persona preset import concise project
/masq:persona preview renfaire:pageant caveman:lite

preview renders a standardized release-status sample through either the current effective stack or an explicitly supplied ordered combination. It is a one-turn comparison and does not change saved or temporary persona state.

Simple natural-language controls are also recognized:

turn on the medieval persona
turn off the caveman profile
show the active persona stack
clear all personas

The Afterdark compatibility command remains available:

/masq:afterdark
/masq:afterdark flirty
/masq:afterdark suggestive
/masq:afterdark direct
/masq:afterdark off

Included Profiles

Renfaire Herald

An overcommitted Medieval Times and renaissance-fair performer who has remained in character several hours past closing.

renfaire:courtly
renfaire:full
renfaire:pageant

The default is renfaire:pageant.

Plain Language

Clear human-facing prose: concrete subjects and verbs, statuses explained rather than left bare, exact technical names preserved, and no invented project shorthand.

plain:light
plain:default
plain:strict

The default is plain:default.

Caveman

Token-conscious terse prose adapted from the MIT-licensed Caveman skill. Caveman removes filler and repetition while preserving technical substance, exact literals, negations, and clarity around consequential actions.

caveman:lite
caveman:full
caveman:ultra
caveman:wenyan-lite
caveman:wenyan-full
caveman:wenyan-ultra

The default is caveman:full. See THIRD_PARTY_NOTICES.md for attribution and license terms.

Dean

A senior-engineer register: casual but competent, point first, prose over structure, no bolted-on summary. Carries channel calibration, so chat, commit messages, and reports each land at their own level.

dean:light
dean:default

The default is dean:default. dean:light leaves genuinely structured content structured; dean:default also collapses bullets back into prose where the items are reasoning rather than a checklist. The working half of this persona is the separate conduct profile below.

Audience

Sets the reader's assumed expertise, and only that: what gets glossed on first use, how far a procedure is spelled out, what gets restated. It sets no register, so it pairs with whichever voice profile you want.

audience:novice
audience:peer
audience:expert

The default is audience:peer. An override runs through every variant: where assuming knowledge would leave a reader unable to act on something consequential, the explanation stays regardless of level. expert means writing for peers, not writing to make someone feel stupid.

Working Conduct

The first profile on the conduct axis. Scope discipline, batched questions instead of permission theater, results that were probed rather than read off a dashboard, residuals named instead of swept into "done," and notes that record the why.

conduct:light
conduct:default
conduct:strict

The default is conduct:default. conduct:light applies the reporting habits only; conduct:strict requires an explicit residuals line on every completion report and a named probe behind every status claim.

A conduct profile is bounded more tightly than a presentation profile: it may change effort, sequencing, and what a report contains, and it may never grant tool authority, widen a permission, lower a confirmation requirement, skip a safety check, or alter a factual claim.

De-tell

Removes the patterns that make prose read as machine-generated: negation-contrast padding, rhetorical-question transitions, throat-clearing openers, filler vocabulary, cadence triplets, and endings that restate their heading. Negations that state a real limit, hedges that reflect real uncertainty, and any quoted or reproduced text survive untouched.

de-tell:light
de-tell:default
de-tell:strict

The default is de-tell:default. It supplies no register of its own, so it composes under any voice profile and strips only filler that no active profile asked for. Its document-side counterpart is the de-tell pass in tali.

Afterdark

A contextual adult intimate-message profile with controlled levels of directness.

afterdark:flirty
afterdark:suggestive
afterdark:direct

Afterdark remains non-graphic, consent-aware, and dormant outside adult intimate-message drafting or revision.

Composition Contract

Masq treats profiles as ordered overlays, not new authorities or identities. Each profile declares a kind that bounds what it may change:

KindChangesNever
presentationwording, register, structure, compressionwhat the response asserts, or detail the reader needs to act
conducteffort, sequencing, report contentspermissions, confirmations, safety checks, facts
policyrequired checks, clarifications, refusals, content limitsloosens anything, it may only tighten

Precedence runs within a kind. A later presentation profile owns the surface register, a later conduct profile wins a direct conduct conflict, and policy requirements sit outside stack precedence with the strictest one applying.

  • Apply profiles from first to last.
  • Preserve every non-conflicting trait.
  • Let a later profile win only direct style conflicts.
  • Keep contextual profiles dormant outside their scope.
  • Preserve code, commands, paths, URLs, identifiers, schemas, errors, quotations, citations, numbers, units, and data exactly.
  • Keep safety, factuality, project instructions, and the current user request above persona styling.
  • State destructive, security, medical, legal, and financial warnings plainly when styling could obscure them.

State Scopes

Masq resolves one effective stack from three scopes:

  1. The user-global stack is the fallback for every project.
  2. A project override, when present for the current canonical working directory, replaces the global stack.
  3. Temporary session profiles overlay the persistent stack and win duplicate profile precedence.

Unqualified mutation commands edit the project override when one exists and otherwise edit the global stack. project unset removes the override and restores global fallback. project clear deliberately creates an empty override. Temporary profiles expire at SessionEnd and remain isolated from other Claude Code sessions.

All state remains inside Claude Code's private plugin-data directory:

${CLAUDE_PLUGIN_DATA}/state.json
${CLAUDE_PLUGIN_DATA}/projects/<project-hash>.json
${CLAUDE_PLUGIN_DATA}/sessions/<session-hash>.json
${CLAUDE_PLUGIN_DATA}/presets.json

Global and project stacks survive new sessions, resume, /clear, and context compaction. Temporary stacks persist while the session remains live and through compaction; Claude Code's SessionEnd hook removes them on exit, /clear, or a session switch. Named presets store canonical stacks and never read or write an arbitrary path.

Run /masq:persona doctor to inspect the catalog, manifest hooks, data directory, current scopes, effective stack, and preset count. Doctor is read-only.

Seed an empty installation with a default stack:

$env:MASQ_DEFAULT_STACK = "dean:default,conduct:default"
claude --plugin-dir .

Reset the saved stack at each true startup:

$env:MASQ_RESET_ON_START = "1"

When both variables are set, startup clears the prior global stack and then loads the defaults. Project overrides remain explicit and unchanged.

Add a Profile

Profiles are discovered automatically from profiles/*.md. No registry or hook edit is required.

npm run new-profile -- goblin-accountant "Goblin Accountant"
npm test

See docs/PROFILE_FORMAT.md for the profile contract and docs/ARCHITECTURE.md for runtime behavior.

Test Locally

Node 18 or newer is required. There are no runtime npm dependencies.

npm test
claude --plugin-dir .

Install from GitHub

claude plugin marketplace add mdn87/masq
claude plugin install masq@masq

Updating

claude plugin update masq@masq

Claude Code pins a plugin's directory once per session. Since 0.7.0 the masq hooks run through src/hooks/launch.js, which checks installed_plugins.json on every hook event and delegates to the installed copy when it differs from the pinned one. New profiles and hook fixes therefore apply on the next prompt in every open session; only a new slash command in SKILL.md waits for a restart. /masq:persona doctor prints a Launcher: line showing which copy is running. The launcher leaves claude --plugin-dir . checkouts alone; MASQ_LIVE_ROOT=<path> points a checkout at another copy on purpose and MASQ_NO_RELAUNCH=1 turns delegation off.

Repository Layout

masq/
├── .claude-plugin/             # Claude Code plugin and marketplace manifests
├── .github/workflows/ci.yml    # Node test matrix
├── profiles/                   # Auto-discovered persona profiles
├── skills/                     # Persona management and compatibility commands
├── src/hooks/                  # Persistent state and prompt-context hooks
├── src/rules/                  # Global composition contract
├── scripts/                    # Validation and profile scaffolding
├── tests/                      # Hook and persistence tests
├── docs/                       # Architecture, profile format, roadmap, ADRs
├── AGENTS.md                   # Repository instructions for coding agents
└── CLAUDE.md                   # Claude Code maintainer context
Source 1 files
hooks/register.js 84 lines
1let report = 'Masq: run /masq status to inspect the active stack';
2let band = report;
3let generation = 0;
4let busy = false;
5
6function reset($) {
7  generation += 1;
8  busy = false;
9  report = 'Masq: run /masq status to inspect the active stack';
10  band = report;
11  $.ui.invalidate('ui.render');
12}
13
14async function runCommand($, args) {
15  if (busy) return 'Masq: the previous command is still running; inspect status before trying again.';
16  busy = true;
17  const epoch = generation;
18  let started = false;
19  const failure = 'Masq: session identity unavailable; no command was run.';
20  try {
21    const root = $.plugin.root;
22    const cwd = await $.session.cwd();
23    const session = await $.session.id();
24    if (!cwd || !session) throw new Error('session identity unavailable');
25    started = true;
26    const result = await $.process.run(['node', root + '/src/cli.js',
27      '--claude-plugin-root', root, '--cwd', cwd, '--session-id', session,
28      '--harness', 'claude-code', '--json', '--', ...args.trim().split(/\s+/).filter(Boolean)],
29      { timeoutMs: 5000 });
30    const output = JSON.parse(result.stdout);
31    if (output.schema_version !== 1 || typeof output.text !== 'string') throw new Error('invalid command response');
32    if (epoch !== generation) return 'Masq: the session changed; inspect status in the current session.';
33    report = output.text;
34    band = typeof output.status_text === 'string' ? output.status_text : report;
35    $.ui.invalidate('ui.render');
36    return report;
37  } catch (_) {
38    const message = started ? 'Masq: command result unavailable. A change may have been saved; run /masq status before retrying.' : failure;
39    if (epoch === generation) {
40      report = message;
41      band = message;
42      $.ui.invalidate('ui.render');
43    }
44    return message;
45  } finally {
46    if (epoch === generation) busy = false;
47  }
48}
49
50export function register(on) {
51  on('session.start', async ($, e, next) => {
52    reset($);
53    await $.command.register({ name: 'masq', immediate: true,
54      description: 'Inspect or change persona selection, order and scope without a model turn',
55      argumentHint: '[status|list|set|move|global|project|temp|preset] [...]' });
56    $.clock.after(0, () => runCommand($, 'status'));
57    return next(e);
58  });
59  on('classic.SessionStart', async ($, e, next) => {
60    const result = await next(e);
61    reset($);
62    $.clock.after(0, () => runCommand($, 'status'));
63    return result;
64  });
65  on('classic.CwdChanged', async ($, e, next) => {
66    const result = await next(e);
67    reset($);
68    $.clock.after(0, () => runCommand($, 'status'));
69    return result;
70  });
71  on('session.end', async ($, e, next) => { reset($); return next(e); });
72  on('command.run', { command: 'masq' }, async ($, e) => ({ text: await runCommand($, e.args || 'status') }));
73  on('turn.complete', async ($, e, next) => {
74    if (e.agentId === undefined) $.clock.after(0, () => runCommand($, 'status'));
75    return next(e);
76  });
77  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
78    const previous = await next(e);
79    const { Box, Text } = $.ui.resolve(e);
80    return Box({ flexDirection: 'column', children: [previous,
81      Text({ dimColor: true, children: [band.split('\n').slice(0, 2).join(' | ')] })].filter(Boolean) });
82  });
83}
84