Per-language quality checks over one declarative registry, gated by comparing a reference tree against the tree being shipped.

Per-language quality checks for one developer and their agent: did this change make the tree's findings worse than the reference commit.
An agent cannot see the quality of what it wrote until something outside it measures it, and by then the session is usually over. qsm-langdev exists so the measurement happens inside the session, at the moment a file lands and again before the agent hands the work back, for the language of the file and for the shape of the whole tree. It never scores a tree alone; it compares the tree being shipped against the tree it replaces, so the only question it ever answers is whether this change made things worse. It refuses to be a linter run for its own sake, a stored score, or a gate that blocks without saying where. It is the floor under every other plugin's promise of quality; without it, "the agent checked its work" is a sentence, not a fact.
| Kind | Name | What it does |
|---|---|---|
| CLI | qsm-langdev | 10 verbs, see USAGE.md |
| Hook | tool.call on Bash | Denies git commit --no-verify and git push --no-verify |
| Hook | tool.check on Bash | Asks you before a command that sets QSM_LANGDEV_ALLOW runs |
| Hook | tool.call on Write, Edit, NotebookEdit | Fixes the file, then tells the model its findings against HEAD. Never blocks |
| Hook | SubagentStop | Blocks a stop when the agent's edited tree got worse, a capped number of times |
| MCP server | sentrux | Architecture queries over the tree: scan, health, dsm, check_rules, test_gaps |
claude plugin install qsm-langdev@qsm-marketplace
bun plugins/qsm-langdev/scripts/install.ts
qsm-langdev init --profile general
The install script compiles qsm-langdev and the stop gate hook, and builds a patched sentrux with cargo (minutes) when it is absent. Details in INSTALL.md. init writes the profile's per-tool configs and records the repository's languages in .qsm-langdev/gates.json.
qsm-langdev doctor # which checker binaries are missing
qsm-langdev check # working tree against HEAD
qsm-langdev check --against main --at pre-push # a branch against main, as the push gate does
qsm-langdev install # write the pre-commit and pre-push git hooks
The edit hook, the stop gate, and the installed git hooks do nothing in a repository that never ran qsm-langdev init. check there says so and exits.
.gitattributes marks linguist-generated or export-ignore is not scored by the quality checkers or structure; the security scanners and the secrets, entropy and denylist gates still read it.check; qsm-langdev doctor names what to install.QSM_LANGDEV_ALLOW="<why>" git commit. --no-verify is denied in a session.semgrep login and semgrep install-semgrep-pro; without it the community engine runs.qsm-langdev --help.hooks/register.tsx 84 lines1// qsm-langdev's function-hooks module: the Bash guard, and the edit hook that tells the model what
2// an edit did to the file's findings. The guard's rule is lib/blocked-git.ts, the findings are the
3// `edited` verb's. The stop gate (hooks.json, dist/gate-hook) stays a classic command hook: its
4// event is SubagentStop, which has no function form, and a function hook cannot block a stop.
5import type { Caught, EngineInterface, On } from "claude-code";
6import { gitDecision } from "../lib/blocked-git.ts";
7import { registerNoun } from "./noun.generated.ts";
8
9const EDIT_TOOLS: ReadonlySet<string> = new Set(["Write", "Edit", "NotebookEdit"]);
10
11/** What every `.catch` here does first: a toast and a log for the person. */
12function failed($: EngineInterface, event: string, error: Caught["error"]): string {
13 const said = error.message ?? error.kind;
14 $.ui.toast(`qsm-langdev ERROR: ${event}: ${said.slice(0, 120)}`);
15 $.ui.log(`${event} failed (${error.kind})`);
16 return said;
17}
18
19/** The `text` of the edited verb's data; null when it had nothing to say. */
20function findingsText(data: unknown): string | null {
21 const text = stringField(data, "text");
22 return text !== null && text.length > 0 ? text : null;
23}
24
25/** The string under `key` of an object of unknown shape; null when it is not there or not a string. */
26function stringField(value: unknown, key: string): string | null {
27 if (typeof value !== "object" || value === null) {
28 return null;
29 }
30 const field: unknown = Reflect.get(value, key);
31 return typeof field === "string" ? field : null;
32}
33
34/** A commit or push that skips the git hooks is refused; `QSM_LANGDEV_ALLOW` goes to the person.
35 * The guard fails closed. */
36function registerGuard(on: On): void {
37 on("tool.call", { tool: "Bash" }, (_$, e, next) => {
38 const answer = e.tool === "Bash" ? gitDecision(e.command) : null;
39 return answer?.decision === "deny" ? { deny: answer.reason } : next(e);
40 }).catch(($, _e, next) => ({
41 deny: `qsm-langdev guard failed: ${failed($, "tool.call", next.error)}`,
42 }));
43
44 on("tool.check", { tool: "Bash" }, (_$, e, next) => {
45 const command = stringField(e.input, "command");
46 const answer = command === null ? null : gitDecision(command);
47 return answer?.decision === "ask" ? { decision: "ask", reason: answer.reason } : next(e);
48 }).catch(($, _e, next) => ({
49 decision: "deny",
50 reason: `qsm-langdev guard failed: ${failed($, "tool.check", next.error)}`,
51 }));
52}
53
54/** After a Write, Edit or NotebookEdit ran, the file's findings against HEAD go to the model as the
55 * result's context. Advisory: a failure leaves the result as the tool made it. */
56function registerEditFindings(on: On): void {
57 on("tool.call", async ($, e, next) => {
58 const result = await next(e);
59 if (result.deny !== undefined || result.isError === true || !EDIT_TOOLS.has(e.tool)) {
60 return result;
61 }
62 const file = stringField(e, "file_path") ?? stringField(e, "notebook_path");
63 if (file === null) {
64 return result;
65 }
66 const text = findingsText(
67 await $.langdev.edited({ file, session: await $.session.id(), agent: e.agentId }),
68 );
69 return text === null ? result : { ...result, context: [...(result.context ?? []), text] };
70 }).catch(($, e, next) => {
71 const said = failed($, "tool.call", next.error);
72 $.ui.log(`the result goes on without findings (${said})`);
73 return next(e);
74 });
75}
76
77export function register(on: On): void {
78 // Adds $.langdev: one method per qsm-langdev verb, generated by
79 // lib/generate-noun.ts. Never hand-edit noun.generated.ts or types/index.d.ts.
80 registerNoun(on);
81 registerGuard(on);
82 registerEditFindings(on);
83}
84lib/blocked-git.ts 28 lines1// What the Bash guard answers for a command line, as one pure function.
2
3export interface GitDecision {
4 readonly decision: "deny" | "ask";
5 readonly reason: string;
6}
7
8/** Within one segment: `git ... commit ... --no-verify|-n` (alone or in a
9 * cluster of short flags), or `git ... push ... --no-verify`. */
10const SKIPS_HOOKS =
11 /\bgit\b[^;&|\n]*?\s(commit\b[^;&|\n]*\s(--no-verify|-[a-zA-Z]*n[a-zA-Z]*)|push\b[^;&|\n]*\s--no-verify)(\s|$)/;
12const ALLOW = /\bQSM_LANGDEV_ALLOW=/;
13
14export function gitDecision(command: string): GitDecision | null {
15 if (SKIPS_HOOKS.test(command)) {
16 const reason =
17 '[qsm-langdev] a commit or push without its hooks skips every gate. To let a measured regression pass once, prefix that one command with QSM_LANGDEV_ALLOW="<why>"; it asks the person first. The leak gates are never allowed.';
18 return { decision: "deny", reason };
19 }
20 return ALLOW.test(command)
21 ? {
22 decision: "ask",
23 reason:
24 "[qsm-langdev] QSM_LANGDEV_ALLOW lets a measured regression pass on this one command; a person decides.",
25 }
26 : null;
27}
28hooks/noun.generated.ts 134 lines1// Generated by lib/generate-noun.ts from qsm.json. Do not hand-edit; run
2// `bun lib/generate-noun.ts plugins/<name>` again after changing qsm.json.
3import type { Caught, EngineInterface, On } from "claude-code";
4import type { Langdev } from "../types/index.d.ts";
5
6/** How one option becomes argv: a positional value (`flag` null), a bare
7 * boolean flag, or a flag and its value; a list repeats it per element. */
8export interface VerbArg {
9 readonly name: string;
10 readonly flag: string | null;
11 readonly boolean?: true;
12}
13
14export interface VerbRow {
15 readonly words: readonly string[];
16 readonly args: readonly VerbArg[];
17 readonly passthrough?: true;
18}
19
20export const verbs: Readonly<Record<string, VerbRow>> = {
21 check: { words: ["qsm-langdev","check"], args: [{ name: "dir", flag: null }, { name: "path", flag: null }, { name: "against", flag: "--against" }, { name: "at", flag: "--at" }, { name: "lang", flag: "--lang" }, { name: "allFindings", flag: "--all-findings", boolean: true }] },
22 list: { words: ["qsm-langdev","list"], args: [] },
23 doctor: { words: ["qsm-langdev","doctor"], args: [{ name: "dir", flag: null }] },
24 languages: { words: ["qsm-langdev","languages"], args: [{ name: "action", flag: null }, { name: "name", flag: null }] },
25 init: { words: ["qsm-langdev","init"], args: [{ name: "dir", flag: null }, { name: "profile", flag: "--profile" }, { name: "target", flag: "--target" }] },
26 structure: { words: ["qsm-langdev","structure"], args: [{ name: "path", flag: null }, { name: "against", flag: "--against" }, { name: "session", flag: "--session" }, { name: "agent", flag: "--agent" }] },
27 install: { words: ["qsm-langdev","install"], args: [{ name: "dir", flag: null }, { name: "lang", flag: "--lang" }] },
28 uninstall: { words: ["qsm-langdev","uninstall"], args: [{ name: "dir", flag: null }] },
29 edited: { words: ["qsm-langdev","edited"], args: [{ name: "file", flag: null }, { name: "session", flag: "--session" }, { name: "agent", flag: "--agent" }] },
30 hook: { words: ["qsm-langdev","hook"], args: [{ name: "name", flag: null }] },
31};
32
33/** One verb's argv: its words, each option in declaration order, the
34 * passthrough rest after `--`, and `--json` last. */
35export function argvOf(row: VerbRow, options: Readonly<Record<string, unknown>> = {}): string[] {
36 const argv = [...row.words];
37 for (const arg of row.args) {
38 const value = options[arg.name];
39 const values = Array.isArray(value) ? value : [value];
40 for (const one of values) {
41 if (one === undefined || one === false) {
42 continue;
43 }
44 argv.push(...(arg.flag === null ? [] : [arg.flag]), ...(arg.boolean ? [] : [String(one)]));
45 }
46 }
47 const rest = options.rest;
48 if (row.passthrough && Array.isArray(rest)) {
49 argv.push("--", ...rest.map(String));
50 }
51 argv.push("--json");
52 return argv;
53}
54
55/** The binary prints one outcome envelope on stdout under --json:
56 * { code, exit, reason, message, warnings, data }. Data on ok; otherwise the
57 * code and message become the error. A stale or misbehaving binary that
58 * prints something else (not JSON, or JSON with no numeric `exit`) throws
59 * loud here instead of a raw JSON.parse SyntaxError or a silent `undefined`. */
60export function unwrap(
61 argv: readonly string[],
62 result: { readonly exitCode: number; readonly stdout: string; readonly stderr: string },
63 label = argv.slice(0, 2).join(" "),
64): unknown {
65 const text = result.stdout.trim();
66 const stderr = result.stderr.trim().slice(0, 200);
67 const cause = `(exit ${result.exitCode}): ${stderr.length > 0 ? stderr : "no stderr"}`;
68 if (text.length === 0) {
69 throw new Error(`${argv.join(" ")}: printed no envelope ${cause}`);
70 }
71 let parsed: unknown;
72 try {
73 parsed = JSON.parse(text);
74 } catch {
75 parsed = undefined;
76 }
77 if (typeof parsed !== "object" || parsed === null || typeof (parsed as { exit?: unknown }).exit !== "number") {
78 throw new Error(`${argv.join(" ")}: stdout is not an outcome envelope ${cause}: ${text.slice(0, 200)}`);
79 }
80 const envelope = parsed as { code: string; exit: number; message: string; data: unknown };
81 if (envelope.exit !== 0) {
82 throw new Error(`${label}: ${envelope.code}: ${envelope.message}`);
83 }
84 return envelope.data;
85}
86
87/** What every `.catch` does first: a toast and a log for the person. The toast says `fixedReason`
88 * when the cause may carry something the toast must not show, else the cause; the cause goes to
89 * the log and comes back for the caller's own policy. */
90export function failure(
91 $: EngineInterface,
92 event: string,
93 error: Caught["error"],
94 fixedReason?: string,
95): string {
96 const said = error.message ?? error.kind;
97 $.ui.toast(`qsm-langdev ERROR: ${event}: ${(fixedReason ?? said).slice(0, 120)}`);
98 $.ui.log(`${event} failed (${error.kind}): ${said}`);
99 return said;
100}
101
102/** Adds $.langdev to the engine. Call once from the plugin's own
103 * `register(on, options)` — never call the engine.create hook's own
104 * `next(e)` result from anywhere but here. */
105export function registerNoun(on: On): void {
106 on("engine.create", async (_$, e, next) => {
107 const beneath = await next(e);
108 const run = async (row: VerbRow, options?: Readonly<Record<string, unknown>>): Promise<unknown> => {
109 const argv = argvOf(row, options);
110 let result: Awaited<ReturnType<typeof beneath.process.run>>;
111 try {
112 result = await beneath.process.run(argv);
113 } catch (error) {
114 // A spawn in a deleted directory fails as ENOENT naming the binary; name the directory. A
115 // check that cannot run leaves the spawn's own error as the cause.
116 const gone = await beneath.session
117 .cwd()
118 .then(async (cwd) => ((await beneath.fs.exists(cwd)) ? null : cwd))
119 .catch(() => null);
120 if (gone !== null) {
121 throw new Error(`${row.words.join(" ")}: the session's working directory ${gone} no longer exists; cd to one that does`);
122 }
123 throw error;
124 }
125 return unwrap(argv, result, row.words.join(" "));
126 };
127 const methods = Object.entries(verbs).map(([method, row]) => [
128 method,
129 (options?: Readonly<Record<string, unknown>>) => run(row, options),
130 ]);
131 return { ...beneath, langdev: Object.fromEntries(methods) as Langdev };
132 });
133}
134types/index.d.ts 54 lines1// Generated by lib/generate-noun.ts from qsm.json. Do not hand-edit; run
2// `bun lib/generate-noun.ts plugins/<name>` again after changing qsm.json.
3
4export type Langdev = {
5 check(options: {
6 dir?: string;
7 path?: (string)[];
8 against?: string;
9 at?: "pre-commit" | "pre-push";
10 lang?: string;
11 allFindings?: boolean;
12 }): Promise<unknown>;
13 list(): Promise<unknown>;
14 doctor(options: {
15 dir?: string;
16 }): Promise<unknown>;
17 languages(options: {
18 action?: "list" | "add" | "remove";
19 name?: string;
20 }): Promise<unknown>;
21 init(options: {
22 dir?: string;
23 profile?: string;
24 target?: (string)[];
25 }): Promise<unknown>;
26 structure(options: {
27 path?: string;
28 against?: string;
29 session?: string;
30 agent?: string;
31 }): Promise<unknown>;
32 install(options: {
33 dir?: string;
34 lang?: "sh" | "py" | "ts";
35 }): Promise<unknown>;
36 uninstall(options: {
37 dir?: string;
38 }): Promise<unknown>;
39 edited(options: {
40 file: string;
41 session: string;
42 agent?: string;
43 }): Promise<unknown>;
44 hook(options: {
45 name: "pre-commit" | "pre-push";
46 }): Promise<unknown>;
47};
48
49declare module 'claude-code' {
50 interface EngineInterface {
51 langdev: Langdev;
52 }
53}
54