@north-light/crouter 0.3.207 → 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.
- 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/__tests__/group-activity.test.js +8 -1
- package/dist/clients/attach/render/card-presentation.d.ts +31 -0
- package/dist/clients/attach/render/card-presentation.js +163 -0
- package/dist/clients/attach/render/chat-view.d.ts +0 -2
- package/dist/clients/attach/render/chat-view.js +12 -23
- package/dist/clients/attach/render/context-message.d.ts +5 -9
- package/dist/clients/attach/render/context-message.js +20 -43
- package/dist/clients/attach/render/group-activity.d.ts +28 -4
- package/dist/clients/attach/render/group-activity.js +65 -11
- package/dist/clients/attach/render/group-recap.d.ts +3 -0
- package/dist/clients/attach/render/group-recap.js +17 -0
- package/dist/clients/attach/viewer.js +516 -516
- 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__/canvas-inbox-watcher.test.js +1 -1
- package/dist/core/__tests__/helpers/broker-clients.d.ts +3 -1
- package/dist/core/__tests__/helpers/broker-clients.js +7 -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/__tests__/seam/broker-attach-multiclient.test.js +9 -0
- package/dist/core/__tests__/serial/broker-snapshot-history.test.js +52 -1
- package/dist/core/__tests__/serial/flagship-lifecycle.test.js +1 -1
- 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/fault-retry.js +2 -7
- package/dist/core/runtime/broker/frame-dispatch.js +1 -1
- package/dist/core/runtime/broker/tool-groups.js +7 -6
- 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/broker.js +4 -4
- package/dist/core/runtime/kickoff.js +4 -10
- package/dist/core/runtime/memory.js +2 -3
- package/dist/core/runtime/node-read.js +7 -1
- package/dist/core/runtime/stop-guard.js +3 -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/daemon/review/comment-notify.js +1 -8
- package/dist/daemon/review/deliver.js +1 -1
- package/dist/daemon/review/finish.js +1 -1
- 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/__tests__/canvas-stophook-agentend.test.js +1 -1
- 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/dist/pi-extensions/canvas-review-boundary.js +1 -1
- package/dist/shared/__tests__/generated-context-grammar.test.js +19 -22
- package/dist/shared/generated-context.d.ts +0 -18
- package/dist/shared/generated-context.js +16 -120
- package/dist/shared/tool-groups.d.ts +1 -0
- package/dist/shared/tool-groups.js +7 -0
- 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,32 @@
|
|
|
1
|
+
import { type Rung, type SubstrateDoc, type SurfaceEntry } from './schema.js';
|
|
2
|
+
/** The project directory owning a doc's store: the path segment stack above
|
|
3
|
+
* the doc's `.crouter` dir. `null` when the doc's path carries no `.crouter`
|
|
4
|
+
* segment (builtin docs). */
|
|
5
|
+
export declare function owningRootOf(doc: Pick<SubstrateDoc, 'path'>): string | null;
|
|
6
|
+
/** True when the (already-realpathed) file sits beneath the doc's store's
|
|
7
|
+
* owning directory. Callers additionally guard on `doc.scope === 'project'`:
|
|
8
|
+
* a profile store's path computes an owning root of `~`, and user/builtin/
|
|
9
|
+
* node docs likewise have no project-owning dir, so a `./`-anchored glob
|
|
10
|
+
* outside a project store must never match. */
|
|
11
|
+
export declare function underOwningRoot(doc: Pick<SubstrateDoc, 'path'>, absFile: string): boolean;
|
|
12
|
+
/** Does a `read` entry fit the read file? `absReadFile` must already be
|
|
13
|
+
* realpathed. `readFrontmatter` is the read file's own parsed YAML (empty
|
|
14
|
+
* for non-markdown files); an empty record never satisfies
|
|
15
|
+
* `matchFrontmatter`. */
|
|
16
|
+
export declare function matchesReadEntry(entry: SurfaceEntry, doc: Pick<SubstrateDoc, 'path' | 'scope'>, absReadFile: string, readFrontmatter: Record<string, unknown>): boolean;
|
|
17
|
+
/** Does a `memory-read` entry on the doc named `carrierName` fit a resolved
|
|
18
|
+
* read of a doc known by `targetNames`? */
|
|
19
|
+
export declare function matchesMemoryReadEntry(entry: SurfaceEntry, carrierName: string, targetNames: string[]): boolean;
|
|
20
|
+
/** Does a `command` entry fit the executed command string? */
|
|
21
|
+
export declare function matchesCommandEntry(entry: SurfaceEntry, command: string): boolean;
|
|
22
|
+
/** The doc's delivery rung for a read of `absReadFile` (already realpathed). */
|
|
23
|
+
export declare function readDeliveryRung(doc: Pick<SubstrateDoc, 'surfaces' | 'path' | 'scope'>, absReadFile: string, readFrontmatter: Record<string, unknown>): Rung;
|
|
24
|
+
/** The doc's workspace-open rung. Presence of an entry is the match; callers
|
|
25
|
+
* scope the corpus to project stores mounted by the subject's cwd/profile. */
|
|
26
|
+
export declare function workspaceOpenRung(doc: Pick<SubstrateDoc, 'surfaces'>): Rung;
|
|
27
|
+
/** The doc's delivery rung for a `crtr memory read` that resolved a doc
|
|
28
|
+
* known by `targetNames` (canonical name, plus the mount-stripped name for
|
|
29
|
+
* plugin-mounted docs). The doc's own `name` anchors `./` globs. */
|
|
30
|
+
export declare function memoryReadDeliveryRung(doc: Pick<SubstrateDoc, 'surfaces' | 'name'>, targetNames: string[]): Rung;
|
|
31
|
+
/** The doc's delivery rung for an executed command string. */
|
|
32
|
+
export declare function commandDeliveryRung(doc: Pick<SubstrateDoc, 'surfaces'>, command: string): Rung;
|
|
@@ -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 },
|
|
@@ -70,14 +70,7 @@ export async function notifyComment(args) {
|
|
|
70
70
|
comment_id: args.comment.comment_id,
|
|
71
71
|
verb: args.verb,
|
|
72
72
|
},
|
|
73
|
-
runtime: {
|
|
74
|
-
kind: 'review-comment',
|
|
75
|
-
facts: {
|
|
76
|
-
review_id: args.review.review_id,
|
|
77
|
-
comment_id: args.comment.comment_id,
|
|
78
|
-
verb: args.verb,
|
|
79
|
-
},
|
|
80
|
-
},
|
|
73
|
+
runtime: { kind: 'review-comment', facts: {} },
|
|
81
74
|
});
|
|
82
75
|
}
|
|
83
76
|
catch (error) {
|
|
@@ -9,7 +9,7 @@ function approvalBody(review, resultPath) {
|
|
|
9
9
|
}
|
|
10
10
|
/** Render the immutable approval fact without replaying comment text. */
|
|
11
11
|
export function renderApprovalMessage(review, resultPath) {
|
|
12
|
-
return formatCard('review-approval', {
|
|
12
|
+
return formatCard('review-approval', {}, approvalBody(review, resultPath));
|
|
13
13
|
}
|
|
14
14
|
function recordedFailedFanout(review, reportBasename) {
|
|
15
15
|
const error = review.delivery_error;
|
|
@@ -164,7 +164,7 @@ async function noteSubmitQueued(review) {
|
|
|
164
164
|
mode: 'quiet',
|
|
165
165
|
label: 'human review submitted — result to follow',
|
|
166
166
|
data: { review_id: review.review_id },
|
|
167
|
-
runtime: { kind: 'review-queued', facts: {
|
|
167
|
+
runtime: { kind: 'review-queued', facts: {} },
|
|
168
168
|
body: `The human submitted their review of \`${review.file}\`; the document is still being edited. `
|
|
169
169
|
+ "You'll be notified with the result once the review completes. Nothing is needed from you now.",
|
|
170
170
|
});
|
|
@@ -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
|
+
}
|