kritya 0.8.46-beta → 0.8.47-beta
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -20
- package/dist/agent/systemPrompt.js +1 -1
- package/dist/agent/workflow.js +324 -19
- package/dist/commands/registry.js +108 -12
- package/dist/headless.js +7 -1
- package/dist/ui/App.js +22 -1
- package/dist/ui/useAgent.js +38 -8
- package/package.json +1 -1
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`
|
|
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
|
|
206
|
-
— writing a durable artifact for each under `docs/<name>/` and
|
|
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
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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 —
|
|
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
|
|
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()})
|
package/dist/agent/workflow.js
CHANGED
|
@@ -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
|
|
168
|
-
if (
|
|
169
|
-
|
|
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:
|
|
423
|
+
name: slug,
|
|
186
424
|
phase,
|
|
187
425
|
updatedAt: new Date().toISOString(),
|
|
188
426
|
};
|
|
189
|
-
|
|
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
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ·
|
|
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
|
|
793
|
-
`/project
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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 ·
|
|
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()
|
|
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,
|
package/dist/ui/useAgent.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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,
|