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.
- package/bin/cn +1301 -33
- 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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
47
|
-
update Update cn
|
|
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
|
-
//
|
|
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
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
96
|
-
const
|
|
97
|
-
|
|
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
|
|
1449
|
+
// Get template version and commit
|
|
280
1450
|
let templateVersion = 'unknown';
|
|
281
|
-
let
|
|
1451
|
+
let cnCommit = 'unknown';
|
|
1452
|
+
const cnDir = path.join(__dirname, '..');
|
|
282
1453
|
try {
|
|
283
|
-
const pkgPath = path.join(
|
|
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
|
-
|
|
1459
|
+
cnCommit = execSync('git rev-parse --short HEAD 2>/dev/null', {
|
|
289
1460
|
encoding: 'utf8',
|
|
290
|
-
cwd:
|
|
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
|
-
|
|
317
|
-
node_version: ${nodeVersion}
|
|
318
|
-
platform: ${platform}
|
|
1501
|
+
openclaw_version: ${openclawVersion}
|
|
319
1502
|
hub_name: ${hubName}
|
|
320
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
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:
|