@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,410 @@
1
+ import fs from 'node:fs';
2
+ import { getCliStateFile, getSessionStateFile, readJsonFile, writeJsonFile } from './cliState.js';
3
+ /** A pausing status is one where continuation is halted but resumable. */
4
+ export const PAUSING_STATUSES = ['paused', 'blocked', 'usage_limited'];
5
+ export const DEFAULT_GOAL_BUDGET = 10;
6
+ /**
7
+ * Hard cap on the goal text length. A goal is supposed to be a 1–3 sentence
8
+ * outcome statement; multi-thousand-character pastes (e.g. full chat logs)
9
+ * derail every subsequent turn because the goal block is re-injected into
10
+ * the system prompt on EVERY iteration.
11
+ */
12
+ export const GOAL_TEXT_MAX_CHARS = 4000;
13
+ export class GoalTooLongError extends Error {
14
+ length;
15
+ constructor(length) {
16
+ super(`Goal condition is limited to ${GOAL_TEXT_MAX_CHARS} characters (got ${length}). ` +
17
+ `Trim it to a 1–3 sentence outcome statement.`);
18
+ this.length = length;
19
+ this.name = 'GoalTooLongError';
20
+ }
21
+ }
22
+ /**
23
+ * Thrown when `setGoal` would overwrite a non-complete existing goal and
24
+ * the caller didn't pass `force: true`. The REPL catches this and prompts
25
+ * the user before replacing — interrupting in-flight work without
26
+ * confirmation is one of the easiest ways to lose progress.
27
+ *
28
+ * A `complete` goal does NOT raise this — replacing a finished goal is
29
+ * just starting fresh, no work is at risk.
30
+ */
31
+ export class GoalConflictError extends Error {
32
+ existing;
33
+ constructor(existing) {
34
+ // Use status-aware wording. The previous "already active" phrasing was
35
+ // misleading when the existing goal was paused, blocked, or
36
+ // usage_limited — the REPL surfaces this message verbatim and users
37
+ // would see "already active" for a goal they explicitly paused. Now
38
+ // the message reflects the actual current state.
39
+ const statusLabel = existing.status.replace('_', ' ');
40
+ const inProgressClause = existing.status === 'active'
41
+ ? 'is in progress'
42
+ : `exists with status: ${statusLabel}`;
43
+ super(`A goal already ${inProgressClause}. ` +
44
+ `Pass force=true to replace it (REPL will prompt for confirmation first).`);
45
+ this.existing = existing;
46
+ this.name = 'GoalConflictError';
47
+ }
48
+ }
49
+ function normalize(raw) {
50
+ if (!raw || !raw.text || raw.text === '')
51
+ return null;
52
+ const setAt = raw.setAt ?? new Date().toISOString();
53
+ const budget = raw.budget ?? { maxIterations: DEFAULT_GOAL_BUDGET, iterationsUsed: 0 };
54
+ // Backfill tokensUsed for older goals so consumers can rely on the field
55
+ // being a number when maxTokens is set later.
56
+ if (budget.maxTokens && typeof budget.tokensUsed !== 'number') {
57
+ budget.tokensUsed = 0;
58
+ }
59
+ return {
60
+ text: raw.text,
61
+ setAt,
62
+ status: raw.status ?? 'active',
63
+ budget,
64
+ startedAt: raw.startedAt ?? setAt,
65
+ updatedAt: raw.updatedAt ?? setAt,
66
+ completedAt: raw.completedAt,
67
+ blockedReason: raw.blockedReason,
68
+ };
69
+ }
70
+ function resolveGoalFile(workspaceRoot, sessionKey) {
71
+ if (sessionKey) {
72
+ const sessionPath = getSessionStateFile(workspaceRoot, sessionKey, 'goal.json');
73
+ if (fs.existsSync(sessionPath))
74
+ return sessionPath;
75
+ }
76
+ return getCliStateFile(workspaceRoot, 'goal.json');
77
+ }
78
+ 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
+ }
85
+ const legacyPath = getCliStateFile(workspaceRoot, 'goal.json');
86
+ if (fs.existsSync(legacyPath)) {
87
+ return normalize(readJsonFile(legacyPath, null));
88
+ }
89
+ return null;
90
+ }
91
+ /**
92
+ * Set a new active goal. Refuses to overwrite an in-progress goal (active,
93
+ * paused, blocked, or usage_limited) unless `force: true` is passed. The
94
+ * REPL catches the resulting GoalConflictError and prompts the user before
95
+ * replacing. Replacing a `complete` goal is allowed silently — at that
96
+ * point the prior goal isn't doing any work and a new one is just starting
97
+ * fresh.
98
+ */
99
+ export function setGoal(workspaceRoot, text, sessionKey, options = {}) {
100
+ const trimmed = text.trim();
101
+ if (trimmed.length > GOAL_TEXT_MAX_CHARS) {
102
+ throw new GoalTooLongError(trimmed.length);
103
+ }
104
+ // Conflict detection: don't silently nuke an in-progress goal. The
105
+ // `complete` status is exempt — the prior work is done, replacing it is
106
+ // just starting fresh. The REPL layer handles the prompt and re-calls
107
+ // with `force: true` once the user confirms.
108
+ if (!options.force) {
109
+ const existing = readGoal(workspaceRoot, sessionKey);
110
+ if (existing && existing.status !== 'complete') {
111
+ throw new GoalConflictError(existing);
112
+ }
113
+ }
114
+ const now = new Date().toISOString();
115
+ const goal = {
116
+ text: trimmed,
117
+ setAt: now,
118
+ status: 'active',
119
+ budget: {
120
+ maxIterations: options.maxIterations ?? DEFAULT_GOAL_BUDGET,
121
+ iterationsUsed: 0,
122
+ ...(options.maxTokens ? { maxTokens: options.maxTokens, tokensUsed: 0 } : {}),
123
+ },
124
+ startedAt: now,
125
+ updatedAt: now,
126
+ };
127
+ const filePath = sessionKey
128
+ ? getSessionStateFile(workspaceRoot, sessionKey, 'goal.json')
129
+ : getCliStateFile(workspaceRoot, 'goal.json');
130
+ writeJsonFile(filePath, goal);
131
+ return goal;
132
+ }
133
+ 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);
140
+ }
141
+ }
142
+ function patchGoal(workspaceRoot, sessionKey, patch) {
143
+ const current = readGoal(workspaceRoot, sessionKey);
144
+ if (!current)
145
+ return null;
146
+ const next = {
147
+ ...current,
148
+ ...patch,
149
+ updatedAt: new Date().toISOString(),
150
+ };
151
+ writeJsonFile(resolveGoalFile(workspaceRoot, sessionKey), next);
152
+ return next;
153
+ }
154
+ export function pauseGoal(workspaceRoot, sessionKey) {
155
+ return patchGoal(workspaceRoot, sessionKey, { status: 'paused' });
156
+ }
157
+ export function resumeGoal(workspaceRoot, sessionKey) {
158
+ return patchGoal(workspaceRoot, sessionKey, { status: 'active' });
159
+ }
160
+ export function completeGoal(workspaceRoot, sessionKey, proof) {
161
+ return patchGoal(workspaceRoot, sessionKey, {
162
+ status: 'complete',
163
+ completedAt: new Date().toISOString(),
164
+ blockedReason: proof,
165
+ });
166
+ }
167
+ export function blockGoal(workspaceRoot, sessionKey, reason) {
168
+ return patchGoal(workspaceRoot, sessionKey, { status: 'blocked', blockedReason: reason });
169
+ }
170
+ /**
171
+ * Mark the goal as `usage_limited` — distinct from paused (user-initiated)
172
+ * and blocked (agent gave up). Used when the iteration or token budget
173
+ * runs out. The user can resume after raising the budget; the loop won't
174
+ * fire another turn on its own until they do.
175
+ */
176
+ export function usageLimitGoal(workspaceRoot, sessionKey, reason) {
177
+ return patchGoal(workspaceRoot, sessionKey, { status: 'usage_limited', blockedReason: reason });
178
+ }
179
+ export function setGoalBudget(workspaceRoot, sessionKey, maxIterations) {
180
+ const current = readGoal(workspaceRoot, sessionKey);
181
+ if (!current)
182
+ return null;
183
+ return patchGoal(workspaceRoot, sessionKey, {
184
+ budget: { ...current.budget, maxIterations: Math.max(1, maxIterations) },
185
+ });
186
+ }
187
+ /**
188
+ * Set or clear the optional token budget. Pass `0` (or any negative) to
189
+ * clear; positive integers set the cap. Resets tokensUsed to 0 when first
190
+ * enabling so the goal doesn't immediately appear exhausted.
191
+ */
192
+ export function setGoalTokenBudget(workspaceRoot, sessionKey, maxTokens) {
193
+ const current = readGoal(workspaceRoot, sessionKey);
194
+ if (!current)
195
+ return null;
196
+ if (maxTokens <= 0) {
197
+ const { maxTokens: _drop, tokensUsed: _drop2, ...rest } = current.budget;
198
+ return patchGoal(workspaceRoot, sessionKey, { budget: rest });
199
+ }
200
+ return patchGoal(workspaceRoot, sessionKey, {
201
+ budget: { ...current.budget, maxTokens, tokensUsed: current.budget.tokensUsed ?? 0 },
202
+ });
203
+ }
204
+ export function tickGoalIteration(workspaceRoot, sessionKey) {
205
+ const current = readGoal(workspaceRoot, sessionKey);
206
+ if (!current)
207
+ return null;
208
+ return patchGoal(workspaceRoot, sessionKey, {
209
+ budget: { ...current.budget, iterationsUsed: current.budget.iterationsUsed + 1 },
210
+ });
211
+ }
212
+ /**
213
+ * Add `delta` tokens to the goal's running tally. No-op if a goal has no
214
+ * token budget set. Returns the updated Goal so callers can decide whether
215
+ * to transition to `usage_limited` afterwards.
216
+ */
217
+ export function addGoalTokens(workspaceRoot, sessionKey, delta) {
218
+ if (!Number.isFinite(delta) || delta <= 0)
219
+ return readGoal(workspaceRoot, sessionKey);
220
+ const current = readGoal(workspaceRoot, sessionKey);
221
+ if (!current || !current.budget.maxTokens)
222
+ return current;
223
+ return patchGoal(workspaceRoot, sessionKey, {
224
+ budget: {
225
+ ...current.budget,
226
+ tokensUsed: (current.budget.tokensUsed ?? 0) + delta,
227
+ },
228
+ });
229
+ }
230
+ /**
231
+ * Unified update entrypoint. Lets callers mutate text/status/budget in a
232
+ * single call instead of stringing pause→budget→resume together. Used by
233
+ * the `/goal edit` REPL subcommand.
234
+ */
235
+ export function editGoal(workspaceRoot, sessionKey, patch) {
236
+ const current = readGoal(workspaceRoot, sessionKey);
237
+ if (!current)
238
+ return null;
239
+ if (patch.text !== undefined) {
240
+ const trimmed = patch.text.trim();
241
+ if (trimmed.length > GOAL_TEXT_MAX_CHARS) {
242
+ throw new GoalTooLongError(trimmed.length);
243
+ }
244
+ if (!trimmed) {
245
+ throw new Error('Cannot set goal text to empty. Use /goal clear instead.');
246
+ }
247
+ }
248
+ const nextBudget = { ...current.budget };
249
+ if (patch.maxIterations !== undefined && patch.maxIterations > 0) {
250
+ nextBudget.maxIterations = Math.floor(patch.maxIterations);
251
+ }
252
+ if (patch.maxTokens !== undefined) {
253
+ if (patch.maxTokens <= 0) {
254
+ delete nextBudget.maxTokens;
255
+ delete nextBudget.tokensUsed;
256
+ }
257
+ else {
258
+ nextBudget.maxTokens = Math.floor(patch.maxTokens);
259
+ nextBudget.tokensUsed = nextBudget.tokensUsed ?? 0;
260
+ }
261
+ }
262
+ return patchGoal(workspaceRoot, sessionKey, {
263
+ text: patch.text !== undefined ? patch.text.trim() : current.text,
264
+ status: patch.status ?? current.status,
265
+ budget: nextBudget,
266
+ });
267
+ }
268
+ /**
269
+ * True iff scheduling ONE MORE iteration would still fit inside BOTH the
270
+ * iteration cap and (if set) the token cap.
271
+ *
272
+ * The continuation loop ticks AFTER deciding to continue (so `iterationsUsed`
273
+ * lags by one until the tick runs). To stop after exactly `maxIterations`
274
+ * runs total, the predicate must ask "is (used+1) still within the cap?",
275
+ * not "is (used) still under the cap?". The old form gave you N+1 runs.
276
+ *
277
+ * Token budget is a hard "currently used vs cap" check — we can't know the
278
+ * next turn's token cost ahead of time, so we just refuse to schedule when
279
+ * we're already at or past the cap.
280
+ */
281
+ export function goalHasBudgetLeft(goal) {
282
+ if (goal.budget.iterationsUsed + 1 >= goal.budget.maxIterations)
283
+ return false;
284
+ if (typeof goal.budget.maxTokens === 'number' && goal.budget.maxTokens > 0) {
285
+ if ((goal.budget.tokensUsed ?? 0) >= goal.budget.maxTokens)
286
+ return false;
287
+ }
288
+ return true;
289
+ }
290
+ /**
291
+ * True iff this is the FINAL turn within the budget — i.e. the iteration
292
+ * tick is about to land but one more after it would exceed the cap. The
293
+ * continuation loop uses this to inject a "wrap up gracefully" steering
294
+ * message so the model lands soft instead of being interrupted mid-thought.
295
+ *
296
+ * Specifically: after this turn's tick, iterationsUsed will equal
297
+ * maxIterations - 1, so `goalHasBudgetLeft` will return false on the next
298
+ * decision. We detect that ahead of time by checking before the tick.
299
+ */
300
+ export function goalIsOnFinalBudgetTurn(goal) {
301
+ if (goal.budget.iterationsUsed + 2 >= goal.budget.maxIterations)
302
+ return true;
303
+ if (typeof goal.budget.maxTokens === 'number' && goal.budget.maxTokens > 0) {
304
+ const remaining = goal.budget.maxTokens - (goal.budget.tokensUsed ?? 0);
305
+ // Heuristic: if more than 80% of the token budget is consumed, treat
306
+ // this as the final turn so the model can wrap up. Avoids the edge
307
+ // case where one big turn would tip us over without warning.
308
+ if (remaining <= goal.budget.maxTokens * 0.2)
309
+ return true;
310
+ }
311
+ return false;
312
+ }
313
+ /**
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.
318
+ *
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.
324
+ */
325
+ export function buildBudgetSteeringMessage(goal) {
326
+ const iterationsRemaining = Math.max(0, goal.budget.maxIterations - goal.budget.iterationsUsed - 1);
327
+ const iterationTight = goal.budget.iterationsUsed + 2 >= goal.budget.maxIterations;
328
+ const tokensTight = typeof goal.budget.maxTokens === 'number' &&
329
+ goal.budget.maxTokens > 0 &&
330
+ (goal.budget.maxTokens - (goal.budget.tokensUsed ?? 0)) <= goal.budget.maxTokens * 0.2;
331
+ let headline;
332
+ if (iterationTight && tokensTight) {
333
+ const tokensRemaining = (goal.budget.maxTokens ?? 0) - (goal.budget.tokensUsed ?? 0);
334
+ headline =
335
+ `Both budgets are nearly exhausted: ${iterationsRemaining} iteration(s) remaining ` +
336
+ `(cap ${goal.budget.maxIterations}) and ~${tokensRemaining.toLocaleString()} tokens remaining ` +
337
+ `(cap ${(goal.budget.maxTokens ?? 0).toLocaleString()}). This is your last turn.`;
338
+ }
339
+ else if (iterationTight) {
340
+ const tokensClause = goal.budget.maxTokens
341
+ ? ` (tokens still have headroom: ${((goal.budget.maxTokens ?? 0) - (goal.budget.tokensUsed ?? 0)).toLocaleString()} of ${(goal.budget.maxTokens ?? 0).toLocaleString()} remaining)`
342
+ : '';
343
+ headline =
344
+ `You have ${iterationsRemaining || 1} iteration(s) left within the goal's iteration budget ` +
345
+ `(cap ${goal.budget.maxIterations})${tokensClause}. This is your last turn.`;
346
+ }
347
+ else {
348
+ // Token cap is the trigger; iterations may still have plenty of headroom.
349
+ const tokensUsed = goal.budget.tokensUsed ?? 0;
350
+ const tokensCap = goal.budget.maxTokens ?? 0;
351
+ const tokensRemaining = Math.max(0, tokensCap - tokensUsed);
352
+ headline =
353
+ `You're at ${tokensUsed.toLocaleString()}/${tokensCap.toLocaleString()} tokens of the goal's budget ` +
354
+ `(${Math.round((tokensUsed / Math.max(1, tokensCap)) * 100)}% used) with only ~${tokensRemaining.toLocaleString()} tokens remaining. ` +
355
+ `Iteration count still has headroom but the token cap will trip before another full turn fits.`;
356
+ }
357
+ return [
358
+ '## Budget about to run out',
359
+ headline,
360
+ 'Do not start any new long-running investigation, spawn new children, or read more files.',
361
+ 'Instead:',
362
+ '1. Synthesize what you already know into a concise wrap-up.',
363
+ '2. If you have enough evidence the goal is satisfied, call `goal_complete` with the proof.',
364
+ '3. If you do not, call `goal_blocked` with the specific unblocker the user needs to provide.',
365
+ '4. If you need more budget, say so explicitly so the user can extend it.',
366
+ ].join('\n');
367
+ }
368
+ export function formatGoalBlock(goal) {
369
+ const remaining = Math.max(0, goal.budget.maxIterations - goal.budget.iterationsUsed);
370
+ const tokenLine = goal.budget.maxTokens
371
+ ? `**Tokens:** ${(goal.budget.tokensUsed ?? 0).toLocaleString()} of ${goal.budget.maxTokens.toLocaleString()} used`
372
+ : '';
373
+ return [
374
+ `## Active Goal — ${goal.status.toUpperCase().replace('_', ' ')}`,
375
+ '',
376
+ `**Outcome:** ${goal.text}`,
377
+ `**Iteration:** ${goal.budget.iterationsUsed + 1} of ${goal.budget.maxIterations} (${remaining} remaining)`,
378
+ tokenLine,
379
+ `**Started:** ${goal.startedAt}`,
380
+ goal.blockedReason ? `**Reason:** ${goal.blockedReason}` : '',
381
+ '',
382
+ 'This goal is a persistent contract. After each turn the CLI may auto-continue',
383
+ 'you with another turn until the contract is satisfied. To complete the loop:',
384
+ '',
385
+ '- **When you call `goal_complete` / `goal_blocked`, the SAME assistant message',
386
+ ' MUST contain the user-visible deliverable as prose** — the actual answer,',
387
+ ' analysis, report, or summary the user asked for. The `proof` / `reason` fields',
388
+ ' are short audit metadata, NOT the deliverable. Final-turn shape:',
389
+ ' `<prose answer the user reads>` → `goal_complete({proof: "<short audit line>"})`.',
390
+ ' If you skip the prose, the user sees only a placeholder and your work is invisible.',
391
+ '- **Plan honesty:** before `goal_complete`, every item in your active plan',
392
+ ' (from `update_plan`) MUST be marked `completed`. The CLI hard-refuses',
393
+ ' goal_complete while pending / in_progress items remain. If you finished',
394
+ ' the work, call `update_plan` first to mark items done. If you decided to',
395
+ ' drop items mid-flight, mark them `completed` with a brief rationale in the',
396
+ ' step text — the plan is your audit record, leaving items pending while',
397
+ ' declaring done is misleading.',
398
+ '- Call `goal_complete` with a 1–2 sentence evidence-based proof the outcome is met',
399
+ ' (e.g. "tests/file_X.test.ts passes; `mobile/app.tsx` renders the route").',
400
+ '- Call `goal_blocked` with a reason and the user input needed if no path remains.',
401
+ '- Otherwise (mid-goal turns): take the next concrete tool action — read a file,',
402
+ ' write code, spawn a worker child, run a verifier. **Prose-only intermediate',
403
+ ' responses ("I will continue") count as a no-op and the CLI will NOT auto-continue',
404
+ ' after them** (anti-spin). This anti-spin rule covers INTERMEDIATE turns only —',
405
+ ' the final goal-completing turn MUST include prose alongside the tool call.',
406
+ '',
407
+ 'Always audit the evidence before declaring complete — failing tests, missing files,',
408
+ 'or unverified claims mean the goal is NOT done yet.',
409
+ ].filter(Boolean).join('\n');
410
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Hookify-style markdown hooks — no-code behavior guards expressed as YAML
3
+ * frontmatter on a `.md` file.
4
+ *
5
+ * Hooks live as `.md` files under
6
+ * ~/.brainrouter/workspaces/<encoded>/hooks/
7
+ * with YAML frontmatter describing the event, regex pattern(s), and action.
8
+ * This gives users a no-code path to install behavior guards without editing
9
+ * shell hooks. Pre-2026-05-21 builds stored these inside the workspace itself
10
+ * (`<workspace>/.brainrouter/hooks/`); those files are auto-migrated to the
11
+ * new home on first run by `cliState.getCliStateDir`.
12
+ *
13
+ * Example file `hooks/block-rm-rf.md`:
14
+ *
15
+ * ---
16
+ * name: block-rm-rf
17
+ * enabled: true
18
+ * event: bash
19
+ * pattern: rm\s+-rf
20
+ * action: block
21
+ * ---
22
+ *
23
+ * ⚠️ Dangerous rm command blocked. Verify the path is correct.
24
+ */
25
+ export type HookifyEvent = 'bash' | 'file' | 'stop' | 'prompt' | 'all';
26
+ export type HookifyAction = 'warn' | 'block';
27
+ export interface HookifyCondition {
28
+ field: string;
29
+ operator: 'regex_match' | 'contains' | 'equals' | 'not_contains' | 'starts_with' | 'ends_with';
30
+ pattern: string;
31
+ }
32
+ export interface HookifyRule {
33
+ /** Stable identifier (filename without extension). */
34
+ id: string;
35
+ /** Human-readable rule name from frontmatter. */
36
+ name: string;
37
+ enabled: boolean;
38
+ event: HookifyEvent;
39
+ /** Primary regex shortcut (mutually exclusive with conditions). */
40
+ pattern?: string;
41
+ /** Composite conditions; all must match for the rule to fire. */
42
+ conditions?: HookifyCondition[];
43
+ action: HookifyAction;
44
+ /** Markdown body shown to the user when the rule fires. */
45
+ message: string;
46
+ /** Absolute path to the source file (so /hookify list can cite it). */
47
+ sourcePath: string;
48
+ }
49
+ export declare function ensureHookDir(workspaceRoot: string): string;
50
+ export declare function listHookifyRules(workspaceRoot: string): HookifyRule[];
51
+ export declare function parseHookifyFile(filePath: string): HookifyRule | null;
52
+ export interface HookifyContext {
53
+ /** The tool name being invoked, normalized: run_command → bash, write_file/edit_file → file, etc. */
54
+ event: HookifyEvent;
55
+ fields: Record<string, string>;
56
+ }
57
+ export interface HookifyMatch {
58
+ rule: HookifyRule;
59
+ /** "warn" surfaces a message; "block" denies the operation. */
60
+ action: HookifyAction;
61
+ }
62
+ /**
63
+ * Map a brainrouter tool invocation to the hookify event taxonomy. Returns
64
+ * the canonical event and the field bag that condition checks will probe.
65
+ */
66
+ export declare function buildHookifyContext(toolName: string, args: Record<string, any>): HookifyContext;
67
+ export declare function buildPromptContext(prompt: string): HookifyContext;
68
+ export declare function buildStopContext(transcript: string): HookifyContext;
69
+ export declare function evaluateHookify(rules: HookifyRule[], ctx: HookifyContext): HookifyMatch[];
70
+ export declare function createHookifyRule(workspaceRoot: string, rule: {
71
+ name: string;
72
+ event: HookifyEvent;
73
+ pattern?: string;
74
+ action?: HookifyAction;
75
+ message: string;
76
+ conditions?: HookifyCondition[];
77
+ enabled?: boolean;
78
+ }): HookifyRule;
79
+ export declare function deleteHookifyRule(workspaceRoot: string, id: string): boolean;
80
+ export declare function toggleHookifyRule(workspaceRoot: string, id: string, enabled: boolean): boolean;