@crouton-kit/crouter 0.3.48 → 0.3.50
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/builtin-pi-packages/pi-crtr-extensions/README.md +1 -1
- package/dist/clients/attach/attach-cmd.js +825 -863
- package/dist/clients/attach/titled-editor.js +6 -3
- package/dist/commands/human/prompts.js +1 -1
- package/dist/commands/human/shared.js +7 -3
- package/dist/commands/memory/write.js +2 -0
- package/dist/commands/node.js +96 -11
- package/dist/commands/profile/default.d.ts +2 -0
- package/dist/commands/profile/default.js +143 -0
- package/dist/commands/profile/new.js +3 -3
- package/dist/commands/profile/project.d.ts +2 -0
- package/dist/commands/profile/project.js +97 -0
- package/dist/commands/profile/show.js +1 -1
- package/dist/commands/profile.js +10 -11
- package/dist/commands/sys/__tests__/sync-import.test.js +156 -3
- package/dist/commands/sys/sync.js +82 -25
- package/dist/core/__tests__/broker-sdk-wiring.test.js +4 -7
- package/dist/core/__tests__/context-intro.test.js +15 -7
- package/dist/core/__tests__/memory-resolver-precedence.test.d.ts +1 -0
- package/dist/core/__tests__/memory-resolver-precedence.test.js +144 -0
- package/dist/core/__tests__/on-read-identity.test.d.ts +1 -0
- package/dist/core/__tests__/on-read-identity.test.js +68 -0
- package/dist/core/fs-utils.d.ts +1 -1
- package/dist/core/fs-utils.js +5 -3
- package/dist/core/memory-resolver.d.ts +27 -11
- package/dist/core/memory-resolver.js +105 -109
- package/dist/core/profiles/default-binding.d.ts +10 -0
- package/dist/core/profiles/default-binding.js +50 -0
- package/dist/core/profiles/select.d.ts +13 -1
- package/dist/core/profiles/select.js +92 -26
- package/dist/core/runtime/bearings.d.ts +4 -9
- package/dist/core/runtime/bearings.js +10 -17
- package/dist/core/runtime/front-door.js +11 -4
- package/dist/core/runtime/revive.js +2 -1
- package/dist/core/runtime/spawn.d.ts +4 -0
- package/dist/core/runtime/spawn.js +1 -1
- package/dist/core/substrate/index.d.ts +1 -1
- package/dist/core/substrate/index.js +3 -1
- package/dist/core/substrate/on-read.js +14 -9
- package/dist/core/substrate/render.js +7 -3
- package/dist/core/substrate/schema.d.ts +8 -2
- package/dist/core/substrate/schema.js +19 -2
- package/dist/daemon/crtrd.js +44 -2
- package/dist/daemon/manage.d.ts +20 -0
- package/dist/daemon/manage.js +64 -2
- package/package.json +3 -3
- package/dist/commands/profile/add-project.d.ts +0 -1
- package/dist/commands/profile/add-project.js +0 -42
- package/dist/commands/profile/remove-project.d.ts +0 -1
- package/dist/commands/profile/remove-project.js +0 -42
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// The on-read POSITIONAL loader resolves a doc's identity with the SAME
|
|
2
|
+
// explicit-name-then-path-fallback rule the boot resolver uses (schema.ts's
|
|
3
|
+
// resolveDocName), never deriving `name` from the physical path alone.
|
|
4
|
+
//
|
|
5
|
+
// A migrated AGENTS.md doc (`crtr sys sync`'s CLAUDE.md/AGENTS.md conversion)
|
|
6
|
+
// is written at `file-read-visibility: content` with an explicit frontmatter
|
|
7
|
+
// `name` (the project label, e.g. `crouter`) so sibling project roots do not
|
|
8
|
+
// collapse onto the shared `AGENTS` leaf. `loadPositionalDoc` must inject the
|
|
9
|
+
// doc under that explicit name, not the path-derived `AGENTS`.
|
|
10
|
+
//
|
|
11
|
+
// Run: node --import tsx/esm --test src/core/__tests__/on-read-identity.test.ts
|
|
12
|
+
import { test, before, beforeEach, after } from 'node:test';
|
|
13
|
+
import assert from 'node:assert/strict';
|
|
14
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { tmpdir } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { closeDb } from '../canvas/db.js';
|
|
18
|
+
import { resetScopeCache } from '../scope.js';
|
|
19
|
+
import { spawnNode } from '../runtime/nodes.js';
|
|
20
|
+
import { renderOnReadDocs } from '../substrate/on-read.js';
|
|
21
|
+
let home;
|
|
22
|
+
let work;
|
|
23
|
+
let prevHomeEnv;
|
|
24
|
+
before(() => {
|
|
25
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-onread-identity-home-'));
|
|
26
|
+
process.env['CRTR_HOME'] = home;
|
|
27
|
+
prevHomeEnv = process.env['HOME'];
|
|
28
|
+
});
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
closeDb();
|
|
31
|
+
resetScopeCache();
|
|
32
|
+
rmSync(home, { recursive: true, force: true });
|
|
33
|
+
if (prevHomeEnv === undefined)
|
|
34
|
+
delete process.env['HOME'];
|
|
35
|
+
else
|
|
36
|
+
process.env['HOME'] = prevHomeEnv;
|
|
37
|
+
work = mkdtempSync(join(tmpdir(), 'crtr-onread-identity-work-'));
|
|
38
|
+
});
|
|
39
|
+
after(() => {
|
|
40
|
+
closeDb();
|
|
41
|
+
resetScopeCache();
|
|
42
|
+
rmSync(home, { recursive: true, force: true });
|
|
43
|
+
rmSync(work, { recursive: true, force: true });
|
|
44
|
+
delete process.env['CRTR_HOME'];
|
|
45
|
+
if (prevHomeEnv === undefined)
|
|
46
|
+
delete process.env['HOME'];
|
|
47
|
+
else
|
|
48
|
+
process.env['HOME'] = prevHomeEnv;
|
|
49
|
+
});
|
|
50
|
+
test('a migrated AGENTS doc injects on-read under its explicit frontmatter name, not the path-derived one', () => {
|
|
51
|
+
const node = spawnNode({ kind: 'general', cwd: work, parent: null }).node_id;
|
|
52
|
+
// Shape of a `crtr sys sync` CLAUDE.md/AGENTS.md migration: physical filename
|
|
53
|
+
// stays AGENTS.md, but frontmatter carries the project's explicit identity.
|
|
54
|
+
const memDir = join(work, '.crouter', 'memory');
|
|
55
|
+
mkdirSync(memDir, { recursive: true });
|
|
56
|
+
writeFileSync(join(memDir, 'AGENTS.md'), '---\nkind: knowledge\n' +
|
|
57
|
+
'name: acme-project\n' +
|
|
58
|
+
'when-and-why-to-read: When working in acme-project, this knowledge should be read because it is the project\'s operating guide.\n' +
|
|
59
|
+
'file-read-visibility: content\n---\n' +
|
|
60
|
+
'ACME PROJECT OPERATING GUIDE\n');
|
|
61
|
+
const readFile = join(work, 'src', 'file.ts');
|
|
62
|
+
mkdirSync(join(work, 'src'), { recursive: true });
|
|
63
|
+
writeFileSync(readFile, 'export const x = 1;\n');
|
|
64
|
+
const rendered = renderOnReadDocs(node, readFile, new Set());
|
|
65
|
+
assert.ok(rendered.includes('ACME PROJECT OPERATING GUIDE'), 'the migrated doc fires positionally');
|
|
66
|
+
assert.ok(rendered.includes('<memory kind="knowledge" name="acme-project"'), `expected explicit frontmatter name "acme-project" in the injected envelope, got: ${rendered}`);
|
|
67
|
+
assert.ok(!rendered.includes('name="AGENTS"'), 'must not fall back to the path-derived name when frontmatter sets one');
|
|
68
|
+
});
|
package/dist/core/fs-utils.d.ts
CHANGED
|
@@ -15,5 +15,5 @@ export declare function linkOrCopy(target: string, linkPath: string, opts?: {
|
|
|
15
15
|
noSymlink?: boolean;
|
|
16
16
|
}): 'symlink' | 'copy';
|
|
17
17
|
export declare function readSymlinkTarget(path: string): string | null;
|
|
18
|
-
export declare function walkFiles(root: string, predicate?: (name: string) => boolean): string[];
|
|
18
|
+
export declare function walkFiles(root: string, predicate?: (name: string) => boolean, skipDir?: (name: string) => boolean): string[];
|
|
19
19
|
export declare function nowIso(): string;
|
package/dist/core/fs-utils.js
CHANGED
|
@@ -90,7 +90,7 @@ export function readSymlinkTarget(path) {
|
|
|
90
90
|
return null;
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
|
-
export function walkFiles(root, predicate = () => true) {
|
|
93
|
+
export function walkFiles(root, predicate = () => true, skipDir = () => false) {
|
|
94
94
|
const out = [];
|
|
95
95
|
if (!existsSync(root))
|
|
96
96
|
return out;
|
|
@@ -106,8 +106,10 @@ export function walkFiles(root, predicate = () => true) {
|
|
|
106
106
|
}
|
|
107
107
|
for (const e of entries) {
|
|
108
108
|
const full = join(dir, e.name);
|
|
109
|
-
if (e.isDirectory())
|
|
110
|
-
|
|
109
|
+
if (e.isDirectory()) {
|
|
110
|
+
if (!skipDir(e.name))
|
|
111
|
+
stack.push(full);
|
|
112
|
+
}
|
|
111
113
|
else if (e.isFile() && predicate(e.name))
|
|
112
114
|
out.push(full);
|
|
113
115
|
}
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import type { InstalledPlugin, Scope } from '../types.js';
|
|
2
2
|
/**
|
|
3
|
-
* Thin memory-document resolver for the document substrate.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
3
|
+
* Thin memory-document resolver for the document substrate. Precedence is the
|
|
4
|
+
* OUTER loop, not a global scan: scopes/sources are tried NEAREST-FIRST
|
|
5
|
+
* (project stack nearest > ... > profile > user > builtin), and WITHIN each
|
|
6
|
+
* source, in order: (1) exact substrate-identity match (`doc.name === query`
|
|
7
|
+
* — the explicit frontmatter `name`, or its path-derived fallback), (2) direct
|
|
8
|
+
* `memory/<name>.md` physical-path lookup (plus the bare-dir/bare-plugin-name
|
|
9
|
+
* → `INDEX.md` convenience neither identity nor a literal path expresses),
|
|
10
|
+
* (3) bare leaf-name fallback (final path segment only). The first hit wins,
|
|
11
|
+
* so a nearer source's match of ANY kind always beats a farther source's
|
|
12
|
+
* match of any kind — a farther-scope identity can never shadow a
|
|
13
|
+
* nearer-scope physical file. It returns the raw parsed frontmatter + body;
|
|
14
|
+
* it does NOT interpret the schema, kind, gate, or rungs — that is the
|
|
8
15
|
* schema/gate layer's job (callers filter by `frontmatter.kind`). Project
|
|
9
16
|
* resolution is a nearest-first stack of every ancestor `.crouter/` (widened by
|
|
10
17
|
* a selected profile's `projects` manifest entries — see `findProjectScopeRoots`
|
|
@@ -12,8 +19,9 @@ import type { InstalledPlugin, Scope } from '../types.js';
|
|
|
12
19
|
* (from `CRTR_PROFILE_ID`), and user/builtin remain singleton scopes.
|
|
13
20
|
*/
|
|
14
21
|
export interface MemoryDoc {
|
|
15
|
-
/**
|
|
16
|
-
*
|
|
22
|
+
/** Resolver identity: the doc's explicit frontmatter `name` when present,
|
|
23
|
+
* otherwise its normalized path under the scope's memory/ root — e.g.
|
|
24
|
+
* memory/taste/foo.md → "taste/foo". */
|
|
17
25
|
name: string;
|
|
18
26
|
scope: MemoryScope;
|
|
19
27
|
/** Absolute path to the resolved .md file. */
|
|
@@ -59,15 +67,23 @@ export declare function listPluginMemoryDocs(plugin: InstalledPlugin, scope: Mem
|
|
|
59
67
|
* caller's first-wins dedup. */
|
|
60
68
|
export declare function listAllMemoryDocs(scope?: MemoryScope, quiet?: boolean): MemoryDoc[];
|
|
61
69
|
/**
|
|
62
|
-
* Resolve a
|
|
70
|
+
* Resolve a memory document name to a single memory document.
|
|
63
71
|
*
|
|
64
72
|
* Accepted identifier forms:
|
|
65
73
|
* <name> — bare name; resolved by scope precedence project>user>builtin
|
|
66
74
|
* <scope>/<name> — pinned to one scope (user|project)
|
|
67
75
|
* `<name>` may carry topical subdirs (`taste/foo`); a bare leaf (`foo`) falls
|
|
68
|
-
* back to a last-segment match
|
|
76
|
+
* back to a last-segment match within the resolved scopes.
|
|
69
77
|
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
78
|
+
* Precedence is the outer loop: sources are visited nearest-first, and the
|
|
79
|
+
* FIRST source with any hit wins outright — a farther source is never even
|
|
80
|
+
* consulted once a nearer one matches. Within each source, in order: (1)
|
|
81
|
+
* exact substrate-identity match (`doc.name === query` — the explicit
|
|
82
|
+
* frontmatter `name`, else its normalized path-derived fallback, the SAME
|
|
83
|
+
* identity `listAllMemoryDocs` reports); (2) direct `memory/<name>.md`
|
|
84
|
+
* physical-path lookup (the bare-dir → INDEX.md and bare-plugin-name → plugin
|
|
85
|
+
* root INDEX.md conveniences neither identity nor a literal path expresses);
|
|
86
|
+
* (3) bare leaf-name fallback (final path segment), ambiguous only when two
|
|
87
|
+
* DIFFERENT identities in the SAME source share a leaf.
|
|
72
88
|
*/
|
|
73
89
|
export declare function resolveMemoryDoc(rawName: string, opts?: MemoryResolutionOpts): MemoryDoc;
|
|
@@ -6,7 +6,7 @@ import { listInstalledPlugins, listInstalledPluginsInRoot, parseSkillQualifier }
|
|
|
6
6
|
import { ambiguous, notFound, usage } from './errors.js';
|
|
7
7
|
import { warn } from './output.js';
|
|
8
8
|
import { pluginMemoryDir, projectScopeRoot, projectScopeRoots, scopeMemoryDir } from './scope.js';
|
|
9
|
-
import { normalizeDocName, normalizeNameSegment } from './substrate/schema.js';
|
|
9
|
+
import { normalizeDocName, normalizeNameSegment, resolveDocName } from './substrate/schema.js';
|
|
10
10
|
import { loadProfileManifest, profileMemoryDir } from './profiles/manifest.js';
|
|
11
11
|
/** Canonical, unambiguous identifier for a memory document: `<scope>/<name>`. */
|
|
12
12
|
export function memoryDocId(doc) {
|
|
@@ -75,14 +75,14 @@ function memorySourcesInPrecedence(scope) {
|
|
|
75
75
|
}
|
|
76
76
|
return out;
|
|
77
77
|
}
|
|
78
|
-
function loadMemoryDoc(
|
|
78
|
+
function loadMemoryDoc(scope, path, fallbackName) {
|
|
79
79
|
const { data, body } = parseFrontmatterGeneric(readText(path));
|
|
80
|
-
return { name, scope, path, frontmatter: data, body };
|
|
80
|
+
return { name: resolveDocName(data, fallbackName), scope, path, frontmatter: data, body };
|
|
81
81
|
}
|
|
82
82
|
/** All memory docs in one memory/ dir, scanned recursively for *.md (topical
|
|
83
|
-
* subdirs supported), sorted by
|
|
84
|
-
*
|
|
85
|
-
* memory/. */
|
|
83
|
+
* subdirs supported), sorted by resolver identity (explicit frontmatter name
|
|
84
|
+
* when present, else path-derived). SKILL.md bundles are legacy Agent Skills
|
|
85
|
+
* and are ignored; crouter memory docs are plain .md files under memory/. */
|
|
86
86
|
function listMemoryDocsInDir(scope, dir, quiet = false) {
|
|
87
87
|
if (!dir || !pathExists(dir))
|
|
88
88
|
return [];
|
|
@@ -117,7 +117,7 @@ function listMemoryDocsInDir(scope, dir, quiet = false) {
|
|
|
117
117
|
// `quiet` suppresses the notice for a targeted resolve (a leaf-name read),
|
|
118
118
|
// where another doc's health is irrelevant noise before the result.
|
|
119
119
|
try {
|
|
120
|
-
docs.push(loadMemoryDoc(
|
|
120
|
+
docs.push(loadMemoryDoc(scope, path, name));
|
|
121
121
|
}
|
|
122
122
|
catch (e) {
|
|
123
123
|
const msg = (e instanceof Error ? e.message : String(e)).split('\n')[0];
|
|
@@ -164,7 +164,7 @@ export function listPluginMemoryDocs(plugin, scope, quiet = false) {
|
|
|
164
164
|
continue;
|
|
165
165
|
const name = `${plugin.name}/${derived}`;
|
|
166
166
|
try {
|
|
167
|
-
docs.push({ ...loadMemoryDoc(
|
|
167
|
+
docs.push({ ...loadMemoryDoc(scope, file, name), plugin: plugin.name });
|
|
168
168
|
}
|
|
169
169
|
catch (e) {
|
|
170
170
|
const msg = (e instanceof Error ? e.message : String(e)).split('\n')[0];
|
|
@@ -174,16 +174,23 @@ export function listPluginMemoryDocs(plugin, scope, quiet = false) {
|
|
|
174
174
|
}
|
|
175
175
|
return docs.sort((a, b) => a.name.localeCompare(b.name));
|
|
176
176
|
}
|
|
177
|
+
/** All docs belonging to a single resolved source: native docs first, then
|
|
178
|
+
* enabled-plugin docs — native wins on a first-wins dedup within the source.
|
|
179
|
+
* Shared by `listAllMemoryDocs` (flattens every source) and `resolveMemoryDoc`
|
|
180
|
+
* (consults one source's docs at a time, nearest source first). */
|
|
181
|
+
function sourceMemoryDocs(source, quiet = false) {
|
|
182
|
+
return [
|
|
183
|
+
...listMemoryDocsInDir(source.scope, source.memoryDir, quiet),
|
|
184
|
+
...source.plugins.flatMap((p) => listPluginMemoryDocs(p, source.scope, quiet)),
|
|
185
|
+
];
|
|
186
|
+
}
|
|
177
187
|
/** All memory docs across the resolved sources, in precedence order: each
|
|
178
188
|
* ancestor project `.crouter/` from nearest to farthest, then the selected
|
|
179
189
|
* profile's memory (if any), then user, then builtin. Within each source,
|
|
180
190
|
* native docs are emitted before enabled-plugin docs, so native wins on the
|
|
181
191
|
* caller's first-wins dedup. */
|
|
182
192
|
export function listAllMemoryDocs(scope, quiet = false) {
|
|
183
|
-
return memorySourcesInPrecedence(scope).flatMap((source) =>
|
|
184
|
-
...listMemoryDocsInDir(source.scope, source.memoryDir, quiet),
|
|
185
|
-
...source.plugins.flatMap((p) => listPluginMemoryDocs(p, source.scope, quiet)),
|
|
186
|
-
]);
|
|
193
|
+
return memorySourcesInPrecedence(scope).flatMap((source) => sourceMemoryDocs(source, quiet));
|
|
187
194
|
}
|
|
188
195
|
/** Find the direct child of `dir` — a `.md` file (matched on name minus
|
|
189
196
|
* extension) or a directory — whose NORMALIZED display name equals
|
|
@@ -246,9 +253,10 @@ function resolveNormalizedPath(baseDir, segments) {
|
|
|
246
253
|
dirPath: matchNormalizedChild(curDir, last, 'dir'),
|
|
247
254
|
};
|
|
248
255
|
}
|
|
249
|
-
/** Direct full-path lookup of memory/<name>.md
|
|
250
|
-
*
|
|
251
|
-
* (
|
|
256
|
+
/** Direct full-path lookup of memory/<name>.md within ONE source. Returns that
|
|
257
|
+
* source's single hit, or undefined — a source can produce at most one direct
|
|
258
|
+
* match (native wins over plugin within the source, and at most one plugin's
|
|
259
|
+
* name can match).
|
|
252
260
|
*
|
|
253
261
|
* A directory INDEX is the cleaner contract: when `<name>.md` is absent but
|
|
254
262
|
* `<name>/INDEX.md` exists, the bare dir name (`taste`) resolves to the dir's
|
|
@@ -259,94 +267,72 @@ function resolveNormalizedPath(baseDir, segments) {
|
|
|
259
267
|
* never authored with a numeric prefix); resolution against the physical tree
|
|
260
268
|
* is prefix-blind via `resolveNormalizedPath` so a normalized name finds an
|
|
261
269
|
* `NN-`-pinned physical file/dir. */
|
|
262
|
-
function
|
|
263
|
-
const matches = [];
|
|
264
|
-
const segments = name.split('/');
|
|
270
|
+
function findMemoryMatchInSource(name, segments, source) {
|
|
265
271
|
const isLegacySkillDoc = segments.at(-1) === 'SKILL';
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
const
|
|
271
|
-
if (
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
if (dirPath !== null) {
|
|
278
|
-
const indexPath = join(dirPath, 'INDEX.md');
|
|
279
|
-
if (pathExists(indexPath)) {
|
|
280
|
-
matches.push(loadMemoryDoc(name, source.scope, indexPath));
|
|
281
|
-
continue;
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
// Plugin memory dir: a `<plugin>/<rest>` name resolves against that enabled
|
|
286
|
-
// plugin's memory/ tree (the `<pluginName>/` mount that listAllMemoryDocs
|
|
287
|
-
// enumerates — `read` must resolve what `list` shows). A BARE plugin name
|
|
288
|
-
// (no slash) resolves the plugin-root INDEX.md, mirroring the native
|
|
289
|
-
// bare-dir-name -> INDEX.md contract for the plugin mount root.
|
|
290
|
-
const slash = name.indexOf('/');
|
|
291
|
-
const pluginName = slash > 0 ? name.slice(0, slash) : name;
|
|
292
|
-
const rest = slash > 0 ? name.slice(slash + 1) : '';
|
|
293
|
-
for (const p of source.plugins) {
|
|
294
|
-
if (p.name !== pluginName)
|
|
295
|
-
continue;
|
|
296
|
-
const pdir = pluginMemoryDir(p);
|
|
297
|
-
const restSegments = rest ? rest.split('/') : [];
|
|
298
|
-
if (rest) {
|
|
299
|
-
const { filePath } = resolveNormalizedPath(pdir, restSegments);
|
|
300
|
-
if (restSegments.at(-1) !== 'SKILL' && filePath !== null) {
|
|
301
|
-
matches.push(loadMemoryDoc(name, source.scope, filePath));
|
|
302
|
-
break;
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
// Bare name -> <plugin>/memory/INDEX.md; slashed name -> dir INDEX.
|
|
306
|
-
const pIndexDir = rest ? resolveNormalizedPath(pdir, restSegments).dirPath : pdir;
|
|
307
|
-
if (pIndexDir !== null) {
|
|
308
|
-
const pindex = join(pIndexDir, 'INDEX.md');
|
|
309
|
-
if (pathExists(pindex)) {
|
|
310
|
-
matches.push(loadMemoryDoc(name, source.scope, pindex));
|
|
311
|
-
break;
|
|
312
|
-
}
|
|
313
|
-
}
|
|
272
|
+
// Native memory dir first inside this source (native-before-plugin
|
|
273
|
+
// precedence), then its enabled plugins.
|
|
274
|
+
const dir = source.memoryDir;
|
|
275
|
+
if (dir) {
|
|
276
|
+
const { filePath, dirPath } = resolveNormalizedPath(dir, segments);
|
|
277
|
+
if (!isLegacySkillDoc && filePath !== null)
|
|
278
|
+
return loadMemoryDoc(source.scope, filePath, name);
|
|
279
|
+
if (dirPath !== null) {
|
|
280
|
+
const indexPath = join(dirPath, 'INDEX.md');
|
|
281
|
+
if (pathExists(indexPath))
|
|
282
|
+
return loadMemoryDoc(source.scope, indexPath, name);
|
|
314
283
|
}
|
|
315
284
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
285
|
+
// Plugin memory dir: a `<plugin>/<rest>` name resolves against that enabled
|
|
286
|
+
// plugin's memory/ tree (the `<pluginName>/` mount that listAllMemoryDocs
|
|
287
|
+
// enumerates — `read` must resolve what `list` shows). A BARE plugin name
|
|
288
|
+
// (no slash) resolves the plugin-root INDEX.md, mirroring the native
|
|
289
|
+
// bare-dir-name -> INDEX.md contract for the plugin mount root.
|
|
290
|
+
const slash = name.indexOf('/');
|
|
291
|
+
const pluginName = slash > 0 ? name.slice(0, slash) : name;
|
|
292
|
+
const rest = slash > 0 ? name.slice(slash + 1) : '';
|
|
293
|
+
for (const p of source.plugins) {
|
|
294
|
+
if (p.name !== pluginName)
|
|
295
|
+
continue;
|
|
296
|
+
const pdir = pluginMemoryDir(p);
|
|
297
|
+
const restSegments = rest ? rest.split('/') : [];
|
|
298
|
+
if (rest) {
|
|
299
|
+
const { filePath } = resolveNormalizedPath(pdir, restSegments);
|
|
300
|
+
if (restSegments.at(-1) !== 'SKILL' && filePath !== null)
|
|
301
|
+
return loadMemoryDoc(source.scope, filePath, name);
|
|
302
|
+
}
|
|
303
|
+
// Bare name -> <plugin>/memory/INDEX.md; slashed name -> dir INDEX.
|
|
304
|
+
const pIndexDir = rest ? resolveNormalizedPath(pdir, restSegments).dirPath : pdir;
|
|
305
|
+
if (pIndexDir !== null) {
|
|
306
|
+
const pindex = join(pIndexDir, 'INDEX.md');
|
|
307
|
+
if (pathExists(pindex))
|
|
308
|
+
return loadMemoryDoc(source.scope, pindex, name);
|
|
309
|
+
}
|
|
332
310
|
}
|
|
333
|
-
return
|
|
311
|
+
return undefined;
|
|
334
312
|
}
|
|
335
313
|
function formatLeafAmbiguous(leaf, matches) {
|
|
336
314
|
const ids = matches.map(memoryDocId).join(', ');
|
|
337
315
|
return `ambiguous memory document: ${leaf} matches multiple documents: ${ids}`;
|
|
338
316
|
}
|
|
339
317
|
/**
|
|
340
|
-
* Resolve a
|
|
318
|
+
* Resolve a memory document name to a single memory document.
|
|
341
319
|
*
|
|
342
320
|
* Accepted identifier forms:
|
|
343
321
|
* <name> — bare name; resolved by scope precedence project>user>builtin
|
|
344
322
|
* <scope>/<name> — pinned to one scope (user|project)
|
|
345
323
|
* `<name>` may carry topical subdirs (`taste/foo`); a bare leaf (`foo`) falls
|
|
346
|
-
* back to a last-segment match
|
|
324
|
+
* back to a last-segment match within the resolved scopes.
|
|
347
325
|
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
326
|
+
* Precedence is the outer loop: sources are visited nearest-first, and the
|
|
327
|
+
* FIRST source with any hit wins outright — a farther source is never even
|
|
328
|
+
* consulted once a nearer one matches. Within each source, in order: (1)
|
|
329
|
+
* exact substrate-identity match (`doc.name === query` — the explicit
|
|
330
|
+
* frontmatter `name`, else its normalized path-derived fallback, the SAME
|
|
331
|
+
* identity `listAllMemoryDocs` reports); (2) direct `memory/<name>.md`
|
|
332
|
+
* physical-path lookup (the bare-dir → INDEX.md and bare-plugin-name → plugin
|
|
333
|
+
* root INDEX.md conveniences neither identity nor a literal path expresses);
|
|
334
|
+
* (3) bare leaf-name fallback (final path segment), ambiguous only when two
|
|
335
|
+
* DIFFERENT identities in the SAME source share a leaf.
|
|
350
336
|
*/
|
|
351
337
|
export function resolveMemoryDoc(rawName, opts = {}) {
|
|
352
338
|
const parsed = parseSkillQualifier(rawName);
|
|
@@ -357,28 +343,38 @@ export function resolveMemoryDoc(rawName, opts = {}) {
|
|
|
357
343
|
const name = parsed.segments.join('/');
|
|
358
344
|
if (name === '')
|
|
359
345
|
throw usage(`memory document name required`);
|
|
360
|
-
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
346
|
+
const segments = name.split('/');
|
|
347
|
+
const isLeafQuery = !name.includes('/');
|
|
348
|
+
for (const source of memorySourcesInPrecedence(effectiveScope)) {
|
|
349
|
+
// Quiet: a targeted read must not spew other docs' frontmatter warnings
|
|
350
|
+
// before its own result (esp. a not_found) — corpus health is `lint`'s job.
|
|
351
|
+
const docs = sourceMemoryDocs(source, true);
|
|
352
|
+
const identity = docs.find((d) => d.name === name);
|
|
353
|
+
if (identity)
|
|
354
|
+
return identity;
|
|
355
|
+
const direct = findMemoryMatchInSource(name, segments, source);
|
|
356
|
+
if (direct)
|
|
357
|
+
return direct;
|
|
358
|
+
if (isLeafQuery) {
|
|
359
|
+
const leafMatches = docs.filter((d) => (d.name.split('/').pop() ?? d.name) === name);
|
|
360
|
+
if (leafMatches.length > 0) {
|
|
361
|
+
// Same path-derived name within this source → precedence wins (return
|
|
362
|
+
// first); genuinely different docs sharing a leaf → ambiguous. Either
|
|
363
|
+
// way this source has an answer — a farther source is never checked.
|
|
364
|
+
const distinctNames = new Set(leafMatches.map((d) => d.name));
|
|
365
|
+
if (distinctNames.size === 1)
|
|
366
|
+
return leafMatches[0];
|
|
367
|
+
throw ambiguous(formatLeafAmbiguous(name, leafMatches), {
|
|
368
|
+
memory: name,
|
|
369
|
+
candidates: leafMatches.map((d) => ({
|
|
370
|
+
id: memoryDocId(d),
|
|
371
|
+
scope: d.scope,
|
|
372
|
+
path: d.path,
|
|
373
|
+
})),
|
|
374
|
+
next: 'Multiple documents share this leaf name. Re-run with one of the full names in candidates.',
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
}
|
|
382
378
|
}
|
|
383
379
|
throw notFound(`memory document not found: ${rawName}`, {
|
|
384
380
|
memory: name,
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Resolve cwd to the same absolute, realpath'd form the profile selector and
|
|
2
|
+
* manifest use, so the mangled-cwd workspace key lines up with the realpath'd
|
|
3
|
+
* project dirs. Falls back to the plain resolved path when the dir can't be
|
|
4
|
+
* stat'd. This is the single resolver every entry point (selector + the
|
|
5
|
+
* `profile default` commands) routes through, so a pin set from one is found
|
|
6
|
+
* by the other. */
|
|
7
|
+
export declare function resolveBindingCwd(cwd: string): string;
|
|
8
|
+
export declare function getDefaultProfileId(cwd: string): string | null;
|
|
9
|
+
export declare function setDefaultProfileId(cwd: string, profileId: string): void;
|
|
10
|
+
export declare function clearDefaultProfile(cwd: string): void;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// A per-cwd "default profile" pin — a small binding stored in the per-cwd
|
|
2
|
+
// workspace root (`~/.crouter/workspaces/<mangled-cwd>/default-profile.json`)
|
|
3
|
+
// that says "when a node boots at THIS directory, prefer THIS profile". It is
|
|
4
|
+
// deliberately NOT manifest state: a profile's `projects` list is what it can
|
|
5
|
+
// see (coverage/purview), whereas this pin is which of several covering
|
|
6
|
+
// profiles the user chose to default to here. The two are independent — you
|
|
7
|
+
// can pin a profile that only covers cwd from a parent dir without widening
|
|
8
|
+
// its purview, and widening purview never changes the pin.
|
|
9
|
+
//
|
|
10
|
+
// Consulted by `selectProfileForCwd` (it wins over global-MRU and stops the
|
|
11
|
+
// prompt) and written from the startup menu (`d`) or `crtr profile default`.
|
|
12
|
+
// A stale pin (its profile deleted, or no longer covering cwd) is self-healed
|
|
13
|
+
// away by the selector rather than trusted.
|
|
14
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
15
|
+
import { join, resolve as resolvePath } from 'node:path';
|
|
16
|
+
import { workspaceRoot } from '../artifact.js';
|
|
17
|
+
import { readJsonIfExists, writeJson, ensureDir, removePath } from '../fs-utils.js';
|
|
18
|
+
/** Resolve cwd to the same absolute, realpath'd form the profile selector and
|
|
19
|
+
* manifest use, so the mangled-cwd workspace key lines up with the realpath'd
|
|
20
|
+
* project dirs. Falls back to the plain resolved path when the dir can't be
|
|
21
|
+
* stat'd. This is the single resolver every entry point (selector + the
|
|
22
|
+
* `profile default` commands) routes through, so a pin set from one is found
|
|
23
|
+
* by the other. */
|
|
24
|
+
export function resolveBindingCwd(cwd) {
|
|
25
|
+
const abs = resolvePath(cwd);
|
|
26
|
+
if (!existsSync(abs))
|
|
27
|
+
return abs;
|
|
28
|
+
try {
|
|
29
|
+
return realpathSync(abs);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return abs;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function bindingPath(cwd) {
|
|
36
|
+
return join(workspaceRoot(resolveBindingCwd(cwd)), 'default-profile.json');
|
|
37
|
+
}
|
|
38
|
+
export function getDefaultProfileId(cwd) {
|
|
39
|
+
const binding = readJsonIfExists(bindingPath(cwd));
|
|
40
|
+
const id = binding?.profileId;
|
|
41
|
+
return typeof id === 'string' && id !== '' ? id : null;
|
|
42
|
+
}
|
|
43
|
+
export function setDefaultProfileId(cwd, profileId) {
|
|
44
|
+
const resolved = resolveBindingCwd(cwd);
|
|
45
|
+
ensureDir(workspaceRoot(resolved));
|
|
46
|
+
writeJson(join(workspaceRoot(resolved), 'default-profile.json'), { profileId });
|
|
47
|
+
}
|
|
48
|
+
export function clearDefaultProfile(cwd) {
|
|
49
|
+
removePath(bindingPath(cwd));
|
|
50
|
+
}
|
|
@@ -1,9 +1,21 @@
|
|
|
1
|
+
import { type ProfileEntry } from './manifest.js';
|
|
2
|
+
/** Whether a profile's purview covers `cwd` (cwd at/under one of its project
|
|
3
|
+
* dirs, or a project dir under cwd — the workspace-root case). Exported for
|
|
4
|
+
* `crtr profile default set`, which rejects pinning a profile that doesn't
|
|
5
|
+
* cover cwd (the selector would ignore/self-heal such a pin anyway). */
|
|
6
|
+
export declare function profileCoversCwd(entry: ProfileEntry, cwd: string): boolean;
|
|
1
7
|
/** Select the profile a node about to boot at `cwd` should run under.
|
|
2
8
|
*
|
|
3
9
|
* 1. `explicitProfile` present → resolve id/name via `loadProfileManifest`. If
|
|
4
10
|
* its manifest does not already cover `cwd` and the session is interactive,
|
|
5
11
|
* offer to add `cwd` to its purview (default yes). Bump `last_used_at`,
|
|
6
12
|
* return the id.
|
|
13
|
+
* 1b. Else, a per-cwd PINNED default (menu `d`) that still covers `cwd` is
|
|
14
|
+
* auto-picked outright — interactive and headless alike, no prompt (that is
|
|
15
|
+
* what "default" means). Interactive prints a breadcrumb naming it and how
|
|
16
|
+
* to change it. `forcePicker` (`crtr --pick-profile`) bypasses the pin to
|
|
17
|
+
* re-open the menu. A stale pin (profile gone / no longer covering) is
|
|
18
|
+
* cleared and ignored.
|
|
7
19
|
* 2. Else, if EXACTLY ONE profile's project dir IS `cwd` (you're at a project
|
|
8
20
|
* root, unambiguously one owner), auto-pick it with no prompt — the
|
|
9
21
|
* strongest signal. If SEVERAL profiles claim `cwd` exactly, it's genuinely
|
|
@@ -18,4 +30,4 @@
|
|
|
18
30
|
* create a profile here or proceed as root (root lives ONLY here). Non-
|
|
19
31
|
* interactive (no TTY): default to root (null) and print the recovery
|
|
20
32
|
* instruction to STDERR — never stdout, which the caller may be piping. */
|
|
21
|
-
export declare function selectProfileForCwd(cwd: string, explicitProfile?: string | null): Promise<string | null>;
|
|
33
|
+
export declare function selectProfileForCwd(cwd: string, explicitProfile?: string | null, forcePicker?: boolean): Promise<string | null>;
|