@north-light/crouter 0.3.208 → 0.3.210
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/client.d.ts +3 -1
- package/dist/api/client.js +4 -0
- package/dist/api/dto/broker-ops.d.ts +2 -12
- package/dist/api/dto/nodes.d.ts +16 -0
- package/dist/api/routes.d.ts +1 -0
- package/dist/api/routes.js +1 -0
- package/dist/builtin-memory/00-runtime-base.md +3 -2
- package/dist/builtin-memory/01-spine/00-has-manager.md +3 -2
- package/dist/builtin-memory/01-spine/01-no-manager.md +3 -2
- package/dist/builtin-memory/02-lifecycle/00-terminal.md +3 -2
- package/dist/builtin-memory/02-lifecycle/01-resident.md +3 -2
- package/dist/builtin-memory/04-base-worker.md +3 -2
- package/dist/builtin-memory/04-orchestration-kernel.md +3 -2
- package/dist/builtin-memory/05-kinds/advisor/00-base.md +3 -2
- package/dist/builtin-memory/05-kinds/advisor/01-orchestrator.md +3 -2
- package/dist/builtin-memory/05-kinds/design/00-base.md +3 -2
- package/dist/builtin-memory/05-kinds/design/01-orchestrator.md +3 -2
- package/dist/builtin-memory/05-kinds/developer/00-base.md +3 -2
- package/dist/builtin-memory/05-kinds/developer/01-orchestrator.md +3 -2
- package/dist/builtin-memory/05-kinds/explore/00-base.md +3 -2
- package/dist/builtin-memory/05-kinds/explore/01-orchestrator.md +3 -2
- package/dist/builtin-memory/05-kinds/general/00-base.md +3 -2
- package/dist/builtin-memory/05-kinds/general/01-orchestrator.md +3 -2
- package/dist/builtin-memory/05-kinds/plan/00-base.md +3 -2
- package/dist/builtin-memory/05-kinds/plan/01-orchestrator.md +3 -2
- package/dist/builtin-memory/05-kinds/plan/reviewers/00-base.md +3 -2
- package/dist/builtin-memory/05-kinds/plan/reviewers/architecture-fit.md +3 -2
- package/dist/builtin-memory/05-kinds/plan/reviewers/code-smells.md +3 -2
- package/dist/builtin-memory/05-kinds/plan/reviewers/pattern-consistency.md +3 -2
- package/dist/builtin-memory/05-kinds/plan/reviewers/requirements-coverage.md +3 -2
- package/dist/builtin-memory/05-kinds/plan/reviewers/security.md +3 -2
- package/dist/builtin-memory/05-kinds/review/00-base.md +3 -2
- package/dist/builtin-memory/05-kinds/review/01-orchestrator.md +3 -2
- package/dist/builtin-memory/05-kinds/review/companion/00-base.md +3 -2
- package/dist/builtin-memory/05-kinds/spec/00-base.md +3 -2
- package/dist/builtin-memory/05-kinds/spec/01-orchestrator.md +3 -2
- package/dist/builtin-memory/05-kinds/spec/requirements.md +3 -2
- package/dist/builtin-memory/advisor/council.md +0 -2
- package/dist/builtin-memory/design.md +3 -2
- package/dist/builtin-memory/development.md +3 -2
- package/dist/builtin-memory/insights/capture.md +1 -3
- package/dist/builtin-memory/insights/init.md +1 -3
- package/dist/builtin-memory/insights/listen.md +3 -2
- package/dist/builtin-memory/internal/INDEX.md +5 -4
- package/dist/builtin-memory/internal/agent-shaping.md +5 -4
- package/dist/builtin-memory/internal/examples/INDEX.md +3 -2
- package/dist/builtin-memory/internal/examples/imessage-assistant.md +3 -2
- package/dist/builtin-memory/internal/marketplaces.md +3 -2
- package/dist/builtin-memory/internal/memory-loading.md +31 -20
- package/dist/builtin-memory/internal/nodes-and-canvas.md +3 -2
- package/dist/builtin-memory/internal/plugins.md +7 -6
- package/dist/builtin-memory/internal/storage-tiers.md +3 -2
- package/dist/builtin-memory/plan/roadmap.md +3 -2
- package/dist/builtin-memory/spec/guide.md +0 -2
- package/dist/builtin-memory/spec/requirements.md +0 -2
- package/dist/builtin-memory/spec/roadmap.md +3 -2
- package/dist/builtin-memory/testing.md +1 -3
- package/dist/builtin-memory/wedged-child-on-runaway-bash.md +3 -2
- package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +1 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/frontmatter-rules/index.ts +3 -3
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/memory-slash-commands.ts +1 -4
- package/dist/clients/attach/viewer.js +366 -366
- package/dist/commands/memory/delete.js +3 -18
- package/dist/commands/memory/edit.js +23 -30
- package/dist/commands/memory/history.js +1 -1
- package/dist/commands/memory/lint.d.ts +7 -8
- package/dist/commands/memory/lint.js +178 -132
- package/dist/commands/memory/list.d.ts +0 -1
- package/dist/commands/memory/list.js +2 -12
- package/dist/commands/memory/move.d.ts +1 -0
- package/dist/commands/memory/move.js +195 -0
- package/dist/commands/memory/read.js +135 -141
- package/dist/commands/memory/shared.d.ts +18 -17
- package/dist/commands/memory/shared.js +93 -39
- package/dist/commands/memory/write.js +23 -33
- package/dist/commands/memory.js +5 -4
- package/dist/commands/pkg/browse/catalog.js +2 -4
- package/dist/commands/pkg/browse/doc-view.js +17 -11
- package/dist/commands/pkg/browse/model.d.ts +7 -9
- package/dist/commands/sys/migrate.d.ts +1 -0
- package/dist/commands/sys/migrate.js +106 -0
- package/dist/commands/sys/sync-deps.js +5 -10
- package/dist/commands/sys/sync-project-guidance.js +36 -16
- package/dist/commands/sys/sync-skills.js +8 -4
- package/dist/commands/sys.js +3 -2
- package/dist/core/__tests__/inline-memory-refs.test.js +8 -5
- package/dist/core/__tests__/memory-resolver-precedence.test.js +5 -4
- package/dist/core/__tests__/nested-store-discovery.test.js +1 -3
- package/dist/core/__tests__/on-read-crouter-home-fence.test.js +3 -3
- package/dist/core/__tests__/on-read-dedup-resume.test.js +12 -12
- package/dist/core/__tests__/on-read-nested-store.test.js +23 -20
- package/dist/core/canvas/db.d.ts +3 -1
- package/dist/core/canvas/db.js +12 -2
- package/dist/core/memory/history.d.ts +4 -1
- package/dist/core/memory/history.js +1 -0
- package/dist/core/memory/inline-ref-inventory.d.ts +5 -4
- package/dist/core/memory/inline-ref-inventory.js +23 -19
- package/dist/core/memory-resolver.d.ts +4 -4
- package/dist/core/memory-resolver.js +16 -43
- package/dist/core/runtime/bearings.d.ts +4 -3
- package/dist/core/runtime/bearings.js +4 -4
- package/dist/core/runtime/broker-extension-render.d.ts +3 -2
- package/dist/core/runtime/broker-extension-render.js +3 -3
- package/dist/core/runtime/memory.js +2 -3
- package/dist/core/substrate/index.d.ts +7 -4
- package/dist/core/substrate/index.js +6 -4
- package/dist/core/substrate/injected-store.d.ts +24 -12
- package/dist/core/substrate/injected-store.js +80 -33
- package/dist/core/substrate/listings.d.ts +21 -0
- package/dist/core/substrate/listings.js +88 -0
- package/dist/core/substrate/on-read-node.d.ts +5 -5
- package/dist/core/substrate/on-read-node.js +4 -5
- package/dist/core/substrate/on-read.d.ts +25 -4
- package/dist/core/substrate/on-read.js +81 -102
- package/dist/core/substrate/render-node.d.ts +5 -2
- package/dist/core/substrate/render-node.js +5 -3
- package/dist/core/substrate/render.d.ts +9 -8
- package/dist/core/substrate/render.js +104 -96
- package/dist/core/substrate/schema.d.ts +34 -18
- package/dist/core/substrate/schema.js +75 -32
- package/dist/core/substrate/surface-match.d.ts +32 -0
- package/dist/core/substrate/surface-match.js +179 -0
- package/dist/daemon/api/handlers/nodes.js +9 -0
- package/dist/migrations/001-surfaces-frontmatter.d.ts +2 -0
- package/dist/migrations/001-surfaces-frontmatter.js +276 -0
- package/dist/migrations/convergent.d.ts +31 -0
- package/dist/migrations/convergent.js +71 -0
- package/dist/migrations/registry.d.ts +2 -0
- package/dist/migrations/registry.js +19 -0
- package/dist/migrations/types.d.ts +40 -0
- package/dist/migrations/types.js +11 -0
- package/dist/pi-extensions/canvas-context-intro.d.ts +2 -1
- package/dist/pi-extensions/canvas-doc-substrate.d.ts +2 -1
- package/dist/pi-extensions/canvas-doc-substrate.js +57 -34
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
- package/dist/core/substrate/ceiling.d.ts +0 -17
- 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,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,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 {};
|