@crouton-kit/crouter 0.3.70 → 0.3.79
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/README.md +1 -1
- package/dist/build-root.d.ts +12 -4
- package/dist/build-root.js +25 -6
- package/dist/builtin-memory/00-runtime-base.md +3 -10
- package/dist/builtin-memory/04-base-worker.md +18 -0
- package/dist/builtin-memory/04-orchestration-kernel.md +1 -1
- package/dist/builtin-memory/crouter-development/plugins.md +82 -5
- package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +13 -34
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +1115 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +234 -71
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/statusline.ts +2 -9
- package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +2 -516
- package/dist/builtin-pi-packages/pi-crtr-extensions/package-lock.json +2 -2
- package/dist/builtin-pi-packages/pi-mode-switch/README.md +11 -39
- package/dist/builtin-pi-packages/pi-mode-switch/extensions/index.ts +20 -15
- package/dist/builtin-pi-packages/pi-mode-switch/package.json +1 -1
- package/dist/builtin-views/canvas/tui.mjs +8 -9
- package/dist/builtin-views/chat/tui.mjs +12 -12
- package/dist/builtin-views/git-pr/tui.mjs +7 -8
- package/dist/builtin-views/inbox/tui.mjs +27 -41
- package/dist/builtin-views/linkedin/tui.mjs +23 -37
- package/dist/builtin-views/prompt-review/tui.mjs +11 -11
- package/dist/builtin-views/settings/tui.mjs +11 -11
- package/dist/builtin-views/workspace-sidebar/tui.mjs +8 -9
- package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +11 -6
- package/dist/clients/attach/__tests__/attach-keybindings.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/attach-keybindings.test.js +113 -0
- package/dist/clients/attach/__tests__/crtr-output-render.test.js +1 -1
- package/dist/clients/attach/__tests__/mermaid-render.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/mermaid-render.test.js +28 -0
- package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +110 -0
- package/dist/clients/attach/attach-cmd.d.ts +9 -1
- package/dist/clients/attach/attach-cmd.js +841 -792
- package/dist/clients/attach/auth-pickers.d.ts +0 -12
- package/dist/clients/attach/auth-pickers.js +64 -15
- package/dist/clients/attach/canvas-panels.js +2 -3
- package/dist/clients/attach/chat-view.d.ts +7 -8
- package/dist/clients/attach/chat-view.js +119 -79
- package/dist/clients/attach/edit-diff-render.d.ts +6 -0
- package/dist/clients/attach/edit-diff-render.js +263 -0
- package/dist/clients/attach/graph-overlay.d.ts +12 -2
- package/dist/clients/attach/graph-overlay.js +83 -33
- package/dist/clients/attach/input-controller.d.ts +28 -2
- package/dist/clients/attach/input-controller.js +38 -5
- package/dist/clients/attach/mermaid-render.js +5 -2
- package/dist/clients/attach/pickers.d.ts +8 -7
- package/dist/clients/attach/pickers.js +11 -17
- package/dist/clients/attach/slash-commands.d.ts +9 -0
- package/dist/clients/attach/slash-commands.js +127 -7
- package/dist/clients/attach/titled-editor.d.ts +12 -1
- package/dist/clients/attach/titled-editor.js +103 -8
- package/dist/commands/canvas-browse.js +2 -2
- package/dist/commands/human/queue.js +3 -4
- package/dist/commands/memory/lint.js +39 -5
- package/dist/commands/memory/write.js +1 -0
- package/dist/commands/node.js +9 -2
- package/dist/commands/pkg/plugin-inspect.js +22 -1
- package/dist/commands/pkg/plugin-manage.js +31 -9
- package/dist/commands/surface-tmux-spread.js +1 -3
- package/dist/commands/sys/__tests__/config-keybindings.test.d.ts +1 -0
- package/dist/commands/sys/__tests__/config-keybindings.test.js +55 -0
- package/dist/commands/sys/__tests__/config-model-ladders.test.d.ts +1 -0
- package/dist/commands/sys/__tests__/config-model-ladders.test.js +121 -0
- package/dist/commands/sys/__tests__/setup-core.test.js +158 -1
- package/dist/commands/sys/config.js +18 -21
- package/dist/commands/sys/doctor.js +42 -4
- package/dist/commands/sys/setup-core.d.ts +49 -1
- package/dist/commands/sys/setup-core.js +161 -4
- package/dist/commands/sys/setup.d.ts +88 -0
- package/dist/commands/sys/setup.js +943 -169
- package/dist/commands/view-pick.d.ts +4 -0
- package/dist/commands/view-pick.js +17 -7
- package/dist/core/__tests__/base-worker-prompt.test.d.ts +1 -0
- package/dist/core/__tests__/base-worker-prompt.test.js +24 -0
- package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +232 -1
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +34 -9
- package/dist/core/__tests__/command-plugins-surfaces.test.d.ts +1 -0
- package/dist/core/__tests__/command-plugins-surfaces.test.js +298 -0
- package/dist/core/__tests__/command-plugins.test.d.ts +1 -0
- package/dist/core/__tests__/command-plugins.test.js +444 -0
- package/dist/core/__tests__/fault-classifier.test.js +15 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +6 -0
- package/dist/core/__tests__/fixtures/fake-engine.js +9 -1
- package/dist/core/__tests__/full/broker-dialogs.test.js +7 -2
- package/dist/core/__tests__/host-teardown-process-group.test.js +15 -4
- package/dist/core/__tests__/preview-registry-sync.test.js +30 -1
- package/dist/core/__tests__/scope-crouter-home-fence.test.d.ts +1 -0
- package/dist/core/__tests__/scope-crouter-home-fence.test.js +55 -0
- package/dist/core/__tests__/stream-watchdog.test.d.ts +1 -0
- package/dist/core/__tests__/stream-watchdog.test.js +70 -0
- package/dist/core/__tests__/tmux-surface.test.js +72 -0
- package/dist/core/canvas/browse/__tests__/model.test.js +23 -5
- package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
- package/dist/core/canvas/browse/app.d.ts +6 -0
- package/dist/core/canvas/browse/app.js +230 -41
- package/dist/core/canvas/browse/model.d.ts +10 -6
- package/dist/core/canvas/browse/model.js +20 -12
- package/dist/core/canvas/browse/pins.d.ts +4 -0
- package/dist/core/canvas/browse/pins.js +29 -0
- package/dist/core/canvas/browse/render.d.ts +13 -1
- package/dist/core/canvas/browse/render.js +72 -15
- package/dist/core/canvas/nav-model.js +5 -11
- package/dist/core/canvas/paths.d.ts +5 -0
- package/dist/core/canvas/paths.js +7 -0
- package/dist/core/command-plugins/adapter.d.ts +15 -0
- package/dist/core/command-plugins/adapter.js +145 -0
- package/dist/core/command-plugins/compose.d.ts +5 -0
- package/dist/core/command-plugins/compose.js +56 -0
- package/dist/core/command-plugins/discovery.d.ts +104 -0
- package/dist/core/command-plugins/discovery.js +565 -0
- package/dist/core/config.d.ts +48 -0
- package/dist/core/config.js +228 -110
- package/dist/core/fault-classifier.js +1 -1
- package/dist/core/fs-utils.d.ts +1 -0
- package/dist/core/fs-utils.js +15 -1
- package/dist/core/keybindings/__tests__/bespoke-consumers.test.d.ts +1 -0
- package/dist/core/keybindings/__tests__/bespoke-consumers.test.js +40 -0
- package/dist/core/keybindings/__tests__/resolve.test.d.ts +1 -0
- package/dist/core/keybindings/__tests__/resolve.test.js +224 -0
- package/dist/core/keybindings/catalog.d.ts +14 -0
- package/dist/core/keybindings/catalog.js +257 -0
- package/dist/core/keybindings/index.d.ts +5 -0
- package/dist/core/keybindings/index.js +4 -0
- package/dist/core/keybindings/match.d.ts +29 -0
- package/dist/core/keybindings/match.js +99 -0
- package/dist/core/keybindings/persistence.d.ts +14 -0
- package/dist/core/keybindings/persistence.js +31 -0
- package/dist/core/keybindings/resolve.d.ts +14 -0
- package/dist/core/keybindings/resolve.js +305 -0
- package/dist/core/keybindings/types.d.ts +37 -0
- package/dist/core/keybindings/types.js +1 -0
- package/dist/core/memory-resolver.d.ts +1 -1
- package/dist/core/memory-resolver.js +10 -3
- package/dist/core/predicate.d.ts +5 -3
- package/dist/core/predicate.js +5 -3
- package/dist/core/profiles/select.d.ts +6 -0
- package/dist/core/profiles/select.js +86 -52
- package/dist/core/provider-management.d.ts +12 -0
- package/dist/core/provider-management.js +24 -0
- package/dist/core/runtime/broker-protocol.d.ts +33 -5
- package/dist/core/runtime/broker.js +164 -14
- package/dist/core/runtime/launch.d.ts +39 -6
- package/dist/core/runtime/launch.js +78 -20
- package/dist/core/runtime/naming.js +3 -3
- package/dist/core/runtime/pi-cli.d.ts +6 -0
- package/dist/core/runtime/pi-cli.js +16 -2
- package/dist/core/runtime/pi-vendored.d.ts +8 -0
- package/dist/core/runtime/pi-vendored.js +14 -0
- package/dist/core/runtime/placement.d.ts +2 -2
- package/dist/core/runtime/placement.js +4 -1
- package/dist/core/runtime/promote.js +4 -0
- package/dist/core/runtime/recap.d.ts +3 -3
- package/dist/core/runtime/recap.js +75 -47
- package/dist/core/runtime/recycle.js +6 -1
- package/dist/core/runtime/reset.js +5 -0
- package/dist/core/runtime/session-list-cache.d.ts +33 -0
- package/dist/core/runtime/session-list-cache.js +338 -0
- package/dist/core/runtime/spawn.js +52 -45
- package/dist/core/runtime/stream-watchdog.d.ts +26 -0
- package/dist/core/runtime/stream-watchdog.js +75 -0
- package/dist/core/runtime/tmux-chrome.d.ts +1 -1
- package/dist/core/runtime/tmux-chrome.js +2 -2
- package/dist/core/runtime/tmux.d.ts +18 -15
- package/dist/core/runtime/tmux.js +217 -110
- package/dist/core/scope.js +27 -4
- package/dist/core/subscription-state.d.ts +90 -0
- package/dist/core/subscription-state.js +762 -0
- package/dist/core/tui/__tests__/host-keybindings.test.d.ts +1 -0
- package/dist/core/tui/__tests__/host-keybindings.test.js +112 -0
- package/dist/core/tui/host.d.ts +30 -1
- package/dist/core/tui/host.js +148 -34
- package/dist/core/view/contract.d.ts +17 -3
- package/dist/daemon/crtrd.js +253 -12
- package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +41 -0
- package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.d.ts +1 -0
- package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +121 -0
- package/dist/pi-extensions/canvas-inbox-watcher.d.ts +1 -1
- package/dist/pi-extensions/canvas-inbox-watcher.js +203 -25
- package/dist/pi-extensions/canvas-recap.d.ts +4 -0
- package/dist/pi-extensions/canvas-recap.js +73 -46
- package/dist/pi-extensions/canvas-stophook.d.ts +11 -0
- package/dist/pi-extensions/canvas-stophook.js +33 -4
- package/dist/types.d.ts +9 -26
- package/dist/types.js +1 -28
- package/dist/web-client/assets/{index-DiFuLcp6.js → index--md2ylfi.js} +1 -1
- package/dist/web-client/index.html +1 -1
- package/dist/web-client/sw.js +1 -1
- package/package.json +8 -4
- package/dist/pi-extensions/canvas-nav.d.ts +0 -43
- package/dist/pi-extensions/canvas-nav.js +0 -640
- package/dist/pi-extensions/widget-order-bus.d.ts +0 -6
- package/dist/pi-extensions/widget-order-bus.js +0 -34
|
@@ -11,6 +11,8 @@ import { pathExists, readText, walkFiles } from '../../core/fs-utils.js';
|
|
|
11
11
|
import { parseFrontmatterGeneric } from '../../core/frontmatter.js';
|
|
12
12
|
import { listInstalledPlugins, listInstalledPluginsInRoot } from '../../core/resolver.js';
|
|
13
13
|
import { pluginMemoryDir, projectScopeRoots, scopeMemoryDir } from '../../core/scope.js';
|
|
14
|
+
import { loadProfileManifest, profileMemoryDir } from '../../core/profiles/manifest.js';
|
|
15
|
+
import { getDefaultProfileId } from '../../core/profiles/default-binding.js';
|
|
14
16
|
import { isDocKind, RUNGS } from '../../core/substrate/index.js';
|
|
15
17
|
/** The parser normalizes the `always` alias to `content`, so it lints valid. */
|
|
16
18
|
const VALID_RUNGS = [...RUNGS, 'always'];
|
|
@@ -101,14 +103,14 @@ function lintFile(file, substrateStore, findings) {
|
|
|
101
103
|
export const lintLeaf = defineLeaf({
|
|
102
104
|
name: 'lint',
|
|
103
105
|
description: 'validate frontmatter across the whole bounded document corpus',
|
|
104
|
-
whenToUse: 'you authored or migrated documents and want the authoring-time gate: strict-parse every doc in the bounded corpus (the substrate memory stores) and fail loudly on any invalid YAML or substrate schema violation. Run it before shipping doc changes; CI-friendly (non-zero exit on any finding).',
|
|
106
|
+
whenToUse: 'you authored or migrated documents and want the authoring-time gate: strict-parse every doc in the bounded corpus (the substrate memory stores) and fail loudly on any invalid YAML or substrate schema violation; also flags any dir the selected profile manages that lacks an AGENTS.md operating guide. Run it before shipping doc changes; CI-friendly (non-zero exit on any finding).',
|
|
105
107
|
help: {
|
|
106
108
|
name: 'memory lint',
|
|
107
109
|
summary: 'strict-parse frontmatter across the bounded corpus; non-zero exit on any finding',
|
|
108
110
|
params: [],
|
|
109
111
|
output: [
|
|
110
112
|
{ name: 'checked', type: 'number', required: true, constraint: 'Files linted across all corpora.' },
|
|
111
|
-
{ name: 'corpora', type: 'object', required: true, constraint: 'Per-corpus
|
|
113
|
+
{ name: 'corpora', type: 'object', required: true, constraint: 'Per-corpus counts: {memory_stores (files), profile_projects (managed dirs checked for AGENTS.md)}.' },
|
|
112
114
|
{ name: 'findings', type: 'object[]', required: true, constraint: 'One row per failure: {path, error}. Empty when green.' },
|
|
113
115
|
],
|
|
114
116
|
outputKind: 'object',
|
|
@@ -121,10 +123,14 @@ export const lintLeaf = defineLeaf({
|
|
|
121
123
|
// A source's corpus is its NATIVE memory dir plus each enabled plugin's memory dir —
|
|
122
124
|
// plugin docs are substrate docs and lint through the same schema gate.
|
|
123
125
|
// MEMORY.md index files are not substrate docs — YAML-parse only.
|
|
126
|
+
// Hidden dirs are skipped to mirror doc enumeration (a dot-segment
|
|
127
|
+
// path never yields a doc name, so e.g. the maintainer store shipped
|
|
128
|
+
// at builtin-memory/.crouter can never register) — lint must not
|
|
129
|
+
// flag files the substrate can never load.
|
|
124
130
|
const lintDir = (dir) => {
|
|
125
131
|
if (!dir || !pathExists(dir))
|
|
126
132
|
return;
|
|
127
|
-
for (const file of walkFiles(dir, (n) => n.endsWith('.md'))) {
|
|
133
|
+
for (const file of walkFiles(dir, (n) => n.endsWith('.md'), (d) => d.startsWith('.'))) {
|
|
128
134
|
if (!relative(dir, file).split(sep).join('/'))
|
|
129
135
|
continue;
|
|
130
136
|
memoryCount += 1;
|
|
@@ -145,6 +151,34 @@ export const lintLeaf = defineLeaf({
|
|
|
145
151
|
lintDir(pluginMemoryDir(plugin));
|
|
146
152
|
}
|
|
147
153
|
}
|
|
154
|
+
// Profile coverage: the selected profile's manifest names the dirs the
|
|
155
|
+
// operator manages. Lint the profile's own store, and require every
|
|
156
|
+
// managed project dir to carry an always-loaded operating guide
|
|
157
|
+
// (.crouter/memory/AGENTS.md) — a managed dir without one boots every
|
|
158
|
+
// agent there blind, so its absence is an authoring finding like any
|
|
159
|
+
// schema violation. (The managed dirs' stores themselves already lint
|
|
160
|
+
// above: projectScopeRoots folds the profile's project pointers in.)
|
|
161
|
+
let profileProjects = 0;
|
|
162
|
+
const profileIdOrName = process.env['CRTR_PROFILE_ID'] || getDefaultProfileId(process.cwd());
|
|
163
|
+
if (profileIdOrName) {
|
|
164
|
+
try {
|
|
165
|
+
const { profileId, manifest } = loadProfileManifest(profileIdOrName);
|
|
166
|
+
lintDir(profileMemoryDir(profileId));
|
|
167
|
+
for (const dir of manifest.projects) {
|
|
168
|
+
profileProjects += 1;
|
|
169
|
+
if (!pathExists(join(dir, '.crouter', 'memory', 'AGENTS.md'))) {
|
|
170
|
+
findings.push({
|
|
171
|
+
path: dir,
|
|
172
|
+
error: `profile "${manifest.name}" manages this dir but it has no .crouter/memory/AGENTS.md — author its always-loaded operating guide (name the doc AGENTS, target the dir with --dir; run \`crtr memory write -h\` first)`,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
// Unresolvable profile (deleted/corrupt manifest): profile coverage
|
|
179
|
+
// just doesn't apply — the file corpus above already linted fully.
|
|
180
|
+
}
|
|
181
|
+
}
|
|
148
182
|
const checked = memoryCount;
|
|
149
183
|
if (findings.length > 0) {
|
|
150
184
|
// Human/agent path renders only the message — surface every offender as
|
|
@@ -154,12 +188,12 @@ export const lintLeaf = defineLeaf({
|
|
|
154
188
|
throw general(`memory lint: ${findings.length} finding(s) across ${checked} files`, {
|
|
155
189
|
checked,
|
|
156
190
|
findings: findings.map((f) => ({ path: f.path, error: f.error })),
|
|
157
|
-
next: 'Fix each doc (quote YAML values containing `: `; use a valid kind/rung/gate), then re-run `crtr memory lint`.',
|
|
191
|
+
next: 'Fix each doc (quote YAML values containing `: `; use a valid kind/rung/gate); for a profile-managed dir missing AGENTS.md, author one — run `crtr memory write -h` — then re-run `crtr memory lint`.',
|
|
158
192
|
});
|
|
159
193
|
}
|
|
160
194
|
return {
|
|
161
195
|
checked,
|
|
162
|
-
corpora: { memory_stores: memoryCount },
|
|
196
|
+
corpora: { memory_stores: memoryCount, profile_projects: profileProjects },
|
|
163
197
|
findings: [],
|
|
164
198
|
follow_up: 'Corpus green — zero invalid frontmatter docs.',
|
|
165
199
|
};
|
|
@@ -15,6 +15,7 @@ export const writeLeaf = defineLeaf({
|
|
|
15
15
|
'Store reusable current truth, not session notes. Useful memories are non-obvious procedures, gotchas, durable preferences, cross-repo conventions, and amendments to plans/specs that future agents must honor. Put plan/spec amendments under path names like `projects/<topic>/...`; add `projects/<topic>/INDEX.md` with `name` visibility so the topic is discoverable without loading the whole body. Do not store chat summaries, implementation history, or facts already recorded in the repo.\n\n' +
|
|
16
16
|
'Set both visibility rungs explicitly on create. There is no kind default. `none`, `name`, `preview`, and `content` move from least to most loaded: `none` keeps the doc out of auto-load and on-read surfaces, `name` is the bare doc tag only, `preview` is the name + envelope + routing line (`when-and-why-to-read`), and `content` inlines the whole body when the body is short enough to justify it. Each axis is independent; usually one carries a real rung and the other is `none`. When a doc is short enough to state in a single sentence — a one-line preference, or a knowledge fact that fits in a sentence — skip `preview` and use `name` or `content` directly: the routing line would run longer than the doc itself, so a `preview` rung just adds words. Sentence-length `content` docs are not just acceptable, they are correct. Never pad a memory to be more verbose than the rule or fact it carries.\n\n' +
|
|
17
17
|
'Choose the scope. `project` is for facts any agent in one repo needs. `user` is for person-wide facts and preferences that should follow the user everywhere. `profile` is for the profile’s bundle of dirs: cross-repo conventions, how the pieces relate, or the user’s stance toward that body of work. `node` is the narrowest — the this-node store (`nodes/<CRTR_NODE_ID>/context/memory/`), scratch memory only this running node sees; it rides straight into this node’s knowledge block and dies with the node. When unsure, choose the narrowest scope that will still reach the next agent who needs it.\n\n' +
|
|
18
|
+
'AGENTS.md is the one special doc: a store’s always-loaded operating guide, and every dir a profile manages should carry one (`crtr memory lint` flags the gaps). Name the doc AGENTS, target the exact dir with --dir, and set system-prompt-visibility to `content` so every agent working there boots with it. It is a front door, not a manual — constraints and gotchas, the key commands, a few sentences of architecture orientation, and only the conventions that differ from defaults. Every line is paid by every session in that dir forever: prefer a pointer (“when X, run Y -h / read Z, because W”) over inlined how-to, and cut any line that would not change an agent’s behavior.\n\n' +
|
|
18
19
|
'Choose the hook — boot vs file-read. Behavior and how-to procedure surface at boot. Knowledge about code belongs next to the code: put the file in that directory’s .crouter/memory/ (`--dir <project-dir>` targets it exactly, from anywhere) and it fires positionally when files there are read, costing nothing at boot. A knowledge doc about a person or process has no code directory to anchor to, so on-read triggering is meaningless — route it through boot instead and set the boot rung to `preview` when you want the routing line to surface.\n\n' +
|
|
19
20
|
'Write the routing line (--when-and-why-to-read) first, before storing anything: "When <circumstance>, this <kind> should be read because <payoff>." The test is whether a stranger mid-task can decide from that one line alone whether to spend the read. If you cannot name the concrete situation that triggers it, you do not yet understand the memory — ask the user one sharp question instead of improvising. Never paraphrase the advice in the routing line; keep it about when to read and why the read is worth it.\n\n' +
|
|
20
21
|
'Gate and read-when share the same predicate language: a field map is AND-ed across fields; dotted fields resolve nested values; field matchers may be scalar, array, or object. Scalar matchers do exact equality, with arrays matching any element. Array matchers do membership or intersection. Object matchers accept `eq`, `ne`, `in`, `nin`, `exists`, `contains`, `containsAll`, `containsAny`, `matches`, `imatches`, `gt`, `gte`, `lt`, and `lte`. Combinators are `all`, `any`, and `not`; sibling field matchers next to them are AND-ed in. An empty condition is inert. An unknown op never matches.\n\n' +
|
package/dist/commands/node.js
CHANGED
|
@@ -911,6 +911,10 @@ const nodeConfig = defineLeaf({
|
|
|
911
911
|
lifecycle: newLifecycle,
|
|
912
912
|
hasManager: meta.parent !== null,
|
|
913
913
|
model: meta.model_override ?? undefined,
|
|
914
|
+
// The TARGET node's cwd + profile — profile-scope kind defaults must
|
|
915
|
+
// resolve for the node being reconfigured, not the calling process.
|
|
916
|
+
cwd: meta.cwd,
|
|
917
|
+
profileId: meta.profile_id,
|
|
914
918
|
});
|
|
915
919
|
meta = updateNode(nodeId, {
|
|
916
920
|
...(kindSpec !== undefined ? { kind: newKind } : {}),
|
|
@@ -1655,6 +1659,8 @@ function setLifecycle(id, value, opts) {
|
|
|
1655
1659
|
lifecycle: value,
|
|
1656
1660
|
hasManager: target.parent !== null,
|
|
1657
1661
|
model: target.model_override ?? undefined,
|
|
1662
|
+
cwd: target.cwd,
|
|
1663
|
+
profileId: target.profile_id,
|
|
1658
1664
|
});
|
|
1659
1665
|
const meta = updateNode(id, { lifecycle: value, launch });
|
|
1660
1666
|
// --detach: stop foregrounding the agent by closing its viewer pane (the
|
|
@@ -1723,6 +1729,7 @@ const nodeYield = defineLeaf({
|
|
|
1723
1729
|
help: {
|
|
1724
1730
|
name: 'node yield',
|
|
1725
1731
|
summary: 'request a context refresh — you will be respawned fresh against your roadmap on your next stop (a base node auto-promotes to orchestrator first); optionally change your kind or model as you refresh',
|
|
1732
|
+
guide: 'A yield discards everything that lives only in this window, so externalize first: checkpoint context/roadmap.md, and if you are holding a question for the human you have not actually posed, post it via `crtr human ask` before yielding — an in-flight ask survives the refresh and its answer wakes your fresh revive — then note the pending ask in your yield message so the fresh window knows what the answer settles.',
|
|
1726
1733
|
params: [
|
|
1727
1734
|
{ kind: 'flag', name: 'kind', type: 'string', required: false, constraint: 'Respecialize as you refresh — change your kind to this (developer, review, spec, design, plan, explore, general); a base node also auto-promotes into an orchestrator of this kind. Defaults to your current kind.' },
|
|
1728
1735
|
{ kind: 'flag', name: 'model', type: 'enum', choices: ['ultra', 'strong'], required: false, constraint: 'Change the model your fresh revive runs on, by capability tier: `ultra` (frontier — reserve for high-taste judgment or enormous work: speccing, planning something large, e2e-testing something hard to test) or `strong` (opus — regular dev work). Omit to keep your current model. A node steering work is never weaker than opus, so these are the only two choices; the change is durable across future revives.' },
|
|
@@ -1976,13 +1983,13 @@ export function registerNode() {
|
|
|
1976
1983
|
rootEntry: {
|
|
1977
1984
|
concept: 'a unit of the agent runtime — an agent on the canvas with its own context dir and pi vehicle. Spawning nodes is how work gets done here, not an optimization you reach for late',
|
|
1978
1985
|
desc: 'spawn, inspect, and navigate nodes on the canvas',
|
|
1979
|
-
useWhen: 'delegating
|
|
1986
|
+
useWhen: 'delegating a genuinely independent subtask, or walking the subscription graph. Base nodes work hands-on; orchestrators fan out. Give each child a bounded outcome distinct from your whole assignment so delegation moves work toward completion instead of recursively passing it down. Once delegated, do not duplicate the child’s work; its finish wakes you. Use the help surface for the exact node contract before invoking it.'
|
|
1980
1987
|
},
|
|
1981
1988
|
help: {
|
|
1982
1989
|
name: 'node',
|
|
1983
1990
|
summary: 'spawn and navigate canvas nodes',
|
|
1984
1991
|
model: 'Every agent is a node in one directed graph (the canvas); `subscribes_to` is the spine — spawn a child and you auto-subscribe (active) to it, so its finish wakes you.\n\n' +
|
|
1985
|
-
'WHEN TO DELEGATE:
|
|
1992
|
+
'WHEN TO DELEGATE: base nodes work hands-on and spawn only genuinely independent subtasks; orchestrators decompose and fan out. A child owns a bounded outcome distinct from the caller’s whole assignment. Spawn independent units in PARALLEL, serialize true dependencies, and never let two live children edit the same files.\n\n' +
|
|
1986
1993
|
'HOW: the new leaf returns a node id immediately and runs the worker as a detached headless broker; no viewer opens unless a surface asks for one. Match the kind to the work and read the leaf schema before invoking. You are woken when a child finishes — the wake message ALREADY IS the coalesced digest, so dereference the report paths in that digest that matter instead of acting on a one-line label. Integrate, then either delegate the next units or finish.\n\n' +
|
|
1987
1994
|
'FINISH: a worker ends its own work with a final push, which writes the canonical result, marks done, and closes the window; stopping without it is not finishing. For a job too big for one context window, promote to an orchestrator; when context fills, yield to refresh against that roadmap.',
|
|
1988
1995
|
},
|
|
@@ -3,6 +3,8 @@ import { notFound } from '../../core/errors.js';
|
|
|
3
3
|
import { paginate } from '../../core/pagination.js';
|
|
4
4
|
import { listInstalledPlugins, findPluginByName, } from '../../core/resolver.js';
|
|
5
5
|
import { listAllMemoryDocs } from '../../core/memory-resolver.js';
|
|
6
|
+
import { validatePluginCommands, validateEffectiveCommandPlugins } from '../../core/command-plugins/discovery.js';
|
|
7
|
+
import { SUBTREE_NAMES } from '../../build-root.js';
|
|
6
8
|
import { resolveScopeArg, projectScopeRoot } from '../../core/scope.js';
|
|
7
9
|
export const pluginList = defineLeaf({
|
|
8
10
|
name: 'list',
|
|
@@ -89,9 +91,10 @@ export const pluginShow = defineLeaf({
|
|
|
89
91
|
{ name: 'enabled', type: 'boolean', required: true, constraint: 'Whether the plugin is active.' },
|
|
90
92
|
{ name: 'manifest', type: 'object', required: true, constraint: 'Full plugin.json contents.' },
|
|
91
93
|
{ name: 'docs', type: 'object[]', required: true, constraint: 'Each: {name, path}. Memory docs provided by the plugin (its `<pluginName>/` memory subtree).' },
|
|
94
|
+
{ name: 'commands', type: 'object', required: false, constraint: 'Present only when the plugin declares a command manifest (manifest.commands). {manifestPath: string, executable: string | null (null when path/exec-bit validation failed), mounts: string[] (accepted top-level command names), issues: object[] ({code, path?, message, received, expected, next})}. Validated statically — the plugin binary is never executed.' },
|
|
92
95
|
],
|
|
93
96
|
outputKind: 'object',
|
|
94
|
-
effects: ['None. Read-only.'],
|
|
97
|
+
effects: ['None. Read-only. Command manifests are validated statically; the plugin executable is never run.'],
|
|
95
98
|
},
|
|
96
99
|
run: async (input) => {
|
|
97
100
|
const name = input['name'];
|
|
@@ -114,6 +117,23 @@ export const pluginShow = defineLeaf({
|
|
|
114
117
|
}
|
|
115
118
|
const prefix = `${found.name}/`;
|
|
116
119
|
const docs = listAllMemoryDocs().filter((d) => d.name.startsWith(prefix));
|
|
120
|
+
let commands;
|
|
121
|
+
if (typeof found.manifest.commands === 'string' && found.manifest.commands.length > 0) {
|
|
122
|
+
const reserved = new Set(SUBTREE_NAMES);
|
|
123
|
+
// Validate against the whole effective set so cross-plugin top-level
|
|
124
|
+
// name collisions show up in this plugin's issues (a collided command is
|
|
125
|
+
// NOT mounted). A copy outside the effective set (disabled, or shadowed
|
|
126
|
+
// by a higher-precedence copy) cannot collide, so the per-plugin
|
|
127
|
+
// validator is exact for it.
|
|
128
|
+
const v = validateEffectiveCommandPlugins(reserved).find((x) => x.plugin.root === found.root) ??
|
|
129
|
+
validatePluginCommands(found, reserved);
|
|
130
|
+
commands = {
|
|
131
|
+
manifestPath: v.manifestPath,
|
|
132
|
+
executable: v.executable ?? null,
|
|
133
|
+
mounts: v.contributions.map((c) => c.node.name),
|
|
134
|
+
issues: v.issues,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
117
137
|
return {
|
|
118
138
|
name: found.name,
|
|
119
139
|
scope: found.scope,
|
|
@@ -124,6 +144,7 @@ export const pluginShow = defineLeaf({
|
|
|
124
144
|
name: d.name,
|
|
125
145
|
path: d.path,
|
|
126
146
|
})),
|
|
147
|
+
...(commands !== undefined ? { commands } : {}),
|
|
127
148
|
};
|
|
128
149
|
},
|
|
129
150
|
});
|
|
@@ -8,7 +8,25 @@ import { updateConfig, updateState, ensureScopeInitialized } from '../../core/co
|
|
|
8
8
|
import { pathExists, ensureDir, removePath, nowIso, linkOrCopy, isSymlink } from '../../core/fs-utils.js';
|
|
9
9
|
import { clone, pull, deriveNameFromUrl, currentSha, isGitRepo } from '../../core/git.js';
|
|
10
10
|
import { readMarketplaceManifest, readPluginManifest } from '../../core/manifest.js';
|
|
11
|
+
import { validatePluginCommands, validateEffectiveCommandPlugins } from '../../core/command-plugins/discovery.js';
|
|
12
|
+
import { SUBTREE_NAMES } from '../../build-root.js';
|
|
11
13
|
import { isGitUrl, setPluginEnabled, resolveInstallScope } from './shared.js';
|
|
14
|
+
/** Validate a just-installed/updated plugin's command manifest, if it declares
|
|
15
|
+
* one. Returns undefined for plugins without a `commands` pointer. Never
|
|
16
|
+
* executes the plugin binary — validation is fully static. */
|
|
17
|
+
function commandReport(name, scope) {
|
|
18
|
+
const plugin = findPluginByName(name, scope);
|
|
19
|
+
if (plugin === null || typeof plugin.manifest.commands !== 'string' || plugin.manifest.commands.length === 0) {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
const reserved = new Set(SUBTREE_NAMES);
|
|
23
|
+
// Report from the effective set so a cross-plugin top-level collision is
|
|
24
|
+
// visible at install time (a collided command is NOT mounted). A copy
|
|
25
|
+
// outside the effective set (disabled, or shadowed) cannot collide.
|
|
26
|
+
const v = validateEffectiveCommandPlugins(reserved).find((x) => x.plugin.root === plugin.root) ??
|
|
27
|
+
validatePluginCommands(plugin, reserved);
|
|
28
|
+
return { mounts: v.contributions.map((c) => c.node.name), issues: v.issues };
|
|
29
|
+
}
|
|
12
30
|
function classifyInstallRef(raw) {
|
|
13
31
|
if (raw.startsWith('./') || raw.startsWith('../') || raw.startsWith('/')) {
|
|
14
32
|
return { kind: 'local', value: raw };
|
|
@@ -203,10 +221,12 @@ function updateOnePlugin(plugin, marketplaceCache, opts) {
|
|
|
203
221
|
}
|
|
204
222
|
const manifest = readPluginManifest(plugin.root);
|
|
205
223
|
updatePluginVersion(plugin.scope, plugin.name, marketplaceVersion ?? manifest?.version);
|
|
224
|
+
const commands = commandReport(plugin.name, plugin.scope);
|
|
206
225
|
return {
|
|
207
226
|
name: plugin.name,
|
|
208
227
|
updated: contentUpdate.updated,
|
|
209
228
|
sha: contentUpdate.sha,
|
|
229
|
+
...(commands !== undefined ? { commands } : {}),
|
|
210
230
|
};
|
|
211
231
|
}
|
|
212
232
|
export const pluginInstall = defineLeaf({
|
|
@@ -225,9 +245,13 @@ export const pluginInstall = defineLeaf({
|
|
|
225
245
|
{ name: 'name', type: 'string', required: true, constraint: 'Plugin name as installed.' },
|
|
226
246
|
{ name: 'scope', type: 'string', required: true, constraint: 'Scope the plugin was installed into.' },
|
|
227
247
|
{ name: 'path', type: 'string', required: true, constraint: 'Absolute path to the installed plugin directory.' },
|
|
248
|
+
{ name: 'commands', type: 'object', required: false, constraint: 'Present only when the plugin declares a command manifest. {mounts: string[] (accepted top-level command names now live for the next invocation), issues: object[] (typed validation issues that rejected a contribution — {code, path?, message, received, expected, next})}. Validated statically; the plugin binary is never executed.' },
|
|
228
249
|
],
|
|
229
250
|
outputKind: 'object',
|
|
230
|
-
effects: [
|
|
251
|
+
effects: [
|
|
252
|
+
'Clones, links, or copies the plugin into the scope plugins directory. Registers the plugin in config.json with enabled=true. Marketplace installs refresh the source marketplace before resolving the plugin entry.',
|
|
253
|
+
'If the plugin declares a command manifest, its commands go live on the next crtr invocation. A command leaf runs the plugin\'s own executable as trusted local code with your authority — only when you explicitly invoke that leaf, never on install, help, or discovery.',
|
|
254
|
+
],
|
|
231
255
|
},
|
|
232
256
|
run: async (input) => {
|
|
233
257
|
const rawRef = input['installRef'];
|
|
@@ -235,13 +259,11 @@ export const pluginInstall = defineLeaf({
|
|
|
235
259
|
const gitRef = input['ref'];
|
|
236
260
|
const scope = resolveInstallScope(scopeInput);
|
|
237
261
|
const classified = classifyInstallRef(rawRef);
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
}
|
|
244
|
-
return installFromGit(classified.value, gitRef, scope);
|
|
262
|
+
const installed = classified.kind === 'marketplace' ? installFromMarketplace(classified.value, scope)
|
|
263
|
+
: classified.kind === 'local' ? installFromLocal(classified.value, scope)
|
|
264
|
+
: installFromGit(classified.value, gitRef, scope);
|
|
265
|
+
const commands = commandReport(installed.name, installed.scope);
|
|
266
|
+
return commands !== undefined ? { ...installed, commands } : installed;
|
|
245
267
|
},
|
|
246
268
|
});
|
|
247
269
|
export const pluginRemove = defineLeaf({
|
|
@@ -351,7 +373,7 @@ export const pluginUpdate = defineLeaf({
|
|
|
351
373
|
{ kind: 'flag', name: 'name', type: 'string', required: false, constraint: 'Plugin name to update. Omit to update all installed plugins.' },
|
|
352
374
|
],
|
|
353
375
|
output: [
|
|
354
|
-
{ name: 'updated', type: 'object[]', required: true, constraint: 'One entry per plugin processed: {name, updated, sha}.' },
|
|
376
|
+
{ name: 'updated', type: 'object[]', required: true, constraint: 'One entry per plugin processed: {name, updated, sha, commands?}. commands is present only for plugins declaring a command manifest: {mounts: string[] (accepted top-level command names), issues: object[] ({code, path?, message, received, expected, next})} — validated statically, never executing the plugin binary.' },
|
|
355
377
|
],
|
|
356
378
|
outputKind: 'object',
|
|
357
379
|
effects: ['Runs git pull in plugin or marketplace directories when they are git checkouts. Updates plugin version and last_updated state.'],
|
|
@@ -2,9 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// A genuine viewer-placement decision (tile every live viewer in a subtree into
|
|
4
4
|
// one window), so it lives under `surface` alongside view/attach/workspace/web.
|
|
5
|
-
//
|
|
6
|
-
// which shells this leaf directly for the cursor node. It "expands" the graph
|
|
7
|
-
// you are watching: the caller's
|
|
5
|
+
// The command expands the graph being watched: the caller's
|
|
8
6
|
// own viewer pane is broken out into a BRAND-NEW window, then a surface-attach
|
|
9
7
|
// viewer pane is tiled beside it for every broker-alive node in the local
|
|
10
8
|
// subtree (ancestry root → self → descendants), so the user lands on a single
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { after, before, beforeEach, test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { configBranch } from '../config.js';
|
|
7
|
+
import { ensureScopeInitialized } from '../../../core/config.js';
|
|
8
|
+
import { resetScopeCache } from '../../../core/scope.js';
|
|
9
|
+
function leaf(name) {
|
|
10
|
+
const found = (configBranch.children ?? []).find((child) => child.name === name);
|
|
11
|
+
if (found === undefined || found.kind !== 'leaf')
|
|
12
|
+
throw new Error(`no leaf '${name}' under sys config`);
|
|
13
|
+
return found;
|
|
14
|
+
}
|
|
15
|
+
let home;
|
|
16
|
+
let realHome;
|
|
17
|
+
before(() => { realHome = process.env['HOME']; });
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-config-keybindings-'));
|
|
20
|
+
process.env['HOME'] = home;
|
|
21
|
+
});
|
|
22
|
+
after(() => {
|
|
23
|
+
if (realHome === undefined)
|
|
24
|
+
delete process.env['HOME'];
|
|
25
|
+
else
|
|
26
|
+
process.env['HOME'] = realHome;
|
|
27
|
+
});
|
|
28
|
+
test.afterEach(() => rmSync(home, { recursive: true, force: true }));
|
|
29
|
+
test('a new project scope initializes through the config lock', () => {
|
|
30
|
+
const project = mkdtempSync(join(tmpdir(), 'crtr-config-project-'));
|
|
31
|
+
const previous = process.cwd();
|
|
32
|
+
try {
|
|
33
|
+
process.chdir(project);
|
|
34
|
+
resetScopeCache();
|
|
35
|
+
const root = join(project, '.crouter');
|
|
36
|
+
ensureScopeInitialized('project', root);
|
|
37
|
+
assert.equal(JSON.parse(readFileSync(join(root, 'config.json'), 'utf8')).keybindings, undefined);
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
process.chdir(previous);
|
|
41
|
+
resetScopeCache();
|
|
42
|
+
rmSync(project, { recursive: true, force: true });
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
test('sys config exposes whole user keybindings read-only and routes edits to setup', async () => {
|
|
46
|
+
mkdirSync(join(home, '.crouter'), { recursive: true });
|
|
47
|
+
writeFileSync(join(home, '.crouter', 'config.json'), JSON.stringify({
|
|
48
|
+
keybindings: { 'crtr.graph.down': ['down'] },
|
|
49
|
+
}));
|
|
50
|
+
const result = await leaf('get').run({ key: 'keybindings', scope: 'user' });
|
|
51
|
+
assert.deepEqual(result['value'], { 'crtr.graph.down': ['down'] });
|
|
52
|
+
await assert.rejects(() => leaf('get').run({ key: 'keybindings.crtr.graph.down', scope: 'user' }), /whole user-scope object/);
|
|
53
|
+
await assert.rejects(() => leaf('set').run({ key: 'keybindings', value: '{}' }), /crtr sys setup/);
|
|
54
|
+
assert.doesNotMatch(JSON.stringify(configBranch), /canvasNav/);
|
|
55
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// config-model-ladders.test.ts — regression coverage for the live Hearth
|
|
2
|
+
// incident where a home's config.json had `openai-codex/gpt-5.6-*` baked into
|
|
3
|
+
// `modelLadders` while the guest's own installed model registry only had
|
|
4
|
+
// `gpt-5.5`. Root cause: both `ensureScopeInitialized` (fresh scope) and
|
|
5
|
+
// `sys config set modelLadders...` (a targeted edit) persisted the FULLY
|
|
6
|
+
// materialized ladder (all 4 strengths x 2 providers) rather than only the
|
|
7
|
+
// genuine override, freezing whatever concrete model ids happened to be
|
|
8
|
+
// compiled into the crouter build that ran at that moment. A later
|
|
9
|
+
// crouter/pi upgrade (or rollback) that changes the registry can then never
|
|
10
|
+
// reach the frozen rungs again.
|
|
11
|
+
import { test, before, after, beforeEach, afterEach } from 'node:test';
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import { mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { ensureScopeInitialized, readConfig, updateConfig } from '../../../core/config.js';
|
|
17
|
+
import { configBranch } from '../config.js';
|
|
18
|
+
function leaf(name) {
|
|
19
|
+
const found = (configBranch.children ?? []).find((c) => c.name === name);
|
|
20
|
+
if (found === undefined || found.kind !== 'leaf')
|
|
21
|
+
throw new Error(`no leaf '${name}' under sys config`);
|
|
22
|
+
return found;
|
|
23
|
+
}
|
|
24
|
+
let home;
|
|
25
|
+
let realHome;
|
|
26
|
+
before(() => {
|
|
27
|
+
realHome = process.env['HOME'];
|
|
28
|
+
});
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-config-ladders-'));
|
|
31
|
+
process.env['HOME'] = home;
|
|
32
|
+
});
|
|
33
|
+
// Every test allocates its own temp HOME in beforeEach, so each must be removed
|
|
34
|
+
// in afterEach -- a single `after` would leak all but the last dir.
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
rmSync(home, { recursive: true, force: true });
|
|
37
|
+
});
|
|
38
|
+
after(() => {
|
|
39
|
+
if (realHome !== undefined)
|
|
40
|
+
process.env['HOME'] = realHome;
|
|
41
|
+
else
|
|
42
|
+
delete process.env['HOME'];
|
|
43
|
+
});
|
|
44
|
+
test('ensureScopeInitialized does NOT bake concrete modelLadders model ids into a fresh config.json', () => {
|
|
45
|
+
const root = join(home, '.crouter');
|
|
46
|
+
ensureScopeInitialized('user', root);
|
|
47
|
+
const cfgPath = join(root, 'config.json');
|
|
48
|
+
assert.equal(existsSync(cfgPath), true);
|
|
49
|
+
const raw = JSON.parse(readFileSync(cfgPath, 'utf8'));
|
|
50
|
+
// The file must exist (so `sys config path` has something to point at) but
|
|
51
|
+
// must NOT freeze a modelLadders snapshot -- that key should be absent so
|
|
52
|
+
// `readConfig` always re-derives it live from whichever build is running.
|
|
53
|
+
assert.equal(raw.modelLadders, undefined);
|
|
54
|
+
// readConfig must still default-fill a full, usable ladder despite the
|
|
55
|
+
// omission -- a config-less scope is never worse off.
|
|
56
|
+
const cfg = readConfig('user');
|
|
57
|
+
assert.equal(typeof cfg.modelLadders.anthropic.strong, 'string');
|
|
58
|
+
assert.equal(typeof cfg.modelLadders.openai.strong, 'string');
|
|
59
|
+
});
|
|
60
|
+
test('sys config set modelLadders.<provider>.<strength> persists ONLY the touched rung, not the whole materialized ladder', async () => {
|
|
61
|
+
const root = join(home, '.crouter');
|
|
62
|
+
ensureScopeInitialized('user', root);
|
|
63
|
+
await leaf('set').run({ key: 'modelLadders.openai.strong', value: 'openai-codex/gpt-5.5:high' });
|
|
64
|
+
const cfgPath = join(root, 'config.json');
|
|
65
|
+
const raw = JSON.parse(readFileSync(cfgPath, 'utf8'));
|
|
66
|
+
assert.equal(raw.modelLadders.openai.strong, 'openai-codex/gpt-5.5:high');
|
|
67
|
+
// The other three openai rungs, and the entire anthropic ladder, must stay
|
|
68
|
+
// UNSET on disk -- baking them in would freeze this scope to today's
|
|
69
|
+
// compiled defaults for rungs the user never touched, the exact staleness
|
|
70
|
+
// bug that produced the live `gpt-5.6` vs. installed-`gpt-5.5` mismatch.
|
|
71
|
+
assert.equal(raw.modelLadders.openai.ultra, undefined);
|
|
72
|
+
assert.equal(raw.modelLadders.openai.medium, undefined);
|
|
73
|
+
assert.equal(raw.modelLadders.openai.light, undefined);
|
|
74
|
+
assert.equal(raw.modelLadders.anthropic, undefined);
|
|
75
|
+
// readConfig still resolves a full ladder: the one override layered over
|
|
76
|
+
// live compiled defaults for everything else.
|
|
77
|
+
const cfg = readConfig('user');
|
|
78
|
+
assert.equal(cfg.modelLadders.openai.strong, 'openai-codex/gpt-5.5:high');
|
|
79
|
+
assert.equal(typeof cfg.modelLadders.openai.ultra, 'string');
|
|
80
|
+
assert.equal(typeof cfg.modelLadders.anthropic.strong, 'string');
|
|
81
|
+
});
|
|
82
|
+
test('an unrelated updateConfig mutation (as fresh bootstrap does) does NOT freeze modelLadders to disk', () => {
|
|
83
|
+
const root = join(home, '.crouter');
|
|
84
|
+
ensureScopeInitialized('user', root);
|
|
85
|
+
// Mimic `ensureOfficialMarketplace`'s bootstrap write: updateConfig mutating an
|
|
86
|
+
// unrelated field on a fresh scope. This must not materialize a full ladder.
|
|
87
|
+
updateConfig('user', (c) => {
|
|
88
|
+
c.marketplaces['crouter-official-marketplace'] = {
|
|
89
|
+
url: 'https://example.invalid/x.git',
|
|
90
|
+
ref: 'main',
|
|
91
|
+
installed_at: '2026-01-01T00:00:00.000Z',
|
|
92
|
+
};
|
|
93
|
+
});
|
|
94
|
+
const cfgPath = join(root, 'config.json');
|
|
95
|
+
const raw = JSON.parse(readFileSync(cfgPath, 'utf8'));
|
|
96
|
+
assert.equal(raw.modelLadders, undefined);
|
|
97
|
+
assert.ok(raw.marketplaces['crouter-official-marketplace']);
|
|
98
|
+
});
|
|
99
|
+
test('a non-ladder sys config set does NOT freeze modelLadders to disk', async () => {
|
|
100
|
+
const root = join(home, '.crouter');
|
|
101
|
+
ensureScopeInitialized('user', root);
|
|
102
|
+
await leaf('set').run({ key: 'max_panes_per_window', value: '5' });
|
|
103
|
+
const cfgPath = join(root, 'config.json');
|
|
104
|
+
const raw = JSON.parse(readFileSync(cfgPath, 'utf8'));
|
|
105
|
+
// The touched key persists; modelLadders stays absent so a later build's
|
|
106
|
+
// registry change still reaches every rung.
|
|
107
|
+
assert.equal(raw.max_panes_per_window, 5);
|
|
108
|
+
assert.equal(raw.modelLadders, undefined);
|
|
109
|
+
});
|
|
110
|
+
test('a second sys config set for a different rung does not clobber an earlier override', async () => {
|
|
111
|
+
const root = join(home, '.crouter');
|
|
112
|
+
ensureScopeInitialized('user', root);
|
|
113
|
+
await leaf('set').run({ key: 'modelLadders.openai.strong', value: 'openai-codex/gpt-5.5:high' });
|
|
114
|
+
await leaf('set').run({ key: 'modelLadders.anthropic.light', value: 'anthropic/claude-haiku-4-5:high' });
|
|
115
|
+
const cfgPath = join(root, 'config.json');
|
|
116
|
+
const raw = JSON.parse(readFileSync(cfgPath, 'utf8'));
|
|
117
|
+
assert.equal(raw.modelLadders.openai.strong, 'openai-codex/gpt-5.5:high');
|
|
118
|
+
assert.equal(raw.modelLadders.anthropic.light, 'anthropic/claude-haiku-4-5:high');
|
|
119
|
+
assert.equal(raw.modelLadders.openai.ultra, undefined);
|
|
120
|
+
assert.equal(raw.modelLadders.anthropic.strong, undefined);
|
|
121
|
+
});
|