@joenandez/academy 0.4.0-rc.1
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 +14 -0
- package/.claude-plugin/plugin.json +6 -0
- package/CHANGELOG.md +46 -0
- package/LICENSE +21 -0
- package/README.md +209 -0
- package/bin/academy +2 -0
- package/conformance/README.md +60 -0
- package/conformance/discovery.test.mjs +140 -0
- package/conformance/envelope.test.mjs +185 -0
- package/conformance/error-codes.test.mjs +125 -0
- package/conformance/harness.mjs +180 -0
- package/conformance/identity.test.mjs +125 -0
- package/docs/integration-guide.md +1026 -0
- package/hooks/hook_runtime.mjs +100 -0
- package/hooks/hooks.json +26 -0
- package/hooks/inject_surface.py +122 -0
- package/hooks/memory_bridge.mjs +120 -0
- package/hooks/memory_store.mjs +66 -0
- package/hooks/register_session.mjs +51 -0
- package/hooks/sync_memory.mjs +27 -0
- package/package.json +41 -0
- package/scripts/agent.mjs +3 -0
- package/scripts/cli/archive.mjs +161 -0
- package/scripts/cli/archived.mjs +82 -0
- package/scripts/cli/args.mjs +282 -0
- package/scripts/cli/codex.mjs +216 -0
- package/scripts/cli/core.mjs +389 -0
- package/scripts/cli/create.mjs +242 -0
- package/scripts/cli/doctor.mjs +203 -0
- package/scripts/cli/eventlog.mjs +129 -0
- package/scripts/cli/events.mjs +80 -0
- package/scripts/cli/hire-headless.mjs +229 -0
- package/scripts/cli/hire-spec.mjs +164 -0
- package/scripts/cli/hire.mjs +92 -0
- package/scripts/cli/inspect.mjs +286 -0
- package/scripts/cli/lifecycle.mjs +296 -0
- package/scripts/cli/main.mjs +102 -0
- package/scripts/cli/migrate.mjs +183 -0
- package/scripts/cli/notes.mjs +104 -0
- package/scripts/cli/rename.mjs +172 -0
- package/scripts/cli/run.mjs +227 -0
- package/scripts/cli/runtime.mjs +47 -0
- package/scripts/cli/scaffold.mjs +332 -0
- package/scripts/cli/sessions.mjs +98 -0
- package/scripts/cli/templates.mjs +104 -0
- package/scripts/cli/yaml.mjs +124 -0
- package/skills/hire/SKILL.md +669 -0
- package/templates/agents/claude-code/knowledge-curator.md +14 -0
- package/templates/agents/codex/knowledge-curator.toml +9 -0
- package/templates/skills/check-in/SKILL.md +122 -0
- package/templates/skills/knowledge-curation/SKILL.md +132 -0
- package/templates/skills/nightly-consolidation/SKILL.md +240 -0
- package/templates/skills/self-update/SKILL.md +121 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
export function readPayload() {
|
|
6
|
+
try {
|
|
7
|
+
const input = readFileSync(0, 'utf8').trim();
|
|
8
|
+
return input ? JSON.parse(input) : null;
|
|
9
|
+
} catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function sameRealPath(left, right) {
|
|
15
|
+
try {
|
|
16
|
+
return realpathSync(left) === realpathSync(right);
|
|
17
|
+
} catch {
|
|
18
|
+
return left === right;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function runtimeEnvFor(payload, env, identityKey) {
|
|
23
|
+
let resolvedEnv = env;
|
|
24
|
+
if (!env[identityKey] && env.ACADEMY_RUNTIME_CONTEXT) {
|
|
25
|
+
try {
|
|
26
|
+
const context = JSON.parse(readFileSync(env.ACADEMY_RUNTIME_CONTEXT, 'utf8'));
|
|
27
|
+
const expired = context.expiresAt && Date.parse(context.expiresAt) < Date.now();
|
|
28
|
+
const merged = expired ? env : { ...env, ...(context.env || {}) };
|
|
29
|
+
const projectMismatch =
|
|
30
|
+
payload?.cwd &&
|
|
31
|
+
merged.ACADEMY_PROJECT_DIR &&
|
|
32
|
+
!sameRealPath(payload.cwd, merged.ACADEMY_PROJECT_DIR);
|
|
33
|
+
if (!projectMismatch) resolvedEnv = merged;
|
|
34
|
+
} catch {
|
|
35
|
+
// Use the provided environment when the runtime context is not valid.
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return resolvedEnv;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// A session record names the agent directory, not just the agent name: two
|
|
42
|
+
// roots can hold an agent of the same name, and only the resolved directory
|
|
43
|
+
// says which one a session belongs to. An unresolvable path still answers
|
|
44
|
+
// absolutely, because a record Academy cannot attribute is worse than one
|
|
45
|
+
// naming a directory that has since moved.
|
|
46
|
+
export function resolvedAgentDir(dir) {
|
|
47
|
+
if (!dir) return null;
|
|
48
|
+
const absolute = resolve(dir);
|
|
49
|
+
try {
|
|
50
|
+
return realpathSync(absolute);
|
|
51
|
+
} catch {
|
|
52
|
+
return absolute;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function sleep(ms) {
|
|
57
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function withFileLock(lockDir, fn) {
|
|
61
|
+
const started = Date.now();
|
|
62
|
+
while (true) {
|
|
63
|
+
try {
|
|
64
|
+
mkdirSync(lockDir, { recursive: false });
|
|
65
|
+
break;
|
|
66
|
+
} catch {
|
|
67
|
+
if (Date.now() - started > 5000) return fn();
|
|
68
|
+
sleep(25);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
return fn();
|
|
74
|
+
} finally {
|
|
75
|
+
rmSync(lockDir, { recursive: true, force: true });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function jsonlHasValue(path, field, value) {
|
|
80
|
+
if (!existsSync(path)) return false;
|
|
81
|
+
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
|
82
|
+
if (!line.trim()) continue;
|
|
83
|
+
try {
|
|
84
|
+
if (JSON.parse(line)[field] === value) return true;
|
|
85
|
+
} catch {
|
|
86
|
+
// Ignore malformed JSONL rows.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function isMainModule(moduleUrl) {
|
|
93
|
+
if (!process.argv[1]) return false;
|
|
94
|
+
const modulePath = fileURLToPath(moduleUrl);
|
|
95
|
+
try {
|
|
96
|
+
return realpathSync(modulePath) === realpathSync(process.argv[1]);
|
|
97
|
+
} catch {
|
|
98
|
+
return modulePath === resolve(process.argv[1]);
|
|
99
|
+
}
|
|
100
|
+
}
|
package/hooks/hooks.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"SessionStart": [
|
|
4
|
+
{
|
|
5
|
+
"matcher": "*",
|
|
6
|
+
"hooks": [
|
|
7
|
+
{
|
|
8
|
+
"type": "command",
|
|
9
|
+
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/register_session.mjs"
|
|
10
|
+
}
|
|
11
|
+
]
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"Stop": [
|
|
15
|
+
{
|
|
16
|
+
"matcher": "*",
|
|
17
|
+
"hooks": [
|
|
18
|
+
{
|
|
19
|
+
"type": "command",
|
|
20
|
+
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/sync_memory.mjs"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""SessionStart hook — emit one boot surface per invocation.
|
|
3
|
+
|
|
4
|
+
Usage: inject_surface.py <surface>
|
|
5
|
+
|
|
6
|
+
Surfaces: identity | role | knowledge | goals | priorities | threads | notes | dailys
|
|
7
|
+
|
|
8
|
+
Each surface lives at ~/.academy/agents/<name>/<surface>.md and is injected
|
|
9
|
+
into the session as additionalContext via stdout JSON.
|
|
10
|
+
|
|
11
|
+
Agent dir resolution (in priority order):
|
|
12
|
+
1. ACADEMY_AGENT_DIR env var
|
|
13
|
+
2. CWD if it contains agent.yaml
|
|
14
|
+
3. CWD walk-up looking for agent.yaml
|
|
15
|
+
|
|
16
|
+
Per v3 §10 Phase 0: "8 simple hooks, one per surface" — no manifest, no
|
|
17
|
+
chunking, no inject_section heuristics. The 8 boot files target ~5–6k tokens
|
|
18
|
+
combined and stay well under the per-hook ~10k-char ceiling.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import sys
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
SURFACES = (
|
|
29
|
+
"identity",
|
|
30
|
+
"role",
|
|
31
|
+
"knowledge",
|
|
32
|
+
"goals",
|
|
33
|
+
"priorities",
|
|
34
|
+
"threads",
|
|
35
|
+
"notes",
|
|
36
|
+
"dailys",
|
|
37
|
+
)
|
|
38
|
+
USAGE_ERROR = 2
|
|
39
|
+
EXPECTED_ARG_COUNT = 2
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def resolve_agent_dir() -> Path | None:
|
|
43
|
+
"""Resolve the agent directory.
|
|
44
|
+
|
|
45
|
+
Priority:
|
|
46
|
+
1. ACADEMY_AGENT_DIR env var (set by `academy run`)
|
|
47
|
+
2. Walk up from CWD looking for agent.yaml
|
|
48
|
+
"""
|
|
49
|
+
env_dir = os.environ.get("ACADEMY_AGENT_DIR")
|
|
50
|
+
if env_dir:
|
|
51
|
+
p = Path(env_dir)
|
|
52
|
+
if p.is_dir():
|
|
53
|
+
return p
|
|
54
|
+
|
|
55
|
+
cwd = Path.cwd()
|
|
56
|
+
for d in (cwd, *cwd.parents):
|
|
57
|
+
if (d / "agent.yaml").is_file():
|
|
58
|
+
return d
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def build_context(surface: str, agent_dir: Path) -> str:
|
|
63
|
+
"""Read <surface>.md and return its content as-is.
|
|
64
|
+
|
|
65
|
+
The file is self-describing (each starts with its own H1). The hook's job
|
|
66
|
+
is to deliver the file into context — not to add framing. Empty/missing
|
|
67
|
+
files emit a one-line placeholder so the agent knows the surface exists
|
|
68
|
+
but is empty.
|
|
69
|
+
"""
|
|
70
|
+
surface_file = agent_dir / f"{surface}.md"
|
|
71
|
+
if not surface_file.is_file():
|
|
72
|
+
return f"# {surface.title()}\n\n_(No {surface}.md present yet.)_"
|
|
73
|
+
body = surface_file.read_text(encoding="utf-8").rstrip()
|
|
74
|
+
if not body:
|
|
75
|
+
return f"# {surface.title()}\n\n_({surface}.md is empty.)_"
|
|
76
|
+
return body
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def emit(context: str) -> None:
|
|
80
|
+
"""Emit Claude Code SessionStart hook JSON output."""
|
|
81
|
+
payload = {
|
|
82
|
+
"hookSpecificOutput": {
|
|
83
|
+
"hookEventName": "SessionStart",
|
|
84
|
+
"additionalContext": context,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
sys.stdout.write(json.dumps(payload))
|
|
88
|
+
sys.stdout.flush()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def main() -> int:
|
|
92
|
+
if len(sys.argv) != EXPECTED_ARG_COUNT:
|
|
93
|
+
print(f"usage: inject_surface.py <{'|'.join(SURFACES)}>", file=sys.stderr)
|
|
94
|
+
return USAGE_ERROR
|
|
95
|
+
|
|
96
|
+
surface = sys.argv[1]
|
|
97
|
+
if surface not in SURFACES:
|
|
98
|
+
print(
|
|
99
|
+
f"unknown surface '{surface}'. valid: {', '.join(SURFACES)}",
|
|
100
|
+
file=sys.stderr,
|
|
101
|
+
)
|
|
102
|
+
return USAGE_ERROR
|
|
103
|
+
|
|
104
|
+
agent_dir = resolve_agent_dir()
|
|
105
|
+
if agent_dir is None:
|
|
106
|
+
# Not running inside an agent — emit nothing, exit clean.
|
|
107
|
+
# This keeps the hook harmless when the user invokes Claude Code
|
|
108
|
+
# in a non-agent dir while the plugin is still installed.
|
|
109
|
+
return 0
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
context = build_context(surface, agent_dir)
|
|
113
|
+
except Exception as exc: # pragma: no cover — defensive
|
|
114
|
+
print(f"inject_surface[{surface}] error: {exc}", file=sys.stderr)
|
|
115
|
+
return 0 # never block session start
|
|
116
|
+
|
|
117
|
+
emit(context)
|
|
118
|
+
return 0
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
if __name__ == "__main__":
|
|
122
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Academy names no specific client except in this module. The memory sync
|
|
7
|
+
* bridge stays client-specific by explicit decision, so every client-named
|
|
8
|
+
* identifier Academy needs — env keys, tool permission, skill guidance, and the
|
|
9
|
+
* observation copy itself — lives here. Client-neutral layers import these
|
|
10
|
+
* declarations and inject them only behind ACADEMY_MEMORY_BRIDGE.
|
|
11
|
+
*/
|
|
12
|
+
export const MEMORY_BRIDGE_ENV = {
|
|
13
|
+
enabled: 'GROVE_MEMORY_ENABLED',
|
|
14
|
+
project: 'GROVE_PROJECT_NAME',
|
|
15
|
+
workspace: 'GROVE_WORKSPACE_NAME',
|
|
16
|
+
home: 'SUBSPACE_HOME',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** Env keys a sandboxed runtime must forward for the bridge to work. */
|
|
20
|
+
export const MEMORY_BRIDGE_ENV_KEYS = Object.values(MEMORY_BRIDGE_ENV);
|
|
21
|
+
|
|
22
|
+
/** Tool permission an agent needs to query the bridge directly. */
|
|
23
|
+
export const MEMORY_BRIDGE_PERMISSIONS = ['Bash(subspace-memory:*)'];
|
|
24
|
+
|
|
25
|
+
/** Optional nightly guidance — the only client-named prose Academy ships. */
|
|
26
|
+
export const MEMORY_BRIDGE_SKILL_GUIDANCE = `### Optional — Subspace memory bridge
|
|
27
|
+
|
|
28
|
+
Academy names no specific client. This bridge is the one exception, and it is
|
|
29
|
+
optional. Skip this section when \`subspace-memory\` is not installed.
|
|
30
|
+
|
|
31
|
+
- Run \`subspace-memory status\` if the archive is thin or appears inconsistent.
|
|
32
|
+
- Run \`subspace-memory timeline --days 2\` if more workspace context is needed.
|
|
33
|
+
|
|
34
|
+
Subspace memory may enrich or corroborate an eligible run. It can never
|
|
35
|
+
establish eligibility.`;
|
|
36
|
+
|
|
37
|
+
function formatDate(date) {
|
|
38
|
+
return date.toISOString().slice(0, 10);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function syncDates() {
|
|
42
|
+
const today = new Date();
|
|
43
|
+
const yesterday = new Date(today);
|
|
44
|
+
yesterday.setDate(today.getDate() - 1);
|
|
45
|
+
return [formatDate(today), formatDate(yesterday)];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function memoryKey(entry) {
|
|
49
|
+
return `${entry.sessionId || ''}|${entry.timestamp || ''}|${entry.turnNumber || ''}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readExistingKeys(path) {
|
|
53
|
+
const keys = new Set();
|
|
54
|
+
if (!existsSync(path)) return keys;
|
|
55
|
+
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
|
56
|
+
const trimmed = line.trim();
|
|
57
|
+
if (!trimmed) continue;
|
|
58
|
+
try {
|
|
59
|
+
keys.add(memoryKey(JSON.parse(trimmed)));
|
|
60
|
+
} catch {
|
|
61
|
+
// Ignore malformed archive lines.
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return keys;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function subspaceObservationsDir(env) {
|
|
68
|
+
const project = env[MEMORY_BRIDGE_ENV.project];
|
|
69
|
+
const workspace = env[MEMORY_BRIDGE_ENV.workspace];
|
|
70
|
+
if (!project || !workspace) return null;
|
|
71
|
+
const roots = [env[MEMORY_BRIDGE_ENV.home], join(env.HOME || homedir(), '.subspace')].filter(
|
|
72
|
+
Boolean,
|
|
73
|
+
);
|
|
74
|
+
for (const root of roots) {
|
|
75
|
+
const dir = join(root, project, workspace, 'memory', 'observations');
|
|
76
|
+
if (existsSync(dir)) return dir;
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function memorySyncEnabled(env) {
|
|
82
|
+
const enabled = env[MEMORY_BRIDGE_ENV.enabled];
|
|
83
|
+
return enabled === '1' || enabled === 'true';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function syncObservationFile(subspaceFile, agentFile, sessionId) {
|
|
87
|
+
const existingKeys = readExistingKeys(agentFile);
|
|
88
|
+
let synced = 0;
|
|
89
|
+
for (const line of readFileSync(subspaceFile, 'utf8').split('\n')) {
|
|
90
|
+
const trimmed = line.trim();
|
|
91
|
+
if (!trimmed) continue;
|
|
92
|
+
try {
|
|
93
|
+
const entry = JSON.parse(trimmed);
|
|
94
|
+
const key = memoryKey(entry);
|
|
95
|
+
if (entry.sessionId !== sessionId || existingKeys.has(key)) continue;
|
|
96
|
+
appendFileSync(agentFile, `${trimmed}\n`);
|
|
97
|
+
existingKeys.add(key);
|
|
98
|
+
synced++;
|
|
99
|
+
} catch {
|
|
100
|
+
// Ignore malformed Subspace observation lines.
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return synced;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Copy the client's observations for this session into the agent's own archive.
|
|
108
|
+
* The client-neutral hook calls this and learns only a count.
|
|
109
|
+
*/
|
|
110
|
+
export function syncBridgeObservations(env, observationsDir, sessionId) {
|
|
111
|
+
const subspaceDir = subspaceObservationsDir(env);
|
|
112
|
+
if (!subspaceDir || !memorySyncEnabled(env)) return 0;
|
|
113
|
+
let synced = 0;
|
|
114
|
+
for (const date of syncDates()) {
|
|
115
|
+
const subspaceFile = join(subspaceDir, `${date}.jsonl`);
|
|
116
|
+
if (!existsSync(subspaceFile)) continue;
|
|
117
|
+
synced += syncObservationFile(subspaceFile, join(observationsDir, `${date}.jsonl`), sessionId);
|
|
118
|
+
}
|
|
119
|
+
return synced;
|
|
120
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import {
|
|
2
|
+
appendFileSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
renameSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from 'node:fs';
|
|
9
|
+
import { join, resolve } from 'node:path';
|
|
10
|
+
import { jsonlHasValue, resolvedAgentDir, withFileLock } from './hook_runtime.mjs';
|
|
11
|
+
|
|
12
|
+
// Academy's own per-agent memory state. Client-neutral: nothing here knows
|
|
13
|
+
// which client, if any, feeds the observation archive.
|
|
14
|
+
|
|
15
|
+
export function pendingMarkerPath(agentDir) {
|
|
16
|
+
return join(resolve(agentDir), 'memory', 'pending-consolidation.json');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function markPendingConsolidation(memoryDir) {
|
|
20
|
+
const markerPath = pendingMarkerPath(resolve(memoryDir, '..'));
|
|
21
|
+
withFileLock(join(memoryDir, 'pending-consolidation.lock'), () => {
|
|
22
|
+
let revision = 0;
|
|
23
|
+
if (existsSync(markerPath)) {
|
|
24
|
+
try {
|
|
25
|
+
revision = Number(JSON.parse(readFileSync(markerPath, 'utf8')).revision) || 0;
|
|
26
|
+
} catch {
|
|
27
|
+
// Replace malformed private state with the next valid revision.
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const tempPath = join(memoryDir, `.pending-consolidation.${process.pid}.${Date.now()}.tmp`);
|
|
31
|
+
writeFileSync(
|
|
32
|
+
tempPath,
|
|
33
|
+
JSON.stringify(
|
|
34
|
+
{
|
|
35
|
+
revision: revision + 1,
|
|
36
|
+
updatedAt: new Date().toISOString(),
|
|
37
|
+
},
|
|
38
|
+
null,
|
|
39
|
+
2,
|
|
40
|
+
) + '\n',
|
|
41
|
+
);
|
|
42
|
+
renameSync(tempPath, markerPath);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function recordSession(memoryDir, payload, env) {
|
|
47
|
+
const sessionId = payload.session_id;
|
|
48
|
+
if (!sessionId) return;
|
|
49
|
+
const sessionsPath = join(memoryDir, 'sessions.jsonl');
|
|
50
|
+
mkdirSync(memoryDir, { recursive: true });
|
|
51
|
+
withFileLock(join(memoryDir, 'sessions.lock'), () => {
|
|
52
|
+
if (jsonlHasValue(sessionsPath, 'sessionId', sessionId)) return;
|
|
53
|
+
appendFileSync(
|
|
54
|
+
sessionsPath,
|
|
55
|
+
JSON.stringify({
|
|
56
|
+
sessionId,
|
|
57
|
+
agentName: env.ACADEMY_AGENT_NAME || null,
|
|
58
|
+
agentDir: resolvedAgentDir(join(memoryDir, '..')),
|
|
59
|
+
timestamp: new Date().toISOString(),
|
|
60
|
+
cwd: payload.cwd || null,
|
|
61
|
+
projectDir: env.ACADEMY_PROJECT_DIR || null,
|
|
62
|
+
source: 'stop',
|
|
63
|
+
}) + '\n',
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import {
|
|
6
|
+
isMainModule,
|
|
7
|
+
jsonlHasValue,
|
|
8
|
+
readPayload,
|
|
9
|
+
resolvedAgentDir,
|
|
10
|
+
runtimeEnvFor,
|
|
11
|
+
withFileLock,
|
|
12
|
+
} from './hook_runtime.mjs';
|
|
13
|
+
|
|
14
|
+
function appendSession(sessionsPath, session) {
|
|
15
|
+
if (jsonlHasValue(sessionsPath, 'sessionId', session.sessionId)) return true;
|
|
16
|
+
appendFileSync(sessionsPath, `${JSON.stringify(session)}\n`);
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function registerAcademySession(payload, env = process.env) {
|
|
21
|
+
env = runtimeEnvFor(payload, env, 'ACADEMY_AGENT_NAME');
|
|
22
|
+
const sessionId = payload?.session_id;
|
|
23
|
+
const agentName = env.ACADEMY_AGENT_NAME;
|
|
24
|
+
const cwd = payload?.cwd;
|
|
25
|
+
if (!sessionId || !agentName || !cwd) return false;
|
|
26
|
+
// Absent when the session did not come from `academy run`. Such a record is
|
|
27
|
+
// unattributable rather than corrupt, so it is written without the field and
|
|
28
|
+
// counted by `doctor`.
|
|
29
|
+
const agentDir = resolvedAgentDir(env.ACADEMY_AGENT_DIR);
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const academyDir = join(env.HOME || homedir(), '.academy');
|
|
33
|
+
const sessionsPath = join(academyDir, 'sessions.jsonl');
|
|
34
|
+
mkdirSync(academyDir, { recursive: true });
|
|
35
|
+
return withFileLock(join(academyDir, 'sessions.lock'), () =>
|
|
36
|
+
appendSession(sessionsPath, {
|
|
37
|
+
sessionId,
|
|
38
|
+
agentName,
|
|
39
|
+
...(agentDir ? { agentDir } : {}),
|
|
40
|
+
cwd,
|
|
41
|
+
startedAt: new Date().toISOString(),
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (isMainModule(import.meta.url)) {
|
|
50
|
+
registerAcademySession(readPayload());
|
|
51
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdirSync } from 'node:fs';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { isMainModule, readPayload, runtimeEnvFor } from './hook_runtime.mjs';
|
|
5
|
+
import { syncBridgeObservations } from './memory_bridge.mjs';
|
|
6
|
+
import { markPendingConsolidation, recordSession } from './memory_store.mjs';
|
|
7
|
+
|
|
8
|
+
export function syncAcademyMemory(payload, env = process.env) {
|
|
9
|
+
env = runtimeEnvFor(payload, env, 'ACADEMY_AGENT_DIR');
|
|
10
|
+
const agentDir = env.ACADEMY_AGENT_DIR;
|
|
11
|
+
const sessionId = payload?.session_id;
|
|
12
|
+
if (!agentDir || !sessionId) return { synced: 0 };
|
|
13
|
+
|
|
14
|
+
const memoryDir = join(resolve(agentDir), 'memory');
|
|
15
|
+
const observationsDir = join(memoryDir, 'observations');
|
|
16
|
+
mkdirSync(observationsDir, { recursive: true });
|
|
17
|
+
recordSession(memoryDir, payload, env);
|
|
18
|
+
// Eligibility tracks Academy session activity, not the bridge copy, so an
|
|
19
|
+
// agent still consolidates when the memory bridge is unavailable.
|
|
20
|
+
if (env.ACADEMY_NIGHTLY_RUN !== '1') markPendingConsolidation(memoryDir);
|
|
21
|
+
|
|
22
|
+
return { synced: syncBridgeObservations(env, observationsDir, sessionId) };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (isMainModule(import.meta.url)) {
|
|
26
|
+
syncAcademyMemory(readPayload());
|
|
27
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@joenandez/academy",
|
|
3
|
+
"version": "0.4.0-rc.1",
|
|
4
|
+
"description": "Academy v3 — portable AI agents with 8 boot surfaces and a skills primitive.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"academy": "./bin/academy"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/joenandez/academy.git"
|
|
12
|
+
},
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"bin/",
|
|
18
|
+
"scripts/agent.mjs",
|
|
19
|
+
"scripts/cli/",
|
|
20
|
+
"skills/",
|
|
21
|
+
"templates/",
|
|
22
|
+
"hooks/",
|
|
23
|
+
".claude-plugin/",
|
|
24
|
+
"conformance/",
|
|
25
|
+
"docs/integration-guide.md",
|
|
26
|
+
"CHANGELOG.md"
|
|
27
|
+
],
|
|
28
|
+
"scripts": {
|
|
29
|
+
"test": "node --test tests/*.test.mjs",
|
|
30
|
+
"quality:setup": "node quality/run.mjs setup",
|
|
31
|
+
"quality:commit": "node quality/run.mjs commit",
|
|
32
|
+
"quality:push": "node quality/run.mjs push",
|
|
33
|
+
"quality:full": "node quality/run.mjs full"
|
|
34
|
+
},
|
|
35
|
+
"keywords": ["academy", "ai-agent", "claude-code", "portable-agent"],
|
|
36
|
+
"author": "Joe Fernandez",
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"knip": "5.70.2"
|
|
40
|
+
}
|
|
41
|
+
}
|