@joenandez/academy 0.5.17-rc.2 → 0.5.18

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 (70) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +150 -0
  3. package/README.md +7 -4
  4. package/package.json +2 -2
  5. package/scripts/cli/agent-gate.mjs +20 -10
  6. package/scripts/cli/agent-row.mjs +24 -0
  7. package/scripts/cli/agent-settings.mjs +104 -0
  8. package/scripts/cli/args-kinds.mjs +59 -8
  9. package/scripts/cli/args-owner.mjs +9 -5
  10. package/scripts/cli/args.mjs +44 -11
  11. package/scripts/cli/copilot/child-env.mjs +2 -0
  12. package/scripts/cli/core.mjs +8 -7
  13. package/scripts/cli/create-options.mjs +43 -0
  14. package/scripts/cli/create.mjs +21 -41
  15. package/scripts/cli/curator-feed-observations.mjs +38 -4
  16. package/scripts/cli/curator-feed-sources.mjs +9 -10
  17. package/scripts/cli/doctor.mjs +3 -0
  18. package/scripts/cli/heartbeat.mjs +9 -39
  19. package/scripts/cli/inspect.mjs +6 -1
  20. package/scripts/cli/leads.mjs +94 -0
  21. package/scripts/cli/lifecycle.mjs +3 -0
  22. package/scripts/cli/main.mjs +6 -1
  23. package/scripts/cli/model.mjs +45 -0
  24. package/scripts/cli/nightly.mjs +46 -16
  25. package/scripts/cli/owner-jobs.mjs +23 -4
  26. package/scripts/cli/owner-sessions.mjs +5 -5
  27. package/scripts/cli/rename.mjs +55 -17
  28. package/scripts/cli/run.mjs +3 -2
  29. package/scripts/cli/scaffold.mjs +12 -6
  30. package/scripts/cli/skill-resource-paths.mjs +140 -0
  31. package/scripts/cli/skill-resources.mjs +158 -0
  32. package/scripts/cli/skills.mjs +13 -53
  33. package/scripts/cli/start.mjs +21 -7
  34. package/scripts/cli/yaml.mjs +10 -0
  35. package/skills/hire/references/owner.md +6 -4
  36. package/templates/agents/claude-code/worker.md +1 -0
  37. package/templates/agents/codex/worker.toml +1 -0
  38. package/templates/operating-model/base.md +31 -11
  39. package/templates/operating-model/owner-loop-off.md +9 -0
  40. package/templates/operating-model/owner.md +3 -2
  41. package/templates/packs/owner/craft.md +2 -2
  42. package/templates/packs/owner/mandate.md +3 -0
  43. package/templates/packs/owner/owner/SKILL.md +31 -26
  44. package/templates/packs/owner/owner-feedback/SKILL.md +4 -3
  45. package/templates/packs/owner/owner-heartbeat/SKILL.md +5 -20
  46. package/templates/packs/owner/owner-nightly/SKILL.md +10 -17
  47. package/templates/packs/owner/owner-review/SKILL.md +5 -4
  48. package/templates/packs/owner/owner-work/SKILL.md +28 -19
  49. package/templates/packs/principal/academy-performance-review/SKILL.md +91 -0
  50. package/templates/packs/principal/academy-performance-review/references/operating-standard.md +59 -0
  51. package/templates/packs/principal/principal-brief/SKILL.md +23 -7
  52. package/templates/packs/principal/principal-nightly/SKILL.md +6 -7
  53. package/templates/skills/delivery/SKILL.md +18 -0
  54. package/templates/skills/delivery/references/build.md +15 -0
  55. package/templates/skills/delivery/references/experience.md +12 -0
  56. package/templates/skills/delivery/references/orchestration.md +15 -0
  57. package/templates/skills/delivery/references/plan.md +15 -0
  58. package/templates/skills/delivery/references/run.md +26 -0
  59. package/templates/skills/delivery/references/scope.md +15 -0
  60. package/templates/skills/delivery/references/ship.md +12 -0
  61. package/templates/skills/delivery/references/validate.md +16 -0
  62. package/templates/skills/explore-options/SKILL.md +66 -0
  63. package/templates/skills/knowledge-curation/SKILL.md +2 -3
  64. package/templates/skills/nightly-consolidation/SKILL.md +9 -4
  65. package/templates/skills/skill-method/SKILL.md +2 -2
  66. package/templates/skills/triage/SKILL.md +119 -0
  67. package/templates/skills/triage/references/curator.md +14 -0
  68. package/templates/skills/triage/references/rank.md +39 -0
  69. package/templates/skills/user-nightly/SKILL.md +18 -10
  70. package/templates/skills/work-graph/SKILL.md +6 -3
@@ -53,6 +53,9 @@ export const UNIVERSAL_SKILLS = [
53
53
  'knowledge-curation',
54
54
  'local-rules',
55
55
  'skill-method',
56
+ 'delivery',
57
+ 'triage',
58
+ 'explore-options',
56
59
  ];
57
60
  export const NIGHTLY_JOB_CRON = '0 22 * * *';
58
61
  // Unattended runs bypass permissions: auto mode refused every memory write
@@ -165,15 +168,13 @@ export const KIND_PROFILES = {
165
168
  specialist: { surfaces: surfaceSet(1000), skills: [...UNIVERSAL_SKILLS, 'user-nightly'] },
166
169
  principal: {
167
170
  surfaces: surfaceSet(500),
171
+ // A Principal runs its own pack nightly, so it has no nightly-consolidation.
168
172
  skills: [
169
173
  'principal-nightly',
170
174
  'principal-brief',
171
175
  'principal-intro',
172
- 'work-graph',
173
- 'check-in',
174
- 'knowledge-curation',
175
- 'local-rules',
176
- 'skill-method',
176
+ 'academy-performance-review',
177
+ ...UNIVERSAL_SKILLS.filter((skill) => skill !== 'nightly-consolidation'),
177
178
  ],
178
179
  },
179
180
  owner: { surfaces: surfaceSet(1000), skills: [...OWNER_PACK, ...UNIVERSAL_SKILLS] },
@@ -351,13 +352,13 @@ function sleep(ms) {
351
352
  }
352
353
 
353
354
  export function withFileLock(lockDir, fn) {
354
- const started = Date.now();
355
+ const [started, limit] = [Date.now(), Number(process.env.ACADEMY_LOCK_TIMEOUT_MS) || 5000];
355
356
  while (true) {
356
357
  try {
357
358
  mkdirSync(lockDir, { recursive: false });
358
359
  break;
359
360
  } catch {
360
- if (Date.now() - started > 5000) throw new LockTimeoutError(lockDir);
361
+ if (Date.now() - started > limit) throw new LockTimeoutError(lockDir);
361
362
  sleep(25);
362
363
  }
363
364
  }
@@ -0,0 +1,43 @@
1
+ import { KIND_PROFILES, exitJsonError } from './core.mjs';
2
+ import { isModelId } from './model.mjs';
3
+ import { toRuntimeProvider } from './runtime.mjs';
4
+ import { DISPLAY_NAME_RULE, isDisplayName } from './yaml.mjs';
5
+
6
+ // Every refusal comes before the name is asked about, so a create Academy will
7
+ // not perform writes nothing. `invalid_spec` is the published code for an
8
+ // unknown option; there is no sixteenth code for it.
9
+ export function assertCreateOptions(kind, invalidOption, json, { model, displayName, runtime }) {
10
+ if (invalidOption !== undefined) {
11
+ refuseCreate(
12
+ `Unknown create option: ${invalidOption}. Use create <name> [--kind <kind>] [--model <id>] [--display-name <text>] [--runtime claude-code|codex|copilot] [--no-start] [--json].`,
13
+ { option: invalidOption },
14
+ json,
15
+ );
16
+ }
17
+ if (model !== undefined && !isModelId(model)) {
18
+ refuseCreate(`Model "${model}" is not a model id Academy can store.`, { model }, json);
19
+ }
20
+ if (runtime !== undefined && !toRuntimeProvider(runtime)) {
21
+ refuseCreate(
22
+ `Runtime "${runtime}" is not a runtime Academy supports. Use claude-code, codex, or copilot.`,
23
+ { runtime },
24
+ json,
25
+ );
26
+ }
27
+ if (displayName !== undefined && !isDisplayName(displayName)) {
28
+ refuseCreate(DISPLAY_NAME_RULE, { displayName }, json);
29
+ }
30
+ if (Object.hasOwn(KIND_PROFILES, kind)) return;
31
+ const kinds = Object.keys(KIND_PROFILES).join(', ');
32
+ refuseCreate(
33
+ `Agent kind "${kind}" is not a kind Academy supports. Use ${kinds}.`,
34
+ { kind },
35
+ json,
36
+ );
37
+ }
38
+
39
+ function refuseCreate(message, fields, json) {
40
+ if (json) exitJsonError('invalid_spec', message, fields);
41
+ console.error(`Error: ${message}`);
42
+ process.exit(1);
43
+ }
@@ -5,13 +5,11 @@ import { pendingMarkerPath } from '../../hooks/memory_store.mjs';
5
5
  import {
6
6
  ACADEMY_ROOT,
7
7
  CLI_NAME,
8
- KIND_PROFILES,
9
8
  activeCommandName,
10
9
  NIGHTLY_JOB_CRON,
11
10
  agentDir,
12
11
  contractError,
13
12
  contractOk,
14
- exitJsonError,
15
13
  kindOf,
16
14
  LockTimeoutError,
17
15
  RuntimeUnavailableError,
@@ -25,8 +23,10 @@ import { assertNotArchived } from './archived.mjs';
25
23
  import { claimDefaultName, claimName, isAgentNameFree } from './default-name.mjs';
26
24
  import { LogCorruptError, appendLifecycleEvent } from './eventlog.mjs';
27
25
  import { agentRecord } from './inspect.mjs';
26
+ import { creatorOf } from './leads.mjs';
27
+ import { assertCreateOptions } from './create-options.mjs';
28
+ import { toRuntimeProvider } from './runtime.mjs';
28
29
  import { nextSteps } from './next-steps.mjs';
29
- import { isStopped } from './agent-gate.mjs';
30
30
  import { deleteAcademyJobs } from './lifecycle.mjs';
31
31
  import {
32
32
  ensureNightlyPermissionMode,
@@ -63,49 +63,25 @@ export const INTRO_PENDING_MARKER = 'intro-pending.md';
63
63
  export function createAgent(
64
64
  name,
65
65
  json = false,
66
- { kind = DEFAULT_KIND, invalidOption, guided } = {},
66
+ { kind = DEFAULT_KIND, model, displayName, runtime, invalidOption, guided } = {},
67
67
  ) {
68
- assertCreateOptions(kind, invalidOption, json);
69
- const created = provisionAgent(name, json, kind);
68
+ assertCreateOptions(kind, invalidOption, json, { model, displayName, runtime });
69
+ // Stored in the provider form `run --agent` writes, before the jobs register.
70
+ const yamlOptions = { model, displayName, runtime: runtime && toRuntimeProvider(runtime) };
71
+ const created = provisionAgent(name, json, kind, yamlOptions);
70
72
  reportCreated(created.name, created.dir, created.jobs, json, guided);
71
73
  return created.name;
72
74
  }
73
75
 
74
- // Both refusals come before the name is asked about, so a create Academy will
75
- // not perform writes nothing. `invalid_spec` is the published code for an
76
- // unknown option; there is no sixteenth code for it.
77
- function assertCreateOptions(kind, invalidOption, json) {
78
- if (invalidOption !== undefined) {
79
- refuseCreate(
80
- `Unknown create option: ${invalidOption}. Use create <name> [--kind <kind>] [--no-start] [--json].`,
81
- { option: invalidOption },
82
- json,
83
- );
84
- }
85
- if (Object.hasOwn(KIND_PROFILES, kind)) return;
86
- const kinds = Object.keys(KIND_PROFILES).join(', ');
87
- refuseCreate(
88
- `Agent kind "${kind}" is not a kind Academy supports. Use ${kinds}.`,
89
- { kind },
90
- json,
91
- );
92
- }
93
-
94
- function refuseCreate(message, fields, json) {
95
- if (json) exitJsonError('invalid_spec', message, fields);
96
- console.error(`Error: ${message}`);
97
- process.exit(1);
98
- }
99
-
100
76
  // The one creation path. `create` reports what it returns as a create and the
101
77
  // headless hire reports the same thing as a hire, so an agent hired without a
102
78
  // terminal is indistinguishable from a created one in ownership, scheduling and
103
79
  // events. A second scaffolder would be a second definition of what an Academy
104
80
  // agent is, and the two would disagree the first time one of them learned a
105
81
  // rule the other did not.
106
- export function provisionAgent(name, json = false, kind = DEFAULT_KIND) {
82
+ export function provisionAgent(name, json = false, kind = DEFAULT_KIND, yamlOptions = {}) {
107
83
  const claimed = name === undefined ? claimDefaultName(kind, json) : claimNamedDir(name, json);
108
- return provisionAgentDir(claimed.dir, claimed.name, json, kind);
84
+ return provisionAgentDir(claimed.dir, claimed.name, json, kind, yamlOptions);
109
85
  }
110
86
 
111
87
  // The mkdir is the one point where this request comes to own the directory. It
@@ -122,8 +98,8 @@ function claimNamedDir(name, json) {
122
98
  return { name, dir };
123
99
  }
124
100
 
125
- function provisionAgentDir(dir, name, json, kind) {
126
- scaffoldAgentDir(dir, name, json, kind);
101
+ function provisionAgentDir(dir, name, json, kind, yamlOptions) {
102
+ scaffoldAgentDir(dir, name, json, kind, yamlOptions);
127
103
  const jobs = registerAcademyJobs(dir, name);
128
104
  const failed = jobs.find((job) => !job.registered && !job.skipped);
129
105
  if (failed) {
@@ -170,11 +146,11 @@ function refuseTakenName(name, dir, json) {
170
146
  process.exit(1);
171
147
  }
172
148
 
173
- function scaffoldAgentDir(dir, name, json, kind) {
149
+ function scaffoldAgentDir(dir, name, json, kind, yamlOptions = {}) {
174
150
  try {
175
151
  const today = new Date().toISOString().slice(0, 10);
176
152
  // agent.yaml first: the kind it names selects the surfaces scaffolded next.
177
- writeAgentYaml(dir, name, today, kind);
153
+ writeAgentYaml(dir, name, today, kind, { ...yamlOptions, createdBy: creatorOf() });
178
154
  scaffoldBootFiles(dir, name, today);
179
155
  writeAgentClaudeMd(dir, name);
180
156
  writeSkillsScaffold(dir, name);
@@ -273,6 +249,9 @@ function reportGuidedCreated(name, nightlyTask, guided) {
273
249
  }
274
250
  }
275
251
 
252
+ // A 22:00 nightly missed while the Mac slept still fires on wake until ~08:00 (10 h).
253
+ const NIGHTLY_MISFIRE_GRACE_SEC = '36000';
254
+
276
255
  export function localTimezone() {
277
256
  return (
278
257
  process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'America/Los_Angeles'
@@ -291,7 +270,6 @@ function registerNightlyConsolidationTask(dir, name) {
291
270
  reason: 'skipped by ACADEMY_SKIP_NIGHTLY_TASK=1',
292
271
  };
293
272
  }
294
- if (isStopped(dir)) return { id, registered: false, skipped: true, reason: 'agent is stopped' };
295
273
  const timezone = localTimezone();
296
274
  let academyBin;
297
275
  let tldrAgentsBin;
@@ -313,6 +291,8 @@ function registerNightlyConsolidationTask(dir, name) {
313
291
  id,
314
292
  '--cron',
315
293
  NIGHTLY_JOB_CRON,
294
+ '--misfire-grace-sec',
295
+ NIGHTLY_MISFIRE_GRACE_SEC,
316
296
  '--timezone',
317
297
  timezone,
318
298
  '--condition-file-exists',
@@ -339,8 +319,8 @@ export function runSchedule(tldrAgentsBin, dir, id, args) {
339
319
 
340
320
  // Every Academy job an agent of its kind runs, nightly first. The skip flag
341
321
  // that silences the nightly silences the set, and an Owner whose nightly did
342
- // not register does not go on to half a schedule. A stopped agent's nightly is
343
- // skipped, so it registers nothing.
322
+ // not register does not go on to half a schedule. A stopped agent registers its
323
+ // nightly only (registerKindTasks).
344
324
  export function registerAcademyJobs(dir, name) {
345
325
  const nightly = registerNightlyConsolidationTask(dir, name);
346
326
  return nightly.registered ? registerKindTasks(dir, name, nightly) : [nightly];
@@ -1,8 +1,16 @@
1
1
  // Memory-bridge observations and the previous curator run's token use, for curator-feed.mjs. Read-only.
2
- import { existsSync } from 'node:fs';
2
+ import { closeSync, existsSync, openSync, readSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { bridgeHomes } from '../../hooks/memory_bridge.mjs';
5
- import { DAY, ifPresent, jsonLines, ls, mtimeMs, textOf } from './curator-feed-sources.mjs';
5
+ import {
6
+ DAY,
7
+ ifPresent,
8
+ jsonLines,
9
+ ls,
10
+ mtimeMs,
11
+ parseJsonLines,
12
+ textOf,
13
+ } from './curator-feed-sources.mjs';
6
14
 
7
15
  // --- C. Observations ---
8
16
  const observationRows = (file) => ifPresent(() => jsonLines(file), []);
@@ -62,6 +70,33 @@ function isCuratorRun(lines) {
62
70
  );
63
71
  }
64
72
 
73
+ // The Helm marker is in the first user entry, which real transcripts reach after up to ~110 KB of
74
+ // hook and attachment lines. Read only that head, in chunks, never the whole transcript.
75
+ // shortcut: a first user entry past the cap is "not a match"; raise the cap if heads grow.
76
+ const HEAD_CAP = 256 * 1024;
77
+ const HEAD_CHUNK = 64 * 1024;
78
+ const hasUserText = (e) => e.type === 'user' && textOf(e.message?.content) != null;
79
+
80
+ function headIsCuratorRun(file) {
81
+ const fd = openSync(file, 'r');
82
+ try {
83
+ let head = Buffer.alloc(0);
84
+ while (head.length < HEAD_CAP) {
85
+ const chunk = Buffer.alloc(HEAD_CHUNK);
86
+ const n = readSync(fd, chunk, 0, HEAD_CHUNK, head.length);
87
+ if (!n) return isCuratorRun(parseJsonLines(head.toString('utf8')));
88
+ head = Buffer.concat([head, chunk.subarray(0, n)]);
89
+ // Complete lines only: the last one may be cut mid-line or mid-character.
90
+ const complete = head.toString('utf8', 0, head.lastIndexOf(10) + 1);
91
+ const lines = parseJsonLines(complete);
92
+ if (lines.some(hasUserText)) return isCuratorRun(lines);
93
+ }
94
+ return false;
95
+ } finally {
96
+ closeSync(fd);
97
+ }
98
+ }
99
+
65
100
  function tokensOf(lines) {
66
101
  const byId = new Map();
67
102
  for (const e of lines)
@@ -90,8 +125,7 @@ export function previousRun(home, agentsRoot) {
90
125
  .map((f) => ({ file: join(dir, f), at: mtimeMs(join(dir, f)) }))
91
126
  .sort((a, b) => b.at - a.at);
92
127
  for (const { file } of files) {
93
- const lines = jsonLines(file);
94
- if (isCuratorRun(lines)) return tokensOf(lines);
128
+ if (headIsCuratorRun(file)) return tokensOf(jsonLines(file));
95
129
  }
96
130
  return null;
97
131
  }
@@ -54,16 +54,15 @@ export function ifPresent(read, fallback) {
54
54
  }
55
55
  }
56
56
  export const ls = (dir) => ifPresent(() => readdirSync(dir), null);
57
- export const jsonLines = (file) =>
58
- readFileSync(file, 'utf8')
59
- .split('\n')
60
- .flatMap((l) => {
61
- try {
62
- return l ? [JSON.parse(l)] : [];
63
- } catch {
64
- return [];
65
- }
66
- });
57
+ export const parseJsonLines = (text) =>
58
+ text.split('\n').flatMap((l) => {
59
+ try {
60
+ return l ? [JSON.parse(l)] : [];
61
+ } catch {
62
+ return [];
63
+ }
64
+ });
65
+ export const jsonLines = (file) => parseJsonLines(readFileSync(file, 'utf8'));
67
66
  export const mtimeMs = (f) => statSync(f).mtimeMs;
68
67
  export const oneLine = (s) => s.replace(/\s+/g, ' ').trim();
69
68
  export const textOf = (content) => {
@@ -44,6 +44,9 @@ const PUBLISHED_COMMANDS = [
44
44
  'migrate',
45
45
  'start',
46
46
  'stop',
47
+ // Additive to v1: an agent's default model and a project's Lead.
48
+ 'set-model',
49
+ 'lead',
47
50
  ];
48
51
 
49
52
  /** Published above and built by no phase yet. Empty: every command is real. */
@@ -3,7 +3,7 @@ import { existsSync } from 'node:fs';
3
3
  import { beadsEnv } from './beads.mjs';
4
4
  import { kindOf, resolveExecutable } from './core.mjs';
5
5
  import { printHelmSkip } from './helm-skip.mjs';
6
- import { bookedFollowup, followupOf, listJobs, otherSession } from './owner-sessions.mjs';
6
+ import { bookedFollowup } from './owner-sessions.mjs';
7
7
 
8
8
  // The run type of an Owner job's run, `daily` or `heartbeat`, derived from its
9
9
  // job id at the run entry (run-context.mjs); a provider job carries no env.
@@ -11,8 +11,6 @@ export const OWNER_RUN_FLAG = 'ACADEMY_OWNER_RUN';
11
11
 
12
12
  /** The stopped items a heartbeat session is started for, comma separated. */
13
13
  const STOPPED_ITEMS_ENV = 'ACADEMY_STOPPED_ITEMS';
14
- /** The ungated ready items an idle heartbeat session is started for, comma separated. */
15
- const READY_ITEMS_ENV = 'ACADEMY_READY_ITEMS';
16
14
 
17
15
  // Each Owner session claims Beads items as its own actor, `<agent>@<pid>`, the
18
16
  // pid of the `academy run` that waits on it (beads.mjs). An item stays its
@@ -85,9 +83,8 @@ function checkGates() {
85
83
  /**
86
84
  * Work a stopped run left behind: this Owner's items (label `owner:<agent>`)
87
85
  * in progress, not blocked by a gate, and not held by a session that is still
88
- * running. Those a running session holds are `held`. `bd blocked` only names
89
- * ids to leave out. No user database is an error: nothing can be known about
90
- * the work.
86
+ * running. `bd blocked` only names ids to leave out. No user database is an
87
+ * error: nothing can be known about the work.
91
88
  */
92
89
  function stoppedWork(name, now) {
93
90
  const database = beadsEnv().BEADS_DIR;
@@ -95,41 +92,14 @@ function stoppedWork(name, now) {
95
92
  const gates = checkGates();
96
93
  const blocked = new Set(bdJson(['blocked']).map((item) => item.id));
97
94
  const items = [];
98
- const held = [];
99
95
  for (const item of bdJson(['list', '--status', 'in_progress', '--label', `owner:${name}`])) {
100
- if (item.id && !blocked.has(item.id)) (holderRunning(item, now) ? held : items).push(item.id);
96
+ if (item.id && !blocked.has(item.id) && !holderRunning(item, now)) items.push(item.id);
101
97
  }
102
- return { items, held, gates, blocked };
98
+ return { items, gates };
103
99
  }
104
100
 
105
101
  const BEADS_SKIPS = new Set(['beads_unavailable', 'beads_busy']);
106
102
 
107
- // Owners release their claims when a session ends, so ready work can sit with
108
- // nothing in progress. Without a booked follow-up or a live session it needs a
109
- // start. An item a live holder has in progress is a live session, whatever the
110
- // jobs show. Any failed lookup skips: a missed start waits one beat, a wrong
111
- // start burns a model session. An epic is left out: it reads ready while all
112
- // its children wait, and its children are the work.
113
- function idleDecision(dir, name, now, ownRunId, { blocked, held, gates }) {
114
- try {
115
- const items = bdJson(['ready', '--label', `owner:${name}`, '--exclude-type', 'workstream,epic'])
116
- .map((item) => item.id)
117
- .filter((id) => id && !blocked.has(id));
118
- if (items.length === 0) return { action: 'skip', reason: 'nothing_due', gates };
119
- const jobs = listJobs(dir);
120
- const followup = jobs.map((job) => followupOf(job, name, now)).find(Boolean);
121
- if (followup) return { action: 'skip', reason: 'followup_booked', items, followup, gates };
122
- const other =
123
- held.length > 0
124
- ? { reason: 'session_running', held }
125
- : otherSession(jobs, dir, name, now, ownRunId);
126
- if (other) return { action: 'skip', ...other, items, gates };
127
- return { action: 'run', reason: 'idle_ready_work', items, gates };
128
- } catch (error) {
129
- return { action: 'skip', reason: 'idle_lookup_failed', error: error.message, gates };
130
- }
131
- }
132
-
133
103
  function heartbeatDecision(dir, name, now = Date.now(), ownRunId = undefined) {
134
104
  let found;
135
105
  try {
@@ -138,7 +108,9 @@ function heartbeatDecision(dir, name, now = Date.now(), ownRunId = undefined) {
138
108
  const reason = error instanceof BeadsBusyError ? 'beads_busy' : 'beads_unavailable';
139
109
  return { action: 'skip', reason, error: error.message };
140
110
  }
141
- if (found.items.length === 0) return idleDecision(dir, name, now, ownRunId, found);
111
+ // New ready work is the daily open's job; a heartbeat only restarts stopped work.
112
+ if (found.items.length === 0)
113
+ return { action: 'skip', reason: 'nothing_due', gates: found.gates };
142
114
  return stoppedDecision(dir, name, now, ownRunId, found);
143
115
  }
144
116
 
@@ -196,7 +168,6 @@ export function guardOwnerHeartbeat(dir, name, env = process.env) {
196
168
  const result = heartbeatDecision(dir, name, started, env.HELM_RUN_ID);
197
169
  const warn =
198
170
  BEADS_SKIPS.has(result.reason) ||
199
- result.reason === 'idle_lookup_failed' ||
200
171
  result.gates === 'failed' ||
201
172
  result.followup === 'lookup_failed';
202
173
  console.error(
@@ -209,8 +180,7 @@ export function guardOwnerHeartbeat(dir, name, env = process.env) {
209
180
  }),
210
181
  );
211
182
  if (result.action === 'run') {
212
- env[result.reason === 'stopped_work' ? STOPPED_ITEMS_ENV : READY_ITEMS_ENV] =
213
- result.items.join(',');
183
+ env[STOPPED_ITEMS_ENV] = result.items.join(',');
214
184
  process.once('exit', () => logOutcome(result.items));
215
185
  return;
216
186
  }
@@ -1,5 +1,5 @@
1
1
  import { existsSync, lstatSync, readdirSync } from 'node:fs';
2
- import { join, resolve } from 'node:path';
2
+ import { basename, join, resolve } from 'node:path';
3
3
  import {
4
4
  AGENTS_ROOT,
5
5
  CLI_NAME,
@@ -16,6 +16,7 @@ import {
16
16
  import { archivedAgentDir, archivedAgentNames, assertNotArchived } from './archived.mjs';
17
17
  import { assertContainedAgentDir } from './lifecycle.mjs';
18
18
  import { readEventLogWatermark } from './eventlog.mjs';
19
+ import { leadOf } from './leads.mjs';
19
20
  import { readRuntimeProvider, runtimeProviderOrNull } from './runtime.mjs';
20
21
  import { academySystemPromptPath, buildAcademySystemPrompt, tokenRecord } from './scaffold.mjs';
21
22
  import { readAgentYaml } from './yaml.mjs';
@@ -112,6 +113,10 @@ export function agentRecord(
112
113
  displayName: yaml.displayName || yaml.display_name || yaml.name || name,
113
114
  runtimeProvider: strictRuntime ? readRuntimeProvider(dir) : runtimeProviderOrNull(dir),
114
115
  kind: strictRuntime ? kindOf(dir) : rosterKind(yaml),
116
+ // Additive: the default model (A4), who created the agent, and the projects it leads (A8).
117
+ model: yaml.model || null,
118
+ created_by: yaml.created_by || null,
119
+ lead_of: leadOf(basename(dir)),
115
120
  };
116
121
  if (yaml.role) record.role = yaml.role;
117
122
  if (includeSurfaces) record.surfaces = surfacePresence(dir);
@@ -0,0 +1,94 @@
1
+ import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { basename, dirname, join, resolve } from 'node:path';
3
+ import { AGENTS_ROOT, NAME_RE } from './core.mjs';
4
+ import { withRootEventLock } from './eventlog.mjs';
5
+
6
+ // A8 provenance: who created an agent (`created_by:` in its agent.yaml, written
7
+ // once by `create`) and each project's Lead. Leads live in one file beside the
8
+ // event log: a map from the project's normalized absolute path to the agent's
9
+ // fixed id, so a rename keeps it. A map key holds one value, so "one Lead per
10
+ // project" needs no check, and every change is one atomic write.
11
+
12
+ const leadsPath = () => join(dirname(resolve(AGENTS_ROOT)), 'leads.json');
13
+
14
+ // Strict: a write must never replace a file it cannot read, or every Lead in it is lost.
15
+ function readLeadMap() {
16
+ let text;
17
+ try {
18
+ text = readFileSync(leadsPath(), 'utf8');
19
+ } catch (error) {
20
+ if (error.code === 'ENOENT') return {};
21
+ throw error;
22
+ }
23
+ const projects = JSON.parse(text).projects;
24
+ if (projects && typeof projects === 'object' && !Array.isArray(projects)) return projects;
25
+ throw new Error(`${leadsPath()} has no "projects" map. Move it aside to start a new one.`);
26
+ }
27
+
28
+ function writeLeadMap(projects) {
29
+ const sorted = Object.fromEntries(
30
+ Object.keys(projects)
31
+ .sort()
32
+ .map((key) => [key, projects[key]]),
33
+ );
34
+ const temp = `${leadsPath()}.tmp`;
35
+ writeFileSync(temp, `${JSON.stringify({ version: 1, projects: sorted }, null, 2)}\n`);
36
+ renameSync(temp, leadsPath());
37
+ }
38
+
39
+ // Sets (an id) or clears (null) the Lead of `project`; returns the previous Lead or null.
40
+ export function writeLead(project, id) {
41
+ return withRootEventLock(() => {
42
+ const projects = readLeadMap();
43
+ const previous = projects[project] ?? null;
44
+ if (id) projects[project] = id;
45
+ else delete projects[project];
46
+ if (previous !== id) writeLeadMap(projects);
47
+ return previous;
48
+ });
49
+ }
50
+
51
+ // A deleted agent leads nothing, and a later agent given the same id must not
52
+ // inherit its projects. The delete has already happened, so a fault is reported, not raised.
53
+ export function dropLeadsOf(id) {
54
+ try {
55
+ withRootEventLock(() => {
56
+ const projects = readLeadMap();
57
+ const kept = Object.entries(projects).filter(([, lead]) => lead !== id);
58
+ if (kept.length !== Object.keys(projects).length) writeLeadMap(Object.fromEntries(kept));
59
+ });
60
+ } catch (error) {
61
+ console.error(
62
+ JSON.stringify({
63
+ event: 'leads.drop_failed',
64
+ status: 'warn',
65
+ params: { agent: id, path: leadsPath() },
66
+ result: { error: error.message },
67
+ }),
68
+ );
69
+ }
70
+ }
71
+
72
+ // The roster read degrades like the rest of `list`: an unreadable file reports no Leads.
73
+ export function leadOf(id) {
74
+ let projects;
75
+ try {
76
+ projects = readLeadMap();
77
+ } catch {
78
+ return [];
79
+ }
80
+ return Object.keys(projects)
81
+ .filter((project) => projects[project] === id)
82
+ .sort();
83
+ }
84
+
85
+ // Who is creating the agent: the agent whose session runs this create (`academy
86
+ // run` exports its home as ACADEMY_AGENT_DIR), else the user. A home outside this
87
+ // AGENTS_ROOT names no agent here, so the creator stays unknown (null).
88
+ export function creatorOf(env = process.env) {
89
+ if (!env.ACADEMY_AGENT_DIR) return 'user';
90
+ const home = resolve(env.ACADEMY_AGENT_DIR);
91
+ const id = basename(home);
92
+ const inRoster = dirname(home) === resolve(AGENTS_ROOT) && NAME_RE.test(id);
93
+ return inRoster && existsSync(join(home, 'agent.yaml')) ? id : null;
94
+ }
@@ -20,6 +20,7 @@ import {
20
20
  } from './core.mjs';
21
21
  import { assertNotArchived, holdingAreaOrNull } from './archived.mjs';
22
22
  import { LogCorruptError, appendLifecycleEvent } from './eventlog.mjs';
23
+ import { dropLeadsOf } from './leads.mjs';
23
24
  import { AgentSpecError, readAgentYaml } from './yaml.mjs';
24
25
  import { agentIdOf, currentAgentName } from './agent-id.mjs';
25
26
  import { helmFailureReason } from './schedule-errors.mjs';
@@ -300,6 +301,7 @@ function deleteAgentLocked(id, _json) {
300
301
  throw unscheduleError('delete', name, unscheduled);
301
302
  }
302
303
  rmSync(quarantine, { recursive: true, force: false });
304
+ dropLeadsOf(id);
303
305
  appendLifecycleEvent('agent_deleted', name, dir);
304
306
  if (_json) {
305
307
  // Absolute, like every other command's `dir`: a client keying agents on the
@@ -329,6 +331,7 @@ export function destroyAgent(name, force) {
329
331
  process.exit(1);
330
332
  }
331
333
  rmSync(dir, { recursive: true, force: true });
334
+ dropLeadsOf(basename(dir));
332
335
  console.log(`Destroyed agent "${name}" (removed ${dir}).`);
333
336
  }
334
337
 
@@ -1,5 +1,6 @@
1
1
  import { resolve } from 'node:path';
2
2
  import { isatty } from 'node:tty';
3
+ import { printAgentRow } from './agent-row.mjs';
3
4
  import { parseArgs, printUsage } from './args.mjs';
4
5
  import {
5
6
  ACADEMY_ROOT,
@@ -11,6 +12,7 @@ import {
11
12
  jsonMode,
12
13
  setActiveCommand,
13
14
  } from './core.mjs';
15
+ import { leadCommand, setAgentModel } from './agent-settings.mjs';
14
16
  import { archiveAgent, unarchiveAgent } from './archive.mjs';
15
17
  import { createAgent } from './create.mjs';
16
18
  import { UNIMPLEMENTED_COMMANDS, reportHealth } from './doctor.mjs';
@@ -52,12 +54,15 @@ const COMMAND_HANDLERS = {
52
54
  migrate: (parsed) => backfillOwnership(parsed),
53
55
  start: (parsed) => startAgent(parsed),
54
56
  stop: (parsed) => stopAgent(parsed.name, parsed.json),
55
- rename: (parsed) => renameAgent(parsed.name, parsed.newName, parsed.json, parsed.invalidOption),
57
+ 'set-model': (parsed) => setAgentModel(parsed),
58
+ lead: (parsed) => leadCommand(parsed),
59
+ rename: (parsed) => renameAgent(parsed),
56
60
  archive: (parsed) => archiveAgent(parsed.name, parsed.json),
57
61
  unarchive: (parsed) => unarchiveAgent(parsed.name, parsed.json),
58
62
  sessions: (parsed) => readSessions(parsed),
59
63
  list: (parsed) => {
60
64
  if (parsed.slackRows) printSlackRows(parsed.name);
65
+ else if (parsed.agentRow) printAgentRow(parsed.name);
61
66
  else if (parsed.json) listAgentsJson();
62
67
  else listAgents();
63
68
  },
@@ -0,0 +1,45 @@
1
+ import { runtimeProviderOrNull, toRuntimeToken } from './runtime.mjs';
2
+ import { readAgentYaml } from './yaml.mjs';
3
+
4
+ // An agent's default model: the agent.yaml `model:` scalar beside `runtime:`.
5
+ // `create --model` writes it, `set-model` changes it, and `run` passes it to
6
+ // the runtime unless the caller already chose a model.
7
+
8
+ // Aliases (opus), full ids, routed ids (openrouter/openai/gpt-5.1), Bedrock ids
9
+ // with a version (`...:0`) and Claude's `[1m]` suffix. Nothing that would end a
10
+ // YAML plain scalar early or read as two argv words.
11
+ const MODEL_RE = /^[A-Za-z0-9][\w.:/@+[\]-]{0,199}$/;
12
+
13
+ export const isModelId = (value) => typeof value === 'string' && MODEL_RE.test(value);
14
+
15
+ export const agentModel = (dir) => readAgentYaml(dir).model || null;
16
+
17
+ // The caller's flag wins: Claude's `--model`, Codex's `-m`/`--model`, and a
18
+ // Codex `-c model=...` override, in every spelling those CLIs accept.
19
+ const MODEL_FLAG = /^(?:-m|--model)(?:=|$)/;
20
+ const CONFIG_MODEL = /^\s*model\s*=/;
21
+
22
+ function codexConfigValue(arg, next) {
23
+ if (arg === '-c' || arg === '--config') return next;
24
+ return arg.match(/^(?:--config=|-c=?)(.+)$/s)?.[1];
25
+ }
26
+
27
+ function callerChoseModel(argv, codex) {
28
+ return argv.some(
29
+ (arg, i) =>
30
+ MODEL_FLAG.test(arg) ||
31
+ (codex && CONFIG_MODEL.test(codexConfigValue(arg, argv[i + 1]) ?? '')),
32
+ );
33
+ }
34
+
35
+ // The stored model names a model of the persisted runtime, so a one-run switch
36
+ // to another runtime drops it. A one-shot Codex run takes `-m` after `exec`.
37
+ export function withModel(dir, runtime, passthrough) {
38
+ const model = runtime === toRuntimeToken(runtimeProviderOrNull(dir)) ? agentModel(dir) : null;
39
+ const codex = runtime === 'codex';
40
+ if (!model || callerChoseModel(passthrough, codex)) return passthrough;
41
+ if (!codex) return ['--model', model, ...passthrough];
42
+ const exec = passthrough.indexOf('exec');
43
+ if (exec === -1) return ['-m', model, ...passthrough];
44
+ return [...passthrough.slice(0, exec + 1), '-m', model, ...passthrough.slice(exec + 1)];
45
+ }