@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.
Files changed (53) hide show
  1. package/.claude-plugin/marketplace.json +14 -0
  2. package/.claude-plugin/plugin.json +6 -0
  3. package/CHANGELOG.md +46 -0
  4. package/LICENSE +21 -0
  5. package/README.md +209 -0
  6. package/bin/academy +2 -0
  7. package/conformance/README.md +60 -0
  8. package/conformance/discovery.test.mjs +140 -0
  9. package/conformance/envelope.test.mjs +185 -0
  10. package/conformance/error-codes.test.mjs +125 -0
  11. package/conformance/harness.mjs +180 -0
  12. package/conformance/identity.test.mjs +125 -0
  13. package/docs/integration-guide.md +1026 -0
  14. package/hooks/hook_runtime.mjs +100 -0
  15. package/hooks/hooks.json +26 -0
  16. package/hooks/inject_surface.py +122 -0
  17. package/hooks/memory_bridge.mjs +120 -0
  18. package/hooks/memory_store.mjs +66 -0
  19. package/hooks/register_session.mjs +51 -0
  20. package/hooks/sync_memory.mjs +27 -0
  21. package/package.json +41 -0
  22. package/scripts/agent.mjs +3 -0
  23. package/scripts/cli/archive.mjs +161 -0
  24. package/scripts/cli/archived.mjs +82 -0
  25. package/scripts/cli/args.mjs +282 -0
  26. package/scripts/cli/codex.mjs +216 -0
  27. package/scripts/cli/core.mjs +389 -0
  28. package/scripts/cli/create.mjs +242 -0
  29. package/scripts/cli/doctor.mjs +203 -0
  30. package/scripts/cli/eventlog.mjs +129 -0
  31. package/scripts/cli/events.mjs +80 -0
  32. package/scripts/cli/hire-headless.mjs +229 -0
  33. package/scripts/cli/hire-spec.mjs +164 -0
  34. package/scripts/cli/hire.mjs +92 -0
  35. package/scripts/cli/inspect.mjs +286 -0
  36. package/scripts/cli/lifecycle.mjs +296 -0
  37. package/scripts/cli/main.mjs +102 -0
  38. package/scripts/cli/migrate.mjs +183 -0
  39. package/scripts/cli/notes.mjs +104 -0
  40. package/scripts/cli/rename.mjs +172 -0
  41. package/scripts/cli/run.mjs +227 -0
  42. package/scripts/cli/runtime.mjs +47 -0
  43. package/scripts/cli/scaffold.mjs +332 -0
  44. package/scripts/cli/sessions.mjs +98 -0
  45. package/scripts/cli/templates.mjs +104 -0
  46. package/scripts/cli/yaml.mjs +124 -0
  47. package/skills/hire/SKILL.md +669 -0
  48. package/templates/agents/claude-code/knowledge-curator.md +14 -0
  49. package/templates/agents/codex/knowledge-curator.toml +9 -0
  50. package/templates/skills/check-in/SKILL.md +122 -0
  51. package/templates/skills/knowledge-curation/SKILL.md +132 -0
  52. package/templates/skills/nightly-consolidation/SKILL.md +240 -0
  53. 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
+ }