@north-light/crouter 0.3.208 → 0.3.209

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 (138) hide show
  1. package/dist/api/client.d.ts +3 -1
  2. package/dist/api/client.js +4 -0
  3. package/dist/api/dto/broker-ops.d.ts +2 -12
  4. package/dist/api/dto/nodes.d.ts +16 -0
  5. package/dist/api/routes.d.ts +1 -0
  6. package/dist/api/routes.js +1 -0
  7. package/dist/builtin-memory/00-runtime-base.md +3 -2
  8. package/dist/builtin-memory/01-spine/00-has-manager.md +3 -2
  9. package/dist/builtin-memory/01-spine/01-no-manager.md +3 -2
  10. package/dist/builtin-memory/02-lifecycle/00-terminal.md +3 -2
  11. package/dist/builtin-memory/02-lifecycle/01-resident.md +3 -2
  12. package/dist/builtin-memory/04-base-worker.md +3 -2
  13. package/dist/builtin-memory/04-orchestration-kernel.md +3 -2
  14. package/dist/builtin-memory/05-kinds/advisor/00-base.md +3 -2
  15. package/dist/builtin-memory/05-kinds/advisor/01-orchestrator.md +3 -2
  16. package/dist/builtin-memory/05-kinds/design/00-base.md +3 -2
  17. package/dist/builtin-memory/05-kinds/design/01-orchestrator.md +3 -2
  18. package/dist/builtin-memory/05-kinds/developer/00-base.md +3 -2
  19. package/dist/builtin-memory/05-kinds/developer/01-orchestrator.md +3 -2
  20. package/dist/builtin-memory/05-kinds/explore/00-base.md +3 -2
  21. package/dist/builtin-memory/05-kinds/explore/01-orchestrator.md +3 -2
  22. package/dist/builtin-memory/05-kinds/general/00-base.md +3 -2
  23. package/dist/builtin-memory/05-kinds/general/01-orchestrator.md +3 -2
  24. package/dist/builtin-memory/05-kinds/plan/00-base.md +3 -2
  25. package/dist/builtin-memory/05-kinds/plan/01-orchestrator.md +3 -2
  26. package/dist/builtin-memory/05-kinds/plan/reviewers/00-base.md +3 -2
  27. package/dist/builtin-memory/05-kinds/plan/reviewers/architecture-fit.md +3 -2
  28. package/dist/builtin-memory/05-kinds/plan/reviewers/code-smells.md +3 -2
  29. package/dist/builtin-memory/05-kinds/plan/reviewers/pattern-consistency.md +3 -2
  30. package/dist/builtin-memory/05-kinds/plan/reviewers/requirements-coverage.md +3 -2
  31. package/dist/builtin-memory/05-kinds/plan/reviewers/security.md +3 -2
  32. package/dist/builtin-memory/05-kinds/review/00-base.md +3 -2
  33. package/dist/builtin-memory/05-kinds/review/01-orchestrator.md +3 -2
  34. package/dist/builtin-memory/05-kinds/review/companion/00-base.md +3 -2
  35. package/dist/builtin-memory/05-kinds/spec/00-base.md +3 -2
  36. package/dist/builtin-memory/05-kinds/spec/01-orchestrator.md +3 -2
  37. package/dist/builtin-memory/05-kinds/spec/requirements.md +3 -2
  38. package/dist/builtin-memory/advisor/council.md +0 -2
  39. package/dist/builtin-memory/design.md +3 -2
  40. package/dist/builtin-memory/development.md +3 -2
  41. package/dist/builtin-memory/insights/capture.md +1 -3
  42. package/dist/builtin-memory/insights/init.md +1 -3
  43. package/dist/builtin-memory/insights/listen.md +3 -2
  44. package/dist/builtin-memory/internal/INDEX.md +5 -4
  45. package/dist/builtin-memory/internal/agent-shaping.md +5 -4
  46. package/dist/builtin-memory/internal/examples/INDEX.md +3 -2
  47. package/dist/builtin-memory/internal/examples/imessage-assistant.md +3 -2
  48. package/dist/builtin-memory/internal/marketplaces.md +3 -2
  49. package/dist/builtin-memory/internal/memory-loading.md +31 -20
  50. package/dist/builtin-memory/internal/nodes-and-canvas.md +3 -2
  51. package/dist/builtin-memory/internal/plugins.md +7 -6
  52. package/dist/builtin-memory/internal/storage-tiers.md +3 -2
  53. package/dist/builtin-memory/plan/roadmap.md +3 -2
  54. package/dist/builtin-memory/spec/guide.md +0 -2
  55. package/dist/builtin-memory/spec/requirements.md +0 -2
  56. package/dist/builtin-memory/spec/roadmap.md +3 -2
  57. package/dist/builtin-memory/testing.md +1 -3
  58. package/dist/builtin-memory/wedged-child-on-runaway-bash.md +3 -2
  59. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +1 -1
  60. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/frontmatter-rules/index.ts +3 -3
  61. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/memory-slash-commands.ts +1 -4
  62. package/dist/clients/attach/viewer.js +366 -366
  63. package/dist/commands/memory/delete.js +3 -18
  64. package/dist/commands/memory/edit.js +23 -30
  65. package/dist/commands/memory/history.js +1 -1
  66. package/dist/commands/memory/lint.d.ts +7 -8
  67. package/dist/commands/memory/lint.js +178 -132
  68. package/dist/commands/memory/list.d.ts +0 -1
  69. package/dist/commands/memory/list.js +2 -12
  70. package/dist/commands/memory/move.d.ts +1 -0
  71. package/dist/commands/memory/move.js +195 -0
  72. package/dist/commands/memory/read.js +135 -141
  73. package/dist/commands/memory/shared.d.ts +18 -17
  74. package/dist/commands/memory/shared.js +93 -39
  75. package/dist/commands/memory/write.js +23 -33
  76. package/dist/commands/memory.js +5 -4
  77. package/dist/commands/pkg/browse/catalog.js +2 -4
  78. package/dist/commands/pkg/browse/doc-view.js +17 -11
  79. package/dist/commands/pkg/browse/model.d.ts +7 -9
  80. package/dist/commands/sys/migrate.d.ts +1 -0
  81. package/dist/commands/sys/migrate.js +106 -0
  82. package/dist/commands/sys/sync-deps.js +5 -10
  83. package/dist/commands/sys/sync-project-guidance.js +36 -16
  84. package/dist/commands/sys/sync-skills.js +8 -4
  85. package/dist/commands/sys.js +3 -2
  86. package/dist/core/__tests__/inline-memory-refs.test.js +8 -5
  87. package/dist/core/__tests__/memory-resolver-precedence.test.js +5 -4
  88. package/dist/core/__tests__/nested-store-discovery.test.js +1 -3
  89. package/dist/core/__tests__/on-read-crouter-home-fence.test.js +3 -3
  90. package/dist/core/__tests__/on-read-dedup-resume.test.js +12 -12
  91. package/dist/core/__tests__/on-read-nested-store.test.js +23 -20
  92. package/dist/core/canvas/db.d.ts +3 -1
  93. package/dist/core/canvas/db.js +12 -2
  94. package/dist/core/memory/history.d.ts +4 -1
  95. package/dist/core/memory/history.js +1 -0
  96. package/dist/core/memory/inline-ref-inventory.d.ts +5 -4
  97. package/dist/core/memory/inline-ref-inventory.js +23 -19
  98. package/dist/core/memory-resolver.d.ts +4 -4
  99. package/dist/core/memory-resolver.js +16 -43
  100. package/dist/core/runtime/bearings.d.ts +4 -3
  101. package/dist/core/runtime/bearings.js +4 -4
  102. package/dist/core/runtime/broker-extension-render.d.ts +3 -2
  103. package/dist/core/runtime/broker-extension-render.js +3 -3
  104. package/dist/core/runtime/memory.js +2 -3
  105. package/dist/core/substrate/index.d.ts +7 -4
  106. package/dist/core/substrate/index.js +6 -4
  107. package/dist/core/substrate/injected-store.d.ts +24 -12
  108. package/dist/core/substrate/injected-store.js +80 -33
  109. package/dist/core/substrate/listings.d.ts +21 -0
  110. package/dist/core/substrate/listings.js +88 -0
  111. package/dist/core/substrate/on-read-node.d.ts +5 -5
  112. package/dist/core/substrate/on-read-node.js +4 -5
  113. package/dist/core/substrate/on-read.d.ts +25 -4
  114. package/dist/core/substrate/on-read.js +81 -102
  115. package/dist/core/substrate/render-node.d.ts +5 -2
  116. package/dist/core/substrate/render-node.js +5 -3
  117. package/dist/core/substrate/render.d.ts +9 -8
  118. package/dist/core/substrate/render.js +104 -96
  119. package/dist/core/substrate/schema.d.ts +34 -18
  120. package/dist/core/substrate/schema.js +75 -32
  121. package/dist/core/substrate/surface-match.d.ts +32 -0
  122. package/dist/core/substrate/surface-match.js +179 -0
  123. package/dist/daemon/api/handlers/nodes.js +9 -0
  124. package/dist/migrations/001-surfaces-frontmatter.d.ts +2 -0
  125. package/dist/migrations/001-surfaces-frontmatter.js +276 -0
  126. package/dist/migrations/convergent.d.ts +31 -0
  127. package/dist/migrations/convergent.js +71 -0
  128. package/dist/migrations/registry.d.ts +2 -0
  129. package/dist/migrations/registry.js +19 -0
  130. package/dist/migrations/types.d.ts +40 -0
  131. package/dist/migrations/types.js +11 -0
  132. package/dist/pi-extensions/canvas-context-intro.d.ts +2 -1
  133. package/dist/pi-extensions/canvas-doc-substrate.d.ts +2 -1
  134. package/dist/pi-extensions/canvas-doc-substrate.js +57 -34
  135. package/package.json +1 -1
  136. package/runtime.lock.json +2 -2
  137. package/dist/core/substrate/ceiling.d.ts +0 -17
  138. package/dist/core/substrate/ceiling.js +0 -67
@@ -0,0 +1,179 @@
1
+ // surface-match.ts — the per-event matchers for `surfaces` entries, plus the
2
+ // owning-root helpers they anchor on. Pure: every function takes its subject
3
+ // explicitly (the read file, the resolved doc name, the command string) and
4
+ // touches no store discovery.
5
+ //
6
+ // Match semantics (design: surfaces-design.md): constraints within one entry
7
+ // AND together — each present constraint must fit, a `match` list is satisfied
8
+ // by any one glob — and multiple entries per event OR together. The `./`
9
+ // prefix is the one relative spelling: it anchors a glob to the doc's own
10
+ // container (its owning repo dir for `read`, its name-directory for
11
+ // `memory-read`).
12
+ import { basename, isAbsolute, matchesGlob, relative, sep } from 'node:path';
13
+ import { CRTR_DIR_NAME } from '../../types.js';
14
+ import { realpathOrSelf } from '../fs-utils.js';
15
+ import { evalCondition } from '../predicate.js';
16
+ import { rungRank } from './schema.js';
17
+ /** The project directory owning a doc's store: the path segment stack above
18
+ * the doc's `.crouter` dir. `null` when the doc's path carries no `.crouter`
19
+ * segment (builtin docs). */
20
+ export function owningRootOf(doc) {
21
+ const parts = doc.path.split(sep);
22
+ const idx = parts.lastIndexOf(CRTR_DIR_NAME);
23
+ if (idx <= 0)
24
+ return null;
25
+ return parts.slice(0, idx).join(sep) || sep;
26
+ }
27
+ /** True when the (already-realpathed) file sits beneath the doc's store's
28
+ * owning directory. Callers additionally guard on `doc.scope === 'project'`:
29
+ * a profile store's path computes an owning root of `~`, and user/builtin/
30
+ * node docs likewise have no project-owning dir, so a `./`-anchored glob
31
+ * outside a project store must never match. */
32
+ export function underOwningRoot(doc, absFile) {
33
+ const root = owningRootOf(doc);
34
+ if (root === null)
35
+ return false;
36
+ const rel = relative(realpathOrSelf(root), absFile);
37
+ return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
38
+ }
39
+ function safeGlob(target, glob) {
40
+ try {
41
+ return matchesGlob(target, glob);
42
+ }
43
+ catch {
44
+ return false;
45
+ }
46
+ }
47
+ /** One read-event glob vs the read file: plain globs match the absolute path
48
+ * and the basename; a `./`-anchored glob matches the path relative to the
49
+ * doc's store's owning repo dir (project-store docs only). */
50
+ function readGlobMatches(glob, doc, absReadFile) {
51
+ if (glob.startsWith('./')) {
52
+ if (doc.scope !== 'project' || !underOwningRoot(doc, absReadFile))
53
+ return false;
54
+ const root = owningRootOf(doc);
55
+ if (root === null)
56
+ return false;
57
+ return safeGlob(relative(realpathOrSelf(root), absReadFile), glob.slice(2));
58
+ }
59
+ return safeGlob(absReadFile, glob) || safeGlob(basename(absReadFile), glob);
60
+ }
61
+ /** Does a `read` entry fit the read file? `absReadFile` must already be
62
+ * realpathed. `readFrontmatter` is the read file's own parsed YAML (empty
63
+ * for non-markdown files); an empty record never satisfies
64
+ * `matchFrontmatter`. */
65
+ export function matchesReadEntry(entry, doc, absReadFile, readFrontmatter) {
66
+ if (entry.on !== 'read')
67
+ return false;
68
+ if (entry.match !== undefined && !entry.match.some((g) => readGlobMatches(g, doc, absReadFile))) {
69
+ return false;
70
+ }
71
+ if (entry.matchFrontmatter !== undefined) {
72
+ if (Object.keys(readFrontmatter).length === 0)
73
+ return false;
74
+ if (!evalCondition(entry.matchFrontmatter, readFrontmatter))
75
+ return false;
76
+ }
77
+ return entry.match !== undefined || entry.matchFrontmatter !== undefined;
78
+ }
79
+ /** One memory-read glob vs the resolved doc's names: plain globs match any of
80
+ * the caller-supplied target names (the full canonical name, plus the
81
+ * mount-stripped name for plugin-mounted docs); a `./`-anchored glob matches
82
+ * each target relative to the CARRIER doc's own name-directory. Names are
83
+ * slash-separated, so path globbing applies segment-wise. */
84
+ function memoryReadGlobMatches(glob, carrierName, targetNames) {
85
+ if (glob.startsWith('./')) {
86
+ const dir = carrierName.split('/').slice(0, -1).join('/');
87
+ const stripped = glob.slice(2);
88
+ return targetNames.some((name) => {
89
+ if (dir === '')
90
+ return safeGlob(name, stripped);
91
+ if (!name.startsWith(dir + '/'))
92
+ return false;
93
+ return safeGlob(name.slice(dir.length + 1), stripped);
94
+ });
95
+ }
96
+ return targetNames.some((name) => safeGlob(name, glob));
97
+ }
98
+ /** Does a `memory-read` entry on the doc named `carrierName` fit a resolved
99
+ * read of a doc known by `targetNames`? */
100
+ export function matchesMemoryReadEntry(entry, carrierName, targetNames) {
101
+ if (entry.on !== 'memory-read' || entry.match === undefined)
102
+ return false;
103
+ return entry.match.some((g) => memoryReadGlobMatches(g, carrierName, targetNames));
104
+ }
105
+ /** A command glob is a STRING glob over the whole command line, not a path
106
+ * glob: `*` crosses every character (including `/` — command lines carry
107
+ * paths), `?` matches one. Translated to a regex rather than fed to
108
+ * `matchesGlob`, whose `*` stops at path separators. */
109
+ function commandGlobMatches(glob, command) {
110
+ const rx = glob
111
+ .split('')
112
+ .map((ch) => (ch === '*' ? '[\\s\\S]*' : ch === '?' ? '[\\s\\S]' : ch.replace(/[.+^${}()|[\]\\]/g, '\\$&')))
113
+ .join('');
114
+ try {
115
+ return new RegExp(`^${rx}$`).test(command);
116
+ }
117
+ catch {
118
+ return false;
119
+ }
120
+ }
121
+ /** Does a `command` entry fit the executed command string? */
122
+ export function matchesCommandEntry(entry, command) {
123
+ if (entry.on !== 'command' || entry.match === undefined)
124
+ return false;
125
+ return entry.match.some((g) => commandGlobMatches(g, command));
126
+ }
127
+ // ---------------------------------------------------------------------------
128
+ // Per-doc rung folds — a doc's delivery rung for one event occurrence is the
129
+ // highest `at` over its matching entries of that event, `none` when none
130
+ // match (entries OR together; `none` is the internal "does not deliver"
131
+ // floor, never an authored rung).
132
+ // ---------------------------------------------------------------------------
133
+ /** The doc's delivery rung for a read of `absReadFile` (already realpathed). */
134
+ export function readDeliveryRung(doc, absReadFile, readFrontmatter) {
135
+ let r = 'none';
136
+ for (const e of doc.surfaces) {
137
+ if (!matchesReadEntry(e, doc, absReadFile, readFrontmatter))
138
+ continue;
139
+ if (rungRank(e.at) > rungRank(r))
140
+ r = e.at;
141
+ }
142
+ return r;
143
+ }
144
+ /** The doc's workspace-open rung. Presence of an entry is the match; callers
145
+ * scope the corpus to project stores mounted by the subject's cwd/profile. */
146
+ export function workspaceOpenRung(doc) {
147
+ let r = 'none';
148
+ for (const e of doc.surfaces) {
149
+ if (e.on !== 'workspace-open')
150
+ continue;
151
+ if (rungRank(e.at) > rungRank(r))
152
+ r = e.at;
153
+ }
154
+ return r;
155
+ }
156
+ /** The doc's delivery rung for a `crtr memory read` that resolved a doc
157
+ * known by `targetNames` (canonical name, plus the mount-stripped name for
158
+ * plugin-mounted docs). The doc's own `name` anchors `./` globs. */
159
+ export function memoryReadDeliveryRung(doc, targetNames) {
160
+ let r = 'none';
161
+ for (const e of doc.surfaces) {
162
+ if (!matchesMemoryReadEntry(e, doc.name, targetNames))
163
+ continue;
164
+ if (rungRank(e.at) > rungRank(r))
165
+ r = e.at;
166
+ }
167
+ return r;
168
+ }
169
+ /** The doc's delivery rung for an executed command string. */
170
+ export function commandDeliveryRung(doc, command) {
171
+ let r = 'none';
172
+ for (const e of doc.surfaces) {
173
+ if (!matchesCommandEntry(e, command))
174
+ continue;
175
+ if (rungRank(e.at) > rungRank(r))
176
+ r = e.at;
177
+ }
178
+ return r;
179
+ }
@@ -16,6 +16,7 @@ import { subtreeIds } from '../../../core/canvas/nav-model.js';
16
16
  import { contextDir, reportsDir } from '../../../core/canvas/paths.js';
17
17
  import { nodeArtifacts } from '../../../core/canvas/history.js';
18
18
  import { readNodeMessagesPage, readNodeSession, readNodeSnapshot, transcriptMarkdown } from '../../../core/runtime/node-read.js';
19
+ import { assembleNodeSubject } from '../../../core/substrate/subject.js';
19
20
  import { reviveAll } from '../../../core/runtime/revive-all.js';
20
21
  import { promote, requestYield, reshapeNode } from '../../../core/runtime/promote.js';
21
22
  import { recycleNode } from '../../../core/runtime/recycle.js';
@@ -304,6 +305,13 @@ async function handleSnapshot(ctx) {
304
305
  };
305
306
  return { status: 200, body };
306
307
  }
308
+ async function handleSubject(ctx) {
309
+ const id = ctx.params['id'];
310
+ const subject = assembleNodeSubject(id);
311
+ if (subject === null)
312
+ throw notFound(`unknown node: ${id}`, { received: id });
313
+ return { status: 200, body: subject };
314
+ }
307
315
  function parseMessagesQuery(ctx) {
308
316
  for (const [field] of ctx.query) {
309
317
  if (field !== 'cursor' && field !== 'limit')
@@ -634,6 +642,7 @@ export const nodeRoutes = [
634
642
  { method: 'POST', pattern: '/v1/nodes/revive-all', handler: () => handleReviveAll() },
635
643
  { method: 'GET', pattern: '/v1/nodes/:id', handler: handleDetail },
636
644
  { method: 'GET', pattern: '/v1/nodes/:id/snapshot', handler: handleSnapshot },
645
+ { method: 'GET', pattern: '/v1/nodes/:id/subject', handler: handleSubject },
637
646
  { method: 'GET', pattern: '/v1/nodes/:id/messages', handler: handleMessages },
638
647
  { method: 'GET', pattern: '/v1/nodes/:id/session', handler: handleSession },
639
648
  { method: 'GET', pattern: '/v1/nodes/:id/transcript', handler: handleTranscript },
@@ -0,0 +1,2 @@
1
+ import type { ConvergentMigration } from './types.js';
2
+ export declare const surfacesFrontmatterMigration: ConvergentMigration;
@@ -0,0 +1,276 @@
1
+ // 001 — the surfaces cut's frontmatter codemod. Folds the four old routing
2
+ // fields (`system-prompt-visibility`, `file-read-visibility`, `applies-to`,
3
+ // `read-when`) into explicit `surfaces` entries, bakes INDEX boot ceilings into
4
+ // each doc's boot rung (per store), and points bare-directory `[[refs]]` at
5
+ // their `[[dir/INDEX]]` docs (a bare-dir ref used to deliver the INDEX body;
6
+ // after the cut it returns the directory listing).
7
+ //
8
+ // Only docs with a valid substrate `kind` are touched. The old-schema parsing
9
+ // and ceiling walk are FROZEN COPIES of the pre-cut substrate code: the live
10
+ // versions are deleted with the cut, and this migration must keep reading the
11
+ // old format for as long as it ships.
12
+ //
13
+ // Serialization is a textual splice: the dead keys' exact line spans are cut
14
+ // from the raw frontmatter and a rendered `surfaces:` block is appended, so
15
+ // every other byte survives verbatim and a migrated doc's diff shows only the
16
+ // dead fields dying, `surfaces` appearing, and rule-5 ref rewrites — never
17
+ // unrelated reformatting (re-serializing through the Document API would unfold
18
+ // folded scalars). Each splice is validated by re-parsing: the result must
19
+ // equal the original record minus the dead fields plus `surfaces`, or the
20
+ // migration throws naming the doc.
21
+ import { basename } from 'node:path';
22
+ import { isMap, parse as parseYaml, parseDocument, stringify } from 'yaml';
23
+ import { parseFrontmatterGeneric } from '../core/frontmatter.js';
24
+ import { findDocLinks } from '../core/memory/doc-link-grammar.js';
25
+ import { createMemoryDocSnapshot } from '../core/memory-resolver.js';
26
+ const OLD_FIELDS = ['system-prompt-visibility', 'file-read-visibility', 'applies-to', 'read-when'];
27
+ // ---------------------------------------------------------------------------
28
+ // Frozen old-schema parsing (pre-cut substrate/schema.ts semantics).
29
+ // ---------------------------------------------------------------------------
30
+ const RUNGS = ['none', 'name', 'preview', 'content'];
31
+ function rungRank(r) {
32
+ return RUNGS.indexOf(r);
33
+ }
34
+ /** Absent/invalid rung falls to the old parser's neutral floor `none`. */
35
+ function parseRung(v) {
36
+ return typeof v === 'string' && RUNGS.includes(v) ? v : 'none';
37
+ }
38
+ function parseAppliesTo(v) {
39
+ if (typeof v === 'string')
40
+ return v.trim() === '' ? [] : [v];
41
+ if (Array.isArray(v))
42
+ return v.filter((g) => typeof g === 'string' && g.trim() !== '');
43
+ return [];
44
+ }
45
+ function parsePredicate(v) {
46
+ return v !== null && typeof v === 'object' && !Array.isArray(v) ? v : undefined;
47
+ }
48
+ /** Strip the optional `NN-` ordering prefix from every segment (identity
49
+ * normalization — frozen copy of normalizeDocName). */
50
+ function normalizeName(name) {
51
+ return name
52
+ .split('/')
53
+ .map((s) => s.replace(/^\d{2}-/, ''))
54
+ .join('/');
55
+ }
56
+ function classify(doc) {
57
+ const fm = doc.frontmatter;
58
+ if (fm === null)
59
+ return null;
60
+ if (fm['kind'] !== 'knowledge' && fm['kind'] !== 'preference')
61
+ return null;
62
+ const fallback = normalizeName(doc.relPath.replace(/\.md$/, ''));
63
+ const rawName = fm['name'];
64
+ const name = typeof rawName === 'string' && rawName.trim() !== '' ? normalizeName(rawName.trim()) : fallback;
65
+ return {
66
+ doc,
67
+ name,
68
+ spv: parseRung(fm['system-prompt-visibility']),
69
+ fv: parseRung(fm['file-read-visibility']),
70
+ appliesTo: parseAppliesTo(fm['applies-to']),
71
+ readWhen: parsePredicate(fm['read-when']),
72
+ hasOldFields: OLD_FIELDS.some((k) => k in fm),
73
+ };
74
+ }
75
+ // ---------------------------------------------------------------------------
76
+ // Frozen boot-ceiling walk (pre-cut substrate/ceiling.ts), per store: a
77
+ // directory INDEX's rung caps every descendant's boot rung; the migration
78
+ // bakes the effective (post-ceiling) rung into each doc so the rendered boot
79
+ // tree is preserved once the ceiling machinery is deleted.
80
+ // ---------------------------------------------------------------------------
81
+ function buildCeiling(docs) {
82
+ const m = new Map();
83
+ for (const d of docs) {
84
+ if (d.name !== 'INDEX' && !d.name.endsWith('/INDEX'))
85
+ continue;
86
+ const dir = d.name === 'INDEX' ? '' : d.name.slice(0, d.name.length - '/INDEX'.length);
87
+ if (!m.has(dir))
88
+ m.set(dir, d.spv);
89
+ }
90
+ return m;
91
+ }
92
+ function effectiveBootRung(d, ceiling) {
93
+ let rung = d.spv;
94
+ const parts = d.name.split('/');
95
+ parts.pop();
96
+ for (let i = parts.length; i > 0; i--) {
97
+ const idx = ceiling.get(parts.slice(0, i).join('/'));
98
+ if (idx !== undefined && rungRank(idx) < rungRank(rung))
99
+ rung = idx;
100
+ }
101
+ return rung;
102
+ }
103
+ /** Old read globs also matched the path RELATIVE to the doc's store's owning
104
+ * repo dir. A glob containing `/` that starts with neither `/`, `**`, nor
105
+ * `./` could ONLY match through that relative target (absolute paths start
106
+ * with `/`, basenames carry no `/`), so it becomes `./`-anchored — the new
107
+ * schema's one relative spelling. Every other shape keeps its meaning as-is. */
108
+ function anchorReadGlob(g) {
109
+ const t = g.trim();
110
+ if (!t.includes('/'))
111
+ return t;
112
+ if (t.startsWith('/') || t.startsWith('**') || t.startsWith('./'))
113
+ return t;
114
+ return `./${t}`;
115
+ }
116
+ function planEntries(d, bootRung) {
117
+ const entries = [];
118
+ if (d.spv !== 'none' && bootRung !== 'none')
119
+ entries.push({ on: 'boot', at: bootRung });
120
+ if (d.fv !== 'none') {
121
+ // The old `.` token meant both "deliver on workspace open" and "deliver on
122
+ // any read inside the owning repo" — spelled as two explicit entries.
123
+ if (d.appliesTo.some((g) => g.trim() === '.')) {
124
+ entries.push({ on: 'workspace-open', at: d.fv });
125
+ entries.push({ on: 'read', match: './**', at: d.fv });
126
+ }
127
+ const globs = d.appliesTo.filter((g) => g.trim() !== '.').map(anchorReadGlob);
128
+ if (globs.length > 0)
129
+ entries.push({ on: 'read', match: globs.length === 1 ? globs[0] : globs, at: d.fv });
130
+ // Old read matching was pathMatch OR frontmatterMatch, so a separate entry
131
+ // is behavior-preserving.
132
+ if (d.readWhen !== undefined)
133
+ entries.push({ on: 'read', 'match-frontmatter': d.readWhen, at: d.fv });
134
+ }
135
+ return entries;
136
+ }
137
+ // ---------------------------------------------------------------------------
138
+ // Rule 5: bare-dir `[[ref]]` → `[[dir/INDEX]]`. A ref is rewritten only when
139
+ // it resolves through the bare-dir/bare-plugin-name → INDEX.md convenience
140
+ // (resolved file is an INDEX.md the ref does not name) AND `<ref>/INDEX`
141
+ // provably resolves to the same file — meaning-preserving or untouched.
142
+ // Resolution uses the fs-only memory resolver from this process's ambient
143
+ // target, because refs cross stores (user docs point into plugin dirs).
144
+ // ---------------------------------------------------------------------------
145
+ let corpusMemo = null;
146
+ const refDecisionMemo = new Map();
147
+ function resolveOne(name) {
148
+ corpusMemo ??= createMemoryDocSnapshot();
149
+ return corpusMemo.resolve([name]).get(name);
150
+ }
151
+ function indexRefReplacement(name) {
152
+ const memo = refDecisionMemo.get(name);
153
+ if (memo !== undefined)
154
+ return memo;
155
+ let out = null;
156
+ if (name !== 'INDEX' && !name.endsWith('/INDEX')) {
157
+ const hit = resolveOne(name);
158
+ if (hit !== undefined && basename(hit.path) === 'INDEX.md') {
159
+ const explicit = resolveOne(`${name}/INDEX`);
160
+ if (explicit !== undefined && explicit.path === hit.path)
161
+ out = `${name}/INDEX`;
162
+ }
163
+ }
164
+ refDecisionMemo.set(name, out);
165
+ return out;
166
+ }
167
+ function rewriteBareDirRefs(body) {
168
+ const links = findDocLinks(body);
169
+ if (links.length === 0)
170
+ return body;
171
+ let out = '';
172
+ let pos = 0;
173
+ for (const link of links) {
174
+ const repl = indexRefReplacement(link.name);
175
+ if (repl === null)
176
+ continue;
177
+ out += body.slice(pos, link.start) + `[[${repl}]]`;
178
+ pos = link.end;
179
+ }
180
+ return out + body.slice(pos);
181
+ }
182
+ function deepEqual(a, b) {
183
+ if (a === b)
184
+ return true;
185
+ if (Array.isArray(a) || Array.isArray(b)) {
186
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
187
+ return false;
188
+ return a.every((v, i) => deepEqual(v, b[i]));
189
+ }
190
+ if (a !== null && b !== null && typeof a === 'object' && typeof b === 'object') {
191
+ const ak = Object.keys(a);
192
+ const bk = Object.keys(b);
193
+ if (ak.length !== bk.length)
194
+ return false;
195
+ return ak.every((k) => deepEqual(a[k], b[k]));
196
+ }
197
+ return false;
198
+ }
199
+ function migratedYaml(rawYaml, entries, docPath) {
200
+ const ydoc = parseDocument(rawYaml);
201
+ if (ydoc.errors.length > 0 || !isMap(ydoc.contents)) {
202
+ throw new Error(`surfaces migration: unparseable frontmatter in ${docPath}`);
203
+ }
204
+ // Whole-line spans of the dead top-level keys (multi-line values included:
205
+ // the value's node-end bounds its last continuation line).
206
+ const spans = [];
207
+ for (const item of ydoc.contents.items) {
208
+ if (!OLD_FIELDS.includes(String(item.key)))
209
+ continue;
210
+ const keyRange = item.key.range;
211
+ const valueRange = item.value?.range;
212
+ if (keyRange === undefined)
213
+ throw new Error(`surfaces migration: rangeless key in ${docPath}`);
214
+ const start = rawYaml.lastIndexOf('\n', keyRange[0] - 1) + 1;
215
+ let end = valueRange?.[2] ?? keyRange[2];
216
+ // Consume through end-of-line unless a block value already owns its newline.
217
+ if (!(end > 0 && rawYaml[end - 1] === '\n')) {
218
+ while (end < rawYaml.length && (rawYaml[end] === ' ' || rawYaml[end] === '\t' || rawYaml[end] === '\r'))
219
+ end++;
220
+ if (rawYaml[end] === '\n')
221
+ end++;
222
+ }
223
+ spans.push([start, end]);
224
+ }
225
+ spans.sort((a, b) => b[0] - a[0]);
226
+ let out = rawYaml;
227
+ for (const [s, e] of spans)
228
+ out = out.slice(0, s) + out.slice(e);
229
+ if (entries.length > 0) {
230
+ // lineWidth 0: render each entry's scalars on one line, never folded.
231
+ const block = stringify({ surfaces: entries }, { lineWidth: 0 });
232
+ out = out === '' ? block : (out.endsWith('\n') ? out : out + '\n') + block;
233
+ }
234
+ out = out.replace(/\r?\n$/, '');
235
+ const expected = { ...parseYaml(rawYaml) };
236
+ for (const k of OLD_FIELDS)
237
+ delete expected[k];
238
+ if (entries.length > 0)
239
+ expected['surfaces'] = entries;
240
+ const actual = parseYaml(out === '' ? '{}' : out);
241
+ if (!deepEqual(actual ?? {}, expected)) {
242
+ throw new Error(`surfaces migration: spliced frontmatter does not round-trip in ${docPath}`);
243
+ }
244
+ return out;
245
+ }
246
+ function rebuildSource(d, newYaml, newBody) {
247
+ const { doc } = d;
248
+ const bodyStart = doc.source.length - doc.body.length;
249
+ const fmBlock = doc.source.slice(0, bodyStart);
250
+ if (newYaml === null)
251
+ return fmBlock + newBody;
252
+ const { raw } = parseFrontmatterGeneric(doc.source);
253
+ const at = fmBlock.indexOf(raw);
254
+ if (raw === '' || at < 0)
255
+ throw new Error(`surfaces migration: cannot locate frontmatter block in ${doc.path}`);
256
+ return fmBlock.slice(0, at) + newYaml + fmBlock.slice(at + raw.length) + newBody;
257
+ }
258
+ export const surfacesFrontmatterMigration = {
259
+ lane: 'convergent',
260
+ description: 'surfaces frontmatter: fold visibility/applies-to/read-when routing into surfaces entries; rewrite bare-directory [[refs]] to [[dir/INDEX]]',
261
+ apply(store) {
262
+ const docs = store.docs.map(classify).filter((d) => d !== null);
263
+ const ceiling = buildCeiling(docs);
264
+ const changes = [];
265
+ for (const d of docs) {
266
+ const newBody = rewriteBareDirRefs(d.doc.body);
267
+ if (!d.hasOldFields && newBody === d.doc.body)
268
+ continue;
269
+ const newYaml = d.hasOldFields
270
+ ? migratedYaml(parseFrontmatterGeneric(d.doc.source).raw, planEntries(d, effectiveBootRung(d, ceiling)), d.doc.path)
271
+ : null;
272
+ changes.push({ path: d.doc.path, after: rebuildSource(d, newYaml, newBody) });
273
+ }
274
+ return changes;
275
+ },
276
+ };
@@ -0,0 +1,31 @@
1
+ import type { StateMigration, StoreSnapshot } from './types.js';
2
+ /** A doc excluded from the snapshot because its frontmatter is not valid YAML.
3
+ * Per-doc throw isolation at the collection layer: the bad file is named and
4
+ * the rest of the store still migrates. */
5
+ export interface SkippedDoc {
6
+ relPath: string;
7
+ error: string;
8
+ }
9
+ export interface ConvergentRunResult {
10
+ root: string;
11
+ /** One row per migration that planned real changes, in registry order. */
12
+ applied: {
13
+ migration: string;
14
+ files: string[];
15
+ }[];
16
+ skipped: SkippedDoc[];
17
+ }
18
+ /** Snapshot every parseable `.md` under `root` (hidden dirs — `.history`
19
+ * sidecars and the like — skipped, mirroring substrate doc enumeration). */
20
+ export declare function loadStoreSnapshot(root: string): {
21
+ snapshot: StoreSnapshot;
22
+ skipped: SkippedDoc[];
23
+ };
24
+ /** Run every registered convergent migration over the store at `root`, in
25
+ * registry order. No-op changes (identical text) are filtered; each real
26
+ * change is written atomically unless `dryRun`. Journaled migrations in the
27
+ * list are the db chain's business and are skipped here. */
28
+ export declare function runConvergentMigrations(root: string, opts?: {
29
+ dryRun?: boolean;
30
+ migrations?: readonly StateMigration[];
31
+ }): ConvergentRunResult;
@@ -0,0 +1,71 @@
1
+ // The convergent-lane runner: snapshot one memory store, let each registered
2
+ // convergent migration plan full-text rewrites, write them atomically.
3
+ // Convergence, not journaling — every run re-scans the store and rewrites
4
+ // whatever still matches an old shape, so a doc that arrives later (sync,
5
+ // restore, hand-edit) is caught by the next run.
6
+ import { relative, sep } from 'node:path';
7
+ import { atomicWriteText, readText, walkFiles } from '../core/fs-utils.js';
8
+ import { parseFrontmatterGeneric } from '../core/frontmatter.js';
9
+ import { STATE_MIGRATIONS } from './registry.js';
10
+ function parseDoc(root, file) {
11
+ const relPath = relative(root, file).split(sep).join('/');
12
+ const source = readText(file);
13
+ try {
14
+ const { data, body } = parseFrontmatterGeneric(source);
15
+ return { doc: { path: file, relPath, source, frontmatter: data, body } };
16
+ }
17
+ catch (e) {
18
+ return { error: (e instanceof Error ? e.message : String(e)).split('\n')[0] };
19
+ }
20
+ }
21
+ /** Snapshot every parseable `.md` under `root` (hidden dirs — `.history`
22
+ * sidecars and the like — skipped, mirroring substrate doc enumeration). */
23
+ export function loadStoreSnapshot(root) {
24
+ const docs = [];
25
+ const skipped = [];
26
+ for (const file of walkFiles(root, (n) => n.endsWith('.md'), (d) => d.startsWith('.'))) {
27
+ const parsed = parseDoc(root, file);
28
+ if ('doc' in parsed)
29
+ docs.push(parsed.doc);
30
+ else
31
+ skipped.push({ relPath: relative(root, file).split(sep).join('/'), error: parsed.error });
32
+ }
33
+ return { snapshot: { root, docs }, skipped };
34
+ }
35
+ /** Fold one applied change back into the in-memory snapshot so the NEXT
36
+ * migration plans against post-change state (dry runs included — that is what
37
+ * makes a dry-run diff equal the real run's writes). A migration that emits
38
+ * invalid YAML is a defect and throws here, before anything else compounds
39
+ * on its output. */
40
+ function refreshDoc(snapshot, change) {
41
+ const relPath = relative(snapshot.root, change.path).split(sep).join('/');
42
+ const { data, body } = parseFrontmatterGeneric(change.after);
43
+ const doc = { path: change.path, relPath, source: change.after, frontmatter: data, body };
44
+ const i = snapshot.docs.findIndex((d) => d.path === change.path);
45
+ if (i >= 0)
46
+ snapshot.docs[i] = doc;
47
+ else
48
+ snapshot.docs.push(doc);
49
+ }
50
+ /** Run every registered convergent migration over the store at `root`, in
51
+ * registry order. No-op changes (identical text) are filtered; each real
52
+ * change is written atomically unless `dryRun`. Journaled migrations in the
53
+ * list are the db chain's business and are skipped here. */
54
+ export function runConvergentMigrations(root, opts = {}) {
55
+ const migrations = (opts.migrations ?? STATE_MIGRATIONS).filter((m) => m.lane === 'convergent');
56
+ const { snapshot, skipped } = loadStoreSnapshot(root);
57
+ const applied = [];
58
+ for (const migration of migrations) {
59
+ const bySource = new Map(snapshot.docs.map((d) => [d.path, d.source]));
60
+ const changes = migration.apply(snapshot).filter((c) => bySource.get(c.path) !== c.after);
61
+ if (changes.length === 0)
62
+ continue;
63
+ for (const change of changes) {
64
+ if (opts.dryRun !== true)
65
+ atomicWriteText(change.path, change.after);
66
+ refreshDoc(snapshot, change);
67
+ }
68
+ applied.push({ migration: migration.description, files: changes.map((c) => c.path) });
69
+ }
70
+ return { root, applied, skipped };
71
+ }
@@ -0,0 +1,2 @@
1
+ import type { StateMigration } from './types.js';
2
+ export declare const STATE_MIGRATIONS: readonly StateMigration[];
@@ -0,0 +1,19 @@
1
+ // The ordered state-migration registry — ONE list for both lanes, so a cut
2
+ // that spans canvas-db and document stores ships as adjacent entries.
3
+ //
4
+ // Authoring contract (each migration is one file, `NNN-<desc>.ts`, appended
5
+ // here in order):
6
+ // • Idempotent. A convergent migration re-runs on every `crtr sys migrate`,
7
+ // so applying it to an already-migrated store must plan zero changes. A
8
+ // journaled migration runs once per database via user_version, but its
9
+ // body must still tolerate a crash-resume re-run of any filesystem work.
10
+ // • Journaled filesystem steps are crash-resumable: fs writes happen outside
11
+ // the db transaction, so a step interrupted mid-fs-work must complete
12
+ // (never corrupt) when the chain re-runs it.
13
+ // • Migration files import ONLY fs/frontmatter utilities (the fs-only
14
+ // memory resolver included) and these types — never canvas-db or canvas
15
+ // accessors. `DatabaseSync` arrives as a parameter; import it type-only.
16
+ // This keeps the registry legal in every CLI leaf's import graph (the
17
+ // build's V-1 gate).
18
+ import { surfacesFrontmatterMigration } from './001-surfaces-frontmatter.js';
19
+ export const STATE_MIGRATIONS = [surfacesFrontmatterMigration];
@@ -0,0 +1,40 @@
1
+ import type { DatabaseSync } from 'node:sqlite';
2
+ export interface JournaledMigration {
3
+ lane: 'journaled';
4
+ description: string;
5
+ /** Runs inside the canvas-db migration transaction. Filesystem work under
6
+ * `canvasHome` is OUTSIDE that transaction — it must be crash-resumable
7
+ * (a re-run after a partial application completes rather than corrupts). */
8
+ apply(db: DatabaseSync, canvasHome: string): void;
9
+ }
10
+ export interface ConvergentMigration {
11
+ lane: 'convergent';
12
+ description: string;
13
+ /** Pure planning: return the full new text of each doc to rewrite. The
14
+ * runner filters no-op changes and performs the writes. */
15
+ apply(store: StoreSnapshot): DocChange[];
16
+ }
17
+ export type StateMigration = JournaledMigration | ConvergentMigration;
18
+ /** One parsed markdown doc in a store snapshot. */
19
+ export interface StoreDoc {
20
+ /** Absolute file path. */
21
+ path: string;
22
+ /** Path relative to the store root, `/`-separated. */
23
+ relPath: string;
24
+ /** Full file text as read from disk. */
25
+ source: string;
26
+ /** Parsed frontmatter record (null when the file has no frontmatter block). */
27
+ frontmatter: Record<string, unknown> | null;
28
+ body: string;
29
+ }
30
+ /** Every parseable `.md` doc under one memory-store root. */
31
+ export interface StoreSnapshot {
32
+ root: string;
33
+ docs: StoreDoc[];
34
+ }
35
+ /** One planned rewrite: the full new file text for `path`. */
36
+ export interface DocChange {
37
+ path: string;
38
+ /** Complete replacement text for the file. */
39
+ after: string;
40
+ }
@@ -0,0 +1,11 @@
1
+ // State-migration shapes. Two lanes, split by which store owns the state:
2
+ //
3
+ // journaled — canvas-db state. Runs inside the db's ordered migration chain
4
+ // (core/canvas/db.ts appends these to MIGRATIONS), so each step
5
+ // gets the chain's BEGIN IMMEDIATE/COMMIT + user_version gating
6
+ // and runs exactly once per database.
7
+ // convergent — on-disk memory documents. No journal exists over a user's
8
+ // stores (files appear, sync in, get hand-edited), so these
9
+ // CONVERGE instead: every run re-scans and rewrites whatever
10
+ // still matches the old shape. `crtr sys migrate` is the runner.
11
+ export {};