@ghl-ai/aw 0.1.44-beta.0 → 0.1.44-beta.2

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,286 @@
1
+ /**
2
+ * c4/commandSurface.mjs — slash-command resolution per harness (G3).
3
+ *
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.
9
+ *
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.
16
+ *
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.
22
+ *
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.
28
+ */
29
+
30
+ import {
31
+ existsSync,
32
+ lstatSync,
33
+ mkdirSync,
34
+ readdirSync,
35
+ readlinkSync,
36
+ symlinkSync,
37
+ unlinkSync,
38
+ } from 'node:fs';
39
+ import { join, basename, extname } 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
+ ]);
57
+
58
+ /**
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).
62
+ *
63
+ * @param {string} eccHome
64
+ * @returns {string[]}
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);
75
+ }
76
+ stageNames.sort();
77
+ return stageNames;
78
+ }
79
+
80
+ function harnessTargetDir(harness, home) {
81
+ if (harness === 'cursor-cloud') return join(home, '.cursor/commands/aw');
82
+ if (harness === 'codex-web') return join(home, '.codex/commands/aw');
83
+ return null;
84
+ }
85
+
86
+ function isCorrectSymlink(linkPath, expectedTarget) {
87
+ if (!existsSync(linkPath)) return false;
88
+ let stat;
89
+ try {
90
+ stat = lstatSync(linkPath);
91
+ } catch {
92
+ return false;
93
+ }
94
+ if (!stat.isSymbolicLink()) return false;
95
+ try {
96
+ return readlinkSync(linkPath) === expectedTarget;
97
+ } catch {
98
+ return false;
99
+ }
100
+ }
101
+
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.
111
+ try {
112
+ if (existsSync(linkPath) || lstatExists(linkPath)) {
113
+ try { unlinkSync(linkPath); } catch { /* may be a directory */ }
114
+ }
115
+ symlinkSync(sourcePath, linkPath);
116
+ return true;
117
+ } catch {
118
+ return false;
119
+ }
120
+ }
121
+
122
+ function lstatExists(p) {
123
+ try {
124
+ lstatSync(p);
125
+ return true;
126
+ } catch {
127
+ return false;
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Ensure the harness slash-command surface for AW stages.
133
+ *
134
+ * @param {object} opts
135
+ * @param {'claude-web'|'cursor-cloud'|'codex-web'|string} opts.harness
136
+ * @param {string} opts.home
137
+ * @param {string} opts.eccHome
138
+ * @returns {{
139
+ * harness: string,
140
+ * expectedCommands: string[],
141
+ * found: string[],
142
+ * missing: string[],
143
+ * installedAction: 'symlink' | 'noop' | 'unsupported',
144
+ * }}
145
+ */
146
+ export function ensureCommandSurface(opts) {
147
+ if (!opts || typeof opts !== 'object') {
148
+ throw new Error('ensureCommandSurface: opts object is required');
149
+ }
150
+ const { harness, home, eccHome } = opts;
151
+ if (!home || typeof home !== 'string') {
152
+ throw new Error('ensureCommandSurface: opts.home is required');
153
+ }
154
+ if (!eccHome || typeof eccHome !== 'string') {
155
+ throw new Error('ensureCommandSurface: opts.eccHome is required');
156
+ }
157
+
158
+ const expectedCommands = discoverEccStageCommands(eccHome);
159
+
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.
164
+ return {
165
+ harness,
166
+ expectedCommands,
167
+ found: [...expectedCommands],
168
+ missing: [],
169
+ installedAction: 'noop',
170
+ };
171
+ }
172
+
173
+ const targetDir = harnessTargetDir(harness, home);
174
+ if (!targetDir) {
175
+ return {
176
+ harness,
177
+ expectedCommands,
178
+ found: [],
179
+ missing: [],
180
+ installedAction: 'unsupported',
181
+ };
182
+ }
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.
187
+ try {
188
+ mkdirSync(targetDir, { recursive: true });
189
+ } catch {
190
+ return {
191
+ harness,
192
+ expectedCommands,
193
+ found: [],
194
+ missing: [...expectedCommands],
195
+ installedAction: 'symlink',
196
+ };
197
+ }
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) {
207
+ return {
208
+ harness,
209
+ expectedCommands,
210
+ found: [],
211
+ missing: [...expectedCommands],
212
+ installedAction: 'symlink',
213
+ };
214
+ }
215
+
216
+ const found = [];
217
+ const missing = [];
218
+ for (const stageName of expectedCommands) {
219
+ const ok = linkStageCommand(stageName, eccHome, targetDir);
220
+ if (ok) found.push(stageName);
221
+ else missing.push(stageName);
222
+ }
223
+
224
+ return {
225
+ harness,
226
+ expectedCommands,
227
+ found,
228
+ missing,
229
+ installedAction: 'symlink',
230
+ };
231
+ }
232
+
233
+ /**
234
+ * Read-only diagnostic. Walks the harness command directory and reports
235
+ * which AW stage commands are resolvable. Does not fix.
236
+ *
237
+ * @param {object} opts
238
+ * @param {'claude-web'|'cursor-cloud'|'codex-web'|string} opts.harness
239
+ * @param {string} opts.home
240
+ * @returns {{ expected: string[], found: string[], missing: string[], ok: boolean }}
241
+ */
242
+ export function diagnoseCommandResolution(opts) {
243
+ if (!opts || typeof opts !== 'object') {
244
+ throw new Error('diagnoseCommandResolution: opts object is required');
245
+ }
246
+ const { harness, home } = opts;
247
+ if (!home || typeof home !== 'string') {
248
+ throw new Error('diagnoseCommandResolution: opts.home is required');
249
+ }
250
+ const expected = [...AW_STAGE_COMMANDS].sort();
251
+
252
+ if (harness === 'claude-web') {
253
+ // Plugin marketplace path. If a `~/.claude/commands` dir exists we
254
+ // honor it; otherwise we trust marketplace dispatch and report ok.
255
+ const claudeDir = join(home, '.claude/commands');
256
+ if (!existsSync(claudeDir)) {
257
+ return { expected, found: [], missing: [], ok: true };
258
+ }
259
+ return walkCommandsDir(claudeDir, expected);
260
+ }
261
+
262
+ const targetDir = harnessTargetDir(harness, home);
263
+ if (!targetDir) {
264
+ return { expected, found: [], missing: [...expected], ok: false };
265
+ }
266
+ return walkCommandsDir(targetDir, expected);
267
+ }
268
+
269
+ function walkCommandsDir(dir, expected) {
270
+ if (!existsSync(dir)) {
271
+ return { expected, found: [], missing: [...expected], ok: expected.length === 0 };
272
+ }
273
+ const present = new Set();
274
+ try {
275
+ for (const entry of readdirSync(dir)) {
276
+ if (extname(entry).toLowerCase() !== '.md') continue;
277
+ const name = basename(entry, '.md');
278
+ if (AW_STAGE_COMMANDS.has(name)) present.add(name);
279
+ }
280
+ } catch {
281
+ // Unreadable directory: treat as nothing-found.
282
+ }
283
+ const found = expected.filter((n) => present.has(n));
284
+ const missing = expected.filter((n) => !present.has(n));
285
+ return { expected, found, missing, ok: missing.length === 0 };
286
+ }
package/c4/detect.mjs ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * c4/detect.mjs — identify the cloud harness this VM is running inside.
3
+ *
4
+ * Returned values (canonical):
5
+ * 'cursor-cloud' | 'codex-web' | 'claude-web' | 'unknown'
6
+ *
7
+ * Detection signals (precedence top → bottom):
8
+ * 1. cursor-cloud ← env.CURSOR_AGENT === 'true' || env.CURSOR_BACKGROUND_AGENT_ID
9
+ * 2. codex-web ← env.CODEX_ENVIRONMENT === '1' || /\.codex(\/|$)/.test(cwd)
10
+ * 3. claude-web ← env.CLAUDE_CODE_REMOTE === 'true' || fsProbe('/home/user/.claude')
11
+ * 4. unknown ← fallback
12
+ *
13
+ * Override: env.AW_C4_HARNESS (a known harness id) wins over all signals.
14
+ *
15
+ * @typedef {'cursor-cloud' | 'codex-web' | 'claude-web' | 'unknown'} HarnessId
16
+ */
17
+
18
+ import { existsSync } from 'node:fs';
19
+
20
+ export const HARNESSES = Object.freeze(['cursor-cloud', 'codex-web', 'claude-web', 'unknown']);
21
+
22
+ const CODEX_CWD_PATTERN = /\.codex(\/|$)/;
23
+
24
+ function defaultFsProbe(path) {
25
+ try {
26
+ return existsSync(path);
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ /**
33
+ * @param {object} [opts]
34
+ * @param {NodeJS.ProcessEnv} [opts.env] Environment to inspect (defaults to process.env)
35
+ * @param {(path: string) => boolean} [opts.fsProbe] Filesystem existence probe (defaults to fs.existsSync)
36
+ * @param {string} [opts.cwd] Current working directory (defaults to process.cwd())
37
+ * @returns {HarnessId}
38
+ */
39
+ export function detectHarness({
40
+ env = process.env,
41
+ fsProbe = defaultFsProbe,
42
+ cwd = process.cwd(),
43
+ } = {}) {
44
+ const override = env.AW_C4_HARNESS;
45
+ if (override && HARNESSES.includes(override)) {
46
+ return /** @type {HarnessId} */ (override);
47
+ }
48
+
49
+ if (env.CURSOR_AGENT === 'true' || env.CURSOR_BACKGROUND_AGENT_ID) {
50
+ return 'cursor-cloud';
51
+ }
52
+
53
+ if (env.CODEX_ENVIRONMENT === '1' || CODEX_CWD_PATTERN.test(cwd)) {
54
+ return 'codex-web';
55
+ }
56
+
57
+ if (env.CLAUDE_CODE_REMOTE === 'true' || fsProbe('/home/user/.claude')) {
58
+ return 'claude-web';
59
+ }
60
+
61
+ return 'unknown';
62
+ }