- Restructure sidebar: Start → Guide → Operations → Developers → Reference - Fix EN/RU sidebar order (Installation before Quickstart) - Expand troubleshooting with diagnostics commands and task-log triage - Improve quickstart with prerequisites, pitfalls, and contributor links - Expand installation docs with verification commands - Add cyberpunk hero theme to landing page - Add atomicFile utility with tests and stage-runtime script - Harden team provisioning with better error handling and progress output - Add cross-team communication, kanban, and workSync improvements
907 lines
29 KiB
JavaScript
907 lines
29 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
const { writeJsonFileSync } = require('./atomicFile.js');
|
|
const reviewStateHelpers = require('./reviewState.js');
|
|
|
|
const TASK_STATUSES = new Set(['pending', 'in_progress', 'completed', 'deleted']);
|
|
const UUID_TASK_ID_PATTERN =
|
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
|
|
function nowIso() {
|
|
return new Date().toISOString();
|
|
}
|
|
|
|
function ensureDir(dirPath) {
|
|
fs.mkdirSync(dirPath, { recursive: true });
|
|
}
|
|
|
|
function readJson(filePath, fallbackValue) {
|
|
try {
|
|
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
} catch {
|
|
return fallbackValue;
|
|
}
|
|
}
|
|
|
|
function writeJson(filePath, value) {
|
|
writeJsonFileSync(filePath, value);
|
|
}
|
|
|
|
function getTaskPath(paths, taskId) {
|
|
return path.join(paths.tasksDir, `${String(taskId)}.json`);
|
|
}
|
|
|
|
function looksLikeCanonicalTaskId(taskId) {
|
|
return UUID_TASK_ID_PATTERN.test(String(taskId || '').trim());
|
|
}
|
|
|
|
function deriveDisplayId(taskId) {
|
|
const normalized = String(taskId || '').trim();
|
|
if (!normalized) return normalized;
|
|
return looksLikeCanonicalTaskId(normalized) ? normalized.slice(0, 8).toLowerCase() : normalized;
|
|
}
|
|
|
|
function normalizeTask(rawTask, filePath) {
|
|
if (!rawTask || typeof rawTask !== 'object') {
|
|
throw new Error(`Invalid task payload${filePath ? `: ${filePath}` : ''}`);
|
|
}
|
|
|
|
const id =
|
|
typeof rawTask.id === 'string' || typeof rawTask.id === 'number' ? String(rawTask.id) : '';
|
|
if (!id) {
|
|
throw new Error(`Task is missing id${filePath ? `: ${filePath}` : ''}`);
|
|
}
|
|
|
|
const task = {
|
|
...rawTask,
|
|
id,
|
|
displayId:
|
|
typeof rawTask.displayId === 'string' && rawTask.displayId.trim()
|
|
? rawTask.displayId.trim()
|
|
: deriveDisplayId(id),
|
|
reviewState: normalizeTaskReviewState(rawTask.reviewState),
|
|
};
|
|
|
|
if (!TASK_STATUSES.has(String(task.status || '').trim())) {
|
|
throw new Error(
|
|
`Invalid task status "${String(task.status || '')}"${filePath ? `: ${filePath}` : ''}`
|
|
);
|
|
}
|
|
task.status = String(task.status).trim();
|
|
|
|
return task;
|
|
}
|
|
|
|
function normalizeTaskReviewState(value) {
|
|
return reviewStateHelpers.normalizeReviewState(value);
|
|
}
|
|
|
|
function listTaskRows(paths, options = {}) {
|
|
ensureDir(paths.tasksDir);
|
|
const entries = fs.readdirSync(paths.tasksDir);
|
|
const includeDeleted = options.includeDeleted === true;
|
|
const tasks = [];
|
|
const anomalies = [];
|
|
|
|
for (const fileName of entries) {
|
|
if (!fileName.endsWith('.json') || fileName.startsWith('.')) continue;
|
|
const filePath = path.join(paths.tasksDir, fileName);
|
|
let rawTask;
|
|
try {
|
|
rawTask = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
} catch (error) {
|
|
anomalies.push({
|
|
code: 'unreadable_task',
|
|
taskId: path.basename(fileName, '.json'),
|
|
filePath,
|
|
detail: error instanceof Error ? error.message : 'Unreadable task row',
|
|
});
|
|
continue;
|
|
}
|
|
if (!rawTask) continue;
|
|
if (rawTask.metadata && rawTask.metadata._internal === true) continue;
|
|
try {
|
|
const task = normalizeTask(rawTask, filePath);
|
|
if (includeDeleted || task.status !== 'deleted') {
|
|
tasks.push(task);
|
|
}
|
|
} catch (error) {
|
|
const taskId =
|
|
typeof rawTask?.id === 'string' || typeof rawTask?.id === 'number'
|
|
? String(rawTask.id)
|
|
: path.basename(fileName, '.json');
|
|
anomalies.push({
|
|
code: 'unreadable_task',
|
|
taskId,
|
|
filePath,
|
|
detail: error instanceof Error ? error.message : 'Unreadable task row',
|
|
});
|
|
}
|
|
}
|
|
|
|
tasks.sort((a, b) => {
|
|
const byDisplay = String(a.displayId || a.id).localeCompare(
|
|
String(b.displayId || b.id),
|
|
undefined,
|
|
{
|
|
numeric: true,
|
|
sensitivity: 'base',
|
|
}
|
|
);
|
|
if (byDisplay !== 0) return byDisplay;
|
|
return String(a.id).localeCompare(String(b.id), undefined, {
|
|
numeric: true,
|
|
sensitivity: 'base',
|
|
});
|
|
});
|
|
|
|
return { tasks, anomalies };
|
|
}
|
|
|
|
function listRawTasks(paths) {
|
|
return listTaskRows(paths, { includeDeleted: true }).tasks;
|
|
}
|
|
|
|
function listTasks(paths, options = {}) {
|
|
return listTaskRows(paths, options).tasks;
|
|
}
|
|
|
|
function resolveTaskRef(paths, taskRef, options = {}) {
|
|
const normalizedRef = String(taskRef || '')
|
|
.trim()
|
|
.replace(/^#/, '');
|
|
if (!normalizedRef) {
|
|
throw new Error('Missing taskId');
|
|
}
|
|
|
|
const includeDeleted = options.includeDeleted === true;
|
|
|
|
// Fast path: if taskRef looks like a canonical UUID, try direct file read first
|
|
if (looksLikeCanonicalTaskId(normalizedRef)) {
|
|
const taskPath = getTaskPath(paths, normalizedRef);
|
|
const rawTask = readJson(taskPath, null);
|
|
if (rawTask && (includeDeleted || rawTask.status !== 'deleted')) {
|
|
return normalizedRef;
|
|
}
|
|
}
|
|
|
|
// Fallback: scan all tasks for displayId match or non-UUID refs
|
|
const tasks = listRawTasks(paths);
|
|
const exact = tasks.find((task) => task.id === normalizedRef);
|
|
if (exact && (includeDeleted || exact.status !== 'deleted')) {
|
|
return exact.id;
|
|
}
|
|
|
|
const byDisplay = tasks.find(
|
|
(task) => task.displayId === normalizedRef && (includeDeleted || task.status !== 'deleted')
|
|
);
|
|
if (byDisplay) {
|
|
return byDisplay.id;
|
|
}
|
|
|
|
throw new Error(`Task not found: ${normalizedRef}`);
|
|
}
|
|
|
|
function readTask(paths, taskRef, options = {}) {
|
|
const taskId = resolveTaskRef(paths, taskRef, options);
|
|
const taskPath = getTaskPath(paths, taskId);
|
|
const rawTask = readJson(taskPath, null);
|
|
if (!rawTask) {
|
|
throw new Error(`Task not found: ${String(taskRef)}`);
|
|
}
|
|
return normalizeTask(rawTask, taskPath);
|
|
}
|
|
|
|
function appendHistoryEvent(events, event) {
|
|
const list = Array.isArray(events) ? [...events] : [];
|
|
list.push({ id: crypto.randomUUID(), timestamp: nowIso(), ...event });
|
|
return list;
|
|
}
|
|
|
|
function isOpenReviewInterval(interval) {
|
|
return interval && interval.completedAt === undefined;
|
|
}
|
|
|
|
function closeOpenReviewIntervals(task, timestamp) {
|
|
if (!Array.isArray(task.reviewIntervals)) return false;
|
|
let changed = false;
|
|
task.reviewIntervals = task.reviewIntervals.map((interval) => {
|
|
if (!isOpenReviewInterval(interval)) return interval;
|
|
changed = true;
|
|
const startedAtMs = Date.parse(interval.startedAt);
|
|
const timestampMs = Date.parse(timestamp);
|
|
const completedAt =
|
|
Number.isFinite(startedAtMs) && Number.isFinite(timestampMs) && timestampMs < startedAtMs
|
|
? interval.startedAt
|
|
: timestamp;
|
|
return { ...interval, completedAt };
|
|
});
|
|
return changed;
|
|
}
|
|
|
|
function normalizeStatus(status) {
|
|
const normalized = String(status || '').trim();
|
|
return TASK_STATUSES.has(normalized) ? normalized : null;
|
|
}
|
|
|
|
function parseRelationshipList(paths, value) {
|
|
const rawValues = Array.isArray(value)
|
|
? value
|
|
: typeof value === 'string'
|
|
? value
|
|
.split(',')
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean)
|
|
: [];
|
|
|
|
return rawValues.map((entry) => resolveTaskRef(paths, entry));
|
|
}
|
|
|
|
function normalizeTaskRefs(taskRefs) {
|
|
if (!Array.isArray(taskRefs) || taskRefs.length === 0) {
|
|
return undefined;
|
|
}
|
|
|
|
const normalized = taskRefs
|
|
.filter((item) => item && typeof item === 'object')
|
|
.map((item) => ({
|
|
taskId: String(item.taskId || '').trim(),
|
|
displayId: String(item.displayId || '').trim(),
|
|
teamName: String(item.teamName || '').trim(),
|
|
}))
|
|
.filter((item) => item.taskId && item.displayId && item.teamName);
|
|
|
|
return normalized.length > 0 ? normalized : undefined;
|
|
}
|
|
|
|
function computeInitialStatus(paths, input, owner, blockedByIds) {
|
|
const explicit = normalizeStatus(input.status);
|
|
if (explicit) return explicit;
|
|
if (blockedByIds.length > 0) return 'pending';
|
|
if (owner && input.startImmediately === true) return 'in_progress';
|
|
return 'pending';
|
|
}
|
|
|
|
function pickTaskId(input) {
|
|
if (typeof input.id === 'string' && input.id.trim()) {
|
|
return input.id.trim();
|
|
}
|
|
return crypto.randomUUID();
|
|
}
|
|
|
|
function pickUniqueDisplayId(paths, canonicalId, explicitDisplayId) {
|
|
const preferred =
|
|
typeof explicitDisplayId === 'string' && explicitDisplayId.trim()
|
|
? explicitDisplayId.trim()
|
|
: deriveDisplayId(canonicalId);
|
|
|
|
const existing = new Set(
|
|
listRawTasks(paths).map((task) => task.displayId || deriveDisplayId(task.id))
|
|
);
|
|
if (!existing.has(preferred)) {
|
|
return preferred;
|
|
}
|
|
|
|
let length = Math.max(preferred.length, 8);
|
|
while (length < canonicalId.length) {
|
|
const candidate = canonicalId.slice(0, length).toLowerCase();
|
|
if (!existing.has(candidate)) {
|
|
return candidate;
|
|
}
|
|
length += 1;
|
|
}
|
|
|
|
return canonicalId.toLowerCase();
|
|
}
|
|
|
|
function wouldCreateBlockCycle(paths, sourceId, targetId) {
|
|
const visited = new Set();
|
|
const stack = [targetId];
|
|
|
|
while (stack.length > 0) {
|
|
const currentId = stack.pop();
|
|
if (!currentId || visited.has(currentId)) continue;
|
|
if (currentId === sourceId) return true;
|
|
visited.add(currentId);
|
|
try {
|
|
const currentTask = readTask(paths, currentId, { includeDeleted: true });
|
|
for (const depId of currentTask.blockedBy || []) {
|
|
stack.push(depId);
|
|
}
|
|
} catch {
|
|
// Ignore unreadable dependency rows during cycle probe.
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function writeTask(paths, task) {
|
|
writeJson(getTaskPath(paths, task.id), task);
|
|
}
|
|
|
|
function createTask(paths, input = {}) {
|
|
ensureDir(paths.tasksDir);
|
|
|
|
const canonicalId = pickTaskId(input);
|
|
if (fs.existsSync(getTaskPath(paths, canonicalId))) {
|
|
throw new Error(`Task already exists: ${canonicalId}`);
|
|
}
|
|
|
|
const blockedByIds = parseRelationshipList(paths, input['blocked-by'] ?? input.blockedBy);
|
|
const relatedIds = parseRelationshipList(paths, input.related);
|
|
const owner =
|
|
typeof input.owner === 'string' && input.owner.trim() ? input.owner.trim() : undefined;
|
|
const createdBy =
|
|
typeof input.from === 'string' && input.from.trim()
|
|
? input.from.trim()
|
|
: typeof input.createdBy === 'string' && input.createdBy.trim()
|
|
? input.createdBy.trim()
|
|
: undefined;
|
|
const createdAt =
|
|
typeof input.createdAt === 'string' && input.createdAt.trim()
|
|
? input.createdAt.trim()
|
|
: nowIso();
|
|
const status = computeInitialStatus(paths, input, owner, blockedByIds);
|
|
const displayId = pickUniqueDisplayId(paths, canonicalId, input.displayId);
|
|
|
|
for (const depId of blockedByIds) {
|
|
if (wouldCreateBlockCycle(paths, canonicalId, depId)) {
|
|
throw new Error(`Circular dependency: ${depId} already depends on ${canonicalId}`);
|
|
}
|
|
}
|
|
|
|
const task = normalizeTask({
|
|
id: canonicalId,
|
|
displayId,
|
|
subject:
|
|
typeof input.subject === 'string' && input.subject.trim()
|
|
? input.subject.trim()
|
|
: String(input.subject || '').trim(),
|
|
description:
|
|
typeof input.description === 'string' && input.description.length > 0
|
|
? input.description
|
|
: String(input.subject || '').trim(),
|
|
descriptionTaskRefs: normalizeTaskRefs(input.descriptionTaskRefs),
|
|
activeForm:
|
|
typeof input.activeForm === 'string'
|
|
? input.activeForm
|
|
: typeof input['active-form'] === 'string'
|
|
? input['active-form']
|
|
: undefined,
|
|
owner,
|
|
createdBy,
|
|
status,
|
|
createdAt,
|
|
updatedAt: createdAt,
|
|
workIntervals:
|
|
status === 'in_progress'
|
|
? [{ startedAt: createdAt }]
|
|
: Array.isArray(input.workIntervals)
|
|
? input.workIntervals
|
|
: undefined,
|
|
historyEvents: appendHistoryEvent(undefined, {
|
|
type: 'task_created',
|
|
status,
|
|
...(createdBy ? { actor: createdBy } : {}),
|
|
timestamp: createdAt,
|
|
}),
|
|
blocks: Array.isArray(input.blocks) ? [...input.blocks] : [],
|
|
blockedBy: blockedByIds,
|
|
related: relatedIds.length > 0 ? relatedIds : undefined,
|
|
projectPath:
|
|
typeof input.projectPath === 'string' && input.projectPath.trim()
|
|
? input.projectPath.trim()
|
|
: undefined,
|
|
comments: Array.isArray(input.comments) ? input.comments : undefined,
|
|
prompt:
|
|
typeof input.prompt === 'string' && input.prompt.trim() ? input.prompt.trim() : undefined,
|
|
promptTaskRefs: normalizeTaskRefs(input.promptTaskRefs),
|
|
needsClarification:
|
|
input.needsClarification === 'lead' || input.needsClarification === 'user'
|
|
? input.needsClarification
|
|
: undefined,
|
|
reviewState: normalizeTaskReviewState(input.reviewState),
|
|
deletedAt:
|
|
status === 'deleted' && typeof input.deletedAt === 'string' ? input.deletedAt : undefined,
|
|
attachments: Array.isArray(input.attachments) ? input.attachments : undefined,
|
|
...(typeof input.sourceMessageId === 'string' && input.sourceMessageId.trim()
|
|
? { sourceMessageId: input.sourceMessageId.trim() }
|
|
: {}),
|
|
...(input.sourceMessage && typeof input.sourceMessage === 'object'
|
|
? { sourceMessage: input.sourceMessage }
|
|
: {}),
|
|
});
|
|
|
|
if (!task.subject) {
|
|
throw new Error('Missing subject');
|
|
}
|
|
|
|
writeTask(paths, task);
|
|
|
|
for (const depId of blockedByIds) {
|
|
const dependencyTask = readTask(paths, depId, { includeDeleted: true });
|
|
const dependencyBlocks = Array.isArray(dependencyTask.blocks) ? dependencyTask.blocks : [];
|
|
if (!dependencyBlocks.includes(task.id)) {
|
|
dependencyTask.blocks = dependencyBlocks.concat([task.id]);
|
|
dependencyTask.updatedAt = nowIso();
|
|
writeTask(paths, dependencyTask);
|
|
}
|
|
}
|
|
|
|
for (const relatedId of relatedIds) {
|
|
const relatedTask = readTask(paths, relatedId, { includeDeleted: true });
|
|
const existingRelated = Array.isArray(relatedTask.related) ? relatedTask.related : [];
|
|
if (!existingRelated.includes(task.id)) {
|
|
relatedTask.related = existingRelated.concat([task.id]);
|
|
relatedTask.updatedAt = nowIso();
|
|
writeTask(paths, relatedTask);
|
|
}
|
|
}
|
|
|
|
return task;
|
|
}
|
|
|
|
function updateTask(paths, taskRef, updater, options = {}) {
|
|
const existingTask = readTask(paths, taskRef, { includeDeleted: true });
|
|
const nextTask = normalizeTask(updater({ ...existingTask }) || existingTask);
|
|
nextTask.updatedAt = nowIso();
|
|
writeTask(paths, nextTask);
|
|
return nextTask;
|
|
}
|
|
|
|
function setTaskStatus(paths, taskRef, nextStatus, actor) {
|
|
const status = normalizeStatus(nextStatus);
|
|
if (!status) {
|
|
throw new Error(`Invalid status: ${String(nextStatus)}`);
|
|
}
|
|
|
|
return updateTask(paths, taskRef, (task) => {
|
|
if (task.status === status) {
|
|
if (status === 'deleted' || status === 'in_progress') {
|
|
task.reviewState = 'none';
|
|
} else if (
|
|
status === 'pending' &&
|
|
normalizeTaskReviewState(task.reviewState) !== 'needsFix'
|
|
) {
|
|
task.reviewState = 'none';
|
|
}
|
|
return task;
|
|
}
|
|
const timestamp = nowIso();
|
|
const workIntervals = Array.isArray(task.workIntervals) ? [...task.workIntervals] : [];
|
|
const lastInterval = workIntervals.length > 0 ? workIntervals[workIntervals.length - 1] : null;
|
|
|
|
if (task.status !== 'in_progress' && status === 'in_progress') {
|
|
if (!lastInterval || lastInterval.completedAt !== undefined) {
|
|
workIntervals.push({ startedAt: timestamp });
|
|
}
|
|
} else if (task.status === 'in_progress' && status !== 'in_progress') {
|
|
if (lastInterval && lastInterval.completedAt === undefined) {
|
|
lastInterval.completedAt = timestamp;
|
|
}
|
|
}
|
|
if (status === 'pending' || status === 'in_progress' || status === 'deleted') {
|
|
closeOpenReviewIntervals(task, timestamp);
|
|
}
|
|
|
|
task.workIntervals = workIntervals.length > 0 ? workIntervals : undefined;
|
|
task.historyEvents = appendHistoryEvent(task.historyEvents, {
|
|
type: 'status_changed',
|
|
from: task.status,
|
|
to: status,
|
|
...(actor ? { actor } : {}),
|
|
timestamp,
|
|
});
|
|
task.status = status;
|
|
|
|
if (status === 'deleted') {
|
|
task.deletedAt = timestamp;
|
|
task.reviewState = 'none';
|
|
} else if (task.deletedAt) {
|
|
delete task.deletedAt;
|
|
}
|
|
|
|
if (status === 'in_progress') {
|
|
task.reviewState = 'none';
|
|
} else if (status === 'pending' && normalizeTaskReviewState(task.reviewState) !== 'needsFix') {
|
|
task.reviewState = 'none';
|
|
}
|
|
|
|
return task;
|
|
});
|
|
}
|
|
|
|
function normalizeOwnerValue(owner) {
|
|
if (owner == null || owner === 'clear' || owner === 'none') {
|
|
return undefined;
|
|
}
|
|
const normalized = String(owner).trim();
|
|
return normalized ? normalized : undefined;
|
|
}
|
|
|
|
function setTaskOwner(paths, taskRef, owner, actor) {
|
|
return updateTask(paths, taskRef, (task) => {
|
|
const previousOwner = normalizeOwnerValue(task.owner);
|
|
const nextOwner = normalizeOwnerValue(owner);
|
|
|
|
if (nextOwner) {
|
|
task.owner = nextOwner;
|
|
} else {
|
|
delete task.owner;
|
|
}
|
|
|
|
if (previousOwner !== nextOwner) {
|
|
task.historyEvents = appendHistoryEvent(task.historyEvents, {
|
|
type: 'owner_changed',
|
|
...(previousOwner ? { from: previousOwner } : {}),
|
|
...(nextOwner ? { to: nextOwner } : {}),
|
|
...(actor ? { actor } : {}),
|
|
});
|
|
}
|
|
|
|
return task;
|
|
});
|
|
}
|
|
|
|
function updateTaskFields(paths, taskRef, fields) {
|
|
return updateTask(paths, taskRef, (task) => {
|
|
if (fields.subject !== undefined) {
|
|
task.subject = fields.subject;
|
|
}
|
|
if (fields.description !== undefined) {
|
|
task.description = fields.description;
|
|
}
|
|
return task;
|
|
});
|
|
}
|
|
|
|
function normalizeMemberName(value) {
|
|
return typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : '';
|
|
}
|
|
|
|
function addTaskComment(paths, taskRef, text, options = {}) {
|
|
if (typeof text !== 'string' || !text.trim()) {
|
|
throw new Error('Missing comment text');
|
|
}
|
|
|
|
const comment = {
|
|
id: options.id || crypto.randomUUID(),
|
|
author:
|
|
typeof options.author === 'string' && options.author.trim() ? options.author.trim() : 'user',
|
|
text,
|
|
createdAt:
|
|
typeof options.createdAt === 'string' && options.createdAt.trim()
|
|
? options.createdAt.trim()
|
|
: nowIso(),
|
|
type: options.type || 'regular',
|
|
...(normalizeTaskRefs(options.taskRefs)
|
|
? { taskRefs: normalizeTaskRefs(options.taskRefs) }
|
|
: {}),
|
|
...(Array.isArray(options.attachments) && options.attachments.length > 0
|
|
? { attachments: options.attachments }
|
|
: {}),
|
|
};
|
|
|
|
let inserted = false;
|
|
const task = updateTask(paths, taskRef, (currentTask) => {
|
|
const comments = Array.isArray(currentTask.comments) ? currentTask.comments : [];
|
|
if (comments.some((entry) => entry.id === comment.id)) {
|
|
return currentTask;
|
|
}
|
|
|
|
currentTask.comments = comments.concat([comment]);
|
|
inserted = true;
|
|
return currentTask;
|
|
});
|
|
|
|
return { comment, task, inserted, clarificationCleared: false };
|
|
}
|
|
|
|
function setNeedsClarification(paths, taskRef, value) {
|
|
return updateTask(paths, taskRef, (task) => {
|
|
if (value === null || value === 'clear') {
|
|
delete task.needsClarification;
|
|
} else if (value === 'lead' || value === 'user') {
|
|
task.needsClarification = value;
|
|
} else {
|
|
throw new Error(`Invalid clarification value: ${String(value)}`);
|
|
}
|
|
return task;
|
|
});
|
|
}
|
|
|
|
function addTaskAttachmentMeta(paths, taskRef, meta) {
|
|
return updateTask(paths, taskRef, (task) => {
|
|
const attachments = Array.isArray(task.attachments) ? task.attachments : [];
|
|
if (!attachments.some((entry) => entry.id === meta.id)) {
|
|
task.attachments = attachments.concat([meta]);
|
|
}
|
|
return task;
|
|
});
|
|
}
|
|
|
|
function removeTaskAttachment(paths, taskRef, attachmentId) {
|
|
return updateTask(paths, taskRef, (task) => {
|
|
const attachments = Array.isArray(task.attachments) ? task.attachments : [];
|
|
const filtered = attachments.filter((entry) => entry.id !== attachmentId);
|
|
if (filtered.length > 0) task.attachments = filtered;
|
|
else delete task.attachments;
|
|
return task;
|
|
});
|
|
}
|
|
|
|
function addCommentAttachmentMeta(paths, taskRef, commentRef, meta) {
|
|
return updateTask(paths, taskRef, (task) => {
|
|
const comments = Array.isArray(task.comments) ? [...task.comments] : [];
|
|
const commentIndex = comments.findIndex((entry) => String(entry.id) === String(commentRef));
|
|
if (commentIndex < 0) {
|
|
throw new Error(`Comment not found: ${String(commentRef)}`);
|
|
}
|
|
const comment = { ...comments[commentIndex] };
|
|
const attachments = Array.isArray(comment.attachments) ? comment.attachments : [];
|
|
if (!attachments.some((entry) => entry.id === meta.id)) {
|
|
comment.attachments = attachments.concat([meta]);
|
|
}
|
|
comments[commentIndex] = comment;
|
|
task.comments = comments;
|
|
return task;
|
|
});
|
|
}
|
|
|
|
function linkTask(paths, taskRef, targetRef, relationship) {
|
|
const sourceId = resolveTaskRef(paths, taskRef);
|
|
const targetId = resolveTaskRef(paths, targetRef);
|
|
if (sourceId === targetId) {
|
|
throw new Error('Cannot link a task to itself');
|
|
}
|
|
|
|
if (relationship === 'blocks') {
|
|
return linkTask(paths, targetId, sourceId, 'blocked-by');
|
|
}
|
|
|
|
if (relationship === 'blocked-by') {
|
|
if (wouldCreateBlockCycle(paths, sourceId, targetId)) {
|
|
throw new Error(`Circular dependency: ${targetId} already depends on ${sourceId}`);
|
|
}
|
|
|
|
const sourceTask = readTask(paths, sourceId, { includeDeleted: true });
|
|
const targetTask = readTask(paths, targetId, { includeDeleted: true });
|
|
if (!(sourceTask.blockedBy || []).includes(targetId)) {
|
|
sourceTask.blockedBy = [...(sourceTask.blockedBy || []), targetId];
|
|
writeTask(paths, sourceTask);
|
|
}
|
|
if (!(targetTask.blocks || []).includes(sourceId)) {
|
|
targetTask.blocks = [...(targetTask.blocks || []), sourceId];
|
|
writeTask(paths, targetTask);
|
|
}
|
|
return readTask(paths, sourceId, { includeDeleted: true });
|
|
}
|
|
|
|
if (relationship !== 'related') {
|
|
throw new Error(`Unsupported relationship: ${String(relationship)}`);
|
|
}
|
|
|
|
const sourceTask = readTask(paths, sourceId, { includeDeleted: true });
|
|
const targetTask = readTask(paths, targetId, { includeDeleted: true });
|
|
if (!(sourceTask.related || []).includes(targetId)) {
|
|
sourceTask.related = [...(sourceTask.related || []), targetId];
|
|
writeTask(paths, sourceTask);
|
|
}
|
|
if (!(targetTask.related || []).includes(sourceId)) {
|
|
targetTask.related = [...(targetTask.related || []), sourceId];
|
|
writeTask(paths, targetTask);
|
|
}
|
|
return readTask(paths, sourceId, { includeDeleted: true });
|
|
}
|
|
|
|
function unlinkTask(paths, taskRef, targetRef, relationship) {
|
|
const sourceId = resolveTaskRef(paths, taskRef, { includeDeleted: true });
|
|
const targetId = resolveTaskRef(paths, targetRef, { includeDeleted: true });
|
|
|
|
if (relationship === 'blocks') {
|
|
return unlinkTask(paths, targetId, sourceId, 'blocked-by');
|
|
}
|
|
|
|
const sourceTask = readTask(paths, sourceId, { includeDeleted: true });
|
|
if (relationship === 'blocked-by') {
|
|
sourceTask.blockedBy = (sourceTask.blockedBy || []).filter((entry) => entry !== targetId);
|
|
writeTask(paths, sourceTask);
|
|
try {
|
|
const targetTask = readTask(paths, targetId, { includeDeleted: true });
|
|
targetTask.blocks = (targetTask.blocks || []).filter((entry) => entry !== sourceId);
|
|
writeTask(paths, targetTask);
|
|
} catch {
|
|
// Ignore missing reverse link target.
|
|
}
|
|
return readTask(paths, sourceId, { includeDeleted: true });
|
|
}
|
|
|
|
if (relationship !== 'related') {
|
|
throw new Error(`Unsupported relationship: ${String(relationship)}`);
|
|
}
|
|
|
|
sourceTask.related = (sourceTask.related || []).filter((entry) => entry !== targetId);
|
|
writeTask(paths, sourceTask);
|
|
try {
|
|
const targetTask = readTask(paths, targetId, { includeDeleted: true });
|
|
targetTask.related = (targetTask.related || []).filter((entry) => entry !== sourceId);
|
|
writeTask(paths, targetTask);
|
|
} catch {
|
|
// Ignore missing reverse link target.
|
|
}
|
|
return readTask(paths, sourceId, { includeDeleted: true });
|
|
}
|
|
|
|
function buildTaskReference(task) {
|
|
return `#${task.displayId || deriveDisplayId(task.id)} (taskId: ${task.id})`;
|
|
}
|
|
|
|
function getTaskFreshness(task) {
|
|
const updated = Date.parse(String(task.updatedAt || ''));
|
|
if (Number.isFinite(updated) && updated > 0) return updated;
|
|
const created = Date.parse(String(task.createdAt || ''));
|
|
if (Number.isFinite(created) && created > 0) return created;
|
|
return 0;
|
|
}
|
|
|
|
function compareTasksByFreshness(a, b) {
|
|
const freshnessDiff = getTaskFreshness(b) - getTaskFreshness(a);
|
|
if (freshnessDiff !== 0) return freshnessDiff;
|
|
const byDisplay = String(a.displayId || a.id).localeCompare(
|
|
String(b.displayId || b.id),
|
|
undefined,
|
|
{
|
|
numeric: true,
|
|
sensitivity: 'base',
|
|
}
|
|
);
|
|
if (byDisplay !== 0) return byDisplay;
|
|
return String(a.id).localeCompare(String(b.id), undefined, {
|
|
numeric: true,
|
|
sensitivity: 'base',
|
|
});
|
|
}
|
|
|
|
function getEffectiveReviewState(kanbanEntry, task) {
|
|
return reviewStateHelpers.getEffectiveReviewState(task, kanbanEntry).state;
|
|
}
|
|
|
|
function formatBriefTaskLine(task, reviewState) {
|
|
const reviewSuffix = reviewState !== 'none' ? `, review=${reviewState}` : '';
|
|
return `- #${task.displayId || deriveDisplayId(task.id)} [status=${task.status}${reviewSuffix}] ${task.subject}`;
|
|
}
|
|
|
|
function formatCommentLine(comment) {
|
|
const author =
|
|
typeof comment.author === 'string' && comment.author.trim() ? comment.author.trim() : 'unknown';
|
|
const text = typeof comment.text === 'string' ? comment.text.trim() : '';
|
|
return ` - ${author}: ${text || '(empty comment)'}`;
|
|
}
|
|
|
|
function formatTaskBriefing(paths, teamName, memberName) {
|
|
const kanbanState = readJson(path.join(paths.teamDir, 'kanban-state.json'), {
|
|
teamName,
|
|
reviewers: [],
|
|
tasks: {},
|
|
});
|
|
const activeTasks = listTasks(paths)
|
|
.filter((task) => task.owner === memberName && task.status !== 'deleted')
|
|
.sort(compareTasksByFreshness);
|
|
|
|
if (activeTasks.length === 0) {
|
|
return `No assigned tasks for ${memberName}.`;
|
|
}
|
|
|
|
const groups = {
|
|
in_progress: activeTasks.filter((task) => task.status === 'in_progress'),
|
|
needs_fix: activeTasks.filter((task) => {
|
|
const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
|
|
return (
|
|
task.status !== 'in_progress' && getEffectiveReviewState(kanbanEntry, task) === 'needsFix'
|
|
);
|
|
}),
|
|
pending: activeTasks.filter((task) => {
|
|
const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
|
|
return task.status === 'pending' && getEffectiveReviewState(kanbanEntry, task) === 'none';
|
|
}),
|
|
review: activeTasks.filter((task) => {
|
|
const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
|
|
return getEffectiveReviewState(kanbanEntry, task) === 'review';
|
|
}),
|
|
completed: activeTasks.filter((task) => {
|
|
const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
|
|
return task.status === 'completed' && getEffectiveReviewState(kanbanEntry, task) === 'none';
|
|
}),
|
|
approved: activeTasks.filter((task) => {
|
|
const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
|
|
return getEffectiveReviewState(kanbanEntry, task) === 'approved';
|
|
}),
|
|
};
|
|
|
|
const lines = [`Task briefing for ${memberName}:`];
|
|
|
|
if (groups.in_progress.length > 0) {
|
|
lines.push('', 'In progress:');
|
|
for (const task of groups.in_progress) {
|
|
const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
|
|
const reviewState = getEffectiveReviewState(kanbanEntry, task);
|
|
lines.push(formatBriefTaskLine(task, reviewState));
|
|
if (task.description) {
|
|
lines.push(` Description: ${task.description}`);
|
|
}
|
|
if (Array.isArray(task.comments) && task.comments.length > 0) {
|
|
lines.push(' Comments:');
|
|
for (const comment of task.comments) {
|
|
lines.push(formatCommentLine(comment));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (groups.needs_fix.length > 0) {
|
|
lines.push('', 'Needs fixes after review:');
|
|
for (const task of groups.needs_fix) {
|
|
const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
|
|
lines.push(formatBriefTaskLine(task, getEffectiveReviewState(kanbanEntry, task)));
|
|
}
|
|
}
|
|
|
|
if (groups.pending.length > 0) {
|
|
lines.push('', 'Pending:');
|
|
for (const task of groups.pending) {
|
|
const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
|
|
lines.push(formatBriefTaskLine(task, getEffectiveReviewState(kanbanEntry, task)));
|
|
}
|
|
}
|
|
|
|
if (groups.review.length > 0) {
|
|
lines.push('', 'Review:');
|
|
for (const task of groups.review) {
|
|
const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
|
|
lines.push(formatBriefTaskLine(task, getEffectiveReviewState(kanbanEntry, task)));
|
|
}
|
|
}
|
|
|
|
if (groups.completed.length > 0) {
|
|
lines.push('', 'Completed:');
|
|
for (const task of groups.completed) {
|
|
const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
|
|
lines.push(formatBriefTaskLine(task, getEffectiveReviewState(kanbanEntry, task)));
|
|
}
|
|
}
|
|
|
|
if (groups.approved.length > 0) {
|
|
lines.push('', 'Approved (last 10):');
|
|
for (const task of groups.approved.slice(0, 10)) {
|
|
const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
|
|
lines.push(formatBriefTaskLine(task, getEffectiveReviewState(kanbanEntry, task)));
|
|
}
|
|
}
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
module.exports = {
|
|
addCommentAttachmentMeta,
|
|
addTaskAttachmentMeta,
|
|
addTaskComment,
|
|
appendHistoryEvent,
|
|
buildTaskReference,
|
|
createTask,
|
|
deriveDisplayId,
|
|
formatTaskBriefing,
|
|
linkTask,
|
|
listTaskRows,
|
|
listTasks,
|
|
readTask,
|
|
removeTaskAttachment,
|
|
resolveTaskRef,
|
|
setNeedsClarification,
|
|
setTaskOwner,
|
|
setTaskStatus,
|
|
unlinkTask,
|
|
updateTask,
|
|
updateTaskFields,
|
|
};
|