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

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.
package/ecc.mjs CHANGED
@@ -3,7 +3,7 @@ import { promisify } from "node:util";
3
3
  const execAsync = promisify(execCb);
4
4
  import {
5
5
  existsSync, readFileSync, readdirSync,
6
- mkdirSync, rmSync, writeFileSync, renameSync,
6
+ mkdirSync, rmSync, writeFileSync,
7
7
  } from "node:fs";
8
8
  import { dirname, join } from "node:path";
9
9
  import { homedir } from "node:os";
@@ -16,7 +16,6 @@ export const AW_ECC_TAG = "v1.4.66";
16
16
  const REQUIRED_ECC_FILES = [
17
17
  "package.json",
18
18
  "scripts/install-apply.js",
19
- "scripts/sync-ecc-to-codex.sh",
20
19
  ];
21
20
 
22
21
  const MARKETPLACE_NAME = "aw-marketplace";
@@ -24,19 +23,31 @@ const PLUGIN_KEY = `aw@${MARKETPLACE_NAME}`;
24
23
 
25
24
  function eccDir() { return join(homedir(), ".aw-ecc"); }
26
25
 
27
- // "claude" uses a no-commands module set so hooks, rules, shared references,
28
- // and install-state land in ~/.claude/ while slash commands stay owned by
29
- // the marketplace plugin under /aw:*.
30
- // Using `--without baseline:commands` with broader profiles is unsafe because
31
- // some optional modules depend on commands-core and cause install-apply to fail.
26
+ // File-copy targets use explicit non-skill, non-command module sets so hooks,
27
+ // rules, shared references, and install-state can remain available while AW
28
+ // skills come from platform-docs/.aw_registry/aw. Slash access is backed by
29
+ // those skills directly, so aw-ecc command artifacts are intentionally skipped.
32
30
  const FILE_COPY_TARGETS = ["claude", "cursor", "codex"];
33
- const CLAUDE_FILE_COPY_MODULES = [
34
- "rules-core",
35
- "agents-core",
36
- "hooks-runtime",
37
- "platform-configs",
38
- "workflow-quality",
39
- ];
31
+ const FILE_COPY_MODULES_BY_TARGET = {
32
+ claude: [
33
+ "rules-core",
34
+ "agents-core",
35
+ "hooks-runtime",
36
+ "platform-configs",
37
+ ],
38
+ cursor: [
39
+ "rules-core",
40
+ "agents-core",
41
+ "hooks-runtime",
42
+ "platform-configs",
43
+ ],
44
+ codex: [
45
+ "rules-core",
46
+ "agents-core",
47
+ "hooks-runtime",
48
+ "platform-configs",
49
+ ],
50
+ };
40
51
 
41
52
  const TARGET_STATE = {
42
53
  claude: { state: ".claude/ecc/install-state.json" },
@@ -241,12 +252,11 @@ function cloneOrUpdate(tag, dest) {
241
252
 
242
253
  /**
243
254
  * Transform canonical /aw: references to Cursor-compatible /aw- in installed
244
- * skill and rule files. Cursor namespaces commands via directory structure
245
- * (commands/aw/plan.md /aw-plan) rather than colons.
255
+ * rule files. Cursor's model-side rule still sees hyphenated route mentions in
256
+ * some generated home instructions.
246
257
  */
247
258
  function transformCursorAwRefs(home) {
248
259
  const dirs = [
249
- join(home, ".cursor", "skills"),
250
260
  join(home, ".cursor", "rules"),
251
261
  ];
252
262
  for (const dir of dirs) {
@@ -280,57 +290,16 @@ function uninstallClaudePlugin() {
280
290
  try { run(`claude plugin marketplace remove ${MARKETPLACE_NAME}`); } catch { /* not registered */ }
281
291
  }
282
292
 
283
- /**
284
- * Move ecc command files from ~/.cursor/commands/*.md ~/.cursor/commands/aw/*.md
285
- * so Cursor exposes them as /aw:tdd, /aw:plan consistent with Claude Code's plugin namespace.
286
- * Also updates the ecc-install-state.json paths so nuke can clean them up correctly.
287
- */
288
- function namespaceCursorCommands(home) {
289
- const commandsDir = join(home, ".cursor", "commands");
290
- const awDir = join(commandsDir, "aw");
291
- if (!existsSync(commandsDir)) return;
292
-
293
- // Move any flat .md files (not already in a subdirectory) into aw/
294
- const moved = [];
295
- for (const file of readdirSync(commandsDir)) {
296
- if (!file.endsWith(".md") || file.startsWith(".")) continue;
297
- const src = join(commandsDir, file);
298
- mkdirSync(awDir, { recursive: true });
299
- const dest = join(awDir, file);
293
+ function pruneEccAuthoredSkillsAndCommands(repoDir) {
294
+ // The local aw-ecc clone is now only a compatibility runtime for hooks,
295
+ // rules, agents, and configs. Active AW skills live in platform-docs under
296
+ // ~/.aw/.aw_registry/aw/skills, and slash adapters point at those SKILL.md
297
+ // files directly.
298
+ for (const relPath of ["commands", "skills"]) {
300
299
  try {
301
- renameSync(src, dest);
302
- moved.push({ from: src, to: dest });
300
+ rmSync(join(repoDir, relPath), { recursive: true, force: true });
303
301
  } catch { /* best effort */ }
304
302
  }
305
-
306
- if (moved.length === 0) return;
307
-
308
- // Update install-state so nuke removes files from the new aw/ location
309
- const statePath = join(home, ".cursor", "ecc-install-state.json");
310
- if (!existsSync(statePath)) return;
311
- try {
312
- const state = JSON.parse(readFileSync(statePath, "utf8"));
313
- const pathMap = new Map(moved.map(({ from, to }) => [from, to]));
314
- state.operations = (state.operations || []).map((op) => {
315
- const newPath = op.destinationPath && pathMap.get(op.destinationPath);
316
- return newPath ? { ...op, destinationPath: newPath } : op;
317
- });
318
- writeFileSync(statePath, JSON.stringify(state, null, 2));
319
- } catch { /* best effort */ }
320
- }
321
-
322
- /**
323
- * Run scripts/sync-ecc-to-codex.sh from the cloned ecc repo.
324
- * Generates ~/.codex/prompts/*.md (Codex equivalent of slash commands),
325
- * using aw-*.md for AW-namespaced commands and ecc-*.md for the rest,
326
- * and merges ~/.codex/AGENTS.md. Best-effort — failure doesn't block init.
327
- */
328
- function syncEccToCodex(repoDir) {
329
- const syncScript = join(repoDir, "scripts", "sync-ecc-to-codex.sh");
330
- if (!existsSync(syncScript)) return;
331
- try {
332
- run(`bash "${syncScript}"`, { cwd: homedir() });
333
- } catch { /* best effort — codex sync failure is non-blocking */ }
334
303
  }
335
304
 
336
305
  export async function installAwEcc(
@@ -347,6 +316,7 @@ export async function installAwEcc(
347
316
  try {
348
317
  if (eccSpinner) eccSpinner.start('Cloning aw-ecc engine...');
349
318
  await cloneOrUpdateAsync(AW_ECC_TAG, repoDir);
319
+ pruneEccAuthoredSkillsAndCommands(repoDir);
350
320
  if (eccSpinner) eccSpinner.message('Installing aw-ecc dependencies...');
351
321
 
352
322
  // Claude Code: plugin install via marketplace CLI (proper agent dispatch)
@@ -381,20 +351,17 @@ export async function installAwEcc(
381
351
 
382
352
  // Always use HOME as cwd so files land in ~/.<target>/ globally.
383
353
  const runCwd = homedir();
384
- const installArgs = target === "claude"
385
- ? `--target ${target} --modules ${CLAUDE_FILE_COPY_MODULES.join(",")}`
386
- : `--target ${target} --profile full`;
354
+ const installModules = FILE_COPY_MODULES_BY_TARGET[target] || [];
355
+ const installArgs = installModules.length > 0
356
+ ? `--target ${target} --modules ${installModules.join(",")}`
357
+ : `--target ${target}`;
387
358
  run(
388
359
  `node ${join(repoDir, "scripts/install-apply.js")} ${installArgs}`,
389
360
  { cwd: runCwd },
390
361
  );
391
362
  if (target === "cursor") {
392
- namespaceCursorCommands(runCwd);
393
363
  transformCursorAwRefs(home);
394
364
  }
395
- if (target === "codex") {
396
- syncEccToCodex(repoDir);
397
- }
398
365
  restoreProtectedConfigs(snapshot);
399
366
  } catch { /* target not supported — skip */ }
400
367
  }));
@@ -446,8 +413,8 @@ export function uninstallAwEcc({ silent = false } = {}) {
446
413
  } catch { /* corrupted state — skip */ }
447
414
  }
448
415
 
449
- // Codex: remove generated prompt files from sync-ecc-to-codex.sh
450
- // (not tracked in install-state — cleaned via manifests, with prefix fallback)
416
+ // Codex: remove legacy generated prompt files from older ECC sync flows
417
+ // (not tracked in install-state — cleaned via manifests, with prefix fallback).
451
418
  const codexPromptsDir = join(HOME, ".codex", "prompts");
452
419
  if (existsSync(codexPromptsDir)) {
453
420
  try {
package/git.mjs CHANGED
@@ -5,7 +5,14 @@ import { mkdtempSync, existsSync, lstatSync, rmSync, readFileSync, symlinkSync,
5
5
  import { join, basename, dirname } from 'node:path';
6
6
  import { homedir, tmpdir } from 'node:os';
7
7
  import { promisify } from 'node:util';
8
- import { REGISTRY_BASE_BRANCH, REGISTRY_DIR, DOCS_SOURCE_DIR, AW_DOCS_DIR, RULES_SOURCE_DIR } from './constants.mjs';
8
+ import {
9
+ REGISTRY_BASE_BRANCH,
10
+ REGISTRY_DIR,
11
+ AW_REGISTRY_NAMESPACE_DIR,
12
+ DOCS_SOURCE_DIR,
13
+ AW_DOCS_DIR,
14
+ RULES_SOURCE_DIR,
15
+ } from './constants.mjs';
9
16
 
10
17
  const exec = promisify(execCb);
11
18
 
@@ -155,6 +162,7 @@ export function includeToSparsePaths(paths) {
155
162
  }
156
163
  result.add(`${REGISTRY_DIR}/AW-PROTOCOL.md`);
157
164
  if (paths.includes('platform')) {
165
+ result.add(AW_REGISTRY_NAMESPACE_DIR);
158
166
  result.add(DOCS_SOURCE_DIR);
159
167
  result.add(AW_DOCS_DIR);
160
168
  result.add(RULES_SOURCE_DIR);
@@ -389,14 +397,9 @@ export async function fetchAndMerge(awHome, { silent = true } = {}) {
389
397
  // git 2.46+ bug on blob:none + no-cone sparse-checkout repos that silently
390
398
  // drops bare-name patterns (e.g. "content", "CODEOWNERS") when HEAD advances.
391
399
  //
392
- // --autostash: AW writes into the registry working tree from external
393
- // sources (ensureAwRuntimeHook copies ~/.aw-ecc/.../session-start.sh into a
394
- // tracked path; transformCursorAwRefs rewrites /aw: /aw- through Cursor
395
- // skill directory symlinks that resolve into .aw_registry/). When those
396
- // versions drift, the working tree is dirty at rebase time and rebase
397
- // refuses to run, silently aborting the entire pull. Autostash stashes the
398
- // dirty bytes, rebases cleanly, and reapplies them — so the registry stays
399
- // in sync even when external sources have leaked into the tracked tree.
400
+ // --autostash: AW can have local registry edits pending when sync runs.
401
+ // Without autostash, git refuses to rebase and the pull silently aborts.
402
+ // Autostash stashes dirty bytes, rebases cleanly, and reapplies them.
400
403
  try {
401
404
  await exec(`git -C "${awHome}" rebase --autostash origin/${REGISTRY_BASE_BRANCH}`);
402
405
  updated = true;
@@ -26,10 +26,10 @@ const CODEX_HOME_PHASE_BLUEPRINTS = {
26
26
  marker: this.scriptMarker,
27
27
  phase: 'SessionStart',
28
28
  targetCandidates: [
29
- '$HOME/.aw_registry/platform/core/skills/using-aw-skills/hooks/session-start.sh',
30
- '$HOME/.aw/.aw_registry/platform/core/skills/using-aw-skills/hooks/session-start.sh',
29
+ '$HOME/.aw_registry/aw/skills/using-aw-skills/hooks/session-start.sh',
30
+ '$HOME/.aw/.aw_registry/aw/skills/using-aw-skills/hooks/session-start.sh',
31
31
  ],
32
- warningMessage: 'WARNING: AW using-aw-skills hook not found in ~/.aw_registry. Run aw init or aw pull platform.',
32
+ warningMessage: 'WARNING: AW using-aw-skills hook not found in ~/.aw_registry/aw. Run aw init or aw pull platform.',
33
33
  telemetryHookPath: '$HOME/.aw-ecc/scripts/hooks/aw-usage-session-start.js',
34
34
  harnessEnv: 'codex',
35
35
  });
package/link.mjs CHANGED
@@ -158,10 +158,12 @@ function cleanSymlinksRecursive(dir) {
158
158
  }
159
159
 
160
160
  /**
161
- * Compute flat IDE name: namespace-slug (always includes namespace).
161
+ * Compute flat IDE names. The top-level `aw` namespace is already encoded in
162
+ * its skill and command names, so keep those names stable instead of producing
163
+ * aw-aw-plan or aw-plan.md variants.
162
164
  */
163
- function flatName(ns, name) {
164
- return `${ns}-${name}`;
165
+ function flatRegistryName(ns, ...parts) {
166
+ return (ns === 'aw' ? parts : [ns, ...parts]).join('-');
165
167
  }
166
168
 
167
169
  /**
@@ -202,7 +204,7 @@ export function linkWorkspace(cwd, awDirOverride = null, { silent = false } = {}
202
204
  for (const type of FILE_TYPES) {
203
205
  for (const { typeDirPath, segments } of findNestedTypeDirs(join(awDir, ns), type)) {
204
206
  for (const file of readdirSync(typeDirPath).filter(f => f.endsWith('.md') && !f.startsWith('.'))) {
205
- const flat = [ns, ...segments, file].join('-');
207
+ const flat = flatRegistryName(ns, ...segments, file);
206
208
 
207
209
  for (const ide of IDE_DIRS) {
208
210
  const linkDir = join(cwd, ide, type);
@@ -219,7 +221,7 @@ export function linkWorkspace(cwd, awDirOverride = null, { silent = false } = {}
219
221
  // Skills: per-skill directory symlinks (recursive for nested domain dirs)
220
222
  for (const { typeDirPath: skillsDir, segments } of findNestedTypeDirs(join(awDir, ns), 'skills')) {
221
223
  for (const skill of listDirs(skillsDir)) {
222
- const flat = [ns, ...segments, skill].join('-');
224
+ const flat = flatRegistryName(ns, ...segments, skill);
223
225
 
224
226
  for (const ide of IDE_DIRS) {
225
227
  const linkDir = join(cwd, ide, 'skills');
@@ -233,7 +235,7 @@ export function linkWorkspace(cwd, awDirOverride = null, { silent = false } = {}
233
235
 
234
236
  for (const { skillDirPath, segments: skillSegments } of findSkillRootDirs(skillsDir)) {
235
237
  if (skillSegments.length <= 1) continue;
236
- const flat = [ns, ...segments, ...skillSegments].join('-');
238
+ const flat = flatRegistryName(ns, ...segments, ...skillSegments);
237
239
 
238
240
  for (const ide of IDE_DIRS) {
239
241
  const linkDir = join(cwd, ide, 'skills');
@@ -254,7 +256,7 @@ export function linkWorkspace(cwd, awDirOverride = null, { silent = false } = {}
254
256
  if (isColocated) {
255
257
  // Colocated evals: agents/evals/<slug>/ — one level of slug dirs
256
258
  for (const evalSlug of listDirs(evalsDir)) {
257
- const flat = [ns, ...segments, evalSlug].join('-');
259
+ const flat = flatRegistryName(ns, ...segments, evalSlug);
258
260
  for (const ide of IDE_DIRS) {
259
261
  const linkDir = join(cwd, ide, 'evals');
260
262
  mkdirSync(linkDir, { recursive: true });
@@ -269,7 +271,7 @@ export function linkWorkspace(cwd, awDirOverride = null, { silent = false } = {}
269
271
  for (const subType of listDirs(evalsDir)) {
270
272
  const subDir = join(evalsDir, subType);
271
273
  for (const evalName of listDirs(subDir)) {
272
- const flat = [ns, ...segments, subType, evalName].join('-');
274
+ const flat = flatRegistryName(ns, ...segments, subType, evalName);
273
275
  for (const ide of IDE_DIRS) {
274
276
  const linkDir = join(cwd, ide, 'evals');
275
277
  mkdirSync(linkDir, { recursive: true });
@@ -310,7 +312,7 @@ export function linkWorkspace(cwd, awDirOverride = null, { silent = false } = {}
310
312
  for (const { typeDirPath: skillsDir, segments } of findNestedTypeDirs(join(awDir, ns), 'skills')) {
311
313
  mkdirSync(agentsSkillsDir, { recursive: true });
312
314
  for (const skill of listDirs(skillsDir)) {
313
- const flat = [ns, ...segments, skill].join('-');
315
+ const flat = flatRegistryName(ns, ...segments, skill);
314
316
  const linkPath = join(agentsSkillsDir, flat);
315
317
  const targetPath = join(skillsDir, skill);
316
318
  const relTarget = relative(agentsSkillsDir, targetPath);
@@ -319,7 +321,7 @@ export function linkWorkspace(cwd, awDirOverride = null, { silent = false } = {}
319
321
 
320
322
  for (const { skillDirPath, segments: skillSegments } of findSkillRootDirs(skillsDir)) {
321
323
  if (skillSegments.length <= 1) continue;
322
- const flat = [ns, ...segments, ...skillSegments].join('-');
324
+ const flat = flatRegistryName(ns, ...segments, ...skillSegments);
323
325
  const linkPath = join(agentsSkillsDir, flat);
324
326
  const relTarget = relative(agentsSkillsDir, skillDirPath);
325
327
  if (linkNestedSkillRoot(relTarget, linkPath, { silent })) created++;
@@ -346,7 +348,7 @@ export function linkWorkspace(cwd, awDirOverride = null, { silent = false } = {}
346
348
  for (const ns of namespaces) {
347
349
  for (const { typeDirPath: commandsDir, segments } of findNestedTypeDirs(join(awDir, ns), 'commands')) {
348
350
  for (const file of readdirSync(commandsDir).filter(f => f.endsWith('.md') && !f.startsWith('.'))) {
349
- const cmdFileName = [ns, ...segments, file].join('-');
351
+ const cmdFileName = flatRegistryName(ns, ...segments, file);
350
352
 
351
353
  for (const ide of IDE_DIRS) {
352
354
  const linkDir = join(cwd, ide, 'commands', 'aw');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ghl-ai/aw",
3
- "version": "0.1.69",
3
+ "version": "0.1.70-beta.1",
4
4
  "description": "Agentic Workspace CLI — pull, push & manage agents, skills and commands from the registry",
5
5
  "type": "module",
6
6
  "bin": {
package/startup.mjs CHANGED
@@ -83,8 +83,10 @@ function resolveRegistryRoot(homeDir = homedir()) {
83
83
  ].find(existsSync) || null;
84
84
  }
85
85
 
86
- function awRuntimeHookSourcePath(homeDir = homedir()) {
87
- return join(homeDir, '.aw-ecc', 'skills', 'using-aw-skills', 'hooks', 'session-start.sh');
86
+ function awRuntimeHookDestinationPath(homeDir = homedir()) {
87
+ const registryRoot = resolveRegistryRoot(homeDir);
88
+ if (!registryRoot) return null;
89
+ return join(registryRoot, 'aw', 'skills', 'using-aw-skills', 'hooks', 'session-start.sh');
88
90
  }
89
91
 
90
92
  function readJson(filePath, fallback = {}) {
@@ -393,27 +395,10 @@ function hasCodexSessionStartScript(homeDir = homedir()) {
393
395
  }
394
396
 
395
397
  export function ensureAwRuntimeHook(homeDir = homedir()) {
396
- const sourcePath = awRuntimeHookSourcePath(homeDir);
397
- const registryRoot = resolveRegistryRoot(homeDir);
398
- if (!existsSync(sourcePath) || !registryRoot) return [];
399
-
400
- const destinationPath = join(
401
- registryRoot,
402
- 'platform',
403
- 'core',
404
- 'skills',
405
- 'using-aw-skills',
406
- 'hooks',
407
- 'session-start.sh',
408
- );
409
- const sourceContent = readFileSync(sourcePath, 'utf8');
410
- const existingContent = existsSync(destinationPath) ? readFileSync(destinationPath, 'utf8') : null;
411
- if (existingContent === sourceContent) return [];
412
-
413
- mkdirSync(dirname(destinationPath), { recursive: true });
414
- writeFileSync(destinationPath, sourceContent);
398
+ const destinationPath = awRuntimeHookDestinationPath(homeDir);
399
+ if (!destinationPath || !existsSync(destinationPath)) return [];
415
400
  try { chmodSync(destinationPath, 0o755); } catch { /* best effort */ }
416
- return [destinationPath];
401
+ return [];
417
402
  }
418
403
 
419
404
  function hasCodexHooksEnabled(homeDir = homedir()) {
@@ -1,236 +0,0 @@
1
- /**
2
- * c4/eccRegistryBridge.mjs — Codex-only Bug A workaround.
3
- *
4
- * Codex Web's upstream `aw-session-start.sh` enumerator scans a fixed
5
- * registry path:
6
- *
7
- * ~/.aw/.aw_registry/platform/core/skills/<name>/SKILL.md
8
- *
9
- * ECC ships skills under a different path:
10
- *
11
- * ~/.aw-ecc/skills/<name>/SKILL.md
12
- *
13
- * On Codex Web, neither Claude's plugin marketplace nor Cursor's direct
14
- * `~/.cursor/skills/...` resolution applies. The only way Codex can see
15
- * ECC skills is if the registry path is populated. We bridge by symlinking
16
- * each ECC skill's SKILL.md into the registry layout. The default mode keeps
17
- * the original non-clobbering behavior. `preferEccSkills` only retargets
18
- * existing symlinks; regular files are treated as user-owned and preserved.
19
- *
20
- * Also installs a fallback symlink at `<homeOf(awHome)>/.aw_registry` →
21
- * `<awHome>/.aw_registry` because some legacy hooks read from that path.
22
- *
23
- * **Scope**: Codex Web only. The orchestrator is responsible for *not*
24
- * invoking this on claude-web or cursor-cloud; the module itself is
25
- * harness-agnostic and just performs the bridge.
26
- */
27
-
28
- import {
29
- existsSync,
30
- lstatSync,
31
- readlinkSync,
32
- readdirSync,
33
- mkdirSync,
34
- symlinkSync,
35
- unlinkSync,
36
- } from 'node:fs';
37
- import { dirname, join } from 'node:path';
38
-
39
- /** True iff `path` is a symlink that points to a nonexistent target. */
40
- function isBrokenSymlink(path) {
41
- try {
42
- const st = lstatSync(path);
43
- if (!st.isSymbolicLink()) return false;
44
- return !existsSync(path);
45
- } catch {
46
- return false;
47
- }
48
- }
49
-
50
- /** True iff `path` is a symlink that already points at `target`. */
51
- function isSymlinkTo(path, target) {
52
- try {
53
- const st = lstatSync(path);
54
- if (!st.isSymbolicLink()) return false;
55
- return readlinkSync(path) === target;
56
- } catch {
57
- return false;
58
- }
59
- }
60
-
61
- /** True iff `path` is any symlink, regardless of where it points. */
62
- function isSymlink(path) {
63
- try {
64
- return lstatSync(path).isSymbolicLink();
65
- } catch {
66
- return false;
67
- }
68
- }
69
-
70
- /**
71
- * Install the fallback symlink `~/.aw_registry → ~/.aw/.aw_registry` if not
72
- * present and not blocked by an existing real directory. Best-effort.
73
- */
74
- function installRegistryFallbackLink(awHome) {
75
- const home = dirname(awHome);
76
- const fallback = join(home, '.aw_registry');
77
- const target = join(awHome, '.aw_registry');
78
-
79
- if (!existsSync(target)) {
80
- // Nothing to point at yet — skip silently.
81
- return;
82
- }
83
-
84
- let existsAtFallback = false;
85
- try {
86
- lstatSync(fallback);
87
- existsAtFallback = true;
88
- } catch {
89
- existsAtFallback = false;
90
- }
91
-
92
- if (existsAtFallback) return; // do not clobber existing dir/symlink
93
-
94
- try {
95
- symlinkSync(target, fallback);
96
- } catch {
97
- // best-effort
98
- }
99
- }
100
-
101
- /**
102
- * Symlink every ECC skill into the registry layout. Idempotent.
103
- *
104
- * Result shape:
105
- * - linked — skill names freshly bridged this call
106
- * - skipped — skill names whose registry target already existed and was preserved
107
- * - replaced — skill names whose registry target was replaced with the ECC symlink
108
- * - broken — skill directories that exist in eccHome/skills/ but have no SKILL.md
109
- * (broken ECC layout — distinct from already-bridged so the orchestrator
110
- * can surface a real diagnostic rather than treating it as success)
111
- * - reason — only set when the result is fully diagnostic:
112
- * 'ecc-missing' — eccHome/skills/ does not exist
113
- * 'already-bridged' — every well-formed skill was already linked
114
- * and there were no broken skills
115
- *
116
- * @param {object} opts
117
- * @param {string} opts.awHome Path of the AW home (e.g. ~/.aw).
118
- * @param {string} opts.eccHome Path of the ECC home (e.g. ~/.aw-ecc).
119
- * @param {boolean} [opts.preferEccSkills=false]
120
- * Retarget existing registry SKILL.md symlinks to ECC. Regular files are
121
- * never replaced because they may be user-owned overrides.
122
- * @returns {{
123
- * linked: string[],
124
- * skipped: string[],
125
- * replaced: string[],
126
- * broken: string[],
127
- * reason?: 'already-bridged' | 'ecc-missing'
128
- * }}
129
- */
130
- export function applyEccRegistryBridge({ awHome, eccHome, preferEccSkills = false } = {}) {
131
- if (!awHome || !eccHome) {
132
- throw new Error('applyEccRegistryBridge: awHome and eccHome are required');
133
- }
134
-
135
- const eccSkillsDir = join(eccHome, 'skills');
136
- if (!existsSync(eccSkillsDir)) {
137
- return { linked: [], skipped: [], broken: [], reason: 'ecc-missing' };
138
- }
139
-
140
- const linked = [];
141
- const skipped = [];
142
- const replaced = [];
143
- const broken = [];
144
-
145
- let entries;
146
- try {
147
- entries = readdirSync(eccSkillsDir, { withFileTypes: true });
148
- } catch {
149
- return { linked: [], skipped: [], broken: [], reason: 'ecc-missing' };
150
- }
151
-
152
- let skillDirCount = 0;
153
- for (const entry of entries) {
154
- if (!entry.isDirectory()) continue;
155
- skillDirCount += 1;
156
- const name = entry.name;
157
- const source = join(eccSkillsDir, name, 'SKILL.md');
158
-
159
- // ECC skill directory exists but lacks SKILL.md → broken layout.
160
- if (!existsSync(source)) {
161
- broken.push(name);
162
- continue;
163
- }
164
-
165
- const targetDir = join(awHome, '.aw_registry/platform/core/skills', name);
166
- const target = join(targetDir, 'SKILL.md');
167
-
168
- // If target already exists and is healthy, preserve regular files. The
169
- // optional preference mode may only retarget generated symlinks; it must
170
- // not delete user-owned registry files.
171
- let targetExists = false;
172
- try {
173
- lstatSync(target);
174
- targetExists = true;
175
- } catch {
176
- targetExists = false;
177
- }
178
-
179
- if (targetExists && !isBrokenSymlink(target)) {
180
- if (isSymlinkTo(target, source)) {
181
- skipped.push(name);
182
- continue;
183
- }
184
-
185
- if (!preferEccSkills || !isSymlink(target)) {
186
- skipped.push(name);
187
- continue;
188
- }
189
-
190
- try {
191
- unlinkSync(target);
192
- } catch {
193
- broken.push(name);
194
- continue;
195
- }
196
-
197
- try {
198
- symlinkSync(source, target);
199
- replaced.push(name);
200
- } catch {
201
- broken.push(name);
202
- }
203
- continue;
204
- }
205
-
206
- // Either absent or a broken symlink — replace.
207
- mkdirSync(targetDir, { recursive: true });
208
- if (targetExists) {
209
- try { unlinkSync(target); } catch { /* best-effort */ }
210
- }
211
- try {
212
- symlinkSync(source, target);
213
- linked.push(name);
214
- } catch {
215
- // Filesystem may forbid symlinks (rare on Codex Web). Treat as broken
216
- // so the orchestrator can surface the real diagnostic.
217
- broken.push(name);
218
- }
219
- }
220
-
221
- installRegistryFallbackLink(awHome);
222
-
223
- const result = { linked, skipped, replaced, broken };
224
- // Only flag 'already-bridged' when every well-formed skill was already linked
225
- // AND no broken skills exist — i.e. this run was a true no-op.
226
- if (
227
- skillDirCount > 0 &&
228
- linked.length === 0 &&
229
- replaced.length === 0 &&
230
- broken.length === 0 &&
231
- skipped.length === skillDirCount
232
- ) {
233
- result.reason = 'already-bridged';
234
- }
235
- return result;
236
- }