@ghl-ai/aw 0.1.44-beta.5 → 0.1.44-beta.7

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.
@@ -0,0 +1,222 @@
1
+ /**
2
+ * c4/cursorRulesShim.mjs — Cursor Cloud slash-command expansion shim.
3
+ *
4
+ * Why: Cursor Cloud Agent's chat UI does not pre-expand `/aw:<NAME>` slash
5
+ * commands the way Cursor Desktop's plugin does. The model sees the raw
6
+ * literal `/aw:...` and falls through to natural-language interpretation,
7
+ * losing the contract from the matching command file even though the file
8
+ * is correctly installed on disk by `commandSurface.mjs`.
9
+ *
10
+ * Workaround: write `<repoRoot>/.cursor/rules/aw-slash-expand.mdc` — a
11
+ * Cursor project rule that teaches the MODEL itself to read the matching
12
+ * command file and execute its contract verbatim. Cursor's `.mdc` rule
13
+ * format is honored in Agent mode (verified via Cursor docs); the rule
14
+ * loads on every session when `alwaysApply: true`.
15
+ *
16
+ * Per-harness behavior:
17
+ * - cursor-cloud → write the rule (idempotent on byte-equal content)
18
+ * - claude-web → skip (UI expands slash commands natively)
19
+ * - codex-web → skip (different routing surface — see
20
+ * aw-c4-codex-slash-shim future work in overview.md)
21
+ * - any other → skip (defensive default)
22
+ *
23
+ * Refs: .aw_docs/features/aw-c4-cursor-slash-shim/{overview,spec,tasks}.md
24
+ */
25
+
26
+ import {
27
+ existsSync,
28
+ lstatSync,
29
+ mkdirSync,
30
+ readFileSync,
31
+ statSync,
32
+ unlinkSync,
33
+ writeFileSync,
34
+ } from 'node:fs';
35
+ import { join } from 'node:path';
36
+
37
+ const RULE_FILENAME = 'aw-slash-expand.mdc';
38
+ const CURSOR_RULES_SUBPATH = '.cursor/rules';
39
+
40
+ /**
41
+ * Canonical project-rule body. Exported so tests can assert the on-disk
42
+ * file matches byte-for-byte without re-reading from a fixture. Must NOT
43
+ * be mutated at runtime.
44
+ *
45
+ * The body lives here (not in a separate fixture file) so that bumping
46
+ * the rule wording is a single-file change that ships with `@ghl-ai/aw`
47
+ * via npm — no additional asset bundling needed.
48
+ */
49
+ export const CURSOR_SLASH_SHIM_RULE = `---
50
+ description: Expand \`/aw:<NAME>\` user messages by reading the matching command file
51
+ alwaysApply: true
52
+ ---
53
+
54
+ # AW slash-command expansion (Cursor Cloud workaround)
55
+
56
+ When the user message starts with \`/aw:<NAME>\` (case-sensitive; \`<NAME>\` may
57
+ contain hyphens — both in the namespace path and in the command name), do
58
+ the following BEFORE producing any other response.
59
+
60
+ ## 1. Stage-command lookup (primary)
61
+
62
+ Read \`~/.cursor/commands/aw/<NAME>.md\`. If the file exists, jump to step 3
63
+ with its content as the resolved instructions.
64
+
65
+ ## 2. Registry-command lookup (fallback, hyphen-disambiguation)
66
+
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
75
+ repo registry root is \`.aw/.aw_registry/\` (relative to the agent's CWD —
76
+ the repo root in Cursor Cloud).
77
+
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\` ✓ |
93
+
94
+ ## 3. Execute
95
+
96
+ Treat the resolved file's full content as your operating instructions for
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
100
+ time and wait" interaction protocol.
101
+
102
+ ## 4. No match
103
+
104
+ If neither lookup resolves to an existing file, do NOT silently fall back
105
+ to natural-language interpretation. Reply with exactly:
106
+
107
+ > \`/aw:<NAME>\` is not a registered AW command on this machine. Run
108
+ > \`aw c4 --diagnose\` and check the output of
109
+ > \`ls ~/.cursor/commands/aw/\` for available stage commands and
110
+ > \`find .aw/.aw_registry -path '*/commands/*.md'\` for available registry
111
+ > commands.
112
+
113
+ This precise reply text is the smoke-test fingerprint for the rule itself.
114
+ The string is unique enough that it cannot be confused with the model's
115
+ natural-language fallback.
116
+
117
+ ---
118
+
119
+ This rule exists because Cursor Cloud's chat UI does not pre-expand slash
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
122
+ \`.aw_docs/features/aw-c4-cursor-slash-shim/overview.md\` for context. Do
123
+ not edit this file by hand — \`aw c4 --harness cursor-cloud\` regenerates
124
+ it on every run.
125
+ `;
126
+
127
+ /**
128
+ * @typedef {object} InstallCursorSlashShimResult
129
+ * @property {string} harness The harness echoed from input.
130
+ * @property {string} path Absolute path of the rule file (or '' on skip).
131
+ * @property {'wrote'|'unchanged'|'skipped:harness'|'skipped:no-repo-root'} action
132
+ */
133
+
134
+ /**
135
+ * Write the canonical AW slash-expand rule into the pilot repo.
136
+ *
137
+ * Idempotent: a re-run on byte-equal content returns 'unchanged' and does
138
+ * NOT touch the file (mtime preserved). Drift recovery: any stale content
139
+ * (including a symlink at the rule path) is replaced with a regular file
140
+ * containing the canonical template.
141
+ *
142
+ * @param {object} opts
143
+ * @param {string} opts.harness 'cursor-cloud' (writes) or other (skips).
144
+ * @param {string} opts.repoRoot Absolute path to the repo root.
145
+ * @returns {InstallCursorSlashShimResult}
146
+ */
147
+ export function installCursorSlashShim(opts) {
148
+ if (!opts || typeof opts !== 'object') {
149
+ throw new Error('installCursorSlashShim: opts object is required');
150
+ }
151
+ const { harness, repoRoot } = opts;
152
+
153
+ if (harness !== 'cursor-cloud') {
154
+ return { harness, path: '', action: 'skipped:harness' };
155
+ }
156
+
157
+ if (!repoRoot || typeof repoRoot !== 'string' || !isExistingDir(repoRoot)) {
158
+ return { harness, path: '', action: 'skipped:no-repo-root' };
159
+ }
160
+
161
+ const rulesDir = join(repoRoot, CURSOR_RULES_SUBPATH);
162
+ const rulePath = join(rulesDir, RULE_FILENAME);
163
+
164
+ // Idempotency check: if a regular file with byte-identical content
165
+ // already exists, return 'unchanged' WITHOUT writing — preserves mtime
166
+ // for the test contract and avoids spurious file-watcher noise.
167
+ if (isRegularFileWithIdenticalContent(rulePath)) {
168
+ return { harness, path: rulePath, action: 'unchanged' };
169
+ }
170
+
171
+ // Drift recovery: clear the existing entry (regular file with stale
172
+ // bytes, or any symlink) before writing a fresh regular file. Symlinks
173
+ // get unlinked rather than overwritten so we never write through to
174
+ // their targets.
175
+ if (lstatExists(rulePath)) {
176
+ try {
177
+ unlinkSync(rulePath);
178
+ } catch {
179
+ // Ignore — writeFileSync below will overwrite a regular file
180
+ // anyway. If it's something stranger (e.g. a directory we can't
181
+ // delete), the writeFileSync will throw and bubble up to safe().
182
+ }
183
+ }
184
+
185
+ mkdirSync(rulesDir, { recursive: true });
186
+ writeFileSync(rulePath, CURSOR_SLASH_SHIM_RULE, 'utf8');
187
+
188
+ return { harness, path: rulePath, action: 'wrote' };
189
+ }
190
+
191
+ function isExistingDir(p) {
192
+ try {
193
+ return statSync(p).isDirectory();
194
+ } catch {
195
+ return false;
196
+ }
197
+ }
198
+
199
+ function lstatExists(p) {
200
+ try {
201
+ lstatSync(p);
202
+ return true;
203
+ } catch {
204
+ return false;
205
+ }
206
+ }
207
+
208
+ function isRegularFileWithIdenticalContent(rulePath) {
209
+ if (!existsSync(rulePath)) return false;
210
+ let isSym = false;
211
+ try {
212
+ isSym = lstatSync(rulePath).isSymbolicLink();
213
+ } catch {
214
+ return false;
215
+ }
216
+ if (isSym) return false; // Always replace symlinks with regular files.
217
+ try {
218
+ return readFileSync(rulePath, 'utf8') === CURSOR_SLASH_SHIM_RULE;
219
+ } catch {
220
+ return false;
221
+ }
222
+ }
@@ -112,6 +112,10 @@ export function summarizeAsOneLine(state) {
112
112
 
113
113
  if (state.mcpProbe) parts.push(`mcp ${state.mcpProbe}`);
114
114
 
115
+ if (state.slashShim) {
116
+ parts.push(`slashShim ${slashShimSummaryToken(state.slashShim.action)}`);
117
+ }
118
+
115
119
  const seconds = Number(state.durationMs ?? 0) / 1000;
116
120
  parts.push(`init ${seconds.toFixed(1)}s`);
117
121
  parts.push(state.didInit ? 'ready' : 'skipped');
@@ -119,6 +123,18 @@ export function summarizeAsOneLine(state) {
119
123
  return parts.join(DOT);
120
124
  }
121
125
 
126
+ /**
127
+ * Map the structured slashShim action into a 1-token line summary glyph.
128
+ * 'wrote' is loud (something changed). 'unchanged' is quiet ('ok'). Both
129
+ * skip variants collapse to 'skip' so the line stays short on Claude/Codex
130
+ * harnesses where slashShim is a no-op.
131
+ */
132
+ function slashShimSummaryToken(action) {
133
+ if (action === 'wrote') return 'wrote';
134
+ if (action === 'unchanged') return 'ok';
135
+ return 'skip';
136
+ }
137
+
122
138
  /* ─────────────────────────────────────────────────────────────────────────
123
139
  * diagnoseAwRouterView
124
140
  * ───────────────────────────────────────────────────────────────────────── */
@@ -455,6 +471,19 @@ export function dumpPostInitState(opts = {}) {
455
471
  for (const skillPath of sampleRegistrySkillPaths(opts.awHome, 10)) {
456
472
  lines.push(` ${skillPath}`);
457
473
  }
474
+ lines.push('');
475
+
476
+ // Cursor slash-shim block (cursor-cloud only; opt.slashShim absent on
477
+ // other harnesses, where the block is intentionally suppressed).
478
+ if (opts.slashShim && typeof opts.slashShim === 'object') {
479
+ const shim = opts.slashShim;
480
+ lines.push('slashShim:');
481
+ lines.push(` harness=${shim.harness ?? '<unknown>'}`);
482
+ lines.push(` action=${shim.action ?? '<unknown>'}`);
483
+ if (shim.path) lines.push(` path=${shim.path}`);
484
+ lines.push('');
485
+ }
486
+
458
487
  lines.push('─── end ───');
459
488
  lines.push('');
460
489
 
package/c4/index.mjs CHANGED
@@ -50,6 +50,7 @@ export { MCP_URL_DEFAULT, registerGhlAiMcp } from './mcpServer.mjs';
50
50
  export { probeMcpServer } from './mcpSmokeProbe.mjs';
51
51
  export { ensureClaudeMarketplace } from './claudePluginRegistry.mjs';
52
52
  export { ensureCommandSurface, diagnoseCommandResolution } from './commandSurface.mjs';
53
+ export { installCursorSlashShim, CURSOR_SLASH_SHIM_RULE } from './cursorRulesShim.mjs';
53
54
  export { ensureRepoLocalClaudeSettings } from './repoLocalClaudeSettings.mjs';
54
55
  export { copyRepoRootInstructions } from './repoRootInstructions.mjs';
55
56
  export { ensureRepoLocalIgnore } from './repoLocalIgnore.mjs';
package/commands/c4.mjs CHANGED
@@ -346,6 +346,17 @@ export async function c4Command(rawArgs, overrides = {}) {
346
346
  // Step 12 — slash command surface.
347
347
  safe('ensureCommandSurface', () => c4.ensureCommandSurface({ harness, home, eccHome }), writer);
348
348
 
349
+ // Step 12b — Cursor Cloud slash-expand rule (no-op on other harnesses).
350
+ // The model-side workaround for Cursor Cloud's chat UI not pre-expanding
351
+ // `/aw:<NAME>` slash commands. The function self-skips on other
352
+ // harnesses, so the orchestrator does not pre-filter; the resulting
353
+ // action is surfaced in the one-line summary and post-init dump.
354
+ const slashShim = safe(
355
+ 'installCursorSlashShim',
356
+ () => c4.installCursorSlashShim({ harness, repoRoot: cwd }),
357
+ writer,
358
+ );
359
+
349
360
  // Step 13 — repo-root context.
350
361
  safe('copyRepoRootInstructions', () => c4.copyRepoRootInstructions(harness, cwd, eccHome), writer);
351
362
 
@@ -368,7 +379,13 @@ export async function c4Command(rawArgs, overrides = {}) {
368
379
  }
369
380
 
370
381
  // Step 17 — post-init dump.
371
- c4.dumpPostInitState({ harness, cwd, awHome, configPaths: [] });
382
+ c4.dumpPostInitState({
383
+ harness,
384
+ cwd,
385
+ awHome,
386
+ configPaths: [],
387
+ slashShim: slashShim?.ok ? slashShim.value : null,
388
+ });
372
389
 
373
390
  // Step 18 — one-line summary.
374
391
  writer.stdout(c4.summarizeAsOneLine({
@@ -381,6 +398,7 @@ export async function c4Command(rawArgs, overrides = {}) {
381
398
  bridge: branch?.bridge,
382
399
  injector: branch?.injector,
383
400
  mcpProbe: mcpProbe?.value?.authStatus,
401
+ slashShim: slashShim?.ok ? slashShim.value : null,
384
402
  }) + '\n');
385
403
 
386
404
  return exit(0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ghl-ai/aw",
3
- "version": "0.1.44-beta.5",
3
+ "version": "0.1.44-beta.7",
4
4
  "description": "Agentic Workspace CLI — pull, push & manage agents, skills and commands from the registry",
5
5
  "type": "module",
6
6
  "bin": {