kritya 0.8.46-beta → 0.8.48-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -44,7 +44,8 @@ autonomously looping until your request is done. Anything that mutates state
44
44
  confining writes to the workspace; falls back to unsandboxed (with a
45
45
  warning) if the sandbox binary isn't installed
46
46
  - **Staged project workflow** — `/flow-brainstorm → spec → plan → build →
47
- review → fix` for building something new end-to-end
47
+ review → fix → ship` for building something new end-to-end, with parked
48
+ projects you can pick up again later
48
49
  - **MCP + Agent Plugins support** — extend with external tools/servers, each
49
50
  individually trust-gated
50
51
  - **Undo/redo/checkpoints** — revert or rewind file changes and conversation
@@ -166,8 +167,11 @@ In-session commands (type `/` to see them with autocomplete; letters filter the
166
167
  | `/flow-build` | project workflow: implement the plan, with tests |
167
168
  | `/flow-review` | project workflow: spec-compliance and security review of the build |
168
169
  | `/flow-fix` | project workflow: fix the review's findings, each one re-verified |
170
+ | `/flow-ship` | project workflow: hand over the finished project, with the test suite actually run |
171
+ | `/flow` | continue the workflow from wherever the project stands |
172
+ | `/flow-back <phase>` | rewind to a phase and re-run everything after it (bounded — see below) |
169
173
  | `/plan` | toggle plan mode: `/plan`, `/plan on`, `/plan off` (unrelated to `/flow-plan`) |
170
- | `/project` | workflow status; `goto <phase>`, `rename <name>`, `clear` to end it |
174
+ | `/project` | workflow status; `list`, `resume <name>`, `goto <phase>`, `rename <name>`, `clear` |
171
175
  | `/diff` | show the cumulative git diff of this session's changes |
172
176
  | `/init` | scan the repo and generate a `KRITYA.md` project-memory file |
173
177
  | `/commit` | have the agent review, stage, and commit the current git changes |
@@ -202,9 +206,9 @@ checkpoints). `Ctrl+K` is the kill switch (see below). `Ctrl+C` exits.
202
206
 
203
207
  - **Staged new-project workflow** — ask kritya to build something new (a
204
208
  FastAPI backend, a Next.js frontend, a CLI) and it doesn't dive straight into
205
- code. It runs six phases — **brainstorm → spec → plan → build → review → fix**
206
- — writing a durable artifact for each under `docs/<name>/` and stopping for
207
- your approval between phases:
209
+ code. It runs seven phases — **brainstorm → spec → plan → build → review →
210
+ fix → ship** — writing a durable artifact for each under `docs/<name>/` and
211
+ stopping for your approval between phases:
208
212
 
209
213
  | Phase | Produces |
210
214
  | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -214,35 +218,63 @@ checkpoints). `Ctrl+K` is the kill switch (see below). `Ctrl+C` exits.
214
218
  | `build` | the code, with tests written before the code they test, plus negative/failure-path tests for security/reliability-tagged milestones, and a live milestone checklist |
215
219
  | `review` | `review.md` — a one-line scorecard, then spec-compliance, security, and reliability findings |
216
220
  | `fix` | `fix.md` — the review's findings addressed and re-verified |
221
+ | `ship` | `ship.md` — a handover summary, the test suite actually run, and an optional commit |
217
222
 
218
223
  Spec comes before plan on purpose: the spec settles _what_ (and pins the
219
224
  numbered acceptance criteria everything downstream is held to), the plan
220
225
  settles _how_ and sequences milestones against those criteria. Each phase
221
226
  reads only the artifact immediately before it, so nothing gets re-derived.
222
- The current phase lives in `.kritya/project.json`, so the flow resumes across
223
- sessions. While a workflow is active the statusline carries a `⚑ name:phase`
224
- flag, and the spinner names the running phase; `/project` shows the full
225
- picture — including a warning if an earlier artifact was edited after a
226
- later one already depended on it — and `/project clear` ends it. A phase
227
- refuses to run if the artifact it reads was never written (`--force`
228
- overrides). The agent walks the phases on its own, or you drive them by hand
229
- with `/flow-brainstorm <idea>`, `/flow-spec`, `/flow-plan`, `/flow-build`,
230
- `/flow-review`, `/flow-fix` — and after each one kritya tells you which
231
- command comes next, so the handoff doesn't depend on the model remembering
232
- to say it. `/flow-fix` only fixes what review found; if anything is still
233
- open afterward it tells you to run `/flow-review` again or `/flow-fix`
234
- again once you've decided how to handle what's left.
227
+ The agent walks the phases on its own, or you drive them by hand with
228
+ `/flow-brainstorm <idea>`, `/flow-spec`, `/flow-plan`, `/flow-build`,
229
+ `/flow-review`, `/flow-fix`, `/flow-ship` — and after each one kritya tells
230
+ you which command comes next, so the handoff doesn't depend on the model
231
+ remembering to say it.
232
+
233
+ **Where it stops is a policy, not a property of the phase list.** It hands
234
+ back for approval after brainstorm, spec and plan — the phases where changing
235
+ course is still cheap — then runs build through ship as one stretch, because
236
+ by then the work is already done and "shall I now review what I just built?"
237
+ adds a round trip without adding a decision. `/flow` continues from wherever
238
+ the project stands; `--auto` runs the rest without stopping, `--until <phase>`
239
+ stops at a named phase, and `--fast` merges brainstorm and spec into one
240
+ stretch. A phase refuses to run if the artifact it reads was never written
241
+ (`--force` overrides). `/flow-fix` only fixes what review found; if anything
242
+ is still open afterward it tells you to run `/flow-review` again, `/flow-fix`
243
+ again, or `/flow-back <phase>` if the finding is really a problem with the
244
+ requirement — that rewinds to a phase and re-runs everything after it, bounded
245
+ at two passes so a disagreement the model can't resolve can't spend forever.
246
+
247
+ **Parking a project keeps its phase.** Starting a second project used to
248
+ overwrite the first's place in the flow, because `.kritya/project.json` only
249
+ ever holds the active one. A registry at `.kritya/projects.json` now keeps
250
+ every project the workspace has run, so `/project list` shows them all and
251
+ `/project resume <name>` picks one up exactly where it stopped — phase,
252
+ loop-back budget and all. `/project clear` ends the active workflow but parks
253
+ rather than forgets it, and a new `/flow-brainstorm` names the way back.
254
+
255
+ **A gate doesn't cost you a file read.** Each phase records a one-line
256
+ scorecard, so `/project` shows what every phase concluded ("6 ACs, 2 MUST")
257
+ instead of just a generic description — you can judge whether to approve
258
+ without opening anything. When a project is active, launch names its phase,
259
+ and after a gated phase `/flow` is prefilled into the input line: Enter
260
+ continues, Esc (or any edit) discards it. The gate still holds — nothing runs
261
+ until you submit.
262
+
263
+ While a workflow is active the statusline carries a `⚑ name N/7 phase` flag
264
+ and the spinner names the running phase. `/project` shows the full picture,
265
+ including a warning if an earlier artifact was edited after a later one
266
+ already depended on it.
235
267
 
236
268
  The project is named from your idea unless you name it yourself with a short
237
269
  prefix — `/flow-brainstorm reverser: a script that reverses a string` gives
238
270
  you `docs/reverser/`. `/project rename <name>` moves an existing one.
239
271
 
240
- Cost matters here — six phases in one session adds up — so kritya compacts
272
+ Cost matters here — seven phases in one session adds up — so kritya compacts
241
273
  the conversation at each phase boundary (the artifact is on disk, so the
242
274
  transcript that produced it is redundant), caps artifact length, and in the
243
275
  build phase dispatches independent milestones to isolated write subagents
244
276
  rather than pulling every file into the main context. The review phase runs
245
- its two reviewers as read-only subagents, so only their findings come back;
277
+ its three reviewers as read-only subagents, so only their findings come back;
246
278
  the fix phase does the same to re-verify only what it changed.
247
279
 
248
280
  In the plan phase, plan mode's read-only guard is relaxed just enough to let
@@ -109,7 +109,7 @@ ${PHASE_ORDER.map((p, i) => {
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. 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}.
112
+ Track state in .kritya/project.json ({ "name", "phase", "updatedAt", "summaries" }): read it at the start of a turn to resume at the right phase, and update "phase" (with write_file) when you advance. When a phase finishes, add a one-line scorecard for it under "summaries" (keyed by phase name, e.g. "6 ACs, 2 MUST") so /project can show what each phase concluded without the user opening the file — read the file, set the one key, and write it back with every other key unchanged. 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()})
@@ -61,6 +61,32 @@ export const PHASE_COMMAND = {
61
61
  * PHASE_ORDER — it is the loop back over it.
62
62
  */
63
63
  export const BACK_COMMAND = "/flow-back";
64
+ /** Runs whatever phase comes next, wherever the project stands. */
65
+ export const CONTINUE_COMMAND = "/flow";
66
+ /**
67
+ * The line that ends a phase, and the command that carries on from it.
68
+ *
69
+ * Two callers print this, and they must not disagree: the turn's own teardown,
70
+ * which covers a single-phase command, and `runFlow`, which has to cover the
71
+ * last phase of a chain — a chain's teardown runs while the chain flag is still
72
+ * set, so it deliberately stays quiet, and without the second caller the last
73
+ * phase of every chain went unannounced.
74
+ */
75
+ export function phaseDoneMessage(phase) {
76
+ const next = nextPhase(phase);
77
+ return next
78
+ ? `✓ ${phase} phase done — next: ${PHASE_COMMAND[next]} or ${CONTINUE_COMMAND} ` +
79
+ `(or /project to review where you are)`
80
+ : `✓ ${phase} phase done — the workflow is complete. /project clear ends it.`;
81
+ }
82
+ /**
83
+ * What to drop into the input line once a phase ends, or null when the workflow
84
+ * is finished. Prefilling the continuation is what makes a gate one keypress
85
+ * instead of a retyped command; nothing runs until it is submitted.
86
+ */
87
+ export function prefillAfter(phase) {
88
+ return nextPhase(phase) ? CONTINUE_COMMAND : null;
89
+ }
64
90
  /** One-line summary of what each phase produces, for help text and the system prompt. */
65
91
  export const PHASE_SUMMARY = {
66
92
  brainstorm: "problem, users, MVP features, recommended stack",
@@ -93,12 +119,220 @@ const PHASE_WORD_CAP = {
93
119
  */
94
120
  export const MAX_REVISITS = 2;
95
121
  const STATE_REL = path.join(".kritya", "project.json");
122
+ const REGISTRY_REL = path.join(".kritya", "projects.json");
96
123
  export function stateFile(workspace) {
97
124
  return path.join(workspace, STATE_REL);
98
125
  }
126
+ /**
127
+ * Path of the registry of every project this workspace has run the workflow
128
+ * for.
129
+ *
130
+ * `project.json` holds only the *active* project, which is why starting a
131
+ * second project used to look like losing the first: the phase pointer was
132
+ * overwritten and the old project's place in the flow went with it. The
133
+ * registry is the durable index that fixes that — one entry per slug, written
134
+ * on every phase change, so a parked project can be picked up again exactly
135
+ * where it stopped.
136
+ */
137
+ export function registryFile(workspace) {
138
+ return path.join(workspace, REGISTRY_REL);
139
+ }
99
140
  function isPhase(v) {
100
141
  return typeof v === "string" && PHASE_ORDER.includes(v);
101
142
  }
143
+ function parseRevisit(raw) {
144
+ if (!raw || typeof raw !== "object")
145
+ return undefined;
146
+ const { to, reason } = raw;
147
+ return isPhase(to) && typeof reason === "string" ? { to, reason } : undefined;
148
+ }
149
+ /**
150
+ * Cap on a recorded scorecard, in characters. The prompt asks for ~100; this is
151
+ * the backstop for a model that ignores it. `/project` lays these out in a
152
+ * fixed-width column next to the phase name, so one paragraph — or one embedded
153
+ * newline — would push the rest of the list off the screen.
154
+ */
155
+ const SUMMARY_MAX = 120;
156
+ /**
157
+ * Reduce a recorded scorecard to one displayable line. Truncates rather than
158
+ * drops: a cut-off line still tells the user more than a missing one, and this
159
+ * is the only thing standing between a chatty model and the `/project` layout.
160
+ */
161
+ function clampSummary(v) {
162
+ const oneLine = v.replace(/\s+/g, " ").trim();
163
+ if (oneLine.length <= SUMMARY_MAX)
164
+ return oneLine;
165
+ return oneLine.slice(0, SUMMARY_MAX - 1).trimEnd() + "…";
166
+ }
167
+ /** Keep only the well-formed, non-empty one-liners; drop everything else. */
168
+ function parseSummaries(raw) {
169
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
170
+ return undefined;
171
+ const out = {};
172
+ for (const [k, v] of Object.entries(raw)) {
173
+ if (!isPhase(k) || typeof v !== "string")
174
+ continue;
175
+ const line = clampSummary(v);
176
+ if (line)
177
+ out[k] = line;
178
+ }
179
+ return Object.keys(out).length ? out : undefined;
180
+ }
181
+ function parseRegistryEntry(raw) {
182
+ if (!raw || typeof raw !== "object")
183
+ return null;
184
+ const { phase, updatedAt, revisits, revisit, summaries } = raw;
185
+ if (!isPhase(phase))
186
+ return null;
187
+ const entry = {
188
+ phase,
189
+ updatedAt: typeof updatedAt === "string" ? updatedAt : "",
190
+ };
191
+ if (typeof revisits === "number" && Number.isFinite(revisits)) {
192
+ entry.revisits = Math.max(0, Math.floor(revisits));
193
+ }
194
+ const r = parseRevisit(revisit);
195
+ if (r)
196
+ entry.revisit = r;
197
+ const s = parseSummaries(summaries);
198
+ if (s)
199
+ entry.summaries = s;
200
+ return entry;
201
+ }
202
+ /**
203
+ * Read the registry, discarding anything malformed.
204
+ *
205
+ * Same defensiveness as `loadProjectState`: a hand-edited or half-written file
206
+ * degrades to "no saved projects" rather than throwing into the UI, and one bad
207
+ * entry never takes the others down with it.
208
+ */
209
+ export function loadRegistry(workspace) {
210
+ try {
211
+ const parsed = JSON.parse(fs.readFileSync(registryFile(workspace), "utf8"));
212
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
213
+ return {};
214
+ const out = {};
215
+ for (const [slug, raw] of Object.entries(parsed)) {
216
+ const entry = parseRegistryEntry(raw);
217
+ if (entry)
218
+ out[slug] = entry;
219
+ }
220
+ return out;
221
+ }
222
+ catch {
223
+ return {};
224
+ }
225
+ }
226
+ function saveRegistry(workspace, registry) {
227
+ const file = registryFile(workspace);
228
+ fs.mkdirSync(path.dirname(file), { recursive: true });
229
+ fs.writeFileSync(file, JSON.stringify(registry, null, 2) + "\n");
230
+ }
231
+ /** The registry entry that mirrors a project's live pointer, exactly. */
232
+ function entryFrom(state) {
233
+ const entry = { phase: state.phase, updatedAt: state.updatedAt };
234
+ if (state.revisits)
235
+ entry.revisits = state.revisits;
236
+ if (state.revisit)
237
+ entry.revisit = state.revisit;
238
+ if (state.summaries && Object.keys(state.summaries).length)
239
+ entry.summaries = state.summaries;
240
+ return entry;
241
+ }
242
+ /**
243
+ * Write the active pointer *and* mirror it into the registry.
244
+ *
245
+ * Every writer of `project.json` goes through here so the two can never drift:
246
+ * the registry is derived state, and a save that updated one but not the other
247
+ * would leave `/project list` showing a phase the active project had already
248
+ * left. The entry is replaced wholesale rather than merged — the pointer is the
249
+ * authority for whichever project is active, and merging would resurrect a
250
+ * value that was deleted on purpose (a summary cleared by re-entering a phase,
251
+ * a revisit cleared by acting on it).
252
+ *
253
+ * The registry is read *before* the pointer moves, and the project being
254
+ * displaced is captured on the way past. The agent edits `project.json` itself
255
+ * when it records a scorecard, so the outgoing project's pointer is routinely
256
+ * fresher than its registry entry — and this is the only moment that is still
257
+ * visible, since the pointer is about to be overwritten.
258
+ */
259
+ function writeProjectState(workspace, state) {
260
+ const registry = loadRegistry(workspace);
261
+ const previous = loadProjectState(workspace);
262
+ if (previous && previous.name !== state.name)
263
+ registry[previous.name] = entryFrom(previous);
264
+ fs.mkdirSync(path.dirname(stateFile(workspace)), { recursive: true });
265
+ fs.writeFileSync(stateFile(workspace), JSON.stringify(state, null, 2) + "\n");
266
+ registry[state.name] = entryFrom(state);
267
+ saveRegistry(workspace, registry);
268
+ }
269
+ /**
270
+ * Every known project, most recently touched first.
271
+ *
272
+ * The active project is always included even if the registry has no entry for
273
+ * it (a workspace whose `project.json` predates the registry), so `/project
274
+ * list` can never omit the one project the user is actually in.
275
+ */
276
+ export function listProjects(workspace) {
277
+ const registry = loadRegistry(workspace);
278
+ const active = loadProjectState(workspace);
279
+ const names = new Set(Object.keys(registry));
280
+ if (active)
281
+ names.add(active.name);
282
+ const rows = [];
283
+ for (const name of names) {
284
+ const isActive = active?.name === name;
285
+ const phase = isActive ? active.phase : registry[name]?.phase;
286
+ if (!phase)
287
+ continue;
288
+ rows.push({
289
+ name,
290
+ phase,
291
+ updatedAt: (isActive ? active.updatedAt : registry[name]?.updatedAt) ?? "",
292
+ revisits: isActive ? active.revisits : registry[name]?.revisits,
293
+ active: isActive,
294
+ });
295
+ }
296
+ return rows.sort((a, b) => (b.updatedAt || "").localeCompare(a.updatedAt || "") || a.name.localeCompare(b.name));
297
+ }
298
+ /**
299
+ * Make a saved project active again, restoring its phase, loop-back budget,
300
+ * pending revisit and per-phase summaries.
301
+ *
302
+ * Returns an error message rather than throwing, since the only caller is a
303
+ * slash command reporting back to the user. Resuming the already-active project
304
+ * is a no-op success, so a stray `/project resume <current>` does not look like
305
+ * a failure.
306
+ */
307
+ export function resumeProject(workspace, name) {
308
+ const slug = slugify(name);
309
+ const active = loadProjectState(workspace);
310
+ if (active?.name === slug)
311
+ return { ok: true, name: slug };
312
+ const entry = loadRegistry(workspace)[slug];
313
+ if (!entry) {
314
+ const known = Object.keys(loadRegistry(workspace));
315
+ return {
316
+ ok: false,
317
+ error: known.length
318
+ ? `No project named "${slug}". Known: ${known.join(", ")}.`
319
+ : `No saved projects yet. Start one with /flow-brainstorm <idea>.`,
320
+ };
321
+ }
322
+ const state = {
323
+ name: slug,
324
+ phase: entry.phase,
325
+ updatedAt: new Date().toISOString(),
326
+ };
327
+ if (entry.revisits)
328
+ state.revisits = entry.revisits;
329
+ if (entry.revisit)
330
+ state.revisit = entry.revisit;
331
+ if (entry.summaries)
332
+ state.summaries = entry.summaries;
333
+ writeProjectState(workspace, state);
334
+ return { ok: true, name: slug };
335
+ }
102
336
  /** Parse a phase name from user input (e.g. `/project goto spec`). */
103
337
  export function parsePhase(v) {
104
338
  const t = v.trim().toLowerCase();
@@ -164,15 +398,15 @@ export function loadProjectState(workspace) {
164
398
  };
165
399
  // The agent writes this file itself when it runs autonomously, so anything
166
400
  // 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
- }
401
+ const revisit = parseRevisit(parsed.revisit);
402
+ if (revisit)
403
+ state.revisit = revisit;
173
404
  if (typeof parsed.revisits === "number" && Number.isFinite(parsed.revisits)) {
174
405
  state.revisits = Math.max(0, Math.floor(parsed.revisits));
175
406
  }
407
+ const summaries = parseSummaries(parsed.summaries);
408
+ if (summaries)
409
+ state.summaries = summaries;
176
410
  return state;
177
411
  }
178
412
  catch {
@@ -181,21 +415,36 @@ export function loadProjectState(workspace) {
181
415
  }
182
416
  export function saveProjectState(workspace, name, phase, carry) {
183
417
  const previous = loadProjectState(workspace);
418
+ const slug = slugify(name);
419
+ // A rename is the same project under a new slug, so it hands its own state in
420
+ // rather than looking like a fresh project and starting from zero.
421
+ const same = previous && previous.name === slug ? previous : undefined;
184
422
  const state = {
185
- name: slugify(name),
423
+ name: slug,
186
424
  phase,
187
425
  updatedAt: new Date().toISOString(),
188
426
  };
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;
427
+ const revisits = same?.revisits ?? carry?.revisits;
195
428
  if (revisits)
196
429
  state.revisits = revisits;
197
- fs.mkdirSync(path.dirname(stateFile(workspace)), { recursive: true });
198
- fs.writeFileSync(stateFile(workspace), JSON.stringify(state, null, 2) + "\n");
430
+ // A recorded revisit is a standing note until it is acted on — so it survives
431
+ // a move to any *other* phase. It used to be dropped by every save, which
432
+ // meant running any phase silently ate a note pointing somewhere else, and
433
+ // only /flow ever displayed it. Clearing it when the phase being saved is the
434
+ // one it names is what "acted on" means, whether that came from /flow-back or
435
+ // from the /project goto + /flow-<phase> escape hatch.
436
+ const revisit = same?.revisit ?? carry?.revisit;
437
+ if (revisit && revisit.to !== phase)
438
+ state.revisit = revisit;
439
+ // Summaries accumulate, so they are never dropped by a save — but the save
440
+ // that moves a project *into* a phase must not carry that phase's old
441
+ // summary forward, or `/project` would show last run's scorecard as if it
442
+ // described the run about to happen. Everything else is kept.
443
+ const summaries = { ...(same?.summaries ?? carry?.summaries) };
444
+ delete summaries[phase];
445
+ if (Object.keys(summaries).length)
446
+ state.summaries = summaries;
447
+ writeProjectState(workspace, state);
199
448
  return state;
200
449
  }
201
450
  /**
@@ -208,12 +457,11 @@ export function markRevisit(workspace, to, reason) {
208
457
  const state = loadProjectState(workspace);
209
458
  if (!state)
210
459
  return false;
211
- const next = {
460
+ writeProjectState(workspace, {
212
461
  ...state,
213
462
  revisit: { to, reason },
214
463
  updatedAt: new Date().toISOString(),
215
- };
216
- fs.writeFileSync(stateFile(workspace), JSON.stringify(next, null, 2) + "\n");
464
+ });
217
465
  return true;
218
466
  }
219
467
  /** How many loop-backs this project has left before MAX_REVISITS is reached. */
@@ -240,15 +488,34 @@ export function takeRevisit(workspace, to) {
240
488
  updatedAt: new Date().toISOString(),
241
489
  revisits: used + 1,
242
490
  };
243
- fs.writeFileSync(stateFile(workspace), JSON.stringify(next, null, 2) + "\n");
491
+ // The phase being re-entered loses its summary: it is about to be produced
492
+ // again, and the old one would be stale by the time the user reads it.
493
+ const summaries = { ...state.summaries };
494
+ delete summaries[to];
495
+ if (Object.keys(summaries).length)
496
+ next.summaries = summaries;
497
+ writeProjectState(workspace, next);
244
498
  return true;
245
499
  }
246
500
  /**
247
501
  * End the active workflow. The docs/<name>/ artifacts are left alone — only the
248
502
  * pointer goes, so the agent stops being told to resume a project the user has
249
503
  * moved on from.
504
+ *
505
+ * The registry entry is deliberately *kept*: ending the active workflow parks
506
+ * the project rather than forgetting it, which is what makes `/project resume`
507
+ * able to bring it back later. Forgetting would need an explicit delete, and
508
+ * nothing in the UI asks for one. The live pointer is mirrored one last time on
509
+ * the way out, so a scorecard the agent wrote since the last save is not lost
510
+ * with the file.
250
511
  */
251
512
  export function clearProjectState(workspace) {
513
+ const state = loadProjectState(workspace);
514
+ if (!state)
515
+ return false;
516
+ const registry = loadRegistry(workspace);
517
+ registry[state.name] = entryFrom(state);
518
+ saveRegistry(workspace, registry);
252
519
  try {
253
520
  fs.rmSync(stateFile(workspace));
254
521
  return true;
@@ -290,7 +557,17 @@ export function renameProject(workspace, from, to) {
290
557
  catch (err) {
291
558
  return { ok: false, error: `Could not move docs/${current}/: ${err.message}` };
292
559
  }
560
+ // The registry is keyed by slug, so the rename is a move of the entry rather
561
+ // than a new one — otherwise the old slug lingers as a project that no longer
562
+ // exists and `/project list` offers to resume it into an empty folder. The
563
+ // delete has to come *after* the save: the save re-mirrors the pointer it is
564
+ // replacing, which still carries the old name.
293
565
  saveProjectState(workspace, next, state.phase, state);
566
+ const registry = loadRegistry(workspace);
567
+ if (registry[current]) {
568
+ delete registry[current];
569
+ saveRegistry(workspace, registry);
570
+ }
294
571
  return { ok: true, name: next };
295
572
  }
296
573
  /** The phase before `phase` in PHASE_ORDER, or null for the first one. */
@@ -472,10 +749,31 @@ function approvalLine(phase, artifact, stopAfter) {
472
749
  return (`${write}Then summarize what you produced in a few lines and ask the user to approve ` +
473
750
  `before the ${next} phase (they will run ${PHASE_COMMAND[next]}). Do not advance on your own.`);
474
751
  }
752
+ /**
753
+ * Ask the agent to record a one-line scorecard for the phase it just finished.
754
+ *
755
+ * A gate hands back to the user with the artifact on disk and a few lines in
756
+ * chat, and the chat scrolls away — so `/project` had no way to say what a
757
+ * phase actually concluded without the user opening seven files. The line is
758
+ * written into `project.json` (not a file of its own) because that is the one
759
+ * record already mirrored into the registry, so it survives parking a project.
760
+ *
761
+ * The wording is deliberately literal about read-modify-write: the model is
762
+ * editing a file it does not own, and the keys it must not disturb are the ones
763
+ * that carry the workflow's own state.
764
+ */
765
+ function summaryLine(phase) {
766
+ return (`Last, record a one-line scorecard so /project can show what this phase concluded ` +
767
+ `without the user opening the file: read .kritya/project.json, set "summaries"."${phase}" ` +
768
+ `to a single line under ~100 characters (a headline, e.g. "3 MVP features, FastAPI + SQLite" ` +
769
+ `or "5/6 acceptance criteria met, 1 high finding"), and write the whole file back with ` +
770
+ `every other key left exactly as it was.\n`);
771
+ }
475
772
  /** The instruction sent to the agent to carry out a given phase. */
476
773
  export function phasePrompt(name, phase, userInput, opts = {}) {
477
774
  const slug = slugify(name);
478
775
  const extra = userInput.trim() ? `\n\nUser input for this phase:\n${userInput.trim()}` : "";
776
+ const summary = summaryLine(phase);
479
777
  const cap = capLine(phase);
480
778
  const approve = approvalLine(phase, artifactPath(name, phase), opts.stopAfter ?? shouldStopAfter(phase));
481
779
  switch (phase) {
@@ -495,6 +793,7 @@ export function phasePrompt(name, phase, userInput, opts = {}) {
495
793
  cap +
496
794
  `Do NOT write application code, do NOT design the architecture, and do NOT specify ` +
497
795
  `interfaces yet — this phase settles direction only.\n` +
796
+ summary +
498
797
  approve +
499
798
  extra);
500
799
  case "spec":
@@ -520,6 +819,7 @@ export function phasePrompt(name, phase, userInput, opts = {}) {
520
819
  cap +
521
820
  `Do NOT design the architecture, choose a folder layout, or sequence the work — that is ` +
522
821
  `the plan phase's job. Do NOT write application code.\n` +
822
+ summary +
523
823
  approve +
524
824
  extra);
525
825
  case "plan":
@@ -550,6 +850,7 @@ export function phasePrompt(name, phase, userInput, opts = {}) {
550
850
  cap +
551
851
  `Do NOT restate the spec's requirements, contracts, or data schema — reference them by ` +
552
852
  `number instead. Duplicated requirements drift.\n` +
853
+ summary +
553
854
  approve +
554
855
  extra);
555
856
  case "build":
@@ -583,6 +884,7 @@ export function phasePrompt(name, phase, userInput, opts = {}) {
583
884
  `and its own branch, which keeps this conversation from accumulating the full text of ` +
584
885
  `every file you touch. Give each one the milestone's criteria verbatim; never a summary ` +
585
886
  `of them. Build sequentially otherwise.\n` +
887
+ summary +
586
888
  approve +
587
889
  extra);
588
890
  case "review":
@@ -613,6 +915,7 @@ export function phasePrompt(name, phase, userInput, opts = {}) {
613
915
  `build satisfies the spec. Do not fix anything in this phase — reviewing and fixing in one ` +
614
916
  `pass produces neither a trustworthy review nor a reviewed fix.\n` +
615
917
  cap +
918
+ summary +
616
919
  approve +
617
920
  extra);
618
921
  case "fix":
@@ -641,6 +944,7 @@ export function phasePrompt(name, phase, userInput, opts = {}) {
641
944
  `${BACK_COMMAND} to redo the phases that depend on it. Reserve this for findings that ` +
642
945
  `genuinely belong upstream — everything else gets fixed here.\n` +
643
946
  cap +
947
+ summary +
644
948
  `Write docs/${slug}/fix.md BEFORE you ask for anything. Approval comes after the file ` +
645
949
  `exists — never show the writeup in chat and ask whether to save it. Then summarize the ` +
646
950
  `outcome in a few lines. If anything is still open, say so, and tell the user they can ` +
@@ -663,6 +967,7 @@ export function phasePrompt(name, phase, userInput, opts = {}) {
663
967
  `are not, and every finding review.md raised that fix.md left open. Cite the AC, SEC ` +
664
968
  `and REL labels rather than restating the requirements.\n` +
665
969
  cap +
970
+ summary +
666
971
  `Then summarize in a few lines and ask whether to commit. Do NOT commit, push or tag ` +
667
972
  `anything until the user says yes — and when they do, use a conventional-commit message ` +
668
973
  `describing the project, not the individual edits.\n` +
@@ -10,7 +10,7 @@ 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 { 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";
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";
@@ -63,7 +63,7 @@ export const BUILTIN_COMMANDS = [
63
63
  },
64
64
  {
65
65
  name: "/project",
66
- description: "workflow status · /project goto <phase> · rename <name> · clear",
66
+ description: "workflow status · list · resume <name> · goto <phase> · rename <name> · clear",
67
67
  category: WORKFLOW,
68
68
  },
69
69
  {
@@ -181,6 +181,7 @@ Project workflow: /flow-brainstorm <idea> starts one; /flow runs the next phase.
181
181
  It stops for your approval after ${DEFAULT_GATES.join(", ")}, then runs the rest
182
182
  through to ship. --auto for no stops · --until <phase> to stop early · --fast to
183
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>.
184
185
  Keys: Esc cancels · Tab completes · Shift+Tab cycles normal/accept-edits/dry-run
185
186
  mode · ↑/↓ recalls history · Ctrl+R searches history · Ctrl+P opens the command
186
187
  palette · Ctrl+B copies the last code block · Ctrl+O toggles full tool output ·
@@ -343,6 +344,20 @@ async function runPhase(ctx, phase, run) {
343
344
  * expensive to get wrong.
344
345
  */
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
+ }
346
361
  const phases = planRun(from, req);
347
362
  const chained = phases.length > 1;
348
363
  // One command, so one user line — not one per phase of the stretch.
@@ -351,6 +366,10 @@ async function runFlow(ctx, from, req) {
351
366
  ctx.addItem({ kind: "info", text: `Running ${phases.length} phases: ${phases.join(" → ")}` });
352
367
  ctx.setChained(true);
353
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;
354
373
  try {
355
374
  for (const [i, phase] of phases.entries()) {
356
375
  const outcome = await runPhase(ctx, phase, {
@@ -358,13 +377,16 @@ async function runFlow(ctx, from, req) {
358
377
  force: req.force === true,
359
378
  stopAfter: shouldStopAfter(phase, req),
360
379
  });
361
- if (outcome === "ok")
380
+ if (outcome === "ok") {
381
+ lastRan = phase;
362
382
  continue;
383
+ }
363
384
  // A phase that did not finish stops the chain, because everything after
364
385
  // it reads the artifact it did not (or only half) wrote. Say so: without
365
386
  // this the run just goes quiet after the blocker message, and the user
366
387
  // has to work out for themselves that phases they were promised never
367
388
  // happened.
389
+ stoppedEarly = true;
368
390
  const abandoned = phases.slice(i + 1);
369
391
  if (outcome === "incomplete") {
370
392
  ctx.addItem({
@@ -391,6 +413,19 @@ async function runFlow(ctx, from, req) {
391
413
  if (chained)
392
414
  ctx.setChained(false);
393
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
+ }
394
429
  }
395
430
  /** The standard handler for a single phase command: /flow-spec, /flow-build, … */
396
431
  function phaseCommand(phase) {
@@ -403,6 +438,31 @@ function phaseCommand(phase) {
403
438
  return runFlow(ctx, phase, req);
404
439
  };
405
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.`));
465
+ }
406
466
  const handlers = {
407
467
  "/help": (ctx) => {
408
468
  const customList = ctx.customCommands.length
@@ -789,8 +849,8 @@ const handlers = {
789
849
  ctx.addItem({
790
850
  kind: "info",
791
851
  text: `Starting a new project "${name}". The previous project "${existing.name}" was in the ` +
792
- `${existing.phase} phase; its docs/${existing.name}/ artifacts are untouched, and ` +
793
- `/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.`,
794
854
  });
795
855
  }
796
856
  saveProjectState(ctx.workspace, name, "brainstorm");
@@ -942,8 +1002,37 @@ const handlers = {
942
1002
  "/flow-ship": phaseCommand("ship"),
943
1003
  "/project": (ctx) => {
944
1004
  const [sub = "", ...rest] = ctx.arg.trim().split(/\s+/);
1005
+ const verb = sub.toLowerCase();
945
1006
  const project = loadProjectState(ctx.workspace);
946
- 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") {
947
1036
  if (!project) {
948
1037
  ctx.addItem({ kind: "info", text: "No active project workflow." });
949
1038
  return;
@@ -952,18 +1041,20 @@ const handlers = {
952
1041
  ctx.refreshWorkflow();
953
1042
  ctx.addItem({
954
1043
  kind: "info",
955
- 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.`,
956
1046
  });
957
1047
  return;
958
1048
  }
959
1049
  if (!project) {
960
1050
  ctx.addItem({
961
1051
  kind: "info",
962
- 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.",
963
1054
  });
964
1055
  return;
965
1056
  }
966
- if (sub.toLowerCase() === "rename") {
1057
+ if (verb === "rename") {
967
1058
  const result = renameProject(ctx.workspace, project.name, rest.join(" "));
968
1059
  if (!result.ok) {
969
1060
  ctx.addItem({ kind: "info", text: result.error });
@@ -976,7 +1067,7 @@ const handlers = {
976
1067
  });
977
1068
  return;
978
1069
  }
979
- if (sub.toLowerCase() === "goto") {
1070
+ if (verb === "goto") {
980
1071
  const target = parsePhase(rest.join(" "));
981
1072
  if (!target) {
982
1073
  ctx.addItem({ kind: "info", text: `Usage: /project goto <${PHASE_ORDER.join("|")}>` });
@@ -995,7 +1086,12 @@ const handlers = {
995
1086
  const artifact = artifactPath(project.name, p);
996
1087
  // build has no document of its own, so there is nothing to look for.
997
1088
  const written = artifact !== null && artifactExists(ctx.workspace, project.name, p);
998
- return (` ${marker} ${String(i + 1).padStart(2)}. ${p.padEnd(11)}${PHASE_SUMMARY[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]}` +
999
1095
  `\n ${artifact ?? "(application code)"}${written ? " ✓" : ""}`);
1000
1096
  });
1001
1097
  const stale = staleArtifacts(ctx.workspace, project.name, project.phase);
@@ -1013,7 +1109,7 @@ const handlers = {
1013
1109
  staleBlock +
1014
1110
  revisitBlock +
1015
1111
  `\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.`,
1112
+ `/project goto <phase> to move · rename <name> · list · resume <name> · clear to end it.`,
1017
1113
  });
1018
1114
  },
1019
1115
  "/diff": (ctx) => {
package/dist/headless.js CHANGED
@@ -35,9 +35,15 @@ import { defaultSandboxMode } from "./shell/sandbox.js";
35
35
  * per-phase modes, no artifact bookkeeping, and it runs until it hits the step
36
36
  * limit. Exported so the check itself is testable without spawning a process
37
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.
38
44
  */
39
45
  export function isSlashCommandPrompt(prompt) {
40
- return prompt.trimStart().startsWith("/");
46
+ return /^\/[a-z][a-z0-9-]*(\s|$)/.test(prompt.trimStart());
41
47
  }
42
48
  function emit(args, r) {
43
49
  if (args.output === "json") {
package/dist/ui/App.js CHANGED
@@ -148,7 +148,7 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
148
148
  .catch(() => { });
149
149
  }, [workspace]);
150
150
  useEffect(refreshFileList, [refreshFileList]);
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
+ const { items, addItem, clearItems, phase, setPhase, stream, thinking, activity, setActivity, runningPhase, setRunningPhase, setChained, workflow, refreshWorkflow, prefill, setPrefill, 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({
152
152
  agent,
153
153
  workspace,
154
154
  modelRef,
@@ -168,6 +168,18 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
168
168
  useEffect(() => {
169
169
  onRequestElicitationReady?.(requestElicitation);
170
170
  }, [onRequestElicitationReady, requestElicitation]);
171
+ // Drop a prefilled command into the input line when a phase ends at a gate,
172
+ // so Enter continues the workflow instead of the user retyping `/flow`. The
173
+ // gate still holds: nothing runs until it is submitted, and any edit — or
174
+ // Esc — discards the suggestion. Applied only from the idle prompt, since a
175
+ // prefill arriving while a picker was open would clobber what was typed there.
176
+ useEffect(() => {
177
+ if (prefill === null || phase !== "input")
178
+ return;
179
+ setInput(prefill);
180
+ setInputKey((k) => k + 1);
181
+ setPrefill(null);
182
+ }, [prefill, phase, setPrefill]);
171
183
  // Tick an elapsed-seconds counter while the agent is working.
172
184
  useEffect(() => {
173
185
  if (phase !== "working") {
@@ -374,6 +386,14 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
374
386
  setActivity("cancelling…");
375
387
  abortRef.current?.abort();
376
388
  }
389
+ // Esc clears the idle input line. This is the documented way to decline a
390
+ // command prefilled at a workflow gate; it also just empties a half-typed
391
+ // prompt, which is what a bare Esc on an idle prompt is expected to do.
392
+ if (key.escape && phase === "input" && input) {
393
+ setInput("");
394
+ setInputKey((k) => k + 1);
395
+ return;
396
+ }
377
397
  // Ctrl+O toggles showing full tool output.
378
398
  if (key.ctrl && _input === "o") {
379
399
  setVerbose((v) => !v);
@@ -537,6 +557,7 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
537
557
  setRunningPhase,
538
558
  setChained,
539
559
  refreshWorkflow,
560
+ setPrefill,
540
561
  setCtxPct,
541
562
  setTasks,
542
563
  setPlanMode,
@@ -9,7 +9,7 @@ import { SWITCHYARD_ROUTE_ID, resolveEffectiveModel } from "../provider/switchya
9
9
  import { KillSwitchError } from "../agent/killSwitch.js";
10
10
  import { TAGLINE } from "./Banner.js";
11
11
  import { defaultSandboxMode, sandboxAvailable } from "../shell/sandbox.js";
12
- import { loadProjectState, nextPhase, PHASE_COMMAND, } from "../agent/workflow.js";
12
+ import { loadProjectState, nextPhase, PHASE_ORDER, phaseDoneMessage, phaseIndex, prefillAfter, } from "../agent/workflow.js";
13
13
  import { tavilySearch } from "../tools/webSearch.js";
14
14
  import { killActiveNotice, useKillSwitch } from "./useKillSwitch.js";
15
15
  import { useUsageBudget } from "./useUsageBudget.js";
@@ -69,6 +69,21 @@ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBr
69
69
  : `Resumed previous session (${resumedCount} messages)${taskNote}.`,
70
70
  });
71
71
  }
72
+ // A project parked mid-flow is the single most useful thing to say at
73
+ // launch: without it the user has to remember which phase they were in,
74
+ // and `/flow` on its own does not say where it will resume from.
75
+ const active = loadProjectState(workspace);
76
+ if (active) {
77
+ const next = nextPhase(active.phase);
78
+ initial.push({
79
+ id: nextId.current++,
80
+ kind: "info",
81
+ text: `Project "${active.name}" is at the ${active.phase} phase ` +
82
+ `(${phaseIndex(active.phase)}/${PHASE_ORDER.length}). ` +
83
+ (next ? `/flow continues with ${next} · ` : `every phase has run · `) +
84
+ `/project for status.`,
85
+ });
86
+ }
72
87
  return initial;
73
88
  });
74
89
  const [phase, setPhase] = useState(resumeSessions?.length ? "resume" : "input");
@@ -114,6 +129,16 @@ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBr
114
129
  }, []);
115
130
  /** The active project workflow, for the statusline. Persists between turns. */
116
131
  const [workflow, setWorkflow] = useState(() => loadProjectState(workspace));
132
+ /**
133
+ * Text to drop into the input line once the turn ends.
134
+ *
135
+ * Stopping at a gate costs two steps — read the result, then type the next
136
+ * command — and the second one is pure retyping of something the app already
137
+ * knows. Prefilling it means Enter continues and Esc (or any edit) discards
138
+ * it, without taking the decision away: nothing runs until the user submits.
139
+ * One-shot; the UI clears it as soon as it is applied.
140
+ */
141
+ const [prefill, setPrefill] = useState(null);
117
142
  const [permission, setPermission] = useState(null);
118
143
  const [elicitation, setElicitation] = useState(null);
119
144
  const [model, setModel] = useState(modelRef.current);
@@ -403,6 +428,12 @@ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBr
403
428
  * paraphrasing its instructions drops it often enough that the handoff can't
404
429
  * depend on that — the user is left at a prompt with no idea what comes next.
405
430
  * Printing it here makes it deterministic.
431
+ *
432
+ * This covers a single-phase command only. The last phase of a *chain* is
433
+ * announced by `runFlow` instead: this runs at the end of every turn, which
434
+ * for a chain is while the chain is still going, so it has no way to tell
435
+ * "middle of a chain" from "chain just ended at a gate" — and staying quiet
436
+ * for both is the safe half of that. `runFlow` knows which it is.
406
437
  */
407
438
  const announceNextPhase = useCallback(() => {
408
439
  const ran = runningPhaseRef.current;
@@ -416,13 +447,10 @@ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBr
416
447
  // Prefer the phase on disk: an autonomous turn may have advanced it past
417
448
  // whatever the command started.
418
449
  const current = loadProjectState(workspace)?.phase ?? ran;
419
- const next = nextPhase(current);
420
- addItem({
421
- kind: "info",
422
- text: next
423
- ? `✓ ${current} phase done — next: ${PHASE_COMMAND[next]} or /flow (or /project to review where you are)`
424
- : `✓ ${current} phase done — the workflow is complete. /project clear ends it.`,
425
- });
450
+ addItem({ kind: "info", text: phaseDoneMessage(current) });
451
+ const prefill = prefillAfter(current);
452
+ if (prefill)
453
+ setPrefill(prefill);
426
454
  }, [workspace, addItem, setRunningPhase]);
427
455
  const runWebSearch = async (query) => {
428
456
  if (agent.kill.active) {
@@ -622,6 +650,8 @@ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBr
622
650
  setChained,
623
651
  workflow,
624
652
  refreshWorkflow,
653
+ prefill,
654
+ setPrefill,
625
655
  permission,
626
656
  elicitation,
627
657
  inFlight,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kritya",
3
- "version": "0.8.46-beta",
3
+ "version": "0.8.48-beta",
4
4
  "description": "Kritya — Superintelligence at your command line",
5
5
  "type": "module",
6
6
  "publishConfig": {