create-agent-rig 0.9.1 → 0.10.1
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 +462 -3
- package/README.md +267 -305
- 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 +111 -50
- package/packages/cli/dist/commands/integrations.js +468 -0
- 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 +508 -101
- package/packages/cli/dist/index.js +658 -47
- 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 +64 -5
- 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 +45 -12
- 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/gate-stop-dod.mjs +4 -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 +59 -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/state.mjs +2 -2
- package/templates/agent-os/universal/.claude/scripts/reconcile-external-prs.mjs +4 -3
- 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 +45 -12
- 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 +112 -40
- 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,79 +142,90 @@ function onDisk(repoDir, rel) {
|
|
|
45
142
|
}
|
|
46
143
|
return dest;
|
|
47
144
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
*/
|
|
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
|
+
}
|
|
56
152
|
async function readIfPresent(repoDir, rel) {
|
|
153
|
+
const dest = await writableOnDisk(repoDir, rel);
|
|
154
|
+
let stat;
|
|
57
155
|
try {
|
|
58
|
-
|
|
156
|
+
stat = await lstat(dest);
|
|
59
157
|
}
|
|
60
158
|
catch (error) {
|
|
61
159
|
if (error.code === 'ENOENT')
|
|
62
|
-
return
|
|
160
|
+
return { kind: 'absent' };
|
|
63
161
|
throw error;
|
|
64
162
|
}
|
|
163
|
+
if (!stat.isFile())
|
|
164
|
+
return { kind: 'non-file' };
|
|
165
|
+
return { kind: 'file', bytes: await readFile(dest) };
|
|
65
166
|
}
|
|
66
|
-
/**
|
|
67
|
-
|
|
68
|
-
|
|
167
|
+
/**
|
|
168
|
+
* The bytes THIS release would render for AGENTS.md, for THIS project,
|
|
169
|
+
* right now — the ONE implementation `upgrade` (deciding the rescue file's
|
|
170
|
+
* status) and `uninstall` (recognising a byte-identical rescue file as
|
|
171
|
+
* rig-owned) both call, closing round 4 cycle 4's finding that the two had
|
|
172
|
+
* separate re-renders which could drift apart. `null` only if a future
|
|
173
|
+
* release ever stopped shipping AGENTS.md as a plain substituted file (it
|
|
174
|
+
* does not today).
|
|
175
|
+
*/
|
|
176
|
+
export async function renderedAgentsMd(project, layers) {
|
|
177
|
+
const files = await initManifest(layers);
|
|
178
|
+
const entry = files.find((f) => f.rel === 'AGENTS.md');
|
|
179
|
+
if (entry === undefined || entry.source === null)
|
|
180
|
+
return null;
|
|
181
|
+
const ctx = { projectName: project.name };
|
|
182
|
+
return substituteContent(await readFile(entry.source, 'utf8'), ctx);
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* What is actually sitting at {@link AGENTS_MD_RESCUE}, read ONCE at plan
|
|
186
|
+
* time (round 5, blocker 3) — `absent`, a plain file's bytes, or `unsafe`
|
|
187
|
+
* (a symlink anywhere in the path, a directory sitting at the leaf, or any
|
|
188
|
+
* other non-regular entry). Never throws: `applyUpgrade`'s preflight is what
|
|
189
|
+
* refuses to proceed on `unsafe`, and a healthy rig with unrelated clutter at
|
|
190
|
+
* this path must see `unsafe` reported, not an exception raised, so it can
|
|
191
|
+
* decide (per {@link AgentsRescueStatus}) to simply ignore it.
|
|
192
|
+
*/
|
|
193
|
+
async function readRescueFile(repoDir) {
|
|
194
|
+
let dest;
|
|
195
|
+
try {
|
|
196
|
+
dest = await writableOnDisk(repoDir, AGENTS_MD_RESCUE);
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return { kind: 'unsafe' };
|
|
200
|
+
}
|
|
201
|
+
let stat;
|
|
202
|
+
try {
|
|
203
|
+
stat = await lstat(dest);
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
if (error.code === 'ENOENT')
|
|
207
|
+
return { kind: 'absent' };
|
|
208
|
+
return { kind: 'unsafe' };
|
|
209
|
+
}
|
|
210
|
+
if (!stat.isFile())
|
|
211
|
+
return { kind: 'unsafe' };
|
|
212
|
+
return { kind: 'file', bytes: await readFile(dest) };
|
|
69
213
|
}
|
|
70
214
|
/**
|
|
71
215
|
* What a rig with no manifest looks like it is, from the files it has.
|
|
72
216
|
*
|
|
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.
|
|
217
|
+
* RP-177 ships only one payload, but a pre-0.10 rig may still carry files that
|
|
218
|
+
* only `create` installed. Those retired paths are compatibility evidence: the
|
|
219
|
+
* new release never installs them, yet their presence preserves the raw
|
|
220
|
+
* directory identity old `create` substituted (including a trailing `-`).
|
|
221
|
+
* A readable manifest remains the stronger source and bypasses this heuristic.
|
|
84
222
|
*/
|
|
85
223
|
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
|
-
}
|
|
224
|
+
for (const rel of LEGACY_CREATE_MARKERS) {
|
|
225
|
+
if (await exists(onDisk(repoDir, rel)))
|
|
226
|
+
return { kind: 'create', stacks: [], region: '' };
|
|
101
227
|
}
|
|
102
|
-
|
|
103
|
-
for (const rel of ARCHITECTURE_ONLY) {
|
|
104
|
-
if (architectural)
|
|
105
|
-
break;
|
|
106
|
-
architectural = await exists(onDisk(repoDir, rel));
|
|
107
|
-
}
|
|
108
|
-
if (!architectural)
|
|
109
|
-
return { kind: 'init', stacks: [], region: '' };
|
|
110
|
-
const target = Object.values(TARGETS).find((t) => t.stacks.length === stacks.length && t.stacks.every((s) => stacks.includes(s)));
|
|
111
|
-
return { kind: 'create', stacks, region: target?.defaultRegion ?? '' };
|
|
112
|
-
}
|
|
113
|
-
async function installSetFor(repoDir, kind, project, stacks) {
|
|
114
|
-
if (kind === 'init')
|
|
115
|
-
return initInstallSet(repoDir, project);
|
|
116
|
-
return agentOsInstallSet(stacks, {
|
|
117
|
-
projectName: project.name,
|
|
118
|
-
projectScope: project.scope,
|
|
119
|
-
region: project.region,
|
|
120
|
-
});
|
|
228
|
+
return { kind: 'init', stacks: [], region: '' };
|
|
121
229
|
}
|
|
122
230
|
/**
|
|
123
231
|
* Whether these bytes are a released version of this file.
|
|
@@ -126,11 +234,19 @@ async function installSetFor(repoDir, kind, project, stacks) {
|
|
|
126
234
|
* bytes with the project's own values turned back into tokens — released
|
|
127
235
|
* template bytes carry `__PROJECT_NAME__`, installed bytes never do.
|
|
128
236
|
*/
|
|
129
|
-
function isReleasedVersion(history, rel, content,
|
|
237
|
+
function isReleasedVersion(history, rel, content, projectName) {
|
|
130
238
|
const known = history.files[rel];
|
|
131
239
|
if (known === undefined || known.hashes.length === 0)
|
|
132
240
|
return false;
|
|
133
|
-
const candidates = new Set([sha256(content)
|
|
241
|
+
const candidates = new Set([sha256(content)]);
|
|
242
|
+
const decoded = content.toString('utf8');
|
|
243
|
+
// Detokenization is a text operation. Invalid UTF-8 must not be rewritten
|
|
244
|
+
// through replacement characters and then mistaken for released bytes.
|
|
245
|
+
// The raw-byte candidate above remains authoritative either way.
|
|
246
|
+
if (Buffer.from(decoded, 'utf8').equals(content)) {
|
|
247
|
+
const detokenized = projectName === '' ? decoded : decoded.replaceAll(projectName, '__PROJECT_NAME__');
|
|
248
|
+
candidates.add(sha256(detokenized));
|
|
249
|
+
}
|
|
134
250
|
return known.hashes.some((hash) => candidates.has(hash));
|
|
135
251
|
}
|
|
136
252
|
/**
|
|
@@ -167,8 +283,9 @@ async function unwiresAnInstalledHook(repoDir, current, next) {
|
|
|
167
283
|
*/
|
|
168
284
|
export async function planUpgrade(repoDir, options = {}) {
|
|
169
285
|
const manifest = await readManifest(repoDir);
|
|
170
|
-
//
|
|
171
|
-
//
|
|
286
|
+
// Old create-only files are no longer shipped, but while they remain in a
|
|
287
|
+
// pre-0.10 rig they still distinguish its substitution identity. A readable
|
|
288
|
+
// manifest remains authoritative whenever one exists.
|
|
172
289
|
const detected = manifest === null
|
|
173
290
|
? await detectInstall(repoDir)
|
|
174
291
|
: { kind: manifest.kind, stacks: manifest.stacks, region: manifest.project.region };
|
|
@@ -209,28 +326,91 @@ export async function planUpgrade(repoDir, options = {}) {
|
|
|
209
326
|
scope: bootstrapName,
|
|
210
327
|
region: detected.region,
|
|
211
328
|
};
|
|
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
329
|
const history = options.history ?? (await loadHashHistory());
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
330
|
+
// RP-180: which layer(s) this rig is allowed to have refreshed.
|
|
331
|
+
//
|
|
332
|
+
// - A readable manifest is authoritative, and `parseManifest` already
|
|
333
|
+
// resolves its own absence of `layers` to `ALL_LAYERS` (every release
|
|
334
|
+
// before RP-180 shipped one payload, so an old manifest with no such
|
|
335
|
+
// field installed everything) — so `manifest.layers` is never actually
|
|
336
|
+
// `undefined` here.
|
|
337
|
+
// - No manifest at all (`bootstrapped` — `manifest === null`, which covers
|
|
338
|
+
// a genuinely missing file AND one `parseManifest` voided over ANY
|
|
339
|
+
// invalid field, not only a missing `layers` key) reads the candidate
|
|
340
|
+
// set off the disk itself, by quorum — see {@link detectLayersOnDisk}
|
|
341
|
+
// and its own history of what was tried here and why each attempt
|
|
342
|
+
// before it was not enough.
|
|
343
|
+
const inference = manifest === null ? await detectLayersOnDisk(repoDir) : null;
|
|
344
|
+
const layers = manifest?.layers ?? inference.layers;
|
|
345
|
+
// A path an OLDER manifest still names but this rig's OWN recorded layers
|
|
346
|
+
// no longer cover (a manifest hand-edited to drop a layer, or one from a
|
|
347
|
+
// release that shipped a layer this one renamed) falls out of `files`
|
|
348
|
+
// below exactly like a path RP-177 retired outright: never written, never
|
|
349
|
+
// deleted, simply no longer this plan's to manage.
|
|
350
|
+
const files = await initInstallSet(repoDir, project, layers);
|
|
351
|
+
// Blocker A's second half, and round 5's correction to it: even an
|
|
352
|
+
// ADOPTED bootstrapped opt-in layer must never manufacture a file it did
|
|
353
|
+
// not find, but "does not create it" is not the same thing as "forgets it
|
|
354
|
+
// existed". Round 4 dropped an absent adopted-layer path from the plan
|
|
355
|
+
// ENTIRELY, which left the rebuilt manifest with no memory of it at all —
|
|
356
|
+
// the very next ORDINARY upgrade (a readable manifest now) then read the
|
|
357
|
+
// absence as "never installed" and proposed `new`, silently reinstating an
|
|
358
|
+
// operator's deliberate deletion with no note that that is what it was
|
|
359
|
+
// doing. An intact manifest never has this problem: a `files` entry naming
|
|
360
|
+
// a path that is now absent already gets the `deleted` verdict, reason
|
|
361
|
+
// "installed by the rig, removed since — not restored", and carries the
|
|
362
|
+
// path's hash forward into the next manifest so this stays true on every
|
|
363
|
+
// later run too.
|
|
364
|
+
//
|
|
365
|
+
// The fix reuses exactly that path rather than inventing a new manifest
|
|
366
|
+
// field: for an absent file of an ADOPTED bootstrapped opt-in layer,
|
|
367
|
+
// `recorded` below is treated as the hash THIS RELEASE would have
|
|
368
|
+
// installed — the same value a normal install would have recorded for it
|
|
369
|
+
// — instead of `undefined`. That is the one piece of information the
|
|
370
|
+
// per-file logic needs to take the identical `deleted`/"not restored"
|
|
371
|
+
// branch an intact manifest takes; nothing else about that branch changes.
|
|
372
|
+
// A file that IS present is untouched by this: `recorded` stays whatever
|
|
373
|
+
// the (bootstrapped, so absent) manifest says, `undefined`, and the
|
|
374
|
+
// existing present-file logic decides its verdict exactly as before.
|
|
375
|
+
const inferredOptInPaths = new Set();
|
|
376
|
+
if (inference !== null) {
|
|
377
|
+
for (const layer of inference.layers) {
|
|
378
|
+
if (layer === 'process')
|
|
379
|
+
continue;
|
|
380
|
+
for (const rel of await layerOnlyPaths(layer))
|
|
381
|
+
inferredOptInPaths.add(rel);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
224
384
|
const actions = [];
|
|
225
385
|
const contents = new Map();
|
|
226
386
|
const nextFiles = {};
|
|
227
387
|
let wiring = null;
|
|
228
388
|
const wiringByPath = new Map();
|
|
389
|
+
// Only ever consulted by the CLAUDE.md/AGENTS.md coupling below, which runs
|
|
390
|
+
// after this loop and needs each file's on-disk bytes to re-vouch for
|
|
391
|
+
// CLAUDE.md's CURRENT content when it holds the shim back.
|
|
392
|
+
const currentBytesByRel = new Map();
|
|
229
393
|
for (const file of files) {
|
|
230
|
-
const
|
|
231
|
-
|
|
394
|
+
const currentFile = await readIfPresent(repoDir, file.rel);
|
|
395
|
+
if (currentFile.kind === 'non-file') {
|
|
396
|
+
actions.push({
|
|
397
|
+
rel: file.rel,
|
|
398
|
+
verdict: 'conflict',
|
|
399
|
+
reason: 'a directory or other non-regular entry exists where this rig-owned file belongs — ' +
|
|
400
|
+
'move or remove it by hand; upgrade will leave it untouched',
|
|
401
|
+
templatePath: file.source,
|
|
402
|
+
});
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
const currentBytes = currentFile.kind === 'file' ? currentFile.bytes : null;
|
|
406
|
+
if (currentBytes !== null)
|
|
407
|
+
currentBytesByRel.set(file.rel, currentBytes);
|
|
408
|
+
const recordedInManifest = manifest?.files[file.rel];
|
|
409
|
+
const recorded = recordedInManifest === undefined && currentBytes === null && inferredOptInPaths.has(file.rel)
|
|
410
|
+
? sha256(file.content)
|
|
411
|
+
: recordedInManifest;
|
|
232
412
|
contents.set(file.rel, file.content);
|
|
233
|
-
if (
|
|
413
|
+
if (currentBytes === null) {
|
|
234
414
|
// Evidence, not a command. The manifest is the direct evidence; without
|
|
235
415
|
// one, a path that shipped in *every* release the table covers was there
|
|
236
416
|
// to be removed, so its absence is a decision. A path added later is
|
|
@@ -239,7 +419,16 @@ export async function planUpgrade(repoDir, options = {}) {
|
|
|
239
419
|
actions.push({
|
|
240
420
|
rel: file.rel,
|
|
241
421
|
verdict: 'deleted',
|
|
242
|
-
reason: 'installed by the rig, removed since — not restored'
|
|
422
|
+
reason: 'installed by the rig, removed since — not restored' +
|
|
423
|
+
// Round 4 advisory: this branch fires just as readily on a rig
|
|
424
|
+
// that finished the RP-186 migration long ago (CLAUDE.md already
|
|
425
|
+
// the `@AGENTS.md` shim) as on one mid-migration — "not
|
|
426
|
+
// restored" alone does not say that CLAUDE.md's own import makes
|
|
427
|
+
// AGENTS.md's absence load NO rulebook at all, not merely an old
|
|
428
|
+
// one.
|
|
429
|
+
(file.rel === 'AGENTS.md'
|
|
430
|
+
? ' — CLAUDE.md imports it (`@AGENTS.md`), so no rulebook loads until it is back'
|
|
431
|
+
: ''),
|
|
243
432
|
});
|
|
244
433
|
nextFiles[file.rel] = recorded;
|
|
245
434
|
}
|
|
@@ -256,6 +445,9 @@ export async function planUpgrade(repoDir, options = {}) {
|
|
|
256
445
|
}
|
|
257
446
|
continue;
|
|
258
447
|
}
|
|
448
|
+
const current = currentBytes.toString('utf8');
|
|
449
|
+
const currentHash = sha256(currentBytes);
|
|
450
|
+
const releasedBytes = Buffer.from(file.content, 'utf8');
|
|
259
451
|
// The two limits that keep wiring files' new replaceability from
|
|
260
452
|
// disarming the rig, both measured rather than reasoned about.
|
|
261
453
|
//
|
|
@@ -274,10 +466,10 @@ export async function planUpgrade(repoDir, options = {}) {
|
|
|
274
466
|
const isWiring = WIRING_PATHS.has(file.rel);
|
|
275
467
|
const wouldUnwireAnInstalledHook = isWiring && (await unwiresAnInstalledHook(repoDir, current, file.content));
|
|
276
468
|
const vouched = isWiring
|
|
277
|
-
? recorded !== undefined &&
|
|
278
|
-
: (recorded !== undefined &&
|
|
279
|
-
isReleasedVersion(history, file.rel,
|
|
280
|
-
if (
|
|
469
|
+
? recorded !== undefined && currentHash === recorded
|
|
470
|
+
: (recorded !== undefined && currentHash === recorded) ||
|
|
471
|
+
isReleasedVersion(history, file.rel, currentBytes, project.name);
|
|
472
|
+
if (currentBytes.equals(releasedBytes)) {
|
|
281
473
|
actions.push({ rel: file.rel, verdict: 'unchanged' });
|
|
282
474
|
nextFiles[file.rel] = sha256(file.content);
|
|
283
475
|
}
|
|
@@ -322,19 +514,164 @@ export async function planUpgrade(repoDir, options = {}) {
|
|
|
322
514
|
// not the version of the init that kept the file.
|
|
323
515
|
const kept = recorded === undefined ? manifest?.kept?.[file.rel] : undefined;
|
|
324
516
|
const keptReason = (since) => `kept by init (already here, not the rig's bytes), ${since} since — treated as yours`;
|
|
517
|
+
let reason = kept !== undefined
|
|
518
|
+
? keptReason(currentHash === kept ? 'unchanged' : 'edited')
|
|
519
|
+
: recorded === undefined
|
|
520
|
+
? 'not a version this rig ever released — treated as yours'
|
|
521
|
+
: 'edited since it was installed';
|
|
522
|
+
// PR #241 round 2, blocker 3: CLAUDE.md is the one file whose CONTENT
|
|
523
|
+
// decides which rulebook Claude Code reads at all. A CLAUDE.md that is
|
|
524
|
+
// not already the `@AGENTS.md` shim — this release's shim, an older
|
|
525
|
+
// rig's own full rulebook text, or genuinely unrelated content — makes
|
|
526
|
+
// Claude Code read THIS file INSTEAD OF AGENTS.md by default (Claude
|
|
527
|
+
// Code's own docs, "AGENTS.md", the `claude-md-or-agents-md` default).
|
|
528
|
+
// A `conflict` verdict already means "kept, never written"; without
|
|
529
|
+
// this the reader has no way to learn that keeping it also means the
|
|
530
|
+
// canonical AGENTS.md this release ships is not what gets read.
|
|
531
|
+
// The exact first line, not a prefix: `@AGENTS.mdEVIL` and
|
|
532
|
+
// `@AGENTS.md.bak` both satisfy `startsWith('@AGENTS.md')` while being
|
|
533
|
+
// neither Claude Code's import syntax nor anything it reads as one.
|
|
534
|
+
if (file.rel === 'CLAUDE.md' && current.split(/\r?\n/, 1)[0] !== '@AGENTS.md') {
|
|
535
|
+
reason +=
|
|
536
|
+
' — this file is not the `@AGENTS.md` shim, so it shadows AGENTS.md: Claude Code ' +
|
|
537
|
+
'reads it INSTEAD OF AGENTS.md by default. Adopt the shim by hand — replace this ' +
|
|
538
|
+
"file's content with `@AGENTS.md` plus anything Claude-Code-specific (see " +
|
|
539
|
+
'docs/decisions/agents-md-canonical.md) — to have Claude Code read the canonical ' +
|
|
540
|
+
'rulebook again.';
|
|
541
|
+
}
|
|
325
542
|
actions.push({
|
|
326
543
|
rel: file.rel,
|
|
327
544
|
verdict: 'conflict',
|
|
328
|
-
reason
|
|
329
|
-
? keptReason(sha256(current) === kept ? 'unchanged' : 'edited')
|
|
330
|
-
: recorded === undefined
|
|
331
|
-
? 'not a version this rig ever released — treated as yours'
|
|
332
|
-
: 'edited since it was installed',
|
|
545
|
+
reason,
|
|
333
546
|
templatePath: file.source,
|
|
334
547
|
});
|
|
335
548
|
// deliberately NOT recorded in `files`: the rig does not own these bytes
|
|
336
549
|
}
|
|
337
550
|
}
|
|
551
|
+
// Round 5 design ruling (replacing round 4's verdict-only rule, gate cycle
|
|
552
|
+
// 4 blocker 1): a PRISTINE CLAUDE.md must never be replaced by the
|
|
553
|
+
// `@AGENTS.md` shim while the on-disk AGENTS.md cannot actually SERVE as
|
|
554
|
+
// the rulebook — but a customised AGENTS.md that still carries a non-empty
|
|
555
|
+
// `elevated-paths` block is exactly the STEADY STATE this project's own
|
|
556
|
+
// shipped rulebook tells every project to reach (extend the block for your
|
|
557
|
+
// own paths). Keying the hold on AGENTS.md's VERDICT alone (`conflict` or
|
|
558
|
+
// `deleted`) made every customisation indistinguishable from a broken
|
|
559
|
+
// rulebook — round 4's own bug. The hold now keys on CONTENT: absent
|
|
560
|
+
// (`deleted`), or present but unreadable as a rulebook
|
|
561
|
+
// ({@link isReadableRulebook} says no — no block, or a block with nothing
|
|
562
|
+
// left after stripping comments and blank lines). An AGENTS.md that IS a
|
|
563
|
+
// readable rulebook, however edited, lets the shim through — the shim then
|
|
564
|
+
// imports the user's own rulebook, which is the whole point of it — and
|
|
565
|
+
// AGENTS.md itself stays an perfectly ordinary, quiet `conflict`: no
|
|
566
|
+
// rescue file, no special wording, nothing this section touches.
|
|
567
|
+
//
|
|
568
|
+
// Both files are ordinary, independently-decided manifest-tracked paths,
|
|
569
|
+
// so nothing upstream of this point knows about the other when it decides
|
|
570
|
+
// either one's verdict; this is the one place both are visible together,
|
|
571
|
+
// after the loop, before the plan is returned.
|
|
572
|
+
//
|
|
573
|
+
// Held back means: not written, and re-vouched for its CURRENT bytes (the
|
|
574
|
+
// still-untouched old rulebook text) rather than the shim's — so the very
|
|
575
|
+
// next `upgrade`, once the human fixes AGENTS.md, evaluates CLAUDE.md as
|
|
576
|
+
// `unchanged` up front and `update` the moment this release's CLAUDE.md
|
|
577
|
+
// content next changes, instead of falling through to a manifest that no
|
|
578
|
+
// longer vouches for anything and staying `conflict` forever.
|
|
579
|
+
const claudeAction = actions.find((a) => a.rel === 'CLAUDE.md');
|
|
580
|
+
const agentsAction = actions.find((a) => a.rel === 'AGENTS.md');
|
|
581
|
+
const agentsUnreadable = (() => {
|
|
582
|
+
if (agentsAction === undefined)
|
|
583
|
+
return true; // no rulebook to speak of at all
|
|
584
|
+
if (agentsAction.verdict === 'deleted')
|
|
585
|
+
return true;
|
|
586
|
+
// `update` / `unchanged` / `new`: this release's OWN canonical AGENTS.md
|
|
587
|
+
// is what will be (or already is) on disk — always a readable rulebook,
|
|
588
|
+
// never a case that needs judging by content.
|
|
589
|
+
if (agentsAction.verdict !== 'conflict')
|
|
590
|
+
return false;
|
|
591
|
+
const bytes = currentBytesByRel.get('AGENTS.md');
|
|
592
|
+
// A `conflict` verdict is only ever reached from the branch that
|
|
593
|
+
// requires `currentBytes !== null` — see the per-file loop above — so
|
|
594
|
+
// this is always defined here; the `undefined` arm is a defensive
|
|
595
|
+
// fallback, never an observed path (same proof shape as the guard
|
|
596
|
+
// below).
|
|
597
|
+
return bytes === undefined || !isReadableRulebook(bytes.toString('utf8'));
|
|
598
|
+
})();
|
|
599
|
+
const heldBack = claudeAction !== undefined && claudeAction.verdict === 'update' && agentsUnreadable;
|
|
600
|
+
if (heldBack) {
|
|
601
|
+
claudeAction.verdict = 'conflict';
|
|
602
|
+
claudeAction.heldBack = true;
|
|
603
|
+
// Round 5, blocker 2: this text never hardcodes `mv` — the actual
|
|
604
|
+
// remedy (`mv` a written/verified rescue copy, or `rm` a differing one)
|
|
605
|
+
// depends on `agentsRescue`'s status, computed AFTER this point, and
|
|
606
|
+
// printed by the CLI's own closing section (`index.ts`'s
|
|
607
|
+
// `renderAgentsRescueNotice`), which reads that status directly. A
|
|
608
|
+
// fixed `mv` promise here was gate cycle 4's own blocker 2: it survived
|
|
609
|
+
// even the `differs` case, where `mv` would install bytes this run
|
|
610
|
+
// never wrote or verified as the live rulebook.
|
|
611
|
+
claudeAction.reason =
|
|
612
|
+
`held back — AGENTS.md ${agentsAction?.verdict === 'deleted'
|
|
613
|
+
? 'is deleted'
|
|
614
|
+
: '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 ` +
|
|
615
|
+
`AGENTS.md first — see the \`${AGENTS_MD_RESCUE}\` section below for the exact remedy, ` +
|
|
616
|
+
'then run `create-agent-rig upgrade` again to finish adopting the shim.';
|
|
617
|
+
// `claudeAction.verdict === 'update'` (the guard above) is only ever
|
|
618
|
+
// reached from the branch that requires `currentBytes !== null` — see the
|
|
619
|
+
// per-file loop above — so `currentBytesByRel` always has an entry for
|
|
620
|
+
// CLAUDE.md here. A guard, not a `!` assertion (round 5 advisory): if this
|
|
621
|
+
// invariant is ever wrong, a clear internal error beats a crash on
|
|
622
|
+
// `undefined`. Pinned by `upgrade.test.ts`'s full RP-186 describe block,
|
|
623
|
+
// none of which would pass if this guard ever actually threw.
|
|
624
|
+
const heldBytes = currentBytesByRel.get('CLAUDE.md');
|
|
625
|
+
if (heldBytes === undefined) {
|
|
626
|
+
throw new UpgradeError('Internal: CLAUDE.md verdict was `update` with no current bytes on record — cannot hold it back.');
|
|
627
|
+
}
|
|
628
|
+
nextFiles['CLAUDE.md'] = sha256(heldBytes);
|
|
629
|
+
}
|
|
630
|
+
// Round 5, blocker 3: the rescue file's status is decided HERE, once, at
|
|
631
|
+
// plan time — never re-probed by `applyUpgrade` except to re-verify a
|
|
632
|
+
// `cleanup` before deleting (RP-192 item 4), so a dry run and a real
|
|
633
|
+
// run report the identical status and every refusal is known before a
|
|
634
|
+
// single byte is written. Round 4 cycle 4's blocker 3 was exactly the
|
|
635
|
+
// alternative: probing the filesystem again inside `applyUpgrade`, AFTER
|
|
636
|
+
// the ordinary payload writes had already run and BEFORE the manifest
|
|
637
|
+
// write — a symlink or a directory there crashed (or exited 1) with files
|
|
638
|
+
// already rewritten and the manifest left stale.
|
|
639
|
+
const rescueFile = await readRescueFile(repoDir);
|
|
640
|
+
const renderedAgentsForRescue = await renderedAgentsMd(project, layers);
|
|
641
|
+
const rescueMatchesRelease = rescueFile.kind === 'file' &&
|
|
642
|
+
renderedAgentsForRescue !== null &&
|
|
643
|
+
rescueFile.bytes.equals(Buffer.from(renderedAgentsForRescue, 'utf8'));
|
|
644
|
+
const agentsRescue = heldBack
|
|
645
|
+
? rescueFile.kind === 'unsafe'
|
|
646
|
+
? { holdBack: true, status: 'unsafe' }
|
|
647
|
+
: rescueFile.kind === 'absent'
|
|
648
|
+
? { holdBack: true, status: 'would-write' }
|
|
649
|
+
: { holdBack: true, status: rescueMatchesRelease ? 'identical' : 'differs' }
|
|
650
|
+
: { holdBack: false, status: rescueMatchesRelease ? 'cleanup' : 'none' };
|
|
651
|
+
// A path an OLDER manifest still names but this release's single payload no
|
|
652
|
+
// longer contains at all (RP-177: the per-target stack overlays and the
|
|
653
|
+
// architecture-only group are retired outright, not merely excluded from
|
|
654
|
+
// one flavour). It is never written and never deleted — there is no
|
|
655
|
+
// template behind it to write, and deleting a file this run does not even
|
|
656
|
+
// read would be exactly the "resolve a stack name into a directory" step
|
|
657
|
+
// this release removes. It simply drops out of `nextFiles`: the rig no
|
|
658
|
+
// longer vouches for it, whatever state it is in on disk.
|
|
659
|
+
//
|
|
660
|
+
// Bootstrapped rigs (`manifest === null`) never reach this loop at all —
|
|
661
|
+
// there is no `manifest.files` to diff the current install set against, so
|
|
662
|
+
// "no longer shipped" has no `files` list to be true of. A claim with
|
|
663
|
+
// nothing behind it is not a smaller retired list; it is not a claim.
|
|
664
|
+
const currentRels = new Set(files.map((f) => f.rel));
|
|
665
|
+
for (const rel of Object.keys(manifest?.files ?? {})) {
|
|
666
|
+
if (currentRels.has(rel))
|
|
667
|
+
continue;
|
|
668
|
+
actions.push({
|
|
669
|
+
rel,
|
|
670
|
+
verdict: 'retired',
|
|
671
|
+
reason: 'no longer shipped by this release — the rig no longer manages it; it is now yours',
|
|
672
|
+
});
|
|
673
|
+
// deliberately NOT recorded in `nextFiles`: the rig drops its claim
|
|
674
|
+
}
|
|
338
675
|
// `kept` travels forward untouched, minus every path the plan now vouches
|
|
339
676
|
// for in `files` — a kept file that turned out to be a released version, or
|
|
340
677
|
// is byte-identical to this release, has become the rig's to manage.
|
|
@@ -359,15 +696,24 @@ export async function planUpgrade(repoDir, options = {}) {
|
|
|
359
696
|
fromVersion: manifest?.version ?? null,
|
|
360
697
|
toVersion: await packageVersion(),
|
|
361
698
|
bootstrapped: manifest === null,
|
|
699
|
+
layerInference: inference?.notes ?? null,
|
|
362
700
|
actions,
|
|
363
701
|
wiring,
|
|
364
702
|
wiringByPath,
|
|
365
703
|
contents,
|
|
704
|
+
agentsRescue,
|
|
366
705
|
manifest: {
|
|
367
706
|
version: await packageVersion(),
|
|
368
707
|
kind,
|
|
369
708
|
project,
|
|
370
|
-
|
|
709
|
+
// Always empty: the single payload has no overlays to record any more.
|
|
710
|
+
// An older manifest's `stacks` is read (never crashes on an unknown
|
|
711
|
+
// entry — RP-177) but never carried forward.
|
|
712
|
+
stacks: [],
|
|
713
|
+
// Carried forward unchanged: `upgrade` refreshes the layers a rig
|
|
714
|
+
// already recorded, it never adds or drops one. Opting in happens
|
|
715
|
+
// through `init --layer workflow`.
|
|
716
|
+
layers,
|
|
371
717
|
files: nextFiles,
|
|
372
718
|
...(Object.keys(nextKept).length > 0 ? { kept: nextKept } : {}),
|
|
373
719
|
},
|
|
@@ -379,8 +725,32 @@ export async function planUpgrade(repoDir, options = {}) {
|
|
|
379
725
|
*/
|
|
380
726
|
export async function applyUpgrade(repoDir, plan, options = {}) {
|
|
381
727
|
const written = [];
|
|
728
|
+
let removedRescue = false;
|
|
382
729
|
if (options.dryRun === true)
|
|
383
|
-
return { written };
|
|
730
|
+
return { written, removedRescue };
|
|
731
|
+
// Round 5, blocker 3: refused BEFORE any write at all — not merely before
|
|
732
|
+
// the manifest write. Round 4 cycle 4 measured that probing the rescue
|
|
733
|
+
// path AFTER the ordinary payload writes left files rewritten and the
|
|
734
|
+
// manifest stale on exactly this refusal. `plan.agentsRescue` was already
|
|
735
|
+
// decided at plan time, so this is a plain read of a fact, not a new probe.
|
|
736
|
+
if (plan.agentsRescue.holdBack && plan.agentsRescue.status === 'unsafe') {
|
|
737
|
+
throw new UpgradeError(`Refusing to write the AGENTS.md rescue file: "${AGENTS_MD_RESCUE}" exists but is not a ` +
|
|
738
|
+
'plain file — a symlink, a directory, or similar sits there. Move or remove it by hand, ' +
|
|
739
|
+
'then run `create-agent-rig upgrade` again.');
|
|
740
|
+
}
|
|
741
|
+
// Preflight the complete write set, including the manifest, before changing
|
|
742
|
+
// any file. Then re-check each destination after mkdir and immediately before
|
|
743
|
+
// writeFile, so both pre-existing and newly-visible symlink components are
|
|
744
|
+
// refused.
|
|
745
|
+
const destinations = new Map();
|
|
746
|
+
for (const rel of [
|
|
747
|
+
...plan.actions
|
|
748
|
+
.filter(({ verdict }) => verdict === 'update' || verdict === 'new')
|
|
749
|
+
.map(({ rel }) => rel),
|
|
750
|
+
MANIFEST_REL,
|
|
751
|
+
]) {
|
|
752
|
+
destinations.set(rel, await writableOnDisk(repoDir, rel));
|
|
753
|
+
}
|
|
384
754
|
for (const action of plan.actions) {
|
|
385
755
|
if (action.verdict !== 'update' && action.verdict !== 'new')
|
|
386
756
|
continue;
|
|
@@ -390,11 +760,48 @@ export async function applyUpgrade(repoDir, plan, options = {}) {
|
|
|
390
760
|
if (content === undefined) {
|
|
391
761
|
throw new UpgradeError(`Internal: no content planned for "${action.rel}" — nothing written.`);
|
|
392
762
|
}
|
|
393
|
-
const dest =
|
|
763
|
+
const dest = destinations.get(action.rel);
|
|
394
764
|
await mkdir(path.dirname(dest), { recursive: true });
|
|
395
|
-
await writeFile(
|
|
765
|
+
await writeFile(await writableOnDisk(repoDir, action.rel), content);
|
|
396
766
|
written.push(action.rel);
|
|
397
767
|
}
|
|
768
|
+
// Round 5: `plan.agentsRescue` says what (if anything) this run may do,
|
|
769
|
+
// decided before any write above ever ran. `would-write` is the only status
|
|
770
|
+
// that writes; `identical` and `differs` are both "leave it exactly as it
|
|
771
|
+
// is"; `cleanup` is the only status that deletes, and the one that reads the
|
|
772
|
+
// file again before it does (below); `unsafe` already returned above and
|
|
773
|
+
// `none` does nothing at all — round 4 cycle 4's "must not affect upgrade" case.
|
|
774
|
+
if (plan.agentsRescue.holdBack) {
|
|
775
|
+
if (plan.agentsRescue.status === 'would-write') {
|
|
776
|
+
const rendered = plan.contents.get('AGENTS.md');
|
|
777
|
+
if (rendered === undefined) {
|
|
778
|
+
throw new UpgradeError('Internal: no rendered content planned for "AGENTS.md".');
|
|
779
|
+
}
|
|
780
|
+
const rescueDest = await writableOnDisk(repoDir, AGENTS_MD_RESCUE);
|
|
781
|
+
await mkdir(path.dirname(rescueDest), { recursive: true });
|
|
782
|
+
await writeFile(rescueDest, rendered);
|
|
783
|
+
written.push(AGENTS_MD_RESCUE);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
else if (plan.agentsRescue.status === 'cleanup') {
|
|
787
|
+
// The one deletion here is re-verified at apply time, the way uninstall
|
|
788
|
+
// re-checks `recordedHash`: an interactive run applies after its prompt,
|
|
789
|
+
// so the leftover may have been edited or removed since it was matched.
|
|
790
|
+
const rendered = plan.contents.get('AGENTS.md');
|
|
791
|
+
const current = await readRescueFile(repoDir);
|
|
792
|
+
if (rendered !== undefined &&
|
|
793
|
+
current.kind === 'file' &&
|
|
794
|
+
current.bytes.equals(Buffer.from(rendered, 'utf8'))) {
|
|
795
|
+
try {
|
|
796
|
+
await unlink(await writableOnDisk(repoDir, AGENTS_MD_RESCUE));
|
|
797
|
+
removedRescue = true;
|
|
798
|
+
}
|
|
799
|
+
catch (error) {
|
|
800
|
+
if (error.code !== 'ENOENT')
|
|
801
|
+
throw error;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
}
|
|
398
805
|
await writeManifest(repoDir, plan.manifest);
|
|
399
|
-
return { written };
|
|
806
|
+
return { written, removedRescue };
|
|
400
807
|
}
|