@joenandez/academy 0.4.0 → 0.5.13

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 (122) hide show
  1. package/.claude-plugin/marketplace.json +5 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +415 -0
  4. package/README.md +67 -122
  5. package/hooks/backstory/.claude-plugin/plugin.json +11 -0
  6. package/hooks/backstory/LICENSE +21 -0
  7. package/hooks/backstory/SOURCE +2 -0
  8. package/hooks/backstory/bin/backstory +9 -0
  9. package/hooks/backstory/hooks/hooks.json +45 -0
  10. package/hooks/backstory/scripts/knowledge.mjs +87 -0
  11. package/hooks/backstory/scripts/memory.mjs +685 -0
  12. package/hooks/backstory/scripts/tool-tick.mjs +1 -0
  13. package/hooks/backstory/skills/capture/SKILL.md +71 -0
  14. package/hooks/backstory/skills/capture/references/knowledge-capture-input.json +16 -0
  15. package/hooks/backstory/skills/capture/references/tagging-policy.md +29 -0
  16. package/hooks/backstory/skills/memory/SKILL.md +70 -0
  17. package/hooks/backstory/skills/setup/SKILL.md +85 -0
  18. package/hooks/context_metrics.mjs +67 -0
  19. package/hooks/hook_runtime.mjs +43 -2
  20. package/hooks/memory_bridge.mjs +286 -52
  21. package/hooks/memory_store.mjs +78 -26
  22. package/hooks/register_session.mjs +76 -14
  23. package/hooks/roque/.claude-plugin/plugin.json +6 -0
  24. package/hooks/roque/LICENSE +21 -0
  25. package/hooks/roque/SOURCE +1 -0
  26. package/hooks/roque/hooks.json +38 -0
  27. package/hooks/roque/package.json +3 -0
  28. package/hooks/roque/roque.js +1690 -0
  29. package/hooks/sync_memory.mjs +11 -7
  30. package/package.json +10 -3
  31. package/scripts/cli/agent-gate.mjs +70 -0
  32. package/scripts/cli/agent-id.mjs +37 -0
  33. package/scripts/cli/archive.mjs +39 -38
  34. package/scripts/cli/archived.mjs +4 -2
  35. package/scripts/cli/args-kinds.mjs +33 -0
  36. package/scripts/cli/args-notes.mjs +42 -0
  37. package/scripts/cli/args-owner.mjs +37 -0
  38. package/scripts/cli/args-run.mjs +38 -0
  39. package/scripts/cli/args.mjs +47 -104
  40. package/scripts/cli/beads.mjs +57 -0
  41. package/scripts/cli/codex.mjs +66 -12
  42. package/scripts/cli/core.mjs +117 -41
  43. package/scripts/cli/create.mjs +224 -76
  44. package/scripts/cli/curator-feed-codex.mjs +81 -0
  45. package/scripts/cli/curator-feed-observations.mjs +97 -0
  46. package/scripts/cli/curator-feed-scope.mjs +124 -0
  47. package/scripts/cli/curator-feed-sources.mjs +172 -0
  48. package/scripts/cli/curator-feed.mjs +318 -0
  49. package/scripts/cli/default-name.mjs +73 -0
  50. package/scripts/cli/doctor.mjs +67 -2
  51. package/scripts/cli/eventlog.mjs +6 -2
  52. package/scripts/cli/exit-code.mjs +9 -0
  53. package/scripts/cli/first-run.mjs +115 -0
  54. package/scripts/cli/heartbeat.mjs +135 -0
  55. package/scripts/cli/helm-skip.mjs +20 -0
  56. package/scripts/cli/hire-headless.mjs +37 -22
  57. package/scripts/cli/hire.mjs +8 -2
  58. package/scripts/cli/inspect.mjs +36 -18
  59. package/scripts/cli/lifecycle.mjs +95 -49
  60. package/scripts/cli/main.mjs +32 -2
  61. package/scripts/cli/memory-env.mjs +124 -0
  62. package/scripts/cli/migrate.mjs +13 -7
  63. package/scripts/cli/next-steps.mjs +13 -0
  64. package/scripts/cli/nightly-launch.mjs +118 -0
  65. package/scripts/cli/nightly.mjs +228 -0
  66. package/scripts/cli/notes.mjs +58 -8
  67. package/scripts/cli/owner-config.mjs +181 -0
  68. package/scripts/cli/owner-jobs.mjs +201 -0
  69. package/scripts/cli/prompt-append.mjs +135 -0
  70. package/scripts/cli/rename.mjs +90 -109
  71. package/scripts/cli/run-context.mjs +50 -0
  72. package/scripts/cli/run.mjs +236 -79
  73. package/scripts/cli/scaffold.mjs +119 -126
  74. package/scripts/cli/schedule-errors.mjs +70 -0
  75. package/scripts/cli/sessions.mjs +10 -3
  76. package/scripts/cli/skills-check.mjs +147 -0
  77. package/scripts/cli/skills.mjs +170 -0
  78. package/scripts/cli/start.mjs +201 -0
  79. package/scripts/cli/templates.mjs +119 -45
  80. package/scripts/cli/tldr.mjs +184 -0
  81. package/scripts/cli/toml-string.mjs +76 -0
  82. package/scripts/cli/user-work-view.mjs +48 -0
  83. package/scripts/cli/writable-dirs.mjs +23 -0
  84. package/scripts/cli/yaml.mjs +1 -1
  85. package/skills/hire/SKILL.md +112 -100
  86. package/skills/hire/references/owner.md +68 -0
  87. package/templates/agents/claude-code/knowledge-curator.md +5 -2
  88. package/templates/agents/codex/knowledge-curator.toml +2 -1
  89. package/templates/operating-model/base.md +78 -0
  90. package/templates/operating-model/owner.md +11 -0
  91. package/templates/operating-model/principal.md +9 -0
  92. package/templates/operating-model/specialist.md +9 -0
  93. package/templates/packs/owner/craft.md +32 -0
  94. package/templates/packs/owner/identity.md +13 -0
  95. package/templates/packs/owner/mandate.md +26 -0
  96. package/templates/packs/owner/owner/SKILL.md +118 -0
  97. package/templates/packs/owner/owner-feedback/SKILL.md +49 -0
  98. package/templates/packs/owner/owner-heartbeat/SKILL.md +35 -0
  99. package/templates/packs/owner/owner-nightly/SKILL.md +114 -0
  100. package/templates/packs/owner/owner-review/SKILL.md +70 -0
  101. package/templates/packs/owner/owner-work/SKILL.md +53 -0
  102. package/templates/packs/principal/craft.md +27 -0
  103. package/templates/packs/principal/identity.md +13 -0
  104. package/templates/packs/principal/mandate.md +23 -0
  105. package/templates/packs/principal/principal-brief/SKILL.md +44 -0
  106. package/templates/packs/principal/principal-intro/SKILL.md +95 -0
  107. package/templates/packs/principal/principal-nightly/SKILL.md +152 -0
  108. package/templates/skills/check-in/SKILL.md +34 -24
  109. package/templates/skills/knowledge-curation/SKILL.md +89 -48
  110. package/templates/skills/local-rules/SKILL.md +86 -0
  111. package/templates/skills/nightly-consolidation/SKILL.md +131 -100
  112. package/templates/skills/skill-method/SKILL.md +79 -0
  113. package/templates/skills/user-nightly/SKILL.md +143 -0
  114. package/templates/skills/work-graph/SKILL.md +75 -0
  115. package/conformance/README.md +0 -60
  116. package/conformance/discovery.test.mjs +0 -140
  117. package/conformance/envelope.test.mjs +0 -185
  118. package/conformance/error-codes.test.mjs +0 -125
  119. package/conformance/harness.mjs +0 -180
  120. package/conformance/identity.test.mjs +0 -125
  121. package/docs/integration-guide.md +0 -1042
  122. package/templates/skills/self-update/SKILL.md +0 -121
@@ -2,7 +2,7 @@
2
2
  import { mkdirSync } from 'node:fs';
3
3
  import { join, resolve } from 'node:path';
4
4
  import { isMainModule, readPayload, runtimeEnvFor } from './hook_runtime.mjs';
5
- import { syncBridgeObservations } from './memory_bridge.mjs';
5
+ import { sweepBridgeObservations } from './memory_bridge.mjs';
6
6
  import { markPendingConsolidation, recordSession } from './memory_store.mjs';
7
7
 
8
8
  export function syncAcademyMemory(payload, env = process.env) {
@@ -12,15 +12,19 @@ export function syncAcademyMemory(payload, env = process.env) {
12
12
  if (!agentDir || !sessionId) return { synced: 0 };
13
13
 
14
14
  const memoryDir = join(resolve(agentDir), 'memory');
15
- const observationsDir = join(memoryDir, 'observations');
16
- mkdirSync(observationsDir, { recursive: true });
15
+ mkdirSync(join(memoryDir, 'observations'), { recursive: true });
17
16
  recordSession(memoryDir, payload, env);
17
+ // Observations land after Stop, so this copies what earlier stops of every
18
+ // recorded session could not yet see (the store is one turn behind).
18
19
  // 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);
20
+ // agent still consolidates when the memory bridge is unavailable. A
21
+ // nightly's own Stop marks only when it swept in earlier sessions' input.
22
+ let synced = 0;
23
+ markPendingConsolidation(memoryDir, () => {
24
+ synced = sweepBridgeObservations(resolve(agentDir), { env });
25
+ return env.ACADEMY_NIGHTLY_RUN !== '1' || synced > 0;
26
+ });
21
27
 
22
- const synced =
23
- env.ACADEMY_MEMORY_BRIDGE === '1' ? syncBridgeObservations(env, observationsDir, sessionId) : 0;
24
28
  return { synced };
25
29
  }
26
30
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joenandez/academy",
3
- "version": "0.4.0",
3
+ "version": "0.5.13",
4
4
  "description": "Academy v3 — portable AI agents with 8 boot surfaces and a skills primitive.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,12 +21,12 @@
21
21
  "templates/",
22
22
  "hooks/",
23
23
  ".claude-plugin/",
24
- "conformance/",
25
- "docs/integration-guide.md",
26
24
  "CHANGELOG.md"
27
25
  ],
28
26
  "scripts": {
29
27
  "test": "node --test tests/*.test.mjs",
28
+ "sync:roque": "node scripts/sync-roque.mjs",
29
+ "sync:backstory": "node scripts/sync-backstory.mjs",
30
30
  "quality:setup": "node quality/run.mjs setup",
31
31
  "quality:commit": "node quality/run.mjs commit",
32
32
  "quality:push": "node quality/run.mjs push",
@@ -35,7 +35,14 @@
35
35
  "keywords": ["academy", "ai-agent", "claude-code", "portable-agent"],
36
36
  "author": "Joe Fernandez",
37
37
  "license": "MIT",
38
+ "engines": {
39
+ "node": ">=24.20.0"
40
+ },
38
41
  "devDependencies": {
42
+ "js-yaml": "4.3.2",
39
43
  "knip": "5.70.2"
44
+ },
45
+ "dependencies": {
46
+ "@joenandez/tldr": "0.2.0"
40
47
  }
41
48
  }
@@ -0,0 +1,70 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { currentAgentName } from './agent-id.mjs';
4
+ import { CLI_NAME, kindOf } from './core.mjs';
5
+ import { printHelmSkip } from './helm-skip.mjs';
6
+ import { schedulerJobId } from './tldr.mjs';
7
+ import { readAgentYaml } from './yaml.mjs';
8
+
9
+ // Decision D1: nothing runs until the user signs the mandate, and a stopped
10
+ // agent runs nothing. Job registration and every run entry ask these two.
11
+
12
+ // The hire placeholder the Owner mandate template carries until a person
13
+ // writes the mandate. Matched as a substring, so the template may change around it.
14
+ const MANDATE_PLACEHOLDER = 'Hire flow will populate this';
15
+
16
+ export const isStopped = (dir) => readAgentYaml(dir).stopped === 'true';
17
+
18
+ export function readMandate(dir) {
19
+ try {
20
+ return readFileSync(join(dir, 'mandate.md'), 'utf8');
21
+ } catch {
22
+ return '';
23
+ }
24
+ }
25
+
26
+ /** Mandate text that is blank or still holds the hire placeholder. */
27
+ export const isDraftMandate = (text) => text.trim() === '' || text.includes(MANDATE_PLACEHOLDER);
28
+
29
+ /** An Owner whose mandate.md is missing, blank, or still holds the hire placeholder. */
30
+ export function isDraftOwner(dir) {
31
+ return kindOf(dir) === 'owner' && isDraftMandate(readMandate(dir));
32
+ }
33
+
34
+ // Of a kind's job ids, nightly first, the ones registered while the agent is
35
+ // in this state: none when stopped, only the nightly for a draft Owner.
36
+ export function liveJobIds(dir, ids) {
37
+ if (isStopped(dir)) return [];
38
+ return isDraftOwner(dir) ? ids.slice(0, 1) : ids;
39
+ }
40
+
41
+ function notStartedReason(dir) {
42
+ if (isStopped(dir)) return 'agent_stopped';
43
+ if (isDraftOwner(dir)) return 'owner_draft';
44
+ return null;
45
+ }
46
+
47
+ // The run entry gate, before any model. A scheduled run, a stale job left by a
48
+ // failed delete included, ends as a skip the scheduler records; a person is
49
+ // told to start the agent.
50
+ export function refuseNotStarted(dir, id) {
51
+ const reason = notStartedReason(dir);
52
+ if (!reason) return;
53
+ const jobId = schedulerJobId();
54
+ console.error(
55
+ JSON.stringify({
56
+ event: 'run.not_started',
57
+ status: 'warn',
58
+ params: { agent: id, jobId },
59
+ result: { skip: reason },
60
+ }),
61
+ );
62
+ if (jobId) {
63
+ printHelmSkip(reason, id);
64
+ process.exit(0);
65
+ }
66
+ const name = currentAgentName(dir);
67
+ const state = reason === 'agent_stopped' ? 'is stopped' : 'has no signed mandate';
68
+ console.error(`Agent "${name}" ${state}. Run \`${CLI_NAME} start ${name}\` to start it.`);
69
+ process.exit(1);
70
+ }
@@ -0,0 +1,37 @@
1
+ import { existsSync, readdirSync } from 'node:fs';
2
+ import { basename, join } from 'node:path';
3
+ import { AGENTS_ROOT, NAME_RE } from './core.mjs';
4
+ import { holdingAreaOrNull, isArchivedAgent } from './archived.mjs';
5
+ import { readAgentYaml } from './yaml.mjs';
6
+
7
+ // An agent's folder name is its fixed id; agent.yaml `name:` is its current
8
+ // name. `agentDir` in core.mjs resolves either through here.
9
+
10
+ /** The id an id or current name resolves to, live or archived, or null. Never exits. */
11
+ export function agentIdOrNull(nameOrId) {
12
+ if (typeof nameOrId !== 'string' || !NAME_RE.test(nameOrId)) return null;
13
+ if (existsSync(join(AGENTS_ROOT, nameOrId)) || isArchivedAgent(nameOrId)) return nameOrId;
14
+ // Read only on a folder miss, so a lookup by id never opens a YAML file.
15
+ const holding = holdingAreaOrNull();
16
+ for (const home of holding === null ? [AGENTS_ROOT] : [AGENTS_ROOT, holding]) {
17
+ const id = listIds(home).find((entry) => readAgentYaml(join(home, entry)).name === nameOrId);
18
+ if (id) return id;
19
+ }
20
+ return null;
21
+ }
22
+
23
+ /** The current name: agent.yaml `name:`, or the id when it names none. */
24
+ export function currentAgentName(dir) {
25
+ return readAgentYaml(dir).name || basename(dir);
26
+ }
27
+
28
+ /** The fixed id an id or current name addresses; an unknown or invalid input as given. */
29
+ export const agentIdOf = (nameOrId) => agentIdOrNull(nameOrId) ?? nameOrId;
30
+
31
+ function listIds(home) {
32
+ try {
33
+ return readdirSync(home).filter((entry) => NAME_RE.test(entry));
34
+ } catch {
35
+ return [];
36
+ }
37
+ }
@@ -11,17 +11,21 @@ import {
11
11
  withFileLock,
12
12
  } from './core.mjs';
13
13
  import { ARCHIVED_DIR, archivedAgentDir, holdingAreaOrNull } from './archived.mjs';
14
- import { registerNightlyConsolidationTask } from './create.mjs';
14
+ import { jobLabel, registerAcademyJobs, scheduledJobFields } from './create.mjs';
15
+ import { agentIdOf, currentAgentName } from './agent-id.mjs';
15
16
  import { appendLifecycleEvent } from './eventlog.mjs';
16
17
  import {
17
18
  DeleteAgentError,
18
19
  SLOT,
20
+ academyJobIds,
19
21
  assertOwnedAgentForDelete,
20
- deleteNightlyConsolidation,
22
+ deleteAcademyJobs,
21
23
  preflightOwnedAgent,
22
24
  reportLifecycleFailure,
25
+ unscheduleError,
23
26
  validateOwnedAgentDir,
24
27
  } from './lifecycle.mjs';
28
+ import { reportRegisteredJobs } from './rename.mjs';
25
29
 
26
30
  // `archive` / `unarchive`. Archived agents live at AGENTS_ROOT/.archived/<name>,
27
31
  // which is not the canonical slot, so they are validated against the holding
@@ -29,16 +33,19 @@ import {
29
33
  // Neither command destroys anything, so neither quarantines: the move itself is
30
34
  // the whole operation and it is reversible by the other command.
31
35
 
36
+ // Both commands act on the fixed id: its folder, slot, marker and lock. The
37
+ // current name is what they report.
32
38
  export function archiveAgent(name, json) {
33
- preflightOwnedAgent(name, json);
34
- runLocked(name, json, 'archive', () => archiveAgentLocked(name, json));
39
+ const { id } = preflightOwnedAgent(name, json);
40
+ runLocked(id, json, 'archive', () => archiveAgentLocked(id, json));
35
41
  }
36
42
 
37
43
  export function unarchiveAgent(name, json) {
38
44
  validateName(name);
39
45
  validateAgentsRoot();
40
- validateOwnedAgentDir(name, archivedAgentDir(name), json, SLOT.archived);
41
- runLocked(name, json, 'unarchive', () => unarchiveAgentLocked(name, json));
46
+ const id = agentIdOf(name);
47
+ validateOwnedAgentDir(id, archivedAgentDir(id), json, SLOT.archived);
48
+ runLocked(id, json, 'unarchive', () => unarchiveAgentLocked(id, json));
42
49
  }
43
50
 
44
51
  // Both commands take the canonical lifecycle lock, the same one `delete` and
@@ -94,26 +101,20 @@ function refuseOccupiedTarget(name, target) {
94
101
  );
95
102
  }
96
103
 
97
- function archiveAgentLocked(name, json) {
98
- const { dirReal } = assertOwnedAgentForDelete(name, agentDir(name));
99
- const previousDir = resolve(agentDir(name));
104
+ function archiveAgentLocked(id, json) {
105
+ const { dirReal } = assertOwnedAgentForDelete(id, agentDir(id));
106
+ const name = currentAgentName(dirReal);
107
+ const previousDir = resolve(agentDir(id));
100
108
  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
- }
109
+ assertContainedHoldingArea(id);
110
+ const target = archivedAgentDir(id);
111
+ assertContainedSlot(id, target);
112
+ refuseOccupiedTarget(id, target);
113
+
114
+ // Unscheduled before the move: the job's --cwd names a directory that is
115
+ // about to move, and an archived agent must not keep firing any of its jobs.
116
+ const unscheduled = deleteAcademyJobs(dirReal, academyJobIds(id, dirReal));
117
+ if (!unscheduled.ok) throw unscheduleError('archive', name, unscheduled);
117
118
 
118
119
  renameSync(dirReal, target);
119
120
  appendLifecycleEvent('agent_archived', name, resolve(target), { previousDir });
@@ -125,37 +126,37 @@ function archiveAgentLocked(name, json) {
125
126
  dir: resolve(target),
126
127
  previousDir,
127
128
  unscheduledJobId: unscheduled.id,
129
+ unscheduledJobIds: unscheduled.ids,
128
130
  });
129
131
  return;
130
132
  }
131
133
  console.log(`Archived agent "${name}" (${previousDir} → ${resolve(target)}).`);
132
- console.log(`Unregistered nightly consolidation job "${unscheduled.id}".`);
134
+ for (const id of unscheduled.ids) console.log(`Unregistered ${jobLabel(id)} "${id}".`);
133
135
  }
134
136
 
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);
137
+ function unarchiveAgentLocked(id, json) {
138
+ assertContainedHoldingArea(id);
139
+ const { dirReal } = assertOwnedAgentForDelete(id, archivedAgentDir(id), SLOT.archived);
140
+ const name = currentAgentName(dirReal);
141
+ const previousDir = resolve(archivedAgentDir(id));
142
+ const target = agentDir(id);
143
+ assertContainedSlot(id, target);
144
+ refuseOccupiedTarget(id, target);
142
145
 
143
146
  renameSync(dirReal, target);
144
- const nightly = registerNightlyConsolidationTask(target, name);
147
+ const jobs = registerAcademyJobs(target, id);
145
148
  appendLifecycleEvent('agent_unarchived', name, resolve(target), { previousDir });
146
149
 
147
- const scheduledJobId = nightly.registered ? nightly.id : null;
148
150
  if (json) {
149
151
  contractOk('unarchive', {
150
152
  unarchived: true,
151
153
  name,
152
154
  dir: resolve(target),
153
155
  previousDir,
154
- scheduledJobId,
156
+ ...scheduledJobFields(jobs),
155
157
  });
156
158
  return;
157
159
  }
158
160
  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
+ reportRegisteredJobs(jobs);
161
162
  }
@@ -1,6 +1,7 @@
1
1
  import { lstatSync, readdirSync, realpathSync, statSync } from 'node:fs';
2
2
  import { join, resolve } from 'node:path';
3
3
  import { AGENTS_ROOT, NAME_RE, activeCommandName, contractError, jsonMode } from './core.mjs';
4
+ import { agentIdOf } from './agent-id.mjs';
4
5
 
5
6
  // The holding area, as one module. Archived agents live in a dot-directory
6
7
  // beside their canonical slots, so the roster's NAME_RE filter already keeps
@@ -73,8 +74,9 @@ function isRealDirectory(path) {
73
74
  // replacement for somebody who is still there. `unarchive` is the one command
74
75
  // allowed to address one, so it is the one command that does not ask.
75
76
  export function assertNotArchived(name, json = jsonMode()) {
76
- if (!isArchivedAgent(name)) return;
77
- const dir = resolve(archivedAgentDir(name));
77
+ const id = agentIdOf(name);
78
+ if (!isArchivedAgent(id)) return;
79
+ const dir = resolve(archivedAgentDir(id));
78
80
  const message = `Agent "${name}" is archived at ${dir}. Unarchive it first.`;
79
81
  if (json) contractError(activeCommandName(), 'agent_archived', message, { name, dir });
80
82
  console.error(`Error: ${message}`);
@@ -0,0 +1,33 @@
1
+ // `create <name> [--kind <kind>] [--no-start] [--json]`. Like `rename`, an option Academy
2
+ // does not know stays on the command so the handler answers it inside the
3
+ // envelope the caller asked for. The kind stays a raw string: only `create` can
4
+ // answer an unsupported one in the envelope.
5
+ export function parseCreateArgs(command, rest) {
6
+ const json = rest.includes('--json');
7
+ let name;
8
+ let kind;
9
+ let noStart = false;
10
+ for (let i = 0; i < rest.length; i++) {
11
+ const arg = rest[i];
12
+ if (arg === '--json') continue;
13
+ else if (arg === '--no-start') noStart = true;
14
+ else if (arg === '--kind') kind = rest[++i] ?? '';
15
+ else if (arg.startsWith('--kind=')) kind = arg.slice('--kind='.length);
16
+ else if (arg.startsWith('--')) return { command, json, name, invalidOption: arg };
17
+ else name ??= arg;
18
+ }
19
+ return { command, json, name, kind, noStart };
20
+ }
21
+
22
+ // `skills check <agent> [--json]`. Like `create`, an option Academy does not
23
+ // know stays on the command so the handler answers it in the envelope.
24
+ export function parseSkillsArgs(command, rest) {
25
+ const json = rest.includes('--json');
26
+ const positional = [];
27
+ for (const arg of rest) {
28
+ if (arg === '--json') continue;
29
+ else if (arg.startsWith('--')) return { command, json, invalidOption: arg };
30
+ else positional.push(arg);
31
+ }
32
+ return { command, json, action: positional[0], name: positional[1] };
33
+ }
@@ -0,0 +1,42 @@
1
+ import { NAME_RE } from './core.mjs';
2
+
3
+ // `notes add [<agent>] [--user] "text"` and `notes list [<agent>] [--last N]`. The first
4
+ // positional token is treated as an agent only when it looks like an agent name
5
+ // (NAME_RE) AND there is more to follow — so quoted single-arg text stays text.
6
+ export function parseNotesArgs(rest) {
7
+ const action = rest[0];
8
+ if (action !== 'add' && action !== 'list') {
9
+ console.error(`Unknown notes action: ${action ?? '(none)'}. Use 'add' or 'list'.`);
10
+ return { command: 'help', exitCode: 1 };
11
+ }
12
+ const args = rest.slice(1);
13
+
14
+ if (action === 'add') {
15
+ // `--user` proposes a fact about the user rather than noting for the agent.
16
+ const user = args.includes('--user');
17
+ const words = args.filter((arg) => arg !== '--user');
18
+ let name;
19
+ let textParts = words;
20
+ if (words.length >= 2 && NAME_RE.test(words[0])) {
21
+ name = words[0];
22
+ textParts = words.slice(1);
23
+ }
24
+ return { command: 'notes', action, name, text: textParts.join(' '), user };
25
+ }
26
+
27
+ // list — pull out --last N (or --last=N), the rest is an optional agent name.
28
+ let last = 12;
29
+ const positional = [];
30
+ for (let i = 0; i < args.length; i++) {
31
+ if (args[i] === '--last') {
32
+ last = Number.parseInt(args[++i], 10);
33
+ } else if (args[i].startsWith('--last=')) {
34
+ last = Number.parseInt(args[i].slice('--last='.length), 10);
35
+ } else {
36
+ positional.push(args[i]);
37
+ }
38
+ }
39
+ if (!Number.isInteger(last) || last <= 0) last = 12;
40
+ const name = positional[0] && NAME_RE.test(positional[0]) ? positional[0] : undefined;
41
+ return { command: 'notes', action, name, last };
42
+ }
@@ -0,0 +1,37 @@
1
+ // `rename <old> <new> [--json]`. Like `events`, `migrate` and `sessions`, an
2
+ // option Academy does not know stays on the command so the handler answers it
3
+ // inside the envelope the caller asked for.
4
+ export function parseRenameArgs(command, rest) {
5
+ const json = rest.includes('--json');
6
+ const positional = [];
7
+ for (const arg of rest) {
8
+ if (arg === '--json') continue;
9
+ else if (arg.startsWith('--')) return { command, json, invalidOption: arg };
10
+ else positional.push(arg);
11
+ }
12
+ return { command, json, name: positional[0], newName: positional[1] };
13
+ }
14
+
15
+ const START_OPTIONS = {
16
+ '--mandate': 'mandate',
17
+ '--hours': 'hours',
18
+ '--contact': 'contact',
19
+ '--trust': 'trust',
20
+ };
21
+
22
+ // `start <name> [--mandate <file>] [--hours <h>] [--contact <c>] [--trust 1-4]
23
+ // [--json]`. Like `rename`, an option Academy does not know stays on the
24
+ // command so the handler answers it inside the envelope.
25
+ export function parseStartArgs(command, rest) {
26
+ const parsed = { command, json: rest.includes('--json') };
27
+ for (let i = 0; i < rest.length; i++) {
28
+ const arg = rest[i];
29
+ const [flag, inline] = arg.split(/=(.*)/s);
30
+ if (arg === '--json') continue;
31
+ else if (Object.hasOwn(START_OPTIONS, flag))
32
+ parsed[START_OPTIONS[flag]] = inline ?? rest[++i] ?? '';
33
+ else if (arg.startsWith('--') || parsed.name !== undefined) parsed.invalidOption ??= arg;
34
+ else parsed.name = arg;
35
+ }
36
+ return parsed;
37
+ }
@@ -0,0 +1,38 @@
1
+ import { RUNTIMES } from './core.mjs';
2
+
3
+ export function parseRunArgs(rest) {
4
+ const name = rest[0];
5
+ const afterName = rest.slice(1);
6
+ const dashIdx = afterName.indexOf('--');
7
+ const optionArgs = dashIdx >= 0 ? afterName.slice(0, dashIdx) : afterName;
8
+ const passthrough = dashIdx >= 0 ? afterName.slice(dashIdx + 1) : [];
9
+ // No default here. The agent is not resolved yet, and defaulting before it is
10
+ // what re-registered a codex agent's nightly job as claude-code for ten
11
+ // nights. A null runtime means "not explicit"; `run` reads the persisted
12
+ // scalar instead.
13
+ let runtime = null;
14
+
15
+ for (let i = 0; i < optionArgs.length; i++) {
16
+ const arg = optionArgs[i];
17
+ if (arg === '--agent') {
18
+ const value = optionArgs[++i];
19
+ if (!RUNTIMES.has(value))
20
+ return invalidRunOption(`Invalid --agent value: ${value ?? '(none)'}`);
21
+ runtime = value;
22
+ } else if (arg?.startsWith('--agent=')) {
23
+ const value = arg.slice('--agent='.length);
24
+ if (!RUNTIMES.has(value))
25
+ return invalidRunOption(`Invalid --agent value: ${value || '(none)'}`);
26
+ runtime = value;
27
+ } else if (arg) {
28
+ return invalidRunOption(`Unknown run option: ${arg}`);
29
+ }
30
+ }
31
+
32
+ return { command: 'run', name, runtime, passthrough };
33
+ }
34
+
35
+ function invalidRunOption(message) {
36
+ console.error(`${message}. Use --agent claude-code or --agent codex before --.`);
37
+ return { command: 'help', exitCode: 1 };
38
+ }