@zenera/cli 1.1.17 → 1.1.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +99 -16
- package/dist/banner.d.ts +19 -0
- package/dist/banner.js +12 -9
- package/dist/command.d.ts +3 -0
- package/dist/commands/index.js +4 -1
- package/dist/commands/init.js +1 -1
- package/dist/commands/inspect.js +77 -13
- package/dist/commands/key.js +49 -1
- package/dist/commands/memory.js +16 -3
- package/dist/commands/meta.d.ts +3 -0
- package/dist/commands/meta.js +596 -0
- package/dist/commands/run.js +63 -38
- package/dist/engine.d.ts +15 -0
- package/dist/engine.js +20 -1
- package/dist/home.d.ts +2 -0
- package/dist/home.js +2 -0
- package/dist/main.js +1 -1
- package/dist/meta.d.ts +221 -0
- package/dist/meta.js +695 -0
- package/dist/podman.js +64 -2
- package/dist/projects.d.ts +9 -1
- package/dist/projects.js +19 -2
- package/dist/resolve.js +7 -1
- package/dist/sandbox.d.ts +2 -0
- package/dist/sandbox.js +1 -1
- package/dist/scaffold.js +1 -1
- package/dist/session.d.ts +10 -0
- package/dist/session.js +19 -1
- package/dist/term.d.ts +7 -0
- package/dist/term.js +8 -1
- package/dist/tui/app.js +33 -5
- package/dist/tui/wrap.d.ts +1 -1
- package/dist/tui/wrap.js +1 -1
- package/package.json +3 -2
- package/templates/editor/.github/copilot-instructions.md +59 -5
- package/templates/editor/.github/prompts/{review-project.prompt.md → project-review.prompt.md} +0 -1
- package/templates/editor/.github/prompts/{update-spec-from-feedback.prompt.md → spec-apply-feedback.prompt.md} +5 -5
- package/templates/editor/.github/prompts/{sync-with-spec.prompt.md → spec-sync-project.prompt.md} +4 -4
- package/templates/editor/.github/skills/zen-cli/references/inspect.md +4 -3
- package/templates/editor/.github/skills/zen-cli/references/memory.md +14 -0
- package/templates/editor/.github/skills/zen-cli/references/run.md +75 -16
- package/templates/editor/.github/skills/zen-memory/SKILL.md +52 -4
- package/templates/editor/.github/skills/zen-review/SKILL.md +2 -2
- package/templates/editor/.github/skills/zen-review/scripts/review.sh +1 -1
- package/templates/editor/.github/skills/zen-spec-sync/SKILL.md +2 -2
- package/templates/editor/.github/skills/zen-spec-sync/scripts/snapshot.sh +1 -1
- package/templates/project/SPECIFICATION.md.tmpl +25 -2
- package/templates/project/agents.yaml.tmpl +3 -3
- package/templates/project/env +7 -1
- package/templates/project/gitignore +4 -4
package/dist/podman.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { runProcess, SandboxError } from '@zenera/neo';
|
|
2
|
-
import { readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
3
3
|
import { homedir, platform } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { CliError, confirm, dim, EXIT, isInteractive, note, progress } from "./term.js";
|
|
@@ -111,9 +111,71 @@ function instructions() {
|
|
|
111
111
|
case 'win32':
|
|
112
112
|
return 'install it with: winget install RedHat.Podman';
|
|
113
113
|
default:
|
|
114
|
-
return
|
|
114
|
+
return linuxInstructions();
|
|
115
115
|
}
|
|
116
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* How podman gets installed, by distribution — podman.io/docs/installation,
|
|
119
|
+
* which is where any of these should be checked against.
|
|
120
|
+
*
|
|
121
|
+
* "Install it with your package manager" is advice nobody needs and everybody
|
|
122
|
+
* has to translate; the host already says which one it has, so the message may
|
|
123
|
+
* as well be the line to paste. It stops at the line: `sudo` wants a terminal
|
|
124
|
+
* to ask for a password on and `runProcess` gives it a pipe, so running this
|
|
125
|
+
* for the user would hang rather than help.
|
|
126
|
+
*/
|
|
127
|
+
const LINUX_INSTALL = {
|
|
128
|
+
alpine: 'install it with: sudo apk add podman',
|
|
129
|
+
arch: 'install it with: sudo pacman -S podman',
|
|
130
|
+
centos: 'install it with: sudo dnf -y install podman',
|
|
131
|
+
debian: 'install it with: sudo apt-get -y install podman',
|
|
132
|
+
fedora: 'install it with: sudo dnf -y install podman',
|
|
133
|
+
gentoo: 'install it with: sudo emerge app-containers/podman',
|
|
134
|
+
// Nix installs nothing imperatively worth keeping: the podman that works
|
|
135
|
+
// rootless is the one the module turns on.
|
|
136
|
+
nixos: 'enable it with: virtualisation.podman.enable = true; then: sudo nixos-rebuild switch',
|
|
137
|
+
opensuse: 'install it with: sudo zypper install podman',
|
|
138
|
+
rhel: 'install it with: sudo dnf -y install podman',
|
|
139
|
+
sles: 'install it with: sudo zypper install podman',
|
|
140
|
+
suse: 'install it with: sudo zypper install podman',
|
|
141
|
+
ubuntu: 'install it with: sudo apt-get update && sudo apt-get -y install podman',
|
|
142
|
+
void: 'install it with: sudo xbps-install -S podman',
|
|
143
|
+
};
|
|
144
|
+
function linuxInstructions() {
|
|
145
|
+
for (const id of osRelease()) {
|
|
146
|
+
// `opensuse-leap` and `rhel-9` name the same package manager as their
|
|
147
|
+
// family does, so a miss on the whole id is worth one on its stem.
|
|
148
|
+
const how = LINUX_INSTALL[id] ?? LINUX_INSTALL[id.split(/[-.]/)[0] ?? ''];
|
|
149
|
+
if (how) {
|
|
150
|
+
return how;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return 'install it with your package manager, e.g. apt-get install podman';
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* `ID` first, then `ID_LIKE` — the distribution's own name, then the families
|
|
157
|
+
* it claims to behave like, which is how Rocky, Alma and Mint get answered
|
|
158
|
+
* without being listed.
|
|
159
|
+
*/
|
|
160
|
+
function osRelease() {
|
|
161
|
+
let text;
|
|
162
|
+
try {
|
|
163
|
+
text = readFileSync('/etc/os-release', 'utf8');
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return [];
|
|
167
|
+
}
|
|
168
|
+
const read = (key) => {
|
|
169
|
+
const line = new RegExp(`^${key}=(.*)$`, 'm').exec(text)?.[1] ?? '';
|
|
170
|
+
return line
|
|
171
|
+
.trim()
|
|
172
|
+
.replace(/^["']|["']$/g, '')
|
|
173
|
+
.toLowerCase()
|
|
174
|
+
.split(/\s+/)
|
|
175
|
+
.filter(Boolean);
|
|
176
|
+
};
|
|
177
|
+
return [...read('ID'), ...read('ID_LIKE')];
|
|
178
|
+
}
|
|
117
179
|
// ---------------------------------------------------------------------------
|
|
118
180
|
// The machine
|
|
119
181
|
// ---------------------------------------------------------------------------
|
package/dist/projects.d.ts
CHANGED
|
@@ -16,7 +16,7 @@ export interface Project {
|
|
|
16
16
|
* and does not edit — and the paths are resolved, because the podman machine on
|
|
17
17
|
* macOS shares the real path or nothing.
|
|
18
18
|
*/
|
|
19
|
-
export declare function projectMounts(root: string, config: ProjectConfig): SandboxMount[];
|
|
19
|
+
export declare function projectMounts(root: string, config: ProjectConfig, memoryAt?: string): SandboxMount[];
|
|
20
20
|
/** Walks up from `start` looking for a project configuration. */
|
|
21
21
|
export declare function findUp(start: string): Promise<Project | undefined>;
|
|
22
22
|
export declare function openDir(dir: string): Promise<Project>;
|
|
@@ -70,6 +70,14 @@ export declare const sessionsDir: (projectDir: string) => string;
|
|
|
70
70
|
/** Session ids, oldest first. Only well-formed stamps count as sessions. */
|
|
71
71
|
export declare function sessionIds(projectDir: string): string[];
|
|
72
72
|
export declare function runIds(sessionDir: string): string[];
|
|
73
|
+
/**
|
|
74
|
+
* When this project was last worked in, as a stamp, or `undefined` for one
|
|
75
|
+
* that never has been. Session and run ids are stamps in the same format, so
|
|
76
|
+
* the newest of either compares directly — and reading directory NAMES is what
|
|
77
|
+
* keeps this cheap enough to ask of every registered project before drawing a
|
|
78
|
+
* chooser.
|
|
79
|
+
*/
|
|
80
|
+
export declare function lastUsedAt(projectDir: string): string | undefined;
|
|
73
81
|
/**
|
|
74
82
|
* What a tree occupies, in allocated blocks rather than in bytes, because a
|
|
75
83
|
* sparse file costs what it was given and not what it claims. Symlinks are
|
package/dist/projects.js
CHANGED
|
@@ -35,7 +35,7 @@ export function isProjectDir(dir) {
|
|
|
35
35
|
* and does not edit — and the paths are resolved, because the podman machine on
|
|
36
36
|
* macOS shares the real path or nothing.
|
|
37
37
|
*/
|
|
38
|
-
export function projectMounts(root, config) {
|
|
38
|
+
export function projectMounts(root, config, memoryAt) {
|
|
39
39
|
const mounts = [];
|
|
40
40
|
const assets = assetsDir(root, config);
|
|
41
41
|
if (assets) {
|
|
@@ -47,7 +47,7 @@ export function projectMounts(root, config) {
|
|
|
47
47
|
for (const dir of skillMounts(skillDirs(root, config), SKILLS_MOUNT)) {
|
|
48
48
|
mounts.push({ host: realpathSync(dir.path), at: dir.at, readOnly: true });
|
|
49
49
|
}
|
|
50
|
-
const memory = memoryDir(root, config);
|
|
50
|
+
const memory = memoryDir(root, config, memoryAt);
|
|
51
51
|
if (memory) {
|
|
52
52
|
// Created here because the mount is decided before the project is
|
|
53
53
|
// loaded, and podman would refuse a source that does not exist yet.
|
|
@@ -224,6 +224,23 @@ export function sessionIds(projectDir) {
|
|
|
224
224
|
export function runIds(sessionDir) {
|
|
225
225
|
return stampedChildren(join(sessionDir, 'runs'));
|
|
226
226
|
}
|
|
227
|
+
/**
|
|
228
|
+
* When this project was last worked in, as a stamp, or `undefined` for one
|
|
229
|
+
* that never has been. Session and run ids are stamps in the same format, so
|
|
230
|
+
* the newest of either compares directly — and reading directory NAMES is what
|
|
231
|
+
* keeps this cheap enough to ask of every registered project before drawing a
|
|
232
|
+
* chooser.
|
|
233
|
+
*/
|
|
234
|
+
export function lastUsedAt(projectDir) {
|
|
235
|
+
let last;
|
|
236
|
+
for (const id of sessionIds(projectDir)) {
|
|
237
|
+
const newest = runIds(join(sessionsDir(projectDir), id)).at(-1) ?? id;
|
|
238
|
+
if (!last || newest > last) {
|
|
239
|
+
last = newest;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return last;
|
|
243
|
+
}
|
|
227
244
|
function stampedChildren(dir) {
|
|
228
245
|
if (!existsSync(dir)) {
|
|
229
246
|
return [];
|
package/dist/resolve.js
CHANGED
|
@@ -23,7 +23,13 @@ export async function project(want) {
|
|
|
23
23
|
if (!isInteractive()) {
|
|
24
24
|
throw usageError('not inside a project', 'name one with --project');
|
|
25
25
|
}
|
|
26
|
-
|
|
26
|
+
// Most recently worked in first: the answer to "which project" is nearly
|
|
27
|
+
// always the one from last time, and registration order is nothing to a
|
|
28
|
+
// person. A project never run sorts last, by name.
|
|
29
|
+
const ranked = known
|
|
30
|
+
.map((entry) => ({ entry, used: Projects.lastUsedAt(entry.path) ?? '' }))
|
|
31
|
+
.sort((a, b) => b.used.localeCompare(a.used) || a.entry.name.localeCompare(b.entry.name));
|
|
32
|
+
const chosen = await choose('Which project?', ranked.map(({ entry }) => ({ label: entry.name, detail: entry.path, value: entry })));
|
|
27
33
|
return Projects.openDir(chosen.path);
|
|
28
34
|
}
|
|
29
35
|
// ---------------------------------------------------------------------------
|
package/dist/sandbox.d.ts
CHANGED
|
@@ -10,6 +10,8 @@ export interface SandboxSetup {
|
|
|
10
10
|
build?: ResolvedBuild;
|
|
11
11
|
/** host side of the container's `$HOME`, created only if it is ever needed */
|
|
12
12
|
home: string;
|
|
13
|
+
/** every tree bound into the container besides the workspace itself */
|
|
14
|
+
mounts: readonly SandboxMount[];
|
|
13
15
|
}
|
|
14
16
|
export interface SandboxInputs {
|
|
15
17
|
config: ProjectConfig;
|
package/dist/sandbox.js
CHANGED
|
@@ -103,7 +103,7 @@ export function buildSandbox(opts) {
|
|
|
103
103
|
readOnly: opts.readOnly,
|
|
104
104
|
mounts,
|
|
105
105
|
});
|
|
106
|
-
return { pool, spec, image: spec.image ?? DEFAULT_SANDBOX_IMAGE, build, home };
|
|
106
|
+
return { pool, spec, image: spec.image ?? DEFAULT_SANDBOX_IMAGE, build, home, mounts };
|
|
107
107
|
}
|
|
108
108
|
/**
|
|
109
109
|
* An agent's overrides on the project's block. `image` and `build` answer the
|
package/dist/scaffold.js
CHANGED
|
@@ -244,7 +244,7 @@ export function scaffold(opts) {
|
|
|
244
244
|
mkdirSync(join(opts.dir, 'agents', 'skills'), { recursive: true });
|
|
245
245
|
mkdirSync(join(opts.dir, 'sessions'), { recursive: true });
|
|
246
246
|
mkdirSync(join(opts.dir, '.tmp'), { recursive: true });
|
|
247
|
-
// Where `/sync-
|
|
247
|
+
// Where `/spec-sync-project` records what it applied, so the next pass works
|
|
248
248
|
// the difference rather than the whole specification again. Empty here, and
|
|
249
249
|
// git does not carry an empty directory: what says a pass has completed is
|
|
250
250
|
// `baseline/manifest.txt`, never the directory itself.
|
package/dist/session.d.ts
CHANGED
|
@@ -71,6 +71,16 @@ export interface RunMeta {
|
|
|
71
71
|
error?: string;
|
|
72
72
|
}
|
|
73
73
|
export declare function writeRunMeta(p: RunPaths, meta: RunMeta): void;
|
|
74
|
+
export interface RunSummary {
|
|
75
|
+
id: string;
|
|
76
|
+
startedAt?: string;
|
|
77
|
+
finishedAt?: string;
|
|
78
|
+
agent?: string;
|
|
79
|
+
stopReason?: string;
|
|
80
|
+
error?: string;
|
|
81
|
+
}
|
|
82
|
+
/** Newest first — the order a picker wants. */
|
|
83
|
+
export declare function listRuns(session: SessionPaths): Promise<RunSummary[]>;
|
|
74
84
|
export declare function newestRun(session: SessionPaths): string | undefined;
|
|
75
85
|
/** A path to show a human: relative when that is shorter, absolute otherwise. */
|
|
76
86
|
export declare function display(path: string, from?: string): string;
|
package/dist/session.js
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node
|
|
|
2
2
|
import { hostname } from 'node:os';
|
|
3
3
|
import { join, relative, resolve } from 'node:path';
|
|
4
4
|
import { readJson, writeJson } from "./home.js";
|
|
5
|
-
import { isStamp, stamp } from "./ids.js";
|
|
5
|
+
import { isStamp, stamp, stampInstant } from "./ids.js";
|
|
6
6
|
import { alive, isBusy, runIds, sessionIds, sessionsDir } from "./projects.js";
|
|
7
7
|
import { CliError, EXIT, invalidError, usageError } from "./term.js";
|
|
8
8
|
export function sessionPaths(projectDir, id) {
|
|
@@ -145,6 +145,24 @@ export function createRun(session) {
|
|
|
145
145
|
export function writeRunMeta(p, meta) {
|
|
146
146
|
writeJson(p.meta, meta, 0o644);
|
|
147
147
|
}
|
|
148
|
+
/** Newest first — the order a picker wants. */
|
|
149
|
+
export async function listRuns(session) {
|
|
150
|
+
const out = [];
|
|
151
|
+
for (const id of runIds(session.dir).reverse()) {
|
|
152
|
+
// A run killed before it recorded anything still has an id, and the id
|
|
153
|
+
// says when it started, so it is listable without its meta.
|
|
154
|
+
const meta = await readJson(runPaths(session, id).meta, {});
|
|
155
|
+
out.push({
|
|
156
|
+
id,
|
|
157
|
+
startedAt: meta.startedAt ?? stampInstant(id),
|
|
158
|
+
finishedAt: meta.finishedAt,
|
|
159
|
+
agent: meta.agent,
|
|
160
|
+
stopReason: meta.stopReason,
|
|
161
|
+
error: meta.error,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
148
166
|
export function newestRun(session) {
|
|
149
167
|
return runIds(session.dir).at(-1);
|
|
150
168
|
}
|
package/dist/term.d.ts
CHANGED
|
@@ -26,6 +26,13 @@ export declare const red: (s: string) => string;
|
|
|
26
26
|
export declare const green: (s: string) => string;
|
|
27
27
|
export declare const yellow: (s: string) => string;
|
|
28
28
|
export declare const cyan: (s: string) => string;
|
|
29
|
+
/**
|
|
30
|
+
* Whether anything should be coloured at all — `styleText`'s own answer, asked
|
|
31
|
+
* by the places that write escape codes directly and so cannot inherit it.
|
|
32
|
+
*/
|
|
33
|
+
export declare const styled: () => boolean;
|
|
34
|
+
/** The text as it reads without styling, which is what a file wants. */
|
|
35
|
+
export declare const plain: (s: string) => string;
|
|
29
36
|
export declare function pad(s: string, to: number): string;
|
|
30
37
|
/** Column-aligned rows. Trailing whitespace is trimmed so `diff` stays quiet. */
|
|
31
38
|
export declare function table(rows: readonly (readonly string[])[], gap?: number): string[];
|
package/dist/term.js
CHANGED
|
@@ -45,10 +45,17 @@ export const red = (s) => styleText('red', s);
|
|
|
45
45
|
export const green = (s) => styleText('green', s);
|
|
46
46
|
export const yellow = (s) => styleText('yellow', s);
|
|
47
47
|
export const cyan = (s) => styleText('cyan', s);
|
|
48
|
+
/**
|
|
49
|
+
* Whether anything should be coloured at all — `styleText`'s own answer, asked
|
|
50
|
+
* by the places that write escape codes directly and so cannot inherit it.
|
|
51
|
+
*/
|
|
52
|
+
export const styled = () => styleText('dim', '.') !== '.';
|
|
48
53
|
/** Visible width — style codes must not count towards column alignment. */
|
|
49
54
|
// eslint-disable-next-line no-control-regex
|
|
50
55
|
const ANSI = /\u001b\[[0-9;]*m/g;
|
|
51
|
-
|
|
56
|
+
/** The text as it reads without styling, which is what a file wants. */
|
|
57
|
+
export const plain = (s) => s.replace(ANSI, '');
|
|
58
|
+
const width = plain;
|
|
52
59
|
export function pad(s, to) {
|
|
53
60
|
return s + ' '.repeat(Math.max(0, to - width(s).length));
|
|
54
61
|
}
|
package/dist/tui/app.js
CHANGED
|
@@ -43,7 +43,7 @@ function Answer({ text }) {
|
|
|
43
43
|
const { stdout } = useStdout();
|
|
44
44
|
const width = answerWidth(stdout?.columns ?? 80);
|
|
45
45
|
const segments = segmentsOf(text);
|
|
46
|
-
// Bounded, like every answer in `examples/`: prose that runs the width of a
|
|
46
|
+
// Bounded, like every answer in `examples/sdk/`: prose that runs the width of a
|
|
47
47
|
// wide terminal is a worse read than prose that stops, and the box is also
|
|
48
48
|
// what separates the answer from the machinery that produced it.
|
|
49
49
|
return (_jsx(Box, { flexDirection: "column", width: width, borderStyle: "round", borderDimColor: true, paddingX: 1, marginY: 1, children: segments.map((s, i) => s.code ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: `\u250c\u2500${s.title ? ` ${s.title}` : ''}` }), s.lines.map((l, j) => (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { dimColor: true, children: '\u2502 ' }), _jsx(Text, { wrap: "truncate-end", children: l || ' ' })] }, j))), _jsx(Text, { dimColor: true, children: '\u2514\u2500' })] }, i)) : (_jsx(Text, { children: s.lines.join('\n') }, i))) }));
|
|
@@ -431,7 +431,7 @@ function App({ engine, options, theme }) {
|
|
|
431
431
|
recall(1);
|
|
432
432
|
}
|
|
433
433
|
});
|
|
434
|
-
return (_jsx(ThemeContext.Provider, { value: theme, children: _jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: [BANNER, ...lines], children: (item) => isBanner(item) ? (_jsx(Header, { engine: engine, readOnly: options.readOnly, started: options.started }, item.key)) : (_jsx(Row, { line: item }, item.key)) }), _jsxs(Box, { flexDirection: "column", justifyContent: "flex-end", height: busy ? grown.current : undefined, overflow: "hidden", children: [_jsx(Branches, { boxes: fitted.boxes, spin: spin, columns: columns }), _jsx(Activity, { rows: fitted.trunk, hidden: fitted.hidden }), thinking && budget.thinking ? (_jsx(Thinking, { text: thinking, columns: columns, rows: budget.thinking, spin: spin, live: streaming })) : null, live ? _jsx(Live, { text: live, columns: columns, rows: budget.live }) : null] }), _jsx(Footer, { agent: agent, busy: busy, spin: spin, running: mine, forked: branches.current.size, step: step, model: model, stats: stats, inflight: inflight, reasoning: streaming, columns: columns }), busy ? null : (_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, children: "\u203A " }), _jsx(TextInput, { defaultValue: draft, placeholder: "Ask something\u2026 (\u2191 for history, /help for commands)", onChange: remember, onSubmit: submit }, generation)] }))] }) }));
|
|
434
|
+
return (_jsx(ThemeContext.Provider, { value: theme, children: _jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: [BANNER, ...lines], children: (item) => isBanner(item) ? (_jsx(Header, { engine: engine, readOnly: options.readOnly, started: options.started }, item.key)) : (_jsx(Row, { line: item }, item.key)) }), _jsxs(Box, { flexDirection: "column", justifyContent: "flex-end", height: busy ? grown.current : undefined, overflow: "hidden", children: [_jsx(Branches, { boxes: fitted.boxes, spin: spin, columns: columns }), _jsx(Activity, { rows: fitted.trunk, hidden: fitted.hidden }), thinking && budget.thinking ? (_jsx(Thinking, { text: thinking, columns: columns, rows: budget.thinking, spin: spin, live: streaming })) : null, live ? _jsx(Live, { text: live, columns: columns, rows: budget.live }) : null] }), _jsx(Footer, { agent: agent, busy: busy, spin: spin, frame: frame, running: mine, forked: branches.current.size, step: step, model: model, stats: stats, inflight: inflight, reasoning: streaming, columns: columns }), busy ? null : (_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, children: "\u203A " }), _jsx(TextInput, { defaultValue: draft, placeholder: "Ask something\u2026 (\u2191 for history, /help for commands)", onChange: remember, onSubmit: submit }, generation)] }))] }) }));
|
|
435
435
|
}
|
|
436
436
|
function Header({ engine, readOnly, started, }) {
|
|
437
437
|
const theme = useTheme();
|
|
@@ -467,7 +467,7 @@ const GUTTER = 2;
|
|
|
467
467
|
/**
|
|
468
468
|
* A fan-out, demultiplexed into one box per branch. Interleaving eight
|
|
469
469
|
* branches' calls into a single list is not a picture of parallel work, it is
|
|
470
|
-
* eight pictures shuffled together; a box per branch is what `examples/board.ts`
|
|
470
|
+
* eight pictures shuffled together; a box per branch is what `examples/sdk/board.ts`
|
|
471
471
|
* gets right and what makes the shape of the fork legible at a glance.
|
|
472
472
|
*
|
|
473
473
|
* A box shows the last few calls the branch made and whatever it has in flight,
|
|
@@ -655,7 +655,35 @@ function Live({ text, columns, rows }) {
|
|
|
655
655
|
}
|
|
656
656
|
/** The label column the two number rows line up behind. */
|
|
657
657
|
const LABEL = 10;
|
|
658
|
-
|
|
658
|
+
/** Frames the crest spends past the end, so a sweep reads as a pass, not a loop. */
|
|
659
|
+
const SWEEP_GAP = 6;
|
|
660
|
+
/**
|
|
661
|
+
* A crest of weight travelling through a word, left to right.
|
|
662
|
+
*
|
|
663
|
+
* The spinner is proof the process is alive, but it is chrome and the eye
|
|
664
|
+
* reads the word beside it — a word that never moves is what a hung run looks
|
|
665
|
+
* like. The wave is weight rather than hue because the theme has no ramp to
|
|
666
|
+
* spend: dim at rest, the terminal's own weight at the crest, and the word
|
|
667
|
+
* keeps whatever colour its role gave it.
|
|
668
|
+
*/
|
|
669
|
+
function Shimmer({ text, frame, color, }) {
|
|
670
|
+
const chars = [...text];
|
|
671
|
+
// A new word is a new thing to say, so the sweep starts over on it rather
|
|
672
|
+
// than picking the crest up wherever the last word had got to.
|
|
673
|
+
const begun = useRef(frame);
|
|
674
|
+
const said = useRef(text);
|
|
675
|
+
if (said.current !== text) {
|
|
676
|
+
said.current = text;
|
|
677
|
+
begun.current = frame;
|
|
678
|
+
}
|
|
679
|
+
const head = (frame - begun.current) % (chars.length + SWEEP_GAP);
|
|
680
|
+
return (_jsx(Text, { children: chars.map((ch, i) => {
|
|
681
|
+
// The crest sits on the head and the tail drags behind it.
|
|
682
|
+
const behind = head - i;
|
|
683
|
+
return (_jsx(Text, { color: color, bold: behind === 0, dimColor: behind < 0 || behind > 2, children: ch }, i));
|
|
684
|
+
}) }));
|
|
685
|
+
}
|
|
686
|
+
function Footer({ agent, busy, spin, frame, running, forked, step, model, stats, inflight, reasoning, columns, }) {
|
|
659
687
|
const theme = useTheme();
|
|
660
688
|
// Who before what. The agent is the subject of the sentence, and in a
|
|
661
689
|
// handoff it is the thing that changed.
|
|
@@ -687,7 +715,7 @@ function Footer({ agent, busy, spin, running, forked, step, model, stats, inflig
|
|
|
687
715
|
// Nothing of its own in flight means the word above is about reasoning, so
|
|
688
716
|
// it takes the same hue the reasoning block does.
|
|
689
717
|
const musing = !running.length && !forked;
|
|
690
|
-
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, children: agent }), busy ? (_jsxs(Text, { color: musing ? theme.thinking.color : theme.warn, children: [' ', spin,
|
|
718
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, children: agent }), busy ? (_jsxs(Text, { color: musing ? theme.thinking.color : theme.warn, children: [' ', spin, ' ', _jsx(Shimmer, { text: status, frame: frame, color: musing ? theme.thinking.color : theme.warn })] })) : null, busy ? _jsx(Text, { dimColor: true, children: ` ${aside}` }) : null] }), inflight ? (_jsxs(Text, { dimColor: true, children: ['this turn'.padEnd(LABEL), tokens(inflight.usage), ` · ${secs(inflight.durationMs)}`] })) : (_jsxs(_Fragment, { children: [stats.turn ? (_jsxs(Text, { dimColor: true, children: ['last turn'.padEnd(LABEL), tokens(stats.turn), stats.durationMs === undefined
|
|
691
719
|
? ''
|
|
692
720
|
: ` · ${durationOf(stats.durationMs) ?? ''}`] })) : null, _jsxs(Text, { dimColor: true, children: ['session'.padEnd(LABEL), tokens(stats.session), stats.calls
|
|
693
721
|
? ` · ${stats.calls} ${stats.calls === 1 ? 'call' : 'calls'}`
|
package/dist/tui/wrap.d.ts
CHANGED
|
@@ -71,7 +71,7 @@ export declare function branchRows(count: number, allowance: number): number;
|
|
|
71
71
|
/**
|
|
72
72
|
* How wide the answer is drawn. A line of prose spanning a 200-column terminal
|
|
73
73
|
* is measurably harder to read than one that stops, which is why every demo in
|
|
74
|
-
* `examples/` puts its answer in a box of bounded width — the terminal is the
|
|
74
|
+
* `examples/sdk/` puts its answer in a box of bounded width — the terminal is the
|
|
75
75
|
* page, not the paragraph.
|
|
76
76
|
*/
|
|
77
77
|
export declare function answerWidth(columns: number): number;
|
package/dist/tui/wrap.js
CHANGED
|
@@ -138,7 +138,7 @@ export function branchRows(count, allowance) {
|
|
|
138
138
|
/**
|
|
139
139
|
* How wide the answer is drawn. A line of prose spanning a 200-column terminal
|
|
140
140
|
* is measurably harder to read than one that stops, which is why every demo in
|
|
141
|
-
* `examples/` puts its answer in a box of bounded width — the terminal is the
|
|
141
|
+
* `examples/sdk/` puts its answer in a box of bounded width — the terminal is the
|
|
142
142
|
* page, not the paragraph.
|
|
143
143
|
*/
|
|
144
144
|
export function answerWidth(columns) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zenera/cli",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.18",
|
|
4
4
|
"description": "Command-line front end for @zenera/neo: agentic projects you can run, share and commit.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|
|
@@ -51,10 +51,11 @@
|
|
|
51
51
|
"@inkjs/ui": "^2.0.0",
|
|
52
52
|
"ink": "^7.1.1",
|
|
53
53
|
"react": "^19.2.8",
|
|
54
|
-
"@zenera/neo": "^1.1.
|
|
54
|
+
"@zenera/neo": "^1.1.18",
|
|
55
55
|
"@anthropic-ai/sdk": "^0.120.0",
|
|
56
56
|
"@google/genai": "^2.18.0",
|
|
57
57
|
"@openrouter/sdk": "^1.2.80",
|
|
58
|
+
"google-auth-library": "^10.0.0",
|
|
58
59
|
"openai": "^6.0.0"
|
|
59
60
|
}
|
|
60
61
|
}
|
|
@@ -59,8 +59,9 @@ schema index it is expected to consult all count. When it does, also load the
|
|
|
59
59
|
|
|
60
60
|
Then create or maintain `agents/skills/rag_search/SKILL.md` in the target
|
|
61
61
|
project. Its description must say when an agent should load it. Its body must
|
|
62
|
-
name the target's actual RAG surface and index location,
|
|
63
|
-
|
|
62
|
+
name the target's actual RAG surface and index location, open by stating what an
|
|
63
|
+
agent needs to follow it and what to do without that, and give an executable
|
|
64
|
+
retrieval procedure:
|
|
64
65
|
|
|
65
66
|
1. Discover document and section names with the exact listing command or tool.
|
|
66
67
|
2. Start with hybrid search for a complete natural-language question.
|
|
@@ -72,6 +73,16 @@ use it, and give an executable retrieval procedure:
|
|
|
72
73
|
5. Retrieve a complete section verbatim, or list a table and read its inclusive
|
|
73
74
|
line range when every row is needed.
|
|
74
75
|
|
|
76
|
+
**State the prerequisite, not the audience.** `skills.allow` restricts loading
|
|
77
|
+
and `zen check` validates it; a list of agent names in the body is a second copy
|
|
78
|
+
of that binding with nothing keeping the two in step, and it is stale the first
|
|
79
|
+
time an agent is renamed (§4.2, rule 10). What a reader can act on is what the
|
|
80
|
+
procedure needs - the RAG tools, or `sandbox:*` and the index at its path - and
|
|
81
|
+
what to do without them: _"say so and stop; do not answer from memory"_ (§4.2,
|
|
82
|
+
rule 6). Put it in the first lines of the body, because the catalog is mounted
|
|
83
|
+
whole at `/skills` before any binding applies (§3.4.1), so an agent that was
|
|
84
|
+
never granted this skill can still read the file and start following it.
|
|
85
|
+
|
|
75
86
|
Keep this as runtime guidance, not a pointer to this editor reference: a target
|
|
76
87
|
agent cannot read `.github/` or this project layout while it runs. Include only
|
|
77
88
|
the commands, tools, paths, and corpus-specific constraints that exist in the
|
|
@@ -106,7 +117,7 @@ without losing anything. Never paraphrase the reference into prose of your own.
|
|
|
106
117
|
See §5.3.
|
|
107
118
|
|
|
108
119
|
A project with a `SPECIFICATION.md` carries one more: **`.spec-sync/` records
|
|
109
|
-
what the last `/sync-
|
|
120
|
+
what the last `/spec-sync-project` pass applied**, and the `zen-spec-sync` skill is
|
|
110
121
|
loaded before running that prompt, whether or not a baseline exists yet. The
|
|
111
122
|
state is committed, because a record only the last machine has is not a record.
|
|
112
123
|
Two things to watch for, neither of which `zen check` reports: a baseline that
|
|
@@ -207,7 +218,7 @@ my-project/
|
|
|
207
218
|
│ ├── prompts/*.prompt.md tasks you invoke by name
|
|
208
219
|
│ └── skills/*/SKILL.md reference the editor loads on demand
|
|
209
220
|
├── .env this project's environment - NEVER committed
|
|
210
|
-
├── .spec-sync/ what the last /sync-
|
|
221
|
+
├── .spec-sync/ what the last /spec-sync-project applied - §0.1
|
|
211
222
|
├── agents.yaml who exists, what they may reach for
|
|
212
223
|
├── agents/
|
|
213
224
|
│ ├── instructions.md house rules, prepended to every agent
|
|
@@ -767,10 +778,18 @@ groups - the workspace tools (§3.6), the sandbox tools (§3.7) and the web tool
|
|
|
767
778
|
the machine, so `tools:` is the whole permission model: an agent that does not
|
|
768
779
|
name a tool cannot use it, whatever its prompt says.
|
|
769
780
|
|
|
770
|
-
|
|
781
|
+
Three rules follow:
|
|
771
782
|
|
|
772
783
|
- **Grant the narrowest set the job needs.** An agent that only reviews should
|
|
773
784
|
not be holding the tools that overwrite files.
|
|
785
|
+
- **Grant by group, then subtract.** Write `workspace:*` and take away what this
|
|
786
|
+
agent must not have - not a hand-picked list of names. The tools in a group are
|
|
787
|
+
designed to be used together, and a list assembled one name at a time leaves out
|
|
788
|
+
the companion the model reaches for next: `write_file` without `apply_patch`
|
|
789
|
+
turns every one-line edit into a whole-file rewrite, and `read_file` without
|
|
790
|
+
`list_dir` and `find_files` leaves it guessing at paths. A group also tracks the
|
|
791
|
+
runtime - a tool added to it later reaches the agent, where an enumerated list
|
|
792
|
+
silently stays behind.
|
|
774
793
|
- **Say in the prompt when to reach for what.** A granted tool the prompt never
|
|
775
794
|
mentions is used at the model's discretion, which is not the same as never.
|
|
776
795
|
|
|
@@ -830,6 +849,13 @@ refuses the file. `workspace:*` needs no quoting. There is no name globbing -
|
|
|
830
849
|
`read_*` is an unknown tool, because a selector should track a declared set, not
|
|
831
850
|
a naming habit.
|
|
832
851
|
|
|
852
|
+
Both agents above are written the way to write them: **the group, minus the
|
|
853
|
+
exceptions**. `tools: [read_file, write_file]` is the shape to avoid - it reads
|
|
854
|
+
as a considered decision and is usually an incomplete one, since the tool that
|
|
855
|
+
makes the granted one usable is the tool that was left out. Subtracting says
|
|
856
|
+
which capability is being withheld, which is the thing a reviewer needs to check
|
|
857
|
+
and the thing the next tool added to the group will not quietly defeat.
|
|
858
|
+
|
|
833
859
|
`zen run --read-only` withholds the four mutating tools whatever `agents.yaml`
|
|
834
860
|
asks for: the deployment overriding the repository, as everywhere else.
|
|
835
861
|
|
|
@@ -1075,6 +1101,7 @@ zen run open the entry agent on this project
|
|
|
1075
1101
|
zen run "what changed?" one shot; stdout is the answer
|
|
1076
1102
|
zen run --session <id> continue a session
|
|
1077
1103
|
zen run --workspace ./repo what the agent may read and write
|
|
1104
|
+
zen run --memory ./mem where the agents remember into, instead of memory/
|
|
1078
1105
|
zen run --model careful override the default model for this run
|
|
1079
1106
|
zen run --image <ref> override the sandbox image for this run, ignoring build:
|
|
1080
1107
|
zen run --read-only withhold every tool that can write
|
|
@@ -1095,11 +1122,17 @@ OPENAI_API_KEY=...
|
|
|
1095
1122
|
ANTHROPIC_API_KEY=...
|
|
1096
1123
|
GOOGLE_APPLICATION_CREDENTIALS=./.keys/vertex.json
|
|
1097
1124
|
EXA_API_KEY=...
|
|
1125
|
+
ZENERA_META_MODEL=vertex/gemini-3.8-flash
|
|
1098
1126
|
```
|
|
1099
1127
|
|
|
1100
1128
|
Never commit. Never inline a key into `agents.yaml` - use `${VAR}`. Never print a
|
|
1101
1129
|
key in a log line, a test fixture, or a chat message.
|
|
1102
1130
|
|
|
1131
|
+
`ZENERA_META_MODEL` is the exception that is not a credential: it names the model
|
|
1132
|
+
`zen meta` runs the meta agent on in this project, as a zen ref. It is here
|
|
1133
|
+
rather than in `agents.yaml` because it is a choice about a tool, not about the
|
|
1134
|
+
project's own agents. `zen meta model` prints where the answer came from.
|
|
1135
|
+
|
|
1103
1136
|
### 3.11 `assets/`
|
|
1104
1137
|
|
|
1105
1138
|
`assets/` next to `agents.yaml` - or `assets: <path>` - is reference material
|
|
@@ -1915,10 +1948,17 @@ candidates; these are the judgements to make about each one)**
|
|
|
1915
1948
|
- [ ] Catalog >~30 entries → `discovery: search`
|
|
1916
1949
|
- [ ] A skill that ships a script writes its `/skills/<name>/...` path, the agent
|
|
1917
1950
|
holds `sandbox:*`, and the sandbox image already has the interpreter
|
|
1951
|
+
- [ ] A skill whose procedure needs particular tools, an index or a mounted file
|
|
1952
|
+
opens by naming them and says what to do without them - rather than naming
|
|
1953
|
+
the agents allowed to load it, which `agents.yaml` already decides - §0.1
|
|
1918
1954
|
|
|
1919
1955
|
**Tools**
|
|
1920
1956
|
|
|
1921
1957
|
- [ ] Every agent holds the narrowest set its job needs
|
|
1958
|
+
- [ ] Every `tools:` list is a group selector minus its exceptions, not an
|
|
1959
|
+
enumeration of individual names - §3.5
|
|
1960
|
+
- [ ] No agent holds `write_file` without `apply_patch`, or `read_file` without
|
|
1961
|
+
`list_dir` and `find_files`
|
|
1922
1962
|
- [ ] An agent that only reads is not holding `write_file`, `apply_patch`,
|
|
1923
1963
|
`move_file` or `delete_file` - subtract them from `workspace:*`
|
|
1924
1964
|
- [ ] `sandbox:*` is granted only where a shell is actually needed
|
|
@@ -2062,3 +2102,17 @@ Update it when:
|
|
|
2062
2102
|
|
|
2063
2103
|
Do not let it grow without pruning. When a section is only true of one agent, it
|
|
2064
2104
|
belongs in that agent's prompt, not here.
|
|
2105
|
+
---
|
|
2106
|
+
|
|
2107
|
+
## 13. Function calling rules
|
|
2108
|
+
|
|
2109
|
+
Before every tool call, write **one line** saying what you are about to do and
|
|
2110
|
+
why. One line per call, in front of it - not a plan for the whole turn, and not
|
|
2111
|
+
a summary afterwards.
|
|
2112
|
+
|
|
2113
|
+
- Say the intent and the reason, not the tool's name or its arguments:
|
|
2114
|
+
_"Reading `agents.yaml` to see which agents hold `sandbox:*`."_
|
|
2115
|
+
- When several calls go out together, write one line covering the batch.
|
|
2116
|
+
- Keep it to a sentence. If it needs two, the call needs splitting, not the
|
|
2117
|
+
sentence.
|
|
2118
|
+
- Do not narrate the result in the same line - the tool answers that.
|
|
@@ -12,7 +12,7 @@ answered.
|
|
|
12
12
|
You change two files and no others: `SPECIFICATION.md` and
|
|
13
13
|
`SPECIFICATION-FEEDBACK.md`. You implement nothing - not a prompt, not a skill,
|
|
14
14
|
not `agents.yaml`. Making the project match the specification is
|
|
15
|
-
`/sync-
|
|
15
|
+
`/spec-sync-project`, which runs after this.
|
|
16
16
|
|
|
17
17
|
## 1. Read both files before editing either
|
|
18
18
|
|
|
@@ -83,7 +83,7 @@ put it where the line above the block says it goes.
|
|
|
83
83
|
edits rather than trusting the line numbers you read at the start.
|
|
84
84
|
|
|
85
85
|
Nothing outside `SPECIFICATION.md` changes here. If an answer's text implies an
|
|
86
|
-
agent, a tool grant, a script or a skill, that is the next `/sync-
|
|
86
|
+
agent, a tool grant, a script or a skill, that is the next `/spec-sync-project`
|
|
87
87
|
pass's work, and saying so is all you do about it.
|
|
88
88
|
|
|
89
89
|
## 4. Mark the question answered
|
|
@@ -108,7 +108,7 @@ order:
|
|
|
108
108
|
the specification to be true again, or `Nothing - the project already does
|
|
109
109
|
this.`
|
|
110
110
|
|
|
111
|
-
That last line is what the next `/sync-
|
|
111
|
+
That last line is what the next `/spec-sync-project` works from, so write it as
|
|
112
112
|
work, not as a summary.
|
|
113
113
|
|
|
114
114
|
Then bring the top of the file back into step: the opening paragraph's count of
|
|
@@ -124,7 +124,7 @@ answer you think is wrong is still the answer; say why in the chat and leave the
|
|
|
124
124
|
text alone.
|
|
125
125
|
|
|
126
126
|
Do not run `zen check`, `scripts/_setup.sh` or anything under `.spec-sync/`. The
|
|
127
|
-
baseline moves at the end of a `/sync-
|
|
127
|
+
baseline moves at the end of a `/spec-sync-project` pass and nowhere else -
|
|
128
128
|
committing it here would record a specification as applied that nothing has
|
|
129
129
|
implemented yet.
|
|
130
130
|
|
|
@@ -138,5 +138,5 @@ In the chat:
|
|
|
138
138
|
- what is still open, split into: more than one answer left, more than one box
|
|
139
139
|
marked, every answer rejected, and could not be applied because the place it
|
|
140
140
|
names is gone
|
|
141
|
-
- then, in one line: send `/sync-
|
|
141
|
+
- then, in one line: send `/spec-sync-project` to make the project match the
|
|
142
142
|
specification you have just changed.
|
package/templates/editor/.github/prompts/{sync-with-spec.prompt.md → spec-sync-project.prompt.md}
RENAMED
|
@@ -172,7 +172,7 @@ nothing to raise, say so in the chat and do not create the file.
|
|
|
172
172
|
**It is a questionnaire, and answering it must take one gesture.** Every
|
|
173
173
|
question carries two to four written-out answers, each with a `[ ]` box in its
|
|
174
174
|
heading. The reader marks the box of the one they want - `[x]` - and deletes the
|
|
175
|
-
others, then sends `/
|
|
175
|
+
others, then sends `/spec-apply-feedback`, which folds what survived into
|
|
176
176
|
`SPECIFICATION.md`. Either gesture alone is enough, and the file says so in
|
|
177
177
|
every question.
|
|
178
178
|
|
|
@@ -190,7 +190,7 @@ Two rules that come before everything else in this section:
|
|
|
190
190
|
The file opens with one short paragraph: how many questions are open, and how to
|
|
191
191
|
answer them - put an `x` in the box of the answer you want, delete the answers
|
|
192
192
|
you do not want, or replace them all with a sentence of your own, then send
|
|
193
|
-
`/
|
|
193
|
+
`/spec-apply-feedback`. Then a contents list, one line per question: its
|
|
194
194
|
number as an anchor link, the question in under ten words, and what is built in
|
|
195
195
|
the meantime. Then the questions, grouped under these headings, omitting any
|
|
196
196
|
heading with no questions. The file is scanned before it is read, so keep the
|
|
@@ -212,7 +212,7 @@ emoji on every heading:
|
|
|
212
212
|
## ✅ Answered - folded into the specification
|
|
213
213
|
```
|
|
214
214
|
|
|
215
|
-
`✅` is written by `/
|
|
215
|
+
`✅` is written by `/spec-apply-feedback`, not by you. Leave what is there
|
|
216
216
|
alone except to strike an entry this pass has actually implemented, and say in
|
|
217
217
|
the chat which ones you closed.
|
|
218
218
|
|
|
@@ -329,7 +329,7 @@ retires with it.
|
|
|
329
329
|
Read the feedback diff from §0 before writing: a question the human answered
|
|
330
330
|
since the last pass is work to close, not a question to ask again. If a question
|
|
331
331
|
has a box marked `[x]`, or one answer left under it, and no `✅` entry, the
|
|
332
|
-
reader answered it and never ran `/
|
|
332
|
+
reader answered it and never ran `/spec-apply-feedback` - implement that
|
|
333
333
|
answer, fold its text into `SPECIFICATION.md` yourself, and say so in the chat.
|
|
334
334
|
|
|
335
335
|
## 6. Run `scripts/_setup.sh` and watch it finish
|
|
@@ -7,12 +7,13 @@ zen inspect [run] [--session <id>] [--open] [--rebuild] [--serve [port]]
|
|
|
7
7
|
Alias: `report`.
|
|
8
8
|
|
|
9
9
|
Renders the trajectory of a run: every message, tool call, skill activation and
|
|
10
|
-
hand-off, in order, with what each one cost. With no arguments it
|
|
11
|
-
|
|
10
|
+
hand-off, in order, with what each one cost. With no arguments it asks which
|
|
11
|
+
session and which run; where there is nothing to ask on — a script, `--json`,
|
|
12
|
+
an agent — it takes the newest run of the newest session that has one.
|
|
12
13
|
|
|
13
14
|
| Flag | Meaning |
|
|
14
15
|
| ---------------- | ------------------------------------------------------- |
|
|
15
|
-
| `[run]` | A run id
|
|
16
|
+
| `[run]` | A run id, in whichever session holds it |
|
|
16
17
|
| `--session <id>` | Which session the run belongs to |
|
|
17
18
|
| `--open` | Open the report in a browser |
|
|
18
19
|
| `--rebuild` | Rebuild `report.html` from the recorded state |
|