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 +23 -1632
- package/package.json +6 -4
- package/tools/dist/cn.js +32097 -0
- package/{dist → tools/dist}/inbox.js +285 -272
- package/tools/dist/peer-sync.js +24150 -0
package/bin/cn
CHANGED
|
@@ -1,1632 +1,23 @@
|
|
|
1
|
-
#!/usr/bin/env
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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" "$@"
|