@joenandez/academy 0.4.0-rc.1
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 +14 -0
- package/.claude-plugin/plugin.json +6 -0
- package/CHANGELOG.md +46 -0
- package/LICENSE +21 -0
- package/README.md +209 -0
- package/bin/academy +2 -0
- package/conformance/README.md +60 -0
- package/conformance/discovery.test.mjs +140 -0
- package/conformance/envelope.test.mjs +185 -0
- package/conformance/error-codes.test.mjs +125 -0
- package/conformance/harness.mjs +180 -0
- package/conformance/identity.test.mjs +125 -0
- package/docs/integration-guide.md +1026 -0
- package/hooks/hook_runtime.mjs +100 -0
- package/hooks/hooks.json +26 -0
- package/hooks/inject_surface.py +122 -0
- package/hooks/memory_bridge.mjs +120 -0
- package/hooks/memory_store.mjs +66 -0
- package/hooks/register_session.mjs +51 -0
- package/hooks/sync_memory.mjs +27 -0
- package/package.json +41 -0
- package/scripts/agent.mjs +3 -0
- package/scripts/cli/archive.mjs +161 -0
- package/scripts/cli/archived.mjs +82 -0
- package/scripts/cli/args.mjs +282 -0
- package/scripts/cli/codex.mjs +216 -0
- package/scripts/cli/core.mjs +389 -0
- package/scripts/cli/create.mjs +242 -0
- package/scripts/cli/doctor.mjs +203 -0
- package/scripts/cli/eventlog.mjs +129 -0
- package/scripts/cli/events.mjs +80 -0
- package/scripts/cli/hire-headless.mjs +229 -0
- package/scripts/cli/hire-spec.mjs +164 -0
- package/scripts/cli/hire.mjs +92 -0
- package/scripts/cli/inspect.mjs +286 -0
- package/scripts/cli/lifecycle.mjs +296 -0
- package/scripts/cli/main.mjs +102 -0
- package/scripts/cli/migrate.mjs +183 -0
- package/scripts/cli/notes.mjs +104 -0
- package/scripts/cli/rename.mjs +172 -0
- package/scripts/cli/run.mjs +227 -0
- package/scripts/cli/runtime.mjs +47 -0
- package/scripts/cli/scaffold.mjs +332 -0
- package/scripts/cli/sessions.mjs +98 -0
- package/scripts/cli/templates.mjs +104 -0
- package/scripts/cli/yaml.mjs +124 -0
- package/skills/hire/SKILL.md +669 -0
- package/templates/agents/claude-code/knowledge-curator.md +14 -0
- package/templates/agents/codex/knowledge-curator.toml +9 -0
- package/templates/skills/check-in/SKILL.md +122 -0
- package/templates/skills/knowledge-curation/SKILL.md +132 -0
- package/templates/skills/nightly-consolidation/SKILL.md +240 -0
- package/templates/skills/self-update/SKILL.md +121 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
AGENTS_ROOT,
|
|
5
|
+
LockTimeoutError,
|
|
6
|
+
NAME_RE,
|
|
7
|
+
agentLifecycleLockPath,
|
|
8
|
+
checkAgentsRoot,
|
|
9
|
+
contractOk,
|
|
10
|
+
exitJsonError,
|
|
11
|
+
isInside,
|
|
12
|
+
isSymlink,
|
|
13
|
+
withFileLock,
|
|
14
|
+
} from './core.mjs';
|
|
15
|
+
import { ownershipFault } from './lifecycle.mjs';
|
|
16
|
+
import { writeOwnershipMarker } from './scaffold.mjs';
|
|
17
|
+
|
|
18
|
+
const MARKER = '.academy-agent.json';
|
|
19
|
+
|
|
20
|
+
// The ownership backfill. Agents scaffolded before the marker existed fail
|
|
21
|
+
// every ownership-gated command, and this is the one operator-invoked repair
|
|
22
|
+
// for that. It writes the marker and nothing else: it never creates an agent,
|
|
23
|
+
// never rewrites agent content, and never touches a directory it cannot prove
|
|
24
|
+
// is the agent's own slot inside AGENTS_ROOT.
|
|
25
|
+
|
|
26
|
+
// Containment is the invariant, and it is proved against the resolved path, not
|
|
27
|
+
// the joined one. A symlink inside the root pointing anywhere else — outside
|
|
28
|
+
// the root, or at another agent beside it — is refused, because writing "kai"
|
|
29
|
+
// ownership through it would mark a directory Academy does not own.
|
|
30
|
+
function slotFault(dirReal, rootReal, name) {
|
|
31
|
+
if (!isInside(dirReal, rootReal)) return `resolves outside AGENTS_ROOT: ${dirReal}`;
|
|
32
|
+
if (dirReal !== join(rootReal, name)) {
|
|
33
|
+
return `resolves to another directory inside AGENTS_ROOT: ${dirReal}`;
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Guarded rather than raised: `doctor` counts the outstanding backfill through
|
|
39
|
+
// this same plan and must never exit from inside a probe.
|
|
40
|
+
function rootEntries(root) {
|
|
41
|
+
try {
|
|
42
|
+
return readdirSync(root)
|
|
43
|
+
.filter((entry) => NAME_RE.test(entry))
|
|
44
|
+
.sort();
|
|
45
|
+
} catch {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Containment is proved for the directory; the marker is a second path and
|
|
51
|
+
// needs its own proof. `existsSync` follows symlinks, so a dangling or non-JSON
|
|
52
|
+
// symlink at the marker reads as "no marker", and `writeFileSync` then opens
|
|
53
|
+
// with O_TRUNC and follows the link to whatever it names. Asked before
|
|
54
|
+
// ownership on purpose: a marker symlinked at a valid file outside the root
|
|
55
|
+
// would otherwise pass the gate silently instead of being reported.
|
|
56
|
+
function markerFault(dirReal) {
|
|
57
|
+
const markerPath = join(dirReal, MARKER);
|
|
58
|
+
if (isSymlink(markerPath)) return `ownership marker is a symlink: ${markerPath}`;
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function classify(root, rootReal, name) {
|
|
63
|
+
const dir = join(root, name);
|
|
64
|
+
let dirReal;
|
|
65
|
+
try {
|
|
66
|
+
dirReal = realpathSync(dir);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
return { refused: { name, dir, reason: `cannot be resolved: ${error.message}` } };
|
|
69
|
+
}
|
|
70
|
+
const fault = slotFault(dirReal, rootReal, name);
|
|
71
|
+
if (fault) return { refused: { name, dir, reason: fault } };
|
|
72
|
+
// Not an agent directory, so not a repair. Marking a directory holding no
|
|
73
|
+
// agent.yaml would create an agent, which the backfill must never do.
|
|
74
|
+
if (!statSync(dirReal).isDirectory()) return {};
|
|
75
|
+
if (!existsSync(join(dirReal, 'agent.yaml'))) return {};
|
|
76
|
+
const marker = markerFault(dirReal);
|
|
77
|
+
if (marker) return { refused: { name, dir, reason: marker } };
|
|
78
|
+
if (ownershipFault(name, dirReal) === null) return {};
|
|
79
|
+
// dirReal is carried, not recomputed at the write: joining the unvalidated
|
|
80
|
+
// path again would traverse the same symlink chain a second time and land
|
|
81
|
+
// somewhere the classification never proved.
|
|
82
|
+
return { repair: { name, dir, dirReal } };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// The one enumeration. `migrate` repairs exactly this set and `doctor` counts
|
|
86
|
+
// exactly this set, so the reported outstanding count and the repair can never
|
|
87
|
+
// disagree.
|
|
88
|
+
export function backfillPlan() {
|
|
89
|
+
const root = resolve(AGENTS_ROOT);
|
|
90
|
+
let rootReal = root;
|
|
91
|
+
try {
|
|
92
|
+
rootReal = realpathSync(root);
|
|
93
|
+
} catch {
|
|
94
|
+
/* an unresolvable root yields no candidates */
|
|
95
|
+
}
|
|
96
|
+
const repair = [];
|
|
97
|
+
const refused = [];
|
|
98
|
+
for (const name of rootEntries(root)) {
|
|
99
|
+
const outcome = classify(root, rootReal, name);
|
|
100
|
+
if (outcome.repair) repair.push(outcome.repair);
|
|
101
|
+
if (outcome.refused) refused.push(outcome.refused);
|
|
102
|
+
}
|
|
103
|
+
return { agentsRoot: root, rootReal, repair, refused };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The whole root is classified before the first write, so the last write lands
|
|
107
|
+
// long after its entry was judged. The lock serialises the commands; it does
|
|
108
|
+
// not revalidate, so the entry is classified again inside it and skipped when
|
|
109
|
+
// it no longer qualifies.
|
|
110
|
+
function writeOneMarker(root, rootReal, { name, dir }) {
|
|
111
|
+
return withFileLock(agentLifecycleLockPath(dir), () => {
|
|
112
|
+
const current = classify(root, rootReal, name);
|
|
113
|
+
if (current.refused) return current;
|
|
114
|
+
if (!current.repair) return { skipped: true };
|
|
115
|
+
writeOwnershipMarker(current.repair.dirReal, name);
|
|
116
|
+
return { written: true };
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// One unwritable directory is one refusal, not an abandoned sweep. The operator
|
|
121
|
+
// asked for every outstanding marker and has to learn which ones landed, and
|
|
122
|
+
// `refused[]` is the channel the payload already publishes for exactly that.
|
|
123
|
+
// Only a lock timeout escapes: it is the one fault that fails the command.
|
|
124
|
+
function writeMarkers(plan) {
|
|
125
|
+
const migrated = [];
|
|
126
|
+
for (const entry of plan.repair) {
|
|
127
|
+
let outcome;
|
|
128
|
+
try {
|
|
129
|
+
outcome = writeOneMarker(plan.agentsRoot, plan.rootReal, entry);
|
|
130
|
+
} catch (error) {
|
|
131
|
+
if (error instanceof LockTimeoutError) throw error;
|
|
132
|
+
outcome = { refused: { name: entry.name, dir: entry.dir, reason: error.message } };
|
|
133
|
+
}
|
|
134
|
+
if (outcome.written) migrated.push(entry);
|
|
135
|
+
if (outcome.refused) plan.refused.push(outcome.refused);
|
|
136
|
+
}
|
|
137
|
+
return migrated;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function backfillOwnership({ json, dryRun, invalidOption }) {
|
|
141
|
+
if (invalidOption !== undefined) return unreadableInvocation(invalidOption, json);
|
|
142
|
+
checkAgentsRoot(json);
|
|
143
|
+
const plan = backfillPlan();
|
|
144
|
+
let migrated = plan.repair;
|
|
145
|
+
try {
|
|
146
|
+
if (!dryRun) migrated = writeMarkers(plan);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
if (!(error instanceof LockTimeoutError)) throw error;
|
|
149
|
+
if (json) exitJsonError(error.code, error.message, error.fields);
|
|
150
|
+
console.error(`Error: ${error.message}`);
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
if (json) {
|
|
154
|
+
contractOk('migrate', {
|
|
155
|
+
agentsRoot: plan.agentsRoot,
|
|
156
|
+
dryRun: Boolean(dryRun),
|
|
157
|
+
migrated: migrated.map(published),
|
|
158
|
+
refused: plan.refused,
|
|
159
|
+
});
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
printBackfill(plan, migrated, dryRun);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// The resolved directory the write targets is internal. A published record
|
|
166
|
+
// carries the two keys it always carried.
|
|
167
|
+
function published({ name, dir }) {
|
|
168
|
+
return { name, dir };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function unreadableInvocation(option, json) {
|
|
172
|
+
const message = `Unknown migrate option: ${option}. Use [--dry-run] [--json].`;
|
|
173
|
+
if (json) exitJsonError('invalid_spec', message, { option });
|
|
174
|
+
console.error(`Error: ${message}`);
|
|
175
|
+
process.exit(1);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function printBackfill(plan, migrated, dryRun) {
|
|
179
|
+
const verb = dryRun ? 'would write' : 'wrote';
|
|
180
|
+
console.log(`migrate ${plan.agentsRoot}: ${verb} ${migrated.length} ownership marker(s)`);
|
|
181
|
+
for (const { name, dir } of migrated) console.log(` marker ${name} ${dir}`);
|
|
182
|
+
for (const entry of plan.refused) console.log(` refused ${entry.name} ${entry.reason}`);
|
|
183
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { basename, join, resolve } from 'node:path';
|
|
3
|
+
import { AGENTS_ROOT, CLI_NAME, agentDir, isInside, validateName } from './core.mjs';
|
|
4
|
+
import { ownershipFault } from './lifecycle.mjs';
|
|
5
|
+
|
|
6
|
+
// `notes` is not a published contract command, so it has no envelope to answer
|
|
7
|
+
// in. It still names the published code for the fault: the containment defect
|
|
8
|
+
// is real whether or not the command is contract, and an operator reading
|
|
9
|
+
// stderr needs the same vocabulary every other command uses.
|
|
10
|
+
function notesFault(code, message) {
|
|
11
|
+
console.error(`Error: ${code}: ${message}`);
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// The identity gate. ACADEMY_AGENT_DIR arrives from the environment, which is
|
|
16
|
+
// not a source Academy can verify, so an accepted directory must prove both
|
|
17
|
+
// that it resolves inside AGENTS_ROOT and that Academy owns it. Without this,
|
|
18
|
+
// `notes add` is an arbitrary append under an identity nobody checked.
|
|
19
|
+
// `migrate` is the repair path for an agent scaffolded before the marker.
|
|
20
|
+
function containedAgentDir(candidate) {
|
|
21
|
+
const dir = resolve(candidate);
|
|
22
|
+
if (!isInside(dir, AGENTS_ROOT)) {
|
|
23
|
+
notesFault('unsafe_agent_path', `agent directory resolves outside AGENTS_ROOT: ${dir}`);
|
|
24
|
+
}
|
|
25
|
+
let real;
|
|
26
|
+
try {
|
|
27
|
+
real = realpathSync(dir);
|
|
28
|
+
} catch {
|
|
29
|
+
// Not there at all. `ensureAgentHome` is the second gate and says so.
|
|
30
|
+
return dir;
|
|
31
|
+
}
|
|
32
|
+
const fault = ownershipFault(basename(real), real);
|
|
33
|
+
if (fault) notesFault('not_academy_owned', `${fault} at ${real}`);
|
|
34
|
+
return real;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function namedAgentDir(name) {
|
|
38
|
+
validateName(name);
|
|
39
|
+
return containedAgentDir(agentDir(name));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function resolveNotesAgentDir(explicitName) {
|
|
43
|
+
if (explicitName) return namedAgentDir(explicitName);
|
|
44
|
+
const home = process.env.ACADEMY_AGENT_DIR || process.env.ACADEMY_AGENT_HOME;
|
|
45
|
+
if (home) return containedAgentDir(home);
|
|
46
|
+
if (process.env.ACADEMY_AGENT_NAME) return namedAgentDir(process.env.ACADEMY_AGENT_NAME);
|
|
47
|
+
console.error(`Error: no agent resolved for \`${CLI_NAME} notes\`.`);
|
|
48
|
+
console.error('Pass an agent name, or run inside an agent so ACADEMY_AGENT_DIR is set.');
|
|
49
|
+
console.error(
|
|
50
|
+
`Usage: ${CLI_NAME} notes add [<agent>] "text" | ${CLI_NAME} notes list [<agent>] [--last N]`,
|
|
51
|
+
);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function ensureAgentHome(dir) {
|
|
56
|
+
if (!existsSync(dir)) {
|
|
57
|
+
console.error(`Agent home not found at ${dir}`);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
return dir;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Local-time stamp `YYYY-MM-DD HH:MM` (not UTC — spec §Appended Format).
|
|
64
|
+
function localNoteStamp(d = new Date()) {
|
|
65
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
66
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function notesAdd(name, text) {
|
|
70
|
+
if (!text || !text.trim()) {
|
|
71
|
+
console.error('Error: note text required.');
|
|
72
|
+
console.error(`Usage: ${CLI_NAME} notes add [<agent>] "text"`);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
const dir = ensureAgentHome(resolveNotesAgentDir(name));
|
|
76
|
+
const notesPath = join(dir, 'notes.md');
|
|
77
|
+
// Append-only: never read or rewrite the whole file (spec §Behavior).
|
|
78
|
+
appendFileSync(notesPath, `- ${localNoteStamp()}: ${text}\n`);
|
|
79
|
+
console.log(`Noted → ${notesPath}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function notesList(name, last) {
|
|
83
|
+
const dir = ensureAgentHome(resolveNotesAgentDir(name));
|
|
84
|
+
const notesPath = join(dir, 'notes.md');
|
|
85
|
+
if (!existsSync(notesPath)) {
|
|
86
|
+
console.log('(no notes yet)');
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
// A note bullet is a top-level `- ` line — excludes headers, `_(…)_` italics,
|
|
90
|
+
// and `---` rules in the scaffold.
|
|
91
|
+
const bullets = readFileSync(notesPath, 'utf8')
|
|
92
|
+
.split('\n')
|
|
93
|
+
.filter((line) => line.startsWith('- '));
|
|
94
|
+
const recent = bullets.slice(-last);
|
|
95
|
+
if (recent.length === 0) {
|
|
96
|
+
console.log('(no notes yet)');
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
console.log(recent.join('\n'));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
103
|
+
// Main
|
|
104
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { existsSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { readJsonFile } from './codex.mjs';
|
|
4
|
+
import {
|
|
5
|
+
agentDir,
|
|
6
|
+
agentLifecycleLockPath,
|
|
7
|
+
contractOk,
|
|
8
|
+
exitJsonError,
|
|
9
|
+
validateName,
|
|
10
|
+
withFileLock,
|
|
11
|
+
} from './core.mjs';
|
|
12
|
+
import { archivedAgentDir, assertNotArchived, isArchivedAgent } from './archived.mjs';
|
|
13
|
+
import { registerNightlyConsolidationTask } from './create.mjs';
|
|
14
|
+
import { appendLifecycleEvent } from './eventlog.mjs';
|
|
15
|
+
import { agentRecord } from './inspect.mjs';
|
|
16
|
+
import {
|
|
17
|
+
DeleteAgentError,
|
|
18
|
+
assertOwnedAgentForDelete,
|
|
19
|
+
deleteJsonError,
|
|
20
|
+
deleteNightlyConsolidation,
|
|
21
|
+
preflightOwnedAgent,
|
|
22
|
+
reportLifecycleFailure,
|
|
23
|
+
} from './lifecycle.mjs';
|
|
24
|
+
import { AgentSpecError, rewriteAgentYamlScalar } from './yaml.mjs';
|
|
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.
|
|
32
|
+
|
|
33
|
+
export function renameAgent(name, newName, json, invalidOption) {
|
|
34
|
+
if (invalidOption !== undefined) return unreadableInvocation(invalidOption, json);
|
|
35
|
+
preflightOwnedAgent(name, json);
|
|
36
|
+
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
|
+
assertNotArchived(newName, json);
|
|
43
|
+
refuseExistingTarget(newName, json);
|
|
44
|
+
try {
|
|
45
|
+
withFileLock(agentLifecycleLockPath(agentDir(name)), () =>
|
|
46
|
+
renameAgentLocked(name, newName, json),
|
|
47
|
+
);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
reportLifecycleFailure(error, 'rename', name, json);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
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));
|
|
68
|
+
// Reported as the absolute joined path, never the realpath: a client keying
|
|
69
|
+
// 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);
|
|
82
|
+
|
|
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) {
|
|
88
|
+
throw new DeleteAgentError(
|
|
89
|
+
unscheduled.code ?? 'unschedule_failed',
|
|
90
|
+
`Refusing to rename "${name}" because nightly unschedule failed: ${unscheduled.reason}`,
|
|
91
|
+
{ name },
|
|
92
|
+
);
|
|
93
|
+
}
|
|
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,
|
|
110
|
+
});
|
|
111
|
+
reportRenamed({ name, newName, previousDir, target, unscheduled, nightly, json });
|
|
112
|
+
}
|
|
113
|
+
|
|
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.
|
|
132
|
+
function assertRewritableAgentYaml(dir) {
|
|
133
|
+
const path = join(dir, 'agent.yaml');
|
|
134
|
+
if (statSync(path).isFile()) return;
|
|
135
|
+
throw new AgentSpecError(`agent.yaml at ${path} is not a regular file Academy can rewrite.`, {
|
|
136
|
+
path,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
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`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function reportRenamed({ name, newName, previousDir, target, unscheduled, nightly, json }) {
|
|
149
|
+
const scheduledJobId = nightly.registered ? nightly.id : null;
|
|
150
|
+
if (json) {
|
|
151
|
+
contractOk('rename', {
|
|
152
|
+
renamed: true,
|
|
153
|
+
...agentRecord(newName),
|
|
154
|
+
previousName: name,
|
|
155
|
+
previousDir,
|
|
156
|
+
unscheduledJobId: unscheduled.id,
|
|
157
|
+
scheduledJobId,
|
|
158
|
+
});
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
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}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function unreadableInvocation(option, json) {
|
|
168
|
+
const message = `Unknown rename option: ${option}. Use rename <old> <new> [--json].`;
|
|
169
|
+
if (json) exitJsonError('invalid_spec', message, { option });
|
|
170
|
+
console.error(`Error: ${message}`);
|
|
171
|
+
process.exit(1);
|
|
172
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { existsSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import { basename, resolve } from 'node:path';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { pendingMarkerPath } from '../../hooks/memory_store.mjs';
|
|
5
|
+
import {
|
|
6
|
+
codexRuntimeContextPath,
|
|
7
|
+
launchCodex,
|
|
8
|
+
readJsonFile,
|
|
9
|
+
writeCodexHooks,
|
|
10
|
+
writeCodexProfile,
|
|
11
|
+
} from './codex.mjs';
|
|
12
|
+
import {
|
|
13
|
+
CLI_NAME,
|
|
14
|
+
ACADEMY_CLI_PATH,
|
|
15
|
+
SCHEDULED_CLAUDE_PERMISSION_ARGS,
|
|
16
|
+
SCHEDULED_CODEX_PERMISSION_ARGS,
|
|
17
|
+
LockTimeoutError,
|
|
18
|
+
agentDir,
|
|
19
|
+
delegateLegacyRun,
|
|
20
|
+
isInside,
|
|
21
|
+
legacyAcademyRoot,
|
|
22
|
+
validateName,
|
|
23
|
+
} from './core.mjs';
|
|
24
|
+
import { registerNightlyConsolidationTask } from './create.mjs';
|
|
25
|
+
import { launchClaude } from './hire.mjs';
|
|
26
|
+
import { readRuntimeProvider, toRuntimeProvider, toRuntimeToken } from './runtime.mjs';
|
|
27
|
+
import { AgentSpecError, writeAgentYamlScalar } from './yaml.mjs';
|
|
28
|
+
import {
|
|
29
|
+
renderAcademySystemPrompt,
|
|
30
|
+
writeProjectCodexSkillBridge,
|
|
31
|
+
writeProjectPluginInstance,
|
|
32
|
+
writeSkillsScaffold,
|
|
33
|
+
} from './scaffold.mjs';
|
|
34
|
+
|
|
35
|
+
// The agent's runtime is only knowable once the agent is resolved. A bare run
|
|
36
|
+
// reads what the agent already declared, so it can neither launch the wrong
|
|
37
|
+
// provider nor re-register the nightly job under one. Only an explicit --agent
|
|
38
|
+
// is a decision, and only a decision is persisted.
|
|
39
|
+
function resolveRuntime(dir, requested) {
|
|
40
|
+
if (!requested) return toRuntimeToken(readRuntimeProvider(dir));
|
|
41
|
+
persistRuntime(dir, requested);
|
|
42
|
+
return requested;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// `run` is outside the envelope, so the durable write's typed failures are
|
|
46
|
+
// rendered the way every other run failure is: one actionable line. Anything
|
|
47
|
+
// untyped still throws, because a run has no envelope to hide it in.
|
|
48
|
+
function persistRuntime(dir, requested) {
|
|
49
|
+
try {
|
|
50
|
+
writeAgentYamlScalar(dir, 'runtime', toRuntimeProvider(requested));
|
|
51
|
+
} catch (error) {
|
|
52
|
+
const typed = error instanceof LockTimeoutError || error instanceof AgentSpecError;
|
|
53
|
+
if (!typed) throw error;
|
|
54
|
+
console.error(`Error: ${error.message}`);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function prepareAgentRun(name, requestedRuntime, passthrough) {
|
|
60
|
+
validateName(name);
|
|
61
|
+
const dir = agentDir(name);
|
|
62
|
+
if (!existsSync(dir)) {
|
|
63
|
+
console.error(`Agent "${name}" not found at ${dir}`);
|
|
64
|
+
console.error(`Run \`${CLI_NAME} create ${name}\` or \`${CLI_NAME} hire\` first.`);
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const legacyRoot = legacyAcademyRoot(dir);
|
|
69
|
+
if (legacyRoot) {
|
|
70
|
+
delegateLegacyRun(name, passthrough, legacyRoot);
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const runtime = resolveRuntime(dir, requestedRuntime);
|
|
75
|
+
writeSkillsScaffold(dir, name);
|
|
76
|
+
const systemPromptPath = renderAcademySystemPrompt(dir, name);
|
|
77
|
+
const projectDir = resolve(process.cwd());
|
|
78
|
+
const inAgentHome = isInside(projectDir, dir);
|
|
79
|
+
const codex =
|
|
80
|
+
runtime === 'codex'
|
|
81
|
+
? { ...writeCodexProfile(dir, name), runtimeContextPath: codexRuntimeContextPath() }
|
|
82
|
+
: undefined;
|
|
83
|
+
if (codex) writeCodexHooks(codex.runtimeContextPath);
|
|
84
|
+
const nightlyTask = registerNightlyConsolidationTask(dir, name);
|
|
85
|
+
if (!nightlyTask.registered && process.env.ACADEMY_SKIP_NIGHTLY_TASK !== '1') {
|
|
86
|
+
console.error(
|
|
87
|
+
`Warning: nightly consolidation job "${nightlyTask.id}" not updated: ${nightlyTask.reason}`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const env = {
|
|
92
|
+
...process.env,
|
|
93
|
+
ACADEMY_AGENT_DIR: dir,
|
|
94
|
+
ACADEMY_AGENT_NAME: name,
|
|
95
|
+
};
|
|
96
|
+
return { codex, dir, env, inAgentHome, name, passthrough, projectDir, runtime, systemPromptPath };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function launchInAgentHome(context) {
|
|
100
|
+
const { codex, dir, env, name, passthrough, systemPromptPath } = context;
|
|
101
|
+
if (!codex) {
|
|
102
|
+
launchClaude(['--system-prompt-file', systemPromptPath, ...passthrough], {
|
|
103
|
+
cwd: dir,
|
|
104
|
+
env,
|
|
105
|
+
message: `Launching ${name} with Claude Code in agent home (${dir})`,
|
|
106
|
+
});
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
launchCodex(
|
|
110
|
+
[
|
|
111
|
+
'--profile',
|
|
112
|
+
codex.profileName,
|
|
113
|
+
'-C',
|
|
114
|
+
dir,
|
|
115
|
+
'--add-dir',
|
|
116
|
+
dir,
|
|
117
|
+
'-c',
|
|
118
|
+
`model_instructions_file=${JSON.stringify(systemPromptPath)}`,
|
|
119
|
+
...passthrough,
|
|
120
|
+
],
|
|
121
|
+
{ cwd: dir, env, message: `Launching ${name} with Codex in agent home (${dir})`, ...codex },
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function launchInProject(context) {
|
|
126
|
+
const { codex, dir, env, name, passthrough, projectDir, systemPromptPath } = context;
|
|
127
|
+
if (codex) {
|
|
128
|
+
writeProjectCodexSkillBridge(projectDir, dir);
|
|
129
|
+
launchCodex(
|
|
130
|
+
[
|
|
131
|
+
'--profile',
|
|
132
|
+
codex.profileName,
|
|
133
|
+
'-C',
|
|
134
|
+
projectDir,
|
|
135
|
+
'--add-dir',
|
|
136
|
+
dir,
|
|
137
|
+
'-c',
|
|
138
|
+
`model_instructions_file=${JSON.stringify(systemPromptPath)}`,
|
|
139
|
+
...passthrough,
|
|
140
|
+
],
|
|
141
|
+
{
|
|
142
|
+
cwd: projectDir,
|
|
143
|
+
env: { ...env, ACADEMY_PROJECT_DIR: projectDir },
|
|
144
|
+
message: `Launching ${name} with Codex for project ${basename(projectDir)}`,
|
|
145
|
+
...codex,
|
|
146
|
+
},
|
|
147
|
+
);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const pluginDir = writeProjectPluginInstance(projectDir, name, dir);
|
|
152
|
+
launchClaude(
|
|
153
|
+
['--plugin-dir', pluginDir, '--system-prompt-file', systemPromptPath, ...passthrough],
|
|
154
|
+
{
|
|
155
|
+
cwd: projectDir,
|
|
156
|
+
env: { ...env, ACADEMY_PROJECT_DIR: projectDir },
|
|
157
|
+
message: `Launching ${name} with Claude Code for project ${basename(projectDir)} (plugin: ${pluginDir})`,
|
|
158
|
+
},
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function runAgent(name, runtime, passthrough) {
|
|
163
|
+
const context = prepareAgentRun(name, runtime, passthrough);
|
|
164
|
+
if (!context) return;
|
|
165
|
+
if (context.inAgentHome) launchInAgentHome(context);
|
|
166
|
+
else launchInProject(context);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Runtime-agnostic by construction. An `--agent` on this command line comes
|
|
170
|
+
// from a job an earlier build registered; it is accepted and ignored, because
|
|
171
|
+
// the persisted scalar is the only source of truth a scheduled run may read.
|
|
172
|
+
// The child is spawned bare, so it persists nothing and cannot revert the
|
|
173
|
+
// operator's edit — editing agent.yaml is the published way to change a
|
|
174
|
+
// runtime, and a nightly that carried a snapshot undid that edit every night.
|
|
175
|
+
export function runNightly(name) {
|
|
176
|
+
validateName(name);
|
|
177
|
+
const dir = agentDir(name);
|
|
178
|
+
if (!existsSync(dir)) {
|
|
179
|
+
console.error(`Agent "${name}" not found at ${dir}`);
|
|
180
|
+
process.exit(1);
|
|
181
|
+
}
|
|
182
|
+
const runtime = toRuntimeToken(readRuntimeProvider(dir));
|
|
183
|
+
|
|
184
|
+
const markerPath = pendingMarkerPath(dir);
|
|
185
|
+
if (!existsSync(markerPath)) {
|
|
186
|
+
console.log(
|
|
187
|
+
`Skipping nightly consolidation for ${name}: no new session activity since the last run.`,
|
|
188
|
+
);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const revision = Number(readJsonFile(markerPath, {}).revision);
|
|
193
|
+
const prompt = [
|
|
194
|
+
"Use the nightly-consolidation skill to consolidate today's pending observation memory.",
|
|
195
|
+
'Update the v3 memory surfaces conservatively, write the dreams report,',
|
|
196
|
+
'and finish with the dreams report path.',
|
|
197
|
+
].join(' ');
|
|
198
|
+
const providerArgs =
|
|
199
|
+
runtime === 'codex'
|
|
200
|
+
? [...SCHEDULED_CODEX_PERMISSION_ARGS, prompt]
|
|
201
|
+
: [...SCHEDULED_CLAUDE_PERMISSION_ARGS, '-p', prompt];
|
|
202
|
+
const result = spawnSync(
|
|
203
|
+
process.execPath,
|
|
204
|
+
[ACADEMY_CLI_PATH, 'run', name, '--', ...providerArgs],
|
|
205
|
+
{
|
|
206
|
+
stdio: 'inherit',
|
|
207
|
+
cwd: dir,
|
|
208
|
+
env: {
|
|
209
|
+
...process.env,
|
|
210
|
+
ACADEMY_SKIP_NIGHTLY_TASK: '1',
|
|
211
|
+
ACADEMY_NIGHTLY_RUN: '1',
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
);
|
|
215
|
+
const status = result.status ?? 1;
|
|
216
|
+
|
|
217
|
+
if (status === 0 && existsSync(markerPath)) {
|
|
218
|
+
const currentRevision = Number(readJsonFile(markerPath, {}).revision);
|
|
219
|
+
if (currentRevision === revision) unlinkSync(markerPath);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
process.exit(status);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
226
|
+
// `list` — list all agents
|
|
227
|
+
// ─────────────────────────────────────────────────────────────────────────────
|