@ghl-ai/aw 0.1.70-beta.2 → 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 {
@@ -24,52 +33,48 @@ import {
24
33
  mkdirSync,
25
34
  readdirSync,
26
35
  readlinkSync,
27
- rmSync,
28
36
  symlinkSync,
29
37
  unlinkSync,
30
38
  } from 'node:fs';
31
- import { join } from 'node:path';
32
-
33
- function awSkillsDir(awHome) {
34
- return join(awHome, '.aw_registry', 'aw', 'skills');
35
- }
36
-
37
- function slashNameForSkill(skillName) {
38
- return skillName.startsWith('aw-') && skillName.length > 3
39
- ? skillName.slice(3)
40
- : skillName;
41
- }
39
+ import { join, basename, extname, relative } from 'node:path';
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
+ ]);
42
57
 
43
58
  /**
44
- * 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).
45
62
  *
46
- * @param {string} awHome
47
- * @returns {Array<{ skillName: string, slashName: string, sourcePath: string }>}
63
+ * @param {string} eccHome
64
+ * @returns {string[]}
48
65
  */
49
- function discoverAwSkillRoutes(awHome) {
50
- const skillsDir = awSkillsDir(awHome);
51
- if (!existsSync(skillsDir)) return [];
52
-
53
- const routes = new Map();
54
- for (const entry of safeReaddir(skillsDir)) {
55
- if (entry.startsWith('.')) continue;
56
- const skillDir = join(skillsDir, entry);
57
- if (!isDir(skillDir)) continue;
58
- const sourcePath = join(skillDir, 'SKILL.md');
59
- if (!existsSync(sourcePath)) continue;
60
-
61
- const slashName = slashNameForSkill(entry);
62
- const existing = routes.get(slashName);
63
- const candidate = { skillName: entry, slashName, sourcePath };
64
- if (!existing || (entry.startsWith('aw-') && !existing.skillName.startsWith('aw-'))) {
65
- routes.set(slashName, candidate);
66
- }
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);
67
75
  }
68
-
69
- return [...routes.values()].sort((a, b) => {
70
- const bySlash = a.slashName.localeCompare(b.slashName);
71
- return bySlash === 0 ? a.skillName.localeCompare(b.skillName) : bySlash;
72
- });
76
+ stageNames.sort();
77
+ return stageNames;
73
78
  }
74
79
 
75
80
  function harnessTargetDir(harness, home) {
@@ -94,79 +99,47 @@ function isCorrectSymlink(linkPath, expectedTarget) {
94
99
  }
95
100
  }
96
101
 
97
- 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.
98
111
  try {
99
- lstatSync(p);
112
+ if (existsSync(linkPath) || lstatExists(linkPath)) {
113
+ try { unlinkSync(linkPath); } catch { /* may be a directory */ }
114
+ }
115
+ symlinkSync(sourcePath, linkPath);
100
116
  return true;
101
117
  } catch {
102
118
  return false;
103
119
  }
104
120
  }
105
121
 
106
- function isDir(p) {
107
- try {
108
- return lstatSync(p).isDirectory();
109
- } catch {
110
- return false;
111
- }
112
- }
113
-
114
- function safeReaddir(dir) {
115
- try {
116
- return readdirSync(dir);
117
- } catch {
118
- return [];
119
- }
120
- }
121
-
122
- function linkSkillRoute(route, targetDir) {
123
- const linkPath = join(targetDir, `${route.slashName}.md`);
124
- if (isCorrectSymlink(linkPath, route.sourcePath)) return true;
125
-
122
+ function lstatExists(p) {
126
123
  try {
127
- if (existsSync(linkPath) || lstatExists(linkPath)) {
128
- try { rmSync(linkPath, { recursive: true, force: true }); } catch { /* best effort */ }
129
- }
130
- symlinkSync(route.sourcePath, linkPath);
124
+ lstatSync(p);
131
125
  return true;
132
126
  } catch {
133
127
  return false;
134
128
  }
135
129
  }
136
130
 
137
- function pruneStaleSlashAdapters(targetDir, routes) {
138
- const expectedFiles = new Set(routes.map((route) => `${route.slashName}.md`));
139
- let pruned = 0;
140
-
141
- for (const entry of safeReaddir(targetDir)) {
142
- if (entry.startsWith('.')) continue;
143
- if (expectedFiles.has(entry)) continue;
144
-
145
- try {
146
- rmSync(join(targetDir, entry), { recursive: true, force: true });
147
- pruned++;
148
- } catch {
149
- // Best effort: stale command cleanup must not block skill adapters.
150
- }
151
- }
152
-
153
- return pruned;
154
- }
155
-
156
131
  /**
157
- * Ensure the harness slash surface for active AW skills.
132
+ * Ensure the harness slash-command surface for AW stages.
158
133
  *
159
134
  * @param {object} opts
160
135
  * @param {'claude-web'|'cursor-cloud'|'codex-web'|string} opts.harness
161
136
  * @param {string} opts.home
162
- * @param {string} opts.awHome
137
+ * @param {string} opts.eccHome
163
138
  * @returns {{
164
139
  * harness: string,
165
140
  * expectedCommands: string[],
166
- * expectedSkills: string[],
167
141
  * found: string[],
168
142
  * missing: string[],
169
- * pruned: number,
170
143
  * installedAction: 'symlink' | 'noop' | 'unsupported',
171
144
  * }}
172
145
  */
@@ -174,26 +147,25 @@ export function ensureCommandSurface(opts) {
174
147
  if (!opts || typeof opts !== 'object') {
175
148
  throw new Error('ensureCommandSurface: opts object is required');
176
149
  }
177
- const { harness, home, awHome } = opts;
150
+ const { harness, home, eccHome } = opts;
178
151
  if (!home || typeof home !== 'string') {
179
152
  throw new Error('ensureCommandSurface: opts.home is required');
180
153
  }
181
- if (!awHome || typeof awHome !== 'string') {
182
- throw new Error('ensureCommandSurface: opts.awHome is required');
154
+ if (!eccHome || typeof eccHome !== 'string') {
155
+ throw new Error('ensureCommandSurface: opts.eccHome is required');
183
156
  }
184
157
 
185
- const routes = discoverAwSkillRoutes(awHome);
186
- const expectedCommands = routes.map((route) => route.slashName);
187
- const expectedSkills = routes.map((route) => route.skillName);
158
+ const expectedCommands = discoverEccStageCommands(eccHome);
188
159
 
189
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.
190
164
  return {
191
165
  harness,
192
166
  expectedCommands,
193
- expectedSkills,
194
167
  found: [...expectedCommands],
195
168
  missing: [],
196
- pruned: 0,
197
169
  installedAction: 'noop',
198
170
  };
199
171
  }
@@ -203,114 +175,217 @@ export function ensureCommandSurface(opts) {
203
175
  return {
204
176
  harness,
205
177
  expectedCommands,
206
- expectedSkills,
207
178
  found: [],
208
179
  missing: [],
209
- pruned: 0,
210
180
  installedAction: 'unsupported',
211
181
  };
212
182
  }
213
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.
214
187
  try {
215
188
  mkdirSync(targetDir, { recursive: true });
216
189
  } catch {
217
190
  return {
218
191
  harness,
219
192
  expectedCommands,
220
- expectedSkills,
221
193
  found: [],
222
194
  missing: [...expectedCommands],
223
- pruned: 0,
224
195
  installedAction: 'symlink',
225
196
  };
226
197
  }
227
-
228
- 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) {
229
207
  return {
230
208
  harness,
231
209
  expectedCommands,
232
- expectedSkills,
233
210
  found: [],
234
211
  missing: [...expectedCommands],
235
- pruned: 0,
236
212
  installedAction: 'symlink',
237
213
  };
238
214
  }
239
215
 
240
- const pruned = pruneStaleSlashAdapters(targetDir, routes);
241
216
  const found = [];
242
217
  const missing = [];
243
- for (const route of routes) {
244
- const ok = linkSkillRoute(route, targetDir);
245
- if (ok) found.push(route.slashName);
246
- 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);
247
222
  }
248
223
 
249
224
  return {
250
225
  harness,
251
226
  expectedCommands,
252
- expectedSkills,
253
227
  found,
254
228
  missing,
255
- pruned,
256
229
  installedAction: 'symlink',
257
230
  };
258
231
  }
259
232
 
260
233
  /**
261
- * Read-only diagnostic. For Cursor/Codex, verifies that slash adapter files
262
- * point to the active AW skill `SKILL.md` files. Claude is skill/plugin
263
- * 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.
264
341
  *
265
342
  * @param {object} opts
266
343
  * @param {'claude-web'|'cursor-cloud'|'codex-web'|string} opts.harness
267
344
  * @param {string} opts.home
268
- * @param {string} [opts.awHome]
269
345
  * @returns {{ expected: string[], found: string[], missing: string[], ok: boolean }}
270
346
  */
271
347
  export function diagnoseCommandResolution(opts) {
272
348
  if (!opts || typeof opts !== 'object') {
273
349
  throw new Error('diagnoseCommandResolution: opts object is required');
274
350
  }
275
- const { harness, home, awHome } = opts;
351
+ const { harness, home } = opts;
276
352
  if (!home || typeof home !== 'string') {
277
353
  throw new Error('diagnoseCommandResolution: opts.home is required');
278
354
  }
279
-
280
- const routes = awHome && typeof awHome === 'string' ? discoverAwSkillRoutes(awHome) : [];
281
- const expected = routes.map((route) => route.slashName);
355
+ const expected = [...AW_STAGE_COMMANDS].sort();
282
356
 
283
357
  if (harness === 'claude-web') {
284
- 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);
285
365
  }
286
366
 
287
367
  const targetDir = harnessTargetDir(harness, home);
288
368
  if (!targetDir) {
289
369
  return { expected, found: [], missing: [...expected], ok: false };
290
370
  }
291
- return walkSkillAdapterDir(targetDir, routes);
371
+ return walkCommandsDir(targetDir, expected);
292
372
  }
293
373
 
294
- function walkSkillAdapterDir(dir, routes) {
374
+ function walkCommandsDir(dir, expected) {
295
375
  if (!existsSync(dir)) {
296
- const expected = routes.map((route) => route.slashName);
297
376
  return { expected, found: [], missing: [...expected], ok: expected.length === 0 };
298
377
  }
299
-
300
- const found = [];
301
- const missing = [];
302
- for (const route of routes) {
303
- const linkPath = join(dir, `${route.slashName}.md`);
304
- if (isCorrectSymlink(linkPath, route.sourcePath)) found.push(route.slashName);
305
- 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.
306
387
  }
307
-
308
- 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));
309
390
  return { expected, found, missing, ok: missing.length === 0 };
310
391
  }
311
-
312
- export const __test__ = {
313
- awSkillsDir,
314
- discoverAwSkillRoutes,
315
- slashNameForSkill,
316
- };
@@ -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.