kritya 0.8.44-beta → 0.8.46-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.
@@ -3,7 +3,7 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { gitStatusShort } from "../git/git.js";
5
5
  import { buildSkillsSection, defaultExtraSkillRoots } from "./skills.js";
6
- import { artifactPath, loadProjectState, PHASE_COMMAND, PHASE_ORDER, PHASE_SUMMARY, } from "./workflow.js";
6
+ import { artifactPath, BACK_COMMAND, DEFAULT_GATES, loadProjectState, PHASE_COMMAND, PHASE_ORDER, PHASE_SUMMARY, } from "./workflow.js";
7
7
  const MEMORY_FILES = ["KRITYA.md"];
8
8
  const MEMORY_MAX_CHARS = 4000;
9
9
  /**
@@ -103,13 +103,13 @@ You help with software engineering tasks: writing code, fixing bugs, explaining
103
103
  - You are rendering into a terminal: prefer bullets to tables, keep any table to 3 columns or fewer with short cells, and never use <br> or other HTML inside them.
104
104
 
105
105
  # Project workflow (new projects)
106
- When the user asks to create a NEW project or app (a FastAPI backend, a Next.js frontend, a CLI, etc.), do not jump straight to code. Run this ${PHASE_ORDER.length}-phase workflow, writing a durable artifact for each phase and STOPPING for the user's approval before advancing:
106
+ When the user asks to create a NEW project or app (a FastAPI backend, a Next.js frontend, a CLI, etc.), do not jump straight to code. Run this ${PHASE_ORDER.length}-phase workflow, writing a durable artifact for each phase:
107
107
  ${PHASE_ORDER.map((p, i) => {
108
108
  const artifact = artifactPath("<name>", p) ?? "the application code";
109
109
  return ` ${i + 1}. ${p.padEnd(11)}-> ${artifact.padEnd(28)}(${PHASE_SUMMARY[p]})`;
110
110
  }).join("\n")}
111
111
  Each phase reads the artifact immediately before it and does not redo that phase's work: the spec owns requirements, contracts and numbered acceptance criteria; the plan owns architecture and the milestone order, citing criteria by number rather than restating them. Keep artifacts dense — every later phase pays to read them.
112
- Track state in .kritya/project.json ({ "name", "phase", "updatedAt" }): read it at the start of a turn to resume at the right phase, and update "phase" (with write_file) when you advance. After writing a phase's artifact, summarize it and ask the user to approve — never advance past a phase on your own. The user may also drive phases manually with ${PHASE_ORDER.map((p) => PHASE_COMMAND[p]).join(", ")}; when they do, that command sets the phase for you.
112
+ Track state in .kritya/project.json ({ "name", "phase", "updatedAt" }): read it at the start of a turn to resume at the right phase, and update "phase" (with write_file) when you advance. Stop and ask the user to approve after ${DEFAULT_GATES.join(", ")} — those are the phases where changing course is still cheap. From build onward the run continues without asking, so carry straight on through the phases the command asked for. The user may also drive phases manually with ${PHASE_ORDER.map((p) => PHASE_COMMAND[p]).join(", ")}; when they do, that command sets the phase for you. If a finding cannot be fixed because the requirement or the design is wrong rather than the code, add a "revisit" field ({ "to": "spec" or "plan", "reason": "<one line>" }) instead of working around it, and tell the user to run ${BACK_COMMAND}.
113
113
  ${memory}${buildSkillsSection(workspace, defaultExtraSkillRoots(workspace, trustWorkspace), trustWorkspace)}
114
114
  # Environment
115
115
  - OS: ${os.platform()} (${os.release()})
@@ -7,7 +7,44 @@ export const PHASE_ORDER = [
7
7
  "build",
8
8
  "review",
9
9
  "fix",
10
+ "ship",
10
11
  ];
12
+ /**
13
+ * Phases the workflow stops after, unless the user overrides it.
14
+ *
15
+ * Everything up to `plan` is cheap to redo and expensive to get wrong, so each
16
+ * one hands back to the user. From `build` on the money is already spent —
17
+ * stopping to ask "shall I now review what I just built?" adds a round trip
18
+ * without adding a decision, so those run through.
19
+ */
20
+ export const DEFAULT_GATES = ["brainstorm", "spec", "plan"];
21
+ /** 1-based position of `phase` in the workflow, for progress display. */
22
+ export function phaseIndex(phase) {
23
+ return PHASE_ORDER.indexOf(phase) + 1;
24
+ }
25
+ /** Whether a run should hand back to the user after `phase`. */
26
+ export function shouldStopAfter(phase, opts = {}) {
27
+ if (opts.until)
28
+ return phase === opts.until;
29
+ if (opts.auto)
30
+ return false;
31
+ return (opts.gates ?? DEFAULT_GATES).includes(phase);
32
+ }
33
+ /**
34
+ * The phases a run starting at `from` will actually execute, in order.
35
+ *
36
+ * Pure, so the gate policy is testable without running anything: this is the
37
+ * list the command layer walks.
38
+ */
39
+ export function planRun(from, opts = {}) {
40
+ const run = [];
41
+ for (let phase = from; phase; phase = nextPhase(phase)) {
42
+ run.push(phase);
43
+ if (shouldStopAfter(phase, opts))
44
+ break;
45
+ }
46
+ return run;
47
+ }
11
48
  /** The slash command that runs each phase, for user-facing guidance. */
12
49
  export const PHASE_COMMAND = {
13
50
  brainstorm: "/flow-brainstorm",
@@ -16,7 +53,14 @@ export const PHASE_COMMAND = {
16
53
  build: "/flow-build",
17
54
  review: "/flow-review",
18
55
  fix: "/flow-fix",
56
+ ship: "/flow-ship",
19
57
  };
58
+ /**
59
+ * Rewind to a phase and re-run everything after it, for when a later phase
60
+ * turns out to be fixing the wrong requirement. Not a phase, so it is not in
61
+ * PHASE_ORDER — it is the loop back over it.
62
+ */
63
+ export const BACK_COMMAND = "/flow-back";
20
64
  /** One-line summary of what each phase produces, for help text and the system prompt. */
21
65
  export const PHASE_SUMMARY = {
22
66
  brainstorm: "problem, users, MVP features, recommended stack",
@@ -25,6 +69,7 @@ export const PHASE_SUMMARY = {
25
69
  build: "the application code, with tests per acceptance criterion, written test-first",
26
70
  review: "spec-compliance, security, and reliability findings, with a scorecard up top",
27
71
  fix: "fixes for the review's findings, each re-verified",
72
+ ship: "a handover summary, the test suite actually run, and an optional commit",
28
73
  };
29
74
  /**
30
75
  * Word budget for each phase's artifact. These docs are read by every phase
@@ -37,7 +82,16 @@ const PHASE_WORD_CAP = {
37
82
  plan: 1000,
38
83
  review: 800,
39
84
  fix: 600,
85
+ ship: 500,
40
86
  };
87
+ /**
88
+ * How many times a project may loop back before the workflow refuses.
89
+ *
90
+ * A loop is fix -> spec -> plan -> build -> review -> fix, so it is not free,
91
+ * and without a bound a disagreement the model cannot resolve on its own
92
+ * becomes an unbounded spend. Past this the user drives it by hand.
93
+ */
94
+ export const MAX_REVISITS = 2;
41
95
  const STATE_REL = path.join(".kritya", "project.json");
42
96
  export function stateFile(workspace) {
43
97
  return path.join(workspace, STATE_REL);
@@ -103,22 +157,92 @@ export function loadProjectState(workspace) {
103
157
  const parsed = JSON.parse(fs.readFileSync(stateFile(workspace), "utf8"));
104
158
  if (!parsed || typeof parsed.name !== "string" || !isPhase(parsed.phase))
105
159
  return null;
106
- return { name: parsed.name, phase: parsed.phase, updatedAt: parsed.updatedAt ?? "" };
160
+ const state = {
161
+ name: parsed.name,
162
+ phase: parsed.phase,
163
+ updatedAt: parsed.updatedAt ?? "",
164
+ };
165
+ // The agent writes this file itself when it runs autonomously, so anything
166
+ // malformed is treated as absent rather than allowed into the UI.
167
+ const raw = parsed.revisit;
168
+ if (raw && typeof raw === "object") {
169
+ const { to, reason } = raw;
170
+ if (isPhase(to) && typeof reason === "string")
171
+ state.revisit = { to, reason };
172
+ }
173
+ if (typeof parsed.revisits === "number" && Number.isFinite(parsed.revisits)) {
174
+ state.revisits = Math.max(0, Math.floor(parsed.revisits));
175
+ }
176
+ return state;
107
177
  }
108
178
  catch {
109
179
  return null;
110
180
  }
111
181
  }
112
- export function saveProjectState(workspace, name, phase) {
182
+ export function saveProjectState(workspace, name, phase, carry) {
183
+ const previous = loadProjectState(workspace);
113
184
  const state = {
114
185
  name: slugify(name),
115
186
  phase,
116
187
  updatedAt: new Date().toISOString(),
117
188
  };
189
+ // Moving to a phase consumes any recorded revisit: the whole point of looping
190
+ // back is to act on it, and leaving it set would make the same suggestion
191
+ // fire again on the next pass. The counter survives, because it is what
192
+ // bounds the loop. A different project starts fresh — unless the caller is a
193
+ // rename, which is the same project under a new slug and hands its count in.
194
+ const revisits = previous && previous.name === state.name ? previous.revisits : carry?.revisits;
195
+ if (revisits)
196
+ state.revisits = revisits;
118
197
  fs.mkdirSync(path.dirname(stateFile(workspace)), { recursive: true });
119
198
  fs.writeFileSync(stateFile(workspace), JSON.stringify(state, null, 2) + "\n");
120
199
  return state;
121
200
  }
201
+ /**
202
+ * Record that `phase` has to change before a finding can be fixed — the
203
+ * programmatic counterpart to the agent writing the same field with
204
+ * `write_file` during an autonomous run. Returns false when there is no
205
+ * active project, since there is nothing to attach the note to.
206
+ */
207
+ export function markRevisit(workspace, to, reason) {
208
+ const state = loadProjectState(workspace);
209
+ if (!state)
210
+ return false;
211
+ const next = {
212
+ ...state,
213
+ revisit: { to, reason },
214
+ updatedAt: new Date().toISOString(),
215
+ };
216
+ fs.writeFileSync(stateFile(workspace), JSON.stringify(next, null, 2) + "\n");
217
+ return true;
218
+ }
219
+ /** How many loop-backs this project has left before MAX_REVISITS is reached. */
220
+ export function revisitsRemaining(workspace) {
221
+ return Math.max(0, MAX_REVISITS - (loadProjectState(workspace)?.revisits ?? 0));
222
+ }
223
+ /**
224
+ * Rewind to `to` and spend one of the project's loop-backs.
225
+ *
226
+ * Returns false when there is no project or the budget is spent — the caller
227
+ * reports that rather than looping anyway, which is what bounds the cycle.
228
+ * The recorded `revisit` is consumed here: acting on it is what it was for.
229
+ */
230
+ export function takeRevisit(workspace, to) {
231
+ const state = loadProjectState(workspace);
232
+ if (!state)
233
+ return false;
234
+ const used = state.revisits ?? 0;
235
+ if (used >= MAX_REVISITS)
236
+ return false;
237
+ const next = {
238
+ name: state.name,
239
+ phase: to,
240
+ updatedAt: new Date().toISOString(),
241
+ revisits: used + 1,
242
+ };
243
+ fs.writeFileSync(stateFile(workspace), JSON.stringify(next, null, 2) + "\n");
244
+ return true;
245
+ }
122
246
  /**
123
247
  * End the active workflow. The docs/<name>/ artifacts are left alone — only the
124
248
  * pointer goes, so the agent stops being told to resume a project the user has
@@ -166,7 +290,7 @@ export function renameProject(workspace, from, to) {
166
290
  catch (err) {
167
291
  return { ok: false, error: `Could not move docs/${current}/: ${err.message}` };
168
292
  }
169
- saveProjectState(workspace, next, state.phase);
293
+ saveProjectState(workspace, next, state.phase, state);
170
294
  return { ok: true, name: next };
171
295
  }
172
296
  /** The phase before `phase` in PHASE_ORDER, or null for the first one. */
@@ -207,6 +331,21 @@ export function artifactExists(workspace, name, phase) {
207
331
  * and it would improvise the requirements instead.
208
332
  */
209
333
  export function phaseBlocker(workspace, name, phase) {
334
+ // Build writes code rather than a document, so the "did the previous phase
335
+ // leave its artifact behind" test passes for review trivially — and always.
336
+ // That let `/flow-review` run against a project that had never been built,
337
+ // dispatching subagents to review nothing. There is no file to look for, so
338
+ // fall back to the one piece of evidence there is: the recorded phase. Only
339
+ // consulted when there *is* a recorded phase, so a bare workspace behaves as
340
+ // before.
341
+ if (phase === "review") {
342
+ const recorded = loadProjectState(workspace)?.phase;
343
+ if (recorded && PHASE_ORDER.indexOf(recorded) < PHASE_ORDER.indexOf("build")) {
344
+ return (`The review phase checks what the build phase produced, and "${name}" has not ` +
345
+ `reached build yet (it is at ${recorded}). Run ${PHASE_COMMAND.build} first.`);
346
+ }
347
+ return null;
348
+ }
210
349
  const prev = previousPhase(phase);
211
350
  if (!prev || artifactExists(workspace, name, prev))
212
351
  return null;
@@ -315,7 +454,7 @@ function capLine(phase) {
315
454
  * the plan phase the belated write lands after plan mode has already been
316
455
  * turned off by the next command.
317
456
  */
318
- function approvalLine(phase, artifact) {
457
+ function approvalLine(phase, artifact, stopAfter) {
319
458
  const next = nextPhase(phase);
320
459
  const write = artifact
321
460
  ? `Write ${artifact} BEFORE you ask for anything. Approval comes after the file exists — ` +
@@ -323,15 +462,22 @@ function approvalLine(phase, artifact) {
323
462
  : "";
324
463
  if (!next)
325
464
  return `${write}Finish by summarizing the findings for the user.`;
465
+ // An ungated phase is followed immediately by the next one in the same run,
466
+ // so asking for approval here would stall a stretch the user asked to run
467
+ // through — and the question would arrive after the next phase had started.
468
+ if (!stopAfter) {
469
+ return (`${write}Then summarize what you produced in a few lines and carry straight on into the ` +
470
+ `${next} phase (${PHASE_SUMMARY[next]}). Do not stop for approval.`);
471
+ }
326
472
  return (`${write}Then summarize what you produced in a few lines and ask the user to approve ` +
327
473
  `before the ${next} phase (they will run ${PHASE_COMMAND[next]}). Do not advance on your own.`);
328
474
  }
329
475
  /** The instruction sent to the agent to carry out a given phase. */
330
- export function phasePrompt(name, phase, userInput) {
476
+ export function phasePrompt(name, phase, userInput, opts = {}) {
331
477
  const slug = slugify(name);
332
478
  const extra = userInput.trim() ? `\n\nUser input for this phase:\n${userInput.trim()}` : "";
333
479
  const cap = capLine(phase);
334
- const approve = approvalLine(phase, artifactPath(name, phase));
480
+ const approve = approvalLine(phase, artifactPath(name, phase), opts.stopAfter ?? shouldStopAfter(phase));
335
481
  switch (phase) {
336
482
  case "brainstorm":
337
483
  return (`PROJECT WORKFLOW — BRAINSTORM phase for project "${slug}".\n` +
@@ -485,12 +631,41 @@ export function phasePrompt(name, phase, userInput) {
485
631
  `verdict per finding — fixed, still open, or newly broken — in docs/${slug}/fix.md.\n` +
486
632
  `If anything is still open after the subagent's check, say so plainly; do not report success ` +
487
633
  `on a finding that didn't actually verify as fixed.\n` +
634
+ `If a finding cannot be fixed in code because the requirement itself is wrong — an ` +
635
+ `acceptance criterion that is ambiguous or contradicts another, a plan milestone that ` +
636
+ `does not survive contact with the code — do NOT work around it. Leave it open, say so ` +
637
+ `plainly, and record where it belongs by writing .kritya/project.json with a "revisit" ` +
638
+ `field alongside the existing keys: { "name": "${slug}", "phase": "fix", ` +
639
+ `"revisit": { "to": "spec", "reason": "<one line>" } }. Use "spec" when the ` +
640
+ `requirement is wrong and "plan" when the design is. That is what lets the user run ` +
641
+ `${BACK_COMMAND} to redo the phases that depend on it. Reserve this for findings that ` +
642
+ `genuinely belong upstream — everything else gets fixed here.\n` +
488
643
  cap +
489
644
  `Write docs/${slug}/fix.md BEFORE you ask for anything. Approval comes after the file ` +
490
645
  `exists — never show the writeup in chat and ask whether to save it. Then summarize the ` +
491
- `outcome in a few lines. If anything is still open, tell the user they can run ` +
492
- `${PHASE_COMMAND.review} to re-check the whole build, or run ${PHASE_COMMAND.fix} again ` +
493
- `once they've decided how to handle what's left.\n` +
646
+ `outcome in a few lines. If anything is still open, say so, and tell the user they can ` +
647
+ `run ${PHASE_COMMAND.review} to re-check the whole build, ${PHASE_COMMAND.fix} again ` +
648
+ `once they've decided how to handle what's left, or ${BACK_COMMAND} <phase> if the ` +
649
+ `finding belongs upstream. Otherwise they can run ${PHASE_COMMAND.ship} to finish.\n` +
650
+ extra);
651
+ case "ship":
652
+ return (`PROJECT WORKFLOW — SHIP phase for project "${slug}". Plan mode is OFF.\n` +
653
+ `Read docs/${slug}/spec.md, plan.md, review.md and fix.md. This is the handover, so it ` +
654
+ `has to be trustworthy rather than encouraging.\n` +
655
+ `First, actually run the project's test suite and report the real result — the exact ` +
656
+ `command you ran and what it printed. If there is no test suite, say that plainly ` +
657
+ `instead of implying one passed. A handover that claims success without running ` +
658
+ `anything is worse than no handover at all.\n` +
659
+ `Then write docs/${slug}/ship.md BEFORE you ask anything — approval comes after the ` +
660
+ `file exists, so never show the handover in chat and ask whether to save it. It should ` +
661
+ `cover: what was built and where the code lives, how to run ` +
662
+ `it and how to run its tests, which acceptance criteria from spec.md are met and which ` +
663
+ `are not, and every finding review.md raised that fix.md left open. Cite the AC, SEC ` +
664
+ `and REL labels rather than restating the requirements.\n` +
665
+ cap +
666
+ `Then summarize in a few lines and ask whether to commit. Do NOT commit, push or tag ` +
667
+ `anything until the user says yes — and when they do, use a conventional-commit message ` +
668
+ `describing the project, not the individual edits.\n` +
494
669
  extra);
495
670
  }
496
671
  }
@@ -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, loadProjectState, MAX_REVISITS, nextPhase, parseIdea, parsePhase, phaseBlocker, phaseIndex, phasePrompt, planRun, renameProject, 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,6 +51,16 @@ 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
66
  description: "workflow status · /project goto <phase> · rename <name> · clear",
@@ -162,6 +177,10 @@ 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.
165
184
  Keys: Esc cancels · Tab completes · Shift+Tab cycles normal/accept-edits/dry-run
166
185
  mode · ↑/↓ recalls history · Ctrl+R searches history · Ctrl+P opens the command
167
186
  palette · Ctrl+B copies the last code block · Ctrl+O toggles full tool output ·
@@ -170,7 +189,7 @@ Ctrl+K is the kill switch (stops everything until /kill off) · Ctrl+C exits`;
170
189
  * A phase boundary is the one place compaction is unambiguously safe: the
171
190
  * phase that just ended wrote its conclusions to a durable artifact on disk,
172
191
  * 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
192
+ * keeps a seven-phase project from dragging every phase's exploration into the
174
193
  * next phase's context window.
175
194
  */
176
195
  async function compactAtPhaseBoundary(ctx, phase) {
@@ -196,10 +215,48 @@ async function compactAtPhaseBoundary(ctx, phase) {
196
215
  ctx.setActivity(null);
197
216
  }
198
217
  }
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 };
218
+ /**
219
+ * Pull the workflow flags out of a command argument, leaving the user's own
220
+ * text behind. `--force` was the only one of these once, and it is still
221
+ * stripped before the prompt is built: it is an instruction to the command,
222
+ * not to the model, and leaving it in reads as part of the idea.
223
+ */
224
+ function takeFlowFlags(arg) {
225
+ let rest = arg;
226
+ const take = (flag) => {
227
+ const re = new RegExp(`(^|\\s)${flag}(\\s|$)`);
228
+ if (!re.test(rest))
229
+ return false;
230
+ rest = rest.replace(re, " ");
231
+ return true;
232
+ };
233
+ const force = take("--force");
234
+ const auto = take("--auto");
235
+ // --fast merges brainstorm and spec into one stretch. They answer nearly the
236
+ // same question, and for a small project the second round trip buys nothing.
237
+ const fast = take("--fast");
238
+ const untilMatch = /(^|\s)--until[= ](\S+)/.exec(rest);
239
+ if (untilMatch) {
240
+ rest = rest.replace(/(^|\s)--until[= ]\S+/, " ");
241
+ const phase = parsePhase(untilMatch[2]);
242
+ if (!phase) {
243
+ return {
244
+ arg: rest.trim(),
245
+ force,
246
+ error: `Unknown phase "${untilMatch[2]}" for --until. Use one of: ${PHASE_ORDER.join(", ")}.`,
247
+ };
248
+ }
249
+ return { arg: rest.trim(), force, until: phase };
250
+ }
251
+ if (/(^|\s)--until(\s|$)/.test(rest)) {
252
+ return { arg: rest.trim(), force, error: "Usage: --until <phase>" };
253
+ }
254
+ if (auto)
255
+ return { arg: rest.trim(), force, auto: true };
256
+ if (fast) {
257
+ return { arg: rest.trim(), force, gates: DEFAULT_GATES.filter((p) => p !== "brainstorm") };
258
+ }
259
+ return { arg: rest.trim(), force };
203
260
  }
204
261
  /** Strip a trailing/leading `--default` from a command argument. */
205
262
  function takeDefault(arg) {
@@ -208,25 +265,29 @@ function takeDefault(arg) {
208
265
  }
209
266
  /**
210
267
  * 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.
268
+ * the phase needs, record the phase, compact, then hand the prompt to the
269
+ * agent.
270
+ *
271
+ * The outcome is what stops a chain. A blocked phase has written nothing, and
272
+ * an incomplete one has written something half-finished — either way everything
273
+ * after it reads an artifact that isn't there yet.
212
274
  */
213
- async function runPhase(ctx, phase) {
275
+ async function runPhase(ctx, phase, run) {
214
276
  const project = loadProjectState(ctx.workspace);
215
277
  if (!project) {
216
278
  ctx.addItem({
217
279
  kind: "info",
218
280
  text: "No active project workflow. Start one with /flow-brainstorm <idea>.",
219
281
  });
220
- return;
282
+ return "blocked";
221
283
  }
222
- const { arg, force } = takeForce(ctx.arg);
223
284
  const blocker = phaseBlocker(ctx.workspace, project.name, phase);
224
- if (blocker && !force) {
285
+ if (blocker && !run.force) {
225
286
  ctx.addItem({
226
287
  kind: "info",
227
288
  text: `${blocker}\nTo run ${PHASE_COMMAND[phase]} anyway: ${PHASE_COMMAND[phase]} --force`,
228
289
  });
229
- return;
290
+ return "blocked";
230
291
  }
231
292
  // Warn (never block) if an earlier artifact was edited after this phase's
232
293
  // input was written from it — e.g. spec.md changed after plan.md already
@@ -256,13 +317,91 @@ async function runPhase(ctx, phase) {
256
317
  text: `${phase} phase for "${project.name}" — ${PHASE_SUMMARY[phase]}` +
257
318
  (artifact ? ` → ${artifact}` : "") +
258
319
  (wantPlanMode ? " (plan mode ON: read-only apart from that doc)" : "") +
259
- (blocker && force ? "\n⚠ Forced past a missing prerequisite." : ""),
320
+ (blocker && run.force ? "\n⚠ Forced past a missing prerequisite." : ""),
260
321
  });
261
- ctx.addItem({ kind: "user", text: ctx.raw.trim() });
262
322
  ctx.setRunningPhase(phase);
263
323
  ctx.refreshWorkflow();
264
324
  await compactAtPhaseBoundary(ctx, phase);
265
- return ctx.runAgent(phasePrompt(project.name, phase, arg));
325
+ await ctx.runAgent(phasePrompt(project.name, phase, run.arg, { stopAfter: run.stopAfter }));
326
+ // A turn that runs out of steps just ends, and the message it appends reads
327
+ // like model output. The agent flags it, so a chain can tell "the phase
328
+ // finished" from "the phase gave up mid-sentence" — which matters because
329
+ // the next phase reads whatever it managed to write. Subagents already
330
+ // report this (`stoppedEarly: "max-steps"`); phases were treating a
331
+ // truncated run as a finished one.
332
+ if (ctx.agent.hitStepLimit)
333
+ return "incomplete";
334
+ return "ok";
335
+ }
336
+ /**
337
+ * Run a stretch of phases back to back from one command.
338
+ *
339
+ * `planRun` decides how far the stretch goes. Stopping between build, review
340
+ * and fix would ask "shall I now review what I just built?" — a round trip
341
+ * that buys no decision, because by then the work is already done. Stopping
342
+ * after brainstorm, spec and plan does buy one: those are cheap to redo and
343
+ * expensive to get wrong.
344
+ */
345
+ async function runFlow(ctx, from, req) {
346
+ const phases = planRun(from, req);
347
+ const chained = phases.length > 1;
348
+ // One command, so one user line — not one per phase of the stretch.
349
+ ctx.addItem({ kind: "user", text: ctx.raw.trim() });
350
+ if (chained) {
351
+ ctx.addItem({ kind: "info", text: `Running ${phases.length} phases: ${phases.join(" → ")}` });
352
+ ctx.setChained(true);
353
+ }
354
+ try {
355
+ for (const [i, phase] of phases.entries()) {
356
+ const outcome = await runPhase(ctx, phase, {
357
+ arg: i === 0 ? req.arg : "",
358
+ force: req.force === true,
359
+ stopAfter: shouldStopAfter(phase, req),
360
+ });
361
+ if (outcome === "ok")
362
+ continue;
363
+ // A phase that did not finish stops the chain, because everything after
364
+ // it reads the artifact it did not (or only half) wrote. Say so: without
365
+ // this the run just goes quiet after the blocker message, and the user
366
+ // has to work out for themselves that phases they were promised never
367
+ // happened.
368
+ const abandoned = phases.slice(i + 1);
369
+ if (outcome === "incomplete") {
370
+ ctx.addItem({
371
+ kind: "info",
372
+ text: `${phase} stopped at the ${ctx.agent.maxSteps}-step limit before it finished, so ` +
373
+ `whatever it wrote is incomplete — and the phases after it read that.` +
374
+ (abandoned.length ? ` ${abandoned.join(", ")} did not run.` : "") +
375
+ `\nSend "continue" to finish ${phase}, then /flow to carry on from there.`,
376
+ });
377
+ }
378
+ else if (abandoned.length) {
379
+ ctx.addItem({
380
+ kind: "info",
381
+ text: `Chain stopped at ${phase} — ${abandoned.join(", ")} did not run. ` +
382
+ `Clear the blocker above, then /flow to carry on from here.`,
383
+ });
384
+ }
385
+ break;
386
+ }
387
+ }
388
+ finally {
389
+ // Only a chain ever sets this, so only a chain releases it — a single-phase
390
+ // command has nothing to suppress and should not touch the flag at all.
391
+ if (chained)
392
+ ctx.setChained(false);
393
+ }
394
+ }
395
+ /** The standard handler for a single phase command: /flow-spec, /flow-build, … */
396
+ function phaseCommand(phase) {
397
+ return async (ctx) => {
398
+ const req = takeFlowFlags(ctx.arg);
399
+ if (req.error) {
400
+ ctx.addItem({ kind: "info", text: req.error });
401
+ return;
402
+ }
403
+ return runFlow(ctx, phase, req);
404
+ };
266
405
  }
267
406
  const handlers = {
268
407
  "/help": (ctx) => {
@@ -623,13 +762,20 @@ const handlers = {
623
762
  `the change.${trailerInstruction} Do not push. Show the final commit hash and message.`);
624
763
  },
625
764
  "/flow-brainstorm": async (ctx) => {
626
- const input = ctx.arg.trim();
765
+ const req = takeFlowFlags(ctx.arg);
766
+ if (req.error) {
767
+ ctx.addItem({ kind: "info", text: req.error });
768
+ return;
769
+ }
770
+ const input = req.arg;
627
771
  const existing = loadProjectState(ctx.workspace);
628
772
  if (!existing && !input) {
629
773
  ctx.addItem({
630
774
  kind: "info",
631
775
  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`,
776
+ `Name it yourself with a short prefix: /flow-brainstorm reverser: a script that reverses a string\n` +
777
+ `Flags: --fast (run brainstorm and spec as one stretch) · --auto (no stops) · ` +
778
+ `--until <phase> · --force`,
633
779
  });
634
780
  return;
635
781
  }
@@ -653,13 +799,117 @@ const handlers = {
653
799
  ctx.agent.planMode = false;
654
800
  ctx.setPlanMode(false);
655
801
  }
656
- ctx.addItem({ kind: "user", text: ctx.raw.trim() });
657
- ctx.setRunningPhase("brainstorm");
658
802
  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));
803
+ // The idea belongs to the brainstorm phase; later phases in a --fast stretch
804
+ // read its artifact rather than the raw idea again.
805
+ return runFlow(ctx, "brainstorm", { ...req, arg: idea });
806
+ },
807
+ "/flow": async (ctx) => {
808
+ const project = loadProjectState(ctx.workspace);
809
+ if (!project) {
810
+ ctx.addItem({
811
+ kind: "info",
812
+ text: "No active project workflow. Start one with /flow-brainstorm <idea>.",
813
+ });
814
+ return;
815
+ }
816
+ const req = takeFlowFlags(ctx.arg);
817
+ if (req.error) {
818
+ ctx.addItem({ kind: "info", text: req.error });
819
+ return;
820
+ }
821
+ // The stored phase is the one that last ran, so continuing means the next
822
+ // one — re-running what just finished is never what "continue" means.
823
+ const from = nextPhase(project.phase);
824
+ if (!from) {
825
+ ctx.addItem({
826
+ kind: "info",
827
+ text: `"${project.name}" has run every phase (last: ${project.phase}). ` +
828
+ `Run /project clear to end the workflow, or ${BACK_COMMAND} <phase> to redo one.`,
829
+ });
830
+ return;
831
+ }
832
+ // A revisit the fix phase recorded means a finding was never really closed.
833
+ // Carrying on forward would run the rest of the workflow on top of a
834
+ // requirement that is known to be wrong, so name it here rather than let
835
+ // the user discover it by reading /project.
836
+ const revisit = project.revisit;
837
+ if (revisit && PHASE_ORDER.indexOf(revisit.to) < PHASE_ORDER.indexOf(from)) {
838
+ ctx.addItem({
839
+ kind: "info",
840
+ text: `↩ ${revisit.to} is still flagged as needing a change — ${revisit.reason}\n` +
841
+ `Run ${BACK_COMMAND} to redo it and everything after, or carry on anyway with ` +
842
+ `${PHASE_COMMAND[from]}.`,
843
+ });
844
+ }
845
+ return runFlow(ctx, from, req);
846
+ },
847
+ "/flow-back": async (ctx) => {
848
+ const project = loadProjectState(ctx.workspace);
849
+ if (!project) {
850
+ ctx.addItem({
851
+ kind: "info",
852
+ text: "No active project workflow. Start one with /flow-brainstorm <idea>.",
853
+ });
854
+ return;
855
+ }
856
+ const req = takeFlowFlags(ctx.arg);
857
+ if (req.error) {
858
+ ctx.addItem({ kind: "info", text: req.error });
859
+ return;
860
+ }
861
+ // Bare /flow-back acts on whatever the fix phase recorded; naming a phase
862
+ // works whether or not anything was recorded.
863
+ const recorded = project.revisit;
864
+ const target = parsePhase(req.arg) ?? recorded?.to ?? null;
865
+ if (!target) {
866
+ ctx.addItem({
867
+ kind: "info",
868
+ text: `Usage: ${BACK_COMMAND} <${PHASE_ORDER.join("|")}> — rewind to a phase and re-run everything after it.\n` +
869
+ `Nothing upstream was recorded either, so name the phase you want to redo.`,
870
+ });
871
+ return;
872
+ }
873
+ if (PHASE_ORDER.indexOf(target) >= PHASE_ORDER.indexOf(project.phase)) {
874
+ ctx.addItem({
875
+ kind: "info",
876
+ text: `${BACK_COMMAND} rewinds, and ${target} is not before the current phase ` +
877
+ `(${project.phase}). Use ${PHASE_COMMAND[target]} to move forward.`,
878
+ });
879
+ return;
880
+ }
881
+ if (revisitsRemaining(ctx.workspace) <= 0) {
882
+ ctx.addItem({
883
+ kind: "info",
884
+ text: `"${project.name}" has already looped back ${MAX_REVISITS} time(s), which is the limit — ` +
885
+ `another automatic pass would be guessing at what you want. Fix what you can by hand, ` +
886
+ `then /project goto ${target} and ${PHASE_COMMAND[target]} if you still want to redo it.`,
887
+ });
888
+ return;
889
+ }
890
+ if (!takeRevisit(ctx.workspace, target)) {
891
+ ctx.addItem({ kind: "info", text: `Could not rewind "${project.name}".` });
892
+ return;
893
+ }
894
+ ctx.addItem({
895
+ kind: "info",
896
+ text: `↩ Rewinding to ${target}` +
897
+ (recorded && recorded.to === target ? ` — ${recorded.reason}` : "") +
898
+ `. Everything after it re-runs, because it all reads what ${target} writes.`,
899
+ });
900
+ ctx.refreshWorkflow();
901
+ // Rewinding exists to regenerate what the wrong phase produced, and to put
902
+ // the user back where they were — not to carry on past it. So it stops at
903
+ // the phase the project had reached, unless the user asked for a longer or
904
+ // shorter run with --auto / --until.
905
+ //
906
+ // `arg` is blanked deliberately: it held the phase name the user typed
907
+ // (`/flow-back spec`), and passing it on would inject the literal string
908
+ // "spec" into the phase prompt as if it were user input.
909
+ const options = { ...req, arg: "" };
910
+ if (!req.auto && !req.until)
911
+ options.until = project.phase;
912
+ return runFlow(ctx, target, options);
663
913
  },
664
914
  // `/plan` (no `flow-` prefix) is pure plan-mode control — on/off or a bare
665
915
  // toggle — and never touches the project workflow. `/flow-plan` runs the
@@ -684,11 +934,12 @@ const handlers = {
684
934
  : "Plan mode OFF — the agent can make changes again.",
685
935
  });
686
936
  },
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"),
937
+ "/flow-spec": phaseCommand("spec"),
938
+ "/flow-plan": phaseCommand("plan"),
939
+ "/flow-build": phaseCommand("build"),
940
+ "/flow-review": phaseCommand("review"),
941
+ "/flow-fix": phaseCommand("fix"),
942
+ "/flow-ship": phaseCommand("ship"),
692
943
  "/project": (ctx) => {
693
944
  const [sub = "", ...rest] = ctx.arg.trim().split(/\s+/);
694
945
  const project = loadProjectState(ctx.workspace);
@@ -735,25 +986,34 @@ const handlers = {
735
986
  ctx.refreshWorkflow();
736
987
  ctx.addItem({
737
988
  kind: "info",
738
- text: `"${project.name}" set to the ${target} phase. Run ${PHASE_COMMAND[target]} to start it.`,
989
+ text: `"${project.name}" set to the ${target} phase. Run ${PHASE_COMMAND[target]} to start it, or /flow to continue from here.`,
739
990
  });
740
991
  return;
741
992
  }
742
- const lines = PHASE_ORDER.map((p) => {
993
+ const lines = PHASE_ORDER.map((p, i) => {
743
994
  const marker = p === project.phase ? "*" : " ";
744
995
  const artifact = artifactPath(project.name, p);
745
- return ` ${marker} ${p.padEnd(11)}${PHASE_SUMMARY[p]}\n ${artifact ?? "(application code)"}`;
996
+ // build has no document of its own, so there is nothing to look for.
997
+ const written = artifact !== null && artifactExists(ctx.workspace, project.name, p);
998
+ return (` ${marker} ${String(i + 1).padStart(2)}. ${p.padEnd(11)}${PHASE_SUMMARY[p]}` +
999
+ `\n ${artifact ?? "(application code)"}${written ? " ✓" : ""}`);
746
1000
  });
747
1001
  const stale = staleArtifacts(ctx.workspace, project.name, project.phase);
748
1002
  const staleBlock = stale.length ? `\n\n${stale.join("\n")}` : "";
1003
+ const revisitBlock = project.revisit
1004
+ ? `\n\n↩ ${project.revisit.to} was flagged as needing a change: ${project.revisit.reason}` +
1005
+ `\n ${BACK_COMMAND} redoes it and everything after it.`
1006
+ : "";
749
1007
  ctx.addItem({
750
1008
  kind: "info",
751
- text: `Project "${project.name}" — ${project.phase} phase` +
1009
+ text: `Project "${project.name}" — ${project.phase} phase ` +
1010
+ `(${phaseIndex(project.phase)}/${PHASE_ORDER.length})` +
752
1011
  (project.updatedAt ? ` (since ${project.updatedAt.slice(0, 10)})` : "") +
753
1012
  `\n\n${lines.join("\n")}` +
754
1013
  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.`,
1014
+ revisitBlock +
1015
+ `\n\n/flow continues · ${PHASE_COMMAND[project.phase]} re-runs this phase · ` +
1016
+ `/project goto <phase> to move · /project rename <name> · /project clear to end the workflow.`,
757
1017
  });
758
1018
  },
759
1019
  "/diff": (ctx) => {
package/dist/headless.js CHANGED
@@ -25,6 +25,20 @@ 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
+ export function isSlashCommandPrompt(prompt) {
40
+ return prompt.trimStart().startsWith("/");
41
+ }
28
42
  function emit(args, r) {
29
43
  if (args.output === "json") {
30
44
  console.log(JSON.stringify(r));
@@ -65,6 +79,20 @@ function approveHeadlessMcp(servers, trust, source) {
65
79
  export async function runHeadless(args) {
66
80
  const startedAt = Date.now();
67
81
  const workspace = path.resolve(args.dir);
82
+ // Refuse rather than silently do something else. See isSlashCommandPrompt.
83
+ if (isSlashCommandPrompt(args.prompt)) {
84
+ return finish(args, startedAt, {
85
+ success: false,
86
+ result: "",
87
+ error: `--prompt starts with "/", which is a slash command. Headless mode does not run ` +
88
+ `slash commands — it sends the text to the model, which would improvise the task ` +
89
+ `instead of following the command. Run the command in the interactive UI, or ` +
90
+ `describe the task in plain language here.`,
91
+ toolCalls: [],
92
+ usage: { promptTokens: 0, completionTokens: 0, cachedPromptTokens: 0, estimated: false },
93
+ durationMs: 0,
94
+ });
95
+ }
68
96
  // Only the user's own global .env is unconditionally trusted; see below.
69
97
  loadDotEnv([path.join(CONFIG_DIR, ".env")]);
70
98
  // There's no terminal to show the interactive trust prompt, so headless
@@ -153,7 +153,7 @@ function windowsQuote(value) {
153
153
  export function canaryProbes(escaped, wroteInside) {
154
154
  return [
155
155
  {
156
- what: "a write outside the workspace (system temp dir) was blocked",
156
+ what: "a write outside the workspace (system temp dir) did not reach the host",
157
157
  held: !escaped,
158
158
  },
159
159
  {
package/dist/ui/App.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Fragment, useCallback, useEffect, useRef, useState } from "react";
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
3
  import { Box, Static, Text, useApp, useBoxMetrics, useInput, useStderr, useWindowSize, } from "ink";
4
4
  import fs from "node:fs";
5
5
  import { glob } from "tinyglobby";
@@ -28,6 +28,7 @@ import { StreamViewport } from "./StreamViewport.js";
28
28
  import { TextInput } from "./TextInput.js";
29
29
  import { TranscriptItem } from "./TranscriptItem.js";
30
30
  import { terminalColumns, terminalRows } from "./viewport.js";
31
+ import { fitRow, listRows, ROW_GUTTER, ROW_SEPARATOR, windowList } from "./windowList.js";
31
32
  import { BUILTIN_COMMANDS, runCommand } from "../commands/registry.js";
32
33
  import { mcpPrompts, mcpResources } from "../mcp/client.js";
33
34
  import { useAgent } from "./useAgent.js";
@@ -147,7 +148,7 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
147
148
  .catch(() => { });
148
149
  }, [workspace]);
149
150
  useEffect(refreshFileList, [refreshFileList]);
150
- const { items, addItem, clearItems, phase, setPhase, stream, thinking, activity, setActivity, runningPhase, setRunningPhase, workflow, refreshWorkflow, permission, elicitation, inFlight, model, provider, servedModel, totalUsage, totalCost, tasks, setTasks, ctxPct, setCtxPct, tokenBudget, budgetPct, budgetUsed, budgetStopped, resetBudget, setBudgetLimit, branch, planMode, setPlanMode, dryRunMode, killed, killReason, engageKill, releaseKill, acceptEdits, setAcceptEdits, bypassMode, setBypassMode, autoApprovedCount, cycleMode, onAcceptEditsConfirm, onBypassModeConfirm, abortRef, setModelEverywhere, setProviderEverywhere, costReport, runAgent, runWebSearch, onPermissionDecision, onElicitationDecision, onResumeSelect, requestPermission, requestElicitation, persistenceWarningCount, } = useAgent({
151
+ const { items, addItem, clearItems, phase, setPhase, stream, thinking, activity, setActivity, runningPhase, setRunningPhase, setChained, workflow, refreshWorkflow, permission, elicitation, inFlight, model, provider, servedModel, totalUsage, totalCost, tasks, setTasks, ctxPct, setCtxPct, tokenBudget, budgetPct, budgetUsed, budgetStopped, resetBudget, setBudgetLimit, branch, planMode, setPlanMode, dryRunMode, killed, killReason, engageKill, releaseKill, acceptEdits, setAcceptEdits, bypassMode, setBypassMode, autoApprovedCount, cycleMode, onAcceptEditsConfirm, onBypassModeConfirm, abortRef, setModelEverywhere, setProviderEverywhere, costReport, runAgent, runWebSearch, onPermissionDecision, onElicitationDecision, onResumeSelect, requestPermission, requestElicitation, persistenceWarningCount, } = useAgent({
151
152
  agent,
152
153
  workspace,
153
154
  modelRef,
@@ -226,6 +227,37 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
226
227
  ? allCommands.filter((c) => c.name.startsWith(input.trim()))
227
228
  : [];
228
229
  const selectedCmd = suggestions.length ? Math.min(cmdIndex, suggestions.length - 1) : 0;
230
+ /**
231
+ * The suggestion list flattened to rendered lines, so a group heading costs
232
+ * a row like any other — windowing the commands alone would still let the
233
+ * headings push real rows off the bottom of the screen.
234
+ *
235
+ * `lineOfCmd[i]` is the line `suggestions[i]` landed on. The cursor indexes
236
+ * commands, because that is what the arrow keys move through; this is what
237
+ * maps it onto a line.
238
+ */
239
+ const suggestionLines = [];
240
+ const lineOfCmd = [];
241
+ {
242
+ // Group headers only earn their keep once more than one category is
243
+ // actually on screen — once typing has filtered down to a single family,
244
+ // a lone header is just noise.
245
+ const showHeaders = new Set(suggestions.map((c) => c.category)).size > 1;
246
+ let lastCategory;
247
+ for (const c of suggestions) {
248
+ if (showHeaders && c.category !== lastCategory) {
249
+ suggestionLines.push({ kind: "header", label: c.category ?? "Other" });
250
+ lastCategory = c.category;
251
+ }
252
+ lineOfCmd.push(suggestionLines.length);
253
+ suggestionLines.push({ kind: "cmd", cmd: c });
254
+ }
255
+ }
256
+ // The transcript above is static; this reserves the prompt box, the hint
257
+ // line and the status line, which is what has to stay visible.
258
+ const suggestionWindow = windowList(suggestionLines, lineOfCmd[selectedCmd] ?? 0, listRows(rows, 8));
259
+ // The list is indented by its own paddingLeft, then by the row's marker.
260
+ const suggestionRowWidth = contentWidth - 2 - ROW_GUTTER;
229
261
  // File suggestions while typing an @mention.
230
262
  const mentionMatch = phase === "input" && !input.startsWith("/") ? MENTION_RE.exec(input) : null;
231
263
  const mentionFragment = mentionMatch ? mentionMatch[2].toLowerCase() : null;
@@ -503,6 +535,7 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
503
535
  setPhase,
504
536
  setActivity,
505
537
  setRunningPhase,
538
+ setChained,
506
539
  refreshWorkflow,
507
540
  setCtxPct,
508
541
  setTasks,
@@ -685,18 +718,18 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
685
718
  setInput(v.replace(/\t/g, ""));
686
719
  setCmdIndex(0);
687
720
  setFileIndex(0);
688
- }, onSubmit: handleSubmit }, inputKey)] }), suggestions.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [(() => {
689
- // Group headers only earn their keep once more than one
690
- // category is actually on screen — once typing has filtered
691
- // down to a single family, a lone header is just noise.
692
- const showHeaders = new Set(suggestions.map((c) => c.category)).size > 1;
693
- let lastCategory;
694
- return suggestions.map((c, i) => {
695
- const showHeader = showHeaders && c.category !== lastCategory;
696
- lastCategory = c.category;
697
- return (_jsxs(Fragment, { children: [showHeader && _jsx(Text, { dimColor: true, children: c.category ?? "Other" }), _jsxs(Text, { color: i === selectedCmd ? "cyan" : undefined, children: [i === selectedCmd ? "❯ " : " ", _jsx(Text, { bold: i === selectedCmd, children: c.name }), _jsxs(Text, { dimColor: true, children: [" \u2014 ", c.description] })] })] }, c.name));
698
- });
699
- })(), _jsx(Text, { dimColor: true, children: "\u2191\u2193 select \u00B7 Tab/Enter select \u00B7 Enter again to run" })] })), fileSuggestions.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [fileSuggestions.map((f, i) => (_jsxs(Text, { color: i === selectedFile ? "cyan" : undefined, children: [i === selectedFile ? "❯ " : " ", f] }, f))), _jsx(Text, { dimColor: true, children: "\u2191\u2193 select \u00B7 Tab/Enter attach file" })] }))] })), _jsx(Box, { children: _jsx(StatusLine, { killed: killed, killReason: killReason, dryRunMode: dryRunMode, planMode: planMode, acceptEdits: acceptEdits, bypassMode: bypassMode, autoApprovedCount: autoApprovedCount, provider: provider, model: servedModel && servedModel !== model
721
+ }, onSubmit: handleSubmit }, inputKey)] }), suggestions.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [suggestionWindow.visible.map((line, i) => {
722
+ if (line.kind === "header") {
723
+ return (_jsx(Text, { dimColor: true, wrap: "truncate", children: line.label }, `header:${line.label}`));
724
+ }
725
+ const c = line.cmd;
726
+ const selected = i === suggestionWindow.cursor;
727
+ const row = fitRow(c.name, c.description, suggestionRowWidth);
728
+ return (_jsxs(Text, { color: selected ? "cyan" : undefined, wrap: "truncate", children: [selected ? "❯ " : " ", _jsx(Text, { bold: selected, children: row.label }), row.hint ? (_jsxs(Text, { dimColor: true, children: [ROW_SEPARATOR, row.hint] })) : null] }, c.name));
729
+ }), suggestionWindow.clipped ? (_jsxs(Text, { dimColor: true, children: [" ", suggestionWindow.from, "\u2013", suggestionWindow.to, " of ", suggestionWindow.total, " \u00B7 \u2191\u2193 to scroll"] })) : null, _jsx(Text, { dimColor: true, children: "\u2191\u2193 select \u00B7 Tab/Enter insert \u00B7 Esc cancel" })] })), phase === "input" &&
730
+ input.startsWith("/") &&
731
+ !input.includes(" ") &&
732
+ suggestions.length === 0 && (_jsx(Box, { paddingLeft: 2, children: _jsxs(Text, { dimColor: true, children: ["no command matches ", input.trim(), " \u00B7 Esc to clear"] }) })), fileSuggestions.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [fileSuggestions.map((f, i) => (_jsxs(Text, { color: i === selectedFile ? "cyan" : undefined, wrap: "truncate", children: [i === selectedFile ? "❯ " : " ", f] }, f))), _jsx(Text, { dimColor: true, children: "\u2191\u2193 select \u00B7 Tab/Enter attach file" })] }))] })), _jsx(Box, { children: _jsx(StatusLine, { killed: killed, killReason: killReason, dryRunMode: dryRunMode, planMode: planMode, acceptEdits: acceptEdits, bypassMode: bypassMode, autoApprovedCount: autoApprovedCount, provider: provider, model: servedModel && servedModel !== model
700
733
  ? `${model}-${modelDisplaySlug(servedModel)}`
701
734
  : model, workflow: workflow, branch: branch, budgetPct: budgetPct, budgetStopped: budgetStopped, totalCost: totalCost, sandboxActive: (config.sandboxExec ?? defaultSandboxMode()) !== "off" && sandboxAvailable(), persistenceWarningCount: persistenceWarningCount, privacyMode: privacyMode }) })] }));
702
735
  }
@@ -1,10 +1,33 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState } from "react";
3
- import { Box, Text, useInput } from "ink";
4
- export function SelectList({ items, onSelect, onCancel, }) {
3
+ import { Box, Text, useInput, useWindowSize } from "ink";
4
+ import { terminalColumns, terminalRows } from "./viewport.js";
5
+ import { fitRow, listRows, ROW_GUTTER, ROW_SEPARATOR, windowList } from "./windowList.js";
6
+ /**
7
+ * The list every picker in the app renders through — the Ctrl+P palette, the
8
+ * model picker, the permission and trust prompts, the elicitation prompt, and
9
+ * session resume.
10
+ *
11
+ * It measures the terminal itself and windows to fit, so all of those inherit
12
+ * scrolling and row truncation without passing anything: a list longer than
13
+ * the screen scrolls with the cursor kept in view, and a long row is cut
14
+ * instead of wrapping onto a second line. `width`/`maxRows` are there for a
15
+ * caller that knows its own layout better than the terminal size suggests.
16
+ */
17
+ export function SelectList({ items, onSelect, onCancel, width, maxRows, reservedRows, }) {
5
18
  const [rawIndex, setIndex] = useState(0);
19
+ const windowSize = useWindowSize();
20
+ const rows = maxRows ??
21
+ (reservedRows === undefined
22
+ ? listRows(terminalRows(windowSize))
23
+ : listRows(terminalRows(windowSize), reservedRows));
24
+ // A row also spends ROW_GUTTER columns on its selection marker, and the
25
+ // border/padding the caller wrapped us in is not ours to know — so leave a
26
+ // couple of columns of slack rather than assume the terminal's full width.
27
+ const rowWidth = (width ?? terminalColumns(windowSize) - 4) - ROW_GUTTER;
6
28
  // Items can shrink under us (e.g. a live filter above the list).
7
29
  const index = items.length ? Math.min(rawIndex, items.length - 1) : 0;
30
+ const list = windowList(items, index, rows);
8
31
  useInput((_input, key) => {
9
32
  if (!items.length) {
10
33
  if (key.escape && onCancel)
@@ -20,5 +43,8 @@ export function SelectList({ items, onSelect, onCancel, }) {
20
43
  else if (key.escape && onCancel)
21
44
  onCancel();
22
45
  });
23
- return (_jsx(Box, { flexDirection: "column", children: items.map((item, i) => (_jsxs(Text, { color: i === index ? "green" : undefined, children: [i === index ? "❯ " : " ", item.label, item.hint ? _jsxs(Text, { dimColor: true, children: [" \u2014 ", item.hint] }) : null] }, item.value))) }));
46
+ return (_jsxs(Box, { flexDirection: "column", children: [list.visible.map((item, i) => {
47
+ const row = fitRow(item.label, item.hint, rowWidth);
48
+ return (_jsxs(Text, { color: i === list.cursor ? "green" : undefined, wrap: "truncate", children: [i === list.cursor ? "❯ " : " ", row.label, row.hint ? (_jsxs(Text, { dimColor: true, children: [ROW_SEPARATOR, row.hint] })) : null] }, item.value));
49
+ }), list.clipped ? (_jsxs(Text, { dimColor: true, children: [" ", list.from, "\u2013", list.to, " of ", list.total, " \u00B7 \u2191\u2193 to scroll"] })) : null] }));
24
50
  }
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Text } from "ink";
3
+ import { PHASE_ORDER, phaseIndex } from "../agent/workflow.js";
3
4
  import { displayModelId } from "../config/models.js";
4
5
  /**
5
6
  * The single dim status bar pinned to the bottom of the screen. Keeps only
@@ -17,5 +18,5 @@ export function StatusLine({ killed, killReason, dryRunMode, planMode, acceptEdi
17
18
  ? "plan"
18
19
  : acceptEdits
19
20
  ? `accept-edits (${autoApprovedCount} auto)`
20
- : "default", " · "] }), displayModelId(provider, model), _jsxs(Text, { color: sandboxActive ? "green" : "red", children: [" ", "\u00B7 ", sandboxActive ? "🔒 sandbox:active" : "🔓 sandbox:inactive"] }), workflow ? (_jsxs(Text, { color: "magenta", children: [" ", "\u00B7 \u2691 ", workflow.name, ":", workflow.phase] })) : (""), branch ? ` · ⎇ ${branch}` : "", budgetStopped || budgetPct >= 80 ? (_jsxs(Text, { color: budgetStopped ? "red" : "yellow", children: [" \u00B7 budget ", budgetPct, "%"] })) : (""), totalCost > 0 ? ` · $${totalCost.toFixed(4)}` : "", persistenceWarningCount > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 \u26A0 persistence warnings: ", persistenceWarningCount] })) : (""), privacyMode ? _jsx(Text, { color: "cyan", children: " \u00B7 privacy:on" }) : "", " · /diff review · /undo revert · Ctrl+K stop · /status for details"] }));
21
+ : "default", " · "] }), displayModelId(provider, model), _jsxs(Text, { color: sandboxActive ? "green" : "red", children: [" ", "\u00B7 ", sandboxActive ? "🔒 sandbox:active" : "🔓 sandbox:inactive"] }), workflow ? (_jsxs(Text, { color: "magenta", children: [" ", "\u00B7 \u2691 ", workflow.name, " ", phaseIndex(workflow.phase), "/", PHASE_ORDER.length, " ", workflow.phase] })) : (""), branch ? ` · ⎇ ${branch}` : "", budgetStopped || budgetPct >= 80 ? (_jsxs(Text, { color: budgetStopped ? "red" : "yellow", children: [" \u00B7 budget ", budgetPct, "%"] })) : (""), totalCost > 0 ? ` · $${totalCost.toFixed(4)}` : "", persistenceWarningCount > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 \u26A0 persistence warnings: ", persistenceWarningCount] })) : (""), privacyMode ? _jsx(Text, { color: "cyan", children: " \u00B7 privacy:on" }) : "", " · /diff review · /undo revert · Ctrl+K stop · /status for details"] }));
21
22
  }
@@ -26,19 +26,19 @@ export function fuzzyMatch(haystack, needle) {
26
26
  * cleared by accident), `checkpoint:<name>` rewinds to that checkpoint.
27
27
  *
28
28
  * `recency` (command name → last-run epoch ms) floats recently *used*
29
- * commands above the rest. Only the unfiltered ordering is affected: once a
30
- * user is typing a fragment they are looking for that fragment, and reordering
31
- * under the cursor as matches arrive would be the opposite of helpful. Within
32
- * each group the sort is stable, so the declaration order stays the tiebreak
33
- * and the list is still predictable on a fresh install (`recency` omitted).
29
+ * commands above the rest, the way VS Code's quick open does — including
30
+ * within filtered results, so the command you reach for stays one Enter away
31
+ * even while typing. Within each group the sort is stable, so the declaration
32
+ * order stays the tiebreak and the list is still predictable on a fresh
33
+ * install (`recency` omitted).
34
34
  */
35
35
  export function buildPaletteItems(commands, checkpoints, filter, recency) {
36
36
  const items = [];
37
- // Sort first, filter second: filtering cannot change the relative order, and
38
- // doing it once here avoids re-sorting an already-sorted list per item.
39
- const ordered = !recency || filter.trim()
40
- ? commands
41
- : [...commands].sort((a, b) => (recency[b.name] ?? 0) - (recency[a.name] ?? 0));
37
+ // Sort first, filter second: filtering only narrows the list, so typing
38
+ // more of a fragment never reorders what is already on screen.
39
+ const ordered = recency
40
+ ? [...commands].sort((a, b) => (recency[b.name] ?? 0) - (recency[a.name] ?? 0))
41
+ : commands;
42
42
  for (const c of ordered) {
43
43
  if (!fuzzyMatch(`${c.name} ${c.description}`, filter))
44
44
  continue;
@@ -103,6 +103,15 @@ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBr
103
103
  runningPhaseRef.current = p;
104
104
  setRunningPhaseState(p);
105
105
  }, []);
106
+ /**
107
+ * True while one command is running several phases back to back. A ref, not
108
+ * state: the teardown reads it in the same tick the command is still inside,
109
+ * and nothing renders from it.
110
+ */
111
+ const chainedRef = useRef(false);
112
+ const setChained = useCallback((chained) => {
113
+ chainedRef.current = chained;
114
+ }, []);
106
115
  /** The active project workflow, for the statusline. Persists between turns. */
107
116
  const [workflow, setWorkflow] = useState(() => loadProjectState(workspace));
108
117
  const [permission, setPermission] = useState(null);
@@ -400,6 +409,10 @@ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBr
400
409
  setRunningPhase(null);
401
410
  if (!ran)
402
411
  return;
412
+ // A chain runs the next phase itself, so naming the command to type would
413
+ // point at work that is already starting. The chain prints its own plan.
414
+ if (chainedRef.current)
415
+ return;
403
416
  // Prefer the phase on disk: an autonomous turn may have advanced it past
404
417
  // whatever the command started.
405
418
  const current = loadProjectState(workspace)?.phase ?? ran;
@@ -407,7 +420,7 @@ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBr
407
420
  addItem({
408
421
  kind: "info",
409
422
  text: next
410
- ? `✓ ${current} phase done — next: ${PHASE_COMMAND[next]} (or /project to review where you are)`
423
+ ? `✓ ${current} phase done — next: ${PHASE_COMMAND[next]} or /flow (or /project to review where you are)`
411
424
  : `✓ ${current} phase done — the workflow is complete. /project clear ends it.`,
412
425
  });
413
426
  }, [workspace, addItem, setRunningPhase]);
@@ -606,6 +619,7 @@ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBr
606
619
  setActivity,
607
620
  runningPhase,
608
621
  setRunningPhase,
622
+ setChained,
609
623
  workflow,
610
624
  refreshWorkflow,
611
625
  permission,
@@ -0,0 +1,100 @@
1
+ import stringWidth from "string-width";
2
+ import { truncateToWidth } from "./inline.js";
3
+ /**
4
+ * Windowing and width-fitting for every list in the UI: the Ctrl+P palette,
5
+ * the inline `/` suggestions, the history search, the model picker, and the
6
+ * permission/trust prompts. They were each written separately and each one
7
+ * grew its own way of not fitting on screen — some wrapped a long row onto a
8
+ * second line, and the long ones (a bare `/` is 33 commands plus group
9
+ * headings) simply ran off the bottom of the terminal with no way to scroll.
10
+ *
11
+ * Kept pure and React-free so the arithmetic is testable on its own, and so
12
+ * the renderers only have to decide what a row looks like, never how much of
13
+ * it fits.
14
+ */
15
+ /** Columns the selection marker (`"❯ "` / `" "`) occupies at the start of a row. */
16
+ export const ROW_GUTTER = 2;
17
+ /** What sits between a row's label and its hint. */
18
+ export const ROW_SEPARATOR = " — ";
19
+ /**
20
+ * The label keeps at least this many columns before a hint is allowed any
21
+ * space — below it the label alone is more use than a two-letter stub plus a
22
+ * truncated hint.
23
+ */
24
+ const MIN_LABEL = 8;
25
+ /** Under this a hint is dropped outright rather than squeezed to a stub. */
26
+ const MIN_HINT = 8;
27
+ /** Chrome a list should leave alone by default: prompt box, hints, status line. */
28
+ const DEFAULT_RESERVED_ROWS = 10;
29
+ /**
30
+ * Rows a list may occupy: the terminal, less the chrome around it.
31
+ *
32
+ * The floor is deliberately low rather than generous — a very short terminal
33
+ * should still show a usable list instead of collapsing to nothing, and the
34
+ * caller can always pass an explicit `maxRows` when it knows its own layout.
35
+ */
36
+ export function listRows(terminalRows, reserved = DEFAULT_RESERVED_ROWS) {
37
+ return Math.max(3, Math.floor(terminalRows) - Math.max(0, Math.floor(reserved)));
38
+ }
39
+ /**
40
+ * The slice of `items` to render, plus where the cursor sits inside it.
41
+ *
42
+ * The window is centred on the cursor and then clamped to the ends, which
43
+ * makes it a pure function of the cursor — there is no scroll offset to carry
44
+ * between renders, so a list can never get stuck scrolled away from the row
45
+ * it is highlighting. Centring also means moving within the first page does
46
+ * not shift the list at all, and the last row is reachable without the window
47
+ * running off the bottom.
48
+ */
49
+ export function windowList(items, cursor, maxRows) {
50
+ const total = items.length;
51
+ const rows = Math.max(1, Math.floor(maxRows));
52
+ if (total === 0) {
53
+ return { visible: [], cursor: 0, from: 0, to: 0, total: 0, clipped: false };
54
+ }
55
+ // `cursor` can be stale — items shrink under a live filter — so clamp it
56
+ // rather than trust it, exactly like the renderers used to do by hand.
57
+ const at = Math.min(Math.max(Math.floor(cursor), 0), total - 1);
58
+ const offset = Math.min(Math.max(at - Math.floor(rows / 2), 0), Math.max(0, total - rows));
59
+ const visible = items.slice(offset, offset + rows);
60
+ return {
61
+ visible,
62
+ cursor: at - offset,
63
+ from: offset + 1,
64
+ to: offset + visible.length,
65
+ total,
66
+ clipped: visible.length < total,
67
+ };
68
+ }
69
+ /**
70
+ * Fit one row into `width` display columns, cutting the *hint* before the
71
+ * label: the label is what identifies the row, so a long description must
72
+ * never be the reason a command's name is unreadable. When there is no room
73
+ * for both, the hint goes entirely.
74
+ *
75
+ * `width` is what is left after the gutter, and may be unknown (-1, or 0 from
76
+ * a terminal that has not reported a size yet) — in which case the row is
77
+ * returned untouched and the renderer's `wrap="truncate"` backstop takes over.
78
+ */
79
+ export function fitRow(label, hint, width) {
80
+ if (width <= 0)
81
+ return { label, hint };
82
+ if (!hint)
83
+ return { label: truncateToWidth(label, width), hint: undefined };
84
+ const sepWidth = stringWidth(ROW_SEPARATOR);
85
+ const labelWidth = stringWidth(label);
86
+ if (width < MIN_LABEL + sepWidth + MIN_HINT) {
87
+ return { label: truncateToWidth(label, width), hint: undefined };
88
+ }
89
+ if (labelWidth + sepWidth + MIN_HINT <= width) {
90
+ return { label, hint: truncateToWidth(hint, width - labelWidth - sepWidth) };
91
+ }
92
+ // The label itself has to give ground, but never below MIN_LABEL's worth of
93
+ // room for the hint to be worth showing at all.
94
+ const fittedLabel = truncateToWidth(label, width - sepWidth - MIN_HINT);
95
+ const rest = width - stringWidth(fittedLabel) - sepWidth;
96
+ return {
97
+ label: fittedLabel,
98
+ hint: rest >= MIN_HINT ? truncateToWidth(hint, rest) : undefined,
99
+ };
100
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kritya",
3
- "version": "0.8.44-beta",
3
+ "version": "0.8.46-beta",
4
4
  "description": "Kritya — Superintelligence at your command line",
5
5
  "type": "module",
6
6
  "publishConfig": {