kritya 0.8.45-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.
- package/dist/agent/systemPrompt.js +3 -3
- package/dist/agent/workflow.js +184 -9
- package/dist/commands/registry.js +296 -36
- package/dist/headless.js +28 -0
- package/dist/ui/App.js +47 -14
- package/dist/ui/SelectList.js +30 -4
- package/dist/ui/StatusLine.js +2 -1
- package/dist/ui/useAgent.js +15 -1
- package/dist/ui/windowList.js +100 -0
- package/package.json +1 -1
|
@@ -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
|
|
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.
|
|
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()})
|
package/dist/agent/workflow.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
492
|
-
|
|
493
|
-
`once they've decided how to handle what's left
|
|
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,
|
|
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,
|
|
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
|
|
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
|
-
/**
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
660
|
-
//
|
|
661
|
-
|
|
662
|
-
|
|
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": (
|
|
688
|
-
"/flow-plan": (
|
|
689
|
-
"/flow-build": (
|
|
690
|
-
"/flow-review": (
|
|
691
|
-
"/flow-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
|
-
|
|
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
|
-
|
|
756
|
-
|
|
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
|
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 {
|
|
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
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
const
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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
|
}
|
package/dist/ui/SelectList.js
CHANGED
|
@@ -1,10 +1,33 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useState } from "react";
|
|
3
|
-
import { Box, Text, useInput } from "ink";
|
|
4
|
-
|
|
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 (
|
|
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
|
}
|
package/dist/ui/StatusLine.js
CHANGED
|
@@ -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, "
|
|
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
|
}
|
package/dist/ui/useAgent.js
CHANGED
|
@@ -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
|
+
}
|