@kinqs/brainrouter-cli 0.3.5 → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +55 -48
- package/bin/cli.cjs +71 -0
- package/dist/agent/agent.d.ts +212 -2
- package/dist/agent/agent.js +428 -38
- package/dist/cli/banner.d.ts +60 -0
- package/dist/cli/banner.js +199 -0
- package/dist/cli/cliPrompt.d.ts +69 -0
- package/dist/cli/cliPrompt.js +287 -0
- package/dist/cli/commands/_helpers.js +6 -6
- package/dist/cli/commands/guard.js +75 -10
- package/dist/cli/commands/mcp.d.ts +17 -0
- package/dist/cli/commands/mcp.js +121 -0
- package/dist/cli/commands/memory.js +2 -2
- package/dist/cli/commands/obs.js +22 -22
- package/dist/cli/commands/session.js +13 -5
- package/dist/cli/commands/ui.js +97 -45
- package/dist/cli/commands/workflow.d.ts +18 -0
- package/dist/cli/commands/workflow.js +314 -43
- package/dist/cli/repl.js +219 -132
- package/dist/cli/spinner.d.ts +34 -0
- package/dist/cli/spinner.js +36 -0
- package/dist/cli/statusline.d.ts +67 -0
- package/dist/cli/statusline.js +204 -0
- package/dist/cli/theme.d.ts +79 -0
- package/dist/cli/theme.js +106 -0
- package/dist/cli/whereView.d.ts +81 -0
- package/dist/cli/whereView.js +245 -0
- package/dist/config/config.d.ts +40 -0
- package/dist/config/config.js +45 -73
- package/dist/index.js +80 -13
- package/dist/memory/briefing.d.ts +10 -0
- package/dist/memory/briefing.js +69 -1
- package/dist/prompt/breadthHint.d.ts +5 -0
- package/dist/prompt/breadthHint.js +44 -0
- package/dist/prompt/systemPrompt.d.ts +34 -0
- package/dist/prompt/systemPrompt.js +124 -108
- package/dist/runtime/dangerousCommand.d.ts +53 -0
- package/dist/runtime/dangerousCommand.js +105 -0
- package/dist/runtime/mcpClient.d.ts +38 -1
- package/dist/runtime/mcpClient.js +90 -2
- package/dist/state/goalStore.d.ts +98 -17
- package/dist/state/goalStore.js +132 -42
- package/dist/state/preferencesStore.d.ts +67 -3
- package/dist/state/preferencesStore.js +84 -1
- package/dist/state/workflowArtifacts.d.ts +63 -2
- package/dist/state/workflowArtifacts.js +120 -8
- package/dist/tests/_helpers.d.ts +31 -0
- package/dist/tests/_helpers.js +91 -0
- package/package.json +5 -4
package/dist/state/goalStore.js
CHANGED
|
@@ -1,8 +1,29 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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(
|
|
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
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
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
|
-
*
|
|
320
|
-
*
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
*
|
|
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
|
-
|
|
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
|
-
'##
|
|
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
|
|
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 ${
|
|
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
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
|
|
26
|
-
|
|
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
|