@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
|
@@ -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.
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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.
|
|
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": "
|
|
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.
|
|
25
|
-
"@kinqs/brainrouter-types": "^0.3.
|
|
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",
|