@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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +140 -0
- package/README.md +7 -4
- package/package.json +2 -2
- package/scripts/cli/agent-gate.mjs +20 -10
- package/scripts/cli/agent-row.mjs +24 -0
- package/scripts/cli/agent-settings.mjs +104 -0
- package/scripts/cli/args-kinds.mjs +43 -5
- package/scripts/cli/args-owner.mjs +9 -5
- package/scripts/cli/args.mjs +42 -11
- package/scripts/cli/copilot/child-env.mjs +2 -0
- package/scripts/cli/core.mjs +8 -7
- package/scripts/cli/create.mjs +28 -16
- package/scripts/cli/curator-feed-observations.mjs +38 -4
- package/scripts/cli/curator-feed-sources.mjs +9 -10
- package/scripts/cli/doctor.mjs +3 -0
- package/scripts/cli/heartbeat.mjs +9 -39
- package/scripts/cli/inspect.mjs +6 -1
- package/scripts/cli/leads.mjs +94 -0
- package/scripts/cli/lifecycle.mjs +3 -0
- package/scripts/cli/main.mjs +6 -1
- package/scripts/cli/model.mjs +45 -0
- package/scripts/cli/nightly.mjs +46 -16
- package/scripts/cli/owner-jobs.mjs +23 -4
- package/scripts/cli/owner-sessions.mjs +5 -5
- package/scripts/cli/rename.mjs +55 -17
- package/scripts/cli/run.mjs +3 -2
- package/scripts/cli/scaffold.mjs +11 -5
- package/scripts/cli/skill-resource-paths.mjs +140 -0
- package/scripts/cli/skill-resources.mjs +158 -0
- package/scripts/cli/skills.mjs +13 -53
- package/scripts/cli/start.mjs +21 -7
- package/scripts/cli/yaml.mjs +10 -0
- package/skills/hire/references/owner.md +6 -4
- package/templates/agents/claude-code/worker.md +1 -0
- package/templates/agents/codex/worker.toml +1 -0
- package/templates/operating-model/base.md +31 -11
- package/templates/operating-model/owner-loop-off.md +9 -0
- package/templates/operating-model/owner.md +3 -2
- package/templates/packs/owner/craft.md +2 -2
- package/templates/packs/owner/mandate.md +3 -0
- package/templates/packs/owner/owner/SKILL.md +31 -26
- package/templates/packs/owner/owner-feedback/SKILL.md +4 -3
- package/templates/packs/owner/owner-heartbeat/SKILL.md +5 -20
- package/templates/packs/owner/owner-nightly/SKILL.md +10 -17
- package/templates/packs/owner/owner-review/SKILL.md +5 -4
- package/templates/packs/owner/owner-work/SKILL.md +28 -19
- package/templates/packs/principal/academy-performance-review/SKILL.md +91 -0
- package/templates/packs/principal/academy-performance-review/references/operating-standard.md +59 -0
- package/templates/packs/principal/principal-brief/SKILL.md +23 -7
- package/templates/packs/principal/principal-nightly/SKILL.md +6 -7
- package/templates/skills/delivery/SKILL.md +18 -0
- package/templates/skills/delivery/references/build.md +15 -0
- package/templates/skills/delivery/references/experience.md +12 -0
- package/templates/skills/delivery/references/orchestration.md +15 -0
- package/templates/skills/delivery/references/plan.md +15 -0
- package/templates/skills/delivery/references/run.md +26 -0
- package/templates/skills/delivery/references/scope.md +15 -0
- package/templates/skills/delivery/references/ship.md +12 -0
- package/templates/skills/delivery/references/validate.md +16 -0
- package/templates/skills/explore-options/SKILL.md +66 -0
- package/templates/skills/knowledge-curation/SKILL.md +2 -3
- package/templates/skills/nightly-consolidation/SKILL.md +9 -4
- package/templates/skills/skill-method/SKILL.md +2 -2
- package/templates/skills/triage/SKILL.md +119 -0
- package/templates/skills/triage/references/curator.md +14 -0
- package/templates/skills/triage/references/rank.md +39 -0
- package/templates/skills/user-nightly/SKILL.md +18 -10
- package/templates/skills/work-graph/SKILL.md +6 -3
package/scripts/cli/create.mjs
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
343
|
-
//
|
|
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 {
|
|
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
|
-
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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) => {
|
package/scripts/cli/doctor.mjs
CHANGED
|
@@ -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
|
|
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.
|
|
89
|
-
*
|
|
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)
|
|
96
|
+
if (item.id && !blocked.has(item.id) && !holderRunning(item, now)) items.push(item.id);
|
|
101
97
|
}
|
|
102
|
-
return { items,
|
|
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
|
-
|
|
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.
|
|
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
|
}
|
package/scripts/cli/inspect.mjs
CHANGED
|
@@ -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
|
|
package/scripts/cli/main.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/scripts/cli/nightly.mjs
CHANGED
|
@@ -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
|
|
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
|
|
109
|
-
//
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
143
|
+
return { verdict: 'run' };
|
|
132
144
|
}
|
|
133
145
|
}
|
|
134
146
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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);
|