2026-08-18 02:59:04 +03:00
|
|
|
/**
|
|
|
|
|
* Project context storage: notes, todos, and plan files.
|
|
|
|
|
*
|
|
|
|
|
* The server is the sole writer of `<projectsDir>/<projectId>/context.json`.
|
|
|
|
|
* The sibling `<projectsDir>/<projectId>.json` stays client-owned (worktree
|
|
|
|
|
* setup, draft starters, project actions) and server-owned only for
|
|
|
|
|
* `version`/`scheduledTasks`; keeping the two apart is what removes the
|
|
|
|
|
* cross-process read-modify-write race that a shared file would create.
|
|
|
|
|
*
|
|
|
|
|
* Plan bodies live as markdown at `<projectsDir>/<projectId>/plans/<file>.md`
|
|
|
|
|
* and are referenced by base name only, so moving the project storage
|
|
|
|
|
* directory never invalidates a reference.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
const PROJECT_CONTEXT_VERSION = 2;
|
|
|
|
|
const PROJECT_NOTE_BODY_MAX_LENGTH = 3000;
|
|
|
|
|
const PROJECT_NOTE_MAX_ITEMS = 200;
|
|
|
|
|
const PROJECT_TODO_TEXT_MAX_LENGTH = 120;
|
|
|
|
|
const PROJECT_PLAN_TITLE_MAX_LENGTH = 160;
|
|
|
|
|
const PROJECT_PLAN_BODY_MAX_LENGTH = 200_000;
|
|
|
|
|
const PROJECT_TODO_MAX_ITEMS = 500;
|
|
|
|
|
const PROJECT_PLAN_MAX_ITEMS = 500;
|
|
|
|
|
|
|
|
|
|
const PROJECT_ID_PATTERN = /^[a-zA-Z0-9._:-]+$/;
|
|
|
|
|
const PLAN_FILE_PATTERN = /^[a-zA-Z0-9._-]+\.md$/;
|
|
|
|
|
|
|
|
|
|
const asNonEmptyString = (value) => {
|
|
|
|
|
if (typeof value !== 'string') return null;
|
|
|
|
|
const trimmed = value.trim();
|
|
|
|
|
return trimmed.length > 0 ? trimmed : null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const clampLength = (value, maxLength) => {
|
|
|
|
|
if (typeof value !== 'string') return '';
|
|
|
|
|
return value.length > maxLength ? value.slice(0, maxLength) : value;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const isObjectRecord = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
|
|
|
|
|
|
|
|
const NOTE_SOURCES = new Set(['manual', 'selection', 'agent']);
|
|
|
|
|
|
|
|
|
|
const sanitizeNoteOrigin = (value) => {
|
|
|
|
|
if (!isObjectRecord(value)) return null;
|
|
|
|
|
const sessionId = asNonEmptyString(value.sessionId);
|
|
|
|
|
const messageId = asNonEmptyString(value.messageId);
|
|
|
|
|
if (!sessionId) return null;
|
|
|
|
|
return messageId ? { sessionId, messageId } : { sessionId };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Notes are a list of entries.
|
|
|
|
|
*
|
|
|
|
|
* Version 1 stored a single string. It is converted here rather than in a
|
|
|
|
|
* separate migration pass so that any read — including one that races another
|
|
|
|
|
* writer — sees the same shape.
|
|
|
|
|
*/
|
|
|
|
|
const sanitizeNotes = (value, now) => {
|
|
|
|
|
if (typeof value === 'string') {
|
|
|
|
|
const body = clampLength(value, PROJECT_NOTE_BODY_MAX_LENGTH).trim();
|
|
|
|
|
if (!body) return [];
|
|
|
|
|
return [{
|
|
|
|
|
id: `note_legacy_${now}`,
|
|
|
|
|
body,
|
|
|
|
|
createdAt: now,
|
|
|
|
|
updatedAt: now,
|
|
|
|
|
source: 'manual',
|
|
|
|
|
pinned: false,
|
|
|
|
|
}];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!Array.isArray(value)) return [];
|
|
|
|
|
|
|
|
|
|
const result = [];
|
|
|
|
|
const seen = new Set();
|
|
|
|
|
for (const entry of value) {
|
|
|
|
|
if (result.length >= PROJECT_NOTE_MAX_ITEMS) break;
|
|
|
|
|
if (!isObjectRecord(entry)) continue;
|
|
|
|
|
const id = asNonEmptyString(entry.id);
|
|
|
|
|
const body = clampLength(typeof entry.body === 'string' ? entry.body : '', PROJECT_NOTE_BODY_MAX_LENGTH).trim();
|
|
|
|
|
if (!id || !body || seen.has(id)) continue;
|
|
|
|
|
seen.add(id);
|
|
|
|
|
|
|
|
|
|
const createdAt = Number.isFinite(entry.createdAt) && entry.createdAt >= 0 ? entry.createdAt : now;
|
|
|
|
|
const origin = sanitizeNoteOrigin(entry.origin);
|
|
|
|
|
result.push({
|
|
|
|
|
id,
|
|
|
|
|
body,
|
|
|
|
|
createdAt,
|
|
|
|
|
updatedAt: Number.isFinite(entry.updatedAt) && entry.updatedAt >= 0 ? entry.updatedAt : createdAt,
|
|
|
|
|
source: NOTE_SOURCES.has(entry.source) ? entry.source : 'manual',
|
|
|
|
|
pinned: entry.pinned === true,
|
|
|
|
|
...(origin ? { origin } : {}),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result.sort((a, b) => b.createdAt - a.createdAt);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const sanitizeTodos = (value, now) => {
|
|
|
|
|
if (!Array.isArray(value)) return [];
|
|
|
|
|
const result = [];
|
|
|
|
|
const seen = new Set();
|
|
|
|
|
for (const entry of value) {
|
|
|
|
|
if (result.length >= PROJECT_TODO_MAX_ITEMS) break;
|
|
|
|
|
if (!isObjectRecord(entry)) continue;
|
|
|
|
|
const id = asNonEmptyString(entry.id);
|
|
|
|
|
const text = clampLength(asNonEmptyString(entry.text) || '', PROJECT_TODO_TEXT_MAX_LENGTH);
|
|
|
|
|
if (!id || !text || seen.has(id)) continue;
|
|
|
|
|
seen.add(id);
|
|
|
|
|
result.push({
|
|
|
|
|
id,
|
|
|
|
|
text,
|
|
|
|
|
completed: entry.completed === true,
|
|
|
|
|
createdAt: Number.isFinite(entry.createdAt) && entry.createdAt >= 0 ? entry.createdAt : now,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return result;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const sanitizePlanTitle = (value) => clampLength(asNonEmptyString(value) || '', PROJECT_PLAN_TITLE_MAX_LENGTH);
|
|
|
|
|
|
|
|
|
|
export const parsePlanMarkdown = (raw) => {
|
|
|
|
|
const normalized = (typeof raw === 'string' ? raw : '').replace(/\r\n?/g, '\n');
|
|
|
|
|
const match = normalized.match(/^\s*#\s+(.+?)\s*(?:\n+|$)/);
|
|
|
|
|
if (match) {
|
|
|
|
|
return {
|
|
|
|
|
title: sanitizePlanTitle(match[1]) || 'Plan',
|
|
|
|
|
body: normalized.slice(match[0].length).replace(/^\n+/, ''),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
const firstLine = normalized.split('\n').map((line) => line.trim()).find(Boolean) || 'Plan';
|
|
|
|
|
return {
|
|
|
|
|
title: sanitizePlanTitle(firstLine.replace(/^#+\s*/, '')) || 'Plan',
|
|
|
|
|
body: normalized.trim(),
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const formatPlanMarkdown = (title, body) => {
|
|
|
|
|
const normalizedTitle = sanitizePlanTitle(title) || 'Plan';
|
|
|
|
|
const normalizedBody = typeof body === 'string' ? body.trim() : '';
|
|
|
|
|
return normalizedBody ? `# ${normalizedTitle}\n\n${normalizedBody}` : `# ${normalizedTitle}\n`;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const slugifyPlanTitle = (value) => {
|
|
|
|
|
const normalized = value
|
|
|
|
|
.trim()
|
|
|
|
|
.toLowerCase()
|
|
|
|
|
.replace(/[`*_#>[\](){}.!?,:;"']/g, '')
|
|
|
|
|
.replace(/\s+/g, '-')
|
|
|
|
|
.replace(/[^a-z0-9-]/g, '-')
|
|
|
|
|
.replace(/-+/g, '-')
|
|
|
|
|
.replace(/^-+|-+$/g, '');
|
|
|
|
|
return normalized || 'plan';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const sanitizePlanLinks = (value, now) => {
|
|
|
|
|
if (!Array.isArray(value)) return [];
|
|
|
|
|
const result = [];
|
|
|
|
|
const seenIds = new Set();
|
|
|
|
|
const seenFiles = new Set();
|
|
|
|
|
for (const entry of value) {
|
|
|
|
|
if (result.length >= PROJECT_PLAN_MAX_ITEMS) break;
|
|
|
|
|
if (!isObjectRecord(entry)) continue;
|
|
|
|
|
const id = asNonEmptyString(entry.id);
|
|
|
|
|
const file = asNonEmptyString(entry.file);
|
|
|
|
|
if (!id || !file || !PLAN_FILE_PATTERN.test(file)) continue;
|
2026-09-07 17:50:55 +03:00
|
|
|
// A personal file and a shared file may carry the same name: they live in different folders.
|
|
|
|
|
const shared = entry.shared === true;
|
|
|
|
|
const fileKey = `${shared ? 'shared' : 'personal'}/${file}`;
|
|
|
|
|
if (seenIds.has(id) || seenFiles.has(fileKey)) continue;
|
2026-08-18 02:59:04 +03:00
|
|
|
seenIds.add(id);
|
2026-09-07 17:50:55 +03:00
|
|
|
seenFiles.add(fileKey);
|
2026-08-18 02:59:04 +03:00
|
|
|
result.push({
|
|
|
|
|
id,
|
|
|
|
|
file,
|
|
|
|
|
title: sanitizePlanTitle(entry.title) || 'Plan',
|
|
|
|
|
createdAt: Number.isFinite(entry.createdAt) && entry.createdAt >= 0 ? entry.createdAt : now,
|
|
|
|
|
pinned: entry.pinned === true,
|
2026-09-07 17:50:55 +03:00
|
|
|
// The user's own plan that was moved into the team's folder: it keeps
|
|
|
|
|
// its id (session attachments still point at it) and this flag says
|
|
|
|
|
// which folder holds the file.
|
|
|
|
|
shared,
|
2026-08-18 02:59:04 +03:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return result.sort((a, b) => b.createdAt - a.createdAt);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const createEmptyContext = () => ({
|
|
|
|
|
version: PROJECT_CONTEXT_VERSION,
|
|
|
|
|
notes: [],
|
|
|
|
|
todos: [],
|
|
|
|
|
plans: [],
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-07 17:50:55 +03:00
|
|
|
const SHARED_PLAN_ID_PREFIX = 'shared:';
|
|
|
|
|
|
2026-08-18 02:59:04 +03:00
|
|
|
export const createProjectContextRuntime = (deps) => {
|
2026-09-07 17:50:55 +03:00
|
|
|
const { fsPromises, path, projectsDirPath, createId, resolveSharedPlansDir } = deps;
|
|
|
|
|
// The team's shared plans folder for a project (absolute path) or null; the
|
|
|
|
|
// project config runtime owns that answer (`plansDir` in the shared file).
|
|
|
|
|
const sharedPlansDirFor = typeof resolveSharedPlansDir === 'function'
|
|
|
|
|
? resolveSharedPlansDir
|
|
|
|
|
: async () => null;
|
2026-08-18 02:59:04 +03:00
|
|
|
|
|
|
|
|
const idFactory = typeof createId === 'function'
|
|
|
|
|
? createId
|
|
|
|
|
: () => (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
|
|
|
|
? crypto.randomUUID()
|
|
|
|
|
: `plan_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`);
|
|
|
|
|
|
|
|
|
|
const writeLocks = new Map();
|
|
|
|
|
|
|
|
|
|
const sanitizeProjectId = (projectId) => {
|
|
|
|
|
const value = asNonEmptyString(projectId);
|
|
|
|
|
if (!value) {
|
|
|
|
|
throw new Error('projectId is required');
|
|
|
|
|
}
|
|
|
|
|
if (!PROJECT_ID_PATTERN.test(value)) {
|
|
|
|
|
throw new Error('projectId contains unsupported characters');
|
|
|
|
|
}
|
|
|
|
|
return value;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const storageDirFor = (projectId) => path.join(projectsDirPath, sanitizeProjectId(projectId));
|
|
|
|
|
const contextPathFor = (projectId) => path.join(storageDirFor(projectId), 'context.json');
|
|
|
|
|
const plansDirFor = (projectId) => path.join(storageDirFor(projectId), 'plans');
|
|
|
|
|
const legacyConfigPathFor = (projectId) => path.join(projectsDirPath, `${sanitizeProjectId(projectId)}.json`);
|
|
|
|
|
|
|
|
|
|
const readJson = async (filePath) => {
|
|
|
|
|
let raw;
|
|
|
|
|
try {
|
|
|
|
|
raw = await fsPromises.readFile(filePath, 'utf8');
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (error && error.code === 'ENOENT') return { missing: true, value: null };
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
const parsed = JSON.parse(raw);
|
|
|
|
|
return { missing: false, value: isObjectRecord(parsed) ? parsed : null };
|
|
|
|
|
} catch {
|
|
|
|
|
return { missing: false, value: null };
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const writeJsonAtomic = async (filePath, value) => {
|
|
|
|
|
const temporaryPath = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
|
|
|
await fsPromises.mkdir(path.dirname(filePath), { recursive: true });
|
2026-08-22 11:55:38 +03:00
|
|
|
try {
|
|
|
|
|
await fsPromises.writeFile(temporaryPath, JSON.stringify(value, null, 2), 'utf8');
|
|
|
|
|
await fsPromises.rename(temporaryPath, filePath);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
await fsPromises.rm(temporaryPath, { force: true }).catch(() => {});
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
2026-08-18 02:59:04 +03:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const withWriteLock = async (projectId, mutate) => {
|
|
|
|
|
const key = sanitizeProjectId(projectId);
|
|
|
|
|
const previous = writeLocks.get(key) || Promise.resolve();
|
|
|
|
|
let release;
|
|
|
|
|
const next = new Promise((resolve) => { release = resolve; });
|
|
|
|
|
const chained = previous.finally(() => next);
|
|
|
|
|
writeLocks.set(key, chained);
|
|
|
|
|
|
|
|
|
|
await previous;
|
|
|
|
|
try {
|
|
|
|
|
return await mutate();
|
|
|
|
|
} finally {
|
|
|
|
|
release();
|
|
|
|
|
if (writeLocks.get(key) === chained) {
|
|
|
|
|
writeLocks.delete(key);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* One-time migration of `projectNotes` / `projectTodos` / `projectPlanFiles`
|
|
|
|
|
* out of the client-owned `<projectId>.json`.
|
|
|
|
|
*
|
|
|
|
|
* Plan links carried absolute paths; those are converted to base names. A
|
|
|
|
|
* referenced file that is not already inside the plans directory is moved
|
|
|
|
|
* there so a stale absolute path from an earlier project id is recovered
|
|
|
|
|
* rather than dropped. A link whose file cannot be located at all is kept
|
|
|
|
|
* out of the result — the markdown is gone, so the link is dead either way.
|
|
|
|
|
*
|
|
|
|
|
* The legacy keys are removed only after `context.json` is durably written.
|
|
|
|
|
* A failure at any point leaves the legacy keys in place, so the migration
|
|
|
|
|
* simply runs again on the next read.
|
|
|
|
|
*/
|
|
|
|
|
const migrateFromLegacyConfig = async (projectId, now) => {
|
|
|
|
|
const legacyPath = legacyConfigPathFor(projectId);
|
|
|
|
|
const legacy = await readJson(legacyPath);
|
|
|
|
|
if (!legacy.value) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const hasLegacyKeys = legacy.value.projectNotes !== undefined
|
|
|
|
|
|| legacy.value.projectTodos !== undefined
|
|
|
|
|
|| legacy.value.projectPlanFiles !== undefined;
|
|
|
|
|
if (!hasLegacyKeys) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const plansDir = plansDirFor(projectId);
|
|
|
|
|
const links = [];
|
|
|
|
|
const rawLinks = Array.isArray(legacy.value.projectPlanFiles) ? legacy.value.projectPlanFiles : [];
|
|
|
|
|
for (const entry of rawLinks) {
|
|
|
|
|
if (!isObjectRecord(entry)) continue;
|
|
|
|
|
const id = asNonEmptyString(entry.id);
|
|
|
|
|
const absolutePath = asNonEmptyString(entry.path);
|
|
|
|
|
if (!id || !absolutePath) continue;
|
|
|
|
|
|
|
|
|
|
const file = path.basename(absolutePath);
|
|
|
|
|
if (!PLAN_FILE_PATTERN.test(file)) continue;
|
|
|
|
|
const targetPath = path.join(plansDir, file);
|
|
|
|
|
|
|
|
|
|
let raw = null;
|
|
|
|
|
try {
|
|
|
|
|
raw = await fsPromises.readFile(targetPath, 'utf8');
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (!error || error.code !== 'ENOENT') throw error;
|
|
|
|
|
// Not in the plans directory yet — recover it from the recorded path.
|
|
|
|
|
try {
|
|
|
|
|
raw = await fsPromises.readFile(absolutePath, 'utf8');
|
|
|
|
|
} catch (recoverError) {
|
|
|
|
|
if (!recoverError || recoverError.code !== 'ENOENT') throw recoverError;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
await fsPromises.mkdir(plansDir, { recursive: true });
|
|
|
|
|
await fsPromises.writeFile(targetPath, raw, 'utf8');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
links.push({
|
|
|
|
|
id,
|
|
|
|
|
file,
|
|
|
|
|
title: parsePlanMarkdown(raw).title,
|
|
|
|
|
createdAt: Number.isFinite(entry.createdAt) && entry.createdAt >= 0 ? entry.createdAt : now,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const migrated = {
|
|
|
|
|
version: PROJECT_CONTEXT_VERSION,
|
|
|
|
|
notes: sanitizeNotes(legacy.value.projectNotes, now),
|
|
|
|
|
todos: sanitizeTodos(legacy.value.projectTodos, now),
|
|
|
|
|
plans: sanitizePlanLinks(links, now),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await writeJsonAtomic(contextPathFor(projectId), migrated);
|
|
|
|
|
|
|
|
|
|
const remaining = { ...legacy.value };
|
|
|
|
|
delete remaining.projectNotes;
|
|
|
|
|
delete remaining.projectTodos;
|
|
|
|
|
delete remaining.projectPlanFiles;
|
|
|
|
|
await writeJsonAtomic(legacyPath, remaining);
|
|
|
|
|
|
|
|
|
|
return migrated;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Read the stored context.
|
|
|
|
|
*
|
|
|
|
|
* Distinguishes the three states the caller must not conflate: a missing
|
|
|
|
|
* file is authoritative empty, malformed JSON is a failure, and an I/O
|
|
|
|
|
* error propagates. Never returns an empty context to paper over a read
|
|
|
|
|
* that did not succeed.
|
|
|
|
|
*
|
|
|
|
|
* Deliberately does NOT take the write lock: every mutator calls this while
|
|
|
|
|
* already holding it, so locking here would deadlock. The legacy migration
|
|
|
|
|
* it can trigger is safe unlocked — both of its writes are atomic renames
|
|
|
|
|
* of identical content, so concurrent migrations converge instead of
|
|
|
|
|
* interleaving.
|
|
|
|
|
*/
|
2026-09-07 17:50:55 +03:00
|
|
|
const readStoredContext = async (projectId) => {
|
2026-08-18 02:59:04 +03:00
|
|
|
const now = Date.now();
|
|
|
|
|
const stored = await readJson(contextPathFor(projectId));
|
|
|
|
|
|
|
|
|
|
if (!stored.missing && !stored.value) {
|
|
|
|
|
throw new Error('Stored project context is malformed');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (stored.missing) {
|
|
|
|
|
const migrated = await migrateFromLegacyConfig(projectId, now);
|
|
|
|
|
if (migrated) {
|
|
|
|
|
return {
|
|
|
|
|
version: PROJECT_CONTEXT_VERSION,
|
|
|
|
|
notes: sanitizeNotes(migrated.notes, now),
|
|
|
|
|
todos: sanitizeTodos(migrated.todos, now),
|
|
|
|
|
plans: sanitizePlanLinks(migrated.plans, now),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
return createEmptyContext();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
version: PROJECT_CONTEXT_VERSION,
|
|
|
|
|
notes: sanitizeNotes(stored.value.notes, now),
|
|
|
|
|
todos: sanitizeTodos(stored.value.todos, now),
|
|
|
|
|
plans: sanitizePlanLinks(stored.value.plans, now),
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-07 17:50:55 +03:00
|
|
|
/** The file name behind a shared plan id, or null when the id is not one. */
|
|
|
|
|
const sharedPlanFileOf = (planId) => {
|
|
|
|
|
if (typeof planId !== 'string' || !planId.startsWith(SHARED_PLAN_ID_PREFIX)) return null;
|
|
|
|
|
const file = planId.slice(SHARED_PLAN_ID_PREFIX.length);
|
|
|
|
|
return PLAN_FILE_PATTERN.test(file) ? file : null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Plans in the team's shared folder: every `.md` file there, newest first,
|
|
|
|
|
* addressed by `shared:<file>`. Plans written by other tools have no
|
|
|
|
|
* manifest entry, so the title comes from the file each time.
|
|
|
|
|
*/
|
|
|
|
|
const listSharedPlans = async (projectId, claimedFiles = new Set()) => {
|
|
|
|
|
const dir = await sharedPlansDirFor(projectId);
|
|
|
|
|
if (!dir) return { dir: null, plans: [] };
|
|
|
|
|
let entries;
|
|
|
|
|
try {
|
|
|
|
|
entries = await fsPromises.readdir(dir, { withFileTypes: true });
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (error && error.code === 'ENOENT') return { dir, plans: [] };
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
const plans = [];
|
|
|
|
|
for (const entry of entries) {
|
|
|
|
|
if (!entry.isFile() || !PLAN_FILE_PATTERN.test(entry.name)) continue;
|
|
|
|
|
// Listed under its own id by the manifest entry that moved it there.
|
|
|
|
|
if (claimedFiles.has(entry.name)) continue;
|
|
|
|
|
const filePath = path.join(dir, entry.name);
|
|
|
|
|
const [raw, stat] = await Promise.all([fsPromises.readFile(filePath, 'utf8'), fsPromises.stat(filePath)]);
|
|
|
|
|
plans.push({
|
|
|
|
|
id: `${SHARED_PLAN_ID_PREFIX}${entry.name}`,
|
|
|
|
|
file: entry.name,
|
|
|
|
|
title: parsePlanMarkdown(raw).title,
|
|
|
|
|
createdAt: Math.round(stat.mtimeMs),
|
|
|
|
|
pinned: false,
|
|
|
|
|
source: 'shared',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
plans.sort((left, right) => right.createdAt - left.createdAt);
|
|
|
|
|
return { dir, plans };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/** What clients see: the stored context plus the team's shared plans, each marked with its source. */
|
|
|
|
|
const readContext = async (projectId) => {
|
|
|
|
|
const stored = await readStoredContext(projectId);
|
|
|
|
|
const claimed = new Set(stored.plans.filter((plan) => plan.shared).map((plan) => plan.file));
|
|
|
|
|
const shared = await listSharedPlans(projectId, claimed);
|
|
|
|
|
const own = stored.plans
|
|
|
|
|
// A moved plan is only reachable while the project still has a shared folder.
|
|
|
|
|
.filter((plan) => !plan.shared || shared.dir)
|
|
|
|
|
.map(({ shared: isShared, ...plan }) => ({ ...plan, source: isShared ? 'shared' : 'personal' }));
|
|
|
|
|
return {
|
|
|
|
|
...stored,
|
|
|
|
|
plans: [...own, ...shared.plans],
|
|
|
|
|
sharedPlansDir: shared.dir,
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/** The folder a manifest plan's file lives in; null for a moved plan when the project lost its shared folder. */
|
|
|
|
|
const folderOfLink = async (projectId, link) => (link.shared ? sharedPlansDirFor(projectId) : plansDirFor(projectId));
|
|
|
|
|
|
2026-08-18 02:59:04 +03:00
|
|
|
const writeContext = async (projectId, context) => {
|
|
|
|
|
await writeJsonAtomic(contextPathFor(projectId), {
|
|
|
|
|
version: PROJECT_CONTEXT_VERSION,
|
|
|
|
|
notes: context.notes,
|
|
|
|
|
todos: context.todos,
|
|
|
|
|
plans: context.plans,
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const saveTodos = async (projectId, todos) => {
|
|
|
|
|
return withWriteLock(projectId, async () => {
|
|
|
|
|
const now = Date.now();
|
2026-09-07 17:50:55 +03:00
|
|
|
const current = await readStoredContext(projectId);
|
2026-08-18 02:59:04 +03:00
|
|
|
const next = { ...current, todos: sanitizeTodos(todos, now) };
|
|
|
|
|
await writeContext(projectId, next);
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Notes are addressed individually.
|
|
|
|
|
*
|
|
|
|
|
* Splitting them from todos is what lets the panel stop writing both fields
|
|
|
|
|
* on every keystroke-driven save: a todo toggle can no longer clobber notes
|
|
|
|
|
* the user is still typing, and an agent-authored note can no longer lose a
|
|
|
|
|
* concurrent todo change.
|
|
|
|
|
*/
|
|
|
|
|
const createNote = async (projectId, value) => {
|
|
|
|
|
const body = clampLength(typeof value?.body === 'string' ? value.body : '', PROJECT_NOTE_BODY_MAX_LENGTH).trim();
|
|
|
|
|
if (!body) {
|
|
|
|
|
throw new Error('body is required');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return withWriteLock(projectId, async () => {
|
|
|
|
|
const now = Date.now();
|
2026-09-07 17:50:55 +03:00
|
|
|
const current = await readStoredContext(projectId);
|
2026-08-18 02:59:04 +03:00
|
|
|
if (current.notes.length >= PROJECT_NOTE_MAX_ITEMS) {
|
|
|
|
|
throw new Error(`A project can hold at most ${PROJECT_NOTE_MAX_ITEMS} notes`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const note = {
|
|
|
|
|
id: idFactory(),
|
|
|
|
|
body,
|
|
|
|
|
createdAt: now,
|
|
|
|
|
updatedAt: now,
|
|
|
|
|
source: NOTE_SOURCES.has(value?.source) ? value.source : 'manual',
|
|
|
|
|
pinned: false,
|
|
|
|
|
...(sanitizeNoteOrigin(value?.origin) ? { origin: sanitizeNoteOrigin(value.origin) } : {}),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const next = { ...current, notes: [note, ...current.notes] };
|
|
|
|
|
await writeContext(projectId, next);
|
|
|
|
|
return { note, context: next };
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Patch one note. Omitted fields are left alone, so pinning a note cannot
|
|
|
|
|
* roll back an edit that landed between the two requests.
|
|
|
|
|
*/
|
|
|
|
|
const updateNote = async (projectId, noteId, patch) => {
|
|
|
|
|
const id = asNonEmptyString(noteId);
|
|
|
|
|
if (!id) {
|
|
|
|
|
throw new Error('noteId is required');
|
|
|
|
|
}
|
|
|
|
|
const hasBody = typeof patch?.body === 'string';
|
|
|
|
|
const hasPinned = typeof patch?.pinned === 'boolean';
|
|
|
|
|
if (!hasBody && !hasPinned) {
|
|
|
|
|
throw new Error('body or pinned is required');
|
|
|
|
|
}
|
|
|
|
|
const body = hasBody ? clampLength(patch.body, PROJECT_NOTE_BODY_MAX_LENGTH).trim() : null;
|
|
|
|
|
if (hasBody && !body) {
|
|
|
|
|
throw new Error('body is required');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return withWriteLock(projectId, async () => {
|
|
|
|
|
const now = Date.now();
|
2026-09-07 17:50:55 +03:00
|
|
|
const current = await readStoredContext(projectId);
|
2026-08-18 02:59:04 +03:00
|
|
|
const existing = current.notes.find((note) => note.id === id);
|
|
|
|
|
if (!existing) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const note = {
|
|
|
|
|
...existing,
|
|
|
|
|
...(hasBody ? { body, updatedAt: now } : {}),
|
|
|
|
|
...(hasPinned ? { pinned: patch.pinned } : {}),
|
|
|
|
|
};
|
|
|
|
|
const next = { ...current, notes: current.notes.map((entry) => (entry.id === id ? note : entry)) };
|
|
|
|
|
await writeContext(projectId, next);
|
|
|
|
|
return { note, context: next };
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const deleteNote = async (projectId, noteId) => {
|
|
|
|
|
const id = asNonEmptyString(noteId);
|
|
|
|
|
if (!id) {
|
|
|
|
|
throw new Error('noteId is required');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return withWriteLock(projectId, async () => {
|
2026-09-07 17:50:55 +03:00
|
|
|
const current = await readStoredContext(projectId);
|
2026-08-18 02:59:04 +03:00
|
|
|
if (!current.notes.some((note) => note.id === id)) {
|
|
|
|
|
return { deleted: false, context: current };
|
|
|
|
|
}
|
|
|
|
|
const next = { ...current, notes: current.notes.filter((note) => note.id !== id) };
|
|
|
|
|
await writeContext(projectId, next);
|
|
|
|
|
return { deleted: true, context: next };
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const readPlan = async (projectId, planId) => {
|
|
|
|
|
const id = asNonEmptyString(planId);
|
|
|
|
|
if (!id) {
|
|
|
|
|
throw new Error('planId is required');
|
|
|
|
|
}
|
2026-09-07 17:50:55 +03:00
|
|
|
const sharedFile = sharedPlanFileOf(id);
|
|
|
|
|
if (sharedFile) {
|
|
|
|
|
const dir = await sharedPlansDirFor(projectId);
|
|
|
|
|
if (!dir) return null;
|
|
|
|
|
let raw;
|
|
|
|
|
try {
|
|
|
|
|
raw = await fsPromises.readFile(path.join(dir, sharedFile), 'utf8');
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (error && error.code === 'ENOENT') return null;
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
const parsed = parsePlanMarkdown(raw);
|
|
|
|
|
return { id, file: sharedFile, createdAt: 0, title: parsed.title, body: parsed.body, raw, source: 'shared' };
|
|
|
|
|
}
|
|
|
|
|
const context = await readStoredContext(projectId);
|
2026-08-18 02:59:04 +03:00
|
|
|
const link = context.plans.find((entry) => entry.id === id);
|
|
|
|
|
if (!link) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-07 17:50:55 +03:00
|
|
|
const folder = await folderOfLink(projectId, link);
|
|
|
|
|
if (!folder) return null;
|
2026-08-18 02:59:04 +03:00
|
|
|
let raw;
|
|
|
|
|
try {
|
2026-09-07 17:50:55 +03:00
|
|
|
raw = await fsPromises.readFile(path.join(folder, link.file), 'utf8');
|
2026-08-18 02:59:04 +03:00
|
|
|
} catch (error) {
|
|
|
|
|
if (error && error.code === 'ENOENT') return null;
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const parsed = parsePlanMarkdown(raw);
|
2026-09-07 17:50:55 +03:00
|
|
|
return { id: link.id, file: link.file, createdAt: link.createdAt, title: parsed.title, body: parsed.body, raw, source: link.shared ? 'shared' : 'personal' };
|
2026-08-18 02:59:04 +03:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Overwrite a plan's markdown in place.
|
|
|
|
|
*
|
|
|
|
|
* Takes the whole raw document, because the editor surface owns the file
|
|
|
|
|
* verbatim — round-tripping through title + body would rewrite the heading
|
|
|
|
|
* and silently reformat what the user typed. The manifest title is
|
|
|
|
|
* re-derived from the saved content so the list never drifts from the file.
|
|
|
|
|
*
|
|
|
|
|
* The file name is deliberately not regenerated on a title change: it is the
|
|
|
|
|
* stable identity behind the link, and renaming it would strand the markdown
|
|
|
|
|
* if the manifest write failed afterwards.
|
|
|
|
|
*/
|
|
|
|
|
const updatePlan = async (projectId, planId, value) => {
|
|
|
|
|
const id = asNonEmptyString(planId);
|
|
|
|
|
if (!id) {
|
|
|
|
|
throw new Error('planId is required');
|
|
|
|
|
}
|
|
|
|
|
if (typeof value?.raw !== 'string') {
|
|
|
|
|
throw new Error('raw is required');
|
|
|
|
|
}
|
|
|
|
|
const raw = clampLength(value.raw, PROJECT_PLAN_BODY_MAX_LENGTH);
|
|
|
|
|
|
2026-09-07 17:50:55 +03:00
|
|
|
const sharedFile = sharedPlanFileOf(id);
|
|
|
|
|
if (sharedFile) {
|
|
|
|
|
// A shared plan is the file itself: written verbatim, no manifest.
|
|
|
|
|
return withWriteLock(projectId, async () => {
|
|
|
|
|
const dir = await sharedPlansDirFor(projectId);
|
|
|
|
|
if (!dir) return null;
|
|
|
|
|
const filePath = path.join(dir, sharedFile);
|
|
|
|
|
try {
|
|
|
|
|
await fsPromises.access(filePath);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (error && error.code === 'ENOENT') return null;
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
await fsPromises.writeFile(filePath, raw, 'utf8');
|
|
|
|
|
const parsed = parsePlanMarkdown(raw);
|
|
|
|
|
const context = await readContext(projectId);
|
|
|
|
|
const plan = context.plans.find((entry) => entry.id === id) ?? { id, file: sharedFile, title: parsed.title, createdAt: Date.now(), pinned: false, source: 'shared' };
|
|
|
|
|
return { plan, context, title: parsed.title, body: parsed.body, raw };
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-18 02:59:04 +03:00
|
|
|
return withWriteLock(projectId, async () => {
|
2026-09-07 17:50:55 +03:00
|
|
|
const current = await readStoredContext(projectId);
|
2026-08-18 02:59:04 +03:00
|
|
|
const link = current.plans.find((entry) => entry.id === id);
|
|
|
|
|
if (!link) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-07 17:50:55 +03:00
|
|
|
const folder = await folderOfLink(projectId, link);
|
|
|
|
|
if (!folder) return null;
|
|
|
|
|
const filePath = path.join(folder, link.file);
|
2026-08-18 02:59:04 +03:00
|
|
|
// Refuse to recreate a file that was deleted underneath us: the link is
|
|
|
|
|
// already dead, and writing here would resurrect it with editor content
|
|
|
|
|
// the user believed was discarded.
|
|
|
|
|
try {
|
|
|
|
|
await fsPromises.access(filePath);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (error && error.code === 'ENOENT') return null;
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await fsPromises.writeFile(filePath, raw, 'utf8');
|
|
|
|
|
|
|
|
|
|
const parsed = parsePlanMarkdown(raw);
|
|
|
|
|
const nextLink = { ...link, title: parsed.title };
|
|
|
|
|
const next = {
|
|
|
|
|
...current,
|
|
|
|
|
plans: current.plans.map((entry) => (entry.id === id ? nextLink : entry)),
|
|
|
|
|
};
|
|
|
|
|
await writeContext(projectId, next);
|
|
|
|
|
|
2026-09-07 17:50:55 +03:00
|
|
|
const { shared: isShared, ...publicLink } = nextLink;
|
|
|
|
|
return { plan: { ...publicLink, source: isShared ? 'shared' : 'personal' }, context: await readContext(projectId), title: parsed.title, body: parsed.body, raw };
|
2026-08-18 02:59:04 +03:00
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Create a plan from title + body.
|
|
|
|
|
*
|
|
|
|
|
* The markdown file is written before the manifest entry. A failure after
|
|
|
|
|
* the file write leaves an unreferenced markdown file rather than a
|
|
|
|
|
* manifest entry pointing at nothing — the orphan is inert, a dangling
|
|
|
|
|
* entry would surface as a broken row in the UI.
|
|
|
|
|
*/
|
|
|
|
|
const createPlan = async (projectId, value) => {
|
|
|
|
|
const title = sanitizePlanTitle(value?.title) || 'Plan';
|
|
|
|
|
const body = clampLength(typeof value?.body === 'string' ? value.body : '', PROJECT_PLAN_BODY_MAX_LENGTH);
|
|
|
|
|
|
|
|
|
|
return withWriteLock(projectId, async () => {
|
2026-09-07 17:50:55 +03:00
|
|
|
const current = await readStoredContext(projectId);
|
2026-08-18 02:59:04 +03:00
|
|
|
const createdAt = Date.now();
|
|
|
|
|
const plansDir = plansDirFor(projectId);
|
|
|
|
|
await fsPromises.mkdir(plansDir, { recursive: true });
|
|
|
|
|
|
|
|
|
|
const baseName = `${createdAt}-${slugifyPlanTitle(title)}`;
|
|
|
|
|
let file = `${baseName}.md`;
|
|
|
|
|
let attempt = 1;
|
2026-09-07 17:50:55 +03:00
|
|
|
while (current.plans.some((entry) => !entry.shared && entry.file === file)) {
|
2026-08-18 02:59:04 +03:00
|
|
|
file = `${baseName}-${attempt}.md`;
|
|
|
|
|
attempt += 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await fsPromises.writeFile(path.join(plansDir, file), formatPlanMarkdown(title, body), 'utf8');
|
|
|
|
|
|
|
|
|
|
const link = { id: idFactory(), file, title, createdAt, pinned: false };
|
|
|
|
|
const next = { ...current, plans: [link, ...current.plans] };
|
|
|
|
|
await writeContext(projectId, next);
|
2026-09-07 17:50:55 +03:00
|
|
|
return { plan: { ...link, source: 'personal' }, context: await readContext(projectId) };
|
2026-08-18 02:59:04 +03:00
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Delete a plan.
|
|
|
|
|
*
|
|
|
|
|
* The manifest entry is removed first so a failed file unlink cannot leave
|
|
|
|
|
* the UI showing a plan that no longer opens. The leftover markdown is
|
|
|
|
|
* unreferenced and harmless.
|
|
|
|
|
*/
|
|
|
|
|
/** Pin state is patched on its own so it cannot roll back a concurrent edit. */
|
|
|
|
|
const setPlanPinned = async (projectId, planId, pinned) => {
|
|
|
|
|
const id = asNonEmptyString(planId);
|
|
|
|
|
if (!id) {
|
|
|
|
|
throw new Error('planId is required');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return withWriteLock(projectId, async () => {
|
2026-09-07 17:50:55 +03:00
|
|
|
const current = await readStoredContext(projectId);
|
2026-08-18 02:59:04 +03:00
|
|
|
const existing = current.plans.find((entry) => entry.id === id);
|
|
|
|
|
if (!existing) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
const plan = { ...existing, pinned: pinned === true };
|
|
|
|
|
const next = { ...current, plans: current.plans.map((entry) => (entry.id === id ? plan : entry)) };
|
|
|
|
|
await writeContext(projectId, next);
|
2026-09-07 17:50:55 +03:00
|
|
|
const { shared: isShared, ...publicPlan } = plan;
|
|
|
|
|
return { plan: { ...publicPlan, source: isShared ? 'shared' : 'personal' }, context: await readContext(projectId) };
|
2026-08-18 02:59:04 +03:00
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const deletePlan = async (projectId, planId) => {
|
|
|
|
|
const id = asNonEmptyString(planId);
|
|
|
|
|
if (!id) {
|
|
|
|
|
throw new Error('planId is required');
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-07 17:50:55 +03:00
|
|
|
const sharedFile = sharedPlanFileOf(id);
|
|
|
|
|
if (sharedFile) {
|
|
|
|
|
return withWriteLock(projectId, async () => {
|
|
|
|
|
const dir = await sharedPlansDirFor(projectId);
|
|
|
|
|
const filePath = dir ? path.join(dir, sharedFile) : null;
|
|
|
|
|
const exists = filePath ? await fsPromises.access(filePath).then(() => true, () => false) : false;
|
|
|
|
|
if (!exists) return { deleted: false, context: await readContext(projectId) };
|
|
|
|
|
await fsPromises.rm(filePath, { force: true });
|
|
|
|
|
return { deleted: true, context: await readContext(projectId) };
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-18 02:59:04 +03:00
|
|
|
return withWriteLock(projectId, async () => {
|
2026-09-07 17:50:55 +03:00
|
|
|
const current = await readStoredContext(projectId);
|
2026-08-18 02:59:04 +03:00
|
|
|
const link = current.plans.find((entry) => entry.id === id);
|
|
|
|
|
if (!link) {
|
2026-09-07 17:50:55 +03:00
|
|
|
return { deleted: false, context: await readContext(projectId) };
|
2026-08-18 02:59:04 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const next = { ...current, plans: current.plans.filter((entry) => entry.id !== id) };
|
|
|
|
|
await writeContext(projectId, next);
|
2026-09-07 17:50:55 +03:00
|
|
|
const folder = await folderOfLink(projectId, link);
|
|
|
|
|
if (folder) await fsPromises.rm(path.join(folder, link.file), { force: true });
|
|
|
|
|
return { deleted: true, context: await readContext(projectId) };
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/** A free file name in `dir`, keeping the wanted base name unless it is taken. */
|
|
|
|
|
const freeFileNameIn = async (dir, wanted, taken = new Set()) => {
|
|
|
|
|
const base = wanted.replace(/\.md$/, '');
|
|
|
|
|
let file = wanted;
|
|
|
|
|
let attempt = 1;
|
|
|
|
|
while (taken.has(file) || await fsPromises.access(path.join(dir, file)).then(() => true, () => false)) {
|
|
|
|
|
file = `${base}-${attempt}.md`;
|
|
|
|
|
attempt += 1;
|
|
|
|
|
}
|
|
|
|
|
return file;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const moveFile = async (from, to) => {
|
|
|
|
|
try {
|
|
|
|
|
await fsPromises.rename(from, to);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (!error || error.code !== 'EXDEV') throw error;
|
|
|
|
|
await fsPromises.copyFile(from, to);
|
|
|
|
|
await fsPromises.rm(from, { force: true });
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Move one of the user's plans into the team's shared folder. The plan
|
|
|
|
|
* keeps its id: the manifest entry stays and gets the `shared` flag, so a
|
|
|
|
|
* session that attached the plan still finds it. The markdown moves first,
|
|
|
|
|
* then the manifest is written; a failure in between leaves the file in the
|
|
|
|
|
* shared folder (listed there as `shared:<file>`) and a dead personal entry
|
|
|
|
|
* that `readPlan` reports as gone. Needs a shared plans folder; refused
|
|
|
|
|
* (validation error) when the project has none.
|
|
|
|
|
*/
|
|
|
|
|
const sharePlan = async (projectId, planId) => {
|
|
|
|
|
const id = asNonEmptyString(planId);
|
|
|
|
|
if (!id) throw new Error('planId is required');
|
|
|
|
|
return withWriteLock(projectId, async () => {
|
|
|
|
|
const dir = await sharedPlansDirFor(projectId);
|
|
|
|
|
if (!dir) throw new Error('shared plans folder is required');
|
|
|
|
|
const current = await readStoredContext(projectId);
|
|
|
|
|
const link = current.plans.find((entry) => entry.id === id);
|
|
|
|
|
if (!link || link.shared) return null;
|
|
|
|
|
const from = path.join(plansDirFor(projectId), link.file);
|
|
|
|
|
const exists = await fsPromises.access(from).then(() => true, () => false);
|
|
|
|
|
if (!exists) return null;
|
|
|
|
|
await fsPromises.mkdir(dir, { recursive: true });
|
|
|
|
|
const file = await freeFileNameIn(dir, link.file);
|
|
|
|
|
await moveFile(from, path.join(dir, file));
|
|
|
|
|
const moved = { ...link, file, shared: true };
|
|
|
|
|
await writeContext(projectId, { ...current, plans: current.plans.map((entry) => (entry.id === id ? moved : entry)) });
|
|
|
|
|
const context = await readContext(projectId);
|
|
|
|
|
return { plan: context.plans.find((entry) => entry.id === id), context };
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Move a shared plan back into the user's own plans; the reverse of
|
|
|
|
|
* `sharePlan`. A plan the user moved keeps its id; a plan that only ever
|
|
|
|
|
* lived in the team's folder (`shared:<file>`) gets a manifest entry now.
|
|
|
|
|
*/
|
|
|
|
|
const unsharePlan = async (projectId, planId) => {
|
|
|
|
|
const id = asNonEmptyString(planId);
|
|
|
|
|
if (!id) throw new Error('planId is required');
|
|
|
|
|
return withWriteLock(projectId, async () => {
|
|
|
|
|
const dir = await sharedPlansDirFor(projectId);
|
|
|
|
|
if (!dir) return null;
|
|
|
|
|
const current = await readStoredContext(projectId);
|
|
|
|
|
const ownLink = current.plans.find((entry) => entry.id === id && entry.shared);
|
|
|
|
|
const sharedFile = ownLink ? ownLink.file : sharedPlanFileOf(id);
|
|
|
|
|
if (!sharedFile) return null;
|
|
|
|
|
const from = path.join(dir, sharedFile);
|
|
|
|
|
let raw;
|
|
|
|
|
try {
|
|
|
|
|
raw = await fsPromises.readFile(from, 'utf8');
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (error && error.code === 'ENOENT') return null;
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
const plansDir = plansDirFor(projectId);
|
|
|
|
|
await fsPromises.mkdir(plansDir, { recursive: true });
|
|
|
|
|
const taken = new Set(current.plans.filter((entry) => !entry.shared).map((entry) => entry.file));
|
|
|
|
|
const file = await freeFileNameIn(plansDir, sharedFile, taken);
|
|
|
|
|
await moveFile(from, path.join(plansDir, file));
|
|
|
|
|
const title = parsePlanMarkdown(raw).title;
|
|
|
|
|
const link = ownLink
|
|
|
|
|
? { ...ownLink, file, title, shared: false }
|
|
|
|
|
: { id: idFactory(), file, title, createdAt: Date.now(), pinned: false, shared: false };
|
|
|
|
|
const plans = ownLink
|
|
|
|
|
? current.plans.map((entry) => (entry.id === id ? link : entry))
|
|
|
|
|
: [link, ...current.plans];
|
|
|
|
|
await writeContext(projectId, { ...current, plans });
|
|
|
|
|
const { shared: _movedFlag, ...publicLink } = link;
|
|
|
|
|
return { plan: { ...publicLink, source: 'personal' }, context: await readContext(projectId) };
|
2026-08-18 02:59:04 +03:00
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
readContext,
|
|
|
|
|
saveTodos,
|
|
|
|
|
createNote,
|
|
|
|
|
updateNote,
|
|
|
|
|
deleteNote,
|
|
|
|
|
readPlan,
|
|
|
|
|
updatePlan,
|
|
|
|
|
createPlan,
|
|
|
|
|
setPlanPinned,
|
|
|
|
|
deletePlan,
|
2026-09-07 17:50:55 +03:00
|
|
|
sharePlan,
|
|
|
|
|
unsharePlan,
|
2026-08-18 02:59:04 +03:00
|
|
|
contextPathFor,
|
|
|
|
|
plansDirFor,
|
|
|
|
|
};
|
|
|
|
|
};
|