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
33 changes: 33 additions & 0 deletions .changeset/foraging-cli-and-hook.md
Original file line number Diff line number Diff line change
@@ -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 <path>]` — synchronous scan of
`<cwd>/examples` that re-indexes changed food sources and leaves
unchanged ones alone. Respects every field in `settings.foraging.*`.
- `colony foraging list [--cwd <path>]` — prints the cached example
rows (name, manifest kind, observation count, last-scanned date).
- `colony foraging clear [--example <name>] [--cwd <path>]` — drops
example rows and their foraged-pattern observations.

Hooks (`@colony/hooks`):

- `sessionStart` now detach-spawns `colony foraging scan --cwd <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 `<repo_root>/examples` without a manual step.
1 change: 1 addition & 0 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
122 changes: 122 additions & 0 deletions apps/cli/src/commands/foraging.ts
Original file line number Diff line number Diff line change
@@ -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 <repo_root>/examples food sources');

group
.command('scan')
.description('Scan <cwd>/examples for changed food sources and re-index them')
.option('--cwd <path>', '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 <path>', '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 <path>', 'Repo root to clear (defaults to process.cwd())')
.option('--example <name>', '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();
}
});
}
2 changes: 2 additions & 0 deletions apps/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -45,6 +46,7 @@ export function createProgram(): Command {
registerObserveCommand(program);
registerDebriefCommand(program);
registerInboxCommand(program);
registerForagingCommand(program);

return program;
}
Expand Down
13 changes: 13 additions & 0 deletions apps/cli/test/program.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ describe('Colony CLI program', () => {
'doctor',
'expand',
'export',
'foraging',
'hook',
'import',
'install',
Expand Down Expand Up @@ -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();
});
});
44 changes: 43 additions & 1 deletion packages/hooks/src/handlers/session-start.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
Expand All @@ -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<HookInput, 'cwd'>): 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 {
Expand Down
70 changes: 70 additions & 0 deletions packages/hooks/test/session-start-foraging.test.ts
Original file line number Diff line number Diff line change
@@ -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)');
});
});
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading