@crouton-kit/crouter 0.3.70 → 0.3.78
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/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-pi-packages/pi-crtr-extensions/README.md +13 -34
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +622 -724
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +231 -68
- 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 +361 -90
- 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 +3 -3
- 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 +20 -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 +1 -1
- package/dist/clients/attach/attach-cmd.js +738 -735
- 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/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/memory/lint.js +39 -5
- package/dist/commands/memory/write.js +1 -0
- package/dist/commands/node.js +9 -2
- 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/config.js +18 -21
- package/dist/commands/sys/setup-core.d.ts +12 -1
- package/dist/commands/sys/setup-core.js +23 -3
- package/dist/commands/sys/setup.js +42 -12
- 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__/fault-classifier.test.js +15 -0
- 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__/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.js +164 -5
- 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/config.d.ts +53 -2
- 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__/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 +256 -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/runtime/broker-protocol.d.ts +33 -5
- package/dist/core/runtime/broker.js +163 -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/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 +47 -44
- 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 +23 -0
- package/dist/core/runtime/session-list-cache.js +270 -0
- package/dist/core/runtime/spawn.js +51 -32
- 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 +216 -110
- 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/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 +38 -37
- package/dist/pi-extensions/canvas-stophook.d.ts +11 -0
- package/dist/pi-extensions/canvas-stophook.js +33 -4
- package/dist/types.d.ts +3 -13
- package/dist/types.js +2 -26
- 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 +3 -3
- 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
|
@@ -6,16 +6,16 @@
|
|
|
6
6
|
// on each state update), mirroring pi's `getThinkingBorderColor`.
|
|
7
7
|
// Both are pure render-layer chrome; nothing here touches the socket or session.
|
|
8
8
|
import { CustomEditor } from '@earendil-works/pi-coding-agent';
|
|
9
|
-
import { truncateToWidth, visibleWidth, } from '@earendil-works/pi-tui';
|
|
10
|
-
/** The thinking
|
|
11
|
-
* its orange complement
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* contrast — a lighter blue/orange washes the label out. */
|
|
9
|
+
import { CURSOR_MARKER, truncateToWidth, visibleWidth, } from '@earendil-works/pi-tui';
|
|
10
|
+
/** The standard thinking levels form a deliberate cool→warm ramp between blue
|
|
11
|
+
* and its orange complement. `max` is a distinct, intense cyan endpoint so it
|
|
12
|
+
* never falls back to the neutral default border. `off` alone falls back to
|
|
13
|
+
* the caller's default border color. The colors double as title-chip
|
|
14
|
+
* BACKGROUNDS under bold white text, so each must remain dark enough for the
|
|
15
|
+
* label to read clearly. */
|
|
17
16
|
const THINKING_START = [46, 92, 190]; // deep blue
|
|
18
17
|
const THINKING_END = [166, 98, 24]; // deep amber
|
|
18
|
+
const THINKING_MAX = [0, 145, 170]; // intense cyan
|
|
19
19
|
function interpolateRgb(start, end, t) {
|
|
20
20
|
return [
|
|
21
21
|
Math.round(start[0] + (end[0] - start[0]) * t),
|
|
@@ -30,7 +30,55 @@ const THINKING_RGB = {
|
|
|
30
30
|
medium: interpolateRgb(THINKING_START, THINKING_END, 2 / 4),
|
|
31
31
|
high: interpolateRgb(THINKING_START, THINKING_END, 3 / 4),
|
|
32
32
|
xhigh: interpolateRgb(THINKING_START, THINKING_END, 4 / 4),
|
|
33
|
+
max: THINKING_MAX,
|
|
33
34
|
};
|
|
35
|
+
/** Named colours `/color` accepts, as RGB — mirrors the tmux colour-name table
|
|
36
|
+
* (so the chip and the pane border read as the same hue). */
|
|
37
|
+
const NAMED_RGB = {
|
|
38
|
+
black: [0, 0, 0],
|
|
39
|
+
red: [205, 49, 49],
|
|
40
|
+
green: [13, 188, 121],
|
|
41
|
+
yellow: [229, 229, 16],
|
|
42
|
+
blue: [36, 114, 200],
|
|
43
|
+
magenta: [188, 63, 188],
|
|
44
|
+
cyan: [17, 168, 205],
|
|
45
|
+
white: [229, 229, 229],
|
|
46
|
+
orange: [228, 152, 68],
|
|
47
|
+
grey: [128, 128, 128],
|
|
48
|
+
gray: [128, 128, 128],
|
|
49
|
+
brightred: [241, 76, 76],
|
|
50
|
+
brightgreen: [35, 209, 139],
|
|
51
|
+
brightyellow: [245, 245, 67],
|
|
52
|
+
brightblue: [59, 142, 234],
|
|
53
|
+
brightmagenta: [214, 112, 214],
|
|
54
|
+
brightcyan: [41, 184, 219],
|
|
55
|
+
};
|
|
56
|
+
/** Build a title-chip styler for a `/color` token (a tmux colour name, `#rrggbb`,
|
|
57
|
+
* or `colourN`): the colour as the chip BACKGROUND with contrast-picked bold
|
|
58
|
+
* text, matching the thinking-chip treatment. `undefined` for an unrenderable
|
|
59
|
+
* token (e.g. `default`) — the caller keeps its fallback style. */
|
|
60
|
+
export function colorChipStyle(token) {
|
|
61
|
+
const t = token.trim().toLowerCase();
|
|
62
|
+
const c256 = /^colour(\d{1,3})$/.exec(t);
|
|
63
|
+
if (c256 !== null)
|
|
64
|
+
return (s) => `\x1b[48;5;${c256[1]}m\x1b[97m\x1b[1m ${s} \x1b[0m`;
|
|
65
|
+
let rgb;
|
|
66
|
+
const hex = /^#([0-9a-f]{6})$/.exec(t);
|
|
67
|
+
if (hex !== null) {
|
|
68
|
+
const h = hex[1];
|
|
69
|
+
rgb = [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
rgb = NAMED_RGB[t];
|
|
73
|
+
}
|
|
74
|
+
if (rgb === undefined)
|
|
75
|
+
return undefined;
|
|
76
|
+
const [r, g, b] = rgb;
|
|
77
|
+
// Pick black-on-light / white-on-dark so the name stays readable on any hue.
|
|
78
|
+
const luma = 0.299 * r + 0.587 * g + 0.114 * b;
|
|
79
|
+
const fg = luma > 160 ? '\x1b[30m' : '\x1b[97m';
|
|
80
|
+
return (s) => `\x1b[48;2;${r};${g};${b}m${fg}\x1b[1m ${s} \x1b[0m`;
|
|
81
|
+
}
|
|
34
82
|
/** The default (thinking `off`) title chip: reverse video + a space of padding
|
|
35
83
|
* each side, so the name reads as a label sitting on the border rule. Used as
|
|
36
84
|
* the fallback when no thinking color applies. */
|
|
@@ -71,6 +119,39 @@ export function composeTopBorder(width, title, info, titleStyle, borderColor) {
|
|
|
71
119
|
const fill = Math.max(0, width - chipW - visibleWidth(infoChip));
|
|
72
120
|
return chip + borderColor('─'.repeat(fill)) + infoChip;
|
|
73
121
|
}
|
|
122
|
+
/** The stock pi-tui editor paints its fake cursor as a solid reverse-video
|
|
123
|
+
* block (`\x1b[7m…\x1b[0m`) unconditionally — in a wall of attach panes every
|
|
124
|
+
* pane shows an identical solid white cursor, and nothing tells you which one
|
|
125
|
+
* keys actually go to. When the hosting pane/terminal is UNFOCUSED we rewrite
|
|
126
|
+
* that cell into a MUTED cursor, mirroring how real terminals de-emphasize
|
|
127
|
+
* the hardware cursor on blur. A pixel-true full-cell outline isn't possible
|
|
128
|
+
* in text cells, and hollow-box glyphs (▯/⍘/□) render undersized, so the
|
|
129
|
+
* muted form is a full-size block in a dim gray background — same footprint
|
|
130
|
+
* as the focused cursor, clearly not the bright active one (ruling: tried the
|
|
131
|
+
* glyphs, Silas preferred the dim block). The rewrite anchors on pi-tui's
|
|
132
|
+
* zero-width CURSOR_MARKER (emitted right before the fake cursor while the
|
|
133
|
+
* editor component has TUI focus) so it can never touch reverse video
|
|
134
|
+
* elsewhere in the line. Returns undefined when the line carries no cursor.
|
|
135
|
+
* Exported pure for tests. */
|
|
136
|
+
const SOLID_CURSOR_PREFIX = '\x1b[7m';
|
|
137
|
+
const SOLID_CURSOR_RESET = '\x1b[0m';
|
|
138
|
+
/** Dim gray bg, default fg — the char under the cursor stays readable. */
|
|
139
|
+
const MUTED_CURSOR_ON = '\x1b[48;5;240m';
|
|
140
|
+
export function outlineCursorLine(line) {
|
|
141
|
+
const m = line.indexOf(CURSOR_MARKER);
|
|
142
|
+
if (m === -1)
|
|
143
|
+
return undefined;
|
|
144
|
+
const start = m + CURSOR_MARKER.length;
|
|
145
|
+
if (!line.startsWith(SOLID_CURSOR_PREFIX, start))
|
|
146
|
+
return undefined;
|
|
147
|
+
const gStart = start + SOLID_CURSOR_PREFIX.length;
|
|
148
|
+
const end = line.indexOf(SOLID_CURSOR_RESET, gStart);
|
|
149
|
+
if (end === -1)
|
|
150
|
+
return undefined;
|
|
151
|
+
const grapheme = line.slice(gStart, end);
|
|
152
|
+
const replacement = `${MUTED_CURSOR_ON}${grapheme}\x1b[0m`;
|
|
153
|
+
return line.slice(0, start) + replacement + line.slice(end + SOLID_CURSOR_RESET.length);
|
|
154
|
+
}
|
|
74
155
|
export class TitledEditor extends CustomEditor {
|
|
75
156
|
/** crtr's OWN keybindings manager — the same one CustomEditor matches `app.*`
|
|
76
157
|
* against. We keep a reference so `handleInput` can resolve the newline chord
|
|
@@ -114,8 +195,22 @@ export class TitledEditor extends CustomEditor {
|
|
|
114
195
|
titleStyle = defaultTitleStyle;
|
|
115
196
|
/** Mode badge colorizer, matched to the current mode type. */
|
|
116
197
|
modeStyle = defaultTitleStyle;
|
|
198
|
+
/** Whether the hosting tmux pane / terminal window has FOCUS (terminal focus
|
|
199
|
+
* reporting, DECSET 1004 — attach-cmd flips this on `\x1b[I` / `\x1b[O`).
|
|
200
|
+
* Distinct from TUI component focus: the editor keeps component focus while
|
|
201
|
+
* its pane is blurred. False → the fake cursor renders as an outline. */
|
|
202
|
+
paneFocused = true;
|
|
117
203
|
render(width) {
|
|
118
204
|
const lines = super.render(width);
|
|
205
|
+
if (!this.paneFocused) {
|
|
206
|
+
for (let i = 0; i < lines.length; i++) {
|
|
207
|
+
const hollowed = outlineCursorLine(lines[i]);
|
|
208
|
+
if (hollowed !== undefined) {
|
|
209
|
+
lines[i] = hollowed;
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
119
214
|
if ((this.title || this.info) && lines.length > 0) {
|
|
120
215
|
// Replace the stock top border; dashes in the current (thinking-aware)
|
|
121
216
|
// border color.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// `crtr canvas browse` — the interactive full-screen canvas navigator.
|
|
2
2
|
//
|
|
3
|
-
// A raw-mode TUI over the WHOLE canvas: tabs (All/Live/Dormant/
|
|
3
|
+
// A raw-mode TUI over the WHOLE canvas: tabs (All/Live/Dormant/Attention/Pinned), an
|
|
4
4
|
// auto-collapsed tree, and `/` fuzzy search. Enter resumes the chosen node via
|
|
5
5
|
// `crtr node focus` (the ONLY sanctioned open — reviveNode, never `pi --session`).
|
|
6
6
|
// Owns the screen, so it returns void and writes nothing to stdout itself.
|
|
@@ -14,7 +14,7 @@ import { readState } from '../core/config.js';
|
|
|
14
14
|
export const browseLeaf = defineLeaf({
|
|
15
15
|
name: 'browse',
|
|
16
16
|
description: 'open the interactive canvas navigator (tabs/tree/search)',
|
|
17
|
-
whenToUse: 'you want to VIEW and NAVIGATE the whole canvas interactively — a full-screen TUI with tabs (All/Live/Dormant/
|
|
17
|
+
whenToUse: 'you want to VIEW and NAVIGATE the whole canvas interactively — a full-screen TUI with tabs (All/Live/Dormant/Attention/Pinned), an expandable tree (children auto-collapsed; → to expand), `/` fuzzy search, `f` to pin, `m` to message and `R` to rename a node in place, and `?` for the full keymap; Enter resumes the chosen node. Use this to find your way around a large canvas. Use `canvas dashboard` instead for a one-shot ASCII tree you can pipe, and `node inspect list` for a flat machine-readable roster',
|
|
18
18
|
help: {
|
|
19
19
|
name: 'canvas browse',
|
|
20
20
|
summary: 'interactive full-screen canvas navigator — tabs, an auto-collapsed tree, and `/` fuzzy search; Enter resumes the chosen node via `crtr node focus`. Outside a TTY it prints the static forest and exits',
|
|
@@ -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
|
},
|
|
@@ -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
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defineBranch, defineLeaf } from '../../core/command.js';
|
|
2
|
-
import { readConfig,
|
|
2
|
+
import { readConfig, updateConfig, configPath as coreConfigPath } from '../../core/config.js';
|
|
3
3
|
import { usage, notFound } from '../../core/errors.js';
|
|
4
4
|
import { scopeRoot, listScopes } from '../../core/scope.js';
|
|
5
5
|
import { resolveScope } from './shared.js';
|
|
@@ -11,7 +11,6 @@ const TOP_LEVEL_KEYS = new Set([
|
|
|
11
11
|
'marketplaces',
|
|
12
12
|
'plugins',
|
|
13
13
|
'max_panes_per_window',
|
|
14
|
-
'canvasNav',
|
|
15
14
|
'modelLadders',
|
|
16
15
|
]);
|
|
17
16
|
const MODEL_PROVIDERS = new Set(['anthropic', 'openai']);
|
|
@@ -44,6 +43,9 @@ function parseConfigValue(raw) {
|
|
|
44
43
|
function setNestedValue(cfg, key, rawValue, value) {
|
|
45
44
|
const parts = key.split('.');
|
|
46
45
|
const topKey = parts[0];
|
|
46
|
+
if (topKey === 'keybindings') {
|
|
47
|
+
throw usage('keybindings are edited only through `crtr sys setup`');
|
|
48
|
+
}
|
|
47
49
|
if (!TOP_LEVEL_KEYS.has(topKey)) {
|
|
48
50
|
throw usage(`unknown config key: ${topKey} (expected: ${[...TOP_LEVEL_KEYS].join('|')})`);
|
|
49
51
|
}
|
|
@@ -69,16 +71,6 @@ function setNestedValue(cfg, key, rawValue, value) {
|
|
|
69
71
|
cfg.max_panes_per_window = Math.floor(value);
|
|
70
72
|
return;
|
|
71
73
|
}
|
|
72
|
-
if (topKey === 'canvasNav') {
|
|
73
|
-
// Only the scalar prefixKey is settable here; the prefixBinds/graphBinds
|
|
74
|
-
// record tables are edited directly in config.json (run `crtr sys config
|
|
75
|
-
// path` to locate it).
|
|
76
|
-
if (key === 'canvasNav.prefixKey') {
|
|
77
|
-
cfg.canvasNav.prefixKey = rawValue;
|
|
78
|
-
return;
|
|
79
|
-
}
|
|
80
|
-
throw usage(`canvasNav.${parts.slice(1).join('.') || '*'} is a record table — edit config.json directly (run \`crtr sys config path\`). Only canvasNav.prefixKey is settable via this command.`);
|
|
81
|
-
}
|
|
82
74
|
if (topKey === 'modelLadders') {
|
|
83
75
|
if (parts.length === 2 && parts[1] === 'defaultProvider') {
|
|
84
76
|
if (!isModelProvider(rawValue)) {
|
|
@@ -121,12 +113,12 @@ function setNestedValue(cfg, key, rawValue, value) {
|
|
|
121
113
|
const configGet = defineLeaf({
|
|
122
114
|
name: 'get',
|
|
123
115
|
description: 'read a config value by key',
|
|
124
|
-
whenToUse: 'you want to
|
|
116
|
+
whenToUse: 'you want to inspect one config value — including the whole user keybindings object — optionally from a specific scope. Use sys setup to edit keybindings and sys config set for supported scalar settings.',
|
|
125
117
|
help: {
|
|
126
118
|
name: 'sys config get',
|
|
127
119
|
summary: 'read a config value by dotted key',
|
|
128
120
|
params: [
|
|
129
|
-
{ kind: 'positional', name: 'key', type: 'string', required: true, constraint: 'Dotted key path. Top-level keys: auto_update, marketplaces, plugins, max_panes_per_window,
|
|
121
|
+
{ kind: 'positional', name: 'key', type: 'string', required: true, constraint: 'Dotted key path. Top-level keys: auto_update, marketplaces, plugins, max_panes_per_window, modelLadders, or the whole keybindings object. Keybindings are edited only through sys setup.' },
|
|
130
122
|
{ kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project'], required: false, constraint: 'Scope to read from. Default: user.' },
|
|
131
123
|
],
|
|
132
124
|
output: [
|
|
@@ -140,6 +132,9 @@ const configGet = defineLeaf({
|
|
|
140
132
|
run: async (input) => {
|
|
141
133
|
const key = input['key'];
|
|
142
134
|
const scope = resolveScope(input['scope']);
|
|
135
|
+
if (key.startsWith('keybindings.') || (key === 'keybindings' && scope !== 'user')) {
|
|
136
|
+
throw notFound('keybindings are available only as the whole user-scope object');
|
|
137
|
+
}
|
|
143
138
|
const cfg = readConfig(scope);
|
|
144
139
|
const value = getNestedValue(cfg, key);
|
|
145
140
|
if (value === undefined) {
|
|
@@ -151,12 +146,12 @@ const configGet = defineLeaf({
|
|
|
151
146
|
const configSet = defineLeaf({
|
|
152
147
|
name: 'set',
|
|
153
148
|
description: 'write a config value by key',
|
|
154
|
-
whenToUse: 'you want to change a
|
|
149
|
+
whenToUse: 'you want to change a supported scalar crtr setting in user or project scope. Use sys setup instead for keybindings, and sys config get to inspect values.',
|
|
155
150
|
help: {
|
|
156
151
|
name: 'sys config set',
|
|
157
152
|
summary: 'write a config value by dotted key',
|
|
158
153
|
params: [
|
|
159
|
-
{ kind: 'positional', name: 'key', type: 'string', required: true, constraint: 'Dotted key path. Supported: auto_update.crtr, auto_update.content, auto_update.interval_hours, max_panes_per_window,
|
|
154
|
+
{ kind: 'positional', name: 'key', type: 'string', required: true, constraint: 'Dotted key path. Supported: auto_update.crtr, auto_update.content, auto_update.interval_hours, max_panes_per_window, modelLadders.defaultProvider, modelLadders.<anthropic|openai>.<ultra|strong|medium|light>. Keybindings are edited only through sys setup.' },
|
|
160
155
|
{ kind: 'flag', name: 'value', type: 'string', required: true, constraint: 'value VALUE — string, required. Stored as-is if quoted; coerced to number or boolean when unambiguous.' },
|
|
161
156
|
{ kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project'], required: false, constraint: 'Scope to write to. Default: user.' },
|
|
162
157
|
],
|
|
@@ -174,9 +169,11 @@ const configSet = defineLeaf({
|
|
|
174
169
|
const scope = resolveScope(input['scope']);
|
|
175
170
|
// Flags are stringly-typed; coerce to number or boolean when unambiguous
|
|
176
171
|
const parsed = parseConfigValue(rawValue);
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
172
|
+
// Mutate + persist through the central raw-partial path. `updateConfig`
|
|
173
|
+
// writes only the keys this set actually touched -- a non-ladder set never
|
|
174
|
+
// freezes a materialized `modelLadders`, and a direct
|
|
175
|
+
// `modelLadders.<provider>.<strength>` set persists only the touched rung.
|
|
176
|
+
const cfg = updateConfig(scope, (c) => setNestedValue(c, key, rawValue, parsed));
|
|
180
177
|
// Read back the written value for echo
|
|
181
178
|
const written = getNestedValue(cfg, key);
|
|
182
179
|
return { key, value: written, scope };
|
|
@@ -185,7 +182,7 @@ const configSet = defineLeaf({
|
|
|
185
182
|
const configPath = defineLeaf({
|
|
186
183
|
name: 'path',
|
|
187
184
|
description: 'print path(s) to config.json',
|
|
188
|
-
whenToUse: 'you need the absolute path to
|
|
185
|
+
whenToUse: 'you need the absolute path to a user or project config.json for inspection or backup.',
|
|
189
186
|
help: {
|
|
190
187
|
name: 'sys config path',
|
|
191
188
|
summary: 'print absolute path(s) to config.json',
|
|
@@ -225,7 +222,7 @@ const configPath = defineLeaf({
|
|
|
225
222
|
export const configBranch = defineBranch({
|
|
226
223
|
name: 'config',
|
|
227
224
|
description: 'read and write configuration',
|
|
228
|
-
whenToUse: 'inspecting
|
|
225
|
+
whenToUse: 'inspecting crtr settings, changing supported scalar values, or locating config.json. Keybinding changes belong in sys setup.',
|
|
229
226
|
help: {
|
|
230
227
|
name: 'sys config',
|
|
231
228
|
summary: 'read and write crtr configuration, including model ladders',
|