Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 173 additions & 0 deletions apps/cli/src/commands/scout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { loadSettings } from '@colony/config';
import { TaskThread } from '@colony/core';
import type { Storage, TaskRow } from '@colony/storage';
import type { Command } from 'commander';
import kleur from 'kleur';
import { withStore } from '../util/store.js';

type ProposalStatus = 'proposed' | 'approved' | 'archived';

interface ScoutCommandOptions {
json?: boolean;
}

interface ScoutRejectOptions extends ScoutCommandOptions {
reason?: string;
}

interface SqlResult {
changes?: number;
}

interface SqlStatement {
all(...args: unknown[]): unknown[];
run(...args: unknown[]): SqlResult;
}

interface SqlDb {
prepare(sql: string): SqlStatement;
}

interface StorageWithDb {
db: SqlDb;
}

interface ProposedTaskRow extends TaskRow {
proposal_status: ProposalStatus;
}

export function registerScoutCommand(program: Command): void {
const group = program.command('scout').description('Review scout task proposals');

group
.command('list')
.description('List proposed scout work')
.option('--json', 'Emit JSON')
.action(async (opts: ScoutCommandOptions) => {
const settings = loadSettings();
await withStore(settings, (store) => {
const rows = listScoutProposals(store.storage);
if (opts.json === true) {
process.stdout.write(`${JSON.stringify(rows.map(proposalPayload), null, 2)}\n`);
return;
}
if (rows.length === 0) {
process.stdout.write(`${kleur.gray('no proposed scout work')}\n`);
return;
}
for (const row of rows) {
process.stdout.write(
`#${row.id} ${row.branch} by=${row.created_by} evidence=${evidenceCount(row)} age=${formatAge(Date.now() - row.created_at)}\n`,
);
}
});
});

group
.command('approve <task_id>')
.description('Approve a proposed scout task for executors')
.option('--json', 'Emit JSON')
.action(async (taskId: string, opts: ScoutCommandOptions) => {
const settings = loadSettings();
const id = parseTaskId(taskId);
await withStore(settings, (store) => {
const approvedBy = process.env.USER?.trim() || 'operator';
const approved = updateProposalStatus(store.storage, id, 'approved', approvedBy);
if (!approved) throw new Error(`proposed task not found: ${id}`);
const payload = { task_id: id, proposal_status: 'approved', approved_by: approvedBy };
process.stdout.write(
`${opts.json === true ? JSON.stringify(payload) : `${kleur.green('approved')} #${id} by=${approvedBy}`}\n`,
);
});
});

group
.command('reject <task_id>')
.description('Archive a proposed scout task with a reason')
.requiredOption('--reason <text>', 'Why the proposal was rejected')
.option('--json', 'Emit JSON')
.action(async (taskId: string, opts: ScoutRejectOptions) => {
const settings = loadSettings();
const id = parseTaskId(taskId);
const reason = opts.reason?.trim();
if (!reason) throw new Error('--reason is required');
await withStore(settings, (store) => {
const rejected = updateProposalStatus(store.storage, id, 'archived', null);
if (!rejected) throw new Error(`proposed task not found: ${id}`);
new TaskThread(store, id).post({
session_id: process.env.USER?.trim() || 'operator',
kind: 'note',
content: `scout proposal rejected: ${reason}`,
metadata: { scout_reject_reason: reason },
});
const payload = { task_id: id, proposal_status: 'archived', reason };
process.stdout.write(
`${opts.json === true ? JSON.stringify(payload) : `${kleur.yellow('rejected')} #${id} reason=${reason}`}\n`,
);
});
});
}

function listScoutProposals(storage: Storage): ProposedTaskRow[] {
return dbFor(storage)
.prepare("SELECT * FROM tasks WHERE proposal_status = 'proposed' ORDER BY created_at ASC")
.all() as ProposedTaskRow[];
}

function updateProposalStatus(
storage: Storage,
taskId: number,
status: Exclude<ProposalStatus, 'proposed'>,
approvedBy: string | null,
): boolean {
const result = dbFor(storage)
.prepare(
`UPDATE tasks
SET proposal_status = ?,
approved_by = ?,
updated_at = ?
WHERE id = ?
AND proposal_status = 'proposed'`,
)
.run(status, approvedBy, Date.now(), taskId);
return (result.changes ?? 0) > 0;
}

function dbFor(storage: Storage): SqlDb {
return (storage as unknown as StorageWithDb).db;
}

function proposalPayload(row: ProposedTaskRow): Record<string, unknown> {
return {
id: row.id,
branch: row.branch,
title: row.title,
by: row.created_by,
evidence: evidenceCount(row),
age_ms: Date.now() - row.created_at,
};
}

function evidenceCount(row: TaskRow): number {
if (!row.observation_evidence_ids) return 0;
try {
const parsed = JSON.parse(row.observation_evidence_ids) as unknown;
return Array.isArray(parsed) ? parsed.length : 0;
} catch {
return 0;
}
}

function formatAge(ms: number): string {
const minutes = Math.max(0, Math.floor(ms / 60_000));
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
if (hours < 48) return `${hours}h`;
return `${Math.floor(hours / 24)}d`;
}

function parseTaskId(value: string): number {
const id = Number(value);
if (!Number.isInteger(id) || id <= 0) throw new Error('task_id must be a positive integer');
return id;
}
2 changes: 2 additions & 0 deletions apps/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { registerReindexCommand } from './commands/reindex.js';
import { registerRescueCommand } from './commands/rescue.js';
import { registerResumeCommand } from './commands/resume.js';
import { registerSearchCommand } from './commands/search.js';
import { registerScoutCommand } from './commands/scout.js';
import { registerSidecarCommand } from './commands/sidecar.js';
import { registerStatusCommand } from './commands/status.js';
import { registerSuggestCommand } from './commands/suggest.js';
Expand Down Expand Up @@ -77,6 +78,7 @@ export function createProgram(): Command {
registerMcpCommand(program);
registerBridgeCommand(program);
registerSearchCommand(program);
registerScoutCommand(program);
registerSidecarCommand(program);
registerSuggestCommand(program);
registerTaskCommand(program);
Expand Down
2 changes: 2 additions & 0 deletions apps/cli/test/program.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ describe('Colony CLI program', () => {
'reindex',
'resume',
'rescue',
'scout',
'search',
'sidecar',
'start',
Expand Down Expand Up @@ -87,6 +88,7 @@ describe('Colony CLI program', () => {
mcp Run the MCP stdio server (typically invoked by the IDE)
bridge OMX/HUD bridge helpers for compact Colony status
search [options] <query> Query memory from the terminal
scout Review scout task proposals
sidecar Manage optional runtime sidecars
suggest [options] <description...> Suggest an approach from similar past task history
task Task scheduling helpers
Expand Down
142 changes: 142 additions & 0 deletions apps/cli/test/scout-commands.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { loadSettings } from '@colony/config';
import type { Storage } from '@colony/storage';
import kleur from 'kleur';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createProgram } from '../src/index.js';
import { withStore } from '../src/util/store.js';

const BASE_TS = Date.parse('2026-05-15T10:00:00.000Z');

let repoRoot: string;
let dataDir: string;
let output: string;
let originalColonyHome: string | undefined;

interface SqlResult {
changes?: number;
}

interface SqlStatement {
run(...args: unknown[]): SqlResult;
}

interface SqlDb {
prepare(sql: string): SqlStatement;
}

interface StorageWithDb {
db: SqlDb;
}

beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(BASE_TS);
kleur.enabled = false;
repoRoot = mkdtempSync(join(tmpdir(), 'colony-scout-cli-repo-'));
dataDir = mkdtempSync(join(tmpdir(), 'colony-scout-cli-data-'));
writeFileSync(join(repoRoot, 'SPEC.md'), '# SPEC\n', 'utf8');
originalColonyHome = process.env.COLONY_HOME;
process.env.COLONY_HOME = dataDir;
output = '';
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
output += String(chunk);
return true;
});
});

afterEach(() => {
vi.restoreAllMocks();
rmSync(repoRoot, { recursive: true, force: true });
rmSync(dataDir, { recursive: true, force: true });
if (originalColonyHome === undefined) delete process.env.COLONY_HOME;
else process.env.COLONY_HOME = originalColonyHome;
kleur.enabled = true;
vi.useRealTimers();
});

describe('colony scout', () => {
it('lists no proposed scout work on a fresh DB', async () => {
await createProgram().parseAsync(['node', 'test', 'scout', 'list'], { from: 'node' });

expect(output).toContain('no proposed scout work');
});

it('approves a proposed scout task', async () => {
const settings = loadSettings();
let taskId = 0;
await withStore(settings, (store) => {
taskId = seedProposal(store.storage, {
branch: 'scout/proposal-a',
createdBy: 'scout-a',
evidence: [100],
});
});

await createProgram().parseAsync(['node', 'test', 'scout', 'approve', String(taskId)], {
from: 'node',
});

expect(output).toContain(`approved #${taskId}`);
await withStore(settings, (store) => {
expect(store.storage.getTask(taskId)).toMatchObject({
proposal_status: 'approved',
approved_by: process.env.USER?.trim() || 'operator',
});
});
});

it('rejects a proposed scout task and records the reason', async () => {
const settings = loadSettings();
let taskId = 0;
await withStore(settings, (store) => {
taskId = seedProposal(store.storage, {
branch: 'scout/proposal-b',
createdBy: 'scout-b',
evidence: [100, 101],
});
});

await createProgram().parseAsync(
['node', 'test', 'scout', 'reject', String(taskId), '--reason', 'duplicate'],
{ from: 'node' },
);

expect(output).toContain(`rejected #${taskId}`);
await withStore(settings, (store) => {
expect(store.storage.getTask(taskId)).toMatchObject({ proposal_status: 'archived' });
expect(store.storage.taskTimeline(taskId, 10).map((row) => row.content)).toContain(
'scout proposal rejected: duplicate',
);
});
});
});

function seedProposal(
storage: Storage,
args: { branch: string; createdBy: string; evidence: number[] },
): number {
const task = storage.findOrCreateTask({
repo_root: repoRoot,
branch: args.branch,
title: args.branch,
created_by: args.createdBy,
});
dbFor(storage)
.prepare(
`UPDATE tasks
SET proposal_status = 'proposed',
observation_evidence_ids = ?,
created_at = ?,
updated_at = ?
WHERE id = ?`,
)
.run(JSON.stringify(args.evidence), BASE_TS - 60_000, BASE_TS - 60_000, task.id);
return task.id;
}

function dbFor(storage: Storage): SqlDb {
return (storage as unknown as StorageWithDb).db;
}
Loading