@sous-io/sous 0.2.6 → 0.2.8

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
@@ -36,7 +36,8 @@ npm install -D @sous-io/sous # the version this project's templates were wri
36
36
  A project install pins the sous version a project builds with, and it always does the
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
- output. When the two versions differ, one line on standard error says which copy ran. Set
39
+ output. When the two versions differ, one line on standard error names the version handed off
40
+ to; `--verbose` announces every hand-off and adds where both installs are. Set
40
41
  `SOUS_NO_DELEGATE=1` to run the copy you invoked instead.
41
42
 
42
43
  Or run it from a clone (useful when developing sous itself):
@@ -57,9 +58,11 @@ sous init
57
58
 
58
59
  `sous init` writes the project's `.sous/` directory and runs the first build. It creates a
59
60
  commented `sous.config.js` (pass `--format json` for a JSON config bound to the shipped
60
- 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`
61
62
  at the project root, the two env files described below, and a `.sous/.gitignore` covering the
62
- 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
63
66
  `core` skills every project gets, and pins them in `.sous/sous.lock.json`. A project that
64
67
  already holds a config is left untouched.
65
68
 
@@ -72,7 +75,7 @@ export const config = {
72
75
  compilation: {
73
76
  targets: [
74
77
  {
75
- entryPoint: "${sousDir}/prompts/AGENTS.md",
78
+ entryPoint: "${sousDir}/memories/AGENTS.md",
76
79
  outputs: [{ destinationFile: "${projectRoot}/AGENTS.md" }],
77
80
  },
78
81
  ],
@@ -164,8 +167,9 @@ A recipe's files are reachable without knowing where anything is installed. An `
164
167
  path may name a recipe by its namespace, so
165
168
  `@~workflow/task-files/_partials/resume-task.md` composes a block published by the recipe
166
169
  `workflow/task-files`, at the version your project has pinned, into your own instruction
167
- file. `@~project/...` names your project's root, and you can define your own aliases with
168
- 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.
169
173
 
170
174
  Sous records every file and directory it writes in a state file, `.sous/sous.state.json` by
171
175
  default, which is what lets `prune` and `clear` clean up precisely instead of guessing.
package/bin/run.js CHANGED
@@ -20,6 +20,19 @@ if (!handedOff) {
20
20
  const { register } = await import("tsx/esm/api");
21
21
  register();
22
22
 
23
+ // `sous --version` is answered here rather than by oclif, whose answer is
24
+ // its user agent string. Plain, it is the version alone; `--verbose` adds
25
+ // the package, where it is installed, the platform and the Node build.
26
+ const argv = process.argv.slice(2);
27
+ const { isVersionRequest, printVersion } = await import("../src/lib/version-report.ts");
28
+ if (isVersionRequest(argv)) {
29
+ printVersion(argv, ownRoot);
30
+ } else {
31
+ await runCommand();
32
+ }
33
+ }
34
+
35
+ async function runCommand() {
23
36
  const { execute, settings } = await import("@oclif/core");
24
37
 
25
38
  // tsx (above) already makes .ts imports work, so oclif's own auto-transpile
@@ -34,18 +34,29 @@ Sous installs three ways, and they work together:
34
34
  directory the way Node resolves a package, so a copy hoisted to a monorepo root is found from
35
35
  any package inside it.
36
36
 
37
- When the two versions differ, one sentence on standard error names the copy that ran and the
38
- one you invoked; standard output is untouched, so a piped command prints exactly what it always
39
- did. Set `SOUS_DEBUG` and the sentence prints on every hand-off. Set `SOUS_NO_DELEGATE` to
40
- anything but `0`, `false`, `no` or `off` to run the copy you invoked instead, for debugging a
41
- broken project install or for deliberately using the global one:
37
+ When the two versions differ, one line on standard error names the version handed off to;
38
+ standard output is untouched, so a piped command prints exactly what it always did. Add
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
+ `SOUS_NO_DELEGATE` to anything but `0`, `false`, `no` or `off` to run the copy you invoked
42
+ instead, for debugging a broken project install or for deliberately using the global one:
42
43
 
43
44
  ```term
44
45
  $ sous --version
45
- Running the project's own sous 0.2.4 from /work/app/node_modules/@sous-io/sous instead of the sous 0.3.0 you invoked; set SOUS_NO_DELEGATE=1 to run the one you invoked.
46
- @sous-io/sous/0.2.4 linux-x64 node-v22.21.0
46
+ Handing off to the project-level Sous install: v0.2.4
47
+ v0.2.4
48
+ $ sous --version --verbose
49
+ Handing off to the project-level Sous install: v0.2.4
50
+ Project install: /work/app/node_modules/@sous-io/sous
51
+ Invoked install: v0.3.0 at /usr/lib/node_modules/@sous-io/sous
52
+ Set SOUS_NO_DELEGATE=1 to run the invoked install instead.
53
+ v0.2.4
54
+ Package : @sous-io/sous
55
+ Install : /work/app/node_modules/@sous-io/sous
56
+ Platform: linux-x64
57
+ Node : v22.21.0
47
58
  $ SOUS_NO_DELEGATE=1 sous --version
48
- @sous-io/sous/0.3.0 linux-x64 node-v22.21.0
59
+ v0.3.0
49
60
  ```
50
61
 
51
62
  ## Where to look
@@ -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)
@@ -38,7 +38,8 @@ under [When sous cannot ask](repositories-consuming.md#when-sous-cannot-ask).
38
38
 
39
39
  Help has four spellings. `sous --help` prints the root screen; `sous repo add --help`, `sous repo add -h` and
40
40
  `sous help repo add` all print that one command's. `sous help` alone lists the topics and commands, and
41
- `sous --version` prints the version, platform and Node build.
41
+ `sous --version` prints the version alone, as `v1.2.3`; `sous --version --verbose` adds the package name, where
42
+ it is installed, the platform and the Node build under it.
42
43
 
43
44
  Every topic answers to both spellings of its name: `repo` and `repos`, `subscription` and `subscriptions`,
44
45
  `namespace` and `namespaces`, `recipe` and `recipes`, `lock` and `locks`, `vars` and `var`, `config` and
@@ -49,9 +50,11 @@ Every topic answers to both spellings of its name: `repo` and `repos`, `subscrip
49
50
  ### `sous init [DIRECTORY]`
50
51
  Sets a project up for sous: writes its `.sous/` directory, then runs the first build. It is the one command that
51
52
  runs before a config exists, and the starting point for a project that has never used sous. It writes a commented
52
- primary config, a starter prompt at `.sous/prompts/AGENTS.md` that the config compiles to `AGENTS.md` at the
53
- project root, `.sous/.env` and `.sous/.env.local.example`, and the sous-managed block in `.sous/.gitignore`. The
54
- 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`.
55
58
 
56
59
  A project whose `.sous/` already holds a primary config is refused, and nothing is written. Setting up a directory
57
60
  inside a project that is already set up is a question rather than an error, since a subproject may want its own
@@ -339,7 +342,7 @@ No expected failure prints a stack trace. A failure sous did not expect prints t
339
342
  asking you to set `SOUS_DEBUG=1` and run the command again. Set `SOUS_DEBUG` to anything but `0`, `false`, `no`
340
343
  or `off` and every reported failure prints its stack to standard error underneath the message:
341
344
  `SOUS_DEBUG=1 sous build`. The one other thing it changes is that every hand-off to a project's own install
342
- announces itself, not only one between different versions.
345
+ announces itself in full, not only one between different versions; `--verbose` does the same.
343
346
 
344
347
  `SOUS_NO_DELEGATE` is the other environment variable every command reads. Set it the same way and the copy of
345
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.6",
3
+ "version": "0.2.8",
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.6
14
+ version: 0.2.8
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
  *
@@ -6,6 +6,7 @@
6
6
  export const PACKAGE_NAME: "@sous-io/sous";
7
7
  export const NO_DELEGATE_ENV: "SOUS_NO_DELEGATE";
8
8
  export const DEBUG_ENV: "SOUS_DEBUG";
9
+ export const VERBOSE_FLAG: "--verbose";
9
10
 
10
11
  export type ProjectInstall =
11
12
  | { same: true; root: string }
@@ -13,19 +14,27 @@ export type ProjectInstall =
13
14
 
14
15
  export type HandoffPlan =
15
16
  | { kind: "run-self" }
16
- | { kind: "hand-off"; install: Extract<ProjectInstall, { same: false }>; notice: string | undefined };
17
+ | { kind: "hand-off"; install: Extract<ProjectInstall, { same: false }>; notice: string[] };
17
18
 
18
19
  export function isEnvFlagOn(value: string | undefined): boolean;
19
20
  export function binEntryOf(pkg: unknown): string | undefined;
20
21
  export function findProjectInstall(startDir: string, ownRoot: string): ProjectInstall | undefined;
22
+ export function formatHandoffNotice(input: {
23
+ install: Extract<ProjectInstall, { same: false }>;
24
+ ownVersion: string;
25
+ ownRoot: string;
26
+ verbose: boolean;
27
+ }): string[];
21
28
  export function planHandoff(input: {
22
29
  cwd: string;
23
30
  ownRoot: string;
24
31
  env: Record<string, string | undefined>;
32
+ argv?: readonly string[];
25
33
  }): HandoffPlan;
26
34
  export function handOffToProjectInstall(input: {
27
35
  ownRoot: string;
28
36
  cwd?: string;
29
37
  env?: Record<string, string | undefined>;
38
+ argv?: readonly string[];
30
39
  stderr?: { write(chunk: string): unknown };
31
40
  }): Promise<boolean>;
@@ -27,8 +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.
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.
32
35
  */
33
36
 
34
37
  import fs from "node:fs";
@@ -44,6 +47,9 @@ export const NO_DELEGATE_ENV = "SOUS_NO_DELEGATE";
44
47
  /** The environment variable that makes every hand-off announce itself. */
45
48
  export const DEBUG_ENV = "SOUS_DEBUG";
46
49
 
50
+ /** The flag that makes the notice say where both installs are. */
51
+ export const VERBOSE_FLAG = "--verbose";
52
+
47
53
  /**
48
54
  * Whether an on/off environment variable is on: set to anything but an empty
49
55
  * string, `0`, `false`, `no` or `off` (case-insensitive, whitespace trimmed).
@@ -129,29 +135,43 @@ function describeInstall(candidate, ownRoot) {
129
135
  return { same: false, root, version: typeof pkg.version === "string" ? pkg.version : "unknown", bin };
130
136
  }
131
137
 
138
+ /**
139
+ * The lines the notice is made of: one line naming the version handed off to,
140
+ * and, when verbose, where both installs are and how to keep the invoked one
141
+ * running. Plain text, because this prints before tsx exists; the block
142
+ * mirrors the shape `showVariables` gives a key and value list.
143
+ */
144
+ export function formatHandoffNotice({ install, ownVersion, ownRoot, verbose }) {
145
+ const lines = [`Handing off to the project-level Sous install: v${install.version}`];
146
+ if (verbose) {
147
+ lines.push(
148
+ ` Project install: ${install.root}`,
149
+ ` Invoked install: v${ownVersion} at ${ownRoot}`,
150
+ `Set ${NO_DELEGATE_ENV}=1 to run the invoked install instead.`
151
+ );
152
+ }
153
+ return lines;
154
+ }
155
+
132
156
  /**
133
157
  * Decides what the invoked install should do, without doing it.
134
158
  *
135
159
  * Returns `{ kind: "run-self" }` when the invoked copy runs the command, or
136
160
  * `{ kind: "hand-off", install, notice }` naming the project copy to import
137
- * and the sentence to print on stderr first (undefined when nothing is said).
161
+ * and the lines to print on stderr first (an empty list when nothing is said).
138
162
  */
139
- export function planHandoff({ cwd, ownRoot, env }) {
163
+ export function planHandoff({ cwd, ownRoot, env, argv = [] }) {
140
164
  if (isEnvFlagOn(env[NO_DELEGATE_ENV])) return { kind: "run-self" };
141
165
  const install = findProjectInstall(cwd, ownRoot);
142
166
  if (!install || install.same) return { kind: "run-self" };
143
167
 
144
168
  const ownPkg = readPackageJson(ownRoot);
145
169
  const ownVersion = typeof ownPkg?.version === "string" ? ownPkg.version : "unknown";
146
- const differ = ownVersion !== install.version;
147
- let notice;
148
- if (differ) {
149
- notice =
150
- `Running the project's own sous ${install.version} from ${install.root} instead of the ` +
151
- `sous ${ownVersion} you invoked; set ${NO_DELEGATE_ENV}=1 to run the one you invoked.`;
152
- } else if (isEnvFlagOn(env[DEBUG_ENV])) {
153
- notice = `Running the project's own sous ${install.version} from ${install.root}.`;
154
- }
170
+ const verbose = isEnvFlagOn(env[DEBUG_ENV]) || argv.includes(VERBOSE_FLAG);
171
+ const announce = ownVersion !== install.version || verbose;
172
+ const notice = announce
173
+ ? formatHandoffNotice({ install, ownVersion, ownRoot: realpathOr(ownRoot), verbose })
174
+ : [];
155
175
  return { kind: "hand-off", install, notice };
156
176
  }
157
177
 
@@ -165,11 +185,12 @@ export async function handOffToProjectInstall({
165
185
  ownRoot,
166
186
  cwd = process.cwd(),
167
187
  env = process.env,
188
+ argv = process.argv.slice(2),
168
189
  stderr = process.stderr,
169
190
  }) {
170
- const plan = planHandoff({ cwd, ownRoot, env });
191
+ const plan = planHandoff({ cwd, ownRoot, env, argv });
171
192
  if (plan.kind !== "hand-off") return false;
172
- if (plan.notice) stderr.write(`${plan.notice}\n`);
193
+ for (const line of plan.notice) stderr.write(`${line}\n`);
173
194
  await import(pathToFileURL(plan.install.bin).href);
174
195
  return true;
175
196
  }
@@ -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.
@@ -0,0 +1,90 @@
1
+ /**
2
+ * What `sous --version` prints.
3
+ *
4
+ * oclif answers `--version` with its user agent string (package, version,
5
+ * platform and Node build on one line). Sous answers it itself, in bin/run.js,
6
+ * before oclif is loaded: the version alone, or with `--verbose` the same facts
7
+ * as a key and value list, so a person asking "which sous is this?" gets one
8
+ * short line and a person asking "where is it?" gets the rest.
9
+ */
10
+
11
+ import fs from "node:fs";
12
+ import os from "node:os";
13
+ import { log, showVariables } from "../utils/formatting.js";
14
+
15
+ /** The one flag oclif treats as a version request, and so does sous. */
16
+ export const VERSION_FLAG = "--version";
17
+
18
+ /** The flag that adds where the install is, the platform and the Node build. */
19
+ export const VERBOSE_FLAG = "--verbose";
20
+
21
+ /** The facts a version report is made of. */
22
+ export type VersionFacts = {
23
+ /** The npm package name. */
24
+ name: string;
25
+ /** The package version. */
26
+ version: string;
27
+ /** The absolute path of the install answering. */
28
+ root: string;
29
+ /** The platform and architecture, the way oclif spells them. */
30
+ platform: string;
31
+ /** The Node build, with its leading `v`. */
32
+ node: string;
33
+ };
34
+
35
+ /** Whether the command line is a version request: `--version` as its first word. */
36
+ export function isVersionRequest(argv: readonly string[]): boolean {
37
+ return argv[0] === VERSION_FLAG;
38
+ }
39
+
40
+ /** Reads the facts about the install rooted at `packageRoot`. */
41
+ export function readVersionFacts(packageRoot: string): VersionFacts {
42
+ const pkg = JSON.parse(fs.readFileSync(`${packageRoot}/package.json`, "utf8")) as {
43
+ name?: unknown;
44
+ version?: unknown;
45
+ };
46
+ return {
47
+ name: typeof pkg.name === "string" ? pkg.name : "unknown",
48
+ version: typeof pkg.version === "string" ? pkg.version : "unknown",
49
+ root: packageRoot,
50
+ platform: `${os.platform()}-${os.arch()}`,
51
+ node: process.version,
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Prints the report: the version on its own line, and with `verbose` the
57
+ * facts under it as a key and value list.
58
+ *
59
+ * @param facts - What to report.
60
+ * @param verbose - Whether to print the facts under the version.
61
+ * @param write - Where each line goes; standard output by default.
62
+ */
63
+ export function printVersionReport(
64
+ facts: VersionFacts,
65
+ verbose: boolean,
66
+ write: (line: string) => void = log
67
+ ): void {
68
+ write(`v${facts.version}`);
69
+ if (!verbose) return;
70
+ showVariables(
71
+ {
72
+ Package: facts.name,
73
+ Install: facts.root,
74
+ Platform: facts.platform,
75
+ Node: facts.node,
76
+ },
77
+ { write }
78
+ );
79
+ }
80
+
81
+ /**
82
+ * Answers a version request from the command line: `sous --version`, or
83
+ * `sous --version --verbose`.
84
+ *
85
+ * @param argv - The command line after the program name.
86
+ * @param packageRoot - The root of the install answering.
87
+ */
88
+ export function printVersion(argv: readonly string[], packageRoot: string): void {
89
+ printVersionReport(readVersionFacts(packageRoot), argv.includes(VERBOSE_FLAG));
90
+ }