@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,9 +1,10 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
3
- import { join } from 'node:path';
3
+ import { join, resolve } from 'node:path';
4
4
  import {
5
5
  ACADEMY_ROOT,
6
6
  AGENTS_ROOT,
7
+ CLI_NAME,
7
8
  CONTRACT_VERSION,
8
9
  agentDir,
9
10
  auditAgentsRoot,
@@ -11,11 +12,14 @@ import {
11
12
  printJson,
12
13
  resolveExecutable,
13
14
  } from './core.mjs';
15
+ import { beadsStatus } from './beads.mjs';
14
16
  import { eventLogPath } from './eventlog.mjs';
15
17
  import { isAgentDirectory } from './inspect.mjs';
16
18
  import { backfillPlan } from './migrate.mjs';
19
+ import { FLAGGED_NIGHTLY_STATUSES, nightlyStatus } from './nightly.mjs';
17
20
  import { runtimeProviderOrNull } from './runtime.mjs';
18
21
  import { unattributableSessionCount } from './sessions.mjs';
22
+ import { readAgentYaml } from './yaml.mjs';
19
23
 
20
24
  // The frozen capability answer at contract_version 1. `notes`, `nightly`,
21
25
  // `clean`, `root`, and `run` all keep working and none of them is contract, so
@@ -38,6 +42,8 @@ const PUBLISHED_COMMANDS = [
38
42
  'unarchive',
39
43
  'delete',
40
44
  'migrate',
45
+ 'start',
46
+ 'stop',
41
47
  ];
42
48
 
43
49
  /** Published above and built by no phase yet. Empty: every command is real. */
@@ -63,6 +69,7 @@ export function reportHealth(json) {
63
69
  commands: PUBLISHED_COMMANDS,
64
70
  runtimes: runtimeAvailability(),
65
71
  errors: audit.problem ? [] : healthErrors(),
72
+ agents: audit.problem ? [] : agentEntries(),
66
73
  };
67
74
 
68
75
  if (audit.problem) return reportUnusableRoot(payload, audit.problem, json);
@@ -188,6 +195,44 @@ function agentNames() {
188
195
  }
189
196
  }
190
197
 
198
+ // Additive to v1: each agent's last nightly receipt, so a nightly that failed
199
+ // or never ran is visible without reading agent files. An Owner also reports
200
+ // its `bd`, probed once for all Owners. The raw kind scalar is read, not
201
+ // kindOf, because doctor must report an agent whose kind it cannot read.
202
+ // `name` is the current name; `dir` is the fixed id folder a rename keeps.
203
+ function agentEntries() {
204
+ let beads;
205
+ return agentNames().map((id) => {
206
+ const dir = resolve(agentDir(id));
207
+ const yaml = readAgentYaml(dir);
208
+ const entry = { name: yaml.name || id, dir, nightly: nightlyStatus(dir) };
209
+ if (yaml.kind !== 'owner') return entry;
210
+ beads ??= beadsStatus();
211
+ return { ...entry, beads };
212
+ });
213
+ }
214
+
215
+ const HOUR_MS = 60 * 60 * 1000;
216
+
217
+ function ageOf(at) {
218
+ if (!at) return '-';
219
+ const hours = Math.floor((Date.now() - Date.parse(at)) / HOUR_MS);
220
+ if (hours < 1) return '<1h ago';
221
+ return hours < 48 ? `${hours}h ago` : `${Math.floor(hours / 24)}d ago`;
222
+ }
223
+
224
+ function printNightly({ name, nightly }) {
225
+ if (!nightly) {
226
+ console.log(` nightly ${name.padEnd(12)} none`);
227
+ return;
228
+ }
229
+ const flag = FLAGGED_NIGHTLY_STATUSES.has(nightly.status) ? ' FLAG' : '';
230
+ const reason = nightly.reason ? ` ${nightly.reason}` : '';
231
+ console.log(
232
+ ` nightly ${name.padEnd(12)} ${nightly.status} ${ageOf(nightly.at)}${reason}${flag}`,
233
+ );
234
+ }
235
+
191
236
  function printReport(payload) {
192
237
  console.log(`academy ${payload.version} contract ${CONTRACT_VERSION}`);
193
238
  console.log(` packageRoot ${payload.packageRoot}`);
@@ -198,6 +243,26 @@ function printReport(payload) {
198
243
  }
199
244
  console.log(` commands ${payload.commands.join(' ')}`);
200
245
  for (const entry of payload.errors) {
201
- console.log(` health ${entry.code} ${entry.count}`);
246
+ console.log(` health ${entry.code} ${entry.count} ${HEALTH_REPAIRS[entry.code]}`);
202
247
  }
248
+ for (const agent of payload.agents) printNightly(agent);
249
+ for (const agent of payload.agents) if (agent.beads) printBeads(agent);
203
250
  }
251
+
252
+ function printBeads({ name, beads }) {
253
+ if (beads.status === 'ok') {
254
+ console.log(` beads ${name.padEnd(12)} ok ${beads.version}`);
255
+ return;
256
+ }
257
+ console.log(
258
+ ` beads ${name.padEnd(12)} ${beads.status} ${beads.version ?? '-'} install bd ${beads.range} FLAG`,
259
+ );
260
+ }
261
+
262
+ // Human text only. `--json` carries the bare code and count, and a client maps
263
+ // the code to its repair from the integration guide's health table.
264
+ const HEALTH_REPAIRS = {
265
+ unowned_agents: `run \`${CLI_NAME} migrate\` to write the missing ownership markers`,
266
+ invalid_runtime_agents: 'set `runtime:` in each agent.yaml to claude_code or codex',
267
+ unattributable_sessions: 'historical records, excluded from `sessions`; no repair',
268
+ };
@@ -62,10 +62,14 @@ function logCreatedRecord() {
62
62
  return { seq: 1, event: 'log_created', logId: randomUUID(), ts: new Date().toISOString() };
63
63
  }
64
64
 
65
+ // The one root lock. Not re-entrant: a caller holding it must not append an event.
66
+ export function withRootEventLock(fn) {
67
+ return withFileLock(join(agentsRootParent(), 'events.lock'), fn);
68
+ }
69
+
65
70
  export function appendLifecycleEvent(event, name, dir, extra = {}) {
66
- const parent = agentsRootParent();
67
71
  const eventsPath = eventLogPath();
68
- withFileLock(join(parent, 'events.lock'), () => {
72
+ withRootEventLock(() => {
69
73
  const text = readEventLog(eventsPath);
70
74
  const records = text === null ? [logCreatedRecord()] : [];
71
75
  const previous = text === null ? 1 : lastSeq(text, eventsPath);
@@ -0,0 +1,9 @@
1
+ import { constants } from 'node:os';
2
+
3
+ // The exit code a child's result stands for. A child killed by a signal did
4
+ // not finish, so it exits 128 + the signal number, as a shell reports it,
5
+ // never 0: a scheduler and the nightly record must not read it as a pass.
6
+ export function childExitCode(result) {
7
+ if (result.status !== null && result.status !== undefined) return result.status;
8
+ return result.signal ? 128 + (constants.signals[result.signal] ?? 0) : 1;
9
+ }
@@ -0,0 +1,115 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync, readdirSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { currentAgentName } from './agent-id.mjs';
5
+ import { CLI_NAME, RuntimeUnavailableError } from './core.mjs';
6
+ import { ownerJobIds } from './owner-jobs.mjs';
7
+ import { helmFailureReason } from './schedule-errors.mjs';
8
+ import { resolveTldrAgentsBinary, spawnTldrTasksArgs, tldrSpawnOptions } from './tldr.mjs';
9
+
10
+ // One first daily run of an Owner, asked of tldr only when no review exists
11
+ // and no earlier first run is live. tldr returns once the session is handed
12
+ // off; a failure after that is in tldr run history, not here.
13
+ export function requestFirstRun(dir, id, scheduledJobIds) {
14
+ const [jobId] = ownerJobIds(id);
15
+ const skipReason = notNeededReason(dir, scheduledJobIds, jobId);
16
+ if (skipReason) return skipped(jobId, skipReason);
17
+ let bin;
18
+ try {
19
+ bin = resolveTldrAgentsBinary();
20
+ } catch (error) {
21
+ if (!(error instanceof RuntimeUnavailableError)) throw error;
22
+ return failedFirstRun(jobId, error.code, error.message);
23
+ }
24
+ return blockedByHistory(bin, dir, jobId) ?? launchFirstRun(bin, dir, jobId);
25
+ }
26
+
27
+ function notNeededReason(dir, scheduledJobIds, jobId) {
28
+ if (!scheduledJobIds.includes(jobId)) return 'daily_job_not_registered';
29
+ return hasReview(dir) ? 'first_review_exists' : null;
30
+ }
31
+
32
+ // The result that stops a launch, or null when history shows no live first run.
33
+ function blockedByHistory(bin, dir, jobId) {
34
+ const history = tldrTasks(bin, dir, ['history', '--cwd', dir, '--id', jobId]);
35
+ const events = historyEvents(history);
36
+ // No reliable evidence that no first run is live: a request could start a second one.
37
+ if (!events) {
38
+ const why =
39
+ history.error || history.status !== 0 ? helmFailureReason(history) : 'no ok envelope';
40
+ const retry = `run ${CLI_NAME} start ${currentAgentName(dir)} again to request the first run, or it runs at its next scheduled time`;
41
+ return failedFirstRun(
42
+ jobId,
43
+ 'history_unavailable',
44
+ `its run history is unreadable (${why})`,
45
+ retry,
46
+ );
47
+ }
48
+ const live = liveRunId(events, dir);
49
+ return live ? { ...skipped(jobId, 'first_run_active'), runId: live } : null;
50
+ }
51
+
52
+ // Exit 1 with an `errors[0].code` is a launch tldr refused at once.
53
+ function launchFirstRun(bin, dir, jobId) {
54
+ const result = tldrTasks(bin, dir, ['run', '--cwd', dir, '--id', jobId]);
55
+ const envelope = firstJsonObject(result.stdout);
56
+ if (result.error || result.status !== 0) {
57
+ const code = envelope?.errors?.[0]?.code ?? 'launch_failed';
58
+ return failedFirstRun(jobId, code, helmFailureReason(result));
59
+ }
60
+ return { status: 'requested', jobId, runId: envelope?.data?.event?.run_id ?? null };
61
+ }
62
+
63
+ const skipped = (jobId, reason) => ({ status: 'skipped', jobId, reason });
64
+
65
+ function failedFirstRun(jobId, code, reason, retry = `${jobId} runs at its next scheduled time`) {
66
+ const firstRun = {
67
+ status: 'failed',
68
+ jobId,
69
+ code,
70
+ message: `The first daily run did not launch: ${reason}. The jobs stay registered; ${retry}.`,
71
+ };
72
+ console.error(JSON.stringify({ event: 'start.first_run', status: 'warn', result: firstRun }));
73
+ return firstRun;
74
+ }
75
+
76
+ function hasReview(dir) {
77
+ const reviews = join(dir, 'reviews');
78
+ return existsSync(reviews) && readdirSync(reviews).some((file) => file.endsWith('.html'));
79
+ }
80
+
81
+ function tldrTasks(bin, dir, args) {
82
+ return spawnSync(bin, spawnTldrTasksArgs(args), tldrSpawnOptions(dir));
83
+ }
84
+
85
+ // tldr keeps no end for a handed-off session in history (A1): the newest
86
+ // hand-off is live until its session outcome file exists.
87
+ function liveRunId(events, dir) {
88
+ const newest = events
89
+ .filter((event) => event.reason === 'skyhook_started' && event.run_id)
90
+ .sort((a, b) => String(a.ts).localeCompare(String(b.ts)))
91
+ .at(-1);
92
+ if (!newest) return null;
93
+ const outcome = join(dir, '.helm', 'runs', 'session-outcomes', `${newest.run_id}.json`);
94
+ return existsSync(outcome) ? null : newest.run_id;
95
+ }
96
+
97
+ // The history events, or null when tldr failed or printed no ok envelope.
98
+ function historyEvents(result) {
99
+ if (result.error || result.status !== 0) return null;
100
+ const envelope = firstJsonObject(result.stdout);
101
+ return envelope?.ok === true ? (envelope.data?.events ?? []) : null;
102
+ }
103
+
104
+ // tldr prints a log line before its JSON, so read the first line that parses.
105
+ function firstJsonObject(output) {
106
+ for (const line of (output ?? '').split('\n')) {
107
+ if (!line.trim().startsWith('{')) continue;
108
+ try {
109
+ return JSON.parse(line);
110
+ } catch {
111
+ // not the envelope; keep looking
112
+ }
113
+ }
114
+ return null;
115
+ }
@@ -0,0 +1,135 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { beadsEnv } from './beads.mjs';
4
+ import { kindOf, resolveExecutable } from './core.mjs';
5
+ import { printHelmSkip } from './helm-skip.mjs';
6
+
7
+ // The run type of an Owner job's run, `daily` or `heartbeat`, derived from its
8
+ // job id at the run entry (run-context.mjs); a provider job carries no env.
9
+ export const OWNER_RUN_FLAG = 'ACADEMY_OWNER_RUN';
10
+
11
+ /** The stopped items a heartbeat session is started for, comma separated. */
12
+ const STOPPED_ITEMS_ENV = 'ACADEMY_STOPPED_ITEMS';
13
+
14
+ // Each Owner session claims Beads items as its own actor, `<agent>@<pid>`, the
15
+ // pid of the `academy run` that waits on it (beads.mjs). An item stays its
16
+ // holder's while that process is alive. A live pid is trusted only while the
17
+ // item was updated in the last six hours, so a reused pid cannot hold an item
18
+ // for ever. An actor with no pid (claimed before per-session actors, or by
19
+ // hand) has no run to wait for.
20
+ const HOLDER_PID = /@(\d+)$/;
21
+ const HOLDER_TRUST_MS = 6 * 60 * 60 * 1000;
22
+ const BD_TIMEOUT_MS = 10_000;
23
+ // `bd gate check` asks GitHub about each gh:pr and gh:run gate.
24
+ const GATE_CHECK_TIMEOUT_MS = 30_000;
25
+
26
+ function pidAlive(pid) {
27
+ try {
28
+ process.kill(pid, 0);
29
+ return true;
30
+ } catch (error) {
31
+ return error.code === 'EPERM';
32
+ }
33
+ }
34
+
35
+ function holderRunning(item, now) {
36
+ const pid = HOLDER_PID.exec(item.assignee ?? '')?.[1];
37
+ if (!pid) return false;
38
+ // An item with no readable update time is judged by its pid alone.
39
+ if (now - Date.parse(item.updated_at) >= HOLDER_TRUST_MS) return false;
40
+ return pidAlive(Number(pid));
41
+ }
42
+
43
+ function bd(args, timeout = BD_TIMEOUT_MS) {
44
+ const bin = resolveExecutable('ACADEMY_BD_BIN', 'bd', { throwOnMissing: true });
45
+ return spawnSync(bin, args, {
46
+ encoding: 'utf8',
47
+ timeout,
48
+ env: { ...process.env, ...beadsEnv() },
49
+ });
50
+ }
51
+
52
+ // The embedded database serves one command at a time, so a burst of writers can
53
+ // hold a `bd` call past its timeout or make it fail on the lock. That is a busy
54
+ // tracker, not a broken one: the next beat tries again.
55
+ class BeadsBusyError extends Error {}
56
+
57
+ function isBusy(result) {
58
+ return result.error?.code === 'ETIMEDOUT' || /\b(locked|busy)\b/i.test(result.stderr ?? '');
59
+ }
60
+
61
+ function bdJson(args) {
62
+ const result = bd([...args, '--json']);
63
+ if (result.error || result.status !== 0) {
64
+ const message = result.error?.message || result.stderr.trim() || `bd exit ${result.status}`;
65
+ throw isBusy(result) ? new BeadsBusyError(message) : new Error(message);
66
+ }
67
+ const parsed = JSON.parse(result.stdout || '[]');
68
+ if (!Array.isArray(parsed)) throw new Error(`bd ${args[0]} returned no list`);
69
+ return parsed;
70
+ }
71
+
72
+ // Closes the gates whose condition is met (a merged PR, an elapsed timer), so
73
+ // their items count in this beat. A failed check (no network, gh signed out)
74
+ // leaves the gates closed for now and does not stop the beat.
75
+ function checkGates() {
76
+ const result = bd(['gate', 'check'], GATE_CHECK_TIMEOUT_MS);
77
+ return result.error || result.status !== 0 ? 'failed' : 'checked';
78
+ }
79
+
80
+ /**
81
+ * Work a stopped run left behind: this Owner's items (label `owner:<agent>`)
82
+ * in progress, not blocked by a gate, and not held by a session that is still
83
+ * running. `bd blocked` only names ids to leave out. No user database is an
84
+ * error: nothing can be known about the work.
85
+ */
86
+ function stoppedWork(name, now) {
87
+ const database = beadsEnv().BEADS_DIR;
88
+ if (!existsSync(database)) throw new Error(`no Beads database at ${database}`);
89
+ const gates = checkGates();
90
+ const blocked = new Set(bdJson(['blocked']).map((item) => item.id));
91
+ const items = bdJson(['list', '--status', 'in_progress', '--label', `owner:${name}`])
92
+ .filter((item) => item.id && !blocked.has(item.id) && !holderRunning(item, now))
93
+ .map((item) => item.id);
94
+ return { items, gates };
95
+ }
96
+
97
+ const BEADS_SKIPS = new Set(['beads_unavailable', 'beads_busy']);
98
+
99
+ function heartbeatDecision(name, now = Date.now()) {
100
+ let found;
101
+ try {
102
+ found = stoppedWork(name, now);
103
+ } catch (error) {
104
+ const reason = error instanceof BeadsBusyError ? 'beads_busy' : 'beads_unavailable';
105
+ return { action: 'skip', reason, error: error.message };
106
+ }
107
+ const { items, gates } = found;
108
+ if (items.length === 0) return { action: 'skip', reason: 'nothing_due', gates };
109
+ return { action: 'run', reason: 'stopped_work', items, gates };
110
+ }
111
+
112
+ // The entry check of a scheduled Owner heartbeat. It decides without a model:
113
+ // nothing to do ends the run with a skip the scheduler records, and stopped
114
+ // work starts the session with the items' ids in its env.
115
+ export function guardOwnerHeartbeat(dir, name, env = process.env) {
116
+ if (env[OWNER_RUN_FLAG] !== 'heartbeat' || kindOf(dir) !== 'owner') return;
117
+ const started = Date.now();
118
+ const result = heartbeatDecision(name, started);
119
+ const warn = BEADS_SKIPS.has(result.reason) || result.gates === 'failed';
120
+ console.error(
121
+ JSON.stringify({
122
+ event: 'owner.heartbeat',
123
+ status: warn ? 'warn' : 'info',
124
+ params: { agent: name },
125
+ result,
126
+ latencyMs: Date.now() - started,
127
+ }),
128
+ );
129
+ if (result.action === 'run') {
130
+ env[STOPPED_ITEMS_ENV] = result.items.join(',');
131
+ return;
132
+ }
133
+ printHelmSkip(result.reason, name, env);
134
+ process.exit(0);
135
+ }
@@ -0,0 +1,20 @@
1
+ import { writeSync } from 'node:fs';
2
+ import { schedulerJobId } from './tldr.mjs';
3
+
4
+ // Contract C1 with tldr (.spectre/features/agent-kinds-mvp/external/
5
+ // tldr-provider-outcome-contract.md): a scheduler-launched run that decides on
6
+ // purpose to start no model session says so on stdout, so the scheduler
7
+ // records a skip instead of a failed start and launches no fallback. The line
8
+ // is the boundary record for the skip; each call site already logs its own
9
+ // decision. Written synchronously, because the callers exit right after.
10
+
11
+ let printed = false;
12
+
13
+ /** Prints the helm.skip line once per process, only under a scheduler job id. */
14
+ export function printHelmSkip(reason, agent, env = process.env) {
15
+ const jobId = schedulerJobId(env);
16
+ if (printed || !jobId) return;
17
+ printed = true;
18
+ const line = { type: 'helm.skip', version: 1, source: 'academy', reason, agent, job_id: jobId };
19
+ writeSync(1, `${JSON.stringify(line)}\n`);
20
+ }
@@ -1,5 +1,5 @@
1
1
  import { rmSync, realpathSync } from 'node:fs';
2
- import { resolve } from 'node:path';
2
+ import { dirname, resolve } from 'node:path';
3
3
  import { spawnSync } from 'node:child_process';
4
4
  import {
5
5
  ACADEMY_ROOT,
@@ -7,13 +7,20 @@ import {
7
7
  contractOk,
8
8
  exitJsonError,
9
9
  resolveExecutable,
10
+ surfacesFor,
10
11
  validateName,
11
12
  } from './core.mjs';
12
- import { MUST_EXIST, assertAgentNameFree, provisionAgent } from './create.mjs';
13
+ import {
14
+ MUST_EXIST,
15
+ assertAgentNameFree,
16
+ provisionAgent,
17
+ registeredJobIds,
18
+ scheduledJobFields,
19
+ } from './create.mjs';
13
20
  import { appendLifecycleEvent } from './eventlog.mjs';
14
21
  import { HireSpecError, readHireSpec } from './hire-spec.mjs';
15
22
  import { agentRecord } from './inspect.mjs';
16
- import { deleteNightlyConsolidation } from './lifecycle.mjs';
23
+ import { deleteAcademyJobs } from './lifecycle.mjs';
17
24
  import { writeAgentYamlScalar } from './yaml.mjs';
18
25
 
19
26
  // `hire --spec <path> [--json]` — the headless form. The interactive form
@@ -29,7 +36,7 @@ import { writeAgentYamlScalar } from './yaml.mjs';
29
36
  // entirely, so the runtime never decides what an Academy agent is.
30
37
 
31
38
  /**
32
- * How long the runtime gets before it is killed. A `-p` turn that writes eight
39
+ * How long the runtime gets before it is killed. A `-p` turn that writes several
33
40
  * surfaces is minutes of work, and a client waiting on a synchronous command
34
41
  * cannot wait on a wedged child forever.
35
42
  */
@@ -96,20 +103,31 @@ function writeSpecScalars(dir, spec) {
96
103
  // change. `role` and `objective` are caller-supplied text and reach the model
97
104
  // here, which is why they are capped and single-line and why the child is given
98
105
  // no authority over creation, naming, scheduling or the event log.
99
- function headlessHirePrompt({ name, role, objective }) {
106
+ function headlessHirePrompt(dir, { name, role, objective }) {
100
107
  return [
101
108
  `Use the Academy hire skill to finish hiring the agent "${name}".`,
102
109
  'Its home is the current working directory and already holds the scaffolded',
103
110
  'boot surfaces, agent.yaml and ownership marker.',
104
111
  `Role: ${role}`,
105
112
  `Objective: ${objective}`,
106
- 'Rewrite the eight surface files in place and stop. Do not create, rename,',
107
- 'archive or delete any agent, and do not write outside this directory.',
113
+ `Rewrite these surface files in place and stop: ${hiredSurfaceFiles(dir)}.`,
114
+ 'Do not create, rename, archive or delete any agent, and do not write',
115
+ 'outside this directory.',
108
116
  'There is no user to ask, so make your own best inference and proceed.',
109
117
  ].join('\n');
110
118
  }
111
119
 
112
- // `acceptEdits` rather than `bypassPermissions`: the child has to write eight
120
+ // The agent's own surfaces from the kind registry, minus local rules (they
121
+ // record corrections made after the hire) and the shared user files (the
122
+ // curator writes them, and they live outside this directory).
123
+ function hiredSurfaceFiles(dir) {
124
+ return surfacesFor(dir)
125
+ .filter(({ name, path }) => name !== 'local-rules' && dirname(path) === resolve(dir))
126
+ .map(({ name }) => `${name}.md`)
127
+ .join(', ');
128
+ }
129
+
130
+ // `acceptEdits` rather than `bypassPermissions`: the child has to write its
113
131
  // markdown files in its own working directory and needs nothing beyond that.
114
132
  // stdio is captured on both streams and stdin is closed, so a runtime that
115
133
  // falls back to asking a question fails fast instead of holding the timeout.
@@ -118,7 +136,7 @@ function runHeadlessRuntime(claudeBin, dir, spec) {
118
136
  claudeBin,
119
137
  [
120
138
  '-p',
121
- headlessHirePrompt(spec),
139
+ headlessHirePrompt(dir, spec),
122
140
  '--permission-mode',
123
141
  'acceptEdits',
124
142
  '--plugin-dir',
@@ -175,14 +193,15 @@ function excerpt(output) {
175
193
  // the agent_created that was already published. Leaving the half-hired agent
176
194
  // would register nightly consolidation for a specialist nobody hired and would
177
195
  // block every retry of the same name behind a manual delete.
178
- function rollbackHire(name, dir, nightlyTask) {
196
+ function rollbackHire(name, dir, jobs) {
179
197
  let dirReal = resolve(dir);
180
198
  try {
181
199
  dirReal = realpathSync(dir);
182
200
  } catch {
183
201
  /* already gone; the unschedule still names the canonical path */
184
202
  }
185
- if (nightlyTask.registered) deleteNightlyConsolidation(name, dirReal);
203
+ const registered = registeredJobIds(jobs);
204
+ if (registered.length > 0) deleteAcademyJobs(dirReal, registered);
186
205
  rmSync(dir, { recursive: true, force: true });
187
206
  try {
188
207
  appendLifecycleEvent('agent_deleted', name, dir);
@@ -194,20 +213,16 @@ function rollbackHire(name, dir, nightlyTask) {
194
213
  }
195
214
  }
196
215
 
197
- function failRuntime(name, dir, nightlyTask, fault, json) {
198
- const rollbackFault = rollbackHire(name, dir, nightlyTask);
216
+ function failRuntime(name, dir, jobs, fault, json) {
217
+ const rollbackFault = rollbackHire(name, dir, jobs);
199
218
  const fields = { name, ...fault.fields };
200
219
  if (rollbackFault) fields.rollbackFault = rollbackFault;
201
220
  failHire('runtime_unavailable', `hire of "${name}" failed: ${fault.message}`, fields, json);
202
221
  }
203
222
 
204
- function reportHired(name, dir, nightlyTask, json) {
223
+ function reportHired(name, dir, jobs, json) {
205
224
  if (json) {
206
- contractOk('hire', {
207
- hired: true,
208
- ...agentRecord(name),
209
- scheduledJobId: nightlyTask.registered ? nightlyTask.id : null,
210
- });
225
+ contractOk('hire', { hired: true, ...agentRecord(name), ...scheduledJobFields(jobs) });
211
226
  return;
212
227
  }
213
228
  console.log(`Hired agent "${name}" at ${dir}`);
@@ -221,9 +236,9 @@ export function hireFromSpec({ spec, json, invalidOption }) {
221
236
  validateName(parsed.name);
222
237
  assertAgentNameFree(parsed.name, json);
223
238
  const claudeBin = resolveHireRuntime(json);
224
- const { dir, nightlyTask } = provisionAgent(parsed.name, json);
239
+ const { dir, jobs } = provisionAgent(parsed.name, json);
225
240
  writeSpecScalars(dir, parsed);
226
241
  const fault = runtimeFault(runHeadlessRuntime(claudeBin, dir, parsed), claudeBin);
227
- if (fault) failRuntime(parsed.name, dir, nightlyTask, fault, json);
228
- reportHired(parsed.name, dir, nightlyTask, json);
242
+ if (fault) failRuntime(parsed.name, dir, jobs, fault, json);
243
+ reportHired(parsed.name, dir, jobs, json);
229
244
  }
@@ -2,6 +2,7 @@ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { dirname, join, resolve } from 'node:path';
4
4
  import { spawnSync } from 'node:child_process';
5
+ import { childExitCode } from './exit-code.mjs';
5
6
  import {
6
7
  ACADEMY_ROOT,
7
8
  AGENTS_ROOT,
@@ -67,6 +68,11 @@ export function hireAgent(passthrough, json = false) {
67
68
  });
68
69
  }
69
70
 
71
+ // A multi-line prompt would break the one-line dry-run view, so the inline
72
+ // append value prints as a JSON string, as Codex's `developer_instructions` does.
73
+ const dryRunArgs = (args) =>
74
+ args.map((arg, i) => (args[i - 1] === '--append-system-prompt' ? JSON.stringify(arg) : arg));
75
+
70
76
  export function launchClaude(args, { cwd, env, message }) {
71
77
  console.log(message);
72
78
  const claudeBin =
@@ -74,7 +80,7 @@ export function launchClaude(args, { cwd, env, message }) {
74
80
  ? process.env.ACADEMY_CLAUDE_BIN || 'claude'
75
81
  : resolveExecutable('ACADEMY_CLAUDE_BIN', 'claude');
76
82
  if (process.env.ACADEMY_DRY_RUN === '1') {
77
- console.log(`[dry-run] ${claudeBin} ${args.join(' ')}`);
83
+ console.log(`[dry-run] ${claudeBin} ${dryRunArgs(args).join(' ')}`);
78
84
  console.log(`[dry-run] cwd=${cwd}`);
79
85
  if (env?.ACADEMY_AGENT_DIR) console.log(`[dry-run] ACADEMY_AGENT_DIR=${env.ACADEMY_AGENT_DIR}`);
80
86
  if (env?.ACADEMY_PROJECT_DIR)
@@ -88,5 +94,5 @@ export function launchClaude(args, { cwd, env, message }) {
88
94
  );
89
95
  process.exit(1);
90
96
  }
91
- process.exit(result.status ?? 0);
97
+ process.exit(childExitCode(result));
92
98
  }