Claude Code engineering workflows, safety reviews, and local memory tools

Skill-led development workflows for Claude Code: clarify, research, plan, implement, review, and deliver, with focused audits and local project memory. This folder is the Claude-only bundle of harness-useful-skills, generated by scripts/build-claude-plugin.js; do not edit it by hand except this README.
/useful-skills:us-workflow, us-plan, us-research, us-implement, us-review, us-check-security, us-check-performance, us-check-code-quality, us-improve-code-coverage, us-improve-codebase-architecture, us-grill-me, us-memory, us-open-pr, us-approve-work, us-ship-backlog-item, us-ignore-workflow, us-concise). Claude also selects them by description.planner, scout, frontend, backend, general-purpose, reviewer, security-reviewer. On first use the plugin writes useful-skills/models.yml with a modelRoles: map of default models (selector <model>[:<effort>]). Each agent reads one role key: planner reads plan (claude-opus-5-5:high), scout reads research (claude-haiku-5-5:high), frontend reads frontend (claude-sonnet-5-5:high), backend reads backend (claude-opus-5-5:high), general-purpose reads implementation (claude-sonnet-5-5:high), reviewer reads review (claude-sonnet-5-5:medium), and security-reviewer reads security (claude-opus-5-5:high); a role missing from the file uses default (claude-opus-5-5:medium). The generated ~/.claude/agents/useful-skills-*.md agents use those models. Edit those keys in the file to change them; the selector inherit uses the session model. The plain useful-skills:<agent> plugin agents (used as a fallback or when a generated agent conflicts) always inherit the session model.memory_status, memory_search, memory_save, graph_build, graph_query.workflow, research, plan, review, security_review, backend_memory, graphify_memory. Saved /useful-skills settings override them.Requires Node.js 22+ on PATH. Install from the Claude plugin directory or with /plugin, then restart Claude Code. Ask for development work normally, or load a skill directly, for example /useful-skills:us-workflow. /useful-skills help lists the slash command's subcommands (list, workflow, stage, status, doctor, update); graph is also accepted and prints how to use the graph MCP tools.
This section describes what the plugin code runs, writes, and sends.
The /useful-skills slash command. The module hooks/register.ts registers the command on session.start and handles command.run for /useful-skills only. It runs node <plugin root>/claude/command.js <your arguments> in the session folder, with a 60 second limit (10 minutes for doctor setup), to show or change settings, list skills, and run health checks. Commands that change settings run only when they come from a prompt you typed or an SDK client (origins composer and sdk); from any other origin only read-only subcommands run, and doctor only checks.
What the mod reads and where it goes (hooks/register.ts line 92): it reads the session's working directory (session.cwd) and, for doctor, the project root (session.root), and passes them only to that local node process, as its working directory and as CLAUDE_PROJECT_DIR. The command is always node, the script is always claude/command.js inside this plugin, and the remaining arguments are the words you typed after /useful-skills (at most 8, each at most 256 characters). The command's output is shown to you in Claude Code and is not sent anywhere else. The mod itself makes no network calls and starts no other program; doctor setup inside command.js may run the Graphify setup described under Network and Programs started.
Hooks (hooks/hooks.json, each runs node claude/hooks.js <event>):
SessionStart: creates useful-skills/models.yml on first use, regenerates the us-* role agents from it, and adds a short note to the session context listing them.UserPromptSubmit: reads your saved and plugin-option workflow settings and adds the effective stage states (enabled, disabled, unknown) to the context of each prompt.UserPromptExpansion (only for useful-skills:us-ignore-workflow): when you invoke that skill directly, adds context that skips the workflow stages for that one request.PreToolUse (Bash only): denies a command that matches one of these patterns. (1) rm with a recursive option (-r, -R, -rf, --recursive, and so on) whose arguments include the root / itself (not a path beneath it such as /tmp/x), ~ or a ~/ path, ., .., or any $, backtick, *, or ? character. (2) mkfs, mkfs.*, or wipefs followed by a path under /dev/, /sys/, or /proc/. (3) dd with of= set to /dev/sd*, /dev/nvme*n*, or /dev/mmcblk*. (4) curl or wget piped straight into sh or bash. This is narrow pattern matching, not a sandbox: other destructive or download-and-run commands (for example find -delete, git clean, other shells, or a download saved and run in two steps) are not caught. Malformed hook input is denied.PostToolUse: best-effort redaction, replacing matches with [REDACTED] in the text of successful tool output. It matches only: sk- or rk- keys with 20+ characters after the prefix; ghp_, gho_, ghu_, ghs_, ghr_, and github_pat_ tokens with 20+ characters after the prefix; AWS access key IDs (AKIA plus 16 characters); Bearer tokens of 20+ characters; and the words api key, access token, refresh token, client secret (the two words separated by _, -, a space, or nothing), token, password, passwd, or secret (case-insensitive) followed by optional whitespace, : or =, an optional quote, and a value of 8+ characters. It does not catch, for example, prefixed names such as DB_PASSWORD=..., JSON fields such as "password": "...", AWS secret access keys, private key blocks, or passwords inside connection strings. Failed tool output is not redacted. memory_save refuses content that matches the same patterns, so it has the same gaps.MCP server. A local stdio server started with node claude/mcp-server.js, exposing the five tools above. Available in Claude Code and Cowork, not on claude.ai. memory_status, memory_search, and memory_save work on private files; graph_build and graph_query run the pinned Graphify locally against your project (the build reads project files and stores its output outside the project).
Programs started. node for the hooks, command, and MCP server. For first-time Graphify setup only: /usr/bin/tar; /usr/bin/flock (Linux) or /usr/bin/lockf (macOS) for a setup lock; /usr/bin/timeout on Linux (on macOS a Node watchdog is used instead); the downloaded uv; and the managed Python, which runs graphify and graph-import-provenance.py. Child processes get a minimal environment (HOME, TMPDIR, UV_CACHE_DIR, and a short PATH), not your shell environment, and NODE_V8_COVERAGE is always set to an empty string. The setup lock command gets only PATH, LC_ALL=C, and the empty NODE_V8_COVERAGE. The managed Python install step also gets UV_PYTHON_INSTALL_MIRROR, a file:// URL for a temporary local directory holding the already verified Python archive, so uv installs it without a further download. Graphify runs also get GRAPHIFY_QUERY_LOG_DISABLE, PYTHONNOUSERSITE, and PYTHONSAFEPATH, and graph builds additionally GRAPHIFY_OUT. Managed Graphify setup is supported only on linux-x64 and darwin-arm64; elsewhere the graph tools report it as unsupported.
Network. None, except the first Graphify setup. It runs when graph_build or graph_query is called, or for /useful-skills doctor setup from your own prompt. With default settings (graphify_memory on) the us-memory skill tells Claude to call graph_build during the normal workflow, so the first download can happen without you asking for a graph; Claude Code's usual MCP tool permission prompts apply. Turn the graphify_memory option off to stop those automatic calls. Setup downloads uv and CPython from GitHub release hosts only (github.com, objects.githubusercontent.com, release-assets.githubusercontent.com, github-releases.githubusercontent.com), checked against the SHA-256 digests in dependencies/manifest.json, then runs uv pip sync --require-hashes --only-binary :all: against dependencies/graphify.lock (graphifyy 0.9.65 and its pinned dependencies), which fetches hash-verified wheels from the default PyPI index. The plugin sends nothing else off your machine and has no telemetry. Later runs reuse the installation offline.
Files written.
~/.claude/useful-skills/models.yml and ~/.claude/useful-skills/workflow-settings/ (role models; saved /useful-skills workflow and stage settings). $CLAUDE_CONFIG_DIR replaces ~/.claude when set.~/.claude/agents/useful-skills-*.md: generated role agents. They are user-level agents, available in every Claude Code session, and contain the absolute path of this plugin install. Uninstalling the plugin does not remove them; delete those files to remove them.CLAUDE_PLUGIN_DATA): useful-skills/memory/ (saved facts, graph snapshots, Graphify runtime state per project) and useful-skills/dependencies/ (the downloaded uv, Python, and Graphify environment). Graphify output is directed into that managed memory directory, and the memory store refuses a location inside your workspace.Credentials. The plugin code reads no credentials or tokens. The PostToolUse hook receives tool output, which may contain secrets, only to redact it; it does not store or send it. The delivery skills (us-open-pr, us-approve-work, us-ship-backlog-item) instruct Claude to use your existing gh and git login, under Claude Code's normal permission prompts: read-only gh queries (for example gh auth status, gh issue list) when selecting or checking work, and pushes or pull requests only for delivery you explicitly request.
GPL-3.0-only. Upstream notices for derived skill text are in licenses/.
hooks/register.ts 375 lines1import type { EngineInterface, PluginOptions, Register } from 'claude-code'
2
3// Claude Code function-hooks module: `/useful-skills`, backed by claude/command.js.
4
5const COMMAND_TIMEOUT_MS = 60_000
6const DOCTOR_TIMEOUT_MS = 600_000
7const MAX_ARGUMENTS = 8
8
9const NODE_REQUIRED = 'Useful Skills needs Node.js 22+ on PATH to run /useful-skills.'
10const FAILED = 'Useful Skills could not complete /useful-skills.'
11const CANCELLED = 'Cancelled; no settings changed.'
12const NO_CHANGE = 'No change.'
13const NO_OUTPUT = 'Useful Skills command produced no output; nothing was confirmed.'
14const WRITE_REFUSED = 'Workflow settings can only be changed from your own prompt.'
15const DOCTOR_PREPARING = 'Useful Skills doctor: preparing memory; first setup can take several minutes'
16const DOCTOR_TIMED_OUT = 'Useful Skills doctor did not finish within 10 minutes; setup may be partial — run it again.'
17const USAGE = [
18 'Usage: /useful-skills [status|workflow|stage|list|doctor|help]',
19 'Run /useful-skills help for the full list.',
20].join('\n')
21const TOO_MANY_ARGUMENTS = [`Too many arguments (at most ${MAX_ARGUMENTS}).`, USAGE].join('\n')
22
23// `doctor` alone prepares memory (writes); `doctor --check` only reports.
24const DOCTOR_SETUP: readonly string[] = ['doctor']
25const DOCTOR_CHECK: readonly string[] = ['doctor', '--check']
26
27// Boolean plugin options travel to the CLI as the env vars Claude gives hook processes.
28const OPTION_ENV: Readonly<Record<string, string>> = {
29 workflow: 'CLAUDE_PLUGIN_OPTION_WORKFLOW',
30 research: 'CLAUDE_PLUGIN_OPTION_RESEARCH',
31 plan: 'CLAUDE_PLUGIN_OPTION_PLAN',
32 review: 'CLAUDE_PLUGIN_OPTION_REVIEW',
33 security_review: 'CLAUDE_PLUGIN_OPTION_SECURITY_REVIEW',
34 backend_memory: 'CLAUDE_PLUGIN_OPTION_BACKEND_MEMORY',
35 graphify_memory: 'CLAUDE_PLUGIN_OPTION_GRAPHIFY_MEMORY',
36}
37
38// Only these origins are the person's own prompt; every other origin may only read.
39const WRITE_ORIGINS: ReadonlySet<string> = new Set(['composer', 'sdk'])
40
41// Every other subcommand can change saved settings; `doctor` from these origins only checks.
42const READ_ONLY: ReadonlySet<string> = new Set(['status', 'help', 'list', 'doctor', 'update', 'graph'])
43
44type Stage = { key: string; label: string }
45
46const STAGES_FIRST: readonly Stage[] = [
47 { key: 'research', label: 'Research' },
48 { key: 'plan', label: 'Plan' },
49 { key: 'review', label: 'Review' },
50]
51const STAGES_SECOND: readonly Stage[] = [
52 { key: 'security-review', label: 'Security review' },
53 { key: 'backend-memory', label: 'Backend memory' },
54 { key: 'graphify-memory', label: 'Graphify memory' },
55]
56
57type Run = (tokens: readonly string[]) => Promise<string>
58
59/** A menu entry either opens a submenu, goes back, or runs one CLI command. */
60type Choice = { label: string; menu?: Menu; command?: readonly string[]; back?: true }
61type Menu = { question: string; header: string; choices: readonly Choice[] }
62
63type CliResult = { exitCode: number; text: string }
64type CliRunOptions = { timeoutMs?: number; extraEnv?: Record<string, string> }
65
66class CliUnavailable extends Error {}
67class DoctorTimedOut extends Error {}
68
69function optionEnvironment(options: PluginOptions): Record<string, string> {
70 const env: Record<string, string> = {}
71
72 for (const [field, name] of Object.entries(OPTION_ENV)) {
73 const value = options[field]
74
75 if (typeof value === 'boolean') {
76 env[name] = value ? 'true' : 'false'
77 }
78 }
79
80 return env
81}
82
83/** Runs the CLI and returns its output; a failure to start it is never described in detail. */
84async function runCli(
85 $: EngineInterface,
86 env: Record<string, string>,
87 tokens: readonly string[],
88 options: CliRunOptions = {},
89): Promise<CliResult> {
90 try {
91 const cwd = await $.session.cwd()
92 const result = await $.process.run(['node', `${$.plugin.root}/claude/command.js`, ...tokens], {
93 cwd,
94 env: { ...env, ...options.extraEnv },
95 timeoutMs: options.timeoutMs ?? COMMAND_TIMEOUT_MS,
96 })
97 const text = result.stdout.trim() || result.stderr.trim()
98
99 return { exitCode: result.exitCode, text }
100 } catch {
101 throw new CliUnavailable()
102 }
103}
104
105function sameTokens(tokens: readonly string[], expected: readonly string[]): boolean {
106 return tokens.length === expected.length && tokens.every((token, index) => token === expected[index])
107}
108
109/** A toast is only a hint: one that cannot be shown, now or later, never stops the command. */
110function showToast($: EngineInterface, text: string): void {
111 try {
112 const shown: unknown = $.ui.toast(text)
113
114 // Not awaited: a toast that fails later is dropped, never an unhandled rejection.
115 Promise.resolve(shown).catch(() => {})
116 } catch {
117 // No surface to show it on; the command still runs.
118 }
119}
120
121/** Full memory setup: may run for minutes, so it gets the longest timeout the host allows. */
122async function runDoctorSetup($: EngineInterface, env: Record<string, string>, extraEnv: Record<string, string>): Promise<CliResult> {
123 showToast($, DOCTOR_PREPARING)
124
125 const startedAt = await $.clock.now()
126 const hasTimedOut = async () => (await $.clock.now()) - startedAt >= DOCTOR_TIMEOUT_MS
127 let result: CliResult
128
129 try {
130 result = await runCli($, env, DOCTOR_SETUP, { timeoutMs: DOCTOR_TIMEOUT_MS, extraEnv })
131 } catch (error) {
132 if (await hasTimedOut()) {
133 throw new DoctorTimedOut()
134 }
135
136 throw error
137 }
138
139 // A host may report a run it killed at the limit as a silent failure (a signal reads as exit 1).
140 const isSilentFailure = result.exitCode !== 0 && !result.text
141
142 if (isSilentFailure && (await hasTimedOut())) {
143 throw new DoctorTimedOut()
144 }
145
146 return result
147}
148
149/** Runs one command; every `doctor` run is told the session's project root. */
150async function runTokens($: EngineInterface, env: Record<string, string>, tokens: readonly string[]): Promise<CliResult> {
151 if (tokens[0] !== 'doctor') {
152 return runCli($, env, tokens)
153 }
154
155 const extraEnv = { CLAUDE_PROJECT_DIR: await $.session.root() }
156
157 if (sameTokens(tokens, DOCTOR_SETUP)) {
158 return runDoctorSetup($, env, extraEnv)
159 }
160
161 return runCli($, env, tokens, { extraEnv })
162}
163
164/** Saved enabled/disabled values by key; empty when the CLI cannot report them. */
165async function readSaved($: EngineInterface, env: Record<string, string>): Promise<Record<string, string>> {
166 const saved: Record<string, string> = {}
167
168 try {
169 const { exitCode, text } = await runCli($, env, ['status', '--json'])
170
171 if (exitCode !== 0) {
172 return saved
173 }
174
175 const status = JSON.parse(text)
176 const entries: Record<string, unknown> = { workflow: status?.workflow, ...status?.stages }
177
178 for (const [key, entry] of Object.entries(entries)) {
179 const value = (entry as { saved?: unknown } | undefined)?.saved
180
181 if (value === 'enabled' || value === 'disabled' || value === 'unknown') {
182 saved[key] = value
183 }
184 }
185 } catch {
186 return {}
187 }
188
189 return saved
190}
191
192function modeMenu(title: string, header: string, saved: string, subject: readonly string[]): Menu {
193 return {
194 question: `Set ${title} (saved: ${saved})?`,
195 header,
196 choices: [
197 { label: 'Enabled', command: [...subject, 'enabled'] },
198 { label: 'Disabled', command: [...subject, 'disabled'] },
199 { label: 'Back', back: true },
200 ],
201 }
202}
203
204function stagesMenu(header: string, stages: readonly Stage[], saved: Record<string, string>): Menu {
205 const choices: Choice[] = stages.map(stage => {
206 const current = saved[stage.key] ?? 'unknown'
207
208 return {
209 label: `${stage.label} (${current})`,
210 menu: modeMenu(`${stage.label} stage`, 'Stage', current, ['stage', stage.key]),
211 }
212 })
213
214 return { question: 'Which stage?', header, choices: [...choices, { label: 'Back', back: true }] }
215}
216
217function buildMenu(saved: Record<string, string>): Menu {
218 const workflow = saved.workflow ?? 'unknown'
219
220 const settings: Menu = {
221 question: 'Which setting?',
222 header: 'Settings',
223 choices: [
224 {
225 label: `Repository workflow (${workflow})`,
226 menu: modeMenu('repository workflow', 'Workflow', workflow, ['workflow']),
227 },
228 { label: 'Stages 1/2', menu: stagesMenu('Stages 1/2', STAGES_FIRST, saved) },
229 { label: 'Stages 2/2', menu: stagesMenu('Stages 2/2', STAGES_SECOND, saved) },
230 { label: 'Back', back: true },
231 ],
232 }
233
234 // Setup installs and writes; checking only reports. The person picks which, never by default.
235 const doctor: Menu = {
236 question: 'Doctor: set up memory (may install and write files) or only check it?',
237 header: 'Doctor',
238 choices: [
239 { label: 'Set up memory', command: DOCTOR_SETUP },
240 { label: 'Check only', command: DOCTOR_CHECK },
241 { label: 'Back', back: true },
242 ],
243 }
244
245 const browse: Menu = {
246 question: 'What would you like to browse?',
247 header: 'Browse',
248 choices: [
249 { label: 'List', command: ['list'] },
250 { label: 'Doctor…', menu: doctor },
251 { label: 'Back', back: true },
252 ],
253 }
254
255 return {
256 question: 'Useful Skills: what would you like to do?',
257 header: 'Menu',
258 choices: [
259 { label: 'Settings', menu: settings },
260 { label: 'Status', command: ['status'] },
261 { label: 'Browse', menu: browse },
262 { label: 'Help', command: ['help'] },
263 ],
264 }
265}
266
267/** Walks the menu tree; only an exact option label ever reaches the CLI. */
268async function chooseAndRun($: EngineInterface, run: Run, saved: Record<string, string>): Promise<string> {
269 const path: Menu[] = [buildMenu(saved)]
270 let hasAsked = false
271
272 for (;;) {
273 const menu = path[path.length - 1]!
274 let answer: string
275
276 try {
277 answer = await $.ui.ask(menu.question, { options: menu.choices.map(choice => choice.label), header: menu.header })
278 } catch {
279 // Dismissed, or no UI to ask in (`-p`): help before any prompt, otherwise cancel.
280 return hasAsked ? CANCELLED : run(['help'])
281 }
282
283 hasAsked = true
284
285 const choice = menu.choices.find(entry => entry.label === answer)
286
287 if (!choice) {
288 return NO_CHANGE
289 }
290
291 if (choice.back) {
292 path.pop()
293 } else if (choice.menu) {
294 path.push(choice.menu)
295 } else if (choice.command) {
296 return run(choice.command)
297 }
298 }
299}
300
301export const register: Register = (on, options) => {
302 const env = optionEnvironment(options)
303
304 on('session.start', async ($, e, next) => {
305 const started = await next(e)
306
307 await $.command.register({
308 name: 'useful-skills',
309 description: 'Workflow settings, status, skills, and health for Useful Skills',
310 argumentHint: '[status|workflow|stage|list|doctor|help]',
311 })
312
313 return started
314 })
315
316 on('command.run', { command: 'useful-skills' }, async ($, e) => {
317 const run: Run = async tokens => {
318 const { exitCode, text } = await runTokens($, env, tokens)
319
320 if (text) {
321 return text
322 }
323
324 // Silence is never success: a CLI that did nothing must not be reported as done.
325 return exitCode === 0 ? NO_OUTPUT : `Useful Skills command failed (exit ${exitCode}).`
326 }
327
328 try {
329 const tokens = e.args.trim().split(/\s+/).filter(Boolean)
330
331 if (tokens.length > MAX_ARGUMENTS) {
332 return { text: TOO_MANY_ARGUMENTS }
333 }
334
335 // Fail closed: a missing or unrecognized origin is read-only, with no menu.
336 const kind: unknown = e.origin?.kind
337
338 if (typeof kind !== 'string' || !WRITE_ORIGINS.has(kind)) {
339 if (tokens.length === 0) {
340 return { text: await run(['help']) }
341 }
342
343 // Memory setup writes, so from here `doctor` only ever checks.
344 if (tokens[0] === 'doctor') {
345 const isCheckable = sameTokens(tokens, DOCTOR_SETUP) || sameTokens(tokens, DOCTOR_CHECK)
346
347 return { text: isCheckable ? await run(DOCTOR_CHECK) : USAGE }
348 }
349
350 if (!READ_ONLY.has(tokens[0]!)) {
351 return { text: WRITE_REFUSED }
352 }
353 }
354
355 if (tokens.length > 0) {
356 return { text: await run(tokens) }
357 }
358
359 const saved = await readSaved($, env)
360
361 return { text: await chooseAndRun($, run, saved) }
362 } catch (error) {
363 if (error instanceof DoctorTimedOut) {
364 return { text: DOCTOR_TIMED_OUT }
365 }
366
367 if (error instanceof CliUnavailable) {
368 return { text: NODE_REQUIRED }
369 }
370
371 return { text: FAILED }
372 }
373 })
374}
375