@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.
Files changed (144) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +7 -0
  3. package/README.md +7 -0
  4. package/dist/adapter.d.ts +20 -0
  5. package/dist/adapter.js +30 -0
  6. package/dist/atomic.d.ts +2 -0
  7. package/dist/atomic.js +28 -0
  8. package/dist/backup.d.ts +27 -0
  9. package/dist/backup.js +582 -0
  10. package/dist/beads.d.ts +65 -0
  11. package/dist/beads.js +213 -0
  12. package/dist/delegation.d.ts +2 -0
  13. package/dist/delegation.js +32 -0
  14. package/dist/errors.d.ts +4 -0
  15. package/dist/errors.js +11 -0
  16. package/dist/formulas.d.ts +25 -0
  17. package/dist/formulas.js +201 -0
  18. package/dist/hook-run.d.ts +44 -0
  19. package/dist/hook-run.js +60 -0
  20. package/dist/hooks.d.ts +35 -0
  21. package/dist/hooks.js +30 -0
  22. package/dist/index.d.ts +30 -0
  23. package/dist/index.js +63 -0
  24. package/dist/init.d.ts +30 -0
  25. package/dist/init.js +88 -0
  26. package/dist/library-edit.d.ts +90 -0
  27. package/dist/library-edit.js +315 -0
  28. package/dist/library.d.ts +28 -0
  29. package/dist/library.js +108 -0
  30. package/dist/log.d.ts +37 -0
  31. package/dist/log.js +156 -0
  32. package/dist/migrations.d.ts +9 -0
  33. package/dist/migrations.js +95 -0
  34. package/dist/models.d.ts +11 -0
  35. package/dist/models.js +44 -0
  36. package/dist/permissions.d.ts +14 -0
  37. package/dist/permissions.js +229 -0
  38. package/dist/profiles.d.ts +27 -0
  39. package/dist/profiles.js +122 -0
  40. package/dist/promptsafe.d.ts +1 -0
  41. package/dist/promptsafe.js +13 -0
  42. package/dist/providers.d.ts +90 -0
  43. package/dist/providers.js +101 -0
  44. package/dist/resolver.d.ts +41 -0
  45. package/dist/resolver.js +240 -0
  46. package/dist/safe-path.d.ts +2 -0
  47. package/dist/safe-path.js +24 -0
  48. package/dist/shell.d.ts +16 -0
  49. package/dist/shell.js +22 -0
  50. package/dist/state-schema.d.ts +9 -0
  51. package/dist/state-schema.js +54 -0
  52. package/dist/task-rows.d.ts +47 -0
  53. package/dist/task-rows.js +45 -0
  54. package/dist/tasks.d.ts +128 -0
  55. package/dist/tasks.js +835 -0
  56. package/dist/teams.d.ts +69 -0
  57. package/dist/teams.js +159 -0
  58. package/dist/template-path.d.ts +2 -0
  59. package/dist/template-path.js +10 -0
  60. package/dist/trust.d.ts +8 -0
  61. package/dist/trust.js +47 -0
  62. package/dist/types.d.ts +51 -0
  63. package/dist/upgrade.d.ts +10 -0
  64. package/dist/upgrade.js +200 -0
  65. package/dist/user-settings.d.ts +6 -0
  66. package/dist/user-settings.js +72 -0
  67. package/dist/validation.d.ts +17 -0
  68. package/dist/validation.js +295 -0
  69. package/dist/verify.d.ts +15 -0
  70. package/dist/verify.js +123 -0
  71. package/dist/workflow.d.ts +14 -0
  72. package/dist/workflow.js +24 -0
  73. package/package.json +309 -0
  74. package/src/adapter.d.ts +20 -0
  75. package/src/adapter.js +29 -0
  76. package/src/atomic.d.ts +2 -0
  77. package/src/atomic.js +28 -0
  78. package/src/backup.d.ts +27 -0
  79. package/src/backup.js +570 -0
  80. package/src/beads.js +236 -0
  81. package/src/delegation.js +28 -0
  82. package/src/errors.js +7 -0
  83. package/src/formulas.d.ts +25 -0
  84. package/src/formulas.js +202 -0
  85. package/src/hook-run.js +57 -0
  86. package/src/hooks.js +36 -0
  87. package/src/index.js +31 -0
  88. package/src/init.js +92 -0
  89. package/src/library-edit.js +346 -0
  90. package/src/library.js +117 -0
  91. package/src/log.d.ts +37 -0
  92. package/src/log.js +177 -0
  93. package/src/migrations.d.ts +9 -0
  94. package/src/migrations.js +111 -0
  95. package/src/models.js +45 -0
  96. package/src/permissions.d.ts +14 -0
  97. package/src/permissions.js +242 -0
  98. package/src/profiles.js +139 -0
  99. package/src/promptsafe.d.ts +1 -0
  100. package/src/promptsafe.js +19 -0
  101. package/src/providers.js +122 -0
  102. package/src/resolver.js +238 -0
  103. package/src/safe-path.js +20 -0
  104. package/src/shell.js +28 -0
  105. package/src/state-schema.d.ts +9 -0
  106. package/src/state-schema.js +60 -0
  107. package/src/task-rows.js +44 -0
  108. package/src/tasks.d.ts +128 -0
  109. package/src/tasks.js +881 -0
  110. package/src/teams.js +170 -0
  111. package/src/template-path.js +8 -0
  112. package/src/trust.js +59 -0
  113. package/src/types.d.ts +51 -0
  114. package/src/upgrade.js +195 -0
  115. package/src/user-settings.d.ts +6 -0
  116. package/src/user-settings.js +76 -0
  117. package/src/validation.d.ts +17 -0
  118. package/src/validation.js +302 -0
  119. package/src/verify.d.ts +15 -0
  120. package/src/verify.js +127 -0
  121. package/src/workflow.js +28 -0
  122. package/templates/ALP.md +52 -0
  123. package/templates/agents/lead/AGENT.md +90 -0
  124. package/templates/agents/main/AGENT.md +150 -0
  125. package/templates/agents/oracle/AGENT.md +44 -0
  126. package/templates/agents/peer/AGENT.md +55 -0
  127. package/templates/agents/reviewer/AGENT.md +80 -0
  128. package/templates/agents/supervisor/AGENT.md +72 -0
  129. package/templates/providers/antigravity.json +6 -0
  130. package/templates/providers/opencode.json +7 -0
  131. package/templates/role-skills.json +8 -0
  132. package/templates/skills/bug-loop/SKILL.md +44 -0
  133. package/templates/skills/bug-loop/references/test-proof.md +25 -0
  134. package/templates/skills/goal-griller/SKILL.md +49 -0
  135. package/templates/skills/prompt-leverage/SKILL.md +37 -0
  136. package/templates/skills/prompt-leverage/references/brief-template.md +27 -0
  137. package/templates/skills/sequence-execution-plan/SKILL.md +42 -0
  138. package/templates/skills/sequence-execution-plan/references/plan-template.md +22 -0
  139. package/templates/skills/smart-commits/SKILL.md +40 -0
  140. package/templates/skills/xia/SKILL.md +42 -0
  141. package/templates/teams/cafe/HOUSE_RULES.md +1 -0
  142. package/templates/teams/cafe/team.json +15 -0
  143. package/templates/teams/pho/HOUSE_RULES.md +1 -0
  144. package/templates/teams/pho/team.json +14 -0
@@ -0,0 +1,139 @@
1
+ import path from 'node:path';
2
+ import os from 'node:os';
3
+ import { createHash } from 'node:crypto';
4
+ import { MODELS } from './models.js';
5
+ import { resolveTeam, teamSources } from './teams.js';
6
+
7
+ export const userLibrary = () => process.env.ALP_HOME ?? path.join(os.homedir(), '.alp');
8
+ /** Accept the original provider map as well as the model list.
9
+ * @param {import('./types.js').AgentProfilesConfig | undefined} config
10
+ * @returns {Array<import('./types.js').AgentModelConfig>}
11
+ */
12
+ export const oracleProfiles = config => config?.profiles ?? [config?.claude, config?.codex].filter(Boolean);
13
+
14
+ /** The choices for any agent, accepting the earlier single-model configuration.
15
+ * @param {import('./types.js').AgentProfilesConfig | undefined} config
16
+ * @param {string} name
17
+ * @returns {Array<import('./types.js').AgentModelConfig>}
18
+ */
19
+ export const agentProfiles = (config, name) => {
20
+ if (!config) return [];
21
+ if (name === 'oracle') return oracleProfiles(config).map(parseProfile);
22
+ const { autocompact, ...legacy } = config;
23
+ return (config.profiles ?? [legacy]).map(parseProfile);
24
+ };
25
+
26
+ /** A profile is "provider:model[:effort]", "provider:*" (any model the provider has), or the earlier { provider, model, effort }. */
27
+ export const parseProfile = profile => {
28
+ if (typeof profile !== 'string') return profile;
29
+ const [provider, model, effort] = profile.split(':');
30
+ return { provider, model, ...(effort ? { effort } : {}) };
31
+ };
32
+ /** The string form of a profile: "provider:model", with ":effort" when it has one. */
33
+ export const profileString = profile => `${profileModel(profile)}${profile.effort ? `:${profile.effort}` : ''}`;
34
+ /** The efforts a runtime-prefixed model takes, for the models ALP knows; undefined for others. */
35
+ export const modelEfforts = id => MODELS.find(model => `${model.runtime}:${model.id}` === id)?.efforts;
36
+ /** An effort the model takes: the one wanted, or else the model's own default. */
37
+ export const fitEffort = (id, effort) => {
38
+ const efforts = modelEfforts(id);
39
+ if (!efforts?.length || efforts.includes(effort)) return effort;
40
+ return MODELS.find(model => `${model.runtime}:${model.id}` === id)?.effort ?? efforts[0];
41
+ };
42
+ /** Whether a profile stands for every model of its provider. */
43
+ export const anyModel = profile => profile?.model === '*';
44
+ /** Whether a runtime-prefixed model is one a profile list offers. */
45
+ export const offers = (profiles, model) => profiles.some(profile => anyModel(profile) ? model.startsWith(`${profile.provider}:`) && model.length > profile.provider.length + 1 : profileModel(profile) === model);
46
+
47
+ /** Only a matching model supplies effort for an explicit choice; otherwise use the first default.
48
+ * @param {Array<import('./types.js').AgentModelConfig>} profiles
49
+ * @param {string | undefined} model
50
+ */
51
+ export const selectedProfile = (profiles, model) => model === undefined ? profiles.find(config => !anyModel(config)) : profiles.find(config => !anyModel(config) && (!config.model || profileModel(config) === model || (!model.includes(':') && config.model === model)));
52
+
53
+ /** Migrate complete choices while leaving legacy partial overrides readable. */
54
+ export function normalizeAgentProfiles(config, name) {
55
+ // The earlier object form becomes "provider:model:effort", which says the same.
56
+ if (config.profiles) return { ...config, profiles: config.profiles.map(profile => typeof profile === 'string' ? profile : profileString(profile)), autocompact: config.autocompact ?? 'auto' };
57
+ const profiles = agentProfiles(config, name);
58
+ if (!profiles.length || profiles.some(profile => !profile.model)) return config;
59
+ return { profiles: profiles.map(profile => profileString({ ...profile, provider: profile.provider ?? profile.model.split(':')[0] })), autocompact: config.autocompact ?? 'auto' };
60
+ }
61
+
62
+ export const profileModel = config => config?.model ? config.model.includes(':') ? config.model : `${config.provider}:${config.model}` : undefined;
63
+
64
+ const withEffort = (model, effort) => {
65
+ if (model.startsWith('acp:') || model.startsWith('antigravity:')) return model;
66
+ const fitted = fitEffort(model, effort);
67
+ return fitted ? `${model}:${fitted}` : model;
68
+ };
69
+
70
+ /** Snapshot current user team choices into editable project profiles. */
71
+ export async function projectProfiles({ root, library = userLibrary(), templates, runtime, teamIds } = {}) {
72
+ const profiles = {};
73
+ for (const id of (await teamSources(root, { library, templates })).keys()) {
74
+ if (teamIds && !teamIds.includes(id)) continue;
75
+ const team = await resolveTeam(root, id, { library, templates });
76
+ const fallback = team.members[team.main].model ?? 'codex:gpt-6.1-sol';
77
+ const profile = {};
78
+ for (const [name, member] of Object.entries(team.members)) {
79
+ if (name === 'oracle') continue;
80
+ const chosen = runtime?.model ? (runtime.model.includes(':') ? runtime.model : `${runtime.provider ?? 'codex'}:${runtime.model}`) : runtime?.provider ? (runtime.provider === 'claude' ? 'claude:claude-opus-5-5' : 'codex:gpt-6.1-sol') : member.model ?? fallback;
81
+ profile[name] = { profiles: [withEffort(chosen, runtime?.reasoning ?? member.thinking ?? team.members[team.main].thinking ?? 'medium')], autocompact: 'auto' };
82
+ }
83
+ if (team.supervisor) profile[team.supervisor.agent] = { profiles: [withEffort(team.supervisor.model ?? fallback, team.supervisor.thinking ?? 'medium')], autocompact: 'auto' };
84
+ if (team.members.oracle) profile.oracle = { profiles: (team.oracleModels ?? ['claude:claude-fable-5-1', 'codex:gpt-6-astra'])
85
+ .map(model => withEffort(model, team.oracleThinking?.[model] ?? team.oracleThinking?.[model.split(':')[0]] ?? 'high')), autocompact: 'auto' };
86
+ profiles[id] = profile;
87
+ }
88
+ return profiles;
89
+ }
90
+
91
+ /** Apply per-project agent choices without replacing the team's graph or rules. */
92
+ export function applyProfile(team, profile) {
93
+ if (!profile) return team;
94
+ const next = structuredClone(team);
95
+ for (const [name, config] of Object.entries(profile)) {
96
+ if (name === 'oracle') {
97
+ const lenses = oracleProfiles(config).map(parseProfile);
98
+ next.oracleModels = lenses.map(profileModel);
99
+ const efforts = lenses.filter(lens => lens.effort);
100
+ if (efforts.length) next.oracleThinking = { ...next.oracleThinking, ...Object.fromEntries(efforts.map(lens => [profileModel(lens), lens.effort])) };
101
+ } else {
102
+ const target = next.members[name] ?? (next.supervisor && next.supervisor.agent === name ? next.supervisor : undefined);
103
+ if (!target) throw new Error(`team.${team.id}.${name}: agent is not in this team`);
104
+ const first = agentProfiles(config, name).find(profile => !anyModel(profile));
105
+ if (first?.model) target.model = profileModel(first);
106
+ if (first?.effort) target.thinking = first.effort;
107
+ }
108
+ }
109
+ return next;
110
+ }
111
+
112
+ /** Fingerprints let generated defaults follow templates without replacing hand-edited project choices. */
113
+ export const profileFingerprint = (config, name) => createHash('sha256').update(JSON.stringify(agentProfiles(config, name).map(profileString))).digest('hex');
114
+ export const profileDefaults = profiles => Object.fromEntries(Object.entries(profiles).map(([team, agents]) => [team, Object.fromEntries(Object.entries(agents).map(([name, config]) => [name, profileFingerprint(config, name)]))]));
115
+
116
+ /** Refresh only inherited profiles; older scaffolds are recognized by their shipped defaults. */
117
+ export async function syncTemplateProfiles(settings, options = {}) {
118
+ const defaults = await projectProfiles({ ...options, runtime: options.replaceModels ? undefined : settings.runtime });
119
+ const legacy = settings.templateDefaults ? undefined : await projectProfiles({ templates: options.templates, library: '', runtime: settings.runtime });
120
+ const previous = settings.templateDefaults ?? profileDefaults(legacy);
121
+ const next = structuredClone(settings);
122
+ next.team = { ...(next.profile ?? {}), ...(next.team ?? {}) };
123
+ delete next.profile;
124
+ for (const id of new Set([...Object.keys(previous), ...Object.keys(defaults)])) {
125
+ if (options.teamIds && !options.teamIds.includes(id)) continue;
126
+ const desired = defaults[id] ?? {};
127
+ const current = next.team[id] ?? {};
128
+ for (const name of new Set([...Object.keys(previous[id] ?? {}), ...(options.replaceModels ? Object.keys(current) : []), ...Object.keys(desired)])) {
129
+ const config = current[name];
130
+ if (!options.replaceModels && config && profileFingerprint(config, name) !== previous[id]?.[name]) continue;
131
+ if (desired[name]) current[name] = { ...desired[name], autocompact: config?.autocompact ?? desired[name].autocompact };
132
+ else delete current[name];
133
+ }
134
+ if (Object.keys(current).length) next.team[id] = current;
135
+ else delete next.team[id];
136
+ }
137
+ next.templateDefaults = options.teamIds ? { ...previous, ...profileDefaults(defaults) } : profileDefaults(defaults);
138
+ return next;
139
+ }
@@ -0,0 +1 @@
1
+ export function promptSafe<T>(text: T): T;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Text another agent wrote, or a command printed, reaches an agent's prompt through
3
+ * mail, handoffs, the board, recall answers, task text and tool results. A literal
4
+ * system-reminder tag in it could pose as the harness, so ALP strips those tags
5
+ * before such text enters a prompt (plans/reference/ALPD.md §32). Stripping repeats
6
+ * until nothing changes, so a tag nested inside another cannot survive.
7
+ */
8
+
9
+ const TAG = /<\s*\/?\s*system-reminder\b[^>]*>/gi;
10
+
11
+ /** `text` without system-reminder open or close tags. */
12
+ export function promptSafe(text) {
13
+ if (typeof text !== 'string') return text;
14
+ for (let current = text; ;) {
15
+ const next = current.replace(TAG, '');
16
+ if (next === current) return next;
17
+ current = next;
18
+ }
19
+ }
@@ -0,0 +1,122 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { readdir, readFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { AlpError } from './errors.js';
5
+ import { settingsKeys, unknownMessage } from './validation.js';
6
+
7
+ /**
8
+ * ACP providers (ALPD §46): agents that speak the Agent Client Protocol, such as Gemini
9
+ * CLI or opencode, defined in the user's library as providers/<id>.json. An agent runs
10
+ * on one with `provider: "<id>"` in its agent.json, or with the model `acp:<id>` or
11
+ * `acp:<id>/<model>`. Providers live only in the library: a project's would run a
12
+ * command from its repository on the user's machine.
13
+ *
14
+ * { "kind": "acp", "label": "Gemini", "command": "gemini", "args": ["--experimental-acp"],
15
+ * "env": {}, "models": [{ "id": "gemini-2.5-pro" }] }
16
+ *
17
+ * The web app's Providers screen (ALPD §65) adds `enabled` (false turns it off), `additionalModels`
18
+ * (added to the models the agent reports, where `models` replaces them) and `params`, which
19
+ * Paseo's catalog carries and ALP keeps without using.
20
+ */
21
+
22
+ export const PROVIDER_SETTINGS = ['$schema', 'kind', 'label', 'description', 'command', 'args', 'env', 'models', 'additionalModels', 'enabled', 'params', 'readOnlyMode'];
23
+ /** Runtimes ALP has built in; no provider takes their names. */
24
+ export const BUILTIN_RUNTIMES = ['codex', 'claude', 'acp', 'antigravity'];
25
+ /** Names no provider takes: the runtimes, and `alp`, which the web app lists ALP's teams under. */
26
+ export const RESERVED_PROVIDERS = [...BUILTIN_RUNTIMES, 'alp'];
27
+
28
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
29
+ const nonempty = value => typeof value === 'string' && value.trim().length > 0;
30
+ const validId = id => typeof id === 'string' && /^[\w.-]+$/.test(id) && id !== '.' && id !== '..';
31
+
32
+ /** Checks a provider definition. */
33
+ export function validateProvider(config, source, id) {
34
+ const check = (ok, message) => { if (!ok) throw new AlpError('INVALID_PROVIDER', `${source}: ${message}`); };
35
+ if (id !== undefined) check(validId(id) && (!RESERVED_PROVIDERS.includes(id) || (id === 'antigravity' && config?.kind === 'antigravity')), `'${id}' cannot name a provider: ${RESERVED_PROVIDERS.join(', ')} are taken`);
36
+ check(object(config), 'expected an object');
37
+ const { unknown } = settingsKeys(config, PROVIDER_SETTINGS);
38
+ check(!unknown.length, unknown.length ? unknownMessage(unknown[0], PROVIDER_SETTINGS) : '');
39
+ check(['acp', 'antigravity'].includes(config.kind), 'kind must be "acp" or "antigravity"');
40
+ if (config.kind === 'antigravity' && id !== undefined) check(id === 'antigravity', 'Antigravity must use provider ID antigravity');
41
+ if (config.readOnlyMode !== undefined) check(config.kind === 'antigravity' && ['instructions', 'reject'].includes(config.readOnlyMode), 'readOnlyMode is only for Antigravity and must be "instructions" or "reject"');
42
+ for (const key of ['label', 'description']) if (config[key] !== undefined) check(typeof config[key] === 'string', `${key} must be a string`);
43
+ check(nonempty(config.command), 'command must be a nonempty string');
44
+ if (config.args !== undefined) check(Array.isArray(config.args) && config.args.every(arg => typeof arg === 'string'), 'args must be a list of strings');
45
+ if (config.env !== undefined) check(object(config.env) && Object.values(config.env).every(value => typeof value === 'string'), 'env must map names to strings');
46
+ for (const key of ['models', 'additionalModels']) {
47
+ if (config[key] === undefined) continue;
48
+ check(Array.isArray(config[key]) && config[key].every(model => object(model) && nonempty(model.id) && !/\s/.test(model.id) &&
49
+ Object.keys(model).every(field => ['id', 'label', 'description'].includes(field)) &&
50
+ ['label', 'description'].every(field => model[field] === undefined || typeof model[field] === 'string')), `${key} must list { id, label?, description? }`);
51
+ check(new Set(config[key].map(model => model.id)).size === config[key].length, `${key} lists an id twice`);
52
+ }
53
+ if (config.enabled !== undefined) check(typeof config.enabled === 'boolean', 'enabled must be true or false');
54
+ if (config.params !== undefined) check(object(config.params), 'params must be an object');
55
+ return config;
56
+ }
57
+
58
+ /**
59
+ * A provider of the library, with its id; undefined when there is none.
60
+ * @returns {Promise<{ id: string, kind: 'acp' | 'antigravity', label: string, description?: string, command: string, args: string[], env: Record<string, string>, models?: Array<{ id: string, label?: string, description?: string }>, additionalModels?: Array<{ id: string, label?: string, description?: string }>, enabled?: boolean, params?: Record<string, unknown>, readOnlyMode?: 'instructions' | 'reject' } | undefined>}
61
+ */
62
+ export async function loadProvider(library, id) {
63
+ if (!library || !validId(id) || (RESERVED_PROVIDERS.includes(id) && id !== 'antigravity')) return undefined;
64
+ const file = path.join(library, 'providers', `${id}.json`);
65
+ let text;
66
+ try { text = await readFile(file, 'utf8'); } catch (error) { if (error.code === 'ENOENT') return undefined; throw error; }
67
+ let config;
68
+ try { config = JSON.parse(text); } catch (cause) { throw new AlpError('INVALID_PROVIDER', `${file}: ${cause.message}`, { cause }); }
69
+ validateProvider(config, file, id);
70
+ // A relative command path is the provider file's; a bare name is looked up on PATH.
71
+ const command = /[\\/]/.test(config.command) ? path.resolve(path.dirname(file), config.command) : config.command;
72
+ return { id, ...config, label: config.label ?? id, command, args: config.args ?? [], env: config.env ?? {} };
73
+ }
74
+
75
+ /** Every provider of the library, by id. */
76
+ export async function listProviders(library) {
77
+ if (!library) return [];
78
+ let names = [];
79
+ try { names = await readdir(path.join(library, 'providers')); } catch (error) { if (error.code !== 'ENOENT') throw error; }
80
+ const providers = [];
81
+ for (const name of names.filter(name => name.endsWith('.json')).sort()) {
82
+ const provider = await loadProvider(library, name.slice(0, -5));
83
+ if (provider) providers.push(provider);
84
+ }
85
+ return providers;
86
+ }
87
+
88
+ /**
89
+ * Splits an ACP model id, `<provider>` or `<provider>/<model>`, as resolveSession keeps it
90
+ * after the `acp:` prefix.
91
+ */
92
+ export function acpModel(model) {
93
+ const slash = model.indexOf('/');
94
+ return slash < 0 ? { provider: model, model: undefined } : { provider: model.slice(0, slash), model: model.slice(slash + 1) || undefined };
95
+ }
96
+
97
+ /**
98
+ * How to start a command without a shell. On Windows a bare name installed with npm is a
99
+ * `<name>.cmd` shim, which spawn refuses to run without one; its target runs instead: the
100
+ * native executable it calls, or the Node script, under this Node. A bare name with an .exe or
101
+ * .com on PATH, any other command, and every command elsewhere are returned as they are.
102
+ * @param {string} command @param {string[]} [args] @param {Record<string, string | undefined>} [env]
103
+ * @returns {{ command: string, args: string[] }}
104
+ */
105
+ export function launchCommand(command, args = [], env = process.env) {
106
+ if (process.platform !== 'win32' || /[\\/]/.test(command) || path.extname(command)) return { command, args };
107
+ const key = Object.keys(env).find(name => name.toUpperCase() === 'PATH');
108
+ for (const directory of (key ? env[key] ?? '' : '').split(';').filter(Boolean)) {
109
+ if (['.exe', '.com'].some(extension => existsSync(path.join(directory, command + extension)))) break;
110
+ const shim = path.join(directory, `${command}.cmd`);
111
+ if (!existsSync(shim)) continue;
112
+ // npm's cmd-shim ends with `"%dp0%\<target>" %*`, or `"%_prog%" [node flags] "%dp0%\<script>" %*`
113
+ // for a Node script. Anything else (a hand-written batch file) stays refused by spawn, as before.
114
+ const text = readFileSync(shim, 'utf8');
115
+ const script = /"%_prog%"((?:\s+[^\s"]+)*)\s+"%~?dp0%\\?([^"]+)"\s+%\*/i.exec(text);
116
+ if (script) return { command: process.execPath, args: [...script[1].split(/\s+/).filter(Boolean), path.join(directory, script[2]), ...args] };
117
+ const native = /^\s*"%~?dp0%\\?([^"]+\.(?:exe|com))"\s+%\*/im.exec(text);
118
+ if (native) return { command: path.join(directory, native[1]), args };
119
+ break;
120
+ }
121
+ return { command, args };
122
+ }
@@ -0,0 +1,238 @@
1
+ import { shippedTemplateRoot } from './template-path.js';
2
+ import { readFile, readdir, stat } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { AlpError } from './errors.js';
5
+ import { validateAgentConfig, validateHook, validateSettings, normalizeMcp, validateResolvedAgent } from './validation.js';
6
+ import { librarySkills } from './library.js';
7
+ export { AlpError } from './errors.js';
8
+
9
+ export async function optionalText(file) {
10
+ try { return await readFile(file, 'utf8'); }
11
+ catch (error) { if (error.code === 'ENOENT') return undefined; throw error; }
12
+ }
13
+
14
+ export async function jsonObject(file, code) {
15
+ const text = await optionalText(file);
16
+ if (text === undefined) return {};
17
+ try {
18
+ const value = JSON.parse(text);
19
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('expected a JSON object');
20
+ return value;
21
+ } catch (cause) { throw new AlpError(code, `${file}: ${cause.message}`, { cause }); }
22
+ }
23
+
24
+ async function entries(directory) {
25
+ try { return (await readdir(directory, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name)); }
26
+ catch (error) { if (error.code === 'ENOENT') return []; throw error; }
27
+ }
28
+
29
+ /** The agents ALP ships; their files come from the package's templates. */
30
+ export const BUILTIN_AGENTS = ['main', 'lead', 'peer', 'oracle', 'reviewer', 'supervisor'];
31
+ /**
32
+ * The package's templates directory, found when first needed. Paseo re-bundles plugin
33
+ * code where import.meta.url is no URL, so this must not run when the module loads;
34
+ * there, bundles pass `templates` instead.
35
+ */
36
+ const templateRoot = () => { try { return shippedTemplateRoot(); } catch { return undefined; } };
37
+ const validName = name => typeof name === 'string' && /^[\w.-]+$/.test(name) && name !== '.' && name !== '..';
38
+
39
+ /** A shipped template file, from `templates` when given (bundles embed them), else from the package. */
40
+ export async function builtinText(relative, templates) {
41
+ if (templates) return templates[relative];
42
+ const root = templateRoot();
43
+ if (!root) return undefined;
44
+ try { return await readFile(new URL(relative, root), 'utf8'); }
45
+ catch (error) { if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return undefined; throw error; }
46
+ }
47
+
48
+ /**
49
+ * Where each agent comes from (ALPD §41): ALP's built-ins, then the user's library
50
+ * (`library`, ALP_HOME), then the project's `.alp/agents`. A later layer replaces an
51
+ * agent of the same name entirely.
52
+ * @returns {Promise<Map<string, { source: 'builtin' | 'library' | 'project', directory?: string, overrides?: 'builtin' | 'library' }>>}
53
+ */
54
+ export async function agentSources(root, { library, templates } = {}) {
55
+ const found = new Map();
56
+ for (const name of BUILTIN_AGENTS) if (await builtinText(`agents/${name}/AGENT.md`, templates) !== undefined) found.set(name, { source: 'builtin' });
57
+ const layer = async (directory, source) => {
58
+ for (const entry of await entries(directory)) {
59
+ if (!entry.isDirectory() || !validName(entry.name)) continue;
60
+ // A library entry needs its instructions; a project directory always counts, as before, and reports a missing AGENT.md when used.
61
+ if (source === 'library' && (await optionalText(path.join(directory, entry.name, 'AGENT.md'))) === undefined) continue;
62
+ const previous = found.get(entry.name);
63
+ found.set(entry.name, { source, directory: path.join(directory, entry.name), ...(previous ? { overrides: previous.source } : {}) });
64
+ }
65
+ };
66
+ if (library) await layer(path.join(library, 'agents'), 'library');
67
+ // Without a project, only the built-ins and the library apply.
68
+ if (root !== undefined) await layer(path.join(path.resolve(root), '.alp', 'agents'), 'project');
69
+ return found;
70
+ }
71
+
72
+ export async function discoverAgents(root, options = {}) {
73
+ return [...(await agentSources(root, options)).keys()].sort((a, b) => a.localeCompare(b));
74
+ }
75
+
76
+ /** What a library kind holds, and the file that defines each entry. */
77
+ const KINDS = {
78
+ skills: name => path.join(name, 'SKILL.md'),
79
+ mcp: name => `${name}.json`,
80
+ hooks: name => `${name}.json`,
81
+ // ACP providers (ALPD §46) live only in the library.
82
+ providers: name => `${name}.json`,
83
+ };
84
+ export const LIBRARY_KINDS = ['agents', ...Object.keys(KINDS)];
85
+
86
+ /**
87
+ * Every entry of one kind with where it comes from (ALPD §41): `agents`, `skills`, `mcp`
88
+ * or `hooks`. A project entry overrides a library one of the same name. `usedBy` lists
89
+ * the agents that name a skill, MCP server or hook; for skills it includes the roles
90
+ * role-skills.json gives it.
91
+ * @returns {Promise<Array<{ name: string, source: 'builtin' | 'library' | 'project', path?: string, overrides?: 'builtin' | 'library', description?: string, usedBy?: string[] }>>}
92
+ */
93
+ export async function libraryEntries(kind, projectRoot, { library, templates } = {}) {
94
+ const root = projectRoot === undefined ? undefined : path.resolve(projectRoot);
95
+ if (kind === 'agents') {
96
+ const rows = [];
97
+ for (const [name, source] of await agentSources(root, { library, templates })) {
98
+ const text = source.directory ? await optionalText(path.join(source.directory, 'agent.json')) : await builtinText(`agents/${name}/agent.json`, templates);
99
+ let description;
100
+ try { description = text === undefined ? undefined : JSON.parse(text).description; } catch {}
101
+ rows.push({ name, source: source.source, ...(source.directory ? { path: source.directory } : {}), ...(source.overrides ? { overrides: source.overrides } : {}), ...(typeof description === 'string' ? { description } : {}) });
102
+ }
103
+ return rows.sort((a, b) => a.name.localeCompare(b.name));
104
+ }
105
+ const file = KINDS[kind];
106
+ if (!file) throw new AlpError('INVALID_KIND', `Unknown library kind '${kind}'; use one of ${LIBRARY_KINDS.join(', ')}`);
107
+ const found = new Map();
108
+ const layer = async (directory, source) => {
109
+ for (const entry of await entries(directory)) {
110
+ const name = kind === 'skills' ? (entry.isDirectory() ? entry.name : undefined) : (entry.isFile() && entry.name.endsWith('.json') ? entry.name.slice(0, -5) : undefined);
111
+ if (!name || !validName(name)) continue;
112
+ const entryPath = path.join(directory, file(name));
113
+ if (!(await stat(entryPath).then(info => info.isFile(), () => false))) continue;
114
+ const previous = found.get(name);
115
+ found.set(name, { name, source, path: entryPath, ...(previous ? { overrides: previous.source } : {}) });
116
+ }
117
+ };
118
+ if (library) await layer(path.join(library, kind), 'library');
119
+ if (root !== undefined && kind !== 'providers') await layer(path.join(root, '.alp', kind), 'project');
120
+ // Who uses each entry: the agents naming it in agent.json, and for skills the roles given it.
121
+ const users = new Map();
122
+ const use = (name, agent) => users.set(name, [...new Set([...(users.get(name) ?? []), agent])]);
123
+ for (const [agent, source] of await agentSources(root, { library, templates })) {
124
+ const text = source.directory ? await optionalText(path.join(source.directory, 'agent.json')) : await builtinText(`agents/${agent}/agent.json`, templates);
125
+ try {
126
+ const config = text === undefined ? {} : JSON.parse(text);
127
+ // An agent names its provider, or the list of each other kind.
128
+ for (const name of kind === 'providers' ? [config.provider].filter(Boolean) : config[kind] ?? []) use(name, agent);
129
+ } catch {}
130
+ }
131
+ if (kind === 'skills' && library) {
132
+ try { for (const [role, names] of Object.entries(JSON.parse((await optionalText(path.join(library, 'role-skills.json'))) ?? '{}'))) for (const name of Array.isArray(names) ? names : []) use(name, role); } catch {}
133
+ }
134
+ return [...found.values()].map(row => ({ ...row, ...(users.has(row.name) ? { usedBy: users.get(row.name).sort() } : {}) })).sort((a, b) => a.name.localeCompare(b.name));
135
+ }
136
+
137
+ /** A named entry of a library kind (skills, mcp, hooks): the project's first, then the library's. */
138
+ async function namedEntry(root, library, kind, name, file) {
139
+ const candidates = [path.join(root, '.alp', kind, file(name)), ...(library ? [path.join(library, kind, file(name))] : [])];
140
+ for (const candidate of candidates) if (await stat(candidate).then(info => info.isFile(), () => false)) return candidate;
141
+ return undefined;
142
+ }
143
+
144
+ /**
145
+ * `library` is the user's library (ALP_HOME): its agents and the skills, MCP servers and
146
+ * hooks agents name. The agent's own skills/ directory replaces a skill of the same name.
147
+ * @returns {Promise<import('./types.js').ResolvedAgent>}
148
+ */
149
+ export async function resolveAgent(projectRoot, { agent, library, templates } = {}) {
150
+ const root = path.resolve(projectRoot);
151
+ const settingsPath = path.join(root, '.alp', 'settings.json');
152
+ const settings = await jsonObject(settingsPath, 'INVALID_SETTINGS');
153
+ const projectRuntime = validateSettings(settings, settingsPath);
154
+ const name = agent ?? settings.defaultAgent ?? 'main';
155
+ if (typeof name !== 'string' || !name.trim() || name === '.' || name === '..' || /[\\/\x00]/.test(name)) {
156
+ throw new AlpError('INVALID_AGENT', 'Agent name must be a single nonempty directory name');
157
+ }
158
+ const source = (await agentSources(root, { library, templates })).get(name);
159
+ if (!source) throw new AlpError('AGENT_NOT_FOUND', `Agent '${name}' not found: it is not built in, nor under ${library ? `${path.join(library, 'agents')} or ` : ''}${path.join(root, '.alp', 'agents')}`);
160
+ const directory = source.directory;
161
+ const agentPath = directory ? path.join(directory, 'AGENT.md') : `built-in agents/${name}/AGENT.md`;
162
+ const instructions = directory ? await optionalText(agentPath) : await builtinText(`agents/${name}/AGENT.md`, templates);
163
+ if (instructions === undefined) throw new AlpError('AGENT_INSTRUCTIONS_MISSING', `Missing ${agentPath}`);
164
+ const configPath = directory ? path.join(directory, 'agent.json') : `built-in agents/${name}/agent.json`;
165
+ const configText = directory ? await optionalText(configPath) : await builtinText(`agents/${name}/agent.json`, templates);
166
+ let config = {};
167
+ if (configText !== undefined) {
168
+ try { config = JSON.parse(configText); }
169
+ catch (cause) { throw new AlpError('INVALID_AGENT_CONFIG', `${configPath}: ${cause.message}`, { cause }); }
170
+ }
171
+ validateAgentConfig(config, configPath);
172
+
173
+ // The role's library skills; a project skill of the same name in .alp/skills replaces one.
174
+ const skills = [];
175
+ for (const skill of library ? await librarySkills(library, name) : []) {
176
+ skills.push({ name: skill.name, path: (await namedEntry(root, undefined, 'skills', skill.name, entry => path.join(entry, 'SKILL.md'))) ?? skill.path });
177
+ }
178
+ const addSkill = (skillName, skillPath) => {
179
+ const shadowed = skills.findIndex(skill => skill.name === skillName);
180
+ if (shadowed >= 0) skills.splice(shadowed, 1);
181
+ skills.push({ name: skillName, path: skillPath });
182
+ };
183
+ for (const skillName of config.skills ?? []) {
184
+ const skillPath = await namedEntry(root, library, 'skills', skillName, entry => path.join(entry, 'SKILL.md'));
185
+ if (!skillPath) throw new AlpError('SKILL_NOT_FOUND', `${configPath}: skill '${skillName}' is neither in ${path.join(root, '.alp', 'skills')} nor in the library`);
186
+ addSkill(skillName, skillPath);
187
+ }
188
+ if (directory) {
189
+ for (const entry of await entries(path.join(directory, 'skills'))) {
190
+ if (!entry.isDirectory()) continue;
191
+ const skillPath = path.join(directory, 'skills', entry.name, 'SKILL.md');
192
+ try { if ((await stat(skillPath)).isFile()) addSkill(entry.name, skillPath); }
193
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
194
+ }
195
+ }
196
+ skills.sort((a, b) => a.name.localeCompare(b.name));
197
+
198
+ const hooks = directory ? (await entries(path.join(directory, 'hooks'))).filter(e => e.isFile()).map(e => ({ name: e.name, path: path.join(directory, 'hooks', e.name) })) : [];
199
+ for (const hookName of config.hooks ?? []) {
200
+ const hookPath = await namedEntry(root, library, 'hooks', hookName, entry => `${entry}.json`);
201
+ if (!hookPath) throw new AlpError('HOOK_NOT_FOUND', `${configPath}: hook '${hookName}' is neither in ${path.join(root, '.alp', 'hooks')} nor in the library`);
202
+ validateHook(await jsonObject(hookPath, 'INVALID_HOOK'), hookPath);
203
+ hooks.push({ name: hookName, path: hookPath });
204
+ }
205
+
206
+ // Named MCP servers first, then the agent's own .mcp.json; a name used twice is an error.
207
+ const servers = {};
208
+ for (const serverName of config.mcp ?? []) {
209
+ const serverPath = await namedEntry(root, library, 'mcp', serverName, entry => `${entry}.json`);
210
+ if (!serverPath) throw new AlpError('MCP_NOT_FOUND', `${configPath}: MCP server '${serverName}' is neither in ${path.join(root, '.alp', 'mcp')} nor in the library`);
211
+ const definition = await jsonObject(serverPath, 'INVALID_MCP');
212
+ Object.assign(servers, normalizeMcp({ mcpServers: { [serverName]: definition } }, path.dirname(serverPath), serverPath).mcpServers);
213
+ }
214
+ const mcpPath = directory ? path.join(directory, '.mcp.json') : undefined;
215
+ const own = mcpPath ? normalizeMcp(await jsonObject(mcpPath, 'INVALID_MCP'), directory, mcpPath).mcpServers : {};
216
+ for (const [serverName, server] of Object.entries(own)) {
217
+ if (Object.hasOwn(servers, serverName)) throw new AlpError('INVALID_MCP', `${mcpPath}: MCP server '${serverName}' is also named in ${configPath}`);
218
+ servers[serverName] = server;
219
+ }
220
+
221
+ // settings.json's runtime is the project's choice for every agent, so it comes before the agent's own (ALPD §42).
222
+ const runtime = {
223
+ ...(config.provider !== undefined ? { provider: config.provider } : {}),
224
+ ...(config.model !== undefined ? { model: config.model } : {}),
225
+ ...(config.thinking !== undefined ? { reasoning: config.thinking } : {}),
226
+ ...projectRuntime,
227
+ };
228
+ return validateResolvedAgent({
229
+ name, projectRoot: root, source: source.source,
230
+ instructions: { project: (await optionalText(path.join(root, 'ALP.md'))) ?? '', agent: instructions },
231
+ skills, hooks, runtime, projectRuntime: { ...projectRuntime },
232
+ ...(config.mode !== undefined ? { mode: config.mode } : {}),
233
+ ...(config.description !== undefined ? { description: config.description } : {}),
234
+ ...(typeof config.context === 'number' ? { context: config.context } : {}),
235
+ ...(config.autocompact !== undefined ? { autocompact: config.autocompact } : {}),
236
+ mcp: { mcpServers: servers },
237
+ });
238
+ }
@@ -0,0 +1,20 @@
1
+ import { lstat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { AlpError } from './errors.js';
4
+
5
+ /** Preflight static repository paths. Does not isolate concurrent same-user processes. */
6
+ export async function assertNoSymlinks(directory, relative) {
7
+ const root = path.resolve(directory);
8
+ const target = path.resolve(root, relative);
9
+ const tail = path.relative(root, target);
10
+ if (tail === '..' || tail.startsWith(`..${path.sep}`) || path.isAbsolute(tail)) {
11
+ throw new AlpError('UNSAFE_PATH', 'Path escapes its scope');
12
+ }
13
+ let current = root;
14
+ for (const part of ['', ...tail.split(path.sep).filter(Boolean)]) {
15
+ if (part) current = path.join(current, part);
16
+ const info = await lstat(current).catch(error => { if (error.code !== 'ENOENT') throw error; });
17
+ if (!info) return;
18
+ if (info.isSymbolicLink()) throw new AlpError('UNSAFE_PATH', `Refusing symlink in scoped path: ${current}`);
19
+ }
20
+ }
package/src/shell.js ADDED
@@ -0,0 +1,28 @@
1
+ import { execFile, spawn } from 'node:child_process';
2
+
3
+ /**
4
+ * Starts `command` through the platform's shell: /bin/sh -c in its own process group, or on
5
+ * Windows cmd.exe /d /s /c with the line quoted whole (cmd strips only the outermost quotes,
6
+ * and Node would otherwise escape the quotes inside it).
7
+ * @param {string} command
8
+ * @param {import('node:child_process').SpawnOptions} options
9
+ */
10
+ export function spawnShell(command, options) {
11
+ if (process.platform !== 'win32') return spawn('/bin/sh', ['-c', command], { ...options, detached: true });
12
+ return spawn(process.env.ComSpec ?? 'cmd.exe', ['/d', '/s', '/c', `"${command}"`], { ...options, windowsHide: true, windowsVerbatimArguments: true });
13
+ }
14
+
15
+ /**
16
+ * Signals a command `spawnShell` started and everything it started. Windows has no signals and
17
+ * cmd.exe's children outlive it, so there the whole tree is ended at once by the first signal
18
+ * and no cleanup runs.
19
+ * @param {import('node:child_process').ChildProcess} child
20
+ * @param {NodeJS.Signals} name
21
+ */
22
+ export function signalShell(child, name) {
23
+ if (process.platform === 'win32') {
24
+ if (name === 'SIGTERM') execFile('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true }, () => {});
25
+ return;
26
+ }
27
+ try { process.kill(-child.pid, name); } catch { child.kill(name); }
28
+ }
@@ -0,0 +1,9 @@
1
+ export const STATE_SCHEMA: number;
2
+ export const OLDEST_STATE_SCHEMA: number;
3
+ export function stateDir(home: string): string;
4
+ export function schemaFile(home: string): string;
5
+ export function migrationFile(home: string): string;
6
+ export type StateSchemaInfo = { schema: number; fresh: boolean; marked: boolean; by?: string };
7
+ export function readStateSchema(home: string): Promise<StateSchemaInfo>;
8
+ export function assertStateSupported(info: StateSchemaInfo, home: string): StateSchemaInfo;
9
+ export function writeStateSchema(home: string, schema: number, by?: string): Promise<void>;
@@ -0,0 +1,60 @@
1
+ import { readdir, readFile, mkdir } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { AlpError } from './errors.js';
4
+ import { writeAtomic } from './atomic.js';
5
+
6
+ /**
7
+ * The version of alpd's durable state in $ALP_HOME/state (plans/reference/ALPD.md §68).
8
+ * `state/schema.json` names it; a state directory without one holds schema 1, the format
9
+ * every ALP before 0.11 wrote. An ALP that finds a newer schema than it knows refuses to
10
+ * open the state and changes nothing in it; an older one is migrated forward at start.
11
+ *
12
+ * 1: session records, receipts and timelines as of ALPD §66; timeline lines may lack a cursor.
13
+ * 2: every timeline line carries its cursor.
14
+ */
15
+ export const STATE_SCHEMA = 2;
16
+ export const OLDEST_STATE_SCHEMA = 1;
17
+
18
+ export const stateDir = home => path.join(home, 'state');
19
+ export const schemaFile = home => path.join(stateDir(home), 'schema.json');
20
+ export const migrationFile = home => path.join(stateDir(home), 'migration.json');
21
+
22
+ /** Directories whose entries mean the state holds data, so a missing marker means schema 1, not a fresh home. */
23
+ const DATA = ['sessions', 'timeline', 'receipts'];
24
+
25
+ /**
26
+ * What the state directory says about its schema. `fresh` when it holds no data and no marker.
27
+ * An unreadable marker is an error: ALP does not guess which format the data is in.
28
+ * @param {string} home
29
+ * @returns {Promise<{ schema: number, fresh: boolean, marked: boolean, by?: string }>}
30
+ */
31
+ export async function readStateSchema(home) {
32
+ let text;
33
+ try { text = await readFile(schemaFile(home), 'utf8'); }
34
+ catch (error) {
35
+ if (error?.code !== 'ENOENT') throw error;
36
+ for (const name of DATA) {
37
+ const entries = await readdir(path.join(stateDir(home), name)).catch(error => { if (error?.code === 'ENOENT') return []; throw error; });
38
+ if (entries.some(entry => !entry.endsWith('.tmp'))) return { schema: 1, fresh: false, marked: false };
39
+ }
40
+ return { schema: STATE_SCHEMA, fresh: true, marked: false };
41
+ }
42
+ let marker;
43
+ try { marker = JSON.parse(text); } catch {}
44
+ if (!marker || !Number.isSafeInteger(marker.schema) || marker.schema < 1) {
45
+ throw new AlpError('STATE_SCHEMA_UNREADABLE', `${schemaFile(home)} does not name a state schema; ALP does not open state whose format it cannot tell. Restore a backup, or put back the file`);
46
+ }
47
+ return { schema: marker.schema, fresh: false, marked: true, ...(typeof marker.by === 'string' ? { by: marker.by } : {}) };
48
+ }
49
+
50
+ /** Refuses state written in a schema this ALP does not know, before anything touches it. */
51
+ export function assertStateSupported(info, home) {
52
+ if (info.schema <= STATE_SCHEMA) return info;
53
+ throw new AlpError('STATE_SCHEMA_TOO_NEW', `${stateDir(home)} holds state schema ${info.schema}${info.by ? `, written by ${info.by}` : ''}; this ALP knows schema ${STATE_SCHEMA} at most and leaves it untouched. Run that ALP version or a newer one, or replace it with a backup made for this version: alp restore FILE --replace-newer (docs/alp/data-recovery.md)`);
54
+ }
55
+
56
+ /** Records the schema the state is now in; written atomically. */
57
+ export async function writeStateSchema(home, schema, by) {
58
+ await mkdir(stateDir(home), { recursive: true, mode: 0o700 });
59
+ await writeAtomic(schemaFile(home), JSON.stringify({ schema, by, at: new Date().toISOString() }) + '\n');
60
+ }