@pat-lewczuk/cezar 0.1.0 → 0.1.2

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.
Files changed (57) hide show
  1. package/dist/config.d.ts +62 -0
  2. package/dist/config.js +49 -0
  3. package/dist/config.js.map +1 -0
  4. package/dist/core/agent-runner.d.ts +13 -0
  5. package/dist/core/claude-cli-runner.js +23 -2
  6. package/dist/core/claude-cli-runner.js.map +1 -1
  7. package/dist/git-worktree.d.ts +56 -0
  8. package/dist/git-worktree.js +143 -0
  9. package/dist/git-worktree.js.map +1 -0
  10. package/dist/handoff.d.ts +42 -0
  11. package/dist/handoff.js +120 -0
  12. package/dist/handoff.js.map +1 -0
  13. package/dist/index.js +27 -6
  14. package/dist/index.js.map +1 -1
  15. package/dist/planner.d.ts +17 -0
  16. package/dist/planner.js +244 -0
  17. package/dist/planner.js.map +1 -0
  18. package/dist/runs/store.d.ts +40 -13
  19. package/dist/runs/store.js +33 -3
  20. package/dist/runs/store.js.map +1 -1
  21. package/dist/server/git.d.ts +5 -0
  22. package/dist/server/git.js +38 -0
  23. package/dist/server/git.js.map +1 -1
  24. package/dist/server/github.d.ts +29 -0
  25. package/dist/server/github.js +140 -0
  26. package/dist/server/github.js.map +1 -0
  27. package/dist/server/launch-key.d.ts +7 -0
  28. package/dist/server/launch-key.js +33 -0
  29. package/dist/server/launch-key.js.map +1 -0
  30. package/dist/server/pr.d.ts +22 -0
  31. package/dist/server/pr.js +98 -0
  32. package/dist/server/pr.js.map +1 -0
  33. package/dist/server/server.js +537 -19
  34. package/dist/server/server.js.map +1 -1
  35. package/dist/skills-remote.d.ts +35 -0
  36. package/dist/skills-remote.js +266 -0
  37. package/dist/skills-remote.js.map +1 -0
  38. package/dist/skills.d.ts +20 -6
  39. package/dist/skills.js +77 -12
  40. package/dist/skills.js.map +1 -1
  41. package/dist/todos.d.ts +60 -0
  42. package/dist/todos.js +166 -0
  43. package/dist/todos.js.map +1 -0
  44. package/dist/workflows/load.js +8 -10
  45. package/dist/workflows/load.js.map +1 -1
  46. package/dist/workflows/run.d.ts +62 -3
  47. package/dist/workflows/run.js +351 -45
  48. package/dist/workflows/run.js.map +1 -1
  49. package/dist/workflows/types.d.ts +96 -26
  50. package/dist/workflows/types.js +73 -2
  51. package/dist/workflows/types.js.map +1 -1
  52. package/package.json +1 -1
  53. package/scripts/mock-claude.mjs +118 -0
  54. package/web/app.js +2821 -154
  55. package/web/index.html +82 -23
  56. package/web/open-mercato.svg +11 -0
  57. package/web/style.css +1652 -222
@@ -1,47 +1,149 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { spawn } from 'node:child_process';
3
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
3
5
  import { ClaudeCliRunner } from '../core/claude-cli-runner.js';
6
+ import { HANDOFF_INSTRUCTIONS, appendHandoffHeartbeat, handoffPath, seedHandoffFile, } from '../handoff.js';
7
+ import { todosPath } from '../todos.js';
4
8
  import { discoverSkills } from '../skills.js';
9
+ import { materializeSkillDir } from '../skills-remote.js';
10
+ import { loadConfig } from '../config.js';
11
+ import { autosaveCommit, createWorktree, resolveBaseRef, worktreeDiff } from '../git-worktree.js';
12
+ import { getRepoInfo } from '../server/git.js';
5
13
  import { DEFAULT_ALLOWED_TOOLS, stepKind } from './types.js';
6
14
  const CHECK_OUTPUT_CAP = 20_000;
7
15
  /** An interactive session that hears nothing from the user closes itself. */
8
16
  export const IDLE_TIMEOUT_MS = 15 * 60_000;
17
+ /** Periodic "cezar autosave" commit in the task worktree (spec 006). */
18
+ export const AUTOSAVE_INTERVAL_MS = 90_000;
19
+ /** Variant letters + the fixed diversification hints (spec 010). A runs the
20
+ * task verbatim; B/C get one constant sentence each — zero configuration. */
21
+ export const VARIANT_LETTERS = ['A', 'B', 'C'];
22
+ const VARIANT_HINTS = {
23
+ A: undefined,
24
+ B: 'Approach hint: prefer the minimal, surgical change.',
25
+ C: 'Approach hint: prefer a thorough, structural approach.',
26
+ };
9
27
  /**
10
28
  * The mini workflow engine: executes a `WorkflowDef` against a repo, one step
11
29
  * at a time, persisting every event to the RunStore (which the SSE endpoints
12
30
  * relay live to the GUI). No GitHub choreography — agent steps and shell
13
31
  * checks with bounded retry loops, plus live sessions: the last agent step
14
32
  * stays open for follow-ups (`waiting`) until "finish", idle timeout, or
15
- * cancel.
33
+ * cancel. Runs queue behind `maxParallel` slots and each run executes in its
34
+ * own git worktree on a `cez/<id8>` branch (spec 006), autosave-committed
35
+ * every 90 s — the user's working tree is never touched.
16
36
  */
17
37
  export class RunManager {
18
38
  store;
19
39
  repoRoot;
20
40
  active = new Map();
41
+ // Queue + `starting` set (spec 006, janitor's pump() pattern): `starting`
42
+ // covers the window between shifting a run off the queue and the run
43
+ // registering in `active`, so parallel-slot counting is never racy.
44
+ queue = [];
45
+ starting = new Set();
46
+ pendingJobs = new Map();
47
+ pumping = false;
48
+ /** `.ai/cezar` — where the per-task handoff files and todos.json live. */
49
+ dataDir;
21
50
  constructor(store, repoRoot) {
22
51
  this.store = store;
23
52
  this.repoRoot = repoRoot;
53
+ this.dataDir = join(repoRoot, '.ai/cezar');
24
54
  }
25
- startRun(workflow, input) {
55
+ /** Env the spawned claude gets so the agent can find its handoff file and
56
+ * the global inbox (spec 007). */
57
+ agentEnv(runId) {
58
+ return {
59
+ CEZ_HANDOFF_FILE: handoffPath(this.dataDir, runId),
60
+ CEZ_TODOS_FILE: todosPath(this.dataDir),
61
+ CEZ_TASK_ID: runId,
62
+ };
63
+ }
64
+ startRun(workflow, input, group) {
26
65
  const run = this.store.createRun({
27
- title: makeTitle(input.task),
66
+ title: makeTitle(input.task) + (group ? ` (${group.variant})` : ''),
28
67
  workflow: workflow.name,
29
68
  task: input.task,
30
69
  model: input.model,
70
+ groupId: group?.groupId,
71
+ variant: group?.variant,
31
72
  steps: workflow.steps.map((s) => ({ id: s.id, name: s.name ?? s.id, kind: stepKind(s) })),
32
73
  });
33
- void this.execute(run.id, workflow, input).catch((err) => {
34
- const message = err instanceof Error ? err.message : String(err);
35
- this.store.updateRun(run.id, {
36
- status: 'failed',
37
- error: `engine crashed: ${message}`,
38
- finishedAt: new Date().toISOString(),
39
- });
40
- this.active.delete(run.id);
41
- });
74
+ this.pendingJobs.set(run.id, { workflow, input });
75
+ this.queue.push(run.id);
76
+ void this.pump();
42
77
  return run;
43
78
  }
79
+ /**
80
+ * Parallel variants (spec 010): N runs of the same workflow on the same
81
+ * task, sharing a groupId. Variant A gets the task verbatim; B and C get a
82
+ * fixed one-line approach hint appended to the *task input* (not the step
83
+ * template), so diversification works with any workflow. The normal queue
84
+ * applies — with maxParallel=2 a third variant simply waits.
85
+ */
86
+ startVariants(workflow, input, count) {
87
+ const groupId = randomUUID();
88
+ return VARIANT_LETTERS.slice(0, Math.min(Math.max(count, 1), VARIANT_LETTERS.length)).map((variant) => {
89
+ const hint = VARIANT_HINTS[variant];
90
+ const task = hint ? `${input.task}\n\n${hint}` : input.task;
91
+ return this.startRun(workflow, { ...input, task }, { groupId, variant });
92
+ });
93
+ }
94
+ /**
95
+ * Start queued runs while parallel slots are free. `maxParallel` comes from
96
+ * `.ai/cezar/config.json` (default 2); a non-git directory degrades to 1
97
+ * sequential run in the repo root (spec 006 degradation rule).
98
+ */
99
+ async pump() {
100
+ if (this.pumping)
101
+ return;
102
+ this.pumping = true;
103
+ try {
104
+ const repo = await getRepoInfo(this.repoRoot);
105
+ const maxParallel = repo ? (await loadConfig(this.repoRoot)).maxParallel : 1;
106
+ while (this.queue.length > 0 && this.active.size + this.starting.size < maxParallel) {
107
+ const runId = this.queue.shift();
108
+ if (!runId)
109
+ break;
110
+ const job = this.pendingJobs.get(runId);
111
+ this.pendingJobs.delete(runId);
112
+ if (!job)
113
+ continue;
114
+ this.starting.add(runId);
115
+ void this.execute(runId, job.workflow, job.input).catch((err) => {
116
+ const message = err instanceof Error ? err.message : String(err);
117
+ this.store.updateRun(runId, {
118
+ status: 'failed',
119
+ error: `engine crashed: ${message}`,
120
+ finishedAt: new Date().toISOString(),
121
+ });
122
+ const state = this.active.get(runId);
123
+ if (state) {
124
+ this.clearIdleTimer(state);
125
+ this.clearAutosaveTimer(state);
126
+ }
127
+ this.starting.delete(runId);
128
+ this.active.delete(runId);
129
+ void this.pump();
130
+ });
131
+ }
132
+ }
133
+ finally {
134
+ this.pumping = false;
135
+ }
136
+ }
44
137
  cancel(runId) {
138
+ // Still waiting in the queue: just drop it there.
139
+ const queuedAt = this.queue.indexOf(runId);
140
+ if (queuedAt >= 0) {
141
+ this.queue.splice(queuedAt, 1);
142
+ this.pendingJobs.delete(runId);
143
+ this.store.updateRun(runId, { status: 'cancelled', finishedAt: new Date().toISOString() });
144
+ this.store.appendEvent(runId, { type: 'lifecycle', message: 'cancelled while queued' });
145
+ return true;
146
+ }
45
147
  const state = this.active.get(runId);
46
148
  if (!state)
47
149
  return false;
@@ -51,7 +153,7 @@ export class RunManager {
51
153
  return true;
52
154
  }
53
155
  isActive(runId) {
54
- return this.active.has(runId);
156
+ return this.active.has(runId) || this.starting.has(runId) || this.queue.includes(runId);
55
157
  }
56
158
  /**
57
159
  * Deliver a user message into the run's live claude session (mid-turn or
@@ -83,15 +185,26 @@ export class RunManager {
83
185
  }
84
186
  return delivered;
85
187
  }
86
- /** Close the open session gracefully — the run then completes as `done`. */
188
+ /** Close the open session gracefully — the run then completes as `done`
189
+ * (or rests at `review` when the worktree holds changes, spec 009).
190
+ * On a run already resting at `review` (no session — the engine loop is
191
+ * over), "Finish" is the third review exit: accept the changes without a
192
+ * PR and flip straight to `done`. */
87
193
  finish(runId) {
88
194
  const state = this.active.get(runId);
89
- if (!state?.session?.open)
90
- return false;
91
- this.clearIdleTimer(state);
92
- this.store.appendEvent(runId, { type: 'lifecycle', message: 'session closed by user' });
93
- state.session.end();
94
- return true;
195
+ if (state?.session?.open) {
196
+ this.clearIdleTimer(state);
197
+ this.store.appendEvent(runId, { type: 'lifecycle', message: 'session closed by user' });
198
+ state.session.end();
199
+ return true;
200
+ }
201
+ const run = this.store.getRun(runId);
202
+ if (run?.status === 'review' && !this.isActive(runId)) {
203
+ this.store.updateRun(runId, { status: 'done' });
204
+ this.store.appendEvent(runId, { type: 'lifecycle', message: 'review accepted — finished without a PR' });
205
+ return true;
206
+ }
207
+ return false;
95
208
  }
96
209
  /**
97
210
  * "Continue" (spec 003): reopen a finished run's claude session in-process
@@ -105,7 +218,8 @@ export class RunManager {
105
218
  const run = this.store.getRun(runId);
106
219
  if (!run)
107
220
  return { ok: false, error: 'not found' };
108
- if (!['done', 'failed', 'cancelled'].includes(run.status)) {
221
+ // `review` is continuable too — that's the "Send back" path (spec 009).
222
+ if (!['done', 'failed', 'cancelled', 'review'].includes(run.status)) {
109
223
  return { ok: false, error: `cannot continue a ${run.status} run` };
110
224
  }
111
225
  const sessionId = [...run.steps].reverse().find((s) => s.sessionId)?.sessionId;
@@ -122,12 +236,22 @@ export class RunManager {
122
236
  finishedAt: new Date().toISOString(),
123
237
  });
124
238
  this.active.delete(runId);
239
+ void this.pump();
125
240
  });
126
241
  return { ok: true };
127
242
  }
128
243
  async runContinuation(runId, stepId, sessionId, prompt) {
129
- const state = { cancelled: false, interrupt: () => undefined };
244
+ // Continuation runs in the task's worktree when it still exists (spec
245
+ // 006) — the resumed session sees exactly what the original run left.
246
+ const record = this.store.getRun(runId);
247
+ const cwd = record?.worktreePath && existsSync(record.worktreePath)
248
+ ? record.worktreePath
249
+ : this.repoRoot;
250
+ const state = { cancelled: false, interrupt: () => undefined, cwd };
130
251
  this.active.set(runId, state);
252
+ this.armAutosave(state);
253
+ if (record)
254
+ seedHandoffFile(this.dataDir, record); // idempotent — normally already there
131
255
  this.store.updateRun(runId, {
132
256
  status: 'running',
133
257
  error: undefined,
@@ -144,6 +268,12 @@ export class RunManager {
144
268
  this.store.appendEvent(runId, { type: 'user-message', stepId, text: prompt, imageCount: 0 });
145
269
  let stepCost = 0;
146
270
  const onEvent = (event) => {
271
+ if (event.type === 'image') {
272
+ const saved = this.persistImage(runId, state, event.mediaType, event.data);
273
+ if (saved)
274
+ this.store.appendEvent(runId, { type: 'image', stepId, ...saved });
275
+ return;
276
+ }
147
277
  this.store.appendEvent(runId, { ...event, stepId });
148
278
  if (event.type === 'token-usage') {
149
279
  this.store.updateStep(runId, stepId, { tokensUsed: event.tokensUsed });
@@ -152,17 +282,24 @@ export class RunManager {
152
282
  stepCost += event.usd;
153
283
  this.store.updateStep(runId, stepId, { costUsd: stepCost });
154
284
  }
155
- if (event.type === 'turn-end' && !state.cancelled && state.session?.open) {
156
- this.store.updateRun(runId, { status: 'waiting' });
157
- this.store.updateStep(runId, stepId, { status: 'waiting' });
158
- this.armIdleTimer(runId, state);
285
+ if (event.type === 'turn-end') {
286
+ const waiting = !state.cancelled && state.session?.open;
287
+ if (waiting) {
288
+ this.store.updateRun(runId, { status: 'waiting' });
289
+ this.store.updateStep(runId, stepId, { status: 'waiting' });
290
+ this.armIdleTimer(runId, state);
291
+ }
292
+ appendHandoffHeartbeat(this.dataDir, runId, `turn complete — status=${waiting ? 'waiting' : 'running'}`);
159
293
  }
160
294
  };
161
295
  const runner = new ClaudeCliRunner();
162
296
  const session = runner.startSession({
297
+ systemPrompt: HANDOFF_INSTRUCTIONS,
163
298
  userPrompt: prompt,
164
- cwd: this.repoRoot,
299
+ cwd: state.cwd,
165
300
  allowedTools: DEFAULT_ALLOWED_TOOLS,
301
+ additionalDirectories: [join(this.dataDir, 'runs')],
302
+ env: this.agentEnv(runId),
166
303
  sessionId,
167
304
  resume: true,
168
305
  timeoutMs: 0,
@@ -177,17 +314,19 @@ export class RunManager {
177
314
  this.store.updateStep(runId, stepId, { status: 'cancelled', finishedAt: finishedAt() });
178
315
  this.store.updateRun(runId, { status: 'cancelled', finishedAt: finishedAt(), currentStepId: undefined });
179
316
  this.store.appendEvent(runId, { type: 'lifecycle', message: 'run cancelled' });
317
+ appendHandoffHeartbeat(this.dataDir, runId, `step "${stepId}" complete — status=cancelled`);
180
318
  }
181
319
  else {
182
320
  this.store.updateStep(runId, stepId, { status: 'done', finishedAt: finishedAt() });
183
- this.store.updateRun(runId, { status: 'done', finishedAt: finishedAt(), currentStepId: undefined });
184
321
  this.store.appendEvent(runId, { type: 'step-end', stepId, status: 'done' });
185
- this.store.appendEvent(runId, { type: 'lifecycle', message: 'run finished' });
322
+ await this.settleSuccess(runId);
323
+ appendHandoffHeartbeat(this.dataDir, runId, `step "${stepId}" complete — status=done`);
186
324
  }
187
325
  }
188
326
  catch (err) {
189
327
  const message = err instanceof Error ? err.message : String(err);
190
328
  this.store.updateStep(runId, stepId, { status: 'failed', error: message, finishedAt: finishedAt() });
329
+ appendHandoffHeartbeat(this.dataDir, runId, `step "${stepId}" complete — status=failed`);
191
330
  this.store.updateRun(runId, {
192
331
  status: 'failed',
193
332
  error: `continue failed: ${message}`,
@@ -198,20 +337,74 @@ export class RunManager {
198
337
  }
199
338
  finally {
200
339
  this.clearIdleTimer(state);
340
+ this.clearAutosaveTimer(state);
341
+ if (state.cwd !== this.repoRoot)
342
+ await autosaveCommit(state.cwd);
201
343
  this.active.delete(runId);
344
+ void this.pump();
202
345
  }
203
346
  }
204
347
  // ---- execution -----------------------------------------------------------
205
348
  async execute(runId, workflow, input) {
206
- const state = { cancelled: false, interrupt: () => undefined };
349
+ const state = { cancelled: false, interrupt: () => undefined, cwd: this.repoRoot };
207
350
  this.active.set(runId, state);
351
+ this.starting.delete(runId);
208
352
  const emit = (event) => this.store.appendEvent(runId, event);
209
353
  this.store.updateRun(runId, { status: 'running', startedAt: new Date().toISOString() });
210
354
  emit({ type: 'lifecycle', message: `run started — workflow "${workflow.name}"` });
355
+ // Worktree per task (spec 006): the agent works on its own branch in
356
+ // `.ai/cezar/worktrees/<id>`, never in the user's working tree. Not a git
357
+ // repo, or worktree creation failed → degrade to running in place.
358
+ const repo = await getRepoInfo(this.repoRoot);
359
+ if (repo) {
360
+ // Fork from the configured base branch (config.json `baseBranch`, e.g.
361
+ // `develop`) — also the target of the eventual draft PR. Unresolvable
362
+ // (typo, not fetched) → note + the currently checked-out branch.
363
+ let base = repo.branch;
364
+ const configured = (await loadConfig(this.repoRoot)).baseBranch;
365
+ if (configured) {
366
+ const resolved = await resolveBaseRef(this.repoRoot, configured);
367
+ if (resolved) {
368
+ base = resolved;
369
+ }
370
+ else {
371
+ emit({
372
+ type: 'note',
373
+ message: `configured base branch "${configured}" not found (locally or on origin) — using "${repo.branch}"`,
374
+ });
375
+ }
376
+ }
377
+ try {
378
+ const wt = await createWorktree(this.repoRoot, runId, base);
379
+ state.cwd = wt.path;
380
+ this.store.updateRun(runId, {
381
+ worktreePath: wt.path,
382
+ branch: wt.branch,
383
+ baseBranch: wt.baseBranch,
384
+ });
385
+ emit({ type: 'note', message: `worktree ready — branch ${wt.branch} (base ${wt.baseBranch})` });
386
+ this.armAutosave(state);
387
+ }
388
+ catch (err) {
389
+ const message = err instanceof Error ? err.message : String(err);
390
+ emit({ type: 'note', message: `worktree creation failed (${message}) — running in the repo working tree` });
391
+ }
392
+ }
393
+ else {
394
+ emit({ type: 'note', message: 'not a git repository — running in place, one task at a time' });
395
+ }
396
+ // Handoff journal (spec 007) — seeded after the worktree exists so the
397
+ // header can name the branch. Idempotent: an existing file stays as-is.
398
+ const seeded = this.store.getRun(runId);
399
+ if (seeded)
400
+ seedHandoffFile(this.dataDir, seeded);
211
401
  const skills = await discoverSkills(this.repoRoot);
212
402
  const retriesUsed = new Map();
213
403
  let checkFailure = null;
214
404
  let runError = null;
405
+ // Task screenshots go with the FIRST agent step's opening message only —
406
+ // later steps and retry loops run in fresh sessions without them.
407
+ let startImages = input.images;
215
408
  const lastAgentIdx = findLastAgentStepIndex(workflow);
216
409
  let i = 0;
217
410
  while (i < workflow.steps.length) {
@@ -233,7 +426,8 @@ export class RunManager {
233
426
  // The last agent step of the workflow is interactive: after its turn
234
427
  // the session stays open for follow-ups until finish/idle/cancel.
235
428
  const interactive = i === lastAgentIdx && i === workflow.steps.length - 1;
236
- const failure = await this.runAgentStep(runId, state, step, input, skills, checkFailure, interactive, emit);
429
+ const failure = await this.runAgentStep(runId, state, step, input, skills, checkFailure, interactive, emit, startImages);
430
+ startImages = undefined;
237
431
  checkFailure = null;
238
432
  if (state.cancelled)
239
433
  break;
@@ -277,6 +471,10 @@ export class RunManager {
277
471
  runError = `check "${step.id}" failed${step.onFail ? ` after ${used + 1} attempts` : ''}`;
278
472
  break;
279
473
  }
474
+ // Final autosave: the branch always ends holding the finished state.
475
+ this.clearAutosaveTimer(state);
476
+ if (state.cwd !== this.repoRoot)
477
+ await autosaveCommit(state.cwd);
280
478
  const finishedAt = new Date().toISOString();
281
479
  if (state.cancelled) {
282
480
  const run = this.store.getRun(runId);
@@ -293,25 +491,39 @@ export class RunManager {
293
491
  emit({ type: 'lifecycle', message: `run failed — ${runError}` });
294
492
  }
295
493
  else {
296
- this.store.updateRun(runId, { status: 'done', finishedAt, currentStepId: undefined });
297
- emit({ type: 'lifecycle', message: 'run finished' });
494
+ await this.settleSuccess(runId);
298
495
  }
299
496
  this.clearIdleTimer(state);
300
497
  this.active.delete(runId);
498
+ void this.pump();
301
499
  }
302
500
  /** Returns an error message, or null on success. */
303
- async runAgentStep(runId, state, step, input, skills, checkFailure, interactive, emit) {
501
+ async runAgentStep(runId, state, step, input, skills, checkFailure, interactive, emit, images) {
304
502
  let systemPrompt;
305
503
  if (step.skill) {
306
504
  const skill = skills.find((s) => s.name === step.skill);
307
505
  if (skill) {
308
506
  systemPrompt = skill.body.trim();
507
+ // Directory team skills (SKILL.md + references/) get materialized
508
+ // into <cwd>/.claude/skills/<name>/ — the run's worktree when there
509
+ // is one — so claude sees the companion files on disk; the shared
510
+ // info/exclude keeps them out of git (and out of autosave commits).
511
+ if (skill.source === 'team' && skill.team?.dir) {
512
+ const seeded = await materializeSkillDir(state.cwd, skill).catch(() => false);
513
+ if (seeded) {
514
+ emit({
515
+ type: 'note',
516
+ stepId: step.id,
517
+ message: `team skill "${skill.name}" materialized to .claude/skills/${skill.name}/`,
518
+ });
519
+ }
520
+ }
309
521
  }
310
522
  else {
311
523
  emit({
312
524
  type: 'note',
313
525
  stepId: step.id,
314
- message: `skill "${step.skill}" not found in .ai/cezar/skills or .ai/skills — running with the plain prompt`,
526
+ message: `skill "${step.skill}" not found in .ai/cezar/skills, .ai/skills or the team skills repo — running with the plain prompt`,
315
527
  });
316
528
  }
317
529
  }
@@ -319,12 +531,25 @@ export class RunManager {
319
531
  if (checkFailure) {
320
532
  userPrompt += `\n\nA verification command failed after the previous attempt. Fix the cause. Failing output:\n\n${checkFailure}`;
321
533
  }
534
+ if (images?.length) {
535
+ emit({
536
+ type: 'note',
537
+ stepId: step.id,
538
+ message: `${images.length} screenshot${images.length > 1 ? 's' : ''} attached to the task`,
539
+ });
540
+ }
322
541
  const sessionId = randomUUID();
323
542
  this.store.updateStep(runId, step.id, { sessionId });
324
543
  const stepRecord = this.store.getRun(runId)?.steps.find((s) => s.id === step.id);
325
544
  const startTokens = stepRecord?.tokensUsed ?? 0;
326
545
  let stepCost = stepRecord?.costUsd ?? 0;
327
546
  const onEvent = (event) => {
547
+ if (event.type === 'image') {
548
+ const saved = this.persistImage(runId, state, event.mediaType, event.data);
549
+ if (saved)
550
+ emit({ type: 'image', stepId: step.id, ...saved });
551
+ return;
552
+ }
328
553
  emit({ ...event, stepId: step.id });
329
554
  if (event.type === 'token-usage') {
330
555
  this.store.updateStep(runId, step.id, { tokensUsed: startTokens + event.tokensUsed });
@@ -333,22 +558,35 @@ export class RunManager {
333
558
  stepCost += event.usd;
334
559
  this.store.updateStep(runId, step.id, { costUsd: stepCost });
335
560
  }
336
- if (event.type === 'turn-end' && interactive && !state.cancelled && state.session?.open) {
337
- // Turn over, session open: the ball is in the user's court.
338
- this.store.updateRun(runId, { status: 'waiting' });
339
- this.store.updateStep(runId, step.id, { status: 'waiting' });
340
- this.armIdleTimer(runId, state);
561
+ if (event.type === 'turn-end') {
562
+ const waiting = interactive && !state.cancelled && state.session?.open;
563
+ if (waiting) {
564
+ // Turn over, session open: the ball is in the user's court.
565
+ this.store.updateRun(runId, { status: 'waiting' });
566
+ this.store.updateStep(runId, step.id, { status: 'waiting' });
567
+ this.armIdleTimer(runId, state);
568
+ }
569
+ // Cez's own heartbeat — the handoff stays current even when the
570
+ // agent forgets to write (spec 007).
571
+ appendHandoffHeartbeat(this.dataDir, runId, `turn complete — status=${waiting ? 'waiting' : 'running'}`);
341
572
  }
342
573
  };
343
574
  const runner = new ClaudeCliRunner();
344
575
  let session;
345
576
  try {
346
577
  session = runner.startSession({
347
- systemPrompt,
578
+ // The handoff/todos contract rides along on every agent step.
579
+ systemPrompt: systemPrompt
580
+ ? `${systemPrompt}\n\n---\n\n${HANDOFF_INSTRUCTIONS}`
581
+ : HANDOFF_INSTRUCTIONS,
348
582
  userPrompt,
349
- cwd: this.repoRoot,
583
+ images,
584
+ cwd: state.cwd,
350
585
  allowedTools: step.allowedTools ?? DEFAULT_ALLOWED_TOOLS,
351
586
  bashAllowlist: step.bashAllowlist,
587
+ // The handoff file lives outside the worktree — grant access.
588
+ additionalDirectories: [join(this.dataDir, 'runs')],
589
+ env: this.agentEnv(runId),
352
590
  model: step.model ?? input.model,
353
591
  sessionId,
354
592
  // Interactive sessions have no wall clock — the idle timer rules.
@@ -376,6 +614,57 @@ export class RunManager {
376
614
  state.interrupt = () => undefined;
377
615
  }
378
616
  }
617
+ /**
618
+ * Diff-first review gate (spec 009), shared by `execute` and
619
+ * `runContinuation`: a *successful* run whose worktree holds changes rests
620
+ * at `review` instead of `done` — the user inspects the diff first, then
621
+ * sends feedback back, opens a draft PR, or just finishes. Failed/cancelled
622
+ * runs never enter review; no worktree or an empty diff means plain `done`.
623
+ */
624
+ async settleSuccess(runId) {
625
+ const run = this.store.getRun(runId);
626
+ let review = false;
627
+ if (run?.worktreePath && existsSync(run.worktreePath)) {
628
+ const diff = await worktreeDiff(run.worktreePath, run.baseBranch ?? 'HEAD');
629
+ review = diff.trim().length > 0 && !diff.startsWith('(diff failed');
630
+ }
631
+ this.store.updateRun(runId, {
632
+ status: review ? 'review' : 'done',
633
+ finishedAt: new Date().toISOString(),
634
+ currentStepId: undefined,
635
+ });
636
+ this.store.appendEvent(runId, {
637
+ type: 'lifecycle',
638
+ message: review
639
+ ? 'changes ready for review — send feedback, open a draft PR, or finish'
640
+ : 'run finished',
641
+ });
642
+ }
643
+ /**
644
+ * Agent screenshot (an image block inside a tool result): the base64 data
645
+ * never enters the NDJSON event log — it lands as a file under
646
+ * `.ai/cezar/runs/<id>-images/` and the transcript event carries only the
647
+ * name + serving URL. Best effort: on failure the screenshot is dropped,
648
+ * the transcript still shows the tool result's `[screenshot]` placeholder.
649
+ */
650
+ persistImage(runId, state, mediaType, data) {
651
+ try {
652
+ const ext = /png/.test(mediaType) ? 'png'
653
+ : /jpe?g/.test(mediaType) ? 'jpg'
654
+ : /webp/.test(mediaType) ? 'webp'
655
+ : /gif/.test(mediaType) ? 'gif'
656
+ : 'img';
657
+ state.imageSeq = (state.imageSeq ?? 0) + 1;
658
+ const name = `screenshot-${state.imageSeq}.${ext}`;
659
+ const dir = join(this.dataDir, 'runs', `${runId}-images`);
660
+ mkdirSync(dir, { recursive: true });
661
+ writeFileSync(join(dir, name), Buffer.from(data, 'base64'));
662
+ return { name, url: `/api/runs/${runId}/images/${name}` };
663
+ }
664
+ catch {
665
+ return null;
666
+ }
667
+ }
379
668
  armIdleTimer(runId, state) {
380
669
  this.clearIdleTimer(state);
381
670
  state.idleTimer = setTimeout(() => {
@@ -395,11 +684,27 @@ export class RunManager {
395
684
  state.idleTimer = undefined;
396
685
  }
397
686
  }
687
+ /** Autosave-commit the worktree every 90 s while the run lives (spec 006). */
688
+ armAutosave(state) {
689
+ if (state.cwd === this.repoRoot || state.autosaveTimer)
690
+ return;
691
+ state.autosaveTimer = setInterval(() => {
692
+ void autosaveCommit(state.cwd);
693
+ }, AUTOSAVE_INTERVAL_MS);
694
+ state.autosaveTimer.unref?.();
695
+ }
696
+ clearAutosaveTimer(state) {
697
+ if (state.autosaveTimer) {
698
+ clearInterval(state.autosaveTimer);
699
+ state.autosaveTimer = undefined;
700
+ }
701
+ }
398
702
  runCheckStep(state, step, emit) {
399
703
  const command = step.command;
400
704
  emit({ type: 'note', stepId: step.id, message: `$ ${command}` });
401
705
  return new Promise((resolve) => {
402
- const child = spawn('bash', ['-lc', command], { cwd: this.repoRoot, env: process.env });
706
+ // Check steps run in the same cwd as the agent steps — the worktree.
707
+ const child = spawn('bash', ['-lc', command], { cwd: state.cwd, env: process.env });
403
708
  state.interrupt = () => child.kill('SIGTERM');
404
709
  let output = '';
405
710
  const collect = (chunk) => {
@@ -414,13 +719,13 @@ export class RunManager {
414
719
  child.on('error', (err) => {
415
720
  state.interrupt = () => undefined;
416
721
  const message = `failed to spawn: ${err.message}`;
417
- emit({ type: 'check-output', stepId: step.id, text: message, exitCode: -1 });
722
+ emit({ type: 'check-output', stepId: step.id, command, text: message, exitCode: -1 });
418
723
  resolve({ ok: false, output: message });
419
724
  });
420
725
  child.on('close', (code) => {
421
726
  state.interrupt = () => undefined;
422
727
  const trimmed = output.trim() || '(no output)';
423
- emit({ type: 'check-output', stepId: step.id, text: trimmed, exitCode: code ?? -1 });
728
+ emit({ type: 'check-output', stepId: step.id, command, text: trimmed, exitCode: code ?? -1 });
424
729
  resolve({ ok: code === 0, output: trimmed });
425
730
  });
426
731
  });
@@ -432,6 +737,7 @@ export class RunManager {
432
737
  finishedAt: new Date().toISOString(),
433
738
  });
434
739
  emit({ type: 'step-end', stepId, status, ...(error ? { error } : {}) });
740
+ appendHandoffHeartbeat(this.dataDir, runId, `step "${stepId}" complete — status=${status}`);
435
741
  }
436
742
  }
437
743
  function findLastAgentStepIndex(workflow) {