cnagent 2.1.0 → 2.1.2

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/bin/cn CHANGED
@@ -1,1632 +1,23 @@
1
- #!/usr/bin/env node
2
-
3
- /**
4
- * cn - Coherent Network agent CLI
5
- *
6
- * This is a thin wrapper that routes to the appropriate tool.
7
- * OCaml source in tools/src/cn/, bundled JS in dist/cn.js
8
- */
9
-
10
- const { execSync, spawn } = require('child_process');
11
- const path = require('path');
12
- const fs = require('fs');
13
-
14
- const VERSION = '2.0.7';
15
-
16
- // Colors (respects NO_COLOR)
17
- const noColor = process.env.NO_COLOR !== undefined;
18
- const c = {
19
- reset: noColor ? '' : '\x1b[0m',
20
- green: noColor ? '' : '\x1b[32m',
21
- red: noColor ? '' : '\x1b[31m',
22
- yellow: noColor ? '' : '\x1b[33m',
23
- cyan: noColor ? '' : '\x1b[36m',
24
- magenta: noColor ? '' : '\x1b[35m',
25
- dim: noColor ? '' : '\x1b[2m',
26
- };
27
-
28
- const ok = (msg) => console.log(`${c.green}✓ ${msg}${c.reset}`);
29
- const fail = (msg) => console.log(`${c.red}✗ ${msg}${c.reset}`);
30
- const info = (msg) => console.log(`${c.cyan}${msg}${c.reset}`);
31
- const warn = (msg) => console.log(`${c.yellow}⚠ ${msg}${c.reset}`);
32
- const cmd = (msg) => `${c.magenta}${msg}${c.reset}`;
33
-
34
- const HELP = `cn - Coherent Network agent CLI
35
-
36
- Usage: cn <command> [options]
37
-
38
- Commands:
39
- # Agent decisions (output)
40
- delete <thread> GTD: discard
41
- defer <thread> GTD: postpone
42
- delegate <t> <peer> GTD: forward
43
- do <thread> GTD: claim/start
44
- done <thread> GTD: complete → archive
45
- reply <thread> <msg> Append to thread
46
- send <peer> <msg> Message to peer (or self)
47
-
48
- # cn operations (orchestrator)
49
- next Get next inbox item (with cadence)
50
- sync Fetch inbound + send outbound
51
- inbox List inbox (cn internal)
52
- outbox List outbox (cn internal)
53
- read <thread> Read thread with cadence
54
-
55
- # Hub management
56
- init [name] Create new hub
57
- status Show hub state
58
- commit [msg] Stage + commit
59
- push Push to origin
60
- save [msg] Commit + push
61
- peer Manage peers
62
- doctor Health check
63
- update Update cn
64
-
65
- Aliases:
66
- i = inbox, s = status, d = doctor
67
-
68
- Flags:
69
- --help, -h Show help
70
- --version, -V Show version
71
-
72
- Examples:
73
- cn init sigma Create hub named 'sigma'
74
- cn inbox check List inbound branches
75
- cn doctor Check system health
76
- `;
77
-
78
- // Expand aliases
79
- const aliases = { i: 'inbox', o: 'outbox', s: 'status', d: 'doctor', p: 'peer', t: 'thread' };
80
- const expandAlias = (cmd) => aliases[cmd] || cmd;
81
-
82
- // Find hub path
83
- function findHubPath() {
84
- let dir = process.cwd();
85
- while (dir !== '/') {
86
- if (fs.existsSync(path.join(dir, '.cn', 'config.json'))) {
87
- return dir;
88
- }
89
- // Also check for cn-* pattern with state/peers.md
90
- if (fs.existsSync(path.join(dir, 'state', 'peers.md'))) {
91
- return dir;
92
- }
93
- dir = path.dirname(dir);
94
- }
95
- return null;
96
- }
97
-
98
- // Derive name from path
99
- function deriveName(hubPath) {
100
- const base = path.basename(hubPath);
101
- return base.startsWith('cn-') ? base.slice(3) : base;
102
- }
103
-
104
- // Append-only action log
105
- function logAction(hubPath, action, details = {}) {
106
- const logsDir = path.join(hubPath, 'logs');
107
- if (!fs.existsSync(logsDir)) {
108
- fs.mkdirSync(logsDir, { recursive: true });
109
- }
110
- const entry = {
111
- ts: new Date().toISOString(),
112
- action,
113
- ...details
114
- };
115
- fs.appendFileSync(
116
- path.join(logsDir, 'cn.log'),
117
- JSON.stringify(entry) + '\n'
118
- );
119
- }
120
-
121
- // Run inbox commands (threads/inbox/ model)
122
- function runInbox(subCmd, hubPath, name) {
123
- const inboxDir = path.join(hubPath, 'threads', 'inbox');
124
- const peersPath = path.join(hubPath, 'state', 'peers.md');
125
-
126
- // Ensure inbox dir exists
127
- if (!fs.existsSync(inboxDir)) {
128
- fs.mkdirSync(inboxDir, { recursive: true });
129
- }
130
-
131
- // Load peers
132
- let peers = {};
133
- if (fs.existsSync(peersPath)) {
134
- const peersContent = fs.readFileSync(peersPath, 'utf8');
135
- peers = parsePeers(peersContent);
136
- }
137
-
138
- if (subCmd === 'check') {
139
- // Actor model: check MY repo for inbound branches from peers (peer/*)
140
- info(`Checking inbox for ${name}...`);
141
-
142
- // Fetch my own repo
143
- try {
144
- execSync('git fetch origin', { cwd: hubPath, stdio: 'pipe' });
145
- } catch (err) {
146
- warn('Failed to fetch origin');
147
- }
148
-
149
- let totalInbound = 0;
150
-
151
- for (const [peerName, peer] of Object.entries(peers)) {
152
- if (peer.kind === 'template') continue; // Skip template repo
153
-
154
- try {
155
- // Find branches from this peer in MY repo (peerName/*)
156
- const branches = execSync(`git branch -r | grep "origin/${peerName}/" | sed 's/.*origin\\///'`, {
157
- cwd: hubPath,
158
- encoding: 'utf8',
159
- stdio: ['pipe', 'pipe', 'pipe']
160
- }).trim().split('\n').filter(b => b);
161
-
162
- if (branches.length > 0) {
163
- logAction(hubPath, 'inbox.fetch', { from: peerName, branches, count: branches.length });
164
- warn(`From ${peerName}: ${branches.length} inbound`);
165
- branches.forEach(b => console.log(` ← ${b}`));
166
- totalInbound += branches.length;
167
- } else {
168
- console.log(` ${c.dim}${peerName}: no inbound${c.reset}`);
169
- }
170
- } catch (err) {
171
- // No branches found
172
- console.log(` ${c.dim}${peerName}: no inbound${c.reset}`);
173
- }
174
- }
175
-
176
- if (totalInbound === 0) {
177
- ok('Inbox clear');
178
- }
179
- return;
180
- }
181
-
182
- if (subCmd === 'process') {
183
- // Actor model: materialize inbound branches from MY repo to threads/inbox/
184
- info('Processing inbox...');
185
- let processed = 0;
186
-
187
- for (const [peerName, peer] of Object.entries(peers)) {
188
- if (peer.kind === 'template') continue; // Skip template repo
189
-
190
- try {
191
- // Find branches from this peer in MY repo
192
- const branches = execSync(`git branch -r | grep "origin/${peerName}/" | sed 's/.*origin\\///'`, {
193
- cwd: hubPath,
194
- encoding: 'utf8',
195
- stdio: ['pipe', 'pipe', 'pipe']
196
- }).trim().split('\n').filter(b => b);
197
-
198
- for (const branch of branches) {
199
- // Get files from this branch
200
- try {
201
- const files = execSync(`git diff main...origin/${branch} --name-only 2>/dev/null || git diff master...origin/${branch} --name-only`, {
202
- cwd: hubPath,
203
- encoding: 'utf8',
204
- stdio: ['pipe', 'pipe', 'pipe']
205
- }).trim().split('\n').filter(f => f.endsWith('.md'));
206
-
207
- for (const file of files) {
208
- // Get file content from branch
209
- const content = execSync(`git show origin/${branch}:${file}`, {
210
- cwd: hubPath,
211
- encoding: 'utf8',
212
- stdio: ['pipe', 'pipe', 'pipe']
213
- });
214
-
215
- // Write to inbox with metadata
216
- const inboxFileName = `${peerName}-${path.basename(branch)}.md`;
217
- const inboxFilePath = path.join(inboxDir, inboxFileName);
218
-
219
- // Skip if already materialized
220
- if (fs.existsSync(inboxFilePath)) {
221
- continue;
222
- }
223
-
224
- // Add/update frontmatter
225
- const meta = {
226
- from: peerName,
227
- branch: branch,
228
- file: file,
229
- received: new Date().toISOString()
230
- };
231
- const finalContent = ensureFrontmatter(content, meta);
232
- fs.writeFileSync(inboxFilePath, finalContent);
233
-
234
- logAction(hubPath, 'inbox.materialize', { from: peerName, branch, file, inboxFile: inboxFileName });
235
- ok(`Materialized: ${inboxFileName}`);
236
- processed++;
237
- }
238
- } catch (err) {
239
- // Branch might not have files or diff failed
240
- }
241
- }
242
- } catch (err) {
243
- // No branches found
244
- }
245
- }
246
-
247
- if (processed === 0) {
248
- info('No new threads to materialize');
249
- } else {
250
- ok(`Processed ${processed} thread(s)`);
251
- }
252
- return;
253
- }
254
-
255
- if (subCmd === 'flush') {
256
- // Scan inbox for agent replies, send back
257
- info('Scanning inbox for replies...');
258
-
259
- const threads = fs.readdirSync(inboxDir)
260
- .filter(f => f.endsWith('.md'))
261
- .map(f => {
262
- const filePath = path.join(inboxDir, f);
263
- const content = fs.readFileSync(filePath, 'utf8');
264
- const meta = parseFrontmatter(content);
265
- return { file: f, path: filePath, content, meta };
266
- });
267
-
268
- let replied = 0;
269
- for (const t of threads) {
270
- // Check if agent has replied (look for ## Reply section or reply: true in frontmatter)
271
- const hasReply = t.meta.reply === 'true' || t.content.includes('\n## Reply\n') || t.content.includes('\n## Response\n');
272
-
273
- if (!hasReply) continue;
274
-
275
- const from = t.meta.from;
276
- if (!from) continue;
277
-
278
- const peer = peers[from];
279
- if (!peer || !peer.clone) continue;
280
-
281
- try {
282
- const threadName = path.basename(t.file, '.md');
283
- const branchName = `${name}/${threadName}-reply`;
284
-
285
- // Push reply to peer's clone
286
- execSync(`git checkout main 2>/dev/null || git checkout master`, { cwd: peer.clone, stdio: 'pipe' });
287
- execSync(`git pull --ff-only 2>/dev/null || true`, { cwd: peer.clone, stdio: 'pipe' });
288
- execSync(`git checkout -b ${branchName} 2>/dev/null || git checkout ${branchName}`, { cwd: peer.clone, stdio: 'pipe' });
289
-
290
- const peerThreadDir = path.join(peer.clone, 'threads', 'adhoc');
291
- if (!fs.existsSync(peerThreadDir)) {
292
- fs.mkdirSync(peerThreadDir, { recursive: true });
293
- }
294
-
295
- // Copy reply thread
296
- const replyFileName = `${name}-${threadName}-reply.md`;
297
- fs.copyFileSync(t.path, path.join(peerThreadDir, replyFileName));
298
-
299
- execSync(`git add "threads/adhoc/${replyFileName}"`, { cwd: peer.clone, stdio: 'pipe' });
300
- execSync(`git commit -m "${name}: reply to ${threadName}"`, { cwd: peer.clone, stdio: 'pipe' });
301
- execSync(`git push -u origin ${branchName} -f`, { cwd: peer.clone, stdio: 'pipe' });
302
- execSync(`git checkout main 2>/dev/null || git checkout master`, { cwd: peer.clone, stdio: 'pipe' });
303
-
304
- // Archive the inbox thread (move to threads/archived/)
305
- const archivedDir = path.join(hubPath, 'threads', 'archived');
306
- if (!fs.existsSync(archivedDir)) {
307
- fs.mkdirSync(archivedDir, { recursive: true });
308
- }
309
- const archivedContent = updateFrontmatter(t.content, { replied: new Date().toISOString() });
310
- fs.writeFileSync(path.join(archivedDir, t.file), archivedContent);
311
- fs.unlinkSync(t.path);
312
-
313
- logAction(hubPath, 'inbox.reply', { to: from, thread: t.file, branch: branchName, result: 'ok' });
314
- ok(`Replied to ${from}: ${t.file}`);
315
- replied++;
316
- } catch (err) {
317
- logAction(hubPath, 'inbox.reply', { to: from, thread: t.file, result: 'error', error: err.message });
318
- fail(`Failed to reply: ${err.message}`);
319
- execSync(`git checkout main 2>/dev/null || git checkout master 2>/dev/null || true`, { cwd: peer.clone, stdio: 'pipe' });
320
- }
321
- }
322
-
323
- if (replied === 0) {
324
- info('No replies to send');
325
- } else {
326
- ok(`Sent ${replied} reply(s)`);
327
- }
328
- return;
329
- }
330
- }
331
-
332
- // Ensure content has frontmatter with given fields
333
- function ensureFrontmatter(content, meta) {
334
- const existing = parseFrontmatter(content);
335
- const merged = { ...existing, ...meta };
336
-
337
- // Remove existing frontmatter if present
338
- const withoutFm = content.replace(/^---\n[\s\S]*?\n---\n*/, '');
339
-
340
- // Build new frontmatter
341
- const fmLines = Object.entries(merged).map(([k, v]) => `${k}: ${v}`).join('\n');
342
- return `---\n${fmLines}\n---\n\n${withoutFm}`;
343
- }
344
-
345
- // Run outbox commands (threads/outbox/ model)
346
- function runOutbox(subCmd, hubPath, name) {
347
- const outboxDir = path.join(hubPath, 'threads', 'outbox');
348
- const sentDir = path.join(hubPath, 'threads', 'sent');
349
- const peersPath = path.join(hubPath, 'state', 'peers.md');
350
-
351
- // Ensure directories exist
352
- if (!fs.existsSync(outboxDir)) {
353
- fs.mkdirSync(outboxDir, { recursive: true });
354
- }
355
- if (!fs.existsSync(sentDir)) {
356
- fs.mkdirSync(sentDir, { recursive: true });
357
- }
358
-
359
- // Load peers
360
- let peers = {};
361
- if (fs.existsSync(peersPath)) {
362
- const peersContent = fs.readFileSync(peersPath, 'utf8');
363
- peers = parsePeers(peersContent);
364
- }
365
-
366
- // Scan outbox for threads
367
- const threads = fs.readdirSync(outboxDir)
368
- .filter(f => f.endsWith('.md'))
369
- .map(f => {
370
- const filePath = path.join(outboxDir, f);
371
- const content = fs.readFileSync(filePath, 'utf8');
372
- const meta = parseFrontmatter(content);
373
- return { file: f, path: filePath, content, meta };
374
- });
375
-
376
- if (subCmd === 'check') {
377
- if (threads.length === 0) {
378
- ok('Outbox clear');
379
- return;
380
- }
381
- warn(`${threads.length} pending send(s):`);
382
- threads.forEach(t => {
383
- const to = t.meta.to || '(no recipient)';
384
- console.log(` → ${to}: ${t.file}`);
385
- });
386
- return;
387
- }
388
-
389
- if (subCmd === 'flush') {
390
- if (threads.length === 0) {
391
- ok('Outbox clear');
392
- return;
393
- }
394
-
395
- info(`Flushing ${threads.length} thread(s)...`);
396
-
397
- for (const t of threads) {
398
- const to = t.meta.to;
399
- if (!to) {
400
- logAction(hubPath, 'outbox.skip', { thread: t.file, reason: 'no recipient (to: field missing)' });
401
- warn(`Skipping ${t.file}: no 'to:' in frontmatter`);
402
- continue;
403
- }
404
-
405
- const peer = peers[to];
406
- if (!peer) {
407
- logAction(hubPath, 'outbox.skip', { thread: t.file, to, reason: 'unknown peer' });
408
- fail(`Unknown peer: ${to}`);
409
- continue;
410
- }
411
-
412
- if (!peer.clone) {
413
- logAction(hubPath, 'outbox.skip', { thread: t.file, to, reason: 'no clone path configured' });
414
- fail(`No clone path for peer: ${to}`);
415
- continue;
416
- }
417
-
418
- try {
419
- const threadName = path.basename(t.file, '.md');
420
- const branchName = `${name}/${threadName}`;
421
-
422
- // Actor model: push to PEER's repo (their clone)
423
- // 1. Ensure peer clone is on main and up to date
424
- execSync(`git checkout main 2>/dev/null || git checkout master`, { cwd: peer.clone, stdio: 'pipe' });
425
- execSync(`git pull --ff-only 2>/dev/null || true`, { cwd: peer.clone, stdio: 'pipe' });
426
-
427
- // 2. Create branch in peer's clone
428
- execSync(`git checkout -b ${branchName} 2>/dev/null || git checkout ${branchName}`, { cwd: peer.clone, stdio: 'pipe' });
429
-
430
- // 3. Copy thread to peer's threads/adhoc/ (standardized location)
431
- const peerThreadDir = path.join(peer.clone, 'threads', 'adhoc');
432
- if (!fs.existsSync(peerThreadDir)) {
433
- fs.mkdirSync(peerThreadDir, { recursive: true });
434
- }
435
- const peerThreadPath = path.join(peerThreadDir, t.file);
436
- fs.copyFileSync(t.path, peerThreadPath);
437
-
438
- // 4. Commit and push
439
- execSync(`git add "threads/adhoc/${t.file}"`, { cwd: peer.clone, stdio: 'pipe' });
440
- execSync(`git commit -m "${name}: ${threadName}"`, { cwd: peer.clone, stdio: 'pipe' });
441
- execSync(`git push -u origin ${branchName} -f`, { cwd: peer.clone, stdio: 'pipe' });
442
-
443
- // 5. Return to main
444
- execSync(`git checkout main 2>/dev/null || git checkout master`, { cwd: peer.clone, stdio: 'pipe' });
445
-
446
- // Move to sent/
447
- const sentPath = path.join(sentDir, t.file);
448
- const sentContent = updateFrontmatter(t.content, { sent: new Date().toISOString() });
449
- fs.writeFileSync(sentPath, sentContent);
450
- fs.unlinkSync(t.path);
451
-
452
- logAction(hubPath, 'outbox.send', { to, thread: t.file, branch: branchName, clone: peer.clone, result: 'ok' });
453
- ok(`Sent to ${to}: ${t.file}`);
454
- } catch (err) {
455
- logAction(hubPath, 'outbox.send', { to, thread: t.file, result: 'error', error: err.message });
456
- fail(`Failed to send ${t.file}: ${err.message}`);
457
- execSync(`git checkout main 2>/dev/null || true`, { cwd: hubPath, stdio: 'pipe' });
458
- }
459
- }
460
-
461
- ok('Outbox flush complete');
462
- }
463
- }
464
-
465
- // Commit command — agent requests, cn executes
466
- function runCommit(hubPath, name, message) {
467
- // Check for uncommitted changes
468
- const status = execSync('git status --porcelain', { cwd: hubPath, encoding: 'utf8' }).trim();
469
-
470
- if (!status) {
471
- info('Nothing to commit');
472
- return;
473
- }
474
-
475
- // Default message if none provided
476
- if (!message || message.trim() === '') {
477
- message = `${name}: auto-commit ${new Date().toISOString().slice(0, 10)}`;
478
- }
479
-
480
- try {
481
- // Stage all changes
482
- execSync('git add -A', { cwd: hubPath, stdio: 'pipe' });
483
-
484
- // Commit
485
- execSync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { cwd: hubPath, stdio: 'pipe' });
486
-
487
- logAction(hubPath, 'commit', { message, result: 'ok' });
488
- ok(`Committed: ${message}`);
489
- } catch (err) {
490
- logAction(hubPath, 'commit', { message, result: 'error', error: err.message });
491
- fail(`Commit failed: ${err.message}`);
492
- }
493
- }
494
-
495
- // Push command — push to origin
496
- function runPush(hubPath, name) {
497
- try {
498
- // Get current branch
499
- const branch = execSync('git branch --show-current', { cwd: hubPath, encoding: 'utf8' }).trim();
500
-
501
- // Push
502
- execSync(`git push origin ${branch}`, { cwd: hubPath, stdio: 'pipe' });
503
-
504
- logAction(hubPath, 'push', { branch, result: 'ok' });
505
- ok(`Pushed to origin/${branch}`);
506
- } catch (err) {
507
- logAction(hubPath, 'push', { result: 'error', error: err.message });
508
- fail(`Push failed: ${err.message}`);
509
- }
510
- }
511
-
512
- // Write file — agent requests, cn executes
513
- function runWrite(hubPath, filePath, content) {
514
- if (!filePath) {
515
- fail('Usage: cn write <path> <content>');
516
- fail(' echo "content" | cn write <path>');
517
- process.exit(1);
518
- }
519
-
520
- // Resolve path relative to hub
521
- const fullPath = path.isAbsolute(filePath) ? filePath : path.join(hubPath, filePath);
522
-
523
- // Security: ensure path is within hub
524
- if (!fullPath.startsWith(hubPath)) {
525
- fail('Path must be within hub directory');
526
- process.exit(1);
527
- }
528
-
529
- // Interpret escape sequences in content
530
- content = content.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
531
-
532
- try {
533
- // Ensure parent directory exists
534
- const dir = path.dirname(fullPath);
535
- if (!fs.existsSync(dir)) {
536
- fs.mkdirSync(dir, { recursive: true });
537
- }
538
-
539
- fs.writeFileSync(fullPath, content);
540
- logAction(hubPath, 'write', { path: filePath, bytes: content.length, result: 'ok' });
541
- ok(`Wrote ${filePath} (${content.length} bytes)`);
542
- } catch (err) {
543
- logAction(hubPath, 'write', { path: filePath, result: 'error', error: err.message });
544
- fail(`Write failed: ${err.message}`);
545
- }
546
- }
547
-
548
- // Append to file
549
- function runAppend(hubPath, filePath, content) {
550
- if (!filePath) {
551
- fail('Usage: cn append <path> <content>');
552
- process.exit(1);
553
- }
554
-
555
- const fullPath = path.isAbsolute(filePath) ? filePath : path.join(hubPath, filePath);
556
-
557
- if (!fullPath.startsWith(hubPath)) {
558
- fail('Path must be within hub directory');
559
- process.exit(1);
560
- }
561
-
562
- // Interpret escape sequences
563
- content = content.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
564
-
565
- try {
566
- fs.appendFileSync(fullPath, content);
567
- logAction(hubPath, 'append', { path: filePath, bytes: content.length, result: 'ok' });
568
- ok(`Appended to ${filePath} (${content.length} bytes)`);
569
- } catch (err) {
570
- logAction(hubPath, 'append', { path: filePath, result: 'error', error: err.message });
571
- fail(`Append failed: ${err.message}`);
572
- }
573
- }
574
-
575
- // Make directory
576
- function runMkdir(hubPath, dirPath) {
577
- if (!dirPath) {
578
- fail('Usage: cn mkdir <path>');
579
- process.exit(1);
580
- }
581
-
582
- const fullPath = path.isAbsolute(dirPath) ? dirPath : path.join(hubPath, dirPath);
583
-
584
- if (!fullPath.startsWith(hubPath)) {
585
- fail('Path must be within hub directory');
586
- process.exit(1);
587
- }
588
-
589
- try {
590
- fs.mkdirSync(fullPath, { recursive: true });
591
- logAction(hubPath, 'mkdir', { path: dirPath, result: 'ok' });
592
- ok(`Created ${dirPath}`);
593
- } catch (err) {
594
- logAction(hubPath, 'mkdir', { path: dirPath, result: 'error', error: err.message });
595
- fail(`Mkdir failed: ${err.message}`);
596
- }
597
- }
598
-
599
- // Remove file
600
- function runRm(hubPath, filePath) {
601
- if (!filePath) {
602
- fail('Usage: cn rm <path>');
603
- process.exit(1);
604
- }
605
-
606
- const fullPath = path.isAbsolute(filePath) ? filePath : path.join(hubPath, filePath);
607
-
608
- if (!fullPath.startsWith(hubPath)) {
609
- fail('Path must be within hub directory');
610
- process.exit(1);
611
- }
612
-
613
- // Safety: don't allow deleting critical files
614
- const critical = ['spec/SOUL.md', 'spec/USER.md', 'state/peers.md'];
615
- const relPath = path.relative(hubPath, fullPath);
616
- if (critical.includes(relPath)) {
617
- fail(`Cannot delete critical file: ${relPath}`);
618
- process.exit(1);
619
- }
620
-
621
- try {
622
- if (fs.existsSync(fullPath)) {
623
- const stat = fs.statSync(fullPath);
624
- if (stat.isDirectory()) {
625
- fs.rmSync(fullPath, { recursive: true });
626
- } else {
627
- fs.unlinkSync(fullPath);
628
- }
629
- logAction(hubPath, 'rm', { path: filePath, result: 'ok' });
630
- ok(`Removed ${filePath}`);
631
- } else {
632
- info(`${filePath} does not exist`);
633
- }
634
- } catch (err) {
635
- logAction(hubPath, 'rm', { path: filePath, result: 'error', error: err.message });
636
- fail(`Remove failed: ${err.message}`);
637
- }
638
- }
639
-
640
- // Thread operations — agent-facing API (no file paths)
641
- function runThread(hubPath, name, args) {
642
- const subCmd = args[0];
643
- const subArgs = args.slice(1);
644
-
645
- switch (subCmd) {
646
- case 'list':
647
- threadList(hubPath, name, subArgs);
648
- break;
649
- case 'new':
650
- threadNew(hubPath, name, subArgs);
651
- break;
652
- case 'show':
653
- threadShow(hubPath, name, subArgs[0]);
654
- break;
655
- case 'reply':
656
- threadReply(hubPath, name, subArgs[0], subArgs.slice(1).join(' '));
657
- break;
658
- case 'close':
659
- threadClose(hubPath, name, subArgs[0]);
660
- break;
661
- case 'fetch':
662
- threadFetch(hubPath, name);
663
- break;
664
- case 'send':
665
- threadSend(hubPath, name);
666
- break;
667
- case 'sync':
668
- threadFetch(hubPath, name);
669
- threadSend(hubPath, name);
670
- break;
671
- default:
672
- fail(`Unknown thread command: ${subCmd}`);
673
- console.log('Usage: cn thread <list|new|show|reply|close|fetch|send|sync>');
674
- process.exit(1);
675
- }
676
- }
677
-
678
- // List threads
679
- function threadList(hubPath, name, args) {
680
- const filter = args.find(a => a.startsWith('--'))?.slice(2) || 'all';
681
- const dirs = {
682
- inbox: path.join(hubPath, 'threads', 'inbox'),
683
- outbox: path.join(hubPath, 'threads', 'outbox'),
684
- daily: path.join(hubPath, 'threads', 'daily'),
685
- adhoc: path.join(hubPath, 'threads', 'adhoc'),
686
- };
687
-
688
- const listDir = (dir, prefix) => {
689
- if (!fs.existsSync(dir)) return [];
690
- return fs.readdirSync(dir)
691
- .filter(f => f.endsWith('.md'))
692
- .map(f => `${prefix}/${f.replace('.md', '')}`);
693
- };
694
-
695
- let threads = [];
696
- if (filter === 'all' || filter === 'inbox') threads.push(...listDir(dirs.inbox, 'inbox'));
697
- if (filter === 'all' || filter === 'outbox') threads.push(...listDir(dirs.outbox, 'outbox'));
698
- if (filter === 'all' || filter === 'daily') threads.push(...listDir(dirs.daily, 'daily'));
699
- if (filter === 'all' || filter === 'adhoc') threads.push(...listDir(dirs.adhoc, 'adhoc'));
700
-
701
- if (threads.length === 0) {
702
- info('No threads');
703
- } else {
704
- console.log(`Threads (${threads.length}):`);
705
- threads.forEach(t => console.log(` ${t}`));
706
- }
707
- }
708
-
709
- // Create new thread
710
- function threadNew(hubPath, name, args) {
711
- const title = args.find(a => !a.startsWith('--'));
712
- const toFlag = args.find(a => a.startsWith('--to='))?.slice(5);
713
- const isDaily = args.includes('--daily');
714
- const isAdhoc = args.includes('--adhoc');
715
-
716
- if (!title) {
717
- fail('Usage: cn thread new <title> [--to <peer>|--daily|--adhoc]');
718
- process.exit(1);
719
- }
720
-
721
- // Determine directory and ID
722
- let dir, threadId, frontmatter;
723
- const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
724
- const today = new Date().toISOString().slice(0, 10).replace(/-/g, '');
725
-
726
- if (toFlag) {
727
- dir = path.join(hubPath, 'threads', 'outbox');
728
- threadId = `outbox/${slug}`;
729
- frontmatter = `---\nto: ${toFlag}\ncreated: ${new Date().toISOString()}\n---\n\n`;
730
- } else if (isDaily) {
731
- dir = path.join(hubPath, 'threads', 'daily');
732
- threadId = `daily/${today}`;
733
- frontmatter = `---\ndate: ${today}\n---\n\n`;
734
- } else if (isAdhoc) {
735
- dir = path.join(hubPath, 'threads', 'adhoc');
736
- threadId = `adhoc/${slug}`;
737
- frontmatter = `---\ncreated: ${new Date().toISOString()}\n---\n\n`;
738
- } else {
739
- // Default to adhoc
740
- dir = path.join(hubPath, 'threads', 'adhoc');
741
- threadId = `adhoc/${slug}`;
742
- frontmatter = `---\ncreated: ${new Date().toISOString()}\n---\n\n`;
743
- }
744
-
745
- if (!fs.existsSync(dir)) {
746
- fs.mkdirSync(dir, { recursive: true });
747
- }
748
-
749
- const filePath = path.join(dir, `${path.basename(threadId)}.md`);
750
- const content = `${frontmatter}# ${title}\n\n`;
751
-
752
- fs.writeFileSync(filePath, content);
753
- logAction(hubPath, 'thread.new', { id: threadId, title, result: 'ok' });
754
- ok(`Created thread: ${threadId}`);
755
- }
756
-
757
- // Show thread content
758
- function threadShow(hubPath, name, threadId) {
759
- if (!threadId) {
760
- fail('Usage: cn thread show <id>');
761
- process.exit(1);
762
- }
763
-
764
- const filePath = threadIdToPath(hubPath, threadId);
765
- if (!fs.existsSync(filePath)) {
766
- fail(`Thread not found: ${threadId}`);
767
- process.exit(1);
768
- }
769
-
770
- const content = fs.readFileSync(filePath, 'utf8');
771
- console.log(content);
772
- }
773
-
774
- // Reply to thread
775
- function threadReply(hubPath, name, threadId, message) {
776
- if (!threadId || !message) {
777
- fail('Usage: cn thread reply <id> <message>');
778
- process.exit(1);
779
- }
780
-
781
- const filePath = threadIdToPath(hubPath, threadId);
782
- if (!fs.existsSync(filePath)) {
783
- fail(`Thread not found: ${threadId}`);
784
- process.exit(1);
785
- }
786
-
787
- // Interpret escape sequences
788
- message = message.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
789
-
790
- const timestamp = new Date().toISOString();
791
- const reply = `\n\n## Reply (${timestamp})\n\n${message}`;
792
-
793
- fs.appendFileSync(filePath, reply);
794
- logAction(hubPath, 'thread.reply', { id: threadId, bytes: message.length, result: 'ok' });
795
- ok(`Replied to: ${threadId}`);
796
- }
797
-
798
- // Close/archive thread
799
- function threadClose(hubPath, name, threadId) {
800
- if (!threadId) {
801
- fail('Usage: cn thread close <id>');
802
- process.exit(1);
803
- }
804
-
805
- const filePath = threadIdToPath(hubPath, threadId);
806
- if (!fs.existsSync(filePath)) {
807
- fail(`Thread not found: ${threadId}`);
808
- process.exit(1);
809
- }
810
-
811
- const archivedDir = path.join(hubPath, 'threads', 'archived');
812
- if (!fs.existsSync(archivedDir)) {
813
- fs.mkdirSync(archivedDir, { recursive: true });
814
- }
815
-
816
- const fileName = path.basename(filePath);
817
- const archivedPath = path.join(archivedDir, fileName);
818
-
819
- // Add closed timestamp to frontmatter
820
- let content = fs.readFileSync(filePath, 'utf8');
821
- content = updateFrontmatter(content, { closed: new Date().toISOString() });
822
-
823
- fs.writeFileSync(archivedPath, content);
824
- fs.unlinkSync(filePath);
825
-
826
- logAction(hubPath, 'thread.close', { id: threadId, result: 'ok' });
827
- ok(`Closed thread: ${threadId} → archived`);
828
- }
829
-
830
- // Fetch inbound threads
831
- function threadFetch(hubPath, name) {
832
- runInbox('check', hubPath, name);
833
- runInbox('process', hubPath, name);
834
- }
835
-
836
- // Send outbound threads
837
- function threadSend(hubPath, name) {
838
- runOutbox('flush', hubPath, name);
839
- }
840
-
841
- // Convert thread ID to file path
842
- function threadIdToPath(hubPath, threadId) {
843
- // threadId format: "inbox/pi-clp" or "daily/20260205"
844
- const parts = threadId.split('/');
845
- if (parts.length !== 2) {
846
- return path.join(hubPath, 'threads', threadId + '.md');
847
- }
848
- return path.join(hubPath, 'threads', parts[0], parts[1] + '.md');
849
- }
850
-
851
- // === CLEAN AGENT API ===
852
-
853
- // Determine cadence from thread location
854
- function getCadence(filePath) {
855
- if (filePath.includes('/inbox/')) return 'inbox';
856
- if (filePath.includes('/daily/')) return 'daily';
857
- if (filePath.includes('/weekly/')) return 'weekly';
858
- if (filePath.includes('/monthly/')) return 'monthly';
859
- if (filePath.includes('/quarterly/')) return 'quarterly';
860
- if (filePath.includes('/yearly/')) return 'yearly';
861
- if (filePath.includes('/adhoc/')) return 'adhoc';
862
- if (filePath.includes('/doing/')) return 'doing';
863
- if (filePath.includes('/deferred/')) return 'deferred';
864
- if (filePath.includes('/outbox/')) return 'outbox';
865
- return 'unknown';
866
- }
867
-
868
- // Get next inbox item (for agent processing)
869
- function getNextInboxItem(hubPath) {
870
- const inboxDir = path.join(hubPath, 'threads', 'inbox');
871
- if (!fs.existsSync(inboxDir)) return null;
872
-
873
- const threads = fs.readdirSync(inboxDir).filter(f => f.endsWith('.md'));
874
- if (threads.length === 0) return null;
875
-
876
- // Return first item (FIFO)
877
- const f = threads[0];
878
- const filePath = path.join(inboxDir, f);
879
- const content = fs.readFileSync(filePath, 'utf8');
880
- const meta = parseFrontmatter(content);
881
-
882
- return {
883
- id: f.replace('.md', ''),
884
- cadence: 'inbox',
885
- from: meta.from || 'unknown',
886
- content: content,
887
- path: filePath
888
- };
889
- }
890
-
891
- // List inbox (threads needing triage)
892
- function listInbox(hubPath, name) {
893
- const inboxDir = path.join(hubPath, 'threads', 'inbox');
894
- if (!fs.existsSync(inboxDir)) {
895
- console.log('(empty)');
896
- return;
897
- }
898
-
899
- const threads = fs.readdirSync(inboxDir).filter(f => f.endsWith('.md'));
900
- if (threads.length === 0) {
901
- console.log('(empty)');
902
- return;
903
- }
904
-
905
- threads.forEach(f => {
906
- const id = f.replace('.md', '');
907
- const content = fs.readFileSync(path.join(inboxDir, f), 'utf8');
908
- const meta = parseFrontmatter(content);
909
- const from = meta.from || 'unknown';
910
- const title = content.match(/^#\s+(.+)$/m)?.[1] || id;
911
- console.log(`${from}/${id} "${title}"`);
912
- });
913
- }
914
-
915
- // List outbox (pending sends)
916
- function listOutbox(hubPath, name) {
917
- const outboxDir = path.join(hubPath, 'threads', 'outbox');
918
- if (!fs.existsSync(outboxDir)) {
919
- console.log('(empty)');
920
- return;
921
- }
922
-
923
- const threads = fs.readdirSync(outboxDir).filter(f => f.endsWith('.md'));
924
- if (threads.length === 0) {
925
- console.log('(empty)');
926
- return;
927
- }
928
-
929
- threads.forEach(f => {
930
- const id = f.replace('.md', '');
931
- const content = fs.readFileSync(path.join(outboxDir, f), 'utf8');
932
- const meta = parseFrontmatter(content);
933
- const to = meta.to || 'unknown';
934
- const title = content.match(/^#\s+(.+)$/m)?.[1] || id;
935
- console.log(`→ ${to} "${title}"`);
936
- });
937
- }
938
-
939
- // Read thread content (with cadence metadata)
940
- function readThread(hubPath, threadId) {
941
- if (!threadId) {
942
- fail('Usage: cn read <thread>');
943
- process.exit(1);
944
- }
945
-
946
- const filePath = findThread(hubPath, threadId);
947
- if (!filePath) {
948
- fail(`Thread not found: ${threadId}`);
949
- process.exit(1);
950
- }
951
-
952
- const content = fs.readFileSync(filePath, 'utf8');
953
- const cadence = getCadence(filePath);
954
- const meta = parseFrontmatter(content);
955
-
956
- // Output with cadence header
957
- console.log(`[cadence: ${cadence}]`);
958
- if (meta.from) console.log(`[from: ${meta.from}]`);
959
- if (meta.to) console.log(`[to: ${meta.to}]`);
960
- console.log('');
961
- console.log(content);
962
- }
963
-
964
- // Reply to thread
965
- function replyThread(hubPath, name, threadId, message) {
966
- if (!threadId || !message) {
967
- fail('Usage: cn reply <thread> <message>');
968
- process.exit(1);
969
- }
970
-
971
- const filePath = findThread(hubPath, threadId);
972
- if (!filePath) {
973
- fail(`Thread not found: ${threadId}`);
974
- process.exit(1);
975
- }
976
-
977
- message = message.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
978
- const timestamp = new Date().toISOString();
979
- const reply = `\n\n## Reply (${timestamp})\n\n${message}`;
980
-
981
- fs.appendFileSync(filePath, reply);
982
- logAction(hubPath, 'reply', { thread: threadId, bytes: message.length });
983
- ok(`Replied to ${threadId}`);
984
- }
985
-
986
- // Send message to peer (creates outbox thread)
987
- function sendToPeer(hubPath, name, peer, message) {
988
- if (!peer || !message) {
989
- fail('Usage: cn send <peer> <message>');
990
- process.exit(1);
991
- }
992
-
993
- const outboxDir = path.join(hubPath, 'threads', 'outbox');
994
- if (!fs.existsSync(outboxDir)) {
995
- fs.mkdirSync(outboxDir, { recursive: true });
996
- }
997
-
998
- const slug = message.slice(0, 30).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
999
- const fileName = `${slug}.md`;
1000
- const filePath = path.join(outboxDir, fileName);
1001
-
1002
- message = message.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
1003
- const content = `---
1004
- to: ${peer}
1005
- created: ${new Date().toISOString()}
1006
- ---
1007
-
1008
- # ${message.split('\n')[0]}
1009
-
1010
- ${message}
1011
- `;
1012
-
1013
- fs.writeFileSync(filePath, content);
1014
- logAction(hubPath, 'send', { to: peer, thread: slug });
1015
- ok(`Created message to ${peer}: ${slug}`);
1016
- info('Run "cn sync" to send');
1017
- }
1018
-
1019
- // GTD: Delete (discard thread)
1020
- function gtdDelete(hubPath, name, threadId) {
1021
- if (!threadId) {
1022
- fail('Usage: cn delete <thread>');
1023
- process.exit(1);
1024
- }
1025
-
1026
- const filePath = findThread(hubPath, threadId);
1027
- if (!filePath) {
1028
- fail(`Thread not found: ${threadId}`);
1029
- process.exit(1);
1030
- }
1031
-
1032
- fs.unlinkSync(filePath);
1033
- logAction(hubPath, 'gtd.delete', { thread: threadId });
1034
- ok(`Deleted: ${threadId}`);
1035
- }
1036
-
1037
- // GTD: Defer (postpone - move to deferred/)
1038
- function gtdDefer(hubPath, name, threadId, until) {
1039
- if (!threadId) {
1040
- fail('Usage: cn defer <thread> [until]');
1041
- process.exit(1);
1042
- }
1043
-
1044
- const filePath = findThread(hubPath, threadId);
1045
- if (!filePath) {
1046
- fail(`Thread not found: ${threadId}`);
1047
- process.exit(1);
1048
- }
1049
-
1050
- const deferredDir = path.join(hubPath, 'threads', 'deferred');
1051
- if (!fs.existsSync(deferredDir)) {
1052
- fs.mkdirSync(deferredDir, { recursive: true });
1053
- }
1054
-
1055
- let content = fs.readFileSync(filePath, 'utf8');
1056
- content = updateFrontmatter(content, { deferred: new Date().toISOString(), until: until || 'unspecified' });
1057
-
1058
- const destPath = path.join(deferredDir, path.basename(filePath));
1059
- fs.writeFileSync(destPath, content);
1060
- fs.unlinkSync(filePath);
1061
-
1062
- logAction(hubPath, 'gtd.defer', { thread: threadId, until });
1063
- ok(`Deferred: ${threadId}` + (until ? ` until ${until}` : ''));
1064
- }
1065
-
1066
- // GTD: Delegate (forward to peer)
1067
- function gtdDelegate(hubPath, name, threadId, peer) {
1068
- if (!threadId || !peer) {
1069
- fail('Usage: cn delegate <thread> <peer>');
1070
- process.exit(1);
1071
- }
1072
-
1073
- const filePath = findThread(hubPath, threadId);
1074
- if (!filePath) {
1075
- fail(`Thread not found: ${threadId}`);
1076
- process.exit(1);
1077
- }
1078
-
1079
- // Read content and create outbox item
1080
- let content = fs.readFileSync(filePath, 'utf8');
1081
- content = updateFrontmatter(content, { to: peer, delegated: new Date().toISOString(), 'delegated-by': name });
1082
-
1083
- const outboxDir = path.join(hubPath, 'threads', 'outbox');
1084
- if (!fs.existsSync(outboxDir)) {
1085
- fs.mkdirSync(outboxDir, { recursive: true });
1086
- }
1087
-
1088
- const destPath = path.join(outboxDir, path.basename(filePath));
1089
- fs.writeFileSync(destPath, content);
1090
- fs.unlinkSync(filePath);
1091
-
1092
- logAction(hubPath, 'gtd.delegate', { thread: threadId, to: peer });
1093
- ok(`Delegated to ${peer}: ${threadId}`);
1094
- info('Run "cn sync" to send');
1095
- }
1096
-
1097
- // GTD: Do (claim - move to doing/)
1098
- function gtdDo(hubPath, name, threadId) {
1099
- if (!threadId) {
1100
- fail('Usage: cn do <thread>');
1101
- process.exit(1);
1102
- }
1103
-
1104
- const filePath = findThread(hubPath, threadId);
1105
- if (!filePath) {
1106
- fail(`Thread not found: ${threadId}`);
1107
- process.exit(1);
1108
- }
1109
-
1110
- const doingDir = path.join(hubPath, 'threads', 'doing');
1111
- if (!fs.existsSync(doingDir)) {
1112
- fs.mkdirSync(doingDir, { recursive: true });
1113
- }
1114
-
1115
- let content = fs.readFileSync(filePath, 'utf8');
1116
- content = updateFrontmatter(content, { started: new Date().toISOString() });
1117
-
1118
- const destPath = path.join(doingDir, path.basename(filePath));
1119
- fs.writeFileSync(destPath, content);
1120
- fs.unlinkSync(filePath);
1121
-
1122
- logAction(hubPath, 'gtd.do', { thread: threadId });
1123
- ok(`Started: ${threadId}`);
1124
- }
1125
-
1126
- // GTD: Done (complete - archive)
1127
- function gtdDone(hubPath, name, threadId) {
1128
- if (!threadId) {
1129
- fail('Usage: cn done <thread>');
1130
- process.exit(1);
1131
- }
1132
-
1133
- const filePath = findThread(hubPath, threadId);
1134
- if (!filePath) {
1135
- fail(`Thread not found: ${threadId}`);
1136
- process.exit(1);
1137
- }
1138
-
1139
- const archivedDir = path.join(hubPath, 'threads', 'archived');
1140
- if (!fs.existsSync(archivedDir)) {
1141
- fs.mkdirSync(archivedDir, { recursive: true });
1142
- }
1143
-
1144
- let content = fs.readFileSync(filePath, 'utf8');
1145
- content = updateFrontmatter(content, { completed: new Date().toISOString() });
1146
-
1147
- const destPath = path.join(archivedDir, path.basename(filePath));
1148
- fs.writeFileSync(destPath, content);
1149
- fs.unlinkSync(filePath);
1150
-
1151
- logAction(hubPath, 'gtd.done', { thread: threadId });
1152
- ok(`Completed: ${threadId} → archived`);
1153
- }
1154
-
1155
- // Find thread across all locations
1156
- function findThread(hubPath, threadId) {
1157
- // Direct path format (inbox/foo)
1158
- if (threadId.includes('/')) {
1159
- const direct = threadIdToPath(hubPath, threadId);
1160
- if (fs.existsSync(direct)) return direct;
1161
- }
1162
-
1163
- // Search in all locations
1164
- const locations = ['inbox', 'outbox', 'doing', 'deferred', 'daily', 'adhoc'];
1165
- for (const loc of locations) {
1166
- const tryPath = path.join(hubPath, 'threads', loc, threadId + '.md');
1167
- if (fs.existsSync(tryPath)) return tryPath;
1168
- }
1169
-
1170
- return null;
1171
- }
1172
-
1173
- // Parse YAML frontmatter
1174
- function parseFrontmatter(content) {
1175
- const match = content.match(/^---\n([\s\S]*?)\n---/);
1176
- if (!match) return {};
1177
- const yaml = match[1];
1178
- const meta = {};
1179
- yaml.split('\n').forEach(line => {
1180
- const [key, ...rest] = line.split(':');
1181
- if (key && rest.length) {
1182
- meta[key.trim().toLowerCase()] = rest.join(':').trim();
1183
- }
1184
- });
1185
- return meta;
1186
- }
1187
-
1188
- // Update frontmatter field
1189
- function updateFrontmatter(content, updates) {
1190
- const match = content.match(/^---\n([\s\S]*?)\n---/);
1191
- if (!match) {
1192
- // No frontmatter, add it
1193
- const lines = Object.entries(updates).map(([k, v]) => `${k}: ${v}`).join('\n');
1194
- return `---\n${lines}\n---\n\n${content}`;
1195
- }
1196
- let yaml = match[1];
1197
- for (const [key, val] of Object.entries(updates)) {
1198
- const regex = new RegExp(`^${key}:.*$`, 'm');
1199
- if (regex.test(yaml)) {
1200
- yaml = yaml.replace(regex, `${key}: ${val}`);
1201
- } else {
1202
- yaml += `\n${key}: ${val}`;
1203
- }
1204
- }
1205
- return content.replace(/^---\n[\s\S]*?\n---/, `---\n${yaml}\n---`);
1206
- }
1207
-
1208
- // Parse outbox.md table
1209
- function parseOutbox(content) {
1210
- const items = [];
1211
- const lines = content.split('\n');
1212
- let inTable = false;
1213
-
1214
- for (const line of lines) {
1215
- if (line.startsWith('| To')) {
1216
- inTable = true;
1217
- continue;
1218
- }
1219
- if (line.startsWith('|--')) continue;
1220
- if (!inTable || !line.startsWith('|')) continue;
1221
-
1222
- const cols = line.split('|').map(c => c.trim()).filter(c => c);
1223
- if (cols.length >= 3) {
1224
- items.push({
1225
- to: cols[0],
1226
- thread: cols[1],
1227
- status: cols[2] || 'pending',
1228
- sent: cols[3] || null
1229
- });
1230
- }
1231
- }
1232
- return items;
1233
- }
1234
-
1235
- // Write outbox.md
1236
- function writeOutbox(outboxPath, items) {
1237
- let content = `# Outbox
1238
-
1239
- Agent writes pending sends here. cn reads and executes.
1240
-
1241
- | To | Thread | Status | Sent |
1242
- |----|--------|--------|------|
1243
- `;
1244
- for (const item of items) {
1245
- content += `| ${item.to} | ${item.thread} | ${item.status} | ${item.sent || '—'} |\n`;
1246
- }
1247
- fs.writeFileSync(outboxPath, content);
1248
- }
1249
-
1250
- // Parse peers.md
1251
- function parsePeers(content) {
1252
- const peers = {};
1253
- // Match each peer block
1254
- const blocks = content.split(/(?=- name:)/);
1255
- for (const block of blocks) {
1256
- const nameMatch = block.match(/- name:\s*(\S+)/);
1257
- if (!nameMatch) continue;
1258
- const name = nameMatch[1];
1259
- const hubMatch = block.match(/hub:\s*(\S+)/);
1260
- const cloneMatch = block.match(/clone:\s*(\S+)/);
1261
- peers[name] = {
1262
- name,
1263
- hub: hubMatch ? hubMatch[1] : null,
1264
- clone: cloneMatch ? cloneMatch[1] : null
1265
- };
1266
- }
1267
- return peers;
1268
- }
1269
-
1270
- // Doctor command
1271
- function doctor(hubPath) {
1272
- console.log(`cn v${VERSION}`);
1273
- info(`Checking health...`);
1274
- console.log('');
1275
-
1276
- let checks = [];
1277
- let warnings = [];
1278
-
1279
- // Git
1280
- try {
1281
- const gitVersion = execSync('git --version', { encoding: 'utf8' }).trim();
1282
- checks.push({ name: 'git', ok: true, val: gitVersion.replace('git version ', '') });
1283
- } catch {
1284
- checks.push({ name: 'git', ok: false, val: 'not installed' });
1285
- }
1286
-
1287
- // Git config
1288
- try {
1289
- const userName = execSync('git config user.name', { encoding: 'utf8' }).trim();
1290
- checks.push({ name: 'git user.name', ok: true, val: userName });
1291
- } catch {
1292
- checks.push({ name: 'git user.name', ok: false, val: 'not set' });
1293
- }
1294
-
1295
- try {
1296
- const userEmail = execSync('git config user.email', { encoding: 'utf8' }).trim();
1297
- checks.push({ name: 'git user.email', ok: true, val: userEmail });
1298
- } catch {
1299
- checks.push({ name: 'git user.email', ok: false, val: 'not set' });
1300
- }
1301
-
1302
- // Hub directory
1303
- checks.push({ name: 'hub directory', ok: fs.existsSync(hubPath), val: fs.existsSync(hubPath) ? 'exists' : 'not found' });
1304
-
1305
- // .cn/config.json
1306
- const configPath = path.join(hubPath, '.cn', 'config.json');
1307
- checks.push({ name: '.cn/config.json', ok: fs.existsSync(configPath), val: fs.existsSync(configPath) ? 'exists' : 'missing' });
1308
-
1309
- // spec/SOUL.md
1310
- const soulPath = path.join(hubPath, 'spec', 'SOUL.md');
1311
- if (fs.existsSync(soulPath)) {
1312
- checks.push({ name: 'spec/SOUL.md', ok: true, val: 'exists' });
1313
- } else {
1314
- warnings.push({ name: 'spec/SOUL.md', val: 'missing (optional)' });
1315
- }
1316
-
1317
- // state/peers.md
1318
- const peersPath = path.join(hubPath, 'state', 'peers.md');
1319
- if (fs.existsSync(peersPath)) {
1320
- const content = fs.readFileSync(peersPath, 'utf8');
1321
- const peerCount = (content.match(/- name:/g) || []).length;
1322
- checks.push({ name: 'state/peers.md', ok: true, val: `${peerCount} peer(s)` });
1323
- } else {
1324
- checks.push({ name: 'state/peers.md', ok: false, val: 'missing' });
1325
- }
1326
-
1327
- // origin remote
1328
- try {
1329
- execSync('git remote get-url origin', { cwd: hubPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
1330
- checks.push({ name: 'origin remote', ok: true, val: 'configured' });
1331
- } catch {
1332
- checks.push({ name: 'origin remote', ok: false, val: 'not configured' });
1333
- }
1334
-
1335
- // inbox status
1336
- try {
1337
- const result = execSync(`node ${path.join(__dirname, '..', 'dist', 'inbox.js')} check ${hubPath} ${deriveName(hubPath)} 2>&1`, { encoding: 'utf8' });
1338
- const inboundMatch = result.match(/(\d+) inbound/);
1339
- if (inboundMatch) {
1340
- const count = parseInt(inboundMatch[1]);
1341
- if (count > 0) {
1342
- warnings.push({ name: 'inbox', val: `${count} pending` });
1343
- } else {
1344
- checks.push({ name: 'inbox', ok: true, val: 'clear' });
1345
- }
1346
- } else if (result.includes('All clear')) {
1347
- checks.push({ name: 'inbox', ok: true, val: 'clear' });
1348
- }
1349
- } catch {
1350
- warnings.push({ name: 'inbox', val: 'check failed' });
1351
- }
1352
-
1353
- // Print checks
1354
- const width = 22;
1355
- checks.forEach(({ name, ok: isOk, val }) => {
1356
- const dots = '.'.repeat(Math.max(1, width - name.length));
1357
- const status = isOk
1358
- ? `${c.green}✓ ${val}${c.reset}`
1359
- : `${c.red}✗ ${val}${c.reset}`;
1360
- console.log(`${name}${dots} ${status}`);
1361
- });
1362
-
1363
- // Print warnings
1364
- warnings.forEach(({ name, val }) => {
1365
- const dots = '.'.repeat(Math.max(1, width - name.length));
1366
- console.log(`${name}${dots} ${c.yellow}⚠ ${val}${c.reset}`);
1367
- });
1368
-
1369
- console.log('');
1370
- const fails = checks.filter(c => !c.ok).length;
1371
- const warns = warnings.length;
1372
-
1373
- if (fails === 0) {
1374
- ok('All critical checks passed.');
1375
- } else {
1376
- fail(`${fails} issue(s) found.`);
1377
- }
1378
-
1379
- console.log(`${c.dim}[status] ok=${checks.filter(c=>c.ok).length} warn=${warns} fail=${fails} version=${VERSION}${c.reset}`);
1380
- process.exit(fails > 0 ? 1 : 0);
1381
- }
1382
-
1383
- // Status command
1384
- function status(hubPath, name) {
1385
- info(`cn hub: ${name}`);
1386
- console.log('');
1387
- console.log(`hub..................... ${c.green}✓${c.reset}`);
1388
- console.log(`name.................... ${c.green}✓ ${name}${c.reset}`);
1389
- console.log(`path.................... ${c.green}✓ ${hubPath}${c.reset}`);
1390
- console.log('');
1391
- console.log(`${c.dim}[status] ok version=${VERSION}${c.reset}`);
1392
- }
1393
-
1394
- // Main
1395
- const args = process.argv.slice(2);
1396
- if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
1397
- console.log(HELP);
1398
- process.exit(0);
1399
- }
1400
-
1401
- if (args[0] === '--version' || args[0] === '-V') {
1402
- console.log(`cn ${VERSION}`);
1403
- process.exit(0);
1404
- }
1405
-
1406
- const command = expandAlias(args[0]);
1407
- const subArgs = args.slice(1);
1408
-
1409
- // Commands that don't need hub
1410
- if (command === 'init') {
1411
- warn('cn init not yet implemented');
1412
- process.exit(1);
1413
- }
1414
-
1415
- if (command === 'update') {
1416
- // Check if already on latest
1417
- try {
1418
- const latest = execSync('npm view cnagent version', { encoding: 'utf8' }).trim();
1419
- if (VERSION === latest) {
1420
- ok(`Already up to date (v${VERSION})`);
1421
- // Still write runtime.md if in a hub
1422
- writeRuntimeMd(findHubPath(), latest);
1423
- process.exit(0);
1424
- }
1425
- info(`Updating cnagent v${VERSION} → v${latest}...`);
1426
- execSync('npm install -g cnagent@latest', { stdio: 'inherit' });
1427
- ok(`Updated to v${latest}`);
1428
- // Write runtime.md if in a hub
1429
- writeRuntimeMd(findHubPath(), latest);
1430
- } catch (e) {
1431
- fail('Update failed. Try: npm install -g cnagent@latest');
1432
- process.exit(1);
1433
- }
1434
- process.exit(0);
1435
- }
1436
-
1437
- // Write state/runtime.md after update
1438
- function writeRuntimeMd(hubPath, cnVersion) {
1439
- if (!hubPath) return; // Not in a hub, skip
1440
-
1441
- const runtimePath = path.join(hubPath, 'state', 'runtime.md');
1442
- const stateDir = path.join(hubPath, 'state');
1443
-
1444
- // Ensure state/ exists
1445
- if (!fs.existsSync(stateDir)) {
1446
- fs.mkdirSync(stateDir, { recursive: true });
1447
- }
1448
-
1449
- // Get template version and commit
1450
- let templateVersion = 'unknown';
1451
- let cnCommit = 'unknown';
1452
- const cnDir = path.join(__dirname, '..');
1453
- try {
1454
- const pkgPath = path.join(cnDir, 'package.json');
1455
- if (fs.existsSync(pkgPath)) {
1456
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
1457
- templateVersion = pkg.version || 'unknown';
1458
- }
1459
- cnCommit = execSync('git rev-parse --short HEAD 2>/dev/null', {
1460
- encoding: 'utf8',
1461
- cwd: cnDir
1462
- }).trim() || 'unknown';
1463
- } catch {}
1464
-
1465
- // Gather runtime info (agent-relevant only)
1466
- const hubName = deriveName(hubPath);
1467
-
1468
- // Hub commit hash
1469
- let hubCommit = 'unknown';
1470
- try {
1471
- hubCommit = execSync('git rev-parse --short HEAD 2>/dev/null', {
1472
- encoding: 'utf8',
1473
- cwd: hubPath
1474
- }).trim() || 'unknown';
1475
- } catch {}
1476
-
1477
- // OpenClaw version
1478
- let openclawVersion = 'not installed';
1479
- try {
1480
- openclawVersion = execSync('openclaw --version 2>/dev/null', { encoding: 'utf8' }).trim();
1481
- } catch {}
1482
-
1483
- let peerCount = 0;
1484
- try {
1485
- const peersPath = path.join(hubPath, 'state', 'peers.md');
1486
- if (fs.existsSync(peersPath)) {
1487
- const content = fs.readFileSync(peersPath, 'utf8');
1488
- peerCount = (content.match(/- name:/g) || []).length;
1489
- }
1490
- } catch {}
1491
-
1492
- const content = `# Runtime State
1493
-
1494
- Auto-generated by \`cn update\`. Do not edit manually.
1495
-
1496
- \`\`\`yaml
1497
- session_start: ${new Date().toISOString()}
1498
- cn_version: ${cnVersion}
1499
- cn_commit: ${cnCommit}
1500
- template_version: ${templateVersion}
1501
- openclaw_version: ${openclawVersion}
1502
- hub_name: ${hubName}
1503
- hub_commit: ${hubCommit}
1504
- peer_count: ${peerCount}
1505
- \`\`\`
1506
- `;
1507
-
1508
- fs.writeFileSync(runtimePath, content);
1509
- info(`Wrote ${runtimePath}`);
1510
- }
1511
-
1512
- // Find hub
1513
- const hubPath = findHubPath();
1514
- if (!hubPath) {
1515
- fail('Not in a cn hub.');
1516
- console.log('');
1517
- console.log('Either:');
1518
- console.log(` 1) ${cmd('cd')} into an existing hub (cn-sigma, cn-pi, etc.)`);
1519
- console.log(` 2) ${cmd('cn init <name>')} to create a new one`);
1520
- process.exit(1);
1521
- }
1522
-
1523
- const name = deriveName(hubPath);
1524
-
1525
- switch (command) {
1526
- case 'status':
1527
- status(hubPath, name);
1528
- break;
1529
-
1530
- case 'doctor':
1531
- doctor(hubPath);
1532
- break;
1533
-
1534
- // === CORE AGENT API ===
1535
-
1536
- case 'next':
1537
- // Get next inbox item for agent processing
1538
- const nextItem = getNextInboxItem(hubPath);
1539
- if (!nextItem) {
1540
- console.log('(inbox empty)');
1541
- } else {
1542
- console.log(`[cadence: ${nextItem.cadence}]`);
1543
- console.log(`[from: ${nextItem.from}]`);
1544
- console.log(`[id: ${nextItem.id}]`);
1545
- console.log('');
1546
- console.log(nextItem.content);
1547
- }
1548
- break;
1549
-
1550
- case 'inbox':
1551
- // List threads in inbox
1552
- listInbox(hubPath, name);
1553
- break;
1554
-
1555
- case 'outbox':
1556
- // List pending outbound
1557
- listOutbox(hubPath, name);
1558
- break;
1559
-
1560
- case 'read':
1561
- // Read thread content
1562
- readThread(hubPath, subArgs[0]);
1563
- break;
1564
-
1565
- case 'reply':
1566
- // Reply to thread
1567
- replyThread(hubPath, name, subArgs[0], subArgs.slice(1).join(' '));
1568
- break;
1569
-
1570
- case 'send':
1571
- // Send message to peer
1572
- sendToPeer(hubPath, name, subArgs[0], subArgs.slice(1).join(' '));
1573
- break;
1574
-
1575
- // === GTD VERBS ===
1576
-
1577
- case 'delete':
1578
- gtdDelete(hubPath, name, subArgs[0]);
1579
- break;
1580
-
1581
- case 'defer':
1582
- gtdDefer(hubPath, name, subArgs[0], subArgs[1]);
1583
- break;
1584
-
1585
- case 'delegate':
1586
- gtdDelegate(hubPath, name, subArgs[0], subArgs[1]);
1587
- break;
1588
-
1589
- case 'do':
1590
- gtdDo(hubPath, name, subArgs[0]);
1591
- break;
1592
-
1593
- case 'done':
1594
- gtdDone(hubPath, name, subArgs[0]);
1595
- break;
1596
-
1597
- // === SYNC ===
1598
-
1599
- case 'sync':
1600
- info('Syncing...');
1601
- runInbox('check', hubPath, name);
1602
- runInbox('process', hubPath, name);
1603
- runOutbox('flush', hubPath, name);
1604
- ok('Sync complete');
1605
- break;
1606
-
1607
- // === GIT OPS (low-level) ===
1608
-
1609
- case 'commit':
1610
- runCommit(hubPath, name, subArgs.join(' '));
1611
- break;
1612
-
1613
- case 'push':
1614
- runPush(hubPath, name);
1615
- break;
1616
-
1617
- case 'save':
1618
- runCommit(hubPath, name, subArgs.join(' ') || `${name}: save ${new Date().toISOString().slice(0, 16)}`);
1619
- runPush(hubPath, name);
1620
- break;
1621
-
1622
- // === LEGACY (keep for now) ===
1623
-
1624
- case 'thread':
1625
- runThread(hubPath, name, subArgs);
1626
- break;
1627
-
1628
- default:
1629
- fail(`Unknown command: ${command}`);
1630
- console.log(`Run ${cmd('cn --help')} for usage.`);
1631
- process.exit(1);
1632
- }
1
+ #!/usr/bin/env sh
2
+ # cn - Coherent Network agent CLI
3
+ # OCaml source: tools/src/cn/
4
+ # Bundled JS: tools/dist/cn.js
5
+
6
+ # Resolve symlinks to find actual script location
7
+ SCRIPT="$0"
8
+ while [ -L "$SCRIPT" ]; do
9
+ SCRIPT_DIR="$(cd "$(dirname "$SCRIPT")" && pwd)"
10
+ SCRIPT="$(readlink "$SCRIPT")"
11
+ [ "${SCRIPT%${SCRIPT#?}}" != "/" ] && SCRIPT="$SCRIPT_DIR/$SCRIPT"
12
+ done
13
+ SCRIPT_DIR="$(cd "$(dirname "$SCRIPT")" && pwd)"
14
+
15
+ DIST="$SCRIPT_DIR/../tools/dist/cn.js"
16
+
17
+ if [ ! -f "$DIST" ]; then
18
+ echo "Error: tools/dist/cn.js not found."
19
+ echo "Run 'npm run build' in the cn-agent directory first."
20
+ exit 1
21
+ fi
22
+
23
+ exec node "$DIST" "$@"