aiwg
Version:
Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo
348 lines (343 loc) • 14 kB
JavaScript
/**
* Activity Log CLI — `aiwg activity-log <subcommand>`
*
* Subcommands:
* show [--since DATE] [--operation OP] [--limit N]
* append <operation> "<summary>"
* stats
*
* Persists through `resolveStorage('activity_log')` per #964 — the
* physical destination is `.aiwg/activity.log` on the default `fs`
* backend, byte-identical to the legacy `echo >> .aiwg/activity.log`
* pattern documented in the activity-log skill.
*
* @design @.aiwg/architecture/storage-design.md (§4, §8.2)
* @issue #934
* @issue #964
*/
import { ACTIVITY_OPERATIONS, formatEntry, formatUtcTimestamp, isActivityOperation, } from './types.js';
import { parseLog, parseUtcDate } from './parser.js';
import { resolveStorage } from '../storage/index.js';
const LOG_PATH = 'activity.log';
const DEFAULT_LIMIT = 20;
export async function main(args) {
const subcommand = args[0];
const subArgs = args.slice(1);
switch (subcommand) {
case 'show':
await handleShow(subArgs);
break;
case 'append':
await handleAppend(subArgs);
break;
case 'stats':
await handleStats();
break;
case 'rotate':
await handleRotate(subArgs);
break;
default:
printUsage();
if (subcommand) {
throw new Error(`Unknown activity-log subcommand: ${subcommand}`);
}
}
}
async function handleShow(args) {
const opts = parseShowArgs(args);
const adapter = await resolveStorage('activity_log');
const entries = await readEntries(adapter);
let filtered = entries;
if (opts.since) {
const since = opts.since;
filtered = filtered.filter((e) => e.timestamp >= since);
}
if (opts.operation) {
filtered = filtered.filter((e) => e.operation === opts.operation);
}
// Newest first
filtered.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
if (filtered.length > opts.limit) {
filtered = filtered.slice(0, opts.limit);
}
if (filtered.length === 0) {
console.log('No activity log entries match the filters.');
return;
}
for (const entry of filtered) {
console.log(formatEntry(entry));
}
}
async function handleAppend(args) {
if (args.length < 2) {
throw new Error(`Usage: aiwg activity-log append <operation> "<summary>"\n` +
` Valid operations: ${ACTIVITY_OPERATIONS.join(', ')}`);
}
const op = args[0];
const summary = args.slice(1).join(' ').trim();
if (!isActivityOperation(op)) {
throw new Error(`Invalid operation "${op}". Valid operations: ${ACTIVITY_OPERATIONS.join(', ')}`);
}
if (summary.length === 0) {
throw new Error('Summary must be a non-empty string');
}
if (summary.length > 120) {
// Soft limit per the activity-log rule. Warn but don't refuse —
// the rule says "≤120 characters" but doesn't promise rejection.
console.warn(`warning: summary is ${summary.length} chars (rule recommends ≤120)`);
}
// #975: honor AIWG_SKIP_ACTIVITY_LOG=1 per the activity-log rule.
if (isActivityLogSkipped()) {
return;
}
const adapter = await resolveStorage('activity_log');
const newLine = formatEntry({ timestamp: new Date(), operation: op, summary });
// #976: prefer atomic append when the backend supports it. The fs
// backend uses fs.appendFile (O_APPEND) so concurrent appenders don't
// race with each other. Fall back to read-then-write for backends
// that don't expose append (e.g., Notion, AnythingLLM, Fortemi) —
// those backends have their own concurrency models anyway.
if (typeof adapter.append === 'function') {
// Append guarantees no read-modify-write race. We need to ensure the
// existing log ends with a newline before our line; do that with a
// single-byte preamble append when needed.
const existing = (await adapter.read(LOG_PATH)) ?? '';
if (existing.length > 0 && !existing.endsWith('\n')) {
await adapter.append(LOG_PATH, '\n');
}
await adapter.append(LOG_PATH, newLine + '\n');
}
else {
const existing = (await adapter.read(LOG_PATH)) ?? '';
const trailing = existing.length === 0 || existing.endsWith('\n') ? '' : '\n';
await adapter.write(LOG_PATH, existing + trailing + newLine + '\n');
}
console.log(`Entry appended to .aiwg/activity.log:\n ${newLine}`);
}
/**
* Returns true when AIWG_SKIP_ACTIVITY_LOG is set (per the activity-log
* rule's documented opt-out). Accepts "1", "true" (case-insensitive),
* or any non-empty value other than "0"/"false".
*/
function isActivityLogSkipped() {
const raw = process.env['AIWG_SKIP_ACTIVITY_LOG'];
if (!raw)
return false;
const v = raw.toLowerCase().trim();
if (v === '0' || v === 'false' || v === '')
return false;
return true;
}
async function handleStats() {
const adapter = await resolveStorage('activity_log');
const entries = await readEntries(adapter);
if (entries.length === 0) {
console.log('Activity log is empty.');
return;
}
const counts = new Map();
for (const op of ACTIVITY_OPERATIONS)
counts.set(op, 0);
for (const e of entries)
counts.set(e.operation, (counts.get(e.operation) ?? 0) + 1);
const sorted = [...entries].sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
const first = sorted[0].timestamp;
const last = sorted[sorted.length - 1].timestamp;
const days = Math.max(1, Math.ceil((last.getTime() - first.getTime()) / 86_400_000) + 1);
console.log(`Activity Log Statistics`);
console.log(`Log file: .aiwg/activity.log`);
console.log(`Date range: ${formatUtcTimestamp(first).slice(0, 10)} → ${formatUtcTimestamp(last).slice(0, 10)} (${days} day${days === 1 ? '' : 's'})`);
console.log(`Total entries: ${entries.length}`);
console.log('');
console.log(`By operation:`);
// Sort by count desc, then by op name asc for stability
const ranked = [...counts.entries()]
.filter(([, n]) => n > 0)
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
const maxCount = ranked[0]?.[1] ?? 1;
for (const [op, n] of ranked) {
const pct = Math.round((n / entries.length) * 100);
const barLen = Math.max(1, Math.round((n / maxCount) * 20));
const bar = '█'.repeat(barLen);
console.log(` ${op.padEnd(8)} ${String(n).padStart(3)} ${bar.padEnd(20)} ${pct}%`);
}
}
async function handleRotate(args) {
const opts = parseRotateArgs(args);
const adapter = await resolveStorage('activity_log');
const existing = (await adapter.read(LOG_PATH)) ?? '';
const entries = parseLog(existing);
if (entries.length === 0) {
throw new Error('Nothing to rotate: the activity log is empty (or contains no parseable entries).');
}
// Decide what stays inline vs what goes to archive
const sortedAsc = [...entries].sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
const { kept, archived } = splitForRotation(sortedAsc, opts.keepLast);
if (archived.length === 0) {
throw new Error(`Nothing to archive: --keep-last would retain all ${entries.length} entries inline.`);
}
// Determine archive destination (subsystem-relative)
const stamp = formatArchiveStamp(new Date());
const archivePath = opts.to ?? `archive/activity-log/${stamp}.log`;
// Refuse to overwrite an existing archive
const existingArchive = await adapter.read(archivePath);
if (existingArchive !== null) {
throw new Error(`Refusing to overwrite existing archive: ${archivePath}\n` +
` Either pass --to <other-path> or remove the existing archive first.`);
}
// Write archive (full historical content, oldest-first to match log order)
const archiveBody = archived.map((e) => formatEntry(e)).join('\n') + '\n';
await adapter.write(archivePath, archiveBody);
// Rewrite the live log with the retained tail
const liveBody = kept.length > 0 ? kept.map((e) => formatEntry(e)).join('\n') + '\n' : '';
await adapter.write(LOG_PATH, liveBody);
// Append a rotate-record entry to the live log (also exercises the
// append path so concurrent-write protection still applies).
const recordSummary = `activity-log rotated, archived ${archived.length} entries to ${archivePath}`;
if (typeof adapter.append === 'function') {
await adapter.append(LOG_PATH, formatEntry({ timestamp: new Date(), operation: 'archive', summary: recordSummary }) + '\n');
}
else {
const live = (await adapter.read(LOG_PATH)) ?? '';
const trailing = live.length === 0 || live.endsWith('\n') ? '' : '\n';
await adapter.write(LOG_PATH, live +
trailing +
formatEntry({ timestamp: new Date(), operation: 'archive', summary: recordSummary }) +
'\n');
}
console.log(`Rotated activity log:`);
console.log(` Archived ${archived.length} entr${archived.length === 1 ? 'y' : 'ies'} to ${archivePath}`);
console.log(` Retained ${kept.length} entr${kept.length === 1 ? 'y' : 'ies'} inline`);
}
function parseRotateArgs(args) {
const opts = {};
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === '--keep-last') {
const v = args[++i];
if (!v)
throw new Error(`--keep-last requires a value (e.g., 90d or 1000)`);
opts.keepLast = parseKeepLast(v);
}
else if (a === '--to') {
const v = args[++i];
if (!v)
throw new Error(`--to requires a path`);
opts.to = v;
}
else {
throw new Error(`Unknown flag: ${a}`);
}
}
return opts;
}
function parseKeepLast(raw) {
const dur = /^(\d+)d$/.exec(raw);
if (dur)
return { kind: 'duration_ms', ms: Number(dur[1]) * 86_400_000 };
const cnt = /^(\d+)$/.exec(raw);
if (cnt) {
const n = Number(cnt[1]);
if (n <= 0)
throw new Error(`--keep-last count must be positive (got ${raw})`);
return { kind: 'count', n };
}
throw new Error(`--keep-last must be <N>d or <N> (got ${JSON.stringify(raw)})`);
}
function splitForRotation(sortedAsc, keepLast) {
if (!keepLast) {
return { kept: [], archived: sortedAsc };
}
if (keepLast.kind === 'count') {
if (sortedAsc.length <= keepLast.n) {
return { kept: [...sortedAsc], archived: [] };
}
const cut = sortedAsc.length - keepLast.n;
return {
archived: sortedAsc.slice(0, cut),
kept: sortedAsc.slice(cut),
};
}
// duration_ms
const cutoff = Date.now() - keepLast.ms;
const kept = [];
const archived = [];
for (const e of sortedAsc) {
if (e.timestamp.getTime() >= cutoff)
kept.push(e);
else
archived.push(e);
}
return { kept, archived };
}
function formatArchiveStamp(d) {
// YYYYMMDD-HHMMSS UTC — sortable, unique to the second
const y = d.getUTCFullYear();
const mo = String(d.getUTCMonth() + 1).padStart(2, '0');
const da = String(d.getUTCDate()).padStart(2, '0');
const h = String(d.getUTCHours()).padStart(2, '0');
const mi = String(d.getUTCMinutes()).padStart(2, '0');
const s = String(d.getUTCSeconds()).padStart(2, '0');
return `${y}${mo}${da}-${h}${mi}${s}`;
}
// ── helpers ──────────────────────────────────────────────────────────
async function readEntries(adapter) {
const content = await adapter.read(LOG_PATH);
if (content === null)
return [];
return parseLog(content);
}
function parseShowArgs(args) {
const opts = { limit: DEFAULT_LIMIT };
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === '--since') {
const v = args[++i];
const d = parseUtcDate(v);
if (!d)
throw new Error(`--since must be YYYY-MM-DD (got ${JSON.stringify(v)})`);
opts.since = d;
}
else if (a === '--operation') {
const v = args[++i];
if (!isActivityOperation(v)) {
throw new Error(`--operation must be one of ${ACTIVITY_OPERATIONS.join(', ')} (got ${JSON.stringify(v)})`);
}
opts.operation = v;
}
else if (a === '--limit') {
const v = Number(args[++i]);
if (!Number.isFinite(v) || v <= 0) {
throw new Error(`--limit must be a positive integer`);
}
opts.limit = Math.floor(v);
}
else {
throw new Error(`Unknown flag: ${a}`);
}
}
return opts;
}
function printUsage() {
console.log(`Usage: aiwg activity-log <subcommand>
Subcommands:
show [--since YYYY-MM-DD] [--operation OP] [--limit N]
append <operation> "<summary>"
stats
rotate [--keep-last <Nd|N>] [--to <path>]
Operations: ${ACTIVITY_OPERATIONS.join(', ')}
Examples:
aiwg activity-log show
aiwg activity-log show --since 2026-04-01 --operation deploy
aiwg activity-log show --limit 5
aiwg activity-log append create ".aiwg/requirements/UC-007 created"
aiwg activity-log stats
aiwg activity-log rotate # archive everything
aiwg activity-log rotate --keep-last 90d # archive entries older than 90 days
aiwg activity-log rotate --keep-last 1000 # keep last 1000 entries inline
Environment:
AIWG_SKIP_ACTIVITY_LOG=1 suppress append (per the activity-log rule)
The log persists at .aiwg/activity.log on the default fs backend.
Configure .aiwg/storage.config to route to an external backend (#934).`);
}
//# sourceMappingURL=cli.js.map