@zenera/cli 1.1.16 → 1.1.17

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 (39) hide show
  1. package/README.md +20 -12
  2. package/dist/catalog.js +19 -20
  3. package/dist/commands/check.js +5 -1
  4. package/dist/commands/init.js +41 -6
  5. package/dist/commands/run.js +4 -0
  6. package/dist/engine.js +6 -0
  7. package/dist/env.d.ts +20 -0
  8. package/dist/env.js +65 -0
  9. package/dist/keys.d.ts +2 -0
  10. package/dist/keys.js +2 -0
  11. package/dist/sandbox.d.ts +9 -0
  12. package/dist/sandbox.js +23 -3
  13. package/dist/scaffold.d.ts +6 -0
  14. package/dist/scaffold.js +56 -15
  15. package/dist/validate.js +57 -15
  16. package/package.json +2 -2
  17. package/templates/editor/.github/copilot-instructions.md +256 -91
  18. package/templates/editor/.github/prompts/review-project.prompt.md +53 -14
  19. package/templates/editor/.github/prompts/sync-with-spec.prompt.md +208 -21
  20. package/templates/editor/.github/prompts/update-spec-from-feedback.prompt.md +142 -0
  21. package/templates/editor/.github/skills/zen-cli/references/check.md +6 -3
  22. package/templates/editor/.github/skills/zen-cli/references/frame.md +5 -3
  23. package/templates/editor/.github/skills/zen-cli/references/projects.md +19 -2
  24. package/templates/editor/.github/skills/zen-cli/references/sandbox.md +6 -0
  25. package/templates/editor/.github/skills/zen-memory/SKILL.md +102 -30
  26. package/templates/editor/.github/skills/zen-memory/scripts/check-instructions.sh +115 -0
  27. package/templates/editor/.github/skills/zen-review/SKILL.md +123 -0
  28. package/templates/editor/.github/skills/zen-review/scripts/check-paths.sh +119 -0
  29. package/templates/editor/.github/skills/zen-review/scripts/review.sh +104 -0
  30. package/templates/editor/.github/skills/zen-spec-sync/SKILL.md +197 -0
  31. package/templates/editor/.github/skills/zen-spec-sync/scripts/snapshot.sh +200 -0
  32. package/templates/editor/.vscode/settings.json +44 -4
  33. package/templates/parts/memory-terms.yaml.tmpl +16 -0
  34. package/templates/parts/memory.yaml.tmpl +12 -0
  35. package/templates/project/SPECIFICATION.md.tmpl +7 -0
  36. package/templates/project/agents/memory-instructions.md +278 -0
  37. package/templates/project/agents.yaml.tmpl +9 -0
  38. package/templates/project/env +22 -0
  39. /package/templates/project/{INSTRUCTIONS.md → agents/instructions.md} +0 -0
package/README.md CHANGED
@@ -205,9 +205,12 @@ In the window that opens:
205
205
  2. **Open the chat panel and send `/sync-with-spec`.** It reads the
206
206
  specification and every file implementing it, works out the difference in
207
207
  both directions, and changes the smallest thing that closes each gap.
208
- 3. **Read `SPECIFICATION-FEEDBACK.md`**, which it writes for anything it could
209
- not do without guessing. Answer its questions by editing `SPECIFICATION.md`
210
- - not by editing prompts - and send `/sync-with-spec` again.
208
+ 3. **Answer `SPECIFICATION-FEEDBACK.md`**, which it writes for anything it could
209
+ not do without guessing. Every question carries two to four written-out
210
+ answers, each with a `[ ]` box: tick one, delete the ones you do not want, or
211
+ write your own in their place.
212
+ 4. **Send `/update-spec-from-feedback`.** It folds the answers you chose into
213
+ `SPECIFICATION.md` - never into a prompt - and then `/sync-with-spec` again.
211
214
 
212
215
  Back in the terminal: `zen check`, then `zen run`.
213
216
 
@@ -218,12 +221,13 @@ They are prompt files under `.github/prompts/`, which VS Code and its forks
218
221
  offer as chat slash-commands. Both `zen init` and `zen open` write them fresh,
219
222
  so they never go stale - and edits to them do not survive.
220
223
 
221
- | In chat | Does |
222
- | ----------------- | ----------------------------------------------------------- |
223
- | `/sync-with-spec` | Makes every file match `SPECIFICATION.md`, both directions. |
224
- | `/review-project` | Reads the project as a reviewer would, and reports. |
225
- | `/new-agent` | Adds an agent - prompt, wiring and hand-offs. |
226
- | `/new-skill` | Adds a skill under `agents/skills/`. |
224
+ | In chat | Does |
225
+ | ---------------------------- | ---------------------------------------------------------------- |
226
+ | `/sync-with-spec` | Makes every file match `SPECIFICATION.md`, both directions. |
227
+ | `/update-spec-from-feedback` | Folds your answers in `SPECIFICATION-FEEDBACK.md` into the spec. |
228
+ | `/review-project` | Reads the project as a reviewer would, and reports. |
229
+ | `/new-agent` | Adds an agent - prompt, wiring and hand-offs. |
230
+ | `/new-skill` | Adds a skill under `agents/skills/`. |
227
231
 
228
232
  In an editor that does not support prompt files, paste the contents of
229
233
  `.github/prompts/sync-with-spec.prompt.md` into its chat instead - it is only a
@@ -378,20 +382,24 @@ is a command on their machine too.
378
382
  Prompts, agent wiring, skills and tool selections are all files - Markdown and
379
383
  YAML - rather than code buried inside an application. So the folder can be
380
384
  committed, copied to another machine, reviewed in a pull request, and handed to
381
- someone else who runs it with one command. Credentials live in `$HOME`, never in
382
- the project, so there is no secret to strip before sharing it.
385
+ someone else who runs it with one command. Model credentials live in `$HOME`,
386
+ never in the project, and anything the project itself needs goes in a
387
+ git-ignored `.env` - so there is no secret to strip before sharing it.
383
388
 
384
389
  ```
385
390
  my-project/
386
391
  SPECIFICATION.md what this is for - the intent
387
- INSTRUCTIONS.md house rules, prepended to every agent
388
392
  agents.yaml who exists, what they may reach for
389
393
  agents/
394
+ instructions.md house rules, prepended to every agent
395
+ <topic>-instructions.md more of them, one subject at a time
390
396
  prompts/<name>.md each agent's own brief
391
397
  skills/<name>/ knowledge loaded on demand, not always-on
392
398
  assets/ reference material, read-only at /assets
393
399
  sandbox/Dockerfile the container commands run in
394
400
  scripts/_setup.sh the one command that initialises the project
401
+ .spec-sync/ what the last sync with the spec applied
402
+ .env this project's environment - git-ignored
395
403
  sessions/ one workspace, memory and trajectory each
396
404
  <id>/
397
405
  workspace/ what the agents can read and write
package/dist/catalog.js CHANGED
@@ -38,29 +38,28 @@ const CATALOG_VERSION = 1;
38
38
  */
39
39
  export const CURATED = {
40
40
  openai: [
41
- { id: 'gpt-4o-mini', roles: ['chat'], contextLength: 128_000 },
42
- { id: 'gpt-4o', roles: ['chat'], contextLength: 128_000 },
41
+ { id: 'gpt-5.6-luna', roles: ['chat'], contextLength: 1_050_000 },
42
+ { id: 'gpt-5.6-terra', roles: ['chat'], contextLength: 1_050_000 },
43
43
  { id: 'text-embedding-3-small', roles: ['embedding'], dimensions: 1536 },
44
44
  { id: 'text-embedding-3-large', roles: ['embedding'], dimensions: 3072 },
45
45
  ],
46
46
  anthropic: [
47
- { id: 'claude-haiku-4-5', roles: ['chat'], contextLength: 200_000 },
48
- { id: 'claude-sonnet-4-5', roles: ['chat'], contextLength: 200_000 },
47
+ { id: 'claude-sonnet-5', roles: ['chat'], contextLength: 1_000_000 },
48
+ { id: 'claude-opus-5', roles: ['chat'], contextLength: 1_000_000 },
49
49
  ],
50
50
  google: [
51
- { id: 'gemini-2.5-flash', roles: ['chat'], contextLength: 1_048_576 },
52
- { id: 'gemini-2.5-pro', roles: ['chat'], contextLength: 1_048_576 },
53
- { id: 'gemini-embedding-001', roles: ['embedding'], dimensions: 3072 },
51
+ { id: 'gemini-3.5-flash-lite', roles: ['chat'], contextLength: 1_048_576 },
52
+ { id: 'gemini-3.8-flash', roles: ['chat'], contextLength: 1_048_576 },
53
+ { id: 'gemini-embedding-2', roles: ['embedding'] },
54
54
  ],
55
55
  vertex: [
56
- { id: 'gemini-2.5-flash', roles: ['chat'], contextLength: 1_048_576 },
57
- { id: 'gemini-2.5-pro', roles: ['chat'], contextLength: 1_048_576 },
58
- { id: 'gemini-embedding-001', roles: ['embedding'], dimensions: 3072 },
59
- { id: 'text-embedding-005', roles: ['embedding'], dimensions: 768 },
56
+ { id: 'gemini-3.5-flash-lite', roles: ['chat'], contextLength: 1_048_576 },
57
+ { id: 'gemini-3.8-flash', roles: ['chat'], contextLength: 1_048_576 },
58
+ { id: 'gemini-embedding-2', roles: ['embedding'] },
60
59
  ],
61
60
  openrouter: [
62
- { id: 'openai/gpt-4o-mini', roles: ['chat'] },
63
- { id: 'anthropic/claude-haiku-4.5', roles: ['chat'] },
61
+ { id: 'openai/gpt-5.6-luna', roles: ['chat'], contextLength: 1_050_000 },
62
+ { id: 'anthropic/claude-sonnet-5', roles: ['chat'], contextLength: 1_000_000 },
64
63
  { id: 'openai/text-embedding-3-small', roles: ['embedding'], dimensions: 1536 },
65
64
  ],
66
65
  };
@@ -73,23 +72,23 @@ export const CURATED = {
73
72
  */
74
73
  export const PREFERRED = {
75
74
  openai: {
76
- chat: ['gpt-4o-mini', 'gpt-4o'],
75
+ chat: ['gpt-5.6-luna', 'gpt-5.6-terra'],
77
76
  embedding: ['text-embedding-3-small', 'text-embedding-3-large'],
78
77
  },
79
78
  anthropic: {
80
- chat: ['claude-haiku-4-5', 'claude-sonnet-4-5'],
79
+ chat: ['claude-sonnet-5', 'claude-opus-5'],
81
80
  embedding: [],
82
81
  },
83
82
  google: {
84
- chat: ['gemini-2.5-flash', 'gemini-2.5-pro'],
85
- embedding: ['gemini-embedding-001'],
83
+ chat: ['gemini-3.5-flash-lite', 'gemini-3.5-flash'],
84
+ embedding: ['gemini-embedding-2'],
86
85
  },
87
86
  vertex: {
88
- chat: ['gemini-2.5-flash', 'gemini-2.5-pro'],
89
- embedding: ['text-embedding-005', 'gemini-embedding-001'],
87
+ chat: ['gemini-3.5-flash-lite', 'gemini-3.5-flash'],
88
+ embedding: ['gemini-embedding-2'],
90
89
  },
91
90
  openrouter: {
92
- chat: ['openai/gpt-4o-mini', 'anthropic/claude-haiku-4.5'],
91
+ chat: ['openai/gpt-5.6-luna', 'anthropic/claude-sonnet-5'],
93
92
  embedding: ['openai/text-embedding-3-small'],
94
93
  },
95
94
  };
@@ -1,6 +1,7 @@
1
1
  import { existsSync, statSync } from 'node:fs';
2
2
  import { basename, dirname, resolve } from 'node:path';
3
3
  import { one, parse } from "../args.js";
4
+ import { loadProjectEnv } from "../env.js";
4
5
  import { KeyStore } from "../keys.js";
5
6
  import { duration } from "../narrate.js";
6
7
  import { Registry } from "../projects.js";
@@ -75,7 +76,10 @@ export const check = {
75
76
  const name = entry?.name ?? basename(dir);
76
77
  // Materialised first, so the credential verdicts are the ones a run
77
78
  // would reach: a key in the environment and a key in the keyring are
78
- // the same key by the time the library asks.
79
+ // the same key by the time the library asks. The project's own `.env`
80
+ // goes first for the same reason — `zen run` reads it, so a check that
81
+ // did not would report a missing credential for a project that runs.
82
+ loadProjectEnv(dir);
79
83
  const keys = await KeyStore.open();
80
84
  keys.materialize();
81
85
  const bar = progress();
@@ -2,6 +2,7 @@ import { readProjectConfig } from '@zenera/neo';
2
2
  import { existsSync, readdirSync } from 'node:fs';
3
3
  import { basename, resolve } from 'node:path';
4
4
  import { one, parse } from "../args.js";
5
+ import { PREFERRED } from "../catalog.js";
5
6
  import { ensureHome } from "../home.js";
6
7
  import { resolveBuild } from "../image.js";
7
8
  import { envNames, isProvider, keyId, KeyStore, PROVIDERS } from "../keys.js";
@@ -43,6 +44,36 @@ const DEFAULT_MODEL = {
43
44
  vertex: { ref: 'vertex:gemini-3.5-flash' },
44
45
  openrouter: { ref: 'openrouter:inclusionai/ling-3.0-flash-fin:free' },
45
46
  };
47
+ /**
48
+ * What vectorises the project's memory, given the provider it ends up on.
49
+ *
50
+ * Taken from the same table `zen models pick` ranks by rather than written out
51
+ * again here, so the scaffold cannot drift from what the catalogue calls
52
+ * current. Anthropic publishes no embeddings API, and a `--model` naming a
53
+ * provider we do not ship gives nothing to guess from; both scaffold without
54
+ * one, and memory still works by ranking recall on term overlap. An
55
+ * `embeddings:` entry pointing at a provider that has none is not a degraded
56
+ * project, it is one that fails to load.
57
+ */
58
+ function defaultEmbedding(provider) {
59
+ if (provider === undefined) {
60
+ return undefined;
61
+ }
62
+ const id = PREFERRED[provider].embedding[0];
63
+ return id === undefined ? undefined : `${provider}:${id}`;
64
+ }
65
+ /**
66
+ * The provider a model ref names, when it names one we know.
67
+ *
68
+ * Every ref the scaffold writes carries its provider, so this is how the
69
+ * memory store ends up on the same credential as the chat model — including
70
+ * for an explicit `--model`, where nothing else says which vendor was meant.
71
+ */
72
+ function providerOf(ref) {
73
+ const colon = ref.indexOf(':');
74
+ const head = colon < 0 ? '' : ref.slice(0, colon);
75
+ return isProvider(head) ? head : undefined;
76
+ }
46
77
  /**
47
78
  * The provider this machine can actually reach.
48
79
  *
@@ -96,10 +127,11 @@ export const init = {
96
127
  summary: 'Create a project here, or in <dir>, and register it.',
97
128
  usage: USAGE,
98
129
  details: [
99
- 'Writes SPECIFICATION.md, INSTRUCTIONS.md, agents.yaml, agents/ and',
100
- 'scripts/, then records the directory so `zen list` and `zen open` can',
101
- 'find it by name. Editor files (.vscode/settings.json and the .github/',
102
- 'tree) are written alongside, replacing any already there.',
130
+ 'Writes SPECIFICATION.md, agents.yaml, agents/ (house rules, the memory',
131
+ 'rules and one role prompt) and scripts/, then records the directory so',
132
+ '`zen list` and `zen open` can find it by name. Editor files',
133
+ '(.vscode/settings.json and the .github/ tree) are written alongside,',
134
+ 'replacing any already there.',
103
135
  '',
104
136
  'A directory that is already a project is adopted rather than rebuilt:',
105
137
  'it is registered under its own name, and nothing in it is touched.',
@@ -111,7 +143,9 @@ export const init = {
111
143
  'there, then run /sync-with-spec in your editor to make the rest match.',
112
144
  '',
113
145
  'The default agent gets the file tools and a sandboxed shell, plus',
114
- '`exa:*` when the keyring holds an Exa key. Without --model, the',
146
+ '`exa:*` when the keyring holds an Exa key. It keeps a memory of its',
147
+ 'own work in memory/, vectorised by the same provider as the model',
148
+ 'when that provider publishes embeddings. Without --model, the',
115
149
  'keyring is checked and the model is picked from a credential the',
116
150
  'provider accepts.',
117
151
  '',
@@ -143,7 +177,8 @@ export const init = {
143
177
  : DEFAULT_MODEL[provider ?? 'openai'];
144
178
  const model = choice.ref;
145
179
  const web = hasExa(store);
146
- const written = scaffold({ dir, model, modelOptions: choice.options, web });
180
+ const embedding = defaultEmbedding(providerOf(model));
181
+ const written = scaffold({ dir, model, modelOptions: choice.options, web, embedding });
147
182
  const registry = await Registry.open();
148
183
  registry.add(name, dir);
149
184
  registry.save();
@@ -29,6 +29,10 @@ export const run = {
29
29
  'The first word is the project when it names one, as in `zen run acme`,',
30
30
  'and the first word of the prompt when it does not. --project settles it.',
31
31
  '',
32
+ "The project's .env is read before it loads, and every name in it is",
33
+ 'forwarded into the sandbox. Your shell wins over the file, and the',
34
+ 'keyring fills in the rest; --no-keys withholds all of it.',
35
+ '',
32
36
  'The prompt comes from the argument, or stdin, or the TUI. There is no',
33
37
  '`resume`: a session continues itself, because its state is what it is.',
34
38
  '',
package/dist/engine.js CHANGED
@@ -3,6 +3,7 @@ import { existsSync } from 'node:fs';
3
3
  import { writeFile } from 'node:fs/promises';
4
4
  import { resolve } from 'node:path';
5
5
  import { auditModels, describeIssue } from "./audit.js";
6
+ import { loadProjectEnv } from "./env.js";
6
7
  import { readJson, writeJson } from "./home.js";
7
8
  import { KeyStore, assertUsable } from "./keys.js";
8
9
  import { projectMounts } from "./projects.js";
@@ -15,6 +16,10 @@ import { CliError, EXIT, invalidError, warn } from "./term.js";
15
16
  * and the whole conversation, its memory and its blobs travel with it.
16
17
  */
17
18
  export async function open(opts) {
19
+ // Before the keyring, so the project's own file wins over the machine's
20
+ // keys, and before the load, so `${VAR}` in agents.yaml can name anything
21
+ // in it. The real environment still beats both.
22
+ const env = loadProjectEnv(opts.project.dir);
18
23
  const keys = await KeyStore.open();
19
24
  keys.materialize();
20
25
  assertUsable(keys);
@@ -47,6 +52,7 @@ export async function open(opts) {
47
52
  readOnly: opts.readOnly,
48
53
  image: opts.image,
49
54
  keys: opts.keys,
55
+ env: env?.names,
50
56
  mounts,
51
57
  });
52
58
  const workspaceOptions = {
package/dist/env.d.ts ADDED
@@ -0,0 +1,20 @@
1
+ /** The file, relative to the project directory. */
2
+ export declare const ENV_FILE = ".env";
3
+ export interface ProjectEnv {
4
+ /** the file it was read from */
5
+ path: string;
6
+ /** every name the file declares, whether or not its value won */
7
+ names: string[];
8
+ }
9
+ /**
10
+ * Reads `<dir>/.env` into `process.env`, without overwriting anything already
11
+ * set, and returns the names it found. A project with no such file is not a
12
+ * problem to report — most have none — so the answer is simply `undefined`.
13
+ *
14
+ * The names are returned in full, including the ones the environment already
15
+ * answered: the variable is in play either way, and the sandbox forwards by
16
+ * name, so what it needs to know is which names matter and not which of the
17
+ * two sources supplied them.
18
+ */
19
+ export declare function loadProjectEnv(dir: string): ProjectEnv | undefined;
20
+ //# sourceMappingURL=env.d.ts.map
package/dist/env.js ADDED
@@ -0,0 +1,65 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { isAbsolute, join, resolve } from 'node:path';
3
+ import { parseEnv } from 'node:util';
4
+ import { FILE_ENVS } from "./keys.js";
5
+ // ---------------------------------------------------------------------------
6
+ // The project's `.env`
7
+ //
8
+ // A project is a directory, and some of what it needs to run is particular to
9
+ // it: the token for the service it talks to, the base url of the environment
10
+ // it talks to it in. The keyring answers "what can this machine pay for"; this
11
+ // answers "what does this project need", and the two are different questions
12
+ // often enough to be worth two places.
13
+ //
14
+ // Precedence is the same one everything else here uses — the real environment
15
+ // always wins. What is already set in the shell is left alone, this file fills
16
+ // the gaps, and the keyring fills whatever is still missing after that. So a
17
+ // variable exported for one command still overrides the file, which is the
18
+ // behaviour anybody typing `FOO=bar zen run` is expecting.
19
+ //
20
+ // The names, not the values, are what leaves this module: they are what the
21
+ // sandbox forwards, and forwarding by name is what keeps a secret out of an
22
+ // argv. See `forwarded()` in sandbox.ts for the rest of that story.
23
+ // ---------------------------------------------------------------------------
24
+ /** The file, relative to the project directory. */
25
+ export const ENV_FILE = '.env';
26
+ /**
27
+ * Reads `<dir>/.env` into `process.env`, without overwriting anything already
28
+ * set, and returns the names it found. A project with no such file is not a
29
+ * problem to report — most have none — so the answer is simply `undefined`.
30
+ *
31
+ * The names are returned in full, including the ones the environment already
32
+ * answered: the variable is in play either way, and the sandbox forwards by
33
+ * name, so what it needs to know is which names matter and not which of the
34
+ * two sources supplied them.
35
+ */
36
+ export function loadProjectEnv(dir) {
37
+ const path = join(dir, ENV_FILE);
38
+ if (!existsSync(path)) {
39
+ return undefined;
40
+ }
41
+ const names = [];
42
+ for (const [name, value] of Object.entries(parseEnv(readFileSync(path, 'utf8')))) {
43
+ if (value === undefined) {
44
+ continue;
45
+ }
46
+ names.push(name);
47
+ // An empty string is not an answer: tools test for absence, and a
48
+ // commented-out line someone half-filled in should not shadow a key
49
+ // the keyring has.
50
+ if (!process.env[name]) {
51
+ process.env[name] = located(dir, name, value);
52
+ }
53
+ }
54
+ return { path, names };
55
+ }
56
+ /**
57
+ * A service-account file is normally kept beside the `.env` that names it, so
58
+ * a relative path there means relative to the *project* — not to wherever the
59
+ * command happened to be run, which is what both the Google SDK and podman's
60
+ * bind mount would otherwise make of it.
61
+ */
62
+ function located(dir, name, value) {
63
+ return FILE_ENVS.includes(name) && !isAbsolute(value) ? resolve(dir, value) : value;
64
+ }
65
+ //# sourceMappingURL=env.js.map
package/dist/keys.d.ts CHANGED
@@ -58,6 +58,8 @@ export declare const SHAPES: Record<KeyOwner, ProviderShape>;
58
58
  export declare function form(provider: KeyOwner): CredentialForm;
59
59
  /** Every variable a provider's credential could arrive in, usual one first. */
60
60
  export declare function envNames(provider: KeyOwner): string[];
61
+ /** Variables that name a credential *file* rather than carrying the secret. */
62
+ export declare const FILE_ENVS: string[];
61
63
  /** The variable this particular credential occupies. */
62
64
  export declare function envOf(entry: Pick<KeyEntry, 'provider' | 'holds' | 'env'>): string;
63
65
  export declare function isProvider(name: string): name is Provider;
package/dist/keys.js CHANGED
@@ -127,6 +127,8 @@ export function form(provider) {
127
127
  export function envNames(provider) {
128
128
  return SHAPES[provider].forms.map((f) => f.env);
129
129
  }
130
+ /** Variables that name a credential *file* rather than carrying the secret. */
131
+ export const FILE_ENVS = OWNERS.flatMap((owner) => SHAPES[owner].forms.filter((f) => f.holds === 'file').map((f) => f.env));
130
132
  /** The variable this particular credential occupies. */
131
133
  export function envOf(entry) {
132
134
  if (entry.env) {
package/dist/sandbox.d.ts CHANGED
@@ -28,6 +28,15 @@ export interface SandboxInputs {
28
28
  image?: string;
29
29
  /** `--no-keys`: refuse to forward credentials, whatever the config says */
30
30
  keys?: boolean;
31
+ /**
32
+ * Names from the project's `.env`, forwarded into the container the same
33
+ * way a credential is — by name, so the value stays out of the argv.
34
+ *
35
+ * They travel with the keys rather than beside them: a `.env` is where an
36
+ * api token lives, so `--no-keys` has to withhold it too or the flag is a
37
+ * promise it does not keep.
38
+ */
39
+ env?: readonly string[];
31
40
  }
32
41
  export declare function buildSandbox(opts: SandboxInputs): SandboxSetup;
33
42
  /**
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({
@@ -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 `/sync-with-spec` 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) };