@sous-io/sous 0.2.7 → 0.2.9

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.
package/README.md CHANGED
@@ -37,8 +37,8 @@ A project install pins the sous version a project builds with, and it always doe
37
37
  building: a global `sous` run anywhere inside a project that holds `node_modules/@sous-io/sous`
38
38
  hands the command to that copy, so `sous`, `npx sous` and a package script all produce the same
39
39
  output. When the two versions differ, one line on standard error names the version handed off
40
- to (`--verbose` adds where both installs are). Set `SOUS_NO_DELEGATE=1` to run the copy you
41
- invoked instead.
40
+ to; `--verbose` announces every hand-off and adds where both installs are. Set
41
+ `SOUS_NO_DELEGATE=1` to run the copy you invoked instead.
42
42
 
43
43
  Or run it from a clone (useful when developing sous itself):
44
44
 
@@ -58,9 +58,11 @@ sous init
58
58
 
59
59
  `sous init` writes the project's `.sous/` directory and runs the first build. It creates a
60
60
  commented `sous.config.js` (pass `--format json` for a JSON config bound to the shipped
61
- schema), a starter prompt at `.sous/prompts/AGENTS.md` that the config compiles to `AGENTS.md`
61
+ schema), a starter prompt at `.sous/memories/AGENTS.md` that the config compiles to `AGENTS.md`
62
62
  at the project root, the two env files described below, and a `.sous/.gitignore` covering the
63
- files sous keeps local to one machine. The first build compiles the starter prompt and the
63
+ files sous keeps local to one machine. A project with a `package.json` also gains `@sous-io/sous`
64
+ as a devDependency at the running version, so the project pins the sous it builds with; run
65
+ your package manager's install afterwards. The first build compiles the starter prompt and the
64
66
  `core` skills every project gets, and pins them in `.sous/sous.lock.json`. A project that
65
67
  already holds a config is left untouched.
66
68
 
@@ -73,7 +75,7 @@ export const config = {
73
75
  compilation: {
74
76
  targets: [
75
77
  {
76
- entryPoint: "${sousDir}/prompts/AGENTS.md",
78
+ entryPoint: "${sousDir}/memories/AGENTS.md",
77
79
  outputs: [{ destinationFile: "${projectRoot}/AGENTS.md" }],
78
80
  },
79
81
  ],
@@ -165,8 +167,9 @@ A recipe's files are reachable without knowing where anything is installed. An `
165
167
  path may name a recipe by its namespace, so
166
168
  `@~workflow/task-files/_partials/resume-task.md` composes a block published by the recipe
167
169
  `workflow/task-files`, at the version your project has pinned, into your own instruction
168
- file. `@~project/...` names your project's root, and you can define your own aliases with
169
- an `_aliases` block.
170
+ file. `@~project/...` names your project's root, `@~/...` names your home directory, and you
171
+ can define your own aliases with an `_aliases` block. Either spelling of a file finds it:
172
+ `@shared.md` also finds `shared.tpl.md`, and the other way round.
170
173
 
171
174
  Sous records every file and directory it writes in a state file, `.sous/sous.state.json` by
172
175
  default, which is what lets `prune` and `clear` clean up precisely instead of guessing.
@@ -36,8 +36,8 @@ Sous installs three ways, and they work together:
36
36
 
37
37
  When the two versions differ, one line on standard error names the version handed off to;
38
38
  standard output is untouched, so a piped command prints exactly what it always did. Add
39
- `--verbose` to any command and the notice also says where both installs are and how to keep
40
- the invoked one running; set `SOUS_DEBUG` and the notice prints on every hand-off. Set
39
+ `--verbose` to any command, or set `SOUS_DEBUG`, and every hand-off is announced, with where
40
+ both installs are and how to keep the invoked one running. Set
41
41
  `SOUS_NO_DELEGATE` to anything but `0`, `false`, `no` or `off` to run the copy you invoked
42
42
  instead, for debugging a broken project install or for deliberately using the global one:
43
43
 
@@ -22,3 +22,4 @@
22
22
  - [0002: Recipe answers in the template scope](adrs/0002-recipe-answers-in-templates.md)
23
23
  - [0003: Project setup with sous init](adrs/0003-project-init.md)
24
24
  - [0004: A global sous defers to the project's install](adrs/0004-project-install-handoff.md)
25
+ - [0005: Follow-ups from the first project on the hand-off](adrs/0005-first-project-follow-ups.md)
@@ -50,9 +50,11 @@ Every topic answers to both spellings of its name: `repo` and `repos`, `subscrip
50
50
  ### `sous init [DIRECTORY]`
51
51
  Sets a project up for sous: writes its `.sous/` directory, then runs the first build. It is the one command that
52
52
  runs before a config exists, and the starting point for a project that has never used sous. It writes a commented
53
- primary config, a starter prompt at `.sous/prompts/AGENTS.md` that the config compiles to `AGENTS.md` at the
54
- project root, `.sous/.env` and `.sous/.env.local.example`, and the sous-managed block in `.sous/.gitignore`. The
55
- first build compiles the starter prompt and the `core` skills, and pins them in `.sous/sous.lock.json`.
53
+ primary config, a starter prompt at `.sous/memories/AGENTS.md` that the config compiles to `AGENTS.md` at the
54
+ project root, `.sous/.env` and `.sous/.env.local.example`, and the sous-managed block in `.sous/.gitignore`. A
55
+ project that has a `package.json` also gains `@sous-io/sous` in its `devDependencies`, at exactly the running
56
+ version, unless it already depends on it; nothing is installed, so run your package manager's install afterwards.
57
+ The first build compiles the starter prompt and the `core` skills, and pins them in `.sous/sous.lock.json`.
56
58
 
57
59
  A project whose `.sous/` already holds a primary config is refused, and nothing is written. Setting up a directory
58
60
  inside a project that is already set up is a question rather than an error, since a subproject may want its own
@@ -340,7 +342,7 @@ No expected failure prints a stack trace. A failure sous did not expect prints t
340
342
  asking you to set `SOUS_DEBUG=1` and run the command again. Set `SOUS_DEBUG` to anything but `0`, `false`, `no`
341
343
  or `off` and every reported failure prints its stack to standard error underneath the message:
342
344
  `SOUS_DEBUG=1 sous build`. The one other thing it changes is that every hand-off to a project's own install
343
- announces itself, not only one between different versions.
345
+ announces itself in full, not only one between different versions; `--verbose` does the same.
344
346
 
345
347
  `SOUS_NO_DELEGATE` is the other environment variable every command reads. Set it the same way and the copy of
346
348
  sous you invoked runs the command, even inside a project that installs its own `@sous-io/sous`; see
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sous-io/sous",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
4
4
  "description": "Compiles AI coding agent configuration (CLAUDE.md, skills, memories) from LiquidJS templates",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -93,8 +93,8 @@ Include another file at render time (path **relative to the template file's dire
93
93
 
94
94
  `render` resolves paths relative to the template file. For files outside that tree, use
95
95
  a recipe reference (`@~<namespace>/<recipe>/...`), a path **alias** (`@~project/...`, or a
96
- user-defined alias) or a `@`-prefixed `${var}` path; the same resolution as `@include`
97
- (see below) works in `render` too.
96
+ user-defined alias), a home-relative path (`@~/...`) or a `@`-prefixed `${var}` path; the
97
+ same resolution as `@include` (see below) works in `render` too.
98
98
 
99
99
  To prevent template sequences from being processed in a code example, wrap the block in
100
100
  `raw` / `endraw` tag blocks. These blocks cannot be nested: the first `endraw`
@@ -156,6 +156,13 @@ namespaces. There is exactly one:
156
156
 
157
157
  - `@~project/...` → the consuming project's root.
158
158
 
159
+ A bare `@~/...` is not an alias: the sigil followed only by a separator is the home
160
+ directory, so `@~/notes/context.md` includes a file from your home directory.
161
+
162
+ An include finds a file by either spelling. `@shared.md` finds `shared.tpl.md` when that is
163
+ what exists, and `@notes.tpl.md` finds `notes.md`; the spelling written is tried first. So a
164
+ file can be turned into a template, or back, without touching the lines that include it.
165
+
159
166
  Everything else sous once shipped inside its own package is published as a recipe now, so
160
167
  a recipe reference is what reaches it.
161
168
 
@@ -11,7 +11,7 @@ formatVersion: 1
11
11
 
12
12
  namespace: core
13
13
  name: sous-skills
14
- version: 0.2.7
14
+ version: 0.2.9
15
15
 
16
16
  description: >-
17
17
  The skills that teach an agent what sous is and how it works: which files sous
@@ -31,10 +31,12 @@ import { nonInteractiveError } from "../lib/interactive.js";
31
31
  import { subscriptionServiceFor } from "../lib/repos/subscription-service.js";
32
32
  import {
33
33
  PROJECT_CONFIG_FORMATS,
34
+ SOUS_PACKAGE_NAME,
34
35
  STARTER_OUTPUT_NAME,
35
36
  STARTER_PROMPT_RELATIVE_PATH,
36
37
  scaffoldProject,
37
38
  sousDirFor,
39
+ type PackageJsonOutcome,
38
40
  type ProjectConfigFormat,
39
41
  } from "../lib/project-scaffold/index.js";
40
42
  import { SOUS_VERSION } from "../lib/settings.js";
@@ -50,8 +52,28 @@ import {
50
52
  showCommandVars,
51
53
  showVariables,
52
54
  warning,
55
+ type VariableEntry,
53
56
  } from "../utils/formatting.js";
54
57
 
58
+ /**
59
+ * The "Dependency" row of the summary: where sous now sits in the project's
60
+ * package.json, or nothing when the project has no package.json.
61
+ */
62
+ function dependencyRow(outcome: PackageJsonOutcome): VariableEntry[] {
63
+ if (outcome.kind === "absent") return [];
64
+ const value = `${SOUS_PACKAGE_NAME} ${outcome.range}`;
65
+ if (outcome.kind === "present") {
66
+ return [{ label: "Dependency", value, detail: `already in ${outcome.section} in package.json` }];
67
+ }
68
+ return [
69
+ {
70
+ label: "Dependency",
71
+ value,
72
+ detail: "added to devDependencies in package.json; run your package manager's install to fetch it",
73
+ },
74
+ ];
75
+ }
76
+
55
77
  export default class Init extends BaseCommand {
56
78
  static description =
57
79
  "Set a project up for sous: write its .sous/ directory, then run the first build";
@@ -129,6 +151,11 @@ export default class Init extends BaseCommand {
129
151
  if (result.dryRun) dryRunNotice(`would write ${file}`);
130
152
  else log(` wrote ${file}`);
131
153
  }
154
+ if (result.packageJson.kind === "added") {
155
+ const what = `${SOUS_PACKAGE_NAME} ${result.packageJson.range} to devDependencies in package.json`;
156
+ if (result.dryRun) dryRunNotice(`would add ${what}`);
157
+ else log(` added ${what}`);
158
+ }
132
159
 
133
160
  if (result.dryRun) {
134
161
  blankLine();
@@ -147,6 +174,7 @@ export default class Init extends BaseCommand {
147
174
  { label: "Prompt source", value: path.join(sousDir, STARTER_PROMPT_RELATIVE_PATH) },
148
175
  { label: "Compiled to", value: path.join(projectRoot, STARTER_OUTPUT_NAME) },
149
176
  { label: "Skills", value: path.join(projectRoot, ".claude", "skills") },
177
+ ...dependencyRow(result.packageJson),
150
178
  { label: "Shared answers", value: path.join(sousDir, ".env"), detail: "committed" },
151
179
  {
152
180
  label: "Local answers",
@@ -1,4 +1,5 @@
1
1
  import path from "node:path";
2
+ import { expandHome } from "./config-discovery.js";
2
3
  import type { NamespaceResolution, NamespaceResolver } from "./repos/namespace-resolver.js";
3
4
 
4
5
  /**
@@ -10,8 +11,11 @@ import type { NamespaceResolution, NamespaceResolver } from "./repos/namespace-r
10
11
  * that exists on disk; if none exist, it errors listing every candidate tried.
11
12
  *
12
13
  * Resolution pipeline for a raw path P (with leading `@` already stripped):
13
- * 1. Substitute ${vars} in P. If the result is absolute, it is the sole
14
- * candidate (feature: `@${sousRootPath}/x.md`).
14
+ * 1. Substitute ${vars} in P, then expand a leading `~/` to the home
15
+ * directory (the `~` sigil on its own, with nothing but a separator after
16
+ * it, names no alias or namespace, so `@~/notes/x.md` is unambiguous). If
17
+ * the result is absolute, it is the sole candidate (feature:
18
+ * `@${sousRootPath}/x.md`).
15
19
  * 2. Split the first segment (up to the first `/` or `:`) as the alias key,
16
20
  * the remainder as `rest`. If the key is a registered alias, push
17
21
  * join(base, rest) for EACH base in the alias's ordered array.
@@ -19,10 +23,16 @@ import type { NamespaceResolution, NamespaceResolver } from "./repos/namespace-r
19
23
  * for the recipe namespace named by the key (minus the `~`) and push its
20
24
  * candidates. Aliases are consulted first, so `~project` keeps meaning
21
25
  * the built-in alias even if a namespace of that name exists.
22
- * 4. Always push the relative candidate: join(baseDir, P) — the FULL path
26
+ * 4. Always push the relative candidate: join(baseDir, P), the FULL path
23
27
  * including the alias segment. This lets an alias augment a real relative
24
28
  * directory of the same name (e.g. `@stuff/x` tries the alias bases, then
25
29
  * `./stuff/x`).
30
+ * 5. Follow every candidate with its `.tpl.` twin: `x.md` is followed by
31
+ * `x.tpl.md`, and `x.tpl.md` by `x.md`. The literal spelling is always
32
+ * tried first, and the twin comes right after it (not after every other
33
+ * candidate), so an alias base still beats the relative fallback. A
34
+ * writer therefore never has to know whether an included file has been
35
+ * turned into a template or back.
26
36
  *
27
37
  * A key WITHOUT the `~` sigil never reaches the namespace resolver: a bare
28
38
  * `@path` is always a relative path or a declared alias, so include lines never
@@ -48,6 +58,39 @@ export function substituteVars(str: string, scope: Record<string, string>): stri
48
58
  return str.replace(/\$\{([^}]+)\}/g, (match, name: string) => scope[name] ?? match);
49
59
  }
50
60
 
61
+ /** The marker that makes a file a template, always right before the final extension. */
62
+ const TEMPLATE_MARKER = ".tpl";
63
+
64
+ /**
65
+ * The `.tpl.` twin of a path: `x.md` gives `x.tpl.md`, `x.tpl.md` gives `x.md`,
66
+ * and a path with no extension has no twin.
67
+ *
68
+ * @param filePath - Any path, absolute or not.
69
+ * @returns The twin, or undefined when there is none.
70
+ */
71
+ export function templateTwin(filePath: string): string | undefined {
72
+ const dir = path.dirname(filePath);
73
+ const base = path.basename(filePath);
74
+ const ext = path.extname(base);
75
+ if (ext === "" || ext === base) return undefined;
76
+ const stem = base.slice(0, -ext.length);
77
+ const twin = stem.endsWith(TEMPLATE_MARKER)
78
+ ? `${stem.slice(0, -TEMPLATE_MARKER.length)}${ext}`
79
+ : `${stem}${TEMPLATE_MARKER}${ext}`;
80
+ return path.join(dir, twin);
81
+ }
82
+
83
+ /** Every candidate followed by its `.tpl.` twin, de-duplicated, order kept. */
84
+ function withTemplateTwins(candidates: readonly string[]): string[] {
85
+ const out: string[] = [];
86
+ for (const candidate of candidates) {
87
+ out.push(candidate);
88
+ const twin = templateTwin(candidate);
89
+ if (twin !== undefined) out.push(twin);
90
+ }
91
+ return [...new Set(out)];
92
+ }
93
+
51
94
  /**
52
95
  * Split an include path into its leading alias key and the remainder. The key
53
96
  * is the run of characters up to the first `/` or `:` separator.
@@ -115,11 +158,11 @@ export type IncludeResolution = {
115
158
  export function resolveInclude(rawPath: string, opts: IncludeResolveOptions): IncludeResolution {
116
159
  const aliases = opts.aliases ?? {};
117
160
  const scope = opts.scope ?? {};
118
- const substituted = substituteVars(rawPath, scope);
161
+ const substituted = expandHome(substituteVars(rawPath, scope));
119
162
 
120
- // 1. Substituted to an absolute path → that is the only candidate.
163
+ // 1. Substituted (or home-expanded) to an absolute path → that, and its twin.
121
164
  if (path.isAbsolute(substituted)) {
122
- return { candidates: [path.normalize(substituted)] };
165
+ return { candidates: withTemplateTwins([path.normalize(substituted)]) };
123
166
  }
124
167
 
125
168
  const candidates: string[] = [];
@@ -151,8 +194,8 @@ export function resolveInclude(rawPath: string, opts: IncludeResolveOptions): In
151
194
  // 4. Relative fallback: the FULL substituted path under the including dir.
152
195
  candidates.push(path.resolve(opts.baseDir, substituted));
153
196
 
154
- // De-dupe, preserving order.
155
- return { candidates: [...new Set(candidates)], namespaceIssue };
197
+ // 5. Each candidate's `.tpl.` twin right after it; de-duped, order kept.
198
+ return { candidates: withTemplateTwins(candidates), namespaceIssue };
156
199
  }
157
200
 
158
201
  /**
@@ -169,7 +212,7 @@ export function resolveIncludeCandidates(rawPath: string, opts: IncludeResolveOp
169
212
  /**
170
213
  * Expand a leading alias segment in a path or glob pattern to one candidate
171
214
  * per alias base, in the alias's base order. Used for config entry paths
172
- * (`entryGlob`/watch patterns), where — unlike @include resolution — there is
215
+ * (`entryGlob`/watch patterns), where, unlike @include resolution, there is
173
216
  * no including file to supply a relative fallback, so a non-alias path is
174
217
  * returned unchanged as the sole candidate.
175
218
  *
@@ -27,11 +27,11 @@
27
27
  * - Anything unreadable or ambiguous means "run the copy that was invoked".
28
28
  * A hand-off is a convenience; a refusal to run is not.
29
29
  * - `SOUS_NO_DELEGATE` (anything but 0/false/no/off) runs the invoked copy.
30
- * - The notice goes to stderr, so piped stdout stays clean, and only when
31
- * the two versions differ; `SOUS_DEBUG` prints it on every hand-off. It is
32
- * one line naming the version handed off to; `--verbose` anywhere on the
33
- * command line (or `SOUS_DEBUG`) adds where both installs are and how to
34
- * keep the invoked one running.
30
+ * - The notice goes to stderr, so piped stdout stays clean. It prints when
31
+ * the two versions differ, and on every hand-off under `SOUS_DEBUG` or
32
+ * with `--verbose` anywhere on the command line. Plain, it is one line
33
+ * naming the version handed off to; verbose (`--verbose` or `SOUS_DEBUG`)
34
+ * it adds where both installs are and how to keep the invoked one running.
35
35
  */
36
36
 
37
37
  import fs from "node:fs";
@@ -167,15 +167,10 @@ export function planHandoff({ cwd, ownRoot, env, argv = [] }) {
167
167
 
168
168
  const ownPkg = readPackageJson(ownRoot);
169
169
  const ownVersion = typeof ownPkg?.version === "string" ? ownPkg.version : "unknown";
170
- const debug = isEnvFlagOn(env[DEBUG_ENV]);
171
- const announce = ownVersion !== install.version || debug;
170
+ const verbose = isEnvFlagOn(env[DEBUG_ENV]) || argv.includes(VERBOSE_FLAG);
171
+ const announce = ownVersion !== install.version || verbose;
172
172
  const notice = announce
173
- ? formatHandoffNotice({
174
- install,
175
- ownVersion,
176
- ownRoot: realpathOr(ownRoot),
177
- verbose: debug || argv.includes(VERBOSE_FLAG),
178
- })
173
+ ? formatHandoffNotice({ install, ownVersion, ownRoot: realpathOr(ownRoot), verbose })
179
174
  : [];
180
175
  return { kind: "hand-off", install, notice };
181
176
  }
@@ -8,10 +8,16 @@
8
8
  *
9
9
  * It refuses to touch a project that is already set up: a `.sous/` directory
10
10
  * holding a primary config is left exactly as it is, and so is any file the
11
- * scaffold would otherwise write. The one file it merges rather than replaces
12
- * is `.sous/.gitignore`, whose sous-managed block is applied by the same writer
11
+ * scaffold would otherwise write. Two files are merged rather than replaced:
12
+ * `.sous/.gitignore`, whose sous-managed block is applied by the same writer
13
13
  * `sous repo link` uses, so running the scaffold over an existing ignore file
14
- * never duplicates an entry.
14
+ * never duplicates an entry; and the project's `package.json`, when there is
15
+ * one, which gains `@sous-io/sous` as a devDependency at the running version
16
+ * unless it already depends on it. A project that installs sous pins the
17
+ * version its templates were written against, and a global sous hands off to
18
+ * that copy, so the dependency is what makes every build of the project use
19
+ * one version. Nothing is installed: the project's own package manager does
20
+ * that, and sous does not guess which one it is.
15
21
  */
16
22
 
17
23
  import fs from "node:fs";
@@ -41,6 +47,15 @@ import {
41
47
 
42
48
  export * from "./templates.js";
43
49
 
50
+ /** The npm package a scaffolded project is made to depend on. */
51
+ export const SOUS_PACKAGE_NAME = "@sous-io/sous";
52
+
53
+ /** What the scaffold did about the project's package.json. */
54
+ export type PackageJsonOutcome =
55
+ | { kind: "absent" }
56
+ | { kind: "present"; path: string; range: string; section: "dependencies" | "devDependencies" }
57
+ | { kind: "added"; path: string; range: string };
58
+
44
59
  /** What to scaffold, and where. */
45
60
  export type ProjectScaffoldOptions = {
46
61
  /**
@@ -72,6 +87,8 @@ export type ProjectScaffoldResult = {
72
87
  name: string;
73
88
  /** Paths of every file written, relative to the project root, in the order written. */
74
89
  files: string[];
90
+ /** Whether `@sous-io/sous` was added to the project's package.json, was already there, or there is no package.json. */
91
+ packageJson: PackageJsonOutcome;
75
92
  /** True when nothing was actually written. */
76
93
  dryRun: boolean;
77
94
  };
@@ -124,6 +141,8 @@ export async function scaffoldProject(
124
141
 
125
142
  const context: ProjectScaffoldContext = { name, sousVersion: options.sousVersion };
126
143
  const files = planFiles(sousDir, projectRoot, format, context);
144
+ const dependency = planPackageJson(projectRoot, options.sousVersion);
145
+ if (dependency.file !== undefined) files.push(dependency.file);
127
146
 
128
147
  assertNothingWouldBeOverwritten(projectRoot, files);
129
148
 
@@ -145,10 +164,72 @@ export async function scaffoldProject(
145
164
  format,
146
165
  name,
147
166
  files: files.map((file) => file.relativePath),
167
+ packageJson: dependency.outcome,
148
168
  dryRun,
149
169
  };
150
170
  }
151
171
 
172
+ /**
173
+ * Works out the package.json edit: nothing when the project has no
174
+ * package.json or already depends on sous, otherwise the file rewritten with
175
+ * `@sous-io/sous` in `devDependencies` at exactly the running version. The
176
+ * file's own indentation and trailing newline are kept, and `devDependencies`
177
+ * stays sorted the way npm keeps it. A package.json that is not JSON is
178
+ * refused by name, before anything is written.
179
+ */
180
+ function planPackageJson(
181
+ projectRoot: string,
182
+ sousVersion: string
183
+ ): { file?: PlannedFile; outcome: PackageJsonOutcome } {
184
+ const packageJsonPath = path.join(projectRoot, "package.json");
185
+ if (!fs.existsSync(packageJsonPath)) return { outcome: { kind: "absent" } };
186
+
187
+ const raw = fs.readFileSync(packageJsonPath, "utf8");
188
+ let parsed: unknown;
189
+ try {
190
+ parsed = JSON.parse(raw);
191
+ } catch (error) {
192
+ throw new ConfigError(
193
+ `'sous init' would add ${SOUS_PACKAGE_NAME} to ${packageJsonPath}, but could not read it as JSON.\n` +
194
+ ` ${(error as Error).message}\n` +
195
+ ` Fix the file, or run 'sous init' in a directory without a package.json.`
196
+ );
197
+ }
198
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
199
+ throw new ConfigError(
200
+ `'sous init' would add ${SOUS_PACKAGE_NAME} to ${packageJsonPath}, but it does not hold a JSON object.`
201
+ );
202
+ }
203
+ const pkg = parsed as Record<string, unknown>;
204
+
205
+ for (const section of ["dependencies", "devDependencies"] as const) {
206
+ const deps = pkg[section];
207
+ if (typeof deps === "object" && deps !== null && SOUS_PACKAGE_NAME in deps) {
208
+ const range = String((deps as Record<string, unknown>)[SOUS_PACKAGE_NAME]);
209
+ return { outcome: { kind: "present", path: packageJsonPath, range, section } };
210
+ }
211
+ }
212
+
213
+ const existing =
214
+ typeof pkg.devDependencies === "object" && pkg.devDependencies !== null
215
+ ? (pkg.devDependencies as Record<string, unknown>)
216
+ : {};
217
+ const devDependencies = Object.fromEntries(
218
+ Object.entries({ ...existing, [SOUS_PACKAGE_NAME]: sousVersion }).sort(([a], [b]) =>
219
+ a < b ? -1 : a > b ? 1 : 0
220
+ )
221
+ );
222
+
223
+ const indent = raw.match(/^(\s+)"/m)?.[1] ?? " ";
224
+ const newline = raw.endsWith("\n") ? "\n" : "";
225
+ const contents = JSON.stringify({ ...pkg, devDependencies }, null, indent) + newline;
226
+
227
+ return {
228
+ file: { relativePath: "package.json", contents, merged: true },
229
+ outcome: { kind: "added", path: packageJsonPath, range: sousVersion },
230
+ };
231
+ }
232
+
152
233
  /** Builds every file the scaffold writes, in the order they are written. */
153
234
  function planFiles(
154
235
  sousDir: string,
@@ -28,10 +28,12 @@ export type ProjectConfigFormat = (typeof PROJECT_CONFIG_FORMATS)[number];
28
28
 
29
29
  /**
30
30
  * The path, relative to `.sous/`, of the starter prompt the scaffolded config
31
- * compiles. It lives under `prompts/` so the source and its compiled output
32
- * (`AGENTS.md` at the project root) are never mistaken for one another.
31
+ * compiles. It lives under `memories/`, the directory for what a build composes
32
+ * into an agent's always-loaded instruction file, so the source and its
33
+ * compiled output (`AGENTS.md` at the project root) are never mistaken for one
34
+ * another.
33
35
  */
34
- export const STARTER_PROMPT_RELATIVE_PATH = "prompts/AGENTS.md";
36
+ export const STARTER_PROMPT_RELATIVE_PATH = "memories/AGENTS.md";
35
37
 
36
38
  /** The name of the file the starter prompt is compiled into, at the project root. */
37
39
  export const STARTER_OUTPUT_NAME = "AGENTS.md";
@@ -152,7 +154,7 @@ source, run \`sous build\`, and the compiled copy follows; never edit the compil
152
154
  ## Working in this project
153
155
 
154
156
  - Describe the project here: what it is, how it is built, and how it is tested.
155
- - Split long sections into their own files under \`${SOUS_DIR_NAME}/prompts/\` and pull
157
+ - Split long sections into their own files under \`${SOUS_DIR_NAME}/memories/\` and pull
156
158
  each one in with a line holding only \`@sections/name.md\`.
157
159
  - The skills under \`.claude/skills\` are written by sous from the recipes this
158
160
  project subscribes to. \`sous recipe list\` shows what is available.