diff --git a/.changeset/foraging-cli-and-hook.md b/.changeset/foraging-cli-and-hook.md new file mode 100644 index 0000000..06e3160 --- /dev/null +++ b/.changeset/foraging-cli-and-hook.md @@ -0,0 +1,33 @@ +--- +"@imdeadpool/colony-cli": minor +"@colony/hooks": minor +--- + +Finish the foraging loop: users get a `colony foraging` command group +and SessionStart auto-scans in the background. + +CLI (`@imdeadpool/colony-cli`): + +- `colony foraging scan [--cwd ]` — synchronous scan of + `/examples` that re-indexes changed food sources and leaves + unchanged ones alone. Respects every field in `settings.foraging.*`. +- `colony foraging list [--cwd ]` — prints the cached example + rows (name, manifest kind, observation count, last-scanned date). +- `colony foraging clear [--example ] [--cwd ]` — drops + example rows and their foraged-pattern observations. + +Hooks (`@colony/hooks`): + +- `sessionStart` now detach-spawns `colony foraging scan --cwd ` + via `@colony/process#spawnNodeScript` when `settings.foraging.enabled` + and `scanOnSessionStart` are both true. The hook never waits on it — + the synchronous preface only surfaces state from previous scans, + keeping the 150 ms p95 budget intact. +- New `buildForagingPreface(store, input)` injects a compact + "## Examples indexed (foraging)" block when cached examples exist + for the current cwd: lists up to 5 example names with an overflow + count, and points agents at `examples_query` / + `examples_integrate_plan`. + +Closes the foraging roadmap: agents can now discover, query, and plan +integrations against `/examples` without a manual step. diff --git a/apps/cli/package.json b/apps/cli/package.json index 88c929a..7a3ba6a 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -64,6 +64,7 @@ "@colony/config": "workspace:*", "@colony/core": "workspace:*", "@colony/embedding": "workspace:*", + "@colony/foraging": "workspace:*", "@colony/hooks": "workspace:*", "@colony/installers": "workspace:*", "@colony/mcp-server": "workspace:*", diff --git a/apps/cli/src/commands/foraging.ts b/apps/cli/src/commands/foraging.ts new file mode 100644 index 0000000..0ca9fa1 --- /dev/null +++ b/apps/cli/src/commands/foraging.ts @@ -0,0 +1,122 @@ +import { join } from 'node:path'; +import { loadSettings, resolveDataDir } from '@colony/config'; +import { MemoryStore } from '@colony/core'; +import { scanExamples } from '@colony/foraging'; +import type { Command } from 'commander'; +import kleur from 'kleur'; + +const FORAGING_SESSION_ID = 'foraging'; + +/** + * The foraging session owns every `foraged-pattern` observation. It's a + * fixed session id (not per-invocation) so repeat scans land on the same + * row and session-wide cleanups ("drop everything foraging ever wrote") + * stay trivial. + */ +function ensureForagingSession(store: MemoryStore): void { + store.startSession({ + id: FORAGING_SESSION_ID, + ide: 'foraging', + cwd: process.cwd(), + }); +} + +export function registerForagingCommand(program: Command): void { + const group = program + .command('foraging') + .description('Index and query /examples food sources'); + + group + .command('scan') + .description('Scan /examples for changed food sources and re-index them') + .option('--cwd ', 'Repo root to scan (defaults to process.cwd())') + .action(async (opts: { cwd?: string }) => { + const settings = loadSettings(); + if (!settings.foraging.enabled) { + process.stdout.write(`${kleur.yellow('foraging disabled')} — set foraging.enabled true\n`); + return; + } + const repo_root = opts.cwd ?? process.cwd(); + const dbPath = join(resolveDataDir(settings.dataDir), 'data.db'); + const store = new MemoryStore({ dbPath, settings }); + try { + ensureForagingSession(store); + const result = scanExamples({ + repo_root, + store, + session_id: FORAGING_SESSION_ID, + limits: { + max_depth: settings.foraging.maxDepth, + max_file_bytes: settings.foraging.maxFileBytes, + max_files_per_source: settings.foraging.maxFilesPerSource, + }, + extra_secret_env_names: settings.foraging.extraSecretEnvNames, + }); + const changed = result.scanned.length - result.skipped_unchanged; + process.stdout.write( + `${kleur.green('✓')} foraging: ${result.scanned.length} source(s), ${changed} re-indexed, ${result.skipped_unchanged} skipped (unchanged), ${result.indexed_observations} observation(s)\n`, + ); + } finally { + store.close(); + } + }); + + group + .command('list') + .description('List indexed example food sources') + .option('--cwd ', 'Repo root to list (defaults to process.cwd())') + .action(async (opts: { cwd?: string }) => { + const settings = loadSettings(); + const repo_root = opts.cwd ?? process.cwd(); + const dbPath = join(resolveDataDir(settings.dataDir), 'data.db'); + const store = new MemoryStore({ dbPath, settings }); + try { + const rows = store.storage.listExamples(repo_root); + if (rows.length === 0) { + process.stdout.write( + `${kleur.gray('no indexed examples — run `colony foraging scan`')}\n`, + ); + return; + } + for (const r of rows) { + const when = new Date(r.last_scanned_at).toISOString().slice(0, 19).replace('T', ' '); + process.stdout.write( + ` ${kleur.cyan(r.example_name.padEnd(28))} ${kleur.dim((r.manifest_kind ?? 'unknown').padEnd(8))} ${r.observation_count} obs ${kleur.dim(when)}\n`, + ); + } + } finally { + store.close(); + } + }); + + group + .command('clear') + .description('Delete indexed example rows (and their foraged observations)') + .option('--cwd ', 'Repo root to clear (defaults to process.cwd())') + .option('--example ', 'Clear a single example rather than all of them') + .action(async (opts: { cwd?: string; example?: string }) => { + const settings = loadSettings(); + const repo_root = opts.cwd ?? process.cwd(); + const dbPath = join(resolveDataDir(settings.dataDir), 'data.db'); + const store = new MemoryStore({ dbPath, settings }); + try { + const targets = opts.example + ? store.storage.listExamples(repo_root).filter((r) => r.example_name === opts.example) + : store.storage.listExamples(repo_root); + if (targets.length === 0) { + process.stdout.write(`${kleur.gray('nothing to clear')}\n`); + return; + } + let dropped = 0; + for (const row of targets) { + dropped += store.storage.deleteForagedObservations(repo_root, row.example_name); + store.storage.deleteExample(repo_root, row.example_name); + } + process.stdout.write( + `${kleur.green('✓')} cleared ${targets.length} example(s), dropped ${dropped} observation(s)\n`, + ); + } finally { + store.close(); + } + }); +} diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index d8b9ad0..4ff02de 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -7,6 +7,7 @@ import { registerConfigCommand } from './commands/config.js'; import { registerDebriefCommand } from './commands/debrief.js'; import { registerDoctorCommand } from './commands/doctor.js'; import { registerExportCommand } from './commands/export.js'; +import { registerForagingCommand } from './commands/foraging.js'; import { registerHookCommand } from './commands/hook.js'; import { registerInboxCommand } from './commands/inbox.js'; import { registerInstallCommand } from './commands/install.js'; @@ -45,6 +46,7 @@ export function createProgram(): Command { registerObserveCommand(program); registerDebriefCommand(program); registerInboxCommand(program); + registerForagingCommand(program); return program; } diff --git a/apps/cli/test/program.test.ts b/apps/cli/test/program.test.ts index d5216a5..9b4cda4 100644 --- a/apps/cli/test/program.test.ts +++ b/apps/cli/test/program.test.ts @@ -12,6 +12,7 @@ describe('Colony CLI program', () => { 'doctor', 'expand', 'export', + 'foraging', 'hook', 'import', 'install', @@ -52,4 +53,16 @@ describe('Colony CLI program', () => { const program = createProgram(); expect(program.version()).toMatch(/^\d+\.\d+\.\d+$/); }); + + it('exposes foraging scan/list/clear subcommands', () => { + const program = createProgram(); + const foraging = program.commands.find((c) => c.name() === 'foraging'); + expect(foraging).toBeDefined(); + const subs = foraging?.commands.map((c) => c.name()).sort() ?? []; + expect(subs).toEqual(['clear', 'list', 'scan']); + const scan = foraging?.commands.find((c) => c.name() === 'scan'); + expect(scan?.options.find((o) => o.long === '--cwd')).toBeDefined(); + const clear = foraging?.commands.find((c) => c.name() === 'clear'); + expect(clear?.options.find((o) => o.long === '--example')).toBeDefined(); + }); }); diff --git a/packages/hooks/src/handlers/session-start.ts b/packages/hooks/src/handlers/session-start.ts index 86a6018..eb5e47f 100644 --- a/packages/hooks/src/handlers/session-start.ts +++ b/packages/hooks/src/handlers/session-start.ts @@ -1,4 +1,5 @@ import { type MemoryStore, ProposalSystem, TaskThread, detectRepoBranch } from '@colony/core'; +import { spawnNodeScript } from '@colony/process'; import type { HookInput } from '../types.js'; export async function sessionStart(store: MemoryStore, input: HookInput): Promise { @@ -10,11 +11,52 @@ export async function sessionStart(store: MemoryStore, input: HookInput): Promis cwd: input.cwd ?? null, }); + kickForagingScan(store, input); + const priorPreface = buildPriorPreface(store, input); const taskPreface = buildTaskPreface(store, input); const proposalPreface = buildProposalPreface(store, input); + const foragingPreface = buildForagingPreface(store, input); + + return [priorPreface, taskPreface, proposalPreface, foragingPreface].filter(Boolean).join('\n\n'); +} + +/** + * Detach-spawn the CLI's `foraging scan` so the scan runs in the + * background. The hook itself must not wait — the synchronous preface + * below only surfaces state from a *previous* scan. First SessionStart + * on a new repo therefore shows nothing foraging-related; second one + * shows the indexed set once the background scan has finished. + */ +function kickForagingScan(store: MemoryStore, input: HookInput): void { + const settings = store.settings; + if (!settings.foraging.enabled) return; + if (!settings.foraging.scanOnSessionStart) return; + const cwd = input.cwd; + if (!cwd) return; + const cli = process.argv[1]; + if (!cli) return; + try { + spawnNodeScript(cli, ['foraging', 'scan', '--cwd', cwd]); + } catch { + // Best-effort. Foraging is not load-bearing for the hook's primary job. + } +} - return [priorPreface, taskPreface, proposalPreface].filter(Boolean).join('\n\n'); +export function buildForagingPreface(store: MemoryStore, input: Pick): string { + if (!input.cwd) return ''; + const rows = store.storage.listExamples(input.cwd); + if (rows.length === 0) return ''; + const names = rows + .slice(0, 5) + .map((r) => r.example_name) + .join(', '); + const more = rows.length > 5 ? ` (+${rows.length - 5} more)` : ''; + return [ + '## Examples indexed (foraging)', + `${rows.length} food source${rows.length === 1 ? '' : 's'}: ${names}${more}.`, + 'Query with examples_query; fetch a plan with examples_integrate_plan.', + ].join('\n'); } function buildPriorPreface(store: MemoryStore, input: HookInput): string { diff --git a/packages/hooks/test/session-start-foraging.test.ts b/packages/hooks/test/session-start-foraging.test.ts new file mode 100644 index 0000000..5ede610 --- /dev/null +++ b/packages/hooks/test/session-start-foraging.test.ts @@ -0,0 +1,70 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { defaultSettings } from '@colony/config'; +import { MemoryStore } from '@colony/core'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { buildForagingPreface } from '../src/handlers/session-start.js'; + +let dir: string; +let store: MemoryStore; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'colony-forage-hook-')); + store = new MemoryStore({ dbPath: join(dir, 'data.db'), settings: defaultSettings }); +}); + +afterEach(() => { + store.close(); + rmSync(dir, { recursive: true, force: true }); +}); + +describe('buildForagingPreface', () => { + it('returns an empty string when there are no indexed sources', () => { + expect(buildForagingPreface(store, { cwd: '/some/repo' })).toBe(''); + }); + + it('returns an empty string when cwd is absent', () => { + store.storage.upsertExample({ + repo_root: '/some/repo', + example_name: 'foo', + content_hash: 'h', + manifest_kind: 'npm', + observation_count: 3, + }); + expect(buildForagingPreface(store, {})).toBe(''); + }); + + it('renders the indexed set with the examples_query hint', () => { + for (const name of ['stripe-webhook', 'next-auth-demo', 'hono-rest']) { + store.storage.upsertExample({ + repo_root: '/some/repo', + example_name: name, + content_hash: 'h', + manifest_kind: 'npm', + observation_count: 4, + }); + } + const preface = buildForagingPreface(store, { cwd: '/some/repo' }); + expect(preface).toMatch(/Examples indexed/); + expect(preface).toContain('3 food sources'); + expect(preface).toContain('stripe-webhook'); + expect(preface).toContain('hono-rest'); + expect(preface).toMatch(/examples_query/); + }); + + it('truncates to the first 5 example names and shows an overflow count', () => { + for (let i = 0; i < 9; i++) { + store.storage.upsertExample({ + repo_root: '/some/repo', + example_name: `ex-${String(i).padStart(2, '0')}`, + content_hash: 'h', + manifest_kind: 'npm', + observation_count: 1, + }); + } + const preface = buildForagingPreface(store, { cwd: '/some/repo' }); + expect(preface).toContain('9 food sources'); + expect(preface).toContain('(+4 more)'); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 15bde59..88762be 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -64,6 +64,9 @@ importers: '@colony/embedding': specifier: workspace:* version: link:../../packages/embedding + '@colony/foraging': + specifier: workspace:* + version: link:../../packages/foraging '@colony/hooks': specifier: workspace:* version: link:../../packages/hooks