@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.
Files changed (147) hide show
  1. package/README.md +1 -1
  2. package/dist/builtin-memory/00-runtime-base.md +3 -10
  3. package/dist/builtin-memory/04-base-worker.md +18 -0
  4. package/dist/builtin-memory/04-orchestration-kernel.md +1 -1
  5. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +13 -34
  6. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +622 -724
  7. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +231 -68
  8. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/statusline.ts +2 -9
  9. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +361 -90
  10. package/dist/builtin-pi-packages/pi-crtr-extensions/package-lock.json +2 -2
  11. package/dist/builtin-pi-packages/pi-mode-switch/README.md +11 -39
  12. package/dist/builtin-pi-packages/pi-mode-switch/extensions/index.ts +20 -15
  13. package/dist/builtin-pi-packages/pi-mode-switch/package.json +1 -1
  14. package/dist/builtin-views/canvas/tui.mjs +8 -9
  15. package/dist/builtin-views/chat/tui.mjs +12 -12
  16. package/dist/builtin-views/git-pr/tui.mjs +7 -8
  17. package/dist/builtin-views/inbox/tui.mjs +27 -41
  18. package/dist/builtin-views/linkedin/tui.mjs +23 -37
  19. package/dist/builtin-views/prompt-review/tui.mjs +11 -11
  20. package/dist/builtin-views/settings/tui.mjs +11 -11
  21. package/dist/builtin-views/workspace-sidebar/tui.mjs +8 -9
  22. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +3 -3
  23. package/dist/clients/attach/__tests__/crtr-output-render.test.js +1 -1
  24. package/dist/clients/attach/__tests__/mermaid-render.test.d.ts +1 -0
  25. package/dist/clients/attach/__tests__/mermaid-render.test.js +20 -0
  26. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.d.ts +1 -0
  27. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +110 -0
  28. package/dist/clients/attach/attach-cmd.d.ts +1 -1
  29. package/dist/clients/attach/attach-cmd.js +738 -735
  30. package/dist/clients/attach/canvas-panels.js +2 -3
  31. package/dist/clients/attach/chat-view.d.ts +7 -8
  32. package/dist/clients/attach/chat-view.js +119 -79
  33. package/dist/clients/attach/edit-diff-render.d.ts +6 -0
  34. package/dist/clients/attach/edit-diff-render.js +263 -0
  35. package/dist/clients/attach/input-controller.d.ts +28 -2
  36. package/dist/clients/attach/input-controller.js +38 -5
  37. package/dist/clients/attach/mermaid-render.js +5 -2
  38. package/dist/clients/attach/pickers.d.ts +8 -7
  39. package/dist/clients/attach/pickers.js +11 -17
  40. package/dist/clients/attach/slash-commands.d.ts +9 -0
  41. package/dist/clients/attach/slash-commands.js +127 -7
  42. package/dist/clients/attach/titled-editor.d.ts +12 -1
  43. package/dist/clients/attach/titled-editor.js +103 -8
  44. package/dist/commands/canvas-browse.js +2 -2
  45. package/dist/commands/memory/lint.js +39 -5
  46. package/dist/commands/memory/write.js +1 -0
  47. package/dist/commands/node.js +9 -2
  48. package/dist/commands/surface-tmux-spread.js +1 -3
  49. package/dist/commands/sys/__tests__/config-keybindings.test.d.ts +1 -0
  50. package/dist/commands/sys/__tests__/config-keybindings.test.js +55 -0
  51. package/dist/commands/sys/__tests__/config-model-ladders.test.d.ts +1 -0
  52. package/dist/commands/sys/__tests__/config-model-ladders.test.js +121 -0
  53. package/dist/commands/sys/config.js +18 -21
  54. package/dist/commands/sys/setup-core.d.ts +12 -1
  55. package/dist/commands/sys/setup-core.js +23 -3
  56. package/dist/commands/sys/setup.js +42 -12
  57. package/dist/core/__tests__/base-worker-prompt.test.d.ts +1 -0
  58. package/dist/core/__tests__/base-worker-prompt.test.js +24 -0
  59. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +232 -1
  60. package/dist/core/__tests__/fault-classifier.test.js +15 -0
  61. package/dist/core/__tests__/full/broker-dialogs.test.js +7 -2
  62. package/dist/core/__tests__/host-teardown-process-group.test.js +15 -4
  63. package/dist/core/__tests__/stream-watchdog.test.d.ts +1 -0
  64. package/dist/core/__tests__/stream-watchdog.test.js +70 -0
  65. package/dist/core/__tests__/tmux-surface.test.js +72 -0
  66. package/dist/core/canvas/browse/__tests__/model.test.js +23 -5
  67. package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
  68. package/dist/core/canvas/browse/app.js +164 -5
  69. package/dist/core/canvas/browse/model.d.ts +10 -6
  70. package/dist/core/canvas/browse/model.js +20 -12
  71. package/dist/core/canvas/browse/pins.d.ts +4 -0
  72. package/dist/core/canvas/browse/pins.js +29 -0
  73. package/dist/core/canvas/browse/render.d.ts +13 -1
  74. package/dist/core/canvas/browse/render.js +72 -15
  75. package/dist/core/canvas/nav-model.js +5 -11
  76. package/dist/core/canvas/paths.d.ts +5 -0
  77. package/dist/core/canvas/paths.js +7 -0
  78. package/dist/core/config.d.ts +53 -2
  79. package/dist/core/config.js +228 -110
  80. package/dist/core/fault-classifier.js +1 -1
  81. package/dist/core/fs-utils.d.ts +1 -0
  82. package/dist/core/fs-utils.js +15 -1
  83. package/dist/core/keybindings/__tests__/resolve.test.d.ts +1 -0
  84. package/dist/core/keybindings/__tests__/resolve.test.js +224 -0
  85. package/dist/core/keybindings/catalog.d.ts +14 -0
  86. package/dist/core/keybindings/catalog.js +256 -0
  87. package/dist/core/keybindings/index.d.ts +5 -0
  88. package/dist/core/keybindings/index.js +4 -0
  89. package/dist/core/keybindings/match.d.ts +29 -0
  90. package/dist/core/keybindings/match.js +99 -0
  91. package/dist/core/keybindings/persistence.d.ts +14 -0
  92. package/dist/core/keybindings/persistence.js +31 -0
  93. package/dist/core/keybindings/resolve.d.ts +14 -0
  94. package/dist/core/keybindings/resolve.js +305 -0
  95. package/dist/core/keybindings/types.d.ts +37 -0
  96. package/dist/core/keybindings/types.js +1 -0
  97. package/dist/core/memory-resolver.d.ts +1 -1
  98. package/dist/core/memory-resolver.js +10 -3
  99. package/dist/core/predicate.d.ts +5 -3
  100. package/dist/core/predicate.js +5 -3
  101. package/dist/core/runtime/broker-protocol.d.ts +33 -5
  102. package/dist/core/runtime/broker.js +163 -14
  103. package/dist/core/runtime/launch.d.ts +39 -6
  104. package/dist/core/runtime/launch.js +78 -20
  105. package/dist/core/runtime/naming.js +3 -3
  106. package/dist/core/runtime/pi-cli.d.ts +6 -0
  107. package/dist/core/runtime/pi-cli.js +16 -2
  108. package/dist/core/runtime/placement.d.ts +2 -2
  109. package/dist/core/runtime/placement.js +4 -1
  110. package/dist/core/runtime/promote.js +4 -0
  111. package/dist/core/runtime/recap.d.ts +3 -3
  112. package/dist/core/runtime/recap.js +47 -44
  113. package/dist/core/runtime/recycle.js +6 -1
  114. package/dist/core/runtime/reset.js +5 -0
  115. package/dist/core/runtime/session-list-cache.d.ts +23 -0
  116. package/dist/core/runtime/session-list-cache.js +270 -0
  117. package/dist/core/runtime/spawn.js +51 -32
  118. package/dist/core/runtime/stream-watchdog.d.ts +26 -0
  119. package/dist/core/runtime/stream-watchdog.js +75 -0
  120. package/dist/core/runtime/tmux-chrome.d.ts +1 -1
  121. package/dist/core/runtime/tmux-chrome.js +2 -2
  122. package/dist/core/runtime/tmux.d.ts +18 -15
  123. package/dist/core/runtime/tmux.js +216 -110
  124. package/dist/core/tui/__tests__/host-keybindings.test.d.ts +1 -0
  125. package/dist/core/tui/__tests__/host-keybindings.test.js +112 -0
  126. package/dist/core/tui/host.d.ts +30 -1
  127. package/dist/core/tui/host.js +148 -34
  128. package/dist/core/view/contract.d.ts +17 -3
  129. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +41 -0
  130. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.d.ts +1 -0
  131. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +121 -0
  132. package/dist/pi-extensions/canvas-inbox-watcher.d.ts +1 -1
  133. package/dist/pi-extensions/canvas-inbox-watcher.js +203 -25
  134. package/dist/pi-extensions/canvas-recap.d.ts +4 -0
  135. package/dist/pi-extensions/canvas-recap.js +38 -37
  136. package/dist/pi-extensions/canvas-stophook.d.ts +11 -0
  137. package/dist/pi-extensions/canvas-stophook.js +33 -4
  138. package/dist/types.d.ts +3 -13
  139. package/dist/types.js +2 -26
  140. package/dist/web-client/assets/{index-DiFuLcp6.js → index--md2ylfi.js} +1 -1
  141. package/dist/web-client/index.html +1 -1
  142. package/dist/web-client/sw.js +1 -1
  143. package/package.json +3 -3
  144. package/dist/pi-extensions/canvas-nav.d.ts +0 -43
  145. package/dist/pi-extensions/canvas-nav.js +0 -640
  146. package/dist/pi-extensions/widget-order-bus.d.ts +0 -6
  147. 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 color scale is a deliberate cool→warm ramp between a blue and
11
- * its orange complement, evenly spaced across the five active levels so each
12
- * step stays readable without jumping through unrelated hues. `off` falls
13
- * back to the caller's default border color. The endpoints are deliberately
14
- * deep (not bright) hues: the same colors double as the title-chip BACKGROUND
15
- * under bold white text, so every step must clear ~4.5:1 white-on-color
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/Flagged), an
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/Flagged), an expandable tree (children auto-collapsed; → to expand), and `/` fuzzy search that auto-expands ancestors of matches; 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',
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 file counts: {memory_stores}.' },
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' +
@@ -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 work, or walking the subscription graph. CRITICAL, and your default reflex — spawning a child is the first move on almost any task, not a fallback. Your own context window is the scarce resource: before you read files into it to understand, implement, debug, or change code you do not already know, hand that to a fresh node and get back just the conclusion, matching the node kind to the work. Keep inline only a fact you already know. Fan out independent units as CONCURRENT children — a wake with idle workers is wasted; serialize only true dependencies, and never let two live children edit the same files. Once you delegate a unit, do not also run it yourself: you auto-subscribe on spawn, so its finish wakes you. Use the help surface for the exact node contract before invoking it.'
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: hand any self-contained unit of work to a child instead of doing it inline it keeps your own context window (your scarce resource) free for steering. Spawn independent units in PARALLEL; a wake with idle workers is wasted. Serialize only true dependencies, and never let two live children edit the same files.\n\n' +
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
- // Also bound to the `/graph` view's `e` key (src/pi-extensions/canvas-nav.ts),
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,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,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, writeConfig, configPath as coreConfigPath } from '../../core/config.js';
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 read a single config value by its dotted key the current auto_update policy, max_panes_per_window, the canvasNav prefix key, or model ladders — optionally from a specific scope. Use sys config set instead to change a value, sys config path to locate the file for hand-editing.',
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, canvasNav, modelLadders (read whole; edit canvasNav.prefixBinds/graphBinds in config.json directly).' },
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 crtr setting — flip auto_update.crtr or auto_update.content to notify, apply, or off, raise max_panes_per_window, rebind canvasNav.prefixKey, or set model ladders — written to the user or project scope. Use sys config get instead to read a value; the canvasNav record tables are not settable here, so edit config.json directly (sys config path) for those.',
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, canvasNav.prefixKey, modelLadders.defaultProvider, modelLadders.<anthropic|openai>.<ultra|strong|medium|light>. The canvasNav.prefixBinds/graphBinds record tables are not settable here — edit config.json directly (`crtr sys config path`).' },
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
- const cfg = readConfig(scope);
178
- setNestedValue(cfg, key, rawValue, parsed);
179
- writeConfig(scope, cfg);
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 config.json typically to hand-edit settings sys config set cannot reach, like the canvasNav.prefixBinds and graphBinds record tables.',
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 or changing crtr settings — read a value with sys config get, change one with sys config set (auto_update policy, max_panes_per_window, canvasNav.prefixKey, model ladders), or locate config.json with sys config path to hand-edit the record tables set cannot reach.',
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',