@kinqs/brainrouter-cli 0.3.5 → 0.3.7

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 (125) hide show
  1. package/README.md +29 -52
  2. package/agents/architect.json +18 -0
  3. package/agents/explorer.json +18 -0
  4. package/agents/reviewer.json +18 -0
  5. package/agents/verifier.json +18 -0
  6. package/agents/worker.json +18 -0
  7. package/bin/cli.cjs +71 -0
  8. package/dist/agent/agent.d.ts +224 -3
  9. package/dist/agent/agent.js +561 -55
  10. package/dist/cli/banner.d.ts +80 -0
  11. package/dist/cli/banner.js +232 -0
  12. package/dist/cli/cliPrompt.d.ts +106 -0
  13. package/dist/cli/cliPrompt.js +314 -0
  14. package/dist/cli/commands/_context.d.ts +3 -1
  15. package/dist/cli/commands/_helpers.d.ts +1 -1
  16. package/dist/cli/commands/_helpers.js +6 -6
  17. package/dist/cli/commands/config.d.ts +46 -0
  18. package/dist/cli/commands/config.js +1042 -0
  19. package/dist/cli/commands/guard.js +75 -10
  20. package/dist/cli/commands/init.d.ts +20 -0
  21. package/dist/cli/commands/init.js +64 -0
  22. package/dist/cli/commands/login.d.ts +13 -0
  23. package/dist/cli/commands/login.js +179 -0
  24. package/dist/cli/commands/mcp.d.ts +19 -0
  25. package/dist/cli/commands/mcp.js +286 -0
  26. package/dist/cli/commands/memory.js +2 -2
  27. package/dist/cli/commands/obs.js +22 -22
  28. package/dist/cli/commands/orchestration.js +18 -0
  29. package/dist/cli/commands/session.js +13 -5
  30. package/dist/cli/commands/ui.js +202 -91
  31. package/dist/cli/commands/workflow.d.ts +20 -0
  32. package/dist/cli/commands/workflow.js +368 -51
  33. package/dist/cli/ink/ChatApp.d.ts +206 -0
  34. package/dist/cli/ink/ChatApp.js +493 -0
  35. package/dist/cli/ink/Frame.d.ts +26 -0
  36. package/dist/cli/ink/Frame.js +5 -0
  37. package/dist/cli/ink/Picker.d.ts +65 -0
  38. package/dist/cli/ink/Picker.js +133 -0
  39. package/dist/cli/ink/SlashPalette.d.ts +51 -0
  40. package/dist/cli/ink/SlashPalette.js +136 -0
  41. package/dist/cli/ink/TextField.d.ts +34 -0
  42. package/dist/cli/ink/TextField.js +47 -0
  43. package/dist/cli/ink/WizardApp.d.ts +7 -0
  44. package/dist/cli/ink/WizardApp.js +422 -0
  45. package/dist/cli/ink/ambientChat.d.ts +34 -0
  46. package/dist/cli/ink/ambientChat.js +7 -0
  47. package/dist/cli/ink/consoleCapture.d.ts +11 -0
  48. package/dist/cli/ink/consoleCapture.js +33 -0
  49. package/dist/cli/ink/markdownRender.d.ts +41 -0
  50. package/dist/cli/ink/markdownRender.js +278 -0
  51. package/dist/cli/ink/renderWithResizeClear.d.ts +14 -0
  52. package/dist/cli/ink/renderWithResizeClear.js +33 -0
  53. package/dist/cli/ink/runChat.d.ts +34 -0
  54. package/dist/cli/ink/runChat.js +571 -0
  55. package/dist/cli/ink/runPicker.d.ts +31 -0
  56. package/dist/cli/ink/runPicker.js +139 -0
  57. package/dist/cli/ink/runSlashPalette.d.ts +23 -0
  58. package/dist/cli/ink/runSlashPalette.js +33 -0
  59. package/dist/cli/ink/runWizard.d.ts +22 -0
  60. package/dist/cli/ink/runWizard.js +133 -0
  61. package/dist/cli/ink/stdinHandoff.d.ts +51 -0
  62. package/dist/cli/ink/stdinHandoff.js +78 -0
  63. package/dist/cli/ink/toolFormat.d.ts +73 -0
  64. package/dist/cli/ink/toolFormat.js +180 -0
  65. package/dist/cli/ink/useTerminalSize.d.ts +35 -0
  66. package/dist/cli/ink/useTerminalSize.js +26 -0
  67. package/dist/cli/repl.d.ts +25 -3
  68. package/dist/cli/repl.js +64 -646
  69. package/dist/cli/slashSuggest.d.ts +32 -0
  70. package/dist/cli/slashSuggest.js +146 -0
  71. package/dist/cli/spinner.d.ts +34 -0
  72. package/dist/cli/spinner.js +36 -0
  73. package/dist/cli/statusline.d.ts +67 -0
  74. package/dist/cli/statusline.js +204 -0
  75. package/dist/cli/theme.d.ts +79 -0
  76. package/dist/cli/theme.js +106 -0
  77. package/dist/cli/whereView.d.ts +81 -0
  78. package/dist/cli/whereView.js +245 -0
  79. package/dist/cli/wizard/modelsApi.d.ts +72 -0
  80. package/dist/cli/wizard/modelsApi.js +166 -0
  81. package/dist/cli/wizard/picker.d.ts +202 -0
  82. package/dist/cli/wizard/picker.js +547 -0
  83. package/dist/cli/wizard/providers.d.ts +86 -0
  84. package/dist/cli/wizard/providers.js +190 -0
  85. package/dist/cli/wizard/runner.d.ts +13 -0
  86. package/dist/cli/wizard/runner.js +488 -0
  87. package/dist/cli/wizard/types.d.ts +122 -0
  88. package/dist/cli/wizard/types.js +109 -0
  89. package/dist/config/config.d.ts +52 -0
  90. package/dist/config/config.js +89 -75
  91. package/dist/index.js +215 -206
  92. package/dist/memory/briefing.d.ts +11 -1
  93. package/dist/memory/briefing.js +69 -1
  94. package/dist/memory/consolidation.d.ts +1 -1
  95. package/dist/orchestration/agentRegistry.d.ts +36 -0
  96. package/dist/orchestration/agentRegistry.js +64 -0
  97. package/dist/orchestration/orchestrator.d.ts +7 -0
  98. package/dist/orchestration/orchestrator.js +2 -0
  99. package/dist/orchestration/tools.d.ts +10 -1
  100. package/dist/orchestration/tools.js +48 -4
  101. package/dist/prompt/breadthHint.d.ts +5 -0
  102. package/dist/prompt/breadthHint.js +44 -0
  103. package/dist/prompt/skillCatalog.d.ts +11 -0
  104. package/dist/prompt/skillCatalog.js +134 -0
  105. package/dist/prompt/skillRunner.d.ts +2 -2
  106. package/dist/prompt/skillRunner.js +2 -31
  107. package/dist/prompt/systemPrompt.d.ts +34 -0
  108. package/dist/prompt/systemPrompt.js +128 -108
  109. package/dist/runtime/dangerousCommand.d.ts +53 -0
  110. package/dist/runtime/dangerousCommand.js +105 -0
  111. package/dist/runtime/mcpClient.d.ts +38 -1
  112. package/dist/runtime/mcpClient.js +104 -13
  113. package/dist/runtime/mcpPool.d.ts +162 -0
  114. package/dist/runtime/mcpPool.js +423 -0
  115. package/dist/runtime/mcpUtils.d.ts +3 -1
  116. package/dist/state/goalStore.d.ts +98 -17
  117. package/dist/state/goalStore.js +132 -42
  118. package/dist/state/preferencesStore.d.ts +67 -3
  119. package/dist/state/preferencesStore.js +84 -1
  120. package/dist/state/workflowArtifacts.d.ts +63 -2
  121. package/dist/state/workflowArtifacts.js +120 -8
  122. package/dist/tests/_helpers.d.ts +31 -0
  123. package/dist/tests/_helpers.js +91 -0
  124. package/package.json +12 -5
  125. package/.env.example +0 -109
@@ -7,19 +7,76 @@ import { spawn } from 'node:child_process';
7
7
  import { promisify } from 'node:util';
8
8
  import { exec } from 'node:child_process';
9
9
  import chalk from 'chalk';
10
- import ora from 'ora';
10
+ import { spinner as makeSpinner } from '../spinner.js';
11
11
  import { LOCAL_TOOLS } from '../../agent/agent.js';
12
12
  import { callMcpTool } from '../../runtime/mcpUtils.js';
13
13
  import { listSessions, reconcileStale } from '../../orchestration/orchestrator.js';
14
- import { ARTIFACT, artifactRelativePath, createWorkflow, getCurrentWorkflow, listWorkflows, readArtifact, updateWorkflowStatus } from '../../state/workflowArtifacts.js';
15
- import { clearGoal, completeGoal, editGoal, GoalConflictError, GoalTooLongError, GOAL_TEXT_MAX_CHARS, pauseGoal, readGoal, resumeGoal, setGoal, setGoalBudget, setGoalTokenBudget } from '../../state/goalStore.js';
14
+ import { ARTIFACT, artifactRelativePath, createWorkflow, getCurrentWorkflow, listWorkflows, readArtifact, setCurrentWorkflow, slugify, updateWorkflowStatus, workflowExists } from '../../state/workflowArtifacts.js';
15
+ import { clearGoal, completeGoal, editGoal, formatBudget, GoalConflictError, GoalTooLongError, GOAL_TEXT_MAX_CHARS, pauseGoal, readGoal, resumeGoal, setGoal, setGoalBudget, setGoalTokenBudget } from '../../state/goalStore.js';
16
16
  import { askYesNo } from '../cliPrompt.js';
17
17
  import { formatPlan, readPlan, updatePlan } from '../../state/taskStore.js';
18
18
  import { getLoopState, parseInterval, startLoop, stopLoop } from '../../runtime/loopRunner.js';
19
19
  import { SLASH_TO_SKILL } from '../../prompt/skillRunner.js';
20
+ import { listFilesystemSkills, mergeSkillLists } from '../../prompt/skillCatalog.js';
20
21
  import { buildGoalKickoffPrompt, runSkillByName, runSkillCommand } from './_helpers.js';
21
22
  // Promise-flavored exec for case bodies that shell out.
22
23
  const execPromise = promisify(exec);
24
+ /**
25
+ * Decide whether `/grill-me` should refuse to fire because the current
26
+ * workflow already has a written `spec.md`. The clarifying pass is meant to
27
+ * happen BEFORE the spec is committed — once a spec exists, asking again
28
+ * usually means we're re-litigating answers the user already gave, which
29
+ * wastes a turn. `--force` is the explicit escape hatch when the user
30
+ * genuinely wants a second clarifying pass (e.g., scope has drifted).
31
+ *
32
+ * Exported helper for unit tests so the guard logic can be exercised
33
+ * without standing up the whole REPL context. NOT pure: reads workflow
34
+ * state from disk (`getCurrentWorkflow`, `readArtifact`) and the latter
35
+ * may mkdirSync the workflow folder as a side effect.
36
+ */
37
+ export function shouldSkipGrillMe(workspaceRoot, force, sessionKey) {
38
+ if (force)
39
+ return { skip: false };
40
+ // 9d-bugfix: scope the "is there an active workflow?" check to THIS
41
+ // session, not the workspace pointer. A fresh CLI with no session
42
+ // binding should not be told "plan already exists" just because a
43
+ // previous CLI ran `/spec` here.
44
+ const slug = getCurrentWorkflow(workspaceRoot, sessionKey);
45
+ if (!slug)
46
+ return { skip: false };
47
+ const spec = readArtifact(workspaceRoot, slug, ARTIFACT.spec);
48
+ if (!spec)
49
+ return { skip: false };
50
+ return {
51
+ skip: true,
52
+ slug,
53
+ specPath: artifactRelativePath(workspaceRoot, slug, ARTIFACT.spec),
54
+ };
55
+ }
56
+ /**
57
+ * Strip a `--force` token from a slash command's arg list, returning the
58
+ * flag's presence plus the rest. Used by /feature-dev / /spec / /review
59
+ * to gate Subtask 6's clobber prompt (mirrors /grill-me's --force parsing).
60
+ */
61
+ function parseForceFlag(args) {
62
+ return { force: args.includes('--force'), rest: args.filter((a) => a !== '--force') };
63
+ }
64
+ /**
65
+ * Print the one-line confirmation banner after a successful `/workflow
66
+ * switch <slug>` (or a no-op switch onto the already-current workflow).
67
+ * Format: `Switched to workflow <slug> — goal: <status>, iteration N of cap`
68
+ * — or `goal: —` when no goal is bound.
69
+ */
70
+ function printWorkflowSwitchConfirmation(slug, goal) {
71
+ if (!goal) {
72
+ console.log(chalk.green(`\n✓ Switched to workflow "${slug}" — goal: —.\n`));
73
+ return;
74
+ }
75
+ const statusLabel = goal.status.replace('_', ' ');
76
+ const iter = goal.budget.iterationsUsed;
77
+ const cap = formatBudget(goal.budget.maxIterations);
78
+ console.log(chalk.green(`\n✓ Switched to workflow "${slug}" — goal: ${statusLabel}, iteration ${iter} of ${cap}.\n`));
79
+ }
23
80
  export async function tryHandleWorkflowCommand(ctx) {
24
81
  const { command, args, agent, mcpClient, config, rl, repl } = ctx;
25
82
  // 'ctx' alias to keep references to the old ReplContext name working
@@ -27,16 +84,29 @@ export async function tryHandleWorkflowCommand(ctx) {
27
84
  switch (command) {
28
85
  case '/skills':
29
86
  {
30
- const spinner = ora(chalk.gray('Fetching skills...')).start();
87
+ const verbose = args.includes('--verbose') || args.includes('-v');
88
+ const spinner = makeSpinner(chalk.gray('Fetching skills...')).start();
31
89
  try {
32
90
  const res = await callMcpTool(mcpClient, 'list_skills', { scope: 'all' });
33
91
  spinner.stop();
34
- if (!res.isError && Array.isArray(res.parsed)) {
35
- const skillsList = res.parsed;
36
- console.log(chalk.bold('\n🧠 BrainRouter Skills:'));
92
+ const mcpSkills = !res.isError ? normalizeSkillsList(res.parsed) : undefined;
93
+ const filesystemSkills = listFilesystemSkills(agent.workspaceRoot);
94
+ const skillsList = mcpSkills
95
+ ? mergeSkillLists(mcpSkills, filesystemSkills)
96
+ : filesystemSkills;
97
+ if (skillsList) {
98
+ console.log(chalk.bold(`\n🧠 BrainRouter Skills (${skillsList.length}):`));
37
99
  if (skillsList.length > 0) {
38
100
  for (const skill of skillsList) {
39
- console.log(` • ${chalk.cyan(skill.name)} (${chalk.gray(skill.scope)}) - ${skill.description}`);
101
+ const category = skill.category ? `${skill.category}/` : '';
102
+ const suffix = verbose && skill.description ? ` - ${skill.description}` : '';
103
+ console.log(` • ${chalk.cyan(`${category}${skill.name}`)} (${chalk.gray(skill.scope ?? 'unknown')})${suffix}`);
104
+ }
105
+ if (mcpSkills && skillsList.length > mcpSkills.length) {
106
+ console.log(chalk.gray(` Showing ${skillsList.length} skills (${mcpSkills.length} from MCP, ${skillsList.length - mcpSkills.length} filled from local files).`));
107
+ }
108
+ else if (!mcpSkills && filesystemSkills.length > 0) {
109
+ console.log(chalk.gray(' MCP list unavailable; showing local filesystem skills.'));
40
110
  }
41
111
  }
42
112
  else {
@@ -45,6 +115,8 @@ export async function tryHandleWorkflowCommand(ctx) {
45
115
  }
46
116
  else {
47
117
  console.log(chalk.red('\nFailed to parse skills list response.'));
118
+ if (res.text)
119
+ console.log(chalk.gray(` ${res.text.slice(0, 300)}`));
48
120
  }
49
121
  }
50
122
  catch (err) {
@@ -56,24 +128,29 @@ export async function tryHandleWorkflowCommand(ctx) {
56
128
  }
57
129
  case '/tools':
58
130
  {
59
- console.log(chalk.bold('\nLocal Workspace Tools:'));
131
+ const verbose = args.includes('--verbose') || args.includes('-v');
132
+ console.log(chalk.bold(`\nLocal Workspace Tools (${LOCAL_TOOLS.length}):`));
60
133
  for (const tool of LOCAL_TOOLS) {
61
- console.log(` ${chalk.cyan(tool.name)} - ${tool.description}`);
134
+ const suffix = verbose ? ` - ${tool.description}` : '';
135
+ console.log(` • ${chalk.cyan(tool.name)}${suffix}`);
62
136
  }
63
- const spinner = ora(chalk.gray('Fetching MCP tools...')).start();
137
+ const spinner = makeSpinner(chalk.gray('Fetching MCP tools...')).start();
64
138
  try {
65
139
  const res = await mcpClient.listTools();
66
140
  spinner.stop();
67
141
  const tools = res.tools || [];
68
- console.log(chalk.bold('\nMCP Tools:'));
142
+ console.log(chalk.bold(`\nMCP Tools (${tools.length}):`));
69
143
  if (tools.length === 0) {
70
144
  console.log(chalk.yellow(' No MCP tools exposed by the active server.'));
71
145
  }
72
146
  else {
73
147
  for (const tool of tools) {
74
- console.log(` ${chalk.cyan(tool.name)} - ${tool.description || 'No description'}`);
148
+ const suffix = verbose ? ` - ${tool.description || 'No description'}` : '';
149
+ console.log(` • ${chalk.cyan(tool.name)}${suffix}`);
75
150
  }
76
151
  }
152
+ if (!verbose)
153
+ console.log(chalk.gray(' Use /tools --verbose to include descriptions.'));
77
154
  }
78
155
  catch (err) {
79
156
  spinner.fail(chalk.red('Failed to list MCP tools.'));
@@ -84,13 +161,31 @@ export async function tryHandleWorkflowCommand(ctx) {
84
161
  }
85
162
  case '/plan':
86
163
  {
164
+ // `/plan clear` is the explicit escape hatch when stale items from a
165
+ // prior workflow are blocking goal_complete (the plan-honesty guard
166
+ // refuses to complete with open items). `/goal <text>` also
167
+ // auto-clears, but this lets the user reset without setting a new
168
+ // goal — useful mid-session if you just abandoned a workflow.
169
+ if (args[0] === 'clear') {
170
+ const before = readPlan(agent.workspaceRoot, agent.sessionKey);
171
+ const pendingCount = before.items.filter((i) => i.status !== 'completed').length;
172
+ updatePlan(agent.workspaceRoot, { plan: [], explanation: 'cleared by /plan clear' }, agent.sessionKey);
173
+ console.log(chalk.green(`\n✓ Plan cleared.`));
174
+ if (pendingCount > 0) {
175
+ console.log(chalk.gray(` Removed ${pendingCount} pending item${pendingCount === 1 ? '' : 's'}.\n`));
176
+ }
177
+ else {
178
+ console.log();
179
+ }
180
+ return true;
181
+ }
87
182
  const state = readPlan(agent.workspaceRoot, agent.sessionKey);
88
183
  console.log(chalk.bold('\nPlan:'));
89
184
  console.log(chalk.gray(formatPlan(state)));
90
185
  if (state.updatedAt) {
91
186
  console.log(chalk.gray(`Updated: ${state.updatedAt}`));
92
187
  }
93
- console.log();
188
+ console.log(chalk.gray('\nSubcommands: /plan | /plan clear\n'));
94
189
  return true;
95
190
  }
96
191
  case '/diff':
@@ -138,7 +233,7 @@ export async function tryHandleWorkflowCommand(ctx) {
138
233
  // nothing to commit. The actual commit work goes through ctx.repl.runAgentTurn
139
234
  // so it inherits the normal pipeline: isProcessing locking, goal
140
235
  // continuation, /raw honoring, contradiction surfacing, token summary.
141
- const spinner = ora(chalk.gray('Checking git status...')).start();
236
+ const spinner = makeSpinner(chalk.gray('Checking git status...')).start();
142
237
  let statusOut = '';
143
238
  let diffOut = '';
144
239
  try {
@@ -165,12 +260,16 @@ export async function tryHandleWorkflowCommand(ctx) {
165
260
  }
166
261
  case '/feature-dev':
167
262
  {
168
- const feature = args.join(' ').trim();
263
+ // `--force` accepted but ignored — workflows no longer carry goals,
264
+ // so there's nothing to "clobber" when starting a new one. Kept on
265
+ // the CLI for back-compat with any user muscle memory / scripts.
266
+ const parsed = parseForceFlag(args);
267
+ const feature = parsed.rest.join(' ').trim();
169
268
  if (!feature) {
170
269
  console.log(chalk.red('\nUsage: /feature-dev <feature description>\n'));
171
270
  break;
172
271
  }
173
- const meta = createWorkflow(agent.workspaceRoot, { title: feature, kind: 'feature-dev' });
272
+ const meta = createWorkflow(agent.workspaceRoot, { title: feature, kind: 'feature-dev', sessionKey: agent.sessionKey });
174
273
  const specPath = artifactRelativePath(agent.workspaceRoot, meta.slug, ARTIFACT.spec);
175
274
  const tasksPath = artifactRelativePath(agent.workspaceRoot, meta.slug, ARTIFACT.tasks);
176
275
  console.log(chalk.gray(`Workflow folder: ${path.dirname(specPath)}`));
@@ -209,14 +308,50 @@ export async function tryHandleWorkflowCommand(ctx) {
209
308
  ].join('\n'), ctx.repl.runAgentTurn);
210
309
  return true;
211
310
  }
311
+ case '/grill-me':
312
+ {
313
+ // `/grill-me` doesn't render its own picker — it just nudges the model
314
+ // (via the CLARIFY-mode overlay in systemPrompt.ts) to ask 2–5
315
+ // questions back instead of jumping to implementation tools. The
316
+ // picker UI lives in cliPrompt.ts and stays untouched.
317
+ const force = args.includes('--force');
318
+ const task = args.filter((a) => a !== '--force').join(' ').trim();
319
+ if (!task) {
320
+ console.log(chalk.red('\nUsage: /grill-me [--force] <task description>\n'));
321
+ return true;
322
+ }
323
+ const decision = shouldSkipGrillMe(agent.workspaceRoot, force, agent.sessionKey);
324
+ if (decision.skip) {
325
+ console.log(chalk.yellow(`\nPlan already exists at ${chalk.cyan(decision.specPath)}.`));
326
+ console.log(chalk.gray(` Drop into it with \`/workflow switch ${decision.slug}\`, or use \`/grill-me --force\` to clarify additional details.\n`));
327
+ return true;
328
+ }
329
+ // Latch activeSkill BEFORE refreshing the system prompt so the
330
+ // CLARIFY overlay lands in chatHistory[0]. The post-turn hook in
331
+ // repl.ts clears activeSkill + refreshes again, so the overlay
332
+ // doesn't bleed into the user's next plain prompt.
333
+ agent.activeSkill = 'grill-me';
334
+ agent.refreshSystemPrompt();
335
+ const prompt = [
336
+ '[CLARIFY — grill-me]',
337
+ '',
338
+ `The user wants help with: ${task}`,
339
+ '',
340
+ 'Before doing anything, ask 2–5 short questions back to disambiguate scope, format, and unstated assumptions. Use `ask_user_choice` for mutually-exclusive options; plain prose for free-form input. End with a one-paragraph "what I\'ll do once you answer" so the user can sanity-check your read of the request.',
341
+ ].join('\n');
342
+ ctx.repl.runAgentTurn(prompt);
343
+ return true;
344
+ }
212
345
  case '/spec':
213
346
  {
214
- const feature = args.join(' ').trim();
347
+ // `--force` accepted but ignored — see /feature-dev for rationale.
348
+ const parsed = parseForceFlag(args);
349
+ const feature = parsed.rest.join(' ').trim();
215
350
  if (!feature) {
216
351
  console.log(chalk.red('\nUsage: /spec <feature title>\n'));
217
352
  break;
218
353
  }
219
- const meta = createWorkflow(agent.workspaceRoot, { title: feature, kind: 'spec' });
354
+ const meta = createWorkflow(agent.workspaceRoot, { title: feature, kind: 'spec', sessionKey: agent.sessionKey });
220
355
  const specPath = artifactRelativePath(agent.workspaceRoot, meta.slug, ARTIFACT.spec);
221
356
  console.log(chalk.gray(`Workflow folder: ${path.dirname(specPath)}`));
222
357
  await runSkillCommand(agent, mcpClient, '/spec', feature, [
@@ -237,8 +372,11 @@ export async function tryHandleWorkflowCommand(ctx) {
237
372
  }
238
373
  case '/review':
239
374
  {
240
- const scope = args.join(' ').trim() || 'current unstaged and staged changes (git diff HEAD)';
241
- const meta = createWorkflow(agent.workspaceRoot, { title: `Review: ${scope}`, kind: 'review' });
375
+ // `--force` accepted but ignored see /feature-dev for rationale.
376
+ const parsed = parseForceFlag(args);
377
+ const scope = parsed.rest.join(' ').trim() || 'current unstaged and staged changes (git diff HEAD)';
378
+ const reviewTitle = `Review: ${scope}`;
379
+ const meta = createWorkflow(agent.workspaceRoot, { title: reviewTitle, kind: 'review', sessionKey: agent.sessionKey });
242
380
  const reportPath = artifactRelativePath(agent.workspaceRoot, meta.slug, 'review.md');
243
381
  console.log(chalk.gray(`Workflow folder: ${path.dirname(reportPath)}`));
244
382
  await runSkillCommand(agent, mcpClient, command, scope, [
@@ -268,8 +406,8 @@ export async function tryHandleWorkflowCommand(ctx) {
268
406
  }
269
407
  // Attach this execution turn to the current workflow if there is one, so
270
408
  // walkthrough.md accumulates per workflow rather than per CLI session.
271
- const currentSlug = getCurrentWorkflow(agent.workspaceRoot);
272
- const slug = currentSlug ?? createWorkflow(agent.workspaceRoot, { title: next.step, kind: 'implement-plan' }).slug;
409
+ const currentSlug = getCurrentWorkflow(agent.workspaceRoot, agent.sessionKey);
410
+ const slug = currentSlug ?? createWorkflow(agent.workspaceRoot, { title: next.step, kind: 'implement-plan', sessionKey: agent.sessionKey }).slug;
273
411
  const walkPath = artifactRelativePath(agent.workspaceRoot, slug, ARTIFACT.walkthrough);
274
412
  console.log(chalk.gray(`Workflow folder: ${path.dirname(walkPath)}`));
275
413
  await runSkillCommand(agent, mcpClient, command, `Next plan item: "${next.step}"`, [
@@ -289,7 +427,7 @@ export async function tryHandleWorkflowCommand(ctx) {
289
427
  }
290
428
  case '/approve':
291
429
  {
292
- const slug = args[0] || getCurrentWorkflow(agent.workspaceRoot);
430
+ const slug = args[0] || getCurrentWorkflow(agent.workspaceRoot, agent.sessionKey);
293
431
  if (!slug) {
294
432
  console.log(chalk.red('\nNo current workflow. Use /spec or /feature-dev first, or /approve <slug>.\n'));
295
433
  return true;
@@ -317,6 +455,109 @@ export async function tryHandleWorkflowCommand(ctx) {
317
455
  `6. STOP after the first task and ask whether to continue. Do not silently work through every task — the user approves slices, not the whole batch.`);
318
456
  return true;
319
457
  }
458
+ case '/workflow':
459
+ {
460
+ // Subcommands: switch <slug> | pause | resume <slug>
461
+ // The plural `/workflows` (next case) is the list command. Singular
462
+ // `/workflow` carries actions on the current pointer / a named slug.
463
+ const sub = (args[0] ?? '').toLowerCase();
464
+ if (!sub) {
465
+ console.log(chalk.red('\nUsage: /workflow switch <slug> | pause | resume <slug>\n'));
466
+ return true;
467
+ }
468
+ if (sub === 'switch') {
469
+ const rawSlug = (args[1] ?? '').trim();
470
+ if (!rawSlug) {
471
+ console.log(chalk.red('\nUsage: /workflow switch <slug>\n'));
472
+ console.log(chalk.gray(' See /workflows for available slugs.\n'));
473
+ return true;
474
+ }
475
+ // Canonicalize at the entry point. Without this, a user typing
476
+ // `/workflow switch My Workflow` (or any title-cased / spaced
477
+ // variant) would have the raw string written verbatim into the
478
+ // pointer file by setCurrentWorkflow, breaking `w.slug ===
479
+ // currentSlug` matching everywhere downstream (the ★ marker
480
+ // on /workflows, the "already on it" no-op below, etc.).
481
+ const targetSlug = slugify(rawSlug);
482
+ if (!workflowExists(agent.workspaceRoot, targetSlug)) {
483
+ console.log(chalk.red(`\nNo such workflow: "${rawSlug}".`));
484
+ console.log(chalk.gray(' Use /workflows to see what exists, or /spec / /feature-dev to create a new one.\n'));
485
+ return true;
486
+ }
487
+ if (getCurrentWorkflow(agent.workspaceRoot, agent.sessionKey) === targetSlug) {
488
+ // Already on it — print the same banner as if we'd switched so
489
+ // the user gets a consistent confirmation instead of "no-op".
490
+ const g = readGoal(agent.workspaceRoot, agent.sessionKey);
491
+ printWorkflowSwitchConfirmation(targetSlug, g);
492
+ return true;
493
+ }
494
+ // Post-decoupling (`/workflow switch` is now pure navigation):
495
+ // - Updates the session-scoped workflow pointer so subsequent
496
+ // `/spec` / `/feature-dev` / `/implement-plan` calls in THIS
497
+ // session land artifacts under <targetSlug>'s folder.
498
+ // - Does NOT touch the session's goal. Goal is session-scoped
499
+ // runtime state, workflows are durable storage — see
500
+ // goalStore.ts:resolveGoalScope for the design rationale.
501
+ // - Earlier migration / conflict prompt (planWorkflowSwitch +
502
+ // migrateSessionGoalToWorkflow + applyMigrationResolution) is
503
+ // gone with the workflow-goal storage that motivated it.
504
+ setCurrentWorkflow(agent.workspaceRoot, targetSlug, agent.sessionKey);
505
+ agent.refreshSystemPrompt();
506
+ const sessionGoal = readGoal(agent.workspaceRoot, agent.sessionKey);
507
+ printWorkflowSwitchConfirmation(targetSlug, sessionGoal);
508
+ return true;
509
+ }
510
+ if (sub === 'pause') {
511
+ // `/workflow pause` is now an alias for `/goal pause` — workflows
512
+ // don't carry their own goal anymore, so "pause the workflow's
513
+ // goal" is just "pause the session's goal" which is what
514
+ // pauseGoal already does.
515
+ const g = pauseGoal(agent.workspaceRoot, agent.sessionKey);
516
+ if (!g) {
517
+ console.log(chalk.yellow('\nNo active goal to pause. Use /goal <text> to set one.\n'));
518
+ return true;
519
+ }
520
+ agent.refreshSystemPrompt();
521
+ const titlePreview = g.text.length > 60 ? g.text.slice(0, 60) + '…' : g.text;
522
+ const slug = getCurrentWorkflow(agent.workspaceRoot, agent.sessionKey);
523
+ const wfLabel = slug ? ` (in workflow "${slug}")` : '';
524
+ console.log(chalk.yellow(`\n⏸ Paused goal${wfLabel}: ${titlePreview}.`));
525
+ console.log(chalk.gray(' /goal resume to continue this goal later.\n'));
526
+ return true;
527
+ }
528
+ if (sub === 'resume') {
529
+ const rawSlug = (args[1] ?? '').trim();
530
+ if (!rawSlug) {
531
+ console.log(chalk.red('\nUsage: /workflow resume <slug>\n'));
532
+ return true;
533
+ }
534
+ // Canonicalize for the same reason as /workflow switch (see above).
535
+ const targetSlug = slugify(rawSlug);
536
+ if (!workflowExists(agent.workspaceRoot, targetSlug)) {
537
+ console.log(chalk.red(`\nNo such workflow: "${rawSlug}".\n`));
538
+ return true;
539
+ }
540
+ // `/workflow resume <slug>` is now sugar for "switch artifacts
541
+ // to <slug>'s folder, then resume the session's paused goal if
542
+ // there is one." Workflows no longer carry goals; resume is a
543
+ // goal-only operation that just happens to want a workflow set
544
+ // first so artifact writes land in the right place.
545
+ setCurrentWorkflow(agent.workspaceRoot, targetSlug, agent.sessionKey);
546
+ const resumed = resumeGoal(agent.workspaceRoot, agent.sessionKey);
547
+ agent.refreshSystemPrompt();
548
+ if (!resumed) {
549
+ console.log(chalk.green(`\n▶ Switched to workflow "${targetSlug}" — no paused session goal to resume.\n`));
550
+ console.log(chalk.gray(` Set a fresh /goal here when you're ready.\n`));
551
+ return true;
552
+ }
553
+ console.log(chalk.green(`\n▶ Switched to workflow "${targetSlug}" and resumed goal (${resumed.budget.iterationsUsed}/${formatBudget(resumed.budget.maxIterations)} used). Firing next iteration…\n`));
554
+ ctx.repl.runAgentTurn(buildGoalKickoffPrompt(resumed, 'resume'));
555
+ return true;
556
+ }
557
+ console.log(chalk.red(`\nUnknown /workflow subcommand: "${sub}".`));
558
+ console.log(chalk.gray(' Subcommands: switch <slug> | pause | resume <slug>\n'));
559
+ return true;
560
+ }
320
561
  case '/workflows':
321
562
  {
322
563
  const workflows = listWorkflows(agent.workspaceRoot);
@@ -325,14 +566,22 @@ export async function tryHandleWorkflowCommand(ctx) {
325
566
  console.log(chalk.yellow(' (none yet — try /spec or /feature-dev)'));
326
567
  }
327
568
  else {
328
- const currentSlug = getCurrentWorkflow(agent.workspaceRoot);
569
+ const currentSlug = getCurrentWorkflow(agent.workspaceRoot, agent.sessionKey);
329
570
  for (const w of workflows) {
330
- const marker = w.slug === currentSlug ? chalk.green(' ← current') : '';
571
+ // Subtask 4: current-pointer marker is now (the spec's chosen
572
+ // glyph). Existing column structure on the first/second lines
573
+ // preserved so script readers don't break — the new goal column
574
+ // lands at the right of the artifact-markers line. The ★
575
+ // reflects THIS session's binding (9d-bugfix), so two CLIs in
576
+ // the same workspace can each see their own bound workflow.
577
+ const marker = w.slug === currentSlug ? chalk.green(' ★') : '';
331
578
  console.log(` ${chalk.cyan(w.slug)} [${chalk.gray(w.status)}] ${chalk.gray(w.kind)}${marker}`);
332
579
  console.log(` ${w.title}`);
333
580
  const hasSpec = !!readArtifact(agent.workspaceRoot, w.slug, ARTIFACT.spec);
334
581
  const hasTasks = !!readArtifact(agent.workspaceRoot, w.slug, ARTIFACT.tasks);
335
582
  const hasWalk = !!readArtifact(agent.workspaceRoot, w.slug, ARTIFACT.walkthrough);
583
+ // Workflows are pure artifact folders now — no goal column.
584
+ // Goal state lives at session scope only; see goalStore.ts.
336
585
  console.log(chalk.gray(` spec.md:${hasSpec ? '✓' : '·'} tasks.md:${hasTasks ? '✓' : '·'} walkthrough.md:${hasWalk ? '✓' : '·'}`));
337
586
  }
338
587
  }
@@ -358,6 +607,13 @@ export async function tryHandleWorkflowCommand(ctx) {
358
607
  case '/goal':
359
608
  {
360
609
  const arg = args.join(' ').trim();
610
+ // Eager session resolve — without this, the FIRST /goal of a new
611
+ // CLI session writes goal.json under the deterministic fallback
612
+ // sessionKey, but every later runTurn reads from the
613
+ // MCP-resolved UUID key. Split-brain: kickoff banner shows the
614
+ // new goal, the agent reads a stale one from a different file.
615
+ // ensureInitialized is idempotent and tolerates missing MCP.
616
+ await agent.ensureInitialized();
361
617
  const ws = agent.workspaceRoot;
362
618
  const sk = agent.sessionKey;
363
619
  const showStatus = (g) => {
@@ -376,7 +632,7 @@ export async function tryHandleWorkflowCommand(ctx) {
376
632
  console.log(chalk.bold('\nGoal'));
377
633
  console.log(` Status: ${status}`);
378
634
  console.log(` Outcome: ${chalk.cyan(g.text)}`);
379
- console.log(` Iterations: ${g.budget.iterationsUsed}/${g.budget.maxIterations} used`);
635
+ console.log(` Iterations: ${g.budget.iterationsUsed}/${formatBudget(g.budget.maxIterations)} used`);
380
636
  if (g.budget.maxTokens) {
381
637
  console.log(` Tokens: ${(g.budget.tokensUsed ?? 0).toLocaleString()}/${g.budget.maxTokens.toLocaleString()} used`);
382
638
  }
@@ -413,12 +669,8 @@ export async function tryHandleWorkflowCommand(ctx) {
413
669
  console.log(chalk.yellow('\nNo goal to resume.\n'));
414
670
  return true;
415
671
  }
416
- // Resume from paused/blocked/usage_limited — any stale "wrap up"
417
- // steering from the previous final-budget tick must be dropped so
418
- // the resumed turn doesn't see contradictory directives.
419
- agent.removeTaggedSystemMessage('goal-budget-steering');
420
672
  agent.refreshSystemPrompt();
421
- console.log(chalk.green(`\n▶ Goal resumed (${g.budget.iterationsUsed}/${g.budget.maxIterations} used). Starting next iteration…\n`));
673
+ console.log(chalk.green(`\n▶ Goal resumed (${g.budget.iterationsUsed}/${formatBudget(g.budget.maxIterations)} used). Starting next iteration…\n`));
422
674
  // Fire the next iteration immediately so the user doesn't have to type
423
675
  // a "proceed" message — the whole point of /goal is autonomy.
424
676
  ctx.repl.runAgentTurn(buildGoalKickoffPrompt(g, 'resume'));
@@ -444,13 +696,8 @@ export async function tryHandleWorkflowCommand(ctx) {
444
696
  if (!g)
445
697
  console.log(chalk.yellow('\nNo goal to update.\n'));
446
698
  else {
447
- // The user just raised (or rarely lowered) the cap — any stale
448
- // wrap-up steering message from the prior tight-budget state is
449
- // now misleading. Drop it; the post-turn loop will re-inject if
450
- // the new state still puts us on a final-budget turn.
451
- agent.removeTaggedSystemMessage('goal-budget-steering');
452
699
  agent.refreshSystemPrompt();
453
- console.log(chalk.green(`\n✓ Iteration budget set to ${g.budget.maxIterations} (${g.budget.iterationsUsed} already used).\n`));
700
+ console.log(chalk.green(`\n✓ Iteration budget set to ${formatBudget(g.budget.maxIterations)} (${g.budget.iterationsUsed} already used).\n`));
454
701
  }
455
702
  return true;
456
703
  }
@@ -466,10 +713,6 @@ export async function tryHandleWorkflowCommand(ctx) {
466
713
  console.log(chalk.yellow('\nNo goal to update.\n'));
467
714
  return true;
468
715
  }
469
- // Clear stale wrap-up steering — the budget state just changed
470
- // and any previously-injected "this is your last turn" directive
471
- // would now be misleading.
472
- agent.removeTaggedSystemMessage('goal-budget-steering');
473
716
  agent.refreshSystemPrompt();
474
717
  if (n === 0) {
475
718
  console.log(chalk.green('\n✓ Token budget cleared (iteration cap still applies).\n'));
@@ -529,10 +772,6 @@ export async function tryHandleWorkflowCommand(ctx) {
529
772
  console.log(chalk.yellow('\nNo goal to edit. Set one first with /goal <text>.\n'));
530
773
  }
531
774
  else {
532
- // Any edit may have changed the budget headroom; clear stale
533
- // wrap-up steering so subsequent turns aren't told "this is
534
- // your last turn" with stale data.
535
- agent.removeTaggedSystemMessage('goal-budget-steering');
536
775
  agent.refreshSystemPrompt();
537
776
  console.log(chalk.green(`\n✓ Updated.\n`));
538
777
  showStatus(g);
@@ -553,9 +792,26 @@ export async function tryHandleWorkflowCommand(ctx) {
553
792
  // GoalConflictError and prompt the user before overwriting; a
554
793
  // complete goal is replaced silently (the prior work is done, this
555
794
  // is just starting fresh and the prompt would be noise).
795
+ //
796
+ // Inline budget parsing: users naturally write "/goal do X. Budget 3
797
+ // iterations." and expect that to set the cap. Without parsing, the
798
+ // goal store falls back to the default (10) and the user is
799
+ // confused by "Budget: 10" in the kickoff banner. We extract the
800
+ // first `budget[:\s]+N[\s iterations?]?` pattern from the text,
801
+ // strip it, and pass N as the maxIterations option.
802
+ let parsedBudget;
803
+ let cleanedText = arg;
804
+ const budgetMatch = arg.match(/\bbudget[:\s]+(\d+)(?:\s*(?:iterations?|turns?|rounds?))?\.?/i);
805
+ if (budgetMatch) {
806
+ const n = Number(budgetMatch[1]);
807
+ if (Number.isFinite(n) && n >= 1 && n <= 200) {
808
+ parsedBudget = Math.floor(n);
809
+ cleanedText = arg.replace(budgetMatch[0], '').replace(/\s{2,}/g, ' ').trim();
810
+ }
811
+ }
556
812
  let goal;
557
813
  try {
558
- goal = setGoal(ws, arg, sk);
814
+ goal = setGoal(ws, cleanedText, sk, parsedBudget !== undefined ? { maxIterations: parsedBudget } : undefined);
559
815
  }
560
816
  catch (err) {
561
817
  if (err instanceof GoalTooLongError) {
@@ -567,15 +823,19 @@ export async function tryHandleWorkflowCommand(ctx) {
567
823
  const existing = err.existing;
568
824
  console.log(chalk.yellow(`\n⚠️ A goal is already ${existing.status.replace('_', ' ')}:`));
569
825
  console.log(` ${chalk.cyan(existing.text)}`);
570
- console.log(` ${chalk.gray(`${existing.budget.iterationsUsed}/${existing.budget.maxIterations} iterations used`)}`);
826
+ console.log(` ${chalk.gray(`${existing.budget.iterationsUsed}/${formatBudget(existing.budget.maxIterations)} iterations used`)}`);
571
827
  const confirmed = await askYesNo('Replace it with the new objective? (y/N) ', false);
572
828
  if (!confirmed) {
573
829
  console.log(chalk.gray('\nKeeping the current goal. Use `/goal edit text <new>` to change just the wording.\n'));
574
830
  return true;
575
831
  }
576
- // Force-replace.
832
+ // Force-replace. Use the cleaned text + parsed budget so the
833
+ // inline "Budget N" still applies on the second-try path.
577
834
  try {
578
- goal = setGoal(ws, arg, sk, { force: true });
835
+ goal = setGoal(ws, cleanedText, sk, {
836
+ force: true,
837
+ ...(parsedBudget !== undefined ? { maxIterations: parsedBudget } : {}),
838
+ });
579
839
  }
580
840
  catch (err2) {
581
841
  console.log(chalk.red(`\n✗ ${err2?.message ?? err2}\n`));
@@ -588,7 +848,39 @@ export async function tryHandleWorkflowCommand(ctx) {
588
848
  }
589
849
  agent.refreshSystemPrompt();
590
850
  console.log(chalk.green(`\n✓ Goal set: ${chalk.cyan(goal.text)}`));
591
- console.log(chalk.gray(`Budget: ${goal.budget.maxIterations} iterations. The CLI will auto-continue after each turn until the agent calls goal_complete / goal_blocked or you /goal pause | clear.`));
851
+ // Reconcile stale plan items from prior workflows. The plan store is
852
+ // sessionKey-scoped, so a leftover `[⏳]` from an abandoned
853
+ // /feature-dev run blocks goal_complete for an unrelated new goal —
854
+ // the plan-honesty guard correctly refuses, but the user is bitten
855
+ // by cross-contamination they didn't sign up for. Mirror what a
856
+ // smart agent does when context shifts: drop the orphan items and
857
+ // start the new objective with a fresh slate. The cleared items
858
+ // are printed so it's transparent, not silent.
859
+ try {
860
+ const existingPlan = readPlan(ws, sk);
861
+ const orphans = existingPlan.items.filter((i) => i.status !== 'completed');
862
+ if (orphans.length > 0) {
863
+ updatePlan(ws, { plan: [], explanation: `auto-cleared on new /goal: ${goal.text.slice(0, 80)}` }, sk);
864
+ console.log(chalk.yellow(`⚠️ Cleared ${orphans.length} stale plan item${orphans.length === 1 ? '' : 's'} from prior work:`));
865
+ for (const it of orphans.slice(0, 5)) {
866
+ const mark = it.status === 'in_progress' ? '⏳' : '☐';
867
+ console.log(chalk.gray(` ${mark} ${it.step.slice(0, 100)}`));
868
+ }
869
+ if (orphans.length > 5)
870
+ console.log(chalk.gray(` …and ${orphans.length - 5} more.`));
871
+ console.log(chalk.gray(` The agent can rebuild a new plan for this goal via update_plan.`));
872
+ }
873
+ }
874
+ catch {
875
+ // Plan reconciliation is best-effort — never fatal to the /goal flow.
876
+ }
877
+ {
878
+ const b = formatBudget(goal.budget.maxIterations);
879
+ const budgetLine = b === 'unlimited'
880
+ ? `Budget: unlimited (cap with "/goal budget N" or include "Budget N" in the goal text). The CLI auto-continues until the agent calls goal_complete / goal_blocked or you /goal pause | clear.`
881
+ : `Budget: ${b} iterations. The CLI auto-continues after each turn until the agent calls goal_complete / goal_blocked or you /goal pause | clear.`;
882
+ console.log(chalk.gray(budgetLine));
883
+ }
592
884
  console.log(chalk.gray('Kicking off iteration 1 now — type anything to cancel.\n'));
593
885
  // Fire the first turn immediately so /goal doesn't require a "proceed"
594
886
  // follow-up. The post-turn continuation loop in runAgentTurn handles
@@ -689,3 +981,28 @@ export async function tryHandleWorkflowCommand(ctx) {
689
981
  }
690
982
  return false;
691
983
  }
984
+ export function normalizeSkillsList(payload) {
985
+ const list = Array.isArray(payload)
986
+ ? payload
987
+ : Array.isArray(payload?.skills)
988
+ ? payload.skills
989
+ : Array.isArray(payload?.items)
990
+ ? payload.items
991
+ : Array.isArray(payload?.results)
992
+ ? payload.results
993
+ : undefined;
994
+ if (!Array.isArray(list))
995
+ return undefined;
996
+ return list
997
+ .filter((item) => item && typeof item === 'object' && typeof item.name === 'string')
998
+ .map((item) => {
999
+ const normalized = { name: item.name };
1000
+ if (typeof item.scope === 'string')
1001
+ normalized.scope = item.scope;
1002
+ if (typeof item.category === 'string')
1003
+ normalized.category = item.category;
1004
+ if (typeof item.description === 'string')
1005
+ normalized.description = item.description;
1006
+ return normalized;
1007
+ });
1008
+ }