@ghl-ai/aw 0.1.68 → 0.1.70-beta.0

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,219 @@
1
+ import { existsSync, readFileSync, statSync } from 'node:fs';
2
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
3
+ import { AW_DOCS_DIR } from './constants.mjs';
4
+
5
+ export const PLAN_ARTIFACTS = [
6
+ { kind: 'prd', source: 'prd.md', html: 'prd.html' },
7
+ { kind: 'design', source: 'design.md', html: 'design.html' },
8
+ { kind: 'spec', source: 'spec.md', html: 'spec.html' },
9
+ { kind: 'tasks', source: 'tasks.md', html: 'tasks.html' },
10
+ ];
11
+
12
+ const GENERATED_STATUSES = new Set(['generated', 'published', 'html_generated_and_published']);
13
+ const SKIPPED_STATUSES = new Set(['skipped']);
14
+
15
+ export function findAwDocsProjectRoot(cwd = process.cwd()) {
16
+ let current = resolve(cwd);
17
+ while (true) {
18
+ if (existsSync(join(current, AW_DOCS_DIR))) return current;
19
+ const next = dirname(current);
20
+ if (next === current) return resolve(cwd);
21
+ current = next;
22
+ }
23
+ }
24
+
25
+ export function normalizeFeatureSlug(value) {
26
+ const raw = String(value || '').trim().replace(/\\/g, '/').replace(/\/+$/, '');
27
+ if (!raw || raw === 'true') return '';
28
+ const match = raw.match(/^(?:\.aw_docs\/)?features\/([^/]+)$/);
29
+ const slug = match ? match[1] : raw;
30
+ if (!/^[A-Za-z0-9._-]+$/.test(slug)) {
31
+ throw new Error(`Invalid feature slug "${slug}". Feature slugs may contain letters, numbers, dot, underscore, and dash only.`);
32
+ }
33
+ return slug;
34
+ }
35
+
36
+ export function resolveDocsOutputMode(projectRoot, env = process.env) {
37
+ const configPath = join(projectRoot, AW_DOCS_DIR, 'config.json');
38
+ const envMode = env.AW_DOCS_OUTPUT_MODE;
39
+ let configMode = '';
40
+
41
+ if (existsSync(configPath)) {
42
+ const config = JSON.parse(readFileSync(configPath, 'utf8'));
43
+ configMode = config.docs?.outputMode || '';
44
+ }
45
+
46
+ return normalizeOutputMode(envMode || configMode || 'dual');
47
+ }
48
+
49
+ function normalizeOutputMode(value) {
50
+ const mode = String(value || 'dual').trim().toLowerCase();
51
+ if (mode === 'md' || mode === 'markdown-only') return 'markdown';
52
+ if (mode === 'html-only') return 'html';
53
+ if (mode === 'markdown' || mode === 'html' || mode === 'dual') return mode;
54
+ return 'dual';
55
+ }
56
+
57
+ export function readFeatureState(featureDir) {
58
+ const statePath = join(featureDir, 'state.json');
59
+ if (!existsSync(statePath)) return { statePath, state: null };
60
+ return { statePath, state: JSON.parse(readFileSync(statePath, 'utf8')) };
61
+ }
62
+
63
+ export function listExpectedPlanArtifacts(featureDir, { full = false } = {}) {
64
+ return PLAN_ARTIFACTS.filter(artifact => full || existsSync(join(featureDir, artifact.source)));
65
+ }
66
+
67
+ export function validateFeatureDocs(options) {
68
+ const projectRoot = resolve(options.projectRoot || findAwDocsProjectRoot(options.cwd));
69
+ const featureSlug = normalizeFeatureSlug(options.featureSlug);
70
+ if (!featureSlug) throw new Error('Missing feature slug. Use: aw docs validate --feature <feature-slug>');
71
+
72
+ const featureDir = join(projectRoot, AW_DOCS_DIR, 'features', featureSlug);
73
+ const result = {
74
+ ok: true,
75
+ projectRoot,
76
+ featureSlug,
77
+ featureDir,
78
+ outputMode: normalizeOutputMode(options.outputMode || resolveDocsOutputMode(projectRoot, options.env)),
79
+ checked: [],
80
+ errors: [],
81
+ warnings: [],
82
+ };
83
+
84
+ if (!existsSync(featureDir) || !statSync(featureDir).isDirectory()) {
85
+ addError(result, `Missing feature folder: ${AW_DOCS_DIR}/features/${featureSlug}`);
86
+ return result;
87
+ }
88
+
89
+ const { statePath, state } = readFeatureState(featureDir);
90
+ if (!state) {
91
+ addError(result, `Missing state file: ${relativeFeaturePath(projectRoot, statePath)}`);
92
+ }
93
+
94
+ const expected = listExpectedPlanArtifacts(featureDir, { full: options.full });
95
+ if (expected.length === 0) {
96
+ addError(result, options.full
97
+ ? 'Full plan validation requires prd.md, design.md, spec.md, and tasks.md.'
98
+ : 'No canonical planning Markdown found to validate.');
99
+ }
100
+
101
+ const markdownOnly = result.outputMode === 'markdown';
102
+ for (const artifact of expected) {
103
+ validateArtifact(result, artifact, { projectRoot, featureSlug, featureDir, state, markdownOnly, publishRequired: options.publishRequired });
104
+ }
105
+
106
+ if (state && expected.length > 0 && marksReadyForBuild(state) && result.errors.length > 0) {
107
+ result.warnings.push('state.json marks this feature ready for build while the HTML companion gate is failing.');
108
+ }
109
+
110
+ return result;
111
+ }
112
+
113
+ function validateArtifact(result, artifact, context) {
114
+ const sourcePath = join(context.featureDir, artifact.source);
115
+ const htmlPath = join(context.featureDir, artifact.html);
116
+ const sourceRel = featureRel(context.featureSlug, artifact.source);
117
+ const htmlRel = featureRel(context.featureSlug, artifact.html);
118
+ const entry = findCompanionEntry(context.state?.html_companion_artifacts, { sourceRel, htmlRel, kind: artifact.kind });
119
+
120
+ const record = {
121
+ kind: artifact.kind,
122
+ source: sourceRel,
123
+ html: htmlRel,
124
+ state: entry || null,
125
+ };
126
+ result.checked.push(record);
127
+
128
+ if (!existsSync(sourcePath)) {
129
+ addError(result, `Missing planning Markdown: ${sourceRel}`);
130
+ return;
131
+ }
132
+
133
+ if (context.markdownOnly || isExplicitMarkdownSkip(entry)) {
134
+ if (!context.markdownOnly && !isExplicitMarkdownSkip(entry)) {
135
+ addError(result, `Invalid Markdown-only skip for ${artifact.html}: missing skip_reason explicit_markdown_only.`);
136
+ }
137
+ return;
138
+ }
139
+
140
+ if (!existsSync(htmlPath)) {
141
+ addError(result, `Missing HTML companion for ${artifact.source}: ${htmlRel}`);
142
+ }
143
+
144
+ if (!entry) {
145
+ addError(result, `Missing state.json html_companion_artifacts entry for ${artifact.html}`);
146
+ return;
147
+ }
148
+
149
+ if (!GENERATED_STATUSES.has(String(entry.status || '').toLowerCase())) {
150
+ addError(result, `Invalid companion status for ${artifact.html}: expected generated, got ${entry.status || 'missing'}`);
151
+ }
152
+
153
+ if (!entry.owner || !entry.execution_mode || !entry.runner) {
154
+ addError(result, `Incomplete companion provenance for ${artifact.html}: owner, execution_mode, and runner are required`);
155
+ }
156
+
157
+ if (context.publishRequired && !hasPublishedOrBlockedLink(entry)) {
158
+ addError(result, `Missing publish result for ${artifact.html}: expected published links or a concrete publish blocker`);
159
+ }
160
+ }
161
+
162
+ function findCompanionEntry(companions, expected) {
163
+ if (!companions) return null;
164
+ const values = Array.isArray(companions)
165
+ ? companions
166
+ : Object.entries(companions).map(([key, value]) => ({ key, value }));
167
+
168
+ for (const item of values) {
169
+ const entry = item?.value && typeof item.value === 'object' ? item.value : item;
170
+ const key = item?.key || '';
171
+ if (!entry || typeof entry !== 'object') continue;
172
+ if (key === expected.htmlRel || key === basename(expected.htmlRel)) return entry;
173
+ if (pathMatches(entry.html_path || entry.primary || entry.publish_copy, expected.htmlRel)) return entry;
174
+ if (pathMatches(entry.source_path, expected.sourceRel)) return entry;
175
+ if (entry.artifact_kind === expected.kind || entry.kind === expected.kind) return entry;
176
+ }
177
+
178
+ return null;
179
+ }
180
+
181
+ function isExplicitMarkdownSkip(entry) {
182
+ if (!entry || typeof entry !== 'object') return false;
183
+ const status = String(entry.status || '').toLowerCase();
184
+ return SKIPPED_STATUSES.has(status) && entry.skip_reason === 'explicit_markdown_only';
185
+ }
186
+
187
+ function hasPublishedOrBlockedLink(entry) {
188
+ const publishStatus = String(entry.publish_status || '').toLowerCase();
189
+ const links = entry.remote_links || {};
190
+ return publishStatus === 'published'
191
+ || Boolean(entry.remote_url || entry.teamofone_url || entry.devtools_url || links.devtools || links.teamofone)
192
+ || (publishStatus === 'blocked' && Boolean(entry.publish_blocker || entry.blocked_reason));
193
+ }
194
+
195
+ function marksReadyForBuild(state) {
196
+ const values = [state.status, state.stage_status, state.planning_status, state.next_stage].map(value => String(value || '').toLowerCase());
197
+ return values.some(value => value.includes('ready_for_build') || value === 'build');
198
+ }
199
+
200
+ function pathMatches(value, expectedRel) {
201
+ if (!value) return false;
202
+ const normalized = String(value).replace(/\\/g, '/').replace(/^\.\//, '');
203
+ const expected = expectedRel.replace(/\\/g, '/').replace(/^\.\//, '');
204
+ return normalized === expected || normalized.endsWith(`/${expected}`);
205
+ }
206
+
207
+ function featureRel(featureSlug, filename) {
208
+ return `${AW_DOCS_DIR}/features/${featureSlug}/${filename}`;
209
+ }
210
+
211
+ function relativeFeaturePath(projectRoot, absPath) {
212
+ if (!isAbsolute(absPath)) return absPath;
213
+ return absPath.startsWith(projectRoot) ? absPath.slice(projectRoot.length + 1) : absPath;
214
+ }
215
+
216
+ function addError(result, message) {
217
+ result.ok = false;
218
+ result.errors.push(message);
219
+ }
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * diagnoseAwRouterView({ awHome })
11
11
  * Codex-relevant: replays the upstream `find * /skills/* /SKILL.md`
12
- * enumeration logic against ~/.aw/.aw_registry/platform/core/skills/
12
+ * enumeration logic against ~/.aw/.aw_registry/aw/skills/
13
13
  * and reports which expected stage skills are visible to the
14
14
  * SessionStart hook. Catches Bug A failures (registry path empty
15
15
  * post-init).
@@ -22,7 +22,7 @@
22
22
  * Tests both that the wrapper is registered AND that it actually
23
23
  * emits the router (catches "wrapper installed but does nothing").
24
24
  *
25
- * diagnoseSkillResolution({ harness, home, awHome, eccHome, skills })
25
+ * diagnoseSkillResolution({ harness, home, awHome, skills })
26
26
  * Multi-path × multi-skill probe. For each skill, checks four
27
27
  * candidate paths in priority order. Top-level ok=true ONLY if
28
28
  * using-aw-skills AND every stage skill the slim card promises
@@ -146,8 +146,8 @@ function slashShimSummaryToken(action) {
146
146
  * ───────────────────────────────────────────────────────────────────────── */
147
147
 
148
148
  /**
149
- * Replay of the upstream session-start.sh enumerator scan:
150
- * find <awHome>/.aw_registry/platform -path STAR/skills/STAR/SKILL.md
149
+ * Replay of the AW registry skill scan:
150
+ * find <awHome>/.aw_registry/aw/skills/<skill>/SKILL.md
151
151
  *
152
152
  * @param {object} opts
153
153
  * @param {string} opts.awHome
@@ -156,8 +156,8 @@ function slashShimSummaryToken(action) {
156
156
  export function diagnoseAwRouterView({ awHome } = {}) {
157
157
  if (!awHome) throw new Error('diagnoseAwRouterView: awHome is required');
158
158
 
159
- const platformRoot = join(awHome, '.aw_registry/platform');
160
- const enumerableSkills = enumerateRegistrySkills(platformRoot);
159
+ const awSkillsRoot = join(awHome, '.aw_registry/aw/skills');
160
+ const enumerableSkills = enumerateAwSkills(awSkillsRoot);
161
161
  const expected = [...EXPECTED_STAGE_SKILLS];
162
162
  const missing = expected.filter((s) => !enumerableSkills.includes(s));
163
163
  const ok = missing.length === 0;
@@ -166,38 +166,23 @@ export function diagnoseAwRouterView({ awHome } = {}) {
166
166
  }
167
167
 
168
168
  /**
169
- * Walk <root>/<team>/skills/<skill>/SKILL.md two levels deep, mirroring the
170
- * upstream find pattern. Returns the deduped sorted list of skill
171
- * directory names.
169
+ * Walk <root>/<skill>/SKILL.md and return the deduped sorted skill names.
172
170
  */
173
- function enumerateRegistrySkills(platformRoot) {
174
- if (!existsSync(platformRoot)) return [];
171
+ function enumerateAwSkills(awSkillsRoot) {
172
+ if (!existsSync(awSkillsRoot)) return [];
175
173
  const set = new Set();
176
- let teams;
174
+ let skills;
177
175
  try {
178
- teams = readdirSync(platformRoot, { withFileTypes: true });
176
+ skills = readdirSync(awSkillsRoot, { withFileTypes: true });
179
177
  } catch {
180
- // readdir on the platform root failed (EACCES, ENOENT). Treat as
178
+ // readdir on the AW skills root failed (EACCES, ENOENT). Treat as
181
179
  // "no skills enumerable" — the caller's missing-list will surface it.
182
180
  return [];
183
181
  }
184
- for (const team of teams) {
185
- if (!team.isDirectory()) continue;
186
- const skillsDir = join(platformRoot, team.name, 'skills');
187
- if (!existsSync(skillsDir)) continue;
188
- let skills;
189
- try {
190
- skills = readdirSync(skillsDir, { withFileTypes: true });
191
- } catch {
192
- // readdir on a team's skills/ subdirectory failed — skip this team
193
- // dir but continue probing siblings.
194
- continue;
195
- }
196
- for (const skill of skills) {
197
- if (!skill.isDirectory()) continue;
198
- const skillMd = join(skillsDir, skill.name, 'SKILL.md');
199
- if (existsSync(skillMd)) set.add(skill.name);
200
- }
182
+ for (const skill of skills) {
183
+ if (!skill.isDirectory()) continue;
184
+ const skillMd = join(awSkillsRoot, skill.name, 'SKILL.md');
185
+ if (existsSync(skillMd)) set.add(skill.name);
201
186
  }
202
187
  return [...set].sort();
203
188
  }
@@ -337,7 +322,6 @@ function envelopeContainsRouter(output) {
337
322
  * @param {Harness} opts.harness
338
323
  * @param {string} opts.home
339
324
  * @param {string} opts.awHome
340
- * @param {string} opts.eccHome
341
325
  * @param {string[]} [opts.skills] Default: DEFAULT_SKILL_LIST
342
326
  * @returns {{
343
327
  * perSkill: SkillResolutionResult[],
@@ -350,12 +334,11 @@ export function diagnoseSkillResolution(opts = {}) {
350
334
  if (!opts.harness) throw new Error('diagnoseSkillResolution: harness is required');
351
335
  if (!opts.home) throw new Error('diagnoseSkillResolution: home is required');
352
336
  if (!opts.awHome) throw new Error('diagnoseSkillResolution: awHome is required');
353
- if (!opts.eccHome) throw new Error('diagnoseSkillResolution: eccHome is required');
354
337
 
355
- const { harness, home, awHome, eccHome } = opts;
338
+ const { harness, home, awHome } = opts;
356
339
  const skills = opts.skills ?? [...DEFAULT_SKILL_LIST];
357
340
 
358
- const perSkill = skills.map((skill) => probeOneSkill(skill, harness, home, awHome, eccHome));
341
+ const perSkill = skills.map((skill) => probeOneSkill(skill, harness, home, awHome));
359
342
  const routerSkill = perSkill.find((e) => e.skill === ROUTER_SKILL) ?? {
360
343
  skill: ROUTER_SKILL,
361
344
  candidatePathsChecked: [],
@@ -374,11 +357,11 @@ function harnessSkillsDir(harness, home) {
374
357
  if (harness === 'claude-web') return join(home, '.claude/skills');
375
358
  if (harness === 'cursor-cloud') return join(home, '.cursor/skills');
376
359
  // codex-web: registry path is the canonical source; the per-harness skills
377
- // dir is conventionally absent. We still probe the registry + ECC paths.
360
+ // dir is conventionally absent. We still probe the AW registry path.
378
361
  return null;
379
362
  }
380
363
 
381
- function probeOneSkill(skill, harness, home, awHome, eccHome) {
364
+ function probeOneSkill(skill, harness, home, awHome) {
382
365
  const harnessDir = harnessSkillsDir(harness, home);
383
366
  const candidatePathsChecked = [];
384
367
 
@@ -386,8 +369,7 @@ function probeOneSkill(skill, harness, home, awHome, eccHome) {
386
369
  candidatePathsChecked.push(join(harnessDir, skill, 'SKILL.md'));
387
370
  candidatePathsChecked.push(join(harnessDir, `platform-core-${skill}`, 'SKILL.md'));
388
371
  }
389
- candidatePathsChecked.push(join(awHome, '.aw_registry/platform/core/skills', skill, 'SKILL.md'));
390
- candidatePathsChecked.push(join(eccHome, 'skills', skill, 'SKILL.md'));
372
+ candidatePathsChecked.push(join(awHome, '.aw_registry/aw/skills', skill, 'SKILL.md'));
391
373
 
392
374
  let found = null;
393
375
  for (const p of candidatePathsChecked) {
@@ -544,7 +526,7 @@ function enumerateConfigKeysOnly(filePath) {
544
526
  }
545
527
 
546
528
  function sampleRegistrySkillPaths(awHome, max) {
547
- const root = join(awHome, '.aw_registry/platform');
529
+ const root = join(awHome, '.aw_registry/aw');
548
530
  if (!existsSync(root)) return [' <registry not provisioned>'];
549
531
  const out = [];
550
532
  walkSkillMd(root, out, max);
@@ -569,8 +551,7 @@ function walkSkillMd(dir, out, max) {
569
551
  } else if (e.isDirectory()) {
570
552
  walkSkillMd(p, out, max);
571
553
  } else if (e.isSymbolicLink()) {
572
- // Cheap stat check — symlinks pointing at SKILL.md (the bridge case)
573
- // should still appear in the dump.
554
+ // Cheap stat check — symlinks pointing at SKILL.md should still appear.
574
555
  try {
575
556
  const st = statSync(p);
576
557
  if (st.isFile() && e.name === 'SKILL.md') out.push(p);
package/c4/index.mjs CHANGED
@@ -29,7 +29,6 @@ export {
29
29
  verifyAuth,
30
30
  preflightPlatformDocs,
31
31
  } from './gitAuth.mjs';
32
- export { applyEccRegistryBridge } from './eccRegistryBridge.mjs';
33
32
  export {
34
33
  SLIM_CARD_HARD_CEILING_BYTES,
35
34
  REQUIRED_ENFORCEMENT_PHRASES,
package/c4/preflight.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  * c4/preflight.mjs — preflight aggregator for `aw c4`.
3
3
  *
4
4
  * Composes harness, token resolution, REST-API auth verification, gh CLI
5
- * presence, ECC bridge-status probe, and disk-free reporting into a single
5
+ * presence, AW registry-skill probe, and disk-free reporting into a single
6
6
  * PreflightResult shape with per-failure recommendation strings. Pure
7
7
  * orchestration over already-tested c4 primitives — no new I/O patterns.
8
8
  *
@@ -71,10 +71,10 @@ const REC = Object.freeze({
71
71
  `Insufficient disk: ${mb}MB free (aw c4 needs at least ${DISK_FREE_THRESHOLD_MB}MB ` +
72
72
  'for ECC clone + registry pull).',
73
73
  bridgeWillInstall:
74
- 'ECC registry bridge will be installed during this run (Codex only ' +
75
- 'Bug A workaround that symlinks ECC stage skills into ~/.aw/.aw_registry).',
74
+ 'AW registry skills will be installed during this run from platform-docs ' +
75
+ '(Codex uses ~/.aw/.aw_registry/aw/skills for stage routing).',
76
76
  bridgeFailed:
77
- 'ECC and/or AW registry not yet provisioned — aw init will create them ' +
77
+ 'AW registry skills are not yet provisioned — aw init will create them ' +
78
78
  'on the first c4 run; re-run preflight afterwards.',
79
79
  });
80
80
 
@@ -120,22 +120,18 @@ function classifyAuthError(verifyResult) {
120
120
  return 'unknown';
121
121
  }
122
122
 
123
- function probeBridgeStatus(harness, awHome, eccHome) {
123
+ function probeBridgeStatus(harness, awHome) {
124
124
  if (harness !== 'codex-web') return 'ok';
125
125
 
126
- // Bridge installs ~/.aw/.aw_registry/platform/core/skills/<aw-*>/SKILL.md
127
- // symlinks pointing at ~/.aw-ecc/skills/<aw-*>/SKILL.md. If aw-plan is
128
- // already present in the registry path, the bridge is a no-op (ok).
129
- const bridgedSkillPath = join(
126
+ // AW skills are now owned by platform-docs under the top-level aw namespace.
127
+ // If aw-plan is already present in the registry path, routing is ready.
128
+ const awSkillPath = join(
130
129
  awHome,
131
- '.aw_registry/platform/core/skills/aw-plan/SKILL.md',
130
+ '.aw_registry/aw/skills/aw-plan/SKILL.md',
132
131
  );
133
- if (existsSync(bridgedSkillPath)) return 'ok';
132
+ if (existsSync(awSkillPath)) return 'ok';
134
133
 
135
- // Bridge can install when both ECC home and the registry skeleton exist.
136
- // (The aw-plan skill source must exist somewhere — the bridge follows
137
- // applyEccRegistryBridge's contract; preflight only certifies feasibility.)
138
- if (existsSync(eccHome) && existsSync(awHome)) return 'will-install';
134
+ if (existsSync(awHome)) return 'will-install';
139
135
 
140
136
  return 'failed';
141
137
  }
@@ -180,7 +176,6 @@ function buildRecommendations({
180
176
  * @param {typeof globalThis.fetch} [opts.fetchImpl]
181
177
  * @param {string} [opts.home] Defaults to env.HOME.
182
178
  * @param {string} [opts.awHome] Defaults to <home>/.aw.
183
- * @param {string} [opts.eccHome] Defaults to <home>/.aw-ecc.
184
179
  * @param {number} [opts.diskFreeMb] Inject for tests; production defaults to a real statfs probe.
185
180
  * @returns {Promise<PreflightResult>}
186
181
  */
@@ -200,7 +195,6 @@ export async function runPreflight(opts = {}) {
200
195
  // when env is the injected `{}`, opts.home should have been passed instead.
201
196
  const home = opts.home ?? env.HOME ?? '~';
202
197
  const awHome = opts.awHome ?? join(home, '.aw');
203
- const eccHome = opts.eccHome ?? join(home, '.aw-ecc');
204
198
 
205
199
  // 1. Token resolution.
206
200
  const { token, source: tokenSource } = getGithubToken(env);
@@ -221,7 +215,7 @@ export async function runPreflight(opts = {}) {
221
215
  const ghCliPresent = runShell(shell, 'command -v gh').ok;
222
216
 
223
217
  // 4. Bridge status (codex-web only; other harnesses always 'ok').
224
- const bridgeStatus = probeBridgeStatus(harness, awHome, eccHome);
218
+ const bridgeStatus = probeBridgeStatus(harness, awHome);
225
219
 
226
220
  // 5. Disk free MB. For tests, callers inject the value directly; in
227
221
  // production the orchestrator passes a measured number. Defaulting to
package/cli.mjs CHANGED
@@ -19,6 +19,7 @@ const COMMANDS = {
19
19
  drop: () => import('./commands/drop.mjs').then(m => m.dropCommand),
20
20
  status: () => import('./commands/status.mjs').then(m => m.statusCommand),
21
21
  doctor: () => import('./commands/doctor.mjs').then(m => m.doctorCommand),
22
+ docs: () => import('./commands/docs.mjs').then(m => m.docsCommand),
22
23
  routing: () => import('./commands/startup.mjs').then(m => m.routingCommand),
23
24
  startup: () => import('./commands/startup.mjs').then(m => m.startupCommand),
24
25
  mcp: () => import('./commands/mcp.mjs').then(m => m.mcpCommand),
@@ -159,6 +160,7 @@ function printHelp() {
159
160
  cmd('aw search <query>', 'Find agents & skills (local + registry)'),
160
161
 
161
162
  sec('Manage'),
163
+ cmd('aw docs validate --feature <slug>', 'Validate .aw_docs planning HTML companions before build handoff'),
162
164
  cmd('aw status', 'Show synced paths, modified files & conflicts'),
163
165
  cmd('aw doctor', 'Run a health check for routing, MCP, plugin, and AW ECC surfaces'),
164
166
  cmd('aw link', 'Link current project as a git worktree (wires IDE symlinks)'),
package/codex.mjs CHANGED
@@ -88,10 +88,19 @@ set -euo pipefail
88
88
 
89
89
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
90
90
  ROOT_DIR="$(cd "\${SCRIPT_DIR}/.." && pwd)"
91
- TARGET_SCRIPT="\$ROOT_DIR/.aw_registry/platform/core/skills/using-aw-skills/hooks/session-start.sh"
92
-
93
- if [[ ! -f "\$TARGET_SCRIPT" ]]; then
94
- echo '{"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": "WARNING: AW session-start hook not found at .aw_registry/platform/core/skills/using-aw-skills/hooks/session-start.sh. Run aw pull platform or aw init."}}'
91
+ TARGET_SCRIPT=""
92
+ for candidate in \
93
+ "\$ROOT_DIR/.aw/.aw_registry/aw/skills/using-aw-skills/hooks/session-start.sh" \
94
+ "\$ROOT_DIR/.aw_registry/aw/skills/using-aw-skills/hooks/session-start.sh"
95
+ do
96
+ if [[ -f "\$candidate" ]]; then
97
+ TARGET_SCRIPT="\$candidate"
98
+ break
99
+ fi
100
+ done
101
+
102
+ if [[ -z "\$TARGET_SCRIPT" ]]; then
103
+ echo '{"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": "WARNING: AW session-start hook not found at .aw_registry/aw/skills/using-aw-skills/hooks/session-start.sh. Run aw pull platform or aw init."}}'
95
104
  exit 0
96
105
  fi
97
106
 
package/commands/c4.mjs CHANGED
@@ -101,11 +101,13 @@ function safeReaddirSync(dir) {
101
101
  try { return readdirSync(dir); } catch { return []; }
102
102
  }
103
103
 
104
- function safeListNamespaceDirs(dir) {
104
+ function safeListNamespaceDirs(dir, fsApi = {}) {
105
+ const readDir = fsApi.readdirSync ?? readdirSync;
106
+ const stat = fsApi.statSync ?? statSync;
105
107
  try {
106
- return readdirSync(dir).filter((entry) => {
108
+ return readDir(dir).filter((entry) => {
107
109
  if (entry.startsWith('.')) return false;
108
- try { return statSync(join(dir, entry)).isDirectory(); } catch { return false; }
110
+ try { return stat(join(dir, entry)).isDirectory(); } catch { return false; }
109
111
  });
110
112
  } catch { return []; }
111
113
  }
@@ -132,17 +134,15 @@ function setupGitAuth({ token, c4, home, cwd }) {
132
134
  c4.ensureOriginRemote({ cwd });
133
135
  }
134
136
 
135
- function resolveCodexRouterSkillPath({ awHome, eccHome, existsSync = fsExistsSync }) {
136
- const eccRouterSkillPath = join(eccHome, 'skills/using-aw-skills/SKILL.md');
137
- if (existsSync(eccRouterSkillPath)) return eccRouterSkillPath;
138
- return join(awHome, '.aw_registry/platform/core/skills/using-aw-skills/SKILL.md');
137
+ function resolveCodexRouterSkillPath({ awHome }) {
138
+ return join(awHome, '.aw_registry/aw/skills/using-aw-skills/SKILL.md');
139
139
  }
140
140
 
141
141
  /* ─────────────────────────────────────────────────────────────────────────
142
142
  * Per-harness branch (step 10).
143
143
  *
144
144
  * Returns `{ ok, bridge, injector }` so the summary line can pick up
145
- * codex-specific status ticks. Hard failures (bridge / injector / claude
145
+ * codex-specific status ticks. Hard failures (injector / claude
146
146
  * marketplace / slim card) bubble up as thrown Errors.
147
147
  * ───────────────────────────────────────────────────────────────────────── */
148
148
 
@@ -165,9 +165,8 @@ function runHarnessBranch({ harness, c4, home, eccHome, cwd, writer, existsSync
165
165
  }
166
166
  if (harness === 'codex-web') {
167
167
  const awHome = join(home, '.aw');
168
- c4.applyEccRegistryBridge({ awHome, eccHome });
169
168
  c4.ensureCodexHooksFlag(home);
170
- const routerSkillPath = resolveCodexRouterSkillPath({ awHome, eccHome, existsSync });
169
+ const routerSkillPath = resolveCodexRouterSkillPath({ awHome });
171
170
  // Pass hookPath explicitly: defaultHookPath() reads os.homedir() at module
172
171
  // load time, which ignores the orchestrator's `home` (env.HOME). Without
173
172
  // this, installs land in the real ~/.codex even when HOME is overridden
@@ -411,11 +410,11 @@ export async function c4Command(rawArgs, overrides = {}) {
411
410
  // check, so we specifically look for directories (namespaces like
412
411
  // "platform") to confirm the registry content was actually pulled.
413
412
  {
414
- const registryNamespaces = safeListNamespaceDirs(awRegistry);
413
+ const registryNamespaces = safeListNamespaceDirs(awRegistry, fs);
415
414
  if (registryNamespaces.length === 0) {
416
415
  writer.stderr('[aw-c4] registry has no namespace directories after init — retrying with aw pull\n');
417
416
  const pullRes = spawnSync('aw', ['pull'], { stdio: 'pipe' });
418
- if (pullRes?.status !== 0 || safeListNamespaceDirs(awRegistry).length === 0) {
417
+ if (pullRes?.status !== 0 || safeListNamespaceDirs(awRegistry, fs).length === 0) {
419
418
  writer.stderr('[aw-c4] FATAL: registry commands were not fetched\n');
420
419
  return exit(1);
421
420
  }
@@ -0,0 +1,78 @@
1
+ import * as fmt from '../fmt.mjs';
2
+ import { chalk } from '../fmt.mjs';
3
+ import { validateFeatureDocs, normalizeFeatureSlug } from '../aw-docs-validate.mjs';
4
+
5
+ export async function docsCommand(args) {
6
+ const subcommand = args._positional?.[0] || 'validate';
7
+ if (args['--help'] || subcommand === 'help') {
8
+ printDocsHelp();
9
+ return;
10
+ }
11
+
12
+ if (!['validate', 'check', 'verify'].includes(subcommand)) {
13
+ fmt.cancel(`Unknown aw docs command: ${subcommand}\nRun aw docs --help for usage.`);
14
+ }
15
+
16
+ const featureInput = args['--feature'] || args._positional?.[1];
17
+ let featureSlug = '';
18
+ try {
19
+ featureSlug = normalizeFeatureSlug(featureInput);
20
+ } catch (error) {
21
+ fmt.cancel(error.message);
22
+ }
23
+ if (!featureSlug) {
24
+ fmt.cancel('Missing feature slug. Use: aw docs validate --feature <feature-slug>');
25
+ }
26
+ const result = validateFeatureDocs({
27
+ cwd: process.cwd(),
28
+ featureSlug,
29
+ full: args['--full'] === true,
30
+ outputMode: args['--output-mode'],
31
+ publishRequired: args['--publish-required'] === true,
32
+ });
33
+
34
+ if (args['--json'] === true) {
35
+ console.log(JSON.stringify(result, null, 2));
36
+ if (!result.ok) process.exitCode = 1;
37
+ return;
38
+ }
39
+
40
+ fmt.intro('aw docs validate');
41
+
42
+ const checked = result.checked.map(item => ` ${item.kind.padEnd(6)} ${chalk.dim(item.source)} -> ${chalk.dim(item.html)}`).join('\n');
43
+ fmt.note([
44
+ `${chalk.dim('feature:')} ${result.featureSlug}`,
45
+ `${chalk.dim('outputMode:')} ${result.outputMode}`,
46
+ `${chalk.dim('checked:')} ${result.checked.length}`,
47
+ ].join('\n'), 'Scope');
48
+
49
+ if (checked) fmt.note(checked, 'Planning Companions');
50
+
51
+ for (const warning of result.warnings) {
52
+ fmt.logWarn(warning);
53
+ }
54
+
55
+ if (!result.ok) {
56
+ fmt.note(result.errors.map(error => ` - ${error}`).join('\n'), chalk.red('Blocking Issues'));
57
+ fmt.cancel('AW docs validation failed. Run platform-core:echo-direct or record an explicit blocker before marking this feature ready.');
58
+ }
59
+
60
+ fmt.outro('⟁ AW docs validation passed');
61
+ }
62
+
63
+ function printDocsHelp() {
64
+ console.log([
65
+ 'Usage:',
66
+ ' aw docs validate --feature <slug> [--full] [--publish-required] [--json]',
67
+ ' aw docs validate .aw_docs/features/<slug> [--full]',
68
+ '',
69
+ 'Validates AW planning HTML companions before ready_for_build handoff.',
70
+ '',
71
+ 'Options:',
72
+ ' --feature <slug> Feature slug under .aw_docs/features/',
73
+ ' --full Require prd/design/spec/tasks Markdown and HTML companions',
74
+ ' --publish-required Require Devtools/GitHub publish links or a concrete publish blocker',
75
+ ' --output-mode <mode> Override docs output mode: dual, html, or markdown',
76
+ ' --json Print machine-readable result',
77
+ ].join('\n'));
78
+ }
@@ -548,8 +548,8 @@ function buildDoctorChecks(homeDir, cwd) {
548
548
  const rulesDir = resolveRulesDir(homeDir);
549
549
 
550
550
  const usingAwHookPath = [
551
- join(homeDir, '.aw_registry', 'platform', 'core', 'skills', 'using-aw-skills', 'hooks', 'session-start.sh'),
552
- join(homeDir, '.aw', '.aw_registry', 'platform', 'core', 'skills', 'using-aw-skills', 'hooks', 'session-start.sh'),
551
+ join(homeDir, '.aw_registry', 'aw', 'skills', 'using-aw-skills', 'hooks', 'session-start.sh'),
552
+ join(homeDir, '.aw', '.aw_registry', 'aw', 'skills', 'using-aw-skills', 'hooks', 'session-start.sh'),
553
553
  ].find(existsSync);
554
554
 
555
555
  checks.push(usingAwHookPath
package/commands/init.mjs CHANGED
@@ -49,7 +49,16 @@ import {
49
49
  syncWorktreeSparseCheckout,
50
50
  findNearestWorktree,
51
51
  } from '../git.mjs';
52
- import { REGISTRY_DIR, REGISTRY_REPO, REGISTRY_URL, DOCS_SOURCE_DIR, AW_DOCS_DIR, RULES_SOURCE_DIR, RULES_RUNTIME_DIR } from '../constants.mjs';
52
+ import {
53
+ REGISTRY_DIR,
54
+ REGISTRY_REPO,
55
+ REGISTRY_URL,
56
+ AW_REGISTRY_NAMESPACE_DIR,
57
+ DOCS_SOURCE_DIR,
58
+ AW_DOCS_DIR,
59
+ RULES_SOURCE_DIR,
60
+ RULES_RUNTIME_DIR,
61
+ } from '../constants.mjs';
53
62
  import { syncFileTree } from '../file-tree.mjs';
54
63
  import { installAwUsageHooks, formatAwUsageHooksInstallReport } from '../install-aw-usage-hooks.mjs';
55
64
 
@@ -390,7 +399,7 @@ export async function initCommand(args) {
390
399
  const isNewSubTeam = folderName && cfg && !cfg.include.includes(folderName);
391
400
  if (isNewSubTeam) {
392
401
  if (!silent) fmt.logStep(`Adding sub-team ${chalk.cyan(folderName)}...`);
393
- const newSparsePaths = [`.aw_registry/${folderName}`, DOCS_SOURCE_DIR, AW_DOCS_DIR, RULES_SOURCE_DIR];
402
+ const newSparsePaths = [AW_REGISTRY_NAMESPACE_DIR, `.aw_registry/${folderName}`, DOCS_SOURCE_DIR, AW_DOCS_DIR, RULES_SOURCE_DIR];
394
403
  addToSparseCheckout(AW_HOME, newSparsePaths);
395
404
  config.addPattern(GLOBAL_AW_DIR, folderName);
396
405
  scaffoldNamespace(AW_HOME, folderName);
@@ -537,7 +546,7 @@ export async function initCommand(args) {
537
546
  }
538
547
 
539
548
  // Determine sparse paths
540
- const sparsePaths = [`.aw_registry/platform`, DOCS_SOURCE_DIR, AW_DOCS_DIR, RULES_SOURCE_DIR, `.aw_registry/AW-PROTOCOL.md`, `CODEOWNERS`];
549
+ const sparsePaths = [AW_REGISTRY_NAMESPACE_DIR, `.aw_registry/platform`, DOCS_SOURCE_DIR, AW_DOCS_DIR, RULES_SOURCE_DIR, `.aw_registry/AW-PROTOCOL.md`, `CODEOWNERS`];
541
550
  if (folderName) {
542
551
  sparsePaths.push(`.aw_registry/${folderName}`);
543
552
  }
package/commands/pull.mjs CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  import {
27
27
  REGISTRY_DIR,
28
28
  REGISTRY_URL,
29
+ AW_REGISTRY_NAMESPACE_DIR,
29
30
  DOCS_SOURCE_DIR,
30
31
  AW_DOCS_DIR,
31
32
  RULES_SOURCE_DIR,
@@ -93,7 +94,7 @@ export async function pullCommand(args) {
93
94
  // Ensure platform pulls also fetch docs and rules on older installs that
94
95
  // pre-date the new sparse-checkout paths.
95
96
  if (input === 'platform') {
96
- addToSparseCheckout(AW_HOME, [`.aw_registry/platform`, DOCS_SOURCE_DIR, AW_DOCS_DIR, RULES_SOURCE_DIR]);
97
+ addToSparseCheckout(AW_HOME, [AW_REGISTRY_NAMESPACE_DIR, `.aw_registry/platform`, DOCS_SOURCE_DIR, AW_DOCS_DIR, RULES_SOURCE_DIR]);
97
98
  if (!cfg.include.includes('platform')) {
98
99
  config.addPattern(GLOBAL_AW_DIR, 'platform');
99
100
  }
package/constants.mjs CHANGED
@@ -19,6 +19,9 @@ export const REGISTRY_URL = process.env.AW_REGISTRY_URL
19
19
  /** Directory inside the registry repo that holds platform/ and [template]/ */
20
20
  export const REGISTRY_DIR = '.aw_registry';
21
21
 
22
+ /** Top-level AW namespace in the registry. Holds active AW-owned skills. */
23
+ export const AW_REGISTRY_NAMESPACE_DIR = `${REGISTRY_DIR}/aw`;
24
+
22
25
  /** Directory in platform-docs repo containing documentation (pulled into platform/docs/) */
23
26
  export const DOCS_SOURCE_DIR = 'content';
24
27
 
package/ecc.mjs CHANGED
@@ -12,7 +12,7 @@ import { applyStoredStartupPreferences } from "./startup.mjs";
12
12
 
13
13
  const AW_ECC_REPO_SSH = "git@github.com:shreyansh-ghl/aw-ecc.git";
14
14
  const AW_ECC_REPO_HTTPS = "https://github.com/shreyansh-ghl/aw-ecc.git";
15
- export const AW_ECC_TAG = "v1.4.65";
15
+ export const AW_ECC_TAG = "v1.4.66";
16
16
  const REQUIRED_ECC_FILES = [
17
17
  "package.json",
18
18
  "scripts/install-apply.js",
@@ -24,19 +24,33 @@ const PLUGIN_KEY = `aw@${MARKETPLACE_NAME}`;
24
24
 
25
25
  function eccDir() { return join(homedir(), ".aw-ecc"); }
26
26
 
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:*.
27
+ // File-copy targets use explicit non-skill module sets so hooks, rules,
28
+ // command compatibility, shared references, and install-state can remain
29
+ // available while AW skills come from platform-docs/.aw_registry/aw.
30
30
  // Using `--without baseline:commands` with broader profiles is unsafe because
31
31
  // some optional modules depend on commands-core and cause install-apply to fail.
32
32
  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
- ];
33
+ const FILE_COPY_MODULES_BY_TARGET = {
34
+ claude: [
35
+ "rules-core",
36
+ "agents-core",
37
+ "hooks-runtime",
38
+ "platform-configs",
39
+ ],
40
+ cursor: [
41
+ "rules-core",
42
+ "agents-core",
43
+ "commands-core",
44
+ "hooks-runtime",
45
+ "platform-configs",
46
+ ],
47
+ codex: [
48
+ "rules-core",
49
+ "agents-core",
50
+ "hooks-runtime",
51
+ "platform-configs",
52
+ ],
53
+ };
40
54
 
41
55
  const TARGET_STATE = {
42
56
  claude: { state: ".claude/ecc/install-state.json" },
@@ -246,7 +260,6 @@ function cloneOrUpdate(tag, dest) {
246
260
  */
247
261
  function transformCursorAwRefs(home) {
248
262
  const dirs = [
249
- join(home, ".cursor", "skills"),
250
263
  join(home, ".cursor", "rules"),
251
264
  ];
252
265
  for (const dir of dirs) {
@@ -320,16 +333,38 @@ function namespaceCursorCommands(home) {
320
333
  }
321
334
 
322
335
  /**
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.
336
+ * Generate Codex prompt files from ECC commands only.
337
+ *
338
+ * The old ECC sync script also materialized ECC skills into Codex-owned
339
+ * skill directories. AW skills now come from platform-docs/.aw_registry/aw,
340
+ * so this runtime keeps the command prompt compatibility surface and does
341
+ * not run the broad ECC skill sync.
327
342
  */
328
- function syncEccToCodex(repoDir) {
329
- const syncScript = join(repoDir, "scripts", "sync-ecc-to-codex.sh");
330
- if (!existsSync(syncScript)) return;
343
+ function syncEccCommandsToCodex(repoDir) {
344
+ const commandsDir = join(repoDir, "commands");
345
+ if (!existsSync(commandsDir)) return;
331
346
  try {
332
- run(`bash "${syncScript}"`, { cwd: homedir() });
347
+ const promptsDir = join(homedir(), ".codex", "prompts");
348
+ mkdirSync(promptsDir, { recursive: true });
349
+ const manifest = [];
350
+
351
+ for (const file of readdirSync(commandsDir).filter((entry) => entry.endsWith(".md") && !entry.startsWith("."))) {
352
+ const commandPath = join(commandsDir, file);
353
+ const content = readFileSync(commandPath, "utf8");
354
+ const basename = file.replace(/\.md$/, "");
355
+ const heading = content.match(/^#\s+(\/\S+)/m)?.[1];
356
+ const promptName = heading?.startsWith("/aw:")
357
+ ? heading.slice(1).replace(/:/g, "-").replace(/[^\w.-]/g, "-")
358
+ : `ecc-${basename}`;
359
+ const promptFile = `${promptName}.md`;
360
+ writeFileSync(join(promptsDir, promptFile), content);
361
+ manifest.push(promptFile);
362
+ }
363
+
364
+ writeFileSync(
365
+ join(promptsDir, "ecc-prompts-manifest.txt"),
366
+ manifest.sort().map((entry) => `${entry}\n`).join(""),
367
+ );
333
368
  } catch { /* best effort — codex sync failure is non-blocking */ }
334
369
  }
335
370
 
@@ -381,9 +416,10 @@ export async function installAwEcc(
381
416
 
382
417
  // Always use HOME as cwd so files land in ~/.<target>/ globally.
383
418
  const runCwd = homedir();
384
- const installArgs = target === "claude"
385
- ? `--target ${target} --modules ${CLAUDE_FILE_COPY_MODULES.join(",")}`
386
- : `--target ${target} --profile full`;
419
+ const installModules = FILE_COPY_MODULES_BY_TARGET[target] || [];
420
+ const installArgs = installModules.length > 0
421
+ ? `--target ${target} --modules ${installModules.join(",")}`
422
+ : `--target ${target}`;
387
423
  run(
388
424
  `node ${join(repoDir, "scripts/install-apply.js")} ${installArgs}`,
389
425
  { cwd: runCwd },
@@ -393,7 +429,7 @@ export async function installAwEcc(
393
429
  transformCursorAwRefs(home);
394
430
  }
395
431
  if (target === "codex") {
396
- syncEccToCodex(repoDir);
432
+ syncEccCommandsToCodex(repoDir);
397
433
  }
398
434
  restoreProtectedConfigs(snapshot);
399
435
  } catch { /* target not supported — skip */ }
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.68",
3
+ "version": "0.1.70-beta.0",
4
4
  "description": "Agentic Workspace CLI — pull, push & manage agents, skills and commands from the registry",
5
5
  "type": "module",
6
6
  "bin": {
@@ -29,6 +29,7 @@
29
29
  "slack-sim/",
30
30
  "file-tree.mjs",
31
31
  "apply.mjs",
32
+ "aw-docs-validate.mjs",
32
33
  "hook-cleanup.mjs",
33
34
  "hook-manifest.mjs",
34
35
  "update.mjs",
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
- }