cnagent 2.0.7 → 2.1.0

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 (2) hide show
  1. package/bin/cn +1301 -33
  2. package/package.json +1 -1
package/bin/cn CHANGED
@@ -36,15 +36,31 @@ const HELP = `cn - Coherent Network agent CLI
36
36
  Usage: cn <command> [options]
37
37
 
38
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
39
56
  init [name] Create new hub
40
57
  status Show hub state
41
- inbox Manage inbound messages
42
- check List inbound branches
43
- process Materialize as threads
44
- flush Execute decisions
58
+ commit [msg] Stage + commit
59
+ push Push to origin
60
+ save [msg] Commit + push
45
61
  peer Manage peers
46
- doctor Check system health
47
- update Update cn to latest version
62
+ doctor Health check
63
+ update Update cn
48
64
 
49
65
  Aliases:
50
66
  i = inbox, s = status, d = doctor
@@ -60,7 +76,7 @@ Examples:
60
76
  `;
61
77
 
62
78
  // Expand aliases
63
- const aliases = { i: 'inbox', s: 'status', d: 'doctor', p: 'peer', t: 'thread' };
79
+ const aliases = { i: 'inbox', o: 'outbox', s: 'status', d: 'doctor', p: 'peer', t: 'thread' };
64
80
  const expandAlias = (cmd) => aliases[cmd] || cmd;
65
81
 
66
82
  // Find hub path
@@ -85,16 +101,1170 @@ function deriveName(hubPath) {
85
101
  return base.startsWith('cn-') ? base.slice(3) : base;
86
102
  }
87
103
 
88
- // Run inbox tool
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)
89
122
  function runInbox(subCmd, hubPath, name) {
90
- const inboxJs = path.join(__dirname, '..', 'dist', 'inbox.js');
91
- if (!fs.existsSync(inboxJs)) {
92
- fail('inbox.js not found. Run from cn-agent directory or install globally.');
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}`);
93
1107
  process.exit(1);
94
1108
  }
95
- const args = [inboxJs, subCmd, hubPath, name];
96
- const result = spawn('node', args, { stdio: 'inherit' });
97
- result.on('close', (code) => process.exit(code));
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;
98
1268
  }
99
1269
 
100
1270
  // Doctor command
@@ -276,26 +1446,40 @@ function writeRuntimeMd(hubPath, cnVersion) {
276
1446
  fs.mkdirSync(stateDir, { recursive: true });
277
1447
  }
278
1448
 
279
- // Get template info
1449
+ // Get template version and commit
280
1450
  let templateVersion = 'unknown';
281
- let templateCommit = 'unknown';
1451
+ let cnCommit = 'unknown';
1452
+ const cnDir = path.join(__dirname, '..');
282
1453
  try {
283
- const pkgPath = path.join(__dirname, '..', 'package.json');
1454
+ const pkgPath = path.join(cnDir, 'package.json');
284
1455
  if (fs.existsSync(pkgPath)) {
285
1456
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
286
1457
  templateVersion = pkg.version || 'unknown';
287
1458
  }
288
- templateCommit = execSync('git rev-parse --short HEAD 2>/dev/null || echo unknown', {
1459
+ cnCommit = execSync('git rev-parse --short HEAD 2>/dev/null', {
289
1460
  encoding: 'utf8',
290
- cwd: hubPath
291
- }).trim();
1461
+ cwd: cnDir
1462
+ }).trim() || 'unknown';
292
1463
  } catch {}
293
1464
 
294
- // Gather runtime info
295
- const nodeVersion = process.version;
296
- const platform = `${process.platform} ${process.arch}`;
1465
+ // Gather runtime info (agent-relevant only)
297
1466
  const hubName = deriveName(hubPath);
298
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
+
299
1483
  let peerCount = 0;
300
1484
  try {
301
1485
  const peersPath = path.join(hubPath, 'state', 'peers.md');
@@ -312,12 +1496,11 @@ Auto-generated by \`cn update\`. Do not edit manually.
312
1496
  \`\`\`yaml
313
1497
  session_start: ${new Date().toISOString()}
314
1498
  cn_version: ${cnVersion}
1499
+ cn_commit: ${cnCommit}
315
1500
  template_version: ${templateVersion}
316
- template_commit: ${templateCommit}
317
- node_version: ${nodeVersion}
318
- platform: ${platform}
1501
+ openclaw_version: ${openclawVersion}
319
1502
  hub_name: ${hubName}
320
- hub_path: ${hubPath}
1503
+ hub_commit: ${hubCommit}
321
1504
  peer_count: ${peerCount}
322
1505
  \`\`\`
323
1506
  `;
@@ -348,13 +1531,98 @@ switch (command) {
348
1531
  doctor(hubPath);
349
1532
  break;
350
1533
 
351
- case 'inbox':
352
- const subCmd = subArgs[0] || 'check';
353
- if (!['check', 'process', 'flush'].includes(subCmd)) {
354
- fail(`Unknown inbox command: ${subCmd}`);
355
- process.exit(1);
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);
356
1547
  }
357
- runInbox(subCmd, hubPath, name);
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);
358
1626
  break;
359
1627
 
360
1628
  default:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cnagent",
3
- "version": "2.0.7",
3
+ "version": "2.1.0",
4
4
  "description": "Coherent Network agent CLI — everything runs through cn",
5
5
  "keywords": [
6
6
  "cn-agent",