@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,216 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { ACADEMY_ROOT, memoryBridgeEnabled, resolveExecutable, shellSingleQuote } from './core.mjs';
|
|
6
|
+
import { MEMORY_BRIDGE_ENV_KEYS } from '../../hooks/memory_bridge.mjs';
|
|
7
|
+
|
|
8
|
+
function codexHome() {
|
|
9
|
+
return process.env.CODEX_HOME || join(homedir(), '.codex');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function codexProfileName(name) {
|
|
13
|
+
return `academy-${name}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function tomlString(value) {
|
|
17
|
+
return JSON.stringify(value);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function codexRuntimeContextPath() {
|
|
21
|
+
return join(codexHome(), 'academy-runtime-context.json');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function academyHookCommand(scriptName, contextPath) {
|
|
25
|
+
return [
|
|
26
|
+
'env',
|
|
27
|
+
`ACADEMY_RUNTIME_CONTEXT=${shellSingleQuote(contextPath)}`,
|
|
28
|
+
'node',
|
|
29
|
+
shellSingleQuote(join(ACADEMY_ROOT, 'hooks', scriptName)),
|
|
30
|
+
].join(' ');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function writeCodexProfile(dir, name) {
|
|
34
|
+
const profileName = codexProfileName(name);
|
|
35
|
+
const profilePath = join(codexHome(), `${profileName}.config.toml`);
|
|
36
|
+
const knowledgeCuratorConfigPath = join(dir, '.codex', 'agents', 'knowledge-curator.toml');
|
|
37
|
+
mkdirSync(dirname(profilePath), { recursive: true });
|
|
38
|
+
const toml = `# Generated by Academy. This profile adds Academy runtime defaults.
|
|
39
|
+
sandbox_mode = "workspace-write"
|
|
40
|
+
approval_policy = "on-request"
|
|
41
|
+
|
|
42
|
+
[sandbox_workspace_write]
|
|
43
|
+
writable_roots = [${tomlString(dir)}]
|
|
44
|
+
|
|
45
|
+
[features]
|
|
46
|
+
hooks = true
|
|
47
|
+
|
|
48
|
+
[agents.knowledge_curator]
|
|
49
|
+
description = "Apply evidence-gated note graduations during Academy nightly consolidation."
|
|
50
|
+
config_file = ${tomlString(knowledgeCuratorConfigPath)}
|
|
51
|
+
`;
|
|
52
|
+
writeFileSync(profilePath, toml);
|
|
53
|
+
return { profileName, profilePath };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function readJsonFile(path, fallback) {
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
59
|
+
} catch {
|
|
60
|
+
return fallback;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function ensureTomlTableEnabled(toml, tableHeader) {
|
|
65
|
+
const lines = toml.split('\n');
|
|
66
|
+
const next = [];
|
|
67
|
+
let found = false;
|
|
68
|
+
for (let i = 0; i < lines.length; i++) {
|
|
69
|
+
if (lines[i] !== tableHeader) {
|
|
70
|
+
next.push(lines[i]);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
found = true;
|
|
74
|
+
next.push(lines[i]);
|
|
75
|
+
let hasEnabled = false;
|
|
76
|
+
i++;
|
|
77
|
+
while (i < lines.length && !lines[i].startsWith('[')) {
|
|
78
|
+
if (lines[i].trim().startsWith('enabled =')) hasEnabled = true;
|
|
79
|
+
next.push(lines[i]);
|
|
80
|
+
i++;
|
|
81
|
+
}
|
|
82
|
+
if (!hasEnabled) next.push('enabled = true');
|
|
83
|
+
i--;
|
|
84
|
+
}
|
|
85
|
+
if (!found) {
|
|
86
|
+
next.push('', tableHeader, 'enabled = true');
|
|
87
|
+
}
|
|
88
|
+
return next.join('\n');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function writeCodexHookState(entries) {
|
|
92
|
+
const configPath = join(codexHome(), 'config.toml');
|
|
93
|
+
let toml = existsSync(configPath) ? readFileSync(configPath, 'utf8') : '';
|
|
94
|
+
if (!toml.includes('[hooks.state]')) {
|
|
95
|
+
toml = `${toml.trimEnd()}\n\n[hooks.state]\n`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for (const entry of entries) {
|
|
99
|
+
const header = `[hooks.state.${tomlString(entry)}]`;
|
|
100
|
+
toml = ensureTomlTableEnabled(toml, header).trimEnd();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
104
|
+
writeFileSync(configPath, toml.trimEnd() + '\n');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function writeCodexHooks(contextPath) {
|
|
108
|
+
const hooksPath = join(codexHome(), 'hooks.json');
|
|
109
|
+
const config = readJsonFile(hooksPath, { hooks: {} });
|
|
110
|
+
config.hooks = config.hooks && typeof config.hooks === 'object' ? config.hooks : {};
|
|
111
|
+
|
|
112
|
+
config.hooks.SessionStart = Array.isArray(config.hooks.SessionStart)
|
|
113
|
+
? config.hooks.SessionStart
|
|
114
|
+
: [];
|
|
115
|
+
config.hooks.Stop = Array.isArray(config.hooks.Stop) ? config.hooks.Stop : [];
|
|
116
|
+
|
|
117
|
+
const sessionStartGroup = {
|
|
118
|
+
hooks: [
|
|
119
|
+
{
|
|
120
|
+
_academy: 'academy-runtime-hooks-v1',
|
|
121
|
+
type: 'command',
|
|
122
|
+
command: academyHookCommand('register_session.mjs', contextPath),
|
|
123
|
+
timeout: 30,
|
|
124
|
+
statusMessage: 'Registering Academy session',
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
};
|
|
128
|
+
const stopGroup = {
|
|
129
|
+
hooks: [
|
|
130
|
+
{
|
|
131
|
+
_academy: 'academy-runtime-hooks-v1',
|
|
132
|
+
type: 'command',
|
|
133
|
+
command: academyHookCommand('sync_memory.mjs', contextPath),
|
|
134
|
+
timeout: 30,
|
|
135
|
+
statusMessage: 'Syncing Academy memory',
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const sessionStartIndex = upsertCodexHookGroup(config.hooks.SessionStart, sessionStartGroup);
|
|
141
|
+
const stopIndex = upsertCodexHookGroup(config.hooks.Stop, stopGroup);
|
|
142
|
+
|
|
143
|
+
mkdirSync(dirname(hooksPath), { recursive: true });
|
|
144
|
+
writeFileSync(hooksPath, JSON.stringify(config, null, 2) + '\n');
|
|
145
|
+
writeCodexHookState([
|
|
146
|
+
`${hooksPath}:session_start:${sessionStartIndex}:0`,
|
|
147
|
+
`${hooksPath}:stop:${stopIndex}:0`,
|
|
148
|
+
]);
|
|
149
|
+
return hooksPath;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function upsertCodexHookGroup(groups, nextGroup) {
|
|
153
|
+
const index = groups.findIndex((group) => {
|
|
154
|
+
return (group?.hooks || []).some((hook) => hook?._academy === 'academy-runtime-hooks-v1');
|
|
155
|
+
});
|
|
156
|
+
if (index >= 0) {
|
|
157
|
+
groups[index] = nextGroup;
|
|
158
|
+
return index;
|
|
159
|
+
}
|
|
160
|
+
groups.push(nextGroup);
|
|
161
|
+
return groups.length - 1;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function writeCodexRuntimeContext(env, contextPath) {
|
|
165
|
+
// The Codex sandbox strips the ambient environment, so forward Academy's own
|
|
166
|
+
// keys — plus whatever the memory bridge declares it needs, but only when the
|
|
167
|
+
// bridge is switched on.
|
|
168
|
+
const keys = [
|
|
169
|
+
'ACADEMY_AGENT_DIR',
|
|
170
|
+
'ACADEMY_AGENT_NAME',
|
|
171
|
+
'ACADEMY_PROJECT_DIR',
|
|
172
|
+
'HOME',
|
|
173
|
+
...(memoryBridgeEnabled() ? MEMORY_BRIDGE_ENV_KEYS : []),
|
|
174
|
+
];
|
|
175
|
+
const context = {
|
|
176
|
+
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
|
177
|
+
env: Object.fromEntries(keys.filter((key) => env?.[key]).map((key) => [key, env[key]])),
|
|
178
|
+
};
|
|
179
|
+
mkdirSync(dirname(contextPath), { recursive: true });
|
|
180
|
+
writeFileSync(contextPath, JSON.stringify(context, null, 2) + '\n');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function launchCodex(
|
|
184
|
+
args,
|
|
185
|
+
{ cwd, env, message, profileName, profilePath, runtimeContextPath },
|
|
186
|
+
) {
|
|
187
|
+
console.log(message);
|
|
188
|
+
const codexBin =
|
|
189
|
+
process.env.ACADEMY_DRY_RUN === '1'
|
|
190
|
+
? process.env.ACADEMY_CODEX_BIN || 'codex'
|
|
191
|
+
: resolveExecutable('ACADEMY_CODEX_BIN', 'codex');
|
|
192
|
+
if (process.env.ACADEMY_DRY_RUN === '1') {
|
|
193
|
+
console.log(`[dry-run] ${codexBin} ${args.join(' ')}`);
|
|
194
|
+
console.log(`[dry-run] cwd=${cwd}`);
|
|
195
|
+
console.log(`[dry-run] CODEX_PROFILE=${profileName}`);
|
|
196
|
+
console.log(`[dry-run] CODEX_PROFILE_PATH=${profilePath}`);
|
|
197
|
+
if (env?.ACADEMY_AGENT_DIR) console.log(`[dry-run] ACADEMY_AGENT_DIR=${env.ACADEMY_AGENT_DIR}`);
|
|
198
|
+
if (env?.ACADEMY_PROJECT_DIR)
|
|
199
|
+
console.log(`[dry-run] ACADEMY_PROJECT_DIR=${env.ACADEMY_PROJECT_DIR}`);
|
|
200
|
+
process.exit(0);
|
|
201
|
+
}
|
|
202
|
+
writeCodexRuntimeContext(env, runtimeContextPath);
|
|
203
|
+
const result = spawnSync(codexBin, args, { stdio: 'inherit', cwd, env });
|
|
204
|
+
rmSync(runtimeContextPath, { force: true });
|
|
205
|
+
if (result.error) {
|
|
206
|
+
console.error(
|
|
207
|
+
`[ACADEMY_RUNTIME] Failed to launch Codex at ${codexBin}: ${result.error.message}`,
|
|
208
|
+
);
|
|
209
|
+
process.exit(1);
|
|
210
|
+
}
|
|
211
|
+
process.exit(result.status ?? 0);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
215
|
+
// `run` — launch Claude Code against the current project via plugin mode
|
|
216
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
lstatSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readlinkSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
realpathSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
statSync,
|
|
10
|
+
symlinkSync,
|
|
11
|
+
unlinkSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from 'node:fs';
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
16
|
+
import { spawnSync } from 'node:child_process';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
|
|
19
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
20
|
+
const __dirname = dirname(__filename);
|
|
21
|
+
|
|
22
|
+
/** Academy package root (parent of scripts/). */
|
|
23
|
+
export const ACADEMY_ROOT = resolve(__dirname, '../..');
|
|
24
|
+
export const ACADEMY_CLI_PATH = join(ACADEMY_ROOT, 'scripts', 'agent.mjs');
|
|
25
|
+
|
|
26
|
+
/** Global agents root — portable plugin layout. Key v3 differentiator (§2). */
|
|
27
|
+
const DEFAULT_AGENTS_ROOT = join(homedir(), '.academy', 'agents');
|
|
28
|
+
const NAMED_AGENTS_ROOT = process.env.AGENTS_ROOT;
|
|
29
|
+
export const AGENTS_ROOT = NAMED_AGENTS_ROOT || DEFAULT_AGENTS_ROOT;
|
|
30
|
+
|
|
31
|
+
// Whether the root is Academy's own or one an operator named. The two states
|
|
32
|
+
// disagree about exactly one thing — a missing parent — and about nothing else.
|
|
33
|
+
const AGENTS_ROOT_IS_DEFAULT = !NAMED_AGENTS_ROOT;
|
|
34
|
+
|
|
35
|
+
/** Display name for a managed wrapper without changing Academy's API. */
|
|
36
|
+
export const CLI_NAME = process.env.ACADEMY_CLI_NAME?.trim() || 'academy';
|
|
37
|
+
|
|
38
|
+
/** Allowed agent name pattern: kebab-case, 1–32 chars. */
|
|
39
|
+
export const NAME_RE = /^[a-z][a-z0-9-]{0,31}$/;
|
|
40
|
+
|
|
41
|
+
export const SURFACES = [
|
|
42
|
+
'identity',
|
|
43
|
+
'role',
|
|
44
|
+
'knowledge',
|
|
45
|
+
'goals',
|
|
46
|
+
'priorities',
|
|
47
|
+
'threads',
|
|
48
|
+
'notes',
|
|
49
|
+
'dailys',
|
|
50
|
+
];
|
|
51
|
+
export const SURFACE_CAPS = {
|
|
52
|
+
identity: 400,
|
|
53
|
+
role: 400,
|
|
54
|
+
knowledge: 2500,
|
|
55
|
+
goals: 150,
|
|
56
|
+
priorities: 250,
|
|
57
|
+
threads: 1750,
|
|
58
|
+
notes: 500,
|
|
59
|
+
dailys: 1050,
|
|
60
|
+
};
|
|
61
|
+
export const TOTAL_SURFACE_CAP = Object.values(SURFACE_CAPS).reduce((sum, cap) => sum + cap, 0);
|
|
62
|
+
export const ENFORCED_SURFACES = new Set(['knowledge', 'priorities', 'threads', 'notes', 'dailys']);
|
|
63
|
+
export const UNIVERSAL_SKILLS = [
|
|
64
|
+
'check-in',
|
|
65
|
+
'self-update',
|
|
66
|
+
'nightly-consolidation',
|
|
67
|
+
'knowledge-curation',
|
|
68
|
+
];
|
|
69
|
+
export const NIGHTLY_JOB_CRON = '0 22 * * *';
|
|
70
|
+
export const SCHEDULED_CLAUDE_PERMISSION_ARGS = ['--permission-mode', 'auto'];
|
|
71
|
+
export const SCHEDULED_CODEX_PERMISSION_ARGS = [
|
|
72
|
+
'--ask-for-approval',
|
|
73
|
+
'never',
|
|
74
|
+
'--sandbox',
|
|
75
|
+
'workspace-write',
|
|
76
|
+
'exec',
|
|
77
|
+
];
|
|
78
|
+
export const ACADEMY_SYSTEM_PROMPT = 'academy-system-prompt.md';
|
|
79
|
+
export const RUNTIMES = new Set(['claude-code', 'codex']);
|
|
80
|
+
|
|
81
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
82
|
+
// Argument parsing
|
|
83
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
export function validateName(name, json = jsonMode()) {
|
|
86
|
+
if (!name) return invalidName('agent name required', name ?? null, json);
|
|
87
|
+
if (!NAME_RE.test(name)) {
|
|
88
|
+
return invalidName(
|
|
89
|
+
`invalid name "${name}". Use kebab-case, 1–32 chars, starting with a letter.`,
|
|
90
|
+
name,
|
|
91
|
+
json,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function invalidName(message, name, json) {
|
|
97
|
+
if (json) contractError(activeCommandName(), 'invalid_name', message, { name });
|
|
98
|
+
console.error(`Error: ${message}`);
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function agentDir(name) {
|
|
103
|
+
return join(AGENTS_ROOT, name);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The holding area — `.archived`, its containment rule and every command's
|
|
107
|
+
// question about it — lives in archived.mjs, which imports from here.
|
|
108
|
+
|
|
109
|
+
// The one lock that serialises everything acting on a single agent. It lives
|
|
110
|
+
// beside the agent directory rather than inside it, so a lifecycle command can
|
|
111
|
+
// still hold it while the directory itself moves. Derived from the directory so
|
|
112
|
+
// `delete` and the agent.yaml scalar write cannot name different locks.
|
|
113
|
+
export function agentLifecycleLockPath(dir) {
|
|
114
|
+
const resolved = resolve(dir);
|
|
115
|
+
return join(dirname(resolved), `.${basename(resolved)}.lifecycle.lock`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The read-only root audit, as an answer rather than an exit. `doctor` reports
|
|
119
|
+
// on a root it cannot use and must never exit from inside a probe, so the
|
|
120
|
+
// finding and the reaction to it live apart. It never creates the root: `list`
|
|
121
|
+
// on an unmounted volume must report the fault, not manufacture an empty roster.
|
|
122
|
+
export function auditAgentsRoot() {
|
|
123
|
+
const root = resolve(AGENTS_ROOT);
|
|
124
|
+
const parent = dirname(root);
|
|
125
|
+
let parentReal;
|
|
126
|
+
try {
|
|
127
|
+
parentReal = realpathSync(parent);
|
|
128
|
+
} catch {
|
|
129
|
+
return missingParent(root, parent);
|
|
130
|
+
}
|
|
131
|
+
if (!existsSync(root)) return { root, exists: false, problem: null };
|
|
132
|
+
return { root, exists: true, problem: rootProblem(root, parentReal) };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// A missing `~/.academy` is a user who has not started yet, not a broken
|
|
136
|
+
// install: the client provisions Academy silently and calls `doctor` before it
|
|
137
|
+
// renders anything, so a first run answered with ok:false is a product that
|
|
138
|
+
// looks dead on arrival. An operator who names a root and gets it wrong still
|
|
139
|
+
// hears about it — an unmounted volume must never read as an empty roster.
|
|
140
|
+
function missingParent(root, parent) {
|
|
141
|
+
if (AGENTS_ROOT_IS_DEFAULT) return { root, exists: false, problem: null };
|
|
142
|
+
return { root, exists: false, problem: `AGENTS_ROOT parent does not exist: ${parent}` };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// All four findings share one stable code: the agents root is not a path
|
|
146
|
+
// Academy can safely use. The text names which check failed.
|
|
147
|
+
function rootProblem(root, parentReal) {
|
|
148
|
+
if (isSymlink(root)) return `AGENTS_ROOT must not be a symlink: ${root}`;
|
|
149
|
+
try {
|
|
150
|
+
if (!statSync(root).isDirectory()) return `AGENTS_ROOT is not a directory: ${root}`;
|
|
151
|
+
if (dirname(realpathSync(root)) !== parentReal) {
|
|
152
|
+
return `AGENTS_ROOT resolves outside its parent: ${root}`;
|
|
153
|
+
}
|
|
154
|
+
} catch (error) {
|
|
155
|
+
return `AGENTS_ROOT cannot be resolved: ${error.message}`;
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// The raising wrapper. Every command that trusts AGENTS_ROOT runs this, so a
|
|
161
|
+
// read command and a mutating command can never disagree about the same root.
|
|
162
|
+
export function checkAgentsRoot(json = jsonMode()) {
|
|
163
|
+
const { root, exists, problem } = auditAgentsRoot();
|
|
164
|
+
if (problem) unsafeAgentsRoot(problem, root, json);
|
|
165
|
+
return { root, exists };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// The default root creates its own parent, because on a first run there is none
|
|
169
|
+
// and nothing else will make it. A named root does not: its parent was proved
|
|
170
|
+
// to exist by the audit above, so one level is all this ever has to create.
|
|
171
|
+
export function validateAgentsRoot(json = jsonMode()) {
|
|
172
|
+
const { root, exists } = checkAgentsRoot(json);
|
|
173
|
+
if (!exists) mkdirSync(root, { recursive: AGENTS_ROOT_IS_DEFAULT });
|
|
174
|
+
return root;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function unsafeAgentsRoot(message, root, json) {
|
|
178
|
+
if (json) contractError(activeCommandName(), 'unsafe_agent_path', message, { agentsRoot: root });
|
|
179
|
+
console.error(`Error: ${message}`);
|
|
180
|
+
process.exit(1);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function printJson(value, stream = process.stdout) {
|
|
184
|
+
stream.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Response contract version. Independent of package semver; frozen shape. */
|
|
188
|
+
export const CONTRACT_VERSION = 1;
|
|
189
|
+
|
|
190
|
+
// Stable error codes. The published set is frozen at contract_version 1:
|
|
191
|
+
// agent_not_found, unsafe_agent_path, not_academy_owned, invalid_name,
|
|
192
|
+
// agent_exists, agent_archived, replay_unavailable, log_corrupt,
|
|
193
|
+
// invalid_runtime, invalid_spec, runtime_unavailable, lock_timeout,
|
|
194
|
+
// internal_error, unschedule_failed, unschedule_failed_restore_blocked.
|
|
195
|
+
// Fifteen codes. internal_error was added by ruling during phase 1 as the
|
|
196
|
+
// floor under the envelope; the two unschedule_* codes predate the contract
|
|
197
|
+
// and were published by ruling rather than retired, because delete already
|
|
198
|
+
// raises them and a client must be able to tell the two apart.
|
|
199
|
+
// Raised today:
|
|
200
|
+
// agent_not_found inspect | tokens | budget | delete on a missing agent
|
|
201
|
+
// invalid_name any agent-addressed command with a non-kebab-case name
|
|
202
|
+
// unsafe_agent_path an unusable AGENTS_ROOT, or an agent dir resolving outside it
|
|
203
|
+
// not_academy_owned a directory in AGENTS_ROOT without valid ownership metadata
|
|
204
|
+
// agent_exists create on a name that already has a directory
|
|
205
|
+
// log_corrupt a lifecycle append onto an event log with no parseable record
|
|
206
|
+
// invalid_runtime an agent.yaml runtime scalar outside the canonical set
|
|
207
|
+
// invalid_spec an agent.yaml naming a key in a form the writer cannot
|
|
208
|
+
// rewrite, and an events invocation Academy cannot read
|
|
209
|
+
// runtime_unavailable an executable Academy needs is missing or failed
|
|
210
|
+
// lock_timeout a lifecycle lock could not be taken inside the timeout
|
|
211
|
+
// internal_error the floor: a throw no command anticipated, under --json
|
|
212
|
+
// `doctor`'s `errors[]` is a different channel: it names a degraded thing and
|
|
213
|
+
// how many of it there are, never a command failure. None of the fifteen codes
|
|
214
|
+
// appears there, and no code from there appears in an `error` object.
|
|
215
|
+
|
|
216
|
+
// The command name and --json flag are set once at dispatch so validators deep
|
|
217
|
+
// in the call stack (validateName, validateAgentsRoot) can emit the envelope
|
|
218
|
+
// without every caller plumbing both values through.
|
|
219
|
+
let activeCommand = { name: 'academy', json: false };
|
|
220
|
+
|
|
221
|
+
export function setActiveCommand(name, json = false) {
|
|
222
|
+
activeCommand = { name, json: Boolean(json) };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function activeCommandName() {
|
|
226
|
+
return activeCommand.name;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function jsonMode() {
|
|
230
|
+
return activeCommand.json;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function contractOk(command, payload) {
|
|
234
|
+
printJson({ contract_version: CONTRACT_VERSION, ok: true, command, ...payload });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function contractError(command, code, message, fields = {}) {
|
|
238
|
+
printJson(
|
|
239
|
+
{ contract_version: CONTRACT_VERSION, ok: false, command, error: { code, message, ...fields } },
|
|
240
|
+
process.stderr,
|
|
241
|
+
);
|
|
242
|
+
process.exit(1);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function exitJsonError(code, message, fields = {}) {
|
|
246
|
+
contractError(activeCommandName(), code, message, fields);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// The memory bridge is Academy's one client-specific surface, and it is opt-in.
|
|
250
|
+
// Unset, a scaffolded agent names no client at all.
|
|
251
|
+
export function memoryBridgeEnabled() {
|
|
252
|
+
return process.env.ACADEMY_MEMORY_BRIDGE === '1';
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function isSymlink(path) {
|
|
256
|
+
try {
|
|
257
|
+
return lstatSync(path).isSymbolicLink();
|
|
258
|
+
} catch {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Raised when a lifecycle lock cannot be taken inside the timeout. Thrown, not
|
|
264
|
+
// exited, so the command that wanted the lock can answer in the envelope.
|
|
265
|
+
export class LockTimeoutError extends Error {
|
|
266
|
+
constructor(lockDir) {
|
|
267
|
+
super(`Timed out acquiring lock: ${lockDir}`);
|
|
268
|
+
this.code = 'lock_timeout';
|
|
269
|
+
this.fields = { lockDir };
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function sleep(ms) {
|
|
274
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function withFileLock(lockDir, fn) {
|
|
278
|
+
const started = Date.now();
|
|
279
|
+
while (true) {
|
|
280
|
+
try {
|
|
281
|
+
mkdirSync(lockDir, { recursive: false });
|
|
282
|
+
break;
|
|
283
|
+
} catch {
|
|
284
|
+
if (Date.now() - started > 5000) throw new LockTimeoutError(lockDir);
|
|
285
|
+
sleep(25);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
try {
|
|
290
|
+
return fn();
|
|
291
|
+
} finally {
|
|
292
|
+
rmSync(lockDir, { recursive: true, force: true });
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function ensureSymlink(target, linkPath, type = 'dir') {
|
|
297
|
+
const resolvedTarget = resolve(target);
|
|
298
|
+
if (isSymlink(linkPath)) {
|
|
299
|
+
const existing = readlinkSync(linkPath);
|
|
300
|
+
const resolvedExisting = resolve(dirname(linkPath), existing);
|
|
301
|
+
if (resolvedExisting === resolvedTarget || resolve(existing) === resolvedTarget) return;
|
|
302
|
+
unlinkSync(linkPath);
|
|
303
|
+
} else if (existsSync(linkPath)) {
|
|
304
|
+
rmSync(linkPath, { recursive: true, force: true });
|
|
305
|
+
}
|
|
306
|
+
mkdirSync(dirname(linkPath), { recursive: true });
|
|
307
|
+
symlinkSync(resolvedTarget, linkPath, type);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function ensureAcademyGitignore(projectDir) {
|
|
311
|
+
const academyDir = join(projectDir, '.academy');
|
|
312
|
+
mkdirSync(academyDir, { recursive: true });
|
|
313
|
+
const gitignorePath = join(academyDir, '.gitignore');
|
|
314
|
+
if (!existsSync(gitignorePath)) writeFileSync(gitignorePath, '*\n');
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function projectPluginDir(projectDir, name) {
|
|
318
|
+
return join(resolve(projectDir), '.academy', 'agents', name);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function legacyAcademyRoot(dir) {
|
|
322
|
+
const rootFile = join(dir, '.academy_root');
|
|
323
|
+
if (!existsSync(rootFile)) return null;
|
|
324
|
+
const root = readFileSync(rootFile, 'utf8').trim();
|
|
325
|
+
if (!root || resolve(root) === ACADEMY_ROOT) return null;
|
|
326
|
+
const legacyCli = join(root, 'scripts', 'agent.mjs');
|
|
327
|
+
return existsSync(legacyCli) ? root : null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function delegateLegacyRun(name, passthrough, legacyRoot) {
|
|
331
|
+
const args = ['run', name, ...(passthrough.length > 0 ? ['--', ...passthrough] : [])];
|
|
332
|
+
const result = spawnSync(process.execPath, [join(legacyRoot, 'scripts', 'agent.mjs'), ...args], {
|
|
333
|
+
stdio: 'inherit',
|
|
334
|
+
cwd: process.cwd(),
|
|
335
|
+
env: process.env,
|
|
336
|
+
});
|
|
337
|
+
process.exit(result.status ?? 0);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function isInside(childPath, parentPath) {
|
|
341
|
+
let child = resolve(childPath);
|
|
342
|
+
let parent = resolve(parentPath);
|
|
343
|
+
try {
|
|
344
|
+
child = realpathSync(child);
|
|
345
|
+
} catch {
|
|
346
|
+
/* use resolved path */
|
|
347
|
+
}
|
|
348
|
+
try {
|
|
349
|
+
parent = realpathSync(parent);
|
|
350
|
+
} catch {
|
|
351
|
+
/* use resolved path */
|
|
352
|
+
}
|
|
353
|
+
return child === parent || child.startsWith(parent + '/');
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export function shellSingleQuote(value) {
|
|
357
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Raised when an external binary Academy needs is not installed. Thrown rather
|
|
361
|
+
// than exited so the caller can roll its work back, release its lock, and
|
|
362
|
+
// answer in the envelope.
|
|
363
|
+
export class RuntimeUnavailableError extends Error {
|
|
364
|
+
constructor(message, fields = {}) {
|
|
365
|
+
super(message);
|
|
366
|
+
this.code = 'runtime_unavailable';
|
|
367
|
+
this.fields = fields;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function resolveExecutable(envName, fallback, { throwOnMissing = false } = {}) {
|
|
372
|
+
const configured = process.env[envName];
|
|
373
|
+
if (configured) return configured;
|
|
374
|
+
if (fallback.includes('/')) return fallback;
|
|
375
|
+
|
|
376
|
+
for (const dir of (process.env.PATH || '').split(':').filter(Boolean)) {
|
|
377
|
+
const candidate = join(dir, fallback);
|
|
378
|
+
if (existsSync(candidate)) return candidate;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const message = `Failed to resolve executable "${fallback}" from PATH for ${envName}.`;
|
|
382
|
+
if (throwOnMissing) throw new RuntimeUnavailableError(message, { executable: fallback });
|
|
383
|
+
console.error(`[ACADEMY_RUNTIME] ${message}`);
|
|
384
|
+
process.exit(1);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
388
|
+
// `create` — scaffold the 8 boot files + universal skills + plugin symlink
|
|
389
|
+
// ─────────────────────────────────────────────────────────────────────────────
|