melchizedek-agents 1.6.0 → 1.8.0

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 (44) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/DOCUMENTATION.md +16 -7
  3. package/config/agents/syndicate.schema.json +63 -0
  4. package/config/agents/syndicateSchema.yaml +50 -0
  5. package/dist/lib/a2a/executor.js +21 -0
  6. package/dist/lib/compile.d.ts +4 -3
  7. package/dist/lib/compile.js +8 -3
  8. package/dist/lib/doctor.d.ts +10 -0
  9. package/dist/lib/doctor.js +33 -4
  10. package/dist/lib/index.d.ts +9 -0
  11. package/dist/lib/index.js +7 -0
  12. package/dist/lib/loadSyndicate.d.ts +13 -0
  13. package/dist/lib/loadSyndicate.js +14 -5
  14. package/dist/lib/observability/supabaseSpanExporter.js +2 -1
  15. package/dist/lib/observability/tracer.d.ts +2 -2
  16. package/dist/lib/observability/tracer.js +2 -2
  17. package/dist/lib/project.d.ts +116 -0
  18. package/dist/lib/project.js +274 -0
  19. package/dist/lib/runtime/native/checkpoint.d.ts +2 -1
  20. package/dist/lib/runtime/native/checkpoint.js +6 -2
  21. package/dist/lib/runtime/native/interrupts.js +9 -2
  22. package/dist/lib/runtime/nativeTurn.d.ts +2 -1
  23. package/dist/lib/runtime/nativeTurn.js +5 -1
  24. package/dist/lib/runtime/syndicateTurn.d.ts +11 -1
  25. package/dist/lib/runtime/syndicateTurn.js +383 -5
  26. package/dist/lib/sandbox/sandbox.d.ts +121 -0
  27. package/dist/lib/sandbox/sandbox.js +276 -0
  28. package/dist/lib/sandbox/worker.d.ts +29 -0
  29. package/dist/lib/sandbox/worker.js +280 -0
  30. package/dist/lib/script/config.d.ts +35 -0
  31. package/dist/lib/script/config.js +17 -0
  32. package/dist/lib/script/journal.d.ts +137 -0
  33. package/dist/lib/script/journal.js +193 -0
  34. package/dist/lib/script/run.d.ts +138 -0
  35. package/dist/lib/script/run.js +364 -0
  36. package/dist/lib/syndicateSchema.d.ts +20 -0
  37. package/dist/lib/syndicateSchema.js +98 -6
  38. package/dist/lib/toolRegistry.js +4 -0
  39. package/dist/lib/tools/runCodeTool.d.ts +50 -0
  40. package/dist/lib/tools/runCodeTool.js +59 -0
  41. package/dist/scripts/a2a_server.js +3 -3
  42. package/dist/scripts/doctor.js +2 -1
  43. package/dist/scripts/mcp_server.js +2 -1
  44. package/package.json +6 -1
@@ -5,6 +5,7 @@ import { createClient } from '@supabase/supabase-js';
5
5
  import { hasSupabaseCredentials } from './persistence/supabaseProvider.js';
6
6
  import { validateSyndicateConfig } from './syndicateSchema.js';
7
7
  import { v1SpellingsOf } from './agentDialect.js';
8
+ import { resolveAgentsDir } from './project.js';
8
9
  // ── Variable Injection (private) ──────────────────────────
9
10
  const TOKEN_RE = /\{\{(\w+)\}\}/g;
10
11
  const FULL_TOKEN_RE = /^\{\{(\w+)\}\}$/;
@@ -87,7 +88,12 @@ function resolveAndValidate(raw, label, options) {
87
88
  ...yamlVars,
88
89
  ...bindings,
89
90
  };
90
- const interpolated = isPlainObject(raw) ? interpolateDeep(raw, merged) : raw;
91
+ // A script's source is code, not a template: `{{` is JavaScript there, and the script reads `variables` itself (ADR 0137 item 5).
92
+ const scriptSource = isPlainObject(raw) && isPlainObject(raw.script) && typeof raw.script.source === 'string' ? raw.script.source : undefined;
93
+ let interpolated = isPlainObject(raw) ? interpolateDeep(raw, merged) : raw;
94
+ if (scriptSource !== undefined && isPlainObject(interpolated) && isPlainObject(interpolated.script)) {
95
+ interpolated = { ...interpolated, script: { ...interpolated.script, source: scriptSource } };
96
+ }
91
97
  warnV1Spellings(interpolated, label, options.onWarning);
92
98
  const unresolved = findUnresolvedTokens(interpolated);
93
99
  if (unresolved.length > 0) {
@@ -96,6 +102,11 @@ function resolveAndValidate(raw, label, options) {
96
102
  const valid = validateSyndicateConfig(bundle !== undefined && isPlainObject(interpolated) ? { ...interpolated, bundled_references: bundle } : interpolated, label);
97
103
  const { variables: _stripped, ...clean } = valid;
98
104
  let config = clean;
105
+ // The script reads the bound variables (ADR 0137 item 6): the YAML's, then the caller's bindings, as {{token}} resolves them.
106
+ if (config.script) {
107
+ const yamlBound = isPlainObject(interpolated) && isPlainObject(interpolated.variables) ? interpolated.variables : {};
108
+ config = { ...config, script: { ...config.script, variables: { ...defaultBindings(), ...yamlBound, ...bindings } } };
109
+ }
99
110
  if (overrides) {
100
111
  config = applyOverrides(config, overrides);
101
112
  }
@@ -280,10 +291,8 @@ export function readSyndicateFile(filename, options = {}) {
280
291
  // Security: confine reads to the agents root. `filename` can originate from
281
292
  // a network-controlled path segment (see a2a_server dynamic routes), so we
282
293
  // must reject any value that resolves outside that root (e.g. "../../.env").
283
- // The root itself is trusted configuration: option → env → cwd default.
284
- const agentsDir = path.resolve(options.agentsDir ??
285
- process.env.MELCHIZEDEK_AGENTS_DIR ??
286
- path.join(process.cwd(), 'config', 'agents'));
294
+ // The root itself is trusted configuration: option → env → project manifest → cwd default (lib/project.ts).
295
+ const agentsDir = resolveAgentsDir(options.agentsDir);
287
296
  let filePath = path.resolve(agentsDir, filename);
288
297
  if (filePath !== agentsDir && !filePath.startsWith(agentsDir + path.sep)) {
289
298
  throw new Error(`Invalid syndicate path: '${filename}' resolves outside the agents directory`);
@@ -81,7 +81,8 @@ export function payloadDecision(policy, traceId, facts) {
81
81
  }
82
82
  // ── Span → row mapping (pure; tested offline) ───────────────────────────────
83
83
  const ROOT_PREFIX = 'Syndicate Execution: ';
84
- const EXPORTED_SPAN_NAMES = /^(llm\.request$|Syndicate Execution: )/;
84
+ // A script's own spans (ADR 0137 item 16) carry its run, its calls' kinds, names, sequence and status, never an argument or a result.
85
+ const EXPORTED_SPAN_NAMES = /^(llm\.request$|Syndicate Execution: |script\.(invoke|call) )/;
85
86
  function attrsOf(span) {
86
87
  return span.attributes;
87
88
  }
@@ -1,4 +1,4 @@
1
- import type { Span, SpanContext, SpanOptions } from '@opentelemetry/api';
1
+ import type { Context, Span, SpanContext, SpanOptions } from '@opentelemetry/api';
2
2
  import type { ReadableSpan } from '@opentelemetry/sdk-trace-base';
3
3
  import type { LlmResponse } from '../models/genaiMapping.ts';
4
4
  import type { TurnEvent as Event } from '../runtime/events.ts';
@@ -67,7 +67,7 @@ export declare function initializeTracing(): void;
67
67
  * the engine opens inside a turn goes through here or checks turnUntraced
68
68
  * itself.
69
69
  */
70
- export declare function startEngineSpan(tracerName: string, name: string, options?: SpanOptions): Span;
70
+ export declare function startEngineSpan(tracerName: string, name: string, options?: SpanOptions, parent?: Context): Span;
71
71
  export interface TraceMetadata {
72
72
  syndicateName: string;
73
73
  bindings?: Record<string, any>;
@@ -345,11 +345,11 @@ function nonRecordingSpan() {
345
345
  * the engine opens inside a turn goes through here or checks turnUntraced
346
346
  * itself.
347
347
  */
348
- export function startEngineSpan(tracerName, name, options) {
348
+ export function startEngineSpan(tracerName, name, options, parent) {
349
349
  if (turnUntraced())
350
350
  return nonRecordingSpan();
351
351
  initializeTracing();
352
- return trace.getTracer(tracerName).startSpan(name, options);
352
+ return trace.getTracer(tracerName).startSpan(name, options, parent);
353
353
  }
354
354
  /** Turn identity by trace id, so llm.request children can carry it too. */
355
355
  const turnContexts = new Map();
@@ -0,0 +1,116 @@
1
+ /**
2
+ * lib/project.ts — the project manifest, `melchizedek.json` (ADR 0138).
3
+ *
4
+ * A project is a folder, usually a git repository, with a `melchizedek.json`
5
+ * at its root. The manifest records what the engine cannot infer from the
6
+ * files: which folder holds the syndicates, which engine version the project
7
+ * is written for, and how each file ships. It holds names, never values: an
8
+ * `env` entry is a variable name, and no field may carry a secret.
9
+ *
10
+ * `findProject()` walks up from a directory to the nearest manifest and stops
11
+ * at the repository root (the first directory holding `.git`) or the home
12
+ * directory, so a manifest outside the repository never steers it.
13
+ * `resolveAgentsDir()` is the one agents-directory chain every loader and bin
14
+ * uses: explicit option → MELCHIZEDEK_AGENTS_DIR → the manifest's `agents` →
15
+ * <cwd>/config/agents. A deployed server sets the variable, so the manifest
16
+ * only changes what a bare command run inside a project finds.
17
+ *
18
+ * Imports only node built-ins, zod and lib/paths.ts.
19
+ */
20
+ import { z } from 'zod';
21
+ export declare const PROJECT_MANIFEST = "melchizedek.json";
22
+ export declare const MANIFEST_VERSION = 1;
23
+ export declare const projectManifestSchema: z.ZodObject<{
24
+ manifest: z.ZodNumber;
25
+ engine: z.ZodString;
26
+ agents: z.ZodString;
27
+ ignore: z.ZodOptional<z.ZodArray<z.ZodString>>;
28
+ tests: z.ZodOptional<z.ZodString>;
29
+ env: z.ZodOptional<z.ZodArray<z.ZodString>>;
30
+ syndicates: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
31
+ deliverable: z.ZodOptional<z.ZodEnum<{
32
+ library: "library";
33
+ mcp: "mcp";
34
+ service: "service";
35
+ }>>;
36
+ publish: z.ZodOptional<z.ZodObject<{
37
+ kind: z.ZodLiteral<"registry">;
38
+ id: z.ZodString;
39
+ }, z.core.$loose>>;
40
+ }, z.core.$loose>>>;
41
+ registry: z.ZodOptional<z.ZodObject<{
42
+ kind: z.ZodLiteral<"supabase">;
43
+ url_env: z.ZodOptional<z.ZodString>;
44
+ restart: z.ZodOptional<z.ZodString>;
45
+ }, z.core.$loose>>;
46
+ production: z.ZodOptional<z.ZodObject<{
47
+ ledger: z.ZodOptional<z.ZodObject<{
48
+ kind: z.ZodLiteral<"postgres">;
49
+ url_env: z.ZodString;
50
+ }, z.core.$loose>>;
51
+ }, z.core.$loose>>;
52
+ tools: z.ZodOptional<z.ZodObject<{
53
+ remote: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
54
+ mcp_env: z.ZodString;
55
+ }, z.core.$loose>>>;
56
+ }, z.core.$loose>>;
57
+ }, z.core.$loose>;
58
+ export type ProjectManifest = z.infer<typeof projectManifestSchema>;
59
+ export interface Project {
60
+ /** The folder holding `melchizedek.json`. */
61
+ root: string;
62
+ /** Absolute path of the manifest. */
63
+ file: string;
64
+ manifest: ProjectManifest;
65
+ /** `agents`, resolved under the root. */
66
+ agentsDir: string;
67
+ /** `tests`, resolved under the root (default `tests/agents`). */
68
+ testsDir: string;
69
+ }
70
+ /** Thrown for a manifest that does not parse or validate; names the file and fields, never a value. */
71
+ export declare class ProjectManifestError extends Error {
72
+ readonly file: string;
73
+ readonly issues: string[];
74
+ constructor(file: string, issues: string[]);
75
+ }
76
+ /**
77
+ * The nearest folder at or above `from` holding a manifest, without crossing
78
+ * the repository root or the home directory. Undefined when there is none.
79
+ */
80
+ export declare function findProjectRoot(from?: string): string | undefined;
81
+ /** Parse and validate a manifest's text. Throws `ProjectManifestError`. */
82
+ export declare function parseProjectManifest(text: string, file?: string): ProjectManifest;
83
+ /** Load the project rooted at `root` (the folder holding the manifest). Throws `ProjectManifestError`. */
84
+ export declare function loadProject(root: string): Project;
85
+ /** The project at or above `from`, or undefined when there is none. Throws on a broken manifest. */
86
+ export declare function findProject(from?: string): Project | undefined;
87
+ /** Forget cached manifest lookups (tests, and an embedder that edits the manifest in-process). */
88
+ export declare function clearProjectCache(): void;
89
+ /**
90
+ * The agents directory: `explicit` → MELCHIZEDEK_AGENTS_DIR → the nearest
91
+ * manifest's `agents` → <cwd>/config/agents. Always absolute.
92
+ */
93
+ export declare function resolveAgentsDir(explicit?: string, env?: NodeJS.ProcessEnv): string;
94
+ /** True when `relFromRoot` (a `/`-separated path relative to the project root) matches an `ignore` pattern. */
95
+ export declare function isIgnored(project: Pick<Project, 'manifest'>, relFromRoot: string): boolean;
96
+ /** The syndicate files directly in the agents folder, as names relative to it, minus `ignore`. */
97
+ export declare function listProjectSyndicates(project: Project): string[];
98
+ /** The installed engine's package version (no commit suffix). */
99
+ export declare function enginePackageVersion(): string;
100
+ export interface ProjectCheck {
101
+ root: string;
102
+ agentsDir: string;
103
+ /** The manifest's engine pin, and the engine running now. */
104
+ engine: {
105
+ pinned: string;
106
+ running: string;
107
+ matches: boolean;
108
+ };
109
+ syndicates: string[];
110
+ /** Stop `melchizedek-doctor --check`. */
111
+ problems: string[];
112
+ /** Shown, never fatal. */
113
+ warnings: string[];
114
+ }
115
+ /** What is wrong with a project: engine pin, missing folders, stale ignores, unset env names. Never reads a value. */
116
+ export declare function checkProject(project: Project, running?: string, env?: NodeJS.ProcessEnv): ProjectCheck;
@@ -0,0 +1,274 @@
1
+ /**
2
+ * lib/project.ts — the project manifest, `melchizedek.json` (ADR 0138).
3
+ *
4
+ * A project is a folder, usually a git repository, with a `melchizedek.json`
5
+ * at its root. The manifest records what the engine cannot infer from the
6
+ * files: which folder holds the syndicates, which engine version the project
7
+ * is written for, and how each file ships. It holds names, never values: an
8
+ * `env` entry is a variable name, and no field may carry a secret.
9
+ *
10
+ * `findProject()` walks up from a directory to the nearest manifest and stops
11
+ * at the repository root (the first directory holding `.git`) or the home
12
+ * directory, so a manifest outside the repository never steers it.
13
+ * `resolveAgentsDir()` is the one agents-directory chain every loader and bin
14
+ * uses: explicit option → MELCHIZEDEK_AGENTS_DIR → the manifest's `agents` →
15
+ * <cwd>/config/agents. A deployed server sets the variable, so the manifest
16
+ * only changes what a bare command run inside a project finds.
17
+ *
18
+ * Imports only node built-ins, zod and lib/paths.ts.
19
+ */
20
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
21
+ import { homedir } from 'node:os';
22
+ import { dirname, join, relative, resolve, sep } from 'node:path';
23
+ import { z } from 'zod';
24
+ import { enginePackageRoot } from './paths.js';
25
+ export const PROJECT_MANIFEST = 'melchizedek.json';
26
+ export const MANIFEST_VERSION = 1;
27
+ /** A path relative to the project root: no absolute paths, no `..` segments. */
28
+ const relPath = z
29
+ .string()
30
+ .min(1)
31
+ .refine((p) => !/^([a-zA-Z]:)?[\\/]/.test(p) && !p.split(/[\\/]/).includes('..'), 'must be relative to the project root and stay inside it');
32
+ const envName = z.string().regex(/^[A-Z][A-Z0-9_]{0,63}$/, 'must be an environment variable name');
33
+ const syndicateRecord = z
34
+ .object({
35
+ deliverable: z.enum(['service', 'library', 'mcp']).optional(),
36
+ publish: z.object({ kind: z.literal('registry'), id: z.string().min(1) }).passthrough().optional(),
37
+ })
38
+ .passthrough();
39
+ export const projectManifestSchema = z
40
+ .object({
41
+ manifest: z.number().int().min(1),
42
+ engine: z.string().regex(/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/, 'must be an exact version, e.g. 1.8.0'),
43
+ agents: relPath,
44
+ ignore: z.array(z.string().min(1)).optional(),
45
+ tests: relPath.optional(),
46
+ env: z.array(envName).optional(),
47
+ syndicates: z.record(z.string(), syndicateRecord).optional(),
48
+ registry: z
49
+ .object({ kind: z.literal('supabase'), url_env: envName.optional(), restart: z.string().optional() })
50
+ .passthrough()
51
+ .optional(),
52
+ production: z
53
+ .object({ ledger: z.object({ kind: z.literal('postgres'), url_env: envName }).passthrough().optional() })
54
+ .passthrough()
55
+ .optional(),
56
+ tools: z
57
+ .object({ remote: z.record(z.string(), z.object({ mcp_env: envName }).passthrough()).optional() })
58
+ .passthrough()
59
+ .optional(),
60
+ })
61
+ // Unknown keys are kept: a newer writer's fields survive an older reader.
62
+ .passthrough();
63
+ /** Thrown for a manifest that does not parse or validate; names the file and fields, never a value. */
64
+ export class ProjectManifestError extends Error {
65
+ file;
66
+ issues;
67
+ constructor(file, issues) {
68
+ super(`${file}: ${issues.join('; ')}`);
69
+ this.file = file;
70
+ this.issues = issues;
71
+ this.name = 'ProjectManifestError';
72
+ }
73
+ }
74
+ /**
75
+ * The nearest folder at or above `from` holding a manifest, without crossing
76
+ * the repository root or the home directory. Undefined when there is none.
77
+ */
78
+ export function findProjectRoot(from = process.cwd()) {
79
+ const home = resolve(homedir());
80
+ let dir = resolve(from);
81
+ for (;;) {
82
+ if (existsSync(join(dir, PROJECT_MANIFEST)))
83
+ return dir;
84
+ if (existsSync(join(dir, '.git')) || dir === home)
85
+ return undefined;
86
+ const parent = dirname(dir);
87
+ if (parent === dir)
88
+ return undefined;
89
+ dir = parent;
90
+ }
91
+ }
92
+ /** Parse and validate a manifest's text. Throws `ProjectManifestError`. */
93
+ export function parseProjectManifest(text, file = PROJECT_MANIFEST) {
94
+ let raw;
95
+ try {
96
+ raw = JSON.parse(text);
97
+ }
98
+ catch {
99
+ throw new ProjectManifestError(file, ['not valid JSON']);
100
+ }
101
+ const parsed = projectManifestSchema.safeParse(raw);
102
+ if (!parsed.success) {
103
+ throw new ProjectManifestError(file, parsed.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`));
104
+ }
105
+ if (parsed.data.manifest > MANIFEST_VERSION) {
106
+ throw new ProjectManifestError(file, [`manifest: version ${parsed.data.manifest} is newer than this engine reads (${MANIFEST_VERSION}); upgrade melchizedek-agents`]);
107
+ }
108
+ return parsed.data;
109
+ }
110
+ /** A path under the root, or an error when it would leave it. */
111
+ function under(root, rel, field, file) {
112
+ const abs = resolve(root, rel);
113
+ if (abs !== root && !abs.startsWith(root + sep))
114
+ throw new ProjectManifestError(file, [`${field}: resolves outside the project root`]);
115
+ return abs;
116
+ }
117
+ /** Load the project rooted at `root` (the folder holding the manifest). Throws `ProjectManifestError`. */
118
+ export function loadProject(root) {
119
+ const r = resolve(root);
120
+ const file = join(r, PROJECT_MANIFEST);
121
+ const manifest = parseProjectManifest(readFileSync(file, 'utf-8'), file);
122
+ return {
123
+ root: r,
124
+ file,
125
+ manifest,
126
+ agentsDir: under(r, manifest.agents, 'agents', file),
127
+ testsDir: under(r, manifest.tests ?? join('tests', 'agents'), 'tests', file),
128
+ };
129
+ }
130
+ /** The project at or above `from`, or undefined when there is none. Throws on a broken manifest. */
131
+ export function findProject(from = process.cwd()) {
132
+ const root = findProjectRoot(from);
133
+ return root ? loadProject(root) : undefined;
134
+ }
135
+ // The agents-directory chain runs on every syndicate read (an A2A route
136
+ // reads per request), so the manifest lookup is cached per working
137
+ // directory. A broken manifest is cached as "none" and reported by
138
+ // `melchizedek-doctor`, never by a request path.
139
+ const agentsDirCache = new Map();
140
+ /** Forget cached manifest lookups (tests, and an embedder that edits the manifest in-process). */
141
+ export function clearProjectCache() {
142
+ agentsDirCache.clear();
143
+ }
144
+ function manifestAgentsDir(cwd) {
145
+ if (!agentsDirCache.has(cwd)) {
146
+ let dir = null;
147
+ try {
148
+ dir = findProject(cwd)?.agentsDir ?? null;
149
+ }
150
+ catch {
151
+ dir = null;
152
+ }
153
+ agentsDirCache.set(cwd, dir);
154
+ }
155
+ return agentsDirCache.get(cwd) ?? undefined;
156
+ }
157
+ /**
158
+ * The agents directory: `explicit` → MELCHIZEDEK_AGENTS_DIR → the nearest
159
+ * manifest's `agents` → <cwd>/config/agents. Always absolute.
160
+ */
161
+ export function resolveAgentsDir(explicit, env = process.env) {
162
+ const fromEnv = env.MELCHIZEDEK_AGENTS_DIR?.trim();
163
+ const cwd = process.cwd();
164
+ return resolve(explicit ?? (fromEnv || undefined) ?? manifestAgentsDir(cwd) ?? join(cwd, 'config', 'agents'));
165
+ }
166
+ /** Glob to RegExp for `*` (one segment), `**` (any depth) and `?`, on `/`-separated paths. */
167
+ function globToRegExp(glob) {
168
+ let re = '';
169
+ for (let i = 0; i < glob.length; i++) {
170
+ const c = glob[i];
171
+ if (c === '*') {
172
+ if (glob[i + 1] === '*') {
173
+ i++;
174
+ if (glob[i + 1] === '/') {
175
+ i++;
176
+ re += '(?:.*/)?';
177
+ }
178
+ else
179
+ re += '.*';
180
+ }
181
+ else
182
+ re += '[^/]*';
183
+ }
184
+ else if (c === '?')
185
+ re += '[^/]';
186
+ else
187
+ re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
188
+ }
189
+ return new RegExp(`^${re}$`);
190
+ }
191
+ /** True when `relFromRoot` (a `/`-separated path relative to the project root) matches an `ignore` pattern. */
192
+ export function isIgnored(project, relFromRoot) {
193
+ const p = relFromRoot.split(sep).join('/');
194
+ return (project.manifest.ignore ?? []).some((g) => globToRegExp(g).test(p));
195
+ }
196
+ /** The syndicate files directly in the agents folder, as names relative to it, minus `ignore`. */
197
+ export function listProjectSyndicates(project) {
198
+ if (!existsSync(project.agentsDir))
199
+ return [];
200
+ const rel = relative(project.root, project.agentsDir);
201
+ return readdirSync(project.agentsDir)
202
+ .filter((f) => (f.endsWith('.yaml') || f.endsWith('.yml')) && f !== 'syndicateSchema.yaml')
203
+ .filter((f) => statSync(join(project.agentsDir, f)).isFile())
204
+ .filter((f) => !isIgnored(project, rel ? `${rel}/${f}` : f))
205
+ .sort();
206
+ }
207
+ /** The installed engine's package version (no commit suffix). */
208
+ export function enginePackageVersion() {
209
+ try {
210
+ const root = enginePackageRoot();
211
+ if (root)
212
+ return String(JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8')).version);
213
+ }
214
+ catch {
215
+ /* fall through */
216
+ }
217
+ return '0.0.0';
218
+ }
219
+ /** What is wrong with a project: engine pin, missing folders, stale ignores, unset env names. Never reads a value. */
220
+ export function checkProject(project, running = enginePackageVersion(), env = process.env) {
221
+ const problems = [];
222
+ const warnings = [];
223
+ const pinned = project.manifest.engine;
224
+ const matches = pinned === running;
225
+ if (!matches)
226
+ problems.push(`engine: the project pins melchizedek-agents ${pinned}; ${running} is running`);
227
+ if (!existsSync(project.agentsDir))
228
+ problems.push(`agents: ${project.manifest.agents} does not exist`);
229
+ if (project.manifest.tests && !existsSync(project.testsDir))
230
+ problems.push(`tests: ${project.manifest.tests} does not exist`);
231
+ for (const g of project.manifest.ignore ?? []) {
232
+ if (!anyPathMatches(project.root, globToRegExp(g)))
233
+ warnings.push(`ignore: "${g}" matches nothing`);
234
+ }
235
+ for (const name of project.manifest.env ?? [])
236
+ if (!env[name]?.trim())
237
+ warnings.push(`env: ${name} is not set`);
238
+ const syndicates = listProjectSyndicates(project);
239
+ for (const f of Object.keys(project.manifest.syndicates ?? {})) {
240
+ if (!syndicates.includes(f))
241
+ warnings.push(`syndicates: ${f} is not in ${project.manifest.agents}`);
242
+ }
243
+ return { root: project.root, agentsDir: project.agentsDir, engine: { pinned, running, matches }, syndicates, problems, warnings };
244
+ }
245
+ const SKIP_DIRS = new Set(['.git', 'node_modules', 'venv', '.venv', '__pycache__', 'dist']);
246
+ /** Whether any path under root (depth-limited, skipping vendor folders) matches. */
247
+ function anyPathMatches(root, re, rel = '', depth = 0) {
248
+ if (depth > 6)
249
+ return false;
250
+ let entries;
251
+ try {
252
+ entries = readdirSync(join(root, rel));
253
+ }
254
+ catch {
255
+ return false;
256
+ }
257
+ for (const e of entries) {
258
+ if (SKIP_DIRS.has(e))
259
+ continue;
260
+ const p = rel ? `${rel}/${e}` : e;
261
+ if (re.test(p))
262
+ return true;
263
+ let dir = false;
264
+ try {
265
+ dir = statSync(join(root, p)).isDirectory();
266
+ }
267
+ catch {
268
+ /* unreadable entry */
269
+ }
270
+ if (dir && anyPathMatches(root, re, p, depth + 1))
271
+ return true;
272
+ }
273
+ return false;
274
+ }
@@ -15,7 +15,8 @@
15
15
  * runner, unchanged, continues where the snapshot stood. runDurableTurn
16
16
  * is runSyndicateTurn with that store and a sink.
17
17
  *
18
- * A STEP BOUNDARY is an append that carries function responses after which
18
+ * A STEP BOUNDARY is an append that carries function responses, or a
19
+ * script's journal event (ADR 0137 item 8), after which
19
20
  * every function call any session of the run holds has its response (calls
20
21
  * listed in their event's longRunningToolIds, which wait on a person, aside).
21
22
  * So a parent waiting on a delegated subagent is never at a boundary, the
@@ -15,7 +15,8 @@
15
15
  * runner, unchanged, continues where the snapshot stood. runDurableTurn
16
16
  * is runSyndicateTurn with that store and a sink.
17
17
  *
18
- * A STEP BOUNDARY is an append that carries function responses after which
18
+ * A STEP BOUNDARY is an append that carries function responses, or a
19
+ * script's journal event (ADR 0137 item 8), after which
19
20
  * every function call any session of the run holds has its response (calls
20
21
  * listed in their event's longRunningToolIds, which wait on a person, aside).
21
22
  * So a parent waiting on a delegated subagent is never at a boundary, the
@@ -75,6 +76,7 @@
75
76
  */
76
77
  import { createHash, randomUUID } from 'node:crypto';
77
78
  import { getFunctionCalls, getFunctionResponses, parseTurnEvents } from '../events.js';
79
+ import { JOURNAL_EVERY_CALL, isJournalEvent } from '../../script/journal.js';
78
80
  import { InProcessSessionService } from '../sessions.js';
79
81
  import { runSyndicateTurn } from '../syndicateTurn.js';
80
82
  // ── Shapes ───────────────────────────────────────────────────────────────────
@@ -127,6 +129,8 @@ export function usableCheckpoint(cp, runId, parts) {
127
129
  /** Content compared as stored JSON. */
128
130
  const sameJson = (a, b) => JSON.stringify(a) === JSON.stringify(b);
129
131
  class CheckpointingStore {
132
+ /** A script run stores each call's journal event as it ends, and each is a step boundary (ADR 0137 item 8). */
133
+ [JOURNAL_EVERY_CALL] = true;
130
134
  inner;
131
135
  opts;
132
136
  openingParts;
@@ -250,7 +254,7 @@ class CheckpointingStore {
250
254
  if (stored.author !== 'user' && this.firstAgentSeq === undefined)
251
255
  this.firstAgentSeq = order;
252
256
  this.track(stored);
253
- if (getFunctionResponses(stored).length > 0 && this.openCalls.size === 0)
257
+ if ((getFunctionResponses(stored).length > 0 || isJournalEvent(stored)) && this.openCalls.size === 0)
254
258
  await this.boundary();
255
259
  return stored;
256
260
  }
@@ -163,6 +163,7 @@ import { approvalRequestOf, pendingApproval } from '../approvals.js';
163
163
  import { consentFrom, pendingConsent } from '../credentials.js';
164
164
  import { getFunctionCalls, getFunctionResponses } from '../events.js';
165
165
  import { ASK_USER, pendingQuestion } from '../questions.js';
166
+ import { isJournalEvent, scriptPauseIn } from '../../script/journal.js';
166
167
  import { childAppName, entryAppName, legacyChild, legacyEntry } from './delegate.js';
167
168
  import { REQUEST_CONFIRMATION_CALL, REQUEST_CREDENTIAL_CALL, REQUEST_INPUT_CALL, isSegmentPrefix } from './history.js';
168
169
  import { isToolset } from './request.js';
@@ -516,6 +517,12 @@ async function conversationPause(key, child, name, id, depth, seen = new Set())
516
517
  ...(routed.consent ? { consent: { ...routed.consent, path } } : {}),
517
518
  };
518
519
  }
520
+ // A nested script syndicate's own pause (ADR 0137 item 15): its askUser or a gated tool() call, authored by the script.
521
+ const scripted = scriptPauseIn(child.events, id);
522
+ if (scripted) {
523
+ const path = scripted.asker === name ? [name] : down([scripted.asker]);
524
+ return { path, ...(scripted.approval ? { approval: { ...scripted.approval, path } } : {}), ...(scripted.question ? { question: { ...scripted.question, path } } : {}) };
525
+ }
519
526
  const approval = pendingApproval(child.events);
520
527
  if (approval && approval.agent && approval.agent !== 'user' && approval.agent !== name && wanted(approval.id)) {
521
528
  const path = down([approval.agent]);
@@ -543,8 +550,8 @@ async function conversationPause(key, child, name, id, depth, seen = new Set())
543
550
  ...(first.consent ? { consent: { ...first.consent, path } } : {}),
544
551
  };
545
552
  }
546
- /** A walk's session: its node events carry a dotted node path (`<workflow>.<node>`). */
547
- const isWalk = (session) => session.events.some((e) => typeof e.nodeInfo?.path === 'string' && e.nodeInfo.path.includes('.'));
553
+ /** A walk's session: its node events carry a dotted node path (`<workflow>.<node>`); a script's journal events do not count. */
554
+ const isWalk = (session) => session.events.some((e) => typeof e.nodeInfo?.path === 'string' && e.nodeInfo.path.includes('.') && !isJournalEvent(e));
548
555
  /**
549
556
  * The pause a nested workflow's walk holds in its child session (ADR 0111):
550
557
  * the workflow's own pause record, the last event, authored by the
@@ -18,7 +18,8 @@
18
18
  * feature (UnsupportedOnRuntimeError): a caller's `transformAgent` (it
19
19
  * transformed ADK agents, and ADK left in 1.0.0), and an `ask_user` tool on
20
20
  * a workflow's agent (the schema refuses it; a config that skipped
21
- * validation is refused here, ADR 0095). A workflow syndicate runs on the
21
+ * validation is refused here, ADR 0095), and a script or a `run_code`
22
+ * tool while MELCHIZEDEK_CODE_SANDBOX is off (CodeSandboxOffError, ADR 0137). A workflow syndicate runs on the
22
23
  * engine's scheduler (lib/workflow/turn.ts, ADR 0095). An answer to an
23
24
  * approval resumes the call (lib/runtime/native/interrupts.ts), an answer
24
25
  * to a question resumes the agent that asked (lib/runtime/questions.ts, ADR
@@ -18,7 +18,8 @@
18
18
  * feature (UnsupportedOnRuntimeError): a caller's `transformAgent` (it
19
19
  * transformed ADK agents, and ADK left in 1.0.0), and an `ask_user` tool on
20
20
  * a workflow's agent (the schema refuses it; a config that skipped
21
- * validation is refused here, ADR 0095). A workflow syndicate runs on the
21
+ * validation is refused here, ADR 0095), and a script or a `run_code`
22
+ * tool while MELCHIZEDEK_CODE_SANDBOX is off (CodeSandboxOffError, ADR 0137). A workflow syndicate runs on the
22
23
  * engine's scheduler (lib/workflow/turn.ts, ADR 0095). An answer to an
23
24
  * approval resumes the call (lib/runtime/native/interrupts.ts), an answer
24
25
  * to a question resumes the agent that asked (lib/runtime/questions.ts, ADR
@@ -30,6 +31,7 @@ import { createTurnEvent } from './events.js';
30
31
  import { runAgentLoop } from './native/agentLoop.js';
31
32
  import { ASK_USER } from './questions.js';
32
33
  import { unsupportedOnNative } from './runtimeFlag.js';
34
+ import { refuseWithoutSandbox } from '../sandbox/sandbox.js';
33
35
  // ── What native refuses before a turn starts ─────────────────────────────────
34
36
  /**
35
37
  * Throws UnsupportedOnRuntimeError for a syndicate or a call the runtime
@@ -39,6 +41,8 @@ export function refuseOnNative(config, call) {
39
41
  const where = config.syndicate_name || config.orchestrator?.name || 'syndicate';
40
42
  if (call.transformAgent)
41
43
  throw unsupportedOnNative('transformAgent (it transformed ADK agents; the ADK runtime was removed in 1.0.0)', where);
44
+ // A script, or an agent listing run_code, runs code: only once the operator turned the sandbox on (ADR 0137 item 4).
45
+ refuseWithoutSandbox(config);
42
46
  if (call.isWorkflow) {
43
47
  // A pause inside an agent node cannot be resumed by the native walk (lib/workflow/resume.ts).
44
48
  const asking = [config.orchestrator, ...(config.subagents ?? [])].find((agent) => (agent?.tools ?? []).includes(ASK_USER));
@@ -64,6 +64,8 @@ export { ASK_USER, pendingQuestion, questionAnswerPart } from './questions.ts';
64
64
  import type { PendingApproval } from './approvals.ts';
65
65
  export { approvalResponsePart, describeApproval, pendingApproval } from './approvals.ts';
66
66
  export type { PendingApproval } from './approvals.ts';
67
+ import type { CodeRun } from '../tools/runCodeTool.ts';
68
+ export type { CodeRun } from '../tools/runCodeTool.ts';
67
69
  import type { AgentBranch, TurnStopReason } from './turnControl.ts';
68
70
  export type { AgentBranch } from './turnControl.ts';
69
71
  /** One part of the user's message. Text is the common case; the genai part
@@ -222,8 +224,10 @@ export interface DrainedRun {
222
224
  code: string;
223
225
  message: string;
224
226
  }>;
227
+ /** Every run_code call that got its result, with the agent that made it (ADR 0137). */
228
+ codeRuns: CodeRun[];
225
229
  }
226
- export type TurnStage = 'classify' | 'dispatch' | 'delegate' | 'workflow';
230
+ export type TurnStage = 'classify' | 'dispatch' | 'delegate' | 'workflow' | 'script';
227
231
  export interface RouteDecision extends RouteResolution {
228
232
  /** `approval`: the turn resumed the route that asked for an approval (ADR 0028).
229
233
  * `answer`: the message answered the route's open `ask_user` question.
@@ -272,6 +276,12 @@ export interface SyndicateTurnResult {
272
276
  usage: TurnUsage;
273
277
  /** Set when a control (cancel, deadline, step limit) stopped the turn. */
274
278
  stopReason?: TurnStopReason;
279
+ /**
280
+ * The run_code runs of agents whose YAML sets `run_code: { attach: true }`
281
+ * (ADR 0137 item 3), in order: the reply's `code_run` data parts. Absent
282
+ * when there are none.
283
+ */
284
+ codeRuns?: CodeRun[];
275
285
  }
276
286
  /** A turn's spend: model calls and the tokens the providers reported. */
277
287
  export interface TurnUsage {