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/README.md +389 -0
- package/SKILL.md +153 -0
- package/noggin-api.d.mts +317 -0
- package/noggin-api.mjs +1236 -0
- package/noggin-mcp.mjs +270 -0
- package/noggin.mjs +482 -0
- package/package.json +48 -0
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
|
+
}
|