@alisio/core 0.1.1 → 0.2.1

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.
@@ -0,0 +1,326 @@
1
+ /**
2
+ * Plan diagrams: the lightweight, render-free validation of the Mermaid diagrams the plan agent
3
+ * passes to `exit_plan`. Mermaid cannot render without a DOM, so the server only checks what it
4
+ * can see in the text: a size limit, an allowlist of diagram types, a node estimate and the
5
+ * constructs that would run code, open links, fetch resources or relax Mermaid's security
6
+ * settings. A diagram that fails is DROPPED with a reason (the plan is still published); a
7
+ * diagram that only fails to draw is shown as source by the web renderer.
8
+ *
9
+ * Pure functions: no I/O, no Mermaid import.
10
+ */
11
+ import { createHash } from "node:crypto";
12
+ /** Largest Mermaid source of one diagram (UTF-8 bytes). */
13
+ export const DIAGRAM_MAX_BYTES = 8 * 1024;
14
+ /** Most nodes (participants, entities, states, tasks...) of one diagram, estimated from the text. */
15
+ export const DIAGRAM_MAX_NODES = 40;
16
+ /** Hard ceiling of `plan.maxDiagrams`. */
17
+ export const DIAGRAMS_MAX_ALLOWED = 8;
18
+ export const DIAGRAM_ID_MAX = 40;
19
+ export const DIAGRAM_TITLE_MAX = 80;
20
+ export const DIAGRAM_EXPLANATION_MAX = 400;
21
+ export const DIAGRAM_SECTION_MAX = 120;
22
+ /** Mermaid diagram keywords a plan may use (the first statement of the source). */
23
+ export const DIAGRAM_SYNTAXES = [
24
+ "flowchart",
25
+ "graph",
26
+ "sequenceDiagram",
27
+ "stateDiagram-v2",
28
+ "stateDiagram",
29
+ "erDiagram",
30
+ "classDiagram",
31
+ "gantt",
32
+ "mindmap",
33
+ "timeline",
34
+ "journey",
35
+ ];
36
+ export const DIAGRAM_TYPES = [
37
+ "overview",
38
+ "flow",
39
+ "components",
40
+ "architecture",
41
+ "sequence",
42
+ "data",
43
+ "state",
44
+ "other",
45
+ ];
46
+ /**
47
+ * The semantic classes of the style guide. The web applies the same names as a `classDef` block
48
+ * at render time when a diagram uses a class without defining it (colors live in the web).
49
+ */
50
+ export const DIAGRAM_CLASSES = [
51
+ "input",
52
+ "process",
53
+ "data",
54
+ "system",
55
+ "external",
56
+ "decision",
57
+ "risk",
58
+ ];
59
+ const ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
60
+ /** Security settings a directive or the front matter may not touch. */
61
+ const FORBIDDEN_CONFIG = /(securityLevel|htmlLabels|\bsecure\b|dompurify|themeCSS|maxTextSize|maxEdges|startOnLoad|callback|logLevel|deterministicIds)/i;
62
+ /** Normalizes line endings and trims: what is stored and hashed. */
63
+ export const normalizeMermaid = (source) => source
64
+ .replace(/\r\n?/g, "\n")
65
+ .replace(/\u0000/g, "")
66
+ .trim();
67
+ const byteLength = (text) => Buffer.byteLength(text, "utf8");
68
+ /** Removes a leading YAML front matter block (`---` ... `---`). */
69
+ function splitFrontMatter(source) {
70
+ const match = /^---[ \t]*\n([\s\S]*?)\n---[ \t]*(?:\n|$)/.exec(source);
71
+ return match
72
+ ? { front: match[1] ?? "", body: source.slice(match[0].length) }
73
+ : { front: "", body: source };
74
+ }
75
+ /** Directives `%%{ ... }%%` (possibly several lines) and plain `%%` comments. */
76
+ function directives(body) {
77
+ return [...body.matchAll(/%%\{[\s\S]*?\}%%/g)].map((m) => m[0]);
78
+ }
79
+ /** The body without directives and comments (what the statement scan reads). */
80
+ function statements(body) {
81
+ return body
82
+ .replace(/%%\{[\s\S]*?\}%%/g, "")
83
+ .split("\n")
84
+ .map((line) => line.replace(/%%.*$/, ""))
85
+ .filter((line) => line.trim());
86
+ }
87
+ /** The diagram keyword of a source (its first statement), or undefined when it has none. */
88
+ export function diagramSyntax(source) {
89
+ const { body } = splitFrontMatter(normalizeMermaid(source));
90
+ const first = statements(body)[0]?.trim();
91
+ if (!first)
92
+ return undefined;
93
+ const word = /^([A-Za-z][\w-]*)/.exec(first)?.[1];
94
+ return word && DIAGRAM_SYNTAXES.includes(word) ? word : (word ?? undefined);
95
+ }
96
+ /** The purpose a syntax implies when the model gave none. */
97
+ export function defaultDiagramType(syntax) {
98
+ switch (syntax) {
99
+ case "sequenceDiagram":
100
+ return "sequence";
101
+ case "stateDiagram":
102
+ case "stateDiagram-v2":
103
+ return "state";
104
+ case "erDiagram":
105
+ case "classDiagram":
106
+ return "data";
107
+ default:
108
+ return "flow";
109
+ }
110
+ }
111
+ const uniqueCount = (items) => new Set(items).size;
112
+ /** Words of a line without bracketed labels, quoted text and edge labels. */
113
+ function bareLine(line) {
114
+ return line
115
+ .replace(/"[^"]*"/g, " ")
116
+ .replace(/\[\[[^\]]*\]\]|\[[^\]]*\]|\(\([^)]*\)\)|\([^)]*\)|\{\{[^}]*\}\}|\{[^}]*\}/g, " ")
117
+ .replace(/\|[^|]*\|/g, " ")
118
+ .replace(/:::[\w-]+/g, " ");
119
+ }
120
+ /**
121
+ * An estimate of the diagram's nodes from its text (participants, entities, states, tasks, topics
122
+ * or flowchart node ids). It errs on the high side: it is a guard against walls of boxes, not a
123
+ * parser.
124
+ */
125
+ export function estimateNodes(source, syntax) {
126
+ const { body } = splitFrontMatter(normalizeMermaid(source));
127
+ const lines = statements(body).map((line) => line.trim());
128
+ const content = lines.slice(1);
129
+ switch (syntax) {
130
+ case "flowchart":
131
+ case "graph": {
132
+ const ids = new Set();
133
+ for (const line of content) {
134
+ if (/^(subgraph|end|classDef|class|style|linkStyle|direction|click|accTitle|accDescr)\b/.test(line))
135
+ continue;
136
+ for (const part of bareLine(line).split(/<?[-=.~]+[ox>]?|&|;|\s+/)) {
137
+ const id = /^[A-Za-z_][\w]*$/.exec(part.trim())?.[0];
138
+ if (id)
139
+ ids.add(id);
140
+ }
141
+ }
142
+ return ids.size;
143
+ }
144
+ case "sequenceDiagram": {
145
+ const names = new Set();
146
+ for (const line of content) {
147
+ const declared = /^(?:participant|actor)\s+(\S+)/.exec(line)?.[1];
148
+ if (declared) {
149
+ names.add(declared);
150
+ continue;
151
+ }
152
+ const message = /^([^\s:]+?)\s*(?:-->>|->>|--x|-x|--\)|-\)|-->|->)\s*[+-]?([^\s:]+)/.exec(line);
153
+ if (message?.[1] && message[2]) {
154
+ names.add(message[1]);
155
+ names.add(message[2]);
156
+ }
157
+ }
158
+ return names.size;
159
+ }
160
+ case "stateDiagram":
161
+ case "stateDiagram-v2": {
162
+ const states = new Set();
163
+ for (const line of content) {
164
+ const transition = /^(\S+)\s*-->\s*(\S+)/.exec(line);
165
+ if (transition?.[1] && transition[2])
166
+ for (const state of [transition[1], transition[2]])
167
+ if (state !== "[*]")
168
+ states.add(state);
169
+ const declared = /^state\s+(?:"[^"]*"\s+as\s+)?(\w+)/.exec(line)?.[1];
170
+ if (declared)
171
+ states.add(declared);
172
+ }
173
+ return states.size;
174
+ }
175
+ case "erDiagram": {
176
+ const entities = new Set();
177
+ for (const line of content) {
178
+ const relation = /^(\S+)\s+[|}o{.-]+\s*[|}o{.-]*\s+(\S+)\s*:/.exec(line);
179
+ if (relation?.[1] && relation[2]) {
180
+ entities.add(relation[1]);
181
+ entities.add(relation[2]);
182
+ }
183
+ const block = /^(\w+)\s*\{/.exec(line)?.[1];
184
+ if (block)
185
+ entities.add(block);
186
+ }
187
+ return entities.size;
188
+ }
189
+ case "classDiagram": {
190
+ const classes = new Set();
191
+ for (const line of content) {
192
+ const declared = /^class\s+(\w+)/.exec(line)?.[1];
193
+ if (declared)
194
+ classes.add(declared);
195
+ const relation = /^(\w+)\s+(?:"[^"]*"\s+)?[<|*o.\-\\/>]+\s+(?:"[^"]*"\s+)?(\w+)/.exec(line);
196
+ if (relation?.[1] && relation[2]) {
197
+ classes.add(relation[1]);
198
+ classes.add(relation[2]);
199
+ }
200
+ }
201
+ return classes.size;
202
+ }
203
+ case "gantt":
204
+ return content.filter((line) => line.includes(":") &&
205
+ !/^(title|dateFormat|axisFormat|section|excludes|includes|todayMarker|tickInterval|weekday|accTitle|accDescr|inclusiveEndDates|topAxis)\b/.test(line)).length;
206
+ case "journey":
207
+ return content.filter((line) => !/^(title|section|accTitle|accDescr)\b/.test(line)).length;
208
+ default:
209
+ // mindmap, timeline and anything else: one node per line.
210
+ return uniqueCount(content.filter((line) => !/^(title|section|accTitle|accDescr)\b/.test(line)));
211
+ }
212
+ }
213
+ /** The first problem that makes a Mermaid source unsafe or unsupported, or undefined when fine. */
214
+ export function mermaidProblem(source, limits = {}) {
215
+ const maxBytes = limits.maxBytes ?? DIAGRAM_MAX_BYTES;
216
+ const maxNodes = limits.maxNodes ?? DIAGRAM_MAX_NODES;
217
+ const text = normalizeMermaid(source);
218
+ if (!text)
219
+ return "the diagram is empty";
220
+ if (byteLength(text) > maxBytes)
221
+ return `the diagram is larger than ${maxBytes} bytes`;
222
+ if (/[\u0001-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(text))
223
+ return "the diagram has control characters";
224
+ const { front, body } = splitFrontMatter(text);
225
+ const syntax = diagramSyntax(text);
226
+ if (!syntax)
227
+ return "the diagram does not start with a Mermaid diagram keyword";
228
+ if (!DIAGRAM_SYNTAXES.includes(syntax))
229
+ return `the diagram type "${syntax}" is not allowed (use ${DIAGRAM_SYNTAXES.join(", ")})`;
230
+ if (FORBIDDEN_CONFIG.test(front))
231
+ return "the front matter changes Mermaid security settings, which plans may not do";
232
+ for (const directive of directives(body))
233
+ if (FORBIDDEN_CONFIG.test(directive))
234
+ return "an init directive changes Mermaid security settings, which plans may not do";
235
+ // The scan below ignores comments and directives, which were checked above.
236
+ const code = statements(body).join("\n");
237
+ if (/(?:javascript|vbscript)\s*:|data\s*:\s*(?:text|image|application)\//i.test(code))
238
+ return "the diagram contains a javascript:, vbscript: or data: URL";
239
+ if (/\bhref\b/i.test(code))
240
+ return "the diagram contains a link (href)";
241
+ if (/url\s*\(|@import/i.test(code))
242
+ return "the diagram references an external resource (url())";
243
+ // `<<choice>>` and `<<interface>>` are Mermaid annotations, not tags.
244
+ if (/<\s*\/?\s*[A-Za-z!][^>]*>/.test(code.replace(/<<[^<>\n]*>>/g, " ")))
245
+ return "labels are text: HTML tags (<br/>, <b>, <script>) are not allowed";
246
+ const interactive = !["mindmap", "timeline", "journey"].includes(syntax);
247
+ if (interactive && /^\s*(?:click|links?|callback)\s/m.test(code))
248
+ return "click, link and callback statements are not allowed";
249
+ if (estimateNodes(text, syntax) > maxNodes)
250
+ return `the diagram has more than ${maxNodes} nodes: split it or keep only the main parts`;
251
+ return undefined;
252
+ }
253
+ /** SHA-256 of what makes a diagram "the same" across revisions. */
254
+ export function diagramHash(diagram) {
255
+ return createHash("sha256")
256
+ .update(JSON.stringify([
257
+ diagram.title,
258
+ diagram.explanation,
259
+ diagram.section ?? "",
260
+ diagram.type,
261
+ normalizeMermaid(diagram.mermaid),
262
+ ]), "utf8")
263
+ .digest("hex");
264
+ }
265
+ const text = (value, max) => typeof value === "string" && value.trim() ? value.trim().slice(0, max) : undefined;
266
+ /**
267
+ * Validates the `diagrams` argument of `exit_plan`. Never throws: every problem becomes a dropped
268
+ * diagram with the reason the model needs to fix it. Order is kept; the first diagram with an id
269
+ * wins; only `limits.max` are accepted.
270
+ */
271
+ export function validateDiagrams(input, limits) {
272
+ const accepted = [];
273
+ const dropped = [];
274
+ if (input === undefined || input === null)
275
+ return { accepted, dropped };
276
+ if (!Array.isArray(input)) {
277
+ dropped.push({ id: "diagrams", reason: "`diagrams` must be an array" });
278
+ return { accepted, dropped };
279
+ }
280
+ const seen = new Set();
281
+ input.forEach((raw, index) => {
282
+ const item = raw && typeof raw === "object" ? raw : {};
283
+ const given = typeof item.id === "string" ? item.id.trim() : "";
284
+ const label = given || `#${index + 1}`;
285
+ const drop = (reason) => dropped.push({ id: label, reason });
286
+ if (!raw || typeof raw !== "object")
287
+ return drop("the diagram must be an object");
288
+ if (!ID.test(given) || given.length > DIAGRAM_ID_MAX)
289
+ return drop(`the id must be kebab-case, at most ${DIAGRAM_ID_MAX} characters (like "request-flow")`);
290
+ if (seen.has(given))
291
+ return drop("the id is already used by another diagram of this plan");
292
+ const title = text(item.title, DIAGRAM_TITLE_MAX);
293
+ if (!title)
294
+ return drop("the title is missing");
295
+ const explanation = text(item.explanation, DIAGRAM_EXPLANATION_MAX);
296
+ if (!explanation)
297
+ return drop("the explanation is missing (one short sentence)");
298
+ if (typeof item.mermaid !== "string")
299
+ return drop("the `mermaid` source is missing");
300
+ if (accepted.length >= limits.max)
301
+ return drop(limits.max === 0
302
+ ? "diagrams are turned off (plan.maxDiagrams is 0)"
303
+ : `only ${limits.max} diagrams are accepted per plan (plan.maxDiagrams)`);
304
+ const problem = mermaidProblem(item.mermaid, limits);
305
+ if (problem)
306
+ return drop(problem);
307
+ const mermaid = normalizeMermaid(item.mermaid);
308
+ const syntax = diagramSyntax(mermaid);
309
+ const type = DIAGRAM_TYPES.includes(item.type)
310
+ ? item.type
311
+ : defaultDiagramType(syntax);
312
+ const section = text(item.section, DIAGRAM_SECTION_MAX);
313
+ seen.add(given);
314
+ accepted.push({
315
+ id: given,
316
+ title,
317
+ explanation,
318
+ ...(section ? { section } : {}),
319
+ type,
320
+ syntax,
321
+ mermaid,
322
+ nodes: estimateNodes(mermaid, syntax),
323
+ });
324
+ });
325
+ return { accepted, dropped };
326
+ }
@@ -1,15 +1,3 @@
1
- /**
2
- * `exit_plan`: how the plan agent hands over its plan. The tool publishes the plan as a Markdown
3
- * artifact (`plan.md`, one revision per call), asks the user for a decision through the same
4
- * question infrastructure as `ask_user_question` and ALWAYS returns a tool result (approved,
5
- * skipped or feedback), so a session never keeps a dangling call. It does not switch agents or
6
- * start anything: an approved plan is recorded (`plan/state.ts`) and the host starts the
7
- * implementation turn once the plan run has ended.
8
- *
9
- * The tool is opt-in (`core/opt-in.ts`): only the plan agent's run lists it. Its effect is
10
- * `read`, so it is allowed under every policy; the plan agent stays read-only because its run
11
- * policy has no write/process/external effect, whatever the permission mode.
12
- */
13
1
  import type { AskQuestionsRequest, AskQuestionsResult } from "@alisio/sdk";
14
2
  import type { ToolRegistry } from "../core/registry.ts";
15
3
  import { type PlanStore } from "./state.ts";
@@ -21,5 +9,10 @@ export interface ExitPlanDeps {
21
9
  };
22
10
  /** `--read-only`: nothing can be implemented afterwards, so there is no decision to take. */
23
11
  readOnly?: boolean;
12
+ /** `plan.diagrams` and `plan.maxDiagrams`, read on every request (they change live). */
13
+ diagrams?: () => {
14
+ enabled: boolean;
15
+ max: number;
16
+ };
24
17
  }
25
18
  export declare function registerExitPlan(registry: ToolRegistry, deps: ExitPlanDeps): void;
@@ -1,7 +1,48 @@
1
+ /**
2
+ * `exit_plan`: how the plan agent hands over its plan. The tool publishes the plan as a Markdown
3
+ * artifact (`plan.md`, one revision per call), asks the user for a decision through the same
4
+ * question infrastructure as `ask_user_question` and ALWAYS returns a tool result (approved,
5
+ * skipped or feedback), so a session never keeps a dangling call. It does not switch agents or
6
+ * start anything: an approved plan is recorded (`plan/state.ts`) and the host starts the
7
+ * implementation turn once the plan run has ended.
8
+ *
9
+ * The tool is opt-in (`core/opt-in.ts`): only the plan agent's run lists it. Its effect is
10
+ * `read`, so it is allowed under every policy; the plan agent stays read-only because its run
11
+ * policy has no write/process/external effect, whatever the permission mode.
12
+ */
13
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
1
16
  import { textResult } from "@alisio/sdk";
2
17
  import { EXIT_PLAN_TOOL } from "../core/opt-in.js";
3
18
  import { objectSchema } from "../tools/standard.js";
4
- import { decidePlan, nextPlanRevision, PLAN_CONTEXT_MAX_CHARS, PLAN_MAX_CHARS, PLAN_OPTIONS, PLAN_QUESTION_ID, PLAN_REVIEW_TITLE, PLAN_TEXT_KEY, planHash, planTitle, proposePlan, withdrawPendingPlan, } from "./state.js";
19
+ import { DIAGRAM_MAX_BYTES, DIAGRAM_MAX_NODES, validateDiagrams, } from "./diagrams.js";
20
+ import { buildPlanManifest } from "./manifest.js";
21
+ import { currentPlan, decidePlan, nextPlanRevision, PLAN_CONTEXT_MAX_CHARS, PLAN_MAX_CHARS, PLAN_OPTIONS, PLAN_QUESTION_ID, PLAN_REVIEW_TITLE, PLAN_TEXT_KEY, planHash, planTitle, proposePlan, withdrawPendingPlan, } from "./state.js";
22
+ const DIAGRAM_SCHEMA = {
23
+ type: "array",
24
+ description: `Optional Mermaid diagrams; see the plan agent's instructions`,
25
+ items: {
26
+ type: "object",
27
+ properties: {
28
+ id: { type: "string", description: "kebab-case, stable across revisions" },
29
+ title: { type: "string" },
30
+ explanation: { type: "string", description: "One short sentence" },
31
+ section: { type: "string", description: "Plan heading it illustrates" },
32
+ type: {
33
+ type: "string",
34
+ description: "overview, flow, components, architecture, sequence, data, state or other",
35
+ },
36
+ mermaid: { type: "string", description: "Mermaid source, no html, click or %%{init}" },
37
+ },
38
+ },
39
+ };
40
+ const BASE_DESCRIPTION = "Finish planning: hand the complete plan, in Markdown, to the user for review. Call it once, " +
41
+ "when the plan is complete, instead of pasting the plan in your reply. The user can approve " +
42
+ "(the build agent then implements exactly this plan), skip for now, or add context (the " +
43
+ "result is `feedback`: revise the plan and call exit_plan again). Only the plan agent has " +
44
+ "this tool. If review is unavailable (headless or read-only sessions) the result says so: " +
45
+ "then give the complete plan as your final reply.";
5
46
  const withArtifact = (result, artifact) => artifact
6
47
  ? {
7
48
  ...result,
@@ -9,49 +50,157 @@ const withArtifact = (result, artifact) => artifact
9
50
  }
10
51
  : result;
11
52
  const answer = (decision, message, extra = {}) => JSON.stringify({ decision, ...extra, message });
53
+ const diagramReport = (outcome) => outcome.involved
54
+ ? {
55
+ diagrams: {
56
+ accepted: outcome.accepted.map((d) => d.id),
57
+ ...(outcome.dropped.length ? { dropped: outcome.dropped } : {}),
58
+ ...(outcome.removed.length ? { removed: outcome.removed.map((d) => d.id) } : {}),
59
+ },
60
+ }
61
+ : {};
62
+ /**
63
+ * Writes the plan folder (`plan.md`, `plan.json`, `diagrams/<id>.mmd`) in a scratch directory the
64
+ * publisher then copies, and removes the directory again.
65
+ */
66
+ async function publishPlanFolder(publisher, files, title) {
67
+ const scratch = await mkdtemp(join(tmpdir(), "alisio-plan-"));
68
+ try {
69
+ const folder = join(scratch, "plan");
70
+ for (const file of files) {
71
+ const target = join(folder, ...file.path.split("/"));
72
+ await mkdir(join(target, ".."), { recursive: true });
73
+ await writeFile(target, file.text, { flag: "wx" });
74
+ }
75
+ const input = { source: folder, title, entry: "plan.md", fileName: "plan.md" };
76
+ return typeof publisher.publishDetailed === "function"
77
+ ? (await publisher.publishDetailed(input)).artifact
78
+ : await publisher.publish(input);
79
+ }
80
+ finally {
81
+ await rm(scratch, { recursive: true, force: true });
82
+ }
83
+ }
12
84
  export function registerExitPlan(registry, deps) {
13
- registry.register({
85
+ const settings = () => deps.diagrams?.() ?? { enabled: true, max: 5 };
86
+ const offered = () => {
87
+ const current = settings();
88
+ return current.enabled && current.max > 0;
89
+ };
90
+ const plain = objectSchema({
91
+ title: { type: "string", maxLength: 120, description: "Short plan title" },
92
+ plan: {
93
+ type: "string",
94
+ minLength: 1,
95
+ maxLength: PLAN_MAX_CHARS,
96
+ description: "The whole plan in Markdown",
97
+ },
98
+ }, ["plan"]);
99
+ const withDiagrams = objectSchema({ ...plain.properties, diagrams: DIAGRAM_SCHEMA }, ["plan"]);
100
+ // The input is always VALIDATED with `diagrams` allowed (a call made while they were switched
101
+ // off must not fail), but the model is only OFFERED the field while diagrams are on: the
102
+ // registry compiles the schema once, while the provider reads it on every request.
103
+ let registering = true;
104
+ const tool = {
14
105
  name: EXIT_PLAN_TOOL,
15
106
  effect: "read",
16
- description: "Finish planning: hand the complete plan, in Markdown, to the user for review. Call it once, " +
17
- "when the plan is complete, instead of pasting the plan in your reply. The user can approve " +
18
- "(the build agent then implements exactly this plan), skip for now, or add context (the " +
19
- "result is `feedback`: revise the plan and call exit_plan again). Only the plan agent has " +
20
- "this tool. If review is unavailable (headless or read-only sessions) the result says so: " +
21
- "then give the complete plan as your final reply.",
22
- inputSchema: objectSchema({
23
- title: { type: "string", maxLength: 120, description: "Short plan title" },
24
- plan: {
25
- type: "string",
26
- minLength: 1,
27
- maxLength: PLAN_MAX_CHARS,
28
- description: "The whole plan in Markdown",
29
- },
30
- }, ["plan"]),
107
+ get description() {
108
+ return offered()
109
+ ? `${BASE_DESCRIPTION} Optional \`diagrams\`: up to ${settings().max} small Mermaid diagrams (see your instructions).`
110
+ : BASE_DESCRIPTION;
111
+ },
112
+ get inputSchema() {
113
+ return registering || offered() ? withDiagrams : plain;
114
+ },
31
115
  async execute(input, context) {
32
116
  const plan = String(input.plan ?? "").trim();
33
117
  if (!plan)
34
118
  throw new Error("exit_plan needs the whole plan in the `plan` field");
35
119
  const sessionId = context.session;
36
120
  const title = planTitle(plan, input.title);
37
- const hash = planHash(plan);
38
121
  const planId = crypto.randomUUID();
39
122
  const reviewable = !!sessionId && deps.ui.interactive() && !deps.readOnly;
40
123
  const revision = sessionId ? nextPlanRevision(deps.store, sessionId) : 1;
124
+ const previous = sessionId
125
+ ? currentPlan(deps.store, sessionId)?.diagrams
126
+ : undefined;
127
+ // Diagrams: validated without rendering; a bad one is dropped and the plan still goes out.
128
+ const current = settings();
129
+ const outcome = { accepted: [], dropped: [], removed: [], involved: false };
130
+ if (input.diagrams !== undefined) {
131
+ outcome.involved = true;
132
+ if (!current.enabled)
133
+ outcome.dropped.push({
134
+ id: "diagrams",
135
+ reason: "diagrams are turned off (plan.diagrams); the plan was published without them",
136
+ });
137
+ else {
138
+ const validated = validateDiagrams(input.diagrams, {
139
+ max: current.max,
140
+ maxBytes: DIAGRAM_MAX_BYTES,
141
+ maxNodes: DIAGRAM_MAX_NODES,
142
+ });
143
+ outcome.accepted = validated.accepted;
144
+ outcome.dropped = validated.dropped;
145
+ }
146
+ }
147
+ const preManifest = buildPlanManifest({
148
+ planId,
149
+ revision,
150
+ title,
151
+ hash: "",
152
+ markdown: plan,
153
+ diagrams: outcome.accepted,
154
+ ...(previous ? { previous } : {}),
155
+ });
156
+ outcome.removed = preManifest.removed;
157
+ if (outcome.removed.length)
158
+ outcome.involved = true;
159
+ const hash = planHash(plan, preManifest.diagrams.map((d) => ({ id: d.id, hash: d.hash })));
160
+ const manifest = { ...preManifest, hash };
161
+ const diagrams = manifest.diagrams.map((entry, index) => ({
162
+ id: entry.id,
163
+ title: entry.title,
164
+ explanation: entry.explanation,
165
+ ...(entry.section ? { section: entry.section } : {}),
166
+ type: entry.type,
167
+ syntax: entry.syntax,
168
+ status: entry.status,
169
+ mermaid: outcome.accepted[index]?.mermaid ?? "",
170
+ }));
41
171
  // The plan is saved first, so the review (and every later turn) can point at it.
42
172
  let artifact;
43
173
  let publishNote = "";
44
- if (context.artifacts)
45
- try {
46
- artifact = await context.artifacts.publishText({
47
- fileName: "plan.md",
48
- title: revision > 1 ? `${title} (revision ${revision})` : title,
49
- text: `${plan}\n`,
50
- });
51
- }
52
- catch (error) {
53
- publishNote = ` The plan could not be saved as an artifact: ${error instanceof Error ? error.message : String(error)}.`;
54
- }
174
+ const artifactTitle = revision > 1 ? `${title} (revision ${revision})` : title;
175
+ const publisher = context.artifacts;
176
+ if (publisher) {
177
+ // A folder (plan.md + plan.json + diagrams) when diagrams are involved, else plan.md alone.
178
+ if (outcome.accepted.length || outcome.removed.length)
179
+ try {
180
+ artifact = await publishPlanFolder(publisher, [
181
+ { path: "plan.md", text: `${plan}\n` },
182
+ { path: "plan.json", text: `${JSON.stringify(manifest, null, 2)}\n` },
183
+ ...outcome.accepted.map((d) => ({
184
+ path: `diagrams/${d.id}.mmd`,
185
+ text: `${d.mermaid}\n`,
186
+ })),
187
+ ], artifactTitle);
188
+ }
189
+ catch (error) {
190
+ publishNote = ` The diagrams could not be saved with the plan: ${error instanceof Error ? error.message : String(error)}.`;
191
+ }
192
+ if (!artifact)
193
+ try {
194
+ artifact = await publisher.publishText({
195
+ fileName: "plan.md",
196
+ title: artifactTitle,
197
+ text: `${plan}\n`,
198
+ });
199
+ }
200
+ catch (error) {
201
+ publishNote += ` The plan could not be saved as an artifact: ${error instanceof Error ? error.message : String(error)}.`;
202
+ }
203
+ }
55
204
  const record = {
56
205
  planId,
57
206
  revision,
@@ -59,12 +208,18 @@ export function registerExitPlan(registry, deps) {
59
208
  title,
60
209
  ...(context.callId ? { callId: context.callId } : {}),
61
210
  ...(artifact ? { artifactId: artifact.id } : {}),
211
+ ...(manifest.diagrams.length
212
+ ? {
213
+ diagrams: manifest.diagrams.map((d) => ({ id: d.id, title: d.title, hash: d.hash })),
214
+ }
215
+ : {}),
62
216
  };
217
+ const report = diagramReport(outcome);
63
218
  if (!reviewable) {
64
219
  // Nobody to ask: keep the revision count honest and tell the model what to do instead.
65
220
  if (sessionId)
66
221
  proposePlan(deps.store, sessionId, { ...record, status: "skipped" });
67
- return withArtifact(textResult(answer("unavailable", `No plan review is possible in this session (${deps.readOnly ? "it is read-only" : "it is not interactive"}), so nothing will be implemented from here. Give the complete plan in Markdown as your final reply and stop.${publishNote}`, artifact ? { artifactId: artifact.id } : {})), artifact);
222
+ return withArtifact(textResult(answer("unavailable", `No plan review is possible in this session (${deps.readOnly ? "it is read-only" : "it is not interactive"}), so nothing will be implemented from here. Give the complete plan in Markdown as your final reply and stop.${publishNote}`, { ...(artifact ? { artifactId: artifact.id } : {}), ...report })), artifact);
68
223
  }
69
224
  proposePlan(deps.store, sessionId, record);
70
225
  context.emitEvent?.("plan_proposed", {
@@ -82,6 +237,7 @@ export function registerExitPlan(registry, deps) {
82
237
  title,
83
238
  markdown: plan,
84
239
  ...(artifact ? { artifact } : {}),
240
+ ...(diagrams.length ? { diagrams } : {}),
85
241
  };
86
242
  const result = await deps.ui.askQuestions({
87
243
  questions: [
@@ -114,17 +270,27 @@ export function registerExitPlan(registry, deps) {
114
270
  hash,
115
271
  decision,
116
272
  });
117
- const base = { planId, revision, ...(artifact ? { artifactId: artifact.id } : {}) };
273
+ const base = {
274
+ planId,
275
+ revision,
276
+ ...(artifact ? { artifactId: artifact.id } : {}),
277
+ ...report,
278
+ };
118
279
  const body = decision === "approve"
119
280
  ? answer("approved", "The user approved the plan. The build agent will implement exactly this plan in the " +
120
281
  "next turn. Do not implement anything yourself: reply with one short sentence and " +
121
282
  "call no more tools.", base)
122
283
  : decision === "context"
123
284
  ? answer("feedback", "The user added context instead of deciding. Revise the plan to account for it and " +
124
- "call exit_plan again with the complete updated plan.", { ...base, feedback: text })
285
+ "call exit_plan again with the complete updated plan" +
286
+ (outcome.involved
287
+ ? ", with every diagram that still applies updated (and any dropped one fixed)."
288
+ : "."), { ...base, feedback: text })
125
289
  : answer("skipped", "The user chose to skip for now. Stay in plan mode and do not implement anything. " +
126
290
  "Do not call exit_plan again unless the user asks; end your turn with one short sentence.", base);
127
291
  return withArtifact(textResult(body), artifact);
128
292
  },
129
- });
293
+ };
294
+ registry.register(tool);
295
+ registering = false;
130
296
  }