@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
@@ -83,6 +83,23 @@ function findClosingQuote(value: string, quote: string): number {
83
83
  return -1;
84
84
  }
85
85
 
86
+ /**
87
+ * Reads one env file and returns its variables as a flat map, WITHOUT touching
88
+ * `process.env`. Returns an empty map when the file does not exist.
89
+ *
90
+ * The variables layer needs each env file on its own: by the time a command
91
+ * runs, `loadEnvFiles` has already merged both files into the process
92
+ * environment, so reading them separately is the only way to say which layer
93
+ * supplied an answer.
94
+ *
95
+ * @param filePath - Absolute path to the env file.
96
+ * @returns The parsed variables, or an empty map when there is no such file.
97
+ */
98
+ export function readEnvFileMap(filePath: string): Record<string, string> {
99
+ if (!fs.existsSync(filePath)) return {};
100
+ return parseEnvLocal(fs.readFileSync(filePath, "utf8"));
101
+ }
102
+
86
103
  /** The outcome of an attempted env file load. */
87
104
  export type EnvLocalLoadResult = {
88
105
  /** Absolute path checked. */
@@ -155,7 +172,7 @@ export function loadEnvDefaults(
155
172
  * `process.env`, if it exists.
156
173
  *
157
174
  * Existing environment values always win: a variable already set in the real
158
- * environment is never overwritten, so `FOO=bar xcv build` behaves as expected.
175
+ * environment is never overwritten, so `FOO=bar sous build` behaves as expected.
159
176
  *
160
177
  * @param sousDir - The discovered `.sous/` directory.
161
178
  * @param env - The environment object to mutate (injectable for tests).
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Shared error types for sous.
3
+ *
4
+ * ConfigError lives in its own module (rather than settings.ts) so that
5
+ * config-discovery.ts can throw it without importing settings.ts — settings.ts
6
+ * imports discovery constants, and a back-import would create a cycle.
7
+ * settings.ts re-exports both names for backwards compatibility.
8
+ */
9
+
10
+ /**
11
+ * A user-facing configuration error: the config file (or environment) is wrong,
12
+ * not the CLI. Commands render these as a plain message with no stack trace,
13
+ * since the stack points at Sous internals and tells the user nothing.
14
+ */
15
+ export class ConfigError extends Error {
16
+ readonly isConfigError = true;
17
+
18
+ constructor(message: string) {
19
+ super(message);
20
+ this.name = "ConfigError";
21
+ }
22
+ }
23
+
24
+ /** True when the value is a ConfigError (safe across module instances). */
25
+ export function isConfigError(error: unknown): boolean {
26
+ return (
27
+ error instanceof ConfigError ||
28
+ (typeof error === "object" &&
29
+ error !== null &&
30
+ (error as { isConfigError?: boolean }).isConfigError === true)
31
+ );
32
+ }
@@ -1,8 +1,9 @@
1
1
  import path from "node:path";
2
+ import type { NamespaceResolution, NamespaceResolver } from "./repos/namespace-resolver.js";
2
3
 
3
4
  /**
4
- * @include path resolution: aliases, variable substitution, and the ordered
5
- * candidate search.
5
+ * @include path resolution: aliases, recipe namespaces, variable substitution,
6
+ * and the ordered candidate search.
6
7
  *
7
8
  * An include path (the part after `@`) is resolved to an ordered list of
8
9
  * candidate absolute paths. The caller tries each in order and uses the first
@@ -14,11 +15,19 @@ import path from "node:path";
14
15
  * 2. Split the first segment (up to the first `/` or `:`) as the alias key,
15
16
  * the remainder as `rest`. If the key is a registered alias, push
16
17
  * join(base, rest) for EACH base in the alias's ordered array.
17
- * 3. Always push the relative candidate: join(baseDir, P) — the FULL path
18
+ * 3. If the key begins with `~` and a namespace resolver was supplied, ask it
19
+ * for the recipe namespace named by the key (minus the `~`) and push its
20
+ * candidates. Aliases are consulted first, so `~project` keeps meaning
21
+ * the built-in alias even if a namespace of that name exists.
22
+ * 4. Always push the relative candidate: join(baseDir, P) — the FULL path
18
23
  * including the alias segment. This lets an alias augment a real relative
19
24
  * directory of the same name (e.g. `@stuff/x` tries the alias bases, then
20
25
  * `./stuff/x`).
21
26
  *
27
+ * A key WITHOUT the `~` sigil never reaches the namespace resolver: a bare
28
+ * `@path` is always a relative path or a declared alias, so include lines never
29
+ * masquerade as filesystem paths.
30
+ *
22
31
  * Aliases whose names begin with `~` are reserved for built-ins; user aliases
23
32
  * may not use that prefix. The primary separator is `/` (TS-style,
24
33
  * `@alias/path`); `:` is accepted as an equivalent (`@alias:path`).
@@ -52,29 +61,69 @@ export function splitAliasKey(p: string): { key: string; rest: string } {
52
61
  return { key: m[1], rest: m[2] };
53
62
  }
54
63
 
64
+ /** Options shared by {@link resolveInclude} and {@link resolveIncludeCandidates}. */
65
+ export type IncludeResolveOptions = {
66
+ /** The resolved alias map (name → ordered base dirs). */
67
+ aliases?: AliasMap;
68
+ /** Variable scope for ${var} substitution. */
69
+ scope?: Record<string, string>;
70
+ /** Directory of the including file (for the relative candidate). */
71
+ baseDir: string;
72
+ /** Resolver consulted for a `~namespace` first segment; omitted means namespaces are unavailable. */
73
+ namespaceResolver?: NamespaceResolver;
74
+ /**
75
+ * Absolute path of the including file, handed to the namespace resolver so it
76
+ * can tell whether the include comes from inside a recipe. Defaults to
77
+ * `baseDir` for callers that only know the directory.
78
+ */
79
+ fromFile?: string;
80
+ };
81
+
82
+ /** A namespace lookup that produced no usable candidates, kept for error reporting. */
83
+ export type NamespaceIssue = {
84
+ /** The namespace that was asked for, without its `~` sigil. */
85
+ namespace: string;
86
+ /** The remainder of the reference (recipe name plus the path inside it). */
87
+ rest: string;
88
+ /** The file (or directory) that performed the include. */
89
+ fromFile: string;
90
+ /** What the resolver returned. */
91
+ resolution: NamespaceResolution;
92
+ };
93
+
94
+ /** The full result of resolving one include path. */
95
+ export type IncludeResolution = {
96
+ /** Ordered, de-duplicated absolute candidate paths. */
97
+ candidates: string[];
98
+ /**
99
+ * Present when the first segment named a `~namespace` that the resolver could
100
+ * not satisfy. The candidate list is still usable (it holds the alias and
101
+ * relative fallbacks); this only explains what went wrong with the namespace.
102
+ */
103
+ namespaceIssue?: NamespaceIssue;
104
+ };
105
+
55
106
  /**
56
- * Compute the ordered list of candidate absolute paths for an include.
107
+ * Resolve an include path to its ordered candidates plus any namespace
108
+ * diagnostic. Use this when the caller wants to report WHY a `~namespace`
109
+ * reference failed; {@link resolveIncludeCandidates} is the plain-list form.
57
110
  *
58
111
  * @param rawPath - The include path with the leading `@` already stripped.
59
- * @param opts.aliases - The resolved alias map (name → ordered base dirs).
60
- * @param opts.scope - Variable scope for ${var} substitution.
61
- * @param opts.baseDir - Directory of the including file (for the relative candidate).
62
- * @returns Ordered, de-duplicated absolute candidate paths.
112
+ * @param opts - Aliases, variable scope, including directory and optional namespace resolver.
113
+ * @returns The candidate list and, when a namespace lookup failed, the reason.
63
114
  */
64
- export function resolveIncludeCandidates(
65
- rawPath: string,
66
- opts: { aliases?: AliasMap; scope?: Record<string, string>; baseDir: string }
67
- ): string[] {
115
+ export function resolveInclude(rawPath: string, opts: IncludeResolveOptions): IncludeResolution {
68
116
  const aliases = opts.aliases ?? {};
69
117
  const scope = opts.scope ?? {};
70
118
  const substituted = substituteVars(rawPath, scope);
71
119
 
72
120
  // 1. Substituted to an absolute path → that is the only candidate.
73
121
  if (path.isAbsolute(substituted)) {
74
- return [path.normalize(substituted)];
122
+ return { candidates: [path.normalize(substituted)] };
75
123
  }
76
124
 
77
125
  const candidates: string[] = [];
126
+ let namespaceIssue: NamespaceIssue | undefined;
78
127
 
79
128
  // 2. Alias bases (ordered), if the first segment is a registered alias.
80
129
  const { key, rest } = splitAliasKey(substituted);
@@ -84,11 +133,55 @@ export function resolveIncludeCandidates(
84
133
  }
85
134
  }
86
135
 
87
- // 3. Relative fallback: the FULL substituted path under the including dir.
136
+ // 3. Recipe namespace, only for a `~`-sigil key naming something after it.
137
+ // Aliases above already had their turn, so a built-in or user alias of the
138
+ // same name is always preferred.
139
+ if (opts.namespaceResolver && key.startsWith("~") && key.length > 1 && rest.length > 0) {
140
+ const namespace = key.slice(1);
141
+ const fromFile = opts.fromFile ?? opts.baseDir;
142
+ const resolution = opts.namespaceResolver.resolve({ namespace, rest, fromFile });
143
+
144
+ if (resolution.kind === "candidates" && resolution.candidates.length > 0) {
145
+ candidates.push(...resolution.candidates.map((c) => path.normalize(c)));
146
+ } else {
147
+ namespaceIssue = { namespace, rest, fromFile, resolution };
148
+ }
149
+ }
150
+
151
+ // 4. Relative fallback: the FULL substituted path under the including dir.
88
152
  candidates.push(path.resolve(opts.baseDir, substituted));
89
153
 
90
154
  // De-dupe, preserving order.
91
- return [...new Set(candidates)];
155
+ return { candidates: [...new Set(candidates)], namespaceIssue };
156
+ }
157
+
158
+ /**
159
+ * Compute the ordered list of candidate absolute paths for an include.
160
+ *
161
+ * @param rawPath - The include path with the leading `@` already stripped.
162
+ * @param opts - Aliases, variable scope, including directory and optional namespace resolver.
163
+ * @returns Ordered, de-duplicated absolute candidate paths.
164
+ */
165
+ export function resolveIncludeCandidates(rawPath: string, opts: IncludeResolveOptions): string[] {
166
+ return resolveInclude(rawPath, opts).candidates;
167
+ }
168
+
169
+ /**
170
+ * Expand a leading alias segment in a path or glob pattern to one candidate
171
+ * per alias base, in the alias's base order. Used for config entry paths
172
+ * (`entryGlob`/watch patterns), where — unlike @include resolution — there is
173
+ * no including file to supply a relative fallback, so a non-alias path is
174
+ * returned unchanged as the sole candidate.
175
+ *
176
+ * @param p - The path or glob pattern (already ${var}-substituted).
177
+ * @param aliases - The resolved alias map (name → ordered base dirs).
178
+ * @returns Ordered candidate paths/patterns; `[p]` when no alias applies.
179
+ */
180
+ export function resolveAliasPrefix(p: string, aliases: AliasMap): string[] {
181
+ if (path.isAbsolute(p)) return [p];
182
+ const { key, rest } = splitAliasKey(p);
183
+ if (!key || !Object.prototype.hasOwnProperty.call(aliases, key)) return [p];
184
+ return aliases[key].map((base) => path.resolve(base, rest));
92
185
  }
93
186
 
94
187
  /**
@@ -0,0 +1,165 @@
1
+ /**
2
+ * The one rule for "may sous ask a question right now?".
3
+ *
4
+ * Every prompt in sous (the trust question, the subscribe confirmation, the
5
+ * choice between candidate refs, a variable question) is gated by this single
6
+ * predicate, so a run either can ask all of them or none of them. Three things
7
+ * can take the terminal away:
8
+ *
9
+ * - the global `--non-interactive` flag, which says so outright;
10
+ * - a truthy `CI` environment variable, which every continuous integration
11
+ * runner sets and which means no human is watching;
12
+ * - stdin or stdout not being a terminal, which is what piping or scripting
13
+ * a command looks like from in here.
14
+ *
15
+ * When a prompt cannot be shown, the run fails rather than guessing, and the
16
+ * failure names both the question that could not be asked and the flag (or the
17
+ * environment variables) that would have answered it ahead of time. Every
18
+ * confirmation in sous is answered by one shared flag, `--yes` (also `-y`,
19
+ * `--force`, and `--trust` on the commands that trust a repository), so a
20
+ * remedy names that flag and lists its other spellings once. The command layer
21
+ * prints the command's own help underneath that error, so the caller can see
22
+ * every flag without going looking; `sous help <command>` prints the same
23
+ * screen on demand.
24
+ *
25
+ * Every input is injectable, so a test can describe a terminal, a pipe or a CI
26
+ * runner without touching the real process.
27
+ */
28
+
29
+ import { ConfigError } from "./errors.js";
30
+
31
+ /** Everything the rule reads. Each field defaults to the real process. */
32
+ export type InteractiveInputs = {
33
+ /** The command line, scanned for `--non-interactive`. Defaults to `process.argv`. */
34
+ argv?: string[];
35
+ /** The environment, read for `CI`. Defaults to `process.env`. */
36
+ env?: NodeJS.ProcessEnv;
37
+ /** Whether stdin is a terminal. Defaults to the real stdin. */
38
+ stdinIsTTY?: boolean;
39
+ /** Whether stdout is a terminal. Defaults to the real stdout. */
40
+ stdoutIsTTY?: boolean;
41
+ };
42
+
43
+ /** The global flag that turns every prompt into an error. */
44
+ export const NON_INTERACTIVE_FLAG = "--non-interactive";
45
+
46
+ /**
47
+ * Values of `CI` that mean "not set". Everything else counts as set, because
48
+ * runners spell it `1`, `true` and `yes` in roughly equal measure and a run
49
+ * that guesses wrong hangs forever waiting on a question nobody can see.
50
+ */
51
+ const FALSY_CI_VALUES = new Set(["", "0", "false", "no", "off"]);
52
+
53
+ /**
54
+ * True when sous is attached to a terminal in both directions, was not told to
55
+ * stay quiet, and is not running inside a continuous integration runner.
56
+ *
57
+ * @param inputs - Overrides for the command line, environment and streams.
58
+ */
59
+ export function isInteractive(inputs: InteractiveInputs = {}): boolean {
60
+ return nonInteractiveReason(inputs) === undefined;
61
+ }
62
+
63
+ /**
64
+ * Why this run cannot ask a question, as a plain sentence, or undefined when it
65
+ * can. The reason is quoted in the error a blocked prompt raises, because "sous
66
+ * is not running where it can ask" is baffling on its own when the caller is
67
+ * sitting at a terminal and only `CI=1` in their shell made it true.
68
+ *
69
+ * @param inputs - Overrides for the command line, environment and streams.
70
+ */
71
+ export function nonInteractiveReason(inputs: InteractiveInputs = {}): string | undefined {
72
+ const argv = inputs.argv ?? process.argv;
73
+ const env = inputs.env ?? process.env;
74
+ const stdinIsTTY = inputs.stdinIsTTY ?? process.stdin.isTTY === true;
75
+ const stdoutIsTTY = inputs.stdoutIsTTY ?? process.stdout.isTTY === true;
76
+
77
+ if (hasNonInteractiveFlag(argv)) {
78
+ return `the '${NON_INTERACTIVE_FLAG}' flag was passed`;
79
+ }
80
+
81
+ const ci = env.CI;
82
+ if (ci !== undefined && !FALSY_CI_VALUES.has(ci.trim().toLowerCase())) {
83
+ return `the 'CI' environment variable is set to '${ci}'`;
84
+ }
85
+
86
+ if (!stdinIsTTY && !stdoutIsTTY) return "neither input nor output is a terminal";
87
+ if (!stdinIsTTY) return "input is not a terminal";
88
+ if (!stdoutIsTTY) return "output is not a terminal";
89
+
90
+ return undefined;
91
+ }
92
+
93
+ /**
94
+ * True when the command line carries `--non-interactive`. Scanning stops at a
95
+ * bare `--`, exactly as the config-flag readers do: anything after it belongs to
96
+ * a launched tool, never to sous.
97
+ *
98
+ * @param argv - The raw command line.
99
+ */
100
+ function hasNonInteractiveFlag(argv: string[]): boolean {
101
+ for (const arg of argv) {
102
+ if (arg === "--") return false;
103
+ if (arg === NON_INTERACTIVE_FLAG) return true;
104
+ }
105
+ return false;
106
+ }
107
+
108
+ /**
109
+ * A ConfigError raised because a question could not be asked. It carries a flag
110
+ * telling the command layer to print the command's own help underneath it, so
111
+ * the caller sees every flag that could have answered the question.
112
+ */
113
+ export class NonInteractiveError extends ConfigError {
114
+ /** Tells `BaseCommand` to print the command's help after this error. */
115
+ readonly showHelp = true;
116
+
117
+ constructor(message: string) {
118
+ super(message);
119
+ this.name = "NonInteractiveError";
120
+ }
121
+ }
122
+
123
+ /** True when an error asked for the command's help to be printed with it. */
124
+ export function wantsHelp(error: unknown): boolean {
125
+ return (
126
+ typeof error === "object" &&
127
+ error !== null &&
128
+ (error as { showHelp?: boolean }).showHelp === true
129
+ );
130
+ }
131
+
132
+ /** What a blocked prompt needs to describe itself. */
133
+ export type BlockedPrompt = {
134
+ /** The question that could not be asked, named as a person would name it. */
135
+ prompt: string;
136
+ /**
137
+ * How to answer it ahead of time: a flag, or the environment variables. A
138
+ * remedy names the flag's primary spelling, and lists its alternate spellings
139
+ * once, in one parenthetical; the alternates are defined in
140
+ * `utils/flags.ts` and the command's own help (printed underneath this error)
141
+ * lists them too.
142
+ */
143
+ remedy: string;
144
+ /** Extra lines of context, such as the candidates that could not be chosen between. */
145
+ details?: string[];
146
+ /** Overrides for the command line, environment and streams. */
147
+ inputs?: InteractiveInputs;
148
+ };
149
+
150
+ /**
151
+ * Builds the error a blocked prompt raises: what could not be asked, why sous
152
+ * could not ask it, and what would have answered it without a terminal.
153
+ *
154
+ * @param blocked - The prompt, the remedy and any extra context.
155
+ */
156
+ export function nonInteractiveError(blocked: BlockedPrompt): NonInteractiveError {
157
+ const reason = nonInteractiveReason(blocked.inputs ?? {}) ?? "there is no terminal to ask on";
158
+ const lines = [
159
+ `Sous has to ask ${blocked.prompt}, and it is not running where it can ask.`,
160
+ ` Why: ${reason}.`,
161
+ ];
162
+ for (const detail of blocked.details ?? []) lines.push(` ${detail}`);
163
+ lines.push(` Answer it ahead of time: ${blocked.remedy}`);
164
+ return new NonInteractiveError(lines.join("\n"));
165
+ }
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { execFileSync } from "node:child_process";
4
4
  import { get_encoding, type Tiktoken } from "tiktoken";
5
- import { displayError, log, showVar, subheading, warning } from "../utils/formatting.js";
5
+ import { displayError, log, showVariable, subheading, warning } from "../utils/formatting.js";
6
6
  import { createLiquidEngine } from "../templating/init-liquid-engine.js";
7
7
  import {
8
8
  StateService,
@@ -11,7 +11,11 @@ import {
11
11
  hashContent,
12
12
  recordDirCreation,
13
13
  } from "./state.js";
14
- import { resolveIncludeCandidates, type AliasMap } from "./include-resolver.js";
14
+ import { resolveInclude, type AliasMap } from "./include-resolver.js";
15
+ import {
16
+ formatNamespaceProblem,
17
+ type NamespaceResolver,
18
+ } from "./repos/namespace-resolver.js";
15
19
 
16
20
  export type ResolvedOutput = {
17
21
  destinationFile?: string;
@@ -62,6 +66,13 @@ export type CompilationServiceOptions = {
62
66
  strict?: boolean;
63
67
  rebuild?: boolean;
64
68
  dryRun?: boolean;
69
+ /**
70
+ * Resolves `@~<namespace>/<recipe>/<path>` includes and the matching
71
+ * `{% render %}` paths against recipe namespaces. Omit it and the `~` sigil
72
+ * only ever means an alias, which is the behavior for projects that use no
73
+ * repositories.
74
+ */
75
+ namespaceResolver?: NamespaceResolver;
65
76
  };
66
77
 
67
78
  /**
@@ -73,6 +84,36 @@ export type CompilationServiceOptions = {
73
84
  * "/foo/bar/*\/baz.md" → "/foo/bar"
74
85
  * "/**\/*" → "/"
75
86
  */
87
+ /**
88
+ * The file one output of one target writes, or undefined when the output names
89
+ * neither a destination file nor a destination directory.
90
+ *
91
+ * A `destinationFile` is used as it stands. A `destinationDir` mirrors the
92
+ * source tree underneath it, relative to the target's `globBase`, with `.tpl.`
93
+ * stripped from the name.
94
+ *
95
+ * Exported because prune needs the same answer without compiling: a group of
96
+ * targets that share one destination directory (every subscribed recipe writing
97
+ * into `.claude/skills`, say) can only be pruned precisely if the exact set of
98
+ * files they write is known.
99
+ *
100
+ * @param target - The compilation target.
101
+ * @param output - One of its outputs.
102
+ */
103
+ export function resolveOutputPath(
104
+ target: Pick<CompilationTarget, "rootInputPath" | "globBase">,
105
+ output: ResolvedOutput
106
+ ): string | undefined {
107
+ if (output.destinationFile) return output.destinationFile;
108
+ if (!output.destinationDir) return undefined;
109
+
110
+ const sourceRelative = target.globBase
111
+ ? path.relative(target.globBase, target.rootInputPath)
112
+ : path.basename(target.rootInputPath);
113
+
114
+ return path.join(output.destinationDir, sourceRelative.replace(/\.tpl\./, "."));
115
+ }
116
+
76
117
  export function inferGlobBase(pattern: string): string {
77
118
  const parts = pattern.split("/");
78
119
  const staticParts: string[] = [];
@@ -101,6 +142,7 @@ export class CompilationService {
101
142
  private numberFormatter: Intl.NumberFormat;
102
143
  private aliases: AliasMap;
103
144
  private includeScope: Record<string, string>;
145
+ private namespaceResolver?: NamespaceResolver;
104
146
  /**
105
147
  * `.tpl.` outputs that were written without a variable scope, so LiquidJS never
106
148
  * ran and the template shipped with its tags intact. Reported at the end of the
@@ -121,6 +163,7 @@ export class CompilationService {
121
163
  this.numberFormatter = new Intl.NumberFormat("en-US");
122
164
  this.aliases = {};
123
165
  this.includeScope = {};
166
+ this.namespaceResolver = options.namespaceResolver;
124
167
  this.unrenderedTemplates = [];
125
168
  }
126
169
 
@@ -146,19 +189,35 @@ export class CompilationService {
146
189
  * Matches an `@`-prefixed `.md` path on its own line. The path may be:
147
190
  * - relative to the including file (`@sections/intro.md`),
148
191
  * - a `${var}`-substituted path (`@${sousRootPath}/x.md`),
149
- * - or an alias path (`@~sous-shared/memories/x.md`, `@docs/x.md`), where the
150
- * first segment (up to `/` or `:`) names a registered alias.
192
+ * - an alias path (`@~project/memories/x.md`, `@docs/x.md`), where the
193
+ * first segment (up to `/` or `:`) names a registered alias,
194
+ * - or a recipe namespace path (`@~workflow/task-files/_partials/x.md`),
195
+ * where the `~` sigil names a namespace and the rest names a recipe and a
196
+ * file inside it. Namespaces are only consulted when a namespace resolver
197
+ * was supplied, and always after aliases.
151
198
  *
152
199
  * Lines inside fenced code blocks (``` or ~~~, per CommonMark) are left
153
200
  * verbatim, so include syntax can be documented without being executed.
154
201
  *
155
202
  * Resolution produces an ordered candidate list (see include-resolver); the
156
203
  * first candidate that exists on disk is used. If none exist, it errors,
157
- * listing every path tried.
204
+ * naming the including file and listing every path tried, plus what went
205
+ * wrong with the namespace lookup when one was attempted.
206
+ *
207
+ * @param content - The file's raw text.
208
+ * @param baseDir - Directory the relative candidate resolves against.
209
+ * @param projectRoot - Root used to render source comments as relative paths.
210
+ * @param fromFile - Absolute path of the file being processed; defaults to `baseDir`.
158
211
  */
159
- private processIncludes(content: string, baseDir: string, projectRoot: string): string {
160
- // First segment allows ~, then path chars; separators / and :; allows ${...}.
161
- const includePattern = /^@([~a-zA-Z0-9_${}][a-zA-Z0-9_\-/.:${}]*\.md)$/;
212
+ private processIncludes(
213
+ content: string,
214
+ baseDir: string,
215
+ projectRoot: string,
216
+ fromFile?: string
217
+ ): string {
218
+ // First segment allows ~ and . (so ./ and ../ work), then path chars;
219
+ // separators / and :; allows ${...}.
220
+ const includePattern = /^@([~a-zA-Z0-9_.${}][a-zA-Z0-9_\-/.:${}]*\.md)$/;
162
221
  const fenceOpenPattern = /^ {0,3}(`{3,}|~{3,})/;
163
222
  const fenceClosePattern = /^ {0,3}(`{3,}|~{3,})[ \t]*$/;
164
223
 
@@ -192,16 +251,23 @@ export class CompilationService {
192
251
  }
193
252
 
194
253
  const includePath = match[1].trim();
195
- const candidates = resolveIncludeCandidates(includePath, {
254
+ const { candidates, namespaceIssue } = resolveInclude(includePath, {
196
255
  aliases: this.aliases,
197
256
  scope: this.includeScope,
198
257
  baseDir,
258
+ namespaceResolver: this.namespaceResolver,
259
+ fromFile: fromFile ?? baseDir,
199
260
  });
200
261
  const fullPath = candidates.find((c) => fs.existsSync(c));
201
262
 
202
263
  if (!fullPath) {
264
+ const explanation = namespaceIssue
265
+ ? `\n${formatNamespaceProblem(namespaceIssue)}`
266
+ : `\n in file: ${fromFile ?? baseDir}`;
203
267
  this.handleError(
204
- `Include not found: @${includePath}\n tried:\n${candidates.map((c) => ` - ${c}`).join("\n")}`
268
+ `Include not found: @${includePath}${explanation}\n tried:\n${candidates
269
+ .map((c) => ` - ${c}`)
270
+ .join("\n")}`
205
271
  );
206
272
  out.push("");
207
273
  continue;
@@ -246,7 +312,7 @@ export class CompilationService {
246
312
  try {
247
313
  const content = fs.readFileSync(filePath, "utf8");
248
314
  const baseDir = path.dirname(filePath);
249
- const processedContent = this.processIncludes(content, baseDir, projectRoot);
315
+ const processedContent = this.processIncludes(content, baseDir, projectRoot, filePath);
250
316
  this.visited.add(filePath);
251
317
  return processedContent;
252
318
  } catch (error) {
@@ -258,11 +324,25 @@ export class CompilationService {
258
324
  }
259
325
  }
260
326
 
261
- /** Render template content using LiquidJS with the given variable scope. */
262
- private async renderContent(content: string, vars: Record<string, string>, roots: string[]): Promise<string> {
327
+ /**
328
+ * Render template content using LiquidJS with the given variable scope.
329
+ *
330
+ * @param content - The template text.
331
+ * @param vars - Variable scope handed to LiquidJS and to `${var}` path substitution.
332
+ * @param roots - Filesystem roots searched by `{% render %}`.
333
+ * @param fromFile - Absolute path of the template, so `~namespace` render paths are scoped correctly.
334
+ */
335
+ private async renderContent(
336
+ content: string,
337
+ vars: Record<string, string>,
338
+ roots: string[],
339
+ fromFile?: string
340
+ ): Promise<string> {
263
341
  const engine = createLiquidEngine(roots, {
264
342
  aliases: this.aliases,
265
343
  scope: { ...this.includeScope, ...vars },
344
+ namespaceResolver: this.namespaceResolver,
345
+ fromFile,
266
346
  });
267
347
  try {
268
348
  return await engine.parseAndRender(content, vars);
@@ -343,7 +423,7 @@ ${taskFileContents}
343
423
  stateFileEntries: StateFileEntry[]
344
424
  ): Promise<boolean> {
345
425
  subheading(path.basename(target.rootInputPath), "▷");
346
- showVar("Entry Point", target.rootInputPath);
426
+ showVariable("Entry Point", target.rootInputPath);
347
427
 
348
428
  this.initializeEncoder();
349
429
 
@@ -377,22 +457,10 @@ ${taskFileContents}
377
457
  // Resolve destination path: prefer destinationFile, fall back to destinationDir mirroring
378
458
  let destFile: string;
379
459
 
380
- if (output.destinationFile) {
381
- destFile = output.destinationFile;
382
- } else if (output.destinationDir) {
383
- // Mirror source path structure under destinationDir
384
- const sourceRelative = target.globBase
385
- ? path.relative(target.globBase, target.rootInputPath)
386
- : path.basename(target.rootInputPath);
387
-
388
- // Strip .tpl. from the output filename (e.g. foo.tpl.md -> foo.md)
389
- const outputRelative = sourceRelative.replace(/\.tpl\./, ".");
390
-
391
- destFile = path.join(output.destinationDir, outputRelative);
392
- } else {
393
- // Neither set — skip
394
- continue;
395
- }
460
+ const resolvedDest = resolveOutputPath(target, output);
461
+ // Neither destinationFile nor destinationDir set — skip
462
+ if (resolvedDest === undefined) continue;
463
+ destFile = resolvedDest;
396
464
 
397
465
  // Skip if content is unchanged and file already exists (unless --rebuild)
398
466
  const existingEntry = state.files.find(f => f.dest === destFile);
@@ -424,11 +492,16 @@ ${taskFileContents}
424
492
  }
425
493
 
426
494
  const resolvedContent = (isTpl && output.vars)
427
- ? await this.renderContent(content, {
428
- ...output.vars,
429
- sousTemplatePath: target.rootInputPath,
430
- sousTemplateDir: promptsRoot,
431
- }, [promptsRoot])
495
+ ? await this.renderContent(
496
+ content,
497
+ {
498
+ ...output.vars,
499
+ sousTemplatePath: target.rootInputPath,
500
+ sousTemplateDir: promptsRoot,
501
+ },
502
+ [promptsRoot],
503
+ target.rootInputPath
504
+ )
432
505
  : content;
433
506
  const fileContent = resolvedContent;
434
507
  const outputDir = path.dirname(destFile);
@@ -557,8 +630,16 @@ ${taskFileContents}
557
630
  subheading("Done.", "✓");
558
631
  }
559
632
 
560
- // Update state with fresh entries
561
- state.files = stateFileEntries;
633
+ // Update state: fresh entries for everything compiled this pass, plus
634
+ // carried-forward entries for outputs this pass did not touch — other
635
+ // targets' outputs during a partial rebuild, and outputs dropped from the
636
+ // config, which must stay tracked so prune/clear can still find them.
637
+ // Entries whose files are gone from disk are released.
638
+ const writtenDests = new Set(stateFileEntries.map((e) => e.dest));
639
+ state.files = [
640
+ ...stateFileEntries,
641
+ ...state.files.filter((f) => !writtenDests.has(f.dest) && fs.existsSync(f.dest)),
642
+ ];
562
643
  state.lastBuild = new Date().toISOString();
563
644
 
564
645
  if (stateFilePath && !this.dryRun) {