@yaag/runtime 0.7.0 → 0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yaag/runtime",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -35,6 +35,13 @@ export interface AgentConfig {
35
35
  readonly skills?: readonly string[];
36
36
  /** Deny-list of skill names, applied after `skills`. */
37
37
  readonly disallowedSkills?: readonly string[];
38
+ /**
39
+ * Load the Effective Config's `agents.extensions` for Agents of this
40
+ * definition. Default true. A spawn's own `SpawnOptions.configExtensions`
41
+ * wins over this value; spawn *overrides* stay topology-only, so the opt-out
42
+ * is not one of them (ADR-0019, ADR-0040).
43
+ */
44
+ readonly configExtensions?: boolean;
38
45
  /** Ask options applied to every Ask on this Agent unless overridden per call. */
39
46
  readonly askDefaults?: AskOptions;
40
47
  }
@@ -0,0 +1,97 @@
1
+ import type { ConfigExtension } from "../config/index.ts";
2
+ import { resolveExtensionPaths } from "../extension/index.ts";
3
+
4
+ /** Everything one spawn needs to turn declarations into `-e` arguments. */
5
+ export interface SpawnExtensionRequest {
6
+ /** Effective Config entries in merge order: global → project → run (spec rule 3). */
7
+ readonly configExtensions: readonly ConfigExtension[];
8
+ /** `SpawnOptions.extensions`, resolved against the program file as before. */
9
+ readonly declared?: readonly string[] | undefined;
10
+ /** False when the spawn or its definition opted out (spec rule 7). */
11
+ readonly useConfigExtensions: boolean;
12
+ /** Orchestration Program source path, the base of a relative spawn declaration. */
13
+ readonly programFile?: string | undefined;
14
+ /** Agent working directory, the project install root for `npm:`/`git:`. */
15
+ readonly projectRoot: string;
16
+ }
17
+
18
+ /** The two projections of one Agent's extensions: what launches, and what identifies. */
19
+ export interface SpawnExtensions {
20
+ /** Absolute launch-ready `-e` arguments, deduplicated, in final order. */
21
+ readonly resolvedPaths: readonly string[];
22
+ /** The surviving declarations behind them, same order, unresolved (ADR-0040). */
23
+ readonly declared: readonly string[];
24
+ }
25
+
26
+ /**
27
+ * Resolves the extensions of one Agent: config-sourced first, spawn-declared
28
+ * last, deduplicated by resolved path with the first occurrence keeping its
29
+ * place. Returns undefined when nothing is declared at all, so an
30
+ * extension-free spawn keeps its request shape.
31
+ *
32
+ * The two bases are deliberately different: a relative *path* from a config
33
+ * file resolves against that file's directory, while an `npm:`/`git:`
34
+ * specifier still installs from the Agent working directory (`projectRoot`).
35
+ * An uninstalled specifier passes through verbatim and dedups by its literal
36
+ * string.
37
+ *
38
+ * A declaration joins `declared` only when it contributed at least one new
39
+ * resolved path, so the identity list describes what actually ran. Two
40
+ * different declarations that resolve to the same path therefore keep only the
41
+ * first, which is machine-dependent by nature of the resolution itself.
42
+ */
43
+ export async function resolveSpawnExtensions(
44
+ request: SpawnExtensionRequest,
45
+ ): Promise<SpawnExtensions | undefined> {
46
+ const entries = request.useConfigExtensions ? request.configExtensions : [];
47
+ if (request.declared === undefined && entries.length === 0) return undefined;
48
+ const resolved: { declaration: string; paths: readonly string[] }[] = [];
49
+ for (const entry of entries) {
50
+ resolved.push({
51
+ declaration: entry.declaration,
52
+ paths: await resolveConfigEntry(entry, request.projectRoot),
53
+ });
54
+ }
55
+ for (const declaration of request.declared ?? []) {
56
+ resolved.push({
57
+ declaration,
58
+ // One declaration per call: `resolveExtensionPaths` keeps no cross-entry
59
+ // state, so the loop preserves its order and its error messages.
60
+ paths: await resolveExtensionPaths([declaration], {
61
+ ...(request.programFile === undefined ? {} : { programFile: request.programFile }),
62
+ projectRoot: request.projectRoot,
63
+ }),
64
+ });
65
+ }
66
+ const seen = new Set<string>();
67
+ const resolvedPaths: string[] = [];
68
+ const declared: string[] = [];
69
+ for (const { declaration, paths } of resolved) {
70
+ let contributed = false;
71
+ for (const path of paths) {
72
+ if (seen.has(path)) continue;
73
+ seen.add(path);
74
+ resolvedPaths.push(path);
75
+ contributed = true;
76
+ }
77
+ if (contributed) declared.push(declaration);
78
+ }
79
+ return { resolvedPaths, declared };
80
+ }
81
+
82
+ /** Names the declaring config file, so a bad entry points at its own source. */
83
+ async function resolveConfigEntry(
84
+ entry: ConfigExtension,
85
+ projectRoot: string,
86
+ ): Promise<readonly string[]> {
87
+ try {
88
+ return await resolveExtensionPaths([entry.declaration], {
89
+ baseDirectory: entry.baseDirectory,
90
+ projectRoot,
91
+ });
92
+ } catch (error) {
93
+ throw new Error(
94
+ `config "${entry.file}": ${error instanceof Error ? error.message : String(error)}`,
95
+ );
96
+ }
97
+ }
@@ -8,6 +8,7 @@ export interface OpenRequestOptions {
8
8
  readonly cwd: string;
9
9
  readonly spawnOptions: SpawnOptions;
10
10
  readonly resolvedExtensionPaths?: readonly string[];
11
+ readonly declaredExtensions?: readonly string[];
11
12
  readonly sessionDir: string | undefined;
12
13
  }
13
14
 
@@ -45,6 +46,11 @@ export function openRequest(options: OpenRequestOptions): OpenOptions {
45
46
  ...(options.resolvedExtensionPaths === undefined
46
47
  ? {}
47
48
  : { resolvedExtensionPaths: options.resolvedExtensionPaths }),
49
+ // An empty list means the same as no list at all, so it stays absent and an
50
+ // extension-free spawn keeps its recorded identity.
51
+ ...(options.declaredExtensions === undefined || options.declaredExtensions.length === 0
52
+ ? {}
53
+ : { declaredExtensions: options.declaredExtensions }),
48
54
  ...(spawnOptions.worktree === true ? { worktree: true as const } : {}),
49
55
  ...(options.sessionDir === undefined ? {} : { sessionDir: options.sessionDir }),
50
56
  };
@@ -1,7 +1,7 @@
1
1
  import { resolve } from "node:path";
2
+ import type { ConfigExtension } from "../config/index.ts";
2
3
  import { YaagError } from "../errors.ts";
3
4
  import type { EventSink } from "../events.ts";
4
- import { resolveExtensionPaths } from "../extension/index.ts";
5
5
  import {
6
6
  ModelErrorHistory,
7
7
  type ModelSelection,
@@ -21,6 +21,7 @@ import type {
21
21
  import { Agent } from "./agent.ts";
22
22
  import { uniqueAgentName } from "./agent-names.ts";
23
23
  import { type AgentDefinition, agentDefinitionConfig, isAgentDefinition } from "./define-agent.ts";
24
+ import { resolveSpawnExtensions } from "./spawn-extensions.ts";
24
25
  import { openRequest, withSelection } from "./spawn-request.ts";
25
26
 
26
27
  /** Dependencies for one Run's Agent-spawn gate. */
@@ -30,6 +31,8 @@ export interface SpawnDependencies {
30
31
  readonly emit: EventSink;
31
32
  readonly sessionDir: string | undefined;
32
33
  readonly programFile: string | undefined;
34
+ /** Effective Config entries, in merge order; empty when the Run has no config. */
35
+ readonly configExtensions: readonly ConfigExtension[];
33
36
  }
34
37
 
35
38
  /** A RunContext spawn function that can synchronously stop accepting new Agents. */
@@ -61,16 +64,25 @@ export function makeSpawn(deps: SpawnDependencies): SpawnGate {
61
64
  const history = new ModelErrorHistory();
62
65
  try {
63
66
  // Extension paths do not vary per candidate, so a bad path fails once, generically.
64
- const resolvedExtensionPaths =
65
- request.spawnOptions.extensions === undefined
66
- ? undefined
67
- : await resolveExtensionPaths(request.spawnOptions.extensions, {
68
- ...(deps.programFile === undefined ? {} : { programFile: deps.programFile }),
69
- projectRoot: cwd,
70
- }).catch((error: unknown) => {
71
- if (error instanceof YaagError) throw error;
72
- throw new YaagError("SPAWN_FAILED", `agent "${name}": ${String(error)}`, name);
73
- });
67
+ const extensions = await resolveSpawnExtensions({
68
+ configExtensions: deps.configExtensions,
69
+ declared: request.spawnOptions.extensions,
70
+ useConfigExtensions: request.spawnOptions.configExtensions !== false,
71
+ ...(deps.programFile === undefined ? {} : { programFile: deps.programFile }),
72
+ projectRoot: cwd,
73
+ }).catch((error: unknown) => {
74
+ if (error instanceof YaagError) throw error;
75
+ throw new YaagError("SPAWN_FAILED", `agent "${name}": ${String(error)}`, name);
76
+ });
77
+ // One local for both open sites, so the launch arguments and the recorded
78
+ // identity cannot drift apart.
79
+ const extensionFields =
80
+ extensions === undefined
81
+ ? {}
82
+ : {
83
+ resolvedExtensionPaths: extensions.resolvedPaths,
84
+ declaredExtensions: extensions.declared,
85
+ };
74
86
  const attempt = async (
75
87
  selection: ModelSelection,
76
88
  ): Promise<{ opened: OpenedTransport; spawnOptions: ResolvedSpawnOptions }> => {
@@ -81,7 +93,7 @@ export function makeSpawn(deps: SpawnDependencies): SpawnGate {
81
93
  name,
82
94
  cwd,
83
95
  spawnOptions: settled,
84
- ...(resolvedExtensionPaths === undefined ? {} : { resolvedExtensionPaths }),
96
+ ...extensionFields,
85
97
  sessionDir: deps.sessionDir,
86
98
  }),
87
99
  spawnOptions: settled,
@@ -94,7 +106,7 @@ export function makeSpawn(deps: SpawnDependencies): SpawnGate {
94
106
  name,
95
107
  cwd,
96
108
  spawnOptions: request.spawnOptions,
97
- ...(resolvedExtensionPaths === undefined ? {} : { resolvedExtensionPaths }),
109
+ ...extensionFields,
98
110
  sessionDir: deps.sessionDir,
99
111
  }),
100
112
  );
@@ -194,6 +206,9 @@ function resolveRequest(
194
206
  ...(config.tools === undefined ? {} : { tools: config.tools }),
195
207
  ...(config.disallowedTools === undefined ? {} : { disallowedTools: config.disallowedTools }),
196
208
  ...(config.skills === undefined ? {} : { skills: config.skills }),
209
+ ...(config.configExtensions === undefined
210
+ ? {}
211
+ : { configExtensions: config.configExtensions }),
197
212
  ...(config.disallowedSkills === undefined
198
213
  ? {}
199
214
  : { disallowedSkills: config.disallowedSkills }),
@@ -256,6 +271,7 @@ interface OpenTransportOptions {
256
271
  readonly cwd: string;
257
272
  readonly spawnOptions: ResolvedSpawnOptions;
258
273
  readonly resolvedExtensionPaths?: readonly string[];
274
+ readonly declaredExtensions?: readonly string[];
259
275
  readonly sessionDir: string | undefined;
260
276
  }
261
277
 
@@ -275,6 +291,9 @@ async function openTransport(options: OpenTransportOptions): Promise<OpenedTrans
275
291
  ...(options.resolvedExtensionPaths === undefined
276
292
  ? {}
277
293
  : { resolvedExtensionPaths: options.resolvedExtensionPaths }),
294
+ ...(options.declaredExtensions === undefined
295
+ ? {}
296
+ : { declaredExtensions: options.declaredExtensions }),
278
297
  sessionDir: options.sessionDir,
279
298
  }),
280
299
  (report) => Object.assign(startup, report),
@@ -21,7 +21,11 @@ export async function publishCassette(path: string, cassette: Cassette): Promise
21
21
  await syncDirectory(directory);
22
22
  } catch (error) {
23
23
  await discard(temp);
24
- throw new Error(`failed to publish cassette ${path}: ${String(error)}`, { cause: error });
24
+ throw new Error(
25
+ `failed to publish cassette ${path}: ${String(error)}; ` +
26
+ "see @yaag/extension docs/troubleshooting.md#failed-to-publish-cassette",
27
+ { cause: error },
28
+ );
25
29
  }
26
30
  }
27
31
 
@@ -37,6 +37,7 @@ const CassetteSpawnSchema = Type.Object({
37
37
  cwd: Type.String(),
38
38
  ...spawnPolicyFields,
39
39
  worktree: Type.Optional(Type.Literal(true)),
40
+ declaredExtensions: Type.Optional(StringArray),
40
41
  });
41
42
 
42
43
  const ContextSpawnSchema = Type.Object({
@@ -83,6 +83,11 @@ export interface CassetteSpawn {
83
83
  readonly disallowedSkills?: readonly string[];
84
84
  /** Deterministic request, present only when the Agent requested a worktree. */
85
85
  readonly worktree?: true;
86
+ /**
87
+ * Declared extensions in declaration order, unresolved so a Cassette matches
88
+ * across machines (ADR-0040). Absent when the Agent ran no extension.
89
+ */
90
+ readonly declaredExtensions?: readonly string[];
86
91
  }
87
92
 
88
93
  /** The frames attributed to one Ask marker. */
@@ -264,6 +269,9 @@ function spawnIdentity(options: OpenOptions): CassetteSpawn {
264
269
  ...(options.disallowedSkills === undefined
265
270
  ? {}
266
271
  : { disallowedSkills: [...options.disallowedSkills] }),
272
+ ...(options.declaredExtensions === undefined || options.declaredExtensions.length === 0
273
+ ? {}
274
+ : { declaredExtensions: [...options.declaredExtensions] }),
267
275
  ...(options.worktree === true ? { worktree: true } : {}),
268
276
  };
269
277
  }
@@ -73,13 +73,16 @@ export const replayMismatch = {
73
73
  },
74
74
  };
75
75
 
76
+ /** Where a user reads what a Divergence means and what to do about it. */
77
+ const DOCS_POINTER = "; see @yaag/extension docs/troubleshooting.md#replay-or-resume-mismatch";
78
+
76
79
  /** Converts a detected mismatch into strict replay's public failure. */
77
80
  export function strictReplay(mismatch: ReplayMismatch): never {
78
81
  if (mismatch.kind === "changed-ask" && mismatch.definitionName !== undefined) {
79
82
  const fields = mismatch.changedFields?.join(", ") ?? "hash inputs";
80
83
  throw new YaagError(
81
84
  "REPLAY_DIVERGED",
82
- `replay diverged for agent "${mismatch.agent}" at ask #${mismatch.index}: definition "${mismatch.definitionName}" changed since recording (${fields})`,
85
+ `replay diverged for agent "${mismatch.agent}" at ask #${mismatch.index}: definition "${mismatch.definitionName}" changed since recording (${fields})${DOCS_POINTER}`,
83
86
  mismatch.agent,
84
87
  );
85
88
  }
@@ -90,7 +93,7 @@ export function strictReplay(mismatch: ReplayMismatch): never {
90
93
  : "";
91
94
  throw new YaagError(
92
95
  "REPLAY_DIVERGED",
93
- `replay diverged for agent "${mismatch.agent}" at ${at}:${changed} expected ${mismatch.expectedHash}, actual ${mismatch.actualHash}`,
96
+ `replay diverged for agent "${mismatch.agent}" at ${at}:${changed} expected ${mismatch.expectedHash}, actual ${mismatch.actualHash}${DOCS_POINTER}`,
94
97
  mismatch.agent,
95
98
  );
96
99
  }
@@ -123,7 +126,11 @@ export function recordedSpawnSelection(
123
126
  };
124
127
  }
125
128
 
126
- /** The spawn fields both Divergence reports compare, so the two cannot drift apart. */
129
+ /**
130
+ * The spawn fields both Divergence reports compare, so the two cannot drift
131
+ * apart. Extensions are the deliberate exception: they identify the process
132
+ * start, not an Ask, so they join only the open-request list below.
133
+ */
127
134
  const SPAWN_IDENTITY_FIELDS = [
128
135
  "cwd",
129
136
  "model",
@@ -138,7 +145,7 @@ const SPAWN_IDENTITY_FIELDS = [
138
145
  ] as const;
139
146
 
140
147
  /** The spawn identity fields plus the Agent name, which only an open request carries. */
141
- const SPAWN_OPEN_FIELDS = ["name", ...SPAWN_IDENTITY_FIELDS] as const;
148
+ const SPAWN_OPEN_FIELDS = ["name", ...SPAWN_IDENTITY_FIELDS, "declaredExtensions"] as const;
142
149
 
143
150
  /** The listed fields whose canonical JSON differs between two identity records. */
144
151
  function changedAmong<Key extends string>(
@@ -206,6 +213,9 @@ function spawnHash(options: CassetteSpawn | OpenOptions): string {
206
213
  ...(options.appendSystemPrompt === undefined
207
214
  ? {}
208
215
  : { appendSystemPrompt: options.appendSystemPrompt }),
216
+ ...(options.declaredExtensions === undefined || options.declaredExtensions.length === 0
217
+ ? {}
218
+ : { declaredExtensions: options.declaredExtensions }),
209
219
  }),
210
220
  );
211
221
  return hasher.digest("hex");
@@ -0,0 +1,67 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import { Value } from "typebox/value";
4
+ import { YaagError } from "../errors.ts";
5
+ import { describeConfigIssues } from "./config-issues.ts";
6
+ import { type YaagConfigDocument, YaagConfigSchema } from "./config-schema.ts";
7
+
8
+ /** One config file that contributed to the Effective Config. */
9
+ export interface ConfigLayer {
10
+ /** The file this layer was read from. */
11
+ readonly file: string;
12
+ /** The file's own directory: relative paths inside it resolve here (spec rule 4). */
13
+ readonly directory: string;
14
+ readonly document: YaagConfigDocument;
15
+ }
16
+
17
+ /** Whether an absent file is an empty layer or a Run-start failure. */
18
+ export interface ReadConfigOptions {
19
+ /** True for the explicit Run Config: a missing file is then an error. */
20
+ readonly required: boolean;
21
+ }
22
+
23
+ /**
24
+ * Reads, parses, and validates one config layer.
25
+ *
26
+ * A missing file at a discovered location yields `undefined` (an empty layer);
27
+ * a missing explicit Run Config fails. Any other errno — `EACCES`, say — always
28
+ * throws: a config yaag cannot read is not an empty config.
29
+ */
30
+ export async function readConfigLayer(
31
+ path: string,
32
+ options: ReadConfigOptions,
33
+ ): Promise<ConfigLayer | undefined> {
34
+ let text: string;
35
+ try {
36
+ text = await readFile(path, "utf8");
37
+ } catch (error) {
38
+ if (isMissing(error)) {
39
+ if (!options.required) return undefined;
40
+ throw invalid(`config file "${path}" does not exist`);
41
+ }
42
+ throw invalid(`cannot read config "${path}": ${String(error)}`);
43
+ }
44
+ let value: unknown;
45
+ try {
46
+ value = JSON.parse(text);
47
+ } catch (error) {
48
+ throw invalid(`cannot parse config "${path}": ${String(error)}`);
49
+ }
50
+ const errors = Value.Errors(YaagConfigSchema, value);
51
+ if (errors.length > 0) {
52
+ throw invalid(`invalid config "${path}": ${describeConfigIssues(value, errors).join("; ")}`);
53
+ }
54
+ // Sound: the strict schema above accepted `value`, so it has exactly this shape.
55
+ const document = value as YaagConfigDocument;
56
+ return { file: path, directory: dirname(path), document };
57
+ }
58
+
59
+ function invalid(message: string): YaagError {
60
+ return new YaagError("CONFIG_INVALID", message);
61
+ }
62
+
63
+ function isMissing(error: unknown): boolean {
64
+ if (typeof error !== "object" || error === null || !("code" in error)) return false;
65
+ const { code } = error;
66
+ return code === "ENOENT" || code === "ENOTDIR";
67
+ }
@@ -0,0 +1,31 @@
1
+ import type { TLocalizedValidationError } from "typebox/error";
2
+ import { formatValidationErrors } from "../validation-errors.ts";
3
+
4
+ /**
5
+ * Turns TypeBox errors into config diagnostics that name the offending key
6
+ * (spec rule 2).
7
+ *
8
+ * A strict object reports an unknown key twice: once at the key itself with the
9
+ * unhelpful `"schema is false"`, and once at the owning object carrying the key
10
+ * name. Neither alone reads well, so the pair collapses into one line. Every
11
+ * other keyword delegates to the shared formatter, which stays untouched
12
+ * because args validation and ADR-0032 steering text depend on its wording.
13
+ */
14
+ export function describeConfigIssues(
15
+ value: unknown,
16
+ errors: Iterable<TLocalizedValidationError>,
17
+ ): readonly string[] {
18
+ return Array.from(errors).flatMap((error) => {
19
+ if (error.keyword === "additionalProperties") {
20
+ const [line] = formatValidationErrors(value, [error]);
21
+ // The formatter renders `<path>: <message>`; the message carries no colon,
22
+ // so the last one is the separator even for quoted path segments.
23
+ const prefix = line === undefined ? "$" : line.slice(0, line.lastIndexOf(":"));
24
+ return error.params.additionalProperties.map(
25
+ (key) => `${prefix}: unknown key ${JSON.stringify(key)}`,
26
+ );
27
+ }
28
+ if (error.keyword === "boolean") return [];
29
+ return formatValidationErrors(value, [error]);
30
+ });
31
+ }
@@ -0,0 +1,38 @@
1
+ import { homedir } from "node:os";
2
+ import { isAbsolute, join } from "node:path";
3
+
4
+ /**
5
+ * The part of the environment this module reads. It is structural on purpose:
6
+ * the vendored declaration tree compiles with no ambient Node globals.
7
+ */
8
+ export interface ConfigEnvironment {
9
+ readonly [name: string]: string | undefined;
10
+ }
11
+
12
+ /** Every config layer lives in a file with this name. */
13
+ export const CONFIG_FILE_NAME = "config.json";
14
+
15
+ /** The Project Config directory inside a Program Directory. */
16
+ export const PROJECT_CONFIG_DIR = ".yaag";
17
+
18
+ /**
19
+ * Resolves the Global Config path: `YAAG_CONFIG_DIR`, then an absolute
20
+ * `XDG_CONFIG_HOME`, then `$HOME/.config`. A relative `XDG_CONFIG_HOME` is
21
+ * ignored (XDG spec), exactly as in `resolveCheckpointDirectory`.
22
+ */
23
+ export function resolveGlobalConfigPath(env: ConfigEnvironment = process.env): string {
24
+ const override = env["YAAG_CONFIG_DIR"];
25
+ if (override !== undefined && override !== "") return join(override, CONFIG_FILE_NAME);
26
+ const xdg = env["XDG_CONFIG_HOME"];
27
+ if (xdg !== undefined && isAbsolute(xdg)) return join(xdg, "yaag", CONFIG_FILE_NAME);
28
+ const home = env["HOME"] !== undefined && env["HOME"] !== "" ? env["HOME"] : homedir();
29
+ return join(home, ".config", "yaag", CONFIG_FILE_NAME);
30
+ }
31
+
32
+ /**
33
+ * Resolves the Project Config path under a Program Directory. The Program
34
+ * Directory is an input: this module never walks upward looking for `.yaag/`.
35
+ */
36
+ export function projectConfigPath(programDirectory: string): string {
37
+ return join(programDirectory, PROJECT_CONFIG_DIR, CONFIG_FILE_NAME);
38
+ }
@@ -0,0 +1,25 @@
1
+ import { type Static, Type } from "typebox";
2
+
3
+ /**
4
+ * The config document of one layer (spec rule 1): plain JSON, namespaced as
5
+ * `{ "agents": { "extensions": ["..."] } }`.
6
+ *
7
+ * Every node sets `additionalProperties: false` on purpose (spec rule 2) — the
8
+ * opposite choice from `cassette-schema.ts`, which tolerates unknown fields so
9
+ * a newer artifact still loads. A config file is hand-written, so an unknown
10
+ * key is a typo the author wants named, not forward compatibility.
11
+ */
12
+ export const YaagConfigSchema = Type.Object(
13
+ {
14
+ agents: Type.Optional(
15
+ Type.Object(
16
+ { extensions: Type.Optional(Type.Array(Type.String())) },
17
+ { additionalProperties: false },
18
+ ),
19
+ ),
20
+ },
21
+ { additionalProperties: false },
22
+ );
23
+
24
+ /** The validated shape of one config file. */
25
+ export type YaagConfigDocument = Static<typeof YaagConfigSchema>;
@@ -0,0 +1,116 @@
1
+ import { type ConfigLayer, readConfigLayer } from "./config-file.ts";
2
+ import {
3
+ type ConfigEnvironment,
4
+ projectConfigPath,
5
+ resolveGlobalConfigPath,
6
+ } from "./config-paths.ts";
7
+
8
+ /** Which of the three layers a config value came from. */
9
+ export type ConfigLayerName = "global" | "project" | "run";
10
+
11
+ /** One extension declaration, kept exactly as written, with its resolution base. */
12
+ export interface ConfigExtension {
13
+ /** The declaration as written in the config file; never resolved here. */
14
+ readonly declaration: string;
15
+ /** The declaring config file's own directory (spec rule 4). */
16
+ readonly baseDirectory: string;
17
+ readonly layer: ConfigLayerName;
18
+ /** The declaring file, for diagnostics. */
19
+ readonly file: string;
20
+ }
21
+
22
+ /** The merged configuration one Run reads at start. */
23
+ export interface EffectiveConfig {
24
+ /** Extension declarations, concatenated global → project → run (spec rule 3). */
25
+ readonly extensions: readonly ConfigExtension[];
26
+ /** Config files that contributed, in merge order. */
27
+ readonly files: readonly string[];
28
+ }
29
+
30
+ /** What to discover and read. */
31
+ export interface EffectiveConfigRequest {
32
+ /** Environment for Global Config discovery; defaults to `process.env`. */
33
+ readonly env?: ConfigEnvironment;
34
+ /** Program Directory of the Run; absent means no Project Config layer. */
35
+ readonly programDirectory?: string;
36
+ /** Explicit Run Config path; a missing file here is an error. */
37
+ readonly configPath?: string;
38
+ /** Suppresses the discovered layers only; an explicit `configPath` survives (spec rule 8). */
39
+ readonly noConfig?: boolean;
40
+ }
41
+
42
+ /** The Effective Config of a Run that found no config at all. */
43
+ export const EMPTY_EFFECTIVE_CONFIG: EffectiveConfig = Object.freeze({
44
+ extensions: Object.freeze([]) as readonly ConfigExtension[],
45
+ files: Object.freeze([]) as readonly string[],
46
+ });
47
+
48
+ interface LoadedLayer {
49
+ readonly layer: ConfigLayer;
50
+ readonly name: ConfigLayerName;
51
+ }
52
+
53
+ interface Candidate {
54
+ readonly path: string;
55
+ readonly layer: ConfigLayerName;
56
+ readonly required: boolean;
57
+ }
58
+
59
+ /**
60
+ * Locates, reads, validates, and merges the three config layers.
61
+ *
62
+ * The result is frozen: the Effective Config is read once at Run start, so a
63
+ * config edit mid-Run changes nothing until the next Run (spec rule 6). Nothing
64
+ * is resolved, existence-checked, or deduplicated here — dedup happens by
65
+ * resolved path once the spawn knows its base directories (ADR-0040).
66
+ */
67
+ export async function loadEffectiveConfig(
68
+ request: EffectiveConfigRequest = {},
69
+ ): Promise<EffectiveConfig> {
70
+ const found: LoadedLayer[] = [];
71
+ // Read in order, sequentially: the first failing file is the reported one.
72
+ for (const candidate of candidates(request)) {
73
+ const layer = await readConfigLayer(candidate.path, { required: candidate.required });
74
+ if (layer !== undefined) found.push({ layer, name: candidate.layer });
75
+ }
76
+ return mergeLayers(found);
77
+ }
78
+
79
+ function candidates(request: EffectiveConfigRequest): readonly Candidate[] {
80
+ const discovered: Candidate[] = [];
81
+ if (request.noConfig !== true) {
82
+ discovered.push({
83
+ path: resolveGlobalConfigPath(request.env),
84
+ layer: "global",
85
+ required: false,
86
+ });
87
+ if (request.programDirectory !== undefined) {
88
+ discovered.push({
89
+ path: projectConfigPath(request.programDirectory),
90
+ layer: "project",
91
+ required: false,
92
+ });
93
+ }
94
+ }
95
+ if (request.configPath !== undefined) {
96
+ discovered.push({ path: request.configPath, layer: "run", required: true });
97
+ }
98
+ return discovered;
99
+ }
100
+
101
+ function mergeLayers(found: readonly LoadedLayer[]): EffectiveConfig {
102
+ const extensions = found.flatMap(({ layer, name }) =>
103
+ (layer.document.agents?.extensions ?? []).map((declaration) =>
104
+ Object.freeze({
105
+ declaration,
106
+ baseDirectory: layer.directory,
107
+ layer: name,
108
+ file: layer.file,
109
+ }),
110
+ ),
111
+ );
112
+ return Object.freeze({
113
+ extensions: Object.freeze(extensions),
114
+ files: Object.freeze(found.map(({ layer }) => layer.file)),
115
+ });
116
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Public surface of the `config/` module: the Global, Project, and Run Config
3
+ * layers and the frozen Effective Config a Run reads at start (ADR-0040).
4
+ * Files inside this directory import each other directly.
5
+ */
6
+
7
+ export {
8
+ CONFIG_FILE_NAME,
9
+ type ConfigEnvironment,
10
+ PROJECT_CONFIG_DIR,
11
+ projectConfigPath,
12
+ resolveGlobalConfigPath,
13
+ } from "./config-paths.ts";
14
+ export type { YaagConfigDocument } from "./config-schema.ts";
15
+ export {
16
+ type ConfigExtension,
17
+ type ConfigLayerName,
18
+ type EffectiveConfig,
19
+ type EffectiveConfigRequest,
20
+ EMPTY_EFFECTIVE_CONFIG,
21
+ loadEffectiveConfig,
22
+ } from "./effective-config.ts";
package/src/errors.ts CHANGED
@@ -46,6 +46,7 @@ export type YaagErrorCode =
46
46
  | "ASK_STALLED" // no frame arrived within the silence budget; escalated, then kill
47
47
  | "ASK_INVALID_OUTPUT" // settled text could not be extracted or satisfy outputSchema
48
48
  | "ARGS_INVALID" // arguments failed schema validation before the Run started
49
+ | "CONFIG_INVALID" // a config layer is missing, unparsable, or violates the schema
49
50
  | "OPTIONS_CONFLICT" // incompatible Run options were supplied
50
51
  | "REPLAY_DIVERGED" // replayed program differed from its Cassette
51
52
  | "RESUME_REFUSED" // resume metadata is absent or the recorded tree moved
@@ -7,16 +7,24 @@ import { parseExtensionSource } from "./extension-source.ts";
7
7
  export interface ExtensionResolutionOptions extends ExtensionInstallRoots {
8
8
  /** Orchestration Program source path, required by relative declarations. */
9
9
  readonly programFile?: string;
10
+ /**
11
+ * Directory a relative declaration resolves against, and which wins over
12
+ * `programFile`: a config-sourced entry resolves from its own config file's
13
+ * directory (ADR-0040, spec rule 4).
14
+ */
15
+ readonly baseDirectory?: string;
10
16
  }
11
17
 
12
18
  /**
13
19
  * Resolves declared extensions into launch-ready `pi -e` arguments before transport opening.
14
20
  *
15
- * Filesystem declarations are resolved against the Orchestration Program directory and
21
+ * Filesystem declarations are resolved against `baseDirectory` when given, and against
22
+ * the Orchestration Program directory otherwise, and are
16
23
  * checked for existence. `npm:`/`git:` specifiers expand to the entry points of the
17
24
  * already-installed package (project install shadows the user install); an uninstalled
18
25
  * specifier passes through verbatim so pi temp-installs it. Results retain declaration
19
- * order. Rejects missing paths and a relative declaration without `programFile`.
26
+ * order. Rejects missing paths, and a relative declaration that has neither
27
+ * `baseDirectory` nor `programFile`.
20
28
  */
21
29
  export async function resolveExtensionPaths(
22
30
  declarations: readonly string[],
@@ -35,7 +43,7 @@ async function resolveOne(
35
43
  ): Promise<readonly string[]> {
36
44
  const source = parseExtensionSource(declaration);
37
45
  if (source.kind === "path") {
38
- const path = resolveDeclaration(declaration, options.programFile);
46
+ const path = resolveDeclaration(declaration, options);
39
47
  await requireExists(declaration, path);
40
48
  return [path];
41
49
  }
@@ -45,12 +53,13 @@ async function resolveOne(
45
53
  return installed;
46
54
  }
47
55
 
48
- function resolveDeclaration(declaration: string, programFile: string | undefined): string {
56
+ function resolveDeclaration(declaration: string, options: ExtensionResolutionOptions): string {
49
57
  if (isAbsolute(declaration)) return declaration;
50
- if (programFile === undefined) {
58
+ if (options.baseDirectory !== undefined) return resolve(options.baseDirectory, declaration);
59
+ if (options.programFile === undefined) {
51
60
  throw new Error(`relative extension "${declaration}" requires RunOptions.programFile`);
52
61
  }
53
- return resolve(dirname(programFile), declaration);
62
+ return resolve(dirname(options.programFile), declaration);
54
63
  }
55
64
 
56
65
  async function requireExists(declaration: string, path: string): Promise<void> {
package/src/index.ts CHANGED
@@ -18,6 +18,18 @@ export {
18
18
  replayTransport,
19
19
  resumeTransport,
20
20
  } from "./cassette/index.ts";
21
+ export type {
22
+ ConfigEnvironment,
23
+ ConfigExtension,
24
+ ConfigLayerName,
25
+ EffectiveConfig,
26
+ EffectiveConfigRequest,
27
+ } from "./config/index.ts";
28
+ export {
29
+ EMPTY_EFFECTIVE_CONFIG,
30
+ loadEffectiveConfig,
31
+ PROJECT_CONFIG_DIR,
32
+ } from "./config/index.ts";
21
33
  export type {
22
34
  AskInvalidOutputOutcome,
23
35
  AskLimitKind,
package/src/run/run.ts CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  resumeTransport,
25
25
  writeRecordingDiagnostic,
26
26
  } from "../cassette/index.ts";
27
+ import { type EffectiveConfig, EMPTY_EFFECTIVE_CONFIG } from "../config/index.ts";
27
28
  import { isYaagError, YaagError } from "../errors.ts";
28
29
  import type { EventSink, LifecycleEvent, RunOutcome, StampedEventSink } from "../events.ts";
29
30
  import { applyEvent, initialSummary, type RunSummary } from "../summary/index.ts";
@@ -69,6 +70,12 @@ export interface RunOptions {
69
70
  readonly signal?: AbortSignal;
70
71
  /** Overrides the default checkpoint directory a stopped Run publishes into (ADR-0021). */
71
72
  readonly checkpointDir?: string;
73
+ /**
74
+ * Effective Config for this Run, read once by the launcher (ADR-0040). The
75
+ * Run never loads it itself: omission runs config-free, so no Run picks up a
76
+ * config it was not given, and every test stays hermetic.
77
+ */
78
+ readonly config?: EffectiveConfig;
72
79
  }
73
80
 
74
81
  /**
@@ -152,6 +159,7 @@ export async function executeRun<Args, Result>(
152
159
  emit,
153
160
  sessionDir: options.sessionDir,
154
161
  programFile: options.programFile,
162
+ configExtensions: options.config?.extensions ?? EMPTY_EFFECTIVE_CONFIG.extensions,
155
163
  });
156
164
 
157
165
  const ctx: RunContext<Args> = { args, spawn: spawnGate.spawn };
@@ -186,6 +186,11 @@ export interface OpenOptions {
186
186
  readonly resolvedSkillPaths?: readonly string[];
187
187
  /** Absolute launch-ready extension paths resolved above the seam, emitted as repeated `-e` flags. */
188
188
  readonly resolvedExtensionPaths?: readonly string[];
189
+ /**
190
+ * Declared extensions in final concatenation order, unresolved (ADR-0040).
191
+ * Spawn identity only: it never becomes argv, and it is absent when empty.
192
+ */
193
+ readonly declaredExtensions?: readonly string[];
189
194
  /** Session storage directory. Used by the e2e suite to stay out of ~/.pi (ticket 06). */
190
195
  readonly sessionDir?: string;
191
196
  /** Resumes an existing pi session, translated to `--session <path>`. */
package/src/types.ts CHANGED
@@ -38,6 +38,11 @@ export interface SpawnOptions {
38
38
  * resolve from the Orchestration Program file; each becomes `pi -e <path>`.
39
39
  */
40
40
  readonly extensions?: readonly string[];
41
+ /**
42
+ * Load the Effective Config's `agents.extensions` for this Agent. Default
43
+ * true; false spawns with only the extensions this call declares (ADR-0040).
44
+ */
45
+ readonly configExtensions?: boolean;
41
46
  /**
42
47
  * Tool allowlist layered over the ADR-0026 baseline; omission is tool-free when hermetic.
43
48
  * `disallowedTools` applies last. A non-empty surviving allowlist is verified after startup;