loopengine 0.1.22 → 0.1.27

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.
@@ -1,8 +1,8 @@
1
- // Backs `loopengine add-package|upgrade-package|remove-package` (see
2
- // bin/cli.ts) — the install/upgrade/remove mechanics PACKAGES.md
3
- // specifies for a "loopengine package": a bundle of tool files, skill
1
+ // Backs `loopengine add-ability|upgrade-ability|remove-ability` (see
2
+ // bin/cli.ts) — the install/upgrade/remove mechanics ABILITIES.md
3
+ // specifies for a "loopengine ability": a bundle of tool files, skill
4
4
  // directories, and actauth rules, installed by *copying* files into an
5
- // agent's own tree (never an npm import) so a package's code is exactly
5
+ // agent's own tree (never an npm import) so an ability's code is exactly
6
6
  // as reviewable/hand-editable as anything the Admin UI's HTTP tool
7
7
  // builder already generates (see web/http-tool-admin.ts's own header
8
8
  // comment on `generateToolCode` for that same "real code, not an opaque
@@ -14,11 +14,11 @@
14
14
  // `threeWayMerge`'s `git merge-file --diff3` — since `loopengine` and
15
15
  // `create-loopengine` are separate published packages and a runtime
16
16
  // depending on a scaffolding tool (or vice versa) isn't a dependency
17
- // direction worth introducing to share ~30 lines (see PACKAGES.md's
17
+ // direction worth introducing to share ~30 lines (see ABILITIES.md's
18
18
  // "Upgrading" section).
19
19
  import { createHash } from 'node:crypto';
20
20
  import { execFileSync } from 'node:child_process';
21
- import { basename, join } from 'node:path';
21
+ import { basename, dirname, join } from 'node:path';
22
22
  import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
23
23
  import { tmpdir } from 'node:os';
24
24
  import { parse as parseYaml } from 'yaml';
@@ -26,29 +26,99 @@ import semver from 'semver';
26
26
  import { agentDir } from '../core/gateway-tools.js';
27
27
  import { addToolToIndex, removeToolFromIndex, toCamelCase } from '../web/http-tool-admin.js';
28
28
  import { addActauthRule, updateActauthRule, removeActauthRule, readActauthConfig } from '../web/actauth-admin.js';
29
- export class PackageManifestError extends Error {
29
+ export class AbilityManifestError extends Error {
30
30
  }
31
- export class PackageVersionError extends Error {
31
+ export class AbilityVersionError extends Error {
32
32
  }
33
- export class PackageCollisionError extends Error {
33
+ export class AbilityCollisionError extends Error {
34
34
  }
35
- export class PackageNotInstalledError extends Error {
35
+ export class AbilityNotInstalledError extends Error {
36
36
  }
37
- export class PackageAlreadyInstalledError extends Error {
37
+ export class AbilityAlreadyInstalledError extends Error {
38
38
  }
39
39
  // Same character sets tool names / skill ids are already validated
40
40
  // against elsewhere (web/http-tool-admin.ts's TOOL_NAME_PATTERN,
41
41
  // web/skills-admin.ts's SKILL_ID_PATTERN) — re-checked here because a
42
- // name derived from an untrusted package's own manifest becomes a path
42
+ // name derived from an untrusted ability's own manifest becomes a path
43
43
  // segment (agents/<agent>/tools/<name>.ts, .../skills/<id>/): without
44
- // this, a package declaring a skill dir literally named ".." would have
44
+ // this, an ability declaring a skill dir literally named ".." would have
45
45
  // `basename('..')` hand back `'..'` unchanged (path.basename does not
46
46
  // strip it), and the later `cpSync` would land one directory up, inside
47
47
  // the agent's own root instead of its skills/ folder.
48
48
  const TOOL_NAME_PATTERN = /^[a-z][a-z0-9_]*$/;
49
49
  const SKILL_ID_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
50
+ /** Two abilities can each declare a skill with the same bare id (both
51
+ * ship a "web-search" skill, say) — refusing the second install outright
52
+ * would make them mutually exclusive for no real reason, since skills
53
+ * don't have to be globally unique the way a model-callable tool name
54
+ * does. Namespacing under the ability's own name only when the bare id
55
+ * is already taken — never unconditionally — keeps the common,
56
+ * no-conflict case's directory layout exactly as it's always been.
57
+ * Reuses SkillGarden's own nested-directory namespacing (discovery.ts:
58
+ * `deploy/web/SKILL.md` -> addressable as `deploy:web`) for free, so a
59
+ * namespaced skill just becomes addressable as `<abilityName>:<skillId>`
60
+ * with no changes needed on the loading side at all. */
61
+ function namespacedSkillId(abilityName, skillId) {
62
+ // abilityName came off the ability's own package.json "name" — for a
63
+ // scoped npm name ("@company/pkg") this is two path segments, which is
64
+ // fine (SkillGarden namespaces however deep the nesting goes), but each
65
+ // segment still has to be a safe path component on its own: same
66
+ // '..'-as-a-path-segment guard TOOL_NAME_PATTERN/SKILL_ID_PATTERN's own
67
+ // comment above already explains the need for, just for a manifest's
68
+ // "name" field instead of a tool/skill declaration.
69
+ for (const segment of abilityName.split('/')) {
70
+ if (segment === '' || segment === '.' || segment === '..') {
71
+ throw new AbilityManifestError(`Ability name '${abilityName}' isn't safe to use as a skill namespace.`);
72
+ }
73
+ }
74
+ return `${abilityName}/${skillId}`;
75
+ }
76
+ // A tool name (unlike a skill id) is a flat identifier, not a path — it
77
+ // has to satisfy TOOL_NAME_PATTERN on its own (it becomes both a file
78
+ // basename and a JS identifier via toCamelCase), so an ability's raw
79
+ // package.json "name" can't be used directly the way it can as a path
80
+ // segment for a skill (hyphens, "@scope/", dots aren't valid there).
81
+ // Collapsing every run of non-alphanumeric characters to a single "_"
82
+ // and guaranteeing a leading letter (TOOL_NAME_PATTERN requires one)
83
+ // always produces a safe result — never throws, unlike
84
+ // namespacedSkillId, since there's no filesystem path to escape here.
85
+ function sanitizeForToolName(name) {
86
+ const cleaned = name
87
+ .toLowerCase()
88
+ .replace(/[^a-z0-9]+/g, '_')
89
+ .replace(/^_+|_+$/g, '');
90
+ return cleaned.length > 0 && /^[a-z]/.test(cleaned) ? cleaned : `a_${cleaned}`;
91
+ }
92
+ /** Two abilities can each declare a tool with the same bare name too —
93
+ * but unlike a skill id, a tool's name is the literal, model-callable
94
+ * function name: run-agent.ts's own dedupeToolsByName keeps only the
95
+ * *last* same-named entry and silently drops the rest before the model
96
+ * ever sees them (its own doc comment: "a name collision would still
97
+ * show up twice in toolSchemas, confusing (or rejected outright by) a
98
+ * real model API"), so two same-named tools can never actually coexist
99
+ * as distinct, callable tools the way two same-named skills can — only
100
+ * namespacing the *name itself* avoids that silent drop. */
101
+ function namespacedToolName(abilityName, toolName) {
102
+ return `${sanitizeForToolName(abilityName)}__${toolName}`;
103
+ }
104
+ /** Whether `manifestToolFile` (a bare path straight off a manifest, e.g.
105
+ * "tools/web_search.ts") is the file an installed `installedToolName`
106
+ * (bare, or namespaced under `abilityName` on a collision — see
107
+ * namespacedToolName) actually came from. Used by upgradeAbility to
108
+ * match a provenance-recorded, possibly-namespaced name back to the
109
+ * right manifest entry — comparing the *installed* name against a
110
+ * freshly-recomputed candidate, rather than trying to strip a namespace
111
+ * prefix back off `installedToolName` textually, since "<ability>__"
112
+ * isn't a structural separator the way a skill id's "/" is: a bare tool
113
+ * name could legitimately contain "__" on its own, so string-stripping
114
+ * it back off would be ambiguous in a way recomputing forward and
115
+ * comparing never is. */
116
+ function matchesInstalledToolName(abilityName, manifestToolFile, installedToolName) {
117
+ const bareToolName = basename(manifestToolFile, '.ts');
118
+ return bareToolName === installedToolName || namespacedToolName(abilityName, bareToolName) === installedToolName;
119
+ }
50
120
  function provenancePath(agentName) {
51
- return join(agentDir(agentName), '.loopengine-packages.json');
121
+ return join(agentDir(agentName), '.loopengine-abilities.json');
52
122
  }
53
123
  function readProvenance(agentName) {
54
124
  const path = provenancePath(agentName);
@@ -69,10 +139,10 @@ function sha256(content) {
69
139
  * accepts anything `npm pack` does: a public/private registry spec, a
70
140
  * git URL (`github:org/repo`, `git+ssh://...`), or a local `file:../path`
71
141
  * — the last of which is what this module's own tests point at, so no
72
- * real network call is needed there; see PACKAGES.md's "Publishing a
73
- * package" section). */
74
- export function fetchPackageDir(spec) {
75
- const workDir = mkdtempSync(join(tmpdir(), 'loopengine-package-'));
142
+ * real network call is needed there; see ABILITIES.md's "Publishing an
143
+ * ability" section). */
144
+ export function fetchAbilityDir(spec) {
145
+ const workDir = mkdtempSync(join(tmpdir(), 'loopengine-ability-'));
76
146
  execFileSync('npm', ['pack', spec, '--pack-destination', workDir], { stdio: 'pipe' });
77
147
  const tarball = readdirSync(workDir).find((f) => f.endsWith('.tgz'));
78
148
  if (!tarball) {
@@ -85,22 +155,33 @@ export function fetchPackageDir(spec) {
85
155
  execFileSync('tar', ['-xzf', join(workDir, tarball), '-C', workDir], { stdio: 'pipe' });
86
156
  return join(workDir, 'package');
87
157
  }
88
- function readManifest(packageDir) {
89
- const manifestPath = join(packageDir, 'loopengine.package.json');
158
+ function readManifest(abilityDir) {
159
+ const manifestPath = join(abilityDir, 'loopengine.ability.json');
90
160
  if (!existsSync(manifestPath)) {
91
- throw new PackageManifestError(`${packageDir} has no loopengine.package.json — not a valid loopengine package.`);
161
+ throw new AbilityManifestError(`${abilityDir} has no loopengine.ability.json — not a valid loopengine ability.`);
162
+ }
163
+ const declared = JSON.parse(readFileSync(manifestPath, 'utf8'));
164
+ if (!declared.loopengineVersion) {
165
+ throw new AbilityManifestError('loopengine.ability.json must have "loopengineVersion".');
92
166
  }
93
- const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
94
- if (!manifest.name || !manifest.version || !manifest.loopengineVersion) {
95
- throw new PackageManifestError('loopengine.package.json must have "name", "version", and "loopengineVersion".');
167
+ // name/version come from package.json, not loopengine.ability.json —
168
+ // see AbilityManifest's own doc comment for why duplicating them here
169
+ // would just be two numbers to keep in sync instead of one.
170
+ const pkgJsonPath = join(abilityDir, 'package.json');
171
+ if (!existsSync(pkgJsonPath)) {
172
+ throw new AbilityManifestError(`${abilityDir} has no package.json — every loopengine ability needs one (name/version), even though its own metadata lives in loopengine.ability.json.`);
96
173
  }
97
- return manifest;
174
+ const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));
175
+ if (!pkgJson.name || !pkgJson.version) {
176
+ throw new AbilityManifestError(`${pkgJsonPath} must have "name" and "version".`);
177
+ }
178
+ return { ...declared, name: pkgJson.name, version: pkgJson.version };
98
179
  }
99
180
  // The installing project's own dependencies.loopengine is itself a
100
181
  // range (e.g. "^0.1.10"), not a concrete installed version — checking
101
182
  // the *floor* of that range against the manifest's required range is
102
183
  // the conservative choice: if even the lowest version the range could
103
- // resolve to wouldn't satisfy the package, refuse now rather than risk
184
+ // resolve to wouldn't satisfy the ability, refuse now rather than risk
104
185
  // the exact silent-failure gap the Parallel-safe checkbox already hit
105
186
  // (a feature that imports an export an *actually installed* older
106
187
  // version doesn't have yet, with no error, just quietly not working).
@@ -131,7 +212,7 @@ function checkLoopengineVersion(manifest, installedRangeOverride) {
131
212
  return;
132
213
  installedRange = ownPkg.dependencies?.loopengine;
133
214
  if (!installedRange) {
134
- throw new PackageVersionError(`This project's package.json has no "loopengine" dependency — can't check compatibility.`);
215
+ throw new AbilityVersionError(`This project's package.json has no "loopengine" dependency — can't check compatibility.`);
135
216
  }
136
217
  }
137
218
  // A non-registry dependency specifier (file:, git:, workspace:, ...) —
@@ -149,19 +230,19 @@ function checkLoopengineVersion(manifest, installedRangeOverride) {
149
230
  return;
150
231
  }
151
232
  if (!floor || !semver.satisfies(floor, manifest.loopengineVersion)) {
152
- throw new PackageVersionError(`Package '${manifest.name}' needs loopengine ${manifest.loopengineVersion}, but this project depends on loopengine ${installedRange} — bump the dependency first.`);
233
+ throw new AbilityVersionError(`Ability '${manifest.name}' needs loopengine ${manifest.loopengineVersion}, but this project depends on loopengine ${installedRange} — bump the dependency first.`);
153
234
  }
154
235
  }
155
- function parseActauthRules(packageDir, manifest) {
236
+ function parseActauthRules(abilityDir, manifest) {
156
237
  if (!manifest.actauth)
157
238
  return [];
158
- const rulesPath = join(packageDir, manifest.actauth);
239
+ const rulesPath = join(abilityDir, manifest.actauth);
159
240
  if (!existsSync(rulesPath)) {
160
- throw new PackageManifestError(`Manifest references actauth file '${manifest.actauth}', which doesn't exist in the package.`);
241
+ throw new AbilityManifestError(`Manifest references actauth file '${manifest.actauth}', which doesn't exist in the ability.`);
161
242
  }
162
243
  const parsed = parseYaml(readFileSync(rulesPath, 'utf8'));
163
244
  if (!Array.isArray(parsed)) {
164
- throw new PackageManifestError(`'${manifest.actauth}' must be a YAML array of {name, scope, tool, decision} rules.`);
245
+ throw new AbilityManifestError(`'${manifest.actauth}' must be a YAML array of {name, scope, tool, decision} rules.`);
165
246
  }
166
247
  return parsed;
167
248
  }
@@ -171,7 +252,7 @@ function parseActauthRules(packageDir, manifest) {
171
252
  * (reusing addToolToIndex exactly as web/http-tool-admin.ts's own
172
253
  * createHttpTool does), copies each skill directory verbatim (a raw
173
254
  * cpSync, not skills-admin.ts's writeSkill — that regenerates
174
- * frontmatter from scratch and can't carry a package's own
255
+ * frontmatter from scratch and can't carry an ability's own
175
256
  * frontmatter/assets), and appends each actauth rule (addActauthRule).
176
257
  *
177
258
  * Deliberately does *not* attempt a live in-memory registry splice the
@@ -179,7 +260,7 @@ function parseActauthRules(packageDir, manifest) {
179
260
  * admin-created tool — that works there because the HTTP request
180
261
  * creating the tool runs *inside the same process* as the already-running
181
262
  * server, so updateAgent's mutation is visible to every later request in
182
- * that same process immediately. `add-package` is a separate, one-off
263
+ * that same process immediately. `add-ability` is a separate, one-off
183
264
  * CLI process with no connection to whatever server might be running
184
265
  * elsewhere — even a successful getEntry/updateAgent call here would
185
266
  * only mutate *this* CLI invocation's own throwaway registry, then
@@ -196,50 +277,61 @@ function parseActauthRules(packageDir, manifest) {
196
277
  *
197
278
  * All-or-nothing: every collision check below runs, and the whole
198
279
  * install is refused, before a single file is written. */
199
- export async function installPackage(agentName, spec, options = {}) {
200
- const fetch = options.fetchPackageDir ?? fetchPackageDir;
201
- const packageDir = fetch(spec);
202
- const manifest = readManifest(packageDir);
280
+ export async function installAbility(agentName, spec, options = {}) {
281
+ const fetch = options.fetchAbilityDir ?? fetchAbilityDir;
282
+ const abilityDir = fetch(spec);
283
+ const manifest = readManifest(abilityDir);
203
284
  checkLoopengineVersion(manifest, options.installedLoopengineRange);
204
285
  const provenance = readProvenance(agentName);
205
286
  if (provenance[manifest.name]) {
206
- throw new PackageAlreadyInstalledError(`Package '${manifest.name}' is already installed for '${agentName}' — use upgrade-package instead.`);
287
+ throw new AbilityAlreadyInstalledError(`Ability '${manifest.name}' is already installed for '${agentName}' — use upgrade-ability instead.`);
207
288
  }
208
289
  const toolFiles = manifest.tools ?? [];
209
290
  const skillDirs = manifest.skills ?? [];
210
- const rules = parseActauthRules(packageDir, manifest);
291
+ const rules = parseActauthRules(abilityDir, manifest);
211
292
  const toolsDir = join(agentDir(agentName), 'tools');
212
293
  const skillsDirPath = join(agentDir(agentName), 'skills');
213
294
  // Collision checks — refuse the whole install before writing anything,
214
295
  // same "refuse rather than guess" rule HttpToolExistsError/
215
296
  // HttpToolIndexShapeError already enforce for a single admin-created
216
- // tool, just applied package-wide.
297
+ // tool, just applied ability-wide.
217
298
  const toolNames = [];
218
299
  for (const toolFile of toolFiles) {
219
- const toolName = basename(toolFile, '.ts');
220
- if (!TOOL_NAME_PATTERN.test(toolName)) {
221
- throw new PackageManifestError(`Tool file '${toolFile}' doesn't name a valid tool (must be lowercase snake_case).`);
300
+ const bareToolName = basename(toolFile, '.ts');
301
+ if (!TOOL_NAME_PATTERN.test(bareToolName)) {
302
+ throw new AbilityManifestError(`Tool file '${toolFile}' doesn't name a valid tool (must be lowercase snake_case).`);
222
303
  }
223
- toolNames.push(toolName);
224
- if (existsSync(join(toolsDir, `${toolName}.ts`))) {
225
- throw new PackageCollisionError(`agents/${agentName}/tools/${toolName}.ts already exists.`);
304
+ // The bare name if it's free; otherwise namespace under this
305
+ // ability's own name rather than refusing the install outright —
306
+ // see namespacedToolName's own doc comment for why this still
307
+ // leaves both tools genuinely callable, unlike a same-named actauth
308
+ // rule (below), which has no such option.
309
+ const installedToolName = existsSync(join(toolsDir, `${bareToolName}.ts`)) ? namespacedToolName(manifest.name, bareToolName) : bareToolName;
310
+ if (existsSync(join(toolsDir, `${installedToolName}.ts`))) {
311
+ throw new AbilityCollisionError(`agents/${agentName}/tools/${installedToolName}.ts already exists.`);
226
312
  }
313
+ toolNames.push(installedToolName);
227
314
  }
228
315
  const skillIds = [];
229
316
  for (const skillDir of skillDirs) {
230
- const skillId = basename(skillDir);
231
- if (!SKILL_ID_PATTERN.test(skillId)) {
232
- throw new PackageManifestError(`Skill directory '${skillDir}' doesn't name a valid skill id (must be lowercase, hyphen-separated).`);
317
+ const bareSkillId = basename(skillDir);
318
+ if (!SKILL_ID_PATTERN.test(bareSkillId)) {
319
+ throw new AbilityManifestError(`Skill directory '${skillDir}' doesn't name a valid skill id (must be lowercase, hyphen-separated).`);
233
320
  }
234
- skillIds.push(skillId);
235
- if (existsSync(join(skillsDirPath, skillId))) {
236
- throw new PackageCollisionError(`agents/${agentName}/skills/${skillId}/ already exists.`);
321
+ // The bare id if it's free; otherwise namespace under this ability's
322
+ // own name rather than refusing the install outright — see
323
+ // namespacedSkillId's own doc comment for why this is safe to do
324
+ // (skills, unlike tools, don't need a single global namespace).
325
+ const installedSkillId = existsSync(join(skillsDirPath, bareSkillId)) ? namespacedSkillId(manifest.name, bareSkillId) : bareSkillId;
326
+ if (existsSync(join(skillsDirPath, installedSkillId))) {
327
+ throw new AbilityCollisionError(`agents/${agentName}/skills/${installedSkillId}/ already exists.`);
237
328
  }
329
+ skillIds.push(installedSkillId);
238
330
  }
239
331
  const existingRuleNames = new Set(readActauthConfig(agentName).rules.map((r) => r.name));
240
332
  for (const rule of rules) {
241
333
  if (existingRuleNames.has(rule.name)) {
242
- throw new PackageCollisionError(`An actauth rule named '${rule.name}' already exists for '${agentName}'.`);
334
+ throw new AbilityCollisionError(`An actauth rule named '${rule.name}' already exists for '${agentName}'.`);
243
335
  }
244
336
  }
245
337
  const contentHashes = {};
@@ -250,27 +342,51 @@ export async function installPackage(agentName, spec, options = {}) {
250
342
  if (toolNames.length > 0) {
251
343
  mkdirSync(toolsDir, { recursive: true });
252
344
  const indexPath = join(toolsDir, 'index.ts');
253
- for (const toolFile of toolFiles) {
254
- const toolName = basename(toolFile, '.ts');
255
- const code = readFileSync(join(packageDir, toolFile), 'utf8');
256
- const destPath = join(toolsDir, `${toolName}.ts`);
345
+ for (const [i, toolFile] of toolFiles.entries()) {
346
+ const bareToolName = basename(toolFile, '.ts');
347
+ const installedToolName = toolNames[i];
348
+ const code = readFileSync(join(abilityDir, toolFile), 'utf8');
349
+ const destPath = join(toolsDir, `${installedToolName}.ts`);
257
350
  writeFileSync(destPath, code);
258
- contentHashes[`tools/${toolName}.ts`] = sha256(code);
259
- const exportName = toCamelCase(toolName);
351
+ contentHashes[`tools/${installedToolName}.ts`] = sha256(code);
352
+ // Plain case: the file's own export is imported and used as-is,
353
+ // exactly as before. Namespaced case: the file's own internal
354
+ // export name is untouched (still whatever the ability itself
355
+ // authored — importing it under that same bare name from two
356
+ // different abilities' files would be a duplicate top-level
357
+ // identifier), so the import gets aliased, and a small wrapper
358
+ // `const` overrides the actual model-facing `name` field, which
359
+ // the JS identifier alone has no effect on. See addToolToIndex's
360
+ // own doc comment for why `importSpecifier`/`arrayExpression` are
361
+ // separate parameters.
362
+ const sourceExportName = toCamelCase(bareToolName);
363
+ const namespaced = installedToolName !== bareToolName;
364
+ const arrayExpression = namespaced ? toCamelCase(installedToolName) : sourceExportName;
365
+ const importSpecifier = namespaced ? `${sourceExportName} as ${arrayExpression}Source` : sourceExportName;
366
+ const preamble = namespaced ? `const ${arrayExpression}: ToolDefinition = { ...${arrayExpression}Source, name: '${installedToolName}' }` : undefined;
260
367
  if (existsSync(indexPath)) {
261
- addToolToIndex(indexPath, toolName, exportName);
368
+ addToolToIndex(indexPath, installedToolName, importSpecifier, arrayExpression, preamble);
262
369
  }
263
370
  else {
264
- writeFileSync(indexPath, `import type { ToolDefinition } from 'loopengine'\nimport { ${exportName} } from './${toolName}.js'\n\nexport const tools: ToolDefinition[] = [${exportName}]\n`);
371
+ const preambleBlock = preamble ? `${preamble}\n\n` : '';
372
+ writeFileSync(indexPath, `import type { ToolDefinition } from 'loopengine'\nimport { ${importSpecifier} } from './${installedToolName}.js'\n\n${preambleBlock}export const tools: ToolDefinition[] = [${arrayExpression}]\n`);
265
373
  }
266
374
  }
267
375
  }
268
- // Copy skill directories verbatim.
269
- for (const skillDir of skillDirs) {
270
- const skillId = basename(skillDir);
271
- const srcPath = join(packageDir, skillDir);
376
+ // Copy skill directories verbatim — skillIds[i] is skillDirs[i]'s
377
+ // already-resolved installed id (bare, or namespaced under this
378
+ // ability's own name if the bare id collided — computed once, above,
379
+ // not recomputed here, so this loop can't disagree with the collision
380
+ // check that already ran against it).
381
+ for (const [i, skillDir] of skillDirs.entries()) {
382
+ const skillId = skillIds[i];
383
+ const srcPath = join(abilityDir, skillDir);
272
384
  const destPath = join(skillsDirPath, skillId);
273
- mkdirSync(skillsDirPath, { recursive: true });
385
+ // dirname(destPath), not skillsDirPath — a namespaced skillId
386
+ // ("<abilityName>/<skillId>") needs its own intermediate directory
387
+ // created first; cpSync creates destPath itself but not necessarily
388
+ // parents beyond that.
389
+ mkdirSync(dirname(destPath), { recursive: true });
274
390
  cpSync(srcPath, destPath, { recursive: true });
275
391
  const skillMdPath = join(destPath, 'SKILL.md');
276
392
  if (existsSync(skillMdPath)) {
@@ -299,7 +415,7 @@ export async function installPackage(agentName, spec, options = {}) {
299
415
  // threeWayMerge already documents. Operates on a disposable scratch
300
416
  // copy, never the real project file directly.
301
417
  function mergeFile(mine, base, theirs) {
302
- const scratchDir = mkdtempSync(join(tmpdir(), 'loopengine-package-merge-'));
418
+ const scratchDir = mkdtempSync(join(tmpdir(), 'loopengine-ability-merge-'));
303
419
  const minePath = join(scratchDir, 'mine');
304
420
  const basePath = join(scratchDir, 'base');
305
421
  const theirsPath = join(scratchDir, 'theirs');
@@ -341,13 +457,13 @@ const PINNED_SPEC = /^(git\+|git:|https?:|file:|\.\.?\/|\/)/;
341
457
  * would still resolve there today. A git/file/URL spec is returned
342
458
  * as-is — see PINNED_SPEC's own doc comment for why appending "@version"
343
459
  * to one of those breaks instead of pinning. `recordedSpec` is only
344
- * absent for a package installed before InstalledPackageRecord.spec
345
- * existed; falling back to `packageName` there reproduces this
460
+ * absent for an ability installed before InstalledAbilityRecord.spec
461
+ * existed; falling back to `abilityName` there reproduces this
346
462
  * function's own old (buggy for a git/file install) behavior exactly —
347
- * no worse than before, and only for a package that hasn't upgraded
463
+ * no worse than before, and only for an ability that hasn't upgraded
348
464
  * since. */
349
- function oldContentSpec(recordedSpec, packageName, version) {
350
- const base = recordedSpec ?? packageName;
465
+ function oldContentSpec(recordedSpec, abilityName, version) {
466
+ const base = recordedSpec ?? abilityName;
351
467
  if (PINNED_SPEC.test(base))
352
468
  return base;
353
469
  // Strip any version/tag the spec already carries (a scoped name's own
@@ -357,27 +473,27 @@ function oldContentSpec(recordedSpec, packageName, version) {
357
473
  const bareName = base.startsWith('@') ? `@${base.slice(1).split('@')[0]}` : base.split('@')[0];
358
474
  return `${bareName}@${version}`;
359
475
  }
360
- /** Upgrades an already-installed package to whatever `spec` (defaulting
361
- * to `packageName`, i.e. "latest") currently resolves to. Tool and skill
476
+ /** Upgrades an already-installed ability to whatever `spec` (defaulting
477
+ * to `abilityName`, i.e. "latest") currently resolves to. Tool and skill
362
478
  * files get a real three-way merge (mine = current file, possibly
363
479
  * hand-edited since install; base = the version recorded at
364
480
  * install/last-upgrade, refetched via oldContentSpec; theirs = newly
365
481
  * fetched) — identical technique to `create-loopengine upgrade`, just
366
- * applied to package-managed files instead of template files. actauth
482
+ * applied to ability-managed files instead of template files. actauth
367
483
  * rules upgrade per-rule instead: the target actauth.yml holds rules
368
- * from other packages and hand-written ones too, so there's no coherent
484
+ * from other abilities and hand-written ones too, so there's no coherent
369
485
  * "whole file" base/theirs to merge — a rule unchanged since install
370
486
  * updates cleanly; a hand-edited one is left alone and reported as a
371
487
  * conflict rather than overwritten. */
372
- export async function upgradePackage(agentName, packageName, options = {}) {
373
- const fetch = options.fetchPackageDir ?? fetchPackageDir;
488
+ export async function upgradeAbility(agentName, abilityName, options = {}) {
489
+ const fetch = options.fetchAbilityDir ?? fetchAbilityDir;
374
490
  const provenance = readProvenance(agentName);
375
- const record = provenance[packageName];
491
+ const record = provenance[abilityName];
376
492
  if (!record) {
377
- throw new PackageNotInstalledError(`Package '${packageName}' isn't installed for '${agentName}'.`);
493
+ throw new AbilityNotInstalledError(`Ability '${abilityName}' isn't installed for '${agentName}'.`);
378
494
  }
379
- const spec = options.spec ?? packageName;
380
- const oldDir = fetch(oldContentSpec(record.spec, packageName, record.version));
495
+ const spec = options.spec ?? abilityName;
496
+ const oldDir = fetch(oldContentSpec(record.spec, abilityName, record.version));
381
497
  const newDir = fetch(spec);
382
498
  const oldManifest = readManifest(oldDir);
383
499
  const newManifest = readManifest(newDir);
@@ -386,8 +502,12 @@ export async function upgradePackage(agentName, packageName, options = {}) {
386
502
  const newContentHashes = { ...record.contentHashes };
387
503
  for (const toolName of record.tools) {
388
504
  const relPath = `tools/${toolName}.ts`;
389
- const oldFile = (oldManifest.tools ?? []).find((f) => basename(f, '.ts') === toolName);
390
- const newFile = (newManifest.tools ?? []).find((f) => basename(f, '.ts') === toolName);
505
+ // matchesInstalledToolName, not a bare basename(f, '.ts') === toolName
506
+ // comparison — toolName may be namespaced (see namespacedToolName),
507
+ // and "<ability>__" isn't a structural separator safe to strip back
508
+ // off textually the way a skill id's "/" is.
509
+ const oldFile = (oldManifest.tools ?? []).find((f) => matchesInstalledToolName(abilityName, f, toolName));
510
+ const newFile = (newManifest.tools ?? []).find((f) => matchesInstalledToolName(abilityName, f, toolName));
391
511
  if (!oldFile || !newFile) {
392
512
  results.push({ path: relPath, status: 'unchanged' });
393
513
  continue;
@@ -407,8 +527,13 @@ export async function upgradePackage(agentName, packageName, options = {}) {
407
527
  }
408
528
  for (const skillId of record.skills) {
409
529
  const relPath = `skills/${skillId}/SKILL.md`;
410
- const oldSkillDir = (oldManifest.skills ?? []).find((s) => basename(s) === skillId);
411
- const newSkillDir = (newManifest.skills ?? []).find((s) => basename(s) === skillId);
530
+ // basename(skillId), not skillId itself — a namespaced install
531
+ // ("<abilityName>/<skillId>") still has to match against the
532
+ // manifest's own bare skill dir names, which never carry the
533
+ // namespace (that's only ever added at install time, on collision).
534
+ const bareSkillId = basename(skillId);
535
+ const oldSkillDir = (oldManifest.skills ?? []).find((s) => basename(s) === bareSkillId);
536
+ const newSkillDir = (newManifest.skills ?? []).find((s) => basename(s) === bareSkillId);
412
537
  const oldFile = oldSkillDir ? join(oldDir, oldSkillDir, 'SKILL.md') : null;
413
538
  const newFile = newSkillDir ? join(newDir, newSkillDir, 'SKILL.md') : null;
414
539
  if (!oldFile || !newFile || !existsSync(oldFile) || !existsSync(newFile)) {
@@ -452,7 +577,7 @@ export async function upgradePackage(agentName, packageName, options = {}) {
452
577
  updateActauthRule(agentName, ruleName, { scope: newRule.scope, tool: newRule.tool, decision: newRule.decision });
453
578
  results.push({ path, status: 'updated' });
454
579
  }
455
- provenance[packageName] = { ...record, version: newManifest.version, spec, contentHashes: newContentHashes, env: newManifest.env ?? record.env };
580
+ provenance[abilityName] = { ...record, version: newManifest.version, spec, contentHashes: newContentHashes, env: newManifest.env ?? record.env };
456
581
  writeProvenance(agentName, provenance);
457
582
  return { files: results };
458
583
  }
@@ -464,21 +589,21 @@ function isDirty(path, recordedHash) {
464
589
  return true;
465
590
  return sha256(readFileSync(path, 'utf8')) !== recordedHash;
466
591
  }
467
- /** Removes an installed package's tool and skill files, and its actauth
592
+ /** Removes an installed ability's tool and skill files, and its actauth
468
593
  * rules. A file whose content no longer matches the hash recorded at
469
594
  * install/last-upgrade is refused (reported in `refused`, left on disk)
470
- * unless `force` is passed — a concrete implementation of PACKAGES.md's
595
+ * unless `force` is passed — a concrete implementation of ABILITIES.md's
471
596
  * "refuses if any of them look hand-modified," cheaper than storing full
472
- * content or refetching the package to diff. Does not patch
597
+ * content or refetching the ability to diff. Does not patch
473
598
  * tools/index.ts to remove the now-dangling import — same "refuse
474
599
  * rather than guess a second time" doctrine addToolToIndex's own doc
475
600
  * comment already applies; a stale import surfaces as a clear build
476
601
  * error, not a silent break. */
477
- export function removePackage(agentName, packageName, force = false) {
602
+ export function removeAbility(agentName, abilityName, force = false) {
478
603
  const provenance = readProvenance(agentName);
479
- const record = provenance[packageName];
604
+ const record = provenance[abilityName];
480
605
  if (!record) {
481
- throw new PackageNotInstalledError(`Package '${packageName}' isn't installed for '${agentName}'.`);
606
+ throw new AbilityNotInstalledError(`Ability '${abilityName}' isn't installed for '${agentName}'.`);
482
607
  }
483
608
  const removed = [];
484
609
  const refused = [];
@@ -494,10 +619,10 @@ export function removePackage(agentName, packageName, force = false) {
494
619
  continue;
495
620
  }
496
621
  rmSync(fullPath, { force: true });
497
- // Undoes installPackage's own addToolToIndex call — without this, a
622
+ // Undoes installAbility's own addToolToIndex call — without this, a
498
623
  // deleted tool's import survives in tools/index.ts, and either fails
499
624
  // the next build (a dangling import to a file that no longer exists)
500
- // or, worse, silently duplicates on a later add-package of the same
625
+ // or, worse, silently duplicates on a later add-ability of the same
501
626
  // tool (addToolToIndex used to have no idempotence check at all —
502
627
  // now it does, but this is the actual fix: the stale entry shouldn't
503
628
  // be there to begin with).
@@ -525,10 +650,10 @@ export function removePackage(agentName, packageName, force = false) {
525
650
  }
526
651
  }
527
652
  if (refused.length === 0) {
528
- delete provenance[packageName];
653
+ delete provenance[abilityName];
529
654
  }
530
655
  else {
531
- provenance[packageName] = { ...record, tools: remainingTools, skills: remainingSkills, actauthRules: [] };
656
+ provenance[abilityName] = { ...record, tools: remainingTools, skills: remainingSkills, actauthRules: [] };
532
657
  }
533
658
  writeProvenance(agentName, provenance);
534
659
  return { removed, refused };