Review threads on a pull or merge request as one atomic call each: one compiled binary, qsm-forge, with a gh group for GitHub and a gl group for GitLab, that…

Read, answer, resolve and submit review threads on a GitHub pull request or a GitLab merge request, one call per step, for the person and the agent who review code together.
The harness default for review threads is raw gh api, glab api and curl calls, several per step: a GraphQL listing, a lookup of the thread behind a comment, then the mutation. qsm-forge replaces them with one atomic verb per step, the same shape on GitHub and GitLab, run under the human's own account, and a hook refuses the raw route so the agent cannot drift back to it. It takes the stance that a forge is a place to act, never to browse, and it refuses to be the review methodology or to post anything the human did not ask for. When both forges expose this as one safe primitive, this plugin can go.
| Kind | Name | What it does |
|---|---|---|
| Skill | review-threads | Reads, answers, resolves, dismisses and submits review threads through the CLI |
| Hook | tool.call on Bash | Refuses raw gh api, glab api and curl calls that touch review state |
| CLI | qsm-forge | 14 verbs (gh, gl, doctor), see USAGE.md |
claude plugin install qsm-forge@qsm-marketplace
bun plugins/qsm-forge/scripts/install.ts
gh auth login # and/or glab auth login
The install script compiles qsm-forge and links it into ~/.local/bin. It never installs gh or glab. More in INSTALL.md.
Ask: "what is unresolved on PR 12?" or "reply to that comment and resolve the thread". The skill fires and runs the verbs for you. You can run them yourself:
qsm-forge gh threads 12
qsm-forge gh reply 12 98765 --message "Fixed in abc1234"
qsm-forge gl resolve 7 42
qsm-forge gh submit-review 12 --file review.json
The repository is the checkout's origin remote. --repo owner/name (gh) or --project namespace/project (gl) names another. All verbs: qsm-forge --help.
gh or glab login. The binary never reads a token.gl resolve there.comments, resolve-all and dismiss-reviews exist for GitHub only.submit-review is several requests. A failure part-way leaves what was already posted. The payload is checked before the first request.qsm-forge doctor checks that gh or glab is on PATH, not that you are logged in.hooks/register.tsx 25 lines1// qsm-forge's function-hooks module: the noun, `$.forge`, from gh and gl verbs, generated from
2// qsm.json, so another module reaches a pull or merge request's threads without shelling out; and
3// the guard that refuses a raw platform call reading or changing review state. The decision is
4// reviewGuard, a pure function of the command line in lib/.
5
6import type { On } from "claude-code";
7import { reviewGuard } from "../lib/forge-guard.ts";
8import { failure, registerNoun } from "./noun.generated.ts";
9
10export function register(on: On): void {
11 registerNoun(on);
12
13 on("tool.call", { tool: "Bash" }, ($, e, next) => {
14 const refusal = reviewGuard(e.command);
15 if (refusal === null) {
16 return next(e);
17 }
18 $.ui.log(`refused a raw review call (${e.command.slice(0, 120)})`);
19 return { deny: refusal };
20 }).catch(($, _e, next) => {
21 const reason = failure($, "tool.call", next.error);
22 return { deny: `qsm-forge: the review guard failed (${reason}); the call was not run` };
23 });
24}
25lib/forge-guard.ts 112 lines1// The hook's whole decision, as a function of the command line alone: no engine, no Node, no
2// filesystem.
3
4interface Rule {
5 readonly pattern: RegExp;
6 /** The wrapper call that covers the blocked one, named in the refusal. */
7 readonly instead: string;
8}
9
10const GITHUB_RULES: readonly Rule[] = [
11 {
12 pattern: /\/pulls\/\d+\/comments\/\d+\/replies/,
13 instead: 'qsm-forge gh reply <pr> <comment-id> --message "<text>"',
14 },
15 {
16 pattern: /\/pulls\/\d+\/reviews\/\d+\/dismissals/,
17 instead: "qsm-forge gh dismiss-reviews <pr>",
18 },
19 {
20 pattern: /\/pulls\/\d+\/reviews\b/,
21 instead:
22 "qsm-forge gh status <pr> to read reviews, qsm-forge gh submit-review <pr> to post one",
23 },
24 {
25 pattern: /\/pulls\/\d+\/comments\b/,
26 instead: "qsm-forge gh comments <pr>",
27 },
28 {
29 pattern:
30 /\b(?:reviewThreads|resolveReviewThread|unresolveReviewThread|addPullRequestReviewThreadReply|pullRequestReviewThread)\b/,
31 instead: "qsm-forge gh threads <pr>, qsm-forge gh resolve <pr> <comment-id>",
32 },
33 {
34 pattern: /\bgh\s+pr\s+comment\b/,
35 instead: 'qsm-forge gh reply <pr> <comment-id> --message "<text>"',
36 },
37 {
38 pattern: /\bgh\s+pr\s+review\b/,
39 instead: "qsm-forge gh submit-review <pr>",
40 },
41 {
42 pattern: /\bgh\s+pr\s+view\b[^\n]*--json[^\n]*\b(?:reviews|reviewDecision|comments)\b/,
43 instead: "qsm-forge gh status <pr>, or qsm-forge gh comments <pr>",
44 },
45];
46
47const GITLAB_RULES: readonly Rule[] = [
48 {
49 pattern: /\/merge_requests\/\d+\/discussions\/[^/\s"']+\/notes/,
50 instead: 'qsm-forge gl reply <mr> <note-id> --message "<text>"',
51 },
52 {
53 pattern: /\/merge_requests\/\d+\/discussions\/[^/\s"']+/,
54 instead: "qsm-forge gl resolve <mr> <note-id>",
55 },
56 {
57 pattern: /\/merge_requests\/\d+\/discussions\b/,
58 instead: "qsm-forge gl threads <mr>",
59 },
60 {
61 pattern: /\/merge_requests\/\d+\/notes\b/,
62 instead: "qsm-forge gl submit-review <mr>",
63 },
64 {
65 pattern: /\/merge_requests\/\d+\/(?:approve|unapprove)\b/,
66 instead: "qsm-forge gl submit-review <mr>",
67 },
68 {
69 pattern: /\bglab\s+mr\s+(?:note|comment)\b/,
70 instead: 'qsm-forge gl reply <mr> <note-id> --message "<text>"',
71 },
72 {
73 pattern: /\bglab\s+mr\s+(?:approve|unapprove)\b/,
74 instead: "qsm-forge gl submit-review <mr>",
75 },
76];
77
78/** The tools a raw review call reaches a forge through: the two platform
79 * CLIs, and the fetchers that can speak to their APIs without one. */
80const INVOKERS = new Set(["gh", "glab", "curl", "wget", "http", "xh"]);
81
82/** The forge tool named anywhere in a command line, or null when none is. */
83export function forgeInvoker(line: string): string | null {
84 for (const raw of line.split(/\s+/)) {
85 const token = raw.replace(/^['"`(]+/, "").replace(/['"`);]+$/, "");
86 if (token.length === 0 || token.startsWith("-") || /^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) {
87 continue;
88 }
89 const name = token.split("/").at(-1) ?? token;
90 if (INVOKERS.has(name)) {
91 return name;
92 }
93 }
94 return null;
95}
96
97function ruleFor(line: string): Rule | undefined {
98 return [...GITHUB_RULES, ...GITLAB_RULES].find((rule) => rule.pattern.test(line));
99}
100
101/** The refusal text, or null when the command may run. */
102export function reviewGuard(line: string): string | null {
103 if (forgeInvoker(line) === null) {
104 return null;
105 }
106 const rule = ruleFor(line);
107 if (rule === undefined) {
108 return null;
109 }
110 return `qsm-forge: this reads or changes review state on a pull or merge request. Use \`${rule.instead}\` instead of a raw call.`;
111}
112hooks/noun.generated.ts 138 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 { Forge } 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 ghStatus: { words: ["qsm-forge","gh","status"], args: [{ name: "pr", flag: null }, { name: "repo", flag: "--repo" }] },
22 ghThreads: { words: ["qsm-forge","gh","threads"], args: [{ name: "pr", flag: null }, { name: "repo", flag: "--repo" }] },
23 ghComments: { words: ["qsm-forge","gh","comments"], args: [{ name: "pr", flag: null }, { name: "unresolved", flag: "--unresolved", boolean: true }, { name: "repo", flag: "--repo" }] },
24 ghReply: { words: ["qsm-forge","gh","reply"], args: [{ name: "pr", flag: null }, { name: "commentId", flag: null }, { name: "message", flag: "--message" }, { name: "noResolve", flag: "--no-resolve", boolean: true }, { name: "repo", flag: "--repo" }] },
25 ghResolve: { words: ["qsm-forge","gh","resolve"], args: [{ name: "pr", flag: null }, { name: "commentId", flag: null }, { name: "message", flag: "--message" }, { name: "repo", flag: "--repo" }] },
26 ghResolveAll: { words: ["qsm-forge","gh","resolve-all"], args: [{ name: "pr", flag: null }, { name: "message", flag: "--message" }, { name: "repo", flag: "--repo" }] },
27 ghDismissReviews: { words: ["qsm-forge","gh","dismiss-reviews"], args: [{ name: "pr", flag: null }, { name: "author", flag: "--author" }, { name: "message", flag: "--message" }, { name: "repo", flag: "--repo" }] },
28 ghSubmitReview: { words: ["qsm-forge","gh","submit-review"], args: [{ name: "pr", flag: null }, { name: "file", flag: "--file" }, { name: "repo", flag: "--repo" }] },
29 glStatus: { words: ["qsm-forge","gl","status"], args: [{ name: "mr", flag: null }, { name: "project", flag: "--project" }] },
30 glThreads: { words: ["qsm-forge","gl","threads"], args: [{ name: "mr", flag: null }, { name: "project", flag: "--project" }] },
31 glReply: { words: ["qsm-forge","gl","reply"], args: [{ name: "mr", flag: null }, { name: "noteId", flag: null }, { name: "message", flag: "--message" }, { name: "project", flag: "--project" }] },
32 glResolve: { words: ["qsm-forge","gl","resolve"], args: [{ name: "mr", flag: null }, { name: "noteId", flag: null }, { name: "message", flag: "--message" }, { name: "project", flag: "--project" }] },
33 glSubmitReview: { words: ["qsm-forge","gl","submit-review"], args: [{ name: "mr", flag: null }, { name: "file", flag: "--file" }, { name: "project", flag: "--project" }] },
34 doctor: { words: ["qsm-forge","doctor"], args: [] },
35};
36
37/** One verb's argv: its words, each option in declaration order, the
38 * passthrough rest after `--`, and `--json` last. */
39export function argvOf(row: VerbRow, options: Readonly<Record<string, unknown>> = {}): string[] {
40 const argv = [...row.words];
41 for (const arg of row.args) {
42 const value = options[arg.name];
43 const values = Array.isArray(value) ? value : [value];
44 for (const one of values) {
45 if (one === undefined || one === false) {
46 continue;
47 }
48 argv.push(...(arg.flag === null ? [] : [arg.flag]), ...(arg.boolean ? [] : [String(one)]));
49 }
50 }
51 const rest = options.rest;
52 if (row.passthrough && Array.isArray(rest)) {
53 argv.push("--", ...rest.map(String));
54 }
55 argv.push("--json");
56 return argv;
57}
58
59/** The binary prints one outcome envelope on stdout under --json:
60 * { code, exit, reason, message, warnings, data }. Data on ok; otherwise the
61 * code and message become the error. A stale or misbehaving binary that
62 * prints something else (not JSON, or JSON with no numeric `exit`) throws
63 * loud here instead of a raw JSON.parse SyntaxError or a silent `undefined`. */
64export function unwrap(
65 argv: readonly string[],
66 result: { readonly exitCode: number; readonly stdout: string; readonly stderr: string },
67 label = argv.slice(0, 2).join(" "),
68): unknown {
69 const text = result.stdout.trim();
70 const stderr = result.stderr.trim().slice(0, 200);
71 const cause = `(exit ${result.exitCode}): ${stderr.length > 0 ? stderr : "no stderr"}`;
72 if (text.length === 0) {
73 throw new Error(`${argv.join(" ")}: printed no envelope ${cause}`);
74 }
75 let parsed: unknown;
76 try {
77 parsed = JSON.parse(text);
78 } catch {
79 parsed = undefined;
80 }
81 if (typeof parsed !== "object" || parsed === null || typeof (parsed as { exit?: unknown }).exit !== "number") {
82 throw new Error(`${argv.join(" ")}: stdout is not an outcome envelope ${cause}: ${text.slice(0, 200)}`);
83 }
84 const envelope = parsed as { code: string; exit: number; message: string; data: unknown };
85 if (envelope.exit !== 0) {
86 throw new Error(`${label}: ${envelope.code}: ${envelope.message}`);
87 }
88 return envelope.data;
89}
90
91/** What every `.catch` does first: a toast and a log for the person. The toast says `fixedReason`
92 * when the cause may carry something the toast must not show, else the cause; the cause goes to
93 * the log and comes back for the caller's own policy. */
94export function failure(
95 $: EngineInterface,
96 event: string,
97 error: Caught["error"],
98 fixedReason?: string,
99): string {
100 const said = error.message ?? error.kind;
101 $.ui.toast(`qsm-forge ERROR: ${event}: ${(fixedReason ?? said).slice(0, 120)}`);
102 $.ui.log(`${event} failed (${error.kind}): ${said}`);
103 return said;
104}
105
106/** Adds $.forge to the engine. Call once from the plugin's own
107 * `register(on, options)` — never call the engine.create hook's own
108 * `next(e)` result from anywhere but here. */
109export function registerNoun(on: On): void {
110 on("engine.create", async (_$, e, next) => {
111 const beneath = await next(e);
112 const run = async (row: VerbRow, options?: Readonly<Record<string, unknown>>): Promise<unknown> => {
113 const argv = argvOf(row, options);
114 let result: Awaited<ReturnType<typeof beneath.process.run>>;
115 try {
116 result = await beneath.process.run(argv);
117 } catch (error) {
118 // A spawn in a deleted directory fails as ENOENT naming the binary; name the directory. A
119 // check that cannot run leaves the spawn's own error as the cause.
120 const gone = await beneath.session
121 .cwd()
122 .then(async (cwd) => ((await beneath.fs.exists(cwd)) ? null : cwd))
123 .catch(() => null);
124 if (gone !== null) {
125 throw new Error(`${row.words.join(" ")}: the session's working directory ${gone} no longer exists; cd to one that does`);
126 }
127 throw error;
128 }
129 return unwrap(argv, result, row.words.join(" "));
130 };
131 const methods = Object.entries(verbs).map(([method, row]) => [
132 method,
133 (options?: Readonly<Record<string, unknown>>) => run(row, options),
134 ]);
135 return { ...beneath, forge: Object.fromEntries(methods) as Forge };
136 });
137}
138types/index.d.ts 80 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 Forge = {
5 ghStatus(options: {
6 pr: number;
7 repo?: string;
8 }): Promise<unknown>;
9 ghThreads(options: {
10 pr: number;
11 repo?: string;
12 }): Promise<unknown>;
13 ghComments(options: {
14 pr: number;
15 unresolved?: boolean;
16 repo?: string;
17 }): Promise<unknown>;
18 ghReply(options: {
19 pr: number;
20 commentId: number;
21 message: string;
22 noResolve?: boolean;
23 repo?: string;
24 }): Promise<unknown>;
25 ghResolve(options: {
26 pr: number;
27 commentId: number;
28 message?: string;
29 repo?: string;
30 }): Promise<unknown>;
31 ghResolveAll(options: {
32 pr: number;
33 message?: string;
34 repo?: string;
35 }): Promise<unknown>;
36 ghDismissReviews(options: {
37 pr: number;
38 author?: string;
39 message?: string;
40 repo?: string;
41 }): Promise<unknown>;
42 ghSubmitReview(options: {
43 pr: number;
44 file?: string;
45 repo?: string;
46 }): Promise<unknown>;
47 glStatus(options: {
48 mr: number;
49 project?: string;
50 }): Promise<unknown>;
51 glThreads(options: {
52 mr: number;
53 project?: string;
54 }): Promise<unknown>;
55 glReply(options: {
56 mr: number;
57 noteId: number;
58 message: string;
59 project?: string;
60 }): Promise<unknown>;
61 glResolve(options: {
62 mr: number;
63 noteId: number;
64 message?: string;
65 project?: string;
66 }): Promise<unknown>;
67 glSubmitReview(options: {
68 mr: number;
69 file?: string;
70 project?: string;
71 }): Promise<unknown>;
72 doctor(): Promise<unknown>;
73};
74
75declare module 'claude-code' {
76 interface EngineInterface {
77 forge: Forge;
78 }
79}
80