@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/core/ops.js ADDED
@@ -0,0 +1,750 @@
1
+ // Board operations: validate, apply as minimal text patches, verify.
2
+ //
3
+ // applyOp({ board, archive }, op) -> { board, archive, files, changed, result }
4
+ //
5
+ // Each op does two independent things: it updates an in-memory copy of the parsed state (the
6
+ // "expected" result, per the format's own rules) and it patches the raw text surgically. Before
7
+ // anything is returned the patched text is re-parsed and must equal the expected state exactly;
8
+ // otherwise nothing is written and a verify_failed error is raised. The text and the state are
9
+ // computed separately on purpose: that is what makes the verify step meaningful.
10
+ import { parseBoard, parseArchive, parseBacklog, generateArchive, generateTaskBlock, normalizeConfig, orderedTasks, parseTaskIds } from './board.js';
11
+ import { BoardError, firstDifference, etagOf, stripEol, toEol } from './text.js';
12
+ import { CURRENT_VERSION } from './migrate.js';
13
+ import { normalizeHousekeeping, formatHousekeepingSpec } from './housekeeping-spec.js';
14
+ import { resolveBacklogFile, formatBacklogFileSpec, isValidStoreFileName, BACKLOG_DEFAULT_FILE } from './backlog-spec.js';
15
+ import * as P from './patch.js';
16
+
17
+ export const DEFAULT_ARCHIVE_STRINGS = { title: '# Task Archive', desc: '> Archived tasks', section: '## ✅ Archives' };
18
+ export const DEFAULT_BACKLOG_STRINGS = { title: '# Backlog', desc: '> Parked tasks, kept out of the board file. A task is here exactly while its status is the backlog column.', section: '## 📥 Backlog' };
19
+ export const TASK_FIELDS = ['title', 'status', 'order', 'priority', 'category', 'assignees', 'tags', 'blockedBy', 'created', 'started', 'due', 'completed', 'description', 'subtasks', 'notes'];
20
+ export const OP_TYPES = ['createTask', 'updateTask', 'moveTask', 'sortTasks', 'archiveTasks', 'setHousekeeping', 'setBacklogFile', 'toggleSubtask', 'addSubtask', 'appendNote', 'archiveTask', 'restoreTask', 'deleteTask', 'setConfigList', 'setColumns'];
21
+ const CONFIG_LISTS = ['categories', 'users', 'priorities', 'tags'];
22
+
23
+ const clone = (v) => JSON.parse(JSON.stringify(v));
24
+ const todayISO = () => new Date().toISOString().slice(0, 10);
25
+ // Same cleaning the page applies to priority entries ("🔴 Critical" -> "Critical").
26
+ export const cleanPriority = (p) => p.replace(/[^\w\s]+/, '').trim();
27
+
28
+ // ---- validation / normalization of field values -----------------------------------------------
29
+
30
+ function fail(code, message, extra) { throw new BoardError(code, message, extra); }
31
+ const isStr = (v) => typeof v === 'string';
32
+ const oneLine = (v, what) => {
33
+ if (!isStr(v)) fail('invalid_field', `${what} must be a string`);
34
+ const s = v.trim();
35
+ if (/[\r\n]/.test(s)) fail('invalid_field', `${what} must be a single line`);
36
+ return s;
37
+ };
38
+ const list = (v, what) => {
39
+ const arr = Array.isArray(v) ? v : isStr(v) ? v.split(',') : fail('invalid_field', `${what} must be an array or a comma separated string`);
40
+ return arr.map((x) => oneLine(x, what)).filter(Boolean);
41
+ };
42
+
43
+ export function normalizeFields(input, config, { partial }) {
44
+ const out = {};
45
+ for (const key of Object.keys(input)) {
46
+ if (!TASK_FIELDS.includes(key)) fail('unknown_field', `Unknown task field "${key}"`, { valid: TASK_FIELDS });
47
+ }
48
+ const has = (k) => input[k] !== undefined;
49
+ if (has('title')) {
50
+ out.title = oneLine(input.title, 'title');
51
+ if (!out.title) fail('invalid_field', 'title must not be empty');
52
+ } else if (!partial) fail('missing_field', 'title is required');
53
+
54
+ if (has('status')) {
55
+ out.status = oneLine(input.status, 'status');
56
+ const ids = config.columns.map((c) => c.id);
57
+ if (!ids.includes(out.status)) fail('unknown_status', `Unknown status "${out.status}"`, { valid: ids, hint: 'Use a column id from the board configuration.' });
58
+ }
59
+ if (has('order')) {
60
+ const v = input.order;
61
+ if (v === '' || v === null) out.order = null;
62
+ else {
63
+ const n = typeof v === 'string' && /^\d+$/.test(v.trim()) ? parseInt(v, 10) : v;
64
+ if (!Number.isInteger(n) || n < 0) fail('invalid_field', 'order must be a non-negative integer, or empty to clear it');
65
+ out.order = n;
66
+ }
67
+ }
68
+ if (has('priority')) {
69
+ out.priority = oneLine(input.priority, 'priority');
70
+ if (out.priority) {
71
+ const valid = (config.priorities || []).map(cleanPriority);
72
+ if (valid.length && !valid.includes(out.priority)) {
73
+ fail('unknown_priority', `Unknown priority "${out.priority}"`, { valid });
74
+ }
75
+ }
76
+ }
77
+ if (has('category')) {
78
+ out.category = oneLine(input.category, 'category');
79
+ if (/\|\s*\*\*\w+\*\*\s*:/.test(out.category)) fail('invalid_field', 'category must not look like another metadata field');
80
+ }
81
+ if (has('assignees')) {
82
+ out.assignees = list(input.assignees, 'assignees');
83
+ if (out.assignees.some((a) => a.includes(','))) fail('invalid_field', 'assignee names must not contain ","');
84
+ }
85
+ if (has('tags')) {
86
+ const tags = list(Array.isArray(input.tags) ? input.tags : isStr(input.tags) ? input.tags.split(/[\s,]+/) : input.tags, 'tags').map((t) => (t.startsWith('#') ? t : '#' + t));
87
+ for (const t of tags) if (!/^#[\w-]+$/.test(t)) fail('invalid_tag', `Invalid tag "${t}"`, { hint: 'Tags are letters, digits, "_" and "-".' });
88
+ out.tags = [...new Set(tags)];
89
+ }
90
+ if (has('blockedBy')) {
91
+ const raw = Array.isArray(input.blockedBy) ? input.blockedBy : isStr(input.blockedBy) ? input.blockedBy.split(/[\s,]+/) : fail('invalid_field', 'blockedBy must be an array or a comma separated string');
92
+ const ids = raw.map((x) => oneLine(x, 'blockedBy')).filter(Boolean);
93
+ for (const id of ids) if (!/^(?:TASK-)?\d+$/i.test(id)) fail('invalid_field', `Invalid task id "${id}" in blockedBy`, { hint: 'Ids look like TASK-014.' });
94
+ out.blockedBy = parseTaskIds(ids.map((i) => (/^\d+$/.test(i) ? 'TASK-' + i : i)).join(' '));
95
+ }
96
+ for (const key of ['created', 'started', 'due', 'completed']) {
97
+ if (!has(key)) continue;
98
+ const v = oneLine(input[key], key);
99
+ if (v && (!/^\d{4}-\d{2}-\d{2}$/.test(v) || Number.isNaN(Date.parse(v)))) fail('invalid_date', `${key} must be YYYY-MM-DD`, { hint: `Got "${v}".` });
100
+ out[key] = v;
101
+ }
102
+ if (has('description')) {
103
+ if (!isStr(input.description)) fail('invalid_field', 'description must be a string');
104
+ const lines = input.description.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
105
+ for (const l of lines) {
106
+ if (/^#{1,6}\s/.test(l)) fail('invalid_description', 'description must not contain markdown headings', { hint: `Offending line: "${l}"` });
107
+ if (/^\*\*(Status|Priority|Category|Assigned|Created|Started|Due|Finished|Tags|Blocked by|Subtasks|Notes|Links|Review|Dependencies)\*\*/.test(l) || /\*\*Notes\*\*:\s*$/.test(l)) {
108
+ fail('invalid_description', 'description must not start a line with a field or section label', { hint: `Offending line: "${l}"` });
109
+ }
110
+ }
111
+ out.description = lines.join('\n');
112
+ }
113
+ if (has('subtasks')) {
114
+ if (!Array.isArray(input.subtasks)) fail('invalid_field', 'subtasks must be an array of { text, completed }');
115
+ out.subtasks = input.subtasks.map((s) => ({ completed: !!s.completed, text: oneLine(isStr(s) ? s : s.text, 'subtask text') }));
116
+ if (out.subtasks.some((s) => !s.text)) fail('invalid_field', 'subtask text must not be empty');
117
+ }
118
+ if (has('notes')) {
119
+ if (!isStr(input.notes)) fail('invalid_field', 'notes must be a string');
120
+ out.notes = noteText(input.notes);
121
+ }
122
+ return out;
123
+ }
124
+
125
+ function noteText(v) {
126
+ const s = v.replace(/\s+$/, '').replace(/^(\s*\r?\n)+/, '');
127
+ if (s.split(/\r?\n/).some((l) => /^###\s+TASK-\d+\s*\|/.test(l))) fail('invalid_notes', 'notes must not contain a task header line');
128
+ return s.replace(/\r\n/g, '\n');
129
+ }
130
+
131
+ // ---- context ----------------------------------------------------------------------------------
132
+
133
+ function open(ws) {
134
+ const bp = parseBoard(ws.board);
135
+ if (bp.formatVersion < CURRENT_VERSION) {
136
+ fail('needs_migration', `kanban.md uses format v${bp.formatVersion}; edits need v${CURRENT_VERSION}`, { hint: 'Run `kanbanmd migrate` (it keeps a backup and refuses anything that would not read back identically), or open the board in the app.' });
137
+ }
138
+ const hasArchive = typeof ws.archive === 'string';
139
+ const ap = hasArchive ? parseArchive(ws.archive, bp.config.columns) : { tasks: [], formatVersion: CURRENT_VERSION };
140
+ // The backlog file, when configured: tasks whose status is its column live there, not in kanban.md.
141
+ const bf = resolveBacklogFile(bp.config);
142
+ const hasBacklog = typeof ws.backlog === 'string';
143
+ const kp = bf && hasBacklog ? parseBacklog(ws.backlog, bp.config.columns, bf.column) : { tasks: [] };
144
+ return {
145
+ board: new P.Doc(ws.board),
146
+ archive: hasArchive ? new P.Doc(ws.archive) : null,
147
+ backlog: bf && hasBacklog ? new P.Doc(ws.backlog) : null,
148
+ backlogName: (bp.config.backlogFile && bp.config.backlogFile.file) || BACKLOG_DEFAULT_FILE,
149
+ strayBacklog: !bf && hasBacklog ? ws.backlog : null, // a backlog.md nobody asked for: never overwritten
150
+ archiveVersion: ap.formatVersion,
151
+ moves: [],
152
+ state: { config: clone(bp.config), tasks: clone(bp.tasks), archive: clone(ap.tasks), backlog: clone(kp.tasks) },
153
+ origin: ws,
154
+ };
155
+ }
156
+
157
+ // ---- the three stores: kanban.md ('board'), archive.md ('archive'), the backlog file ('backlog') ----
158
+ const STORE_KINDS = ['board', 'archive', 'backlog'];
159
+ const listOf = (ctx, kind) => (kind === 'board' ? ctx.state.tasks : ctx.state[kind]);
160
+ const setList = (ctx, kind, list) => { if (kind === 'board') ctx.state.tasks = list; else ctx.state[kind] = list; };
161
+ const storeName = (ctx, kind) => (kind === 'board' ? 'kanban.md' : kind === 'archive' ? 'archive.md' : ctx.backlogName);
162
+
163
+ function backlogDoc(ctx) {
164
+ if (!ctx.backlog) {
165
+ if (ctx.strayBacklog !== null && !ctx.state.config.backlogFile) {
166
+ const stray = parseBacklog(ctx.strayBacklog, ctx.state.config.columns, 'backlog');
167
+ if (stray.tasks.length) fail('backlog_file_exists', `${ctx.backlogName} already exists and holds ${stray.tasks.length} task(s)`, { hint: 'Move or rename that file first; it is not touched.' });
168
+ }
169
+ ctx.backlog = new P.Doc(toEol(generateArchive([], DEFAULT_BACKLOG_STRINGS, { sourceComment: true }), ctx.board.eol));
170
+ }
171
+ return ctx.backlog;
172
+ }
173
+ const destDoc = (ctx, kind) => (kind === 'archive' ? archiveDoc(ctx) : kind === 'backlog' ? backlogDoc(ctx) : ctx.board);
174
+
175
+ // Cut a task's block from one store and append it, verbatim, to another.
176
+ function moveBlock(ctx, task, from, to) {
177
+ const src = ctx[from];
178
+ const dest = destDoc(ctx, to);
179
+ const loc = P.locateTask(src, from, task.id);
180
+ if (!loc) fail('ambiguous_id', `${task.id} appears more than once in ${storeName(ctx, from)}`, { hint: 'Fix the duplicate id by hand first.' });
181
+ const lines = carry(P.cutBlock(src, loc), task, src, dest);
182
+ P.pasteBlock(dest, dest.lines.length, lines);
183
+ setList(ctx, from, listOf(ctx, from).filter((x) => x !== task));
184
+ listOf(ctx, to).push(task);
185
+ ctx.moves.push({ from, to });
186
+ }
187
+
188
+ // Re-establish the invariant after an op: with a backlog file, a task is in it exactly when its status
189
+ // is the backlog column. This is what makes promoting and demoting "just change the status".
190
+ function relocate(ctx) {
191
+ const bc = resolveBacklogFile(ctx.state.config);
192
+ if (!bc) return;
193
+ for (const t of [...ctx.state.tasks]) {
194
+ if (t.status !== bc.column) continue;
195
+ syncStatusLine(ctx, { task: t, kind: 'board', doc: ctx.board }, t.status); // the file reads **Status** to know its column
196
+ moveBlock(ctx, t, 'board', 'backlog');
197
+ }
198
+ for (const t of [...ctx.state.backlog]) {
199
+ if (t.status === bc.column) continue;
200
+ syncStatusLine(ctx, { task: t, kind: 'backlog', doc: ctx.backlog }, t.status);
201
+ moveBlock(ctx, t, 'backlog', 'board');
202
+ }
203
+ }
204
+
205
+ // Write destinations before sources, so a crash between two writes duplicates a task instead of losing it.
206
+ export function orderKinds(kinds, moves) {
207
+ const score = (k) => moves.filter((m) => m.to === k).length - moves.filter((m) => m.from === k).length;
208
+ // ties (no moves, or moves both ways): the side files first, kanban.md last
209
+ const tie = ['archive', 'backlog', 'board'];
210
+ return [...kinds].sort((a, b) => score(b) - score(a) || tie.indexOf(a) - tie.indexOf(b));
211
+ }
212
+
213
+ function archiveDoc(ctx) {
214
+ if (!ctx.archive) {
215
+ ctx.archive = new P.Doc(toEol(generateArchive([], DEFAULT_ARCHIVE_STRINGS, { sourceComment: true }), ctx.board.eol)); // same line endings as the board
216
+ ctx.archiveVersion = CURRENT_VERSION;
217
+ return ctx.archive;
218
+ }
219
+ if (ctx.archiveVersion < CURRENT_VERSION) {
220
+ if (P.findBlocks(ctx.archive.lines).length > 0) needArchiveMigration(ctx);
221
+ P.setArchiveMarker(ctx.archive); // an empty archive can simply be stamped
222
+ ctx.archiveVersion = CURRENT_VERSION;
223
+ }
224
+ return ctx.archive;
225
+ }
226
+
227
+ function needArchiveMigration(ctx) {
228
+ fail('needs_migration', `archive.md uses format v${ctx.archiveVersion}; edits need v${CURRENT_VERSION}`, { hint: 'Run `kanbanmd migrate`, or open the board in the app.' });
229
+ }
230
+
231
+ // Every id in a **Blocked by** list must name a task (board, backlog or archive), and a task cannot wait on
232
+ // itself. A cycle is allowed (the page only warns about it), and a blocker deleted later just stops counting.
233
+ function checkBlockers(ctx, ids, selfId, current = []) {
234
+ const known = new Set([...ctx.state.tasks, ...ctx.state.backlog, ...ctx.state.archive].map((t) => t.id));
235
+ for (const id of ids) {
236
+ if (id === selfId) fail('invalid_field', `${id} cannot be blocked by itself`);
237
+ if (current.includes(id)) continue; // already there (maybe deleted since): keeping it is not a new claim
238
+ if (!known.has(id)) fail('unknown_task', `blockedBy names ${id}, which does not exist`, { hint: 'Ids look like TASK-014.' });
239
+ }
240
+ }
241
+
242
+ function findTask(ctx, id) {
243
+ const norm = /^TASK-\d+$/i.test(id || '') ? 'TASK-' + id.slice(5).padStart(3, '0') : id;
244
+ const inBoard = ctx.state.tasks.find((t) => t.id === norm);
245
+ if (inBoard) return { task: inBoard, kind: 'board', doc: ctx.board };
246
+ const inBacklog = ctx.state.backlog.find((t) => t.id === norm);
247
+ if (inBacklog) return { task: inBacklog, kind: 'backlog', doc: ctx.backlog };
248
+ const inArchive = ctx.state.archive.find((t) => t.id === norm);
249
+ if (inArchive) {
250
+ if (ctx.archiveVersion < CURRENT_VERSION) needArchiveMigration(ctx);
251
+ return { task: inArchive, kind: 'archive', doc: ctx.archive };
252
+ }
253
+ fail('unknown_task', `No task ${id}`, { hint: 'Ids look like TASK-014.' });
254
+ }
255
+
256
+ function locate(ctx, found) {
257
+ const loc = P.locateTask(found.doc, found.kind, found.task.id);
258
+ if (!loc) fail('ambiguous_id', `${found.task.id} appears more than once in ${storeName(ctx, found.kind)}`, { hint: 'Fix the duplicate id by hand first.' });
259
+ return loc;
260
+ }
261
+
262
+ function checkEtag(ctx, found, op) {
263
+ if (!op.ifMatch) return;
264
+ const current = etagOf(P.blockText(found.doc, locate(ctx, found)));
265
+ if (current !== op.ifMatch) fail('conflict', `${found.task.id} changed since you read it`, { details: { current, task: found.task } });
266
+ }
267
+
268
+ // ---- patching a task from a before/after pair -------------------------------------------------
269
+
270
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
271
+
272
+ function patchFields(ctx, found, before, after) {
273
+ const doc = found.doc, kind = found.kind, id = before.id;
274
+ const loc = () => locate(ctx, found);
275
+ try {
276
+ if (before.title !== after.title) P.setTitle(doc, loc(), after.title);
277
+ for (const key of ['status', 'order', 'priority', 'category', 'assignees', 'created', 'started', 'due', 'completed', 'tags', 'blockedBy']) {
278
+ if (!same(before[key], after[key])) P.setField(doc, loc(), key, after[key]);
279
+ }
280
+ if (before.description !== after.description) P.setDescription(doc, loc(), after.description);
281
+ if (!same(before.subtasks, after.subtasks)) P.setSubtasks(doc, loc(), after.subtasks);
282
+ if (before.notes !== after.notes) P.setNotes(doc, loc(), after.notes);
283
+ } catch (e) {
284
+ if (!(e instanceof P.Fallback)) throw e;
285
+ P.replaceBlock(doc, loc(), after); // this one block only, canonical layout
286
+ }
287
+ }
288
+
289
+ // ---- manual ordering --------------------------------------------------------------------------
290
+ // Optional **Order** keys decide the display order within a column (see orderedTasks in board.js).
291
+ // Ordering is "on" as soon as any board task carries a key. While it is on, positional moves write a
292
+ // key instead of relocating a block, so a reorder is a one- or two-line diff.
293
+ const STEP = 1000;
294
+ const orderActive = (tasks) => tasks.some((t) => t.order != null);
295
+
296
+ function setKey(ctx, task, key) {
297
+ const loc = P.locateTask(ctx.board, 'board', task.id);
298
+ if (!loc) fail('ambiguous_id', `${task.id} appears more than once in kanban.md`, { hint: 'Fix the duplicate id by hand first.' });
299
+ task.order = key;
300
+ P.setField(ctx.board, loc, 'order', key);
301
+ }
302
+
303
+ // Give every key-less task a key after the existing ones (in file order), so a new or hand-added
304
+ // task cannot sort ahead of a position that is about to be set.
305
+ function ensureKeys(ctx) {
306
+ const tasks = ctx.state.tasks;
307
+ if (!orderActive(tasks)) return;
308
+ let max = Math.max(...tasks.filter((t) => t.order != null).map((t) => t.order));
309
+ for (const t of tasks) if (t.order == null) setKey(ctx, t, (max += STEP));
310
+ }
311
+
312
+ // ---- the ops ----------------------------------------------------------------------------------
313
+
314
+ function maxId(ctx) {
315
+ return [...ctx.state.tasks, ...ctx.state.backlog, ...ctx.state.archive].reduce((m, t) => Math.max(m, parseInt((t.id.match(/(\d+)$/) || [])[1], 10) || 0), 0);
316
+ }
317
+
318
+ const OPS = {
319
+ createTask(ctx, op, env) {
320
+ const f = normalizeFields(op.task || fail('missing_field', 'createTask needs a "task" object'), ctx.state.config, { partial: false });
321
+ if (f.blockedBy) checkBlockers(ctx, f.blockedBy, null);
322
+ const task = {
323
+ id: '', title: f.title, status: f.status || ctx.state.config.columns[0].id,
324
+ priority: f.priority || '', category: f.category || '', assignees: f.assignees || [], tags: f.tags || [], blockedBy: f.blockedBy || [],
325
+ created: f.created || env.today(), started: f.started || '', due: f.due || '', completed: f.completed || '',
326
+ description: f.description || '', subtasks: f.subtasks || [], notes: f.notes || '', extra: '',
327
+ order: f.order === undefined ? null : f.order,
328
+ };
329
+ task.notes = task.notes.replace(/\n/g, ctx.board.eol); // the parser keeps the file's EOL inside notes
330
+ const n = Math.max(ctx.state.config.lastTaskId, maxId(ctx)) + 1;
331
+ task.id = 'TASK-' + String(n).padStart(3, '0');
332
+ if (f.order === undefined && orderActive(ctx.state.tasks)) { // keep "new tasks go last" under manual ordering
333
+ ensureKeys(ctx);
334
+ task.order = Math.max(...ctx.state.tasks.map((t) => t.order)) + STEP;
335
+ }
336
+ ctx.state.config.lastTaskId = n;
337
+ ctx.state.tasks.push(task);
338
+ P.appendBlockText(ctx.board, generateTaskBlock(task));
339
+ P.setLastTaskId(ctx.board, n);
340
+ return { id: task.id, kind: 'board' };
341
+ },
342
+
343
+ updateTask(ctx, op) {
344
+ const found = findTask(ctx, op.id);
345
+ checkEtag(ctx, found, op);
346
+ const patch = normalizeFields(op.set || fail('missing_field', 'updateTask needs a "set" object'), ctx.state.config, { partial: true });
347
+ if (patch.blockedBy) checkBlockers(ctx, patch.blockedBy, found.task.id, found.task.blockedBy || []);
348
+ const before = clone(found.task);
349
+ if (patch.notes !== undefined) patch.notes = patch.notes.replace(/\n/g, found.doc.eol);
350
+ const after = Object.assign(found.task, patch);
351
+ patchFields(ctx, found, before, after);
352
+ return { id: after.id, kind: found.kind };
353
+ },
354
+
355
+ moveTask(ctx, op) {
356
+ let found = findTask(ctx, op.id);
357
+ if (found.kind === 'archive') fail('task_archived', `${found.task.id} is archived`, { hint: 'Restore it first.' });
358
+ checkEtag(ctx, found, op);
359
+ const { config } = ctx.state;
360
+ const status = op.status === undefined ? found.task.status : normalizeFields({ status: op.status }, config, { partial: true }).status;
361
+ if (op.before && op.atEnd) fail('invalid_field', 'give either before or atEnd, not both');
362
+ const bc = resolveBacklogFile(config);
363
+ const setStatus = () => { const before = clone(found.task); found.task.status = status; patchFields(ctx, found, before, found.task); };
364
+ if (found.kind === 'backlog') {
365
+ if (status === bc.column) return { id: found.task.id, kind: 'backlog' }; // already in the backlog (no positions there)
366
+ setStatus(); // promote: the status edit is the whole change; the block then moves to kanban.md
367
+ relocate(ctx);
368
+ found = findTask(ctx, op.id);
369
+ if (!op.before && !op.atEnd) return { id: found.task.id, kind: 'board' };
370
+ } else if (bc && status === bc.column && found.task.status !== status) {
371
+ setStatus(); // demote: same, in the other direction
372
+ return { id: found.task.id, kind: 'backlog' };
373
+ }
374
+ const { tasks } = ctx.state;
375
+ const t = found.task;
376
+ if (op.before) {
377
+ const b = tasks.find((x) => x.id === op.before);
378
+ if (!b || b.id === t.id) fail('unknown_task', `No task ${op.before} to place it before`);
379
+ if (b.status !== status) fail('before_not_in_column', `${b.id} is not in column "${status}"`);
380
+ }
381
+ const statusChanged = t.status !== status;
382
+ const positional = !!(op.before || op.atEnd);
383
+ if (positional && orderActive(tasks)) {
384
+ // Manual ordering is on: write a key, never relocate the block.
385
+ ensureKeys(ctx);
386
+ if (statusChanged) { P.setField(ctx.board, locate(ctx, found), 'status', status); t.status = status; }
387
+ const col = orderedTasks(tasks).filter((x) => x.status === status);
388
+ const ti = col.indexOf(t);
389
+ const others = col.filter((x) => x !== t);
390
+ if (op.before ? col[ti + 1] && col[ti + 1].id === op.before : col[col.length - 1] === t) return { id: t.id, kind: 'board' }; // already there
391
+ if (op.before) {
392
+ const j = others.findIndex((x) => x.id === op.before);
393
+ const next = others[j].order, prev = j > 0 ? others[j - 1].order : null;
394
+ const key = prev === null ? Math.floor(next / 2) : Math.floor((prev + next) / 2);
395
+ if (key < next && (prev === null || key > prev)) setKey(ctx, t, key);
396
+ else { // no room between the neighbours: renumber evenly, with t placed before the anchor
397
+ const list = orderedTasks(tasks).filter((x) => x !== t);
398
+ list.splice(list.findIndex((x) => x.id === op.before), 0, t);
399
+ list.forEach((x, i) => { if (x.order !== (i + 1) * STEP) setKey(ctx, x, (i + 1) * STEP); });
400
+ }
401
+ } else {
402
+ const pool = others.length ? others : tasks.filter((x) => x !== t);
403
+ setKey(ctx, t, Math.max(0, ...pool.map((x) => x.order)) + STEP);
404
+ }
405
+ return { id: t.id, kind: 'board' };
406
+ }
407
+ // By default a move edits the **Status** line in place and the block stays where it is in the
408
+ // file (so the diff is one line). The block is relocated only when a position is asked for:
409
+ // `before` (ahead of that task) or `atEnd` (after the last task of the column, as a drag to
410
+ // the bottom of a column does in the page).
411
+ const rest = tasks.filter((x) => x !== t);
412
+ let idx = -1;
413
+ if (op.before) idx = rest.findIndex((x) => x.id === op.before);
414
+ else if (op.atEnd) {
415
+ idx = rest.length;
416
+ for (let i = rest.length - 1; i >= 0; i--) if (rest[i].status === status) { idx = i + 1; break; }
417
+ }
418
+ const reordered = idx < 0 ? tasks : [...rest.slice(0, idx), t, ...rest.slice(idx)];
419
+ const orderChanged = reordered.some((x, i) => x !== tasks[i]);
420
+
421
+ if (statusChanged) P.setField(ctx.board, locate(ctx, found), 'status', status);
422
+ if (orderChanged) {
423
+ const lines = P.cutBlock(ctx.board, locate(ctx, found));
424
+ const anchor = idx < rest.length ? P.locateTask(ctx.board, 'board', rest[idx].id) : null;
425
+ P.pasteBlock(ctx.board, anchor ? anchor.start : ctx.board.lines.length, lines);
426
+ }
427
+ t.status = status;
428
+ ctx.state.tasks = reordered;
429
+ return { id: t.id, kind: 'board' };
430
+ },
431
+
432
+ // Explicit sort. Modes:
433
+ // blocks reorder the task blocks in the file by numeric id
434
+ // ids assign keys that reflect id order
435
+ // positions assign keys that reflect the current order (keys, then file position); also how
436
+ // manual ordering is switched on
437
+ // clear remove every key (order falls back to file position)
438
+ sortTasks(ctx, op) {
439
+ // Sorting rewrites the file wholesale, so the mode is never guessed.
440
+ const mode = op.mode;
441
+ const MODES = ['blocks', 'ids', 'positions', 'clear'];
442
+ if (!mode) fail('missing_field', 'sortTasks needs a mode', { valid: MODES, hint: 'blocks: reorder file blocks by id. ids / positions: write Order keys from id order / current order. clear: remove all Order keys.' });
443
+ if (!MODES.includes(mode)) fail('invalid_field', `Unknown sort mode "${mode}"`, { valid: MODES });
444
+ if (op.by && op.by !== 'id') fail('invalid_field', `Cannot sort by "${op.by}"`, { valid: ['id'] });
445
+ const dir = op.order === 'desc' ? -1 : 1;
446
+ const num = (t) => parseInt((t.id.match(/(\d+)$/) || [])[1], 10) || 0;
447
+ const tasks = ctx.state.tasks;
448
+
449
+ if (mode === 'clear') {
450
+ for (const t of tasks) if (t.order != null) setKey(ctx, t, null);
451
+ return { mode };
452
+ }
453
+ if (mode === 'ids' || mode === 'positions') {
454
+ const list = mode === 'positions' ? orderedTasks(tasks) : [...tasks].sort((a, b) => dir * (num(a) - num(b)) || tasks.indexOf(a) - tasks.indexOf(b));
455
+ list.forEach((t, i) => { if (t.order !== (i + 1) * STEP) setKey(ctx, t, (i + 1) * STEP); });
456
+ return { mode };
457
+ }
458
+ const scope = op.scope || 'board';
459
+ if (!['board', 'archive', 'both'].includes(scope)) fail('invalid_field', `Unknown scope "${scope}"`, { valid: ['board', 'archive', 'both'] });
460
+ for (const kind of ['board', 'archive']) {
461
+ if (scope !== 'both' && scope !== kind) continue;
462
+ const doc = kind === 'board' ? ctx.board : ctx.archive;
463
+ const list = kind === 'board' ? ctx.state.tasks : ctx.state.archive;
464
+ if (!doc || list.length < 2) continue;
465
+ const order = list.map((_, i) => i).sort((a, b) => dir * (num(list[a]) - num(list[b])) || a - b);
466
+ if (order.every((v, i) => v === i)) continue;
467
+ if (P.blocksOf(doc, kind).length !== list.length) fail('ambiguous_id', 'Could not match every task block in the file', { hint: 'Fix duplicate or malformed task headers first.' });
468
+ P.reorderBlocks(doc, kind, order);
469
+ const sorted = order.map((i) => list[i]);
470
+ if (kind === 'board') ctx.state.tasks = sorted; else ctx.state.archive = sorted;
471
+ }
472
+ return { mode, scope };
473
+ },
474
+
475
+ toggleSubtask(ctx, op) {
476
+ const found = findTask(ctx, op.id);
477
+ checkEtag(ctx, found, op);
478
+ const t = found.task;
479
+ let i = Number.isInteger(op.index) ? op.index : t.subtasks.findIndex((s) => s.text === (op.text || '').trim());
480
+ if (!t.subtasks[i]) fail('unknown_subtask', `${t.id} has no such subtask`, { details: { count: t.subtasks.length } });
481
+ const done = op.completed === undefined ? !t.subtasks[i].completed : !!op.completed;
482
+ if (t.subtasks[i].completed !== done) {
483
+ const before = clone(t);
484
+ t.subtasks[i].completed = done;
485
+ patchFields(ctx, found, before, t);
486
+ }
487
+ return { id: t.id, kind: found.kind };
488
+ },
489
+
490
+ addSubtask(ctx, op) {
491
+ const found = findTask(ctx, op.id);
492
+ checkEtag(ctx, found, op);
493
+ const [s] = normalizeFields({ subtasks: [{ text: op.text, completed: op.completed }] }, ctx.state.config, { partial: true }).subtasks;
494
+ found.task.subtasks.push(s);
495
+ try { P.addSubtask(found.doc, locate(ctx, found), s); }
496
+ catch (e) { if (!(e instanceof P.Fallback)) throw e; P.replaceBlock(found.doc, locate(ctx, found), found.task); }
497
+ return { id: found.task.id, kind: found.kind };
498
+ },
499
+
500
+ appendNote(ctx, op) {
501
+ const found = findTask(ctx, op.id);
502
+ checkEtag(ctx, found, op);
503
+ const text = normalizeFields({ notes: op.text }, ctx.state.config, { partial: true }).notes;
504
+ if (!text) fail('invalid_field', 'note text must not be empty');
505
+ const t = found.task;
506
+ const expected = text.replace(/\n/g, found.doc.eol);
507
+ // The appended text follows the block's last line, so the separator the parser sees is that line's own ending.
508
+ const sep = t.notes && P.lastContentEol(found.doc, locate(ctx, found));
509
+ t.notes = t.notes ? `${t.notes}${sep}${expected}` : expected;
510
+ try { P.appendNote(found.doc, locate(ctx, found), text); }
511
+ catch (e) { if (!(e instanceof P.Fallback)) throw e; P.replaceBlock(found.doc, locate(ctx, found), t); }
512
+ return { id: t.id, kind: found.kind };
513
+ },
514
+
515
+ archiveTask(ctx, op) {
516
+ const found = findTask(ctx, op.id);
517
+ if (found.kind === 'archive') fail('already_archived', `${found.task.id} is already archived`);
518
+ checkEtag(ctx, found, op);
519
+ const t = found.task;
520
+ const dest = archiveDoc(ctx);
521
+ syncStatusLine(ctx, found, t.status); // the archive reads **Status** to remember the column
522
+ const lines = carry(P.cutBlock(found.doc, locate(ctx, found)), t, found.doc, dest);
523
+ P.pasteBlock(dest, dest.lines.length, lines);
524
+ setList(ctx, found.kind, listOf(ctx, found.kind).filter((x) => x !== t));
525
+ ctx.state.archive.push(t);
526
+ ctx.moves.push({ from: found.kind, to: 'archive' });
527
+ return { id: t.id, kind: 'archive' };
528
+ },
529
+
530
+ // Several tasks to the archive in one verified edit (a clean-up is one diff). All or nothing.
531
+ archiveTasks(ctx, op) {
532
+ const ids = [...new Set((Array.isArray(op.ids) ? op.ids : []).map(String))];
533
+ if (!ids.length) fail('missing_field', 'archiveTasks needs a non-empty "ids" array');
534
+ for (const id of ids) OPS.archiveTask(ctx, { type: 'archiveTask', id });
535
+ return { ids, count: ids.length, kind: 'archive' };
536
+ },
537
+
538
+ restoreTask(ctx, op) {
539
+ const found = findTask(ctx, op.id);
540
+ if (found.kind !== 'archive') fail('not_archived', `${found.task.id} is not archived`);
541
+ const t = found.task;
542
+ // Same rule as the page: a column that no longer exists falls back to the first column.
543
+ const status = ctx.state.config.columns.some((c) => c.id === t.status) ? t.status : ctx.state.config.columns[0].id;
544
+ syncStatusLine(ctx, found, status);
545
+ const lines = carry(P.cutBlock(ctx.archive, locate(ctx, found)), t, ctx.archive, ctx.board);
546
+ P.pasteBlock(ctx.board, ctx.board.lines.length, lines);
547
+ t.status = status;
548
+ ctx.state.archive = ctx.state.archive.filter((x) => x !== t);
549
+ ctx.state.tasks.push(t);
550
+ ctx.moves.push({ from: 'archive', to: 'board' });
551
+ return { id: t.id, kind: 'board' }; // relocate() sends it on to the backlog file if its column is the backlog's
552
+ },
553
+
554
+ deleteTask(ctx, op) {
555
+ const found = findTask(ctx, op.id);
556
+ checkEtag(ctx, found, op);
557
+ P.cutBlock(found.doc, locate(ctx, found));
558
+ setList(ctx, found.kind, listOf(ctx, found.kind).filter((x) => x !== found.task));
559
+ return { id: found.task.id, kind: found.kind };
560
+ },
561
+
562
+ // Turn the backlog file on or off. { enabled: true, column?, file? }: tasks whose status is the column move
563
+ // into the file (created if need be). { enabled: false }: they come back to kanban.md and the line goes.
564
+ setBacklogFile(ctx, op) {
565
+ const config = ctx.state.config;
566
+ if (op.enabled === false) {
567
+ if (!config.backlogFile) return { name: 'backlogFile' };
568
+ config.backlogFile = null; // stop enforcing the invariant, then bring everything home
569
+ for (const t of [...ctx.state.backlog]) { syncStatusLine(ctx, { task: t, kind: 'backlog', doc: ctx.backlog }, t.status); moveBlock(ctx, t, 'backlog', 'board'); }
570
+ P.removeConfigLine(ctx.board, 'backlogFile');
571
+ return { name: 'backlogFile' };
572
+ }
573
+ const cols = config.columns.map((c) => c.id);
574
+ const current = config.backlogFile || {};
575
+ const file = op.file || current.file || BACKLOG_DEFAULT_FILE;
576
+ if (!isValidStoreFileName(file)) fail('invalid_field', `Invalid backlog file name "${file}"`, { hint: 'A plain *.md name, not kanban.md or archive.md.' });
577
+ const column = op.column || current.column || resolveBacklogFile({ columns: config.columns, backlogFile: { file } })?.column;
578
+ if (!column) fail('missing_field', 'Which column is the backlog? Pass column.', { valid: cols });
579
+ if (!cols.includes(column)) fail('unknown_status', `Unknown column "${column}"`, { valid: cols });
580
+ if (ctx.state.backlog.length && file !== current.file) fail('backlog_file_in_use', `${current.file} still holds ${ctx.state.backlog.length} task(s)`, { hint: 'Disable the backlog file first (enabled: false), then enable it under the new name.' });
581
+ ctx.backlogName = file;
582
+ backlogDoc(ctx); // create it first: this refuses to overwrite a stray file that holds tasks
583
+ config.backlogFile = { file, column };
584
+ P.setConfigLine(ctx.board, 'backlogFile', formatBacklogFileSpec(config.backlogFile), normalizeConfig(config));
585
+ return { name: 'backlogFile' };
586
+ },
587
+
588
+ // The "**Housekeeping**:" policy. { off: true } turns the nudges off, { reset: true } removes the line (defaults).
589
+ setHousekeeping(ctx, op) {
590
+ const cols = ctx.state.config.columns.map((c) => c.id);
591
+ if (op.reset) {
592
+ ctx.state.config.housekeeping = null;
593
+ P.removeConfigLine(ctx.board, 'housekeeping');
594
+ return { name: 'housekeeping' };
595
+ }
596
+ const h = op.off ? { enabled: false } : { enabled: true };
597
+ const side = (key) => {
598
+ const v = op[key];
599
+ if (v === undefined) return;
600
+ if (typeof v !== 'object' || v === null) fail('invalid_field', `${key} must be { column, afterDays }`);
601
+ if (v.column !== undefined && !cols.includes(v.column)) fail('unknown_status', `Unknown column "${v.column}"`, { valid: cols });
602
+ if (v.afterDays !== undefined && (!Number.isInteger(v.afterDays) || v.afterDays < 1)) fail('invalid_field', `${key}.afterDays must be a whole number of days, 1 or more`);
603
+ h[key] = { ...(v.column !== undefined && { column: v.column }), ...(v.afterDays !== undefined && { afterDays: v.afterDays }) };
604
+ };
605
+ if (!op.off) {
606
+ side('done'); side('backlog');
607
+ if (op.max !== undefined) {
608
+ if (!Number.isInteger(op.max) || op.max < 1) fail('invalid_field', 'max must be a whole number, 1 or more');
609
+ h.max = op.max;
610
+ }
611
+ }
612
+ const norm = normalizeHousekeeping(h);
613
+ ctx.state.config.housekeeping = norm;
614
+ P.setConfigLine(ctx.board, 'housekeeping', formatHousekeepingSpec(norm), normalizeConfig(ctx.state.config));
615
+ return { name: 'housekeeping' };
616
+ },
617
+
618
+ setConfigList(ctx, op) {
619
+ if (!CONFIG_LISTS.includes(op.name)) fail('unknown_config', `Unknown config list "${op.name}"`, { valid: CONFIG_LISTS });
620
+ if (!Array.isArray(op.values)) fail('invalid_field', 'values must be an array');
621
+ let values = op.values.map((v) => oneLine(v, op.name)).filter(Boolean);
622
+ if (op.name === 'tags') {
623
+ values = values.map((v) => v.replace(/^#/, ''));
624
+ for (const v of values) if (!/^[\w-]+$/.test(v)) fail('invalid_tag', `Invalid tag "${v}"`);
625
+ }
626
+ if (['categories', 'users'].includes(op.name) && values.some((v) => v.includes(','))) fail('invalid_field', `${op.name} must not contain ","`);
627
+ if (op.name === 'priorities' && values.some((v) => v.includes('|'))) fail('invalid_field', 'priorities must not contain "|"');
628
+ if (op.name !== 'priorities') values = [...new Set(values)];
629
+ if (!values.length) fail('invalid_field', `${op.name} must not be empty`, { hint: 'An empty list falls back to defaults, so it would not stay empty.' });
630
+ ctx.state.config[op.name] = values;
631
+ P.setConfigLine(ctx.board, op.name, P.configValueText[op.name](values), normalizeConfig(ctx.state.config));
632
+ return { name: op.name };
633
+ },
634
+
635
+ setColumns(ctx, op) {
636
+ if (!Array.isArray(op.columns) || !op.columns.length) fail('invalid_field', 'columns must be a non-empty array of { name, id }');
637
+ const current = new Map(ctx.state.config.columns.map((c) => [c.id, c]));
638
+ const cols = op.columns.map((c) => ({ name: oneLine(c.name, 'column name'), id: oneLine(c.id, 'column id') }));
639
+ const ids = new Set();
640
+ for (const c of cols) {
641
+ const old = current.get(c.id);
642
+ if (!old || old.name !== c.name) {
643
+ if (!c.name || /[|]/.test(c.name) || /\)\s*$/.test(c.name)) fail('invalid_column', `Invalid column name "${c.name}"`, { hint: 'Names must not contain "|" or end with ")".' });
644
+ }
645
+ if (!old && !/^[\w-]+$/.test(c.id)) fail('invalid_column', `Invalid column id "${c.id}"`, { hint: 'Ids are letters, digits, "_" and "-".' });
646
+ if (ids.has(c.id)) fail('invalid_column', `Duplicate column id "${c.id}"`);
647
+ ids.add(c.id);
648
+ }
649
+ const reassign = op.reassign || {};
650
+ for (const to of Object.values(reassign)) if (!ids.has(to)) fail('invalid_column', `reassign target "${to}" is not a column`);
651
+ // A board task must have a column to live in. An archived task in a removed column may keep its
652
+ // old id (it reads back as "archived" and restores into the first column), unless a reassign says otherwise.
653
+ const orphans = ctx.state.tasks.filter((t) => current.has(t.status) && !ids.has(t.status) && !reassign[t.status]).map((t) => ({ id: t.id, status: t.status }));
654
+ if (orphans.length) fail('orphaned_tasks', `${orphans.length} task(s) are in columns being removed`, { details: { orphans }, hint: 'Pass reassign: { oldColumnId: newColumnId }.' });
655
+ for (const t of ctx.state.tasks) if (reassign[t.status] && !ids.has(t.status)) { statusEdit(ctx, 'board', t, reassign[t.status]); }
656
+ for (const t of ctx.state.archive) if (reassign[t.status] && !ids.has(t.status)) { statusEdit(ctx, 'archive', t, reassign[t.status]); }
657
+ for (const t of ctx.state.backlog) if (reassign[t.status] && !ids.has(t.status)) { statusEdit(ctx, 'backlog', t, reassign[t.status]); }
658
+ const bf = ctx.state.config.backlogFile;
659
+ const bc = resolveBacklogFile(ctx.state.config);
660
+ if (bc && !ids.has(bc.column)) {
661
+ // Only a rename (the new id did not exist before) can carry the backlog file along; merging the column into an
662
+ // existing one, or deleting it, would silently repoint or lose the file's meaning.
663
+ if (!reassign[bc.column] || current.has(reassign[bc.column])) fail('backlog_column_in_use', `The backlog file holds the tasks of column "${bc.column}"`, { hint: 'Disable the backlog file first (setBacklogFile enabled: false), or rename the column (reassign to a new id).' });
664
+ ctx.state.config.backlogFile = { file: bf.file, column: reassign[bc.column] };
665
+ P.setConfigLine(ctx.board, 'backlogFile', formatBacklogFileSpec(ctx.state.config.backlogFile), normalizeConfig(ctx.state.config));
666
+ }
667
+ ctx.state.config.columns = cols;
668
+ P.setConfigLine(ctx.board, 'columns', P.configValueText.columns(cols), normalizeConfig(ctx.state.config));
669
+ return { name: 'columns' };
670
+ },
671
+ };
672
+
673
+ // Blocks move between files verbatim. Only when the two files use different line endings is the
674
+ // block re-terminated, and then the expected task is updated the same way: the parser keeps the
675
+ // file's line endings inside notes and preserved sections.
676
+ function carry(lines, task, from, to) {
677
+ const foreign = lines.some((l) => { const e = (/\r?\n$/.exec(l) || [''])[0]; return e && e !== to.eol; });
678
+ if (!foreign || (from.eol === to.eol && !P.hasUniformEol(to))) return lines;
679
+ const conv = (v) => v.replace(/\r?\n/g, to.eol);
680
+ task.notes = conv(task.notes);
681
+ task.extra = conv(task.extra);
682
+ return P.convertBlockEol(lines, to.eol);
683
+ }
684
+
685
+ function statusEdit(ctx, kind, task, status) {
686
+ const doc = ctx[kind];
687
+ P.setField(doc, P.locateTask(doc, kind, task.id), 'status', status);
688
+ task.status = status;
689
+ }
690
+
691
+ // Make the block's **Status** line say `status` (the line may be missing or name a removed column).
692
+ function syncStatusLine(ctx, found, status) {
693
+ const loc = locate(ctx, found);
694
+ if (P.getField(found.doc, loc, 'status') !== status) P.setField(found.doc, loc, 'status', status);
695
+ }
696
+
697
+ // ---- entry point ------------------------------------------------------------------------------
698
+
699
+ export function applyOp(ws, op, options = {}) {
700
+ if (!op || !OPS[op.type]) fail('unknown_op', `Unknown operation "${op && op.type}"`, { valid: OP_TYPES });
701
+ const env = { today: options.today || todayISO };
702
+ const ctx = open(ws);
703
+ const result = OPS[op.type](ctx, op, env);
704
+ relocate(ctx); // whatever the op did to a status, put every task in the file its status belongs to
705
+
706
+ const orig = { board: ws.board, archive: ws.archive ?? null, backlog: ws.backlog ?? null };
707
+ const texts = {
708
+ board: ctx.board.toString(),
709
+ archive: ctx.archive ? ctx.archive.toString() : orig.archive,
710
+ backlog: ctx.backlog ? ctx.backlog.toString() : orig.backlog, // an unconfigured stray file is left exactly as it was
711
+ };
712
+ verify(ctx, texts);
713
+
714
+ const changed = Object.fromEntries(STORE_KINDS.map((k) => [k, texts[k] !== orig[k]]));
715
+ const files = orderKinds(STORE_KINDS.filter((k) => changed[k]), ctx.moves).map((k) => ({ name: storeName(ctx, k), kind: k, text: texts[k] }));
716
+
717
+ // Fresh etag of the affected block (it may have moved to another file), so a caller can chain edits with ifMatch.
718
+ let etag, kind = result.kind;
719
+ if (result.id) {
720
+ const at = STORE_KINDS.find((k) => listOf(ctx, k).some((t) => t.id === result.id));
721
+ if (at && ctx[at]) {
722
+ kind = at;
723
+ const loc = P.locateTask(ctx[at], at, result.id);
724
+ if (loc) etag = etagOf(P.blockText(ctx[at], loc));
725
+ }
726
+ }
727
+ return { ...texts, files, changed, moves: ctx.moves, result: { op: op.type, ...result, kind, etag } };
728
+ }
729
+
730
+ function verify(ctx, texts) {
731
+ const bp = parseBoard(texts.board);
732
+ const diff = firstDifference({ config: ctx.state.config, tasks: ctx.state.tasks }, { config: bp.config, tasks: bp.tasks });
733
+ if (bp.formatVersion < CURRENT_VERSION || diff) {
734
+ fail('verify_failed', 'The change could not be written so that it reads back identically; nothing was written.', { details: diff || { path: 'formatVersion' } });
735
+ }
736
+ if (texts.archive !== null) {
737
+ const ap = parseArchive(texts.archive, bp.config.columns);
738
+ const adiff = firstDifference(ctx.state.archive, ap.tasks);
739
+ if (adiff) fail('verify_failed', 'The archive change could not be written so that it reads back identically; nothing was written.', { details: { file: 'archive.md', ...adiff } });
740
+ }
741
+ const bc = resolveBacklogFile(ctx.state.config);
742
+ if (bc) {
743
+ const kp = texts.backlog === null ? { tasks: [] } : parseBacklog(texts.backlog, bp.config.columns, bc.column);
744
+ const kdiff = firstDifference(ctx.state.backlog, kp.tasks);
745
+ if (kdiff) fail('verify_failed', 'The backlog change could not be written so that it reads back identically; nothing was written.', { details: { file: bc.file, ...kdiff } });
746
+ // the invariant itself
747
+ const stray = ctx.state.tasks.find((t) => t.status === bc.column) || ctx.state.backlog.find((t) => t.status !== bc.column);
748
+ if (stray) fail('verify_failed', `${stray.id} is in the wrong file for its status; nothing was written.`, { details: { id: stray.id, status: stray.status } });
749
+ }
750
+ }