@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
@@ -0,0 +1,201 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { homedir } from 'node:os';
3
+ import { join, resolve } from 'node:path';
4
+ import { isDraftOwner } from './agent-gate.mjs';
5
+ import { AGENTS_ROOT, RuntimeUnavailableError, kindOf } from './core.mjs';
6
+ import { localTimezone, runSchedule } from './create.mjs';
7
+ import { ownerConfig, ownerDailyCron, ownerHeartbeatCron } from './owner-config.mjs';
8
+ import { runtimeProviderOrNull } from './runtime.mjs';
9
+ import { resolveTldrAgentsBinary, spawnTldrTasksArgs, tldrSpawnOptions } from './tldr.mjs';
10
+
11
+ // An Owner's two Academy jobs beside its nightly: the daily open and the
12
+ // heartbeat. Registered by `registerAcademyJobs` in create.mjs and deleted by
13
+ // id through `academyJobIds` in lifecycle.mjs.
14
+
15
+ export const ownerJobIds = (name) => [`${name}-owner-daily`, `${name}-owner-heartbeat`];
16
+
17
+ // A Principal's morning brief job, beside its nightly. Built from the folder id.
18
+ export const principalJobIds = (name) => [`${name}-principal-brief`];
19
+
20
+ // The Owner schedules these one-off sessions itself through the router skill, so
21
+ // the CLI never registers them; lifecycle must still delete them by id.
22
+ export const ownerSessionJobIds = (name) => [`${name}-owner-next`, `${name}-owner-eod`];
23
+
24
+ const OWNER_ROUTES = {
25
+ daily: 'Daily open: use the owner-work skill.',
26
+ heartbeat: 'Scheduled heartbeat: use the owner-heartbeat skill.',
27
+ };
28
+
29
+ // Every Owner job that runs through the Academy provider, the nightly included,
30
+ // names the runtime: the provider builds a different argv for each one.
31
+ export function academyRuntimeArgs(dir) {
32
+ return ['--academy-runtime', runtimeProviderOrNull(dir) === 'codex' ? 'codex' : 'claude'];
33
+ }
34
+
35
+ // A daily run can last the whole work session, so both jobs get its length.
36
+ const OWNER_JOB_TIMEOUT_SEC = '10800';
37
+ const ownerJob = (run) => ({
38
+ route: OWNER_ROUTES[run],
39
+ tags: `academy,owner,${run}`,
40
+ timeoutSec: OWNER_JOB_TIMEOUT_SEC,
41
+ });
42
+ const BRIEF_JOB = {
43
+ route: 'Morning brief: use the principal-brief skill.',
44
+ tags: 'academy,principal,brief',
45
+ timeoutSec: '3600',
46
+ };
47
+
48
+ // The provider jobs beside a registered nightly: an Owner's daily open and
49
+ // heartbeat, a Principal's 07:00 morning brief (an ordinary run with its prompt,
50
+ // no run flag). A provider run finds its agent only in the default root, so each
51
+ // set warns when registered elsewhere; a Principal's nightly is a command job
52
+ // with its env, so it is not in the warning. Other kinds have none.
53
+ export function registerKindTasks(dir, name, nightly) {
54
+ const kind = kindOf(dir);
55
+ if (kind === 'owner') return warnOnNamedAgentsRoot([nightly, ...registerOwnerTasks(dir, name)]);
56
+ if (kind !== 'principal') return [nightly];
57
+ const [id] = principalJobIds(name);
58
+ const schedule = ['--cron', '0 7 * * *', '--timezone', localTimezone()];
59
+ return [
60
+ nightly,
61
+ ...warnOnNamedAgentsRoot([scheduleOwnerJob(dir, name, id, BRIEF_JOB, schedule)]),
62
+ ];
63
+ }
64
+
65
+ // The daily open and the heartbeat. Like the Owner nightly they name a
66
+ // runtime, and `run --agent` re-registers the set whenever the runtime changes.
67
+ // They carry no job env: the scheduler refuses env on a provider job and would
68
+ // not pass it to the run. `academy run` derives ACADEMY_OWNER_RUN from the job
69
+ // id instead (run-context.mjs). Both are cron lines in owner_timezone, so the
70
+ // schedule alone keeps them inside owner_hours. A draft Owner (no signed
71
+ // mandate) gets neither: nothing runs until the user signs it.
72
+ function registerOwnerTasks(dir, name) {
73
+ if (isDraftOwner(dir)) return [];
74
+ const { owner_hours, owner_timezone, owner_heartbeat } = ownerConfig(dir);
75
+ const [dailyId, heartbeatId] = ownerJobIds(name);
76
+ const timezone = ['--timezone', owner_timezone];
77
+ const dailyCron = ownerDailyCron(owner_hours);
78
+ const daily = dailyCron
79
+ ? scheduleOwnerJob(dir, name, dailyId, ownerJob('daily'), ['--cron', dailyCron, ...timezone])
80
+ : unscheduled(dailyId, `owner_hours "${owner_hours}" has no opening time`);
81
+ const heartbeatCron = ownerHeartbeatCron(owner_hours, owner_heartbeat);
82
+ const heartbeat = heartbeatCron
83
+ ? scheduleOwnerJob(dir, name, heartbeatId, ownerJob('heartbeat'), [
84
+ '--cron',
85
+ heartbeatCron,
86
+ ...timezone,
87
+ ])
88
+ : unscheduled(
89
+ heartbeatId,
90
+ `owner_hours "${owner_hours}" with owner_heartbeat "${owner_heartbeat}" has no heartbeat cron`,
91
+ );
92
+ return [daily, heartbeat];
93
+ }
94
+
95
+ const unscheduled = (id, reason) => ({ id, registered: false, reason });
96
+
97
+ // The permission mode a new Owner job runs with. tldr stores it on the job and
98
+ // renders it into the provider argv, so it is visible and editable in tldr.
99
+ const DEFAULT_PERMISSION_MODE = { claude: 'bypassPermissions', codex: 'danger-full-access' };
100
+
101
+ function tldrTasks(bin, dir, args, input) {
102
+ const result = spawnSync(bin, spawnTldrTasksArgs(args), tldrSpawnOptions(dir, { input }));
103
+ if (result.error || result.status !== 0) return null;
104
+ try {
105
+ return JSON.parse(result.stdout);
106
+ } catch {
107
+ return {};
108
+ }
109
+ }
110
+
111
+ // `tasks schedule` does not store `--permission-mode` for a provider job, and a
112
+ // partial patch replaces the job's execution hints, so this reads the job and
113
+ // writes its whole hints back with the default. A mode the user chose is never
114
+ // changed. Academy's default for the other runtime is not valid for this one,
115
+ // so a runtime change swaps it.
116
+ function permissionModeFor(dir, stored) {
117
+ const runtime = runtimeProviderOrNull(dir) === 'codex' ? 'codex' : 'claude';
118
+ const other = DEFAULT_PERMISSION_MODE[runtime === 'codex' ? 'claude' : 'codex'];
119
+ return stored && stored !== other ? null : DEFAULT_PERMISSION_MODE[runtime];
120
+ }
121
+
122
+ function ensurePermissionMode(bin, dir, id) {
123
+ const hints = tldrTasks(bin, dir, ['get', '--cwd', dir, '--id', id, '--json'])?.data?.job
124
+ ?.execution_hints;
125
+ const config = hints?.provider_config;
126
+ const mode = config && permissionModeFor(dir, config.runtime_options?.permission_mode);
127
+ let result = config ? 'kept' : 'unknown';
128
+ if (mode) {
129
+ const runtimeOptions = { ...config.runtime_options, permission_mode: mode };
130
+ const patch = {
131
+ execution_hints: {
132
+ ...hints,
133
+ provider_config: { ...config, runtime_options: runtimeOptions },
134
+ },
135
+ };
136
+ const args = ['update', '--cwd', dir, '--id', id, '--stdin', '--json'];
137
+ result = tldrTasks(bin, dir, args, JSON.stringify(patch)) ? 'set' : 'failed';
138
+ }
139
+ // Quiet when it worked: lifecycle `--json` clients read stderr for the error
140
+ // envelope, so only a problem earns a line there.
141
+ if (result === 'set' || result === 'kept') return;
142
+ console.error(
143
+ JSON.stringify({ event: 'jobs.permission_mode', status: 'warn', params: { job: id }, result }),
144
+ );
145
+ }
146
+
147
+ function scheduleOwnerJob(dir, name, id, { route, tags, timeoutSec }, schedule) {
148
+ let tldrAgentsBin;
149
+ try {
150
+ tldrAgentsBin = resolveTldrAgentsBinary();
151
+ } catch (error) {
152
+ if (!(error instanceof RuntimeUnavailableError)) throw error;
153
+ return { id, registered: false, reason: error.message };
154
+ }
155
+ const args = spawnTldrTasksArgs([
156
+ 'schedule',
157
+ '--cwd',
158
+ dir,
159
+ '--id',
160
+ id,
161
+ ...schedule,
162
+ '--provider',
163
+ 'academy',
164
+ '--academy-agent',
165
+ name,
166
+ ...academyRuntimeArgs(dir),
167
+ '--prompt',
168
+ route,
169
+ '--overlap-policy',
170
+ 'forbid',
171
+ '--merge',
172
+ '--tags',
173
+ tags,
174
+ '--timeout-sec',
175
+ timeoutSec,
176
+ ]);
177
+ const job = runSchedule(tldrAgentsBin, dir, id, args);
178
+ if (job.registered) ensurePermissionMode(tldrAgentsBin, dir, id);
179
+ return job;
180
+ }
181
+
182
+ // A provider run gets no job env, so no AGENTS_ROOT: `academy run` looks for
183
+ // the agent under the default root. An agent anywhere else registers, but its
184
+ // provider jobs will not find it, so the registration says so. A set with a
185
+ // failed job is reported as that failure instead.
186
+ function warnOnNamedAgentsRoot(jobs) {
187
+ const agentsRoot = resolve(AGENTS_ROOT);
188
+ if (agentsRoot === join(homedir(), '.academy', 'agents')) return jobs;
189
+ if (jobs.some((job) => !job.registered && !job.skipped)) return jobs;
190
+ const ids = jobs.filter((job) => job.registered).map((job) => job.id);
191
+ const warning = `Provider jobs run under the default AGENTS_ROOT (~/.academy/agents), not ${agentsRoot}; they will not find this agent`;
192
+ console.error(
193
+ JSON.stringify({
194
+ event: 'jobs.agents_root',
195
+ status: 'warn',
196
+ message: warning,
197
+ params: { agentsRoot, jobs: ids },
198
+ }),
199
+ );
200
+ return jobs.map((job) => (job.registered ? { ...job, warning } : job));
201
+ }
@@ -0,0 +1,135 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { decodeTomlString } from './toml-string.mjs';
3
+
4
+ // Academy's prompt is appended to the provider's own prompt, never a
5
+ // replacement for it. A launch carries exactly one Academy appended-prompt
6
+ // value (Claude: inline text; Codex: one key); a prompt the caller already
7
+ // appends in the passthrough is folded in after Academy's, and its own flag is
8
+ // dropped.
9
+ //
10
+ // Why Claude gets the inline form: Claude keeps only the last value of each
11
+ // append form, but one `--append-system-prompt <text>` and one
12
+ // `--append-system-prompt-file <path>` combine. A host's terminal wrapper can put
13
+ // its own `--append-system-prompt-file` ahead of our arguments, so a file flag
14
+ // from Academy would replace the host's session context. The inline form
15
+ // leaves the file form to the wrapper. An incoming file flag is merged into our
16
+ // inline value too (not left in the passthrough): an Agent Pane launch through
17
+ // that wrapper would otherwise carry two file flags, and one would be lost.
18
+
19
+ const CLAUDE_INLINE_FLAG = '--append-system-prompt';
20
+ const CLAUDE_FILE_FLAG = '--append-system-prompt-file';
21
+ const CODEX_KEY = 'developer_instructions';
22
+
23
+ function logAppend(runtime, incomingCount, bytes) {
24
+ console.error(
25
+ JSON.stringify({
26
+ event: 'run.prompt_append',
27
+ params: { runtime: runtime ?? 'claude', incoming: incomingCount },
28
+ result: { bytes },
29
+ status: 'merged',
30
+ }),
31
+ );
32
+ }
33
+
34
+ function fail(message) {
35
+ console.error(`Error: ${message}`);
36
+ process.exit(1);
37
+ }
38
+
39
+ // Splits `argv` into the arguments that are not an incoming prompt and the
40
+ // incoming values, in order. `match(arg, next)` returns `{ value, consumed }`
41
+ // for an argument that carries one, else undefined.
42
+ function takeIncoming(argv, match) {
43
+ const rest = [];
44
+ const values = [];
45
+ for (let i = 0; i < argv.length; i++) {
46
+ const hit = match(argv[i], argv[i + 1]);
47
+ if (!hit) {
48
+ rest.push(argv[i]);
49
+ continue;
50
+ }
51
+ values.push(hit.value);
52
+ i += hit.consumed - 1;
53
+ }
54
+ return { rest, values };
55
+ }
56
+
57
+ // An incoming Claude append: `{ value, consumed, file }` for either flag in
58
+ // either spelling (`--flag v` or `--flag=v`), else undefined.
59
+ function matchClaudeAppend(arg, next) {
60
+ for (const [flag, file] of [
61
+ [CLAUDE_FILE_FLAG, true],
62
+ [CLAUDE_INLINE_FLAG, false],
63
+ ]) {
64
+ if (arg === flag) {
65
+ if (next === undefined) fail(`${flag} needs ${file ? 'a file path' : 'text'}`);
66
+ return { value: { text: next, file }, consumed: 2 };
67
+ }
68
+ if (arg.startsWith(`${flag}=`)) {
69
+ return { value: { text: arg.slice(flag.length + 1), file }, consumed: 1 };
70
+ }
71
+ }
72
+ return undefined;
73
+ }
74
+
75
+ // Every spelling of a Codex override that carries `developer_instructions`:
76
+ // `-c k=v`, `-c=k=v`, `-ck=v`, `--config k=v` and `--config=k=v`. Other keys
77
+ // are not matched, so they stay in the passthrough.
78
+ const CODEX_INSTRUCTIONS = new RegExp(`^${CODEX_KEY}\\s*=\\s*(.*)$`, 's');
79
+ const CODEX_ATTACHED = /^(?:-c=?|--config=)/;
80
+
81
+ function instructionsValue(kv) {
82
+ const value = CODEX_INSTRUCTIONS.exec(kv)?.[1];
83
+ return value === undefined ? undefined : decodeTomlString(value);
84
+ }
85
+
86
+ function matchCodexInstructions(arg, next) {
87
+ if (arg === '-c' || arg === '--config') {
88
+ if (next === undefined) fail(`${arg} needs a value`);
89
+ const value = next === undefined ? undefined : instructionsValue(next);
90
+ return value === undefined ? undefined : { value, consumed: 2 };
91
+ }
92
+ const attached = CODEX_ATTACHED.exec(arg);
93
+ const value = attached ? instructionsValue(arg.slice(attached[0].length)) : undefined;
94
+ return value === undefined ? undefined : { value, consumed: 1 };
95
+ }
96
+
97
+ function readIncomingFile(path) {
98
+ try {
99
+ return readFileSync(path, 'utf8');
100
+ } catch (error) {
101
+ return fail(`cannot read ${CLAUDE_FILE_FLAG} ${path}: ${error.message}`);
102
+ }
103
+ }
104
+
105
+ const incomingClaudeText = ({ text, file }) => (file ? readIncomingFile(text) : text);
106
+
107
+ const joinPrompts = (academy, incoming) =>
108
+ [academy.trimEnd(), ...incoming.map((text) => text.trim())].join('\n\n') + '\n';
109
+
110
+ /**
111
+ * The one place a launch decides how Academy's prompt reaches the provider.
112
+ * `promptPath` holds the rendered Academy prompt. Returns the provider args that
113
+ * carry it and the passthrough with any incoming appended prompt removed.
114
+ *
115
+ * Claude: the prompt (plus any incoming one, after it) is passed as
116
+ * `--append-system-prompt <text>`; no file is written per run.
117
+ * Codex: pending spike S2 confirmation, the merged prompt is passed as
118
+ * `-c developer_instructions=<JSON string>`.
119
+ */
120
+ export function appendedPromptLaunch({ runtime, promptPath, passthrough }) {
121
+ const academy = readFileSync(promptPath, 'utf8');
122
+ const codex = runtime === 'codex';
123
+ const { rest, values } = takeIncoming(
124
+ passthrough,
125
+ codex ? matchCodexInstructions : matchClaudeAppend,
126
+ );
127
+ const incoming = codex ? values : values.map(incomingClaudeText);
128
+ const prompt = incoming.length ? joinPrompts(academy, incoming) : academy;
129
+ if (incoming.length) logAppend(runtime, incoming.length, prompt.length);
130
+
131
+ const promptArgs = codex
132
+ ? ['-c', `${CODEX_KEY}=${JSON.stringify(prompt)}`]
133
+ : [CLAUDE_INLINE_FLAG, prompt];
134
+ return { promptArgs, passthrough: rest };
135
+ }
@@ -1,6 +1,5 @@
1
- import { existsSync, renameSync, statSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
2
2
  import { join, resolve } from 'node:path';
3
- import { readJsonFile } from './codex.mjs';
4
3
  import {
5
4
  agentDir,
6
5
  agentLifecycleLockPath,
@@ -9,126 +8,84 @@ import {
9
8
  validateName,
10
9
  withFileLock,
11
10
  } from './core.mjs';
11
+ import { liveJobIds } from './agent-gate.mjs';
12
+ import { agentIdOrNull, currentAgentName } from './agent-id.mjs';
12
13
  import { archivedAgentDir, assertNotArchived, isArchivedAgent } from './archived.mjs';
13
- import { registerNightlyConsolidationTask } from './create.mjs';
14
- import { appendLifecycleEvent } from './eventlog.mjs';
14
+ import { jobLabel } from './create.mjs';
15
+ import { appendLifecycleEvent, withRootEventLock } from './eventlog.mjs';
15
16
  import { agentRecord } from './inspect.mjs';
16
17
  import {
17
18
  DeleteAgentError,
18
19
  assertOwnedAgentForDelete,
19
- deleteJsonError,
20
- deleteNightlyConsolidation,
20
+ kindJobIds,
21
21
  preflightOwnedAgent,
22
22
  reportLifecycleFailure,
23
23
  } from './lifecycle.mjs';
24
24
  import { AgentSpecError, rewriteAgentYamlScalar } from './yaml.mjs';
25
25
 
26
- // `rename <old> <new>`. The ownership marker is the blocker: both validators
27
- // reject a directory whose marker names another agent, so a rename that moved
28
- // the directory without rewriting `.academy-agent.json` would leave the agent
29
- // permanently `not_academy_owned` — no delete, no archive, no second rename.
30
- // The move, the marker and the `name:` scalar are therefore one critical
31
- // section, and the lock is taken exactly once around all three.
26
+ // `rename <old> <new>` writes the agent.yaml `name:` scalar and nothing else
27
+ // that identifies the agent. The folder is the fixed id: live sessions hold its
28
+ // absolute path, and the scheduled jobs, the ownership marker and the Beads
29
+ // labels are keyed on it, so a rename is safe at any time, mid-session too.
32
30
 
33
31
  export function renameAgent(name, newName, json, invalidOption) {
34
32
  if (invalidOption !== undefined) return unreadableInvocation(invalidOption, json);
35
- preflightOwnedAgent(name, json);
33
+ const { id } = preflightOwnedAgent(name, json);
36
34
  validateName(newName);
37
- // The target name is asked the same two questions `create` asks it, and for
38
- // the same reason `create` asks them: a live agent moved onto a name the
39
- // holding area already owns leaves two records under one name, and every
40
- // published command then answers `agent_archived` for the live one. No
41
- // published command can reach it again.
42
35
  assertNotArchived(newName, json);
43
- refuseExistingTarget(newName, json);
44
36
  try {
45
- withFileLock(agentLifecycleLockPath(agentDir(name)), () =>
46
- renameAgentLocked(name, newName, json),
47
- );
37
+ withFileLock(agentLifecycleLockPath(agentDir(id)), () => renameAgentLocked(id, newName, json));
48
38
  } catch (error) {
49
39
  reportLifecycleFailure(error, 'rename', name, json);
50
40
  }
51
41
  }
52
42
 
53
- // Refused before the lock is taken, because every exit path here ends the
54
- // process and a lock released by nothing would strand the agent for good.
55
- function refuseExistingTarget(newName, json) {
56
- const target = agentDir(newName);
57
- if (!existsSync(target)) return;
58
- deleteJsonError(
59
- 'agent_exists',
60
- `Agent "${newName}" already exists at ${resolve(target)}`,
61
- { name: newName, dir: resolve(target) },
62
- json,
63
- );
64
- }
65
-
66
- function renameAgentLocked(name, newName, json) {
67
- const { dirReal } = assertOwnedAgentForDelete(name, agentDir(name));
43
+ function renameAgentLocked(id, newName, json) {
68
44
  // Reported as the absolute joined path, never the realpath: a client keying
69
45
  // agents on `dir` matches this against the record `create` gave it.
70
- const previousDir = resolve(agentDir(name));
71
- const target = agentDir(newName);
72
- if (existsSync(target)) {
73
- throw new DeleteAgentError(
74
- 'agent_exists',
75
- `Agent "${newName}" already exists at ${resolve(target)}`,
76
- { name: newName, dir: resolve(target) },
77
- );
78
- }
79
- refuseArchivedTarget(newName);
80
- // Named by the joined path, like every other path this command reports.
81
- assertRewritableAgentYaml(previousDir);
46
+ const dir = resolve(agentDir(id));
47
+ assertOwnedAgentForDelete(id, dir);
48
+ assertRewritableAgentYaml(dir);
49
+ const previousName = currentAgentName(dir);
50
+
51
+ // The final check and the write share the root event lock with `create`'s
52
+ // name claim, so one name is never taken twice. The checks inside throw, so
53
+ // the lock releases before any exit and before the event append takes it.
54
+ withRootEventLock(() => {
55
+ refuseTakenName(id, newName);
56
+ // The unlocked writer: this section already holds the agent's lifecycle lock.
57
+ rewriteAgentYamlScalar(dir, 'name', newName);
58
+ });
59
+ renameSeededText(dir, previousName, newName);
60
+
61
+ // An append failure is reported and the new name stands, as `createAgent`
62
+ // reports a failed publish.
63
+ appendLifecycleEvent('agent_renamed', newName, dir, { previousName, previousDir: dir });
64
+ reportRenamed({ id, dir, previousName, newName, json });
65
+ }
82
66
 
83
- // Unschedule before the move. Registration replaces on the *new* identifier,
84
- // so an old job left behind would fire nightly against a directory that has
85
- // moved, forever.
86
- const unscheduled = deleteNightlyConsolidation(name, dirReal);
87
- if (!unscheduled.ok) {
67
+ // Taken means any other live or archived agent's id or current name. The
68
+ // agent's own id or name is free to it, so a rename back to the id works.
69
+ function refuseTakenName(id, newName) {
70
+ const holder = agentIdOrNull(newName);
71
+ if (holder === null || holder === id) return;
72
+ const archived = isArchivedAgent(holder);
73
+ const dir = resolve(archived ? archivedAgentDir(holder) : agentDir(holder));
74
+ if (archived) {
88
75
  throw new DeleteAgentError(
89
- unscheduled.code ?? 'unschedule_failed',
90
- `Refusing to rename "${name}" because nightly unschedule failed: ${unscheduled.reason}`,
91
- { name },
76
+ 'agent_archived',
77
+ `Agent "${newName}" is archived at ${dir}. Unarchive it first.`,
78
+ { name: newName, dir },
92
79
  );
93
80
  }
94
-
95
- renameSync(dirReal, target);
96
- rewriteOwnershipMarker(target, newName);
97
- // The unlocked writer: this section already holds the agent's lifecycle lock,
98
- // and `writeAgentYamlScalar` would take the same lock again and time out.
99
- rewriteAgentYamlScalar(target, 'name', newName);
100
- assertOwnedAgentForDelete(newName, target);
101
- const nightly = registerNightlyConsolidationTask(target, newName);
102
-
103
- // The move is complete and consistent by here. An append failure is reported
104
- // and the move stands, as `createAgent` reports a failed publish: unwinding a
105
- // rename whose old scheduled job is already gone would lose more than it
106
- // repairs.
107
- appendLifecycleEvent('agent_renamed', newName, resolve(target), {
108
- previousName: name,
109
- previousDir,
81
+ throw new DeleteAgentError('agent_exists', `Agent "${newName}" already exists at ${dir}`, {
82
+ name: newName,
83
+ dir,
110
84
  });
111
- reportRenamed({ name, newName, previousDir, target, unscheduled, nightly, json });
112
85
  }
113
86
 
114
- // Mirrored inside the lock exactly as `existsSync(target)` is mirrored above:
115
- // an agent archived between the preflight and the lock must not be overwritten
116
- // by a rename that checked before the holding area claimed the name.
117
- function refuseArchivedTarget(newName) {
118
- if (!isArchivedAgent(newName)) return;
119
- const dir = resolve(archivedAgentDir(newName));
120
- throw new DeleteAgentError(
121
- 'agent_archived',
122
- `Agent "${newName}" is archived at ${dir}. Unarchive it first.`,
123
- { name: newName, dir },
124
- );
125
- }
126
-
127
- // The `name:` scalar is rewritten after the move, so a rewrite that throws
128
- // there leaves the directory moved, the marker rewritten, the old nightly job
129
- // unregistered, the new one never registered and no `agent_renamed` event
130
- // appended — while the client is told the rename failed. Proved rewritable
131
- // before any of it, the same refusal costs nothing.
87
+ // Proved rewritable before the name claim, so an agent.yaml Academy cannot
88
+ // write is refused with nothing changed.
132
89
  function assertRewritableAgentYaml(dir) {
133
90
  const path = join(dir, 'agent.yaml');
134
91
  if (statSync(path).isFile()) return;
@@ -137,31 +94,55 @@ function assertRewritableAgentYaml(dir) {
137
94
  });
138
95
  }
139
96
 
140
- // The marker is rewritten, not rewritten from scratch: `createdAt` records when
141
- // the agent was hired, which a rename does not change.
142
- function rewriteOwnershipMarker(dir, name) {
143
- const path = join(dir, '.academy-agent.json');
144
- const marker = readJsonFile(path, {});
145
- writeFileSync(path, `${JSON.stringify({ ...marker, name }, null, 2)}\n`);
97
+ // Only the line create seeded changes: the first match in its file, which
98
+ // sits above anything the user adds. Every other line stays, where
99
+ // `writeAgentClaudeMd` would replace them all. Names match NAME_RE, so they
100
+ // need no regex escaping.
101
+ const youAre = (old, name) => [new RegExp(`^You are ${old}([.,])`, 'm'), `You are ${name}$1`];
102
+ const SEEDED_LINES = {
103
+ 'identity.md': youAre,
104
+ 'mandate.md': youAre,
105
+ 'CLAUDE.md': (old, name) => [new RegExp(`^# ${old}$`, 'm'), `# ${name}`],
106
+ };
107
+
108
+ function renameSeededText(dir, previousName, newName) {
109
+ for (const [file, seeded] of Object.entries(SEEDED_LINES)) {
110
+ const path = join(dir, file);
111
+ if (!existsSync(path)) continue;
112
+ const text = readFileSync(path, 'utf8');
113
+ let renamed = text.replace(...seeded(previousName, newName));
114
+ if (file === 'CLAUDE.md') {
115
+ renamed = renamed.replace(`\`academy run ${previousName}\``, `\`academy run ${newName}\``);
116
+ }
117
+ if (renamed !== text) writeFileSync(path, renamed);
118
+ }
146
119
  }
147
120
 
148
- function reportRenamed({ name, newName, previousDir, target, unscheduled, nightly, json }) {
149
- const scheduledJobId = nightly.registered ? nightly.id : null;
121
+ function reportRenamed({ id, dir, previousName, newName, json }) {
150
122
  if (json) {
123
+ // The jobs keep their fixed-id names and none is unscheduled. Read from the
124
+ // agent's state, not from tldr: a stopped agent or a draft Owner has fewer.
125
+ const scheduledJobIds = liveJobIds(dir, kindJobIds(id, dir));
151
126
  contractOk('rename', {
152
127
  renamed: true,
153
- ...agentRecord(newName),
154
- previousName: name,
155
- previousDir,
156
- unscheduledJobId: unscheduled.id,
157
- scheduledJobId,
128
+ ...agentRecord(id),
129
+ previousName,
130
+ previousDir: dir,
131
+ unscheduledJobId: null,
132
+ unscheduledJobIds: [],
133
+ scheduledJobId: scheduledJobIds[0] ?? null,
134
+ scheduledJobIds,
158
135
  });
159
136
  return;
160
137
  }
161
- console.log(`Renamed agent "${name}" to "${newName}" (${previousDir} → ${resolve(target)}).`);
162
- console.log(`Unregistered nightly consolidation job "${unscheduled.id}".`);
163
- if (scheduledJobId) console.log(`Registered nightly consolidation job "${scheduledJobId}".`);
164
- else console.log(`Nightly consolidation job not registered: ${nightly.reason}`);
138
+ console.log(`Renamed agent "${previousName}" to "${newName}". Folder ${dir} and jobs unchanged.`);
139
+ }
140
+
141
+ export function reportRegisteredJobs(jobs) {
142
+ for (const job of jobs) {
143
+ if (job.registered) console.log(`Registered ${jobLabel(job.id)} "${job.id}".`);
144
+ else console.log(`${jobLabel(job.id)} "${job.id}" not registered: ${job.reason}`);
145
+ }
165
146
  }
166
147
 
167
148
  function unreadableInvocation(option, json) {
@@ -0,0 +1,50 @@
1
+ import { NIGHTLY_JOB_FLAG } from './nightly.mjs';
2
+ import { ownerJobIds } from './owner-jobs.mjs';
3
+ import { OWNER_RUN_FLAG } from './heartbeat.mjs';
4
+ import { schedulerJobId } from './tldr.mjs';
5
+
6
+ // An Academy-provider job reaches `academy run` with no job env: the scheduler
7
+ // sets only its own context, which names the job. Its run flags are derived
8
+ // from that id once, at the run entry, and written to process.env, so the
9
+ // heartbeat check and every child after see explicit flags and never derive
10
+ // again. Command jobs and tests keep setting the flags explicitly.
11
+
12
+ // Any of these present means the run type is already decided. The nightly
13
+ // child carries ACADEMY_NIGHTLY_RUN and the scheduler's job id it inherited,
14
+ // so this is also what keeps it from entering the nightly routine again.
15
+ const RUN_FLAGS = [OWNER_RUN_FLAG, NIGHTLY_JOB_FLAG, 'ACADEMY_NIGHTLY_RUN'];
16
+
17
+ function flagsByJobId(name) {
18
+ const [dailyId, heartbeatId] = ownerJobIds(name);
19
+ return {
20
+ [`${name}-nightly-consolidation`]: { [NIGHTLY_JOB_FLAG]: '1' },
21
+ [dailyId]: { [OWNER_RUN_FLAG]: 'daily' },
22
+ [heartbeatId]: { [OWNER_RUN_FLAG]: 'heartbeat' },
23
+ };
24
+ }
25
+
26
+ /** The run flags the scheduler context implies for `name`. Pure. */
27
+ export function deriveRunFlags(name, env) {
28
+ const jobId = schedulerJobId(env);
29
+ if (!jobId) return { jobId: null, derived: {}, reason: 'no_job' };
30
+ if (RUN_FLAGS.some((flag) => env[flag])) return { jobId, derived: {}, reason: 'explicit_flags' };
31
+ const derived = flagsByJobId(name)[jobId];
32
+ return derived
33
+ ? { jobId, derived, reason: 'academy_job' }
34
+ : { jobId, derived: {}, reason: 'other_job' };
35
+ }
36
+
37
+ // A job that is not one of this agent's Academy jobs (a one-off, another
38
+ // assignment) derives nothing and runs the way a user's run does.
39
+ export function applyRunContext(name, env = process.env) {
40
+ const { jobId, derived, reason } = deriveRunFlags(name, env);
41
+ if (!jobId) return;
42
+ Object.assign(env, derived);
43
+ console.error(
44
+ JSON.stringify({
45
+ event: 'run.context',
46
+ params: { agent: name, jobId },
47
+ result: { derived, reason },
48
+ }),
49
+ );
50
+ }