@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,161 @@
1
+ import { existsSync, mkdirSync, renameSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
3
+ import {
4
+ AGENTS_ROOT,
5
+ agentDir,
6
+ agentLifecycleLockPath,
7
+ contractOk,
8
+ isSymlink,
9
+ validateAgentsRoot,
10
+ validateName,
11
+ withFileLock,
12
+ } from './core.mjs';
13
+ import { ARCHIVED_DIR, archivedAgentDir, holdingAreaOrNull } from './archived.mjs';
14
+ import { registerNightlyConsolidationTask } from './create.mjs';
15
+ import { appendLifecycleEvent } from './eventlog.mjs';
16
+ import {
17
+ DeleteAgentError,
18
+ SLOT,
19
+ assertOwnedAgentForDelete,
20
+ deleteNightlyConsolidation,
21
+ preflightOwnedAgent,
22
+ reportLifecycleFailure,
23
+ validateOwnedAgentDir,
24
+ } from './lifecycle.mjs';
25
+
26
+ // `archive` / `unarchive`. Archived agents live at AGENTS_ROOT/.archived/<name>,
27
+ // which is not the canonical slot, so they are validated against the holding
28
+ // area instead — see SLOT in lifecycle.mjs, where that relaxation is bounded.
29
+ // Neither command destroys anything, so neither quarantines: the move itself is
30
+ // the whole operation and it is reversible by the other command.
31
+
32
+ export function archiveAgent(name, json) {
33
+ preflightOwnedAgent(name, json);
34
+ runLocked(name, json, 'archive', () => archiveAgentLocked(name, json));
35
+ }
36
+
37
+ export function unarchiveAgent(name, json) {
38
+ validateName(name);
39
+ validateAgentsRoot();
40
+ validateOwnedAgentDir(name, archivedAgentDir(name), json, SLOT.archived);
41
+ runLocked(name, json, 'unarchive', () => unarchiveAgentLocked(name, json));
42
+ }
43
+
44
+ // Both commands take the canonical lifecycle lock, the same one `delete` and
45
+ // `rename` take, so an archive can never interleave with either.
46
+ function runLocked(name, json, verb, work) {
47
+ try {
48
+ withFileLock(agentLifecycleLockPath(agentDir(name)), work);
49
+ } catch (error) {
50
+ reportLifecycleFailure(error, verb, name, json);
51
+ }
52
+ }
53
+
54
+ // The holding area is the only boundary left once the canonical-slot rule is
55
+ // relaxed, so it is proved before anything moves. Proved after the rename
56
+ // instead, a refusal reports failure with the agent already outside the root,
57
+ // where `list` cannot see it and no published command can bring it back. The
58
+ // rule itself is `holdingAreaOrNull`, the same one every read now asks: a write
59
+ // refuses what a read reports empty, and the two can no longer disagree.
60
+ function assertContainedHoldingArea(name) {
61
+ if (holdingAreaOrNull() !== null) return;
62
+ const holding = join(resolve(AGENTS_ROOT), ARCHIVED_DIR);
63
+ throw unsafePath(name, holding, 'is not the real directory AGENTS_ROOT/.archived');
64
+ }
65
+
66
+ // Created only by `archive`, and never through a symlink: `mkdirSync` would
67
+ // follow one and provision a directory outside the root before the check runs.
68
+ function ensureHoldingArea() {
69
+ const holding = join(resolve(AGENTS_ROOT), ARCHIVED_DIR);
70
+ if (!isSymlink(holding) && !existsSync(holding)) mkdirSync(holding, { recursive: true });
71
+ }
72
+
73
+ // A slot that is a symlink is never written through. `refuseOccupiedTarget`
74
+ // asks `existsSync`, which follows one, so a dangling link would otherwise read
75
+ // as a free slot and the rename would silently replace it.
76
+ function assertContainedSlot(name, target) {
77
+ if (isSymlink(target)) throw unsafePath(name, target, 'is a symlink');
78
+ }
79
+
80
+ function unsafePath(name, path, reason) {
81
+ return new DeleteAgentError(
82
+ 'unsafe_agent_path',
83
+ `Agent "${name}" cannot move: ${path} ${reason}`,
84
+ { name, dir: path },
85
+ );
86
+ }
87
+
88
+ function refuseOccupiedTarget(name, target) {
89
+ if (!existsSync(target)) return;
90
+ throw new DeleteAgentError(
91
+ 'agent_exists',
92
+ `Agent "${name}" already occupies ${resolve(target)}`,
93
+ { name, dir: resolve(target) },
94
+ );
95
+ }
96
+
97
+ function archiveAgentLocked(name, json) {
98
+ const { dirReal } = assertOwnedAgentForDelete(name, agentDir(name));
99
+ const previousDir = resolve(agentDir(name));
100
+ ensureHoldingArea();
101
+ assertContainedHoldingArea(name);
102
+ const target = archivedAgentDir(name);
103
+ assertContainedSlot(name, target);
104
+ refuseOccupiedTarget(name, target);
105
+
106
+ // Unscheduled before the move, for the same reason `rename` unschedules
107
+ // first: the job's --cwd names a directory that is about to move, and an
108
+ // archived agent must not keep firing nightly.
109
+ const unscheduled = deleteNightlyConsolidation(name, dirReal);
110
+ if (!unscheduled.ok) {
111
+ throw new DeleteAgentError(
112
+ unscheduled.code ?? 'unschedule_failed',
113
+ `Refusing to archive "${name}" because nightly unschedule failed: ${unscheduled.reason}`,
114
+ { name },
115
+ );
116
+ }
117
+
118
+ renameSync(dirReal, target);
119
+ appendLifecycleEvent('agent_archived', name, resolve(target), { previousDir });
120
+
121
+ if (json) {
122
+ contractOk('archive', {
123
+ archived: true,
124
+ name,
125
+ dir: resolve(target),
126
+ previousDir,
127
+ unscheduledJobId: unscheduled.id,
128
+ });
129
+ return;
130
+ }
131
+ console.log(`Archived agent "${name}" (${previousDir} → ${resolve(target)}).`);
132
+ console.log(`Unregistered nightly consolidation job "${unscheduled.id}".`);
133
+ }
134
+
135
+ function unarchiveAgentLocked(name, json) {
136
+ assertContainedHoldingArea(name);
137
+ const { dirReal } = assertOwnedAgentForDelete(name, archivedAgentDir(name), SLOT.archived);
138
+ const previousDir = resolve(archivedAgentDir(name));
139
+ const target = agentDir(name);
140
+ assertContainedSlot(name, target);
141
+ refuseOccupiedTarget(name, target);
142
+
143
+ renameSync(dirReal, target);
144
+ const nightly = registerNightlyConsolidationTask(target, name);
145
+ appendLifecycleEvent('agent_unarchived', name, resolve(target), { previousDir });
146
+
147
+ const scheduledJobId = nightly.registered ? nightly.id : null;
148
+ if (json) {
149
+ contractOk('unarchive', {
150
+ unarchived: true,
151
+ name,
152
+ dir: resolve(target),
153
+ previousDir,
154
+ scheduledJobId,
155
+ });
156
+ return;
157
+ }
158
+ console.log(`Unarchived agent "${name}" (${previousDir} → ${resolve(target)}).`);
159
+ if (scheduledJobId) console.log(`Registered nightly consolidation job "${scheduledJobId}".`);
160
+ else console.log(`Nightly consolidation job not registered: ${nightly.reason}`);
161
+ }
@@ -0,0 +1,82 @@
1
+ import { lstatSync, readdirSync, realpathSync, statSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
3
+ import { AGENTS_ROOT, NAME_RE, activeCommandName, contractError, jsonMode } from './core.mjs';
4
+
5
+ // The holding area, as one module. Archived agents live in a dot-directory
6
+ // beside their canonical slots, so the roster's NAME_RE filter already keeps
7
+ // the holding area itself out of `list`.
8
+ //
9
+ // Every reader and writer resolves it through `holdingAreaOrNull` below. Stated
10
+ // twice — once for reads with a bare `existsSync`, once for writes with an
11
+ // explicit containment check — the two ends disagreed the moment `.archived`
12
+ // became a symlink: `archive` refused to write through it while `inspect` and
13
+ // `delete` still answered `agent_archived` for a live agent that was never
14
+ // archived at all, with no published way back, and `list` published an
15
+ // out-of-root role string under a `dir` claiming to be inside the root.
16
+
17
+ export const ARCHIVED_DIR = '.archived';
18
+
19
+ // The path an archived agent is *reported* at: joined, never realpath'd, so a
20
+ // client keying agents on `dir` matches this against the record it already has.
21
+ export function archivedAgentDir(name) {
22
+ return join(AGENTS_ROOT, ARCHIVED_DIR, name);
23
+ }
24
+
25
+ // The holding area Academy will act on, or null when there is none it can
26
+ // prove. Equality with the expected real path is stricter than containment on
27
+ // purpose: it also refuses a `.archived` symlinked elsewhere inside the root.
28
+ // A path that is not a directory is refused for the same reason — a regular
29
+ // file resolves to itself and would pass every check up to `renameSync`, which
30
+ // fails only after the nightly job has already been unregistered.
31
+ export function holdingAreaOrNull() {
32
+ try {
33
+ const expected = join(realpathSync(AGENTS_ROOT), ARCHIVED_DIR);
34
+ if (realpathSync(expected) !== expected) return null;
35
+ return statSync(expected).isDirectory() ? expected : null;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ // A root with no provable holding area holds no archived agents. That is the
42
+ // answer a read owes, not a fault: refusing to read must never lock a live
43
+ // agent out of every published command because something was planted beside it.
44
+ export function archivedAgentNames() {
45
+ const holding = holdingAreaOrNull();
46
+ if (holding === null) return [];
47
+ try {
48
+ return readdirSync(holding)
49
+ .filter((entry) => NAME_RE.test(entry) && isRealDirectory(join(holding, entry)))
50
+ .sort();
51
+ } catch {
52
+ return [];
53
+ }
54
+ }
55
+
56
+ export function isArchivedAgent(name) {
57
+ const holding = holdingAreaOrNull();
58
+ return holding !== null && NAME_RE.test(name) && isRealDirectory(join(holding, name));
59
+ }
60
+
61
+ // lstat, not stat: a symlinked slot is not an archived agent, and `unarchive`
62
+ // rejects it as unsafe. Naming it archived would make the two ends disagree.
63
+ function isRealDirectory(path) {
64
+ try {
65
+ return lstatSync(path).isDirectory();
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+
71
+ // An archived agent is answered with its own code, never `agent_not_found`: a
72
+ // client told an archived specialist does not exist would offer to hire a
73
+ // replacement for somebody who is still there. `unarchive` is the one command
74
+ // allowed to address one, so it is the one command that does not ask.
75
+ export function assertNotArchived(name, json = jsonMode()) {
76
+ if (!isArchivedAgent(name)) return;
77
+ const dir = resolve(archivedAgentDir(name));
78
+ const message = `Agent "${name}" is archived at ${dir}. Unarchive it first.`;
79
+ if (json) contractError(activeCommandName(), 'agent_archived', message, { name, dir });
80
+ console.error(`Error: ${message}`);
81
+ process.exit(1);
82
+ }
@@ -0,0 +1,282 @@
1
+ import { CLI_NAME, NAME_RE, RUNTIMES } from './core.mjs';
2
+ import { UNIMPLEMENTED_COMMANDS } from './doctor.mjs';
3
+
4
+ const COMMAND_PARSERS = {
5
+ // Published at contract_version 1 and built by a later phase. Parsed here so
6
+ // dispatch can answer them in the envelope instead of a usage dump.
7
+ ...Object.fromEntries(UNIMPLEMENTED_COMMANDS.map((command) => [command, jsonOnlyCommand])),
8
+ doctor: (command, rest) => jsonOnlyCommand(command, rest),
9
+ create: (command, rest) => namedJsonCommand(command, rest),
10
+ hire: (command, rest) => parseHireArgs(command, rest),
11
+ run: (_command, rest) => parseRunArgs(rest),
12
+ nightly: (_command, rest) => {
13
+ const parsed = parseRunArgs(rest);
14
+ return parsed.command === 'help' ? parsed : { ...parsed, command: 'nightly' };
15
+ },
16
+ events: (command, rest) => parseEventsArgs(command, rest),
17
+ migrate: (command, rest) => parseMigrateArgs(command, rest),
18
+ rename: (command, rest) => parseRenameArgs(command, rest),
19
+ archive: (command, rest) => namedJsonCommand(command, rest),
20
+ unarchive: (command, rest) => namedJsonCommand(command, rest),
21
+ sessions: (command, rest) => parseSessionsArgs(command, rest),
22
+ list: (command, rest) => jsonOnlyCommand(command, rest),
23
+ inspect: (command, rest) => namedJsonCommand(command, rest),
24
+ tokens: (command, rest) => namedJsonCommand(command, rest),
25
+ budget: (command, rest) => namedJsonCommand(command, rest),
26
+ clean: (command, rest) => ({ command, name: rest[0] }),
27
+ delete: (command, rest) => namedJsonCommand(command, rest),
28
+ destroy: (command, rest) => ({ command, name: rest[0], force: rest.includes('--force') }),
29
+ root: (command, rest) => jsonOnlyCommand(command, rest),
30
+ notes: (_command, rest) => parseNotesArgs(rest),
31
+ };
32
+
33
+ export function parseArgs(argv) {
34
+ const [command, ...rest] = argv;
35
+ if (!command || command === '-h' || command === '--help') return { command: 'help' };
36
+ const parser = COMMAND_PARSERS[command];
37
+ if (parser) return parser(command, rest);
38
+ console.error(`Unknown command: ${command}`);
39
+ return { command: 'help', exitCode: 1 };
40
+ }
41
+
42
+ function hasFlag(args, flag) {
43
+ return args.includes(flag);
44
+ }
45
+
46
+ function jsonOnlyCommand(command, rest) {
47
+ return { command, json: hasFlag(rest, '--json') };
48
+ }
49
+
50
+ function namedJsonCommand(command, rest) {
51
+ return { command, name: rest.find((arg) => arg !== '--json'), json: hasFlag(rest, '--json') };
52
+ }
53
+
54
+ // `hire [--spec <path>] [--json] [-- ...]`. `--spec` selects the headless form
55
+ // and nothing else does: without it the interactive launch parses exactly as it
56
+ // always has, including the positional token that seeds the first user message.
57
+ // An option Academy does not know is only a fault in the headless form, because
58
+ // that is the only form that publishes an envelope to answer it in.
59
+ function parseHireArgs(command, rest) {
60
+ const json = hasFlag(rest, '--json');
61
+ const dashIdx = rest.indexOf('--');
62
+ const optionArgs = dashIdx >= 0 ? rest.slice(0, dashIdx) : rest;
63
+ let spec = null;
64
+ let invalidOption = null;
65
+ for (let i = 0; i < optionArgs.length; i++) {
66
+ const arg = optionArgs[i];
67
+ if (arg === '--json') continue;
68
+ else if (arg === '--spec') spec = optionArgs[++i] ?? '';
69
+ else if (arg.startsWith('--spec=')) spec = arg.slice('--spec='.length);
70
+ else if (arg.startsWith('--')) invalidOption ??= arg;
71
+ }
72
+ return { command, json, spec, invalidOption, passthrough: extractPassthrough(rest) };
73
+ }
74
+
75
+ function parseRunArgs(rest) {
76
+ const name = rest[0];
77
+ const afterName = rest.slice(1);
78
+ const dashIdx = afterName.indexOf('--');
79
+ const optionArgs = dashIdx >= 0 ? afterName.slice(0, dashIdx) : afterName;
80
+ const passthrough = dashIdx >= 0 ? afterName.slice(dashIdx + 1) : [];
81
+ // No default here. The agent is not resolved yet, and defaulting before it is
82
+ // what re-registered a codex agent's nightly job as claude-code for ten
83
+ // nights. A null runtime means "not explicit"; `run` reads the persisted
84
+ // scalar instead.
85
+ let runtime = null;
86
+
87
+ for (let i = 0; i < optionArgs.length; i++) {
88
+ const arg = optionArgs[i];
89
+ if (arg === '--agent') {
90
+ const value = optionArgs[++i];
91
+ if (!RUNTIMES.has(value))
92
+ return invalidRunOption(`Invalid --agent value: ${value ?? '(none)'}`);
93
+ runtime = value;
94
+ } else if (arg?.startsWith('--agent=')) {
95
+ const value = arg.slice('--agent='.length);
96
+ if (!RUNTIMES.has(value))
97
+ return invalidRunOption(`Invalid --agent value: ${value || '(none)'}`);
98
+ runtime = value;
99
+ } else if (arg) {
100
+ return invalidRunOption(`Unknown run option: ${arg}`);
101
+ }
102
+ }
103
+
104
+ return { command: 'run', name, runtime, passthrough };
105
+ }
106
+
107
+ function invalidRunOption(message) {
108
+ console.error(`${message}. Use --agent claude-code or --agent codex before --.`);
109
+ return { command: 'help', exitCode: 1 };
110
+ }
111
+
112
+ // `events --since <seq> [--logid <id>] [--json]`. `--since` stays a raw string:
113
+ // only `events` can answer an unreadable watermark in the envelope, and
114
+ // reinterpreting one here would serve the whole log to a client that asked for
115
+ // part of it.
116
+ function parseEventsArgs(command, rest) {
117
+ let since = '0';
118
+ let logId = null;
119
+ for (let i = 0; i < rest.length; i++) {
120
+ const arg = rest[i];
121
+ if (arg === '--json') continue;
122
+ else if (arg === '--since') since = rest[++i] ?? '';
123
+ else if (arg.startsWith('--since=')) since = arg.slice('--since='.length);
124
+ else if (arg === '--logid') logId = rest[++i] ?? '';
125
+ else if (arg.startsWith('--logid=')) logId = arg.slice('--logid='.length);
126
+ // Not a usage dump. The command stays `events` so dispatch keeps the --json
127
+ // flag and the command name, and `events` answers the fault in the envelope
128
+ // it publishes rather than on stdout as human text.
129
+ else return { command, json: hasFlag(rest, '--json'), invalidOption: arg };
130
+ }
131
+ return { command, json: hasFlag(rest, '--json'), since, logId };
132
+ }
133
+
134
+ // `migrate [--dry-run] [--json]`. An option Academy does not know stays on the
135
+ // `migrate` command rather than reaching the usage printer, so the handler
136
+ // answers it inside the envelope the caller asked for.
137
+ function parseMigrateArgs(command, rest) {
138
+ const json = hasFlag(rest, '--json');
139
+ let dryRun = false;
140
+ for (const arg of rest) {
141
+ if (arg === '--json') continue;
142
+ else if (arg === '--dry-run') dryRun = true;
143
+ else return { command, json, invalidOption: arg };
144
+ }
145
+ return { command, json, dryRun };
146
+ }
147
+
148
+ // `sessions [--agent <name>] [--json]`. Like `events` and `migrate`, an option
149
+ // Academy does not know is answered by the command in its own envelope.
150
+ function parseSessionsArgs(command, rest) {
151
+ const json = hasFlag(rest, '--json');
152
+ let agent;
153
+ for (let i = 0; i < rest.length; i++) {
154
+ const arg = rest[i];
155
+ if (arg === '--json') continue;
156
+ else if (arg === '--agent') agent = rest[++i] ?? '';
157
+ else if (arg.startsWith('--agent=')) agent = arg.slice('--agent='.length);
158
+ else return { command, json, invalidOption: arg };
159
+ }
160
+ return { command, json, agent };
161
+ }
162
+
163
+ // `rename <old> <new> [--json]`. Like `events`, `migrate` and `sessions`, an
164
+ // option Academy does not know stays on the command so the handler answers it
165
+ // inside the envelope the caller asked for.
166
+ function parseRenameArgs(command, rest) {
167
+ const json = hasFlag(rest, '--json');
168
+ const positional = [];
169
+ for (const arg of rest) {
170
+ if (arg === '--json') continue;
171
+ else if (arg.startsWith('--')) return { command, json, invalidOption: arg };
172
+ else positional.push(arg);
173
+ }
174
+ return { command, json, name: positional[0], newName: positional[1] };
175
+ }
176
+
177
+ function extractPassthrough(rest) {
178
+ const dashIdx = rest.indexOf('--');
179
+ return dashIdx >= 0 ? rest.slice(dashIdx + 1) : [];
180
+ }
181
+
182
+ // `notes add [<agent>] "text"` and `notes list [<agent>] [--last N]`. The first
183
+ // positional token is treated as an agent only when it looks like an agent name
184
+ // (NAME_RE) AND there is more to follow — so quoted single-arg text stays text.
185
+ function parseNotesArgs(rest) {
186
+ const action = rest[0];
187
+ if (action !== 'add' && action !== 'list') {
188
+ console.error(`Unknown notes action: ${action ?? '(none)'}. Use 'add' or 'list'.`);
189
+ return { command: 'help', exitCode: 1 };
190
+ }
191
+ const args = rest.slice(1);
192
+
193
+ if (action === 'add') {
194
+ let name;
195
+ let textParts = args;
196
+ if (args.length >= 2 && NAME_RE.test(args[0])) {
197
+ name = args[0];
198
+ textParts = args.slice(1);
199
+ }
200
+ return { command: 'notes', action, name, text: textParts.join(' ') };
201
+ }
202
+
203
+ // list — pull out --last N (or --last=N), the rest is an optional agent name.
204
+ let last = 12;
205
+ const positional = [];
206
+ for (let i = 0; i < args.length; i++) {
207
+ if (args[i] === '--last') {
208
+ last = Number.parseInt(args[++i], 10);
209
+ } else if (args[i].startsWith('--last=')) {
210
+ last = Number.parseInt(args[i].slice('--last='.length), 10);
211
+ } else {
212
+ positional.push(args[i]);
213
+ }
214
+ }
215
+ if (!Number.isInteger(last) || last <= 0) last = 12;
216
+ const name = positional[0] && NAME_RE.test(positional[0]) ? positional[0] : undefined;
217
+ return { command: 'notes', action, name, last };
218
+ }
219
+
220
+ export function printUsage() {
221
+ console.log(
222
+ `
223
+ Usage: ${CLI_NAME} <command> [options]
224
+
225
+ Commands:
226
+ doctor [--json] Report Academy's version, roots, runtimes, and capability
227
+ create <name> [--json] Scaffold a new portable agent at ~/.academy/agents/<name>/
228
+ hire Interactive hire flow — produces 8 boot files via Claude Code
229
+ hire --spec <path> [--json]
230
+ Headless hire from a JSON specification file
231
+ {name, role, objective, runtime?}
232
+ run <name> [--agent claude-code|codex] [-- ...]
233
+ Launch an agent with its persisted runtime; --agent
234
+ overrides it and persists the new choice
235
+ nightly <name> Run scheduled consolidation with the agent's persisted
236
+ runtime when observation memory is pending
237
+ events --since <seq> [--logid <id>] [--json]
238
+ Replay lifecycle events after a sequence
239
+ migrate [--dry-run] [--json]
240
+ Write the missing ownership marker for agents inside
241
+ AGENTS_ROOT; --dry-run reports without writing
242
+ sessions [--agent <name>] [--json]
243
+ List sessions whose agent directory is inside
244
+ AGENTS_ROOT
245
+ rename <old> <new> [--json]
246
+ Move an agent to a new name, rewriting its ownership
247
+ marker and re-registering its nightly job
248
+ archive <name> [--json] Move an agent into the archived holding area
249
+ unarchive <name> [--json]
250
+ Restore an archived agent to its canonical slot
251
+ list List all agents
252
+ inspect <name> Inspect one agent
253
+ tokens <name> Estimate generated prompt tokens by surface
254
+ budget <name> Check prompt token budget by surface
255
+ clean <name> Truncate transient surfaces (notes.md, threads.md)
256
+ destroy <name> --force Remove an agent and all its files
257
+ root Print Academy package root
258
+ notes add [<agent>] "…" Append a short note to the agent's notes.md
259
+ notes list [<agent>] [--last N] Show recent notes (default last 12)
260
+
261
+ Examples:
262
+ ${CLI_NAME} doctor --json
263
+ ${CLI_NAME} create kai
264
+ ${CLI_NAME} hire
265
+ ${CLI_NAME} hire --spec ./kai.json --json
266
+ ${CLI_NAME} run kai
267
+ ${CLI_NAME} run kai -- -p "Run today's analytics review"
268
+ ${CLI_NAME} tokens kai
269
+ ${CLI_NAME} budget kai --json
270
+ ${CLI_NAME} events --since 42 --logid 8f2c1d4e --json
271
+ ${CLI_NAME} migrate --dry-run --json
272
+ ${CLI_NAME} sessions --agent kai --json
273
+ ${CLI_NAME} rename kai nova --json
274
+ ${CLI_NAME} notes add "User prefers short status updates before edits"
275
+ ${CLI_NAME} notes list --last 20
276
+ `.trim(),
277
+ );
278
+ }
279
+
280
+ // ─────────────────────────────────────────────────────────────────────────────
281
+ // Validation helpers
282
+ // ─────────────────────────────────────────────────────────────────────────────