@zenera/cli 1.1.10 → 1.1.12

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 (65) hide show
  1. package/README.md +69 -15
  2. package/dist/banner.js +69 -47
  3. package/dist/cache.d.ts +98 -0
  4. package/dist/cache.js +301 -0
  5. package/dist/catalog.d.ts +3 -0
  6. package/dist/catalog.js +35 -11
  7. package/dist/commands/cache.d.ts +7 -0
  8. package/dist/commands/cache.js +245 -0
  9. package/dist/commands/check.js +6 -3
  10. package/dist/commands/index.js +5 -0
  11. package/dist/commands/init.js +8 -4
  12. package/dist/commands/key.js +68 -14
  13. package/dist/commands/memory.d.ts +3 -0
  14. package/dist/commands/memory.js +375 -0
  15. package/dist/commands/models.js +17 -1
  16. package/dist/commands/run.js +11 -3
  17. package/dist/commands/sandbox.js +70 -23
  18. package/dist/engine.js +14 -9
  19. package/dist/home.d.ts +2 -2
  20. package/dist/home.js +2 -2
  21. package/dist/keys.d.ts +22 -0
  22. package/dist/keys.js +105 -2
  23. package/dist/lib.d.ts +1 -0
  24. package/dist/lib.js +1 -0
  25. package/dist/liveness.js +11 -0
  26. package/dist/main.js +9 -0
  27. package/dist/podman.js +43 -9
  28. package/dist/projects.js +10 -2
  29. package/dist/resolve.d.ts +4 -0
  30. package/dist/resolve.js +43 -17
  31. package/dist/scaffold.js +22 -1
  32. package/dist/term.d.ts +23 -2
  33. package/dist/term.js +215 -8
  34. package/dist/tui/app.d.ts +10 -0
  35. package/dist/tui/app.js +478 -54
  36. package/dist/tui/theme.d.ts +13 -2
  37. package/dist/tui/theme.js +26 -13
  38. package/dist/tui/wrap.d.ts +92 -0
  39. package/dist/tui/wrap.js +147 -2
  40. package/dist/validate.d.ts +2 -0
  41. package/dist/validate.js +87 -2
  42. package/package.json +2 -2
  43. package/templates/editor/.github/copilot-instructions.md +200 -55
  44. package/templates/editor/.github/prompts/new-agent.prompt.md +5 -2
  45. package/templates/editor/.github/prompts/review-project.prompt.md +5 -0
  46. package/templates/editor/.github/prompts/sync-with-spec.prompt.md +11 -4
  47. package/templates/editor/.github/skills/zen-cli/SKILL.md +3 -0
  48. package/templates/editor/.github/skills/zen-cli/references/faker.md +18 -8
  49. package/templates/editor/.github/skills/zen-cli/references/keys.md +7 -7
  50. package/templates/editor/.github/skills/zen-cli/references/memory.md +113 -0
  51. package/templates/editor/.github/skills/zen-cli/references/rag.md +2 -2
  52. package/templates/editor/.github/skills/zen-cli/references/run.md +3 -1
  53. package/templates/editor/.github/skills/zen-memory/SKILL.md +555 -0
  54. package/templates/editor/.github/skills/zen-rag-docs/SKILL.md +575 -0
  55. package/templates/editor/.github/skills/{api-schema-index → zen-rag-schema}/SKILL.md +4 -4
  56. package/templates/editor/.vscode/settings.json +4 -2
  57. package/templates/parts/exa.prompt.md.tmpl +4 -0
  58. package/templates/parts/exa.spec.md.tmpl +2 -0
  59. package/templates/project/INSTRUCTIONS.md +8 -0
  60. package/templates/project/SPECIFICATION.md.tmpl +47 -0
  61. package/templates/project/agents/prompts/{default.md → default.md.tmpl} +4 -0
  62. package/templates/project/agents.yaml.tmpl +4 -0
  63. package/templates/project/gitignore +7 -1
  64. package/templates/project/scripts/_setup.sh +101 -0
  65. package/templates/project/SPECIFICATION.md +0 -6
@@ -13,8 +13,19 @@ 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
+ * Reasoning. It is not a tool call and it is not the answer, and drawn in
18
+ * the same dim foreground as the rows around it there was nothing to say
19
+ * which — a wall of one weight reads as one thing. Its own hue is what
20
+ * separates the model's thinking from the work it went on to do.
21
+ */
22
+ readonly thinking: LineStyle;
23
+ /**
24
+ * Branch colours, cycled in order of first sight. A fan-out is the one
25
+ * place where colour carries information rather than decoration: eight
26
+ * branches reporting at once are only separable if they are told apart.
27
+ */
28
+ readonly lanes: readonly string[];
18
29
  }
19
30
  export declare const THEMES: Record<Appearance, Theme>;
20
31
  export type ThemeChoice = Appearance | 'auto';
package/dist/tui/theme.js CHANGED
@@ -4,13 +4,19 @@
4
4
  // A terminal already has a colour scheme, and it is not ours to replace. The
5
5
  // rule here is to name as few colours as possible and to name them by role:
6
6
  // the answer is drawn in the terminal's *own* foreground (no colour at all),
7
- // asides are drawn dim, and only the few things that must stand out —
8
- // the person's own turn, the agent name, a warning, an error — take a colour.
7
+ // and only the few things that must stand out — the person's own turn, the
8
+ // agent name, what it is reasoning about, 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.
15
+ //
16
+ // Dim is spent on *chrome* — box rules, clocks, the footer's numbers — and no
17
+ // longer on content. Tool rows are most of what is on screen, and dimming them
18
+ // put the bulk of the transcript one step from unreadable: the whole thing read
19
+ // as too dark. They are content, so they get the foreground like content does.
14
20
  //
15
21
  // Note what is *not* here: no hex, no 256-colour ramps, no attempt at a brand.
16
22
  // A palette that ignores the user's scheme is worse on both schemes than one
@@ -21,28 +27,35 @@ const DARK = {
21
27
  line: {
22
28
  you: { color: 'cyan' },
23
29
  agent: {},
24
- tool: { color: 'gray', dim: true },
25
- note: { color: 'cyan', dim: true },
30
+ // Never dim, and never `gray`: bright black is already the faintest
31
+ // colour a terminal has, and either one puts the bulk of the
32
+ // transcript a step from unreadable.
33
+ tool: {},
34
+ note: { color: 'cyan' },
26
35
  error: { color: 'red' },
27
36
  },
28
37
  accent: 'cyan',
29
38
  warn: 'yellow',
30
- rule: 'gray',
39
+ thinking: { color: 'magenta' },
40
+ lanes: ['cyan', 'green', 'yellow', 'blue', 'red', 'magenta'],
31
41
  };
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.
42
+ // On a light background `cyan` and `yellow` are barely darker than the paper.
43
+ // Dimmed default foreground and `blue`/`magenta` are the same information,
44
+ // still legible.
35
45
  const LIGHT = {
36
46
  appearance: 'light',
37
47
  line: {
38
48
  you: { color: 'blue' },
39
49
  agent: {},
40
- tool: { dim: true },
41
- note: { color: 'blue', dim: true },
50
+ tool: {},
51
+ note: { color: 'blue' },
42
52
  error: { color: 'red' },
43
53
  },
44
54
  accent: 'blue',
45
55
  warn: 'magenta',
56
+ thinking: { color: 'magenta' },
57
+ // No cyan or yellow: on paper they are barely darker than the paper.
58
+ lanes: ['blue', 'green', 'red', 'magenta'],
46
59
  };
47
60
  export const THEMES = { dark: DARK, light: LIGHT };
48
61
  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.10",
3
+ "version": "1.1.12",
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.10",
54
+ "@zenera/neo": "^1.1.12",
55
55
  "@anthropic-ai/sdk": "^0.120.0",
56
56
  "@google/genai": "^2.18.0",
57
57
  "@openrouter/sdk": "^1.2.80",