@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
@@ -3,15 +3,14 @@ import { join, resolve } from 'node:path';
3
3
  import {
4
4
  AGENTS_ROOT,
5
5
  CLI_NAME,
6
- ENFORCED_SURFACES,
6
+ KIND_PROFILES,
7
7
  NAME_RE,
8
- SURFACES,
9
- SURFACE_CAPS,
10
- TOTAL_SURFACE_CAP,
11
8
  agentDir,
12
9
  checkAgentsRoot,
13
10
  contractOk,
14
11
  exitJsonError,
12
+ kindOf,
13
+ surfacesFor,
15
14
  validateName,
16
15
  } from './core.mjs';
17
16
  import { archivedAgentDir, archivedAgentNames, assertNotArchived } from './archived.mjs';
@@ -20,6 +19,7 @@ import { readEventLogWatermark } from './eventlog.mjs';
20
19
  import { readRuntimeProvider, runtimeProviderOrNull } from './runtime.mjs';
21
20
  import { academySystemPromptPath, buildAcademySystemPrompt, tokenRecord } from './scaffold.mjs';
22
21
  import { readAgentYaml } from './yaml.mjs';
22
+ import { currentAgentName } from './agent-id.mjs';
23
23
 
24
24
  export function listAgents() {
25
25
  const entries = agentNames();
@@ -33,9 +33,12 @@ export function listAgents() {
33
33
  }
34
34
  }
35
35
 
36
- function printRosterLine(name, dir, prefix) {
36
+ // The current name, and the fixed id after it when a rename made them differ.
37
+ function printRosterLine(id, dir, prefix) {
37
38
  const role = readAgentYaml(dir).role || '(no role set)';
38
- console.log(` ${(prefix + name).padEnd(20)} ${role}`);
39
+ const name = currentAgentName(dir);
40
+ const label = name === id ? name : `${name} (id: ${id})`;
41
+ console.log(` ${(prefix + label).padEnd(20)} ${role}`);
39
42
  }
40
43
 
41
44
  // The watermark is read before the roster, and that order is the contract. Read
@@ -104,20 +107,26 @@ export function agentRecord(
104
107
  ) {
105
108
  const yaml = readAgentYaml(dir);
106
109
  const record = {
107
- name,
110
+ name: yaml.name || name,
108
111
  dir: resolve(dir),
109
112
  displayName: yaml.displayName || yaml.display_name || yaml.name || name,
110
113
  runtimeProvider: strictRuntime ? readRuntimeProvider(dir) : runtimeProviderOrNull(dir),
114
+ kind: strictRuntime ? kindOf(dir) : rosterKind(yaml),
111
115
  };
112
116
  if (yaml.role) record.role = yaml.role;
113
117
  if (includeSurfaces) record.surfaces = surfacePresence(dir);
114
118
  return record;
115
119
  }
116
120
 
121
+ // The roster degrades an unsupported kind to null the way it degrades the
122
+ // runtime; a missing one is a Specialist, as `kindOf` reads it.
123
+ function rosterKind(yaml) {
124
+ const kind = yaml.kind || 'specialist';
125
+ return Object.hasOwn(KIND_PROFILES, kind) ? kind : null;
126
+ }
127
+
117
128
  function surfacePresence(dir) {
118
- return Object.fromEntries(
119
- SURFACES.map((surface) => [surface, existsSync(join(dir, `${surface}.md`))]),
120
- );
129
+ return Object.fromEntries(surfacesFor(dir).map(({ name, path }) => [name, existsSync(path)]));
121
130
  }
122
131
 
123
132
  export function inspectAgent(name, json) {
@@ -145,12 +154,17 @@ export function inspectAgent(name, json) {
145
154
  if (record.role) console.log(` role: ${record.role}`);
146
155
  }
147
156
 
157
+ // Staged `## Proposed` lines reach the prompt but do not count toward the user cap.
158
+ function withoutProposed({ name, content }) {
159
+ return name === 'user' ? content.split(/^## Proposed\s*$/m)[0] : content;
160
+ }
161
+
148
162
  function roundPercent(part, total) {
149
163
  if (!total) return 0;
150
164
  return Math.round((part / total) * 1000) / 10;
151
165
  }
152
166
 
153
- function promptTokenReport(name) {
167
+ function promptTokenReport(name, budgetOnly = false) {
154
168
  validateName(name);
155
169
  checkAgentsRoot();
156
170
  assertNotArchived(name);
@@ -158,7 +172,7 @@ function promptTokenReport(name) {
158
172
  if (!existsSync(dir)) return { missing: true, dir };
159
173
  assertContainedAgentDir(name, dir);
160
174
 
161
- const built = buildAcademySystemPrompt(dir, name);
175
+ const built = buildAcademySystemPrompt(dir, currentAgentName(dir));
162
176
  const total = tokenRecord(built.prompt);
163
177
  const overheadText = [
164
178
  ...built.intro,
@@ -166,7 +180,7 @@ function promptTokenReport(name) {
166
180
  ].join('\n');
167
181
  const overhead = tokenRecord(overheadText);
168
182
  const surfaces = built.surfaces.map((surface) => {
169
- const count = tokenRecord(surface.content);
183
+ const count = tokenRecord(budgetOnly ? withoutProposed(surface) : surface.content);
170
184
  return {
171
185
  name: surface.name,
172
186
  file: surface.file,
@@ -223,7 +237,7 @@ export function printPromptTokens(name, json) {
223
237
  }
224
238
 
225
239
  export function printSurfaceBudget(name, json) {
226
- const tokenReport = promptTokenReport(name);
240
+ const tokenReport = promptTokenReport(name, true);
227
241
  if (tokenReport.missing) {
228
242
  const message = `Agent "${name}" not found at ${tokenReport.dir}`;
229
243
  if (json) exitJsonError('agent_not_found', message, { name });
@@ -231,8 +245,11 @@ export function printSurfaceBudget(name, json) {
231
245
  process.exit(1);
232
246
  }
233
247
 
248
+ // Both the token report and the limits come from surfacesFor, so a surface
249
+ // is joined by its name, never by its position in the list.
250
+ const limits = new Map(surfacesFor(tokenReport.dir).map((limit) => [limit.name, limit]));
234
251
  const surfaces = tokenReport.surfaces.map((surface) => {
235
- const cap = SURFACE_CAPS[surface.name];
252
+ const { cap, enforced } = limits.get(surface.name);
236
253
  const overBy = Math.max(0, surface.estimatedTokens - cap);
237
254
  return {
238
255
  name: surface.name,
@@ -240,10 +257,11 @@ export function printSurfaceBudget(name, json) {
240
257
  cap,
241
258
  overBy,
242
259
  withinCap: overBy === 0,
243
- enforced: ENFORCED_SURFACES.has(surface.name),
260
+ enforced,
244
261
  };
245
262
  });
246
263
  const totalEstimatedTokens = surfaces.reduce((sum, surface) => sum + surface.estimatedTokens, 0);
264
+ const totalCap = surfaces.reduce((sum, surface) => sum + surface.cap, 0);
247
265
  const violations = surfaces.filter((surface) => !surface.withinCap);
248
266
  const report = {
249
267
  agent: tokenReport.agent,
@@ -251,8 +269,8 @@ export function printSurfaceBudget(name, json) {
251
269
  withinBudget: !violations.some((surface) => surface.enforced),
252
270
  total: {
253
271
  estimatedTokens: totalEstimatedTokens,
254
- cap: TOTAL_SURFACE_CAP,
255
- overBy: Math.max(0, totalEstimatedTokens - TOTAL_SURFACE_CAP),
272
+ cap: totalCap,
273
+ overBy: Math.max(0, totalEstimatedTokens - totalCap),
256
274
  },
257
275
  surfaces,
258
276
  violations,
@@ -1,9 +1,10 @@
1
1
  import { existsSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
2
- import { dirname, join, resolve } from 'node:path';
2
+ import { basename, dirname, join, resolve } from 'node:path';
3
3
  import { spawnSync } from 'node:child_process';
4
4
  import { readJsonFile } from './codex.mjs';
5
5
  import {
6
6
  AGENTS_ROOT,
7
+ CLI_NAME,
7
8
  agentDir,
8
9
  agentLifecycleLockPath,
9
10
  contractOk,
@@ -13,16 +14,18 @@ import {
13
14
  jsonMode,
14
15
  LockTimeoutError,
15
16
  RuntimeUnavailableError,
16
- resolveExecutable,
17
17
  validateAgentsRoot,
18
18
  validateName,
19
19
  withFileLock,
20
20
  } from './core.mjs';
21
21
  import { assertNotArchived, holdingAreaOrNull } from './archived.mjs';
22
22
  import { LogCorruptError, appendLifecycleEvent } from './eventlog.mjs';
23
- import { AgentSpecError } from './yaml.mjs';
24
- import { MUST_EXIST, helmFailureReason } from './create.mjs';
23
+ import { AgentSpecError, readAgentYaml } from './yaml.mjs';
24
+ import { agentIdOf, currentAgentName } from './agent-id.mjs';
25
+ import { helmFailureReason } from './schedule-errors.mjs';
26
+ import { ownerJobIds, ownerSessionJobIds, principalJobIds } from './owner-jobs.mjs';
25
27
  import { TEMPLATES } from './templates.mjs';
28
+ import { resolveTldrAgentsBinary, spawnTldrTasksArgs, tldrSpawnOptions } from './tldr.mjs';
26
29
 
27
30
  export function cleanAgent(name) {
28
31
  validateName(name);
@@ -31,19 +34,20 @@ export function cleanAgent(name) {
31
34
  console.error(`Agent "${name}" not found at ${dir}`);
32
35
  process.exit(1);
33
36
  }
34
- // Reset notes.md and threads.md to scaffolded state. Other surfaces are
35
- // load-bearing (identity/role/knowledge) or natural-decay (dailys/goals/priorities).
37
+ // Reset notes.md only. It is the one transient surface. work.md is durable:
38
+ // it holds the goals and Workstreams the nightly run keeps, so clean never
39
+ // resets it.
36
40
  const today = new Date().toISOString().slice(0, 10);
37
- writeFileSync(join(dir, 'notes.md'), TEMPLATES['notes.md'](name, today));
38
- writeFileSync(join(dir, 'threads.md'), TEMPLATES['threads.md'](name, today));
39
- console.log(`Cleaned transient surfaces for "${name}" (notes.md, threads.md).`);
41
+ const current = currentAgentName(dir);
42
+ writeFileSync(join(dir, 'notes.md'), TEMPLATES['notes.md'](current, today));
43
+ console.log(`Cleaned transient surfaces for "${current}" (notes.md).`);
40
44
  }
41
45
 
42
46
  // ─────────────────────────────────────────────────────────────────────────────
43
47
  // `destroy` — nuke an agent (--force required)
44
48
  // ─────────────────────────────────────────────────────────────────────────────
45
49
 
46
- export function deleteJsonError(code, message, fields = {}, json = false) {
50
+ function deleteJsonError(code, message, fields = {}, json = false) {
47
51
  if (json) exitJsonError(code, message, fields);
48
52
  console.error(message);
49
53
  process.exit(1);
@@ -57,17 +61,25 @@ export class DeleteAgentError extends Error {
57
61
  }
58
62
  }
59
63
 
64
+ const MIGRATE_HINT = `Run \`${CLI_NAME} migrate\` to write it.`;
65
+
60
66
  // Ownership is one question, asked by every command that acts on an agent
61
67
  // directory and by `doctor` when it counts what `migrate` still has to fix.
62
68
  // It answers with the fault text, or null when the directory is owned.
69
+ // A marker fault on a directory holding agent.yaml is exactly what `migrate`
70
+ // repairs, so the text names it; with no agent.yaml there is no agent to
71
+ // repair, and pointing at `migrate` would send the operator nowhere.
63
72
  export function ownershipFault(name, dir) {
64
- const markerPath = join(dir, '.academy-agent.json');
65
- if (!existsSync(markerPath) || !existsSync(join(dir, 'agent.yaml'))) {
73
+ if (!existsSync(join(dir, 'agent.yaml'))) {
66
74
  return `Agent "${name}" is missing Academy ownership metadata`;
67
75
  }
76
+ const markerPath = join(dir, '.academy-agent.json');
77
+ if (!existsSync(markerPath)) {
78
+ return `Agent "${name}" is missing Academy ownership metadata. ${MIGRATE_HINT}`;
79
+ }
68
80
  const marker = readJsonFile(markerPath, {});
69
81
  if (marker.capability !== 'academy-agent' || marker.name !== name) {
70
- return `Agent "${name}" ownership metadata is invalid`;
82
+ return `Agent "${name}" ownership metadata is invalid. ${MIGRATE_HINT}`;
71
83
  }
72
84
  return null;
73
85
  }
@@ -144,7 +156,8 @@ function ownedAgentFault(name, dir, slot) {
144
156
  // landed, because a read that follows one out of the root publishes content
145
157
  // from outside it under a `dir` field claiming to be inside.
146
158
  export function assertContainedAgentDir(name, dir, json = jsonMode()) {
147
- const fault = containmentFault(name, dir, SLOT.canonical);
159
+ // The slot is the id folder `dir` names, whichever name addressed it.
160
+ const fault = containmentFault(basename(resolve(dir)), dir, SLOT.canonical);
148
161
  if (fault.code) deleteJsonError(fault.code, fault.message, { name }, json);
149
162
  }
150
163
 
@@ -157,11 +170,14 @@ export function validateOwnedAgentDir(name, dir, json = false, slot = SLOT.canon
157
170
  // The shared preflight for `delete`, `rename` and `archive`. `unarchive` is the
158
171
  // one lifecycle command that may address an archived agent, so it preflights
159
172
  // against the holding area instead of coming through here.
173
+ // Every check after the name resolves asks about the fixed id: the folder,
174
+ // its slot and its marker are all keyed by it, never by the current name.
160
175
  export function preflightOwnedAgent(name, json = false) {
161
176
  validateName(name);
162
177
  validateAgentsRoot();
163
- assertNotArchived(name, json);
164
- return validateOwnedAgentDir(name, agentDir(name), json);
178
+ const id = agentIdOf(name);
179
+ assertNotArchived(id, json);
180
+ return { ...validateOwnedAgentDir(id, agentDir(id), json), id };
165
181
  }
166
182
 
167
183
  export function assertOwnedAgentForDelete(name, dir, slot = SLOT.canonical) {
@@ -170,30 +186,61 @@ export function assertOwnedAgentForDelete(name, dir, slot = SLOT.canonical) {
170
186
  return outcome;
171
187
  }
172
188
 
173
- export function deleteNightlyConsolidation(name, dir) {
174
- const id = `${name}-nightly-consolidation`;
189
+ // Every Academy job the agent's kind registers, nightly first. Read before the
190
+ // directory moves, and read leniently: an unreadable kind must not exit inside
191
+ // the lifecycle lock, and only an Owner has jobs beyond the nightly.
192
+ export function academyJobIds(name, dir) {
193
+ const ids = kindJobIds(name, dir);
194
+ return readAgentYaml(dir).kind === 'owner' ? [...ids, ...ownerSessionJobIds(name)] : ids;
195
+ }
196
+
197
+ // The jobs Academy itself registers for the agent's kind, nightly first; the
198
+ // Owner's self-scheduled sessions are not among them.
199
+ export function kindJobIds(name, dir) {
200
+ const nightly = `${name}-nightly-consolidation`;
201
+ const { kind } = readAgentYaml(dir);
202
+ if (kind === 'owner') return [nightly, ...ownerJobIds(name)];
203
+ return kind === 'principal' ? [nightly, ...principalJobIds(name)] : [nightly];
204
+ }
205
+
206
+ // Each id is deleted by its exact (cwd, id) pair, so no other agent's job can
207
+ // match. Stops at the first failure and names it; `id` on success is the
208
+ // nightly, the published `unscheduledJobId`.
209
+ export function deleteAcademyJobs(dir, ids) {
175
210
  // Resolved inside the lock, so exiting here would strand the lock directory
176
211
  // and make every later delete of this agent unanswerable.
177
- let helmTasksBin;
212
+ let tldrAgentsBin;
178
213
  try {
179
- helmTasksBin = resolveExecutable('ACADEMY_HELM_TASKS_BIN', 'helm-tasks', MUST_EXIST);
214
+ tldrAgentsBin = resolveTldrAgentsBinary();
180
215
  } catch (error) {
181
216
  if (!(error instanceof RuntimeUnavailableError)) throw error;
182
- return { ok: false, id, code: error.code, reason: error.message };
217
+ return { ok: false, id: ids[0], code: error.code, reason: error.message };
183
218
  }
184
- const result = spawnSync(helmTasksBin, ['delete', '--cwd', dir, '--id', id], {
185
- cwd: dirname(dir),
186
- encoding: 'utf8',
187
- env: process.env,
188
- });
189
- if (result.error) return { ok: false, id, reason: result.error.message };
190
- if (result.status !== 0)
191
- return {
192
- ok: false,
193
- id,
194
- reason: helmFailureReason(result.stderr || result.stdout, result.status),
195
- };
196
- return { ok: true, id };
219
+ for (const id of ids) {
220
+ const failure = deleteJob(tldrAgentsBin, dir, id);
221
+ if (failure) return { ok: false, id, reason: failure };
222
+ }
223
+ return { ok: true, id: ids[0], ids };
224
+ }
225
+
226
+ function deleteJob(tldrAgentsBin, dir, id) {
227
+ const result = spawnSync(
228
+ tldrAgentsBin,
229
+ spawnTldrTasksArgs(['delete', '--cwd', dir, '--id', id]),
230
+ tldrSpawnOptions(dirname(dir)),
231
+ );
232
+ if (result.error || result.status !== 0) return helmFailureReason(result);
233
+ return null;
234
+ }
235
+
236
+ // The refusal `delete` and `archive` raise when a job of the set
237
+ // would not go.
238
+ export function unscheduleError(verb, name, unscheduled) {
239
+ return new DeleteAgentError(
240
+ unscheduled.code ?? 'unschedule_failed',
241
+ `Refusing to ${verb} "${name}" because unscheduling "${unscheduled.id}" failed: ${unscheduled.reason}`,
242
+ { name },
243
+ );
197
244
  }
198
245
 
199
246
  // The one failure path `delete`, `rename` and `archive` share. Each stated the
@@ -219,40 +266,38 @@ export function reportLifecycleFailure(error, verb, name, json) {
219
266
  }
220
267
 
221
268
  export function deleteAgent(name, json) {
222
- preflightOwnedAgent(name, json);
223
- const lockDir = agentLifecycleLockPath(agentDir(name));
269
+ const { id } = preflightOwnedAgent(name, json);
270
+ const lockDir = agentLifecycleLockPath(agentDir(id));
224
271
  try {
225
- withFileLock(lockDir, () => deleteAgentLocked(name, json));
272
+ withFileLock(lockDir, () => deleteAgentLocked(id, json));
226
273
  } catch (error) {
227
- reportLifecycleFailure(error, 'delete', name, json);
274
+ reportLifecycleFailure(error, 'delete', id, json);
228
275
  }
229
276
  }
230
277
 
231
- function deleteAgentLocked(name, _json) {
232
- const { dir, dirReal } = assertOwnedAgentForDelete(name, agentDir(name));
278
+ function deleteAgentLocked(id, _json) {
279
+ const { dir, dirReal } = assertOwnedAgentForDelete(id, agentDir(id));
280
+ const name = currentAgentName(dirReal);
281
+ const jobIds = academyJobIds(id, dirReal);
233
282
  const quarantine = join(
234
283
  resolve(AGENTS_ROOT),
235
- `.${name}.delete-quarantine.${process.pid}.${Date.now()}`,
284
+ `.${id}.delete-quarantine.${process.pid}.${Date.now()}`,
236
285
  );
237
286
  renameSync(dir, quarantine);
238
- assertOwnedAgentForDelete(name, quarantine, SLOT.contained);
287
+ assertOwnedAgentForDelete(id, quarantine, SLOT.contained);
239
288
 
240
- const unscheduled = deleteNightlyConsolidation(name, dirReal);
289
+ const unscheduled = deleteAcademyJobs(dirReal, jobIds);
241
290
  if (!unscheduled.ok) {
242
291
  if (!existsSync(dir)) {
243
292
  renameSync(quarantine, dir);
244
293
  } else {
245
294
  throw new DeleteAgentError(
246
295
  'unschedule_failed_restore_blocked',
247
- `Refusing to delete "${name}" because nightly unschedule failed and ${dir} is occupied: ${unscheduled.reason}`,
296
+ `Refusing to delete "${name}" because unscheduling "${unscheduled.id}" failed and ${dir} is occupied: ${unscheduled.reason}`,
248
297
  { name, quarantine },
249
298
  );
250
299
  }
251
- throw new DeleteAgentError(
252
- unscheduled.code ?? 'unschedule_failed',
253
- `Refusing to delete "${name}" because nightly unschedule failed: ${unscheduled.reason}`,
254
- { name },
255
- );
300
+ throw unscheduleError('delete', name, unscheduled);
256
301
  }
257
302
  rmSync(quarantine, { recursive: true, force: false });
258
303
  appendLifecycleEvent('agent_deleted', name, dir);
@@ -264,6 +309,7 @@ function deleteAgentLocked(name, _json) {
264
309
  name,
265
310
  dir: resolve(dir),
266
311
  unscheduledJobId: unscheduled.id,
312
+ unscheduledJobIds: unscheduled.ids,
267
313
  });
268
314
  return;
269
315
  }
@@ -1,8 +1,10 @@
1
1
  import { resolve } from 'node:path';
2
+ import { isatty } from 'node:tty';
2
3
  import { parseArgs, printUsage } from './args.mjs';
3
4
  import {
4
5
  ACADEMY_ROOT,
5
6
  AGENTS_ROOT,
7
+ CLI_NAME,
6
8
  CONTRACT_VERSION,
7
9
  contractOk,
8
10
  exitJsonError,
@@ -28,6 +30,8 @@ import { notesAdd, notesList } from './notes.mjs';
28
30
  import { renameAgent } from './rename.mjs';
29
31
  import { runAgent, runNightly } from './run.mjs';
30
32
  import { readSessions } from './sessions.mjs';
33
+ import { checkSkills } from './skills-check.mjs';
34
+ import { startAgent, stopAgent } from './start.mjs';
31
35
 
32
36
  const COMMAND_HANDLERS = {
33
37
  ...Object.fromEntries(UNIMPLEMENTED_COMMANDS.map((command) => [command, unimplementedCommand])),
@@ -36,7 +40,7 @@ const COMMAND_HANDLERS = {
36
40
  printUsage();
37
41
  process.exit(parsed.exitCode ?? 0);
38
42
  },
39
- create: (parsed) => createAgent(parsed.name, parsed.json),
43
+ create: createCommand,
40
44
  // `--spec` is the only thing that selects the headless form; without it the
41
45
  // interactive launch runs exactly as it always has.
42
46
  hire: (parsed) =>
@@ -45,6 +49,8 @@ const COMMAND_HANDLERS = {
45
49
  nightly: (parsed) => runNightly(parsed.name),
46
50
  events: (parsed) => readEvents(parsed),
47
51
  migrate: (parsed) => backfillOwnership(parsed),
52
+ start: (parsed) => startAgent(parsed),
53
+ stop: (parsed) => stopAgent(parsed.name, parsed.json),
48
54
  rename: (parsed) => renameAgent(parsed.name, parsed.newName, parsed.json, parsed.invalidOption),
49
55
  archive: (parsed) => archiveAgent(parsed.name, parsed.json),
50
56
  unarchive: (parsed) => unarchiveAgent(parsed.name, parsed.json),
@@ -53,6 +59,7 @@ const COMMAND_HANDLERS = {
53
59
  inspect: (parsed) => inspectAgent(parsed.name, parsed.json),
54
60
  tokens: (parsed) => printPromptTokens(parsed.name, parsed.json),
55
61
  budget: (parsed) => printSurfaceBudget(parsed.name, parsed.json),
62
+ skills: (parsed) => checkSkills(parsed),
56
63
  clean: (parsed) => cleanAgent(parsed.name),
57
64
  delete: (parsed) => deleteAgent(parsed.name, parsed.json),
58
65
  destroy: (parsed) => destroyAgent(parsed.name, parsed.force),
@@ -62,11 +69,34 @@ const COMMAND_HANDLERS = {
62
69
  else console.log(ACADEMY_ROOT);
63
70
  },
64
71
  notes: (parsed) => {
65
- if (parsed.action === 'add') notesAdd(parsed.name, parsed.text);
72
+ if (parsed.action === 'add') notesAdd(parsed.name, parsed.text, { user: parsed.user });
66
73
  else notesList(parsed.name, parsed.last);
67
74
  },
68
75
  };
69
76
 
77
+ const PRINCIPAL_PITCH = [
78
+ 'New Principal',
79
+ 'Your main agent. You direct the work; it does the work with you.',
80
+ 'It keeps its job, its memory, and your work between sessions.',
81
+ ];
82
+
83
+ // Only a person at a terminal has a session opened for them. Every other
84
+ // create (--json, a pipe, a script, another kind) is the plain create. No
85
+ // create asks a question: a missing name is the first free default name.
86
+ function createCommand(parsed) {
87
+ const atTerminal = isatty(0) && isatty(1);
88
+ const guided = parsed.kind === 'principal' && !parsed.json && !parsed.invalidOption && atTerminal;
89
+ if (!guided) return createAgent(parsed.name, parsed.json, parsed);
90
+ if (parsed.name === undefined) console.log(`${PRINCIPAL_PITCH.join('\n')}\n`);
91
+ const name = createAgent(parsed.name, false, {
92
+ ...parsed,
93
+ guided: parsed.noStart ? 'later' : 'opening',
94
+ });
95
+ if (parsed.name === undefined)
96
+ console.log(`Rename it any time: ${CLI_NAME} rename ${name} <new-name>`);
97
+ if (!parsed.noStart) runAgent(name, null, []);
98
+ }
99
+
70
100
  // `doctor` advertises the whole contract_version 1 command set, including the
71
101
  // commands later phases build. Answering those in the envelope is what keeps
72
102
  // the capability list honest: a client that calls what doctor named gets a
@@ -0,0 +1,124 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { statSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
4
+ import {
5
+ bridgeClaudeSettingsArgs,
6
+ bridgeEnvPresent,
7
+ bridgeHooksInUserSettings,
8
+ bridgeLaunch,
9
+ bridgeLauncher,
10
+ } from '../../hooks/memory_bridge.mjs';
11
+ import { ACADEMY_CLI_PATH, isInside } from './core.mjs';
12
+ import { childExitCode } from './exit-code.mjs';
13
+ import { readAgentYaml } from './yaml.mjs';
14
+
15
+ // Set on the relaunched run so it launches its provider directly instead of
16
+ // wrapping itself again. Cleared before the provider starts.
17
+ export const MEMORY_RELAUNCH_FLAG = 'ACADEMY_MEMORY_RELAUNCH';
18
+
19
+ function isDirectory(path) {
20
+ try {
21
+ return statSync(path).isDirectory();
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
26
+
27
+ // The agent home is not a memory workspace, so a run started there falls
28
+ // through to the workspace the agent declares, if any.
29
+ function memoryWorkspace(dir, cwd, env) {
30
+ const declared = readAgentYaml(dir).memory_workspace;
31
+ const candidates = [
32
+ env.ACADEMY_PROJECT_DIR,
33
+ isInside(cwd, dir) ? null : cwd,
34
+ declared ? resolve(dir, declared) : null,
35
+ ];
36
+ const found = candidates.find((path) => path && isDirectory(path));
37
+ return found ? resolve(found) : null;
38
+ }
39
+
40
+ /** Whether a run of the agent in `dir` from `cwd` launches with a memory workspace. */
41
+ export function resolveMemoryEnv(dir, { cwd, env }) {
42
+ if (bridgeEnvPresent(env)) return { status: 'on', reason: 'inherited', workspace: null };
43
+ const launcher = bridgeLauncher(env);
44
+ if (!launcher) return { status: 'off', reason: 'wrapper_missing', workspace: null };
45
+ const workspace = memoryWorkspace(dir, cwd, env);
46
+ if (!workspace) return { status: 'off', reason: 'no_memory_workspace', workspace: null };
47
+ return { status: 'on', reason: null, workspace, launcher };
48
+ }
49
+
50
+ /** The receipt form of a decision: no launcher internals. */
51
+ export function memoryEnvRecord({ status, reason, workspace }) {
52
+ return { status, reason, workspace };
53
+ }
54
+
55
+ function logMemoryEnv(name, decision) {
56
+ const event = decision.status === 'on' ? 'memory.env_on' : 'memory.env_off';
57
+ const params =
58
+ decision.status === 'on'
59
+ ? {
60
+ agent: name,
61
+ workspace: decision.workspace,
62
+ source: decision.reason ?? 'resolved',
63
+ }
64
+ : { agent: name, reason: decision.reason };
65
+ console.error(JSON.stringify({ event, params }));
66
+ }
67
+
68
+ // The client's settings load only when the user's own Claude settings do not
69
+ // already run its hooks: both sets would fire on every turn otherwise.
70
+ function claudeSettingsArgs({ name, runtime, passthrough, launcher }) {
71
+ if (runtime === 'codex' || passthrough.includes('--settings')) return [];
72
+ const source = bridgeHooksInUserSettings(process.env) ? 'global' : 'injected';
73
+ console.error(JSON.stringify({ event: 'memory.settings', params: { agent: name, source } }));
74
+ return source === 'global' ? [] : bridgeClaudeSettingsArgs(launcher);
75
+ }
76
+
77
+ function relaunchArgs({ name, requestedRuntime, runtime, passthrough, launcher }) {
78
+ const providerArgs = [
79
+ ...claudeSettingsArgs({ name, runtime, passthrough, launcher }),
80
+ ...passthrough,
81
+ ];
82
+ return [
83
+ ACADEMY_CLI_PATH,
84
+ 'run',
85
+ name,
86
+ ...(requestedRuntime ? ['--agent', requestedRuntime] : []),
87
+ ...(providerArgs.length > 0 ? ['--', ...providerArgs] : []),
88
+ ];
89
+ }
90
+
91
+ // Re-runs this same `academy run` under the memory wrapper, so the provider,
92
+ // its hooks, and any runtime context all see the resolved memory env. Returns
93
+ // only when the run should launch directly: memory resolved nothing, the env
94
+ // was inherited, or the wrapper could not start. A user's run always launches.
95
+ export function relaunchWithMemoryEnv(run) {
96
+ if (process.env[MEMORY_RELAUNCH_FLAG] === '1') return;
97
+ const decision = resolveMemoryEnv(run.dir, {
98
+ cwd: process.cwd(),
99
+ env: process.env,
100
+ });
101
+ if (!decision.launcher) {
102
+ logMemoryEnv(run.name, decision);
103
+ return;
104
+ }
105
+ const launch = bridgeLaunch(decision.launcher, {
106
+ command: process.execPath,
107
+ args: relaunchArgs({ ...run, launcher: decision.launcher }),
108
+ env: { ...process.env, [MEMORY_RELAUNCH_FLAG]: '1' },
109
+ workspaceCwd: decision.workspace,
110
+ });
111
+ logMemoryEnv(run.name, decision);
112
+ const result = spawnSync(launch.command, launch.args, {
113
+ stdio: 'inherit',
114
+ env: launch.env,
115
+ });
116
+ if (result.error) {
117
+ logMemoryEnv(run.name, {
118
+ status: 'off',
119
+ reason: `wrapper_failed: ${result.error.code}`,
120
+ });
121
+ return;
122
+ }
123
+ process.exit(childExitCode(result));
124
+ }
@@ -59,27 +59,33 @@ function markerFault(dirReal) {
59
59
  return null;
60
60
  }
61
61
 
62
+ function refusal(name, dir, reason) {
63
+ return { refused: { name, dir, reason } };
64
+ }
65
+
66
+ function holdsAgent(dirReal) {
67
+ return statSync(dirReal).isDirectory() && existsSync(join(dirReal, 'agent.yaml'));
68
+ }
69
+
62
70
  function classify(root, rootReal, name) {
63
71
  const dir = join(root, name);
64
72
  let dirReal;
65
73
  try {
66
74
  dirReal = realpathSync(dir);
67
75
  } catch (error) {
68
- return { refused: { name, dir, reason: `cannot be resolved: ${error.message}` } };
76
+ return refusal(name, dir, `cannot be resolved: ${error.message}`);
69
77
  }
70
78
  const fault = slotFault(dirReal, rootReal, name);
71
- if (fault) return { refused: { name, dir, reason: fault } };
79
+ if (fault) return refusal(name, dir, fault);
72
80
  // Not an agent directory, so not a repair. Marking a directory holding no
73
81
  // agent.yaml would create an agent, which the backfill must never do.
74
- if (!statSync(dirReal).isDirectory()) return {};
75
- if (!existsSync(join(dirReal, 'agent.yaml'))) return {};
82
+ if (!holdsAgent(dirReal)) return {};
76
83
  const marker = markerFault(dirReal);
77
- if (marker) return { refused: { name, dir, reason: marker } };
78
- if (ownershipFault(name, dirReal) === null) return {};
84
+ if (marker) return refusal(name, dir, marker);
79
85
  // dirReal is carried, not recomputed at the write: joining the unvalidated
80
86
  // path again would traverse the same symlink chain a second time and land
81
87
  // somewhere the classification never proved.
82
- return { repair: { name, dir, dirReal } };
88
+ return ownershipFault(name, dirReal) === null ? {} : { repair: { name, dir, dirReal } };
83
89
  }
84
90
 
85
91
  // The one enumeration. `migrate` repairs exactly this set and `doctor` counts
@@ -0,0 +1,13 @@
1
+ import { CLI_NAME } from './core.mjs';
2
+
3
+ // What `create` prints under "Next:". A new Owner is a draft: it starts once its mandate is signed.
4
+ export function nextSteps(name, kind) {
5
+ const hire = ` ${CLI_NAME} hire # interactive hire flow to populate the boot surfaces`;
6
+ if (kind !== 'owner') {
7
+ return [hire, ` ${CLI_NAME} run ${name} # launch Claude Code in the agent's home`];
8
+ }
9
+ return [
10
+ ` ${CLI_NAME} start ${name} --mandate <file> # sign the mandate and start the draft`,
11
+ ` ${CLI_NAME} hire # drafts the mandate with you`,
12
+ ];
13
+ }