@sous-io/sous 0.2.1 → 0.2.2

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.
@@ -1,1084 +1,320 @@
1
1
  # Repository File Formats
2
2
 
3
- Repositories publish versioned **recipes**, grouped into **namespaces**, and projects subscribe
4
- to them. Six files carry the whole system on disk. Three of them you write by hand; three sous
5
- writes for you.
3
+ The reference for every file and config key in the Repositories system; the pages explaining how to use them
4
+ are listed at the end. You write the repository manifest and one recipe manifest per recipe; sous writes the
5
+ rest: the published index, a project's lockfile, the marker on a store entry, and the links map.
6
6
 
7
- | File | Where | Written by | Purpose |
8
- |------|-------|-----------|---------|
9
- | `sous.repo.yaml` | repository root | you | Declares the repository: its namespaces and where its recipes live |
10
- | `sous.recipe.yaml` | each recipe folder | you | Declares one recipe: version, dependencies, contents, variables |
11
- | `sous.index.json` | repository root | `sous repo release` | Every namespace, recipe and published version, with content hashes |
12
- | `sous.lock.json` | a project's `.sous/` | sous | What the project actually resolved to |
13
- | `.sous.entry.json` | each store entry | sous | Makes a cached recipe version self-describing |
14
- | `sous.links.json` | `.sous/` or `$SOUS_HOME` | `sous repo link` | Redirects a repository at a local working copy |
7
+ Every one carries `formatVersion: 1`; a future incompatible change raises that number, so an older sous
8
+ refuses a file it would otherwise misread.
15
9
 
16
- Every one of them carries `formatVersion: 1`. A future incompatible change bumps that number, so
17
- an older sous refuses a file it would otherwise misread.
18
-
19
- ?> Hand-written manifests are YAML or JSON, never JavaScript. Repository trust rests on being
20
- able to read a repository's whole surface without running any of its code, and a manifest that
21
- could execute would break that guarantee.
10
+ ?> Both hand-written manifests are YAML or JSON, never JavaScript: `.yaml`, `.yml`, `.json` or `.jsonc`
11
+ (both JSON forms allow comments and trailing commas). Two in one directory is an error, not a first-match
12
+ win. Unknown keys are rejected except extension keys starting with `x-`, carried through untouched; [the one
13
+ sous reads](repositories-authoring.md#declare-the-variables-a-recipe-needs) is `x-intentional`. Every path
14
+ in a manifest is checked: absolute paths, backslashes, `.` or `..` segments, empty segments and trailing
15
+ slashes are refused.
22
16
 
23
17
  ## Refs: how anything is named
24
18
 
25
- A **ref** names a namespace or a recipe. The same grammar works on the command line and in a
26
- project's subscriptions. A recipe manifest names its dependencies by LOCATION instead, which is
27
- a small variation on the same grammar; see [dependencies](#dependencies-named-by-location).
28
-
29
- ```
19
+ ```text
30
20
  ref := [ repo ":" ] namespace [ "/" recipe [ "@" range ] ]
31
21
  ```
32
22
 
33
- | Ref | Means |
34
- |-----|-------|
35
- | `workflow` | The whole `workflow` namespace, including recipes published later |
36
- | `workflow/task-files` | One recipe, any published version |
37
- | `workflow/task-files@^1.2.0` | One recipe, constrained to a semantic version range |
38
- | `sous-recipes:workflow/task-files@^1.2.0` | The same recipe, in a named repository |
23
+ So `workflow` is a whole namespace, `workflow/task-files@^1.2.0` one recipe constrained to a range (npm's
24
+ rules; namespaces are not versioned), and `sous-recipes:workflow/task-files` that recipe in a named
25
+ repository. Only a consuming project writes the `repo:` qualifier; it is that project's own label.
39
26
 
40
- The rules behind the grammar:
41
-
42
- - **No prefix.** A ref is written bare. `@` introduces a version range and nothing else; `~` is
43
- the template include sigil and never appears in a ref.
44
- - **The repo qualifier is optional, and is yours.** Refs resolve across the cached indexes of
45
- every added repository. You only need `repo:` when the same ref genuinely resolves in more than
46
- one, and in that case sous reports the conflict and asks for the qualified form rather than
47
- picking a winner. The short name is your project's own label for a repository, so it means
48
- nothing in a published manifest and is refused there.
49
- - **A version range applies to a recipe.** Namespaces are not versioned, so `workflow@^1.0.0` is
50
- an error.
51
- - **Ranges follow npm's rules.** `^1.2.0`, `~2.1`, `>=1.0.0 <2.0.0`, `1.x` and `*` all behave
52
- exactly as they do in npm, because sous resolves them with npm's own `semver` package.
53
- Prerelease versions sit out of range matching unless a subscription opts in.
54
-
55
- Namespace names, recipe names and repository short names are lowercase kebab-case: a letter,
56
- then letters, digits or hyphens.
27
+ | Thing | Shape |
28
+ |-------|-------|
29
+ | Repository short name, namespace, recipe name | lowercase kebab-case |
30
+ | Recipe key | always both segments, `namespace/recipe` |
31
+ | Repository identity | the host, then the path it lives at, lowercased |
32
+ | Variable name, environment variable name | camelCase, and upper snake case |
33
+ | Content hash | `sha256-` and 64 lowercase hex characters |
34
+ | Timestamp | ISO 8601 |
57
35
 
58
36
  ## `sous.repo.yaml`: the repository manifest
59
37
 
60
- The entry point sous reads to learn what a repository publishes. It lives at the repository root
61
- and may be written as `sous.repo.yaml`, `sous.repo.yml` or `sous.repo.json`. Exactly one of
62
- those, never two.
63
-
64
38
  ```yaml
65
- formatVersion: 1
66
-
67
- # A suggested short name. A project records the name it actually uses when the
68
- # repository is added, so two repositories suggesting the same name never collide.
69
- name: sous-recipes
70
-
71
- description: The official sous recipe repository.
72
-
73
- # Surfaced when a provider cannot support `sous repo submit`, so a contributor is
74
- # never left without a route. A URL or plain prose.
75
- contribute: https://github.com/sous-io/sous-recipes/blob/main/CONTRIBUTING.md
76
-
39
+ formatVersion: 1 # every format on this page carries it
40
+ name: qa-recipes # suggested short name; a project records its own
77
41
  namespaces:
78
- core:
79
- description: Skills that teach agents about sous itself.
80
42
  workflow:
81
- description: Task tracking and branch workflow.
82
-
83
- # Each path holds a sous.recipe.yaml. Paths are relative to the repository root.
84
- recipes:
85
- - recipes/core/sous-skills
86
- - recipes/workflow/task-files
43
+ description: Recipes about how work moves through a project.
44
+ quality:
45
+ description: Recipes that exercise the edges.
46
+ recipes: # every recipe folder, relative to the repository root
47
+ - recipes/workflow/qa-variables
48
+ - recipes/quality/qa-remote-dep
87
49
  ```
88
50
 
89
- | Field | Required | Type | Notes |
90
- |-------|----------|------|-------|
91
- | `formatVersion` | yes | `1` | The on-disk format version |
92
- | `name` | yes | kebab-case string | Suggested short name for the repository |
93
- | `description` | no | string | Shown by `sous repo list` and `sous repo search` |
94
- | `contribute` | no | string | URL or prose describing how to contribute |
95
- | `namespaces` | yes | map of name to `{ description? }` | Every namespace the repository publishes |
96
- | `recipes` | yes | list of relative paths | Every recipe folder, each holding a recipe manifest |
97
-
98
- Recipe paths stay inside the repository: an absolute path, a backslash, a `..` segment or a
99
- trailing slash is rejected.
51
+ | Field | Required | Notes |
52
+ |-------|----------|-------|
53
+ | `name` | yes | A suggestion only; `sous repo add --name` decides what a project calls it |
54
+ | `description`, `contribute` | no | A summary for anyone reading the repository, and where to send a contribution when a provider cannot support `sous repo submit` |
55
+ | `namespaces` | yes | Keyed by namespace name; each entry takes an optional `description` |
56
+ | `recipes` | yes | Relative paths, each holding a recipe manifest; a path listed twice is an error |
100
57
 
101
58
  ## `sous.recipe.yaml`: the recipe manifest
102
59
 
103
- One recipe, complete. It lives in the recipe's own folder, as `sous.recipe.yaml`,
104
- `sous.recipe.yml` or `sous.recipe.json`.
105
-
106
60
  ```yaml
107
61
  formatVersion: 1
108
- namespace: workflow
109
- name: task-files
110
- version: 1.2.0
111
- description: Per-branch task files, with skills for starting and resuming work.
112
-
113
- # Build dependencies. Fetched, pinned and trust-gated, and addressable from this
114
- # recipe's own files, but their files do NOT enter a subscriber's output.
115
- # A bare ref names a recipe in this same repository.
116
- depends:
117
- - workflow/sat
118
-
119
- # Co-subscriptions. Subscribing to this recipe subscribes the project to these too,
120
- # with full semantics: their questions run and their files DO enter the output.
121
- # A curated bundle is simply a recipe made mostly of these. A locator URL names a
122
- # recipe in another repository.
123
- subscribes:
124
- - github://sous-io/sous-recipes/communication/control-flow@^2.0.0
125
-
126
- # The files this recipe contributes. Patterns are relative to the recipe folder.
127
- contents:
62
+ namespace: quality
63
+ name: qa-remote-dep
64
+ version: 0.1.0
65
+ depends: # 'subscribes' takes the same two spellings
66
+ - workflow/qa-helper
67
+ - github://sous-io/sous-recipes/workflow/sub-agent-delegation@^1.0
68
+ contents: # what a subscriber actually receives
128
69
  - kind: skills
129
- include:
130
- - skills/**/*.md
131
- exclude:
132
- - skills/**/draft-*.md
133
- - kind: memories
134
- include:
135
- - memories/*.md
136
- - kind: config
137
- include:
138
- - config/510-task-files.json
139
-
140
- # Published specifications, not values. A question is asked only when a subscribed
141
- # recipe needs the variable and no valid answer is already in scope.
142
- variables:
143
- - name: taskFileRoot
144
- env: SOUS_VAR_TASK_FILE_ROOT
145
- type: path
146
- prompt: Where should task files live?
147
- description: >-
148
- One markdown file per git branch is written here. Most projects keep these
149
- as local working notes and gitignore the directory.
150
- example: .sous/tasks
151
- default: .sous/tasks
152
- required: true
153
- scope: shared
154
-
155
- - name: ticketSystem
156
- type: enum
157
- prompt: Which ticket system do you use?
158
- description: >-
159
- Decides which ticket identifiers the skills expect and which links they
160
- write into a task file.
161
- example: github
162
- default: github
163
- validate:
164
- enum: [github, jira, linear]
70
+ include: [skills/**/*.md] # 'exclude' is the optional counterpart
165
71
  ```
166
72
 
167
- ### Top-level fields
73
+ | Field | Required | Notes |
74
+ |-------|----------|-------|
75
+ | `namespace`, `name` | yes | The namespace must be declared by the repository manifest; the name is unique within it |
76
+ | `version` | yes | An exact semantic version; the manifest is the source of truth and the git tag follows it |
77
+ | `description` | no | Copied into the index at release time; shown by `sous recipe list` and `sous repo search` |
78
+ | `depends`, `subscribes` | no | Build dependencies and co-subscriptions; within either list, no entry may repeat |
79
+ | `contents`, `variables` | no | Contents default to an empty list, which is what a curated bundle publishes; a duplicated variable name, or two definitions claiming one `env`, is an error |
168
80
 
169
- | Field | Required | Type | Notes |
170
- |-------|----------|------|-------|
171
- | `formatVersion` | yes | `1` | The on-disk format version |
172
- | `namespace` | yes | kebab-case string | Must be declared by the repository manifest |
173
- | `name` | yes | kebab-case string | Unique within its namespace |
174
- | `version` | yes | exact semantic version | Never a range; `1.2.0`, or `2.0.0-beta.1` for a prerelease |
175
- | `description` | no | string | Shown by `sous repo search` and `sous repo list` |
176
- | `depends` | no | list of dependencies | Build dependencies, named by location |
177
- | `subscribes` | no | list of dependencies | Co-subscriptions, named by location |
178
- | `contents` | no | list of content groups | Defaults to an empty list, which is what a curated bundle wants |
179
- | `variables` | no | list of variable definitions | Published specifications |
180
-
181
- Recipe metadata is the source of truth for versions. A git tag shaped
182
- `namespace/recipe@1.2.3` is a convenience ref that `sous repo release` keeps consistent with the
183
- `version` field; a missing or wrong tag is reported rather than silently hiding a version.
81
+ `contents[].kind` is `skills`, `memories`, `prompts` or `config`. The first three are written to the
82
+ destinations named by [`recipeOutputs`](#recipeoutputs-where-the-files-land); `config` entries become config
83
+ layers instead. `include` needs at least one glob, and both lists are recipe-relative.
184
84
 
185
85
  ### Dependencies named by location
186
86
 
187
- `depends` and `subscribes` hold plain strings, and a manifest names its targets by WHERE THEY
188
- LIVE. There are two spellings.
189
-
190
- **A sibling**, in this same repository, is a bare ref:
191
-
192
- ```yaml
193
- depends:
194
- - workflow/sat # the sibling as released alongside me
195
- - workflow/sat@^1.1 # supported, and uncommon
196
- ```
87
+ `depends` and `subscribes` share one grammar in two spellings: `workflow/qa-helper` is a sibling in this
88
+ same repository, and `github://sous-io/sous-recipes/workflow/sub-agent-delegation@^1.0` is a recipe in
89
+ another one. A locator URL's scheme is the provider's identifier (`github` and `gitlab` ship today), and its
90
+ path is read from the RIGHT: the last two segments are the namespace and the recipe, and everything before
91
+ them names the repository, whose first segment is the host when it carries a dot and otherwise the
92
+ provider's public host, so `gitlab://gitlab.example.com/group/subgroup/project/workflow/task-files`
93
+ resolves. A trailing `.git` is dropped and at most one `@` range may follow. A manifest may write no `repo:`
94
+ qualifier, no `local://` locator (a local repository is a convenience, not a published location) and no
95
+ filesystem path such as `../qa-helper`.
197
96
 
198
- With no range, a sibling means "the version released alongside me": `sous repo release` cuts both
199
- tags in one run and records the exact version in the index, so the pairing is fixed forever.
97
+ ### Variable definitions
200
98
 
201
- **Another repository** is a locator URL whose scheme is the provider's identifier:
99
+ A definition is a specification, not a value; [Recipe variables](repositories-variables.md) covers how an
100
+ answer is found and stored, and a worked pair of definitions, including a secret, is in [Declare the
101
+ variables a recipe needs](repositories-authoring.md#declare-the-variables-a-recipe-needs).
202
102
 
203
103
  ```yaml
204
- subscribes:
205
- - github://sous-io/sous-recipes/workflow/sat@^1.1
206
- - gitlab://gitlab.example.com/group/subgroup/project/workflow/sat
104
+ variables:
105
+ - name: qaTaskRoot
106
+ type: path
107
+ prompt: Where should the review notes be stored?
108
+ description: One review note per branch is read from and written to this directory.
109
+ example: ~/qa-notes
110
+ default: .sous/qa-notes
207
111
  ```
208
112
 
209
- The parsing rule is worth stating exactly, because it never guesses:
210
-
211
- - The **last two path segments are always the namespace and the recipe**. That is the recipe's
212
- published identity, never a path on disk: a recipe stored at `recipes/shared/sat/` and
213
- published as `workflow/sat` is written `github://owner/repo/workflow/sat`. The repository's own
214
- index maps that identity to the directory.
215
- - **Everything before them is the repository.** A first segment carrying a dot is the host
216
- (`gitlab.example.com`); otherwise the provider's own public host is used (`github.com`,
217
- `gitlab.com`). Everything after the host is the repository's path, so GitLab subgroups work
218
- without any extra syntax.
219
- - The **range after `@` is optional**, and follows npm's rules like every other range.
220
-
221
- Two things a manifest may not write:
113
+ | Field | Required | Notes |
114
+ |-------|----------|-------|
115
+ | `name`, `type` | yes | camelCase, as templates refer to it; one of `string`, `number`, `boolean`, `enum`, `path` or `url` |
116
+ | `prompt`, `description` | yes | The one-line question, and the paragraph explaining the variable; both are shown when sous asks and by `sous vars show <name>` |
117
+ | `example` | yes | A realistic sample answer; documentation only, never stored and never a fallback. Use `default` for the value offered when nothing else is in scope |
118
+ | `env` | no | The environment variable an answer binds to; omitted, it is `SOUS_VAR_` plus the upper snake case form of the variable's own name, so `qaScratchDir` binds to `SOUS_VAR_QA_SCRATCH_DIR` |
119
+ | `required`, `secret` | no | Default to `true` and `false`; a secret is always written to the gitignored `.sous/.env.local` |
120
+ | `scope` | no | `shared` (the committed `.sous/.env`) or `local` (the gitignored `.sous/.env.local`); defaults to `shared` |
121
+ | `validate` | no | `pattern`, `minLength`, `maxLength`, `min`, `max`, `enum` |
222
122
 
223
- - **`local://` is refused.** A local repository is a consumer's convenience for working on
224
- recipes, not a published location; publish the recipe and depend on it where it lives.
225
- - **`repo:` short names are refused.** A short name is the label one project chose when it added
226
- a repository, and no other project has to agree with it.
227
-
228
- A consumer matches a locator against the repositories it has added by their canonical identity
229
- (`github.com/sous-io/sous-recipes`), so a project that added the same repository under a
230
- different short name resolves against the copy it already has. One it has not added goes through
231
- the ordinary trust round, which can show the URL the dependency named.
232
-
233
- ### Content groups
234
-
235
- | Field | Required | Type | Notes |
236
- |-------|----------|------|-------|
237
- | `kind` | yes | `skills`, `memories`, `prompts` or `config` | Decides where the files land in a subscribing project |
238
- | `include` | yes | list of glob patterns | At least one; relative to the recipe folder |
239
- | `exclude` | no | list of glob patterns | Removed from the include set |
240
-
241
- `config` entries name config layer files that are merged into the subscriber's config. Like
242
- recipe paths, include and exclude patterns may not escape the recipe folder.
243
-
244
- ### Variable definitions
123
+ At publish time: a `type: enum` variable must list its options under `validate.enum`; `minLength` cannot
124
+ exceed `maxLength`, nor `min` exceed `max`; `default` and `example` must match the type and, for an enum, be
125
+ one of its options; `secret: true` with `scope: shared` is refused; the committed env file would leak it.
245
126
 
246
- | Field | Required | Type | Notes |
247
- |-------|----------|------|-------|
248
- | `name` | yes | camelCase string | How templates refer to the variable |
249
- | `env` | no | upper snake case string | The environment variable an answer binds to. `sous repo release` derives a default when it is omitted; the runtime never derives one |
250
- | `type` | yes | `string`, `number`, `boolean`, `enum`, `path` or `url` | How the answer is validated and prompted for |
251
- | `prompt` | yes | string | The one-line question text |
252
- | `description` | yes | non-empty string | The paragraph explaining what the variable is for. Shown above the question when sous asks, and by `sous vars show <name>` |
253
- | `example` | yes | string, number or boolean | A realistic sample answer, shown with the question. Checked against the declared type and the enum options exactly as `default` is, but never stored and never offered as the answer |
254
- | `default` | no | string, number or boolean | Must match the declared type, and for an enum must be one of the options |
255
- | `required` | no | boolean, default `true` | Whether a build needs an answer |
256
- | `secret` | no | boolean, default `false` | A secret is always stored in the gitignored `.sous/.env.local` |
257
- | `scope` | no | `shared` or `local`, default `shared` | Which env file the answer is written to |
258
- | `validate` | no | object | Constraints, below |
127
+ ## `sous.index.json`
259
128
 
260
- A publisher has to explain every variable: `description` and `example` are both required, and a
261
- manifest missing either is refused. The two are not interchangeable. An `example` is documentation
262
- only, so it shows what a real answer looks like without sous ever storing it; a `default` is a real
263
- value, offered as the answer when nothing else is in scope. A variable whose sample answer is
264
- genuinely the right starting value carries the same text in both.
265
-
266
- `scope: shared` writes to `.sous/.env`, which is committed and shared with the team.
267
- `scope: local` writes to `.sous/.env.local`, which is gitignored and machine-specific. A secret
268
- declared as shared is rejected, because that combination would commit the secret.
269
-
270
- Constraints under `validate`:
271
-
272
- | Field | Type | Applies to |
273
- |-------|------|-----------|
274
- | `pattern` | string holding a regular expression | String-like answers |
275
- | `minLength`, `maxLength` | whole numbers | String-like answers |
276
- | `min`, `max` | numbers | Numeric answers |
277
- | `enum` | list of strings | Required when `type` is `enum` |
278
-
279
- A `pattern` runs under a time budget, on a worker rather than on the thread waiting for the
280
- answer; a pattern that exceeds the budget fails validation, and the failure names the pattern
281
- rather than blaming the answer.
282
-
283
- !> A schema may only LOOSEN within a major version. Tightening a constraint is a major bump,
284
- and an upgrade re-validates stored answers, re-prompting only where an old answer no longer
285
- fits.
286
-
287
- ### Unknown keys
288
-
289
- Both hand-written manifests reject an unknown key and report it as a likely typo, naming the
290
- file and the field's path. The one exception is the reserved `x-` namespace: a key such as
291
- `x-team` is accepted and ignored, so a repository can carry metadata sous knows nothing about.
292
-
293
- ### JSON manifests
294
-
295
- A `.json` manifest is read with a permissive parser: line comments, block comments and trailing
296
- commas are all allowed, so a manifest can explain itself.
129
+ Machine-written by `sous repo release` and committed beside the recipes it describes. Adding a repository
130
+ fetches only this file; nothing more is downloaded until a project subscribes.
297
131
 
298
132
  ```json
299
133
  {
300
- // the only format version sous understands
301
- "formatVersion": 1,
302
- "namespace": "workflow",
303
- "name": "task-files",
304
- "version": "1.2.0",
305
- "contents": [
306
- { "kind": "skills", "include": ["skills/**/*.md"] },
307
- ]
134
+ "formatVersion": 1, "name": "qa-recipes", "generator": "0.2.0",
135
+ "generatedAt": "2026-09-12T07:06:33.236Z",
136
+ "namespaces": { "quality": { "description": "Recipes that exercise the edges." } },
137
+ "recipes": { "quality/qa-remote-dep": { "path": "recipes/quality/qa-remote-dep",
138
+ "versions": { "0.1.0": {
139
+ "dependencies": { "workflow/qa-helper": { "version": "0.1.0" },
140
+ "workflow/sub-agent-delegation": { "range": "^1.0", "repo": "github.com/sous-io/sous-recipes" } },
141
+ "hash": "sha256-46e75442aeb368116c8830ad382707759f1ecd03974c4b5d42a5fabffce0324f",
142
+ "prerelease": false, "releasedAt": "2026-09-12T07:06:33.236Z",
143
+ "tag": "quality/qa-remote-dep@0.1.0"
144
+ } } } }
308
145
  }
309
146
  ```
310
147
 
311
- ## `sous.index.json`: the repository index
148
+ | Field | Where | Notes |
149
+ |-------|-------|-------|
150
+ | `generatedAt`, `generator`, `name`, `namespaces` | top level | When the index was generated and by which sous version, then the name and namespaces copied from the repository manifest. `$comment` is free text, since JSON has no comments, and is ignored on a published index |
151
+ | `recipes` | top level | Keyed `namespace/recipe`; a recipe whose namespace is not declared is an error. Each entry carries `path` (the recipe folder), `description`, and `versions`, keyed by exact version |
152
+ | `hash`, `tag` | per version | The content hash verified after every fetch, and the tag, which must be exactly `namespace/recipe@version` so a version can never point at a branch |
153
+ | `prerelease`, `releasedAt`, `seeded` | per version | Whether ranges skip it unless a subscription opts in, when it was released, and whether it is the copy sous folds in from its own package |
312
154
 
313
- Machine-written by `sous repo release` and committed alongside the recipes it describes. The
314
- index is the portable contract across providers: whatever a provider's API looks like, it can
315
- hand back this one file, and it is all sous needs to resolve a ref, enumerate published versions
316
- and check whether a cached copy is current.
155
+ Dependencies are resolved at release time and keyed `namespace/recipe`. An entry carries `version` (a
156
+ sibling, resolved exactly) or `range` plus `repo` (the identity of the repository publishing it, whose own
157
+ index resolves the range); at least one is required. Installing a version installs these rather than
158
+ re-resolving the manifest's ranges.
317
159
 
318
- Adding a repository fetches only this file. Nothing else is downloaded until a project
319
- subscribes to something inside it.
160
+ **The `seeded` field.** Sous ships the `core` recipe in its own npm package, so a project can build before
161
+ reaching the network. That copy is folded into the official repository's index in memory and resolved like
162
+ any other, marked `seeded: true`; if the repository already publishes it, the published copy wins. Sous
163
+ never writes `seeded` onto a fetched index, and when it writes the placeholder into the index cache it
164
+ stamps a fixed `$comment`, so a later run can tell its own placeholder from a real index and replace it.
320
165
 
321
- ```json
322
- {
323
- "formatVersion": 1,
324
- "name": "sous-recipes",
325
- "generatedAt": "2026-09-09T14:03:11.482Z",
326
- "generator": "0.2.0",
327
- "namespaces": {
328
- "core": { "description": "Skills that teach agents about sous itself." },
329
- "workflow": { "description": "Task tracking and branch workflow." }
330
- },
331
- "recipes": {
332
- "workflow/task-files": {
333
- "path": "recipes/workflow/task-files",
334
- "description": "Per-branch task files.",
335
- "versions": {
336
- "1.0.0": {
337
- "hash": "sha256-3b1f...c9",
338
- "tag": "workflow/task-files@1.0.0",
339
- "prerelease": false,
340
- "releasedAt": "2026-08-01T09:00:00.000Z",
341
- "dependencies": {
342
- "workflow/sat": { "version": "1.4.0" },
343
- "communication/control-flow": {
344
- "repo": "github.com/sous-io/sous-recipes",
345
- "range": "^2.0.0"
346
- }
347
- }
348
- },
349
- "1.1.0-beta.1": {
350
- "hash": "sha256-77ad...20",
351
- "tag": "workflow/task-files@1.1.0-beta.1",
352
- "prerelease": true
353
- }
354
- }
355
- }
356
- }
357
- }
358
- ```
359
-
360
- | Field | Required | Type | Notes |
361
- |-------|----------|------|-------|
362
- | `formatVersion` | yes | `1` | The on-disk format version |
363
- | `name` | yes | kebab-case string | The repository's suggested short name |
364
- | `generatedAt` | yes | ISO 8601 timestamp | When the index was generated |
365
- | `generator` | yes | exact semantic version | The version of sous that generated it |
366
- | `namespaces` | yes | map of name to `{ description? }` | Copied from the repository manifest |
367
- | `recipes` | yes | map of `namespace/recipe` to a recipe entry | Every recipe published |
368
- | `$comment` | no | string | A note about where this copy came from. JSON has no comment syntax, and an index is machine-written, so this is the one place a writer can say something to whoever opens the file. Sous ignores it, with one exception: the seed index below |
369
-
370
- A recipe entry holds `path`, an optional `description`, and `versions`: a map from an exact
371
- version to `{ hash, tag, prerelease, releasedAt?, dependencies?, seeded? }`. Every recipe needs
372
- at least one version, and its namespace must be one the index declares. `seeded` is never
373
- written by `sous repo release`; it marks the packaged core version sous folds in itself, and is
374
- described under the seed index below.
375
-
376
- ### Resolved dependencies
377
-
378
- `dependencies` records what one exact version was released against, keyed `namespace/recipe`. It
379
- is why a published version means one thing forever: a consumer installing `1.0.0` installs the
380
- versions `1.0.0` was published with, rather than re-resolving its ranges months later.
381
-
382
- | Field | When | Notes |
383
- |-------|------|-------|
384
- | `version` | a sibling | The exact version, settled when both tags were cut |
385
- | `repo` | another repository | That repository's canonical identity, `<host>/<owner path>/<name>` |
386
- | `range` | another repository | The range the manifest declared; its exact version lives in that repository's own index |
387
-
388
- The field is additive: an index written before it existed still parses, and a consumer that
389
- finds no entry falls back to the ranges the recipe's manifest declares. `formatVersion` stays
390
- `1`.
391
-
392
- A version's `tag` must be exactly `<namespace>/<recipe>@<version>` for the entry it sits under,
393
- and an index that says otherwise is refused when it is read. The tag is what the provider
394
- fetches, so an index publishing `1.0.0` with `tag: "main"` would point a pinned version at a
395
- branch: the content behind it changes on every push and the pinned hash then simply starts
396
- failing, with nothing on the consumer's side able to say why.
397
-
398
- Content hashes are written as `sha256-` followed by 64 lowercase hexadecimal characters. The
399
- hash of a version is checked after every fetch, and against the lockfile before a cached copy is
400
- used.
166
+ ## `.sous/sous.lock.json`
401
167
 
402
- ### The seed index
403
-
404
- One index is not published by any repository: sous writes a stand-in index for its own
405
- `sous-recipes` entry, into the index cache, when nothing real has ever been fetched from it. It
406
- lists exactly one recipe, `core/sous-skills`, at the version of the running sous, with the hash
407
- of the copy that was just seeded out of the installed package. That is what lets a project
408
- resolve the core namespace on a machine that has never had a network connection.
409
-
410
- The stand-in says so in its `$comment`, which is how a later run recognizes its own placeholder
411
- and is willing to replace it; an index a repository actually published is never overwritten. No
412
- freshness sidecar is written beside it, so the very first command that does have a network
413
- fetches the real index rather than waiting out a window the stand-in never earned. When there is
414
- still no network, sous reports that it could not check and uses the stand-in, which is the same
415
- last-good behavior every repository gets.
416
-
417
- ### The packaged core version
418
-
419
- The stand-in covers a machine that has never fetched anything. A machine that has been using
420
- sous for a while holds the repository's real index instead, and that index publishes whatever
421
- core versions the release pipeline has cut so far. Upgrade sous and the version the built-in
422
- `core` subscription asks for is, for a while, not among them.
423
-
424
- So sous folds the packaged version into that index IN MEMORY whenever the index does not carry
425
- it: one more entry under `core/sous-skills`, at the running sous version, with the hash of the
426
- copy just seeded out of the package, `prerelease` set from the version itself, and `seeded` set
427
- to `true`. Nothing is written to disk; the cached file stays exactly what the repository served,
428
- so the next real fetch is compared against the truth. The moment the repository does publish
429
- that version, its own entry is what gets used, and if the two disagree about the content hash
430
- sous says so once and prefers the published one.
431
-
432
- ## `sous.lock.json`: the project lockfile
433
-
434
- Machine-written into the project's `.sous/` directory, and committed. The lockfile records the
435
- exact version and content hash of everything the project currently uses, so a fresh clone
436
- restores deterministically with no prompts and no version drift. Together with repository trust
437
- it is the supply-chain defense: nothing new enters a project except through an explicit, visible
438
- change to these files.
168
+ Machine-written, committed, and the reason a fresh clone restores with no prompts and no drift.
439
169
 
440
170
  ```json
441
171
  {
442
172
  "formatVersion": 1,
443
- "repos": {
444
- "sous-recipes": {
445
- "url": "https://github.com/sous-io/sous-recipes",
446
- "identity": "github.com/sous-io/sous-recipes",
447
- "indexHash": "sha256-91cc...4e"
448
- }
449
- },
450
173
  "recipes": {
451
- "core/sous-skills": {
452
- "repo": "sous-recipes",
453
- "version": "0.2.0",
454
- "hash": "sha256-4d20...af",
455
- "requestedBy": ["workflow/task-files"],
456
- "kind": "depends"
457
- },
458
- "workflow/task-files": {
459
- "repo": "sous-recipes",
460
- "version": "1.2.0",
461
- "hash": "sha256-3b1f...c9",
462
- "requestedBy": ["project"],
463
- "kind": "subscribes"
464
- }
465
- }
466
- }
467
- ```
468
-
469
- | Field | Required | Type | Notes |
470
- |-------|----------|------|-------|
471
- | `formatVersion` | yes | `1` | The on-disk format version |
472
- | `repos` | yes | map of short name to `{ url, identity, indexHash? }` | Every repository the locked recipes came from |
473
- | `recipes` | yes | map of `namespace/recipe` to a locked entry | Everything currently in use |
474
-
475
- A locked entry holds `repo` (which must appear under `repos`), the exact `version` resolved, its
476
- `hash`, a `requestedBy` list and a `kind` of `subscribes` or `depends`.
477
-
478
- A repository appears twice over, and the two are for different readers. The KEY is your
479
- project's short name, which is what every message and every recipe entry names. The `identity`
480
- is the canonical location it was derived from, and it is what the machine-wide store and the
481
- index cache file that repository under, so two projects that call the same repository different
482
- things still share one cached copy.
483
-
484
- `requestedBy` is what makes removal safe. Every entry lists who holds it: the literal string
485
- `project` for something the project subscribed to directly, or a recipe key for something pulled
486
- in as a dependency. Unsubscribing removes one holder, and the entry itself goes only when the
487
- last holder does.
488
-
489
- Every key is written sorted, so a regenerated lockfile changes only when its content genuinely
490
- does.
491
-
492
- ## `.sous.entry.json`: the store entry marker
493
-
494
- The machine-wide store lives under the user-level sous directory, one folder per recipe version:
495
- `$SOUS_HOME/cache/<repository identity>/<namespace>/<recipe>/<version>/`. A marker beside each
496
- one makes the entry self-describing, so the store can be verified and swept without consulting
497
- any project.
498
-
499
- ```json
500
- {
501
- "formatVersion": 1,
502
- "repo": "github.com/sous-io/sous-recipes",
503
- "namespace": "workflow",
504
- "name": "task-files",
505
- "version": "1.2.0",
506
- "hash": "sha256-3b1f...c9",
507
- "fetchedAt": "2026-09-01T10:00:00.000Z",
508
- "lastAccessAt": "2026-09-09T14:03:11.482Z",
509
- "sizeBytes": 20480
174
+ "quality/qa-remote-dep": { "kind": "subscribes", "repo": "qa-recipes", "version": "0.1.0",
175
+ "requestedBy": ["project"], "hash": "sha256-46e75442aeb368116c8830ad38...ffce0324f" },
176
+ "workflow/qa-helper": { "kind": "depends", "repo": "qa-recipes", "version": "0.1.0",
177
+ "requestedBy": ["quality/qa-remote-dep"], "hash": "sha256-78660ab9889e707a38be...b2c69698d" }
178
+ },
179
+ "repos": { "qa-recipes": { "identity": "localhost/home/me/projects/qa", "url": "/home/me/Projects/qa" } }
510
180
  }
511
181
  ```
512
182
 
513
- Every field is required. `repo` is the repository's canonical identity, because the store is
514
- shared by every project on the machine and a short name is one project's private label. `hash`
515
- is checked against the lockfile before the entry is used; `sizeBytes` and `lastAccessAt` drive
516
- the size-capped, least-recently-used collection that `sous repo gc` performs.
183
+ | Field | Notes |
184
+ |-------|-------|
185
+ | `repos.<name>.url`, `.identity`, `.indexHash` | Where the repository lives, as recorded when it was added; the canonical identity the store and index cache file it under; and the hash of the index this lock was resolved against, when known |
186
+ | `recipes.<key>.repo`, `.version`, `.hash` | The project's short name for the repository, which must appear under `repos`; the version resolved; and the hash verified against the store after every fetch |
187
+ | `recipes.<key>.requestedBy`, `.kind` | Who holds the entry, the literal `project` or the key of a recipe that requires it, and whether that holder is a `subscribes` or a `depends` |
517
188
 
518
- The store is disposable by design: everything in it is re-fetchable from the pins in a project's
519
- lockfile. Builds read inputs from the store and render or copy outputs into the project; sous
520
- does not symlink store content into a project, and store content is never edited in place.
189
+ A repository appears under two names on purpose: the key is the project's own short name, which every
190
+ message uses, while `identity` is what the store is keyed by, so two projects with different labels share
191
+ one cached copy. `identity` is optional on read only, for older lockfiles, and is derived from the URL on
192
+ the next write. `requestedBy` makes removal safe; an entry goes when its last holder does.
521
193
 
522
194
  ## The store on disk
523
195
 
524
- The store is a plain directory tree under the user-level sous directory, which is `~/.sous`
525
- unless `SOUS_HOME` says otherwise. Unlike `SOUS_CONFIG` and `SOUS_DIR`, `SOUS_HOME` does not
526
- decide which project is active, so it may be set in an env file (`.sous/.env.local` or
527
- `.sous/.env`) as well as in the shell. The same directory holds globally linked checkouts
528
- (`repos/`) and the machine-wide links map.
196
+ The store is machine-wide, immutable per version, and disposable (everything is re-fetchable from a
197
+ lockfile's pins); every entry carries a `.sous.entry.json` marker. It sits under `$SOUS_HOME`, default
198
+ `~/.sous`.
529
199
 
530
200
  ```text
531
201
  $SOUS_HOME/
532
- cache/ the store root
533
- _indexes/<identity>.json one cached index per repository
534
- <identity>/<namespace>/<recipe>/<version>/
535
- .sous.entry.json the marker for this entry
536
- ... the recipe's files, exactly as fetched
537
- repos/<owner>/<repo>/ checkouts linked with --global
538
- sous.links.json the machine-wide links map
202
+ cache/<repository identity>/<namespace>/<name>/<version>/ .sous.entry.json, then the files
203
+ cache/_indexes/<repository identity>.json the cached index
204
+ cache/_indexes/<repository identity>.meta.json etag, fetchedAt, lastCheckedAt, ref
205
+ repos/ checkouts cloned by 'sous repo link --global'
206
+ sous.links.json the machine-wide links map
539
207
  ```
540
208
 
541
- `<identity>` is the repository's canonical location, `<host>/<owner path>/<name>`, so it is
542
- several directories deep on its own: `github.com/sous-io/sous-recipes/`, with the namespace, the
543
- recipe and the version following it. Keying by location rather than by
544
- a short name is what lets two projects that call a repository different things share one cached
545
- copy, and stops two projects that use the same short name for different repositories from
546
- colliding. Nothing migrates a store keyed another way: entries sous cannot find are simply
547
- fetched again, which costs a download and nothing else.
548
-
549
- An entry is written atomically: sous copies the fetched files into a temporary directory
550
- beside the entry's final home, hashes them, checks the hash against the pin it was given,
551
- writes the marker, and only then renames the directory into place. A published version is
552
- immutable, so re-storing one is allowed only when the content hashes the same; different
553
- content under a version already in the store is an error rather than a silent overwrite.
554
-
555
- The content hash is SHA-256 over a canonical serialization of the folder: files in bytewise
556
- order of their relative paths, each contributing its path, its byte length and its bytes.
557
- File modes, owners and timestamps are excluded, so the same recipe hashes the same after a
558
- copy, a clone or an archive round-trip. `.git` and the entry's own `.sous.entry.json` are
559
- skipped, which is what lets sous touch the marker without invalidating the entry. Every read
560
- re-verifies the hash; an entry that no longer matches is removed and re-fetched, and the
561
- build says so.
562
-
563
- Symbolic links are skipped entirely, by the hash and by the copy into the store alike, and so
564
- is anything under a linked directory. A link points at bytes the repository does not own, so
565
- following one would make the same published version hash differently on the publisher's
566
- machine and the consumer's, and every install would then fail against its own pin.
567
- `sous repo release` refuses to publish a recipe folder containing a link, naming it, so this
568
- is caught where it can be fixed rather than at install time. A recipe that needs a file ships
569
- the file.
570
-
571
- Collection is size-capped and least-recently-used. `store.maxBytes` sets the cap (one
572
- gigabyte by default) and `lastAccessAt` in each marker sets the order; entries a lockfile
573
- still pins are never evicted, even when honoring the cap would require it. Everything in
574
- the store is re-fetchable from those pins, so deleting the whole directory costs a download
575
- and nothing else.
576
-
577
- ## `sous.links.json`: the links map
578
-
579
- A link redirects a repository's resolution away from the store and at a real working copy, which
580
- is how a maintainer edits recipes: edits happen in a checkout, never in the store.
209
+ The marker makes an entry self-describing, so the store is sweepable without consulting a project:
581
210
 
582
211
  ```json
583
- {
584
- "formatVersion": 1,
585
- "links": {
586
- "sous-recipes": {
587
- "path": "/home/me/Projects/sous-recipes",
588
- "linkedAt": "2026-09-09T14:03:11.482Z",
589
- "origin": "clone"
590
- }
591
- }
592
- }
212
+ { "formatVersion": 1, "repo": "github.com/sous-io/sous-recipes", "namespace": "workflow",
213
+ "name": "sub-agent-delegation", "version": "1.0.0", "sizeBytes": 3032,
214
+ "hash": "sha256-6841b6f61a9a62d84152bc0fcfe246af35b7f75001527d6addf55e51068f8387",
215
+ "fetchedAt": "2026-09-12T07:06:57.123Z", "lastAccessAt": "2026-09-12T07:06:57.292Z" }
593
216
  ```
594
217
 
595
- | Field | Required | Type | Notes |
596
- |-------|----------|------|-------|
597
- | `formatVersion` | yes | `1` | The on-disk format version |
598
- | `links` | yes | map of repository short name to a link entry | Everything currently linked |
599
-
600
- A link entry holds an absolute `path`, a `linkedAt` timestamp, and an `origin` of `clone` (sous
601
- cloned the working copy itself) or `path` (sous was pointed at an existing checkout). Unlinking
602
- removes the entry and leaves the checkout on disk.
603
-
604
- Two maps are read: the project's `.sous/sous.links.json` and the machine-wide
605
- `$SOUS_HOME/sous.links.json`, with the project map winning on conflict. The file is never
606
- committed. A link bypasses versions, the lockfile and freshness checks, and those bypasses
607
- belong to one person's machine rather than to the team, so builds announce a linked repository
608
- loudly.
609
-
610
- ### Where a linked checkout lives
611
-
612
- `sous repo link <repo>` with no path clones the repository for you. The working copy lands in
613
- `.sous/repos/<owner>/<name>`, or in `$SOUS_HOME/repos/<owner>/<name>` with `--global`, where
614
- every project on the machine shares one checkout. A directory that is already a checkout of the
615
- same remote is reused rather than cloned again, so running the command twice is harmless; one
616
- holding a different remote is an error, because reading the wrong recipes silently would be
617
- worse than stopping.
618
-
619
- `sous repo link <repo> <path>` links a checkout that already exists and clones nothing. The path
620
- must hold a repo manifest at its root, since a directory without one is not a repository.
218
+ `hash` is checked against the lockfile before the entry is used, and `sizeBytes` and `lastAccessAt` drive
219
+ the size-capped, least-recently-used collection `sous repo gc` performs. The hash is SHA-256 over each
220
+ file's path and bytes in a canonical order, with `.git`, the marker and anything behind a symlink excluded,
221
+ so a version hashes the same on every machine.
621
222
 
622
- `<repo>` is normally the short name of a repository this project has already added. A URL is
623
- accepted, but it is not a way around adding one: a linked repository's recipes are read with no
624
- version, no lockfile and no hash check, so a URL the project has not added runs the same trust
625
- ceremony `sous repo add` runs before anything is cloned or linked. It asks inline, `--trust`
626
- acknowledges instead for a run with no terminal, and a URL whose short name already belongs to a
627
- different repository is refused outright.
223
+ ## `sous.links.json`
628
224
 
629
- `sous repo unlink <repo>` removes the map entry and nothing else. The checkout stays where it
630
- is, and its path is printed so you can delete it yourself if you want to.
225
+ A link redirects a repository away from the store and at a real working copy, which is how a maintainer
226
+ edits recipes. It is machine-local and never committed, because a link bypasses versions, the lockfile and
227
+ freshness checks; entries are keyed by the repository's configured short name.
631
228
 
632
- ### Ignore hygiene
633
-
634
- Everything sous keeps under `.sous/` for one machine is kept out of the project's repository,
635
- and linking maintains both files that do it:
636
-
637
- - `.sous/repos/.gitignore` holds a single `*`. That covers the ignore file itself, so a cloned
638
- checkout underneath it contributes nothing at all to the project's repository.
639
- - `.sous/.gitignore` carries a delimited managed block:
640
-
641
- ```
642
- # >>> sous managed (do not edit between these markers)
643
- sous.links.json
644
- sous.state.json
645
- sous.pid
646
- repos/
647
- # <<< sous managed
229
+ ```json
230
+ { "formatVersion": 1, "links": {
231
+ "qa-recipes": { "path": "/home/me/Projects/qa", "origin": "path", "linkedAt": "2026-09-12T07:07:42.800Z" } } }
648
232
  ```
649
233
 
650
- Only the lines between the markers are ever rewritten. Anything you put above or below them is
651
- left exactly as it was, and both files are written only when their contents would change, so
652
- linking repeatedly never produces a diff. An opening marker with no closing partner stops the
653
- command with an error rather than a guess about where the block ends.
654
-
655
- `sous prune` and `sous clear` only ever touch paths recorded in the state file, so nothing in
656
- `.sous/repos/` is at risk from either of them.
234
+ `path` is absolute; `origin` is `clone` when sous cloned the working copy itself and `path` when it was
235
+ pointed at an existing checkout, and unlinking removes the entry while leaving either in place. Both the
236
+ project's `.sous/sous.links.json` and `$SOUS_HOME/sous.links.json` are read, and the project's entries win.
657
237
 
658
- ## Project configuration
238
+ ## Configuration keys
659
239
 
660
- Four optional top-level keys in a project's sous config carry the consumer side. `sous repo add`
661
- and `sous subscription add` write the first two into machine-managed `conf.d/` layers, and you may also
662
- hand-write them in the primary config; the layers merge like anything else. The fourth,
663
- `recipeOutputs`, is always yours to write and is covered under
664
- [Consuming recipes](#consuming-recipes).
240
+ These live at the top level of the merged config; [The config file](configuration.md) covers the rest.
665
241
 
666
242
  ```yaml
667
- # Trusted repositories, keyed by the short name refs use. Adding a repository IS
668
- # trusting it, and removing the entry withdraws that trust.
669
243
  repos:
670
- team-recipes:
671
- url: https://github.com/example-org/team-recipes
672
- provider: github # inferred from the URL when omitted
673
- enabled: true # defaults to true; false takes it out of play entirely
674
- alwaysPull: false
675
- addedAt: 2026-09-09T14:03:11.482Z
676
- addedBy: user # "user", "sous", or the ref of the recipe that required it
677
-
678
- # What the project subscribes to, keyed by ref key.
244
+ sous-recipes:
245
+ url: https://github.com/sous-io/sous-recipes
246
+ provider: github # inferred from the URL when omitted; github, gitlab or local
247
+ enabled: true # false opts out of a repository sous provides itself
248
+ alwaysPull: false # install a newer in-range version rather than holding the lock
249
+ addedBy: sous # provenance; "user", "sous" for an entry sous provides itself, or the
250
+ # ref of the recipe that pulled it in; one you add records 'addedAt' too
679
251
  subscriptions:
680
252
  workflow/task-files:
681
- range: ^1.2.0
682
- enabled: true # defaults to true
683
- prerelease: false
684
- alwaysPull: false
685
-
686
- # Knobs for the machine-wide store. Every number here is configurable; the values
687
- # sous ships are defaults, not assumptions.
688
- store:
689
- maxBytes: 1073741824 # one gigabyte
690
- freshnessSeconds: 300 # five minutes
691
- watchPollSeconds: 300 # five minutes
253
+ range: "^1.2.0" # defaults to "*"; prerelease, enabled, alwaysPull, addedAt
254
+ prerelease: false # and addedBy work as they do above
255
+ store: # every value is a default you can change
256
+ maxBytes: 1073741824 # one gigabyte; past it, 'sous repo gc' evicts unpinned entries
257
+ freshnessSeconds: 300 # how long a fetched index stays fresh
258
+ watchPollSeconds: 300 # how often watch mode polls upstream
259
+ recipeOutputs: # 'memories' and 'prompts' take the same list-of-paths shape
260
+ skills: ["${projectRoot}/.claude/skills", "${projectRoot}/.codex/skills"]
261
+ varMappings:
262
+ QA_SERVICE_TOKEN: workflow/qa-variables/qaServiceToken
692
263
  ```
693
264
 
694
- A subscription key is a ref key: a namespace, or a namespace and a recipe. It never carries a
695
- repository qualifier or a version range, because the range belongs in the entry.
696
-
697
- ### The entries sous provides itself
698
-
699
- Two entries are there without you writing them. Sous lays them UNDER whatever your config
700
- layers produced, so `sous config show` prints them and `sous repo list` marks the repository
701
- "built in":
702
-
703
- - the repository `sous-recipes`, pointing at `https://github.com/sous-io/sous-recipes`, and
704
- - a `core` subscription, whose range is exactly the version of sous you are running.
705
-
706
- The `core` namespace holds the skills that teach an agent what sous is and how it works, and a
707
- copy of it ships inside the sous package, so a brand new project builds with those skills even
708
- with no network. Trust is not a question here: sous itself ships the recipe and pins the
709
- version to its own.
265
+ | Key | Keyed by | Notes |
266
+ |-----|----------|-------|
267
+ | `repos` | the short name refs use | Adding a repository IS trusting it, and removing the entry withdraws that trust |
268
+ | `subscriptions` | a ref key | A bare namespace (every recipe in it, including ones published later) or `namespace/recipe`. No repo qualifier and no range in the key |
269
+ | `enabled: false` | either of the two above | The opt-out for the entries sous provides itself, the `sous-recipes` repository and the `core` subscription |
270
+ | `store` | fixed fields | Plain numbers, all optional; the values sous ships are defaults, not assumptions |
271
+ | `recipeOutputs` | content kind | Destination directories per kind; see [recipeOutputs: where the files land](#recipeoutputs-where-the-files-land) |
272
+ | `varMappings` | environment variable name | Binds one name to one recipe variable, written `namespace/recipe/variableName` with an optional `repo:` qualifier |
710
273
 
711
- The range being the exact running version is deliberate. Core is published in lockstep with the
712
- CLI, so upgrading sous upgrades core with it, and a build re-pins core the first time it notices
713
- that the locked version no longer satisfies the range.
274
+ A repository's `url` may be a URL or an absolute path to one on this machine, read by the `local` provider
275
+ with identical trust. A hand-written entry is laid over sous's default field by field, so an entry holding
276
+ `{ enabled: false }` is a complete opt-out, and a written `url` repoints the repository while the rest of
277
+ the default stands.
714
278
 
715
- ### Switching either one off
279
+ ### recipeOutputs: where the files land
716
280
 
717
- `enabled` is an ordinary field on any `repos` or `subscriptions` entry. It defaults to true, and
718
- setting it to false takes that entry out of play entirely: nothing resolves through it, nothing
719
- is fetched for it, and nothing it publishes is compiled. The entry stays in your config, so the
720
- opt-out is legible to whoever reads it next.
721
-
722
- The two entries above are what it is mostly for:
723
-
724
- ```yaml
725
- # Keep the repository, but do not install the core skills.
726
- subscriptions:
727
- core:
728
- enabled: false
729
-
730
- # Or drop the repository entirely, which withdraws the core subscription with it.
731
- repos:
732
- sous-recipes:
733
- enabled: false
734
- ```
735
-
736
- Those two lines are complete entries on their own. Sous merges your fields over its own field by
737
- field, so `{ enabled: false }` inherits the URL and provider underneath it; writing a `url`
738
- repoints the repository and changes nothing else.
739
-
740
- `alwaysPull` installs a newer in-range version whenever one exists rather than holding the
741
- locked one. It never widens the range a subscription or a dependency declared, and the lockfile
742
- is still regenerated continuously so it records what the last build actually used. A freshness
743
- check that fails never breaks a build; the last good answer stands.
281
+ Where each content kind lands, `${var}` substituted as in any config path. Only `skills` has a default,
282
+ `<project root>/.claude/skills`; sous cannot guess where memories or prompts go, so a kind with no
283
+ destination is skipped and one warning names this key. A recipe's `config` contents become layers, not files.
744
284
 
745
285
  ## Managed config layers
746
286
 
747
- Sous writes three of those keys itself, into the `conf.d/` band reserved for machine-written
748
- layers:
749
-
750
- | File | Holds | Written by |
751
- |------|-------|------------|
752
- | `conf.d/500-repos.jsonc` | the `repos:` map | `sous repo add` |
753
- | `conf.d/510-subscriptions.jsonc` | the `subscriptions:` map | `sous subscription add`, `sous subscription remove` |
754
- | `conf.d/520-var-mappings.jsonc` | the `varMappings:` map | `sous vars ask` |
755
-
756
- All three are ordinary config layers: they load in filename order after your primary config and
757
- merge into it, so a repository you hand-write in your own config and one sous added are the same
758
- thing by the time anything reads them. Sous never edits your primary config, and never edits a
759
- layer outside the `500` through `599` band.
287
+ Three files in a project's `conf.d/` are written by sous rather than by a person, in the machine-written
288
+ `500` to `599` band described under [Layers and merging](config-layers.md#the-managed-5xx-layer-band). They
289
+ are `.jsonc` so they can carry real comments, and each opens with a header saying what it is:
760
290
 
761
- **Sous edits these files by key; you may edit them too.** An edit rewrites only the bytes of the
762
- entry that changes, through a JSON-with-comments editor, so your comments, your key order and your
763
- formatting all survive it. New entries are inserted in sorted order, so a change to one repository
764
- shows up as a change to one repository in your version control history.
765
-
766
- They are `.jsonc`, not `.json`, which is why each one can open with a header comment saying what it
767
- holds:
291
+ | Layer | Holds | Written by |
292
+ |-------|-------|-----------|
293
+ | `500-repos.jsonc` | `repos` | `sous repo add`, `sous repo remove` |
294
+ | `510-subscriptions.jsonc` | `subscriptions` | `sous subscription add`, `sous subscription remove` |
295
+ | `520-var-mappings.jsonc` | `varMappings` | `sous vars ask` |
768
296
 
769
297
  ```jsonc
770
298
  // This file is managed by sous. Sous edits these files by key; you may edit
771
299
  // them too, and your comments, key order and formatting are kept.
772
300
  //
773
- // It records the repositories this project trusts. The 'sous repo add'
774
- // command writes the entries under 'repos'.
301
+ // It records the repositories this project trusts. The 'sous repo add' and
302
+ // 'sous repo remove' commands write the entries under 'repos'.
775
303
  //
776
304
  // It is JSON with comments (.jsonc): line comments, block comments and trailing
777
305
  // commas are all allowed here.
778
- {
779
- "repos": {
780
- "sous-recipes": {
781
- "url": "https://github.com/sous-io/sous-recipes",
782
- "addedAt": "2026-09-09T14:03:11.482Z",
783
- // ours; the whole team reads from it
784
- "addedBy": "user"
785
- }
786
- }
787
- }
788
- ```
789
-
790
- Sous reads `.jsonc` anywhere it reads `.json`: a primary `sous.config.jsonc`, any `conf.d/` layer, a
791
- repo or recipe manifest, and a config layer a recipe contributes. A layer still named
792
- `500-repos.json` from an older sous is read as a fallback, and the next write moves it to `.jsonc`
793
- and removes the old file. Only one of the two names may exist at a time; two layers whose names
794
- differ only by extension are a hard error.
795
-
796
- Where a comment is impossible because the format really is strict JSON, the convention is a `//`
797
- key, which sous ignores wherever it appears. Nothing sous writes uses one today.
798
-
799
- The machine-written files that are NOT config layers stay strict JSON, because they carry no prose
800
- and other tooling parses them: the lockfile, the repo index, the links map and the marker beside a
801
- store entry.
802
-
803
- ## Consuming recipes
804
-
805
- Subscribing pins a recipe; building is what turns it into files in your project. A recipe's
806
- `contents` block says what it contributes and of what kind, and each kind lands somewhere your
807
- config decides.
808
-
809
- ### `recipeOutputs`: where the files land
810
-
811
- ```yaml
812
- # Where the files subscribed recipes contribute are written, per content kind.
813
- # Every path is ${var} substituted like any other config path, and a kind may
814
- # name several destinations so one recipe feeds more than one agent directory.
815
- recipeOutputs:
816
- skills:
817
- - ${projectRoot}/.claude/skills
818
- - ${projectRoot}/.codex/skills
819
- memories:
820
- - ${projectRoot}/.claude/memories
821
- prompts:
822
- - ${projectRoot}/prompts/recipes
823
- ```
824
-
825
- Only `skills` has a default: `<project root>/.claude/skills`, the project root being the parent
826
- of your `.sous/` directory. That is where every agent looks, so it is worth defaulting. Nothing
827
- else is: a kind with no destination is skipped, and sous says so once, naming this key. It will
828
- not guess where you want your memories or your prompts.
829
-
830
- Files are written the same way an `entryGlob` target of your own writes them. The static part of
831
- each include pattern is the base the output tree mirrors, so a recipe publishing
832
- `skills/task-files/SKILL.md` under `include: ["skills/**/*.md"]` writes
833
- `<destination>/task-files/SKILL.md`. The [`.tpl.` convention](configuration.md) applies
834
- unchanged: a `.tpl.md` file is rendered and loses the `.tpl.` from its name, and everything else
835
- is copied verbatim.
836
-
837
- ?> Only recipes you are subscribed to contribute files. A recipe pulled in through `depends` is
838
- fetched, pinned and addressable from the recipe that declared it, and its files never enter your
839
- output. That is the whole difference between the two dependency kinds.
840
-
841
- Recipe outputs are tracked like every other file sous writes, so `sous prune` removes what an
842
- unsubscribed recipe used to write, and `sous clear` removes all of it. Neither ever reaches into
843
- a linked checkout or the machine-wide store; both hold work that is not a project's to delete.
844
-
845
- ### `config` contents: recipes that configure
846
-
847
- A recipe's `config` contents are not written anywhere. They are config layers, and they load
848
- after your primary config and before your own `conf.d/` layers, so a recipe can supply defaults
849
- and your project always wins over them.
850
-
851
- A recipe's config layer is JSON or YAML only (`.json`, `.yaml` or `.yml`). Sous must be able to
852
- read everything a repository publishes without running any of it, so an executable layer from a
853
- recipe is refused with a warning rather than loaded, exactly as manifests are.
854
-
855
- A recipe's config layer may set only these top-level keys:
856
-
857
- | Key | What a recipe uses it for |
858
- |-----|---------------------------|
859
- | `_vars` | Default values for the variables its templates read |
860
- | `_aliases` | Include aliases pointing at the files it ships |
861
- | `compilation` | Targets that compile what it ships |
862
- | `runtimeContext` | Runtime context for the templates it ships |
863
- | `recipeOutputs` | Where the files it contributes are written |
864
- | `store` | Knobs for the machine-wide store |
865
- | `varMappings` | Bindings from an environment variable name to one of its variables |
866
-
867
- Every other key is removed before the layer is merged, and sous prints a warning naming the
868
- recipe and the key it removed. In particular a recipe may not set `repos`, `subscriptions`,
869
- `tools`, `_env`, `version`, `name` or `$schema`, and it may not set a key sous does not
870
- recognise. Subscribing to a recipe is not a decision to let it choose which repositories this
871
- project trusts, what else it subscribes to, or which programs `sous launch` runs; those stay
872
- yours. Sous reads the layer itself and applies this filter before the config kernel merges
873
- anything, so the kernel never opens a recipe's layer file at all.
874
-
875
- ### The `local` provider: repositories on this machine
876
-
877
- A repository does not have to be hosted. Name one by a path, relative or absolute, or by the
878
- same path in `file:///...` form, and sous reads it through the built-in `local` provider:
879
-
880
- ```bash
881
- sous repo add /home/me/Projects/my-recipes --name my-recipes
882
- sous repo add ../my-recipes --name my-recipes
306
+ { "repos": { "qa-recipes": { "url": "/home/me/Projects/qa", "addedBy": "user" } } }
883
307
  ```
884
308
 
885
- A relative path is resolved against the working directory and stored in its absolute form, since
886
- a repository on this machine is machine-specific anyway.
887
-
888
- It is meant for local development and for tests: authoring a repository, trying a recipe before
889
- publishing it, or running a whole workflow with no network at all. The index is read from the
890
- working tree when the file is there, so an index you are still writing is picked up without a
891
- commit, and from the committed copy otherwise. A recipe's files come from the version's tag in
892
- the local git repository; a directory that is not a git repository has no versions to honour, so
893
- its working tree is copied instead.
894
-
895
- !> Trust semantics are identical to a hosted repository. A local path is added, and therefore
896
- trusted, through the same ceremony, because the recipes in it still run on this machine. "It is
897
- already on my disk" is not a reason to skip the question.
898
-
899
- For editing a repository you are already subscribed to, reach for `sous repo link` instead: it
900
- redirects one repository's resolution at a working copy without changing what your project
901
- subscribes to.
902
-
903
- ## Variables and answers
904
-
905
- A variable **definition** is a published specification; an **answer** is the stored value. A
906
- question is asked only when a subscribed recipe needs a variable and nothing in scope answers
907
- it, or when the answer in scope no longer fits the definition.
908
-
909
- ### Where answers live
910
-
911
- Answers are stored in the project's own env files, and sous edits them the way a careful person
912
- would: exactly one value line is rewritten or appended, and comments, blank lines, ordering and
913
- quoting all survive. A newly added entry gets a short generated header comment above it saying
914
- where the value came from. Comments are output only; sous never reads one back.
915
-
916
- | File | Committed | Holds |
917
- |------|-----------|-------|
918
- | `.sous/.env` | yes | Shared answers (`scope: shared`), the team's defaults |
919
- | `.sous/.env.local` | no, gitignored | Machine-specific answers (`scope: local`) and every secret |
920
-
921
- ### The resolution ladder
922
-
923
- For each variable sous generates a list of environment variable names and tries them in order,
924
- most specific first. Within a rung, the real shell environment wins, then `.sous/.env.local`,
925
- then `.sous/.env`.
926
-
927
- | Rung | Name | Example |
928
- |------|------|---------|
929
- | 1. mapping record | whatever the record names | `TEAM_API_URL` |
930
- | 2. recipe scope | `SOUS_VAR_<NAMESPACE>_<RECIPE>_<VARIABLE>` | `SOUS_VAR_MISC_STUFF_API_URL` |
931
- | 3. namespace scope | `SOUS_VAR_<NAMESPACE>_<VARIABLE>` | `SOUS_VAR_MISC_API_URL` |
932
- | 4. shared scope | `SOUS_VAR_<VARIABLE>` | `SOUS_VAR_API_URL` |
933
- | 5. declared name | the definition's own `env` field | `GITHUB_TOKEN` |
934
-
935
- !> Candidate names are only ever GENERATED and looked up, never parsed back into scopes. The
936
- underscore is both the delimiter and a legal identifier character, so no parse of a name would
937
- be trustworthy. When names collide, a mapping record settles it.
938
-
939
- ### Mapping records
940
-
941
- A mapping record binds one environment variable, of any name, to one fully qualified variable.
942
- It is the top rung of the ladder and the universal conflict resolver: two recipes wanting the
943
- same name, or a name already meaning something else in your environment.
944
-
945
- ```jsonc
946
- // This file is managed by sous. Sous edits these files by key; you may edit
947
- // them too, and your comments, key order and formatting are kept.
948
- {
949
- "varMappings": {
950
- "TEAM_API_URL": "sous-recipes:misc/stuff/apiUrl"
951
- }
952
- }
953
- ```
954
-
955
- A target is written `namespace/recipe/variableName`, optionally qualified as
956
- `repo:namespace/recipe/variableName`. Records live under the top-level `varMappings` config key;
957
- sous writes the ones it creates into the machine-managed `conf.d/520-var-mappings.jsonc` layer,
958
- editing one record at a time so each name has exactly one, and you may hand-write `varMappings` in
959
- the primary config too.
960
-
961
- ### The commands
962
-
963
- | Command | What it does |
964
- |---------|--------------|
965
- | `sous vars list` | Lists every variable in play: its recipe, the environment variable that answered it, the value (hidden for a secret) and the source |
966
- | `sous vars show <name>` | Shows one variable in full, including every environment variable on the ladder and which rung answered |
967
- | `sous vars ask [name]` | Answers what is unanswered, or one named variable; `--all` asks everything again |
968
- | `sous vars ask --file <path>` | Asks the definitions in a standalone file holding the same `variables:` array a recipe manifest carries |
969
-
970
- `sous vars list`, `sous vars show` and `sous vars ask` all accept `--file`, and `sous vars ask`
971
- accepts `--dry-run`.
972
-
973
- Answers already in scope are listed visibly and never re-asked. Without a terminal, an
974
- unanswered required variable fails the run and names the exact environment variables that would
975
- satisfy it, most specific first, which is what a continuous integration log needs.
976
-
977
- ## Releasing and contributing
978
-
979
- Two commands work inside a recipe repository rather than inside a project, so neither one looks
980
- for a `.sous/` directory: `sous repo release` publishes, and `sous repo submit` proposes a change
981
- to a repository someone else maintains. Both refuse to do anything until the repository describes
982
- itself consistently.
983
-
984
- ### What is checked
985
-
986
- Every run of either command checks the same things, and reports all of them at once rather than
987
- stopping at the first:
988
-
989
- - Every folder listed under `recipes` in `sous.repo.yaml` exists and holds exactly one recipe
990
- manifest.
991
- - Every recipe belongs to a namespace the repository manifest declares, and no two recipes share
992
- a `namespace/name` key.
993
- - Every `depends` and `subscribes` ref parses.
994
- - No two variable definitions of DIFFERENT names claim the same environment variable. Two
995
- definitions of the SAME name may share one, because that is exactly what the shared rung of the
996
- resolution ladder is for.
997
- - A tag exists for the version its recipe manifest declares, or that version is pending; when the
998
- tag does exist, the manifest carried by that tag declares the same version, and the folder's
999
- content still matches what the tag published.
1000
-
1001
- Claiming a well-known name such as `PATH`, `HOME`, `GITHUB_TOKEN` or anything beginning `AWS_` or
1002
- `SOUS_` is a warning rather than an error. Binding an existing token is legitimate; it just
1003
- deserves saying out loud. Add `x-intentional: true` to the definition to say you meant it.
1004
-
1005
- ### Versions, tags and the index
1006
-
1007
- Recipe metadata is the source of truth for versions. A tag shaped `namespace/recipe@1.2.3` is a
1008
- convenience ref that records which commit a version was published from, and `sous.index.json` is
1009
- the catalog subscribers read. The three are kept in step by three rules:
1010
-
1011
- - **A published version never changes.** Its hash is carried forward exactly as published, and a
1012
- disagreement is an error telling you to bump the version rather than republish it.
1013
- - **A version is published when its tag exists.** The one moment an index records a version
1014
- without a tag is the release commit itself: the index is committed and the tag is cut on that
1015
- commit. Any older version missing its tag is an error.
1016
- - **The tags are the backstop.** A tagged version missing from the index is rebuilt from its tag,
1017
- so deleting `sous.index.json` and regenerating it restores the same catalog.
1018
-
1019
- !> A release commits the version bumps and the index it writes, and nothing else. It refuses to
1020
- run while anything else is uncommitted, because a tag names one commit and the index records
1021
- what each recipe folder holds right now.
1022
-
1023
- ### `sous repo release`
1024
-
1025
- One run plans, asks once, and then publishes. Within its scope it releases only recipes whose
1026
- files changed since the tag that last published them, bumping any whose version still equals
1027
- that tag.
1028
-
1029
- | Invocation | What it does |
1030
- |------------|--------------|
1031
- | `sous repo release` | Plan, ask once, then bump, regenerate the index, commit and tag. |
1032
- | `sous repo release --dry-run` | Print the plan and stop. |
1033
- | `sous repo release --yes` | Skip the question; everything else is the same. |
1034
- | `sous repo release --namespace <ns>` | Release only that namespace. Repeatable. |
1035
- | `sous repo release --recipe <ns/name>` | Release only that recipe. Repeatable. |
1036
- | `sous repo release --bump <level>` | `patch` (the default), `minor`, `major` or `prerelease`. |
1037
- | `sous repo release --no-bump` | Raise nothing; a changed recipe nobody raised is an error. |
1038
- | `sous repo release --include-unchanged` | Release everything in scope, changed or not. |
1039
- | `sous repo release --tag` | Cut the tags even on a branch other than the default one. |
1040
- | `sous repo release --push` | Push the commit, and the tags this run created, to `origin`. |
1041
- | `sous repo release --check` | Read only: validate, and fail when the committed index is out of date. This is what a pull request runs. |
1042
- | `sous repo release --ci` | The merge preset: never bump, accept the plan, never ask, fail on anything unbumped. |
1043
-
1044
- Tags are cut dependency-first, and each version's resolved dependencies are written into the
1045
- index. On a branch other than the default one a release bumps and commits but cuts no tags,
1046
- because tags are cut on the default branch by the merge; `--tag` overrides that.
1047
-
1048
- A version bump edits the manifest in place, so its comments, its field order and its layout
1049
- survive. Two small normalizations happen in a YAML manifest: a folded block of prose may be
1050
- re-wrapped, and the spacing before a trailing comment is collapsed to one space.
1051
-
1052
- The workflow `sous repo init` scaffolds runs `--check` on every pull request and `--ci --push`
1053
- on a merge.
1054
-
1055
- ### `sous repo submit`
1056
-
1057
- `submit` means "propose a change for maintainers to review". It never publishes and never writes
1058
- to a repository directly.
1059
-
1060
- Sous validates first, because a proposal that fails the maintainer's own checks wastes their
1061
- review, and then hands the mechanics to the provider's own command line tool, which already holds
1062
- your credentials:
1063
-
1064
- 1. **Preflight.** An `origin` remote exists, sous recognizes its provider, that provider's CLI
1065
- (`gh` or `glab`) is installed and signed in, and everything is committed.
1066
- 2. **Validation.** The repository validates, and the committed index is current.
1067
- 3. **Delegation.** On GitHub, sous asks whether you can push to the repository itself; if you
1068
- cannot, it forks it onto your own account and proposes from there. The branch is pushed and a
1069
- pull request (a merge request on GitLab) is opened. A change sitting on the default branch is
1070
- moved to a branch named `sous/submit-<date>-<time>` first.
1071
-
1072
- | Flag | What it does |
1073
- |------|--------------|
1074
- | `--title <text>` | The proposal's title. Defaults to your last commit's subject. |
1075
- | `--body <text>` | The proposal's body. Defaults to a summary sous writes, listing the recipes and the versions the change would publish. |
1076
- | `--draft` | Opens the proposal as a draft. |
1077
- | `--dry-run` | Runs the whole preflight and sends nothing. |
309
+ A write rewrites only the bytes of the entry that changes, staging the result and renaming it over the file,
310
+ so an interrupted write leaves the previous layer rather than a truncated one. A layer still carrying its
311
+ old `.json` name is read as a fallback and migrates on the next write: the `.jsonc` file is written and the
312
+ `.json` one removed, so a project never ends up with both, which would be a hard config error.
1078
313
 
1079
- Every step prints before it runs, and a failure says exactly which steps completed. A pushed
1080
- branch with no proposal behind it is a normal outcome of a network failure, and you are told
1081
- about it rather than left guessing.
314
+ ## Where to go next
1082
315
 
1083
- ?> If a repository's provider cannot open a proposal for you, sous prints the `contribute` pointer
1084
- from its `sous.repo.yaml` instead, so you are never left without a route.
316
+ - [Repositories](repositories.md): the model, trust, and the lockfile
317
+ - [Consuming recipes](repositories-consuming.md): adding, subscribing and building
318
+ - [Authoring a repository](repositories-authoring.md): writing and releasing recipes
319
+ - [Providers](repositories-providers.md): how a URL becomes a repository identity
320
+ - [Troubleshooting repositories](repositories-troubleshooting.md): what each error means