diff --git a/packages/core/src/messaging/do-command.test.ts b/packages/core/src/messaging/do-command.test.ts index bde3184..99e0a0e 100644 --- a/packages/core/src/messaging/do-command.test.ts +++ b/packages/core/src/messaging/do-command.test.ts @@ -183,4 +183,46 @@ describe('makeDoCommandHandler — happy paths', () => { expect(conv?.closedAt).toBeNull(); t.dispose(); }); + + it('free-form args + project snapshot → uses snapshot project + args as goal', async () => { + const t = harness(); + seedActiveConversationWith(t, { sessionId: 'sess-1', projectId: PROJECT_A.id }); + const reply = await t.handler( + { name: 'do', scope: SCOPE, userId: 'u1', args: 'fix the panic in the login flow' }, + 'trace-1', + ); + expect(reply).toMatch(/Promoting to task/); + const cmd = t.orch.created[0]; + expect(cmd?.projectSlugOrPath).toBe(PROJECT_A.slug); + expect(cmd?.prompt).toBe('fix the panic in the login flow'); + expect(cmd?.branchRef).toBeUndefined(); + t.dispose(); + }); + + it('free-form args with trailing branch tail → snapshot project + branchRef extracted', async () => { + const t = harness(); + seedActiveConversationWith(t, { sessionId: 'sess-1', projectId: PROJECT_A.id }); + const reply = await t.handler( + { name: 'do', scope: SCOPE, userId: 'u1', args: 'rewrite the readme, branch off feat/x' }, + 'trace-1', + ); + expect(reply).toMatch(/Promoting to task/); + const cmd = t.orch.created[0]; + expect(cmd?.projectSlugOrPath).toBe(PROJECT_A.slug); + expect(cmd?.prompt).toBe('rewrite the readme'); + expect(cmd?.branchRef).toBe('feat/x'); + t.dispose(); + }); + + it('free-form args + no project snapshot → REPLY_DO_NO_PROJECT', async () => { + const t = harness(); + seedActiveConversationWith(t, { sessionId: 'sess-1' }); + const reply = await t.handler( + { name: 'do', scope: SCOPE, userId: 'u1', args: 'fix the panic' }, + 'trace-1', + ); + expect(reply).toBe(REPLY_DO_NO_PROJECT); + expect(t.orch.created).toHaveLength(0); + t.dispose(); + }); }); diff --git a/packages/core/src/messaging/do-command.ts b/packages/core/src/messaging/do-command.ts index 16bbeaa..0eb37c9 100644 --- a/packages/core/src/messaging/do-command.ts +++ b/packages/core/src/messaging/do-command.ts @@ -7,9 +7,11 @@ * 1. No active conversation in scope → "send a message first" (E2 sibling) * 2. Conversation has no `last_session_id` yet → "send a message first" (E2) * 3. RunCoordinator says a chat or task is in flight → "wait or /new" (E1, E4) - * 4. Args supplied (`/do work on X in Y`) but slug bad → "unknown project: " hard-reject (E9) + * 4. Args supplied as `work on in ` but slug bad → "unknown project: " hard-reject (E9) * 5. Args omitted: snapshot project_id; missing snapshot → "no project for this conversation" hint - * 6. Otherwise: build CreateTaskCommand whose orchestrator path resumes + * 6. Args supplied as free-form `/do ` → use snapshot project + args as goal. + * Same missing-snapshot hint as case 5. + * 7. Otherwise: build CreateTaskCommand whose orchestrator path resumes * `lastSessionId`. Conversation is NOT closed; chat lane lives on so the * user can continue brainstorming after the task lands. * @@ -21,7 +23,7 @@ import { truncate } from '../agent/format.js'; import type { ConversationsRepo } from '../datastore/repos/conversations.js'; import type { TaskOrchestrator } from '../orchestrator/orchestrator.js'; import type { CreateTaskCommand } from '../orchestrator/types.js'; -import { parseDiscordCommand } from '../parser/index.js'; +import { parseDiscordCommand, parseRoutedMessage } from '../parser/index.js'; import type { Project } from '../types/index.js'; import type { DoCommandHandler } from './dispatcher.js'; import type { RunCoordinator } from './run-coordinator.js'; @@ -36,6 +38,8 @@ export const REPLY_DO_RUN_IN_FLIGHT = 'A run is currently active on this conversation. Wait for it to finish or run `/new`.'; export const REPLY_DO_NO_PROJECT = 'This conversation has no project context. Use `/do work on in ` to specify one.'; +export const REPLY_DO_EMPTY_GOAL = + 'Could not parse `/do` arguments. Try: `/do ` or `/do work on in `.'; export interface DoCommandOpts { conversations: Pick; @@ -59,22 +63,33 @@ export function makeDoCommandHandler(opts: DoCommandOpts): DoCommandHandler { let goal: string; let branchRef: string | undefined; - if (args.length > 0) { - const parsed = parseDiscordCommand(args); - if (!parsed) { - return 'Could not parse `/do` arguments. Try: `/do work on in `.'; - } - const resolved = opts.resolveProject(parsed.projectHint); - if (!resolved) return `unknown project: ${parsed.projectHint}`; - project = resolved; - goal = parsed.goal; - branchRef = parsed.branchRef; - } else { + if (args.length === 0) { if (!conversation.projectId) return REPLY_DO_NO_PROJECT; const snapshot = opts.resolveProjectById(conversation.projectId); if (!snapshot) return `unknown project: ${conversation.projectId}`; project = snapshot; goal = RESUME_PROMPT; + } else { + // Try the strict `work on in ` grammar first — it lets the + // user explicitly override the conversation's snapshot project. + const explicit = parseDiscordCommand(args); + if (explicit) { + const resolved = opts.resolveProject(explicit.projectHint); + if (!resolved) return `unknown project: ${explicit.projectHint}`; + project = resolved; + goal = explicit.goal; + branchRef = explicit.branchRef; + } else { + // Free-form `/do ` — infer project from conversation snapshot. + if (!conversation.projectId) return REPLY_DO_NO_PROJECT; + const snapshot = opts.resolveProjectById(conversation.projectId); + if (!snapshot) return `unknown project: ${conversation.projectId}`; + const routed = parseRoutedMessage(args); + if (!routed) return REPLY_DO_EMPTY_GOAL; + project = snapshot; + goal = routed.goal; + branchRef = routed.branchRef; + } } const ctc: CreateTaskCommand = { diff --git a/packages/core/src/messaging/index.ts b/packages/core/src/messaging/index.ts index 752ee90..c855e73 100644 --- a/packages/core/src/messaging/index.ts +++ b/packages/core/src/messaging/index.ts @@ -23,6 +23,7 @@ export { export type { DoCommandOpts } from './do-command.js'; export { makeDoCommandHandler, + REPLY_DO_EMPTY_GOAL, REPLY_DO_NO_CONVERSATION, REPLY_DO_NO_PROJECT, REPLY_DO_NO_SESSION,