@sous-io/sous 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +154 -0
  3. package/bin/run.js +17 -0
  4. package/bin/xcv +5 -0
  5. package/package.json +81 -0
  6. package/shared-prompts/_partials/resume-task.md +51 -0
  7. package/shared-prompts/_partials/sub-agent-delegation.md +32 -0
  8. package/shared-prompts/_partials/update-task-file.md +52 -0
  9. package/shared-prompts/memories/automated-browser-tasks/INDEX.tpl.md +52 -0
  10. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/SKILL.tpl.md +102 -0
  11. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/auth-failure-handling.mjs +81 -0
  12. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/chained-workflow.mjs +126 -0
  13. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/simple-fetch.mjs +92 -0
  14. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/architecture.md +61 -0
  15. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/auth-and-sessions.md +65 -0
  16. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/ctx-api.md +96 -0
  17. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/installation.md +104 -0
  18. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/script-conventions.md +243 -0
  19. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/chrome-state.mjs +148 -0
  20. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.mjs +383 -0
  21. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.spec.mjs +267 -0
  22. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/eslint.config.mjs +56 -0
  23. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/harness.mjs +169 -0
  24. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/keyring.mjs +59 -0
  25. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/logger.mjs +25 -0
  26. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/params.mjs +140 -0
  27. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/run.mjs +140 -0
  28. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/settings.tpl.mjs +1 -0
  29. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/utils.mjs +185 -0
  30. package/shared-prompts/skills/automated-browser-tasks/create-automated-browser-task/SKILL.tpl.md +52 -0
  31. package/shared-prompts/skills/automated-browser-tasks/running-automated-browser-tasks/SKILL.tpl.md +59 -0
  32. package/shared-prompts/skills/automated-browser-tasks/update-automated-browser-task/SKILL.tpl.md +47 -0
  33. package/shared-prompts/skills/control-flow/approve/SKILL.tpl.md +26 -0
  34. package/shared-prompts/skills/control-flow/opine/SKILL.tpl.md +58 -0
  35. package/shared-prompts/skills/control-flow/repeat/SKILL.tpl.md +27 -0
  36. package/shared-prompts/skills/control-flow/research/SKILL.tpl.md +34 -0
  37. package/shared-prompts/skills/sous-skills/about-agent-skills/SKILL.tpl.md +177 -0
  38. package/shared-prompts/skills/sous-skills/about-agent-skills/examples/about-something.md +45 -0
  39. package/shared-prompts/skills/sous-skills/about-agent-skills/examples/do-something.md +33 -0
  40. package/shared-prompts/skills/sous-skills/about-agent-skills/references/advanced-patterns.md +87 -0
  41. package/shared-prompts/skills/sous-skills/about-agent-skills/references/commands.md +46 -0
  42. package/shared-prompts/skills/sous-skills/about-agent-skills/references/frontmatter.md +25 -0
  43. package/shared-prompts/skills/sous-skills/about-agent-skills/references/substitutions.md +50 -0
  44. package/shared-prompts/skills/sous-skills/about-liquid-templates/SKILL.tpl.md +268 -0
  45. package/shared-prompts/skills/sous-skills/about-liquid-templates/references/liquid-filters.md +82 -0
  46. package/shared-prompts/skills/sous-skills/about-sous/SKILL.tpl.md +51 -0
  47. package/shared-prompts/skills/sous-skills/create-skill/SKILL.tpl.md +114 -0
  48. package/shared-prompts/skills/task-files/about-task-files/SKILL.tpl.md +122 -0
  49. package/shared-prompts/skills/task-files/continue-task-in-new-branch/SKILL.tpl.md +80 -0
  50. package/shared-prompts/skills/task-files/go/SKILL.tpl.md +14 -0
  51. package/shared-prompts/skills/task-files/resume-task/SKILL.tpl.md +13 -0
  52. package/shared-prompts/skills/task-files/start-task/SKILL.tpl.md +93 -0
  53. package/shared-prompts/skills/task-files/update/SKILL.tpl.md +14 -0
  54. package/shared-prompts/skills/task-files/update-task-file/SKILL.tpl.md +13 -0
  55. package/src/base-command.ts +163 -0
  56. package/src/commands/build.ts +196 -0
  57. package/src/commands/clear.ts +71 -0
  58. package/src/commands/compile.ts +95 -0
  59. package/src/commands/launch.ts +111 -0
  60. package/src/commands/prune.ts +48 -0
  61. package/src/lib/build-service.ts +258 -0
  62. package/src/lib/config-discovery.ts +199 -0
  63. package/src/lib/env-local.ts +195 -0
  64. package/src/lib/include-resolver.ts +146 -0
  65. package/src/lib/markdown-compiler.ts +580 -0
  66. package/src/lib/pid-service.ts +88 -0
  67. package/src/lib/settings.ts +695 -0
  68. package/src/lib/state.ts +135 -0
  69. package/src/lib/watch-service.ts +115 -0
  70. package/src/templating/filters/bullet-list.ts +9 -0
  71. package/src/templating/filters/index.ts +8 -0
  72. package/src/templating/init-liquid-engine.ts +82 -0
  73. package/src/templating/lib/glob-files.ts +74 -0
  74. package/src/templating/lib/import-export.ts +32 -0
  75. package/src/templating/lib/tag-args.ts +19 -0
  76. package/src/templating/tags/exportScalarVarsJs.ts +43 -0
  77. package/src/templating/tags/getFiles.ts +89 -0
  78. package/src/templating/tags/index.ts +14 -0
  79. package/src/templating/tags/listFiles.ts +54 -0
  80. package/src/templating/tags/showVars.ts +22 -0
  81. package/src/utils/formatting.ts +338 -0
  82. package/src/utils/prompts.ts +19 -0
@@ -0,0 +1,695 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+ import { createRequire } from "node:module";
5
+ import { fileURLToPath, pathToFileURL } from "node:url";
6
+ import { globSync } from "glob";
7
+ import { inferGlobBase, type CompilationConfig, type CompilationTarget, type ResolvedRuntimeContext } from "./markdown-compiler.js";
8
+ import { buildAliasMap, type AliasMap } from "./include-resolver.js";
9
+ import { ENV_DEFAULTS_NAME, ENV_LOCAL_NAME, SOUS_DIR_NAME } from "./config-discovery.js";
10
+ import { warning } from "../utils/formatting.js";
11
+
12
+ const __filename = fileURLToPath(import.meta.url);
13
+
14
+ /** Resolved path to the cli/ package root (two levels up from src/lib/) */
15
+ export const CLI_ROOT = path.resolve(path.dirname(__filename), "../..");
16
+
17
+ /** Version string read from package.json at module load time. */
18
+ const _pkgJson = JSON.parse(
19
+ fs.readFileSync(path.join(CLI_ROOT, "package.json"), "utf8")
20
+ ) as { version: string };
21
+ export const SOUS_VERSION: string = _pkgJson.version;
22
+
23
+ // --- Variable scope ------------------------------------------------------------------------------
24
+
25
+ /** A flat map of resolved variable names to their string values. */
26
+ export type VarScope = Record<string, string>;
27
+
28
+ // --- Raw settings types (matching settings.local.js shape) --------------------------------------
29
+
30
+ type RawOutput = {
31
+ _if?: Record<string, { eq: string }>;
32
+ _vars?: Record<string, string>;
33
+ destinationFile?: string;
34
+ destinationDir?: string;
35
+ };
36
+
37
+ type RawRuntimeContext = {
38
+ /** ${var} path to the git repo root used for branch detection. */
39
+ gitRoot: string;
40
+ /** ${var} path where the generated session-context file is written. */
41
+ outputPath: string;
42
+ /** ${var} root directory for task files; branch name is appended to locate the active file. */
43
+ taskFileRoot: string;
44
+ /** Regex pattern string; branches matching this pattern trigger task file lookup. */
45
+ branchPattern?: string;
46
+ };
47
+
48
+ type RawTarget = {
49
+ _vars?: Record<string, string>;
50
+ /** Point-to-point source file path. Exactly one of entryPoint or entryGlob must be set. */
51
+ entryPoint?: string;
52
+ /** Glob pattern expanding to multiple source files. Exactly one of entryPoint or entryGlob must be set. */
53
+ entryGlob?: string;
54
+ /**
55
+ * Explicit base directory for computing relative output paths when using destinationDir.
56
+ * Only meaningful for entryGlob targets. When omitted, inferred from the glob pattern.
57
+ */
58
+ globBase?: string;
59
+ /**
60
+ * When true, the compiler generates a runtime session context file (branch name, task file)
61
+ * alongside the entry point before compilation. Only meaningful for AGENTS-style entry points.
62
+ */
63
+ generateRuntimeContext?: boolean;
64
+ outputs: RawOutput[];
65
+ };
66
+
67
+ type RawProjectCompilation = {
68
+ _vars?: Record<string, string>;
69
+ includeSourceComments?: boolean;
70
+ targets: RawTarget[];
71
+ };
72
+
73
+ /** Configuration for a launchable tool (e.g. claude, codex). */
74
+ type ToolConfig = {
75
+ /** The executable command to run. */
76
+ command: string;
77
+ /** Arguments passed before the prompt file argument. */
78
+ args?: string[];
79
+ /**
80
+ * Path to a file whose contents are appended as the final argument to the command.
81
+ * Path is resolved through the project's var scope.
82
+ */
83
+ promptFile?: string;
84
+ };
85
+
86
+ export type RawProject = {
87
+ /**
88
+ * Project-level variables. A few names are read by Sous itself:
89
+ * `stateFilePath` overrides where the build state file is written (see
90
+ * StateService.getFilePath), and `pidFilePath` does the same for the watcher
91
+ * PID file. Both resolve through the PROJECT scope.
92
+ */
93
+ _vars?: Record<string, string>;
94
+ _aliases?: Record<string, string | string[]>;
95
+ name: string;
96
+ compilation?: RawProjectCompilation;
97
+ runtimeContext?: RawRuntimeContext;
98
+ tools?: Record<string, ToolConfig>;
99
+ };
100
+
101
+ export type Settings = {
102
+ _env?: Record<string, string>;
103
+ _vars?: Record<string, string>;
104
+ _aliases?: Record<string, string | string[]>;
105
+ defaultProject?: string;
106
+ projects: Record<string, RawProject>;
107
+ };
108
+
109
+ // --- Loader -------------------------------------------------------------------------------------
110
+
111
+ /**
112
+ * Loads settings from the given config file path.
113
+ * Supports .js / .mjs (ES module with a `config` or `default` export)
114
+ * and .json (plain JSON matching the Settings shape).
115
+ *
116
+ * A JS/MJS config is imported in a FRESH Node subprocess that serialises it to
117
+ * JSON. Two attempts are made, in this order:
118
+ *
119
+ * 1. Plain Node, no loader. This is what a normal ESM config needs, and it is
120
+ * the only thing that works for a `.sous/sous.config.js` sitting in a repo
121
+ * whose package.json has no `"type": "module"` (under the tsx loader such a
122
+ * file is treated as CJS and dies with ERR_REQUIRE_CYCLE_MODULE).
123
+ * 2. The tsx loader, so a config may use TypeScript syntax and extensionless
124
+ * relative imports.
125
+ *
126
+ * The subprocess (rather than a direct `import()`) avoids the require(esm) cycle
127
+ * that tsx triggers in the parent process. Because the result is round-tripped
128
+ * through JSON, functions, RegExp, Date and undefined values are dropped.
129
+ */
130
+ /* c8 ignore next 60 */
131
+ export async function loadSettings(configPath: string): Promise<Settings> {
132
+ if (!fs.existsSync(configPath)) {
133
+ throw new Error(`Settings file not found: ${configPath}`);
134
+ }
135
+
136
+ if (configPath.endsWith(".json")) {
137
+ try {
138
+ const raw = JSON.parse(fs.readFileSync(configPath, "utf8"));
139
+ return raw as Settings;
140
+ } catch (error) {
141
+ const message = error instanceof Error ? error.message : String(error);
142
+ throw new Error(`Failed to parse settings JSON at ${configPath}: ${message}`);
143
+ }
144
+ }
145
+
146
+ const settingsUrl = pathToFileURL(configPath).href;
147
+ const loaderScript = `
148
+ const mod = await import(${JSON.stringify(settingsUrl)});
149
+ const raw = mod.config ?? mod.default ?? mod;
150
+ process.stdout.write(JSON.stringify(raw));
151
+ `;
152
+ // Resolve tsx via module resolution so this works when npm hoists the
153
+ // dependency (local install, npx) as well as when it nests it (global,
154
+ // repo clone).
155
+ let tsxPath: string;
156
+ try {
157
+ tsxPath = createRequire(import.meta.url).resolve("tsx/esm");
158
+ } catch {
159
+ tsxPath = path.resolve(CLI_ROOT, "node_modules/tsx/dist/esm/index.cjs");
160
+ }
161
+
162
+ const attempts: { label: string; args: string[] }[] = [
163
+ { label: "node", args: ["--input-type=module"] },
164
+ { label: "tsx", args: ["--import", tsxPath, "--input-type=module"] },
165
+ ];
166
+
167
+ const failures: string[] = [];
168
+
169
+ for (const attempt of attempts) {
170
+ const result = spawnSync(process.execPath, attempt.args, {
171
+ input: loaderScript,
172
+ encoding: "utf8",
173
+ });
174
+
175
+ if (result.status === 0) {
176
+ try {
177
+ return JSON.parse(result.stdout) as Settings;
178
+ } catch {
179
+ throw new Error(
180
+ `Config at ${configPath} did not produce valid JSON. It must export a plain ` +
181
+ `object (as \`config\` or \`default\`).\n Got: ${result.stdout.slice(0, 300)}`
182
+ );
183
+ }
184
+ }
185
+
186
+ failures.push(` [via ${attempt.label}] ${result.stderr?.trim() || "unknown error"}`);
187
+ }
188
+
189
+ throw new Error(`Failed to load config from ${configPath}\n${failures.join("\n\n")}`);
190
+ }
191
+
192
+ // --- Variable Resolution -------------------------------------------------------------------------
193
+
194
+ /**
195
+ * Substitutes ${varName} references in a string using the provided scope.
196
+ * Unknown variable references are left as-is.
197
+ */
198
+ export function substituteVars(str: string, scope: VarScope): string {
199
+ return str.replace(/\$\{([^}]+)\}/g, (match, name: string) => scope[name] ?? match);
200
+ }
201
+
202
+ /**
203
+ * A user-facing configuration error: the config file (or environment) is wrong,
204
+ * not the CLI. Commands render these as a plain message with no stack trace,
205
+ * since the stack points at Sous internals and tells the user nothing.
206
+ */
207
+ export class ConfigError extends Error {
208
+ readonly isConfigError = true;
209
+
210
+ constructor(message: string) {
211
+ super(message);
212
+ this.name = "ConfigError";
213
+ }
214
+ }
215
+
216
+ /** True when the value is a ConfigError (safe across module instances). */
217
+ export function isConfigError(error: unknown): boolean {
218
+ return (
219
+ error instanceof ConfigError ||
220
+ (typeof error === "object" &&
221
+ error !== null &&
222
+ (error as { isConfigError?: boolean }).isConfigError === true)
223
+ );
224
+ }
225
+
226
+ /** Returns the names of every `${var}` reference left unresolved in a string. */
227
+ export function findUnresolvedVars(str: string): string[] {
228
+ return [...new Set([...str.matchAll(/\$\{([^}]+)\}/g)].map((m) => m[1]))];
229
+ }
230
+
231
+ /**
232
+ * Collapses `.` and `..` segments in an already-substituted absolute path.
233
+ *
234
+ * Output destinations must be normalized at resolution time, not left as written.
235
+ * A config that names a directory as `${sousDir}/..` (the natural way to reach the
236
+ * repo root from a discovered `.sous/`) otherwise produces a `destinationDir` of
237
+ * `/repo/.sous/../.claude/skills`, while the file paths Sous actually writes go
238
+ * through `path.join` and come out as `/repo/.claude/skills/...`. Prune compares
239
+ * tracked destinations against `destinationDir` by string prefix, so the two forms
240
+ * never match and prune deletes every file compile had just written.
241
+ *
242
+ * Relative values are left alone: they are resolved later against a context this
243
+ * function does not have.
244
+ *
245
+ * @param value - A substituted path from the config.
246
+ * @returns The normalized path, or `value` unchanged when it is not absolute.
247
+ */
248
+ export function normalizeConfigPath(value: string): string {
249
+ return path.isAbsolute(value) ? path.normalize(value) : value;
250
+ }
251
+
252
+ /**
253
+ * Substitutes `${varName}` references and throws when any reference cannot be
254
+ * resolved from the scope. Used for every value Sous acts on (entry points,
255
+ * destinations, prompt files, runtime-context paths) so a typo'd or missing
256
+ * variable fails loudly instead of silently producing a literal `${var}` path.
257
+ *
258
+ * @param str - The raw value from the config.
259
+ * @param scope - The resolved variable scope.
260
+ * @param context - Where the value came from, e.g.
261
+ * `project 'foundry' → compilation.targets[0].entryPoint`. Named in the error.
262
+ * @returns The fully substituted string.
263
+ * @throws When one or more `${var}` references are unresolved.
264
+ */
265
+ export function substituteVarsStrict(str: string, scope: VarScope, context: string): string {
266
+ const result = substituteVars(str, scope);
267
+ const unresolved = findUnresolvedVars(result);
268
+
269
+ if (unresolved.length > 0) {
270
+ const names = unresolved.map((n) => `\${${n}}`).join(", ");
271
+ const plural = unresolved.length === 1 ? "variable" : "variables";
272
+ const available = Object.keys(scope).sort();
273
+ throw new ConfigError(
274
+ `Unresolved ${plural} ${names} in ${context}\n` +
275
+ ` raw value: ${str}\n` +
276
+ ` Define ${unresolved.length === 1 ? "it" : "them"} in a _vars block, or map ` +
277
+ `${unresolved.length === 1 ? "it" : "them"} from the environment via the top-level ` +
278
+ `_env block (values can come from .sous/.env.local or .sous/.env).\n` +
279
+ ` Variables in scope here: ${available.length > 0 ? available.join(", ") : "(none)"}`
280
+ );
281
+ }
282
+
283
+ return result;
284
+ }
285
+
286
+ /**
287
+ * Resolves a _vars block into a new scope by:
288
+ * 1. Merging the inherited scope with the block (block keys take precedence)
289
+ * 2. Topologically sorting intra-block dependencies so vars can reference each other
290
+ * 3. Substituting all variable references in topological order
291
+ */
292
+ export function resolveScope(block: Record<string, string>, inherited: VarScope): VarScope {
293
+ const blockKeys = Object.keys(block);
294
+
295
+ // Warn if user defines vars in the reserved 'sous*' namespace
296
+ for (const key of blockKeys) {
297
+ if (key.startsWith("sous")) {
298
+ console.warn(`Warning: variable '${key}' uses the reserved 'sous*' namespace and may conflict with auto-injected variables.`);
299
+ }
300
+ }
301
+
302
+ // Build intra-block dependency map (only deps on other block keys, not inherited)
303
+ const deps = new Map<string, Set<string>>();
304
+ for (const key of blockKeys) {
305
+ const refs = [...block[key].matchAll(/\$\{([^}]+)\}/g)].map(m => m[1]);
306
+ deps.set(key, new Set(refs.filter(r => blockKeys.includes(r))));
307
+ }
308
+
309
+ // Topological sort (DFS with cycle guard)
310
+ const sorted: string[] = [];
311
+ const visited = new Set<string>();
312
+ const visiting = new Set<string>();
313
+
314
+ function visit(key: string): void {
315
+ if (visited.has(key)) return;
316
+ if (visiting.has(key)) {
317
+ // Circular dep — add as-is to avoid infinite loop
318
+ sorted.push(key);
319
+ return;
320
+ }
321
+ visiting.add(key);
322
+ for (const dep of deps.get(key) ?? []) {
323
+ visit(dep);
324
+ }
325
+ visiting.delete(key);
326
+ visited.add(key);
327
+ sorted.push(key);
328
+ }
329
+
330
+ for (const key of blockKeys) {
331
+ visit(key);
332
+ }
333
+
334
+ // Resolve in topological order, starting from the inherited scope
335
+ const scope: VarScope = { ...inherited };
336
+ for (const key of sorted) {
337
+ scope[key] = substituteVars(block[key], scope);
338
+ }
339
+
340
+ return scope;
341
+ }
342
+
343
+ /**
344
+ * Where the active config came from. Threaded through variable resolution so
345
+ * configs can reference their own `.sous/` directory and so error messages can
346
+ * point at the right `.env.local` / `.env`.
347
+ */
348
+ export type ConfigContext = {
349
+ /** Absolute path to the `.sous/` directory holding the config. */
350
+ sousDir: string;
351
+ /** Absolute path to the config file itself. */
352
+ configPath: string;
353
+ };
354
+
355
+ /**
356
+ * Builds the auto-injected variable scope. These vars are always available
357
+ * and injected first, before _env and _vars.
358
+ * The 'sous*' namespace is reserved — warns if user defines a var starting with 'sous'.
359
+ *
360
+ * @param context - The discovered config location. When supplied, adds
361
+ * `sousDir` and `sousConfigPath` so configs can build paths relative to
362
+ * their own `.sous/` directory.
363
+ */
364
+ export function buildAutoVars(context?: ConfigContext): VarScope {
365
+ return {
366
+ sousRootPath: CLI_ROOT,
367
+ sousVersion: SOUS_VERSION,
368
+ ...(context !== undefined && {
369
+ sousDir: context.sousDir,
370
+ sousConfigPath: context.configPath,
371
+ }),
372
+ };
373
+ }
374
+
375
+ /**
376
+ * Resolves the top-level _env block into a VarScope.
377
+ * Each entry maps a config var name (key) to an environment variable name (value).
378
+ * Throws a clear error if any referenced env var is not set.
379
+ * Only called on the root Settings object — _env is top-level only.
380
+ *
381
+ * @param settings - The root settings object.
382
+ * @param context - The discovered config location, used to name the
383
+ * `.sous/.env.local` and `.sous/.env` files in the error message.
384
+ */
385
+ export function resolveEnvScope(settings: Settings, context?: ConfigContext): VarScope {
386
+ const env = settings._env ?? {};
387
+ const scope: VarScope = {};
388
+
389
+ for (const [varName, envVarName] of Object.entries(env)) {
390
+ const value = process.env[envVarName];
391
+ if (value === undefined) {
392
+ const envLocalPath = context
393
+ ? path.join(context.sousDir, ENV_LOCAL_NAME)
394
+ : `${SOUS_DIR_NAME}/${ENV_LOCAL_NAME}`;
395
+ const envDefaultsPath = context
396
+ ? path.join(context.sousDir, ENV_DEFAULTS_NAME)
397
+ : `${SOUS_DIR_NAME}/${ENV_DEFAULTS_NAME}`;
398
+ throw new ConfigError(
399
+ `_env resolution failed: environment variable '${envVarName}' (mapped to config ` +
400
+ `var '${varName}') is not set.\n` +
401
+ ` Define it in ${envLocalPath} as:\n` +
402
+ ` ${envVarName}=<value>\n` +
403
+ ` ...or, if the value is shared by the whole team and is not a secret, in ` +
404
+ `${envDefaultsPath} (committed).\n` +
405
+ ` ...or export it in your shell before running sous.`
406
+ );
407
+ }
408
+ scope[varName] = value;
409
+ }
410
+
411
+ return scope;
412
+ }
413
+
414
+ /**
415
+ * Resolves the root-level _vars from a Settings object into a scope.
416
+ * Chains: auto-vars → env scope → root _vars.
417
+ *
418
+ * @param settings - The root settings object.
419
+ * @param context - The discovered config location (optional in tests).
420
+ */
421
+ export function resolveRootScope(settings: Settings, context?: ConfigContext): VarScope {
422
+ const autoVars = buildAutoVars(context);
423
+ const envScope = resolveEnvScope(settings, context);
424
+ const baseScope = { ...autoVars, ...envScope };
425
+ return resolveScope(settings._vars ?? {}, baseScope);
426
+ }
427
+
428
+ /**
429
+ * Built-in `@include` aliases, always available and reserved (their names begin
430
+ * with `~` so user `_aliases` can never shadow them). Add new entries here as
431
+ * needed — keep names kebab-case.
432
+ *
433
+ * - `~sous-shared` → the Sous CLI's `shared-prompts` directory (the only dir
434
+ * downstream projects consume; path into it, e.g. `@~sous-shared/skills/...`).
435
+ * - `~project` → the consuming project's root (`projectRoot`).
436
+ */
437
+ export function buildBuiltInAliases(scope: VarScope): AliasMap {
438
+ const sousRoot = scope.sousRootPath ?? CLI_ROOT;
439
+ const builtIns: AliasMap = {
440
+ "~sous-shared": [path.join(sousRoot, "shared-prompts")],
441
+ };
442
+ if (scope.projectRoot) builtIns["~project"] = [scope.projectRoot];
443
+ return builtIns;
444
+ }
445
+
446
+ /**
447
+ * Resolve the full `@include` alias map for a project: built-ins, then root
448
+ * `_aliases`, then project `_aliases` (later prepends to earlier so user entries
449
+ * are tried first and fall through to built-in bases). User alias names starting
450
+ * with `~` are rejected (reserved).
451
+ *
452
+ * @param settings - The root settings (for root-level `_aliases`).
453
+ * @param project - The project (for project-level `_aliases`).
454
+ * @param scope - The resolved project scope (for ${var} substitution + projectRoot).
455
+ */
456
+ export function resolveAliases(
457
+ settings: Settings,
458
+ project: RawProject,
459
+ scope: VarScope
460
+ ): AliasMap {
461
+ return buildAliasMap({
462
+ builtIns: buildBuiltInAliases(scope),
463
+ userAliases: [settings._aliases, project._aliases],
464
+ scope,
465
+ onError: warning,
466
+ });
467
+ }
468
+
469
+ /** A resolved tool configuration with promptFile path substituted. */
470
+ export type ResolvedToolConfig = {
471
+ command: string;
472
+ args?: string[];
473
+ promptFile?: string;
474
+ };
475
+
476
+ /**
477
+ * Resolves a project's tools config, substituting vars in promptFile paths.
478
+ * Returns an empty object if no tools are configured.
479
+ */
480
+ export function resolveProjectTools(
481
+ project: RawProject,
482
+ rootScope: VarScope = {},
483
+ projectKey = project.name
484
+ ): Record<string, ResolvedToolConfig> {
485
+ if (!project.tools) return {};
486
+
487
+ const projectScope = resolveScope(project._vars ?? {}, rootScope);
488
+
489
+ return Object.fromEntries(
490
+ Object.entries(project.tools).map(([name, tool]) => [
491
+ name,
492
+ {
493
+ command: tool.command,
494
+ ...(tool.args !== undefined && { args: tool.args }),
495
+ ...(tool.promptFile !== undefined && {
496
+ promptFile: substituteVarsStrict(
497
+ tool.promptFile,
498
+ projectScope,
499
+ `project '${projectKey}' → tools.${name}.promptFile`
500
+ ),
501
+ }),
502
+ },
503
+ ])
504
+ );
505
+ }
506
+
507
+ // --- Compilation Resolution ----------------------------------------------------------------------
508
+
509
+ /**
510
+ * Resolves a RawRuntimeContext into a ResolvedRuntimeContext by substituting
511
+ * ${var} references and converting branchPattern from string to RegExp.
512
+ */
513
+ function resolveRuntimeContext(
514
+ raw: RawRuntimeContext,
515
+ scope: VarScope,
516
+ context: string
517
+ ): ResolvedRuntimeContext {
518
+ return {
519
+ gitRoot: substituteVarsStrict(raw.gitRoot, scope, `${context}.gitRoot`),
520
+ outputPath: substituteVarsStrict(raw.outputPath, scope, `${context}.outputPath`),
521
+ taskFileRoot: substituteVarsStrict(raw.taskFileRoot, scope, `${context}.taskFileRoot`),
522
+ branchPattern: new RegExp(raw.branchPattern ?? "PT-"),
523
+ };
524
+ }
525
+
526
+ /**
527
+ * Resolves a project's compilation config into the shape the compiler expects.
528
+ * Walks the config tree resolving _vars at each level (root → project → target → output).
529
+ * Pass rootScope from resolveRootScope(settings) to thread root vars down.
530
+ * Returns null if the project has no compilation config.
531
+ */
532
+ export function resolveProjectCompilation(
533
+ project: RawProject,
534
+ rootScope: VarScope = {},
535
+ settings: Settings = { projects: {} },
536
+ projectKey = project.name
537
+ ): CompilationConfig | null {
538
+ if (!project.compilation) return null;
539
+
540
+ const projectScope = resolveScope(project._vars ?? {}, rootScope);
541
+ const compilationScope = resolveScope(project.compilation._vars ?? {}, projectScope);
542
+ const aliases = resolveAliases(settings, project, projectScope);
543
+
544
+ return {
545
+ includeSourceComments: project.compilation.includeSourceComments,
546
+ aliases,
547
+ includeScope: projectScope,
548
+ targets: project.compilation.targets.flatMap((target, targetIndex): CompilationTarget[] => {
549
+ const targetScope = resolveScope(target._vars ?? {}, compilationScope);
550
+ const where = `project '${projectKey}' → compilation.targets[${targetIndex}]`;
551
+ const hasSingle = target.entryPoint !== undefined;
552
+ const hasGlob = target.entryGlob !== undefined;
553
+
554
+ if (hasSingle && hasGlob) {
555
+ throw new Error("Target cannot have both entryPoint and entryGlob");
556
+ }
557
+ if (!hasSingle && !hasGlob) {
558
+ throw new Error("Target must have either entryPoint or entryGlob");
559
+ }
560
+
561
+ /**
562
+ * Resolves the outputs array for a target, filtering by _if conditions and substituting vars.
563
+ */
564
+ function resolveOutputs(scope: VarScope) {
565
+ return target.outputs
566
+ .filter(output => {
567
+ if (!output._if) return true;
568
+ const outputScope = resolveScope(output._vars ?? {}, scope);
569
+ return Object.entries(output._if).every(([varName, condition]) => {
570
+ const val = outputScope[varName] ?? scope[varName] ?? compilationScope[varName];
571
+ return val === condition.eq;
572
+ });
573
+ })
574
+ .map((output, outputIndex) => {
575
+ const outputScope = resolveScope(output._vars ?? {}, scope);
576
+ const outputWhere = `${where}.outputs[${outputIndex}]`;
577
+ return {
578
+ ...(output.destinationFile !== undefined && {
579
+ destinationFile: normalizeConfigPath(
580
+ substituteVarsStrict(
581
+ output.destinationFile,
582
+ outputScope,
583
+ `${outputWhere}.destinationFile`
584
+ )
585
+ ),
586
+ }),
587
+ ...(output.destinationDir !== undefined && {
588
+ destinationDir: normalizeConfigPath(
589
+ substituteVarsStrict(
590
+ output.destinationDir,
591
+ outputScope,
592
+ `${outputWhere}.destinationDir`
593
+ )
594
+ ),
595
+ }),
596
+ vars: outputScope,
597
+ };
598
+ });
599
+ }
600
+
601
+ if (hasSingle) {
602
+ const runtimeContext =
603
+ target.generateRuntimeContext && project.runtimeContext
604
+ ? resolveRuntimeContext(
605
+ project.runtimeContext,
606
+ projectScope,
607
+ `project '${projectKey}' → runtimeContext`
608
+ )
609
+ : undefined;
610
+ return [{
611
+ rootInputPath: normalizeConfigPath(
612
+ substituteVarsStrict(target.entryPoint!, targetScope, `${where}.entryPoint`)
613
+ ),
614
+ ...(runtimeContext !== undefined && { runtimeContext }),
615
+ outputs: resolveOutputs(targetScope),
616
+ }];
617
+ }
618
+
619
+ /* c8 ignore start */
620
+ // Glob target: expand pattern into one CompilationTarget per matched file, skipping dirs
621
+ const pattern = substituteVarsStrict(target.entryGlob!, targetScope, `${where}.entryGlob`);
622
+ const matchedFiles = globSync(pattern, { absolute: true })
623
+ .filter(filePath => fs.statSync(filePath).isFile());
624
+
625
+ if (matchedFiles.length === 0) {
626
+ warning(`Glob pattern matched no files:\n${pattern}`);
627
+ }
628
+
629
+ const globBase = normalizeConfigPath(
630
+ target.globBase
631
+ ? substituteVarsStrict(target.globBase, targetScope, `${where}.globBase`)
632
+ : inferGlobBase(pattern)
633
+ );
634
+ return matchedFiles.map(filePath => ({
635
+ rootInputPath: filePath,
636
+ globBase,
637
+ outputs: resolveOutputs(targetScope),
638
+ }));
639
+ /* c8 ignore stop */
640
+ }),
641
+ };
642
+ }
643
+
644
+ /** Resolved watch configuration for a project. */
645
+ export type WatchConfig = {
646
+ /** Exact file paths — watched directly by chokidar. Trigger partial rebuilds. */
647
+ files: string[];
648
+ /**
649
+ * Glob patterns — chokidar watches their base directories; incoming events are
650
+ * filtered against these patterns before triggering a partial rebuild.
651
+ */
652
+ globs: string[];
653
+ /**
654
+ * Additional paths (files or directories) that trigger a full rebuild when changed.
655
+ * Used for the settings file, templating directory, and config imports.
656
+ */
657
+ fullRebuildPaths?: string[];
658
+ };
659
+
660
+ /**
661
+ * Returns the watch configuration for a project's compilation targets.
662
+ *
663
+ * - entryPoint targets → exact file path in `files`.
664
+ * - entryGlob targets → resolved glob string in `globs`.
665
+ */
666
+ export function resolveWatchConfig(
667
+ project: RawProject,
668
+ rootScope: VarScope = {},
669
+ projectKey = project.name
670
+ ): WatchConfig {
671
+ if (!project.compilation) return { files: [], globs: [] };
672
+
673
+ const projectScope = resolveScope(project._vars ?? {}, rootScope);
674
+ const compilationScope = resolveScope(project.compilation._vars ?? {}, projectScope);
675
+ const files: string[] = [];
676
+ const globs: string[] = [];
677
+
678
+ for (const [targetIndex, target] of project.compilation.targets.entries()) {
679
+ const targetScope = resolveScope(target._vars ?? {}, compilationScope);
680
+ const where = `project '${projectKey}' → compilation.targets[${targetIndex}]`;
681
+
682
+ if (target.entryPoint) {
683
+ files.push(substituteVarsStrict(target.entryPoint, targetScope, `${where}.entryPoint`));
684
+ }
685
+
686
+ if (target.entryGlob) {
687
+ globs.push(substituteVarsStrict(target.entryGlob, targetScope, `${where}.entryGlob`));
688
+ }
689
+ }
690
+
691
+ return {
692
+ files: [...new Set(files)],
693
+ globs: [...new Set(globs)],
694
+ };
695
+ }