@kinqs/brainrouter-cli 0.3.4

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 (87) hide show
  1. package/.env.example +109 -0
  2. package/README.md +185 -0
  3. package/dist/agent/agent.d.ts +765 -0
  4. package/dist/agent/agent.js +1977 -0
  5. package/dist/cli/cliPrompt.d.ts +15 -0
  6. package/dist/cli/cliPrompt.js +62 -0
  7. package/dist/cli/commands/_context.d.ts +53 -0
  8. package/dist/cli/commands/_context.js +14 -0
  9. package/dist/cli/commands/_helpers.d.ts +45 -0
  10. package/dist/cli/commands/_helpers.js +140 -0
  11. package/dist/cli/commands/guard.d.ts +6 -0
  12. package/dist/cli/commands/guard.js +292 -0
  13. package/dist/cli/commands/memory.d.ts +12 -0
  14. package/dist/cli/commands/memory.js +263 -0
  15. package/dist/cli/commands/obs.d.ts +6 -0
  16. package/dist/cli/commands/obs.js +208 -0
  17. package/dist/cli/commands/orchestration.d.ts +6 -0
  18. package/dist/cli/commands/orchestration.js +218 -0
  19. package/dist/cli/commands/session.d.ts +6 -0
  20. package/dist/cli/commands/session.js +191 -0
  21. package/dist/cli/commands/ui.d.ts +6 -0
  22. package/dist/cli/commands/ui.js +477 -0
  23. package/dist/cli/commands/workflow.d.ts +6 -0
  24. package/dist/cli/commands/workflow.js +691 -0
  25. package/dist/cli/repl.d.ts +12 -0
  26. package/dist/cli/repl.js +894 -0
  27. package/dist/config/config.d.ts +22 -0
  28. package/dist/config/config.js +105 -0
  29. package/dist/config/workspace.d.ts +7 -0
  30. package/dist/config/workspace.js +62 -0
  31. package/dist/index.d.ts +2 -0
  32. package/dist/index.js +610 -0
  33. package/dist/memory/briefing.d.ts +46 -0
  34. package/dist/memory/briefing.js +152 -0
  35. package/dist/memory/consolidation.d.ts +60 -0
  36. package/dist/memory/consolidation.js +208 -0
  37. package/dist/memory/formatters.d.ts +38 -0
  38. package/dist/memory/formatters.js +102 -0
  39. package/dist/memory/mentions.d.ts +10 -0
  40. package/dist/memory/mentions.js +72 -0
  41. package/dist/orchestration/orchestrator.d.ts +36 -0
  42. package/dist/orchestration/orchestrator.js +71 -0
  43. package/dist/orchestration/roles.d.ts +11 -0
  44. package/dist/orchestration/roles.js +117 -0
  45. package/dist/orchestration/tools.d.ts +244 -0
  46. package/dist/orchestration/tools.js +528 -0
  47. package/dist/prompt/breadthHint.d.ts +48 -0
  48. package/dist/prompt/breadthHint.js +93 -0
  49. package/dist/prompt/compactor.d.ts +31 -0
  50. package/dist/prompt/compactor.js +112 -0
  51. package/dist/prompt/initAgentMd.d.ts +13 -0
  52. package/dist/prompt/initAgentMd.js +194 -0
  53. package/dist/prompt/skillRunner.d.ts +34 -0
  54. package/dist/prompt/skillRunner.js +146 -0
  55. package/dist/prompt/systemPrompt.d.ts +10 -0
  56. package/dist/prompt/systemPrompt.js +171 -0
  57. package/dist/runtime/clipboard.d.ts +17 -0
  58. package/dist/runtime/clipboard.js +52 -0
  59. package/dist/runtime/llmSemaphore.d.ts +30 -0
  60. package/dist/runtime/llmSemaphore.js +67 -0
  61. package/dist/runtime/loopRunner.d.ts +25 -0
  62. package/dist/runtime/loopRunner.js +79 -0
  63. package/dist/runtime/mcpClient.d.ts +156 -0
  64. package/dist/runtime/mcpClient.js +234 -0
  65. package/dist/runtime/mcpUtils.d.ts +36 -0
  66. package/dist/runtime/mcpUtils.js +64 -0
  67. package/dist/runtime/sandbox.d.ts +48 -0
  68. package/dist/runtime/sandbox.js +156 -0
  69. package/dist/runtime/tracing.d.ts +25 -0
  70. package/dist/runtime/tracing.js +91 -0
  71. package/dist/state/cliState.d.ts +59 -0
  72. package/dist/state/cliState.js +311 -0
  73. package/dist/state/goalStore.d.ts +174 -0
  74. package/dist/state/goalStore.js +410 -0
  75. package/dist/state/hookifyStore.d.ts +80 -0
  76. package/dist/state/hookifyStore.js +237 -0
  77. package/dist/state/hooksStore.d.ts +42 -0
  78. package/dist/state/hooksStore.js +71 -0
  79. package/dist/state/preferencesStore.d.ts +41 -0
  80. package/dist/state/preferencesStore.js +25 -0
  81. package/dist/state/sessionStore.d.ts +42 -0
  82. package/dist/state/sessionStore.js +193 -0
  83. package/dist/state/taskStore.d.ts +23 -0
  84. package/dist/state/taskStore.js +80 -0
  85. package/dist/state/workflowArtifacts.d.ts +33 -0
  86. package/dist/state/workflowArtifacts.js +139 -0
  87. package/package.json +71 -0
@@ -0,0 +1,59 @@
1
+ export declare function isPathInside(parent: string, candidate: string): boolean;
2
+ /**
3
+ * User-global brainrouter home. Defaults to `~/.brainrouter`. Override with
4
+ * the `BRAINROUTER_HOME` env var — tests set this to keep their state out of
5
+ * the real user home.
6
+ */
7
+ export declare function getBrainrouterHome(): string;
8
+ /**
9
+ * Per-workspace state root inside the global home. Encodes the absolute
10
+ * workspace path with a readable prefix + short hash so two workspaces with
11
+ * the same basename never collide.
12
+ *
13
+ * ~/.brainrouter/workspaces/BrainRouter-3a7f9c12/
14
+ */
15
+ export declare function getWorkspaceStateRoot(workspaceRoot: string): string;
16
+ /**
17
+ * CLI state directory for a workspace. Defaults to
18
+ * ~/.brainrouter/workspaces/<encoded>/cli
19
+ * Older builds wrote to <workspaceRoot>/.brainrouter/cli — `getWorkspaceStateRoot`
20
+ * handles the one-time migration so transcripts/goals/plans/hooks/memories
21
+ * follow the user instead of cluttering the project.
22
+ */
23
+ export declare function getCliStateDir(workspaceRoot: string): string;
24
+ /**
25
+ * Workspace-local state directory, e.g. `<workspace>/.brainrouter/`. Reserved
26
+ * for artifacts that are *meant* to be committed alongside the code — durable
27
+ * workflow specs, task breakdowns, walkthrough notes. Everything else
28
+ * (sessions, hooks, hookify rules, memories, preferences, transcripts) lives
29
+ * under `getWorkspaceStateRoot` in the user-global home so the project tree
30
+ * stays clean.
31
+ */
32
+ export declare function getWorkspaceLocalDir(workspaceRoot: string): string;
33
+ export declare function getCliStateFile(workspaceRoot: string, fileName: string): string;
34
+ /**
35
+ * Encode a sessionKey to a safe directory name. Base64url keeps it short and
36
+ * round-trippable so listSessions can recover the original key. The 180-char
37
+ * cap matches the previous transcript filename limit.
38
+ */
39
+ export declare function encodeSessionKey(sessionKey: string): string;
40
+ export declare function decodeSessionKey(encoded: string): string;
41
+ /**
42
+ * Per-session state bucket at `<workspace>/.brainrouter/cli/sessions/<encoded>/`.
43
+ * Goal, plan, transcript, and any future per-session artifacts live together
44
+ * here so users can browse one folder per chat session instead of hunting
45
+ * across siblings.
46
+ */
47
+ export declare function getSessionStateDir(workspaceRoot: string, sessionKey: string): string;
48
+ export declare function getSessionStateFile(workspaceRoot: string, sessionKey: string, fileName: string): string;
49
+ /**
50
+ * List every persisted session bucket: returns `{ sessionKey, dir, modifiedAt }`
51
+ * newest first. Used by `/sessions` to render a picker.
52
+ */
53
+ export declare function listSessionDirs(workspaceRoot: string): Array<{
54
+ sessionKey: string;
55
+ dir: string;
56
+ modifiedAt: string;
57
+ }>;
58
+ export declare function readJsonFile<T>(filePath: string, fallback: T): T;
59
+ export declare function writeJsonFile(filePath: string, value: unknown): void;
@@ -0,0 +1,311 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+ export function isPathInside(parent, candidate) {
6
+ const relative = path.relative(parent, candidate);
7
+ return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative));
8
+ }
9
+ /**
10
+ * User-global brainrouter home. Defaults to `~/.brainrouter`. Override with
11
+ * the `BRAINROUTER_HOME` env var — tests set this to keep their state out of
12
+ * the real user home.
13
+ */
14
+ export function getBrainrouterHome() {
15
+ const override = process.env.BRAINROUTER_HOME?.trim();
16
+ const target = override ?? path.join(os.homedir(), '.brainrouter');
17
+ fs.mkdirSync(target, { recursive: true });
18
+ // Resolve symlinks (eg. macOS /tmp → /private/tmp) so callers comparing
19
+ // against `realpathSync(workspaceRoot)` see the same root.
20
+ try {
21
+ return fs.realpathSync(target);
22
+ }
23
+ catch {
24
+ return target;
25
+ }
26
+ }
27
+ /**
28
+ * Per-workspace state root inside the global home. Encodes the absolute
29
+ * workspace path with a readable prefix + short hash so two workspaces with
30
+ * the same basename never collide.
31
+ *
32
+ * ~/.brainrouter/workspaces/BrainRouter-3a7f9c12/
33
+ */
34
+ export function getWorkspaceStateRoot(workspaceRoot) {
35
+ const abs = fs.realpathSync(workspaceRoot);
36
+ const home = getBrainrouterHome();
37
+ const encoded = encodeWorkspacePath(abs);
38
+ const dir = path.join(home, 'workspaces', encoded);
39
+ fs.mkdirSync(dir, { recursive: true });
40
+ // Migration check fires here (idempotent) so hooks/ and memories/ get
41
+ // moved over even if the caller never goes through getCliStateDir.
42
+ migrateLegacyWorkspaceState(workspaceRoot, dir);
43
+ return dir;
44
+ }
45
+ function encodeWorkspacePath(absWorkspaceRoot) {
46
+ const base = path.basename(absWorkspaceRoot).replace(/[^A-Za-z0-9._-]+/g, '_') || 'root';
47
+ const hash = crypto.createHash('sha1').update(absWorkspaceRoot).digest('hex').slice(0, 8);
48
+ return `${base.slice(0, 60)}-${hash}`;
49
+ }
50
+ /**
51
+ * CLI state directory for a workspace. Defaults to
52
+ * ~/.brainrouter/workspaces/<encoded>/cli
53
+ * Older builds wrote to <workspaceRoot>/.brainrouter/cli — `getWorkspaceStateRoot`
54
+ * handles the one-time migration so transcripts/goals/plans/hooks/memories
55
+ * follow the user instead of cluttering the project.
56
+ */
57
+ export function getCliStateDir(workspaceRoot) {
58
+ const wsRoot = getWorkspaceStateRoot(workspaceRoot);
59
+ const stateDir = path.join(wsRoot, 'cli');
60
+ if (!isPathInside(wsRoot, stateDir)) {
61
+ throw new Error('CLI state directory escapes workspace state root.');
62
+ }
63
+ fs.mkdirSync(stateDir, { recursive: true });
64
+ return stateDir;
65
+ }
66
+ let migrationAttempted = new Set();
67
+ function migrateLegacyWorkspaceState(workspaceRoot, newRoot) {
68
+ if (migrationAttempted.has(workspaceRoot))
69
+ return;
70
+ migrationAttempted.add(workspaceRoot);
71
+ try {
72
+ const abs = fs.realpathSync(workspaceRoot);
73
+ const legacyRoot = path.join(abs, '.brainrouter');
74
+ if (!fs.existsSync(legacyRoot))
75
+ return;
76
+ // If the legacy tree IS the new tree (because BRAINROUTER_HOME points at the
77
+ // workspace), do nothing.
78
+ if (path.resolve(legacyRoot) === path.resolve(newRoot))
79
+ return;
80
+ // The workspace-local "workflows/" tree is intentionally part of the
81
+ // workspace and must NOT be migrated away — that's the documented
82
+ // place to keep spec.md / tasks.md / walkthrough.md so the team can
83
+ // commit them. We only rescue cli/, hooks/, and memories/.
84
+ const markerFile = path.join(newRoot, '.migrated-from-workspace');
85
+ if (!fs.existsSync(markerFile)) {
86
+ for (const sub of ['cli', 'hooks', 'memories']) {
87
+ const src = path.join(legacyRoot, sub);
88
+ if (fs.existsSync(src)) {
89
+ copyDirRecursive(src, path.join(newRoot, sub));
90
+ }
91
+ }
92
+ fs.writeFileSync(markerFile, `Migrated from ${legacyRoot} at ${new Date().toISOString()}\n`, 'utf8');
93
+ process.stderr.write(`brainrouter: migrated legacy state from ${legacyRoot} to ${newRoot}\n`);
94
+ }
95
+ // Now neutralize the legacy directory so the agent's list_dir / read_file
96
+ // don't see stale state in the workspace tree. Anything that ISN'T a
97
+ // workflows/ folder is moved to .brainrouter.migrated/. If only
98
+ // workflows/ remains, the workspace-local .brainrouter/ stays as the
99
+ // canonical home for committable artifacts.
100
+ const archiveRoot = path.join(abs, '.brainrouter.migrated');
101
+ const entries = fs.readdirSync(legacyRoot, { withFileTypes: true });
102
+ let archivedAny = false;
103
+ for (const entry of entries) {
104
+ if (entry.name === 'workflows')
105
+ continue;
106
+ const from = path.join(legacyRoot, entry.name);
107
+ const to = path.join(archiveRoot, entry.name);
108
+ try {
109
+ fs.mkdirSync(archiveRoot, { recursive: true });
110
+ if (!fs.existsSync(to)) {
111
+ fs.renameSync(from, to);
112
+ archivedAny = true;
113
+ }
114
+ else {
115
+ // Already archived from a prior run — just remove the stale copy.
116
+ fs.rmSync(from, { recursive: true, force: true });
117
+ }
118
+ }
119
+ catch {
120
+ // best-effort: skip files we can't rename
121
+ }
122
+ }
123
+ if (archivedAny) {
124
+ process.stderr.write(`brainrouter: archived legacy in-workspace state to ${archiveRoot} (safe to delete after verifying)\n`);
125
+ }
126
+ // If the workspace-local `.brainrouter/` is now completely empty (no
127
+ // `workflows/` to preserve), remove the empty shell so the user
128
+ // doesn't see a stray folder reappear every session. We only delete
129
+ // it when empty — never when it still has committable workflow
130
+ // artifacts inside.
131
+ try {
132
+ const remaining = fs.readdirSync(legacyRoot);
133
+ if (remaining.length === 0) {
134
+ fs.rmdirSync(legacyRoot);
135
+ }
136
+ }
137
+ catch {
138
+ // best-effort cleanup
139
+ }
140
+ }
141
+ catch (err) {
142
+ // Migration is best-effort. If it fails (permissions etc.), the CLI still
143
+ // runs against the new location and the user can copy manually.
144
+ process.stderr.write(`brainrouter: legacy-state migration skipped (${err.message ?? err})\n`);
145
+ }
146
+ }
147
+ /**
148
+ * Workspace-local state directory, e.g. `<workspace>/.brainrouter/`. Reserved
149
+ * for artifacts that are *meant* to be committed alongside the code — durable
150
+ * workflow specs, task breakdowns, walkthrough notes. Everything else
151
+ * (sessions, hooks, hookify rules, memories, preferences, transcripts) lives
152
+ * under `getWorkspaceStateRoot` in the user-global home so the project tree
153
+ * stays clean.
154
+ */
155
+ export function getWorkspaceLocalDir(workspaceRoot) {
156
+ const root = fs.realpathSync(workspaceRoot);
157
+ const dir = path.join(root, '.brainrouter');
158
+ if (!isPathInside(root, dir)) {
159
+ throw new Error('Workspace-local brainrouter directory escapes workspace root.');
160
+ }
161
+ fs.mkdirSync(dir, { recursive: true });
162
+ return dir;
163
+ }
164
+ function copyDirRecursive(src, dst) {
165
+ if (!fs.existsSync(src))
166
+ return;
167
+ fs.mkdirSync(dst, { recursive: true });
168
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
169
+ const srcPath = path.join(src, entry.name);
170
+ const dstPath = path.join(dst, entry.name);
171
+ if (entry.isDirectory()) {
172
+ copyDirRecursive(srcPath, dstPath);
173
+ }
174
+ else if (entry.isFile()) {
175
+ if (fs.existsSync(dstPath))
176
+ continue; // don't clobber existing state
177
+ fs.copyFileSync(srcPath, dstPath);
178
+ }
179
+ }
180
+ }
181
+ export function getCliStateFile(workspaceRoot, fileName) {
182
+ if (!/^[a-zA-Z0-9._-]+$/.test(fileName)) {
183
+ throw new Error(`Invalid CLI state file name: ${fileName}`);
184
+ }
185
+ const stateDir = getCliStateDir(workspaceRoot);
186
+ const filePath = path.join(stateDir, fileName);
187
+ if (!isPathInside(stateDir, filePath)) {
188
+ throw new Error(`CLI state file escapes state directory: ${fileName}`);
189
+ }
190
+ return filePath;
191
+ }
192
+ /**
193
+ * Encode a sessionKey to a safe directory name. Base64url keeps it short and
194
+ * round-trippable so listSessions can recover the original key. The 180-char
195
+ * cap matches the previous transcript filename limit.
196
+ */
197
+ export function encodeSessionKey(sessionKey) {
198
+ return Buffer.from(sessionKey, 'utf8').toString('base64url').slice(0, 180);
199
+ }
200
+ export function decodeSessionKey(encoded) {
201
+ try {
202
+ return Buffer.from(encoded, 'base64url').toString('utf8');
203
+ }
204
+ catch {
205
+ return encoded;
206
+ }
207
+ }
208
+ /**
209
+ * Per-session state bucket at `<workspace>/.brainrouter/cli/sessions/<encoded>/`.
210
+ * Goal, plan, transcript, and any future per-session artifacts live together
211
+ * here so users can browse one folder per chat session instead of hunting
212
+ * across siblings.
213
+ */
214
+ export function getSessionStateDir(workspaceRoot, sessionKey) {
215
+ const stateDir = getCliStateDir(workspaceRoot);
216
+ const sessionsDir = path.join(stateDir, 'sessions');
217
+ fs.mkdirSync(sessionsDir, { recursive: true });
218
+ const sessionDir = path.join(sessionsDir, encodeSessionKey(sessionKey));
219
+ if (!isPathInside(sessionsDir, sessionDir)) {
220
+ throw new Error('Session state directory escapes CLI state dir.');
221
+ }
222
+ fs.mkdirSync(sessionDir, { recursive: true });
223
+ return sessionDir;
224
+ }
225
+ export function getSessionStateFile(workspaceRoot, sessionKey, fileName) {
226
+ if (!/^[a-zA-Z0-9._-]+$/.test(fileName)) {
227
+ throw new Error(`Invalid session state file name: ${fileName}`);
228
+ }
229
+ const sessionDir = getSessionStateDir(workspaceRoot, sessionKey);
230
+ const filePath = path.join(sessionDir, fileName);
231
+ if (!isPathInside(sessionDir, filePath)) {
232
+ throw new Error('Session state file escapes session directory.');
233
+ }
234
+ return filePath;
235
+ }
236
+ /**
237
+ * List every persisted session bucket: returns `{ sessionKey, dir, modifiedAt }`
238
+ * newest first. Used by `/sessions` to render a picker.
239
+ */
240
+ export function listSessionDirs(workspaceRoot) {
241
+ const sessionsDir = path.join(getCliStateDir(workspaceRoot), 'sessions');
242
+ if (!fs.existsSync(sessionsDir))
243
+ return [];
244
+ const entries = fs.readdirSync(sessionsDir, { withFileTypes: true });
245
+ const out = [];
246
+ for (const entry of entries) {
247
+ if (!entry.isDirectory())
248
+ continue;
249
+ const dir = path.join(sessionsDir, entry.name);
250
+ let mtime = new Date(0);
251
+ try {
252
+ const stat = fs.statSync(dir);
253
+ mtime = stat.mtime;
254
+ // The transcript drives "last activity" better than the dir mtime.
255
+ const transcript = path.join(dir, 'transcript.jsonl');
256
+ if (fs.existsSync(transcript)) {
257
+ mtime = fs.statSync(transcript).mtime;
258
+ }
259
+ }
260
+ catch { /* unreadable */ }
261
+ out.push({
262
+ sessionKey: decodeSessionKey(entry.name),
263
+ dir,
264
+ modifiedAt: mtime.toISOString(),
265
+ });
266
+ }
267
+ return out.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt));
268
+ }
269
+ export function readJsonFile(filePath, fallback) {
270
+ if (!fs.existsSync(filePath)) {
271
+ return fallback;
272
+ }
273
+ try {
274
+ const raw = fs.readFileSync(filePath, 'utf8');
275
+ if (!raw.trim()) {
276
+ return fallback;
277
+ }
278
+ return JSON.parse(raw);
279
+ }
280
+ catch (err) {
281
+ // Falling back instead of throwing means a single corrupted state file
282
+ // (truncated JSON from Ctrl-C mid-write, partial migration, hand-edit)
283
+ // can't prevent the REPL from booting. Quarantine the bad file so the
284
+ // user can inspect it, then return the caller's fallback value. The
285
+ // alternative — propagating — meant a half-byte goal.json bricked the
286
+ // entire CLI because createSystemMessage reads it on every turn start.
287
+ try {
288
+ const quarantine = `${filePath}.corrupt-${Date.now()}`;
289
+ fs.renameSync(filePath, quarantine);
290
+ console.warn(`[brainrouter] could not parse ${filePath} (${err.message}); ` +
291
+ `moved to ${quarantine} and falling back to default.`);
292
+ }
293
+ catch {
294
+ // Couldn't quarantine — just warn and continue with the fallback.
295
+ console.warn(`[brainrouter] could not parse ${filePath}: ${err.message}; using default.`);
296
+ }
297
+ return fallback;
298
+ }
299
+ }
300
+ export function writeJsonFile(filePath, value) {
301
+ const dir = path.dirname(filePath);
302
+ fs.mkdirSync(dir, { recursive: true });
303
+ // Temp suffix needs to be unique even when two writers run in the same
304
+ // millisecond (e.g. goal + plan + prefs writes during a single
305
+ // auto-continuation tick). Date.now() is millisecond-resolution so the old
306
+ // form `${pid}.${ms}.tmp` collides under load; add a 6-byte random nonce.
307
+ const nonce = crypto.randomBytes(6).toString('hex');
308
+ const tmpPath = `${filePath}.${process.pid}.${Date.now()}.${nonce}.tmp`;
309
+ fs.writeFileSync(tmpPath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
310
+ fs.renameSync(tmpPath, filePath);
311
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Persistent goal / continuation contract for the agent. A goal is not just
3
+ * a sticky string — it carries lifecycle status, a budget that bounds how
4
+ * far auto-continuation will go, and timestamps so resumed sessions know
5
+ * exactly where they left off.
6
+ *
7
+ * - text: the outcome that should be true when done
8
+ * - status: active | paused | complete | blocked | usage_limited
9
+ * - budget: iteration AND optional token caps; auto-continuation
10
+ * halts (and the goal moves to `usage_limited`) when
11
+ * either is exhausted
12
+ * - timestamps: startedAt, updatedAt, completedAt
13
+ * - blockedReason: filled when the agent calls goal_blocked
14
+ *
15
+ * Status semantics:
16
+ * - active — continuation loop is allowed to fire next turn
17
+ * - paused — user-initiated suspend; resume re-arms the loop
18
+ * - complete — outcome satisfied; loop stops permanently
19
+ * - blocked — agent reported a hard impasse (missing data, external
20
+ * dep); loop stops until user intervenes
21
+ * - usage_limited — budget (iterations or tokens) exhausted; resumable
22
+ * after raising the budget. NEW compared to the old
23
+ * paused/blocked-only model: lets the UI distinguish
24
+ * "you ran out of room" from "user paused" from
25
+ * "agent gave up."
26
+ *
27
+ * Storage (per-session bucket):
28
+ * ~/.brainrouter/workspaces/<encoded>/cli/sessions/<encodedKey>/goal.json
29
+ *
30
+ * Legacy fallback paths exist for sessions created before per-session
31
+ * goal isolation was added. normalize() fills missing fields with defaults
32
+ * so resumed sessions don't crash on first read.
33
+ */
34
+ export type GoalStatus = 'active' | 'paused' | 'complete' | 'blocked' | 'usage_limited';
35
+ /** A pausing status is one where continuation is halted but resumable. */
36
+ export declare const PAUSING_STATUSES: readonly GoalStatus[];
37
+ export interface GoalBudget {
38
+ maxIterations: number;
39
+ iterationsUsed: number;
40
+ /**
41
+ * Optional cumulative-token cap. When set, each turn's prompt+completion
42
+ * tokens accumulate into `tokensUsed`; once `tokensUsed >= maxTokens` the
43
+ * goal moves to `usage_limited` instead of just consuming another
44
+ * iteration. Lets users protect a fixed dollar budget without having to
45
+ * estimate the iteration count by hand.
46
+ */
47
+ maxTokens?: number;
48
+ tokensUsed?: number;
49
+ }
50
+ export interface Goal {
51
+ text: string;
52
+ setAt: string;
53
+ status: GoalStatus;
54
+ budget: GoalBudget;
55
+ startedAt: string;
56
+ updatedAt: string;
57
+ completedAt?: string;
58
+ blockedReason?: string;
59
+ }
60
+ export declare const DEFAULT_GOAL_BUDGET = 10;
61
+ /**
62
+ * Hard cap on the goal text length. A goal is supposed to be a 1–3 sentence
63
+ * outcome statement; multi-thousand-character pastes (e.g. full chat logs)
64
+ * derail every subsequent turn because the goal block is re-injected into
65
+ * the system prompt on EVERY iteration.
66
+ */
67
+ export declare const GOAL_TEXT_MAX_CHARS = 4000;
68
+ export declare class GoalTooLongError extends Error {
69
+ readonly length: number;
70
+ constructor(length: number);
71
+ }
72
+ /**
73
+ * Thrown when `setGoal` would overwrite a non-complete existing goal and
74
+ * the caller didn't pass `force: true`. The REPL catches this and prompts
75
+ * the user before replacing — interrupting in-flight work without
76
+ * confirmation is one of the easiest ways to lose progress.
77
+ *
78
+ * A `complete` goal does NOT raise this — replacing a finished goal is
79
+ * just starting fresh, no work is at risk.
80
+ */
81
+ export declare class GoalConflictError extends Error {
82
+ readonly existing: Goal;
83
+ constructor(existing: Goal);
84
+ }
85
+ export declare function readGoal(workspaceRoot: string, sessionKey?: string): Goal | null;
86
+ /**
87
+ * Set a new active goal. Refuses to overwrite an in-progress goal (active,
88
+ * paused, blocked, or usage_limited) unless `force: true` is passed. The
89
+ * REPL catches the resulting GoalConflictError and prompts the user before
90
+ * replacing. Replacing a `complete` goal is allowed silently — at that
91
+ * point the prior goal isn't doing any work and a new one is just starting
92
+ * fresh.
93
+ */
94
+ export declare function setGoal(workspaceRoot: string, text: string, sessionKey?: string, options?: {
95
+ maxIterations?: number;
96
+ maxTokens?: number;
97
+ force?: boolean;
98
+ }): Goal;
99
+ export declare function clearGoal(workspaceRoot: string, sessionKey?: string): void;
100
+ export declare function pauseGoal(workspaceRoot: string, sessionKey?: string): Goal | null;
101
+ export declare function resumeGoal(workspaceRoot: string, sessionKey?: string): Goal | null;
102
+ export declare function completeGoal(workspaceRoot: string, sessionKey?: string, proof?: string): Goal | null;
103
+ export declare function blockGoal(workspaceRoot: string, sessionKey: string | undefined, reason: string): Goal | null;
104
+ /**
105
+ * Mark the goal as `usage_limited` — distinct from paused (user-initiated)
106
+ * and blocked (agent gave up). Used when the iteration or token budget
107
+ * runs out. The user can resume after raising the budget; the loop won't
108
+ * fire another turn on its own until they do.
109
+ */
110
+ export declare function usageLimitGoal(workspaceRoot: string, sessionKey: string | undefined, reason: string): Goal | null;
111
+ export declare function setGoalBudget(workspaceRoot: string, sessionKey: string | undefined, maxIterations: number): Goal | null;
112
+ /**
113
+ * Set or clear the optional token budget. Pass `0` (or any negative) to
114
+ * clear; positive integers set the cap. Resets tokensUsed to 0 when first
115
+ * enabling so the goal doesn't immediately appear exhausted.
116
+ */
117
+ export declare function setGoalTokenBudget(workspaceRoot: string, sessionKey: string | undefined, maxTokens: number): Goal | null;
118
+ export declare function tickGoalIteration(workspaceRoot: string, sessionKey?: string): Goal | null;
119
+ /**
120
+ * Add `delta` tokens to the goal's running tally. No-op if a goal has no
121
+ * token budget set. Returns the updated Goal so callers can decide whether
122
+ * to transition to `usage_limited` afterwards.
123
+ */
124
+ export declare function addGoalTokens(workspaceRoot: string, sessionKey: string | undefined, delta: number): Goal | null;
125
+ /**
126
+ * Unified update entrypoint. Lets callers mutate text/status/budget in a
127
+ * single call instead of stringing pause→budget→resume together. Used by
128
+ * the `/goal edit` REPL subcommand.
129
+ */
130
+ export declare function editGoal(workspaceRoot: string, sessionKey: string | undefined, patch: {
131
+ text?: string;
132
+ status?: GoalStatus;
133
+ maxIterations?: number;
134
+ maxTokens?: number;
135
+ }): Goal | null;
136
+ /**
137
+ * True iff scheduling ONE MORE iteration would still fit inside BOTH the
138
+ * iteration cap and (if set) the token cap.
139
+ *
140
+ * The continuation loop ticks AFTER deciding to continue (so `iterationsUsed`
141
+ * lags by one until the tick runs). To stop after exactly `maxIterations`
142
+ * runs total, the predicate must ask "is (used+1) still within the cap?",
143
+ * not "is (used) still under the cap?". The old form gave you N+1 runs.
144
+ *
145
+ * Token budget is a hard "currently used vs cap" check — we can't know the
146
+ * next turn's token cost ahead of time, so we just refuse to schedule when
147
+ * we're already at or past the cap.
148
+ */
149
+ export declare function goalHasBudgetLeft(goal: Goal): boolean;
150
+ /**
151
+ * True iff this is the FINAL turn within the budget — i.e. the iteration
152
+ * tick is about to land but one more after it would exceed the cap. The
153
+ * continuation loop uses this to inject a "wrap up gracefully" steering
154
+ * message so the model lands soft instead of being interrupted mid-thought.
155
+ *
156
+ * Specifically: after this turn's tick, iterationsUsed will equal
157
+ * maxIterations - 1, so `goalHasBudgetLeft` will return false on the next
158
+ * decision. We detect that ahead of time by checking before the tick.
159
+ */
160
+ export declare function goalIsOnFinalBudgetTurn(goal: Goal): boolean;
161
+ /**
162
+ * Wrap-up steering message injected on the final-budget turn. The agent
163
+ * loop pushes this into the chat history as a system message so the model
164
+ * pivots from "continue investigating" to "consolidate and report." Plain
165
+ * directive, no role-play.
166
+ *
167
+ * The message specifically reports WHICH cap is tight (iterations, tokens,
168
+ * or both) so the model doesn't get told "one turn left" when it actually
169
+ * has many iterations remaining but is near the token cap, or vice versa.
170
+ * Earlier versions hardcoded the iteration framing even when only the
171
+ * token heuristic tripped, which misled the model on token-budgeted runs.
172
+ */
173
+ export declare function buildBudgetSteeringMessage(goal: Goal): string;
174
+ export declare function formatGoalBlock(goal: Goal): string;