@zenera/cli 1.1.16 → 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.
Files changed (71) hide show
  1. package/README.md +110 -19
  2. package/dist/banner.d.ts +19 -0
  3. package/dist/banner.js +12 -9
  4. package/dist/catalog.js +19 -20
  5. package/dist/command.d.ts +3 -0
  6. package/dist/commands/check.js +5 -1
  7. package/dist/commands/index.js +4 -1
  8. package/dist/commands/init.js +42 -7
  9. package/dist/commands/inspect.js +77 -13
  10. package/dist/commands/key.js +49 -1
  11. package/dist/commands/memory.js +16 -3
  12. package/dist/commands/meta.d.ts +3 -0
  13. package/dist/commands/meta.js +596 -0
  14. package/dist/commands/run.js +64 -35
  15. package/dist/engine.d.ts +15 -0
  16. package/dist/engine.js +26 -1
  17. package/dist/env.d.ts +20 -0
  18. package/dist/env.js +65 -0
  19. package/dist/home.d.ts +2 -0
  20. package/dist/home.js +2 -0
  21. package/dist/keys.d.ts +2 -0
  22. package/dist/keys.js +2 -0
  23. package/dist/main.js +1 -1
  24. package/dist/meta.d.ts +221 -0
  25. package/dist/meta.js +695 -0
  26. package/dist/podman.js +64 -2
  27. package/dist/projects.d.ts +9 -1
  28. package/dist/projects.js +19 -2
  29. package/dist/resolve.js +7 -1
  30. package/dist/sandbox.d.ts +11 -0
  31. package/dist/sandbox.js +24 -4
  32. package/dist/scaffold.d.ts +6 -0
  33. package/dist/scaffold.js +56 -15
  34. package/dist/session.d.ts +10 -0
  35. package/dist/session.js +19 -1
  36. package/dist/term.d.ts +7 -0
  37. package/dist/term.js +8 -1
  38. package/dist/tui/app.js +33 -5
  39. package/dist/tui/wrap.d.ts +1 -1
  40. package/dist/tui/wrap.js +1 -1
  41. package/dist/validate.js +57 -15
  42. package/package.json +3 -2
  43. package/templates/editor/.github/copilot-instructions.md +313 -94
  44. package/templates/editor/.github/prompts/project-review.prompt.md +74 -0
  45. package/templates/editor/.github/prompts/spec-apply-feedback.prompt.md +142 -0
  46. package/templates/editor/.github/prompts/spec-sync-project.prompt.md +392 -0
  47. package/templates/editor/.github/skills/zen-cli/references/check.md +6 -3
  48. package/templates/editor/.github/skills/zen-cli/references/frame.md +5 -3
  49. package/templates/editor/.github/skills/zen-cli/references/inspect.md +4 -3
  50. package/templates/editor/.github/skills/zen-cli/references/memory.md +14 -0
  51. package/templates/editor/.github/skills/zen-cli/references/projects.md +19 -2
  52. package/templates/editor/.github/skills/zen-cli/references/run.md +75 -16
  53. package/templates/editor/.github/skills/zen-cli/references/sandbox.md +6 -0
  54. package/templates/editor/.github/skills/zen-memory/SKILL.md +153 -33
  55. package/templates/editor/.github/skills/zen-memory/scripts/check-instructions.sh +115 -0
  56. package/templates/editor/.github/skills/zen-review/SKILL.md +123 -0
  57. package/templates/editor/.github/skills/zen-review/scripts/check-paths.sh +119 -0
  58. package/templates/editor/.github/skills/zen-review/scripts/review.sh +104 -0
  59. package/templates/editor/.github/skills/zen-spec-sync/SKILL.md +197 -0
  60. package/templates/editor/.github/skills/zen-spec-sync/scripts/snapshot.sh +200 -0
  61. package/templates/editor/.vscode/settings.json +44 -4
  62. package/templates/parts/memory-terms.yaml.tmpl +16 -0
  63. package/templates/parts/memory.yaml.tmpl +12 -0
  64. package/templates/project/SPECIFICATION.md.tmpl +32 -2
  65. package/templates/project/agents/memory-instructions.md +278 -0
  66. package/templates/project/agents.yaml.tmpl +12 -3
  67. package/templates/project/env +28 -0
  68. package/templates/project/gitignore +4 -4
  69. package/templates/editor/.github/prompts/review-project.prompt.md +0 -36
  70. package/templates/editor/.github/prompts/sync-with-spec.prompt.md +0 -205
  71. /package/templates/project/{INSTRUCTIONS.md → agents/instructions.md} +0 -0
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 'install it with your package manager, e.g. apt install podman';
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
  // ---------------------------------------------------------------------------
@@ -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
- const chosen = await choose('Which project?', known.map((e) => ({ label: e.name, detail: e.path, value: e })));
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;
@@ -28,6 +30,15 @@ export interface SandboxInputs {
28
30
  image?: string;
29
31
  /** `--no-keys`: refuse to forward credentials, whatever the config says */
30
32
  keys?: boolean;
33
+ /**
34
+ * Names from the project's `.env`, forwarded into the container the same
35
+ * way a credential is — by name, so the value stays out of the argv.
36
+ *
37
+ * They travel with the keys rather than beside them: a `.env` is where an
38
+ * api token lives, so `--no-keys` has to withhold it too or the flag is a
39
+ * promise it does not keep.
40
+ */
41
+ env?: readonly string[];
31
42
  }
32
43
  export declare function buildSandbox(opts: SandboxInputs): SandboxSetup;
33
44
  /**
package/dist/sandbox.js CHANGED
@@ -82,7 +82,7 @@ export function buildSandbox(opts) {
82
82
  // read-only directory fails on writing its own `__pycache__` — a confusing
83
83
  // error about a file nobody asked for.
84
84
  const extra = { HOME, PYTHONDONTWRITEBYTECODE: '1', ...(keys?.env ?? {}) };
85
- const spec = toSpec(base, extra, keys?.secrets);
85
+ const spec = toSpec(base, extra, secretsOf(keys?.secrets, opts.env));
86
86
  const agents = {};
87
87
  for (const agent of opts.config.agents) {
88
88
  if (agent.sandbox) {
@@ -92,7 +92,7 @@ export function buildSandbox(opts) {
92
92
  // so the variable is what actually decides, and without it the file
93
93
  // sitting there is not a credential anything will look for.
94
94
  const own = merged.keys === false ? undefined : keys;
95
- agents[agent.name] = toSpec(merged, { HOME, PYTHONDONTWRITEBYTECODE: '1', ...(own?.env ?? {}) }, own?.secrets);
95
+ agents[agent.name] = toSpec(merged, { HOME, PYTHONDONTWRITEBYTECODE: '1', ...(own?.env ?? {}) }, own && secretsOf(own.secrets, opts.env));
96
96
  }
97
97
  }
98
98
  const pool = new SandboxPool({
@@ -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
@@ -121,10 +121,30 @@ function merge(base, agent) {
121
121
  }
122
122
  return merged;
123
123
  }
124
+ /**
125
+ * The two sources of forwarded names, as one list: the credentials the keyring
126
+ * selected, and whatever the project's `.env` declares. Deduped, because the
127
+ * same variable named twice is the same `--env` twice.
128
+ *
129
+ * `undefined` in means keys are withheld, and the file goes with them.
130
+ */
131
+ function secretsOf(secrets, env) {
132
+ if (!secrets) {
133
+ return undefined;
134
+ }
135
+ return [...new Set([...secrets, ...(env ?? [])])];
136
+ }
124
137
  /**
125
138
  * Config names a variable; this reads it. A name that is not set on this host
126
139
  * is simply not forwarded — an empty string in the container is a different
127
140
  * thing from an absent one, and tools test for absence.
141
+ *
142
+ * A name that has already been answered with a value drops off the forwarded
143
+ * list: `--env NAME=value` and `--env NAME` are the same variable twice, and
144
+ * the one that carries a value is the one that was resolved on purpose. That
145
+ * matters for `GOOGLE_APPLICATION_CREDENTIALS`, which is rewritten here to
146
+ * where the key file was mounted — a `.env` naming it must not put the host's
147
+ * path back, since that path does not exist inside the container.
128
148
  */
129
149
  function toSpec(config, extra, secrets) {
130
150
  const env = { ...extra };
@@ -144,7 +164,7 @@ function toSpec(config, extra, secrets) {
144
164
  user: config.user,
145
165
  persist: config.persist,
146
166
  env,
147
- secrets,
167
+ secrets: secrets?.filter((name) => !(name in env)),
148
168
  };
149
169
  }
150
170
  /**
@@ -14,6 +14,12 @@ export interface ScaffoldOptions {
14
14
  modelOptions?: string;
15
15
  /** give the default agent `exa:*` — set when a key for it is on hand */
16
16
  web?: boolean;
17
+ /**
18
+ * What vectorises the memory graph, as a provider-prefixed ref. Left out
19
+ * when the project's provider publishes no embeddings API — memory still
20
+ * works, ranking recall by term overlap instead of by meaning.
21
+ */
22
+ embedding?: string;
17
23
  }
18
24
  export interface Scaffolded {
19
25
  /** the project's own files, in the order they were written */
package/dist/scaffold.js CHANGED
@@ -1,5 +1,5 @@
1
- import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, } from 'node:fs';
2
- import { join } from 'node:path';
1
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  // ---------------------------------------------------------------------------
5
5
  // Scaffolding
@@ -18,6 +18,7 @@ import { fileURLToPath } from 'node:url';
18
18
  //
19
19
  // The trees are copied whole and nothing enumerates them, so adding a file to
20
20
  // a new project is adding a file to `templates/project/` and nothing else.
21
+ // `MEMORY_RULES` is the single exception, and it is named below.
21
22
  //
22
23
  // What is there is deliberately close to empty: a template full of
23
24
  // commented-out options is a template nobody reads and everybody deletes. The
@@ -32,6 +33,10 @@ import { fileURLToPath } from 'node:url';
32
33
  const TEMPLATES = fileURLToPath(new URL('../templates', import.meta.url));
33
34
  /** The suffix on a file with `{{...}}` in it, dropped when the file lands. */
34
35
  const TEMPLATE = '.tmpl';
36
+ /** How to use the memory graph: a house rules file, landing under `agents/`. */
37
+ const MEMORY_RULES = join('agents', 'memory-instructions.md');
38
+ /** The same bytes, kept in the `zen-memory` skill to restore or diff against. */
39
+ const MEMORY_REFERENCE = join('.github', 'skills', 'zen-memory', 'references', 'memory-instructions.md');
35
40
  /**
36
41
  * This `zen`'s own version, which the scaffold pins the sandbox's tools to.
37
42
  * The publishable packages move in lockstep, so one number covers them all.
@@ -98,17 +103,24 @@ function modelSection(ref, options) {
98
103
  });
99
104
  }
100
105
  /**
101
- * The name a template file lands under.
106
+ * Template files that land under a name they cannot be stored under.
102
107
  *
103
- * `gitignore` gains its dot here because it cannot have one in the repository:
104
- * npm strips a `.gitignore` out of a published tarball, and git would read this
105
- * one as rules about `packages/cli/templates/` rather than as content.
108
+ * `gitignore` cannot have its dot in the repository: npm strips a `.gitignore`
109
+ * out of a published tarball, and git would read this one as rules about
110
+ * `packages/cli/templates/` rather than as content. `env` cannot have its dot
111
+ * for a plainer reason — this repository ignores `.env` everywhere, as every
112
+ * repository should, so the template would never be committed at all.
106
113
  */
114
+ const DOTFILES = {
115
+ gitignore: '.gitignore',
116
+ env: '.env',
117
+ };
118
+ /** The name a template file lands under. */
107
119
  function target(name) {
108
120
  if (name.endsWith(TEMPLATE)) {
109
121
  return name.slice(0, -TEMPLATE.length);
110
122
  }
111
- return name === 'gitignore' ? '.gitignore' : name;
123
+ return DOTFILES[name] ?? name;
112
124
  }
113
125
  /**
114
126
  * Copies one template directory into `dir` at `rel`, depth first, sorted so the
@@ -145,13 +157,19 @@ function copyTree(from, dir, rel, opts) {
145
157
  // ---------------------------------------------------------------------------
146
158
  // Telling the editor which instructions are not for it
147
159
  //
148
- // The project's house rules are `INSTRUCTIONS.md`, deliberately not
160
+ // The project's house rules are `agents/instructions.md`, deliberately not
149
161
  // `AGENTS.md`: every coding assistant now reads that name out of an open
150
162
  // folder and feeds it to itself as always-on instructions, and `zen open`
151
163
  // opens exactly this directory. A name nobody else claims means the two are
152
164
  // never confused, and `chat.useAgentsMdFile` no longer has to be switched off
153
165
  // to keep them apart.
154
166
  //
167
+ // They sit under `agents/` because there is rarely only one of them. Anything
168
+ // named `agents/<topic>-instructions.md` is read too, in filename order, so a
169
+ // subject that is true for every agent but is about one capability — memory,
170
+ // say — gets its own document instead of another section in a file that keeps
171
+ // growing.
172
+ //
155
173
  // `chat.useNestedAgentsMdFiles` is still written. It is already false by
156
174
  // default, but it is opt-in globally, and this is a directory the agent itself
157
175
  // writes into — someone who turned it on would otherwise have the editor pick
@@ -159,12 +177,12 @@ function copyTree(from, dir, rel, opts) {
159
177
  // setting, so it applies only in a trusted workspace; that is the right way
160
178
  // round, since an untrusted folder is not one to run agents in either.
161
179
  //
162
- // `INSTRUCTIONS.md` addresses the *project's* agents. The editor's assistant
163
- // still needs a brief of its own, and what it needs to know is how this kind
164
- // of project is put together — the file formats, how a prompt is written, when
165
- // to add a skill rather than an agent. That is what the `.github/` tree is: the
166
- // standing brief, plus the prompt files and skills the editor picks up from the
167
- // same place.
180
+ // `agents/instructions.md` addresses the *project's* agents. The editor's
181
+ // assistant still needs a brief of its own, and what it needs to know is how
182
+ // this kind of project is put together — the file formats, how a prompt is
183
+ // written, when to add a skill rather than an agent. That is what the
184
+ // `.github/` tree is: the standing brief, plus the prompt files and skills the
185
+ // editor picks up from the same place.
168
186
  // ---------------------------------------------------------------------------
169
187
  /**
170
188
  * Writes the editor's files under `dir`, replacing what is there. They are
@@ -174,7 +192,21 @@ function copyTree(from, dir, rel, opts) {
174
192
  * none. Returns the relative paths written.
175
193
  */
176
194
  export function editorFiles(dir) {
177
- return copyTree(join(TEMPLATES, 'editor'), dir, '', {});
195
+ const written = copyTree(join(TEMPLATES, 'editor'), dir, '', {});
196
+ // The one file that belongs to both trees. It is a project file first —
197
+ // house rules prepended to every agent that can see the graph — and it is
198
+ // written once and edited from then on, like every other project file. But
199
+ // a project whose copy drifted, or that deleted it and turned memory back
200
+ // on later, needs somewhere to get the current text from, and the editor
201
+ // tree is replaced on every `init` and `open`. Copying the same bytes into
202
+ // the skill's references is what makes `diff` between the two mean
203
+ // something, and what stops a second copy of these rules existing in the
204
+ // repository to fall out of step with the first.
205
+ const reference = join(dir, MEMORY_REFERENCE);
206
+ mkdirSync(dirname(reference), { recursive: true });
207
+ copyFileSync(join(TEMPLATES, 'project', MEMORY_RULES), reference);
208
+ written.push(MEMORY_REFERENCE);
209
+ return written;
178
210
  }
179
211
  /**
180
212
  * Writes a project. Never overwrites the project's own files — a second `init`
@@ -198,6 +230,9 @@ export function scaffold(opts) {
198
230
  // different readers — and a tool nothing says to use is not one.
199
231
  web: opts.web ? part('exa.spec.md') : '',
200
232
  webPrompt: opts.web ? part('exa.prompt.md') : '',
233
+ memory: opts.embedding
234
+ ? part('memory.yaml', { embedding: opts.embedding })
235
+ : part('memory-terms.yaml'),
201
236
  version: ownVersion(),
202
237
  },
203
238
  });
@@ -209,6 +244,12 @@ export function scaffold(opts) {
209
244
  mkdirSync(join(opts.dir, 'agents', 'skills'), { recursive: true });
210
245
  mkdirSync(join(opts.dir, 'sessions'), { recursive: true });
211
246
  mkdirSync(join(opts.dir, '.tmp'), { recursive: true });
247
+ // Where `/spec-sync-project` records what it applied, so the next pass works
248
+ // the difference rather than the whole specification again. Empty here, and
249
+ // git does not carry an empty directory: what says a pass has completed is
250
+ // `baseline/manifest.txt`, never the directory itself.
251
+ mkdirSync(join(opts.dir, '.spec-sync', 'baseline'), { recursive: true });
252
+ mkdirSync(join(opts.dir, '.spec-sync', 'history'), { recursive: true });
212
253
  // The project directory is what `zen open` opens, so this is where the
213
254
  // editor actually reads them.
214
255
  return { files, editor: editorFiles(opts.dir) };
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
- const width = (s) => s.replace(ANSI, '');
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
- function Footer({ agent, busy, spin, running, forked, step, model, stats, inflight, reasoning, columns, }) {
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, " ", status] })) : 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
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'}`
@@ -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) {