noggin-cli 0.1.2

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/noggin-api.mjs ADDED
@@ -0,0 +1,1236 @@
1
+ // noggin-api — typed, in-process API for the noggin working-memory tree.
2
+ //
3
+ // Used by both the CLI wrapper (cli/noggin.mjs) and the VS Code extension.
4
+ // Two layers:
5
+ // 1. Stateless verb functions (`apiPush`, `apiAdd`, …) that take a file
6
+ // path and an options object, do load → mutate → save, and return a
7
+ // view of the resulting tree. These power the CLI.
8
+ // 2. `Noggin` class — long-lived handle over one file. Caches the parsed
9
+ // store, watches the file for external edits, fires onDidChange.
10
+ // Used by the extension.
11
+ //
12
+ // Every failure throws a `NogginError` with a stable `code` and a CLI-style
13
+ // `exitCode`. Nothing in here writes to process.stderr or calls process.exit;
14
+ // that is the CLI wrapper's job. Error messages preserve the exact wording
15
+ // of the original cli.mjs so user-visible behaviour is unchanged.
16
+
17
+ /// <reference path="./noggin-api.d.mts" />
18
+
19
+ import yaml from 'js-yaml';
20
+ import fs from 'node:fs';
21
+ import path from 'node:path';
22
+ import os from 'node:os';
23
+ import crypto from 'node:crypto';
24
+
25
+ export const SCHEMA_VERSION = 1;
26
+ export const DEFAULT_FILE = path.join(os.homedir(), '.noggin.yaml');
27
+
28
+ /**
29
+ * Version tag stamped onto every JSON envelope this module produces (via
30
+ * `formatSuccess` / `formatError`). Independent of the on-disk store
31
+ * `SCHEMA_VERSION`; bump when the shape of `CurrentTreeView`, the envelope,
32
+ * or any per-verb payload changes in a breaking way.
33
+ */
34
+ export const JSON_SCHEMA_VERSION = 2;
35
+
36
+ /**
37
+ * Text of the system-generated note appended whenever an item transitions
38
+ * from open to done. The note's timestamp records when the close happened
39
+ * — there is no separate closedAt field on the item.
40
+ */
41
+ export const CLOSE_NOTE_TEXT = 'closed';
42
+
43
+ // ── Errors ───────────────────────────────────────────────────────────────────
44
+
45
+ export class NogginError extends Error {
46
+ /**
47
+ * @param {string} message
48
+ * @param {{ code?: string, exitCode?: number }} [opts]
49
+ */
50
+ constructor(message, opts = {}) {
51
+ super(message);
52
+ this.name = 'NogginError';
53
+ this.code = opts.code || 'noggin-error';
54
+ this.exitCode = typeof opts.exitCode === 'number' ? opts.exitCode : 2;
55
+ }
56
+ }
57
+
58
+ /** Throw a usage-style error (exit code 2). */
59
+ function usage(code, message) {
60
+ throw new NogginError(message, { code, exitCode: 2 });
61
+ }
62
+
63
+ /** Throw a runtime/state-style error (exit code 1). */
64
+ function runtime(code, message) {
65
+ throw new NogginError(message, { code, exitCode: 1 });
66
+ }
67
+
68
+ // ── Low-level helpers ────────────────────────────────────────────────────────
69
+
70
+ function nowIso() {
71
+ return new Date().toISOString();
72
+ }
73
+
74
+ function newKey() {
75
+ const d = new Date();
76
+ const pad = (n, w = 2) => String(n).padStart(w, '0');
77
+ const slug =
78
+ `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` +
79
+ `-${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}`;
80
+ const hex = crypto.randomBytes(3).toString('hex');
81
+ return `i-${slug}-${hex}`;
82
+ }
83
+
84
+ function emptyStore() {
85
+ return { schemaVersion: SCHEMA_VERSION, active: null, items: [] };
86
+ }
87
+
88
+ function normalizeNote(note) {
89
+ if (note && typeof note === 'object' && note.text !== undefined) {
90
+ return { timestamp: note.timestamp ? String(note.timestamp) : null, text: String(note.text) };
91
+ }
92
+ usage('invalid-note', 'internal: invalid note object');
93
+ }
94
+
95
+ function normalizeStore(store) {
96
+ store.schemaVersion = SCHEMA_VERSION;
97
+ for (const f of store.items) {
98
+ if (!Array.isArray(f.notes)) usage('invalid-store', 'invalid contents: item notes must be an array');
99
+ f.notes = f.notes.map(normalizeNote);
100
+ // closedAt and pushedAt were both dropped before noggin shipped.
101
+ // Strip them on load so a dev's pre-rename test file doesn't carry
102
+ // dead fields forward into the new on-disk shape.
103
+ if ('closedAt' in f) delete f.closedAt;
104
+ if ('pushedAt' in f) delete f.pushedAt;
105
+ }
106
+ return store;
107
+ }
108
+
109
+ function validateStore(store) {
110
+ const keys = new Set();
111
+ for (const f of store.items) {
112
+ if (!f.key) usage('invalid-store', 'internal: item missing key');
113
+ if (keys.has(f.key)) usage('invalid-store', 'internal: duplicate item key detected');
114
+ keys.add(f.key);
115
+ }
116
+ for (const f of store.items) {
117
+ if (f.parentKey && !keys.has(f.parentKey)) {
118
+ usage('invalid-store', 'internal: item has unknown parent reference');
119
+ }
120
+ }
121
+ if (store.active && !keys.has(store.active)) {
122
+ usage('invalid-store', 'internal: active points to unknown item');
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Load and validate a YAML store. Returns an empty store if the file does
128
+ * not exist or is empty.
129
+ */
130
+ export function loadStore(filePath) {
131
+ if (!fs.existsSync(filePath)) return emptyStore();
132
+ let raw;
133
+ try { raw = fs.readFileSync(filePath, 'utf8'); }
134
+ catch (e) { usage('io', `failed to read ${filePath}: ${e.message}`); }
135
+ if (!raw.trim()) return emptyStore();
136
+ let data;
137
+ try { data = yaml.load(raw); }
138
+ catch (e) { usage('invalid-store', `failed to parse ${filePath}: ${e.message}`); }
139
+ if (!data || typeof data !== 'object') {
140
+ usage('invalid-store', `invalid contents in ${filePath}: expected a mapping`);
141
+ }
142
+ if (data.schemaVersion !== SCHEMA_VERSION) {
143
+ usage(
144
+ 'unsupported-schema',
145
+ `schemaVersion ${data.schemaVersion} in ${filePath} not supported by this CLI ` +
146
+ `(expected ${SCHEMA_VERSION}).`,
147
+ );
148
+ }
149
+ if (!Array.isArray(data.items)) usage('invalid-store', `invalid contents in ${filePath}: expected items array`);
150
+ if (data.active === undefined) usage('invalid-store', `invalid contents in ${filePath}: expected active field`);
151
+ return normalizeStore(data);
152
+ }
153
+
154
+ function dumpStore(store) {
155
+ return yaml.dump(store, { noRefs: true, lineWidth: 100, sortKeys: false });
156
+ }
157
+
158
+ function writeAtomic(filePath, contents) {
159
+ const dir = path.dirname(filePath);
160
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
161
+ const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
162
+ fs.writeFileSync(tmp, contents, 'utf8');
163
+ fs.renameSync(tmp, filePath);
164
+ }
165
+
166
+ /** Write a YAML store. Atomic where the platform allows. */
167
+ export function saveStore(filePath, store) {
168
+ normalizeStore(store);
169
+ validateStore(store);
170
+ writeAtomic(filePath, dumpStore(store));
171
+ }
172
+
173
+ // ── Tree helpers ─────────────────────────────────────────────────────────────
174
+
175
+ function findByKey(items, key) {
176
+ if (!key) return null;
177
+ return items.find((f) => f.key === key) || null;
178
+ }
179
+
180
+ function _childrenOf(items, parentKey) {
181
+ return items.filter((f) => f.parentKey === parentKey);
182
+ }
183
+
184
+ function positionOf(items, item) {
185
+ if (!item) return null;
186
+ const siblings = _childrenOf(items, item.parentKey);
187
+ const index = siblings.findIndex((s) => s.key === item.key);
188
+ return index >= 0 ? index + 1 : null;
189
+ }
190
+
191
+ /**
192
+ * Compute the canonical absolute path string for an item: `/1/2/3`.
193
+ *
194
+ * The leading `/` is the contract marker that distinguishes an
195
+ * absolute path from a relative one. Every absolute path emitted by
196
+ * the API — `activePath`, `ItemView.path`, `parentPath`, error message
197
+ * fragments — has this leading slash. (CLI input still accepts the
198
+ * legacy bare-position form `1/2/3` for ergonomics.)
199
+ */
200
+ function _pathOf(items, item) {
201
+ if (!item) return null;
202
+ const parts = [];
203
+ let f = item;
204
+ while (f) {
205
+ const position = positionOf(items, f);
206
+ if (!position) return null;
207
+ parts.unshift(String(position));
208
+ f = f.parentKey ? findByKey(items, f.parentKey) : null;
209
+ }
210
+ return '/' + parts.join('/');
211
+ }
212
+
213
+ function ancestorsOf(items, item) {
214
+ const chain = [];
215
+ let f = item;
216
+ while (f && f.parentKey) {
217
+ const p = findByKey(items, f.parentKey);
218
+ if (!p) break;
219
+ chain.unshift(p);
220
+ f = p;
221
+ }
222
+ return chain;
223
+ }
224
+
225
+ // ── Path resolution ──────────────────────────────────────────────────────────
226
+
227
+ function siblingRelative(items, item, delta, originalForError) {
228
+ const peers = _childrenOf(items, item.parentKey || null);
229
+ const index = peers.findIndex((p) => p.key === item.key);
230
+ const target = peers[index + delta];
231
+ if (!target) {
232
+ const direction = delta < 0 ? 'previous' : 'next';
233
+ return { ok: false, error: `path '${originalForError}': active item has no ${direction} sibling` };
234
+ }
235
+ return { ok: true, item: target };
236
+ }
237
+
238
+ function walkPath(items, base, segPath, originalForError) {
239
+ const segments = segPath.split('/').filter(Boolean);
240
+ if (segments.length === 0) {
241
+ return base ? { ok: true, item: base } : { ok: false, error: `path '${originalForError}' is empty` };
242
+ }
243
+ let current = base;
244
+ for (const seg of segments) {
245
+ if (!/^\d+$/.test(seg) || Number(seg) < 1) {
246
+ return { ok: false, error: `path '${originalForError}': segment '${seg}' is not a 1-based position` };
247
+ }
248
+ const parentKey = current ? current.key : null;
249
+ const position = Number(seg);
250
+ const match = _childrenOf(items, parentKey)[position - 1];
251
+ if (!match) {
252
+ const where = current ? `under '${_pathOf(items, current)}'` : 'at root';
253
+ return { ok: false, error: `path not found: ${originalForError} (no position ${position} ${where})` };
254
+ }
255
+ current = match;
256
+ }
257
+ return { ok: true, item: current };
258
+ }
259
+
260
+ /**
261
+ * Resolve a path string against a store. Path grammar:
262
+ *
263
+ * Absolute (always starts with `/`):
264
+ * '/1/2/3'
265
+ *
266
+ * Relative (anything else; resolved against the active item):
267
+ * '.' active item
268
+ * '..' parent of active
269
+ * '-' / '+' previous / next sibling of active
270
+ * './X/Y' descend from active
271
+ * '../X' sibling of active (child X of parent)
272
+ * '-/X' / '+/X' descend from previous / next sibling
273
+ * '../../X' walk up two and then down
274
+ * 'X' / 'X/Y' bare positions are short for './X' / './X/Y'
275
+ *
276
+ * Returns `{ ok: true, item } | { ok: false, error }`.
277
+ */
278
+ function tryResolveDetailed(store, p) {
279
+ if (!p) return { ok: false, error: `path: empty path` };
280
+ const s = String(p);
281
+ const active = store.active ? findByKey(store.items, store.active) : null;
282
+
283
+ // Absolute. The leading `/` is the unambiguous marker.
284
+ if (s.startsWith('/')) {
285
+ const rest = s.slice(1);
286
+ if (rest === '') return { ok: false, error: `path '${s}': empty absolute path` };
287
+ return walkPath(store.items, null, rest, s);
288
+ }
289
+
290
+ // Relative special tokens.
291
+ if (s === '.') {
292
+ if (!active) return { ok: false, error: `path '.': no active item` };
293
+ return { ok: true, item: active };
294
+ }
295
+ if (s === '..') {
296
+ if (!active) return { ok: false, error: `path '..': no active item` };
297
+ if (!active.parentKey) return { ok: false, error: `path '..': active item has no parent` };
298
+ return { ok: true, item: findByKey(store.items, active.parentKey) };
299
+ }
300
+ if (s === '-' || s === '+') {
301
+ if (!active) return { ok: false, error: `path '${s}': no active item` };
302
+ return siblingRelative(store.items, active, s === '-' ? -1 : 1, s);
303
+ }
304
+
305
+ if (s.startsWith('-/') || s.startsWith('+/')) {
306
+ if (!active) return { ok: false, error: `path '${s}' is relative but no active item` };
307
+ const direction = s[0] === '-' ? -1 : 1;
308
+ const sibling = siblingRelative(store.items, active, direction, s);
309
+ if (!sibling.ok) return sibling;
310
+ const rest = s.slice(2);
311
+ if (rest === '') return { ok: false, error: `path '${s}': trailing slash with no descendant` };
312
+ return walkPath(store.items, sibling.item, rest, s);
313
+ }
314
+
315
+ // Everything else is relative to active: `./X`, `../X`, or bare `X/Y`
316
+ // (which is implicit `./X/Y`). Walk up for any leading `../` segments,
317
+ // then strip the optional `./` and descend.
318
+ if (!active) return { ok: false, error: `path '${s}' is relative but no active item` };
319
+ let base = active;
320
+ let rest = s;
321
+ while (rest === '..' || rest.startsWith('../')) {
322
+ if (!base.parentKey) return { ok: false, error: `path '${s}': cannot go above root` };
323
+ base = findByKey(store.items, base.parentKey);
324
+ rest = rest === '..' ? '' : rest.slice(3);
325
+ }
326
+ if (rest.startsWith('./')) rest = rest.slice(2);
327
+ if (rest === '') return { ok: true, item: base };
328
+ return walkPath(store.items, base, rest, s);
329
+ }
330
+
331
+ /** Resolve a path or throw NogginError (exit 1). */
332
+ export function resolvePath(store, p) {
333
+ const r = tryResolveDetailed(store, p);
334
+ if (r.ok) return r.item;
335
+ runtime('path-not-found', r.error);
336
+ }
337
+
338
+ /** Resolve a path or return null. */
339
+ export function tryResolvePath(store, p) {
340
+ const r = tryResolveDetailed(store, p);
341
+ return r.ok ? r.item : null;
342
+ }
343
+
344
+ /** Compute the absolute 1-based path for an item in the store. */
345
+ export function pathOf(store, item) {
346
+ return _pathOf(store.items, item);
347
+ }
348
+
349
+ /** Children of a parent (null = roots), in stable on-disk order. */
350
+ export function childrenOf(store, parentKey) {
351
+ return _childrenOf(store.items, parentKey || null);
352
+ }
353
+
354
+ // ── Subtree utilities ────────────────────────────────────────────────────────
355
+
356
+ function isDescendant(items, candidate, root) {
357
+ if (!candidate || !root) return false;
358
+ let node = candidate;
359
+ while (node && node.parentKey) {
360
+ if (node.parentKey === root.key) return true;
361
+ node = findByKey(items, node.parentKey);
362
+ }
363
+ return false;
364
+ }
365
+
366
+ function countOpenDescendants(items, root) {
367
+ let n = 0;
368
+ const stack = _childrenOf(items, root.key);
369
+ while (stack.length) {
370
+ const f = stack.pop();
371
+ if (!f.done) n++;
372
+ for (const c of _childrenOf(items, f.key)) stack.push(c);
373
+ }
374
+ return n;
375
+ }
376
+
377
+ function collectDescendants(items, root) {
378
+ const out = [];
379
+ const stack = [..._childrenOf(items, root.key)];
380
+ while (stack.length) {
381
+ const f = stack.pop();
382
+ out.push(f);
383
+ for (const c of _childrenOf(items, f.key)) stack.push(c);
384
+ }
385
+ return out;
386
+ }
387
+
388
+ // ── View builders ────────────────────────────────────────────────────────────
389
+
390
+ function toPublicItem(items, f) {
391
+ return {
392
+ key: f.key,
393
+ parentKey: f.parentKey || null,
394
+ path: _pathOf(items, f),
395
+ position: positionOf(items, f),
396
+ title: f.title,
397
+ done: Boolean(f.done),
398
+ createdAt: f.createdAt,
399
+ notes: Array.isArray(f.notes) ? f.notes.map(normalizeNote) : [],
400
+ };
401
+ }
402
+
403
+ /**
404
+ * Build the CurrentTreeView shape — the unified payload returned by every
405
+ * mutating verb and by `show`. Pure; does not mutate the store.
406
+ *
407
+ * Options (all default to "normal show" behavior):
408
+ * includeChildren expand target.children; default true
409
+ * (set false for --no-children)
410
+ * withSiblings include the full sibling row at every ancestor
411
+ * depth (default: ancestors are trimmed to the
412
+ * single item on the spine)
413
+ * withDescendants expand the target's subtree recursively instead
414
+ * of just first-level kids (default: kids are leaves)
415
+ *
416
+ * Without options, the recursion walks the direct ancestor chain from
417
+ * root to target. Each ancestor's `children` is a single-element array
418
+ * (sibling-of-ancestors trimmed). The target's parent's `children` is
419
+ * the full peer row. The target itself has `children` populated with
420
+ * its first-level kids. Peers and grandkids are leaves — no `children`
421
+ * field.
422
+ *
423
+ * With `withSiblings`, each intermediate ancestor's `children` is the full
424
+ * sibling row at that depth, not just the spine item. Sibling subtrees
425
+ * of those ancestors stay collapsed (leaves) so the spine is still
426
+ * visible.
427
+ *
428
+ * With `withDescendants`, the target's subtree is fully expanded recursively;
429
+ * every descendant has a `children` field describing its own subtree.
430
+ *
431
+ * If the target is itself a root, `items` is the target's full peer row
432
+ * (the actual roots of the store).
433
+ */
434
+ export function buildView(store, target, opts = {}) {
435
+ if (!target) return null;
436
+ const includeChildren = opts.includeChildren !== false;
437
+ const withSiblings = opts.withSiblings === true;
438
+ const withDescendants = opts.withDescendants === true;
439
+ const activeItem = store.active ? findByKey(store.items, store.active) : null;
440
+ const lineage = [...ancestorsOf(store.items, target), target];
441
+
442
+ // Render a single item as a leaf (no `children` field).
443
+ const leaf = (item) => toPublicItem(store.items, item);
444
+
445
+ // Render an item with its full subtree expanded recursively.
446
+ function expanded(item) {
447
+ return {
448
+ ...toPublicItem(store.items, item),
449
+ children: _childrenOf(store.items, item.key).map(expanded),
450
+ };
451
+ }
452
+
453
+ // Target node. Carries `children` only when --no-children wasn't passed.
454
+ // With withDescendants, expand the whole subtree; otherwise grandkids are
455
+ // leaves (no `children` field).
456
+ let targetNode;
457
+ if (!includeChildren) {
458
+ targetNode = leaf(target);
459
+ } else if (withDescendants) {
460
+ targetNode = expanded(target);
461
+ } else {
462
+ targetNode = {
463
+ ...toPublicItem(store.items, target),
464
+ children: _childrenOf(store.items, target.key).map(leaf),
465
+ };
466
+ }
467
+
468
+ // Target's full peer row. Peers other than the target are leaves.
469
+ let level = _childrenOf(store.items, target.parentKey || null).map((it) =>
470
+ it.key === target.key ? targetNode : leaf(it),
471
+ );
472
+
473
+ // Wrap each ancestor (root → target's parent) with a `children` slot
474
+ // that descends into the level we just built.
475
+ //
476
+ // The lowest ancestor (target's parent) always gets the full peer row
477
+ // as its children — that's the peer row of the target itself, which
478
+ // we never trim. Higher ancestors get either just the single descent
479
+ // path (default) or the full sibling row at that depth with sibling
480
+ // subtrees collapsed (withSiblings).
481
+ for (let i = lineage.length - 2; i >= 0; i--) {
482
+ const ancestor = lineage[i];
483
+ const isTargetParent = i === lineage.length - 2;
484
+ let ancestorChildren;
485
+ if (isTargetParent || !withSiblings) {
486
+ ancestorChildren = level;
487
+ } else {
488
+ // Higher ancestor + withSiblings: include all of this ancestor's
489
+ // children. The spine child (`level[0]`) keeps its expanded
490
+ // subtree; the rest are leaves with no `children` field, so
491
+ // sibling subtrees stay collapsed.
492
+ const nextSpineKey = level[0].key;
493
+ ancestorChildren = _childrenOf(store.items, ancestor.key).map((it) =>
494
+ it.key === nextSpineKey ? level[0] : leaf(it),
495
+ );
496
+ }
497
+ level = [{
498
+ ...toPublicItem(store.items, ancestor),
499
+ children: ancestorChildren,
500
+ }];
501
+ }
502
+
503
+ // If the target is itself a root and withSiblings is on, the items array
504
+ // is already the target's full peer row (= the actual store roots).
505
+ // No further wrapping needed.
506
+
507
+ return {
508
+ activePath: activeItem ? _pathOf(store.items, activeItem) : null,
509
+ activeKey: activeItem ? activeItem.key : null,
510
+ targetKey: target.key,
511
+ items: level,
512
+ };
513
+ }
514
+
515
+ // ── JSON envelope ────────────────────────────────────────────────────────────
516
+
517
+ /**
518
+ * Whitelist of fields whose default value is stripped from JSON output
519
+ * to keep payloads focused. The predicate decides whether a given value
520
+ * counts as "default" for that field name. Anything not listed here is
521
+ * always emitted, even if null/false/empty — explicit beats implicit.
522
+ *
523
+ * Notable omissions:
524
+ * - `children`: encoded by presence rather than value (absent means
525
+ * "leaf of view"; present means "view renders this node's child
526
+ * level", possibly with `[]`). Pruning doesn't apply.
527
+ * - `path` / `position`: absent only when the item was just deleted;
528
+ * the absence is the signal, so don't suppress it.
529
+ * - envelope fields (`status`, `schemaVersion`, `verb`, `file`,
530
+ * `data`, `error`): always present; not data.
531
+ */
532
+ const PRUNABLE_DEFAULTS = {
533
+ parentKey: (v) => v === null,
534
+ done: (v) => v === false,
535
+ notes: (v) => Array.isArray(v) && v.length === 0,
536
+ activePath: (v) => v === null,
537
+ activeKey: (v) => v === null,
538
+ descendantCount: (v) => v === 0,
539
+ exists: (v) => v === false,
540
+ env: (v) => v === null,
541
+ view: (v) => v === null,
542
+ };
543
+
544
+ /**
545
+ * Recursively strip whitelisted default values from `data`. Arrays and
546
+ * plain objects are walked; everything else is returned as-is.
547
+ */
548
+ function pruneDefaults(value) {
549
+ if (Array.isArray(value)) return value.map(pruneDefaults);
550
+ if (value === null || typeof value !== 'object') return value;
551
+ const out = {};
552
+ for (const [key, raw] of Object.entries(value)) {
553
+ const predicate = PRUNABLE_DEFAULTS[key];
554
+ if (predicate && predicate(raw)) continue;
555
+ out[key] = pruneDefaults(raw);
556
+ }
557
+ return out;
558
+ }
559
+
560
+ /**
561
+ * Wrap a successful verb result in the canonical JSON envelope. Used by
562
+ * both the CLI `--json` flag and the VS Code extension's language-model
563
+ * tools so the two surfaces emit byte-identical shapes.
564
+ *
565
+ * The envelope itself (status, schemaVersion, verb, file, data) is
566
+ * always fully present. `data` is run through `pruneDefaults` so
567
+ * whitelisted fields equal to their declared default are omitted.
568
+ *
569
+ * @param {object} opts
570
+ * @param {string} [opts.verb] Verb name (e.g. 'push', 'show').
571
+ * @param {string|null} [opts.file] Resolved noggin file path, or null.
572
+ * @param {any} [opts.data] Verb-specific payload (e.g. CurrentTreeView).
573
+ */
574
+ export function formatSuccess({ verb, file, data } = {}) {
575
+ return {
576
+ status: 'ok',
577
+ schemaVersion: JSON_SCHEMA_VERSION,
578
+ verb: verb || null,
579
+ file: file || null,
580
+ data: data === undefined ? null : pruneDefaults(data),
581
+ };
582
+ }
583
+
584
+ /**
585
+ * Wrap an error in the canonical JSON envelope. Accepts a `NogginError`
586
+ * (preserves its `code` and `exitCode`) or any other thrown value.
587
+ *
588
+ * @param {object} opts
589
+ * @param {string} [opts.verb]
590
+ * @param {string|null} [opts.file]
591
+ * @param {unknown} [opts.error]
592
+ */
593
+ export function formatError({ verb, file, error } = {}) {
594
+ const isNoggin = error instanceof NogginError;
595
+ const message = error instanceof Error ? error.message : String(error ?? 'unknown error');
596
+ const code = isNoggin ? error.code : 'noggin-error';
597
+ const exitCode = isNoggin ? error.exitCode : 1;
598
+ return {
599
+ status: 'error',
600
+ schemaVersion: JSON_SCHEMA_VERSION,
601
+ verb: verb || null,
602
+ file: file || null,
603
+ error: { code, message, exitCode },
604
+ };
605
+ }
606
+
607
+ // ── File resolution ──────────────────────────────────────────────────────────
608
+
609
+ /**
610
+ * Resolve the noggin file path with the same priority as the CLI:
611
+ * 1. `opts.file`
612
+ * 2. `opts.env.NOGGIN_FILE` (defaults to process.env)
613
+ * 3. `~/.noggin.yaml`
614
+ */
615
+ export function resolveFile(opts = {}) {
616
+ const env = opts.env || process.env;
617
+ let file, source;
618
+ if (opts.file) { file = opts.file; source = 'flag'; }
619
+ else if (env.NOGGIN_FILE) { file = env.NOGGIN_FILE; source = 'env'; }
620
+ else { file = DEFAULT_FILE; source = 'default'; }
621
+ return {
622
+ file,
623
+ source,
624
+ exists: fs.existsSync(file),
625
+ defaultFile: DEFAULT_FILE,
626
+ env: env.NOGGIN_FILE || null,
627
+ };
628
+ }
629
+
630
+ // ── Internal verb helpers ────────────────────────────────────────────────────
631
+
632
+ function applyGoto(store, base, goto, commandName) {
633
+ if (goto === undefined) return base;
634
+ if (!base) runtime('goto-base-missing', `${commandName}: --goto has no base item`);
635
+ const gotoPath = goto === true ? '.' : goto;
636
+ if (!gotoPath) runtime('goto-path-required', `${commandName}: --goto requires a path`);
637
+ const scopedStore = { ...store, active: base.key };
638
+ const resolved = tryResolveDetailed(scopedStore, gotoPath);
639
+ if (!resolved.ok) runtime('goto-unresolved', `${commandName}: --goto ${resolved.error}`);
640
+ store.active = resolved.item.key;
641
+ return resolved.item;
642
+ }
643
+
644
+ function makeItem({ title, parentKey }) {
645
+ return {
646
+ key: newKey(),
647
+ parentKey,
648
+ title,
649
+ done: false,
650
+ createdAt: nowIso(),
651
+ notes: [],
652
+ };
653
+ }
654
+
655
+ /** Append the system-generated close note. */
656
+ function appendCloseNote(item) {
657
+ if (!Array.isArray(item.notes)) item.notes = [];
658
+ item.notes.push({ timestamp: nowIso(), text: CLOSE_NOTE_TEXT });
659
+ }
660
+
661
+ /**
662
+ * Validate a placement option ({ kind, anchor } where anchor is a path).
663
+ * Returns the resolved anchor item and the kind.
664
+ */
665
+ function resolvePlacement(store, placement, commandName) {
666
+ if (!placement) return null;
667
+ const { kind, anchor } = placement;
668
+ if (!kind || !anchor) {
669
+ usage('placement-missing', `${commandName}: placement requires both kind and anchor`);
670
+ }
671
+ if (kind !== 'before' && kind !== 'after' && kind !== 'into') {
672
+ usage('placement-invalid', `${commandName}: unknown placement kind '${kind}'`);
673
+ }
674
+ const anchorItem = resolvePath(store, anchor);
675
+ return { kind, anchor: anchorItem };
676
+ }
677
+
678
+ // ── Verb implementations ─────────────────────────────────────────────────────
679
+
680
+ /**
681
+ * push: create a child of active (or a root if none) and become active.
682
+ */
683
+ export function apiPush(file, opts) {
684
+ const title = (opts && opts.title || '').toString().trim();
685
+ if (!title) usage('title-required', 'push: title required (--title or positional)');
686
+ const store = loadStore(file);
687
+ const activeItem = findByKey(store.items, store.active);
688
+ const item = makeItem({ title, parentKey: activeItem ? activeItem.key : null });
689
+ store.items.push(item);
690
+ store.active = item.key;
691
+ saveStore(file, store);
692
+ return buildView(store, item);
693
+ }
694
+
695
+ /**
696
+ * add: create an item. With no placement, becomes a child of active (or root).
697
+ * Placement flags (`{ kind: 'before'|'after'|'into', anchor: path }`) override.
698
+ * Active is unchanged unless `goto` is supplied.
699
+ */
700
+ export function apiAdd(file, opts = {}) {
701
+ const title = (opts.title || '').toString().trim();
702
+ if (!title) usage('title-required', 'add: title required (--title or positional)');
703
+ const store = loadStore(file);
704
+ const activeItem = findByKey(store.items, store.active);
705
+ const placement = resolvePlacement(store, opts.placement, 'add');
706
+
707
+ let parentKey;
708
+ let insertIndex;
709
+ if (placement) {
710
+ const { kind, anchor } = placement;
711
+ if (kind === 'into') {
712
+ parentKey = anchor.key;
713
+ insertIndex = store.items.length;
714
+ } else {
715
+ parentKey = anchor.parentKey;
716
+ const anchorIdx = store.items.indexOf(anchor);
717
+ insertIndex = kind === 'before' ? anchorIdx : anchorIdx + 1;
718
+ }
719
+ } else {
720
+ parentKey = activeItem ? activeItem.key : null;
721
+ insertIndex = store.items.length;
722
+ }
723
+
724
+ const item = makeItem({ title, parentKey });
725
+ store.items.splice(insertIndex, 0, item);
726
+ const outputTarget = opts.goto !== undefined ? applyGoto(store, item, opts.goto, 'add') : item;
727
+ saveStore(file, store);
728
+ return buildView(store, outputTarget);
729
+ }
730
+
731
+ /**
732
+ * move: relocate an item. Default target = active. Placement is required.
733
+ * Active pointer is preserved by key; cycles are rejected.
734
+ */
735
+ export function apiMove(file, opts = {}) {
736
+ const store = loadStore(file);
737
+ const placement = resolvePlacement(store, opts.placement, 'move');
738
+ if (!placement) usage('placement-missing', 'move: choose exactly one of --before, --after, or --into');
739
+ const { kind, anchor } = placement;
740
+
741
+ let target;
742
+ if (opts.path) target = resolvePath(store, opts.path);
743
+ else {
744
+ target = findByKey(store.items, store.active);
745
+ if (!target) runtime('no-active-item', 'move: no active item; pass a path');
746
+ }
747
+
748
+ if (kind === 'into') {
749
+ if (target.key === anchor.key) {
750
+ runtime('cycle', `move: cannot move ${_pathOf(store.items, target)} into itself (would create a cycle)`);
751
+ }
752
+ if (isDescendant(store.items, anchor, target)) {
753
+ runtime('cycle', `move: cannot move ${_pathOf(store.items, target)} into its own subtree (would create a cycle)`);
754
+ }
755
+ } else {
756
+ if (isDescendant(store.items, anchor, target)) {
757
+ runtime('cycle', `move: cannot move ${_pathOf(store.items, target)} next to its own descendant (would create a cycle)`);
758
+ }
759
+ if (anchor.key === target.key) {
760
+ // before/after self: same place. Silent no-op.
761
+ const activeItem = findByKey(store.items, store.active);
762
+ const outputTarget = opts.goto !== undefined ? applyGoto(store, target, opts.goto, 'move') : (activeItem || target);
763
+ saveStore(file, store);
764
+ return buildView(store, outputTarget);
765
+ }
766
+ }
767
+
768
+ const newParentKey = kind === 'into' ? anchor.key : anchor.parentKey;
769
+ const targetIdx = store.items.indexOf(target);
770
+ store.items.splice(targetIdx, 1);
771
+
772
+ let insertIndex;
773
+ if (kind === 'into') {
774
+ insertIndex = store.items.length;
775
+ } else {
776
+ const anchorIdx = store.items.indexOf(anchor);
777
+ insertIndex = kind === 'before' ? anchorIdx : anchorIdx + 1;
778
+ }
779
+
780
+ target.parentKey = newParentKey;
781
+ store.items.splice(insertIndex, 0, target);
782
+
783
+ const activeItem = findByKey(store.items, store.active);
784
+ const outputTarget = opts.goto !== undefined ? applyGoto(store, target, opts.goto, 'move') : (activeItem || target);
785
+ saveStore(file, store);
786
+ return buildView(store, outputTarget);
787
+ }
788
+
789
+ /** goto: make the item at `path` active. */
790
+ export function apiGoto(file, opts = {}) {
791
+ if (!opts.path) usage('path-required', 'goto: path required');
792
+ const store = loadStore(file);
793
+ const target = resolvePath(store, opts.path);
794
+ store.active = target.key;
795
+ saveStore(file, store);
796
+ return buildView(store, target);
797
+ }
798
+
799
+ /**
800
+ * Close `target` (and optionally its open descendants), enforcing the
801
+ * open-descendant rule unless `force` or `closeAll` opts it out. Shared
802
+ * by `apiDone`/`apiPop`/`apiEdit`. Mutates `store` in place; idempotent
803
+ * when `target` is already done.
804
+ *
805
+ * force skip the open-descendant check; close just the target
806
+ * even though some kids remain open
807
+ * closeAll walk descendants first; close every open one (each
808
+ * gets its own system "closed" note)
809
+ *
810
+ * Throws a runtime NogginError with code `open-descendants` if there
811
+ * are open descendants and neither flag is set.
812
+ */
813
+ function closeWithRules(store, target, opts, verb) {
814
+ const force = opts.force === true;
815
+ const closeAll = opts.closeAll === true;
816
+ if (closeAll) {
817
+ for (const d of collectDescendants(store.items, target)) {
818
+ if (!d.done) {
819
+ d.done = true;
820
+ appendCloseNote(d);
821
+ }
822
+ }
823
+ }
824
+ if (!force && !closeAll) {
825
+ const open = countOpenDescendants(store.items, target);
826
+ if (open > 0) {
827
+ runtime(
828
+ 'open-descendants',
829
+ `${verb}: ${_pathOf(store.items, target)} has ${open} open descendant(s); ` +
830
+ `pass --closeall to close them too, or --force to close ${target.title} anyway`,
831
+ );
832
+ }
833
+ }
834
+ if (!target.done) {
835
+ target.done = true;
836
+ appendCloseNote(target);
837
+ }
838
+ }
839
+
840
+ /**
841
+ * done: mark an item done, then move active to the target's parent.
842
+ *
843
+ * Idempotent — if the target is already done, no error and no extra
844
+ * close note; the navigational side-effect (surface to parent) still
845
+ * happens.
846
+ *
847
+ * `--force` skips the open-descendant safety check; `--closeall` first
848
+ * closes every open descendant. Without either flag, an open
849
+ * descendant blocks the call with a runtime error.
850
+ */
851
+ export function apiDone(file, opts = {}) {
852
+ if (opts.goto !== undefined) usage('goto-unsupported', 'done: --goto is not supported; done always moves to the target parent');
853
+ const store = loadStore(file);
854
+ let target;
855
+ if (opts.path) target = resolvePath(store, opts.path);
856
+ else {
857
+ target = findByKey(store.items, store.active);
858
+ if (!target) runtime('no-active-item', 'done: no active item; pass a path');
859
+ }
860
+ closeWithRules(store, target, opts, 'done');
861
+ const parent = target.parentKey ? findByKey(store.items, target.parentKey) : null;
862
+ store.active = parent ? parent.key : null;
863
+ saveStore(file, store);
864
+ return buildView(store, parent || target);
865
+ }
866
+
867
+ /** pop: shorthand for done() on the active item. Honors --force / --closeall. */
868
+ export function apiPop(file, opts = {}) {
869
+ if (opts && opts.path !== undefined) usage('pop-no-path', 'pop: takes no path; pop always operates on the active item');
870
+ if (opts && opts.goto !== undefined) usage('goto-unsupported', 'pop: --goto is not supported; pop always moves to the active item\'s parent');
871
+ const store = loadStore(file);
872
+ if (!findByKey(store.items, store.active)) runtime('no-active-item', 'pop: no active item');
873
+ return apiDone(file, {
874
+ force: opts.force === true,
875
+ closeAll: opts.closeAll === true,
876
+ });
877
+ }
878
+
879
+ /**
880
+ * edit: explicitly mutate one item's lifecycle state and/or title. Combines
881
+ * the old `set-state` and `retitle` verbs. At least one of `done`/`title`
882
+ * is required. Each operation is idempotent (no error if the value already
883
+ * matches).
884
+ *
885
+ * done true → close (subject to open-descendant rules below)
886
+ * false → reopen
887
+ * undefined → don't touch state
888
+ * title new title (trimmed; empty string is ignored, not an error)
889
+ * force when closing, skip the open-descendant check
890
+ * closeAll when closing, first close every open descendant
891
+ * goto standard reposition-after-write option
892
+ *
893
+ * Unlike `done`, `edit --done` does NOT surface active to the parent;
894
+ * active is unchanged unless `--goto` is passed.
895
+ */
896
+ export function apiEdit(file, opts = {}) {
897
+ const hasState = typeof opts.done === 'boolean';
898
+ const rawTitle = opts.title;
899
+ const hasTitle = typeof rawTitle === 'string' && rawTitle.trim() !== '';
900
+ if (!hasState && !hasTitle) {
901
+ usage('nothing-to-edit', 'edit: nothing to edit; pass at least one of --done, --open, --title');
902
+ }
903
+ const closing = hasState && opts.done === true;
904
+ if (!closing && opts.force === true) {
905
+ usage('option-misused', 'edit: --force only applies when closing (with --done)');
906
+ }
907
+ if (!closing && opts.closeAll === true) {
908
+ usage('option-misused', 'edit: --close-all only applies when closing (with --done)');
909
+ }
910
+
911
+ const store = loadStore(file);
912
+ let target;
913
+ if (opts.path) target = resolvePath(store, opts.path);
914
+ else {
915
+ target = findByKey(store.items, store.active);
916
+ if (!target) runtime('no-active-item', 'edit: no active item; pass a path');
917
+ }
918
+
919
+ if (hasState) {
920
+ if (opts.done) {
921
+ closeWithRules(store, target, opts, 'edit');
922
+ } else if (target.done) {
923
+ target.done = false;
924
+ }
925
+ }
926
+
927
+ if (hasTitle) {
928
+ const next = rawTitle.toString().trim();
929
+ if (target.title !== next) target.title = next;
930
+ }
931
+
932
+ const outputTarget = opts.goto !== undefined ? applyGoto(store, target, opts.goto, 'edit') : target;
933
+ saveStore(file, store);
934
+ return buildView(store, outputTarget);
935
+ }
936
+
937
+ /**
938
+ * show: detail for one item plus first-level children. Default target = active.
939
+ * Returns null if no target can be resolved (no active item, no path given).
940
+ */
941
+ export function apiShow(file, opts = {}) {
942
+ const store = loadStore(file);
943
+ const target = opts.path
944
+ ? resolvePath(store, opts.path)
945
+ : findByKey(store.items, store.active);
946
+ if (!target) return null;
947
+ const outputTarget = opts.goto !== undefined ? applyGoto(store, target, opts.goto, 'show') : target;
948
+ if (opts.goto !== undefined) saveStore(file, store);
949
+ return buildView(store, outputTarget, {
950
+ includeChildren: opts.includeChildren !== false,
951
+ withSiblings: opts.withSiblings === true,
952
+ withDescendants: opts.withDescendants === true,
953
+ });
954
+ }
955
+
956
+ /** note: append a timestamped note. Path defaults to active. */
957
+ export function apiNote(file, opts = {}) {
958
+ const text = (opts.text || '').toString().trim();
959
+ if (!text) usage('text-required', 'note: text required');
960
+ const store = loadStore(file);
961
+ let target;
962
+ if (opts.path) target = resolvePath(store, opts.path);
963
+ else {
964
+ target = findByKey(store.items, store.active);
965
+ if (!target) runtime('no-active-item', 'note: no active item and no path given');
966
+ }
967
+ if (!Array.isArray(target.notes)) target.notes = [];
968
+ target.notes.push({ timestamp: nowIso(), text });
969
+ const outputTarget = opts.goto !== undefined ? applyGoto(store, target, opts.goto, 'note') : target;
970
+ saveStore(file, store);
971
+ return buildView(store, outputTarget);
972
+ }
973
+
974
+ /**
975
+ * delete: remove an item. Refuses if it has descendants unless `recursive`.
976
+ * If the deleted subtree contains the active item, active becomes the
977
+ * deleted item's parent (or null if it was a root).
978
+ */
979
+ export function apiDelete(file, opts = {}) {
980
+ if (opts.goto !== undefined) usage('goto-unsupported', 'delete: --goto is not supported');
981
+ if (!opts.path) usage('path-required', 'delete: path required');
982
+ const store = loadStore(file);
983
+ const target = resolvePath(store, opts.path);
984
+ const targetPath = _pathOf(store.items, target);
985
+ const targetKey = target.key;
986
+ const targetTitle = target.title;
987
+ const descendants = collectDescendants(store.items, target);
988
+ if (descendants.length > 0 && opts.recursive !== true) {
989
+ runtime(
990
+ 'has-descendants',
991
+ `delete: ${targetPath} has ${descendants.length} descendant(s); ` +
992
+ `pass --recursive to delete the whole subtree`,
993
+ );
994
+ }
995
+ const removeKeys = new Set([target.key, ...descendants.map((d) => d.key)]);
996
+ const activeWasRemoved = store.active != null && removeKeys.has(store.active);
997
+ store.items = store.items.filter((i) => !removeKeys.has(i.key));
998
+ if (activeWasRemoved) {
999
+ store.active = target.parentKey || null;
1000
+ }
1001
+ saveStore(file, store);
1002
+ const newActive = findByKey(store.items, store.active);
1003
+ return {
1004
+ deleted: { key: targetKey, path: targetPath, title: targetTitle },
1005
+ descendantCount: descendants.length,
1006
+ view: newActive ? buildView(store, newActive) : null,
1007
+ };
1008
+ }
1009
+
1010
+ /** where: returns the resolved file info for the current options. */
1011
+ export function apiWhere(opts = {}) {
1012
+ return resolveFile(opts);
1013
+ }
1014
+
1015
+ // ── Noggin class ─────────────────────────────────────────────────────────────
1016
+
1017
+ /**
1018
+ * Long-lived handle over a single noggin file. Caches the parsed store in
1019
+ * memory, watches the file for external edits, and fires onDidChange when
1020
+ * the store changes (via a verb method or an external edit).
1021
+ *
1022
+ * Read accessors are cheap. Verbs reload from disk before mutating so they
1023
+ * see any external edits, then write atomically and refresh the cache.
1024
+ */
1025
+ export class Noggin {
1026
+ /**
1027
+ * @param {string} file Absolute path to the noggin YAML file.
1028
+ * @param {{ watch?: boolean }} [opts]
1029
+ */
1030
+ constructor(file, opts = {}) {
1031
+ if (!file) throw new NogginError('Noggin: file path required', { code: 'no-file', exitCode: 2 });
1032
+ this.file = file;
1033
+ /** @type {any} */
1034
+ this._store = emptyStore();
1035
+ /** @type {Set<() => void>} */
1036
+ this._changeListeners = new Set();
1037
+ /** @type {Set<(err: NogginError) => void>} */
1038
+ this._errorListeners = new Set();
1039
+ this._watcher = null;
1040
+ this._reloadTimer = null;
1041
+ this._disposed = false;
1042
+
1043
+ // Bind so they look like vscode.Event<T>: function-shaped subscribe.
1044
+ this.onDidChange = (handler) => {
1045
+ this._changeListeners.add(handler);
1046
+ return { dispose: () => this._changeListeners.delete(handler) };
1047
+ };
1048
+ this.onDidError = (handler) => {
1049
+ this._errorListeners.add(handler);
1050
+ return { dispose: () => this._errorListeners.delete(handler) };
1051
+ };
1052
+
1053
+ // Best-effort initial load. A bad file surfaces as onDidError but the
1054
+ // instance still works (the cache stays empty until reload succeeds).
1055
+ try { this._store = freezeStore(loadStore(file)); }
1056
+ catch (e) {
1057
+ if (e instanceof NogginError) this._fireError(e);
1058
+ else throw e;
1059
+ }
1060
+
1061
+ if (opts.watch) this._startWatch();
1062
+ }
1063
+
1064
+ // ── Read accessors ──────────────────────────────────────────────────
1065
+ get store() { return this._store; }
1066
+ get active() { return this._store.active ? findByKey(this._store.items, this._store.active) : null; }
1067
+ get roots() { return _childrenOf(this._store.items, null); }
1068
+
1069
+ findByKey(key) { return findByKey(this._store.items, key); }
1070
+ childrenOf(parentKey) { return _childrenOf(this._store.items, parentKey || null); }
1071
+ pathOf(item) { return _pathOf(this._store.items, item); }
1072
+ resolvePath(p) { return resolvePath(this._store, p); }
1073
+ tryResolvePath(p) { return tryResolvePath(this._store, p); }
1074
+
1075
+ /**
1076
+ * Build a CurrentTreeView. Target may be an item, a path string, or null
1077
+ * (defaults to the active item). Returns null if no target is found.
1078
+ */
1079
+ view(target, opts = {}) {
1080
+ let item = null;
1081
+ if (target == null) item = this.active;
1082
+ else if (typeof target === 'string') item = this.tryResolvePath(target);
1083
+ else item = target;
1084
+ if (!item) return null;
1085
+ return buildView(this._store, item, opts);
1086
+ }
1087
+
1088
+ // ── Lifecycle ───────────────────────────────────────────────────────
1089
+
1090
+ /** Reload from disk. Returns true if the cached store actually changed. */
1091
+ reload() {
1092
+ const prev = this._store;
1093
+ let next;
1094
+ try { next = loadStore(this.file); }
1095
+ catch (e) {
1096
+ if (e instanceof NogginError) { this._fireError(e); return false; }
1097
+ throw e;
1098
+ }
1099
+ if (storesEqual(prev, next)) return false;
1100
+ this._store = freezeStore(next);
1101
+ this._fireChange();
1102
+ return true;
1103
+ }
1104
+
1105
+ dispose() {
1106
+ if (this._disposed) return;
1107
+ this._disposed = true;
1108
+ if (this._reloadTimer) { clearTimeout(this._reloadTimer); this._reloadTimer = null; }
1109
+ if (this._watcher) { try { this._watcher.close(); } catch { /* ignore */ } this._watcher = null; }
1110
+ this._changeListeners.clear();
1111
+ this._errorListeners.clear();
1112
+ }
1113
+
1114
+ // ── Verbs ───────────────────────────────────────────────────────────
1115
+ push(opts) { return this._run(apiPush, opts); }
1116
+ add(opts) { return this._run(apiAdd, opts); }
1117
+ move(opts) { return this._run(apiMove, opts); }
1118
+ goto(p) { return this._run(apiGoto, { path: p }); }
1119
+ done(opts) { return this._run(apiDone, opts); }
1120
+ pop(opts) { return this._run(apiPop, opts || {}); }
1121
+ edit(opts) { return this._run(apiEdit, opts); }
1122
+ show(opts) { return this._runRead(apiShow, opts); }
1123
+ note(opts) { return this._run(apiNote, opts); }
1124
+ delete(opts) { return this._run(apiDelete, opts); }
1125
+ where() { return resolveFile({ file: this.file }); }
1126
+
1127
+ // ── Internals ───────────────────────────────────────────────────────
1128
+
1129
+ _run(fn, opts) {
1130
+ const result = fn(this.file, opts || {});
1131
+ // Refresh cache and notify listeners.
1132
+ try {
1133
+ const next = loadStore(this.file);
1134
+ this._store = freezeStore(next);
1135
+ this._fireChange();
1136
+ } catch (e) {
1137
+ if (e instanceof NogginError) this._fireError(e);
1138
+ else throw e;
1139
+ }
1140
+ return result;
1141
+ }
1142
+
1143
+ _runRead(fn, opts) {
1144
+ const result = fn(this.file, opts || {});
1145
+ // show with --goto mutates; refresh cache in that case.
1146
+ if (opts && opts.goto !== undefined) {
1147
+ try {
1148
+ this._store = freezeStore(loadStore(this.file));
1149
+ this._fireChange();
1150
+ } catch (e) {
1151
+ if (e instanceof NogginError) this._fireError(e);
1152
+ }
1153
+ }
1154
+ return result;
1155
+ }
1156
+
1157
+ _fireChange() {
1158
+ for (const h of this._changeListeners) {
1159
+ try { h(); } catch { /* listener errors don't propagate */ }
1160
+ }
1161
+ }
1162
+
1163
+ _fireError(err) {
1164
+ for (const h of this._errorListeners) {
1165
+ try { h(err); } catch { /* swallow */ }
1166
+ }
1167
+ }
1168
+
1169
+ _startWatch() {
1170
+ const dir = path.dirname(this.file);
1171
+ if (!fs.existsSync(dir)) return; // can't watch a nonexistent dir; bail
1172
+ try {
1173
+ this._watcher = fs.watch(dir, { persistent: false }, (_event, name) => {
1174
+ if (!name) { this._scheduleReload(); return; }
1175
+ if (path.basename(this.file) === name) this._scheduleReload();
1176
+ });
1177
+ } catch { /* watching is best-effort */ }
1178
+ }
1179
+
1180
+ _scheduleReload() {
1181
+ if (this._reloadTimer) clearTimeout(this._reloadTimer);
1182
+ this._reloadTimer = setTimeout(() => {
1183
+ this._reloadTimer = null;
1184
+ if (this._disposed) return;
1185
+ this.reload();
1186
+ }, 50);
1187
+ }
1188
+ }
1189
+
1190
+ /** Convenience constructor: opens a watched Noggin. */
1191
+ export function openNoggin(file) {
1192
+ return new Noggin(file, { watch: true });
1193
+ }
1194
+
1195
+ // ── Snapshot helpers ─────────────────────────────────────────────────────────
1196
+
1197
+ function storesEqual(a, b) {
1198
+ if (a === b) return true;
1199
+ if (!a || !b) return false;
1200
+ if (a.active !== b.active) return false;
1201
+ if (a.items.length !== b.items.length) return false;
1202
+ for (let i = 0; i < a.items.length; i++) {
1203
+ if (!itemsEqual(a.items[i], b.items[i])) return false;
1204
+ }
1205
+ return true;
1206
+ }
1207
+
1208
+ function itemsEqual(a, b) {
1209
+ if (a === b) return true;
1210
+ if (a.key !== b.key) return false;
1211
+ if (a.parentKey !== b.parentKey) return false;
1212
+ if (a.title !== b.title) return false;
1213
+ if (Boolean(a.done) !== Boolean(b.done)) return false;
1214
+ if (a.createdAt !== b.createdAt) return false;
1215
+ const an = a.notes || [];
1216
+ const bn = b.notes || [];
1217
+ if (an.length !== bn.length) return false;
1218
+ for (let i = 0; i < an.length; i++) {
1219
+ if (an[i].timestamp !== bn[i].timestamp) return false;
1220
+ if (an[i].text !== bn[i].text) return false;
1221
+ }
1222
+ return true;
1223
+ }
1224
+
1225
+ function freezeStore(store) {
1226
+ // Deep-freeze prevents consumers from mutating cached state. Items and
1227
+ // their notes arrays are frozen; the top-level object is the snapshot.
1228
+ for (const item of store.items) {
1229
+ for (const note of item.notes || []) Object.freeze(note);
1230
+ Object.freeze(item.notes);
1231
+ Object.freeze(item);
1232
+ }
1233
+ Object.freeze(store.items);
1234
+ Object.freeze(store);
1235
+ return store;
1236
+ }