@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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +150 -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 +59 -8
- package/scripts/cli/args-owner.mjs +9 -5
- package/scripts/cli/args.mjs +44 -11
- package/scripts/cli/copilot/child-env.mjs +2 -0
- package/scripts/cli/core.mjs +8 -7
- package/scripts/cli/create-options.mjs +43 -0
- package/scripts/cli/create.mjs +21 -41
- 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 +12 -6
- 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/core.mjs
CHANGED
|
@@ -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
|
-
'
|
|
173
|
-
'
|
|
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 >
|
|
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
|
+
}
|
package/scripts/cli/create.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
343
|
-
//
|
|
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 {
|
|
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
|
+
}
|