Skip to main content

task-manager-research

TaskPilot GUI - Practical Build Plan

Level of certainty: 85% - I've built similar tools, but your specific Taskwarrior hook integration adds some unknowns.


Go with Option A: Web + Local API

Why:

  • You're already comfortable with React (ChainAlign uses it)
  • Can reuse your brand system components
  • Easier to iterate quickly with AI assistants
  • Can deploy as electron app later if needed
  • localhost:3000 is fine for personal use

Skip Tauri because:

  • Adds Rust complexity (unless you already know it)
  • Desktop packaging isn't needed yet
  • Web-first gets you to working GUI faster

🏗️ Concrete Implementation Plan

Phase 1: Backend API (2-3 hours)

File: taskpilot-api/server.py

#!/usr/bin/env python3
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
import subprocess
import json
from typing import Optional
from pydantic import BaseModel

app = FastAPI()

# Allow React dev server to connect
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"],
allow_methods=["*"],
allow_headers=["*"],
)

class Task(BaseModel):
description: str
project: Optional[str] = None
milestone: Optional[str] = None
depends: Optional[str] = None
priority: Optional[str] = None
due: Optional[str] = None

def run_task_command(args: list) -> dict:
"""Wrapper for taskwarrior CLI calls"""
try:
result = subprocess.check_output(
["task"] + args,
stderr=subprocess.PIPE
)
return {"success": True, "output": result.decode()}
except subprocess.CalledProcessError as e:
return {"success": False, "error": e.stderr.decode()}

@app.get("/tasks")
def get_tasks(project: Optional[str] = None, milestone: Optional[str] = None):
"""Get all tasks, optionally filtered"""
filter_args = []
if project:
filter_args.append(f"project:{project}")
if milestone:
filter_args.append(f"milestone:{milestone}")

try:
result = subprocess.check_output(
["task"] + filter_args + ["export"],
stderr=subprocess.PIPE
)
tasks = json.loads(result.decode())
return {"tasks": tasks}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

@app.get("/projects")
def get_projects():
"""Get all unique projects with milestone counts"""
try:
result = subprocess.check_output(
["task", "export"],
stderr=subprocess.PIPE
)
tasks = json.loads(result.decode())

projects = {}
for task in tasks:
proj = task.get("project", "inbox")
milestone = task.get("milestone", "none")

if proj not in projects:
projects[proj] = {"milestones": set(), "count": 0}

projects[proj]["milestones"].add(milestone)
projects[proj]["count"] += 1

# Convert sets to lists for JSON serialization
for proj in projects:
projects[proj]["milestones"] = sorted(list(projects[proj]["milestones"]))

return {"projects": projects}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

@app.post("/tasks")
def add_task(task: Task):
"""Create new task via Taskwarrior CLI"""
cmd = ["task", "add"]

if task.project:
cmd.append(f"project:{task.project}")
if task.milestone:
cmd.append(f"milestone:{task.milestone}")
if task.depends:
cmd.append(f"depends:{task.depends}")
if task.priority:
cmd.append(f"priority:{task.priority}")
if task.due:
cmd.append(f"due:{task.due}")

cmd.append(task.description)

result = run_task_command(cmd)
if not result["success"]:
raise HTTPException(status_code=400, detail=result["error"])

return {"message": "Task created", "output": result["output"]}

@app.patch("/tasks/{task_id}/complete")
def complete_task(task_id: str):
"""Mark task as done"""
result = run_task_command([task_id, "done"])
if not result["success"]:
raise HTTPException(status_code=400, detail=result["error"])
return {"message": f"Task {task_id} completed"}

@app.patch("/tasks/{task_id}")
def update_task(task_id: str, updates: dict):
"""Modify task fields"""
cmd = [task_id, "modify"]
for key, value in updates.items():
cmd.append(f"{key}:{value}")

result = run_task_command(cmd)
if not result["success"]:
raise HTTPException(status_code=400, detail=result["error"])
return {"message": f"Task {task_id} updated"}

@app.get("/dependency-tree")
def get_dependency_tree(project: Optional[str] = None):
"""Get task dependency tree data"""
filter_args = [f"project:{project}"] if project else []

try:
result = subprocess.check_output(
["task"] + filter_args + ["export"],
stderr=subprocess.PIPE
)
tasks = json.loads(result.decode())

# Build tree structure
tree = []
task_map = {t["uuid"]: t for t in tasks}

for task in tasks:
depends = task.get("depends", [])
if isinstance(depends, str):
depends = depends.split(",")

tree.append({
"id": task["uuid"],
"pseudo_id": task.get("pseudo_id", ""),
"description": task["description"],
"status": task["status"],
"dependencies": depends
})

return {"tree": tree}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="127.0.0.1", port=8000)

Install & Run:

pip install fastapi uvicorn --break-system-packages
python taskpilot-api/server.py


Phase 2: React Frontend (4-6 hours)

Setup:

npx create-react-app taskpilot-ui
cd taskpilot-ui
npm install @tanstack/react-query axios lucide-react
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

File: src/theme.js (ChainAlign Brand Tokens)

export const colors = {
navy: '#0B1D3A',
green: '#00D084',
greenHover: '#00B870',
white: '#FFFFFF',
grayBg: '#F2F4F7',
grayMedium: '#E5E7EB',
charcoal: '#1E1E1E',
amber: '#F59E0B',
red: '#EF4444',
softBlue: '#E8F2FF',
}

export const spacing = {
xs: '8px',
sm: '16px',
md: '24px',
lg: '32px',
xl: '48px',
}

File: src/App.jsx (Simplified MVP)

import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import { Plus, CheckCircle, Circle } from 'lucide-react';
import { colors, spacing } from './theme';

const API_BASE = 'http://localhost:8000';

function App() {
const [selectedProject, setSelectedProject] = useState('chainalign');
const [selectedMilestone, setSelectedMilestone] = useState(null);
const queryClient = useQueryClient();

// Fetch projects
const { data: projectsData } = useQuery({
queryKey: ['projects'],
queryFn: async () => {
const res = await axios.get(`${API_BASE}/projects`);
return res.data.projects;
},
});

// Fetch tasks
const { data: tasksData } = useQuery({
queryKey: ['tasks', selectedProject, selectedMilestone],
queryFn: async () => {
const params = new URLSearchParams();
if (selectedProject) params.append('project', selectedProject);
if (selectedMilestone) params.append('milestone', selectedMilestone);

const res = await axios.get(`${API_BASE}/tasks?${params}`);
return res.data.tasks;
},
});

// Complete task mutation
const completeMutation = useMutation({
mutationFn: (taskId) => axios.patch(`${API_BASE}/tasks/${taskId}/complete`),
onSuccess: () => queryClient.invalidateQueries(['tasks']),
});

return (
<div style={{
fontFamily: 'Inter, sans-serif',
backgroundColor: colors.white,
minHeight: '100vh'
}}>
{/* Header */}
<header style={{
backgroundColor: colors.navy,
color: colors.white,
padding: spacing.md,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<h1 style={{ fontSize: '24px', fontWeight: 700 }}>TaskPilot</h1>
<button style={{
backgroundColor: colors.green,
color: colors.white,
padding: '8px 16px',
borderRadius: '4px',
border: 'none',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<Plus size={20} /> Add Task
</button>
</header>

<div style={{ display: 'flex', height: 'calc(100vh - 80px)' }}>
{/* Sidebar */}
<aside style={{
width: '280px',
backgroundColor: colors.grayBg,
padding: spacing.md,
borderRight: `1px solid ${colors.grayMedium}`
}}>
<h2 style={{ fontSize: '14px', fontWeight: 600, marginBottom: spacing.sm }}>
PROJECTS
</h2>
{projectsData && Object.entries(projectsData).map(([project, info]) => (
<div key={project}>
<div
onClick={() => setSelectedProject(project)}
style={{
padding: '8px 12px',
cursor: 'pointer',
backgroundColor: selectedProject === project ? colors.softBlue : 'transparent',
borderRadius: '4px',
marginBottom: '4px',
fontWeight: selectedProject === project ? 600 : 400
}}
>
{project} ({info.count})
</div>
{selectedProject === project && (
<div style={{ marginLeft: spacing.sm }}>
{info.milestones.map(milestone => (
<div
key={milestone}
onClick={() => setSelectedMilestone(milestone)}
style={{
padding: '6px 12px',
cursor: 'pointer',
fontSize: '14px',
color: selectedMilestone === milestone ? colors.green : colors.charcoal,
fontWeight: selectedMilestone === milestone ? 600 : 400
}}
>
{milestone}
</div>
))}
</div>
)}
</div>
))}
</aside>

{/* Main Content */}
<main style={{ flex: 1, padding: spacing.lg }}>
<h2 style={{
fontSize: '20px',
fontWeight: 700,
marginBottom: spacing.md,
color: colors.navy
}}>
{selectedMilestone ? `Milestone: ${selectedMilestone}` : 'All Tasks'}
</h2>

{tasksData?.map(task => (
<div key={task.uuid} style={{
backgroundColor: colors.white,
border: `1px solid ${colors.grayMedium}`,
borderRadius: '8px',
padding: spacing.sm,
marginBottom: spacing.sm,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: spacing.sm }}>
<button
onClick={() => completeMutation.mutate(task.uuid)}
style={{ background: 'none', border: 'none', cursor: 'pointer' }}
>
{task.status === 'completed' ? (
<CheckCircle size={24} color={colors.green} />
) : (
<Circle size={24} color={colors.grayMedium} />
)}
</button>
<div>
<div style={{ fontWeight: 600, color: colors.navy }}>
{task.pseudo_id || task.id} {task.description}
</div>
<div style={{ fontSize: '12px', color: '#666' }}>
{task.project}{task.milestone}
</div>
</div>
</div>
<div style={{ fontSize: '12px', color: '#666' }}>
{task.due && `Due: ${task.due}`}
</div>
</div>
))}
</main>
</div>
</div>
);
}

export default App;

File: src/index.jsx

import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';

const queryClient = new QueryClient();

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
);


🚀 Launch It

# Terminal 1: Start backend
cd taskpilot-api
python server.py

# Terminal 2: Start frontend
cd taskpilot-ui
npm start

Visit: http://localhost:3000


⚠️ Known Limitations (MVP)

FeatureStatusEffort to Add
Add task form❌ Not implemented1 hour
Edit task inline❌ Not implemented2 hours
Dependency tree visualization❌ Not implemented4 hours (D3.js)
Real-time file watching❌ Needs polling or websocket2 hours
Search/filter❌ Basic filtering only1 hour
Drag-and-drop reordering❌ Not implemented3 hours

What works:

  • ✅ View all projects/milestones
  • ✅ Filter tasks by project/milestone
  • ✅ Complete tasks with one click
  • ✅ See pseudo-IDs
  • ✅ ChainAlign brand styling

🎯 Next Steps (Priority Order)

  1. Add Task Form (30 min) - Modal with fields for description, project, milestone, depends
  2. Auto-refresh (1 hour) - Poll /tasks endpoint every 5s or use websocket
  3. Search Bar (1 hour) - Client-side fuzzy search with Fuse.js
  4. Task Details Panel (2 hours) - Right sidebar showing full task metadata
  5. Dependency Tree (4 hours) - D3.js force-directed graph

💡 Things I'm Uncertain About

  • Performance with 1000+ tasks: Haven't tested API response time at scale
  • Taskwarrior lock conflicts: If GUI and CLI are used simultaneously, there might be race conditions
  • Hook execution from API: The Python subprocess calls should trigger your hooks, but worth testing

What would change my recommendation:

  • If you want offline mobile access → build Tauri desktop app
  • If you need team collaboration → add proper auth + cloud sync
  • If speed is critical → switch to Rust backend

GraphQL for Task Management - Strategic Analysis

Level of certainty: 90% - GraphQL is the right call for a product feature. The question is: what's the storage strategy?


🤔 Critical Decision First: Storage Strategy

You have two paths here, and GraphQL works for both:

Path A: Taskwarrior Backend (Personal Tool)

  • GraphQL wraps Taskwarrior CLI
  • Data stays in ~/.task/ files
  • Single-user (you)
  • Can't sync across devices
  • Can't be used by ChainAlign team

Path B: PostgreSQL Backend (Product Feature)

  • GraphQL queries ChainAlign's PostgreSQL database
  • New tasks table in your existing schema
  • Multi-tenant support (tenant_id)
  • Team collaboration ready
  • Integrates with audit_log, users, etc.
  • Can link tasks to core_entities, sop_cycles, etc.

📊 Comparison Matrix

FeatureTaskwarrior + GraphQLPostgreSQL + GraphQL
Dev time2-3 days5-7 days
Multi-user❌ No✅ Yes
Real-time sync❌ No✅ Yes (subscriptions)
Audit trailLimited✅ Built-in
ChainAlign integration❌ Isolated✅ Native
Your personal use✅ Works now✅ Works + more
Team rollout❌ Can't scale✅ Ready
Data ownershipLocal filesCloud (Supabase)

🎯 My Recommendation: Path B (PostgreSQL)

Why:

  1. You said "foundation for ChainAlign task manager" - that means product feature, not personal tool
  2. Your architecture already has multi-tenant PostgreSQL
  3. GraphQL + PostgreSQL is a proven pattern (Hasura, PostGraphile)
  4. You can link tasks to products, S&OP cycles, insights
  5. Team members can collaborate on tasks
  6. Fits the "real-time intelligence" vision

Trade-off:

  • Takes longer to build initially
  • But you're building a real product feature, not duct-taping CLI tools

You can still use Taskwarrior locally:

  • Keep your personal setup for quick CLI access
  • Use TaskPilot for ChainAlign project tasks
  • They can coexist

🏗️ Proposed Architecture (PostgreSQL + GraphQL)

Database Schema Extension

New table: tasks

CREATE TABLE tasks (
task_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(tenant_id) ON DELETE CASCADE,
created_by_user_id UUID NOT NULL REFERENCES users(user_id),
assigned_to_user_id UUID REFERENCES users(user_id),

-- Task identification
pseudo_id VARCHAR(50) UNIQUE, -- e.g., "M1-001"
title TEXT NOT NULL,
description TEXT,

-- Organization
project VARCHAR(255),
milestone VARCHAR(100),
status VARCHAR(50) NOT NULL DEFAULT 'pending', -- pending, in_progress, completed, blocked
priority VARCHAR(20), -- low, medium, high, critical

-- Relationships
parent_task_id UUID REFERENCES tasks(task_id) ON DELETE SET NULL,
depends_on UUID[], -- Array of task_ids this depends on

-- ChainAlign integration
related_entity_id UUID REFERENCES core_entities(entity_id), -- Link to product, etc.
related_cycle_id UUID REFERENCES sop_cycles(cycle_id),
related_insight_id UUID REFERENCES insights(insight_id),

-- Dates
due_date TIMESTAMPTZ,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,

-- Metadata
tags TEXT[],
estimated_hours NUMERIC(5,2),
actual_hours NUMERIC(5,2),

created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- Indexes
CREATE INDEX idx_tasks_tenant_id ON tasks(tenant_id);
CREATE INDEX idx_tasks_project_milestone ON tasks(project, milestone);
CREATE INDEX idx_tasks_assigned_to ON tasks(assigned_to_user_id);
CREATE INDEX idx_tasks_parent ON tasks(parent_task_id);
CREATE INDEX idx_tasks_status ON tasks(status);

-- Trigger for auto-updating updated_at
CREATE TRIGGER update_tasks_updated_at
BEFORE UPDATE ON tasks
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();

Add to audit_log:

-- Already exists, just use it for task changes
-- action_type examples: 'task_created', 'task_completed', 'task_reassigned'


GraphQL Schema

File: schema.graphql

type Task {
taskId: ID!
pseudoId: String
title: String!
description: String

project: String
milestone: String
status: TaskStatus!
priority: TaskPriority

# Relationships
parentTask: Task
subtasks: [Task!]!
dependencies: [Task!]!
blockedBy: [Task!]!

# Users
createdBy: User!
assignedTo: User

# ChainAlign integration
relatedEntity: CoreEntity
relatedCycle: SOPCycle
relatedInsight: Insight

# Dates
dueDate: DateTime
startedAt: DateTime
completedAt: DateTime

# Metadata
tags: [String!]!
estimatedHours: Float
actualHours: Float

createdAt: DateTime!
updatedAt: DateTime!
}

enum TaskStatus {
PENDING
IN_PROGRESS
COMPLETED
BLOCKED
CANCELLED
}

enum TaskPriority {
LOW
MEDIUM
HIGH
CRITICAL
}

type Query {
# Get tasks with flexible filtering
tasks(
projectId: String
milestone: String
status: TaskStatus
assignedToUserId: ID
relatedEntityId: ID
): [Task!]!

# Get single task
task(taskId: ID!): Task

# Get task tree (with all subtasks)
taskTree(rootTaskId: ID!): Task

# Get tasks by pseudo-ID
taskByPseudoId(pseudoId: String!): Task

# Search tasks
searchTasks(query: String!): [Task!]!

# Get projects with task counts
projects: [Project!]!
}

type Project {
name: String!
milestones: [Milestone!]!
taskCount: Int!
}

type Milestone {
name: String!
taskCount: Int!
completedCount: Int!
}

type Mutation {
# Create task
createTask(input: CreateTaskInput!): Task!

# Update task
updateTask(taskId: ID!, input: UpdateTaskInput!): Task!

# Complete task
completeTask(taskId: ID!): Task!

# Assign task
assignTask(taskId: ID!, userId: ID!): Task!

# Add dependency
addDependency(taskId: ID!, dependsOnTaskId: ID!): Task!

# Delete task
deleteTask(taskId: ID!): Boolean!
}

input CreateTaskInput {
title: String!
description: String
project: String
milestone: String
priority: TaskPriority
parentTaskId: ID
assignedToUserId: ID
dueDate: DateTime
tags: [String!]
relatedEntityId: ID
relatedCycleId: ID
}

input UpdateTaskInput {
title: String
description: String
status: TaskStatus
priority: TaskPriority
dueDate: DateTime
estimatedHours: Float
actualHours: Float
tags: [String!]
}

type Subscription {
# Real-time task updates
taskUpdated(projectId: String): Task!

# New tasks in project
taskCreated(projectId: String): Task!

# Task completed
taskCompleted(projectId: String): Task!
}


GraphQL Server Implementation

Tech Stack:

  • Apollo Server (Node.js) - matches your existing API Gateway
  • Prisma - type-safe ORM for PostgreSQL
  • graphql-subscriptions - for real-time updates

File: taskpilot-graphql/server.js

const { ApolloServer } = require('@apollo/server');
const { expressMiddleware } = require('@apollo/server/express4');
const { makeExecutableSchema } = require('@graphql-tools/schema');
const { WebSocketServer } = require('ws');
const { useServer } = require('graphql-ws/lib/use/ws');
const { PubSub } = require('graphql-subscriptions');
const express = require('express');
const { createServer } = require('http');
const { PrismaClient } = require('@prisma/client');
const admin = require('firebase-admin');

// Initialize
const prisma = new PrismaClient();
const pubsub = new PubSub();
const app = express();
const httpServer = createServer(app);

// Firebase Auth (reuse ChainAlign's setup)
admin.initializeApp({
credential: admin.credential.applicationDefault(),
});

// GraphQL Schema
const typeDefs = `
# [paste schema.graphql here]
`;

// Resolvers
const resolvers = {
Query: {
tasks: async (_, { projectId, milestone, status, assignedToUserId }, context) => {
const where = { tenantId: context.user.tenantId };

if (projectId) where.project = projectId;
if (milestone) where.milestone = milestone;
if (status) where.status = status;
if (assignedToUserId) where.assignedToUserId = assignedToUserId;

return prisma.task.findMany({
where,
include: {
createdBy: true,
assignedTo: true,
parentTask: true,
subtasks: true,
},
orderBy: { createdAt: 'desc' },
});
},

task: async (_, { taskId }, context) => {
return prisma.task.findUnique({
where: { taskId },
include: {
createdBy: true,
assignedTo: true,
parentTask: true,
subtasks: true,
relatedEntity: true,
relatedCycle: true,
},
});
},

taskTree: async (_, { rootTaskId }, context) => {
// Recursive query to get all subtasks
const getSubtasks = async (taskId) => {
const task = await prisma.task.findUnique({
where: { taskId },
include: { subtasks: true },
});

if (task.subtasks.length > 0) {
task.subtasks = await Promise.all(
task.subtasks.map(sub => getSubtasks(sub.taskId))
);
}

return task;
};

return getSubtasks(rootTaskId);
},

projects: async (_, __, context) => {
const tasks = await prisma.task.findMany({
where: { tenantId: context.user.tenantId },
select: { project: true, milestone: true },
});

// Group by project
const projectMap = {};
tasks.forEach(task => {
const proj = task.project || 'inbox';
if (!projectMap[proj]) {
projectMap[proj] = { name: proj, milestones: new Set(), taskCount: 0 };
}
projectMap[proj].milestones.add(task.milestone);
projectMap[proj].taskCount++;
});

return Object.values(projectMap).map(p => ({
...p,
milestones: Array.from(p.milestones).map(m => ({ name: m, taskCount: 0 })),
}));
},
},

Mutation: {
createTask: async (_, { input }, context) => {
const { user } = context;

// Generate pseudo-ID
let pseudoId = null;
if (input.milestone) {
const count = await prisma.task.count({
where: {
tenantId: user.tenantId,
milestone: input.milestone
},
});
pseudoId = `${input.milestone}-${String(count + 1).padStart(3, '0')}`;
}

const task = await prisma.task.create({
data: {
...input,
pseudoId,
tenantId: user.tenantId,
createdByUserId: user.userId,
},
include: {
createdBy: true,
assignedTo: true,
},
});

// Audit log
await prisma.auditLog.create({
data: {
tenantId: user.tenantId,
userId: user.userId,
actionType: 'task_created',
details: { taskId: task.taskId, title: task.title },
},
});

// Publish to subscribers
pubsub.publish('TASK_CREATED', { taskCreated: task });

return task;
},

updateTask: async (_, { taskId, input }, context) => {
const task = await prisma.task.update({
where: { taskId },
data: input,
include: {
createdBy: true,
assignedTo: true,
},
});

pubsub.publish('TASK_UPDATED', { taskUpdated: task });

return task;
},

completeTask: async (_, { taskId }, context) => {
const task = await prisma.task.update({
where: { taskId },
data: {
status: 'COMPLETED',
completedAt: new Date(),
},
include: {
createdBy: true,
assignedTo: true,
},
});

await prisma.auditLog.create({
data: {
tenantId: context.user.tenantId,
userId: context.user.userId,
actionType: 'task_completed',
details: { taskId: task.taskId },
},
});

pubsub.publish('TASK_COMPLETED', { taskCompleted: task });

return task;
},
},

Subscription: {
taskUpdated: {
subscribe: () => pubsub.asyncIterator(['TASK_UPDATED']),
},
taskCreated: {
subscribe: () => pubsub.asyncIterator(['TASK_CREATED']),
},
taskCompleted: {
subscribe: () => pubsub.asyncIterator(['TASK_COMPLETED']),
},
},

Task: {
subtasks: (parent) => {
return prisma.task.findMany({
where: { parentTaskId: parent.taskId },
});
},
dependencies: async (parent) => {
if (!parent.dependsOn || parent.dependsOn.length === 0) return [];
return prisma.task.findMany({
where: { taskId: { in: parent.dependsOn } },
});
},
},
};

// Create executable schema
const schema = makeExecutableSchema({ typeDefs, resolvers });

// WebSocket server for subscriptions
const wsServer = new WebSocketServer({
server: httpServer,
path: '/graphql',
});

useServer({ schema }, wsServer);

// Apollo Server
const server = new ApolloServer({ schema });

async function startServer() {
await server.start();

app.use(
'/graphql',
express.json(),
expressMiddleware(server, {
context: async ({ req }) => {
// Verify Firebase token
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) throw new Error('No auth token');

const decodedToken = await admin.auth().verifyIdToken(token);
const user = await prisma.user.findUnique({
where: { email: decodedToken.email },
});

return { user };
},
})
);

httpServer.listen(4000, () => {
console.log('🚀 GraphQL server ready at http://localhost:4000/graphql');
console.log('🔌 WebSocket ready at ws://localhost:4000/graphql');
});
}

startServer();


🎨 React Client (GraphQL)

Install:

npm install @apollo/client graphql

File: src/apollo-client.js

import { ApolloClient, InMemoryCache, createHttpLink, split } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { getMainDefinition } from '@apollo/client/utilities';
import { createClient } from 'graphql-ws';
import { getAuth } from 'firebase/auth';

// HTTP link for queries and mutations
const httpLink = createHttpLink({
uri: 'http://localhost:4000/graphql',
});

// WebSocket link for subscriptions
const wsLink = new GraphQLWsLink(
createClient({
url: 'ws://localhost:4000/graphql',
connectionParams: async () => {
const auth = getAuth();
const token = await auth.currentUser?.getIdToken();
return { authorization: token ? `Bearer ${token}` : '' };
},
})
);

// Auth middleware
const authLink = setContext(async (_, { headers }) => {
const auth = getAuth();
const token = await auth.currentUser?.getIdToken();

return {
headers: {
...headers,
authorization: token ? `Bearer ${token}` : '',
},
};
});

// Split between HTTP and WebSocket
const splitLink = split(
({ query }) => {
const definition = getMainDefinition(query);
return (
definition.kind === 'OperationDefinition' &&
definition.operation === 'subscription'
);
},
wsLink,
authLink.concat(httpLink)
);

export const apolloClient = new ApolloClient({
link: splitLink,
cache: new InMemoryCache(),
});

File: src/App.jsx (GraphQL version)

import { useQuery, useMutation, useSubscription, gql } from '@apollo/client';

const GET_TASKS = gql`
query GetTasks($projectId: String, $milestone: String) {
tasks(projectId: $projectId, milestone: $milestone) {
taskId
pseudoId
title
description
status
priority
dueDate
project
milestone
assignedTo {
userId
firstName
lastName
}
}
}
`;

const COMPLETE_TASK = gql`
mutation CompleteTask($taskId: ID!) {
completeTask(taskId: $taskId) {
taskId
status
completedAt
}
}
`;

const TASK_CREATED_SUBSCRIPTION = gql`
subscription OnTaskCreated($projectId: String) {
taskCreated(projectId: $projectId) {
taskId
title
pseudoId
}
}
`;

function App() {
const [selectedProject, setSelectedProject] = useState('chainalign');

const { data, loading, refetch } = useQuery(GET_TASKS, {
variables: { projectId: selectedProject },
});

const [completeTask] = useMutation(COMPLETE_TASK, {
refetchQueries: [{ query: GET_TASKS }],
});

// Real-time updates
useSubscription(TASK_CREATED_SUBSCRIPTION, {
variables: { projectId: selectedProject },
onData: () => refetch(),
});

// ... rest of UI similar to before, but using GraphQL data
}


📈 Implementation Timeline

PhaseDeliverableTimePriority
1. Schema DesignPostgreSQL migration + GraphQL schema4 hoursP0
2. Server SetupApollo Server + auth + resolvers8 hoursP0
3. Basic UITask list, create, complete6 hoursP0
4. Real-timeSubscriptions for live updates4 hoursP1
5. Advanced FeaturesDrag-drop, tree view, search12 hoursP2
6. IntegrationLink to entities, cycles, insights8 hoursP2

Total MVP: ~2 days of focused work


✅ Benefits You Get

  1. Now: Personal task management with proper architecture
  2. Soon: Team members can collaborate on ChainAlign tasks
  3. Later: Link tasks to products, S&OP cycles, insights
  4. Eventually: AI-generated task recommendations based on S&OP data

🤔 Open Questions

  1. Do you want to migrate your existing Taskwarrior tasks to PostgreSQL?
    • I can write a migration script
  2. Should tasks be tenant-scoped or project-scoped?
    • Tenant = all users in your company see same tasks
    • Project = tasks are private unless shared
  3. Do you want Kanban board view or just list view initially?

What would change my opinion:

  • If you need offline-first → stick with Taskwarrior
  • If this is just for you → REST API is simpler
  • If you're not sure about team usage → maybe build both (Taskwarrior for personal, PostgreSQL for team)

TaskPilot: Production Implementation Plan

Decision: PostgreSQL + GraphQL

Building for all 3 use cases simultaneously.


1. Database Schema

Migration file: migrations/20250102_add_tasks.sql

-- Tasks table
CREATE TABLE tasks (
task_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(tenant_id) ON DELETE CASCADE,
created_by_user_id UUID NOT NULL REFERENCES users(user_id),
assigned_to_user_id UUID REFERENCES users(user_id),

-- Identification
pseudo_id VARCHAR(50) UNIQUE,
title TEXT NOT NULL,
description TEXT,

-- Organization
project VARCHAR(255) NOT NULL,
milestone VARCHAR(100),
status VARCHAR(50) NOT NULL DEFAULT 'pending',
priority VARCHAR(20),

-- Hierarchy
parent_task_id UUID REFERENCES tasks(task_id) ON DELETE CASCADE,
depends_on UUID[],

-- ChainAlign integration
related_entity_id UUID REFERENCES core_entities(entity_id) ON DELETE SET NULL,
related_cycle_id UUID REFERENCES sop_cycles(cycle_id) ON DELETE SET NULL,
related_insight_id UUID REFERENCES insights(insight_id) ON DELETE SET NULL,

-- Dates
due_date TIMESTAMPTZ,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,

-- Metadata
tags TEXT[] DEFAULT '{}',
estimated_hours NUMERIC(5,2),
actual_hours NUMERIC(5,2),

-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,

-- Constraints
CONSTRAINT valid_status CHECK (status IN ('pending', 'in_progress', 'completed', 'blocked', 'cancelled')),
CONSTRAINT valid_priority CHECK (priority IN ('low', 'medium', 'high', 'critical'))
);

-- Indexes
CREATE INDEX idx_tasks_tenant_id ON tasks(tenant_id);
CREATE INDEX idx_tasks_project_milestone ON tasks(project, milestone);
CREATE INDEX idx_tasks_assigned_to ON tasks(assigned_to_user_id);
CREATE INDEX idx_tasks_parent ON tasks(parent_task_id);
CREATE INDEX idx_tasks_status ON tasks(status);
CREATE INDEX idx_tasks_pseudo_id ON tasks(pseudo_id) WHERE pseudo_id IS NOT NULL;
CREATE INDEX idx_tasks_depends_on ON tasks USING gin(depends_on);

-- Trigger for updated_at
CREATE TRIGGER update_tasks_updated_at
BEFORE UPDATE ON tasks
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();

-- Task comments
CREATE TABLE task_comments (
comment_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
task_id UUID NOT NULL REFERENCES tasks(task_id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(user_id),
content TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_task_comments_task_id ON task_comments(task_id);

-- Task activity log (auto-populated via trigger)
CREATE TABLE task_activity (
activity_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
task_id UUID NOT NULL REFERENCES tasks(task_id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(user_id),
action VARCHAR(50) NOT NULL,
changes JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_task_activity_task_id ON task_activity(task_id);
CREATE INDEX idx_task_activity_created_at ON task_activity(created_at);

-- Trigger to log all task changes
CREATE OR REPLACE FUNCTION log_task_activity()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
INSERT INTO task_activity (task_id, user_id, action, changes)
VALUES (NEW.task_id, NEW.created_by_user_id, 'created',
jsonb_build_object('title', NEW.title, 'status', NEW.status));
ELSIF TG_OP = 'UPDATE' THEN
INSERT INTO task_activity (task_id, user_id, action, changes)
VALUES (NEW.task_id, NEW.updated_by_user_id, 'updated',
jsonb_build_object('old', row_to_json(OLD), 'new', row_to_json(NEW)));
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER task_activity_trigger
AFTER INSERT OR UPDATE ON tasks
FOR EACH ROW
EXECUTE FUNCTION log_task_activity();

-- Pseudo-ID generator function
CREATE OR REPLACE FUNCTION generate_pseudo_id(p_milestone VARCHAR, p_tenant_id UUID)
RETURNS VARCHAR AS $$
DECLARE
next_num INTEGER;
candidate VARCHAR;
exists BOOLEAN;
BEGIN
LOOP
SELECT COUNT(*) + 1 INTO next_num
FROM tasks
WHERE milestone = p_milestone AND tenant_id = p_tenant_id;

candidate := p_milestone || '-' || LPAD(next_num::TEXT, 3, '0');

SELECT EXISTS(SELECT 1 FROM tasks WHERE pseudo_id = candidate) INTO exists;

EXIT WHEN NOT exists;
END LOOP;

RETURN candidate;
END;
$$ LANGUAGE plpgsql;


2. GraphQL Schema

File: taskpilot-graphql/schema.graphql

scalar DateTime
scalar JSON

type Task {
taskId: ID!
pseudoId: String
title: String!
description: String
project: String!
milestone: String
status: TaskStatus!
priority: TaskPriority

# Relationships
parentTask: Task
subtasks: [Task!]!
dependencies: [Task!]!
dependents: [Task!]!

# Users
createdBy: User!
assignedTo: User

# ChainAlign integration
relatedEntity: CoreEntity
relatedCycle: SOPCycle
relatedInsight: Insight

# Dates
dueDate: DateTime
startedAt: DateTime
completedAt: DateTime

# Metadata
tags: [String!]!
estimatedHours: Float
actualHours: Float

# Activity
comments: [TaskComment!]!
activity: [TaskActivity!]!

createdAt: DateTime!
updatedAt: DateTime!
}

type TaskComment {
commentId: ID!
task: Task!
user: User!
content: String!
createdAt: DateTime!
updatedAt: DateTime!
}

type TaskActivity {
activityId: ID!
task: Task!
user: User!
action: String!
changes: JSON
createdAt: DateTime!
}

enum TaskStatus {
PENDING
IN_PROGRESS
COMPLETED
BLOCKED
CANCELLED
}

enum TaskPriority {
LOW
MEDIUM
HIGH
CRITICAL
}

type Project {
name: String!
milestones: [Milestone!]!
taskCount: Int!
completedCount: Int!
}

type Milestone {
name: String!
project: String!
taskCount: Int!
completedCount: Int!
progress: Float!
tasks: [Task!]!
}

type Query {
# Tasks
tasks(
project: String
milestone: String
status: TaskStatus
assignedToUserId: ID
createdByUserId: ID
search: String
): [Task!]!

task(taskId: ID!): Task
taskByPseudoId(pseudoId: String!): Task
taskTree(rootTaskId: ID!): Task

# Projects & Milestones
projects: [Project!]!
milestones(project: String!): [Milestone!]!

# My tasks
myTasks(status: TaskStatus): [Task!]!
myTasksDueThisWeek: [Task!]!
}

type Mutation {
# Create
createTask(input: CreateTaskInput!): Task!

# Update
updateTask(taskId: ID!, input: UpdateTaskInput!): Task!
startTask(taskId: ID!): Task!
completeTask(taskId: ID!, actualHours: Float): Task!
blockTask(taskId: ID!, reason: String): Task!

# Assign
assignTask(taskId: ID!, userId: ID!): Task!

# Dependencies
addDependency(taskId: ID!, dependsOnTaskId: ID!): Task!
removeDependency(taskId: ID!, dependsOnTaskId: ID!): Task!

# Comments
addComment(taskId: ID!, content: String!): TaskComment!

# Bulk operations
bulkCreateTasks(inputs: [CreateTaskInput!]!): [Task!]!
bulkUpdateStatus(taskIds: [ID!]!, status: TaskStatus!): [Task!]!

# Delete
deleteTask(taskId: ID!): Boolean!
}

input CreateTaskInput {
title: String!
description: String
project: String!
milestone: String
priority: TaskPriority
parentTaskId: ID
dependsOn: [ID!]
assignedToUserId: ID
dueDate: DateTime
estimatedHours: Float
tags: [String!]
relatedEntityId: ID
relatedCycleId: ID
relatedInsightId: ID
}

input UpdateTaskInput {
title: String
description: String
status: TaskStatus
priority: TaskPriority
milestone: String
dueDate: DateTime
estimatedHours: Float
actualHours: Float
tags: [String!]
}

type Subscription {
taskUpdated(project: String): Task!
taskCreated(project: String): Task!
taskCompleted(project: String): Task!
taskCommentAdded(taskId: ID!): TaskComment!
}


3. Server Implementation

File: taskpilot-graphql/package.json

{
"name": "taskpilot-graphql",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "nodemon server.js",
"start": "node server.js"
},
"dependencies": {
"@apollo/server": "^4.9.5",
"@graphql-tools/schema": "^10.0.2",
"express": "^4.18.2",
"graphql": "^16.8.1",
"graphql-subscriptions": "^2.0.0",
"graphql-ws": "^5.14.3",
"pg": "^8.11.3",
"ws": "^8.16.0",
"firebase-admin": "^12.0.0"
}
}

File: taskpilot-graphql/db.js**

import pg from 'pg';
const { Pool } = pg;

export const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false
});

export const query = (text, params) => pool.query(text, params);

File: taskpilot-graphql/resolvers.js**

import { query } from './db.js';
import { PubSub } from 'graphql-subscriptions';

const pubsub = new PubSub();

export const resolvers = {
Query: {
tasks: async (_, { project, milestone, status, assignedToUserId, search }, context) => {
let sql = `
SELECT t.*,
u1.first_name || ' ' || u1.last_name as created_by_name,
u2.first_name || ' ' || u2.last_name as assigned_to_name
FROM tasks t
LEFT JOIN users u1 ON t.created_by_user_id = u1.user_id
LEFT JOIN users u2 ON t.assigned_to_user_id = u2.user_id
WHERE t.tenant_id = $1
`;
const params = [context.user.tenant_id];
let paramIndex = 2;

if (project) {
sql += ` AND t.project = $${paramIndex++}`;
params.push(project);
}
if (milestone) {
sql += ` AND t.milestone = $${paramIndex++}`;
params.push(milestone);
}
if (status) {
sql += ` AND t.status = $${paramIndex++}`;
params.push(status.toLowerCase());
}
if (assignedToUserId) {
sql += ` AND t.assigned_to_user_id = $${paramIndex++}`;
params.push(assignedToUserId);
}
if (search) {
sql += ` AND (t.title ILIKE $${paramIndex} OR t.description ILIKE $${paramIndex})`;
params.push(`%${search}%`);
paramIndex++;
}

sql += ` ORDER BY t.created_at DESC`;

const result = await query(sql, params);
return result.rows;
},

task: async (_, { taskId }) => {
const result = await query(
'SELECT * FROM tasks WHERE task_id = $1',
[taskId]
);
return result.rows[0];
},

taskByPseudoId: async (_, { pseudoId }) => {
const result = await query(
'SELECT * FROM tasks WHERE pseudo_id = $1',
[pseudoId]
);
return result.rows[0];
},

projects: async (_, __, context) => {
const result = await query(`
SELECT
project,
milestone,
COUNT(*) as task_count,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed_count
FROM tasks
WHERE tenant_id = $1
GROUP BY project, milestone
ORDER BY project, milestone
`, [context.user.tenant_id]);

const projectMap = {};
result.rows.forEach(row => {
if (!projectMap[row.project]) {
projectMap[row.project] = {
name: row.project,
milestones: [],
taskCount: 0,
completedCount: 0
};
}
projectMap[row.project].milestones.push({
name: row.milestone,
project: row.project,
taskCount: parseInt(row.task_count),
completedCount: parseInt(row.completed_count),
progress: row.task_count > 0 ? (row.completed_count / row.task_count) * 100 : 0
});
projectMap[row.project].taskCount += parseInt(row.task_count);
projectMap[row.project].completedCount += parseInt(row.completed_count);
});

return Object.values(projectMap);
},

myTasks: async (_, { status }, context) => {
let sql = `
SELECT * FROM tasks
WHERE tenant_id = $1 AND assigned_to_user_id = $2
`;
const params = [context.user.tenant_id, context.user.user_id];

if (status) {
sql += ` AND status = $3`;
params.push(status.toLowerCase());
}

sql += ` ORDER BY due_date ASC NULLS LAST, priority DESC`;

const result = await query(sql, params);
return result.rows;
},
},

Mutation: {
createTask: async (_, { input }, context) => {
const { user } = context;

// Generate pseudo-ID if milestone provided
let pseudoId = null;
if (input.milestone) {
const result = await query(
`SELECT generate_pseudo_id($1, $2) as pseudo_id`,
[input.milestone, user.tenant_id]
);
pseudoId = result.rows[0].pseudo_id;
}

const result = await query(`
INSERT INTO tasks (
tenant_id, created_by_user_id, assigned_to_user_id,
pseudo_id, title, description, project, milestone,
priority, parent_task_id, depends_on, due_date,
estimated_hours, tags, related_entity_id,
related_cycle_id, related_insight_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
RETURNING *
`, [
user.tenant_id,
user.user_id,
input.assignedToUserId || null,
pseudoId,
input.title,
input.description || null,
input.project,
input.milestone || null,
input.priority?.toLowerCase() || null,
input.parentTaskId || null,
input.dependsOn || [],
input.dueDate || null,
input.estimatedHours || null,
input.tags || [],
input.relatedEntityId || null,
input.relatedCycleId || null,
input.relatedInsightId || null
]);

const task = result.rows[0];

// Audit log
await query(`
INSERT INTO audit_log (tenant_id, user_id, action_type, details)
VALUES ($1, $2, 'task_created', $3)
`, [user.tenant_id, user.user_id, JSON.stringify({ taskId: task.task_id, title: task.title })]);

pubsub.publish('TASK_CREATED', { taskCreated: task });

return task;
},

completeTask: async (_, { taskId, actualHours }, context) => {
const result = await query(`
UPDATE tasks
SET status = 'completed',
completed_at = NOW(),
actual_hours = COALESCE($2, actual_hours),
updated_at = NOW()
WHERE task_id = $1
RETURNING *
`, [taskId, actualHours]);

const task = result.rows[0];

await query(`
INSERT INTO audit_log (tenant_id, user_id, action_type, details)
VALUES ($1, $2, 'task_completed', $3)
`, [context.user.tenant_id, context.user.user_id, JSON.stringify({ taskId })]);

pubsub.publish('TASK_COMPLETED', { taskCompleted: task });

return task;
},

bulkCreateTasks: async (_, { inputs }, context) => {
const tasks = [];

for (const input of inputs) {
const task = await resolvers.Mutation.createTask(_, { input }, context);
tasks.push(task);
}

return tasks;
},
},

Task: {
subtasks: async (parent) => {
const result = await query(
'SELECT * FROM tasks WHERE parent_task_id = $1 ORDER BY created_at',
[parent.task_id]
);
return result.rows;
},

dependencies: async (parent) => {
if (!parent.depends_on || parent.depends_on.length === 0) return [];
const result = await query(
'SELECT * FROM tasks WHERE task_id = ANY($1)',
[parent.depends_on]
);
return result.rows;
},

comments: async (parent) => {
const result = await query(
'SELECT * FROM task_comments WHERE task_id = $1 ORDER BY created_at DESC',
[parent.task_id]
);
return result.rows;
},
},

Subscription: {
taskCreated: {
subscribe: () => pubsub.asyncIterator(['TASK_CREATED']),
},
taskUpdated: {
subscribe: () => pubsub.asyncIterator(['TASK_UPDATED']),
},
taskCompleted: {
subscribe: () => pubsub.asyncIterator(['TASK_COMPLETED']),
},
},
};

File: taskpilot-graphql/server.js**

import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
import express from 'express';
import { createServer } from 'http';
import { readFileSync } from 'fs';
import admin from 'firebase-admin';
import { resolvers } from './resolvers.js';
import { query } from './db.js';

// Firebase init
admin.initializeApp({
credential: admin.credential.applicationDefault(),
});

// Load schema
const typeDefs = readFileSync('./schema.graphql', 'utf-8');

// Create schema
const schema = makeExecutableSchema({ typeDefs, resolvers });

// Express app
const app = express();
const httpServer = createServer(app);

// WebSocket server
const wsServer = new WebSocketServer({
server: httpServer,
path: '/graphql',
});

useServer({ schema }, wsServer);

// Apollo Server
const server = new ApolloServer({ schema });

await server.start();

app.use(
'/graphql',
express.json(),
expressMiddleware(server, {
context: async ({ req }) => {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) throw new Error('Unauthorized');

const decodedToken = await admin.auth().verifyIdToken(token);

const result = await query(
'SELECT * FROM users WHERE email = $1',
[decodedToken.email]
);

if (!result.rows[0]) throw new Error('User not found');

return { user: result.rows[0] };
},
})
);

const PORT = process.env.PORT || 4000;
httpServer.listen(PORT, () => {
console.log(`🚀 Server ready at http://localhost:${PORT}/graphql`);
console.log(`🔌 WebSocket ready at ws://localhost:${PORT}/graphql`);
});


4. Deployment Checklist

# 1. Run migration
psql $DATABASE_URL < migrations/20250102_add_tasks.sql

# 2. Install server dependencies
cd taskpilot-graphql
npm install

# 3. Set environment variables
export DATABASE_URL="postgresql://..."
export GOOGLE_APPLICATION_CREDENTIALS="path/to/firebase-key.json"

# 4. Start server
npm run dev

# 5. Test query
curl http://localhost:4000/graphql \
-H "Authorization: Bearer YOUR_FIREBASE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"query":"{ projects { name taskCount } }"}'


5. React Client (Brief)

npm install @apollo/client graphql graphql-ws

Wrap app:

import { ApolloProvider } from '@apollo/client';
import { apolloClient } from './apollo-client';

<ApolloProvider client={apolloClient}>
<App />
</ApolloProvider>

Use in components:

const { data } = useQuery(gql`
query GetMyTasks {
myTasks(status: PENDING) {
taskId pseudoId title dueDate
}
}
`);


Ready to deploy. The architecture handles all 3 use cases: personal (now), team (soon), product feature (embedded in ChainAlign).

CLI Wrapper for AI Assistants

You need a CLI that wraps the GraphQL API so coding assistants can use familiar commands.


CLI Implementation

File: taskpilot-cli/task (executable)

#!/usr/bin/env node
import { ApolloClient, InMemoryCache, HttpLink, gql } from '@apollo/client/core';
import fetch from 'cross-fetch';
import { readFileSync } from 'fs';
import { homedir } from 'os';

const configPath = `${homedir()}/.taskpilot/config.json`;
const config = JSON.parse(readFileSync(configPath, 'utf-8'));

const client = new ApolloClient({
link: new HttpLink({
uri: config.apiUrl || 'http://localhost:4000/graphql',
fetch,
headers: {
authorization: `Bearer ${config.token}`,
},
}),
cache: new InMemoryCache(),
});

const args = process.argv.slice(2);
const command = args[0];

// Task add project:X milestone:Y "description"
if (command === 'add') {
const project = args.find(a => a.startsWith('project:'))?.split(':')[1];
const milestone = args.find(a => a.startsWith('milestone:'))?.split(':')[1];
const depends = args.find(a => a.startsWith('depends:'))?.split(':')[1];
const priority = args.find(a => a.startsWith('priority:'))?.split(':')[1];
const due = args.find(a => a.startsWith('due:'))?.split(':')[1];
const title = args.find(a => !a.includes(':'));

const { data } = await client.mutate({
mutation: gql`
mutation CreateTask($input: CreateTaskInput!) {
createTask(input: $input) {
taskId pseudoId title
}
}
`,
variables: {
input: {
title,
project,
milestone,
priority: priority?.toUpperCase(),
dependsOn: depends ? [depends] : [],
dueDate: due,
},
},
});

console.log(`Created: ${data.createTask.pseudoId} ${data.createTask.title}`);
}

// Task list project:X
else if (command === 'list' || command === 'mlist') {
const project = args.find(a => a.startsWith('project:'))?.split(':')[1];
const milestone = args.find(a => a.startsWith('milestone:'))?.split(':')[1];

const { data } = await client.query({
query: gql`
query GetTasks($project: String, $milestone: String) {
tasks(project: $project, milestone: $milestone) {
pseudoId title status priority dueDate
}
}
`,
variables: { project, milestone },
});

console.log('ID Title Status Pri Due');
console.log('---------------------------------------------------------------');
data.tasks.forEach(t => {
const due = t.dueDate ? new Date(t.dueDate).toLocaleDateString() : '';
console.log(
`${(t.pseudoId || '').padEnd(8)} ${t.title.padEnd(30).slice(0,30)} ${t.status.padEnd(9)} ${(t.priority || '').padEnd(4)} ${due}`
);
});
}

// Task done <pseudo-id>
else if (command === 'done') {
const pseudoId = args[1];

// Get task by pseudoId first
const { data: taskData } = await client.query({
query: gql`
query GetTask($pseudoId: String!) {
taskByPseudoId(pseudoId: $pseudoId) {
taskId
}
}
`,
variables: { pseudoId },
});

await client.mutate({
mutation: gql`
mutation CompleteTask($taskId: ID!) {
completeTask(taskId: $taskId) {
pseudoId status
}
}
`,
variables: { taskId: taskData.taskByPseudoId.taskId },
});

console.log(`Completed task ${pseudoId}`);
}

// Task <pseudo-id> modify priority:high
else if (args[1] === 'modify') {
const pseudoId = args[0];
const updates = {};

args.slice(2).forEach(arg => {
const [key, value] = arg.split(':');
if (key === 'priority') updates.priority = value.toUpperCase();
if (key === 'status') updates.status = value.toUpperCase();
if (key === 'milestone') updates.milestone = value;
if (key === 'due') updates.dueDate = value;
});

const { data: taskData } = await client.query({
query: gql`
query GetTask($pseudoId: String!) {
taskByPseudoId(pseudoId: $pseudoId) { taskId }
}
`,
variables: { pseudoId },
});

await client.mutate({
mutation: gql`
mutation UpdateTask($taskId: ID!, $input: UpdateTaskInput!) {
updateTask(taskId: $taskId, input: $input) {
pseudoId
}
}
`,
variables: {
taskId: taskData.taskByPseudoId.taskId,
input: updates,
},
});

console.log(`Updated task ${pseudoId}`);
}

// Task export (JSON format for AI assistants)
else if (command === 'export') {
const { data } = await client.query({
query: gql`
query GetAllTasks {
tasks {
taskId pseudoId title description project milestone
status priority dueDate tags createdAt
}
}
`,
});

console.log(JSON.stringify(data.tasks, null, 2));
}

else {
console.log('Usage:');
console.log(' task add project:X milestone:Y "Task title"');
console.log(' task list [project:X] [milestone:Y]');
console.log(' task done <pseudo-id>');
console.log(' task <pseudo-id> modify priority:high');
console.log(' task export');
}

Install:

npm install @apollo/client graphql cross-fetch
chmod +x taskpilot-cli/task
ln -s $(pwd)/taskpilot-cli/task /usr/local/bin/task

Setup config:

mkdir -p ~/.taskpilot
cat > ~/.taskpilot/config.json << EOF
{
"apiUrl": "http://localhost:4000/graphql",
"token": "YOUR_FIREBASE_TOKEN"
}
EOF


For AI Coding Assistants

Documentation file for AI assistants:

## TaskPilot CLI Commands

### Create Task
task add project:chainalign milestone:M1 priority:high "Implement GraphQL schema"

### List Tasks
task list project:chainalign
task list milestone:M1

### Complete Task
task done M1-003

### Modify Task
task M1-003 modify priority:critical
task M1-003 modify status:in_progress

### Export (JSON for parsing)
task export > tasks.json

### Bulk Create (pipe JSON)
cat << EOF | task bulk-add
{"title": "Task 1", "project": "chainalign", "milestone": "M1"}
{"title": "Task 2", "project": "chainalign", "milestone": "M1"}
EOF


Architecture Summary

┌─────────────────┐
│ You (GUI) │
│ React + Apollo │
└────────┬────────┘

├─────────────┐
│ │
▼ ▼
┌─────────────┐ ┌──────────────┐
│ GraphQL │ │ CLI Wrapper │
│ Server │◄─┤ (for AIs) │
└──────┬──────┘ └──────────────┘


┌─────────────┐
│ PostgreSQL │
└─────────────┘

Key points:

  • GUI uses GraphQL directly (Apollo Client)
  • CLI wraps GraphQL for AI assistants (familiar syntax)
  • Both interfaces share same data/business logic
  • Team members use GUI when they join
  • AI assistants use CLI with same data

Token management: Store Firebase token in ~/.taskpilot/config.json - refresh it periodically (tokens expire after 1 hour by default).

I'll create a TaskPilot mockup following ChainAlign's brand system.View TaskPilot mockup

Design Decisions

Following ChainAlign brand:

  • Deep Navy (#0B1D3A) for headers and primary text
  • Signal Green (#00D084) for CTAs and active states
  • Inter font for UI, JetBrains Mono for IDs/data
  • 8px spacing grid, 8px card border radius
  • Clean, data-focused layout

Key features shown:

  • Three-column layout (sidebar, main, details)
  • Project → Milestone hierarchy
  • Pseudo-IDs prominently displayed
  • Status/priority badges with brand colors
  • Activity timeline in right panel
  • List/Board/Tree view toggles

Enterprise touches:

  • Minimal decoration, maximum information density
  • Monospace font for technical data (IDs, time estimates)
  • Subtle hover states
  • Clear visual hierarchy

The mockup is static HTML - ready to convert to React components using the same styling.

Schema addition:

-- Add to tasks table
ALTER TABLE tasks ADD COLUMN related_documents JSONB DEFAULT '[]';

-- Example structure:
-- [
-- {"type": "fsd", "path": "/docs/M1-architecture-fsd.md", "status": "approved"},
-- {"type": "dev_summary", "path": "/docs/M1-dev-summary.md"},
-- {"type": "tdd", "path": "/docs/M1-tdd-summary.md"}
-- ]

-- Or link to existing documents table
ALTER TABLE tasks ADD COLUMN fsd_document_id UUID REFERENCES source_documents(id);
ALTER TABLE tasks ADD COLUMN dev_summary_id UUID REFERENCES source_documents(id);
ALTER TABLE tasks ADD COLUMN tdd_summary_id UUID REFERENCES source_documents(id);

Pattern for milestones:

CREATE TABLE milestone_documents (
milestone_id VARCHAR(50) PRIMARY KEY, -- "M1", "M2"
project VARCHAR(255),
fsd_document_id UUID REFERENCES source_documents(id),
dev_summary_id UUID REFERENCES source_documents(id),
tdd_summary_id UUID REFERENCES source_documents(id),
fsd_approved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);

GraphQL additions:

type Task {
# ... existing fields
fsdDocument: Document
devSummary: Document
tddSummary: Document
}

type Milestone {
name: String!
documents: MilestoneDocuments!
tasks: [Task!]!
completionStatus: MilestoneStatus!
}

type MilestoneStatus {
fsdApproved: Boolean!
tasksCompleted: Int!
tasksTotal: Int!
devSummaryExists: Boolean!
tddSummaryExists: Boolean!
}

UI mockup addition:

Milestone card shows:

  • ✅ FSD Approved (Nov 10)
  • 🏗️ Tasks: 7/12 complete
  • 📝 Dev Summary (in progress)
  • ⏳ TDD Summary (pending)

Workflow:

  1. Create milestone → auto-creates FSD placeholder
  2. Approve FSD → unlocks task creation
  3. Tasks link to specific FSD sections
  4. Complete tasks → auto-prompt for dev summary
  5. All tasks done → require TDD summary before milestone close

Best practice from Linear/Notion:

  • Each task shows "📄 Linked docs" badge
  • Click to view in side panel or open in editor
  • Documents reference tasks by pseudo-ID (M1-003)
  • Bidirectional sync keeps both updated

This makes your process auditable and professional for when investors/partners ask "show me your process."

Implementation Gap Problem

Root cause: FSD → actual code mapping breaks during interruptions.

Solution: Task manager as implementation ledger

-- Track actual implementation state
ALTER TABLE tasks ADD COLUMN implementation_status JSONB DEFAULT '{
"files_modified": [],
"fsd_sections_completed": [],
"last_checkpoint": null,
"verified": false
}';

-- Link task to FSD sections
ALTER TABLE tasks ADD COLUMN fsd_sections TEXT[];
-- Example: ["3.2.1 GraphQL Schema", "4.1 Database Migration"]

Auto-checkpoint on interruption:

// Pre-commit hook or IDE integration
function createCheckpoint(taskId) {
const modifiedFiles = getGitDiff();
const currentSection = getCurrentFSDSection(); // from cursor position

await updateTask(taskId, {
implementation_status: {
files_modified: modifiedFiles,
last_checkpoint: new Date(),
current_fsd_section: currentSection
}
});
}

Resume workflow:

task resume M1-003
# Shows:
# Last worked: 2 hours ago
# FSD section: 3.2.1 GraphQL Schema
# Files modified: schema.graphql, resolvers.js
# Next: Implement mutations (section 3.2.2)

Gap detection:

// Compare FSD checklist vs completed tasks
function findGaps(milestoneId) {
const fsdSections = parseFSD(milestoneId); // Extract all "[ ]" items
const completedSections = getCompletedTaskSections(milestoneId);

return fsdSections.filter(s => !completedSections.includes(s));
}

UI shows:

  • ⚠️ FSD-Code Gap: Section 4.2 (Auth middleware) has no task
  • 🔄 Interrupted Task: M1-003 (last worked 3 hours ago)
  • Verified: M1-001 (code matches FSD)

This turns task manager into your "where was I?" and "what's missing?" tool.

FSD → Task Auto-Generation

Parser implementation:

// fsd-parser.js
import { readFileSync } from 'fs';
import { marked } from 'marked';

export function parseFSDToTasks(fsdPath, milestone, project) {
const content = readFileSync(fsdPath, 'utf-8');
const tokens = marked.lexer(content);

const tasks = [];
let currentSection = '';
let sectionDepth = 0;

tokens.forEach((token, idx) => {
// Track section hierarchy
if (token.type === 'heading') {
currentSection = token.text;
sectionDepth = token.depth;
}

// Extract implementation items
if (token.type === 'list') {
token.items.forEach(item => {
// Match patterns like:
// - [ ] Implement GraphQL schema
// - [ ] Create migration for tasks table
// - [ ] Add authentication middleware
const match = item.text.match(/^\[ \] (.+)/);
if (match) {
tasks.push({
title: match[1],
fsd_section: currentSection,
fsd_section_number: getSectionNumber(tokens, idx),
project,
milestone,
estimated_hours: extractEstimate(item.text),
});
}
});
}
});

return tasks;
}

function getSectionNumber(tokens, startIdx) {
// Walk backwards to find section number like "3.2.1"
for (let i = startIdx; i >= 0; i--) {
if (tokens[i].type === 'heading') {
const match = tokens[i].text.match(/^(\d+(?:\.\d+)*)/);
if (match) return match[1];
}
}
return null;
}

function extractEstimate(text) {
// Match patterns: (2h), (4 hours), etc.
const match = text.match(/\((\d+)\s*h(?:ours?)?\)/i);
return match ? parseInt(match[1]) : null;
}

CLI command:

task import-fsd docs/M1-architecture-fsd.md \
--project=chainalign \
--milestone=M1

# Output:
# ✓ Created M1-001: Implement GraphQL schema (Section 3.2.1)
# ✓ Created M1-002: Create migration for tasks table (Section 3.2.2)
# ✓ Created M1-003: Add authentication middleware (Section 4.1)
#
# 12 tasks created from FSD

Session checkpoint schema:

CREATE TABLE work_sessions (
session_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(user_id),
task_id UUID REFERENCES tasks(task_id),
milestone VARCHAR(50),

-- Context preservation
fsd_section TEXT,
files_in_progress TEXT[],
last_command TEXT,
cursor_position JSONB, -- {"file": "schema.graphql", "line": 42}

-- AI context
session_summary TEXT,
token_budget_used INT,

started_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
ended_at TIMESTAMPTZ,
ended_reason VARCHAR(50) -- 'completed', 'interrupted', 'token_limit', 'crash'
);

-- Auto-create on first task interaction
CREATE OR REPLACE FUNCTION start_work_session()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.status = 'in_progress' AND OLD.status = 'pending' THEN
INSERT INTO work_sessions (user_id, task_id, milestone, fsd_section)
VALUES (NEW.updated_by_user_id, NEW.task_id, NEW.milestone, NEW.fsd_sections[1]);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

Smart resume:

// resume-service.js
export async function getResumeContext(userId) {
const session = await query(`
SELECT
ws.*,
t.pseudo_id,
t.title,
t.fsd_sections,
t.implementation_status
FROM work_sessions ws
JOIN tasks t ON ws.task_id = t.task_id
WHERE ws.user_id = $1
AND ws.ended_at IS NULL
ORDER BY ws.started_at DESC
LIMIT 1
`, [userId]);

if (!session.rows[0]) return null;

const s = session.rows[0];

return {
task: {
id: s.pseudo_id,
title: s.title,
},
fsd_section: s.fsd_section,
files_modified: s.files_in_progress,
last_worked: formatDistance(s.started_at),

// Generate concise summary
summary: `Resume: ${s.pseudo_id} - ${s.title}
Working on FSD section: ${s.fsd_section}
Files: ${s.files_in_progress.join(', ')}
Last: ${s.session_summary || 'No summary available'}`,

// Smart next steps
suggested_next: await getSuggestedNextSteps(s.task_id, s.fsd_section),
};
}

async function getSuggestedNextSteps(taskId, currentSection) {
// Parse FSD to find next uncompleted section
const fsd = await getFSDForTask(taskId);
const sections = parseFSDSections(fsd);
const currentIdx = sections.findIndex(s => s.title === currentSection);

return sections[currentIdx + 1]?.title || 'Task completion';
}

CLI workflow:

# End of session (auto-triggered by Claude or manual)
task checkpoint M1-003 \
--summary "Implemented GraphQL schema, need to add resolvers" \
--files schema.graphql,resolvers.js

# Next session starts
task resume

# Output:
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Resume Session
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#
# Task: M1-003 - Add authentication middleware
# FSD Section: 4.1 Auth Middleware Implementation
# Last worked: 3 hours ago
#
# Files in progress:
# - auth-middleware.js
# - server.js
#
# Last session summary:
# "Implemented GraphQL schema, need to add resolvers"
#
# Suggested next:
# → Section 4.2: Token verification logic
#
# [Enter to continue | Ctrl+C to start fresh]

GraphQL additions:

type Query {
resumeContext: ResumeContext
sessionHistory(limit: Int): [WorkSession!]!
}

type ResumeContext {
task: Task
fsdSection: String
filesModified: [String!]!
lastWorked: String
summary: String
suggestedNext: String
}

type Mutation {
checkpointSession(
taskId: ID!
summary: String!
files: [String!]
): WorkSession!

importFSD(
fsdPath: String!
project: String!
milestone: String!
): [Task!]!
}

UI additions:

Top of TaskPilot dashboard:

┌─────────────────────────────────────────────────┐
│ 🔄 Resume Session │
│ │
│ M1-003: Add authentication middleware │
│ Section 4.1 • Last worked 3h ago │
│ │
│ Files: auth-middleware.js, server.js │
│ Next: Section 4.2 - Token verification │
│ │
│ [Resume →] [Start Fresh] │
└─────────────────────────────────────────────────┘

Token optimization:

Instead of feeding full FSD every session:

  • Load only relevant section (4.1, not entire doc)
  • Include last 3 checkpoints for continuity
  • Summary = ~500 tokens vs 3000+ for full FSD

Implementation priority:

  1. FSD parser + import command (2 hours)
  2. Session checkpoints (2 hours)
  3. Resume context API (1 hour)
  4. UI integration (2 hours)

This solves your session continuity problem and makes the task manager your implementation memory.

TDD Workflow Integration

Your workflow is solid. The frustration is normal - tests often take 2-3x coding time initially, but save 10x later. Let me update the FSD to support your exact flow:

Schema additions:

-- Add TDD-specific fields to tasks
ALTER TABLE tasks ADD COLUMN tdd_status JSONB DEFAULT '{
"test_files": [],
"code_files": [],
"tests_passing": 0,
"tests_failing": 0,
"last_test_run": null,
"debugging_notes": []
}';

ALTER TABLE tasks ADD COLUMN git_commits TEXT[];
ALTER TABLE tasks ADD COLUMN test_coverage NUMERIC(5,2);

Enhanced task statuses:

-- More granular TDD workflow
ALTER TABLE tasks DROP CONSTRAINT valid_status;
ALTER TABLE tasks ADD CONSTRAINT valid_status CHECK (status IN (
'pending',
'writing_tests',
'writing_code',
'testing',
'debugging',
'code_review',
'completed',
'blocked'
));

Auto-checkpoint on test failure:

CREATE OR REPLACE FUNCTION checkpoint_on_test_failure()
RETURNS TRIGGER AS $$
BEGIN
IF (NEW.tdd_status->>'tests_failing')::int > 0
AND (OLD.tdd_status->>'tests_failing')::int = 0 THEN

-- Auto-create checkpoint when tests start failing
INSERT INTO work_sessions (
user_id, task_id, ended_reason,
session_summary, ended_at
) VALUES (
NEW.assigned_to_user_id,
NEW.task_id,
'test_failure',
format('Tests failing: %s/%s',
NEW.tdd_status->>'tests_failing',
(NEW.tdd_status->>'tests_passing')::int +
(NEW.tdd_status->>'tests_failing')::int
),
NOW()
);
END IF;

RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CLI commands for TDD:

# Start writing tests
task start-tests M1-003
# Status: pending → writing_tests

# Mark tests written, start code
task start-code M1-003
# Status: writing_tests → writing_code

# Run tests and record results
task test M1-003 --passed 5 --failed 2
# Auto-checkpoints if failures
# Updates tdd_status

# Mark complete (requires all tests passing)
task done M1-003 --commit abc123
# Verifies tests passing, links commit

GraphQL additions:

type Task {
tddStatus: TDDStatus!
gitCommits: [String!]!
testCoverage: Float
}

type TDDStatus {
testFiles: [String!]!
codeFiles: [String!]!
testsPassing: Int!
testsFailing: Int!
lastTestRun: DateTime
debuggingNotes: [String!]!
}

type Mutation {
startTests(taskId: ID!): Task!
startCode(taskId: ID!): Task!
recordTestRun(
taskId: ID!
passing: Int!
failing: Int!
output: String
): Task!
addDebuggingNote(taskId: ID!, note: String!): Task!
}

Updated FSD section (testing):

## 4.3 TDD Workflow Support

- [ ] Add tdd_status column (1h)
- [ ] Implement status transitions (2h) [priority: high]
- [ ] Auto-checkpoint on test failures (1h)
- [ ] CLI test commands (2h)
- [ ] Test coverage tracking (1h)

Testing time reduction tips:

  1. Parallel tests: Run fast unit tests first, slow integration tests only when units pass
  2. Test templates: Create reusable test patterns for common cases
  3. Snapshot failures: CLI shows exact test output, no context switch to IDE
  4. Debug notes: task debug M1-003 "Issue: auth middleware not firing" - searchable later