@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.
- package/.claude-plugin/marketplace.json +18 -0
- package/.claude-plugin/plugin.json +30 -0
- package/.muse-plugin/plugin.json +94 -0
- package/LICENSE +32 -0
- package/README.md +190 -0
- package/dist/mcp/state-server.d.ts +13 -0
- package/dist/mcp/state-server.js +109 -0
- package/dist/mcp/state-server.js.map +1 -0
- package/dist/paths.d.ts +58 -0
- package/dist/paths.js +167 -0
- package/dist/paths.js.map +1 -0
- package/dist/personas.d.ts +40 -0
- package/dist/personas.js +93 -0
- package/dist/personas.js.map +1 -0
- package/dist/state.d.ts +38 -0
- package/dist/state.js +59 -0
- package/dist/state.js.map +1 -0
- package/docs/recipe.md +253 -0
- package/hooks/hooks.json +34 -0
- package/hooks/lib.mjs +66 -0
- package/hooks/routing.mjs +99 -0
- package/hooks/session-start.mjs +34 -0
- package/hooks/stop.mjs +52 -0
- package/hooks/user-prompt-submit.mjs +16 -0
- package/package.json +57 -0
- package/personas/architect/SOUL.md +27 -0
- package/personas/code-reviewer/SOUL.md +30 -0
- package/personas/critic/SOUL.md +28 -0
- package/personas/debugger/SOUL.md +28 -0
- package/personas/executor/SOUL.md +25 -0
- package/personas/explore/SOUL.md +24 -0
- package/personas/manifest.json +119 -0
- package/personas/planner/SOUL.md +25 -0
- package/personas/test-engineer/SOUL.md +27 -0
- package/personas/verifier/SOUL.md +29 -0
- package/personas/writer/SOUL.md +27 -0
- package/scripts/install.mjs +303 -0
- package/scripts/preflight.mjs +121 -0
- package/scripts/settings-install.mjs +155 -0
- package/scripts/verify-manifest.mjs +211 -0
- package/scripts/verify-skills.mjs +78 -0
- package/skills/cancel/SKILL.md +76 -0
- package/skills/deep-dive/SKILL.md +73 -0
- package/skills/deep-interview/SKILL.md +101 -0
- package/skills/ralph/SKILL.md +111 -0
- package/skills/ralplan/SKILL.md +96 -0
- package/skills/team/SKILL.md +94 -0
- 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.
|