noggin-cli 0.1.3 → 0.4.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 CHANGED
@@ -1,37 +1,41 @@
1
- // noggin-api — typed, in-process API for the noggin working-memory tree.
1
+ // noggin-api — typed, in-process engine for the noggin working-memory tree.
2
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.
3
+ // Three pillars:
4
+ // 1. `NogginDocument` and atomic ops (`AtomicOp`, `applyOps`) — the data
5
+ // model and the only way to mutate it.
6
+ // 2. `verbs.*` the user-facing verb behaviors (push, add, done, …)
7
+ // implemented exactly once. Each verb reads state via a `Noggin`,
8
+ // composes a list of `AtomicOp`s, and calls `noggin.apply(ops)`.
9
+ // 3. Factories + `openNoggin(location)` backends register a scheme
10
+ // prefix and an `open(location)` function. The engine never touches
11
+ // a file or any other storage; backends do.
11
12
  //
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.
13
+ // Backends only implement the `Noggin` interface (a handful of read
14
+ // accessors + `apply(ops)` + lifecycle + events). Verb semantics are
15
+ // not per-backend; they live in `verbs.*` and call the backend through
16
+ // the small `apply` primitive.
17
+ //
18
+ // Every failure throws a `NogginError` with a stable `code` and a
19
+ // CLI-style `exitCode`. Nothing in here writes to process.stderr or
20
+ // calls process.exit.
16
21
 
17
22
  /// <reference path="./noggin-api.d.mts" />
18
23
 
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
24
  import crypto from 'node:crypto';
24
25
 
25
26
  export const SCHEMA_VERSION = 1;
26
- export const DEFAULT_FILE = path.join(os.homedir(), '.noggin.yaml');
27
27
 
28
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.
29
+ * Version stamped onto every response envelope this module produces
30
+ * (via `formatSuccess` / `formatError`). Distinct from the on-disk
31
+ * document `SCHEMA_VERSION`; bump when the envelope shape, the
32
+ * `CurrentTreeView` shape, or any per-verb payload changes in a
33
+ * breaking way.
33
34
  */
34
- export const JSON_SCHEMA_VERSION = 2;
35
+ export const RESPONSE_ENVELOPE_VERSION = 3;
36
+
37
+ /** @deprecated Renamed to `RESPONSE_ENVELOPE_VERSION`. */
38
+ export const JSON_SCHEMA_VERSION = RESPONSE_ENVELOPE_VERSION;
35
39
 
36
40
  /**
37
41
  * Text of the system-generated note appended whenever an item transitions
@@ -67,8 +71,8 @@ function runtime(code, message) {
67
71
 
68
72
  // ── Low-level helpers ────────────────────────────────────────────────────────
69
73
 
70
- function nowIso() {
71
- return new Date().toISOString();
74
+ function nowIso(ctx) {
75
+ return ((ctx && ctx.now) || new Date()).toISOString();
72
76
  }
73
77
 
74
78
  function newKey() {
@@ -81,21 +85,29 @@ function newKey() {
81
85
  return `i-${slug}-${hex}`;
82
86
  }
83
87
 
84
- function emptyStore() {
88
+ function emptyDocument() {
85
89
  return { schemaVersion: SCHEMA_VERSION, active: null, items: [] };
86
90
  }
87
91
 
88
- function normalizeNote(note) {
92
+ /**
93
+ * Normalize a single note object. Exported for the serializers, which
94
+ * share this shape contract.
95
+ */
96
+ export function normalizeNote(note) {
89
97
  if (note && typeof note === 'object' && note.text !== undefined) {
90
98
  return { timestamp: note.timestamp ? String(note.timestamp) : null, text: String(note.text) };
91
99
  }
92
100
  usage('invalid-note', 'internal: invalid note object');
93
101
  }
94
102
 
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');
103
+ /**
104
+ * Normalize a parsed document in place: stamp schemaVersion, normalize
105
+ * notes, strip legacy fields. Used by serializers and by `applyOps`.
106
+ */
107
+ export function normalizeDocument(doc) {
108
+ doc.schemaVersion = SCHEMA_VERSION;
109
+ for (const f of doc.items) {
110
+ if (!Array.isArray(f.notes)) usage('invalid-document', 'invalid contents: item notes must be an array');
99
111
  f.notes = f.notes.map(normalizeNote);
100
112
  // closedAt and pushedAt were both dropped before noggin shipped.
101
113
  // Strip them on load so a dev's pre-rename test file doesn't carry
@@ -103,71 +115,48 @@ function normalizeStore(store) {
103
115
  if ('closedAt' in f) delete f.closedAt;
104
116
  if ('pushedAt' in f) delete f.pushedAt;
105
117
  }
106
- return store;
118
+ return doc;
107
119
  }
108
120
 
109
- function validateStore(store) {
121
+ /**
122
+ * Validate a document's structural invariants. Throws `NogginError`
123
+ * with code `'invalid-document'` if anything's wrong:
124
+ * - unique keys
125
+ * - parentKey references resolve
126
+ * - no cycles in the parent chain
127
+ * - active references an existing item (or is null)
128
+ */
129
+ export function validateDocument(doc) {
130
+ if (!doc || !Array.isArray(doc.items)) {
131
+ usage('invalid-document', 'invalid contents: expected items array');
132
+ }
110
133
  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');
134
+ for (const f of doc.items) {
135
+ if (!f.key) usage('invalid-document', 'internal: item missing key');
136
+ if (keys.has(f.key)) usage('invalid-document', 'internal: duplicate item key detected');
114
137
  keys.add(f.key);
115
138
  }
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');
139
+ for (const f of doc.items) {
140
+ if (f.parentKey != null && !keys.has(f.parentKey)) {
141
+ usage('invalid-document', `internal: item '${f.key}' has unknown parent reference '${f.parentKey}'`);
119
142
  }
120
143
  }
121
- if (store.active && !keys.has(store.active)) {
122
- usage('invalid-store', 'internal: active points to unknown item');
144
+ // Cycle check: walk parent chain from each item, bound by item count.
145
+ const limit = doc.items.length + 1;
146
+ for (const f of doc.items) {
147
+ let n = f;
148
+ let steps = 0;
149
+ while (n.parentKey != null) {
150
+ if (++steps > limit) {
151
+ usage('invalid-document', `internal: parent chain cycle detected at '${f.key}'`);
152
+ }
153
+ n = doc.items.find((x) => x.key === n.parentKey);
154
+ if (!n) break; // already caught above; defensive
155
+ }
123
156
  }
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
- );
157
+ if (doc.active != null && !keys.has(doc.active)) {
158
+ usage('invalid-document', `internal: active points to unknown item '${doc.active}'`);
148
159
  }
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
160
  }
172
161
 
173
162
  // ── Tree helpers ─────────────────────────────────────────────────────────────
@@ -558,111 +547,74 @@ function pruneDefaults(value) {
558
547
  }
559
548
 
560
549
  /**
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.
550
+ * Wrap a successful verb result in the canonical response envelope.
551
+ * Used by both the CLI `--json` flag and the VS Code extension's
552
+ * language-model tools so the two surfaces emit byte-identical shapes.
564
553
  *
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.
554
+ * The envelope itself (status, envelopeVersion, verb, data) is always
555
+ * fully present. `data` is run through `pruneDefaults` so whitelisted
556
+ * fields equal to their declared default are omitted.
568
557
  *
569
558
  * @param {object} opts
570
559
  * @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).
560
+ * @param {any} [opts.data] Verb-specific payload (e.g. CurrentTreeView).
573
561
  */
574
- export function formatSuccess({ verb, file, data } = {}) {
562
+ export function formatSuccess({ verb, data } = {}) {
575
563
  return {
576
564
  status: 'ok',
577
- schemaVersion: JSON_SCHEMA_VERSION,
565
+ envelopeVersion: RESPONSE_ENVELOPE_VERSION,
578
566
  verb: verb || null,
579
- file: file || null,
580
567
  data: data === undefined ? null : pruneDefaults(data),
581
568
  };
582
569
  }
583
570
 
584
571
  /**
585
- * Wrap an error in the canonical JSON envelope. Accepts a `NogginError`
586
- * (preserves its `code` and `exitCode`) or any other thrown value.
572
+ * Wrap an error in the canonical response envelope. Accepts a
573
+ * `NogginError` (preserves its `code` and `exitCode`) or any other
574
+ * thrown value.
587
575
  *
588
576
  * @param {object} opts
589
577
  * @param {string} [opts.verb]
590
- * @param {string|null} [opts.file]
591
578
  * @param {unknown} [opts.error]
592
579
  */
593
- export function formatError({ verb, file, error } = {}) {
580
+ export function formatError({ verb, error } = {}) {
594
581
  const isNoggin = error instanceof NogginError;
595
582
  const message = error instanceof Error ? error.message : String(error ?? 'unknown error');
596
583
  const code = isNoggin ? error.code : 'noggin-error';
597
584
  const exitCode = isNoggin ? error.exitCode : 1;
598
585
  return {
599
586
  status: 'error',
600
- schemaVersion: JSON_SCHEMA_VERSION,
587
+ envelopeVersion: RESPONSE_ENVELOPE_VERSION,
601
588
  verb: verb || null,
602
- file: file || null,
603
589
  error: { code, message, exitCode },
604
590
  };
605
591
  }
606
592
 
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
593
  // ── Internal verb helpers ────────────────────────────────────────────────────
631
594
 
632
- function applyGoto(store, base, goto, commandName) {
595
+ function executeGotoOption(snapshot, base, goto, commandName) {
633
596
  if (goto === undefined) return base;
634
597
  if (!base) runtime('goto-base-missing', `${commandName}: --goto has no base item`);
635
598
  const gotoPath = goto === true ? '.' : goto;
636
599
  if (!gotoPath) runtime('goto-path-required', `${commandName}: --goto requires a path`);
637
- const scopedStore = { ...store, active: base.key };
638
- const resolved = tryResolveDetailed(scopedStore, gotoPath);
600
+ const scopedDoc = { ...snapshot, active: base.key };
601
+ const resolved = tryResolveDetailed(scopedDoc, gotoPath);
639
602
  if (!resolved.ok) runtime('goto-unresolved', `${commandName}: --goto ${resolved.error}`);
640
- store.active = resolved.item.key;
641
603
  return resolved.item;
642
604
  }
643
605
 
644
- function makeItem({ title, parentKey }) {
606
+ function makeItem({ title, parentKey }, ctx) {
645
607
  return {
646
608
  key: newKey(),
647
- parentKey,
609
+ parentKey: parentKey ?? null,
648
610
  title,
649
611
  done: false,
650
- createdAt: nowIso(),
612
+ createdAt: nowIso(ctx),
651
613
  notes: [],
652
614
  };
653
615
  }
654
616
 
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) {
617
+ function resolvePlacement(snapshot, placement, commandName) {
666
618
  if (!placement) return null;
667
619
  const { kind, anchor } = placement;
668
620
  if (!kind || !anchor) {
@@ -671,229 +623,352 @@ function resolvePlacement(store, placement, commandName) {
671
623
  if (kind !== 'before' && kind !== 'after' && kind !== 'into') {
672
624
  usage('placement-invalid', `${commandName}: unknown placement kind '${kind}'`);
673
625
  }
674
- const anchorItem = resolvePath(store, anchor);
626
+ const anchorItem = resolvePath(snapshot, anchor);
675
627
  return { kind, anchor: anchorItem };
676
628
  }
677
629
 
678
- // ── Verb implementations ─────────────────────────────────────────────────────
630
+ /** Compute (parentKey, position) for a placement spec against the current snapshot. */
631
+ function placementToTarget(snapshot, placement) {
632
+ const { kind, anchor } = placement;
633
+ if (kind === 'into') {
634
+ return { parentKey: anchor.key, position: 'end' };
635
+ }
636
+ const siblings = _childrenOf(snapshot.items, anchor.parentKey ?? null);
637
+ const idx = siblings.findIndex((s) => s.key === anchor.key);
638
+ return {
639
+ parentKey: anchor.parentKey ?? null,
640
+ position: kind === 'before' ? idx : idx + 1,
641
+ };
642
+ }
643
+
644
+ /** Snapshot the noggin's live state into a doc-shaped {items, active} object. */
645
+ function nogginSnapshot(noggin) {
646
+ return {
647
+ items: noggin.items,
648
+ active: noggin.active ? noggin.active.key : null,
649
+ };
650
+ }
651
+
652
+ // ── Atomic ops ───────────────────────────────────────────────────────────────
679
653
 
680
654
  /**
681
- * push: create a child of active (or a root if none) and become active.
655
+ * Apply a list of `AtomicOp`s to a NogginDocument in-place, then
656
+ * validate the result. Throws `NogginError` if any op references a
657
+ * missing item or the resulting document violates invariants.
658
+ *
659
+ * Op vocabulary:
660
+ * { type: 'add', item, parentKey, position }
661
+ * { type: 'remove', keys }
662
+ * { type: 'set', key, patch: { title?, done? } }
663
+ * { type: 'note', key, note: { timestamp, text } }
664
+ * { type: 'move', key, parentKey, position }
665
+ * { type: 'setActive', key }
666
+ *
667
+ * `position` is the 0-based index among siblings of `parentKey`, or
668
+ * the string 'end' for append.
669
+ *
670
+ * This is the single mutation primitive every backend's `apply()`
671
+ * delegates to. Verbs build the op list; backends execute it.
682
672
  */
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);
673
+ export function applyOps(doc, ops) {
674
+ if (!Array.isArray(ops)) usage('invalid-op', 'applyOps: ops must be an array');
675
+ for (const op of ops) applyOp(doc, op);
676
+ validateDocument(doc);
677
+ return doc;
678
+ }
679
+
680
+ function applyOp(doc, op) {
681
+ if (!op || typeof op !== 'object') usage('invalid-op', 'applyOps: op must be an object');
682
+ switch (op.type) {
683
+ case 'add': return opAdd(doc, op);
684
+ case 'remove': return opRemove(doc, op);
685
+ case 'set': return opSet(doc, op);
686
+ case 'note': return opNote(doc, op);
687
+ case 'move': return opMove(doc, op);
688
+ case 'setActive': return opSetActive(doc, op);
689
+ default: usage('invalid-op', `applyOps: unknown op type '${op && op.type}'`);
690
+ }
693
691
  }
694
692
 
693
+ function insertAtPosition(items, item, parentKey, position) {
694
+ const pkey = parentKey ?? null;
695
+ if (position === 'end') {
696
+ items.push(item);
697
+ return;
698
+ }
699
+ if (typeof position !== 'number' || position < 0) {
700
+ usage('invalid-op', `add/move: invalid position ${JSON.stringify(position)}`);
701
+ }
702
+ const siblings = items.filter((i) => (i.parentKey ?? null) === pkey);
703
+ if (position >= siblings.length) {
704
+ if (siblings.length === 0) { items.push(item); return; }
705
+ const last = siblings[siblings.length - 1];
706
+ items.splice(items.indexOf(last) + 1, 0, item);
707
+ return;
708
+ }
709
+ const before = siblings[position];
710
+ items.splice(items.indexOf(before), 0, item);
711
+ }
712
+
713
+ function opAdd(doc, op) {
714
+ if (!op.item || !op.item.key) usage('invalid-op', 'add: op.item with key required');
715
+ if (doc.items.some((i) => i.key === op.item.key)) {
716
+ usage('invalid-op', `add: item with key '${op.item.key}' already exists`);
717
+ }
718
+ const item = {
719
+ key: op.item.key,
720
+ parentKey: op.parentKey ?? null,
721
+ title: op.item.title,
722
+ done: Boolean(op.item.done),
723
+ createdAt: op.item.createdAt,
724
+ notes: Array.isArray(op.item.notes) ? op.item.notes.map(normalizeNote) : [],
725
+ };
726
+ insertAtPosition(doc.items, item, op.parentKey, op.position);
727
+ }
728
+
729
+ function opRemove(doc, op) {
730
+ if (!Array.isArray(op.keys)) usage('invalid-op', 'remove: op.keys array required');
731
+ const removeSet = new Set(op.keys);
732
+ doc.items = doc.items.filter((i) => !removeSet.has(i.key));
733
+ }
734
+
735
+ function opSet(doc, op) {
736
+ if (!op.key) usage('invalid-op', 'set: op.key required');
737
+ const item = doc.items.find((i) => i.key === op.key);
738
+ if (!item) usage('invalid-op', `set: item with key '${op.key}' not found`);
739
+ if (!op.patch || typeof op.patch !== 'object') usage('invalid-op', 'set: op.patch object required');
740
+ if (op.patch.title !== undefined) item.title = op.patch.title;
741
+ if (op.patch.done !== undefined) item.done = Boolean(op.patch.done);
742
+ }
743
+
744
+ function opNote(doc, op) {
745
+ if (!op.key) usage('invalid-op', 'note: op.key required');
746
+ const item = doc.items.find((i) => i.key === op.key);
747
+ if (!item) usage('invalid-op', `note: item with key '${op.key}' not found`);
748
+ if (!op.note || op.note.text === undefined) usage('invalid-op', 'note: op.note.text required');
749
+ if (!Array.isArray(item.notes)) item.notes = [];
750
+ item.notes.push(normalizeNote(op.note));
751
+ }
752
+
753
+ function opMove(doc, op) {
754
+ if (!op.key) usage('invalid-op', 'move: op.key required');
755
+ const item = doc.items.find((i) => i.key === op.key);
756
+ if (!item) usage('invalid-op', `move: item with key '${op.key}' not found`);
757
+ const idx = doc.items.indexOf(item);
758
+ doc.items.splice(idx, 1);
759
+ item.parentKey = op.parentKey ?? null;
760
+ insertAtPosition(doc.items, item, op.parentKey, op.position);
761
+ }
762
+
763
+ function opSetActive(doc, op) {
764
+ doc.active = op.key ?? null;
765
+ }
766
+
767
+ /**
768
+ * Apply ops to a clone of the current state without persisting, so a
769
+ * verb can resolve a `--goto` path against the projected post-apply
770
+ * state before submitting the real apply.
771
+ */
772
+ function projectOps(noggin, ops) {
773
+ const doc = {
774
+ schemaVersion: SCHEMA_VERSION,
775
+ active: noggin.active ? noggin.active.key : null,
776
+ items: noggin.items.map((i) => ({
777
+ key: i.key,
778
+ parentKey: i.parentKey ?? null,
779
+ title: i.title,
780
+ done: Boolean(i.done),
781
+ createdAt: i.createdAt,
782
+ notes: (i.notes || []).map((n) => ({ timestamp: n.timestamp, text: n.text })),
783
+ })),
784
+ };
785
+ for (const op of ops) applyOp(doc, op);
786
+ return doc;
787
+ }
788
+
789
+ // ── Verbs ────────────────────────────────────────────────────────────────────
790
+
695
791
  /**
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.
792
+ * The single verb implementation, shared by every backend. Each verb
793
+ * takes a `Noggin`, reads state via its accessors, composes the
794
+ * appropriate `AtomicOp[]`, calls `noggin.apply(ops)` once, and returns
795
+ * a `CurrentTreeView` (or a `DeleteResult` for delete).
796
+ *
797
+ * Verb behavior contracts — push moves active; add does not unless
798
+ * --goto; done appends a close note and surfaces to parent; --force
799
+ * vs --close-all close semantics; cycle protection on move; etc. —
800
+ * live here. Backends do not implement verbs.
699
801
  */
700
- export function apiAdd(file, opts = {}) {
802
+ export const verbs = {
803
+ push: verbPush,
804
+ add: verbAdd,
805
+ move: verbMove,
806
+ goto: verbGoto,
807
+ done: verbDone,
808
+ pop: verbPop,
809
+ edit: verbEdit,
810
+ show: verbShow,
811
+ note: verbNote,
812
+ delete: verbDelete,
813
+ copy: verbCopy,
814
+ };
815
+
816
+ /** push: create a child of active (or a root if none) and become active. */
817
+ async function verbPush(noggin, opts, ctx) {
818
+ const title = (opts && opts.title || '').toString().trim();
819
+ if (!title) usage('title-required', 'push: title required (--title or positional)');
820
+ const active = noggin.active;
821
+ const item = makeItem({ title, parentKey: active ? active.key : null }, ctx);
822
+ const ops = [
823
+ { type: 'add', item, parentKey: active ? active.key : null, position: 'end' },
824
+ { type: 'setActive', key: item.key },
825
+ ];
826
+ await noggin.apply(ops);
827
+ return buildView(nogginSnapshot(noggin), noggin.findByKey(item.key), {});
828
+ }
829
+
830
+ /** add: capture an item without making it active (unless --goto). */
831
+ async function verbAdd(noggin, opts = {}, ctx) {
701
832
  const title = (opts.title || '').toString().trim();
702
833
  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');
834
+ const snap = nogginSnapshot(noggin);
835
+ const active = noggin.active;
836
+ const placement = resolvePlacement(snap, opts.placement, 'add');
706
837
 
707
- let parentKey;
708
- let insertIndex;
838
+ let parentKey, position;
709
839
  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
- }
840
+ ({ parentKey, position } = placementToTarget(snap, placement));
719
841
  } else {
720
- parentKey = activeItem ? activeItem.key : null;
721
- insertIndex = store.items.length;
842
+ parentKey = active ? active.key : null;
843
+ position = 'end';
844
+ }
845
+
846
+ const item = makeItem({ title, parentKey }, ctx);
847
+ const ops = [{ type: 'add', item, parentKey, position }];
848
+
849
+ let viewTargetKey = item.key;
850
+ if (opts.goto !== undefined) {
851
+ const projected = projectOps(noggin, ops);
852
+ const projectedNew = findByKey(projected.items, item.key);
853
+ const target = executeGotoOption(projected, projectedNew, opts.goto, 'add');
854
+ ops.push({ type: 'setActive', key: target.key });
855
+ viewTargetKey = target.key;
722
856
  }
723
857
 
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);
858
+ await noggin.apply(ops);
859
+ return buildView(nogginSnapshot(noggin), noggin.findByKey(viewTargetKey), {});
729
860
  }
730
861
 
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');
862
+ /** move: relocate an item. Required placement; cycle-checked. */
863
+ async function verbMove(noggin, opts = {}) {
864
+ const snap = nogginSnapshot(noggin);
865
+ const placement = resolvePlacement(snap, opts.placement, 'move');
738
866
  if (!placement) usage('placement-missing', 'move: choose exactly one of --before, --after, or --into');
739
867
  const { kind, anchor } = placement;
740
868
 
741
869
  let target;
742
- if (opts.path) target = resolvePath(store, opts.path);
870
+ if (opts.path) target = noggin.resolvePath(opts.path);
743
871
  else {
744
- target = findByKey(store.items, store.active);
872
+ target = noggin.active;
745
873
  if (!target) runtime('no-active-item', 'move: no active item; pass a path');
746
874
  }
747
875
 
876
+ // Cycle checks.
748
877
  if (kind === 'into') {
749
878
  if (target.key === anchor.key) {
750
- runtime('cycle', `move: cannot move ${_pathOf(store.items, target)} into itself (would create a cycle)`);
879
+ runtime('cycle', `move: cannot move ${noggin.pathOf(target)} into itself (would create a cycle)`);
751
880
  }
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)`);
881
+ if (isDescendant(noggin.items, anchor, target)) {
882
+ runtime('cycle', `move: cannot move ${noggin.pathOf(target)} into its own subtree (would create a cycle)`);
754
883
  }
755
884
  } 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);
885
+ if (isDescendant(noggin.items, anchor, target)) {
886
+ runtime('cycle', `move: cannot move ${noggin.pathOf(target)} next to its own descendant (would create a cycle)`);
765
887
  }
766
888
  }
767
889
 
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;
890
+ // Compute new parentKey + position. For before/after we exclude the
891
+ // target itself from the sibling list so its current position doesn't
892
+ // shift the anchor index.
893
+ let parentKey, position;
773
894
  if (kind === 'into') {
774
- insertIndex = store.items.length;
895
+ parentKey = anchor.key;
896
+ position = 'end';
897
+ } else if (anchor.key === target.key) {
898
+ // before/after self → silent no-op. Submit a redundant move op
899
+ // anyway so the verb still goes through the normal apply path
900
+ // (keeps event semantics consistent).
901
+ parentKey = target.parentKey ?? null;
902
+ const siblings = _childrenOf(noggin.items, parentKey);
903
+ position = siblings.findIndex((s) => s.key === target.key);
775
904
  } else {
776
- const anchorIdx = store.items.indexOf(anchor);
777
- insertIndex = kind === 'before' ? anchorIdx : anchorIdx + 1;
905
+ parentKey = anchor.parentKey ?? null;
906
+ const siblings = _childrenOf(noggin.items, parentKey).filter((s) => s.key !== target.key);
907
+ const anchorIdx = siblings.findIndex((s) => s.key === anchor.key);
908
+ position = kind === 'before' ? anchorIdx : anchorIdx + 1;
778
909
  }
779
910
 
780
- target.parentKey = newParentKey;
781
- store.items.splice(insertIndex, 0, target);
911
+ const ops = [{ type: 'move', key: target.key, parentKey, position }];
782
912
 
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
- }
913
+ let viewTargetKey;
914
+ if (opts.goto !== undefined) {
915
+ const projected = projectOps(noggin, ops);
916
+ const projectedTarget = findByKey(projected.items, target.key);
917
+ const gotoTarget = executeGotoOption(projected, projectedTarget, opts.goto, 'move');
918
+ ops.push({ type: 'setActive', key: gotoTarget.key });
919
+ viewTargetKey = gotoTarget.key;
920
+ } else {
921
+ // Default view target: active (preserved by key) if still exists, else the moved target.
922
+ viewTargetKey = noggin.active ? noggin.active.key : target.key;
923
+ }
788
924
 
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);
925
+ await noggin.apply(ops);
926
+ return buildView(nogginSnapshot(noggin), noggin.findByKey(viewTargetKey), {});
797
927
  }
798
928
 
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
- }
929
+ /** goto: make `path` active. */
930
+ async function verbGoto(noggin, opts = {}) {
931
+ if (!opts.path) usage('path-required', 'goto: path required');
932
+ const target = noggin.resolvePath(opts.path);
933
+ const ops = [{ type: 'setActive', key: target.key }];
934
+ await noggin.apply(ops);
935
+ return buildView(nogginSnapshot(noggin), noggin.findByKey(target.key), {});
838
936
  }
839
937
 
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 = {}) {
938
+ /** done: mark target done, then surface active to parent. Idempotent. */
939
+ async function verbDone(noggin, opts = {}, ctx) {
852
940
  if (opts.goto !== undefined) usage('goto-unsupported', 'done: --goto is not supported; done always moves to the target parent');
853
- const store = loadStore(file);
941
+
854
942
  let target;
855
- if (opts.path) target = resolvePath(store, opts.path);
943
+ if (opts.path) target = noggin.resolvePath(opts.path);
856
944
  else {
857
- target = findByKey(store.items, store.active);
945
+ target = noggin.active;
858
946
  if (!target) runtime('no-active-item', 'done: no active item; pass a path');
859
947
  }
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);
948
+
949
+ const closeOps = buildCloseOps(noggin, target, opts, 'done', ctx);
950
+ const parentKey = target.parentKey ?? null;
951
+ const ops = [...closeOps, { type: 'setActive', key: parentKey }];
952
+
953
+ await noggin.apply(ops);
954
+ const newActive = noggin.active;
955
+ const viewTarget = newActive || noggin.findByKey(target.key);
956
+ return buildView(nogginSnapshot(noggin), viewTarget, {});
865
957
  }
866
958
 
867
- /** pop: shorthand for done() on the active item. Honors --force / --closeall. */
868
- export function apiPop(file, opts = {}) {
959
+ /** pop: done on active, no path argument. */
960
+ async function verbPop(noggin, opts = {}, ctx) {
869
961
  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, {
962
+ if (opts && opts.goto !== undefined) usage('goto-unsupported', "pop: --goto is not supported; pop always moves to the active item's parent");
963
+ if (!noggin.active) runtime('no-active-item', 'pop: no active item');
964
+ return verbDone(noggin, {
874
965
  force: opts.force === true,
875
966
  closeAll: opts.closeAll === true,
876
- });
967
+ }, ctx);
877
968
  }
878
969
 
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 = {}) {
970
+ /** edit: idempotent mutation of state and/or title. */
971
+ async function verbEdit(noggin, opts = {}, ctx) {
897
972
  const hasState = typeof opts.done === 'boolean';
898
973
  const rawTitle = opts.title;
899
974
  const hasTitle = typeof rawTitle === 'string' && rawTitle.trim() !== '';
@@ -908,293 +983,335 @@ export function apiEdit(file, opts = {}) {
908
983
  usage('option-misused', 'edit: --close-all only applies when closing (with --done)');
909
984
  }
910
985
 
911
- const store = loadStore(file);
912
986
  let target;
913
- if (opts.path) target = resolvePath(store, opts.path);
987
+ if (opts.path) target = noggin.resolvePath(opts.path);
914
988
  else {
915
- target = findByKey(store.items, store.active);
989
+ target = noggin.active;
916
990
  if (!target) runtime('no-active-item', 'edit: no active item; pass a path');
917
991
  }
918
992
 
993
+ const ops = [];
994
+
919
995
  if (hasState) {
920
996
  if (opts.done) {
921
- closeWithRules(store, target, opts, 'edit');
997
+ ops.push(...buildCloseOps(noggin, target, opts, 'edit', ctx));
922
998
  } else if (target.done) {
923
- target.done = false;
999
+ ops.push({ type: 'set', key: target.key, patch: { done: false } });
924
1000
  }
925
1001
  }
926
1002
 
927
1003
  if (hasTitle) {
928
1004
  const next = rawTitle.toString().trim();
929
- if (target.title !== next) target.title = next;
1005
+ if (target.title !== next) {
1006
+ ops.push({ type: 'set', key: target.key, patch: { title: next } });
1007
+ }
930
1008
  }
931
1009
 
932
- const outputTarget = opts.goto !== undefined ? applyGoto(store, target, opts.goto, 'edit') : target;
933
- saveStore(file, store);
934
- return buildView(store, outputTarget);
1010
+ let viewTargetKey = target.key;
1011
+ if (opts.goto !== undefined) {
1012
+ const projected = projectOps(noggin, ops);
1013
+ const projectedTarget = findByKey(projected.items, target.key);
1014
+ const gotoTarget = executeGotoOption(projected, projectedTarget, opts.goto, 'edit');
1015
+ ops.push({ type: 'setActive', key: gotoTarget.key });
1016
+ viewTargetKey = gotoTarget.key;
1017
+ }
1018
+
1019
+ if (ops.length > 0) await noggin.apply(ops);
1020
+ return buildView(nogginSnapshot(noggin), noggin.findByKey(viewTargetKey), {});
935
1021
  }
936
1022
 
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);
1023
+ /** show: read-only view; --goto activates after. */
1024
+ async function verbShow(noggin, opts = {}) {
1025
+ const target = opts.path ? noggin.resolvePath(opts.path) : noggin.active;
946
1026
  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, {
1027
+
1028
+ let viewTargetKey = target.key;
1029
+ if (opts.goto !== undefined) {
1030
+ const gotoTarget = executeGotoOption(nogginSnapshot(noggin), target, opts.goto, 'show');
1031
+ if (gotoTarget.key !== (noggin.active ? noggin.active.key : null)) {
1032
+ await noggin.apply([{ type: 'setActive', key: gotoTarget.key }]);
1033
+ }
1034
+ viewTargetKey = gotoTarget.key;
1035
+ }
1036
+
1037
+ return buildView(nogginSnapshot(noggin), noggin.findByKey(viewTargetKey), {
950
1038
  includeChildren: opts.includeChildren !== false,
951
1039
  withSiblings: opts.withSiblings === true,
952
1040
  withDescendants: opts.withDescendants === true,
953
1041
  });
954
1042
  }
955
1043
 
956
- /** note: append a timestamped note. Path defaults to active. */
957
- export function apiNote(file, opts = {}) {
1044
+ /** note: append a timestamped note. */
1045
+ async function verbNote(noggin, opts = {}, ctx) {
958
1046
  const text = (opts.text || '').toString().trim();
959
1047
  if (!text) usage('text-required', 'note: text required');
960
- const store = loadStore(file);
1048
+
961
1049
  let target;
962
- if (opts.path) target = resolvePath(store, opts.path);
1050
+ if (opts.path) target = noggin.resolvePath(opts.path);
963
1051
  else {
964
- target = findByKey(store.items, store.active);
1052
+ target = noggin.active;
965
1053
  if (!target) runtime('no-active-item', 'note: no active item and no path given');
966
1054
  }
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);
1055
+
1056
+ const ops = [{
1057
+ type: 'note',
1058
+ key: target.key,
1059
+ note: { timestamp: nowIso(ctx), text },
1060
+ }];
1061
+
1062
+ let viewTargetKey = target.key;
1063
+ if (opts.goto !== undefined) {
1064
+ const projected = projectOps(noggin, ops);
1065
+ const projectedTarget = findByKey(projected.items, target.key);
1066
+ const gotoTarget = executeGotoOption(projected, projectedTarget, opts.goto, 'note');
1067
+ ops.push({ type: 'setActive', key: gotoTarget.key });
1068
+ viewTargetKey = gotoTarget.key;
1069
+ }
1070
+
1071
+ await noggin.apply(ops);
1072
+ return buildView(nogginSnapshot(noggin), noggin.findByKey(viewTargetKey), {});
972
1073
  }
973
1074
 
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 = {}) {
1075
+ /** delete: remove item; --recursive for subtree. */
1076
+ async function verbDelete(noggin, opts = {}) {
980
1077
  if (opts.goto !== undefined) usage('goto-unsupported', 'delete: --goto is not supported');
981
1078
  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);
1079
+ const target = noggin.resolvePath(opts.path);
985
1080
  const targetKey = target.key;
1081
+ const targetPath = noggin.pathOf(target);
986
1082
  const targetTitle = target.title;
987
- const descendants = collectDescendants(store.items, target);
1083
+ const descendants = collectDescendants(noggin.items, target);
1084
+
988
1085
  if (descendants.length > 0 && opts.recursive !== true) {
989
1086
  runtime(
990
1087
  'has-descendants',
991
- `delete: ${targetPath} has ${descendants.length} descendant(s); ` +
992
- `pass --recursive to delete the whole subtree`,
1088
+ `delete: ${targetPath} has ${descendants.length} descendant(s); pass --recursive to delete the whole subtree`,
993
1089
  );
994
1090
  }
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));
1091
+
1092
+ const removeKeys = [target.key, ...descendants.map((d) => d.key)];
1093
+ const ops = [{ type: 'remove', keys: removeKeys }];
1094
+
1095
+ const activeWasRemoved = noggin.active != null && removeKeys.includes(noggin.active.key);
998
1096
  if (activeWasRemoved) {
999
- store.active = target.parentKey || null;
1097
+ ops.push({ type: 'setActive', key: target.parentKey ?? null });
1000
1098
  }
1001
- saveStore(file, store);
1002
- const newActive = findByKey(store.items, store.active);
1099
+
1100
+ await noggin.apply(ops);
1101
+
1102
+ const newActive = noggin.active;
1003
1103
  return {
1004
1104
  deleted: { key: targetKey, path: targetPath, title: targetTitle },
1005
1105
  descendantCount: descendants.length,
1006
- view: newActive ? buildView(store, newActive) : null,
1106
+ view: newActive ? buildView(nogginSnapshot(noggin), newActive, {}) : null,
1007
1107
  };
1008
1108
  }
1009
1109
 
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
1110
  /**
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).
1111
+ * copy: append every item from `source` into `dest`, preserving tree
1112
+ * structure but generating fresh keys.
1021
1113
  *
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.
1114
+ * v1 semantics (intentionally narrow; extension points reserved for
1115
+ * future versions):
1116
+ * - whole-noggin copy: every source item is copied; source paths/keys
1117
+ * are not selectable
1118
+ * - append-only: source roots become new roots at the end of dest's
1119
+ * root list, never overwriting existing dest content
1120
+ * - active is not transferred: dest's active pointer is unchanged
1121
+ * - notes (including system "closed" notes), `done`, and `createdAt`
1122
+ * are preserved verbatim — a copied item looks like the original
1123
+ * work, just under a different location
1124
+ * - same-noggin copy (source === dest) is supported: the entire
1125
+ * tree gets duplicated at the root with fresh keys
1126
+ *
1127
+ * Returns `{ copied, mapping }` where `mapping` is a `{oldKey: newKey}`
1128
+ * dictionary the caller can use to find the dest counterpart of any
1129
+ * source item.
1024
1130
  */
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
- };
1131
+ async function verbCopy(source, dest, opts = {}, ctx) {
1132
+ if (!source || typeof source.apply !== 'function') usage('source-required', 'copy: source noggin required');
1133
+ if (!dest || typeof dest.apply !== 'function') usage('dest-required', 'copy: dest noggin required');
1134
+
1135
+ // Snapshot the source up-front. If source === dest, this freezes the
1136
+ // view we're copying from before any add ops mutate dest.
1137
+ const srcItems = source.items.map((it) => ({
1138
+ key: it.key,
1139
+ parentKey: it.parentKey ?? null,
1140
+ title: it.title,
1141
+ done: Boolean(it.done),
1142
+ createdAt: it.createdAt,
1143
+ notes: (it.notes || []).map((n) => ({ timestamp: n.timestamp, text: n.text })),
1144
+ }));
1145
+
1146
+ if (srcItems.length === 0) {
1147
+ return { copied: 0, mapping: {} };
1148
+ }
1052
1149
 
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;
1150
+ // Walk source as a tree (roots first, depth-first) so every parent
1151
+ // appears in the op list before its children. The flat items array
1152
+ // isn't guaranteed to be in topo order; the recursive walk is.
1153
+ const childrenByParent = new Map();
1154
+ for (const it of srcItems) {
1155
+ const parent = it.parentKey ?? null;
1156
+ if (!childrenByParent.has(parent)) childrenByParent.set(parent, []);
1157
+ childrenByParent.get(parent).push(it);
1158
+ }
1159
+ const ordered = [];
1160
+ function walk(parentKey) {
1161
+ const kids = childrenByParent.get(parentKey) || [];
1162
+ for (const kid of kids) {
1163
+ ordered.push(kid);
1164
+ walk(kid.key);
1059
1165
  }
1060
-
1061
- if (opts.watch) this._startWatch();
1062
1166
  }
1167
+ walk(null);
1168
+
1169
+ // Allocate new keys for every source item.
1170
+ const mapping = Object.create(null);
1171
+ for (const it of ordered) mapping[it.key] = newKey();
1172
+
1173
+ // Build add ops in topo order. Source roots (parentKey === null)
1174
+ // become new roots in dest at position 'end' — appended after any
1175
+ // existing dest content. Every other item lands under its (already
1176
+ // added) parent.
1177
+ const ops = ordered.map((it) => {
1178
+ const newParentKey = it.parentKey ? mapping[it.parentKey] : null;
1179
+ return {
1180
+ type: 'add',
1181
+ item: {
1182
+ key: mapping[it.key],
1183
+ parentKey: newParentKey,
1184
+ title: it.title,
1185
+ done: it.done,
1186
+ createdAt: it.createdAt,
1187
+ notes: it.notes,
1188
+ },
1189
+ parentKey: newParentKey,
1190
+ position: 'end',
1191
+ };
1192
+ });
1063
1193
 
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); }
1194
+ await dest.apply(ops);
1068
1195
 
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); }
1196
+ return { copied: ordered.length, mapping };
1197
+ }
1074
1198
 
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);
1199
+ /**
1200
+ * Build the op list for closing `target`, enforcing the open-descendant
1201
+ * rule unless `force` or `closeAll` opts it out. Shared by `done`,
1202
+ * `pop` (via done), and `edit --done`. Does NOT include the setActive
1203
+ * op callers add that if their verb surfaces active.
1204
+ */
1205
+ function buildCloseOps(noggin, target, opts, verb, ctx) {
1206
+ const force = opts.force === true;
1207
+ const closeAll = opts.closeAll === true;
1208
+ const ops = [];
1209
+ const ts = nowIso(ctx);
1210
+
1211
+ if (closeAll) {
1212
+ for (const d of collectDescendants(noggin.items, target)) {
1213
+ if (!d.done) {
1214
+ ops.push({ type: 'set', key: d.key, patch: { done: true } });
1215
+ ops.push({ type: 'note', key: d.key, note: { timestamp: ts, text: CLOSE_NOTE_TEXT } });
1152
1216
  }
1153
1217
  }
1154
- return result;
1155
1218
  }
1156
-
1157
- _fireChange() {
1158
- for (const h of this._changeListeners) {
1159
- try { h(); } catch { /* listener errors don't propagate */ }
1219
+ if (!force && !closeAll) {
1220
+ const open = countOpenDescendants(noggin.items, target);
1221
+ if (open > 0) {
1222
+ runtime(
1223
+ 'open-descendants',
1224
+ `${verb}: ${noggin.pathOf(target)} has ${open} open descendant(s); ` +
1225
+ `pass --closeall to close them too, or --force to close ${target.title} anyway`,
1226
+ );
1160
1227
  }
1161
1228
  }
1162
-
1163
- _fireError(err) {
1164
- for (const h of this._errorListeners) {
1165
- try { h(err); } catch { /* swallow */ }
1166
- }
1229
+ if (!target.done) {
1230
+ ops.push({ type: 'set', key: target.key, patch: { done: true } });
1231
+ ops.push({ type: 'note', key: target.key, note: { timestamp: ts, text: CLOSE_NOTE_TEXT } });
1167
1232
  }
1233
+ return ops;
1234
+ }
1168
1235
 
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
- }
1236
+ // ── Factory registry ─────────────────────────────────────────────────────────
1179
1237
 
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
- }
1238
+ function createRegistry() {
1239
+ /** @type {Map<string, any>} */
1240
+ const byScheme = new Map();
1241
+ /** @type {string|null} */
1242
+ let defaultScheme = null;
1243
+ return {
1244
+ register(factory, opts = {}) {
1245
+ if (!factory || typeof factory.scheme !== 'string' || !factory.scheme) {
1246
+ throw new TypeError('factories.register: factory.scheme (non-empty string) required');
1247
+ }
1248
+ if (typeof factory.open !== 'function') {
1249
+ throw new TypeError('factories.register: factory.open function required');
1250
+ }
1251
+ byScheme.set(factory.scheme, factory);
1252
+ if (opts.default) defaultScheme = factory.scheme;
1253
+ },
1254
+ unregister(scheme) {
1255
+ const had = byScheme.delete(scheme);
1256
+ if (defaultScheme === scheme) defaultScheme = null;
1257
+ return had;
1258
+ },
1259
+ get(scheme) { return byScheme.get(scheme) || null; },
1260
+ getDefault() {
1261
+ return defaultScheme ? byScheme.get(defaultScheme) || null : null;
1262
+ },
1263
+ list() {
1264
+ return Array.from(byScheme.values()).map((f) => ({
1265
+ scheme: f.scheme,
1266
+ default: f.scheme === defaultScheme,
1267
+ }));
1268
+ },
1269
+ };
1188
1270
  }
1189
1271
 
1190
- /** Convenience constructor: opens a watched Noggin. */
1191
- export function openNoggin(file) {
1192
- return new Noggin(file, { watch: true });
1272
+ /**
1273
+ * The process-wide noggin factory registry. Backends call
1274
+ * `factories.register({scheme, open})` (typically on import side-effect).
1275
+ * `openNoggin(location)` consults this registry to pick a factory by
1276
+ * scheme prefix; bare locations go to whichever factory was registered
1277
+ * with `{default: true}`.
1278
+ */
1279
+ export const factories = createRegistry();
1280
+
1281
+ function parseLocation(s) {
1282
+ const m = String(s == null ? '' : s).match(/^([a-z][a-z0-9+.-]*):\/\/(.*)$/i);
1283
+ return m ? { scheme: m[1].toLowerCase(), rest: m[2] } : { scheme: null, rest: String(s == null ? '' : s) };
1193
1284
  }
1194
1285
 
1195
- // ── Snapshot helpers ─────────────────────────────────────────────────────────
1286
+ /**
1287
+ * Open a noggin by location. The scheme prefix (e.g. `file://`,
1288
+ * `localstorage://`) selects the factory; a bare location goes to
1289
+ * the default factory.
1290
+ *
1291
+ * @param {string} location
1292
+ * @param {object} [opts] Forwarded to the factory.
1293
+ * @returns {Promise<any>}
1294
+ */
1295
+ export async function openNoggin(location, opts) {
1296
+ if (!location) {
1297
+ throw new NogginError('openNoggin: location required', { code: 'no-location', exitCode: 2 });
1298
+ }
1299
+ const { scheme, rest } = parseLocation(location);
1300
+ const factory = scheme ? factories.get(scheme) : factories.getDefault();
1301
+ if (!factory) {
1302
+ if (scheme) usage('no-factory', `no factory registered for scheme '${scheme}://'`);
1303
+ usage('no-factory', `no default factory registered; cannot open '${location}'`);
1304
+ }
1305
+ // Forward the original location so backends can preserve it for
1306
+ // round-trippable `where` output. Backends still receive `rest` (the
1307
+ // post-scheme portion) as the resolution input.
1308
+ return factory.open(rest, { ...opts, location });
1309
+ }
1196
1310
 
1197
- function storesEqual(a, b) {
1311
+ // ── Snapshot helpers (used by backends) ──────────────────────────────────────
1312
+
1313
+ /** Structural equality between two NogginDocuments. */
1314
+ export function documentsEqual(a, b) {
1198
1315
  if (a === b) return true;
1199
1316
  if (!a || !b) return false;
1200
1317
  if (a.active !== b.active) return false;
@@ -1222,15 +1339,18 @@ function itemsEqual(a, b) {
1222
1339
  return true;
1223
1340
  }
1224
1341
 
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) {
1342
+ /**
1343
+ * Deep-freeze a noggin document. Backends call this on the in-memory
1344
+ * cache that's exposed via accessors so consumers can't accidentally
1345
+ * mutate it.
1346
+ */
1347
+ export function freezeDocument(doc) {
1348
+ for (const item of doc.items) {
1229
1349
  for (const note of item.notes || []) Object.freeze(note);
1230
1350
  Object.freeze(item.notes);
1231
1351
  Object.freeze(item);
1232
1352
  }
1233
- Object.freeze(store.items);
1234
- Object.freeze(store);
1235
- return store;
1353
+ Object.freeze(doc.items);
1354
+ Object.freeze(doc);
1355
+ return doc;
1236
1356
  }