agent-working-memory 0.14.1 → 0.14.6

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 (76) hide show
  1. package/README.md +240 -653
  2. package/dist/adapters/claude-code.d.ts.map +1 -1
  3. package/dist/adapters/claude-code.js +234 -131
  4. package/dist/adapters/claude-code.js.map +1 -1
  5. package/dist/adapters/common.d.ts +44 -5
  6. package/dist/adapters/common.d.ts.map +1 -1
  7. package/dist/adapters/common.js +111 -41
  8. package/dist/adapters/common.js.map +1 -1
  9. package/dist/adapters/hook-scripts.d.ts +54 -0
  10. package/dist/adapters/hook-scripts.d.ts.map +1 -0
  11. package/dist/adapters/hook-scripts.js +340 -0
  12. package/dist/adapters/hook-scripts.js.map +1 -0
  13. package/dist/adapters/types.d.ts +13 -3
  14. package/dist/adapters/types.d.ts.map +1 -1
  15. package/dist/cli.js +21 -5
  16. package/dist/cli.js.map +1 -1
  17. package/dist/core/agent-id.d.ts +19 -0
  18. package/dist/core/agent-id.d.ts.map +1 -0
  19. package/dist/core/agent-id.js +28 -0
  20. package/dist/core/agent-id.js.map +1 -0
  21. package/dist/core/whoami.d.ts +9 -1
  22. package/dist/core/whoami.d.ts.map +1 -1
  23. package/dist/core/whoami.js +12 -2
  24. package/dist/core/whoami.js.map +1 -1
  25. package/dist/engine/activation.d.ts +2 -0
  26. package/dist/engine/activation.d.ts.map +1 -1
  27. package/dist/engine/activation.js +18 -3
  28. package/dist/engine/activation.js.map +1 -1
  29. package/dist/engine/eval.d.ts +15 -0
  30. package/dist/engine/eval.d.ts.map +1 -1
  31. package/dist/engine/eval.js +23 -0
  32. package/dist/engine/eval.js.map +1 -1
  33. package/dist/hooks/prime.d.ts +10 -4
  34. package/dist/hooks/prime.d.ts.map +1 -1
  35. package/dist/hooks/prime.js +16 -6
  36. package/dist/hooks/prime.js.map +1 -1
  37. package/dist/hooks/sidecar.d.ts +18 -2
  38. package/dist/hooks/sidecar.d.ts.map +1 -1
  39. package/dist/hooks/sidecar.js +30 -4
  40. package/dist/hooks/sidecar.js.map +1 -1
  41. package/dist/mcp.js +48 -19
  42. package/dist/mcp.js.map +1 -1
  43. package/dist/storage/pglite.d.ts +7 -0
  44. package/dist/storage/pglite.d.ts.map +1 -1
  45. package/dist/storage/pglite.js +17 -3
  46. package/dist/storage/pglite.js.map +1 -1
  47. package/dist/storage/postgres.d.ts +7 -0
  48. package/dist/storage/postgres.d.ts.map +1 -1
  49. package/dist/storage/postgres.js +17 -3
  50. package/dist/storage/postgres.js.map +1 -1
  51. package/dist/storage/sqlite.d.ts +12 -0
  52. package/dist/storage/sqlite.d.ts.map +1 -1
  53. package/dist/storage/sqlite.js +24 -3
  54. package/dist/storage/sqlite.js.map +1 -1
  55. package/dist/types/engram.d.ts +16 -0
  56. package/dist/types/engram.d.ts.map +1 -1
  57. package/dist/types/eval.d.ts +2 -0
  58. package/dist/types/eval.d.ts.map +1 -1
  59. package/package.json +94 -72
  60. package/src/adapters/claude-code.ts +223 -125
  61. package/src/adapters/common.ts +117 -42
  62. package/src/adapters/hook-scripts.ts +370 -0
  63. package/src/adapters/types.ts +14 -3
  64. package/src/cli.ts +19 -5
  65. package/src/core/agent-id.ts +28 -0
  66. package/src/core/whoami.ts +11 -1
  67. package/src/engine/activation.ts +18 -3
  68. package/src/engine/eval.ts +34 -0
  69. package/src/hooks/prime.ts +22 -7
  70. package/src/hooks/sidecar.ts +50 -6
  71. package/src/mcp.ts +50 -18
  72. package/src/storage/pglite.ts +22 -4
  73. package/src/storage/postgres.ts +22 -4
  74. package/src/storage/sqlite.ts +26 -4
  75. package/src/types/engram.ts +16 -0
  76. package/src/types/eval.ts +3 -1
@@ -12,6 +12,8 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
12
12
  import { join, dirname, basename } from 'node:path';
13
13
  import type { CLIAdapter, SetupContext, DiagnosticResult } from './types.js';
14
14
  import { resolveMcpCommand, homedir, AWM_INSTRUCTION_CONTENT, upsertAwmSection } from './common.js';
15
+ import { HOOK_SCRIPTS, AWM_HOOKS_VERSION, PRIME_DISABLE_FILE, type HooksRecord } from './hook-scripts.js';
16
+ import { request as httpRequest } from 'node:http';
15
17
 
16
18
  /**
17
19
  * DB-mutation reminder hook (PostToolUse on Bash|PowerShell).
@@ -48,40 +50,101 @@ process.stdin.on('end', () => {
48
50
  });
49
51
  `;
50
52
 
53
+ const SERVER_NAME = 'agent-working-memory';
54
+
55
+ /** Where this adapter keeps the AWM server entry for a scope. */
56
+ function mcpJsonPathFor(isGlobal: boolean, cwd: string): string {
57
+ // Global: ~/.mcp.json (standard MCP location, all projects)
58
+ // Project: .claude/mcp.json (Claude Code's native project scope)
59
+ return isGlobal ? join(homedir(), '.mcp.json') : join(cwd, '.claude', 'mcp.json');
60
+ }
61
+
62
+ function readJson(path: string): any | null {
63
+ if (!existsSync(path)) return null;
64
+ try { return JSON.parse(readFileSync(path, 'utf-8')); } catch { return null; }
65
+ }
66
+
67
+ /**
68
+ * Exactly the hook scripts `awm setup` installs. Used to recognise our own groups in
69
+ * settings.json so they can be replaced on a re-run.
70
+ *
71
+ * This list must stay TIGHT. It was briefly a loose `awm-*.cjs` pattern, and on a real
72
+ * install that silently deleted the owner's own `awm-dev-log.cjs` and
73
+ * `awm-recall-stamp.cjs` hooks, which merely share the prefix. Anything not installed by
74
+ * this adapter belongs to the user.
75
+ */
76
+ const OWNED_HOOK_FILES = [
77
+ ...HOOK_SCRIPTS.map(h => h.file),
78
+ 'awm-db-mutation-reminder.cjs',
79
+ ];
80
+
81
+ /** True for a settings.json hook group that `awm setup` wrote, in any version. */
82
+ function isAwmHookGroup(group: any): boolean {
83
+ const text = JSON.stringify(group ?? {});
84
+ return OWNED_HOOK_FILES.some(f => text.includes(f)) // 0.14.6 scripts
85
+ || /curl[^"]*\/hooks\/checkpoint/.test(text) // pre-0.14.6 inline curl
86
+ || text.includes('MEMORY: (1) Did you learn'); // the Stop reminder
87
+ }
88
+
89
+ /** Merge one hook group for `event`: drop every AWM-owned group, keep the user's, append ours. */
90
+ function setHookGroup(settings: any, event: string, group: any | null): void {
91
+ const others = (settings.hooks[event] || []).filter((g: any) => !isAwmHookGroup(g));
92
+ settings.hooks[event] = group ? [...others, group] : others;
93
+ if (settings.hooks[event].length === 0) delete settings.hooks[event];
94
+ }
95
+
96
+ interface LiveSidecar { port: number; agentId: string | null; version: string; pid: number | null }
97
+
98
+ /** GET /health on one sidecar port; null when nothing answers or it is not a sidecar. */
99
+ function probeSidecar(port: number, timeoutMs = 400): Promise<LiveSidecar | null> {
100
+ return new Promise(resolve => {
101
+ const req = httpRequest({ host: '127.0.0.1', port, path: '/health', method: 'GET', timeout: timeoutMs }, res => {
102
+ let data = '';
103
+ res.on('data', c => { data += c; });
104
+ res.on('end', () => {
105
+ try {
106
+ const j = JSON.parse(data);
107
+ resolve(j && j.sidecar ? { port, agentId: j.agentId ?? null, version: j.version ?? '0.0.0', pid: j.pid ?? null } : null);
108
+ } catch { resolve(null); }
109
+ });
110
+ });
111
+ req.on('error', () => resolve(null));
112
+ req.on('timeout', () => { req.destroy(); resolve(null); });
113
+ req.end();
114
+ });
115
+ }
116
+
51
117
  const adapter: CLIAdapter = {
52
118
  id: 'claude-code',
53
119
  name: 'Claude Code',
54
120
  supportsProjectScope: true,
55
121
  supportsGlobalScope: true,
56
122
 
123
+ readExistingEnv(isGlobal: boolean, cwd: string): Record<string, string> | null {
124
+ const entry = readJson(mcpJsonPathFor(isGlobal, cwd))?.mcpServers?.[SERVER_NAME];
125
+ return entry?.env && typeof entry.env === 'object' ? { ...entry.env } : null;
126
+ },
127
+
57
128
  writeMcpConfig(ctx: SetupContext): string {
58
129
  const mcpCmd = resolveMcpCommand(ctx);
59
- const mcpConfig = { ...mcpCmd, env: ctx.envVars };
60
-
61
- // Global: ~/.mcp.json (standard MCP location, all projects)
62
- // Project: .claude/mcp.json (Claude Code's native project scope)
63
- const mcpJsonPath = ctx.isGlobal
64
- ? join(homedir(), '.mcp.json')
65
- : join(ctx.cwd, '.claude', 'mcp.json');
66
-
130
+ // ctx.envVars already layers recommended defaults < the user's existing env < owned keys
131
+ // (buildEnvVars). Only the env and the command are ours; anything else the user put on
132
+ // the entry (e.g. a `type`, a `cwd`) is carried across.
133
+ const mcpJsonPath = mcpJsonPathFor(ctx.isGlobal, ctx.cwd);
67
134
  const mcpDir = dirname(mcpJsonPath);
68
- if (!existsSync(mcpDir)) {
69
- mkdirSync(mcpDir, { recursive: true });
70
- }
135
+ if (!existsSync(mcpDir)) mkdirSync(mcpDir, { recursive: true });
71
136
 
72
- let existing: any = {};
73
- if (existsSync(mcpJsonPath)) {
74
- try {
75
- existing = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
76
- } catch {
77
- existing = {};
78
- }
79
- }
137
+ const existing = readJson(mcpJsonPath) ?? {};
80
138
  if (!existing.mcpServers) existing.mcpServers = {};
81
-
82
- existing.mcpServers['agent-working-memory'] = mcpConfig;
139
+ const prior = existing.mcpServers[SERVER_NAME] ?? {};
140
+ const priorEnv: Record<string, string> = prior.env ?? {};
141
+ const preserved = Object.keys(priorEnv).filter(k => ctx.envVars[k] === priorEnv[k]);
142
+ existing.mcpServers[SERVER_NAME] = { ...prior, ...mcpCmd, env: ctx.envVars };
83
143
  writeFileSync(mcpJsonPath, JSON.stringify(existing, null, 2) + '\n');
84
- return `MCP config: ${mcpJsonPath}`;
144
+ const kept = Object.keys(priorEnv).length
145
+ ? ` (${preserved.length} of ${Object.keys(priorEnv).length} existing env values carried over)`
146
+ : '';
147
+ return `MCP config: ${mcpJsonPath}${kept}`;
85
148
  },
86
149
 
87
150
  writeInstructions(ctx: SetupContext, skip: boolean): string {
@@ -99,20 +162,30 @@ const adapter: CLIAdapter = {
99
162
  if (skip) return 'Hooks: skipped (--no-hooks)';
100
163
 
101
164
  const settingsPath = join(homedir(), '.claude', 'settings.json');
102
- let settings: any = {};
103
- if (existsSync(settingsPath)) {
104
- try {
105
- settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
106
- } catch {
107
- settings = {};
108
- }
109
- }
165
+ const settings: any = readJson(settingsPath) ?? {};
110
166
  if (!settings.hooks) settings.hooks = {};
111
167
 
112
- const hookUrl = `http://127.0.0.1:${ctx.hookPort}/hooks/checkpoint`;
168
+ // 0.14.6: hooks are scripts that resolve the sidecar, the agent and the secret at run
169
+ // time (see hook-scripts.ts). Nothing about ports or secrets is baked into settings.json.
170
+ const hooksDir = join(homedir(), '.claude', 'hooks');
171
+ if (!existsSync(hooksDir)) mkdirSync(hooksDir, { recursive: true });
172
+ for (const { file, source } of HOOK_SCRIPTS) {
173
+ writeFileSync(join(hooksDir, file), source);
174
+ }
175
+ const record: HooksRecord = {
176
+ version: AWM_HOOKS_VERSION,
177
+ agentId: ctx.agentId,
178
+ dbPath: ctx.envVars.AWM_DB_PATH,
179
+ secretPath: join(dirname(ctx.dbPath), '.awm-hook-secret').replace(/\\/g, '/'),
180
+ hookPort: ctx.hookPort,
181
+ hookPortRange: ctx.hookPortRange,
182
+ installedAt: new Date().toISOString(),
183
+ };
184
+ writeFileSync(join(hooksDir, 'awm-hooks.json'), JSON.stringify(record, null, 2) + '\n');
185
+ const script = (file: string) => `node "${join(hooksDir, file).replace(/\\/g, '/')}"`;
113
186
 
114
187
  // Stop — remind Claude to write/recall/switch tasks
115
- settings.hooks.Stop = [{
188
+ setHookGroup(settings, 'Stop', {
116
189
  matcher: '',
117
190
  hooks: [{
118
191
  type: 'command',
@@ -120,73 +193,50 @@ const adapter: CLIAdapter = {
120
193
  timeout: 5,
121
194
  async: true,
122
195
  }],
123
- }];
124
-
125
- // Multi-port fallback for separate memory pools
126
- const altPort = ctx.hookPort === '8401' ? '8402' : '8401';
127
- const hookUrlAlt = `http://127.0.0.1:${altPort}/hooks/checkpoint`;
128
- const buildHookCmd = (event: string, maxTime: number) => {
129
- const primary = `curl -sf -X POST ${hookUrl} -H "Content-Type: application/json" -H "Authorization: Bearer ${ctx.hookSecret}" -d "{\\"hook_event_name\\":\\"${event}\\"}" --max-time ${maxTime}`;
130
- const fallback = `curl -sf -X POST ${hookUrlAlt} -H "Content-Type: application/json" -H "Authorization: Bearer ${ctx.hookSecret}" -d "{\\"hook_event_name\\":\\"${event}\\"}" --max-time ${maxTime}`;
131
- return `${primary} || ${fallback}`;
132
- };
196
+ });
133
197
 
134
198
  // PreCompact — auto-checkpoint before context compaction
135
- settings.hooks.PreCompact = [{
199
+ setHookGroup(settings, 'PreCompact', {
136
200
  matcher: '',
137
- hooks: [{
138
- type: 'command',
139
- command: buildHookCmd('PreCompact', 5),
140
- timeout: 10,
141
- }],
142
- }];
201
+ hooks: [{ type: 'command', command: script('awm-checkpoint.cjs'), timeout: 10 }],
202
+ });
143
203
 
144
- // SessionEnd — auto-checkpoint on session close
145
- settings.hooks.SessionEnd = [{
204
+ // SessionEnd — auto-checkpoint on session close (+ consolidation in the sidecar)
205
+ setHookGroup(settings, 'SessionEnd', {
146
206
  matcher: '',
147
- hooks: [{
148
- type: 'command',
149
- command: buildHookCmd('SessionEnd', 2),
150
- timeout: 5,
151
- }],
152
- }];
207
+ hooks: [{ type: 'command', command: script('awm-checkpoint.cjs'), timeout: 8 }],
208
+ });
209
+
210
+ // UserPromptSubmit — PRIME: inject relevant memories, or nothing (0.14.6)
211
+ setHookGroup(settings, 'UserPromptSubmit', ctx.installPrime ? {
212
+ matcher: '',
213
+ hooks: [{ type: 'command', command: script('awm-prime.cjs'), timeout: 6 }],
214
+ } : null);
153
215
 
154
216
  // PostToolUse — DB-mutation reminder: a production data change is not
155
- // complete until it is memory_written. Deploy the script, then merge the
156
- // hook entry (preserve any unrelated PostToolUse groups the user has).
157
- const hooksDir = join(homedir(), '.claude', 'hooks');
158
- if (!existsSync(hooksDir)) {
159
- mkdirSync(hooksDir, { recursive: true });
160
- }
217
+ // complete until it is memory_written.
161
218
  const mutationHookPath = join(hooksDir, 'awm-db-mutation-reminder.cjs');
162
219
  writeFileSync(mutationHookPath, DB_MUTATION_HOOK_SCRIPT);
163
- const mutationHookCmd = `node "${mutationHookPath.replace(/\\/g, '/')}"`;
164
- const otherPostToolUse = (settings.hooks.PostToolUse || []).filter(
165
- (g: any) => !JSON.stringify(g).includes('awm-db-mutation-reminder')
166
- );
167
- settings.hooks.PostToolUse = [
168
- ...otherPostToolUse,
169
- {
170
- matcher: 'Bash|PowerShell',
171
- hooks: [{
172
- type: 'command',
173
- command: mutationHookCmd,
174
- timeout: 10,
175
- }],
176
- },
177
- ];
220
+ setHookGroup(settings, 'PostToolUse', {
221
+ matcher: 'Bash|PowerShell',
222
+ hooks: [{ type: 'command', command: script('awm-db-mutation-reminder.cjs'), timeout: 10 }],
223
+ });
178
224
 
179
225
  const settingsDir = dirname(settingsPath);
180
- if (!existsSync(settingsDir)) {
181
- mkdirSync(settingsDir, { recursive: true });
182
- }
226
+ if (!existsSync(settingsDir)) mkdirSync(settingsDir, { recursive: true });
183
227
  writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
184
228
 
185
- return `Hooks: Stop + PreCompact + SessionEnd (port ${ctx.hookPort}) + PostToolUse DB-mutation reminder`;
229
+ const lastPort = Number(ctx.hookPort) + Number(ctx.hookPortRange) - 1;
230
+ const primeNote = ctx.installPrime
231
+ ? `UserPromptSubmit prime (off switch: create ${join(hooksDir, PRIME_DISABLE_FILE).replace(/\\/g, '/')})`
232
+ : 'prime skipped (--no-prime)';
233
+ return `Hooks: Stop + PreCompact/SessionEnd checkpoint + ${primeNote} + PostToolUse DB-mutation reminder\n` +
234
+ ` scripts in ${hooksDir.replace(/\\/g, '/')} find this session's sidecar on ports ${ctx.hookPort}–${lastPort} at run time`;
186
235
  },
187
236
 
188
- diagnose(ctx: SetupContext): DiagnosticResult[] {
237
+ async diagnose(ctx: SetupContext): Promise<DiagnosticResult[]> {
189
238
  const results: DiagnosticResult[] = [];
239
+ const rerun = `Re-run: awm setup claude-code${ctx.isGlobal ? ' --global' : ''}`;
190
240
 
191
241
  // Check MCP config — Claude Code reads from both locations, check all
192
242
  const globalPath = join(homedir(), '.mcp.json');
@@ -194,36 +244,36 @@ const adapter: CLIAdapter = {
194
244
  const candidates = [projectPath, globalPath];
195
245
 
196
246
  let foundConfig = false;
247
+ let configuredEnv: Record<string, string> = {};
197
248
  for (const mcpJsonPath of candidates) {
198
- if (!existsSync(mcpJsonPath)) continue;
199
- try {
200
- const config = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
201
- if (config.mcpServers?.['agent-working-memory']) {
202
- results.push({ check: 'MCP config', status: 'ok', message: `AWM registered in ${mcpJsonPath}` });
203
- foundConfig = true;
204
- break;
205
- }
206
- } catch { /* not valid JSON, skip */ }
249
+ const entry = readJson(mcpJsonPath)?.mcpServers?.[SERVER_NAME];
250
+ if (!entry) continue;
251
+ configuredEnv = entry.env ?? {};
252
+ const agentNote = configuredEnv.AWM_AGENT_ID
253
+ ? ` (agent ${configuredEnv.AWM_AGENT_ID})`
254
+ : ' (no AWM_AGENT_ID — the server derives it from the directory)';
255
+ results.push({ check: 'MCP config', status: 'ok', message: `AWM registered in ${mcpJsonPath}${agentNote}` });
256
+ const missing = ['AWM_RERANK2', 'AWM_RERANK_WINDOW', 'AWM_RERANK_TAGS'].filter(k => !(k in configuredEnv));
257
+ if (missing.length) {
258
+ results.push({
259
+ check: 'Recommended env',
260
+ status: 'warn',
261
+ message: `not set: ${missing.join(', ')} (the measured-good retrieval configuration)`,
262
+ fix: `${rerun} — adds them; every other env value you have is kept`,
263
+ });
264
+ }
265
+ foundConfig = true;
266
+ break;
207
267
  }
208
268
  if (!foundConfig) {
209
- results.push({
210
- check: 'MCP config',
211
- status: 'fail',
212
- message: 'No AWM MCP config found',
213
- fix: `Run: awm setup claude-code${ctx.isGlobal ? ' --global' : ''}`,
214
- });
269
+ results.push({ check: 'MCP config', status: 'fail', message: 'No AWM MCP config found', fix: `Run: awm setup claude-code${ctx.isGlobal ? ' --global' : ''}` });
215
270
  }
216
271
 
217
272
  // Check MCP entrypoint
218
273
  if (ctx.hasDist) {
219
274
  results.push({ check: 'MCP entrypoint', status: 'ok', message: `dist/mcp.js exists` });
220
275
  } else {
221
- results.push({
222
- check: 'MCP entrypoint',
223
- status: 'warn',
224
- message: 'dist/mcp.js not found — using dev mode (npx tsx)',
225
- fix: 'Run: npm run build',
226
- });
276
+ results.push({ check: 'MCP entrypoint', status: 'warn', message: 'dist/mcp.js not found — using dev mode (npx tsx)', fix: 'Run: npm run build' });
227
277
  }
228
278
 
229
279
  // Check database
@@ -240,38 +290,86 @@ const adapter: CLIAdapter = {
240
290
  if (existsSync(claudeMdPath)) {
241
291
  const content = readFileSync(claudeMdPath, 'utf-8');
242
292
  if (content.includes('## Memory (AWM)')) {
243
- results.push({ check: 'Instructions', status: 'ok', message: `CLAUDE.md has AWM section` });
244
- } else {
293
+ const marked = content.includes('AWM:GENERATED:BEGIN');
245
294
  results.push({
246
295
  check: 'Instructions',
247
- status: 'warn',
248
- message: `CLAUDE.md exists but missing AWM section`,
249
- fix: `Run: awm setup claude-code${ctx.isGlobal ? ' --global' : ''}`,
296
+ status: 'ok',
297
+ message: `CLAUDE.md has AWM section${marked ? '' : ' (hand-maintained, predates generated markers — awm setup leaves it alone)'}`,
250
298
  });
299
+ } else {
300
+ results.push({ check: 'Instructions', status: 'warn', message: `CLAUDE.md exists but missing AWM section`, fix: rerun });
251
301
  }
252
302
  } else {
253
303
  results.push({ check: 'Instructions', status: 'warn', message: `CLAUDE.md not found` });
254
304
  }
255
305
 
256
- // Check hooks
306
+ // Check hooks — scripts present and current, settings pointing at them, no legacy curl
307
+ const hooksDir = join(homedir(), '.claude', 'hooks');
257
308
  const settingsPath = join(homedir(), '.claude', 'settings.json');
258
- if (existsSync(settingsPath)) {
259
- try {
260
- const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
261
- const hasHooks = settings.hooks?.PreCompact && settings.hooks?.SessionEnd;
262
- const hasMutationHook = JSON.stringify(settings.hooks?.PostToolUse || []).includes('awm-db-mutation-reminder');
263
- if (hasHooks && hasMutationHook) {
264
- results.push({ check: 'Hooks', status: 'ok', message: 'PreCompact + SessionEnd + DB-mutation reminder configured' });
265
- } else if (hasHooks) {
266
- results.push({ check: 'Hooks', status: 'warn', message: 'Checkpoint hooks ok; DB-mutation reminder missing (re-run awm setup)' });
267
- } else {
268
- results.push({ check: 'Hooks', status: 'warn', message: 'Hooks partially configured' });
269
- }
270
- } catch {
271
- results.push({ check: 'Hooks', status: 'warn', message: 'settings.json is not valid JSON' });
309
+ const settings = readJson(settingsPath);
310
+ if (!settings) {
311
+ results.push({
312
+ check: 'Hooks',
313
+ status: 'warn',
314
+ message: existsSync(settingsPath) ? 'settings.json is not valid JSON' : 'No hooks configured (auto-checkpoint and prime disabled)',
315
+ fix: rerun,
316
+ });
317
+ } else {
318
+ const hooksText = JSON.stringify(settings.hooks ?? {});
319
+ const has = (ev: string, needle: string) => JSON.stringify(settings.hooks?.[ev] ?? []).includes(needle);
320
+ const legacyCurl = /curl[^"]*\/hooks\/checkpoint/.test(hooksText);
321
+ const record = readJson(join(hooksDir, 'awm-hooks.json')) as HooksRecord | null;
322
+ const scriptsPresent = HOOK_SCRIPTS.every(h => existsSync(join(hooksDir, h.file)));
323
+ const checkpointWired = has('PreCompact', 'awm-checkpoint.cjs') && has('SessionEnd', 'awm-checkpoint.cjs');
324
+ const primeWired = has('UserPromptSubmit', 'awm-prime.cjs');
325
+ const primeDisabled = existsSync(join(hooksDir, PRIME_DISABLE_FILE));
326
+ const mutationWired = has('PostToolUse', 'awm-db-mutation-reminder');
327
+
328
+ if (legacyCurl || !scriptsPresent || !checkpointWired) {
329
+ results.push({
330
+ check: 'Hooks',
331
+ status: 'warn',
332
+ message: legacyCurl
333
+ ? 'checkpoint hooks are the pre-0.14.6 inline curl (fixed port 8401/8402, secret baked into settings.json)'
334
+ : !scriptsPresent ? 'hook scripts missing from ~/.claude/hooks' : 'checkpoint hooks not wired to awm-checkpoint.cjs',
335
+ fix: rerun,
336
+ });
337
+ } else if (record && record.version !== AWM_HOOKS_VERSION) {
338
+ results.push({ check: 'Hooks', status: 'warn', message: `hook scripts v${record.version}; current is v${AWM_HOOKS_VERSION}`, fix: rerun });
339
+ } else {
340
+ results.push({ check: 'Hooks', status: 'ok', message: `PreCompact + SessionEnd checkpoint via awm-checkpoint.cjs (hooks v${record?.version ?? AWM_HOOKS_VERSION})` });
272
341
  }
342
+ if (!primeWired) {
343
+ results.push({ check: 'Prime hook', status: 'warn', message: 'not installed — memory reaches the agent only when it chooses to call memory_recall', fix: `${rerun} (without --no-prime)` });
344
+ } else if (primeDisabled) {
345
+ results.push({ check: 'Prime hook', status: 'warn', message: `installed but OFF (${PRIME_DISABLE_FILE} present)`, fix: `Delete ${join(hooksDir, PRIME_DISABLE_FILE)} to turn it on` });
346
+ } else {
347
+ results.push({ check: 'Prime hook', status: 'ok', message: 'UserPromptSubmit → awm-prime.cjs (memories injected per prompt when confident)' });
348
+ }
349
+ if (!mutationWired) {
350
+ results.push({ check: 'DB-mutation reminder', status: 'warn', message: 'PostToolUse reminder missing', fix: rerun });
351
+ }
352
+ }
353
+
354
+ // Live sidecars — the thing the hooks actually talk to
355
+ const basePort = parseInt(configuredEnv.AWM_HOOK_PORT ?? ctx.hookPort, 10) || 8401;
356
+ const range = Math.max(1, parseInt(configuredEnv.AWM_HOOK_PORT_RANGE ?? ctx.hookPortRange, 10) || 10);
357
+ const probes = await Promise.all(Array.from({ length: range }, (_, i) => probeSidecar(basePort + i)));
358
+ const live = probes.filter((s): s is LiveSidecar => s !== null);
359
+ if (live.length === 0) {
360
+ results.push({ check: 'Sidecars', status: 'warn', message: `none listening on ${basePort}–${basePort + range - 1} (normal when no Claude Code session is open)` });
273
361
  } else {
274
- results.push({ check: 'Hooks', status: 'warn', message: 'No hooks configured (auto-checkpoint disabled)' });
362
+ const summary = live.map(s => `${s.port}=${s.agentId ?? '?'}@${s.version}${s.pid ? ` pid ${s.pid}` : ''}`).join(', ');
363
+ results.push({ check: 'Sidecars', status: 'ok', message: `${live.length} live: ${summary}` });
364
+ const agents = [...new Set(live.map(s => s.agentId))];
365
+ const configuredAgent = configuredEnv.AWM_AGENT_ID ?? ctx.agentId;
366
+ if (!agents.includes(configuredAgent)) {
367
+ results.push({
368
+ check: 'Sidecar match',
369
+ status: 'warn',
370
+ message: `no live sidecar serves agent "${configuredAgent}" — hooks from this directory fail open (live agents: ${agents.join(', ')})`,
371
+ });
372
+ }
275
373
  }
276
374
 
277
375
  return results;