@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,242 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, realpathSync, rmSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { pendingMarkerPath } from '../../hooks/memory_store.mjs';
|
|
5
|
+
import {
|
|
6
|
+
ACADEMY_ROOT,
|
|
7
|
+
AGENTS_ROOT,
|
|
8
|
+
CLI_NAME,
|
|
9
|
+
activeCommandName,
|
|
10
|
+
NIGHTLY_JOB_CRON,
|
|
11
|
+
agentDir,
|
|
12
|
+
contractError,
|
|
13
|
+
contractOk,
|
|
14
|
+
LockTimeoutError,
|
|
15
|
+
RuntimeUnavailableError,
|
|
16
|
+
resolveExecutable,
|
|
17
|
+
validateAgentsRoot,
|
|
18
|
+
validateName,
|
|
19
|
+
} from './core.mjs';
|
|
20
|
+
import { assertNotArchived } from './archived.mjs';
|
|
21
|
+
import { LogCorruptError, appendLifecycleEvent } from './eventlog.mjs';
|
|
22
|
+
import { agentRecord } from './inspect.mjs';
|
|
23
|
+
import { deleteNightlyConsolidation } from './lifecycle.mjs';
|
|
24
|
+
import {
|
|
25
|
+
renderAcademySystemPrompt,
|
|
26
|
+
scaffoldBootFiles,
|
|
27
|
+
writeAgentClaudeMd,
|
|
28
|
+
writeAgentYaml,
|
|
29
|
+
writeOwnershipMarker,
|
|
30
|
+
writePluginSymlink,
|
|
31
|
+
writeSettingsLocal,
|
|
32
|
+
writeSkillsScaffold,
|
|
33
|
+
} from './scaffold.mjs';
|
|
34
|
+
|
|
35
|
+
// Every executable Academy needs is resolved this way so a missing binary
|
|
36
|
+
// raises RuntimeUnavailableError instead of exiting mid-operation.
|
|
37
|
+
export const MUST_EXIST = { throwOnMissing: true };
|
|
38
|
+
|
|
39
|
+
export function createAgent(name, json = false) {
|
|
40
|
+
const { dir, nightlyTask } = provisionAgent(name, json);
|
|
41
|
+
reportCreated(name, dir, nightlyTask, json);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The one creation path. `create` reports what it returns as a create and the
|
|
45
|
+
// headless hire reports the same thing as a hire, so an agent hired without a
|
|
46
|
+
// terminal is indistinguishable from a created one in ownership, scheduling and
|
|
47
|
+
// events. A second scaffolder would be a second definition of what an Academy
|
|
48
|
+
// agent is, and the two would disagree the first time one of them learned a
|
|
49
|
+
// rule the other did not.
|
|
50
|
+
export function provisionAgent(name, json = false) {
|
|
51
|
+
const dir = assertAgentNameFree(name, json);
|
|
52
|
+
scaffoldAgentDir(dir, name, json);
|
|
53
|
+
const nightlyTask = registerNightlyConsolidationTask(dir, name);
|
|
54
|
+
if (!nightlyTask.registered && !nightlyTask.skipped) {
|
|
55
|
+
rmSync(dir, { recursive: true, force: true });
|
|
56
|
+
failNightlyRegistration(name, nightlyTask, json);
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
appendLifecycleEvent('agent_created', name, dir);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (nightlyTask.registered) deleteNightlyConsolidation(name, realpathSync(dir));
|
|
62
|
+
rmSync(dir, { recursive: true, force: true });
|
|
63
|
+
failLifecyclePublish(name, error, json);
|
|
64
|
+
}
|
|
65
|
+
return { dir, nightlyTask };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The name question, asked before anything is written. The headless hire asks
|
|
69
|
+
// it before it resolves a runtime, so a name that is already taken never
|
|
70
|
+
// becomes a child process holding an explicit write permission.
|
|
71
|
+
export function assertAgentNameFree(name, json = false) {
|
|
72
|
+
validateName(name);
|
|
73
|
+
validateAgentsRoot();
|
|
74
|
+
// A fresh agent must not take a name the holding area still owns: unarchiving
|
|
75
|
+
// it afterwards could only ever collide.
|
|
76
|
+
assertNotArchived(name, json);
|
|
77
|
+
const dir = agentDir(name);
|
|
78
|
+
if (!existsSync(dir)) return dir;
|
|
79
|
+
const message = `Agent "${name}" already exists at ${dir}`;
|
|
80
|
+
if (json) {
|
|
81
|
+
contractError(activeCommandName(), 'agent_exists', message, { name, dir: resolve(dir) });
|
|
82
|
+
}
|
|
83
|
+
console.error(message);
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function scaffoldAgentDir(dir, name, json) {
|
|
88
|
+
try {
|
|
89
|
+
mkdirSync(dir, { recursive: false });
|
|
90
|
+
const today = scaffoldBootFiles(dir, name);
|
|
91
|
+
writeAgentYaml(dir, name, today);
|
|
92
|
+
writeAgentClaudeMd(dir, name);
|
|
93
|
+
writeSkillsScaffold(dir, name);
|
|
94
|
+
writeSettingsLocal(dir);
|
|
95
|
+
writePluginSymlink(dir);
|
|
96
|
+
writeOwnershipMarker(dir, name);
|
|
97
|
+
renderAcademySystemPrompt(dir, name);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
rmSync(dir, { recursive: true, force: true });
|
|
100
|
+
if (json) throw error;
|
|
101
|
+
console.error(`Error: failed to create agent "${name}": ${error.message}`);
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// A host with no scheduler binary used to exit from inside the resolver, which
|
|
107
|
+
// skipped the rollback, the event, and the envelope, and left an orphan agent
|
|
108
|
+
// that no client could see and no retry could replace.
|
|
109
|
+
function failNightlyRegistration(name, nightlyTask, json) {
|
|
110
|
+
const message = `failed to register nightly consolidation job for "${name}": ${nightlyTask.reason}`;
|
|
111
|
+
if (json) contractError(activeCommandName(), 'runtime_unavailable', message, { name });
|
|
112
|
+
console.error(`Error: ${message}`);
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Reached only after the scaffold is rolled back, so the envelope reports a
|
|
117
|
+
// create that left nothing behind.
|
|
118
|
+
function failLifecyclePublish(name, error, json) {
|
|
119
|
+
const message = `failed to publish lifecycle event for "${name}": ${error.message}`;
|
|
120
|
+
if (json) {
|
|
121
|
+
if (error instanceof LockTimeoutError || error instanceof LogCorruptError) {
|
|
122
|
+
contractError(activeCommandName(), error.code, message, error.fields);
|
|
123
|
+
}
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
console.error(`Error: ${message}`);
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function reportCreated(name, dir, nightlyTask, json) {
|
|
131
|
+
if (json) {
|
|
132
|
+
contractOk('create', {
|
|
133
|
+
created: true,
|
|
134
|
+
...agentRecord(name),
|
|
135
|
+
scheduledJobId: nightlyTask.registered ? nightlyTask.id : null,
|
|
136
|
+
});
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
console.log(`Created agent "${name}" at ${dir}`);
|
|
141
|
+
if (nightlyTask.registered) {
|
|
142
|
+
console.log(
|
|
143
|
+
`Registered nightly consolidation job "${nightlyTask.id}" (${NIGHTLY_JOB_CRON} ${nightlyTask.timezone}).`,
|
|
144
|
+
);
|
|
145
|
+
} else {
|
|
146
|
+
console.log(`Nightly consolidation job not registered: ${nightlyTask.reason}`);
|
|
147
|
+
}
|
|
148
|
+
console.log('');
|
|
149
|
+
console.log('Next:');
|
|
150
|
+
console.log(` ${CLI_NAME} hire # interactive hire flow to populate the 8 surfaces`);
|
|
151
|
+
console.log(` ${CLI_NAME} run ${name} # launch Claude Code in the agent's home`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function localTimezone() {
|
|
155
|
+
return (
|
|
156
|
+
process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'America/Los_Angeles'
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// The job is registered once and then runs unattended for months, so it carries
|
|
161
|
+
// no runtime: a token here would be a snapshot of the scalar as it stood on
|
|
162
|
+
// registration day, and the child run would rewrite every later operator edit
|
|
163
|
+
// back to it. `nightly` reads the persisted scalar instead.
|
|
164
|
+
export function registerNightlyConsolidationTask(dir, name) {
|
|
165
|
+
const id = `${name}-nightly-consolidation`;
|
|
166
|
+
if (process.env.ACADEMY_SKIP_NIGHTLY_TASK === '1') {
|
|
167
|
+
return {
|
|
168
|
+
id,
|
|
169
|
+
registered: false,
|
|
170
|
+
skipped: true,
|
|
171
|
+
reason: 'skipped by ACADEMY_SKIP_NIGHTLY_TASK=1',
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const timezone = localTimezone();
|
|
176
|
+
const runArgs = ['nightly', name];
|
|
177
|
+
let academyBin;
|
|
178
|
+
let helmTasksBin;
|
|
179
|
+
try {
|
|
180
|
+
academyBin = resolveExecutable('ACADEMY_BIN', join(ACADEMY_ROOT, 'bin', 'academy'), MUST_EXIST);
|
|
181
|
+
helmTasksBin = resolveExecutable('ACADEMY_HELM_TASKS_BIN', 'helm-tasks', MUST_EXIST);
|
|
182
|
+
} catch (error) {
|
|
183
|
+
if (!(error instanceof RuntimeUnavailableError)) throw error;
|
|
184
|
+
return { id, registered: false, reason: error.message };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const args = [
|
|
188
|
+
'schedule',
|
|
189
|
+
'--cwd',
|
|
190
|
+
dir,
|
|
191
|
+
'--id',
|
|
192
|
+
id,
|
|
193
|
+
'--cron',
|
|
194
|
+
NIGHTLY_JOB_CRON,
|
|
195
|
+
'--timezone',
|
|
196
|
+
timezone,
|
|
197
|
+
'--condition-file-exists',
|
|
198
|
+
pendingMarkerPath(dir),
|
|
199
|
+
'--command',
|
|
200
|
+
academyBin,
|
|
201
|
+
'--env-json',
|
|
202
|
+
JSON.stringify({ AGENTS_ROOT: resolve(AGENTS_ROOT) }),
|
|
203
|
+
'--replace',
|
|
204
|
+
'--tags',
|
|
205
|
+
'academy,nightly,consolidation',
|
|
206
|
+
'--timeout-sec',
|
|
207
|
+
'3600',
|
|
208
|
+
'--',
|
|
209
|
+
...runArgs,
|
|
210
|
+
];
|
|
211
|
+
|
|
212
|
+
const result = spawnSync(helmTasksBin, args, {
|
|
213
|
+
cwd: dir,
|
|
214
|
+
encoding: 'utf8',
|
|
215
|
+
env: process.env,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
if (result.error) return { id, registered: false, reason: result.error.message };
|
|
219
|
+
if (result.status !== 0) {
|
|
220
|
+
const reason = helmFailureReason(result.stderr || result.stdout, result.status);
|
|
221
|
+
return { id, registered: false, reason };
|
|
222
|
+
}
|
|
223
|
+
return { id, registered: true, timezone };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function helmFailureReason(output, status) {
|
|
227
|
+
const fallback = `helm-tasks exited ${status}`;
|
|
228
|
+
const text = output?.trim();
|
|
229
|
+
if (!text) return fallback;
|
|
230
|
+
try {
|
|
231
|
+
const parsed = JSON.parse(text);
|
|
232
|
+
const message = parsed.errors?.[0]?.message || parsed.data?.activation?.health?.reason;
|
|
233
|
+
if (message) return message;
|
|
234
|
+
} catch {
|
|
235
|
+
// Fall through to text truncation for non-JSON helm output.
|
|
236
|
+
}
|
|
237
|
+
return text.length > 240 ? `${text.slice(0, 237)}...` : text;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
241
|
+
// `hire` — launch Claude Code with the hire skill loaded
|
|
242
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import {
|
|
5
|
+
ACADEMY_ROOT,
|
|
6
|
+
AGENTS_ROOT,
|
|
7
|
+
CONTRACT_VERSION,
|
|
8
|
+
agentDir,
|
|
9
|
+
auditAgentsRoot,
|
|
10
|
+
contractOk,
|
|
11
|
+
printJson,
|
|
12
|
+
resolveExecutable,
|
|
13
|
+
} from './core.mjs';
|
|
14
|
+
import { eventLogPath } from './eventlog.mjs';
|
|
15
|
+
import { isAgentDirectory } from './inspect.mjs';
|
|
16
|
+
import { backfillPlan } from './migrate.mjs';
|
|
17
|
+
import { runtimeProviderOrNull } from './runtime.mjs';
|
|
18
|
+
import { unattributableSessionCount } from './sessions.mjs';
|
|
19
|
+
|
|
20
|
+
// The frozen capability answer at contract_version 1. `notes`, `nightly`,
|
|
21
|
+
// `clean`, `root`, and `run` all keep working and none of them is contract, so
|
|
22
|
+
// none of them appears here: `notes` writes a surface, `root` duplicates
|
|
23
|
+
// `agentsRoot`, `nightly` is called by the scheduler Academy registers, `clean`
|
|
24
|
+
// traces to no criterion, and `run` spawns with inherited stdio and can never
|
|
25
|
+
// emit an envelope.
|
|
26
|
+
const PUBLISHED_COMMANDS = [
|
|
27
|
+
'doctor',
|
|
28
|
+
'list',
|
|
29
|
+
'inspect',
|
|
30
|
+
'tokens',
|
|
31
|
+
'budget',
|
|
32
|
+
'sessions',
|
|
33
|
+
'events',
|
|
34
|
+
'create',
|
|
35
|
+
'hire',
|
|
36
|
+
'rename',
|
|
37
|
+
'archive',
|
|
38
|
+
'unarchive',
|
|
39
|
+
'delete',
|
|
40
|
+
'migrate',
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/** Published above and built by no phase yet. Empty: every command is real. */
|
|
44
|
+
export const UNIMPLEMENTED_COMMANDS = [];
|
|
45
|
+
|
|
46
|
+
/** The executable each published provider needs, as [env override, PATH name]. */
|
|
47
|
+
const RUNTIME_EXECUTABLES = {
|
|
48
|
+
claude_code: ['ACADEMY_CLAUDE_BIN', 'claude'],
|
|
49
|
+
codex: ['ACADEMY_CODEX_BIN', 'codex'],
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// The availability gate. Read-only, and it reports every healthy part of a
|
|
53
|
+
// degraded install rather than failing: a client calls this before it renders
|
|
54
|
+
// anything, so an answer it cannot parse is an Academy it cannot show at all.
|
|
55
|
+
export function reportHealth(json) {
|
|
56
|
+
const audit = auditAgentsRoot();
|
|
57
|
+
const payload = {
|
|
58
|
+
contracts: [CONTRACT_VERSION],
|
|
59
|
+
version: reportedVersion(),
|
|
60
|
+
packageRoot: ACADEMY_ROOT,
|
|
61
|
+
agentsRoot: audit.root,
|
|
62
|
+
eventLog: eventLogPath(),
|
|
63
|
+
commands: PUBLISHED_COMMANDS,
|
|
64
|
+
runtimes: runtimeAvailability(),
|
|
65
|
+
errors: audit.problem ? [] : healthErrors(),
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
if (audit.problem) return reportUnusableRoot(payload, audit.problem, json);
|
|
69
|
+
if (json) return contractOk('doctor', payload);
|
|
70
|
+
printReport(payload);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The one state answered with ok:false. Academy can still describe itself, but
|
|
74
|
+
// every agent-addressed command raises `unsafe_agent_path` on exactly this
|
|
75
|
+
// root, so a client told ok:true would render an interface whose first call
|
|
76
|
+
// fails. The whole payload ships anyway, because a client parsing the failure
|
|
77
|
+
// still needs the version and contract it is talking to.
|
|
78
|
+
function reportUnusableRoot(payload, problem, json) {
|
|
79
|
+
if (json) {
|
|
80
|
+
printJson(
|
|
81
|
+
{
|
|
82
|
+
contract_version: CONTRACT_VERSION,
|
|
83
|
+
ok: false,
|
|
84
|
+
command: 'doctor',
|
|
85
|
+
...payload,
|
|
86
|
+
error: { code: 'unsafe_agent_path', message: problem, agentsRoot: payload.agentsRoot },
|
|
87
|
+
},
|
|
88
|
+
process.stderr,
|
|
89
|
+
);
|
|
90
|
+
} else {
|
|
91
|
+
printReport(payload);
|
|
92
|
+
console.error(`Error: ${problem}`);
|
|
93
|
+
}
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Two builds with different capabilities must never report the same version.
|
|
98
|
+
// A published version covers installs, because npm forbids reusing one. It does
|
|
99
|
+
// not cover checkouts, so a checkout names its own commit as semver build
|
|
100
|
+
// metadata. No `.git` means no checkout — including an install that happens to
|
|
101
|
+
// sit inside somebody else's repository.
|
|
102
|
+
function reportedVersion() {
|
|
103
|
+
const { version } = JSON.parse(readFileSync(join(ACADEMY_ROOT, 'package.json'), 'utf8'));
|
|
104
|
+
const build = describeCheckout();
|
|
105
|
+
return build ? `${version}+${build}` : version;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Git prefers GIT_DIR over discovery from cwd, and any git hook, `git rebase
|
|
109
|
+
// --exec`, or CI wrapper exports one. Left inherited, two Academy checkouts
|
|
110
|
+
// invoked under the same GIT_DIR report an identical version — the collision
|
|
111
|
+
// `version` exists to prevent. The repository is named outright and every
|
|
112
|
+
// inherited git variable is dropped. `--dirty` walks the work tree, so the one
|
|
113
|
+
// command a client calls before rendering anything is bounded in time too.
|
|
114
|
+
const INHERITED_GIT_VARS = ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_COMMON_DIR'];
|
|
115
|
+
|
|
116
|
+
function gitCleanEnv() {
|
|
117
|
+
const env = { ...process.env };
|
|
118
|
+
for (const name of INHERITED_GIT_VARS) delete env[name];
|
|
119
|
+
return env;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function describeCheckout() {
|
|
123
|
+
if (!existsSync(join(ACADEMY_ROOT, '.git'))) return null;
|
|
124
|
+
const described = spawnSync(
|
|
125
|
+
'git',
|
|
126
|
+
[
|
|
127
|
+
'--git-dir',
|
|
128
|
+
join(ACADEMY_ROOT, '.git'),
|
|
129
|
+
'--work-tree',
|
|
130
|
+
ACADEMY_ROOT,
|
|
131
|
+
'describe',
|
|
132
|
+
'--always',
|
|
133
|
+
'--dirty',
|
|
134
|
+
],
|
|
135
|
+
{ cwd: ACADEMY_ROOT, encoding: 'utf8', timeout: 2000, env: gitCleanEnv() },
|
|
136
|
+
);
|
|
137
|
+
if (described.error || described.status !== 0) return null;
|
|
138
|
+
return described.stdout.trim().replace(/[^0-9A-Za-z-]/g, '-') || null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function runtimeAvailability() {
|
|
142
|
+
return Object.fromEntries(
|
|
143
|
+
Object.entries(RUNTIME_EXECUTABLES).map(([provider, [envName, fallback]]) => [
|
|
144
|
+
provider,
|
|
145
|
+
{ available: executableExists(envName, fallback) },
|
|
146
|
+
]),
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// A probe, not a resolution. `resolveExecutable` exits or throws for the
|
|
151
|
+
// commands that need the binary, and doctor may do neither. The existence check
|
|
152
|
+
// covers an env override pointing at a path that is not there.
|
|
153
|
+
function executableExists(envName, fallback) {
|
|
154
|
+
try {
|
|
155
|
+
return existsSync(resolveExecutable(envName, fallback, { throwOnMissing: true }));
|
|
156
|
+
} catch {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// The health channel, and it is not the error channel. A code here names a
|
|
162
|
+
// degraded thing and how much of it there is; it never appears inside an
|
|
163
|
+
// `error` object, and none of the fifteen command-failure codes appears here.
|
|
164
|
+
// Zero counts are omitted, so a healthy install reports an empty array.
|
|
165
|
+
// `unowned_agents` is counted from the backfill's own plan, not from a second
|
|
166
|
+
// ownership sweep. A count a client acts on must equal what `migrate` then
|
|
167
|
+
// repairs, and two enumerations of "unowned" drift the moment one of them
|
|
168
|
+
// learns a rule the other has not.
|
|
169
|
+
function healthErrors() {
|
|
170
|
+
return [
|
|
171
|
+
{ code: 'unowned_agents', count: backfillPlan().repair.length },
|
|
172
|
+
{ code: 'invalid_runtime_agents', count: agentNames().filter(hasUnreadableRuntime).length },
|
|
173
|
+
{ code: 'unattributable_sessions', count: unattributableSessionCount() },
|
|
174
|
+
].filter((entry) => entry.count > 0);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function hasUnreadableRuntime(name) {
|
|
178
|
+
return runtimeProviderOrNull(agentDir(name)) === null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Guarded rather than raised: a root that passed the audit and then cannot be
|
|
182
|
+
// listed is still a root doctor must report the rest of the build against.
|
|
183
|
+
function agentNames() {
|
|
184
|
+
try {
|
|
185
|
+
return readdirSync(AGENTS_ROOT).filter(isAgentDirectory).sort();
|
|
186
|
+
} catch {
|
|
187
|
+
return [];
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function printReport(payload) {
|
|
192
|
+
console.log(`academy ${payload.version} contract ${CONTRACT_VERSION}`);
|
|
193
|
+
console.log(` packageRoot ${payload.packageRoot}`);
|
|
194
|
+
console.log(` agentsRoot ${payload.agentsRoot}`);
|
|
195
|
+
console.log(` eventLog ${payload.eventLog}`);
|
|
196
|
+
for (const [provider, { available }] of Object.entries(payload.runtimes)) {
|
|
197
|
+
console.log(` runtime ${provider.padEnd(12)} ${available ? 'available' : 'unavailable'}`);
|
|
198
|
+
}
|
|
199
|
+
console.log(` commands ${payload.commands.join(' ')}`);
|
|
200
|
+
for (const entry of payload.errors) {
|
|
201
|
+
console.log(` health ${entry.code} ${entry.count}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { AGENTS_ROOT, withFileLock } from './core.mjs';
|
|
5
|
+
|
|
6
|
+
// The lifecycle event log is the durable history of every specialist hired and
|
|
7
|
+
// removed. It is append-only and never trimmed, so the sequence it carries is
|
|
8
|
+
// permanent and a client can dedup and resume on it.
|
|
9
|
+
|
|
10
|
+
function agentsRootParent() {
|
|
11
|
+
return dirname(resolve(AGENTS_ROOT));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function eventLogPath() {
|
|
15
|
+
return join(agentsRootParent(), 'events.jsonl');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Raised when the event log holds bytes but no record Academy can sequence
|
|
19
|
+
// from. Thrown rather than exited so the caller can roll its work back first.
|
|
20
|
+
export class LogCorruptError extends Error {
|
|
21
|
+
constructor(message, eventLog) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.code = 'log_corrupt';
|
|
24
|
+
this.fields = { eventLog };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readEventLog(eventsPath) {
|
|
29
|
+
if (!existsSync(eventsPath)) return null;
|
|
30
|
+
const text = readFileSync(eventsPath, 'utf8');
|
|
31
|
+
return text.trim() === '' ? null : text;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function recordSeq(line) {
|
|
35
|
+
if (line.trim() === '') return null;
|
|
36
|
+
try {
|
|
37
|
+
const { seq } = JSON.parse(line);
|
|
38
|
+
return Number.isInteger(seq) && seq >= 1 ? seq : null;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Scan backwards to the last record that parses. A final line torn by a
|
|
45
|
+
// disk-full or a kill -9 must never reset the sequence to 1: duplicate seq
|
|
46
|
+
// values in one log silently corrupt every client that dedups on them.
|
|
47
|
+
function lastSeq(text, eventsPath) {
|
|
48
|
+
const lines = text.split('\n');
|
|
49
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
50
|
+
const seq = recordSeq(lines[i]);
|
|
51
|
+
if (seq !== null) return seq;
|
|
52
|
+
}
|
|
53
|
+
throw new LogCorruptError(
|
|
54
|
+
`Event log ${eventsPath} has bytes but no parseable record. Move it aside to start a new log.`,
|
|
55
|
+
eventsPath,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function logCreatedRecord() {
|
|
60
|
+
// The log declares its own identity in its own first record, so a deleted and
|
|
61
|
+
// rebuilt log is a new logId and a client can tell the epochs apart.
|
|
62
|
+
return { seq: 1, event: 'log_created', logId: randomUUID(), ts: new Date().toISOString() };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function appendLifecycleEvent(event, name, dir, extra = {}) {
|
|
66
|
+
const parent = agentsRootParent();
|
|
67
|
+
const eventsPath = eventLogPath();
|
|
68
|
+
withFileLock(join(parent, 'events.lock'), () => {
|
|
69
|
+
const text = readEventLog(eventsPath);
|
|
70
|
+
const records = text === null ? [logCreatedRecord()] : [];
|
|
71
|
+
const previous = text === null ? 1 : lastSeq(text, eventsPath);
|
|
72
|
+
records.push({
|
|
73
|
+
seq: previous + 1,
|
|
74
|
+
event,
|
|
75
|
+
agentName: name,
|
|
76
|
+
agentDir: dir,
|
|
77
|
+
ts: new Date().toISOString(),
|
|
78
|
+
...extra,
|
|
79
|
+
});
|
|
80
|
+
// A torn final line has no newline. Start a fresh one so this record lands
|
|
81
|
+
// parseable instead of extending the damaged line.
|
|
82
|
+
const separator = text !== null && !text.endsWith('\n') ? '\n' : '';
|
|
83
|
+
appendFileSync(eventsPath, separator + records.map((r) => `${JSON.stringify(r)}\n`).join(''));
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The replay read. Deliberately more forgiving than the append path: a log with
|
|
88
|
+
// bytes but no parseable record is an operator repair only when Academy is
|
|
89
|
+
// about to write to it. On replay it simply carries no servable sequence, and
|
|
90
|
+
// the one replay rule in `events` answers that without a second code.
|
|
91
|
+
export function readSequencedRecords() {
|
|
92
|
+
const text = readEventLog(eventLogPath());
|
|
93
|
+
if (text === null) return [];
|
|
94
|
+
return text
|
|
95
|
+
.split('\n')
|
|
96
|
+
.map(parseRecord)
|
|
97
|
+
.filter((record) => record !== null);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function parseRecord(line) {
|
|
101
|
+
if (line.trim() === '') return null;
|
|
102
|
+
try {
|
|
103
|
+
const record = JSON.parse(line);
|
|
104
|
+
return Number.isInteger(record.seq) && record.seq >= 1 ? record : null;
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// The bounds a client dedups and resumes on, derived from records the caller
|
|
111
|
+
// already holds. A command that ships records must publish the bounds of those
|
|
112
|
+
// same records: read the log twice and the watermark can be strictly newer than
|
|
113
|
+
// the response, so a client adopting it as its resume point skips every record
|
|
114
|
+
// that landed in between, permanently.
|
|
115
|
+
export function boundsOf(records) {
|
|
116
|
+
if (records.length === 0) return { firstSeq: 0, lastSeq: 0, logId: null };
|
|
117
|
+
return {
|
|
118
|
+
firstSeq: records[0].seq,
|
|
119
|
+
lastSeq: records[records.length - 1].seq,
|
|
120
|
+
logId: records[0].logId ?? null,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// `firstSeq` is derived, not assumed: nothing trims, so it reads 1 for every
|
|
125
|
+
// log Academy has written, and deriving it keeps that an observation rather
|
|
126
|
+
// than a promise.
|
|
127
|
+
export function readEventLogWatermark() {
|
|
128
|
+
return boundsOf(readSequencedRecords());
|
|
129
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { checkAgentsRoot, contractOk, exitJsonError } from './core.mjs';
|
|
2
|
+
import { boundsOf, readSequencedRecords } from './eventlog.mjs';
|
|
3
|
+
|
|
4
|
+
// Delivery is at-least-once and `seq` is the dedup key: a client applies each
|
|
5
|
+
// seq exactly once, so replaying an overlapping range must derive the same
|
|
6
|
+
// state. Nothing here filters by anything but the sequence.
|
|
7
|
+
export function readEvents({ since, logId, json, invalidOption }) {
|
|
8
|
+
if (invalidOption !== undefined) return unreadableInvocation(invalidOption, json);
|
|
9
|
+
checkAgentsRoot(json);
|
|
10
|
+
// One read. The bounds are derived from the very records this call ships, so
|
|
11
|
+
// the watermark a client adopts can never be newer than what it was given.
|
|
12
|
+
const records = readSequencedRecords();
|
|
13
|
+
const bounds = boundsOf(records);
|
|
14
|
+
const requestedSeq = parseWatermark(since);
|
|
15
|
+
|
|
16
|
+
if (!servable(requestedSeq, logId, bounds)) {
|
|
17
|
+
replayUnavailable(since, logId, bounds, json);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const events = records.filter((record) => record.seq > requestedSeq);
|
|
22
|
+
if (json) {
|
|
23
|
+
contractOk('events', { ...bounds, events });
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
printEvents(bounds, events);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Answered before the log is touched. A client that asked for --json and got
|
|
30
|
+
// human text has no code, no contract_version and no way to tell a rejected
|
|
31
|
+
// invocation from a crash, so the option it cannot use is named in the envelope.
|
|
32
|
+
// `invalid_spec` is the published code for a request Academy will not accept.
|
|
33
|
+
function unreadableInvocation(option, json) {
|
|
34
|
+
const message = `Unknown events option: ${option}. Use --since <seq> [--logid <id>] [--json].`;
|
|
35
|
+
if (json) exitJsonError('invalid_spec', message, { option });
|
|
36
|
+
console.error(`Error: ${message}`);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A watermark is a non-negative integer. Zero means "I have applied nothing". */
|
|
41
|
+
function parseWatermark(since) {
|
|
42
|
+
return /^\d+$/.test(String(since)) ? Number(since) : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// One failure rule, not a table. A watermark that cannot be served exactly is
|
|
46
|
+
// never answered with an empty success, because a client cannot tell that
|
|
47
|
+
// apart from "you are up to date" and would silently diverge.
|
|
48
|
+
function servable(requestedSeq, logId, bounds) {
|
|
49
|
+
if (requestedSeq === null) return false;
|
|
50
|
+
if (requestedSeq > bounds.lastSeq) return false;
|
|
51
|
+
return logId === null || logId === bounds.logId;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function replayUnavailable(since, logId, bounds, json) {
|
|
55
|
+
const message = logIdMismatch(logId, bounds)
|
|
56
|
+
? `Event log ${bounds.logId ?? '(absent)'} is not the log ${logId} this watermark came from. Re-sync from list.`
|
|
57
|
+
: `Sequence ${since} cannot be served from a log holding ${bounds.firstSeq}..${bounds.lastSeq}. Re-sync from list.`;
|
|
58
|
+
const fields = { requestedSeq: normaliseRequested(since), ...bounds };
|
|
59
|
+
if (json) exitJsonError('replay_unavailable', message, fields);
|
|
60
|
+
console.error(`Error: ${message}`);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function logIdMismatch(logId, bounds) {
|
|
65
|
+
return logId !== null && logId !== bounds.logId;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Echo an unreadable watermark back verbatim so the client can see what it
|
|
69
|
+
// sent; a readable one is echoed as the number it is.
|
|
70
|
+
function normaliseRequested(since) {
|
|
71
|
+
const parsed = parseWatermark(since);
|
|
72
|
+
return parsed === null ? String(since) : parsed;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function printEvents(bounds, events) {
|
|
76
|
+
console.log(`log ${bounds.logId ?? '(none)'} seq ${bounds.firstSeq}..${bounds.lastSeq}`);
|
|
77
|
+
for (const record of events) {
|
|
78
|
+
console.log(` ${String(record.seq).padStart(6)} ${record.event} ${record.agentName ?? ''}`);
|
|
79
|
+
}
|
|
80
|
+
}
|