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 +95 -10
- package/README.md +38 -0
- package/package.json +1 -1
- package/packages/cli/dist/commands/create.js +35 -7
- package/packages/cli/dist/commands/init.js +41 -3
- package/packages/cli/dist/commands/upgrade.js +300 -0
- package/packages/cli/dist/index.js +100 -13
- package/packages/cli/dist/lib/copy-tree.js +9 -1
- package/packages/cli/dist/lib/history.js +49 -0
- package/packages/cli/dist/lib/install-set.js +46 -0
- package/packages/cli/dist/lib/manifest.js +99 -0
- package/packages/cli/dist/lib/prompts.js +20 -0
- package/packages/cli/dist/lib/safe-path.js +41 -0
- package/packages/cli/dist/lib/substitute.js +32 -0
- package/packages/cli/dist/lib/version.js +15 -0
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +17 -2
- package/templates/agent-os/universal/PLAN.md +14 -3
- package/templates/hash-history.json +263 -0
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.
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
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.
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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,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 {
|
|
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
|
-
|
|
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 = {
|
|
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
|
+
}
|