reslop 0.0.1 → 0.1.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/lib/review.js ADDED
@@ -0,0 +1,687 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ const { itemPath } = require('./files.js');
7
+
8
+ const REVIEW_DIR = '.review';
9
+ const TEMPLATES_FILE = 'templates.json';
10
+ const TEMPLATE_SHOW = 5;
11
+ const AUTOSAVE_MS = 3000;
12
+ const REVIEW_NAME = /^(\d{4}-\d{2}-\d{2})-(\d+)\.md$/;
13
+ const REVIEW_STATUSES = ['editing', 'ready', 'partial', 'done'];
14
+ const STATUS_ALIAS = Object.assign(Object.create(null), {
15
+ pending: 'ready',
16
+ });
17
+ const FEED_HEAD = /^### Feedback `([^`]+)` \+(\d+) \((.*)\)\s*$/;
18
+ const FEED_BLOCK = /^(.*), block (\d+)$/;
19
+ const FEED_BLOCK_ONLY = /^block (\d+)$/;
20
+ const FEED_META = /^<!-- reslop:(.*) -->\s*$/;
21
+ const FEED_SUFFIX = /^(.*) - (.+):(\d+):(\d+):(\d+)$/;
22
+ const CHECK_ITEM = /^- \[([ xX])\] (.*)$/;
23
+ const FILE_QUOTE = /^> (.+)$/;
24
+ const STATUS_LINE = /^status:\s*(\S+)\s*$/;
25
+ const GIT_ORIGINS = ['unstaged', 'staged', 'untracked', 'commit'];
26
+ const SECTION_LINE = Object.assign(Object.create(null), {
27
+ '### Todo': 'todo',
28
+ '### Feedback': 'feedback',
29
+ });
30
+
31
+ const AGENT_INSTRUCTIONS = [
32
+ 'Execute reviews with `status: ready` or `status: partial`.',
33
+ 'Do not start `editing` or `done` review files.',
34
+ 'Work through unchecked items. After finishing one, mark it `[x]`.',
35
+ 'Set `status` to `partial` if some remain, or `done` if all are `[x]`.',
36
+ 'Do not redo items already marked `[x]`.',
37
+ ].join('\n');
38
+
39
+ const toInt = (text) => parseInt(text, 10);
40
+
41
+ const pad2 = (n) => `${n}`.padStart(2, '0');
42
+
43
+ const padIndex = (n) => (n < 100 ? pad2(n) : `${n}`);
44
+
45
+ const dateStamp = (date) => {
46
+ const y = date.getFullYear();
47
+ const month = pad2(date.getMonth() + 1);
48
+ const day = pad2(date.getDate());
49
+ return `${y}-${month}-${day}`;
50
+ };
51
+
52
+ const allocateReviewPath = (dir, date, existingNames) => {
53
+ const stamp = dateStamp(date);
54
+ let max = -1;
55
+ for (const name of existingNames) {
56
+ const match = REVIEW_NAME.exec(name);
57
+ if (!match) continue;
58
+ if (match[1] !== stamp) continue;
59
+ const n = toInt(match[2]);
60
+ if (n > max) max = n;
61
+ }
62
+ const file = `${stamp}-${padIndex(max + 1)}.md`;
63
+ return path.join(dir, REVIEW_DIR, file);
64
+ };
65
+
66
+ const listReviewNames = (dir, readdirSync = fs.readdirSync) => {
67
+ const folder = path.join(dir, REVIEW_DIR);
68
+ try {
69
+ return readdirSync(folder);
70
+ } catch {
71
+ return [];
72
+ }
73
+ };
74
+
75
+ const compareReviewNames = (left, right) => {
76
+ const a = REVIEW_NAME.exec(left);
77
+ const b = REVIEW_NAME.exec(right);
78
+ if (!a && !b) return 0;
79
+ if (!a) return -1;
80
+ if (!b) return 1;
81
+ if (a[1] < b[1]) return -1;
82
+ if (a[1] > b[1]) return 1;
83
+ return toInt(a[2]) - toInt(b[2]);
84
+ };
85
+
86
+ const latestReviewName = (names) => {
87
+ let best = '';
88
+ for (const name of names) {
89
+ if (!REVIEW_NAME.test(name)) continue;
90
+ if (!best || compareReviewNames(name, best) > 0) best = name;
91
+ }
92
+ return best;
93
+ };
94
+
95
+ const canonicalStatus = (raw) => {
96
+ if (!raw) return '';
97
+ const name = STATUS_ALIAS[raw] ?? raw;
98
+ if (REVIEW_STATUSES.includes(name)) return name;
99
+ return '';
100
+ };
101
+
102
+ const parseFrontmatterStatus = (text) => {
103
+ if (!text.startsWith('---')) return '';
104
+ const nl = text.indexOf('\n');
105
+ if (nl < 0) return '';
106
+ const rest = text.slice(nl + 1);
107
+ const end = rest.indexOf('\n---');
108
+ if (end < 0) return '';
109
+ const matter = rest.slice(0, end);
110
+ for (const line of matter.split('\n')) {
111
+ const match = STATUS_LINE.exec(line);
112
+ if (match) return canonicalStatus(match[1]);
113
+ }
114
+ return '';
115
+ };
116
+
117
+ const readReviewStatus = (reviewPath, readFileSync = fs.readFileSync) => {
118
+ try {
119
+ return parseFrontmatterStatus(readFileSync(reviewPath, 'utf8'));
120
+ } catch {
121
+ return '';
122
+ }
123
+ };
124
+
125
+ const resolveReviewPath = (dir, date, existingNames, options = {}) => {
126
+ const forceNew = options.forceNew ?? false;
127
+ const readFileSync = options.readFileSync ?? fs.readFileSync;
128
+ if (!forceNew) {
129
+ const latest = latestReviewName(existingNames);
130
+ if (latest) {
131
+ const reviewPath = path.join(dir, REVIEW_DIR, latest);
132
+ const status = readReviewStatus(reviewPath, readFileSync);
133
+ if (status === 'editing') return { reviewPath, resume: true };
134
+ }
135
+ }
136
+ return {
137
+ reviewPath: allocateReviewPath(dir, date, existingNames),
138
+ resume: false,
139
+ };
140
+ };
141
+
142
+ const rankedTemplates = (templates) => {
143
+ const ranked = [...templates];
144
+ ranked.sort((a, b) => {
145
+ const byCount = b.count - a.count;
146
+ if (byCount) return byCount;
147
+ return a.text.localeCompare(b.text);
148
+ });
149
+ return ranked;
150
+ };
151
+
152
+ const prefixTemplates = (templates, prefix) => {
153
+ if (!prefix) return templates;
154
+ return templates.filter((entry) => entry.text.startsWith(prefix));
155
+ };
156
+
157
+ const upsertTemplate = (templates, text) => {
158
+ const trimmed = text.trim();
159
+ if (!trimmed) return templates;
160
+ const next = [...templates];
161
+ const i = next.findIndex((entry) => entry.text === trimmed);
162
+ if (i < 0) {
163
+ next.push({ text: trimmed, count: 1 });
164
+ return next;
165
+ }
166
+ const prev = next[i];
167
+ next[i] = { text: prev.text, count: prev.count + 1 };
168
+ return next;
169
+ };
170
+
171
+ const createStore = (reviewPath, templates = []) => ({
172
+ reviewPath,
173
+ dirty: false,
174
+ nextTodoId: 1,
175
+ feedback: new Map(),
176
+ todos: [],
177
+ templates: [...templates],
178
+ status: 'editing',
179
+ });
180
+
181
+ const noteCounts = (store) => {
182
+ const counts = { feedback: 0, todo: 0 };
183
+ if (!store) return counts;
184
+ for (const todo of store.todos) {
185
+ if (todo.text.trim()) counts.todo += 1;
186
+ }
187
+ for (const note of store.feedback.values()) {
188
+ if (note.text.trim()) counts.feedback += 1;
189
+ }
190
+ return counts;
191
+ };
192
+
193
+ const hasNotes = (store) => {
194
+ const counts = noteCounts(store);
195
+ return counts.feedback > 0 || counts.todo > 0;
196
+ };
197
+
198
+ const rememberTemplate = (store, prevText, nextText) => {
199
+ const trimmed = nextText.trim();
200
+ if (!trimmed) return;
201
+ const prev = (prevText ?? '').trim();
202
+ if (prev === trimmed) return;
203
+ store.templates = upsertTemplate(store.templates, trimmed);
204
+ store.dirty = true;
205
+ };
206
+
207
+ const setFeedback = (store, key, note) => {
208
+ const text = note.text ?? '';
209
+ const trimmed = text.trim();
210
+ const prev = store.feedback.get(key);
211
+ if (!trimmed) {
212
+ if (!prev) return;
213
+ store.feedback.delete(key);
214
+ store.dirty = true;
215
+ return;
216
+ }
217
+ if (prev && prev.text === text) return;
218
+ store.feedback.set(key, {
219
+ file: note.file,
220
+ oldStart: note.oldStart ?? 0,
221
+ newStart: note.newStart ?? 0,
222
+ blockId: note.blockId ?? 0,
223
+ origin: note.origin ?? '',
224
+ header: note.header ?? '',
225
+ text,
226
+ done: note.done ?? prev?.done ?? false,
227
+ });
228
+ store.dirty = true;
229
+ };
230
+
231
+ const addTodo = (store, file, text = '') => {
232
+ const id = store.nextTodoId;
233
+ store.nextTodoId += 1;
234
+ const todo = { id, file, text, done: false };
235
+ store.todos.push(todo);
236
+ store.dirty = true;
237
+ return todo;
238
+ };
239
+
240
+ const removeTodo = (store, id) => {
241
+ const next = [];
242
+ let found = false;
243
+ for (const todo of store.todos) {
244
+ if (todo.id === id) {
245
+ found = true;
246
+ continue;
247
+ }
248
+ next.push(todo);
249
+ }
250
+ if (!found) return false;
251
+ store.todos = next;
252
+ store.dirty = true;
253
+ return true;
254
+ };
255
+
256
+ const setTodoText = (store, id, text) => {
257
+ const i = store.todos.findIndex((todo) => todo.id === id);
258
+ if (i < 0) return;
259
+ const prev = store.todos[i];
260
+ const trimmed = text.trim();
261
+ if (!trimmed) {
262
+ removeTodo(store, id);
263
+ return;
264
+ }
265
+ if (prev.text === text) return;
266
+ store.todos[i] = { id: prev.id, file: prev.file, text, done: prev.done };
267
+ store.dirty = true;
268
+ };
269
+
270
+ const isDoneMark = (mark) => mark === 'x' || mark === 'X';
271
+
272
+ const pushTodo = (store, file, text, done) => {
273
+ const id = store.nextTodoId;
274
+ store.nextTodoId += 1;
275
+ store.todos.push({ id, file, text, done });
276
+ };
277
+
278
+ const checkboxLine = (text, done) => {
279
+ const flat = text.trim().replace(/\n/g, ' ');
280
+ const mark = done ? 'x' : ' ';
281
+ return `- [${mark}] ${flat}`;
282
+ };
283
+
284
+ const checkLabel = (text, done) => {
285
+ const mark = done ? 'x' : ' ';
286
+ return `[${mark}] ${text}`;
287
+ };
288
+
289
+ const feedbackKey = (note) => {
290
+ const file = note.file ?? '';
291
+ const oldStart = note.oldStart ?? 0;
292
+ const newStart = note.newStart ?? 0;
293
+ const block = note.blockId ?? 0;
294
+ return `${file}:${oldStart}:${newStart}:${block}`;
295
+ };
296
+
297
+ const storeFeedback = (store, note, key) => {
298
+ const next = {
299
+ file: note.file,
300
+ oldStart: note.oldStart ?? 0,
301
+ newStart: note.newStart ?? 0,
302
+ blockId: note.blockId ?? 0,
303
+ origin: note.origin ?? '',
304
+ header: note.header ?? '',
305
+ text: note.text,
306
+ done: note.done ?? false,
307
+ };
308
+ store.feedback.set(key || feedbackKey(next), next);
309
+ };
310
+
311
+ const feedbackLine = (note) => {
312
+ const loc = feedbackKey(note);
313
+ return `${checkboxLine(note.text, note.done)} - ${loc}`;
314
+ };
315
+
316
+ const parseLoc = (raw) => {
317
+ const parts = raw.split(':');
318
+ const n = parts.length;
319
+ if (n < 4) return null;
320
+ const blockId = toInt(parts[n - 1]);
321
+ const newStart = toInt(parts[n - 2]);
322
+ const oldStart = toInt(parts[n - 3]);
323
+ if (!Number.isFinite(blockId + newStart + oldStart)) return null;
324
+ let fileParts = parts.slice(0, n - 3);
325
+ let origin = '';
326
+ if (fileParts.length > 1 && GIT_ORIGINS.includes(fileParts[0])) {
327
+ origin = fileParts[0];
328
+ fileParts = fileParts.slice(1);
329
+ }
330
+ if (!fileParts.length) return null;
331
+ return {
332
+ origin,
333
+ file: fileParts.join(':'),
334
+ oldStart,
335
+ newStart,
336
+ blockId,
337
+ };
338
+ };
339
+
340
+ const parseFeedSuffix = (textItem) => {
341
+ const match = FEED_SUFFIX.exec(textItem);
342
+ if (!match) return null;
343
+ return {
344
+ text: match[1],
345
+ file: match[2],
346
+ oldStart: toInt(match[3]),
347
+ newStart: toInt(match[4]),
348
+ blockId: toInt(match[5]),
349
+ };
350
+ };
351
+
352
+ const fileGroup = (groups, file) => {
353
+ let group = groups.get(file);
354
+ if (!group) {
355
+ group = { todos: [], feedback: [] };
356
+ groups.set(file, group);
357
+ }
358
+ return group;
359
+ };
360
+
361
+ const groupReviewNotes = (store) => {
362
+ const groups = new Map();
363
+ for (const todo of store.todos) {
364
+ if (!todo.text.trim()) continue;
365
+ fileGroup(groups, todo.file).todos.push(todo);
366
+ }
367
+ for (const [key, note] of store.feedback) {
368
+ if (!note.text.trim()) continue;
369
+ fileGroup(groups, note.file).feedback.push({ key, note });
370
+ }
371
+ return groups;
372
+ };
373
+
374
+ const reviewHeader = (store) => {
375
+ const name = path.basename(store.reviewPath, '.md');
376
+ const status = canonicalStatus(store.status) || 'editing';
377
+ return [
378
+ '---',
379
+ `status: ${status}`,
380
+ '---',
381
+ '',
382
+ `# reslop review ${name}`,
383
+ '',
384
+ '## Agent instructions',
385
+ '',
386
+ AGENT_INSTRUCTIONS,
387
+ '',
388
+ ];
389
+ };
390
+
391
+ const emitReviewGroups = (lines, groups) => {
392
+ for (const [file, group] of groups) {
393
+ lines.push(`## ${file}`);
394
+ lines.push('');
395
+ for (const todo of group.todos) {
396
+ lines.push(checkboxLine(todo.text, todo.done));
397
+ }
398
+ for (const item of group.feedback) {
399
+ lines.push(feedbackLine(item.note));
400
+ }
401
+ lines.push('');
402
+ }
403
+ };
404
+
405
+ const serializeReview = (store) => {
406
+ const lines = reviewHeader(store);
407
+ emitReviewGroups(lines, groupReviewNotes(store));
408
+ return `${lines.join('\n')}\n`;
409
+ };
410
+
411
+ const parseFeedbackHeading = (line) => {
412
+ const match = FEED_HEAD.exec(line);
413
+ if (!match) return null;
414
+ const file = match[1];
415
+ const newStart = toInt(match[2]);
416
+ const rest = match[3];
417
+ const only = FEED_BLOCK_ONLY.exec(rest);
418
+ if (only) {
419
+ return { file, newStart, header: '', blockId: toInt(only[1]) };
420
+ }
421
+ const withHead = FEED_BLOCK.exec(rest);
422
+ if (!withHead) {
423
+ return { file, newStart, header: rest, blockId: 0 };
424
+ }
425
+ return {
426
+ file,
427
+ newStart,
428
+ header: withHead[1],
429
+ blockId: toInt(withHead[2]),
430
+ };
431
+ };
432
+
433
+ const flushPendingFeedback = (store, pending) => {
434
+ if (!pending || !(pending.text ?? '').trim()) return null;
435
+ storeFeedback(store, pending, pending.key);
436
+ return null;
437
+ };
438
+
439
+ const headingFile = (line) => {
440
+ const quoted = FILE_QUOTE.exec(line);
441
+ if (quoted) return quoted[1];
442
+ if (line.startsWith('## ') && line !== '## Agent instructions') {
443
+ return line.slice(3);
444
+ }
445
+ return null;
446
+ };
447
+
448
+ const startFeedbackPending = (heading, file) => ({
449
+ file: heading.file || file,
450
+ newStart: heading.newStart,
451
+ header: heading.header,
452
+ blockId: heading.blockId,
453
+ oldStart: heading.newStart,
454
+ origin: '',
455
+ key: '',
456
+ text: '',
457
+ });
458
+
459
+ const applyFeedMeta = (pending, raw) => {
460
+ const loc = parseLoc(raw);
461
+ if (!loc) return pending;
462
+ pending.origin = loc.origin;
463
+ pending.file = loc.file;
464
+ pending.oldStart = loc.oldStart;
465
+ pending.newStart = loc.newStart;
466
+ pending.blockId = loc.blockId;
467
+ pending.key = feedbackKey(loc);
468
+ return pending;
469
+ };
470
+
471
+ const applyCheckItem = (store, file, section, pending, textItem, done) => {
472
+ if (section === 'todo' && file) {
473
+ pushTodo(store, file, textItem, done);
474
+ return pending;
475
+ }
476
+ const parsed = parseFeedSuffix(textItem);
477
+ if (parsed) {
478
+ flushPendingFeedback(store, pending);
479
+ storeFeedback(store, {
480
+ file: parsed.file || file,
481
+ oldStart: parsed.oldStart,
482
+ newStart: parsed.newStart,
483
+ blockId: parsed.blockId,
484
+ origin: '',
485
+ header: '',
486
+ text: parsed.text,
487
+ done,
488
+ });
489
+ return null;
490
+ }
491
+ if (pending) {
492
+ pending.text = textItem;
493
+ pending.done = done;
494
+ return flushPendingFeedback(store, pending);
495
+ }
496
+ if (file && section !== 'feedback') {
497
+ pushTodo(store, file, textItem, done);
498
+ }
499
+ return pending;
500
+ };
501
+
502
+ const applyReviewLine = (store, file, section, pending, line) => {
503
+ const nextFile = headingFile(line);
504
+ if (nextFile !== null) {
505
+ pending = flushPendingFeedback(store, pending);
506
+ return { file: nextFile, section: '', pending };
507
+ }
508
+ const nextSection = SECTION_LINE[line];
509
+ if (nextSection) {
510
+ pending = flushPendingFeedback(store, pending);
511
+ return { file, section: nextSection, pending };
512
+ }
513
+ const heading = parseFeedbackHeading(line);
514
+ if (heading) {
515
+ flushPendingFeedback(store, pending);
516
+ pending = startFeedbackPending(heading, file);
517
+ return { file, section: 'feedback', pending };
518
+ }
519
+ const meta = FEED_META.exec(line);
520
+ if (meta && pending) {
521
+ pending = applyFeedMeta(pending, meta[1]);
522
+ return { file, section, pending };
523
+ }
524
+ const item = CHECK_ITEM.exec(line);
525
+ if (!item) return { file, section, pending };
526
+ const done = isDoneMark(item[1]);
527
+ pending = applyCheckItem(store, file, section, pending, item[2], done);
528
+ return { file, section, pending };
529
+ };
530
+
531
+ const parseReview = (text, reviewPath, templates = []) => {
532
+ const store = createStore(reviewPath, templates);
533
+ store.status = parseFrontmatterStatus(text) || 'editing';
534
+ let file = '';
535
+ let section = '';
536
+ let pending = null;
537
+ for (const line of text.split('\n')) {
538
+ const next = applyReviewLine(store, file, section, pending, line);
539
+ file = next.file;
540
+ section = next.section;
541
+ pending = next.pending;
542
+ }
543
+ flushPendingFeedback(store, pending);
544
+ store.dirty = false;
545
+ return store;
546
+ };
547
+
548
+ const loadReview = (reviewPath, templates, readFileSync = fs.readFileSync) => {
549
+ const raw = readFileSync(reviewPath, 'utf8');
550
+ return parseReview(raw, reviewPath, templates);
551
+ };
552
+
553
+ const loadTemplates = (dir, readFileSync = fs.readFileSync) => {
554
+ const file = path.join(dir, REVIEW_DIR, TEMPLATES_FILE);
555
+ try {
556
+ const raw = readFileSync(file, 'utf8');
557
+ const data = JSON.parse(raw);
558
+ if (!Array.isArray(data)) return [];
559
+ const templates = [];
560
+ for (const entry of data) {
561
+ if (!entry || typeof entry.text !== 'string') continue;
562
+ const count = toInt(entry.count);
563
+ templates.push({
564
+ text: entry.text,
565
+ count: Number.isFinite(count) ? count : 1,
566
+ });
567
+ }
568
+ return templates;
569
+ } catch {
570
+ return [];
571
+ }
572
+ };
573
+
574
+ const flushReview = (store, io = {}) => {
575
+ const writeFileSync = io.writeFileSync ?? fs.writeFileSync;
576
+ const mkdirSync = io.mkdirSync ?? fs.mkdirSync;
577
+ if (!store || !store.dirty) return false;
578
+ if (!hasNotes(store) && !io.force) {
579
+ store.dirty = false;
580
+ return false;
581
+ }
582
+ const folder = path.dirname(store.reviewPath);
583
+ mkdirSync(folder, { recursive: true });
584
+ writeFileSync(store.reviewPath, serializeReview(store), 'utf8');
585
+ const top = path.dirname(folder);
586
+ const templatesFile = path.join(top, REVIEW_DIR, TEMPLATES_FILE);
587
+ const body = `${JSON.stringify(store.templates, null, 2)}\n`;
588
+ writeFileSync(templatesFile, body, 'utf8');
589
+ store.dirty = false;
590
+ return true;
591
+ };
592
+
593
+ const todosByFile = (todos) => {
594
+ const byFile = new Map();
595
+ for (const todo of todos) {
596
+ const list = byFile.get(todo.file);
597
+ if (list) list.push(todo);
598
+ else byFile.set(todo.file, [todo]);
599
+ }
600
+ return byFile;
601
+ };
602
+
603
+ const makeTodoItem = (file) => ({
604
+ origin: 'todo',
605
+ file: {
606
+ oldPath: file,
607
+ newPath: file,
608
+ isNew: false,
609
+ isDeleted: false,
610
+ isBinary: false,
611
+ preamble: [],
612
+ hunks: [],
613
+ },
614
+ hunk: null,
615
+ blockId: 'todo',
616
+ patchAdd: '',
617
+ patchRevert: '',
618
+ });
619
+
620
+ const mergeTodos = (gitItems, todos, extraFiles = []) => {
621
+ const base = gitItems.filter((item) => item.origin !== 'todo');
622
+ const byFile = todosByFile(todos);
623
+ for (const file of extraFiles) {
624
+ if (!file || byFile.has(file)) continue;
625
+ byFile.set(file, []);
626
+ }
627
+ if (!byFile.size) return base;
628
+ const out = [];
629
+ const placed = new Set();
630
+ const placePage = (rel) => {
631
+ if (placed.has(rel)) return;
632
+ if (!byFile.has(rel)) return;
633
+ placed.add(rel);
634
+ out.push(makeTodoItem(rel));
635
+ };
636
+ for (let i = 0; i < base.length; i++) {
637
+ const item = base[i];
638
+ const rel = itemPath(item);
639
+ const prev = base[i - 1];
640
+ const prevRel = prev ? itemPath(prev) : '';
641
+ if (rel !== prevRel) placePage(rel);
642
+ out.push(item);
643
+ }
644
+ for (const rel of byFile.keys()) {
645
+ if (placed.has(rel)) continue;
646
+ placePage(rel);
647
+ }
648
+ return out;
649
+ };
650
+
651
+ module.exports = {
652
+ REVIEW_DIR,
653
+ TEMPLATES_FILE,
654
+ TEMPLATE_SHOW,
655
+ AUTOSAVE_MS,
656
+ REVIEW_STATUSES,
657
+ AGENT_INSTRUCTIONS,
658
+ dateStamp,
659
+ allocateReviewPath,
660
+ listReviewNames,
661
+ compareReviewNames,
662
+ latestReviewName,
663
+ parseFrontmatterStatus,
664
+ readReviewStatus,
665
+ resolveReviewPath,
666
+ rankedTemplates,
667
+ prefixTemplates,
668
+ upsertTemplate,
669
+ createStore,
670
+ noteCounts,
671
+ hasNotes,
672
+ rememberTemplate,
673
+ setFeedback,
674
+ addTodo,
675
+ removeTodo,
676
+ setTodoText,
677
+ isDoneMark,
678
+ checkLabel,
679
+ feedbackKey,
680
+ serializeReview,
681
+ parseReview,
682
+ loadReview,
683
+ loadTemplates,
684
+ flushReview,
685
+ makeTodoItem,
686
+ mergeTodos,
687
+ };