@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,100 @@
1
+ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ export function readPayload() {
6
+ try {
7
+ const input = readFileSync(0, 'utf8').trim();
8
+ return input ? JSON.parse(input) : null;
9
+ } catch {
10
+ return null;
11
+ }
12
+ }
13
+
14
+ function sameRealPath(left, right) {
15
+ try {
16
+ return realpathSync(left) === realpathSync(right);
17
+ } catch {
18
+ return left === right;
19
+ }
20
+ }
21
+
22
+ export function runtimeEnvFor(payload, env, identityKey) {
23
+ let resolvedEnv = env;
24
+ if (!env[identityKey] && env.ACADEMY_RUNTIME_CONTEXT) {
25
+ try {
26
+ const context = JSON.parse(readFileSync(env.ACADEMY_RUNTIME_CONTEXT, 'utf8'));
27
+ const expired = context.expiresAt && Date.parse(context.expiresAt) < Date.now();
28
+ const merged = expired ? env : { ...env, ...(context.env || {}) };
29
+ const projectMismatch =
30
+ payload?.cwd &&
31
+ merged.ACADEMY_PROJECT_DIR &&
32
+ !sameRealPath(payload.cwd, merged.ACADEMY_PROJECT_DIR);
33
+ if (!projectMismatch) resolvedEnv = merged;
34
+ } catch {
35
+ // Use the provided environment when the runtime context is not valid.
36
+ }
37
+ }
38
+ return resolvedEnv;
39
+ }
40
+
41
+ // A session record names the agent directory, not just the agent name: two
42
+ // roots can hold an agent of the same name, and only the resolved directory
43
+ // says which one a session belongs to. An unresolvable path still answers
44
+ // absolutely, because a record Academy cannot attribute is worse than one
45
+ // naming a directory that has since moved.
46
+ export function resolvedAgentDir(dir) {
47
+ if (!dir) return null;
48
+ const absolute = resolve(dir);
49
+ try {
50
+ return realpathSync(absolute);
51
+ } catch {
52
+ return absolute;
53
+ }
54
+ }
55
+
56
+ function sleep(ms) {
57
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
58
+ }
59
+
60
+ export function withFileLock(lockDir, fn) {
61
+ const started = Date.now();
62
+ while (true) {
63
+ try {
64
+ mkdirSync(lockDir, { recursive: false });
65
+ break;
66
+ } catch {
67
+ if (Date.now() - started > 5000) return fn();
68
+ sleep(25);
69
+ }
70
+ }
71
+
72
+ try {
73
+ return fn();
74
+ } finally {
75
+ rmSync(lockDir, { recursive: true, force: true });
76
+ }
77
+ }
78
+
79
+ export function jsonlHasValue(path, field, value) {
80
+ if (!existsSync(path)) return false;
81
+ for (const line of readFileSync(path, 'utf8').split('\n')) {
82
+ if (!line.trim()) continue;
83
+ try {
84
+ if (JSON.parse(line)[field] === value) return true;
85
+ } catch {
86
+ // Ignore malformed JSONL rows.
87
+ }
88
+ }
89
+ return false;
90
+ }
91
+
92
+ export function isMainModule(moduleUrl) {
93
+ if (!process.argv[1]) return false;
94
+ const modulePath = fileURLToPath(moduleUrl);
95
+ try {
96
+ return realpathSync(modulePath) === realpathSync(process.argv[1]);
97
+ } catch {
98
+ return modulePath === resolve(process.argv[1]);
99
+ }
100
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "hooks": {
3
+ "SessionStart": [
4
+ {
5
+ "matcher": "*",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/register_session.mjs"
10
+ }
11
+ ]
12
+ }
13
+ ],
14
+ "Stop": [
15
+ {
16
+ "matcher": "*",
17
+ "hooks": [
18
+ {
19
+ "type": "command",
20
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/sync_memory.mjs"
21
+ }
22
+ ]
23
+ }
24
+ ]
25
+ }
26
+ }
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env python3
2
+ """SessionStart hook — emit one boot surface per invocation.
3
+
4
+ Usage: inject_surface.py <surface>
5
+
6
+ Surfaces: identity | role | knowledge | goals | priorities | threads | notes | dailys
7
+
8
+ Each surface lives at ~/.academy/agents/<name>/<surface>.md and is injected
9
+ into the session as additionalContext via stdout JSON.
10
+
11
+ Agent dir resolution (in priority order):
12
+ 1. ACADEMY_AGENT_DIR env var
13
+ 2. CWD if it contains agent.yaml
14
+ 3. CWD walk-up looking for agent.yaml
15
+
16
+ Per v3 §10 Phase 0: "8 simple hooks, one per surface" — no manifest, no
17
+ chunking, no inject_section heuristics. The 8 boot files target ~5–6k tokens
18
+ combined and stay well under the per-hook ~10k-char ceiling.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import os
25
+ import sys
26
+ from pathlib import Path
27
+
28
+ SURFACES = (
29
+ "identity",
30
+ "role",
31
+ "knowledge",
32
+ "goals",
33
+ "priorities",
34
+ "threads",
35
+ "notes",
36
+ "dailys",
37
+ )
38
+ USAGE_ERROR = 2
39
+ EXPECTED_ARG_COUNT = 2
40
+
41
+
42
+ def resolve_agent_dir() -> Path | None:
43
+ """Resolve the agent directory.
44
+
45
+ Priority:
46
+ 1. ACADEMY_AGENT_DIR env var (set by `academy run`)
47
+ 2. Walk up from CWD looking for agent.yaml
48
+ """
49
+ env_dir = os.environ.get("ACADEMY_AGENT_DIR")
50
+ if env_dir:
51
+ p = Path(env_dir)
52
+ if p.is_dir():
53
+ return p
54
+
55
+ cwd = Path.cwd()
56
+ for d in (cwd, *cwd.parents):
57
+ if (d / "agent.yaml").is_file():
58
+ return d
59
+ return None
60
+
61
+
62
+ def build_context(surface: str, agent_dir: Path) -> str:
63
+ """Read <surface>.md and return its content as-is.
64
+
65
+ The file is self-describing (each starts with its own H1). The hook's job
66
+ is to deliver the file into context — not to add framing. Empty/missing
67
+ files emit a one-line placeholder so the agent knows the surface exists
68
+ but is empty.
69
+ """
70
+ surface_file = agent_dir / f"{surface}.md"
71
+ if not surface_file.is_file():
72
+ return f"# {surface.title()}\n\n_(No {surface}.md present yet.)_"
73
+ body = surface_file.read_text(encoding="utf-8").rstrip()
74
+ if not body:
75
+ return f"# {surface.title()}\n\n_({surface}.md is empty.)_"
76
+ return body
77
+
78
+
79
+ def emit(context: str) -> None:
80
+ """Emit Claude Code SessionStart hook JSON output."""
81
+ payload = {
82
+ "hookSpecificOutput": {
83
+ "hookEventName": "SessionStart",
84
+ "additionalContext": context,
85
+ }
86
+ }
87
+ sys.stdout.write(json.dumps(payload))
88
+ sys.stdout.flush()
89
+
90
+
91
+ def main() -> int:
92
+ if len(sys.argv) != EXPECTED_ARG_COUNT:
93
+ print(f"usage: inject_surface.py <{'|'.join(SURFACES)}>", file=sys.stderr)
94
+ return USAGE_ERROR
95
+
96
+ surface = sys.argv[1]
97
+ if surface not in SURFACES:
98
+ print(
99
+ f"unknown surface '{surface}'. valid: {', '.join(SURFACES)}",
100
+ file=sys.stderr,
101
+ )
102
+ return USAGE_ERROR
103
+
104
+ agent_dir = resolve_agent_dir()
105
+ if agent_dir is None:
106
+ # Not running inside an agent — emit nothing, exit clean.
107
+ # This keeps the hook harmless when the user invokes Claude Code
108
+ # in a non-agent dir while the plugin is still installed.
109
+ return 0
110
+
111
+ try:
112
+ context = build_context(surface, agent_dir)
113
+ except Exception as exc: # pragma: no cover — defensive
114
+ print(f"inject_surface[{surface}] error: {exc}", file=sys.stderr)
115
+ return 0 # never block session start
116
+
117
+ emit(context)
118
+ return 0
119
+
120
+
121
+ if __name__ == "__main__":
122
+ raise SystemExit(main())
@@ -0,0 +1,120 @@
1
+ import { appendFileSync, existsSync, readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ /**
6
+ * Academy names no specific client except in this module. The memory sync
7
+ * bridge stays client-specific by explicit decision, so every client-named
8
+ * identifier Academy needs — env keys, tool permission, skill guidance, and the
9
+ * observation copy itself — lives here. Client-neutral layers import these
10
+ * declarations and inject them only behind ACADEMY_MEMORY_BRIDGE.
11
+ */
12
+ export const MEMORY_BRIDGE_ENV = {
13
+ enabled: 'GROVE_MEMORY_ENABLED',
14
+ project: 'GROVE_PROJECT_NAME',
15
+ workspace: 'GROVE_WORKSPACE_NAME',
16
+ home: 'SUBSPACE_HOME',
17
+ };
18
+
19
+ /** Env keys a sandboxed runtime must forward for the bridge to work. */
20
+ export const MEMORY_BRIDGE_ENV_KEYS = Object.values(MEMORY_BRIDGE_ENV);
21
+
22
+ /** Tool permission an agent needs to query the bridge directly. */
23
+ export const MEMORY_BRIDGE_PERMISSIONS = ['Bash(subspace-memory:*)'];
24
+
25
+ /** Optional nightly guidance — the only client-named prose Academy ships. */
26
+ export const MEMORY_BRIDGE_SKILL_GUIDANCE = `### Optional — Subspace memory bridge
27
+
28
+ Academy names no specific client. This bridge is the one exception, and it is
29
+ optional. Skip this section when \`subspace-memory\` is not installed.
30
+
31
+ - Run \`subspace-memory status\` if the archive is thin or appears inconsistent.
32
+ - Run \`subspace-memory timeline --days 2\` if more workspace context is needed.
33
+
34
+ Subspace memory may enrich or corroborate an eligible run. It can never
35
+ establish eligibility.`;
36
+
37
+ function formatDate(date) {
38
+ return date.toISOString().slice(0, 10);
39
+ }
40
+
41
+ function syncDates() {
42
+ const today = new Date();
43
+ const yesterday = new Date(today);
44
+ yesterday.setDate(today.getDate() - 1);
45
+ return [formatDate(today), formatDate(yesterday)];
46
+ }
47
+
48
+ function memoryKey(entry) {
49
+ return `${entry.sessionId || ''}|${entry.timestamp || ''}|${entry.turnNumber || ''}`;
50
+ }
51
+
52
+ function readExistingKeys(path) {
53
+ const keys = new Set();
54
+ if (!existsSync(path)) return keys;
55
+ for (const line of readFileSync(path, 'utf8').split('\n')) {
56
+ const trimmed = line.trim();
57
+ if (!trimmed) continue;
58
+ try {
59
+ keys.add(memoryKey(JSON.parse(trimmed)));
60
+ } catch {
61
+ // Ignore malformed archive lines.
62
+ }
63
+ }
64
+ return keys;
65
+ }
66
+
67
+ function subspaceObservationsDir(env) {
68
+ const project = env[MEMORY_BRIDGE_ENV.project];
69
+ const workspace = env[MEMORY_BRIDGE_ENV.workspace];
70
+ if (!project || !workspace) return null;
71
+ const roots = [env[MEMORY_BRIDGE_ENV.home], join(env.HOME || homedir(), '.subspace')].filter(
72
+ Boolean,
73
+ );
74
+ for (const root of roots) {
75
+ const dir = join(root, project, workspace, 'memory', 'observations');
76
+ if (existsSync(dir)) return dir;
77
+ }
78
+ return null;
79
+ }
80
+
81
+ function memorySyncEnabled(env) {
82
+ const enabled = env[MEMORY_BRIDGE_ENV.enabled];
83
+ return enabled === '1' || enabled === 'true';
84
+ }
85
+
86
+ function syncObservationFile(subspaceFile, agentFile, sessionId) {
87
+ const existingKeys = readExistingKeys(agentFile);
88
+ let synced = 0;
89
+ for (const line of readFileSync(subspaceFile, 'utf8').split('\n')) {
90
+ const trimmed = line.trim();
91
+ if (!trimmed) continue;
92
+ try {
93
+ const entry = JSON.parse(trimmed);
94
+ const key = memoryKey(entry);
95
+ if (entry.sessionId !== sessionId || existingKeys.has(key)) continue;
96
+ appendFileSync(agentFile, `${trimmed}\n`);
97
+ existingKeys.add(key);
98
+ synced++;
99
+ } catch {
100
+ // Ignore malformed Subspace observation lines.
101
+ }
102
+ }
103
+ return synced;
104
+ }
105
+
106
+ /**
107
+ * Copy the client's observations for this session into the agent's own archive.
108
+ * The client-neutral hook calls this and learns only a count.
109
+ */
110
+ export function syncBridgeObservations(env, observationsDir, sessionId) {
111
+ const subspaceDir = subspaceObservationsDir(env);
112
+ if (!subspaceDir || !memorySyncEnabled(env)) return 0;
113
+ let synced = 0;
114
+ for (const date of syncDates()) {
115
+ const subspaceFile = join(subspaceDir, `${date}.jsonl`);
116
+ if (!existsSync(subspaceFile)) continue;
117
+ synced += syncObservationFile(subspaceFile, join(observationsDir, `${date}.jsonl`), sessionId);
118
+ }
119
+ return synced;
120
+ }
@@ -0,0 +1,66 @@
1
+ import {
2
+ appendFileSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ renameSync,
7
+ writeFileSync,
8
+ } from 'node:fs';
9
+ import { join, resolve } from 'node:path';
10
+ import { jsonlHasValue, resolvedAgentDir, withFileLock } from './hook_runtime.mjs';
11
+
12
+ // Academy's own per-agent memory state. Client-neutral: nothing here knows
13
+ // which client, if any, feeds the observation archive.
14
+
15
+ export function pendingMarkerPath(agentDir) {
16
+ return join(resolve(agentDir), 'memory', 'pending-consolidation.json');
17
+ }
18
+
19
+ export function markPendingConsolidation(memoryDir) {
20
+ const markerPath = pendingMarkerPath(resolve(memoryDir, '..'));
21
+ withFileLock(join(memoryDir, 'pending-consolidation.lock'), () => {
22
+ let revision = 0;
23
+ if (existsSync(markerPath)) {
24
+ try {
25
+ revision = Number(JSON.parse(readFileSync(markerPath, 'utf8')).revision) || 0;
26
+ } catch {
27
+ // Replace malformed private state with the next valid revision.
28
+ }
29
+ }
30
+ const tempPath = join(memoryDir, `.pending-consolidation.${process.pid}.${Date.now()}.tmp`);
31
+ writeFileSync(
32
+ tempPath,
33
+ JSON.stringify(
34
+ {
35
+ revision: revision + 1,
36
+ updatedAt: new Date().toISOString(),
37
+ },
38
+ null,
39
+ 2,
40
+ ) + '\n',
41
+ );
42
+ renameSync(tempPath, markerPath);
43
+ });
44
+ }
45
+
46
+ export function recordSession(memoryDir, payload, env) {
47
+ const sessionId = payload.session_id;
48
+ if (!sessionId) return;
49
+ const sessionsPath = join(memoryDir, 'sessions.jsonl');
50
+ mkdirSync(memoryDir, { recursive: true });
51
+ withFileLock(join(memoryDir, 'sessions.lock'), () => {
52
+ if (jsonlHasValue(sessionsPath, 'sessionId', sessionId)) return;
53
+ appendFileSync(
54
+ sessionsPath,
55
+ JSON.stringify({
56
+ sessionId,
57
+ agentName: env.ACADEMY_AGENT_NAME || null,
58
+ agentDir: resolvedAgentDir(join(memoryDir, '..')),
59
+ timestamp: new Date().toISOString(),
60
+ cwd: payload.cwd || null,
61
+ projectDir: env.ACADEMY_PROJECT_DIR || null,
62
+ source: 'stop',
63
+ }) + '\n',
64
+ );
65
+ });
66
+ }
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env node
2
+ import { appendFileSync, mkdirSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import {
6
+ isMainModule,
7
+ jsonlHasValue,
8
+ readPayload,
9
+ resolvedAgentDir,
10
+ runtimeEnvFor,
11
+ withFileLock,
12
+ } from './hook_runtime.mjs';
13
+
14
+ function appendSession(sessionsPath, session) {
15
+ if (jsonlHasValue(sessionsPath, 'sessionId', session.sessionId)) return true;
16
+ appendFileSync(sessionsPath, `${JSON.stringify(session)}\n`);
17
+ return true;
18
+ }
19
+
20
+ export function registerAcademySession(payload, env = process.env) {
21
+ env = runtimeEnvFor(payload, env, 'ACADEMY_AGENT_NAME');
22
+ const sessionId = payload?.session_id;
23
+ const agentName = env.ACADEMY_AGENT_NAME;
24
+ const cwd = payload?.cwd;
25
+ if (!sessionId || !agentName || !cwd) return false;
26
+ // Absent when the session did not come from `academy run`. Such a record is
27
+ // unattributable rather than corrupt, so it is written without the field and
28
+ // counted by `doctor`.
29
+ const agentDir = resolvedAgentDir(env.ACADEMY_AGENT_DIR);
30
+
31
+ try {
32
+ const academyDir = join(env.HOME || homedir(), '.academy');
33
+ const sessionsPath = join(academyDir, 'sessions.jsonl');
34
+ mkdirSync(academyDir, { recursive: true });
35
+ return withFileLock(join(academyDir, 'sessions.lock'), () =>
36
+ appendSession(sessionsPath, {
37
+ sessionId,
38
+ agentName,
39
+ ...(agentDir ? { agentDir } : {}),
40
+ cwd,
41
+ startedAt: new Date().toISOString(),
42
+ }),
43
+ );
44
+ } catch {
45
+ return false;
46
+ }
47
+ }
48
+
49
+ if (isMainModule(import.meta.url)) {
50
+ registerAcademySession(readPayload());
51
+ }
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ import { mkdirSync } from 'node:fs';
3
+ import { join, resolve } from 'node:path';
4
+ import { isMainModule, readPayload, runtimeEnvFor } from './hook_runtime.mjs';
5
+ import { syncBridgeObservations } from './memory_bridge.mjs';
6
+ import { markPendingConsolidation, recordSession } from './memory_store.mjs';
7
+
8
+ export function syncAcademyMemory(payload, env = process.env) {
9
+ env = runtimeEnvFor(payload, env, 'ACADEMY_AGENT_DIR');
10
+ const agentDir = env.ACADEMY_AGENT_DIR;
11
+ const sessionId = payload?.session_id;
12
+ if (!agentDir || !sessionId) return { synced: 0 };
13
+
14
+ const memoryDir = join(resolve(agentDir), 'memory');
15
+ const observationsDir = join(memoryDir, 'observations');
16
+ mkdirSync(observationsDir, { recursive: true });
17
+ recordSession(memoryDir, payload, env);
18
+ // Eligibility tracks Academy session activity, not the bridge copy, so an
19
+ // agent still consolidates when the memory bridge is unavailable.
20
+ if (env.ACADEMY_NIGHTLY_RUN !== '1') markPendingConsolidation(memoryDir);
21
+
22
+ return { synced: syncBridgeObservations(env, observationsDir, sessionId) };
23
+ }
24
+
25
+ if (isMainModule(import.meta.url)) {
26
+ syncAcademyMemory(readPayload());
27
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@joenandez/academy",
3
+ "version": "0.4.0-rc.1",
4
+ "description": "Academy v3 — portable AI agents with 8 boot surfaces and a skills primitive.",
5
+ "type": "module",
6
+ "bin": {
7
+ "academy": "./bin/academy"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/joenandez/academy.git"
12
+ },
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "files": [
17
+ "bin/",
18
+ "scripts/agent.mjs",
19
+ "scripts/cli/",
20
+ "skills/",
21
+ "templates/",
22
+ "hooks/",
23
+ ".claude-plugin/",
24
+ "conformance/",
25
+ "docs/integration-guide.md",
26
+ "CHANGELOG.md"
27
+ ],
28
+ "scripts": {
29
+ "test": "node --test tests/*.test.mjs",
30
+ "quality:setup": "node quality/run.mjs setup",
31
+ "quality:commit": "node quality/run.mjs commit",
32
+ "quality:push": "node quality/run.mjs push",
33
+ "quality:full": "node quality/run.mjs full"
34
+ },
35
+ "keywords": ["academy", "ai-agent", "claude-code", "portable-agent"],
36
+ "author": "Joe Fernandez",
37
+ "license": "MIT",
38
+ "devDependencies": {
39
+ "knip": "5.70.2"
40
+ }
41
+ }
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ /** Academy v3 CLI entry point. */
3
+ import './cli/main.mjs';