@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
@@ -3,4 +3,22 @@
3
3
  * Hand-tune imports if the compiler complains.
4
4
  */
5
5
  import type { CommandContext } from './_context.js';
6
+ /**
7
+ * Decide whether `/grill-me` should refuse to fire because the current
8
+ * workflow already has a written `spec.md`. The clarifying pass is meant to
9
+ * happen BEFORE the spec is committed — once a spec exists, asking again
10
+ * usually means we're re-litigating answers the user already gave, which
11
+ * wastes a turn. `--force` is the explicit escape hatch when the user
12
+ * genuinely wants a second clarifying pass (e.g., scope has drifted).
13
+ *
14
+ * Exported helper for unit tests so the guard logic can be exercised
15
+ * without standing up the whole REPL context. NOT pure: reads workflow
16
+ * state from disk (`getCurrentWorkflow`, `readArtifact`) and the latter
17
+ * may mkdirSync the workflow folder as a side effect.
18
+ */
19
+ export declare function shouldSkipGrillMe(workspaceRoot: string, force: boolean, sessionKey?: string): {
20
+ skip: boolean;
21
+ slug?: string;
22
+ specPath?: string;
23
+ };
6
24
  export declare function tryHandleWorkflowCommand(ctx: CommandContext): Promise<boolean>;
@@ -7,12 +7,12 @@ 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';
@@ -20,6 +20,62 @@ import { SLASH_TO_SKILL } from '../../prompt/skillRunner.js';
20
20
  import { buildGoalKickoffPrompt, runSkillByName, runSkillCommand } from './_helpers.js';
21
21
  // Promise-flavored exec for case bodies that shell out.
22
22
  const execPromise = promisify(exec);
23
+ /**
24
+ * Decide whether `/grill-me` should refuse to fire because the current
25
+ * workflow already has a written `spec.md`. The clarifying pass is meant to
26
+ * happen BEFORE the spec is committed — once a spec exists, asking again
27
+ * usually means we're re-litigating answers the user already gave, which
28
+ * wastes a turn. `--force` is the explicit escape hatch when the user
29
+ * genuinely wants a second clarifying pass (e.g., scope has drifted).
30
+ *
31
+ * Exported helper for unit tests so the guard logic can be exercised
32
+ * without standing up the whole REPL context. NOT pure: reads workflow
33
+ * state from disk (`getCurrentWorkflow`, `readArtifact`) and the latter
34
+ * may mkdirSync the workflow folder as a side effect.
35
+ */
36
+ export function shouldSkipGrillMe(workspaceRoot, force, sessionKey) {
37
+ if (force)
38
+ return { skip: false };
39
+ // 9d-bugfix: scope the "is there an active workflow?" check to THIS
40
+ // session, not the workspace pointer. A fresh CLI with no session
41
+ // binding should not be told "plan already exists" just because a
42
+ // previous CLI ran `/spec` here.
43
+ const slug = getCurrentWorkflow(workspaceRoot, sessionKey);
44
+ if (!slug)
45
+ return { skip: false };
46
+ const spec = readArtifact(workspaceRoot, slug, ARTIFACT.spec);
47
+ if (!spec)
48
+ return { skip: false };
49
+ return {
50
+ skip: true,
51
+ slug,
52
+ specPath: artifactRelativePath(workspaceRoot, slug, ARTIFACT.spec),
53
+ };
54
+ }
55
+ /**
56
+ * Strip a `--force` token from a slash command's arg list, returning the
57
+ * flag's presence plus the rest. Used by /feature-dev / /spec / /review
58
+ * to gate Subtask 6's clobber prompt (mirrors /grill-me's --force parsing).
59
+ */
60
+ function parseForceFlag(args) {
61
+ return { force: args.includes('--force'), rest: args.filter((a) => a !== '--force') };
62
+ }
63
+ /**
64
+ * Print the one-line confirmation banner after a successful `/workflow
65
+ * switch <slug>` (or a no-op switch onto the already-current workflow).
66
+ * Format: `Switched to workflow <slug> — goal: <status>, iteration N of cap`
67
+ * — or `goal: —` when no goal is bound.
68
+ */
69
+ function printWorkflowSwitchConfirmation(slug, goal) {
70
+ if (!goal) {
71
+ console.log(chalk.green(`\n✓ Switched to workflow "${slug}" — goal: —.\n`));
72
+ return;
73
+ }
74
+ const statusLabel = goal.status.replace('_', ' ');
75
+ const iter = goal.budget.iterationsUsed;
76
+ const cap = formatBudget(goal.budget.maxIterations);
77
+ console.log(chalk.green(`\n✓ Switched to workflow "${slug}" — goal: ${statusLabel}, iteration ${iter} of ${cap}.\n`));
78
+ }
23
79
  export async function tryHandleWorkflowCommand(ctx) {
24
80
  const { command, args, agent, mcpClient, config, rl, repl } = ctx;
25
81
  // 'ctx' alias to keep references to the old ReplContext name working
@@ -27,7 +83,7 @@ export async function tryHandleWorkflowCommand(ctx) {
27
83
  switch (command) {
28
84
  case '/skills':
29
85
  {
30
- const spinner = ora(chalk.gray('Fetching skills...')).start();
86
+ const spinner = makeSpinner(chalk.gray('Fetching skills...')).start();
31
87
  try {
32
88
  const res = await callMcpTool(mcpClient, 'list_skills', { scope: 'all' });
33
89
  spinner.stop();
@@ -60,7 +116,7 @@ export async function tryHandleWorkflowCommand(ctx) {
60
116
  for (const tool of LOCAL_TOOLS) {
61
117
  console.log(` ${chalk.cyan(tool.name)} - ${tool.description}`);
62
118
  }
63
- const spinner = ora(chalk.gray('Fetching MCP tools...')).start();
119
+ const spinner = makeSpinner(chalk.gray('Fetching MCP tools...')).start();
64
120
  try {
65
121
  const res = await mcpClient.listTools();
66
122
  spinner.stop();
@@ -84,13 +140,31 @@ export async function tryHandleWorkflowCommand(ctx) {
84
140
  }
85
141
  case '/plan':
86
142
  {
143
+ // `/plan clear` is the explicit escape hatch when stale items from a
144
+ // prior workflow are blocking goal_complete (the plan-honesty guard
145
+ // refuses to complete with open items). `/goal <text>` also
146
+ // auto-clears, but this lets the user reset without setting a new
147
+ // goal — useful mid-session if you just abandoned a workflow.
148
+ if (args[0] === 'clear') {
149
+ const before = readPlan(agent.workspaceRoot, agent.sessionKey);
150
+ const pendingCount = before.items.filter((i) => i.status !== 'completed').length;
151
+ updatePlan(agent.workspaceRoot, { plan: [], explanation: 'cleared by /plan clear' }, agent.sessionKey);
152
+ console.log(chalk.green(`\n✓ Plan cleared.`));
153
+ if (pendingCount > 0) {
154
+ console.log(chalk.gray(` Removed ${pendingCount} pending item${pendingCount === 1 ? '' : 's'}.\n`));
155
+ }
156
+ else {
157
+ console.log();
158
+ }
159
+ return true;
160
+ }
87
161
  const state = readPlan(agent.workspaceRoot, agent.sessionKey);
88
162
  console.log(chalk.bold('\nPlan:'));
89
163
  console.log(chalk.gray(formatPlan(state)));
90
164
  if (state.updatedAt) {
91
165
  console.log(chalk.gray(`Updated: ${state.updatedAt}`));
92
166
  }
93
- console.log();
167
+ console.log(chalk.gray('\nSubcommands: /plan | /plan clear\n'));
94
168
  return true;
95
169
  }
96
170
  case '/diff':
@@ -138,7 +212,7 @@ export async function tryHandleWorkflowCommand(ctx) {
138
212
  // nothing to commit. The actual commit work goes through ctx.repl.runAgentTurn
139
213
  // so it inherits the normal pipeline: isProcessing locking, goal
140
214
  // continuation, /raw honoring, contradiction surfacing, token summary.
141
- const spinner = ora(chalk.gray('Checking git status...')).start();
215
+ const spinner = makeSpinner(chalk.gray('Checking git status...')).start();
142
216
  let statusOut = '';
143
217
  let diffOut = '';
144
218
  try {
@@ -165,12 +239,16 @@ export async function tryHandleWorkflowCommand(ctx) {
165
239
  }
166
240
  case '/feature-dev':
167
241
  {
168
- const feature = args.join(' ').trim();
242
+ // `--force` accepted but ignored — workflows no longer carry goals,
243
+ // so there's nothing to "clobber" when starting a new one. Kept on
244
+ // the CLI for back-compat with any user muscle memory / scripts.
245
+ const parsed = parseForceFlag(args);
246
+ const feature = parsed.rest.join(' ').trim();
169
247
  if (!feature) {
170
248
  console.log(chalk.red('\nUsage: /feature-dev <feature description>\n'));
171
249
  break;
172
250
  }
173
- const meta = createWorkflow(agent.workspaceRoot, { title: feature, kind: 'feature-dev' });
251
+ const meta = createWorkflow(agent.workspaceRoot, { title: feature, kind: 'feature-dev', sessionKey: agent.sessionKey });
174
252
  const specPath = artifactRelativePath(agent.workspaceRoot, meta.slug, ARTIFACT.spec);
175
253
  const tasksPath = artifactRelativePath(agent.workspaceRoot, meta.slug, ARTIFACT.tasks);
176
254
  console.log(chalk.gray(`Workflow folder: ${path.dirname(specPath)}`));
@@ -209,14 +287,50 @@ export async function tryHandleWorkflowCommand(ctx) {
209
287
  ].join('\n'), ctx.repl.runAgentTurn);
210
288
  return true;
211
289
  }
290
+ case '/grill-me':
291
+ {
292
+ // `/grill-me` doesn't render its own picker — it just nudges the model
293
+ // (via the CLARIFY-mode overlay in systemPrompt.ts) to ask 2–5
294
+ // questions back instead of jumping to implementation tools. The
295
+ // picker UI lives in cliPrompt.ts and stays untouched.
296
+ const force = args.includes('--force');
297
+ const task = args.filter((a) => a !== '--force').join(' ').trim();
298
+ if (!task) {
299
+ console.log(chalk.red('\nUsage: /grill-me [--force] <task description>\n'));
300
+ return true;
301
+ }
302
+ const decision = shouldSkipGrillMe(agent.workspaceRoot, force, agent.sessionKey);
303
+ if (decision.skip) {
304
+ console.log(chalk.yellow(`\nPlan already exists at ${chalk.cyan(decision.specPath)}.`));
305
+ console.log(chalk.gray(` Drop into it with \`/workflow switch ${decision.slug}\`, or use \`/grill-me --force\` to clarify additional details.\n`));
306
+ return true;
307
+ }
308
+ // Latch activeSkill BEFORE refreshing the system prompt so the
309
+ // CLARIFY overlay lands in chatHistory[0]. The post-turn hook in
310
+ // repl.ts clears activeSkill + refreshes again, so the overlay
311
+ // doesn't bleed into the user's next plain prompt.
312
+ agent.activeSkill = 'grill-me';
313
+ agent.refreshSystemPrompt();
314
+ const prompt = [
315
+ '[CLARIFY — grill-me]',
316
+ '',
317
+ `The user wants help with: ${task}`,
318
+ '',
319
+ '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.',
320
+ ].join('\n');
321
+ ctx.repl.runAgentTurn(prompt);
322
+ return true;
323
+ }
212
324
  case '/spec':
213
325
  {
214
- const feature = args.join(' ').trim();
326
+ // `--force` accepted but ignored — see /feature-dev for rationale.
327
+ const parsed = parseForceFlag(args);
328
+ const feature = parsed.rest.join(' ').trim();
215
329
  if (!feature) {
216
330
  console.log(chalk.red('\nUsage: /spec <feature title>\n'));
217
331
  break;
218
332
  }
219
- const meta = createWorkflow(agent.workspaceRoot, { title: feature, kind: 'spec' });
333
+ const meta = createWorkflow(agent.workspaceRoot, { title: feature, kind: 'spec', sessionKey: agent.sessionKey });
220
334
  const specPath = artifactRelativePath(agent.workspaceRoot, meta.slug, ARTIFACT.spec);
221
335
  console.log(chalk.gray(`Workflow folder: ${path.dirname(specPath)}`));
222
336
  await runSkillCommand(agent, mcpClient, '/spec', feature, [
@@ -237,8 +351,11 @@ export async function tryHandleWorkflowCommand(ctx) {
237
351
  }
238
352
  case '/review':
239
353
  {
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' });
354
+ // `--force` accepted but ignored see /feature-dev for rationale.
355
+ const parsed = parseForceFlag(args);
356
+ const scope = parsed.rest.join(' ').trim() || 'current unstaged and staged changes (git diff HEAD)';
357
+ const reviewTitle = `Review: ${scope}`;
358
+ const meta = createWorkflow(agent.workspaceRoot, { title: reviewTitle, kind: 'review', sessionKey: agent.sessionKey });
242
359
  const reportPath = artifactRelativePath(agent.workspaceRoot, meta.slug, 'review.md');
243
360
  console.log(chalk.gray(`Workflow folder: ${path.dirname(reportPath)}`));
244
361
  await runSkillCommand(agent, mcpClient, command, scope, [
@@ -268,8 +385,8 @@ export async function tryHandleWorkflowCommand(ctx) {
268
385
  }
269
386
  // Attach this execution turn to the current workflow if there is one, so
270
387
  // 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;
388
+ const currentSlug = getCurrentWorkflow(agent.workspaceRoot, agent.sessionKey);
389
+ const slug = currentSlug ?? createWorkflow(agent.workspaceRoot, { title: next.step, kind: 'implement-plan', sessionKey: agent.sessionKey }).slug;
273
390
  const walkPath = artifactRelativePath(agent.workspaceRoot, slug, ARTIFACT.walkthrough);
274
391
  console.log(chalk.gray(`Workflow folder: ${path.dirname(walkPath)}`));
275
392
  await runSkillCommand(agent, mcpClient, command, `Next plan item: "${next.step}"`, [
@@ -289,7 +406,7 @@ export async function tryHandleWorkflowCommand(ctx) {
289
406
  }
290
407
  case '/approve':
291
408
  {
292
- const slug = args[0] || getCurrentWorkflow(agent.workspaceRoot);
409
+ const slug = args[0] || getCurrentWorkflow(agent.workspaceRoot, agent.sessionKey);
293
410
  if (!slug) {
294
411
  console.log(chalk.red('\nNo current workflow. Use /spec or /feature-dev first, or /approve <slug>.\n'));
295
412
  return true;
@@ -317,6 +434,109 @@ export async function tryHandleWorkflowCommand(ctx) {
317
434
  `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
435
  return true;
319
436
  }
437
+ case '/workflow':
438
+ {
439
+ // Subcommands: switch <slug> | pause | resume <slug>
440
+ // The plural `/workflows` (next case) is the list command. Singular
441
+ // `/workflow` carries actions on the current pointer / a named slug.
442
+ const sub = (args[0] ?? '').toLowerCase();
443
+ if (!sub) {
444
+ console.log(chalk.red('\nUsage: /workflow switch <slug> | pause | resume <slug>\n'));
445
+ return true;
446
+ }
447
+ if (sub === 'switch') {
448
+ const rawSlug = (args[1] ?? '').trim();
449
+ if (!rawSlug) {
450
+ console.log(chalk.red('\nUsage: /workflow switch <slug>\n'));
451
+ console.log(chalk.gray(' See /workflows for available slugs.\n'));
452
+ return true;
453
+ }
454
+ // Canonicalize at the entry point. Without this, a user typing
455
+ // `/workflow switch My Workflow` (or any title-cased / spaced
456
+ // variant) would have the raw string written verbatim into the
457
+ // pointer file by setCurrentWorkflow, breaking `w.slug ===
458
+ // currentSlug` matching everywhere downstream (the ★ marker
459
+ // on /workflows, the "already on it" no-op below, etc.).
460
+ const targetSlug = slugify(rawSlug);
461
+ if (!workflowExists(agent.workspaceRoot, targetSlug)) {
462
+ console.log(chalk.red(`\nNo such workflow: "${rawSlug}".`));
463
+ console.log(chalk.gray(' Use /workflows to see what exists, or /spec / /feature-dev to create a new one.\n'));
464
+ return true;
465
+ }
466
+ if (getCurrentWorkflow(agent.workspaceRoot, agent.sessionKey) === targetSlug) {
467
+ // Already on it — print the same banner as if we'd switched so
468
+ // the user gets a consistent confirmation instead of "no-op".
469
+ const g = readGoal(agent.workspaceRoot, agent.sessionKey);
470
+ printWorkflowSwitchConfirmation(targetSlug, g);
471
+ return true;
472
+ }
473
+ // Post-decoupling (`/workflow switch` is now pure navigation):
474
+ // - Updates the session-scoped workflow pointer so subsequent
475
+ // `/spec` / `/feature-dev` / `/implement-plan` calls in THIS
476
+ // session land artifacts under <targetSlug>'s folder.
477
+ // - Does NOT touch the session's goal. Goal is session-scoped
478
+ // runtime state, workflows are durable storage — see
479
+ // goalStore.ts:resolveGoalScope for the design rationale.
480
+ // - Earlier migration / conflict prompt (planWorkflowSwitch +
481
+ // migrateSessionGoalToWorkflow + applyMigrationResolution) is
482
+ // gone with the workflow-goal storage that motivated it.
483
+ setCurrentWorkflow(agent.workspaceRoot, targetSlug, agent.sessionKey);
484
+ agent.refreshSystemPrompt();
485
+ const sessionGoal = readGoal(agent.workspaceRoot, agent.sessionKey);
486
+ printWorkflowSwitchConfirmation(targetSlug, sessionGoal);
487
+ return true;
488
+ }
489
+ if (sub === 'pause') {
490
+ // `/workflow pause` is now an alias for `/goal pause` — workflows
491
+ // don't carry their own goal anymore, so "pause the workflow's
492
+ // goal" is just "pause the session's goal" which is what
493
+ // pauseGoal already does.
494
+ const g = pauseGoal(agent.workspaceRoot, agent.sessionKey);
495
+ if (!g) {
496
+ console.log(chalk.yellow('\nNo active goal to pause. Use /goal <text> to set one.\n'));
497
+ return true;
498
+ }
499
+ agent.refreshSystemPrompt();
500
+ const titlePreview = g.text.length > 60 ? g.text.slice(0, 60) + '…' : g.text;
501
+ const slug = getCurrentWorkflow(agent.workspaceRoot, agent.sessionKey);
502
+ const wfLabel = slug ? ` (in workflow "${slug}")` : '';
503
+ console.log(chalk.yellow(`\n⏸ Paused goal${wfLabel}: ${titlePreview}.`));
504
+ console.log(chalk.gray(' /goal resume to continue this goal later.\n'));
505
+ return true;
506
+ }
507
+ if (sub === 'resume') {
508
+ const rawSlug = (args[1] ?? '').trim();
509
+ if (!rawSlug) {
510
+ console.log(chalk.red('\nUsage: /workflow resume <slug>\n'));
511
+ return true;
512
+ }
513
+ // Canonicalize for the same reason as /workflow switch (see above).
514
+ const targetSlug = slugify(rawSlug);
515
+ if (!workflowExists(agent.workspaceRoot, targetSlug)) {
516
+ console.log(chalk.red(`\nNo such workflow: "${rawSlug}".\n`));
517
+ return true;
518
+ }
519
+ // `/workflow resume <slug>` is now sugar for "switch artifacts
520
+ // to <slug>'s folder, then resume the session's paused goal if
521
+ // there is one." Workflows no longer carry goals; resume is a
522
+ // goal-only operation that just happens to want a workflow set
523
+ // first so artifact writes land in the right place.
524
+ setCurrentWorkflow(agent.workspaceRoot, targetSlug, agent.sessionKey);
525
+ const resumed = resumeGoal(agent.workspaceRoot, agent.sessionKey);
526
+ agent.refreshSystemPrompt();
527
+ if (!resumed) {
528
+ console.log(chalk.green(`\n▶ Switched to workflow "${targetSlug}" — no paused session goal to resume.\n`));
529
+ console.log(chalk.gray(` Set a fresh /goal here when you're ready.\n`));
530
+ return true;
531
+ }
532
+ console.log(chalk.green(`\n▶ Switched to workflow "${targetSlug}" and resumed goal (${resumed.budget.iterationsUsed}/${formatBudget(resumed.budget.maxIterations)} used). Firing next iteration…\n`));
533
+ ctx.repl.runAgentTurn(buildGoalKickoffPrompt(resumed, 'resume'));
534
+ return true;
535
+ }
536
+ console.log(chalk.red(`\nUnknown /workflow subcommand: "${sub}".`));
537
+ console.log(chalk.gray(' Subcommands: switch <slug> | pause | resume <slug>\n'));
538
+ return true;
539
+ }
320
540
  case '/workflows':
321
541
  {
322
542
  const workflows = listWorkflows(agent.workspaceRoot);
@@ -325,14 +545,22 @@ export async function tryHandleWorkflowCommand(ctx) {
325
545
  console.log(chalk.yellow(' (none yet — try /spec or /feature-dev)'));
326
546
  }
327
547
  else {
328
- const currentSlug = getCurrentWorkflow(agent.workspaceRoot);
548
+ const currentSlug = getCurrentWorkflow(agent.workspaceRoot, agent.sessionKey);
329
549
  for (const w of workflows) {
330
- const marker = w.slug === currentSlug ? chalk.green(' ← current') : '';
550
+ // Subtask 4: current-pointer marker is now (the spec's chosen
551
+ // glyph). Existing column structure on the first/second lines
552
+ // preserved so script readers don't break — the new goal column
553
+ // lands at the right of the artifact-markers line. The ★
554
+ // reflects THIS session's binding (9d-bugfix), so two CLIs in
555
+ // the same workspace can each see their own bound workflow.
556
+ const marker = w.slug === currentSlug ? chalk.green(' ★') : '';
331
557
  console.log(` ${chalk.cyan(w.slug)} [${chalk.gray(w.status)}] ${chalk.gray(w.kind)}${marker}`);
332
558
  console.log(` ${w.title}`);
333
559
  const hasSpec = !!readArtifact(agent.workspaceRoot, w.slug, ARTIFACT.spec);
334
560
  const hasTasks = !!readArtifact(agent.workspaceRoot, w.slug, ARTIFACT.tasks);
335
561
  const hasWalk = !!readArtifact(agent.workspaceRoot, w.slug, ARTIFACT.walkthrough);
562
+ // Workflows are pure artifact folders now — no goal column.
563
+ // Goal state lives at session scope only; see goalStore.ts.
336
564
  console.log(chalk.gray(` spec.md:${hasSpec ? '✓' : '·'} tasks.md:${hasTasks ? '✓' : '·'} walkthrough.md:${hasWalk ? '✓' : '·'}`));
337
565
  }
338
566
  }
@@ -358,6 +586,13 @@ export async function tryHandleWorkflowCommand(ctx) {
358
586
  case '/goal':
359
587
  {
360
588
  const arg = args.join(' ').trim();
589
+ // Eager session resolve — without this, the FIRST /goal of a new
590
+ // CLI session writes goal.json under the deterministic fallback
591
+ // sessionKey, but every later runTurn reads from the
592
+ // MCP-resolved UUID key. Split-brain: kickoff banner shows the
593
+ // new goal, the agent reads a stale one from a different file.
594
+ // ensureInitialized is idempotent and tolerates missing MCP.
595
+ await agent.ensureInitialized();
361
596
  const ws = agent.workspaceRoot;
362
597
  const sk = agent.sessionKey;
363
598
  const showStatus = (g) => {
@@ -376,7 +611,7 @@ export async function tryHandleWorkflowCommand(ctx) {
376
611
  console.log(chalk.bold('\nGoal'));
377
612
  console.log(` Status: ${status}`);
378
613
  console.log(` Outcome: ${chalk.cyan(g.text)}`);
379
- console.log(` Iterations: ${g.budget.iterationsUsed}/${g.budget.maxIterations} used`);
614
+ console.log(` Iterations: ${g.budget.iterationsUsed}/${formatBudget(g.budget.maxIterations)} used`);
380
615
  if (g.budget.maxTokens) {
381
616
  console.log(` Tokens: ${(g.budget.tokensUsed ?? 0).toLocaleString()}/${g.budget.maxTokens.toLocaleString()} used`);
382
617
  }
@@ -413,12 +648,8 @@ export async function tryHandleWorkflowCommand(ctx) {
413
648
  console.log(chalk.yellow('\nNo goal to resume.\n'));
414
649
  return true;
415
650
  }
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
651
  agent.refreshSystemPrompt();
421
- console.log(chalk.green(`\n▶ Goal resumed (${g.budget.iterationsUsed}/${g.budget.maxIterations} used). Starting next iteration…\n`));
652
+ console.log(chalk.green(`\n▶ Goal resumed (${g.budget.iterationsUsed}/${formatBudget(g.budget.maxIterations)} used). Starting next iteration…\n`));
422
653
  // Fire the next iteration immediately so the user doesn't have to type
423
654
  // a "proceed" message — the whole point of /goal is autonomy.
424
655
  ctx.repl.runAgentTurn(buildGoalKickoffPrompt(g, 'resume'));
@@ -444,13 +675,8 @@ export async function tryHandleWorkflowCommand(ctx) {
444
675
  if (!g)
445
676
  console.log(chalk.yellow('\nNo goal to update.\n'));
446
677
  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
678
  agent.refreshSystemPrompt();
453
- console.log(chalk.green(`\n✓ Iteration budget set to ${g.budget.maxIterations} (${g.budget.iterationsUsed} already used).\n`));
679
+ console.log(chalk.green(`\n✓ Iteration budget set to ${formatBudget(g.budget.maxIterations)} (${g.budget.iterationsUsed} already used).\n`));
454
680
  }
455
681
  return true;
456
682
  }
@@ -466,10 +692,6 @@ export async function tryHandleWorkflowCommand(ctx) {
466
692
  console.log(chalk.yellow('\nNo goal to update.\n'));
467
693
  return true;
468
694
  }
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
695
  agent.refreshSystemPrompt();
474
696
  if (n === 0) {
475
697
  console.log(chalk.green('\n✓ Token budget cleared (iteration cap still applies).\n'));
@@ -529,10 +751,6 @@ export async function tryHandleWorkflowCommand(ctx) {
529
751
  console.log(chalk.yellow('\nNo goal to edit. Set one first with /goal <text>.\n'));
530
752
  }
531
753
  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
754
  agent.refreshSystemPrompt();
537
755
  console.log(chalk.green(`\n✓ Updated.\n`));
538
756
  showStatus(g);
@@ -553,9 +771,26 @@ export async function tryHandleWorkflowCommand(ctx) {
553
771
  // GoalConflictError and prompt the user before overwriting; a
554
772
  // complete goal is replaced silently (the prior work is done, this
555
773
  // is just starting fresh and the prompt would be noise).
774
+ //
775
+ // Inline budget parsing: users naturally write "/goal do X. Budget 3
776
+ // iterations." and expect that to set the cap. Without parsing, the
777
+ // goal store falls back to the default (10) and the user is
778
+ // confused by "Budget: 10" in the kickoff banner. We extract the
779
+ // first `budget[:\s]+N[\s iterations?]?` pattern from the text,
780
+ // strip it, and pass N as the maxIterations option.
781
+ let parsedBudget;
782
+ let cleanedText = arg;
783
+ const budgetMatch = arg.match(/\bbudget[:\s]+(\d+)(?:\s*(?:iterations?|turns?|rounds?))?\.?/i);
784
+ if (budgetMatch) {
785
+ const n = Number(budgetMatch[1]);
786
+ if (Number.isFinite(n) && n >= 1 && n <= 200) {
787
+ parsedBudget = Math.floor(n);
788
+ cleanedText = arg.replace(budgetMatch[0], '').replace(/\s{2,}/g, ' ').trim();
789
+ }
790
+ }
556
791
  let goal;
557
792
  try {
558
- goal = setGoal(ws, arg, sk);
793
+ goal = setGoal(ws, cleanedText, sk, parsedBudget !== undefined ? { maxIterations: parsedBudget } : undefined);
559
794
  }
560
795
  catch (err) {
561
796
  if (err instanceof GoalTooLongError) {
@@ -567,15 +802,19 @@ export async function tryHandleWorkflowCommand(ctx) {
567
802
  const existing = err.existing;
568
803
  console.log(chalk.yellow(`\n⚠️ A goal is already ${existing.status.replace('_', ' ')}:`));
569
804
  console.log(` ${chalk.cyan(existing.text)}`);
570
- console.log(` ${chalk.gray(`${existing.budget.iterationsUsed}/${existing.budget.maxIterations} iterations used`)}`);
805
+ console.log(` ${chalk.gray(`${existing.budget.iterationsUsed}/${formatBudget(existing.budget.maxIterations)} iterations used`)}`);
571
806
  const confirmed = await askYesNo('Replace it with the new objective? (y/N) ', false);
572
807
  if (!confirmed) {
573
808
  console.log(chalk.gray('\nKeeping the current goal. Use `/goal edit text <new>` to change just the wording.\n'));
574
809
  return true;
575
810
  }
576
- // Force-replace.
811
+ // Force-replace. Use the cleaned text + parsed budget so the
812
+ // inline "Budget N" still applies on the second-try path.
577
813
  try {
578
- goal = setGoal(ws, arg, sk, { force: true });
814
+ goal = setGoal(ws, cleanedText, sk, {
815
+ force: true,
816
+ ...(parsedBudget !== undefined ? { maxIterations: parsedBudget } : {}),
817
+ });
579
818
  }
580
819
  catch (err2) {
581
820
  console.log(chalk.red(`\n✗ ${err2?.message ?? err2}\n`));
@@ -588,7 +827,39 @@ export async function tryHandleWorkflowCommand(ctx) {
588
827
  }
589
828
  agent.refreshSystemPrompt();
590
829
  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.`));
830
+ // Reconcile stale plan items from prior workflows. The plan store is
831
+ // sessionKey-scoped, so a leftover `[⏳]` from an abandoned
832
+ // /feature-dev run blocks goal_complete for an unrelated new goal —
833
+ // the plan-honesty guard correctly refuses, but the user is bitten
834
+ // by cross-contamination they didn't sign up for. Mirror what a
835
+ // smart agent does when context shifts: drop the orphan items and
836
+ // start the new objective with a fresh slate. The cleared items
837
+ // are printed so it's transparent, not silent.
838
+ try {
839
+ const existingPlan = readPlan(ws, sk);
840
+ const orphans = existingPlan.items.filter((i) => i.status !== 'completed');
841
+ if (orphans.length > 0) {
842
+ updatePlan(ws, { plan: [], explanation: `auto-cleared on new /goal: ${goal.text.slice(0, 80)}` }, sk);
843
+ console.log(chalk.yellow(`⚠️ Cleared ${orphans.length} stale plan item${orphans.length === 1 ? '' : 's'} from prior work:`));
844
+ for (const it of orphans.slice(0, 5)) {
845
+ const mark = it.status === 'in_progress' ? '⏳' : '☐';
846
+ console.log(chalk.gray(` ${mark} ${it.step.slice(0, 100)}`));
847
+ }
848
+ if (orphans.length > 5)
849
+ console.log(chalk.gray(` …and ${orphans.length - 5} more.`));
850
+ console.log(chalk.gray(` The agent can rebuild a new plan for this goal via update_plan.`));
851
+ }
852
+ }
853
+ catch {
854
+ // Plan reconciliation is best-effort — never fatal to the /goal flow.
855
+ }
856
+ {
857
+ const b = formatBudget(goal.budget.maxIterations);
858
+ const budgetLine = b === 'unlimited'
859
+ ? `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.`
860
+ : `Budget: ${b} iterations. The CLI auto-continues after each turn until the agent calls goal_complete / goal_blocked or you /goal pause | clear.`;
861
+ console.log(chalk.gray(budgetLine));
862
+ }
592
863
  console.log(chalk.gray('Kicking off iteration 1 now — type anything to cancel.\n'));
593
864
  // Fire the first turn immediately so /goal doesn't require a "proceed"
594
865
  // follow-up. The post-turn continuation loop in runAgentTurn handles