@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.
- package/aw-docs-validate.mjs +219 -0
- package/c4/diagnostics.mjs +23 -42
- package/c4/index.mjs +0 -1
- package/c4/preflight.mjs +12 -18
- package/cli.mjs +2 -0
- package/codex.mjs +13 -4
- package/commands/c4.mjs +11 -12
- package/commands/docs.mjs +78 -0
- package/commands/doctor.mjs +2 -2
- package/commands/init.mjs +12 -3
- package/commands/pull.mjs +2 -1
- package/constants.mjs +3 -0
- package/ecc.mjs +60 -24
- package/git.mjs +12 -9
- package/hooks/codex-home.mjs +3 -3
- package/link.mjs +13 -11
- package/package.json +2 -1
- package/startup.mjs +7 -22
- package/c4/eccRegistryBridge.mjs +0 -236
|
@@ -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
|
+
}
|
package/c4/diagnostics.mjs
CHANGED
|
@@ -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/
|
|
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,
|
|
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
|
|
150
|
-
* find <awHome>/.aw_registry/
|
|
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
|
|
160
|
-
const enumerableSkills =
|
|
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>/<
|
|
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
|
|
174
|
-
if (!existsSync(
|
|
171
|
+
function enumerateAwSkills(awSkillsRoot) {
|
|
172
|
+
if (!existsSync(awSkillsRoot)) return [];
|
|
175
173
|
const set = new Set();
|
|
176
|
-
let
|
|
174
|
+
let skills;
|
|
177
175
|
try {
|
|
178
|
-
|
|
176
|
+
skills = readdirSync(awSkillsRoot, { withFileTypes: true });
|
|
179
177
|
} catch {
|
|
180
|
-
// readdir on the
|
|
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
|
|
185
|
-
if (!
|
|
186
|
-
const
|
|
187
|
-
if (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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/
|
|
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/
|
|
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
|
|
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
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,
|
|
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
|
-
'
|
|
75
|
-
'
|
|
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
|
-
'
|
|
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
|
|
123
|
+
function probeBridgeStatus(harness, awHome) {
|
|
124
124
|
if (harness !== 'codex-web') return 'ok';
|
|
125
125
|
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
|
|
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/
|
|
130
|
+
'.aw_registry/aw/skills/aw-plan/SKILL.md',
|
|
132
131
|
);
|
|
133
|
-
if (existsSync(
|
|
132
|
+
if (existsSync(awSkillPath)) return 'ok';
|
|
134
133
|
|
|
135
|
-
|
|
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
|
|
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="
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
108
|
+
return readDir(dir).filter((entry) => {
|
|
107
109
|
if (entry.startsWith('.')) return false;
|
|
108
|
-
try { return
|
|
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
|
|
136
|
-
|
|
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 (
|
|
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
|
|
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
|
+
}
|
package/commands/doctor.mjs
CHANGED
|
@@ -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', '
|
|
552
|
-
join(homeDir, '.aw', '.aw_registry', '
|
|
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 {
|
|
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.
|
|
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
|
-
//
|
|
28
|
-
// and install-state
|
|
29
|
-
//
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
*
|
|
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
|
|
329
|
-
const
|
|
330
|
-
if (!existsSync(
|
|
343
|
+
function syncEccCommandsToCodex(repoDir) {
|
|
344
|
+
const commandsDir = join(repoDir, "commands");
|
|
345
|
+
if (!existsSync(commandsDir)) return;
|
|
331
346
|
try {
|
|
332
|
-
|
|
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
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
393
|
-
//
|
|
394
|
-
//
|
|
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;
|
package/hooks/codex-home.mjs
CHANGED
|
@@ -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/
|
|
30
|
-
'$HOME/.aw/.aw_registry/
|
|
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
|
|
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
|
|
164
|
-
return
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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.
|
|
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
|
|
87
|
-
|
|
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
|
|
397
|
-
|
|
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 [
|
|
401
|
+
return [];
|
|
417
402
|
}
|
|
418
403
|
|
|
419
404
|
function hasCodexHooksEnabled(homeDir = homedir()) {
|
package/c4/eccRegistryBridge.mjs
DELETED
|
@@ -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
|
-
}
|