@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
package/src/log.js ADDED
@@ -0,0 +1,177 @@
1
+ import { open, stat } from 'node:fs/promises';
2
+
3
+ /**
4
+ * Structured, redacted logging for ALP processes. One JSON object per line, written to stderr
5
+ * unless a sink is given: stdout belongs to command output, `--json` and the ACP stream, so a
6
+ * log line never lands there. A record has `time`, `level`, `component`, an optional error
7
+ * `code`, `msg`, correlation ids (`sessionId`, `rootId`, `assignmentId`, `clientMessageId`,
8
+ * `requestId`, `agent`) and a few extra fields. Logging never throws.
9
+ *
10
+ * Nothing sensitive is logged by default: keys that carry prompts, environments or credentials
11
+ * are replaced, secret-looking values and the values of secret-named environment variables are
12
+ * scrubbed from every string, and long strings are cut.
13
+ */
14
+
15
+ export const LEVELS = Object.freeze({ debug: 10, info: 20, warn: 30, error: 40 });
16
+ export const REDACTED = '[redacted]';
17
+ export const OMITTED = '[omitted]';
18
+ const MAX_STRING = 2000;
19
+ const MAX_DEPTH = 5;
20
+ const MAX_KEYS = 50;
21
+ const MAX_ITEMS = 20;
22
+ const STACK_LINES = 8;
23
+
24
+ /** Fields that hold a credential: the value is replaced. */
25
+ const SECRET_KEY = /pass(word|wd)?|secret|token|api[-_]?key|authorization|credential|cookie|private[-_]?key|bearer|signature/i;
26
+ /** Fields that hold user content or the process environment: never logged, not even scrubbed. */
27
+ const OMITTED_KEY = /^(env|environment|prompt|prompts|instructions|content|text|input|headers|body|args|argv)$/i;
28
+ /** Environment variable names whose values are secrets, to find them inside any text. */
29
+ const SECRET_NAME = /KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH|COOKIE|PRIVATE/i;
30
+
31
+ const PATTERNS = [
32
+ [/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi, `$1 ${REDACTED}`],
33
+ [/\b(sk|rk|pk)-[A-Za-z0-9_-]{16,}/g, REDACTED],
34
+ [/\bgh[pousr]_[A-Za-z0-9]{20,}/g, REDACTED],
35
+ [/\bgithub_pat_[A-Za-z0-9_]{20,}/g, REDACTED],
36
+ [/\bxox[abprs]-[A-Za-z0-9-]{10,}/g, REDACTED],
37
+ [/\bAKIA[0-9A-Z]{16}\b/g, REDACTED],
38
+ [/\bAIza[0-9A-Za-z_-]{30,}/g, REDACTED],
39
+ [/\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}/g, REDACTED],
40
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(-----END [A-Z ]*PRIVATE KEY-----|$)/g, REDACTED],
41
+ [/(\b[a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+:[^\s/@]+@/gi, `$1${REDACTED}@`],
42
+ // NAME=value and NAME: value for names that look like secrets; also JSON "token": "value".
43
+ [/(\b[\w.-]*(?:key|token|secret|passw(?:or)?d|credential|authorization|cookie)[\w.-]*["']?\s*[=:]\s*)(?:"[^"]{4,}"|'[^']{4,}'|[^\s,;&"']{8,})/gi, `$1${REDACTED}`],
44
+ // --token value, --api-key value
45
+ [/(--[\w-]*(?:key|token|secret|passw(?:or)?d|credential)[\w-]*\s+)[^\s-][^\s]*/gi, `$1${REDACTED}`],
46
+ ];
47
+
48
+ /** Values of secret-named variables in `env`, long enough that replacing them is not noise. */
49
+ function secretValues(env) {
50
+ const values = new Set();
51
+ for (const [name, value] of Object.entries(env ?? {})) if (typeof value === 'string' && value.length >= 8 && SECRET_NAME.test(name)) values.add(value);
52
+ return [...values].sort((a, b) => b.length - a.length);
53
+ }
54
+
55
+ /** `text` without anything that looks like a credential, cut to `max` characters. */
56
+ export function scrubText(text, { env = process.env, max = MAX_STRING } = {}) {
57
+ let result = String(text);
58
+ for (const value of secretValues(env)) result = result.split(value).join(REDACTED);
59
+ for (const [pattern, replacement] of PATTERNS) result = result.replace(pattern, replacement);
60
+ return result.length > max ? `${result.slice(0, max)}... (${result.length - max} more characters)` : result;
61
+ }
62
+
63
+ /** An error as a plain, scrubbed object: name, code, message and the top of its stack. */
64
+ export function describeError(error, options = {}) {
65
+ if (!(error instanceof Error)) return { message: scrubText(error, options) };
66
+ const stack = typeof error.stack === 'string' ? error.stack.split('\n').slice(0, STACK_LINES).join('\n') : undefined;
67
+ return {
68
+ name: error.name,
69
+ ...(typeof error.code === 'string' || typeof error.code === 'number' ? { code: error.code } : {}),
70
+ message: scrubText(error.message, options),
71
+ ...(stack ? { stack: scrubText(stack, options) } : {}),
72
+ ...(error.cause !== undefined ? { cause: describeError(error.cause, options) } : {}),
73
+ };
74
+ }
75
+
76
+ /** A copy of `value` that is safe to write to a log: see the module comment. */
77
+ export function redact(value, options = {}, depth = 0, seen = new WeakSet()) {
78
+ if (value === null || value === undefined || typeof value === 'boolean' || typeof value === 'number') return value;
79
+ if (typeof value === 'string') return scrubText(value, options);
80
+ if (typeof value === 'bigint') return String(value);
81
+ if (typeof value !== 'object') return undefined;
82
+ if (value instanceof Error) return describeError(value, options);
83
+ if (seen.has(value)) return '[circular]';
84
+ if (depth >= MAX_DEPTH) return '[too deep]';
85
+ seen.add(value);
86
+ if (Array.isArray(value)) {
87
+ const items = value.slice(0, MAX_ITEMS).map(item => redact(item, options, depth + 1, seen));
88
+ return value.length > MAX_ITEMS ? [...items, `... ${value.length - MAX_ITEMS} more`] : items;
89
+ }
90
+ const result = {};
91
+ for (const [key, item] of Object.entries(value).slice(0, MAX_KEYS)) {
92
+ result[key] = OMITTED_KEY.test(key) ? OMITTED : SECRET_KEY.test(key) ? REDACTED : redact(item, options, depth + 1, seen);
93
+ }
94
+ return result;
95
+ }
96
+
97
+ /** Fields every record may carry to tie its lines to a session, assignment or request. */
98
+ const CORRELATION = ['sessionId', 'rootId', 'assignmentId', 'clientMessageId', 'requestId', 'agent'];
99
+
100
+ const stderrSink = line => { try { process.stderr.write(`${line}\n`); } catch {} };
101
+
102
+ /**
103
+ * A logger for `component` (`alpd`, `store`, `runtime`, `acp`, ...). `level` defaults to
104
+ * ALP_LOG_LEVEL, then `info`. `context` ids are added to every record. `write` receives one
105
+ * serialized line without its newline.
106
+ */
107
+ export function createLogger({ component, level, context = {}, write = stderrSink, now = () => new Date(), env = process.env } = {}) {
108
+ const threshold = LEVELS[level ?? env.ALP_LOG_LEVEL] ?? LEVELS.info;
109
+ const options = { env };
110
+ const emit = (name, message, fields = {}) => {
111
+ if (LEVELS[name] < threshold) return;
112
+ try {
113
+ const { code, error, ...rest } = { ...context, ...fields };
114
+ const record = {
115
+ time: now().toISOString(), level: name, component, ...(code ? { code: String(code) } : {}), msg: scrubText(message, options),
116
+ };
117
+ for (const key of CORRELATION) if (rest[key] !== undefined) record[key] = redact(String(rest[key]), options);
118
+ if (error !== undefined) {
119
+ record.error = describeError(error, options);
120
+ // An error's own code names the failure when the caller gave none.
121
+ if (!record.code && record.error.code !== undefined) record.code = String(record.error.code);
122
+ }
123
+ for (const [key, value] of Object.entries(rest)) {
124
+ if (CORRELATION.includes(key)) continue;
125
+ record[key] = OMITTED_KEY.test(key) ? OMITTED : SECRET_KEY.test(key) ? REDACTED : redact(value, options);
126
+ }
127
+ write(JSON.stringify(record));
128
+ } catch {}
129
+ };
130
+ return {
131
+ component,
132
+ debug: (message, fields) => emit('debug', message, fields),
133
+ info: (message, fields) => emit('info', message, fields),
134
+ warn: (message, fields) => emit('warn', message, fields),
135
+ error: (message, fields) => emit('error', message, fields),
136
+ child: (extra = {}, name = component) => createLogger({ component: name, level, context: { ...context, ...extra }, write, now, env }),
137
+ };
138
+ }
139
+
140
+ const TAIL_BYTES = 256 * 1024;
141
+
142
+ /**
143
+ * The records at the end of a log file, oldest first. Lines that are not ALP records (output
144
+ * from before structured logging, a runtime's own stderr) are returned as `{ legacy: text }`
145
+ * with the text scrubbed. A file that is missing gives an empty list.
146
+ */
147
+ export async function readLogTail(file, { bytes = TAIL_BYTES, env = process.env } = {}) {
148
+ let handle;
149
+ try {
150
+ handle = await open(file, 'r');
151
+ const { size } = await stat(file);
152
+ const start = Math.max(0, size - bytes);
153
+ const buffer = Buffer.alloc(size - start);
154
+ await handle.read(buffer, 0, buffer.length, start);
155
+ const lines = buffer.toString('utf8').split('\n');
156
+ // The first line of a tail may be cut.
157
+ if (start > 0) lines.shift();
158
+ const records = [];
159
+ for (const line of lines) {
160
+ if (!line.trim()) continue;
161
+ try {
162
+ const record = JSON.parse(line);
163
+ if (record && typeof record === 'object' && typeof record.time === 'string' && typeof record.level === 'string' && typeof record.msg === 'string') {
164
+ records.push(redact(record, { env }));
165
+ continue;
166
+ }
167
+ } catch {}
168
+ records.push({ legacy: scrubText(line, { env, max: 500 }) });
169
+ }
170
+ return records;
171
+ } catch (error) {
172
+ if (error?.code === 'ENOENT') return [];
173
+ throw error;
174
+ } finally {
175
+ await handle?.close();
176
+ }
177
+ }
@@ -0,0 +1,9 @@
1
+ export type Migration = { from: number; to: number; name: string; run(home: string): Promise<void> };
2
+ export const MIGRATIONS: Migration[];
3
+ export type MigrationResult = { from: number; to: number; steps: Array<{ name: string; from: number; to: number; backup: string; resumed: boolean }> };
4
+ export function migrateState(home: string, options?: {
5
+ by?: string;
6
+ migrations?: Migration[];
7
+ target?: number;
8
+ log?: { info(message: string, fields?: object): void; warn(message: string, fields?: object): void };
9
+ }): Promise<MigrationResult>;
@@ -0,0 +1,111 @@
1
+ import { readdir, readFile, rm, stat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { AlpError } from './errors.js';
4
+ import { writeAtomic } from './atomic.js';
5
+ import { STATE_SCHEMA, assertStateSupported, migrationFile, readStateSchema, stateDir, writeStateSchema } from './state-schema.js';
6
+ import { STATE_UNITS, automaticBackup, restoreBackup } from './backup.js';
7
+
8
+ /**
9
+ * Versioned migrations of alpd's state (plans/reference/ALPD.md §68). Each step moves the
10
+ * state from one schema to the next and is idempotent: run again over its own partial or
11
+ * complete result it writes the same files. Before a step, the state is saved to a backup
12
+ * and `state/migration.json` names it. Policy:
13
+ *
14
+ * - interrupted (a crash, a kill, a power cut): the next start finds the journal and runs
15
+ * the step again over what it left, with the same backup;
16
+ * - failed (the step threw): the state is restored from that backup, the journal removed,
17
+ * and the start fails with STATE_MIGRATION_FAILED; the backup stays.
18
+ *
19
+ * The schema marker is written only after a step completed, and the journal removed last.
20
+ */
21
+
22
+ /**
23
+ * Schema 1 → 2: every timeline line gets the cursor the store already gives it when reading
24
+ * (a line without a larger cursor than the line before is numbered one past it). Lines that
25
+ * do not parse are kept as they are; the store skips them, as before.
26
+ */
27
+ async function timelineCursors(home) {
28
+ const directory = path.join(stateDir(home), 'timeline');
29
+ const names = await readdir(directory).catch(error => { if (error?.code === 'ENOENT') return []; throw error; });
30
+ for (const name of names.filter(name => name.endsWith('.jsonl'))) {
31
+ const file = path.join(directory, name);
32
+ const lines = [];
33
+ let cursor = 0;
34
+ let changed = false;
35
+ for (const line of (await readFile(file, 'utf8')).split('\n')) {
36
+ if (!line) continue;
37
+ let envelope;
38
+ try { envelope = JSON.parse(line); } catch { lines.push(line); continue; }
39
+ cursor = typeof envelope?.cursor === 'number' && envelope.cursor > cursor ? envelope.cursor : cursor + 1;
40
+ if (envelope?.cursor === cursor) { lines.push(line); continue; }
41
+ lines.push(JSON.stringify({ ...envelope, cursor }));
42
+ changed = true;
43
+ }
44
+ if (changed) await writeAtomic(file, lines.map(line => `${line}\n`).join(''));
45
+ }
46
+ }
47
+
48
+ /** @type {Array<{ from: number, to: number, name: string, run(home: string): Promise<void> }>} */
49
+ export const MIGRATIONS = [
50
+ { from: 1, to: 2, name: 'timeline-cursors', run: timelineCursors },
51
+ ];
52
+
53
+ async function readJournal(home) {
54
+ try { return JSON.parse(await readFile(migrationFile(home), 'utf8')); }
55
+ catch (error) {
56
+ if (error?.code === 'ENOENT') return undefined;
57
+ // A journal cut short says only that a migration was under way: start the step over.
58
+ return {};
59
+ }
60
+ }
61
+
62
+ const exists = file => stat(file).then(() => true, () => false);
63
+
64
+ /**
65
+ * Brings the state to `target` (the current schema), or refuses a newer one without touching it.
66
+ * Returns the steps it ran; `resumed` marks a step an interruption had left unfinished.
67
+ * @param {string} home
68
+ * @param {{ by?: string, migrations?: typeof MIGRATIONS, target?: number, log?: { info(message: string, fields?: object): void, warn(message: string, fields?: object): void } }} [options]
69
+ */
70
+ export async function migrateState(home, { by = 'alp', migrations = MIGRATIONS, target = STATE_SCHEMA, log } = {}) {
71
+ const info = assertStateSupported(await readStateSchema(home), home);
72
+ let journal = await readJournal(home);
73
+ if (journal && Number.isSafeInteger(journal.to) && journal.to <= info.schema) {
74
+ // The step completed and only the journal's removal was cut off.
75
+ await rm(migrationFile(home), { force: true });
76
+ journal = undefined;
77
+ }
78
+ if (info.fresh && !journal) {
79
+ await writeStateSchema(home, target, by);
80
+ return { from: target, to: target, steps: [] };
81
+ }
82
+ let schema = info.schema;
83
+ const steps = [];
84
+ while (schema < target) {
85
+ const step = migrations.find(candidate => candidate.from === schema);
86
+ if (!step) throw new AlpError('STATE_MIGRATION_MISSING', `ALP has no migration from state schema ${schema}; the state was not changed`);
87
+ const resumed = journal?.to === step.to && typeof journal.backup === 'string' && await exists(journal.backup);
88
+ const backup = resumed ? journal.backup : await automaticBackup(home, `pre-migrate-v${step.from}-v${step.to}`, { units: STATE_UNITS, by });
89
+ if (!resumed) await writeAtomic(migrationFile(home), JSON.stringify({ from: step.from, to: step.to, name: step.name, backup, by, startedAt: new Date().toISOString() }) + '\n');
90
+ log?.info(`${resumed ? 'continuing' : 'running'} state migration ${step.name} (schema ${step.from} → ${step.to}); backup ${backup}`, { code: 'ALP_STATE_MIGRATING', from: step.from, to: step.to, migration: step.name, resumed });
91
+ try {
92
+ await step.run(home);
93
+ } catch (error) {
94
+ try {
95
+ await restoreBackup({ home, file: backup, projects: false, safety: false, clearLive: false, by });
96
+ } catch (rollback) {
97
+ // The journal stays: the next start runs the step again over what is there.
98
+ throw new AlpError('STATE_MIGRATION_ROLLBACK_FAILED', `state migration ${step.name} failed (${error?.message ?? error}) and putting the state back from ${backup} failed too (${rollback?.message ?? rollback}); the next start tries the migration again. See docs/alp/data-recovery.md`, { cause: error });
99
+ }
100
+ await rm(migrationFile(home), { force: true });
101
+ throw new AlpError('STATE_MIGRATION_FAILED', `state migration ${step.name} (schema ${step.from} → ${step.to}) failed: ${error?.message ?? error}; the state was put back from ${backup} and is unchanged. See docs/alp/data-recovery.md`, { cause: error });
102
+ }
103
+ await writeStateSchema(home, step.to, by);
104
+ await rm(migrationFile(home), { force: true });
105
+ steps.push({ name: step.name, from: step.from, to: step.to, backup, resumed });
106
+ schema = step.to;
107
+ journal = undefined;
108
+ }
109
+ if (!info.marked && !steps.length) await writeStateSchema(home, schema, by);
110
+ return { from: info.schema, to: schema, steps };
111
+ }
package/src/models.js ADDED
@@ -0,0 +1,45 @@
1
+ /** Models ALP offers, with the efforts each takes: the runtime's catalog and alp setup read this list. */
2
+ const codexFull = ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'];
3
+ const codexStandard = ['low', 'medium', 'high', 'xhigh', 'max'];
4
+ const claudeFull = ['low', 'medium', 'high', 'xhigh', 'max', 'ultracode'];
5
+ const claudeLegacy = ['off', 'low', 'medium', 'high', 'xhigh', 'max', 'ultracode'];
6
+ const claude46 = ['off', 'low', 'medium', 'high', 'max'];
7
+
8
+ /**
9
+ * @type {Array<{ runtime: 'codex' | 'claude' | 'antigravity', id: string, label: string, description: string, efforts: string[], effort?: string }>}
10
+ */
11
+ export const MODELS = [
12
+ ['codex', 'gpt-6.1-sol', 'GPT-6.1-Sol', 'Latest workhorse model for coding and everyday work.', codexFull, 'low'],
13
+ // ALP runs GPT-6 Astra at low effort only.
14
+ ['codex', 'gpt-6-astra', 'GPT-6-Astra', 'Frontier intelligence for the most demanding work.', ['low'], 'low'],
15
+ ['codex', 'gpt-6-sol', 'GPT-6-Sol', 'Previous generation workhorse model.', codexFull, 'low'],
16
+ ['codex', 'gpt-6-luna', 'GPT-6-Luna', 'Fast and affordable model for easier tasks.', codexStandard, 'low'],
17
+ ['codex', 'gpt-5.6-sol', 'GPT-5.6-Sol', 'Older generation workhorse model.', codexFull, 'low'],
18
+ ['codex', 'gpt-5.6-terra', 'GPT-5.6-Terra', 'Older balanced model for straightforward work.', codexFull, 'low'],
19
+ ['codex', 'gpt-5.6-luna', 'GPT-5.6-Luna', 'Older fast and efficient model.', codexStandard, 'low'],
20
+ ['claude', 'claude-opus-5-5', 'Opus 5.5', 'Latest release.', claudeFull, 'medium'],
21
+ ['claude', 'claude-opus-5', 'Opus 5', 'Previous release.', claudeLegacy, 'high'],
22
+ ['claude', 'claude-fable-5-1', 'Fable 5.1', 'Most powerful model.', claudeFull, 'high'],
23
+ ['claude', 'claude-fable-5', 'Fable 5', 'Previous release.', claudeFull, 'high'],
24
+ ['claude', 'claude-opus-4-8[1m]', 'Opus 4.8 1M', 'Opus 4.8 with 1M context window.', claudeLegacy, 'high'],
25
+ ['claude', 'claude-opus-4-8', 'Opus 4.8', 'Previous release.', claudeLegacy, 'high'],
26
+ ['claude', 'claude-sonnet-5-5', 'Sonnet 5.5', 'Best for everyday tasks.', claudeFull, 'medium'],
27
+ ['claude', 'claude-sonnet-5', 'Sonnet 5', 'Previous release.', claudeLegacy, 'high'],
28
+ ['claude', 'claude-sonnet-5[1m]', 'Sonnet 5 1M', 'Sonnet 5 with 1M context window.', claudeLegacy, 'high'],
29
+ ['claude', 'claude-opus-4-7[1m]', 'Opus 4.7 1M', 'Opus 4.7 with 1M context window.', claudeLegacy, 'high'],
30
+ ['claude', 'claude-opus-4-7', 'Opus 4.7', 'Previous release.', claudeLegacy, 'high'],
31
+ ['claude', 'claude-opus-4-6[1m]', 'Opus 4.6 1M', 'Opus 4.6 with 1M context window.', claude46, 'high'],
32
+ ['claude', 'claude-opus-4-6', 'Opus 4.6', 'Most capable for complex work.', claude46, 'high'],
33
+ ['claude', 'claude-sonnet-4-6[1m]', 'Sonnet 4.6 1M', 'Sonnet 4.6 with 1M context window.', claude46, 'high'],
34
+ ['claude', 'claude-sonnet-4-6', 'Sonnet 4.6', 'Best for everyday tasks.', claude46, 'high'],
35
+ ['claude', 'claude-haiku-4-5', 'Haiku 4.5', 'Fastest for quick answers.', [], undefined],
36
+ ['claude', 'sonnet', 'sonnet', 'From Claude settings.json model.', [], undefined],
37
+ ['antigravity', 'gemini-3.8-flash-high', 'Gemini 3.8 Flash (High)', 'High reasoning effort workhorse for complex tasks.', [], undefined],
38
+ ['antigravity', 'gemini-3.8-flash-medium', 'Gemini 3.8 Flash (Medium)', 'Balanced speed and reasoning.', [], undefined],
39
+ ['antigravity', 'gemini-3.8-flash-low', 'Gemini 3.8 Flash (Low)', 'Fast and efficient for straightforward work.', [], undefined],
40
+ ['antigravity', 'gemini-2.5-pro', 'Gemini 2.5 Pro', 'Deep reasoning for coding and analysis.', [], undefined],
41
+ ['antigravity', 'gemini-2.5-flash', 'Gemini 2.5 Flash', 'Fast and versatile everyday model.', [], undefined],
42
+ ['antigravity', 'claude-sonnet-4-6', 'Claude Sonnet 4.6', 'Sonnet 4.6 via Antigravity.', [], undefined],
43
+ ['antigravity', 'claude-opus-4-6-thinking', 'Claude Opus 4.6 Thinking', 'Opus 4.6 with thinking via Antigravity.', [], undefined],
44
+ ['antigravity', 'gpt-oss-120b-medium', 'GPT-OSS 120B (Medium)', 'Open weights model via Antigravity.', [], undefined],
45
+ ].map(([runtime, id, label, description, efforts, effort]) => ({ runtime, id, label, description, efforts, ...(effort ? { effort } : {}) }));
@@ -0,0 +1,14 @@
1
+ export type PermissionBase = 'read-only' | 'workspace-write' | 'full-access';
2
+ export type PermissionProfile = { name: string; base: PermissionBase; allow: string[]; ask: string[]; deny: string[]; beyondMode: 'refuse' | 'ask'; workdir?: 'copy' };
3
+ export const BASES: PermissionBase[];
4
+ export const ADVISORS: string[];
5
+ export const MAX_RULES: number;
6
+ export const TOOLS: string[];
7
+ export function parseRule(rule: unknown, source?: string): { tool: string; specifier?: string };
8
+ export function validatePermissions(value: unknown, source: string): { profiles: Record<string, { base?: PermissionBase; beyondMode?: 'refuse' | 'ask'; workdir?: 'copy'; allow: string[]; ask: string[]; deny: string[] }>; agents: Record<string, string> };
9
+ export function addAllowRule(projectRoot: string, home: string | undefined, profileName: string, rule: string): Promise<string>;
10
+ export function profileFor(projectRoot: string, home: string | undefined, agent: string): Promise<PermissionProfile | null>;
11
+ export function capMode(mode: string, base: string): string;
12
+ export function unwrapShell(command: string): string;
13
+ export function simpleCommands(line: string): string[] | null;
14
+ export function commandDecision(profile: Pick<PermissionProfile, 'allow' | 'deny'> & { ask?: string[] }, command: string): 'allow' | 'ask' | 'deny' | undefined;
@@ -0,0 +1,242 @@
1
+ import path from 'node:path';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { readFile, writeFile } from 'node:fs/promises';
4
+ import { moveFile } from './atomic.js';
5
+ import { AlpError } from './errors.js';
6
+
7
+ /**
8
+ * Permission profiles: what an agent may do beyond, or never do within, its
9
+ * base mode. Rules use Claude Code's syntax, `Tool` or `Tool(specifier)`, such as
10
+ * `Bash(npm test:*)` (a command prefix), `Bash(git status)` (one exact command),
11
+ * `Edit(src/**)` or `WebFetch(domain:example.com)`.
12
+ *
13
+ * Profiles and the agents they apply to come from `permissions` in the project's
14
+ * .alp/settings.json and the user's $ALP_HOME/settings.json. A profile defined in
15
+ * both takes its base from the project and the rules of both. An agent's entry in
16
+ * the project wins over the user's. Deny wins over ask, and ask over allow: an ask
17
+ * rule makes ALP ask the user every time. With `beyondMode: "ask"`, what the mode
18
+ * refuses and no rule covers is asked too, and the user may allow it always.
19
+ *
20
+ * ALP enforces rules natively where it can: Claude receives them as its own
21
+ * allowed and disallowed tools, which hold in every permission mode. Codex runs
22
+ * commands inside its sandbox without asking, so for Codex an allow rule lets a
23
+ * command leave the sandbox when Codex asks to, and a deny rule refuses that.
24
+ */
25
+
26
+ export const BASES = ['read-only', 'workspace-write', 'full-access'];
27
+ const RANK = { 'read-only': 0, 'workspace-write': 1, 'full-access': 2 };
28
+ /** Agents that advise, review or watch: their profiles stay read-only. */
29
+ export const ADVISORS = ['oracle', 'reviewer', 'supervisor'];
30
+ export const MAX_RULES = 200;
31
+ const NAME = /^[\w.-]{1,64}$/;
32
+ const RULE = /^([A-Za-z][\w-]*)(?:\((.*)\))?$/s;
33
+ /** Tools a rule may name; a misspelt one would silently match nothing. MCP tools are mcp__<server>__<tool>. */
34
+ export const TOOLS = ['Bash', 'Read', 'Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Glob', 'Grep', 'WebFetch', 'WebSearch', 'Skill'];
35
+
36
+ const own = (value, key) => Object.hasOwn(value, key) ? value[key] : undefined;
37
+ const setOwn = (value, key, entry) => Object.defineProperty(value, key, { value: entry, enumerable: true, writable: true, configurable: true });
38
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
39
+ const fail = (source, message) => { throw new AlpError('INVALID_SETTINGS', `${source}: ${message}`); };
40
+
41
+ /** Parses one rule; returns { tool, specifier? } or throws. */
42
+ export function parseRule(rule, source = 'rule') {
43
+ if (typeof rule !== 'string' || rule.length > 500) fail(source, 'a rule is text of at most 500 characters');
44
+ const match = RULE.exec(rule.trim());
45
+ if (!match) fail(source, `${JSON.stringify(rule)} is not Tool or Tool(specifier)`);
46
+ const [, tool, specifier] = match;
47
+ if (!TOOLS.includes(tool) && !/^mcp__[\w-]+(__[\w-]+)?$/.test(tool)) fail(source, `${JSON.stringify(rule)} names unknown tool ${tool}; use ${TOOLS.join(', ')} or mcp__<server>__<tool>`);
48
+ if (specifier !== undefined && !specifier.trim()) fail(source, `${JSON.stringify(rule)} has an empty specifier`);
49
+ return { tool, ...(specifier !== undefined && specifier.trim() !== '*' ? { specifier: specifier.trim() } : {}) };
50
+ }
51
+
52
+ /** Checks the `permissions` field of a settings file. */
53
+ export function validatePermissions(value, source) {
54
+ if (value === undefined) return { profiles: {}, agents: {} };
55
+ if (!object(value)) fail(source, 'permissions must be an object');
56
+ for (const key of Object.keys(value)) if (!['profiles', 'agents'].includes(key)) fail(source, `unsupported permissions field '${key}'`);
57
+ const profiles = {};
58
+ if (value.profiles !== undefined) {
59
+ if (!object(value.profiles)) fail(source, 'permissions.profiles must be an object');
60
+ for (const [name, profile] of Object.entries(value.profiles)) {
61
+ const where = `permissions.profiles.${name}`;
62
+ if (!NAME.test(name)) fail(source, `${where}: a profile name has letters, digits, dots, dashes and underscores`);
63
+ if (!object(profile)) fail(source, `${where} must be an object`);
64
+ for (const key of Object.keys(profile)) if (!['base', 'allow', 'ask', 'deny', 'beyondMode', 'workdir'].includes(key)) fail(source, `${where}: unsupported field '${key}'`);
65
+ if (profile.workdir !== undefined && profile.workdir !== 'copy') fail(source, `${where}.workdir must be copy`);
66
+ if (profile.workdir === 'copy' && profile.base !== undefined && profile.base !== 'read-only') fail(source, `${where}: workdir copy is for read-only profiles; writers use worktree isolation`);
67
+ if (profile.base !== undefined && !BASES.includes(profile.base)) fail(source, `${where}.base must be ${BASES.join(', ')}`);
68
+ if (profile.beyondMode !== undefined && !['refuse', 'ask'].includes(profile.beyondMode)) fail(source, `${where}.beyondMode must be refuse or ask`);
69
+ const rules = {};
70
+ for (const kind of ['allow', 'ask', 'deny']) {
71
+ const list = profile[kind] ?? [];
72
+ if (!Array.isArray(list) || list.length > MAX_RULES) fail(source, `${where}.${kind} must list at most ${MAX_RULES} rules`);
73
+ for (const rule of list) parseRule(rule, `${source}: ${where}.${kind}`);
74
+ rules[kind] = list.map(rule => rule.trim());
75
+ }
76
+ setOwn(profiles, name, { ...(profile.base ? { base: profile.base } : {}), ...(profile.beyondMode ? { beyondMode: profile.beyondMode } : {}), ...(profile.workdir ? { workdir: profile.workdir } : {}), ...rules });
77
+ }
78
+ }
79
+ const agents = {};
80
+ if (value.agents !== undefined) {
81
+ if (!object(value.agents)) fail(source, 'permissions.agents must be an object');
82
+ for (const [agent, profile] of Object.entries(value.agents)) {
83
+ if (typeof profile !== 'string' || !NAME.test(profile)) fail(source, `permissions.agents.${agent} must name a profile`);
84
+ setOwn(agents, agent, profile);
85
+ }
86
+ }
87
+ return { profiles, agents };
88
+ }
89
+
90
+ async function settingsPermissions(file) {
91
+ let text;
92
+ try { text = await readFile(file, 'utf8'); } catch (error) { if (error.code === 'ENOENT') return { profiles: {}, agents: {} }; throw error; }
93
+ let settings;
94
+ try { settings = JSON.parse(text); } catch (error) { fail(file, `invalid JSON: ${error.message}`); }
95
+ if (!object(settings)) fail(file, 'expected an object');
96
+ return validatePermissions(settings.permissions, file);
97
+ }
98
+
99
+ /**
100
+ * The permission profile of an agent, or null when none applies: then the agent
101
+ * keeps the runtime's defaults. Advisors without a profile get a read-only one.
102
+ * `workdir: 'copy'` runs the agent in a disposable copy of its requester's tree,
103
+ * where it may write, build and test; the requester's tree stays untouched.
104
+ * @returns {Promise<{ name: string, base: string, allow: string[], ask: string[], deny: string[], beyondMode: 'refuse' | 'ask', workdir?: 'copy' } | null>}
105
+ */
106
+ export async function profileFor(projectRoot, home, agent) {
107
+ const project = await settingsPermissions(path.join(projectRoot, '.alp', 'settings.json'));
108
+ const user = home ? await settingsPermissions(path.join(home, 'settings.json')) : { profiles: {}, agents: {} };
109
+ const name = own(project.agents, agent) ?? own(user.agents, agent);
110
+ const advisor = ADVISORS.includes(agent);
111
+ if (!name) return advisor ? { name: 'read-only', base: 'read-only', allow: [], ask: [], deny: [], beyondMode: 'refuse' } : null;
112
+ const defined = [own(project.profiles, name), own(user.profiles, name)].filter(Boolean);
113
+ if (!defined.length && !BASES.includes(name)) {
114
+ throw new AlpError('INVALID_SETTINGS', `permissions.agents.${agent} names profile '${name}', which no settings file defines`);
115
+ }
116
+ // A bare base name is a profile with no rules.
117
+ const base = defined.find(profile => profile.base)?.base ?? (BASES.includes(name) ? name : advisor ? 'read-only' : 'full-access');
118
+ const copy = defined.some(profile => profile.workdir === 'copy');
119
+ if (copy && base !== 'read-only') throw new AlpError('INVALID_SETTINGS', `Profile '${name}' has workdir copy, which needs base read-only`);
120
+ if (advisor && base !== 'read-only') throw new AlpError('INVALID_SETTINGS', `${agent} is an advisor; its profile '${name}' must have base read-only`);
121
+ return {
122
+ name, base,
123
+ allow: [...new Set(defined.flatMap(profile => profile.allow ?? []))],
124
+ ask: [...new Set(defined.flatMap(profile => profile.ask ?? []))],
125
+ deny: [...new Set(defined.flatMap(profile => profile.deny ?? []))],
126
+ beyondMode: defined.find(profile => profile.beyondMode)?.beyondMode ?? 'refuse',
127
+ ...(copy ? { workdir: 'copy' } : {}),
128
+ };
129
+ }
130
+
131
+ /**
132
+ * Adds an allow rule to a profile, in the settings file that defines it (the
133
+ * project's first), when the user allows something always. Returns the file.
134
+ */
135
+ export async function addAllowRule(projectRoot, home, profileName, rule) {
136
+ parseRule(rule);
137
+ for (const file of [path.join(projectRoot, '.alp', 'settings.json'), ...(home ? [path.join(home, 'settings.json')] : [])]) {
138
+ let settings;
139
+ try { settings = JSON.parse(await readFile(file, 'utf8')); } catch (error) { if (error.code === 'ENOENT') continue; throw error; }
140
+ const profile = settings?.permissions?.profiles?.[profileName];
141
+ if (!object(profile)) continue;
142
+ profile.allow = [...new Set([...(profile.allow ?? []), rule])];
143
+ validatePermissions(settings.permissions, file);
144
+ const temporary = `${file}.${randomUUID().slice(0, 8)}.tmp`;
145
+ await writeFile(temporary, `${JSON.stringify(settings, null, 2)}\n`);
146
+ await moveFile(temporary, file);
147
+ return file;
148
+ }
149
+ throw new AlpError('INVALID_SETTINGS', `No settings file defines profile ${profileName}`);
150
+ }
151
+
152
+ /** The lower of two modes. */
153
+ export const capMode = (mode, base) => (RANK[mode] ?? 0) <= (RANK[base] ?? 0) ? mode : base;
154
+
155
+ // --- commands --------------------------------------------------------------------
156
+
157
+ /** Removes a shell wrapper such as `/bin/zsh -lc '...'` that Codex puts around a command. */
158
+ export function unwrapShell(command) {
159
+ const match = /^(?:\/\S*\/)?(?:ba|z|da)?sh\s+-l?c\s+(['"])([\s\S]*)\1\s*$/.exec(command.trim());
160
+ if (!match) return command.trim();
161
+ return match[1] === "'" ? match[2].replace(/'\\''/g, "'") : match[2].replace(/\\(["\\$`])/g, '$1');
162
+ }
163
+
164
+ /**
165
+ * Splits a command line into its simple commands at `&&`, `||`, `;`, `|` and new
166
+ * lines, outside quotes. Returns null when the line substitutes commands or
167
+ * processes, or its quotes do not close: no rule can vouch for it.
168
+ */
169
+ export function simpleCommands(line) {
170
+ const parts = [];
171
+ let current = '';
172
+ let quote = null;
173
+ for (let i = 0; i < line.length; i++) {
174
+ const char = line[i];
175
+ if (quote) {
176
+ if (char === '\\' && quote === '"') { current += char + (line[++i] ?? ''); continue; }
177
+ if (char === quote) quote = null;
178
+ if (quote === '"' && (char === '`' || (char === '$' && line[i + 1] === '('))) return null;
179
+ current += char;
180
+ continue;
181
+ }
182
+ if (char === '\\') { current += char + (line[++i] ?? ''); continue; }
183
+ if (char === "'" || char === '"') { quote = char; current += char; continue; }
184
+ if (char === '`' || (char === '$' && line[i + 1] === '(') || ((char === '<' || char === '>') && line[i + 1] === '(')) return null;
185
+ const two = line.slice(i, i + 2);
186
+ // 2>&1 and &>file redirect; they do not end a command.
187
+ if (two === '>&' || two === '<&' || two === '&>') { current += two; i++; continue; }
188
+ if (two === '&&' || two === '||') { parts.push(current); current = ''; i++; continue; }
189
+ if (char === ';' || char === '|' || char === '\n' || char === '&') { parts.push(current); current = ''; continue; }
190
+ current += char;
191
+ }
192
+ if (quote) return null;
193
+ parts.push(current);
194
+ return parts.map(part => part.trim()).filter(Boolean);
195
+ }
196
+
197
+ /** Whether a Bash rule's specifier covers one simple command. */
198
+ function covers(specifier, command) {
199
+ if (specifier === undefined) return true;
200
+ const normalized = command.replace(/\s+/g, ' ');
201
+ // `npm test:*` and `npm test *` both cover npm test and anything after it.
202
+ if (specifier.endsWith(':*') || specifier.endsWith(' *')) {
203
+ const prefix = specifier.slice(0, -2).trim().replace(/\s+/g, ' ');
204
+ return normalized === prefix || normalized.startsWith(`${prefix} `);
205
+ }
206
+ if (specifier.endsWith('*')) return normalized.startsWith(specifier.slice(0, -1).replace(/\s+/g, ' '));
207
+ return normalized === specifier.replace(/\s+/g, ' ');
208
+ }
209
+
210
+ /** Whether a simple command writes a file through a redirect (not a descriptor or /dev/null). */
211
+ function redirects(command) {
212
+ const unquoted = command.replace(/'[^']*'|"(?:[^"\\]|\\.)*"/g, '""');
213
+ for (const match of unquoted.matchAll(/(?:\d*|&)>>?\s*(&?)(\S*)/g)) {
214
+ if (match[1] === '&' && /^\d+$/.test(match[2])) continue;
215
+ if (match[2] === '/dev/null') continue;
216
+ return true;
217
+ }
218
+ return false;
219
+ }
220
+
221
+ const bashRules = rules => rules.map(rule => parseRule(rule)).filter(rule => rule.tool === 'Bash');
222
+
223
+ /**
224
+ * What a profile says about running a command: 'deny' when a deny rule covers any
225
+ * of its simple commands, 'ask' when an ask rule covers any, 'allow' when allow
226
+ * rules cover every one of them and none writes through a redirect, otherwise
227
+ * undefined (the base mode decides).
228
+ */
229
+ export function commandDecision(profile, command) {
230
+ const line = unwrapShell(command);
231
+ const parts = simpleCommands(line);
232
+ const deny = bashRules(profile.deny);
233
+ if (deny.some(rule => rule.specifier === undefined)) return 'deny';
234
+ // A command ALP cannot split may hide anything a deny rule names.
235
+ if (!parts) return deny.length ? 'deny' : (profile.ask ?? []).length ? 'ask' : undefined;
236
+ if (parts.some(part => deny.some(rule => covers(rule.specifier, part)))) return 'deny';
237
+ const ask = bashRules(profile.ask ?? []);
238
+ if (ask.some(rule => rule.specifier === undefined) || parts.some(part => ask.some(rule => covers(rule.specifier, part)))) return 'ask';
239
+ const allow = bashRules(profile.allow);
240
+ if (parts.every(part => !redirects(part) && allow.some(rule => covers(rule.specifier, part)))) return 'allow';
241
+ return undefined;
242
+ }