@joenandez/academy 0.4.0-rc.1
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/.claude-plugin/marketplace.json +14 -0
- package/.claude-plugin/plugin.json +6 -0
- package/CHANGELOG.md +46 -0
- package/LICENSE +21 -0
- package/README.md +209 -0
- package/bin/academy +2 -0
- package/conformance/README.md +60 -0
- package/conformance/discovery.test.mjs +140 -0
- package/conformance/envelope.test.mjs +185 -0
- package/conformance/error-codes.test.mjs +125 -0
- package/conformance/harness.mjs +180 -0
- package/conformance/identity.test.mjs +125 -0
- package/docs/integration-guide.md +1026 -0
- package/hooks/hook_runtime.mjs +100 -0
- package/hooks/hooks.json +26 -0
- package/hooks/inject_surface.py +122 -0
- package/hooks/memory_bridge.mjs +120 -0
- package/hooks/memory_store.mjs +66 -0
- package/hooks/register_session.mjs +51 -0
- package/hooks/sync_memory.mjs +27 -0
- package/package.json +41 -0
- package/scripts/agent.mjs +3 -0
- package/scripts/cli/archive.mjs +161 -0
- package/scripts/cli/archived.mjs +82 -0
- package/scripts/cli/args.mjs +282 -0
- package/scripts/cli/codex.mjs +216 -0
- package/scripts/cli/core.mjs +389 -0
- package/scripts/cli/create.mjs +242 -0
- package/scripts/cli/doctor.mjs +203 -0
- package/scripts/cli/eventlog.mjs +129 -0
- package/scripts/cli/events.mjs +80 -0
- package/scripts/cli/hire-headless.mjs +229 -0
- package/scripts/cli/hire-spec.mjs +164 -0
- package/scripts/cli/hire.mjs +92 -0
- package/scripts/cli/inspect.mjs +286 -0
- package/scripts/cli/lifecycle.mjs +296 -0
- package/scripts/cli/main.mjs +102 -0
- package/scripts/cli/migrate.mjs +183 -0
- package/scripts/cli/notes.mjs +104 -0
- package/scripts/cli/rename.mjs +172 -0
- package/scripts/cli/run.mjs +227 -0
- package/scripts/cli/runtime.mjs +47 -0
- package/scripts/cli/scaffold.mjs +332 -0
- package/scripts/cli/sessions.mjs +98 -0
- package/scripts/cli/templates.mjs +104 -0
- package/scripts/cli/yaml.mjs +124 -0
- package/skills/hire/SKILL.md +669 -0
- package/templates/agents/claude-code/knowledge-curator.md +14 -0
- package/templates/agents/codex/knowledge-curator.toml +9 -0
- package/templates/skills/check-in/SKILL.md +122 -0
- package/templates/skills/knowledge-curation/SKILL.md +132 -0
- package/templates/skills/nightly-consolidation/SKILL.md +240 -0
- package/templates/skills/self-update/SKILL.md +121 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { existsSync, lstatSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
AGENTS_ROOT,
|
|
5
|
+
CLI_NAME,
|
|
6
|
+
ENFORCED_SURFACES,
|
|
7
|
+
NAME_RE,
|
|
8
|
+
SURFACES,
|
|
9
|
+
SURFACE_CAPS,
|
|
10
|
+
TOTAL_SURFACE_CAP,
|
|
11
|
+
agentDir,
|
|
12
|
+
checkAgentsRoot,
|
|
13
|
+
contractOk,
|
|
14
|
+
exitJsonError,
|
|
15
|
+
validateName,
|
|
16
|
+
} from './core.mjs';
|
|
17
|
+
import { archivedAgentDir, archivedAgentNames, assertNotArchived } from './archived.mjs';
|
|
18
|
+
import { assertContainedAgentDir } from './lifecycle.mjs';
|
|
19
|
+
import { readEventLogWatermark } from './eventlog.mjs';
|
|
20
|
+
import { readRuntimeProvider, runtimeProviderOrNull } from './runtime.mjs';
|
|
21
|
+
import { academySystemPromptPath, buildAcademySystemPrompt, tokenRecord } from './scaffold.mjs';
|
|
22
|
+
import { readAgentYaml } from './yaml.mjs';
|
|
23
|
+
|
|
24
|
+
export function listAgents() {
|
|
25
|
+
const entries = agentNames();
|
|
26
|
+
if (entries.length === 0 && archivedAgentNames().length === 0) {
|
|
27
|
+
console.log(`(no agents yet — try \`${CLI_NAME} hire\` or \`${CLI_NAME} create <name>\`)`);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
for (const name of entries) printRosterLine(name, agentDir(name), '');
|
|
31
|
+
for (const name of archivedAgentNames()) {
|
|
32
|
+
printRosterLine(name, archivedAgentDir(name), '(archived) ');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function printRosterLine(name, dir, prefix) {
|
|
37
|
+
const role = readAgentYaml(dir).role || '(no role set)';
|
|
38
|
+
console.log(` ${(prefix + name).padEnd(20)} ${role}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The watermark is read before the roster, and that order is the contract. Read
|
|
42
|
+
// after, an agent created between the two reads would be missing from the
|
|
43
|
+
// roster and below the watermark, so no later `events --since` could ever
|
|
44
|
+
// deliver it. Read before, the same agent is in the roster and its event is
|
|
45
|
+
// re-delivered, which the client's seq dedup absorbs.
|
|
46
|
+
//
|
|
47
|
+
// `agents` is the working roster and `archived` names what it leaves out. An
|
|
48
|
+
// archived agent that simply vanished would be indistinguishable from a deleted
|
|
49
|
+
// one, and this is the documented re-sync path after a replay gap.
|
|
50
|
+
export function listAgentsJson() {
|
|
51
|
+
const { lastSeq, logId } = readEventLogWatermark();
|
|
52
|
+
contractOk('list', {
|
|
53
|
+
agents: agentNames().map((name) => agentRecord(name)),
|
|
54
|
+
archived: archivedAgentNames().map((name) =>
|
|
55
|
+
agentRecord(name, { dir: archivedAgentDir(name) }),
|
|
56
|
+
),
|
|
57
|
+
lastSeq,
|
|
58
|
+
logId,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The single directory-listing entry point for the agents root. The root audit
|
|
63
|
+
// runs first so an unusable root is reported rather than read as empty.
|
|
64
|
+
// Name-filtering before the stat keeps the archive, lock, and delete-quarantine
|
|
65
|
+
// dot-directories out of the roster, and the guarded stat lets an entry deleted
|
|
66
|
+
// mid-scan disappear quietly instead of throwing a non-envelope stack trace.
|
|
67
|
+
function agentNames() {
|
|
68
|
+
if (!checkAgentsRoot().exists) return [];
|
|
69
|
+
return readAgentsRoot().filter(isAgentDirectory).sort();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// A root that passed the audit but cannot be listed is not a usable root, and
|
|
73
|
+
// reporting it empty would tell a re-syncing client to drop its whole roster.
|
|
74
|
+
function readAgentsRoot() {
|
|
75
|
+
try {
|
|
76
|
+
return readdirSync(AGENTS_ROOT);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
exitJsonError('unsafe_agent_path', `AGENTS_ROOT cannot be listed: ${error.message}`, {
|
|
79
|
+
agentsRoot: resolve(AGENTS_ROOT),
|
|
80
|
+
});
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// lstat, not stat: a symlinked entry is not an agent Academy owns, and `delete`
|
|
86
|
+
// rejects it as unsafe. Listing it would make the two commands disagree.
|
|
87
|
+
export function isAgentDirectory(entry) {
|
|
88
|
+
if (!NAME_RE.test(entry)) return false;
|
|
89
|
+
try {
|
|
90
|
+
return lstatSync(join(AGENTS_ROOT, entry)).isDirectory();
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// `strictRuntime` is the difference between a roster and an answer. The roster
|
|
97
|
+
// degrades per agent so one unparseable `runtime:` cannot hide the healthy
|
|
98
|
+
// agents beside it — `list` is the documented re-sync path, and a client locked
|
|
99
|
+
// out of it has no recovery left. A direct question about one agent raises
|
|
100
|
+
// instead. Neither path ever names a provider the scalar did not.
|
|
101
|
+
export function agentRecord(
|
|
102
|
+
name,
|
|
103
|
+
{ dir = agentDir(name), includeSurfaces = false, strictRuntime = false } = {},
|
|
104
|
+
) {
|
|
105
|
+
const yaml = readAgentYaml(dir);
|
|
106
|
+
const record = {
|
|
107
|
+
name,
|
|
108
|
+
dir: resolve(dir),
|
|
109
|
+
displayName: yaml.displayName || yaml.display_name || yaml.name || name,
|
|
110
|
+
runtimeProvider: strictRuntime ? readRuntimeProvider(dir) : runtimeProviderOrNull(dir),
|
|
111
|
+
};
|
|
112
|
+
if (yaml.role) record.role = yaml.role;
|
|
113
|
+
if (includeSurfaces) record.surfaces = surfacePresence(dir);
|
|
114
|
+
return record;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function surfacePresence(dir) {
|
|
118
|
+
return Object.fromEntries(
|
|
119
|
+
SURFACES.map((surface) => [surface, existsSync(join(dir, `${surface}.md`))]),
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function inspectAgent(name, json) {
|
|
124
|
+
validateName(name);
|
|
125
|
+
checkAgentsRoot();
|
|
126
|
+
assertNotArchived(name);
|
|
127
|
+
const dir = agentDir(name);
|
|
128
|
+
if (!existsSync(dir)) {
|
|
129
|
+
const message = `Agent "${name}" not found at ${dir}`;
|
|
130
|
+
if (json) exitJsonError('agent_not_found', message, { name });
|
|
131
|
+
console.error(message);
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
assertContainedAgentDir(name, dir);
|
|
135
|
+
|
|
136
|
+
const record = agentRecord(name, { includeSurfaces: true, strictRuntime: true });
|
|
137
|
+
if (json) {
|
|
138
|
+
contractOk('inspect', record);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
console.log(`${record.name} (${record.runtimeProvider})`);
|
|
143
|
+
console.log(` dir: ${record.dir}`);
|
|
144
|
+
console.log(` displayName: ${record.displayName}`);
|
|
145
|
+
if (record.role) console.log(` role: ${record.role}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function roundPercent(part, total) {
|
|
149
|
+
if (!total) return 0;
|
|
150
|
+
return Math.round((part / total) * 1000) / 10;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function promptTokenReport(name) {
|
|
154
|
+
validateName(name);
|
|
155
|
+
checkAgentsRoot();
|
|
156
|
+
assertNotArchived(name);
|
|
157
|
+
const dir = resolve(agentDir(name));
|
|
158
|
+
if (!existsSync(dir)) return { missing: true, dir };
|
|
159
|
+
assertContainedAgentDir(name, dir);
|
|
160
|
+
|
|
161
|
+
const built = buildAcademySystemPrompt(dir, name);
|
|
162
|
+
const total = tokenRecord(built.prompt);
|
|
163
|
+
const overheadText = [
|
|
164
|
+
...built.intro,
|
|
165
|
+
...built.surfaces.flatMap((surface) => [surface.marker, '']),
|
|
166
|
+
].join('\n');
|
|
167
|
+
const overhead = tokenRecord(overheadText);
|
|
168
|
+
const surfaces = built.surfaces.map((surface) => {
|
|
169
|
+
const count = tokenRecord(surface.content);
|
|
170
|
+
return {
|
|
171
|
+
name: surface.name,
|
|
172
|
+
file: surface.file,
|
|
173
|
+
path: surface.path,
|
|
174
|
+
exists: surface.exists,
|
|
175
|
+
...count,
|
|
176
|
+
percent: roundPercent(count.estimatedTokens, total.estimatedTokens),
|
|
177
|
+
};
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
agent: name,
|
|
182
|
+
dir,
|
|
183
|
+
promptPath: academySystemPromptPath(dir),
|
|
184
|
+
tokenizer: 'estimated:chars-and-words-v1',
|
|
185
|
+
total,
|
|
186
|
+
overhead: {
|
|
187
|
+
...overhead,
|
|
188
|
+
percent: roundPercent(overhead.estimatedTokens, total.estimatedTokens),
|
|
189
|
+
},
|
|
190
|
+
surfaces,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function printPromptTokens(name, json) {
|
|
195
|
+
const report = promptTokenReport(name);
|
|
196
|
+
if (report.missing) {
|
|
197
|
+
const message = `Agent "${name}" not found at ${report.dir}`;
|
|
198
|
+
if (json) exitJsonError('agent_not_found', message, { name });
|
|
199
|
+
console.error(message);
|
|
200
|
+
process.exit(1);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (json) {
|
|
204
|
+
contractOk('tokens', report);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
console.log(`${report.agent} prompt token estimate`);
|
|
209
|
+
console.log(`tokenizer: ${report.tokenizer}`);
|
|
210
|
+
console.log('');
|
|
211
|
+
console.log(
|
|
212
|
+
`${'total'.padEnd(12)} ${String(report.total.estimatedTokens).padStart(7)} tokens ${String(report.total.chars).padStart(7)} chars`,
|
|
213
|
+
);
|
|
214
|
+
console.log(
|
|
215
|
+
`${'overhead'.padEnd(12)} ${String(report.overhead.estimatedTokens).padStart(7)} tokens ${String(report.overhead.chars).padStart(7)} chars ${report.overhead.percent.toFixed(1).padStart(5)}%`,
|
|
216
|
+
);
|
|
217
|
+
console.log('');
|
|
218
|
+
for (const surface of report.surfaces) {
|
|
219
|
+
console.log(
|
|
220
|
+
`${surface.name.padEnd(12)} ${String(surface.estimatedTokens).padStart(7)} tokens ${String(surface.chars).padStart(7)} chars ${surface.percent.toFixed(1).padStart(5)}%`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function printSurfaceBudget(name, json) {
|
|
226
|
+
const tokenReport = promptTokenReport(name);
|
|
227
|
+
if (tokenReport.missing) {
|
|
228
|
+
const message = `Agent "${name}" not found at ${tokenReport.dir}`;
|
|
229
|
+
if (json) exitJsonError('agent_not_found', message, { name });
|
|
230
|
+
console.error(message);
|
|
231
|
+
process.exit(1);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const surfaces = tokenReport.surfaces.map((surface) => {
|
|
235
|
+
const cap = SURFACE_CAPS[surface.name];
|
|
236
|
+
const overBy = Math.max(0, surface.estimatedTokens - cap);
|
|
237
|
+
return {
|
|
238
|
+
name: surface.name,
|
|
239
|
+
estimatedTokens: surface.estimatedTokens,
|
|
240
|
+
cap,
|
|
241
|
+
overBy,
|
|
242
|
+
withinCap: overBy === 0,
|
|
243
|
+
enforced: ENFORCED_SURFACES.has(surface.name),
|
|
244
|
+
};
|
|
245
|
+
});
|
|
246
|
+
const totalEstimatedTokens = surfaces.reduce((sum, surface) => sum + surface.estimatedTokens, 0);
|
|
247
|
+
const violations = surfaces.filter((surface) => !surface.withinCap);
|
|
248
|
+
const report = {
|
|
249
|
+
agent: tokenReport.agent,
|
|
250
|
+
dir: tokenReport.dir,
|
|
251
|
+
withinBudget: !violations.some((surface) => surface.enforced),
|
|
252
|
+
total: {
|
|
253
|
+
estimatedTokens: totalEstimatedTokens,
|
|
254
|
+
cap: TOTAL_SURFACE_CAP,
|
|
255
|
+
overBy: Math.max(0, totalEstimatedTokens - TOTAL_SURFACE_CAP),
|
|
256
|
+
},
|
|
257
|
+
surfaces,
|
|
258
|
+
violations,
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const exitCode = report.violations.some((surface) => surface.enforced) ? 1 : 0;
|
|
262
|
+
if (json) {
|
|
263
|
+
contractOk('budget', report);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
console.log(`${report.agent} prompt budget`);
|
|
268
|
+
console.log(
|
|
269
|
+
`${'total'.padEnd(12)} ${String(report.total.estimatedTokens).padStart(7)} / ${String(report.total.cap).padStart(4)} tokens ${report.total.overBy === 0 ? 'OK' : `OVER by ${report.total.overBy}`}`,
|
|
270
|
+
);
|
|
271
|
+
console.log('');
|
|
272
|
+
for (const surface of report.surfaces) {
|
|
273
|
+
const status = surface.withinCap ? 'OK' : `OVER by ${surface.overBy}`;
|
|
274
|
+
const mode = surface.enforced ? 'enforced' : 'advisory';
|
|
275
|
+
console.log(
|
|
276
|
+
`${surface.name.padEnd(12)} ${String(surface.estimatedTokens).padStart(7)} / ${String(surface.cap).padStart(4)} tokens ${status} ${mode}`,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
console.log('');
|
|
280
|
+
console.log(exitCode === 0 ? 'PASS' : 'FAIL');
|
|
281
|
+
process.exitCode = exitCode;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
285
|
+
// `clean` — truncate transient surfaces
|
|
286
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { existsSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { readJsonFile } from './codex.mjs';
|
|
5
|
+
import {
|
|
6
|
+
AGENTS_ROOT,
|
|
7
|
+
agentDir,
|
|
8
|
+
agentLifecycleLockPath,
|
|
9
|
+
contractOk,
|
|
10
|
+
exitJsonError,
|
|
11
|
+
isInside,
|
|
12
|
+
isSymlink,
|
|
13
|
+
jsonMode,
|
|
14
|
+
LockTimeoutError,
|
|
15
|
+
RuntimeUnavailableError,
|
|
16
|
+
resolveExecutable,
|
|
17
|
+
validateAgentsRoot,
|
|
18
|
+
validateName,
|
|
19
|
+
withFileLock,
|
|
20
|
+
} from './core.mjs';
|
|
21
|
+
import { assertNotArchived, holdingAreaOrNull } from './archived.mjs';
|
|
22
|
+
import { LogCorruptError, appendLifecycleEvent } from './eventlog.mjs';
|
|
23
|
+
import { AgentSpecError } from './yaml.mjs';
|
|
24
|
+
import { MUST_EXIST, helmFailureReason } from './create.mjs';
|
|
25
|
+
import { TEMPLATES } from './templates.mjs';
|
|
26
|
+
|
|
27
|
+
export function cleanAgent(name) {
|
|
28
|
+
validateName(name);
|
|
29
|
+
const dir = agentDir(name);
|
|
30
|
+
if (!existsSync(dir)) {
|
|
31
|
+
console.error(`Agent "${name}" not found at ${dir}`);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
// Reset notes.md and threads.md to scaffolded state. Other surfaces are
|
|
35
|
+
// load-bearing (identity/role/knowledge) or natural-decay (dailys/goals/priorities).
|
|
36
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
37
|
+
writeFileSync(join(dir, 'notes.md'), TEMPLATES['notes.md'](name, today));
|
|
38
|
+
writeFileSync(join(dir, 'threads.md'), TEMPLATES['threads.md'](name, today));
|
|
39
|
+
console.log(`Cleaned transient surfaces for "${name}" (notes.md, threads.md).`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
43
|
+
// `destroy` — nuke an agent (--force required)
|
|
44
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
export function deleteJsonError(code, message, fields = {}, json = false) {
|
|
47
|
+
if (json) exitJsonError(code, message, fields);
|
|
48
|
+
console.error(message);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class DeleteAgentError extends Error {
|
|
53
|
+
constructor(code, message, fields = {}) {
|
|
54
|
+
super(message);
|
|
55
|
+
this.code = code;
|
|
56
|
+
this.fields = fields;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Ownership is one question, asked by every command that acts on an agent
|
|
61
|
+
// directory and by `doctor` when it counts what `migrate` still has to fix.
|
|
62
|
+
// It answers with the fault text, or null when the directory is owned.
|
|
63
|
+
export function ownershipFault(name, dir) {
|
|
64
|
+
const markerPath = join(dir, '.academy-agent.json');
|
|
65
|
+
if (!existsSync(markerPath) || !existsSync(join(dir, 'agent.yaml'))) {
|
|
66
|
+
return `Agent "${name}" is missing Academy ownership metadata`;
|
|
67
|
+
}
|
|
68
|
+
const marker = readJsonFile(markerPath, {});
|
|
69
|
+
if (marker.capability !== 'academy-agent' || marker.name !== name) {
|
|
70
|
+
return `Agent "${name}" ownership metadata is invalid`;
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The slot an agent directory is allowed to occupy, and the whole boundary
|
|
76
|
+
// once the canonical rule is relaxed. `canonical` is every command's default;
|
|
77
|
+
// `archived` is the holding area, checked exactly as strictly; `contained` is
|
|
78
|
+
// the delete quarantine, which sits directly inside the root under a name no
|
|
79
|
+
// agent can have.
|
|
80
|
+
export const SLOT = { canonical: 'canonical', archived: 'archived', contained: 'contained' };
|
|
81
|
+
|
|
82
|
+
// The holding area is resolved by the one rule in archived.mjs, which every
|
|
83
|
+
// read and every write now shares: an unprovable `.archived` has no slots at
|
|
84
|
+
// all, so an archived agent can never resolve to some other directory.
|
|
85
|
+
function archivedSlot(name) {
|
|
86
|
+
const holding = holdingAreaOrNull();
|
|
87
|
+
return holding === null ? null : join(holding, name);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function slotFault(name, dirReal, rootReal, slot) {
|
|
91
|
+
if (!isInside(dirReal, rootReal)) return `Agent "${name}" resolves outside AGENTS_ROOT`;
|
|
92
|
+
if (slot === SLOT.contained) return null;
|
|
93
|
+
const expected = slot === SLOT.archived ? archivedSlot(name) : join(rootReal, name);
|
|
94
|
+
if (expected === dirReal) return null;
|
|
95
|
+
return `Agent "${name}" does not occupy its ${slot} slot inside AGENTS_ROOT`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Where an agent directory is allowed to be, with no question about what is
|
|
99
|
+
// inside it. Stated once, because a boundary expressed in two places is a
|
|
100
|
+
// boundary that disagrees with itself the first time one end learns a rule the
|
|
101
|
+
// other has not: `delete`, `rename` and `archive` ask it through
|
|
102
|
+
// `ownedAgentFault` below before they move a directory, and `inspect`, `tokens`
|
|
103
|
+
// and `budget` ask it directly before they publish one.
|
|
104
|
+
function containmentFault(name, dir, slot) {
|
|
105
|
+
if (isSymlink(dir)) {
|
|
106
|
+
return {
|
|
107
|
+
code: 'unsafe_agent_path',
|
|
108
|
+
message: `Agent "${name}" is not an owned Academy directory: ${dir}`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
let rootReal;
|
|
112
|
+
let dirReal;
|
|
113
|
+
try {
|
|
114
|
+
rootReal = realpathSync(AGENTS_ROOT);
|
|
115
|
+
dirReal = realpathSync(dir);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
return {
|
|
118
|
+
code: 'unsafe_agent_path',
|
|
119
|
+
message: `Agent "${name}" cannot be resolved: ${error.message}`,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
const misplaced = slotFault(name, dirReal, rootReal, slot);
|
|
123
|
+
if (misplaced) return { code: 'unsafe_agent_path', message: misplaced };
|
|
124
|
+
return { dirReal };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// One rule, two reactions. `delete` exits on it before taking the lock and
|
|
128
|
+
// throws on it inside; a validator stated twice is a validator that disagrees
|
|
129
|
+
// with itself the first time one end learns a rule the other has not.
|
|
130
|
+
function ownedAgentFault(name, dir, slot) {
|
|
131
|
+
if (!existsSync(dir)) {
|
|
132
|
+
return { code: 'agent_not_found', message: `Agent "${name}" not found at ${dir}` };
|
|
133
|
+
}
|
|
134
|
+
const contained = containmentFault(name, dir, slot);
|
|
135
|
+
if (contained.code) return contained;
|
|
136
|
+
const fault = ownershipFault(name, dir);
|
|
137
|
+
if (fault) return { code: 'not_academy_owned', message: fault };
|
|
138
|
+
return { dir, dirReal: contained.dirReal };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The containment half, as the raising check a read command owes. A read moves
|
|
142
|
+
// nothing, so it never asks the ownership question — an unowned directory is
|
|
143
|
+
// still readable, and `migrate` is the answer to it. It asks where the slot
|
|
144
|
+
// landed, because a read that follows one out of the root publishes content
|
|
145
|
+
// from outside it under a `dir` field claiming to be inside.
|
|
146
|
+
export function assertContainedAgentDir(name, dir, json = jsonMode()) {
|
|
147
|
+
const fault = containmentFault(name, dir, SLOT.canonical);
|
|
148
|
+
if (fault.code) deleteJsonError(fault.code, fault.message, { name }, json);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function validateOwnedAgentDir(name, dir, json = false, slot = SLOT.canonical) {
|
|
152
|
+
const outcome = ownedAgentFault(name, dir, slot);
|
|
153
|
+
if (outcome.code) deleteJsonError(outcome.code, outcome.message, { name }, json);
|
|
154
|
+
return outcome;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// The shared preflight for `delete`, `rename` and `archive`. `unarchive` is the
|
|
158
|
+
// one lifecycle command that may address an archived agent, so it preflights
|
|
159
|
+
// against the holding area instead of coming through here.
|
|
160
|
+
export function preflightOwnedAgent(name, json = false) {
|
|
161
|
+
validateName(name);
|
|
162
|
+
validateAgentsRoot();
|
|
163
|
+
assertNotArchived(name, json);
|
|
164
|
+
return validateOwnedAgentDir(name, agentDir(name), json);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function assertOwnedAgentForDelete(name, dir, slot = SLOT.canonical) {
|
|
168
|
+
const outcome = ownedAgentFault(name, dir, slot);
|
|
169
|
+
if (outcome.code) throw new DeleteAgentError(outcome.code, outcome.message, { name });
|
|
170
|
+
return outcome;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function deleteNightlyConsolidation(name, dir) {
|
|
174
|
+
const id = `${name}-nightly-consolidation`;
|
|
175
|
+
// Resolved inside the lock, so exiting here would strand the lock directory
|
|
176
|
+
// and make every later delete of this agent unanswerable.
|
|
177
|
+
let helmTasksBin;
|
|
178
|
+
try {
|
|
179
|
+
helmTasksBin = resolveExecutable('ACADEMY_HELM_TASKS_BIN', 'helm-tasks', MUST_EXIST);
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if (!(error instanceof RuntimeUnavailableError)) throw error;
|
|
182
|
+
return { ok: false, id, code: error.code, reason: error.message };
|
|
183
|
+
}
|
|
184
|
+
const result = spawnSync(helmTasksBin, ['delete', '--cwd', dir, '--id', id], {
|
|
185
|
+
cwd: dirname(dir),
|
|
186
|
+
encoding: 'utf8',
|
|
187
|
+
env: process.env,
|
|
188
|
+
});
|
|
189
|
+
if (result.error) return { ok: false, id, reason: result.error.message };
|
|
190
|
+
if (result.status !== 0)
|
|
191
|
+
return {
|
|
192
|
+
ok: false,
|
|
193
|
+
id,
|
|
194
|
+
reason: helmFailureReason(result.stderr || result.stdout, result.status),
|
|
195
|
+
};
|
|
196
|
+
return { ok: true, id };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// The one failure path `delete`, `rename` and `archive` share. Each stated the
|
|
200
|
+
// typed set itself and then printed prose and exited, so an untyped throw never
|
|
201
|
+
// reached the floor in `main.mjs` — and every one of those throws happens after
|
|
202
|
+
// the directory has already moved. A --json client was told the operation
|
|
203
|
+
// failed, in bytes it cannot parse, about a move that stands.
|
|
204
|
+
export function reportLifecycleFailure(error, verb, name, json) {
|
|
205
|
+
if (
|
|
206
|
+
error instanceof DeleteAgentError ||
|
|
207
|
+
error instanceof LockTimeoutError ||
|
|
208
|
+
error instanceof LogCorruptError ||
|
|
209
|
+
error instanceof AgentSpecError
|
|
210
|
+
) {
|
|
211
|
+
deleteJsonError(error.code, error.message, error.fields, json);
|
|
212
|
+
}
|
|
213
|
+
// Rethrown, not printed: `main.mjs` renders `internal_error`, the floor under
|
|
214
|
+
// the envelope. A shell user keeps the prose, which is more useful at a
|
|
215
|
+
// terminal, and the stack the floor would swallow.
|
|
216
|
+
if (json) throw error;
|
|
217
|
+
console.error(`Error: failed to ${verb} agent "${name}": ${error.message}`);
|
|
218
|
+
process.exit(1);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function deleteAgent(name, json) {
|
|
222
|
+
preflightOwnedAgent(name, json);
|
|
223
|
+
const lockDir = agentLifecycleLockPath(agentDir(name));
|
|
224
|
+
try {
|
|
225
|
+
withFileLock(lockDir, () => deleteAgentLocked(name, json));
|
|
226
|
+
} catch (error) {
|
|
227
|
+
reportLifecycleFailure(error, 'delete', name, json);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function deleteAgentLocked(name, _json) {
|
|
232
|
+
const { dir, dirReal } = assertOwnedAgentForDelete(name, agentDir(name));
|
|
233
|
+
const quarantine = join(
|
|
234
|
+
resolve(AGENTS_ROOT),
|
|
235
|
+
`.${name}.delete-quarantine.${process.pid}.${Date.now()}`,
|
|
236
|
+
);
|
|
237
|
+
renameSync(dir, quarantine);
|
|
238
|
+
assertOwnedAgentForDelete(name, quarantine, SLOT.contained);
|
|
239
|
+
|
|
240
|
+
const unscheduled = deleteNightlyConsolidation(name, dirReal);
|
|
241
|
+
if (!unscheduled.ok) {
|
|
242
|
+
if (!existsSync(dir)) {
|
|
243
|
+
renameSync(quarantine, dir);
|
|
244
|
+
} else {
|
|
245
|
+
throw new DeleteAgentError(
|
|
246
|
+
'unschedule_failed_restore_blocked',
|
|
247
|
+
`Refusing to delete "${name}" because nightly unschedule failed and ${dir} is occupied: ${unscheduled.reason}`,
|
|
248
|
+
{ name, quarantine },
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
throw new DeleteAgentError(
|
|
252
|
+
unscheduled.code ?? 'unschedule_failed',
|
|
253
|
+
`Refusing to delete "${name}" because nightly unschedule failed: ${unscheduled.reason}`,
|
|
254
|
+
{ name },
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
rmSync(quarantine, { recursive: true, force: false });
|
|
258
|
+
appendLifecycleEvent('agent_deleted', name, dir);
|
|
259
|
+
if (_json) {
|
|
260
|
+
// Absolute, like every other command's `dir`: a client keying agents on the
|
|
261
|
+
// field must be able to match its create record against this confirmation.
|
|
262
|
+
contractOk('delete', {
|
|
263
|
+
deleted: true,
|
|
264
|
+
name,
|
|
265
|
+
dir: resolve(dir),
|
|
266
|
+
unscheduledJobId: unscheduled.id,
|
|
267
|
+
});
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
console.log(`Deleted agent "${name}" (removed ${dir}).`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function destroyAgent(name, force) {
|
|
274
|
+
validateName(name);
|
|
275
|
+
const dir = agentDir(name);
|
|
276
|
+
if (!existsSync(dir)) {
|
|
277
|
+
console.error(`Agent "${name}" not found at ${dir}`);
|
|
278
|
+
process.exit(1);
|
|
279
|
+
}
|
|
280
|
+
if (!force) {
|
|
281
|
+
console.error(`Refusing to destroy "${name}" without --force.`);
|
|
282
|
+
console.error(`This removes ${dir} and all its files.`);
|
|
283
|
+
process.exit(1);
|
|
284
|
+
}
|
|
285
|
+
rmSync(dir, { recursive: true, force: true });
|
|
286
|
+
console.log(`Destroyed agent "${name}" (removed ${dir}).`);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
290
|
+
// `notes` — append-only micro-steering staging on an agent's notes.md
|
|
291
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
// Resolve the target agent home for a notes command. Precedence: explicit
|
|
294
|
+
// <agent> arg → ACADEMY_AGENT_DIR → ACADEMY_AGENT_HOME (spec synonym) →
|
|
295
|
+
// ACADEMY_AGENT_NAME → error. The *_DIR/_HOME vars are absolute agent-home
|
|
296
|
+
// paths; ACADEMY_AGENT_DIR is what `academy run` exports.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { parseArgs, printUsage } from './args.mjs';
|
|
3
|
+
import {
|
|
4
|
+
ACADEMY_ROOT,
|
|
5
|
+
AGENTS_ROOT,
|
|
6
|
+
CONTRACT_VERSION,
|
|
7
|
+
contractOk,
|
|
8
|
+
exitJsonError,
|
|
9
|
+
jsonMode,
|
|
10
|
+
setActiveCommand,
|
|
11
|
+
} from './core.mjs';
|
|
12
|
+
import { archiveAgent, unarchiveAgent } from './archive.mjs';
|
|
13
|
+
import { createAgent } from './create.mjs';
|
|
14
|
+
import { UNIMPLEMENTED_COMMANDS, reportHealth } from './doctor.mjs';
|
|
15
|
+
import { readEvents } from './events.mjs';
|
|
16
|
+
import { hireAgent } from './hire.mjs';
|
|
17
|
+
import { hireFromSpec } from './hire-headless.mjs';
|
|
18
|
+
import {
|
|
19
|
+
inspectAgent,
|
|
20
|
+
listAgents,
|
|
21
|
+
listAgentsJson,
|
|
22
|
+
printPromptTokens,
|
|
23
|
+
printSurfaceBudget,
|
|
24
|
+
} from './inspect.mjs';
|
|
25
|
+
import { cleanAgent, deleteAgent, destroyAgent } from './lifecycle.mjs';
|
|
26
|
+
import { backfillOwnership } from './migrate.mjs';
|
|
27
|
+
import { notesAdd, notesList } from './notes.mjs';
|
|
28
|
+
import { renameAgent } from './rename.mjs';
|
|
29
|
+
import { runAgent, runNightly } from './run.mjs';
|
|
30
|
+
import { readSessions } from './sessions.mjs';
|
|
31
|
+
|
|
32
|
+
const COMMAND_HANDLERS = {
|
|
33
|
+
...Object.fromEntries(UNIMPLEMENTED_COMMANDS.map((command) => [command, unimplementedCommand])),
|
|
34
|
+
doctor: (parsed) => reportHealth(parsed.json),
|
|
35
|
+
help: (parsed) => {
|
|
36
|
+
printUsage();
|
|
37
|
+
process.exit(parsed.exitCode ?? 0);
|
|
38
|
+
},
|
|
39
|
+
create: (parsed) => createAgent(parsed.name, parsed.json),
|
|
40
|
+
// `--spec` is the only thing that selects the headless form; without it the
|
|
41
|
+
// interactive launch runs exactly as it always has.
|
|
42
|
+
hire: (parsed) =>
|
|
43
|
+
parsed.spec === null ? hireAgent(parsed.passthrough, parsed.json) : hireFromSpec(parsed),
|
|
44
|
+
run: (parsed) => runAgent(parsed.name, parsed.runtime, parsed.passthrough),
|
|
45
|
+
nightly: (parsed) => runNightly(parsed.name),
|
|
46
|
+
events: (parsed) => readEvents(parsed),
|
|
47
|
+
migrate: (parsed) => backfillOwnership(parsed),
|
|
48
|
+
rename: (parsed) => renameAgent(parsed.name, parsed.newName, parsed.json, parsed.invalidOption),
|
|
49
|
+
archive: (parsed) => archiveAgent(parsed.name, parsed.json),
|
|
50
|
+
unarchive: (parsed) => unarchiveAgent(parsed.name, parsed.json),
|
|
51
|
+
sessions: (parsed) => readSessions(parsed),
|
|
52
|
+
list: (parsed) => (parsed.json ? listAgentsJson() : listAgents()),
|
|
53
|
+
inspect: (parsed) => inspectAgent(parsed.name, parsed.json),
|
|
54
|
+
tokens: (parsed) => printPromptTokens(parsed.name, parsed.json),
|
|
55
|
+
budget: (parsed) => printSurfaceBudget(parsed.name, parsed.json),
|
|
56
|
+
clean: (parsed) => cleanAgent(parsed.name),
|
|
57
|
+
delete: (parsed) => deleteAgent(parsed.name, parsed.json),
|
|
58
|
+
destroy: (parsed) => destroyAgent(parsed.name, parsed.force),
|
|
59
|
+
root: (parsed) => {
|
|
60
|
+
if (parsed.json)
|
|
61
|
+
contractOk('root', { packageRoot: ACADEMY_ROOT, agentsRoot: resolve(AGENTS_ROOT) });
|
|
62
|
+
else console.log(ACADEMY_ROOT);
|
|
63
|
+
},
|
|
64
|
+
notes: (parsed) => {
|
|
65
|
+
if (parsed.action === 'add') notesAdd(parsed.name, parsed.text);
|
|
66
|
+
else notesList(parsed.name, parsed.last);
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// `doctor` advertises the whole contract_version 1 command set, including the
|
|
71
|
+
// commands later phases build. Answering those in the envelope is what keeps
|
|
72
|
+
// the capability list honest: a client that calls what doctor named gets a
|
|
73
|
+
// parseable failure naming the gap, not a usage dump with no contract shape.
|
|
74
|
+
// `internal_error` is the floor under the envelope and the only one of the
|
|
75
|
+
// fifteen codes that fits — inventing a sixteenth is not permitted.
|
|
76
|
+
function unimplementedCommand(parsed) {
|
|
77
|
+
const message = `Command "${parsed.command}" is published at contract_version ${CONTRACT_VERSION} but is not implemented in this build.`;
|
|
78
|
+
if (parsed.json) exitJsonError('internal_error', message);
|
|
79
|
+
console.error(`Error: ${message}`);
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function main() {
|
|
84
|
+
const parsed = parseArgs(process.argv.slice(2));
|
|
85
|
+
setActiveCommand(parsed.command, parsed.json);
|
|
86
|
+
const handler = COMMAND_HANDLERS[parsed.command];
|
|
87
|
+
if (!handler) {
|
|
88
|
+
printUsage();
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
handler(parsed);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
// The floor under the envelope, not the primary handler: a --json caller
|
|
95
|
+
// gets a parseable failure for a throw no command anticipated. A shell user
|
|
96
|
+
// keeps the stack trace, which is more useful at a terminal.
|
|
97
|
+
if (!jsonMode()) throw error;
|
|
98
|
+
exitJsonError('internal_error', error.message);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
main();
|