@zenera/cli 1.1.9 → 1.1.11

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 (45) hide show
  1. package/README.md +49 -15
  2. package/dist/cache.d.ts +98 -0
  3. package/dist/cache.js +301 -0
  4. package/dist/catalog.d.ts +3 -0
  5. package/dist/catalog.js +35 -11
  6. package/dist/commands/cache.d.ts +7 -0
  7. package/dist/commands/cache.js +245 -0
  8. package/dist/commands/check.js +6 -3
  9. package/dist/commands/index.js +3 -1
  10. package/dist/commands/key.js +68 -14
  11. package/dist/commands/models.js +17 -1
  12. package/dist/commands/run.js +11 -3
  13. package/dist/commands/sandbox.js +70 -23
  14. package/dist/history.d.ts +18 -0
  15. package/dist/history.js +93 -0
  16. package/dist/home.d.ts +2 -2
  17. package/dist/home.js +2 -2
  18. package/dist/keys.d.ts +22 -0
  19. package/dist/keys.js +105 -2
  20. package/dist/lib.d.ts +1 -0
  21. package/dist/lib.js +1 -0
  22. package/dist/liveness.js +11 -0
  23. package/dist/resolve.d.ts +4 -0
  24. package/dist/resolve.js +43 -17
  25. package/dist/term.d.ts +25 -2
  26. package/dist/term.js +224 -10
  27. package/dist/tui/app.d.ts +10 -0
  28. package/dist/tui/app.js +542 -58
  29. package/dist/tui/theme.d.ts +6 -2
  30. package/dist/tui/theme.js +14 -8
  31. package/dist/tui/wrap.d.ts +92 -0
  32. package/dist/tui/wrap.js +147 -2
  33. package/dist/validate.d.ts +2 -0
  34. package/dist/validate.js +87 -2
  35. package/package.json +2 -2
  36. package/templates/editor/.github/copilot-instructions.md +50 -13
  37. package/templates/editor/.github/prompts/new-agent.prompt.md +5 -2
  38. package/templates/editor/.github/prompts/sync-with-spec.prompt.md +202 -0
  39. package/templates/editor/.github/skills/zen-cli/SKILL.md +2 -1
  40. package/templates/editor/.github/skills/zen-cli/references/faker.md +18 -8
  41. package/templates/editor/.github/skills/zen-cli/references/keys.md +7 -7
  42. package/templates/editor/.github/skills/zen-cli/references/rag.md +104 -0
  43. package/templates/editor/.github/skills/zen-rag-docs/SKILL.md +575 -0
  44. package/templates/editor/.github/skills/{api-schema-index → zen-rag-schema}/SKILL.md +28 -20
  45. package/templates/editor/.vscode/settings.json +1 -1
@@ -13,8 +13,12 @@ export interface Theme {
13
13
  readonly accent: string;
14
14
  /** Read-only badge, busy label. */
15
15
  readonly warn: string;
16
- /** Gutters and marks: structure, not content. Always drawn dim. */
17
- readonly rule?: string;
16
+ /**
17
+ * Branch colours, cycled in order of first sight. A fan-out is the one
18
+ * place where colour carries information rather than decoration: eight
19
+ * branches reporting at once are only separable if they are told apart.
20
+ */
21
+ readonly lanes: readonly string[];
18
22
  }
19
23
  export declare const THEMES: Record<Appearance, Theme>;
20
24
  export type ThemeChoice = Appearance | 'auto';
package/dist/tui/theme.js CHANGED
@@ -8,9 +8,10 @@
8
8
  // the person's own turn, the agent name, a warning, an error — take a colour.
9
9
  //
10
10
  // That alone fixes most of it. `white` was the bug: it is legible on exactly
11
- // one kind of background, and half the world runs the other kind. What is left
12
- // is the handful of accents that ANSI *does* let a light theme get wrong
13
- // `cyan` on paper, `gray` on paper so those swap.
11
+ // one kind of background, and half the world runs the other kind. `gray` was
12
+ // the same bug wearing a hat bright black, dimmed again, is a step from
13
+ // unreadable on dark and invisible on paper. What is left is the handful of
14
+ // accents that ANSI *does* let a light theme get wrong, so those swap.
14
15
  //
15
16
  // Note what is *not* here: no hex, no 256-colour ramps, no attempt at a brand.
16
17
  // A palette that ignores the user's scheme is worse on both schemes than one
@@ -21,17 +22,20 @@ const DARK = {
21
22
  line: {
22
23
  you: { color: 'cyan' },
23
24
  agent: {},
24
- tool: { color: 'gray', dim: true },
25
+ // Dim alone, never dim *and* `gray`: bright black is already the
26
+ // faintest colour a terminal has, and dimming it again puts the bulk
27
+ // of the transcript a step from unreadable.
28
+ tool: { dim: true },
25
29
  note: { color: 'cyan', dim: true },
26
30
  error: { color: 'red' },
27
31
  },
28
32
  accent: 'cyan',
29
33
  warn: 'yellow',
30
- rule: 'gray',
34
+ lanes: ['magenta', 'cyan', 'green', 'yellow', 'blue', 'red'],
31
35
  };
32
- // On a light background `gray` is bright black pale grey on white — and
33
- // `cyan` and `yellow` are barely darker than the paper. Dimmed default
34
- // foreground and `blue`/`magenta` are the same information, still legible.
36
+ // On a light background `cyan` and `yellow` are barely darker than the paper.
37
+ // Dimmed default foreground and `blue`/`magenta` are the same information,
38
+ // still legible.
35
39
  const LIGHT = {
36
40
  appearance: 'light',
37
41
  line: {
@@ -43,6 +47,8 @@ const LIGHT = {
43
47
  },
44
48
  accent: 'blue',
45
49
  warn: 'magenta',
50
+ // No cyan or yellow: on paper they are barely darker than the paper.
51
+ lanes: ['magenta', 'blue', 'green', 'red'],
46
52
  };
47
53
  export const THEMES = { dark: DARK, light: LIGHT };
48
54
  export function parseChoice(value) {
@@ -7,6 +7,98 @@
7
7
  * and bounds the work per keystroke on a stream that never stops growing.
8
8
  */
9
9
  export declare function windowOf(text: string, width: number, rows: number): string[];
10
+ /**
11
+ * `text` flattened onto one row of at most `width` columns, ellipsized when it
12
+ * did not fit. Newlines and runs of space collapse: a row is a row, and a tool
13
+ * argument or a result preview arrives with whatever shape it happened to have.
14
+ */
15
+ export declare function clip(text: string, width: number): string;
10
16
  /** Word wrap. Every returned row is at most `width` columns wide. */
11
17
  export declare function wrap(text: string, width: number): string[];
18
+ /** The three footer rows, its margin, the prompt, and a row in hand. */
19
+ export declare const CHROME_ROWS = 7;
20
+ /** How much of the reasoning stream is worth showing. It is a progress bar. */
21
+ export declare const THINKING_ROWS = 6;
22
+ /** How much of the frame the work in flight may take. A branch is a box now,
23
+ * not a row, so a fan-out of two costs fourteen of these. */
24
+ export declare const ACTIVITY_ROWS = 16;
25
+ /** The rule that opens a reasoning block and the one that closes it. */
26
+ export declare const THINKING_CHROME = 2;
27
+ export interface Budget {
28
+ /** rows for the list of what is in flight */
29
+ activity: number;
30
+ /** rows for the tail of the reasoning stream */
31
+ thinking: number;
32
+ /** rows for the answer as it arrives */
33
+ live: number;
34
+ /**
35
+ * What the three of them occupy together, rules included — a constant for
36
+ * a given terminal, whatever is happening inside it. The region grows to
37
+ * this and stops, and never shrinks back while the turn runs, which is what
38
+ * keeps the footer on one row instead of riding up and down on the
39
+ * reasoning block.
40
+ */
41
+ total: number;
42
+ }
43
+ /**
44
+ * How many rows each repainting block may draw in a terminal `rows` tall.
45
+ *
46
+ * Activity is capped first and never takes the last two rows: the answer as it
47
+ * arrives matters more than the machinery producing it. Reasoning yields to it
48
+ * in turn, because it is a progress indicator and the answer is the point.
49
+ *
50
+ * `thinking` is how many rows of reasoning are wanted, counting text only: none
51
+ * when there is no reasoning, fewer than `THINKING_ROWS` once the stream has
52
+ * settled and its tail is history rather than progress. The two rules are
53
+ * charged on top, so a block too cramped to be worth boxing is not drawn at all
54
+ * rather than drawn as a border with a line in it.
55
+ */
56
+ export declare function budgetOf(rows: number, activity: number, thinking: number): Budget;
57
+ /** The rows a branch box may spend on its own calls and its reasoning. */
58
+ export declare const BRANCH_ROWS = 5;
59
+ /** Rows a branch box spends on chrome: the title rule and the closing one. */
60
+ export declare const BOX_CHROME = 2;
61
+ /**
62
+ * How many call rows each of `count` branch boxes may draw, given the rows the
63
+ * activity region has to divide between them.
64
+ *
65
+ * Every branch gets the same number, because they are the same kind of thing
66
+ * and a fan-out is read across, not down. A wide fork spends its rows on being
67
+ * complete rather than on being detailed: eight branches showing one call each
68
+ * is a picture of the fork, eight rows of one branch is not.
69
+ */
70
+ export declare function branchRows(count: number, allowance: number): number;
71
+ /**
72
+ * How wide the answer is drawn. A line of prose spanning a 200-column terminal
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
75
+ * page, not the paragraph.
76
+ */
77
+ export declare function answerWidth(columns: number): number;
78
+ /**
79
+ * A tool payload as a person would read it, rather than as it was serialised.
80
+ *
81
+ * A lone field is printed as its bare value, because the name of a generic tool
82
+ * says almost nothing on its own — `run_command` is every shell command there
83
+ * is, and the argument is the part that identifies THIS call.
84
+ *
85
+ * It scans rather than parses: previews are cut to a length, so the JSON very
86
+ * often does not close, and precisely the calls worth reading are the long ones
87
+ * that got cut.
88
+ */
89
+ export declare function readable(preview: string): string;
90
+ /** A run of lines from an answer, and whether it was fenced as code. */
91
+ export interface Segment {
92
+ code: boolean;
93
+ /** the fence's info string, when it had one */
94
+ title?: string;
95
+ lines: string[];
96
+ }
97
+ /**
98
+ * Splits an answer on ``` fences. Prose is left exactly as it was — the common
99
+ * answer has no fence in it and comes back in one piece — but a fenced block is
100
+ * the one thing a terminal must not reflow: its indentation is its meaning, and
101
+ * wrapping it as prose destroys it.
102
+ */
103
+ export declare function segmentsOf(text: string): Segment[];
12
104
  //# sourceMappingURL=wrap.d.ts.map
package/dist/tui/wrap.js CHANGED
@@ -23,8 +23,23 @@
23
23
  export function windowOf(text, width, rows) {
24
24
  const w = Math.max(8, width);
25
25
  const n = Math.max(1, rows);
26
- const wrapped = wrap(text.slice(-w * n * 2).replace(/\n{2,}/g, '\n'), w);
27
- return wrapped.slice(-n);
26
+ // Trailing blank rows are never information, and a stream that ends on a
27
+ // paragraph break would spend one of the few rows it has on nothing.
28
+ const tail = text
29
+ .slice(-w * n * 2)
30
+ .replace(/\n{2,}/g, '\n')
31
+ .trimEnd();
32
+ return wrap(tail, w).slice(-n);
33
+ }
34
+ /**
35
+ * `text` flattened onto one row of at most `width` columns, ellipsized when it
36
+ * did not fit. Newlines and runs of space collapse: a row is a row, and a tool
37
+ * argument or a result preview arrives with whatever shape it happened to have.
38
+ */
39
+ export function clip(text, width) {
40
+ const flat = text.replace(/\s+/g, ' ').trim();
41
+ const w = Math.max(1, width);
42
+ return flat.length <= w ? flat : `${flat.slice(0, w - 1)}…`;
28
43
  }
29
44
  /** Word wrap. Every returned row is at most `width` columns wide. */
30
45
  export function wrap(text, width) {
@@ -59,4 +74,134 @@ export function wrap(text, width) {
59
74
  }
60
75
  return out;
61
76
  }
77
+ // ---------------------------------------------------------------------------
78
+ // Dividing the frame
79
+ //
80
+ // The same invariant, stated as arithmetic: every repainting block gets its
81
+ // rows from here, and the sum is never more than the viewport has to give.
82
+ // ---------------------------------------------------------------------------
83
+ /** The three footer rows, its margin, the prompt, and a row in hand. */
84
+ export const CHROME_ROWS = 7;
85
+ /** How much of the reasoning stream is worth showing. It is a progress bar. */
86
+ export const THINKING_ROWS = 6;
87
+ /** How much of the frame the work in flight may take. A branch is a box now,
88
+ * not a row, so a fan-out of two costs fourteen of these. */
89
+ export const ACTIVITY_ROWS = 16;
90
+ /** The rule that opens a reasoning block and the one that closes it. */
91
+ export const THINKING_CHROME = 2;
92
+ /**
93
+ * How many rows each repainting block may draw in a terminal `rows` tall.
94
+ *
95
+ * Activity is capped first and never takes the last two rows: the answer as it
96
+ * arrives matters more than the machinery producing it. Reasoning yields to it
97
+ * in turn, because it is a progress indicator and the answer is the point.
98
+ *
99
+ * `thinking` is how many rows of reasoning are wanted, counting text only: none
100
+ * when there is no reasoning, fewer than `THINKING_ROWS` once the stream has
101
+ * settled and its tail is history rather than progress. The two rules are
102
+ * charged on top, so a block too cramped to be worth boxing is not drawn at all
103
+ * rather than drawn as a border with a line in it.
104
+ */
105
+ export function budgetOf(rows, activity, thinking) {
106
+ const total = Math.max(2, rows - CHROME_ROWS);
107
+ const shown = Math.min(Math.max(0, activity), ACTIVITY_ROWS, Math.max(0, total - 2));
108
+ const rest = total - shown;
109
+ const room = rest - 1 - THINKING_CHROME;
110
+ const tail = room >= 1 ? Math.min(Math.max(0, thinking), THINKING_ROWS, room) : 0;
111
+ return {
112
+ activity: shown,
113
+ thinking: tail,
114
+ live: rest - tail - (tail ? THINKING_CHROME : 0),
115
+ total,
116
+ };
117
+ }
118
+ /** The rows a branch box may spend on its own calls and its reasoning. */
119
+ export const BRANCH_ROWS = 5;
120
+ /** Rows a branch box spends on chrome: the title rule and the closing one. */
121
+ export const BOX_CHROME = 2;
122
+ /**
123
+ * How many call rows each of `count` branch boxes may draw, given the rows the
124
+ * activity region has to divide between them.
125
+ *
126
+ * Every branch gets the same number, because they are the same kind of thing
127
+ * and a fan-out is read across, not down. A wide fork spends its rows on being
128
+ * complete rather than on being detailed: eight branches showing one call each
129
+ * is a picture of the fork, eight rows of one branch is not.
130
+ */
131
+ export function branchRows(count, allowance) {
132
+ if (count <= 0) {
133
+ return 0;
134
+ }
135
+ const each = Math.floor(Math.max(0, allowance) / count) - BOX_CHROME;
136
+ return Math.max(1, Math.min(BRANCH_ROWS, each));
137
+ }
138
+ /**
139
+ * How wide the answer is drawn. A line of prose spanning a 200-column terminal
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
142
+ * page, not the paragraph.
143
+ */
144
+ export function answerWidth(columns) {
145
+ return Math.max(24, Math.min(columns - 4, 96));
146
+ }
147
+ // ---------------------------------------------------------------------------
148
+ // Reading a payload
149
+ // ---------------------------------------------------------------------------
150
+ /** One field of a preview: a JSON string, or a bare token when it was cut. */
151
+ const FIELD = /"([A-Za-z_][\w-]*)"\s*:\s*("(?:[^"\\]|\\.)*"?|[^,}\s]+)/g;
152
+ /**
153
+ * A tool payload as a person would read it, rather than as it was serialised.
154
+ *
155
+ * A lone field is printed as its bare value, because the name of a generic tool
156
+ * says almost nothing on its own — `run_command` is every shell command there
157
+ * is, and the argument is the part that identifies THIS call.
158
+ *
159
+ * It scans rather than parses: previews are cut to a length, so the JSON very
160
+ * often does not close, and precisely the calls worth reading are the long ones
161
+ * that got cut.
162
+ */
163
+ export function readable(preview) {
164
+ const found = [...preview.matchAll(FIELD)];
165
+ if (!found.length) {
166
+ return preview.trim();
167
+ }
168
+ const fields = found.map((m) => [m[1], unquote(m[2])]);
169
+ const one = fields.length === 1 ? fields[0] : undefined;
170
+ return one ? one[1] : fields.map(([k, v]) => `${k}=${v}`).join(' ');
171
+ }
172
+ function unquote(raw) {
173
+ if (!raw.startsWith('"')) {
174
+ return raw;
175
+ }
176
+ const body = raw.length > 1 && raw.endsWith('"') ? raw.slice(1, -1) : raw.slice(1);
177
+ return body.replace(/\\[nrt]/g, ' ').replace(/\\(["\\/])/g, '$1');
178
+ }
179
+ /**
180
+ * Splits an answer on ``` fences. Prose is left exactly as it was — the common
181
+ * answer has no fence in it and comes back in one piece — but a fenced block is
182
+ * the one thing a terminal must not reflow: its indentation is its meaning, and
183
+ * wrapping it as prose destroys it.
184
+ */
185
+ export function segmentsOf(text) {
186
+ const out = [];
187
+ let current = { code: false, lines: [] };
188
+ const flush = () => {
189
+ if (current.lines.length) {
190
+ out.push(current);
191
+ }
192
+ };
193
+ for (const raw of text.split('\n')) {
194
+ const fence = /^\s*```+\s*(\S*)/.exec(raw);
195
+ if (!fence) {
196
+ current.lines.push(raw);
197
+ continue;
198
+ }
199
+ flush();
200
+ current = current.code
201
+ ? { code: false, lines: [] }
202
+ : { code: true, title: fence[1] || undefined, lines: [] };
203
+ }
204
+ flush();
205
+ return out;
206
+ }
62
207
  //# sourceMappingURL=wrap.js.map
@@ -73,6 +73,8 @@ export interface SkillReport {
73
73
  export interface ModelReport {
74
74
  /** the alias it is declared under, or the reference itself */
75
75
  name: string;
76
+ /** what `name` resolves to, when it is an alias rather than a reference */
77
+ ref?: string;
76
78
  /** what the config declared it for */
77
79
  role: DeclaredRole;
78
80
  /** the provider it resolves to */
package/dist/validate.js CHANGED
@@ -181,6 +181,7 @@ export async function validateProject(opts) {
181
181
  for (const spec of config.agents) {
182
182
  agents.push(checkAgent(root, config, spec, entry, available, record, add));
183
183
  }
184
+ checkReturnPaths(config, add);
184
185
  // -----------------------------------------------------------------------
185
186
  // Skills
186
187
  // -----------------------------------------------------------------------
@@ -244,7 +245,7 @@ async function askModels(targets, opts, add) {
244
245
  if (probe.check.state === 'live') {
245
246
  return;
246
247
  }
247
- const where = `${report.role} "${report.name}"${report.provider ? ` (${report.provider})` : ''}`;
248
+ const where = named(report);
248
249
  if (probe.check.state === 'blocked') {
249
250
  const pick = report.role === 'embedding' ? '--embedding' : '--chat';
250
251
  add({
@@ -576,6 +577,75 @@ function checkAgent(root, config, spec, entry, available, record, add) {
576
577
  ownSandbox: Boolean(spec.sandbox),
577
578
  };
578
579
  }
580
+ /**
581
+ * Every hand-off must be returnable.
582
+ *
583
+ * A hand-off is a one-way door: control moves to the other agent and stays
584
+ * there. Nothing hands it back on its own, so an agent reached by an edge that
585
+ * has no way home owns the conversation for the rest of the session — the next
586
+ * question, whatever it is about, is answered by the specialist the router sent
587
+ * the user to. That is not a runtime error; it is a system that quietly stops
588
+ * being the system that was drawn.
589
+ *
590
+ * So for every `A → B` there must be a path from B back to A. Indirect counts:
591
+ * `B → C → A` is a way home, which is the same as saying every hand-off edge
592
+ * lies on a cycle.
593
+ *
594
+ * Forks are exempt, and always will be. A branch runs, answers, and control
595
+ * returns to the agent that forked it — the return is the mechanism, not an
596
+ * edge someone has to remember to declare. An agent that wants an answer rather
597
+ * than to give up the conversation should fork, not hand off.
598
+ *
599
+ * A warning rather than an error: the project loads, runs, and answers. What it
600
+ * cannot do is come back. `--strict` is for the repository that wants that to
601
+ * fail the build.
602
+ */
603
+ function checkReturnPaths(config, add) {
604
+ const known = new Set(config.agents.map((a) => a.name));
605
+ // Unknown names and self-hand-offs are already errors of their own; walking
606
+ // them here would only report the same mistake in a second vocabulary.
607
+ const edges = new Map(config.agents.map((a) => [
608
+ a.name,
609
+ (a.handoffs ?? []).filter((to) => to !== a.name && known.has(to)),
610
+ ]));
611
+ const reaches = (from, to) => {
612
+ const seen = new Set([from]);
613
+ const queue = [from];
614
+ while (queue.length) {
615
+ for (const next of edges.get(queue.shift()) ?? []) {
616
+ if (next === to) {
617
+ return true;
618
+ }
619
+ if (!seen.has(next)) {
620
+ seen.add(next);
621
+ queue.push(next);
622
+ }
623
+ }
624
+ }
625
+ return false;
626
+ };
627
+ for (const [from, targets] of edges) {
628
+ for (const to of targets) {
629
+ if (reaches(to, from)) {
630
+ continue;
631
+ }
632
+ add({
633
+ severity: 'warning',
634
+ code: 'handoff.one-way',
635
+ where: `agents.${from}.handoffs`,
636
+ message: `"${to}" has no way back to "${from}": nothing it can hand off to, ` +
637
+ `directly or through another agent, reaches "${from}" again. A hand-off ` +
638
+ `does not return by itself, so once control moves to "${to}" it stays ` +
639
+ `there — every later question in the session is answered by "${to}", ` +
640
+ 'whether or not it is that agent’s job',
641
+ fix: `add "${from}" to agents.${to}.handoffs (or to the handoffs of an agent ` +
642
+ `"${to}" can reach), or — if "${from}" wants an answer rather than to give ` +
643
+ `up the conversation — drop the edge and declare fork: { agents: [${to}] } ` +
644
+ `on "${from}", because a branch returns on its own`,
645
+ });
646
+ }
647
+ }
648
+ }
579
649
  async function checkSkills(root, config, available, record, add) {
580
650
  const declared = config.skills
581
651
  ? Array.isArray(config.skills)
@@ -1183,6 +1253,15 @@ function checkModels(root, config, keys, add, probe = false) {
1183
1253
  if (keys) {
1184
1254
  report.credential = held.present ? 'present' : 'missing';
1185
1255
  }
1256
+ // An alias is the one name the provider has never heard of, so the
1257
+ // report says what it stands for — otherwise `main was refused`
1258
+ // names nothing anyone can look up or retype.
1259
+ const spec = typeof ref === 'string' ? registry.parse(ref) : ref;
1260
+ const api = 'api' in spec && spec.api ? `/${spec.api}` : '';
1261
+ const resolved = `${need.provider}${api}:${spec.model}`;
1262
+ if (resolved !== name) {
1263
+ report.ref = resolved;
1264
+ }
1186
1265
  }
1187
1266
  catch (err) {
1188
1267
  add({
@@ -1208,7 +1287,7 @@ function checkModels(root, config, keys, add, probe = false) {
1208
1287
  add({
1209
1288
  severity: 'warning',
1210
1289
  code: `credential.${issue.reason}`,
1211
- where: `${role} "${name}" (${issue.provider})`,
1290
+ where: named(report, issue.provider),
1212
1291
  message: issue.reason === 'missing'
1213
1292
  ? `nothing to authenticate with — ${issue.env} is not set, and the ` +
1214
1293
  'keyring holds no key for this provider'
@@ -1257,6 +1336,12 @@ function checkModels(root, config, keys, add, probe = false) {
1257
1336
  }
1258
1337
  return { providers: registry.names(), models, targets };
1259
1338
  }
1339
+ /** How a finding names a model: as written, and as resolved when the two differ. */
1340
+ function named(report, provider = report.provider) {
1341
+ return (`${report.role} "${report.name}"` +
1342
+ (report.ref ? ` = ${report.ref}` : '') +
1343
+ (provider ? ` (${provider})` : ''));
1344
+ }
1260
1345
  /** The config key a bad reference was written under. */
1261
1346
  function whereFor(config, role, name, usedBy) {
1262
1347
  if (role === 'embedding') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenera/cli",
3
- "version": "1.1.9",
3
+ "version": "1.1.11",
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,7 +51,7 @@
51
51
  "@inkjs/ui": "^2.0.0",
52
52
  "ink": "^7.1.1",
53
53
  "react": "^19.2.8",
54
- "@zenera/neo": "^1.1.9",
54
+ "@zenera/neo": "^1.1.11",
55
55
  "@anthropic-ai/sdk": "^0.120.0",
56
56
  "@google/genai": "^2.18.0",
57
57
  "@openrouter/sdk": "^1.2.80",
@@ -307,6 +307,9 @@ wrong provider (§7.3), and any combination of knobs the vendor rejects at reque
307
307
  time, such as OpenAI reasoning on chat completions (§7.6). Both surface on the
308
308
  first call, so read §7 before writing a `models:` entry.
309
309
 
310
+ **Caught by `zen check`, not by the loader** — a handoff with no path back
311
+ (`handoff.one-way`, §6.3). The project runs; it just cannot return.
312
+
310
313
  **Comments in `agents.yaml`** — this file is the architecture diagram of the
311
314
  project, and its comments are read by whoever has to change it next. Write them
312
315
  at that level:
@@ -1086,18 +1089,20 @@ Do **not** split because:
1086
1089
 
1087
1090
  **Router + specialists.** A cheap, fast agent whose only job is classification
1088
1091
  and handoff. Its prompt is short, it holds few tools, and it must be forbidden
1089
- from answering. Specialists never hand back to it.
1092
+ from answering. Every specialist hands back to it — a handoff does not return on
1093
+ its own, so without that edge the first routed question is the last one routed.
1090
1094
 
1091
1095
  ```yaml
1092
1096
  agents:
1093
1097
  - { name: intake, model: router, handoffs: [billing, technical, escalation] }
1094
- - { name: billing, model: balanced, skills: { allow: [refund_policy, invoicing] } }
1095
- - { name: technical, model: balanced, tools: [search_logs, restart_service] }
1096
- - { name: escalation, model: careful }
1098
+ - { name: billing, model: balanced, handoffs: [intake], skills: { allow: [refund_policy] } }
1099
+ - { name: technical, model: balanced, handoffs: [intake], tools: [search_logs] }
1100
+ - { name: escalation, model: careful, handoffs: [intake] }
1097
1101
  ```
1098
1102
 
1099
1103
  **Pipeline.** Fixed stages, each handing to the next; only the last answers the
1100
- user. Encode the order in `handoffs:` so a stage cannot skip ahead.
1104
+ user. Encode the order in `handoffs:` so a stage cannot skip ahead — and close
1105
+ the loop, by handing back from the last stage to the first.
1101
1106
 
1102
1107
  **Fan-out / join.** For independent parallel work — ten regions, four review
1103
1108
  lenses, six candidate suppliers — declare `fork:` on the agent that owns the
@@ -1113,12 +1118,39 @@ multi-agent instinct suggests.
1113
1118
  Write it as a routing condition: _"Applies the written peril policies to a
1114
1119
  claim and explains the outcome."_ — not _"The adjuster agent."_
1115
1120
  - Handoffs are bare name strings; there is no per-edge configuration.
1116
- - Self-handoff is a load error. Cycles are legal but usually a bug — a router in
1117
- the `handoffs` of its own specialists produces ping-pong.
1121
+ - Self-handoff is a load error.
1122
+ - **Every handoff must be returnable.** For every `A → B` there must be a path
1123
+ from `B` back to `A`. Indirect counts — `B → C → A` is a way home — so the
1124
+ rule is that every handoff edge lies on a cycle. `zen check` warns
1125
+ (`handoff.one-way`) on any edge that does not.
1118
1126
  - Handoff collapses history by policy: the receiving agent sees a selection, not
1119
1127
  the full transcript. Do not assume it saw a detail three turns back; if it
1120
1128
  matters, put it in the handoff.
1121
1129
 
1130
+ **Why the return edge is not optional.** A handoff is a one-way door: control
1131
+ moves and stays moved. Nothing hands it back, so an agent reached by a dead-end
1132
+ edge owns the conversation for the rest of the session — the next question,
1133
+ whatever it is about, is answered by the specialist the router sent the user to.
1134
+ The drawn architecture holds for one turn and then quietly stops being the
1135
+ architecture.
1136
+
1137
+ **Forks are the exception, and the alternative.** A branch runs, answers, and
1138
+ control returns to the agent that forked it; the return is the mechanism, not an
1139
+ edge anyone has to declare. So there are two shapes and they are not
1140
+ interchangeable:
1141
+
1142
+ - The other agent should **own the conversation from here** → `handoffs:`, and
1143
+ something on the far side has to lead back.
1144
+ - This agent needs **an answer and then carries on** → `fork:`. Never a handoff:
1145
+ a handoff spends the conversation to get the answer.
1146
+
1147
+ But the return is **condensed**: `A → fork → B` rejoins as one tool result
1148
+ holding B's answer, so A never sees the steps B took — no tool calls, no files
1149
+ read, no intermediate reasoning. That is what makes fanning out cheap, and it is
1150
+ what to design around: whatever A will need must be _in_ the answer, so say so in
1151
+ B's prompt. Work whose value is the trace rather than the conclusion does not
1152
+ survive a fork.
1153
+
1122
1154
  ### 6.4 Forking (fan-out / join)
1123
1155
 
1124
1156
  Forking is **opt-in per agent**. Without the key the agent is never offered the
@@ -1158,7 +1190,9 @@ Rules worth knowing before you write the key:
1158
1190
  - Nesting is capped by the run's `maxForkDepth` (2 by default), so a branch may
1159
1191
  fork again but not without bound.
1160
1192
  - Fork vs handoff: a handoff is _one_ conversation changing owner; a fork is the
1161
- _same_ question asked N times at once and merged.
1193
+ _same_ question asked N times at once and merged. **A fork returns; a handoff
1194
+ does not** — which is why a handoff needs a return path (§6.3) and a fork
1195
+ needs nothing.
1162
1196
 
1163
1197
  Choose `context:` deliberately — the model sets it per call, so say in the
1164
1198
  agent's prompt which one this work wants:
@@ -1237,8 +1271,8 @@ model: balanced # fallback for agents that do not pin their own
1237
1271
 
1238
1272
  agents:
1239
1273
  - { name: intake, model: router, handoffs: [adjuster] }
1240
- - { name: adjuster, model: balanced, handoffs: [escalation] }
1241
- - { name: escalation, model: careful }
1274
+ - { name: adjuster, model: balanced, handoffs: [escalation, intake] }
1275
+ - { name: escalation, model: careful, handoffs: [intake] }
1242
1276
  ```
1243
1277
 
1244
1278
  ### 7.3 Shorthand
@@ -1582,7 +1616,7 @@ Before finishing any change here:
1582
1616
  - [ ] `agents.yaml` still loads; no unknown keys, no dangling names
1583
1617
  - [ ] Top-level `default:` names the entry agent explicitly
1584
1618
  - [ ] Every agent has a `description:` written as a routing condition
1585
- - [ ] No self-handoff; no accidental cycle back to the router
1619
+ - [ ] No self-handoff; every handoff has a path back (`zen check` warns if not)
1586
1620
  - [ ] Names match `^[a-z0-9]+(?:[-_][a-z0-9]+)*$`
1587
1621
  - [ ] An agent expected to fan out has `fork:`, and its prompt says when to use it
1588
1622
  - [ ] Comments explain the design, not the runtime or the key they sit above
@@ -1660,6 +1694,7 @@ Before finishing any change here:
1660
1694
  | A model or embedder refuses every call | `zen models test <ref>` — if `blocked`, `zen models pick` — §8 |
1661
1695
  | Answers instead of routing | Router prompt prohibition; check `handoffs:` |
1662
1696
  | Routes to the wrong specialist | The target agents' `description:` fields |
1697
+ | Gets stuck in the agent it routed to | The target needs a handoff back — §6.3 |
1663
1698
  | Loses a detail after a handoff | Say it in the handoff; check the collapse policy |
1664
1699
  | Works through N independent items serially | `fork:` on that agent, and a prompt line — §6.4 |
1665
1700
  | Forks when the steps actually depend | Prompt line: branches cannot see each other |
@@ -1689,8 +1724,10 @@ Before finishing any change here:
1689
1724
  - **Fixing prompts with models.** See §4.4.
1690
1725
  - **The chatty router.** A router that answers before handing off, because its
1691
1726
  prompt never forbade it.
1692
- - **Ping-pong handoffs.** Specialists that hand back to the router, which hands
1693
- back to a specialist.
1727
+ - **The one-way handoff.** A specialist with no way back, so the router routes
1728
+ once and then never sees the conversation again — §6.3.
1729
+ - **Handing off for an answer.** Wanting one lookup and spending the whole
1730
+ conversation on it. That is a `fork:`, which returns by itself — §6.4.
1694
1731
  - **Forking a chain.** Branches never see each other, so a fork whose second
1695
1732
  branch needs the first branch's answer is a sequence wearing a fork's clothes.
1696
1733
  - **Installing the toolchain every run.** A prompt that begins with `apt-get
@@ -29,8 +29,11 @@ If it is warranted:
29
29
  3. Add the `agents.yaml` entry. `description:` is written **for the model** — it
30
30
  is the whole of what a sibling sees when deciding to hand off. Grant the
31
31
  narrowest `tools:` the job needs.
32
- 4. Wire the handoffs in both directions only if both directions are real.
33
- Specialists handing back to a router is ping-pong; prefer terminating.
32
+ 4. Wire the return edge. A handoff does not come back on its own, so every
33
+ `A B` needs a path from B back to A directly, or through another agent.
34
+ `zen check` warns on an edge that has none. If what this agent really wants
35
+ is an _answer_ rather than to give up the conversation, declare `fork:`
36
+ instead: a branch returns by itself.
34
37
  5. Add a one-line comment above the entry saying why this agent exists — its
35
38
  job or its tier, not what the keys mean.
36
39