@gaia-ai/gaia 0.9.2 → 0.11.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/dist/src/host.js CHANGED
@@ -7,6 +7,7 @@ import { commands as uiCommands } from '@gaia-ai/ui/preset';
7
7
  import { Command } from 'commander';
8
8
  import { registerUpdate } from './update.js';
9
9
  import { registerUpgrade } from './upgrade.js';
10
+ import { registerValidate } from './validate.js';
10
11
  // GAIA-201: the `gaia` plugin HOST. The meta package `@gaia-ai/gaia` owns the
11
12
  // CLI entrypoint (AC-1); ui / conductor / dropsh / deployment are declared,
12
13
  // ESLint-style resolved, LAZILY mounted command plugins (AC-2). The host
@@ -163,6 +164,7 @@ export async function buildHostProgram(argv, opts = {}) {
163
164
  });
164
165
  registerUpgrade(program);
165
166
  registerUpdate(program);
167
+ registerValidate(program);
166
168
  const target = opts.forceTarget ?? scanTarget(argv, names);
167
169
  for (const entry of commands) {
168
170
  if (entry.name === target) {
@@ -0,0 +1,78 @@
1
+ import { type EmptyLoaderShape, type SkillContract, type StepContractErrorCode } from '@gaia-ai/core';
2
+ import type { Command } from 'commander';
3
+ /** A `SKILL.md` whose frontmatter is not a readable skill contract. */
4
+ export interface SkillReadFailure {
5
+ path: string;
6
+ reason: string;
7
+ }
8
+ /** What the skill scan found: the name index, plus what it could not read. */
9
+ export interface SkillIndex {
10
+ byName: Map<string, SkillContract>;
11
+ /** Paths that parsed, in scan order — for the green summary's count. */
12
+ indexed: string[];
13
+ /** A later `SKILL.md` re-declaring an already-indexed name (first wins). */
14
+ shadowed: {
15
+ name: string;
16
+ path: string;
17
+ }[];
18
+ failures: SkillReadFailure[];
19
+ }
20
+ /** The verdict: `ok`, or the first contract defect with its message. */
21
+ export interface ValidateReport {
22
+ ok: boolean;
23
+ workflowPath: string;
24
+ roots: string[];
25
+ index: SkillIndex;
26
+ /** Loaded leaf skills — owners plus helpers — when the load got that far. */
27
+ loaded?: {
28
+ owners: string[];
29
+ helpers: string[];
30
+ };
31
+ tripleCount?: number;
32
+ error?: string;
33
+ /**
34
+ * The contract defect's classification, when core tagged one. GAIA-436: this is
35
+ * the seam `formatReport` branches on — it replaced a regex over core's message
36
+ * text, which matched only one of the two throw sites that need a hint and would
37
+ * have broken silently the next time a message was reworded.
38
+ */
39
+ errorCode?: StepContractErrorCode;
40
+ /** For `unresolved_skill`: the skill name the load could not resolve. */
41
+ unresolvedSkill?: string;
42
+ /**
43
+ * For `empty_loader`: what the `## Loaded skills` section actually holds. Core
44
+ * raises that code for three shapes and its message can only describe one of
45
+ * them, so the shape is measured here and `formatReport` prints the remedy that
46
+ * fits the document rather than the one that fits the common case.
47
+ */
48
+ emptyLoader?: EmptyLoaderShape;
49
+ }
50
+ /**
51
+ * Index every skill reachable from `roots` by its declared `name` — the key a
52
+ * `WORKFLOW.md` bullet resolves on, whatever namespace prefix the bullet wears.
53
+ *
54
+ * A `SKILL.md` that is not a readable GAIA skill contract is RECORDED rather than
55
+ * thrown on: a project's skill tree legitimately holds third-party skills that
56
+ * declare no GAIA frontmatter, and failing the whole validation over one of them
57
+ * would make the command useless exactly where it is most needed. The record is
58
+ * printed only when a bullet then fails to resolve, where it is the likely cause.
59
+ */
60
+ export declare function indexSkills(roots: string[]): SkillIndex;
61
+ export interface ValidateOptions {
62
+ /** A project directory, or a loader document (`WORKFLOW.md`) directly. */
63
+ path?: string | undefined;
64
+ /** Project root for default skill-root discovery; defaults from `path`. */
65
+ project?: string | undefined;
66
+ /** Extra skill roots, in precedence order before the defaults. */
67
+ skills?: string[] | undefined;
68
+ }
69
+ /**
70
+ * Validate a project's loader: read `WORKFLOW.md`, index the skills it can reach,
71
+ * expand the load, derive the project's triple set, and enforce the contract.
72
+ *
73
+ * Returns the verdict rather than throwing, so the caller owns the exit code.
74
+ */
75
+ export declare function runValidate(options?: ValidateOptions): ValidateReport;
76
+ /** Render the report for a terminal. Returns the lines, so tests can read them. */
77
+ export declare function formatReport(report: ValidateReport): string[];
78
+ export declare function registerValidate(program: Command): void;
@@ -0,0 +1,272 @@
1
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
3
+ import { deriveProjectTriples, expandLoad, measureEmptyLoader, readSkillWhen, StepContractError, validateLoad, } from '@gaia-ai/core';
4
+ // GAIA-436: `gaia validate` is the step contract's ENTRY POINT.
5
+ //
6
+ // `@gaia-ai/core` has shipped `expandLoad` / `validateLoad` since GAIA-204, but
7
+ // nothing outside the contract test ever called them: the contract was enforced
8
+ // only where an agent happened to run it by hand, so a drifted `WORKFLOW.md`
9
+ // degraded in silence — on one consumer project, eleven overrides resolved to
10
+ // nothing for six weeks after a key rename, with no error anywhere.
11
+ //
12
+ // This command is deliberately thin. It discovers, delegates to core, and
13
+ // reports; every rule it enforces lives in `core/src/workflow/step-contract.ts`,
14
+ // so the CLI and the contract test can never drift apart. Enforcing the contract
15
+ // at conductor claim time was considered and rejected for this ticket — it needs
16
+ // its own rollout story, because it would start failing live claims.
17
+ /** Directory names never worth walking when hunting for `SKILL.md` files. */
18
+ const SKIP_DIRS = new Set([
19
+ 'node_modules',
20
+ '.git',
21
+ 'dist',
22
+ 'vendor',
23
+ '.ddev',
24
+ 'coverage',
25
+ ]);
26
+ /** How deep below a skill root a `SKILL.md` may sit. `.claude/skills/<plugin>/skills/<name>/SKILL.md`
27
+ * is 3 directories below `.claude/skills`; a plugin cache adds a version segment. */
28
+ const MAX_SKILL_DEPTH = 5;
29
+ /** How many offending load sites the empty-loader report prints before eliding. */
30
+ const MAX_LISTED_LOAD_SITES = 5;
31
+ /**
32
+ * The in-repo skill roots discovered without being told — the obvious two.
33
+ *
34
+ * The Claude **plugin cache** is deliberately NOT here. It would let a project
35
+ * that does not vendor its skills validate with no `--skills`, but the cache holds
36
+ * every installed version side by side (`cache/gaia/gaia/{0.18.0,0.19.0,0.20.0}/`),
37
+ * so a scan would index whichever version it walked first and report `OK` against
38
+ * a skill set weeks out of date — the exact silent degradation this command exists
39
+ * to end. Resolving the cache needs a version choice made on purpose; until then a
40
+ * consumer project passes `--skills`, and `formatReport` says so when a bullet
41
+ * fails to resolve rather than letting a discovery gap wear a loader defect's
42
+ * message.
43
+ */
44
+ const DEFAULT_SKILL_ROOTS = ['.claude/skills', '.agents/skills'];
45
+ /** Every `SKILL.md` under `root`, depth-capped, deterministic (name-sorted). */
46
+ function findSkillFiles(root, depth = 0) {
47
+ let entries;
48
+ try {
49
+ entries = readdirSync(root, { withFileTypes: true });
50
+ }
51
+ catch {
52
+ return []; // an absent or unreadable root is simply not a skill root
53
+ }
54
+ const found = [];
55
+ for (const entry of [...entries].sort((a, b) => a.name.localeCompare(b.name))) {
56
+ if (entry.isFile() && entry.name === 'SKILL.md') {
57
+ found.push(join(root, entry.name));
58
+ continue;
59
+ }
60
+ if (!entry.isDirectory())
61
+ continue;
62
+ if (SKIP_DIRS.has(entry.name) || depth >= MAX_SKILL_DEPTH)
63
+ continue;
64
+ found.push(...findSkillFiles(join(root, entry.name), depth + 1));
65
+ }
66
+ return found;
67
+ }
68
+ /**
69
+ * Index every skill reachable from `roots` by its declared `name` — the key a
70
+ * `WORKFLOW.md` bullet resolves on, whatever namespace prefix the bullet wears.
71
+ *
72
+ * A `SKILL.md` that is not a readable GAIA skill contract is RECORDED rather than
73
+ * thrown on: a project's skill tree legitimately holds third-party skills that
74
+ * declare no GAIA frontmatter, and failing the whole validation over one of them
75
+ * would make the command useless exactly where it is most needed. The record is
76
+ * printed only when a bullet then fails to resolve, where it is the likely cause.
77
+ */
78
+ export function indexSkills(roots) {
79
+ const byName = new Map();
80
+ const indexed = [];
81
+ const shadowed = [];
82
+ const failures = [];
83
+ for (const root of roots) {
84
+ for (const path of findSkillFiles(root)) {
85
+ let contract;
86
+ try {
87
+ contract = readSkillWhen(readFileSync(path, 'utf8'));
88
+ }
89
+ catch (cause) {
90
+ failures.push({ path, reason: cause.message });
91
+ continue;
92
+ }
93
+ if (byName.has(contract.name)) {
94
+ shadowed.push({ name: contract.name, path });
95
+ continue; // first root wins, so the order of `roots` is the precedence
96
+ }
97
+ byName.set(contract.name, contract);
98
+ indexed.push(path);
99
+ }
100
+ }
101
+ return { byName, indexed, shadowed, failures };
102
+ }
103
+ /** Resolve `path` into the loader document and the project root to scan from. */
104
+ function resolveTargets(options) {
105
+ const raw = options.path ?? process.cwd();
106
+ const target = isAbsolute(raw) ? raw : resolve(process.cwd(), raw);
107
+ let isDir = false;
108
+ try {
109
+ isDir = statSync(target).isDirectory();
110
+ }
111
+ catch {
112
+ // Not there: treat a bare directory-looking path as a directory so the
113
+ // error names the WORKFLOW.md we went looking for.
114
+ isDir = !target.endsWith('.md');
115
+ }
116
+ const workflowPath = isDir ? join(target, 'WORKFLOW.md') : target;
117
+ const fallbackRoot = isDir ? target : dirname(target);
118
+ const projectRoot = options.project === undefined
119
+ ? fallbackRoot
120
+ : resolve(process.cwd(), options.project);
121
+ return { workflowPath, projectRoot };
122
+ }
123
+ /**
124
+ * Validate a project's loader: read `WORKFLOW.md`, index the skills it can reach,
125
+ * expand the load, derive the project's triple set, and enforce the contract.
126
+ *
127
+ * Returns the verdict rather than throwing, so the caller owns the exit code.
128
+ */
129
+ export function runValidate(options = {}) {
130
+ const { workflowPath, projectRoot } = resolveTargets(options);
131
+ const roots = [
132
+ ...(options.skills ?? []).map((d) => resolve(process.cwd(), d)),
133
+ ...DEFAULT_SKILL_ROOTS.map((d) => join(projectRoot, d)),
134
+ ];
135
+ const index = indexSkills(roots);
136
+ let workflowMd;
137
+ try {
138
+ workflowMd = readFileSync(workflowPath, 'utf8');
139
+ }
140
+ catch {
141
+ return {
142
+ ok: false,
143
+ workflowPath,
144
+ roots,
145
+ index,
146
+ error: `cannot read \`${workflowPath}\` — a GAIA project's loader lives at its repository root.`,
147
+ };
148
+ }
149
+ try {
150
+ const { skills, values } = expandLoad(workflowMd, index.byName);
151
+ const triples = deriveProjectTriples(skills);
152
+ validateLoad(skills, values, triples);
153
+ return {
154
+ ok: true,
155
+ workflowPath,
156
+ roots,
157
+ index,
158
+ loaded: {
159
+ owners: skills.filter((s) => s.when !== undefined).map((s) => s.name),
160
+ helpers: skills.filter((s) => s.when === undefined).map((s) => s.name),
161
+ },
162
+ tripleCount: triples.length,
163
+ };
164
+ }
165
+ catch (cause) {
166
+ // A StepContractError is the product of this command; anything else is a bug
167
+ // in it, and must not be dressed up as a contract defect.
168
+ if (!(cause instanceof StepContractError))
169
+ throw cause;
170
+ const report = {
171
+ ok: false,
172
+ workflowPath,
173
+ roots,
174
+ index,
175
+ error: cause.message,
176
+ };
177
+ // `exactOptionalPropertyTypes` is on: set these only when core supplied them.
178
+ if (cause.code !== undefined)
179
+ report.errorCode = cause.code;
180
+ if (cause.skill !== undefined)
181
+ report.unresolvedSkill = cause.skill;
182
+ // Measured here, where the document is still in hand, and only for the code
183
+ // that needs it — `formatReport` stays a pure function of the report.
184
+ if (cause.code === 'empty_loader') {
185
+ report.emptyLoader = measureEmptyLoader(workflowMd);
186
+ }
187
+ return report;
188
+ }
189
+ }
190
+ /** Render the report for a terminal. Returns the lines, so tests can read them. */
191
+ export function formatReport(report) {
192
+ const lines = [`workflow: ${report.workflowPath}`];
193
+ lines.push(`skill roots: ${report.roots.join(', ')}`);
194
+ lines.push(`skills indexed: ${report.index.indexed.length}`);
195
+ if (report.ok) {
196
+ const owners = report.loaded?.owners ?? [];
197
+ const helpers = report.loaded?.helpers ?? [];
198
+ lines.push(`OK — ${owners.length} step owner(s)${helpers.length > 0 ? ` + ${helpers.length} helper(s)` : ''} cover all ${report.tripleCount} project triple(s), every override resolves.`);
199
+ lines.push(` owners: ${owners.join(', ')}`);
200
+ if (helpers.length > 0)
201
+ lines.push(` helpers: ${helpers.join(', ')}`);
202
+ for (const s of report.index.shadowed) {
203
+ lines.push(` note: \`${s.name}\` also declared at ${s.path} (ignored)`);
204
+ }
205
+ return lines;
206
+ }
207
+ lines.push(`FAILED: ${report.error}`);
208
+ // An unresolved name is ambiguous on its own: the bullet (or the bundle body)
209
+ // may name the wrong skill, or the skill may simply live somewhere this scan
210
+ // never looked. Say which, so a discovery gap stops wearing a loader defect's
211
+ // message.
212
+ //
213
+ // Branching on the CODE rather than on core's wording (GAIA-436 review) is what
214
+ // brings the bundle-member half of that defect in: it is raised by the same
215
+ // `walk()`, needs the same remedy, and the old message regex never matched it.
216
+ if (report.errorCode === 'unresolved_skill') {
217
+ lines.push(report.index.indexed.length === 0
218
+ ? ` no \`SKILL.md\` was found under any scanned root, so nothing could resolve: pass \`--skills <dir>\` pointing at the skills this project loads.`
219
+ : ` \`${report.unresolvedSkill}\` is not among the ${report.index.indexed.length} skill(s) found under the roots above — either the bullet names the wrong skill, or that skill is not vendored here: pass \`--skills <dir>\`.`);
220
+ }
221
+ // The second code, and the reason it earns a branch (GAIA-436 review round 3).
222
+ // `empty_loader` is raised for three documents but carries ONE message, whose
223
+ // "an indented `- @…` line is read as value" fits only the first: a section
224
+ // written with a `*` marker, or one with no line at all, has nothing indented,
225
+ // and sending its author to look at whitespace is the same misdirection as the
226
+ // `coverage gap` this code was introduced to replace. The shape decides the
227
+ // remedy, and the offending lines are named — a report the message cannot give.
228
+ if (report.errorCode === 'empty_loader') {
229
+ const indented = report.emptyLoader?.indented ?? [];
230
+ const bodyLines = report.emptyLoader?.bodyLines ?? 0;
231
+ if (indented.length > 0) {
232
+ lines.push(` ${indented.length} address-shaped line(s) in that section sit below indent 0, so each was read as the preceding bullet's value — de-indent them to column 0:`);
233
+ for (const site of indented.slice(0, MAX_LISTED_LOAD_SITES)) {
234
+ lines.push(` line ${site.line} (indent ${site.indent}): ${site.text}`);
235
+ }
236
+ if (indented.length > MAX_LISTED_LOAD_SITES) {
237
+ lines.push(` … and ${indented.length - MAX_LISTED_LOAD_SITES} more indented line(s)`);
238
+ }
239
+ }
240
+ else if (bodyLines > 0) {
241
+ lines.push(` indentation is NOT the defect here: the section's ${bodyLines} non-blank line(s) carry no \`- @…\` bullet at any indent. A load site is a \`-\`, one space, then \`@<namespace>/<skill-name>\` — a \`*\` marker, or a \`-@\` written without the space, is not one.`);
242
+ }
243
+ else {
244
+ lines.push(` that section is empty: it carries no line between its heading and the next \`##\`, so there is nothing to move — add one \`- @<namespace>/<skill-name>\` bullet per skill the project loads.`);
245
+ }
246
+ }
247
+ // Only relevant on failure, and then usually the cause: a bullet that will not
248
+ // resolve is very often a `SKILL.md` this scan could not read.
249
+ for (const f of report.index.failures) {
250
+ lines.push(` unreadable skill: ${f.path} — ${f.reason}`);
251
+ }
252
+ return lines;
253
+ }
254
+ export function registerValidate(program) {
255
+ program
256
+ .command('validate')
257
+ .description("validate the project's WORKFLOW.md against the loaded skills' step contract")
258
+ .argument('[path]', 'project directory, or a WORKFLOW.md file directly (default: cwd)')
259
+ .option('--project <dir>', 'project root used to discover .claude/skills and .agents/skills')
260
+ .option('--skills <dir>', 'additional skill root to scan for SKILL.md (repeatable)', (value, previous = []) => [...previous, value])
261
+ .action((path, opts) => {
262
+ const report = runValidate({
263
+ path,
264
+ project: opts.project,
265
+ skills: opts.skills,
266
+ });
267
+ const out = report.ok ? process.stdout : process.stderr;
268
+ out.write(`${formatReport(report).join('\n')}\n`);
269
+ if (!report.ok)
270
+ process.exitCode = 1;
271
+ });
272
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaia-ai/gaia",
3
- "version": "0.9.2",
3
+ "version": "0.11.0",
4
4
  "description": "GAIA meta package: the `gaia` plugin-host CLI + all runtime plugins. Global install target.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -38,23 +38,25 @@
38
38
  "jsonapi"
39
39
  ],
40
40
  "dependencies": {
41
- "@gaia-ai/addon-auth-basic": "^0.9.2",
42
- "@gaia-ai/addon-claude": "^0.9.2",
43
- "@gaia-ai/addon-codex": "^0.9.2",
44
- "@gaia-ai/addon-deployment": "^0.9.2",
45
- "@gaia-ai/addon-dropsh": "^0.9.2",
46
- "@gaia-ai/addon-essentials": "^0.9.2",
47
- "@gaia-ai/addon-gaia-ui": "^0.9.2",
48
- "@gaia-ai/addon-grok": "^0.9.2",
49
- "@gaia-ai/addon-herdr": "^0.9.2",
50
- "@gaia-ai/addon-kimi": "^0.9.2",
51
- "@gaia-ai/addon-opencode": "^0.9.2",
52
- "@gaia-ai/addon-pi": "^0.9.2",
53
- "@gaia-ai/addon-remote-drupal": "^0.9.2",
54
- "@gaia-ai/addon-workspace-git": "^0.9.2",
55
- "@gaia-ai/conductor": "^0.9.2",
56
- "@gaia-ai/core": "^0.9.2",
57
- "@gaia-ai/ui": "^0.9.2",
41
+ "@gaia-ai/addon-auth-basic": "^0.11.0",
42
+ "@gaia-ai/addon-claude": "^0.11.0",
43
+ "@gaia-ai/addon-codex": "^0.11.0",
44
+ "@gaia-ai/addon-deployment": "^0.11.0",
45
+ "@gaia-ai/addon-dropsh": "^0.11.0",
46
+ "@gaia-ai/addon-essentials": "^0.11.0",
47
+ "@gaia-ai/addon-gaia-ui": "^0.11.0",
48
+ "@gaia-ai/addon-gaia-ui-artifacts": "^0.11.0",
49
+ "@gaia-ai/addon-grok": "^0.11.0",
50
+ "@gaia-ai/addon-herdr": "^0.11.0",
51
+ "@gaia-ai/addon-kimi": "^0.11.0",
52
+ "@gaia-ai/addon-opencode": "^0.11.0",
53
+ "@gaia-ai/addon-pi": "^0.11.0",
54
+ "@gaia-ai/addon-remote-drupal": "^0.11.0",
55
+ "@gaia-ai/addon-workspace": "^0.11.0",
56
+ "@gaia-ai/addon-workspace-git": "^0.11.0",
57
+ "@gaia-ai/conductor": "^0.11.0",
58
+ "@gaia-ai/core": "^0.11.0",
59
+ "@gaia-ai/ui": "^0.11.0",
58
60
  "commander": "^12.1.0"
59
61
  }
60
62
  }