@sous-io/sous 0.2.32 → 0.2.34

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 (111) 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-variables.md +16 -0
  10. package/docs/markdown/repositories.md +8 -4
  11. package/package.json +2 -1
  12. package/recipes/core/sous-skills/skills/about-liquid-templates/SKILL.tpl.md +33 -20
  13. package/recipes/core/sous-skills/skills/about-sous-configuration/SKILL.tpl.md +4 -0
  14. package/recipes/core/sous-skills/skills/about-sous-recipes/SKILL.tpl.md +21 -9
  15. package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
  16. package/sous.config.schema.json +27 -15
  17. package/src/commands/lock/rebuild.ts +3 -6
  18. package/src/commands/namespace/show.ts +2 -2
  19. package/src/commands/recipe/show.ts +26 -9
  20. package/src/commands/repo/contribute.ts +32 -23
  21. package/src/commands/repo/gc.ts +1 -1
  22. package/src/commands/repo/remove.ts +1 -1
  23. package/src/commands/repo/search.ts +1 -1
  24. package/src/commands/vars/ask.ts +75 -54
  25. package/src/lib/build-service.ts +93 -15
  26. package/src/lib/config-schema.ts +59 -19
  27. package/src/lib/include-resolver.ts +307 -69
  28. package/src/lib/markdown-compiler.ts +289 -82
  29. package/src/lib/project-scaffold/templates.ts +19 -11
  30. package/src/lib/repos/catalog-inputs.ts +1 -1
  31. package/src/lib/repos/catalog.ts +98 -72
  32. package/src/lib/repos/declarations.ts +22 -12
  33. package/src/lib/repos/formats/index-file.ts +1 -1
  34. package/src/lib/repos/formats/recipe-manifest.ts +3 -4
  35. package/src/lib/repos/lock-service.ts +1 -1
  36. package/src/lib/repos/locked-namespace-resolver.ts +14 -6
  37. package/src/lib/repos/locked-recipe-lookup.ts +123 -0
  38. package/src/lib/repos/locked-recipes.ts +1 -1
  39. package/src/lib/repos/memories-hash-name.ts +33 -0
  40. package/src/lib/repos/namespace-resolver.ts +198 -119
  41. package/src/lib/repos/recipe-config-layers.ts +29 -1
  42. package/src/lib/repos/recipe-key-matcher.ts +62 -0
  43. package/src/lib/repos/recipe-memories.ts +175 -0
  44. package/src/lib/repos/recipe-targets.ts +32 -28
  45. package/src/lib/repos/ref-search.ts +1 -1
  46. package/src/lib/repos/release/index-builder.ts +23 -15
  47. package/src/lib/repos/release/plan.ts +14 -7
  48. package/src/lib/repos/release/settle.ts +38 -121
  49. package/src/lib/repos/release/submit-checkout.ts +42 -23
  50. package/src/lib/repos/release/tags.ts +1 -1
  51. package/src/lib/repos/resolver.ts +90 -72
  52. package/src/lib/repos/subscription-service.ts +192 -170
  53. package/src/lib/settings.ts +37 -33
  54. package/src/lib/vars/answers.ts +9 -1
  55. package/src/lib/vars/ask.ts +3 -3
  56. package/src/lib/vars/display.ts +1 -3
  57. package/src/lib/vars/index.ts +1 -0
  58. package/src/lib/vars/mappings.ts +17 -9
  59. package/src/lib/vars/secrets.ts +131 -0
  60. package/src/services/ref-resolver/container.ts +107 -0
  61. package/src/services/ref-resolver/dependency.ts +60 -0
  62. package/src/services/ref-resolver/format.ts +197 -0
  63. package/src/services/ref-resolver/glob.ts +115 -0
  64. package/src/services/ref-resolver/hash-names.ts +155 -0
  65. package/src/services/ref-resolver/index.ts +55 -0
  66. package/src/services/ref-resolver/injectable.ts +36 -0
  67. package/src/services/ref-resolver/location.ts +62 -0
  68. package/src/services/ref-resolver/lookups/cached-index-lookup.ts +53 -0
  69. package/src/services/ref-resolver/lookups/catalog-lookup.ts +23 -0
  70. package/src/services/ref-resolver/lookups/catalog-matcher.ts +261 -0
  71. package/src/services/ref-resolver/lookups/chained-lookup.ts +27 -0
  72. package/src/services/ref-resolver/lookups/env-var-lookup.ts +65 -0
  73. package/src/services/ref-resolver/lookups/fetched-index-lookup.ts +150 -0
  74. package/src/services/ref-resolver/lookups/recorded-dependency-lookup.ts +62 -0
  75. package/src/services/ref-resolver/lookups/ref-lookup.ts +54 -0
  76. package/src/services/ref-resolver/lookups/variable-lookup.ts +123 -0
  77. package/src/services/ref-resolver/named-refs.ts +144 -0
  78. package/src/services/ref-resolver/parser/env-var-splitter.ts +27 -0
  79. package/src/services/ref-resolver/parser/location-splitter.ts +239 -0
  80. package/src/services/ref-resolver/parser/name-path-splitter.ts +200 -0
  81. package/src/services/ref-resolver/parser/partial-ref.ts +84 -0
  82. package/src/services/ref-resolver/parser/query-splitter.ts +61 -0
  83. package/src/services/ref-resolver/parser/range-splitter.ts +52 -0
  84. package/src/services/ref-resolver/parser/ref-parser.ts +115 -0
  85. package/src/services/ref-resolver/parser/repo-qualifier-splitter.ts +51 -0
  86. package/src/services/ref-resolver/parts.ts +115 -0
  87. package/src/services/ref-resolver/pruners/command-line-pruner.ts +47 -0
  88. package/src/services/ref-resolver/pruners/include-pruner.ts +66 -0
  89. package/src/services/ref-resolver/pruners/manifest-pruner.ts +130 -0
  90. package/src/services/ref-resolver/pruners/ref-pruner.ts +135 -0
  91. package/src/services/ref-resolver/pruners/rule-helpers.ts +44 -0
  92. package/src/services/ref-resolver/pruners/stored-key-pruner.ts +136 -0
  93. package/src/services/ref-resolver/ref-helpers.ts +62 -0
  94. package/src/services/ref-resolver/ref-picker-service.ts +204 -0
  95. package/src/{lib/repos/reference-report.ts → services/ref-resolver/ref-report.ts} +2 -40
  96. package/src/services/ref-resolver/ref-resolve-arguments.ts +47 -0
  97. package/src/services/ref-resolver/ref-resolve-result.ts +36 -0
  98. package/src/services/ref-resolver/ref-resolver-service.ts +323 -0
  99. package/src/services/ref-resolver/source.ts +48 -0
  100. package/src/services/ref-resolver/tokens.ts +25 -0
  101. package/src/services/ref-resolver/types.ts +155 -0
  102. package/src/templating/init-liquid-engine.ts +33 -18
  103. package/src/templating/lib/secret-scope.ts +41 -0
  104. package/src/templating/tags/exportScalarVarsJs.ts +5 -2
  105. package/src/templating/tags/showVars.ts +8 -2
  106. package/src/lib/refs/find.ts +0 -770
  107. package/src/lib/refs/index.ts +0 -14
  108. package/src/lib/refs/parse.ts +0 -844
  109. package/src/lib/refs/pick.ts +0 -147
  110. package/src/lib/refs/scopes.ts +0 -95
  111. package/src/lib/refs/settle.ts +0 -219
@@ -295,26 +295,38 @@
295
295
  },
296
296
  "additionalProperties": false
297
297
  },
298
- "recipeOutputs": {
298
+ "recipes": {
299
299
  "type": "object",
300
300
  "properties": {
301
301
  "skills": {
302
- "type": "array",
303
- "items": {
304
- "type": "string"
305
- }
302
+ "type": "object",
303
+ "properties": {
304
+ "outputs": {
305
+ "type": "array",
306
+ "items": {
307
+ "type": "string"
308
+ }
309
+ }
310
+ },
311
+ "additionalProperties": false
306
312
  },
307
313
  "memories": {
308
- "type": "array",
309
- "items": {
310
- "type": "string"
311
- }
312
- },
313
- "prompts": {
314
- "type": "array",
315
- "items": {
316
- "type": "string"
317
- }
314
+ "type": "object",
315
+ "properties": {
316
+ "first": {
317
+ "type": "array",
318
+ "items": {
319
+ "type": "string"
320
+ }
321
+ },
322
+ "exclude": {
323
+ "type": "array",
324
+ "items": {
325
+ "type": "string"
326
+ }
327
+ }
328
+ },
329
+ "additionalProperties": false
318
330
  }
319
331
  },
320
332
  "additionalProperties": false
@@ -24,7 +24,7 @@ import { recipeFilesDirectory } from "../../lib/repos/catalog-inputs.js";
24
24
  import { readRecipeManifestIn } from "../../lib/repos/locked-recipes.js";
25
25
  import { createEmptyLockfile } from "../../lib/repos/formats/lockfile.js";
26
26
  import type { LockRepoInput } from "../../lib/repos/lock-service.js";
27
- import { RefSource, parseShortRef } from "../../lib/refs/index.js";
27
+ import { RefSource, parseNamedRef } from "../../services/ref-resolver/index.js";
28
28
  import {
29
29
  PROJECT_REQUESTER,
30
30
  resolveRefs,
@@ -133,12 +133,9 @@ export default class LockRebuild extends BaseCommand {
133
133
  .sort()
134
134
  .map((key) => {
135
135
  const entry = subscriptions[key]!;
136
- const parsed = parseShortRef(key, RefSource.Config);
137
136
  return {
138
- ref: {
139
- ...parsed,
140
- ...(entry.range === undefined ? {} : { range: entry.range }),
141
- },
137
+ ref: parseNamedRef(key, RefSource.Config),
138
+ ...(entry.range === undefined ? {} : { range: entry.range }),
142
139
  requestedBy: PROJECT_REQUESTER,
143
140
  kind: "subscribes" as const,
144
141
  ...(entry.prerelease === true ? { prerelease: true } : {}),
@@ -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;
@@ -116,13 +118,15 @@ export function resolveRecipeTargets(
116
118
 
117
119
  const missing = unansweredWarning(resolved, rootScope);
118
120
  if (missing !== undefined) recipes.warnings.push(missing);
119
- return recipes;
121
+ return { ...recipes, secretVariables: [...resolved.secrets] };
120
122
  }
121
123
 
122
124
  /**
123
125
  * Adds the recipe targets to a project's own compilation config. A project with
124
126
  * no compilation block of its own still compiles its recipes, so the config is
125
- * created when there is none and there is something to compile.
127
+ * created when there is none and there is something to compile. The names the
128
+ * recipes declare secret go onto the config either way, because a project's own
129
+ * template may dump the scope too.
126
130
  *
127
131
  * @param config - The project's own compilation config, or null when it has none.
128
132
  * @param recipes - The targets the subscribed recipes contribute.
@@ -135,15 +139,65 @@ export function withRecipeTargets(
135
139
  settings: Settings,
136
140
  rootScope: Record<string, string>
137
141
  ): CompilationConfig | null {
138
- if (recipes.targets.length === 0) return config;
142
+ const secrets = recipes.secretVariables?.length ? { secretVariables: recipes.secretVariables } : {};
143
+ if (recipes.targets.length === 0) return config === null ? null : { ...config, ...secrets };
139
144
  if (config === null) {
140
145
  return {
141
146
  targets: recipes.targets,
142
147
  aliases: resolveAliases(settings, rootScope),
143
148
  includeScope: rootScope,
149
+ ...secrets,
144
150
  };
145
151
  }
146
- return { ...config, targets: [...config.targets, ...recipes.targets] };
152
+ return { ...config, targets: [...config.targets, ...recipes.targets], ...secrets };
153
+ }
154
+
155
+ /**
156
+ * The memories an active recipe publishes that no output of the build included
157
+ * by any route (an exact path, a glob or the `#memories` view). Recipes under
158
+ * `recipes.memories.exclude` are never listed.
159
+ *
160
+ * @param settings - The merged project config, for `recipes.memories`.
161
+ * @param sousDir - The project's `.sous/` directory.
162
+ * @param included - Every file the compile read, as real paths.
163
+ */
164
+ export function unincludedMemories(
165
+ settings: Settings,
166
+ sousDir: string,
167
+ included: ReadonlySet<string>
168
+ ): MemoryFile[] {
169
+ const memories = settings.recipes?.memories;
170
+ return listMemories({ sousDir, first: memories?.first, exclude: memories?.exclude }).filter(
171
+ (memory) => !included.has(realPath(memory.file))
172
+ );
173
+ }
174
+
175
+ /** The path with symbolic links resolved, or the path itself when it cannot be read. */
176
+ function realPath(filePath: string): string {
177
+ try {
178
+ return fs.realpathSync(filePath);
179
+ } catch {
180
+ return filePath;
181
+ }
182
+ }
183
+
184
+ /**
185
+ * The warning for memories no output included, or undefined when there are none.
186
+ *
187
+ * @param missing - What {@link unincludedMemories} found.
188
+ */
189
+ export function unincludedMemoriesWarning(missing: readonly MemoryFile[]): string | undefined {
190
+ if (missing.length === 0) return undefined;
191
+ const noun = missing.length === 1 ? "memory" : "memories";
192
+ return (
193
+ `${missing.length} ${noun} published by your subscribed recipes ${
194
+ missing.length === 1 ? "is" : "are"
195
+ } not included in any output, so no agent will read ${missing.length === 1 ? "it" : "them"}:\n` +
196
+ missing.map((memory) => `${BULLET} ${memory.recipe}: ${memory.relative}`).join("\n") +
197
+ `\nTo include every memory, add the line "@#memories/**/*.md" to your project's instruction ` +
198
+ `source (the file your AGENTS.md or CLAUDE.md is built from). To leave a recipe's memories out on purpose, ` +
199
+ `list the recipe in "recipes.memories.exclude" in your sous config.`
200
+ );
147
201
  }
148
202
 
149
203
  /**
@@ -193,6 +247,7 @@ function collectIncludeGraph(
193
247
  aliases?: Record<string, string[]>;
194
248
  scope?: Record<string, string>;
195
249
  namespaceResolver?: NamespaceResolver;
250
+ views?: ViewMap;
196
251
  } = {},
197
252
  visited: Set<string> = new Set()
198
253
  ): Set<string> {
@@ -208,21 +263,23 @@ function collectIncludeGraph(
208
263
  return visited;
209
264
  }
210
265
 
211
- const includePattern = /^@([~a-zA-Z0-9_${}][a-zA-Z0-9_\-/.:${}]*\.md)$/gm;
212
266
  const baseDir = path.dirname(filePath);
213
- let match: RegExpExecArray | null;
214
267
 
215
- while ((match = includePattern.exec(content)) !== null) {
216
- const includePath = match[1].trim();
217
- const candidates = resolveIncludeCandidates(includePath, {
268
+ for (const line of content.split("\n")) {
269
+ const match = INCLUDE_LINE_PATTERN.exec(line.trimEnd());
270
+ if (match === null) continue;
271
+ const { files, candidates } = resolveIncludeFiles(match[1].trim(), {
218
272
  aliases: resolveOpts.aliases,
273
+ views: resolveOpts.views,
219
274
  scope: resolveOpts.scope,
220
275
  baseDir,
221
276
  namespaceResolver: resolveOpts.namespaceResolver,
222
277
  fromFile: filePath,
223
278
  });
224
- const fullPath = candidates.find((c) => fs.existsSync(c)) ?? candidates[0];
225
- collectIncludeGraph(fullPath, resolveOpts, visited);
279
+ // A file that does not exist yet is still part of the graph, as its first candidate.
280
+ for (const fullPath of files.length > 0 ? files : candidates.slice(0, 1)) {
281
+ collectIncludeGraph(fullPath, resolveOpts, visited);
282
+ }
226
283
  }
227
284
 
228
285
  return visited;
@@ -248,6 +305,7 @@ export function findAffectedTargets(
248
305
  return config.targets.filter(target => {
249
306
  const graph = collectIncludeGraph(target.rootInputPath, {
250
307
  aliases: config.aliases,
308
+ views: config.views,
251
309
  scope: config.includeScope,
252
310
  namespaceResolver,
253
311
  });
@@ -313,12 +371,23 @@ export class BuildService {
313
371
  const recipes = resolveRecipeTargets(settings, rootScope, options.configContext, answers);
314
372
  for (const notice of recipes.warnings) warning(notice);
315
373
 
316
- const config = withRecipeTargets(
374
+ const withoutViews = withRecipeTargets(
317
375
  resolveCompilation(settings, rootScope),
318
376
  recipes,
319
377
  settings,
320
378
  rootScope
321
379
  );
380
+ // The files each view lists (`#memories`), worked out once per build.
381
+ const config: CompilationConfig | null =
382
+ withoutViews === null || options.configContext === undefined
383
+ ? withoutViews
384
+ : {
385
+ ...withoutViews,
386
+ views: sharedHashNames().viewMap({
387
+ sousDir: options.configContext.sousDir,
388
+ settings,
389
+ }),
390
+ };
322
391
 
323
392
  if (config) {
324
393
  let effectiveConfig: CompilationConfig = config;
@@ -351,6 +420,15 @@ export class BuildService {
351
420
  });
352
421
  const compileOk = await compiler.compile(effectiveConfig, stateFilePath);
353
422
  if (!compileOk) success = false;
423
+
424
+ // Only a full build knows every route an include can take, so only a
425
+ // full build that compiled cleanly checks that every memory got in.
426
+ if (compileOk && options.configContext !== undefined) {
427
+ const notice = unincludedMemoriesWarning(
428
+ unincludedMemories(settings, options.configContext.sousDir, compiler.includedFiles())
429
+ );
430
+ if (notice !== undefined) warning(notice);
431
+ }
354
432
  }
355
433
  }
356
434
  }