@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
|
@@ -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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
241
|
-
const
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|