@sous-io/sous 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Scaffolding a project's `.sous/` directory, which is what `sous init` does.
3
+ *
4
+ * The scaffold is planned in memory first, checked against what is already on
5
+ * disk, and only then written. Once written, the config is read back through
6
+ * the very loader every command uses, so a scaffold sous itself cannot load is
7
+ * never reported as a success.
8
+ *
9
+ * It refuses to touch a project that is already set up: a `.sous/` directory
10
+ * holding a primary config is left exactly as it is, and so is any file the
11
+ * scaffold would otherwise write. The one file it merges rather than replaces
12
+ * is `.sous/.gitignore`, whose sous-managed block is applied by the same writer
13
+ * `sous repo link` uses, so running the scaffold over an existing ignore file
14
+ * never duplicates an entry.
15
+ */
16
+
17
+ import fs from "node:fs";
18
+ import path from "node:path";
19
+ import { ConfigError } from "../errors.js";
20
+ import {
21
+ CONFIG_FILE_NAMES,
22
+ ENV_DEFAULTS_NAME,
23
+ ENV_LOCAL_NAME,
24
+ SOUS_DIR_NAME,
25
+ findConfigInSousDir,
26
+ resolveConfigFlag,
27
+ } from "../config-discovery.js";
28
+ import { applyManagedIgnoreBlock } from "../repos/links.js";
29
+ import { loadSettings } from "../settings.js";
30
+ import {
31
+ PROJECT_CONFIG_FORMATS,
32
+ STARTER_PROMPT_RELATIVE_PATH,
33
+ buildConfigJs,
34
+ buildConfigJson,
35
+ buildEnvDefaults,
36
+ buildEnvLocalExample,
37
+ buildStarterPrompt,
38
+ type ProjectConfigFormat,
39
+ type ProjectScaffoldContext,
40
+ } from "./templates.js";
41
+
42
+ export * from "./templates.js";
43
+
44
+ /** What to scaffold, and where. */
45
+ export type ProjectScaffoldOptions = {
46
+ /**
47
+ * Absolute path to the `.sous/` directory to create. Its parent is the
48
+ * project root, which is where the starter prompt compiles to.
49
+ */
50
+ sousDir: string;
51
+ /** Which config format to write. Defaults to the first of `PROJECT_CONFIG_FORMATS`. */
52
+ format?: ProjectConfigFormat;
53
+ /** The project's display name. Defaults to the project root's own directory name. */
54
+ name?: string;
55
+ /** Work out every file, check the target, but write nothing. */
56
+ dryRun?: boolean;
57
+ /** The version of sous doing the scaffolding, named in the generated files. */
58
+ sousVersion: string;
59
+ };
60
+
61
+ /** What a scaffold produced. */
62
+ export type ProjectScaffoldResult = {
63
+ /** The `.sous/` directory the scaffold was written into. */
64
+ sousDir: string;
65
+ /** The project root: the parent of `sousDir`. */
66
+ projectRoot: string;
67
+ /** Absolute path of the primary config that was written. */
68
+ configPath: string;
69
+ /** The format the config was written in. */
70
+ format: ProjectConfigFormat;
71
+ /** The display name written into the config. */
72
+ name: string;
73
+ /** Paths of every file written, relative to the project root, in the order written. */
74
+ files: string[];
75
+ /** True when nothing was actually written. */
76
+ dryRun: boolean;
77
+ };
78
+
79
+ /** One planned file: where it goes, and what goes in it. */
80
+ type PlannedFile = {
81
+ /** Path relative to the project root. */
82
+ relativePath: string;
83
+ /** The complete file contents. */
84
+ contents: string;
85
+ /**
86
+ * True for a file whose existing contents were merged into `contents`
87
+ * rather than a file the scaffold refuses to write over.
88
+ */
89
+ merged?: boolean;
90
+ };
91
+
92
+ /** The file name a config of the given format is written under. */
93
+ export function configFileNameFor(format: ProjectConfigFormat): string {
94
+ const name = `sous.config.${format}`;
95
+ /* c8 ignore next 5 */
96
+ if (!(CONFIG_FILE_NAMES as readonly string[]).includes(name)) {
97
+ throw new ConfigError(
98
+ `sous cannot write a '${format}' config: discovery does not recognize ${name}.`
99
+ );
100
+ }
101
+ return name;
102
+ }
103
+
104
+ /**
105
+ * Creates a project's `.sous/` directory: a primary config, the starter prompt
106
+ * it compiles, the two answers files, and the sous-managed block in
107
+ * `.sous/.gitignore`.
108
+ *
109
+ * @param options - What to scaffold, and where.
110
+ * @throws ConfigError when the target already holds a primary config, or any
111
+ * other file the scaffold would write, or when the written config does not
112
+ * load.
113
+ */
114
+ export async function scaffoldProject(
115
+ options: ProjectScaffoldOptions
116
+ ): Promise<ProjectScaffoldResult> {
117
+ const sousDir = path.resolve(options.sousDir);
118
+ const projectRoot = path.dirname(sousDir);
119
+ const format = options.format ?? PROJECT_CONFIG_FORMATS[0];
120
+ const name = (options.name ?? path.basename(projectRoot)).trim() || path.basename(projectRoot);
121
+ const dryRun = options.dryRun === true;
122
+
123
+ assertNoExistingConfig(sousDir);
124
+
125
+ const context: ProjectScaffoldContext = { name, sousVersion: options.sousVersion };
126
+ const files = planFiles(sousDir, projectRoot, format, context);
127
+
128
+ assertNothingWouldBeOverwritten(projectRoot, files);
129
+
130
+ const configPath = path.join(sousDir, configFileNameFor(format));
131
+
132
+ if (!dryRun) {
133
+ for (const file of files) {
134
+ const target = path.join(projectRoot, file.relativePath);
135
+ fs.mkdirSync(path.dirname(target), { recursive: true });
136
+ fs.writeFileSync(target, file.contents, "utf8");
137
+ }
138
+ await verifyScaffold(configPath, projectRoot);
139
+ }
140
+
141
+ return {
142
+ sousDir,
143
+ projectRoot,
144
+ configPath,
145
+ format,
146
+ name,
147
+ files: files.map((file) => file.relativePath),
148
+ dryRun,
149
+ };
150
+ }
151
+
152
+ /** Builds every file the scaffold writes, in the order they are written. */
153
+ function planFiles(
154
+ sousDir: string,
155
+ projectRoot: string,
156
+ format: ProjectConfigFormat,
157
+ context: ProjectScaffoldContext
158
+ ): PlannedFile[] {
159
+ const inSousDir = (name: string): string =>
160
+ path.relative(projectRoot, path.join(sousDir, name));
161
+
162
+ const gitignorePath = path.join(sousDir, ".gitignore");
163
+ const existingIgnore = fs.existsSync(gitignorePath)
164
+ ? fs.readFileSync(gitignorePath, "utf8")
165
+ : undefined;
166
+
167
+ return [
168
+ {
169
+ relativePath: inSousDir(configFileNameFor(format)),
170
+ contents: format === "json" ? buildConfigJson(context) : buildConfigJs(context),
171
+ },
172
+ {
173
+ relativePath: inSousDir(STARTER_PROMPT_RELATIVE_PATH),
174
+ contents: buildStarterPrompt(context),
175
+ },
176
+ { relativePath: inSousDir(ENV_DEFAULTS_NAME), contents: buildEnvDefaults(context) },
177
+ {
178
+ relativePath: inSousDir(`${ENV_LOCAL_NAME}.example`),
179
+ contents: buildEnvLocalExample(context),
180
+ },
181
+ {
182
+ relativePath: inSousDir(".gitignore"),
183
+ contents: applyManagedIgnoreBlock(existingIgnore, gitignorePath),
184
+ merged: true,
185
+ },
186
+ ];
187
+ }
188
+
189
+ /**
190
+ * Refuses to scaffold a `.sous/` directory that already holds a primary
191
+ * config. The config is what makes a directory a sous project, so that is what
192
+ * is checked; a `.sous/` holding only, say, task files is a fine place to set
193
+ * one up.
194
+ */
195
+ function assertNoExistingConfig(sousDir: string): void {
196
+ const existing = findConfigInSousDir(sousDir);
197
+ if (existing !== null) {
198
+ throw new ConfigError(
199
+ `${path.dirname(sousDir)} is already set up for sous.\n` +
200
+ ` ${existing} exists, and sous will not overwrite it.\n` +
201
+ ` Edit that config instead, or run 'sous init' in another directory.`
202
+ );
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Refuses to write over any file the scaffold produces, so a run that fails
208
+ * here has changed nothing. The merged ignore file is exempt: its existing
209
+ * lines are carried into what is written.
210
+ */
211
+ function assertNothingWouldBeOverwritten(projectRoot: string, files: PlannedFile[]): void {
212
+ const clashes = files
213
+ .filter((file) => file.merged !== true)
214
+ .map((file) => path.join(projectRoot, file.relativePath))
215
+ .filter((target) => fs.existsSync(target));
216
+
217
+ if (clashes.length === 0) return;
218
+
219
+ throw new ConfigError(
220
+ `${projectRoot} already holds ${clashes.length === 1 ? "a file" : "files"} that ` +
221
+ `'sous init' would write, and sous will not overwrite ${clashes.length === 1 ? "it" : "them"}:\n` +
222
+ clashes.map((target) => ` ${target}`).join("\n") +
223
+ `\n Move ${clashes.length === 1 ? "it" : "them"} aside, or run 'sous init' in another directory.`
224
+ );
225
+ }
226
+
227
+ /**
228
+ * Loads the written config through the same loader every command uses, so
229
+ * the scaffold is never reported as a success unless sous can actually read
230
+ * it. A failure here is a bug in the scaffold, and is reported as one.
231
+ */
232
+ async function verifyScaffold(configPath: string, projectRoot: string): Promise<void> {
233
+ try {
234
+ await loadSettings(resolveConfigFlag(configPath, projectRoot));
235
+ } catch (error) {
236
+ const reason = error instanceof Error ? error.message : String(error);
237
+ throw new ConfigError(
238
+ `The config 'sous init' wrote at ${configPath} does not load.\n` +
239
+ ` This is a bug in sous; please report it. The loader said:\n` +
240
+ `${reason
241
+ .split("\n")
242
+ .map((line) => ` ${line}`)
243
+ .join("\n")}`
244
+ );
245
+ }
246
+ }
247
+
248
+ /** The `.sous/` directory a project root would hold. */
249
+ export function sousDirFor(projectRoot: string): string {
250
+ return path.join(path.resolve(projectRoot), SOUS_DIR_NAME);
251
+ }
@@ -0,0 +1,230 @@
1
+ /**
2
+ * The files `sous init` writes, as plain string builders.
3
+ *
4
+ * As with the repository scaffold, these are deliberately not templates run
5
+ * through a template engine. A scaffolded project is read by a person before
6
+ * it is read by a machine, and the comments are what make the first config
7
+ * legible; a rendering step would only stand between the author of the
8
+ * scaffold and the person who has to live with the result.
9
+ *
10
+ * Every builder returns a complete file, ending in a newline. The JSON config
11
+ * is the one file that cannot carry a comment: the config schema is strict and
12
+ * accepts `$schema` but no comment key, so what the JS variant explains in
13
+ * comments the JSON variant leaves to the documentation.
14
+ */
15
+
16
+ import {
17
+ CONFD_DIR_NAME,
18
+ ENV_DEFAULTS_NAME,
19
+ ENV_LOCAL_NAME,
20
+ SOUS_DIR_NAME,
21
+ } from "../config-discovery.js";
22
+
23
+ /** The config formats `sous init` can write. The first one is the default. */
24
+ export const PROJECT_CONFIG_FORMATS = ["js", "json"] as const;
25
+
26
+ /** One of the config formats `sous init` can write. */
27
+ export type ProjectConfigFormat = (typeof PROJECT_CONFIG_FORMATS)[number];
28
+
29
+ /**
30
+ * The path, relative to `.sous/`, of the starter prompt the scaffolded config
31
+ * compiles. It lives under `prompts/` so the source and its compiled output
32
+ * (`AGENTS.md` at the project root) are never mistaken for one another.
33
+ */
34
+ export const STARTER_PROMPT_RELATIVE_PATH = "prompts/AGENTS.md";
35
+
36
+ /** The name of the file the starter prompt is compiled into, at the project root. */
37
+ export const STARTER_OUTPUT_NAME = "AGENTS.md";
38
+
39
+ /** What every builder needs to know about the project being scaffolded. */
40
+ export type ProjectScaffoldContext = {
41
+ /** The project's display name, written into the config's `name`. */
42
+ name: string;
43
+ /** The version of sous doing the scaffolding, named in the generated files. */
44
+ sousVersion: string;
45
+ };
46
+
47
+ /**
48
+ * Where the JSON Schema for a config of this sous version is published. The
49
+ * schema ships inside the package too, but an editor resolving `$schema` wants
50
+ * a URL, and the tagged copy on GitHub is the one that matches this version
51
+ * for as long as the tag exists.
52
+ *
53
+ * @param sousVersion - The running sous version.
54
+ */
55
+ export function configSchemaUrl(sousVersion: string): string {
56
+ return `https://raw.githubusercontent.com/sous-io/sous/v${sousVersion}/sous.config.schema.json`;
57
+ }
58
+
59
+ /**
60
+ * The primary config as a JavaScript module, commented so a first-time reader
61
+ * can see what each block is for without opening the documentation.
62
+ *
63
+ * @param context - The project being scaffolded.
64
+ */
65
+ export function buildConfigJs(context: ProjectScaffoldContext): string {
66
+ return `// The primary sous config for ${context.name}.
67
+ //
68
+ // sous finds this file by walking up from the directory it was run in until it
69
+ // meets a \`${SOUS_DIR_NAME}/\` directory holding a config. Everything a build needs
70
+ // starts here. Drop-in layers under \`${SOUS_DIR_NAME}/${CONFD_DIR_NAME}/\` merge over it, and
71
+ // the layers sous writes for itself go into that directory, never into this
72
+ // file. Written by \`sous init\` (sous ${context.sousVersion}); it is yours now.
73
+
74
+ export const config = {
75
+ // Shown in command output. Any string.
76
+ name: ${JSON.stringify(context.name)},
77
+
78
+ // Variables for the rest of this file. \`\${sousDir}\` is this \`${SOUS_DIR_NAME}/\` directory,
79
+ // injected by sous, so nothing here depends on where the project is checked
80
+ // out. Reference a variable anywhere below as \`\${name}\`.
81
+ _vars: {
82
+ projectRoot: "\${sousDir}/..",
83
+ },
84
+
85
+ // What to compile. Each target reads one source and writes it somewhere. The
86
+ // starter target renders \`${SOUS_DIR_NAME}/${STARTER_PROMPT_RELATIVE_PATH}\` to the project root.
87
+ // A line holding only \`@path/to/file.md\` in a source pulls that file in, and a
88
+ // \`.tpl.\` in a file name turns Liquid templating on for it. Compiled files
89
+ // are build output: ignore them or commit them, as your team prefers.
90
+ compilation: {
91
+ targets: [
92
+ {
93
+ entryPoint: "\${sousDir}/${STARTER_PROMPT_RELATIVE_PATH}",
94
+ outputs: [{ destinationFile: "\${projectRoot}/${STARTER_OUTPUT_NAME}" }],
95
+ },
96
+ ],
97
+ },
98
+
99
+ // Where the recipes this project subscribes to write their files. Every
100
+ // project subscribes to the \`core\` namespace on its own, which is how the
101
+ // skills that teach an agent about sous reach \`.claude/skills\`. Memories and
102
+ // prompts have no default home; name one to receive them.
103
+ recipeOutputs: {
104
+ skills: ["\${projectRoot}/.claude/skills"],
105
+ // memories: ["\${projectRoot}/.claude/memories"],
106
+ // prompts: ["\${projectRoot}/.claude/prompts"],
107
+ },
108
+ };
109
+ `;
110
+ }
111
+
112
+ /**
113
+ * The same primary config as strict JSON, bound to the shipped schema through
114
+ * `$schema` so an editor can validate and complete it.
115
+ *
116
+ * @param context - The project being scaffolded.
117
+ */
118
+ export function buildConfigJson(context: ProjectScaffoldContext): string {
119
+ const config = {
120
+ $schema: configSchemaUrl(context.sousVersion),
121
+ name: context.name,
122
+ _vars: {
123
+ projectRoot: "${sousDir}/..",
124
+ },
125
+ compilation: {
126
+ targets: [
127
+ {
128
+ entryPoint: `\${sousDir}/${STARTER_PROMPT_RELATIVE_PATH}`,
129
+ outputs: [{ destinationFile: `\${projectRoot}/${STARTER_OUTPUT_NAME}` }],
130
+ },
131
+ ],
132
+ },
133
+ recipeOutputs: {
134
+ skills: ["${projectRoot}/.claude/skills"],
135
+ },
136
+ };
137
+ return `${JSON.stringify(config, null, 2)}\n`;
138
+ }
139
+
140
+ /**
141
+ * The starter prompt the config compiles: a short, real instruction file, so
142
+ * the first build produces something worth reading rather than a placeholder.
143
+ *
144
+ * @param context - The project being scaffolded.
145
+ */
146
+ export function buildStarterPrompt(context: ProjectScaffoldContext): string {
147
+ return `# ${context.name}
148
+
149
+ This file is compiled by sous into \`${STARTER_OUTPUT_NAME}\` at the project root. Edit this
150
+ source, run \`sous build\`, and the compiled copy follows; never edit the compiled copy.
151
+
152
+ ## Working in this project
153
+
154
+ - Describe the project here: what it is, how it is built, and how it is tested.
155
+ - Split long sections into their own files under \`${SOUS_DIR_NAME}/prompts/\` and pull
156
+ each one in with a line holding only \`@sections/name.md\`.
157
+ - The skills under \`.claude/skills\` are written by sous from the recipes this
158
+ project subscribes to. \`sous recipe list\` shows what is available.
159
+ `;
160
+ }
161
+
162
+ /**
163
+ * The committed answers file: the layer for what the whole team shares.
164
+ *
165
+ * @param context - The project being scaffolded.
166
+ */
167
+ export function buildEnvDefaults(context: ProjectScaffoldContext): string {
168
+ return `# ${SOUS_DIR_NAME}/${ENV_DEFAULTS_NAME}
169
+ #
170
+ # Answers to recipe variables that the whole team shares. Commit this file.
171
+ #
172
+ # A recipe this project subscribes to may ask questions (a board name, a
173
+ # ticket prefix, where task files go), and the answers live here as
174
+ # \`KEY=VALUE\` lines. \`sous vars ask\` writes them one question at a time, and
175
+ # \`sous vars list\` shows every variable in play with where its answer came from.
176
+ #
177
+ # Never put a secret, a login or a machine-specific path here. Those belong in
178
+ # \`${SOUS_DIR_NAME}/${ENV_LOCAL_NAME}\`, which is gitignored and overrides this file
179
+ # key for key. Precedence, highest first: your shell, then \`${ENV_LOCAL_NAME}\`,
180
+ # then this file.
181
+ #
182
+ # Written by \`sous init\` (sous ${context.sousVersion}).
183
+ `;
184
+ }
185
+
186
+ /**
187
+ * The example for the gitignored answers file, explaining what belongs in the
188
+ * local layer and how to start one.
189
+ *
190
+ * @param context - The project being scaffolded.
191
+ */
192
+ export function buildEnvLocalExample(context: ProjectScaffoldContext): string {
193
+ return `# ${SOUS_DIR_NAME}/${ENV_LOCAL_NAME}.example
194
+ #
195
+ # Copy this file to \`${SOUS_DIR_NAME}/${ENV_LOCAL_NAME}\` and fill in your own values:
196
+ #
197
+ # cp ${SOUS_DIR_NAME}/${ENV_LOCAL_NAME}.example ${SOUS_DIR_NAME}/${ENV_LOCAL_NAME}
198
+ #
199
+ # ...or let sous write it for you, one question at a time:
200
+ #
201
+ # sous vars ask
202
+ #
203
+ # \`${SOUS_DIR_NAME}/${ENV_LOCAL_NAME}\` is gitignored. It is the layer for anything that
204
+ # differs between people or machines, or must not be committed: your own login
205
+ # and name, absolute paths outside the repository, API tokens.
206
+ #
207
+ # The COMMITTED layer is \`${SOUS_DIR_NAME}/${ENV_DEFAULTS_NAME}\`. Same syntax, but it holds
208
+ # the answers the whole team shares. Never put a secret there.
209
+ #
210
+ # How both files are loaded:
211
+ # - \`KEY=VALUE\` per line. \`#\` starts a comment. \`export KEY=VALUE\` also works.
212
+ # - Loaded into the environment before the config resolves any variables.
213
+ # - Precedence, highest first: your shell, then \`${ENV_LOCAL_NAME}\`, then \`${ENV_DEFAULTS_NAME}\`.
214
+ # So \`FOO=bar sous build\` overrides \`FOO\` from either file for that one run,
215
+ # and a key in \`${ENV_LOCAL_NAME}\` overrides the same key in \`${ENV_DEFAULTS_NAME}\`.
216
+ # - A recipe's variable answers reach its templates on their own. \`sous vars
217
+ # list\` shows each one, with the environment variable that supplied it.
218
+ # - The config's own \`_env\` block still works for anything a recipe does not
219
+ # ask about: \`_env: { myPath: "MY_PATH" }\` makes \`\${myPath}\` available
220
+ # throughout the config from \`MY_PATH\` in either file.
221
+ #
222
+ # NOT here: the config-location variables \`SOUS_CONFIG\`, \`SOUS_DIR\` and
223
+ # \`SOUS_CONFD\`. Those decide where sous looks for \`${SOUS_DIR_NAME}/\`, so they are
224
+ # read from the real shell environment only; this file is not found until
225
+ # discovery has already run.
226
+ #
227
+ # Written by \`sous init\` (sous ${context.sousVersion}). Add a line per answer below,
228
+ # with a comment saying what it is for.
229
+ `;
230
+ }
@@ -49,12 +49,14 @@ export const IGNORE_BLOCK_END = "# <<< sous managed";
49
49
  /**
50
50
  * The entries sous keeps inside its managed block in `.sous/.gitignore`. All of
51
51
  * them are machine-local: the links map, the build state file, the watcher's
52
- * PID file, and the directory linked checkouts are cloned into.
52
+ * PID file, the local answers file, and the directory linked checkouts are
53
+ * cloned into.
53
54
  */
54
55
  export const IGNORE_BLOCK_ENTRIES = [
55
56
  LINKS_FILENAME,
56
57
  "sous.state.json",
57
58
  "sous.pid",
59
+ ".env.local",
58
60
  `${REPOS_DIRNAME}/`,
59
61
  ] as const;
60
62
 
@@ -52,6 +52,12 @@ export type RecipeTargetOptions = {
52
52
  settings: Settings;
53
53
  /** The resolved settings scope, used to substitute `${var}` in destinations. */
54
54
  scope?: VarScope;
55
+ /**
56
+ * The scope one recipe's own files render with. A recipe's answers to its own
57
+ * questions are laid over the project scope there, so a recipe sees its own
58
+ * answer even when another recipe asks the same name. Defaults to `scope`.
59
+ */
60
+ scopeFor?: (recipe: LockedRecipeLocation) => VarScope;
55
61
  /** The environment to read; decides where the store is. */
56
62
  env?: NodeJS.ProcessEnv;
57
63
  /** The locked recipes, when the caller has already located them. */
@@ -167,6 +173,8 @@ export function buildRecipeTargets(options: RecipeTargetOptions): RecipeTargets
167
173
  for (const destination of kindDestinations) destinations.add(destination);
168
174
  if (recipe.linked) watchDirs.add(recipe.dir);
169
175
 
176
+ const recipeScope = options.scopeFor?.(recipe) ?? options.scope ?? {};
177
+
170
178
  const ignore = (content.exclude ?? []).map((pattern) =>
171
179
  path.join(recipe.dir, pattern)
172
180
  );
@@ -182,7 +190,7 @@ export function buildRecipeTargets(options: RecipeTargetOptions): RecipeTargets
182
190
  globBase,
183
191
  outputs: kindDestinations.map((destination) => ({
184
192
  destinationDir: destination,
185
- vars: options.scope ?? {},
193
+ vars: recipeScope,
186
194
  })),
187
195
  });
188
196
  }
@@ -18,6 +18,11 @@ import { resolveSousHome } from "./sous-home.js";
18
18
  import { validateSettings } from "./config-schema.js";
19
19
  import { applyRepoDefaults } from "./repos/defaults.js";
20
20
  import type { RecipeConfigLayer } from "./repos/recipe-config-layers.js";
21
+ import {
22
+ answersForRecipe,
23
+ resolveRecipeAnswers,
24
+ type RecipeAnswers,
25
+ } from "./vars/answers.js";
21
26
  import { warning } from "../utils/formatting.js";
22
27
 
23
28
  // Re-exported for backwards compatibility: ConfigError moved to ./errors.ts so
@@ -757,20 +762,63 @@ export function resolveEnvScope(settings: Settings, context?: ConfigContext): Va
757
762
  return scope;
758
763
  }
759
764
 
765
+ /** What else a root scope may be built from; see resolveRootScope. */
766
+ export type RootScopeOptions = {
767
+ /**
768
+ * The recipe answers already resolved for this project. When omitted and a
769
+ * config context is given, they are resolved here; pass them when a caller
770
+ * has them already, so the lockfile and the manifests are read once.
771
+ */
772
+ answers?: RecipeAnswers;
773
+ /**
774
+ * The recipe whose own files this scope renders, as `namespace/recipe`. Its
775
+ * own answers are laid over the merged view, so a recipe sees the answer to
776
+ * its own question even when another recipe asks the same name.
777
+ */
778
+ recipe?: string;
779
+ };
780
+
760
781
  /**
761
782
  * Resolves the root-level _vars from a Settings object into a scope.
762
- * Chains: auto-vars → env scope → root _vars.
783
+ * Chains: auto-vars → recipe answers → env scope → root _vars.
784
+ *
785
+ * The recipe answers are the values the project's env files and shell hold for
786
+ * every variable its subscribed recipes publish, found through the ladder in
787
+ * `vars/ladder.ts`. They sit under `_env` and `_vars`, so an explicit config
788
+ * value always wins, and they are present only when a config context says which
789
+ * project this is; a settings object built by hand in a test has none.
763
790
  *
764
791
  * @param settings - The root settings object.
765
792
  * @param context - The discovered config location (optional in tests).
793
+ * @param options - Answers already resolved, or the recipe the scope is for.
766
794
  */
767
- export function resolveRootScope(settings: Settings, context?: ConfigContext): VarScope {
795
+ export function resolveRootScope(
796
+ settings: Settings,
797
+ context?: ConfigContext,
798
+ options: RootScopeOptions = {}
799
+ ): VarScope {
768
800
  const autoVars = buildAutoVars(context);
801
+ const answers = resolveAnswerLayer(settings, context, options);
769
802
  const envScope = resolveEnvScope(settings, context);
770
- const baseScope = { ...autoVars, ...envScope };
803
+ const baseScope = { ...autoVars, ...answers, ...envScope };
771
804
  return resolveScope(settings._vars ?? {}, baseScope);
772
805
  }
773
806
 
807
+ /**
808
+ * The recipe-answer layer of a root scope: the merged view, or a recipe's own
809
+ * view of it. Empty without a config context.
810
+ */
811
+ function resolveAnswerLayer(
812
+ settings: Settings,
813
+ context: ConfigContext | undefined,
814
+ options: RootScopeOptions
815
+ ): VarScope {
816
+ if (context === undefined) return {};
817
+ const answers =
818
+ options.answers ?? resolveRecipeAnswers({ settings, sousDir: context.sousDir });
819
+ return options.recipe === undefined ? answers.merged : answersForRecipe(answers, options.recipe);
820
+ }
821
+
774
822
  /**
775
823
  * Built-in `@include` aliases, always available and reserved (their names begin
776
824
  * with `~` so user `_aliases` can never shadow them). Add new entries here as