@siddicky/oh-my-musecode 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/.claude-plugin/marketplace.json +18 -0
  2. package/.claude-plugin/plugin.json +30 -0
  3. package/.muse-plugin/plugin.json +94 -0
  4. package/LICENSE +32 -0
  5. package/README.md +190 -0
  6. package/dist/mcp/state-server.d.ts +13 -0
  7. package/dist/mcp/state-server.js +109 -0
  8. package/dist/mcp/state-server.js.map +1 -0
  9. package/dist/paths.d.ts +58 -0
  10. package/dist/paths.js +167 -0
  11. package/dist/paths.js.map +1 -0
  12. package/dist/personas.d.ts +40 -0
  13. package/dist/personas.js +93 -0
  14. package/dist/personas.js.map +1 -0
  15. package/dist/state.d.ts +38 -0
  16. package/dist/state.js +59 -0
  17. package/dist/state.js.map +1 -0
  18. package/docs/recipe.md +253 -0
  19. package/hooks/hooks.json +34 -0
  20. package/hooks/lib.mjs +66 -0
  21. package/hooks/routing.mjs +99 -0
  22. package/hooks/session-start.mjs +34 -0
  23. package/hooks/stop.mjs +52 -0
  24. package/hooks/user-prompt-submit.mjs +16 -0
  25. package/package.json +57 -0
  26. package/personas/architect/SOUL.md +27 -0
  27. package/personas/code-reviewer/SOUL.md +30 -0
  28. package/personas/critic/SOUL.md +28 -0
  29. package/personas/debugger/SOUL.md +28 -0
  30. package/personas/executor/SOUL.md +25 -0
  31. package/personas/explore/SOUL.md +24 -0
  32. package/personas/manifest.json +119 -0
  33. package/personas/planner/SOUL.md +25 -0
  34. package/personas/test-engineer/SOUL.md +27 -0
  35. package/personas/verifier/SOUL.md +29 -0
  36. package/personas/writer/SOUL.md +27 -0
  37. package/scripts/install.mjs +303 -0
  38. package/scripts/preflight.mjs +121 -0
  39. package/scripts/settings-install.mjs +155 -0
  40. package/scripts/verify-manifest.mjs +211 -0
  41. package/scripts/verify-skills.mjs +78 -0
  42. package/skills/cancel/SKILL.md +76 -0
  43. package/skills/deep-dive/SKILL.md +73 -0
  44. package/skills/deep-interview/SKILL.md +101 -0
  45. package/skills/ralph/SKILL.md +111 -0
  46. package/skills/ralplan/SKILL.md +96 -0
  47. package/skills/team/SKILL.md +94 -0
  48. package/skills/trace/SKILL.md +75 -0
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Pure escalation-preflight logic, separated from the installer so the
3
+ * security-relevant branches are testable without an enterprise config document.
4
+ *
5
+ * Enterprise policy can only arrive from a system file or macOS managed
6
+ * preferences, neither of which a test can create. Keeping the parsing pure means
7
+ * the "refuse to install" path is covered by tests rather than by hope.
8
+ *
9
+ * Every probe is three-valued — yes / no / unknown. An earlier version collapsed
10
+ * unknown into "available" and "policy absent", so a crashed or missing `muse`
11
+ * made the installer report a *more* permissive posture than reality. A safety
12
+ * preflight must fail closed, so an indeterminate probe now blocks installation
13
+ * rather than being read as good news.
14
+ */
15
+
16
+ /** @typedef {'yes' | 'no' | 'unknown'} Tri */
17
+
18
+ /**
19
+ * @typedef {object} ProbeResult
20
+ * @property {number | null} status exit code, or null if the process never ran
21
+ * @property {string | null} signal terminating signal, if any
22
+ * @property {string} output combined stdout + stderr
23
+ * @property {boolean} ran whether the process actually started
24
+ */
25
+
26
+ /**
27
+ * Classifies whether named permission profiles are usable.
28
+ *
29
+ * @param {ProbeResult | null} probe
30
+ * @returns {Tri}
31
+ */
32
+ export function namedProfilesAvailable(probe) {
33
+ if (!probe || !probe.ran || probe.signal) return 'unknown';
34
+
35
+ // An explicit refusal is a definite "no" whatever the exit code.
36
+ if (
37
+ /profile does not exist|permission profiles? .*(is |are )?unavailable|no permission profile/i.test(
38
+ probe.output,
39
+ )
40
+ ) {
41
+ return 'no';
42
+ }
43
+
44
+ // Anything other than a clean exit tells us nothing about the capability.
45
+ if (probe.status !== 0) return 'unknown';
46
+
47
+ return 'yes';
48
+ }
49
+
50
+ /**
51
+ * Parses `muse config status` output.
52
+ *
53
+ * @param {ProbeResult | null} probe
54
+ * @returns {{ policyPresent: Tri, sandboxBypassForbidden: Tri }}
55
+ */
56
+ export function parseConfigStatus(probe) {
57
+ if (!probe || !probe.ran || probe.signal || probe.status !== 0 || !probe.output.trim()) {
58
+ return { policyPresent: 'unknown', sandboxBypassForbidden: 'unknown' };
59
+ }
60
+
61
+ // A recognisable status document must actually mention the planes; anything
62
+ // else is output we do not understand and must not interpret.
63
+ if (!/plane=(defaults|policy)\b/.test(probe.output)) {
64
+ return { policyPresent: 'unknown', sandboxBypassForbidden: 'unknown' };
65
+ }
66
+
67
+ const policyPresent = probe.output
68
+ .split('\n')
69
+ .some((line) => /plane=policy\b/.test(line) && !/\bstate=absent\b/.test(line));
70
+
71
+ if (!policyPresent) return { policyPresent: 'no', sandboxBypassForbidden: 'no' };
72
+
73
+ return {
74
+ policyPresent: 'yes',
75
+ sandboxBypassForbidden: /forbid_sandbox_bypass/i.test(probe.output) ? 'yes' : 'no',
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Combines both probes into the verdict the installer acts on.
81
+ *
82
+ * `blocked` is true when installation must not proceed: either policy definitely
83
+ * forbids the only escalation route, or the posture could not be determined at
84
+ * all. `blockReason` explains which.
85
+ *
86
+ * @param {{ profileProbe: ProbeResult | null, configProbe: ProbeResult | null }} probes
87
+ */
88
+ export function escalationVerdict({ profileProbe, configProbe }) {
89
+ const namedProfiles = namedProfilesAvailable(profileProbe);
90
+ const { policyPresent, sandboxBypassForbidden } = parseConfigStatus(configProbe);
91
+
92
+ const describe = {
93
+ yes: 'AVAILABLE',
94
+ no: 'unavailable on this build (cannot scope the escalation)',
95
+ unknown: 'UNKNOWN — could not determine (probe failed)',
96
+ };
97
+
98
+ const detail = [`named permission profiles: ${describe[namedProfiles]}`];
99
+ if (policyPresent === 'unknown') {
100
+ detail.push('enterprise policy: UNKNOWN — could not read `muse config status`');
101
+ } else if (policyPresent === 'no') {
102
+ detail.push('enterprise policy: absent (no forbid_sandbox_bypass lock)');
103
+ } else {
104
+ detail.push(
105
+ `enterprise policy: present${sandboxBypassForbidden === 'yes' ? ' and forbids sandbox bypass' : ''}`,
106
+ );
107
+ }
108
+
109
+ let blocked = false;
110
+ let blockReason = null;
111
+
112
+ if (sandboxBypassForbidden === 'yes') {
113
+ blocked = true;
114
+ blockReason = 'policy-forbids-bypass';
115
+ } else if (policyPresent === 'unknown' || namedProfiles === 'unknown') {
116
+ blocked = true;
117
+ blockReason = 'posture-undetermined';
118
+ }
119
+
120
+ return { namedProfiles, policyPresent, sandboxBypassForbidden, detail, blocked, blockReason };
121
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Delivery through muse settings, for builds where the plugins subsystem is off.
3
+ *
4
+ * muse 1.0.3-R2198.1 answers "plugins are not available in this build" to every
5
+ * `muse plugins` command, and a registered marketplace yields
6
+ * `{"skills":[],"diagnostics":[]}` — no discovery and no error. A plugin manifest
7
+ * alone therefore delivers nothing at all on this build.
8
+ *
9
+ * These routes were each verified to work instead:
10
+ * - skills: `muse skills install --scope user` installs into $CONFIG_DIR/skills/
11
+ * - hooks: a `hooks` entry in $CONFIG_DIR/muse/settings.json genuinely fires
12
+ * (a SessionStart hook configured this way ran and created .omm/)
13
+ * - mcp: an `mcpServers` entry in the same file is accepted
14
+ *
15
+ * The merge is deliberately conservative: it only touches keys it owns, so a
16
+ * hand-maintained settings.json survives installation intact.
17
+ */
18
+
19
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
20
+ import { dirname, join } from 'node:path';
21
+
22
+ /**
23
+ * Identifies the entries this installer owns.
24
+ *
25
+ * Ownership is inferred from the command path rather than stamped with a marker
26
+ * field. An earlier version added `__owner` to each hook entry; muse rejected the
27
+ * unknown member and silently stopped loading the hook — no diagnostic, no error,
28
+ * the hook simply never fired. Settings documents only tolerate the members muse
29
+ * knows, so ownership has to be derived from what is already there.
30
+ */
31
+ export const OWNER_TAG = 'oh-my-musecode';
32
+
33
+ /**
34
+ * POSIX single-quote escaping for a path embedded in a shell command string.
35
+ *
36
+ * `JSON.stringify` is NOT shell quoting. Double quotes still permit command
37
+ * substitution, so a plugin root containing `$(...)` or backticks would execute
38
+ * when muse ran the hook — a checkout path is enough to get code execution.
39
+ * Single quotes suppress all expansion; the only character needing care is the
40
+ * single quote itself, closed and reopened around an escaped literal.
41
+ */
42
+ function shellQuote(value) {
43
+ return `'${String(value).replaceAll("'", `'\\''`)}'`;
44
+ }
45
+
46
+ /** True when a hook command points at a script inside this plugin. */
47
+ function commandBelongsTo(command, pluginRoot) {
48
+ return typeof command === 'string' && command.includes(join(pluginRoot, 'hooks'));
49
+ }
50
+
51
+ /** Builds the hook entries, rooted at an absolute plugin directory. */
52
+ export function desiredHooks(pluginRoot) {
53
+ const hook = (script) => ({
54
+ type: 'command',
55
+ command: `node ${shellQuote(join(pluginRoot, 'hooks', script))}`,
56
+ });
57
+
58
+ return {
59
+ UserPromptSubmit: [{ hooks: [hook('user-prompt-submit.mjs')] }],
60
+ SessionStart: [{ hooks: [hook('session-start.mjs')] }],
61
+ Stop: [{ hooks: [hook('stop.mjs')] }],
62
+ };
63
+ }
64
+
65
+ /** Builds the MCP server entry. */
66
+ export function desiredMcpServers(pluginRoot) {
67
+ return {
68
+ 'omm-state': {
69
+ transport: 'stdio',
70
+ command: 'node',
71
+ args: [join(pluginRoot, 'dist', 'mcp', 'state-server.js')],
72
+ },
73
+ };
74
+ }
75
+
76
+ /** True when a settings hook group belongs to us. */
77
+ function isOurs(group, pluginRoot) {
78
+ return (group?.hooks ?? []).some((h) => commandBelongsTo(h?.command, pluginRoot));
79
+ }
80
+
81
+ /**
82
+ * Merges our hooks and MCP server into an existing settings document without
83
+ * disturbing anything else.
84
+ *
85
+ * Idempotent by construction: our own previous entries are dropped before the
86
+ * current ones are appended, so repeated installs converge rather than stacking
87
+ * duplicate hooks.
88
+ */
89
+ export function mergeSettings(existing, pluginRoot) {
90
+ const next = existing && typeof existing === 'object' ? structuredClone(existing) : {};
91
+ if (typeof next.schema_version !== 'number') next.schema_version = 1;
92
+
93
+ const hooks = { ...(next.hooks ?? {}) };
94
+ for (const [event, groups] of Object.entries(desiredHooks(pluginRoot))) {
95
+ const foreign = (hooks[event] ?? []).filter((group) => !isOurs(group, pluginRoot));
96
+ hooks[event] = [...foreign, ...groups];
97
+ }
98
+ next.hooks = hooks;
99
+
100
+ const servers = { ...(next.mcpServers ?? {}) };
101
+ for (const [id, server] of Object.entries(desiredMcpServers(pluginRoot))) {
102
+ const existingServer = servers[id];
103
+ // Merge and unmerge must agree on ownership. Unmerge only removes an
104
+ // omm-state entry pointing into this plugin, so merge must not silently
105
+ // replace one that does not — that would destroy an unrelated server that
106
+ // merely shares the id, and leave it unrecoverable.
107
+ if (existingServer && !(existingServer.args ?? []).join(' ').includes(pluginRoot)) {
108
+ throw new Error(
109
+ `settings already define an mcpServer named "${id}" that is not ours ` +
110
+ `(${JSON.stringify(existingServer.command ?? '')}). Refusing to overwrite it; ` +
111
+ `rename or remove it first.`,
112
+ );
113
+ }
114
+ servers[id] = server;
115
+ }
116
+ next.mcpServers = servers;
117
+
118
+ return next;
119
+ }
120
+
121
+ /** Removes every entry this installer owns. */
122
+ export function unmergeSettings(existing, pluginRoot) {
123
+ const next = structuredClone(existing ?? {});
124
+
125
+ if (next.hooks) {
126
+ for (const event of Object.keys(next.hooks)) {
127
+ const remaining = (next.hooks[event] ?? []).filter((group) => !isOurs(group, pluginRoot));
128
+ if (remaining.length > 0) next.hooks[event] = remaining;
129
+ else delete next.hooks[event];
130
+ }
131
+ if (Object.keys(next.hooks).length === 0) delete next.hooks;
132
+ }
133
+
134
+ if (next.mcpServers) {
135
+ for (const [id, server] of Object.entries(next.mcpServers)) {
136
+ const arg = (server?.args ?? []).join(' ');
137
+ if (id === 'omm-state' && arg.includes(pluginRoot)) delete next.mcpServers[id];
138
+ }
139
+ if (Object.keys(next.mcpServers).length === 0) delete next.mcpServers;
140
+ }
141
+
142
+ return next;
143
+ }
144
+
145
+ /** Reads a settings document, or null when absent. Throws on malformed JSON. */
146
+ export function readSettings(settingsPath) {
147
+ if (!existsSync(settingsPath)) return null;
148
+ return JSON.parse(readFileSync(settingsPath, 'utf8'));
149
+ }
150
+
151
+ /** Writes a settings document, creating the config directory if needed. */
152
+ export function writeSettings(settingsPath, document) {
153
+ mkdirSync(dirname(settingsPath), { recursive: true });
154
+ writeFileSync(settingsPath, JSON.stringify(document, null, 2) + '\n', 'utf8');
155
+ }
@@ -0,0 +1,211 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Validates the plugin manifests against muse's native plugin contract.
4
+ *
5
+ * muse ships the authoritative contract with its bundled `create-plugin` skill
6
+ * (`.../skills/create-plugin/references/native-plugin-contract.md`). We cannot run
7
+ * `muse plugins validate` because the whole plugins subsystem answers "plugins are
8
+ * not available in this build" on 1.0.3-R2198.1 — so this script enforces the
9
+ * documented contract locally instead, and is the closest thing to a validator the
10
+ * build allows.
11
+ *
12
+ * The `agents` check matters most: the validator rejects that capability family,
13
+ * and a manifest declaring it loads while its definitions stay permanently
14
+ * inactive. Failing the build is better than shipping a roster that silently
15
+ * never activates.
16
+ */
17
+
18
+ import { existsSync, readFileSync } from 'node:fs';
19
+ import { fileURLToPath } from 'node:url';
20
+ import { dirname, join } from 'node:path';
21
+
22
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
23
+
24
+ const ACCEPTED_CAPABILITIES = new Set([
25
+ 'skills',
26
+ 'commands',
27
+ 'hooks',
28
+ 'mcpServers',
29
+ 'reminders',
30
+ ]);
31
+
32
+ // Rejected outright by muse's validator.
33
+ const REJECTED_CAPABILITIES = new Set(['tools', 'agents', 'outputStyles', 'settings', 'apps']);
34
+
35
+ const HOOK_EVENTS = new Set([
36
+ 'PreToolUse',
37
+ 'PostToolUse',
38
+ 'UserPromptSubmit',
39
+ 'SessionStart',
40
+ 'SessionEnd',
41
+ 'Stop',
42
+ 'SubagentStop',
43
+ 'PreCompact',
44
+ 'Notification',
45
+ ]);
46
+
47
+ // Portable capability/plugin id grammar from the contract.
48
+ const ID_GRAMMAR = /^[a-z0-9][a-z0-9._-]{0,79}$/;
49
+ const RESERVED_BASENAMES = new Set([
50
+ 'con', 'prn', 'aux', 'nul',
51
+ ...Array.from({ length: 9 }, (_, i) => `com${i + 1}`),
52
+ ...Array.from({ length: 9 }, (_, i) => `lpt${i + 1}`),
53
+ ]);
54
+
55
+ const problems = [];
56
+
57
+ function readJson(relPath, { required }) {
58
+ const abs = join(ROOT, relPath);
59
+ if (!existsSync(abs)) {
60
+ if (required) problems.push(`${relPath}: missing`);
61
+ return null;
62
+ }
63
+ try {
64
+ return JSON.parse(readFileSync(abs, 'utf8'));
65
+ } catch (err) {
66
+ problems.push(`${relPath}: ${err.message}`);
67
+ return null;
68
+ }
69
+ }
70
+
71
+ /** Contract: relative UTF-8 paths, `/` separators, no traversal, no absolute paths. */
72
+ function checkRelativePath(label, value) {
73
+ if (typeof value !== 'string' || value === '') {
74
+ problems.push(`${label}: path must be a non-empty string`);
75
+ return;
76
+ }
77
+ if (value.startsWith('/')) problems.push(`${label}: absolute paths are rejected (${value})`);
78
+ if (value.includes('\\')) problems.push(`${label}: backslashes are rejected (${value})`);
79
+ if (value.split('/').includes('..')) problems.push(`${label}: parent traversal is rejected (${value})`);
80
+ if (!existsSync(join(ROOT, value))) problems.push(`${label}: referenced file does not exist (${value})`);
81
+ }
82
+
83
+ function checkId(label, id) {
84
+ if (typeof id !== 'string' || !ID_GRAMMAR.test(id)) {
85
+ problems.push(`${label}: id "${id}" does not match the portable id grammar`);
86
+ return;
87
+ }
88
+ if (RESERVED_BASENAMES.has(id.split('.')[0].toLowerCase())) {
89
+ problems.push(`${label}: id "${id}" case-folds to a reserved device name`);
90
+ }
91
+ }
92
+
93
+ // ------------------------------------------------- native manifest (authoritative)
94
+
95
+ const native = readJson('.muse-plugin/plugin.json', { required: true });
96
+
97
+ if (native) {
98
+ if (native.schemaVersion !== 1) problems.push('plugin.json: schemaVersion must be 1');
99
+ if (!native.name) problems.push('plugin.json: missing `name`');
100
+ else checkId('plugin.json name', native.name);
101
+ if (native.name === 'loop' || native.name === 'muse-core') {
102
+ problems.push(`plugin.json: plugin id "${native.name}" is reserved by the product bundle`);
103
+ }
104
+ if (!native.version) problems.push('plugin.json: missing `version`');
105
+ if (!native.description) problems.push('plugin.json: missing `description`');
106
+ if (native.compat?.source !== 'native') problems.push('plugin.json: compat.source must be "native"');
107
+ if (native.compat?.manifestDir !== '.muse-plugin') {
108
+ problems.push('plugin.json: compat.manifestDir must be ".muse-plugin"');
109
+ }
110
+
111
+ const caps = native.capabilities;
112
+ if (!caps || typeof caps !== 'object' || Array.isArray(caps)) {
113
+ problems.push('plugin.json: `capabilities` must be an object');
114
+ } else {
115
+ for (const key of Object.keys(caps)) {
116
+ if (REJECTED_CAPABILITIES.has(key)) {
117
+ problems.push(
118
+ `plugin.json: capability \`${key}\` is rejected by muse's validator and must not be declared` +
119
+ (key === 'agents'
120
+ ? ' — personas are rendered into subagent prompts instead (see src/personas.ts)'
121
+ : ''),
122
+ );
123
+ } else if (!ACCEPTED_CAPABILITIES.has(key)) {
124
+ problems.push(`plugin.json: unknown capability \`${key}\``);
125
+ } else if (!Array.isArray(caps[key])) {
126
+ problems.push(`plugin.json: capability \`${key}\` must be an array`);
127
+ }
128
+ }
129
+
130
+ for (const skill of caps.skills ?? []) {
131
+ checkId('plugin.json skill', skill.id);
132
+ checkRelativePath(`plugin.json skill ${skill.id}`, skill.path);
133
+ if (!skill.path?.endsWith('SKILL.md')) {
134
+ problems.push(`plugin.json skill ${skill.id}: path must target a SKILL.md`);
135
+ }
136
+ }
137
+
138
+ for (const command of caps.commands ?? []) {
139
+ checkId('plugin.json command', command.id);
140
+ checkRelativePath(`plugin.json command ${command.id}`, command.path);
141
+ }
142
+
143
+ const hookSources = new Map();
144
+ for (const hook of caps.hooks ?? []) {
145
+ checkId('plugin.json hook', hook.id);
146
+ if (!HOOK_EVENTS.has(hook.event)) {
147
+ problems.push(`plugin.json hook ${hook.id}: unsupported event "${hook.event}"`);
148
+ }
149
+ if (!Array.isArray(hook.command) || hook.command.length === 0) {
150
+ problems.push(`plugin.json hook ${hook.id}: command must be a structured argv array`);
151
+ continue;
152
+ }
153
+ // Any argv element naming a relative source file must exist beneath the root.
154
+ for (const arg of hook.command.slice(1)) {
155
+ if (typeof arg === 'string' && arg.includes('/')) {
156
+ checkRelativePath(`plugin.json hook ${hook.id}`, arg);
157
+ // Contract: two hook ids may not share a source path.
158
+ if (hookSources.has(arg)) {
159
+ problems.push(
160
+ `plugin.json hook ${hook.id}: shares source ${arg} with ${hookSources.get(arg)}`,
161
+ );
162
+ }
163
+ hookSources.set(arg, hook.id);
164
+ }
165
+ }
166
+ }
167
+
168
+ for (const server of caps.mcpServers ?? []) {
169
+ checkId('plugin.json mcpServer', server.id);
170
+ if (server.transport && server.transport !== 'stdio' && !server.url) {
171
+ problems.push(`plugin.json mcpServer ${server.id}: non-stdio transport requires a url`);
172
+ }
173
+ if (!Array.isArray(server.command) || server.command.length === 0) {
174
+ problems.push(`plugin.json mcpServer ${server.id}: command must be a structured argv array`);
175
+ }
176
+ }
177
+ }
178
+ }
179
+
180
+ // ------------------------------------------- Claude-family manifest (compatibility)
181
+
182
+ // Kept so the same tree can be consumed by Claude-family hosts. It is NOT the
183
+ // delivery mechanism for muse; it must simply not contradict the native manifest.
184
+ const marketplace = readJson('.claude-plugin/marketplace.json', { required: false });
185
+ if (marketplace) {
186
+ if (!Array.isArray(marketplace.plugins) || marketplace.plugins.length === 0) {
187
+ problems.push('marketplace.json: `plugins` must be a non-empty array');
188
+ } else {
189
+ for (const [i, plugin] of marketplace.plugins.entries()) {
190
+ if (!plugin.source) problems.push(`marketplace.json: plugins[${i}] is missing \`source\``);
191
+ if (!plugin.name) problems.push(`marketplace.json: plugins[${i}] is missing \`name\``);
192
+ }
193
+ }
194
+ }
195
+
196
+ const claude = readJson('.claude-plugin/plugin.json', { required: false });
197
+ if (claude?.capabilities) {
198
+ for (const key of Object.keys(claude.capabilities)) {
199
+ if (REJECTED_CAPABILITIES.has(key)) {
200
+ problems.push(`.claude-plugin/plugin.json: capability \`${key}\` is rejected by muse`);
201
+ }
202
+ }
203
+ }
204
+
205
+ if (problems.length > 0) {
206
+ console.error('Manifest verification FAILED:\n');
207
+ for (const problem of problems) console.error(` - ${problem}`);
208
+ process.exit(1);
209
+ }
210
+
211
+ console.log('Manifest verification passed: native contract satisfied, no rejected capabilities.');
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Validates every skill in the corpus against the installed `muse` binary.
4
+ *
5
+ * This is the only trustworthy oracle for frontmatter conformance. `valid: true`
6
+ * on its own is not enough: muse tolerates unknown frontmatter keys by recording
7
+ * them in `compatibility.unknown_fields` and ignoring them at runtime. A skill
8
+ * carrying `triggers:` or `pipeline:` therefore validates "successfully" while
9
+ * behaving as if those fields were never written — so an empty unknown_fields
10
+ * array is part of the pass condition, not a nicety.
11
+ */
12
+
13
+ import { spawnSync } from 'node:child_process';
14
+ import { readdirSync, existsSync } from 'node:fs';
15
+ import { dirname, join } from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
19
+ const SKILLS_DIR = join(ROOT, 'skills');
20
+
21
+ const skills = readdirSync(SKILLS_DIR, { withFileTypes: true })
22
+ .filter((entry) => entry.isDirectory() && existsSync(join(SKILLS_DIR, entry.name, 'SKILL.md')))
23
+ .map((entry) => entry.name)
24
+ .sort();
25
+
26
+ if (skills.length === 0) {
27
+ console.error('verify-skills: no skills found under skills/');
28
+ process.exit(1);
29
+ }
30
+
31
+ let failures = 0;
32
+
33
+ for (const name of skills) {
34
+ const result = spawnSync('muse', ['skills', 'validate', join(SKILLS_DIR, name), '--json'], {
35
+ encoding: 'utf8',
36
+ });
37
+
38
+ if (result.error) {
39
+ console.error(` ${name}: could not run muse (${result.error.message})`);
40
+ failures++;
41
+ continue;
42
+ }
43
+
44
+ let report;
45
+ try {
46
+ report = JSON.parse(result.stdout);
47
+ } catch {
48
+ console.error(` ${name}: muse did not return parseable JSON`);
49
+ failures++;
50
+ continue;
51
+ }
52
+
53
+ const unknown = report.compatibility?.unknown_fields ?? [];
54
+ const unsupported = (report.diagnostics ?? []).filter(
55
+ (d) => d.code === 'unsupported-skill-field',
56
+ );
57
+
58
+ const problems = [];
59
+ if (!report.valid) problems.push('valid=false');
60
+ if (unknown.length > 0) problems.push(`inert frontmatter keys: ${unknown.join(', ')}`);
61
+ if (unsupported.length > 0) {
62
+ problems.push(`unsupported fields: ${unsupported.map((d) => d.message).join('; ')}`);
63
+ }
64
+
65
+ if (problems.length > 0) {
66
+ console.error(` ${name}: FAIL — ${problems.join(' | ')}`);
67
+ failures++;
68
+ } else {
69
+ console.log(` ${name}: ok`);
70
+ }
71
+ }
72
+
73
+ if (failures > 0) {
74
+ console.error(`\nverify-skills: ${failures} of ${skills.length} skills failed.`);
75
+ process.exit(1);
76
+ }
77
+
78
+ console.log(`\nverify-skills: all ${skills.length} skills valid with no inert frontmatter.`);
@@ -0,0 +1,76 @@
1
+ ---
2
+ name: cancel
3
+ description: End any active pipeline stage (deep-interview, deep-dive, trace, ralplan, ralph, team) and clean up .omm/ state left running or in progress. Use ONLY when the user explicitly asks to cancel, stop, or abandon current work, or invokes /cancel. Do NOT use to pause and resume later (leave in-progress state alone if the user just wants a break), and do not use it to undo already-committed code changes — this skill stops orchestration state, it does not revert files.
4
+ ---
5
+
6
+ # Cancel
7
+
8
+ Stop whatever pipeline stage is active and leave `.omm/` state in a clean,
9
+ honest, resumable-or-discardable condition. Explicit invocation only — like
10
+ every skill here, nothing auto-cancels on your behalf; the user has to ask.
11
+
12
+ ## Scope
13
+
14
+ This skill ends orchestration state. It does not:
15
+
16
+ - Revert code changes already made to the working tree. If the user also
17
+ wants that, they need to say so separately (`git` operations are a
18
+ distinct, more destructive action and should never be inferred from a
19
+ bare "cancel").
20
+ - Kill a task the user actually wants paused rather than abandoned. If the
21
+ request is genuinely "pause, I'll come back to this," leave the state
22
+ files as they are — this skill is for "stop, and I mean stop," not for a
23
+ checkpoint.
24
+
25
+ If it is unclear which the user means, ask before deleting anything —
26
+ cleanup here is one-directional.
27
+
28
+ ## What "active" means
29
+
30
+ Check for state that implies an in-flight stage:
31
+
32
+ - `.omm/specs/*.md` with no terminal approval marker (an interview left
33
+ mid-round or a spec left unapproved).
34
+ - `.omm/state/trace-*.md` for a trace that never converged.
35
+ - `.omm/state/prd.json` with any story `status` other than `"done"` or
36
+ `"blocked"`.
37
+ - Any `subagent_spawn` children still running from a `team` or `ralph` run
38
+ in this session — check with `subagent_status` across tracked ids.
39
+
40
+ ## Steps
41
+
42
+ 1. **Cancel live subagents first.** For every tracked subagent id from an
43
+ active `team` or `ralph` run that is still running, call
44
+ `subagent_cancel` and wait for terminal cancellation
45
+ (`subagent_wait`/`subagent_status`) before touching files — do not leave
46
+ a subagent writing into a worktree you are about to declare abandoned.
47
+ 2. **Mark state, don't just delete it.** For each in-flight artifact found
48
+ above, decide with the user (or from their instruction) whether to:
49
+ - **Discard**: delete the file/entry. Only do this for state the user
50
+ explicitly wants gone.
51
+ - **Archive**: move it under `.omm/logs/cancelled/<timestamp>-<name>` so
52
+ the evidence of what was attempted survives even though the run did
53
+ not complete. Prefer this default over silent deletion — an
54
+ abandoned run is still useful history.
55
+ 3. **Leave completed work alone.** Stories already `"done"` in `prd.json`,
56
+ approved specs, and merged `team` results are not part of what gets
57
+ cleaned up — cancel affects the active/pending state, not history that
58
+ already succeeded.
59
+ 4. **Report exactly what changed.** List every file archived or deleted and
60
+ every subagent cancelled. Do not report "cancelled" if some tracked
61
+ subagent could not be confirmed terminal — say so and flag it instead of
62
+ claiming a clean stop.
63
+
64
+ ## Worktrees
65
+
66
+ If a `team` or `ralph` run left worktrees under muse's worktree area from
67
+ `worktree_isolation`, note their existence in the report so the user can
68
+ decide whether to keep or discard them — this skill does not assume
69
+ authority to delete a worktree that might contain unmerged, wanted work.
70
+
71
+ ## Handoff
72
+
73
+ Cancel is terminal by design — it does not chain into anything. After
74
+ reporting, stop. If the user wants to restart the same effort, they invoke
75
+ the relevant skill again explicitly; cancel does not offer to do that for
76
+ them.