kritya 0.8.45-beta → 0.8.47-beta
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -20
- package/dist/agent/systemPrompt.js +3 -3
- package/dist/agent/workflow.js +492 -12
- package/dist/commands/registry.js +400 -44
- package/dist/headless.js +34 -0
- package/dist/ui/App.js +68 -14
- package/dist/ui/SelectList.js +30 -4
- package/dist/ui/StatusLine.js +2 -1
- package/dist/ui/useAgent.js +52 -8
- package/dist/ui/windowList.js +100 -0
- package/package.json +1 -1
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,40 @@ 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";
|
|
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
|
+
}
|
|
20
90
|
/** One-line summary of what each phase produces, for help text and the system prompt. */
|
|
21
91
|
export const PHASE_SUMMARY = {
|
|
22
92
|
brainstorm: "problem, users, MVP features, recommended stack",
|
|
@@ -25,6 +95,7 @@ export const PHASE_SUMMARY = {
|
|
|
25
95
|
build: "the application code, with tests per acceptance criterion, written test-first",
|
|
26
96
|
review: "spec-compliance, security, and reliability findings, with a scorecard up top",
|
|
27
97
|
fix: "fixes for the review's findings, each re-verified",
|
|
98
|
+
ship: "a handover summary, the test suite actually run, and an optional commit",
|
|
28
99
|
};
|
|
29
100
|
/**
|
|
30
101
|
* Word budget for each phase's artifact. These docs are read by every phase
|
|
@@ -37,14 +108,231 @@ const PHASE_WORD_CAP = {
|
|
|
37
108
|
plan: 1000,
|
|
38
109
|
review: 800,
|
|
39
110
|
fix: 600,
|
|
111
|
+
ship: 500,
|
|
40
112
|
};
|
|
113
|
+
/**
|
|
114
|
+
* How many times a project may loop back before the workflow refuses.
|
|
115
|
+
*
|
|
116
|
+
* A loop is fix -> spec -> plan -> build -> review -> fix, so it is not free,
|
|
117
|
+
* and without a bound a disagreement the model cannot resolve on its own
|
|
118
|
+
* becomes an unbounded spend. Past this the user drives it by hand.
|
|
119
|
+
*/
|
|
120
|
+
export const MAX_REVISITS = 2;
|
|
41
121
|
const STATE_REL = path.join(".kritya", "project.json");
|
|
122
|
+
const REGISTRY_REL = path.join(".kritya", "projects.json");
|
|
42
123
|
export function stateFile(workspace) {
|
|
43
124
|
return path.join(workspace, STATE_REL);
|
|
44
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
|
+
}
|
|
45
140
|
function isPhase(v) {
|
|
46
141
|
return typeof v === "string" && PHASE_ORDER.includes(v);
|
|
47
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
|
+
}
|
|
48
336
|
/** Parse a phase name from user input (e.g. `/project goto spec`). */
|
|
49
337
|
export function parsePhase(v) {
|
|
50
338
|
const t = v.trim().toLowerCase();
|
|
@@ -103,28 +391,131 @@ export function loadProjectState(workspace) {
|
|
|
103
391
|
const parsed = JSON.parse(fs.readFileSync(stateFile(workspace), "utf8"));
|
|
104
392
|
if (!parsed || typeof parsed.name !== "string" || !isPhase(parsed.phase))
|
|
105
393
|
return null;
|
|
106
|
-
|
|
394
|
+
const state = {
|
|
395
|
+
name: parsed.name,
|
|
396
|
+
phase: parsed.phase,
|
|
397
|
+
updatedAt: parsed.updatedAt ?? "",
|
|
398
|
+
};
|
|
399
|
+
// The agent writes this file itself when it runs autonomously, so anything
|
|
400
|
+
// malformed is treated as absent rather than allowed into the UI.
|
|
401
|
+
const revisit = parseRevisit(parsed.revisit);
|
|
402
|
+
if (revisit)
|
|
403
|
+
state.revisit = revisit;
|
|
404
|
+
if (typeof parsed.revisits === "number" && Number.isFinite(parsed.revisits)) {
|
|
405
|
+
state.revisits = Math.max(0, Math.floor(parsed.revisits));
|
|
406
|
+
}
|
|
407
|
+
const summaries = parseSummaries(parsed.summaries);
|
|
408
|
+
if (summaries)
|
|
409
|
+
state.summaries = summaries;
|
|
410
|
+
return state;
|
|
107
411
|
}
|
|
108
412
|
catch {
|
|
109
413
|
return null;
|
|
110
414
|
}
|
|
111
415
|
}
|
|
112
|
-
export function saveProjectState(workspace, name, phase) {
|
|
416
|
+
export function saveProjectState(workspace, name, phase, carry) {
|
|
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;
|
|
113
422
|
const state = {
|
|
114
|
-
name:
|
|
423
|
+
name: slug,
|
|
115
424
|
phase,
|
|
116
425
|
updatedAt: new Date().toISOString(),
|
|
117
426
|
};
|
|
118
|
-
|
|
119
|
-
|
|
427
|
+
const revisits = same?.revisits ?? carry?.revisits;
|
|
428
|
+
if (revisits)
|
|
429
|
+
state.revisits = revisits;
|
|
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);
|
|
120
448
|
return state;
|
|
121
449
|
}
|
|
450
|
+
/**
|
|
451
|
+
* Record that `phase` has to change before a finding can be fixed — the
|
|
452
|
+
* programmatic counterpart to the agent writing the same field with
|
|
453
|
+
* `write_file` during an autonomous run. Returns false when there is no
|
|
454
|
+
* active project, since there is nothing to attach the note to.
|
|
455
|
+
*/
|
|
456
|
+
export function markRevisit(workspace, to, reason) {
|
|
457
|
+
const state = loadProjectState(workspace);
|
|
458
|
+
if (!state)
|
|
459
|
+
return false;
|
|
460
|
+
writeProjectState(workspace, {
|
|
461
|
+
...state,
|
|
462
|
+
revisit: { to, reason },
|
|
463
|
+
updatedAt: new Date().toISOString(),
|
|
464
|
+
});
|
|
465
|
+
return true;
|
|
466
|
+
}
|
|
467
|
+
/** How many loop-backs this project has left before MAX_REVISITS is reached. */
|
|
468
|
+
export function revisitsRemaining(workspace) {
|
|
469
|
+
return Math.max(0, MAX_REVISITS - (loadProjectState(workspace)?.revisits ?? 0));
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Rewind to `to` and spend one of the project's loop-backs.
|
|
473
|
+
*
|
|
474
|
+
* Returns false when there is no project or the budget is spent — the caller
|
|
475
|
+
* reports that rather than looping anyway, which is what bounds the cycle.
|
|
476
|
+
* The recorded `revisit` is consumed here: acting on it is what it was for.
|
|
477
|
+
*/
|
|
478
|
+
export function takeRevisit(workspace, to) {
|
|
479
|
+
const state = loadProjectState(workspace);
|
|
480
|
+
if (!state)
|
|
481
|
+
return false;
|
|
482
|
+
const used = state.revisits ?? 0;
|
|
483
|
+
if (used >= MAX_REVISITS)
|
|
484
|
+
return false;
|
|
485
|
+
const next = {
|
|
486
|
+
name: state.name,
|
|
487
|
+
phase: to,
|
|
488
|
+
updatedAt: new Date().toISOString(),
|
|
489
|
+
revisits: used + 1,
|
|
490
|
+
};
|
|
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);
|
|
498
|
+
return true;
|
|
499
|
+
}
|
|
122
500
|
/**
|
|
123
501
|
* End the active workflow. The docs/<name>/ artifacts are left alone — only the
|
|
124
502
|
* pointer goes, so the agent stops being told to resume a project the user has
|
|
125
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.
|
|
126
511
|
*/
|
|
127
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);
|
|
128
519
|
try {
|
|
129
520
|
fs.rmSync(stateFile(workspace));
|
|
130
521
|
return true;
|
|
@@ -166,7 +557,17 @@ export function renameProject(workspace, from, to) {
|
|
|
166
557
|
catch (err) {
|
|
167
558
|
return { ok: false, error: `Could not move docs/${current}/: ${err.message}` };
|
|
168
559
|
}
|
|
169
|
-
|
|
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.
|
|
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
|
+
}
|
|
170
571
|
return { ok: true, name: next };
|
|
171
572
|
}
|
|
172
573
|
/** The phase before `phase` in PHASE_ORDER, or null for the first one. */
|
|
@@ -207,6 +608,21 @@ export function artifactExists(workspace, name, phase) {
|
|
|
207
608
|
* and it would improvise the requirements instead.
|
|
208
609
|
*/
|
|
209
610
|
export function phaseBlocker(workspace, name, phase) {
|
|
611
|
+
// Build writes code rather than a document, so the "did the previous phase
|
|
612
|
+
// leave its artifact behind" test passes for review trivially — and always.
|
|
613
|
+
// That let `/flow-review` run against a project that had never been built,
|
|
614
|
+
// dispatching subagents to review nothing. There is no file to look for, so
|
|
615
|
+
// fall back to the one piece of evidence there is: the recorded phase. Only
|
|
616
|
+
// consulted when there *is* a recorded phase, so a bare workspace behaves as
|
|
617
|
+
// before.
|
|
618
|
+
if (phase === "review") {
|
|
619
|
+
const recorded = loadProjectState(workspace)?.phase;
|
|
620
|
+
if (recorded && PHASE_ORDER.indexOf(recorded) < PHASE_ORDER.indexOf("build")) {
|
|
621
|
+
return (`The review phase checks what the build phase produced, and "${name}" has not ` +
|
|
622
|
+
`reached build yet (it is at ${recorded}). Run ${PHASE_COMMAND.build} first.`);
|
|
623
|
+
}
|
|
624
|
+
return null;
|
|
625
|
+
}
|
|
210
626
|
const prev = previousPhase(phase);
|
|
211
627
|
if (!prev || artifactExists(workspace, name, prev))
|
|
212
628
|
return null;
|
|
@@ -315,7 +731,7 @@ function capLine(phase) {
|
|
|
315
731
|
* the plan phase the belated write lands after plan mode has already been
|
|
316
732
|
* turned off by the next command.
|
|
317
733
|
*/
|
|
318
|
-
function approvalLine(phase, artifact) {
|
|
734
|
+
function approvalLine(phase, artifact, stopAfter) {
|
|
319
735
|
const next = nextPhase(phase);
|
|
320
736
|
const write = artifact
|
|
321
737
|
? `Write ${artifact} BEFORE you ask for anything. Approval comes after the file exists — ` +
|
|
@@ -323,15 +739,43 @@ function approvalLine(phase, artifact) {
|
|
|
323
739
|
: "";
|
|
324
740
|
if (!next)
|
|
325
741
|
return `${write}Finish by summarizing the findings for the user.`;
|
|
742
|
+
// An ungated phase is followed immediately by the next one in the same run,
|
|
743
|
+
// so asking for approval here would stall a stretch the user asked to run
|
|
744
|
+
// through — and the question would arrive after the next phase had started.
|
|
745
|
+
if (!stopAfter) {
|
|
746
|
+
return (`${write}Then summarize what you produced in a few lines and carry straight on into the ` +
|
|
747
|
+
`${next} phase (${PHASE_SUMMARY[next]}). Do not stop for approval.`);
|
|
748
|
+
}
|
|
326
749
|
return (`${write}Then summarize what you produced in a few lines and ask the user to approve ` +
|
|
327
750
|
`before the ${next} phase (they will run ${PHASE_COMMAND[next]}). Do not advance on your own.`);
|
|
328
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
|
+
}
|
|
329
772
|
/** The instruction sent to the agent to carry out a given phase. */
|
|
330
|
-
export function phasePrompt(name, phase, userInput) {
|
|
773
|
+
export function phasePrompt(name, phase, userInput, opts = {}) {
|
|
331
774
|
const slug = slugify(name);
|
|
332
775
|
const extra = userInput.trim() ? `\n\nUser input for this phase:\n${userInput.trim()}` : "";
|
|
776
|
+
const summary = summaryLine(phase);
|
|
333
777
|
const cap = capLine(phase);
|
|
334
|
-
const approve = approvalLine(phase, artifactPath(name, phase));
|
|
778
|
+
const approve = approvalLine(phase, artifactPath(name, phase), opts.stopAfter ?? shouldStopAfter(phase));
|
|
335
779
|
switch (phase) {
|
|
336
780
|
case "brainstorm":
|
|
337
781
|
return (`PROJECT WORKFLOW — BRAINSTORM phase for project "${slug}".\n` +
|
|
@@ -349,6 +793,7 @@ export function phasePrompt(name, phase, userInput) {
|
|
|
349
793
|
cap +
|
|
350
794
|
`Do NOT write application code, do NOT design the architecture, and do NOT specify ` +
|
|
351
795
|
`interfaces yet — this phase settles direction only.\n` +
|
|
796
|
+
summary +
|
|
352
797
|
approve +
|
|
353
798
|
extra);
|
|
354
799
|
case "spec":
|
|
@@ -374,6 +819,7 @@ export function phasePrompt(name, phase, userInput) {
|
|
|
374
819
|
cap +
|
|
375
820
|
`Do NOT design the architecture, choose a folder layout, or sequence the work — that is ` +
|
|
376
821
|
`the plan phase's job. Do NOT write application code.\n` +
|
|
822
|
+
summary +
|
|
377
823
|
approve +
|
|
378
824
|
extra);
|
|
379
825
|
case "plan":
|
|
@@ -404,6 +850,7 @@ export function phasePrompt(name, phase, userInput) {
|
|
|
404
850
|
cap +
|
|
405
851
|
`Do NOT restate the spec's requirements, contracts, or data schema — reference them by ` +
|
|
406
852
|
`number instead. Duplicated requirements drift.\n` +
|
|
853
|
+
summary +
|
|
407
854
|
approve +
|
|
408
855
|
extra);
|
|
409
856
|
case "build":
|
|
@@ -437,6 +884,7 @@ export function phasePrompt(name, phase, userInput) {
|
|
|
437
884
|
`and its own branch, which keeps this conversation from accumulating the full text of ` +
|
|
438
885
|
`every file you touch. Give each one the milestone's criteria verbatim; never a summary ` +
|
|
439
886
|
`of them. Build sequentially otherwise.\n` +
|
|
887
|
+
summary +
|
|
440
888
|
approve +
|
|
441
889
|
extra);
|
|
442
890
|
case "review":
|
|
@@ -467,6 +915,7 @@ export function phasePrompt(name, phase, userInput) {
|
|
|
467
915
|
`build satisfies the spec. Do not fix anything in this phase — reviewing and fixing in one ` +
|
|
468
916
|
`pass produces neither a trustworthy review nor a reviewed fix.\n` +
|
|
469
917
|
cap +
|
|
918
|
+
summary +
|
|
470
919
|
approve +
|
|
471
920
|
extra);
|
|
472
921
|
case "fix":
|
|
@@ -485,12 +934,43 @@ export function phasePrompt(name, phase, userInput) {
|
|
|
485
934
|
`verdict per finding — fixed, still open, or newly broken — in docs/${slug}/fix.md.\n` +
|
|
486
935
|
`If anything is still open after the subagent's check, say so plainly; do not report success ` +
|
|
487
936
|
`on a finding that didn't actually verify as fixed.\n` +
|
|
937
|
+
`If a finding cannot be fixed in code because the requirement itself is wrong — an ` +
|
|
938
|
+
`acceptance criterion that is ambiguous or contradicts another, a plan milestone that ` +
|
|
939
|
+
`does not survive contact with the code — do NOT work around it. Leave it open, say so ` +
|
|
940
|
+
`plainly, and record where it belongs by writing .kritya/project.json with a "revisit" ` +
|
|
941
|
+
`field alongside the existing keys: { "name": "${slug}", "phase": "fix", ` +
|
|
942
|
+
`"revisit": { "to": "spec", "reason": "<one line>" } }. Use "spec" when the ` +
|
|
943
|
+
`requirement is wrong and "plan" when the design is. That is what lets the user run ` +
|
|
944
|
+
`${BACK_COMMAND} to redo the phases that depend on it. Reserve this for findings that ` +
|
|
945
|
+
`genuinely belong upstream — everything else gets fixed here.\n` +
|
|
488
946
|
cap +
|
|
947
|
+
summary +
|
|
489
948
|
`Write docs/${slug}/fix.md BEFORE you ask for anything. Approval comes after the file ` +
|
|
490
949
|
`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
|
|
950
|
+
`outcome in a few lines. If anything is still open, say so, and tell the user they can ` +
|
|
951
|
+
`run ${PHASE_COMMAND.review} to re-check the whole build, ${PHASE_COMMAND.fix} again ` +
|
|
952
|
+
`once they've decided how to handle what's left, or ${BACK_COMMAND} <phase> if the ` +
|
|
953
|
+
`finding belongs upstream. Otherwise they can run ${PHASE_COMMAND.ship} to finish.\n` +
|
|
954
|
+
extra);
|
|
955
|
+
case "ship":
|
|
956
|
+
return (`PROJECT WORKFLOW — SHIP phase for project "${slug}". Plan mode is OFF.\n` +
|
|
957
|
+
`Read docs/${slug}/spec.md, plan.md, review.md and fix.md. This is the handover, so it ` +
|
|
958
|
+
`has to be trustworthy rather than encouraging.\n` +
|
|
959
|
+
`First, actually run the project's test suite and report the real result — the exact ` +
|
|
960
|
+
`command you ran and what it printed. If there is no test suite, say that plainly ` +
|
|
961
|
+
`instead of implying one passed. A handover that claims success without running ` +
|
|
962
|
+
`anything is worse than no handover at all.\n` +
|
|
963
|
+
`Then write docs/${slug}/ship.md BEFORE you ask anything — approval comes after the ` +
|
|
964
|
+
`file exists, so never show the handover in chat and ask whether to save it. It should ` +
|
|
965
|
+
`cover: what was built and where the code lives, how to run ` +
|
|
966
|
+
`it and how to run its tests, which acceptance criteria from spec.md are met and which ` +
|
|
967
|
+
`are not, and every finding review.md raised that fix.md left open. Cite the AC, SEC ` +
|
|
968
|
+
`and REL labels rather than restating the requirements.\n` +
|
|
969
|
+
cap +
|
|
970
|
+
summary +
|
|
971
|
+
`Then summarize in a few lines and ask whether to commit. Do NOT commit, push or tag ` +
|
|
972
|
+
`anything until the user says yes — and when they do, use a conventional-commit message ` +
|
|
973
|
+
`describing the project, not the individual edits.\n` +
|
|
494
974
|
extra);
|
|
495
975
|
}
|
|
496
976
|
}
|