create-agent-rig 0.3.2 → 0.5.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 +294 -10
- package/README.md +84 -13
- package/package.json +2 -1
- package/packages/cli/dist/commands/create.js +41 -8
- package/packages/cli/dist/commands/init.js +120 -34
- package/packages/cli/dist/commands/upgrade.js +382 -0
- package/packages/cli/dist/index.js +132 -21
- package/packages/cli/dist/lib/copy-tree.js +44 -7
- package/packages/cli/dist/lib/history.js +49 -0
- package/packages/cli/dist/lib/init-settings.js +12 -0
- package/packages/cli/dist/lib/install-set.js +44 -0
- package/packages/cli/dist/lib/manifest.js +111 -0
- package/packages/cli/dist/lib/prompts.js +20 -0
- package/packages/cli/dist/lib/safe-path.js +71 -0
- package/packages/cli/dist/lib/substitute.js +32 -0
- package/packages/cli/dist/lib/version.js +15 -0
- package/templates/agent-os/init/AGENTS.md +191 -0
- package/templates/agent-os/init/CLAUDE.md +61 -9
- package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +98 -0
- package/templates/agent-os/stack/aws-cdk/.agents/skills/ro-debug/SKILL.md +117 -0
- package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +34 -3
- package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +1 -1
- package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +34 -0
- package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +4 -0
- package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +233 -0
- package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +676 -0
- package/templates/agent-os/universal/.agents/skills/new-invariant/SKILL.md +102 -0
- package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.mjs +78 -0
- package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.test.mjs +89 -0
- package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +338 -0
- package/templates/agent-os/universal/.agents/skills/worktree-task/SKILL.md +73 -0
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +33 -1
- package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +64 -1
- package/templates/agent-os/universal/.claude/agents/security-scanner.md +33 -0
- package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +274 -24
- package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +43 -5
- package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +25 -11
- package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +180 -0
- package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +32 -13
- package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +175 -7
- package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +480 -0
- package/templates/agent-os/universal/.claude/rules/autonomy.md +61 -1
- package/templates/agent-os/universal/.claude/rules/invariants.md +71 -16
- package/templates/agent-os/universal/.claude/rules/workflow.md +55 -5
- package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1242 -0
- package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +65 -2
- package/templates/agent-os/universal/.claude/scripts/git-env.mjs +49 -0
- package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +490 -0
- package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +433 -0
- package/templates/agent-os/universal/.claude/scripts/preflight.mjs +6 -16
- package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +89 -0
- package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +334 -23
- package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +160 -0
- package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +7 -2
- package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +413 -8
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +75 -13
- package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +222 -21
- package/templates/agent-os/universal/.claude/scripts/queue/state.mjs +197 -0
- package/templates/agent-os/universal/.claude/scripts/run-journal.mjs +435 -0
- package/templates/agent-os/universal/.claude/scripts/run-state.mjs +441 -0
- package/templates/agent-os/universal/.claude/scripts/verdict.mjs +101 -0
- package/templates/agent-os/universal/.claude/settings.json +7 -2
- package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +115 -7
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +395 -33
- package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +300 -25
- package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +4 -0
- package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +4 -0
- package/templates/agent-os/universal/.codex/agents/security-scanner.toml +4 -0
- package/templates/agent-os/universal/.codex/agents/test-writer.toml +4 -0
- package/templates/agent-os/universal/.codex/hooks.json +65 -0
- package/templates/agent-os/universal/AGENTS.md +164 -0
- package/templates/agent-os/universal/CLAUDE.md +47 -14
- package/templates/agent-os/universal/PLAN.md +7 -29
- package/templates/agent-os/universal/docs/decisions/closing-a-task.md +98 -0
- package/templates/agent-os/universal/docs/decisions/codex-adapter.md +108 -0
- package/templates/agent-os/universal/docs/decisions/fail-open-guards.md +43 -0
- package/templates/agent-os/universal/docs/decisions/review-lanes.md +72 -0
- package/templates/agent-os/universal/docs/decisions/run-directory.md +44 -0
- package/templates/agent-os/universal/docs/decisions/spacing-rations-mechanisms.md +125 -0
- package/templates/agent-os/universal/docs/decisions/stop-conditions-in-a-file.md +46 -0
- package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +62 -0
- package/templates/agent-os/universal/journal/README.md +101 -0
- package/templates/agent-os/universal/layers.json +36 -2
- package/templates/hash-history.json +264 -0
- package/templates/skeleton/aws-serverless/.github/workflows/deploy.yml +34 -1
- package/templates/skeleton/aws-serverless/README.md +91 -9
- package/templates/skeleton/aws-serverless/apps/web/src/lib/api.ts +9 -2
- package/templates/skeleton/aws-serverless/gitignore +37 -0
- package/templates/skeleton/aws-serverless/infra/bin/app.ts +73 -13
- package/templates/skeleton/aws-serverless/infra/lib/app-stack.ts +136 -4
- package/templates/skeleton/aws-serverless/infra/lib/web-stack.ts +14 -1
- package/templates/skeleton/aws-serverless/infra/test/allowed-origins.test.ts +301 -0
- package/templates/skeleton/aws-serverless/infra/test/app-composition.test.ts +137 -0
- package/templates/skeleton/aws-serverless/infra/test/app-stack.test.ts +36 -20
- package/templates/skeleton/aws-serverless/infra/test/web-stack.test.ts +15 -12
- package/templates/skeleton/aws-serverless/services/api/src/handlers/create-note.ts +33 -6
- package/templates/skeleton/aws-serverless/services/api/src/handlers/list-notes.ts +18 -5
- package/templates/skeleton/aws-serverless/services/api/src/usecases/create-note.ts +11 -0
- package/templates/skeleton/aws-serverless/services/api/test/create-note.handler.test.ts +120 -1
- package/templates/skeleton/aws-serverless/services/api/test/list-notes.test.ts +72 -1
- package/templates/skeleton/node-service/README.md +11 -1
- package/templates/skeleton/node-service/gitignore +34 -0
- package/templates/skeleton/node-service/packages/db/src/note-store.ts +47 -10
- package/templates/skeleton/node-service/packages/db/test/note-store.test.ts +20 -0
- package/templates/skeleton/node-service/services/api/src/main.ts +2 -9
- package/templates/skeleton/node-service/services/api/src/server.ts +93 -10
- package/templates/skeleton/node-service/services/api/src/static-dir.ts +20 -0
- package/templates/skeleton/node-service/services/api/test/server.test.ts +98 -13
- package/templates/skeleton/node-service/services/api/test/static-dir.test.ts +28 -0
|
@@ -1,12 +1,19 @@
|
|
|
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 { mapConcurrent } from '../lib/copy-tree.js';
|
|
5
|
+
import { readManifest, sha256, writeManifest } from '../lib/manifest.js';
|
|
4
6
|
import { substituteContent } from '../lib/substitute.js';
|
|
5
7
|
import { agentOsInitDir, agentOsUniversalDir } from '../templates.js';
|
|
8
|
+
import { packageVersion } from '../lib/version.js';
|
|
6
9
|
/** A user-facing failure: message is printed as-is, no stack trace. */
|
|
7
10
|
export class InitError extends Error {
|
|
8
11
|
}
|
|
12
|
+
/** What `--force` answers with now that `upgrade` owns the case it stood in for. */
|
|
13
|
+
export const FORCE_DEPRECATED = 'deprecated — init --force replaced only CLAUDE.md; run create-agent-rig upgrade instead';
|
|
9
14
|
const SETTINGS = '.claude/settings.json';
|
|
15
|
+
const CODEX_HOOKS = '.codex/hooks.json';
|
|
16
|
+
const MAPS = ['CLAUDE.md', 'AGENTS.md'];
|
|
10
17
|
async function loadManifest() {
|
|
11
18
|
const raw = await readFile(path.join(agentOsUniversalDir(), 'layers.json'), 'utf8');
|
|
12
19
|
return JSON.parse(raw);
|
|
@@ -51,15 +58,15 @@ export async function initManifest() {
|
|
|
51
58
|
const manifest = await loadManifest();
|
|
52
59
|
const universal = agentOsUniversalDir();
|
|
53
60
|
const override = agentOsInitDir();
|
|
54
|
-
const files = []
|
|
55
|
-
for (const rel of [...manifest.process, 'CLAUDE.md']) {
|
|
61
|
+
const files = await mapConcurrent([...manifest.process, ...MAPS], 16, async (rel) => {
|
|
56
62
|
const overridden = path.join(override, rel);
|
|
57
|
-
|
|
63
|
+
return {
|
|
58
64
|
rel,
|
|
59
65
|
source: (await exists(overridden)) ? overridden : path.join(universal, rel),
|
|
60
|
-
}
|
|
61
|
-
}
|
|
66
|
+
};
|
|
67
|
+
});
|
|
62
68
|
files.push({ rel: SETTINGS, source: null });
|
|
69
|
+
files.push({ rel: CODEX_HOOKS, source: null });
|
|
63
70
|
return files;
|
|
64
71
|
}
|
|
65
72
|
/**
|
|
@@ -71,57 +78,136 @@ export async function initManifest() {
|
|
|
71
78
|
* `__PROJECT_NAME__` in `stop-flag.mjs` is a kill switch that silently never
|
|
72
79
|
* fires.
|
|
73
80
|
*/
|
|
74
|
-
export async function initFileContents(repoDir) {
|
|
75
|
-
const projectName = projectNameFor(repoDir);
|
|
76
|
-
const ctx = {
|
|
81
|
+
export async function initFileContents(repoDir, project) {
|
|
82
|
+
const projectName = project?.name ?? projectNameFor(repoDir);
|
|
83
|
+
const ctx = {
|
|
84
|
+
projectName,
|
|
85
|
+
projectScope: project?.scope ?? projectName,
|
|
86
|
+
region: project?.region ?? '',
|
|
87
|
+
};
|
|
77
88
|
const files = await initManifest();
|
|
78
89
|
const contents = new Map();
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
90
|
+
const sourceFiles = files.filter((file) => file.source !== null);
|
|
91
|
+
const rendered = await mapConcurrent(sourceFiles, 16, async ({ rel, source }) => ({
|
|
92
|
+
rel,
|
|
93
|
+
content: substituteContent(await readFile(source, 'utf8'), ctx),
|
|
94
|
+
}));
|
|
95
|
+
for (const { rel, content } of rendered) {
|
|
96
|
+
contents.set(rel, content);
|
|
83
97
|
}
|
|
84
98
|
const installedHooks = new Set(files.map((f) => f.rel).filter((rel) => rel.startsWith('.claude/hooks/')));
|
|
85
|
-
const
|
|
99
|
+
const [shippedSettings, shippedCodexHooks] = await Promise.all([
|
|
100
|
+
readFile(path.join(agentOsUniversalDir(), SETTINGS), 'utf8'),
|
|
101
|
+
readFile(path.join(agentOsUniversalDir(), CODEX_HOOKS), 'utf8'),
|
|
102
|
+
]);
|
|
103
|
+
const shipped = JSON.parse(shippedSettings);
|
|
86
104
|
contents.set(SETTINGS, `${JSON.stringify(settingsForInstalledHooks(shipped, installedHooks), null, 2)}\n`);
|
|
105
|
+
const shippedCodex = JSON.parse(shippedCodexHooks);
|
|
106
|
+
contents.set(CODEX_HOOKS, `${JSON.stringify(settingsForInstalledHooks(shippedCodex, installedHooks), null, 2)}\n`);
|
|
87
107
|
return contents;
|
|
88
108
|
}
|
|
109
|
+
/** The process layer as a set of {@link InstalledFile}s — what `upgrade` reads. */
|
|
110
|
+
export async function initInstallSet(repoDir, project) {
|
|
111
|
+
const files = await initManifest();
|
|
112
|
+
const contents = await initFileContents(repoDir, project);
|
|
113
|
+
return files.map(({ rel, source }) => ({ rel, source, content: contents.get(rel) ?? '' }));
|
|
114
|
+
}
|
|
89
115
|
export async function planInit(repoDir) {
|
|
90
116
|
const files = (await initManifest()).map((f) => f.rel);
|
|
91
|
-
const conflicts =
|
|
92
|
-
for (const rel of files) {
|
|
93
|
-
if (await exists(path.join(repoDir, rel)))
|
|
94
|
-
conflicts.push(rel);
|
|
95
|
-
}
|
|
117
|
+
const conflicts = (await mapConcurrent(files, 16, async (rel) => (await exists(path.join(repoDir, rel))) ? rel : null)).filter((rel) => rel !== null);
|
|
96
118
|
return { files: files.map((p) => ({ path: p })), conflicts };
|
|
97
119
|
}
|
|
98
120
|
export async function initProject(repoDir, options) {
|
|
121
|
+
// Refused before anything is read or written, so a deprecated flag cannot
|
|
122
|
+
// half-install: `upgrade` covers what this stood in for, and it decides per
|
|
123
|
+
// file from the manifest instead of overriding one refusal wholesale.
|
|
124
|
+
if (options.force)
|
|
125
|
+
throw new InitError(FORCE_DEPRECATED);
|
|
99
126
|
const files = (await initManifest()).map((f) => f.rel);
|
|
100
|
-
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
127
|
+
const previous = await readManifest(repoDir);
|
|
128
|
+
// Refuse to clobber an existing CLAUDE.md — init edits someone's working
|
|
129
|
+
// repository (brief §4, non-negotiable).
|
|
130
|
+
for (const map of MAPS) {
|
|
131
|
+
if (files.includes(map) && (await exists(path.join(repoDir, map)))) {
|
|
132
|
+
// A create rig whose CLAUDE.md was deleted is the legacy route into init.
|
|
133
|
+
// Its generated AGENTS.md must not newly close that route, but only the
|
|
134
|
+
// manifest can distinguish that file from a user's own Codex guidance.
|
|
135
|
+
if (map === 'AGENTS.md' &&
|
|
136
|
+
previous?.files[map] !== undefined &&
|
|
137
|
+
sha256(await readFile(path.join(repoDir, map), 'utf8')) === previous.files[map]) {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
throw new InitError(`This repo already has an ${map}. Refusing to overwrite it. ` +
|
|
141
|
+
'Merge the agent-os map in by hand, or run create-agent-rig upgrade to refresh a rig.');
|
|
106
142
|
}
|
|
107
143
|
}
|
|
108
144
|
const contents = await initFileContents(repoDir);
|
|
109
|
-
const written = [];
|
|
110
|
-
const skipped = [];
|
|
111
145
|
const plannedCount = files.length;
|
|
112
|
-
|
|
146
|
+
const actions = await mapConcurrent(files, 16, async (rel) => {
|
|
113
147
|
const dest = path.join(repoDir, rel);
|
|
114
|
-
|
|
115
|
-
if ((await exists(dest)) && !(isForceableMeta && options.force)) {
|
|
148
|
+
if (await exists(dest)) {
|
|
116
149
|
// never overwrite a file init did not write (a user's own copy)
|
|
117
|
-
skipped
|
|
118
|
-
continue;
|
|
150
|
+
return { rel, verdict: 'skipped' };
|
|
119
151
|
}
|
|
120
152
|
if (options.dryRun)
|
|
121
|
-
|
|
153
|
+
return { rel, verdict: 'planned' };
|
|
122
154
|
await mkdir(path.dirname(dest), { recursive: true });
|
|
123
155
|
await writeFile(dest, contents.get(rel) ?? '');
|
|
124
|
-
written
|
|
125
|
-
}
|
|
156
|
+
return { rel, verdict: 'written' };
|
|
157
|
+
});
|
|
158
|
+
const written = actions.filter(({ verdict }) => verdict === 'written').map(({ rel }) => rel);
|
|
159
|
+
const skipped = actions.filter(({ verdict }) => verdict === 'skipped').map(({ rel }) => rel);
|
|
160
|
+
if (!options.dryRun)
|
|
161
|
+
await recordInstall(repoDir, written, contents);
|
|
126
162
|
return { written, skipped, plannedCount };
|
|
127
163
|
}
|
|
164
|
+
/**
|
|
165
|
+
* Record what was installed, so a later `upgrade` can tell a file it wrote
|
|
166
|
+
* from a file the user owns.
|
|
167
|
+
*
|
|
168
|
+
* Only files actually **written** are recorded. A file `init` kept is
|
|
169
|
+
* somebody else's — claiming it here would let the next upgrade replace a
|
|
170
|
+
* user's own document with the rig's. Earlier entries are preserved: a re-run
|
|
171
|
+
* writes nothing and must not therefore un-remember everything.
|
|
172
|
+
*
|
|
173
|
+
* 🔴 **`kind`, `project` and `stacks` are preserved, not rewritten.** Reached
|
|
174
|
+
* inside a rig `create` produced, this used to stamp `kind: 'init'`,
|
|
175
|
+
* `stacks: []` and an empty `region` over the truth — and `planUpgrade` trusts
|
|
176
|
+
* a manifest wholesale (it never re-detects), so the next upgrade routed to the
|
|
177
|
+
* `init` install set and the stack overlays left the plan entirely: not
|
|
178
|
+
* reported as deleted, not as a conflict, simply absent. `init` describes what
|
|
179
|
+
* it wrote; it does not get to re-describe how the rig was installed.
|
|
180
|
+
*
|
|
181
|
+
* ⚠ **The limit, stated because the fix reads as wider than it is:** this
|
|
182
|
+
* preserves a manifest, so a rig that has none — anything from before 0.4.0 —
|
|
183
|
+
* still gets `kind: 'init'`, no stacks and an empty region, and the advisory in
|
|
184
|
+
* `runInit` stays silent for the same reason. `upgrade`'s `detectInstall`
|
|
185
|
+
* recovers all three from the files on disk, so those values are not
|
|
186
|
+
* unavailable, only unavailable *here*: reaching for it would point
|
|
187
|
+
* `commands/init` at `commands/upgrade`, which already imports this module.
|
|
188
|
+
* The fallback below is the honest floor, not the best available answer.
|
|
189
|
+
*
|
|
190
|
+
* The item that asked for this also floated refusing `init` outright on a
|
|
191
|
+
* `create` manifest. It is already refused a step earlier and for a different
|
|
192
|
+
* reason — {@link initProject} throws on the existing `CLAUDE.md`. The gap that
|
|
193
|
+
* leaves is a `create` rig whose `CLAUDE.md` was deleted, and this function is
|
|
194
|
+
* what makes that case safe.
|
|
195
|
+
*/
|
|
196
|
+
async function recordInstall(repoDir, written, contents) {
|
|
197
|
+
const previous = await readManifest(repoDir);
|
|
198
|
+
const name = projectNameFor(repoDir);
|
|
199
|
+
const files = { ...(previous?.files ?? {}) };
|
|
200
|
+
for (const rel of written)
|
|
201
|
+
files[rel] = sha256(contents.get(rel) ?? '');
|
|
202
|
+
const manifest = {
|
|
203
|
+
version: await packageVersion(),
|
|
204
|
+
kind: previous?.kind ?? 'init',
|
|
205
|
+
// No manifest: fall back to the directory name, which is all this module
|
|
206
|
+
// reads. See the limit above — `upgrade` can do better from the files
|
|
207
|
+
// themselves, and `init` deliberately does not reach for it.
|
|
208
|
+
project: previous?.project ?? { name, scope: name, region: '' },
|
|
209
|
+
stacks: previous?.stacks ?? [],
|
|
210
|
+
files,
|
|
211
|
+
};
|
|
212
|
+
await writeManifest(repoDir, manifest);
|
|
213
|
+
}
|
|
@@ -0,0 +1,382 @@
|
|
|
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 { hookFilesReferencedIn } from '../lib/init-settings.js';
|
|
5
|
+
import { loadHashHistory, presentInEveryRelease } from '../lib/history.js';
|
|
6
|
+
import { agentOsInstallSet, agentOsLayerDirs } from '../lib/install-set.js';
|
|
7
|
+
import { listTree } from '../lib/copy-tree.js';
|
|
8
|
+
import { readManifest, sha256, writeManifest } from '../lib/manifest.js';
|
|
9
|
+
import { isSafeSubstitutionValue, resolveInside } from '../lib/safe-path.js';
|
|
10
|
+
import { detokenizeContent, substituteFileName } from '../lib/substitute.js';
|
|
11
|
+
import { TARGETS } from '../lib/targets.js';
|
|
12
|
+
import { packageVersion } from '../lib/version.js';
|
|
13
|
+
/** A user-facing failure: message is printed as-is, no stack trace. */
|
|
14
|
+
export class UpgradeError extends Error {
|
|
15
|
+
}
|
|
16
|
+
const SETTINGS = '.claude/settings.json';
|
|
17
|
+
const CODEX_HOOKS = '.codex/hooks.json';
|
|
18
|
+
const WIRING_PATHS = new Set([SETTINGS, CODEX_HOOKS]);
|
|
19
|
+
/** The universal layer's architecture group — installed by `create`, never by `init`. */
|
|
20
|
+
const ARCHITECTURE_ONLY = [
|
|
21
|
+
'.claude/rules/architecture.md',
|
|
22
|
+
'.claude/hooks/guard-core-purity.mjs',
|
|
23
|
+
'.claude/hooks/guard-web-boundary.mjs',
|
|
24
|
+
];
|
|
25
|
+
async function exists(p) {
|
|
26
|
+
try {
|
|
27
|
+
await access(p);
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Where `rel` lives inside the rig — refused outright if it lands anywhere
|
|
36
|
+
* else. Nothing should be able to produce such a path once the manifest is
|
|
37
|
+
* validated, which is exactly why this stays: the whole command is writes into
|
|
38
|
+
* somebody's repository, and a containment check is cheap next to the cost of
|
|
39
|
+
* being wrong about that.
|
|
40
|
+
*/
|
|
41
|
+
function onDisk(repoDir, rel) {
|
|
42
|
+
const dest = resolveInside(repoDir, rel);
|
|
43
|
+
if (dest === null) {
|
|
44
|
+
throw new UpgradeError(`Refusing to touch "${rel}" — it resolves outside ${repoDir}.`);
|
|
45
|
+
}
|
|
46
|
+
return dest;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The file's bytes, or `null` when it is genuinely **absent**.
|
|
50
|
+
*
|
|
51
|
+
* Only "not there" is absence. Any other failure — a permission, a directory
|
|
52
|
+
* where a file should be, a path this command refuses to touch — is rethrown,
|
|
53
|
+
* because "I could not read your file" must never become "so I wrote mine over
|
|
54
|
+
* it": every caller of this treats `null` as grounds to install.
|
|
55
|
+
*/
|
|
56
|
+
async function readIfPresent(repoDir, rel) {
|
|
57
|
+
try {
|
|
58
|
+
return await readFile(onDisk(repoDir, rel), 'utf8');
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
if (error.code === 'ENOENT')
|
|
62
|
+
return null;
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Every stack overlay any target composes — the candidates a rig can carry. */
|
|
67
|
+
function knownStacks() {
|
|
68
|
+
return [...new Set(Object.values(TARGETS).flatMap((t) => t.stacks))];
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* What a rig with no manifest looks like it is, from the files it has.
|
|
72
|
+
*
|
|
73
|
+
* Two signals, because one file is too thin a thread to hang a project's map
|
|
74
|
+
* on: the architecture rules and hooks, which `create` installs and `init`
|
|
75
|
+
* deliberately does not, **and** any stack-overlay file at all — `init`
|
|
76
|
+
* composes no overlays, so one of those is proof on its own. The region comes
|
|
77
|
+
* from the target whose stack set matches; it is the only value substitution
|
|
78
|
+
* needs that the directory name cannot give.
|
|
79
|
+
*
|
|
80
|
+
* 🔴 Limit: a `create` rig that deleted every architecture file *and* every
|
|
81
|
+
* stack file reads as an `init` rig. It is then offered the `init` flavour of
|
|
82
|
+
* `CLAUDE.md` — a map of a different project shape. Nothing but a manifest
|
|
83
|
+
* distinguishes those two rigs, which is why 0.4.0 writes one.
|
|
84
|
+
*/
|
|
85
|
+
async function detectInstall(repoDir) {
|
|
86
|
+
const ctx = { projectName: '', projectScope: '', region: '' };
|
|
87
|
+
const stacks = [];
|
|
88
|
+
for (const stack of knownStacks()) {
|
|
89
|
+
const [layer] = agentOsLayerDirs([stack]).slice(1);
|
|
90
|
+
if (layer === undefined)
|
|
91
|
+
continue;
|
|
92
|
+
const rels = await listTree(layer.dir, {
|
|
93
|
+
transformName: (name) => substituteFileName(name, ctx),
|
|
94
|
+
});
|
|
95
|
+
for (const rel of rels) {
|
|
96
|
+
if (await exists(onDisk(repoDir, rel))) {
|
|
97
|
+
stacks.push(stack);
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
let architectural = stacks.length > 0;
|
|
103
|
+
for (const rel of ARCHITECTURE_ONLY) {
|
|
104
|
+
if (architectural)
|
|
105
|
+
break;
|
|
106
|
+
architectural = await exists(onDisk(repoDir, rel));
|
|
107
|
+
}
|
|
108
|
+
if (!architectural)
|
|
109
|
+
return { kind: 'init', stacks: [], region: '' };
|
|
110
|
+
const target = Object.values(TARGETS).find((t) => t.stacks.length === stacks.length && t.stacks.every((s) => stacks.includes(s)));
|
|
111
|
+
return { kind: 'create', stacks, region: target?.defaultRegion ?? '' };
|
|
112
|
+
}
|
|
113
|
+
async function installSetFor(repoDir, kind, project, stacks) {
|
|
114
|
+
if (kind === 'init')
|
|
115
|
+
return initInstallSet(repoDir, project);
|
|
116
|
+
return agentOsInstallSet(stacks, {
|
|
117
|
+
projectName: project.name,
|
|
118
|
+
projectScope: project.scope,
|
|
119
|
+
region: project.region,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Whether these bytes are a released version of this file.
|
|
124
|
+
*
|
|
125
|
+
* Two candidates are offered to the table: the bytes as they sit, and the
|
|
126
|
+
* bytes with the project's own values turned back into tokens — released
|
|
127
|
+
* template bytes carry `__PROJECT_NAME__`, installed bytes never do.
|
|
128
|
+
*/
|
|
129
|
+
function isReleasedVersion(history, rel, content, ctx) {
|
|
130
|
+
const known = history.files[rel];
|
|
131
|
+
if (known === undefined || known.hashes.length === 0)
|
|
132
|
+
return false;
|
|
133
|
+
const candidates = new Set([sha256(content), sha256(detokenizeContent(content, ctx))]);
|
|
134
|
+
return known.hashes.some((hash) => candidates.has(hash));
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Would writing `next` over `current` stop calling a hook that is still there?
|
|
138
|
+
*
|
|
139
|
+
* The one question the hash arms cannot answer. They prove the bytes belong to
|
|
140
|
+
* the rig; they do not prove the replacement wires the same hooks, and
|
|
141
|
+
* Wiring files have flavours that differ in exactly that. A hook file
|
|
142
|
+
* still on disk with nothing wired to it is the quiet failure
|
|
143
|
+
* `lib/init-settings.ts` names: the rules claim it is enforced and nothing ever
|
|
144
|
+
* calls it.
|
|
145
|
+
*
|
|
146
|
+
* Only hooks whose FILE is still present count. One the user deleted on purpose
|
|
147
|
+
* is not being silenced by this write — it was already gone.
|
|
148
|
+
*/
|
|
149
|
+
async function unwiresAnInstalledHook(repoDir, current, next) {
|
|
150
|
+
const nextHooks = hookFilesReferencedIn(next);
|
|
151
|
+
for (const hook of hookFilesReferencedIn(current)) {
|
|
152
|
+
if (nextHooks.has(hook))
|
|
153
|
+
continue;
|
|
154
|
+
const onDisk = resolveInside(repoDir, hook);
|
|
155
|
+
if (onDisk !== null && (await exists(onDisk)))
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* What an upgrade would do, decided per file, writing nothing.
|
|
162
|
+
*
|
|
163
|
+
* The rule is the whole design: **replace what the rig installed and the user
|
|
164
|
+
* did not touch; report everything else.** There is no three-way merge and no
|
|
165
|
+
* patching — silently merging someone's edits into a file the agent loop obeys
|
|
166
|
+
* is how a rig stops meaning what its owner thinks it means.
|
|
167
|
+
*/
|
|
168
|
+
export async function planUpgrade(repoDir, options = {}) {
|
|
169
|
+
const manifest = await readManifest(repoDir);
|
|
170
|
+
// Detection is a whole-tree probe, and it answers a question the manifest
|
|
171
|
+
// has already answered when there is one.
|
|
172
|
+
const detected = manifest === null
|
|
173
|
+
? await detectInstall(repoDir)
|
|
174
|
+
: { kind: manifest.kind, stacks: manifest.stacks, region: manifest.project.region };
|
|
175
|
+
const kind = manifest?.kind ?? detected.kind;
|
|
176
|
+
// With no manifest to read, guess the name the rig's own files were written
|
|
177
|
+
// with — and each command wrote them differently, so the guess branches the
|
|
178
|
+
// same way:
|
|
179
|
+
//
|
|
180
|
+
// - `init` substitutes the **slug** and records the slug (`init.ts`,
|
|
181
|
+
// `projectNameFor` in both places), so for an init rig the slug is not an
|
|
182
|
+
// approximation, it is the value;
|
|
183
|
+
// - `create` substitutes the name it was **given**, having validated it — and
|
|
184
|
+
// that validation accepts a trailing `-` or `.`, which `projectNameFor`
|
|
185
|
+
// strips. So slugging a create rig renames it: `my-app.` became `my-app`,
|
|
186
|
+
// stopped matching its own installed files, and returned four of them as
|
|
187
|
+
// conflicts.
|
|
188
|
+
//
|
|
189
|
+
// The one case where the raw name cannot be kept is a directory the manifest
|
|
190
|
+
// reader would refuse — `My App` produced `{"name":"My App"}`, which
|
|
191
|
+
// `parseManifest` voids, so the manifest this command exists to write was
|
|
192
|
+
// written and immediately unreadable and every later run reported "no
|
|
193
|
+
// manifest here (a pre-0.4.0 rig)". The condition is that reader's own
|
|
194
|
+
// exported predicate, not a second copy of its rule.
|
|
195
|
+
//
|
|
196
|
+
// 🔴 All three branches were bought by a defect, and two of those defects
|
|
197
|
+
// were introduced by fixing the other — the mirror is easy to miss, because
|
|
198
|
+
// each fix looks total until the other kind is tried. Change nothing here
|
|
199
|
+
// without running the three sibling cases in `upgrade.test.ts`.
|
|
200
|
+
//
|
|
201
|
+
// ⚠ It is still the *directory's* name, so a renamed or cloned rig with no
|
|
202
|
+
// manifest bootstraps the new name and its substituted files come back as
|
|
203
|
+
// conflicts — kept and reported, never overwritten. Committing the manifest
|
|
204
|
+
// is what removes the guess, and that is unchanged from 0.4.0.
|
|
205
|
+
const rawName = path.basename(path.resolve(repoDir));
|
|
206
|
+
const bootstrapName = kind === 'init' || !isSafeSubstitutionValue(rawName) ? projectNameFor(repoDir) : rawName;
|
|
207
|
+
const project = manifest?.project ?? {
|
|
208
|
+
name: bootstrapName,
|
|
209
|
+
scope: bootstrapName,
|
|
210
|
+
region: detected.region,
|
|
211
|
+
};
|
|
212
|
+
// Only overlays this version actually ships. An unknown name is not input
|
|
213
|
+
// being dropped — there is no layer behind it to install from — and reading
|
|
214
|
+
// a directory a manifest names would be reading a directory a manifest names.
|
|
215
|
+
const shipped = new Set(knownStacks());
|
|
216
|
+
const stacks = (manifest?.stacks ?? detected.stacks).filter((stack) => shipped.has(stack));
|
|
217
|
+
const history = options.history ?? (await loadHashHistory());
|
|
218
|
+
const files = await installSetFor(repoDir, kind, project, stacks);
|
|
219
|
+
const ctx = {
|
|
220
|
+
projectName: project.name,
|
|
221
|
+
projectScope: project.scope,
|
|
222
|
+
region: project.region,
|
|
223
|
+
};
|
|
224
|
+
const actions = [];
|
|
225
|
+
const contents = new Map();
|
|
226
|
+
const nextFiles = {};
|
|
227
|
+
let wiring = null;
|
|
228
|
+
const wiringByPath = new Map();
|
|
229
|
+
for (const file of files) {
|
|
230
|
+
const current = await readIfPresent(repoDir, file.rel);
|
|
231
|
+
const recorded = manifest?.files[file.rel];
|
|
232
|
+
contents.set(file.rel, file.content);
|
|
233
|
+
if (current === null) {
|
|
234
|
+
// Evidence, not a command. The manifest is the direct evidence; without
|
|
235
|
+
// one, a path that shipped in *every* release the table covers was there
|
|
236
|
+
// to be removed, so its absence is a decision. A path added later is
|
|
237
|
+
// simply missing from an older rig, and that one is delivered.
|
|
238
|
+
if (recorded !== undefined) {
|
|
239
|
+
actions.push({
|
|
240
|
+
rel: file.rel,
|
|
241
|
+
verdict: 'deleted',
|
|
242
|
+
reason: 'installed by the rig, removed since — not restored',
|
|
243
|
+
});
|
|
244
|
+
nextFiles[file.rel] = recorded;
|
|
245
|
+
}
|
|
246
|
+
else if (presentInEveryRelease(history, file.rel)) {
|
|
247
|
+
actions.push({
|
|
248
|
+
rel: file.rel,
|
|
249
|
+
verdict: 'deleted',
|
|
250
|
+
reason: `shipped in every release since ${history.versions[0]}, and is gone — not restored`,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
actions.push({ rel: file.rel, verdict: 'new', templatePath: file.source });
|
|
255
|
+
nextFiles[file.rel] = sha256(file.content);
|
|
256
|
+
}
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
// The two limits that keep wiring files' new replaceability from
|
|
260
|
+
// disarming the rig, both measured rather than reasoned about.
|
|
261
|
+
//
|
|
262
|
+
// 1. The released-hash fallback is not enough for THIS file. Every other
|
|
263
|
+
// file has one flavour; this one has two — `create` wires all the hooks,
|
|
264
|
+
// `init` wires only the ones it installs — and they share a history
|
|
265
|
+
// entry. A manifest-less rig that ran `init` is recorded `kind: 'init'`,
|
|
266
|
+
// so matching a released hash would write the narrow wiring over the
|
|
267
|
+
// full one. The item asks for the manifest arm and says the rest is
|
|
268
|
+
// reported, which is also the reading with no regression behind it.
|
|
269
|
+
// 2. Even the manifest arm is not enough on its own, because `kind` is
|
|
270
|
+
// trusted from a file that travels in pull requests. So the decision is
|
|
271
|
+
// gated on the wiring itself: if the replacement would stop calling a
|
|
272
|
+
// hook still sitting in `.claude/hooks/`, it is handed over. That check
|
|
273
|
+
// does not care which flavour anything claims to be.
|
|
274
|
+
const isWiring = WIRING_PATHS.has(file.rel);
|
|
275
|
+
const wouldUnwireAnInstalledHook = isWiring && (await unwiresAnInstalledHook(repoDir, current, file.content));
|
|
276
|
+
const vouched = isWiring
|
|
277
|
+
? recorded !== undefined && sha256(current) === recorded
|
|
278
|
+
: (recorded !== undefined && sha256(current) === recorded) ||
|
|
279
|
+
isReleasedVersion(history, file.rel, current, ctx);
|
|
280
|
+
if (current === file.content) {
|
|
281
|
+
actions.push({ rel: file.rel, verdict: 'unchanged' });
|
|
282
|
+
nextFiles[file.rel] = sha256(file.content);
|
|
283
|
+
}
|
|
284
|
+
else if (vouched && !wouldUnwireAnInstalledHook) {
|
|
285
|
+
actions.push({
|
|
286
|
+
rel: file.rel,
|
|
287
|
+
verdict: 'update',
|
|
288
|
+
templatePath: file.source,
|
|
289
|
+
// Every other replacement is routine; this one rewrites what calls the
|
|
290
|
+
// guards, so it says so rather than arriving as one more `~` line.
|
|
291
|
+
...(isWiring ? { reason: 'the hook wiring, replaced — you never edited it' } : {}),
|
|
292
|
+
});
|
|
293
|
+
nextFiles[file.rel] = sha256(file.content);
|
|
294
|
+
}
|
|
295
|
+
else if (isWiring) {
|
|
296
|
+
// Nothing vouches for these bytes, or replacing them would silence a hook
|
|
297
|
+
// that is still installed. Either way this is the one file whose conflict
|
|
298
|
+
// is a merge rather than a choice, so the released file is handed over.
|
|
299
|
+
wiring = file.content;
|
|
300
|
+
wiringByPath.set(file.rel, file.content);
|
|
301
|
+
actions.push({
|
|
302
|
+
rel: file.rel,
|
|
303
|
+
verdict: 'wiring',
|
|
304
|
+
// Say what was actually checked. This file no longer consults the
|
|
305
|
+
// released-hash table (see the limits above), so it cannot claim these
|
|
306
|
+
// bytes are not a release — only that the manifest does not vouch for
|
|
307
|
+
// them, which is the check that ran.
|
|
308
|
+
reason: wouldUnwireAnInstalledHook
|
|
309
|
+
? 'replacing it would stop calling a hook that is still installed — merge the entries below by hand'
|
|
310
|
+
: recorded === undefined
|
|
311
|
+
? 'the manifest does not vouch for it — treated as yours, merge the entries below by hand'
|
|
312
|
+
: 'edited since it was installed — merge the entries below by hand',
|
|
313
|
+
});
|
|
314
|
+
if (recorded !== undefined)
|
|
315
|
+
nextFiles[file.rel] = recorded;
|
|
316
|
+
}
|
|
317
|
+
else {
|
|
318
|
+
actions.push({
|
|
319
|
+
rel: file.rel,
|
|
320
|
+
verdict: 'conflict',
|
|
321
|
+
reason: recorded === undefined
|
|
322
|
+
? 'not a version this rig ever released — treated as yours'
|
|
323
|
+
: 'edited since it was installed',
|
|
324
|
+
templatePath: file.source,
|
|
325
|
+
});
|
|
326
|
+
// deliberately NOT recorded: the rig does not own these bytes
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
// With no manifest, "there is a rig here" has to be *recognised*, not
|
|
330
|
+
// assumed from a file existing: `CLAUDE.md` and `.claude/settings.json` are
|
|
331
|
+
// in the install set and in nearly every repository ever opened by an agent.
|
|
332
|
+
// Recognition means bytes we know — a file already current, or one that
|
|
333
|
+
// matches a released version. Without that this command would silently
|
|
334
|
+
// perform an `init` nobody asked for.
|
|
335
|
+
if (manifest === null &&
|
|
336
|
+
!actions.some((a) => a.verdict === 'unchanged' || a.verdict === 'update'))
|
|
337
|
+
throw new UpgradeError(`No rig found in ${repoDir}. Nothing here is recognisable as a create-agent-rig ` +
|
|
338
|
+
'install — run `create-agent-rig init` to install the process layer, or upgrade ' +
|
|
339
|
+
'from the directory that holds the rig.');
|
|
340
|
+
return {
|
|
341
|
+
kind,
|
|
342
|
+
fromVersion: manifest?.version ?? null,
|
|
343
|
+
toVersion: await packageVersion(),
|
|
344
|
+
bootstrapped: manifest === null,
|
|
345
|
+
actions,
|
|
346
|
+
wiring,
|
|
347
|
+
wiringByPath,
|
|
348
|
+
contents,
|
|
349
|
+
manifest: {
|
|
350
|
+
version: await packageVersion(),
|
|
351
|
+
kind,
|
|
352
|
+
project,
|
|
353
|
+
stacks: [...stacks],
|
|
354
|
+
files: nextFiles,
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Write the plan: the `update` and `new` files, then the manifest. Everything
|
|
360
|
+
* else in the plan is a sentence for a human, not an edit.
|
|
361
|
+
*/
|
|
362
|
+
export async function applyUpgrade(repoDir, plan, options = {}) {
|
|
363
|
+
const written = [];
|
|
364
|
+
if (options.dryRun === true)
|
|
365
|
+
return { written };
|
|
366
|
+
for (const action of plan.actions) {
|
|
367
|
+
if (action.verdict !== 'update' && action.verdict !== 'new')
|
|
368
|
+
continue;
|
|
369
|
+
const content = plan.contents.get(action.rel);
|
|
370
|
+
// Never a silent empty file: a missing entry is a defect in the plan, and
|
|
371
|
+
// truncating somebody's rule file is the worst way to report one.
|
|
372
|
+
if (content === undefined) {
|
|
373
|
+
throw new UpgradeError(`Internal: no content planned for "${action.rel}" — nothing written.`);
|
|
374
|
+
}
|
|
375
|
+
const dest = onDisk(repoDir, action.rel);
|
|
376
|
+
await mkdir(path.dirname(dest), { recursive: true });
|
|
377
|
+
await writeFile(dest, content);
|
|
378
|
+
written.push(action.rel);
|
|
379
|
+
}
|
|
380
|
+
await writeManifest(repoDir, plan.manifest);
|
|
381
|
+
return { written };
|
|
382
|
+
}
|