create-agent-rig 0.9.0 → 0.10.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 +466 -3
- package/README.md +267 -299
- package/package.json +8 -14
- package/packages/cli/dist/commands/create.js +56 -88
- package/packages/cli/dist/commands/doctor.js +213 -0
- package/packages/cli/dist/commands/init.js +151 -54
- package/packages/cli/dist/commands/integrations.js +468 -0
- package/packages/cli/dist/commands/memory.js +63 -4
- package/packages/cli/dist/commands/setup-wizard.js +61 -0
- package/packages/cli/dist/commands/uninstall.js +1223 -0
- package/packages/cli/dist/commands/upgrade.js +489 -89
- package/packages/cli/dist/index.js +657 -49
- package/packages/cli/dist/integrations/declaration.js +158 -0
- package/packages/cli/dist/integrations/doctor-guards.js +126 -0
- package/packages/cli/dist/integrations/doctor-workflow.js +25 -0
- package/packages/cli/dist/integrations/mcp-json.js +107 -0
- package/packages/cli/dist/integrations/memory-doctor.js +124 -0
- package/packages/cli/dist/integrations/registry.js +22 -0
- package/packages/cli/dist/integrations/spawn.js +228 -0
- package/packages/cli/dist/integrations/spec-kit.js +280 -0
- package/packages/cli/dist/integrations/verify.js +193 -0
- package/packages/cli/dist/integrations/windows-job.js +275 -0
- package/packages/cli/dist/lib/elevated-paths.js +74 -0
- package/packages/cli/dist/lib/install-set.js +1 -44
- package/packages/cli/dist/lib/manifest.js +86 -9
- package/packages/cli/dist/lib/prompts.js +0 -23
- package/packages/cli/dist/lib/safe-path.js +193 -2
- package/packages/cli/dist/lib/safe-text.js +78 -0
- package/packages/cli/dist/lib/substitute.js +1 -52
- package/packages/cli/dist/lib/summary.js +5 -5
- package/packages/cli/dist/templates.js +4 -14
- package/scripts/prepare.mjs +28 -12
- package/templates/agent-os/subagent-routing.json +4 -4
- package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +13 -9
- package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +31 -6
- package/templates/agent-os/universal/.agents/skills/new-invariant/SKILL.md +5 -5
- package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +1 -1
- package/templates/agent-os/universal/.agents/skills/worktree-task/SKILL.md +3 -3
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +8 -6
- package/templates/agent-os/universal/.claude/agents/implementation-agent.md +41 -0
- package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +8 -4
- package/templates/agent-os/universal/.claude/agents/security-scanner.md +4 -2
- package/templates/agent-os/universal/.claude/agents/test-writer.md +2 -2
- package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +83 -4
- package/templates/agent-os/universal/.claude/rules/autonomy.md +55 -27
- package/templates/agent-os/universal/.claude/rules/invariants.md +9 -8
- package/templates/agent-os/universal/.claude/rules/workflow.md +47 -22
- package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1 -1
- package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +12 -9
- package/templates/agent-os/universal/.claude/scripts/doctor.mjs +100 -2
- package/templates/agent-os/universal/.claude/scripts/lib/claim-records.mjs +236 -6
- package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +43 -0
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +18 -1
- package/templates/agent-os/universal/.claude/scripts/queue/state.mjs +2 -2
- package/templates/agent-os/universal/.claude/scripts/reconcile-external-prs.mjs +273 -35
- package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +16 -0
- package/templates/agent-os/universal/.claude/settings.json +0 -8
- package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +13 -9
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +31 -6
- package/templates/agent-os/universal/.claude/skills/new-invariant/SKILL.md +5 -5
- package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +1 -1
- package/templates/agent-os/universal/.claude/skills/worktree-task/SKILL.md +3 -3
- package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +2 -2
- package/templates/agent-os/universal/.codex/agents/implementation-agent.toml +6 -0
- package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +2 -2
- package/templates/agent-os/universal/.codex/agents/security-scanner.toml +1 -1
- package/templates/agent-os/universal/.codex/agents/test-writer.toml +1 -1
- package/templates/agent-os/universal/.codex/hooks.json +0 -10
- package/templates/agent-os/universal/AGENTS.md +208 -107
- package/templates/agent-os/universal/CLAUDE.md +15 -162
- package/templates/agent-os/universal/PLAN.md +17 -10
- package/templates/agent-os/universal/docs/decisions/agents-md-canonical.md +313 -0
- package/templates/agent-os/universal/docs/decisions/codex-adapter.md +15 -10
- package/templates/agent-os/universal/docs/decisions/review-lanes.md +12 -8
- package/templates/agent-os/universal/docs/decisions/session-start-wire-format.md +206 -0
- package/templates/agent-os/universal/docs/decisions/subagent-routing.md +6 -3
- package/templates/agent-os/universal/docs/decisions/workflow-layer-split.md +235 -0
- package/templates/agent-os/universal/layers.json +25 -30
- package/templates/hash-history.json +73 -24
- package/templates/release-ledger.json +3 -1
- package/packages/cli/dist/lib/composition.js +0 -20
- package/packages/cli/dist/lib/targets.js +0 -28
- package/packages/cli/dist/policy/benchmark/corpus.js +0 -165
- package/packages/cli/dist/policy/core/adapter.js +0 -18
- package/packages/cli/dist/policy/core/coverage.js +0 -253
- package/packages/cli/dist/policy/core/decision-record.js +0 -287
- package/packages/cli/dist/policy/core/declaration.js +0 -127
- package/packages/cli/dist/policy/core/evidence-matrix.js +0 -94
- package/packages/cli/dist/policy/core/probe.js +0 -442
- package/packages/cli/dist/policy/core/registry.js +0 -115
- package/packages/cli/dist/policy/core/validation.js +0 -275
- package/packages/cli/dist/policy/core/vocabulary.js +0 -123
- package/packages/cli/dist/policy/harness/claude.js +0 -47
- package/packages/cli/dist/policy/harness/codex.js +0 -87
- package/packages/cli/dist/policy/harness/index.js +0 -15
- package/packages/cli/dist/policy/harness/shared-hooks.js +0 -28
- package/packages/cli/dist/policy/index.js +0 -17
- package/templates/agent-os/init/AGENTS.md +0 -201
- package/templates/agent-os/init/CLAUDE.md +0 -201
- package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +0 -105
- package/templates/agent-os/stack/aws-cdk/.agents/skills/ro-debug/SKILL.md +0 -117
- package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +0 -89
- package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +0 -105
- package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +0 -105
- package/templates/agent-os/stack/aws-cdk/.claude/skills/ro-debug/SKILL.md +0 -117
- package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +0 -6
- package/templates/agent-os/stack/node-ts/.claude/hooks/dod-checks.json +0 -1
- package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +0 -85
- package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +0 -89
- package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +0 -68
- package/templates/agent-os/universal/.claude/rules/architecture.md +0 -81
- package/templates/skeleton/aws-serverless/.github/workflows/ci.yml +0 -28
- package/templates/skeleton/aws-serverless/.github/workflows/deploy.yml +0 -90
- package/templates/skeleton/aws-serverless/README.md +0 -181
- package/templates/skeleton/aws-serverless/apps/web/next.config.mjs +0 -17
- package/templates/skeleton/aws-serverless/apps/web/package.json +0 -19
- package/templates/skeleton/aws-serverless/apps/web/src/app/layout.tsx +0 -17
- package/templates/skeleton/aws-serverless/apps/web/src/app/page.tsx +0 -96
- package/templates/skeleton/aws-serverless/apps/web/src/lib/api.ts +0 -36
- package/templates/skeleton/aws-serverless/apps/web/src/lib/validate.ts +0 -23
- package/templates/skeleton/aws-serverless/apps/web/test/shared-validation.test.ts +0 -38
- package/templates/skeleton/aws-serverless/apps/web/tsconfig.json +0 -14
- package/templates/skeleton/aws-serverless/eslint.config.mjs +0 -20
- package/templates/skeleton/aws-serverless/gitignore +0 -56
- package/templates/skeleton/aws-serverless/infra/bin/app.ts +0 -79
- package/templates/skeleton/aws-serverless/infra/cdk.json +0 -3
- package/templates/skeleton/aws-serverless/infra/lib/app-stack.ts +0 -248
- package/templates/skeleton/aws-serverless/infra/lib/web-stack.ts +0 -45
- package/templates/skeleton/aws-serverless/infra/package.json +0 -18
- package/templates/skeleton/aws-serverless/infra/test/allowed-origins.test.ts +0 -301
- package/templates/skeleton/aws-serverless/infra/test/app-composition.test.ts +0 -137
- package/templates/skeleton/aws-serverless/infra/test/app-stack.test.ts +0 -120
- package/templates/skeleton/aws-serverless/infra/test/web-stack.test.ts +0 -44
- package/templates/skeleton/aws-serverless/package.json +0 -30
- package/templates/skeleton/aws-serverless/packages/core/package.json +0 -11
- package/templates/skeleton/aws-serverless/packages/core/src/events.ts +0 -14
- package/templates/skeleton/aws-serverless/packages/core/src/index.ts +0 -15
- package/templates/skeleton/aws-serverless/packages/core/src/note.ts +0 -69
- package/templates/skeleton/aws-serverless/packages/core/test/events.test.ts +0 -23
- package/templates/skeleton/aws-serverless/packages/core/test/note.test.ts +0 -101
- package/templates/skeleton/aws-serverless/packages/db/package.json +0 -14
- package/templates/skeleton/aws-serverless/packages/db/src/client.ts +0 -17
- package/templates/skeleton/aws-serverless/packages/db/src/index.ts +0 -2
- package/templates/skeleton/aws-serverless/packages/db/src/note-model.ts +0 -52
- package/templates/skeleton/aws-serverless/packages/db/test/note-model.test.ts +0 -91
- package/templates/skeleton/aws-serverless/packages/shared/package.json +0 -11
- package/templates/skeleton/aws-serverless/packages/shared/src/env.ts +0 -17
- package/templates/skeleton/aws-serverless/packages/shared/src/errors.ts +0 -33
- package/templates/skeleton/aws-serverless/packages/shared/src/index.ts +0 -3
- package/templates/skeleton/aws-serverless/packages/shared/src/logger.ts +0 -20
- package/templates/skeleton/aws-serverless/packages/shared/test/env.test.ts +0 -26
- package/templates/skeleton/aws-serverless/packages/shared/test/errors.test.ts +0 -28
- package/templates/skeleton/aws-serverless/packages/shared/test/logger.test.ts +0 -19
- package/templates/skeleton/aws-serverless/pnpm-lock.yaml +0 -2855
- package/templates/skeleton/aws-serverless/pnpm-workspace.yaml +0 -14
- package/templates/skeleton/aws-serverless/services/api/package.json +0 -15
- package/templates/skeleton/aws-serverless/services/api/src/adapters/sqs-publisher.ts +0 -26
- package/templates/skeleton/aws-serverless/services/api/src/handlers/create-note.ts +0 -69
- package/templates/skeleton/aws-serverless/services/api/src/handlers/list-notes.ts +0 -37
- package/templates/skeleton/aws-serverless/services/api/src/list-main.ts +0 -12
- package/templates/skeleton/aws-serverless/services/api/src/main.ts +0 -21
- package/templates/skeleton/aws-serverless/services/api/src/usecases/create-note.ts +0 -41
- package/templates/skeleton/aws-serverless/services/api/src/usecases/list-notes.ts +0 -14
- package/templates/skeleton/aws-serverless/services/api/test/create-note.handler.test.ts +0 -211
- package/templates/skeleton/aws-serverless/services/api/test/create-note.usecase.test.ts +0 -45
- package/templates/skeleton/aws-serverless/services/api/test/list-notes.test.ts +0 -122
- package/templates/skeleton/aws-serverless/services/api/test/sqs-publisher.test.ts +0 -22
- package/templates/skeleton/aws-serverless/services/worker/package.json +0 -12
- package/templates/skeleton/aws-serverless/services/worker/src/handlers/note-created.ts +0 -15
- package/templates/skeleton/aws-serverless/services/worker/src/main.ts +0 -7
- package/templates/skeleton/aws-serverless/services/worker/src/usecases/process-note-created.ts +0 -37
- package/templates/skeleton/aws-serverless/services/worker/test/note-created.test.ts +0 -61
- package/templates/skeleton/aws-serverless/tsconfig.base.json +0 -15
- package/templates/skeleton/aws-serverless/tsconfig.json +0 -16
- package/templates/skeleton/aws-serverless/vitest.config.ts +0 -14
- package/templates/skeleton/node-service/.github/workflows/ci.yml +0 -27
- package/templates/skeleton/node-service/.github/workflows/deploy.yml +0 -29
- package/templates/skeleton/node-service/README.md +0 -104
- package/templates/skeleton/node-service/apps/web/next.config.mjs +0 -17
- package/templates/skeleton/node-service/apps/web/package.json +0 -19
- package/templates/skeleton/node-service/apps/web/src/app/layout.tsx +0 -17
- package/templates/skeleton/node-service/apps/web/src/app/page.tsx +0 -96
- package/templates/skeleton/node-service/apps/web/src/lib/api.ts +0 -29
- package/templates/skeleton/node-service/apps/web/src/lib/validate.ts +0 -23
- package/templates/skeleton/node-service/apps/web/test/shared-validation.test.ts +0 -38
- package/templates/skeleton/node-service/apps/web/tsconfig.json +0 -14
- package/templates/skeleton/node-service/eslint.config.mjs +0 -20
- package/templates/skeleton/node-service/gitignore +0 -54
- package/templates/skeleton/node-service/package.json +0 -30
- package/templates/skeleton/node-service/packages/core/package.json +0 -11
- package/templates/skeleton/node-service/packages/core/src/events.ts +0 -14
- package/templates/skeleton/node-service/packages/core/src/index.ts +0 -15
- package/templates/skeleton/node-service/packages/core/src/note.ts +0 -69
- package/templates/skeleton/node-service/packages/core/test/events.test.ts +0 -23
- package/templates/skeleton/node-service/packages/core/test/note.test.ts +0 -101
- package/templates/skeleton/node-service/packages/db/package.json +0 -12
- package/templates/skeleton/node-service/packages/db/src/index.ts +0 -1
- package/templates/skeleton/node-service/packages/db/src/note-store.ts +0 -100
- package/templates/skeleton/node-service/packages/db/test/note-store.test.ts +0 -100
- package/templates/skeleton/node-service/packages/shared/package.json +0 -11
- package/templates/skeleton/node-service/packages/shared/src/env.ts +0 -17
- package/templates/skeleton/node-service/packages/shared/src/errors.ts +0 -33
- package/templates/skeleton/node-service/packages/shared/src/index.ts +0 -3
- package/templates/skeleton/node-service/packages/shared/src/logger.ts +0 -20
- package/templates/skeleton/node-service/packages/shared/test/env.test.ts +0 -26
- package/templates/skeleton/node-service/packages/shared/test/errors.test.ts +0 -28
- package/templates/skeleton/node-service/packages/shared/test/logger.test.ts +0 -19
- package/templates/skeleton/node-service/pnpm-lock.yaml +0 -2402
- package/templates/skeleton/node-service/pnpm-workspace.yaml +0 -13
- package/templates/skeleton/node-service/scripts/build-artifact.mjs +0 -34
- package/templates/skeleton/node-service/services/api/package.json +0 -17
- package/templates/skeleton/node-service/services/api/src/adapters/spool-publisher.ts +0 -23
- package/templates/skeleton/node-service/services/api/src/handlers/create-note.ts +0 -40
- package/templates/skeleton/node-service/services/api/src/handlers/list-notes.ts +0 -23
- package/templates/skeleton/node-service/services/api/src/main.ts +0 -40
- package/templates/skeleton/node-service/services/api/src/server.ts +0 -172
- package/templates/skeleton/node-service/services/api/src/static-dir.ts +0 -20
- package/templates/skeleton/node-service/services/api/src/usecases/create-note.ts +0 -30
- package/templates/skeleton/node-service/services/api/src/usecases/list-notes.ts +0 -14
- package/templates/skeleton/node-service/services/api/test/artifact.test.ts +0 -92
- package/templates/skeleton/node-service/services/api/test/create-note.handler.test.ts +0 -64
- package/templates/skeleton/node-service/services/api/test/create-note.usecase.test.ts +0 -43
- package/templates/skeleton/node-service/services/api/test/list-notes.test.ts +0 -48
- package/templates/skeleton/node-service/services/api/test/package-manager.test.ts +0 -40
- package/templates/skeleton/node-service/services/api/test/package-manager.ts +0 -51
- package/templates/skeleton/node-service/services/api/test/server.test.ts +0 -208
- package/templates/skeleton/node-service/services/api/test/spool-publisher.test.ts +0 -32
- package/templates/skeleton/node-service/services/api/test/static-dir.test.ts +0 -29
- package/templates/skeleton/node-service/services/worker/package.json +0 -16
- package/templates/skeleton/node-service/services/worker/src/main.ts +0 -28
- package/templates/skeleton/node-service/services/worker/src/spool.ts +0 -60
- package/templates/skeleton/node-service/services/worker/src/usecases/process-note-created.ts +0 -38
- package/templates/skeleton/node-service/services/worker/test/process-note-created.test.ts +0 -34
- package/templates/skeleton/node-service/services/worker/test/spool.test.ts +0 -76
- package/templates/skeleton/node-service/tsconfig.base.json +0 -15
- package/templates/skeleton/node-service/tsconfig.json +0 -13
- package/templates/skeleton/node-service/vitest.config.ts +0 -12
|
@@ -1,26 +1,39 @@
|
|
|
1
|
-
import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { access, lstat, mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { initInstallSet, projectNameFor } from './init.js';
|
|
3
|
+
import { initInstallSet, initManifest, layerOnlyPaths, projectNameFor } from './init.js';
|
|
4
|
+
import { isReadableRulebook } from '../lib/elevated-paths.js';
|
|
4
5
|
import { hookFilesReferencedIn } from '../lib/init-settings.js';
|
|
5
6
|
import { loadHashHistory, presentInEveryRelease } from '../lib/history.js';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import { isSafeSubstitutionValue, resolveInside } from '../lib/safe-path.js';
|
|
10
|
-
import { detokenizeContent, substituteFileName } from '../lib/substitute.js';
|
|
11
|
-
import { TARGETS } from '../lib/targets.js';
|
|
7
|
+
import { ALL_LAYERS, MANIFEST_REL, readManifest, sha256, writeManifest } from '../lib/manifest.js';
|
|
8
|
+
import { isSafeSubstitutionValue, resolveInside, resolveWritableInside } from '../lib/safe-path.js';
|
|
9
|
+
import { substituteContent } from '../lib/substitute.js';
|
|
12
10
|
import { packageVersion } from '../lib/version.js';
|
|
13
11
|
/** A user-facing failure: message is printed as-is, no stack trace. */
|
|
14
12
|
export class UpgradeError extends Error {
|
|
15
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* The sibling file `upgrade` writes RENDERED (already project-substituted)
|
|
16
|
+
* AGENTS.md bytes into when — and only when — CLAUDE.md's shim adoption is
|
|
17
|
+
* genuinely held back (round 5's content-based rule; see
|
|
18
|
+
* {@link isReadableRulebook}). Never `AGENTS.md` itself, and never recorded
|
|
19
|
+
* in the manifest. See {@link AgentsRescueStatus} for what decides whether
|
|
20
|
+
* this run touches it at all.
|
|
21
|
+
*/
|
|
22
|
+
export const AGENTS_MD_RESCUE = 'AGENTS.md.rig-new';
|
|
16
23
|
const SETTINGS = '.claude/settings.json';
|
|
17
24
|
const CODEX_HOOKS = '.codex/hooks.json';
|
|
18
25
|
const WIRING_PATHS = new Set([SETTINGS, CODEX_HOOKS]);
|
|
19
|
-
|
|
20
|
-
|
|
26
|
+
// Files only the pre-0.10 `create` shape installed. They remain recognition
|
|
27
|
+
// evidence for upgrades even though none of them is shipped by the new single
|
|
28
|
+
// payload. Keeping their names here does not restore a stack or architecture
|
|
29
|
+
// promise; it preserves the identity those old bytes were substituted with.
|
|
30
|
+
const LEGACY_CREATE_MARKERS = [
|
|
21
31
|
'.claude/rules/architecture.md',
|
|
22
32
|
'.claude/hooks/guard-core-purity.mjs',
|
|
23
33
|
'.claude/hooks/guard-web-boundary.mjs',
|
|
34
|
+
'.claude/rules/node-ts.md',
|
|
35
|
+
'.claude/rules/aws-cdk.md',
|
|
36
|
+
'.claude/agents/cdk-diff-reviewer.md',
|
|
24
37
|
];
|
|
25
38
|
async function exists(p) {
|
|
26
39
|
try {
|
|
@@ -31,6 +44,90 @@ async function exists(p) {
|
|
|
31
44
|
return false;
|
|
32
45
|
}
|
|
33
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Whether `p` is a REGULAR file — `lstat`, never `access`, and never
|
|
49
|
+
* following a symlink. Quorum evidence (`detectLayersOnDisk`) uses this
|
|
50
|
+
* rather than `exists`: a directory or a symlink sitting at a workflow-layer
|
|
51
|
+
* path is not a file this release ever installed there, and counting it
|
|
52
|
+
* toward the quorum would let something that is not actually one of the
|
|
53
|
+
* layer's files push an unrelated layer over the threshold. `lstat` (not
|
|
54
|
+
* `stat`) so a symlink itself is correctly seen as "not a regular file"
|
|
55
|
+
* rather than resolved through to whatever it points at.
|
|
56
|
+
*/
|
|
57
|
+
async function isRegularFile(p) {
|
|
58
|
+
try {
|
|
59
|
+
return (await lstat(p)).isFile();
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The quorum a bootstrapped, opt-in layer must clear to be adopted: MORE
|
|
67
|
+
* THAN HALF of its own files already on disk (RP-180 round 4, blocker A).
|
|
68
|
+
*
|
|
69
|
+
* Chosen from the two populations this has to tell apart, not from either
|
|
70
|
+
* one's exact size: a genuine workflow rig with a deleted or corrupted
|
|
71
|
+
* manifest still has (nearly) all ~33 of its files on disk — comfortably
|
|
72
|
+
* over half. A Core-only rig that happens to have a stray file sharing a
|
|
73
|
+
* workflow-layer path (a hand-placed `journal/README.md`, a `.claude/
|
|
74
|
+
* queue.json` written for something unrelated) has one, or a small handful
|
|
75
|
+
* — comfortably under half. The threshold does not need to sit close to
|
|
76
|
+
* either population; it needs to separate them, and a strict majority does
|
|
77
|
+
* that with room on both sides. Pinned in `packages/cli/test/upgrade.test.ts`
|
|
78
|
+
* › `describe('upgrade — the layer-adoption quorum on the bootstrapped path
|
|
79
|
+
* (RP-180 round 4, blocker A)')`.
|
|
80
|
+
*/
|
|
81
|
+
export const LAYER_ADOPTION_QUORUM = 0.5;
|
|
82
|
+
/**
|
|
83
|
+
* Which layer(s) to treat as installed when there is no manifest to read at
|
|
84
|
+
* all — `readManifest` returns `null` both for a genuinely missing file and
|
|
85
|
+
* for one `parseManifest` voided over ANY invalid field (a corrupt `layers`,
|
|
86
|
+
* an unsafe `version`, a non-array `stacks`, …), so this path is reached far
|
|
87
|
+
* more often than "this rig predates the manifest" alone.
|
|
88
|
+
*
|
|
89
|
+
* Two defects lived here in turn. The first (round 3): the fallback was
|
|
90
|
+
* `ALL_LAYERS` unconditionally — `presentInEveryRelease` below does not save
|
|
91
|
+
* it, since that guard only covers paths the hash history already has an
|
|
92
|
+
* entry for, and a workflow file added for the first time has none. The
|
|
93
|
+
* second (round 4, blocker A): the round-3 fix asked only "does AT LEAST ONE
|
|
94
|
+
* of this layer's files exist", so a Core-only rig with a single stray file
|
|
95
|
+
* sharing a workflow-layer path — the user's own `journal/README.md` is
|
|
96
|
+
* enough — re-adopted the WHOLE ~33-file layer the moment the manifest broke
|
|
97
|
+
* for any reason.
|
|
98
|
+
*
|
|
99
|
+
* The fix is a quorum, not a presence check: an opt-in layer is adopted only
|
|
100
|
+
* when {@link LAYER_ADOPTION_QUORUM} of its own files are already there.
|
|
101
|
+
* `process` is never put through this — it is not opt-in, and is normalised
|
|
102
|
+
* into the result unconditionally below, so a `layers` this function returns
|
|
103
|
+
* can never lack it (a `["workflow"]`-only result would leave every
|
|
104
|
+
* enforcement hook unowned). Below quorum, a layer's stray files are left
|
|
105
|
+
* alone entirely: not read into the plan, not adopted into the manifest —
|
|
106
|
+
* see the caller in `planUpgrade` for how "adopted" also bounds what gets
|
|
107
|
+
* WRITTEN (an adopted layer's ABSENT files are still never created).
|
|
108
|
+
*/
|
|
109
|
+
export async function detectLayersOnDisk(repoDir) {
|
|
110
|
+
const notes = [];
|
|
111
|
+
const adopted = new Set();
|
|
112
|
+
for (const layer of ALL_LAYERS) {
|
|
113
|
+
if (layer === 'process')
|
|
114
|
+
continue; // never opt-in; normalised in below regardless of measurement
|
|
115
|
+
const paths = await layerOnlyPaths(layer);
|
|
116
|
+
let present = 0;
|
|
117
|
+
for (const rel of paths) {
|
|
118
|
+
if (await isRegularFile(onDisk(repoDir, rel)))
|
|
119
|
+
present += 1;
|
|
120
|
+
}
|
|
121
|
+
const isAdopted = present > paths.length * LAYER_ADOPTION_QUORUM;
|
|
122
|
+
notes.push({ layer, present, total: paths.length, adopted: isAdopted });
|
|
123
|
+
if (isAdopted)
|
|
124
|
+
adopted.add(layer);
|
|
125
|
+
}
|
|
126
|
+
// `process` is always present in the result — never a measurement, a
|
|
127
|
+
// normalisation, so a bootstrapped `layers` can never lack Core.
|
|
128
|
+
const layers = ['process', ...adopted];
|
|
129
|
+
return { layers, notes };
|
|
130
|
+
}
|
|
34
131
|
/**
|
|
35
132
|
* Where `rel` lives inside the rig — refused outright if it lands anywhere
|
|
36
133
|
* else. Nothing should be able to produce such a path once the manifest is
|
|
@@ -45,6 +142,13 @@ function onDisk(repoDir, rel) {
|
|
|
45
142
|
}
|
|
46
143
|
return dest;
|
|
47
144
|
}
|
|
145
|
+
async function writableOnDisk(repoDir, rel) {
|
|
146
|
+
const dest = await resolveWritableInside(repoDir, rel);
|
|
147
|
+
if (dest === null) {
|
|
148
|
+
throw new UpgradeError(`Refusing to touch "${rel}" through a symlink or outside ${repoDir}.`);
|
|
149
|
+
}
|
|
150
|
+
return dest;
|
|
151
|
+
}
|
|
48
152
|
/**
|
|
49
153
|
* The file's bytes, or `null` when it is genuinely **absent**.
|
|
50
154
|
*
|
|
@@ -55,7 +159,7 @@ function onDisk(repoDir, rel) {
|
|
|
55
159
|
*/
|
|
56
160
|
async function readIfPresent(repoDir, rel) {
|
|
57
161
|
try {
|
|
58
|
-
return await readFile(
|
|
162
|
+
return await readFile(await writableOnDisk(repoDir, rel));
|
|
59
163
|
}
|
|
60
164
|
catch (error) {
|
|
61
165
|
if (error.code === 'ENOENT')
|
|
@@ -63,61 +167,68 @@ async function readIfPresent(repoDir, rel) {
|
|
|
63
167
|
throw error;
|
|
64
168
|
}
|
|
65
169
|
}
|
|
66
|
-
/**
|
|
67
|
-
|
|
68
|
-
|
|
170
|
+
/**
|
|
171
|
+
* The bytes THIS release would render for AGENTS.md, for THIS project,
|
|
172
|
+
* right now — the ONE implementation `upgrade` (deciding the rescue file's
|
|
173
|
+
* status) and `uninstall` (recognising a byte-identical rescue file as
|
|
174
|
+
* rig-owned) both call, closing round 4 cycle 4's finding that the two had
|
|
175
|
+
* separate re-renders which could drift apart. `null` only if a future
|
|
176
|
+
* release ever stopped shipping AGENTS.md as a plain substituted file (it
|
|
177
|
+
* does not today).
|
|
178
|
+
*/
|
|
179
|
+
export async function renderedAgentsMd(project, layers) {
|
|
180
|
+
const files = await initManifest(layers);
|
|
181
|
+
const entry = files.find((f) => f.rel === 'AGENTS.md');
|
|
182
|
+
if (entry === undefined || entry.source === null)
|
|
183
|
+
return null;
|
|
184
|
+
const ctx = { projectName: project.name };
|
|
185
|
+
return substituteContent(await readFile(entry.source, 'utf8'), ctx);
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* What is actually sitting at {@link AGENTS_MD_RESCUE}, read ONCE at plan
|
|
189
|
+
* time (round 5, blocker 3) — `absent`, a plain file's bytes, or `unsafe`
|
|
190
|
+
* (a symlink anywhere in the path, a directory sitting at the leaf, or any
|
|
191
|
+
* other non-regular entry). Never throws: `applyUpgrade`'s preflight is what
|
|
192
|
+
* refuses to proceed on `unsafe`, and a healthy rig with unrelated clutter at
|
|
193
|
+
* this path must see `unsafe` reported, not an exception raised, so it can
|
|
194
|
+
* decide (per {@link AgentsRescueStatus}) to simply ignore it.
|
|
195
|
+
*/
|
|
196
|
+
async function readRescueFile(repoDir) {
|
|
197
|
+
let dest;
|
|
198
|
+
try {
|
|
199
|
+
dest = await writableOnDisk(repoDir, AGENTS_MD_RESCUE);
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
return { kind: 'unsafe' };
|
|
203
|
+
}
|
|
204
|
+
let stat;
|
|
205
|
+
try {
|
|
206
|
+
stat = await lstat(dest);
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
if (error.code === 'ENOENT')
|
|
210
|
+
return { kind: 'absent' };
|
|
211
|
+
return { kind: 'unsafe' };
|
|
212
|
+
}
|
|
213
|
+
if (!stat.isFile())
|
|
214
|
+
return { kind: 'unsafe' };
|
|
215
|
+
return { kind: 'file', bytes: await readFile(dest) };
|
|
69
216
|
}
|
|
70
217
|
/**
|
|
71
218
|
* What a rig with no manifest looks like it is, from the files it has.
|
|
72
219
|
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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.
|
|
220
|
+
* RP-177 ships only one payload, but a pre-0.10 rig may still carry files that
|
|
221
|
+
* only `create` installed. Those retired paths are compatibility evidence: the
|
|
222
|
+
* new release never installs them, yet their presence preserves the raw
|
|
223
|
+
* directory identity old `create` substituted (including a trailing `-`).
|
|
224
|
+
* A readable manifest remains the stronger source and bypasses this heuristic.
|
|
84
225
|
*/
|
|
85
226
|
async function detectInstall(repoDir) {
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
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));
|
|
227
|
+
for (const rel of LEGACY_CREATE_MARKERS) {
|
|
228
|
+
if (await exists(onDisk(repoDir, rel)))
|
|
229
|
+
return { kind: 'create', stacks: [], region: '' };
|
|
107
230
|
}
|
|
108
|
-
|
|
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
|
-
});
|
|
231
|
+
return { kind: 'init', stacks: [], region: '' };
|
|
121
232
|
}
|
|
122
233
|
/**
|
|
123
234
|
* Whether these bytes are a released version of this file.
|
|
@@ -126,11 +237,19 @@ async function installSetFor(repoDir, kind, project, stacks) {
|
|
|
126
237
|
* bytes with the project's own values turned back into tokens — released
|
|
127
238
|
* template bytes carry `__PROJECT_NAME__`, installed bytes never do.
|
|
128
239
|
*/
|
|
129
|
-
function isReleasedVersion(history, rel, content,
|
|
240
|
+
function isReleasedVersion(history, rel, content, projectName) {
|
|
130
241
|
const known = history.files[rel];
|
|
131
242
|
if (known === undefined || known.hashes.length === 0)
|
|
132
243
|
return false;
|
|
133
|
-
const candidates = new Set([sha256(content)
|
|
244
|
+
const candidates = new Set([sha256(content)]);
|
|
245
|
+
const decoded = content.toString('utf8');
|
|
246
|
+
// Detokenization is a text operation. Invalid UTF-8 must not be rewritten
|
|
247
|
+
// through replacement characters and then mistaken for released bytes.
|
|
248
|
+
// The raw-byte candidate above remains authoritative either way.
|
|
249
|
+
if (Buffer.from(decoded, 'utf8').equals(content)) {
|
|
250
|
+
const detokenized = projectName === '' ? decoded : decoded.replaceAll(projectName, '__PROJECT_NAME__');
|
|
251
|
+
candidates.add(sha256(detokenized));
|
|
252
|
+
}
|
|
134
253
|
return known.hashes.some((hash) => candidates.has(hash));
|
|
135
254
|
}
|
|
136
255
|
/**
|
|
@@ -167,8 +286,9 @@ async function unwiresAnInstalledHook(repoDir, current, next) {
|
|
|
167
286
|
*/
|
|
168
287
|
export async function planUpgrade(repoDir, options = {}) {
|
|
169
288
|
const manifest = await readManifest(repoDir);
|
|
170
|
-
//
|
|
171
|
-
//
|
|
289
|
+
// Old create-only files are no longer shipped, but while they remain in a
|
|
290
|
+
// pre-0.10 rig they still distinguish its substitution identity. A readable
|
|
291
|
+
// manifest remains authoritative whenever one exists.
|
|
172
292
|
const detected = manifest === null
|
|
173
293
|
? await detectInstall(repoDir)
|
|
174
294
|
: { kind: manifest.kind, stacks: manifest.stacks, region: manifest.project.region };
|
|
@@ -209,28 +329,80 @@ export async function planUpgrade(repoDir, options = {}) {
|
|
|
209
329
|
scope: bootstrapName,
|
|
210
330
|
region: detected.region,
|
|
211
331
|
};
|
|
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
332
|
const history = options.history ?? (await loadHashHistory());
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
333
|
+
// RP-180: which layer(s) this rig is allowed to have refreshed.
|
|
334
|
+
//
|
|
335
|
+
// - A readable manifest is authoritative, and `parseManifest` already
|
|
336
|
+
// resolves its own absence of `layers` to `ALL_LAYERS` (every release
|
|
337
|
+
// before RP-180 shipped one payload, so an old manifest with no such
|
|
338
|
+
// field installed everything) — so `manifest.layers` is never actually
|
|
339
|
+
// `undefined` here.
|
|
340
|
+
// - No manifest at all (`bootstrapped` — `manifest === null`, which covers
|
|
341
|
+
// a genuinely missing file AND one `parseManifest` voided over ANY
|
|
342
|
+
// invalid field, not only a missing `layers` key) reads the candidate
|
|
343
|
+
// set off the disk itself, by quorum — see {@link detectLayersOnDisk}
|
|
344
|
+
// and its own history of what was tried here and why each attempt
|
|
345
|
+
// before it was not enough.
|
|
346
|
+
const inference = manifest === null ? await detectLayersOnDisk(repoDir) : null;
|
|
347
|
+
const layers = manifest?.layers ?? inference.layers;
|
|
348
|
+
// A path an OLDER manifest still names but this rig's OWN recorded layers
|
|
349
|
+
// no longer cover (a manifest hand-edited to drop a layer, or one from a
|
|
350
|
+
// release that shipped a layer this one renamed) falls out of `files`
|
|
351
|
+
// below exactly like a path RP-177 retired outright: never written, never
|
|
352
|
+
// deleted, simply no longer this plan's to manage.
|
|
353
|
+
const files = await initInstallSet(repoDir, project, layers);
|
|
354
|
+
// Blocker A's second half, and round 5's correction to it: even an
|
|
355
|
+
// ADOPTED bootstrapped opt-in layer must never manufacture a file it did
|
|
356
|
+
// not find, but "does not create it" is not the same thing as "forgets it
|
|
357
|
+
// existed". Round 4 dropped an absent adopted-layer path from the plan
|
|
358
|
+
// ENTIRELY, which left the rebuilt manifest with no memory of it at all —
|
|
359
|
+
// the very next ORDINARY upgrade (a readable manifest now) then read the
|
|
360
|
+
// absence as "never installed" and proposed `new`, silently reinstating an
|
|
361
|
+
// operator's deliberate deletion with no note that that is what it was
|
|
362
|
+
// doing. An intact manifest never has this problem: a `files` entry naming
|
|
363
|
+
// a path that is now absent already gets the `deleted` verdict, reason
|
|
364
|
+
// "installed by the rig, removed since — not restored", and carries the
|
|
365
|
+
// path's hash forward into the next manifest so this stays true on every
|
|
366
|
+
// later run too.
|
|
367
|
+
//
|
|
368
|
+
// The fix reuses exactly that path rather than inventing a new manifest
|
|
369
|
+
// field: for an absent file of an ADOPTED bootstrapped opt-in layer,
|
|
370
|
+
// `recorded` below is treated as the hash THIS RELEASE would have
|
|
371
|
+
// installed — the same value a normal install would have recorded for it
|
|
372
|
+
// — instead of `undefined`. That is the one piece of information the
|
|
373
|
+
// per-file logic needs to take the identical `deleted`/"not restored"
|
|
374
|
+
// branch an intact manifest takes; nothing else about that branch changes.
|
|
375
|
+
// A file that IS present is untouched by this: `recorded` stays whatever
|
|
376
|
+
// the (bootstrapped, so absent) manifest says, `undefined`, and the
|
|
377
|
+
// existing present-file logic decides its verdict exactly as before.
|
|
378
|
+
const inferredOptInPaths = new Set();
|
|
379
|
+
if (inference !== null) {
|
|
380
|
+
for (const layer of inference.layers) {
|
|
381
|
+
if (layer === 'process')
|
|
382
|
+
continue;
|
|
383
|
+
for (const rel of await layerOnlyPaths(layer))
|
|
384
|
+
inferredOptInPaths.add(rel);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
224
387
|
const actions = [];
|
|
225
388
|
const contents = new Map();
|
|
226
389
|
const nextFiles = {};
|
|
227
390
|
let wiring = null;
|
|
228
391
|
const wiringByPath = new Map();
|
|
392
|
+
// Only ever consulted by the CLAUDE.md/AGENTS.md coupling below, which runs
|
|
393
|
+
// after this loop and needs each file's on-disk bytes to re-vouch for
|
|
394
|
+
// CLAUDE.md's CURRENT content when it holds the shim back.
|
|
395
|
+
const currentBytesByRel = new Map();
|
|
229
396
|
for (const file of files) {
|
|
230
|
-
const
|
|
231
|
-
|
|
397
|
+
const currentBytes = await readIfPresent(repoDir, file.rel);
|
|
398
|
+
if (currentBytes !== null)
|
|
399
|
+
currentBytesByRel.set(file.rel, currentBytes);
|
|
400
|
+
const recordedInManifest = manifest?.files[file.rel];
|
|
401
|
+
const recorded = recordedInManifest === undefined && currentBytes === null && inferredOptInPaths.has(file.rel)
|
|
402
|
+
? sha256(file.content)
|
|
403
|
+
: recordedInManifest;
|
|
232
404
|
contents.set(file.rel, file.content);
|
|
233
|
-
if (
|
|
405
|
+
if (currentBytes === null) {
|
|
234
406
|
// Evidence, not a command. The manifest is the direct evidence; without
|
|
235
407
|
// one, a path that shipped in *every* release the table covers was there
|
|
236
408
|
// to be removed, so its absence is a decision. A path added later is
|
|
@@ -239,7 +411,16 @@ export async function planUpgrade(repoDir, options = {}) {
|
|
|
239
411
|
actions.push({
|
|
240
412
|
rel: file.rel,
|
|
241
413
|
verdict: 'deleted',
|
|
242
|
-
reason: 'installed by the rig, removed since — not restored'
|
|
414
|
+
reason: 'installed by the rig, removed since — not restored' +
|
|
415
|
+
// Round 4 advisory: this branch fires just as readily on a rig
|
|
416
|
+
// that finished the RP-186 migration long ago (CLAUDE.md already
|
|
417
|
+
// the `@AGENTS.md` shim) as on one mid-migration — "not
|
|
418
|
+
// restored" alone does not say that CLAUDE.md's own import makes
|
|
419
|
+
// AGENTS.md's absence load NO rulebook at all, not merely an old
|
|
420
|
+
// one.
|
|
421
|
+
(file.rel === 'AGENTS.md'
|
|
422
|
+
? ' — CLAUDE.md imports it (`@AGENTS.md`), so no rulebook loads until it is back'
|
|
423
|
+
: ''),
|
|
243
424
|
});
|
|
244
425
|
nextFiles[file.rel] = recorded;
|
|
245
426
|
}
|
|
@@ -256,6 +437,9 @@ export async function planUpgrade(repoDir, options = {}) {
|
|
|
256
437
|
}
|
|
257
438
|
continue;
|
|
258
439
|
}
|
|
440
|
+
const current = currentBytes.toString('utf8');
|
|
441
|
+
const currentHash = sha256(currentBytes);
|
|
442
|
+
const releasedBytes = Buffer.from(file.content, 'utf8');
|
|
259
443
|
// The two limits that keep wiring files' new replaceability from
|
|
260
444
|
// disarming the rig, both measured rather than reasoned about.
|
|
261
445
|
//
|
|
@@ -274,10 +458,10 @@ export async function planUpgrade(repoDir, options = {}) {
|
|
|
274
458
|
const isWiring = WIRING_PATHS.has(file.rel);
|
|
275
459
|
const wouldUnwireAnInstalledHook = isWiring && (await unwiresAnInstalledHook(repoDir, current, file.content));
|
|
276
460
|
const vouched = isWiring
|
|
277
|
-
? recorded !== undefined &&
|
|
278
|
-
: (recorded !== undefined &&
|
|
279
|
-
isReleasedVersion(history, file.rel,
|
|
280
|
-
if (
|
|
461
|
+
? recorded !== undefined && currentHash === recorded
|
|
462
|
+
: (recorded !== undefined && currentHash === recorded) ||
|
|
463
|
+
isReleasedVersion(history, file.rel, currentBytes, project.name);
|
|
464
|
+
if (currentBytes.equals(releasedBytes)) {
|
|
281
465
|
actions.push({ rel: file.rel, verdict: 'unchanged' });
|
|
282
466
|
nextFiles[file.rel] = sha256(file.content);
|
|
283
467
|
}
|
|
@@ -315,17 +499,178 @@ export async function planUpgrade(repoDir, options = {}) {
|
|
|
315
499
|
nextFiles[file.rel] = recorded;
|
|
316
500
|
}
|
|
317
501
|
else {
|
|
502
|
+
// A path `init` found and left alone carries its provenance in `kept`
|
|
503
|
+
// (RP-182): the reason can then say what happened to the file since,
|
|
504
|
+
// instead of only that no release ever shipped these bytes. It names no
|
|
505
|
+
// version — `manifest.version` is rewritten by every upgrade, so it is
|
|
506
|
+
// not the version of the init that kept the file.
|
|
507
|
+
const kept = recorded === undefined ? manifest?.kept?.[file.rel] : undefined;
|
|
508
|
+
const keptReason = (since) => `kept by init (already here, not the rig's bytes), ${since} since — treated as yours`;
|
|
509
|
+
let reason = kept !== undefined
|
|
510
|
+
? keptReason(currentHash === kept ? 'unchanged' : 'edited')
|
|
511
|
+
: recorded === undefined
|
|
512
|
+
? 'not a version this rig ever released — treated as yours'
|
|
513
|
+
: 'edited since it was installed';
|
|
514
|
+
// PR #241 round 2, blocker 3: CLAUDE.md is the one file whose CONTENT
|
|
515
|
+
// decides which rulebook Claude Code reads at all. A CLAUDE.md that is
|
|
516
|
+
// not already the `@AGENTS.md` shim — this release's shim, an older
|
|
517
|
+
// rig's own full rulebook text, or genuinely unrelated content — makes
|
|
518
|
+
// Claude Code read THIS file INSTEAD OF AGENTS.md by default (Claude
|
|
519
|
+
// Code's own docs, "AGENTS.md", the `claude-md-or-agents-md` default).
|
|
520
|
+
// A `conflict` verdict already means "kept, never written"; without
|
|
521
|
+
// this the reader has no way to learn that keeping it also means the
|
|
522
|
+
// canonical AGENTS.md this release ships is not what gets read.
|
|
523
|
+
// The exact first line, not a prefix: `@AGENTS.mdEVIL` and
|
|
524
|
+
// `@AGENTS.md.bak` both satisfy `startsWith('@AGENTS.md')` while being
|
|
525
|
+
// neither Claude Code's import syntax nor anything it reads as one.
|
|
526
|
+
if (file.rel === 'CLAUDE.md' && current.split(/\r?\n/, 1)[0] !== '@AGENTS.md') {
|
|
527
|
+
reason +=
|
|
528
|
+
' — this file is not the `@AGENTS.md` shim, so it shadows AGENTS.md: Claude Code ' +
|
|
529
|
+
'reads it INSTEAD OF AGENTS.md by default. Adopt the shim by hand — replace this ' +
|
|
530
|
+
"file's content with `@AGENTS.md` plus anything Claude-Code-specific (see " +
|
|
531
|
+
'docs/decisions/agents-md-canonical.md) — to have Claude Code read the canonical ' +
|
|
532
|
+
'rulebook again.';
|
|
533
|
+
}
|
|
318
534
|
actions.push({
|
|
319
535
|
rel: file.rel,
|
|
320
536
|
verdict: 'conflict',
|
|
321
|
-
reason
|
|
322
|
-
? 'not a version this rig ever released — treated as yours'
|
|
323
|
-
: 'edited since it was installed',
|
|
537
|
+
reason,
|
|
324
538
|
templatePath: file.source,
|
|
325
539
|
});
|
|
326
|
-
// deliberately NOT recorded
|
|
540
|
+
// deliberately NOT recorded in `files`: the rig does not own these bytes
|
|
327
541
|
}
|
|
328
542
|
}
|
|
543
|
+
// Round 5 design ruling (replacing round 4's verdict-only rule, gate cycle
|
|
544
|
+
// 4 blocker 1): a PRISTINE CLAUDE.md must never be replaced by the
|
|
545
|
+
// `@AGENTS.md` shim while the on-disk AGENTS.md cannot actually SERVE as
|
|
546
|
+
// the rulebook — but a customised AGENTS.md that still carries a non-empty
|
|
547
|
+
// `elevated-paths` block is exactly the STEADY STATE this project's own
|
|
548
|
+
// shipped rulebook tells every project to reach (extend the block for your
|
|
549
|
+
// own paths). Keying the hold on AGENTS.md's VERDICT alone (`conflict` or
|
|
550
|
+
// `deleted`) made every customisation indistinguishable from a broken
|
|
551
|
+
// rulebook — round 4's own bug. The hold now keys on CONTENT: absent
|
|
552
|
+
// (`deleted`), or present but unreadable as a rulebook
|
|
553
|
+
// ({@link isReadableRulebook} says no — no block, or a block with nothing
|
|
554
|
+
// left after stripping comments and blank lines). An AGENTS.md that IS a
|
|
555
|
+
// readable rulebook, however edited, lets the shim through — the shim then
|
|
556
|
+
// imports the user's own rulebook, which is the whole point of it — and
|
|
557
|
+
// AGENTS.md itself stays an perfectly ordinary, quiet `conflict`: no
|
|
558
|
+
// rescue file, no special wording, nothing this section touches.
|
|
559
|
+
//
|
|
560
|
+
// Both files are ordinary, independently-decided manifest-tracked paths,
|
|
561
|
+
// so nothing upstream of this point knows about the other when it decides
|
|
562
|
+
// either one's verdict; this is the one place both are visible together,
|
|
563
|
+
// after the loop, before the plan is returned.
|
|
564
|
+
//
|
|
565
|
+
// Held back means: not written, and re-vouched for its CURRENT bytes (the
|
|
566
|
+
// still-untouched old rulebook text) rather than the shim's — so the very
|
|
567
|
+
// next `upgrade`, once the human fixes AGENTS.md, evaluates CLAUDE.md as
|
|
568
|
+
// `unchanged` up front and `update` the moment this release's CLAUDE.md
|
|
569
|
+
// content next changes, instead of falling through to a manifest that no
|
|
570
|
+
// longer vouches for anything and staying `conflict` forever.
|
|
571
|
+
const claudeAction = actions.find((a) => a.rel === 'CLAUDE.md');
|
|
572
|
+
const agentsAction = actions.find((a) => a.rel === 'AGENTS.md');
|
|
573
|
+
const agentsUnreadable = (() => {
|
|
574
|
+
if (agentsAction === undefined)
|
|
575
|
+
return true; // no rulebook to speak of at all
|
|
576
|
+
if (agentsAction.verdict === 'deleted')
|
|
577
|
+
return true;
|
|
578
|
+
// `update` / `unchanged` / `new`: this release's OWN canonical AGENTS.md
|
|
579
|
+
// is what will be (or already is) on disk — always a readable rulebook,
|
|
580
|
+
// never a case that needs judging by content.
|
|
581
|
+
if (agentsAction.verdict !== 'conflict')
|
|
582
|
+
return false;
|
|
583
|
+
const bytes = currentBytesByRel.get('AGENTS.md');
|
|
584
|
+
// A `conflict` verdict is only ever reached from the branch that
|
|
585
|
+
// requires `currentBytes !== null` — see the per-file loop above — so
|
|
586
|
+
// this is always defined here; the `undefined` arm is a defensive
|
|
587
|
+
// fallback, never an observed path (same proof shape as the guard
|
|
588
|
+
// below).
|
|
589
|
+
return bytes === undefined || !isReadableRulebook(bytes.toString('utf8'));
|
|
590
|
+
})();
|
|
591
|
+
const heldBack = claudeAction !== undefined && claudeAction.verdict === 'update' && agentsUnreadable;
|
|
592
|
+
if (heldBack) {
|
|
593
|
+
claudeAction.verdict = 'conflict';
|
|
594
|
+
claudeAction.heldBack = true;
|
|
595
|
+
// Round 5, blocker 2: this text never hardcodes `mv` — the actual
|
|
596
|
+
// remedy (`mv` a written/verified rescue copy, or `rm` a differing one)
|
|
597
|
+
// depends on `agentsRescue`'s status, computed AFTER this point, and
|
|
598
|
+
// printed by the CLI's own closing section (`index.ts`'s
|
|
599
|
+
// `renderAgentsRescueNotice`), which reads that status directly. A
|
|
600
|
+
// fixed `mv` promise here was gate cycle 4's own blocker 2: it survived
|
|
601
|
+
// even the `differs` case, where `mv` would install bytes this run
|
|
602
|
+
// never wrote or verified as the live rulebook.
|
|
603
|
+
claudeAction.reason =
|
|
604
|
+
`held back — AGENTS.md ${agentsAction?.verdict === 'deleted'
|
|
605
|
+
? 'is deleted'
|
|
606
|
+
: 'does not carry a readable rulebook (no non-empty `elevated-paths` block)'}, so writing the \`@AGENTS.md\` shim now would leave the rulebook unreadable. Resolve ` +
|
|
607
|
+
`AGENTS.md first — see the \`${AGENTS_MD_RESCUE}\` section below for the exact remedy, ` +
|
|
608
|
+
'then run `create-agent-rig upgrade` again to finish adopting the shim.';
|
|
609
|
+
// `claudeAction.verdict === 'update'` (the guard above) is only ever
|
|
610
|
+
// reached from the branch that requires `currentBytes !== null` — see the
|
|
611
|
+
// per-file loop above — so `currentBytesByRel` always has an entry for
|
|
612
|
+
// CLAUDE.md here. A guard, not a `!` assertion (round 5 advisory): if this
|
|
613
|
+
// invariant is ever wrong, a clear internal error beats a crash on
|
|
614
|
+
// `undefined`. Pinned by `upgrade.test.ts`'s full RP-186 describe block,
|
|
615
|
+
// none of which would pass if this guard ever actually threw.
|
|
616
|
+
const heldBytes = currentBytesByRel.get('CLAUDE.md');
|
|
617
|
+
if (heldBytes === undefined) {
|
|
618
|
+
throw new UpgradeError('Internal: CLAUDE.md verdict was `update` with no current bytes on record — cannot hold it back.');
|
|
619
|
+
}
|
|
620
|
+
nextFiles['CLAUDE.md'] = sha256(heldBytes);
|
|
621
|
+
}
|
|
622
|
+
// Round 5, blocker 3: the rescue file's status is decided HERE, once, at
|
|
623
|
+
// plan time — never re-probed by `applyUpgrade`, so a dry run and a real
|
|
624
|
+
// run report the identical status and every refusal is known before a
|
|
625
|
+
// single byte is written. Round 4 cycle 4's blocker 3 was exactly the
|
|
626
|
+
// alternative: probing the filesystem again inside `applyUpgrade`, AFTER
|
|
627
|
+
// the ordinary payload writes had already run and BEFORE the manifest
|
|
628
|
+
// write — a symlink or a directory there crashed (or exited 1) with files
|
|
629
|
+
// already rewritten and the manifest left stale.
|
|
630
|
+
const rescueFile = await readRescueFile(repoDir);
|
|
631
|
+
const renderedAgentsForRescue = await renderedAgentsMd(project, layers);
|
|
632
|
+
const rescueMatchesRelease = rescueFile.kind === 'file' &&
|
|
633
|
+
renderedAgentsForRescue !== null &&
|
|
634
|
+
rescueFile.bytes.equals(Buffer.from(renderedAgentsForRescue, 'utf8'));
|
|
635
|
+
const agentsRescue = heldBack
|
|
636
|
+
? rescueFile.kind === 'unsafe'
|
|
637
|
+
? { holdBack: true, status: 'unsafe' }
|
|
638
|
+
: rescueFile.kind === 'absent'
|
|
639
|
+
? { holdBack: true, status: 'would-write' }
|
|
640
|
+
: { holdBack: true, status: rescueMatchesRelease ? 'identical' : 'differs' }
|
|
641
|
+
: { holdBack: false, status: rescueMatchesRelease ? 'cleanup' : 'none' };
|
|
642
|
+
// A path an OLDER manifest still names but this release's single payload no
|
|
643
|
+
// longer contains at all (RP-177: the per-target stack overlays and the
|
|
644
|
+
// architecture-only group are retired outright, not merely excluded from
|
|
645
|
+
// one flavour). It is never written and never deleted — there is no
|
|
646
|
+
// template behind it to write, and deleting a file this run does not even
|
|
647
|
+
// read would be exactly the "resolve a stack name into a directory" step
|
|
648
|
+
// this release removes. It simply drops out of `nextFiles`: the rig no
|
|
649
|
+
// longer vouches for it, whatever state it is in on disk.
|
|
650
|
+
//
|
|
651
|
+
// Bootstrapped rigs (`manifest === null`) never reach this loop at all —
|
|
652
|
+
// there is no `manifest.files` to diff the current install set against, so
|
|
653
|
+
// "no longer shipped" has no `files` list to be true of. A claim with
|
|
654
|
+
// nothing behind it is not a smaller retired list; it is not a claim.
|
|
655
|
+
const currentRels = new Set(files.map((f) => f.rel));
|
|
656
|
+
for (const rel of Object.keys(manifest?.files ?? {})) {
|
|
657
|
+
if (currentRels.has(rel))
|
|
658
|
+
continue;
|
|
659
|
+
actions.push({
|
|
660
|
+
rel,
|
|
661
|
+
verdict: 'retired',
|
|
662
|
+
reason: 'no longer shipped by this release — the rig no longer manages it; it is now yours',
|
|
663
|
+
});
|
|
664
|
+
// deliberately NOT recorded in `nextFiles`: the rig drops its claim
|
|
665
|
+
}
|
|
666
|
+
// `kept` travels forward untouched, minus every path the plan now vouches
|
|
667
|
+
// for in `files` — a kept file that turned out to be a released version, or
|
|
668
|
+
// is byte-identical to this release, has become the rig's to manage.
|
|
669
|
+
const nextKept = {};
|
|
670
|
+
for (const [rel, hash] of Object.entries(manifest?.kept ?? {})) {
|
|
671
|
+
if (nextFiles[rel] === undefined)
|
|
672
|
+
nextKept[rel] = hash;
|
|
673
|
+
}
|
|
329
674
|
// With no manifest, "there is a rig here" has to be *recognised*, not
|
|
330
675
|
// assumed from a file existing: `CLAUDE.md` and `.claude/settings.json` are
|
|
331
676
|
// in the install set and in nearly every repository ever opened by an agent.
|
|
@@ -342,16 +687,26 @@ export async function planUpgrade(repoDir, options = {}) {
|
|
|
342
687
|
fromVersion: manifest?.version ?? null,
|
|
343
688
|
toVersion: await packageVersion(),
|
|
344
689
|
bootstrapped: manifest === null,
|
|
690
|
+
layerInference: inference?.notes ?? null,
|
|
345
691
|
actions,
|
|
346
692
|
wiring,
|
|
347
693
|
wiringByPath,
|
|
348
694
|
contents,
|
|
695
|
+
agentsRescue,
|
|
349
696
|
manifest: {
|
|
350
697
|
version: await packageVersion(),
|
|
351
698
|
kind,
|
|
352
699
|
project,
|
|
353
|
-
|
|
700
|
+
// Always empty: the single payload has no overlays to record any more.
|
|
701
|
+
// An older manifest's `stacks` is read (never crashes on an unknown
|
|
702
|
+
// entry — RP-177) but never carried forward.
|
|
703
|
+
stacks: [],
|
|
704
|
+
// Carried forward unchanged: `upgrade` refreshes the layers a rig
|
|
705
|
+
// already recorded, it never adds or drops one. Opting in happens
|
|
706
|
+
// through `init --layer workflow`.
|
|
707
|
+
layers,
|
|
354
708
|
files: nextFiles,
|
|
709
|
+
...(Object.keys(nextKept).length > 0 ? { kept: nextKept } : {}),
|
|
355
710
|
},
|
|
356
711
|
};
|
|
357
712
|
}
|
|
@@ -363,6 +718,29 @@ export async function applyUpgrade(repoDir, plan, options = {}) {
|
|
|
363
718
|
const written = [];
|
|
364
719
|
if (options.dryRun === true)
|
|
365
720
|
return { written };
|
|
721
|
+
// Round 5, blocker 3: refused BEFORE any write at all — not merely before
|
|
722
|
+
// the manifest write. Round 4 cycle 4 measured that probing the rescue
|
|
723
|
+
// path AFTER the ordinary payload writes left files rewritten and the
|
|
724
|
+
// manifest stale on exactly this refusal. `plan.agentsRescue` was already
|
|
725
|
+
// decided at plan time, so this is a plain read of a fact, not a new probe.
|
|
726
|
+
if (plan.agentsRescue.holdBack && plan.agentsRescue.status === 'unsafe') {
|
|
727
|
+
throw new UpgradeError(`Refusing to write the AGENTS.md rescue file: "${AGENTS_MD_RESCUE}" exists but is not a ` +
|
|
728
|
+
'plain file — a symlink, a directory, or similar sits there. Move or remove it by hand, ' +
|
|
729
|
+
'then run `create-agent-rig upgrade` again.');
|
|
730
|
+
}
|
|
731
|
+
// Preflight the complete write set, including the manifest, before changing
|
|
732
|
+
// any file. Then re-check each destination after mkdir and immediately before
|
|
733
|
+
// writeFile, so both pre-existing and newly-visible symlink components are
|
|
734
|
+
// refused.
|
|
735
|
+
const destinations = new Map();
|
|
736
|
+
for (const rel of [
|
|
737
|
+
...plan.actions
|
|
738
|
+
.filter(({ verdict }) => verdict === 'update' || verdict === 'new')
|
|
739
|
+
.map(({ rel }) => rel),
|
|
740
|
+
MANIFEST_REL,
|
|
741
|
+
]) {
|
|
742
|
+
destinations.set(rel, await writableOnDisk(repoDir, rel));
|
|
743
|
+
}
|
|
366
744
|
for (const action of plan.actions) {
|
|
367
745
|
if (action.verdict !== 'update' && action.verdict !== 'new')
|
|
368
746
|
continue;
|
|
@@ -372,11 +750,33 @@ export async function applyUpgrade(repoDir, plan, options = {}) {
|
|
|
372
750
|
if (content === undefined) {
|
|
373
751
|
throw new UpgradeError(`Internal: no content planned for "${action.rel}" — nothing written.`);
|
|
374
752
|
}
|
|
375
|
-
const dest =
|
|
753
|
+
const dest = destinations.get(action.rel);
|
|
376
754
|
await mkdir(path.dirname(dest), { recursive: true });
|
|
377
|
-
await writeFile(
|
|
755
|
+
await writeFile(await writableOnDisk(repoDir, action.rel), content);
|
|
378
756
|
written.push(action.rel);
|
|
379
757
|
}
|
|
758
|
+
// Round 5: no new filesystem probing here — `plan.agentsRescue` already
|
|
759
|
+
// says exactly what (if anything) this run does, decided before any write
|
|
760
|
+
// above ever ran. `would-write` is the only status that writes; `identical`
|
|
761
|
+
// and `differs` are both "leave it exactly as it is"; `cleanup` is the only
|
|
762
|
+
// status that deletes; `unsafe` already returned above and `none` does
|
|
763
|
+
// nothing at all — round 4 cycle 4's "must not affect upgrade" case.
|
|
764
|
+
if (plan.agentsRescue.holdBack) {
|
|
765
|
+
if (plan.agentsRescue.status === 'would-write') {
|
|
766
|
+
const rendered = plan.contents.get('AGENTS.md');
|
|
767
|
+
if (rendered === undefined) {
|
|
768
|
+
throw new UpgradeError('Internal: no rendered content planned for "AGENTS.md".');
|
|
769
|
+
}
|
|
770
|
+
const rescueDest = await writableOnDisk(repoDir, AGENTS_MD_RESCUE);
|
|
771
|
+
await mkdir(path.dirname(rescueDest), { recursive: true });
|
|
772
|
+
await writeFile(rescueDest, rendered);
|
|
773
|
+
written.push(AGENTS_MD_RESCUE);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
else if (plan.agentsRescue.status === 'cleanup') {
|
|
777
|
+
const rescueDest = await writableOnDisk(repoDir, AGENTS_MD_RESCUE);
|
|
778
|
+
await unlink(rescueDest);
|
|
779
|
+
}
|
|
380
780
|
await writeManifest(repoDir, plan.manifest);
|
|
381
781
|
return { written };
|
|
382
782
|
}
|