@joenandez/academy 0.5.17-rc.2 → 0.5.17

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 (69) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +140 -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 +43 -5
  9. package/scripts/cli/args-owner.mjs +9 -5
  10. package/scripts/cli/args.mjs +42 -11
  11. package/scripts/cli/copilot/child-env.mjs +2 -0
  12. package/scripts/cli/core.mjs +8 -7
  13. package/scripts/cli/create.mjs +28 -16
  14. package/scripts/cli/curator-feed-observations.mjs +38 -4
  15. package/scripts/cli/curator-feed-sources.mjs +9 -10
  16. package/scripts/cli/doctor.mjs +3 -0
  17. package/scripts/cli/heartbeat.mjs +9 -39
  18. package/scripts/cli/inspect.mjs +6 -1
  19. package/scripts/cli/leads.mjs +94 -0
  20. package/scripts/cli/lifecycle.mjs +3 -0
  21. package/scripts/cli/main.mjs +6 -1
  22. package/scripts/cli/model.mjs +45 -0
  23. package/scripts/cli/nightly.mjs +46 -16
  24. package/scripts/cli/owner-jobs.mjs +23 -4
  25. package/scripts/cli/owner-sessions.mjs +5 -5
  26. package/scripts/cli/rename.mjs +55 -17
  27. package/scripts/cli/run.mjs +3 -2
  28. package/scripts/cli/scaffold.mjs +11 -5
  29. package/scripts/cli/skill-resource-paths.mjs +140 -0
  30. package/scripts/cli/skill-resources.mjs +158 -0
  31. package/scripts/cli/skills.mjs +13 -53
  32. package/scripts/cli/start.mjs +21 -7
  33. package/scripts/cli/yaml.mjs +10 -0
  34. package/skills/hire/references/owner.md +6 -4
  35. package/templates/agents/claude-code/worker.md +1 -0
  36. package/templates/agents/codex/worker.toml +1 -0
  37. package/templates/operating-model/base.md +31 -11
  38. package/templates/operating-model/owner-loop-off.md +9 -0
  39. package/templates/operating-model/owner.md +3 -2
  40. package/templates/packs/owner/craft.md +2 -2
  41. package/templates/packs/owner/mandate.md +3 -0
  42. package/templates/packs/owner/owner/SKILL.md +31 -26
  43. package/templates/packs/owner/owner-feedback/SKILL.md +4 -3
  44. package/templates/packs/owner/owner-heartbeat/SKILL.md +5 -20
  45. package/templates/packs/owner/owner-nightly/SKILL.md +10 -17
  46. package/templates/packs/owner/owner-review/SKILL.md +5 -4
  47. package/templates/packs/owner/owner-work/SKILL.md +28 -19
  48. package/templates/packs/principal/academy-performance-review/SKILL.md +91 -0
  49. package/templates/packs/principal/academy-performance-review/references/operating-standard.md +59 -0
  50. package/templates/packs/principal/principal-brief/SKILL.md +23 -7
  51. package/templates/packs/principal/principal-nightly/SKILL.md +6 -7
  52. package/templates/skills/delivery/SKILL.md +18 -0
  53. package/templates/skills/delivery/references/build.md +15 -0
  54. package/templates/skills/delivery/references/experience.md +12 -0
  55. package/templates/skills/delivery/references/orchestration.md +15 -0
  56. package/templates/skills/delivery/references/plan.md +15 -0
  57. package/templates/skills/delivery/references/run.md +26 -0
  58. package/templates/skills/delivery/references/scope.md +15 -0
  59. package/templates/skills/delivery/references/ship.md +12 -0
  60. package/templates/skills/delivery/references/validate.md +16 -0
  61. package/templates/skills/explore-options/SKILL.md +66 -0
  62. package/templates/skills/knowledge-curation/SKILL.md +2 -3
  63. package/templates/skills/nightly-consolidation/SKILL.md +9 -4
  64. package/templates/skills/skill-method/SKILL.md +2 -2
  65. package/templates/skills/triage/SKILL.md +119 -0
  66. package/templates/skills/triage/references/curator.md +14 -0
  67. package/templates/skills/triage/references/rank.md +39 -0
  68. package/templates/skills/user-nightly/SKILL.md +18 -10
  69. package/templates/skills/work-graph/SKILL.md +6 -3
@@ -25,8 +25,10 @@ import { assertNotArchived } from './archived.mjs';
25
25
  import { claimDefaultName, claimName, isAgentNameFree } from './default-name.mjs';
26
26
  import { LogCorruptError, appendLifecycleEvent } from './eventlog.mjs';
27
27
  import { agentRecord } from './inspect.mjs';
28
+ import { creatorOf } from './leads.mjs';
29
+ import { isModelId } from './model.mjs';
30
+ import { DISPLAY_NAME_RULE, isDisplayName } from './yaml.mjs';
28
31
  import { nextSteps } from './next-steps.mjs';
29
- import { isStopped } from './agent-gate.mjs';
30
32
  import { deleteAcademyJobs } from './lifecycle.mjs';
31
33
  import {
32
34
  ensureNightlyPermissionMode,
@@ -63,25 +65,31 @@ export const INTRO_PENDING_MARKER = 'intro-pending.md';
63
65
  export function createAgent(
64
66
  name,
65
67
  json = false,
66
- { kind = DEFAULT_KIND, invalidOption, guided } = {},
68
+ { kind = DEFAULT_KIND, model, displayName, invalidOption, guided } = {},
67
69
  ) {
68
- assertCreateOptions(kind, invalidOption, json);
69
- const created = provisionAgent(name, json, kind);
70
+ assertCreateOptions(kind, invalidOption, json, { model, displayName });
71
+ const created = provisionAgent(name, json, kind, { model, displayName });
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
76
+ // Every refusal comes before the name is asked about, so a create Academy will
75
77
  // not perform writes nothing. `invalid_spec` is the published code for an
76
78
  // unknown option; there is no sixteenth code for it.
77
- function assertCreateOptions(kind, invalidOption, json) {
79
+ function assertCreateOptions(kind, invalidOption, json, { model, displayName }) {
78
80
  if (invalidOption !== undefined) {
79
81
  refuseCreate(
80
- `Unknown create option: ${invalidOption}. Use create <name> [--kind <kind>] [--no-start] [--json].`,
82
+ `Unknown create option: ${invalidOption}. Use create <name> [--kind <kind>] [--model <id>] [--display-name <text>] [--no-start] [--json].`,
81
83
  { option: invalidOption },
82
84
  json,
83
85
  );
84
86
  }
87
+ if (model !== undefined && !isModelId(model)) {
88
+ refuseCreate(`Model "${model}" is not a model id Academy can store.`, { model }, json);
89
+ }
90
+ if (displayName !== undefined && !isDisplayName(displayName)) {
91
+ refuseCreate(DISPLAY_NAME_RULE, { displayName }, json);
92
+ }
85
93
  if (Object.hasOwn(KIND_PROFILES, kind)) return;
86
94
  const kinds = Object.keys(KIND_PROFILES).join(', ');
87
95
  refuseCreate(
@@ -103,9 +111,9 @@ function refuseCreate(message, fields, json) {
103
111
  // events. A second scaffolder would be a second definition of what an Academy
104
112
  // agent is, and the two would disagree the first time one of them learned a
105
113
  // rule the other did not.
106
- export function provisionAgent(name, json = false, kind = DEFAULT_KIND) {
114
+ export function provisionAgent(name, json = false, kind = DEFAULT_KIND, yamlOptions = {}) {
107
115
  const claimed = name === undefined ? claimDefaultName(kind, json) : claimNamedDir(name, json);
108
- return provisionAgentDir(claimed.dir, claimed.name, json, kind);
116
+ return provisionAgentDir(claimed.dir, claimed.name, json, kind, yamlOptions);
109
117
  }
110
118
 
111
119
  // The mkdir is the one point where this request comes to own the directory. It
@@ -122,8 +130,8 @@ function claimNamedDir(name, json) {
122
130
  return { name, dir };
123
131
  }
124
132
 
125
- function provisionAgentDir(dir, name, json, kind) {
126
- scaffoldAgentDir(dir, name, json, kind);
133
+ function provisionAgentDir(dir, name, json, kind, yamlOptions) {
134
+ scaffoldAgentDir(dir, name, json, kind, yamlOptions);
127
135
  const jobs = registerAcademyJobs(dir, name);
128
136
  const failed = jobs.find((job) => !job.registered && !job.skipped);
129
137
  if (failed) {
@@ -170,11 +178,11 @@ function refuseTakenName(name, dir, json) {
170
178
  process.exit(1);
171
179
  }
172
180
 
173
- function scaffoldAgentDir(dir, name, json, kind) {
181
+ function scaffoldAgentDir(dir, name, json, kind, yamlOptions = {}) {
174
182
  try {
175
183
  const today = new Date().toISOString().slice(0, 10);
176
184
  // agent.yaml first: the kind it names selects the surfaces scaffolded next.
177
- writeAgentYaml(dir, name, today, kind);
185
+ writeAgentYaml(dir, name, today, kind, { ...yamlOptions, createdBy: creatorOf() });
178
186
  scaffoldBootFiles(dir, name, today);
179
187
  writeAgentClaudeMd(dir, name);
180
188
  writeSkillsScaffold(dir, name);
@@ -273,6 +281,9 @@ function reportGuidedCreated(name, nightlyTask, guided) {
273
281
  }
274
282
  }
275
283
 
284
+ // A 22:00 nightly missed while the Mac slept still fires on wake until ~08:00 (10 h).
285
+ const NIGHTLY_MISFIRE_GRACE_SEC = '36000';
286
+
276
287
  export function localTimezone() {
277
288
  return (
278
289
  process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'America/Los_Angeles'
@@ -291,7 +302,6 @@ function registerNightlyConsolidationTask(dir, name) {
291
302
  reason: 'skipped by ACADEMY_SKIP_NIGHTLY_TASK=1',
292
303
  };
293
304
  }
294
- if (isStopped(dir)) return { id, registered: false, skipped: true, reason: 'agent is stopped' };
295
305
  const timezone = localTimezone();
296
306
  let academyBin;
297
307
  let tldrAgentsBin;
@@ -313,6 +323,8 @@ function registerNightlyConsolidationTask(dir, name) {
313
323
  id,
314
324
  '--cron',
315
325
  NIGHTLY_JOB_CRON,
326
+ '--misfire-grace-sec',
327
+ NIGHTLY_MISFIRE_GRACE_SEC,
316
328
  '--timezone',
317
329
  timezone,
318
330
  '--condition-file-exists',
@@ -339,8 +351,8 @@ export function runSchedule(tldrAgentsBin, dir, id, args) {
339
351
 
340
352
  // Every Academy job an agent of its kind runs, nightly first. The skip flag
341
353
  // 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.
354
+ // not register does not go on to half a schedule. A stopped agent registers its
355
+ // nightly only (registerKindTasks).
344
356
  export function registerAcademyJobs(dir, name) {
345
357
  const nightly = registerNightlyConsolidationTask(dir, name);
346
358
  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
+ }
@@ -100,13 +100,13 @@ export function removeMarkerIfUnchanged(markerPath, revision) {
100
100
  }
101
101
  }
102
102
 
103
- const DAY_MS = 24 * 60 * 60 * 1000;
104
103
  const CLEAN_STATUSES = new Set(['pass', 'skipped_no_input']);
105
- const NOTE_STAMP_RE = /^- (\d{4}-\d\d-\d\d \d\d:\d\d)/gm;
104
+ const NIGHTLY_MIN_OBSERVATIONS = 10;
106
105
 
107
106
  // Whether a nightly has anything to consolidate: input newer than the last
108
- // clean record, notes over their cap, or a note stamp a week old. Any doubt
109
- // (no clean record, a kept marker, a read error) answers true, so a night is
107
+ // clean record or notes over their cap. Few new observations (under the
108
+ // minimum) answer 'below_threshold': they wait for a later night. Any doubt
109
+ // (no clean record, a kept marker, a read error) answers 'run', so a night is
110
110
  // never lost. A record without markerKept predates it, so it counts as kept.
111
111
  //
112
112
  // No input is lost, by construction. A writer appends and bumps the marker in
@@ -117,26 +117,43 @@ const NOTE_STAMP_RE = /^- (\d{4}-\d\d-\d\d \d\d:\d\d)/gm;
117
117
  // newer than the mark taken inside it. Against a skip: its mark comes before
118
118
  // these reads, so an append they miss is newer than the mark, and a bump
119
119
  // before its remover's check keeps the marker, which markerKept launches.
120
+ // A below-threshold skip keeps both the marker and the mark, so what it leaves
121
+ // unread is still newer than the mark and counts toward a later night.
120
122
  function nightlyHasWork(dir) {
121
123
  try {
122
124
  const last = readJsonFile(recordPath(dir), null);
123
125
  const since = Date.parse(last?.finishedAt);
124
- if (!CLEAN_STATUSES.has(last?.status) || last.markerKept !== false) return true;
125
- if (Number.isNaN(since)) return true;
126
126
  const notes = surfacesFor(dir).find((entry) => entry.name === 'notes');
127
- const text = readFileSync(notes.path, 'utf8');
128
- if (estimateTokens(text) > notes.cap || hasStaleStamp(text)) return true;
129
- return [notes.path, ...observationFiles(dir)].some((path) => statSync(path).mtimeMs > since);
127
+ if (
128
+ !CLEAN_STATUSES.has(last?.status) ||
129
+ last.markerKept !== false ||
130
+ Number.isNaN(since) ||
131
+ estimateTokens(readFileSync(notes.path, 'utf8')) > notes.cap
132
+ )
133
+ return { verdict: 'run' };
134
+ const count = newObservationCount(dir, since);
135
+ // A file touched after the mark is waiting input even when its entries carry old timestamps
136
+ // (the memory bridge copies them late), so the 'nothing' branch never drops their marker.
137
+ const waiting =
138
+ count > 0 || [notes.path, ...observationFiles(dir)].some((p) => statSync(p).mtimeMs > since);
139
+ let verdict = waiting ? 'below_threshold' : 'nothing';
140
+ if (count >= NIGHTLY_MIN_OBSERVATIONS) verdict = 'run';
141
+ return { verdict, count };
130
142
  } catch {
131
- return true;
143
+ return { verdict: 'run' };
132
144
  }
133
145
  }
134
146
 
135
- function hasStaleStamp(text) {
136
- const weekAgo = Date.now() - 7 * DAY_MS;
137
- return [...text.matchAll(NOTE_STAMP_RE)].some(
138
- ([, stamp]) => new Date(stamp.replace(' ', 'T')).getTime() < weekAgo,
139
- );
147
+ // shortcut: counts by entry timestamp, so an entry swept in late with an old timestamp is not counted toward the minimum (it waits as below_threshold and the next pass consumes it); upgrade to a consumed-entry cursor if skips run long
148
+ function newObservationCount(dir, since) {
149
+ let count = 0;
150
+ for (const file of observationFiles(dir)) {
151
+ for (const line of readFileSync(file, 'utf8').split('\n')) {
152
+ if (!line.trim()) continue;
153
+ if (Date.parse(JSON.parse(line).timestamp) > since) count += 1;
154
+ }
155
+ }
156
+ return count;
140
157
  }
141
158
 
142
159
  function observationFiles(dir) {
@@ -165,7 +182,20 @@ export function skipNightlyWithoutMarker(attempt) {
165
182
  export function skipNightlyWithoutWork(attempt, markerPath, revision) {
166
183
  // The record ends when the check began, so input that lands during it is newer.
167
184
  const checkedAt = new Date();
168
- if (nightlyHasWork(attempt.dir)) return false;
185
+ const { verdict, count } = nightlyHasWork(attempt.dir);
186
+ if (verdict === 'run') return false;
187
+ if (verdict === 'below_threshold') {
188
+ console.log(
189
+ `Skipping nightly for ${attempt.name}: ${count} new observations, below ${NIGHTLY_MIN_OBSERVATIONS}; carried over.`,
190
+ );
191
+ recordNightlyOutcome(attempt, {
192
+ status: 'skipped_no_input',
193
+ reason: 'below_threshold',
194
+ keepMark: true,
195
+ });
196
+ printHelmSkip('below_threshold', attempt.name);
197
+ return true;
198
+ }
169
199
  console.log(`Skipping nightly consolidation for ${attempt.name}: nothing new to consolidate.`);
170
200
  // Reconciled before the record, so the record says whether the marker stayed.
171
201
  const { markerKept } = removeMarkerIfUnchanged(markerPath, revision);