SLOPSHOPPER

qsm-openspec

Spec-first development on top of the OpenSpec CLI: three skills carry one change from an unresolved idea through an apply-ready proposal, implementation, and…

newprocess
A shopper browsing a rack in a slop shop
README

qsm-openspec

Spec-first development for a developer and an agent: skills that carry one change from proposal to archive on top of the OpenSpec CLI.

Why it exists

A change that starts in code ends in code nobody can review against an intention. qsm-openspec exists so a change starts as a spec: what the system does and what proves it, written as requirements and scenarios that the code, the tests and the review are read against. It takes the stance that a spec is a developer's professional note, written better than the agent would without it, and that it ends by closing the work it started. It refuses to be a tracker, a design essay, or a place where history accumulates. When code can be reviewed against intention without a spec, this plugin has no reason to exist.

What you get

KindNameWhat it does
Skillspec-proposeTurns a settled idea into an apply-ready change, or revises a plan that no longer matches reality
Skillspec-applyImplements an apply-ready change and works through its tasks
Skillspec-archiveMerges the change's delta specs into the durable specs and closes the change
Hook$.openspec nounGives other plug-in hooks the doctor verb as $.openspec.doctor()
CLIqsm-openspec1 verb, see USAGE.md

Install

claude plugin install qsm-openspec@qsm-marketplace
bun plugins/qsm-openspec/scripts/install.ts
openspec init --tools none   # once per project

Compiles qsm-openspec and installs the upstream OpenSpec CLI unless it is there. --tools none stops upstream from generating prompts that duplicate the skills. Details are in INSTALL.md.

Brings qsm-essentials with it: spec-apply hands disjoint tasks to its subagents skill. Brings qsm-tasks with it: it serves the task port, where each change's task is created, claimed and closed.

Use

Skills fire on the kind of ask.

  • ask: "write a spec for this feature" loads spec-propose.
  • ask: "implement the proposal" loads spec-apply.
  • ask: "we are done with this change, archive it" loads spec-archive.
qsm-openspec doctor
qsm-openspec doctor --root ~/Projects/my-repo

Caveats

  • Every repository needs its own OpenSpec root, or the CLI resolves to an ancestor's and writes there silently. The skills stop instead.
  • doctor exits 4 (missing) when the openspec binary is not on PATH. The report still prints.
Source 3 files
hooks/register.tsx 10 lines
1// qsm-openspec's function-hooks module.
2import type { On } from "claude-code";
3import { registerNoun } from "./noun.generated.ts";
4
5export function register(on: On): void {
6  // Adds $.openspec: one method per qsm-openspec verb, generated by lib/generate-noun.ts. Never
7  // hand-edit noun.generated.ts or types/index.d.ts.
8  registerNoun(on);
9}
10
hooks/noun.generated.ts 125 lines
1// 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 { Openspec } 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  doctor: { words: ["qsm-openspec","doctor"], args: [{ name: "root", flag: "--root" }] },
22};
23
24/** One verb's argv: its words, each option in declaration order, the
25 * passthrough rest after `--`, and `--json` last. */
26export function argvOf(row: VerbRow, options: Readonly<Record<string, unknown>> = {}): string[] {
27  const argv = [...row.words];
28  for (const arg of row.args) {
29    const value = options[arg.name];
30    const values = Array.isArray(value) ? value : [value];
31    for (const one of values) {
32      if (one === undefined || one === false) {
33        continue;
34      }
35      argv.push(...(arg.flag === null ? [] : [arg.flag]), ...(arg.boolean ? [] : [String(one)]));
36    }
37  }
38  const rest = options.rest;
39  if (row.passthrough && Array.isArray(rest)) {
40    argv.push("--", ...rest.map(String));
41  }
42  argv.push("--json");
43  return argv;
44}
45
46/** The binary prints one outcome envelope on stdout under --json:
47 * { code, exit, reason, message, warnings, data }. Data on ok; otherwise the
48 * code and message become the error. A stale or misbehaving binary that
49 * prints something else (not JSON, or JSON with no numeric `exit`) throws
50 * loud here instead of a raw JSON.parse SyntaxError or a silent `undefined`. */
51export function unwrap(
52  argv: readonly string[],
53  result: { readonly exitCode: number; readonly stdout: string; readonly stderr: string },
54  label = argv.slice(0, 2).join(" "),
55): unknown {
56  const text = result.stdout.trim();
57  const stderr = result.stderr.trim().slice(0, 200);
58  const cause = `(exit ${result.exitCode}): ${stderr.length > 0 ? stderr : "no stderr"}`;
59  if (text.length === 0) {
60    throw new Error(`${argv.join(" ")}: printed no envelope ${cause}`);
61  }
62  let parsed: unknown;
63  try {
64    parsed = JSON.parse(text);
65  } catch {
66    parsed = undefined;
67  }
68  if (typeof parsed !== "object" || parsed === null || typeof (parsed as { exit?: unknown }).exit !== "number") {
69    throw new Error(`${argv.join(" ")}: stdout is not an outcome envelope ${cause}: ${text.slice(0, 200)}`);
70  }
71  const envelope = parsed as { code: string; exit: number; message: string; data: unknown };
72  if (envelope.exit !== 0) {
73    throw new Error(`${label}: ${envelope.code}: ${envelope.message}`);
74  }
75  return envelope.data;
76}
77
78/** What every `.catch` does first: a toast and a log for the person. The toast says `fixedReason`
79 * when the cause may carry something the toast must not show, else the cause; the cause goes to
80 * the log and comes back for the caller's own policy. */
81export function failure(
82  $: EngineInterface,
83  event: string,
84  error: Caught["error"],
85  fixedReason?: string,
86): string {
87  const said = error.message ?? error.kind;
88  $.ui.toast(`qsm-openspec ERROR: ${event}: ${(fixedReason ?? said).slice(0, 120)}`);
89  $.ui.log(`${event} failed (${error.kind}): ${said}`);
90  return said;
91}
92
93/** Adds $.openspec to the engine. Call once from the plugin's own
94 * `register(on, options)` — never call the engine.create hook's own
95 * `next(e)` result from anywhere but here. */
96export function registerNoun(on: On): void {
97  on("engine.create", async (_$, e, next) => {
98    const beneath = await next(e);
99    const run = async (row: VerbRow, options?: Readonly<Record<string, unknown>>): Promise<unknown> => {
100      const argv = argvOf(row, options);
101      let result: Awaited<ReturnType<typeof beneath.process.run>>;
102      try {
103        result = await beneath.process.run(argv);
104      } catch (error) {
105        // A spawn in a deleted directory fails as ENOENT naming the binary; name the directory. A
106        // check that cannot run leaves the spawn's own error as the cause.
107        const gone = await beneath.session
108          .cwd()
109          .then(async (cwd) => ((await beneath.fs.exists(cwd)) ? null : cwd))
110          .catch(() => null);
111        if (gone !== null) {
112          throw new Error(`${row.words.join(" ")}: the session's working directory ${gone} no longer exists; cd to one that does`);
113        }
114        throw error;
115      }
116      return unwrap(argv, result, row.words.join(" "));
117    };
118    const methods = Object.entries(verbs).map(([method, row]) => [
119      method,
120      (options?: Readonly<Record<string, unknown>>) => run(row, options),
121    ]);
122    return { ...beneath, openspec: Object.fromEntries(methods) as Openspec };
123  });
124}
125
types/index.d.ts 15 lines
1// 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 Openspec = {
5  doctor(options: {
6    root?: string;
7  }): Promise<unknown>;
8};
9
10declare module 'claude-code' {
11  interface EngineInterface {
12    openspec: Openspec;
13  }
14}
15