diff --git a/apps/cli/src/commands/scout.ts b/apps/cli/src/commands/scout.ts new file mode 100644 index 0000000..1684fc5 --- /dev/null +++ b/apps/cli/src/commands/scout.ts @@ -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 ') + .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 ') + .description('Archive a proposed scout task with a reason') + .requiredOption('--reason ', '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, + 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 { + 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; +} diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 33aae00..d198860 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -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'; @@ -77,6 +78,7 @@ export function createProgram(): Command { registerMcpCommand(program); registerBridgeCommand(program); registerSearchCommand(program); + registerScoutCommand(program); registerSidecarCommand(program); registerSuggestCommand(program); registerTaskCommand(program); diff --git a/apps/cli/test/program.test.ts b/apps/cli/test/program.test.ts index af105d7..5200c78 100644 --- a/apps/cli/test/program.test.ts +++ b/apps/cli/test/program.test.ts @@ -39,6 +39,7 @@ describe('Colony CLI program', () => { 'reindex', 'resume', 'rescue', + 'scout', 'search', 'sidecar', 'start', @@ -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 memory from the terminal + scout Review scout task proposals sidecar Manage optional runtime sidecars suggest [options] Suggest an approach from similar past task history task Task scheduling helpers diff --git a/apps/cli/test/scout-commands.test.ts b/apps/cli/test/scout-commands.test.ts new file mode 100644 index 0000000..6af49f0 --- /dev/null +++ b/apps/cli/test/scout-commands.test.ts @@ -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; +}