@stravica-ai/digital-operator-pa 0.1.68 → 0.1.69

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.
@@ -199,6 +199,37 @@ Live reload was temporarily removed pending a workspace rebuild. Refresh the bro
199
199
  - **Writes.** The PA writes the board only with `pa status set | patch | show` (validated, atomic temp-then-rename). Bare `pa status` is still the environment probe.
200
200
  - **Behaviour.** Refreshes every 60 seconds and when the tab comes back into view, without reloading (scroll position and the "Done today" open state survive). The freshness pill turns amber at 45 minutes and red at 90 since `updatedAt`. A bad file (unparseable or off-schema) keeps the last good copy on screen behind a red banner. No board yet shows a plain "No status board yet today" card and keeps checking.
201
201
 
202
+ ## Notes page (notes API)
203
+
204
+ `app/notes.html` is the core **Notes** page. It lists every note the PA and the operator keep, newest first, and opens each one in the viewer. The home page notes rail shows the first eight of the same list.
205
+
206
+ - **Roots.** `GET /__api/notes` walks these notes roots, in order:
207
+ 1. `operator/notes`, the instance's canonical notes directory (the scaffold creates it). Always first.
208
+ 2. `agents/<paName>/workspace/notes`, where the running PA writes its session notes beside its status board. `paName` resolves the same way the Status page does.
209
+ 3. Each repo-relative path in the preferences key `notesRoots` (an array of strings in `operator/config/preferences.json` or the local overlay), in the order given. Example: `"notesRoots": ["agents/<other-agent>/workspace/notes", "shared/notes"]`.
210
+
211
+ With no `notesRoots` key the first two roots are walked; that is the default. Discovery covers the resolved PA only, not every `agents/*` folder, so a second agent's notes need a `notesRoots` entry. A configured root that is not a non-empty string, names the repo root itself or resolves outside the repo (by path or through a symlink) is refused: the server logs a warning and never walks it. A root named twice is walked once, and a note under a root nested inside an earlier root is listed once, under the earlier root.
212
+ - **What counts as a note.** In every root: files ending `.md` or `.html` (the viewer renders `.md` and serves `.html` as is), at any depth, except under a top-level `assets/` folder of that root. Nothing inside a file is read. The title is the filename without its extension and without one leading `discuss-`, `research-`, `draft-`, `spec-`, `pickup-` or `marketing-` prefix, hyphens as spaces, each word capitalised. A same-basename `.md` and `.html` pair in one folder lists as two notes; the page tags the `.html` one.
213
+ - **Response.**
214
+
215
+ ```json
216
+ {
217
+ "notes": [
218
+ { "name": "x.md", "title": "X", "path": "operator/notes/topic/x.md",
219
+ "root": "operator/notes", "folder": "topic", "modified": "2026-01-02T03:04:05.000Z" }
220
+ ],
221
+ "notesDir": "present",
222
+ "notesDirPath": "operator/notes",
223
+ "roots": [
224
+ { "path": "operator/notes", "state": "present", "count": 1 },
225
+ { "path": "agents/<paName>/workspace/notes", "state": "missing", "count": 0 }
226
+ ]
227
+ }
228
+ ```
229
+
230
+ `notes` is one list, newest `modified` (file mtime) first across all roots, ties broken by `path` ascending. `path` is repo-relative, the value `viewer.html?file=` takes. `root` is the note's repo-relative root and `folder` is relative to that root (`''` at its top level). `notesDir` (`missing`, `empty` or `present`) and `notesDirPath` describe `operator/notes` alone. `roots` has one entry per root walked, with its own state and the count of notes listed under it. A failure is a 500 with `{ "error": "..." }`.
231
+ - **Page.** Each row shows its root under the title when more than one root is walked, and a chip per root (with its count) filters the list. Search matches the root, the folder, the title and, for fetched notes, the first lines of the content. With no notes at all, the empty state names every root walked and its state.
232
+
202
233
  ## Tests
203
234
 
204
235
  ```bash
@@ -688,7 +688,7 @@
688
688
  var tagMatch = /^([a-z]+)-/i.exec(name);
689
689
  var tag = tagMatch ? tagMatch[1] : 'note';
690
690
  var age = fmtRelativeDate(n.modified);
691
- var summary = name.replace(/\.md$/, '').replace(/^[a-z]+-/i, '').replace(/-/g, ' ');
691
+ var summary = name.replace(/\.(md|html)$/, '').replace(/^[a-z]+-/i, '').replace(/-/g, ' ');
692
692
  return (
693
693
  '<a class="card note-card" onclick="nav(\'/viewer.html?file=' + encodeURIComponent(n.path) + '\')">' +
694
694
  '<div class="id">' + esc((age ? age + ' · ' : '') + tag) + '</div>' +
@@ -37,6 +37,14 @@
37
37
  .note-snippet { font-size: 0.7rem; color: var(--text-dim); margin-top: 0.1rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 400px; }
38
38
  .note-snippet mark { background: rgba(122,162,247,0.15); color: var(--accent); border-radius: 2px; }
39
39
  .note-meta { color: var(--text-dim); font-size: 0.68rem; white-space: nowrap; margin-left: 0.75rem; flex-shrink: 0; }
40
+ .note-root { font-size: 0.68rem; color: var(--text-dim); margin-top: 0.1rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 400px; }
41
+
42
+ /* Root filter: one chip per notes root walked */
43
+ .root-filter { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-bottom: 0.75rem; }
44
+ .root-filter[hidden] { display: none; }
45
+ .root-chip { font-family: inherit; font-size: 0.7rem; padding: 0.15rem 0.55rem; border: 1px solid var(--border); border-radius: 999px; background: var(--bg-surface); color: var(--text-dim); cursor: pointer; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
46
+ .root-chip:hover { border-color: var(--accent); }
47
+ .root-chip.active { border-color: var(--accent); color: var(--accent); }
40
48
  .badge { display: inline-block; padding: 0.08rem 0.35rem; border-radius: 3px; font-size: 0.62rem; font-weight: 600; white-space: nowrap; flex-shrink: 0; }
41
49
  .b-discuss { background: rgba(122,162,247,0.15); color: var(--accent); }
42
50
  .b-research { background: rgba(158,206,106,0.15); color: var(--green); }
@@ -59,6 +67,8 @@
59
67
  <span class="search-count" id="search-count"></span>
60
68
  </div>
61
69
 
70
+ <div class="root-filter" id="root-filter" hidden></div>
71
+
62
72
  <div id="container"><div class="empty">Loading…</div></div>
63
73
 
64
74
  <noscript><div style="padding:2rem;color:var(--text-dim);text-align:center;">Enable JavaScript to view notes.</div></noscript>
@@ -66,6 +76,9 @@
66
76
  var allNotes = [];
67
77
  var notesDirState = 'present';
68
78
  var notesDirPath = 'operator/notes';
79
+ // Every notes root the server walked: [{ path, state, count }].
80
+ var notesRoots = [];
81
+ var rootFilter = '';
69
82
  var noteContents = {};
70
83
  var searchTimer = null;
71
84
 
@@ -73,6 +86,8 @@
73
86
  var el = document.createElement('span'); el.textContent = s == null ? '' : String(s); return el.innerHTML;
74
87
  }
75
88
 
89
+ function escAttr(s) { return esc(s).replace(/"/g, '&quot;'); }
90
+
76
91
  function navTo(path) {
77
92
  if (window.parent && window.parent.navigate) window.parent.navigate(path);
78
93
  else window.location.href = path;
@@ -97,6 +112,35 @@
97
112
  return '';
98
113
  }
99
114
 
115
+ function rootStateText(r) {
116
+ if (r.state === 'missing') return r.path + ' (no directory)';
117
+ if (r.state === 'empty') return r.path + ' (empty)';
118
+ return r.path;
119
+ }
120
+
121
+ function joinOr(items) {
122
+ if (items.length <= 1) return items.join('');
123
+ return items.slice(0, -1).join(', ') + ' or ' + items[items.length - 1];
124
+ }
125
+
126
+ function renderRootFilter() {
127
+ var el = document.getElementById('root-filter');
128
+ if (notesRoots.length < 2) { el.hidden = true; el.innerHTML = ''; return; }
129
+ var html = '<button type="button" class="root-chip' + (rootFilter ? '' : ' active') + '" data-root="">All roots (' + allNotes.length + ')</button>';
130
+ notesRoots.forEach(function(r) {
131
+ html += '<button type="button" class="root-chip' + (rootFilter === r.path ? ' active' : '') + '" data-root="' + escAttr(r.path) + '" title="' + escAttr(rootStateText(r)) + '">' + esc(r.path) + ' (' + (r.count || 0) + ')</button>';
132
+ });
133
+ el.innerHTML = html;
134
+ el.hidden = false;
135
+ }
136
+
137
+ document.getElementById('root-filter').addEventListener('click', function(e) {
138
+ var chip = e.target.closest('.root-chip'); if (!chip) return;
139
+ rootFilter = chip.getAttribute('data-root') || '';
140
+ renderRootFilter();
141
+ render(document.getElementById('search').value.trim());
142
+ });
143
+
100
144
  // Simple fuzzy match: all query words must appear in target (case-insensitive)
101
145
  function fuzzyMatch(target, query) {
102
146
  if (!query) return true;
@@ -133,12 +177,13 @@
133
177
  var countEl = document.getElementById('search-count');
134
178
 
135
179
  var filtered = allNotes.filter(function(n) {
180
+ if (rootFilter && n.root !== rootFilter) return false;
136
181
  if (!query) return true;
137
182
  var contentSnippet = (noteContents[n.path] || '').slice(0, 200);
138
- return fuzzyMatch((n.folder || '') + ' ' + n.title + ' ' + contentSnippet, query);
183
+ return fuzzyMatch((n.root || '') + ' ' + (n.folder || '') + ' ' + n.title + ' ' + contentSnippet, query);
139
184
  });
140
185
 
141
- countEl.textContent = query ? filtered.length + ' / ' + allNotes.length : '';
186
+ countEl.textContent = (query || rootFilter) ? filtered.length + ' / ' + allNotes.length : '';
142
187
 
143
188
  if (filtered.length === 0) {
144
189
  // 0.1.54 (w-2026-09-21-dave-002, F7a/B3): distinguish missing vs
@@ -147,6 +192,12 @@
147
192
  var emptyMsg;
148
193
  if (query) {
149
194
  emptyMsg = 'No notes match "' + esc(query) + '".';
195
+ } else if (rootFilter) {
196
+ emptyMsg = 'No notes in ' + esc(rootFilter) + '.';
197
+ } else if (notesRoots.length && notesRoots.every(function(r) { return r.state === 'missing'; })) {
198
+ emptyMsg = 'No notes directory at ' + esc(joinOr(notesRoots.map(function(r) { return r.path; }))) + '. Create one and add a .md or .html file to see it here.';
199
+ } else if (notesRoots.length) {
200
+ emptyMsg = 'No notes yet. Looked in ' + esc(notesRoots.map(rootStateText).join(', ')) + '.';
150
201
  } else if (notesDirState === 'missing') {
151
202
  emptyMsg = 'No notes directory at ' + esc(notesDirPath) + '. Create it and add a .md file to see it here.';
152
203
  } else {
@@ -167,8 +218,10 @@
167
218
  html += '<li class="note-item" onclick="openNote(' + idx + ')">';
168
219
  html += '<div class="note-item-left"><div>';
169
220
  html += noteBadge(note.name);
221
+ if (/\.html$/.test(note.name)) html += ' <span class="badge b-other">html</span> ';
170
222
  if (note.folder) html += '<span class="note-folder">' + highlight(note.folder + '/', query) + '</span>';
171
223
  html += '<span class="note-title">' + highlight(note.title, query) + '</span>';
224
+ if (note.root && notesRoots.length > 1) html += '<div class="note-root">' + highlight(note.root, query) + '</div>';
172
225
  if (snippet) html += snippet;
173
226
  html += '</div></div>';
174
227
  html += '<span class="note-meta">' + relativeTime(note.modified) + '</span>';
@@ -202,6 +255,8 @@
202
255
  allNotes = data.notes || [];
203
256
  notesDirState = data.notesDir || 'present';
204
257
  notesDirPath = data.notesDirPath || 'operator/notes';
258
+ notesRoots = Array.isArray(data.roots) ? data.roots : [];
259
+ renderRootFilter();
205
260
  render('');
206
261
  }).catch(function(err) {
207
262
  document.getElementById('container').innerHTML = '<div class="empty">Failed to load: ' + esc(err.message) + '</div>';
@@ -1,5 +1,5 @@
1
- import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync } from 'node:fs';
2
- import { join, resolve, relative } from 'node:path';
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, realpathSync } from 'node:fs';
2
+ import { join, resolve, relative, isAbsolute, sep } from 'node:path';
3
3
  import { execSync, execFile } from 'node:child_process';
4
4
  import { promisify } from 'node:util';
5
5
 
@@ -36,7 +36,9 @@ import {
36
36
  * @param {URL} url
37
37
  * @param {string} repoRoot -- absolute path to the project root
38
38
  * @param {object} [deps] -- injectable bridges (tests only). Currently:
39
- * deps.scheduler = { detect, list }.
39
+ * deps.scheduler = { detect, list },
40
+ * deps.warn = (message) => void (notes-root
41
+ * refusals; default console.warn, once each).
40
42
  * @returns {boolean}
41
43
  */
42
44
  const SERVER_STARTED_AT = new Date().toISOString();
@@ -224,20 +226,30 @@ export function handleApi(req, res, url, repoRoot, deps = {}) {
224
226
  }
225
227
 
226
228
  // GET /__api/notes
227
- // 0.1.54 fix (w-2026-09-21-dave-002, F7a/B3): missing-vs-empty is
228
- // now visible to the client. The 0.1.53 shape (`{notes: []}`) could
229
- // not distinguish "no notes directory on disk" from "directory
230
- // exists and holds zero .md files"; the Notes page said "you have
231
- // no notes" for both. `notesDir` carries `missing` / `empty` /
232
- // `present`, and the page now says "no notes directory at
233
- // operator/notes" when the scaffold seam has not run.
229
+ // The notes index across every notes root (see resolveNotesRoots):
230
+ // {
231
+ // notes: [{ name, title, path, root, folder, modified }],
232
+ // notesDir: 'missing' | 'empty' | 'present',
233
+ // notesDirPath: 'operator/notes',
234
+ // roots: [{ path, state: 'missing' | 'empty' | 'present', count }],
235
+ // }
236
+ // `notes` is one list, newest `modified` first across all roots, ties
237
+ // broken by `path` ascending. `path` is repo-relative (what
238
+ // viewer.html?file= takes), `root` is the note's repo-relative root and
239
+ // `folder` is relative to that root ('' at its top level). `notesDir`
240
+ // and `notesDirPath` describe operator/notes alone, so a page that
241
+ // predates `roots` keeps its missing-versus-empty message. `roots`
242
+ // lists each root walked, in walk order, with the state and count of
243
+ // the notes listed under it. A throw is a 500 with `{ error }`.
234
244
  if (method === 'GET' && pathname === '/__api/notes') {
235
245
  try {
236
- const walked = walkNotes(repoRoot);
246
+ const walked = walkNotes(repoRoot, { warn: deps.warn });
247
+ const canonical = walked.roots[0];
237
248
  sendJson(res, 200, {
238
249
  notes: walked.notes,
239
- notesDir: walked.state,
240
- notesDirPath: 'operator/notes',
250
+ notesDir: canonical.state,
251
+ notesDirPath: canonical.path,
252
+ roots: walked.roots,
241
253
  });
242
254
  } catch (err) {
243
255
  sendJson(res, 500, { error: err.message });
@@ -841,46 +853,152 @@ function walkWork(repoRoot) {
841
853
  return items;
842
854
  }
843
855
 
844
- function walkNotes(repoRoot) {
845
- const notesDir = join(repoRoot, 'operator', 'notes');
846
- if (!existsSync(notesDir)) return { notes: [], state: 'missing' };
847
- // Recursive: notes written into subfolders (operator/notes/<topic>/x.md)
848
- // are listed too, carrying `folder` relative to operator/notes ('' at
849
- // the top level). assets/ holds images and attachments, never notes.
856
+ // Notes roots, in walk order:
857
+ // 1. operator/notes, the instance's canonical notes directory (the
858
+ // scaffold creates it), always first;
859
+ // 2. agents/<paName>/workspace/notes, where the running PA writes its
860
+ // session notes beside its status board (paName from
861
+ // resolveAgentName, the same resolution the status board uses);
862
+ // 3. each repo-relative path in preferences `notesRoots` (an array of
863
+ // strings), in the order given.
864
+ // Discovery covers the resolved PA only, not every agents/* folder: a
865
+ // second agent's notes, or any other notes folder, need a `notesRoots`
866
+ // entry. A configured root that is not a non-empty string, names the repo
867
+ // root itself, or resolves outside the repo (by path or through a
868
+ // symlink) is refused: logged and never walked. A root named twice is
869
+ // walked once.
870
+ const CANONICAL_NOTES_ROOT = 'operator/notes';
871
+ const NOTES_ROOTS_PREFERENCE = 'notesRoots';
872
+ // Every root follows the same rules: any depth, a top-level assets/
873
+ // skipped (images and attachments, never notes), .md and .html accepted
874
+ // (the viewer renders .md and serves .html as is). Nothing inside a file
875
+ // is read: the title comes from the filename.
876
+ const NOTE_FILE_RE = /\.(md|html)$/;
877
+ const NOTE_TITLE_PREFIXES = ['discuss-', 'research-', 'draft-', 'spec-', 'pickup-', 'marketing-'];
878
+
879
+ const warnedNotesRoots = new Set();
880
+ function warnNotesRootOnce(repoRoot, message) {
881
+ const key = repoRoot + '\0' + message;
882
+ if (warnedNotesRoots.has(key)) return;
883
+ warnedNotesRoots.add(key);
884
+ console.warn('[workspace] ' + message);
885
+ }
886
+
887
+ function isOutsideRel(rel) {
888
+ return rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel);
889
+ }
890
+
891
+ function normaliseNotesRoot(repoRoot, raw) {
892
+ if (typeof raw !== 'string' || raw.trim() === '') return { error: 'not a non-empty string' };
893
+ const abs = resolve(repoRoot, raw.trim());
894
+ const rel = relative(repoRoot, abs);
895
+ if (rel === '') return { error: 'names the repo root itself' };
896
+ if (isOutsideRel(rel)) return { error: 'resolves outside the repo' };
897
+ if (existsSync(abs)) {
898
+ let realRel = null;
899
+ try { realRel = relative(realpathSync(repoRoot), realpathSync(abs)); } catch { realRel = null; }
900
+ if (realRel !== null && (realRel === '' || isOutsideRel(realRel))) {
901
+ return { error: 'resolves outside the repo through a symlink' };
902
+ }
903
+ }
904
+ return { path: rel.split(sep).join('/') };
905
+ }
906
+
907
+ function resolveNotesRoots(repoRoot, opts = {}) {
908
+ const warn = typeof opts.warn === 'function'
909
+ ? opts.warn
910
+ : function (message) { warnNotesRootOnce(repoRoot, message); };
911
+ const roots = [CANONICAL_NOTES_ROOT];
912
+ const paName = resolveAgentName(repoRoot);
913
+ if (isValidAgentName(paName)) {
914
+ roots.push('agents/' + paName + '/workspace/notes');
915
+ } else {
916
+ warn('notes: paName ' + JSON.stringify(paName) + ' is not a valid agent folder name; its workspace notes are not walked');
917
+ }
918
+ let prefs = null;
919
+ try { prefs = readPreferencesSync(repoRoot); } catch { prefs = null; }
920
+ const configured = prefs ? prefs[NOTES_ROOTS_PREFERENCE] : undefined;
921
+ if (configured !== undefined && configured !== null) {
922
+ if (!Array.isArray(configured)) {
923
+ warn(NOTES_ROOTS_PREFERENCE + ': expected an array of repo-relative paths; ignored');
924
+ } else {
925
+ for (const raw of configured) {
926
+ const r = normaliseNotesRoot(repoRoot, raw);
927
+ if (r.error) {
928
+ warn(NOTES_ROOTS_PREFERENCE + ': refused ' + JSON.stringify(raw) + ' (' + r.error + '); not walked');
929
+ continue;
930
+ }
931
+ roots.push(r.path);
932
+ }
933
+ }
934
+ }
935
+ return roots.filter(function (r, i) { return roots.indexOf(r) === i; });
936
+ }
937
+
938
+ function noteTitle(name) {
939
+ let title = name.replace(NOTE_FILE_RE, '');
940
+ for (const prefix of NOTE_TITLE_PREFIXES) {
941
+ if (title.startsWith(prefix)) { title = title.slice(prefix.length); break; }
942
+ }
943
+ return title.replace(/-/g, ' ').replace(/\b\w/g, function (c) { return c.toUpperCase(); });
944
+ }
945
+
946
+ // Note files under one root: [{ name, folder, relPath }], relPath and
947
+ // folder relative to the root ('' folder at its top level).
948
+ function listNoteFiles(absRoot) {
850
949
  const files = [];
851
950
  (function walk(rel) {
852
- const entries = readdirSync(join(notesDir, rel), { withFileTypes: true });
951
+ const entries = readdirSync(rel ? join(absRoot, rel) : absRoot, { withFileTypes: true });
853
952
  for (const e of entries) {
854
953
  const relPath = rel ? rel + '/' + e.name : e.name;
855
954
  if (e.isDirectory()) {
856
955
  if (relPath !== 'assets') walk(relPath);
857
- } else if (e.name.endsWith('.md')) {
956
+ } else if (NOTE_FILE_RE.test(e.name)) {
858
957
  files.push({ name: e.name, folder: rel, relPath: relPath });
859
958
  }
860
959
  }
861
960
  })('');
862
- var prefixes = ['discuss-', 'research-', 'draft-', 'spec-', 'pickup-', 'marketing-'];
863
- var notes = [];
864
- for (var i = 0; i < files.length; i++) {
865
- var name = files[i].name;
866
- var absPath = join(notesDir, files[i].relPath);
867
- var st;
868
- try { st = statSync(absPath); } catch(e) { continue; }
869
- var title = name.replace(/\.md$/, '');
870
- for (var j = 0; j < prefixes.length; j++) {
871
- if (title.startsWith(prefixes[j])) { title = title.slice(prefixes[j].length); break; }
961
+ return files;
962
+ }
963
+
964
+ function walkNotes(repoRoot, opts = {}) {
965
+ const notes = [];
966
+ const roots = [];
967
+ const listed = new Set();
968
+ for (const rootPath of resolveNotesRoots(repoRoot, opts)) {
969
+ const absRoot = join(repoRoot, ...rootPath.split('/'));
970
+ let isDir = false;
971
+ try { isDir = statSync(absRoot).isDirectory(); } catch { isDir = false; }
972
+ if (!isDir) {
973
+ roots.push({ path: rootPath, state: 'missing', count: 0 });
974
+ continue;
872
975
  }
873
- title = title.replace(/-/g, ' ').replace(/\b\w/g, function(c) { return c.toUpperCase(); });
874
- notes.push({
875
- name: name,
876
- title: title,
877
- path: 'operator/notes/' + files[i].relPath,
878
- folder: files[i].folder,
879
- modified: st.mtime.toISOString(),
880
- });
976
+ let count = 0;
977
+ for (const f of listNoteFiles(absRoot)) {
978
+ const path = rootPath + '/' + f.relPath;
979
+ // A root nested inside an earlier root: its notes stay with the
980
+ // earlier root, listed once.
981
+ if (listed.has(path)) continue;
982
+ let st;
983
+ try { st = statSync(join(absRoot, f.relPath)); } catch { continue; }
984
+ listed.add(path);
985
+ notes.push({
986
+ name: f.name,
987
+ title: noteTitle(f.name),
988
+ path: path,
989
+ root: rootPath,
990
+ folder: f.folder,
991
+ modified: st.mtime.toISOString(),
992
+ });
993
+ count++;
994
+ }
995
+ roots.push({ path: rootPath, state: count === 0 ? 'empty' : 'present', count: count });
881
996
  }
882
- notes.sort(function(a, b) { return b.modified.localeCompare(a.modified); });
883
- return { notes: notes, state: notes.length === 0 ? 'empty' : 'present' };
997
+ notes.sort(function (a, b) {
998
+ if (a.modified !== b.modified) return a.modified < b.modified ? 1 : -1;
999
+ return a.path < b.path ? -1 : (a.path > b.path ? 1 : 0);
1000
+ });
1001
+ return { notes: notes, roots: roots };
884
1002
  }
885
1003
 
886
1004
  function walkDocuments(dir, repoRoot, result) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stravica-ai/digital-operator-pa",
3
- "version": "0.1.68",
3
+ "version": "0.1.69",
4
4
  "description": "Digital Operator PA: single-bundled public package surface (bins + library) for the platform.",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",