@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/beads.js ADDED
@@ -0,0 +1,236 @@
1
+ import { DESCRIPTION_CHARS, MAX_LABELS, TASK_TYPES, batch, isTaskId } from './tasks.js';
2
+
3
+ /**
4
+ * Interchange with beads (github.com/gastownhall/beads) through its JSONL issue
5
+ * format: `bd export` writes one issue per line and `bd import` upserts them.
6
+ * Relations become beads dependencies stored on the dependent issue
7
+ * ({ issue_id, depends_on_id, type }); what beads has no field for (review,
8
+ * gates, paths, handoffs) rides in metadata.alp, so ALP → beads → ALP keeps it.
9
+ */
10
+
11
+ const DEPENDENCY = { blockedBy: 'blocks', parent: 'parent-child', discoveredFrom: 'discovered-from', related: 'related' };
12
+ const RELATION = Object.fromEntries(Object.entries(DEPENDENCY).map(([relation, type]) => [type, relation]));
13
+ const LABEL = /^[\w.:/-]{1,50}$/;
14
+
15
+ /** Tasks as beads issues, sorted by id like bd export. */
16
+ export function exportBeads(tasks) {
17
+ return [...tasks].sort((a, b) => a.id.localeCompare(b.id)).map(task => {
18
+ const dependencies = [
19
+ ...task.blockedBy.map(id => [id, 'blocks']),
20
+ ...(task.parent ? [[task.parent, 'parent-child']] : []),
21
+ ...(task.discoveredFrom ? [[task.discoveredFrom, 'discovered-from']] : []),
22
+ ...task.related.map(id => [id, 'related']),
23
+ ].map(([dependsOn, type]) => ({ issue_id: task.id, depends_on_id: dependsOn, type }));
24
+ const timers = task.gates.filter(gate => gate.kind === 'timer' && !gate.resolved).map(gate => gate.until).sort();
25
+ const alp = {
26
+ ...(task.status === 'review' ? { status: 'review' } : {}),
27
+ ...(task.paths.length ? { paths: task.paths } : {}),
28
+ ...(task.gates.length ? { gates: task.gates } : {}),
29
+ ...(task.handoff ? { handoff: task.handoff } : {}),
30
+ ...(task.closed ? { closed: task.closed } : {}),
31
+ ...(task.compacted ? { compacted: task.compacted } : {}),
32
+ ...(task.step ? { step: task.step } : {}),
33
+ ...(task.formula ? { formula: task.formula } : {}),
34
+ };
35
+ return {
36
+ id: task.id,
37
+ title: task.title,
38
+ ...(task.description ? { description: task.description } : {}),
39
+ status: task.status === 'review' ? 'in_progress' : task.status,
40
+ priority: task.priority,
41
+ issue_type: task.type,
42
+ ...(task.assignee ? { assignee: task.assignee.agent } : {}),
43
+ ...(task.labels.length ? { labels: task.labels } : {}),
44
+ ...(dependencies.length ? { dependencies } : {}),
45
+ created_at: task.createdAt,
46
+ created_by: task.createdBy,
47
+ updated_at: task.updatedAt,
48
+ ...(task.closed ? { closed_at: task.closed.at, close_reason: task.closed.summary ?? task.closed.reason } : {}),
49
+ ...(timers.length ? { defer_until: timers.at(-1) } : {}),
50
+ ...(task.external ? { external_ref: task.external.id, source_system: task.external.system } : {}),
51
+ ...(Object.keys(alp).length ? { metadata: { alp } } : {}),
52
+ };
53
+ });
54
+ }
55
+
56
+ /** Parses JSONL, keeping the line number of every record or error. */
57
+ export function parseJsonl(text) {
58
+ const records = [];
59
+ const errors = [];
60
+ text.split('\n').forEach((line, index) => {
61
+ if (!line.trim()) return;
62
+ try {
63
+ const record = JSON.parse(line);
64
+ if (!record || typeof record !== 'object' || Array.isArray(record)) throw new Error('not a JSON object');
65
+ records.push({ line: index + 1, record });
66
+ } catch (error) {
67
+ errors.push({ line: index + 1, reason: error.message });
68
+ }
69
+ });
70
+ return { records, errors };
71
+ }
72
+
73
+ const sections = record => [
74
+ typeof record.description === 'string' ? record.description : '',
75
+ ...[['design', 'Design'], ['acceptance_criteria', 'Acceptance criteria'], ['notes', 'Notes']]
76
+ .filter(([key]) => typeof record[key] === 'string' && record[key].trim())
77
+ .map(([key, title]) => `## ${title}\n\n${record[key].trim()}`),
78
+ ].filter(Boolean).join('\n\n');
79
+
80
+ /** metadata.alp, whether beads wrote metadata as an object or as JSON text. */
81
+ function alpOf(record) {
82
+ let metadata = record.metadata;
83
+ if (typeof metadata === 'string') { try { metadata = JSON.parse(metadata); } catch { return {}; } }
84
+ return metadata?.alp && typeof metadata.alp === 'object' && !Array.isArray(metadata.alp) ? metadata.alp : {};
85
+ }
86
+
87
+ const clip = (text, limit) => text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
88
+
89
+ /** What an issue becomes in ALP, apart from its relations and status. */
90
+ function taskFields(record) {
91
+ const type = TASK_TYPES.includes(record.issue_type) ? record.issue_type : 'task';
92
+ const labels = [
93
+ ...(Array.isArray(record.labels) ? record.labels.filter(label => typeof label === 'string' && LABEL.test(label)) : []),
94
+ ...(record.issue_type && type !== record.issue_type && LABEL.test(`beads:${record.issue_type}`) ? [`beads:${record.issue_type}`] : []),
95
+ ];
96
+ const priority = Number.isInteger(record.priority) ? Math.min(4, Math.max(0, record.priority)) : 2;
97
+ const alp = alpOf(record);
98
+ return {
99
+ title: clip(String(record.title).trim(), 200),
100
+ description: clip(sections(record), DESCRIPTION_CHARS),
101
+ type,
102
+ priority,
103
+ labels: [...new Set(labels)].slice(0, MAX_LABELS),
104
+ ...(Array.isArray(alp.paths) ? { paths: alp.paths.filter(entry => typeof entry === 'string') } : {}),
105
+ };
106
+ }
107
+
108
+ /** Parents before children, so a child's numbered id can follow its parent's. */
109
+ function byParent(entries, parentOf) {
110
+ const ordered = [];
111
+ const placed = new Set();
112
+ const visit = (entry, trail = new Set()) => {
113
+ if (placed.has(entry.record.id) || trail.has(entry.record.id)) return;
114
+ trail.add(entry.record.id);
115
+ const parent = entries.find(candidate => candidate.record.id === parentOf(entry.record));
116
+ if (parent) visit(parent, trail);
117
+ placed.add(entry.record.id);
118
+ ordered.push(entry);
119
+ };
120
+ for (const entry of entries) visit(entry);
121
+ return ordered;
122
+ }
123
+
124
+ /**
125
+ * Upserts beads issues into the project's tasks, as bd import does: an issue
126
+ * whose id is an ALP task id, or that ALP imported before (external beads id),
127
+ * updates that task; any other becomes a new task. Missing records are never deleted.
128
+ * @returns {Promise<{ created: Array<{ id: string, from: string }>, updated: Array<{ id: string, from: string }>, skipped: Array<{ line: number, reason: string }>, warnings: string[] }>}
129
+ */
130
+ export async function importBeads(projectRoot, records, { dryRun = false, by = 'user' } = {}) {
131
+ const report = { created: [], updated: [], skipped: [], warnings: [] };
132
+ const usable = [];
133
+ for (const { line, record } of records) {
134
+ // bd export marks issues with _type; other record types are not tasks.
135
+ if (record._type !== undefined && record._type !== 'issue') report.skipped.push({ line, reason: `${record._type} record, not an issue` });
136
+ else if (typeof record.title !== 'string' || !record.title.trim()) report.skipped.push({ line, reason: 'no title' });
137
+ else if (typeof record.id !== 'string' || !record.id.trim()) report.skipped.push({ line, reason: 'no id' });
138
+ else if (record.status === 'tombstone') report.skipped.push({ line, reason: `${record.id} is deleted (tombstone)` });
139
+ else if (record.ephemeral === true || record.wisp === true) report.skipped.push({ line, reason: `${record.id} is ephemeral` });
140
+ else usable.push({ line, record });
141
+ }
142
+ const dependencies = record => (Array.isArray(record.dependencies) ? record.dependencies : [])
143
+ .filter(dep => dep && typeof dep.depends_on_id === 'string' && (dep.issue_id === undefined || dep.issue_id === record.id));
144
+ const parentOf = record => dependencies(record).find(dep => dep.type === 'parent-child')?.depends_on_id ?? (typeof record.parent === 'string' ? record.parent : undefined);
145
+
146
+ return batch(projectRoot, ({ tasks, add, touch, link }) => {
147
+ const ids = new Map();
148
+ const find = foreign => ids.get(foreign)
149
+ ?? tasks.find(task => task.id === foreign && isTaskId(foreign))?.id
150
+ ?? tasks.find(task => task.external?.system === 'beads' && task.external.id === foreign)?.id;
151
+
152
+ for (const { line, record } of byParent(usable, parentOf)) {
153
+ try {
154
+ const values = taskFields(record);
155
+ const alp = alpOf(record);
156
+ const existing = find(record.id);
157
+ let task;
158
+ let before;
159
+ if (existing) {
160
+ task = tasks.find(entry => entry.id === existing);
161
+ before = JSON.stringify(task);
162
+ Object.assign(task, values);
163
+ } else {
164
+ const parent = parentOf(record) ? find(parentOf(record)) : undefined;
165
+ if (parentOf(record) && !parent) report.warnings.push(`${record.id}: parent ${parentOf(record)} is not in the file or the project`);
166
+ task = add({ ...values, ...(parent ? { parent } : {}) }, by, {
167
+ id: record.id,
168
+ createdAt: typeof record.created_at === 'string' ? record.created_at : undefined,
169
+ event: 'imported', details: { from: record.id },
170
+ extra: {
171
+ ...(typeof record.created_by === 'string' && record.created_by ? { createdBy: record.created_by } : {}),
172
+ ...(Array.isArray(alp.gates) ? { gates: alp.gates } : {}),
173
+ ...(alp.step ? { step: alp.step } : {}),
174
+ ...(alp.formula ? { formula: alp.formula } : {}),
175
+ },
176
+ });
177
+ if (task.id !== record.id) task.external = { system: 'beads', id: record.id };
178
+ report.created.push({ id: task.id, from: record.id });
179
+ }
180
+ ids.set(record.id, task.id);
181
+ applyStatus(task, record, alp, by, report);
182
+ const deferred = typeof record.defer_until === 'string' && Date.parse(record.defer_until) > Date.now() ? new Date(record.defer_until).toISOString() : undefined;
183
+ // bd keeps whole seconds, so a timer within a second of defer_until is the same one.
184
+ if (deferred && !task.gates.some(gate => gate.kind === 'timer' && Math.abs(Date.parse(gate.until) - Date.parse(deferred)) < 1000)) {
185
+ const next = Math.max(0, ...task.gates.map(gate => Number(gate.id.slice(1)) || 0)) + 1;
186
+ task.gates = [...task.gates, { id: `g${next}`, kind: 'timer', until: deferred, at: new Date().toISOString(), by }];
187
+ }
188
+ if (existing && JSON.stringify(task) !== before) {
189
+ touch(task, by, 'imported', { from: record.id });
190
+ report.updated.push({ id: task.id, from: record.id });
191
+ }
192
+ } catch (error) {
193
+ report.skipped.push({ line, reason: `${record.id}: ${error.message}` });
194
+ }
195
+ }
196
+
197
+ // Relations once every issue has a task.
198
+ for (const { record } of usable) {
199
+ const id = ids.get(record.id);
200
+ if (!id) continue;
201
+ const task = tasks.find(entry => entry.id === id);
202
+ for (const dep of dependencies(record)) {
203
+ const relation = RELATION[dep.type];
204
+ if (!relation) { report.warnings.push(`${record.id}: dependency type ${dep.type} has no ALP relation; skipped`); continue; }
205
+ const other = find(dep.depends_on_id);
206
+ if (!other) { report.warnings.push(`${record.id}: ${dep.type} ${dep.depends_on_id} is not in the file or the project; skipped`); continue; }
207
+ const before = JSON.stringify(task);
208
+ const refused = link(task, relation, other);
209
+ if (refused) report.warnings.push(`${record.id}: ${dep.type} ${dep.depends_on_id} skipped: ${refused}`);
210
+ else if (JSON.stringify(task) !== before) touch(task);
211
+ }
212
+ }
213
+ return report;
214
+ }, { dryRun });
215
+ }
216
+
217
+ /** beads statuses in ALP: closed stays closed, review comes back from metadata, everything else is open again. */
218
+ function applyStatus(task, record, alp, by, report) {
219
+ const status = record.status ?? 'open';
220
+ if (status === 'closed') {
221
+ if (task.status === 'closed') return;
222
+ task.status = 'closed';
223
+ task.assignee = null;
224
+ task.closed = alp.closed && typeof alp.closed === 'object'
225
+ ? alp.closed
226
+ : { at: typeof record.closed_at === 'string' ? record.closed_at : new Date().toISOString(), by: 'beads', reason: 'done', ...(typeof record.close_reason === 'string' && record.close_reason ? { summary: record.close_reason } : {}) };
227
+ } else if (alp.status === 'review' && alp.handoff) {
228
+ task.status = 'review';
229
+ task.handoff = alp.handoff;
230
+ } else if (status === 'in_progress' && task.status === 'open') {
231
+ // Nobody in ALP holds it; it is open until main or the user starts it.
232
+ report.warnings.push(`${record.id}: was in progress${record.assignee ? ` with ${record.assignee}` : ''} in beads; imported as open`);
233
+ } else if (!['open', 'in_progress', 'blocked', 'deferred'].includes(status)) {
234
+ report.warnings.push(`${record.id}: beads status ${status} imported as open`);
235
+ }
236
+ }
@@ -0,0 +1,28 @@
1
+ import path from 'node:path';
2
+ import { jsonObject } from './resolver.js';
3
+ import { AlpError } from './errors.js';
4
+
5
+ const name = value => typeof value === 'string' && value.trim() && !['.', '..'].includes(value) && !/[\\/\x00]/.test(value);
6
+
7
+ /** The graph is project data, not a registry or provider-specific role enum. */
8
+ export async function resolveDelegation(projectRoot) {
9
+ const source = path.join(projectRoot, '.alp', 'settings.json');
10
+ const settings = await jsonObject(source, 'INVALID_SETTINGS');
11
+ const graph = settings.delegation === undefined ? {} : settings.delegation;
12
+ const invalid = message => { throw new AlpError('INVALID_DELEGATION', `${source}: ${message}`); };
13
+ if (!graph || typeof graph !== 'object' || Array.isArray(graph)) invalid('delegation must map agent names to target arrays');
14
+ for (const [owner, targets] of Object.entries(graph)) {
15
+ if (!name(owner) || !Array.isArray(targets) || !targets.every(name) || new Set(targets).size !== targets.length) invalid(`invalid targets for '${owner}'`);
16
+ }
17
+ const visiting = new Set();
18
+ const visited = new Set();
19
+ function visit(owner) {
20
+ if (visiting.has(owner)) invalid(`delegation cycle at '${owner}'`);
21
+ if (visited.has(owner)) return;
22
+ visiting.add(owner);
23
+ for (const target of Object.hasOwn(graph, owner) ? graph[owner] : []) visit(target);
24
+ visiting.delete(owner); visited.add(owner);
25
+ }
26
+ for (const owner of Object.keys(graph)) visit(owner);
27
+ return graph;
28
+ }
package/src/errors.js ADDED
@@ -0,0 +1,7 @@
1
+ export class AlpError extends Error {
2
+ constructor(code, message, options) {
3
+ super(message, options);
4
+ this.name = 'AlpError';
5
+ this.code = code;
6
+ }
7
+ }
@@ -0,0 +1,25 @@
1
+ import type { Task } from './tasks.js';
2
+
3
+ export type FormulaStep = { id: string; title: string; description?: string; type: string; needs: string[]; priority?: number; labels?: string[]; paths?: string[] };
4
+ export type Formula = {
5
+ formula: string;
6
+ title?: string;
7
+ description?: string;
8
+ version?: number;
9
+ priority?: number;
10
+ vars: Record<string, { description?: string; required: boolean; default?: string }>;
11
+ steps: FormulaStep[];
12
+ };
13
+ /** Parses TOML text; the core has none of its own (e.g. smol-toml's parse). */
14
+ export type TomlParser = (text: string) => unknown;
15
+ type Parsers = { toml?: TomlParser };
16
+ export type FormulaEntry = { name: string; file: string; formula?: Formula; error?: string };
17
+
18
+ export const MAX_STEPS: number;
19
+ export function formulaDirs(projectRoot: string, home?: string): string[];
20
+ export function listFormulas(projectRoot: string, home?: string, parsers?: Parsers): Promise<FormulaEntry[]>;
21
+ export function findFormula(projectRoot: string, home: string | undefined, name: string, parsers?: Parsers): Promise<FormulaEntry & { formula: Formula }>;
22
+ export function loadFormula(file: string, parsers?: Parsers): Promise<Formula>;
23
+ export function validateFormula(data: unknown): Formula;
24
+ export function resolveVars(formula: Formula, given?: Record<string, string>): Record<string, string>;
25
+ export function pourFormula(projectRoot: string, formula: Formula, given: Record<string, string> | undefined, by: string, options?: { dryRun?: boolean; parent?: string }): Promise<{ epic: Task; tasks: Task[] }>;
@@ -0,0 +1,202 @@
1
+ import { readdir, readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { AlpError } from './errors.js';
4
+ import { DESCRIPTION_CHARS, TASK_TYPES, batch } from './tasks.js';
5
+
6
+ /**
7
+ * Formulas, after beads: a workflow template whose steps become tasks. A
8
+ * formula file is <name>.formula.toml or <name>.formula.json in the project's
9
+ * .alp/formulas, the user's $ALP_HOME/formulas, or the project's .beads/formulas
10
+ * (searched in that order; the first of a name wins). Pouring it creates an
11
+ * epic and one child task per step, with `needs` as blockedBy between them. A
12
+ * `human` step is the user's: it waits on a human gate, and approving it completes it.
13
+ * The core has no TOML parser of its own; callers pass one as `toml`.
14
+ */
15
+
16
+ const fail = (message) => { throw new AlpError('INVALID_FORMULA', message); };
17
+ const NAME = /^[\w.-]{1,64}$/;
18
+ const STEP_ID = /^[\w-]{1,64}$/;
19
+ const VAR = /\{\{\s*([\w-]+)\s*\}\}/g;
20
+ export const MAX_STEPS = 50;
21
+
22
+ export function formulaDirs(projectRoot, home) {
23
+ return [
24
+ path.join(path.resolve(projectRoot), '.alp', 'formulas'),
25
+ ...(home ? [path.join(home, 'formulas')] : []),
26
+ path.join(path.resolve(projectRoot), '.beads', 'formulas'),
27
+ ];
28
+ }
29
+
30
+ const FILE = /^(.+)\.formula\.(toml|json)$/;
31
+
32
+ /** Every formula file found, first of each name, with what parsing it reported. */
33
+ export async function listFormulas(projectRoot, home, { toml } = {}) {
34
+ const found = new Map();
35
+ for (const dir of formulaDirs(projectRoot, home)) {
36
+ let names;
37
+ try { names = await readdir(dir); } catch (error) { if (error.code === 'ENOENT') continue; throw error; }
38
+ for (const name of names.sort()) {
39
+ const match = FILE.exec(name);
40
+ if (!match || found.has(match[1])) continue;
41
+ const file = path.join(dir, name);
42
+ try {
43
+ const formula = await loadFormula(file, { toml });
44
+ found.set(match[1], { name: match[1], file, formula });
45
+ } catch (error) {
46
+ found.set(match[1], { name: match[1], file, error: error.message });
47
+ }
48
+ }
49
+ }
50
+ return [...found.values()];
51
+ }
52
+
53
+ export async function findFormula(projectRoot, home, name, { toml } = {}) {
54
+ if (!NAME.test(name ?? '')) fail('A formula name has letters, digits, dots, dashes and underscores');
55
+ const entry = (await listFormulas(projectRoot, home, { toml })).find(candidate => candidate.name === name);
56
+ if (!entry) fail(`No formula ${name} in ${formulaDirs(projectRoot, home).join(', ')}`);
57
+ if (entry.error) fail(`${entry.file}: ${entry.error}`);
58
+ return entry;
59
+ }
60
+
61
+ export async function loadFormula(file, { toml } = {}) {
62
+ const text = await readFile(file, 'utf8');
63
+ if (!file.endsWith('.json') && !toml) fail('reading TOML formulas needs a TOML parser');
64
+ let data;
65
+ try { data = file.endsWith('.json') ? JSON.parse(text) : toml(text); }
66
+ catch (error) { fail(`cannot parse: ${error.message.split('\n')[0]}`); }
67
+ return validateFormula(data);
68
+ }
69
+
70
+ const optionalText = (value, name, limit) => {
71
+ if (value === undefined) return undefined;
72
+ if (typeof value !== 'string' || value.length > limit) fail(`${name} must be text of at most ${limit} characters`);
73
+ return value;
74
+ };
75
+
76
+ /** Checks a parsed formula and returns it normalized. */
77
+ export function validateFormula(data) {
78
+ if (!data || typeof data !== 'object' || Array.isArray(data)) fail('A formula is an object');
79
+ if (typeof data.formula !== 'string' || !NAME.test(data.formula)) fail('formula must name it: letters, digits, dots, dashes and underscores');
80
+ const vars = {};
81
+ if (data.vars !== undefined) {
82
+ if (!data.vars || typeof data.vars !== 'object' || Array.isArray(data.vars)) fail('vars must be a table of variables');
83
+ for (const [name, spec] of Object.entries(data.vars)) {
84
+ if (!/^[\w-]{1,64}$/.test(name)) fail(`Variable ${name} has an invalid name`);
85
+ const value = spec && typeof spec === 'object' ? spec : {};
86
+ vars[name] = {
87
+ ...(typeof value.description === 'string' ? { description: value.description } : {}),
88
+ required: value.required === true,
89
+ ...(value.default !== undefined ? { default: String(value.default) } : {}),
90
+ };
91
+ }
92
+ }
93
+ if (!Array.isArray(data.steps) || !data.steps.length || data.steps.length > MAX_STEPS) fail(`steps must list 1 to ${MAX_STEPS} steps`);
94
+ const ids = new Set();
95
+ const steps = data.steps.map((step, index) => {
96
+ if (!step || typeof step !== 'object') fail(`Step ${index + 1} is not a table`);
97
+ if (typeof step.id !== 'string' || !STEP_ID.test(step.id)) fail(`Step ${index + 1} needs an id of letters, digits, dashes and underscores`);
98
+ if (ids.has(step.id)) fail(`Two steps have id ${step.id}`);
99
+ ids.add(step.id);
100
+ if (typeof step.title !== 'string' || !step.title.trim()) fail(`Step ${step.id} needs a title`);
101
+ const needs = step.needs === undefined ? [] : step.needs;
102
+ if (!Array.isArray(needs) || !needs.every(need => typeof need === 'string')) fail(`Step ${step.id}: needs must list step ids`);
103
+ if (step.priority !== undefined && (!Number.isInteger(step.priority) || step.priority < 0 || step.priority > 4)) fail(`Step ${step.id}: priority must be 0 to 4`);
104
+ return {
105
+ id: step.id,
106
+ title: step.title,
107
+ ...(optionalText(step.description, `Step ${step.id} description`, DESCRIPTION_CHARS) ? { description: step.description } : {}),
108
+ type: typeof step.type === 'string' ? step.type : 'task',
109
+ needs: [...new Set(needs)],
110
+ ...(step.priority !== undefined ? { priority: step.priority } : {}),
111
+ ...(Array.isArray(step.labels) ? { labels: step.labels.filter(label => typeof label === 'string') } : {}),
112
+ ...(Array.isArray(step.paths) ? { paths: step.paths.filter(entry => typeof entry === 'string') } : {}),
113
+ };
114
+ });
115
+ for (const step of steps) for (const need of step.needs) if (!ids.has(need)) fail(`Step ${step.id} needs unknown step ${need}`);
116
+ // Steps must form no cycle.
117
+ const state = new Map();
118
+ const visit = (id, trail) => {
119
+ if (state.get(id) === 'done') return;
120
+ if (state.get(id) === 'open') fail(`Steps form a cycle: ${[...trail, id].join(' → ')}`);
121
+ state.set(id, 'open');
122
+ for (const need of steps.find(step => step.id === id).needs) visit(need, [...trail, id]);
123
+ state.set(id, 'done');
124
+ };
125
+ for (const step of steps) visit(step.id, []);
126
+ return {
127
+ formula: data.formula,
128
+ ...(optionalText(data.title, 'title', 200) ? { title: data.title } : {}),
129
+ ...(optionalText(data.description, 'description', DESCRIPTION_CHARS) ? { description: data.description } : {}),
130
+ ...(Number.isInteger(data.version) ? { version: data.version } : {}),
131
+ ...(Number.isInteger(data.priority) && data.priority >= 0 && data.priority <= 4 ? { priority: data.priority } : {}),
132
+ vars,
133
+ steps,
134
+ };
135
+ }
136
+
137
+ /** The variables a pour uses: given values over defaults; unknown or missing required ones refuse it. */
138
+ export function resolveVars(formula, given = {}) {
139
+ const unknown = Object.keys(given).filter(name => !(name in formula.vars));
140
+ if (unknown.length) fail(`${formula.formula} has no variable ${unknown.join(', ')}; it takes ${Object.keys(formula.vars).join(', ') || 'none'}`);
141
+ const values = {};
142
+ for (const [name, spec] of Object.entries(formula.vars)) {
143
+ const value = given[name] ?? spec.default;
144
+ if (value === undefined && spec.required) fail(`${formula.formula} needs --var ${name}=…${spec.description ? ` (${spec.description})` : ''}`);
145
+ if (value !== undefined) values[name] = String(value);
146
+ }
147
+ return values;
148
+ }
149
+
150
+ function fill(text, values, where) {
151
+ return text.replace(VAR, (whole, name) => {
152
+ if (!(name in values)) fail(`${where} uses {{${name}}}, which has no value`);
153
+ return values[name];
154
+ });
155
+ }
156
+
157
+ /**
158
+ * Pours a formula: an epic for the run and a child task per step, in step order,
159
+ * so step n is <epic>.n. Returns the epic and its steps' tasks.
160
+ */
161
+ export async function pourFormula(projectRoot, formula, given, by, { dryRun = false, parent } = {}) {
162
+ const values = resolveVars(formula, given);
163
+ const summary = Object.entries(values).map(([name, value]) => `${name}=${value}`).join(', ');
164
+ const title = formula.title ? fill(formula.title, values, 'title') : `${formula.formula}${summary ? ` (${summary})` : ''}`;
165
+ const steps = formula.steps.map(step => ({
166
+ ...step,
167
+ title: fill(step.title, values, `Step ${step.id}`),
168
+ ...(step.description ? { description: fill(step.description, values, `Step ${step.id}`) } : {}),
169
+ }));
170
+ return batch(projectRoot, ({ add, link }) => {
171
+ const epic = add({
172
+ title: title.slice(0, 200), type: 'epic', priority: formula.priority ?? 2, labels: [`formula:${formula.formula}`.slice(0, 50)],
173
+ ...(formula.description ? { description: fill(formula.description, values, 'description') } : {}),
174
+ ...(parent ? { parent } : {}),
175
+ }, by, { event: 'poured', details: { formula: formula.formula }, extra: { formula: { name: formula.formula, ...(formula.version ? { version: formula.version } : {}), vars: values } } });
176
+ const made = new Map();
177
+ for (const step of steps) {
178
+ const human = step.type === 'human';
179
+ const type = TASK_TYPES.includes(step.type) && step.type !== 'epic' ? step.type : 'task';
180
+ const task = add({
181
+ title: step.title.slice(0, 200), type, priority: step.priority ?? formula.priority ?? 2, parent: epic.id,
182
+ ...(step.description ? { description: step.description } : {}),
183
+ ...(step.labels ? { labels: step.labels } : {}),
184
+ ...(step.paths ? { paths: step.paths } : {}),
185
+ }, by, {
186
+ event: 'poured', details: { formula: formula.formula, step: step.id },
187
+ extra: {
188
+ step: { formula: formula.formula, id: step.id, ...(human ? { human: true } : {}) },
189
+ ...(human ? { gates: [{ id: 'g1', kind: 'human', note: `Your step: ${step.title}`.slice(0, 2000), at: new Date().toISOString(), by }] } : {}),
190
+ },
191
+ });
192
+ made.set(step.id, task);
193
+ }
194
+ for (const step of steps) {
195
+ for (const need of step.needs) {
196
+ const refused = link(made.get(step.id), 'blockedBy', made.get(need).id);
197
+ if (refused) fail(`Step ${step.id} cannot need ${need}: ${refused}`);
198
+ }
199
+ }
200
+ return { epic, tasks: [...made.values()] };
201
+ }, { dryRun });
202
+ }
@@ -0,0 +1,57 @@
1
+ import { signalShell, spawnShell } from './shell.js';
2
+
3
+ const OUTPUT_LIMIT = 64 * 1024;
4
+ const GRACE_MS = 2000;
5
+
6
+ /**
7
+ * Runs a hook's command once (ALPD §43): through /bin/sh (cmd.exe on Windows) in `cwd`, with the event as JSON
8
+ * on stdin and `env` added to ALP's environment. It runs in its own process group, so
9
+ * past its timeout the whole group gets SIGTERM, then SIGKILL two seconds later; a
10
+ * shell's children do not outlive it. Output beyond 64 KiB per stream is cut.
11
+ * @param {{ command: string, timeoutSec?: number }} hook
12
+ * @param {unknown} payload
13
+ * @param {{ cwd: string, env?: Record<string, string>, timeoutMs?: number }} options
14
+ * @returns {Promise<{ exitCode: number | null, signal: string | null, timedOut: boolean, stdout: string, stderr: string, durationMs: number }>}
15
+ */
16
+ export function runHook(hook, payload, { cwd, env = {}, timeoutMs } = {}) {
17
+ const limit = timeoutMs ?? (hook.timeoutSec ?? 60) * 1000;
18
+ const started = Date.now();
19
+ return new Promise((resolve, reject) => {
20
+ const child = spawnShell(hook.command, { cwd, env: { ...process.env, ...env }, stdio: ['pipe', 'pipe', 'pipe'] });
21
+ const signal = name => signalShell(child, name);
22
+ const output = { stdout: '', stderr: '' };
23
+ for (const stream of ['stdout', 'stderr']) {
24
+ child[stream].setEncoding('utf8');
25
+ child[stream].on('data', chunk => { if (output[stream].length < OUTPUT_LIMIT) output[stream] = (output[stream] + chunk).slice(0, OUTPUT_LIMIT); });
26
+ }
27
+ let timedOut = false;
28
+ let kill;
29
+ const timer = setTimeout(() => {
30
+ timedOut = true;
31
+ signal('SIGTERM');
32
+ kill = setTimeout(() => signal('SIGKILL'), GRACE_MS);
33
+ }, limit);
34
+ child.on('error', error => { clearTimeout(timer); clearTimeout(kill); reject(error); });
35
+ // A background process the hook started may keep its output open; the hook is done when its shell exits.
36
+ child.on('exit', () => setTimeout(() => { child.stdout.destroy(); child.stderr.destroy(); }, 200).unref());
37
+ child.on('close', (exitCode, signalName) => {
38
+ clearTimeout(timer);
39
+ clearTimeout(kill);
40
+ resolve({ exitCode, signal: signalName, timedOut, ...output, durationMs: Date.now() - started });
41
+ });
42
+ // A command that does not read stdin closes it early; that is not an error.
43
+ child.stdin.on('error', () => {});
44
+ child.stdin.end(JSON.stringify(payload) + '\n');
45
+ });
46
+ }
47
+
48
+ /** A payload like the one ALP sends for an event, for trying a hook out. */
49
+ export function samplePayload(event, { project, agent = 'main', session = 'alp-sample-session', task } = {}) {
50
+ return {
51
+ event, sample: true, project, session, agent,
52
+ ...(task ? { task } : {}),
53
+ ...(event === 'handoff' ? { handoff: { outcome: 'complete', summary: 'Sample handoff for a hook test' } } : {}),
54
+ ...(event === 'task.close' ? { task: task ?? 'alp-sample', reason: 'done' } : {}),
55
+ ...(event === 'merge' ? { branch: 'alp/sample', files: [] } : {}),
56
+ };
57
+ }
package/src/hooks.js ADDED
@@ -0,0 +1,36 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { AlpError } from './errors.js';
4
+ import { validateHook } from './validation.js';
5
+
6
+ /**
7
+ * The hook definitions an agent uses (ALPD §45): those it names in agent.json and the
8
+ * JSON files in its own hooks/ directory. `project` marks a hook that comes from the
9
+ * project's .alp/, which runs only once the user trusts the workspace.
10
+ * @param {import('./types.js').ResolvedAgent} agent
11
+ * @returns {Promise<Array<{ name: string, path: string, project: boolean, event: string, command: string, blocking?: boolean, timeoutSec?: number, match?: { agent?: string, label?: string }, description?: string }>>}
12
+ */
13
+ export async function loadHooks(agent) {
14
+ const project = path.join(agent.projectRoot, '.alp') + path.sep;
15
+ const hooks = [];
16
+ for (const { name, path: file } of agent.hooks) {
17
+ if (!file.endsWith('.json')) throw new AlpError('INVALID_HOOK', `${file}: a hook is a JSON definition, hooks/<name>.json, with an event and a command`);
18
+ let config;
19
+ try { config = JSON.parse(await readFile(file, 'utf8')); }
20
+ catch (cause) { throw new AlpError('INVALID_HOOK', `${file}: ${cause.message}`, { cause }); }
21
+ validateHook(config, file);
22
+ hooks.push({ ...config, name: name.replace(/\.json$/, ''), path: file, project: file.startsWith(project) });
23
+ }
24
+ return hooks;
25
+ }
26
+
27
+ /**
28
+ * Whether a hook applies: to its agent, if it names one, and to tasks with its label, if it names one.
29
+ * @param {{ match?: { agent?: string, label?: string } }} hook
30
+ * @param {{ agent: string, labels?: string[] }} context
31
+ */
32
+ export function hookMatches(hook, { agent, labels = [] }) {
33
+ if (hook.match?.agent && hook.match.agent !== agent) return false;
34
+ if (hook.match?.label && !labels.includes(hook.match.label)) return false;
35
+ return true;
36
+ }
package/src/index.js ADDED
@@ -0,0 +1,31 @@
1
+ // Stable module namespaces avoid collisions between domain exports.
2
+ export * as adapter from './adapter.js';
3
+ export * as atomic from './atomic.js';
4
+ export * as backup from './backup.js';
5
+ export * as beads from './beads.js';
6
+ export * as delegation from './delegation.js';
7
+ export * as errors from './errors.js';
8
+ export * as formulas from './formulas.js';
9
+ export * as hook_run from './hook-run.js';
10
+ export * as hooks from './hooks.js';
11
+ export * as init from './init.js';
12
+ export * as library_edit from './library-edit.js';
13
+ export * as library from './library.js';
14
+ export * as log from './log.js';
15
+ export * as migrations from './migrations.js';
16
+ export * as models from './models.js';
17
+ export * as permissions from './permissions.js';
18
+ export * as profiles from './profiles.js';
19
+ export * as promptsafe from './promptsafe.js';
20
+ export * as providers from './providers.js';
21
+ export * as resolver from './resolver.js';
22
+ export * as state_schema from './state-schema.js';
23
+ export * as task_rows from './task-rows.js';
24
+ export * as tasks from './tasks.js';
25
+ export * as teams from './teams.js';
26
+ export * as trust from './trust.js';
27
+ export * as upgrade from './upgrade.js';
28
+ export * as user_settings from './user-settings.js';
29
+ export * as validation from './validation.js';
30
+ export * as verify from './verify.js';
31
+ export * as workflow from './workflow.js';