diff --git a/.changeset/colony-session-cwd-binding.md b/.changeset/colony-session-cwd-binding.md new file mode 100644 index 0000000..0482952 --- /dev/null +++ b/.changeset/colony-session-cwd-binding.md @@ -0,0 +1,7 @@ +--- +"@cavemem/hooks": patch +"@cavemem/storage": patch +"cavemem": patch +--- + +Bind hook-created sessions back to their repository cwd so colony views can see live Codex/Claude work instead of orphan `cwd: null` sessions. diff --git a/apps/cli/src/commands/hook.ts b/apps/cli/src/commands/hook.ts index a1c9219..3cd62b1 100644 --- a/apps/cli/src/commands/hook.ts +++ b/apps/cli/src/commands/hook.ts @@ -35,10 +35,13 @@ export function registerHookCommand(program: Command): void { const hookName = name as HookName; const raw = await readStdin(); const parsed = raw.trim() ? safeJson(raw) : {}; + const sessionId = readString(parsed.session_id) ?? 'unknown'; + const ide = opts.ide ?? readString(parsed.ide) ?? inferIdeFromSessionId(sessionId); const input = { - session_id: typeof parsed.session_id === 'string' ? parsed.session_id : 'unknown', ...parsed, - ...(opts.ide ? { ide: opts.ide } : {}), + session_id: sessionId, + cwd: readString(parsed.cwd) ?? process.cwd(), + ...(ide ? { ide } : {}), } as Parameters[1]; const result = await runHook(hookName, input); @@ -83,6 +86,17 @@ function safeJson(s: string): Record { } } +function readString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value : undefined; +} + +function inferIdeFromSessionId(sessionId: string): string | undefined { + const prefix = sessionId.split('@')[0]?.toLowerCase(); + if (prefix === 'codex') return 'codex'; + if (prefix === 'claude' || prefix === 'claude-code') return 'claude-code'; + return undefined; +} + function readStdin(): Promise { return new Promise((resolve) => { if (process.stdin.isTTY) { diff --git a/apps/cli/test/program.test.ts b/apps/cli/test/program.test.ts index b1cf1c7..d5216a5 100644 --- a/apps/cli/test/program.test.ts +++ b/apps/cli/test/program.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { createProgram } from '../src/index.js'; -describe('cavemem CLI program', () => { +describe('Colony CLI program', () => { it('registers every top-level command', () => { const program = createProgram(); const names = program.commands.map((c) => c.name()).sort(); diff --git a/apps/mcp-server/test/server.test.ts b/apps/mcp-server/test/server.test.ts index 5233e79..82c6ad6 100644 --- a/apps/mcp-server/test/server.test.ts +++ b/apps/mcp-server/test/server.test.ts @@ -28,7 +28,7 @@ async function seed(): Promise<{ a: number; b: number }> { } beforeEach(async () => { - dir = mkdtempSync(join(tmpdir(), 'cavemem-mcp-')); + dir = mkdtempSync(join(tmpdir(), 'colony-mcp-')); store = new MemoryStore({ dbPath: join(dir, 'data.db'), settings: defaultSettings }); const server = buildServer(store, defaultSettings); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); diff --git a/apps/mcp-server/test/task-threads.test.ts b/apps/mcp-server/test/task-threads.test.ts index 5e16ecd..c629f30 100644 --- a/apps/mcp-server/test/task-threads.test.ts +++ b/apps/mcp-server/test/task-threads.test.ts @@ -42,7 +42,7 @@ function seedTwoSessionTask(): { task_id: number; sessionA: string; sessionB: st } beforeEach(async () => { - dir = mkdtempSync(join(tmpdir(), 'cavemem-task-threads-')); + dir = mkdtempSync(join(tmpdir(), 'colony-task-threads-')); store = new MemoryStore({ dbPath: join(dir, 'data.db'), settings: defaultSettings }); const server = buildServer(store, defaultSettings); const [clientT, serverT] = InMemoryTransport.createLinkedPair(); diff --git a/apps/worker/test/embed-loop.test.ts b/apps/worker/test/embed-loop.test.ts index efe2725..f04ab32 100644 --- a/apps/worker/test/embed-loop.test.ts +++ b/apps/worker/test/embed-loop.test.ts @@ -35,7 +35,7 @@ function mockEmbedder(model: string, dim: number): Embedder { } beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'cavemem-embed-')); + dir = mkdtempSync(join(tmpdir(), 'colony-embed-')); store = new MemoryStore({ dbPath: join(dir, 'data.db'), settings: buildSettings() }); }); diff --git a/apps/worker/test/server.test.ts b/apps/worker/test/server.test.ts index 51072f2..307c291 100644 --- a/apps/worker/test/server.test.ts +++ b/apps/worker/test/server.test.ts @@ -80,7 +80,7 @@ function seedFileLocks(repoRoot: string): void { } beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'cavemem-worker-')); + dir = mkdtempSync(join(tmpdir(), 'colony-worker-')); store = new MemoryStore({ dbPath: join(dir, 'data.db'), settings: defaultSettings }); app = buildApp(store); }); diff --git a/packages/core/test/memory-store-search.test.ts b/packages/core/test/memory-store-search.test.ts index a2397e7..fbd0079 100644 --- a/packages/core/test/memory-store-search.test.ts +++ b/packages/core/test/memory-store-search.test.ts @@ -32,7 +32,7 @@ let dir: string; let store: MemoryStore; beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'cavemem-core-')); + dir = mkdtempSync(join(tmpdir(), 'colony-core-')); store = new MemoryStore({ dbPath: join(dir, 'data.db'), settings: defaultSettings }); store.startSession({ id: 's1', ide: 'test', cwd: '/tmp' }); store.addObservation({ diff --git a/packages/core/test/pheromone.test.ts b/packages/core/test/pheromone.test.ts index 7948ed3..72887c2 100644 --- a/packages/core/test/pheromone.test.ts +++ b/packages/core/test/pheromone.test.ts @@ -24,7 +24,7 @@ function seedTwoSessionTask(): number { } beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'cavemem-pheromone-')); + dir = mkdtempSync(join(tmpdir(), 'colony-pheromone-')); store = new MemoryStore({ dbPath: join(dir, 'data.db'), settings: defaultSettings }); }); diff --git a/packages/core/test/proposal-system.test.ts b/packages/core/test/proposal-system.test.ts index 66883ce..6846d0f 100644 --- a/packages/core/test/proposal-system.test.ts +++ b/packages/core/test/proposal-system.test.ts @@ -16,7 +16,7 @@ function seed(...ids: string[]): void { } beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'cavemem-proposal-')); + dir = mkdtempSync(join(tmpdir(), 'colony-proposal-')); store = new MemoryStore({ dbPath: join(dir, 'data.db'), settings: defaultSettings }); }); @@ -106,7 +106,8 @@ describe('ProposalSystem.reinforce', () => { // The promoted task should exist on a synthetic branch so it doesn't // collide with the source branch's task via the (repo_root, branch) // UNIQUE constraint. - const task = store.storage.getTask(proposal!.task_id!); + if (!proposal?.task_id) throw new Error('expected promoted task id'); + const task = store.storage.getTask(proposal.task_id); expect(task?.branch).toBe(`b/proposal-${id}`); expect(task?.title).toBe('the real thing'); }); @@ -124,12 +125,12 @@ describe('ProposalSystem.reinforce', () => { }); proposals.reinforce({ proposal_id: id, session_id: 'B', kind: 'explicit' }); proposals.reinforce({ proposal_id: id, session_id: 'C', kind: 'explicit' }); - const first_task_id = store.storage.getProposal(id)!.task_id; + const first_task_id = store.storage.getProposal(id)?.task_id; expect(first_task_id).not.toBeNull(); const result = proposals.reinforce({ proposal_id: id, session_id: 'D', kind: 'explicit' }); expect(result.promoted).toBe(false); - expect(store.storage.getProposal(id)!.task_id).toBe(first_task_id); + expect(store.storage.getProposal(id)?.task_id).toBe(first_task_id); }); }); @@ -229,7 +230,7 @@ describe('ProposalSystem.foragingReport', () => { }); proposals.reinforce({ proposal_id: strong, session_id: 'B', kind: 'explicit' }); // Weak proposal: proposer only, strength ~1.0. - const weak = proposals.propose({ + proposals.propose({ repo_root: '/r', branch: 'b', summary: 'weak', diff --git a/packages/core/test/response-thresholds.test.ts b/packages/core/test/response-thresholds.test.ts index 9d69c97..e62671d 100644 --- a/packages/core/test/response-thresholds.test.ts +++ b/packages/core/test/response-thresholds.test.ts @@ -23,7 +23,7 @@ function seed(...ids: string[]): void { } beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'cavemem-thresholds-')); + dir = mkdtempSync(join(tmpdir(), 'colony-thresholds-')); store = new MemoryStore({ dbPath: join(dir, 'data.db'), settings: defaultSettings }); }); @@ -180,7 +180,7 @@ describe('TaskThread.handOff suggested_candidates integration', () => { expect(meta.suggested_candidates?.find((c) => c.agent === 'claude-sender')).toBeUndefined(); }); - it("leaves suggested_candidates undefined for directed handoffs", () => { + it('leaves suggested_candidates undefined for directed handoffs', () => { seed('claude-a', 'codex-a'); const thread = TaskThread.open(store, { repo_root: '/r', diff --git a/packages/core/test/task-thread.test.ts b/packages/core/test/task-thread.test.ts index a1f6eaf..74ec95a 100644 --- a/packages/core/test/task-thread.test.ts +++ b/packages/core/test/task-thread.test.ts @@ -16,7 +16,7 @@ function seed(...ids: string[]): void { } beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'cavemem-task-thread-')); + dir = mkdtempSync(join(tmpdir(), 'colony-task-thread-')); store = new MemoryStore({ dbPath: join(dir, 'data.db'), settings: defaultSettings }); }); diff --git a/packages/hooks/src/active-session.ts b/packages/hooks/src/active-session.ts new file mode 100644 index 0000000..00b8097 --- /dev/null +++ b/packages/hooks/src/active-session.ts @@ -0,0 +1,105 @@ +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { detectRepoBranch } from '@cavemem/core'; +import type { HookInput, HookName } from './types.js'; + +type ActiveSessionState = 'working' | 'thinking' | 'idle'; + +const ACTIVE_SESSIONS_RELATIVE_DIR = join('.omx', 'state', 'active-sessions'); +const PREVIEW_LIMIT = 180; + +export function upsertActiveSession(input: HookInput, hook: HookName): void { + const detected = detectFromInput(input); + if (!detected) return; + + const filePath = activeSessionFilePath(detected.repo_root, input.session_id); + const existing = readExisting(filePath); + const now = new Date().toISOString(); + const preview = taskPreview(input, hook); + const record = { + schemaVersion: 1, + repoRoot: detected.repo_root, + branch: detected.branch, + taskName: preview || existing?.taskName || 'Agent session', + latestTaskPreview: preview || existing?.latestTaskPreview || '', + agentName: agentName(input), + cliName: input.ide ?? agentName(input), + worktreePath: detected.repo_root, + taskMode: existing?.taskMode ?? '', + openspecTier: existing?.openspecTier ?? '', + taskRoutingReason: 'cavemem hook cwd binding', + startedAt: existing?.startedAt ?? now, + lastHeartbeatAt: now, + state: stateForHook(hook), + sessionKey: input.session_id, + }; + + mkdirSync(dirname(filePath), { recursive: true }); + writeFileSync(filePath, `${JSON.stringify(record, null, 2)}\n`, 'utf8'); +} + +export function removeActiveSession(input: HookInput): void { + const detected = detectFromInput(input); + if (!detected) return; + + const filePath = activeSessionFilePath(detected.repo_root, input.session_id); + if (existsSync(filePath)) unlinkSync(filePath); +} + +function detectFromInput(input: Pick) { + if (!input.cwd) return null; + return detectRepoBranch(input.cwd); +} + +function activeSessionFilePath(repoRoot: string, sessionId: string): string { + return join(repoRoot, ACTIVE_SESSIONS_RELATIVE_DIR, `${sanitize(sessionId)}.json`); +} + +function readExisting(filePath: string): Record | null { + try { + const parsed = JSON.parse(readFileSync(filePath, 'utf8')); + return parsed && typeof parsed === 'object' ? parsed : null; + } catch { + return null; + } +} + +function sanitize(value: string): string { + const cleaned = value.replace(/[^a-zA-Z0-9._-]+/g, '_').replace(/^_+|_+$/g, ''); + return cleaned || 'unknown-session'; +} + +function stateForHook(hook: HookName): ActiveSessionState { + if (hook === 'user-prompt-submit') return 'thinking'; + if (hook === 'stop') return 'idle'; + return 'working'; +} + +function agentName(input: Pick): string { + if (input.ide === 'claude-code') return 'claude'; + if (input.ide === 'codex') return 'codex'; + const prefix = input.session_id.split('@')[0]?.toLowerCase(); + if (prefix === 'claude' || prefix === 'claude-code') return 'claude'; + if (prefix === 'codex') return 'codex'; + return input.ide ?? 'agent'; +} + +function taskPreview(input: HookInput, hook: HookName): string { + const raw = + hook === 'user-prompt-submit' + ? input.prompt + : hook === 'post-tool-use' + ? `Tool: ${input.tool_name ?? input.tool ?? 'unknown'}` + : hook === 'stop' + ? (input.turn_summary ?? input.last_assistant_message) + : hook === 'session-end' + ? input.reason + : input.source + ? `Session start: ${input.source}` + : 'Session start'; + return typeof raw === 'string' ? oneLine(raw).slice(0, PREVIEW_LIMIT) : ''; +} + +function oneLine(value: string): string { + return value.replace(/\s+/g, ' ').trim(); +} diff --git a/packages/hooks/src/handlers/session-start.ts b/packages/hooks/src/handlers/session-start.ts index 50278b5..f159e97 100644 --- a/packages/hooks/src/handlers/session-start.ts +++ b/packages/hooks/src/handlers/session-start.ts @@ -95,6 +95,7 @@ export function buildTaskPreface( // accepting. Purely advisory — anyone eligible can still accept. if (h.meta.to_agent === 'any' && h.meta.suggested_candidates?.length) { const top = h.meta.suggested_candidates[0]; + if (!top) continue; const mine = h.meta.suggested_candidates.find((c) => c.agent === agent); const hints = [`top match: ${top.agent} (${top.score.toFixed(2)})`]; if (mine && mine.agent !== top.agent) { @@ -121,10 +122,7 @@ export function buildTaskPreface( * task_reinforce) or silently ignore. A quiet queue with zero pending * is the right UX — the preface stays empty and doesn't waste context. */ -export function buildProposalPreface( - store: MemoryStore, - input: Pick, -): string { +export function buildProposalPreface(store: MemoryStore, input: Pick): string { const cwd = input.cwd; if (!cwd) return ''; const detected = detectRepoBranch(cwd); diff --git a/packages/hooks/src/runner.ts b/packages/hooks/src/runner.ts index b5552e5..6c75ba5 100644 --- a/packages/hooks/src/runner.ts +++ b/packages/hooks/src/runner.ts @@ -1,10 +1,11 @@ import { join } from 'node:path'; import { loadSettings, resolveDataDir } from '@cavemem/config'; import { MemoryStore } from '@cavemem/core'; +import { removeActiveSession, upsertActiveSession } from './active-session.js'; import { ensureWorkerRunning } from './auto-spawn.js'; import { postToolUse } from './handlers/post-tool-use.js'; import { sessionEnd } from './handlers/session-end.js'; -import { sessionStart } from './handlers/session-start.js'; +import { buildProposalPreface, buildTaskPreface, sessionStart } from './handlers/session-start.js'; import { stop } from './handlers/stop.js'; import { userPromptSubmit } from './handlers/user-prompt-submit.js'; import type { HookInput, HookName, HookResult } from './types.js'; @@ -35,22 +36,35 @@ export async function runHook( store = new MemoryStore({ dbPath, settings }); } try { + let bootstrapContext = ''; + if (name !== 'session-start') { + materializeSession(store, input); + if (name !== 'session-end') { + bootstrapContext = ensureTaskBinding(store, input); + } + } + let context: string | undefined; switch (name) { case 'session-start': + upsertActiveSession(input, name); context = await sessionStart(store, input); break; case 'user-prompt-submit': - context = await userPromptSubmit(store, input); + upsertActiveSession(input, name); + context = joinContext(bootstrapContext, await userPromptSubmit(store, input)); break; case 'post-tool-use': + upsertActiveSession(input, name); await postToolUse(store, input); break; case 'stop': + upsertActiveSession(input, name); await stop(store, input); break; case 'session-end': await sessionEnd(store, input); + removeActiveSession(input); break; } // Fire-and-forget: ensure the worker is running so embeddings happen @@ -72,3 +86,31 @@ export async function runHook( if (!injected) store.close(); } } + +function materializeSession(store: MemoryStore, input: HookInput): void { + store.startSession({ + id: input.session_id, + ide: input.ide ?? inferIdeFromSessionId(input.session_id) ?? 'unknown', + cwd: input.cwd ?? null, + }); +} + +function ensureTaskBinding(store: MemoryStore, input: HookInput): string { + if (!input.cwd) return ''; + if (store.storage.findActiveTaskForSession(input.session_id) !== undefined) return ''; + return joinContext(buildTaskPreface(store, input), buildProposalPreface(store, input)); +} + +function inferIdeFromSessionId(sessionId: string): string | undefined { + const prefix = sessionId.split('@')[0]?.toLowerCase(); + if (prefix === 'codex') return 'codex'; + if (prefix === 'claude' || prefix === 'claude-code') return 'claude-code'; + return undefined; +} + +function joinContext(...parts: Array): string { + return parts + .map((p) => p?.trim()) + .filter(Boolean) + .join('\n\n'); +} diff --git a/packages/hooks/test/auto-claim.test.ts b/packages/hooks/test/auto-claim.test.ts index 20cfd1d..6825fc7 100644 --- a/packages/hooks/test/auto-claim.test.ts +++ b/packages/hooks/test/auto-claim.test.ts @@ -27,7 +27,7 @@ function seedTwoSessionTask(): number { } beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'cavemem-auto-claim-')); + dir = mkdtempSync(join(tmpdir(), 'colony-auto-claim-')); store = new MemoryStore({ dbPath: join(dir, 'data.db'), settings: defaultSettings }); }); diff --git a/packages/hooks/test/pheromone.test.ts b/packages/hooks/test/pheromone.test.ts index b65f0c2..af6308d 100644 --- a/packages/hooks/test/pheromone.test.ts +++ b/packages/hooks/test/pheromone.test.ts @@ -25,7 +25,7 @@ function seedTwoSessionTask(): number { } beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'cavemem-pheromone-hooks-')); + dir = mkdtempSync(join(tmpdir(), 'colony-pheromone-hooks-')); store = new MemoryStore({ dbPath: join(dir, 'data.db'), settings: defaultSettings }); }); @@ -94,7 +94,7 @@ describe('buildPheromoneConflictPreface', () => { expect(preface).toContain('pheromone'); }); - it("does not warn about files I have never touched", () => { + it('does not warn about files I have never touched', () => { seedTwoSessionTask(); // B edits solo.ts; A never touches it. depositPheromoneFromToolUse(store, { @@ -115,15 +115,17 @@ describe('buildPheromoneConflictPreface', () => { seedTwoSessionTask(); // One-deposit trail from B is exactly at the 1.0 threshold — overlap // must fire. But an artificially weaker trail must not. + const taskId = store.storage.findActiveTaskForSession('A'); + if (taskId === undefined) throw new Error('expected active task'); store.storage.upsertPheromone({ - task_id: store.storage.findActiveTaskForSession('A')!, + task_id: taskId, file_path: 'weak.ts', session_id: 'B', strength: 0.3, deposited_at: Date.now(), }); store.storage.upsertPheromone({ - task_id: store.storage.findActiveTaskForSession('A')!, + task_id: taskId, file_path: 'weak.ts', session_id: 'A', strength: 0.3, diff --git a/packages/hooks/test/proposal-system.test.ts b/packages/hooks/test/proposal-system.test.ts index dd36bca..9cb909a 100644 --- a/packages/hooks/test/proposal-system.test.ts +++ b/packages/hooks/test/proposal-system.test.ts @@ -23,7 +23,7 @@ function seedTwoSessionTask(): { task_id: number; repo_root: string; branch: str } beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'cavemem-proposal-hooks-')); + dir = mkdtempSync(join(tmpdir(), 'colony-proposal-hooks-')); store = new MemoryStore({ dbPath: join(dir, 'data.db'), settings: defaultSettings }); }); diff --git a/packages/hooks/test/runner.test.ts b/packages/hooks/test/runner.test.ts index 95a7a87..65e6738 100644 --- a/packages/hooks/test/runner.test.ts +++ b/packages/hooks/test/runner.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { defaultSettings } from '@cavemem/config'; @@ -10,7 +10,7 @@ let dir: string; let store: MemoryStore; beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'cavemem-hooks-')); + dir = mkdtempSync(join(tmpdir(), 'colony-hooks-')); store = new MemoryStore({ dbPath: join(dir, 'data.db'), settings: defaultSettings }); }); @@ -158,6 +158,47 @@ describe('runHook', () => { expect(store.timeline('orphan')).toHaveLength(2); }); + it('repo-binds downstream hooks when SessionStart never fired but cwd is known', async () => { + const repo = join(dir, 'repo'); + mkdirSync(join(repo, '.git'), { recursive: true }); + writeFileSync(join(repo, '.git', 'HEAD'), 'ref: refs/heads/agent/codex/live\n', 'utf8'); + + const r = await runHook( + 'user-prompt-submit', + { + session_id: 'codex@late', + ide: 'codex', + cwd: repo, + prompt: 'fix colony cwd registration', + }, + { store }, + ); + + expect(r.ok).toBe(true); + expect(store.storage.getSession('codex@late')).toMatchObject({ + ide: 'codex', + cwd: repo, + }); + expect(store.storage.listTasks(5)[0]).toMatchObject({ + repo_root: repo, + branch: 'agent/codex/live', + }); + + const sessionFile = join(repo, '.omx', 'state', 'active-sessions', 'codex_late.json'); + expect(existsSync(sessionFile)).toBe(true); + const active = JSON.parse(readFileSync(sessionFile, 'utf8')) as Record; + expect(active).toMatchObject({ + repoRoot: repo, + branch: 'agent/codex/live', + agentName: 'codex', + cliName: 'codex', + worktreePath: repo, + latestTaskPreview: 'fix colony cwd registration', + state: 'thinking', + sessionKey: 'codex@late', + }); + }); + it('post-tool-use accepts Claude Code field names (tool_name, tool_response)', async () => { await runHook( 'session-start', diff --git a/packages/hooks/test/task-injection.test.ts b/packages/hooks/test/task-injection.test.ts index 95afbf7..58d281d 100644 --- a/packages/hooks/test/task-injection.test.ts +++ b/packages/hooks/test/task-injection.test.ts @@ -25,7 +25,7 @@ function fakeGitCheckout(path: string, branch: string): void { } beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'cavemem-hook-inject-')); + dir = mkdtempSync(join(tmpdir(), 'colony-hook-inject-')); store = new MemoryStore({ dbPath: join(dir, 'data.db'), settings: defaultSettings }); repo = join(dir, 'repo'); mkdirSync(repo, { recursive: true }); diff --git a/packages/installers/test/installers.test.ts b/packages/installers/test/installers.test.ts index 0e56c6c..029ff90 100644 --- a/packages/installers/test/installers.test.ts +++ b/packages/installers/test/installers.test.ts @@ -13,7 +13,7 @@ let originalHome: string | undefined; let ctx: InstallContext; beforeEach(() => { - home = mkdtempSync(join(tmpdir(), 'cavemem-ins-')); + home = mkdtempSync(join(tmpdir(), 'colony-ins-')); originalHome = process.env.HOME; process.env.HOME = home; ctx = { diff --git a/packages/storage/src/storage.ts b/packages/storage/src/storage.ts index 58e1a68..5a2309e 100644 --- a/packages/storage/src/storage.ts +++ b/packages/storage/src/storage.ts @@ -61,11 +61,27 @@ export class Storage { // --- sessions --- createSession(s: Omit): void { - // INSERT OR IGNORE: SessionStart re-fires on resume/clear/compact with the - // same session_id, and we want the original row (and ended_at=null) preserved. + // SessionStart re-fires on resume/clear/compact with the same session_id, + // so preserve the original started_at / ended_at values. If a downstream + // hook had to create an orphan row first, later richer hook payloads can + // still fill in the IDE and cwd so colony/task binding is not lost. this.db .prepare( - 'INSERT OR IGNORE INTO sessions(id, ide, cwd, started_at, metadata) VALUES (?, ?, ?, ?, ?)', + `INSERT INTO sessions(id, ide, cwd, started_at, metadata) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + ide = CASE + WHEN sessions.ide = 'unknown' AND excluded.ide != 'unknown' THEN excluded.ide + ELSE sessions.ide + END, + cwd = CASE + WHEN sessions.cwd IS NULL AND excluded.cwd IS NOT NULL THEN excluded.cwd + ELSE sessions.cwd + END, + metadata = CASE + WHEN sessions.metadata IS NULL AND excluded.metadata IS NOT NULL THEN excluded.metadata + ELSE sessions.metadata + END`, ) .run(s.id, s.ide, s.cwd, s.started_at, s.metadata); } @@ -461,15 +477,9 @@ export class Storage { } /** One pheromone row for (task, file, session) or undefined. */ - getPheromone( - task_id: number, - file_path: string, - session_id: string, - ): PheromoneRow | undefined { + getPheromone(task_id: number, file_path: string, session_id: string): PheromoneRow | undefined { return this.db - .prepare( - 'SELECT * FROM pheromones WHERE task_id = ? AND file_path = ? AND session_id = ?', - ) + .prepare('SELECT * FROM pheromones WHERE task_id = ? AND file_path = ? AND session_id = ?') .get(task_id, file_path, session_id) as PheromoneRow | undefined; } @@ -538,9 +548,7 @@ export class Storage { task_id: patch.task_id === undefined ? current.task_id : patch.task_id, }; this.db - .prepare( - 'UPDATE proposals SET status = ?, promoted_at = ?, task_id = ? WHERE id = ?', - ) + .prepare('UPDATE proposals SET status = ?, promoted_at = ?, task_id = ? WHERE id = ?') .run(next.status, next.promoted_at, next.task_id, id); } @@ -589,9 +597,9 @@ export class Storage { } getAgentProfile(agent: string): AgentProfileRow | undefined { - return this.db - .prepare('SELECT * FROM agent_profiles WHERE agent = ?') - .get(agent) as AgentProfileRow | undefined; + return this.db.prepare('SELECT * FROM agent_profiles WHERE agent = ?').get(agent) as + | AgentProfileRow + | undefined; } listAgentProfiles(): AgentProfileRow[] { diff --git a/packages/storage/test/agent-profiles.test.ts b/packages/storage/test/agent-profiles.test.ts index a294f5f..d1ad82e 100644 --- a/packages/storage/test/agent-profiles.test.ts +++ b/packages/storage/test/agent-profiles.test.ts @@ -8,7 +8,7 @@ let dir: string; let storage: Storage; beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'cavemem-agent-profiles-')); + dir = mkdtempSync(join(tmpdir(), 'colony-agent-profiles-')); storage = new Storage(join(dir, 'test.db')); }); @@ -44,7 +44,8 @@ describe('agent profiles storage', () => { updated_at: 2_000, }); const row = storage.getAgentProfile('codex'); - expect(JSON.parse(row!.capabilities)).toEqual({ api_work: 0.9, infra_work: 0.8 }); + if (!row) throw new Error('expected codex profile'); + expect(JSON.parse(row.capabilities)).toEqual({ api_work: 0.9, infra_work: 0.8 }); expect(row?.updated_at).toBe(2_000); }); diff --git a/packages/storage/test/pheromones.test.ts b/packages/storage/test/pheromones.test.ts index 8dd9232..02eb1d0 100644 --- a/packages/storage/test/pheromones.test.ts +++ b/packages/storage/test/pheromones.test.ts @@ -30,7 +30,7 @@ function seedTask(created_by: string): number { } beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'cavemem-pheromones-')); + dir = mkdtempSync(join(tmpdir(), 'colony-pheromones-')); storage = new Storage(join(dir, 'test.db')); }); diff --git a/packages/storage/test/proposals.test.ts b/packages/storage/test/proposals.test.ts index 6b2133a..9775e7e 100644 --- a/packages/storage/test/proposals.test.ts +++ b/packages/storage/test/proposals.test.ts @@ -20,7 +20,7 @@ function seed(...ids: string[]): void { } beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'cavemem-proposals-')); + dir = mkdtempSync(join(tmpdir(), 'colony-proposals-')); storage = new Storage(join(dir, 'test.db')); }); @@ -54,7 +54,8 @@ describe('proposals storage', () => { promoted_at: null, task_id: null, }); - expect(JSON.parse(row!.touches_files)).toEqual(['src/core.ts', 'src/ranker.ts']); + if (!row) throw new Error('expected proposal row'); + expect(JSON.parse(row.touches_files)).toEqual(['src/core.ts', 'src/ranker.ts']); }); it('updateProposal applies partial patch and leaves other fields intact', () => { diff --git a/packages/storage/test/storage.test.ts b/packages/storage/test/storage.test.ts index 16e201b..06fda68 100644 --- a/packages/storage/test/storage.test.ts +++ b/packages/storage/test/storage.test.ts @@ -8,7 +8,7 @@ let dir: string; let storage: Storage; beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'cavemem-')); + dir = mkdtempSync(join(tmpdir(), 'colony-')); storage = new Storage(join(dir, 'test.db')); }); @@ -18,6 +18,56 @@ afterEach(() => { }); describe('Storage', () => { + it('fills orphan session cwd and ide when richer hook payload arrives', () => { + storage.createSession({ + id: 'codex@abc', + ide: 'unknown', + cwd: null, + started_at: 100, + metadata: null, + }); + storage.createSession({ + id: 'codex@abc', + ide: 'codex', + cwd: '/repo', + started_at: 200, + metadata: JSON.stringify({ source: 'hook' }), + }); + + const row = storage.getSession('codex@abc'); + expect(row).toMatchObject({ + id: 'codex@abc', + ide: 'codex', + cwd: '/repo', + started_at: 100, + ended_at: null, + }); + expect(row?.metadata).toBe(JSON.stringify({ source: 'hook' })); + }); + + it('preserves known session cwd and ide on weaker duplicate payloads', () => { + storage.createSession({ + id: 'codex@known', + ide: 'codex', + cwd: '/repo', + started_at: 100, + metadata: null, + }); + storage.createSession({ + id: 'codex@known', + ide: 'unknown', + cwd: null, + started_at: 200, + metadata: null, + }); + + expect(storage.getSession('codex@known')).toMatchObject({ + ide: 'codex', + cwd: '/repo', + started_at: 100, + }); + }); + it('stores and retrieves observations', () => { storage.createSession({ id: 'sess-1', diff --git a/packages/storage/test/tasks.test.ts b/packages/storage/test/tasks.test.ts index 165ed18..af646fe 100644 --- a/packages/storage/test/tasks.test.ts +++ b/packages/storage/test/tasks.test.ts @@ -20,7 +20,7 @@ function seedSessions(...ids: string[]): void { } beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'cavemem-tasks-')); + dir = mkdtempSync(join(tmpdir(), 'colony-tasks-')); storage = new Storage(join(dir, 'test.db')); });