@onlooker-community/ecosystem 0.10.0 → 0.14.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 (106) hide show
  1. package/.claude-plugin/marketplace.json +39 -1
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/.github/copilot-instructions.md +46 -0
  4. package/.github/workflows/coverage.yml +78 -0
  5. package/.github/workflows/release.yml +24 -8
  6. package/.github/workflows/test.yml +3 -0
  7. package/.markdownlintignore +3 -0
  8. package/.release-please-manifest.json +4 -1
  9. package/CHANGELOG.md +37 -0
  10. package/README.md +57 -13
  11. package/config.json +6 -1
  12. package/docs/adr/001-claude-code-hooks-as-integration-surface.md +43 -0
  13. package/docs/adr/002-centralized-jsonl-event-log.md +39 -0
  14. package/docs/adr/003-ulid-over-uuid.md +40 -0
  15. package/docs/adr/004-plugin-config-with-settings-overlay.md +34 -0
  16. package/docs/architecture.md +117 -0
  17. package/hooks/hooks.json +4 -0
  18. package/package.json +13 -7
  19. package/plugins/archivist/.claude-plugin/plugin.json +14 -0
  20. package/plugins/archivist/CHANGELOG.md +8 -0
  21. package/plugins/archivist/README.md +105 -0
  22. package/plugins/archivist/config.json +18 -0
  23. package/plugins/archivist/hooks/hooks.json +35 -0
  24. package/plugins/archivist/scripts/hooks/archivist-extract.sh +238 -0
  25. package/plugins/archivist/scripts/hooks/archivist-inject.sh +159 -0
  26. package/plugins/archivist/scripts/lib/archivist-config.sh +66 -0
  27. package/plugins/archivist/scripts/lib/archivist-project-key.sh +91 -0
  28. package/plugins/archivist/scripts/lib/archivist-storage.sh +215 -0
  29. package/plugins/archivist/scripts/lib/archivist-ulid.sh +52 -0
  30. package/plugins/echo/.claude-plugin/plugin.json +14 -0
  31. package/plugins/echo/CHANGELOG.md +24 -0
  32. package/plugins/echo/README.md +110 -0
  33. package/plugins/echo/config.json +15 -0
  34. package/plugins/echo/docs/adr/001-echo-as-separate-plugin.md +33 -0
  35. package/plugins/echo/docs/adr/002-direct-evaluation-vs-tribunal-pipeline.md +35 -0
  36. package/plugins/echo/docs/adr/003-stop-hook-trigger.md +40 -0
  37. package/plugins/echo/hooks/hooks.json +15 -0
  38. package/plugins/echo/scripts/hooks/echo-stop-gate.sh +366 -0
  39. package/plugins/echo/scripts/lib/echo-config.sh +108 -0
  40. package/plugins/echo/scripts/lib/echo-events.sh +74 -0
  41. package/plugins/echo/scripts/lib/echo-project-key.sh +81 -0
  42. package/plugins/echo/scripts/lib/echo-ulid.sh +46 -0
  43. package/plugins/tribunal/.claude-plugin/plugin.json +20 -0
  44. package/plugins/tribunal/CHANGELOG.md +10 -0
  45. package/plugins/tribunal/README.md +134 -0
  46. package/plugins/tribunal/agents/tribunal-actor.md +35 -0
  47. package/plugins/tribunal/agents/tribunal-judge-adversarial.md +51 -0
  48. package/plugins/tribunal/agents/tribunal-judge-security.md +47 -0
  49. package/plugins/tribunal/agents/tribunal-judge-standard.md +47 -0
  50. package/plugins/tribunal/agents/tribunal-meta-judge.md +61 -0
  51. package/plugins/tribunal/config.json +50 -0
  52. package/plugins/tribunal/docs/adr/001-actor-jury-meta-gate-loop.md +40 -0
  53. package/plugins/tribunal/docs/adr/002-majority-gate-policy.md +48 -0
  54. package/plugins/tribunal/hooks/hooks.json +15 -0
  55. package/plugins/tribunal/scripts/hooks/tribunal-stop-gate.sh +267 -0
  56. package/plugins/tribunal/scripts/lib/tribunal-aggregate.sh +65 -0
  57. package/plugins/tribunal/scripts/lib/tribunal-config.sh +101 -0
  58. package/plugins/tribunal/scripts/lib/tribunal-events.sh +97 -0
  59. package/plugins/tribunal/scripts/lib/tribunal-gate.sh +111 -0
  60. package/plugins/tribunal/scripts/lib/tribunal-jury.sh +102 -0
  61. package/plugins/tribunal/scripts/lib/tribunal-project-key.sh +84 -0
  62. package/plugins/tribunal/scripts/lib/tribunal-rubric.sh +153 -0
  63. package/plugins/tribunal/scripts/lib/tribunal-ulid.sh +50 -0
  64. package/plugins/tribunal/scripts/lib/tribunal-verdict.sh +127 -0
  65. package/plugins/tribunal/skills/tribunal/SKILL.md +129 -0
  66. package/release-please-config.json +43 -5
  67. package/scripts/coverage/bash-coverage.mjs +169 -0
  68. package/scripts/coverage/format-comment.mjs +120 -0
  69. package/scripts/coverage/run-coverage.mjs +151 -0
  70. package/scripts/hooks/agent-spawn-tracker.sh +4 -4
  71. package/scripts/hooks/prompt-rule-injector.sh +122 -0
  72. package/scripts/lib/portable-lock.sh +48 -0
  73. package/scripts/lib/prompt-rules.sh +207 -0
  74. package/scripts/lib/tool-history.sh +7 -8
  75. package/scripts/lib/validate-path.sh +4 -0
  76. package/scripts/lint/check-manifests.mjs +314 -0
  77. package/scripts/lint/check-references.mjs +311 -0
  78. package/skills/list-prompt-rules/SKILL.md +15 -0
  79. package/test/bats/archivist-config-files.bats +60 -0
  80. package/test/bats/archivist-config.bats +54 -0
  81. package/test/bats/archivist-inject.bats +73 -0
  82. package/test/bats/archivist-project-key.bats +75 -0
  83. package/test/bats/archivist-storage.bats +119 -0
  84. package/test/bats/archivist-ulid.bats +36 -0
  85. package/test/bats/config.bats +10 -10
  86. package/test/bats/echo-config.bats +90 -0
  87. package/test/bats/echo-events.bats +121 -0
  88. package/test/bats/echo-project-key.bats +115 -0
  89. package/test/bats/echo-stop-hook.bats +101 -0
  90. package/test/bats/echo-ulid.bats +38 -0
  91. package/test/bats/portable-lock.bats +62 -0
  92. package/test/bats/prompt-rules.bats +269 -0
  93. package/test/bats/tribunal-aggregate.bats +77 -0
  94. package/test/bats/tribunal-config.bats +86 -0
  95. package/test/bats/tribunal-events.bats +209 -0
  96. package/test/bats/tribunal-gate.bats +95 -0
  97. package/test/bats/tribunal-jury.bats +80 -0
  98. package/test/bats/tribunal-rubric.bats +119 -0
  99. package/test/bats/tribunal-stop-hook.bats +73 -0
  100. package/test/bats/tribunal-verdict.bats +71 -0
  101. package/test/fixtures/hook-inputs/user-prompt-submit-rule-match.json +8 -0
  102. package/test/fixtures/hook-inputs/user-prompt-submit-rule-nomatch.json +8 -0
  103. package/test/helpers/setup.bash +9 -0
  104. package/test/node/check-manifests.test.mjs +173 -0
  105. package/test/node/check-references.test.mjs +279 -0
  106. package/test/node/coverage.test.mjs +143 -0
@@ -0,0 +1,314 @@
1
+ #!/usr/bin/env node
2
+ // Manifest validator for the Onlooker marketplace.
3
+ //
4
+ // Asserts that .claude-plugin/marketplace.json and every plugin's
5
+ // .claude-plugin/plugin.json match the Claude Code plugin schema, and that
6
+ // our own conventions hold:
7
+ //
8
+ // * marketplace.json plugins[] entries MUST NOT carry a `version` field
9
+ // (the drift trap — plugin.json's version silently wins at runtime, so
10
+ // setting both is documented as harmful in plugins-reference).
11
+ // * plugin.json `name` matches its marketplace entry name (no surprise
12
+ // renames).
13
+ // * plugin.json `name` is kebab-case (matches plugin loader conventions).
14
+ // * plugin.json `version` is semver-shaped.
15
+ // * resource arrays (skills/commands/agents) and mcpServers/hooks fields
16
+ // have the expected types.
17
+ //
18
+ // Exit codes:
19
+ // 0 ok
20
+ // 1 one or more schema violations
21
+ // 2 setup/usage error
22
+ //
23
+ // Flags:
24
+ // --strict treat warnings (unknown fields, missing optional
25
+ // recommended fields) as errors
26
+ // --root <path> override the repo root
27
+ // --plugin <name> only validate the named plugin (repeatable)
28
+
29
+ import { existsSync, readFileSync, statSync } from 'node:fs';
30
+ import { dirname, join, relative, resolve } from 'node:path';
31
+ import { fileURLToPath } from 'node:url';
32
+
33
+ const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
34
+ const KEBAB_CASE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
35
+
36
+ // Fields we recognize on each manifest. Unknown fields trigger a warning
37
+ // (typo detection). New fields should be added here as Claude Code's schema
38
+ // evolves.
39
+ const KNOWN_MARKETPLACE_FIELDS = new Set(['name', 'owner', 'metadata', 'plugins', '$schema']);
40
+ const KNOWN_MARKETPLACE_PLUGIN_FIELDS = new Set([
41
+ 'name',
42
+ 'source',
43
+ 'description',
44
+ 'author',
45
+ 'homepage',
46
+ 'repository',
47
+ 'license',
48
+ 'keywords',
49
+ 'tags',
50
+ ]);
51
+ const KNOWN_PLUGIN_JSON_FIELDS = new Set([
52
+ 'name',
53
+ 'version',
54
+ 'description',
55
+ 'author',
56
+ 'homepage',
57
+ 'repository',
58
+ 'license',
59
+ 'skills',
60
+ 'commands',
61
+ 'agents',
62
+ 'mcpServers',
63
+ 'hooks',
64
+ 'keywords',
65
+ '$schema',
66
+ ]);
67
+ const KNOWN_HOOK_EVENTS = new Set([
68
+ 'PreToolUse',
69
+ 'PostToolUse',
70
+ 'PostToolUseFailure',
71
+ 'PermissionRequest',
72
+ 'PermissionDenied',
73
+ 'SessionStart',
74
+ 'SessionEnd',
75
+ 'Notification',
76
+ 'SubagentStart',
77
+ 'PreCompact',
78
+ 'PostCompact',
79
+ 'SubagentStop',
80
+ 'ConfigChange',
81
+ 'CwdChanged',
82
+ 'FileChanged',
83
+ 'StopFailure',
84
+ 'InstructionsLoaded',
85
+ 'Elicitation',
86
+ 'ElicitationResult',
87
+ 'UserPromptSubmit',
88
+ 'UserPromptExpansion',
89
+ 'Stop',
90
+ 'TeammateIdle',
91
+ 'TaskCreated',
92
+ 'TaskCompleted',
93
+ 'WorktreeCreate',
94
+ 'WorktreeRemove',
95
+ ]);
96
+
97
+ function findRepoRoot(start) {
98
+ let cur = resolve(start);
99
+ while (cur !== '/') {
100
+ if (existsSync(join(cur, '.claude-plugin', 'marketplace.json'))) return cur;
101
+ cur = dirname(cur);
102
+ }
103
+ throw new Error(`could not find .claude-plugin/marketplace.json above ${start}`);
104
+ }
105
+
106
+ function parseArgs(argv) {
107
+ const out = { strict: false, plugins: [], root: null };
108
+ for (let i = 2; i < argv.length; i++) {
109
+ const a = argv[i];
110
+ if (a === '--strict') out.strict = true;
111
+ else if (a === '--plugin') out.plugins.push(argv[++i]);
112
+ else if (a === '--root') out.root = argv[++i];
113
+ else if (a === '-h' || a === '--help') {
114
+ process.stderr.write('Usage: check-manifests.mjs [--strict] [--plugin name]... [--root path]\n');
115
+ process.exit(0);
116
+ } else {
117
+ process.stderr.write(`unknown argument: ${a}\n`);
118
+ process.exit(2);
119
+ }
120
+ }
121
+ return out;
122
+ }
123
+
124
+ function isPlainObject(v) {
125
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
126
+ }
127
+
128
+ function readJson(path, label) {
129
+ try {
130
+ return JSON.parse(readFileSync(path, 'utf8'));
131
+ } catch (err) {
132
+ return { __readError: `cannot read ${label} at ${path}: ${err.message}` };
133
+ }
134
+ }
135
+
136
+ function validateMarketplace(marketplace, errors, warnings) {
137
+ if (!isPlainObject(marketplace)) {
138
+ errors.push('marketplace.json must be a JSON object');
139
+ return;
140
+ }
141
+ if (typeof marketplace.name !== 'string' || marketplace.name.length === 0) {
142
+ errors.push('marketplace.json: `name` is required and must be a non-empty string');
143
+ }
144
+ if (!Array.isArray(marketplace.plugins)) {
145
+ errors.push('marketplace.json: `plugins` is required and must be an array');
146
+ }
147
+ if (marketplace.owner !== undefined && !isPlainObject(marketplace.owner)) {
148
+ errors.push('marketplace.json: `owner` must be an object when present');
149
+ }
150
+ if (marketplace.owner && typeof marketplace.owner.name !== 'string') {
151
+ errors.push('marketplace.json: `owner.name` is required when `owner` is present');
152
+ }
153
+ for (const key of Object.keys(marketplace)) {
154
+ if (!KNOWN_MARKETPLACE_FIELDS.has(key)) {
155
+ warnings.push(`marketplace.json: unknown top-level field "${key}"`);
156
+ }
157
+ }
158
+ }
159
+
160
+ function validateMarketplacePluginEntry(entry, index, errors, warnings) {
161
+ const label = `marketplace.json plugins[${index}]`;
162
+ if (!isPlainObject(entry)) {
163
+ errors.push(`${label}: must be an object`);
164
+ return;
165
+ }
166
+ if (typeof entry.name !== 'string' || !KEBAB_CASE.test(entry.name)) {
167
+ errors.push(`${label}: \`name\` is required and must be kebab-case (got ${JSON.stringify(entry.name)})`);
168
+ }
169
+ if (typeof entry.source !== 'string' || entry.source.length === 0) {
170
+ errors.push(`${label}: \`source\` is required and must be a string`);
171
+ }
172
+ if ('version' in entry) {
173
+ errors.push(
174
+ `${label}: \`version\` MUST NOT be set in marketplace.json — Claude Code reads version from each plugin's own plugin.json, and setting both is a documented drift hazard.`,
175
+ );
176
+ }
177
+ for (const key of Object.keys(entry)) {
178
+ if (!KNOWN_MARKETPLACE_PLUGIN_FIELDS.has(key)) {
179
+ warnings.push(`${label}: unknown field "${key}"`);
180
+ }
181
+ }
182
+ }
183
+
184
+ function validatePluginJson(pluginJson, marketplaceName, label, errors, warnings) {
185
+ if (!isPlainObject(pluginJson)) {
186
+ errors.push(`${label}: plugin.json must be a JSON object`);
187
+ return;
188
+ }
189
+ if (typeof pluginJson.name !== 'string' || !KEBAB_CASE.test(pluginJson.name)) {
190
+ errors.push(`${label}: \`name\` is required and must be kebab-case (got ${JSON.stringify(pluginJson.name)})`);
191
+ } else if (pluginJson.name !== marketplaceName) {
192
+ errors.push(
193
+ `${label}: plugin.json \`name\` "${pluginJson.name}" does not match marketplace entry name "${marketplaceName}"`,
194
+ );
195
+ }
196
+ if (typeof pluginJson.version !== 'string' || pluginJson.version.length === 0) {
197
+ errors.push(`${label}: \`version\` is required and must be a non-empty string`);
198
+ } else if (!SEMVER.test(pluginJson.version)) {
199
+ errors.push(`${label}: \`version\` is not semver-shaped (got ${JSON.stringify(pluginJson.version)})`);
200
+ }
201
+ if (typeof pluginJson.description !== 'string' || pluginJson.description.length === 0) {
202
+ errors.push(`${label}: \`description\` is required and must be a non-empty string`);
203
+ }
204
+ for (const field of ['skills', 'commands', 'agents']) {
205
+ if (pluginJson[field] !== undefined && !Array.isArray(pluginJson[field])) {
206
+ errors.push(`${label}: \`${field}\` must be an array when present`);
207
+ }
208
+ }
209
+ if (pluginJson.mcpServers !== undefined && !isPlainObject(pluginJson.mcpServers)) {
210
+ errors.push(`${label}: \`mcpServers\` must be an object when present`);
211
+ }
212
+ for (const key of Object.keys(pluginJson)) {
213
+ if (!KNOWN_PLUGIN_JSON_FIELDS.has(key)) {
214
+ warnings.push(`${label}: unknown field "${key}"`);
215
+ }
216
+ }
217
+ }
218
+
219
+ function validateHooksJson(hooksJson, label, errors, warnings) {
220
+ if (!isPlainObject(hooksJson)) {
221
+ errors.push(`${label}: hooks.json must be a JSON object`);
222
+ return;
223
+ }
224
+ if (!isPlainObject(hooksJson.hooks)) {
225
+ errors.push(`${label}: hooks.json must contain a \`hooks\` object`);
226
+ return;
227
+ }
228
+ for (const [event, value] of Object.entries(hooksJson.hooks)) {
229
+ if (!KNOWN_HOOK_EVENTS.has(event)) {
230
+ warnings.push(`${label}: hooks.json declares unknown event "${event}"`);
231
+ }
232
+ if (!Array.isArray(value)) {
233
+ errors.push(`${label}: hooks.json event "${event}" must be an array`);
234
+ continue;
235
+ }
236
+ for (let i = 0; i < value.length; i++) {
237
+ const matcher = value[i];
238
+ if (!isPlainObject(matcher)) {
239
+ errors.push(`${label}: hooks.json ${event}[${i}] must be an object`);
240
+ continue;
241
+ }
242
+ if (!Array.isArray(matcher.hooks)) {
243
+ errors.push(`${label}: hooks.json ${event}[${i}].hooks must be an array`);
244
+ }
245
+ }
246
+ }
247
+ }
248
+
249
+ function main() {
250
+ const args = parseArgs(process.argv);
251
+ const here = dirname(fileURLToPath(import.meta.url));
252
+ const root = args.root ? resolve(args.root) : findRepoRoot(here);
253
+
254
+ const marketplacePath = join(root, '.claude-plugin', 'marketplace.json');
255
+ const marketplace = readJson(marketplacePath, 'marketplace.json');
256
+ if (marketplace.__readError) {
257
+ process.stderr.write(`error: ${marketplace.__readError}\n`);
258
+ process.exit(2);
259
+ }
260
+
261
+ const errors = [];
262
+ const warnings = [];
263
+
264
+ validateMarketplace(marketplace, errors, warnings);
265
+
266
+ const plugins = Array.isArray(marketplace.plugins) ? marketplace.plugins : [];
267
+ const filter = args.plugins.length === 0 ? null : new Set(args.plugins);
268
+
269
+ for (let i = 0; i < plugins.length; i++) {
270
+ const entry = plugins[i];
271
+ validateMarketplacePluginEntry(entry, i, errors, warnings);
272
+ if (!isPlainObject(entry) || typeof entry.name !== 'string' || typeof entry.source !== 'string') {
273
+ continue;
274
+ }
275
+ if (filter && !filter.has(entry.name)) continue;
276
+
277
+ const pluginRoot = resolve(root, entry.source);
278
+ const pluginJsonPath = join(pluginRoot, '.claude-plugin', 'plugin.json');
279
+ const label = `plugin "${entry.name}"`;
280
+ const pluginJson = readJson(pluginJsonPath, `${label} plugin.json`);
281
+ if (pluginJson.__readError) {
282
+ errors.push(pluginJson.__readError);
283
+ continue;
284
+ }
285
+ validatePluginJson(pluginJson, entry.name, label, errors, warnings);
286
+
287
+ // Validate hooks/hooks.json if the plugin appears to ship hooks.
288
+ const hooksJsonPath = join(pluginRoot, 'hooks', 'hooks.json');
289
+ if (existsSync(hooksJsonPath)) {
290
+ const stat = statSync(hooksJsonPath);
291
+ if (stat.isFile()) {
292
+ const hooksJson = readJson(hooksJsonPath, `${label} hooks.json`);
293
+ if (hooksJson.__readError) {
294
+ errors.push(hooksJson.__readError);
295
+ } else {
296
+ validateHooksJson(hooksJson, `${label} (${relative(root, hooksJsonPath)})`, errors, warnings);
297
+ }
298
+ }
299
+ }
300
+ }
301
+
302
+ for (const e of errors) process.stderr.write(`error: ${e}\n`);
303
+ for (const w of warnings) process.stderr.write(`warn: ${w}\n`);
304
+
305
+ const failing = errors.length > 0 || (args.strict && warnings.length > 0);
306
+ if (failing) {
307
+ process.stderr.write(`check-manifests: ${errors.length} error(s), ${warnings.length} warning(s)\n`);
308
+ process.exit(1);
309
+ }
310
+
311
+ process.stdout.write(`check-manifests: ok (${plugins.length} plugin(s), ${warnings.length} warning(s))\n`);
312
+ }
313
+
314
+ main();
@@ -0,0 +1,311 @@
1
+ #!/usr/bin/env node
2
+ // Cross-reference linter for the Onlooker marketplace.
3
+ //
4
+ // Walks every plugin declared in `.claude-plugin/marketplace.json` and:
5
+ // 1. asserts every path under plugin.json's `skills`, `commands`, `agents`,
6
+ // and `hooks` fields resolves to a real file or directory inside the
7
+ // plugin's source tree;
8
+ // 2. parses YAML frontmatter from each markdown skill/command/agent and
9
+ // asserts the required fields (`name`, `description`) are present;
10
+ // 3. builds a cross-marketplace registry of declared commands/skills/agents
11
+ // and scans every markdown body for slash-command references
12
+ // (`/foo`) — anything that is not a known built-in or a declared
13
+ // command is reported as a warning.
14
+ //
15
+ // Exit codes:
16
+ // 0 no problems
17
+ // 1 at least one error (broken path or invalid frontmatter)
18
+ // 2 setup/usage error
19
+ //
20
+ // Flags:
21
+ // --strict treat warnings as errors
22
+ // --plugin <name> only check the named plugin (repeatable)
23
+ // --root <path> override the repo root (defaults to git toplevel)
24
+ // --print-registry dump the resolved registry to stderr (debugging)
25
+
26
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
27
+ import { dirname, join, relative, resolve } from 'node:path';
28
+ import { fileURLToPath } from 'node:url';
29
+
30
+ // Slash commands shipped by Claude Code itself. References to these never
31
+ // fail; they are valid even though we cannot resolve them in the
32
+ // marketplace. Kept short on purpose — additions should be deliberate.
33
+ const BUILTIN_COMMANDS = new Set([
34
+ 'clear',
35
+ 'compact',
36
+ 'config',
37
+ 'help',
38
+ 'loop',
39
+ 'plugin',
40
+ 'reload-plugins',
41
+ 'effort',
42
+ 'commit',
43
+ 'fast',
44
+ ]);
45
+
46
+ // Required frontmatter fields per resource kind. Skills/commands/agents all
47
+ // need at least name+description; everything else is optional metadata.
48
+ const REQUIRED_FRONTMATTER = ['name', 'description'];
49
+
50
+ function findRepoRoot(start) {
51
+ let cur = resolve(start);
52
+ while (cur !== '/') {
53
+ try {
54
+ statSync(join(cur, '.claude-plugin', 'marketplace.json'));
55
+ return cur;
56
+ } catch {}
57
+ cur = dirname(cur);
58
+ }
59
+ throw new Error(`could not find .claude-plugin/marketplace.json above ${start}`);
60
+ }
61
+
62
+ function parseArgs(argv) {
63
+ const out = { strict: false, plugins: [], printRegistry: false, root: null };
64
+ for (let i = 2; i < argv.length; i++) {
65
+ const a = argv[i];
66
+ if (a === '--strict') out.strict = true;
67
+ else if (a === '--print-registry') out.printRegistry = true;
68
+ else if (a === '--plugin') out.plugins.push(argv[++i]);
69
+ else if (a === '--root') out.root = argv[++i];
70
+ else if (a === '-h' || a === '--help') {
71
+ process.stderr.write(
72
+ 'Usage: check-references.mjs [--strict] [--plugin name]... [--root path] [--print-registry]\n',
73
+ );
74
+ process.exit(0);
75
+ } else {
76
+ process.stderr.write(`unknown argument: ${a}\n`);
77
+ process.exit(2);
78
+ }
79
+ }
80
+ return out;
81
+ }
82
+
83
+ // Minimal YAML frontmatter parser. We only need `key: value` and quoted
84
+ // strings — no nesting, no lists. Sufficient for plugin resource frontmatter,
85
+ // and avoids pulling in a YAML dependency.
86
+ function parseFrontmatter(source) {
87
+ const lines = source.split(/\r?\n/);
88
+ if (lines[0] !== '---') return { frontmatter: null, body: source };
89
+ let end = -1;
90
+ for (let i = 1; i < lines.length; i++) {
91
+ if (lines[i] === '---') {
92
+ end = i;
93
+ break;
94
+ }
95
+ }
96
+ if (end === -1) return { frontmatter: null, body: source };
97
+ const fm = {};
98
+ for (let i = 1; i < end; i++) {
99
+ const m = lines[i].match(/^([A-Za-z_][A-Za-z0-9_-]*):\s*(.*)$/);
100
+ if (!m) continue;
101
+ let v = m[2].trim();
102
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
103
+ v = v.slice(1, -1);
104
+ }
105
+ fm[m[1]] = v;
106
+ }
107
+ return { frontmatter: fm, body: lines.slice(end + 1).join('\n') };
108
+ }
109
+
110
+ // Resolve a plugin manifest entry that may be either a bare string or
111
+ // `{ source, name? }` to an absolute path inside the plugin tree.
112
+ function entryPath(pluginRoot, entry) {
113
+ if (typeof entry === 'string') return resolve(pluginRoot, entry);
114
+ if (entry && typeof entry === 'object' && typeof entry.source === 'string') {
115
+ return resolve(pluginRoot, entry.source);
116
+ }
117
+ return null;
118
+ }
119
+
120
+ // Extract a friendly identifier from an entry for error messages.
121
+ function entryLabel(entry) {
122
+ if (typeof entry === 'string') return entry;
123
+ if (entry && typeof entry === 'object') {
124
+ return entry.name ?? entry.source ?? JSON.stringify(entry);
125
+ }
126
+ return String(entry);
127
+ }
128
+
129
+ // Walk a single resource kind (skills/commands/agents) for a plugin.
130
+ // Returns { errors[], records[] } where records are { kind, name, file, plugin }.
131
+ function checkResourceKind(plugin, pluginJson, kind, pluginRoot) {
132
+ const errors = [];
133
+ const records = [];
134
+ const entries = pluginJson[kind];
135
+ if (!Array.isArray(entries)) return { errors, records };
136
+
137
+ for (const entry of entries) {
138
+ const abs = entryPath(pluginRoot, entry);
139
+ if (!abs) {
140
+ errors.push(`[${plugin}] ${kind} entry has neither a string path nor a {source} field: ${entryLabel(entry)}`);
141
+ continue;
142
+ }
143
+
144
+ let stat;
145
+ try {
146
+ stat = statSync(abs);
147
+ } catch {
148
+ errors.push(`[${plugin}] ${kind} entry points to a missing file: ${entryLabel(entry)} (${abs})`);
149
+ continue;
150
+ }
151
+
152
+ // If it's a directory, look for either SKILL.md / COMMAND.md / AGENT.md
153
+ // or any single .md file inside. We treat each found .md as one record.
154
+ const files = stat.isDirectory() ? collectMarkdownInDir(abs) : [abs];
155
+ if (files.length === 0) {
156
+ errors.push(`[${plugin}] ${kind} entry directory contains no markdown: ${abs}`);
157
+ continue;
158
+ }
159
+
160
+ for (const file of files) {
161
+ const raw = readFileSync(file, 'utf8');
162
+ const { frontmatter } = parseFrontmatter(raw);
163
+ if (!frontmatter) {
164
+ errors.push(`[${plugin}] ${kind} file is missing YAML frontmatter: ${relative(pluginRoot, file)}`);
165
+ continue;
166
+ }
167
+ for (const required of REQUIRED_FRONTMATTER) {
168
+ if (!frontmatter[required] || frontmatter[required].trim() === '') {
169
+ errors.push(
170
+ `[${plugin}] ${kind} file is missing required frontmatter field "${required}": ${relative(pluginRoot, file)}`,
171
+ );
172
+ }
173
+ }
174
+ records.push({
175
+ kind,
176
+ plugin,
177
+ name: frontmatter.name ?? entryLabel(entry),
178
+ file,
179
+ });
180
+ }
181
+ }
182
+
183
+ return { errors, records };
184
+ }
185
+
186
+ function collectMarkdownInDir(dir) {
187
+ const out = [];
188
+ const stack = [dir];
189
+ while (stack.length) {
190
+ const cur = stack.pop();
191
+ let items;
192
+ try {
193
+ items = readdirSync(cur, { withFileTypes: true });
194
+ } catch {
195
+ continue;
196
+ }
197
+ for (const item of items) {
198
+ const p = join(cur, item.name);
199
+ if (item.isDirectory()) stack.push(p);
200
+ else if (item.isFile() && p.toLowerCase().endsWith('.md')) out.push(p);
201
+ }
202
+ }
203
+ return out.sort();
204
+ }
205
+
206
+ // Scan a markdown body for slash-command references. Returns an array of
207
+ // `{ name, line }` for everything that looks like a slash command.
208
+ function findSlashCommands(body) {
209
+ const refs = [];
210
+ const lines = body.split(/\r?\n/);
211
+ // A slash command is /name where name is lowercase + digits + hyphen.
212
+ // We require a non-word char (or start-of-string) before the `/` so URLs,
213
+ // regex literals, and option flags don't match.
214
+ const rx = /(^|[\s(`'"])\/([a-z][a-z0-9-]{1,40})\b/g;
215
+ for (let i = 0; i < lines.length; i++) {
216
+ const line = lines[i];
217
+ // Skip fenced code blocks and inline code: an exhaustive parser is
218
+ // overkill, but we strip backtick-enclosed runs first to cut down on
219
+ // the most common false positives.
220
+ const stripped = line.replace(/`[^`]*`/g, '');
221
+ rx.lastIndex = 0;
222
+ let m = rx.exec(stripped);
223
+ while (m !== null) {
224
+ refs.push({ name: m[2], line: i + 1 });
225
+ m = rx.exec(stripped);
226
+ }
227
+ }
228
+ return refs;
229
+ }
230
+
231
+ function main() {
232
+ const args = parseArgs(process.argv);
233
+ const here = dirname(fileURLToPath(import.meta.url));
234
+ const root = args.root ? resolve(args.root) : findRepoRoot(here);
235
+
236
+ const marketplacePath = join(root, '.claude-plugin', 'marketplace.json');
237
+ let marketplace;
238
+ try {
239
+ marketplace = JSON.parse(readFileSync(marketplacePath, 'utf8'));
240
+ } catch (err) {
241
+ process.stderr.write(`could not read ${marketplacePath}: ${err.message}\n`);
242
+ process.exit(2);
243
+ }
244
+
245
+ const plugins = (marketplace.plugins ?? []).filter((p) => args.plugins.length === 0 || args.plugins.includes(p.name));
246
+ if (plugins.length === 0) {
247
+ process.stderr.write('no plugins matched\n');
248
+ process.exit(2);
249
+ }
250
+
251
+ const errors = [];
252
+ const warnings = [];
253
+ const allRecords = [];
254
+ const commandRegistry = new Set(BUILTIN_COMMANDS);
255
+
256
+ for (const plugin of plugins) {
257
+ const pluginRoot = resolve(root, plugin.source ?? '.');
258
+ const pluginJsonPath = join(pluginRoot, '.claude-plugin', 'plugin.json');
259
+ let pluginJson;
260
+ try {
261
+ pluginJson = JSON.parse(readFileSync(pluginJsonPath, 'utf8'));
262
+ } catch (err) {
263
+ errors.push(`[${plugin.name}] cannot read plugin.json: ${err.message}`);
264
+ continue;
265
+ }
266
+
267
+ for (const kind of ['skills', 'commands', 'agents']) {
268
+ const { errors: kindErrors, records } = checkResourceKind(plugin.name, pluginJson, kind, pluginRoot);
269
+ errors.push(...kindErrors);
270
+ allRecords.push(...records);
271
+ if (kind === 'commands') {
272
+ for (const rec of records) commandRegistry.add(rec.name);
273
+ }
274
+ }
275
+ }
276
+
277
+ // Pass 2: cross-reference body slash commands.
278
+ for (const rec of allRecords) {
279
+ const raw = readFileSync(rec.file, 'utf8');
280
+ const { body } = parseFrontmatter(raw);
281
+ const refs = findSlashCommands(body ?? raw);
282
+ for (const r of refs) {
283
+ if (!commandRegistry.has(r.name)) {
284
+ warnings.push(
285
+ `[${rec.plugin}] ${rec.kind}/${rec.name} references unknown command "/${r.name}" at line ${r.line} of ${relative(root, rec.file)}`,
286
+ );
287
+ }
288
+ }
289
+ }
290
+
291
+ if (args.printRegistry) {
292
+ process.stderr.write(`registry: ${JSON.stringify([...commandRegistry].sort())}\n`);
293
+ process.stderr.write(`records:\n`);
294
+ for (const r of allRecords) {
295
+ process.stderr.write(` [${r.plugin}] ${r.kind}/${r.name} -> ${relative(root, r.file)}\n`);
296
+ }
297
+ }
298
+
299
+ for (const e of errors) process.stderr.write(`error: ${e}\n`);
300
+ for (const w of warnings) process.stderr.write(`warn: ${w}\n`);
301
+
302
+ const failing = errors.length > 0 || (args.strict && warnings.length > 0);
303
+ if (failing) {
304
+ process.stderr.write(`check-references: ${errors.length} error(s), ${warnings.length} warning(s)\n`);
305
+ process.exit(1);
306
+ }
307
+
308
+ process.stdout.write(`check-references: ok (${allRecords.length} records, ${warnings.length} warning(s))\n`);
309
+ }
310
+
311
+ main();
@@ -0,0 +1,15 @@
1
+ ---
2
+ description: List all prompt rules (global + project) with their pattern, guidance, and per-session fire status. Use when asked which prompt rules are active, why a rule did or didn't fire, or to debug the prompt-rule-injector hook.
3
+ disable-model-invocation: true
4
+ allowed-tools: Bash(bash *)
5
+ ---
6
+
7
+ # List prompt rules
8
+
9
+ ```!
10
+ bash -c '
11
+ source "${CLAUDE_PLUGIN_ROOT}/scripts/lib/validate-path.sh"
12
+ source "${CLAUDE_PLUGIN_ROOT}/scripts/lib/prompt-rules.sh"
13
+ prompt_rules_list_table "${CLAUDE_SESSION_ID}" "$(pwd)"
14
+ '
15
+ ```
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env bats
2
+ # Verifies the on-disk plugin manifests for archivist are wired up correctly.
3
+
4
+ setup_file() {
5
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
6
+ }
7
+
8
+ @test "archivist plugin.json is valid JSON with name and version" {
9
+ run jq -e '.name == "archivist" and (.version | length > 0)' \
10
+ "${REPO_ROOT}/plugins/archivist/.claude-plugin/plugin.json"
11
+ [ "$status" -eq 0 ]
12
+ }
13
+
14
+ @test "archivist config.json has plugin_name and archivist.enabled defaulting to false" {
15
+ run jq -e '.plugin_name == "archivist" and .archivist.enabled == false' \
16
+ "${REPO_ROOT}/plugins/archivist/config.json"
17
+ [ "$status" -eq 0 ]
18
+ }
19
+
20
+ @test "marketplace.json lists ecosystem first and archivist second" {
21
+ run jq -e '.plugins[0].name == "ecosystem" and .plugins[1].name == "archivist"' \
22
+ "${REPO_ROOT}/.claude-plugin/marketplace.json"
23
+ [ "$status" -eq 0 ]
24
+ }
25
+
26
+ @test "marketplace.json plugin entries omit version (claude reads version from plugin.json)" {
27
+ # See https://code.claude.com/docs/en/plugins-reference.md#version-management:
28
+ # plugin.json's version is the cache key. Setting it in both locations is a
29
+ # documented drift hazard.
30
+ run jq -e 'all(.plugins[]; has("version") | not)' "${REPO_ROOT}/.claude-plugin/marketplace.json"
31
+ [ "$status" -eq 0 ]
32
+ }
33
+
34
+ @test "release-please-manifest.json tracks plugins/archivist" {
35
+ run jq -e '.["plugins/archivist"]' "${REPO_ROOT}/.release-please-manifest.json"
36
+ [ "$status" -eq 0 ]
37
+ }
38
+
39
+ @test "release-please-config.json declares plugins/archivist as a package" {
40
+ run jq -e '.packages["plugins/archivist"] | type == "object"' \
41
+ "${REPO_ROOT}/release-please-config.json"
42
+ [ "$status" -eq 0 ]
43
+ }
44
+
45
+ @test "archivist hooks.json wires PreCompact manual+auto and SessionStart" {
46
+ local f="${REPO_ROOT}/plugins/archivist/hooks/hooks.json"
47
+ run jq -e '
48
+ (.hooks.PreCompact | length) == 2 and
49
+ .hooks.PreCompact[0].matcher == "manual" and
50
+ .hooks.PreCompact[1].matcher == "auto" and
51
+ .hooks.SessionStart[0].matcher == "*"
52
+ ' "$f"
53
+ [ "$status" -eq 0 ]
54
+ }
55
+
56
+ @test "archivist hook scripts are executable" {
57
+ for script in archivist-extract.sh archivist-inject.sh; do
58
+ test -x "${REPO_ROOT}/plugins/archivist/scripts/hooks/$script"
59
+ done
60
+ }