@crouton-kit/crouter 0.3.70 → 0.3.79

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/README.md +1 -1
  2. package/dist/build-root.d.ts +12 -4
  3. package/dist/build-root.js +25 -6
  4. package/dist/builtin-memory/00-runtime-base.md +3 -10
  5. package/dist/builtin-memory/04-base-worker.md +18 -0
  6. package/dist/builtin-memory/04-orchestration-kernel.md +1 -1
  7. package/dist/builtin-memory/crouter-development/plugins.md +82 -5
  8. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +13 -34
  9. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +1115 -1
  10. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +234 -71
  11. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/statusline.ts +2 -9
  12. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +2 -516
  13. package/dist/builtin-pi-packages/pi-crtr-extensions/package-lock.json +2 -2
  14. package/dist/builtin-pi-packages/pi-mode-switch/README.md +11 -39
  15. package/dist/builtin-pi-packages/pi-mode-switch/extensions/index.ts +20 -15
  16. package/dist/builtin-pi-packages/pi-mode-switch/package.json +1 -1
  17. package/dist/builtin-views/canvas/tui.mjs +8 -9
  18. package/dist/builtin-views/chat/tui.mjs +12 -12
  19. package/dist/builtin-views/git-pr/tui.mjs +7 -8
  20. package/dist/builtin-views/inbox/tui.mjs +27 -41
  21. package/dist/builtin-views/linkedin/tui.mjs +23 -37
  22. package/dist/builtin-views/prompt-review/tui.mjs +11 -11
  23. package/dist/builtin-views/settings/tui.mjs +11 -11
  24. package/dist/builtin-views/workspace-sidebar/tui.mjs +8 -9
  25. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +11 -6
  26. package/dist/clients/attach/__tests__/attach-keybindings.test.d.ts +1 -0
  27. package/dist/clients/attach/__tests__/attach-keybindings.test.js +113 -0
  28. package/dist/clients/attach/__tests__/crtr-output-render.test.js +1 -1
  29. package/dist/clients/attach/__tests__/mermaid-render.test.d.ts +1 -0
  30. package/dist/clients/attach/__tests__/mermaid-render.test.js +28 -0
  31. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.d.ts +1 -0
  32. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +110 -0
  33. package/dist/clients/attach/attach-cmd.d.ts +9 -1
  34. package/dist/clients/attach/attach-cmd.js +841 -792
  35. package/dist/clients/attach/auth-pickers.d.ts +0 -12
  36. package/dist/clients/attach/auth-pickers.js +64 -15
  37. package/dist/clients/attach/canvas-panels.js +2 -3
  38. package/dist/clients/attach/chat-view.d.ts +7 -8
  39. package/dist/clients/attach/chat-view.js +119 -79
  40. package/dist/clients/attach/edit-diff-render.d.ts +6 -0
  41. package/dist/clients/attach/edit-diff-render.js +263 -0
  42. package/dist/clients/attach/graph-overlay.d.ts +12 -2
  43. package/dist/clients/attach/graph-overlay.js +83 -33
  44. package/dist/clients/attach/input-controller.d.ts +28 -2
  45. package/dist/clients/attach/input-controller.js +38 -5
  46. package/dist/clients/attach/mermaid-render.js +5 -2
  47. package/dist/clients/attach/pickers.d.ts +8 -7
  48. package/dist/clients/attach/pickers.js +11 -17
  49. package/dist/clients/attach/slash-commands.d.ts +9 -0
  50. package/dist/clients/attach/slash-commands.js +127 -7
  51. package/dist/clients/attach/titled-editor.d.ts +12 -1
  52. package/dist/clients/attach/titled-editor.js +103 -8
  53. package/dist/commands/canvas-browse.js +2 -2
  54. package/dist/commands/human/queue.js +3 -4
  55. package/dist/commands/memory/lint.js +39 -5
  56. package/dist/commands/memory/write.js +1 -0
  57. package/dist/commands/node.js +9 -2
  58. package/dist/commands/pkg/plugin-inspect.js +22 -1
  59. package/dist/commands/pkg/plugin-manage.js +31 -9
  60. package/dist/commands/surface-tmux-spread.js +1 -3
  61. package/dist/commands/sys/__tests__/config-keybindings.test.d.ts +1 -0
  62. package/dist/commands/sys/__tests__/config-keybindings.test.js +55 -0
  63. package/dist/commands/sys/__tests__/config-model-ladders.test.d.ts +1 -0
  64. package/dist/commands/sys/__tests__/config-model-ladders.test.js +121 -0
  65. package/dist/commands/sys/__tests__/setup-core.test.js +158 -1
  66. package/dist/commands/sys/config.js +18 -21
  67. package/dist/commands/sys/doctor.js +42 -4
  68. package/dist/commands/sys/setup-core.d.ts +49 -1
  69. package/dist/commands/sys/setup-core.js +161 -4
  70. package/dist/commands/sys/setup.d.ts +88 -0
  71. package/dist/commands/sys/setup.js +943 -169
  72. package/dist/commands/view-pick.d.ts +4 -0
  73. package/dist/commands/view-pick.js +17 -7
  74. package/dist/core/__tests__/base-worker-prompt.test.d.ts +1 -0
  75. package/dist/core/__tests__/base-worker-prompt.test.js +24 -0
  76. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +232 -1
  77. package/dist/core/__tests__/canvas-inbox-watcher.test.js +34 -9
  78. package/dist/core/__tests__/command-plugins-surfaces.test.d.ts +1 -0
  79. package/dist/core/__tests__/command-plugins-surfaces.test.js +298 -0
  80. package/dist/core/__tests__/command-plugins.test.d.ts +1 -0
  81. package/dist/core/__tests__/command-plugins.test.js +444 -0
  82. package/dist/core/__tests__/fault-classifier.test.js +15 -0
  83. package/dist/core/__tests__/fixtures/fake-engine.d.ts +6 -0
  84. package/dist/core/__tests__/fixtures/fake-engine.js +9 -1
  85. package/dist/core/__tests__/full/broker-dialogs.test.js +7 -2
  86. package/dist/core/__tests__/host-teardown-process-group.test.js +15 -4
  87. package/dist/core/__tests__/preview-registry-sync.test.js +30 -1
  88. package/dist/core/__tests__/scope-crouter-home-fence.test.d.ts +1 -0
  89. package/dist/core/__tests__/scope-crouter-home-fence.test.js +55 -0
  90. package/dist/core/__tests__/stream-watchdog.test.d.ts +1 -0
  91. package/dist/core/__tests__/stream-watchdog.test.js +70 -0
  92. package/dist/core/__tests__/tmux-surface.test.js +72 -0
  93. package/dist/core/canvas/browse/__tests__/model.test.js +23 -5
  94. package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
  95. package/dist/core/canvas/browse/app.d.ts +6 -0
  96. package/dist/core/canvas/browse/app.js +230 -41
  97. package/dist/core/canvas/browse/model.d.ts +10 -6
  98. package/dist/core/canvas/browse/model.js +20 -12
  99. package/dist/core/canvas/browse/pins.d.ts +4 -0
  100. package/dist/core/canvas/browse/pins.js +29 -0
  101. package/dist/core/canvas/browse/render.d.ts +13 -1
  102. package/dist/core/canvas/browse/render.js +72 -15
  103. package/dist/core/canvas/nav-model.js +5 -11
  104. package/dist/core/canvas/paths.d.ts +5 -0
  105. package/dist/core/canvas/paths.js +7 -0
  106. package/dist/core/command-plugins/adapter.d.ts +15 -0
  107. package/dist/core/command-plugins/adapter.js +145 -0
  108. package/dist/core/command-plugins/compose.d.ts +5 -0
  109. package/dist/core/command-plugins/compose.js +56 -0
  110. package/dist/core/command-plugins/discovery.d.ts +104 -0
  111. package/dist/core/command-plugins/discovery.js +565 -0
  112. package/dist/core/config.d.ts +48 -0
  113. package/dist/core/config.js +228 -110
  114. package/dist/core/fault-classifier.js +1 -1
  115. package/dist/core/fs-utils.d.ts +1 -0
  116. package/dist/core/fs-utils.js +15 -1
  117. package/dist/core/keybindings/__tests__/bespoke-consumers.test.d.ts +1 -0
  118. package/dist/core/keybindings/__tests__/bespoke-consumers.test.js +40 -0
  119. package/dist/core/keybindings/__tests__/resolve.test.d.ts +1 -0
  120. package/dist/core/keybindings/__tests__/resolve.test.js +224 -0
  121. package/dist/core/keybindings/catalog.d.ts +14 -0
  122. package/dist/core/keybindings/catalog.js +257 -0
  123. package/dist/core/keybindings/index.d.ts +5 -0
  124. package/dist/core/keybindings/index.js +4 -0
  125. package/dist/core/keybindings/match.d.ts +29 -0
  126. package/dist/core/keybindings/match.js +99 -0
  127. package/dist/core/keybindings/persistence.d.ts +14 -0
  128. package/dist/core/keybindings/persistence.js +31 -0
  129. package/dist/core/keybindings/resolve.d.ts +14 -0
  130. package/dist/core/keybindings/resolve.js +305 -0
  131. package/dist/core/keybindings/types.d.ts +37 -0
  132. package/dist/core/keybindings/types.js +1 -0
  133. package/dist/core/memory-resolver.d.ts +1 -1
  134. package/dist/core/memory-resolver.js +10 -3
  135. package/dist/core/predicate.d.ts +5 -3
  136. package/dist/core/predicate.js +5 -3
  137. package/dist/core/profiles/select.d.ts +6 -0
  138. package/dist/core/profiles/select.js +86 -52
  139. package/dist/core/provider-management.d.ts +12 -0
  140. package/dist/core/provider-management.js +24 -0
  141. package/dist/core/runtime/broker-protocol.d.ts +33 -5
  142. package/dist/core/runtime/broker.js +164 -14
  143. package/dist/core/runtime/launch.d.ts +39 -6
  144. package/dist/core/runtime/launch.js +78 -20
  145. package/dist/core/runtime/naming.js +3 -3
  146. package/dist/core/runtime/pi-cli.d.ts +6 -0
  147. package/dist/core/runtime/pi-cli.js +16 -2
  148. package/dist/core/runtime/pi-vendored.d.ts +8 -0
  149. package/dist/core/runtime/pi-vendored.js +14 -0
  150. package/dist/core/runtime/placement.d.ts +2 -2
  151. package/dist/core/runtime/placement.js +4 -1
  152. package/dist/core/runtime/promote.js +4 -0
  153. package/dist/core/runtime/recap.d.ts +3 -3
  154. package/dist/core/runtime/recap.js +75 -47
  155. package/dist/core/runtime/recycle.js +6 -1
  156. package/dist/core/runtime/reset.js +5 -0
  157. package/dist/core/runtime/session-list-cache.d.ts +33 -0
  158. package/dist/core/runtime/session-list-cache.js +338 -0
  159. package/dist/core/runtime/spawn.js +52 -45
  160. package/dist/core/runtime/stream-watchdog.d.ts +26 -0
  161. package/dist/core/runtime/stream-watchdog.js +75 -0
  162. package/dist/core/runtime/tmux-chrome.d.ts +1 -1
  163. package/dist/core/runtime/tmux-chrome.js +2 -2
  164. package/dist/core/runtime/tmux.d.ts +18 -15
  165. package/dist/core/runtime/tmux.js +217 -110
  166. package/dist/core/scope.js +27 -4
  167. package/dist/core/subscription-state.d.ts +90 -0
  168. package/dist/core/subscription-state.js +762 -0
  169. package/dist/core/tui/__tests__/host-keybindings.test.d.ts +1 -0
  170. package/dist/core/tui/__tests__/host-keybindings.test.js +112 -0
  171. package/dist/core/tui/host.d.ts +30 -1
  172. package/dist/core/tui/host.js +148 -34
  173. package/dist/core/view/contract.d.ts +17 -3
  174. package/dist/daemon/crtrd.js +253 -12
  175. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +41 -0
  176. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.d.ts +1 -0
  177. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +121 -0
  178. package/dist/pi-extensions/canvas-inbox-watcher.d.ts +1 -1
  179. package/dist/pi-extensions/canvas-inbox-watcher.js +203 -25
  180. package/dist/pi-extensions/canvas-recap.d.ts +4 -0
  181. package/dist/pi-extensions/canvas-recap.js +73 -46
  182. package/dist/pi-extensions/canvas-stophook.d.ts +11 -0
  183. package/dist/pi-extensions/canvas-stophook.js +33 -4
  184. package/dist/types.d.ts +9 -26
  185. package/dist/types.js +1 -28
  186. package/dist/web-client/assets/{index-DiFuLcp6.js → index--md2ylfi.js} +1 -1
  187. package/dist/web-client/index.html +1 -1
  188. package/dist/web-client/sw.js +1 -1
  189. package/package.json +8 -4
  190. package/dist/pi-extensions/canvas-nav.d.ts +0 -43
  191. package/dist/pi-extensions/canvas-nav.js +0 -640
  192. package/dist/pi-extensions/widget-order-bus.d.ts +0 -6
  193. package/dist/pi-extensions/widget-order-bus.js +0 -34
@@ -0,0 +1,565 @@
1
+ // Command-plugin discovery + validation.
2
+ //
3
+ // An installed, enabled plugin may declare `commands?: string` in its
4
+ // `plugin.json` — a plugin-root-relative path to one declarative command
5
+ // manifest (`commands.json`). This module reads and validates those manifests
6
+ // WITHOUT executing any plugin code, turning each into either an accepted
7
+ // contribution (a validated top-level branch node) or one or more typed
8
+ // discovery issues. crtr owns parse/help/render/errors; the plugin owns one
9
+ // executable that the adapter direct-spawns per leaf invocation.
10
+ //
11
+ // Everything here is invocation-local and side-effect-free: no cache, no
12
+ // persisted state, no spawning. `discoverCommandContributions` is called only
13
+ // on the fallthrough (non-core-fast-path) command path (see `build-root.ts`);
14
+ // `validateEffectiveCommandPlugins` is the entry point Phase 2 lifecycle
15
+ // surfaces (`pkg plugin show`, `sys doctor`) consume to report issues — it
16
+ // carries cross-plugin collision issues that the per-plugin
17
+ // `validatePluginCommands` structurally cannot see.
18
+ import { realpathSync, readFileSync, statSync } from 'node:fs';
19
+ import { resolve, sep } from 'node:path';
20
+ import { listInstalledPlugins, listInstalledPluginsInRoot } from '../resolver.js';
21
+ import { projectScopeRoots } from '../scope.js';
22
+ const TIERS = new Set(['normal', 'common', 'important']);
23
+ const FLAG_TYPES = new Set(['string', 'int', 'bool', 'path', 'enum']);
24
+ const KEBAB = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
25
+ const MANIFEST_KEYS = new Set(['schemaVersion', 'executable', 'mounts']);
26
+ const MOUNT_KEYS = new Set(['parent', 'node']);
27
+ const BRANCH_KEYS = new Set([
28
+ 'kind', 'name', 'description', 'whenToUse', 'tier', 'rootEntry', 'summary', 'model', 'children',
29
+ ]);
30
+ const LEAF_KEYS = new Set([
31
+ 'kind', 'name', 'description', 'whenToUse', 'tier', 'summary', 'params', 'output', 'outputKind', 'effects',
32
+ ]);
33
+ const ROOT_ENTRY_KEYS = new Set(['concept', 'description', 'whenToUse']);
34
+ // Allowed keys are per-kind: a key valid on a flag (e.g. `default`, `choices`)
35
+ // is rejected on a stdin param, and `repeatable`/`hidden` are not external API
36
+ // at all (repeatable is unimplemented; external plugins cannot declare hidden
37
+ // commands) — so they are unknown keys everywhere. This keeps the strict
38
+ // reject-unknown posture from silently dropping a declared-but-ignored key.
39
+ const PARAM_KEYS_BY_KIND = {
40
+ positional: new Set(['kind', 'name', 'type', 'required', 'constraint']),
41
+ flag: new Set(['kind', 'name', 'type', 'choices', 'required', 'constraint', 'default']),
42
+ stdin: new Set(['kind', 'name', 'required', 'constraint']),
43
+ 'context-file': new Set(['kind', 'name', 'required', 'constraint', 'shape']),
44
+ };
45
+ const OUTPUT_KEYS = new Set(['name', 'type', 'required', 'constraint']);
46
+ // ---------------------------------------------------------------------------
47
+ // Effective plugin resolution (M3 — reuse, no new resolver)
48
+ // ---------------------------------------------------------------------------
49
+ /** The effective, deduped set of enabled plugins that declare a command
50
+ * manifest, in scope-precedence order. Enumerated with existing machinery
51
+ * only: `projectScopeRoots()` (profile-widened, nearest-first) via
52
+ * `listInstalledPluginsInRoot('project', root)`, then user scope. Dedup is by
53
+ * plugin NAME across ALL copies (highest precedence wins) BEFORE the enabled
54
+ * filter — so a disabled higher-precedence copy suppresses lower-precedence
55
+ * copies of the same name. */
56
+ export function effectiveCommandPlugins(startDir = process.cwd(), profileId) {
57
+ const seen = new Set();
58
+ const deduped = [];
59
+ const consider = (p) => {
60
+ if (seen.has(p.name))
61
+ return;
62
+ seen.add(p.name);
63
+ deduped.push(p);
64
+ };
65
+ for (const root of projectScopeRoots(startDir, profileId)) {
66
+ for (const p of listInstalledPluginsInRoot('project', root))
67
+ consider(p);
68
+ }
69
+ for (const p of listInstalledPlugins('user'))
70
+ consider(p);
71
+ return deduped.filter((p) => p.enabled && typeof p.manifest.commands === 'string' && p.manifest.commands.length > 0);
72
+ }
73
+ // ---------------------------------------------------------------------------
74
+ // Discovery snapshot (tree composer entry point)
75
+ // ---------------------------------------------------------------------------
76
+ /** Validate every effective command plugin and compose an invocation-local
77
+ * registry snapshot. `reservedNames` are the core top-level subtree names —
78
+ * a contribution claiming one is silently skipped from the tree and recorded
79
+ * as a `command_collision` issue (core always wins). A plugin-vs-plugin
80
+ * top-level name collision drops all claimants with an issue. Accepted
81
+ * contributions are sorted by name for deterministic help/order. */
82
+ export function discoverCommandContributions(reservedNames, startDir = process.cwd(), profileId) {
83
+ const validations = validateEffectiveCommandPlugins(reservedNames, startDir, profileId);
84
+ const contributions = validations.flatMap((v) => v.contributions);
85
+ contributions.sort((a, b) => a.node.name.localeCompare(b.node.name));
86
+ return { contributions, issues: validations.flatMap((v) => v.issues) };
87
+ }
88
+ /** Validate every effective command plugin, including the cross-plugin
89
+ * top-level name collision pass: a name claimed by more than one plugin is
90
+ * dropped from EVERY claimant's contributions and a `command_collision`
91
+ * issue is recorded on EACH claimant's validation, so per-plugin surfaces
92
+ * (`sys doctor`, `pkg plugin show`) see it too. This is the entry point for
93
+ * any surface that reports on mounted commands — the per-plugin
94
+ * `validatePluginCommands` cannot see other plugins' claims. */
95
+ export function validateEffectiveCommandPlugins(reservedNames, startDir = process.cwd(), profileId) {
96
+ const validations = effectiveCommandPlugins(startDir, profileId).map((p) => validatePluginCommands(p, reservedNames));
97
+ // Plugin-vs-plugin top-level name collision: drop all claimants + flag each.
98
+ const claimants = new Map();
99
+ for (const v of validations) {
100
+ for (const c of v.contributions) {
101
+ const list = claimants.get(c.node.name);
102
+ if (list !== undefined)
103
+ list.push(v);
104
+ else
105
+ claimants.set(c.node.name, [v]);
106
+ }
107
+ }
108
+ for (const [name, vs] of claimants) {
109
+ if (vs.length === 1)
110
+ continue;
111
+ const names = vs.map((x) => x.plugin.name).join(', ');
112
+ for (const v of vs) {
113
+ v.contributions = v.contributions.filter((c) => c.node.name !== name);
114
+ v.issues.push({
115
+ code: 'command_collision',
116
+ plugin: v.plugin.name,
117
+ path: name,
118
+ message: `top-level command "${name}" is claimed by multiple plugins: ${names}`,
119
+ received: names,
120
+ expected: 'exactly one plugin per top-level command name',
121
+ next: `Disable or remove all but one of: ${names}.`,
122
+ });
123
+ }
124
+ }
125
+ return validations;
126
+ }
127
+ // ---------------------------------------------------------------------------
128
+ // Per-plugin validation (Phase 2 lifecycle entry point)
129
+ // ---------------------------------------------------------------------------
130
+ /** Validate one plugin's command manifest without executing its binary.
131
+ * `reservedNames` are the core top-level names used for the core-collision
132
+ * check; pass an empty set to skip it. Never throws. */
133
+ export function validatePluginCommands(plugin, reservedNames = new Set()) {
134
+ const issues = [];
135
+ const contributions = [];
136
+ const rel = plugin.manifest.commands ?? '';
137
+ const manifestPath = resolve(plugin.root, rel);
138
+ const issue = (code, message, received, expected, next, path) => {
139
+ issues.push({ code, plugin: plugin.name, ...(path !== undefined ? { path } : {}), message, received, expected, next });
140
+ };
141
+ // Load + parse the manifest under path-safety confinement.
142
+ const safe = safePath(plugin.root, rel);
143
+ if (safe === null) {
144
+ issue('command_path_unsafe', `commands pointer escapes the plugin root or is not a regular file`, rel, 'a relative path to a regular file inside the plugin root', 'Fix the `commands` field in plugin.json.');
145
+ return { plugin, manifestPath, contributions, issues };
146
+ }
147
+ let raw;
148
+ try {
149
+ raw = readFileSync(safe, 'utf8');
150
+ }
151
+ catch {
152
+ issue('command_manifest_unreadable', `commands.json is missing or unreadable`, safe, 'a readable JSON file', 'Reinstall or update the plugin.');
153
+ return { plugin, manifestPath, contributions, issues };
154
+ }
155
+ let parsed;
156
+ try {
157
+ parsed = JSON.parse(raw);
158
+ }
159
+ catch {
160
+ issue('command_manifest_invalid', `commands.json is not valid JSON`, safe, 'a JSON object', 'Regenerate commands.json from the plugin\'s command definitions.');
161
+ return { plugin, manifestPath, contributions, issues };
162
+ }
163
+ if (!isObject(parsed)) {
164
+ issue('command_manifest_invalid', `commands.json must be a JSON object`, typeName(parsed), 'a JSON object', 'Regenerate commands.json.');
165
+ return { plugin, manifestPath, contributions, issues };
166
+ }
167
+ // Top-level shape: exact schemaVersion, no unknown keys.
168
+ const unknown = Object.keys(parsed).filter((k) => !MANIFEST_KEYS.has(k));
169
+ if (unknown.length > 0) {
170
+ issue('command_manifest_invalid', `commands.json has unknown top-level keys`, unknown.join(', '), 'only: schemaVersion, executable, mounts', 'Remove the unknown keys.');
171
+ return { plugin, manifestPath, contributions, issues };
172
+ }
173
+ if (parsed['schemaVersion'] !== 1) {
174
+ issue('command_schema_version', `unsupported command schemaVersion`, String(parsed['schemaVersion']), 'exactly the integer 1', 'Update the plugin to a version compatible with this crtr.');
175
+ return { plugin, manifestPath, contributions, issues };
176
+ }
177
+ // Executable path safety + exec bit.
178
+ const execRel = parsed['executable'];
179
+ if (typeof execRel !== 'string' || execRel.length === 0) {
180
+ issue('command_manifest_invalid', `executable must be a non-empty string`, typeName(execRel), 'a relative path string', 'Set executable to the plugin-relative path of the command binary.', 'executable');
181
+ return { plugin, manifestPath, contributions, issues };
182
+ }
183
+ const execSafe = safePath(plugin.root, execRel);
184
+ if (execSafe === null) {
185
+ issue('command_path_unsafe', `executable escapes the plugin root or is not a regular file`, execRel, 'a relative path to a regular file inside the plugin root', 'Fix the executable path in commands.json.', 'executable');
186
+ return { plugin, manifestPath, contributions, issues };
187
+ }
188
+ if (!hasExecBit(execSafe)) {
189
+ issue('command_not_executable', `executable lacks the POSIX exec bit`, execRel, 'a file with an executable permission bit', 'chmod +x the plugin\'s command binary, then update the plugin.', 'executable');
190
+ return { plugin, manifestPath, contributions, issues };
191
+ }
192
+ // Mounts.
193
+ const mounts = parsed['mounts'];
194
+ if (!Array.isArray(mounts)) {
195
+ issue('command_manifest_invalid', `mounts must be an array`, typeName(mounts), 'an array of mounts', 'Declare at least one mount.', 'mounts');
196
+ return { plugin, manifestPath, executable: execSafe, contributions, issues };
197
+ }
198
+ if (mounts.length === 0) {
199
+ issue('command_manifest_invalid', `manifest declares no mounts (inert)`, '[]', 'at least one top-level mount', 'Declare a mount, or drop the `commands` field.', 'mounts');
200
+ }
201
+ const topNames = new Set();
202
+ mounts.forEach((mount, i) => {
203
+ const path = `mounts[${i}]`;
204
+ if (!isObject(mount)) {
205
+ issue('command_node_invalid', `mount must be an object`, typeName(mount), 'a { parent, node } object', 'Fix the mount.', path);
206
+ return;
207
+ }
208
+ const mUnknown = Object.keys(mount).filter((k) => !MOUNT_KEYS.has(k));
209
+ if (mUnknown.length > 0) {
210
+ issue('command_node_invalid', `mount has unknown keys`, mUnknown.join(', '), 'only: parent, node', 'Remove the unknown keys.', path);
211
+ return;
212
+ }
213
+ // v1: top-level mounts only — parent must be [].
214
+ if (!Array.isArray(mount['parent']) || mount['parent'].length !== 0) {
215
+ issue('command_parent_invalid', `mount parent must be [] (top-level only in v1)`, JSON.stringify(mount['parent']), 'an empty array []', 'Mount the node at top level; nested mounts are not supported in v1.', `${path}.parent`);
216
+ return;
217
+ }
218
+ const node = validateNode(mount['node'], `${path}.node`, true, issue);
219
+ if (node === null)
220
+ return;
221
+ // node is a top-level branch here (validateNode enforced kind + rootEntry).
222
+ if (topNames.has(node.name)) {
223
+ issue('command_node_invalid', `this plugin declares two top-level commands named "${node.name}"`, node.name, 'a unique top-level command name per plugin', 'Rename one of the duplicate top-level mounts.', `${path}.node.name`);
224
+ return;
225
+ }
226
+ topNames.add(node.name);
227
+ if (reservedNames.has(node.name)) {
228
+ issue('command_collision', `top-level command "${node.name}" collides with a core command`, node.name, 'a name not used by a core crtr command', 'Rename the plugin\'s top-level command.', `${path}.node.name`);
229
+ return;
230
+ }
231
+ contributions.push({ plugin, executable: execSafe, node: node });
232
+ });
233
+ return { plugin, manifestPath, executable: execSafe, contributions, issues };
234
+ }
235
+ /** Validate one declarative node. Returns the typed node on success, else null
236
+ * (recording issues). `topLevel` requires a branch with a rootEntry. */
237
+ function validateNode(raw, path, topLevel, issue) {
238
+ if (!isObject(raw)) {
239
+ issue('command_node_invalid', `node must be an object`, typeName(raw), 'a branch or leaf node object', 'Fix the node.', path);
240
+ return null;
241
+ }
242
+ const kind = raw['kind'];
243
+ if (kind === 'branch')
244
+ return validateBranch(raw, path, topLevel, issue);
245
+ if (kind === 'leaf') {
246
+ if (topLevel) {
247
+ issue('command_node_invalid', `top-level node must be a branch`, 'leaf', 'a branch node with a rootEntry', 'Wrap the command in a top-level branch (noun) with a rootEntry.', `${path}.kind`);
248
+ return null;
249
+ }
250
+ return validateLeaf(raw, path, issue);
251
+ }
252
+ issue('command_node_invalid', `node kind must be "branch" or "leaf"`, String(kind), 'branch | leaf', 'Set kind to branch or leaf.', `${path}.kind`);
253
+ return null;
254
+ }
255
+ function validateBranch(raw, path, topLevel, issue) {
256
+ if (!checkKeys(raw, BRANCH_KEYS, path, issue))
257
+ return null;
258
+ if (!checkCommon(raw, path, issue))
259
+ return null;
260
+ // rootEntry: required at top level, forbidden nested.
261
+ const hasRoot = raw['rootEntry'] !== undefined;
262
+ if (topLevel && !hasRoot) {
263
+ issue('command_node_invalid', `top-level branch requires a rootEntry`, '(missing)', 'a rootEntry { concept, description, whenToUse }', 'Add a rootEntry to the top-level branch.', `${path}.rootEntry`);
264
+ return null;
265
+ }
266
+ if (!topLevel && hasRoot) {
267
+ issue('command_node_invalid', `nested branch must not declare a rootEntry`, 'rootEntry', 'no rootEntry (only top-level branches carry one)', 'Remove rootEntry from the nested branch.', `${path}.rootEntry`);
268
+ return null;
269
+ }
270
+ let rootEntry;
271
+ if (hasRoot) {
272
+ const re = validateRootEntry(raw['rootEntry'], `${path}.rootEntry`, issue);
273
+ if (re === null)
274
+ return null;
275
+ rootEntry = re;
276
+ }
277
+ if (typeof raw['summary'] !== 'string' || raw['summary'].length === 0) {
278
+ issue('command_node_invalid', `branch summary must be a non-empty string`, typeName(raw['summary']), 'a one-line summary string', 'Add a summary.', `${path}.summary`);
279
+ return null;
280
+ }
281
+ if (raw['model'] !== undefined && typeof raw['model'] !== 'string') {
282
+ issue('command_node_invalid', `branch model must be a string`, typeName(raw['model']), 'a string or omitted', 'Fix or remove model.', `${path}.model`);
283
+ return null;
284
+ }
285
+ const children = raw['children'];
286
+ if (!Array.isArray(children) || children.length === 0) {
287
+ issue('command_node_invalid', `branch children must be a non-empty array`, typeName(children), 'a non-empty array of branch/leaf nodes', 'Add at least one child.', `${path}.children`);
288
+ return null;
289
+ }
290
+ const validated = [];
291
+ const childNames = new Set();
292
+ for (let i = 0; i < children.length; i++) {
293
+ const c = validateNode(children[i], `${path}.children[${i}]`, false, issue);
294
+ if (c === null)
295
+ return null;
296
+ if (childNames.has(c.name)) {
297
+ issue('command_node_invalid', `duplicate child name`, c.name, 'unique child names within a branch', 'Rename the duplicate child.', `${path}.children[${i}].name`);
298
+ return null;
299
+ }
300
+ childNames.add(c.name);
301
+ validated.push(c);
302
+ }
303
+ return {
304
+ kind: 'branch',
305
+ name: raw['name'],
306
+ description: raw['description'],
307
+ whenToUse: raw['whenToUse'],
308
+ ...(raw['tier'] !== undefined ? { tier: raw['tier'] } : {}),
309
+ ...(rootEntry !== undefined ? { rootEntry } : {}),
310
+ summary: raw['summary'],
311
+ ...(raw['model'] !== undefined ? { model: raw['model'] } : {}),
312
+ children: validated,
313
+ };
314
+ }
315
+ function validateLeaf(raw, path, issue) {
316
+ if (!checkKeys(raw, LEAF_KEYS, path, issue))
317
+ return null;
318
+ if (!checkCommon(raw, path, issue))
319
+ return null;
320
+ if (typeof raw['summary'] !== 'string' || raw['summary'].length === 0) {
321
+ issue('command_node_invalid', `leaf summary must be a non-empty string`, typeName(raw['summary']), 'a one-line summary string', 'Add a summary.', `${path}.summary`);
322
+ return null;
323
+ }
324
+ if (raw['outputKind'] !== 'object') {
325
+ issue('command_node_invalid', `leaf outputKind must be "object" in v1`, String(raw['outputKind']), 'exactly "object"', 'Set outputKind to "object".', `${path}.outputKind`);
326
+ return null;
327
+ }
328
+ const params = validateParams(raw['params'], `${path}.params`, issue);
329
+ if (params === null)
330
+ return null;
331
+ const output = validateOutput(raw['output'], `${path}.output`, issue);
332
+ if (output === null)
333
+ return null;
334
+ const effects = raw['effects'];
335
+ if (!Array.isArray(effects) || effects.length === 0 || !effects.every((e) => typeof e === 'string')) {
336
+ issue('command_node_invalid', `leaf effects must be a non-empty string array`, typeName(effects), 'a non-empty array of strings (["None. Read-only."] for read-only leaves)', 'Declare every persistent effect.', `${path}.effects`);
337
+ return null;
338
+ }
339
+ return {
340
+ kind: 'leaf',
341
+ name: raw['name'],
342
+ description: raw['description'],
343
+ whenToUse: raw['whenToUse'],
344
+ ...(raw['tier'] !== undefined ? { tier: raw['tier'] } : {}),
345
+ summary: raw['summary'],
346
+ params,
347
+ output,
348
+ outputKind: 'object',
349
+ effects: effects,
350
+ };
351
+ }
352
+ /** Validate name/description/whenToUse/tier shared by branches and leaves. */
353
+ function checkCommon(raw, path, issue) {
354
+ const name = raw['name'];
355
+ if (typeof name !== 'string' || !KEBAB.test(name)) {
356
+ issue('command_node_invalid', `node name must be a lowercase kebab-case token`, String(name), 'a kebab-case token like "app" or "list-all"', 'Rename the node.', `${path}.name`);
357
+ return false;
358
+ }
359
+ if (typeof raw['description'] !== 'string' || raw['description'].length === 0) {
360
+ issue('command_node_invalid', `node description must be a non-empty string`, typeName(raw['description']), 'a short parent-listing description', 'Add a description.', `${path}.description`);
361
+ return false;
362
+ }
363
+ if (typeof raw['whenToUse'] !== 'string' || raw['whenToUse'].length === 0) {
364
+ issue('command_node_invalid', `node whenToUse must be a non-empty string`, typeName(raw['whenToUse']), 'a parent selection rubric', 'Add a whenToUse rubric.', `${path}.whenToUse`);
365
+ return false;
366
+ }
367
+ if (raw['tier'] !== undefined && !TIERS.has(String(raw['tier']))) {
368
+ issue('command_node_invalid', `node tier must be normal|common|important`, String(raw['tier']), 'normal | common | important', 'Fix or omit tier (external plugins cannot declare hidden commands).', `${path}.tier`);
369
+ return false;
370
+ }
371
+ return true;
372
+ }
373
+ function validateRootEntry(raw, path, issue) {
374
+ if (!isObject(raw)) {
375
+ issue('command_node_invalid', `rootEntry must be an object`, typeName(raw), 'a { concept, description, whenToUse } object', 'Fix rootEntry.', path);
376
+ return null;
377
+ }
378
+ if (!checkKeys(raw, ROOT_ENTRY_KEYS, path, issue))
379
+ return null;
380
+ for (const k of ['concept', 'description', 'whenToUse']) {
381
+ if (typeof raw[k] !== 'string' || raw[k].length === 0) {
382
+ issue('command_node_invalid', `rootEntry.${k} must be a non-empty string`, typeName(raw[k]), 'a non-empty string', `Add rootEntry.${k}.`, `${path}.${k}`);
383
+ return null;
384
+ }
385
+ }
386
+ return {
387
+ concept: raw['concept'],
388
+ description: raw['description'],
389
+ whenToUse: raw['whenToUse'],
390
+ };
391
+ }
392
+ function validateParams(raw, path, issue) {
393
+ if (raw === undefined)
394
+ return [];
395
+ if (!Array.isArray(raw)) {
396
+ issue('command_node_invalid', `params must be an array`, typeName(raw), 'an array of params (or omitted)', 'Fix params.', path);
397
+ return null;
398
+ }
399
+ const out = [];
400
+ const names = new Set();
401
+ let positionals = 0;
402
+ for (let i = 0; i < raw.length; i++) {
403
+ const p = raw[i];
404
+ const pp = `${path}[${i}]`;
405
+ if (!isObject(p)) {
406
+ issue('command_node_invalid', `param must be an object`, typeName(p), 'a param object', 'Fix the param.', pp);
407
+ return null;
408
+ }
409
+ const kind = p['kind'];
410
+ if (typeof kind !== 'string' || !(kind in PARAM_KEYS_BY_KIND)) {
411
+ issue('command_node_invalid', `param kind must be positional|flag|stdin|context-file`, String(kind), 'positional | flag | stdin | context-file', 'Fix the param kind.', `${pp}.kind`);
412
+ return null;
413
+ }
414
+ if (!checkKeys(p, PARAM_KEYS_BY_KIND[kind], pp, issue))
415
+ return null;
416
+ const name = p['name'];
417
+ if (typeof name !== 'string' || !KEBAB.test(name)) {
418
+ issue('command_node_invalid', `param name must be a kebab-case token`, String(name), 'a kebab-case token', 'Rename the param.', `${pp}.name`);
419
+ return null;
420
+ }
421
+ if (names.has(name)) {
422
+ issue('command_node_invalid', `duplicate param name`, name, 'unique param names', 'Rename the duplicate param.', `${pp}.name`);
423
+ return null;
424
+ }
425
+ names.add(name);
426
+ if (typeof p['required'] !== 'boolean') {
427
+ issue('command_node_invalid', `param required must be a boolean`, typeName(p['required']), 'true | false', 'Set required.', `${pp}.required`);
428
+ return null;
429
+ }
430
+ if (typeof p['constraint'] !== 'string') {
431
+ issue('command_node_invalid', `param constraint must be a string`, typeName(p['constraint']), 'an inline constraint string', 'Add a constraint.', `${pp}.constraint`);
432
+ return null;
433
+ }
434
+ if (kind === 'positional') {
435
+ positionals++;
436
+ if (positionals > 1) {
437
+ issue('command_node_invalid', `a leaf may declare at most one positional`, 'multiple positionals', 'at most one positional param', 'Convert extra positionals to flags.', pp);
438
+ return null;
439
+ }
440
+ out.push({ kind: 'positional', name, ...(p['type'] !== undefined ? { type: p['type'] } : {}), required: p['required'], constraint: p['constraint'] });
441
+ }
442
+ else if (kind === 'stdin') {
443
+ out.push({ kind: 'stdin', name, required: p['required'], constraint: p['constraint'] });
444
+ }
445
+ else if (kind === 'context-file') {
446
+ out.push({ kind: 'context-file', name, required: p['required'], constraint: p['constraint'], ...(typeof p['shape'] === 'string' ? { shape: p['shape'] } : {}) });
447
+ }
448
+ else if (kind === 'flag') {
449
+ const type = p['type'];
450
+ if (typeof type !== 'string' || !FLAG_TYPES.has(type)) {
451
+ issue('command_node_invalid', `flag type must be string|int|bool|path|enum`, String(type), 'string | int | bool | path | enum', 'Fix the flag type.', `${pp}.type`);
452
+ return null;
453
+ }
454
+ let choices;
455
+ if (type === 'enum') {
456
+ const c = p['choices'];
457
+ if (!Array.isArray(c) || c.length === 0 || !c.every((x) => typeof x === 'string')) {
458
+ issue('command_node_invalid', `enum flag requires a non-empty string choices array`, typeName(c), 'a non-empty array of strings', 'Declare choices for the enum flag.', `${pp}.choices`);
459
+ return null;
460
+ }
461
+ choices = c;
462
+ }
463
+ else if (p['choices'] !== undefined) {
464
+ issue('command_node_invalid', `choices is only valid on an enum flag`, 'choices', 'no choices unless type is enum', 'Remove choices.', `${pp}.choices`);
465
+ return null;
466
+ }
467
+ out.push({
468
+ kind: 'flag', name, type: type,
469
+ ...(choices !== undefined ? { choices } : {}),
470
+ required: p['required'], constraint: p['constraint'],
471
+ ...(p['default'] !== undefined ? { default: p['default'] } : {}),
472
+ });
473
+ }
474
+ }
475
+ return out;
476
+ }
477
+ function validateOutput(raw, path, issue) {
478
+ if (!Array.isArray(raw)) {
479
+ issue('command_node_invalid', `output must be an array`, typeName(raw), 'an array of output fields', 'Declare the leaf output fields.', path);
480
+ return null;
481
+ }
482
+ const out = [];
483
+ for (let i = 0; i < raw.length; i++) {
484
+ const f = raw[i];
485
+ const fp = `${path}[${i}]`;
486
+ if (!isObject(f)) {
487
+ issue('command_node_invalid', `output field must be an object`, typeName(f), 'a { name, type, required, constraint } object', 'Fix the output field.', fp);
488
+ return null;
489
+ }
490
+ if (!checkKeys(f, OUTPUT_KEYS, fp, issue))
491
+ return null;
492
+ if (typeof f['name'] !== 'string' || f['name'].length === 0) {
493
+ issue('command_node_invalid', `output field name must be a non-empty string`, typeName(f['name']), 'a field name', 'Add a name.', `${fp}.name`);
494
+ return null;
495
+ }
496
+ if (typeof f['type'] !== 'string' || f['type'].length === 0) {
497
+ issue('command_node_invalid', `output field type must be a non-empty string`, typeName(f['type']), 'a type name', 'Add a type.', `${fp}.type`);
498
+ return null;
499
+ }
500
+ if (typeof f['required'] !== 'boolean') {
501
+ issue('command_node_invalid', `output field required must be a boolean`, typeName(f['required']), 'true | false', 'Set required.', `${fp}.required`);
502
+ return null;
503
+ }
504
+ if (typeof f['constraint'] !== 'string') {
505
+ issue('command_node_invalid', `output field constraint must be a string`, typeName(f['constraint']), 'a constraint string', 'Add a constraint.', `${fp}.constraint`);
506
+ return null;
507
+ }
508
+ out.push({ name: f['name'], type: f['type'], required: f['required'], constraint: f['constraint'] });
509
+ }
510
+ return out;
511
+ }
512
+ // ---------------------------------------------------------------------------
513
+ // Small helpers
514
+ // ---------------------------------------------------------------------------
515
+ function isObject(v) {
516
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
517
+ }
518
+ function typeName(v) {
519
+ if (v === null)
520
+ return 'null';
521
+ if (Array.isArray(v))
522
+ return 'array';
523
+ return typeof v;
524
+ }
525
+ function checkKeys(raw, allowed, path, issue) {
526
+ const unknown = Object.keys(raw).filter((k) => !allowed.has(k));
527
+ if (unknown.length > 0) {
528
+ issue('command_node_invalid', `unknown keys`, unknown.join(', '), `only: ${[...allowed].join(', ')}`, 'Remove the unknown keys.', path);
529
+ return false;
530
+ }
531
+ return true;
532
+ }
533
+ /** Resolve `rel` under `root`, confining it to the realpath'd root and
534
+ * requiring a regular file. Returns the resolved absolute path or null. */
535
+ function safePath(root, rel) {
536
+ if (typeof rel !== 'string' || rel.length === 0)
537
+ return null;
538
+ let rootReal;
539
+ let real;
540
+ try {
541
+ rootReal = realpathSync(root);
542
+ real = realpathSync(resolve(root, rel));
543
+ }
544
+ catch {
545
+ return null;
546
+ }
547
+ if (real !== rootReal && !real.startsWith(rootReal + sep))
548
+ return null;
549
+ try {
550
+ if (!statSync(real).isFile())
551
+ return null;
552
+ }
553
+ catch {
554
+ return null;
555
+ }
556
+ return real;
557
+ }
558
+ function hasExecBit(file) {
559
+ try {
560
+ return (statSync(file).mode & 0o111) !== 0;
561
+ }
562
+ catch {
563
+ return false;
564
+ }
565
+ }
@@ -3,9 +3,27 @@ export declare function configPath(scope: Scope): string | null;
3
3
  export declare function statePath(scope: Scope): string | null;
4
4
  export declare function readConfig(scope: Scope): ScopeConfig;
5
5
  export declare function readState(scope: Scope): ScopeState;
6
+ type RawConfig = Record<string, unknown>;
7
+ export declare function updateRawConfigAtomically(scope: Scope, mutate: (latest: RawConfig) => RawConfig): RawConfig;
6
8
  export declare function writeConfig(scope: Scope, config: ScopeConfig): void;
9
+ /** Persist a RAW PARTIAL config to a scope's config.json exactly as given.
10
+ * Unlike `writeConfig`, this makes no promise that every field is present --
11
+ * callers pass the sparse partial that should live on disk, so an untouched
12
+ * key stays absent (and `readConfig`/`mergeConfig` re-derive it live from
13
+ * whichever build is running). This is the single central primitive every
14
+ * mutation path funnels through so no writer ever freezes a fully
15
+ * materialized `modelLadders` (concrete model ids that go stale across
16
+ * builds -- the live-Hearth freeze bug) onto disk. */
17
+ export declare function writeRawConfig(scope: Scope, config: Partial<ScopeConfig>): void;
7
18
  export declare function writeState(scope: Scope, state: ScopeState): void;
8
19
  export declare function ensureScopeInitialized(scope: Scope, root: string): void;
20
+ /** Raw (un-defaulted) partial config for one scope, or null if the scope has
21
+ * no root or no config.json. Used by `readMergedLaunchConfig` to layer
22
+ * scopes onto each other WITHOUT each scope's own default-fill masking a
23
+ * lower-precedence scope's real customization (see that function's comment
24
+ * for why `readConfig(scope)`, which already defaults-fills, is unusable
25
+ * for cross-scope layering). */
26
+ export declare function readRawScopeConfig(scope: Scope): Partial<ScopeConfig> | null;
9
27
  export interface MergedLaunchConfig {
10
28
  kinds: Record<string, KindConfig>;
11
29
  modelLadders: ModelLaddersConfig;
@@ -65,5 +83,35 @@ export declare function subKindsAvailableTo(kind: string): {
65
83
  kind: string;
66
84
  whenToUse: string;
67
85
  }[];
86
+ /** Apply `mutate` to a scope's MERGED config (so callers can read + write any
87
+ * field), then persist only a RAW PARTIAL: every top-level key the mutation
88
+ * left untouched keeps its exact on-disk raw value (absent stays absent, a
89
+ * sparse override stays sparse), and only keys the mutation actually changed
90
+ * are written. `modelLadders` gets sub-field-granular sparse handling via
91
+ * `sparseModelLaddersToPersist`. Result: a fresh bootstrap or any unrelated
92
+ * `sys config set` no longer freezes a full concrete `modelLadders` to disk,
93
+ * and a direct ladder-rung set still persists only the touched rung -- all
94
+ * through one lock-protected atomic update path. */
68
95
  export declare function updateConfig(scope: Scope, mutate: (cfg: ScopeConfig) => void): ScopeConfig;
96
+ /** Persist a model as the DEFAULT for a kind — the `/model`-picker "defaults
97
+ * going forward" write. Writes a SPARSE `kinds.<kind>` patch (just the one
98
+ * model field, merged by `normalizeKindPatch`/`mergeKinds` over whatever the
99
+ * lower scopes define — never a frozen full kind entry) into the PROFILE's
100
+ * config.json when the node runs under a profile, else the user scope's.
101
+ * Mode-aware: an orchestrator's pick lands on `orchestratorModel` (base
102
+ * workers of the same kind keep their own default), a base node's on `model`.
103
+ * Callers pass a model string in any form `normalizeModel` accepts; portable
104
+ * ladder tokens (`ultra`, `anthropic/strong`) are preferred by callers since
105
+ * they never go stale across builds. */
106
+ export declare function persistDefaultKindModel(opts: {
107
+ kind: string;
108
+ model: string;
109
+ orchestrator?: boolean;
110
+ profileId?: string | null;
111
+ }): {
112
+ scope: 'profile' | 'user';
113
+ path: string;
114
+ field: 'model' | 'orchestratorModel';
115
+ };
69
116
  export declare function updateState(scope: Scope, mutate: (s: ScopeState) => void): ScopeState;
117
+ export {};