@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,19 +1,19 @@
1
- import { existsSync, unlinkSync } from 'node:fs';
2
- import { basename, resolve } from 'node:path';
3
- import { spawnSync } from 'node:child_process';
1
+ import { existsSync } from 'node:fs';
2
+ import { basename, join, resolve } from 'node:path';
3
+ import { isatty } from 'node:tty';
4
+ import { bridgeEnvPresent, bridgeLauncher, MEMORY_BRIDGE_ENV } from '../../hooks/memory_bridge.mjs';
4
5
  import { pendingMarkerPath } from '../../hooks/memory_store.mjs';
5
6
  import {
6
7
  codexRuntimeContextPath,
7
8
  launchCodex,
8
9
  readJsonFile,
10
+ withRepoCheck,
9
11
  writeCodexHooks,
10
12
  writeCodexProfile,
11
13
  } from './codex.mjs';
12
14
  import {
15
+ ACADEMY_ROOT,
13
16
  CLI_NAME,
14
- ACADEMY_CLI_PATH,
15
- SCHEDULED_CLAUDE_PERMISSION_ARGS,
16
- SCHEDULED_CODEX_PERMISSION_ARGS,
17
17
  LockTimeoutError,
18
18
  agentDir,
19
19
  delegateLegacyRun,
@@ -21,17 +21,56 @@ import {
21
21
  legacyAcademyRoot,
22
22
  validateName,
23
23
  } from './core.mjs';
24
- import { registerNightlyConsolidationTask } from './create.mjs';
24
+ import { agentIdOf, currentAgentName } from './agent-id.mjs';
25
+ import { refuseNotStarted } from './agent-gate.mjs';
26
+ import { beadsEnv } from './beads.mjs';
27
+ import { INTRO_PENDING_MARKER, jobLabel, registerAcademyJobs } from './create.mjs';
25
28
  import { launchClaude } from './hire.mjs';
29
+ import {
30
+ MEMORY_RELAUNCH_FLAG,
31
+ memoryEnvRecord,
32
+ relaunchWithMemoryEnv,
33
+ resolveMemoryEnv,
34
+ } from './memory-env.mjs';
35
+ import {
36
+ NIGHTLY_JOB_FLAG,
37
+ nightlySkillFor,
38
+ recordNightlyOutcome,
39
+ removeMarkerIfUnchanged,
40
+ skipNightlyWithoutMarker,
41
+ skipNightlyWithoutWork,
42
+ sweepBeforeNightly,
43
+ } from './nightly.mjs';
44
+ import { launchNightlyChild, nightlyChildEnv } from './nightly-launch.mjs';
45
+ import { appendedPromptLaunch } from './prompt-append.mjs';
46
+ import { printHelmSkip } from './helm-skip.mjs';
47
+ import { guardOwnerHeartbeat } from './heartbeat.mjs';
48
+ import { applyRunContext } from './run-context.mjs';
26
49
  import { readRuntimeProvider, toRuntimeProvider, toRuntimeToken } from './runtime.mjs';
50
+ import { bundledTldrPluginRoot, schedulerJobId } from './tldr.mjs';
51
+ import { logUserWorkView } from './user-work-view.mjs';
52
+ import { codexWritableDirArgs, sharedWritableDirArgs } from './writable-dirs.mjs';
27
53
  import { AgentSpecError, writeAgentYamlScalar } from './yaml.mjs';
28
54
  import {
29
55
  renderAcademySystemPrompt,
56
+ scaffoldLocalRules,
30
57
  writeProjectCodexSkillBridge,
31
58
  writeProjectPluginInstance,
32
59
  writeSkillsScaffold,
33
60
  } from './scaffold.mjs';
34
61
 
62
+ // The `roque` plugin replaces any installed `roque` for the session, so Roque
63
+ // never runs twice next to a normal-session install. Two more plugins load only
64
+ // on a machine without the Mac's installs: the bundled tldr plugin (tldr did not
65
+ // resolve from a Claude plugin install) and Backstory (no memory wrapper).
66
+ export function claudePluginArgs(pluginDir, dir, env = process.env, academyRoot = ACADEMY_ROOT) {
67
+ const dirs = [pluginDir, join(academyRoot, 'hooks', 'roque')];
68
+ const tldr = bundledTldrPluginRoot(env, academyRoot);
69
+ if (tldr) dirs.push(tldr);
70
+ if (!bridgeLauncher(env)) dirs.push(join(academyRoot, 'hooks', 'backstory'));
71
+ return [...dirs.flatMap((d) => ['--plugin-dir', d]), ...sharedWritableDirArgs(dir)];
72
+ }
73
+
35
74
  // The agent's runtime is only knowable once the agent is resolved. A bare run
36
75
  // reads what the agent already declared, so it can neither launch the wrong
37
76
  // provider nor re-register the nightly job under one. Only an explicit --agent
@@ -56,50 +95,130 @@ function persistRuntime(dir, requested) {
56
95
  }
57
96
  }
58
97
 
59
- function prepareAgentRun(name, requestedRuntime, passthrough) {
60
- validateName(name);
61
- const dir = agentDir(name);
98
+ // A scheduler job whose agent is not here (a job registered under another
99
+ // AGENTS_ROOT) is a decision to skip, not a failed start: a failed start makes
100
+ // the scheduler launch a fallback session that lacks Academy's context. A
101
+ // person's run has no job id and still fails with the not-found message.
102
+ function skipMissingAgentUnderJob(name) {
103
+ const jobId = schedulerJobId();
104
+ if (!jobId) return;
105
+ console.error(
106
+ JSON.stringify({
107
+ event: 'run.agent_missing',
108
+ status: 'warn',
109
+ params: { agent: name, jobId },
110
+ result: { skip: 'agent_not_found' },
111
+ }),
112
+ );
113
+ printHelmSkip('agent_not_found', name);
114
+ process.exit(0);
115
+ }
116
+
117
+ // A new Principal's first interactive session starts with one plain sentence
118
+ // that points at its own rendered intro skill by path: a command name resolves
119
+ // differently in Claude project mode and Codex project mode. Any passthrough
120
+ // at all means a caller (a schedule, a resume, a host) already chose the first
121
+ // message, so only an empty one is seeded. No terminal, no seed.
122
+ export function introSeed({ dir, runtime, passthrough, isTTY }) {
123
+ if (!isTTY || passthrough.length > 0 || !existsSync(join(dir, INTRO_PENDING_MARKER))) return;
124
+ const surface = runtime === 'codex' ? '.agents' : '.claude';
125
+ return [
126
+ `Start your first-session intro: read and follow ${dir}/${surface}/skills/principal-intro/SKILL.md`,
127
+ ];
128
+ }
129
+
130
+ function seedFirstSession(dir, name, runtime, passthrough) {
131
+ const seed = introSeed({ dir, runtime, passthrough, isTTY: isatty(0) });
132
+ if (!seed) return passthrough;
133
+ console.error(JSON.stringify({ event: 'run.intro_seed', params: { agent: name, runtime } }));
134
+ return seed;
135
+ }
136
+
137
+ // `id` keys everything that must survive a name change: the heartbeat's Beads
138
+ // label, the Beads actor, job registration, the relaunch and per-agent paths.
139
+ // The current name is what the session is told it is.
140
+ function prepareAgentRun(id, requestedRuntime, passthrough) {
141
+ validateName(id);
142
+ const dir = agentDir(id);
62
143
  if (!existsSync(dir)) {
63
- console.error(`Agent "${name}" not found at ${dir}`);
64
- console.error(`Run \`${CLI_NAME} create ${name}\` or \`${CLI_NAME} hire\` first.`);
144
+ skipMissingAgentUnderJob(id);
145
+ console.error(`Agent "${id}" not found at ${dir}`);
146
+ console.error(`Run \`${CLI_NAME} create ${id}\` or \`${CLI_NAME} hire\` first.`);
65
147
  process.exit(1);
66
148
  }
67
149
 
68
150
  const legacyRoot = legacyAcademyRoot(dir);
69
151
  if (legacyRoot) {
70
- delegateLegacyRun(name, passthrough, legacyRoot);
152
+ delegateLegacyRun(id, passthrough, legacyRoot);
71
153
  return null;
72
154
  }
155
+ const name = currentAgentName(dir);
73
156
 
157
+ // Before any model session, and once: the memory relaunch's child inherits
158
+ // the parent's decision. Runs of one agent may overlap; only a scheduled
159
+ // Owner heartbeat checks first whether it has anything to do.
160
+ const relaunchRuntime = requestedRuntime ?? toRuntimeToken(readRuntimeProvider(dir));
161
+ if (process.env[MEMORY_RELAUNCH_FLAG] !== '1') {
162
+ guardOwnerHeartbeat(dir, id);
163
+ // Seeded before the relaunch so the child receives it as its passthrough
164
+ // and, under the flag, does not decide again.
165
+ passthrough = seedFirstSession(dir, id, relaunchRuntime, passthrough);
166
+ }
167
+ relaunchWithMemoryEnv({
168
+ dir,
169
+ name: id,
170
+ passthrough,
171
+ requestedRuntime,
172
+ runtime: relaunchRuntime,
173
+ });
74
174
  const runtime = resolveRuntime(dir, requestedRuntime);
175
+ scaffoldLocalRules(dir, name);
75
176
  writeSkillsScaffold(dir, name);
76
- const systemPromptPath = renderAcademySystemPrompt(dir, name);
177
+ const { promptPath: systemPromptPath, surfaces } = renderAcademySystemPrompt(dir, name);
178
+ logUserWorkView(name, surfaces);
77
179
  const projectDir = resolve(process.cwd());
78
180
  const inAgentHome = isInside(projectDir, dir);
79
181
  const codex =
80
182
  runtime === 'codex'
81
- ? { ...writeCodexProfile(dir, name), runtimeContextPath: codexRuntimeContextPath() }
183
+ ? { ...writeCodexProfile(dir, id), runtimeContextPath: codexRuntimeContextPath() }
82
184
  : undefined;
83
185
  if (codex) writeCodexHooks(codex.runtimeContextPath);
84
- const nightlyTask = requestedRuntime ? registerNightlyConsolidationTask(dir, name) : null;
85
- if (nightlyTask && !nightlyTask.registered && process.env.ACADEMY_SKIP_NIGHTLY_TASK !== '1') {
86
- console.error(
87
- `Warning: nightly consolidation job "${nightlyTask.id}" not updated: ${nightlyTask.reason}`,
88
- );
186
+ const jobs = requestedRuntime ? registerAcademyJobs(dir, id) : [];
187
+ for (const job of jobs.filter(({ registered, skipped }) => !registered && !skipped)) {
188
+ console.error(`Warning: ${jobLabel(job.id)} "${job.id}" not updated: ${job.reason}`);
89
189
  }
90
190
 
191
+ const { [MEMORY_RELAUNCH_FLAG]: _relaunched, ...inherited } = process.env;
192
+ // A session's Beads actor names this process, which waits on the provider,
193
+ // so the actor lives exactly as long as the session.
91
194
  const env = {
92
- ...process.env,
195
+ ...inherited,
93
196
  ACADEMY_AGENT_DIR: dir,
94
197
  ACADEMY_AGENT_NAME: name,
198
+ // One compaction point per agent session; Roque's hints fire at fractions of it.
199
+ CLAUDE_CODE_AUTO_COMPACT_WINDOW: inherited.CLAUDE_CODE_AUTO_COMPACT_WINDOW || '430000',
200
+ // The client's memory is always on in a topic, but a scheduled job's
201
+ // wrapper sets only the topic; without the flag no turn is captured or recorded.
202
+ ...(bridgeEnvPresent(inherited) && {
203
+ [MEMORY_BRIDGE_ENV.enabled]: inherited[MEMORY_BRIDGE_ENV.enabled] || '1',
204
+ }),
205
+ ...beadsEnv(`${id}@${process.pid}`),
95
206
  };
96
207
  return { codex, dir, env, inAgentHome, name, passthrough, projectDir, runtime, systemPromptPath };
97
208
  }
98
209
 
99
210
  function launchInAgentHome(context) {
100
211
  const { codex, dir, env, name, passthrough, systemPromptPath } = context;
212
+ const { promptArgs, passthrough: rest } = appendedPromptLaunch({
213
+ runtime: codex ? 'codex' : 'claude',
214
+ promptPath: systemPromptPath,
215
+ passthrough,
216
+ });
101
217
  if (!codex) {
102
- launchClaude(['--system-prompt-file', systemPromptPath, ...passthrough], {
218
+ // The home is its own plugin root: `create` links `.claude-plugin` and
219
+ // `hooks` into it. Claude Code loads a plugin only when told to, so without
220
+ // this flag a home run registers no session and syncs no memory.
221
+ launchClaude([...claudePluginArgs(dir, dir), ...promptArgs, ...rest], {
103
222
  cwd: dir,
104
223
  env,
105
224
  message: `Launching ${name} with Claude Code in agent home (${dir})`,
@@ -112,11 +231,9 @@ function launchInAgentHome(context) {
112
231
  codex.profileName,
113
232
  '-C',
114
233
  dir,
115
- '--add-dir',
116
- dir,
117
- '-c',
118
- `model_instructions_file=${JSON.stringify(systemPromptPath)}`,
119
- ...passthrough,
234
+ ...codexWritableDirArgs(dir),
235
+ ...promptArgs,
236
+ ...withRepoCheck(rest),
120
237
  ],
121
238
  { cwd: dir, env, message: `Launching ${name} with Codex in agent home (${dir})`, ...codex },
122
239
  );
@@ -124,6 +241,11 @@ function launchInAgentHome(context) {
124
241
 
125
242
  function launchInProject(context) {
126
243
  const { codex, dir, env, name, passthrough, projectDir, systemPromptPath } = context;
244
+ const { promptArgs, passthrough: rest } = appendedPromptLaunch({
245
+ runtime: codex ? 'codex' : 'claude',
246
+ promptPath: systemPromptPath,
247
+ passthrough,
248
+ });
127
249
  if (codex) {
128
250
  writeProjectCodexSkillBridge(projectDir, dir);
129
251
  launchCodex(
@@ -132,11 +254,9 @@ function launchInProject(context) {
132
254
  codex.profileName,
133
255
  '-C',
134
256
  projectDir,
135
- '--add-dir',
136
- dir,
137
- '-c',
138
- `model_instructions_file=${JSON.stringify(systemPromptPath)}`,
139
- ...passthrough,
257
+ ...codexWritableDirArgs(dir),
258
+ ...promptArgs,
259
+ ...rest,
140
260
  ],
141
261
  {
142
262
  cwd: projectDir,
@@ -148,78 +268,115 @@ function launchInProject(context) {
148
268
  return;
149
269
  }
150
270
 
151
- const pluginDir = writeProjectPluginInstance(projectDir, name, dir);
152
- launchClaude(
153
- ['--plugin-dir', pluginDir, '--system-prompt-file', systemPromptPath, ...passthrough],
154
- {
155
- cwd: projectDir,
156
- env: { ...env, ACADEMY_PROJECT_DIR: projectDir },
157
- message: `Launching ${name} with Claude Code for project ${basename(projectDir)} (plugin: ${pluginDir})`,
158
- },
159
- );
271
+ // Keyed by the id folder, so a name change does not orphan the instance.
272
+ const pluginDir = writeProjectPluginInstance(projectDir, basename(dir), dir);
273
+ launchClaude([...claudePluginArgs(pluginDir, dir), ...promptArgs, ...rest], {
274
+ cwd: projectDir,
275
+ env: { ...env, ACADEMY_PROJECT_DIR: projectDir },
276
+ message: `Launching ${name} with Claude Code for project ${basename(projectDir)} (plugin: ${pluginDir})`,
277
+ });
160
278
  }
161
279
 
162
280
  export function runAgent(name, runtime, passthrough) {
163
- const context = prepareAgentRun(name, runtime, passthrough);
281
+ // First: an Academy job's flags come from its job id, which is keyed by the
282
+ // fixed id, and the nightly dispatch below is one of them. The name is
283
+ // checked before that, so no path it names is read.
284
+ validateName(name);
285
+ const id = agentIdOf(name);
286
+ applyRunContext(id);
287
+ refuseNotStarted(agentDir(id), id);
288
+ if (process.env[NIGHTLY_JOB_FLAG] === '1') {
289
+ dispatchNightlyJob(id, passthrough);
290
+ return;
291
+ }
292
+ const context = prepareAgentRun(id, runtime, passthrough);
164
293
  if (!context) return;
165
294
  if (context.inAgentHome) launchInAgentHome(context);
166
295
  else launchInProject(context);
167
296
  }
168
297
 
298
+ // An Academy-provider nightly job reaches `academy run` with the provider's own
299
+ // argv. The flag routes it into the routine `academy nightly` runs, so both
300
+ // share one preflight and one verdict; the routine's child runs without the
301
+ // flag and is an ordinary run. The provider argv (prompt, runtime token) is
302
+ // not launched: the routine builds its prompt and reads the persisted runtime.
303
+ // Only its permission and output flags reach the child: the job approved the
304
+ // one, and its scheduler reads the provider's session id through the other.
305
+ function dispatchNightlyJob(name, passthrough) {
306
+ console.log(
307
+ JSON.stringify({
308
+ event: 'nightly.dispatch',
309
+ params: { agent: name, source: 'academy_provider', providerArgs: passthrough.length },
310
+ }),
311
+ );
312
+ runNightly(name, { providerArgv: passthrough });
313
+ }
314
+
169
315
  // Runtime-agnostic by construction. An `--agent` on this command line comes
170
316
  // from a job an earlier build registered; it is accepted and ignored, because
171
317
  // the persisted scalar is the only source of truth a scheduled run may read.
172
318
  // The child is spawned bare, so it persists nothing and cannot revert the
173
319
  // operator's edit — editing agent.yaml is the published way to change a
174
320
  // runtime, and a nightly that carried a snapshot undid that edit every night.
175
- export function runNightly(name) {
176
- validateName(name);
177
- const dir = agentDir(name);
321
+ // `providerArgv` is a provider job's own argv; `templatesRoot` serves tests.
322
+ //
323
+ // The pending marker is the one input check: any session activity since the
324
+ // last nightly that exited 0. The skill decides what in the archive is new.
325
+ export function runNightly(nameOrId, { providerArgv, templatesRoot } = {}) {
326
+ validateName(nameOrId);
327
+ // The nightly is keyed by the fixed id: its job, its child run, its skip line.
328
+ const id = agentIdOf(nameOrId);
329
+ const dir = agentDir(id);
178
330
  if (!existsSync(dir)) {
179
- console.error(`Agent "${name}" not found at ${dir}`);
331
+ skipMissingAgentUnderJob(id);
332
+ console.error(`Agent "${id}" not found at ${dir}`);
180
333
  process.exit(1);
181
334
  }
182
- const runtime = toRuntimeToken(readRuntimeProvider(dir));
335
+ refuseNotStarted(dir, id);
336
+ const provider = readRuntimeProvider(dir);
337
+ const attempt = {
338
+ dir,
339
+ name: id,
340
+ provider,
341
+ startedAt: new Date(),
342
+ memory: memoryEnvRecord(resolveMemoryEnv(dir, { cwd: dir, env: nightlyChildEnv() })),
343
+ };
183
344
 
345
+ sweepBeforeNightly(dir, id);
184
346
  const markerPath = pendingMarkerPath(dir);
185
347
  if (!existsSync(markerPath)) {
186
- console.log(
187
- `Skipping nightly consolidation for ${name}: no new session activity since the last run.`,
188
- );
348
+ skipNightlyWithoutMarker(attempt);
189
349
  return;
190
350
  }
191
351
 
352
+ // Read the revision before the run, so activity that lands during it bumps
353
+ // the revision and keeps the marker.
192
354
  const revision = Number(readJsonFile(markerPath, {}).revision);
193
- const prompt = [
194
- "Use the nightly-consolidation skill to consolidate today's pending observation memory.",
195
- 'Update the v3 memory surfaces conservatively, write the dreams report,',
196
- 'and finish with the dreams report path.',
197
- ].join(' ');
198
- const providerArgs =
199
- runtime === 'codex'
200
- ? [...SCHEDULED_CODEX_PERMISSION_ARGS, prompt]
201
- : [...SCHEDULED_CLAUDE_PERMISSION_ARGS, '-p', prompt];
202
- const result = spawnSync(
203
- process.execPath,
204
- [ACADEMY_CLI_PATH, 'run', name, '--', ...providerArgs],
205
- {
206
- stdio: 'inherit',
207
- cwd: dir,
208
- env: {
209
- ...process.env,
210
- ACADEMY_SKIP_NIGHTLY_TASK: '1',
211
- ACADEMY_NIGHTLY_RUN: '1',
212
- },
213
- },
214
- );
215
- const status = result.status ?? 1;
216
-
217
- if (status === 0 && existsSync(markerPath)) {
218
- const currentRevision = Number(readJsonFile(markerPath, {}).revision);
219
- if (currentRevision === revision) unlinkSync(markerPath);
220
- }
355
+ if (skipNightlyWithoutWork(attempt, markerPath, revision)) return;
356
+ const skill = requireNightlySkill(attempt, templatesRoot);
357
+ const runtime = toRuntimeToken(provider);
358
+ const exitCode = launchNightlyChild(dir, id, { runtime, skill, providerArgv });
359
+ // A pass takes its gate mark under the marker lock; a failure keeps the marker.
360
+ const gate = exitCode === 0 ? removeMarkerIfUnchanged(markerPath, revision) : {};
361
+ recordNightlyOutcome(attempt, {
362
+ status: exitCode === 0 ? 'pass' : 'failed',
363
+ reason: exitCode === 0 ? 'exit_0' : `exit_code_${exitCode}`,
364
+ exitCode,
365
+ ...gate,
366
+ });
367
+ process.exit(exitCode);
368
+ }
221
369
 
222
- process.exit(status);
370
+ // A kind whose nightly skill is not installed fails before launch: running the
371
+ // universal skill instead would consolidate against the wrong surfaces.
372
+ function requireNightlySkill(attempt, templatesRoot) {
373
+ const { kind, skill, present } = nightlySkillFor(attempt.dir, templatesRoot);
374
+ if (present) return skill;
375
+ console.error(
376
+ `Error: nightly skill "${skill}" is not installed for ${kind} agent "${attempt.name}".`,
377
+ );
378
+ recordNightlyOutcome(attempt, { status: 'failed', reason: 'nightly_skill_missing' });
379
+ process.exit(1);
223
380
  }
224
381
 
225
382
  // ─────────────────────────────────────────────────────────────────────────────