create-agent-rig 0.4.0 → 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 +207 -8
- package/README.md +50 -17
- package/package.json +2 -1
- package/packages/cli/dist/commands/create.js +8 -3
- package/packages/cli/dist/commands/init.js +82 -34
- package/packages/cli/dist/commands/upgrade.js +112 -30
- package/packages/cli/dist/index.js +38 -14
- package/packages/cli/dist/lib/copy-tree.js +35 -6
- package/packages/cli/dist/lib/init-settings.js +12 -0
- package/packages/cli/dist/lib/install-set.js +6 -8
- package/packages/cli/dist/lib/manifest.js +21 -9
- package/packages/cli/dist/lib/safe-path.js +30 -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 +378 -31
- 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 -40
- 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 +2 -1
- 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,11 +1,12 @@
|
|
|
1
1
|
import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { initInstallSet, projectNameFor } from './init.js';
|
|
4
|
+
import { hookFilesReferencedIn } from '../lib/init-settings.js';
|
|
4
5
|
import { loadHashHistory, presentInEveryRelease } from '../lib/history.js';
|
|
5
6
|
import { agentOsInstallSet, agentOsLayerDirs } from '../lib/install-set.js';
|
|
6
7
|
import { listTree } from '../lib/copy-tree.js';
|
|
7
8
|
import { readManifest, sha256, writeManifest } from '../lib/manifest.js';
|
|
8
|
-
import { resolveInside } from '../lib/safe-path.js';
|
|
9
|
+
import { isSafeSubstitutionValue, resolveInside } from '../lib/safe-path.js';
|
|
9
10
|
import { detokenizeContent, substituteFileName } from '../lib/substitute.js';
|
|
10
11
|
import { TARGETS } from '../lib/targets.js';
|
|
11
12
|
import { packageVersion } from '../lib/version.js';
|
|
@@ -13,6 +14,8 @@ import { packageVersion } from '../lib/version.js';
|
|
|
13
14
|
export class UpgradeError extends Error {
|
|
14
15
|
}
|
|
15
16
|
const SETTINGS = '.claude/settings.json';
|
|
17
|
+
const CODEX_HOOKS = '.codex/hooks.json';
|
|
18
|
+
const WIRING_PATHS = new Set([SETTINGS, CODEX_HOOKS]);
|
|
16
19
|
/** The universal layer's architecture group — installed by `create`, never by `init`. */
|
|
17
20
|
const ARCHITECTURE_ONLY = [
|
|
18
21
|
'.claude/rules/architecture.md',
|
|
@@ -130,6 +133,30 @@ function isReleasedVersion(history, rel, content, ctx) {
|
|
|
130
133
|
const candidates = new Set([sha256(content), sha256(detokenizeContent(content, ctx))]);
|
|
131
134
|
return known.hashes.some((hash) => candidates.has(hash));
|
|
132
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
|
+
}
|
|
133
160
|
/**
|
|
134
161
|
* What an upgrade would do, decided per file, writing nothing.
|
|
135
162
|
*
|
|
@@ -146,11 +173,37 @@ export async function planUpgrade(repoDir, options = {}) {
|
|
|
146
173
|
? await detectInstall(repoDir)
|
|
147
174
|
: { kind: manifest.kind, stacks: manifest.stacks, region: manifest.project.region };
|
|
148
175
|
const kind = manifest?.kind ?? detected.kind;
|
|
149
|
-
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
|
|
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;
|
|
154
207
|
const project = manifest?.project ?? {
|
|
155
208
|
name: bootstrapName,
|
|
156
209
|
scope: bootstrapName,
|
|
@@ -172,6 +225,7 @@ export async function planUpgrade(repoDir, options = {}) {
|
|
|
172
225
|
const contents = new Map();
|
|
173
226
|
const nextFiles = {};
|
|
174
227
|
let wiring = null;
|
|
228
|
+
const wiringByPath = new Map();
|
|
175
229
|
for (const file of files) {
|
|
176
230
|
const current = await readIfPresent(repoDir, file.rel);
|
|
177
231
|
const recorded = manifest?.files[file.rel];
|
|
@@ -202,36 +256,63 @@ export async function planUpgrade(repoDir, options = {}) {
|
|
|
202
256
|
}
|
|
203
257
|
continue;
|
|
204
258
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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);
|
|
224
280
|
if (current === file.content) {
|
|
225
281
|
actions.push({ rel: file.rel, verdict: 'unchanged' });
|
|
226
282
|
nextFiles[file.rel] = sha256(file.content);
|
|
227
283
|
}
|
|
228
|
-
else if (
|
|
229
|
-
actions.push({
|
|
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
|
+
});
|
|
230
293
|
nextFiles[file.rel] = sha256(file.content);
|
|
231
294
|
}
|
|
232
|
-
else if (
|
|
233
|
-
|
|
234
|
-
|
|
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;
|
|
235
316
|
}
|
|
236
317
|
else {
|
|
237
318
|
actions.push({
|
|
@@ -263,6 +344,7 @@ export async function planUpgrade(repoDir, options = {}) {
|
|
|
263
344
|
bootstrapped: manifest === null,
|
|
264
345
|
actions,
|
|
265
346
|
wiring,
|
|
347
|
+
wiringByPath,
|
|
266
348
|
contents,
|
|
267
349
|
manifest: {
|
|
268
350
|
version: await packageVersion(),
|
|
@@ -4,13 +4,14 @@ import { CreateError, createProject } from './commands/create.js';
|
|
|
4
4
|
import { InitError, initFileContents, initProject, planInit } from './commands/init.js';
|
|
5
5
|
import { UpgradeError, applyUpgrade, planUpgrade } from './commands/upgrade.js';
|
|
6
6
|
import { makePalette } from './lib/colors.js';
|
|
7
|
+
import { readManifest } from './lib/manifest.js';
|
|
7
8
|
import { promptConfirm, promptTarget } from './lib/prompts.js';
|
|
8
9
|
import { collectGovernance, renderSummary } from './lib/summary.js';
|
|
9
10
|
import { DEFAULT_TARGET, TARGET_NAMES } from './lib/targets.js';
|
|
10
11
|
import { packageVersion } from './lib/version.js';
|
|
11
12
|
const USAGE = `Usage: create-agent-rig <dir> [options]
|
|
12
13
|
|
|
13
|
-
Scaffolds a new project into <dir>: agent operating system
|
|
14
|
+
Scaffolds a new project into <dir>: a Claude Code + Codex agent operating system
|
|
14
15
|
plus a runnable code skeleton. Refuses to write into a non-empty directory.
|
|
15
16
|
|
|
16
17
|
Options
|
|
@@ -22,9 +23,12 @@ Options
|
|
|
22
23
|
--version print the version
|
|
23
24
|
-h, --help this text
|
|
24
25
|
|
|
25
|
-
Also: create-agent-rig init [--dry-run]
|
|
26
|
+
Also: create-agent-rig init [--dry-run]
|
|
26
27
|
Install the process layer (rules, gates, stop rules — no architecture
|
|
27
|
-
assumptions) into the CURRENT existing repo. Refuses to clobber CLAUDE.md
|
|
28
|
+
assumptions) into the CURRENT existing repo. Refuses to clobber CLAUDE.md
|
|
29
|
+
or AGENTS.md.
|
|
30
|
+
--force is deprecated: it refuses and points at upgrade, which refreshes a
|
|
31
|
+
rig file by file. It is removed in 0.6.
|
|
28
32
|
|
|
29
33
|
Also: create-agent-rig upgrade [--dry-run] [--yes]
|
|
30
34
|
Bring the rig in the CURRENT repo up to this version. Replaces the files it
|
|
@@ -44,10 +48,23 @@ async function runInit(rawArgs) {
|
|
|
44
48
|
}
|
|
45
49
|
const cwd = process.cwd();
|
|
46
50
|
const dryRun = values['dry-run'] === true;
|
|
51
|
+
// `init` adopts a repo the rig knows nothing about. Run inside a rig `create`
|
|
52
|
+
// generated — reachable when its CLAUDE.md was deleted — it is the wrong
|
|
53
|
+
// command: it installs the process layer alone and never refreshes the stack
|
|
54
|
+
// overlays. Say so before anything is written, so it is visible on --dry-run
|
|
55
|
+
// too.
|
|
56
|
+
//
|
|
57
|
+
// It is a manifest read, so a pre-0.4.0 rig with no manifest gets no advisory
|
|
58
|
+
// even when it came from `create` — the same limit `recordInstall` carries,
|
|
59
|
+
// and stated in both places because either one alone reads as wider.
|
|
60
|
+
const existing = await readManifest(cwd);
|
|
47
61
|
const plan = await planInit(cwd);
|
|
48
62
|
process.stdout.write(`agent-rig init — process layer into ${cwd}\n\n` +
|
|
49
63
|
plan.files.map((f) => ` + ${f.path}`).join('\n') +
|
|
50
64
|
'\n');
|
|
65
|
+
if (existing?.kind === 'create') {
|
|
66
|
+
process.stdout.write(`\n! This rig was created by create-agent-rig — \`init\` only fills gaps here; use \`upgrade\` to refresh.\n`);
|
|
67
|
+
}
|
|
51
68
|
if (plan.conflicts.length > 0) {
|
|
52
69
|
process.stdout.write(`\nAlready present (kept, not overwritten):\n` +
|
|
53
70
|
plan.conflicts.map((c) => ` · ${c}`).join('\n') +
|
|
@@ -61,12 +78,15 @@ async function runInit(rawArgs) {
|
|
|
61
78
|
process.stdout.write(`\nInstalled ${result.written.length} files` +
|
|
62
79
|
(result.skipped.length ? `, kept ${result.skipped.length} existing` : '') +
|
|
63
80
|
'.\n');
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
81
|
+
// A kept harness config silently disables that harness's enforcement: the
|
|
82
|
+
// hooks sit on disk and are never called, while the rules claim they are.
|
|
83
|
+
// Say so loudly, and hand over the exact entries for each affected harness.
|
|
84
|
+
const generated = await initFileContents(cwd);
|
|
85
|
+
for (const wiringPath of ['.claude/settings.json', '.codex/hooks.json']) {
|
|
86
|
+
if (!result.skipped.includes(wiringPath))
|
|
87
|
+
continue;
|
|
88
|
+
const wiring = generated.get(wiringPath) ?? '';
|
|
89
|
+
process.stdout.write(`\n! ${wiringPath} already exists — it was kept, so the rig's hooks are NOT wired there.\n` +
|
|
70
90
|
` Until you merge these entries into it, nothing enforces the rules:\n\n` +
|
|
71
91
|
wiring.replace(/^/gm, ' ') +
|
|
72
92
|
'\n');
|
|
@@ -124,11 +144,15 @@ async function runUpgrade(rawArgs) {
|
|
|
124
144
|
// The one thing this command will not do for you — printed with the plan,
|
|
125
145
|
// because the dry run is where a reader decides whether there is work here,
|
|
126
146
|
// and a report that mentions entries it never shows is not a plan.
|
|
127
|
-
if (plan.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
147
|
+
if (plan.wiringByPath.size > 0) {
|
|
148
|
+
for (const [wiringPath, wiring] of plan.wiringByPath) {
|
|
149
|
+
process.stdout.write(`\n! ${wiringPath} was handed over rather than replaced — the reason is\n` +
|
|
150
|
+
` on its line above. It is hook wiring, so it is never overwritten\n` +
|
|
151
|
+
` without proof the rig wrote those exact bytes.\n` +
|
|
152
|
+
` This version wires it like this; merge in what is missing:\n\n` +
|
|
153
|
+
wiring.replace(/^/gm, ' ') +
|
|
154
|
+
'\n');
|
|
155
|
+
}
|
|
132
156
|
}
|
|
133
157
|
if (values['dry-run'] === true) {
|
|
134
158
|
process.stdout.write('\nDry run — nothing written.\n');
|
|
@@ -25,8 +25,38 @@ function isBinary(buffer) {
|
|
|
25
25
|
}
|
|
26
26
|
export async function copyTree(srcDir, destDir, options = {}) {
|
|
27
27
|
const ignore = new Set(options.ignore ?? DEFAULT_IGNORE);
|
|
28
|
+
const files = [];
|
|
28
29
|
await mkdir(destDir, { recursive: true });
|
|
29
|
-
await
|
|
30
|
+
await collectCopyTasks(srcDir, destDir, '', { ...options, ignore }, files);
|
|
31
|
+
await mapConcurrent(files, 16, ({ srcPath, destPath, relPath }) => copyFileEntry(srcPath, destPath, relPath, { ...options, ignore }));
|
|
32
|
+
}
|
|
33
|
+
/** Run independent async work through a bounded pool while preserving result order. */
|
|
34
|
+
export async function mapConcurrent(items, limit, worker) {
|
|
35
|
+
if (!Number.isInteger(limit) || limit < 1)
|
|
36
|
+
throw new RangeError('concurrency limit must be positive');
|
|
37
|
+
const results = new Array(items.length);
|
|
38
|
+
let next = 0;
|
|
39
|
+
let failed = false;
|
|
40
|
+
let firstError;
|
|
41
|
+
const run = async () => {
|
|
42
|
+
while (!failed && next < items.length) {
|
|
43
|
+
const index = next;
|
|
44
|
+
next += 1;
|
|
45
|
+
try {
|
|
46
|
+
results[index] = await worker(items[index]);
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (!failed) {
|
|
50
|
+
failed = true;
|
|
51
|
+
firstError = error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => run()));
|
|
57
|
+
if (failed)
|
|
58
|
+
throw firstError;
|
|
59
|
+
return results;
|
|
30
60
|
}
|
|
31
61
|
/**
|
|
32
62
|
* The destination-relative file paths {@link copyTree} would produce — same
|
|
@@ -62,7 +92,7 @@ export async function listTreeEntries(srcDir, options = {}) {
|
|
|
62
92
|
await walk(srcDir, '');
|
|
63
93
|
return files;
|
|
64
94
|
}
|
|
65
|
-
async function
|
|
95
|
+
async function collectCopyTasks(srcDir, destDir, relDir, options, files) {
|
|
66
96
|
const entries = await readdir(srcDir, { withFileTypes: true });
|
|
67
97
|
for (const entry of entries) {
|
|
68
98
|
if (options.ignore.has(entry.name))
|
|
@@ -73,10 +103,10 @@ async function copyDir(srcDir, destDir, relDir, options) {
|
|
|
73
103
|
const relPath = path.join(relDir, entry.name);
|
|
74
104
|
if (entry.isDirectory()) {
|
|
75
105
|
await mkdir(destPath, { recursive: true });
|
|
76
|
-
await
|
|
106
|
+
await collectCopyTasks(srcPath, destPath, relPath, options, files);
|
|
77
107
|
}
|
|
78
108
|
else if (entry.isFile()) {
|
|
79
|
-
|
|
109
|
+
files.push({ srcPath, destPath, relPath });
|
|
80
110
|
}
|
|
81
111
|
// Symlinks and other special entries are intentionally not copied:
|
|
82
112
|
// templates are plain trees.
|
|
@@ -87,7 +117,7 @@ async function copyFileEntry(srcPath, destPath, relPath, options) {
|
|
|
87
117
|
await copyFile(srcPath, destPath); // copyFile preserves the mode by itself
|
|
88
118
|
return;
|
|
89
119
|
}
|
|
90
|
-
const buffer = await readFile(srcPath);
|
|
120
|
+
const [buffer, { mode }] = await Promise.all([readFile(srcPath), stat(srcPath)]);
|
|
91
121
|
if (isBinary(buffer)) {
|
|
92
122
|
await writeFile(destPath, buffer);
|
|
93
123
|
}
|
|
@@ -96,6 +126,5 @@ async function copyFileEntry(srcPath, destPath, relPath, options) {
|
|
|
96
126
|
}
|
|
97
127
|
// writeFile does NOT preserve permissions — restore them (chmod ignores umask),
|
|
98
128
|
// otherwise executable template files (scripts, hooks) arrive non-executable.
|
|
99
|
-
const { mode } = await stat(srcPath);
|
|
100
129
|
await chmod(destPath, mode & 0o777);
|
|
101
130
|
}
|
|
@@ -13,6 +13,18 @@
|
|
|
13
13
|
*/
|
|
14
14
|
/** Matches the hook file a wired command runs, e.g. `.claude/hooks/guard-bash.mjs`. */
|
|
15
15
|
const HOOK_REFERENCE = /\.claude\/hooks\/[A-Za-z0-9._-]+\.mjs/;
|
|
16
|
+
/**
|
|
17
|
+
* Every hook file some command in this settings text would run.
|
|
18
|
+
*
|
|
19
|
+
* Deliberately a scan of the raw text rather than a walk of the parsed shape:
|
|
20
|
+
* the caller compares two settings files to ask whether one stops calling a
|
|
21
|
+
* hook the other called, and a shape this module does not understand must not
|
|
22
|
+
* make that question answer "nothing was wired". The same expression decides
|
|
23
|
+
* both, so the filter and the comparison cannot drift apart.
|
|
24
|
+
*/
|
|
25
|
+
export function hookFilesReferencedIn(settingsText) {
|
|
26
|
+
return new Set(settingsText.match(new RegExp(HOOK_REFERENCE, 'g')) ?? []);
|
|
27
|
+
}
|
|
16
28
|
function isRecord(value) {
|
|
17
29
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
18
30
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
|
-
import { listTreeEntries } from './copy-tree.js';
|
|
2
|
+
import { listTreeEntries, mapConcurrent } from './copy-tree.js';
|
|
3
3
|
import { substituteContent, substituteFileName } from './substitute.js';
|
|
4
4
|
import { agentOsStackDir, agentOsUniversalDir } from '../templates.js';
|
|
5
5
|
/**
|
|
@@ -34,13 +34,11 @@ export async function agentOsInstallSet(stacks, ctx) {
|
|
|
34
34
|
const entries = await listTreeEntries(layer.dir, {
|
|
35
35
|
transformName: (name) => substituteFileName(name, ctx),
|
|
36
36
|
});
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
});
|
|
43
|
-
}
|
|
37
|
+
files.push(...(await mapConcurrent(entries, 16, async (entry) => ({
|
|
38
|
+
rel: entry.rel,
|
|
39
|
+
source: entry.source,
|
|
40
|
+
content: substituteContent(await readFile(entry.source, 'utf8'), ctx),
|
|
41
|
+
}))));
|
|
44
42
|
}
|
|
45
43
|
return files;
|
|
46
44
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import {
|
|
4
|
+
import { isSafeSubstitutionValue } from './safe-path.js';
|
|
5
5
|
/**
|
|
6
6
|
* The install manifest: what this rig installed, at which version, and the
|
|
7
7
|
* hash each file had when it was written.
|
|
@@ -54,18 +54,30 @@ export function parseManifest(raw) {
|
|
|
54
54
|
typeof project.region !== 'string') {
|
|
55
55
|
return null;
|
|
56
56
|
}
|
|
57
|
-
// Values, not just types.
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
|
|
57
|
+
// Values, not just types. This file is committed, so it reaches a
|
|
58
|
+
// maintainer's disk through a pull request. A name of `../..` would send a
|
|
59
|
+
// write out of the repository — and `name` and `region` go further than
|
|
60
|
+
// paths: they are substituted into installed **files**, where a quote closes
|
|
61
|
+
// the string literal `stop-flag.mjs` embeds the name in, which `guard-bash`
|
|
62
|
+
// imports on every Bash call. (`scope` reaches no template today and a
|
|
63
|
+
// `stacks` entry names an overlay *directory*.) An unsafe value invalidates
|
|
64
|
+
// the whole manifest rather than being quietly corrected into something
|
|
65
|
+
// plausible.
|
|
66
|
+
//
|
|
67
|
+
// One check, not two: `isSafeSubstitutionValue` is strictly stronger than
|
|
68
|
+
// `isSafeSegment` here — its first character excludes `.`, and its class
|
|
69
|
+
// admits neither `/` nor `\` nor `\0` — so pairing them would leave a second
|
|
70
|
+
// predicate that can never fire, read as cover, and quietly stop being true
|
|
71
|
+
// if either one moves. `isSafeSegment` still guards every path segment at
|
|
72
|
+
// write time, in `resolveInside`.
|
|
73
|
+
if (!isSafeSubstitutionValue(project.name) || !isSafeSubstitutionValue(project.scope)) {
|
|
63
74
|
return null;
|
|
64
|
-
|
|
75
|
+
}
|
|
76
|
+
if (project.region !== '' && !isSafeSubstitutionValue(project.region))
|
|
65
77
|
return null;
|
|
66
78
|
if (!Array.isArray(m.stacks) || m.stacks.some((s) => typeof s !== 'string'))
|
|
67
79
|
return null;
|
|
68
|
-
if (m.stacks.some((s) => !
|
|
80
|
+
if (m.stacks.some((s) => !isSafeSubstitutionValue(s)))
|
|
69
81
|
return null;
|
|
70
82
|
if (!isStringRecord(m.files))
|
|
71
83
|
return null;
|
|
@@ -16,6 +16,36 @@ export function isSafeSegment(value) {
|
|
|
16
16
|
!value.includes('\\') &&
|
|
17
17
|
!value.includes('\0'));
|
|
18
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* A value that can be substituted into an **installed file** without changing
|
|
21
|
+
* what that file means.
|
|
22
|
+
*
|
|
23
|
+
* 🔴 **Why this is not {@link isSafeSegment}, and why widening that one instead
|
|
24
|
+
* would be wrong.** `isSafeSegment` answers "can this steer a path", and it is
|
|
25
|
+
* what {@link resolveInside} holds every path segment to when an upgrade
|
|
26
|
+
* writes — `CLAUDE.md` and `.claude/rules/workflow.md` have to keep passing it,
|
|
27
|
+
* so it cannot become this whitelist. But `project.name` is substituted into
|
|
28
|
+
* `.claude/scripts/stop-flag.mjs` **inside a single-quoted JavaScript string
|
|
29
|
+
* literal**, and `guard-bash` imports that module on every Bash call. A value
|
|
30
|
+
* that steers no path at all still closes that quote: it reaches code
|
|
31
|
+
* execution in the hook process, and it silently disarms the kill switch,
|
|
32
|
+
* because the paths it computes stop pointing at `~/.claude/<name>-loop-STOP`.
|
|
33
|
+
* A brake that looks installed and is not is the worst of the two.
|
|
34
|
+
*
|
|
35
|
+
* So the rule is a whitelist, not a blacklist of the payloads anyone thought
|
|
36
|
+
* of. It costs nothing real: `create` already refuses anything outside this
|
|
37
|
+
* shape, and `projectNameFor` only ever emits `[a-z0-9._-]` — plus a leading
|
|
38
|
+
* `_`, which is why the first character allows it.
|
|
39
|
+
*
|
|
40
|
+
* One legitimate value it does reject on purpose: the **empty** region, which
|
|
41
|
+
* `init` writes to mean "no region". That carve-out belongs to the caller
|
|
42
|
+
* (`parseManifest`), not here — an empty string is exactly what a whitelist of
|
|
43
|
+
* substitutable characters should refuse, and folding "or empty" into this
|
|
44
|
+
* predicate would hand it to `name` and `scope` as well.
|
|
45
|
+
*/
|
|
46
|
+
export function isSafeSubstitutionValue(value) {
|
|
47
|
+
return /^[a-z0-9_][a-z0-9._-]*$/.test(value);
|
|
48
|
+
}
|
|
19
49
|
/**
|
|
20
50
|
* `rel` resolved under `root`, or `null` when it would land anywhere else —
|
|
21
51
|
* including an absolute path, an empty path, and the classic sibling
|