superwiki 0.1.2 → 0.1.4
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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +63 -25
- package/commands/stats.md +5 -0
- package/package.json +1 -1
- package/skills/sw-config/SKILL.md +19 -8
- package/skills/sw-config/assets/reviewer.md +41 -0
- package/skills/sw-config/scripts/config.mjs +6 -1
- package/skills/sw-implement/SKILL.md +49 -23
- package/skills/sw-init/assets/sw.mjs +398 -16
- package/skills/sw-init/assets/templates/task.md +5 -1
- package/skills/sw-init/assets/viewer.html +2 -0
- package/skills/sw-init/scripts/init.mjs +178 -99
- package/skills/sw-migrate/SKILL.md +36 -25
- package/skills/sw-migrate/scripts/migrate.mjs +422 -180
- package/skills/sw-stats/SKILL.md +50 -0
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Generated by scripts/build.mjs from src/core.js
|
|
2
|
+
// Generated by scripts/build.mjs from src/core.js, src/stats.js, src/cli.js. Do not edit.
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { basename, dirname, join, resolve as resolvePath, sep } from 'node:path';
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import { createServer } from 'node:http';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
3
9
|
// Superwiki core: vault model, derived task state and lint. Pure: no fs, no DOM.
|
|
4
10
|
// Runs in Node (docs/.sw/sw.mjs) and inlined in the viewer, so both report the same findings.
|
|
5
11
|
|
|
@@ -115,6 +121,8 @@ export function buildVault(files) {
|
|
|
115
121
|
deps: asList(d.deps).map(String), softDeps: asList(d.soft_deps).map(String),
|
|
116
122
|
milestone: d.milestone || '', priority: d.priority == null || d.priority === '' ? null : Number(d.priority),
|
|
117
123
|
started: d.started || '', finished: d.finished || '',
|
|
124
|
+
// Any value asks for a separate review before the task may be done; the value names the kind.
|
|
125
|
+
review: d.review ? String(d.review) : '',
|
|
118
126
|
state: null, wave: 0, dependents: [], plan: null,
|
|
119
127
|
});
|
|
120
128
|
}
|
|
@@ -314,13 +322,368 @@ export function guideFor(vault, area) {
|
|
|
314
322
|
return vault.pages.find(p => p.folder === 'wiki' && p.data.type === 'guide' && key(p.data.area ?? '') === key(area)) || null;
|
|
315
323
|
}
|
|
316
324
|
|
|
325
|
+
// Session statistics: what the agent session working in this project has cost so far.
|
|
326
|
+
// Claude Code, Codex and Copilot CLI each keep a record of every session on disk. This finds the
|
|
327
|
+
// record of the project's session and reduces it to one row per agent: the main session and each
|
|
328
|
+
// subagent it started.
|
|
329
|
+
|
|
330
|
+
export const STATS_TOOLS = ['claude', 'codex', 'copilot'];
|
|
331
|
+
|
|
332
|
+
const MAIN = 'main';
|
|
333
|
+
// Codex keeps every project's sessions in one tree; only the most recent files are opened.
|
|
334
|
+
const CODEX_FILES_SCANNED = 100;
|
|
335
|
+
|
|
336
|
+
// ---------- Reading records ----------
|
|
337
|
+
|
|
338
|
+
// A record still being written can end in half a line; lines that do not parse are skipped.
|
|
339
|
+
function jsonLines(path) {
|
|
340
|
+
const records = [];
|
|
341
|
+
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
|
342
|
+
if (!line) continue;
|
|
343
|
+
try {
|
|
344
|
+
records.push(JSON.parse(line));
|
|
345
|
+
} catch {}
|
|
346
|
+
}
|
|
347
|
+
return records;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function jsonFile(path) {
|
|
351
|
+
try {
|
|
352
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
353
|
+
} catch {
|
|
354
|
+
return {};
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const modifiedAt = path => statSync(path).mtimeMs;
|
|
359
|
+
|
|
360
|
+
// The project root as typed and as resolved: tools record the working directory either way.
|
|
361
|
+
function rootForms(root) {
|
|
362
|
+
const forms = new Set([root]);
|
|
363
|
+
try {
|
|
364
|
+
forms.add(realpathSync(root));
|
|
365
|
+
} catch {}
|
|
366
|
+
return [...forms];
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const isWithin = (roots, dir) => Boolean(dir) && roots.some(root => dir === root || dir.startsWith(root + sep));
|
|
370
|
+
|
|
371
|
+
// ---------- The summary being built ----------
|
|
372
|
+
|
|
373
|
+
const newSession = (tool, id) => ({ tool, id, agents: [], tools: {}, note: '' });
|
|
374
|
+
|
|
375
|
+
// Token fields stay null when the record does not hold them, so "unknown" never prints as 0.
|
|
376
|
+
const newAgent = name => ({
|
|
377
|
+
name, model: '', steps: 0, first: null, peak: null, sent: null, cached: null, output: null, toolCalls: 0, start: null, end: null,
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
const plus = (sum, n) => (sum ?? 0) + (n || 0);
|
|
381
|
+
|
|
382
|
+
// One model request. `context` is everything sent with it, `cached` the part read from the cache.
|
|
383
|
+
function addStep(agent, { context, cached, output }) {
|
|
384
|
+
agent.steps++;
|
|
385
|
+
agent.first ??= context;
|
|
386
|
+
agent.peak = Math.max(agent.peak ?? 0, context);
|
|
387
|
+
agent.sent = plus(agent.sent, context);
|
|
388
|
+
agent.cached = plus(agent.cached, cached);
|
|
389
|
+
agent.output = plus(agent.output, output);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function addToolCall(session, agent, name) {
|
|
393
|
+
agent.toolCalls++;
|
|
394
|
+
session.tools[name] = (session.tools[name] || 0) + 1;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Widens the agent's working period to include this record.
|
|
398
|
+
function touch(agent, timestamp) {
|
|
399
|
+
const time = Date.parse(timestamp);
|
|
400
|
+
if (Number.isNaN(time)) return;
|
|
401
|
+
agent.start = Math.min(agent.start ?? time, time);
|
|
402
|
+
agent.end = Math.max(agent.end ?? time, time);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Subagents are listed in the order they were started.
|
|
406
|
+
const byStart = (a, b) => (a.start ?? 0) - (b.start ?? 0);
|
|
407
|
+
|
|
408
|
+
// ---------- Claude Code ----------
|
|
409
|
+
// ~/.claude/projects/<working directory, non-alphanumerics as dashes>/<session>.jsonl, and next to
|
|
410
|
+
// it <session>/subagents/agent-<id>.jsonl with a .meta.json naming the agent type.
|
|
411
|
+
|
|
412
|
+
const claudeHome = () => process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
|
|
413
|
+
|
|
414
|
+
function claudeSessions(root) {
|
|
415
|
+
const sessions = [];
|
|
416
|
+
for (const form of rootForms(root)) {
|
|
417
|
+
const dir = join(claudeHome(), 'projects', form.replace(/[^A-Za-z0-9]/g, '-'));
|
|
418
|
+
if (!existsSync(dir)) continue;
|
|
419
|
+
for (const name of readdirSync(dir)) {
|
|
420
|
+
if (!name.endsWith('.jsonl')) continue;
|
|
421
|
+
const id = name.slice(0, -'.jsonl'.length);
|
|
422
|
+
const path = join(dir, name);
|
|
423
|
+
sessions.push({
|
|
424
|
+
tool: 'claude',
|
|
425
|
+
id,
|
|
426
|
+
modified: modifiedAt(path),
|
|
427
|
+
current: id === process.env.CLAUDE_CODE_SESSION_ID,
|
|
428
|
+
read: () => readClaude(id, path, join(dir, id, 'subagents')),
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return sessions;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function readClaudeAgent(session, name, path) {
|
|
436
|
+
const agent = newAgent(name);
|
|
437
|
+
// A reply is written as one line per content block. Each line repeats the reply's usage, and
|
|
438
|
+
// only the last one has the final output count, so the last line of a reply is the one kept.
|
|
439
|
+
const replies = new Map();
|
|
440
|
+
for (const record of jsonLines(path)) {
|
|
441
|
+
touch(agent, record.timestamp);
|
|
442
|
+
const message = record.type === 'assistant' && record.message;
|
|
443
|
+
if (!message) continue;
|
|
444
|
+
for (const block of message.content || []) {
|
|
445
|
+
if (block.type === 'tool_use') addToolCall(session, agent, block.name);
|
|
446
|
+
}
|
|
447
|
+
if (message.usage) replies.set(message.id, message);
|
|
448
|
+
}
|
|
449
|
+
for (const { model, usage } of replies.values()) {
|
|
450
|
+
agent.model = model || agent.model;
|
|
451
|
+
const cached = usage.cache_read_input_tokens || 0;
|
|
452
|
+
addStep(agent, {
|
|
453
|
+
context: (usage.input_tokens || 0) + cached + (usage.cache_creation_input_tokens || 0),
|
|
454
|
+
cached,
|
|
455
|
+
output: usage.output_tokens,
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
return agent;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function readClaude(id, path, subagentDir) {
|
|
462
|
+
const session = newSession('claude', id);
|
|
463
|
+
session.agents.push(readClaudeAgent(session, MAIN, path));
|
|
464
|
+
if (!existsSync(subagentDir)) return session;
|
|
465
|
+
const subagents = readdirSync(subagentDir)
|
|
466
|
+
.filter(name => name.endsWith('.jsonl'))
|
|
467
|
+
.map(name => {
|
|
468
|
+
const meta = jsonFile(join(subagentDir, name.replace(/\.jsonl$/, '.meta.json')));
|
|
469
|
+
return readClaudeAgent(session, meta.agentType || 'subagent', join(subagentDir, name));
|
|
470
|
+
});
|
|
471
|
+
session.agents.push(...subagents.sort(byStart));
|
|
472
|
+
return session;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// ---------- Codex ----------
|
|
476
|
+
// ~/.codex/sessions/<year>/<month>/<day>/rollout-*.jsonl. The first line is the session's meta:
|
|
477
|
+
// its working directory and, for a subagent, the session that spawned it and its role.
|
|
478
|
+
|
|
479
|
+
const codexHome = () => process.env.CODEX_HOME || join(homedir(), '.codex');
|
|
480
|
+
|
|
481
|
+
function rolloutFiles(dir, found = []) {
|
|
482
|
+
if (!existsSync(dir)) return found;
|
|
483
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
484
|
+
const path = join(dir, entry.name);
|
|
485
|
+
if (entry.isDirectory()) rolloutFiles(path, found);
|
|
486
|
+
else if (entry.name.endsWith('.jsonl')) found.push({ path, modified: modifiedAt(path) });
|
|
487
|
+
}
|
|
488
|
+
return found;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function codexSessions(root) {
|
|
492
|
+
const roots = rootForms(root);
|
|
493
|
+
const recent = rolloutFiles(join(codexHome(), 'sessions')).sort((a, b) => b.modified - a.modified).slice(0, CODEX_FILES_SCANNED);
|
|
494
|
+
const byId = new Map();
|
|
495
|
+
for (const { path, modified } of recent) {
|
|
496
|
+
const records = jsonLines(path);
|
|
497
|
+
const meta = records[0]?.type === 'session_meta' ? records[0].payload : null;
|
|
498
|
+
if (!meta || !isWithin(roots, meta.cwd)) continue;
|
|
499
|
+
const id = meta.session_id || meta.id;
|
|
500
|
+
if (!byId.has(id)) byId.set(id, { modified: 0, files: [] });
|
|
501
|
+
const group = byId.get(id);
|
|
502
|
+
group.modified = Math.max(group.modified, modified);
|
|
503
|
+
group.files.push({ meta, records });
|
|
504
|
+
}
|
|
505
|
+
return [...byId].map(([id, { modified, files }]) => ({ tool: 'codex', id, modified, current: false, read: () => readCodex(id, files) }));
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function readCodexAgent(session, name, records) {
|
|
509
|
+
const agent = newAgent(name);
|
|
510
|
+
let lastTotal = null;
|
|
511
|
+
for (const record of records) {
|
|
512
|
+
touch(agent, record.timestamp);
|
|
513
|
+
const payload = record.payload || {};
|
|
514
|
+
if (record.type === 'turn_context') agent.model = payload.model || agent.model;
|
|
515
|
+
if (record.type === 'response_item' && /_call$/.test(payload.type || '')) addToolCall(session, agent, payload.name || payload.type);
|
|
516
|
+
if (record.type !== 'event_msg' || payload.type !== 'token_count' || !payload.info) continue;
|
|
517
|
+
// The count is also repeated when only the rate limits change; a new request moves the total.
|
|
518
|
+
const total = payload.info.total_token_usage?.total_tokens;
|
|
519
|
+
if (total === lastTotal) continue;
|
|
520
|
+
lastTotal = total;
|
|
521
|
+
const last = payload.info.last_token_usage || {};
|
|
522
|
+
addStep(agent, { context: last.input_tokens || 0, cached: last.cached_input_tokens, output: last.output_tokens });
|
|
523
|
+
}
|
|
524
|
+
return agent;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function readCodex(id, files) {
|
|
528
|
+
const session = newSession('codex', id);
|
|
529
|
+
const subagents = [];
|
|
530
|
+
for (const { meta, records } of files) {
|
|
531
|
+
if (meta.thread_source === 'subagent') subagents.push(readCodexAgent(session, meta.agent_role || meta.agent_nickname || 'subagent', records));
|
|
532
|
+
else session.agents.push(readCodexAgent(session, MAIN, records));
|
|
533
|
+
}
|
|
534
|
+
session.agents.push(...subagents.sort(byStart));
|
|
535
|
+
return session;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// ---------- Copilot CLI ----------
|
|
539
|
+
// ~/.copilot/session-state/<session>/events.jsonl, with the working directory in workspace.yaml.
|
|
540
|
+
// Events of a subagent carry its agentId. Token counts are written only when the session closes.
|
|
541
|
+
|
|
542
|
+
const copilotHome = () => join(homedir(), '.copilot');
|
|
543
|
+
|
|
544
|
+
function copilotSessions(root) {
|
|
545
|
+
const roots = rootForms(root);
|
|
546
|
+
const base = join(copilotHome(), 'session-state');
|
|
547
|
+
if (!existsSync(base)) return [];
|
|
548
|
+
const sessions = [];
|
|
549
|
+
for (const id of readdirSync(base)) {
|
|
550
|
+
const events = join(base, id, 'events.jsonl');
|
|
551
|
+
const workspace = join(base, id, 'workspace.yaml');
|
|
552
|
+
if (!existsSync(events) || !existsSync(workspace)) continue;
|
|
553
|
+
const cwd = (readFileSync(workspace, 'utf8').match(/^cwd: (.*)$/m) || [])[1];
|
|
554
|
+
if (!isWithin(roots, cwd)) continue;
|
|
555
|
+
sessions.push({ tool: 'copilot', id, modified: modifiedAt(events), current: false, read: () => readCopilot(id, events) });
|
|
556
|
+
}
|
|
557
|
+
return sessions;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function readCopilot(id, path) {
|
|
561
|
+
const session = newSession('copilot', id);
|
|
562
|
+
const agents = new Map();
|
|
563
|
+
const agentOf = key => {
|
|
564
|
+
if (!agents.has(key)) agents.set(key, newAgent(key));
|
|
565
|
+
return agents.get(key);
|
|
566
|
+
};
|
|
567
|
+
agentOf(MAIN);
|
|
568
|
+
let closed = false;
|
|
569
|
+
for (const event of jsonLines(path)) {
|
|
570
|
+
const data = event.data || {};
|
|
571
|
+
const agent = agentOf(event.agentId || MAIN);
|
|
572
|
+
touch(agent, event.timestamp);
|
|
573
|
+
if (event.type === 'subagent.started') {
|
|
574
|
+
agent.name = data.agentName || agent.name;
|
|
575
|
+
} else if (event.type === 'assistant.message') {
|
|
576
|
+
agent.steps++;
|
|
577
|
+
agent.model = data.model || agent.model;
|
|
578
|
+
} else if (event.type === 'tool.execution_start') {
|
|
579
|
+
addToolCall(session, agent, data.toolName);
|
|
580
|
+
} else if (event.type === 'session.shutdown' && data.agentMetrics) {
|
|
581
|
+
// A resumed session closes more than once; each close reports the run that ended with it.
|
|
582
|
+
closed = true;
|
|
583
|
+
for (const [key, metrics] of Object.entries(data.agentMetrics)) {
|
|
584
|
+
const reported = agentOf(key);
|
|
585
|
+
for (const { usage = {} } of Object.values(metrics.modelMetrics || {})) {
|
|
586
|
+
reported.sent = plus(reported.sent, usage.inputTokens);
|
|
587
|
+
reported.cached = plus(reported.cached, usage.cacheReadTokens);
|
|
588
|
+
reported.output = plus(reported.output, usage.outputTokens);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
session.agents = [...agents.values()];
|
|
594
|
+
if (!closed) session.note = 'Copilot writes token counts when the session closes; until then /usage shows them.';
|
|
595
|
+
return session;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// ---------- Choosing the session ----------
|
|
599
|
+
|
|
600
|
+
const LISTERS = { claude: claudeSessions, codex: codexSessions, copilot: copilotSessions };
|
|
601
|
+
|
|
602
|
+
// The session to report: the one named by `id` (a prefix is enough), else the session this command
|
|
603
|
+
// runs in when the tool says which one that is, else the most recently written one.
|
|
604
|
+
export function sessionStats(root, { tool, id } = {}) {
|
|
605
|
+
let sessions = (tool ? [tool] : STATS_TOOLS).flatMap(name => LISTERS[name](root));
|
|
606
|
+
if (id) sessions = sessions.filter(session => session.id.startsWith(id));
|
|
607
|
+
sessions.sort((a, b) => b.modified - a.modified);
|
|
608
|
+
const chosen = (!id && sessions.find(session => session.current)) || sessions[0];
|
|
609
|
+
return chosen ? chosen.read() : null;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// ---------- Printing ----------
|
|
613
|
+
|
|
614
|
+
const COLUMNS = ['agent', 'model', 'steps', 'first', 'peak', 'sent', 'cached', 'output', 'tools', 'min'];
|
|
615
|
+
const TEXT_COLUMNS = 2; // agent and model align left; the numbers after them align right
|
|
616
|
+
const LEGEND = 'first, peak: tokens sent with one request. sent: that, summed over every step. cached: the share of sent read from the cache.';
|
|
617
|
+
|
|
618
|
+
function tokens(n) {
|
|
619
|
+
if (n == null) return '-';
|
|
620
|
+
if (n < 1000) return String(n);
|
|
621
|
+
if (n < 1e6) return `${Math.round(n / 1000)}k`;
|
|
622
|
+
return `${(n / 1e6).toFixed(1)}M`;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const cachedShare = agent => (agent.sent ? `${Math.round((100 * (agent.cached || 0)) / agent.sent)}%` : '-');
|
|
626
|
+
const minutes = (start, end) => Math.round((end - start) / 60000);
|
|
627
|
+
|
|
628
|
+
function clock(time) {
|
|
629
|
+
const d = new Date(time);
|
|
630
|
+
const two = n => String(n).padStart(2, '0');
|
|
631
|
+
return `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())} ${two(d.getHours())}:${two(d.getMinutes())}`;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// "2026-10-05 10:27 to 11:04, 37 min"; the end repeats the date only when it is another day.
|
|
635
|
+
function period(start, end) {
|
|
636
|
+
const [from, to] = [clock(start), clock(end)];
|
|
637
|
+
const sameDay = from.slice(0, 10) === to.slice(0, 10);
|
|
638
|
+
return `${from} to ${sameDay ? to.slice(11) : to}, ${minutes(start, end)} min`;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function sessionPeriod(agents) {
|
|
642
|
+
const timed = agents.filter(agent => agent.start != null);
|
|
643
|
+
if (!timed.length) return '';
|
|
644
|
+
return period(Math.min(...timed.map(agent => agent.start)), Math.max(...timed.map(agent => agent.end)));
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// The sum of the agents. It has no context of its own and no period, so those cells stay empty.
|
|
648
|
+
function totalOf(agents) {
|
|
649
|
+
const total = newAgent('total');
|
|
650
|
+
for (const agent of agents) {
|
|
651
|
+
total.steps += agent.steps;
|
|
652
|
+
total.toolCalls += agent.toolCalls;
|
|
653
|
+
for (const field of ['sent', 'cached', 'output']) {
|
|
654
|
+
if (agent[field] != null) total[field] = plus(total[field], agent[field]);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return total;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const cells = agent => [
|
|
661
|
+
agent.name, agent.model, String(agent.steps), tokens(agent.first), tokens(agent.peak), tokens(agent.sent), cachedShare(agent),
|
|
662
|
+
tokens(agent.output), String(agent.toolCalls), agent.start == null ? '' : String(minutes(agent.start, agent.end)),
|
|
663
|
+
];
|
|
664
|
+
|
|
665
|
+
function table(rows) {
|
|
666
|
+
const widths = COLUMNS.map((_, column) => Math.max(...rows.map(row => row[column].length)));
|
|
667
|
+
const pad = (cell, column) => (column < TEXT_COLUMNS ? cell.padEnd(widths[column]) : cell.padStart(widths[column]));
|
|
668
|
+
return rows.map(row => row.map(pad).join(' ').trimEnd());
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
export function formatStats(session) {
|
|
672
|
+
const { agents } = session;
|
|
673
|
+
const rows = [COLUMNS, ...agents.map(cells)];
|
|
674
|
+
if (agents.length > 1) rows.push(cells(totalOf(agents)));
|
|
675
|
+
const calls = Object.entries(session.tools).sort((a, b) => b[1] - a[1]).map(([name, count]) => `${name} ${count}`);
|
|
676
|
+
return [
|
|
677
|
+
['session', session.tool, session.id, sessionPeriod(agents)].filter(Boolean).join(' '),
|
|
678
|
+
...table(rows),
|
|
679
|
+
`tool calls ${calls.join(' ') || 'none'}`,
|
|
680
|
+
LEGEND,
|
|
681
|
+
...(session.note ? [session.note] : []),
|
|
682
|
+
].join('\n');
|
|
683
|
+
}
|
|
684
|
+
|
|
317
685
|
// Superwiki CLI. Lives in a project at docs/.sw/sw.mjs and prints short answers,
|
|
318
686
|
// so agents do not have to read the vault to get them.
|
|
319
|
-
import { spawn } from 'node:child_process';
|
|
320
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from 'node:fs';
|
|
321
|
-
import { createServer } from 'node:http';
|
|
322
|
-
import { basename, dirname, join, resolve as resolvePath } from 'node:path';
|
|
323
|
-
import { fileURLToPath } from 'node:url';
|
|
324
687
|
|
|
325
688
|
const HELP = `sw <command> [--docs <dir>] [--json]
|
|
326
689
|
|
|
@@ -331,6 +694,8 @@ const HELP = `sw <command> [--docs <dir>] [--json]
|
|
|
331
694
|
search <words> pages and log entries that mention the words, best match first
|
|
332
695
|
next-id <AREA> next free task id for an area (numbers are never reused)
|
|
333
696
|
lint structural checks; exit code 1 on errors
|
|
697
|
+
stats what the agent session here has cost so far: steps, context and tokens per agent
|
|
698
|
+
[--session <id>] another session of this project [--tool ${STATS_TOOLS.join('|')}]
|
|
334
699
|
serve [--open] start (or reuse) a local viewer at http://127.0.0.1:<port>/ that reads the files live
|
|
335
700
|
snapshot write docs/.sw/data.js so docs/viewer.html opens as a file, frozen at this moment`;
|
|
336
701
|
|
|
@@ -392,8 +757,8 @@ export function vaultData(docs) {
|
|
|
392
757
|
export const dataScript = data => `window.SW_DATA = ${JSON.stringify(data).replace(/</g, '\\u003c')};\n`;
|
|
393
758
|
|
|
394
759
|
// ---------- Commands ----------
|
|
395
|
-
// Each command gets { docs, vault, args, flags } and returns { data, text, code? }
|
|
396
|
-
// `data` is what --json prints; `text` is the default output.
|
|
760
|
+
// Each command gets { docs, vault, args, flags } and returns { data, text, code? } or
|
|
761
|
+
// { error, code? }. `data` is what --json prints; `text` is the default output.
|
|
397
762
|
|
|
398
763
|
function status({ vault }) {
|
|
399
764
|
const s = summary(vault);
|
|
@@ -436,12 +801,13 @@ function check(ctx) {
|
|
|
436
801
|
: `can start: n/a, status is ${t.status}${openDeps}`;
|
|
437
802
|
const draft = t.plan?.data.status === 'draft' ? ' (draft, not approved)' : '';
|
|
438
803
|
return {
|
|
439
|
-
data: { id: t.id, status: t.status, canStart, canFinish, openDeps: t.openDeps, openSoftDeps, plan },
|
|
804
|
+
data: { id: t.id, status: t.status, canStart, canFinish, openDeps: t.openDeps, openSoftDeps, plan, review: t.review || null },
|
|
440
805
|
text: [
|
|
441
806
|
`${t.id} ${t.status} ${t.title}`,
|
|
442
807
|
startLine,
|
|
443
808
|
`can finish: ${canFinish ? 'yes' : 'no'}${openSoftDeps.length ? ` open soft deps: ${openSoftDeps.join(', ')}` : ''}`,
|
|
444
809
|
`plan: ${plan ? plan + draft : 'none'}`,
|
|
810
|
+
`review: ${t.review ? `required (${t.review})` : 'not required'}`,
|
|
445
811
|
].join('\n'),
|
|
446
812
|
};
|
|
447
813
|
}
|
|
@@ -466,6 +832,7 @@ function explain(ctx) {
|
|
|
466
832
|
t.priority != null && `priority: ${t.priority}`,
|
|
467
833
|
t.started && `started: ${t.started}`,
|
|
468
834
|
t.finished && `finished: ${t.finished}`,
|
|
835
|
+
t.review && `review: ${t.review}`,
|
|
469
836
|
].filter(Boolean);
|
|
470
837
|
return {
|
|
471
838
|
data: {
|
|
@@ -553,6 +920,17 @@ function lintCommand({ vault }) {
|
|
|
553
920
|
};
|
|
554
921
|
}
|
|
555
922
|
|
|
923
|
+
// Agent sessions are recorded by the folder they ran in: the project root, which holds docs/.
|
|
924
|
+
function stats({ docs, flags }) {
|
|
925
|
+
if (flags.tool && !STATS_TOOLS.includes(flags.tool)) {
|
|
926
|
+
return { error: `usage: sw stats [--session <id>] [--tool ${STATS_TOOLS.join('|')}]` };
|
|
927
|
+
}
|
|
928
|
+
const root = dirname(docs);
|
|
929
|
+
const session = sessionStats(root, { tool: flags.tool, id: flags.session });
|
|
930
|
+
if (!session) return { error: `no ${flags.tool || 'agent'} session record found for ${root}`, code: 1 };
|
|
931
|
+
return { data: session, text: formatStats(session) };
|
|
932
|
+
}
|
|
933
|
+
|
|
556
934
|
function snapshot({ docs }) {
|
|
557
935
|
const data = vaultData(docs);
|
|
558
936
|
mkdirSync(join(docs, '.sw'), { recursive: true });
|
|
@@ -660,20 +1038,24 @@ const COMMANDS = {
|
|
|
660
1038
|
search: { run: searchCommand, needsVault: true },
|
|
661
1039
|
'next-id': { run: nextIdCommand, needsVault: true },
|
|
662
1040
|
lint: { run: lintCommand, needsVault: true },
|
|
1041
|
+
stats: { run: stats, needsVault: false },
|
|
663
1042
|
snapshot: { run: snapshot, needsVault: false },
|
|
664
1043
|
serve: { run: serve, needsVault: false },
|
|
665
1044
|
};
|
|
666
1045
|
|
|
1046
|
+
// Flags that are on or off, and flags that take the next argument as their value.
|
|
1047
|
+
const SWITCHES = ['json', 'open', 'foreground'];
|
|
1048
|
+
const OPTIONS = ['docs', 'tool', 'session'];
|
|
1049
|
+
|
|
1050
|
+
// Anything that is not a known flag is positional, so search words may start with dashes.
|
|
667
1051
|
function parseArgs(argv) {
|
|
668
|
-
const flags =
|
|
1052
|
+
const flags = Object.fromEntries([...SWITCHES.map(name => [name, false]), ...OPTIONS.map(name => [name, null])]);
|
|
669
1053
|
const positional = [];
|
|
670
1054
|
for (let i = 0; i < argv.length; i++) {
|
|
671
|
-
const
|
|
672
|
-
if (
|
|
673
|
-
else if (
|
|
674
|
-
else
|
|
675
|
-
else if (arg === '--foreground') flags.foreground = true;
|
|
676
|
-
else positional.push(arg);
|
|
1055
|
+
const name = argv[i].startsWith('--') ? argv[i].slice(2) : null;
|
|
1056
|
+
if (SWITCHES.includes(name)) flags[name] = true;
|
|
1057
|
+
else if (OPTIONS.includes(name)) flags[name] = argv[++i] ?? null;
|
|
1058
|
+
else positional.push(argv[i]);
|
|
677
1059
|
}
|
|
678
1060
|
return { command: positional[0], args: positional.slice(1), flags };
|
|
679
1061
|
}
|
|
@@ -7,6 +7,7 @@ deps: []
|
|
|
7
7
|
soft_deps: []
|
|
8
8
|
milestone:
|
|
9
9
|
priority:
|
|
10
|
+
review:
|
|
10
11
|
started:
|
|
11
12
|
finished:
|
|
12
13
|
---
|
|
@@ -29,5 +30,8 @@ What exists when this is done, and why it matters.
|
|
|
29
30
|
id: from `node docs/.sw/sw.mjs next-id <AREA>`; the file is docs/tasks/<id>.md.
|
|
30
31
|
status: todo | in-progress | done | cancelled. Cancelled tasks stay; ids are never reused.
|
|
31
32
|
deps: must be done before this starts. soft_deps: may start, cannot finish before them.
|
|
32
|
-
|
|
33
|
+
review: leave empty for no separate review. Any value (for example `required`, or the name of
|
|
34
|
+
the project's review class) makes sw-implement run a reviewer before the task can be done.
|
|
35
|
+
Keep this file short: steps go in docs/plans/<id>-plan.md, what happened goes in docs/log.md.
|
|
36
|
+
Delete this comment.
|
|
33
37
|
-->
|
|
@@ -494,6 +494,8 @@ function buildVault(files) {
|
|
|
494
494
|
deps: asList(d.deps).map(String), softDeps: asList(d.soft_deps).map(String),
|
|
495
495
|
milestone: d.milestone || '', priority: d.priority == null || d.priority === '' ? null : Number(d.priority),
|
|
496
496
|
started: d.started || '', finished: d.finished || '',
|
|
497
|
+
// Any value asks for a separate review before the task may be done; the value names the kind.
|
|
498
|
+
review: d.review ? String(d.review) : '',
|
|
497
499
|
state: null, wave: 0, dependents: [], plan: null,
|
|
498
500
|
});
|
|
499
501
|
}
|