@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
@@ -1,4 +1,8 @@
1
- import { CLI_NAME, NAME_RE, RUNTIMES } from './core.mjs';
1
+ import { parseCreateArgs, parseSkillsArgs } from './args-kinds.mjs';
2
+ import { parseNotesArgs } from './args-notes.mjs';
3
+ import { parseRenameArgs, parseStartArgs } from './args-owner.mjs';
4
+ import { parseRunArgs } from './args-run.mjs';
5
+ import { CLI_NAME } from './core.mjs';
2
6
  import { UNIMPLEMENTED_COMMANDS } from './doctor.mjs';
3
7
 
4
8
  const COMMAND_PARSERS = {
@@ -6,7 +10,7 @@ const COMMAND_PARSERS = {
6
10
  // dispatch can answer them in the envelope instead of a usage dump.
7
11
  ...Object.fromEntries(UNIMPLEMENTED_COMMANDS.map((command) => [command, jsonOnlyCommand])),
8
12
  doctor: (command, rest) => jsonOnlyCommand(command, rest),
9
- create: (command, rest) => namedJsonCommand(command, rest),
13
+ create: (command, rest) => parseCreateArgs(command, rest),
10
14
  hire: (command, rest) => parseHireArgs(command, rest),
11
15
  run: (_command, rest) => parseRunArgs(rest),
12
16
  nightly: (_command, rest) => {
@@ -15,6 +19,8 @@ const COMMAND_PARSERS = {
15
19
  },
16
20
  events: (command, rest) => parseEventsArgs(command, rest),
17
21
  migrate: (command, rest) => parseMigrateArgs(command, rest),
22
+ start: (command, rest) => parseStartArgs(command, rest),
23
+ stop: (command, rest) => namedJsonCommand(command, rest),
18
24
  rename: (command, rest) => parseRenameArgs(command, rest),
19
25
  archive: (command, rest) => namedJsonCommand(command, rest),
20
26
  unarchive: (command, rest) => namedJsonCommand(command, rest),
@@ -25,9 +31,14 @@ const COMMAND_PARSERS = {
25
31
  budget: (command, rest) => namedJsonCommand(command, rest),
26
32
  clean: (command, rest) => ({ command, name: rest[0] }),
27
33
  delete: (command, rest) => namedJsonCommand(command, rest),
28
- destroy: (command, rest) => ({ command, name: rest[0], force: rest.includes('--force') }),
34
+ destroy: (command, rest) => ({
35
+ command,
36
+ name: rest[0],
37
+ force: rest.includes('--force'),
38
+ }),
29
39
  root: (command, rest) => jsonOnlyCommand(command, rest),
30
40
  notes: (_command, rest) => parseNotesArgs(rest),
41
+ skills: (command, rest) => parseSkillsArgs(command, rest),
31
42
  };
32
43
 
33
44
  export function parseArgs(argv) {
@@ -48,7 +59,11 @@ function jsonOnlyCommand(command, rest) {
48
59
  }
49
60
 
50
61
  function namedJsonCommand(command, rest) {
51
- return { command, name: rest.find((arg) => arg !== '--json'), json: hasFlag(rest, '--json') };
62
+ return {
63
+ command,
64
+ name: rest.find((arg) => arg !== '--json'),
65
+ json: hasFlag(rest, '--json'),
66
+ };
52
67
  }
53
68
 
54
69
  // `hire [--spec <path>] [--json] [-- ...]`. `--spec` selects the headless form
@@ -69,44 +84,13 @@ function parseHireArgs(command, rest) {
69
84
  else if (arg.startsWith('--spec=')) spec = arg.slice('--spec='.length);
70
85
  else if (arg.startsWith('--')) invalidOption ??= arg;
71
86
  }
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 };
87
+ return {
88
+ command,
89
+ json,
90
+ spec,
91
+ invalidOption,
92
+ passthrough: extractPassthrough(rest),
93
+ };
110
94
  }
111
95
 
112
96
  // `events --since <seq> [--logid <id>] [--json]`. `--since` stays a raw string:
@@ -160,71 +144,20 @@ function parseSessionsArgs(command, rest) {
160
144
  return { command, json, agent };
161
145
  }
162
146
 
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
147
  function extractPassthrough(rest) {
178
148
  const dashIdx = rest.indexOf('--');
179
149
  return dashIdx >= 0 ? rest.slice(dashIdx + 1) : [];
180
150
  }
181
151
 
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
- `
152
+ // A module constant, so the command list can grow without growing a function.
153
+ const USAGE = `
223
154
  Usage: ${CLI_NAME} <command> [options]
224
155
 
225
156
  Commands:
226
157
  doctor [--json] Report Academy's version, roots, runtimes, and capability
227
- create <name> [--json] Scaffold a new portable agent at ~/.academy/agents/<name>/
158
+ create [<name>] [--kind specialist|principal|owner] [--no-start] [--json]
159
+ Scaffold a portable agent at ~/.academy/agents/<name>/ (default
160
+ specialist); a terminal Principal opens its first session
228
161
  hire Interactive hire flow — produces 8 boot files via Claude Code
229
162
  hire --spec <path> [--json]
230
163
  Headless hire from a JSON specification file
@@ -243,8 +176,13 @@ Commands:
243
176
  List sessions whose agent directory is inside
244
177
  AGENTS_ROOT
245
178
  rename <old> <new> [--json]
246
- Move an agent to a new name, rewriting its ownership
247
- marker and re-registering its nightly job
179
+ Change an agent's name: the name: field and the seeded
180
+ name lines. No folder moves and no job changes; safe
181
+ any time, also mid-session
182
+ start <name> [--mandate <file>] [--hours <h>] [--contact <c>] [--trust 1-4] [--json]
183
+ Start an agent and register its jobs; an Owner needs a
184
+ signed mandate and gets one first daily run
185
+ stop <name> [--json] Stop an agent and delete its jobs; its files stay
248
186
  archive <name> [--json] Move an agent into the archived holding area
249
187
  unarchive <name> [--json]
250
188
  Restore an archived agent to its canonical slot
@@ -252,15 +190,16 @@ Commands:
252
190
  inspect <name> Inspect one agent
253
191
  tokens <name> Estimate generated prompt tokens by surface
254
192
  budget <name> Check prompt token budget by surface
255
- clean <name> Truncate transient surfaces (notes.md, threads.md)
193
+ clean <name> Reset notes.md (work.md is kept)
256
194
  destroy <name> --force Remove an agent and all its files
257
195
  root Print Academy package root
258
- notes add [<agent>] "…" Append a short note to the agent's notes.md
196
+ notes add [<agent>] [--user] "…" Append a note to notes.md; --user proposes it in user.md
259
197
  notes list [<agent>] [--last N] Show recent notes (default last 12)
260
198
 
261
199
  Examples:
262
200
  ${CLI_NAME} doctor --json
263
201
  ${CLI_NAME} create kai
202
+ ${CLI_NAME} create ada --kind principal --json
264
203
  ${CLI_NAME} hire
265
204
  ${CLI_NAME} hire --spec ./kai.json --json
266
205
  ${CLI_NAME} run kai
@@ -271,10 +210,14 @@ Examples:
271
210
  ${CLI_NAME} migrate --dry-run --json
272
211
  ${CLI_NAME} sessions --agent kai --json
273
212
  ${CLI_NAME} rename kai nova --json
213
+ ${CLI_NAME} start owner-one --mandate ./mandate.md --trust 3 --json
214
+ ${CLI_NAME} stop owner-one --json
274
215
  ${CLI_NAME} notes add "User prefers short status updates before edits"
275
216
  ${CLI_NAME} notes list --last 20
276
- `.trim(),
277
- );
217
+ `.trim();
218
+
219
+ export function printUsage() {
220
+ console.log(USAGE);
278
221
  }
279
222
 
280
223
  // ─────────────────────────────────────────────────────────────────────────────
@@ -0,0 +1,57 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { join } from 'node:path';
3
+ import { ACADEMY_HOME, resolveExecutable } from './core.mjs';
4
+
5
+ // Agents track work in Beads (`bd`). Academy never wraps it: skills call
6
+ // `bd … --json` directly, so Academy only pins the version range, points every
7
+ // agent at the one user database, and turns telemetry off.
8
+
9
+ const BEADS_VERSION_RANGE = '>=1.3.1 <1.4';
10
+ const RANGE_FLOOR = [1, 3, 1];
11
+ const RANGE_CEILING = [1, 4, 0];
12
+
13
+ /**
14
+ * The env a run adds: the one Beads database of the user (embedded, shared by
15
+ * every agent), no telemetry, and, for a session, its own actor. Beads refuses
16
+ * a second actor's claim on an item another holds, so two sessions can never
17
+ * work the same item, and the actor's pid tells the heartbeat whether the
18
+ * holder is still running.
19
+ */
20
+ export function beadsEnv(actor) {
21
+ return {
22
+ BEADS_DIR: join(ACADEMY_HOME, '.beads'),
23
+ OTEL_SDK_DISABLED: 'true',
24
+ ...(actor ? { BEADS_ACTOR: actor } : {}),
25
+ };
26
+ }
27
+
28
+ function compareVersions(left, right) {
29
+ for (let index = 0; index < 3; index += 1) {
30
+ if (left[index] !== right[index]) return left[index] - right[index];
31
+ }
32
+ return 0;
33
+ }
34
+
35
+ function inPinnedRange(version) {
36
+ const parts = version.split('.').map(Number);
37
+ return compareVersions(parts, RANGE_FLOOR) >= 0 && compareVersions(parts, RANGE_CEILING) < 0;
38
+ }
39
+
40
+ // A probe, like doctor's runtime check: it never exits and never throws. An
41
+ // output it cannot read is reported out of range, because the pin cannot be
42
+ // confirmed.
43
+ export function beadsStatus() {
44
+ let bin;
45
+ try {
46
+ bin = resolveExecutable('ACADEMY_BD_BIN', 'bd', { throwOnMissing: true });
47
+ } catch {
48
+ return { status: 'missing', version: null, range: BEADS_VERSION_RANGE };
49
+ }
50
+ const probe = spawnSync(bin, ['version'], { encoding: 'utf8', timeout: 2000 });
51
+ if (probe.error || probe.status !== 0) {
52
+ return { status: 'missing', version: null, range: BEADS_VERSION_RANGE };
53
+ }
54
+ const version = /\b(\d+\.\d+\.\d+)\b/.exec(probe.stdout)?.[1] ?? null;
55
+ const status = version && inPinnedRange(version) ? 'ok' : 'out_of_range';
56
+ return { status, version, range: BEADS_VERSION_RANGE };
57
+ }
@@ -2,9 +2,13 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node
2
2
  import { homedir } from 'node:os';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { spawnSync } from 'node:child_process';
5
- import { ACADEMY_ROOT, memoryBridgeEnabled, resolveExecutable, shellSingleQuote } from './core.mjs';
5
+ import { ACADEMY_ROOT, resolveExecutable, shellSingleQuote } from './core.mjs';
6
+ import { childExitCode } from './exit-code.mjs';
6
7
  import { MEMORY_BRIDGE_ENV_KEYS } from '../../hooks/memory_bridge.mjs';
7
8
 
9
+ const ROQUE_POST_TOOL_USE_TAG = 'academy-roque-post-tool-use-v1';
10
+ const ROQUE_COMPACT_TAG = 'academy-roque-compact-v1';
11
+
8
12
  function codexHome() {
9
13
  return process.env.CODEX_HOME || join(homedir(), '.codex');
10
14
  }
@@ -30,6 +34,15 @@ function academyHookCommand(scriptName, contextPath) {
30
34
  ].join(' ');
31
35
  }
32
36
 
37
+ // Only a kind that renders the curator agent may point Codex at its config.
38
+ function knowledgeCuratorProfileSection(configPath) {
39
+ return `
40
+ [agents.knowledge_curator]
41
+ description = "Apply evidence-gated note graduations during Academy nightly consolidation."
42
+ config_file = ${tomlString(configPath)}
43
+ `;
44
+ }
45
+
33
46
  export function writeCodexProfile(dir, name) {
34
47
  const profileName = codexProfileName(name);
35
48
  const profilePath = join(codexHome(), `${profileName}.config.toml`);
@@ -44,11 +57,7 @@ writable_roots = [${tomlString(dir)}]
44
57
 
45
58
  [features]
46
59
  hooks = true
47
-
48
- [agents.knowledge_curator]
49
- description = "Apply evidence-gated note graduations during Academy nightly consolidation."
50
- config_file = ${tomlString(knowledgeCuratorConfigPath)}
51
- `;
60
+ ${existsSync(knowledgeCuratorConfigPath) ? knowledgeCuratorProfileSection(knowledgeCuratorConfigPath) : ''}`;
52
61
  writeFileSync(profilePath, toml);
53
62
  return { profileName, profilePath };
54
63
  }
@@ -139,19 +148,55 @@ export function writeCodexHooks(contextPath) {
139
148
 
140
149
  const sessionStartIndex = upsertCodexHookGroup(config.hooks.SessionStart, sessionStartGroup);
141
150
  const stopIndex = upsertCodexHookGroup(config.hooks.Stop, stopGroup);
151
+ config.hooks.PostToolUse = Array.isArray(config.hooks.PostToolUse)
152
+ ? config.hooks.PostToolUse
153
+ : [];
154
+ const roquePostToolUseIndex = upsertCodexHookGroup(
155
+ config.hooks.PostToolUse,
156
+ roqueGroup(ROQUE_POST_TOOL_USE_TAG),
157
+ ROQUE_POST_TOOL_USE_TAG,
158
+ );
159
+ const roqueCompactIndex = upsertCodexHookGroup(
160
+ config.hooks.SessionStart,
161
+ roqueGroup(ROQUE_COMPACT_TAG, 'compact'),
162
+ ROQUE_COMPACT_TAG,
163
+ );
142
164
 
143
165
  mkdirSync(dirname(hooksPath), { recursive: true });
144
166
  writeFileSync(hooksPath, JSON.stringify(config, null, 2) + '\n');
145
167
  writeCodexHookState([
146
168
  `${hooksPath}:session_start:${sessionStartIndex}:0`,
147
169
  `${hooksPath}:stop:${stopIndex}:0`,
170
+ `${hooksPath}:post_tool_use:${roquePostToolUseIndex}:0`,
171
+ `${hooksPath}:session_start:${roqueCompactIndex}:0`,
148
172
  ]);
149
173
  return hooksPath;
150
174
  }
151
175
 
152
- function upsertCodexHookGroup(groups, nextGroup) {
176
+ // The guard keeps a checkout without hooks/roque from failing Codex tool calls.
177
+ function roqueCommand() {
178
+ const bundle = shellSingleQuote(join(ACADEMY_ROOT, 'hooks', 'roque', 'roque.js'));
179
+ return `[ ! -f ${bundle} ] || node ${bundle} --tool codex`;
180
+ }
181
+
182
+ // One hook per group: Codex trust is keyed by group index plus hook index :0.
183
+ function roqueGroup(tag, matcher) {
184
+ return {
185
+ ...(matcher ? { matcher } : {}),
186
+ hooks: [
187
+ {
188
+ _academy: tag,
189
+ type: 'command',
190
+ command: roqueCommand(),
191
+ timeout: 10,
192
+ },
193
+ ],
194
+ };
195
+ }
196
+
197
+ function upsertCodexHookGroup(groups, nextGroup, tag = 'academy-runtime-hooks-v1') {
153
198
  const index = groups.findIndex((group) => {
154
- return (group?.hooks || []).some((hook) => hook?._academy === 'academy-runtime-hooks-v1');
199
+ return (group?.hooks || []).some((hook) => hook?._academy === tag);
155
200
  });
156
201
  if (index >= 0) {
157
202
  groups[index] = nextGroup;
@@ -163,14 +208,15 @@ function upsertCodexHookGroup(groups, nextGroup) {
163
208
 
164
209
  function writeCodexRuntimeContext(env, contextPath) {
165
210
  // The Codex sandbox strips the ambient environment, so forward Academy's own
166
- // keys — plus whatever the memory bridge declares it needs, but only when the
167
- // bridge is switched on.
211
+ // keys — plus whatever the memory bridge declares it needs. The nightly flag
212
+ // must reach the Stop hook, or a nightly re-arms its own pending marker.
168
213
  const keys = [
169
214
  'ACADEMY_AGENT_DIR',
170
215
  'ACADEMY_AGENT_NAME',
216
+ 'ACADEMY_NIGHTLY_RUN',
171
217
  'ACADEMY_PROJECT_DIR',
172
218
  'HOME',
173
- ...(memoryBridgeEnabled() ? MEMORY_BRIDGE_ENV_KEYS : []),
219
+ ...MEMORY_BRIDGE_ENV_KEYS,
174
220
  ];
175
221
  const context = {
176
222
  expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
@@ -180,6 +226,14 @@ function writeCodexRuntimeContext(env, contextPath) {
180
226
  writeFileSync(contextPath, JSON.stringify(context, null, 2) + '\n');
181
227
  }
182
228
 
229
+ // `codex exec` refuses a folder that is not a git repository unless told
230
+ // otherwise, and an agent home is not one. The flag belongs right after `exec`.
231
+ export function withRepoCheck(argv) {
232
+ const exec = argv.indexOf('exec');
233
+ if (exec === -1 || argv.slice(exec + 1).includes('--skip-git-repo-check')) return argv;
234
+ return [...argv.slice(0, exec + 1), '--skip-git-repo-check', ...argv.slice(exec + 1)];
235
+ }
236
+
183
237
  export function launchCodex(
184
238
  args,
185
239
  { cwd, env, message, profileName, profilePath, runtimeContextPath },
@@ -208,7 +262,7 @@ export function launchCodex(
208
262
  );
209
263
  process.exit(1);
210
264
  }
211
- process.exit(result.status ?? 0);
265
+ process.exit(childExitCode(result));
212
266
  }
213
267
 
214
268
  // ─────────────────────────────────────────────────────────────────────────────
@@ -13,8 +13,11 @@ import {
13
13
  } from 'node:fs';
14
14
  import { homedir } from 'node:os';
15
15
  import { basename, dirname, join, resolve } from 'node:path';
16
+ import { childExitCode } from './exit-code.mjs';
16
17
  import { spawnSync } from 'node:child_process';
17
18
  import { fileURLToPath } from 'node:url';
19
+ import { readAgentYaml } from './yaml.mjs';
20
+ import { agentIdOf } from './agent-id.mjs';
18
21
 
19
22
  const __filename = fileURLToPath(import.meta.url);
20
23
  const __dirname = dirname(__filename);
@@ -28,6 +31,11 @@ const DEFAULT_AGENTS_ROOT = join(homedir(), '.academy', 'agents');
28
31
  const NAMED_AGENTS_ROOT = process.env.AGENTS_ROOT;
29
32
  export const AGENTS_ROOT = NAMED_AGENTS_ROOT || DEFAULT_AGENTS_ROOT;
30
33
 
34
+ /** Shared home for files no one agent owns: user.md, user-work.md, proposals/. */
35
+ // Resolved, because every kind now reports shared surface paths and a relative
36
+ // AGENTS_ROOT would otherwise leave `user.md` relative.
37
+ export const ACADEMY_HOME = resolve(process.env.ACADEMY_HOME || dirname(AGENTS_ROOT));
38
+
31
39
  // Whether the root is Academy's own or one an operator named. The two states
32
40
  // disagree about exactly one thing — a missing parent — and about nothing else.
33
41
  const AGENTS_ROOT_IS_DEFAULT = !NAMED_AGENTS_ROOT;
@@ -38,42 +46,23 @@ export const CLI_NAME = process.env.ACADEMY_CLI_NAME?.trim() || 'academy';
38
46
  /** Allowed agent name pattern: kebab-case, 1–32 chars. */
39
47
  export const NAME_RE = /^[a-z][a-z0-9-]{0,31}$/;
40
48
 
41
- export const SURFACES = [
42
- 'identity',
43
- 'role',
44
- 'knowledge',
45
- 'goals',
46
- 'priorities',
47
- 'threads',
48
- 'notes',
49
- 'dailys',
50
- ];
51
- export const SURFACE_CAPS = {
52
- identity: 400,
53
- role: 400,
54
- knowledge: 2500,
55
- goals: 150,
56
- priorities: 250,
57
- threads: 1750,
58
- notes: 500,
59
- dailys: 1050,
60
- };
61
- export const TOTAL_SURFACE_CAP = Object.values(SURFACE_CAPS).reduce((sum, cap) => sum + cap, 0);
62
- export const ENFORCED_SURFACES = new Set(['knowledge', 'priorities', 'threads', 'notes', 'dailys']);
63
49
  export const UNIVERSAL_SKILLS = [
64
50
  'check-in',
65
- 'self-update',
51
+ 'work-graph',
66
52
  'nightly-consolidation',
67
53
  'knowledge-curation',
54
+ 'local-rules',
55
+ 'skill-method',
68
56
  ];
69
57
  export const NIGHTLY_JOB_CRON = '0 22 * * *';
70
- export const SCHEDULED_CLAUDE_PERMISSION_ARGS = ['--permission-mode', 'auto'];
58
+ // Unattended runs bypass permissions: auto mode refused every memory write
59
+ // with "no verdict", so nightly runs could not save their work.
60
+ export const SCHEDULED_CLAUDE_PERMISSION_ARGS = ['--permission-mode', 'bypassPermissions'];
71
61
  export const SCHEDULED_CODEX_PERMISSION_ARGS = [
72
- '--ask-for-approval',
73
- 'never',
74
- '--sandbox',
75
- 'workspace-write',
62
+ '--dangerously-bypass-approvals-and-sandbox',
76
63
  'exec',
64
+ // An agent home is not a git repository; without this, exec refuses to run.
65
+ '--skip-git-repo-check',
77
66
  ];
78
67
  export const ACADEMY_SYSTEM_PROMPT = 'academy-system-prompt.md';
79
68
  export const RUNTIMES = new Set(['claude-code', 'codex']);
@@ -99,9 +88,10 @@ function invalidName(message, name, json) {
99
88
  process.exit(1);
100
89
  }
101
90
 
102
- export function agentDir(name) {
103
- return join(AGENTS_ROOT, name);
104
- }
91
+ // The folder name is an agent's fixed id; agent.yaml `name:` is its current
92
+ // name, so a name change never moves a running session's directory. Every
93
+ // command accepts either. A folder answers first; YAML is read only on a miss.
94
+ export const agentDir = (nameOrId) => join(AGENTS_ROOT, agentIdOf(nameOrId));
105
95
 
106
96
  // The holding area — `.archived`, its containment rule and every command's
107
97
  // question about it — lives in archived.mjs, which imports from here.
@@ -115,6 +105,97 @@ export function agentLifecycleLockPath(dir) {
115
105
  return join(dirname(resolved), `.${basename(resolved)}.lifecycle.lock`);
116
106
  }
117
107
 
108
+ // ─────────────────────────────────────────────────────────────────────────────
109
+ // Kind registry
110
+ // ─────────────────────────────────────────────────────────────────────────────
111
+
112
+ // The registry is the one surface and skill source for compile (scaffold),
113
+ // measurement (inspect, tokens, budget), skills and create. Its contract:
114
+ //
115
+ // KIND_PROFILES[kind] = {
116
+ // surfaces: [{ name, path: (dir) => absolutePath, cap, enforced }],
117
+ // skills: [skillName],
118
+ // }
119
+ //
120
+ // `surfaces` is ordered, and the order is the compile order. `path` takes the
121
+ // agent dir; shared surfaces ignore it and resolve under ACADEMY_HOME. `cap` is
122
+ // an estimated-token cap; a kind's total cap is the sum of its surface caps.
123
+ // `skills` lists only names whose templates exist — a later name enters this
124
+ // list together with its template, because rendering throws on a missing one.
125
+ // Each kind owns its own copy of the list, so a pack skill added to one kind
126
+ // never reaches another. A name renders from `templates/packs/<kind>/<name>`
127
+ // when that exists, else from `templates/skills/<name>`.
128
+ // `surfacesFor(dir)` is the per-agent view with `path` already resolved.
129
+
130
+ // A shared surface lives under ACADEMY_HOME, not in the agent dir.
131
+ const surface = (name, cap, enforced, { shared = false } = {}) => ({
132
+ name,
133
+ path: (dir) => join(shared ? ACADEMY_HOME : dir, `${name}.md`),
134
+ cap,
135
+ enforced,
136
+ });
137
+
138
+ // Every kind compiles the same nine surfaces, in this order: the agent's own
139
+ // seven, then the user's two. Only the cap of `work` differs by kind: a
140
+ // Principal's work.md is thin (only work unique to it); an Owner's is full.
141
+ const surfaceSet = (workCap) => [
142
+ surface('identity', 400, false),
143
+ surface('craft', 600, false),
144
+ surface('mandate', 600, true),
145
+ surface('work', workCap, true),
146
+ surface('knowledge', 2500, true),
147
+ surface('notes', 500, true),
148
+ surface('local-rules', 500, true),
149
+ surface('user', 1000, true, { shared: true }),
150
+ surface('user-work', 1000, true, { shared: true }),
151
+ ];
152
+
153
+ const OWNER_PACK = [
154
+ 'owner',
155
+ 'owner-work',
156
+ 'owner-review',
157
+ 'owner-heartbeat',
158
+ 'owner-feedback',
159
+ 'owner-nightly',
160
+ ];
161
+
162
+ export const KIND_PROFILES = {
163
+ // The curator is an ordinary Specialist, so `user-nightly` is the one skill a
164
+ // Specialist has that no other kind has.
165
+ specialist: { surfaces: surfaceSet(1000), skills: [...UNIVERSAL_SKILLS, 'user-nightly'] },
166
+ principal: {
167
+ surfaces: surfaceSet(500),
168
+ skills: [
169
+ 'principal-nightly',
170
+ 'principal-brief',
171
+ 'principal-intro',
172
+ 'work-graph',
173
+ 'check-in',
174
+ 'knowledge-curation',
175
+ 'local-rules',
176
+ 'skill-method',
177
+ ],
178
+ },
179
+ owner: { surfaces: surfaceSet(1000), skills: [...OWNER_PACK, ...UNIVERSAL_SKILLS] },
180
+ };
181
+
182
+ // A missing `kind:` is a Specialist: every agent created before kinds. An
183
+ // unknown one is raised, not defaulted, the way the runtime is: compiling it as
184
+ // a Specialist would hand the agent the wrong prompt.
185
+ export function kindOf(dir) {
186
+ const kind = readAgentYaml(dir).kind || 'specialist';
187
+ if (Object.hasOwn(KIND_PROFILES, kind)) return kind;
188
+
189
+ const message = `Agent kind "${kind}" is not a kind Academy supports. Use ${Object.keys(KIND_PROFILES).join(', ')}.`;
190
+ if (jsonMode()) exitJsonError('invalid_spec', message, { kind, dir: resolve(dir) });
191
+ console.error(`Error: ${message}`);
192
+ process.exit(1);
193
+ }
194
+
195
+ export function surfacesFor(dir) {
196
+ return KIND_PROFILES[kindOf(dir)].surfaces.map((entry) => ({ ...entry, path: entry.path(dir) }));
197
+ }
198
+
118
199
  // The read-only root audit, as an answer rather than an exit. `doctor` reports
119
200
  // on a root it cannot use and must never exit from inside a probe, so the
120
201
  // finding and the reaction to it live apart. It never creates the root: `list`
@@ -166,11 +247,12 @@ export function checkAgentsRoot(json = jsonMode()) {
166
247
  }
167
248
 
168
249
  // The default root creates its own parent, because on a first run there is none
169
- // and nothing else will make it. A named root does not: its parent was proved
170
- // to exist by the audit above, so one level is all this ever has to create.
250
+ // and nothing else will make it. A named root's parent was proved to exist by
251
+ // the audit above, so for it `recursive` still creates one level. It is set for
252
+ // both because without it two first creates race on the mkdir and one fails.
171
253
  export function validateAgentsRoot(json = jsonMode()) {
172
254
  const { root, exists } = checkAgentsRoot(json);
173
- if (!exists) mkdirSync(root, { recursive: AGENTS_ROOT_IS_DEFAULT });
255
+ if (!exists) mkdirSync(root, { recursive: true });
174
256
  return root;
175
257
  }
176
258
 
@@ -246,12 +328,6 @@ export function exitJsonError(code, message, fields = {}) {
246
328
  contractError(activeCommandName(), code, message, fields);
247
329
  }
248
330
 
249
- // The memory bridge is Academy's one client-specific surface, and it is opt-in.
250
- // Unset, a scaffolded agent names no client at all.
251
- export function memoryBridgeEnabled() {
252
- return process.env.ACADEMY_MEMORY_BRIDGE === '1';
253
- }
254
-
255
331
  export function isSymlink(path) {
256
332
  try {
257
333
  return lstatSync(path).isSymbolicLink();
@@ -334,7 +410,7 @@ export function delegateLegacyRun(name, passthrough, legacyRoot) {
334
410
  cwd: process.cwd(),
335
411
  env: process.env,
336
412
  });
337
- process.exit(result.status ?? 0);
413
+ process.exit(childExitCode(result));
338
414
  }
339
415
 
340
416
  export function isInside(childPath, parentPath) {