@zenera/cli 1.1.25 → 1.1.26

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 (96) hide show
  1. package/README.md +13 -1
  2. package/dist/catalog.d.ts +20 -0
  3. package/dist/catalog.js +60 -0
  4. package/dist/commands/index.js +2 -0
  5. package/dist/commands/inspect.d.ts +5 -1
  6. package/dist/commands/inspect.js +54 -14
  7. package/dist/commands/memory.js +28 -3
  8. package/dist/commands/meta.js +24 -5
  9. package/dist/commands/models.js +2 -1
  10. package/dist/commands/sandbox.js +73 -3
  11. package/dist/commands/view.d.ts +8 -0
  12. package/dist/commands/view.js +114 -0
  13. package/dist/engine.d.ts +3 -1
  14. package/dist/engine.js +6 -0
  15. package/dist/eventlog.d.ts +5 -0
  16. package/dist/eventlog.js +25 -1
  17. package/dist/liveness.js +20 -2
  18. package/dist/main.js +0 -0
  19. package/dist/meta/editor.d.ts +1 -1
  20. package/dist/meta/editor.js +4 -21
  21. package/dist/meta/finetune/analyze-brief.d.ts +26 -0
  22. package/dist/meta/finetune/analyze-brief.js +131 -0
  23. package/dist/meta/finetune/apply-brief.d.ts +23 -0
  24. package/dist/meta/finetune/apply-brief.js +247 -0
  25. package/dist/meta/finetune/brief.d.ts +45 -0
  26. package/dist/meta/finetune/brief.js +158 -0
  27. package/dist/meta/finetune/command.d.ts +1 -1
  28. package/dist/meta/finetune/command.js +224 -46
  29. package/dist/meta/finetune/cost.d.ts +46 -0
  30. package/dist/meta/finetune/cost.js +105 -0
  31. package/dist/meta/finetune/dataset/types.d.ts +3 -1
  32. package/dist/meta/finetune/feedback.d.ts +14 -2
  33. package/dist/meta/finetune/feedback.js +22 -3
  34. package/dist/meta/finetune/guard.d.ts +29 -0
  35. package/dist/meta/finetune/guard.js +104 -0
  36. package/dist/meta/finetune/improvements.d.ts +50 -3
  37. package/dist/meta/finetune/improvements.js +290 -33
  38. package/dist/meta/finetune/live.d.ts +17 -7
  39. package/dist/meta/finetune/live.js +56 -10
  40. package/dist/meta/finetune/loop.js +59 -10
  41. package/dist/meta/finetune/recheck.d.ts +58 -0
  42. package/dist/meta/finetune/recheck.js +311 -0
  43. package/dist/meta/finetune/report.d.ts +9 -2
  44. package/dist/meta/finetune/report.js +551 -438
  45. package/dist/meta/finetune/sampler.js +8 -2
  46. package/dist/meta/finetune/train.d.ts +6 -2
  47. package/dist/meta/finetune/train.js +55 -21
  48. package/dist/meta/finetune/tui.d.ts +3 -3
  49. package/dist/meta/finetune/tui.js +37 -12
  50. package/dist/meta/finetune/tuning.d.ts +27 -1
  51. package/dist/meta/finetune/tuning.js +21 -2
  52. package/dist/meta/finetune/usage.d.ts +4 -0
  53. package/dist/meta/finetune/usage.js +35 -0
  54. package/dist/meta/finetune/view.d.ts +9 -0
  55. package/dist/meta/finetune/view.js +27 -0
  56. package/dist/meta/host.d.ts +2 -1
  57. package/dist/meta/host.js +2 -1
  58. package/dist/meta.d.ts +291 -0
  59. package/dist/meta.js +981 -0
  60. package/dist/resources.d.ts +215 -0
  61. package/dist/resources.js +856 -0
  62. package/dist/scaffold.d.ts +3 -4
  63. package/dist/scaffold.js +21 -9
  64. package/dist/trace.d.ts +5 -0
  65. package/dist/trace.js +54 -5
  66. package/dist/tui/app.js +19 -5
  67. package/dist/tui/links.d.ts +27 -0
  68. package/dist/tui/links.js +142 -0
  69. package/dist/tui/models.d.ts +3 -1
  70. package/dist/tui/models.js +65 -7
  71. package/dist/tui/sandbox.d.ts +10 -0
  72. package/dist/tui/sandbox.js +131 -0
  73. package/package.json +3 -2
  74. package/templates/editor/.github/copilot-instructions.md +29 -43
  75. package/templates/editor/.github/prompts/analyze.prompt.md +3 -1
  76. package/templates/editor/.github/prompts/finetune-apply.prompt.md +221 -42
  77. package/templates/editor/.github/prompts/project-review.prompt.md +1 -1
  78. package/templates/editor/.github/skills/zen-analyze-run/SKILL.md +162 -57
  79. package/templates/editor/.github/skills/zen-cli/references/finetune.md +74 -14
  80. package/templates/editor/.github/skills/zen-cli/references/inspect.md +16 -2
  81. package/templates/editor/.github/skills/zen-cli/references/projects.md +4 -6
  82. package/templates/editor/.github/skills/zen-cli/references/sandbox.md +25 -13
  83. package/templates/editor/.github/skills/zen-finetune/SKILL.md +74 -33
  84. package/templates/editor/.github/skills/zen-inspect/SKILL.md +14 -1
  85. package/templates/editor/.github/skills/zen-inspect-ask/SKILL.md +4 -3
  86. package/templates/editor/.github/skills/zen-instructions/SKILL.md +4 -12
  87. package/templates/editor/.github/skills/zen-memory/SKILL.md +64 -39
  88. package/templates/editor/.github/skills/zen-review/SKILL.md +6 -31
  89. package/templates/editor/.github/skills/zen-review/scripts/check-paths.sh +1 -2
  90. package/templates/editor/.github/skills/zen-review/scripts/review.sh +3 -12
  91. package/templates/editor/.github/skills/zen-sandbox/SKILL.md +2 -1
  92. package/templates/editor/.github/skills/zen-spec-sync/SKILL.md +2 -4
  93. package/templates/project/agents/fork-instructions.md +78 -28
  94. package/templates/project/agents/memory-instructions.md +222 -493
  95. package/templates/project/agents/tools-instructions.md +7 -0
  96. package/templates/editor/.github/skills/zen-memory/scripts/check-instructions.sh +0 -116
@@ -1,11 +1,24 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { basename, join } from 'node:path';
4
+ import { writeApplyBrief } from "./apply-brief.js";
5
+ import { costChanges, limits, measure, overLimits } from "./cost.js";
4
6
  import { errorIn, sizeOf } from "./failure.js";
5
- import { writeApply } from "./report.js";
7
+ import { forProse } from "./feedback.js";
8
+ import { patchFiles, writeApply } from "./report.js";
6
9
  import { readJson, Stopped } from "./tuning.js";
7
- import { envelopesIn, spent } from "./usage.js";
10
+ import { applyTokens, spent } from "./usage.js";
8
11
  const pad = (n) => String(n).padStart(3, '0');
12
+ const kept = (d) => d?.decision === 'applied' || d?.decision === 'merged';
13
+ const DECIDED = new Set(['applied', 'merged', 'rejected', 'deferred', 'structural']);
14
+ /** `<request> <improvement>` for each improvement of inputs.json that decisions.json gives no valid decision. */
15
+ export function undecided(dir) {
16
+ const inputs = readJson(join(dir, 'inputs.json')) ?? [];
17
+ const decided = readJson(join(dir, 'decisions.json')) ?? {};
18
+ return inputs.flatMap((f) => forProse(f)
19
+ .filter((i) => !DECIDED.has(decided[f.id]?.[i.id]?.decision ?? ''))
20
+ .map((i) => `${f.id} ${i.id}`));
21
+ }
9
22
  export class Improvements {
10
23
  #t;
11
24
  pending = [];
@@ -53,10 +66,85 @@ export class Improvements {
53
66
  isApplied(id) {
54
67
  return this.#applied.has(id);
55
68
  }
56
- /** Parks until an apply has taken this feedback, the worker free meanwhile. Taken already - after a restart - returns at once. */
69
+ /**
70
+ * Every apply's decisions, later ones over earlier, per improvement. An edit
71
+ * that was undone kept nothing, so what it applied or merged reads `discarded`.
72
+ */
73
+ decisions() {
74
+ const all = {};
75
+ for (const a of this.applies()) {
76
+ const undone = a.applied?.discarded;
77
+ for (const [id, byImprovement] of Object.entries(readJson(join(a.dir, 'decisions.json')) ?? {})) {
78
+ for (const [i, d] of Object.entries(byImprovement ?? {})) {
79
+ (all[id] ??= {})[i] =
80
+ undone && kept(d)
81
+ ? {
82
+ ...d,
83
+ decision: 'discarded',
84
+ note: `${d.note ?? ''} - undone: ${undone}`.trim(),
85
+ }
86
+ : d;
87
+ }
88
+ }
89
+ }
90
+ return all;
91
+ }
92
+ /** Improvements held back for want of a second case, and not taken up since. */
93
+ deferred() {
94
+ const open = new Map();
95
+ for (const a of this.applies()) {
96
+ const inputs = new Map((a.inputs ?? []).map((f) => [f.id, f]));
97
+ const undone = Boolean(a.applied?.discarded);
98
+ for (const [id, byImprovement] of Object.entries(readJson(join(a.dir, 'decisions.json')) ?? {})) {
99
+ for (const [i, d] of Object.entries(byImprovement ?? {})) {
100
+ const key = `${id} ${i}`;
101
+ if (d?.decision === 'deferred') {
102
+ const improvement = inputs.get(id)?.improvements.find((x) => x.id === i) ??
103
+ open.get(key)?.improvement;
104
+ if (improvement) {
105
+ open.set(key, {
106
+ request: id,
107
+ case: inputs.get(id)?.case ?? open.get(key)?.case ?? id.split('@')[0],
108
+ apply: a.name,
109
+ improvement,
110
+ ...(d.note ? { note: d.note } : {}),
111
+ });
112
+ }
113
+ }
114
+ else if (!undone) {
115
+ open.delete(key);
116
+ }
117
+ }
118
+ }
119
+ }
120
+ return [...open.values()];
121
+ }
122
+ /** The skills an apply made or changed for this request: the case's next run should load them. */
123
+ skillsFor(id) {
124
+ const mine = this.decisions()[id] ?? {};
125
+ return [
126
+ ...new Set(Object.values(mine)
127
+ .filter((d) => kept(d) && d.skill)
128
+ .map((d) => d.skill)),
129
+ ];
130
+ }
131
+ /** Nothing of this request was applied, merged or deferred, and some of it is structural: prose cannot fix it. */
132
+ #structural(id) {
133
+ const mine = Object.values(this.decisions()[id] ?? {});
134
+ const structural = mine.filter((d) => d.decision === 'structural');
135
+ if (structural.length === 0 ||
136
+ mine.some((d) => kept(d) || d.decision === 'deferred' || d.decision === 'discarded')) {
137
+ return undefined;
138
+ }
139
+ return structural.map((d) => d.note || 'structural').join('; ');
140
+ }
141
+ /**
142
+ * Parks until an apply has taken this feedback, the worker free meanwhile. Taken already - after a restart - returns at once.
143
+ * `structural` when the apply found nothing prose can fix.
144
+ */
57
145
  async apply(feedback, s) {
58
146
  if (this.#applied.has(feedback.id)) {
59
- return;
147
+ return this.#outcome(feedback.id);
60
148
  }
61
149
  if (!this.pending.some((f) => f.id === feedback.id)) {
62
150
  this.pending.push(feedback);
@@ -68,7 +156,7 @@ export class Improvements {
68
156
  case: feedback.case,
69
157
  phase: feedback.phase,
70
158
  attempt: feedback.attempt,
71
- detail: `${feedback.improvements.length} improvement(s), worker freed`,
159
+ detail: `${forProse(feedback).length} improvement(s), worker freed`,
72
160
  });
73
161
  this.#t.unseat(s);
74
162
  try {
@@ -82,7 +170,24 @@ export class Improvements {
82
170
  finally {
83
171
  this.parked--;
84
172
  }
173
+ const outcome = this.#outcome(feedback.id);
174
+ if (outcome.structural) {
175
+ return outcome;
176
+ }
85
177
  await this.#t.seat(s, true);
178
+ return outcome;
179
+ }
180
+ #outcome(id) {
181
+ const structural = this.#structural(id);
182
+ return structural ? { structural } : {};
183
+ }
184
+ /** A recheck's request: queued like a parked case's, with no case waiting on it. */
185
+ regressed(feedback) {
186
+ if (!this.#applied.has(feedback.id) &&
187
+ !this.pending.some((f) => f.id === feedback.id) &&
188
+ !this.inApply.some((f) => f.id === feedback.id)) {
189
+ this.pending.push(feedback);
190
+ }
86
191
  }
87
192
  /** Before a run starts: wait out an apply, or stop. */
88
193
  async notApplying() {
@@ -96,7 +201,10 @@ export class Improvements {
96
201
  async loop() {
97
202
  while (!this.#t.finished) {
98
203
  await this.#t.tick();
99
- if (this.#t.stopping || this.pending.length === 0) {
204
+ if (this.#t.stopping) {
205
+ continue;
206
+ }
207
+ if (this.pending.length === 0) {
100
208
  continue;
101
209
  }
102
210
  if (this.pending.length < this.#t.config.applyAt &&
@@ -112,6 +220,20 @@ export class Improvements {
112
220
  }
113
221
  }
114
222
  }
223
+ /** What the agents read when the tuning began - its first version, even for a tuning begun before this was kept. */
224
+ async baseline() {
225
+ const file = join(this.#t.dir, 'baseline.json');
226
+ const saved = readJson(file)?.cost;
227
+ if (saved) {
228
+ return saved;
229
+ }
230
+ const first = this.#t.system.rows()[0];
231
+ const at = first ? this.#t.system.snapshotDir(first.v) : undefined;
232
+ const cost = await measure(at && existsSync(at) ? at : this.#t.root);
233
+ mkdirSync(this.#t.dir, { recursive: true });
234
+ writeFileSync(file, `${JSON.stringify({ at: new Date().toISOString(), version: first?.v ?? 0, cost }, null, 2)}\n`);
235
+ return cost;
236
+ }
115
237
  async #applyNow() {
116
238
  const t = this.#t;
117
239
  this.applying = true;
@@ -139,13 +261,23 @@ export class Improvements {
139
261
  const t = this.#t;
140
262
  const startedAt = new Date().toISOString();
141
263
  mkdirSync(dir, { recursive: true });
142
- writeFileSync(join(dir, 'inputs.json'), `${JSON.stringify(batch.map((f) => ({
143
- ...f,
144
- file: join(t.attemptDir({ id: f.case, rev: f.caseRev }, f.phase, f.attempt), 'feedback.json'),
145
- })), null, 2)}\n`);
146
- const total = batch.reduce((n, f) => n + f.improvements.length, 0);
264
+ writeFileSync(join(dir, 'inputs.json'), `${JSON.stringify(batch.map((f) => {
265
+ const at = f.dir ?? t.attemptDir({ id: f.case, rev: f.caseRev }, f.phase, f.attempt);
266
+ const missed = readJson(join(at, 'run.json'))?.missedSkills;
267
+ const { dir: _, ...rest } = f;
268
+ return {
269
+ ...rest,
270
+ improvements: forProse(f),
271
+ file: join(at, 'feedback.json'),
272
+ ...(missed?.length ? { missedSkills: missed } : {}),
273
+ };
274
+ }), null, 2)}\n`);
275
+ writeFileSync(join(dir, 'deferred.json'), `${JSON.stringify(this.deferred(), null, 2)}\n`);
276
+ const total = batch.reduce((n, f) => n + forProse(f).length, 0);
147
277
  this.current = { since: Date.now(), name, dir, requests: batch.length };
148
278
  let to = from;
279
+ let discarded;
280
+ let bytes;
149
281
  if (total > 0) {
150
282
  t.event({
151
283
  what: 'apply',
@@ -153,17 +285,20 @@ export class Improvements {
153
285
  });
154
286
  writeApply(t, dir);
155
287
  try {
156
- await this.#edit(dir, from);
288
+ const done = await this.#edit(dir, from);
289
+ bytes = done.bytes;
290
+ if ('discarded' in done) {
291
+ discarded = done.discarded;
292
+ }
293
+ else {
294
+ to = done.to;
295
+ }
157
296
  }
158
297
  catch (err) {
159
298
  writeFileSync(join(dir, 'failed.json'), `${JSON.stringify({ at: new Date().toISOString(), startedAt, error: err.message }, null, 2)}\n`);
160
299
  writeApply(t, dir);
161
300
  throw err;
162
301
  }
163
- to = t.system.record('apply', name).v;
164
- if (to !== from) {
165
- writeFileSync(join(dir, 'diff.patch'), t.system.diff(from, to));
166
- }
167
302
  }
168
303
  const applied = {
169
304
  ids: batch.map((f) => f.id),
@@ -172,6 +307,8 @@ export class Improvements {
172
307
  at: new Date().toISOString(),
173
308
  startedAt,
174
309
  ...(total === 0 ? { skipped: true } : {}),
310
+ ...(discarded ? { discarded } : {}),
311
+ ...(bytes ? { bytes } : {}),
175
312
  };
176
313
  writeFileSync(join(dir, 'applied.json'), `${JSON.stringify(applied, null, 2)}\n`);
177
314
  writeApply(t, dir);
@@ -181,22 +318,78 @@ export class Improvements {
181
318
  this.count++;
182
319
  this.current = undefined;
183
320
  t.event({
184
- what: 'applied',
185
- detail: `${name}: v${from} -> v${to}, see applies/${name}/APPLY.md`,
186
- result: total === 0 ? 'nothing to change' : `v${from} → v${to}`,
321
+ what: discarded ? 'apply undone' : 'applied',
322
+ detail: `${name}: ${discarded ? `undone - ${discarded}` : `v${from} -> v${to}`}, see applies/${name}/APPLY.md`,
323
+ result: total === 0 ? 'nothing to change' : discarded ? 'undone' : `v${from} → v${to}`,
187
324
  took: Date.now() - Date.parse(startedAt),
188
- tokens: spent(envelopesIn(dir, 'apply')),
325
+ tokens: spent(applyTokens(dir)),
189
326
  });
327
+ if (!discarded && to !== from) {
328
+ t.rechecks.queue(name);
329
+ }
190
330
  }
191
- /** The meta agent edits; `zen check` must pass, or the edit is undone and tried once more. */
331
+ /** What an apply is handed about the prose: sizes, limits, and which files grew. */
332
+ async #writeBudget(dir, now) {
333
+ const start = await this.baseline();
334
+ const { files: _, ...rest } = now;
335
+ writeFileSync(join(dir, 'budget.json'), `${JSON.stringify({
336
+ ...rest,
337
+ start: { total: start.total, alwaysRead: start.alwaysRead },
338
+ ...limits(start, this.#t.config),
339
+ }, null, 2)}\n`);
340
+ const touched = new Map();
341
+ for (const a of this.applies()) {
342
+ if (!a.applied || a.applied.discarded || !existsSync(join(a.dir, 'diff.patch'))) {
343
+ continue;
344
+ }
345
+ for (const f of patchFiles(readFileSync(join(a.dir, 'diff.patch'), 'utf8'))) {
346
+ touched.set(f.file, [...(touched.get(f.file) ?? []), a.name]);
347
+ }
348
+ }
349
+ const growth = Object.keys(now.files)
350
+ .map((file) => ({
351
+ file,
352
+ start: start.files[file] ?? 0,
353
+ now: now.files[file],
354
+ changes: touched.get(file)?.length ?? 0,
355
+ recent: (touched.get(file) ?? []).slice(-5),
356
+ }))
357
+ .sort((a, b) => b.now - b.start - (a.now - a.start));
358
+ writeFileSync(join(dir, 'growth.json'), `${JSON.stringify(growth, null, 2)}\n`);
359
+ }
360
+ /**
361
+ * The meta agent edits, then two gates: `zen check` and the budget. A failed
362
+ * gate undoes the edit and the agent tries once more, told why. `zen check`
363
+ * failing twice is fatal - the agent cannot leave the project loadable; the
364
+ * budget failing twice only undoes it. What the edit broke elsewhere is the
365
+ * rechecks' to find, after it.
366
+ */
192
367
  async #edit(dir, from) {
193
368
  const t = this.#t;
369
+ const name = basename(dir);
370
+ const before = await measure(t.root);
371
+ await this.#writeBudget(dir, before);
372
+ const bound = limits(await this.baseline(), t.config);
373
+ const gate = { before, limits: bound };
374
+ writeFileSync(join(dir, 'gate.json'), `${JSON.stringify(gate)}\n`);
375
+ const unchanged = { before: before.total, after: before.total };
376
+ const at = (step) => {
377
+ if (this.current) {
378
+ this.current.step = step;
379
+ }
380
+ };
194
381
  for (let attempt = 1;; attempt++) {
382
+ at('edit');
195
383
  const session = randomUUID();
196
384
  writeFileSync(join(dir, 'session'), `${session}\n`);
197
385
  const applyLog = join(dir, 'apply.log');
198
386
  const checkLog = join(dir, 'check.log');
199
387
  const offset = { apply: sizeOf(applyLog), check: sizeOf(checkLog) };
388
+ // An undone attempt's record must not pass for this one's.
389
+ for (const f of ['decisions.json', 'changes.md']) {
390
+ rmSync(join(dir, f), { force: true });
391
+ }
392
+ const brief = await writeApplyBrief(t, dir, attempt);
200
393
  const res = await t.zen([
201
394
  'meta',
202
395
  'run',
@@ -206,24 +399,88 @@ export class Improvements {
206
399
  session,
207
400
  '--events',
208
401
  join(dir, 'apply.events.jsonl'),
402
+ '--prompt-file',
403
+ brief,
209
404
  '/finetune-apply',
210
405
  dir,
211
406
  ], applyLog);
212
407
  writeFileSync(join(dir, attempt === 1 ? 'apply.json' : `apply-${attempt}.json`), res.stdout);
408
+ // An agent cut off by its provider can still exit 0, having decided nothing.
409
+ const missing = res.code === 0 ? undecided(dir) : [];
410
+ if (missing.length > 0) {
411
+ writeFileSync(join(dir, 'missing.md'), [
412
+ `# Attempt ${attempt} was undone: decisions missing`,
413
+ '',
414
+ 'Written by the tuning loop: the apply ended without a decision for these improvements, so the project was put back. This attempt must write `decisions.json` with every one of them, and `changes.md`.',
415
+ '',
416
+ ...missing.map((m) => `- ${m}`),
417
+ '',
418
+ ].join('\n'));
419
+ t.error(`apply ${name}${attempt > 1 ? ` try ${attempt}` : ''}`, `the apply ended without a decision for ${missing.length} improvement(s)`, applyLog);
420
+ t.system.restore(from);
421
+ if (attempt === 2) {
422
+ throw new Error(`apply ${dir} ended twice without deciding every improvement - see missing.md`);
423
+ }
424
+ t.event({
425
+ what: 'apply retry',
426
+ detail: `no decision for ${missing.length} improvement(s)`,
427
+ });
428
+ continue;
429
+ }
430
+ at('check');
213
431
  const check = await t.zen(['check', '--no-models', '--no-sandbox'], checkLog);
214
- if (res.code === 0 && check.code === 0) {
215
- return;
432
+ if (res.code !== 0 || check.code !== 0) {
433
+ const e = res.code !== 0
434
+ ? errorIn(applyLog, offset.apply)
435
+ : errorIn(checkLog, offset.check);
436
+ t.error(`apply ${name}${attempt > 1 ? ` try ${attempt}` : ''}`, `${res.code !== 0 ? 'the apply failed' : 'zen check failed after the apply'}${e.message ? `: ${e.message}` : ''}`, e.log);
437
+ t.system.restore(from);
438
+ if (attempt === 2) {
439
+ throw new Error(`apply ${dir} left the project broken twice - see check.log`);
440
+ }
441
+ t.event({
442
+ what: 'apply retry',
443
+ detail: res.code !== 0 ? 'the apply failed' : 'zen check failed',
444
+ });
445
+ continue;
216
446
  }
217
- const e = res.code !== 0 ? errorIn(applyLog, offset.apply) : errorIn(checkLog, offset.check);
218
- t.error(`apply ${basename(dir)}${attempt > 1 ? ` try ${attempt}` : ''}`, `${res.code !== 0 ? 'the apply failed' : 'zen check failed after the apply'}${e.message ? `: ${e.message}` : ''}`, e.log);
219
- t.system.restore(from);
220
- if (attempt === 2) {
221
- throw new Error(`apply ${dir} left the project broken twice - see check.log`);
447
+ const after = await measure(t.root);
448
+ const over = overLimits(before, after, bound);
449
+ if (over.length > 0) {
450
+ writeFileSync(join(dir, 'budget.md'), [
451
+ `# Attempt ${attempt} was undone: over the limits`,
452
+ '',
453
+ 'Written by the tuning loop: this edit grew what the agents read past a limit, so the project was put back. The next attempt reads this file to know what to cut.',
454
+ '',
455
+ ...over.map((o) => `- ${o}`),
456
+ '',
457
+ '## What moved',
458
+ '',
459
+ ...costChanges(before, after),
460
+ '',
461
+ ].join('\n'));
462
+ t.system.restore(from);
463
+ if (attempt === 2) {
464
+ return { discarded: `over the limits: ${over[0]}`, bytes: unchanged };
465
+ }
466
+ t.event({ what: 'apply retry', detail: `over the limits: ${over[0]}` });
467
+ continue;
222
468
  }
223
- t.event({
224
- what: 'apply retry',
225
- detail: res.code !== 0 ? 'the apply failed' : 'zen check failed',
226
- });
469
+ const to = t.system.record('apply', name).v;
470
+ if (to === from) {
471
+ return { to, bytes: unchanged };
472
+ }
473
+ writeFileSync(join(dir, 'diff.patch'), t.system.diff(from, to));
474
+ const moved = costChanges(before, after);
475
+ writeFileSync(join(dir, 'cost.md'), [
476
+ '# What moved',
477
+ '',
478
+ 'Written by the tuning loop once this apply passed its gates: bytes before and after it - what each agent reads on every call, each skill body, each file under agents/.',
479
+ '',
480
+ ...(moved.length > 0 ? moved : ['Nothing the agents read changed size.']),
481
+ '',
482
+ ].join('\n'));
483
+ return { to, bytes: { before: before.total, after: after.total } };
227
484
  }
228
485
  }
229
486
  /** Requests every apply so far took, for the report. */
@@ -1,11 +1,7 @@
1
1
  import type { EventLine } from '../host.ts';
2
2
  import type { StepFailure, Tuning } from './tuning.ts';
3
- import { type Stage } from './usage.ts';
4
- export interface ModelSpend {
5
- calls: number;
6
- input: number;
7
- output: number;
8
- }
3
+ import { type Stage, type Tokens } from './usage.ts';
4
+ export type ModelSpend = Tokens;
9
5
  /** One events file, folded as it grows. */
10
6
  export interface Activity {
11
7
  model?: string;
@@ -13,8 +9,14 @@ export interface Activity {
13
9
  input: number;
14
10
  output: number;
15
11
  byModel: Record<string, ModelSpend>;
16
- /** the latest tool call or thing said */
12
+ /** the latest tool call, with what was said just before it, or the latest thing said */
17
13
  last?: string;
14
+ /** said since the last tool call */
15
+ said?: string;
16
+ /** what the agent is doing now: `says: …`, `thinks: …` or `runs: …` */
17
+ now?: string;
18
+ /** the last few `now` lines, oldest first */
19
+ recent: string[];
18
20
  /** the latest lines, oldest first */
19
21
  lines: EventLine[];
20
22
  }
@@ -27,6 +29,10 @@ export interface NowRow {
27
29
  sinceMs: number;
28
30
  stage?: Stage;
29
31
  act?: Activity;
32
+ /** what it is doing now, in one line */
33
+ now?: string;
34
+ /** the last few such lines, oldest first */
35
+ recent?: string[];
30
36
  }
31
37
  export interface TokenRow extends ModelSpend {
32
38
  stage: Stage;
@@ -61,6 +67,10 @@ export interface Snapshot {
61
67
  /** the latest step failures, newest first */
62
68
  errors: StepFailure[];
63
69
  }
70
+ /** What the applier runs once the agent is done. */
71
+ export declare const CHECK_NOW = "runs: zen check --no-models --no-sandbox";
72
+ /** `now` lines a step keeps. */
73
+ export declare const KEEP_NOW = 6;
64
74
  /** Reads only what each events file gained since the last look; a file cleared for a new step starts over. */
65
75
  export declare class Tails {
66
76
  #private;
@@ -7,7 +7,27 @@ const KEEP_ERRORS = 5;
7
7
  const PHASE_WORD = { nomem: 'no memory', mem: 'with memory' };
8
8
  /** Not log rows: a start shows in `now` and ends in an event of its own, an error has its own section. */
9
9
  const UNLOGGED = new Set(['run', 'analyze', 'apply', 'memory merge', 'idle', 'error']);
10
- const blank = () => ({ calls: 0, input: 0, output: 0, byModel: {}, lines: [] });
10
+ /** Shell tools: their subject is the command itself. */
11
+ const SHELL = new Set(['bash', 'run_command', 'run_command_background']);
12
+ /** What the applier runs once the agent is done. */
13
+ export const CHECK_NOW = 'runs: zen check --no-models --no-sandbox';
14
+ /** `now` lines a step keeps. */
15
+ export const KEEP_NOW = 6;
16
+ const blank = () => ({
17
+ calls: 0,
18
+ input: 0,
19
+ output: 0,
20
+ byModel: {},
21
+ recent: [],
22
+ lines: [],
23
+ });
24
+ function doing(a, line) {
25
+ a.now = line;
26
+ a.recent.push(line);
27
+ if (a.recent.length > KEEP_NOW) {
28
+ a.recent.shift();
29
+ }
30
+ }
11
31
  function fold(a, text) {
12
32
  let e;
13
33
  try {
@@ -26,16 +46,24 @@ function fold(a, text) {
26
46
  a.calls++;
27
47
  a.input += e.in;
28
48
  a.output += e.out;
29
- const m = (a.byModel[model] ??= { calls: 0, input: 0, output: 0 });
49
+ const m = (a.byModel[model] ??= { calls: 0, input: 0, cached: 0, output: 0, reasoning: 0 });
30
50
  m.calls++;
31
51
  m.input += e.in;
52
+ m.cached += e.cached;
32
53
  m.output += e.out;
54
+ m.reasoning += e.reasoning;
33
55
  }
34
56
  else if (e.type === 'tool' && e.phase === 'start') {
35
- a.last = `${e.name} ${e.subject}`.trim();
57
+ const tool = `${e.name} ${e.subject}`.trim();
58
+ a.last = a.said ? `${a.said} · ${tool}` : tool;
59
+ a.said = undefined;
60
+ doing(a, `runs: ${SHELL.has(e.name) && e.subject ? e.subject : tool}`);
36
61
  }
37
- else if (e.type === 'say') {
62
+ else if (e.type === 'say' || e.type === 'think') {
63
+ // A call's thought comes before what it said, so the words win.
64
+ a.said = e.text;
38
65
  a.last = e.text;
66
+ doing(a, `${e.type === 'say' ? 'says' : 'thinks'}: ${e.text}`);
39
67
  }
40
68
  }
41
69
  /** Reads only what each events file gained since the last look; a file cleared for a new step starts over. */
@@ -117,27 +145,34 @@ export function snapshot(t) {
117
145
  if (!w.case || (w.step !== 'run' && w.step !== 'analyze')) {
118
146
  continue;
119
147
  }
120
- const dir = t.attemptDir(w.case, w.phase ?? 'nomem', w.attempt ?? 1);
148
+ const dir = t.workDir({ ...w, case: w.case });
149
+ const act = watch(join(dir, w.step === 'run' ? 'events.jsonl' : 'analyze.events.jsonl'));
121
150
  now.push({
122
151
  key: `w${w.slot}`,
123
152
  who: `worker ${w.slot}`,
124
153
  step: w.step,
125
- what: `${w.case.id} · ${PHASE_WORD[w.phase ?? 'nomem']} try ${w.attempt ?? 1}`,
154
+ what: `${w.case.id} · ${w.recheck ? 'recheck' : PHASE_WORD[w.phase ?? 'nomem']} try ${w.attempt ?? 1}`,
126
155
  sinceMs: clock - w.since,
127
156
  stage: w.step,
128
- act: watch(join(dir, w.step === 'run' ? 'events.jsonl' : 'analyze.events.jsonl')),
157
+ act,
158
+ ...(act.now ? { now: act.now, recent: [...act.recent] } : {}),
129
159
  });
130
160
  }
131
161
  const q = t.improvements;
132
162
  if (q.current?.dir) {
163
+ const checking = q.current.step === 'check';
164
+ const act = watch(join(q.current.dir, 'apply.events.jsonl'));
165
+ const line = checking ? CHECK_NOW : act.now;
166
+ const recent = checking ? [...act.recent, CHECK_NOW].slice(-KEEP_NOW) : [...act.recent];
133
167
  now.push({
134
168
  key: 'apply',
135
169
  who: 'applier',
136
- step: 'apply',
170
+ step: checking ? 'check' : 'apply',
137
171
  what: `apply ${basename(q.current.dir)} · ${q.current.requests} request(s)`,
138
172
  sinceMs: clock - q.current.since,
139
173
  stage: 'apply',
140
- act: watch(join(q.current.dir, 'apply.events.jsonl')),
174
+ act,
175
+ ...(line ? { now: line, recent } : {}),
141
176
  });
142
177
  }
143
178
  else if (q.current) {
@@ -163,10 +198,20 @@ export function snapshot(t) {
163
198
  const sums = new Map();
164
199
  const add = (stage, model, s) => {
165
200
  const key = `${stage}\u0000${model}`;
166
- const row = sums.get(key) ?? { stage, model, calls: 0, input: 0, output: 0 };
201
+ const row = sums.get(key) ?? {
202
+ stage,
203
+ model,
204
+ calls: 0,
205
+ input: 0,
206
+ cached: 0,
207
+ output: 0,
208
+ reasoning: 0,
209
+ };
167
210
  row.calls += s.calls;
168
211
  row.input += s.input;
212
+ row.cached += s.cached;
169
213
  row.output += s.output;
214
+ row.reasoning += s.reasoning;
170
215
  sums.set(key, row);
171
216
  };
172
217
  for (const stage of STAGES) {
@@ -218,6 +263,7 @@ export function publish(t) {
218
263
  // A live view that failed to draw must not stop the tuning.
219
264
  return undefined;
220
265
  }
266
+ t.live = snap;
221
267
  for (const watcher of t.watchers) {
222
268
  watcher(snap);
223
269
  }