@joenandez/academy 0.4.0 → 0.5.12
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.
- package/.claude-plugin/marketplace.json +5 -0
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +390 -0
- package/README.md +67 -122
- package/hooks/backstory/.claude-plugin/plugin.json +11 -0
- package/hooks/backstory/SOURCE +2 -0
- package/hooks/backstory/bin/backstory +9 -0
- package/hooks/backstory/hooks/hooks.json +45 -0
- package/hooks/backstory/scripts/knowledge.mjs +87 -0
- package/hooks/backstory/scripts/memory.mjs +685 -0
- package/hooks/backstory/scripts/tool-tick.mjs +1 -0
- package/hooks/backstory/skills/capture/SKILL.md +71 -0
- package/hooks/backstory/skills/capture/references/knowledge-capture-input.json +16 -0
- package/hooks/backstory/skills/capture/references/tagging-policy.md +29 -0
- package/hooks/backstory/skills/memory/SKILL.md +70 -0
- package/hooks/backstory/skills/setup/SKILL.md +85 -0
- package/hooks/context_metrics.mjs +67 -0
- package/hooks/hook_runtime.mjs +43 -2
- package/hooks/memory_bridge.mjs +286 -52
- package/hooks/memory_store.mjs +78 -26
- package/hooks/register_session.mjs +76 -14
- package/hooks/roque/.claude-plugin/plugin.json +6 -0
- package/hooks/roque/LICENSE +21 -0
- package/hooks/roque/SOURCE +1 -0
- package/hooks/roque/hooks.json +38 -0
- package/hooks/roque/package.json +3 -0
- package/hooks/roque/roque.js +1690 -0
- package/hooks/sync_memory.mjs +11 -7
- package/package.json +7 -3
- package/scripts/cli/agent-gate.mjs +70 -0
- package/scripts/cli/agent-id.mjs +37 -0
- package/scripts/cli/archive.mjs +39 -38
- package/scripts/cli/archived.mjs +4 -2
- package/scripts/cli/args-kinds.mjs +33 -0
- package/scripts/cli/args-notes.mjs +42 -0
- package/scripts/cli/args-owner.mjs +37 -0
- package/scripts/cli/args-run.mjs +38 -0
- package/scripts/cli/args.mjs +47 -104
- package/scripts/cli/beads.mjs +57 -0
- package/scripts/cli/codex.mjs +66 -12
- package/scripts/cli/core.mjs +117 -41
- package/scripts/cli/create.mjs +224 -76
- package/scripts/cli/curator-feed-codex.mjs +81 -0
- package/scripts/cli/curator-feed-observations.mjs +97 -0
- package/scripts/cli/curator-feed-scope.mjs +124 -0
- package/scripts/cli/curator-feed-sources.mjs +172 -0
- package/scripts/cli/curator-feed.mjs +318 -0
- package/scripts/cli/default-name.mjs +73 -0
- package/scripts/cli/doctor.mjs +67 -2
- package/scripts/cli/eventlog.mjs +6 -2
- package/scripts/cli/exit-code.mjs +9 -0
- package/scripts/cli/first-run.mjs +115 -0
- package/scripts/cli/heartbeat.mjs +135 -0
- package/scripts/cli/helm-skip.mjs +20 -0
- package/scripts/cli/hire-headless.mjs +37 -22
- package/scripts/cli/hire.mjs +8 -2
- package/scripts/cli/inspect.mjs +36 -18
- package/scripts/cli/lifecycle.mjs +95 -49
- package/scripts/cli/main.mjs +32 -2
- package/scripts/cli/memory-env.mjs +124 -0
- package/scripts/cli/migrate.mjs +13 -7
- package/scripts/cli/next-steps.mjs +13 -0
- package/scripts/cli/nightly-launch.mjs +118 -0
- package/scripts/cli/nightly.mjs +228 -0
- package/scripts/cli/notes.mjs +58 -8
- package/scripts/cli/owner-config.mjs +181 -0
- package/scripts/cli/owner-jobs.mjs +201 -0
- package/scripts/cli/prompt-append.mjs +135 -0
- package/scripts/cli/rename.mjs +90 -109
- package/scripts/cli/run-context.mjs +50 -0
- package/scripts/cli/run.mjs +236 -79
- package/scripts/cli/scaffold.mjs +119 -126
- package/scripts/cli/schedule-errors.mjs +70 -0
- package/scripts/cli/sessions.mjs +10 -3
- package/scripts/cli/skills-check.mjs +147 -0
- package/scripts/cli/skills.mjs +170 -0
- package/scripts/cli/start.mjs +201 -0
- package/scripts/cli/templates.mjs +119 -45
- package/scripts/cli/tldr.mjs +184 -0
- package/scripts/cli/toml-string.mjs +76 -0
- package/scripts/cli/user-work-view.mjs +48 -0
- package/scripts/cli/writable-dirs.mjs +23 -0
- package/scripts/cli/yaml.mjs +1 -1
- package/skills/hire/SKILL.md +112 -100
- package/skills/hire/references/owner.md +68 -0
- package/templates/agents/claude-code/knowledge-curator.md +5 -2
- package/templates/agents/codex/knowledge-curator.toml +2 -1
- package/templates/operating-model/base.md +78 -0
- package/templates/operating-model/owner.md +11 -0
- package/templates/operating-model/principal.md +9 -0
- package/templates/operating-model/specialist.md +9 -0
- package/templates/packs/owner/craft.md +32 -0
- package/templates/packs/owner/identity.md +13 -0
- package/templates/packs/owner/mandate.md +26 -0
- package/templates/packs/owner/owner/SKILL.md +118 -0
- package/templates/packs/owner/owner-feedback/SKILL.md +49 -0
- package/templates/packs/owner/owner-heartbeat/SKILL.md +35 -0
- package/templates/packs/owner/owner-nightly/SKILL.md +114 -0
- package/templates/packs/owner/owner-review/SKILL.md +70 -0
- package/templates/packs/owner/owner-work/SKILL.md +53 -0
- package/templates/packs/principal/craft.md +27 -0
- package/templates/packs/principal/identity.md +13 -0
- package/templates/packs/principal/mandate.md +23 -0
- package/templates/packs/principal/principal-brief/SKILL.md +44 -0
- package/templates/packs/principal/principal-intro/SKILL.md +95 -0
- package/templates/packs/principal/principal-nightly/SKILL.md +152 -0
- package/templates/skills/check-in/SKILL.md +34 -24
- package/templates/skills/knowledge-curation/SKILL.md +89 -48
- package/templates/skills/local-rules/SKILL.md +86 -0
- package/templates/skills/nightly-consolidation/SKILL.md +131 -100
- package/templates/skills/skill-method/SKILL.md +79 -0
- package/templates/skills/user-nightly/SKILL.md +143 -0
- package/templates/skills/work-graph/SKILL.md +75 -0
- package/conformance/README.md +0 -60
- package/conformance/discovery.test.mjs +0 -140
- package/conformance/envelope.test.mjs +0 -185
- package/conformance/error-codes.test.mjs +0 -125
- package/conformance/harness.mjs +0 -180
- package/conformance/identity.test.mjs +0 -125
- package/docs/integration-guide.md +0 -1042
- 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
|
+
}
|
package/scripts/cli/rename.mjs
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { existsSync,
|
|
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 {
|
|
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
|
-
|
|
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
|
|
27
|
-
//
|
|
28
|
-
// the
|
|
29
|
-
//
|
|
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(
|
|
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
|
-
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
if (
|
|
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
|
-
|
|
90
|
-
`
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
//
|
|
115
|
-
//
|
|
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
|
-
//
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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({
|
|
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(
|
|
154
|
-
previousName
|
|
155
|
-
previousDir,
|
|
156
|
-
unscheduledJobId:
|
|
157
|
-
|
|
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 "${
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
+
}
|