@ghl-ai/aw 0.1.70-beta.1 → 0.1.70-beta.3

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.
@@ -12,7 +12,7 @@
12
12
  *
13
13
  * 2. enabledPlugins["aw@aw-marketplace"] = true
14
14
  * Activates the plugin (without this Claude knows about the marketplace
15
- * but does not surface its routing hooks).
15
+ * but does not surface its commands/skills).
16
16
  *
17
17
  * 3. permissions.allow includes "Skill"
18
18
  * Without this Claude refuses to dispatch the Skill tool. Append-only
@@ -114,8 +114,8 @@ function ensureSkillPermission(root) {
114
114
  }
115
115
 
116
116
  /**
117
- * Ensure Claude is wired up to the AW plugin marketplace + Skill permission.
118
- * Active AW skill bodies come from ~/.aw/.aw_registry/aw/skills.
117
+ * Ensure Claude is wired up to surface ECC's `aw:<stage>` skills via plugin
118
+ * marketplace + Skill permission.
119
119
  *
120
120
  * @param {string} home User home (e.g. tmp dir in tests).
121
121
  * @param {string} eccDir Path to the ECC plugin directory (~/.aw-ecc).
@@ -1,21 +1,30 @@
1
1
  /**
2
- * c4/commandSurface.mjs — slash-command resolution per harness.
2
+ * c4/commandSurface.mjs — slash-command resolution per harness (G3).
3
3
  *
4
- * `/aw:*` now resolves to AW skills directly. The active source of truth is
5
- * `~/.aw/.aw_registry/aw/skills/<skill>/SKILL.md`; command markdown from
6
- * aw-ecc is intentionally not installed into the cloud slash surface.
4
+ * Why: intent-based routing fires via the slim card / SessionStart hook,
5
+ * but a user typing `/aw:plan` directly relies on a per-harness slash
6
+ * command surface. Without these symlinks (or, on Claude, the plugin
7
+ * marketplace registration), `/aw:plan` errors out and the AC for
8
+ * "manual command works" fails.
7
9
  *
8
- * Naming convention:
9
- * ~/.cursor/commands/aw/<name>.md -> /aw:<name>
10
- * ~/.codex/commands/aw/<name>.md -> /aw:<name>
10
+ * Naming convention (verified, L4): ECC ships UN-PREFIXED command files
11
+ * (`plan.md`, `build.md`, …). Slash namespacing is by SUBDIRECTORY:
12
+ * ~/.cursor/commands/aw/<name>.md /aw:<name>
13
+ * ~/.codex/commands/aw/<name>.md → /aw:<name>
14
+ * The legacy `~/.cursor/commands/aw-<name>.md` shape is NOT what the
15
+ * pilot transcripts produced and is not what we install.
11
16
  *
12
- * The adapter file is a symlink to the skill's `SKILL.md`:
13
- * aw-plan/SKILL.md -> /aw:plan
14
- * grill-with-docs/SKILL.md -> /aw:grill-with-docs
17
+ * Per-harness behavior:
18
+ * - claude-web: 'noop' — plugin marketplace (registered separately by
19
+ * claudePluginRegistry.mjs) exposes the ECC commands directly.
20
+ * - cursor-cloud / codex-web: 'symlink' — link each ECC command into
21
+ * the harness command dir, replacing stale symlinks idempotently.
15
22
  *
16
- * The `aw-` prefix is stripped only for slash aliases so skill directory
17
- * names remain stable while user-facing routes stay `/aw:plan`, `/aw:build`,
18
- * etc. Non-prefixed skill names are exposed unchanged.
23
+ * `expectedCommands` is derived at runtime by globbing
24
+ * `<eccHome>/commands/*.md` and filtering to the AW routing-stage list.
25
+ * No hardcoded array drives the install; ECC is the source of truth.
26
+ *
27
+ * Contract: spec.md::§"c4/commandSurface.mjs", tasks.md::3.7.
19
28
  */
20
29
 
21
30
  import {
@@ -27,48 +36,45 @@ import {
27
36
  symlinkSync,
28
37
  unlinkSync,
29
38
  } from 'node:fs';
30
- import { join } from 'node:path';
31
-
32
- function awSkillsDir(awHome) {
33
- return join(awHome, '.aw_registry', 'aw', 'skills');
34
- }
39
+ import { join, basename, extname, relative } from 'node:path';
35
40
 
36
- function slashNameForSkill(skillName) {
37
- return skillName.startsWith('aw-') && skillName.length > 3
38
- ? skillName.slice(3)
39
- : skillName;
40
- }
41
+ // Canonical AW routing-stage commands. Anything in <eccHome>/commands/*.md
42
+ // not on this list is treated as "not a slash-command" (e.g. README.md).
43
+ // This list is a filter applied AFTER globbing; it is not a source of truth
44
+ // for what gets installed — only what we recognize as a stage.
45
+ const AW_STAGE_COMMANDS = new Set([
46
+ 'plan',
47
+ 'build',
48
+ 'investigate',
49
+ 'review',
50
+ 'test',
51
+ 'deploy',
52
+ 'ship',
53
+ 'feature',
54
+ 'adk',
55
+ 'publish',
56
+ ]);
41
57
 
42
58
  /**
43
- * Discover active AW skills and their slash aliases.
59
+ * Walk <eccHome>/commands/*.md and return the AW-stage basenames in
60
+ * alphabetical order. Stage filename matching is case-sensitive (ECC ships
61
+ * lowercase).
44
62
  *
45
- * @param {string} awHome
46
- * @returns {Array<{ skillName: string, slashName: string, sourcePath: string }>}
63
+ * @param {string} eccHome
64
+ * @returns {string[]}
47
65
  */
48
- function discoverAwSkillRoutes(awHome) {
49
- const skillsDir = awSkillsDir(awHome);
50
- if (!existsSync(skillsDir)) return [];
51
-
52
- const routes = new Map();
53
- for (const entry of safeReaddir(skillsDir)) {
54
- if (entry.startsWith('.')) continue;
55
- const skillDir = join(skillsDir, entry);
56
- if (!isDir(skillDir)) continue;
57
- const sourcePath = join(skillDir, 'SKILL.md');
58
- if (!existsSync(sourcePath)) continue;
59
-
60
- const slashName = slashNameForSkill(entry);
61
- const existing = routes.get(slashName);
62
- const candidate = { skillName: entry, slashName, sourcePath };
63
- if (!existing || (entry.startsWith('aw-') && !existing.skillName.startsWith('aw-'))) {
64
- routes.set(slashName, candidate);
65
- }
66
+ function discoverEccStageCommands(eccHome) {
67
+ const commandsDir = join(eccHome, 'commands');
68
+ if (!existsSync(commandsDir)) return [];
69
+ const entries = readdirSync(commandsDir);
70
+ const stageNames = [];
71
+ for (const entry of entries) {
72
+ if (extname(entry).toLowerCase() !== '.md') continue;
73
+ const name = basename(entry, '.md');
74
+ if (AW_STAGE_COMMANDS.has(name)) stageNames.push(name);
66
75
  }
67
-
68
- return [...routes.values()].sort((a, b) => {
69
- const bySlash = a.slashName.localeCompare(b.slashName);
70
- return bySlash === 0 ? a.skillName.localeCompare(b.skillName) : bySlash;
71
- });
76
+ stageNames.sort();
77
+ return stageNames;
72
78
  }
73
79
 
74
80
  function harnessTargetDir(harness, home) {
@@ -93,40 +99,29 @@ function isCorrectSymlink(linkPath, expectedTarget) {
93
99
  }
94
100
  }
95
101
 
96
- function lstatExists(p) {
102
+ /**
103
+ * Symlink one stage command from ECC into the harness command directory,
104
+ * replacing a stale symlink if needed. Returns true on success.
105
+ */
106
+ function linkStageCommand(stageName, eccHome, targetDir) {
107
+ const sourcePath = join(eccHome, 'commands', `${stageName}.md`);
108
+ const linkPath = join(targetDir, `${stageName}.md`);
109
+ if (isCorrectSymlink(linkPath, sourcePath)) return true;
110
+ // Replace any stale symlink or wrong-type entry.
97
111
  try {
98
- lstatSync(p);
112
+ if (existsSync(linkPath) || lstatExists(linkPath)) {
113
+ try { unlinkSync(linkPath); } catch { /* may be a directory */ }
114
+ }
115
+ symlinkSync(sourcePath, linkPath);
99
116
  return true;
100
117
  } catch {
101
118
  return false;
102
119
  }
103
120
  }
104
121
 
105
- function isDir(p) {
106
- try {
107
- return lstatSync(p).isDirectory();
108
- } catch {
109
- return false;
110
- }
111
- }
112
-
113
- function safeReaddir(dir) {
114
- try {
115
- return readdirSync(dir);
116
- } catch {
117
- return [];
118
- }
119
- }
120
-
121
- function linkSkillRoute(route, targetDir) {
122
- const linkPath = join(targetDir, `${route.slashName}.md`);
123
- if (isCorrectSymlink(linkPath, route.sourcePath)) return true;
124
-
122
+ function lstatExists(p) {
125
123
  try {
126
- if (existsSync(linkPath) || lstatExists(linkPath)) {
127
- try { unlinkSync(linkPath); } catch { /* may be a directory */ }
128
- }
129
- symlinkSync(route.sourcePath, linkPath);
124
+ lstatSync(p);
130
125
  return true;
131
126
  } catch {
132
127
  return false;
@@ -134,16 +129,15 @@ function linkSkillRoute(route, targetDir) {
134
129
  }
135
130
 
136
131
  /**
137
- * Ensure the harness slash surface for active AW skills.
132
+ * Ensure the harness slash-command surface for AW stages.
138
133
  *
139
134
  * @param {object} opts
140
135
  * @param {'claude-web'|'cursor-cloud'|'codex-web'|string} opts.harness
141
136
  * @param {string} opts.home
142
- * @param {string} opts.awHome
137
+ * @param {string} opts.eccHome
143
138
  * @returns {{
144
139
  * harness: string,
145
140
  * expectedCommands: string[],
146
- * expectedSkills: string[],
147
141
  * found: string[],
148
142
  * missing: string[],
149
143
  * installedAction: 'symlink' | 'noop' | 'unsupported',
@@ -153,23 +147,23 @@ export function ensureCommandSurface(opts) {
153
147
  if (!opts || typeof opts !== 'object') {
154
148
  throw new Error('ensureCommandSurface: opts object is required');
155
149
  }
156
- const { harness, home, awHome } = opts;
150
+ const { harness, home, eccHome } = opts;
157
151
  if (!home || typeof home !== 'string') {
158
152
  throw new Error('ensureCommandSurface: opts.home is required');
159
153
  }
160
- if (!awHome || typeof awHome !== 'string') {
161
- throw new Error('ensureCommandSurface: opts.awHome is required');
154
+ if (!eccHome || typeof eccHome !== 'string') {
155
+ throw new Error('ensureCommandSurface: opts.eccHome is required');
162
156
  }
163
157
 
164
- const routes = discoverAwSkillRoutes(awHome);
165
- const expectedCommands = routes.map((route) => route.slashName);
166
- const expectedSkills = routes.map((route) => route.skillName);
158
+ const expectedCommands = discoverEccStageCommands(eccHome);
167
159
 
168
160
  if (harness === 'claude-web') {
161
+ // Plugin marketplace (handled by claudePluginRegistry) exposes the same
162
+ // commands. We only verify resolution; a separate per-repo resolver will
163
+ // catch missing-marketplace cases via dumpPostInitState.
169
164
  return {
170
165
  harness,
171
166
  expectedCommands,
172
- expectedSkills,
173
167
  found: [...expectedCommands],
174
168
  missing: [],
175
169
  installedAction: 'noop',
@@ -181,31 +175,38 @@ export function ensureCommandSurface(opts) {
181
175
  return {
182
176
  harness,
183
177
  expectedCommands,
184
- expectedSkills,
185
178
  found: [],
186
179
  missing: [],
187
180
  installedAction: 'unsupported',
188
181
  };
189
182
  }
190
183
 
184
+ // Best-effort directory creation. If the parent path is blocked (e.g. a
185
+ // file already occupies the directory location), we cannot symlink and
186
+ // every command will surface as missing.
191
187
  try {
192
188
  mkdirSync(targetDir, { recursive: true });
193
189
  } catch {
194
190
  return {
195
191
  harness,
196
192
  expectedCommands,
197
- expectedSkills,
198
193
  found: [],
199
194
  missing: [...expectedCommands],
200
195
  installedAction: 'symlink',
201
196
  };
202
197
  }
203
-
204
- if (!isDir(targetDir)) {
198
+ // mkdirSync(recursive:true) is a no-op if a non-directory file exists at
199
+ // the target; we must verify the target is actually a directory now.
200
+ let isDir = false;
201
+ try {
202
+ isDir = lstatSync(targetDir).isDirectory();
203
+ } catch {
204
+ isDir = false;
205
+ }
206
+ if (!isDir) {
205
207
  return {
206
208
  harness,
207
209
  expectedCommands,
208
- expectedSkills,
209
210
  found: [],
210
211
  missing: [...expectedCommands],
211
212
  installedAction: 'symlink',
@@ -214,16 +215,15 @@ export function ensureCommandSurface(opts) {
214
215
 
215
216
  const found = [];
216
217
  const missing = [];
217
- for (const route of routes) {
218
- const ok = linkSkillRoute(route, targetDir);
219
- if (ok) found.push(route.slashName);
220
- else missing.push(route.slashName);
218
+ for (const stageName of expectedCommands) {
219
+ const ok = linkStageCommand(stageName, eccHome, targetDir);
220
+ if (ok) found.push(stageName);
221
+ else missing.push(stageName);
221
222
  }
222
223
 
223
224
  return {
224
225
  harness,
225
226
  expectedCommands,
226
- expectedSkills,
227
227
  found,
228
228
  missing,
229
229
  installedAction: 'symlink',
@@ -231,59 +231,161 @@ export function ensureCommandSurface(opts) {
231
231
  }
232
232
 
233
233
  /**
234
- * Read-only diagnostic. For Cursor/Codex, verifies that slash adapter files
235
- * point to the active AW skill `SKILL.md` files. Claude is skill/plugin
236
- * routed and does not require adapter files.
234
+ * Link ALL registry commands from ~/.aw/.aw_registry into the harness
235
+ * command directory not just the 10 stage commands.
236
+ *
237
+ * `ensureCommandSurface` only links ECC stage commands (plan, build, etc.).
238
+ * The full registry (synced by `aw init`) contains 100+ domain-specific
239
+ * commands (e.g. platform-review-security-hardening) that were invisible
240
+ * in cloud harnesses because nothing linked them.
241
+ *
242
+ * This function mirrors the logic in link.mjs::linkWorkspace's command
243
+ * section but is callable from the c4 orchestrator without importing the
244
+ * full link surface.
245
+ *
246
+ * @param {object} opts
247
+ * @param {'claude-web'|'cursor-cloud'|'codex-web'|string} opts.harness
248
+ * @param {string} opts.home
249
+ * @param {string} opts.awRegistryDir e.g. ~/.aw/.aw_registry
250
+ * @returns {{ linked: number, skipped: number, harness: string }}
251
+ */
252
+ export function ensureRegistryCommandSurface(opts) {
253
+ if (!opts || typeof opts !== 'object') {
254
+ throw new Error('ensureRegistryCommandSurface: opts object is required');
255
+ }
256
+ const { harness, home, awRegistryDir } = opts;
257
+
258
+ if (harness === 'claude-web') {
259
+ return { linked: 0, skipped: 0, harness, installedAction: 'noop' };
260
+ }
261
+
262
+ const targetDir = harnessTargetDir(harness, home);
263
+ if (!targetDir || !existsSync(awRegistryDir)) {
264
+ return { linked: 0, skipped: 0, harness, installedAction: 'unsupported' };
265
+ }
266
+
267
+ try {
268
+ mkdirSync(targetDir, { recursive: true });
269
+ } catch {
270
+ return { linked: 0, skipped: 0, harness, installedAction: 'symlink' };
271
+ }
272
+
273
+ let linked = 0;
274
+ let skipped = 0;
275
+
276
+ const namespaces = safeReaddir(awRegistryDir).filter(
277
+ (d) => !d.startsWith('.') && isDir(join(awRegistryDir, d)),
278
+ );
279
+
280
+ for (const ns of namespaces) {
281
+ for (const { dir: commandsDir, segments } of findCommandDirs(join(awRegistryDir, ns))) {
282
+ for (const file of safeReaddir(commandsDir).filter((f) => f.endsWith('.md') && !f.startsWith('.'))) {
283
+ const cmdFileName = [ns, ...segments, file].join('-');
284
+ const sourcePath = join(commandsDir, file);
285
+ const linkPath = join(targetDir, cmdFileName);
286
+
287
+ if (isCorrectSymlink(linkPath, sourcePath)) {
288
+ skipped++;
289
+ continue;
290
+ }
291
+
292
+ try {
293
+ if (existsSync(linkPath) || lstatExists(linkPath)) {
294
+ try { unlinkSync(linkPath); } catch { /* stale entry */ }
295
+ }
296
+ symlinkSync(sourcePath, linkPath);
297
+ linked++;
298
+ } catch {
299
+ skipped++;
300
+ }
301
+ }
302
+ }
303
+ }
304
+
305
+ return { linked, skipped, harness, installedAction: 'symlink' };
306
+ }
307
+
308
+ function safeReaddir(dir) {
309
+ try { return readdirSync(dir); } catch { return []; }
310
+ }
311
+
312
+ function isDir(p) {
313
+ try { return lstatSync(p).isDirectory(); } catch { return false; }
314
+ }
315
+
316
+ /**
317
+ * Recursively find `commands/` directories under a namespace dir.
318
+ * Supports nested domain dirs (e.g. platform/review/commands/).
319
+ */
320
+ function findCommandDirs(nsDir, segments = []) {
321
+ if (segments.includes('evals')) return [];
322
+
323
+ const results = [];
324
+ const commandsDir = join(nsDir, 'commands');
325
+ if (existsSync(commandsDir) && isDir(commandsDir)) {
326
+ results.push({ dir: commandsDir, segments });
327
+ }
328
+ for (const entry of safeReaddir(nsDir)) {
329
+ if (entry === 'commands' || entry === 'evals' || entry.startsWith('.')) continue;
330
+ const sub = join(nsDir, entry);
331
+ if (isDir(sub)) {
332
+ results.push(...findCommandDirs(sub, [...segments, entry]));
333
+ }
334
+ }
335
+ return results;
336
+ }
337
+
338
+ /**
339
+ * Read-only diagnostic. Walks the harness command directory and reports
340
+ * which AW stage commands are resolvable. Does not fix.
237
341
  *
238
342
  * @param {object} opts
239
343
  * @param {'claude-web'|'cursor-cloud'|'codex-web'|string} opts.harness
240
344
  * @param {string} opts.home
241
- * @param {string} [opts.awHome]
242
345
  * @returns {{ expected: string[], found: string[], missing: string[], ok: boolean }}
243
346
  */
244
347
  export function diagnoseCommandResolution(opts) {
245
348
  if (!opts || typeof opts !== 'object') {
246
349
  throw new Error('diagnoseCommandResolution: opts object is required');
247
350
  }
248
- const { harness, home, awHome } = opts;
351
+ const { harness, home } = opts;
249
352
  if (!home || typeof home !== 'string') {
250
353
  throw new Error('diagnoseCommandResolution: opts.home is required');
251
354
  }
252
-
253
- const routes = awHome && typeof awHome === 'string' ? discoverAwSkillRoutes(awHome) : [];
254
- const expected = routes.map((route) => route.slashName);
355
+ const expected = [...AW_STAGE_COMMANDS].sort();
255
356
 
256
357
  if (harness === 'claude-web') {
257
- return { expected, found: [...expected], missing: [], ok: true };
358
+ // Plugin marketplace path. If a `~/.claude/commands` dir exists we
359
+ // honor it; otherwise we trust marketplace dispatch and report ok.
360
+ const claudeDir = join(home, '.claude/commands');
361
+ if (!existsSync(claudeDir)) {
362
+ return { expected, found: [], missing: [], ok: true };
363
+ }
364
+ return walkCommandsDir(claudeDir, expected);
258
365
  }
259
366
 
260
367
  const targetDir = harnessTargetDir(harness, home);
261
368
  if (!targetDir) {
262
369
  return { expected, found: [], missing: [...expected], ok: false };
263
370
  }
264
- return walkSkillAdapterDir(targetDir, routes);
371
+ return walkCommandsDir(targetDir, expected);
265
372
  }
266
373
 
267
- function walkSkillAdapterDir(dir, routes) {
374
+ function walkCommandsDir(dir, expected) {
268
375
  if (!existsSync(dir)) {
269
- const expected = routes.map((route) => route.slashName);
270
376
  return { expected, found: [], missing: [...expected], ok: expected.length === 0 };
271
377
  }
272
-
273
- const found = [];
274
- const missing = [];
275
- for (const route of routes) {
276
- const linkPath = join(dir, `${route.slashName}.md`);
277
- if (isCorrectSymlink(linkPath, route.sourcePath)) found.push(route.slashName);
278
- else missing.push(route.slashName);
378
+ const present = new Set();
379
+ try {
380
+ for (const entry of readdirSync(dir)) {
381
+ if (extname(entry).toLowerCase() !== '.md') continue;
382
+ const name = basename(entry, '.md');
383
+ if (AW_STAGE_COMMANDS.has(name)) present.add(name);
384
+ }
385
+ } catch {
386
+ // Unreadable directory: treat as nothing-found.
279
387
  }
280
-
281
- const expected = routes.map((route) => route.slashName);
388
+ const found = expected.filter((n) => present.has(n));
389
+ const missing = expected.filter((n) => !present.has(n));
282
390
  return { expected, found, missing, ok: missing.length === 0 };
283
391
  }
284
-
285
- export const __test__ = {
286
- awSkillsDir,
287
- discoverAwSkillRoutes,
288
- slashNameForSkill,
289
- };
@@ -4,12 +4,12 @@
4
4
  * Why: Cursor Cloud Agent's chat UI does not pre-expand `/aw:<NAME>` slash
5
5
  * commands the way Cursor Desktop's plugin does. The model sees the raw
6
6
  * literal `/aw:...` and falls through to natural-language interpretation,
7
- * losing the contract from the matching AW skill even though the slash
8
- * adapter is correctly installed on disk by `commandSurface.mjs`.
7
+ * losing the contract from the matching command file even though the file
8
+ * is correctly installed on disk by `commandSurface.mjs`.
9
9
  *
10
10
  * Workaround: write `<repoRoot>/.cursor/rules/aw-slash-expand.mdc` — a
11
11
  * Cursor project rule that teaches the MODEL itself to read the matching
12
- * AW skill file and execute its contract verbatim. Cursor's `.mdc` rule
12
+ * command file and execute its contract verbatim. Cursor's `.mdc` rule
13
13
  * format is honored in Agent mode (verified via Cursor docs); the rule
14
14
  * loads on every session when `alwaysApply: true`.
15
15
  *
@@ -47,35 +47,56 @@ const CURSOR_RULES_SUBPATH = '.cursor/rules';
47
47
  * via npm — no additional asset bundling needed.
48
48
  */
49
49
  export const CURSOR_SLASH_SHIM_RULE = `---
50
- description: Expand \`/aw:<NAME>\` user messages by reading the matching AW skill file
50
+ description: Expand \`/aw:<NAME>\` user messages by reading the matching command file
51
51
  alwaysApply: true
52
52
  ---
53
53
 
54
- # AW skill slash expansion (Cursor Cloud workaround)
54
+ # AW slash-command expansion (Cursor Cloud workaround)
55
55
 
56
56
  When the user message starts with \`/aw:<NAME>\` (case-sensitive; \`<NAME>\` may
57
- contain hyphens), do the following BEFORE producing any other response.
57
+ contain hyphens both in the namespace path and in the command name), do
58
+ the following BEFORE producing any other response.
58
59
 
59
- ## 1. Skill adapter lookup (primary)
60
+ ## 1. Stage-command lookup (primary)
60
61
 
61
62
  Read \`~/.cursor/commands/aw/<NAME>.md\`. If the file exists, jump to step 3
62
63
  with its content as the resolved instructions.
63
64
 
64
- ## 2. Registry skill lookup (fallback)
65
+ ## 2. Registry-command lookup (fallback, hyphen-disambiguation)
65
66
 
66
- If step 1 did not resolve, read the first candidate below that exists. The
67
+ If step 1 did not resolve and \`<NAME>\` contains at least one hyphen, the
68
+ slash form is a flattened \`<ns_segments...>-<cmd_segments...>\` token. The
69
+ boundary between namespace and command is ambiguous because both can
70
+ contain hyphens (registry has e.g. \`pr-review\`, \`capture-learning\`,
71
+ \`ship-auto\`, \`draw-architecture\`).
72
+
73
+ Disambiguate by **iterating every hyphen position in \`<NAME>\` from
74
+ RIGHTMOST to LEFTMOST**, and use the first candidate file that exists. The
67
75
  repo registry root is \`.aw/.aw_registry/\` (relative to the agent's CWD —
68
76
  the repo root in Cursor Cloud).
69
77
 
70
- 1. \`.aw/.aw_registry/aw/skills/aw-<NAME>/SKILL.md\`
71
- 2. \`.aw/.aw_registry/aw/skills/<NAME>/SKILL.md\`
78
+ For each split position \`i\` from rightmost to leftmost:
79
+ - \`prefix = <NAME>[:i]\` (everything before the hyphen at position \`i\`)
80
+ - \`suffix = <NAME>[i+1:]\` (everything after; may itself contain hyphens)
81
+ - candidate = \`.aw/.aw_registry/<prefix-with-hyphens-replaced-by-slashes>/commands/<suffix>.md\`
82
+ - Try \`Read\` on the candidate. If the file exists, jump to step 3 with
83
+ that content.
84
+
85
+ ### Worked examples
86
+
87
+ | User typed | Registry file | Splits tried (rightmost first) |
88
+ |----------------------------------------------------|------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------|
89
+ | \`/aw:platform-core-brainstorm\` | \`platform/core/commands/brainstorm.md\` | \`platform/core/commands/brainstorm.md\` ✓ (first try) |
90
+ | \`/aw:platform-core-pr-review\` | \`platform/core/commands/pr-review.md\` | \`platform/core/pr/commands/review.md\` ❌ → \`platform/core/commands/pr-review.md\` ✓ |
91
+ | \`/aw:platform-core-capture-learning\` | \`platform/core/commands/capture-learning.md\` | \`platform/core/capture/commands/learning.md\` ❌ → \`platform/core/commands/capture-learning.md\` ✓ |
92
+ | \`/aw:platform-data-clickhouse-cluster-creation\` | \`platform/data/clickhouse/commands/cluster-creation.md\` | \`…/cluster/commands/creation.md\` ❌ → \`platform/data/clickhouse/commands/cluster-creation.md\` ✓ |
72
93
 
73
94
  ## 3. Execute
74
95
 
75
96
  Treat the resolved file's full content as your operating instructions for
76
- this turn. Execute the skill's contract on the text that follows the slash
77
- command in the user message. Do not summarize or paraphrase the skill file
78
- — execute it verbatim, including any "ask one question at a
97
+ this turn. Execute the command's phased contract on the text that follows
98
+ the slash command in the user message. Do not summarize or paraphrase the
99
+ command file — execute it verbatim, including any "ask one question at a
79
100
  time and wait" interaction protocol.
80
101
 
81
102
  ## 4. No match
@@ -83,10 +104,11 @@ time and wait" interaction protocol.
83
104
  If neither lookup resolves to an existing file, do NOT silently fall back
84
105
  to natural-language interpretation. Reply with exactly:
85
106
 
86
- > \`/aw:<NAME>\` is not a registered AW skill on this machine. Run
107
+ > \`/aw:<NAME>\` is not a registered AW command on this machine. Run
87
108
  > \`aw c4 --diagnose\` and check the output of
88
- > \`ls ~/.cursor/commands/aw/\` for available slash skill adapters and
89
- > \`find .aw/.aw_registry/aw/skills -name SKILL.md\` for available AW skills.
109
+ > \`ls ~/.cursor/commands/aw/\` for available stage commands and
110
+ > \`find .aw/.aw_registry -path '*/commands/*.md'\` for available registry
111
+ > commands.
90
112
 
91
113
  This precise reply text is the smoke-test fingerprint for the rule itself.
92
114
  The string is unique enough that it cannot be confused with the model's
@@ -95,8 +117,8 @@ natural-language fallback.
95
117
  ---
96
118
 
97
119
  This rule exists because Cursor Cloud's chat UI does not pre-expand slash
98
- commands the way Cursor Desktop does. The skill adapter files ARE installed
99
- on disk by \`aw c4\`; this rule teaches the model to load them itself. See
120
+ commands the way Cursor Desktop does. The command files ARE installed on
121
+ disk by \`aw c4\`; this rule teaches the model to load them itself. See
100
122
  \`.aw_docs/features/aw-c4-cursor-slash-shim/overview.md\` for context. Do
101
123
  not edit this file by hand — \`aw c4 --harness cursor-cloud\` regenerates
102
124
  it on every run.