@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.
Files changed (121) hide show
  1. package/.claude-plugin/marketplace.json +5 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +390 -0
  4. package/README.md +67 -122
  5. package/hooks/backstory/.claude-plugin/plugin.json +11 -0
  6. package/hooks/backstory/SOURCE +2 -0
  7. package/hooks/backstory/bin/backstory +9 -0
  8. package/hooks/backstory/hooks/hooks.json +45 -0
  9. package/hooks/backstory/scripts/knowledge.mjs +87 -0
  10. package/hooks/backstory/scripts/memory.mjs +685 -0
  11. package/hooks/backstory/scripts/tool-tick.mjs +1 -0
  12. package/hooks/backstory/skills/capture/SKILL.md +71 -0
  13. package/hooks/backstory/skills/capture/references/knowledge-capture-input.json +16 -0
  14. package/hooks/backstory/skills/capture/references/tagging-policy.md +29 -0
  15. package/hooks/backstory/skills/memory/SKILL.md +70 -0
  16. package/hooks/backstory/skills/setup/SKILL.md +85 -0
  17. package/hooks/context_metrics.mjs +67 -0
  18. package/hooks/hook_runtime.mjs +43 -2
  19. package/hooks/memory_bridge.mjs +286 -52
  20. package/hooks/memory_store.mjs +78 -26
  21. package/hooks/register_session.mjs +76 -14
  22. package/hooks/roque/.claude-plugin/plugin.json +6 -0
  23. package/hooks/roque/LICENSE +21 -0
  24. package/hooks/roque/SOURCE +1 -0
  25. package/hooks/roque/hooks.json +38 -0
  26. package/hooks/roque/package.json +3 -0
  27. package/hooks/roque/roque.js +1690 -0
  28. package/hooks/sync_memory.mjs +11 -7
  29. package/package.json +7 -3
  30. package/scripts/cli/agent-gate.mjs +70 -0
  31. package/scripts/cli/agent-id.mjs +37 -0
  32. package/scripts/cli/archive.mjs +39 -38
  33. package/scripts/cli/archived.mjs +4 -2
  34. package/scripts/cli/args-kinds.mjs +33 -0
  35. package/scripts/cli/args-notes.mjs +42 -0
  36. package/scripts/cli/args-owner.mjs +37 -0
  37. package/scripts/cli/args-run.mjs +38 -0
  38. package/scripts/cli/args.mjs +47 -104
  39. package/scripts/cli/beads.mjs +57 -0
  40. package/scripts/cli/codex.mjs +66 -12
  41. package/scripts/cli/core.mjs +117 -41
  42. package/scripts/cli/create.mjs +224 -76
  43. package/scripts/cli/curator-feed-codex.mjs +81 -0
  44. package/scripts/cli/curator-feed-observations.mjs +97 -0
  45. package/scripts/cli/curator-feed-scope.mjs +124 -0
  46. package/scripts/cli/curator-feed-sources.mjs +172 -0
  47. package/scripts/cli/curator-feed.mjs +318 -0
  48. package/scripts/cli/default-name.mjs +73 -0
  49. package/scripts/cli/doctor.mjs +67 -2
  50. package/scripts/cli/eventlog.mjs +6 -2
  51. package/scripts/cli/exit-code.mjs +9 -0
  52. package/scripts/cli/first-run.mjs +115 -0
  53. package/scripts/cli/heartbeat.mjs +135 -0
  54. package/scripts/cli/helm-skip.mjs +20 -0
  55. package/scripts/cli/hire-headless.mjs +37 -22
  56. package/scripts/cli/hire.mjs +8 -2
  57. package/scripts/cli/inspect.mjs +36 -18
  58. package/scripts/cli/lifecycle.mjs +95 -49
  59. package/scripts/cli/main.mjs +32 -2
  60. package/scripts/cli/memory-env.mjs +124 -0
  61. package/scripts/cli/migrate.mjs +13 -7
  62. package/scripts/cli/next-steps.mjs +13 -0
  63. package/scripts/cli/nightly-launch.mjs +118 -0
  64. package/scripts/cli/nightly.mjs +228 -0
  65. package/scripts/cli/notes.mjs +58 -8
  66. package/scripts/cli/owner-config.mjs +181 -0
  67. package/scripts/cli/owner-jobs.mjs +201 -0
  68. package/scripts/cli/prompt-append.mjs +135 -0
  69. package/scripts/cli/rename.mjs +90 -109
  70. package/scripts/cli/run-context.mjs +50 -0
  71. package/scripts/cli/run.mjs +236 -79
  72. package/scripts/cli/scaffold.mjs +119 -126
  73. package/scripts/cli/schedule-errors.mjs +70 -0
  74. package/scripts/cli/sessions.mjs +10 -3
  75. package/scripts/cli/skills-check.mjs +147 -0
  76. package/scripts/cli/skills.mjs +170 -0
  77. package/scripts/cli/start.mjs +201 -0
  78. package/scripts/cli/templates.mjs +119 -45
  79. package/scripts/cli/tldr.mjs +184 -0
  80. package/scripts/cli/toml-string.mjs +76 -0
  81. package/scripts/cli/user-work-view.mjs +48 -0
  82. package/scripts/cli/writable-dirs.mjs +23 -0
  83. package/scripts/cli/yaml.mjs +1 -1
  84. package/skills/hire/SKILL.md +112 -100
  85. package/skills/hire/references/owner.md +68 -0
  86. package/templates/agents/claude-code/knowledge-curator.md +5 -2
  87. package/templates/agents/codex/knowledge-curator.toml +2 -1
  88. package/templates/operating-model/base.md +78 -0
  89. package/templates/operating-model/owner.md +11 -0
  90. package/templates/operating-model/principal.md +9 -0
  91. package/templates/operating-model/specialist.md +9 -0
  92. package/templates/packs/owner/craft.md +32 -0
  93. package/templates/packs/owner/identity.md +13 -0
  94. package/templates/packs/owner/mandate.md +26 -0
  95. package/templates/packs/owner/owner/SKILL.md +118 -0
  96. package/templates/packs/owner/owner-feedback/SKILL.md +49 -0
  97. package/templates/packs/owner/owner-heartbeat/SKILL.md +35 -0
  98. package/templates/packs/owner/owner-nightly/SKILL.md +114 -0
  99. package/templates/packs/owner/owner-review/SKILL.md +70 -0
  100. package/templates/packs/owner/owner-work/SKILL.md +53 -0
  101. package/templates/packs/principal/craft.md +27 -0
  102. package/templates/packs/principal/identity.md +13 -0
  103. package/templates/packs/principal/mandate.md +23 -0
  104. package/templates/packs/principal/principal-brief/SKILL.md +44 -0
  105. package/templates/packs/principal/principal-intro/SKILL.md +95 -0
  106. package/templates/packs/principal/principal-nightly/SKILL.md +152 -0
  107. package/templates/skills/check-in/SKILL.md +34 -24
  108. package/templates/skills/knowledge-curation/SKILL.md +89 -48
  109. package/templates/skills/local-rules/SKILL.md +86 -0
  110. package/templates/skills/nightly-consolidation/SKILL.md +131 -100
  111. package/templates/skills/skill-method/SKILL.md +79 -0
  112. package/templates/skills/user-nightly/SKILL.md +143 -0
  113. package/templates/skills/work-graph/SKILL.md +75 -0
  114. package/conformance/README.md +0 -60
  115. package/conformance/discovery.test.mjs +0 -140
  116. package/conformance/envelope.test.mjs +0 -185
  117. package/conformance/error-codes.test.mjs +0 -125
  118. package/conformance/harness.mjs +0 -180
  119. package/conformance/identity.test.mjs +0 -125
  120. package/docs/integration-guide.md +0 -1042
  121. package/templates/skills/self-update/SKILL.md +0 -121
@@ -1,13 +1,25 @@
1
- import { appendFileSync, existsSync, readFileSync } from 'node:fs';
1
+ import {
2
+ accessSync,
3
+ appendFileSync,
4
+ constants,
5
+ existsSync,
6
+ mkdirSync,
7
+ readdirSync,
8
+ readFileSync,
9
+ } from 'node:fs';
2
10
  import { homedir } from 'node:os';
3
11
  import { join } from 'node:path';
12
+ import { withFileLock } from './hook_runtime.mjs';
4
13
 
5
14
  /**
6
15
  * Academy names no specific client except in this module. The memory sync
7
16
  * bridge stays client-specific by explicit decision, so every client-named
8
17
  * identifier Academy needs — env keys, tool permission, skill guidance, and the
9
18
  * observation copy itself — lives here. Client-neutral layers import these
10
- * declarations and inject them only behind ACADEMY_MEMORY_BRIDGE.
19
+ * declarations and inject them on every agent. The bridge is always on; it
20
+ * copies nothing unless the client's own keys and observations directory exist.
21
+ * The module also holds the Backstory source: observations of recorded
22
+ * sessions, found by session id under the Backstory home.
11
23
  */
12
24
  export const MEMORY_BRIDGE_ENV = {
13
25
  enabled: 'GROVE_MEMORY_ENABLED',
@@ -34,48 +46,145 @@ optional. Skip this section when \`subspace-memory\` is not installed.
34
46
  Subspace memory may enrich or corroborate an eligible run. It can never
35
47
  establish eligibility.`;
36
48
 
37
- function formatDate(date) {
38
- return date.toISOString().slice(0, 10);
49
+ // The Subspace wrapper is the only reader of grove.db. Academy hands it a
50
+ // workspace path and lets it resolve GROVE_* for the child, exactly as tldr's
51
+ // subspace_memory_launch.mjs does for scheduled jobs.
52
+ const MEMORY_WRAPPER = join('bin', 'helm-job-memory-wrapper');
53
+ const CLAUDE_SETTINGS_FILE = 'claude-code-subspace-settings.json';
54
+ const PANE_ENV_KEYS = ['GROVE_PANE_ID', 'SUBSPACE_PANE_ID'];
55
+
56
+ /** True when the client already resolved this process's memory workspace. */
57
+ export function bridgeEnvPresent(env) {
58
+ return Boolean(env[MEMORY_BRIDGE_ENV.project] && env[MEMORY_BRIDGE_ENV.workspace]);
59
+ }
60
+
61
+ function bridgeHome(env) {
62
+ const explicit = String(env[MEMORY_BRIDGE_ENV.home] ?? '').trim();
63
+ return explicit || join(env.HOME || homedir(), '.subspace');
39
64
  }
40
65
 
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)];
66
+ function isExecutable(path) {
67
+ try {
68
+ accessSync(path, constants.X_OK);
69
+ return true;
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+
75
+ /** The installed wrapper, or null when this machine has none. */
76
+ export function bridgeLauncher(env) {
77
+ const home = bridgeHome(env);
78
+ const wrapper = join(home, MEMORY_WRAPPER);
79
+ return isExecutable(wrapper) ? { home, wrapper } : null;
80
+ }
81
+
82
+ // The memory Stop hook ships async; a one-shot run exits before an async hook
83
+ // writes, so it is forced synchronous (same transform as tldr).
84
+ function isAsyncMemoryStopHook(hook) {
85
+ const command = typeof hook?.command === 'string' ? hook.command : '';
86
+ return (
87
+ hook?.async === true &&
88
+ command.includes('subspace-memory') &&
89
+ command.includes('stop-lifecycle')
90
+ );
91
+ }
92
+
93
+ function effectiveClaudeSettings(path) {
94
+ try {
95
+ const settings = JSON.parse(readFileSync(path, 'utf8'));
96
+ const hooks = (settings?.hooks?.Stop || []).flatMap((group) => group?.hooks || []);
97
+ const asyncHooks = hooks.filter(isAsyncMemoryStopHook);
98
+ for (const hook of asyncHooks) hook.async = false;
99
+ return asyncHooks.length > 0 ? JSON.stringify(settings) : path;
100
+ } catch {
101
+ return path;
102
+ }
103
+ }
104
+
105
+ /** Claude arguments that load the client's settings, or none when absent. */
106
+ export function bridgeClaudeSettingsArgs(launcher) {
107
+ const path = join(launcher.home, CLAUDE_SETTINGS_FILE);
108
+ if (!existsSync(path)) return [];
109
+ return ['--settings', effectiveClaudeSettings(path)];
110
+ }
111
+
112
+ const USER_MEMORY_HOOK = /subspace-memory|subspace-hooks\/|subspace-claude-/;
113
+
114
+ function userClaudeSettingsPaths(env) {
115
+ const paths = [join(env.HOME || homedir(), '.claude', 'settings.json')];
116
+ if (env.CLAUDE_CONFIG_DIR) paths.push(join(env.CLAUDE_CONFIG_DIR, 'settings.json'));
117
+ return paths;
118
+ }
119
+
120
+ function runsMemoryHooks(path) {
121
+ try {
122
+ const groups = Object.values(JSON.parse(readFileSync(path, 'utf8'))?.hooks ?? {}).flat();
123
+ return groups
124
+ .flatMap((group) => group?.hooks || [])
125
+ .some((hook) => USER_MEMORY_HOOK.test(String(hook?.command ?? '')));
126
+ } catch {
127
+ return false;
128
+ }
129
+ }
130
+
131
+ /**
132
+ * True when the user's own Claude settings already run the client's memory
133
+ * hooks. Claude Code merges settings and de-duplicates hooks only on identical
134
+ * command text, so loading the client's settings file too would fire every
135
+ * memory hook twice.
136
+ */
137
+ export function bridgeHooksInUserSettings(env) {
138
+ return userClaudeSettingsPaths(env).some(runsMemoryHooks);
139
+ }
140
+
141
+ /** The wrapped argv and env that start `command` with a memory workspace. */
142
+ export function bridgeLaunch(launcher, { command, args, env, workspaceCwd }) {
143
+ const unsetPane = PANE_ENV_KEYS.flatMap((key) => ['-u', key]);
144
+ return {
145
+ command: launcher.wrapper,
146
+ args: ['/usr/bin/env', ...unsetPane, command, ...args],
147
+ env: {
148
+ ...env,
149
+ [MEMORY_BRIDGE_ENV.home]: launcher.home,
150
+ PATH: `${join(launcher.home, 'bin')}:${env.PATH || ''}`,
151
+ [MEMORY_BRIDGE_ENV.enabled]: '1',
152
+ HELM_MEMORY_CONTEXT: JSON.stringify({ workspace_cwd: workspaceCwd }),
153
+ },
154
+ };
155
+ }
156
+
157
+ const DAY_MS = 24 * 60 * 60 * 1000;
158
+
159
+ function sweepDates(days) {
160
+ const now = Date.now();
161
+ return Array.from({ length: days }, (_, back) =>
162
+ new Date(now - back * DAY_MS).toISOString().slice(0, 10),
163
+ );
46
164
  }
47
165
 
48
166
  function memoryKey(entry) {
49
167
  return `${entry.sessionId || ''}|${entry.timestamp || ''}|${entry.turnNumber || ''}`;
50
168
  }
51
169
 
52
- function readExistingKeys(path) {
53
- const keys = new Set();
54
- if (!existsSync(path)) return keys;
170
+ // Each parsed row keeps its source line, so a copy is byte-for-byte.
171
+ function readJsonlLines(path) {
172
+ if (!existsSync(path)) return [];
173
+ const rows = [];
55
174
  for (const line of readFileSync(path, 'utf8').split('\n')) {
56
175
  const trimmed = line.trim();
57
176
  if (!trimmed) continue;
58
177
  try {
59
- keys.add(memoryKey(JSON.parse(trimmed)));
178
+ rows.push({ entry: JSON.parse(trimmed), line: trimmed });
60
179
  } catch {
61
- // Ignore malformed archive lines.
180
+ // Ignore malformed lines.
62
181
  }
63
182
  }
64
- return keys;
183
+ return rows;
65
184
  }
66
185
 
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;
186
+ function readJsonl(path) {
187
+ return readJsonlLines(path).map(({ entry }) => entry);
79
188
  }
80
189
 
81
190
  function memorySyncEnabled(env) {
@@ -83,38 +192,163 @@ function memorySyncEnabled(env) {
83
192
  return enabled === '1' || enabled === 'true';
84
193
  }
85
194
 
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;
195
+ /**
196
+ * The memory location a session record keeps, so a later sweep reads that
197
+ * session's own observation folder. A nightly session records none: its own
198
+ * turns must never become the next nightly's input.
199
+ */
200
+ export function bridgeSessionFields(env) {
201
+ if (!memorySyncEnabled(env) || !bridgeEnvPresent(env)) return {};
202
+ if (env.ACADEMY_NIGHTLY_RUN === '1') return {};
203
+ return {
204
+ groveProject: env[MEMORY_BRIDGE_ENV.project],
205
+ groveWorkspace: env[MEMORY_BRIDGE_ENV.workspace],
206
+ };
207
+ }
208
+
209
+ function recordedSessionsByWorkspace(agentDir) {
210
+ const groups = new Map();
211
+ for (const row of readJsonl(join(agentDir, 'memory', 'sessions.jsonl'))) {
212
+ if (!row.sessionId || !row.groveProject || !row.groveWorkspace) continue;
213
+ const key = JSON.stringify([row.groveProject, row.groveWorkspace]);
214
+ if (!groups.has(key)) {
215
+ groups.set(key, {
216
+ project: row.groveProject,
217
+ workspace: row.groveWorkspace,
218
+ sessionIds: new Set(),
219
+ });
220
+ }
221
+ groups.get(key).sessionIds.add(row.sessionId);
222
+ }
223
+ return [...groups.values()];
224
+ }
225
+
226
+ // SUBSPACE_HOME first, then ~/.subspace, per date file: a home can hold an
227
+ // older copy of a workspace folder while today's file lives in the other.
228
+ function observationFile(env, group, date) {
229
+ const roots = [env[MEMORY_BRIDGE_ENV.home], join(env.HOME || homedir(), '.subspace')];
230
+ return roots
231
+ .filter(Boolean)
232
+ .map((root) => join(root, group.project, group.workspace, 'memory', 'observations'))
233
+ .map((dir) => join(dir, `${date}.jsonl`))
234
+ .find((path) => existsSync(path));
235
+ }
236
+
237
+ // shortcut: scans every Backstory context for the date, upgrade if Backstory is installed widely.
238
+ function backstoryObservationFiles(env, date) {
239
+ const projects = join(
240
+ env.BACKSTORY_HOME || join(env.HOME || homedir(), '.backstory'),
241
+ 'projects',
242
+ );
243
+ const dirs = (path) => {
92
244
  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++;
245
+ return readdirSync(path);
99
246
  } catch {
100
- // Ignore malformed Subspace observation lines.
247
+ return [];
248
+ }
249
+ };
250
+ return dirs(projects)
251
+ .flatMap((project) =>
252
+ dirs(join(projects, project, 'contexts')).map((context) =>
253
+ join(projects, project, 'contexts', context, 'memory', 'observations', `${date}.jsonl`),
254
+ ),
255
+ )
256
+ .filter((path) => existsSync(path));
257
+ }
258
+
259
+ // Nightly sessions are excluded: their own turns must not feed the next nightly.
260
+ function backstorySessionIds(agentDir) {
261
+ return new Set(
262
+ readJsonl(join(agentDir, 'memory', 'sessions.jsonl'))
263
+ .filter((row) => row.sessionId && !row.nightly)
264
+ .map((row) => row.sessionId),
265
+ );
266
+ }
267
+
268
+ function sweepBackstory(archiveDir, sessionIds, days, env) {
269
+ let copied = 0;
270
+ for (const date of sweepDates(days)) {
271
+ for (const source of backstoryObservationFiles(env, date)) {
272
+ copied += copySessionObservations(source, join(archiveDir, `${date}.jsonl`), sessionIds);
101
273
  }
102
274
  }
103
- return synced;
275
+ return copied;
276
+ }
277
+
278
+ function copySessionObservations(sourceFile, agentFile, sessionIds) {
279
+ const existingKeys = new Set(readJsonl(agentFile).map(memoryKey));
280
+ let copied = 0;
281
+ for (const { entry, line } of readJsonlLines(sourceFile)) {
282
+ const key = memoryKey(entry);
283
+ if (!sessionIds.has(entry.sessionId) || existingKeys.has(key)) continue;
284
+ appendFileSync(agentFile, `${line}\n`);
285
+ existingKeys.add(key);
286
+ copied++;
287
+ }
288
+ return copied;
104
289
  }
105
290
 
106
291
  /**
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.
292
+ * Copy observations the client wrote for any session recorded for this agent,
293
+ * from each session's own workspace folder, into the agent's own archive. The
294
+ * client writes observations after Stop, so every sweep catches what earlier
295
+ * ones could not yet see; `memoryKey` keeps a repeated sweep from duplicating.
296
+ * The client-neutral callers learn only a count.
109
297
  */
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);
298
+ export function sweepBridgeObservations(agentDir, { days = 2, env = process.env } = {}) {
299
+ try {
300
+ return sweepRecordedSessions(agentDir, days, env);
301
+ } catch (error) {
302
+ // A failed sweep must not cost the caller its own work; the next sweep
303
+ // covers the same window.
304
+ process.stderr.write(
305
+ `${JSON.stringify({ event: 'memory.sweep_failed', params: { agentDir, days }, error: error.stack })}\n`,
306
+ );
307
+ return 0;
118
308
  }
119
- return synced;
309
+ }
310
+
311
+ function sweepRecordedSessions(agentDir, days, env) {
312
+ const groups = recordedSessionsByWorkspace(agentDir);
313
+ const archiveDir = join(agentDir, 'memory', 'observations');
314
+ mkdirSync(archiveDir, { recursive: true });
315
+ return withFileLock(join(agentDir, 'memory', 'sweep.lock'), () => {
316
+ let copied = 0;
317
+ for (const group of groups) {
318
+ for (const date of sweepDates(days)) {
319
+ const source = observationFile(env, group, date);
320
+ if (!source) continue;
321
+ const target = join(archiveDir, `${date}.jsonl`);
322
+ copied += copySessionObservations(source, target, group.sessionIds);
323
+ }
324
+ }
325
+ return copied + sweepBackstory(archiveDir, backstorySessionIds(agentDir), days, env);
326
+ });
327
+ }
328
+
329
+ // --- Curator feed: the client facts scripts/cli/curator-feed*.mjs reads ---
330
+
331
+ /** The Codex originator the client stamps on sessions it starts for the user. */
332
+ export const MEMORY_BRIDGE_CODEX_ORIGINATOR = 'subspace';
333
+
334
+ /** Every client home the feed reads, in order: SUBSPACE_HOME, ~/.subspace-alpha, ~/.subspace. */
335
+ export function bridgeHomes(env) {
336
+ const home = env.HOME || homedir();
337
+ const homes = [
338
+ env[MEMORY_BRIDGE_ENV.home],
339
+ join(home, '.subspace-alpha'),
340
+ join(home, '.subspace'),
341
+ ];
342
+ return [...new Set(homes.filter(Boolean))];
343
+ }
344
+
345
+ /** The client project and workspace a cwd sits in, or null outside a client home. */
346
+ export function bridgeWorkspaceOf(cwd) {
347
+ const m = /\.subspace(?:-alpha)?\/([^/]+)\/([^/]+)/.exec(cwd ?? '');
348
+ return m ? { project: m[1], workspace: m[2] } : null;
349
+ }
350
+
351
+ /** A prompt without the context blocks the client injects into it. */
352
+ export function stripBridgeContext(text) {
353
+ return text.replace(/<(subspace|memory|rag|context)[-_a-z]*>.*?<\/\1[-_a-z]*>/gs, '');
120
354
  }
@@ -8,39 +8,72 @@ import {
8
8
  } from 'node:fs';
9
9
  import { join, resolve } from 'node:path';
10
10
  import { jsonlHasValue, resolvedAgentDir, withFileLock } from './hook_runtime.mjs';
11
+ import { bridgeSessionFields } from './memory_bridge.mjs';
11
12
 
12
13
  // Academy's own per-agent memory state. Client-neutral: nothing here knows
13
- // which client, if any, feeds the observation archive.
14
+ // which client, if any, feeds the observation archive; the bridge supplies
15
+ // any client location a session record keeps.
14
16
 
15
17
  export function pendingMarkerPath(agentDir) {
16
18
  return join(resolve(agentDir), 'memory', 'pending-consolidation.json');
17
19
  }
18
20
 
19
- export function markPendingConsolidation(memoryDir) {
21
+ // The marker's writers and the nightly's remover share one lock, so a bump
22
+ // never lands between the remover's revision check and its unlink.
23
+ export function withPendingMarkerLock(memoryDir, fn) {
24
+ return withFileLock(join(memoryDir, 'pending-consolidation.lock'), fn);
25
+ }
26
+
27
+ // `write` runs inside the lock, so a writer's input commits with its bump: the
28
+ // nightly's remover sees both or neither. A `write` that returns false skips
29
+ // the bump. False means the lock timed out and `write` never ran: the caller
30
+ // wrote nothing, so a hook's idempotent sweep runs again next time and
31
+ // `notes add` reports the failure.
32
+ export function markPendingConsolidation(memoryDir, write = () => true) {
20
33
  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.
34
+ try {
35
+ withPendingMarkerLock(memoryDir, () => {
36
+ if (write() === false) return;
37
+ let revision = 0;
38
+ if (existsSync(markerPath)) {
39
+ try {
40
+ revision = Number(JSON.parse(readFileSync(markerPath, 'utf8')).revision) || 0;
41
+ } catch {
42
+ // Replace malformed private state with the next valid revision.
43
+ }
28
44
  }
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
- });
45
+ const tempPath = join(memoryDir, `.pending-consolidation.${process.pid}.${Date.now()}.tmp`);
46
+ writeFileSync(
47
+ tempPath,
48
+ JSON.stringify(
49
+ {
50
+ revision: revision + 1,
51
+ updatedAt: new Date().toISOString(),
52
+ },
53
+ null,
54
+ 2,
55
+ ) + '\n',
56
+ );
57
+ renameSync(tempPath, markerPath);
58
+ });
59
+ return true;
60
+ } catch (error) {
61
+ if (!skippedOnLockTimeout(error, { memoryDir, write: 'pending_marker' })) throw error;
62
+ return false;
63
+ }
64
+ }
65
+
66
+ function skippedOnLockTimeout(error, params) {
67
+ if (error?.code !== 'lock_timeout') return false;
68
+ console.error(
69
+ JSON.stringify({
70
+ event: 'memory.lock_timeout',
71
+ params,
72
+ result: { written: false },
73
+ status: 'skipped',
74
+ }),
75
+ );
76
+ return true;
44
77
  }
45
78
 
46
79
  export function recordSession(memoryDir, payload, env) {
@@ -48,7 +81,7 @@ export function recordSession(memoryDir, payload, env) {
48
81
  if (!sessionId) return;
49
82
  const sessionsPath = join(memoryDir, 'sessions.jsonl');
50
83
  mkdirSync(memoryDir, { recursive: true });
51
- withFileLock(join(memoryDir, 'sessions.lock'), () => {
84
+ const appendRow = () => {
52
85
  if (jsonlHasValue(sessionsPath, 'sessionId', sessionId)) return;
53
86
  appendFileSync(
54
87
  sessionsPath,
@@ -60,7 +93,26 @@ export function recordSession(memoryDir, payload, env) {
60
93
  cwd: payload.cwd || null,
61
94
  projectDir: env.ACADEMY_PROJECT_DIR || null,
62
95
  source: 'stop',
96
+ ...bridgeSessionFields(env),
97
+ // The Backstory source matches by session id, so it needs this marker.
98
+ ...(env.ACADEMY_NIGHTLY_RUN === '1' ? { nightly: true } : {}),
63
99
  }) + '\n',
64
100
  );
65
- });
101
+ };
102
+ try {
103
+ withFileLock(join(memoryDir, 'sessions.lock'), appendRow);
104
+ } catch (error) {
105
+ if (error?.code !== 'lock_timeout') throw error;
106
+ // A one-turn session has no later Stop to record it, so write without the
107
+ // lock. The sweep groups ids in a Set, so a duplicate row is harmless.
108
+ console.error(
109
+ JSON.stringify({
110
+ event: 'memory.lock_timeout',
111
+ params: { memoryDir, write: 'session_record' },
112
+ result: { written: true, locked: false },
113
+ status: 'warn',
114
+ }),
115
+ );
116
+ appendRow();
117
+ }
66
118
  }
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { appendFileSync, mkdirSync } from 'node:fs';
2
+ import { appendFileSync, existsSync, mkdirSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import {
@@ -10,6 +10,9 @@ import {
10
10
  runtimeEnvFor,
11
11
  withFileLock,
12
12
  } from './hook_runtime.mjs';
13
+ import { measureSessionContext } from './context_metrics.mjs';
14
+ import { bridgeSessionFields, sweepBridgeObservations } from './memory_bridge.mjs';
15
+ import { markPendingConsolidation } from './memory_store.mjs';
13
16
 
14
17
  function appendSession(sessionsPath, session) {
15
18
  if (jsonlHasValue(sessionsPath, 'sessionId', session.sessionId)) return true;
@@ -27,25 +30,84 @@ export function registerAcademySession(payload, env = process.env) {
27
30
  // unattributable rather than corrupt, so it is written without the field and
28
31
  // counted by `doctor`.
29
32
  const agentDir = resolvedAgentDir(env.ACADEMY_AGENT_DIR);
33
+ const recorded = writeIndexRow(env, {
34
+ sessionId,
35
+ agentName,
36
+ ...(agentDir ? { agentDir } : {}),
37
+ cwd,
38
+ startedAt: new Date().toISOString(),
39
+ ...(payload.transcript_path ? { transcriptPath: payload.transcript_path } : {}),
40
+ ...contextField(agentDir),
41
+ ...bridgeSessionFields(env),
42
+ });
43
+ sweepEarlierSessions(agentDir, env);
44
+ return recorded;
45
+ }
30
46
 
47
+ // shortcut: reads the prompt file after launch, so two near-simultaneous launches of the same agent can read each other's render; upgrade by passing the measurement through the launch env.
48
+ function contextField(agentDir) {
49
+ if (!agentDir) return {};
31
50
  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(),
51
+ const context = measureSessionContext(agentDir);
52
+ return context ? { context } : {};
53
+ } catch (err) {
54
+ console.error(
55
+ JSON.stringify({
56
+ event: 'session.context_measure_failed',
57
+ params: { agentDir },
58
+ result: { error: err.message },
59
+ status: 'failed',
42
60
  }),
43
61
  );
44
- } catch {
45
- return false;
62
+ return {};
46
63
  }
47
64
  }
48
65
 
66
+ function logHookFailure(event, params, error, status = 'failed') {
67
+ console.error(
68
+ JSON.stringify({ event, params, result: { error: error.message }, status, stack: error.stack }),
69
+ );
70
+ }
71
+
72
+ // An unlocked append can duplicate a row only if the lock holder writes this
73
+ // same session id at once; appendSession still checks for it first.
74
+ function writeIndexRow(env, row) {
75
+ const academyDir = join(env.HOME || homedir(), '.academy');
76
+ const sessionsPath = join(academyDir, 'sessions.jsonl');
77
+ try {
78
+ mkdirSync(academyDir, { recursive: true });
79
+ return withFileLock(join(academyDir, 'sessions.lock'), () => appendSession(sessionsPath, row));
80
+ } catch (error) {
81
+ if (error?.code !== 'lock_timeout') {
82
+ logHookFailure('session.index_write_failed', { academyDir }, error);
83
+ return false;
84
+ }
85
+ logHookFailure('session.lock_timeout', { academyDir, write: 'session_index' }, error, 'warn');
86
+ try {
87
+ return appendSession(sessionsPath, row);
88
+ } catch (writeError) {
89
+ logHookFailure('session.index_write_failed', { academyDir }, writeError);
90
+ return false;
91
+ }
92
+ }
93
+ }
94
+
95
+ // A new session picks up the last turn of the session before it, which the
96
+ // client wrote after that session's final Stop had already swept.
97
+ // The copies commit with the marker bump; no memory/ means nothing to sweep.
98
+ function sweepEarlierSessions(agentDir, env) {
99
+ if (!agentDir || !existsSync(join(agentDir, 'memory'))) return;
100
+ markPendingConsolidation(
101
+ join(agentDir, 'memory'),
102
+ () => sweepBridgeObservations(agentDir, { env }) > 0,
103
+ );
104
+ }
105
+
49
106
  if (isMainModule(import.meta.url)) {
50
- registerAcademySession(readPayload());
107
+ try {
108
+ registerAcademySession(readPayload());
109
+ } catch (error) {
110
+ // A hook must never fail the session it observes.
111
+ logHookFailure('session.register_failed', {}, error);
112
+ }
51
113
  }
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "roque",
3
+ "version": "0.1.1+54ca4d5",
4
+ "description": "Keeps long Claude Code and Codex sessions on track across native compaction with a per-session state file.",
5
+ "hooks": "./hooks.json"
6
+ }
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Joe Fernandez
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ 54ca4d587b911adee6f71ce6239d448359a10843