@cruxy/cli 0.14.0 → 0.17.0

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 (66) hide show
  1. package/dist/agent/loop.d.ts +26 -0
  2. package/dist/agent/loop.js +59 -3
  3. package/dist/agent/session.d.ts +17 -1
  4. package/dist/agent/session.js +23 -2
  5. package/dist/brand/index.d.ts +1 -0
  6. package/dist/brand/index.js +1 -0
  7. package/dist/brand/voice.d.ts +94 -0
  8. package/dist/brand/voice.js +127 -0
  9. package/dist/cli/commands/checkpoint.js +1 -1
  10. package/dist/cli/commands/hooks.d.ts +8 -0
  11. package/dist/cli/commands/hooks.js +83 -0
  12. package/dist/cli/commands/init.js +1 -1
  13. package/dist/cli/commands/pr.js +10 -2
  14. package/dist/cli/commands/rollback.js +1 -1
  15. package/dist/cli/commands/run.js +13 -3
  16. package/dist/cli/commands/skills.js +2 -2
  17. package/dist/cli/program.js +5 -2
  18. package/dist/cli/repl.d.ts +2 -1
  19. package/dist/cli/repl.js +54 -3
  20. package/dist/cli/session-factory.d.ts +2 -2
  21. package/dist/cli/session-factory.js +13 -2
  22. package/dist/config/schema.d.ts +139 -46
  23. package/dist/config/schema.js +42 -0
  24. package/dist/constants.d.ts +9 -0
  25. package/dist/constants.js +9 -0
  26. package/dist/errors/constructors.d.ts +25 -0
  27. package/dist/errors/constructors.js +98 -6
  28. package/dist/errors/types.d.ts +14 -0
  29. package/dist/errors/types.js +23 -0
  30. package/dist/hooks/config.d.ts +21 -0
  31. package/dist/hooks/config.js +253 -0
  32. package/dist/hooks/index.d.ts +6 -0
  33. package/dist/hooks/index.js +6 -0
  34. package/dist/hooks/runner.d.ts +76 -0
  35. package/dist/hooks/runner.js +114 -0
  36. package/dist/hooks/service.d.ts +38 -0
  37. package/dist/hooks/service.js +49 -0
  38. package/dist/hooks/slash.d.ts +48 -0
  39. package/dist/hooks/slash.js +58 -0
  40. package/dist/hooks/trust.d.ts +46 -0
  41. package/dist/hooks/trust.js +106 -0
  42. package/dist/hooks/types.d.ts +147 -0
  43. package/dist/hooks/types.js +61 -0
  44. package/dist/onboarding/steps.js +1 -1
  45. package/dist/plan/service.d.ts +6 -0
  46. package/dist/plan/service.js +4 -0
  47. package/dist/render/state.js +4 -1
  48. package/dist/render/types.d.ts +7 -1
  49. package/dist/routing/index.d.ts +2 -0
  50. package/dist/routing/index.js +5 -0
  51. package/dist/routing/resolve.d.ts +17 -0
  52. package/dist/routing/resolve.js +18 -0
  53. package/dist/routing/router.d.ts +47 -0
  54. package/dist/routing/router.js +84 -0
  55. package/dist/routing/types.d.ts +42 -0
  56. package/dist/routing/types.js +27 -0
  57. package/dist/subagent/orchestrator.d.ts +6 -0
  58. package/dist/subagent/orchestrator.js +2 -0
  59. package/dist/subagent/types.d.ts +6 -0
  60. package/dist/tools/shell/exec.d.ts +53 -0
  61. package/dist/tools/shell/exec.js +128 -0
  62. package/dist/tools/shell/run-command.d.ts +4 -0
  63. package/dist/tools/shell/run-command.js +26 -116
  64. package/dist/vcs/generate.d.ts +3 -1
  65. package/dist/vcs/generate.js +4 -1
  66. package/package.json +2 -2
@@ -6,12 +6,13 @@ import { createRenderer } from "../../render/index.js";
6
6
  import { themeForColor } from "../../theme/index.js";
7
7
  import { CheckpointService } from "../../checkpoint/index.js";
8
8
  import { SandboxService } from "../../sandbox/index.js";
9
+ import { buildHooksService } from "../../hooks/index.js";
9
10
  import { runInteractive } from "../repl.js";
10
11
  import { buildAgentSession } from "../session-factory.js";
11
12
  import { apiKeyEnvVar, maybeRunOnboarding } from "../onboard.js";
12
13
  export function runCommand() {
13
14
  return new Command("run")
14
- .description("run cruxy on a prompt (one-shot), or with no prompt for an interactive session")
15
+ .description("run a task once, or start an interactive session")
15
16
  .argument("[prompt...]", "the task for cruxy to perform (omit for interactive)")
16
17
  .option("--plan", "plan mode: propose a step-by-step plan for approval before executing")
17
18
  .option("--sandbox", "run shell + test commands inside an isolated container (fails loud if no runtime)")
@@ -79,9 +80,18 @@ export function runCommand() {
79
80
  if (sandbox) {
80
81
  logger.info(t.muted(`sandbox: ${sandbox.runtimeName} (network ${config.sandbox.network})`));
81
82
  }
82
- const session = buildAgentSession(config, apiKey, process.cwd(), Boolean(process.stdin.isTTY), planMode, renderer, checkpoints, sandbox);
83
+ // Hooks + custom slash commands (C.19). Built once per run: loads the
84
+ // layered catalog and yields the lifecycle runner (threaded into the
85
+ // session) + the resolved custom slash commands (given to the REPL).
86
+ const hooksService = await buildHooksService({
87
+ cwd: process.cwd(),
88
+ config,
89
+ interactive: Boolean(process.stdin.isTTY),
90
+ logger,
91
+ });
92
+ const session = buildAgentSession(config, apiKey, process.cwd(), Boolean(process.stdin.isTTY), planMode, renderer, checkpoints, sandbox, hooksService.runner);
83
93
  if (interactive) {
84
- await runInteractive(session, undefined, renderer, checkpoints);
94
+ await runInteractive(session, undefined, renderer, checkpoints, hooksService.commands);
85
95
  return;
86
96
  }
87
97
  checkpoints?.beginRun(prompt);
@@ -33,9 +33,9 @@ export function skillsCommand() {
33
33
  resetSkillServices();
34
34
  return;
35
35
  }
36
- logger.print(`\n${t.strong("sources")} ${t.muted("(precedence high low):")}`);
36
+ logger.print(`\n${t.heading("sources")} ${t.muted("(precedence, high to low)")}`);
37
37
  for (const { source, dir } of status.sources) {
38
- logger.print(` ${source.padEnd(8)} ${t.muted(dir)}`);
38
+ logger.print(` ${t.kv(source, t.muted(dir), 8)}`);
39
39
  }
40
40
  logger.print("");
41
41
  if (status.errors.length === 0) {
@@ -1,5 +1,6 @@
1
1
  import { Command } from "commander";
2
- import { APP_NAME, APP_VERSION, APP_DESCRIPTION } from "../constants.js";
2
+ import { APP_NAME, APP_VERSION } from "../constants.js";
3
+ import { PRODUCT_MASTHEAD } from "../brand/index.js";
3
4
  import { logger } from "../utils/logger.js";
4
5
  import { shouldUseColor, usageError } from "../errors/index.js";
5
6
  import { themeForColor } from "../theme/index.js";
@@ -13,13 +14,14 @@ import { initCommand } from "./commands/init.js";
13
14
  import { checkpointCommand } from "./commands/checkpoint.js";
14
15
  import { rollbackCommand } from "./commands/rollback.js";
15
16
  import { testCommand } from "./commands/test.js";
17
+ import { hooksCommand } from "./commands/hooks.js";
16
18
  import { loadConfig } from "../config/index.js";
17
19
  import { maybeRunOnboarding } from "./onboard.js";
18
20
  export function buildProgram() {
19
21
  const program = new Command();
20
22
  program
21
23
  .name(APP_NAME)
22
- .description(`cruxy-code — ${APP_DESCRIPTION}`)
24
+ .description(PRODUCT_MASTHEAD)
23
25
  .version(APP_VERSION, "-v, --version", "print the cruxy version")
24
26
  .option("-c, --config <path>", "use a specific config file")
25
27
  .option("--log-level <level>", "debug | info | warn | error | silent")
@@ -42,6 +44,7 @@ export function buildProgram() {
42
44
  program.addCommand(checkpointCommand());
43
45
  program.addCommand(rollbackCommand());
44
46
  program.addCommand(testCommand());
47
+ program.addCommand(hooksCommand());
45
48
  // Default action: bare `cruxy` -> entrypoint. An unrecognized first operand
46
49
  // means an unknown command (Commander runs the default action with it as an
47
50
  // operand rather than erroring), so reject it as a usage error.
@@ -1,6 +1,7 @@
1
1
  import type { Readable, Writable } from "node:stream";
2
2
  import type { Session } from "../agent/index.js";
3
3
  import type { CheckpointService } from "../checkpoint/index.js";
4
+ import { type SlashCommandSpec } from "../hooks/index.js";
4
5
  import { type StreamRenderer } from "../render/index.js";
5
6
  /**
6
7
  * The REPL's slash commands — the autocomplete candidate set (U.7). Keep in
@@ -23,4 +24,4 @@ export interface ReplIO {
23
24
  * live region, anything else the plain append-only renderer); `cruxy run`
24
25
  * passes its own so the approval prompt's status-suspend hook shares it.
25
26
  */
26
- export declare function runInteractive(session: Session, io?: ReplIO, renderer?: StreamRenderer, checkpoints?: CheckpointService): Promise<void>;
27
+ export declare function runInteractive(session: Session, io?: ReplIO, renderer?: StreamRenderer, checkpoints?: CheckpointService, slashCommands?: readonly SlashCommandSpec[]): Promise<void>;
package/dist/cli/repl.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import readline from "node:readline";
2
2
  import { makeReplCompleter } from "../components/index.js";
3
+ import { resolveSlash } from "../hooks/index.js";
4
+ import { runGatedShell } from "../tools/shell/exec.js";
3
5
  import { themeForColor } from "../theme/index.js";
4
6
  import { formatError, fromUnknown, isVerbose, shouldUseColor, } from "../errors/index.js";
5
7
  import { createRenderer } from "../render/index.js";
@@ -70,6 +72,35 @@ function readLine(io, prompt) {
70
72
  });
71
73
  });
72
74
  }
75
+ /**
76
+ * Run a shell-bound custom slash command (C.19) through the SAME gated +
77
+ * sandboxed path as `run_command` (`runGatedShell` over the session's tool
78
+ * context) — never a privileged route. Prints the result like a shell run; a
79
+ * gate rejection or a thrown coded error (e.g. sandbox) is surfaced, not fatal.
80
+ */
81
+ async function runSlashShell(spec, session) {
82
+ logger.print(theme.muted(`running /${spec.name}${theme.glyph.ellipsis}`));
83
+ try {
84
+ const outcome = await runGatedShell(spec.command ?? "", session.toolContext);
85
+ if (!outcome.approved) {
86
+ logger.print(theme.muted(outcome.rejection ?? "command denied by the user"));
87
+ return;
88
+ }
89
+ const e = outcome.exec;
90
+ if (e.timedOut) {
91
+ logger.print(theme.warning("timed out"));
92
+ return;
93
+ }
94
+ if (e.spawnError !== undefined) {
95
+ logger.print(theme.danger(e.spawnError));
96
+ return;
97
+ }
98
+ logger.print(`exit code ${e.exitCode ?? e.signal ?? "unknown"}\n${e.output}`);
99
+ }
100
+ catch (err) {
101
+ printReplError(err);
102
+ }
103
+ }
73
104
  /**
74
105
  * Render a non-fatal error inline (classified + formatted, the same 4-part
75
106
  * shape as the fatal boundary) and return to the prompt — the REPL must survive
@@ -93,16 +124,16 @@ function printReplError(err) {
93
124
  * live region, anything else the plain append-only renderer); `cruxy run`
94
125
  * passes its own so the approval prompt's status-suspend hook shares it.
95
126
  */
96
- export async function runInteractive(session, io = defaultIO(), renderer = createRenderer(io.output, process.stderr), checkpoints) {
127
+ export async function runInteractive(session, io = defaultIO(), renderer = createRenderer(io.output, process.stderr), checkpoints, slashCommands = []) {
97
128
  logger.print(theme.muted("interactive session — /help for commands, /exit or Ctrl+D to quit"));
98
129
  try {
99
- await replLoop(session, io, renderer, checkpoints);
130
+ await replLoop(session, io, renderer, checkpoints, slashCommands);
100
131
  }
101
132
  finally {
102
133
  renderer.close();
103
134
  }
104
135
  }
105
- async function replLoop(session, io, renderer, checkpoints) {
136
+ async function replLoop(session, io, renderer, checkpoints, slashCommands = []) {
106
137
  for (;;) {
107
138
  const line = await readLine(io, PROMPT);
108
139
  // EOF / Ctrl+D.
@@ -151,6 +182,26 @@ async function replLoop(session, io, renderer, checkpoints) {
151
182
  logger.print(HELP);
152
183
  continue;
153
184
  }
185
+ // Custom slash commands (C.19) — consulted AFTER builtins, so a custom
186
+ // command can never shadow /help, /exit, etc. A `prompt` command expands to
187
+ // text fed to the agent (safe); a `shell` command runs through the SAME
188
+ // gate + sandbox as any command (never a bypass). Unknown "/…" input falls
189
+ // through to a normal turn, preserving prior behavior.
190
+ const slash = resolveSlash(trimmed, slashCommands);
191
+ if (slash.kind === "prompt") {
192
+ try {
193
+ checkpoints?.beginRun(slash.prompt);
194
+ await session.send(slash.prompt, renderer);
195
+ }
196
+ catch (err) {
197
+ printReplError(err);
198
+ }
199
+ continue;
200
+ }
201
+ if (slash.kind === "shell") {
202
+ await runSlashShell(slash.spec, session);
203
+ continue;
204
+ }
154
205
  // A real turn. Assistant text streams through the renderer delta by delta
155
206
  // (leading blank lines trimmed, code fences highlighted); the agent loop
156
207
  // closes each segment with one newline, so the next prompt lands on its own
@@ -4,7 +4,7 @@ import type { CheckpointService } from "../checkpoint/index.js";
4
4
  import type { SandboxService } from "../sandbox/index.js";
5
5
  import type { StreamRenderer } from "../render/index.js";
6
6
  import { type ApproveAction } from "../tools/index.js";
7
- import { Session } from "../agent/index.js";
7
+ import { Session, type LifecycleHookRunner } from "../agent/index.js";
8
8
  /**
9
9
  * Wrap the approval gate with the C.32 auto-checkpoint hook. Ordering is the
10
10
  * whole point: a tool mutates only *after* `requestApproval` resolves, so
@@ -24,4 +24,4 @@ export declare function withCheckpointGate(requestApproval: (action: ApproveActi
24
24
  * over the shared U.3 allowlist and a `planRunner` so `session.send` proposes →
25
25
  * approves → executes. Plan mode is fully opt-in; the default path is unchanged.
26
26
  */
27
- export declare function buildAgentSession(config: CruxyConfig, apiKey: string, cwd: string, ttyInteractive: boolean, planMode?: boolean, renderer?: StreamRenderer, checkpoints?: CheckpointService, sandbox?: SandboxService): Session;
27
+ export declare function buildAgentSession(config: CruxyConfig, apiKey: string, cwd: string, ttyInteractive: boolean, planMode?: boolean, renderer?: StreamRenderer, checkpoints?: CheckpointService, sandbox?: SandboxService, hooks?: LifecycleHookRunner): Session;
@@ -5,8 +5,9 @@ import { getGitInfo } from "../utils/git.js";
5
5
  import { ApprovalService, InteractivePolicy, SessionAllowlist, classify, defaultPromptIO, } from "../approval/index.js";
6
6
  import { shouldUseColor } from "../errors/index.js";
7
7
  import { buildDefaultRegistry } from "../tools/index.js";
8
- import { Session } from "../agent/index.js";
8
+ import { Session, } from "../agent/index.js";
9
9
  import { PlanExecutionPolicy, runPlanSession } from "../plan/index.js";
10
+ import { routerForConfig } from "../routing/index.js";
10
11
  import { SubagentOrchestrator, makeSpawnSubagentTool, } from "../subagent/index.js";
11
12
  /**
12
13
  * Wrap a PromptIO so the live region yields before any prompt text lands
@@ -87,7 +88,7 @@ export function withCheckpointGate(requestApproval, checkpoints, cwd) {
87
88
  * over the shared U.3 allowlist and a `planRunner` so `session.send` proposes →
88
89
  * approves → executes. Plan mode is fully opt-in; the default path is unchanged.
89
90
  */
90
- export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode = false, renderer, checkpoints, sandbox) {
91
+ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode = false, renderer, checkpoints, sandbox, hooks) {
91
92
  const provider = createProvider({
92
93
  provider: config.model.provider,
93
94
  apiKey,
@@ -96,6 +97,10 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
96
97
  temperature: config.model.temperature,
97
98
  gatewayUrl: config.cruxy.gatewayUrl,
98
99
  });
100
+ // Multi-model routing (C.30): null unless routing is configured on a cruxy
101
+ // session, so the default path threads `undefined` and behaves exactly as
102
+ // before. One router is shared by the main loop, subagents, and plan mode.
103
+ const router = routerForConfig(config) ?? undefined;
99
104
  const execRegistry = buildDefaultRegistry();
100
105
  const git = getGitInfo(cwd);
101
106
  const projectInstructions = loadProjectInstructions(cwd);
@@ -114,6 +119,7 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
114
119
  const orchestrator = new SubagentOrchestrator({
115
120
  provider,
116
121
  config,
122
+ router,
117
123
  parentRegistry: execRegistry,
118
124
  cwd,
119
125
  logger,
@@ -156,6 +162,7 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
156
162
  git,
157
163
  projectInstructions,
158
164
  renderer: turnRenderer,
165
+ router,
159
166
  });
160
167
  return new Session({
161
168
  provider,
@@ -166,6 +173,8 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
166
173
  projectInstructions,
167
174
  planMode: true,
168
175
  planRunner,
176
+ hooks,
177
+ router,
169
178
  });
170
179
  }
171
180
  const approval = new ApprovalService({
@@ -181,5 +190,7 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
181
190
  ctx,
182
191
  git,
183
192
  projectInstructions,
193
+ hooks,
194
+ router,
184
195
  });
185
196
  }
@@ -339,6 +339,53 @@ export declare const SandboxConfigSchema: z.ZodObject<{
339
339
  mounts?: string[] | undefined;
340
340
  }>;
341
341
  export type SandboxConfig = z.infer<typeof SandboxConfigSchema>;
342
+ /**
343
+ * Hooks + custom slash commands (C.19). Hooks run user-authored shell commands
344
+ * on lifecycle events — a real execution surface — so they are OFF by default
345
+ * and, when on, still funnel through the SAME U.3 gate + C.16 sandbox as any
346
+ * agent command (never a bypass). The hook *definitions* live in layered
347
+ * `hooks.json` files (project > user, source-tracked for the trust model); this
348
+ * schema holds only the global toggles.
349
+ */
350
+ export declare const HooksConfigSchema: z.ZodObject<{
351
+ /** Master switch. When false, NO hook ever fires (config with hooks stays
352
+ * inert until explicitly enabled). */
353
+ enabled: z.ZodDefault<z.ZodBoolean>;
354
+ /**
355
+ * Whether to interactively prompt to trust a project's hooks the first time
356
+ * they would run. When false, untrusted project hooks fail loud
357
+ * (CRUXY_E_HOOK_UNTRUSTED) instead of prompting — never auto-trust.
358
+ */
359
+ trustPrompt: z.ZodDefault<z.ZodBoolean>;
360
+ }, "strict", z.ZodTypeAny, {
361
+ enabled: boolean;
362
+ trustPrompt: boolean;
363
+ }, {
364
+ enabled?: boolean | undefined;
365
+ trustPrompt?: boolean | undefined;
366
+ }>;
367
+ export type HooksConfig = z.infer<typeof HooksConfigSchema>;
368
+ /**
369
+ * Multi-model routing (C.30): map declared task classes to tiers so mechanical
370
+ * work runs on a cheap tier and hard reasoning on a strong one. Fully opt-in —
371
+ * with an empty `map` and no `default`, every task class routes to the session's
372
+ * single tier and behavior is unchanged (routing stays inert until configured).
373
+ * Only tier names appear here; upstream model names never do (U.8). Keys are the
374
+ * fixed {@link TASK_CLASSES}, so a mistyped class is rejected at config load.
375
+ */
376
+ export declare const RoutingConfigSchema: z.ZodObject<{
377
+ /** Tier for any task class not in `map`. Unset → the tier implied by
378
+ * `model.model` (a real tier, or the auto-fallback tier). */
379
+ default: z.ZodOptional<z.ZodEnum<["kavi", "vaani", "mira"]>>;
380
+ /** Per-task-class tier overrides; anything omitted takes `default`. */
381
+ map: z.ZodDefault<z.ZodRecord<z.ZodEnum<["main-turn", "subagent", "plan", "commit-msg", "classify", "summarize"]>, z.ZodEnum<["kavi", "vaani", "mira"]>>>;
382
+ }, "strict", z.ZodTypeAny, {
383
+ map: Partial<Record<"main-turn" | "subagent" | "plan" | "commit-msg" | "classify" | "summarize", "kavi" | "vaani" | "mira">>;
384
+ default?: "kavi" | "vaani" | "mira" | undefined;
385
+ }, {
386
+ map?: Partial<Record<"main-turn" | "subagent" | "plan" | "commit-msg" | "classify" | "summarize", "kavi" | "vaani" | "mira">> | undefined;
387
+ default?: "kavi" | "vaani" | "mira" | undefined;
388
+ }>;
342
389
  /** MCP server entry — stdio or URL transport (wired up in a later phase). */
343
390
  export declare const McpServerSchema: z.ZodObject<{
344
391
  command: z.ZodOptional<z.ZodString>;
@@ -654,6 +701,36 @@ export declare const CruxyConfigSchema: z.ZodObject<{
654
701
  timeout?: number | undefined;
655
702
  mounts?: string[] | undefined;
656
703
  }>>;
704
+ hooks: z.ZodDefault<z.ZodObject<{
705
+ /** Master switch. When false, NO hook ever fires (config with hooks stays
706
+ * inert until explicitly enabled). */
707
+ enabled: z.ZodDefault<z.ZodBoolean>;
708
+ /**
709
+ * Whether to interactively prompt to trust a project's hooks the first time
710
+ * they would run. When false, untrusted project hooks fail loud
711
+ * (CRUXY_E_HOOK_UNTRUSTED) instead of prompting — never auto-trust.
712
+ */
713
+ trustPrompt: z.ZodDefault<z.ZodBoolean>;
714
+ }, "strict", z.ZodTypeAny, {
715
+ enabled: boolean;
716
+ trustPrompt: boolean;
717
+ }, {
718
+ enabled?: boolean | undefined;
719
+ trustPrompt?: boolean | undefined;
720
+ }>>;
721
+ routing: z.ZodDefault<z.ZodObject<{
722
+ /** Tier for any task class not in `map`. Unset → the tier implied by
723
+ * `model.model` (a real tier, or the auto-fallback tier). */
724
+ default: z.ZodOptional<z.ZodEnum<["kavi", "vaani", "mira"]>>;
725
+ /** Per-task-class tier overrides; anything omitted takes `default`. */
726
+ map: z.ZodDefault<z.ZodRecord<z.ZodEnum<["main-turn", "subagent", "plan", "commit-msg", "classify", "summarize"]>, z.ZodEnum<["kavi", "vaani", "mira"]>>>;
727
+ }, "strict", z.ZodTypeAny, {
728
+ map: Partial<Record<"main-turn" | "subagent" | "plan" | "commit-msg" | "classify" | "summarize", "kavi" | "vaani" | "mira">>;
729
+ default?: "kavi" | "vaani" | "mira" | undefined;
730
+ }, {
731
+ map?: Partial<Record<"main-turn" | "subagent" | "plan" | "commit-msg" | "classify" | "summarize", "kavi" | "vaani" | "mira">> | undefined;
732
+ default?: "kavi" | "vaani" | "mira" | undefined;
733
+ }>>;
657
734
  mcpServers: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
658
735
  command: z.ZodOptional<z.ZodString>;
659
736
  args: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
@@ -672,6 +749,31 @@ export declare const CruxyConfigSchema: z.ZodObject<{
672
749
  cruxy: {
673
750
  gatewayUrl: string;
674
751
  };
752
+ checkpoint: {
753
+ enabled: boolean;
754
+ retention: number;
755
+ };
756
+ sandbox: {
757
+ image: string;
758
+ enabled: boolean;
759
+ memory: string;
760
+ network: "none" | "host-loopback" | "full";
761
+ pids: number;
762
+ cpus: number;
763
+ mounts: string[];
764
+ timeout?: number | undefined;
765
+ };
766
+ approval: {
767
+ mode: "prompt";
768
+ };
769
+ subagent: {
770
+ maxDepth: number;
771
+ defaultBudget: {
772
+ maxTokens: number;
773
+ maxIterations: number;
774
+ timeoutMs?: number | undefined;
775
+ };
776
+ };
675
777
  model: {
676
778
  provider: "cruxy" | "anthropic" | "openai" | "custom";
677
779
  model: string;
@@ -702,9 +804,6 @@ export declare const CruxyConfigSchema: z.ZodObject<{
702
804
  compactThreshold: number;
703
805
  keepRecentMessages: number;
704
806
  };
705
- approval: {
706
- mode: "prompt";
707
- };
708
807
  index: {
709
808
  search: {
710
809
  defaultK: number;
@@ -720,32 +819,18 @@ export declare const CruxyConfigSchema: z.ZodObject<{
720
819
  overlapLines: number;
721
820
  };
722
821
  };
723
- checkpoint: {
724
- enabled: boolean;
725
- retention: number;
726
- };
727
- subagent: {
728
- maxDepth: number;
729
- defaultBudget: {
730
- maxTokens: number;
731
- maxIterations: number;
732
- timeoutMs?: number | undefined;
733
- };
734
- };
735
822
  test: {
736
823
  maxIterations: number;
737
824
  captureBytes: number;
738
825
  command?: string | undefined;
739
826
  };
740
- sandbox: {
741
- image: string;
827
+ hooks: {
742
828
  enabled: boolean;
743
- memory: string;
744
- network: "none" | "host-loopback" | "full";
745
- pids: number;
746
- cpus: number;
747
- mounts: string[];
748
- timeout?: number | undefined;
829
+ trustPrompt: boolean;
830
+ };
831
+ routing: {
832
+ map: Partial<Record<"main-turn" | "subagent" | "plan" | "commit-msg" | "classify" | "summarize", "kavi" | "vaani" | "mira">>;
833
+ default?: "kavi" | "vaani" | "mira" | undefined;
749
834
  };
750
835
  mcpServers: Record<string, {
751
836
  command?: string | undefined;
@@ -757,6 +842,31 @@ export declare const CruxyConfigSchema: z.ZodObject<{
757
842
  cruxy?: {
758
843
  gatewayUrl?: string | undefined;
759
844
  } | undefined;
845
+ checkpoint?: {
846
+ enabled?: boolean | undefined;
847
+ retention?: number | undefined;
848
+ } | undefined;
849
+ sandbox?: {
850
+ image?: string | undefined;
851
+ enabled?: boolean | undefined;
852
+ memory?: string | undefined;
853
+ network?: "none" | "host-loopback" | "full" | undefined;
854
+ pids?: number | undefined;
855
+ cpus?: number | undefined;
856
+ timeout?: number | undefined;
857
+ mounts?: string[] | undefined;
858
+ } | undefined;
859
+ approval?: {
860
+ mode?: "prompt" | undefined;
861
+ } | undefined;
862
+ subagent?: {
863
+ maxDepth?: number | undefined;
864
+ defaultBudget?: {
865
+ maxTokens?: number | undefined;
866
+ maxIterations?: number | undefined;
867
+ timeoutMs?: number | undefined;
868
+ } | undefined;
869
+ } | undefined;
760
870
  model?: {
761
871
  provider?: "cruxy" | "anthropic" | "openai" | "custom" | undefined;
762
872
  model?: string | undefined;
@@ -787,9 +897,6 @@ export declare const CruxyConfigSchema: z.ZodObject<{
787
897
  compactThreshold?: number | undefined;
788
898
  keepRecentMessages?: number | undefined;
789
899
  } | undefined;
790
- approval?: {
791
- mode?: "prompt" | undefined;
792
- } | undefined;
793
900
  index?: {
794
901
  search?: {
795
902
  defaultK?: number | undefined;
@@ -805,32 +912,18 @@ export declare const CruxyConfigSchema: z.ZodObject<{
805
912
  overlapLines?: number | undefined;
806
913
  } | undefined;
807
914
  } | undefined;
808
- checkpoint?: {
809
- enabled?: boolean | undefined;
810
- retention?: number | undefined;
811
- } | undefined;
812
- subagent?: {
813
- maxDepth?: number | undefined;
814
- defaultBudget?: {
815
- maxTokens?: number | undefined;
816
- maxIterations?: number | undefined;
817
- timeoutMs?: number | undefined;
818
- } | undefined;
819
- } | undefined;
820
915
  test?: {
821
916
  maxIterations?: number | undefined;
822
917
  command?: string | undefined;
823
918
  captureBytes?: number | undefined;
824
919
  } | undefined;
825
- sandbox?: {
826
- image?: string | undefined;
920
+ hooks?: {
827
921
  enabled?: boolean | undefined;
828
- memory?: string | undefined;
829
- network?: "none" | "host-loopback" | "full" | undefined;
830
- pids?: number | undefined;
831
- cpus?: number | undefined;
832
- timeout?: number | undefined;
833
- mounts?: string[] | undefined;
922
+ trustPrompt?: boolean | undefined;
923
+ } | undefined;
924
+ routing?: {
925
+ map?: Partial<Record<"main-turn" | "subagent" | "plan" | "commit-msg" | "classify" | "summarize", "kavi" | "vaani" | "mira">> | undefined;
926
+ default?: "kavi" | "vaani" | "mira" | undefined;
834
927
  } | undefined;
835
928
  mcpServers?: Record<string, {
836
929
  command?: string | undefined;
@@ -1,5 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { LOG_LEVELS } from "../utils/logger.js";
3
+ import { MODEL_TIERS } from "../brand/voice.js";
4
+ import { TASK_CLASSES } from "../routing/types.js";
3
5
  export const ProviderSchema = z.enum([
4
6
  "cruxy",
5
7
  "anthropic",
@@ -233,6 +235,44 @@ export const SandboxConfigSchema = z
233
235
  mounts: z.array(z.string().min(1)).default([]),
234
236
  })
235
237
  .strict();
238
+ /**
239
+ * Hooks + custom slash commands (C.19). Hooks run user-authored shell commands
240
+ * on lifecycle events — a real execution surface — so they are OFF by default
241
+ * and, when on, still funnel through the SAME U.3 gate + C.16 sandbox as any
242
+ * agent command (never a bypass). The hook *definitions* live in layered
243
+ * `hooks.json` files (project > user, source-tracked for the trust model); this
244
+ * schema holds only the global toggles.
245
+ */
246
+ export const HooksConfigSchema = z
247
+ .object({
248
+ /** Master switch. When false, NO hook ever fires (config with hooks stays
249
+ * inert until explicitly enabled). */
250
+ enabled: z.boolean().default(false),
251
+ /**
252
+ * Whether to interactively prompt to trust a project's hooks the first time
253
+ * they would run. When false, untrusted project hooks fail loud
254
+ * (CRUXY_E_HOOK_UNTRUSTED) instead of prompting — never auto-trust.
255
+ */
256
+ trustPrompt: z.boolean().default(true),
257
+ })
258
+ .strict();
259
+ /**
260
+ * Multi-model routing (C.30): map declared task classes to tiers so mechanical
261
+ * work runs on a cheap tier and hard reasoning on a strong one. Fully opt-in —
262
+ * with an empty `map` and no `default`, every task class routes to the session's
263
+ * single tier and behavior is unchanged (routing stays inert until configured).
264
+ * Only tier names appear here; upstream model names never do (U.8). Keys are the
265
+ * fixed {@link TASK_CLASSES}, so a mistyped class is rejected at config load.
266
+ */
267
+ export const RoutingConfigSchema = z
268
+ .object({
269
+ /** Tier for any task class not in `map`. Unset → the tier implied by
270
+ * `model.model` (a real tier, or the auto-fallback tier). */
271
+ default: z.enum(MODEL_TIERS).optional(),
272
+ /** Per-task-class tier overrides; anything omitted takes `default`. */
273
+ map: z.record(z.enum(TASK_CLASSES), z.enum(MODEL_TIERS)).default({}),
274
+ })
275
+ .strict();
236
276
  /** MCP server entry — stdio or URL transport (wired up in a later phase). */
237
277
  export const McpServerSchema = z
238
278
  .object({
@@ -256,6 +296,8 @@ export const CruxyConfigSchema = z
256
296
  subagent: SubagentConfigSchema.default({}),
257
297
  test: TestConfigSchema.default({}),
258
298
  sandbox: SandboxConfigSchema.default({}),
299
+ hooks: HooksConfigSchema.default({}),
300
+ routing: RoutingConfigSchema.default({}),
259
301
  mcpServers: z.record(z.string(), McpServerSchema).default({}),
260
302
  logLevel: z.enum(LOG_LEVELS).default("info"),
261
303
  })
@@ -21,6 +21,15 @@ export declare const PROJECT_INSTRUCTION_FILENAMES: string[];
21
21
  * root for shipped builtins.
22
22
  */
23
23
  export declare const SKILLS_DIR_NAME = "skills";
24
+ /** Hook definitions file, under the project dir (`<cwd>/.cruxy/hooks.json`) and
25
+ * the global dir (`~/.cruxy/hooks.json`). Pure data, validated, never eval'd (C.19). */
26
+ export declare const HOOKS_FILE_NAME = "hooks.json";
27
+ /** Custom slash-command directory (`<cwd>/.cruxy/commands`, `~/.cruxy/commands`);
28
+ * one `<name>.md` per command (frontmatter + template body), mirroring skills. */
29
+ export declare const COMMANDS_DIR_NAME = "commands";
30
+ /** Per-repo hook-trust record, in the GLOBAL dir only (`~/.cruxy/trust.json`) —
31
+ * never in a repo, so cloning carries no trust (C.19 supply-chain safety). */
32
+ export declare const TRUST_FILE_NAME = "trust.json";
24
33
  /**
25
34
  * Absolute path of the shipped builtin skills directory (`<pkg>/skills`).
26
35
  * Anchored the same way as the package.json lookup above: both `dist/` and
package/dist/constants.js CHANGED
@@ -41,6 +41,15 @@ export const PROJECT_INSTRUCTION_FILENAMES = ["CRUXY.md", "AGENTS.md"];
41
41
  * root for shipped builtins.
42
42
  */
43
43
  export const SKILLS_DIR_NAME = "skills";
44
+ /** Hook definitions file, under the project dir (`<cwd>/.cruxy/hooks.json`) and
45
+ * the global dir (`~/.cruxy/hooks.json`). Pure data, validated, never eval'd (C.19). */
46
+ export const HOOKS_FILE_NAME = "hooks.json";
47
+ /** Custom slash-command directory (`<cwd>/.cruxy/commands`, `~/.cruxy/commands`);
48
+ * one `<name>.md` per command (frontmatter + template body), mirroring skills. */
49
+ export const COMMANDS_DIR_NAME = "commands";
50
+ /** Per-repo hook-trust record, in the GLOBAL dir only (`~/.cruxy/trust.json`) —
51
+ * never in a repo, so cloning carries no trust (C.19 supply-chain safety). */
52
+ export const TRUST_FILE_NAME = "trust.json";
44
53
  /**
45
54
  * Absolute path of the shipped builtin skills directory (`<pkg>/skills`).
46
55
  * Anchored the same way as the package.json lookup above: both `dist/` and
@@ -11,6 +11,15 @@ export declare function usageError(title: string, nextSteps?: string[]): CruxyEr
11
11
  export declare function interactiveRequired(what: string, alternatives?: string[]): CruxyError;
12
12
  export declare function configKeyUnknown(key: string): CruxyError;
13
13
  export declare function providerUnsupported(provider: string): CruxyError;
14
+ /**
15
+ * A task class is routed (C.30) to a tier the gateway does not offer. A usage
16
+ * error the user fixes in config — cruxy fails loud here rather than silently
17
+ * substituting a different tier (which would hand a user a model they never
18
+ * asked for). Distinct from runtime unavailability (overload/budget), which
19
+ * stays on the U.5 api codes. Params are tier/class NAMES only — never an
20
+ * upstream model id — so the message is gag-safe by construction (U.8).
21
+ */
22
+ export declare function routingTierUnavailable(tier: string, taskClass: string, offered: string[]): CruxyError;
14
23
  export declare function configParse(path: string, underlying?: unknown): CruxyError;
15
24
  export declare function configInvalid(issues: string, path?: string): CruxyError;
16
25
  export declare function authMissingKey(provider: string, envVar: string): CruxyError;
@@ -46,6 +55,22 @@ export declare function sandboxImage(image: string, underlying?: unknown): Cruxy
46
55
  * can't launch is never papered over with a host run.
47
56
  */
48
57
  export declare function sandboxExec(underlying?: unknown): CruxyError;
58
+ /**
59
+ * A blocking hook failed (non-zero exit, gate-declined, or errored). The action
60
+ * that fired it is aborted fail-closed — a failing pre-check must stop, never
61
+ * proceed. Advisory hooks report but never raise this.
62
+ */
63
+ export declare function hookFailed(name: string, reason: string, underlying?: unknown): CruxyError;
64
+ /**
65
+ * A project (a cloned/opened repo) defines hooks that have not been trusted for
66
+ * this machine. Cruxy NEVER runs another author's hooks silently — trust is an
67
+ * explicit, per-repo decision recorded in your home, not in the repo.
68
+ */
69
+ export declare function hookUntrusted(root: string, count: number): CruxyError;
70
+ /** A malformed hook definition — excluded from the catalog, never eval'd. */
71
+ export declare function hookInvalid(reason: string, source?: string): CruxyError;
72
+ /** A malformed custom slash-command definition — excluded and surfaced. */
73
+ export declare function slashInvalid(reason: string, source?: string): CruxyError;
49
74
  /**
50
75
  * A side-effecting action needs approval but cruxy can't ask (non-interactive,
51
76
  * no policy). Default-deny — never auto-approve. A distinct exit code (10) so CI