@sztlink/pi-ensemble 0.1.0-alpha.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/core.mjs ADDED
@@ -0,0 +1,517 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ export const PROTOCOL_VERSION = 1;
6
+ export const MESSAGE_TYPES = new Set(['note', 'handoff', 'question', 'result', 'ack']);
7
+ export const AGENT_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
8
+ export const MESSAGE_ID_PATTERN = /^msg_[A-Za-z0-9_-]{8,64}$/;
9
+
10
+ export function nowIso() {
11
+ return new Date().toISOString();
12
+ }
13
+
14
+ export function defaultAgent() {
15
+ const agent = process.env.PI_ENSEMBLE_AGENT || process.env.USER || 'agent';
16
+ return AGENT_NAME_PATTERN.test(agent) ? agent : 'agent';
17
+ }
18
+
19
+ export function validateAgentName(agent) {
20
+ if (!agent || typeof agent !== 'string') throw new Error('agent name is required');
21
+ if (!AGENT_NAME_PATTERN.test(agent)) {
22
+ throw new Error(`Invalid agent name: ${agent}. Use 1-64 chars: letters, numbers, dot, underscore, hyphen.`);
23
+ }
24
+ return agent;
25
+ }
26
+
27
+ export function validateMessageType(type) {
28
+ if (!MESSAGE_TYPES.has(type)) throw new Error(`Invalid type: ${type}`);
29
+ return type;
30
+ }
31
+
32
+ export function createMessageId() {
33
+ return `msg_${randomUUID().replace(/-/g, '').slice(0, 16)}`;
34
+ }
35
+
36
+ export function validateMessageId(messageId) {
37
+ if (!messageId || typeof messageId !== 'string') throw new Error('message id is required');
38
+ if (!MESSAGE_ID_PATTERN.test(messageId)) throw new Error(`Invalid message id: ${messageId}`);
39
+ return messageId;
40
+ }
41
+
42
+ export function findWorkspaceRoot(start = process.cwd()) {
43
+ let current = path.resolve(start);
44
+ while (true) {
45
+ if (fs.existsSync(path.join(current, '.pi-ensemble'))) return current;
46
+ const parent = path.dirname(current);
47
+ if (parent === current) return null;
48
+ current = parent;
49
+ }
50
+ }
51
+
52
+ export function requireWorkspaceRoot(start = process.cwd()) {
53
+ const root = findWorkspaceRoot(start);
54
+ if (!root) throw new Error('No .pi-ensemble/ found. Run `ensemble init` first.');
55
+ return root;
56
+ }
57
+
58
+ export function ensembleDir(root) {
59
+ return path.join(root, '.pi-ensemble');
60
+ }
61
+
62
+ function ensureDir(p) {
63
+ fs.mkdirSync(p, { recursive: true, mode: 0o700 });
64
+ }
65
+
66
+ function writeIfMissing(file, content) {
67
+ if (!fs.existsSync(file)) fs.writeFileSync(file, content, { encoding: 'utf8', mode: 0o600 });
68
+ }
69
+
70
+ function append(file, content) {
71
+ fs.appendFileSync(file, content, { encoding: 'utf8', mode: 0o600 });
72
+ }
73
+
74
+ function agentStateFile(root, agent) {
75
+ return path.join(ensembleDir(root), 'agents', agent, 'state.json');
76
+ }
77
+
78
+ function readAgentState(root, agent) {
79
+ return readJson(agentStateFile(root, agent), {});
80
+ }
81
+
82
+ function writeAgentState(root, agent, patch) {
83
+ const current = readAgentState(root, agent);
84
+ const next = { ...current, ...patch };
85
+ writeJson(agentStateFile(root, agent), next);
86
+ return next;
87
+ }
88
+
89
+ function parseInboxMessages(content) {
90
+ const lines = content.split('\n');
91
+ const starts = [];
92
+ for (let i = 0; i < lines.length; i++) {
93
+ const match = lines[i].match(/^## ([0-9]{4}-[0-9]{2}-[0-9]{2}T[^ ]+) — .*?(?:\{#(msg_[A-Za-z0-9_-]+)\})?\s*$/);
94
+ if (match) starts.push({ index: i, ts: match[1], messageId: match[2] || null });
95
+ }
96
+ return starts.map((start, idx) => {
97
+ const end = starts[idx + 1]?.index ?? lines.length;
98
+ return { ts: start.ts, messageId: start.messageId, block: lines.slice(start.index, end).join('\n').trimEnd() };
99
+ });
100
+ }
101
+
102
+ function inboxHeader(agent) {
103
+ return `# inbox: ${agent}\n\n`;
104
+ }
105
+
106
+ function inboxSince(content, agent, since) {
107
+ if (!since) return content;
108
+ const sinceMs = Date.parse(since);
109
+ if (!Number.isFinite(sinceMs)) return content;
110
+ const messages = parseInboxMessages(content).filter(message => Date.parse(message.ts) > sinceMs);
111
+ return inboxHeader(agent) + (messages.length ? `\n${messages.map(m => m.block).join('\n\n')}\n` : '');
112
+ }
113
+
114
+ function latestInboxReadAt(root, agent) {
115
+ const file = path.join(ensembleDir(root), 'audit.jsonl');
116
+ try {
117
+ const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
118
+ for (let i = lines.length - 1; i >= 0; i--) {
119
+ const record = JSON.parse(lines[i]);
120
+ if ((record.action === 'inbox_read' || record.action === 'inbox_clear') && record.agent === agent) return record.ts;
121
+ }
122
+ } catch {}
123
+ return null;
124
+ }
125
+
126
+ export function init(root = process.cwd(), { agent = defaultAgent() } = {}) {
127
+ validateAgentName(agent);
128
+ const base = ensembleDir(root);
129
+ ensureDir(base);
130
+ ensureDir(path.join(base, 'agents', agent));
131
+ writeIfMissing(path.join(base, 'config.yaml'), `version: ${PROTOCOL_VERSION}\ndefault_agent: ${agent}\n`);
132
+ writeIfMissing(path.join(base, 'blackboard.md'), '# pi-ensemble blackboard\n\nAppend-only shared notes for local coding agents.\n');
133
+ writeIfMissing(path.join(base, 'worktrees.json'), '{}\n');
134
+ writeIfMissing(path.join(base, 'audit.jsonl'), '');
135
+ writeIfMissing(path.join(base, 'agents', agent, 'inbox.md'), `# inbox: ${agent}\n\n`);
136
+ writeIfMissing(path.join(base, 'agents', agent, 'state.json'), JSON.stringify({ status: 'idle', task: null, since: nowIso() }, null, 2) + '\n');
137
+ audit(root, { action: 'init', actor: agent, root });
138
+ return { root, dir: base, agent };
139
+ }
140
+
141
+ export function ensureAgent(root, agent) {
142
+ validateAgentName(agent);
143
+ const dir = path.join(ensembleDir(root), 'agents', agent);
144
+ ensureDir(dir);
145
+ writeIfMissing(path.join(dir, 'inbox.md'), `# inbox: ${agent}\n\n`);
146
+ writeIfMissing(path.join(dir, 'state.json'), JSON.stringify({ status: 'idle', task: null, since: nowIso() }, null, 2) + '\n');
147
+ return dir;
148
+ }
149
+
150
+ export function ensureBlackboard(root) {
151
+ const file = path.join(ensembleDir(root), 'blackboard.md');
152
+ writeIfMissing(file, '# pi-ensemble blackboard\n\nAppend-only shared notes for local coding agents.\n');
153
+ return file;
154
+ }
155
+
156
+ export function audit(root, event) {
157
+ const record = { ts: nowIso(), ...event };
158
+ append(path.join(ensembleDir(root), 'audit.jsonl'), JSON.stringify(record) + '\n');
159
+ return record;
160
+ }
161
+
162
+ function messageBlock({ from, to, type, body, messageId }) {
163
+ const header = to ? `${from} → ${to}` : from;
164
+ const id = messageId ? ` {#${messageId}}` : '';
165
+ return `\n## ${nowIso()} — ${header} [${type}]${id}\n\n${body.trim()}\n`;
166
+ }
167
+
168
+ export function note(root, { from = defaultAgent(), body }) {
169
+ validateAgentName(from);
170
+ if (!body?.trim()) throw new Error('note body is required');
171
+ append(ensureBlackboard(root), messageBlock({ from, type: 'note', body }));
172
+ return audit(root, { action: 'note', from, type: 'note', body });
173
+ }
174
+
175
+ export function send(root, { from = defaultAgent(), to, type = 'handoff', body }) {
176
+ validateAgentName(from);
177
+ validateAgentName(to);
178
+ validateMessageType(type);
179
+ if (!body?.trim()) throw new Error('message body is required');
180
+ ensureAgent(root, to);
181
+ const messageId = createMessageId();
182
+ append(path.join(ensembleDir(root), 'agents', to, 'inbox.md'), messageBlock({ from, to, type, body, messageId }));
183
+ return audit(root, { action: 'send', from, to, type, body, messageId });
184
+ }
185
+
186
+ export function ack(root, { from = defaultAgent(), messageId, body = '' } = {}) {
187
+ validateAgentName(from);
188
+ validateMessageId(messageId);
189
+ return audit(root, { action: 'ack', from, messageId, body: body.trim() || undefined });
190
+ }
191
+
192
+ export function done(root, { from = defaultAgent(), messageId, body = '' } = {}) {
193
+ validateAgentName(from);
194
+ validateMessageId(messageId);
195
+ return audit(root, { action: 'done', from, messageId, body: body.trim() || undefined });
196
+ }
197
+
198
+ export function readInbox(root, { agent = defaultAgent(), clear = true, sinceLastRead = false } = {}) {
199
+ validateAgentName(agent);
200
+ ensureAgent(root, agent);
201
+ const file = path.join(ensembleDir(root), 'agents', agent, 'inbox.md');
202
+ const content = fs.readFileSync(file, 'utf8');
203
+ const state = readAgentState(root, agent);
204
+ const lastReadAt = state.lastReadAt || latestInboxReadAt(root, agent);
205
+ const visible = sinceLastRead ? inboxSince(content, agent, lastReadAt) : content;
206
+ const readAt = nowIso();
207
+ writeAgentState(root, agent, { lastReadAt: readAt });
208
+ if (clear) {
209
+ const archive = path.join(ensembleDir(root), 'agents', agent, 'inbox.read.md');
210
+ append(archive, `\n<!-- cleared ${readAt} -->\n${content}\n`);
211
+ fs.writeFileSync(file, inboxHeader(agent), { encoding: 'utf8', mode: 0o600 });
212
+ audit(root, { action: 'inbox_clear', agent, sinceLastRead });
213
+ } else {
214
+ audit(root, { action: 'inbox_read', agent, sinceLastRead });
215
+ }
216
+ return visible;
217
+ }
218
+
219
+ export function readBoard(root) {
220
+ const file = ensureBlackboard(root);
221
+ audit(root, { action: 'board_read' });
222
+ return fs.readFileSync(file, 'utf8');
223
+ }
224
+
225
+ export function claims(root) {
226
+ const file = path.join(ensembleDir(root), 'worktrees.json');
227
+ audit(root, { action: 'claims_read' });
228
+ return readJson(file, {});
229
+ }
230
+
231
+ export function readAudit(root, { limit = 50 } = {}) {
232
+ const file = path.join(ensembleDir(root), 'audit.jsonl');
233
+ writeIfMissing(file, '');
234
+ const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
235
+ const selected = limit > 0 ? lines.slice(-limit) : lines;
236
+ return selected.map(line => {
237
+ try { return JSON.parse(line); }
238
+ catch { return { malformed: true, line }; }
239
+ });
240
+ }
241
+
242
+ function workspaceStatus(root) {
243
+ const base = ensembleDir(root);
244
+ const agentsRoot = path.join(base, 'agents');
245
+ const agents = fs.existsSync(agentsRoot)
246
+ ? fs.readdirSync(agentsRoot, { withFileTypes: true }).filter(d => d.isDirectory()).map(d => d.name)
247
+ : [];
248
+ const config = readConfig(path.join(base, 'config.yaml'));
249
+ const worktrees = readJson(path.join(base, 'worktrees.json'), {});
250
+ const rows = agents.map(agent => {
251
+ ensureAgent(root, agent);
252
+ const inbox = fs.readFileSync(path.join(agentsRoot, agent, 'inbox.md'), 'utf8');
253
+ const messages = parseInboxMessages(inbox);
254
+ const pending = messages.length;
255
+ const state = readAgentState(root, agent);
256
+ const lastReadAt = state.lastReadAt || latestInboxReadAt(root, agent);
257
+ const lastReadMs = Date.parse(lastReadAt || '');
258
+ const unread = Number.isFinite(lastReadMs)
259
+ ? messages.filter(message => Date.parse(message.ts) > lastReadMs).length
260
+ : pending;
261
+ const stale = Math.max(0, pending - unread);
262
+ return {
263
+ agent,
264
+ pending,
265
+ unread,
266
+ stale,
267
+ lastReadAt: lastReadAt || null,
268
+ oldestPendingAt: messages[0]?.ts || null,
269
+ newestPendingAt: messages[messages.length - 1]?.ts || null,
270
+ state,
271
+ };
272
+ });
273
+ return { root, version: config.version ?? PROTOCOL_VERSION, agents: rows, worktrees };
274
+ }
275
+
276
+ export function status(root) {
277
+ const result = workspaceStatus(root);
278
+ audit(root, { action: 'status' });
279
+ return result;
280
+ }
281
+
282
+ export function overview(root, { limit = 10 } = {}) {
283
+ const current = workspaceStatus(root);
284
+ const pending = current.agents.filter(agent => agent.pending > 0);
285
+ const unread = current.agents.filter(agent => agent.unread > 0);
286
+ const stale = current.agents.filter(agent => agent.stale > 0);
287
+ const claimEntries = Object.entries(current.worktrees).map(([targetPath, owner]) => ({ path: targetPath, ...owner }));
288
+ const openMessages = messages(root, { open: true, limit });
289
+ return {
290
+ root: current.root,
291
+ version: current.version,
292
+ agents: current.agents,
293
+ pending,
294
+ unread,
295
+ stale,
296
+ claims: claimEntries,
297
+ openMessages,
298
+ recent: timeline(root, { limit }),
299
+ };
300
+ }
301
+
302
+ export function messages(root, { limit = 50, open = false } = {}) {
303
+ const index = new Map();
304
+ for (const record of readAudit(root, { limit: 0 })) {
305
+ if (record.malformed) continue;
306
+ if (record.action === 'send' && record.messageId) {
307
+ index.set(record.messageId, {
308
+ messageId: record.messageId,
309
+ ts: record.ts,
310
+ from: record.from,
311
+ to: record.to,
312
+ type: record.type,
313
+ body: record.body,
314
+ status: 'open',
315
+ ackedBy: [],
316
+ doneBy: null,
317
+ doneAt: null,
318
+ events: [{ action: 'send', ts: record.ts, from: record.from }],
319
+ });
320
+ } else if ((record.action === 'ack' || record.action === 'done') && record.messageId) {
321
+ const current = index.get(record.messageId) || {
322
+ messageId: record.messageId,
323
+ ts: record.ts,
324
+ from: null,
325
+ to: null,
326
+ type: null,
327
+ body: null,
328
+ status: 'unknown',
329
+ ackedBy: [],
330
+ doneBy: null,
331
+ doneAt: null,
332
+ events: [],
333
+ };
334
+ current.events.push({ action: record.action, ts: record.ts, from: record.from, body: record.body });
335
+ if (record.action === 'ack') {
336
+ current.ackedBy.push({ from: record.from, ts: record.ts, body: record.body });
337
+ if (current.status !== 'done') current.status = 'acked';
338
+ } else {
339
+ current.status = 'done';
340
+ current.doneBy = record.from;
341
+ current.doneAt = record.ts;
342
+ current.doneBody = record.body;
343
+ }
344
+ index.set(record.messageId, current);
345
+ }
346
+ }
347
+ let rows = [...index.values()].sort((a, b) => String(a.ts).localeCompare(String(b.ts)));
348
+ if (open) rows = rows.filter(row => row.status !== 'done');
349
+ return limit > 0 ? rows.slice(-limit) : rows;
350
+ }
351
+
352
+ export function doctor(root, { nestedDepth = 4 } = {}) {
353
+ const checks = [];
354
+ const add = (name, status, message, details = undefined) => checks.push({ name, status, message, ...(details === undefined ? {} : { details }) });
355
+ const base = ensembleDir(root);
356
+ const required = ['config.yaml', 'blackboard.md', 'worktrees.json', 'audit.jsonl'];
357
+
358
+ add('root', fs.existsSync(base) ? 'pass' : 'fail', fs.existsSync(base) ? `.pi-ensemble found at ${base}` : `.pi-ensemble missing at ${base}`);
359
+ for (const file of required) {
360
+ const full = path.join(base, file);
361
+ add(`file:${file}`, fs.existsSync(full) ? 'pass' : 'fail', fs.existsSync(full) ? `${file} exists` : `${file} is missing`);
362
+ }
363
+
364
+ const config = readConfig(path.join(base, 'config.yaml'));
365
+ const configVersion = config.version ?? null;
366
+ add('protocol-version', configVersion === PROTOCOL_VERSION ? 'pass' : 'warn', `config version=${configVersion ?? 'missing'} expected=${PROTOCOL_VERSION}`);
367
+
368
+ const auditIssues = auditParseIssues(path.join(base, 'audit.jsonl'));
369
+ add('audit-jsonl', auditIssues.length ? 'warn' : 'pass', auditIssues.length ? `${auditIssues.length} malformed audit line(s)` : 'audit log parses cleanly', auditIssues.length ? auditIssues.slice(0, 5) : undefined);
370
+
371
+ const current = workspaceStatus(root);
372
+ const invalidAgents = current.agents.filter(a => !AGENT_NAME_PATTERN.test(a.agent)).map(a => a.agent);
373
+ add('agents', invalidAgents.length ? 'fail' : 'pass', invalidAgents.length ? `invalid agent names: ${invalidAgents.join(', ')}` : `${current.agents.length} agent(s) registered`);
374
+
375
+ const unread = current.agents.filter(a => a.unread > 0).map(a => ({ agent: a.agent, unread: a.unread }));
376
+ add('inbox-unread', unread.length ? 'info' : 'pass', unread.length ? `${unread.length} agent(s) have unread inbox items` : 'no unread inbox items', unread.length ? unread : undefined);
377
+
378
+ const stale = current.agents.filter(a => a.stale > 0).map(a => ({ agent: a.agent, stale: a.stale }));
379
+ add('inbox-retained', stale.length ? 'info' : 'pass', stale.length ? `${stale.length} agent(s) retain already-read inbox history` : 'no retained read inbox items', stale.length ? stale : undefined);
380
+
381
+ const claimsList = Object.entries(current.worktrees).map(([targetPath, owner]) => ({ path: targetPath, ...owner }));
382
+ const brokenClaims = claimsList.filter(c => !c.agent || !AGENT_NAME_PATTERN.test(c.agent) || !fs.existsSync(c.path));
383
+ add('claims', brokenClaims.length ? 'warn' : 'pass', brokenClaims.length ? `${brokenClaims.length} claim(s) need attention` : `${claimsList.length} active claim(s) look valid`, brokenClaims.length ? brokenClaims : undefined);
384
+
385
+ const nested = findNestedEnsembleDirs(root, { maxDepth: nestedDepth }).filter(dir => path.resolve(dir) !== path.resolve(base));
386
+ add('nested-ledgers', nested.length ? 'warn' : 'pass', nested.length ? `${nested.length} nested .pi-ensemble folder(s) found` : 'no nested .pi-ensemble folders found', nested.length ? nested : undefined);
387
+
388
+ audit(root, { action: 'doctor', ok: !checks.some(c => c.status === 'fail') });
389
+ return {
390
+ root,
391
+ ok: !checks.some(c => c.status === 'fail'),
392
+ summary: {
393
+ pass: checks.filter(c => c.status === 'pass').length,
394
+ info: checks.filter(c => c.status === 'info').length,
395
+ warn: checks.filter(c => c.status === 'warn').length,
396
+ fail: checks.filter(c => c.status === 'fail').length,
397
+ },
398
+ checks,
399
+ };
400
+ }
401
+
402
+ export function timeline(root, { limit = 50 } = {}) {
403
+ return readAudit(root, { limit }).map(record => ({
404
+ ts: record.ts,
405
+ action: record.action,
406
+ summary: summarizeAuditRecord(record),
407
+ record,
408
+ }));
409
+ }
410
+
411
+ function summarizeAuditRecord(record) {
412
+ if (record.malformed) return 'malformed audit record';
413
+ if (record.action === 'send') return `${record.from} → ${record.to} [${record.type}] ${record.messageId ? `${record.messageId} ` : ''}${short(record.body)}`;
414
+ if (record.action === 'ack') return `${record.from} acked ${record.messageId}${record.body ? ` — ${short(record.body)}` : ''}`;
415
+ if (record.action === 'done') return `${record.from} resolved ${record.messageId}${record.body ? ` — ${short(record.body)}` : ''}`;
416
+ if (record.action === 'note') return `${record.from} noted ${short(record.body)}`;
417
+ if (record.action === 'claim' || record.action === 'claim_update') return `${record.agent} claimed ${record.path}`;
418
+ if (record.action === 'release') return `${record.agent} released ${record.path}`;
419
+ if (record.action === 'inbox_clear') return `${record.agent} cleared inbox`;
420
+ if (record.action === 'inbox_read') return `${record.agent} read inbox`;
421
+ if (record.action === 'init') return `${record.actor} initialized ${record.root}`;
422
+ if (record.action === 'doctor') return `doctor ${record.ok ? 'ok' : 'failed'}`;
423
+ return record.action || 'unknown';
424
+ }
425
+
426
+ function short(value, max = 96) {
427
+ const text = String(value ?? '').replace(/\s+/g, ' ').trim();
428
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
429
+ }
430
+
431
+ function readConfig(file) {
432
+ const out = {};
433
+ try {
434
+ const text = fs.readFileSync(file, 'utf8');
435
+ for (const line of text.split('\n')) {
436
+ const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
437
+ if (!match) continue;
438
+ const value = match[2]?.trim() ?? '';
439
+ out[match[1]] = /^\d+$/.test(value) ? Number(value) : value;
440
+ }
441
+ } catch {}
442
+ return out;
443
+ }
444
+
445
+ function readJson(file, fallback) {
446
+ try { return JSON.parse(fs.readFileSync(file, 'utf8') || 'null') ?? fallback; }
447
+ catch { return fallback; }
448
+ }
449
+
450
+ function auditParseIssues(file) {
451
+ try {
452
+ return fs.readFileSync(file, 'utf8').split('\n').flatMap((line, index) => {
453
+ if (!line.trim()) return [];
454
+ try { JSON.parse(line); return []; }
455
+ catch (err) { return [{ line: index + 1, error: err instanceof Error ? err.message : String(err) }]; }
456
+ });
457
+ } catch {
458
+ return [];
459
+ }
460
+ }
461
+
462
+ function findNestedEnsembleDirs(root, { maxDepth = 4 } = {}) {
463
+ const found = [];
464
+ const skip = new Set(['.git', 'node_modules', '.cache', 'dist', 'build']);
465
+ function walk(dir, depth) {
466
+ if (depth > maxDepth) return;
467
+ let entries;
468
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
469
+ catch { return; }
470
+ for (const entry of entries) {
471
+ if (!entry.isDirectory()) continue;
472
+ if (entry.name === '.pi-ensemble') {
473
+ found.push(path.join(dir, entry.name));
474
+ continue;
475
+ }
476
+ if (skip.has(entry.name)) continue;
477
+ walk(path.join(dir, entry.name), depth + 1);
478
+ }
479
+ }
480
+ walk(root, 0);
481
+ return found;
482
+ }
483
+
484
+ function writeJson(file, value) {
485
+ fs.writeFileSync(file, JSON.stringify(value, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
486
+ }
487
+
488
+ export function claim(root, { agent = defaultAgent(), targetPath, force = false } = {}) {
489
+ validateAgentName(agent);
490
+ if (!targetPath?.trim()) throw new Error('path is required');
491
+ const file = path.join(ensembleDir(root), 'worktrees.json');
492
+ const worktrees = readJson(file, {});
493
+ const resolved = path.resolve(root, targetPath);
494
+ const previous = worktrees[resolved];
495
+ if (previous?.agent && previous.agent !== agent && !force) {
496
+ throw new Error(`Path already claimed by ${previous.agent}: ${resolved}. Use --force to override.`);
497
+ }
498
+ worktrees[resolved] = { agent, since: nowIso(), previous: force ? previous : undefined };
499
+ if (worktrees[resolved].previous === undefined) delete worktrees[resolved].previous;
500
+ writeJson(file, worktrees);
501
+ return audit(root, { action: previous ? 'claim_update' : 'claim', agent, path: resolved, previous, force });
502
+ }
503
+
504
+ export function release(root, { agent = defaultAgent(), targetPath, force = false } = {}) {
505
+ validateAgentName(agent);
506
+ if (!targetPath?.trim()) throw new Error('path is required');
507
+ const file = path.join(ensembleDir(root), 'worktrees.json');
508
+ const worktrees = readJson(file, {});
509
+ const resolved = path.resolve(root, targetPath);
510
+ const previous = worktrees[resolved];
511
+ if (previous?.agent && previous.agent !== agent && !force) {
512
+ throw new Error(`Path claimed by ${previous.agent}, not ${agent}: ${resolved}. Use --force to release anyway.`);
513
+ }
514
+ delete worktrees[resolved];
515
+ writeJson(file, worktrees);
516
+ return audit(root, { action: 'release', agent, path: resolved, previous, force });
517
+ }
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@sztlink/pi-ensemble",
3
+ "version": "0.1.0-alpha.12",
4
+ "description": "Shared workspace coordination for parallel coding agents",
5
+ "type": "module",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/sztlink/pi-ensemble.git"
9
+ },
10
+ "bugs": {
11
+ "url": "https://github.com/sztlink/pi-ensemble/issues"
12
+ },
13
+ "homepage": "https://github.com/sztlink/pi-ensemble#readme",
14
+ "bin": {
15
+ "ensemble": "bin/ensemble.mjs"
16
+ },
17
+ "scripts": {
18
+ "test": "node --test",
19
+ "check": "node --check bin/ensemble.mjs && npm test"
20
+ },
21
+ "keywords": [
22
+ "pi-package",
23
+ "pi",
24
+ "pi-coding-agent",
25
+ "coding-workflow",
26
+ "agents",
27
+ "coordination",
28
+ "blackboard"
29
+ ],
30
+ "license": "MIT",
31
+ "pi": {
32
+ "extensions": ["./extensions"]
33
+ },
34
+ "peerDependencies": {
35
+ "@mariozechner/pi-ai": "*",
36
+ "@mariozechner/pi-coding-agent": "*",
37
+ "typebox": "*"
38
+ },
39
+ "engines": {
40
+ "node": ">=20"
41
+ },
42
+ "files": [
43
+ "bin/",
44
+ "lib/",
45
+ "extensions/",
46
+ "docs/",
47
+ "examples/",
48
+ "README.md",
49
+ "SECURITY.md",
50
+ "CHANGELOG.md",
51
+ "LICENSE",
52
+ "package.json"
53
+ ]
54
+ }