@kinqs/brainrouter-cli 0.3.5 → 0.3.7

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 (125) hide show
  1. package/README.md +29 -52
  2. package/agents/architect.json +18 -0
  3. package/agents/explorer.json +18 -0
  4. package/agents/reviewer.json +18 -0
  5. package/agents/verifier.json +18 -0
  6. package/agents/worker.json +18 -0
  7. package/bin/cli.cjs +71 -0
  8. package/dist/agent/agent.d.ts +224 -3
  9. package/dist/agent/agent.js +561 -55
  10. package/dist/cli/banner.d.ts +80 -0
  11. package/dist/cli/banner.js +232 -0
  12. package/dist/cli/cliPrompt.d.ts +106 -0
  13. package/dist/cli/cliPrompt.js +314 -0
  14. package/dist/cli/commands/_context.d.ts +3 -1
  15. package/dist/cli/commands/_helpers.d.ts +1 -1
  16. package/dist/cli/commands/_helpers.js +6 -6
  17. package/dist/cli/commands/config.d.ts +46 -0
  18. package/dist/cli/commands/config.js +1042 -0
  19. package/dist/cli/commands/guard.js +75 -10
  20. package/dist/cli/commands/init.d.ts +20 -0
  21. package/dist/cli/commands/init.js +64 -0
  22. package/dist/cli/commands/login.d.ts +13 -0
  23. package/dist/cli/commands/login.js +179 -0
  24. package/dist/cli/commands/mcp.d.ts +19 -0
  25. package/dist/cli/commands/mcp.js +286 -0
  26. package/dist/cli/commands/memory.js +2 -2
  27. package/dist/cli/commands/obs.js +22 -22
  28. package/dist/cli/commands/orchestration.js +18 -0
  29. package/dist/cli/commands/session.js +13 -5
  30. package/dist/cli/commands/ui.js +202 -91
  31. package/dist/cli/commands/workflow.d.ts +20 -0
  32. package/dist/cli/commands/workflow.js +368 -51
  33. package/dist/cli/ink/ChatApp.d.ts +206 -0
  34. package/dist/cli/ink/ChatApp.js +493 -0
  35. package/dist/cli/ink/Frame.d.ts +26 -0
  36. package/dist/cli/ink/Frame.js +5 -0
  37. package/dist/cli/ink/Picker.d.ts +65 -0
  38. package/dist/cli/ink/Picker.js +133 -0
  39. package/dist/cli/ink/SlashPalette.d.ts +51 -0
  40. package/dist/cli/ink/SlashPalette.js +136 -0
  41. package/dist/cli/ink/TextField.d.ts +34 -0
  42. package/dist/cli/ink/TextField.js +47 -0
  43. package/dist/cli/ink/WizardApp.d.ts +7 -0
  44. package/dist/cli/ink/WizardApp.js +422 -0
  45. package/dist/cli/ink/ambientChat.d.ts +34 -0
  46. package/dist/cli/ink/ambientChat.js +7 -0
  47. package/dist/cli/ink/consoleCapture.d.ts +11 -0
  48. package/dist/cli/ink/consoleCapture.js +33 -0
  49. package/dist/cli/ink/markdownRender.d.ts +41 -0
  50. package/dist/cli/ink/markdownRender.js +278 -0
  51. package/dist/cli/ink/renderWithResizeClear.d.ts +14 -0
  52. package/dist/cli/ink/renderWithResizeClear.js +33 -0
  53. package/dist/cli/ink/runChat.d.ts +34 -0
  54. package/dist/cli/ink/runChat.js +571 -0
  55. package/dist/cli/ink/runPicker.d.ts +31 -0
  56. package/dist/cli/ink/runPicker.js +139 -0
  57. package/dist/cli/ink/runSlashPalette.d.ts +23 -0
  58. package/dist/cli/ink/runSlashPalette.js +33 -0
  59. package/dist/cli/ink/runWizard.d.ts +22 -0
  60. package/dist/cli/ink/runWizard.js +133 -0
  61. package/dist/cli/ink/stdinHandoff.d.ts +51 -0
  62. package/dist/cli/ink/stdinHandoff.js +78 -0
  63. package/dist/cli/ink/toolFormat.d.ts +73 -0
  64. package/dist/cli/ink/toolFormat.js +180 -0
  65. package/dist/cli/ink/useTerminalSize.d.ts +35 -0
  66. package/dist/cli/ink/useTerminalSize.js +26 -0
  67. package/dist/cli/repl.d.ts +25 -3
  68. package/dist/cli/repl.js +64 -646
  69. package/dist/cli/slashSuggest.d.ts +32 -0
  70. package/dist/cli/slashSuggest.js +146 -0
  71. package/dist/cli/spinner.d.ts +34 -0
  72. package/dist/cli/spinner.js +36 -0
  73. package/dist/cli/statusline.d.ts +67 -0
  74. package/dist/cli/statusline.js +204 -0
  75. package/dist/cli/theme.d.ts +79 -0
  76. package/dist/cli/theme.js +106 -0
  77. package/dist/cli/whereView.d.ts +81 -0
  78. package/dist/cli/whereView.js +245 -0
  79. package/dist/cli/wizard/modelsApi.d.ts +72 -0
  80. package/dist/cli/wizard/modelsApi.js +166 -0
  81. package/dist/cli/wizard/picker.d.ts +202 -0
  82. package/dist/cli/wizard/picker.js +547 -0
  83. package/dist/cli/wizard/providers.d.ts +86 -0
  84. package/dist/cli/wizard/providers.js +190 -0
  85. package/dist/cli/wizard/runner.d.ts +13 -0
  86. package/dist/cli/wizard/runner.js +488 -0
  87. package/dist/cli/wizard/types.d.ts +122 -0
  88. package/dist/cli/wizard/types.js +109 -0
  89. package/dist/config/config.d.ts +52 -0
  90. package/dist/config/config.js +89 -75
  91. package/dist/index.js +215 -206
  92. package/dist/memory/briefing.d.ts +11 -1
  93. package/dist/memory/briefing.js +69 -1
  94. package/dist/memory/consolidation.d.ts +1 -1
  95. package/dist/orchestration/agentRegistry.d.ts +36 -0
  96. package/dist/orchestration/agentRegistry.js +64 -0
  97. package/dist/orchestration/orchestrator.d.ts +7 -0
  98. package/dist/orchestration/orchestrator.js +2 -0
  99. package/dist/orchestration/tools.d.ts +10 -1
  100. package/dist/orchestration/tools.js +48 -4
  101. package/dist/prompt/breadthHint.d.ts +5 -0
  102. package/dist/prompt/breadthHint.js +44 -0
  103. package/dist/prompt/skillCatalog.d.ts +11 -0
  104. package/dist/prompt/skillCatalog.js +134 -0
  105. package/dist/prompt/skillRunner.d.ts +2 -2
  106. package/dist/prompt/skillRunner.js +2 -31
  107. package/dist/prompt/systemPrompt.d.ts +34 -0
  108. package/dist/prompt/systemPrompt.js +128 -108
  109. package/dist/runtime/dangerousCommand.d.ts +53 -0
  110. package/dist/runtime/dangerousCommand.js +105 -0
  111. package/dist/runtime/mcpClient.d.ts +38 -1
  112. package/dist/runtime/mcpClient.js +104 -13
  113. package/dist/runtime/mcpPool.d.ts +162 -0
  114. package/dist/runtime/mcpPool.js +423 -0
  115. package/dist/runtime/mcpUtils.d.ts +3 -1
  116. package/dist/state/goalStore.d.ts +98 -17
  117. package/dist/state/goalStore.js +132 -42
  118. package/dist/state/preferencesStore.d.ts +67 -3
  119. package/dist/state/preferencesStore.js +84 -1
  120. package/dist/state/workflowArtifacts.d.ts +63 -2
  121. package/dist/state/workflowArtifacts.js +120 -8
  122. package/dist/tests/_helpers.d.ts +31 -0
  123. package/dist/tests/_helpers.js +91 -0
  124. package/package.json +12 -5
  125. package/.env.example +0 -109
@@ -1,8 +1,29 @@
1
1
  import fs from 'node:fs';
2
- import { getCliStateFile, getSessionStateFile, readJsonFile, writeJsonFile } from './cliState.js';
2
+ import path from 'node:path';
3
+ import { getCliStateDir, getCliStateFile, getSessionStateFile, readJsonFile, writeJsonFile } from './cliState.js';
3
4
  /** A pausing status is one where continuation is halted but resumable. */
4
5
  export const PAUSING_STATUSES = ['paused', 'blocked', 'usage_limited'];
5
- export const DEFAULT_GOAL_BUDGET = 10;
6
+ /**
7
+ * Default iteration cap when the user doesn't pass one.
8
+ *
9
+ * Set to a very high number (effectively "unlimited" for any real task)
10
+ * rather than a tight 10. Rationale: the goal lifecycle has three
11
+ * independent safety nets that already prevent runaway loops —
12
+ * 1. Anti-spin — a turn that made zero tool calls doesn't continue
13
+ * 2. Repeat-loop — calling the same tool with identical args 3× errors
14
+ * 3. Manual stop — Ctrl-C, /goal pause, /goal clear
15
+ *
16
+ * A hard iteration cap on top of those is overly paternalistic for users
17
+ * running local models (no $ cost) and is easily lifted with /goal budget
18
+ * <n> when wanted. Display layers should treat any value >= UNLIMITED_THRESHOLD
19
+ * as "unlimited" for friendlier UX.
20
+ */
21
+ export const DEFAULT_GOAL_BUDGET = 1_000_000;
22
+ export const UNLIMITED_BUDGET_THRESHOLD = 100_000;
23
+ /** Format helper — used by REPL display + status output. */
24
+ export function formatBudget(maxIterations) {
25
+ return maxIterations >= UNLIMITED_BUDGET_THRESHOLD ? 'unlimited' : String(maxIterations);
26
+ }
6
27
  /**
7
28
  * Hard cap on the goal text length. A goal is supposed to be a 1–3 sentence
8
29
  * outcome statement; multi-thousand-character pastes (e.g. full chat logs)
@@ -67,26 +88,55 @@ function normalize(raw) {
67
88
  blockedReason: raw.blockedReason,
68
89
  };
69
90
  }
70
- function resolveGoalFile(workspaceRoot, sessionKey) {
91
+ /**
92
+ * Resolve the on-disk location where the active goal for this CLI process
93
+ * lives.
94
+ *
95
+ * **Design (0.3.6 decouple-goal-from-workflow, supersedes Item 3):** goal
96
+ * is **always per-session**. Workflows are durable artifact folders
97
+ * (spec.md, tasks.md, walkthrough.md, meta.json) that have nothing to do
98
+ * with the agent's autonomy primitive. The earlier Item 3 design coupled
99
+ * the two by storing goal state inside `<workflow>/goal.json`, which
100
+ * meant any two CLI sessions in the same workspace that happened to land
101
+ * on the same workflow shared a goal — silently reintroducing the
102
+ * cross-session leak PR #26 had fixed. We removed that coupling
103
+ * entirely: workflows are storage, goals are runtime, no overlap.
104
+ *
105
+ * Priority chain:
106
+ * 1. Session-scoped — `<session>/goal.json`. The normal case.
107
+ * 2. Legacy — `<cli-state>/goal.json`. Only hit by callers without a
108
+ * sessionKey (rare; mostly tooling paths that pre-date Item 1).
109
+ */
110
+ export function resolveGoalScope(workspaceRoot, sessionKey) {
71
111
  if (sessionKey) {
72
- const sessionPath = getSessionStateFile(workspaceRoot, sessionKey, 'goal.json');
73
- if (fs.existsSync(sessionPath))
74
- return sessionPath;
112
+ return {
113
+ scope: 'session',
114
+ sessionKey,
115
+ path: getSessionStateFile(workspaceRoot, sessionKey, 'goal.json'),
116
+ };
75
117
  }
76
- return getCliStateFile(workspaceRoot, 'goal.json');
118
+ return { scope: 'legacy', path: getCliStateFile(workspaceRoot, 'goal.json') };
77
119
  }
78
120
  export function readGoal(workspaceRoot, sessionKey) {
79
- if (sessionKey) {
80
- const sessionPath = getSessionStateFile(workspaceRoot, sessionKey, 'goal.json');
81
- if (fs.existsSync(sessionPath)) {
82
- return normalize(readJsonFile(sessionPath, null));
83
- }
84
- }
121
+ const scope = resolveGoalScope(workspaceRoot, sessionKey);
122
+ if (!fs.existsSync(scope.path))
123
+ return null;
124
+ return normalize(readJsonFile(scope.path, null));
125
+ }
126
+ function archiveLegacyGoal(workspaceRoot) {
85
127
  const legacyPath = getCliStateFile(workspaceRoot, 'goal.json');
86
- if (fs.existsSync(legacyPath)) {
87
- return normalize(readJsonFile(legacyPath, null));
128
+ if (!fs.existsSync(legacyPath))
129
+ return;
130
+ const archiveDir = path.join(getCliStateDir(workspaceRoot), '.brainrouter.migrated');
131
+ fs.mkdirSync(archiveDir, { recursive: true });
132
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
133
+ let archivePath = path.join(archiveDir, `legacy-goal-${stamp}.json`);
134
+ let suffix = 1;
135
+ while (fs.existsSync(archivePath)) {
136
+ archivePath = path.join(archiveDir, `legacy-goal-${stamp}-${suffix}.json`);
137
+ suffix += 1;
88
138
  }
89
- return null;
139
+ fs.renameSync(legacyPath, archivePath);
90
140
  }
91
141
  /**
92
142
  * Set a new active goal. Refuses to overwrite an in-progress goal (active,
@@ -111,6 +161,13 @@ export function setGoal(workspaceRoot, text, sessionKey, options = {}) {
111
161
  throw new GoalConflictError(existing);
112
162
  }
113
163
  }
164
+ const scope = resolveGoalScope(workspaceRoot, sessionKey);
165
+ // Archive any stale workspace-level goal.json the moment we write to a
166
+ // non-legacy scope (workflow OR session). This preserves the Item 1 fix:
167
+ // never leave the legacy file where a future session would re-pick it up.
168
+ if (scope.scope !== 'legacy') {
169
+ archiveLegacyGoal(workspaceRoot);
170
+ }
114
171
  const now = new Date().toISOString();
115
172
  const goal = {
116
173
  text: trimmed,
@@ -124,19 +181,19 @@ export function setGoal(workspaceRoot, text, sessionKey, options = {}) {
124
181
  startedAt: now,
125
182
  updatedAt: now,
126
183
  };
127
- const filePath = sessionKey
128
- ? getSessionStateFile(workspaceRoot, sessionKey, 'goal.json')
129
- : getCliStateFile(workspaceRoot, 'goal.json');
130
- writeJsonFile(filePath, goal);
184
+ writeJsonFile(scope.path, goal);
131
185
  return goal;
132
186
  }
133
187
  export function clearGoal(workspaceRoot, sessionKey) {
134
- if (sessionKey) {
135
- writeJsonFile(getSessionStateFile(workspaceRoot, sessionKey, 'goal.json'), null);
136
- }
137
- const legacy = getCliStateFile(workspaceRoot, 'goal.json');
138
- if (fs.existsSync(legacy)) {
139
- writeJsonFile(legacy, null);
188
+ const scope = resolveGoalScope(workspaceRoot, sessionKey);
189
+ writeJsonFile(scope.path, null);
190
+ // Also clear the legacy workspace file when we're operating on a higher-
191
+ // priority scope leaving it behind would let a future no-sessionKey
192
+ // read resurface a stale goal.
193
+ if (scope.scope !== 'legacy') {
194
+ const legacy = getCliStateFile(workspaceRoot, 'goal.json');
195
+ if (fs.existsSync(legacy))
196
+ writeJsonFile(legacy, null);
140
197
  }
141
198
  }
142
199
  function patchGoal(workspaceRoot, sessionKey, patch) {
@@ -148,7 +205,7 @@ function patchGoal(workspaceRoot, sessionKey, patch) {
148
205
  ...patch,
149
206
  updatedAt: new Date().toISOString(),
150
207
  };
151
- writeJsonFile(resolveGoalFile(workspaceRoot, sessionKey), next);
208
+ writeJsonFile(resolveGoalScope(workspaceRoot, sessionKey).path, next);
152
209
  return next;
153
210
  }
154
211
  export function pauseGoal(workspaceRoot, sessionKey) {
@@ -311,18 +368,19 @@ export function goalIsOnFinalBudgetTurn(goal) {
311
368
  return false;
312
369
  }
313
370
  /**
314
- * Wrap-up steering message injected on the final-budget turn. The agent
315
- * loop pushes this into the chat history as a system message so the model
316
- * pivots from "continue investigating" to "consolidate and report." Plain
317
- * directive, no role-play.
371
+ * Wrap-up directive folded into the goal-anchor block when the goal is on
372
+ * its final budget turn. Reports WHICH cap is tight (iterations, tokens,
373
+ * or both) so the model isn't told "one turn left" when it actually has
374
+ * many iterations remaining but is near the token cap, or vice versa.
318
375
  *
319
- * The message specifically reports WHICH cap is tight (iterations, tokens,
320
- * or both) so the model doesn't get told "one turn left" when it actually
321
- * has many iterations remaining but is near the token cap, or vice versa.
322
- * Earlier versions hardcoded the iteration framing even when only the
323
- * token heuristic tripped, which misled the model on token-budgeted runs.
376
+ * Pre-0.3.6-9d this lived in its own `buildBudgetSteeringMessage` function
377
+ * emitted as a separate `goal-budget-steering` tagged system message
378
+ * which meant the same iteration/token counts appeared in TWO places per
379
+ * turn (the goal anchor AND the steering message). 9d folded the wrap-up
380
+ * directive into the anchor itself; `formatGoalBlock(goal)` calls this
381
+ * helper internally when `goalIsOnFinalBudgetTurn(goal)` returns true.
324
382
  */
325
- export function buildBudgetSteeringMessage(goal) {
383
+ function buildWrapUpDirective(goal) {
326
384
  const iterationsRemaining = Math.max(0, goal.budget.maxIterations - goal.budget.iterationsUsed - 1);
327
385
  const iterationTight = goal.budget.iterationsUsed + 2 >= goal.budget.maxIterations;
328
386
  const tokensTight = typeof goal.budget.maxTokens === 'number' &&
@@ -345,7 +403,6 @@ export function buildBudgetSteeringMessage(goal) {
345
403
  `(cap ${goal.budget.maxIterations})${tokensClause}. This is your last turn.`;
346
404
  }
347
405
  else {
348
- // Token cap is the trigger; iterations may still have plenty of headroom.
349
406
  const tokensUsed = goal.budget.tokensUsed ?? 0;
350
407
  const tokensCap = goal.budget.maxTokens ?? 0;
351
408
  const tokensRemaining = Math.max(0, tokensCap - tokensUsed);
@@ -355,7 +412,7 @@ export function buildBudgetSteeringMessage(goal) {
355
412
  `Iteration count still has headroom but the token cap will trip before another full turn fits.`;
356
413
  }
357
414
  return [
358
- '## Budget about to run out',
415
+ '## ⚠️ Final iteration wrap up cleanly',
359
416
  headline,
360
417
  'Do not start any new long-running investigation, spawn new children, or read more files.',
361
418
  'Instead:',
@@ -365,16 +422,21 @@ export function buildBudgetSteeringMessage(goal) {
365
422
  '4. If you need more budget, say so explicitly so the user can extend it.',
366
423
  ].join('\n');
367
424
  }
368
- export function formatGoalBlock(goal) {
369
- const remaining = Math.max(0, goal.budget.maxIterations - goal.budget.iterationsUsed);
425
+ export function formatGoalBlock(goal, options = {}) {
426
+ const cap = formatBudget(goal.budget.maxIterations);
427
+ const remaining = cap === 'unlimited'
428
+ ? 'unlimited'
429
+ : String(Math.max(0, goal.budget.maxIterations - goal.budget.iterationsUsed));
370
430
  const tokenLine = goal.budget.maxTokens
371
431
  ? `**Tokens:** ${(goal.budget.tokensUsed ?? 0).toLocaleString()} of ${goal.budget.maxTokens.toLocaleString()} used`
372
432
  : '';
433
+ const isFinalBudgetTurn = options.finalBudgetTurn ?? goalIsOnFinalBudgetTurn(goal);
434
+ const wrapUp = isFinalBudgetTurn && goal.status === 'active' ? buildWrapUpDirective(goal) : '';
373
435
  return [
374
436
  `## Active Goal — ${goal.status.toUpperCase().replace('_', ' ')}`,
375
437
  '',
376
438
  `**Outcome:** ${goal.text}`,
377
- `**Iteration:** ${goal.budget.iterationsUsed + 1} of ${goal.budget.maxIterations} (${remaining} remaining)`,
439
+ `**Iteration:** ${goal.budget.iterationsUsed + 1} of ${cap} (${remaining} remaining)`,
378
440
  tokenLine,
379
441
  `**Started:** ${goal.startedAt}`,
380
442
  goal.blockedReason ? `**Reason:** ${goal.blockedReason}` : '',
@@ -406,5 +468,33 @@ export function formatGoalBlock(goal) {
406
468
  '',
407
469
  'Always audit the evidence before declaring complete — failing tests, missing files,',
408
470
  'or unverified claims mean the goal is NOT done yet.',
471
+ wrapUp ? '' : '',
472
+ wrapUp,
409
473
  ].filter(Boolean).join('\n');
410
474
  }
475
+ /**
476
+ * Drift / ready check used by the goal-continuation prompt. Compressed
477
+ * from the prose-heavy 4-paragraph form into a 2-line checklist as part
478
+ * of 9d's prompt deduplication — the goal text, status, and budget are
479
+ * now owned by the goal-anchor system message; the continuation prompt
480
+ * carries only the per-turn drift check + a pointer to the anchor.
481
+ *
482
+ * Distinct from `buildGoalKickoffPrompt` (in `commands/_helpers.ts`),
483
+ * which is the FIRST-turn prompt fired by `/goal <text>` and `/goal resume`.
484
+ */
485
+ export function buildGoalContinuationPrompt(goal, lastPrompt, lastAnswer) {
486
+ const iter = goal.budget.iterationsUsed + 1;
487
+ const cap = formatBudget(goal.budget.maxIterations);
488
+ return [
489
+ `[GOAL CONTINUATION — iteration ${iter}/${cap}]`,
490
+ '',
491
+ 'Your goal, budget, and the goal_complete / goal_blocked contract are pinned in the goal-anchor system message above. This turn must serve that contract.',
492
+ '',
493
+ '**Drift check (mandatory):**',
494
+ '1. Does the next tool call advance the outcome stated in the anchor? If no, stop and either pick one that does, or call `goal_complete` / `goal_blocked`.',
495
+ '2. Restating intent in prose without a tool call is anti-spin — the loop will halt on intermediate turns that emit only prose. Final goal-completing turns require prose alongside the tool call.',
496
+ '',
497
+ `Last user message: ${lastPrompt || '(none)'}`,
498
+ `Your previous response (truncated): ${lastAnswer.slice(0, 600)}${lastAnswer.length > 600 ? '…' : ''}`,
499
+ ].join('\n');
500
+ }
@@ -5,6 +5,9 @@
5
5
  * CLI restarts but stay scoped to the project (different repos can have
6
6
  * different settings).
7
7
  */
8
+ export type ExecutionMode = 'planning' | 'fast';
9
+ export type ReviewPolicy = 'request' | 'proceed';
10
+ export type EffortLevel = 'low' | 'medium' | 'high';
8
11
  export interface Preferences {
9
12
  /** When true, every worker spawn is auto-followed by a reviewer pass on the diff. */
10
13
  autoReview: boolean;
@@ -13,9 +16,25 @@ export interface Preferences {
13
16
  /** Status-line layout: comma-separated segments from {mode,branch,dirty,model,tokens,session}. */
14
17
  statusline: string;
15
18
  /**
16
- * When true, `run_command` skips the per-call confirmation prompt and runs
17
- * immediately. Pair with sandboxing (BRAINROUTER_SANDBOX=on) if you want
18
- * the safety net without the friction. Off by default opt-in via /yolo.
19
+ * Session execution stance. `planning` (default) routes `run_command`
20
+ * through the per-call `askYesNo` confirmation and keeps the system prompt
21
+ * leaning toward clarify-before-act. `fast` skips the confirmation for
22
+ * non-dangerous commands (see `isDangerousCommand`) and tells the model
23
+ * to jump to implementation. Toggle with `/mode`.
24
+ */
25
+ executionMode: ExecutionMode;
26
+ /**
27
+ * Behaviour at workflow / multi-file approval gates. `request` (default)
28
+ * keeps today's prose-based "ready for your approval?" gesture in front
29
+ * of `/approve`. `proceed` tells the model to apply the plan and report
30
+ * after, without the explicit ask. Toggle with `/review-policy`.
31
+ */
32
+ reviewPolicy: ReviewPolicy;
33
+ /**
34
+ * DEPRECATED — superseded by `executionMode` + `reviewPolicy` in 0.3.6.
35
+ * Kept on disk so older callers keep functioning during the alias
36
+ * transition. New code MUST read `executionMode === 'fast'` instead;
37
+ * `readPreferences` back-fills the new fields from this on first read.
19
38
  */
20
39
  autoApproveShell: boolean;
21
40
  /** Syntax highlighting theme for markdown output. Tied to/theme. */
@@ -36,6 +55,51 @@ export interface Preferences {
36
55
  sandboxReadPaths: string[];
37
56
  /** Extra write-allowed paths granted to sandboxed run_command. */
38
57
  sandboxWritePaths: string[];
58
+ /**
59
+ * When true, hide non-essential chrome from the REPL: briefing/recall
60
+ * tables, tool-completion previews, spawn dumps. Leaves spinner + model
61
+ * prose. Tied to /quiet and the --quiet startup flag. Off by default.
62
+ */
63
+ quiet: boolean;
64
+ /**
65
+ * Reasoning depth preference. `medium` (default) is today's behaviour and
66
+ * emits no system-prompt overlay and no provider-side reasoning slot.
67
+ * `low` adds a "be terse, skip ceremony" overlay; `high` adds a
68
+ * step-by-step audit overlay. When the LLM endpoint exposes a reasoning
69
+ * slot (gpt-5 / o-series via Chat Completions accept `reasoning_effort`),
70
+ * the level is also forwarded as the provider-native enum. Tied to
71
+ * `/effort` and the `BRAINROUTER_EFFORT` env override.
72
+ */
73
+ effort: EffortLevel;
39
74
  }
40
75
  export declare function readPreferences(workspaceRoot: string): Preferences;
41
76
  export declare function writePreferences(workspaceRoot: string, prefs: Partial<Preferences>): Preferences;
77
+ /**
78
+ * `/yolo on` shorthand: flip both new fields to their "do not interrupt me"
79
+ * setting. We also keep the legacy `autoApproveShell` mirror in sync so any
80
+ * external tooling that still inspects it sees a consistent state during
81
+ * the alias period.
82
+ */
83
+ export declare function applyYoloOn(workspaceRoot: string): Preferences;
84
+ /**
85
+ * `/yolo off` shorthand: restore the conservative defaults on both axes.
86
+ */
87
+ export declare function applyYoloOff(workspaceRoot: string): Preferences;
88
+ export interface ResolvedEffort {
89
+ effort: EffortLevel;
90
+ source: 'env' | 'preference' | 'default';
91
+ }
92
+ /**
93
+ * Resolve the active reasoning-depth level using env > preference > default.
94
+ * Matches the precedence pattern set by `resolveTheme` and the
95
+ * `BRAINROUTER_QUIET` override.
96
+ *
97
+ * A garbled `BRAINROUTER_EFFORT` value (e.g. `BRAINROUTER_EFFORT=ludicrous`)
98
+ * is treated as unset so users don't get cryptic crashes — the preference
99
+ * takes over.
100
+ *
101
+ * We read the raw file (not `readPreferences`) so we can distinguish a
102
+ * preference that was explicitly written from the default that
103
+ * `readPreferences` injects via its spread.
104
+ */
105
+ export declare function resolveEffort(workspaceRoot?: string): ResolvedEffort;
@@ -3,6 +3,8 @@ const DEFAULT = {
3
3
  autoReview: false,
4
4
  editorMode: 'emacs',
5
5
  statusline: 'mode',
6
+ executionMode: 'planning',
7
+ reviewPolicy: 'request',
6
8
  autoApproveShell: false,
7
9
  theme: 'auto',
8
10
  terminalTitle: 'model,session',
@@ -13,13 +15,94 @@ const DEFAULT = {
13
15
  keymap: '',
14
16
  sandboxReadPaths: [],
15
17
  sandboxWritePaths: [],
18
+ quiet: false,
19
+ effort: 'medium',
16
20
  };
21
+ /**
22
+ * Back-fill `executionMode` / `reviewPolicy` from the legacy
23
+ * `autoApproveShell` flag when an older prefs file is read for the first
24
+ * time. Migration is read-only and idempotent: if either new field is
25
+ * already present we leave both alone (the user has already opted into the
26
+ * new model and any drift between the two is intentional). The legacy field
27
+ * stays on disk so other readers don't break during the alias period.
28
+ */
29
+ function migrateLegacyShell(stored) {
30
+ const hasNewFields = stored.executionMode !== undefined || stored.reviewPolicy !== undefined;
31
+ if (hasNewFields)
32
+ return stored;
33
+ if (stored.autoApproveShell !== true)
34
+ return stored;
35
+ return {
36
+ ...stored,
37
+ executionMode: 'fast',
38
+ reviewPolicy: 'proceed',
39
+ };
40
+ }
17
41
  export function readPreferences(workspaceRoot) {
18
42
  const stored = readJsonFile(getCliStateFile(workspaceRoot, 'preferences.json'), {});
19
- return { ...DEFAULT, ...stored };
43
+ return { ...DEFAULT, ...migrateLegacyShell(stored) };
20
44
  }
21
45
  export function writePreferences(workspaceRoot, prefs) {
22
46
  const merged = { ...readPreferences(workspaceRoot), ...prefs };
23
47
  writeJsonFile(getCliStateFile(workspaceRoot, 'preferences.json'), merged);
24
48
  return merged;
25
49
  }
50
+ /**
51
+ * `/yolo on` shorthand: flip both new fields to their "do not interrupt me"
52
+ * setting. We also keep the legacy `autoApproveShell` mirror in sync so any
53
+ * external tooling that still inspects it sees a consistent state during
54
+ * the alias period.
55
+ */
56
+ export function applyYoloOn(workspaceRoot) {
57
+ return writePreferences(workspaceRoot, {
58
+ executionMode: 'fast',
59
+ reviewPolicy: 'proceed',
60
+ autoApproveShell: true,
61
+ });
62
+ }
63
+ /**
64
+ * `/yolo off` shorthand: restore the conservative defaults on both axes.
65
+ */
66
+ export function applyYoloOff(workspaceRoot) {
67
+ return writePreferences(workspaceRoot, {
68
+ executionMode: 'planning',
69
+ reviewPolicy: 'request',
70
+ autoApproveShell: false,
71
+ });
72
+ }
73
+ function normalizeEffort(raw) {
74
+ if (typeof raw !== 'string')
75
+ return undefined;
76
+ const v = raw.trim().toLowerCase();
77
+ return v === 'low' || v === 'medium' || v === 'high' ? v : undefined;
78
+ }
79
+ /**
80
+ * Resolve the active reasoning-depth level using env > preference > default.
81
+ * Matches the precedence pattern set by `resolveTheme` and the
82
+ * `BRAINROUTER_QUIET` override.
83
+ *
84
+ * A garbled `BRAINROUTER_EFFORT` value (e.g. `BRAINROUTER_EFFORT=ludicrous`)
85
+ * is treated as unset so users don't get cryptic crashes — the preference
86
+ * takes over.
87
+ *
88
+ * We read the raw file (not `readPreferences`) so we can distinguish a
89
+ * preference that was explicitly written from the default that
90
+ * `readPreferences` injects via its spread.
91
+ */
92
+ export function resolveEffort(workspaceRoot) {
93
+ const envEffort = normalizeEffort(process.env.BRAINROUTER_EFFORT);
94
+ if (envEffort)
95
+ return { effort: envEffort, source: 'env' };
96
+ if (workspaceRoot) {
97
+ try {
98
+ const stored = readJsonFile(getCliStateFile(workspaceRoot, 'preferences.json'), {});
99
+ const prefEffort = normalizeEffort(stored.effort);
100
+ if (prefEffort)
101
+ return { effort: prefEffort, source: 'preference' };
102
+ }
103
+ catch {
104
+ // Preferences file unreadable — fall through to default.
105
+ }
106
+ }
107
+ return { effort: 'medium', source: 'default' };
108
+ }
@@ -15,15 +15,76 @@ export declare const ARTIFACT: {
15
15
  export declare function slugify(input: string, fallback?: string): string;
16
16
  export declare function getWorkflowsRoot(workspaceRoot: string): string;
17
17
  export declare function getWorkflowDir(workspaceRoot: string, slug: string): string;
18
+ /**
19
+ * Create (or reopen) a workflow folder + bind it as the current
20
+ * workflow.
21
+ *
22
+ * `sessionKey` is threaded through to `setCurrentWorkflow` so that the
23
+ * created workflow is bound to THIS session (not to every other CLI
24
+ * session in the workspace via the workspace-level pointer). Legacy
25
+ * callers without a session context fall through to workspace-level
26
+ * binding only — same back-compat path `setCurrentWorkflow` provides.
27
+ */
18
28
  export declare function createWorkflow(workspaceRoot: string, input: {
19
29
  title: string;
20
30
  kind: WorkflowMeta['kind'];
21
31
  slug?: string;
32
+ sessionKey?: string;
22
33
  }): WorkflowMeta;
23
34
  export declare function updateWorkflowStatus(workspaceRoot: string, slug: string, status: WorkflowMeta['status']): WorkflowMeta | undefined;
24
35
  export declare function listWorkflows(workspaceRoot: string): WorkflowMeta[];
25
- export declare function setCurrentWorkflow(workspaceRoot: string, slug: string): void;
26
- export declare function getCurrentWorkflow(workspaceRoot: string): string | undefined;
36
+ /**
37
+ * Bind a workflow to the current CLI session AND update the workspace-
38
+ * level "last used" hint. When `sessionKey` is omitted (legacy callers,
39
+ * some first-run paths), only the workspace pointer is written — those
40
+ * callers don't have a session context yet, so per-session binding
41
+ * doesn't apply.
42
+ *
43
+ * The workspace pointer is updated unconditionally because we still
44
+ * want a fresh CLI in the same workspace to be ABLE to see "X is the
45
+ * last workflow that was touched here" — for display via
46
+ * `getLastUsedWorkflow`, for the `/workflows` listing's `★` marker, and
47
+ * for the post-9d "do you want to switch to <X>?" UX (the latter not
48
+ * yet shipped, tracked separately).
49
+ */
50
+ export declare function setCurrentWorkflow(workspaceRoot: string, slug: string, sessionKey?: string): void;
51
+ /**
52
+ * Which workflow is bound to THIS CLI session?
53
+ *
54
+ * - With `sessionKey`: reads ONLY the session-level pointer. A fresh
55
+ * CLI session has no session-level pointer → returns `undefined`,
56
+ * even when a workspace-level "last used" hint exists. This is the
57
+ * load-bearing fix: new sessions don't auto-inherit another session's
58
+ * workflow binding (which previously dragged that workflow's goal
59
+ * into the new session via `resolveGoalScope`).
60
+ * - Without `sessionKey` (legacy / display-only callers): falls back
61
+ * to the workspace-level pointer for back-compat.
62
+ *
63
+ * Display surfaces that want to show "the last workflow touched here,
64
+ * regardless of session binding" should call `getLastUsedWorkflow`
65
+ * instead so the distinction stays explicit.
66
+ */
67
+ export declare function getCurrentWorkflow(workspaceRoot: string, sessionKey?: string): string | undefined;
68
+ /**
69
+ * Display-only "last workflow used in this workspace" lookup. Reads
70
+ * the workspace-level pointer unconditionally — never consults the
71
+ * session-level binding. Use when you want to render a hint like
72
+ * "you were last on workflow X" without implying that the current
73
+ * session is bound to it.
74
+ */
75
+ export declare function getLastUsedWorkflow(workspaceRoot: string): string | undefined;
76
+ /**
77
+ * Clear the session-level workflow binding (workspace-level hint
78
+ * preserved). Used by `/new` and `/fork` so a freshly-forked session
79
+ * doesn't drag the parent's binding along.
80
+ */
81
+ export declare function clearSessionWorkflow(workspaceRoot: string, sessionKey: string): void;
82
+ /**
83
+ * True iff a workflow folder with the given slug exists (and carries a
84
+ * meta.json). Used by `/workflow switch <slug>` to surface "no such
85
+ * workflow" without the side-effect mkdir that `getWorkflowDir` performs.
86
+ */
87
+ export declare function workflowExists(workspaceRoot: string, slug: string): boolean;
27
88
  /**
28
89
  * Path (relative to workspace root) the LLM should `write_file` to for a
29
90
  * given artifact. We return a workspace-relative path because that's the