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.
@@ -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, saveProjectState, slugify, } from "../agent/workflow.js";
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, with tests",
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 · /project goto <phase> · rename <name> · clear",
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 five-phase project from dragging every phase's exploration into the
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
- /** Strip a trailing/leading `--force` from a command argument. */
200
- function takeForce(arg) {
201
- const force = /(^|\s)--force(\s|$)/.test(arg);
202
- return { arg: arg.replace(/(^|\s)--force(\s|$)/, " ").trim(), force };
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 agent.
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
- return ctx.runAgent(phasePrompt(project.name, phase, arg));
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 input = ctx.arg.trim();
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; its docs/${existing.name}/ artifacts are untouched, and ` +
647
- `/project goto ${existing.phase} after /flow-brainstorm ${existing.name} returns to it.`,
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
- // A no-op at the start of a session; earns its keep when a second project
660
- // is started in a session that already ran one.
661
- await compactAtPhaseBoundary(ctx, "brainstorm");
662
- return ctx.runAgent(phasePrompt(name, "brainstorm", idea));
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": (ctx) => runPhase(ctx, "spec"),
688
- "/flow-plan": (ctx) => runPhase(ctx, "plan"),
689
- "/flow-build": (ctx) => runPhase(ctx, "build"),
690
- "/flow-review": (ctx) => runPhase(ctx, "review"),
691
- "/flow-fix": (ctx) => runPhase(ctx, "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
- if (sub.toLowerCase() === "clear") {
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 (sub.toLowerCase() === "rename") {
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 (sub.toLowerCase() === "goto") {
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
- return ` ${marker} ${p.padEnd(11)}${PHASE_SUMMARY[p]}\n ${artifact ?? "(application code)"}`;
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
- `\n\nRun ${PHASE_COMMAND[project.phase]} to continue · /project goto <phase> to move · ` +
756
- `/project rename <name> · /project clear to end the workflow.`,
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