@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,229 @@
|
|
|
1
|
+
import { rmSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import {
|
|
5
|
+
ACADEMY_ROOT,
|
|
6
|
+
RuntimeUnavailableError,
|
|
7
|
+
contractOk,
|
|
8
|
+
exitJsonError,
|
|
9
|
+
resolveExecutable,
|
|
10
|
+
validateName,
|
|
11
|
+
} from './core.mjs';
|
|
12
|
+
import { MUST_EXIST, assertAgentNameFree, provisionAgent } from './create.mjs';
|
|
13
|
+
import { appendLifecycleEvent } from './eventlog.mjs';
|
|
14
|
+
import { HireSpecError, readHireSpec } from './hire-spec.mjs';
|
|
15
|
+
import { agentRecord } from './inspect.mjs';
|
|
16
|
+
import { deleteNightlyConsolidation } from './lifecycle.mjs';
|
|
17
|
+
import { writeAgentYamlScalar } from './yaml.mjs';
|
|
18
|
+
|
|
19
|
+
// `hire --spec <path> [--json]` — the headless form. The interactive form
|
|
20
|
+
// spawns with inherited stdio and exits on the child's status, so no envelope
|
|
21
|
+
// can ever print from it and the child's own streams would pollute the JSON a
|
|
22
|
+
// client parses. This path captures both child streams and forwards neither.
|
|
23
|
+
//
|
|
24
|
+
// The risk this phase carries is that a specification file drives a runtime
|
|
25
|
+
// holding an explicit non-interactive write permission. Every fault Academy can
|
|
26
|
+
// name is therefore answered before the runtime is resolved or spawned: the
|
|
27
|
+
// file is proved acceptable, the name is proved free, and only then does a
|
|
28
|
+
// child exist. Creation and nightly registration stay outside the child
|
|
29
|
+
// entirely, so the runtime never decides what an Academy agent is.
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* How long the runtime gets before it is killed. A `-p` turn that writes eight
|
|
33
|
+
* surfaces is minutes of work, and a client waiting on a synchronous command
|
|
34
|
+
* cannot wait on a wedged child forever.
|
|
35
|
+
*/
|
|
36
|
+
const DEFAULT_TIMEOUT_MS = 600_000;
|
|
37
|
+
|
|
38
|
+
/** Enough captured output to report a failure, and a bound on a chatty child. */
|
|
39
|
+
const MAX_CAPTURE_BYTES = 4 * 1024 * 1024;
|
|
40
|
+
const REPORTED_OUTPUT_CHARS = 500;
|
|
41
|
+
|
|
42
|
+
function hireTimeoutMs() {
|
|
43
|
+
const configured = Number.parseInt(process.env.ACADEMY_HIRE_TIMEOUT_MS ?? '', 10);
|
|
44
|
+
return Number.isInteger(configured) && configured > 0 ? configured : DEFAULT_TIMEOUT_MS;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function failHire(code, message, fields, json) {
|
|
48
|
+
if (json) exitJsonError(code, message, fields);
|
|
49
|
+
console.error(`Error: ${message}`);
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Not a usage dump: the headless form publishes an envelope, so the option it
|
|
54
|
+
// does not know is answered inside it.
|
|
55
|
+
function assertKnownOptions(invalidOption, json) {
|
|
56
|
+
if (!invalidOption) return;
|
|
57
|
+
failHire(
|
|
58
|
+
'invalid_spec',
|
|
59
|
+
`Unknown hire option: ${invalidOption}. Use --spec <path> and --json.`,
|
|
60
|
+
{ option: invalidOption },
|
|
61
|
+
json,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function loadSpec(specPath, json) {
|
|
66
|
+
try {
|
|
67
|
+
return readHireSpec(specPath);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (!(error instanceof HireSpecError)) throw error;
|
|
70
|
+
failHire(error.code, error.message, error.fields, json);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function resolveHireRuntime(json) {
|
|
75
|
+
try {
|
|
76
|
+
return resolveExecutable('ACADEMY_CLAUDE_BIN', 'claude', MUST_EXIST);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (!(error instanceof RuntimeUnavailableError)) throw error;
|
|
79
|
+
failHire(error.code, error.message, error.fields, json);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The specification's own fields, persisted before the child starts so the
|
|
84
|
+
// runtime reads its brief from the same agent.yaml every other command reads.
|
|
85
|
+
// Quoted through JSON.stringify: the free-text fields already refuse control
|
|
86
|
+
// characters, quotes and backslashes, so the quoted form round-trips exactly
|
|
87
|
+
// through the CLI's line-regex reader.
|
|
88
|
+
function writeSpecScalars(dir, spec) {
|
|
89
|
+
writeAgentYamlScalar(dir, 'role', JSON.stringify(spec.role));
|
|
90
|
+
writeAgentYamlScalar(dir, 'objective', JSON.stringify(spec.objective));
|
|
91
|
+
if (spec.runtime) writeAgentYamlScalar(dir, 'runtime', spec.runtime);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Not contract. The specification schema and the response envelope are the
|
|
95
|
+
// published surface; what Academy says to its own runtime is Academy's to
|
|
96
|
+
// change. `role` and `objective` are caller-supplied text and reach the model
|
|
97
|
+
// here, which is why they are capped and single-line and why the child is given
|
|
98
|
+
// no authority over creation, naming, scheduling or the event log.
|
|
99
|
+
function headlessHirePrompt({ name, role, objective }) {
|
|
100
|
+
return [
|
|
101
|
+
`Use the Academy hire skill to finish hiring the agent "${name}".`,
|
|
102
|
+
'Its home is the current working directory and already holds the scaffolded',
|
|
103
|
+
'boot surfaces, agent.yaml and ownership marker.',
|
|
104
|
+
`Role: ${role}`,
|
|
105
|
+
`Objective: ${objective}`,
|
|
106
|
+
'Rewrite the eight surface files in place and stop. Do not create, rename,',
|
|
107
|
+
'archive or delete any agent, and do not write outside this directory.',
|
|
108
|
+
'There is no user to ask, so make your own best inference and proceed.',
|
|
109
|
+
].join('\n');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// `acceptEdits` rather than `bypassPermissions`: the child has to write eight
|
|
113
|
+
// markdown files in its own working directory and needs nothing beyond that.
|
|
114
|
+
// stdio is captured on both streams and stdin is closed, so a runtime that
|
|
115
|
+
// falls back to asking a question fails fast instead of holding the timeout.
|
|
116
|
+
function runHeadlessRuntime(claudeBin, dir, spec) {
|
|
117
|
+
return spawnSync(
|
|
118
|
+
claudeBin,
|
|
119
|
+
[
|
|
120
|
+
'-p',
|
|
121
|
+
headlessHirePrompt(spec),
|
|
122
|
+
'--permission-mode',
|
|
123
|
+
'acceptEdits',
|
|
124
|
+
'--plugin-dir',
|
|
125
|
+
ACADEMY_ROOT,
|
|
126
|
+
],
|
|
127
|
+
{
|
|
128
|
+
cwd: dir,
|
|
129
|
+
encoding: 'utf8',
|
|
130
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
131
|
+
timeout: hireTimeoutMs(),
|
|
132
|
+
// SIGKILL, because a hire being rolled back has nothing left to clean up
|
|
133
|
+
// and a runtime that ignores SIGTERM would hold the client open.
|
|
134
|
+
killSignal: 'SIGKILL',
|
|
135
|
+
maxBuffer: MAX_CAPTURE_BYTES,
|
|
136
|
+
},
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// The success signal is the child's exit status. A sentinel in its output was
|
|
141
|
+
// rejected: it would make a prompt token load-bearing, and the hire skill's
|
|
142
|
+
// prompts are explicitly not contract.
|
|
143
|
+
function runtimeFault(result, claudeBin) {
|
|
144
|
+
const timedOut = result.error?.code === 'ETIMEDOUT' || result.signal !== null;
|
|
145
|
+
if (timedOut) {
|
|
146
|
+
return {
|
|
147
|
+
message: `runtime ${claudeBin} did not finish within ${hireTimeoutMs()}ms`,
|
|
148
|
+
fields: { executable: claudeBin, status: null, timedOut: true },
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
if (result.error) {
|
|
152
|
+
return {
|
|
153
|
+
message: `failed to start runtime ${claudeBin}: ${result.error.message}`,
|
|
154
|
+
fields: { executable: claudeBin, status: null, timedOut: false },
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
if (result.status === 0) return null;
|
|
158
|
+
return {
|
|
159
|
+
message: `runtime ${claudeBin} exited ${result.status}: ${excerpt(result.stderr || result.stdout)}`,
|
|
160
|
+
fields: { executable: claudeBin, status: result.status, timedOut: false },
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function excerpt(output) {
|
|
165
|
+
const text = (output ?? '').trim();
|
|
166
|
+
if (text === '') return '(no output)';
|
|
167
|
+
return text.length > REPORTED_OUTPUT_CHARS
|
|
168
|
+
? `${text.slice(0, REPORTED_OUTPUT_CHARS - 3)}...`
|
|
169
|
+
: text;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// A hire that did not finish is not a hire. The envelope says ok:false, so the
|
|
173
|
+
// disk must agree: the nightly job is unscheduled and the directory removed,
|
|
174
|
+
// and the append-only log gets a compensating agent_deleted rather than losing
|
|
175
|
+
// the agent_created that was already published. Leaving the half-hired agent
|
|
176
|
+
// would register nightly consolidation for a specialist nobody hired and would
|
|
177
|
+
// block every retry of the same name behind a manual delete.
|
|
178
|
+
function rollbackHire(name, dir, nightlyTask) {
|
|
179
|
+
let dirReal = resolve(dir);
|
|
180
|
+
try {
|
|
181
|
+
dirReal = realpathSync(dir);
|
|
182
|
+
} catch {
|
|
183
|
+
/* already gone; the unschedule still names the canonical path */
|
|
184
|
+
}
|
|
185
|
+
if (nightlyTask.registered) deleteNightlyConsolidation(name, dirReal);
|
|
186
|
+
rmSync(dir, { recursive: true, force: true });
|
|
187
|
+
try {
|
|
188
|
+
appendLifecycleEvent('agent_deleted', name, dir);
|
|
189
|
+
return null;
|
|
190
|
+
} catch (error) {
|
|
191
|
+
// Reported inside the runtime fault rather than swallowed: a client
|
|
192
|
+
// replaying the log has to know the compensating record is missing.
|
|
193
|
+
return error.message;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function failRuntime(name, dir, nightlyTask, fault, json) {
|
|
198
|
+
const rollbackFault = rollbackHire(name, dir, nightlyTask);
|
|
199
|
+
const fields = { name, ...fault.fields };
|
|
200
|
+
if (rollbackFault) fields.rollbackFault = rollbackFault;
|
|
201
|
+
failHire('runtime_unavailable', `hire of "${name}" failed: ${fault.message}`, fields, json);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function reportHired(name, dir, nightlyTask, json) {
|
|
205
|
+
if (json) {
|
|
206
|
+
contractOk('hire', {
|
|
207
|
+
hired: true,
|
|
208
|
+
...agentRecord(name),
|
|
209
|
+
scheduledJobId: nightlyTask.registered ? nightlyTask.id : null,
|
|
210
|
+
});
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
console.log(`Hired agent "${name}" at ${dir}`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function hireFromSpec({ spec, json, invalidOption }) {
|
|
217
|
+
assertKnownOptions(invalidOption, json);
|
|
218
|
+
const parsed = loadSpec(spec, json);
|
|
219
|
+
// The one field that reaches a path join, held to the rule every other
|
|
220
|
+
// agent-addressed command applies.
|
|
221
|
+
validateName(parsed.name);
|
|
222
|
+
assertAgentNameFree(parsed.name, json);
|
|
223
|
+
const claudeBin = resolveHireRuntime(json);
|
|
224
|
+
const { dir, nightlyTask } = provisionAgent(parsed.name, json);
|
|
225
|
+
writeSpecScalars(dir, parsed);
|
|
226
|
+
const fault = runtimeFault(runHeadlessRuntime(claudeBin, dir, parsed), claudeBin);
|
|
227
|
+
if (fault) failRuntime(parsed.name, dir, nightlyTask, fault, json);
|
|
228
|
+
reportHired(parsed.name, dir, nightlyTask, json);
|
|
229
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { toRuntimeToken } from './runtime.mjs';
|
|
4
|
+
|
|
5
|
+
// The `hire --spec` file is the whole published input to a headless hire, and
|
|
6
|
+
// it is untrusted: it reaches a path join, a yaml scalar write, and a prompt
|
|
7
|
+
// handed to a runtime running with an explicit non-interactive permission mode.
|
|
8
|
+
// Everything here runs before the runtime is resolved or spawned, so a file
|
|
9
|
+
// Academy will not accept never becomes a child process.
|
|
10
|
+
//
|
|
11
|
+
// Schema — the same fields `create` writes into agent.yaml:
|
|
12
|
+
// { "name": "<kebab-case>", "role": "<text>", "objective": "<text>",
|
|
13
|
+
// "runtime": "claude_code" | "codex" (optional) }
|
|
14
|
+
|
|
15
|
+
/** Required keys. Every other published key is optional. */
|
|
16
|
+
const HIRE_SPEC_REQUIRED = ['name', 'role', 'objective'];
|
|
17
|
+
|
|
18
|
+
/** The closed key set. An unpublished key is a rejection, not a silent drop. */
|
|
19
|
+
const HIRE_SPEC_KEYS = [...HIRE_SPEC_REQUIRED, 'runtime'];
|
|
20
|
+
|
|
21
|
+
/** Longest a free-text field may be. Both fields land in agent.yaml. */
|
|
22
|
+
const HIRE_SPEC_TEXT_CAP = 2000;
|
|
23
|
+
|
|
24
|
+
/** Largest specification file Academy will read into memory. */
|
|
25
|
+
const SPEC_FILE_CAP = 64 * 1024;
|
|
26
|
+
|
|
27
|
+
// Control characters would close the yaml scalar the writer emits and open a
|
|
28
|
+
// second top-level key; a quote or a backslash survives the write but not the
|
|
29
|
+
// CLI's line-regex reader, so the round-trip would stop being exact.
|
|
30
|
+
const UNSAFE_TEXT_RE = /[\p{Cc}"\\]/u;
|
|
31
|
+
|
|
32
|
+
// Thrown rather than exited so the caller answers in the envelope it owns.
|
|
33
|
+
// `code` is one of the published fifteen: `invalid_spec` for a file Academy
|
|
34
|
+
// cannot read or a schema it does not accept, `invalid_runtime` for a runtime
|
|
35
|
+
// outside the canonical set.
|
|
36
|
+
export class HireSpecError extends Error {
|
|
37
|
+
constructor(code, message, fields = {}) {
|
|
38
|
+
super(message);
|
|
39
|
+
this.code = code;
|
|
40
|
+
this.fields = fields;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function invalidSpec(message, specPath, fields = {}) {
|
|
45
|
+
return new HireSpecError('invalid_spec', message, { specPath, ...fields });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function readHireSpec(path) {
|
|
49
|
+
const specPath = resolve(path);
|
|
50
|
+
return validateHireSpec(parseSpecFile(readSpecFile(specPath), specPath), specPath);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// A missing file, a directory, an unreadable one and an oversized one are all
|
|
54
|
+
// the same answer to the client: this is not a specification Academy can read.
|
|
55
|
+
function readSpecFile(specPath) {
|
|
56
|
+
let stats;
|
|
57
|
+
try {
|
|
58
|
+
stats = statSync(specPath);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
throw invalidSpec(`cannot read hire specification at ${specPath}: ${error.code}`, specPath);
|
|
61
|
+
}
|
|
62
|
+
if (!stats.isFile()) {
|
|
63
|
+
throw invalidSpec(`hire specification at ${specPath} is not a file`, specPath);
|
|
64
|
+
}
|
|
65
|
+
if (stats.size > SPEC_FILE_CAP) {
|
|
66
|
+
throw invalidSpec(
|
|
67
|
+
`hire specification at ${specPath} is ${stats.size} bytes, over the ${SPEC_FILE_CAP}-byte limit`,
|
|
68
|
+
specPath,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
return readFileSync(specPath, 'utf8');
|
|
73
|
+
} catch (error) {
|
|
74
|
+
throw invalidSpec(`cannot read hire specification at ${specPath}: ${error.code}`, specPath);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function parseSpecFile(text, specPath) {
|
|
79
|
+
let parsed;
|
|
80
|
+
try {
|
|
81
|
+
parsed = JSON.parse(text);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
throw invalidSpec(
|
|
84
|
+
`hire specification at ${specPath} is not valid JSON: ${error.message}`,
|
|
85
|
+
specPath,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
89
|
+
throw invalidSpec(`hire specification at ${specPath} must be a JSON object`, specPath);
|
|
90
|
+
}
|
|
91
|
+
return parsed;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// The name is checked for shape here and for the agent-name rule by
|
|
95
|
+
// `validateName` in the caller, so a headless hire is held to exactly the rule
|
|
96
|
+
// every other agent-addressed command applies.
|
|
97
|
+
function validateHireSpec(parsed, specPath) {
|
|
98
|
+
assertKnownKeys(parsed, specPath);
|
|
99
|
+
const spec = { specPath, runtime: null };
|
|
100
|
+
for (const field of HIRE_SPEC_REQUIRED) {
|
|
101
|
+
spec[field] = assertText(parsed, field, specPath);
|
|
102
|
+
}
|
|
103
|
+
if ('runtime' in parsed) spec.runtime = assertRuntime(parsed.runtime, specPath);
|
|
104
|
+
return spec;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function assertKnownKeys(parsed, specPath) {
|
|
108
|
+
const unknown = Object.keys(parsed).filter((key) => !HIRE_SPEC_KEYS.includes(key));
|
|
109
|
+
if (unknown.length === 0) return;
|
|
110
|
+
throw invalidSpec(
|
|
111
|
+
`hire specification at ${specPath} declares unpublished key(s): ${unknown.join(', ')}. Use ${HIRE_SPEC_KEYS.join(', ')}.`,
|
|
112
|
+
specPath,
|
|
113
|
+
{ keys: unknown },
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function assertText(parsed, field, specPath) {
|
|
118
|
+
const value = parsed[field];
|
|
119
|
+
if (typeof value !== 'string') {
|
|
120
|
+
throw invalidSpec(`hire specification at ${specPath} needs a string "${field}"`, specPath, {
|
|
121
|
+
field,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
const text = value.trim();
|
|
125
|
+
if (text === '') {
|
|
126
|
+
throw invalidSpec(`hire specification at ${specPath} has an empty "${field}"`, specPath, {
|
|
127
|
+
field,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
if (text.length > HIRE_SPEC_TEXT_CAP) {
|
|
131
|
+
throw invalidSpec(
|
|
132
|
+
`hire specification at ${specPath} has a "${field}" of ${text.length} characters, over the ${HIRE_SPEC_TEXT_CAP}-character limit`,
|
|
133
|
+
specPath,
|
|
134
|
+
{ field },
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
if (UNSAFE_TEXT_RE.test(text)) {
|
|
138
|
+
throw invalidSpec(
|
|
139
|
+
`hire specification at ${specPath} has a "${field}" carrying a control character, quote or backslash`,
|
|
140
|
+
specPath,
|
|
141
|
+
{ field },
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
return text;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// The spec speaks the yaml and JSON vocabulary (claude_code), never the
|
|
148
|
+
// `--agent` token (claude-code), so the two forms cannot be used interchangeably
|
|
149
|
+
// by a client reading the published schema.
|
|
150
|
+
function assertRuntime(value, specPath) {
|
|
151
|
+
if (typeof value !== 'string') {
|
|
152
|
+
throw invalidSpec(`hire specification at ${specPath} needs a string "runtime"`, specPath, {
|
|
153
|
+
field: 'runtime',
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
if (!toRuntimeToken(value)) {
|
|
157
|
+
throw new HireSpecError(
|
|
158
|
+
'invalid_runtime',
|
|
159
|
+
`Runtime "${value}" is not a runtime Academy supports. Use claude_code or codex.`,
|
|
160
|
+
{ runtime: value, specPath },
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
return value;
|
|
164
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import {
|
|
6
|
+
ACADEMY_ROOT,
|
|
7
|
+
AGENTS_ROOT,
|
|
8
|
+
RuntimeUnavailableError,
|
|
9
|
+
contractError,
|
|
10
|
+
resolveExecutable,
|
|
11
|
+
} from './core.mjs';
|
|
12
|
+
|
|
13
|
+
function hireContextPath() {
|
|
14
|
+
const baseDir = join(process.env.HOME || homedir(), '.academy', 'hire-contexts');
|
|
15
|
+
mkdirSync(baseDir, { recursive: true });
|
|
16
|
+
const contextDir = mkdtempSync(join(baseDir, 'hire-'));
|
|
17
|
+
return join(contextDir, 'context.json');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function writeHireContext(passthrough) {
|
|
21
|
+
const path = hireContextPath();
|
|
22
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
23
|
+
writeFileSync(
|
|
24
|
+
path,
|
|
25
|
+
JSON.stringify(
|
|
26
|
+
{
|
|
27
|
+
createdAt: new Date().toISOString(),
|
|
28
|
+
packageRoot: ACADEMY_ROOT,
|
|
29
|
+
agentsRoot: resolve(AGENTS_ROOT),
|
|
30
|
+
passthrough,
|
|
31
|
+
},
|
|
32
|
+
null,
|
|
33
|
+
2,
|
|
34
|
+
) + '\n',
|
|
35
|
+
);
|
|
36
|
+
return path;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// `hire` is published in doctor's frozen command list, so a --json caller is
|
|
40
|
+
// entitled to parse the answer. The launch itself inherits stdio and no
|
|
41
|
+
// envelope can wrap it, but a launch that never happens is a command failure
|
|
42
|
+
// like any other. Asked before the context is written, so a run that cannot
|
|
43
|
+
// start leaves no orphaned hire-context directory behind.
|
|
44
|
+
function assertHireRuntime(json) {
|
|
45
|
+
if (process.env.ACADEMY_DRY_RUN === '1') return;
|
|
46
|
+
try {
|
|
47
|
+
resolveExecutable('ACADEMY_CLAUDE_BIN', 'claude', { throwOnMissing: true });
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (!(error instanceof RuntimeUnavailableError)) throw error;
|
|
50
|
+
if (json) contractError('hire', error.code, error.message, error.fields);
|
|
51
|
+
console.error(`[ACADEMY_RUNTIME] ${error.message}`);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function hireAgent(passthrough, json = false) {
|
|
57
|
+
// The hire skill is the orchestrator: it calls `academy create <slug>`,
|
|
58
|
+
// writes runnable starter surfaces, then schedules knowledge enrichment
|
|
59
|
+
// after the user-facing hiring summary.
|
|
60
|
+
// Positional arg seeds the first user message; session stays interactive.
|
|
61
|
+
assertHireRuntime(json);
|
|
62
|
+
const contextPath = writeHireContext(passthrough);
|
|
63
|
+
launchClaude(['--plugin-dir', ACADEMY_ROOT, 'run /hire', ...passthrough], {
|
|
64
|
+
cwd: ACADEMY_ROOT,
|
|
65
|
+
env: { ...process.env, ACADEMY_HIRE_CONTEXT: '1', ACADEMY_HIRE_CONTEXT_PATH: contextPath },
|
|
66
|
+
message: 'Launching Academy hire flow',
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function launchClaude(args, { cwd, env, message }) {
|
|
71
|
+
console.log(message);
|
|
72
|
+
const claudeBin =
|
|
73
|
+
process.env.ACADEMY_DRY_RUN === '1'
|
|
74
|
+
? process.env.ACADEMY_CLAUDE_BIN || 'claude'
|
|
75
|
+
: resolveExecutable('ACADEMY_CLAUDE_BIN', 'claude');
|
|
76
|
+
if (process.env.ACADEMY_DRY_RUN === '1') {
|
|
77
|
+
console.log(`[dry-run] ${claudeBin} ${args.join(' ')}`);
|
|
78
|
+
console.log(`[dry-run] cwd=${cwd}`);
|
|
79
|
+
if (env?.ACADEMY_AGENT_DIR) console.log(`[dry-run] ACADEMY_AGENT_DIR=${env.ACADEMY_AGENT_DIR}`);
|
|
80
|
+
if (env?.ACADEMY_PROJECT_DIR)
|
|
81
|
+
console.log(`[dry-run] ACADEMY_PROJECT_DIR=${env.ACADEMY_PROJECT_DIR}`);
|
|
82
|
+
process.exit(0);
|
|
83
|
+
}
|
|
84
|
+
const result = spawnSync(claudeBin, args, { stdio: 'inherit', cwd, env });
|
|
85
|
+
if (result.error) {
|
|
86
|
+
console.error(
|
|
87
|
+
`[ACADEMY_RUNTIME] Failed to launch Claude Code at ${claudeBin}: ${result.error.message}`,
|
|
88
|
+
);
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
process.exit(result.status ?? 0);
|
|
92
|
+
}
|