@anhlp/alp-core 0.0.0-beta.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/LICENSE +202 -0
- package/NOTICE +7 -0
- package/README.md +7 -0
- package/dist/adapter.d.ts +20 -0
- package/dist/adapter.js +30 -0
- package/dist/atomic.d.ts +2 -0
- package/dist/atomic.js +28 -0
- package/dist/backup.d.ts +27 -0
- package/dist/backup.js +582 -0
- package/dist/beads.d.ts +65 -0
- package/dist/beads.js +213 -0
- package/dist/delegation.d.ts +2 -0
- package/dist/delegation.js +32 -0
- package/dist/errors.d.ts +4 -0
- package/dist/errors.js +11 -0
- package/dist/formulas.d.ts +25 -0
- package/dist/formulas.js +201 -0
- package/dist/hook-run.d.ts +44 -0
- package/dist/hook-run.js +60 -0
- package/dist/hooks.d.ts +35 -0
- package/dist/hooks.js +30 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.js +63 -0
- package/dist/init.d.ts +30 -0
- package/dist/init.js +88 -0
- package/dist/library-edit.d.ts +90 -0
- package/dist/library-edit.js +315 -0
- package/dist/library.d.ts +28 -0
- package/dist/library.js +108 -0
- package/dist/log.d.ts +37 -0
- package/dist/log.js +156 -0
- package/dist/migrations.d.ts +9 -0
- package/dist/migrations.js +95 -0
- package/dist/models.d.ts +11 -0
- package/dist/models.js +44 -0
- package/dist/permissions.d.ts +14 -0
- package/dist/permissions.js +229 -0
- package/dist/profiles.d.ts +27 -0
- package/dist/profiles.js +122 -0
- package/dist/promptsafe.d.ts +1 -0
- package/dist/promptsafe.js +13 -0
- package/dist/providers.d.ts +90 -0
- package/dist/providers.js +101 -0
- package/dist/resolver.d.ts +41 -0
- package/dist/resolver.js +240 -0
- package/dist/safe-path.d.ts +2 -0
- package/dist/safe-path.js +24 -0
- package/dist/shell.d.ts +16 -0
- package/dist/shell.js +22 -0
- package/dist/state-schema.d.ts +9 -0
- package/dist/state-schema.js +54 -0
- package/dist/task-rows.d.ts +47 -0
- package/dist/task-rows.js +45 -0
- package/dist/tasks.d.ts +128 -0
- package/dist/tasks.js +835 -0
- package/dist/teams.d.ts +69 -0
- package/dist/teams.js +159 -0
- package/dist/template-path.d.ts +2 -0
- package/dist/template-path.js +10 -0
- package/dist/trust.d.ts +8 -0
- package/dist/trust.js +47 -0
- package/dist/types.d.ts +51 -0
- package/dist/upgrade.d.ts +10 -0
- package/dist/upgrade.js +200 -0
- package/dist/user-settings.d.ts +6 -0
- package/dist/user-settings.js +72 -0
- package/dist/validation.d.ts +17 -0
- package/dist/validation.js +295 -0
- package/dist/verify.d.ts +15 -0
- package/dist/verify.js +123 -0
- package/dist/workflow.d.ts +14 -0
- package/dist/workflow.js +24 -0
- package/package.json +309 -0
- package/src/adapter.d.ts +20 -0
- package/src/adapter.js +29 -0
- package/src/atomic.d.ts +2 -0
- package/src/atomic.js +28 -0
- package/src/backup.d.ts +27 -0
- package/src/backup.js +570 -0
- package/src/beads.js +236 -0
- package/src/delegation.js +28 -0
- package/src/errors.js +7 -0
- package/src/formulas.d.ts +25 -0
- package/src/formulas.js +202 -0
- package/src/hook-run.js +57 -0
- package/src/hooks.js +36 -0
- package/src/index.js +31 -0
- package/src/init.js +92 -0
- package/src/library-edit.js +346 -0
- package/src/library.js +117 -0
- package/src/log.d.ts +37 -0
- package/src/log.js +177 -0
- package/src/migrations.d.ts +9 -0
- package/src/migrations.js +111 -0
- package/src/models.js +45 -0
- package/src/permissions.d.ts +14 -0
- package/src/permissions.js +242 -0
- package/src/profiles.js +139 -0
- package/src/promptsafe.d.ts +1 -0
- package/src/promptsafe.js +19 -0
- package/src/providers.js +122 -0
- package/src/resolver.js +238 -0
- package/src/safe-path.js +20 -0
- package/src/shell.js +28 -0
- package/src/state-schema.d.ts +9 -0
- package/src/state-schema.js +60 -0
- package/src/task-rows.js +44 -0
- package/src/tasks.d.ts +128 -0
- package/src/tasks.js +881 -0
- package/src/teams.js +170 -0
- package/src/template-path.js +8 -0
- package/src/trust.js +59 -0
- package/src/types.d.ts +51 -0
- package/src/upgrade.js +195 -0
- package/src/user-settings.d.ts +6 -0
- package/src/user-settings.js +76 -0
- package/src/validation.d.ts +17 -0
- package/src/validation.js +302 -0
- package/src/verify.d.ts +15 -0
- package/src/verify.js +127 -0
- package/src/workflow.js +28 -0
- package/templates/ALP.md +52 -0
- package/templates/agents/lead/AGENT.md +90 -0
- package/templates/agents/main/AGENT.md +150 -0
- package/templates/agents/oracle/AGENT.md +44 -0
- package/templates/agents/peer/AGENT.md +55 -0
- package/templates/agents/reviewer/AGENT.md +80 -0
- package/templates/agents/supervisor/AGENT.md +72 -0
- package/templates/providers/antigravity.json +6 -0
- package/templates/providers/opencode.json +7 -0
- package/templates/role-skills.json +8 -0
- package/templates/skills/bug-loop/SKILL.md +44 -0
- package/templates/skills/bug-loop/references/test-proof.md +25 -0
- package/templates/skills/goal-griller/SKILL.md +49 -0
- package/templates/skills/prompt-leverage/SKILL.md +37 -0
- package/templates/skills/prompt-leverage/references/brief-template.md +27 -0
- package/templates/skills/sequence-execution-plan/SKILL.md +42 -0
- package/templates/skills/sequence-execution-plan/references/plan-template.md +22 -0
- package/templates/skills/smart-commits/SKILL.md +40 -0
- package/templates/skills/xia/SKILL.md +42 -0
- package/templates/teams/cafe/HOUSE_RULES.md +1 -0
- package/templates/teams/cafe/team.json +15 -0
- package/templates/teams/pho/HOUSE_RULES.md +1 -0
- package/templates/teams/pho/team.json +14 -0
package/src/teams.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { AlpError } from './errors.js';
|
|
4
|
+
import { builtinText, optionalText } from './resolver.js';
|
|
5
|
+
import { settingsKeys, unknownMessage } from './validation.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Teams (ALPD §42): a main agent, its members, who may delegate to whom, and the
|
|
9
|
+
* house rules every member follows. Phở and Cafe ship as built-in teams; the user's
|
|
10
|
+
* library (ALP_HOME/teams) and the project (.alp/teams) add teams or override one by
|
|
11
|
+
* its id, the later layer replacing the team whole.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const BUILTIN_TEAMS = ['pho', 'cafe'];
|
|
15
|
+
export const TEAM_SETTINGS = ['$schema', 'label', 'description', 'main', 'members', 'delegation', 'maxPeers', 'supervisor', 'oracleModels', 'oracleThinking', 'modelSetup'];
|
|
16
|
+
export const TEAM_ROLES = ['lead', 'peer', 'advisor', 'reviewer'];
|
|
17
|
+
const MEMBER_SETTINGS = ['role', 'model', 'thinking'];
|
|
18
|
+
const SUPERVISOR_SETTINGS = ['agent', 'model', 'thinking'];
|
|
19
|
+
|
|
20
|
+
/** Names of teams before they were renamed; settings and resumed sessions may still carry them. */
|
|
21
|
+
const aliases = { smart: 'pho', supervised: 'cafe' };
|
|
22
|
+
/** The team an id selects, accepting the old names. */
|
|
23
|
+
export const teamOf = id => aliases[id] ?? id;
|
|
24
|
+
|
|
25
|
+
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
26
|
+
const nonempty = value => typeof value === 'string' && value.trim().length > 0;
|
|
27
|
+
const validName = name => typeof name === 'string' && /^[\w.-]+$/.test(name) && name !== '.' && name !== '..';
|
|
28
|
+
|
|
29
|
+
function keys(value, known, check, where) {
|
|
30
|
+
const { unknown } = settingsKeys(value, known);
|
|
31
|
+
check(!unknown.length, unknown.length ? `${where}${unknownMessage(unknown[0], known)}` : '');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Checks a team.json; the delegation graph must name members and stay acyclic. */
|
|
35
|
+
export function validateTeam(config, source) {
|
|
36
|
+
const check = (ok, message) => { if (!ok) throw new AlpError('INVALID_TEAM', `${source}: ${message}`); };
|
|
37
|
+
check(object(config), 'expected an object');
|
|
38
|
+
keys(config, TEAM_SETTINGS, check, '');
|
|
39
|
+
if (config.modelSetup !== undefined) {
|
|
40
|
+
const setup = config.modelSetup;
|
|
41
|
+
check(object(setup) && ['default', 'custom'].includes(setup.mode), 'modelSetup.mode must be default or custom');
|
|
42
|
+
keys(setup, ['mode', 'preset'], check, 'modelSetup: ');
|
|
43
|
+
if (setup.mode === 'default') check(['claude', 'codex', 'opencode', 'antigravity', 'mix'].includes(setup.preset), 'modelSetup.preset must name a preset');
|
|
44
|
+
else check(setup.preset === undefined, 'custom modelSetup has no preset');
|
|
45
|
+
}
|
|
46
|
+
check(nonempty(config.label), 'label must be a nonempty string');
|
|
47
|
+
if (config.description !== undefined) check(typeof config.description === 'string', 'description must be a string');
|
|
48
|
+
check(validName(config.main), 'main must name an agent');
|
|
49
|
+
check(object(config.members), 'members must map agent names to { role, model, thinking }');
|
|
50
|
+
check(Object.hasOwn(config.members, config.main), `members must include main (${config.main})`);
|
|
51
|
+
for (const [name, member] of Object.entries(config.members)) {
|
|
52
|
+
const where = `members.${name}`;
|
|
53
|
+
check(validName(name) && object(member), `${where} must be an object under an agent name`);
|
|
54
|
+
keys(member, MEMBER_SETTINGS, check, `${where}: `);
|
|
55
|
+
if (name === config.main) check(member.role === undefined || member.role === 'main', `${where}.role: main's role is main`);
|
|
56
|
+
else check(TEAM_ROLES.includes(member.role), `${where}.role must be one of ${TEAM_ROLES.join(', ')}`);
|
|
57
|
+
for (const key of ['model', 'thinking']) if (member[key] !== undefined) check(nonempty(member[key]), `${where}.${key} must be a nonempty string`);
|
|
58
|
+
}
|
|
59
|
+
check(object(config.delegation), 'delegation must map members to the members they may delegate to');
|
|
60
|
+
for (const [owner, targets] of Object.entries(config.delegation)) {
|
|
61
|
+
check(Object.hasOwn(config.members, owner), `delegation.${owner}: not a member`);
|
|
62
|
+
check(Array.isArray(targets) && new Set(targets).size === targets.length, `delegation.${owner} must list members once each`);
|
|
63
|
+
for (const target of targets) check(Object.hasOwn(config.members, target) && target !== config.main, `delegation.${owner}: ${target} is not a member main can be assigned to`);
|
|
64
|
+
}
|
|
65
|
+
const visiting = new Set();
|
|
66
|
+
const visited = new Set();
|
|
67
|
+
const visit = owner => {
|
|
68
|
+
check(!visiting.has(owner), `delegation cycle at '${owner}'`);
|
|
69
|
+
if (visited.has(owner)) return;
|
|
70
|
+
visiting.add(owner);
|
|
71
|
+
for (const target of config.delegation[owner] ?? []) visit(target);
|
|
72
|
+
visiting.delete(owner);
|
|
73
|
+
visited.add(owner);
|
|
74
|
+
};
|
|
75
|
+
for (const owner of Object.keys(config.delegation)) visit(owner);
|
|
76
|
+
if (config.oracleModels !== undefined) {
|
|
77
|
+
check(Array.isArray(config.oracleModels) && config.oracleModels.length > 0, 'oracleModels must list runtime-prefixed models');
|
|
78
|
+
check(config.oracleModels.every(model => typeof model === 'string' && /^(codex|claude|antigravity|acp):[^\s]+$/.test(model)) && new Set(config.oracleModels).size === config.oracleModels.length, 'oracleModels must list each model once');
|
|
79
|
+
}
|
|
80
|
+
if (config.oracleThinking !== undefined) {
|
|
81
|
+
check(object(config.oracleThinking), 'oracleThinking must map providers to effort');
|
|
82
|
+
for (const [provider, effort] of Object.entries(config.oracleThinking)) check((['codex', 'claude', 'antigravity', 'acp'].includes(provider) || /^(codex|claude|antigravity|acp):[^\s]+$/.test(provider)) && ['none', 'off', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra', 'ultracode'].includes(effort), 'oracleThinking requires a provider or model ID and a supported effort');
|
|
83
|
+
}
|
|
84
|
+
if (config.maxPeers !== undefined) check(Number.isSafeInteger(config.maxPeers) && config.maxPeers >= 1, 'maxPeers must be a positive integer');
|
|
85
|
+
if (config.supervisor !== undefined && config.supervisor !== false) {
|
|
86
|
+
check(object(config.supervisor), 'supervisor must be false or { agent, model, thinking }');
|
|
87
|
+
keys(config.supervisor, SUPERVISOR_SETTINGS, check, 'supervisor: ');
|
|
88
|
+
check(validName(config.supervisor.agent), 'supervisor.agent must name an agent');
|
|
89
|
+
check(!Object.hasOwn(config.members, config.supervisor.agent), 'supervisor.agent watches the team and cannot be a member');
|
|
90
|
+
for (const key of ['model', 'thinking']) if (config.supervisor[key] !== undefined) check(nonempty(config.supervisor[key]), `supervisor.${key} must be a nonempty string`);
|
|
91
|
+
}
|
|
92
|
+
return config;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function directories(directory) {
|
|
96
|
+
try { return (await readdir(directory, { withFileTypes: true })).filter(entry => entry.isDirectory() && validName(entry.name)).map(entry => entry.name).sort(); }
|
|
97
|
+
catch (error) { if (error.code === 'ENOENT') return []; throw error; }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Where each team comes from: built in, then the library, then the project. A team
|
|
102
|
+
* directory counts only with a team.json. `root` may be undefined outside a project.
|
|
103
|
+
* @returns {Promise<Map<string, { source: 'builtin' | 'library' | 'project', directory?: string, overrides?: 'builtin' | 'library' }>>}
|
|
104
|
+
*/
|
|
105
|
+
export async function teamSources(root, { library, templates } = {}) {
|
|
106
|
+
const found = new Map();
|
|
107
|
+
for (const id of BUILTIN_TEAMS) if (await builtinText(`teams/${id}/team.json`, templates) !== undefined) found.set(id, { source: 'builtin' });
|
|
108
|
+
const layer = async (directory, source) => {
|
|
109
|
+
for (const id of await directories(directory)) {
|
|
110
|
+
if ((await optionalText(path.join(directory, id, 'team.json'))) === undefined) continue;
|
|
111
|
+
const previous = found.get(id);
|
|
112
|
+
found.set(id, { source, directory: path.join(directory, id), ...(previous ? { overrides: previous.source } : {}) });
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
if (library) await layer(path.join(library, 'teams'), 'library');
|
|
116
|
+
// Without a project, only the built-ins and the library apply.
|
|
117
|
+
if (root !== undefined) await layer(path.join(path.resolve(root), '.alp', 'teams'), 'project');
|
|
118
|
+
return found;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* A team by id, with its house rules.
|
|
123
|
+
* @returns {Promise<{ id: string, source: 'builtin' | 'library' | 'project', label: string, description?: string, main: string,
|
|
124
|
+
* members: Record<string, { role?: string, model?: string, thinking?: string }>, delegation: Record<string, string[]>,
|
|
125
|
+
* oracleThinking?: Record<string, string>, oracleModels?: string[], maxPeers?: number, supervisor: false | { agent: string, model?: string, thinking?: string }, houseRules: string }>}
|
|
126
|
+
*/
|
|
127
|
+
export async function resolveTeam(root, id, { library, templates } = {}) {
|
|
128
|
+
const team = teamOf(id);
|
|
129
|
+
const sources = await teamSources(root, { library, templates });
|
|
130
|
+
const found = sources.get(team);
|
|
131
|
+
if (!found) throw new AlpError('TEAM_NOT_FOUND', `Team '${id}' not found; teams: ${[...sources.keys()].join(', ')}`);
|
|
132
|
+
const where = found.directory ? path.join(found.directory, 'team.json') : `built-in teams/${team}/team.json`;
|
|
133
|
+
const text = found.directory ? await optionalText(where) : await builtinText(`teams/${team}/team.json`, templates);
|
|
134
|
+
let config;
|
|
135
|
+
try { config = JSON.parse(text ?? ''); }
|
|
136
|
+
catch (cause) { throw new AlpError('INVALID_TEAM', `${where}: ${cause.message}`, { cause }); }
|
|
137
|
+
validateTeam(config, where);
|
|
138
|
+
const houseRules = (found.directory ? await optionalText(path.join(found.directory, 'HOUSE_RULES.md')) : await builtinText(`teams/${team}/HOUSE_RULES.md`, templates)) ?? '';
|
|
139
|
+
return {
|
|
140
|
+
id: team, source: found.source, label: config.label,
|
|
141
|
+
...(config.description !== undefined ? { description: config.description } : {}),
|
|
142
|
+
main: config.main, members: config.members, delegation: config.delegation,
|
|
143
|
+
...(config.maxPeers !== undefined ? { maxPeers: config.maxPeers } : {}),
|
|
144
|
+
supervisor: config.supervisor ?? false,
|
|
145
|
+
oracleModels: config.oracleModels,
|
|
146
|
+
oracleThinking: config.oracleThinking,
|
|
147
|
+
...(config.modelSetup ? { modelSetup: config.modelSetup } : {}),
|
|
148
|
+
houseRules: houseRules.trim(),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Every team a project can use, in order: built-in, library, project; with what each overrides.
|
|
154
|
+
* A team that does not load is listed with its error.
|
|
155
|
+
* @param {string | undefined} root
|
|
156
|
+
* @param {{ library?: string, templates?: Record<string, string> }} [options]
|
|
157
|
+
* @returns {Promise<Array<{ id: string, source: 'builtin' | 'library' | 'project', label?: string, description?: string, overrides?: 'builtin' | 'library', path?: string, error?: string }>>}
|
|
158
|
+
*/
|
|
159
|
+
export async function listTeams(root, options = {}) {
|
|
160
|
+
/** @type {Array<{ id: string, source: 'builtin' | 'library' | 'project', label?: string, description?: string, overrides?: 'builtin' | 'library', path?: string, error?: string }>} */
|
|
161
|
+
const rows = [];
|
|
162
|
+
for (const [id, source] of await teamSources(root, options)) {
|
|
163
|
+
let team;
|
|
164
|
+
try { team = await resolveTeam(root, id, options); }
|
|
165
|
+
catch (error) { rows.push({ id, source: source.source, ...(source.overrides ? { overrides: source.overrides } : {}), error: error.message }); continue; }
|
|
166
|
+
rows.push({ id, source: source.source, label: team.label, ...(team.description !== undefined ? { description: team.description } : {}), ...(source.overrides ? { overrides: source.overrides } : {}), ...(source.directory ? { path: source.directory } : {}) });
|
|
167
|
+
}
|
|
168
|
+
const rank = { builtin: 0, library: 1, project: 2 };
|
|
169
|
+
return rows.sort((a, b) => rank[a.source] - rank[b.source] || (BUILTIN_TEAMS.indexOf(a.id) + 1 || 99) - (BUILTIN_TEAMS.indexOf(b.id) + 1 || 99) || a.id.localeCompare(b.id));
|
|
170
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
/** Source packages carry templates beside src; legacy bundles live one directory deeper. */
|
|
4
|
+
export function shippedTemplateRoot() {
|
|
5
|
+
const packaged = new URL('../templates/', import.meta.url);
|
|
6
|
+
const candidates = [packaged, new URL('../../templates/', import.meta.url), new URL('../../../templates/', import.meta.url), new URL('../../../../templates/', import.meta.url), new URL('../../../../../templates/', import.meta.url)];
|
|
7
|
+
return candidates.find(candidate => existsSync(candidate)) ?? packaged;
|
|
8
|
+
}
|
package/src/trust.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { mkdir, readFile, realpath, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { moveFile } from './atomic.js';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Which workspaces' hooks the user trusts (ALPD §45). A project's hooks come with its
|
|
8
|
+
* repository, so ALP asks the user once per project before running them; once the user
|
|
9
|
+
* agrees, that project's hooks run from then on, including hooks added or changed later.
|
|
10
|
+
* Hooks in the user's library are always trusted. Kept in $ALP_HOME/state/trust.json.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const file = home => path.join(home, 'state', 'trust.json');
|
|
14
|
+
/** One key per workspace, however its path was spelled. */
|
|
15
|
+
const key = root => realpath(path.resolve(root)).catch(() => path.resolve(root));
|
|
16
|
+
|
|
17
|
+
async function load(home) {
|
|
18
|
+
try {
|
|
19
|
+
const value = JSON.parse(await readFile(file(home), 'utf8'));
|
|
20
|
+
return value && typeof value === 'object' && value.projects && typeof value.projects === 'object' ? value : { projects: {} };
|
|
21
|
+
} catch (error) {
|
|
22
|
+
if (error.code === 'ENOENT' || error instanceof SyntaxError) return { projects: {} };
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function store(home, value) {
|
|
28
|
+
await mkdir(path.dirname(file(home)), { recursive: true });
|
|
29
|
+
const temporary = `${file(home)}.${randomUUID().slice(0, 8)}.tmp`;
|
|
30
|
+
await writeFile(temporary, JSON.stringify(value, null, 2) + '\n');
|
|
31
|
+
await moveFile(temporary, file(home));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Whether the user trusts this project's hooks. */
|
|
35
|
+
export async function isTrusted(home, projectRoot) {
|
|
36
|
+
return Object.hasOwn((await load(home)).projects, await key(projectRoot));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Records that the user trusts this project's hooks; `by` says how they agreed. */
|
|
40
|
+
export async function trustProject(home, projectRoot, by = 'user') {
|
|
41
|
+
const value = await load(home);
|
|
42
|
+
value.projects[await key(projectRoot)] = { trustedAt: new Date().toISOString(), by };
|
|
43
|
+
await store(home, value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Forgets a project's trust: ALP asks again before its hooks run. Returns whether it was trusted. */
|
|
47
|
+
export async function revokeProject(home, projectRoot) {
|
|
48
|
+
const value = await load(home);
|
|
49
|
+
const root = await key(projectRoot);
|
|
50
|
+
if (!Object.hasOwn(value.projects, root)) return false;
|
|
51
|
+
delete value.projects[root];
|
|
52
|
+
await store(home, value);
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Every trusted project, with when and how the user agreed. */
|
|
57
|
+
export async function trustedProjects(home) {
|
|
58
|
+
return Object.entries((await load(home)).projects).map(([root, entry]) => ({ root, ...entry })).sort((a, b) => a.root.localeCompare(b.root));
|
|
59
|
+
}
|
package/src/types.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export interface AgentModelConfig { provider?: 'codex' | 'claude' | 'acp' | 'antigravity'; model?: string; effort?: string }
|
|
2
|
+
export type AutoCompact = 'auto' | number;
|
|
3
|
+
export type AgentProfilesConfig = {
|
|
4
|
+
/** "provider:model", "provider:*" for any model of that provider, or the earlier object form. */
|
|
5
|
+
profiles?: Array<string | AgentModelConfig>;
|
|
6
|
+
autocompact?: AutoCompact;
|
|
7
|
+
claude?: AgentModelConfig;
|
|
8
|
+
codex?: AgentModelConfig;
|
|
9
|
+
} & AgentModelConfig;
|
|
10
|
+
/** Disk configuration; no provider SDK types. */
|
|
11
|
+
export interface RawSettings {
|
|
12
|
+
templateDefaults?: Record<string, Record<string, string>>;
|
|
13
|
+
team?: Record<string, Record<string, AgentProfilesConfig>>;
|
|
14
|
+
/** Legacy settings key; use team. */
|
|
15
|
+
profile?: Record<string, Record<string, AgentProfilesConfig>>;
|
|
16
|
+
defaultAgent?: string;
|
|
17
|
+
workflow?: { mode?: 'pho' | 'cafe' | 'smart' | 'supervised'; maxPeers?: number; supervisor?: boolean };
|
|
18
|
+
delegation?: Record<string, string[]>;
|
|
19
|
+
runtime?: { provider?: string; model?: string; reasoning?: string };
|
|
20
|
+
}
|
|
21
|
+
export interface RawMcpServer {
|
|
22
|
+
command?: string;
|
|
23
|
+
args?: string[];
|
|
24
|
+
cwd?: string;
|
|
25
|
+
env?: Record<string, string>;
|
|
26
|
+
url?: string;
|
|
27
|
+
headers?: Record<string, string>;
|
|
28
|
+
}
|
|
29
|
+
export interface RawMcpConfig { mcpServers?: Record<string, RawMcpServer> }
|
|
30
|
+
export interface ResolvedResource { name: string; path: string }
|
|
31
|
+
export interface ResolvedMcpConfig { mcpServers: Record<string, RawMcpServer> }
|
|
32
|
+
/** JSON-serializable adapter input. All filesystem resource paths/cwd are absolute. */
|
|
33
|
+
export interface ResolvedAgent {
|
|
34
|
+
name: string;
|
|
35
|
+
projectRoot: string;
|
|
36
|
+
instructions: { project: string; agent: string };
|
|
37
|
+
skills: ResolvedResource[];
|
|
38
|
+
hooks: ResolvedResource[];
|
|
39
|
+
mcp: ResolvedMcpConfig;
|
|
40
|
+
runtime: { provider?: string; model?: string; reasoning?: string };
|
|
41
|
+
/** The part of runtime that settings.json sets for every agent; it comes before a team's or the agent's choice. */
|
|
42
|
+
projectRuntime?: { provider?: string; model?: string; reasoning?: string };
|
|
43
|
+
/** Where the agent is defined: ALP's built-ins, the user's library, or the project (ALPD §41). */
|
|
44
|
+
source?: 'builtin' | 'library' | 'project';
|
|
45
|
+
/** The permission mode the agent runs in unless its caller asks for another. */
|
|
46
|
+
mode?: 'read-only' | 'workspace-write' | 'full-access';
|
|
47
|
+
description?: string;
|
|
48
|
+
/** The context window it works in before its runtime compacts, in tokens; absent: the model's (ALPD §57). */
|
|
49
|
+
context?: number;
|
|
50
|
+
autocompact?: AutoCompact;
|
|
51
|
+
}
|
package/src/upgrade.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { shippedTemplateRoot } from './template-path.js';
|
|
2
|
+
import { projectProfiles, normalizeAgentProfiles, syncTemplateProfiles } from './profiles.js';
|
|
3
|
+
import { readFile, writeFile, mkdir, mkdtemp, lstat, readdir, rename, rm, rmdir } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { initProject } from './init.js';
|
|
8
|
+
import { validateSettings } from './validation.js';
|
|
9
|
+
import { resolveDelegation } from './delegation.js';
|
|
10
|
+
|
|
11
|
+
const legacy = {
|
|
12
|
+
'ALP.md': '# Project instructions\n\nAdd project-wide instructions here.\n',
|
|
13
|
+
'.alp/agents/main/AGENT.md': '# Main agent\n\nHelp with tasks in this project.\n',
|
|
14
|
+
};
|
|
15
|
+
// SHA-256 of shipped role definitions after CRLF normalization. Custom
|
|
16
|
+
// definitions do not match and remain untouched. Keep historical IDs immutable.
|
|
17
|
+
const teamV1 = {
|
|
18
|
+
'ALP.md': ['c2785aac6538d10e79bd6fb59b11066ccdec77287edf950fad59aa57bf3417f1', '0875293195d35d965effb70bb150d1edbc789f66f72c545f7760dea4346b1a9a'],
|
|
19
|
+
'.alp/agents/main/AGENT.md': ['15d217cbc5ede782790d1086124598d9a2b5ff13bcf971a37231962fcef1b47c', 'e7201a1ed7800d7500b5c8091bd8a435aa9d5138f247c2b02b8bf76299387c90', '5001b807bbb686aa1a3b7ecadf9501428909e052e8231281454fd41cbe0ebf27', '90f9903fcc97ddf6336e1a28b81103dddc60a6038dcf74d326be8998862cb022', '9085ab906e2744a4151ae5c63dd66a545115cd4c876be1fd80f0a0ed4e623da8', 'fb55cfb0852d07592090de2c49e6c5c2b57936cbffc823dd24767c2db5e5f7fb'],
|
|
20
|
+
'.alp/agents/lead/AGENT.md': ['c259b085a3c102445f97e5c7d402ca68347bcc1a9b02d6ab96e02be6d8b6f9af', '2eef2a4474f757cd89b35b50638a1b23fe49a54c26bf0dedb24a96d7117ddd92', '6c71393c9b3aa655c4731bda96396ea3ec0d4da62cddb3ad50d1a24113416b77', 'a382f0faa724e23a0ba9c212205978aa3fef87c3aecdf8823fba136e04568487', '547e3f8fc354b22c7beb3432182d68f4de1f24938375c8bfb167ae8afbf97de2'],
|
|
21
|
+
'.alp/agents/peer/AGENT.md': ['f3dcf16c8c899a5378a468e922ca3956fd72cbb3e3798ed1109f11e01aa8f476', '39ccf3317eba3003faad77ebad4489fae3eb3c4d89258a03f619d1d3d65af0b7', 'c59e45e8f22eb0daca9b8b5e1b19e90666156807f160e75a21eca48b5fee1102', '9623dbf76eda598f6267629e993663c149acb0efd253f3900a4ee1eda8f2f9e7'],
|
|
22
|
+
'.alp/agents/oracle/AGENT.md': ['cdce1babcd587a190dc94a32a951ed70ce2c75f99c205a4049df940851ac7059'],
|
|
23
|
+
'.alp/agents/reviewer/AGENT.md': ['6eab9f918c79335a0665bf4e3a3a26b66258684d6725c4c95637e70e8d355123'],
|
|
24
|
+
};
|
|
25
|
+
/** Profiles' names before 0.4. */
|
|
26
|
+
const renamedProfiles = { smart: 'pho', supervised: 'cafe' };
|
|
27
|
+
|
|
28
|
+
/** A skill directory's regular files by relative path, CRLF-normalized; undefined when absent or not plain files. */
|
|
29
|
+
async function skillFiles(directory) {
|
|
30
|
+
const files = new Map();
|
|
31
|
+
async function walk(relative) {
|
|
32
|
+
for (const entry of await readdir(path.join(directory, relative), { withFileTypes: true })) {
|
|
33
|
+
const name = relative ? `${relative}/${entry.name}` : entry.name;
|
|
34
|
+
if (entry.isDirectory()) { if (!await walk(name)) return false; }
|
|
35
|
+
else if (entry.isFile()) files.set(name, (await readFile(path.join(directory, name), 'utf8')).replaceAll('\r\n', '\n'));
|
|
36
|
+
else return false;
|
|
37
|
+
}
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const info = await lstat(directory);
|
|
42
|
+
if (!info.isDirectory() || info.isSymbolicLink()) return undefined;
|
|
43
|
+
return await walk('') ? files : undefined;
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (error.code === 'ENOENT') return undefined;
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Explicit migration of the original scaffold; custom instructions remain owned by the user. */
|
|
51
|
+
export async function upgradeProject(projectRoot, { library, templates } = {}) {
|
|
52
|
+
const root = path.resolve(projectRoot);
|
|
53
|
+
// Validate before creating files or changing settings.
|
|
54
|
+
let settingsText;
|
|
55
|
+
try { settingsText = await readFile(path.join(root, '.alp/settings.json'), 'utf8'); }
|
|
56
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
57
|
+
const settings = settingsText === undefined ? undefined : JSON.parse(settingsText);
|
|
58
|
+
if (settings !== undefined) validateSettings(settings, '.alp/settings.json');
|
|
59
|
+
await resolveDelegation(root);
|
|
60
|
+
const result = { ...await initProject(root, { library, templates, syncProfiles: false }), updated: [], removed: [], customSkills: [], customInstructions: [], backup: undefined };
|
|
61
|
+
async function backupPath(name) {
|
|
62
|
+
if (!result.backup) {
|
|
63
|
+
const backups = path.join(root, '.alp/backups');
|
|
64
|
+
await mkdir(backups, { recursive: true });
|
|
65
|
+
result.backup = await mkdtemp(path.join(backups, 'upgrade-'));
|
|
66
|
+
}
|
|
67
|
+
const backup = path.join(result.backup, name);
|
|
68
|
+
await mkdir(path.dirname(backup), { recursive: true });
|
|
69
|
+
return backup;
|
|
70
|
+
}
|
|
71
|
+
async function replace(name, before, after) {
|
|
72
|
+
const target = path.join(root, name);
|
|
73
|
+
if (!(await lstat(target)).isFile() || await readFile(target, 'utf8') !== before) throw new Error(`File changed during upgrade: ${target}`);
|
|
74
|
+
const backup = await backupPath(name);
|
|
75
|
+
await writeFile(backup, before, { flag: 'wx' });
|
|
76
|
+
await writeFile(target, after);
|
|
77
|
+
result.updated.push(name);
|
|
78
|
+
}
|
|
79
|
+
for (const name of ['ALP.md']) {
|
|
80
|
+
const current = await readFile(path.join(root, name), 'utf8');
|
|
81
|
+
const template = name === 'ALP.md' ? 'ALP.md' : name.slice('.alp/'.length);
|
|
82
|
+
const desired = await readFile(new URL(`${template}`, shippedTemplateRoot()), 'utf8');
|
|
83
|
+
const normalized = current.replaceAll('\r\n', '\n');
|
|
84
|
+
const known = normalized === legacy[name] || teamV1[name]?.includes(createHash('sha256').update(normalized).digest('hex'));
|
|
85
|
+
if (current !== desired && known) await replace(name, current, desired);
|
|
86
|
+
else if (current !== desired) result.customInstructions.push(name);
|
|
87
|
+
}
|
|
88
|
+
const oldGraph = settings?.delegation;
|
|
89
|
+
const shippedGraph = oldGraph && Object.keys(oldGraph).length === 2 && JSON.stringify(oldGraph.main) === '["lead"]' && JSON.stringify(oldGraph.lead) === '["peer"]';
|
|
90
|
+
if (settings !== undefined) {
|
|
91
|
+
let next = structuredClone(settings);
|
|
92
|
+
if (next.workflow === undefined && (oldGraph === undefined || shippedGraph)) {
|
|
93
|
+
if (shippedGraph) delete next.delegation;
|
|
94
|
+
next.workflow = { mode: shippedGraph ? 'cafe' : 'pho', maxPeers: 2 };
|
|
95
|
+
} else if (Object.hasOwn(renamedProfiles, next.workflow?.mode)) next.workflow.mode = renamedProfiles[next.workflow.mode];
|
|
96
|
+
next.team = next.team || next.profile
|
|
97
|
+
? { ...(next.profile ?? {}), ...(next.team ?? {}) }
|
|
98
|
+
: await projectProfiles({ root, library, templates, runtime: next.runtime });
|
|
99
|
+
delete next.profile;
|
|
100
|
+
for (const profile of Object.values(next.team ?? {})) {
|
|
101
|
+
for (const [name, config] of Object.entries(profile)) profile[name] = normalizeAgentProfiles(config, name);
|
|
102
|
+
}
|
|
103
|
+
next = await syncTemplateProfiles(next, { root, library, templates });
|
|
104
|
+
validateSettings(next, '.alp/settings.json');
|
|
105
|
+
if (JSON.stringify(next) !== JSON.stringify(settings)) await replace('.alp/settings.json', settingsText, JSON.stringify(next, null, 2) + '\n');
|
|
106
|
+
}
|
|
107
|
+
// Skills now come from the user's library; archive project copies that still match what ALP shipped.
|
|
108
|
+
const shipped = JSON.parse(await readFile(new URL('role-skills.json', shippedTemplateRoot()), 'utf8'));
|
|
109
|
+
for (const [role, skills] of Object.entries(shipped)) {
|
|
110
|
+
for (const skill of skills) {
|
|
111
|
+
const relative = `.alp/agents/${role}/skills/${skill}`;
|
|
112
|
+
const files = await skillFiles(path.join(root, relative));
|
|
113
|
+
if (!files) continue;
|
|
114
|
+
const template = await skillFiles(fileURLToPath(new URL(`skills/${skill}`, shippedTemplateRoot())));
|
|
115
|
+
const same = template && files.size === template.size && [...files].every(([name, text]) => template.get(name) === text);
|
|
116
|
+
if (!same) { result.customSkills.push(relative); continue; }
|
|
117
|
+
for (const name of files.keys()) await rename(path.join(root, relative, name), await backupPath(`${relative}/${name}`));
|
|
118
|
+
await rm(path.join(root, relative), { recursive: true });
|
|
119
|
+
result.removed.push(relative);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// Retire only the exact previously shipped router; archive it outside discovery.
|
|
123
|
+
for (const role of ['main', 'lead', 'peer']) {
|
|
124
|
+
const relative = `.alp/agents/${role}/skills/ask-alp`;
|
|
125
|
+
const directory = path.join(root, relative);
|
|
126
|
+
try {
|
|
127
|
+
const info = await lstat(directory);
|
|
128
|
+
if (!info.isDirectory() || info.isSymbolicLink()) { result.customSkills.push(relative); continue; }
|
|
129
|
+
const names = await readdir(directory);
|
|
130
|
+
const file = path.join(directory, 'SKILL.md');
|
|
131
|
+
if (names.length !== 1 || names[0] !== 'SKILL.md' || !(await lstat(file)).isFile()) { result.customSkills.push(relative); continue; }
|
|
132
|
+
const content = await readFile(file, 'utf8');
|
|
133
|
+
const hash = createHash('sha256').update(content.replaceAll('\r\n', '\n')).digest('hex');
|
|
134
|
+
if (hash !== 'f38128fafa2e8a75a0237a2fda9a71fc92ed3f1e0e62878db50e5a07376f2108') { result.customSkills.push(relative); continue; }
|
|
135
|
+
await rename(file, await backupPath(`${relative}/SKILL.md`));
|
|
136
|
+
await rmdir(directory);
|
|
137
|
+
result.removed.push(relative);
|
|
138
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
139
|
+
}
|
|
140
|
+
await retireAgentCopies(root, result, backupPath, replace);
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const known = (name, text) => {
|
|
145
|
+
const normalized = text.replaceAll('\r\n', '\n');
|
|
146
|
+
return normalized === legacy[name] || teamV1[name]?.includes(createHash('sha256').update(normalized).digest('hex'));
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/** An agent directory's entries apart from AGENT.md, when they hold nothing: an empty .mcp.json and empty skills/ and hooks/. */
|
|
150
|
+
async function onlyInstructions(directory) {
|
|
151
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
152
|
+
if (entry.name === 'AGENT.md' && entry.isFile()) continue;
|
|
153
|
+
if (entry.name === '.mcp.json' && entry.isFile()) {
|
|
154
|
+
try { const mcp = JSON.parse(await readFile(path.join(directory, entry.name), 'utf8')); if (!Object.keys(mcp.mcpServers ?? {}).length && Object.keys(mcp).every(key => key === 'mcpServers')) continue; } catch {}
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
if ((entry.name === 'skills' || entry.name === 'hooks') && entry.isDirectory() && !(await readdir(path.join(directory, entry.name))).length) continue;
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Projects used to get a copy of each built-in agent (ALPD §41). A copy that is still
|
|
165
|
+
* what ALP shipped, with nothing added, is backed up and removed, so the project uses
|
|
166
|
+
* the built-in and its updates. A shipped copy the user added skills, MCP servers or
|
|
167
|
+
* hooks to stays as the project's override, with its instructions brought up to date;
|
|
168
|
+
* customized instructions stay as they are.
|
|
169
|
+
*/
|
|
170
|
+
async function retireAgentCopies(root, result, backupPath, replace) {
|
|
171
|
+
for (const name of ['main', 'lead', 'peer', 'oracle', 'reviewer', 'supervisor']) {
|
|
172
|
+
const relative = `.alp/agents/${name}`;
|
|
173
|
+
const directory = path.join(root, relative);
|
|
174
|
+
const info = await lstat(directory).catch(() => undefined);
|
|
175
|
+
if (!info?.isDirectory() || info.isSymbolicLink()) continue;
|
|
176
|
+
const file = `${relative}/AGENT.md`;
|
|
177
|
+
const current = await readFile(path.join(root, file), 'utf8').catch(() => undefined);
|
|
178
|
+
if (current === undefined) continue;
|
|
179
|
+
const desired = await readFile(new URL(`agents/${name}/AGENT.md`, shippedTemplateRoot()), 'utf8');
|
|
180
|
+
const shipped = current === desired || known(file, current);
|
|
181
|
+
if (!shipped) { result.customInstructions.push(file); continue; }
|
|
182
|
+
if (await onlyInstructions(directory)) {
|
|
183
|
+
// Its empty skills/ and hooks/ go; its files go to the backup, beside any skills archived above.
|
|
184
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
185
|
+
if (entry.isDirectory()) await rmdir(path.join(directory, entry.name));
|
|
186
|
+
else await rename(path.join(directory, entry.name), await backupPath(`${relative}/${entry.name}`));
|
|
187
|
+
}
|
|
188
|
+
await rmdir(directory);
|
|
189
|
+
result.removed.push(relative);
|
|
190
|
+
} else if (current !== desired) {
|
|
191
|
+
await replace(file, current, desired);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
await rmdir(path.join(root, '.alp/agents')).catch(() => {});
|
|
195
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export const DEFAULT_LANGUAGE: string;
|
|
2
|
+
export const LANGUAGE_CHARS: number;
|
|
3
|
+
export function userLanguage(home?: string): Promise<string>;
|
|
4
|
+
export function languageSetting(home: string): Promise<{ language: string | null; applies: string; default: string }>;
|
|
5
|
+
export function setLanguage(home: string | undefined, language: string | null): Promise<{ language: string | null; applies: string; default: string }>;
|
|
6
|
+
export function setWebRelay(home: string, patch: { enabled?: boolean; endpoint?: string; useTls?: boolean }): Promise<{ enabled?: boolean; endpoint?: string; useTls?: boolean }>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { moveFile } from './atomic.js';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { AlpError } from './errors.js';
|
|
6
|
+
import { validateUserSettings } from './validation.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The user's own settings in $ALP_HOME/settings.json that ALP and its screens edit
|
|
10
|
+
* (ALPD §54). The language is the one everything the user reads is written in:
|
|
11
|
+
* agents' messages and questions, approvals, and ALP's own notices.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** The language ALP and its agents write in for the user when none is set. */
|
|
15
|
+
export const DEFAULT_LANGUAGE = 'Vietnamese';
|
|
16
|
+
export const LANGUAGE_CHARS = 40;
|
|
17
|
+
|
|
18
|
+
async function readSettings(home) {
|
|
19
|
+
const file = path.join(home, 'settings.json');
|
|
20
|
+
let text;
|
|
21
|
+
try { text = await readFile(file, 'utf8'); }
|
|
22
|
+
catch (error) { if (error.code === 'ENOENT') return { file, settings: {} }; throw error; }
|
|
23
|
+
let settings;
|
|
24
|
+
try { settings = JSON.parse(text); }
|
|
25
|
+
catch (cause) { throw new AlpError('INVALID_SETTINGS', `${file}: ${cause.message}`, { cause }); }
|
|
26
|
+
return { file, settings: validateUserSettings(settings, file) };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The user's language: their setting, else Vietnamese. A broken settings file falls back too. */
|
|
30
|
+
export async function userLanguage(home) {
|
|
31
|
+
if (!home) return DEFAULT_LANGUAGE;
|
|
32
|
+
try { return (await readSettings(home)).settings.language ?? DEFAULT_LANGUAGE; }
|
|
33
|
+
catch { return DEFAULT_LANGUAGE; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** What the settings screen shows: the language set, if any, and the one that applies. */
|
|
37
|
+
export async function languageSetting(home) {
|
|
38
|
+
const { settings } = await readSettings(home);
|
|
39
|
+
return { language: settings.language ?? null, applies: settings.language ?? DEFAULT_LANGUAGE, default: DEFAULT_LANGUAGE };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Sets the user's language, or clears it with null so the default applies. Other keys
|
|
44
|
+
* of settings.json are kept as they are.
|
|
45
|
+
*/
|
|
46
|
+
export async function setLanguage(home, language) {
|
|
47
|
+
if (!home) throw new AlpError('INVALID_SCOPE', 'No library: ALP_HOME is not set');
|
|
48
|
+
if (language !== null && (typeof language !== 'string' || !language.trim() || language.trim().length > LANGUAGE_CHARS)) {
|
|
49
|
+
throw new AlpError('INVALID_SETTINGS', `A language is a name of at most ${LANGUAGE_CHARS} characters, such as Vietnamese or English`);
|
|
50
|
+
}
|
|
51
|
+
const { file, settings } = await readSettings(home);
|
|
52
|
+
const next = { ...settings };
|
|
53
|
+
if (language === null) delete next.language; else next.language = language.trim();
|
|
54
|
+
validateUserSettings(next, file);
|
|
55
|
+
await mkdir(home, { recursive: true, mode: 0o700 });
|
|
56
|
+
const temporary = `${file}.${randomUUID().slice(0, 8)}.tmp`;
|
|
57
|
+
await writeFile(temporary, JSON.stringify(next, null, 2) + '\n');
|
|
58
|
+
await moveFile(temporary, file);
|
|
59
|
+
return languageSetting(home);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Changes the relay setting under web (ALPD §64), keeping everything else in settings.json.
|
|
64
|
+
* @param {string} home
|
|
65
|
+
* @param {{ enabled?: boolean, endpoint?: string, useTls?: boolean }} patch
|
|
66
|
+
*/
|
|
67
|
+
export async function setWebRelay(home, patch) {
|
|
68
|
+
const { file, settings } = await readSettings(home);
|
|
69
|
+
const next = { ...settings, web: { ...settings.web, relay: { ...settings.web?.relay, ...patch } } };
|
|
70
|
+
validateUserSettings(next, file);
|
|
71
|
+
await mkdir(home, { recursive: true, mode: 0o700 });
|
|
72
|
+
const temporary = `${file}.${randomUUID().slice(0, 8)}.tmp`;
|
|
73
|
+
await writeFile(temporary, JSON.stringify(next, null, 2) + '\n');
|
|
74
|
+
await moveFile(temporary, file);
|
|
75
|
+
return next.web.relay;
|
|
76
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const PROJECT_SETTINGS: string[];
|
|
2
|
+
export const USER_SETTINGS: string[];
|
|
3
|
+
export const RETIRED_SETTINGS: { project: Record<string, string>; user: Record<string, string> };
|
|
4
|
+
export function settingsKeys(settings: unknown, known: string[], retired?: Record<string, string>): { unknown: Array<{ key: string; suggestion?: string }>; retired: Array<{ key: string; instead: string }> };
|
|
5
|
+
export function settingsWarnings(settings: unknown, scope: 'project' | 'user'): string[];
|
|
6
|
+
export function validateSettings(settings: unknown, source: string): { provider?: string; model?: string; reasoning?: string };
|
|
7
|
+
export function validateUserSettings(settings: unknown, source: string): { language?: string; permissions?: unknown; limits?: { autoResume?: boolean }; recovery?: { autoResume?: boolean } };
|
|
8
|
+
export function normalizeMcp(raw: unknown, directory: string, source: string): { mcpServers: Record<string, Record<string, unknown>> };
|
|
9
|
+
export function validateResolvedAgent(agent: unknown): unknown;
|
|
10
|
+
export const AGENT_SETTINGS: string[];
|
|
11
|
+
export const CONTEXT_TOKENS: { min: number; max: number };
|
|
12
|
+
export function validateAgentConfig(config: unknown, source: string): { description?: string; provider?: string; model?: string; thinking?: string; mode?: 'read-only' | 'workspace-write' | 'full-access'; skills?: string[]; mcp?: string[]; hooks?: string[] };
|
|
13
|
+
export const HOOK_SETTINGS: string[];
|
|
14
|
+
export const HOOK_EVENTS: string[];
|
|
15
|
+
export const BLOCKING_HOOK_EVENTS: string[];
|
|
16
|
+
export function validateHook(config: unknown, source: string): { description?: string; event: string; command: string; blocking?: boolean; timeoutSec?: number; match?: { agent?: string; label?: string } };
|
|
17
|
+
export function unknownMessage(unknown: { key: string; suggestion?: string }, known: string[]): string;
|