@anhlp/alp-client 0.0.0-stage → 0.10.0

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/src/doctor.js ADDED
@@ -0,0 +1,375 @@
1
+ import { access, chmod, constants, lstat, readdir, readFile, unlink, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { runCommand } from './command.js';
4
+ import { daemonPaths, lockAlive, readLock } from './index.js';
5
+ import { installedProgram, serviceFor } from './service.js';
6
+ import { jsonObject, agentSources, resolveAgent } from '@anhlp/alp-core/resolver';
7
+ import { loadHooks } from '@anhlp/alp-core/hooks';
8
+ import { isTrusted } from '@anhlp/alp-core/trust';
9
+ import { listProviders } from '@anhlp/alp-core/providers';
10
+ import { settingsWarnings, validateSettings, validateUserSettings } from '@anhlp/alp-core/validation';
11
+
12
+ /**
13
+ * `alp doctor` (ALPD §36): checks what ALP needs from this machine and project, and
14
+ * what earlier runs may have left behind. Every check reports one of
15
+ * - ok: nothing to do;
16
+ * - info: worth knowing, nothing is wrong;
17
+ * - warn: something to fix, and ALP still works;
18
+ * - fail: ALP cannot work as configured.
19
+ * A check that `--fix` can repair carries a fix. Fixes only remove what nothing uses
20
+ * (stale locks, merged branches, entries for vanished projects) or tighten modes;
21
+ * they never touch running work or unmerged branches.
22
+ */
23
+
24
+ const firstLine = text => text.trim().split('\n')[0] ?? '';
25
+
26
+ /** The executable `command` names, as a spawn without a shell would find it. */
27
+ async function executable(command, env) {
28
+ const candidates = path.isAbsolute(command) ? [command] : (env.PATH ?? '').split(path.delimiter).filter(Boolean).map(directory => path.join(directory, command));
29
+ for (const candidate of candidates) {
30
+ try { await access(candidate, constants.X_OK); if ((await lstat(candidate)).isDirectory()) continue; return candidate; } catch {}
31
+ }
32
+ return undefined;
33
+ }
34
+
35
+ /** How to tell whether each native runtime is logged in. */
36
+ const RUNTIMES = [
37
+ { kind: 'codex', variable: 'ALP_CODEX_BIN', login: ['login', 'status'], loggedIn: result => result.code === 0, hint: 'codex login' },
38
+ {
39
+ kind: 'claude', variable: 'ALP_CLAUDE_BIN', login: ['auth', 'status'], hint: 'claude auth login',
40
+ loggedIn: result => { try { return JSON.parse(result.stdout).loggedIn === true; } catch { return result.code === 0; } },
41
+ },
42
+ {
43
+ kind: 'antigravity', command: 'agy', variable: 'ALP_ANTIGRAVITY_BIN', login: ['models'], hint: 'agy and sign in',
44
+ loggedIn: result => result.code === 0,
45
+ },
46
+ ];
47
+
48
+ async function runtimeChecks({ env, run }) {
49
+ const checks = [];
50
+ for (const runtime of RUNTIMES) {
51
+ const command = env[runtime.variable] || runtime.command || runtime.kind;
52
+ const found = await executable(command, env);
53
+ if (!found) {
54
+ checks.push({ id: runtime.kind, status: 'warn', summary: `${command} not found on PATH; ${runtime.kind} agents cannot run (set ${runtime.variable} to its path)` });
55
+ continue;
56
+ }
57
+ const version = await run(found, ['--version'], { env });
58
+ if (version.code !== 0) {
59
+ checks.push({ id: runtime.kind, status: 'fail', summary: `${found} --version failed: ${firstLine(version.stderr || version.stdout) || `exit ${version.code}`}` });
60
+ continue;
61
+ }
62
+ const login = await run(found, runtime.login, { env });
63
+ checks.push(runtime.loggedIn(login)
64
+ ? { id: runtime.kind, status: 'ok', summary: `${firstLine(version.stdout)} at ${found}, logged in` }
65
+ : { id: runtime.kind, status: 'fail', summary: `${firstLine(version.stdout)} at ${found} is not logged in; run ${runtime.hint}` });
66
+ }
67
+ // One runtime is enough; with neither, nothing can run.
68
+ if (checks.every(check => check.status !== 'ok')) for (const check of checks) if (check.status === 'warn') check.status = 'fail';
69
+ return checks;
70
+ }
71
+
72
+ /** An installed alpd service whose node or alpd.js moved can no longer start alpd. */
73
+ async function serviceCheck(home, env) {
74
+ const service = serviceFor({ home, env });
75
+ const program = service && await installedProgram(service);
76
+ if (!program) return undefined;
77
+ const missing = [];
78
+ for (const file of program) if (!await lstat(file).then(() => true, () => false)) missing.push(file);
79
+ return missing.length
80
+ ? { id: 'service', status: 'fail', summary: `the ${service.kind} service ${service.name} runs files that are gone; run alp daemon install again`, details: missing }
81
+ : { id: 'service', status: 'ok', summary: `${service.kind} runs alpd as ${service.name}` };
82
+ }
83
+
84
+ async function daemonCheck(home) {
85
+ const paths = daemonPaths(home);
86
+ const lock = await readLock(home);
87
+ const exists = file => lstat(file).then(() => true, () => false);
88
+ if (lockAlive(lock)) return { id: 'alpd', status: 'ok', summary: `running (pid ${lock.pid}, ${lock.version})${lock.ready ? '' : ', starting'}` };
89
+ const stale = [];
90
+ if (await exists(paths.lock)) stale.push(paths.lock);
91
+ if (await exists(paths.socket)) stale.push(paths.socket);
92
+ const crashed = await exists(path.join(home, 'state', 'alpd.running'));
93
+ const after = crashed ? '; the last alpd stopped unexpectedly, and the next start continues its work' : '';
94
+ if (!stale.length) return { id: 'alpd', status: crashed ? 'info' : 'ok', summary: `not running${after}` };
95
+ return {
96
+ id: 'alpd', status: 'warn', summary: `not running, and left ${stale.map(file => path.basename(file)).join(' and ')} behind${after}`,
97
+ fix: {
98
+ describe: `remove ${stale.join(', ')}`,
99
+ async apply() {
100
+ // Check again: an alpd may have started meanwhile.
101
+ if (lockAlive(await readLock(home))) return 'skipped: alpd started meanwhile';
102
+ for (const file of stale) await unlink(file).catch(() => {});
103
+ return `removed ${stale.length} stale file${stale.length > 1 ? 's' : ''}`;
104
+ },
105
+ },
106
+ };
107
+ }
108
+
109
+ /** Files and directories under ALP_HOME that hold logs, state or settings. */
110
+ async function privateEntries(home) {
111
+ const entries = [home];
112
+ for (const name of ['settings.json', 'alpd.lock', 'alpd.json', 'role-skills.json']) entries.push(path.join(home, name));
113
+ for (const directory of ['state', 'runs', 'logs', 'boards', 'worktrees', 'copies']) {
114
+ const full = path.join(home, directory);
115
+ entries.push(full);
116
+ if (directory === 'worktrees' || directory === 'copies') continue;
117
+ for (const name of await readdir(full).catch(() => [])) entries.push(path.join(full, name));
118
+ }
119
+ return entries;
120
+ }
121
+
122
+ async function permissionsCheck(home) {
123
+ const open = [];
124
+ for (const entry of await privateEntries(home)) {
125
+ const info = await lstat(entry).catch(() => undefined);
126
+ if (!info || info.isSymbolicLink() || !(info.mode & 0o077)) continue;
127
+ open.push({ entry, mode: info.mode & 0o777, directory: info.isDirectory() });
128
+ }
129
+ if (!open.length) return { id: 'permissions', status: 'ok', summary: `${home} is private to you` };
130
+ return {
131
+ id: 'permissions', status: 'warn',
132
+ summary: `${open.length} entr${open.length > 1 ? 'ies' : 'y'} under ${home} readable by others; they hold task briefs, results and settings`,
133
+ details: open.map(item => `${item.mode.toString(8).padStart(4, '0')} ${item.entry}`),
134
+ fix: {
135
+ describe: 'remove group and other access from them',
136
+ async apply() {
137
+ for (const item of open) await chmod(item.entry, item.mode & 0o700).catch(() => {});
138
+ return `tightened ${open.length} entr${open.length > 1 ? 'ies' : 'y'}`;
139
+ },
140
+ },
141
+ };
142
+ }
143
+
144
+ async function userSettingsCheck(home) {
145
+ const file = path.join(home, 'settings.json');
146
+ try {
147
+ const text = await readFile(file, 'utf8').catch(error => { if (error.code === 'ENOENT') return undefined; throw error; });
148
+ if (text === undefined) return { id: 'user settings', status: 'ok', summary: `none (${file})` };
149
+ const settings = JSON.parse(text);
150
+ validateUserSettings(settings, file);
151
+ const warnings = settingsWarnings(settings, 'user');
152
+ return warnings.length
153
+ ? { id: 'user settings', status: 'warn', summary: `${file}: ${warnings.length} key${warnings.length > 1 ? 's' : ''} ALP ignores`, details: warnings }
154
+ : { id: 'user settings', status: 'ok', summary: file };
155
+ } catch (error) {
156
+ return { id: 'user settings', status: 'fail', summary: `alpd ignores ${file}: ${error.message.replace(`${file}: `, '')}` };
157
+ }
158
+ }
159
+
160
+ async function projectChecks(project, home) {
161
+ const checks = [];
162
+ const file = path.join(project, '.alp', 'settings.json');
163
+ let settings;
164
+ try {
165
+ settings = await jsonObject(file, 'INVALID_SETTINGS');
166
+ validateSettings(settings, file);
167
+ const warnings = settingsWarnings(settings, 'project');
168
+ checks.push(warnings.length
169
+ ? { id: 'settings', status: 'warn', summary: `${file}: ${warnings.length} key${warnings.length > 1 ? 's' : ''} ALP ignores`, details: warnings }
170
+ : { id: 'settings', status: 'ok', summary: file });
171
+ } catch (error) {
172
+ checks.push({ id: 'settings', status: 'fail', summary: error.message });
173
+ return checks;
174
+ }
175
+ const sources = await agentSources(project, { library: home });
176
+ const agents = [...sources.keys()].sort((a, b) => a.localeCompare(b));
177
+ const broken = [];
178
+ const shadows = [];
179
+ const hooks = new Map();
180
+ // An override replaces the whole agent, so it misses later updates to the one it replaces.
181
+ for (const [agent, { source, overrides }] of sources) if (overrides) shadows.push(`${agent}: the ${source}'s copy replaces the ${overrides === 'builtin' ? 'built-in' : 'library'} agent`);
182
+ for (const agent of agents) {
183
+ try {
184
+ const resolved = await resolveAgent(project, { agent, library: home });
185
+ for (const hook of await loadHooks(resolved)) hooks.set(hook.path, { ...hook, agents: [...(hooks.get(hook.path)?.agents ?? []), agent] });
186
+ for (const skill of resolved.skills) if (!skill.path.startsWith(home + path.sep)) {
187
+ const library = path.join(home, 'skills', skill.name, 'SKILL.md');
188
+ if (await lstat(library).then(() => true, () => false)) shadows.push(`${agent}: ${skill.name} from the project replaces the library's`);
189
+ }
190
+ } catch (error) { broken.push(`${agent}: ${error.message}`); }
191
+ }
192
+ if (!agents.length) checks.push({ id: 'agents', status: 'fail', summary: 'no agents: neither ALP\'s built-ins nor the library nor the project define one' });
193
+ else if (broken.length) checks.push({ id: 'agents', status: 'fail', summary: `${broken.length} of ${agents.length} agents cannot start`, details: broken });
194
+ else checks.push({ id: 'agents', status: shadows.length ? 'info' : 'ok', summary: `${agents.length} agents resolve: ${agents.join(', ')}`, ...(shadows.length ? { details: shadows } : {}) });
195
+ if (hooks.size) checks.push(await hooksCheck(project, home, [...hooks.values()]));
196
+ if (settings.defaultAgent && !agents.includes(settings.defaultAgent)) checks.push({ id: 'agents', status: 'fail', summary: `defaultAgent ${settings.defaultAgent} is not an agent of this project` });
197
+ return checks;
198
+ }
199
+
200
+ /** The command a hook starts, when it is a plain program name or path: the first word of its shell command. */
201
+ const program = command => /^\s*([\w./-]+)/.exec(command)?.[1];
202
+ const SHELL_WORDS = new Set(['cd', 'test', '[', 'echo', 'exit', 'true', 'false', 'set', 'export', 'if', 'for', 'while', 'exec', 'env', 'command', 'printf', 'read', 'source', '.']);
203
+
204
+ /** Hooks the agents use: whether the project's may run, and whether their programs exist (ALPD §45). */
205
+ async function hooksCheck(project, home, hooks) {
206
+ const missing = [];
207
+ for (const hook of hooks) {
208
+ const name = program(hook.command);
209
+ if (!name || SHELL_WORDS.has(name)) continue;
210
+ const candidates = name.includes('/') ? [path.resolve(project, name)] : (process.env.PATH ?? '').split(path.delimiter).filter(Boolean).map(directory => path.join(directory, name));
211
+ let found = false;
212
+ for (const candidate of candidates) if (await access(candidate, constants.X_OK).then(() => true, () => false)) { found = true; break; }
213
+ if (!found) missing.push(`${hook.name} (${hook.event}, used by ${hook.agents.join(', ')}): ${name} is not found`);
214
+ }
215
+ const fromProject = hooks.filter(hook => hook.project);
216
+ const trusted = fromProject.length ? await isTrusted(home, project) : true;
217
+ const details = [...missing, ...(!trusted ? [`not trusted yet: ${fromProject.map(hook => hook.name).join(', ')}; alpd asks the first time one runs, or run alp trust`] : [])];
218
+ return {
219
+ id: 'hooks', status: missing.length ? 'warn' : trusted ? 'ok' : 'info',
220
+ summary: `${hooks.length} hook${hooks.length > 1 ? 's' : ''}${fromProject.length ? `, ${fromProject.length} from the project${trusted ? ', which the user trusts' : ', not trusted yet'}` : ''}${missing.length ? `; ${missing.length} cannot start` : ''}`,
221
+ ...(details.length ? { details } : {}),
222
+ };
223
+ }
224
+
225
+ /**
226
+ * ACP providers of the library (ALPD §46): whether their commands exist, and what ALP
227
+ * cannot do for an agent it reaches through ACP.
228
+ */
229
+ async function providersCheck(home, env) {
230
+ let providers;
231
+ try { providers = await listProviders(home); } catch (error) { return { id: 'providers', status: 'fail', summary: error.message }; }
232
+ if (!providers.length) return undefined;
233
+ const missing = [];
234
+ for (const provider of providers) if (!await executable(provider.command, { ...env, ...provider.env })) missing.push(`${provider.id}: ${provider.command} is not found`);
235
+ return {
236
+ id: 'providers', status: 'warn',
237
+ summary: `${providers.length} ACP provider${providers.length > 1 ? 's' : ''} (${providers.map(provider => provider.id).join(', ')})${missing.length ? `; ${missing.length} cannot start` : ''}`,
238
+ details: [...missing,
239
+ 'ALP has no sandbox around an ACP agent: it holds the session\'s mode only by answering the permission requests the agent sends, so a command the agent runs without asking runs unchecked',
240
+ 'ALP cannot steer an ACP agent mid-turn (mail waits for the turn to end), resumes its sessions only when it supports session/load, and cannot recall them or give them review copies'],
241
+ };
242
+ }
243
+
244
+ async function librarySkillsCheck(home) {
245
+ const file = path.join(home, 'role-skills.json');
246
+ let roles;
247
+ try {
248
+ const text = await readFile(file, 'utf8').catch(error => { if (error.code === 'ENOENT') return undefined; throw error; });
249
+ if (text === undefined) return undefined;
250
+ roles = JSON.parse(text);
251
+ } catch (error) { return { id: 'skills', status: 'fail', summary: `${file}: ${error.message}` }; }
252
+ if (!roles || typeof roles !== 'object' || Array.isArray(roles)) return { id: 'skills', status: 'fail', summary: `${file}: expected an object of role names to skill lists` };
253
+ const missing = [];
254
+ for (const [role, names] of Object.entries(roles)) {
255
+ if (!Array.isArray(names)) { missing.push(`${role}: not a list`); continue; }
256
+ for (const name of names) {
257
+ if (typeof name !== 'string' || !await lstat(path.join(home, 'skills', name, 'SKILL.md')).then(() => true, () => false)) missing.push(`${role}: ${name}`);
258
+ }
259
+ }
260
+ return missing.length
261
+ ? { id: 'skills', status: 'warn', summary: `${file} lists ${missing.length} skill${missing.length > 1 ? 's' : ''} the library lacks; agents start without them`, details: missing }
262
+ : { id: 'skills', status: 'ok', summary: `the library's skills exist (${file})` };
263
+ }
264
+
265
+ /** The live book alpd continues at start: entries whose project is gone can never continue. */
266
+ async function liveCheck(home, running) {
267
+ const file = path.join(home, 'state', 'live.json');
268
+ let entries;
269
+ try { entries = JSON.parse(await readFile(file, 'utf8')); } catch { return undefined; }
270
+ if (!Array.isArray(entries)) return { id: 'live work', status: 'warn', summary: `${file} is not a list; alpd ignores it` };
271
+ const gone = [];
272
+ for (const entry of entries) {
273
+ if (typeof entry?.project !== 'string' || !await lstat(entry.project).then(() => true, () => false)) gone.push(entry);
274
+ }
275
+ if (!gone.length) return { id: 'live work', status: entries.length && !running ? 'info' : 'ok', summary: entries.length ? `${entries.length} assignment${entries.length > 1 ? 's' : ''} ${running ? 'running' : 'waiting for alpd to continue'}` : 'no running assignments' };
276
+ return {
277
+ id: 'live work', status: 'warn',
278
+ summary: `${gone.length} interrupted assignment${gone.length > 1 ? 's' : ''} belong to projects that no longer exist`,
279
+ details: gone.map(entry => `${entry?.agent} ${entry?.assignmentId} in ${entry?.project}`),
280
+ fix: running ? undefined : {
281
+ describe: `drop them from ${file}`,
282
+ async apply() {
283
+ const current = JSON.parse(await readFile(file, 'utf8'));
284
+ const ids = new Set(gone.map(entry => entry?.assignmentId));
285
+ const kept = current.filter(entry => !ids.has(entry?.assignmentId));
286
+ await writeFile(file, JSON.stringify(kept, null, 2) + '\n', { mode: 0o600 });
287
+ return `dropped ${current.length - kept.length}`;
288
+ },
289
+ },
290
+ ...(running ? { hint: 'stop alpd to drop them' } : {}),
291
+ };
292
+ }
293
+
294
+ /** Worktrees git lost track of, and alp/* branches: merged ones are dead weight, unmerged ones hold kept work. */
295
+ async function branchCheck(project, home, run) {
296
+ const git = args => run('git', args, { cwd: project });
297
+ const top = await git(['rev-parse', '--show-toplevel']);
298
+ if (top.code !== 0) return [];
299
+ const checks = [];
300
+ const worktrees = (await git(['worktree', 'list', '--porcelain'])).stdout.split('\n\n').filter(Boolean);
301
+ const prunable = worktrees.filter(block => /^prunable/m.test(block)).map(block => block.match(/^worktree (.*)$/m)?.[1]).filter(Boolean);
302
+ if (prunable.length) checks.push({
303
+ id: 'worktrees', status: 'warn', summary: `git still lists ${prunable.length} worktree${prunable.length > 1 ? 's' : ''} that no longer exist`, details: prunable,
304
+ fix: { describe: 'git worktree prune', async apply() { await git(['worktree', 'prune']); return 'pruned'; } },
305
+ });
306
+ const checkedOut = new Set(worktrees.map(block => block.match(/^branch refs\/heads\/(.*)$/m)?.[1]).filter(Boolean));
307
+ let live = [];
308
+ try { live = JSON.parse(await readFile(path.join(home, 'state', 'live.json'), 'utf8')); } catch {}
309
+ const inUse = new Set([...checkedOut, ...(Array.isArray(live) ? live.map(entry => entry?.worktree?.branch).filter(Boolean) : [])]);
310
+ const branches = (await git(['for-each-ref', '--format=%(refname:short)', 'refs/heads/alp/'])).stdout.split('\n').filter(Boolean);
311
+ if (!branches.length) return checks;
312
+ const merged = new Set((await git(['branch', '--format=%(refname:short)', '--merged', 'HEAD', '--list', 'alp/*'])).stdout.split('\n').filter(Boolean));
313
+ const spare = branches.filter(branch => merged.has(branch) && !inUse.has(branch));
314
+ const kept = branches.filter(branch => !merged.has(branch) && !inUse.has(branch));
315
+ if (spare.length) checks.push({
316
+ id: 'branches', status: 'warn', summary: `${spare.length} alp/* branch${spare.length > 1 ? 'es are' : ' is'} already merged into HEAD`, details: spare,
317
+ fix: {
318
+ describe: `delete ${spare.length > 1 ? 'them' : 'it'} with git branch -d`,
319
+ async apply() {
320
+ let deleted = 0;
321
+ for (const branch of spare) if ((await git(['branch', '-d', branch])).code === 0) deleted++;
322
+ return `deleted ${deleted}`;
323
+ },
324
+ },
325
+ });
326
+ if (kept.length) checks.push({ id: 'branches', status: 'info', summary: `${kept.length} alp/* branch${kept.length > 1 ? 'es hold' : ' holds'} unmerged work from finished or interrupted assignments; merge or delete ${kept.length > 1 ? 'them' : 'it'} yourself`, details: kept });
327
+ if (!spare.length && !kept.length) checks.push({ id: 'branches', status: 'ok', summary: `${branches.length} alp/* branch${branches.length > 1 ? 'es' : ''} in use` });
328
+ return checks;
329
+ }
330
+
331
+ /**
332
+ * Runs every check. `project` is an ALP project root or undefined; `sandbox` says whether
333
+ * Claude can sandbox Bash here (undefined when unknown); `daemonEntry` is alpd's path.
334
+ */
335
+ export { runCommand };
336
+
337
+ export async function diagnose({ home, project, env = process.env, run = runCommand, sandbox, daemonEntry } = {}) {
338
+ const checks = [];
339
+ if (daemonEntry) {
340
+ checks.push(await lstat(daemonEntry).then(() => ({ id: 'build', status: 'ok', summary: daemonEntry }), () => ({ id: 'build', status: 'fail', summary: `alpd is not built (${daemonEntry}); run npm run build` })));
341
+ }
342
+ checks.push(...await runtimeChecks({ env, run }));
343
+ const daemon = await daemonCheck(home);
344
+ checks.push(daemon);
345
+ const service = await serviceCheck(home, env);
346
+ if (service) checks.push(service);
347
+ const running = daemon.status === 'ok' && daemon.summary.startsWith('running');
348
+ if (sandbox !== undefined) checks.push(sandbox
349
+ ? { id: 'sandbox', status: 'ok', summary: 'Claude sessions run Bash in an OS sandbox that holds their mode' }
350
+ : { id: 'sandbox', status: 'warn', summary: `no Bash sandbox for Claude (${process.platform === 'linux' ? 'install bubblewrap and socat' : 'Seatbelt is missing'}): read-only Claude agents rely on their permission rules alone, and review copies on Claude cannot start` });
351
+ checks.push(await permissionsCheck(home));
352
+ checks.push(await userSettingsCheck(home));
353
+ const skills = await librarySkillsCheck(home);
354
+ if (skills) checks.push(skills);
355
+ const providers = await providersCheck(home, env);
356
+ if (providers) checks.push(providers);
357
+ const live = await liveCheck(home, running);
358
+ if (live) checks.push(live);
359
+ if (project) {
360
+ checks.push(...await projectChecks(project, home));
361
+ checks.push(...await branchCheck(project, home, run));
362
+ }
363
+ return checks;
364
+ }
365
+
366
+ /** Applies the fixes of the given checks in order; returns what each did. */
367
+ export async function repair(checks) {
368
+ const done = [];
369
+ for (const check of checks) {
370
+ if (!check.fix) continue;
371
+ try { done.push({ id: check.id, result: await check.fix.apply() }); }
372
+ catch (error) { done.push({ id: check.id, result: `failed: ${error.message}` }); }
373
+ }
374
+ return done;
375
+ }