melchizedek-agents 1.7.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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,36 @@ the starter pack and the templates), not the repo's full history.
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 1.8.0 — 2026-10-18
10
+
11
+ A minor release that gives a repository a manifest (ADR 0138). A
12
+ `melchizedek.json` at the root names the agents folder, the exact engine
13
+ version the project is written for, and how each syndicate ships; the
14
+ bins find it on their own, `melchizedek-doctor` checks the pin, and the
15
+ desktop app opens a repository through it. Nothing changes for a
16
+ deployment that sets `MELCHIZEDEK_AGENTS_DIR`.
17
+
18
+ ### Added
19
+
20
+ - **The project manifest, `melchizedek.json` (ADR 0138).** A JSON file at
21
+ a repository's root naming its agents folder (`agents`), the engine
22
+ version it is written for (`engine`, an exact pin), an `ignore` list,
23
+ a `tests` folder, the env variable *names* it needs, and per-file
24
+ shipping records (`syndicates.<file>.deliverable`,
25
+ `publish: { kind: "registry", id }`). New subpath
26
+ `melchizedek-agents/project` and barrel exports: `findProject`,
27
+ `loadProject`, `parseProjectManifest`, `resolveAgentsDir`,
28
+ `listProjectSyndicates`, `checkProject`, `projectManifestSchema`.
29
+ - **The agents directory reads the manifest.** Every loader and bin
30
+ resolves it as: explicit option, `MELCHIZEDEK_AGENTS_DIR`, the nearest
31
+ manifest's `agents` (not above the repository root), then
32
+ `<cwd>/config/agents`. A deployment that sets the variable is
33
+ unchanged.
34
+ - **`melchizedek-doctor` reports the project**: the engine pin against
35
+ the running engine, a missing agents or tests folder, `ignore`
36
+ patterns that match nothing, unset env names, and `syndicates` keys
37
+ with no file. A pin mismatch or a missing folder fails `--check`.
38
+
9
39
  ## 1.7.0 — 2026-10-17
10
40
 
11
41
  A minor release that lets a syndicate hold code (ADR 0137). A `script:`
package/DOCUMENTATION.md CHANGED
@@ -1610,22 +1610,27 @@ subclass), and register it behind a model-id prefix.
1610
1610
  subagent. `scripts/demo_mcp_server.ts` is a complete server to copy —
1611
1611
  tool definitions, SSE wiring, and persistent state in ~250 lines.
1612
1612
 
1613
- **Teach your coding agent the framework**: `skills/` holds six Agent
1613
+ **Teach your coding agent the framework**: `skills/` holds eleven Agent
1614
1614
  Skills (the open SKILL.md standard — a directory per skill, `name` and
1615
1615
  `description` frontmatter) that give Claude Code, Codex, Cursor,
1616
1616
  OpenCode or Gemini CLI the catalog of syndicates and the procedures in
1617
1617
  this document: `melchizedek` (find and run, delegate a task),
1618
1618
  `melchizedek-author`, `melchizedek-serve`, `melchizedek-memory`,
1619
- `melchizedek-models`, `melchizedek-scribe`. `npx melchizedek-skills
1619
+ `melchizedek-models`, `melchizedek-scribe`, and five onboarding skills
1620
+ that route a person by the credentials they have — `melchizedek-onboard`
1621
+ (the triage) and `melchizedek-onboard-local`, `-keys`, `-cloud` and
1622
+ `-serve` (the authentication levels each owns). `npx melchizedek-skills
1620
1623
  install` copies them into `.claude/skills/` and `.agents/skills/` of the
1621
1624
  current project (`--for claude,codex,cursor,opencode,gemini,agents,all`,
1622
1625
  `--global`, `--dir <path>`, `--only <names>`, `--force`, `--dry-run`;
1623
1626
  `npm run skills:install` in a clone). The installer (`lib/skills.ts`)
1624
1627
  copies only from the package's own `skills/` directory, follows no
1625
- symlinks, and never overwrites a differing file without `--force`. Each
1626
- SKILL.md body was written by the Scribe syndicate
1627
- (`config/agents/examples/scribe.yaml`) from a brief of facts and reviewed
1628
- by a person; `skills/README.md` records the procedure.
1628
+ symlinks, and never overwrites a differing file without `--force`. The
1629
+ onboarding skills are short procedures written by hand that print their
1630
+ facts from `melchizedek-setup --level <id>`; every other SKILL.md body was
1631
+ written by the Scribe syndicate (`config/agents/examples/scribe.yaml`)
1632
+ from a brief of facts and reviewed by a person; `skills/README.md`
1633
+ records the procedure.
1629
1634
 
1630
1635
  ## 8. Security notes
1631
1636
 
@@ -27,6 +27,7 @@ import type { Platform } from './models/endpoints.ts';
27
27
  import type { CapabilityGap, CapabilityReport } from './models/capabilities.ts';
28
28
  import type { ProviderId } from './models/providerMap.ts';
29
29
  import type { RuntimeName, RuntimeSource } from './runtime/runtimeFlag.ts';
30
+ import { type ProjectCheck } from './project.ts';
30
31
  /**
31
32
  * What a file costs to run: `keyless` (all Ollama), a single provider's id
32
33
  * when every agent shares one (`gemini`, `anthropic`, `openai`, `xai` — one
@@ -169,7 +170,16 @@ export interface DoctorResult {
169
170
  serving?: DoctorServing;
170
171
  /** A stored Sign in with ChatGPT credential (ADR 0126), when one is present or its variables are set. */
171
172
  chatgpt?: DoctorChatGpt;
173
+ /** The project manifest (ADR 0138) found from the working directory, when there is one. */
174
+ project?: DoctorProject;
172
175
  }
176
+ /** A `melchizedek.json` at or above the working directory: its checks, or why it does not load. */
177
+ export type DoctorProject = ProjectCheck | {
178
+ root: string;
179
+ error: string;
180
+ problems: string[];
181
+ warnings: string[];
182
+ };
173
183
  /**
174
184
  * A Sign in with ChatGPT credential (ADR 0126): where it is, whether it is
175
185
  * signed in and carries OpenAI ids here, and what stops it. Never a token.
@@ -42,6 +42,7 @@ import { OAUTH_HOSTS_ENV } from './tools/oauthHosts.js';
42
42
  import { CREDENTIAL_HOSTS_ENV, credentialHosts } from './tools/credentialHosts.js';
43
43
  import { syndicateCredentialUses } from './tools/credentialUses.js';
44
44
  import { credentialHostEnvProblems, unboundCredentialWarning } from './a2a/oauthSetup.js';
45
+ import { checkProject, findProject, isIgnored, ProjectManifestError, resolveAgentsDir } from './project.js';
45
46
  /** The `gateway.id` the doctor reports when `MODEL_GATEWAY` names no known gateway; the value itself is never kept. */
46
47
  const UNRECOGNISED_GATEWAY = 'unrecognised';
47
48
  function resolvable(module) {
@@ -457,10 +458,11 @@ export function flagMemoryNamespaces(syndicates) {
457
458
  }
458
459
  }
459
460
  export function runDoctor(options = {}) {
460
- const agentsDir = path.resolve(options.agentsDir ??
461
- process.env.MELCHIZEDEK_AGENTS_DIR ??
462
- path.join(process.cwd(), 'config', 'agents'));
463
- const syndicates = listSyndicateFiles(agentsDir).map((f) => diagnoseSyndicate(f, agentsDir, options.scripts));
461
+ const agentsDir = resolveAgentsDir(options.agentsDir);
462
+ const { project, report: projectReport } = projectFromCwd();
463
+ // The manifest's ignore list applies when the doctor reads the project's own folder.
464
+ const listed = listSyndicateFiles(agentsDir).filter((f) => !project || agentsDir !== project.agentsDir || !isIgnored(project, path.relative(project.root, path.join(agentsDir, f))));
465
+ const syndicates = listed.map((f) => diagnoseSyndicate(f, agentsDir, options.scripts));
464
466
  // Which single variable unlocks what. A syndicate counts toward a
465
467
  // variable only when that variable is ALL it is missing.
466
468
  const byEnv = new Map();
@@ -541,8 +543,22 @@ export function runDoctor(options = {}) {
541
543
  providers: providerPaths(),
542
544
  ...(serving ? { serving } : {}),
543
545
  ...(chatgpt ? { chatgpt } : {}),
546
+ ...(projectReport ? { project: projectReport } : {}),
544
547
  };
545
548
  }
549
+ /** The project at or above the working directory, checked; a broken manifest becomes a problem, not a throw. */
550
+ function projectFromCwd() {
551
+ try {
552
+ const project = findProject();
553
+ return project ? { project, report: checkProject(project) } : {};
554
+ }
555
+ catch (e) {
556
+ if (!(e instanceof ProjectManifestError))
557
+ throw e;
558
+ const problem = `melchizedek.json: ${e.issues.join('; ')}`;
559
+ return { report: { root: path.dirname(e.file), error: problem, problems: [problem], warnings: [] } };
560
+ }
561
+ }
546
562
  /**
547
563
  * The Sign in with ChatGPT line (ADR 0126), when a credential file is present
548
564
  * or one of its variables is set. `served`: a serving variable is set, so
@@ -613,6 +629,8 @@ export function checkProblems(result) {
613
629
  problems.push(`credentials: ${p}`);
614
630
  for (const p of result.chatgpt?.problems ?? [])
615
631
  problems.push(`chatgpt: ${p}`);
632
+ for (const p of result.project?.problems ?? [])
633
+ problems.push(`project: ${p}`);
616
634
  for (const s of result.syndicates) {
617
635
  for (const g of s.grants ?? [])
618
636
  for (const p of g.hostProblems ?? [])
@@ -635,6 +653,17 @@ export function renderDoctor(result, opts = {}) {
635
653
  : `${c.green}${text}${c.reset}`;
636
654
  lines.push(`${c.bold}melchizedek doctor${c.reset} ${c.dim}· ${result.agentsDir}${c.reset}`);
637
655
  lines.push('');
656
+ // The project manifest (ADR 0138): the engine pin, and what in it is stale.
657
+ if (result.project) {
658
+ const p = result.project;
659
+ const pin = 'engine' in p ? `${p.engine.matches ? c.green + '✓' : c.red + '✗'}${c.reset} engine ${p.engine.pinned}${p.engine.matches ? '' : `${c.red}, ${p.engine.running} running${c.reset}`} · ${p.syndicates.length} syndicate(s) ` : '';
660
+ lines.push(`project ${pin}${c.dim}${p.root}${c.reset}`);
661
+ for (const x of p.problems)
662
+ if (!x.startsWith('engine:'))
663
+ lines.push(` ${c.red}✗ ${x}${c.reset}`);
664
+ for (const x of p.warnings)
665
+ lines.push(` ${c.yellow}! ${x}${c.reset}`);
666
+ }
638
667
  // The runtime a turn would run on, and where that came from (ADR 0107).
639
668
  if (result.runtime) {
640
669
  const rt = result.runtime;
@@ -67,3 +67,4 @@ export { PROVIDERS, providerForModel, providerKeyPresent, providerStatuses, logP
67
67
  export { loadEnv } from './loadEnv.ts';
68
68
  export { setCredentialSource, credentialValue } from './models/credentialSource.ts';
69
69
  export type { CredentialSource } from './models/credentialSource.ts';
70
+ export { PROJECT_MANIFEST, projectManifestSchema, ProjectManifestError, findProject, findProjectRoot, loadProject, parseProjectManifest, resolveAgentsDir, listProjectSyndicates, checkProject, type Project, type ProjectManifest, type ProjectCheck, } from './project.ts';
package/dist/lib/index.js CHANGED
@@ -55,3 +55,5 @@ describeCapabilities, capabilitySummary, planTransport, gatewayConfig, gatewayPr
55
55
  export { loadEnv } from './loadEnv.js';
56
56
  // Where credentials are read, by environment variable name (ADR 0133).
57
57
  export { setCredentialSource, credentialValue } from './models/credentialSource.js';
58
+ // Project manifest, melchizedek.json (ADR 0138).
59
+ export { PROJECT_MANIFEST, projectManifestSchema, ProjectManifestError, findProject, findProjectRoot, loadProject, parseProjectManifest, resolveAgentsDir, listProjectSyndicates, checkProject, } from './project.js';
@@ -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+)\}\}$/;
@@ -290,10 +291,8 @@ export function readSyndicateFile(filename, options = {}) {
290
291
  // Security: confine reads to the agents root. `filename` can originate from
291
292
  // a network-controlled path segment (see a2a_server dynamic routes), so we
292
293
  // must reject any value that resolves outside that root (e.g. "../../.env").
293
- // The root itself is trusted configuration: option → env → cwd default.
294
- const agentsDir = path.resolve(options.agentsDir ??
295
- process.env.MELCHIZEDEK_AGENTS_DIR ??
296
- 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);
297
296
  let filePath = path.resolve(agentsDir, filename);
298
297
  if (filePath !== agentsDir && !filePath.startsWith(agentsDir + path.sep)) {
299
298
  throw new Error(`Invalid syndicate path: '${filename}' resolves outside the agents directory`);
@@ -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
+ }
@@ -97,7 +97,6 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
97
97
  */
98
98
  import { randomBytes } from 'node:crypto';
99
99
  import { readdirSync, realpathSync, writeFileSync } from 'node:fs';
100
- import path from 'node:path';
101
100
  import { pathToFileURL } from 'node:url';
102
101
  import { createA2AApp } from '../lib/a2a/app.js';
103
102
  import { callerTokens, firstOf, hashCallerToken, jwtIdentity, parseCallers, sharedSecret, trustedHeader, } from '../lib/a2a/identity.js';
@@ -116,6 +115,7 @@ import { callbackIdentity, callbackIdentityProblem, oauthServerSetup } from '../
116
115
  import { postgresCredentialRows } from '../lib/storage/postgres/credentialStore.js';
117
116
  import { setOAuthHosts } from '../lib/tools/oauthHosts.js';
118
117
  import { setCredentialHosts } from '../lib/tools/credentialHosts.js';
118
+ import { resolveAgentsDir } from '../lib/project.js';
119
119
  // Re-exported for existing importers (tests/failNarration.test.ts).
120
120
  export { describeTurnError } from '../lib/a2a/executor.js';
121
121
  /** Minimum bearer-secret length below which the server warns. */
@@ -181,7 +181,7 @@ export function servedFileSyndicates(syndicateName, servedAgents, registryAgents
181
181
  add(id);
182
182
  }
183
183
  else {
184
- const agentsDir = path.resolve(process.env.MELCHIZEDEK_AGENTS_DIR ?? path.join(process.cwd(), 'config', 'agents'));
184
+ const agentsDir = resolveAgentsDir();
185
185
  let names = [];
186
186
  try {
187
187
  names = readdirSync(agentsDir);
@@ -633,7 +633,7 @@ if (invokedAsMain) {
633
633
  if (error?.code === 'ENOENT') {
634
634
  console.error(`[A2A] Syndicate not found: ${arg || 'syndicate.yaml'}.\n`
635
635
  + ' Pass your syndicate: melchizedek-serve <name>.yaml\n'
636
- + ' (agents directory: MELCHIZEDEK_AGENTS_DIR, or <cwd>/config/agents)');
636
+ + ' (agents directory: MELCHIZEDEK_AGENTS_DIR, else the agents folder in melchizedek.json, else <cwd>/config/agents)');
637
637
  process.exit(1);
638
638
  }
639
639
  console.error(error);
@@ -33,6 +33,7 @@ import { loadEnv } from '../lib/loadEnv.js';
33
33
  import { checkProblems, renderDoctor, runDoctor } from '../lib/doctor.js';
34
34
  import { renderCapabilityMatrix } from '../lib/models/capabilities.js';
35
35
  import { assignMemoryNamespace, LEGACY_MEMORY_APP_NAME } from '../lib/memory/namespace.js';
36
+ import { resolveAgentsDir } from '../lib/project.js';
36
37
  loadEnv();
37
38
  const args = process.argv.slice(2);
38
39
  const json = args.includes('--json');
@@ -52,7 +53,7 @@ function packageScripts() {
52
53
  if (fixAt !== -1) {
53
54
  // ADR 0020: give each named long-term syndicate its own memory namespace.
54
55
  // Only the files named are touched; with none named, list the candidates.
55
- const agentsDir = path.resolve(process.env.MELCHIZEDEK_AGENTS_DIR ?? path.join(process.cwd(), 'config', 'agents'));
56
+ const agentsDir = resolveAgentsDir();
56
57
  const files = args.slice(fixAt + 1).filter((a) => !a.startsWith('--'));
57
58
  if (files.length === 0) {
58
59
  const pending = runDoctor({ agentsDir }).syndicates.filter((s) => s.memory && !s.memory.declared && !s.file.includes('/'));
@@ -38,6 +38,7 @@
38
38
  import { readdirSync, realpathSync } from 'node:fs';
39
39
  import path from 'node:path';
40
40
  import { pathToFileURL } from 'node:url';
41
+ import { resolveAgentsDir } from '../lib/project.js';
41
42
  /** The bin's flags. Throws, naming the flag, on one it does not know. */
42
43
  export function parseMcpArgs(argv) {
43
44
  const out = { syndicates: [], http: false, help: false };
@@ -160,7 +161,7 @@ export async function startMcpServer(argv = process.argv.slice(2)) {
160
161
  const bindings = () => ({
161
162
  current_date: new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }),
162
163
  });
163
- const agentsDir = path.resolve(process.env.MELCHIZEDEK_AGENTS_DIR ?? path.join(process.cwd(), 'config', 'agents'));
164
+ const agentsDir = resolveAgentsDir();
164
165
  let files = args.syndicates.map((id) => (id.endsWith('.yaml') || id.endsWith('.yml') ? id : `${id}.yaml`));
165
166
  const named = files.length > 0;
166
167
  if (!named) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "melchizedek-agents",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "A multi-model, multi-agent orchestration framework — YAML-defined syndicates on the engine's own agent loop, Supabase or Postgres session persistence, pgvector long-term memory. Companion repo to the lyceumagents.com curriculum.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,6 +33,7 @@
33
33
  "./a2a/limits": "./dist/lib/a2a/redisLimits.js",
34
34
  "./mcp": "./dist/lib/mcp/server.js",
35
35
  "./registry": "./dist/lib/registry.js",
36
+ "./project": "./dist/lib/project.js",
36
37
  "./server": "./dist/scripts/a2a_server.js",
37
38
  "./guards": "./dist/lib/guards/index.js",
38
39
  "./storage/postgres": "./dist/lib/storage/postgres/index.js",