create-agent-rig 0.3.2 → 0.4.0

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/CHANGELOG.md CHANGED
@@ -7,6 +7,69 @@ the generator.
7
7
  Versions are published to npm as [`create-agent-rig`](https://www.npmjs.com/package/create-agent-rig);
8
8
  `npx github:serhii-baksheiev/create-agent-rig` keeps working for either path.
9
9
 
10
+ Numbering is ordinary semver — **additive is a minor, a fix is a patch** — so
11
+ that "I only take minors" remains a usable policy; 0.3.2 shipped additive
12
+ content as a patch by the owner's call and stays recorded as one.
13
+
14
+ ## 0.4.0
15
+
16
+ Upgrading is a command now: **`npx create-agent-rig@0.4.0 upgrade`** (`@latest`
17
+ once you know what latest is — this section will not). That sentence replaces
18
+ the six-file manual procedure 0.3.2 had to print, and it is the whole point of
19
+ this release: a rig you cannot bring forward stops being maintained at whatever
20
+ version you installed it at.
21
+
22
+ Read the [Upgrading](README.md#upgrading-a-rig-you-already-have) section before
23
+ the first run on an existing rig; `--dry-run` prints the plan and writes
24
+ nothing.
25
+
26
+ ### Added
27
+
28
+ - **`create-agent-rig upgrade [--dry-run] [--yes]`** — brings an installed rig
29
+ to this version: it replaces the files the rig wrote **and you have not
30
+ touched**, installs what the release adds, and **reports everything else**.
31
+ There is no three-way merge and no patching, by decision rather than
32
+ omission: silently folding your edits into the documents an agent loop obeys
33
+ is how a rig stops meaning what its owner thinks it means. Every conflict
34
+ names the file, why it was kept, and the path to the new version, so the diff
35
+ you may want is one command away.
36
+ - **`.claude/.rig-manifest.json`** — written by `create` and by `init`: the rig
37
+ version and a hash per installed file. **Commit it.** It is what lets an
38
+ upgrade tell a file the rig wrote from a file you own, and without it in the
39
+ repository the command is blind on CI and on a colleague's machine. `init`
40
+ records only files it actually wrote — never one it kept, which would be
41
+ claiming somebody else's document.
42
+ - **A released-hash table travels in the package**, generated from the git tags
43
+ at release time and never by hand. It is what makes a 0.3.x rig upgradable at
44
+ all, and it answers a second question too: a file that shipped in every
45
+ release it covers and is gone from disk was **deleted on purpose**, so it
46
+ stays deleted. The rules tell you to delete the invariants your project does
47
+ not have; an upgrade that quietly restored them would be undoing your work.
48
+ - **The `loop` skill writes back what a close unblocked**, in a required
49
+ journal field with three distinct answers — the items that were waiting, by
50
+ name; "nothing was waiting"; or "this queue has no dependency links" where
51
+ the adapter cannot answer at all. It is a **report, not an edit** to those
52
+ items: correcting queue state by hand destroys the evidence that the state is
53
+ unreliable, which the rules forbid by name.
54
+
55
+ ### Two things `upgrade` deliberately will not do
56
+
57
+ - **Replace `.claude/settings.json`.** It is where your own hooks live, so the
58
+ new wiring is printed for you to merge — in the dry run too. The cost is
59
+ real and stated: a release that adds a hook delivers the file and not its
60
+ wiring, and whether a manifest-proven-unmodified settings file should be
61
+ refreshed is an open decision for 0.5.
62
+ - **Touch the skeleton.** After `create`, the code is your project. The manifest
63
+ covers the agent-os layer and nothing else.
64
+
65
+ ### Deferred, and on what condition
66
+
67
+ - The clarify-gate (`C-0…C-2`) — unchanged from 0.3.2: it enters once that gate
68
+ has fired at least once anywhere. Until then there is nothing to copy but an
69
+ intention.
70
+ - `init --force` now overlaps `upgrade`, and its future is an open question
71
+ rather than a deprecation: decided in 0.5, unchanged here.
72
+
10
73
  ## 0.3.2
11
74
 
12
75
  Numbered as a patch by the owner's call; the content below is additive, so
@@ -35,10 +98,14 @@ will not touch them:
35
98
  .claude/hooks/gate-stop-dod.mjs # judges the tree it is in
36
99
  ```
37
100
 
38
- Delete those six and re-run `init`, or copy them across by hand. A proper
39
- upgrade command is queued, not shipped and until it exists this note tells you
40
- the manual steps rather than an easy sentence that leaves half the release
41
- inert. That failure mode is the whole subject of 0.3.1, immediately below.
101
+ Delete those six and re-run `init`, or copy them across by hand. This note tells
102
+ you the manual steps rather than an easy sentence that leaves half the release
103
+ inert that failure mode is the whole subject of 0.3.1, immediately below.
104
+
105
+ > **Superseded in 0.4.0.** `create-agent-rig upgrade` delivers exactly these
106
+ > files, and the ones every release after it changes. The procedure above is
107
+ > kept as the record of what 0.3.2 asked of its users; do not follow it if you
108
+ > have 0.4.0 or later.
42
109
 
43
110
  ### Added
44
111
 
@@ -256,7 +323,8 @@ layers, and the `aws-serverless` and `node-service` targets.
256
323
  ## Releasing
257
324
 
258
325
  `npm publish` needs 2FA and cannot be undone, so an agent prepares a release and
259
- **stops at that command**. Everything before it is mechanical:
326
+ **stops at the first step it is not allowed to take** — normally `npm publish`,
327
+ sometimes earlier (step 6). Everything before that is mechanical:
260
328
 
261
329
  1. `pnpm test` — the full suite, including the e2e that generates both targets
262
330
  cold and runs their own checks through the git path **and** the pack path.
@@ -264,8 +332,25 @@ layers, and the `aws-serverless` and `node-service` targets.
264
332
  tree, are in the tarball. This is where scaffolders break, and the git path
265
333
  cannot catch it.
266
334
  3. Version in `package.json` (and the private inner package, kept in step).
267
- 4. This file, and `PLAN.md` if the plan's claims changed.
268
- 5. `git tag v<version> && git push --tags`.
269
- 6. **Owner:** `npm publish`.
270
- 7. **Owner:** smoke the published artifact — `npx create-agent-rig@<version>` in
271
- an empty directory, then `pnpm install && pnpm check` inside it.
335
+ 4. `node scripts/build-hash-history.mjs` regenerate the released-hash table
336
+ from the tags **after** the version bump, so the version now shipping is the
337
+ first one it excludes. Forgetting it would leave `upgrade` unable to
338
+ recognise the previous release.
339
+ 5. This file, and `PLAN.md` if the plan's claims changed.
340
+ 6. **`pnpm test` again — this run, not step 1, is the one that can catch a
341
+ stale hash table.** The check compares the table against the versions this
342
+ file lists below the one in `package.json`, so before steps 3–5 it is
343
+ comparing the _old_ release to the _old_ table and passes either way. A
344
+ guard that can only fire after the thing it guards has changed has to be run
345
+ after it.
346
+ 7. `git tag v<version> && git push --tags` — **first check that the tag does not
347
+ already exist** (`git ls-remote --tags origin`). A leftover from an abandoned
348
+ attempt is a published ref: deleting or moving it is an **owner** action, and
349
+ the release stops here until it is gone. A tag pointing at the wrong commit
350
+ is not cosmetic — the next release builds its hash table from it, and every
351
+ file that changed in between then reads as user-modified and stops being
352
+ upgradable.
353
+ 8. **Owner:** `npm publish`.
354
+ 9. **Owner:** smoke the published artifact — `npx create-agent-rig@<version>` in
355
+ an empty directory, then `pnpm install && pnpm check` inside it; and
356
+ `upgrade --dry-run` in a rig installed from the previous version.
package/README.md CHANGED
@@ -32,6 +32,44 @@ Two things it deliberately leaves to you, and says so in the installed
32
32
  `CLAUDE.md`: the Definition-of-Done gate has no `dod-checks.json` (it cannot know
33
33
  your commands), and the elevated-path list names only what every repo has.
34
34
 
35
+ ## Upgrading a rig you already have
36
+
37
+ A release changes files, and `init` only ever _adds_ — so bringing an existing
38
+ rig forward is its own command:
39
+
40
+ ```sh
41
+ npx create-agent-rig@latest upgrade --dry-run # print the plan, write nothing
42
+ npx create-agent-rig@latest upgrade # print the plan, then ask before writing
43
+ npx create-agent-rig@latest upgrade --yes # the answer up front (required off a terminal)
44
+ ```
45
+
46
+ It replaces the files the rig installed **and you have not touched**, installs
47
+ what the release added, and **reports everything else** — no three-way merge, no
48
+ patching. Silently merging your edits into the documents an agent loop obeys is
49
+ how a rig quietly stops meaning what you think it means; a conflict report is how
50
+ it does not. Each conflict names the file, why it was kept, and the path to the
51
+ new version so you can diff it yourself.
52
+
53
+ How it knows: `create` and `init` write `.claude/.rig-manifest.json` — the rig
54
+ version plus a hash per installed file. **Commit it**; without it in the
55
+ repository the command is blind on CI and on a colleague's machine. Rigs
56
+ installed before 0.4.0 have no manifest, so the package also carries the hashes
57
+ of every **tagged** release (0.3.0 onward — 0.1.0 and 0.2.0 shipped untagged,
58
+ and a rig from those reports every file as yours) and recognises a file matching
59
+ one of them.
60
+
61
+ `.claude/settings.json` is never replaced — it is where your own hooks live, so
62
+ the new wiring is printed for you to merge.
63
+
64
+ **A file you deleted stays deleted.** The rules invite you to delete the ones
65
+ whose invariant your project does not have, so an upgrade that quietly restored
66
+ them would be undoing your work. With a manifest that is direct — it names the
67
+ file, the disk does not have it, and the manifest is _evidence_, not a command.
68
+ Without one, the shipped table answers instead: a file that was in every release
69
+ it covers was there to be removed. The single case nothing can tell apart is a
70
+ file a **later** release added, which your rig never had — that one is installed,
71
+ and `--dry-run` lists it before anything is written.
72
+
35
73
  ## What you get
36
74
 
37
75
  **A system of boundaries, each held by tooling.** An agent (or a human using
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-agent-rig",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "Scaffold a new project with an agent operating system (rules, gates, hooks) and a runnable code skeleton",
5
5
  "keywords": [
6
6
  "create",
@@ -1,13 +1,16 @@
1
1
  import { execFile } from 'node:child_process';
2
- import { mkdir, readdir, stat } from 'node:fs/promises';
2
+ import { mkdir, readFile, readdir, stat } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { promisify } from 'node:util';
5
5
  import { copyTree, listTree } from '../lib/copy-tree.js';
6
6
  import { ALLOWED_OVERWRITES, detectCollisions } from '../lib/composition.js';
7
+ import { agentOsLayerDirs } from '../lib/install-set.js';
8
+ import { sha256, writeManifest } from '../lib/manifest.js';
7
9
  import { substituteContent, substituteFileName } from '../lib/substitute.js';
8
10
  import { gitEnv } from '../lib/git-env.js';
9
11
  import { DEFAULT_TARGET, TARGETS, TARGET_NAMES } from '../lib/targets.js';
10
- import { agentOsStackDir, agentOsUniversalDir, skeletonDir } from '../templates.js';
12
+ import { skeletonDir } from '../templates.js';
13
+ import { packageVersion } from '../lib/version.js';
11
14
  /** A user-facing failure: message is printed as-is, no stack trace. */
12
15
  export class CreateError extends Error {
13
16
  }
@@ -36,13 +39,10 @@ export async function createProject(dirArg, options) {
36
39
  transformName: (name) => substituteFileName(name, ctx),
37
40
  };
38
41
  // Layer 2 (the skeleton) + layer 1 (agent-os: universal + stack overlays).
42
+ const agentOsLayers = agentOsLayerDirs(target.stacks);
39
43
  const layers = [
40
44
  { name: `skeleton/${target.skeletonDir}`, dir: skeletonDir(target.skeletonDir) },
41
- { name: 'agent-os/universal', dir: agentOsUniversalDir() },
42
- ...target.stacks.map((stack) => ({
43
- name: `agent-os/stack/${stack}`,
44
- dir: agentOsStackDir(stack),
45
- })),
45
+ ...agentOsLayers,
46
46
  ];
47
47
  // Composition safety: layers must claim disjoint paths. Checked before any
48
48
  // copy — a collision is a template bug and must never be resolved by order.
@@ -61,11 +61,39 @@ export async function createProject(dirArg, options) {
61
61
  for (const layer of layers) {
62
62
  await copyTree(layer.dir, projectDir, transforms);
63
63
  }
64
+ await recordInstall(projectDir, agentOsLayers, transforms, ctx, targetName);
64
65
  if (options.git !== false) {
65
66
  await initGitBaseline(projectDir);
66
67
  }
67
68
  return { projectDir, projectName };
68
69
  }
70
+ /**
71
+ * Record the agent-os layer in `.claude/.rig-manifest.json`, so a later
72
+ * `upgrade` can tell a file the rig wrote from a file the project's own people
73
+ * changed.
74
+ *
75
+ * The skeleton is **not** recorded, and that is the boundary of the whole
76
+ * upgrade story: once generated, the code belongs to the project. The hashes
77
+ * are read back off the disk rather than recomputed, so the manifest states
78
+ * what is actually there and cannot drift from what was copied. The manifest
79
+ * lands before the baseline commit — it is part of the pristine template, and
80
+ * it belongs in the project's git history.
81
+ */
82
+ async function recordInstall(projectDir, agentOsLayers, transforms, ctx, target) {
83
+ const files = {};
84
+ for (const layer of agentOsLayers) {
85
+ for (const rel of await listTree(layer.dir, transforms)) {
86
+ files[rel] = sha256(await readFile(path.join(projectDir, ...rel.split('/')), 'utf8'));
87
+ }
88
+ }
89
+ await writeManifest(projectDir, {
90
+ version: await packageVersion(),
91
+ kind: 'create',
92
+ project: { name: ctx.projectName, scope: ctx.projectScope, region: ctx.region },
93
+ stacks: [...(TARGETS[target]?.stacks ?? [])],
94
+ files,
95
+ });
96
+ }
69
97
  const run = promisify(execFile);
70
98
  async function initGitBaseline(projectDir) {
71
99
  // Disable git's background maintenance for these one-shot commands: a commit
@@ -1,8 +1,10 @@
1
1
  import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { settingsForInstalledHooks } from '../lib/init-settings.js';
4
+ import { readManifest, sha256, writeManifest } from '../lib/manifest.js';
4
5
  import { substituteContent } from '../lib/substitute.js';
5
6
  import { agentOsInitDir, agentOsUniversalDir } from '../templates.js';
7
+ import { packageVersion } from '../lib/version.js';
6
8
  /** A user-facing failure: message is printed as-is, no stack trace. */
7
9
  export class InitError extends Error {
8
10
  }
@@ -71,9 +73,13 @@ export async function initManifest() {
71
73
  * `__PROJECT_NAME__` in `stop-flag.mjs` is a kill switch that silently never
72
74
  * fires.
73
75
  */
74
- export async function initFileContents(repoDir) {
75
- const projectName = projectNameFor(repoDir);
76
- const ctx = { projectName, projectScope: projectName, region: '' };
76
+ export async function initFileContents(repoDir, project) {
77
+ const projectName = project?.name ?? projectNameFor(repoDir);
78
+ const ctx = {
79
+ projectName,
80
+ projectScope: project?.scope ?? projectName,
81
+ region: project?.region ?? '',
82
+ };
77
83
  const files = await initManifest();
78
84
  const contents = new Map();
79
85
  for (const { rel, source } of files) {
@@ -86,6 +92,12 @@ export async function initFileContents(repoDir) {
86
92
  contents.set(SETTINGS, `${JSON.stringify(settingsForInstalledHooks(shipped, installedHooks), null, 2)}\n`);
87
93
  return contents;
88
94
  }
95
+ /** The process layer as a set of {@link InstalledFile}s — what `upgrade` reads. */
96
+ export async function initInstallSet(repoDir, project) {
97
+ const files = await initManifest();
98
+ const contents = await initFileContents(repoDir, project);
99
+ return files.map(({ rel, source }) => ({ rel, source, content: contents.get(rel) ?? '' }));
100
+ }
89
101
  export async function planInit(repoDir) {
90
102
  const files = (await initManifest()).map((f) => f.rel);
91
103
  const conflicts = [];
@@ -123,5 +135,31 @@ export async function initProject(repoDir, options) {
123
135
  await writeFile(dest, contents.get(rel) ?? '');
124
136
  written.push(rel);
125
137
  }
138
+ if (!options.dryRun)
139
+ await recordInstall(repoDir, written, contents);
126
140
  return { written, skipped, plannedCount };
127
141
  }
142
+ /**
143
+ * Record what was installed, so a later `upgrade` can tell a file it wrote
144
+ * from a file the user owns.
145
+ *
146
+ * Only files actually **written** are recorded. A file `init` kept is
147
+ * somebody else's — claiming it here would let the next upgrade replace a
148
+ * user's own document with the rig's. Earlier entries are preserved: a re-run
149
+ * writes nothing and must not therefore un-remember everything.
150
+ */
151
+ async function recordInstall(repoDir, written, contents) {
152
+ const previous = await readManifest(repoDir);
153
+ const name = projectNameFor(repoDir);
154
+ const files = { ...(previous?.files ?? {}) };
155
+ for (const rel of written)
156
+ files[rel] = sha256(contents.get(rel) ?? '');
157
+ const manifest = {
158
+ version: await packageVersion(),
159
+ kind: 'init',
160
+ project: { name, scope: name, region: '' },
161
+ stacks: [],
162
+ files,
163
+ };
164
+ await writeManifest(repoDir, manifest);
165
+ }
@@ -0,0 +1,300 @@
1
+ import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { initInstallSet, projectNameFor } from './init.js';
4
+ import { loadHashHistory, presentInEveryRelease } from '../lib/history.js';
5
+ import { agentOsInstallSet, agentOsLayerDirs } from '../lib/install-set.js';
6
+ import { listTree } from '../lib/copy-tree.js';
7
+ import { readManifest, sha256, writeManifest } from '../lib/manifest.js';
8
+ import { resolveInside } from '../lib/safe-path.js';
9
+ import { detokenizeContent, substituteFileName } from '../lib/substitute.js';
10
+ import { TARGETS } from '../lib/targets.js';
11
+ import { packageVersion } from '../lib/version.js';
12
+ /** A user-facing failure: message is printed as-is, no stack trace. */
13
+ export class UpgradeError extends Error {
14
+ }
15
+ const SETTINGS = '.claude/settings.json';
16
+ /** The universal layer's architecture group — installed by `create`, never by `init`. */
17
+ const ARCHITECTURE_ONLY = [
18
+ '.claude/rules/architecture.md',
19
+ '.claude/hooks/guard-core-purity.mjs',
20
+ '.claude/hooks/guard-web-boundary.mjs',
21
+ ];
22
+ async function exists(p) {
23
+ try {
24
+ await access(p);
25
+ return true;
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ }
31
+ /**
32
+ * Where `rel` lives inside the rig — refused outright if it lands anywhere
33
+ * else. Nothing should be able to produce such a path once the manifest is
34
+ * validated, which is exactly why this stays: the whole command is writes into
35
+ * somebody's repository, and a containment check is cheap next to the cost of
36
+ * being wrong about that.
37
+ */
38
+ function onDisk(repoDir, rel) {
39
+ const dest = resolveInside(repoDir, rel);
40
+ if (dest === null) {
41
+ throw new UpgradeError(`Refusing to touch "${rel}" — it resolves outside ${repoDir}.`);
42
+ }
43
+ return dest;
44
+ }
45
+ /**
46
+ * The file's bytes, or `null` when it is genuinely **absent**.
47
+ *
48
+ * Only "not there" is absence. Any other failure — a permission, a directory
49
+ * where a file should be, a path this command refuses to touch — is rethrown,
50
+ * because "I could not read your file" must never become "so I wrote mine over
51
+ * it": every caller of this treats `null` as grounds to install.
52
+ */
53
+ async function readIfPresent(repoDir, rel) {
54
+ try {
55
+ return await readFile(onDisk(repoDir, rel), 'utf8');
56
+ }
57
+ catch (error) {
58
+ if (error.code === 'ENOENT')
59
+ return null;
60
+ throw error;
61
+ }
62
+ }
63
+ /** Every stack overlay any target composes — the candidates a rig can carry. */
64
+ function knownStacks() {
65
+ return [...new Set(Object.values(TARGETS).flatMap((t) => t.stacks))];
66
+ }
67
+ /**
68
+ * What a rig with no manifest looks like it is, from the files it has.
69
+ *
70
+ * Two signals, because one file is too thin a thread to hang a project's map
71
+ * on: the architecture rules and hooks, which `create` installs and `init`
72
+ * deliberately does not, **and** any stack-overlay file at all — `init`
73
+ * composes no overlays, so one of those is proof on its own. The region comes
74
+ * from the target whose stack set matches; it is the only value substitution
75
+ * needs that the directory name cannot give.
76
+ *
77
+ * 🔴 Limit: a `create` rig that deleted every architecture file *and* every
78
+ * stack file reads as an `init` rig. It is then offered the `init` flavour of
79
+ * `CLAUDE.md` — a map of a different project shape. Nothing but a manifest
80
+ * distinguishes those two rigs, which is why 0.4.0 writes one.
81
+ */
82
+ async function detectInstall(repoDir) {
83
+ const ctx = { projectName: '', projectScope: '', region: '' };
84
+ const stacks = [];
85
+ for (const stack of knownStacks()) {
86
+ const [layer] = agentOsLayerDirs([stack]).slice(1);
87
+ if (layer === undefined)
88
+ continue;
89
+ const rels = await listTree(layer.dir, {
90
+ transformName: (name) => substituteFileName(name, ctx),
91
+ });
92
+ for (const rel of rels) {
93
+ if (await exists(onDisk(repoDir, rel))) {
94
+ stacks.push(stack);
95
+ break;
96
+ }
97
+ }
98
+ }
99
+ let architectural = stacks.length > 0;
100
+ for (const rel of ARCHITECTURE_ONLY) {
101
+ if (architectural)
102
+ break;
103
+ architectural = await exists(onDisk(repoDir, rel));
104
+ }
105
+ if (!architectural)
106
+ return { kind: 'init', stacks: [], region: '' };
107
+ const target = Object.values(TARGETS).find((t) => t.stacks.length === stacks.length && t.stacks.every((s) => stacks.includes(s)));
108
+ return { kind: 'create', stacks, region: target?.defaultRegion ?? '' };
109
+ }
110
+ async function installSetFor(repoDir, kind, project, stacks) {
111
+ if (kind === 'init')
112
+ return initInstallSet(repoDir, project);
113
+ return agentOsInstallSet(stacks, {
114
+ projectName: project.name,
115
+ projectScope: project.scope,
116
+ region: project.region,
117
+ });
118
+ }
119
+ /**
120
+ * Whether these bytes are a released version of this file.
121
+ *
122
+ * Two candidates are offered to the table: the bytes as they sit, and the
123
+ * bytes with the project's own values turned back into tokens — released
124
+ * template bytes carry `__PROJECT_NAME__`, installed bytes never do.
125
+ */
126
+ function isReleasedVersion(history, rel, content, ctx) {
127
+ const known = history.files[rel];
128
+ if (known === undefined || known.hashes.length === 0)
129
+ return false;
130
+ const candidates = new Set([sha256(content), sha256(detokenizeContent(content, ctx))]);
131
+ return known.hashes.some((hash) => candidates.has(hash));
132
+ }
133
+ /**
134
+ * What an upgrade would do, decided per file, writing nothing.
135
+ *
136
+ * The rule is the whole design: **replace what the rig installed and the user
137
+ * did not touch; report everything else.** There is no three-way merge and no
138
+ * patching — silently merging someone's edits into a file the agent loop obeys
139
+ * is how a rig stops meaning what its owner thinks it means.
140
+ */
141
+ export async function planUpgrade(repoDir, options = {}) {
142
+ const manifest = await readManifest(repoDir);
143
+ // Detection is a whole-tree probe, and it answers a question the manifest
144
+ // has already answered when there is one.
145
+ const detected = manifest === null
146
+ ? await detectInstall(repoDir)
147
+ : { kind: manifest.kind, stacks: manifest.stacks, region: manifest.project.region };
148
+ const kind = manifest?.kind ?? detected.kind;
149
+ const name = path.basename(path.resolve(repoDir));
150
+ // `init` slugs the directory name into something an operator can type (it
151
+ // ends up in the kill-switch filename); `create` validated it as an npm name
152
+ // at generation time, so there the basename is already the project name.
153
+ const bootstrapName = kind === 'init' ? projectNameFor(repoDir) : name;
154
+ const project = manifest?.project ?? {
155
+ name: bootstrapName,
156
+ scope: bootstrapName,
157
+ region: detected.region,
158
+ };
159
+ // Only overlays this version actually ships. An unknown name is not input
160
+ // being dropped — there is no layer behind it to install from — and reading
161
+ // a directory a manifest names would be reading a directory a manifest names.
162
+ const shipped = new Set(knownStacks());
163
+ const stacks = (manifest?.stacks ?? detected.stacks).filter((stack) => shipped.has(stack));
164
+ const history = options.history ?? (await loadHashHistory());
165
+ const files = await installSetFor(repoDir, kind, project, stacks);
166
+ const ctx = {
167
+ projectName: project.name,
168
+ projectScope: project.scope,
169
+ region: project.region,
170
+ };
171
+ const actions = [];
172
+ const contents = new Map();
173
+ const nextFiles = {};
174
+ let wiring = null;
175
+ for (const file of files) {
176
+ const current = await readIfPresent(repoDir, file.rel);
177
+ const recorded = manifest?.files[file.rel];
178
+ contents.set(file.rel, file.content);
179
+ if (current === null) {
180
+ // Evidence, not a command. The manifest is the direct evidence; without
181
+ // one, a path that shipped in *every* release the table covers was there
182
+ // to be removed, so its absence is a decision. A path added later is
183
+ // simply missing from an older rig, and that one is delivered.
184
+ if (recorded !== undefined) {
185
+ actions.push({
186
+ rel: file.rel,
187
+ verdict: 'deleted',
188
+ reason: 'installed by the rig, removed since — not restored',
189
+ });
190
+ nextFiles[file.rel] = recorded;
191
+ }
192
+ else if (presentInEveryRelease(history, file.rel)) {
193
+ actions.push({
194
+ rel: file.rel,
195
+ verdict: 'deleted',
196
+ reason: `shipped in every release since ${history.versions[0]}, and is gone — not restored`,
197
+ });
198
+ }
199
+ else {
200
+ actions.push({ rel: file.rel, verdict: 'new', templatePath: file.source });
201
+ nextFiles[file.rel] = sha256(file.content);
202
+ }
203
+ continue;
204
+ }
205
+ if (file.rel === SETTINGS) {
206
+ if (current === file.content) {
207
+ actions.push({ rel: file.rel, verdict: 'unchanged' });
208
+ nextFiles[file.rel] = sha256(file.content);
209
+ }
210
+ else {
211
+ // Same special case `init` makes: this file is a merge target, not a
212
+ // payload — replacing it can unwire hooks the user added themselves.
213
+ wiring = file.content;
214
+ actions.push({
215
+ rel: file.rel,
216
+ verdict: 'wiring',
217
+ reason: 'never replaced — merge the entries below by hand',
218
+ });
219
+ if (recorded !== undefined)
220
+ nextFiles[file.rel] = recorded;
221
+ }
222
+ continue;
223
+ }
224
+ if (current === file.content) {
225
+ actions.push({ rel: file.rel, verdict: 'unchanged' });
226
+ nextFiles[file.rel] = sha256(file.content);
227
+ }
228
+ else if (recorded !== undefined && sha256(current) === recorded) {
229
+ actions.push({ rel: file.rel, verdict: 'update', templatePath: file.source });
230
+ nextFiles[file.rel] = sha256(file.content);
231
+ }
232
+ else if (isReleasedVersion(history, file.rel, current, ctx)) {
233
+ actions.push({ rel: file.rel, verdict: 'update', templatePath: file.source });
234
+ nextFiles[file.rel] = sha256(file.content);
235
+ }
236
+ else {
237
+ actions.push({
238
+ rel: file.rel,
239
+ verdict: 'conflict',
240
+ reason: recorded === undefined
241
+ ? 'not a version this rig ever released — treated as yours'
242
+ : 'edited since it was installed',
243
+ templatePath: file.source,
244
+ });
245
+ // deliberately NOT recorded: the rig does not own these bytes
246
+ }
247
+ }
248
+ // With no manifest, "there is a rig here" has to be *recognised*, not
249
+ // assumed from a file existing: `CLAUDE.md` and `.claude/settings.json` are
250
+ // in the install set and in nearly every repository ever opened by an agent.
251
+ // Recognition means bytes we know — a file already current, or one that
252
+ // matches a released version. Without that this command would silently
253
+ // perform an `init` nobody asked for.
254
+ if (manifest === null &&
255
+ !actions.some((a) => a.verdict === 'unchanged' || a.verdict === 'update'))
256
+ throw new UpgradeError(`No rig found in ${repoDir}. Nothing here is recognisable as a create-agent-rig ` +
257
+ 'install — run `create-agent-rig init` to install the process layer, or upgrade ' +
258
+ 'from the directory that holds the rig.');
259
+ return {
260
+ kind,
261
+ fromVersion: manifest?.version ?? null,
262
+ toVersion: await packageVersion(),
263
+ bootstrapped: manifest === null,
264
+ actions,
265
+ wiring,
266
+ contents,
267
+ manifest: {
268
+ version: await packageVersion(),
269
+ kind,
270
+ project,
271
+ stacks: [...stacks],
272
+ files: nextFiles,
273
+ },
274
+ };
275
+ }
276
+ /**
277
+ * Write the plan: the `update` and `new` files, then the manifest. Everything
278
+ * else in the plan is a sentence for a human, not an edit.
279
+ */
280
+ export async function applyUpgrade(repoDir, plan, options = {}) {
281
+ const written = [];
282
+ if (options.dryRun === true)
283
+ return { written };
284
+ for (const action of plan.actions) {
285
+ if (action.verdict !== 'update' && action.verdict !== 'new')
286
+ continue;
287
+ const content = plan.contents.get(action.rel);
288
+ // Never a silent empty file: a missing entry is a defect in the plan, and
289
+ // truncating somebody's rule file is the worst way to report one.
290
+ if (content === undefined) {
291
+ throw new UpgradeError(`Internal: no content planned for "${action.rel}" — nothing written.`);
292
+ }
293
+ const dest = onDisk(repoDir, action.rel);
294
+ await mkdir(path.dirname(dest), { recursive: true });
295
+ await writeFile(dest, content);
296
+ written.push(action.rel);
297
+ }
298
+ await writeManifest(repoDir, plan.manifest);
299
+ return { written };
300
+ }