@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.
Files changed (49) hide show
  1. package/.env.example +55 -48
  2. package/bin/cli.cjs +71 -0
  3. package/dist/agent/agent.d.ts +212 -2
  4. package/dist/agent/agent.js +428 -38
  5. package/dist/cli/banner.d.ts +60 -0
  6. package/dist/cli/banner.js +199 -0
  7. package/dist/cli/cliPrompt.d.ts +69 -0
  8. package/dist/cli/cliPrompt.js +287 -0
  9. package/dist/cli/commands/_helpers.js +6 -6
  10. package/dist/cli/commands/guard.js +75 -10
  11. package/dist/cli/commands/mcp.d.ts +17 -0
  12. package/dist/cli/commands/mcp.js +121 -0
  13. package/dist/cli/commands/memory.js +2 -2
  14. package/dist/cli/commands/obs.js +22 -22
  15. package/dist/cli/commands/session.js +13 -5
  16. package/dist/cli/commands/ui.js +97 -45
  17. package/dist/cli/commands/workflow.d.ts +18 -0
  18. package/dist/cli/commands/workflow.js +314 -43
  19. package/dist/cli/repl.js +219 -132
  20. package/dist/cli/spinner.d.ts +34 -0
  21. package/dist/cli/spinner.js +36 -0
  22. package/dist/cli/statusline.d.ts +67 -0
  23. package/dist/cli/statusline.js +204 -0
  24. package/dist/cli/theme.d.ts +79 -0
  25. package/dist/cli/theme.js +106 -0
  26. package/dist/cli/whereView.d.ts +81 -0
  27. package/dist/cli/whereView.js +245 -0
  28. package/dist/config/config.d.ts +40 -0
  29. package/dist/config/config.js +45 -73
  30. package/dist/index.js +80 -13
  31. package/dist/memory/briefing.d.ts +10 -0
  32. package/dist/memory/briefing.js +69 -1
  33. package/dist/prompt/breadthHint.d.ts +5 -0
  34. package/dist/prompt/breadthHint.js +44 -0
  35. package/dist/prompt/systemPrompt.d.ts +34 -0
  36. package/dist/prompt/systemPrompt.js +124 -108
  37. package/dist/runtime/dangerousCommand.d.ts +53 -0
  38. package/dist/runtime/dangerousCommand.js +105 -0
  39. package/dist/runtime/mcpClient.d.ts +38 -1
  40. package/dist/runtime/mcpClient.js +90 -2
  41. package/dist/state/goalStore.d.ts +98 -17
  42. package/dist/state/goalStore.js +132 -42
  43. package/dist/state/preferencesStore.d.ts +67 -3
  44. package/dist/state/preferencesStore.js +84 -1
  45. package/dist/state/workflowArtifacts.d.ts +63 -2
  46. package/dist/state/workflowArtifacts.js +120 -8
  47. package/dist/tests/_helpers.d.ts +31 -0
  48. package/dist/tests/_helpers.js +91 -0
  49. package/package.json +5 -4
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { getCliStateFile, getWorkspaceLocalDir, isPathInside, readJsonFile, writeJsonFile } from './cliState.js';
3
+ import { getCliStateFile, getSessionStateFile, getWorkspaceLocalDir, isPathInside, readJsonFile, writeJsonFile } from './cliState.js';
4
4
  /**
5
5
  * Canonical home for durable workflow artifacts produced by the multi-agent
6
6
  * commands (/feature-dev, /spec, /review, /implement-plan).
@@ -18,12 +18,42 @@ import { getCliStateFile, getWorkspaceLocalDir, isPathInside, readJsonFile, writ
18
18
  * team shares them, and (b) the agent's `write_file` tool only accepts paths
19
19
  * relative to the workspace root. Personal CLI state (sessions, hooks,
20
20
  * memories, preferences) lives in `~/.brainrouter/workspaces/<encoded>/` and
21
- * never touches the project tree. The current-workflow pointer is still per-
22
- * user (it tracks which workflow YOU are focused on right now) so it lives
23
- * with the CLI state, not the workspace.
21
+ * never touches the project tree.
22
+ *
23
+ * **Workflows do NOT carry goal state.** Earlier 0.3.6 (Item 3) stored a
24
+ * `goal.json` inside each workflow folder so switching workflows would
25
+ * "carry the goal with it." That conflated two different concerns — a goal
26
+ * is **per-session runtime intent** (let the agent run autonomously until
27
+ * complete) while a workflow is **durable storage** (committed artifacts
28
+ * shared across users and CLIs). The conflation produced a cross-session
29
+ * goal leak (two CLIs in the same workspace bound to the same workflow
30
+ * shared a goal). The decoupling lives in goalStore.ts's `resolveGoalScope`
31
+ * — goals are always session-scoped, workflows are pure navigation. The
32
+ * per-session `workflow.json` pointer here is for "which folder am I
33
+ * writing artifacts to RIGHT NOW", not "which goal am I working on."
24
34
  */
25
35
  const WORKFLOWS_SUBDIR = 'workflows';
36
+ /**
37
+ * Workspace-level "last used workflow" pointer. Pre-9d-bugfix this was
38
+ * BOTH the source of truth for "which workflow is the current CLI
39
+ * session bound to" AND the display-only "what was the last workflow
40
+ * touched in this workspace" hint. The two responsibilities are now
41
+ * split: this file is the hint (any CLI in this workspace can see it),
42
+ * while `SESSION_POINTER_FILE` carries the per-session binding that
43
+ * actually drives goal scoping. The hint is still useful for the
44
+ * `/workflows` listing and for surfacing "you were last on X" in a
45
+ * fresh CLI without auto-binding it to that workflow's goal.
46
+ */
26
47
  const CURRENT_POINTER_FILE = 'current-workflow.json';
48
+ /**
49
+ * Per-session workflow binding (the actual source of truth for goal
50
+ * scoping). Lives under the session state directory so two CLIs in the
51
+ * same workspace can have independent workflows bound — fixes the
52
+ * "session A's `/feature-dev` automatically becomes session B's active
53
+ * workflow + active goal" leak that reintroduced Item 1's cross-session
54
+ * leak via the Item 3 workspace pointer.
55
+ */
56
+ const SESSION_POINTER_FILE = 'workflow.json';
27
57
  /** Canonical artifact names. Use the constants rather than hard-coded strings so a future rename is one edit. */
28
58
  export const ARTIFACT = {
29
59
  spec: 'spec.md',
@@ -58,6 +88,16 @@ export function getWorkflowDir(workspaceRoot, slug) {
58
88
  fs.mkdirSync(dir, { recursive: true });
59
89
  return dir;
60
90
  }
91
+ /**
92
+ * Create (or reopen) a workflow folder + bind it as the current
93
+ * workflow.
94
+ *
95
+ * `sessionKey` is threaded through to `setCurrentWorkflow` so that the
96
+ * created workflow is bound to THIS session (not to every other CLI
97
+ * session in the workspace via the workspace-level pointer). Legacy
98
+ * callers without a session context fall through to workspace-level
99
+ * binding only — same back-compat path `setCurrentWorkflow` provides.
100
+ */
61
101
  export function createWorkflow(workspaceRoot, input) {
62
102
  const slug = slugify(input.slug ?? input.title);
63
103
  const dir = getWorkflowDir(workspaceRoot, slug);
@@ -81,7 +121,7 @@ export function createWorkflow(workspaceRoot, input) {
81
121
  meta.kind = input.kind;
82
122
  }
83
123
  writeJsonFile(metaPath, meta);
84
- setCurrentWorkflow(workspaceRoot, slug);
124
+ setCurrentWorkflow(workspaceRoot, slug, input.sessionKey);
85
125
  return meta;
86
126
  }
87
127
  export function updateWorkflowStatus(workspaceRoot, slug, status) {
@@ -111,13 +151,85 @@ export function listWorkflows(workspaceRoot) {
111
151
  }
112
152
  return out.sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''));
113
153
  }
114
- export function setCurrentWorkflow(workspaceRoot, slug) {
115
- writeJsonFile(getCliStateFile(workspaceRoot, CURRENT_POINTER_FILE), { slug, at: new Date().toISOString() });
154
+ /**
155
+ * Bind a workflow to the current CLI session AND update the workspace-
156
+ * level "last used" hint. When `sessionKey` is omitted (legacy callers,
157
+ * some first-run paths), only the workspace pointer is written — those
158
+ * callers don't have a session context yet, so per-session binding
159
+ * doesn't apply.
160
+ *
161
+ * The workspace pointer is updated unconditionally because we still
162
+ * want a fresh CLI in the same workspace to be ABLE to see "X is the
163
+ * last workflow that was touched here" — for display via
164
+ * `getLastUsedWorkflow`, for the `/workflows` listing's `★` marker, and
165
+ * for the post-9d "do you want to switch to <X>?" UX (the latter not
166
+ * yet shipped, tracked separately).
167
+ */
168
+ export function setCurrentWorkflow(workspaceRoot, slug, sessionKey) {
169
+ const ts = new Date().toISOString();
170
+ writeJsonFile(getCliStateFile(workspaceRoot, CURRENT_POINTER_FILE), { slug, at: ts });
171
+ if (sessionKey) {
172
+ writeJsonFile(getSessionStateFile(workspaceRoot, sessionKey, SESSION_POINTER_FILE), { slug, at: ts });
173
+ }
116
174
  }
117
- export function getCurrentWorkflow(workspaceRoot) {
175
+ /**
176
+ * Which workflow is bound to THIS CLI session?
177
+ *
178
+ * - With `sessionKey`: reads ONLY the session-level pointer. A fresh
179
+ * CLI session has no session-level pointer → returns `undefined`,
180
+ * even when a workspace-level "last used" hint exists. This is the
181
+ * load-bearing fix: new sessions don't auto-inherit another session's
182
+ * workflow binding (which previously dragged that workflow's goal
183
+ * into the new session via `resolveGoalScope`).
184
+ * - Without `sessionKey` (legacy / display-only callers): falls back
185
+ * to the workspace-level pointer for back-compat.
186
+ *
187
+ * Display surfaces that want to show "the last workflow touched here,
188
+ * regardless of session binding" should call `getLastUsedWorkflow`
189
+ * instead so the distinction stays explicit.
190
+ */
191
+ export function getCurrentWorkflow(workspaceRoot, sessionKey) {
192
+ if (sessionKey) {
193
+ const sessionPtr = readJsonFile(getSessionStateFile(workspaceRoot, sessionKey, SESSION_POINTER_FILE), null);
194
+ return sessionPtr?.slug || undefined;
195
+ }
196
+ const ptr = readJsonFile(getCliStateFile(workspaceRoot, CURRENT_POINTER_FILE), null);
197
+ return ptr?.slug;
198
+ }
199
+ /**
200
+ * Display-only "last workflow used in this workspace" lookup. Reads
201
+ * the workspace-level pointer unconditionally — never consults the
202
+ * session-level binding. Use when you want to render a hint like
203
+ * "you were last on workflow X" without implying that the current
204
+ * session is bound to it.
205
+ */
206
+ export function getLastUsedWorkflow(workspaceRoot) {
118
207
  const ptr = readJsonFile(getCliStateFile(workspaceRoot, CURRENT_POINTER_FILE), null);
119
208
  return ptr?.slug;
120
209
  }
210
+ /**
211
+ * Clear the session-level workflow binding (workspace-level hint
212
+ * preserved). Used by `/new` and `/fork` so a freshly-forked session
213
+ * doesn't drag the parent's binding along.
214
+ */
215
+ export function clearSessionWorkflow(workspaceRoot, sessionKey) {
216
+ const pointerPath = getSessionStateFile(workspaceRoot, sessionKey, SESSION_POINTER_FILE);
217
+ try {
218
+ fs.unlinkSync(pointerPath);
219
+ }
220
+ catch { /* idempotent — no file to remove is fine */ }
221
+ }
222
+ /**
223
+ * True iff a workflow folder with the given slug exists (and carries a
224
+ * meta.json). Used by `/workflow switch <slug>` to surface "no such
225
+ * workflow" without the side-effect mkdir that `getWorkflowDir` performs.
226
+ */
227
+ export function workflowExists(workspaceRoot, slug) {
228
+ const safeSlug = slugify(slug);
229
+ const root = getWorkflowsRoot(workspaceRoot);
230
+ const candidate = path.join(root, safeSlug, 'meta.json');
231
+ return fs.existsSync(candidate);
232
+ }
121
233
  /**
122
234
  * Path (relative to workspace root) the LLM should `write_file` to for a
123
235
  * given artifact. We return a workspace-relative path because that's the
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Shared fixtures + harness for the split-up CLI test suites.
3
+ *
4
+ * Lives under `src/tests/` alongside the actual `*.test.ts` files. The
5
+ * leading underscore is convention only — the test runner picks up files by
6
+ * the `*.test.js` glob, so a non-test filename is enough to keep node:test
7
+ * from trying to execute this module as a suite.
8
+ *
9
+ * Everything here was lifted verbatim out of the original `src/agent.test.ts`
10
+ * during the split. Don't add new fixtures unless they're used by ≥2 files.
11
+ */
12
+ import { Agent } from '../agent/agent.js';
13
+ /**
14
+ * Construct an Agent without touching MCP or the LLM. Only safe for tests
15
+ * that exercise pure state-machine extensions (model, accessMode, history,
16
+ * fork, refreshSystemPrompt) — anything that triggers `bootstrapSession`
17
+ * will hit the stub MCP and either no-op or surface a misleading error.
18
+ */
19
+ export declare function makeAgent(workspace: string): Agent;
20
+ /**
21
+ * Run a synchronous test body inside a fresh temp workspace. Restores cwd,
22
+ * BRAINROUTER_WORKSPACE, and BRAINROUTER_HOME afterwards. BRAINROUTER_HOME
23
+ * is also pinned to a sibling tmp dir so tests never touch the real
24
+ * `~/.brainrouter` on the developer's machine.
25
+ */
26
+ export declare function withTempWorkspace(fn: (workspace: string) => void): void;
27
+ /**
28
+ * Async sibling of `withTempWorkspace`. Same restore semantics; awaits the
29
+ * body so promise rejections still tear the workspace down.
30
+ */
31
+ export declare function withTempWorkspaceAsync<T>(fn: (workspace: string) => Promise<T>): Promise<T>;
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Shared fixtures + harness for the split-up CLI test suites.
3
+ *
4
+ * Lives under `src/tests/` alongside the actual `*.test.ts` files. The
5
+ * leading underscore is convention only — the test runner picks up files by
6
+ * the `*.test.js` glob, so a non-test filename is enough to keep node:test
7
+ * from trying to execute this module as a suite.
8
+ *
9
+ * Everything here was lifted verbatim out of the original `src/agent.test.ts`
10
+ * during the split. Don't add new fixtures unless they're used by ≥2 files.
11
+ */
12
+ import fs from 'node:fs';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+ import { Agent } from '../agent/agent.js';
16
+ /**
17
+ * Construct an Agent without touching MCP or the LLM. Only safe for tests
18
+ * that exercise pure state-machine extensions (model, accessMode, history,
19
+ * fork, refreshSystemPrompt) — anything that triggers `bootstrapSession`
20
+ * will hit the stub MCP and either no-op or surface a misleading error.
21
+ */
22
+ export function makeAgent(workspace) {
23
+ const stubMcp = {
24
+ listTools: async () => ({ tools: [] }),
25
+ callTool: async () => ({ content: [{ text: '{}' }] }),
26
+ close: async () => { },
27
+ };
28
+ const llm = { provider: 'openai', apiKey: 'k', model: 'test-model' };
29
+ return new Agent(stubMcp, llm, {
30
+ workspaceRoot: workspace,
31
+ launchCwd: workspace,
32
+ sessionKey: 'session:test',
33
+ silent: true, // skip bootstrap + briefing so we don't touch MCP at all
34
+ });
35
+ }
36
+ /**
37
+ * Run a synchronous test body inside a fresh temp workspace. Restores cwd,
38
+ * BRAINROUTER_WORKSPACE, and BRAINROUTER_HOME afterwards. BRAINROUTER_HOME
39
+ * is also pinned to a sibling tmp dir so tests never touch the real
40
+ * `~/.brainrouter` on the developer's machine.
41
+ */
42
+ export function withTempWorkspace(fn) {
43
+ const previousCwd = process.cwd();
44
+ const previousWorkspace = process.env.BRAINROUTER_WORKSPACE;
45
+ const previousHome = process.env.BRAINROUTER_HOME;
46
+ const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'brainrouter-cli-'));
47
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'brainrouter-home-'));
48
+ try {
49
+ delete process.env.BRAINROUTER_WORKSPACE;
50
+ process.env.BRAINROUTER_HOME = home;
51
+ process.chdir(workspace);
52
+ fn(workspace);
53
+ }
54
+ finally {
55
+ process.chdir(previousCwd);
56
+ if (previousWorkspace === undefined)
57
+ delete process.env.BRAINROUTER_WORKSPACE;
58
+ else
59
+ process.env.BRAINROUTER_WORKSPACE = previousWorkspace;
60
+ if (previousHome === undefined)
61
+ delete process.env.BRAINROUTER_HOME;
62
+ else
63
+ process.env.BRAINROUTER_HOME = previousHome;
64
+ fs.rmSync(workspace, { recursive: true, force: true });
65
+ fs.rmSync(home, { recursive: true, force: true });
66
+ }
67
+ }
68
+ /**
69
+ * Async sibling of `withTempWorkspace`. Same restore semantics; awaits the
70
+ * body so promise rejections still tear the workspace down.
71
+ */
72
+ export async function withTempWorkspaceAsync(fn) {
73
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'brainrouter-test-'));
74
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'brainrouter-home-'));
75
+ const previousCwd = process.cwd();
76
+ const previousHome = process.env.BRAINROUTER_HOME;
77
+ process.env.BRAINROUTER_HOME = home;
78
+ process.chdir(tmp);
79
+ try {
80
+ return await fn(tmp);
81
+ }
82
+ finally {
83
+ process.chdir(previousCwd);
84
+ if (previousHome === undefined)
85
+ delete process.env.BRAINROUTER_HOME;
86
+ else
87
+ process.env.BRAINROUTER_HOME = previousHome;
88
+ fs.rmSync(tmp, { recursive: true, force: true });
89
+ fs.rmSync(home, { recursive: true, force: true });
90
+ }
91
+ }
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@kinqs/brainrouter-cli",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
4
4
  "description": "Memory-native terminal coding agent. Talks to the BrainRouter MCP cognitive engine for recall, skills, capture, persona, focus scenes, and contradiction tracking.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "bin": {
8
- "brainrouter": "dist/index.js"
8
+ "brainrouter": "bin/cli.cjs"
9
9
  },
10
10
  "files": [
11
+ "bin",
11
12
  "dist",
12
13
  "README.md",
13
14
  ".env.example"
@@ -21,8 +22,8 @@
21
22
  "prepack": "npm run build && find dist -name '*.test.*' -delete"
22
23
  },
23
24
  "dependencies": {
24
- "@kinqs/brainrouter-sdk": "^0.3.5",
25
- "@kinqs/brainrouter-types": "^0.3.5",
25
+ "@kinqs/brainrouter-sdk": "^0.3.6",
26
+ "@kinqs/brainrouter-types": "^0.3.6",
26
27
  "@modelcontextprotocol/sdk": "^1.11.0",
27
28
  "chalk": "^5.3.0",
28
29
  "commander": "^12.1.0",