From a54cf0bf2227ad35a12bbb0bd31a217a70667d4a Mon Sep 17 00:00:00 2001 From: Max Lin Date: Tue, 24 Mar 2026 23:11:01 -0700 Subject: [PATCH 1/2] Make committed TimeSpec authoritative for scheduling constraints Remove vestigial temporal encoding from planner LLM. The discourse layer's committed TimeSpec now provides the ScheduleConstraint via injection in processInboxItem, replacing the planner's redundant temporal parsing that was producing invalid constraints. - Add buildScheduleConstraintFromSlots in core/time-spec.ts - Add scheduleConstraint to ProcessInboxItemRequest with injection logic - Build constraint from committed slots at both webhook mutation paths - Remove scheduleConstraint from planner response format schemas - Strip Temporal Encoding Rules section from planner prompt - Add unit tests validating all TimeSpec variants against schema Co-Authored-By: Claude Opus 4.6 --- apps/web/src/lib/server/process-inbox-item.ts | 24 ++++ apps/web/src/lib/server/telegram-webhook.ts | 11 ++ packages/core/src/index.ts | 9 -- packages/core/src/time-spec.test.ts | 103 ++++++++++++++++++ packages/core/src/time-spec.ts | 75 ++++++++++++- packages/integrations/src/openai.ts | 4 +- packages/integrations/src/prompts/planner.ts | 34 +----- 7 files changed, 216 insertions(+), 44 deletions(-) create mode 100644 packages/core/src/time-spec.test.ts diff --git a/apps/web/src/lib/server/process-inbox-item.ts b/apps/web/src/lib/server/process-inbox-item.ts index ee5cbec..cae390c 100644 --- a/apps/web/src/lib/server/process-inbox-item.ts +++ b/apps/web/src/lib/server/process-inbox-item.ts @@ -11,6 +11,7 @@ import { resolveScheduleBlockReference, resolveTaskReference, type ScheduleBlock, + type ScheduleConstraint, type Task, } from "@atlas/core"; import { @@ -33,6 +34,7 @@ export type ProcessInboxItemRequest = { planningInboxTextOverride?: { text: string; }; + scheduleConstraint?: ScheduleConstraint | null; }; export type ProcessInboxItemDependencies = { @@ -87,6 +89,10 @@ export async function processInboxItem( throw error; } + if (input.scheduleConstraint !== undefined) { + planning = injectScheduleConstraint(planning, input.scheduleConstraint); + } + const plannerRun = buildPlannerRun(context, planningContext, planning); try { @@ -1185,6 +1191,24 @@ function parseProcessInboxItemRequest( return input; } +function injectScheduleConstraint( + planning: InboxPlanningOutput, + constraint: ScheduleConstraint | null, +): InboxPlanningOutput { + return { + ...planning, + actions: planning.actions.map((action) => { + if ( + action.type === "create_schedule_block" || + action.type === "move_schedule_block" + ) { + return { ...action, scheduleConstraint: constraint }; + } + return action; + }), + }; +} + function withRenderedFollowUp( result: ProcessedInboxResult, timeZone: string, diff --git a/apps/web/src/lib/server/telegram-webhook.ts b/apps/web/src/lib/server/telegram-webhook.ts index 70d377e..c30ddf3 100644 --- a/apps/web/src/lib/server/telegram-webhook.ts +++ b/apps/web/src/lib/server/telegram-webhook.ts @@ -1,4 +1,5 @@ import { + buildScheduleConstraintFromSlots, buildTelegramFollowUpIdempotencyKey, buildTelegramWebhookIdempotencyKey, type ConfirmedMutationRecoveryInput, @@ -528,12 +529,17 @@ export async function handleTelegramWebhook( await dependencies.primeProcessingStore?.(ingress.inboxItem); + const recoveryConstraint = buildScheduleConstraintFromSlots( + routedWithContext.policy.committedSlots, + ); + const processing = await processInboxItem( { inboxItemId: ingress.inboxItem.id, planningInboxTextOverride: { text: synthesis.text, }, + scheduleConstraint: recoveryConstraint, }, { ...(dependencies.store ? { store: dependencies.store } : {}), @@ -611,9 +617,14 @@ export async function handleTelegramWebhook( }); await dependencies.primeProcessingStore?.(ingress.inboxItem); + const mutationConstraint = buildScheduleConstraintFromSlots( + routedWithContext.policy.committedSlots, + ); + const processing = await processInboxItem( { inboxItemId: ingress.inboxItem.id, + scheduleConstraint: mutationConstraint, }, { ...(dependencies.store ? { store: dependencies.store } : {}), diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7910fd4..d0851c4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -644,9 +644,6 @@ export const createScheduleBlockPlanningActionResponseFormatSchema = z.object({ }) .nullable() .optional(), - scheduleConstraint: scheduleConstraintResponseFormatSchema - .nullable() - .optional(), reason: z.string().min(1).nullable().optional(), }); @@ -665,9 +662,6 @@ export const moveScheduleBlockPlanningActionResponseFormatSchema = z.object({ }) .nullable() .optional(), - scheduleConstraint: scheduleConstraintResponseFormatSchema - .nullable() - .optional(), reason: z.string().min(1).nullable().optional(), }); @@ -715,9 +709,6 @@ export const planningActionResponseFormatSchema = z.object({ }) .nullable() .optional(), - scheduleConstraint: scheduleConstraintResponseFormatSchema - .nullable() - .optional(), reason: z.string().min(1).nullable().optional(), }); diff --git a/packages/core/src/time-spec.test.ts b/packages/core/src/time-spec.test.ts new file mode 100644 index 0000000..b20b859 --- /dev/null +++ b/packages/core/src/time-spec.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import { buildScheduleConstraintFromSlots } from "./time-spec"; +import { scheduleConstraintSchema } from "./index"; + +describe("buildScheduleConstraintFromSlots", () => { + it("returns null when no day or time provided", () => { + expect(buildScheduleConstraintFromSlots({})).toBeNull(); + }); + + it("builds constraint from absolute time + tomorrow", () => { + const result = buildScheduleConstraintFromSlots({ + day: "tomorrow", + time: { kind: "absolute", hour: 15, minute: 0 }, + }); + + expect(result).toMatchObject({ + dayReference: "tomorrow", + weekday: null, + weekOffset: null, + explicitHour: 15, + minute: 0, + preferredWindow: null, + }); + expect(scheduleConstraintSchema.safeParse(result).success).toBe(true); + }); + + it("builds constraint from relative time only", () => { + const result = buildScheduleConstraintFromSlots({ + time: { kind: "relative", minutes: 15 }, + }); + + expect(result).toMatchObject({ + dayReference: null, + weekday: null, + weekOffset: null, + relativeMinutes: 15, + explicitHour: null, + }); + expect(scheduleConstraintSchema.safeParse(result).success).toBe(true); + }); + + it("builds constraint from window time + weekday", () => { + const result = buildScheduleConstraintFromSlots({ + day: "friday", + time: { kind: "window", window: "morning" }, + }); + + expect(result).toMatchObject({ + dayReference: "weekday", + weekday: "friday", + weekOffset: 0, + explicitHour: null, + preferredWindow: "morning", + }); + expect(scheduleConstraintSchema.safeParse(result).success).toBe(true); + }); + + it("defaults to morning window when day-only", () => { + const result = buildScheduleConstraintFromSlots({ day: "today" }); + + expect(result).toMatchObject({ + dayReference: "today", + preferredWindow: "morning", + explicitHour: null, + }); + expect(scheduleConstraintSchema.safeParse(result).success).toBe(true); + }); + + it("builds constraint from window time + today", () => { + const result = buildScheduleConstraintFromSlots({ + day: "today", + time: { kind: "window", window: "evening" }, + }); + + expect(result).toMatchObject({ + dayReference: "today", + preferredWindow: "evening", + explicitHour: null, + }); + expect(scheduleConstraintSchema.safeParse(result).success).toBe(true); + }); + + it("builds constraint from time-only absolute", () => { + const result = buildScheduleConstraintFromSlots({ + time: { kind: "absolute", hour: 9, minute: 30 }, + }); + + expect(result).toMatchObject({ + dayReference: null, + explicitHour: 9, + minute: 30, + }); + expect(scheduleConstraintSchema.safeParse(result).success).toBe(true); + }); + + it("synthesizes readable sourceText", () => { + const result = buildScheduleConstraintFromSlots({ + day: "tomorrow", + time: { kind: "absolute", hour: 15, minute: 0 }, + }); + expect(result?.sourceText).toBe("tomorrow at 3pm"); + }); +}); diff --git a/packages/core/src/time-spec.ts b/packages/core/src/time-spec.ts index 7acc77b..d343aad 100644 --- a/packages/core/src/time-spec.ts +++ b/packages/core/src/time-spec.ts @@ -1,4 +1,77 @@ -import type { TimeSpec } from "./discourse-state"; +import type { TimeSpec, ResolvedSlots } from "./discourse-state"; + +type Weekday = + | "sunday" + | "monday" + | "tuesday" + | "wednesday" + | "thursday" + | "friday" + | "saturday"; + +const WEEKDAYS = new Set([ + "sunday", + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", +]); + +export function buildScheduleConstraintFromSlots( + slots: Pick, +): { + dayReference: "today" | "tomorrow" | "weekday" | null; + weekday: Weekday | null; + weekOffset: number | null; + relativeMinutes?: number | null; + explicitHour: number | null; + minute: number | null; + preferredWindow: "morning" | "afternoon" | "evening" | null; + sourceText: string; +} | null { + if (!slots.day && !slots.time) return null; + + const timeFields = slots.time + ? timeSpecToConstraintFields(slots.time) + : { + explicitHour: null, + minute: null, + relativeMinutes: null, + preferredWindow: "morning" as const, + }; + + let dayReference: "today" | "tomorrow" | "weekday" | null = null; + let weekday: Weekday | null = null; + let weekOffset: number | null = null; + + if (slots.day) { + const lower = slots.day.toLowerCase(); + if (lower === "today") { + dayReference = "today"; + } else if (lower === "tomorrow") { + dayReference = "tomorrow"; + } else if (WEEKDAYS.has(lower)) { + dayReference = "weekday"; + weekday = lower as Weekday; + weekOffset = 0; + } + } + + const parts: string[] = []; + if (slots.day) parts.push(slots.day); + if (slots.time) parts.push(formatTimeSpec(slots.time)); + const sourceText = parts.join(" at ") || "scheduled"; + + return { + dayReference, + weekday, + weekOffset, + ...timeFields, + sourceText, + }; +} export function timeSpecToHHMM(spec: TimeSpec): string | null { if (spec.kind === "absolute") { diff --git a/packages/integrations/src/openai.ts b/packages/integrations/src/openai.ts index 92e89c6..973dd64 100644 --- a/packages/integrations/src/openai.ts +++ b/packages/integrations/src/openai.ts @@ -487,14 +487,14 @@ function normalizePlanningOutput( return { type: action.type, taskRef: action.taskRef, - scheduleConstraint: action.scheduleConstraint ?? null, + scheduleConstraint: null, reason: action.reason, }; case "move_schedule_block": return { type: action.type, blockRef: action.blockRef, - scheduleConstraint: action.scheduleConstraint ?? null, + scheduleConstraint: null, reason: action.reason, }; case "complete_task": diff --git a/packages/integrations/src/prompts/planner.ts b/packages/integrations/src/prompts/planner.ts index e83a263..4a70fb9 100644 --- a/packages/integrations/src/prompts/planner.ts +++ b/packages/integrations/src/prompts/planner.ts @@ -37,32 +37,11 @@ export const inboxPlannerSystemPrompt = buildPromptSpec([ lines: [ "Use create_task when the inbox item introduces new work.", "If the user asks to schedule new work, use create_task and create_schedule_block for that work unless clarification is required.", - "If the user asks to schedule new work but gives no specific timing details, default to create_task plus create_schedule_block with scheduleConstraint=null so Atlas can place it in the next reasonable opening.", - "Use create_schedule_block to schedule either a created task alias or an existing task alias.", - "If the user explicitly delegates slot choice to Atlas, such as 'schedule it for me', 'pick a time', or 'find an open spot', use a schedule action with scheduleConstraint=null so the application can choose the next reasonable opening.", + "Use create_schedule_block to schedule either a created task alias or an existing task alias. The application handles timing from context.", "Use move_schedule_block only for one existing schedule_block alias from the provided context when that block is clearly referenced.", "Use complete_task when the user clearly says an existing task is done or completed.", ], }, - { - title: "Temporal Encoding Rules", - lines: [ - "Emit temporal intent, not computed day counts or calendar offsets.", - "Use context.referenceTime and the user's timezone only to interpret the phrase semantics safely.", - "For relative phrases like 'in 15 min', 'in 2 hours', or '15 minutes from now', set relativeMinutes and leave every absolute date/time field null.", - "For same-day phrases, use dayReference='today', weekday=null, and weekOffset=null.", - "For tomorrow phrases, use dayReference='tomorrow', weekday=null, and weekOffset=null.", - "For named weekday phrases, use dayReference='weekday' with a lowercase weekday and a weekOffset.", - "Use weekOffset=0 for Friday or this Friday, weekOffset=1 for next Friday, and weekOffset=2 for next next Friday.", - "For time-only phrases like at 3pm, leave dayReference, weekday, and weekOffset null.", - "Use explicitHour and minute for exact times.", - "When the user gives an explicit time block like '11:05 to 11:09' or 'from 2pm to 2:30pm', also set endExplicitHour and endMinute so Atlas preserves that exact duration.", - "Use preferredWindow only for broad phrases like morning, afternoon, or evening, and leave explicitHour null in those cases.", - "If the user gives a soft but usable preference like 'morning but not too early', infer a sensible time instead of asking for an exact hour. For example, 10:30am is a valid interpretation.", - "Do not ask for an exact time when the user has already given a usable broad window or delegated slot choice to Atlas.", - "If the temporal phrase cannot be represented safely by the current schema, return exactly one clarify action.", - ], - }, { title: "Clarification And Safety", lines: [ @@ -86,17 +65,8 @@ export const inboxPlannerSystemPrompt = buildPromptSpec([ { title: "Examples", lines: [ - "tomorrow at 3pm -> dayReference='tomorrow', weekday=null, weekOffset=null, explicitHour=15, minute=0.", - "in like 15 min -> relativeMinutes=15, dayReference=null, weekday=null, weekOffset=null, explicitHour=null, minute=null, preferredWindow=null.", - "Friday morning -> dayReference='weekday', weekday='friday', weekOffset=0, explicitHour=null, preferredWindow='morning'.", - "at 3pm -> dayReference=null, weekday=null, weekOffset=null, explicitHour=15, minute=0.", - "today from 11:05 to 11:09 -> dayReference='today', explicitHour=11, minute=5, endExplicitHour=11, endMinute=9.", - "If context.referenceTime is Wednesday, March 18, 2026 in America/Los_Angeles, then Friday at 10am -> dayReference='weekday', weekday='friday', weekOffset=0, explicitHour=10, minute=0.", - "If context.referenceTime is Wednesday, March 18, 2026 in America/Los_Angeles, then next Friday at 10am -> dayReference='weekday', weekday='friday', weekOffset=1, explicitHour=10, minute=0.", "If the user says 'journal is done' and the provided task context includes one journaling task alias, emit exactly one complete_task action for that existing task alias.", - "If the user says 'schedule an oil change' with no specific timing detail, emit create_task plus create_schedule_block with scheduleConstraint=null.", - "If the user says 'schedule it for me and just pick an opening' for one clear task, emit a schedule action with scheduleConstraint=null.", - "If the user says 'tomorrow morning but not too early', emit a concrete late-morning time such as explicitHour=10 and minute=30.", + "If the user says 'schedule an oil change' with no specific timing detail, emit create_task plus create_schedule_block.", "If the user says 'move that to Friday and add a grocery task' but the existing referent is not clear, emit exactly one clarify action.", "If the user says 'if tomorrow is slammed push the workout to Friday', emit exactly one clarify action.", ], From d8b6615539f7a0d0e51691cc4b1c8f1f6f7f0d26 Mon Sep 17 00:00:00 2001 From: Max Lin Date: Wed, 25 Mar 2026 10:00:28 -0700 Subject: [PATCH 2/2] Fix worktree dir --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 789d98d..9096684 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,7 +111,7 @@ Build Atlas as a production-quality, Telegram-first planning assistant. The code ## Git Workflow Rules - **Every workflow — no exceptions — must use a git worktree and a dedicated branch.** - - Create a worktree before touching any files: `git worktree add ../atlas- -b codex/` + - Create a worktree before touching any files: `git worktree add .worktrees/atlas- -b claude/` - All edits, experiments, and commits happen inside that worktree, never in the main checkout. - This prevents dirty-worktree collisions between concurrent tasks (e.g. formatting runs vs. feature work). - Follow `docs/workflows/feature-delivery.md` for product features, fixes, and behavior changes.