@critical-path/core
v0.62.0
Published
Core headless domain engine, plugin architecture, and data stores for Critical Path
Readme
@critical-path/core
The foundational domain engine, domain event bus, graph cycle detection, and storage adapters for Critical Path.
Upgrading? Upgrade every
@critical-path/*package together. Coding agents: follow UPGRADING.md; breaking changes are marked BREAKING in this package'sCHANGELOG.md.
📦 Features
- One engine, one set of rules:
CriticalPathEngineenforces every invariant (workflow statuses and transitions, derived fields, references, permissions) for direct callers, the HTTP API, the client and MCP alike. - Typed Domain Event Bus: In-memory pub/sub
DomainEventBusenabling reactive subscriptions to granular domain events (task.created,task.status_changed,time.logged,dependency.added, etc.). - DAG Graph Cycle Invariant: Built-in topological validation (
detectDependencyCycle,CircularDependencyError) to ensure strict acyclic task dependency graphs. Dependencies areblockingorrelates_to(informational, never part of the schedule or cycle check);precedenceOfreads a dependency's direction. - Custom Field Value Object Validation: Strict type-checking (
validateCustomFieldValues,CustomFieldValidationError) against configured project field schemas. - Interface Segregated Repositories: Focused repository contracts (
ProjectRepository,TaskRepository,WorkflowRepository, etc.) composed intoStorageAdapter. - Plugin Lifecycle Architecture: Extensible hooks (
beforeTaskCreate,afterTaskUpdate,beforeTaskDelete, etc.). - Multiple Built-in Storage Adapters:
InMemoryStore: Fast, zero-config in-memory storage for local dev and testing.SQLiteStore: Embedded relational database storage powered by native Node.js SQLite (node:sqlite).FirebaseStore: Firestore collection mapping for cloud-native web and mobile backends.
- File Storage Adapters for Attachments:
InMemoryFileStore: Lightweight in-memory binary asset storage with presigned URL simulation.S3StorageAdapter: S3 / MinIO / Cloudflare R2 adapter driven by your AWS SDK v3 client, command classes andgetSignedUrlpresigner (core itself has no AWS dependency). Supports signed uploads and private-bucket signed downloads.FirebaseStorageAdapter: Google Cloud Storage & Firebase Storage bucket adapter.
- Threaded Conversations, Discussions & Emoji Reactions:
- Nested replies (
parentId), multi-author taxonomy (user,agent,system), emoji reactions (addCommentReaction,removeCommentReaction), and real-time domain event streaming (comment.created,comment.updated,comment.deleted,comment.reaction.added,comment.reaction.removed).
- Nested replies (
- Multi-Assignee Support & Agent Collaboration:
- First-class
TaskAssigneetaxonomy supporting co-assignments across users, autonomous AI agents, and teams with role metadata and custom avatar URLs.
- First-class
- Bidirectional Workflow Transitions:
- Symmetrical transition helpers (
getAllowedNextStatuses,getAllowedPreviousStatuses) and engine methods for moving tasks backwards and forwards through customized workflow states.
- Symmetrical transition helpers (
- Universal Semantic Status & Implied Status Framework:
- Clean 3-tier status architecture: Universal Semantic Statuses (
not_started,in_progress,completed,canceled), customizable workflow-defined statuses mapped bycategory, and automatic system-derived implied statuses (isReady,isBlocked,blockingTaskIds,isOverdue,isUpcoming,isUnplanned,isUnassigned,isStalled,isOverEstimate,isPaceWarning).
- Clean 3-tier status architecture: Universal Semantic Statuses (
- Deliverables:
- Outputs a project produces, linked to the tasks that produce them, with a configurable status set mapped to semantic categories (
deliverableStatusDefinitions; defaultDEFAULT_DELIVERABLE_STATUSES), an automaticdeliveredAt, andoutputUrls. - Presets for creative production:
CREATIVE_DELIVERABLE_STATUSESand theDEFAULT_CREATIVE_WORKFLOWtask workflow. - Rollup calculations via
getDeliverableSummary()delivering progress percentages, completed tasks, and total estimated/logged hours across assigned tasks.
- Outputs a project produces, linked to the tasks that produce them, with a configurable status set mapped to semantic categories (
- Ladder of Abstraction & Critical Path Method (CPM):
- Multi-scale timeline synthesis: Macro bird's-eye phase rollups (
getTimelineLadder({ level: 'macro' })), Standard Gantt view with topological CPM forward/backward passes and total float/slack, and Concrete grounding (attachments, deliverables, checklist items, daily effort histograms, and reality deltas). - Single-task contextual drilldown via
getTaskLadder(taskId). - Comprehensive CPM analysis via
calculateCriticalPath(projectId)identifying project bottleneck tasks and critical path duration.
- Multi-scale timeline synthesis: Macro bird's-eye phase rollups (
- Work Schedules, Working Hours, Working Days & Holidays Subsystem:
- Configurable
WorkScheduledata model defining day-by-day active working hours (e.g. 09:00–17:00), non-working days/weekends, and organization or regionalHolidayexemptions. - Multi-level schedule inheritance hierarchy: Task Assignee / Team -> Project -> Global Engine Default (
DEFAULT_WORK_SCHEDULE), used by workload and capacity calculations. CPM date projections use the project (or engine default) schedule. - Calendar math domain operations (
addWorkingHours,subtractWorkingHours,getWorkingHoursBetween,getWorkingDaysBetween,getNetAvailableCapacity). - Automatic exclusion of weekends and holidays during Critical Path Method (CPM) forward and backward schedule passes (
earlyStartDate,earlyFinishDate,lateStartDate,lateFinishDate,totalWorkingHours,projectEndDate). - Calendar-aware capacity reductions in Workload Distribution and working-day schedule variance metrics (
scheduleVarianceWorkingDays).
- Configurable
- Headless Workload & Capacity Distribution (Streamgraphs & Capacity Planning):
- Time-series aggregations across customizable intervals (
day,week,month) and dimensions (assignee,team,taskType,priority,status). - Pluggable effort distribution metrics (
scheduled,logged,remaining,blended) with contiguous, gap-free calendar buckets and zero-filled tabular series matrices ready for D3 (d3.stack().offset(d3.stackOffsetWiggle)). - Dynamic capacity modeling per person and team, reporting bucket-level capacity thresholds and utilization ratios (
totalHours / totalCapacity).
- Time-series aggregations across customizable intervals (
- Base-62 Fractional Lexical Indexing:
- Zero-dependency Base-62 fractional indexing (
generateKeyBetween,generateNKeysBetween) for instant Kanban and backlog reordering without re-indexing or array shifting.
- Zero-dependency Base-62 fractional indexing (
- Code-Immune Mention & Tag Extraction:
- High-performance mention parsing (
extractMentions) with automatic markdown code suppression (stripMarkdownCode) ignoring code blocks, inline code, and URLs while parsing user and agent handles.
- High-performance mention parsing (
- Task Lifecycle & Semantic Status Predicates:
- Full suite of functional predicates (
isDraftTask,isArchivedTask,isTrashedTask,isTaskCompleted,isTaskInProgress,isTaskActive,isTempTaskId) for headless UI rendering.
- Full suite of functional predicates (
- Completion Timestamping & Progress Invariant:
- Automatic
actualEndDateandcompletedAtlifecycle timestamping upon entering completed or canceled statuses, with automatic timestamp clearing and progress reset (100 -> 0) when reopening tasks.
- Automatic
🚀 Usage Examples
1. Initializing the Engine & Subscribing to Domain Events
import { CriticalPathEngine, SQLiteStore, type TaskStatusChangedEvent } from '@critical-path/core';
const store = new SQLiteStore({ filename: 'critical-path.db' });
const engine = new CriticalPathEngine({ store });
// Subscribe to specific typed domain events
engine.events.subscribe<TaskStatusChangedEvent>('task.status_changed', (event) => {
console.log(`Task ${event.aggregateId} moved from ${event.payload.previousStatus} to ${event.payload.newStatus}`);
});
// Or subscribe to all domain events with wildcard
engine.events.subscribe('*', (event) => {
console.log(`[Domain Event] ${event.name}`, event);
});store must be an adapter instance (InMemoryStore by default). Critical Path does not store users: pass your app's user directory as users (an array, or a function called with the acting user) so workload and capacity calculations use real names, weekly capacity and schedules:
const engine = new CriticalPathEngine({
store,
users: async (actor) => (await myAuth.listUsers(actor?.tenantId)).map(toCriticalPathUser)
});2. Invariants Live in the Engine
import { CriticalPathEngine } from '@critical-path/core';
const engine = new CriticalPathEngine();
const project = await engine.createProject({ name: 'Payments' });
const task = await engine.createTask({ projectId: project.id, title: 'Implement Payment Gateway', priority: 'high' });
// Workflow transitions are enforced; semanticStatus and timestamps are derived
await engine.updateTask(task.id, { status: 'in_progress' });
// Hours come from time entries
await engine.logTime({ taskId: task.id, hours: 3.5, isBillable: true });3. DAG Dependency Cycle Prevention
import { CircularDependencyError } from '@critical-path/core';
try {
await engine.addDependency({
taskId: 'task_C',
dependsOnTaskId: 'task_A',
type: 'blocking'
});
} catch (error) {
if (error instanceof CircularDependencyError) {
console.error(`Blocked cyclic dependency! Cycle path: ${error.cyclePath.join(' -> ')}`);
}
}Remove a dependency with engine.removeDependency(dependencyId) (publishes dependency.removed).
Deletes cascade: deleteTask removes subtasks (or detaches them with { subtasks: 'detach' }), dependencies, comments, attachments with their stored files, and time entries; deleteProject also removes containers, iterations and deliverables; deleting a container, iteration or deliverable clears the reference on its tasks. The activity log is kept as an audit trail. On stores with transaction (such as SQLiteStore), each cascade is atomic: if any step fails, for example because a beforeTaskDelete hook throws for a subtask, nothing is deleted. Events, after-hooks and file deletions run only after commit.
Cycle detection follows the full upstream chain, so indirect cycles (A → B → C → D → A) are rejected as well as direct ones.
Request Payload Schemas (@critical-path/core/schemas)
import { CreateTaskSchema, UpdateTaskSchema, parsePayload } from '@critical-path/core/schemas';
const input = parsePayload(CreateTaskSchema, await request.json()); // throws ValidationError with `issues`
await engine.createTask(input);Create and update schemas exist for workflows, projects, tasks, dependencies, deliverables, teams, containers, iterations, comments, reactions, attachments and time entries. They are strict: server-assigned fields, identity fields and unknown keys are rejected. The schemas live on a subpath so importing @critical-path/core in a browser bundle does not pull in zod. A compile-time test fails the build if a domain type gains a field its schema lacks.
Custom Storage Adapters & Conformance
Implement StorageAdapter for your database and verify it with the same suite the built-in adapters pass:
import { describe, it, expect } from 'vitest';
import { runStorageAdapterConformance } from '@critical-path/core/testing';
runStorageAdapterConformance({ name: 'PostgresStore', createStore: () => new PostgresStore(db), describe, it, expect });Upload Limits
new CriticalPathEngine({
fileStorage,
uploads: { maxBytes: 25 * 1024 * 1024, allowedMimeTypes: ['image/*', 'application/pdf'] }
});Limits apply to direct uploads (checked against the decoded size), presigned uploads (content type) and registered attachments (declared sizeBytes and mimeType). Presigned uploads go straight to storage, so also cap their size in your bucket policy.
Querying & Pagination
const page = await engine.queryTasks({ projectId, status: ['todo', 'in_progress'], assigneeId: 'ana', limit: 50 });
const next = await engine.queryTasks({ projectId, limit: 50, cursor: page.nextCursor });
const feed = await engine.queryActivities({ projectId, limit: 20 }); // newest first
const thread = await engine.queryComments({ taskId, limit: 50 }); // oldest firstTasks and comments page oldest first and activities newest first, using keyset cursors (createdAt, then id). SQLiteStore filters and pages in SQL with indexes. FirebaseStore pushes down the project filter and applies the rest in memory. Without projectId, results are limited to projects the actor can read.
Authorization & Multi-Tenancy
import { CriticalPathEngine, createRolePolicy } from '@critical-path/core';
const engine = new CriticalPathEngine({ store, authorize: createRolePolicy() });
const asVic = engine.withActor({ userId: 'vic', tenantId: 'acme' });
await asVic.createTask({ projectId, title: 'x' }); // ForbiddenError if vic is only a viewerChecks run on withActor views (the base engine is trusted). Roles come from project.members: users ({ userId, role }) or whole teams ({ teamId, role }), with roles viewer < contributor < project_manager < admin. Project creators become admin. Actors with roles: ['admin'] are superusers. Projects the actor cannot read, or that belong to another tenant, behave as if they do not exist (NotFoundError). Lists are filtered. Records created through a view are stamped with the actor's tenantId. See the docs site's Authorization page for the full permission matrix and custom policies.
Custom fields
Every entity (projects, workflows, teams, tasks, containers, iterations, deliverables, comments, attachments, time entries, dependencies, webhooks) accepts a customFields JSON object on create and update for your own data. Task custom fields are validated against the project's customFieldDefinitions; on other entities any JSON object is stored as-is. Updates replace the whole object.
Webhooks
const { webhook, secret } = await engine.createWebhook({
name: 'CI',
url: 'https://ci.example.com/hooks',
events: ['task.created', 'task.status_changed'] // or ['*']
});
// Receivers: verifyWebhookSignature({ secret, body, timestamp, signature })Every domain event can be delivered. Deliveries carry X-CriticalPath-Signature (HMAC-SHA256 over "<timestamp>.<body>"), time out after 10s, and retry with exponential backoff (webhookDelivery options). The default queue is in-process. For durable delivery, pass new OutboxWebhookQueue(store) as webhookDelivery.queue and call outbox.start() (or processDue() from a cron job). On SQLiteStore, jobs are written in the same transaction as the mutation that produced them. You can also bring your own queue that calls engine.webhooks.deliver(job). Targets resolving to private addresses are refused before every attempt, and redirects are not followed. Secrets are only returned on creation.
Attributing Mutations to a User (withActor)
const asAlice = engine.withActor({ userId: 'alice', username: 'Alice' });
await asAlice.updateTask(taskId, { status: 'in_progress' }); // activity actorId: 'alice'
await asAlice.addComment({ taskId, content: 'On it' }); // authorId: 'alice'withActor returns a per-request view that shares the store, plugins and event bus with the base engine. On the view, every write is attributed to the actor: activity log entries, comment authors, reactions, time entries, attachment uploaders, and the default task reporterId. updateTask never reads identity from the update payload; trusted server-side automation can pass updateTask(id, updates, { actorId }) explicitly. Without a view, addComment, reactions and createAttachment accept an optional authorId / userId / uploaderId and fall back to 'system'. @critical-path/server runs every request through a view (the resolved user, or anonymous).
Other domain errors are exported for callers and route handlers to map: ValidationError (bad input, e.g. non-positive logged hours) and NotFoundError (missing referenced entity). engine.deleteProject(id) deletes a project and its tasks through deleteTask, then publishes project.deleted.
4. Tracking Creative Deliverables & Rollup Metrics
import { CriticalPathEngine, CREATIVE_DELIVERABLE_STATUSES } from '@critical-path/core';
const engine = new CriticalPathEngine();
// Creative statuses for this project's deliverables (the default set is planned / in_progress / in_review / delivered / canceled)
await engine.updateProject('proj_1', { deliverableStatusDefinitions: CREATIVE_DELIVERABLE_STATUSES });
// 1. Create deliverable; domain-specific details go in customFields
const deliverable = await engine.createDeliverable({
projectId: 'proj_1',
title: 'Brand Hero Video 30s',
customFields: { format: 'ProRes 422HQ', resolution: '3840x2160', fps: 24 }
});
// 2. Attach tasks to deliverable
const storyboard = await engine.createTask({
projectId: 'proj_1',
deliverableId: deliverable.id,
title: 'Storyboard & Animatic',
status: 'approved',
estimatedHours: 12,
progress: 100
});
// Logged hours come from time entries
await engine.logTime({ taskId: storyboard.id, hours: 12 });
// 3. Rollup metrics
const summary = await engine.getDeliverableSummary(deliverable.id);
console.log(`Progress: ${summary?.progressPercentage}%, Total tasks: ${summary?.totalTasks}`);5. Evaluating Implied Statuses & Dependency Readiness
// Evaluates task status category, upstream dependency states, schedules, assignees, and pace
const state = await engine.getTaskLifecycleState('task_123');
if (state?.isBlocked) {
console.warn(`Task blocked by upstream tasks: ${state.blockingTaskIds.join(', ')}`);
} else if (state?.isReady) {
console.log('All dependencies satisfied! Task is ready to start.');
}
if (state?.isOverEstimate) {
console.warn('Task logged hours have exceeded estimated hours!');
}
if (state?.isPaceWarning) {
console.warn('Task has been active longer than its estimated duration!');
}6. Task Metrics, Inferred Actuals & EVM
// Calculate comprehensive task metrics
const metrics = await engine.getTaskMetrics('task_123');
console.log('Inferred Actuals:', metrics?.inferredActuals);
// { actualStartDate: '...', isStartDateInferred: false, activeWorkingHours: 18.5 }
console.log('Progress Inference:', metrics?.progress);
// { progressPercentage: 80, source: 'todos', breakdown: { todoProgress: 80 } }
console.log('Earned Value Management:', metrics?.evm);
// { plannedValue: 20, earnedValue: 16, actualCost: 18, cpi: 0.89, spi: 0.8 }
// Reconstruct historical progress time-series and curve profile
const history = await engine.getTaskProgressHistory('task_123');
console.log('Curve Profile:', history?.curveProfile);
// 's_curve' | 'linear' | 'early_surge' | 'late_rush' | 'stalled'7. Headless Workload & Capacity Distribution (Streamgraphs)
// Calculate weekly capacity and workload distribution across assignees
const workload = await engine.getWorkloadDistribution('proj_123', {
interval: 'week',
groupBy: 'assignee',
metric: 'blended'
});
console.log('Series Keys (Assignee IDs):', workload.seriesKeys);
console.log('Contiguous Weekly Buckets:', workload.buckets);
// Each bucket contains zero-filled numeric values for all seriesKeys:
// {
// date: '2026-09-01',
// timestamp: 1788220800000,
// values: { alice: 32, bob: 18, unassigned: 0 },
// capacity: { alice: 40, bob: 40 },
// totalHours: 50,
// totalCapacity: 80,
// utilizationRatio: 0.625
// }8. Configuring Work Schedules, Working Hours & Holidays
import { CriticalPathEngine, type WorkSchedule, addWorkingHours } from '@critical-path/core';
// Define a project schedule with 4-day workweeks and holidays
const engineeringSchedule: WorkSchedule = {
timezone: 'UTC',
days: [
{ dayOfWeek: 0, isWorkingDay: false }, // Sunday
{ dayOfWeek: 1, isWorkingDay: true, hours: [{ start: '09:00', end: '17:00' }] }, // Monday
{ dayOfWeek: 2, isWorkingDay: true, hours: [{ start: '09:00', end: '17:00' }] }, // Tuesday
{ dayOfWeek: 3, isWorkingDay: true, hours: [{ start: '09:00', end: '17:00' }] }, // Wednesday
{ dayOfWeek: 4, isWorkingDay: true, hours: [{ start: '09:00', end: '17:00' }] }, // Thursday
{ dayOfWeek: 5, isWorkingDay: false }, // Friday
{ dayOfWeek: 6, isWorkingDay: false } // Saturday
],
holidays: [
{ date: '2026-12-25', name: 'Christmas Day' },
{ date: '2026-12-26', name: 'Boxing Day' }
]
};
const engine = new CriticalPathEngine({
defaultSchedule: engineeringSchedule
});
// CPM automatically rolls over non-working days and holidays
const cpm = await engine.calculateCriticalPath('proj_123', {
projectStartDate: '2026-09-01T09:00:00Z',
schedule: engineeringSchedule
});
console.log('Project End Date (skipping weekends & holidays):', cpm.projectEndDate);
console.log('Total Working Hours on Critical Path:', cpm.totalWorkingHours);
// Schedule each task on its assignee's (or team's) calendar instead, using the `users` directory
const byAssignee = await engine.calculateCriticalPath('proj_123', { calendars: 'assignee' });
// ...and limit each assignee to one task at a time (resource levelling)
const leveled = await engine.calculateCriticalPath('proj_123', { calendars: 'assignee', levelResources: true });
console.log(leveled.unleveledProjectEndDate, '->', leveled.projectEndDate);With calendars: 'assignee' (or criticalPathCalendars: 'assignee' on the engine), each task uses its assignee's schedule, then its team's, then the project calendar. Passes run on dates, slack is measured in each task's calendar, and each calendar is evaluated in its own timezone (IANA name; default UTC), including daylight-saving changes. levelResources: true additionally delays tasks so nobody works on two at once (priority rule levelingPriority, default least slack), reporting levelingDelayHours and waitingOn per task. Tasks with allocation below 1 take that share of their assignee (a 16h task at 0.5 spans 32 working hours); levelling fits them within 100% per person, and overallocations lists who is booked above 100% and when. Teams act as pools of headcount people (default: member count) shared by team-only tasks and members' own tasks. Finished tasks take no time. calculatePortfolioCriticalPath({ projectIds }) levels several projects together, sharing people and teams across them; includeHiddenWork also counts unreadable projects' work as 'hidden'. effort: 'remaining' schedules only what is left on in-progress tasks.
Save the plan with engine.createBaseline(projectId, { name }) and pass baselineId to later analyses to see how far each task and the project end moved. Tasks with subtasks are summary tasks, reported as roll-ups of their leaves (isSummary); dependencies and deadlines on them apply to every leaf. Unestimated tasks use defaultEstimateHours (default 1) and are listed in unestimatedTaskIds. Tasks with isMilestone: true are zero-duration checkpoints (their estimates are ignored). A deadline on any task caps its late finish: a forecast that misses it shows as negative slack along the path driving it, with deadlineDate and deadlineVarianceHours per task and missedDeadlineTaskIds on the analysis. A date-only deadline means the end of that working day. earliestStart (start no earlier than) and dependency lagHours (wait after the predecessor; negative for a lead) constrain the forward pass. Project-calendar mode needs a start date to apply these date constraints.
9. Fractional Lexical Indexing (Kanban Reordering)
import { generateKeyBetween, generateNKeysBetween } from '@critical-path/core';
// Insert an item between two adjacent items without shifting arrays:
const firstItemOrder = 'a0';
const secondItemOrder = 'a1';
const newItemOrder = generateKeyBetween(firstItemOrder, secondItemOrder);
// -> 'a0V' (lexicographically between 'a0' and 'a1')
// Generate keys for multiple items inserted at once:
const newKeys = generateNKeysBetween(firstItemOrder, secondItemOrder, 3);10. Code-Immune Mention & Reference Extraction
import { extractMentions } from '@critical-path/core';
const markdown = `
Check with @alice and agent @gemini-bot.
Ignore \`@not_a_user\` in inline code and code blocks:
\`\`\`ts
const query = "@ignored";
\`\`\`
`;
const mentions = extractMentions(markdown);
// -> ['alice', 'gemini-bot']🔌 Creating a Custom Plugin
import type { CriticalPathPlugin } from '@critical-path/core';
export const auditPlugin: CriticalPathPlugin = {
id: 'audit-logger',
name: 'Audit Logger',
version: '1.0.0',
hooks: {
beforeTaskCreate: (task) => {
return { ...task, tags: [...(task.tags || []), 'AUDITED'] };
}
}
};Plugins can also contribute init(engine) (awaited via engine.ready), customFieldTypes (validated custom field types for project customFieldDefinitions), and HTTP routes / middleware served by @critical-path/server (routes appear in /openapi.json, described by their optional openapi metadata). Before-hook output is validated like caller input, and after-hook errors are logged instead of failing the write.
