From 9e359627f03ec65356b6169120402ec3d814dbae Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Wed, 22 Jul 2026 08:59:26 +0530 Subject: [PATCH 1/2] Harden Defty project target resolution --- apps/api/src/lib/mcp-tools/human.ts | 46 ++-- apps/api/src/lib/resolve-project-target.ts | 225 +++++++++++++++++++ apps/api/src/workers/handlers/agent-reply.ts | 78 ++----- apps/api/test/resolve-project-target.test.ts | 104 +++++++++ 4 files changed, 384 insertions(+), 69 deletions(-) create mode 100644 apps/api/src/lib/resolve-project-target.ts create mode 100644 apps/api/test/resolve-project-target.test.ts diff --git a/apps/api/src/lib/mcp-tools/human.ts b/apps/api/src/lib/mcp-tools/human.ts index d6c5094..4191cc7 100644 --- a/apps/api/src/lib/mcp-tools/human.ts +++ b/apps/api/src/lib/mcp-tools/human.ts @@ -38,6 +38,7 @@ import { bulkUpdateTasks, BulkTaskUpdateError, bulkTaskUpdateSchema } from '../t import { queryCompactTasks, type CompactTaskQuery } from '../task-compact-query.js'; import { visibleNoteCondition } from '../note-visibility.js'; import { approveAction, rejectAction } from '../agent-approval-resolver.js'; +import { resolveProjectTargetFromRows } from '../resolve-project-target.js'; import { ensureAttentionBackfillForUser, filterVisibleAttentionItems, @@ -746,14 +747,17 @@ async function resolveProjectForHumanTask( ORDER BY updated_at DESC LIMIT 200 `); - const projectsRows = ((rows as any).rows ?? []) as Array<{ id: string; name: string; prefix: string | null }>; - const ranked = rankResolverCandidates(lookupQuery, projectsRows, (row) => [ - { value: row.name, reason: 'name' }, - { value: row.prefix, reason: 'prefix', weight: 0.98 }, - ]); - const response = buildResolverResponse(lookupQuery, ranked, { limit: 5, resolvedThreshold: 0.76 }); - if (!response.selected) return { project: null, error: resolverError('task_create project', query, response) }; - return { project: response.selected }; + const projectsRows = (((rows as any).rows ?? []) as Array<{ id: string; name: string; prefix: string | null }>) + .map((project) => ({ ...project, is_archived: false, is_deleted: false })); + const resolution = resolveProjectTargetFromRows(projectsRows, { projectName: lookupQuery }); + if (resolution.status === 'resolved') return { project: resolution.project }; + if (resolution.status === 'ambiguous') { + return { + project: null, + error: errorResult(`task_create project: ambiguous target. Confirm one of: ${resolution.matches.map((project) => project.name).join(', ')}.`), + }; + } + return { project: null, error: errorResult('task_create project: no active project matched that target.') }; } const [fallback] = await db @@ -1790,12 +1794,26 @@ export async function humanResolveProject(args: { query?: string; limit?: number ORDER BY p.updated_at DESC LIMIT 200 `); - const projectsRows = ((rows as any).rows ?? []) as Array>; - const ranked = rankResolverCandidates(lookupQuery, projectsRows, (row) => [ - { value: row.name, reason: 'name' }, - { value: row.prefix, reason: 'prefix', weight: 0.98 }, - ]); - return textResult(buildResolverResponse(query, ranked, { limit: args.limit, resolvedThreshold: 0.76 })); + const projectsRows = (((rows as any).rows ?? []) as Array>) + .map((project) => ({ ...project, is_deleted: false })) as Array<{ + id: string; + name: string; + prefix: string | null; + is_archived: boolean; + is_deleted: boolean; + [key: string]: unknown; + }>; + const resolution = resolveProjectTargetFromRows(projectsRows, { projectName: lookupQuery }); + const limit = Math.min(Math.max(1, args.limit ?? 5), 20); + const candidates = resolution.candidates.slice(0, limit); + return textResult({ + query, + status: resolution.status === 'missing' ? 'not_found' : resolution.status, + selected: resolution.status === 'resolved' ? resolution.project : null, + candidates, + needs_confirmation: resolution.status !== 'resolved', + confidence: candidates[0]?.confidence ?? 0, + }); } export async function humanProjectGet(args: { project_id?: string }, ctx: HumanToolContext): Promise { diff --git a/apps/api/src/lib/resolve-project-target.ts b/apps/api/src/lib/resolve-project-target.ts new file mode 100644 index 0000000..e4807e3 --- /dev/null +++ b/apps/api/src/lib/resolve-project-target.ts @@ -0,0 +1,225 @@ +import { and, eq } from 'drizzle-orm'; +import { projectSpaces, projects } from '@deft/db/schema'; +import { db } from './db.js'; + +export type ProjectTargetRow = { + id: string; + name: string; + prefix: string | null; + is_archived: boolean; + is_deleted: boolean; +}; + +export type RankedProjectTarget = T & { + confidence: number; + match_reason: string; + linked_to_source: boolean; +}; + +export type ProjectTargetResolution = + | { status: 'resolved'; project: RankedProjectTarget; candidates: RankedProjectTarget[] } + | { status: 'missing'; message: string; candidates: RankedProjectTarget[] } + | { status: 'ambiguous'; message: string; matches: RankedProjectTarget[]; candidates: RankedProjectTarget[] }; + +export function normalizeProjectTargetName(value: unknown): string { + if (typeof value !== 'string') return ''; + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function compact(value: unknown): string { + return normalizeProjectTargetName(value).replace(/\s+/g, ''); +} + +function scorePart(query: string, value: unknown, reason: string): { confidence: number; reason: string } { + const normalizedQuery = normalizeProjectTargetName(query); + const normalizedValue = normalizeProjectTargetName(value); + if (!normalizedQuery || !normalizedValue) return { confidence: 0, reason }; + + if (normalizedQuery === normalizedValue || compact(query) === compact(value)) { + return { confidence: 1, reason: `${reason}: exact` }; + } + + // A planner may return a canonical name followed by explanatory prose. A + // complete, contiguous project name is safe to recover; token fragments are not. + const paddedQuery = ` ${normalizedQuery} `; + const paddedValue = ` ${normalizedValue} `; + if (paddedQuery.includes(paddedValue)) { + return { confidence: 0.98, reason: `${reason}: complete name in text` }; + } + + if (normalizedValue.startsWith(normalizedQuery) || compact(value).startsWith(compact(query))) { + return { confidence: 0.92, reason: `${reason}: prefix` }; + } + + const queryTokens = normalizedQuery.split(' ').filter(Boolean); + const valueTokens = normalizedValue.split(' ').filter(Boolean); + const querySet = new Set(queryTokens); + const matchedValueTokens = valueTokens.filter((token) => querySet.has(token)).length; + if (valueTokens.length > 1 && matchedValueTokens === valueTokens.length) { + return { confidence: 0.9, reason: `${reason}: all project words` }; + } + + return { confidence: 0, reason }; +} + +export function rankProjectTargets( + query: string, + rows: T[], + linkedProjectIds: ReadonlySet = new Set(), +): RankedProjectTarget[] { + return rows + .filter((project) => !project.is_archived && !project.is_deleted) + .map((project) => { + const nameScore = scorePart(query, project.name, 'name'); + const prefixScore = scorePart(query, project.prefix, 'prefix'); + const best = nameScore.confidence >= prefixScore.confidence ? nameScore : prefixScore; + return { + ...project, + confidence: best.confidence, + match_reason: best.reason, + linked_to_source: linkedProjectIds.has(project.id), + }; + }) + .filter((project) => project.confidence > 0) + .sort((a, b) => { + if (b.confidence !== a.confidence) return b.confidence - a.confidence; + const nameLengthDelta = normalizeProjectTargetName(b.name).length - normalizeProjectTargetName(a.name).length; + if (nameLengthDelta !== 0) return nameLengthDelta; + if (a.linked_to_source !== b.linked_to_source) return a.linked_to_source ? -1 : 1; + return a.name.localeCompare(b.name); + }); +} + +function asRanked(project: T, linkedProjectIds: ReadonlySet): RankedProjectTarget { + return { + ...project, + confidence: 1, + match_reason: 'single project linked to source space', + linked_to_source: linkedProjectIds.has(project.id), + }; +} + +export function resolveProjectTargetFromRows( + rows: T[], + args: { projectId?: unknown; projectName?: unknown; linkedProjectIds?: ReadonlySet }, +): ProjectTargetResolution { + const activeRows = rows.filter((project) => !project.is_archived && !project.is_deleted); + const linkedProjectIds = args.linkedProjectIds ?? new Set(); + const projectId = typeof args.projectId === 'string' ? args.projectId.trim() : ''; + const projectName = typeof args.projectName === 'string' ? args.projectName.trim() : ''; + + if (projectId) { + const exact = activeRows.find((project) => project.id === projectId); + if (exact) { + const project = asRanked(exact, linkedProjectIds); + return { status: 'resolved', project, candidates: [project] }; + } + return { + status: 'missing', + message: 'I could not find that active project. Choose an existing project and I will draft it again.', + candidates: [], + }; + } + + if (!projectName) { + const linked = activeRows.filter((project) => linkedProjectIds.has(project.id)); + if (linked.length === 1) { + const project = asRanked(linked[0]!, linkedProjectIds); + return { status: 'resolved', project, candidates: [project] }; + } + if (linked.length > 1) { + const matches = linked.map((project) => asRanked(project, linkedProjectIds)); + return { + status: 'ambiguous', + message: `This conversation is linked to several projects: ${matches.map((project) => project.name).join(', ')}. Which one should I use?`, + matches, + candidates: matches, + }; + } + return { + status: 'missing', + message: 'I need the target project before I can create the task approval card.', + candidates: [], + }; + } + + const candidates = rankProjectTargets(projectName, activeRows, linkedProjectIds); + const top = candidates[0]; + const second = candidates[1]; + if (!top || top.confidence < 0.9) { + return { + status: 'missing', + message: 'I could not confidently match the target project. Name the project exactly and I will draft it again.', + candidates: candidates.slice(0, 5), + }; + } + + const normalizedTopName = normalizeProjectTargetName(top.name); + const normalizedSecondName = second ? normalizeProjectTargetName(second.name) : ''; + const topIsSpecificVersionOfSecond = Boolean( + second + && normalizedTopName.startsWith(`${normalizedSecondName} `) + && normalizeProjectTargetName(projectName).startsWith(`${normalizedTopName} `), + ); + const exactTie = Boolean(second && top.confidence === 1 && second.confidence === 1); + const closeNonExactMatch = Boolean( + second + && top.confidence < 1 + && top.confidence - second.confidence < 0.1 + && !topIsSpecificVersionOfSecond, + ); + if (exactTie || closeNonExactMatch) { + const matches = candidates.filter((candidate) => top.confidence - candidate.confidence < 0.1).slice(0, 5); + return { + status: 'ambiguous', + message: `I found several possible projects: ${matches.map((project) => project.name).join(', ')}. Which one should I use?`, + matches, + candidates: candidates.slice(0, 5), + }; + } + + return { status: 'resolved', project: top, candidates: candidates.slice(0, 5) }; +} + +export async function resolveProjectTarget( + orgId: string, + args: { projectId?: unknown; projectName?: unknown; sourceSpaceId?: unknown }, +): Promise { + const rows = await db + .select({ + id: projects.id, + name: projects.name, + prefix: projects.prefix, + is_archived: projects.is_archived, + is_deleted: projects.is_deleted, + }) + .from(projects) + .where(and(eq(projects.org_id, orgId), eq(projects.is_archived, false), eq(projects.is_deleted, false))); + + const sourceSpaceId = typeof args.sourceSpaceId === 'string' ? args.sourceSpaceId.trim() : ''; + const linkedProjectIds = new Set(); + if (sourceSpaceId) { + const links = await db + .select({ projectId: projectSpaces.project_id }) + .from(projectSpaces) + .innerJoin(projects, and( + eq(projectSpaces.project_id, projects.id), + eq(projects.org_id, orgId), + eq(projects.is_archived, false), + eq(projects.is_deleted, false), + )) + .where(eq(projectSpaces.space_id, sourceSpaceId)); + for (const link of links) linkedProjectIds.add(link.projectId); + } + + return resolveProjectTargetFromRows(rows, { + projectId: args.projectId, + projectName: args.projectName, + linkedProjectIds, + }); +} diff --git a/apps/api/src/workers/handlers/agent-reply.ts b/apps/api/src/workers/handlers/agent-reply.ts index 90968b3..4a09d63 100644 --- a/apps/api/src/workers/handlers/agent-reply.ts +++ b/apps/api/src/workers/handlers/agent-reply.ts @@ -1,15 +1,16 @@ // Handler: process @agent/@deft mentions in chat and generate AI replies in-thread import type { JobData } from '../types.js'; import { db } from '../../lib/db.js'; -import { messages, users, spaces, spaceMembers, projects, projectSpaces, tasks, wikiPages, notes } from '@deft/db/schema'; +import { messages, users, spaces, spaceMembers, projects, tasks, wikiPages, notes } from '@deft/db/schema'; import { getApprovalTier } from '../../lib/agent-approval.js'; -import { eq, and, desc, sql, ne, lt, ilike, inArray } from 'drizzle-orm'; +import { eq, and, desc, sql, ne, lt, inArray } from 'drizzle-orm'; import { getIO } from '../../socket.js'; import { runAgentQuery } from '../../lib/agent-runner.js'; import { ensureDeftyMembership, DEFTY_NAME } from '../../lib/ensure-defty-membership.js'; import { toPlainText, truncatePlainText } from '../../lib/plain-text.js'; import { resolveSpaceTarget } from '../../lib/resolve-space-target.js'; import { resolveAssigneeWithMatches } from '../../lib/resolve-assignee.js'; +import { resolveProjectTarget } from '../../lib/resolve-project-target.js'; import { compileDeftyActionDraft, persistAgentReplyWithActions, @@ -171,32 +172,20 @@ async function resolvePendingActionTargets( const params = action.params; let projectName = typeof params.project_name === 'string' ? params.project_name.trim() : ''; if (!projectName) { - projectName = await resolveProjectNameForMentionFallback( - orgId, - typeof params.source_space_id === 'string' ? params.source_space_id : sourceSpaceId, + projectName = extractExplicitProjectName( [params.title, params.description].filter(Boolean).join('\n'), ) ?? ''; } - if (!projectName) { - warnings.push('I need the target project before I can create the task approval card.'); - continue; - } - - const [project] = await db - .select({ id: projects.id, name: projects.name }) - .from(projects) - .where(and( - eq(projects.org_id, orgId), - ilike(projects.name, projectName), - eq(projects.is_archived, false), - eq(projects.is_deleted, false), - )) - .limit(1); - if (!project) { - warnings.push(`I could not find an active project named "${projectName}".`); + const projectResolution = await resolveProjectTarget(orgId, { + projectName, + sourceSpaceId: typeof params.source_space_id === 'string' ? params.source_space_id : sourceSpaceId, + }); + if (projectResolution.status !== 'resolved') { + warnings.push(projectResolution.message); continue; } + const project = projectResolution.project; if (typeof params.assignee_name === 'string' && params.assignee_name.trim()) { const resolvedAssignee = await resolveAssigneeWithMatches(params.assignee_name.trim(), orgId); @@ -982,40 +971,19 @@ async function resolveProjectNameForMentionFallback( spaceId: string, content: string, ): Promise { - const explicitProjectName = content.match(/\bproject\s+"([^"]+)"/i)?.[1] - ?? content.match(/\bproject\s+'([^']+)'/i)?.[1] - ?? content.match(/\bin\s+(?:the\s+)?([A-Z][A-Za-z0-9 &+_-]{2,80})\s+project\b/i)?.[1]; - - if (explicitProjectName?.trim()) { - const [project] = await db - .select({ name: projects.name }) - .from(projects) - .where(and( - eq(projects.org_id, orgId), - ilike(projects.name, explicitProjectName.trim()), - eq(projects.is_archived, false), - eq(projects.is_deleted, false), - )) - .limit(1); - if (project) return project.name; - } - - const [linked] = await db - .select({ name: projects.name }) - .from(projectSpaces) - .innerJoin(projects, and( - eq(projectSpaces.project_id, projects.id), - eq(projects.org_id, orgId), - )) - .where(and( - eq(projectSpaces.space_id, spaceId), - eq(projects.is_archived, false), - eq(projects.is_deleted, false), - )) - .limit(1); - if (linked) return linked.name; + const explicitProjectName = extractExplicitProjectName(content); + const resolution = await resolveProjectTarget(orgId, { + projectName: explicitProjectName, + sourceSpaceId: spaceId, + }); + return resolution.status === 'resolved' ? resolution.project.name : null; +} - return null; +function extractExplicitProjectName(content: string): string | null { + return content.match(/\bproject\s+"([^"]+)"/i)?.[1]?.trim() + ?? content.match(/\bproject\s+'([^']+)'/i)?.[1]?.trim() + ?? content.match(/\bin\s+(?:the\s+)?([A-Z][A-Za-z0-9 &+_-]{2,80})\s+project\b/i)?.[1]?.trim() + ?? null; } function extractDiscussionTaskActionFromReply(replyText: string, sourceMessageId: string) { diff --git a/apps/api/test/resolve-project-target.test.ts b/apps/api/test/resolve-project-target.test.ts new file mode 100644 index 0000000..4b8cbc1 --- /dev/null +++ b/apps/api/test/resolve-project-target.test.ts @@ -0,0 +1,104 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + resolveProjectTargetFromRows, + type ProjectTargetRow, +} from '../src/lib/resolve-project-target.js'; + +function project(id: string, name: string, prefix: string): ProjectTargetRow { + return { + id, + name, + prefix, + is_archived: false, + is_deleted: false, + }; +} + +const route = project('route', 'Route + Packing Reliability', 'RPR'); +const pilot = project('pilot', 'Pilot Marketing Launch', 'MKT'); + +test('resolves an exact project name to its canonical row', () => { + const resolution = resolveProjectTargetFromRows([route, pilot], { + projectName: 'route + packing reliability', + }); + assert.equal(resolution.status, 'resolved'); + if (resolution.status === 'resolved') assert.equal(resolution.project.id, route.id); +}); + +test('recovers a complete canonical project name from contaminated planner prose', () => { + const resolution = resolveProjectTargetFromRows([route, pilot], { + projectName: 'Route + Packing Reliability Let\'s proceed to create this task. Please provide anything else if needed.', + }); + assert.equal(resolution.status, 'resolved'); + if (resolution.status === 'resolved') { + assert.equal(resolution.project.id, route.id); + assert.equal(resolution.project.name, route.name); + assert.equal(resolution.project.match_reason, 'name: complete name in text'); + } +}); + +test('does not echo malformed model output when no project matches', () => { + const malformed = 'Definitely Not A Project plus generated internal instructions'; + const resolution = resolveProjectTargetFromRows([route, pilot], { projectName: malformed }); + assert.equal(resolution.status, 'missing'); + if (resolution.status === 'missing') assert.equal(resolution.message.includes(malformed), false); +}); + +test('asks when planner text contains two complete project names', () => { + const resolution = resolveProjectTargetFromRows([route, pilot], { + projectName: 'Coordinate Route + Packing Reliability and Pilot Marketing Launch before proceeding.', + }); + assert.equal(resolution.status, 'ambiguous'); + if (resolution.status === 'ambiguous') { + assert.deepEqual(new Set(resolution.matches.map((match) => match.id)), new Set([route.id, pilot.id])); + } +}); + +test('prefers the more specific exact project in a nested project family', () => { + const sales = project('sales', 'Sales', 'SAL'); + const internal = project('sales-internal', 'Sales Internal', 'SINT'); + const leadership = project('sales-leadership', 'Sales Leadership', 'SLEAD'); + const resolution = resolveProjectTargetFromRows([sales, internal, leadership], { + projectName: 'Sales Internal needs a follow-up task.', + }); + assert.equal(resolution.status, 'resolved'); + if (resolution.status === 'resolved') assert.equal(resolution.project.id, internal.id); +}); + +test('asks when a short name matches several sibling projects', () => { + const internal = project('sales-internal', 'Sales Internal', 'SINT'); + const leadership = project('sales-leadership', 'Sales Leadership', 'SLEAD'); + const resolution = resolveProjectTargetFromRows([internal, leadership], { projectName: 'Sales' }); + assert.equal(resolution.status, 'ambiguous'); +}); + +test('uses a source-space link only when exactly one active project is linked', () => { + const resolution = resolveProjectTargetFromRows([route, pilot], { + linkedProjectIds: new Set([route.id]), + }); + assert.equal(resolution.status, 'resolved'); + if (resolution.status === 'resolved') assert.equal(resolution.project.id, route.id); +}); + +test('asks instead of choosing the first project when a space has multiple links', () => { + const resolution = resolveProjectTargetFromRows([route, pilot], { + linkedProjectIds: new Set([route.id, pilot.id]), + }); + assert.equal(resolution.status, 'ambiguous'); +}); + +test('an explicit nonexistent target never falls back to the linked project', () => { + const resolution = resolveProjectTargetFromRows([route, pilot], { + projectName: 'Nonexistent Expansion Project', + linkedProjectIds: new Set([route.id]), + }); + assert.equal(resolution.status, 'missing'); +}); + +test('archived and deleted projects cannot be resolved', () => { + const archived = { ...route, is_archived: true }; + const deleted = { ...pilot, is_deleted: true }; + assert.equal(resolveProjectTargetFromRows([archived], { projectName: route.name }).status, 'missing'); + assert.equal(resolveProjectTargetFromRows([deleted], { projectName: pilot.name }).status, 'missing'); +}); From 263404bb0a3ff47a43a7c0b71604b3a21b686c7f Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:33:09 +0530 Subject: [PATCH 2/2] Harden production runtime package tooling --- Dockerfile | 6 +++++- scripts/docker-entrypoint.sh | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index c0ec984..fbba7da 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,7 +42,11 @@ RUN pnpm --filter @deft/web build # Stage 3: Production FROM node:22-alpine AS runner -RUN corepack enable && corepack prepare pnpm@9 --activate +# Runtime maintenance commands still use pnpm. pnpm 10 carries the patched +# node-tar release; npm is unused and is removed with its bundled dependencies. +RUN corepack enable && corepack prepare pnpm@10.34.5 --activate \ + && rm -rf /usr/local/lib/node_modules/npm \ + && rm -f /usr/local/bin/npm /usr/local/bin/npx WORKDIR /app ENV NODE_ENV=production diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh index f54a146..62a7670 100644 --- a/scripts/docker-entrypoint.sh +++ b/scripts/docker-entrypoint.sh @@ -6,4 +6,4 @@ node /app/scripts/inject-public-env.mjs (cd /app/apps/api && node --import tsx src/server.ts) & cd /app/apps/web -exec pnpm exec next start -p 3000 +exec node node_modules/next/dist/bin/next start -p 3000