@joenandez/academy 0.4.0 → 0.5.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/.claude-plugin/marketplace.json +5 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +390 -0
  4. package/README.md +67 -122
  5. package/hooks/backstory/.claude-plugin/plugin.json +11 -0
  6. package/hooks/backstory/SOURCE +2 -0
  7. package/hooks/backstory/bin/backstory +9 -0
  8. package/hooks/backstory/hooks/hooks.json +45 -0
  9. package/hooks/backstory/scripts/knowledge.mjs +87 -0
  10. package/hooks/backstory/scripts/memory.mjs +685 -0
  11. package/hooks/backstory/scripts/tool-tick.mjs +1 -0
  12. package/hooks/backstory/skills/capture/SKILL.md +71 -0
  13. package/hooks/backstory/skills/capture/references/knowledge-capture-input.json +16 -0
  14. package/hooks/backstory/skills/capture/references/tagging-policy.md +29 -0
  15. package/hooks/backstory/skills/memory/SKILL.md +70 -0
  16. package/hooks/backstory/skills/setup/SKILL.md +85 -0
  17. package/hooks/context_metrics.mjs +67 -0
  18. package/hooks/hook_runtime.mjs +43 -2
  19. package/hooks/memory_bridge.mjs +286 -52
  20. package/hooks/memory_store.mjs +78 -26
  21. package/hooks/register_session.mjs +76 -14
  22. package/hooks/roque/.claude-plugin/plugin.json +6 -0
  23. package/hooks/roque/LICENSE +21 -0
  24. package/hooks/roque/SOURCE +1 -0
  25. package/hooks/roque/hooks.json +38 -0
  26. package/hooks/roque/package.json +3 -0
  27. package/hooks/roque/roque.js +1690 -0
  28. package/hooks/sync_memory.mjs +11 -7
  29. package/package.json +7 -3
  30. package/scripts/cli/agent-gate.mjs +70 -0
  31. package/scripts/cli/agent-id.mjs +37 -0
  32. package/scripts/cli/archive.mjs +39 -38
  33. package/scripts/cli/archived.mjs +4 -2
  34. package/scripts/cli/args-kinds.mjs +33 -0
  35. package/scripts/cli/args-notes.mjs +42 -0
  36. package/scripts/cli/args-owner.mjs +37 -0
  37. package/scripts/cli/args-run.mjs +38 -0
  38. package/scripts/cli/args.mjs +47 -104
  39. package/scripts/cli/beads.mjs +57 -0
  40. package/scripts/cli/codex.mjs +66 -12
  41. package/scripts/cli/core.mjs +117 -41
  42. package/scripts/cli/create.mjs +224 -76
  43. package/scripts/cli/curator-feed-codex.mjs +81 -0
  44. package/scripts/cli/curator-feed-observations.mjs +97 -0
  45. package/scripts/cli/curator-feed-scope.mjs +124 -0
  46. package/scripts/cli/curator-feed-sources.mjs +172 -0
  47. package/scripts/cli/curator-feed.mjs +318 -0
  48. package/scripts/cli/default-name.mjs +73 -0
  49. package/scripts/cli/doctor.mjs +67 -2
  50. package/scripts/cli/eventlog.mjs +6 -2
  51. package/scripts/cli/exit-code.mjs +9 -0
  52. package/scripts/cli/first-run.mjs +115 -0
  53. package/scripts/cli/heartbeat.mjs +135 -0
  54. package/scripts/cli/helm-skip.mjs +20 -0
  55. package/scripts/cli/hire-headless.mjs +37 -22
  56. package/scripts/cli/hire.mjs +8 -2
  57. package/scripts/cli/inspect.mjs +36 -18
  58. package/scripts/cli/lifecycle.mjs +95 -49
  59. package/scripts/cli/main.mjs +32 -2
  60. package/scripts/cli/memory-env.mjs +124 -0
  61. package/scripts/cli/migrate.mjs +13 -7
  62. package/scripts/cli/next-steps.mjs +13 -0
  63. package/scripts/cli/nightly-launch.mjs +118 -0
  64. package/scripts/cli/nightly.mjs +228 -0
  65. package/scripts/cli/notes.mjs +58 -8
  66. package/scripts/cli/owner-config.mjs +181 -0
  67. package/scripts/cli/owner-jobs.mjs +201 -0
  68. package/scripts/cli/prompt-append.mjs +135 -0
  69. package/scripts/cli/rename.mjs +90 -109
  70. package/scripts/cli/run-context.mjs +50 -0
  71. package/scripts/cli/run.mjs +236 -79
  72. package/scripts/cli/scaffold.mjs +119 -126
  73. package/scripts/cli/schedule-errors.mjs +70 -0
  74. package/scripts/cli/sessions.mjs +10 -3
  75. package/scripts/cli/skills-check.mjs +147 -0
  76. package/scripts/cli/skills.mjs +170 -0
  77. package/scripts/cli/start.mjs +201 -0
  78. package/scripts/cli/templates.mjs +119 -45
  79. package/scripts/cli/tldr.mjs +184 -0
  80. package/scripts/cli/toml-string.mjs +76 -0
  81. package/scripts/cli/user-work-view.mjs +48 -0
  82. package/scripts/cli/writable-dirs.mjs +23 -0
  83. package/scripts/cli/yaml.mjs +1 -1
  84. package/skills/hire/SKILL.md +112 -100
  85. package/skills/hire/references/owner.md +68 -0
  86. package/templates/agents/claude-code/knowledge-curator.md +5 -2
  87. package/templates/agents/codex/knowledge-curator.toml +2 -1
  88. package/templates/operating-model/base.md +78 -0
  89. package/templates/operating-model/owner.md +11 -0
  90. package/templates/operating-model/principal.md +9 -0
  91. package/templates/operating-model/specialist.md +9 -0
  92. package/templates/packs/owner/craft.md +32 -0
  93. package/templates/packs/owner/identity.md +13 -0
  94. package/templates/packs/owner/mandate.md +26 -0
  95. package/templates/packs/owner/owner/SKILL.md +118 -0
  96. package/templates/packs/owner/owner-feedback/SKILL.md +49 -0
  97. package/templates/packs/owner/owner-heartbeat/SKILL.md +35 -0
  98. package/templates/packs/owner/owner-nightly/SKILL.md +114 -0
  99. package/templates/packs/owner/owner-review/SKILL.md +70 -0
  100. package/templates/packs/owner/owner-work/SKILL.md +53 -0
  101. package/templates/packs/principal/craft.md +27 -0
  102. package/templates/packs/principal/identity.md +13 -0
  103. package/templates/packs/principal/mandate.md +23 -0
  104. package/templates/packs/principal/principal-brief/SKILL.md +44 -0
  105. package/templates/packs/principal/principal-intro/SKILL.md +95 -0
  106. package/templates/packs/principal/principal-nightly/SKILL.md +152 -0
  107. package/templates/skills/check-in/SKILL.md +34 -24
  108. package/templates/skills/knowledge-curation/SKILL.md +89 -48
  109. package/templates/skills/local-rules/SKILL.md +86 -0
  110. package/templates/skills/nightly-consolidation/SKILL.md +131 -100
  111. package/templates/skills/skill-method/SKILL.md +79 -0
  112. package/templates/skills/user-nightly/SKILL.md +143 -0
  113. package/templates/skills/work-graph/SKILL.md +75 -0
  114. package/conformance/README.md +0 -60
  115. package/conformance/discovery.test.mjs +0 -140
  116. package/conformance/envelope.test.mjs +0 -185
  117. package/conformance/error-codes.test.mjs +0 -125
  118. package/conformance/harness.mjs +0 -180
  119. package/conformance/identity.test.mjs +0 -125
  120. package/docs/integration-guide.md +0 -1042
  121. package/templates/skills/self-update/SKILL.md +0 -121
@@ -1,26 +1,36 @@
1
- import { existsSync, mkdirSync, realpathSync, rmSync } from 'node:fs';
2
- import { join, resolve } from 'node:path';
1
+ import { realpathSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { basename, join, resolve } from 'node:path';
3
3
  import { spawnSync } from 'node:child_process';
4
4
  import { pendingMarkerPath } from '../../hooks/memory_store.mjs';
5
5
  import {
6
6
  ACADEMY_ROOT,
7
7
  AGENTS_ROOT,
8
8
  CLI_NAME,
9
+ KIND_PROFILES,
9
10
  activeCommandName,
10
11
  NIGHTLY_JOB_CRON,
11
12
  agentDir,
12
13
  contractError,
13
14
  contractOk,
15
+ exitJsonError,
16
+ kindOf,
14
17
  LockTimeoutError,
15
18
  RuntimeUnavailableError,
16
19
  resolveExecutable,
17
20
  validateAgentsRoot,
18
21
  validateName,
19
22
  } from './core.mjs';
23
+ import { resolveTldrAgentsBinary, spawnTldrTasksArgs, tldrSpawnOptions } from './tldr.mjs';
24
+ import { scheduleFailureReason } from './schedule-errors.mjs';
20
25
  import { assertNotArchived } from './archived.mjs';
26
+ import { claimDefaultName, claimName, isAgentNameFree } from './default-name.mjs';
21
27
  import { LogCorruptError, appendLifecycleEvent } from './eventlog.mjs';
22
28
  import { agentRecord } from './inspect.mjs';
23
- import { deleteNightlyConsolidation } from './lifecycle.mjs';
29
+ import { nextSteps } from './next-steps.mjs';
30
+ import { isStopped } from './agent-gate.mjs';
31
+ import { deleteAcademyJobs } from './lifecycle.mjs';
32
+ import { nightlyRoute } from './nightly.mjs';
33
+ import { academyRuntimeArgs, registerKindTasks } from './owner-jobs.mjs';
24
34
  import {
25
35
  renderAcademySystemPrompt,
26
36
  scaffoldBootFiles,
@@ -36,9 +46,50 @@ import {
36
46
  // raises RuntimeUnavailableError instead of exiting mid-operation.
37
47
  export const MUST_EXIST = { throwOnMissing: true };
38
48
 
39
- export function createAgent(name, json = false) {
40
- const { dir, nightlyTask } = provisionAgent(name, json);
41
- reportCreated(name, dir, nightlyTask, json);
49
+ const DEFAULT_KIND = 'specialist';
50
+ export const INTRO_PENDING_MARKER = 'intro-pending.md';
51
+
52
+ // `guided` is the caller saying a person at a terminal made this create:
53
+ // 'opening' (a first session starts right after, so the report says that) or
54
+ // 'later' (--no-start, so the report names `run`). Either replaces the Next:
55
+ // block. `main.mjs` starts the session: this module cannot import run.mjs,
56
+ // which imports this one. With no name, the first free default name is claimed
57
+ // and returned.
58
+ export function createAgent(
59
+ name,
60
+ json = false,
61
+ { kind = DEFAULT_KIND, invalidOption, guided } = {},
62
+ ) {
63
+ assertCreateOptions(kind, invalidOption, json);
64
+ const created = provisionAgent(name, json, kind);
65
+ reportCreated(created.name, created.dir, created.jobs, json, guided);
66
+ return created.name;
67
+ }
68
+
69
+ // Both refusals come before the name is asked about, so a create Academy will
70
+ // not perform writes nothing. `invalid_spec` is the published code for an
71
+ // unknown option; there is no sixteenth code for it.
72
+ function assertCreateOptions(kind, invalidOption, json) {
73
+ if (invalidOption !== undefined) {
74
+ refuseCreate(
75
+ `Unknown create option: ${invalidOption}. Use create <name> [--kind <kind>] [--no-start] [--json].`,
76
+ { option: invalidOption },
77
+ json,
78
+ );
79
+ }
80
+ if (Object.hasOwn(KIND_PROFILES, kind)) return;
81
+ const kinds = Object.keys(KIND_PROFILES).join(', ');
82
+ refuseCreate(
83
+ `Agent kind "${kind}" is not a kind Academy supports. Use ${kinds}.`,
84
+ { kind },
85
+ json,
86
+ );
87
+ }
88
+
89
+ function refuseCreate(message, fields, json) {
90
+ if (json) exitJsonError('invalid_spec', message, fields);
91
+ console.error(`Error: ${message}`);
92
+ process.exit(1);
42
93
  }
43
94
 
44
95
  // The one creation path. `create` reports what it returns as a create and the
@@ -47,22 +98,48 @@ export function createAgent(name, json = false) {
47
98
  // events. A second scaffolder would be a second definition of what an Academy
48
99
  // agent is, and the two would disagree the first time one of them learned a
49
100
  // rule the other did not.
50
- export function provisionAgent(name, json = false) {
101
+ export function provisionAgent(name, json = false, kind = DEFAULT_KIND) {
102
+ const claimed = name === undefined ? claimDefaultName(kind, json) : claimNamedDir(name, json);
103
+ return provisionAgentDir(claimed.dir, claimed.name, json, kind);
104
+ }
105
+
106
+ // The mkdir is the one point where this request comes to own the directory. It
107
+ // shares the root event lock with every other name claim, and the final check
108
+ // runs inside it, where nothing exits. The loser refuses after release and
109
+ // removes nothing, because what is there belongs to the winner. Rollback is
110
+ // for a directory this request made.
111
+ function claimNamedDir(name, json) {
51
112
  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);
113
+ const claimed = claimName(name, json);
114
+ if (claimed instanceof Error) failScaffold(name, claimed, json);
115
+ // The same checks, asked again after release, exit with the code that fits.
116
+ if (!claimed) refuseTakenName(name, assertAgentNameFree(name, json), json);
117
+ return { name, dir };
118
+ }
119
+
120
+ function provisionAgentDir(dir, name, json, kind) {
121
+ scaffoldAgentDir(dir, name, json, kind);
122
+ const jobs = registerAcademyJobs(dir, name);
123
+ const failed = jobs.find((job) => !job.registered && !job.skipped);
124
+ if (failed) {
125
+ unregisterJobs(dir, jobs);
126
+ failJobRegistration(name, failed, json);
57
127
  }
58
128
  try {
59
129
  appendLifecycleEvent('agent_created', name, dir);
60
130
  } catch (error) {
61
- if (nightlyTask.registered) deleteNightlyConsolidation(name, realpathSync(dir));
62
- rmSync(dir, { recursive: true, force: true });
131
+ unregisterJobs(dir, jobs);
63
132
  failLifecyclePublish(name, error, json);
64
133
  }
65
- return { dir, nightlyTask };
134
+ return { name, dir, jobs };
135
+ }
136
+
137
+ // The rollback of a create that will not stand: every job it registered goes
138
+ // with the directory, so a retry of the name starts from nothing.
139
+ function unregisterJobs(dir, jobs) {
140
+ const ids = registeredJobIds(jobs);
141
+ if (ids.length > 0) deleteAcademyJobs(realpathSync(dir), ids);
142
+ rmSync(dir, { recursive: true, force: true });
66
143
  }
67
144
 
68
145
  // The name question, asked before anything is written. The headless hire asks
@@ -71,11 +148,15 @@ export function provisionAgent(name, json = false) {
71
148
  export function assertAgentNameFree(name, json = false) {
72
149
  validateName(name);
73
150
  validateAgentsRoot();
151
+ const dir = agentDir(name);
152
+ if (isAgentNameFree(name)) return dir;
74
153
  // A fresh agent must not take a name the holding area still owns: unarchiving
75
154
  // it afterwards could only ever collide.
76
- assertNotArchived(name, json);
77
- const dir = agentDir(name);
78
- if (!existsSync(dir)) return dir;
155
+ assertNotArchived(basename(dir), json);
156
+ return refuseTakenName(name, dir, json);
157
+ }
158
+
159
+ function refuseTakenName(name, dir, json) {
79
160
  const message = `Agent "${name}" already exists at ${dir}`;
80
161
  if (json) {
81
162
  contractError(activeCommandName(), 'agent_exists', message, { name, dir: resolve(dir) });
@@ -84,30 +165,45 @@ export function assertAgentNameFree(name, json = false) {
84
165
  process.exit(1);
85
166
  }
86
167
 
87
- function scaffoldAgentDir(dir, name, json) {
168
+ function scaffoldAgentDir(dir, name, json, kind) {
88
169
  try {
89
- mkdirSync(dir, { recursive: false });
90
- const today = scaffoldBootFiles(dir, name);
91
- writeAgentYaml(dir, name, today);
170
+ const today = new Date().toISOString().slice(0, 10);
171
+ // agent.yaml first: the kind it names selects the surfaces scaffolded next.
172
+ writeAgentYaml(dir, name, today, kind);
173
+ scaffoldBootFiles(dir, name, today);
92
174
  writeAgentClaudeMd(dir, name);
93
175
  writeSkillsScaffold(dir, name);
94
176
  writeSettingsLocal(dir);
95
177
  writePluginSymlink(dir);
96
178
  writeOwnershipMarker(dir, name);
179
+ if (kind === 'principal') writeIntroPendingMarker(dir);
97
180
  renderAcademySystemPrompt(dir, name);
98
181
  } catch (error) {
99
182
  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);
183
+ failScaffold(name, error, json);
103
184
  }
104
185
  }
105
186
 
187
+ // Not a boot surface and never in the prompt: `academy run` reads it to seed the
188
+ // first message, and the intro skill deletes it once the first work has started.
189
+ function writeIntroPendingMarker(dir) {
190
+ writeFileSync(
191
+ join(dir, INTRO_PENDING_MARKER),
192
+ 'The first-session intro is not finished. The principal-intro skill removes this file.\n',
193
+ );
194
+ }
195
+
196
+ function failScaffold(name, error, json) {
197
+ if (json) throw error;
198
+ console.error(`Error: failed to create agent "${name}": ${error.message}`);
199
+ process.exit(1);
200
+ }
201
+
106
202
  // A host with no scheduler binary used to exit from inside the resolver, which
107
203
  // skipped the rollback, the event, and the envelope, and left an orphan agent
108
204
  // 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}`;
205
+ function failJobRegistration(name, failed, json) {
206
+ const message = `failed to register ${jobLabel(failed.id)} "${failed.id}" for "${name}": ${failed.reason}`;
111
207
  if (json) contractError(activeCommandName(), 'runtime_unavailable', message, { name });
112
208
  console.error(`Error: ${message}`);
113
209
  process.exit(1);
@@ -127,16 +223,19 @@ function failLifecyclePublish(name, error, json) {
127
223
  process.exit(1);
128
224
  }
129
225
 
130
- function reportCreated(name, dir, nightlyTask, json) {
226
+ function reportCreated(name, dir, jobs, json, guided) {
131
227
  if (json) {
132
- contractOk('create', {
133
- created: true,
134
- ...agentRecord(name),
135
- scheduledJobId: nightlyTask.registered ? nightlyTask.id : null,
136
- });
228
+ // A new Owner is a draft until its mandate is signed (`start`), so it is never started here.
229
+ const fields = { created: true, ...agentRecord(name), ...scheduledJobFields(jobs) };
230
+ contractOk('create', kindOf(dir) === 'owner' ? { ...fields, started: false } : fields);
137
231
  return;
138
232
  }
139
233
 
234
+ const [nightlyTask, ...ownerTasks] = jobs;
235
+ if (guided) {
236
+ reportGuidedCreated(name, nightlyTask, guided);
237
+ return;
238
+ }
140
239
  console.log(`Created agent "${name}" at ${dir}`);
141
240
  if (nightlyTask.registered) {
142
241
  console.log(
@@ -145,23 +244,42 @@ function reportCreated(name, dir, nightlyTask, json) {
145
244
  } else {
146
245
  console.log(`Nightly consolidation job not registered: ${nightlyTask.reason}`);
147
246
  }
247
+ for (const task of ownerTasks) console.log(`Registered ${jobLabel(task.id)} "${task.id}".`);
148
248
  console.log('');
149
249
  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`);
250
+ console.log(nextSteps(name, kindOf(dir)).join('\n'));
152
251
  }
153
252
 
154
- function localTimezone() {
253
+ // The guided path is a person at a terminal making a Principal: short lines, no paths.
254
+ function reportGuidedCreated(name, nightlyTask, guided) {
255
+ console.log(`✓ Created ${name}`);
256
+ if (nightlyTask.registered) {
257
+ const [minute, hour] = NIGHTLY_JOB_CRON.split(' ');
258
+ const time = `${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`;
259
+ console.log(`✓ Nightly run at ${time} ${nightlyTask.timezone}`);
260
+ } else {
261
+ console.log(`Nightly consolidation job not registered: ${nightlyTask.reason}`);
262
+ }
263
+ if (guided === 'opening') {
264
+ console.log(`Opening your first session with ${name}…`);
265
+ console.log(`Next time: ${CLI_NAME} run ${name}`);
266
+ } else {
267
+ console.log(`Start it when you are ready: ${CLI_NAME} run ${name}`);
268
+ }
269
+ }
270
+
271
+ export function localTimezone() {
155
272
  return (
156
273
  process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'America/Los_Angeles'
157
274
  );
158
275
  }
159
276
 
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) {
277
+ // The command job is registered once and then runs unattended for months, so it
278
+ // carries no runtime: a token here would be a snapshot of the scalar as it stood
279
+ // on registration day, and the child run would rewrite every later operator
280
+ // edit back to it. `nightly` reads the persisted scalar instead. An Owner's
281
+ // provider job must name its runtime; `run --agent` re-registers it on change.
282
+ function registerNightlyConsolidationTask(dir, name) {
165
283
  const id = `${name}-nightly-consolidation`;
166
284
  if (process.env.ACADEMY_SKIP_NIGHTLY_TASK === '1') {
167
285
  return {
@@ -171,20 +289,20 @@ export function registerNightlyConsolidationTask(dir, name) {
171
289
  reason: 'skipped by ACADEMY_SKIP_NIGHTLY_TASK=1',
172
290
  };
173
291
  }
292
+ if (isStopped(dir)) return { id, registered: false, skipped: true, reason: 'agent is stopped' };
174
293
 
175
294
  const timezone = localTimezone();
176
- const runArgs = ['nightly', name];
177
295
  let academyBin;
178
- let helmTasksBin;
296
+ let tldrAgentsBin;
179
297
  try {
180
298
  academyBin = resolveExecutable('ACADEMY_BIN', join(ACADEMY_ROOT, 'bin', 'academy'), MUST_EXIST);
181
- helmTasksBin = resolveExecutable('ACADEMY_HELM_TASKS_BIN', 'helm-tasks', MUST_EXIST);
299
+ tldrAgentsBin = resolveTldrAgentsBinary();
182
300
  } catch (error) {
183
301
  if (!(error instanceof RuntimeUnavailableError)) throw error;
184
302
  return { id, registered: false, reason: error.message };
185
303
  }
186
304
 
187
- const args = [
305
+ const args = spawnTldrTasksArgs([
188
306
  'schedule',
189
307
  '--cwd',
190
308
  dir,
@@ -196,45 +314,75 @@ export function registerNightlyConsolidationTask(dir, name) {
196
314
  timezone,
197
315
  '--condition-file-exists',
198
316
  pendingMarkerPath(dir),
199
- '--command',
200
- academyBin,
201
- '--env-json',
202
- JSON.stringify({ AGENTS_ROOT: resolve(AGENTS_ROOT) }),
203
- '--replace',
317
+ ...nightlyJobLaunch(dir, name, academyBin),
318
+ '--merge',
204
319
  '--tags',
205
320
  'academy,nightly,consolidation',
206
321
  '--timeout-sec',
207
322
  '3600',
208
- '--',
209
- ...runArgs,
210
- ];
323
+ ...nightlyJobCommandArgs(dir, name),
324
+ ]);
211
325
 
212
- const result = spawnSync(helmTasksBin, args, {
213
- cwd: dir,
214
- encoding: 'utf8',
215
- env: process.env,
216
- });
326
+ const task = runSchedule(tldrAgentsBin, dir, id, args);
327
+ return task.registered ? { ...task, timezone } : task;
328
+ }
217
329
 
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 };
330
+ export function runSchedule(tldrAgentsBin, dir, id, args) {
331
+ const result = spawnSync(tldrAgentsBin, args, tldrSpawnOptions(dir));
332
+ if (result.error || result.status !== 0)
333
+ return { id, registered: false, reason: scheduleFailureReason(result) };
334
+ return { id, registered: true };
224
335
  }
225
336
 
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;
337
+ // Every Academy job an agent of its kind runs, nightly first. The skip flag
338
+ // that silences the nightly silences the set, and an Owner whose nightly did
339
+ // not register does not go on to half a schedule. A stopped agent's nightly is
340
+ // skipped, so it registers nothing.
341
+ export function registerAcademyJobs(dir, name) {
342
+ const nightly = registerNightlyConsolidationTask(dir, name);
343
+ return nightly.registered ? registerKindTasks(dir, name, nightly) : [nightly];
344
+ }
345
+
346
+ export const registeredJobIds = (jobs) => jobs.filter((job) => job.registered).map((job) => job.id);
347
+
348
+ export const jobLabel = (id) =>
349
+ id.endsWith('-nightly-consolidation')
350
+ ? 'nightly consolidation job'
351
+ : id.endsWith('-principal-brief')
352
+ ? 'Principal brief job'
353
+ : 'Owner job';
354
+
355
+ // `scheduledJobId` keeps its published meaning, the nightly; the set is
356
+ // additive, so a client that knows only the nightly reads what it always did.
357
+ export function scheduledJobFields(jobs) {
358
+ const [nightly] = jobs;
359
+ return {
360
+ scheduledJobId: nightly.registered ? nightly.id : null,
361
+ scheduledJobIds: registeredJobIds(jobs),
362
+ };
363
+ }
364
+
365
+ // An Owner's nightly runs through the Academy provider, like its other Academy
366
+ // jobs; `academy run` derives ACADEMY_NIGHTLY_JOB from the job id and enters
367
+ // the same routine as `academy nightly`. A provider job carries no env: the
368
+ // scheduler refuses it there. Every other kind keeps the command job, which
369
+ // carries its env and already records the real exit.
370
+ function nightlyJobLaunch(dir, name, academyBin) {
371
+ const env = { AGENTS_ROOT: resolve(AGENTS_ROOT) };
372
+ if (kindOf(dir) !== 'owner') return ['--command', academyBin, '--env-json', JSON.stringify(env)];
373
+ return [
374
+ '--provider',
375
+ 'academy',
376
+ '--academy-agent',
377
+ name,
378
+ ...academyRuntimeArgs(dir),
379
+ '--prompt',
380
+ nightlyRoute('owner'),
381
+ ];
382
+ }
383
+
384
+ function nightlyJobCommandArgs(dir, name) {
385
+ return kindOf(dir) === 'owner' ? [] : ['--', 'nightly', name];
238
386
  }
239
387
 
240
388
  // ─────────────────────────────────────────────────────────────────────────────
@@ -0,0 +1,81 @@
1
+ // Codex prompt reader for curator-feed.mjs. Read-only.
2
+ import { readdirSync } from 'node:fs';
3
+ import { basename, join } from 'node:path';
4
+ import { MEMORY_BRIDGE_CODEX_ORIGINATOR } from '../../hooks/memory_bridge.mjs';
5
+ import {
6
+ inWindow,
7
+ isTempCwd,
8
+ jsonLines,
9
+ mtimeMs,
10
+ stripEnvelopes,
11
+ } from './curator-feed-sources.mjs';
12
+
13
+ const CODEX_ORIGINATORS = [
14
+ MEMORY_BRIDGE_CODEX_ORIGINATOR,
15
+ 'codex_cli_rs',
16
+ 'codex-tui',
17
+ 'codex_tui',
18
+ 'codex_vscode',
19
+ ];
20
+
21
+ function codexText(p) {
22
+ return (p.content ?? [])
23
+ .filter((b) => ['input_text', 'output_text'].includes(b?.type))
24
+ .map((b) => b.text ?? '')
25
+ .join('\n')
26
+ .trim();
27
+ }
28
+
29
+ const codexPrompt = ({ p, meta, n, ts, text, prev }) => ({
30
+ runtime: 'codex',
31
+ ts,
32
+ cwd: meta.cwd || '',
33
+ branch: meta.git?.branch || null,
34
+ sessionId: meta.id ?? basename(n, '.jsonl'),
35
+ turnId: p.internal_chat_message_metadata_passthrough?.turn_id ?? null,
36
+ text,
37
+ prev,
38
+ });
39
+
40
+ function codexFilePrompts(lines, meta, n, win) {
41
+ const prompts = [];
42
+ let last = '';
43
+ for (const e of lines) {
44
+ const p = e.payload ?? {};
45
+ if (e.type !== 'response_item' || p.type !== 'message') continue;
46
+ const tx = codexText(p);
47
+ if (p.role === 'assistant') {
48
+ last = tx || last;
49
+ continue;
50
+ }
51
+ const ts = Date.parse(e.timestamp);
52
+ if (p.role !== 'user' || !inWindow(ts, win)) continue;
53
+ const text = stripEnvelopes(tx);
54
+ if (text) prompts.push(codexPrompt({ p, meta, n, ts, text, prev: last }));
55
+ }
56
+ return prompts;
57
+ }
58
+
59
+ const isCodexUserSession = (meta) =>
60
+ CODEX_ORIGINATORS.includes(meta?.originator) && !isTempCwd(meta.cwd || '');
61
+
62
+ export function codexPrompts(home, win) {
63
+ const base = join(home, '.codex', 'sessions');
64
+ let names;
65
+ try {
66
+ names = readdirSync(base, { recursive: true });
67
+ } catch (e) {
68
+ if (e.code === 'ENOENT') return null;
69
+ throw e;
70
+ }
71
+ const prompts = [];
72
+ for (const n of names) {
73
+ const file = join(base, n);
74
+ if (!n.endsWith('.jsonl') || mtimeMs(file) < win.since) continue;
75
+ const lines = jsonLines(file);
76
+ const meta = lines[0]?.payload;
77
+ if (!isCodexUserSession(meta)) continue;
78
+ prompts.push(...codexFilePrompts(lines, meta, n, win));
79
+ }
80
+ return prompts;
81
+ }
@@ -0,0 +1,97 @@
1
+ // Memory-bridge observations and the previous curator run's token use, for curator-feed.mjs. Read-only.
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { bridgeHomes } from '../../hooks/memory_bridge.mjs';
5
+ import { DAY, ifPresent, jsonLines, ls, mtimeMs, textOf } from './curator-feed-sources.mjs';
6
+
7
+ // --- C. Observations ---
8
+ const observationRows = (file) => ifPresent(() => jsonLines(file), []);
9
+
10
+ function observationFiles(homes, win) {
11
+ const dates = [];
12
+ for (let d = Math.floor((win.since - DAY) / DAY) * DAY; d <= win.until + DAY; d += DAY)
13
+ dates.push(new Date(d).toISOString().slice(0, 10));
14
+ const files = [];
15
+ for (const h of homes)
16
+ for (const project of ls(h) ?? [])
17
+ for (const ws of ls(join(h, project)) ?? [])
18
+ for (const date of dates)
19
+ files.push(join(h, project, ws, 'memory', 'observations', `${date}.jsonl`));
20
+ return files;
21
+ }
22
+
23
+ // Rows without an id cannot be told apart, so only rows with an id dedupe.
24
+ function isNewObservation(o, seen) {
25
+ if (!o.sessionId || (o.id && seen.has(o.id))) return false;
26
+ if (o.id) seen.add(o.id);
27
+ return true;
28
+ }
29
+
30
+ export function observations(env, win) {
31
+ const homes = bridgeHomes(env).filter((h) => existsSync(h));
32
+ if (!homes.length) return { homes, byTurn: null };
33
+ const seen = new Set();
34
+ const byTurn = new Map();
35
+ for (const file of observationFiles(homes, win)) {
36
+ for (const o of observationRows(file)) {
37
+ if (!isNewObservation(o, seen)) continue;
38
+ const key = `${o.sessionId}|${o.turnId}`;
39
+ if (!(Date.parse(byTurn.get(key)?.timestamp) >= Date.parse(o.timestamp))) byTurn.set(key, o);
40
+ }
41
+ }
42
+ return { homes, byTurn };
43
+ }
44
+
45
+ // A malformed observation row throws here, apart from its prompt: only its Result is skipped.
46
+ export function checkedObservation(obs) {
47
+ for (const k of ['tags', 'files'])
48
+ if (obs[k] != null && !(Array.isArray(obs[k]) && obs[k].every((s) => typeof s === 'string')))
49
+ throw new Error(`${k} is not a list of strings`);
50
+ for (const k of ['actionStatus', 'summary', 'branch'])
51
+ if (obs[k] != null && typeof obs[k] !== 'string') throw new Error(`${k} is not a string`);
52
+ return obs;
53
+ }
54
+
55
+ // --- D. Previous run tokens ---
56
+ function isCuratorRun(lines) {
57
+ const first = textOf(
58
+ lines.find((e) => e.type === 'user' && textOf(e.message?.content) != null)?.message?.content,
59
+ );
60
+ return Boolean(
61
+ first?.startsWith('<helm-context>') && first.includes('HELM_JOB_ID=curator-user-nightly'),
62
+ );
63
+ }
64
+
65
+ function tokensOf(lines) {
66
+ const byId = new Map();
67
+ for (const e of lines)
68
+ if (e.type === 'assistant' && e.message?.id && e.message.usage)
69
+ byId.set(e.message.id, e.message.usage);
70
+ const sum = (k) => [...byId.values()].reduce((n, u) => n + (u[k] ?? 0), 0);
71
+ return {
72
+ calls: byId.size,
73
+ cacheRead: sum('cache_read_input_tokens'),
74
+ cacheWrite: sum('cache_creation_input_tokens'),
75
+ output: sum('output_tokens'),
76
+ };
77
+ }
78
+
79
+ // shortcut: Claude transcripts only; add Codex token_count when a curator runs on Codex.
80
+ export function previousRun(home, agentsRoot) {
81
+ const dir = join(
82
+ home,
83
+ '.claude',
84
+ 'projects',
85
+ join(agentsRoot, 'curator').replace(/[^A-Za-z0-9]/g, '-'),
86
+ );
87
+ const current = process.env.CLAUDE_CODE_SESSION_ID;
88
+ const files = (ls(dir) ?? [])
89
+ .filter((f) => f.endsWith('.jsonl') && f !== `${current}.jsonl`)
90
+ .map((f) => ({ file: join(dir, f), at: mtimeMs(join(dir, f)) }))
91
+ .sort((a, b) => b.at - a.at);
92
+ for (const { file } of files) {
93
+ const lines = jsonLines(file);
94
+ if (isCuratorRun(lines)) return tokensOf(lines);
95
+ }
96
+ return null;
97
+ }