@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.
- package/README.md +49 -15
- package/dist/cache.d.ts +98 -0
- package/dist/cache.js +301 -0
- package/dist/catalog.d.ts +3 -0
- package/dist/catalog.js +35 -11
- package/dist/commands/cache.d.ts +7 -0
- package/dist/commands/cache.js +245 -0
- package/dist/commands/check.js +6 -3
- package/dist/commands/index.js +3 -1
- package/dist/commands/key.js +68 -14
- package/dist/commands/models.js +17 -1
- package/dist/commands/run.js +11 -3
- package/dist/commands/sandbox.js +70 -23
- package/dist/history.d.ts +18 -0
- package/dist/history.js +93 -0
- package/dist/home.d.ts +2 -2
- package/dist/home.js +2 -2
- package/dist/keys.d.ts +22 -0
- package/dist/keys.js +105 -2
- package/dist/lib.d.ts +1 -0
- package/dist/lib.js +1 -0
- package/dist/liveness.js +11 -0
- package/dist/resolve.d.ts +4 -0
- package/dist/resolve.js +43 -17
- package/dist/term.d.ts +25 -2
- package/dist/term.js +224 -10
- package/dist/tui/app.d.ts +10 -0
- package/dist/tui/app.js +542 -58
- package/dist/tui/theme.d.ts +6 -2
- package/dist/tui/theme.js +14 -8
- package/dist/tui/wrap.d.ts +92 -0
- package/dist/tui/wrap.js +147 -2
- package/dist/validate.d.ts +2 -0
- package/dist/validate.js +87 -2
- package/package.json +2 -2
- package/templates/editor/.github/copilot-instructions.md +50 -13
- package/templates/editor/.github/prompts/new-agent.prompt.md +5 -2
- package/templates/editor/.github/prompts/sync-with-spec.prompt.md +202 -0
- package/templates/editor/.github/skills/zen-cli/SKILL.md +2 -1
- package/templates/editor/.github/skills/zen-cli/references/faker.md +18 -8
- package/templates/editor/.github/skills/zen-cli/references/keys.md +7 -7
- package/templates/editor/.github/skills/zen-cli/references/rag.md +104 -0
- package/templates/editor/.github/skills/zen-rag-docs/SKILL.md +575 -0
- package/templates/editor/.github/skills/{api-schema-index → zen-rag-schema}/SKILL.md +28 -20
- package/templates/editor/.vscode/settings.json +1 -1
package/dist/tui/theme.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
17
|
-
|
|
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.
|
|
12
|
-
//
|
|
13
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
34
|
+
lanes: ['magenta', 'cyan', 'green', 'yellow', 'blue', 'red'],
|
|
31
35
|
};
|
|
32
|
-
// On a light background `
|
|
33
|
-
//
|
|
34
|
-
//
|
|
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) {
|
package/dist/tui/wrap.d.ts
CHANGED
|
@@ -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
|
-
|
|
27
|
-
|
|
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
|
package/dist/validate.d.ts
CHANGED
|
@@ -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 =
|
|
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:
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
1095
|
-
- { name: technical, model: balanced, tools: [search_logs
|
|
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.
|
|
1117
|
-
|
|
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;
|
|
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
|
-
- **
|
|
1693
|
-
|
|
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
|
|
33
|
-
|
|
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
|
|