@north-light/crouter 0.3.231 → 0.3.232

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.
Files changed (173) hide show
  1. package/dist/api/client.d.ts +3 -1
  2. package/dist/api/client.js +4 -0
  3. package/dist/api/dto/canvas.d.ts +10 -0
  4. package/dist/api/dto/common.d.ts +1 -1
  5. package/dist/api/dto/health.d.ts +2 -1
  6. package/dist/api/dto/lifecycle.d.ts +3 -4
  7. package/dist/api/dto/messages.d.ts +5 -4
  8. package/dist/api/dto/nodes.d.ts +2 -0
  9. package/dist/api/dto/profiles.d.ts +5 -0
  10. package/dist/api/routes.d.ts +1 -0
  11. package/dist/api/routes.js +1 -0
  12. package/dist/builtin-memory/00-runtime-base/00-authoring.md +8 -0
  13. package/dist/builtin-memory/00-runtime-base/01-escalation.md +1 -1
  14. package/dist/builtin-memory/01-spine/00-has-manager.md +1 -1
  15. package/dist/builtin-memory/02-turn-lifecycle/00-ending-a-turn.md +5 -0
  16. package/dist/builtin-memory/02-turn-lifecycle/02-resident.md +5 -3
  17. package/dist/builtin-memory/04-orchestration-kernel.md +2 -2
  18. package/dist/builtin-memory/insights/capture.md +3 -2
  19. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +6 -1
  20. package/dist/clients/attach/render/diagram.js +13 -5
  21. package/dist/clients/attach/render/page-block.d.ts +0 -1
  22. package/dist/clients/attach/render/page-block.js +4 -57
  23. package/dist/clients/attach/viewer.js +570 -563
  24. package/dist/clients/inbox/__tests__/integration/inbox-controller.test.js +9 -0
  25. package/dist/clients/inbox/__tests__/integration/mount-panel.test.js +62 -1
  26. package/dist/clients/inbox/controller.d.ts +10 -0
  27. package/dist/clients/inbox/controller.js +56 -12
  28. package/dist/clients/inbox/tui/input.js +38 -10
  29. package/dist/clients/inbox/tui/page-body.d.ts +10 -0
  30. package/dist/clients/inbox/tui/page-body.js +66 -0
  31. package/dist/clients/inbox/tui/panel.js +6 -4
  32. package/dist/clients/inbox/tui/render.js +89 -17
  33. package/dist/clients/inbox/tui/types.d.ts +4 -4
  34. package/dist/commands/__tests__/human.test.js +18 -3
  35. package/dist/commands/__tests__/node-message.test.js +3 -3
  36. package/dist/commands/api-client.js +1 -7
  37. package/dist/commands/canvas-config.js +6 -14
  38. package/dist/commands/canvas-use.js +4 -6
  39. package/dist/commands/cron.js +16 -22
  40. package/dist/commands/human/prompts.d.ts +1 -1
  41. package/dist/commands/human/prompts.js +118 -112
  42. package/dist/commands/human/request.js +13 -14
  43. package/dist/commands/human/review.js +3 -4
  44. package/dist/commands/human/shared.d.ts +6 -0
  45. package/dist/commands/human/shared.js +43 -5
  46. package/dist/commands/human.js +1 -1
  47. package/dist/commands/memory/delete.js +4 -6
  48. package/dist/commands/memory/edit.js +0 -4
  49. package/dist/commands/memory/move.js +3 -5
  50. package/dist/commands/memory/shared.d.ts +1 -1
  51. package/dist/commands/memory/shared.js +9 -5
  52. package/dist/commands/memory/write.js +60 -29
  53. package/dist/commands/memory.js +1 -1
  54. package/dist/commands/node/bash.js +6 -9
  55. package/dist/commands/node/create.js +89 -22
  56. package/dist/commands/node/inspect.js +3 -3
  57. package/dist/commands/node/lifecycle.js +30 -27
  58. package/dist/commands/node/message.js +24 -45
  59. package/dist/commands/node/subscription.js +6 -15
  60. package/dist/commands/node/wait.js +2 -3
  61. package/dist/commands/node-lifecycle-revive.js +1 -12
  62. package/dist/commands/pkg/browse/actions.js +2 -3
  63. package/dist/commands/pkg/market-manage.js +2 -5
  64. package/dist/commands/pkg/plugin-manage.js +7 -8
  65. package/dist/commands/profile/default.js +5 -5
  66. package/dist/commands/profile/delete.js +1 -1
  67. package/dist/commands/profile/env.js +9 -15
  68. package/dist/commands/profile/kind.js +3 -7
  69. package/dist/commands/profile/meta.js +3 -5
  70. package/dist/commands/profile/new.js +0 -6
  71. package/dist/commands/profile/pause.js +4 -8
  72. package/dist/commands/profile/project.js +5 -9
  73. package/dist/commands/profile/rename.js +3 -7
  74. package/dist/commands/profile/show.js +3 -3
  75. package/dist/commands/profile.js +4 -3
  76. package/dist/commands/surface-tmux-spread.js +1 -3
  77. package/dist/commands/sys/config.js +3 -4
  78. package/dist/commands/sys/support/prepare.js +8 -4
  79. package/dist/commands/sys/support/submit.js +2 -3
  80. package/dist/commands/sys/sync-deps.js +1 -9
  81. package/dist/commands/sys/sync-project-guidance.js +1 -7
  82. package/dist/commands/sys/sync-skills.js +1 -11
  83. package/dist/core/__tests__/cron-node-sink-parked-root.test.d.ts +1 -0
  84. package/dist/core/__tests__/cron-node-sink-parked-root.test.js +147 -0
  85. package/dist/core/__tests__/history-inbox.test.js +11 -1
  86. package/dist/core/__tests__/human-deliver.test.js +2 -1
  87. package/dist/core/__tests__/integration/command-plugins.test.js +0 -1
  88. package/dist/core/__tests__/integration/deferred-no-wake.test.js +0 -1
  89. package/dist/core/__tests__/lifecycle.test.js +30 -2
  90. package/dist/core/__tests__/revive-parked-fresh.test.d.ts +1 -0
  91. package/dist/core/__tests__/revive-parked-fresh.test.js +109 -0
  92. package/dist/core/__tests__/seam/dormancy-release.test.js +32 -5
  93. package/dist/core/canvas/attention.d.ts +2 -0
  94. package/dist/core/canvas/attention.js +25 -18
  95. package/dist/core/canvas/extensions.d.ts +1 -1
  96. package/dist/core/canvas/extensions.js +7 -1
  97. package/dist/core/canvas/history.js +20 -2
  98. package/dist/core/canvas/types.d.ts +1 -1
  99. package/dist/core/command.js +33 -8
  100. package/dist/core/help.d.ts +28 -2
  101. package/dist/core/help.js +46 -10
  102. package/dist/core/human/__tests__/page-html-markdown.test.d.ts +1 -0
  103. package/dist/core/human/__tests__/page-html-markdown.test.js +48 -0
  104. package/dist/core/human/component-docs.js +4 -4
  105. package/dist/core/human/page-html-markdown.d.ts +8 -0
  106. package/dist/core/human/page-html-markdown.js +260 -0
  107. package/dist/core/memory/lint.d.ts +15 -0
  108. package/dist/core/memory/lint.js +150 -90
  109. package/dist/core/profiles/__tests__/fuzzy-match.test.d.ts +1 -0
  110. package/dist/core/profiles/__tests__/fuzzy-match.test.js +51 -0
  111. package/dist/core/profiles/fuzzy-match.d.ts +19 -0
  112. package/dist/core/profiles/fuzzy-match.js +92 -0
  113. package/dist/core/profiles/manifest.d.ts +14 -7
  114. package/dist/core/profiles/manifest.js +62 -12
  115. package/dist/core/profiles/select.d.ts +3 -1
  116. package/dist/core/profiles/select.js +5 -3
  117. package/dist/core/profiles/state-block.js +4 -3
  118. package/dist/core/runtime/boot-root.d.ts +3 -2
  119. package/dist/core/runtime/canvas-extensions.d.ts +7 -1
  120. package/dist/core/runtime/canvas-extensions.js +8 -1
  121. package/dist/core/runtime/lifecycle.d.ts +11 -2
  122. package/dist/core/runtime/lifecycle.js +15 -2
  123. package/dist/core/runtime/model-selection.d.ts +4 -0
  124. package/dist/core/runtime/model-selection.js +5 -0
  125. package/dist/core/runtime/nodes.js +5 -0
  126. package/dist/core/runtime/reopen.d.ts +6 -0
  127. package/dist/core/runtime/reopen.js +12 -1
  128. package/dist/core/runtime/revive.d.ts +6 -0
  129. package/dist/core/runtime/revive.js +22 -2
  130. package/dist/core/runtime/spawn.d.ts +5 -2
  131. package/dist/core/runtime/spawn.js +18 -32
  132. package/dist/core/runtime/structured-output.d.ts +6 -0
  133. package/dist/core/runtime/structured-output.js +6 -0
  134. package/dist/core/substrate/__tests__/surface-match-command.test.d.ts +1 -0
  135. package/dist/core/substrate/__tests__/surface-match-command.test.js +89 -0
  136. package/dist/core/substrate/on-read.js +2 -1
  137. package/dist/core/substrate/surface-match.js +164 -12
  138. package/dist/core/termrender/version.d.ts +1 -1
  139. package/dist/core/termrender/version.js +1 -1
  140. package/dist/core/user-settings.js +1 -1
  141. package/dist/daemon/api/__tests__/broker-settle-park.test.d.ts +1 -0
  142. package/dist/daemon/api/__tests__/broker-settle-park.test.js +102 -0
  143. package/dist/daemon/api/__tests__/canvas-snapshot-fields.test.d.ts +1 -0
  144. package/dist/daemon/api/__tests__/canvas-snapshot-fields.test.js +188 -0
  145. package/dist/daemon/api/__tests__/node-create-description.test.d.ts +1 -0
  146. package/dist/daemon/api/__tests__/node-create-description.test.js +83 -0
  147. package/dist/daemon/api/__tests__/profile-metadata-route.test.d.ts +1 -0
  148. package/dist/daemon/api/__tests__/profile-metadata-route.test.js +92 -0
  149. package/dist/daemon/api/__tests__/reopen-delivery.test.d.ts +1 -0
  150. package/dist/daemon/api/__tests__/reopen-delivery.test.js +173 -0
  151. package/dist/daemon/api/handlers/broker-ops.js +21 -0
  152. package/dist/daemon/api/handlers/canvas.js +10 -0
  153. package/dist/daemon/api/handlers/messages.js +25 -16
  154. package/dist/daemon/api/handlers/nodes.js +5 -0
  155. package/dist/daemon/api/handlers/profiles.js +22 -1
  156. package/dist/daemon/cron-run.js +19 -1
  157. package/dist/daemon/manage.d.ts +16 -1
  158. package/dist/daemon/manage.js +20 -1
  159. package/dist/daemon/park-pending.d.ts +13 -0
  160. package/dist/daemon/park-pending.js +42 -0
  161. package/dist/daemon/reconcilers/broker-supervision.d.ts +13 -0
  162. package/dist/daemon/reconcilers/broker-supervision.js +139 -21
  163. package/dist/daemon/reconcilers/live-obligation.d.ts +10 -4
  164. package/dist/daemon/reconcilers/live-obligation.js +7 -3
  165. package/dist/daemon/reconcilers/storage-maintenance.d.ts +0 -4
  166. package/dist/daemon/reconcilers/storage-maintenance.js +1 -33
  167. package/dist/pi-extensions/canvas-prompt-scrub.d.ts +13 -0
  168. package/dist/pi-extensions/canvas-prompt-scrub.js +53 -0
  169. package/dist/shared/generated-context.d.ts +7 -0
  170. package/dist/shared/generated-context.js +11 -0
  171. package/package.json +4 -4
  172. package/runtime.lock.json +2 -2
  173. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/strip-skills-docs.ts +0 -47
@@ -0,0 +1,260 @@
1
+ /** An HTML page's document projected to markdown, so a terminal reader sees the
2
+ * page's actual content instead of a placeholder line. HTML pages are authored
3
+ * for a browser surface and carry no slots, so this is a reading projection
4
+ * only — layout, styling, and scripting are dropped, and what survives is the
5
+ * prose, headings, lists, links, code, and table cells the author wrote. */
6
+ const DROPPED = new Set(['script', 'style', 'head', 'template', 'noscript', 'svg', 'iframe', 'object']);
7
+ const BLOCK = new Set([
8
+ 'address', 'article', 'aside', 'blockquote', 'details', 'dialog', 'div', 'dl', 'fieldset', 'figcaption', 'figure',
9
+ 'footer', 'form', 'header', 'main', 'nav', 'p', 'section', 'summary', 'table', 'tbody', 'tfoot', 'thead', 'video',
10
+ ]);
11
+ const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
12
+ const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', mdash: '—', ndash: '–', hellip: '…', rsquo: '’', lsquo: '‘', rdquo: '”', ldquo: '“' };
13
+ const TAG = /<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<![^>]*>|<(\/?)([a-zA-Z][^\s/>]*)((?:"[^"]*"|'[^']*'|[^>])*?)\/?>/g;
14
+ const ATTR = /([^\s=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
15
+ function decode(value) {
16
+ return value.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);/g, (whole, body) => {
17
+ if (body.startsWith('#')) {
18
+ const code = body[1] === 'x' || body[1] === 'X' ? Number.parseInt(body.slice(2), 16) : Number.parseInt(body.slice(1), 10);
19
+ return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : whole;
20
+ }
21
+ return ENTITIES[body.toLowerCase()] ?? whole;
22
+ });
23
+ }
24
+ function attrsOf(source) {
25
+ const attrs = new Map();
26
+ for (const match of source.matchAll(ATTR))
27
+ attrs.set(match[1].toLowerCase(), decode(match[2] ?? match[3] ?? match[4] ?? ''));
28
+ return attrs;
29
+ }
30
+ function tokenize(document) {
31
+ const tokens = [];
32
+ let at = 0;
33
+ for (const match of document.matchAll(TAG)) {
34
+ if (match.index > at)
35
+ tokens.push({ kind: 'text', text: document.slice(at, match.index) });
36
+ at = match.index + match[0].length;
37
+ if (match[2] !== undefined)
38
+ tokens.push({ kind: 'tag', name: match[2].toLowerCase(), closing: match[1] === '/', selfClosing: match[0].endsWith('/>'), attrs: attrsOf(match[3] ?? '') });
39
+ }
40
+ if (at < document.length)
41
+ tokens.push({ kind: 'text', text: document.slice(at) });
42
+ return tokens;
43
+ }
44
+ /** Markdown's structural characters, escaped so authored prose reads as prose.
45
+ * Mirrors the JSX projection's escaping so both dialects render alike. */
46
+ function escapeText(value) {
47
+ return value.replace(/([\\`*_[\]<>#])/g, '\\$1');
48
+ }
49
+ class Sink {
50
+ buffers = [''];
51
+ write(text) { this.buffers[this.buffers.length - 1] += text; }
52
+ get top() { return this.buffers[this.buffers.length - 1]; }
53
+ set top(value) { this.buffers[this.buffers.length - 1] = value; }
54
+ push() { this.buffers.push(''); }
55
+ pop() { return this.buffers.length > 1 ? this.buffers.pop() : ''; }
56
+ get nested() { return this.buffers.length > 1; }
57
+ /** End the current line without opening a new block. */
58
+ line() {
59
+ this.top = this.top.replace(/[ \t]+$/, '');
60
+ if (this.top !== '' && !this.top.endsWith('\n'))
61
+ this.write('\n');
62
+ }
63
+ /** Separate what follows from what precedes by one blank line. */
64
+ block() {
65
+ this.top = this.top.replace(/[ \t\n]+$/, '');
66
+ if (this.top !== '')
67
+ this.write('\n\n');
68
+ }
69
+ done() { return this.buffers.join('').replace(/\n{3,}/g, '\n\n').replace(/[ \t]+\n/g, '\n').trim(); }
70
+ }
71
+ /** Project one authored HTML document to markdown. Never throws: a document
72
+ * this cannot make sense of yields whatever text it did find, down to ''. */
73
+ export function projectHtmlDocumentMarkdown(document) {
74
+ const sink = new Sink();
75
+ const lists = [];
76
+ const anchors = [];
77
+ const dropped = [];
78
+ let preDepth = 0;
79
+ let cellOpen = false;
80
+ let headerRow = false;
81
+ let rowCells = 0;
82
+ const indent = () => ' '.repeat(Math.max(0, lists.length - 1));
83
+ for (const token of tokenize(document)) {
84
+ if (token.kind === 'text') {
85
+ if (dropped.length > 0)
86
+ continue;
87
+ if (preDepth > 0) {
88
+ sink.write(token.text);
89
+ continue;
90
+ }
91
+ const text = decode(token.text).replace(/\s+/g, ' ');
92
+ if (text.trim() === '') {
93
+ if (/\s/.test(text))
94
+ sink.write(' ');
95
+ continue;
96
+ }
97
+ sink.write(escapeText(text));
98
+ continue;
99
+ }
100
+ const { name, closing, attrs } = token;
101
+ // Unwinding to the matching open keeps one stray close tag from reopening
102
+ // the whole document, and one stray open from swallowing the rest of it.
103
+ if (DROPPED.has(name)) {
104
+ if (closing) {
105
+ const at = dropped.lastIndexOf(name);
106
+ if (at >= 0)
107
+ dropped.length = at;
108
+ }
109
+ else if (!token.selfClosing)
110
+ dropped.push(name);
111
+ continue;
112
+ }
113
+ if (dropped.length > 0)
114
+ continue;
115
+ const heading = /^h([1-6])$/.exec(name);
116
+ if (heading !== null) {
117
+ sink.block();
118
+ if (!closing)
119
+ sink.write(`${'#'.repeat(Number(heading[1]))} `);
120
+ continue;
121
+ }
122
+ if (name === 'pre') {
123
+ if (closing) {
124
+ preDepth = Math.max(0, preDepth - 1);
125
+ sink.line();
126
+ sink.write('```');
127
+ sink.block();
128
+ }
129
+ else {
130
+ sink.block();
131
+ sink.write('```\n');
132
+ preDepth++;
133
+ }
134
+ continue;
135
+ }
136
+ if (name === 'br') {
137
+ sink.line();
138
+ continue;
139
+ }
140
+ if (name === 'hr') {
141
+ sink.block();
142
+ sink.write('---');
143
+ sink.block();
144
+ continue;
145
+ }
146
+ if (name === 'ul' || name === 'ol') {
147
+ if (closing)
148
+ lists.pop();
149
+ else {
150
+ sink.block();
151
+ lists.push({ ordered: name === 'ol', index: 0 });
152
+ }
153
+ if (closing)
154
+ sink.block();
155
+ continue;
156
+ }
157
+ if (name === 'li') {
158
+ if (closing)
159
+ continue;
160
+ const frame = lists[lists.length - 1];
161
+ sink.line();
162
+ if (frame === undefined)
163
+ sink.write('- ');
164
+ else {
165
+ frame.index++;
166
+ sink.write(`${indent()}${frame.ordered ? `${frame.index}. ` : '- '}`);
167
+ }
168
+ continue;
169
+ }
170
+ if (name === 'dt' || name === 'dd') {
171
+ sink.line();
172
+ if (!closing)
173
+ sink.write(name === 'dt' ? '' : ' ');
174
+ continue;
175
+ }
176
+ if (name === 'tr') {
177
+ if (closing) {
178
+ sink.write(rowCells > 0 ? ' |' : '');
179
+ if (headerRow && rowCells > 0) {
180
+ sink.line();
181
+ sink.write(`|${' --- |'.repeat(rowCells)}`);
182
+ }
183
+ sink.line();
184
+ }
185
+ else {
186
+ sink.line();
187
+ headerRow = false;
188
+ rowCells = 0;
189
+ }
190
+ continue;
191
+ }
192
+ if (name === 'td' || name === 'th') {
193
+ if (closing) {
194
+ if (!cellOpen)
195
+ continue;
196
+ cellOpen = false;
197
+ sink.write(sink.pop().replace(/\s+/g, ' ').trim());
198
+ }
199
+ else {
200
+ if (name === 'th')
201
+ headerRow = true;
202
+ rowCells++;
203
+ sink.write(rowCells === 1 ? '| ' : ' | ');
204
+ sink.push();
205
+ cellOpen = true;
206
+ }
207
+ continue;
208
+ }
209
+ if (name === 'a') {
210
+ // The label is captured rather than streamed, so a link with no text
211
+ // never leaves a bare `[](url)` behind.
212
+ if (closing) {
213
+ if (anchors.length === 0)
214
+ continue;
215
+ const href = anchors.pop();
216
+ if (href === null)
217
+ continue;
218
+ const label = sink.pop().replace(/\s+/g, ' ').trim();
219
+ if (label !== '')
220
+ sink.write(`[${label}](${href})`);
221
+ continue;
222
+ }
223
+ const href = attrs.get('href');
224
+ const linkable = href !== undefined && href !== '' && !href.startsWith('#');
225
+ anchors.push(linkable ? href : null);
226
+ if (linkable)
227
+ sink.push();
228
+ continue;
229
+ }
230
+ if (name === 'code' && preDepth === 0) {
231
+ sink.write('`');
232
+ continue;
233
+ }
234
+ if (name === 'strong' || name === 'b') {
235
+ sink.write('**');
236
+ continue;
237
+ }
238
+ if (name === 'em' || name === 'i') {
239
+ sink.write('*');
240
+ continue;
241
+ }
242
+ if (name === 'img') {
243
+ const alt = attrs.get('alt');
244
+ if (alt !== undefined && alt !== '')
245
+ sink.write(escapeText(alt));
246
+ continue;
247
+ }
248
+ if (name === 'blockquote') {
249
+ sink.block();
250
+ if (!closing)
251
+ sink.write('> ');
252
+ continue;
253
+ }
254
+ if (BLOCK.has(name) && !VOID.has(name)) {
255
+ sink.block();
256
+ continue;
257
+ }
258
+ }
259
+ return sink.done();
260
+ }
@@ -112,6 +112,21 @@ export interface MemoryLintStoreOptions {
112
112
  * the identities its links resolve against are undetermined until migration
113
113
  * declares the namespace. */
114
114
  export declare function lintMemoryStore(store: MemoryStoreDescriptor, options?: MemoryLintStoreOptions): MemoryLintResult;
115
+ export interface MemoryLintDocumentOptions {
116
+ /** Canonical names a `[[link]]` or route may target. Omit to skip reference
117
+ * checks — the caller pays for the corpus index only when the document
118
+ * actually carries a reference to resolve. */
119
+ resolvable?: ReadonlySet<string>;
120
+ /** The store sits below the mounted project roots, so its boot entries are
121
+ * inert. Omit when the document carries no boot entry: the rule cannot fire. */
122
+ nestedStore?: boolean;
123
+ }
124
+ /** Lint ONE document, by absolute path, in the store that holds it. Runs every
125
+ * file-level rule the store sweep runs, and the store-level front-door rule
126
+ * this one file can violate on its own. It does NOT run the rules that need
127
+ * the whole store (canonical collisions, missing front-door coverage), so it
128
+ * never replaces `crtr memory lint` — it catches what an author just typed. */
129
+ export declare function lintMemoryDocument(store: MemoryStoreDescriptor, filePath: string, options?: MemoryLintDocumentOptions): MemoryLintResult;
115
130
  export interface MemoryLintTargetOptions {
116
131
  /** Whose view to lint. Default: the ambient process (cwd + CRTR_PROFILE_ID +
117
132
  * CRTR_NODE_ID). */
@@ -229,96 +229,19 @@ export function lintMemoryStore(store, options = {}) {
229
229
  if (relPath === '')
230
230
  continue;
231
231
  checked += 1;
232
- let fm;
233
- let body;
234
- try {
235
- ({ data: fm, body } = parseFrontmatterGeneric(readText(file)));
236
- }
237
- catch (e) {
238
- add('error', 'yaml', file, `invalid YAML frontmatter: ${(e instanceof Error ? e.message : String(e)).split('\n')[0]}`);
239
- continue;
240
- }
241
- if (NON_DOCUMENT_FILES.has(basename(file)))
242
- continue;
243
- documents += 1;
244
- const isRootFile = localNameFromPath(relPath) === '';
245
- const doc = docsByPath.get(file);
246
- // A file the loader dropped (a root INDEX.md composing to the empty name)
247
- // carries no canonical identity, but its frontmatter contract still holds —
248
- // the field rules below run before the identity-dependent ones.
249
- const named = doc === undefined ? {} : { name: doc.name };
250
- const schemaError = lintSubstrateFrontmatter(fm);
251
- if (schemaError !== null)
252
- add('error', 'schema', file, schemaError, named);
253
- if (fm !== null) {
254
- const isRepositoryRootStore = isProjectStore && store.ownerRelativePath === '';
255
- if (fm['namespace'] !== undefined && !(isRootFile && isRepositoryRootStore)) {
256
- const message = isRootFile && isProjectStore && store.ownerRelativePath !== undefined && store.ownerRelativePath !== ''
257
- ? `\`namespace:\` belongs only on the repository-root store's ${INDEX_SEGMENT}.md (${store.namespacePath ?? store.storeRoot}) — a nested store's prefix is derived from its path under ${store.repositoryRoot ?? store.ownerDir ?? store.storeRoot}`
258
- : `\`namespace:\` belongs only on a project store's root ${INDEX_SEGMENT}.md, where it declares the whole store's prefix — drop it here (an explicit \`name:\` is store-LOCAL and never carries a namespace)`;
259
- add('error', 'namespace-placement', file, message, named);
260
- }
261
- if (isRootFile && fm['name'] !== undefined) {
262
- add('error', 'root-name', file, `a store's root ${INDEX_SEGMENT}.md carries no \`name:\` — its identity answers at \`${store.namespace}\`; drop the field`, named);
263
- }
264
- }
265
- if (doc === undefined) {
266
- // In a project store the missing/invalid declaration is already the finding.
267
- if (isRootFile && !isProjectStore) {
268
- add('error', 'root-index-unaddressable', file, `a root ${INDEX_SEGMENT}.md has no canonical name in a ${store.scope} store, which declares no namespace — only a project store's root document answers at its namespace, so rename this file to a named document (\`<topic>.md\`) or fold its content into the docs it introduces`);
269
- }
270
- continue;
271
- }
272
- if (fm !== null) {
273
- if (!isRootFile && fm['name'] !== undefined) {
274
- const raw = fm['name'];
275
- if (typeof raw !== 'string' || raw.trim() === '') {
276
- add('error', 'local-name', file, `invalid name: ${JSON.stringify(raw)} (expected a non-empty store-local name)`, named);
277
- }
278
- else {
279
- // Validated as WRITTEN: normalizing first would silently accept an
280
- // `NN-` ordering prefix, which is physical-path-only. The suggestion
281
- // is the normalized spelling.
282
- const valid = validateExplicitLocalName(raw.trim(), store.namespace);
283
- if (!valid.ok) {
284
- const suggested = suggestedLocalName(normalizeDocName(raw.trim()), store.namespace, relPath);
285
- add('error', 'local-name', file, `invalid name \`${raw}\`: ${valid.reason} — ${suggested}`, named);
286
- }
287
- }
288
- }
289
- for (const issue of validateMemoryExtensionValues(fm['extensions'], memoryExtensionValidationCatalog(doc))) {
290
- add('error', 'extension', file, `${issue.path}: ${issue.message}`, named);
291
- }
292
- const lengthError = lintBodyLength(fm, body, doc.name);
293
- if (lengthError !== null)
294
- add('error', 'length', file, lengthError, named);
295
- const shortPreview = lintShortPreviewBody(fm, body);
296
- if (shortPreview !== null)
297
- add('warning', 'short-preview', file, shortPreview, named);
298
- const entries = parsedSubstrateSurfaces(fm);
299
- if (options.nestedStore === true && entries.some((e) => e.on === 'boot')) {
300
- add('warning', 'nested-store-boot', file, 'nested-store docs never ride the boot catalog, so its boot surfaces entries are inert — drop them', named);
301
- }
302
- if (isProjectStore && entries.some((e) => e.on === 'workspace-open' && e.at === 'content')) {
303
- const complete = entries.some((entry) => {
304
- if (entry.on !== 'read' || entry.at !== 'content')
305
- return false;
306
- return entry.match?.includes('./**') === true;
307
- });
308
- frontDoors.push({ path: file, root: isRootFile, complete });
309
- }
310
- for (const entry of entries) {
311
- if (entry.on !== 'memory-read')
312
- continue;
313
- lintMemoryReadEntry(entry, doc, fm, options.resolvable, mounted, add);
314
- }
315
- }
316
- if (options.resolvable !== undefined && mounted) {
317
- for (const name of docLinkNames(body)) {
318
- if (options.resolvable.has(name))
319
- continue;
320
- add('error', 'dangling-link', file, `dangling doc link [[${name}]]: no memory document or directory in this target view has that exact canonical name.${missingNameDiagnostic(name)}`, named);
321
- }
232
+ if (lintDocumentFile({
233
+ store,
234
+ file,
235
+ relPath,
236
+ doc: docsByPath.get(file),
237
+ isProjectStore,
238
+ mounted,
239
+ ...(options.resolvable === undefined ? {} : { resolvable: options.resolvable }),
240
+ nestedStore: options.nestedStore === true,
241
+ add,
242
+ frontDoors,
243
+ }) === 'document') {
244
+ documents += 1;
322
245
  }
323
246
  }
324
247
  // Same-store canonical collisions. The loader already orders `foo/INDEX.md`
@@ -355,6 +278,143 @@ export function lintMemoryStore(store, options = {}) {
355
278
  }
356
279
  return makeResult(findings, checked, documents, 1);
357
280
  }
281
+ /** Every rule that reads ONE file. The store sweep runs it per file, and
282
+ * `lintMemoryDocument` runs it alone; a rule asserted in one caller holds only
283
+ * there, so neither reimplements one. Store-level rules (canonical collisions,
284
+ * front-door coverage) are the caller's, because one file cannot see them. */
285
+ function lintDocumentFile(ctx) {
286
+ const { store, file, relPath, doc, isProjectStore, mounted, resolvable, add } = ctx;
287
+ let fm;
288
+ let body;
289
+ try {
290
+ ({ data: fm, body } = parseFrontmatterGeneric(readText(file)));
291
+ }
292
+ catch (e) {
293
+ add('error', 'yaml', file, `invalid YAML frontmatter: ${(e instanceof Error ? e.message : String(e)).split('\n')[0]}`);
294
+ return 'yaml-error';
295
+ }
296
+ if (NON_DOCUMENT_FILES.has(basename(file)))
297
+ return 'non-document';
298
+ const isRootFile = localNameFromPath(relPath) === '';
299
+ // A file the loader dropped (a root INDEX.md composing to the empty name)
300
+ // carries no canonical identity, but its frontmatter contract still holds —
301
+ // the field rules below run before the identity-dependent ones.
302
+ const named = doc === undefined ? {} : { name: doc.name };
303
+ const schemaError = lintSubstrateFrontmatter(fm);
304
+ if (schemaError !== null)
305
+ add('error', 'schema', file, schemaError, named);
306
+ if (fm !== null) {
307
+ const isRepositoryRootStore = isProjectStore && store.ownerRelativePath === '';
308
+ if (fm['namespace'] !== undefined && !(isRootFile && isRepositoryRootStore)) {
309
+ const message = isRootFile && isProjectStore && store.ownerRelativePath !== undefined && store.ownerRelativePath !== ''
310
+ ? `\`namespace:\` belongs only on the repository-root store's ${INDEX_SEGMENT}.md (${store.namespacePath ?? store.storeRoot}) — a nested store's prefix is derived from its path under ${store.repositoryRoot ?? store.ownerDir ?? store.storeRoot}`
311
+ : `\`namespace:\` belongs only on a project store's root ${INDEX_SEGMENT}.md, where it declares the whole store's prefix — drop it here (an explicit \`name:\` is store-LOCAL and never carries a namespace)`;
312
+ add('error', 'namespace-placement', file, message, named);
313
+ }
314
+ if (isRootFile && fm['name'] !== undefined) {
315
+ add('error', 'root-name', file, `a store's root ${INDEX_SEGMENT}.md carries no \`name:\` — its identity answers at \`${store.namespace}\`; drop the field`, named);
316
+ }
317
+ }
318
+ if (doc === undefined) {
319
+ // In a project store the missing/invalid declaration is already the finding.
320
+ if (isRootFile && !isProjectStore) {
321
+ add('error', 'root-index-unaddressable', file, `a root ${INDEX_SEGMENT}.md has no canonical name in a ${store.scope} store, which declares no namespace — only a project store's root document answers at its namespace, so rename this file to a named document (\`<topic>.md\`) or fold its content into the docs it introduces`);
322
+ }
323
+ return 'document';
324
+ }
325
+ if (fm !== null) {
326
+ if (!isRootFile && fm['name'] !== undefined) {
327
+ const raw = fm['name'];
328
+ if (typeof raw !== 'string' || raw.trim() === '') {
329
+ add('error', 'local-name', file, `invalid name: ${JSON.stringify(raw)} (expected a non-empty store-local name)`, named);
330
+ }
331
+ else {
332
+ // Validated as WRITTEN: normalizing first would silently accept an
333
+ // `NN-` ordering prefix, which is physical-path-only. The suggestion
334
+ // is the normalized spelling.
335
+ const valid = validateExplicitLocalName(raw.trim(), store.namespace);
336
+ if (!valid.ok) {
337
+ const suggested = suggestedLocalName(normalizeDocName(raw.trim()), store.namespace, relPath);
338
+ add('error', 'local-name', file, `invalid name \`${raw}\`: ${valid.reason} — ${suggested}`, named);
339
+ }
340
+ }
341
+ }
342
+ for (const issue of validateMemoryExtensionValues(fm['extensions'], memoryExtensionValidationCatalog(doc))) {
343
+ add('error', 'extension', file, `${issue.path}: ${issue.message}`, named);
344
+ }
345
+ const lengthError = lintBodyLength(fm, body, doc.name);
346
+ if (lengthError !== null)
347
+ add('error', 'length', file, lengthError, named);
348
+ const shortPreview = lintShortPreviewBody(fm, body);
349
+ if (shortPreview !== null)
350
+ add('warning', 'short-preview', file, shortPreview, named);
351
+ const entries = parsedSubstrateSurfaces(fm);
352
+ if (ctx.nestedStore && entries.some((e) => e.on === 'boot')) {
353
+ add('warning', 'nested-store-boot', file, 'nested-store docs never ride the boot catalog, so its boot surfaces entries are inert — drop them', named);
354
+ }
355
+ if (isProjectStore && entries.some((e) => e.on === 'workspace-open' && e.at === 'content')) {
356
+ const complete = entries.some((entry) => {
357
+ if (entry.on !== 'read' || entry.at !== 'content')
358
+ return false;
359
+ return entry.match?.includes('./**') === true;
360
+ });
361
+ ctx.frontDoors.push({ path: file, root: isRootFile, complete });
362
+ }
363
+ for (const entry of entries) {
364
+ if (entry.on !== 'memory-read')
365
+ continue;
366
+ lintMemoryReadEntry(entry, doc, fm, resolvable, mounted, add);
367
+ }
368
+ }
369
+ if (resolvable !== undefined && mounted) {
370
+ for (const name of docLinkNames(body)) {
371
+ if (resolvable.has(name))
372
+ continue;
373
+ add('error', 'dangling-link', file, `dangling doc link [[${name}]]: no memory document or directory in this target view has that exact canonical name.${missingNameDiagnostic(name)}`, named);
374
+ }
375
+ }
376
+ return 'document';
377
+ }
378
+ /** Lint ONE document, by absolute path, in the store that holds it. Runs every
379
+ * file-level rule the store sweep runs, and the store-level front-door rule
380
+ * this one file can violate on its own. It does NOT run the rules that need
381
+ * the whole store (canonical collisions, missing front-door coverage), so it
382
+ * never replaces `crtr memory lint` — it catches what an author just typed. */
383
+ export function lintMemoryDocument(store, filePath, options = {}) {
384
+ const findings = [];
385
+ const base = {
386
+ scope: store.scope,
387
+ ...(store.storeRoot === '' ? {} : { storeRoot: store.storeRoot }),
388
+ };
389
+ const add = (severity, rule, path, message, extra = {}) => {
390
+ findings.push({ severity, rule, path, message, ...base, ...extra });
391
+ };
392
+ const relPath = relative(store.storeRoot, filePath).split(sep).join('/');
393
+ const doc = loadStoreMemoryDocs(store, true).find((candidate) => candidate.path === filePath);
394
+ const frontDoors = [];
395
+ const outcome = lintDocumentFile({
396
+ store,
397
+ file: filePath,
398
+ relPath,
399
+ doc,
400
+ isProjectStore: store.scope === 'project' && store.plugin === undefined,
401
+ mounted: store.mountStatus === 'ready',
402
+ ...(options.resolvable === undefined ? {} : { resolvable: options.resolvable }),
403
+ nestedStore: options.nestedStore === true,
404
+ add,
405
+ frontDoors,
406
+ });
407
+ // The one store-level rule a single document can break by itself: a front
408
+ // door anywhere but the store root delivers a second whole doc at every
409
+ // workspace open. A MISSING front door is the store's fault, not this doc's,
410
+ // so it stays with the sweep.
411
+ for (const door of frontDoors) {
412
+ if (door.root)
413
+ continue;
414
+ add('error', 'front-door', door.path, `a project store's workspace front door is its root ${INDEX_SEGMENT}.md, not this doc — a {on: workspace-open, at: content} entry here delivers a second whole document at every workspace open; fold it into the root document or lower this entry`);
415
+ }
416
+ return makeResult(findings, 1, outcome === 'document' ? 1 : 0, 1);
417
+ }
358
418
  /** The local spelling an invalid explicit `name:` should use: dropping the field
359
419
  * entirely when the physical path already derives the same identity. */
360
420
  function suggestedLocalName(local, namespace, physicalRelativePath) {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,51 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { fuzzyMatchProfile } from '../fuzzy-match.js';
4
+ const PROFILES = [
5
+ { profileId: 'crouter-1a2b3c4d', name: 'crouter' },
6
+ { profileId: 'northlight-5e6f7a8b', name: 'Northlight' },
7
+ { profileId: 'personal-ops-9c0d1e2f', name: 'personal ops' },
8
+ ];
9
+ function idOf(operand) {
10
+ return fuzzyMatchProfile(operand, PROFILES).match?.profileId ?? null;
11
+ }
12
+ test('a truncated name resolves to the profile it prefixes', () => {
13
+ assert.equal(idOf('crout'), 'crouter-1a2b3c4d');
14
+ assert.equal(idOf('north'), 'northlight-5e6f7a8b');
15
+ });
16
+ test('case and separators are irrelevant', () => {
17
+ assert.equal(idOf('CROUTER'), 'crouter-1a2b3c4d');
18
+ assert.equal(idOf('personal-ops'), 'personal-ops-9c0d1e2f');
19
+ assert.equal(idOf('PersonalOps'), 'personal-ops-9c0d1e2f');
20
+ });
21
+ test('a transposition or a dropped character still resolves', () => {
22
+ assert.equal(idOf('cruoter'), 'crouter-1a2b3c4d');
23
+ assert.equal(idOf('crouterr'), 'crouter-1a2b3c4d');
24
+ assert.equal(idOf('northlite'), 'northlight-5e6f7a8b');
25
+ });
26
+ test('the id slug matches without its generated hex suffix', () => {
27
+ assert.equal(idOf('personal-ops-9c0d1e2f'), 'personal-ops-9c0d1e2f');
28
+ assert.equal(idOf('personalops'), 'personal-ops-9c0d1e2f');
29
+ });
30
+ test('a genuinely unrelated operand matches nothing', () => {
31
+ assert.equal(idOf('zzzzzzzzzz'), null);
32
+ assert.equal(idOf(''), null);
33
+ });
34
+ test('equally close candidates are reported as a tie, never guessed', () => {
35
+ const twins = [
36
+ { profileId: 'alpha-00000001', name: 'alpha' },
37
+ { profileId: 'alpha-00000002', name: 'alpho' },
38
+ ];
39
+ const result = fuzzyMatchProfile('alphx', twins);
40
+ assert.equal(result.match, null);
41
+ assert.deepEqual(result.tied.map((c) => c.profileId), ['alpha-00000001', 'alpha-00000002']);
42
+ });
43
+ test('a stronger tier wins over a closer edit distance in a weaker tier', () => {
44
+ // "nort" is a prefix of northlight and one edit from "note" — the prefix
45
+ // must win, because a user who stopped typing meant the thing they started.
46
+ const candidates = [
47
+ { profileId: 'northlight-5e6f7a8b', name: 'northlight' },
48
+ { profileId: 'note-11111111', name: 'note' },
49
+ ];
50
+ assert.equal(fuzzyMatchProfile('nort', candidates).match?.profileId, 'northlight-5e6f7a8b');
51
+ });
@@ -0,0 +1,19 @@
1
+ /** One candidate offered to the matcher: the durable id plus the mutable name. */
2
+ export interface FuzzyCandidate {
3
+ readonly profileId: string;
4
+ readonly name: string;
5
+ }
6
+ export interface FuzzyResult {
7
+ /** The single profile the needle resolved to, or `null` when nothing was
8
+ * close enough or several were equally close. */
9
+ readonly match: FuzzyCandidate | null;
10
+ /** Populated only when several profiles tied — the caller reports these so
11
+ * the user can disambiguate without running `profile list`. */
12
+ readonly tied: readonly FuzzyCandidate[];
13
+ }
14
+ /** Resolve a typed operand against the profiles on this host. Returns a single
15
+ * match only when it is unambiguously the best one: the strongest tier any
16
+ * candidate reached, and within it the single lowest edit distance. A genuine
17
+ * tie is reported rather than guessed, because silently running under the
18
+ * wrong profile is worse than one more prompt. */
19
+ export declare function fuzzyMatchProfile(operand: string, candidates: readonly FuzzyCandidate[]): FuzzyResult;