@sous-io/sous 0.2.32 → 0.2.33

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 (103) hide show
  1. package/README.md +7 -6
  2. package/docs/markdown/_sidebar.md +1 -0
  3. package/docs/markdown/commands.md +8 -4
  4. package/docs/markdown/configuration.md +28 -4
  5. package/docs/markdown/repositories-authoring.md +22 -2
  6. package/docs/markdown/repositories-consuming.md +40 -11
  7. package/docs/markdown/repositories-file-formats.md +28 -13
  8. package/docs/markdown/repositories-quickstart.md +1 -1
  9. package/docs/markdown/repositories.md +8 -4
  10. package/package.json +2 -1
  11. package/recipes/core/sous-skills/skills/about-liquid-templates/SKILL.tpl.md +25 -17
  12. package/recipes/core/sous-skills/skills/about-sous-configuration/SKILL.tpl.md +4 -0
  13. package/recipes/core/sous-skills/skills/about-sous-recipes/SKILL.tpl.md +21 -9
  14. package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
  15. package/sous.config.schema.json +27 -15
  16. package/src/commands/lock/rebuild.ts +3 -6
  17. package/src/commands/namespace/show.ts +2 -2
  18. package/src/commands/recipe/show.ts +26 -9
  19. package/src/commands/repo/contribute.ts +32 -23
  20. package/src/commands/repo/gc.ts +1 -1
  21. package/src/commands/repo/remove.ts +1 -1
  22. package/src/commands/repo/search.ts +1 -1
  23. package/src/commands/vars/ask.ts +75 -54
  24. package/src/lib/build-service.ts +85 -11
  25. package/src/lib/config-schema.ts +59 -19
  26. package/src/lib/include-resolver.ts +307 -69
  27. package/src/lib/markdown-compiler.ts +273 -81
  28. package/src/lib/project-scaffold/templates.ts +19 -11
  29. package/src/lib/repos/catalog-inputs.ts +1 -1
  30. package/src/lib/repos/catalog.ts +98 -72
  31. package/src/lib/repos/declarations.ts +22 -12
  32. package/src/lib/repos/formats/index-file.ts +1 -1
  33. package/src/lib/repos/formats/recipe-manifest.ts +3 -4
  34. package/src/lib/repos/lock-service.ts +1 -1
  35. package/src/lib/repos/locked-namespace-resolver.ts +14 -6
  36. package/src/lib/repos/locked-recipe-lookup.ts +123 -0
  37. package/src/lib/repos/locked-recipes.ts +1 -1
  38. package/src/lib/repos/memories-hash-name.ts +33 -0
  39. package/src/lib/repos/namespace-resolver.ts +198 -119
  40. package/src/lib/repos/recipe-config-layers.ts +29 -1
  41. package/src/lib/repos/recipe-key-matcher.ts +62 -0
  42. package/src/lib/repos/recipe-memories.ts +175 -0
  43. package/src/lib/repos/recipe-targets.ts +27 -28
  44. package/src/lib/repos/ref-search.ts +1 -1
  45. package/src/lib/repos/release/index-builder.ts +23 -15
  46. package/src/lib/repos/release/plan.ts +14 -7
  47. package/src/lib/repos/release/settle.ts +38 -121
  48. package/src/lib/repos/release/submit-checkout.ts +42 -23
  49. package/src/lib/repos/release/tags.ts +1 -1
  50. package/src/lib/repos/resolver.ts +90 -72
  51. package/src/lib/repos/subscription-service.ts +192 -170
  52. package/src/lib/settings.ts +37 -33
  53. package/src/lib/vars/ask.ts +3 -3
  54. package/src/lib/vars/mappings.ts +17 -9
  55. package/src/services/ref-resolver/container.ts +107 -0
  56. package/src/services/ref-resolver/dependency.ts +60 -0
  57. package/src/services/ref-resolver/format.ts +197 -0
  58. package/src/services/ref-resolver/glob.ts +115 -0
  59. package/src/services/ref-resolver/hash-names.ts +155 -0
  60. package/src/services/ref-resolver/index.ts +55 -0
  61. package/src/services/ref-resolver/injectable.ts +36 -0
  62. package/src/services/ref-resolver/location.ts +62 -0
  63. package/src/services/ref-resolver/lookups/cached-index-lookup.ts +53 -0
  64. package/src/services/ref-resolver/lookups/catalog-lookup.ts +23 -0
  65. package/src/services/ref-resolver/lookups/catalog-matcher.ts +261 -0
  66. package/src/services/ref-resolver/lookups/chained-lookup.ts +27 -0
  67. package/src/services/ref-resolver/lookups/env-var-lookup.ts +65 -0
  68. package/src/services/ref-resolver/lookups/fetched-index-lookup.ts +150 -0
  69. package/src/services/ref-resolver/lookups/recorded-dependency-lookup.ts +62 -0
  70. package/src/services/ref-resolver/lookups/ref-lookup.ts +54 -0
  71. package/src/services/ref-resolver/lookups/variable-lookup.ts +123 -0
  72. package/src/services/ref-resolver/named-refs.ts +144 -0
  73. package/src/services/ref-resolver/parser/env-var-splitter.ts +27 -0
  74. package/src/services/ref-resolver/parser/location-splitter.ts +239 -0
  75. package/src/services/ref-resolver/parser/name-path-splitter.ts +200 -0
  76. package/src/services/ref-resolver/parser/partial-ref.ts +84 -0
  77. package/src/services/ref-resolver/parser/query-splitter.ts +61 -0
  78. package/src/services/ref-resolver/parser/range-splitter.ts +52 -0
  79. package/src/services/ref-resolver/parser/ref-parser.ts +115 -0
  80. package/src/services/ref-resolver/parser/repo-qualifier-splitter.ts +51 -0
  81. package/src/services/ref-resolver/parts.ts +115 -0
  82. package/src/services/ref-resolver/pruners/command-line-pruner.ts +47 -0
  83. package/src/services/ref-resolver/pruners/include-pruner.ts +66 -0
  84. package/src/services/ref-resolver/pruners/manifest-pruner.ts +130 -0
  85. package/src/services/ref-resolver/pruners/ref-pruner.ts +135 -0
  86. package/src/services/ref-resolver/pruners/rule-helpers.ts +44 -0
  87. package/src/services/ref-resolver/pruners/stored-key-pruner.ts +136 -0
  88. package/src/services/ref-resolver/ref-helpers.ts +62 -0
  89. package/src/services/ref-resolver/ref-picker-service.ts +204 -0
  90. package/src/{lib/repos/reference-report.ts → services/ref-resolver/ref-report.ts} +2 -40
  91. package/src/services/ref-resolver/ref-resolve-arguments.ts +47 -0
  92. package/src/services/ref-resolver/ref-resolve-result.ts +36 -0
  93. package/src/services/ref-resolver/ref-resolver-service.ts +323 -0
  94. package/src/services/ref-resolver/source.ts +48 -0
  95. package/src/services/ref-resolver/tokens.ts +25 -0
  96. package/src/services/ref-resolver/types.ts +155 -0
  97. package/src/templating/init-liquid-engine.ts +25 -18
  98. package/src/lib/refs/find.ts +0 -770
  99. package/src/lib/refs/index.ts +0 -14
  100. package/src/lib/refs/parse.ts +0 -844
  101. package/src/lib/refs/pick.ts +0 -147
  102. package/src/lib/refs/scopes.ts +0 -95
  103. package/src/lib/refs/settle.ts +0 -219
@@ -85,8 +85,8 @@ export default class NamespaceShow extends BaseCommand {
85
85
  });
86
86
 
87
87
  const detail = flags.installed
88
- ? describeInstalled(inputs, args.ref, describeNamespace, "namespace")
89
- : describeNamespace(inputs, args.ref);
88
+ ? await describeInstalled(inputs, args.ref, describeNamespace, "namespace")
89
+ : await describeNamespace(inputs, args.ref);
90
90
 
91
91
  heading(`The namespace ${detail.namespace}`);
92
92
  blankLine();
@@ -34,6 +34,7 @@ import {
34
34
  type RecipeVersionListing,
35
35
  } from "../../lib/repos/catalog.js";
36
36
  import { PROJECT_REQUESTER } from "../../lib/repos/resolver.js";
37
+ import { recipeRef } from "../../services/ref-resolver/index.js";
37
38
  import { describeDeclaration, describeDependencyKind } from "../../lib/repos/declarations.js";
38
39
  import { formatQuestionPlan } from "../../lib/vars/question-plan.js";
39
40
  import { describeError } from "../../lib/repos/release/validate.js";
@@ -156,8 +157,8 @@ export default class RecipeShow extends BaseCommand {
156
157
  });
157
158
 
158
159
  const detail = flags.installed
159
- ? describeInstalled(inputs, args.ref, describeRecipe, "recipe")
160
- : describeRecipe(inputs, args.ref);
160
+ ? await describeInstalled(inputs, args.ref, describeRecipe, "recipe")
161
+ : await describeRecipe(inputs, args.ref);
161
162
 
162
163
  heading(detail.key);
163
164
  blankLine();
@@ -236,12 +237,10 @@ export default class RecipeShow extends BaseCommand {
236
237
  }
237
238
  try {
238
239
  return await service.previewSubscription(
239
- {
240
+ recipeRef(detail.namespace, detail.name, {
240
241
  repo: detail.repo,
241
- namespace: detail.namespace,
242
- recipe: detail.name,
243
242
  range: detail.describing,
244
- },
243
+ }),
245
244
  {
246
245
  indexes: new Map(inputs.repos.map((repo) => [repo.name, repo.index])),
247
246
  ...(semver.prerelease(detail.describing) === null ? {} : { prerelease: true }),
@@ -409,9 +408,7 @@ export default class RecipeShow extends BaseCommand {
409
408
  destination:
410
409
  entry.destinations.length > 0
411
410
  ? entry.destinations.join(", ")
412
- : entry.kind === "config"
413
- ? "loaded as a config layer, so nothing is written"
414
- : `nowhere: this project sets no 'recipeOutputs.${entry.kind}' directory`,
411
+ : noDestinationText(entry.kind),
415
412
  }));
416
413
 
417
414
  for (const line of renderTable(CONTENT_COLUMNS, rows, { indent: INDENT })) {
@@ -420,3 +417,23 @@ export default class RecipeShow extends BaseCommand {
420
417
  }
421
418
  }
422
419
 
420
+ /**
421
+ * What to say where a content kind has no destination directory: each kind
422
+ * reaches the project in its own way.
423
+ *
424
+ * noDestinationText("memories"); // -> "included in an agent's instructions by an include line, so nothing is written"
425
+ *
426
+ * @param kind - The content kind.
427
+ */
428
+ function noDestinationText(kind: string): string {
429
+ switch (kind) {
430
+ case "config":
431
+ return "loaded as a config layer, so nothing is written";
432
+ case "memories":
433
+ return "reaches an agent through an include line in its instructions, so nothing is written";
434
+ case "skills":
435
+ return "nowhere: this project sets no 'recipes.skills.outputs' directory";
436
+ default:
437
+ return `nowhere: this project has no destination for ${kind} yet`;
438
+ }
439
+ }
@@ -3,11 +3,14 @@ import { BaseCommand } from "../../base-command.js";
3
3
  import { ConfigError } from "../../lib/errors.js";
4
4
  import { nonInteractiveError } from "../../lib/interactive.js";
5
5
  import {
6
- SousScope,
7
- findReference,
8
- pickReference,
9
- type ReferenceRepo,
10
- } from "../../lib/refs/index.js";
6
+ CatalogLookup,
7
+ RefPickArguments,
8
+ RefResolveArguments,
9
+ repoOf,
10
+ sharedRefPicker,
11
+ sharedRefResolver,
12
+ type CatalogRepo,
13
+ } from "../../services/ref-resolver/index.js";
11
14
  import {
12
15
  assessPendingWork,
13
16
  assessProposal,
@@ -373,27 +376,33 @@ export default class RepoContribute extends BaseCommand {
373
376
  shellEnv: this.shellEnv,
374
377
  });
375
378
 
376
- const repos: ReferenceRepo[] = service.cachedReferenceRepos();
379
+ const repos: CatalogRepo[] = service.cachedReferenceRepos();
377
380
 
378
- const matches = findReference(
379
- ref,
380
- [SousScope.Repository, SousScope.Namespace, SousScope.Recipe],
381
- { repos }
381
+ const { refs } = await sharedRefResolver().resolve(
382
+ new RefResolveArguments({
383
+ input: ref,
384
+ lookup: new CatalogLookup(repos),
385
+ kinds: ["repo", "namespace", "recipe"],
386
+ refusedIsEmpty: true,
387
+ })
382
388
  );
383
389
 
384
- const chosen = await pickReference(matches, {
385
- search: ref,
386
- interactive: this.interactive,
387
- acceptFirst,
388
- prompt: `Which '${ref}' do you want to contribute to?`,
389
- details: [
390
- " No trusted repository, namespace or recipe has that name.",
391
- repos.length === 0
392
- ? " This project trusts no repositories."
393
- : ` This project trusts: ${repos.map((entry) => entry.name).join(", ")}.`,
394
- ],
395
- });
396
- return chosen.repo ?? chosen.key;
390
+ const chosen = await sharedRefPicker().pick(
391
+ refs,
392
+ new RefPickArguments({
393
+ search: ref,
394
+ interactive: this.interactive,
395
+ acceptFirst,
396
+ prompt: `Which '${ref}' do you want to contribute to?`,
397
+ details: [
398
+ " No trusted repository, namespace or recipe has that name.",
399
+ repos.length === 0
400
+ ? " This project trusts no repositories."
401
+ : ` This project trusts: ${repos.map((entry) => entry.name).join(", ")}.`,
402
+ ],
403
+ })
404
+ );
405
+ return chosen.kind === "repo" ? chosen.name! : repoOf(chosen)!.name!;
397
406
  }
398
407
 
399
408
  // --- The link ---------------------------------------------------------------------------------
@@ -17,7 +17,7 @@ import { subscriptionServiceFor } from "../../lib/repos/subscription-service.js"
17
17
  import { resolveStoreSettings } from "../../lib/repos/store/settings.js";
18
18
  import type { StoreKey } from "../../lib/repos/store/contract.js";
19
19
  import { renderTable, type TableColumn } from "../../utils/table.js";
20
- import { splitRecipeKey } from "../../lib/refs/parse.js";
20
+ import { splitRecipeKey } from "../../services/ref-resolver/index.js";
21
21
  import {
22
22
  blankLine,
23
23
  dryRunNotice,
@@ -105,7 +105,7 @@ export default class RepoRemove extends BaseCommand {
105
105
  yes: flags.yes,
106
106
  dryRun,
107
107
  // The output files a removed recipe wrote can only be named once the
108
- // `recipeOutputs` destinations have had their variables substituted.
108
+ // `recipes.skills.outputs` destinations have had their variables substituted.
109
109
  scope: resolveRootScope(this.settings, this.configContext),
110
110
  });
111
111
 
@@ -21,7 +21,7 @@ import {
21
21
  } from "../../lib/repos/catalog-display.js";
22
22
  import { readEffectiveLinks } from "../../lib/repos/links.js";
23
23
  import { renderTable, type TableColumn } from "../../utils/table.js";
24
- import { namespaceOfKey } from "../../lib/refs/parse.js";
24
+ import { namespaceOfKey } from "../../services/ref-resolver/index.js";
25
25
  import { browsingFlags } from "../../utils/flags.js";
26
26
  import {
27
27
  blankLine,
@@ -5,7 +5,7 @@
5
5
  * answers in `.sous/.env` or `.sous/.env.local`. By default it asks only what
6
6
  * is unanswered or no longer fits; `--all` asks everything again.
7
7
  *
8
- * The optional name is a reference, resolved through `src/lib/refs/` exactly as
8
+ * The optional name is a reference, resolved through `src/services/ref-resolver/` exactly as
9
9
  * every other command resolves one. It may name a variable, an environment
10
10
  * variable that answers one, a recipe, a namespace or a repository, at any
11
11
  * level of qualification; naming anything larger than a variable asks every
@@ -38,18 +38,22 @@ import { Args, Flags } from "@oclif/core";
38
38
  import { BaseCommand } from "../../base-command.js";
39
39
  import { ConfigError } from "../../lib/errors.js";
40
40
  import {
41
- ALL_SCOPES,
42
- SousScope,
43
- findNamespace,
44
- findReference,
45
- findRepository,
46
- findVariable,
47
- pickReference,
48
- referenceContextFromVariables,
49
- variableReferenceKey,
50
- type ReferenceMatch,
51
- type ReferenceRepo,
52
- } from "../../lib/refs/index.js";
41
+ ChainedLookup,
42
+ RefPickArguments,
43
+ RefResolveArguments,
44
+ VariableLookup,
45
+ namespaceOf,
46
+ recipeOf,
47
+ refKey,
48
+ repoOf,
49
+ sharedRefPicker,
50
+ sharedRefResolver,
51
+ variableRefOf,
52
+ type CatalogRepo,
53
+ type RefKind,
54
+ type SousRef,
55
+ } from "../../services/ref-resolver/index.js";
56
+ import { EnvVarLookup } from "../../services/ref-resolver/lookups/env-var-lookup.js";
53
57
  import { subscriptionServiceFor } from "../../lib/repos/subscription-service.js";
54
58
  import {
55
59
  applyProvidedAnswers,
@@ -269,10 +273,10 @@ export default class VarsAsk extends BaseCommand {
269
273
  defined: DefinedVariable[],
270
274
  ladder: LadderContext,
271
275
  selection: Selection,
272
- trusted: ReferenceRepo[] = []
276
+ trusted: CatalogRepo[] = []
273
277
  ): Promise<string[] | undefined> {
274
- const contextOf = (pool: DefinedVariable[]) =>
275
- referenceContextFromVariables(pool, ladder, trusted);
278
+ const lookupOf = (pool: DefinedVariable[]) =>
279
+ new ChainedLookup(new VariableLookup(pool, trusted), new EnvVarLookup(pool, ladder));
276
280
  const { name, repo, namespace, vars } = selection;
277
281
  if (name === undefined && repo === undefined && namespace === undefined && vars === undefined) {
278
282
  return undefined;
@@ -281,36 +285,24 @@ export default class VarsAsk extends BaseCommand {
281
285
  let pool = defined;
282
286
 
283
287
  if (repo !== undefined) {
284
- const match = await this.pick(findRepository(repo, contextOf(pool)), repo, selection);
285
- pool = pool.filter((entry) => entry.recipe.repo === match.repo);
288
+ const match = await this.pick(repo, lookupOf(pool), ["repo"], selection);
289
+ pool = pool.filter((entry) => entry.recipe.repo === repoOf(match)?.name);
286
290
  }
287
291
 
288
292
  if (namespace !== undefined) {
289
- const match = await this.pick(
290
- findNamespace(namespace, contextOf(pool)),
291
- namespace,
292
- selection
293
- );
293
+ const match = await this.pick(namespace, lookupOf(pool), ["namespace"], selection);
294
294
  pool = narrowTo(pool, match);
295
295
  }
296
296
 
297
297
  if (name !== undefined) {
298
- const match = await this.pick(
299
- findReference(name, ALL_SCOPES, contextOf(pool)),
300
- name,
301
- selection
302
- );
298
+ const match = await this.pick(name, lookupOf(pool), undefined, selection);
303
299
  pool = narrowTo(pool, match);
304
300
  }
305
301
 
306
302
  if (vars !== undefined) {
307
303
  const chosen: DefinedVariable[] = [];
308
304
  for (const wanted of vars) {
309
- const match = await this.pick(
310
- findVariable(wanted, contextOf(pool)),
311
- wanted,
312
- selection
313
- );
305
+ const match = await this.pick(wanted, lookupOf(pool), ["variable", "envVar"], selection);
314
306
  chosen.push(...narrowTo(pool, match));
315
307
  }
316
308
  pool = chosen;
@@ -323,7 +315,7 @@ export default class VarsAsk extends BaseCommand {
323
315
  );
324
316
  }
325
317
 
326
- return [...new Set(pool.map(variableReferenceKey))];
318
+ return [...new Set(pool.map((entry) => refKey(variableRefOf(entry))))];
327
319
  }
328
320
 
329
321
  /**
@@ -331,7 +323,7 @@ export default class VarsAsk extends BaseCommand {
331
323
  * indexes alone. A project whose repositories cannot be read this way still
332
324
  * answers every other form of reference, so a failure here is an empty list.
333
325
  */
334
- private trustedRepos(): ReferenceRepo[] {
326
+ private trustedRepos(): CatalogRepo[] {
335
327
  try {
336
328
  return subscriptionServiceFor({
337
329
  configContext: this.configContext,
@@ -347,38 +339,67 @@ export default class VarsAsk extends BaseCommand {
347
339
  * Settles which of the things a reference could have meant this run proceeds
348
340
  * with, through the rule every command shares.
349
341
  *
350
- * @param matches - What the reference could have meant, in listing order.
351
342
  * @param search - The reference exactly as it was written.
343
+ * @param lookup - What the reference is searched in.
344
+ * @param kinds - The kinds of ref this step accepts; every kind when left out.
352
345
  * @param selection - The selection, read for `--accept-first`.
353
346
  */
354
347
  private async pick(
355
- matches: ReferenceMatch[],
356
348
  search: string,
349
+ lookup: ChainedLookup,
350
+ kinds: readonly RefKind[] | undefined,
357
351
  selection: Selection
358
- ): Promise<ReferenceMatch> {
359
- return pickReference(matches, {
360
- search,
361
- interactive: this.interactive,
362
- acceptFirst: selection.acceptFirst,
363
- announce: false,
364
- details: [SEE_ALL],
365
- });
352
+ ): Promise<SousRef> {
353
+ const { refs } = await sharedRefResolver().resolve(
354
+ new RefResolveArguments({
355
+ input: search,
356
+ lookup,
357
+ refusedIsEmpty: true,
358
+ ...(kinds === undefined ? {} : { kinds }),
359
+ })
360
+ );
361
+ // A name that answers several variables is one candidate per variable, so
362
+ // choosing between them is the same question as choosing between variables.
363
+ const candidates = refs.flatMap((ref) =>
364
+ ref.kind === "envVar" && (ref.variables?.length ?? 0) > 1
365
+ ? ref.variables!.map((variable): SousRef => ({ ...ref, variables: [variable] }))
366
+ : [ref]
367
+ );
368
+ return sharedRefPicker().pick(
369
+ candidates,
370
+ new RefPickArguments({
371
+ search,
372
+ interactive: this.interactive,
373
+ acceptFirst: selection.acceptFirst,
374
+ announce: false,
375
+ details: [SEE_ALL],
376
+ })
377
+ );
366
378
  }
367
379
  }
368
380
 
369
- /** The variables one match covers: a whole repository, namespace, recipe, or one variable. */
370
- function narrowTo(pool: DefinedVariable[], match: ReferenceMatch): DefinedVariable[] {
381
+ /**
382
+ * The variables one ref covers: a whole repository, namespace, recipe, or one
383
+ * variable, or the variable an environment variable name answers.
384
+ */
385
+ function narrowTo(pool: DefinedVariable[], match: SousRef): DefinedVariable[] {
386
+ const target = match.kind === "envVar" ? match.variables?.[0] : match;
387
+ if (target === undefined) return [];
388
+ const repo = repoOf(target)?.name;
389
+ const namespace = namespaceOf(target)?.name;
390
+ const recipe = recipeOf(target)?.name;
391
+
371
392
  return pool.filter((entry) => {
372
- if (match.repo !== undefined && entry.recipe.repo !== match.repo) return false;
373
- if (match.scope === SousScope.Repository) return true;
393
+ if (repo !== undefined && entry.recipe.repo !== repo) return false;
394
+ if (target.kind === "repo") return true;
374
395
 
375
- if (match.namespace !== undefined && entry.recipe.namespace !== match.namespace) return false;
376
- if (match.scope === SousScope.Namespace) return true;
396
+ if (namespace !== undefined && entry.recipe.namespace !== namespace) return false;
397
+ if (target.kind === "namespace") return true;
377
398
 
378
- if (match.recipe !== undefined && entry.recipe.name !== match.recipe) return false;
379
- if (match.scope === SousScope.Recipe) return true;
399
+ if (recipe !== undefined && entry.recipe.name !== recipe) return false;
400
+ if (target.kind === "recipe") return true;
380
401
 
381
- return entry.definition.name === match.variable;
402
+ return target.kind === "variable" && entry.definition.name === target.name;
382
403
  });
383
404
  }
384
405
 
@@ -2,11 +2,11 @@ import path from "node:path";
2
2
  import fs from "node:fs";
3
3
  import type { ConfigContext, Settings } from "./settings.js";
4
4
  import { resolveAliases, resolveCompilation, resolveRootScope } from "./settings.js";
5
- import { resolveIncludeCandidates } from "./include-resolver.js";
5
+ import { resolveIncludeFiles, type ViewMap } from "./include-resolver.js";
6
6
  import type { NamespaceResolver } from "./repos/namespace-resolver.js";
7
7
  import { createProjectNamespaceResolver } from "./repos/locked-namespace-resolver.js";
8
8
  import { buildRecipeTargets, type RecipeTargets } from "./repos/recipe-targets.js";
9
- import { CompilationService, resolveOutputPath } from "./markdown-compiler.js";
9
+ import { CompilationService, INCLUDE_LINE_PATTERN, resolveOutputPath } from "./markdown-compiler.js";
10
10
  import type { CompilationConfig, CompilationTarget } from "./markdown-compiler.js";
11
11
  import { StateService } from "./state.js";
12
12
  import { isProtectedPath } from "./state.js";
@@ -19,7 +19,9 @@ import {
19
19
  unansweredWarning,
20
20
  type RecipeAnswers,
21
21
  } from "./vars/answers.js";
22
- import { footer, heading, log, warning } from "../utils/formatting.js";
22
+ import { BULLET, footer, heading, log, warning } from "../utils/formatting.js";
23
+ import { sharedHashNames } from "../services/ref-resolver/index.js";
24
+ import { listMemories, type MemoryFile } from "./repos/recipe-memories.js";
23
25
 
24
26
  export type BuildOptions = {
25
27
  strict?: boolean;
@@ -146,6 +148,54 @@ export function withRecipeTargets(
146
148
  return { ...config, targets: [...config.targets, ...recipes.targets] };
147
149
  }
148
150
 
151
+ /**
152
+ * The memories an active recipe publishes that no output of the build included
153
+ * by any route (an exact path, a glob or the `#memories` view). Recipes under
154
+ * `recipes.memories.exclude` are never listed.
155
+ *
156
+ * @param settings - The merged project config, for `recipes.memories`.
157
+ * @param sousDir - The project's `.sous/` directory.
158
+ * @param included - Every file the compile read, as real paths.
159
+ */
160
+ export function unincludedMemories(
161
+ settings: Settings,
162
+ sousDir: string,
163
+ included: ReadonlySet<string>
164
+ ): MemoryFile[] {
165
+ const memories = settings.recipes?.memories;
166
+ return listMemories({ sousDir, first: memories?.first, exclude: memories?.exclude }).filter(
167
+ (memory) => !included.has(realPath(memory.file))
168
+ );
169
+ }
170
+
171
+ /** The path with symbolic links resolved, or the path itself when it cannot be read. */
172
+ function realPath(filePath: string): string {
173
+ try {
174
+ return fs.realpathSync(filePath);
175
+ } catch {
176
+ return filePath;
177
+ }
178
+ }
179
+
180
+ /**
181
+ * The warning for memories no output included, or undefined when there are none.
182
+ *
183
+ * @param missing - What {@link unincludedMemories} found.
184
+ */
185
+ export function unincludedMemoriesWarning(missing: readonly MemoryFile[]): string | undefined {
186
+ if (missing.length === 0) return undefined;
187
+ const noun = missing.length === 1 ? "memory" : "memories";
188
+ return (
189
+ `${missing.length} ${noun} published by your subscribed recipes ${
190
+ missing.length === 1 ? "is" : "are"
191
+ } not included in any output, so no agent will read ${missing.length === 1 ? "it" : "them"}:\n` +
192
+ missing.map((memory) => `${BULLET} ${memory.recipe}: ${memory.relative}`).join("\n") +
193
+ `\nTo include every memory, add the line "@#memories/**/*.md" to your project's instruction ` +
194
+ `source (the file your AGENTS.md or CLAUDE.md is built from). To leave a recipe's memories out on purpose, ` +
195
+ `list the recipe in "recipes.memories.exclude" in your sous config.`
196
+ );
197
+ }
198
+
149
199
  /**
150
200
  * The directories a build's deletions must never reach into, for the project the
151
201
  * options describe. Empty when the caller gave no config context, which is the
@@ -193,6 +243,7 @@ function collectIncludeGraph(
193
243
  aliases?: Record<string, string[]>;
194
244
  scope?: Record<string, string>;
195
245
  namespaceResolver?: NamespaceResolver;
246
+ views?: ViewMap;
196
247
  } = {},
197
248
  visited: Set<string> = new Set()
198
249
  ): Set<string> {
@@ -208,21 +259,23 @@ function collectIncludeGraph(
208
259
  return visited;
209
260
  }
210
261
 
211
- const includePattern = /^@([~a-zA-Z0-9_${}][a-zA-Z0-9_\-/.:${}]*\.md)$/gm;
212
262
  const baseDir = path.dirname(filePath);
213
- let match: RegExpExecArray | null;
214
263
 
215
- while ((match = includePattern.exec(content)) !== null) {
216
- const includePath = match[1].trim();
217
- const candidates = resolveIncludeCandidates(includePath, {
264
+ for (const line of content.split("\n")) {
265
+ const match = INCLUDE_LINE_PATTERN.exec(line.trimEnd());
266
+ if (match === null) continue;
267
+ const { files, candidates } = resolveIncludeFiles(match[1].trim(), {
218
268
  aliases: resolveOpts.aliases,
269
+ views: resolveOpts.views,
219
270
  scope: resolveOpts.scope,
220
271
  baseDir,
221
272
  namespaceResolver: resolveOpts.namespaceResolver,
222
273
  fromFile: filePath,
223
274
  });
224
- const fullPath = candidates.find((c) => fs.existsSync(c)) ?? candidates[0];
225
- collectIncludeGraph(fullPath, resolveOpts, visited);
275
+ // A file that does not exist yet is still part of the graph, as its first candidate.
276
+ for (const fullPath of files.length > 0 ? files : candidates.slice(0, 1)) {
277
+ collectIncludeGraph(fullPath, resolveOpts, visited);
278
+ }
226
279
  }
227
280
 
228
281
  return visited;
@@ -248,6 +301,7 @@ export function findAffectedTargets(
248
301
  return config.targets.filter(target => {
249
302
  const graph = collectIncludeGraph(target.rootInputPath, {
250
303
  aliases: config.aliases,
304
+ views: config.views,
251
305
  scope: config.includeScope,
252
306
  namespaceResolver,
253
307
  });
@@ -313,12 +367,23 @@ export class BuildService {
313
367
  const recipes = resolveRecipeTargets(settings, rootScope, options.configContext, answers);
314
368
  for (const notice of recipes.warnings) warning(notice);
315
369
 
316
- const config = withRecipeTargets(
370
+ const withoutViews = withRecipeTargets(
317
371
  resolveCompilation(settings, rootScope),
318
372
  recipes,
319
373
  settings,
320
374
  rootScope
321
375
  );
376
+ // The files each view lists (`#memories`), worked out once per build.
377
+ const config: CompilationConfig | null =
378
+ withoutViews === null || options.configContext === undefined
379
+ ? withoutViews
380
+ : {
381
+ ...withoutViews,
382
+ views: sharedHashNames().viewMap({
383
+ sousDir: options.configContext.sousDir,
384
+ settings,
385
+ }),
386
+ };
322
387
 
323
388
  if (config) {
324
389
  let effectiveConfig: CompilationConfig = config;
@@ -351,6 +416,15 @@ export class BuildService {
351
416
  });
352
417
  const compileOk = await compiler.compile(effectiveConfig, stateFilePath);
353
418
  if (!compileOk) success = false;
419
+
420
+ // Only a full build knows every route an include can take, so only a
421
+ // full build that compiled cleanly checks that every memory got in.
422
+ if (compileOk && options.configContext !== undefined) {
423
+ const notice = unincludedMemoriesWarning(
424
+ unincludedMemories(settings, options.configContext.sousDir, compiler.includedFiles())
425
+ );
426
+ if (notice !== undefined) warning(notice);
427
+ }
354
428
  }
355
429
  }
356
430
  }
@@ -19,6 +19,7 @@
19
19
 
20
20
  import { z } from "zod";
21
21
  import { ConfigError } from "./errors.js";
22
+ import { compileRecipeKeyMatcher } from "./repos/recipe-key-matcher.js";
22
23
  import { repoUrlSchema, semverRangeSchema } from "./repos/formats/common.js";
23
24
  import { REF_KEY_PATTERN, REPO_NAME_PATTERN } from "./repos/formats/patterns.js";
24
25
  import type { Settings } from "./settings.js";
@@ -174,27 +175,66 @@ const storeSchema = z
174
175
  .strict();
175
176
 
176
177
  /**
177
- * Where the files a subscribed recipe contributes are written, one list of
178
- * destination directories per content kind. Each destination is `${var}`
179
- * substituted like any other config path, and a kind may name several so the
180
- * same recipe feeds more than one agent directory (`.claude/skills` and
181
- * `.codex/skills`, say).
178
+ * One list of recipe keys: each entry is a glob over `namespace/recipe` keys, or
179
+ * a regular expression written between two slashes (`/^tool-usage\//`). A
180
+ * regular expression that does not compile is an error naming the key and the
181
+ * value.
182
+ */
183
+ function recipeKeyListSchema(where: string) {
184
+ return z.array(z.string()).superRefine((entries, context) => {
185
+ entries.forEach((entry, index) => {
186
+ try {
187
+ compileRecipeKeyMatcher([entry], `${where}[${index}]`);
188
+ } catch (error) {
189
+ context.addIssue({
190
+ code: "custom",
191
+ path: [index],
192
+ message: error instanceof Error ? error.message : String(error),
193
+ });
194
+ }
195
+ });
196
+ });
197
+ }
198
+
199
+ /**
200
+ * What a project does with the content kinds the subscribed recipes publish, one
201
+ * block per kind.
182
202
  *
183
- * Only `skills` has a default: `<project root>/.claude/skills`, the project root
184
- * being the parent of the discovered `.sous/` directory. A kind with no
185
- * destination is skipped, with one warning naming this key, because sous cannot
186
- * guess where a project wants its memories or its prompts. A recipe's `config`
187
- * contents are not listed here; they are loaded as config layers rather than
188
- * written anywhere.
203
+ * `skills.outputs` lists the destination directories recipe skill bundles are
204
+ * written into. Each is `${var}` substituted like any other config path, and
205
+ * several may be named so the same recipe feeds more than one agent directory
206
+ * (`.claude/skills` and `.codex/skills`, say). The default is
207
+ * `<project root>/.claude/skills`, the project root being the parent of the
208
+ * discovered `.sous/` directory.
209
+ *
210
+ * `memories.first` and `memories.exclude` shape which recipe memories an
211
+ * `@#memories` include pulls in and in what order: recipes matching `first` lead,
212
+ * and recipes matching `exclude` are left out. Each entry is a glob over
213
+ * `namespace/recipe` keys, or a regular expression written between two slashes.
214
+ *
215
+ * A recipe's `config` contents are not listed here; they are loaded as config
216
+ * layers rather than written anywhere.
189
217
  */
190
- const recipeOutputsSchema = z
218
+ const recipesSchema = z
191
219
  .object({
192
220
  /** Where recipe skill bundles are written. */
193
- skills: z.array(z.string()).optional(),
194
- /** Where recipe memory files are written. */
195
- memories: z.array(z.string()).optional(),
196
- /** Where recipe prompt files are written. */
197
- prompts: z.array(z.string()).optional(),
221
+ skills: z
222
+ .object({
223
+ /** Destination directories, `${var}` substituted. */
224
+ outputs: z.array(z.string()).optional(),
225
+ })
226
+ .strict()
227
+ .optional(),
228
+ /** Which recipe memories an `@#memories` include pulls in, and in what order. */
229
+ memories: z
230
+ .object({
231
+ /** Recipes whose memories come first. */
232
+ first: recipeKeyListSchema("recipes.memories.first").optional(),
233
+ /** Recipes whose memories are left out. */
234
+ exclude: recipeKeyListSchema("recipes.memories.exclude").optional(),
235
+ })
236
+ .strict()
237
+ .optional(),
198
238
  })
199
239
  .strict();
200
240
 
@@ -267,8 +307,8 @@ export const settingsSchema = z
267
307
  .optional(),
268
308
  /** Knobs for the machine-wide recipe store. */
269
309
  store: storeSchema.optional(),
270
- /** Where the files subscribed recipes contribute are written, per content kind. */
271
- recipeOutputs: recipeOutputsSchema.optional(),
310
+ /** What the project does with each content kind its subscribed recipes publish. */
311
+ recipes: recipesSchema.optional(),
272
312
  /**
273
313
  * Variable mapping records, keyed by environment variable name. Each entry
274
314
  * binds that name to one recipe variable, which is how an answer is stored