@aragon-agent/cli 0.4.3

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.
Files changed (156) hide show
  1. package/CHANGELOG.md +292 -0
  2. package/README.md +558 -0
  3. package/dist/agent/coalesce.d.ts +14 -0
  4. package/dist/agent/coalesce.js +41 -0
  5. package/dist/agent/controller.d.ts +132 -0
  6. package/dist/agent/controller.js +332 -0
  7. package/dist/agent/headless.d.ts +22 -0
  8. package/dist/agent/headless.js +74 -0
  9. package/dist/agent/reducer.d.ts +181 -0
  10. package/dist/agent/reducer.js +420 -0
  11. package/dist/agent/system-prompt.d.ts +30 -0
  12. package/dist/agent/system-prompt.js +58 -0
  13. package/dist/agent/usage.d.ts +13 -0
  14. package/dist/agent/usage.js +41 -0
  15. package/dist/cli.d.ts +11 -0
  16. package/dist/cli.js +482 -0
  17. package/dist/commands/builtins.d.ts +16 -0
  18. package/dist/commands/builtins.js +223 -0
  19. package/dist/commands/registry.d.ts +48 -0
  20. package/dist/commands/registry.js +45 -0
  21. package/dist/commands/skills.d.ts +22 -0
  22. package/dist/commands/skills.js +625 -0
  23. package/dist/config/app-paths.d.ts +11 -0
  24. package/dist/config/app-paths.js +16 -0
  25. package/dist/config/env.d.ts +28 -0
  26. package/dist/config/env.js +92 -0
  27. package/dist/config/load.d.ts +48 -0
  28. package/dist/config/load.js +158 -0
  29. package/dist/config/migrate-legacy-state.d.ts +78 -0
  30. package/dist/config/migrate-legacy-state.js +181 -0
  31. package/dist/config/schema.d.ts +249 -0
  32. package/dist/config/schema.js +247 -0
  33. package/dist/config/store.d.ts +44 -0
  34. package/dist/config/store.js +126 -0
  35. package/dist/input/keymap.d.ts +20 -0
  36. package/dist/input/keymap.js +85 -0
  37. package/dist/session/persist.d.ts +24 -0
  38. package/dist/session/persist.js +51 -0
  39. package/dist/skills/archive.d.ts +41 -0
  40. package/dist/skills/archive.js +152 -0
  41. package/dist/skills/cli-commands.d.ts +52 -0
  42. package/dist/skills/cli-commands.js +502 -0
  43. package/dist/skills/fetch-source.d.ts +104 -0
  44. package/dist/skills/fetch-source.js +448 -0
  45. package/dist/skills/installer.d.ts +117 -0
  46. package/dist/skills/installer.js +414 -0
  47. package/dist/skills/lock.d.ts +75 -0
  48. package/dist/skills/lock.js +223 -0
  49. package/dist/skills/manifest.d.ts +47 -0
  50. package/dist/skills/manifest.js +95 -0
  51. package/dist/skills/node-host.d.ts +23 -0
  52. package/dist/skills/node-host.js +59 -0
  53. package/dist/skills/paths.d.ts +61 -0
  54. package/dist/skills/paths.js +107 -0
  55. package/dist/skills/service.d.ts +256 -0
  56. package/dist/skills/service.js +730 -0
  57. package/dist/skills/tools.d.ts +35 -0
  58. package/dist/skills/tools.js +164 -0
  59. package/dist/skills/updater.d.ts +101 -0
  60. package/dist/skills/updater.js +330 -0
  61. package/dist/skills/usage.d.ts +72 -0
  62. package/dist/skills/usage.js +216 -0
  63. package/dist/tools/bash-tool.d.ts +12 -0
  64. package/dist/tools/bash-tool.js +131 -0
  65. package/dist/tools/diff.d.ts +15 -0
  66. package/dist/tools/diff.js +50 -0
  67. package/dist/tools/fs-tools.d.ts +16 -0
  68. package/dist/tools/fs-tools.js +215 -0
  69. package/dist/tools/index.d.ts +93 -0
  70. package/dist/tools/index.js +157 -0
  71. package/dist/tools/search-tools.d.ts +11 -0
  72. package/dist/tools/search-tools.js +225 -0
  73. package/dist/ui/App.d.ts +24 -0
  74. package/dist/ui/App.js +522 -0
  75. package/dist/ui/AutocompletePopup.d.ts +21 -0
  76. package/dist/ui/AutocompletePopup.js +19 -0
  77. package/dist/ui/Composer.d.ts +40 -0
  78. package/dist/ui/Composer.js +61 -0
  79. package/dist/ui/Header.d.ts +30 -0
  80. package/dist/ui/Header.js +25 -0
  81. package/dist/ui/Logo.d.ts +50 -0
  82. package/dist/ui/Logo.js +85 -0
  83. package/dist/ui/Markdown.d.ts +23 -0
  84. package/dist/ui/Markdown.js +122 -0
  85. package/dist/ui/PromptInput.d.ts +81 -0
  86. package/dist/ui/PromptInput.js +383 -0
  87. package/dist/ui/SessionOpener.d.ts +30 -0
  88. package/dist/ui/SessionOpener.js +12 -0
  89. package/dist/ui/StatusBar.d.ts +39 -0
  90. package/dist/ui/StatusBar.js +43 -0
  91. package/dist/ui/ToastStack.d.ts +24 -0
  92. package/dist/ui/ToastStack.js +38 -0
  93. package/dist/ui/Transcript.d.ts +62 -0
  94. package/dist/ui/Transcript.js +128 -0
  95. package/dist/ui/capabilities.d.ts +25 -0
  96. package/dist/ui/capabilities.js +59 -0
  97. package/dist/ui/console-bridge.d.ts +19 -0
  98. package/dist/ui/console-bridge.js +52 -0
  99. package/dist/ui/density.d.ts +26 -0
  100. package/dist/ui/density.js +34 -0
  101. package/dist/ui/entries/AssistantEntry.d.ts +23 -0
  102. package/dist/ui/entries/AssistantEntry.js +13 -0
  103. package/dist/ui/entries/EntryFrame.d.ts +23 -0
  104. package/dist/ui/entries/EntryFrame.js +6 -0
  105. package/dist/ui/entries/ToolCard.d.ts +38 -0
  106. package/dist/ui/entries/ToolCard.js +72 -0
  107. package/dist/ui/entries/ToolPreview.d.ts +57 -0
  108. package/dist/ui/entries/ToolPreview.js +144 -0
  109. package/dist/ui/entries/UserEntry.d.ts +14 -0
  110. package/dist/ui/entries/UserEntry.js +5 -0
  111. package/dist/ui/exit-snapshot.d.ts +27 -0
  112. package/dist/ui/exit-snapshot.js +26 -0
  113. package/dist/ui/gauge.d.ts +24 -0
  114. package/dist/ui/gauge.js +36 -0
  115. package/dist/ui/glyphs.d.ts +87 -0
  116. package/dist/ui/glyphs.js +127 -0
  117. package/dist/ui/gradient.d.ts +11 -0
  118. package/dist/ui/gradient.js +50 -0
  119. package/dist/ui/layout/AppShell.d.ts +24 -0
  120. package/dist/ui/layout/AppShell.js +9 -0
  121. package/dist/ui/layout/Gutter.d.ts +67 -0
  122. package/dist/ui/layout/Gutter.js +42 -0
  123. package/dist/ui/layout/OverlayFrame.d.ts +62 -0
  124. package/dist/ui/layout/OverlayFrame.js +92 -0
  125. package/dist/ui/layout/ScrollViewport.d.ts +33 -0
  126. package/dist/ui/layout/ScrollViewport.js +71 -0
  127. package/dist/ui/layout/budget.d.ts +42 -0
  128. package/dist/ui/layout/budget.js +47 -0
  129. package/dist/ui/layout/frame.d.ts +58 -0
  130. package/dist/ui/layout/frame.js +75 -0
  131. package/dist/ui/layout/overlay-window.d.ts +43 -0
  132. package/dist/ui/layout/overlay-window.js +51 -0
  133. package/dist/ui/layout/scroll.d.ts +28 -0
  134. package/dist/ui/layout/scroll.js +76 -0
  135. package/dist/ui/layout/useTerminalSize.d.ts +13 -0
  136. package/dist/ui/layout/useTerminalSize.js +50 -0
  137. package/dist/ui/markdown-blocks.d.ts +62 -0
  138. package/dist/ui/markdown-blocks.js +120 -0
  139. package/dist/ui/overlays/ConfirmDialog.d.ts +30 -0
  140. package/dist/ui/overlays/ConfirmDialog.js +25 -0
  141. package/dist/ui/overlays/HelpOverlay.d.ts +17 -0
  142. package/dist/ui/overlays/HelpOverlay.js +60 -0
  143. package/dist/ui/overlays/ModelPicker.d.ts +29 -0
  144. package/dist/ui/overlays/ModelPicker.js +29 -0
  145. package/dist/ui/overlays/SettingsScreen.d.ts +37 -0
  146. package/dist/ui/overlays/SettingsScreen.js +106 -0
  147. package/dist/ui/palettes.d.ts +79 -0
  148. package/dist/ui/palettes.js +115 -0
  149. package/dist/ui/screen.d.ts +27 -0
  150. package/dist/ui/screen.js +57 -0
  151. package/dist/ui/theme.d.ts +78 -0
  152. package/dist/ui/theme.js +127 -0
  153. package/dist/ui/transcript-text.d.ts +27 -0
  154. package/dist/ui/transcript-text.js +110 -0
  155. package/package.json +73 -0
  156. package/skills/skill-creator/SKILL.md +96 -0
@@ -0,0 +1,730 @@
1
+ /**
2
+ * SkillService — discovery, lazy loading, prompt-block assembly, and the
3
+ * lifecycle surface the UI and the agent tools call into (spec §12.2).
4
+ *
5
+ * Everything mutating (install / create / remove) delegates to `installer.ts`;
6
+ * this file owns the read side and the registry.
7
+ */
8
+ import { rmSync, statSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { SkillRegistry, computeToolPolicy, parseFrontmatter, renderSkillCatalog, } from '@aragon-agent/core';
11
+ import { SKILL_FILES_MAX, SKILL_MD_MAX_BYTES, SKILL_SCAN_MAX_DIRS, SKILL_SCAN_SOFT_TIMEOUT_MS, STAGING_TTL_MS, TOOL_POLICY_DENY_ESCALATE_AT, normalizeFrontmatter, renderAlwaysSkills, renderDenyEscalation, renderDenyEscalationNotice, validateSkillFrontmatter, } from '@aragon-agent/core/skills';
12
+ import { SKILL_TOOL_FLOOR } from '../tools/index.js';
13
+ import { currentPlatform } from './node-host.js';
14
+ import { MANIFEST_FILENAME, readManifest, sha256Buffer, sha256File } from './manifest.js';
15
+ import { isDirectory, normalizeTrustPath, resolveSkillRoots, getStagingDir, } from './paths.js';
16
+ import { USAGE_MAX_ENTRIES, loadUsage, pruneUsage, recordUse } from './usage.js';
17
+ const ENTRY_FILE = 'SKILL.md';
18
+ /**
19
+ * Re-exported from `manifest.ts` rather than spelled out again. The two copies
20
+ * of this literal were already a drift hazard; the rename made it concrete by
21
+ * requiring both to change together (spec §6.3).
22
+ */
23
+ const MANIFEST_FILE = MANIFEST_FILENAME;
24
+ const BUNDLED_FILE_DEPTH = 3;
25
+ /** v1 left backups inside the skills root; skip them so they cannot resurrect (P1-6). */
26
+ const BAK_DIR_PATTERN = /\.bak-\d+$/;
27
+ export class SkillService {
28
+ deps;
29
+ registry = new SkillRegistry();
30
+ config;
31
+ lastErrors = [];
32
+ pendingTrust = [];
33
+ /** Empty when `skills.usageTracking` is off — which is exactly invariant I-A1. */
34
+ usage = {};
35
+ /** The integrity warning is worth saying once a session, not once a rescan. */
36
+ integrityWarned = false;
37
+ /**
38
+ * `/skills policy <mode>` writes here. `null` = this session has no opinion.
39
+ * NEVER persisted — the config file is written separately by the same command.
40
+ */
41
+ sessionToolPolicy = null;
42
+ /**
43
+ * The previous decision, used to notice when the ceiling WIDENS (§5.6e).
44
+ *
45
+ * A skill can tell the model to load a second skill, and because the ceiling
46
+ * is a union with `skill` permanently in the floor, that works (D-G17). The
47
+ * path is not blocked — every way of blocking it costs more than it saves —
48
+ * so instead it leaves a trace. This pair of fields is that trace's memory.
49
+ */
50
+ lastDecisionAllowed = null;
51
+ lastDecisionSources = new Set();
52
+ constructor(deps) {
53
+ this.deps = deps;
54
+ this.config = deps.config;
55
+ if (this.config.usageTracking)
56
+ this.usage = loadUsage();
57
+ }
58
+ // -----------------------------------------------------------------------
59
+ // Discovery
60
+ // -----------------------------------------------------------------------
61
+ /**
62
+ * Scan all four roots in ascending precedence and rebuild the registry.
63
+ *
64
+ * One unreadable directory must never sink the whole pass: every root and
65
+ * every candidate is wrapped, and partial results are returned alongside the
66
+ * errors so `/skills` can show both what loaded and what did not.
67
+ */
68
+ discover() {
69
+ const errors = [];
70
+ const pendingTrust = [];
71
+ const records = [];
72
+ const startedAt = Date.now();
73
+ this.reclaimStaleStaging();
74
+ for (const root of resolveSkillRoots(this.deps.getCwd(), this.config.projectDirs)) {
75
+ if (Date.now() - startedAt > SKILL_SCAN_SOFT_TIMEOUT_MS) {
76
+ errors.push(`skill scan exceeded ${SKILL_SCAN_SOFT_TIMEOUT_MS}ms; remaining roots skipped`);
77
+ break;
78
+ }
79
+ if (!isDirectory(root.dir))
80
+ continue;
81
+ if (root.scope === 'project' && !this.isTrusted(root.dir)) {
82
+ pendingTrust.push(root.dir);
83
+ continue;
84
+ }
85
+ records.push(...this.scanRoot(root, errors));
86
+ }
87
+ this.registry.replaceAll(records);
88
+ for (const name of this.config.disabled)
89
+ this.registry.setDisabled(name, true);
90
+ this.lastErrors = errors;
91
+ this.pendingTrust = pendingTrust;
92
+ this.pruneUsageIfOverCap();
93
+ this.reportIntegrity();
94
+ return { records: this.registry.list(), errors, pendingTrust };
95
+ }
96
+ /**
97
+ * Enforce the usage-file cap (§9.2) — and ONLY when it is actually exceeded.
98
+ *
99
+ * `pruneUsage()` drops every counter whose skill is absent from the registry,
100
+ * and the registry is CWD-DEPENDENT: it holds the `project` skills of
101
+ * whichever directory this process happens to be running in. Calling it on
102
+ * every scan would therefore make a session in repo B silently erase the
103
+ * usage history of repo A — and a root that was momentarily unreadable would
104
+ * erase all of it. Counters for uninstalled skills are worth a few hundred
105
+ * bytes; the ranking signal they would take with them is not.
106
+ */
107
+ pruneUsageIfOverCap() {
108
+ if (!this.config.usageTracking)
109
+ return;
110
+ if (Object.keys(this.usage).length <= USAGE_MAX_ENTRIES)
111
+ return;
112
+ pruneUsage(this.registry.names());
113
+ }
114
+ /**
115
+ * Tell the user once per session which skills no longer match what they
116
+ * approved (`warn` mode only — `strict` already refuses them outright, and
117
+ * repeating the notice on every rescan is how a warning becomes wallpaper).
118
+ */
119
+ reportIntegrity() {
120
+ if (this.config.integrity !== 'warn' || this.integrityWarned)
121
+ return;
122
+ const modified = this.registry
123
+ .list()
124
+ .filter((r) => r.integrity === 'modified')
125
+ .map((r) => r.name);
126
+ if (modified.length === 0)
127
+ return;
128
+ this.integrityWarned = true;
129
+ this.deps.notify?.('warn', `SKILL.md changed since install: ${modified.join(', ')}. ` +
130
+ 'Run "aragon skills doctor" for details, or "aragon skills update <name> --force" to restore.');
131
+ }
132
+ /** Scan one root. Returns the records it yielded; appends to `errors` in place. */
133
+ scanRoot(root, errors) {
134
+ const out = [];
135
+ let entries;
136
+ try {
137
+ entries = this.deps.host.listDir(root.dir);
138
+ }
139
+ catch (err) {
140
+ errors.push(`cannot read skills root ${root.dir}: ${message(err)}`);
141
+ return out;
142
+ }
143
+ const dirs = entries
144
+ .filter((e) => e.isDirectory)
145
+ .filter((e) => !e.name.startsWith('.'))
146
+ .filter((e) => !BAK_DIR_PATTERN.test(e.name))
147
+ // Deterministic order: the same-name tie-break below must not depend on
148
+ // the platform's readdir sequence (§5.1 / §6.2 step 4).
149
+ .sort((a, b) => a.name.localeCompare(b.name, 'en'))
150
+ .slice(0, SKILL_SCAN_MAX_DIRS);
151
+ const seenInRoot = new Set();
152
+ for (const entry of dirs) {
153
+ const dir = join(root.dir, entry.name);
154
+ try {
155
+ const record = this.readSkillDir(dir, entry.name, root);
156
+ if (!record)
157
+ continue;
158
+ if (seenInRoot.has(record.name)) {
159
+ errors.push(`duplicate skill name "${record.name}" in ${root.dir}; keeping the first directory in lexical order`);
160
+ continue;
161
+ }
162
+ seenInRoot.add(record.name);
163
+ out.push(record);
164
+ }
165
+ catch (err) {
166
+ errors.push(`skipping ${dir}: ${message(err)}`);
167
+ }
168
+ }
169
+ return out;
170
+ }
171
+ /** Build a record from a candidate directory, or `null` when it is not a skill. */
172
+ readSkillDir(dir, dirName, root) {
173
+ const entryPath = join(dir, ENTRY_FILE);
174
+ if (!this.deps.host.exists(entryPath))
175
+ return null;
176
+ const bytes = statSync(entryPath).size;
177
+ const issues = [];
178
+ let source = '';
179
+ let readOk = false;
180
+ if (bytes > SKILL_MD_MAX_BYTES) {
181
+ issues.push({
182
+ level: 'error',
183
+ code: 'SKILL_MD_TOO_LARGE',
184
+ message: `SKILL.md is ${bytes} bytes (max ${SKILL_MD_MAX_BYTES})`,
185
+ });
186
+ }
187
+ else {
188
+ source = this.deps.host.readTextFile(entryPath);
189
+ readOk = true;
190
+ }
191
+ const parsed = source.length > 0 ? parseFrontmatter(source) : null;
192
+ if (source.length > 0 && !parsed) {
193
+ issues.push({
194
+ level: 'error',
195
+ code: 'SKILL_FRONTMATTER_MISSING',
196
+ message: 'SKILL.md has no parseable YAML frontmatter block',
197
+ });
198
+ }
199
+ const data = parsed?.data ?? {};
200
+ issues.push(...validateSkillFrontmatter(data, dirName));
201
+ const frontmatter = normalizeFrontmatter(data, dirName);
202
+ const manifest = readManifest(join(dir, MANIFEST_FILE));
203
+ const integrity = readOk
204
+ ? this.computeIntegrity(entryPath, source, manifest)
205
+ : 'unverified';
206
+ // `strict` turns a mismatch into a validation error, which is what removes
207
+ // the skill from the catalog, from the always-block, from `skill_find` and
208
+ // from the dynamic slash commands — all of which already key off `invalid`.
209
+ // Expressing it as an issue rather than four separate filters means a new
210
+ // consumer cannot forget to honour it.
211
+ if (integrity === 'modified' && this.config.integrity === 'strict') {
212
+ issues.push({
213
+ level: 'error',
214
+ code: 'SKILL_INTEGRITY_MISMATCH',
215
+ message: 'SKILL.md does not match the version that was approved at install time. ' +
216
+ 'Run "aragon skills doctor" to inspect it, or "aragon skills update <name> --force" to restore it.',
217
+ });
218
+ }
219
+ return {
220
+ name: frontmatter.name,
221
+ description: frontmatter.description,
222
+ scope: root.scope,
223
+ dir,
224
+ entryPath,
225
+ frontmatter,
226
+ body: null,
227
+ files: null,
228
+ bytes,
229
+ disabled: false,
230
+ issues,
231
+ invalid: issues.some((i) => i.level === 'error'),
232
+ shadowed: [],
233
+ manifest,
234
+ writable: root.writable,
235
+ integrity,
236
+ };
237
+ }
238
+ /**
239
+ * Compare SKILL.md against the hash recorded at install time (§8.1).
240
+ *
241
+ * Costs no extra read on the happy path: the text is already in memory from
242
+ * the scan, and the manifest was already parsed.
243
+ *
244
+ * D-A12 — THE SECOND HASH IS NOT REDUNDANT. `readTextFile()` returns a
245
+ * DECODED string. Re-encoding it round-trips byte-for-byte only for
246
+ * well-formed UTF-8; a file containing an invalid sequence comes back with
247
+ * U+FFFD substituted, so the re-encoded bytes differ from what is on disk and
248
+ * an untouched file would be reported as tampered. A false alarm here is
249
+ * worse than no alarm at all — it teaches the user that this warning is noise,
250
+ * and it is the same warning that has to be believed on the day it is real.
251
+ */
252
+ computeIntegrity(entryPath, sourceText, manifest) {
253
+ if (!manifest || this.config.integrity === 'off')
254
+ return 'unverified';
255
+ const entry = manifest.files.find((f) => f.path === ENTRY_FILE);
256
+ if (!entry)
257
+ return 'unverified';
258
+ if (sha256Buffer(Buffer.from(sourceText, 'utf-8')) === entry.sha256)
259
+ return 'ok';
260
+ try {
261
+ return sha256File(entryPath) === entry.sha256 ? 'ok' : 'modified';
262
+ }
263
+ catch {
264
+ return 'modified';
265
+ }
266
+ }
267
+ /** Reload from disk and notify the controller so the prompt is rebuilt. */
268
+ reload() {
269
+ const result = this.discover();
270
+ this.deps.onChange?.();
271
+ return result;
272
+ }
273
+ // -----------------------------------------------------------------------
274
+ // Read surface
275
+ // -----------------------------------------------------------------------
276
+ list() {
277
+ return this.registry.list();
278
+ }
279
+ get(name) {
280
+ return this.registry.get(name);
281
+ }
282
+ getRegistry() {
283
+ return this.registry;
284
+ }
285
+ errors() {
286
+ return this.lastErrors;
287
+ }
288
+ /** Project roots the trust gate is still holding back (§9.3). */
289
+ untrustedDirs() {
290
+ return this.pendingTrust;
291
+ }
292
+ /**
293
+ * Level 2 load: SKILL.md body + the bundled-file listing.
294
+ * MAY THROW — see the `SkillHost` error contract; `skill`'s `execute()` and
295
+ * every UI caller wrap this.
296
+ *
297
+ * THE SINGLE USAGE MEASUREMENT POINT (F8). All three consumption paths — the
298
+ * `skill` tool, a `/<skill-name>` command, and `alwaysBlock()` — funnel
299
+ * through here, so one call site covers everything.
300
+ *
301
+ * `countUse: false` is MANDATORY for `alwaysBlock()`. An always-on skill is
302
+ * injected automatically on every single turn; counting that would pin it to
303
+ * the top of the ranking forever and drown out the signal the ranking exists
304
+ * to capture, which is what a human or a model actually CHOSE to reach for.
305
+ */
306
+ loadBody(name, opts = {}) {
307
+ const record = this.registry.get(name);
308
+ if (!record)
309
+ throw new Error(`unknown skill "${name}"`);
310
+ const source = this.deps.host.readTextFile(record.entryPath);
311
+ const parsed = parseFrontmatter(source);
312
+ if (opts.countUse !== false && this.config.usageTracking)
313
+ recordUse(name);
314
+ return { body: parsed ? parsed.body : source, files: this.listSkillFiles(record.dir) };
315
+ }
316
+ /** Walk a skill directory for bundled resources (depth ≤ 3, dotfiles skipped). */
317
+ listSkillFiles(dir, prefix = '', depth = 0) {
318
+ if (depth >= BUNDLED_FILE_DEPTH)
319
+ return [];
320
+ let entries;
321
+ try {
322
+ entries = this.deps.host.listDir(dir);
323
+ }
324
+ catch {
325
+ return [];
326
+ }
327
+ const out = [];
328
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name, 'en'))) {
329
+ if (entry.name.startsWith('.'))
330
+ continue;
331
+ if (entry.name === ENTRY_FILE && prefix === '')
332
+ continue;
333
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
334
+ if (entry.isDirectory) {
335
+ out.push(...this.listSkillFiles(join(dir, entry.name), rel, depth + 1));
336
+ }
337
+ else {
338
+ out.push({ path: rel, bytes: entry.bytes });
339
+ }
340
+ if (out.length > SKILL_FILES_MAX * 2)
341
+ break;
342
+ }
343
+ return out;
344
+ }
345
+ // -----------------------------------------------------------------------
346
+ // Prompt blocks
347
+ // -----------------------------------------------------------------------
348
+ /** Level 1 catalog for the system prompt, or `''` when there is nothing to say. */
349
+ catalogBlock() {
350
+ if (!this.config.enabled)
351
+ return '';
352
+ return renderSkillCatalog(this.registry.list(), {
353
+ maxBytes: this.config.catalogMaxBytes,
354
+ // Omitted entirely when tracking is off, which is what makes the order
355
+ // degrade to the pre-ranking one byte for byte (I-A1).
356
+ ...(this.config.usageTracking ? { usage: this.usage } : {}),
357
+ });
358
+ }
359
+ /**
360
+ * `activation: always` bodies plus anything forced with `--skill`.
361
+ * Returns `''` when empty so the caller can concatenate unconditionally
362
+ * without disturbing invariant I-S1.
363
+ */
364
+ alwaysBlock() {
365
+ if (!this.config.enabled)
366
+ return '';
367
+ const records = this.registry.alwaysOn(this.deps.runtime.forcedSkills);
368
+ if (records.length === 0)
369
+ return '';
370
+ // Bodies are lazy by default (Level 2 is normally reached through the
371
+ // `skill` tool). Always-on skills are the one case where the body is needed
372
+ // at prompt-build time, so hydrate here — rendering the unhydrated record
373
+ // would emit a well-formed but EMPTY <skill> block, which looks like a
374
+ // working feature and teaches the model nothing.
375
+ const hydrated = [];
376
+ for (const record of records) {
377
+ if (record.body === null) {
378
+ try {
379
+ // countUse: false — see the `loadBody` contract. This injection is
380
+ // automatic, not a choice, and must not colour the ranking.
381
+ const loaded = this.loadBody(record.name, { countUse: false });
382
+ record.body = loaded.body;
383
+ record.files = loaded.files;
384
+ }
385
+ catch (err) {
386
+ this.lastErrors = [...this.lastErrors, `cannot load always-on skill ${record.name}: ${message(err)}`];
387
+ continue;
388
+ }
389
+ }
390
+ hydrated.push(record);
391
+ }
392
+ if (hydrated.length === 0)
393
+ return '';
394
+ // `platform` YES, `policy` NEVER (D-G18 / AC-G24).
395
+ //
396
+ // D-G9 keeps these skills out of the frame, so they impose no ceiling. Saying
397
+ // otherwise here would repeat "only these tools are permitted" in the system
398
+ // prompt on every single turn, for a restriction that does not exist, with
399
+ // nothing in the UI pointing at the frontmatter line responsible. Note the
400
+ // guarantee is structural, not a habit: `renderAlwaysSkills` has no `policy`
401
+ // field to pass, so this cannot regress by accident.
402
+ const block = renderAlwaysSkills(hydrated, {
403
+ bodyMaxBytes: this.config.bodyMaxBytes,
404
+ platform: this.platform(),
405
+ });
406
+ return block.length > 0 ? `\n${block}` : '';
407
+ }
408
+ /** Host shell family, forwarded into every Level 2 / Level 3 render (D-G14). */
409
+ platform() {
410
+ return currentPlatform();
411
+ }
412
+ // -----------------------------------------------------------------------
413
+ // Tool ceiling (§5)
414
+ // -----------------------------------------------------------------------
415
+ /**
416
+ * The mode actually in force, and where it came from.
417
+ *
418
+ * THREE LAYERS, not two (D-G19). `SkillsRuntimeOptions` is read-only for the
419
+ * life of the session, so with only `flag > config` a user who started with
420
+ * `--skill-tool-policy enforce` could run the very command the refusal text
421
+ * recommends — `/skills policy off` — and have precisely nothing happen, with
422
+ * no message either. An iteration about silent failures cannot ship one in its
423
+ * own escape hatch.
424
+ */
425
+ effectiveToolPolicy() {
426
+ if (this.sessionToolPolicy !== null)
427
+ return { mode: this.sessionToolPolicy, from: 'session' };
428
+ const flag = this.deps.runtime.toolPolicy;
429
+ if (flag)
430
+ return { mode: flag, from: 'flag' };
431
+ return { mode: this.config.toolPolicy, from: 'config' };
432
+ }
433
+ setSessionToolPolicy(mode) {
434
+ this.sessionToolPolicy = mode;
435
+ }
436
+ /** `/skills policy <mode>` — session slot and config file, together. */
437
+ persistToolPolicy(mode) {
438
+ this.updateConfig({ toolPolicy: mode });
439
+ }
440
+ /**
441
+ * Compute the ceiling for right now. Constant-time when the frame is empty or
442
+ * the mode is `off`, which is the overwhelmingly common case on the hot path.
443
+ */
444
+ toolPolicyDecision(getRegistered) {
445
+ const mode = this.effectiveToolPolicy().mode;
446
+ if (!this.config.enabled) {
447
+ return { mode: 'off', allowed: null, sources: [], sourceNames: [], ignored: [] };
448
+ }
449
+ const registered = getRegistered();
450
+ const decision = computeToolPolicy({
451
+ frame: this.registry.frameRecords(),
452
+ registered,
453
+ // Intersected with what is registered so `--no-skills`-style setups cannot
454
+ // advertise `skill` / `skill_find` in a permitted set that does not contain
455
+ // them. A phantom tool in that list is a small lie the model would act on.
456
+ floor: SKILL_TOOL_FLOOR.filter((n) => registered.includes(n)),
457
+ mode,
458
+ });
459
+ this.reportCeilingLifted();
460
+ this.reportFailOpen(decision);
461
+ this.reportCeilingWidened(decision);
462
+ return decision;
463
+ }
464
+ /** The renderer-facing slice of the current decision, or `undefined` for "say nothing". */
465
+ toolPolicyView(getRegistered) {
466
+ return toView(this.toolPolicyDecision(getRegistered));
467
+ }
468
+ /**
469
+ * The ceiling a QUEUED skill is about to impose — for the `/<skill-name>` path,
470
+ * whose message has not been submitted yet and whose frame is therefore still
471
+ * pending (§13.2).
472
+ *
473
+ * Computed from the pending set alone, which is exactly right for the common
474
+ * case (`controller.prompt()` replaces the frame with the pending one). When
475
+ * the agent happens to be running, `steer()` unions instead, so this can
476
+ * understate — it will never claim a tool is forbidden that is in fact allowed
477
+ * only because some other skill is also in the frame. Overstating would be the
478
+ * dangerous direction; a refusal, if one ever happens, prints the true set.
479
+ *
480
+ * Emits no announcements: nothing has been decided yet.
481
+ */
482
+ pendingToolPolicyView(getRegistered) {
483
+ const pending = this.registry.pendingFrameNames
484
+ .map((name) => this.registry.get(name))
485
+ .filter((r) => !!r && !r.disabled && !r.invalid);
486
+ if (pending.length === 0)
487
+ return undefined;
488
+ const registered = getRegistered();
489
+ return toView(computeToolPolicy({
490
+ frame: pending,
491
+ registered,
492
+ floor: SKILL_TOOL_FLOOR.filter((n) => registered.includes(n)),
493
+ mode: this.effectiveToolPolicy().mode,
494
+ }));
495
+ }
496
+ /**
497
+ * One notice per skill per session when a frame member stops constraining
498
+ * because it was uninstalled, disabled, or turned invalid (D-G20 / AC-G21).
499
+ *
500
+ * Without this, `/skills disable`, a `/skills reload` after an uninstall, and a
501
+ * flip to `integrity: strict` each end with the ceiling gone and nothing said
502
+ * — a silent lift, which is the same failure class as the silent no-op this
503
+ * whole iteration set out to remove.
504
+ */
505
+ reportCeilingLifted() {
506
+ for (const name of this.registry.frameNames) {
507
+ const record = this.registry.get(name);
508
+ if (record && !record.disabled && !record.invalid)
509
+ continue;
510
+ // A skill that never declared anything was never contributing, so its
511
+ // disappearance lifts nothing and is not worth a line.
512
+ if (record && record.frontmatter.allowedTools.length === 0)
513
+ continue;
514
+ if (!this.registry.noteFailOpenOnce(`lifted:${name}`))
515
+ continue;
516
+ this.deps.notify?.('warn', `Tool ceiling lifted: skill "${name}" is no longer available (uninstalled/disabled).`);
517
+ }
518
+ }
519
+ /** One notice per skill per session when D-G4 waives it (§5.7 / RG3). */
520
+ reportFailOpen(decision) {
521
+ for (const entry of decision.ignored) {
522
+ if (!this.registry.noteFailOpenOnce(`failopen:${entry.name}`))
523
+ continue;
524
+ this.deps.notify?.('warn', `Skill "${entry.name}" declares tools this host does not recognise ` +
525
+ `(${entry.unresolved.join(', ')}), so it is NOT ENFORCEABLE and imposes no tool ceiling. ` +
526
+ 'Run "aragon skills doctor" for details.');
527
+ }
528
+ }
529
+ /** Print the union-escalation trace (§5.6e / D-G17) and remember the new state. */
530
+ reportCeilingWidened(decision) {
531
+ const previous = this.lastDecisionAllowed;
532
+ if (decision.allowed !== null && previous !== null) {
533
+ const added = [...decision.allowed]
534
+ .filter((tool) => !previous.has(tool))
535
+ .sort((a, b) => a.localeCompare(b, 'en'));
536
+ if (added.length > 0) {
537
+ const fresh = decision.sourceNames.filter((n) => !this.lastDecisionSources.has(n));
538
+ const by = (fresh.length > 0 ? fresh : decision.sourceNames).join('", "');
539
+ this.deps.notify?.('warn', `Tool ceiling widened by skill "${by}": ${added.map((t) => `+${t}`).join(', ')}`);
540
+ }
541
+ }
542
+ this.lastDecisionAllowed = decision.allowed;
543
+ this.lastDecisionSources = new Set(decision.sourceNames);
544
+ }
545
+ /**
546
+ * The single place the three announce-once rules live (§5.4c / P1-2).
547
+ *
548
+ * `verdict.message` is MUTATED in the escalate branch rather than being
549
+ * followed by a second message: the model is already reading this text, and a
550
+ * separate message is one more thing for it to skip.
551
+ */
552
+ reportToolPolicyEvent(e) {
553
+ // A blocked tool with several sources still needs ONE bucket key. Attribution
554
+ // lives in `message`, which lists every source; this is only about how often
555
+ // the same complaint reaches the transcript.
556
+ const primary = e.sourceNames[0] ?? 'skill';
557
+ if (e.verdict.allow) {
558
+ if (!this.registry.noteWarnOnce(primary, e.tool))
559
+ return;
560
+ if (e.verdict.notice)
561
+ this.deps.notify?.('warn', e.verdict.notice);
562
+ return;
563
+ }
564
+ if (e.verdict.notice)
565
+ this.deps.notify?.('error', e.verdict.notice);
566
+ const count = this.registry.countDeny(e.tool);
567
+ if (count === TOOL_POLICY_DENY_ESCALATE_AT) {
568
+ e.verdict.message = `${e.verdict.message ?? ''}\n${renderDenyEscalation(e.tool, count)}`;
569
+ this.deps.notify?.('warn', renderDenyEscalationNotice(primary, e.tool, count));
570
+ }
571
+ }
572
+ // Frame lifecycle — thin forwards so the controller never reaches into the
573
+ // registry itself (`controller.ts` owns *when*, this class owns *what*).
574
+ beginUserTurn() {
575
+ this.registry.beginUserTurn();
576
+ this.lastDecisionAllowed = null;
577
+ this.lastDecisionSources = new Set();
578
+ }
579
+ absorbPendingFrames() {
580
+ this.registry.absorbPendingFrames();
581
+ }
582
+ queueFrame(name) {
583
+ this.registry.queueFrame(name);
584
+ }
585
+ clearFrames() {
586
+ this.registry.clearFrames();
587
+ this.lastDecisionAllowed = null;
588
+ this.lastDecisionSources = new Set();
589
+ }
590
+ frameNames() {
591
+ return this.registry.frameNames;
592
+ }
593
+ /** Level 2 body budget — shared by the tool path and the slash path (GG3). */
594
+ bodyMaxBytes() {
595
+ return this.config.bodyMaxBytes;
596
+ }
597
+ /** Warn once about `--skill <name>` values that match nothing (§7.5). */
598
+ reportUnknownForcedSkills() {
599
+ for (const name of this.deps.runtime.forcedSkills) {
600
+ if (!this.registry.get(name)) {
601
+ this.deps.notify?.('warn', `--skill "${name}" does not match any installed skill.`);
602
+ }
603
+ }
604
+ }
605
+ // -----------------------------------------------------------------------
606
+ // Mutators
607
+ // -----------------------------------------------------------------------
608
+ setDisabled(name, disabled) {
609
+ this.registry.setDisabled(name, disabled);
610
+ const next = new Set(this.config.disabled);
611
+ if (disabled)
612
+ next.add(name);
613
+ else
614
+ next.delete(name);
615
+ this.updateConfig({ disabled: [...next] });
616
+ this.deps.onChange?.();
617
+ }
618
+ getConfig() {
619
+ return this.config;
620
+ }
621
+ /** Local use counters backing `--sort=recent`; empty when tracking is off. */
622
+ getUsage() {
623
+ return this.config.usageTracking ? this.usage : {};
624
+ }
625
+ /** Working directory an install should resolve `--scope project` against. */
626
+ getCwdForInstall() {
627
+ return this.deps.getCwd();
628
+ }
629
+ getRuntime() {
630
+ return this.deps.runtime;
631
+ }
632
+ getApproval() {
633
+ return this.deps.approval;
634
+ }
635
+ notify(level, text) {
636
+ this.deps.notify?.(level, text);
637
+ }
638
+ /** Keep the in-memory copy and the config file in step (deep-merged, §10.3.2). */
639
+ updateConfig(patch) {
640
+ this.config = { ...this.config, ...patch };
641
+ this.deps.persist?.(patch);
642
+ }
643
+ // -----------------------------------------------------------------------
644
+ // Directory trust (D13 / §9.3)
645
+ // -----------------------------------------------------------------------
646
+ isTrusted(dir) {
647
+ const normalized = normalizeTrustPath(dir);
648
+ if (!normalized)
649
+ return false;
650
+ return this.config.trustedProjectDirs.some((t) => normalizeTrustPath(t) === normalized);
651
+ }
652
+ trustDir(dir) {
653
+ const normalized = normalizeTrustPath(dir);
654
+ if (!normalized)
655
+ return false;
656
+ if (this.config.trustedProjectDirs.includes(normalized))
657
+ return true;
658
+ this.updateConfig({ trustedProjectDirs: [...this.config.trustedProjectDirs, normalized] });
659
+ return true;
660
+ }
661
+ untrustDir(dir) {
662
+ const normalized = normalizeTrustPath(dir) ?? dir;
663
+ const next = this.config.trustedProjectDirs.filter((t) => (normalizeTrustPath(t) ?? t) !== normalized);
664
+ if (next.length === this.config.trustedProjectDirs.length)
665
+ return false;
666
+ this.updateConfig({ trustedProjectDirs: next });
667
+ return true;
668
+ }
669
+ /**
670
+ * Load a project root for THIS SESSION ONLY, without persisting trust.
671
+ * Used when the user answers the confirm dialog with "just this once".
672
+ */
673
+ trustForSession(dir) {
674
+ this.pendingTrust = this.pendingTrust.filter((d) => d !== dir);
675
+ const root = { dir, scope: 'project', writable: dir.includes('.aragon') };
676
+ const errors = [];
677
+ for (const record of this.scanRoot(root, errors))
678
+ this.registry.add(record);
679
+ for (const name of this.config.disabled)
680
+ this.registry.setDisabled(name, true);
681
+ this.lastErrors = [...this.lastErrors, ...errors];
682
+ this.deps.onChange?.();
683
+ }
684
+ // -----------------------------------------------------------------------
685
+ // Housekeeping
686
+ // -----------------------------------------------------------------------
687
+ /**
688
+ * Drop staging directories left behind by a crashed install (P2-10).
689
+ * Same discipline as the host repo's workspace cleanup: age-based, best
690
+ * effort, and never allowed to interrupt discovery.
691
+ */
692
+ reclaimStaleStaging() {
693
+ const staging = getStagingDir();
694
+ if (!isDirectory(staging))
695
+ return;
696
+ const cutoff = Date.now() - STAGING_TTL_MS;
697
+ let entries;
698
+ try {
699
+ entries = this.deps.host.listDir(staging);
700
+ }
701
+ catch {
702
+ return;
703
+ }
704
+ for (const entry of entries) {
705
+ if (!entry.isDirectory)
706
+ continue;
707
+ const dir = join(staging, entry.name);
708
+ try {
709
+ if (statSync(dir).mtimeMs >= cutoff)
710
+ continue;
711
+ rmSync(dir, { recursive: true, force: true });
712
+ }
713
+ catch {
714
+ // Best effort: a locked directory is retried on the next launch.
715
+ }
716
+ }
717
+ }
718
+ }
719
+ function message(err) {
720
+ return err instanceof Error ? err.message : String(err);
721
+ }
722
+ /** Decision → the flat, sorted shape the renderers take. `null` means "say nothing". */
723
+ function toView(decision) {
724
+ if (decision.allowed === null)
725
+ return undefined;
726
+ return {
727
+ mode: decision.mode,
728
+ allowed: [...decision.allowed].sort((a, b) => a.localeCompare(b, 'en')),
729
+ };
730
+ }