@ashikahmad/kanbanmd 0.0.0-stage → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/markdown-task-manager/SKILL.md +77 -0
- package/LICENSE +373 -0
- package/README.md +77 -2
- package/cli/init.js +139 -0
- package/cli/kanbanmd.js +192 -0
- package/cli/serve.js +161 -0
- package/core/backlog-spec.js +48 -0
- package/core/board-url.js +35 -0
- package/core/board.js +548 -0
- package/core/changes.js +130 -0
- package/core/dates.js +14 -0
- package/core/housekeeping-spec.js +62 -0
- package/core/housekeeping.js +81 -0
- package/core/index.js +14 -0
- package/core/links.js +55 -0
- package/core/migrate.js +69 -0
- package/core/ops.js +750 -0
- package/core/patch.js +438 -0
- package/core/read.js +252 -0
- package/core/share.js +41 -0
- package/core/text.js +145 -0
- package/io/node-fs.js +188 -0
- package/kanbanmd.html +1 -0
- package/mcp/server.js +25 -0
- package/mcp/tools.js +201 -0
- package/package.json +50 -4
package/core/read.js
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// Read-side queries over a board: compact, filterable, paginated. Pure: take the two file texts,
|
|
2
|
+
// return plain JSON-able objects. The CLI and the MCP server are thin wrappers around these.
|
|
3
|
+
//
|
|
4
|
+
// Compactness is the point (an agent pays for every byte it reads):
|
|
5
|
+
// - a board listing returns summaries, never descriptions, subtasks or notes
|
|
6
|
+
// - empty fields are omitted (an absent key means "empty")
|
|
7
|
+
// - filtering and paging happen here, not in the agent's context
|
|
8
|
+
// - the archive is only read when asked for
|
|
9
|
+
import { parseBoard, parseArchive, parseBacklog, orderedTasks } from './board.js';
|
|
10
|
+
import { resolveBacklogFile } from './backlog-spec.js';
|
|
11
|
+
import { BoardError, etagOf } from './text.js';
|
|
12
|
+
import { Doc, blocksOf } from './patch.js';
|
|
13
|
+
import { localToday, dayNumber } from './dates.js';
|
|
14
|
+
import { getHousekeeping } from './housekeeping.js';
|
|
15
|
+
import { indexTasks, blockersOf, blockedByThis } from './links.js';
|
|
16
|
+
import { resolveHousekeeping } from './housekeeping-spec.js';
|
|
17
|
+
|
|
18
|
+
export { localToday };
|
|
19
|
+
|
|
20
|
+
export const TASK_VIEW_FIELDS = ['id', 'title', 'status', 'order', 'priority', 'category', 'assignees', 'tags', 'blockedBy', 'openBlockers', 'blocks', 'created', 'started', 'due',
|
|
21
|
+
'completed', 'description', 'subtasks', 'notes', 'extra', 'progress', 'etag'];
|
|
22
|
+
const SUMMARY_FIELDS = ['id', 'title', 'status', 'priority', 'assignees', 'due', 'tags'];
|
|
23
|
+
const MAX_LIMIT = 500, DEFAULT_LIMIT = 50, STATS_LIST_CAP = 20;
|
|
24
|
+
|
|
25
|
+
const fail = (code, message, extra) => { throw new BoardError(code, message, extra); };
|
|
26
|
+
const clean = (p) => p.replace(/[^\w\s]+/, '').trim();
|
|
27
|
+
const empty = (v) => v === '' || v === null || v === undefined || (Array.isArray(v) && v.length === 0);
|
|
28
|
+
const list = (v) => (v === undefined || v === null || v === '' ? [] : Array.isArray(v) ? v : String(v).split(','));
|
|
29
|
+
const norm = (s) => String(s).trim().toLowerCase();
|
|
30
|
+
// "@bob (Bob Ito)" -> "bob"
|
|
31
|
+
const userKey = (u) => norm(u).replace(/^@/, '').replace(/[\s(].*$/, '');
|
|
32
|
+
|
|
33
|
+
// Same rule as the page's deadline badge: 'overdue' (due before today), 'soon' (within 2 days,
|
|
34
|
+
// today included), or null. A task with a Finished date is never flagged.
|
|
35
|
+
export function dueStatus(task, today) {
|
|
36
|
+
if (!task.due || task.completed) return null;
|
|
37
|
+
const due = dayNumber(task.due.trim()), now = dayNumber(today);
|
|
38
|
+
if (due === null || now === null) return null;
|
|
39
|
+
const days = due - now;
|
|
40
|
+
return days < 0 ? 'overdue' : days <= 2 ? 'soon' : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---- loading ----------------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
function load(ws) {
|
|
46
|
+
const bp = parseBoard(ws.board);
|
|
47
|
+
const ap = typeof ws.archive === 'string' ? parseArchive(ws.archive, bp.config.columns) : { tasks: [], formatVersion: null };
|
|
48
|
+
// The backlog file's tasks, when one is configured. `live` is every task still in play (board + backlog).
|
|
49
|
+
const bf = resolveBacklogFile(bp.config);
|
|
50
|
+
const kp = bf && typeof ws.backlog === 'string' ? parseBacklog(ws.backlog, bp.config.columns, bf.column) : { tasks: [] };
|
|
51
|
+
// Derived link fields, readable like any other: `openBlockers` (blockedBy ids that are not finished yet) and
|
|
52
|
+
// `blocks` (the tasks that name this one). Nothing of this is stored.
|
|
53
|
+
const byId = indexTasks({ board: bp.tasks, backlog: kp.tasks, archive: ap.tasks });
|
|
54
|
+
for (const { task } of byId.values()) {
|
|
55
|
+
task.openBlockers = blockersOf(task, byId, bp.config.columns).open.map((e) => e.task.id);
|
|
56
|
+
task.blocks = blockedByThis(task.id, byId).map((e) => e.task.id);
|
|
57
|
+
}
|
|
58
|
+
return { bp, ap, kp, bf, live: [...bp.tasks, ...kp.tasks], ws };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function etags(text, kind) {
|
|
62
|
+
if (typeof text !== 'string') return new Map();
|
|
63
|
+
const doc = new Doc(text);
|
|
64
|
+
return new Map(blocksOf(doc, kind).map((b) => [b.id, etagOf(doc.lines.slice(b.start, b.end).join(''))]));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---- field selection --------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
// `fields` replaces the default set; id is always kept (and first).
|
|
70
|
+
function fieldList(fields, fallback) {
|
|
71
|
+
const given = !(fields === undefined || fields === null || fields === '');
|
|
72
|
+
const want = given ? list(fields).map((f) => String(f).trim()).filter(Boolean) : fallback;
|
|
73
|
+
for (const f of want) if (!TASK_VIEW_FIELDS.includes(f)) fail('invalid_field', `Unknown field "${f}"`, { valid: TASK_VIEW_FIELDS });
|
|
74
|
+
return { fields: ['id', ...want.filter((f) => f !== 'id')], given };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function view(task, fields, etag, extra = {}) {
|
|
78
|
+
const out = {};
|
|
79
|
+
for (const f of fields) {
|
|
80
|
+
let v;
|
|
81
|
+
if (f === 'progress') v = task.subtasks.length ? `${task.subtasks.filter((s) => s.completed).length}/${task.subtasks.length}` : '';
|
|
82
|
+
else if (f === 'etag') v = etag;
|
|
83
|
+
else v = task[f];
|
|
84
|
+
if (f === 'id' || !empty(v)) out[f] = v;
|
|
85
|
+
}
|
|
86
|
+
return { ...out, ...extra };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---- filtering --------------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
function makeFilter(q, config, today) {
|
|
92
|
+
const statuses = list(q.status).map((s) => String(s).trim()).filter(Boolean);
|
|
93
|
+
const ids = config.columns.map((c) => c.id);
|
|
94
|
+
for (const s of statuses) if (!ids.includes(s)) fail('unknown_status', `Unknown status "${s}"`, { valid: ids });
|
|
95
|
+
const assignees = list(q.assignee).map(userKey).filter(Boolean);
|
|
96
|
+
const tags = list(q.tag).map((t) => norm(t).replace(/^#/, '')).filter(Boolean);
|
|
97
|
+
const priorities = list(q.priority).map((p) => norm(clean(String(p)))).filter(Boolean);
|
|
98
|
+
const categories = list(q.category).map(norm).filter(Boolean);
|
|
99
|
+
const terms = norm(q.text || '').split(/\s+/).filter(Boolean);
|
|
100
|
+
let dueBefore = null;
|
|
101
|
+
if (q.dueBefore !== undefined && q.dueBefore !== null && q.dueBefore !== '') {
|
|
102
|
+
if (dayNumber(String(q.dueBefore)) === null) fail('invalid_date', 'dueBefore must be YYYY-MM-DD', { hint: `Got "${q.dueBefore}".` });
|
|
103
|
+
dueBefore = String(q.dueBefore);
|
|
104
|
+
}
|
|
105
|
+
return (t) => {
|
|
106
|
+
if (statuses.length && !statuses.includes(t.status)) return false;
|
|
107
|
+
if (assignees.length && !assignees.some((a) => t.assignees.some((u) => userKey(u) === a))) return false;
|
|
108
|
+
if (tags.length && !tags.every((g) => t.tags.some((x) => norm(x).replace(/^#/, '') === g))) return false;
|
|
109
|
+
if (priorities.length && !priorities.includes(norm(clean(t.priority)))) return false;
|
|
110
|
+
if (categories.length && !categories.includes(norm(t.category))) return false;
|
|
111
|
+
if (dueBefore && !(t.due && t.due < dueBefore)) return false;
|
|
112
|
+
if (q.overdue && dueStatus(t, today) !== 'overdue') return false;
|
|
113
|
+
if (terms.length) {
|
|
114
|
+
const hay = norm([t.id, t.title, t.description, t.notes, t.category, t.tags.join(' ')].join(' \n '));
|
|
115
|
+
if (!terms.every((w) => hay.includes(w))) return false;
|
|
116
|
+
}
|
|
117
|
+
return true;
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function page(rows, q) {
|
|
122
|
+
const limit = q.limit === undefined || q.limit === null ? DEFAULT_LIMIT : Number(q.limit);
|
|
123
|
+
const offset = q.offset === undefined || q.offset === null ? 0 : Number(q.offset);
|
|
124
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) fail('invalid_field', `limit must be 1 to ${MAX_LIMIT}`);
|
|
125
|
+
if (!Number.isInteger(offset) || offset < 0) fail('invalid_field', 'offset must be 0 or more');
|
|
126
|
+
const slice = rows.slice(offset, offset + limit);
|
|
127
|
+
return { slice, meta: { total: rows.length, returned: slice.length, offset, ...(offset + limit < rows.length && { nextOffset: offset + limit }) } };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---- queries ----------------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
// Board listing: summaries in display order, filtered and paged.
|
|
133
|
+
export function getBoard(ws, q = {}, { today = localToday() } = {}) {
|
|
134
|
+
if (q.backlog) return listFile(ws, q, 'backlog', { today });
|
|
135
|
+
const { bp, kp } = load(ws);
|
|
136
|
+
const { fields, given } = fieldList(q.fields, SUMMARY_FIELDS.concat('etag'));
|
|
137
|
+
const match = makeFilter(q, bp.config, today);
|
|
138
|
+
const tags = etags(ws.board, 'board');
|
|
139
|
+
const all = orderedTasks(bp.tasks);
|
|
140
|
+
const { slice, meta } = page(all.filter(match), q);
|
|
141
|
+
const counts = Object.fromEntries(bp.config.columns.map((c) => [c.id, 0]));
|
|
142
|
+
for (const t of [...bp.tasks, ...kp.tasks]) counts[t.status] = (counts[t.status] || 0) + 1; // the backlog file's tasks count too
|
|
143
|
+
return {
|
|
144
|
+
rev: etagOf(ws.board),
|
|
145
|
+
...meta,
|
|
146
|
+
columns: bp.config.columns.map((c) => ({ id: c.id, name: c.name, count: counts[c.id] })),
|
|
147
|
+
tasks: slice.map((t) => view(t, fields, tags.get(t.id), !given && t.order !== null ? { order: t.order } : {})),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const normId = (id) => {
|
|
152
|
+
const m = /^(?:task-)?(\d+)$/i.exec(String(id).trim());
|
|
153
|
+
return m ? 'TASK-' + m[1].padStart(3, '0') : String(id).trim();
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// One task in full (or just `fields`), from the board or the archive.
|
|
157
|
+
export function getTask(ws, id, q = {}) {
|
|
158
|
+
const { bp, ap, kp } = load(ws);
|
|
159
|
+
const want = normId(id);
|
|
160
|
+
let task = bp.tasks.find((t) => t.id === want), kind = 'board';
|
|
161
|
+
if (!task) { task = kp.tasks.find((t) => t.id === want); kind = 'backlog'; }
|
|
162
|
+
if (!task) { task = ap.tasks.find((t) => t.id === want); kind = 'archive'; }
|
|
163
|
+
if (!task) fail('unknown_task', `No task ${id}`, { hint: 'Ids look like TASK-014. Use get_board with filters to find one.' });
|
|
164
|
+
const { fields } = fieldList(q.fields, TASK_VIEW_FIELDS.filter((f) => f !== 'progress'));
|
|
165
|
+
const tag = etags(kind === 'board' ? ws.board : kind === 'backlog' ? ws.backlog : ws.archive, kind).get(task.id);
|
|
166
|
+
return view(task, fields, tag, kind === 'archive' ? { archived: true } : kind === 'backlog' ? { backlog: true } : {});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function getConfig(ws) {
|
|
170
|
+
const { bp, ap, kp, bf } = load(ws);
|
|
171
|
+
const c = bp.config;
|
|
172
|
+
return {
|
|
173
|
+
title: c.boardTitle,
|
|
174
|
+
rev: etagOf(ws.board),
|
|
175
|
+
formatVersion: bp.formatVersion,
|
|
176
|
+
...(bp.formatVersion < 3 && { migrationNeeded: true, hint: 'Edits need format v3: run `kanbanmd migrate`.' }),
|
|
177
|
+
lastTaskId: c.lastTaskId,
|
|
178
|
+
columns: c.columns.map((x) => ({ id: x.id, name: x.name })),
|
|
179
|
+
categories: c.categories,
|
|
180
|
+
users: c.users,
|
|
181
|
+
priorities: c.priorities.map(clean),
|
|
182
|
+
tags: c.tags,
|
|
183
|
+
housekeeping: resolveHousekeeping(bp.config),
|
|
184
|
+
manualOrder: bp.tasks.some((t) => t.order !== null),
|
|
185
|
+
tasks: bp.tasks.length,
|
|
186
|
+
...(bf && { backlogFile: { file: bf.file, column: bf.column, tasks: kp.tasks.length } }),
|
|
187
|
+
archived: ap.tasks.length,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// A side file's tasks (the archive or the backlog file): summaries, filtered and paged like the board.
|
|
192
|
+
function listFile(ws, q, kind, { today = localToday() } = {}) {
|
|
193
|
+
const { bp, ap, kp, bf } = load(ws);
|
|
194
|
+
if (kind === 'backlog' && !bf) fail('no_backlog_file', 'This board has no backlog file', { hint: 'Its backlog tasks are on the board: filter get_board by status. To split them off: update_config backlogFile { enabled: true }.' });
|
|
195
|
+
const { fields } = fieldList(q.fields, SUMMARY_FIELDS.concat('etag'));
|
|
196
|
+
const match = makeFilter(q, bp.config, today);
|
|
197
|
+
const tags = etags(kind === 'backlog' ? ws.backlog : ws.archive, kind);
|
|
198
|
+
let rows = (kind === 'backlog' ? kp.tasks : ap.tasks).filter(match);
|
|
199
|
+
if (q.reverse) rows = [...rows].reverse();
|
|
200
|
+
const { slice, meta } = page(rows, q);
|
|
201
|
+
return { ...meta, tasks: slice.map((t) => view(t, fields, tags.get(t.id))) };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function listArchive(ws, q = {}, { today = localToday() } = {}) {
|
|
205
|
+
return listFile(ws, q, 'archive', { today });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function getStats(ws, { today = localToday() } = {}) {
|
|
209
|
+
const { bp, ap, kp, bf, live } = load(ws);
|
|
210
|
+
const tally = (keyOf) => {
|
|
211
|
+
const out = {};
|
|
212
|
+
for (const t of live) for (const k of [].concat(keyOf(t))) if (k) out[k] = (out[k] || 0) + 1;
|
|
213
|
+
return out;
|
|
214
|
+
};
|
|
215
|
+
const byStatus = Object.fromEntries(bp.config.columns.map((c) => [c.id, 0]));
|
|
216
|
+
for (const t of live) byStatus[t.status] = (byStatus[t.status] || 0) + 1;
|
|
217
|
+
// The lists are capped so the call stays small on a big board; the count says how many exist.
|
|
218
|
+
const flagged = (kind) => orderedTasks(live).filter((t) => dueStatus(t, today) === kind)
|
|
219
|
+
.sort((a, b) => a.due.localeCompare(b.due));
|
|
220
|
+
const row = (t) => ({ id: t.id, title: t.title, due: t.due, status: t.status });
|
|
221
|
+
const overdue = flagged('overdue'), soon = flagged('soon');
|
|
222
|
+
const hk = getHousekeeping(ws, { today });
|
|
223
|
+
return {
|
|
224
|
+
rev: etagOf(ws.board),
|
|
225
|
+
today,
|
|
226
|
+
total: live.length,
|
|
227
|
+
...(bf && { backlog: kp.tasks.length }),
|
|
228
|
+
archived: ap.tasks.length,
|
|
229
|
+
byStatus,
|
|
230
|
+
byAssignee: tally((t) => t.assignees.map((a) => '@' + userKey(a))),
|
|
231
|
+
unassigned: live.filter((t) => !t.assignees.length).length,
|
|
232
|
+
byPriority: tally((t) => clean(t.priority)),
|
|
233
|
+
overdue: overdue.slice(0, STATS_LIST_CAP).map(row),
|
|
234
|
+
...(overdue.length > STATS_LIST_CAP && { overdueTotal: overdue.length }),
|
|
235
|
+
dueSoon: soon.slice(0, STATS_LIST_CAP).map(row),
|
|
236
|
+
...(soon.length > STATS_LIST_CAP && { dueSoonTotal: soon.length }),
|
|
237
|
+
...(hk.suggestions.length && { housekeeping: hk.suggestions }),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Entries of the activity log (see io/node-fs.js) after `since`, compacted. Edits made through the
|
|
242
|
+
// page are not logged, so `rev` (changes with any edit) is the authoritative "did it change?".
|
|
243
|
+
export function summarizeChanges(entries, { since, rev, limit = 200 } = {}) {
|
|
244
|
+
const rows = entries.filter((e) => !since || e.ts > since);
|
|
245
|
+
const cut = rows.slice(-limit);
|
|
246
|
+
return {
|
|
247
|
+
rev,
|
|
248
|
+
...(rows.length > cut.length && { truncated: rows.length - cut.length }),
|
|
249
|
+
changes: cut.map((e) => ({ ts: e.ts, actor: e.actor, op: e.op, ...(e.id && { id: e.id }) })),
|
|
250
|
+
note: 'Page edits are not logged; compare rev to detect any change.',
|
|
251
|
+
};
|
|
252
|
+
}
|
package/core/share.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Rendering a task for sharing outside the board: plain text for people (chat, mail), and a trimmed
|
|
2
|
+
// Markdown block (the file format itself) that a board can import. Pure. What is included is the
|
|
3
|
+
// sender's choice: notes and assignees can be left out.
|
|
4
|
+
import { generateTaskBlock } from './board.js';
|
|
5
|
+
|
|
6
|
+
const lf = (s) => String(s).replace(/\r\n/g, '\n');
|
|
7
|
+
|
|
8
|
+
// Readable anywhere: no Markdown syntax needed to make sense of it.
|
|
9
|
+
export function taskToPlainText(task, columns = [], { notes = true, assignees = true } = {}) {
|
|
10
|
+
const column = columns.find((c) => c.id === task.status);
|
|
11
|
+
const meta = [
|
|
12
|
+
task.priority,
|
|
13
|
+
task.category,
|
|
14
|
+
column ? column.name : task.status,
|
|
15
|
+
task.due && `Due ${task.due}`,
|
|
16
|
+
assignees && task.assignees.length && `Assigned ${task.assignees.join(', ')}`,
|
|
17
|
+
].filter(Boolean).join(' · ');
|
|
18
|
+
const out = [task.title];
|
|
19
|
+
if (meta) out.push(meta);
|
|
20
|
+
if (task.tags.length) out.push(task.tags.join(' '));
|
|
21
|
+
if (task.description) out.push('', lf(task.description));
|
|
22
|
+
if (task.subtasks.length) {
|
|
23
|
+
const done = task.subtasks.filter((s) => s.completed).length;
|
|
24
|
+
out.push('', `Subtasks (${done}/${task.subtasks.length}):`, ...task.subtasks.map((s) => `${s.completed ? '[x]' : '[ ]'} ${s.text}`));
|
|
25
|
+
}
|
|
26
|
+
if (notes && task.notes) out.push('', 'Notes:', lf(task.notes));
|
|
27
|
+
return out.join('\n') + '\n';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The task as a board block (format v3), with the board-specific parts dropped: no manual order key and no
|
|
31
|
+
// **Blocked by** (ids mean nothing outside the board they came from).
|
|
32
|
+
export function taskToMarkdown(task, { notes = true, assignees = true } = {}) {
|
|
33
|
+
return lf(generateTaskBlock({
|
|
34
|
+
...task,
|
|
35
|
+
assignees: assignees ? task.assignees : [],
|
|
36
|
+
notes: notes ? task.notes : '',
|
|
37
|
+
extra: notes ? task.extra : '',
|
|
38
|
+
order: null,
|
|
39
|
+
blockedBy: [],
|
|
40
|
+
})).trimEnd() + '\n';
|
|
41
|
+
}
|
package/core/text.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Text helpers shared by the patch layer: lines, hashing, deep equality, line diffs.
|
|
2
|
+
// Pure: no I/O, no DOM.
|
|
3
|
+
|
|
4
|
+
// Structured, fixable errors. `code` is stable and meant for programs; `message` and `hint` for
|
|
5
|
+
// the human or agent reading it.
|
|
6
|
+
export class BoardError extends Error {
|
|
7
|
+
constructor(code, message, extra = {}) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = 'BoardError';
|
|
10
|
+
this.code = code;
|
|
11
|
+
Object.assign(this, extra); // hint, valid, details, ...
|
|
12
|
+
}
|
|
13
|
+
toJSON() {
|
|
14
|
+
const { code, message, hint, valid, details } = this;
|
|
15
|
+
return { code, message, ...(hint && { hint }), ...(valid && { valid }), ...(details && { details }) };
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Split into lines, each keeping its own terminator ("\n" or "\r\n"); the last may have none.
|
|
20
|
+
export function splitLines(text) {
|
|
21
|
+
return text.match(/[^\n]*\n|[^\n]+$/g) || [];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const stripEol = (line) => line.replace(/\r?\n$/, '');
|
|
25
|
+
export const lineEol = (line) => (/\r?\n$/.exec(line) || [''])[0];
|
|
26
|
+
|
|
27
|
+
// The file's line ending, taken from its first newline.
|
|
28
|
+
export function detectEol(text) {
|
|
29
|
+
const i = text.indexOf('\n');
|
|
30
|
+
return i > 0 && text[i - 1] === '\r' ? '\r\n' : '\n';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const toEol = (text, eol) => text.replace(/\r?\n/g, eol);
|
|
34
|
+
|
|
35
|
+
// 53-bit string hash (cyrb53). Not cryptographic: used for change detection (etags), not security.
|
|
36
|
+
export function hash53(str, seed = 0) {
|
|
37
|
+
let h1 = 0xdeadbeef ^ seed, h2 = 0x41c6ce57 ^ seed;
|
|
38
|
+
for (let i = 0; i < str.length; i++) {
|
|
39
|
+
const ch = str.charCodeAt(i);
|
|
40
|
+
h1 = Math.imul(h1 ^ ch, 2654435761);
|
|
41
|
+
h2 = Math.imul(h2 ^ ch, 1597334677);
|
|
42
|
+
}
|
|
43
|
+
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
|
|
44
|
+
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
|
|
45
|
+
return 4294967296 * (2097151 & h2) + (h1 >>> 0);
|
|
46
|
+
}
|
|
47
|
+
export const etagOf = (text) => hash53(text).toString(36);
|
|
48
|
+
|
|
49
|
+
export function deepEqual(a, b) {
|
|
50
|
+
if (a === b) return true;
|
|
51
|
+
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false;
|
|
52
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
53
|
+
const ka = Object.keys(a), kb = Object.keys(b);
|
|
54
|
+
if (ka.length !== kb.length) return false;
|
|
55
|
+
return ka.every((k) => Object.prototype.hasOwnProperty.call(b, k) && deepEqual(a[k], b[k]));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// First path at which two JSON-like values differ, for error messages.
|
|
59
|
+
export function firstDifference(a, b, path = '') {
|
|
60
|
+
if (deepEqual(a, b)) return null;
|
|
61
|
+
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null || Array.isArray(a) !== Array.isArray(b)) {
|
|
62
|
+
return { path, expected: a, actual: b };
|
|
63
|
+
}
|
|
64
|
+
for (const k of new Set([...Object.keys(a), ...Object.keys(b)])) {
|
|
65
|
+
const d = firstDifference(a[k], b[k], `${path}/${k}`);
|
|
66
|
+
if (d) return d;
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Line diff as hunks [{ at, del: [lines], add: [lines] }] where `at` is the index in `a`.
|
|
72
|
+
// Common prefix/suffix are trimmed first, then an LCS runs on the middle (capped, so a huge
|
|
73
|
+
// rewrite degrades to a single hunk instead of an O(n*m) blow-up).
|
|
74
|
+
export function diffLines(aText, bText) {
|
|
75
|
+
const a = splitLines(aText), b = splitLines(bText);
|
|
76
|
+
let p = 0;
|
|
77
|
+
while (p < a.length && p < b.length && a[p] === b[p]) p++;
|
|
78
|
+
let s = 0;
|
|
79
|
+
while (s < a.length - p && s < b.length - p && a[a.length - 1 - s] === b[b.length - 1 - s]) s++;
|
|
80
|
+
const am = a.slice(p, a.length - s), bm = b.slice(p, b.length - s);
|
|
81
|
+
if (!am.length && !bm.length) return [];
|
|
82
|
+
if (am.length * bm.length > 4_000_000) return [{ at: p, del: am, add: bm }];
|
|
83
|
+
|
|
84
|
+
const n = am.length, m = bm.length;
|
|
85
|
+
const L = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
|
|
86
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
87
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
88
|
+
L[i][j] = am[i] === bm[j] ? L[i + 1][j + 1] + 1 : Math.max(L[i + 1][j], L[i][j + 1]);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const hunks = [];
|
|
92
|
+
let i = 0, j = 0, cur = null;
|
|
93
|
+
const flush = () => { if (cur) { hunks.push(cur); cur = null; } };
|
|
94
|
+
while (i < n || j < m) {
|
|
95
|
+
if (i < n && j < m && am[i] === bm[j]) { flush(); i++; j++; continue; }
|
|
96
|
+
if (!cur) cur = { at: p + i, del: [], add: [] };
|
|
97
|
+
if (j < m && (i === n || L[i][j + 1] >= L[i + 1][j])) cur.add.push(bm[j++]);
|
|
98
|
+
else cur.del.push(am[i++]);
|
|
99
|
+
}
|
|
100
|
+
flush();
|
|
101
|
+
return hunks;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Summary used by tests and dry runs.
|
|
105
|
+
export function diffStats(aText, bText) {
|
|
106
|
+
const hunks = diffLines(aText, bText);
|
|
107
|
+
return {
|
|
108
|
+
hunks: hunks.length,
|
|
109
|
+
removed: hunks.reduce((n, h) => n + h.del.length, 0),
|
|
110
|
+
added: hunks.reduce((n, h) => n + h.add.length, 0),
|
|
111
|
+
firstLine: hunks.length ? hunks[0].at : null,
|
|
112
|
+
lastLine: hunks.length ? Math.max(...hunks.map((h) => h.at + h.del.length)) : null,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function unifiedDiff(aText, bText, name = 'file', context = 3) {
|
|
117
|
+
const a = splitLines(aText);
|
|
118
|
+
const hunks = diffLines(aText, bText);
|
|
119
|
+
if (!hunks.length) return '';
|
|
120
|
+
const out = [`--- a/${name}`, `+++ b/${name}`];
|
|
121
|
+
// Merge hunks whose context windows touch.
|
|
122
|
+
const groups = [];
|
|
123
|
+
for (const h of hunks) {
|
|
124
|
+
const last = groups[groups.length - 1];
|
|
125
|
+
if (last && h.at - (last.at + last.span) <= context * 2) { last.hunks.push(h); last.span = h.at + h.del.length - last.at; }
|
|
126
|
+
else groups.push({ at: h.at, span: h.del.length, hunks: [h] });
|
|
127
|
+
}
|
|
128
|
+
let delta = 0;
|
|
129
|
+
for (const g of groups) {
|
|
130
|
+
const start = Math.max(0, g.at - context);
|
|
131
|
+
const end = Math.min(a.length, g.at + g.span + context);
|
|
132
|
+
const body = [];
|
|
133
|
+
let pos = start, added = 0, removed = 0;
|
|
134
|
+
for (const h of g.hunks) {
|
|
135
|
+
for (; pos < h.at; pos++) body.push(' ' + stripEol(a[pos]));
|
|
136
|
+
for (const l of h.del) { body.push('-' + stripEol(l)); removed++; pos++; }
|
|
137
|
+
for (const l of h.add) { body.push('+' + stripEol(l)); added++; }
|
|
138
|
+
}
|
|
139
|
+
for (; pos < end; pos++) body.push(' ' + stripEol(a[pos]));
|
|
140
|
+
const aLen = end - start, bLen = aLen - removed + added;
|
|
141
|
+
out.push(`@@ -${start + 1},${aLen} +${start + 1 + delta},${bLen} @@`, ...body);
|
|
142
|
+
delta += added - removed;
|
|
143
|
+
}
|
|
144
|
+
return out.join('\n') + '\n';
|
|
145
|
+
}
|
package/io/node-fs.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// Node file port: reads the two board files and applies an op to them safely.
|
|
2
|
+
//
|
|
3
|
+
// Writers that go through this module (CLI, MCP server, any number of processes) take turns via
|
|
4
|
+
// an exclusive lock directory (mkdir is atomic), so their read-modify-write cycles never
|
|
5
|
+
// interleave. The page cannot take that lock, so it is covered by a second layer: just before the
|
|
6
|
+
// rename, every file that was read is re-read and its hash compared, and if anything moved the
|
|
7
|
+
// op is recomputed from the fresh content. Each file is replaced atomically (temp file in the same
|
|
8
|
+
// directory, fsync, rename), so a reader never sees a half-written file. The only remaining gap is
|
|
9
|
+
// a foreign writer landing between that last check and the rename (microseconds); the per-task
|
|
10
|
+
// etag (ifMatch) is the tool for callers that need certainty beyond that.
|
|
11
|
+
import { promises as fs } from 'node:fs';
|
|
12
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
13
|
+
import { join, basename, dirname } from 'node:path';
|
|
14
|
+
import { applyOps } from '../core/changes.js';
|
|
15
|
+
import { parseBoard } from '../core/board.js';
|
|
16
|
+
import { migrateBoardText, migrateArchiveText } from '../core/migrate.js';
|
|
17
|
+
import { backlogFileNameOf } from '../core/backlog-spec.js';
|
|
18
|
+
import { BoardError, unifiedDiff } from '../core/text.js';
|
|
19
|
+
|
|
20
|
+
const BOARD = 'kanban.md', ARCHIVE = 'archive.md', ACTIVITY = '.kanban-activity.jsonl', LOCK = '.kanban.lock';
|
|
21
|
+
const sha = (s) => (s === null ? '-' : createHash('sha1').update(s).digest('hex'));
|
|
22
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
23
|
+
|
|
24
|
+
async function readOptional(path) {
|
|
25
|
+
try { return await fs.readFile(path, 'utf8'); }
|
|
26
|
+
catch (e) { if (e.code === 'ENOENT') return null; throw e; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function readWorkspace(dir) {
|
|
30
|
+
const board = await readOptional(join(dir, BOARD));
|
|
31
|
+
if (board === null) throw new BoardError('no_board', `No ${BOARD} in ${dir}`, { hint: 'Pass --dir pointing at the project folder.' });
|
|
32
|
+
// The backlog file: the one the board declares, else a default-named file if one happens to exist (the
|
|
33
|
+
// ops need to see it to refuse to overwrite it).
|
|
34
|
+
return { board, archive: await readOptional(join(dir, ARCHIVE)), backlog: await readOptional(join(dir, backlogFileNameOf(board))) };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Write `text` to a temp file next to `path` and return handles to commit (rename) or abort it.
|
|
38
|
+
async function stage(path, text) {
|
|
39
|
+
const tmp = join(dirname(path), `.${basename(path)}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`);
|
|
40
|
+
const fh = await fs.open(tmp, 'w');
|
|
41
|
+
try {
|
|
42
|
+
await fh.writeFile(text, 'utf8');
|
|
43
|
+
await fh.sync();
|
|
44
|
+
} catch (e) {
|
|
45
|
+
await fh.close();
|
|
46
|
+
await fs.rm(tmp, { force: true });
|
|
47
|
+
throw e;
|
|
48
|
+
}
|
|
49
|
+
await fh.close();
|
|
50
|
+
return { tmp, path, abort: () => fs.rm(tmp, { force: true }), commit: () => fs.rename(tmp, path) };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Exclusive, cross-process lock on a project folder. A lock older than `staleMs` is assumed
|
|
54
|
+
// to belong to a crashed process and is broken.
|
|
55
|
+
async function withLock(dir, fn, { timeoutMs = 15000, staleMs = 10000 } = {}) {
|
|
56
|
+
const lock = join(dir, LOCK);
|
|
57
|
+
const started = Date.now();
|
|
58
|
+
for (;;) {
|
|
59
|
+
try { await fs.mkdir(lock); break; }
|
|
60
|
+
catch (e) {
|
|
61
|
+
if (e.code !== 'EEXIST') throw e;
|
|
62
|
+
try {
|
|
63
|
+
const { mtimeMs } = await fs.stat(lock);
|
|
64
|
+
if (Date.now() - mtimeMs > staleMs) { await fs.rm(lock, { recursive: true, force: true }); continue; }
|
|
65
|
+
} catch { /* released between the mkdir and the stat: just retry */ }
|
|
66
|
+
if (Date.now() - started > timeoutMs) {
|
|
67
|
+
throw new BoardError('busy', 'Another process is writing to this board', { hint: 'Retry the call.' });
|
|
68
|
+
}
|
|
69
|
+
await sleep(2 + Math.random() * 8);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
try { return await fn(); }
|
|
73
|
+
finally { await fs.rm(lock, { recursive: true, force: true }); }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function logActivity(dir, entry) {
|
|
77
|
+
const path = join(dir, ACTIVITY);
|
|
78
|
+
try {
|
|
79
|
+
await fs.appendFile(path, JSON.stringify(entry) + '\n');
|
|
80
|
+
const { size } = await fs.stat(path);
|
|
81
|
+
if (size > 256 * 1024) { // keep the log bounded
|
|
82
|
+
const lines = (await fs.readFile(path, 'utf8')).trimEnd().split('\n');
|
|
83
|
+
await fs.writeFile(path, lines.slice(-1000).join('\n') + '\n');
|
|
84
|
+
}
|
|
85
|
+
} catch { /* the activity log is best effort and must never fail a write */ }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The activity log as parsed entries (oldest first); [] when there is none.
|
|
89
|
+
export async function readActivity(dir) {
|
|
90
|
+
const text = await readOptional(join(dir, ACTIVITY));
|
|
91
|
+
if (!text) return [];
|
|
92
|
+
return text.split('\n').filter(Boolean).flatMap((l) => { try { return [JSON.parse(l)]; } catch { return []; } });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Apply one op, or an array of ops applied in order as one edit, to the project in `dir`.
|
|
96
|
+
// Returns { result, files, diff? } for one op, { results, files, diff? } for an array.
|
|
97
|
+
export async function mutate(dir, opOrOps, { dryRun = false, retries = 8, actor = 'cli', today, hooks = {}, lockTimeoutMs, lockStaleMs } = {}) {
|
|
98
|
+
const ops = Array.isArray(opOrOps) ? opOrOps : [opOrOps];
|
|
99
|
+
const shape = (results) => (Array.isArray(opOrOps) ? { results } : { result: results[0] });
|
|
100
|
+
const attempt = async () => {
|
|
101
|
+
const ws = await readWorkspace(dir);
|
|
102
|
+
const backlogName = backlogFileNameOf(ws.board);
|
|
103
|
+
const out = applyOps(ws, ops, { today });
|
|
104
|
+
// every file this edit read or will write: if any of them changes before the rename, start over
|
|
105
|
+
const watched = [...new Set([BOARD, ARCHIVE, backlogName, ...out.files.map((f) => f.name)])];
|
|
106
|
+
const hashes = {};
|
|
107
|
+
for (const name of watched) hashes[name] = sha(name === BOARD ? ws.board : name === ARCHIVE ? ws.archive : name === backlogName ? ws.backlog : await readOptional(join(dir, name)));
|
|
108
|
+
if (hooks.afterCompute) await hooks.afterCompute(); // test seam: lets a test interleave a foreign write
|
|
109
|
+
const names = out.files.map((f) => f.name);
|
|
110
|
+
|
|
111
|
+
if (dryRun) {
|
|
112
|
+
const diff = out.files.map((f) => unifiedDiff(ws[f.kind] || '', f.text, f.name)).join('');
|
|
113
|
+
return { done: { ...shape(out.results), files: names, diff, dryRun: true } };
|
|
114
|
+
}
|
|
115
|
+
if (!out.files.length) return { done: { ...shape(out.results), files: [] } };
|
|
116
|
+
|
|
117
|
+
const staged = [];
|
|
118
|
+
try {
|
|
119
|
+
for (const f of out.files) staged.push(await stage(join(dir, f.name), f.text));
|
|
120
|
+
// Last check before the renames: did a writer outside the lock (the page) move anything?
|
|
121
|
+
let moved = false;
|
|
122
|
+
for (const name of watched) if (sha(await readOptional(join(dir, name))) !== hashes[name]) moved = true;
|
|
123
|
+
if (moved) {
|
|
124
|
+
await Promise.all(staged.map((s) => s.abort()));
|
|
125
|
+
return { retry: true };
|
|
126
|
+
}
|
|
127
|
+
for (const s of staged) {
|
|
128
|
+
if (hooks.beforeRename) await hooks.beforeRename(s.path);
|
|
129
|
+
await s.commit();
|
|
130
|
+
}
|
|
131
|
+
} catch (e) {
|
|
132
|
+
await Promise.all(staged.map((s) => s.abort()));
|
|
133
|
+
throw e;
|
|
134
|
+
}
|
|
135
|
+
const ts = new Date().toISOString();
|
|
136
|
+
for (let i = 0; i < ops.length; i++) await logActivity(dir, { ts, actor, op: ops[i].type, id: out.results[i].id || null, files: names });
|
|
137
|
+
return { done: { ...shape(out.results), files: names } };
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const run = async () => {
|
|
141
|
+
for (let i = 0; i <= retries; i++) {
|
|
142
|
+
const r = await attempt();
|
|
143
|
+
if (r.done) return r.done;
|
|
144
|
+
await sleep(2 + Math.random() * 10 * (i + 1));
|
|
145
|
+
}
|
|
146
|
+
throw new BoardError('busy', 'The board kept changing underneath this edit; gave up after several retries', { hint: 'Retry the call.' });
|
|
147
|
+
};
|
|
148
|
+
return dryRun ? run() : withLock(dir, run, { timeoutMs: lockTimeoutMs, staleMs: lockStaleMs });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Migrate a project folder to the current format. Every file that changes is backed up first
|
|
152
|
+
// (kanban.v<N>-backup.md; an existing backup is never overwritten), and nothing is written unless
|
|
153
|
+
// the core verified that the new text reads back identically. Returns { changed, files, backups, diff? }.
|
|
154
|
+
export async function migrateProject(dir, { dryRun = false, actor = 'cli', lockTimeoutMs, lockStaleMs } = {}) {
|
|
155
|
+
const run = async () => {
|
|
156
|
+
const ws = await readWorkspace(dir);
|
|
157
|
+
const board = migrateBoardText(ws.board);
|
|
158
|
+
const columns = parseBoard(board.text).config.columns;
|
|
159
|
+
const archive = ws.archive === null ? { changed: false } : migrateArchiveText(ws.archive, columns);
|
|
160
|
+
const steps = [
|
|
161
|
+
board.changed && { name: BOARD, from: board.from, before: ws.board, text: board.text },
|
|
162
|
+
archive.changed && { name: ARCHIVE, from: archive.from, before: ws.archive, text: archive.text },
|
|
163
|
+
].filter(Boolean);
|
|
164
|
+
if (!steps.length) return { changed: false, files: [], backups: [] };
|
|
165
|
+
const files = steps.map((x) => x.name);
|
|
166
|
+
if (dryRun) return { changed: true, files, from: Object.fromEntries(steps.map((x) => [x.name, x.from])), dryRun: true, diff: steps.map((x) => unifiedDiff(x.before, x.text, x.name)).join('') };
|
|
167
|
+
|
|
168
|
+
const backups = [];
|
|
169
|
+
for (const x of steps) {
|
|
170
|
+
let name = x.name.replace(/\.md$/, `.v${x.from}-backup.md`);
|
|
171
|
+
if (await readOptional(join(dir, name)) !== null) name = name.replace(/\.md$/, `.${Date.now()}.md`);
|
|
172
|
+
const st = await stage(join(dir, name), x.before);
|
|
173
|
+
await st.commit();
|
|
174
|
+
backups.push(name);
|
|
175
|
+
}
|
|
176
|
+
const staged = [];
|
|
177
|
+
try {
|
|
178
|
+
for (const x of steps) staged.push(await stage(join(dir, x.name), x.text));
|
|
179
|
+
for (const st of staged) await st.commit();
|
|
180
|
+
} catch (e) {
|
|
181
|
+
await Promise.all(staged.map((st) => st.abort()));
|
|
182
|
+
throw e;
|
|
183
|
+
}
|
|
184
|
+
await logActivity(dir, { ts: new Date().toISOString(), actor, op: 'migrate', id: null, files });
|
|
185
|
+
return { changed: true, files, backups, from: Object.fromEntries(steps.map((x) => [x.name, x.from])) };
|
|
186
|
+
};
|
|
187
|
+
return withLock(dir, run, { timeoutMs: lockTimeoutMs, staleMs: lockStaleMs });
|
|
188
|
+
}
|