kritya 0.8.45-beta → 0.8.47-beta
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/README.md +52 -20
- package/dist/agent/systemPrompt.js +3 -3
- package/dist/agent/workflow.js +492 -12
- package/dist/commands/registry.js +400 -44
- package/dist/headless.js +34 -0
- package/dist/ui/App.js +68 -14
- package/dist/ui/SelectList.js +30 -4
- package/dist/ui/StatusLine.js +2 -1
- package/dist/ui/useAgent.js +52 -8
- package/dist/ui/windowList.js +100 -0
- package/package.json +1 -1
|
@@ -10,15 +10,20 @@ import { scanPluginMcpServers } from "../plugins/mcp.js";
|
|
|
10
10
|
import { listProviders } from "../config/config.js";
|
|
11
11
|
import { expandCommand } from "./custom.js";
|
|
12
12
|
import { recordCommandUse } from "../ui/recentCommands.js";
|
|
13
|
-
import { artifactPath, clearProjectState, loadProjectState, parseIdea, parsePhase, phaseBlocker, phasePrompt, renameProject, staleArtifacts, PHASE_COMMAND, PHASE_ORDER, PHASE_SUMMARY,
|
|
13
|
+
import { artifactExists, artifactPath, BACK_COMMAND, clearProjectState, DEFAULT_GATES, listProjects, loadProjectState, MAX_REVISITS, nextPhase, parseIdea, parsePhase, phaseBlocker, phaseDoneMessage, phaseIndex, phasePrompt, planRun, prefillAfter, renameProject, resumeProject, revisitsRemaining, saveProjectState, shouldStopAfter, staleArtifacts, takeRevisit, PHASE_COMMAND, PHASE_ORDER, PHASE_SUMMARY, slugify, } from "../agent/workflow.js";
|
|
14
14
|
const WORKFLOW = "Workflow";
|
|
15
15
|
const SESSION = "Session";
|
|
16
16
|
const SAFETY = "Safety & limits";
|
|
17
17
|
const INFO = "Info";
|
|
18
18
|
export const BUILTIN_COMMANDS = [
|
|
19
|
+
{
|
|
20
|
+
name: "/flow",
|
|
21
|
+
description: "run the next workflow phase, or a stretch of them: /flow [--until <phase>|--auto]",
|
|
22
|
+
category: WORKFLOW,
|
|
23
|
+
},
|
|
19
24
|
{
|
|
20
25
|
name: "/flow-brainstorm",
|
|
21
|
-
description: "start a new-project workflow: /flow-brainstorm <idea> (brainstorm→spec→plan→build→review→fix)",
|
|
26
|
+
description: "start a new-project workflow: /flow-brainstorm <idea> (brainstorm→spec→plan→build→review→fix→ship)",
|
|
22
27
|
category: WORKFLOW,
|
|
23
28
|
},
|
|
24
29
|
{
|
|
@@ -33,7 +38,7 @@ export const BUILTIN_COMMANDS = [
|
|
|
33
38
|
},
|
|
34
39
|
{
|
|
35
40
|
name: "/flow-build",
|
|
36
|
-
description: "project workflow: implement the plan,
|
|
41
|
+
description: "project workflow: implement the plan, then review and fix it",
|
|
37
42
|
category: WORKFLOW,
|
|
38
43
|
},
|
|
39
44
|
{
|
|
@@ -46,9 +51,19 @@ export const BUILTIN_COMMANDS = [
|
|
|
46
51
|
description: "project workflow: fix the review's findings, re-verified",
|
|
47
52
|
category: WORKFLOW,
|
|
48
53
|
},
|
|
54
|
+
{
|
|
55
|
+
name: "/flow-ship",
|
|
56
|
+
description: "project workflow: run the tests and write the handover summary",
|
|
57
|
+
category: WORKFLOW,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: "/flow-back",
|
|
61
|
+
description: "rewind to a phase and re-run everything after it, when a finding belongs upstream",
|
|
62
|
+
category: WORKFLOW,
|
|
63
|
+
},
|
|
49
64
|
{
|
|
50
65
|
name: "/project",
|
|
51
|
-
description: "workflow status ·
|
|
66
|
+
description: "workflow status · list · resume <name> · goto <phase> · rename <name> · clear",
|
|
52
67
|
category: WORKFLOW,
|
|
53
68
|
},
|
|
54
69
|
{
|
|
@@ -162,6 +177,11 @@ Also: @path/to/file attaches a file to your message (with autocomplete).
|
|
|
162
177
|
@mcp:server/name attaches a document an MCP server offers.
|
|
163
178
|
MCP servers can also contribute their own /server-prompt commands.
|
|
164
179
|
Project memory: put standing instructions in KRITYA.md at your workspace root.
|
|
180
|
+
Project workflow: /flow-brainstorm <idea> starts one; /flow runs the next phase.
|
|
181
|
+
It stops for your approval after ${DEFAULT_GATES.join(", ")}, then runs the rest
|
|
182
|
+
through to ship. --auto for no stops · --until <phase> to stop early · --fast to
|
|
183
|
+
merge brainstorm and spec · ${BACK_COMMAND} <phase> when a finding belongs upstream.
|
|
184
|
+
Starting another project parks this one: /project list · /project resume <name>.
|
|
165
185
|
Keys: Esc cancels · Tab completes · Shift+Tab cycles normal/accept-edits/dry-run
|
|
166
186
|
mode · ↑/↓ recalls history · Ctrl+R searches history · Ctrl+P opens the command
|
|
167
187
|
palette · Ctrl+B copies the last code block · Ctrl+O toggles full tool output ·
|
|
@@ -170,7 +190,7 @@ Ctrl+K is the kill switch (stops everything until /kill off) · Ctrl+C exits`;
|
|
|
170
190
|
* A phase boundary is the one place compaction is unambiguously safe: the
|
|
171
191
|
* phase that just ended wrote its conclusions to a durable artifact on disk,
|
|
172
192
|
* so the transcript that produced them is redundant. Compacting here is what
|
|
173
|
-
* keeps a
|
|
193
|
+
* keeps a seven-phase project from dragging every phase's exploration into the
|
|
174
194
|
* next phase's context window.
|
|
175
195
|
*/
|
|
176
196
|
async function compactAtPhaseBoundary(ctx, phase) {
|
|
@@ -196,10 +216,48 @@ async function compactAtPhaseBoundary(ctx, phase) {
|
|
|
196
216
|
ctx.setActivity(null);
|
|
197
217
|
}
|
|
198
218
|
}
|
|
199
|
-
/**
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
219
|
+
/**
|
|
220
|
+
* Pull the workflow flags out of a command argument, leaving the user's own
|
|
221
|
+
* text behind. `--force` was the only one of these once, and it is still
|
|
222
|
+
* stripped before the prompt is built: it is an instruction to the command,
|
|
223
|
+
* not to the model, and leaving it in reads as part of the idea.
|
|
224
|
+
*/
|
|
225
|
+
function takeFlowFlags(arg) {
|
|
226
|
+
let rest = arg;
|
|
227
|
+
const take = (flag) => {
|
|
228
|
+
const re = new RegExp(`(^|\\s)${flag}(\\s|$)`);
|
|
229
|
+
if (!re.test(rest))
|
|
230
|
+
return false;
|
|
231
|
+
rest = rest.replace(re, " ");
|
|
232
|
+
return true;
|
|
233
|
+
};
|
|
234
|
+
const force = take("--force");
|
|
235
|
+
const auto = take("--auto");
|
|
236
|
+
// --fast merges brainstorm and spec into one stretch. They answer nearly the
|
|
237
|
+
// same question, and for a small project the second round trip buys nothing.
|
|
238
|
+
const fast = take("--fast");
|
|
239
|
+
const untilMatch = /(^|\s)--until[= ](\S+)/.exec(rest);
|
|
240
|
+
if (untilMatch) {
|
|
241
|
+
rest = rest.replace(/(^|\s)--until[= ]\S+/, " ");
|
|
242
|
+
const phase = parsePhase(untilMatch[2]);
|
|
243
|
+
if (!phase) {
|
|
244
|
+
return {
|
|
245
|
+
arg: rest.trim(),
|
|
246
|
+
force,
|
|
247
|
+
error: `Unknown phase "${untilMatch[2]}" for --until. Use one of: ${PHASE_ORDER.join(", ")}.`,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
return { arg: rest.trim(), force, until: phase };
|
|
251
|
+
}
|
|
252
|
+
if (/(^|\s)--until(\s|$)/.test(rest)) {
|
|
253
|
+
return { arg: rest.trim(), force, error: "Usage: --until <phase>" };
|
|
254
|
+
}
|
|
255
|
+
if (auto)
|
|
256
|
+
return { arg: rest.trim(), force, auto: true };
|
|
257
|
+
if (fast) {
|
|
258
|
+
return { arg: rest.trim(), force, gates: DEFAULT_GATES.filter((p) => p !== "brainstorm") };
|
|
259
|
+
}
|
|
260
|
+
return { arg: rest.trim(), force };
|
|
203
261
|
}
|
|
204
262
|
/** Strip a trailing/leading `--default` from a command argument. */
|
|
205
263
|
function takeDefault(arg) {
|
|
@@ -208,25 +266,29 @@ function takeDefault(arg) {
|
|
|
208
266
|
}
|
|
209
267
|
/**
|
|
210
268
|
* Run one workflow phase: check its prerequisite artifact exists, set the mode
|
|
211
|
-
* the phase needs, record the phase, compact, then hand the prompt to the
|
|
269
|
+
* the phase needs, record the phase, compact, then hand the prompt to the
|
|
270
|
+
* agent.
|
|
271
|
+
*
|
|
272
|
+
* The outcome is what stops a chain. A blocked phase has written nothing, and
|
|
273
|
+
* an incomplete one has written something half-finished — either way everything
|
|
274
|
+
* after it reads an artifact that isn't there yet.
|
|
212
275
|
*/
|
|
213
|
-
async function runPhase(ctx, phase) {
|
|
276
|
+
async function runPhase(ctx, phase, run) {
|
|
214
277
|
const project = loadProjectState(ctx.workspace);
|
|
215
278
|
if (!project) {
|
|
216
279
|
ctx.addItem({
|
|
217
280
|
kind: "info",
|
|
218
281
|
text: "No active project workflow. Start one with /flow-brainstorm <idea>.",
|
|
219
282
|
});
|
|
220
|
-
return;
|
|
283
|
+
return "blocked";
|
|
221
284
|
}
|
|
222
|
-
const { arg, force } = takeForce(ctx.arg);
|
|
223
285
|
const blocker = phaseBlocker(ctx.workspace, project.name, phase);
|
|
224
|
-
if (blocker && !force) {
|
|
286
|
+
if (blocker && !run.force) {
|
|
225
287
|
ctx.addItem({
|
|
226
288
|
kind: "info",
|
|
227
289
|
text: `${blocker}\nTo run ${PHASE_COMMAND[phase]} anyway: ${PHASE_COMMAND[phase]} --force`,
|
|
228
290
|
});
|
|
229
|
-
return;
|
|
291
|
+
return "blocked";
|
|
230
292
|
}
|
|
231
293
|
// Warn (never block) if an earlier artifact was edited after this phase's
|
|
232
294
|
// input was written from it — e.g. spec.md changed after plan.md already
|
|
@@ -256,13 +318,150 @@ async function runPhase(ctx, phase) {
|
|
|
256
318
|
text: `${phase} phase for "${project.name}" — ${PHASE_SUMMARY[phase]}` +
|
|
257
319
|
(artifact ? ` → ${artifact}` : "") +
|
|
258
320
|
(wantPlanMode ? " (plan mode ON: read-only apart from that doc)" : "") +
|
|
259
|
-
(blocker && force ? "\n⚠ Forced past a missing prerequisite." : ""),
|
|
321
|
+
(blocker && run.force ? "\n⚠ Forced past a missing prerequisite." : ""),
|
|
260
322
|
});
|
|
261
|
-
ctx.addItem({ kind: "user", text: ctx.raw.trim() });
|
|
262
323
|
ctx.setRunningPhase(phase);
|
|
263
324
|
ctx.refreshWorkflow();
|
|
264
325
|
await compactAtPhaseBoundary(ctx, phase);
|
|
265
|
-
|
|
326
|
+
await ctx.runAgent(phasePrompt(project.name, phase, run.arg, { stopAfter: run.stopAfter }));
|
|
327
|
+
// A turn that runs out of steps just ends, and the message it appends reads
|
|
328
|
+
// like model output. The agent flags it, so a chain can tell "the phase
|
|
329
|
+
// finished" from "the phase gave up mid-sentence" — which matters because
|
|
330
|
+
// the next phase reads whatever it managed to write. Subagents already
|
|
331
|
+
// report this (`stoppedEarly: "max-steps"`); phases were treating a
|
|
332
|
+
// truncated run as a finished one.
|
|
333
|
+
if (ctx.agent.hitStepLimit)
|
|
334
|
+
return "incomplete";
|
|
335
|
+
return "ok";
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Run a stretch of phases back to back from one command.
|
|
339
|
+
*
|
|
340
|
+
* `planRun` decides how far the stretch goes. Stopping between build, review
|
|
341
|
+
* and fix would ask "shall I now review what I just built?" — a round trip
|
|
342
|
+
* that buys no decision, because by then the work is already done. Stopping
|
|
343
|
+
* after brainstorm, spec and plan does buy one: those are cheap to redo and
|
|
344
|
+
* expensive to get wrong.
|
|
345
|
+
*/
|
|
346
|
+
async function runFlow(ctx, from, req) {
|
|
347
|
+
// An `until` behind the start phase can never fire — shouldStopAfter matches
|
|
348
|
+
// on equality — so the run would quietly do the opposite of what was asked
|
|
349
|
+
// and execute every remaining phase. A *typo* is caught when the flag is
|
|
350
|
+
// parsed; a phase that is merely behind is not, and this is the one place
|
|
351
|
+
// that knows both ends, so it is checked here rather than in the parser.
|
|
352
|
+
if (req.until && PHASE_ORDER.indexOf(req.until) < PHASE_ORDER.indexOf(from)) {
|
|
353
|
+
ctx.addItem({
|
|
354
|
+
kind: "info",
|
|
355
|
+
text: `--until ${req.until} is behind ${from}, the phase this run starts at, so it would ` +
|
|
356
|
+
`never stop and every remaining phase would run. Pick ${from} or a later phase, ` +
|
|
357
|
+
`or use --auto to say that you do want the whole stretch.`,
|
|
358
|
+
});
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
const phases = planRun(from, req);
|
|
362
|
+
const chained = phases.length > 1;
|
|
363
|
+
// One command, so one user line — not one per phase of the stretch.
|
|
364
|
+
ctx.addItem({ kind: "user", text: ctx.raw.trim() });
|
|
365
|
+
if (chained) {
|
|
366
|
+
ctx.addItem({ kind: "info", text: `Running ${phases.length} phases: ${phases.join(" → ")}` });
|
|
367
|
+
ctx.setChained(true);
|
|
368
|
+
}
|
|
369
|
+
// The last phase that ran, and whether the chain was cut short. A chain that
|
|
370
|
+
// reaches its end still has to say so — see the announcement after the loop.
|
|
371
|
+
let lastRan = null;
|
|
372
|
+
let stoppedEarly = false;
|
|
373
|
+
try {
|
|
374
|
+
for (const [i, phase] of phases.entries()) {
|
|
375
|
+
const outcome = await runPhase(ctx, phase, {
|
|
376
|
+
arg: i === 0 ? req.arg : "",
|
|
377
|
+
force: req.force === true,
|
|
378
|
+
stopAfter: shouldStopAfter(phase, req),
|
|
379
|
+
});
|
|
380
|
+
if (outcome === "ok") {
|
|
381
|
+
lastRan = phase;
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
// A phase that did not finish stops the chain, because everything after
|
|
385
|
+
// it reads the artifact it did not (or only half) wrote. Say so: without
|
|
386
|
+
// this the run just goes quiet after the blocker message, and the user
|
|
387
|
+
// has to work out for themselves that phases they were promised never
|
|
388
|
+
// happened.
|
|
389
|
+
stoppedEarly = true;
|
|
390
|
+
const abandoned = phases.slice(i + 1);
|
|
391
|
+
if (outcome === "incomplete") {
|
|
392
|
+
ctx.addItem({
|
|
393
|
+
kind: "info",
|
|
394
|
+
text: `${phase} stopped at the ${ctx.agent.maxSteps}-step limit before it finished, so ` +
|
|
395
|
+
`whatever it wrote is incomplete — and the phases after it read that.` +
|
|
396
|
+
(abandoned.length ? ` ${abandoned.join(", ")} did not run.` : "") +
|
|
397
|
+
`\nSend "continue" to finish ${phase}, then /flow to carry on from there.`,
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
else if (abandoned.length) {
|
|
401
|
+
ctx.addItem({
|
|
402
|
+
kind: "info",
|
|
403
|
+
text: `Chain stopped at ${phase} — ${abandoned.join(", ")} did not run. ` +
|
|
404
|
+
`Clear the blocker above, then /flow to carry on from here.`,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
finally {
|
|
411
|
+
// Only a chain ever sets this, so only a chain releases it — a single-phase
|
|
412
|
+
// command has nothing to suppress and should not touch the flag at all.
|
|
413
|
+
if (chained)
|
|
414
|
+
ctx.setChained(false);
|
|
415
|
+
}
|
|
416
|
+
// Announce the end of a chain here, because nothing else can. Each phase's
|
|
417
|
+
// own turn teardown runs *while* the chain flag is still set — it cannot tell
|
|
418
|
+
// "middle of a chain" from "chain just ended at a gate", so it stays quiet for
|
|
419
|
+
// both. This is the only place that knows the loop is over, and a chain is
|
|
420
|
+
// exactly where the handoff matters most: the user has just watched two
|
|
421
|
+
// phases run, so the next step should be one keypress, not a retyped command.
|
|
422
|
+
// A chain that stopped early has already explained itself above.
|
|
423
|
+
if (chained && !stoppedEarly && lastRan) {
|
|
424
|
+
ctx.addItem({ kind: "info", text: phaseDoneMessage(lastRan) });
|
|
425
|
+
const prefill = prefillAfter(lastRan);
|
|
426
|
+
if (prefill)
|
|
427
|
+
ctx.setPrefill(prefill);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
/** The standard handler for a single phase command: /flow-spec, /flow-build, … */
|
|
431
|
+
function phaseCommand(phase) {
|
|
432
|
+
return async (ctx) => {
|
|
433
|
+
const req = takeFlowFlags(ctx.arg);
|
|
434
|
+
if (req.error) {
|
|
435
|
+
ctx.addItem({ kind: "info", text: req.error });
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
return runFlow(ctx, phase, req);
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* `/project list` — every project this workspace knows, newest first.
|
|
443
|
+
*
|
|
444
|
+
* The active one is starred, and its phase is read from the live pointer rather
|
|
445
|
+
* than the registry so the two can never disagree on screen.
|
|
446
|
+
*/
|
|
447
|
+
function projectListText(workspace) {
|
|
448
|
+
const rows = listProjects(workspace);
|
|
449
|
+
if (!rows.length) {
|
|
450
|
+
return "No projects yet. Start one with /flow-brainstorm <idea>.";
|
|
451
|
+
}
|
|
452
|
+
const width = Math.max(...rows.map((r) => r.name.length));
|
|
453
|
+
const lines = rows.map((r) => {
|
|
454
|
+
const marker = r.active ? "*" : " ";
|
|
455
|
+
const date = r.updatedAt ? ` ${r.updatedAt.slice(0, 10)}` : "";
|
|
456
|
+
const loops = r.revisits ? ` ${r.revisits} loop-back(s)` : "";
|
|
457
|
+
return (` ${marker} ${r.name.padEnd(width)} ${r.phase.padEnd(9)} ` +
|
|
458
|
+
`(${phaseIndex(r.phase)}/${PHASE_ORDER.length})${date}${loops}`);
|
|
459
|
+
});
|
|
460
|
+
const active = rows.find((r) => r.active);
|
|
461
|
+
return (`Projects (* = active):\n${lines.join("\n")}\n\n` +
|
|
462
|
+
(active
|
|
463
|
+
? `/project resume <name> switches to another one — "${active.name}" keeps its phase.`
|
|
464
|
+
: `Nothing is active. /project resume <name> picks one up where it stopped.`));
|
|
266
465
|
}
|
|
267
466
|
const handlers = {
|
|
268
467
|
"/help": (ctx) => {
|
|
@@ -623,13 +822,20 @@ const handlers = {
|
|
|
623
822
|
`the change.${trailerInstruction} Do not push. Show the final commit hash and message.`);
|
|
624
823
|
},
|
|
625
824
|
"/flow-brainstorm": async (ctx) => {
|
|
626
|
-
const
|
|
825
|
+
const req = takeFlowFlags(ctx.arg);
|
|
826
|
+
if (req.error) {
|
|
827
|
+
ctx.addItem({ kind: "info", text: req.error });
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
const input = req.arg;
|
|
627
831
|
const existing = loadProjectState(ctx.workspace);
|
|
628
832
|
if (!existing && !input) {
|
|
629
833
|
ctx.addItem({
|
|
630
834
|
kind: "info",
|
|
631
835
|
text: `Usage: /flow-brainstorm <your project idea>. Starts the ${PHASE_ORDER.join(" → ")} workflow.\n` +
|
|
632
|
-
`Name it yourself with a short prefix: /flow-brainstorm reverser: a script that reverses a string
|
|
836
|
+
`Name it yourself with a short prefix: /flow-brainstorm reverser: a script that reverses a string\n` +
|
|
837
|
+
`Flags: --fast (run brainstorm and spec as one stretch) · --auto (no stops) · ` +
|
|
838
|
+
`--until <phase> · --force`,
|
|
633
839
|
});
|
|
634
840
|
return;
|
|
635
841
|
}
|
|
@@ -643,8 +849,8 @@ const handlers = {
|
|
|
643
849
|
ctx.addItem({
|
|
644
850
|
kind: "info",
|
|
645
851
|
text: `Starting a new project "${name}". The previous project "${existing.name}" was in the ` +
|
|
646
|
-
`${existing.phase} phase
|
|
647
|
-
`/project
|
|
852
|
+
`${existing.phase} phase — its docs/${existing.name}/ artifacts are untouched, and ` +
|
|
853
|
+
`/project resume ${existing.name} picks it back up there.`,
|
|
648
854
|
});
|
|
649
855
|
}
|
|
650
856
|
saveProjectState(ctx.workspace, name, "brainstorm");
|
|
@@ -653,13 +859,117 @@ const handlers = {
|
|
|
653
859
|
ctx.agent.planMode = false;
|
|
654
860
|
ctx.setPlanMode(false);
|
|
655
861
|
}
|
|
656
|
-
ctx.addItem({ kind: "user", text: ctx.raw.trim() });
|
|
657
|
-
ctx.setRunningPhase("brainstorm");
|
|
658
862
|
ctx.refreshWorkflow();
|
|
659
|
-
//
|
|
660
|
-
//
|
|
661
|
-
|
|
662
|
-
|
|
863
|
+
// The idea belongs to the brainstorm phase; later phases in a --fast stretch
|
|
864
|
+
// read its artifact rather than the raw idea again.
|
|
865
|
+
return runFlow(ctx, "brainstorm", { ...req, arg: idea });
|
|
866
|
+
},
|
|
867
|
+
"/flow": async (ctx) => {
|
|
868
|
+
const project = loadProjectState(ctx.workspace);
|
|
869
|
+
if (!project) {
|
|
870
|
+
ctx.addItem({
|
|
871
|
+
kind: "info",
|
|
872
|
+
text: "No active project workflow. Start one with /flow-brainstorm <idea>.",
|
|
873
|
+
});
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
const req = takeFlowFlags(ctx.arg);
|
|
877
|
+
if (req.error) {
|
|
878
|
+
ctx.addItem({ kind: "info", text: req.error });
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
// The stored phase is the one that last ran, so continuing means the next
|
|
882
|
+
// one — re-running what just finished is never what "continue" means.
|
|
883
|
+
const from = nextPhase(project.phase);
|
|
884
|
+
if (!from) {
|
|
885
|
+
ctx.addItem({
|
|
886
|
+
kind: "info",
|
|
887
|
+
text: `"${project.name}" has run every phase (last: ${project.phase}). ` +
|
|
888
|
+
`Run /project clear to end the workflow, or ${BACK_COMMAND} <phase> to redo one.`,
|
|
889
|
+
});
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
// A revisit the fix phase recorded means a finding was never really closed.
|
|
893
|
+
// Carrying on forward would run the rest of the workflow on top of a
|
|
894
|
+
// requirement that is known to be wrong, so name it here rather than let
|
|
895
|
+
// the user discover it by reading /project.
|
|
896
|
+
const revisit = project.revisit;
|
|
897
|
+
if (revisit && PHASE_ORDER.indexOf(revisit.to) < PHASE_ORDER.indexOf(from)) {
|
|
898
|
+
ctx.addItem({
|
|
899
|
+
kind: "info",
|
|
900
|
+
text: `↩ ${revisit.to} is still flagged as needing a change — ${revisit.reason}\n` +
|
|
901
|
+
`Run ${BACK_COMMAND} to redo it and everything after, or carry on anyway with ` +
|
|
902
|
+
`${PHASE_COMMAND[from]}.`,
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
return runFlow(ctx, from, req);
|
|
906
|
+
},
|
|
907
|
+
"/flow-back": async (ctx) => {
|
|
908
|
+
const project = loadProjectState(ctx.workspace);
|
|
909
|
+
if (!project) {
|
|
910
|
+
ctx.addItem({
|
|
911
|
+
kind: "info",
|
|
912
|
+
text: "No active project workflow. Start one with /flow-brainstorm <idea>.",
|
|
913
|
+
});
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
const req = takeFlowFlags(ctx.arg);
|
|
917
|
+
if (req.error) {
|
|
918
|
+
ctx.addItem({ kind: "info", text: req.error });
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
// Bare /flow-back acts on whatever the fix phase recorded; naming a phase
|
|
922
|
+
// works whether or not anything was recorded.
|
|
923
|
+
const recorded = project.revisit;
|
|
924
|
+
const target = parsePhase(req.arg) ?? recorded?.to ?? null;
|
|
925
|
+
if (!target) {
|
|
926
|
+
ctx.addItem({
|
|
927
|
+
kind: "info",
|
|
928
|
+
text: `Usage: ${BACK_COMMAND} <${PHASE_ORDER.join("|")}> — rewind to a phase and re-run everything after it.\n` +
|
|
929
|
+
`Nothing upstream was recorded either, so name the phase you want to redo.`,
|
|
930
|
+
});
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
if (PHASE_ORDER.indexOf(target) >= PHASE_ORDER.indexOf(project.phase)) {
|
|
934
|
+
ctx.addItem({
|
|
935
|
+
kind: "info",
|
|
936
|
+
text: `${BACK_COMMAND} rewinds, and ${target} is not before the current phase ` +
|
|
937
|
+
`(${project.phase}). Use ${PHASE_COMMAND[target]} to move forward.`,
|
|
938
|
+
});
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
if (revisitsRemaining(ctx.workspace) <= 0) {
|
|
942
|
+
ctx.addItem({
|
|
943
|
+
kind: "info",
|
|
944
|
+
text: `"${project.name}" has already looped back ${MAX_REVISITS} time(s), which is the limit — ` +
|
|
945
|
+
`another automatic pass would be guessing at what you want. Fix what you can by hand, ` +
|
|
946
|
+
`then /project goto ${target} and ${PHASE_COMMAND[target]} if you still want to redo it.`,
|
|
947
|
+
});
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
if (!takeRevisit(ctx.workspace, target)) {
|
|
951
|
+
ctx.addItem({ kind: "info", text: `Could not rewind "${project.name}".` });
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
ctx.addItem({
|
|
955
|
+
kind: "info",
|
|
956
|
+
text: `↩ Rewinding to ${target}` +
|
|
957
|
+
(recorded && recorded.to === target ? ` — ${recorded.reason}` : "") +
|
|
958
|
+
`. Everything after it re-runs, because it all reads what ${target} writes.`,
|
|
959
|
+
});
|
|
960
|
+
ctx.refreshWorkflow();
|
|
961
|
+
// Rewinding exists to regenerate what the wrong phase produced, and to put
|
|
962
|
+
// the user back where they were — not to carry on past it. So it stops at
|
|
963
|
+
// the phase the project had reached, unless the user asked for a longer or
|
|
964
|
+
// shorter run with --auto / --until.
|
|
965
|
+
//
|
|
966
|
+
// `arg` is blanked deliberately: it held the phase name the user typed
|
|
967
|
+
// (`/flow-back spec`), and passing it on would inject the literal string
|
|
968
|
+
// "spec" into the phase prompt as if it were user input.
|
|
969
|
+
const options = { ...req, arg: "" };
|
|
970
|
+
if (!req.auto && !req.until)
|
|
971
|
+
options.until = project.phase;
|
|
972
|
+
return runFlow(ctx, target, options);
|
|
663
973
|
},
|
|
664
974
|
// `/plan` (no `flow-` prefix) is pure plan-mode control — on/off or a bare
|
|
665
975
|
// toggle — and never touches the project workflow. `/flow-plan` runs the
|
|
@@ -684,15 +994,45 @@ const handlers = {
|
|
|
684
994
|
: "Plan mode OFF — the agent can make changes again.",
|
|
685
995
|
});
|
|
686
996
|
},
|
|
687
|
-
"/flow-spec": (
|
|
688
|
-
"/flow-plan": (
|
|
689
|
-
"/flow-build": (
|
|
690
|
-
"/flow-review": (
|
|
691
|
-
"/flow-fix": (
|
|
997
|
+
"/flow-spec": phaseCommand("spec"),
|
|
998
|
+
"/flow-plan": phaseCommand("plan"),
|
|
999
|
+
"/flow-build": phaseCommand("build"),
|
|
1000
|
+
"/flow-review": phaseCommand("review"),
|
|
1001
|
+
"/flow-fix": phaseCommand("fix"),
|
|
1002
|
+
"/flow-ship": phaseCommand("ship"),
|
|
692
1003
|
"/project": (ctx) => {
|
|
693
1004
|
const [sub = "", ...rest] = ctx.arg.trim().split(/\s+/);
|
|
1005
|
+
const verb = sub.toLowerCase();
|
|
694
1006
|
const project = loadProjectState(ctx.workspace);
|
|
695
|
-
|
|
1007
|
+
// `list` and `resume` are the two subcommands that have to work with no
|
|
1008
|
+
// active project — that is exactly the state they exist for. Everything
|
|
1009
|
+
// below this point assumes a project is active.
|
|
1010
|
+
if (verb === "list") {
|
|
1011
|
+
ctx.addItem({ kind: "info", text: projectListText(ctx.workspace) });
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
if (verb === "resume") {
|
|
1015
|
+
const name = rest.join(" ").trim();
|
|
1016
|
+
if (!name) {
|
|
1017
|
+
ctx.addItem({ kind: "info", text: "Usage: /project resume <name>. See /project list." });
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
const result = resumeProject(ctx.workspace, name);
|
|
1021
|
+
if (!result.ok) {
|
|
1022
|
+
ctx.addItem({ kind: "info", text: result.error });
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
ctx.refreshWorkflow();
|
|
1026
|
+
const resumed = loadProjectState(ctx.workspace);
|
|
1027
|
+
ctx.addItem({
|
|
1028
|
+
kind: "info",
|
|
1029
|
+
text: `Resumed "${result.name}" at the ${resumed?.phase ?? "?"} phase` +
|
|
1030
|
+
(resumed ? ` (${phaseIndex(resumed.phase)}/${PHASE_ORDER.length})` : "") +
|
|
1031
|
+
`. /flow continues from here.`,
|
|
1032
|
+
});
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
if (verb === "clear") {
|
|
696
1036
|
if (!project) {
|
|
697
1037
|
ctx.addItem({ kind: "info", text: "No active project workflow." });
|
|
698
1038
|
return;
|
|
@@ -701,18 +1041,20 @@ const handlers = {
|
|
|
701
1041
|
ctx.refreshWorkflow();
|
|
702
1042
|
ctx.addItem({
|
|
703
1043
|
kind: "info",
|
|
704
|
-
text: `Workflow for "${project.name}" ended. Its docs/${project.name}/ artifacts are kept
|
|
1044
|
+
text: `Workflow for "${project.name}" ended. Its docs/${project.name}/ artifacts are kept, ` +
|
|
1045
|
+
`and /project resume ${project.name} brings it back at the ${project.phase} phase.`,
|
|
705
1046
|
});
|
|
706
1047
|
return;
|
|
707
1048
|
}
|
|
708
1049
|
if (!project) {
|
|
709
1050
|
ctx.addItem({
|
|
710
1051
|
kind: "info",
|
|
711
|
-
text: "No active project workflow. Start one with /flow-brainstorm <idea
|
|
1052
|
+
text: "No active project workflow. Start one with /flow-brainstorm <idea>, " +
|
|
1053
|
+
"or /project list to resume a saved one.",
|
|
712
1054
|
});
|
|
713
1055
|
return;
|
|
714
1056
|
}
|
|
715
|
-
if (
|
|
1057
|
+
if (verb === "rename") {
|
|
716
1058
|
const result = renameProject(ctx.workspace, project.name, rest.join(" "));
|
|
717
1059
|
if (!result.ok) {
|
|
718
1060
|
ctx.addItem({ kind: "info", text: result.error });
|
|
@@ -725,7 +1067,7 @@ const handlers = {
|
|
|
725
1067
|
});
|
|
726
1068
|
return;
|
|
727
1069
|
}
|
|
728
|
-
if (
|
|
1070
|
+
if (verb === "goto") {
|
|
729
1071
|
const target = parsePhase(rest.join(" "));
|
|
730
1072
|
if (!target) {
|
|
731
1073
|
ctx.addItem({ kind: "info", text: `Usage: /project goto <${PHASE_ORDER.join("|")}>` });
|
|
@@ -735,25 +1077,39 @@ const handlers = {
|
|
|
735
1077
|
ctx.refreshWorkflow();
|
|
736
1078
|
ctx.addItem({
|
|
737
1079
|
kind: "info",
|
|
738
|
-
text: `"${project.name}" set to the ${target} phase. Run ${PHASE_COMMAND[target]} to start it.`,
|
|
1080
|
+
text: `"${project.name}" set to the ${target} phase. Run ${PHASE_COMMAND[target]} to start it, or /flow to continue from here.`,
|
|
739
1081
|
});
|
|
740
1082
|
return;
|
|
741
1083
|
}
|
|
742
|
-
const lines = PHASE_ORDER.map((p) => {
|
|
1084
|
+
const lines = PHASE_ORDER.map((p, i) => {
|
|
743
1085
|
const marker = p === project.phase ? "*" : " ";
|
|
744
1086
|
const artifact = artifactPath(project.name, p);
|
|
745
|
-
|
|
1087
|
+
// build has no document of its own, so there is nothing to look for.
|
|
1088
|
+
const written = artifact !== null && artifactExists(ctx.workspace, project.name, p);
|
|
1089
|
+
// A recorded scorecard is what the phase actually concluded; PHASE_SUMMARY
|
|
1090
|
+
// is the generic description of what the phase *does*, so it is only the
|
|
1091
|
+
// fallback — for a phase that hasn't run, or one run before summaries
|
|
1092
|
+
// existed.
|
|
1093
|
+
const recorded = project.summaries?.[p];
|
|
1094
|
+
return (` ${marker} ${String(i + 1).padStart(2)}. ${p.padEnd(11)}${recorded ?? PHASE_SUMMARY[p]}` +
|
|
1095
|
+
`\n ${artifact ?? "(application code)"}${written ? " ✓" : ""}`);
|
|
746
1096
|
});
|
|
747
1097
|
const stale = staleArtifacts(ctx.workspace, project.name, project.phase);
|
|
748
1098
|
const staleBlock = stale.length ? `\n\n${stale.join("\n")}` : "";
|
|
1099
|
+
const revisitBlock = project.revisit
|
|
1100
|
+
? `\n\n↩ ${project.revisit.to} was flagged as needing a change: ${project.revisit.reason}` +
|
|
1101
|
+
`\n ${BACK_COMMAND} redoes it and everything after it.`
|
|
1102
|
+
: "";
|
|
749
1103
|
ctx.addItem({
|
|
750
1104
|
kind: "info",
|
|
751
|
-
text: `Project "${project.name}" — ${project.phase} phase` +
|
|
1105
|
+
text: `Project "${project.name}" — ${project.phase} phase ` +
|
|
1106
|
+
`(${phaseIndex(project.phase)}/${PHASE_ORDER.length})` +
|
|
752
1107
|
(project.updatedAt ? ` (since ${project.updatedAt.slice(0, 10)})` : "") +
|
|
753
1108
|
`\n\n${lines.join("\n")}` +
|
|
754
1109
|
staleBlock +
|
|
755
|
-
|
|
756
|
-
|
|
1110
|
+
revisitBlock +
|
|
1111
|
+
`\n\n/flow continues · ${PHASE_COMMAND[project.phase]} re-runs this phase · ` +
|
|
1112
|
+
`/project goto <phase> to move · rename <name> · list · resume <name> · clear to end it.`,
|
|
757
1113
|
});
|
|
758
1114
|
},
|
|
759
1115
|
"/diff": (ctx) => {
|
package/dist/headless.js
CHANGED
|
@@ -25,6 +25,26 @@ import { gatedContentHash, isTrusted } from "./trust/trust.js";
|
|
|
25
25
|
import { partitionByTrust, serverFingerprint, trustServer } from "./trust/mcpTrust.js";
|
|
26
26
|
import { installCrashHandlers } from "./crash.js";
|
|
27
27
|
import { defaultSandboxMode } from "./shell/sandbox.js";
|
|
28
|
+
/**
|
|
29
|
+
* Whether a `--prompt` value looks like a slash command.
|
|
30
|
+
*
|
|
31
|
+
* Slash commands are dispatched by the interactive UI, which owns the command
|
|
32
|
+
* layer; headless mode has none, so a `/flow-brainstorm ...` prompt was handed
|
|
33
|
+
* to the model as literal text. The model then improvised the workflow from the
|
|
34
|
+
* system prompt — which looks like it worked and does not: no gate stops, no
|
|
35
|
+
* per-phase modes, no artifact bookkeeping, and it runs until it hits the step
|
|
36
|
+
* limit. Exported so the check itself is testable without spawning a process
|
|
37
|
+
* (the e2e suite that would otherwise cover it needs a real CLI run).
|
|
38
|
+
*
|
|
39
|
+
* Deliberately narrow: `/` + a command word + end-or-whitespace. A plain-language
|
|
40
|
+
* prompt can legitimately begin with a path — "/home/me notes.txt summarize" —
|
|
41
|
+
* and refusing that is a false positive against exactly the input this check's
|
|
42
|
+
* own error message invites. The giveaway is the second `/`: a command name has
|
|
43
|
+
* no further slash, a path has several.
|
|
44
|
+
*/
|
|
45
|
+
export function isSlashCommandPrompt(prompt) {
|
|
46
|
+
return /^\/[a-z][a-z0-9-]*(\s|$)/.test(prompt.trimStart());
|
|
47
|
+
}
|
|
28
48
|
function emit(args, r) {
|
|
29
49
|
if (args.output === "json") {
|
|
30
50
|
console.log(JSON.stringify(r));
|
|
@@ -65,6 +85,20 @@ function approveHeadlessMcp(servers, trust, source) {
|
|
|
65
85
|
export async function runHeadless(args) {
|
|
66
86
|
const startedAt = Date.now();
|
|
67
87
|
const workspace = path.resolve(args.dir);
|
|
88
|
+
// Refuse rather than silently do something else. See isSlashCommandPrompt.
|
|
89
|
+
if (isSlashCommandPrompt(args.prompt)) {
|
|
90
|
+
return finish(args, startedAt, {
|
|
91
|
+
success: false,
|
|
92
|
+
result: "",
|
|
93
|
+
error: `--prompt starts with "/", which is a slash command. Headless mode does not run ` +
|
|
94
|
+
`slash commands — it sends the text to the model, which would improvise the task ` +
|
|
95
|
+
`instead of following the command. Run the command in the interactive UI, or ` +
|
|
96
|
+
`describe the task in plain language here.`,
|
|
97
|
+
toolCalls: [],
|
|
98
|
+
usage: { promptTokens: 0, completionTokens: 0, cachedPromptTokens: 0, estimated: false },
|
|
99
|
+
durationMs: 0,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
68
102
|
// Only the user's own global .env is unconditionally trusted; see below.
|
|
69
103
|
loadDotEnv([path.join(CONFIG_DIR, ".env")]);
|
|
70
104
|
// There's no terminal to show the interactive trust prompt, so headless
|