@sous-io/sous 0.1.1 → 0.2.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 (205) hide show
  1. package/README.md +115 -35
  2. package/bin/run.js +10 -1
  3. package/docs/markdown/README.md +27 -0
  4. package/docs/markdown/_sidebar.md +18 -0
  5. package/docs/markdown/commands.md +308 -0
  6. package/docs/markdown/config-discovery.md +74 -0
  7. package/docs/markdown/config-inspection.md +69 -0
  8. package/docs/markdown/config-layers.md +92 -0
  9. package/docs/markdown/config-variables.md +79 -0
  10. package/docs/markdown/configuration.md +71 -0
  11. package/docs/markdown/design-principles.md +59 -0
  12. package/docs/markdown/repositories-authoring.md +408 -0
  13. package/docs/markdown/repositories-consuming.md +580 -0
  14. package/docs/markdown/repositories-file-formats.md +1084 -0
  15. package/docs/markdown/repositories-variables.md +387 -0
  16. package/docs/markdown/repositories.md +303 -0
  17. package/docs/markdown/skill-categories.md +58 -0
  18. package/package.json +72 -8
  19. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/SKILL.tpl.md +20 -20
  20. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/examples/about-something.md +2 -2
  21. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/examples/do-something.md +1 -1
  22. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/advanced-patterns.md +6 -6
  23. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/commands.md +5 -5
  24. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/frontmatter.md +3 -3
  25. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-liquid-templates/SKILL.tpl.md +40 -25
  26. package/recipes/core/sous-skills/skills/about-sous/SKILL.tpl.md +70 -0
  27. package/recipes/core/sous-skills/skills/about-sous-configuration/SKILL.tpl.md +75 -0
  28. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/create-skill/SKILL.tpl.md +8 -9
  29. package/recipes/core/sous-skills/sous.recipe.yaml +45 -0
  30. package/sous.config.schema.json +337 -0
  31. package/src/base-command.ts +220 -67
  32. package/src/commands/build.ts +150 -73
  33. package/src/commands/clear.ts +23 -15
  34. package/src/commands/compile.ts +74 -16
  35. package/src/commands/config/get.ts +110 -0
  36. package/src/commands/config/show.ts +32 -0
  37. package/src/commands/config/validate.ts +53 -0
  38. package/src/commands/help.ts +46 -0
  39. package/src/commands/launch.ts +36 -14
  40. package/src/commands/lock/rebuild.ts +241 -0
  41. package/src/commands/lock/show.ts +115 -0
  42. package/src/commands/namespace/list.ts +117 -0
  43. package/src/commands/namespace/show.ts +110 -0
  44. package/src/commands/prune.ts +3 -11
  45. package/src/commands/recipe/list.ts +95 -0
  46. package/src/commands/recipe/show.ts +301 -0
  47. package/src/commands/repo/add.ts +145 -0
  48. package/src/commands/repo/gc.ts +172 -0
  49. package/src/commands/repo/init.ts +136 -0
  50. package/src/commands/repo/link.ts +500 -0
  51. package/src/commands/repo/list.ts +179 -0
  52. package/src/commands/repo/release.ts +619 -0
  53. package/src/commands/repo/remove.ts +193 -0
  54. package/src/commands/repo/search.ts +189 -0
  55. package/src/commands/repo/submit.ts +133 -0
  56. package/src/commands/repo/unlink.ts +147 -0
  57. package/src/commands/subscription/add.ts +285 -0
  58. package/src/commands/subscription/list.ts +129 -0
  59. package/src/commands/subscription/remove.ts +181 -0
  60. package/src/commands/vars/ask.ts +374 -0
  61. package/src/commands/vars/index.ts +79 -0
  62. package/src/commands/vars/list.ts +67 -0
  63. package/src/commands/vars/show.ts +77 -0
  64. package/src/config-command.ts +30 -0
  65. package/src/lib/build-service.ts +206 -54
  66. package/src/lib/config-discovery.ts +220 -27
  67. package/src/lib/config-inspect.ts +145 -0
  68. package/src/lib/config-kernel.mjs +377 -0
  69. package/src/lib/config-schema.ts +361 -0
  70. package/src/lib/env-file.ts +328 -0
  71. package/src/lib/env-local.ts +18 -1
  72. package/src/lib/errors.ts +32 -0
  73. package/src/lib/include-resolver.ts +108 -15
  74. package/src/lib/interactive.ts +165 -0
  75. package/src/lib/markdown-compiler.ts +118 -37
  76. package/src/lib/package-info.ts +25 -0
  77. package/src/lib/pid-service.ts +32 -21
  78. package/src/lib/refs/find.ts +589 -0
  79. package/src/lib/refs/index.ts +12 -0
  80. package/src/lib/refs/pick.ts +147 -0
  81. package/src/lib/refs/scopes.ts +61 -0
  82. package/src/lib/repos/catalog-display.ts +116 -0
  83. package/src/lib/repos/catalog-inputs.ts +160 -0
  84. package/src/lib/repos/catalog.ts +722 -0
  85. package/src/lib/repos/core-recipe.ts +105 -0
  86. package/src/lib/repos/defaults.ts +175 -0
  87. package/src/lib/repos/formats/common.ts +389 -0
  88. package/src/lib/repos/formats/index-file.ts +215 -0
  89. package/src/lib/repos/formats/links-map.ts +96 -0
  90. package/src/lib/repos/formats/lockfile.ts +167 -0
  91. package/src/lib/repos/formats/patterns.ts +57 -0
  92. package/src/lib/repos/formats/recipe-manifest.ts +395 -0
  93. package/src/lib/repos/formats/repo-manifest.ts +88 -0
  94. package/src/lib/repos/formats/store-entry.ts +84 -0
  95. package/src/lib/repos/freshness.ts +208 -0
  96. package/src/lib/repos/git-clone.ts +312 -0
  97. package/src/lib/repos/identity.ts +89 -0
  98. package/src/lib/repos/index.ts +58 -0
  99. package/src/lib/repos/links.ts +353 -0
  100. package/src/lib/repos/load-manifest.ts +236 -0
  101. package/src/lib/repos/lock-service.ts +453 -0
  102. package/src/lib/repos/locked-namespace-resolver.ts +90 -0
  103. package/src/lib/repos/locked-recipes.ts +254 -0
  104. package/src/lib/repos/managed-layer.ts +422 -0
  105. package/src/lib/repos/namespace-resolver.ts +370 -0
  106. package/src/lib/repos/providers/base.ts +206 -0
  107. package/src/lib/repos/providers/git.ts +233 -0
  108. package/src/lib/repos/providers/github.ts +294 -0
  109. package/src/lib/repos/providers/gitlab.ts +263 -0
  110. package/src/lib/repos/providers/http.ts +102 -0
  111. package/src/lib/repos/providers/index-cache.ts +382 -0
  112. package/src/lib/repos/providers/index.ts +106 -0
  113. package/src/lib/repos/providers/local.ts +391 -0
  114. package/src/lib/repos/providers/provider.ts +401 -0
  115. package/src/lib/repos/recipe-config-layers.ts +287 -0
  116. package/src/lib/repos/recipe-targets.ts +223 -0
  117. package/src/lib/repos/ref-search.ts +46 -0
  118. package/src/lib/repos/ref.ts +513 -0
  119. package/src/lib/repos/reference-report.ts +122 -0
  120. package/src/lib/repos/release/bump.ts +161 -0
  121. package/src/lib/repos/release/git-state.ts +305 -0
  122. package/src/lib/repos/release/index-builder.ts +635 -0
  123. package/src/lib/repos/release/index.ts +16 -0
  124. package/src/lib/repos/release/plan.ts +512 -0
  125. package/src/lib/repos/release/submit-service.ts +496 -0
  126. package/src/lib/repos/release/tags.ts +243 -0
  127. package/src/lib/repos/release/validate.ts +463 -0
  128. package/src/lib/repos/resolver.ts +789 -0
  129. package/src/lib/repos/scaffold/index.ts +238 -0
  130. package/src/lib/repos/scaffold/templates.ts +413 -0
  131. package/src/lib/repos/seed.ts +414 -0
  132. package/src/lib/repos/store/contract.ts +64 -0
  133. package/src/lib/repos/store/hash.ts +114 -0
  134. package/src/lib/repos/store/recipe-store.ts +599 -0
  135. package/src/lib/repos/store/settings.ts +58 -0
  136. package/src/lib/repos/subscription-service.ts +2678 -0
  137. package/src/lib/repos/trust.ts +447 -0
  138. package/src/lib/settings.ts +546 -189
  139. package/src/lib/sous-home.ts +104 -0
  140. package/src/lib/state.ts +52 -20
  141. package/src/lib/vars/ask.ts +1152 -0
  142. package/src/lib/vars/definition-source.ts +252 -0
  143. package/src/lib/vars/display.ts +233 -0
  144. package/src/lib/vars/index.ts +18 -0
  145. package/src/lib/vars/ladder.ts +282 -0
  146. package/src/lib/vars/mappings.ts +265 -0
  147. package/src/lib/vars/names.ts +94 -0
  148. package/src/lib/vars/preanswers.ts +395 -0
  149. package/src/lib/vars/question-plan.ts +218 -0
  150. package/src/lib/vars/report.ts +228 -0
  151. package/src/lib/vars/safe-regex.ts +235 -0
  152. package/src/lib/vars/validate.ts +312 -0
  153. package/src/lib/watch-loop.ts +148 -0
  154. package/src/templating/init-liquid-engine.ts +58 -16
  155. package/src/utils/choice-prompt.ts +143 -0
  156. package/src/utils/command-errors.ts +186 -0
  157. package/src/utils/command-help.ts +45 -0
  158. package/src/utils/confirm-prompt.ts +110 -0
  159. package/src/utils/flags.ts +153 -0
  160. package/src/utils/formatting.ts +540 -55
  161. package/src/utils/prompts.ts +35 -1
  162. package/src/utils/sous-directory.ts +245 -0
  163. package/src/utils/table.ts +603 -0
  164. package/src/utils/value-prompt.ts +119 -0
  165. package/shared-prompts/_partials/resume-task.md +0 -51
  166. package/shared-prompts/_partials/sub-agent-delegation.md +0 -32
  167. package/shared-prompts/_partials/update-task-file.md +0 -52
  168. package/shared-prompts/memories/automated-browser-tasks/INDEX.tpl.md +0 -52
  169. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/SKILL.tpl.md +0 -102
  170. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/auth-failure-handling.mjs +0 -81
  171. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/chained-workflow.mjs +0 -126
  172. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/simple-fetch.mjs +0 -92
  173. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/architecture.md +0 -61
  174. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/auth-and-sessions.md +0 -65
  175. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/ctx-api.md +0 -96
  176. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/installation.md +0 -104
  177. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/script-conventions.md +0 -243
  178. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/chrome-state.mjs +0 -148
  179. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.mjs +0 -383
  180. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.spec.mjs +0 -267
  181. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/eslint.config.mjs +0 -56
  182. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/harness.mjs +0 -169
  183. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/keyring.mjs +0 -59
  184. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/logger.mjs +0 -25
  185. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/params.mjs +0 -140
  186. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/run.mjs +0 -140
  187. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/settings.tpl.mjs +0 -1
  188. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/utils.mjs +0 -185
  189. package/shared-prompts/skills/automated-browser-tasks/create-automated-browser-task/SKILL.tpl.md +0 -52
  190. package/shared-prompts/skills/automated-browser-tasks/running-automated-browser-tasks/SKILL.tpl.md +0 -59
  191. package/shared-prompts/skills/automated-browser-tasks/update-automated-browser-task/SKILL.tpl.md +0 -47
  192. package/shared-prompts/skills/control-flow/approve/SKILL.tpl.md +0 -26
  193. package/shared-prompts/skills/control-flow/opine/SKILL.tpl.md +0 -58
  194. package/shared-prompts/skills/control-flow/repeat/SKILL.tpl.md +0 -27
  195. package/shared-prompts/skills/control-flow/research/SKILL.tpl.md +0 -34
  196. package/shared-prompts/skills/sous-skills/about-sous/SKILL.tpl.md +0 -51
  197. package/shared-prompts/skills/task-files/about-task-files/SKILL.tpl.md +0 -122
  198. package/shared-prompts/skills/task-files/continue-task-in-new-branch/SKILL.tpl.md +0 -80
  199. package/shared-prompts/skills/task-files/go/SKILL.tpl.md +0 -14
  200. package/shared-prompts/skills/task-files/resume-task/SKILL.tpl.md +0 -13
  201. package/shared-prompts/skills/task-files/start-task/SKILL.tpl.md +0 -93
  202. package/shared-prompts/skills/task-files/update/SKILL.tpl.md +0 -14
  203. package/shared-prompts/skills/task-files/update-task-file/SKILL.tpl.md +0 -13
  204. /package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/substitutions.md +0 -0
  205. /package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-liquid-templates/references/liquid-filters.md +0 -0
@@ -2,23 +2,33 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { createRequire } from "node:module";
5
- import { fileURLToPath, pathToFileURL } from "node:url";
5
+ import { fileURLToPath } from "node:url";
6
6
  import { globSync } from "glob";
7
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";
8
+ import { buildAliasMap, resolveAliasPrefix, type AliasMap } from "./include-resolver.js";
9
+ import {
10
+ CONFD_DIR_NAME,
11
+ ENV_DEFAULTS_NAME,
12
+ ENV_LOCAL_NAME,
13
+ SOUS_DIR_NAME,
14
+ type DiscoveredConfig,
15
+ } from "./config-discovery.js";
16
+ import { ConfigError } from "./errors.js";
17
+ import { resolveSousHome } from "./sous-home.js";
18
+ import { validateSettings } from "./config-schema.js";
19
+ import { applyRepoDefaults } from "./repos/defaults.js";
20
+ import type { RecipeConfigLayer } from "./repos/recipe-config-layers.js";
10
21
  import { warning } from "../utils/formatting.js";
11
22
 
12
- const __filename = fileURLToPath(import.meta.url);
23
+ // Re-exported for backwards compatibility: ConfigError moved to ./errors.ts so
24
+ // config-discovery.ts can throw it without importing this module (cycle).
25
+ export { ConfigError, isConfigError } from "./errors.js";
13
26
 
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;
27
+ // CLI_ROOT and SOUS_VERSION live in their own module so that modules this one
28
+ // imports can read them without importing this one back. Re-exported here under
29
+ // the names everything already uses.
30
+ import { CLI_ROOT, SOUS_VERSION } from "./package-info.js";
31
+ export { CLI_ROOT, SOUS_VERSION };
22
32
 
23
33
  // --- Variable scope ------------------------------------------------------------------------------
24
34
 
@@ -70,6 +80,88 @@ type RawProjectCompilation = {
70
80
  targets: RawTarget[];
71
81
  };
72
82
 
83
+ /**
84
+ * One trusted repository, keyed in `Settings.repos` by the short name refs use
85
+ * in their `repo:` qualifier. Adding a repo is what trusts it.
86
+ */
87
+ export type RepoEntry = {
88
+ /** Where the repository lives. */
89
+ url: string;
90
+ /**
91
+ * Whether the repository takes part in anything. Defaults to true; setting it
92
+ * to false is how a project opts out of a repository sous provides itself,
93
+ * without deleting an entry it does not own.
94
+ */
95
+ enabled?: boolean;
96
+ /**
97
+ * Which provider handles it. Inferred from the URL when omitted. `local` is a
98
+ * repository on this machine, for local development and tests.
99
+ */
100
+ provider?: "github" | "gitlab" | "local";
101
+ /** Install a newer in-range version whenever one exists, rather than holding the lock. */
102
+ alwaysPull?: boolean;
103
+ /** When the repo was added. */
104
+ addedAt?: string;
105
+ /** Who required it: "user", or the ref of the recipe whose dependency pulled it in. */
106
+ addedBy?: string;
107
+ };
108
+
109
+ /**
110
+ * One subscription, keyed in `Settings.subscriptions` by a ref key: a bare
111
+ * namespace, or `namespace/recipe`.
112
+ */
113
+ export type SubscriptionEntry = {
114
+ /**
115
+ * Whether the subscription takes part in anything. Defaults to true; setting
116
+ * it to false is how a project opts out of the `core` namespace sous
117
+ * subscribes it to.
118
+ */
119
+ enabled?: boolean;
120
+ /** The semantic version range to resolve within. Defaults to "*". */
121
+ range?: string;
122
+ /** Let prerelease versions take part in range matching. */
123
+ prerelease?: boolean;
124
+ /** Per-subscription form of the repo-level always-pull flag. */
125
+ alwaysPull?: boolean;
126
+ /** When the subscription was added. */
127
+ addedAt?: string;
128
+ /** Who required it: "user", or the ref of the recipe that co-subscribed it. */
129
+ addedBy?: string;
130
+ };
131
+
132
+ /**
133
+ * Knobs for the machine-wide recipe store. Defaults are applied by the store
134
+ * itself, not here; see config-schema.ts for the values sous ships.
135
+ */
136
+ type StoreConfig = {
137
+ /** Size cap, past which least-recently-used entries are collected. */
138
+ maxBytes?: number;
139
+ /** How long a fetched index stays fresh before sous re-checks upstream. */
140
+ freshnessSeconds?: number;
141
+ /** How often watch mode polls upstream. */
142
+ watchPollSeconds?: number;
143
+ };
144
+
145
+ /**
146
+ * Where the files a subscribed recipe contributes are written, one list of
147
+ * destination directories per content kind. Each destination is `${var}`
148
+ * substituted like any other config path, and a kind may name several so the
149
+ * same recipe feeds more than one agent directory.
150
+ *
151
+ * Only `skills` has a default (`<project root>/.claude/skills`, the project root
152
+ * being the parent of `.sous/`). A kind with no destination is skipped, with one
153
+ * warning naming this config key, because sous cannot guess where a project
154
+ * wants its memories or its prompts.
155
+ */
156
+ type RecipeOutputs = {
157
+ /** Where recipe skill bundles are written. */
158
+ skills?: string[];
159
+ /** Where recipe memory files are written. */
160
+ memories?: string[];
161
+ /** Where recipe prompt files are written. */
162
+ prompts?: string[];
163
+ };
164
+
73
165
  /** Configuration for a launchable tool (e.g. claude, codex). */
74
166
  type ToolConfig = {
75
167
  /** The executable command to run. */
@@ -83,72 +175,139 @@ type ToolConfig = {
83
175
  promptFile?: string;
84
176
  };
85
177
 
86
- export type RawProject = {
178
+ export type Settings = {
179
+ /** Config schema version. Optional; when present must be 1 (validated at load). */
180
+ version?: number;
181
+ _env?: Record<string, string>;
87
182
  /**
88
- * Project-level variables. A few names are read by Sous itself:
183
+ * Config variables. A few names are read by Sous itself:
89
184
  * `stateFilePath` overrides where the build state file is written (see
90
185
  * StateService.getFilePath), and `pidFilePath` does the same for the watcher
91
- * PID file. Both resolve through the PROJECT scope.
186
+ * PID file. Both resolve through the settings scope.
92
187
  */
93
188
  _vars?: Record<string, string>;
94
189
  _aliases?: Record<string, string | string[]>;
95
- name: string;
190
+ /** Optional display name for the configured project. */
191
+ name?: string;
96
192
  compilation?: RawProjectCompilation;
97
193
  runtimeContext?: RawRuntimeContext;
98
194
  tools?: Record<string, ToolConfig>;
195
+ /** Trusted repositories, keyed by the short name refs use. */
196
+ repos?: Record<string, RepoEntry>;
197
+ /** Subscriptions, keyed by ref key (`namespace` or `namespace/recipe`). */
198
+ subscriptions?: Record<string, SubscriptionEntry>;
199
+ /** Knobs for the machine-wide recipe store. */
200
+ store?: StoreConfig;
201
+ /**
202
+ * Where the files subscribed recipes contribute are written, per content kind.
203
+ */
204
+ recipeOutputs?: RecipeOutputs;
205
+ /**
206
+ * Variable mapping records, keyed by environment variable name, each bound to
207
+ * one recipe variable written as `namespace/recipe/variableName` with an
208
+ * optional `repo:` qualifier. The top rung of the answer resolution ladder.
209
+ */
210
+ varMappings?: Record<string, string>;
99
211
  };
100
212
 
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>;
213
+ // --- Loader -------------------------------------------------------------------------------------
214
+
215
+ /** Options accepted by loadSettings / loadSettingsWithLayers. */
216
+ export type LoadSettingsOptions = {
217
+ /**
218
+ * When true, the kernel returns one cumulative-config snapshot per top-level
219
+ * layer (the provenance seam behind `sous config get --layers`).
220
+ */
221
+ trace?: boolean;
107
222
  };
108
223
 
109
- // --- Loader -------------------------------------------------------------------------------------
224
+ /** One trace-mode snapshot: the cumulative config AFTER `path` was merged. */
225
+ export type SettingsLayer = {
226
+ path: string;
227
+ config: unknown;
228
+ };
229
+
230
+ /** Absolute path to the config kernel subprocess entry (plain .mjs, ships in src/). */
231
+ const CONFIG_KERNEL_PATH = path.join(CLI_ROOT, "src", "lib", "config-kernel.mjs");
110
232
 
111
233
  /**
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).
234
+ * Loads settings from a discovered config (primary file + conf.d layers) and
235
+ * returns the merged result together with any trace-mode layer snapshots.
115
236
  *
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:
237
+ * Every layer — .js, .mjs, .json and .yaml alike — is loaded by ONE kernel
238
+ * subprocess (src/lib/config-kernel.mjs), which parses/imports each file in
239
+ * order, JSON-forces it, deep-merges it into a live cumulative config, runs
240
+ * `configure(currentConfig, builder)` exports, and serialises the final JSON
241
+ * once. Uniform kernel semantics beat the spawn cost, so there is no
242
+ * parent-side shortcut for plain JSON.
243
+ *
244
+ * Two spawn attempts are made, in this order:
118
245
  *
119
246
  * 1. Plain Node, no loader. This is what a normal ESM config needs, and it is
120
247
  * the only thing that works for a `.sous/sous.config.js` sitting in a repo
121
248
  * whose package.json has no `"type": "module"` (under the tsx loader such a
122
249
  * file is treated as CJS and dies with ERR_REQUIRE_CYCLE_MODULE).
123
250
  * 2. The tsx loader, so a config may use TypeScript syntax and extensionless
124
- * relative imports.
251
+ * relative imports. (The kernel itself is plain .mjs and runs under both.)
125
252
  *
126
253
  * 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
254
+ * that tsx triggers in the parent process. Because every layer is round-tripped
128
255
  * through JSON, functions, RegExp, Date and undefined values are dropped.
256
+ *
257
+ * @param source - The full DiscoveredConfig, or a bare config file path. A bare
258
+ * string means exactly that one file: no conf.d scan is performed (back-compat
259
+ * for tests and direct callers).
260
+ * @param options - `{ trace }` — see LoadSettingsOptions.
129
261
  */
130
- /* c8 ignore next 60 */
131
- export async function loadSettings(configPath: string): Promise<Settings> {
262
+ /* c8 ignore next 75 */
263
+ export async function loadSettingsWithLayers(
264
+ source: DiscoveredConfig | string,
265
+ options: LoadSettingsOptions = {}
266
+ ): Promise<{ settings: Settings; layers: SettingsLayer[] }> {
267
+ let configPath: string;
268
+ let sousDir: string;
269
+ let confDir: string;
270
+ let layerPaths: string[];
271
+ let recipeLayers: RecipeConfigLayer[];
272
+
273
+ if (typeof source === "string") {
274
+ configPath = path.resolve(source);
275
+ sousDir = path.dirname(configPath);
276
+ confDir = path.join(sousDir, CONFD_DIR_NAME);
277
+ layerPaths = [configPath];
278
+ recipeLayers = [];
279
+ } else {
280
+ ({ configPath, sousDir, confDir, layerPaths } = source);
281
+ recipeLayers = source.recipeLayers ?? [];
282
+ }
283
+
132
284
  if (!fs.existsSync(configPath)) {
133
285
  throw new Error(`Settings file not found: ${configPath}`);
134
286
  }
135
287
 
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
- }
288
+ // A recipe layer is handed to the kernel as content, not as a path, because
289
+ // it has already been read and filtered down to the keys a recipe may set
290
+ // (see `repos/recipe-config-layers.ts`). The kernel never opens the file, so
291
+ // there is no second, unfiltered reading of it.
292
+ const recipeLayerByPath = new Map(recipeLayers.map((layer) => [layer.path, layer]));
293
+ const sources = layerPaths.map((layerPath) => {
294
+ const recipeLayer = recipeLayerByPath.get(layerPath);
295
+ if (recipeLayer === undefined) return layerPath;
296
+ return { path: recipeLayer.path, config: recipeLayer.config };
297
+ });
298
+
299
+ const kernelInput = JSON.stringify({
300
+ sources,
301
+ context: {
302
+ sousDir,
303
+ confDir,
304
+ sousRootPath: CLI_ROOT,
305
+ sousVersion: SOUS_VERSION,
306
+ configPath,
307
+ },
308
+ trace: options.trace === true,
309
+ });
145
310
 
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
311
  // Resolve tsx via module resolution so this works when npm hoists the
153
312
  // dependency (local install, npx) as well as when it nests it (global,
154
313
  // repo clone).
@@ -160,67 +319,119 @@ export async function loadSettings(configPath: string): Promise<Settings> {
160
319
  }
161
320
 
162
321
  const attempts: { label: string; args: string[] }[] = [
163
- { label: "node", args: ["--input-type=module"] },
164
- { label: "tsx", args: ["--import", tsxPath, "--input-type=module"] },
322
+ { label: "node", args: [CONFIG_KERNEL_PATH] },
323
+ { label: "tsx", args: ["--import", tsxPath, CONFIG_KERNEL_PATH] },
165
324
  ];
166
325
 
167
- const failures: string[] = [];
326
+ const failures: { label: string; message: string }[] = [];
168
327
 
169
328
  for (const attempt of attempts) {
170
329
  const result = spawnSync(process.execPath, attempt.args, {
171
- input: loaderScript,
330
+ input: kernelInput,
172
331
  encoding: "utf8",
173
332
  });
174
333
 
175
334
  if (result.status === 0) {
335
+ let parsed: { config: unknown; layers?: SettingsLayer[] };
176
336
  try {
177
- return JSON.parse(result.stdout) as Settings;
337
+ parsed = JSON.parse(result.stdout) as { config: unknown; layers?: SettingsLayer[] };
178
338
  } catch {
179
339
  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)}`
340
+ `Config at ${configPath} did not produce valid JSON. Every layer must resolve ` +
341
+ `to a plain object (a \`config\`/default export, or a \`configure\` function).\n` +
342
+ ` Got: ${result.stdout.slice(0, 300)}`
182
343
  );
183
344
  }
345
+ // assertFlatConfig runs FIRST: its multi-project migration message is more
346
+ // actionable than a generic unknown-key error. validateSettings then checks
347
+ // the MERGED config against the zod schema (version, strict keys, shapes).
348
+ const flat = assertFlatConfig(parsed.config, configPath);
349
+ // The built-in repository and the implicit `core` subscription are added
350
+ // UNDER whatever the layers produced, and BEFORE validation, so that the
351
+ // shortest opt-out a project can write (`{ enabled: false }`) is a
352
+ // complete, valid entry once the built-in fields are underneath it. See
353
+ // `repos/defaults.ts`.
354
+ return {
355
+ settings: validateSettings(applyRepoDefaults(flat), configPath),
356
+ layers: parsed.layers ?? [],
357
+ };
184
358
  }
185
359
 
186
- failures.push(` [via ${attempt.label}] ${result.stderr?.trim() || "unknown error"}`);
360
+ failures.push({ label: attempt.label, message: result.stderr?.trim() || "unknown error" });
187
361
  }
188
362
 
189
- throw new Error(`Failed to load config from ${configPath}\n${failures.join("\n\n")}`);
363
+ // Kernel-side errors (bad JSON/YAML, old schema, configure() throw, cycle,
364
+ // bad builder var, non-object layer) are raised inside the .mjs kernel, which
365
+ // runs identically under both the `node` and `tsx` attempts — so both stderrs
366
+ // are the same. Collapse identical messages so a single config error is not
367
+ // printed twice as if it were two distinct failures.
368
+ //
369
+ // The tsx attempt re-imports an ESM `.js` config that the `node` attempt
370
+ // already loaded and reported a real error for; on the tsx attempt this
371
+ // surfaces as a spurious "Cannot require() ES Module … in a cycle"
372
+ // (ERR_REQUIRE_CYCLE_MODULE) that is never a real user-config problem. Drop it
373
+ // whenever another attempt produced a genuine error, so the real message is
374
+ // not buried under an unrelated require(esm)-cycle warning.
375
+ const isTsxCycleArtifact = (message: string): boolean =>
376
+ /ERR_REQUIRE_CYCLE_MODULE/.test(message) ||
377
+ /Cannot require\(\) ES Module .* in a cycle/.test(message);
378
+ const meaningfulFailures = failures.filter((f) => !isTsxCycleArtifact(f.message));
379
+ const effectiveFailures = meaningfulFailures.length > 0 ? meaningfulFailures : failures;
380
+
381
+ const uniqueMessages = [...new Set(effectiveFailures.map((f) => f.message))];
382
+ const detail =
383
+ uniqueMessages.length === 1
384
+ ? ` ${uniqueMessages[0]}`
385
+ : effectiveFailures.map((f) => ` [via ${f.label}] ${f.message}`).join("\n\n");
386
+
387
+ throw new ConfigError(`Failed to load config from ${configPath}\n${detail}`);
190
388
  }
191
389
 
192
- // --- Variable Resolution -------------------------------------------------------------------------
193
-
194
390
  /**
195
- * Substitutes ${varName} references in a string using the provided scope.
196
- * Unknown variable references are left as-is.
391
+ * Loads the merged settings for a discovered config (or a bare config file
392
+ * path). Thin wrapper over loadSettingsWithLayers for callers that do not need
393
+ * the trace-mode layer snapshots.
197
394
  */
198
- export function substituteVars(str: string, scope: VarScope): string {
199
- return str.replace(/\$\{([^}]+)\}/g, (match, name: string) => scope[name] ?? match);
395
+ export async function loadSettings(
396
+ source: DiscoveredConfig | string,
397
+ options: LoadSettingsOptions = {}
398
+ ): Promise<Settings> {
399
+ const { settings } = await loadSettingsWithLayers(source, options);
400
+ return settings;
200
401
  }
201
402
 
202
403
  /**
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.
404
+ * Rejects configs written in the removed multi-project schema. One config now
405
+ * describes exactly one project; the fields that used to live inside a
406
+ * `projects.<key>` entry sit at the top level instead.
206
407
  */
207
- export class ConfigError extends Error {
208
- readonly isConfigError = true;
209
-
210
- constructor(message: string) {
211
- super(message);
212
- this.name = "ConfigError";
408
+ function assertFlatConfig(raw: unknown, configPath: string): Settings {
409
+ if (
410
+ raw !== null &&
411
+ typeof raw === "object" &&
412
+ ("projects" in raw || "defaultProject" in raw)
413
+ ) {
414
+ throw new ConfigError(
415
+ `Config at ${configPath} uses the removed multi-project schema ` +
416
+ `('projects' / 'defaultProject').\n` +
417
+ ` A sous config now describes exactly one project. To migrate:\n` +
418
+ ` 1. Move your single project's fields (name, _vars, _aliases, compilation,\n` +
419
+ ` runtimeContext, tools) to the top level of the config.\n` +
420
+ ` 2. Delete the 'projects' and 'defaultProject' keys.\n` +
421
+ ` A config with several projects must be split into one config per project.`
422
+ );
213
423
  }
424
+ return raw as Settings;
214
425
  }
215
426
 
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
- );
427
+ // --- Variable Resolution -------------------------------------------------------------------------
428
+
429
+ /**
430
+ * Substitutes ${varName} references in a string using the provided scope.
431
+ * Unknown variable references are left as-is.
432
+ */
433
+ export function substituteVars(str: string, scope: VarScope): string {
434
+ return str.replace(/\$\{([^}]+)\}/g, (match, name: string) => scope[name] ?? match);
224
435
  }
225
436
 
226
437
  /** Returns the names of every `${var}` reference left unresolved in a string. */
@@ -258,7 +469,7 @@ export function normalizeConfigPath(value: string): string {
258
469
  * @param str - The raw value from the config.
259
470
  * @param scope - The resolved variable scope.
260
471
  * @param context - Where the value came from, e.g.
261
- * `project 'foundry' → compilation.targets[0].entryPoint`. Named in the error.
472
+ * `compilation.targets[0].entryPoint`. Named in the error.
262
473
  * @returns The fully substituted string.
263
474
  * @throws When one or more `${var}` references are unresolved.
264
475
  */
@@ -284,10 +495,20 @@ export function substituteVarsStrict(str: string, scope: VarScope, context: stri
284
495
  }
285
496
 
286
497
  /**
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
498
+ * Resolves a _vars block into a new scope with a FIXPOINT loop:
499
+ *
500
+ * 1. Start from the inherited scope. Every block entry begins unresolved.
501
+ * 2. Each round, substitute every still-unresolved entry against the current
502
+ * scope (inherited vars + entries already finalized this pass). An entry
503
+ * whose `${refs}` all resolve is finalized and added to the scope.
504
+ * 3. Repeat until a round finalizes nothing.
505
+ *
506
+ * Because each round re-scans every unresolved entry, declaration order does not
507
+ * matter: `{ file: "${root}/x", root: "/data" }` resolves as readily as the
508
+ * reverse. When progress stops with entries still unresolved, that is a hard
509
+ * error (see buildUnresolvedScopeError): a `${ref}` that never resolves — a typo,
510
+ * a cycle, or a name defined nowhere — is a config mistake, not a literal to be
511
+ * passed through silently.
291
512
  */
292
513
  export function resolveScope(block: Record<string, string>, inherited: VarScope): VarScope {
293
514
  const blockKeys = Object.keys(block);
@@ -299,45 +520,158 @@ export function resolveScope(block: Record<string, string>, inherited: VarScope)
299
520
  }
300
521
  }
301
522
 
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))));
523
+ const scope: VarScope = { ...inherited };
524
+ const unresolved = new Set(blockKeys);
525
+
526
+ let progressed = true;
527
+ while (progressed && unresolved.size > 0) {
528
+ progressed = false;
529
+ for (const key of unresolved) {
530
+ const substituted = substituteVars(block[key], scope);
531
+ if (findUnresolvedVars(substituted).length === 0) {
532
+ scope[key] = substituted;
533
+ unresolved.delete(key);
534
+ progressed = true;
535
+ }
536
+ }
537
+ }
538
+
539
+ if (unresolved.size > 0) {
540
+ throw buildUnresolvedScopeError(block, scope, unresolved);
541
+ }
542
+
543
+ return scope;
544
+ }
545
+
546
+ /**
547
+ * Builds the ConfigError thrown when resolveScope's fixpoint stops with entries
548
+ * still unresolved. The message names each unresolved entry and the exact
549
+ * `${names}` it still needs, then separates the two failure modes: reference
550
+ * CYCLES (entries that depend on each other, no starting point) and UNDEFINED
551
+ * references (names defined nowhere). It closes with the variables that ARE in
552
+ * scope, so a typo is obvious at a glance.
553
+ *
554
+ * @param block - The raw _vars block being resolved.
555
+ * @param scope - The working scope (inherited vars + every entry that DID
556
+ * resolve); its keys are the "in scope" list.
557
+ * @param unresolved - The block keys that never resolved.
558
+ */
559
+ function buildUnresolvedScopeError(
560
+ block: Record<string, string>,
561
+ scope: VarScope,
562
+ unresolved: Set<string>
563
+ ): ConfigError {
564
+ // For each unresolved entry, the ${names} still missing after fixpoint. Every
565
+ // such name is either another unresolved block key (an intra-block edge) or a
566
+ // name defined nowhere (resolvable block keys and inherited vars are already
567
+ // in `scope`, so they never appear here).
568
+ const needs = new Map<string, string[]>();
569
+ for (const key of unresolved) {
570
+ needs.set(key, findUnresolvedVars(substituteVars(block[key], scope)));
571
+ }
572
+
573
+ // Dependency graph among unresolved entries: u -> v when u still needs the
574
+ // still-unresolved block key v. SCCs of this graph are the reference cycles.
575
+ const edges = new Map<string, string[]>();
576
+ for (const key of unresolved) {
577
+ edges.set(key, (needs.get(key) ?? []).filter((n) => unresolved.has(n)));
578
+ }
579
+ const cycles = findCycles([...unresolved], edges);
580
+
581
+ // Undefined references: needed names that are not block keys at all.
582
+ const undefinedRefs = new Map<string, string[]>();
583
+ for (const key of unresolved) {
584
+ for (const name of needs.get(key) ?? []) {
585
+ if (!unresolved.has(name)) {
586
+ const referrers = undefinedRefs.get(name) ?? [];
587
+ referrers.push(key);
588
+ undefinedRefs.set(name, referrers);
589
+ }
590
+ }
591
+ }
592
+
593
+ const lines: string[] = [
594
+ "Unresolved variables after fixpoint resolution of a _vars block:",
595
+ ];
596
+ for (const key of [...unresolved].sort()) {
597
+ const names = (needs.get(key) ?? []).map((n) => `\${${n}}`).join(", ");
598
+ lines.push(` - ${key} still needs ${names || "(nothing resolvable)"}`);
307
599
  }
308
600
 
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;
601
+ if (cycles.length > 0) {
602
+ lines.push("");
603
+ lines.push("Reference cycles (these variables reference each other):");
604
+ for (const cycle of cycles) {
605
+ const sorted = [...cycle].sort();
606
+ lines.push(` - ${[...sorted, sorted[0]].join(" -> ")}`);
320
607
  }
321
- visiting.add(key);
322
- for (const dep of deps.get(key) ?? []) {
323
- visit(dep);
608
+ }
609
+
610
+ if (undefinedRefs.size > 0) {
611
+ lines.push("");
612
+ lines.push("Undefined references (names defined nowhere — no _vars, _env, or auto-var):");
613
+ for (const name of [...undefinedRefs.keys()].sort()) {
614
+ const referrers = [...new Set(undefinedRefs.get(name) ?? [])].sort();
615
+ lines.push(` - \${${name}} (needed by ${referrers.join(", ")})`);
324
616
  }
325
- visiting.delete(key);
326
- visited.add(key);
327
- sorted.push(key);
328
617
  }
329
618
 
330
- for (const key of blockKeys) {
331
- visit(key);
619
+ const inScope = Object.keys(scope).sort();
620
+ lines.push("");
621
+ lines.push(`Variables in scope here: ${inScope.length > 0 ? inScope.join(", ") : "(none)"}`);
622
+
623
+ return new ConfigError(lines.join("\n"));
624
+ }
625
+
626
+ /**
627
+ * Finds reference cycles in a directed graph via Tarjan's strongly-connected-
628
+ * component algorithm. A cycle is an SCC with more than one member, or a single
629
+ * node that references itself. Returns each cycle as its member list.
630
+ */
631
+ function findCycles(nodes: string[], edges: Map<string, string[]>): string[][] {
632
+ const index = new Map<string, number>();
633
+ const low = new Map<string, number>();
634
+ const onStack = new Set<string>();
635
+ const stack: string[] = [];
636
+ const sccs: string[][] = [];
637
+ let counter = 0;
638
+
639
+ function strongconnect(v: string): void {
640
+ index.set(v, counter);
641
+ low.set(v, counter);
642
+ counter++;
643
+ stack.push(v);
644
+ onStack.add(v);
645
+
646
+ for (const w of edges.get(v) ?? []) {
647
+ if (!index.has(w)) {
648
+ strongconnect(w);
649
+ low.set(v, Math.min(low.get(v)!, low.get(w)!));
650
+ } else if (onStack.has(w)) {
651
+ low.set(v, Math.min(low.get(v)!, index.get(w)!));
652
+ }
653
+ }
654
+
655
+ if (low.get(v) === index.get(v)) {
656
+ const component: string[] = [];
657
+ let w: string;
658
+ do {
659
+ w = stack.pop()!;
660
+ onStack.delete(w);
661
+ component.push(w);
662
+ } while (w !== v);
663
+ sccs.push(component);
664
+ }
332
665
  }
333
666
 
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);
667
+ for (const v of nodes) {
668
+ if (!index.has(v)) strongconnect(v);
338
669
  }
339
670
 
340
- return scope;
671
+ return sccs.filter(
672
+ (component) =>
673
+ component.length > 1 || (edges.get(component[0]) ?? []).includes(component[0])
674
+ );
341
675
  }
342
676
 
343
677
  /**
@@ -348,8 +682,12 @@ export function resolveScope(block: Record<string, string>, inherited: VarScope)
348
682
  export type ConfigContext = {
349
683
  /** Absolute path to the `.sous/` directory holding the config. */
350
684
  sousDir: string;
351
- /** Absolute path to the config file itself. */
685
+ /** Absolute path to the primary config file itself. */
352
686
  configPath: string;
687
+ /** Absolute path to the `conf.d/` drop-in directory (may not exist). */
688
+ confDir?: string;
689
+ /** Ordered absolute paths of every loaded config layer (primary first). */
690
+ layerPaths?: string[];
353
691
  };
354
692
 
355
693
  /**
@@ -357,18 +695,26 @@ export type ConfigContext = {
357
695
  * and injected first, before _env and _vars.
358
696
  * The 'sous*' namespace is reserved — warns if user defines a var starting with 'sous'.
359
697
  *
698
+ * `sousHome` is resolved from `process.env` on every call rather than captured
699
+ * once, because `SOUS_HOME` is file-settable: `.sous/.env.local` and
700
+ * `.sous/.env` are loaded into `process.env` before settings resolve.
701
+ *
360
702
  * @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.
703
+ * `sousDir` and `sousConfigPath` (plus `sousConfDir` when known) so configs
704
+ * can build paths relative to their own `.sous/` directory.
363
705
  */
364
706
  export function buildAutoVars(context?: ConfigContext): VarScope {
365
707
  return {
366
708
  sousRootPath: CLI_ROOT,
367
709
  sousVersion: SOUS_VERSION,
710
+ sousHome: resolveSousHome(),
368
711
  ...(context !== undefined && {
369
712
  sousDir: context.sousDir,
370
713
  sousConfigPath: context.configPath,
371
714
  }),
715
+ ...(context?.confDir !== undefined && {
716
+ sousConfDir: context.confDir,
717
+ }),
372
718
  };
373
719
  }
374
720
 
@@ -428,39 +774,36 @@ export function resolveRootScope(settings: Settings, context?: ConfigContext): V
428
774
  /**
429
775
  * Built-in `@include` aliases, always available and reserved (their names begin
430
776
  * with `~` so user `_aliases` can never shadow them). Add new entries here as
431
- * needed — keep names kebab-case.
777
+ * needed; keep names kebab-case.
432
778
  *
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`).
779
+ * - `~project` → the consuming project's root (`projectRoot`).
780
+ *
781
+ * There is exactly one, on purpose. Files that used to be reached through a
782
+ * built-in alias pointing inside the sous package are published as recipes now,
783
+ * and a recipe's files are addressed by its namespace (`@~workflow/task-files/
784
+ * _partials/resume-task.md`), resolved against what the project has pinned. A
785
+ * `~namespace` reference is NOT an alias: it is resolved separately, after the
786
+ * alias map has been tried; see `locked-namespace-resolver.ts`.
436
787
  */
437
788
  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
- };
789
+ const builtIns: AliasMap = {};
442
790
  if (scope.projectRoot) builtIns["~project"] = [scope.projectRoot];
443
791
  return builtIns;
444
792
  }
445
793
 
446
794
  /**
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
795
+ * Resolve the full `@include` alias map: built-ins, then the config's
796
+ * `_aliases` block (user entries prepend, so they are tried first and fall
797
+ * through to built-in bases of the same name). User alias names starting
450
798
  * with `~` are rejected (reserved).
451
799
  *
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).
800
+ * @param settings - The loaded settings (for the `_aliases` block).
801
+ * @param scope - The resolved settings scope (for ${var} substitution + projectRoot).
455
802
  */
456
- export function resolveAliases(
457
- settings: Settings,
458
- project: RawProject,
459
- scope: VarScope
460
- ): AliasMap {
803
+ export function resolveAliases(settings: Settings, scope: VarScope): AliasMap {
461
804
  return buildAliasMap({
462
805
  builtIns: buildBuiltInAliases(scope),
463
- userAliases: [settings._aliases, project._aliases],
806
+ userAliases: [settings._aliases],
464
807
  scope,
465
808
  onError: warning,
466
809
  });
@@ -474,20 +817,20 @@ export type ResolvedToolConfig = {
474
817
  };
475
818
 
476
819
  /**
477
- * Resolves a project's tools config, substituting vars in promptFile paths.
820
+ * Resolves the config's tools block, substituting vars in promptFile paths.
478
821
  * Returns an empty object if no tools are configured.
822
+ *
823
+ * @param settings - The loaded settings.
824
+ * @param scope - The resolved settings scope (from resolveRootScope).
479
825
  */
480
- export function resolveProjectTools(
481
- project: RawProject,
482
- rootScope: VarScope = {},
483
- projectKey = project.name
826
+ export function resolveTools(
827
+ settings: Settings,
828
+ scope: VarScope = {}
484
829
  ): Record<string, ResolvedToolConfig> {
485
- if (!project.tools) return {};
486
-
487
- const projectScope = resolveScope(project._vars ?? {}, rootScope);
830
+ if (!settings.tools) return {};
488
831
 
489
832
  return Object.fromEntries(
490
- Object.entries(project.tools).map(([name, tool]) => [
833
+ Object.entries(settings.tools).map(([name, tool]) => [
491
834
  name,
492
835
  {
493
836
  command: tool.command,
@@ -495,8 +838,8 @@ export function resolveProjectTools(
495
838
  ...(tool.promptFile !== undefined && {
496
839
  promptFile: substituteVarsStrict(
497
840
  tool.promptFile,
498
- projectScope,
499
- `project '${projectKey}' → tools.${name}.promptFile`
841
+ scope,
842
+ `tools.${name}.promptFile`
500
843
  ),
501
844
  }),
502
845
  },
@@ -524,30 +867,27 @@ function resolveRuntimeContext(
524
867
  }
525
868
 
526
869
  /**
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.
870
+ * Resolves the config's compilation block into the shape the compiler expects.
871
+ * Walks the config tree resolving _vars at each level (settings → compilation → target → output).
872
+ * Pass scope from resolveRootScope(settings) to thread the settings vars down.
873
+ * Returns null if the config has no compilation block.
531
874
  */
532
- export function resolveProjectCompilation(
533
- project: RawProject,
534
- rootScope: VarScope = {},
535
- settings: Settings = { projects: {} },
536
- projectKey = project.name
875
+ export function resolveCompilation(
876
+ settings: Settings,
877
+ scope: VarScope = {}
537
878
  ): CompilationConfig | null {
538
- if (!project.compilation) return null;
879
+ if (!settings.compilation) return null;
539
880
 
540
- const projectScope = resolveScope(project._vars ?? {}, rootScope);
541
- const compilationScope = resolveScope(project.compilation._vars ?? {}, projectScope);
542
- const aliases = resolveAliases(settings, project, projectScope);
881
+ const compilationScope = resolveScope(settings.compilation._vars ?? {}, scope);
882
+ const aliases = resolveAliases(settings, scope);
543
883
 
544
884
  return {
545
- includeSourceComments: project.compilation.includeSourceComments,
885
+ includeSourceComments: settings.compilation.includeSourceComments,
546
886
  aliases,
547
- includeScope: projectScope,
548
- targets: project.compilation.targets.flatMap((target, targetIndex): CompilationTarget[] => {
887
+ includeScope: scope,
888
+ targets: settings.compilation.targets.flatMap((target, targetIndex): CompilationTarget[] => {
549
889
  const targetScope = resolveScope(target._vars ?? {}, compilationScope);
550
- const where = `project '${projectKey}' → compilation.targets[${targetIndex}]`;
890
+ const where = `compilation.targets[${targetIndex}]`;
551
891
  const hasSingle = target.entryPoint !== undefined;
552
892
  const hasGlob = target.entryGlob !== undefined;
553
893
 
@@ -600,12 +940,8 @@ export function resolveProjectCompilation(
600
940
 
601
941
  if (hasSingle) {
602
942
  const runtimeContext =
603
- target.generateRuntimeContext && project.runtimeContext
604
- ? resolveRuntimeContext(
605
- project.runtimeContext,
606
- projectScope,
607
- `project '${projectKey}' → runtimeContext`
608
- )
943
+ target.generateRuntimeContext && settings.runtimeContext
944
+ ? resolveRuntimeContext(settings.runtimeContext, scope, "runtimeContext")
609
945
  : undefined;
610
946
  return [{
611
947
  rootInputPath: normalizeConfigPath(
@@ -617,13 +953,26 @@ export function resolveProjectCompilation(
617
953
  }
618
954
 
619
955
  /* 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());
956
+ // Glob target: expand pattern into one CompilationTarget per matched file, skipping dirs.
957
+ // A leading alias (`~project/skills/**`) expands to one candidate pattern per alias
958
+ // base; the first base that matches any files wins, mirroring the first-existing-wins
959
+ // rule of @include resolution.
960
+ const rawPattern = substituteVarsStrict(target.entryGlob!, targetScope, `${where}.entryGlob`);
961
+ const patternCandidates = resolveAliasPrefix(rawPattern, aliases);
962
+ let pattern = patternCandidates[0];
963
+ let matchedFiles: string[] = [];
964
+ for (const candidate of patternCandidates) {
965
+ const matches = globSync(candidate, { absolute: true })
966
+ .filter(filePath => fs.statSync(filePath).isFile());
967
+ if (matches.length > 0) {
968
+ pattern = candidate;
969
+ matchedFiles = matches;
970
+ break;
971
+ }
972
+ }
624
973
 
625
974
  if (matchedFiles.length === 0) {
626
- warning(`Glob pattern matched no files:\n${pattern}`);
975
+ warning(`Glob pattern matched no files:\n${patternCandidates.join("\n")}`);
627
976
  }
628
977
 
629
978
  const globBase = normalizeConfigPath(
@@ -658,33 +1007,41 @@ export type WatchConfig = {
658
1007
  };
659
1008
 
660
1009
  /**
661
- * Returns the watch configuration for a project's compilation targets.
1010
+ * Returns the watch configuration for the config's compilation targets.
662
1011
  *
663
1012
  * - entryPoint targets → exact file path in `files`.
664
1013
  * - entryGlob targets → resolved glob string in `globs`.
1014
+ *
1015
+ * Alias prefixes in entryGlob patterns are expanded (every base of the alias
1016
+ * is watched, matching compile's fall-through resolution).
665
1017
  */
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);
1018
+ export function resolveWatchConfig(settings: Settings, scope: VarScope = {}): WatchConfig {
1019
+ if (!settings.compilation) return { files: [], globs: [] };
1020
+
1021
+ const compilationScope = resolveScope(settings.compilation._vars ?? {}, scope);
1022
+ const aliases = resolveAliases(settings, scope);
675
1023
  const files: string[] = [];
676
1024
  const globs: string[] = [];
677
1025
 
678
- for (const [targetIndex, target] of project.compilation.targets.entries()) {
1026
+ for (const [targetIndex, target] of settings.compilation.targets.entries()) {
679
1027
  const targetScope = resolveScope(target._vars ?? {}, compilationScope);
680
- const where = `project '${projectKey}' → compilation.targets[${targetIndex}]`;
1028
+ const where = `compilation.targets[${targetIndex}]`;
681
1029
 
682
1030
  if (target.entryPoint) {
683
- files.push(substituteVarsStrict(target.entryPoint, targetScope, `${where}.entryPoint`));
1031
+ // Normalize to match the compilation path (rootInputPath) and, more
1032
+ // importantly, chokidar's change events: chokidar reports the OS-normalized
1033
+ // path, so an unnormalized `${sousDir}/../prompts/A.md` entry would never
1034
+ // string-match the `/prompts/A.md` event and the partial rebuild would never fire.
1035
+ files.push(
1036
+ normalizeConfigPath(
1037
+ substituteVarsStrict(target.entryPoint, targetScope, `${where}.entryPoint`)
1038
+ )
1039
+ );
684
1040
  }
685
1041
 
686
1042
  if (target.entryGlob) {
687
- globs.push(substituteVarsStrict(target.entryGlob, targetScope, `${where}.entryGlob`));
1043
+ const pattern = substituteVarsStrict(target.entryGlob, targetScope, `${where}.entryGlob`);
1044
+ globs.push(...resolveAliasPrefix(pattern, aliases).map(normalizeConfigPath));
688
1045
  }
689
1046
  }
690
1047