@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
@@ -11,7 +11,13 @@ import {
11
11
  hashContent,
12
12
  recordDirCreation,
13
13
  } from "./state.js";
14
- import { resolveInclude, type AliasMap } from "./include-resolver.js";
14
+ import {
15
+ resolveIncludeFiles,
16
+ splitIncludeQuery,
17
+ type AliasMap,
18
+ type ViewMap,
19
+ } from "./include-resolver.js";
20
+ import { RefSource, sharedRefResolver } from "../services/ref-resolver/index.js";
15
21
  import {
16
22
  formatNamespaceProblem,
17
23
  type NamespaceResolver,
@@ -58,6 +64,8 @@ export type CompilationConfig = {
58
64
  targets: CompilationTarget[];
59
65
  /** Resolved `@include` alias map (name → ordered base dirs). */
60
66
  aliases?: Record<string, string[]>;
67
+ /** The files each view lists (`#memories`), keyed by the name with its `#`. */
68
+ views?: ViewMap;
61
69
  /** Variable scope for `${var}` substitution in `@include` paths. */
62
70
  includeScope?: Record<string, string>;
63
71
  };
@@ -145,12 +153,55 @@ export function stableVarsFingerprint(vars: Record<string, string>): string {
145
153
  return JSON.stringify(Object.keys(vars).sort().map((key) => [key, vars[key]]));
146
154
  }
147
155
 
156
+ /**
157
+ * An include line: `@`, a path that ends in `.md` and an optional
158
+ * `?name=value` query, and nothing else. The first character is a sigil, a
159
+ * variable, a dot or a letter, a digit or a glob character; the rest may hold
160
+ * the characters of a path, a `${var}`, a glob and a recipe reference's `repo:`
161
+ * qualifier.
162
+ */
163
+ export const INCLUDE_LINE_PATTERN =
164
+ /^@([~#a-zA-Z0-9_.${}*?[][a-zA-Z0-9_\-/.:${}*?[\],!~#%+^()]*\.md(?:\?[^?\s]*=\S*)?)$/;
165
+
166
+ /**
167
+ * Whether a line that is not a well-formed include still looks like one: it
168
+ * starts with `@` and is a single path-like word, meaning it holds a `/` or a
169
+ * `.md`, or starts with a sigil or a variable (`@~`, `@#`, `@.`, `@$`). A line
170
+ * that merely starts with `@` (a mention or an email address followed by
171
+ * words, or a lone `@name`) does not.
172
+ *
173
+ * looksLikeIncludeLine("@docs/notes.txt"); // -> true, the include has no .md
174
+ * looksLikeIncludeLine("@alice thanks for the review"); // -> false
175
+ * looksLikeIncludeLine("@alice"); // -> false
176
+ *
177
+ * @param line - One line of a file, with trailing whitespace already removed.
178
+ */
179
+ export function looksLikeIncludeLine(line: string): boolean {
180
+ if (!/^@\S+$/.test(line)) return false;
181
+ const word = line.slice(1);
182
+ return /[/]/.test(word) || /\.md\b/.test(word) || /^[~#.$]/.test(word);
183
+ }
184
+
185
+ /** One piece of a read file: a line of its own text, or a file it includes. */
186
+ type Part = string | { file: string };
187
+
188
+ /** A file read for a target: its text, whether its name makes it a template, and its pieces. */
189
+ type FileNode = {
190
+ path: string;
191
+ content: string;
192
+ /** True when the file's own name contains `.tpl.`: the only files that render as Liquid. */
193
+ isTpl: boolean;
194
+ parts: Part[];
195
+ };
196
+
148
197
  export class CompilationService {
149
198
  private strict: boolean;
150
199
  private rebuild: boolean;
151
200
  private dryRun: boolean;
152
- private visited: Set<string>;
153
201
  private includeStack: string[];
202
+ private nodes: Map<string, FileNode>;
203
+ private views: ViewMap;
204
+ private includedFilesSeen: Set<string>;
154
205
  private errors: string[];
155
206
  private includeSourceComments: boolean;
156
207
  private currentIncludeSourceComments: boolean;
@@ -170,8 +221,10 @@ export class CompilationService {
170
221
  this.strict = options.strict ?? false;
171
222
  this.rebuild = options.rebuild ?? false;
172
223
  this.dryRun = options.dryRun ?? false;
173
- this.visited = new Set();
174
224
  this.includeStack = [];
225
+ this.nodes = new Map();
226
+ this.views = {};
227
+ this.includedFilesSeen = new Set();
175
228
  this.errors = [];
176
229
  this.includeSourceComments = false;
177
230
  this.currentIncludeSourceComments = false;
@@ -214,23 +267,36 @@ export class CompilationService {
214
267
  /**
215
268
  * Process @<path> includes in content.
216
269
  *
217
- * Matches an `@`-prefixed `.md` path on its own line. The path may be:
270
+ * Matches an `@`-prefixed `.md` path on its own line, optionally followed by
271
+ * a `?name=value` query (read by the ref service and otherwise ignored). The
272
+ * path may be:
218
273
  * - relative to the including file (`@sections/intro.md`),
219
274
  * - a `${var}`-substituted path (`@${sousRootPath}/x.md`),
220
- * - an alias path (`@~project/memories/x.md`, `@docs/x.md`), where the
221
- * first segment (up to `/` or `:`) names a registered alias,
222
- * - or a recipe namespace path (`@~workflow/task-files/_partials/x.md`),
223
- * where the `~` sigil names a namespace and the rest names a recipe and a
224
- * file inside it. Namespaces are only consulted when a namespace resolver
225
- * was supplied, and always after aliases.
275
+ * - a `#name` path (`@#project/memories/x.md`), or an alias path
276
+ * (`@docs/x.md`), where the first segment (up to `/` or `:`) names a
277
+ * registered alias,
278
+ * - a home-relative path (`@~/notes/x.md`),
279
+ * - or a recipe reference (`@~workflow/task-files/_partials/x.md`), where
280
+ * the `~` sigil is followed by a namespace, a recipe and a file inside it.
281
+ * Recipe references are only consulted when a namespace resolver was
282
+ * supplied.
283
+ * Any of them may use glob syntax, and then includes every file it matches,
284
+ * in sorted path order; a glob that matches nothing is an error.
285
+ *
286
+ * A file may be included any number of times; only a file that includes
287
+ * itself (directly or through others) is an error.
226
288
  *
227
289
  * Lines inside fenced code blocks (``` or ~~~, per CommonMark) are left
228
290
  * verbatim, so include syntax can be documented without being executed.
229
291
  *
230
- * Resolution produces an ordered candidate list (see include-resolver); the
231
- * first candidate that exists on disk is used. If none exist, it errors,
232
- * naming the including file and listing every path tried, plus what went
233
- * wrong with the namespace lookup when one was attempted.
292
+ * A line that starts with `@` and looks like an include (see
293
+ * {@link looksLikeIncludeLine}) but is not a well-formed one is a compile
294
+ * error naming the file and the line, never text copied to the output.
295
+ *
296
+ * Resolution produces ordered groups of candidates (see include-resolver);
297
+ * the first group that names a file is used. If none does, it errors, naming
298
+ * the including file and listing every path tried, plus what went wrong with
299
+ * a `~` or `#` reference when one was attempted.
234
300
  *
235
301
  * @param content - The file's raw text.
236
302
  * @param baseDir - Directory the relative candidate resolves against.
@@ -242,18 +308,16 @@ export class CompilationService {
242
308
  baseDir: string,
243
309
  projectRoot: string,
244
310
  fromFile?: string
245
- ): string {
246
- // First segment allows ~ and . (so ./ and ../ work), then path chars;
247
- // separators / and :; allows ${...}.
248
- const includePattern = /^@([~a-zA-Z0-9_.${}][a-zA-Z0-9_\-/.:${}]*\.md)$/;
311
+ ): Part[] {
249
312
  const fenceOpenPattern = /^ {0,3}(`{3,}|~{3,})/;
250
313
  const fenceClosePattern = /^ {0,3}(`{3,}|~{3,})[ \t]*$/;
314
+ const where = fromFile ?? baseDir;
251
315
 
252
- const out: string[] = [];
316
+ const out: Part[] = [];
253
317
  let fenceChar: string | null = null;
254
318
  let fenceLength = 0;
255
319
 
256
- for (const line of content.split("\n")) {
320
+ for (const [lineIndex, line] of content.split("\n").entries()) {
257
321
  if (fenceChar !== null) {
258
322
  // Inside a fence: emit verbatim; only a matching closing fence ends it.
259
323
  const close = fenceClosePattern.exec(line);
@@ -272,28 +336,63 @@ export class CompilationService {
272
336
  continue;
273
337
  }
274
338
 
275
- const match = includePattern.exec(line);
339
+ const trimmed = line.trimEnd();
340
+ const match = INCLUDE_LINE_PATTERN.exec(trimmed);
276
341
  if (!match) {
342
+ if (looksLikeIncludeLine(trimmed)) {
343
+ this.handleError(
344
+ `Malformed include line: ${trimmed}\n in file: ${where}, line ${lineIndex + 1}\n` +
345
+ ` An include is "@" followed by a path that ends in .md, optionally followed by ` +
346
+ `"?name=value". The path may hold letters, digits and . _ - / : $ { } * ? [ ] , ~ #.\n` +
347
+ ` To write a line that starts with "@" as text, put a word after it.`
348
+ );
349
+ }
277
350
  out.push(line);
278
351
  continue;
279
352
  }
280
353
 
281
354
  const includePath = match[1].trim();
282
- const { candidates, namespaceIssue } = resolveInclude(includePath, {
283
- aliases: this.aliases,
284
- scope: this.includeScope,
285
- baseDir,
286
- namespaceResolver: this.namespaceResolver,
287
- fromFile: fromFile ?? baseDir,
288
- });
289
- const fullPath = candidates.find((c) => fs.existsSync(c));
290
-
291
- if (!fullPath) {
355
+ const { files, glob, candidates, namespaceIssue, hashIssue, view } = resolveIncludeFiles(
356
+ includePath,
357
+ {
358
+ aliases: this.aliases,
359
+ views: this.views,
360
+ scope: this.includeScope,
361
+ baseDir,
362
+ namespaceResolver: this.namespaceResolver,
363
+ fromFile: where,
364
+ }
365
+ );
366
+
367
+ if (files.length === 0) {
368
+ // A view is a list that may be empty: a glob over it that selects
369
+ // nothing includes nothing, and the line leaves no trace.
370
+ if (view === true && glob && hashIssue === undefined) continue;
371
+ if (this.namespaceResolver === undefined && includePath.startsWith("~")) {
372
+ // Without a resolver the reference is only a relative path, so say
373
+ // what is wrong with a malformed one instead of "not found".
374
+ const reference = splitIncludeQuery(includePath).path.slice(1);
375
+ if (reference.includes("/") && !reference.includes("${")) {
376
+ try {
377
+ sharedRefResolver().parse(reference, RefSource.Include);
378
+ } catch (error) {
379
+ this.handleError(
380
+ `Malformed include line: ${trimmed}\n in file: ${where}, line ${lineIndex + 1}\n ${
381
+ error instanceof Error ? error.message : String(error)
382
+ }`
383
+ );
384
+ out.push("");
385
+ continue;
386
+ }
387
+ }
388
+ }
292
389
  const explanation = namespaceIssue
293
390
  ? `\n${formatNamespaceProblem(namespaceIssue)}`
294
- : `\n in file: ${fromFile ?? baseDir}`;
391
+ : hashIssue
392
+ ? `\n in file: ${where}\n ${hashIssue.split("\n").join("\n ")}`
393
+ : `\n in file: ${where}`;
295
394
  this.handleError(
296
- `Include not found: @${includePath}${explanation}\n tried:\n${candidates
395
+ `${glob ? "Include matched no files" : "Include not found"}: @${includePath}${explanation}\n tried:\n${candidates
297
396
  .map((c) => ` - ${c}`)
298
397
  .join("\n")}`
299
398
  );
@@ -301,57 +400,121 @@ export class CompilationService {
301
400
  continue;
302
401
  }
303
402
 
304
- if (this.includeStack.includes(fullPath)) {
305
- this.handleError(
306
- `Circular dependency detected: ${this.includeStack.join(" -> ")} -> ${fullPath}`
307
- );
308
- out.push("");
309
- continue;
310
- }
403
+ for (const fullPath of files) {
404
+ if (this.includeStack.includes(fullPath)) {
405
+ this.handleError(
406
+ `Circular dependency detected: ${this.includeStack.join(" -> ")} -> ${fullPath}`
407
+ );
408
+ out.push("");
409
+ continue;
410
+ }
311
411
 
312
- const includedContent = this.loadFile(fullPath, projectRoot);
313
- if (includedContent !== null) {
314
- const relativePath = path.relative(projectRoot, fullPath);
315
- const sourceComment = this.currentIncludeSourceComments
316
- ? `<!-- from: ${relativePath} -->\n`
317
- : "";
318
- out.push(sourceComment + includedContent);
319
- } else {
320
- out.push("");
412
+ out.push(this.loadFile(fullPath, projectRoot) === null ? "" : { file: fullPath });
321
413
  }
322
414
  }
323
415
 
324
- return out.join("\n");
416
+ return out;
325
417
  }
326
418
 
327
- /** Load a file and recursively process its includes. */
328
- private loadFile(filePath: string, projectRoot: string): string | null {
419
+ /**
420
+ * Reads a file and, recursively, every file it includes, without rendering
421
+ * anything. A file already read for this target is not read again (its
422
+ * includes resolve the same way whoever includes it), so a file may be
423
+ * included any number of times; only a cycle is an error.
424
+ */
425
+ private loadFile(filePath: string, projectRoot: string): FileNode | null {
426
+ const known = this.nodes.get(filePath);
427
+ if (known !== undefined) return known;
428
+
329
429
  if (!fs.existsSync(filePath)) {
330
430
  this.handleError(`File not found: ${filePath}`);
331
431
  return null;
332
432
  }
333
433
 
334
- if (this.visited.has(filePath)) {
335
- return "";
336
- }
337
-
338
434
  this.includeStack.push(filePath);
339
435
 
340
436
  try {
341
437
  const content = fs.readFileSync(filePath, "utf8");
342
438
  const baseDir = path.dirname(filePath);
343
- const processedContent = this.processIncludes(content, baseDir, projectRoot, filePath);
344
- this.visited.add(filePath);
345
- return processedContent;
439
+ const node: FileNode = {
440
+ path: filePath,
441
+ content,
442
+ isTpl: path.basename(filePath).includes(".tpl."),
443
+ parts: [],
444
+ };
445
+ // Registered before its includes are read so the graph lists parents
446
+ // first; the cycle check uses the stack, not this map.
447
+ this.nodes.set(filePath, node);
448
+ node.parts = this.processIncludes(content, baseDir, projectRoot, filePath);
449
+ return node;
346
450
  } catch (error) {
347
451
  const message = error instanceof Error ? error.message : String(error);
348
452
  this.handleError(`Failed to read file ${filePath}: ${message}`);
453
+ this.nodes.delete(filePath);
349
454
  return null;
350
455
  } finally {
351
456
  this.includeStack.pop();
352
457
  }
353
458
  }
354
459
 
460
+ /**
461
+ * Turns a read file into its final text.
462
+ *
463
+ * A file whose own name contains `.tpl.` is rendered as Liquid with the
464
+ * given variables; no other file ever is. Each include is left as a unique
465
+ * marker while the file renders, so a condition around an include line is
466
+ * honored, and an included file's own output is never rendered again. Every
467
+ * marker that survives the render is then replaced by that file's final text,
468
+ * produced the same way.
469
+ *
470
+ * @param filePath - The file to expand.
471
+ * @param vars - The output's variable scope; undefined means templates stay unrendered.
472
+ * @param entryDir - The directory of the target's entry point, also searched by `{% render %}`.
473
+ * @param projectRoot - Root used to render source comments as relative paths.
474
+ */
475
+ private async expand(
476
+ filePath: string,
477
+ vars: Record<string, string> | undefined,
478
+ entryDir: string,
479
+ projectRoot: string
480
+ ): Promise<string> {
481
+ const node = this.nodes.get(filePath)!;
482
+ const nonce = `${process.pid}-${Math.random().toString(36).slice(2)}`;
483
+ const markers = new Map<string, string>();
484
+ const chunks = node.parts.map((part) => {
485
+ if (typeof part === "string") return part;
486
+ const marker = `\u0001SOUS-INCLUDE-${nonce}-${markers.size}\u0001`;
487
+ markers.set(marker, part.file);
488
+ return marker;
489
+ });
490
+
491
+ let text = chunks.join("\n");
492
+ if (node.isTpl && vars !== undefined) {
493
+ const dir = path.dirname(node.path);
494
+ text = await this.renderContent(
495
+ text,
496
+ { ...vars, sousTemplatePath: node.path, sousTemplateDir: dir },
497
+ [...new Set([dir, entryDir])],
498
+ node.path
499
+ );
500
+ }
501
+
502
+ for (const [marker, child] of markers) {
503
+ if (!text.includes(marker)) continue;
504
+ const body = await this.expand(child, vars, entryDir, projectRoot);
505
+ const comment = this.currentIncludeSourceComments
506
+ ? `<!-- from: ${path.relative(projectRoot, child)} -->\n`
507
+ : "";
508
+ text = text.split(marker).join(comment + body);
509
+ }
510
+ return text;
511
+ }
512
+
513
+ /** Every file read for the current target, parents before the files they include. */
514
+ private graphNodes(): FileNode[] {
515
+ return [...this.nodes.values()];
516
+ }
517
+
355
518
  /**
356
519
  * Render template content using LiquidJS with the given variable scope.
357
520
  *
@@ -368,6 +531,7 @@ export class CompilationService {
368
531
  ): Promise<string> {
369
532
  const engine = createLiquidEngine(roots, {
370
533
  aliases: this.aliases,
534
+ views: this.views,
371
535
  scope: { ...this.includeScope, ...vars },
372
536
  namespaceResolver: this.namespaceResolver,
373
537
  fromFile,
@@ -473,8 +637,8 @@ ${taskFileContents}
473
637
 
474
638
  this.initializeEncoder();
475
639
 
476
- this.visited.clear();
477
640
  this.includeStack = [];
641
+ this.nodes = new Map();
478
642
  this.currentIncludeSourceComments =
479
643
  typeof target.includeSourceComments === "boolean"
480
644
  ? target.includeSourceComments
@@ -487,9 +651,9 @@ ${taskFileContents}
487
651
  }
488
652
 
489
653
  const promptsRoot = path.dirname(target.rootInputPath);
490
- const content = this.loadFile(target.rootInputPath, promptsRoot);
654
+ const entry = this.loadFile(target.rootInputPath, promptsRoot);
491
655
 
492
- if (content === null) {
656
+ if (entry === null) {
493
657
  displayError(`Failed to compile ${target.rootInputPath}`);
494
658
  return false;
495
659
  }
@@ -499,12 +663,24 @@ ${taskFileContents}
499
663
  // be written: the previous copy stays in place.
500
664
  const assembled = this.errors.length === errorsBefore;
501
665
 
502
- // Compute source hash once per target from the assembled content
503
- const contentHash = hashContent(content);
666
+ // Compute the source hash once per target from every file read: the entry
667
+ // point and everything it includes, whatever a condition decides later.
668
+ const graph = this.graphNodes();
669
+ for (const node of graph) this.includedFilesSeen.add(realPathOf(node.path));
670
+ const templates = graph.filter((node) => node.isTpl);
671
+ const contentHash = hashContent(
672
+ JSON.stringify([
673
+ this.currentIncludeSourceComments,
674
+ graph.map((node) => [path.relative(promptsRoot, node.path), node.content]),
675
+ ])
676
+ );
504
677
 
505
678
  let allSucceeded = true;
506
679
 
507
- const isTpl = path.basename(target.rootInputPath).includes(".tpl.");
680
+ const isTpl = entry.isTpl;
681
+ // Whether any file of this target renders as Liquid, which makes the
682
+ // output depend on its variables.
683
+ const rendersAnything = templates.length > 0;
508
684
 
509
685
  for (const output of target.outputs) {
510
686
  // Resolve destination path: prefer destinationFile, fall back to destinationDir mirroring
@@ -525,8 +701,8 @@ ${taskFileContents}
525
701
  // changed answer or `_vars` value with the same template must re-render,
526
702
  // so the variable scope is part of a `.tpl.` output's source hash. A
527
703
  // verbatim copy hashes its content alone.
528
- const srcHash = isTpl && output.vars
529
- ? hashContent(`${content}\n${stableVarsFingerprint(output.vars)}`)
704
+ const srcHash = rendersAnything && output.vars
705
+ ? hashContent(`${contentHash}\n${stableVarsFingerprint(output.vars)}`)
530
706
  : contentHash;
531
707
 
532
708
  // Skip if content is unchanged and file already exists (unless --rebuild)
@@ -554,8 +730,10 @@ ${taskFileContents}
554
730
  // resolver always sets `vars` on every output (at minimum the inherited
555
731
  // scope). It is a real guard for callers that build a CompilationConfig
556
732
  // directly, and it stays as a tripwire in case the resolver ever changes.
557
- if (isTpl && !output.vars) {
558
- this.unrenderedTemplates.push(`${target.rootInputPath} → ${destFile}`);
733
+ if (rendersAnything && !output.vars) {
734
+ for (const template of templates) {
735
+ this.unrenderedTemplates.push(`${template.path} → ${destFile}`);
736
+ }
559
737
  // In strict mode this warning is an error, so the output is not written.
560
738
  if (this.strict) {
561
739
  this.reportNotWritten(destFile);
@@ -565,18 +743,12 @@ ${taskFileContents}
565
743
  }
566
744
 
567
745
  const errorsBeforeRender = this.errors.length;
568
- const resolvedContent = (isTpl && output.vars)
569
- ? await this.renderContent(
570
- content,
571
- {
572
- ...output.vars,
573
- sousTemplatePath: target.rootInputPath,
574
- sousTemplateDir: promptsRoot,
575
- },
576
- [promptsRoot],
577
- target.rootInputPath
578
- )
579
- : content;
746
+ const resolvedContent = await this.expand(
747
+ target.rootInputPath,
748
+ output.vars,
749
+ promptsRoot,
750
+ promptsRoot
751
+ );
580
752
 
581
753
  // A template that failed to render is not written either.
582
754
  if (this.errors.length > errorsBeforeRender) {
@@ -644,6 +816,15 @@ ${taskFileContents}
644
816
  return allSucceeded;
645
817
  }
646
818
 
819
+ /**
820
+ * Every file the last compile read, as real paths: the entry points and every
821
+ * file any include line of any target named, by any route (exact path, glob
822
+ * or view). Includes inside an unmet condition count, since they are read.
823
+ */
824
+ includedFiles(): ReadonlySet<string> {
825
+ return this.includedFilesSeen;
826
+ }
827
+
647
828
  /**
648
829
  * Compile all targets from the given config. Every target is compiled even
649
830
  * after an error, so every error is listed; an output whose target had an
@@ -675,6 +856,8 @@ ${taskFileContents}
675
856
 
676
857
  this.includeSourceComments = config.includeSourceComments === true;
677
858
  this.aliases = config.aliases ?? {};
859
+ this.views = config.views ?? {};
860
+ this.includedFilesSeen = new Set();
678
861
  this.includeScope = config.includeScope ?? {};
679
862
 
680
863
  this.initializeEncoder();
@@ -738,3 +921,12 @@ ${taskFileContents}
738
921
  // Backward-compat alias so existing imports of MarkdownCompiler keep working
739
922
  export { CompilationService as MarkdownCompiler };
740
923
  export type { CompilationServiceOptions as MarkdownCompilerOptions };
924
+
925
+ /** The path with symbolic links resolved, or the path itself when it cannot be read. */
926
+ function realPathOf(filePath: string): string {
927
+ try {
928
+ return fs.realpathSync(filePath);
929
+ } catch {
930
+ return filePath;
931
+ }
932
+ }
@@ -33,7 +33,7 @@ export type ProjectConfigFormat = (typeof PROJECT_CONFIG_FORMATS)[number];
33
33
  * compiled output (`AGENTS.md` at the project root) are never mistaken for one
34
34
  * another.
35
35
  */
36
- export const STARTER_PROMPT_RELATIVE_PATH = "memories/AGENTS.md";
36
+ export const STARTER_PROMPT_RELATIVE_PATH = "memories/AGENTS.tpl.md";
37
37
 
38
38
  /** The name of the file the starter prompt is compiled into, at the project root. */
39
39
  export const STARTER_OUTPUT_NAME = "AGENTS.md";
@@ -98,14 +98,19 @@ export const config = {
98
98
  ],
99
99
  },
100
100
 
101
- // Where the recipes this project subscribes to write their files. Every
102
- // project subscribes to the \`core\` namespace on its own, which is how the
103
- // skills that teach an agent about sous reach \`.claude/skills\`. Memories and
104
- // prompts have no default home; name one to receive them.
105
- recipeOutputs: {
106
- skills: ["\${projectRoot}/.claude/skills"],
107
- // memories: ["\${projectRoot}/.claude/memories"],
108
- // prompts: ["\${projectRoot}/.claude/prompts"],
101
+ // What to do with the content the recipes this project subscribes to publish.
102
+ // Every project subscribes to the \`core\` namespace on its own, which is how
103
+ // the skills that teach an agent about sous reach \`.claude/skills\`. Memories
104
+ // are not copied anywhere: the starter prompt pulls them in with one include
105
+ // line, and the lists below (uncomment to use them) choose which come first
106
+ // and which are left out. An entry is a glob over \`namespace/recipe\`, or a
107
+ // regular expression written between two slashes.
108
+ recipes: {
109
+ skills: { outputs: ["\${projectRoot}/.claude/skills"] },
110
+ // memories: {
111
+ // first: ["communication/*"],
112
+ // exclude: ["tool-usage/automated-browser-tasks", "/^experimental\\//"],
113
+ // },
109
114
  },
110
115
  };
111
116
  `;
@@ -132,8 +137,8 @@ export function buildConfigJson(context: ProjectScaffoldContext): string {
132
137
  },
133
138
  ],
134
139
  },
135
- recipeOutputs: {
136
- skills: ["${projectRoot}/.claude/skills"],
140
+ recipes: {
141
+ skills: { outputs: ["${projectRoot}/.claude/skills"] },
137
142
  },
138
143
  };
139
144
  return `${JSON.stringify(config, null, 2)}\n`;
@@ -158,6 +163,9 @@ source, run \`sous build\`, and the compiled copy follows; never edit the compil
158
163
  each one in with a line holding only \`@sections/name.md\`.
159
164
  - The skills under \`.claude/skills\` are written by sous from the recipes this
160
165
  project subscribes to. \`sous recipe list\` shows what is available.
166
+
167
+ {% comment %}This line pulls in the memories of every recipe this project subscribes to.{% endcomment %}
168
+ @#memories/**/*.md
161
169
  `;
162
170
  }
163
171
 
@@ -6,7 +6,7 @@
6
6
  * deliberately the only place that knows: the subscription service for the
7
7
  * trusted repositories and their cached indexes, the lockfile service for what
8
8
  * is pinned, the links map and the store for a recipe's own files, and
9
- * `recipeOutputs` for where a content kind lands.
9
+ * `recipes` for where a content kind lands.
10
10
  *
11
11
  * By default nothing here downloads anything: a repository whose index has
12
12
  * never been fetched is left out of the catalog and named separately, so a