@zenera/cli 1.1.22 → 1.1.24

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 (73) hide show
  1. package/README.md +72 -2
  2. package/dist/archive.d.ts +139 -0
  3. package/dist/archive.js +376 -0
  4. package/dist/ask.d.ts +51 -0
  5. package/dist/ask.js +143 -0
  6. package/dist/batch.d.ts +77 -0
  7. package/dist/batch.js +338 -0
  8. package/dist/command.d.ts +6 -0
  9. package/dist/commands/export.d.ts +3 -0
  10. package/dist/commands/export.js +255 -0
  11. package/dist/commands/import.d.ts +3 -0
  12. package/dist/commands/import.js +173 -0
  13. package/dist/commands/index.d.ts +5 -1
  14. package/dist/commands/index.js +13 -1
  15. package/dist/commands/inspect.d.ts +11 -0
  16. package/dist/commands/inspect.js +388 -89
  17. package/dist/commands/list.js +128 -19
  18. package/dist/commands/memory.js +453 -16
  19. package/dist/commands/models.js +14 -2
  20. package/dist/commands/run.js +139 -34
  21. package/dist/commands/sandbox.js +72 -1
  22. package/dist/engine.d.ts +19 -0
  23. package/dist/engine.js +60 -8
  24. package/dist/liveness.js +6 -3
  25. package/dist/main.js +5 -2
  26. package/dist/podman.d.ts +16 -0
  27. package/dist/podman.js +115 -2
  28. package/dist/progress.d.ts +33 -0
  29. package/dist/progress.js +402 -0
  30. package/dist/request.d.ts +45 -0
  31. package/dist/request.js +300 -0
  32. package/dist/sandbox.js +10 -0
  33. package/dist/scaffold.d.ts +8 -0
  34. package/dist/scaffold.js +4 -1
  35. package/dist/session.d.ts +26 -1
  36. package/dist/session.js +42 -3
  37. package/dist/term.d.ts +3 -0
  38. package/dist/term.js +17 -1
  39. package/dist/trace.d.ts +84 -0
  40. package/dist/trace.js +686 -0
  41. package/dist/validate.js +93 -4
  42. package/package.json +12 -6
  43. package/templates/editor/.github/copilot-instructions.md +11 -6
  44. package/templates/editor/.github/prompts/finetune.prompt.md +251 -0
  45. package/templates/editor/.github/prompts/spec-sync-project.prompt.md +25 -2
  46. package/templates/editor/.github/skills/zen-cli/SKILL.md +14 -3
  47. package/templates/editor/.github/skills/zen-cli/references/check.md +13 -5
  48. package/templates/editor/.github/skills/zen-cli/references/export.md +107 -0
  49. package/templates/editor/.github/skills/zen-cli/references/inspect.md +262 -21
  50. package/templates/editor/.github/skills/zen-cli/references/memory.md +123 -15
  51. package/templates/editor/.github/skills/zen-cli/references/models.md +1 -1
  52. package/templates/editor/.github/skills/zen-cli/references/projects.md +12 -5
  53. package/templates/editor/.github/skills/zen-cli/references/rag.md +6 -0
  54. package/templates/editor/.github/skills/zen-cli/references/run.md +201 -4
  55. package/templates/editor/.github/skills/zen-finetune/SKILL.md +1000 -0
  56. package/templates/editor/.github/skills/zen-finetune/scripts/collect.sh +432 -0
  57. package/templates/editor/.github/skills/zen-finetune/scripts/sample.sh +199 -0
  58. package/templates/editor/.github/skills/zen-inspect/SKILL.md +396 -0
  59. package/templates/editor/.github/skills/zen-memory/SKILL.md +74 -28
  60. package/templates/editor/.github/skills/zen-memory-warmup/SKILL.md +370 -408
  61. package/templates/editor/.github/skills/zen-sandbox/SKILL.md +19 -0
  62. package/templates/editor/.github/skills/zen-sandbox-capacity/SKILL.md +334 -0
  63. package/templates/editor/.github/skills/zen-sandbox-capacity/references/loadtest.md +181 -0
  64. package/templates/editor/.github/skills/zen-sandbox-capacity/scripts/preflight_sandbox.sh +457 -0
  65. package/templates/editor/.github/skills/zen-spec-sync/SKILL.md +6 -5
  66. package/templates/editor/.vscode/agents.schema.json +1 -1
  67. package/templates/editor/.vscode/settings.json +1 -0
  68. package/templates/project/SPECIFICATION.md.tmpl +2 -0
  69. package/templates/project/agents/files-instructions.md +13 -0
  70. package/templates/project/agents/fork-instructions.md +27 -0
  71. package/templates/project/agents/memory-instructions.md +80 -7
  72. package/templates/project/gitignore +3 -0
  73. package/templates/project/scripts/_setup.sh +49 -7
@@ -0,0 +1,300 @@
1
+ import { media, text } from '@zenera/neo';
2
+ import { readFileSync, statSync } from 'node:fs';
3
+ import { dirname, extname, isAbsolute, resolve } from 'node:path';
4
+ import { readStdin, usageError, warn } from "./term.js";
5
+ /**
6
+ * Local media is read and inlined, so the state a run is resumed from carries
7
+ * the picture rather than a path that meant something on another machine. That
8
+ * also puts the bytes in `state.json`, rewritten at every checkpoint of every
9
+ * later turn — hence a ceiling, and a word of warning well before it.
10
+ */
11
+ const MAX_MEDIA_BYTES = 20 * 1024 * 1024;
12
+ const WARN_MEDIA_BYTES = 2 * 1024 * 1024;
13
+ /** Enough to name what people actually attach; anything else says `mimeType`. */
14
+ const MIME = {
15
+ '.png': 'image/png',
16
+ '.jpg': 'image/jpeg',
17
+ '.jpeg': 'image/jpeg',
18
+ '.gif': 'image/gif',
19
+ '.webp': 'image/webp',
20
+ '.bmp': 'image/bmp',
21
+ '.svg': 'image/svg+xml',
22
+ '.heic': 'image/heic',
23
+ '.avif': 'image/avif',
24
+ '.mp3': 'audio/mpeg',
25
+ '.wav': 'audio/wav',
26
+ '.ogg': 'audio/ogg',
27
+ '.m4a': 'audio/mp4',
28
+ '.flac': 'audio/flac',
29
+ '.aac': 'audio/aac',
30
+ '.mp4': 'video/mp4',
31
+ '.webm': 'video/webm',
32
+ '.mov': 'video/quicktime',
33
+ '.mkv': 'video/x-matroska',
34
+ '.pdf': 'application/pdf',
35
+ '.txt': 'text/plain',
36
+ '.md': 'text/markdown',
37
+ '.csv': 'text/csv',
38
+ '.json': 'application/json',
39
+ '.xml': 'application/xml',
40
+ '.html': 'text/html',
41
+ '.yaml': 'application/yaml',
42
+ '.yml': 'application/yaml',
43
+ };
44
+ const MEDIA_KEYS = ['image', 'audio', 'video', 'file'];
45
+ // ---------------------------------------------------------------------------
46
+ // Reading
47
+ // ---------------------------------------------------------------------------
48
+ /**
49
+ * `-` is stdin, and then there is no file to resolve paths against, so the cwd
50
+ * answers instead — the only place a piped request could have meant.
51
+ */
52
+ export async function readRequest(file, cwd, piped) {
53
+ if (file === '-') {
54
+ const body = piped ?? (await readStdin());
55
+ if (body === undefined) {
56
+ throw usageError('--input - expects the request on stdin', 'pipe a JSON object in');
57
+ }
58
+ return parseRequest(body, cwd, '<stdin>');
59
+ }
60
+ const at = resolve(cwd, file);
61
+ let body;
62
+ try {
63
+ body = readFileSync(at, 'utf8');
64
+ }
65
+ catch {
66
+ throw usageError(`cannot read ${at}`);
67
+ }
68
+ return parseRequest(body, dirname(at), at);
69
+ }
70
+ /**
71
+ * `base` is the directory every relative path in the request is resolved
72
+ * against — the file's own, so a case travels with the images beside it.
73
+ */
74
+ export function parseRequest(body, base, where) {
75
+ let raw;
76
+ try {
77
+ raw = JSON.parse(body);
78
+ }
79
+ catch (err) {
80
+ throw usageError(`${where}: ${err.message}`, 'the request must be JSON');
81
+ }
82
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
83
+ throw usageError(`${where}: expected a JSON object`, 'see: zen run --help');
84
+ }
85
+ if (raw.input === undefined) {
86
+ throw usageError(`${where}: no "input"`, 'a request has to say what to ask');
87
+ }
88
+ return {
89
+ project: project(raw.project, base, where),
90
+ input: toInput(raw.input, base, where),
91
+ workspace: path(raw.workspace, base, where, 'workspace'),
92
+ memory: path(raw.memory, base, where, 'memory'),
93
+ };
94
+ }
95
+ /** The batch file. `-` is stdin, and then the cwd is what paths mean. */
96
+ export async function readBatch(file, cwd, piped) {
97
+ if (file === '-') {
98
+ const body = piped ?? (await readStdin());
99
+ if (body === undefined) {
100
+ throw usageError('--input - expects the batch on stdin', 'pipe a JSON object in');
101
+ }
102
+ return parseBatch(body, cwd, '<stdin>');
103
+ }
104
+ const at = resolve(cwd, file);
105
+ let body;
106
+ try {
107
+ body = readFileSync(at, 'utf8');
108
+ }
109
+ catch {
110
+ throw usageError(`cannot read ${at}`);
111
+ }
112
+ return parseBatch(body, dirname(at), at);
113
+ }
114
+ /**
115
+ * Everything refusable is refused here, before a single model is called: a
116
+ * batch that fails on item 80 has already spent seventy-nine runs finding out
117
+ * something its file could have been read for.
118
+ */
119
+ export function parseBatch(body, base, where) {
120
+ let raw;
121
+ try {
122
+ raw = JSON.parse(body);
123
+ }
124
+ catch (err) {
125
+ throw usageError(`${where}: ${err.message}`, 'the batch must be JSON');
126
+ }
127
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
128
+ throw usageError(`${where}: expected a JSON object`, 'see: zen run batch --help');
129
+ }
130
+ for (const key of ['memory', 'workspace', 'project']) {
131
+ if (raw[key] !== undefined) {
132
+ throw usageError(`${where}: "${key}" is not part of a batch file`, `it applies to every item, so it is a flag: --${key}`);
133
+ }
134
+ }
135
+ if (!Array.isArray(raw.batch)) {
136
+ throw usageError(`${where}: no "batch" array`, 'a batch is a list of requests');
137
+ }
138
+ if (raw.batch.length === 0) {
139
+ throw usageError(`${where}: "batch" is empty`, 'there is nothing to run');
140
+ }
141
+ const items = raw.batch.map((item, index) => toItem(item, index, base, `${where}: batch`));
142
+ unique(items.map((i) => i.id), 'id', where);
143
+ unique(items.flatMap((i) => (i.workspace ? [i.workspace] : [])), 'workspace', where);
144
+ return { items };
145
+ }
146
+ function toItem(raw, index, base, where) {
147
+ const at = `${where}[${index}]`;
148
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
149
+ throw usageError(`${at}: expected an object`, 'see: zen run batch --help');
150
+ }
151
+ const obj = raw;
152
+ for (const key of ['memory', 'project']) {
153
+ if (obj[key] !== undefined) {
154
+ throw usageError(`${at}: "${key}" is not part of a batch item`, `every item shares one, so it is a flag: --${key}`);
155
+ }
156
+ }
157
+ if (obj.input === undefined) {
158
+ throw usageError(`${at}: no "input"`, 'an item has to say what to ask');
159
+ }
160
+ return {
161
+ id: id(obj.id, index, at),
162
+ index,
163
+ input: toInput(obj.input, base, at),
164
+ workspace: path(obj.workspace, base, at, 'workspace'),
165
+ };
166
+ }
167
+ /** An id names a directory, so it has to be one path segment and nothing else. */
168
+ function id(value, index, at) {
169
+ if (value === undefined) {
170
+ return String(index);
171
+ }
172
+ if (typeof value !== 'string' || !value) {
173
+ throw usageError(`${at}: "id" must be a non-empty string`);
174
+ }
175
+ if (!/^[\w.-]+$/.test(value) || value === '.' || value === '..') {
176
+ throw usageError(`${at}: "${value}" cannot be an id`, 'it names a directory: letters, digits, dot, dash and underscore');
177
+ }
178
+ return value;
179
+ }
180
+ function unique(values, what, where) {
181
+ const seen = new Set();
182
+ for (const value of values) {
183
+ if (seen.has(value)) {
184
+ throw usageError(`${where}: two items share the ${what} "${value}"`, 'every item runs at the same time as the others; give each its own');
185
+ }
186
+ seen.add(value);
187
+ }
188
+ }
189
+ /**
190
+ * A registered name is not a path and must not be resolved into one; a name
191
+ * that looks like a path is one. `Projects.open` takes either.
192
+ */
193
+ function project(value, base, where) {
194
+ if (value === undefined) {
195
+ return undefined;
196
+ }
197
+ if (typeof value !== 'string' || !value) {
198
+ throw usageError(`${where}: "project" must be a name or a directory`);
199
+ }
200
+ return value.startsWith('.') || isAbsolute(value) ? resolve(base, value) : value;
201
+ }
202
+ function path(value, base, where, field) {
203
+ if (value === undefined) {
204
+ return undefined;
205
+ }
206
+ if (typeof value !== 'string' || !value) {
207
+ throw usageError(`${where}: "${field}" must be a directory`);
208
+ }
209
+ return resolve(base, value);
210
+ }
211
+ // ---------------------------------------------------------------------------
212
+ // Input
213
+ // ---------------------------------------------------------------------------
214
+ /**
215
+ * Canonical `ContentPart[]` out, whatever went in: the shorthands exist for
216
+ * whoever writes the file, and stop existing the moment it is read.
217
+ */
218
+ export function toInput(raw, base, where) {
219
+ if (typeof raw === 'string') {
220
+ return raw;
221
+ }
222
+ if (!Array.isArray(raw)) {
223
+ throw usageError(`${where}: "input" must be a string or an array of parts`);
224
+ }
225
+ return raw.map((part, i) => toPart(part, base, `${where}: input[${i}]`));
226
+ }
227
+ function toPart(part, base, at) {
228
+ if (typeof part === 'string') {
229
+ return text(part);
230
+ }
231
+ if (part === null || typeof part !== 'object' || Array.isArray(part)) {
232
+ throw usageError(`${at}: expected a string or an object`);
233
+ }
234
+ const obj = part;
235
+ const mimeType = mime(obj.mimeType, at);
236
+ if (typeof obj.type === 'string') {
237
+ if (obj.type === 'text') {
238
+ return text(str(obj.text, at, 'text'));
239
+ }
240
+ if (MEDIA_KEYS.includes(obj.type)) {
241
+ const kind = obj.type;
242
+ return url(kind, str(obj.url, at, 'url'), mimeType, base, at);
243
+ }
244
+ throw usageError(`${at}: unknown part type "${obj.type}"`, `text, ${MEDIA_KEYS.join(', ')}`);
245
+ }
246
+ if (typeof obj.text === 'string') {
247
+ return text(obj.text);
248
+ }
249
+ for (const kind of MEDIA_KEYS) {
250
+ if (obj[kind] !== undefined) {
251
+ return url(kind, str(obj[kind], at, kind), mimeType, base, at);
252
+ }
253
+ }
254
+ throw usageError(`${at}: no text, ${MEDIA_KEYS.join(', ')} or type`, 'see: zen run --help');
255
+ }
256
+ /**
257
+ * A url the model can already fetch is left exactly as it is; anything else is
258
+ * a path on this machine, and a path is only a url once it carries its bytes.
259
+ */
260
+ function url(kind, value, mimeType, base, at) {
261
+ if (/^(https?|data):/.test(value)) {
262
+ return media(kind, value, mimeType);
263
+ }
264
+ const file = resolve(base, value);
265
+ const type = mimeType ?? MIME[extname(file).toLowerCase()];
266
+ if (!type) {
267
+ throw usageError(`${at}: nothing known about "${extname(file) || value}"`, 'name the type with "mimeType"');
268
+ }
269
+ let size;
270
+ try {
271
+ size = statSync(file).size;
272
+ }
273
+ catch {
274
+ throw usageError(`${at}: cannot read ${file}`);
275
+ }
276
+ if (size > MAX_MEDIA_BYTES) {
277
+ throw usageError(`${at}: ${file} is ${Math.round(size / 1024 / 1024)} MB`, `inlined media lives in the run state — the limit is ${MAX_MEDIA_BYTES / 1024 / 1024} MB`);
278
+ }
279
+ if (size > WARN_MEDIA_BYTES) {
280
+ warn(`${file} is large; it is written into the session state on every later turn`);
281
+ }
282
+ const bytes = readFileSync(file);
283
+ return media(kind, `data:${type};base64,${bytes.toString('base64')}`, type);
284
+ }
285
+ function str(value, at, field) {
286
+ if (typeof value !== 'string' || !value) {
287
+ throw usageError(`${at}: "${field}" must be a non-empty string`);
288
+ }
289
+ return value;
290
+ }
291
+ function mime(value, at) {
292
+ if (value === undefined) {
293
+ return undefined;
294
+ }
295
+ if (typeof value !== 'string' || !value) {
296
+ throw usageError(`${at}: "mimeType" must be a string`);
297
+ }
298
+ return value;
299
+ }
300
+ //# sourceMappingURL=request.js.map
package/dist/sandbox.js CHANGED
@@ -178,6 +178,12 @@ function toSpec(config, extra, secrets) {
178
178
  export function usesSandbox(project) {
179
179
  return project.agents.some((a) => a.tools.some((t) => t.group === SANDBOX_GROUP));
180
180
  }
181
+ /**
182
+ * Images this process has already settled. A batch opens the same project
183
+ * dozens of times at once, and without this each one shells out to podman and
184
+ * the concurrent copies race each other to pull the very same image.
185
+ */
186
+ const READY = new Set();
181
187
  /**
182
188
  * Asked before the first turn rather than at the first tool call, so a missing
183
189
  * container engine costs nothing instead of costing a round trip and half a
@@ -185,6 +191,9 @@ export function usesSandbox(project) {
185
191
  */
186
192
  export async function preflight(setup, yes) {
187
193
  mkdirSync(setup.home, { recursive: true });
194
+ if (READY.has(setup.image)) {
195
+ return;
196
+ }
188
197
  await ensurePodmanReady({
189
198
  image: setup.image,
190
199
  build: setup.build,
@@ -192,6 +201,7 @@ export async function preflight(setup, yes) {
192
201
  memory: setup.spec.memory,
193
202
  yes,
194
203
  });
204
+ READY.add(setup.image);
195
205
  }
196
206
  /** Best-effort teardown: losing a container must never lose a run. */
197
207
  export async function teardown(pool) {
@@ -11,6 +11,14 @@
11
11
  * own beside them.
12
12
  */
13
13
  export declare const SHARED_RULES: readonly string[];
14
+ /**
15
+ * This `zen`'s own version, which the scaffold pins the sandbox's tools to.
16
+ * The publishable packages move in lockstep, so one number covers them all.
17
+ *
18
+ * Exported so the check that reports a drifted pin reads the same number the
19
+ * scaffold wrote, rather than a second definition of "this version".
20
+ */
21
+ export declare function ownVersion(): string;
14
22
  /**
15
23
  * Writes the editor's files under `dir`, replacing what is there. They are
16
24
  * ours: they say how the editor is to treat a directory the agents write into,
package/dist/scaffold.js CHANGED
@@ -59,8 +59,11 @@ const MEMORY_REFERENCE = join('.github', 'skills', 'zen-memory', 'references', '
59
59
  /**
60
60
  * This `zen`'s own version, which the scaffold pins the sandbox's tools to.
61
61
  * The publishable packages move in lockstep, so one number covers them all.
62
+ *
63
+ * Exported so the check that reports a drifted pin reads the same number the
64
+ * scaffold wrote, rather than a second definition of "this version".
62
65
  */
63
- function ownVersion() {
66
+ export function ownVersion() {
64
67
  const manifest = fileURLToPath(new URL('../package.json', import.meta.url));
65
68
  return JSON.parse(readFileSync(manifest, 'utf8')).version;
66
69
  }
package/dist/session.d.ts CHANGED
@@ -6,7 +6,6 @@ export interface SessionPaths {
6
6
  data: string;
7
7
  /** the live, resumable state — rewritten after every run */
8
8
  state: string;
9
- memory: string;
10
9
  blobs: string;
11
10
  runs: string;
12
11
  lock: string;
@@ -52,10 +51,30 @@ export interface RunPaths {
52
51
  output: string;
53
52
  state: string;
54
53
  report: string;
54
+ /** the same run as one Mermaid flowchart, for a model to read */
55
+ graph: string;
55
56
  meta: string;
56
57
  }
57
58
  export declare function runPaths(session: SessionPaths, id: string): RunPaths;
58
59
  export declare function createRun(session: SessionPaths): RunPaths;
60
+ /**
61
+ * The same paths, reached from a directory instead of from a pair of ids.
62
+ *
63
+ * `zen run --json` hands back a run directory, and a caller holding one has no
64
+ * reason to take it apart again to ask a second question about the same run.
65
+ * The layout is fixed, so the ids can be read back out of the path — and both
66
+ * still go through `isStamp`, so a path from anywhere cannot name a directory
67
+ * this layout would never have produced.
68
+ *
69
+ * The session comes back too, not only the run: blobs are stored per session,
70
+ * so anything that resolves a payload needs both halves — and the project, so
71
+ * a command reached this way can still read the project's config.
72
+ */
73
+ export declare function runPathsAt(dir: string): {
74
+ project: string;
75
+ session: SessionPaths;
76
+ run: RunPaths;
77
+ };
59
78
  export interface RunMeta {
60
79
  version: 1;
61
80
  id: string;
@@ -68,9 +87,15 @@ export interface RunMeta {
68
87
  turns: number;
69
88
  usage: unknown;
70
89
  workspace: string;
90
+ memory?: string;
71
91
  error?: string;
72
92
  }
73
93
  export declare function writeRunMeta(p: RunPaths, meta: RunMeta): void;
94
+ /**
95
+ * Partial, because a run killed before it recorded anything still has a
96
+ * directory, and every field here is worth having on its own.
97
+ */
98
+ export declare function readRunMeta(p: RunPaths): Promise<Partial<RunMeta>>;
74
99
  export interface RunSummary {
75
100
  id: string;
76
101
  startedAt?: string;
package/dist/session.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { hostname } from 'node:os';
3
- import { join, relative, resolve } from 'node:path';
3
+ import { basename, dirname, join, relative, resolve } from 'node:path';
4
4
  import { readJson, writeJson } from "./home.js";
5
5
  import { isStamp, stamp, stampInstant } from "./ids.js";
6
6
  import { alive, isBusy, runIds, sessionIds, sessionsDir } from "./projects.js";
@@ -17,7 +17,6 @@ export function sessionPaths(projectDir, id) {
17
17
  workspace: join(dir, 'workspace'),
18
18
  data,
19
19
  state: join(data, 'state.json'),
20
- memory: join(data, 'memory'),
21
20
  blobs: join(data, 'blobs'),
22
21
  runs: join(dir, 'runs'),
23
22
  lock: join(dir, '.lock'),
@@ -134,6 +133,7 @@ export function runPaths(session, id) {
134
133
  output: join(dir, 'output.md'),
135
134
  state: join(dir, 'state.json'),
136
135
  report: join(dir, 'report.html'),
136
+ graph: join(dir, 'graph.mmd'),
137
137
  meta: join(dir, 'meta.json'),
138
138
  };
139
139
  }
@@ -142,16 +142,55 @@ export function createRun(session) {
142
142
  mkdirSync(p.dir, { recursive: true });
143
143
  return p;
144
144
  }
145
+ /**
146
+ * The same paths, reached from a directory instead of from a pair of ids.
147
+ *
148
+ * `zen run --json` hands back a run directory, and a caller holding one has no
149
+ * reason to take it apart again to ask a second question about the same run.
150
+ * The layout is fixed, so the ids can be read back out of the path — and both
151
+ * still go through `isStamp`, so a path from anywhere cannot name a directory
152
+ * this layout would never have produced.
153
+ *
154
+ * The session comes back too, not only the run: blobs are stored per session,
155
+ * so anything that resolves a payload needs both halves — and the project, so
156
+ * a command reached this way can still read the project's config.
157
+ */
158
+ export function runPathsAt(dir) {
159
+ const full = resolve(dir);
160
+ const runId = basename(full);
161
+ const sessionDir = dirname(dirname(full));
162
+ const wrong = () => usageError(`${display(full)} is not a run directory`, 'runs live at <project>/sessions/<session>/runs/<run>');
163
+ if (!isStamp(runId) || !isStamp(basename(sessionDir)) || basename(dirname(full)) !== 'runs') {
164
+ throw wrong();
165
+ }
166
+ const project = dirname(dirname(sessionDir));
167
+ const session = sessionPaths(project, basename(sessionDir));
168
+ const run = runPaths(session, runId);
169
+ if (resolve(run.dir) !== full) {
170
+ throw wrong();
171
+ }
172
+ if (!existsSync(run.state)) {
173
+ throw invalidError(`run ${runId} has no state.json`, 'only a run that got far enough to save state can be inspected');
174
+ }
175
+ return { project, session, run };
176
+ }
145
177
  export function writeRunMeta(p, meta) {
146
178
  writeJson(p.meta, meta, 0o644);
147
179
  }
180
+ /**
181
+ * Partial, because a run killed before it recorded anything still has a
182
+ * directory, and every field here is worth having on its own.
183
+ */
184
+ export async function readRunMeta(p) {
185
+ return await readJson(p.meta, {});
186
+ }
148
187
  /** Newest first — the order a picker wants. */
149
188
  export async function listRuns(session) {
150
189
  const out = [];
151
190
  for (const id of runIds(session.dir).reverse()) {
152
191
  // A run killed before it recorded anything still has an id, and the id
153
192
  // says when it started, so it is listable without its meta.
154
- const meta = await readJson(runPaths(session, id).meta, {});
193
+ const meta = await readRunMeta(runPaths(session, id));
155
194
  out.push({
156
195
  id,
157
196
  startedAt: meta.startedAt ?? stampInstant(id),
package/dist/term.d.ts CHANGED
@@ -56,6 +56,8 @@ export declare function write(line?: string): void;
56
56
  export declare function writeAll(lines: readonly string[]): void;
57
57
  /** Machine-readable output. Pretty-printed: it is read by people too. */
58
58
  export declare function json(value: unknown): void;
59
+ /** The same bytes, for a caller sending them somewhere other than stdout. */
60
+ export declare function jsonText(value: unknown): string;
59
61
  export declare function note(line?: string): void;
60
62
  /**
61
63
  * One line of narration that rewrites itself while work is in flight, so a
@@ -72,6 +74,7 @@ export declare function warn(message: string): void;
72
74
  export declare function title(text: string): void;
73
75
  export declare function fail(message: string, hint?: string): void;
74
76
  export declare function isInteractive(): boolean;
77
+ export declare function deferBanner(print: () => void): void;
75
78
  export declare function ask(question: string, fallback?: string): Promise<string>;
76
79
  /**
77
80
  * Reads without echoing. `readline` writes what you type through its `output`
package/dist/term.js CHANGED
@@ -154,7 +154,11 @@ export function writeAll(lines) {
154
154
  }
155
155
  /** Machine-readable output. Pretty-printed: it is read by people too. */
156
156
  export function json(value) {
157
- process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
157
+ process.stdout.write(jsonText(value));
158
+ }
159
+ /** The same bytes, for a caller sending them somewhere other than stdout. */
160
+ export function jsonText(value) {
161
+ return `${JSON.stringify(value, null, 2)}\n`;
158
162
  }
159
163
  export function note(line = '') {
160
164
  process.stderr.write(`${line}\n`);
@@ -229,10 +233,22 @@ export function fail(message, hint) {
229
233
  export function isInteractive() {
230
234
  return Boolean(process.stdin.isTTY && process.stderr.isTTY);
231
235
  }
236
+ // A banner is for whoever is sitting there, and the only proof that anyone is
237
+ // sitting there is that we are about to ask them something. Commands whose
238
+ // stdout is the answer hand theirs over here instead of printing it, and it
239
+ // appears if — and only if — the run turns out to need a person. Held as a
240
+ // thunk so this module stays below the banner, which is drawn with its colours.
241
+ let pending;
242
+ export function deferBanner(print) {
243
+ pending = print;
244
+ }
232
245
  function requireTty(what, flag) {
233
246
  if (!isInteractive()) {
234
247
  throw usageError(`cannot ask for ${what} without a terminal`, `pass ${flag} instead`);
235
248
  }
249
+ const banner = pending;
250
+ pending = undefined;
251
+ banner?.();
236
252
  }
237
253
  export async function ask(question, fallback) {
238
254
  requireTty(question, '--yes or the matching flag');
@@ -0,0 +1,84 @@
1
+ import { type AgentState, type PayloadResolver, type TrajectoryNode } from '@zenera/neo';
2
+ export interface TraceEntry {
3
+ /** diagram id — `n1`, `n2`, …, in the order the run appended nodes */
4
+ key: string;
5
+ node: TrajectoryNode;
6
+ depth: number;
7
+ /** the branch this ran in, or null on the trunk */
8
+ branch: string | null;
9
+ /** hidden from the model's context by a later compaction */
10
+ covered: boolean;
11
+ /** the id of the compaction that hid it, when it is on the same level */
12
+ coveredBy?: string;
13
+ children: TraceBranch[];
14
+ turnStart: number | null;
15
+ startMs: number | null;
16
+ endMs: number | null;
17
+ }
18
+ export interface TraceBranch {
19
+ name: string;
20
+ agent: string;
21
+ status: string;
22
+ entries: TraceEntry[];
23
+ startMs: number | null;
24
+ endMs: number | null;
25
+ turnStart: number | null;
26
+ /** the one branch the join actually waited for */
27
+ slowest: boolean;
28
+ }
29
+ export interface Trace {
30
+ root: TraceEntry[];
31
+ /** every entry, branches included, in key order */
32
+ entries: TraceEntry[];
33
+ byKey: Map<string, TraceEntry>;
34
+ }
35
+ export declare function traceOf(state: AgentState): Trace;
36
+ export declare function safe(text: string, max?: number): string;
37
+ export interface TraceOptions {
38
+ /** the run id to name in the header, when it differs from `state.runId` */
39
+ runId?: string;
40
+ /** the run directory, so the header can spell the command that opens a node */
41
+ dir?: string;
42
+ /** the files the run worked on */
43
+ workspace?: string;
44
+ /** the memory graph the run read, when it had one */
45
+ memory?: string;
46
+ timing?: boolean;
47
+ usage?: boolean;
48
+ /** false drops the palette: a model reads the labels, not the colours */
49
+ style?: boolean;
50
+ }
51
+ export declare function traceMermaid(state: AgentState, opts?: TraceOptions): string;
52
+ /** The node list on its own, for `--json`. */
53
+ export declare function traceIndex(trace: Trace): {
54
+ id: string;
55
+ nodeId: string;
56
+ kind: string;
57
+ agent: string;
58
+ branch: string | null;
59
+ ts: string;
60
+ label: string;
61
+ }[];
62
+ export interface NodePart {
63
+ name: string;
64
+ text: string;
65
+ }
66
+ export interface NodeDetail {
67
+ id: string;
68
+ nodeId: string;
69
+ kind: string;
70
+ agent: string;
71
+ branch: string | null;
72
+ ts: string;
73
+ label: string;
74
+ parts: NodePart[];
75
+ /** fields worth reading that are not payloads */
76
+ facts: Record<string, string>;
77
+ }
78
+ /**
79
+ * `n5`, or `n5..n9`. A range is how a reader asks about a stretch, which is
80
+ * the shape of the question the diagram provokes ("what are n14 to n38 doing").
81
+ */
82
+ export declare function parseNodeIds(words: readonly string[], known: Map<string, unknown>): string[];
83
+ export declare function nodeDetail(trace: Trace, ids: readonly string[], payloads: PayloadResolver): Promise<NodeDetail[]>;
84
+ //# sourceMappingURL=trace.d.ts.map