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