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.
@@ -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
- return { name: parsed.name, phase: parsed.phase, updatedAt: parsed.updatedAt ?? "" };
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: slugify(name),
423
+ name: slug,
115
424
  phase,
116
425
  updatedAt: new Date().toISOString(),
117
426
  };
118
- fs.mkdirSync(path.dirname(stateFile(workspace)), { recursive: true });
119
- fs.writeFileSync(stateFile(workspace), JSON.stringify(state, null, 2) + "\n");
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
- saveProjectState(workspace, next, state.phase);
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 run ` +
492
- `${PHASE_COMMAND.review} to re-check the whole build, or run ${PHASE_COMMAND.fix} again ` +
493
- `once they've decided how to handle what's left.\n` +
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
  }