@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.
Files changed (53) hide show
  1. package/.claude-plugin/marketplace.json +14 -0
  2. package/.claude-plugin/plugin.json +6 -0
  3. package/CHANGELOG.md +46 -0
  4. package/LICENSE +21 -0
  5. package/README.md +209 -0
  6. package/bin/academy +2 -0
  7. package/conformance/README.md +60 -0
  8. package/conformance/discovery.test.mjs +140 -0
  9. package/conformance/envelope.test.mjs +185 -0
  10. package/conformance/error-codes.test.mjs +125 -0
  11. package/conformance/harness.mjs +180 -0
  12. package/conformance/identity.test.mjs +125 -0
  13. package/docs/integration-guide.md +1026 -0
  14. package/hooks/hook_runtime.mjs +100 -0
  15. package/hooks/hooks.json +26 -0
  16. package/hooks/inject_surface.py +122 -0
  17. package/hooks/memory_bridge.mjs +120 -0
  18. package/hooks/memory_store.mjs +66 -0
  19. package/hooks/register_session.mjs +51 -0
  20. package/hooks/sync_memory.mjs +27 -0
  21. package/package.json +41 -0
  22. package/scripts/agent.mjs +3 -0
  23. package/scripts/cli/archive.mjs +161 -0
  24. package/scripts/cli/archived.mjs +82 -0
  25. package/scripts/cli/args.mjs +282 -0
  26. package/scripts/cli/codex.mjs +216 -0
  27. package/scripts/cli/core.mjs +389 -0
  28. package/scripts/cli/create.mjs +242 -0
  29. package/scripts/cli/doctor.mjs +203 -0
  30. package/scripts/cli/eventlog.mjs +129 -0
  31. package/scripts/cli/events.mjs +80 -0
  32. package/scripts/cli/hire-headless.mjs +229 -0
  33. package/scripts/cli/hire-spec.mjs +164 -0
  34. package/scripts/cli/hire.mjs +92 -0
  35. package/scripts/cli/inspect.mjs +286 -0
  36. package/scripts/cli/lifecycle.mjs +296 -0
  37. package/scripts/cli/main.mjs +102 -0
  38. package/scripts/cli/migrate.mjs +183 -0
  39. package/scripts/cli/notes.mjs +104 -0
  40. package/scripts/cli/rename.mjs +172 -0
  41. package/scripts/cli/run.mjs +227 -0
  42. package/scripts/cli/runtime.mjs +47 -0
  43. package/scripts/cli/scaffold.mjs +332 -0
  44. package/scripts/cli/sessions.mjs +98 -0
  45. package/scripts/cli/templates.mjs +104 -0
  46. package/scripts/cli/yaml.mjs +124 -0
  47. package/skills/hire/SKILL.md +669 -0
  48. package/templates/agents/claude-code/knowledge-curator.md +14 -0
  49. package/templates/agents/codex/knowledge-curator.toml +9 -0
  50. package/templates/skills/check-in/SKILL.md +122 -0
  51. package/templates/skills/knowledge-curation/SKILL.md +132 -0
  52. package/templates/skills/nightly-consolidation/SKILL.md +240 -0
  53. 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
+ // ─────────────────────────────────────────────────────────────────────────────