create-agent-rig 0.9.1 → 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 +419 -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 +472 -90
- package/packages/cli/dist/index.js +651 -46
- 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 +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/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 +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 +8 -4
- package/templates/release-ledger.json +2 -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
|
@@ -7,7 +7,9 @@ Red → Green → Refactor, in that order, every time:
|
|
|
7
7
|
1. **Red** — write the test that describes the behavior; run it; watch it fail.
|
|
8
8
|
Use the `test-writer` agent for this step: it writes the failing test and is
|
|
9
9
|
scoped so it cannot "helpfully" write the implementation too.
|
|
10
|
-
2. **Green** — the minimum implementation that makes the test pass.
|
|
10
|
+
2. **Green** — the minimum implementation that makes the test pass. Use the
|
|
11
|
+
`implementation-agent` for this step: it starts from the failing test and
|
|
12
|
+
never edits a test to reach green.
|
|
11
13
|
3. **Refactor** — clean up with the tests staying green.
|
|
12
14
|
|
|
13
15
|
No implementation before its failing test exists. A bug fix starts with a test
|
|
@@ -34,9 +36,14 @@ that reproduces the bug.
|
|
|
34
36
|
The session that wrote the code is measurably worse at reviewing it: it
|
|
35
37
|
carries its own reasoning in context and will not challenge its own decisions
|
|
36
38
|
the way a cold reader does. That is *why* `code-reviewer` is a separate
|
|
37
|
-
subagent with a fresh context
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
subagent with a fresh context. **The `pr-ship` skill, which fans reviewers
|
|
40
|
+
out automatically, ships only with the opt-in workflow layer** (`init
|
|
41
|
+
--layer workflow`; `AGENTS.md`'s "The opt-in workflow layer" section) —
|
|
42
|
+
without it, the session itself dispatches `code-reviewer` (and
|
|
43
|
+
`security-scanner`/`prose-reviewer` when their triggers fire) directly,
|
|
44
|
+
before opening or merging a PR. Either way, this isolation is load-bearing,
|
|
45
|
+
not ceremony — do not "optimise" it away by reviewing in the authoring
|
|
46
|
+
session.
|
|
40
47
|
|
|
41
48
|
A reviewer whose definition pins its model is never dispatched with a call-site
|
|
42
49
|
`model`: which model reads a change is the routing policy's decision, not the
|
|
@@ -61,6 +68,13 @@ travels one path to merge, in this order:
|
|
|
61
68
|
| `fast-path` | documentation outside the rulebook, and derived files under those same two rules | `prose-reviewer` |
|
|
62
69
|
| `model` | everything else, including anything unclassifiable | `code-reviewer`, **always** |
|
|
63
70
|
|
|
71
|
+
**`.claude/scripts/decision-router.mjs` ships with the opt-in workflow
|
|
72
|
+
layer** (`init --layer workflow`; `AGENTS.md`'s "The opt-in workflow layer"
|
|
73
|
+
section). Without it, this table is still the rule — it is just applied by
|
|
74
|
+
a human or the session rather than by the script, and the safe default on
|
|
75
|
+
any doubt is `model`, exactly as the script's own refusal-to-decide reads
|
|
76
|
+
below.
|
|
77
|
+
|
|
64
78
|
`.claude/scripts/decision-router.mjs` decides this from the **committed**
|
|
65
79
|
diff's paths — an uncommitted edit is not routed — and **risk flags escalate
|
|
66
80
|
ahead of all three**: a file under a declared elevated path, a dependency
|
|
@@ -70,8 +84,8 @@ travels one path to merge, in this order:
|
|
|
70
84
|
so it never reaches the prose lane; `.md`/`.mdx` files and test paths that
|
|
71
85
|
provision nothing are inert, so a README inside an elevated directory does
|
|
72
86
|
not escalate on that ground alone. **Rulebook paths are exempt from that
|
|
73
|
-
carve-out** — `CLAUDE.md`, anything under `.claude/`, and the
|
|
74
|
-
records under `docs/decisions/`, which are extracted rationale and reviewed
|
|
87
|
+
carve-out** — `CLAUDE.md`, `AGENTS.md`, anything under `.claude/`, and the
|
|
88
|
+
decision records under `docs/decisions/`, which are extracted rationale and reviewed
|
|
75
89
|
like the rules they explain. The inert set is otherwise those two extensions
|
|
76
90
|
and test paths exactly — **not** the router's own notion of prose, which is
|
|
77
91
|
`.md`/`.txt`. Neither set contains the other, and reconciling them breaks a
|
|
@@ -96,19 +110,29 @@ travels one path to merge, in this order:
|
|
|
96
110
|
parsing, file handling, or outbound calls;
|
|
97
111
|
- `prose-reviewer` when it touches the documents that instruct agents — a
|
|
98
112
|
rule file, a skill, an agent spec, a decision record under
|
|
99
|
-
`docs/decisions/`, `CLAUDE.md`, the README. In this layer the prose *is*
|
|
113
|
+
`docs/decisions/`, `CLAUDE.md`, `AGENTS.md`, the README. In this layer the prose *is*
|
|
100
114
|
the implementation, and it fails the same way code does: silently, in the
|
|
101
115
|
direction of false confidence;
|
|
102
|
-
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
The `pr-ship` skill
|
|
106
|
-
|
|
116
|
+
- any additional review a repository rule explicitly names for the touched
|
|
117
|
+
surface.
|
|
118
|
+
|
|
119
|
+
**The `pr-ship` skill ships only with the opt-in workflow layer**
|
|
120
|
+
(`init --layer workflow`) and, where installed, drives this fan-out and
|
|
121
|
+
returns a SHIP / HOLD verdict with named blockers. **Without the layer,
|
|
122
|
+
the session itself drives the same fan-out** — dispatch each reviewer the
|
|
123
|
+
table and the triggers above name, read every verdict, and resolve every
|
|
124
|
+
blocking finding the same way `pr-ship` would; nothing about the review
|
|
125
|
+
floor changes with or without the skill. Blocking findings are resolved,
|
|
126
|
+
not argued with, either way.
|
|
107
127
|
|
|
108
128
|
**A verdict is a block, not a sentence.** Every gate ends its report with one
|
|
109
|
-
fenced `json` block of the shape `.claude/scripts/lib/verdict.mjs` defines
|
|
110
|
-
|
|
111
|
-
|
|
129
|
+
fenced `json` block of the shape `.claude/scripts/lib/verdict.mjs` defines
|
|
130
|
+
(Core — `verdict.mjs` and its two dependencies ship unconditionally,
|
|
131
|
+
`docs/decisions/workflow-layer-split.md` explains why), and `node
|
|
132
|
+
.claude/scripts/verdict.mjs check` runs on each answer **before** anything
|
|
133
|
+
is decided from it — `pr-ship` runs it when the layer is installed; the
|
|
134
|
+
session runs the same command by hand otherwise. A report that does not
|
|
135
|
+
parse — no
|
|
112
136
|
block, a word no gate returns, a blocker naming no rule, a stop naming no
|
|
113
137
|
blocker — is `incomplete`: the reviewer did not answer, which is neither a
|
|
114
138
|
pass nor a stop. Reading it as a pass is the failure the check exists to
|
|
@@ -118,13 +142,14 @@ travels one path to merge, in this order:
|
|
|
118
142
|
3. **Merge — on an explicit, non-lazy criterion.** Do not trust a watcher
|
|
119
143
|
command that can exit before the checks have even registered. Confirm that
|
|
120
144
|
the **required** check completed successfully **for this commit** — a list
|
|
121
|
-
that is merely "not failing yet" is not a pass. The concrete command is
|
|
122
|
-
|
|
145
|
+
that is merely "not failing yet" is not a pass. The concrete command is the
|
|
146
|
+
repository's documented check command when it names one; otherwise query
|
|
147
|
+
the hosting service's check runs by the exact head SHA.
|
|
123
148
|
|
|
124
|
-
**Post-merge tail:**
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
149
|
+
**Post-merge tail:** update `PLAN.md` (close the task, record any follow-up in
|
|
150
|
+
a queue). When the repository's own documentation declares a runtime or other
|
|
151
|
+
operational surface, run the health check it documents. Merge is not the finish
|
|
152
|
+
line; the queue and every declared operational check must reflect reality.
|
|
128
153
|
|
|
129
154
|
## PR policy
|
|
130
155
|
|
|
@@ -141,7 +166,7 @@ A change is done when **all** of these hold:
|
|
|
141
166
|
- [ ] A test written first demonstrates the new behavior (and failed before the change)
|
|
142
167
|
- [ ] The full test suite is green — nothing skipped, nothing weakened
|
|
143
168
|
- [ ] Lint and typecheck are clean
|
|
144
|
-
- [ ]
|
|
169
|
+
- [ ] Repository-specific invariants and declared boundaries remain satisfied
|
|
145
170
|
- [ ] No secrets, credentials, or personal data in code, config, or fixtures
|
|
146
171
|
- [ ] Docs touched by the change (README, rules) are updated
|
|
147
172
|
- [ ] The autonomy tier of the change was checked and honored (`autonomy.md`)
|
|
@@ -1193,7 +1193,7 @@ if (invokedDirectly()) {
|
|
|
1193
1193
|
process.stderr.write(
|
|
1194
1194
|
'decision-router: nothing in this project declares an elevated path, so the ' +
|
|
1195
1195
|
'`elevated-path` risk flag cannot be evaluated and no lane can be trusted. Add an ' +
|
|
1196
|
-
'`elevated-paths` block to
|
|
1196
|
+
'`elevated-paths` block to AGENTS.md or a rule file. Nothing was routed — treat ' +
|
|
1197
1197
|
'this as the expensive lane, never as a cheap one.\n',
|
|
1198
1198
|
);
|
|
1199
1199
|
process.exit(1);
|
|
@@ -36,15 +36,18 @@ import { dirname, join } from 'node:path';
|
|
|
36
36
|
* detecting. Reading the declaration removes that failure mode instead of
|
|
37
37
|
* monitoring it.
|
|
38
38
|
*
|
|
39
|
-
* `
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
39
|
+
* `AGENTS.md` carries the project's own paths — it is the canonical rulebook
|
|
40
|
+
* since RP-186, and `CLAUDE.md` is a short shim that imports it and declares
|
|
41
|
+
* no block of its own; reading both costs nothing when a not-yet-migrated
|
|
42
|
+
* rig still carries the full text in `CLAUDE.md`. Each stack layer's rule
|
|
43
|
+
* file carries the ones that only exist in that shape (`infra/` comes from
|
|
44
|
+
* the infrastructure layer, and a project without one must not declare it).
|
|
45
|
+
* Seeding every path in one place would declare directories that do not
|
|
46
|
+
* exist in half the targets — and a gate declared over a missing directory
|
|
47
|
+
* reports "clean" while looking nowhere.
|
|
45
48
|
*/
|
|
46
49
|
export const readDeclaredPaths = (projectRoot, { readFile = readFileSync, listDir = null } = {}) => {
|
|
47
|
-
const sources = [join(projectRoot, 'CLAUDE.md')];
|
|
50
|
+
const sources = [join(projectRoot, 'AGENTS.md'), join(projectRoot, 'CLAUDE.md')];
|
|
48
51
|
try {
|
|
49
52
|
const rulesDir = join(projectRoot, '.claude', 'rules');
|
|
50
53
|
const entries = listDir ? listDir(rulesDir) : readdirSync(rulesDir);
|
|
@@ -52,7 +55,7 @@ export const readDeclaredPaths = (projectRoot, { readFile = readFileSync, listDi
|
|
|
52
55
|
if (entry.endsWith('.md')) sources.push(join(rulesDir, entry));
|
|
53
56
|
}
|
|
54
57
|
} catch {
|
|
55
|
-
// no rules directory — CLAUDE.md alone then
|
|
58
|
+
// no rules directory — AGENTS.md and CLAUDE.md alone then
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
const declared = [];
|
|
@@ -396,7 +399,7 @@ export const sweep = ({ prs = [], elevatedPaths = [], epoch = null } = {}) => {
|
|
|
396
399
|
findings.push({
|
|
397
400
|
kind: 'no-elevated-paths-declared',
|
|
398
401
|
why:
|
|
399
|
-
'
|
|
402
|
+
'AGENTS.md declares no `elevated-paths` block, so this sweep cannot tell ' +
|
|
400
403
|
'an elevated merge from an ordinary one. Until it does, "no findings" ' +
|
|
401
404
|
'means "did not look".',
|
|
402
405
|
actions: ['journal-line', 'escalation-issue'],
|
|
@@ -208,6 +208,66 @@ export const manifestFilesOf = (root) => {
|
|
|
208
208
|
return files;
|
|
209
209
|
};
|
|
210
210
|
|
|
211
|
+
/** The closed set `layers.json` names today — the only strings `layersOf` ever echoes back. */
|
|
212
|
+
const KNOWN_LAYERS = ['process', 'workflow'];
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Which `layers.json` layer(s) this rig recorded installing (RP-180), or
|
|
216
|
+
* `null` when there is no manifest to read at all — a third answer, not a
|
|
217
|
+
* guess: `report()` prints nothing about layers in that case rather than
|
|
218
|
+
* claiming either "Core only" or "every layer".
|
|
219
|
+
*
|
|
220
|
+
* A manifest with no `layers` key (every release before RP-180) means "every
|
|
221
|
+
* layer" — the CLI's own default (`packages/cli/src/lib/manifest.ts`,
|
|
222
|
+
* `LEGACY_LAYERS`), restated here rather than imported: this script ships
|
|
223
|
+
* standalone into a generated rig and has no access to the CLI package.
|
|
224
|
+
*
|
|
225
|
+
* RP-180 round 3 security review: a PRESENT `layers` is filtered down to
|
|
226
|
+
* {@link KNOWN_LAYERS} and deduplicated — never echoed as typed, and never
|
|
227
|
+
* widened to "every layer" just because it failed to parse as a clean array.
|
|
228
|
+
* A committed manifest is untrusted input read by a script whose own output
|
|
229
|
+
* lands on a terminal: an unknown or non-string entry is dropped rather than
|
|
230
|
+
* printed, and a `layers` field that is present but not a usable array (the
|
|
231
|
+
* wrong type, or an array with nothing recognisable in it) reports as
|
|
232
|
+
* `[]` — the caller's own "nothing to say" — never silently promoted back to
|
|
233
|
+
* "every layer", which is reserved for the field's TRUE ABSENCE. Bounded
|
|
234
|
+
* work regardless of the array's length: filter + `Set` is one pass, and the
|
|
235
|
+
* result can never hold more than {@link KNOWN_LAYERS}'s own two entries, so
|
|
236
|
+
* a manifest naming the same layer 100,000 times costs no more render-time
|
|
237
|
+
* output than naming it once.
|
|
238
|
+
*
|
|
239
|
+
* ⚠ **Exported for this file's own tests only — not a stable contract.**
|
|
240
|
+
* Round 4 changed this function's own return shape (`string[] | null` to
|
|
241
|
+
* `{ known, unrecognisedCount }`) to carry the unrecognised count out to
|
|
242
|
+
* `report()`; a caller outside this module that had come to depend on the
|
|
243
|
+
* OLD shape would have broken silently. `report()`'s own return value
|
|
244
|
+
* (`layers: string[] | null`, `layersUnrecognisedCount: number`) is the
|
|
245
|
+
* stable, documented surface — read that, or the rendered/`--json` output,
|
|
246
|
+
* never this function's return value directly.
|
|
247
|
+
*/
|
|
248
|
+
export const layersOf = (root) => {
|
|
249
|
+
const parsed = readJson(path.join(root, ...MANIFEST_REL.split('/')));
|
|
250
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
|
251
|
+
if (!Object.prototype.hasOwnProperty.call(parsed, 'layers')) {
|
|
252
|
+
return { known: [...KNOWN_LAYERS], unrecognisedCount: 0 };
|
|
253
|
+
}
|
|
254
|
+
const { layers } = parsed;
|
|
255
|
+
if (!Array.isArray(layers)) return { known: [], unrecognisedCount: 0 };
|
|
256
|
+
const known = new Set();
|
|
257
|
+
let unrecognisedCount = 0;
|
|
258
|
+
// One pass, bounded regardless of the array's length: a manifest naming
|
|
259
|
+
// the same junk entry 100,000 times reports the same one count, never
|
|
260
|
+
// grows the output, and the entry itself is counted, never echoed.
|
|
261
|
+
for (const entry of layers) {
|
|
262
|
+
if (typeof entry === 'string' && KNOWN_LAYERS.includes(entry)) known.add(entry);
|
|
263
|
+
else unrecognisedCount += 1;
|
|
264
|
+
}
|
|
265
|
+
return { known: [...known], unrecognisedCount };
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
/** `workflow` reads as experimental everywhere doctor names it; every other layer is plain. */
|
|
269
|
+
const layerLabel = (layer) => (layer === 'workflow' ? `${layer} (experimental)` : layer);
|
|
270
|
+
|
|
211
271
|
/**
|
|
212
272
|
* The files directly in `dir`: `{ names, unreadable }`, or `null` when the
|
|
213
273
|
* directory itself cannot be listed. One entry that cannot be stat'ed (a dangling
|
|
@@ -307,9 +367,29 @@ export const report = (root) => {
|
|
|
307
367
|
const all = [...problems, ...audited.hooks];
|
|
308
368
|
const audit = { verdict: verdictOf(all.map((r) => r.mark)), hooks: all };
|
|
309
369
|
const absent = scopes.filter((scope) => !scope.present && scope.dir !== HOOKS_DIR).map((scope) => scope.dir);
|
|
370
|
+
const layerInfo = layersOf(root);
|
|
371
|
+
// `layers` is untrusted committed input, exactly like an exemption reason
|
|
372
|
+
// or a hook's own relative path elsewhere in this report — printed only
|
|
373
|
+
// through `printable`, never raw, and an unrecognised entry is COUNTED,
|
|
374
|
+
// never echoed. An empty `known` list (but a non-null result) means the
|
|
375
|
+
// field was PRESENT and had nothing recognisable in it, which reads
|
|
376
|
+
// differently from "nothing to say" and is worth its own line. A MIXED
|
|
377
|
+
// manifest (a known layer alongside junk) says both: the known layer by
|
|
378
|
+
// name, and that something else was there and dropped.
|
|
379
|
+
const layersLine = (() => {
|
|
380
|
+
if (layerInfo === null) return null;
|
|
381
|
+
const { known, unrecognisedCount } = layerInfo;
|
|
382
|
+
const suffix =
|
|
383
|
+
unrecognisedCount > 0
|
|
384
|
+
? ` (+${unrecognisedCount} unrecognised ${unrecognisedCount === 1 ? 'entry' : 'entries'})`
|
|
385
|
+
: '';
|
|
386
|
+
if (known.length === 0) return `**layers:** (unrecognised — see ${MANIFEST_REL})`;
|
|
387
|
+
return `**layers:** ${printable(known.map(layerLabel).join(', '))}${printable(suffix)}`;
|
|
388
|
+
})();
|
|
310
389
|
const lines = [
|
|
311
390
|
`**doctor** — verdict: ${audit.verdict}`,
|
|
312
391
|
'',
|
|
392
|
+
...(layersLine !== null ? [layersLine, ''] : []),
|
|
313
393
|
// Names come from the file system, reasons from a repo file; both are
|
|
314
394
|
// stripped of control bytes here, once, where they reach the terminal.
|
|
315
395
|
...audit.hooks.map((hook) => `- ${hook.mark} · ${printable(hook.rel)} — ${printable(hook.detail)}`),
|
|
@@ -320,7 +400,14 @@ export const report = (root) => {
|
|
|
320
400
|
`_Not checked by this script — still yours (${UNCHECKED.length}):_`,
|
|
321
401
|
...UNCHECKED.map((item) => `- ${item}`),
|
|
322
402
|
];
|
|
323
|
-
return {
|
|
403
|
+
return {
|
|
404
|
+
...audit,
|
|
405
|
+
scopes,
|
|
406
|
+
layers: layerInfo === null ? null : layerInfo.known,
|
|
407
|
+
layersUnrecognisedCount: layerInfo === null ? 0 : layerInfo.unrecognisedCount,
|
|
408
|
+
unchecked: UNCHECKED,
|
|
409
|
+
rendered: lines.join('\n'),
|
|
410
|
+
};
|
|
324
411
|
};
|
|
325
412
|
|
|
326
413
|
const invokedDirectly = () => {
|
|
@@ -347,7 +434,18 @@ if (invokedDirectly()) {
|
|
|
347
434
|
const result = report(root);
|
|
348
435
|
process.stdout.write(
|
|
349
436
|
args.includes('--json')
|
|
350
|
-
? `${JSON.stringify(
|
|
437
|
+
? `${JSON.stringify(
|
|
438
|
+
{
|
|
439
|
+
verdict: result.verdict,
|
|
440
|
+
hooks: result.hooks,
|
|
441
|
+
scopes: result.scopes,
|
|
442
|
+
layers: result.layers,
|
|
443
|
+
layersUnrecognisedCount: result.layersUnrecognisedCount,
|
|
444
|
+
unchecked: result.unchecked,
|
|
445
|
+
},
|
|
446
|
+
null,
|
|
447
|
+
2,
|
|
448
|
+
)}\n`
|
|
351
449
|
: `${result.rendered}\n`,
|
|
352
450
|
);
|
|
353
451
|
process.exit(result.verdict === 'STOP' ? 1 : 0);
|
|
@@ -439,14 +439,225 @@ const committedObjectOf = (projectRoot, path) => {
|
|
|
439
439
|
}
|
|
440
440
|
};
|
|
441
441
|
|
|
442
|
-
|
|
443
|
-
|
|
442
|
+
// RP-190: `--path <relativePath>` makes `hash-object` apply the same clean
|
|
443
|
+
// filter git itself would apply when staging that path — including the
|
|
444
|
+
// built-in CRLF-to-LF normalisation `core.autocrlf=true` performs on commit.
|
|
445
|
+
// Windows defaults `core.autocrlf` to `true`, so a checkout that
|
|
446
|
+
// re-materializes a tracked (LF-committed) claim record smudges it back to
|
|
447
|
+
// CRLF in the worktree; a bare `hash-object --stdin` (no `--path`) hashes
|
|
448
|
+
// those CRLF bytes literally, which can never equal the LF blob `HEAD:<path>`
|
|
449
|
+
// names, and the integrity check below reported "diverges" for a file whose
|
|
450
|
+
// CONTENT never changed. `--path` makes this comparison ask git's own
|
|
451
|
+
// question — "would `git add` at this path produce the committed blob" —
|
|
452
|
+
// instead of a byte-literal one: an edit git itself would see as a
|
|
453
|
+
// modification at this path still yields a different blob and still holds.
|
|
454
|
+
// LIMIT: the answer is now git's, so it follows this repository's attributes
|
|
455
|
+
// and configuration — a clean filter configured for the path decides what
|
|
456
|
+
// counts as "unmodified", exactly as it does for `git status`.
|
|
457
|
+
// Pinned in the generator's (absent in a generated rig)
|
|
458
|
+
// `test/template/revalidate.test.ts` › "does not report UNVERIFIABLE when a
|
|
459
|
+
// checkout re-materializes the tracked claim as CRLF", and in the generator's
|
|
460
|
+
// (absent in a generated rig) `test/template/revalidate.test.ts` › "still
|
|
461
|
+
// holds on a genuine content change to the tracked claim under
|
|
462
|
+
// core.autocrlf=true".
|
|
463
|
+
const objectOf = (projectRoot, raw, relativePath) =>
|
|
464
|
+
execFileSync('git', ['-C', projectRoot, 'hash-object', '--path', relativePath, '--stdin'], {
|
|
444
465
|
encoding: 'utf8',
|
|
445
466
|
env: withoutGitLocation(),
|
|
446
467
|
input: raw,
|
|
447
468
|
stdio: ['pipe', 'pipe', 'ignore'],
|
|
448
469
|
}).trim();
|
|
449
470
|
|
|
471
|
+
/**
|
|
472
|
+
* At `BEFORE_CLOSE`, `targetSha` is resolved right after this item's OWN PR
|
|
473
|
+
* merged and was fetched — so it almost always differs from the value the
|
|
474
|
+
* claim recorded at take-up, even when nothing else landed on the default
|
|
475
|
+
* branch. This asks a narrower question than "did the target move": did it
|
|
476
|
+
* move by EXACTLY the one commit that IS `mergeCommit` — the SHA the CALLER
|
|
477
|
+
* vouches for as this item's own merge (resolved from the tracker's own PR
|
|
478
|
+
* metadata, e.g. `gh pr view <pr> --json mergeCommit -q .mergeCommit.oid`,
|
|
479
|
+
* never guessed from anything inside this function) — AND that this exact
|
|
480
|
+
* checkout's own tree is what that merge commit actually shipped.
|
|
481
|
+
*
|
|
482
|
+
* This decides identity, never text. An earlier version matched the moved
|
|
483
|
+
* commit's SUBJECT LINE against the ticket id in parentheses; a review gate
|
|
484
|
+
* found that unsound on two counts — an empty commit message silently
|
|
485
|
+
* disappeared from a naive `git log` line count, and ANY commit merely
|
|
486
|
+
* mentioning the ticket (a stray doc commit, a revert of the real merge)
|
|
487
|
+
* satisfied a text match without having merged anything. Nothing here reads a
|
|
488
|
+
* commit message any more.
|
|
489
|
+
*
|
|
490
|
+
* A LATER review gate found the SHA-identity version still unsound:
|
|
491
|
+
* `mergeCommit` is pure caller attestation — the only check tying it to
|
|
492
|
+
* anything was `toSha === mergeCommit`, which a lazy or hostile
|
|
493
|
+
* `--merge-commit "$(git rev-parse origin/master)"` satisfies trivially, no
|
|
494
|
+
* matter what actually advanced the target. Conditions 5, 6 and 7 below are
|
|
495
|
+
* the fix: a LOCAL binding to `HEAD` in `projectRoot` that nothing but the
|
|
496
|
+
* actual checkout can satisfy. Condition 2 stays load-bearing on its own —
|
|
497
|
+
* it is the only place `mergeCommit` is ever compared against the REAL
|
|
498
|
+
* target advance (`toSha`); conditions 3 and 4 check `fromSha`/`toSha`
|
|
499
|
+
* without reading `mergeCommit` at all, so a caller could otherwise name a
|
|
500
|
+
* commit that is not the target's tip yet still shares HEAD's tree, and
|
|
501
|
+
* nothing past condition 2 would catch it.
|
|
502
|
+
*
|
|
503
|
+
* True only when ALL of:
|
|
504
|
+
* 1. `fromSha`, `toSha` and `mergeCommit` are each a syntactically valid git
|
|
505
|
+
* object id, and `fromSha !== toSha`;
|
|
506
|
+
* 2. `toSha === mergeCommit` — the target's current tip literally IS the SHA
|
|
507
|
+
* the caller named, not merely a commit somewhere in the range that
|
|
508
|
+
* mentions the item;
|
|
509
|
+
* 3. `fromSha` is a git-ancestor of `toSha` (`merge-base --is-ancestor`) — a
|
|
510
|
+
* target that moved BACKWARDS (a rewind, a force-push) is never exempted,
|
|
511
|
+
* no matter what `mergeCommit` claims;
|
|
512
|
+
* 4. `git rev-list --count fromSha..toSha` is exactly `1` — counting commits,
|
|
513
|
+
* not lines of text, so a foreign commit sharing the window with the real
|
|
514
|
+
* merge (whether or not IT carries an empty message) still holds;
|
|
515
|
+
* 5. `mergeCommit` is neither `HEAD` nor an ancestor of it, in `projectRoot`
|
|
516
|
+
* — `HEAD` must still be the genuine PRE-merge checkout (the loop's own
|
|
517
|
+
* task branch, not yet fast-forwarded onto the merge). Without this, a
|
|
518
|
+
* checkout already sitting on the merge would compare its tree against
|
|
519
|
+
* itself and pass vacuously;
|
|
520
|
+
* 6. `fromSha` IS an ancestor of `HEAD` — `HEAD`'s own history must descend
|
|
521
|
+
* from the same baseline the target advanced from, so a tree match can
|
|
522
|
+
* only mean "this checkout's work is what the merge shipped", never a
|
|
523
|
+
* coincidence from an unrelated checkout that happens to hold the same
|
|
524
|
+
* files;
|
|
525
|
+
* 7. `git rev-parse mergeCommit^{tree}` equals `git rev-parse HEAD^{tree}` —
|
|
526
|
+
* the exempted commit's TREE is byte-identical to this checkout's own
|
|
527
|
+
* tree. A foreign commit whose tree DIFFERS is refused by this condition;
|
|
528
|
+
* a stale squash (the target moved before it landed) carries that extra
|
|
529
|
+
* content and is refused the same way. This condition judges CONTENT,
|
|
530
|
+
* never provenance: a commit built by any other route that happens to
|
|
531
|
+
* carry the exact byte-identical tree — meaning the target's content
|
|
532
|
+
* really is exactly what this run's own merge would have produced — is
|
|
533
|
+
* indistinguishable from a genuine squash merge and IS exempted. That is
|
|
534
|
+
* judged correct, not a gap: what is being protected is the target's
|
|
535
|
+
* content, not the mechanism that produced it.
|
|
536
|
+
*
|
|
537
|
+
* Any other shape — no `mergeCommit` supplied, a mismatch, a non-ancestor
|
|
538
|
+
* `fromSha`, more than one commit in range, `HEAD` already at or past the
|
|
539
|
+
* merge, `HEAD` not descended from `fromSha`, a tree mismatch, or git itself
|
|
540
|
+
* failing to resolve any of the above — returns false, and the caller keeps
|
|
541
|
+
* treating the target movement as scope drift exactly as it did before this
|
|
542
|
+
* existed. The false side is the safe default: this function only ever makes
|
|
543
|
+
* `scopeMoved` MORE permissive, never less, and every path that cannot prove
|
|
544
|
+
* "this SHA is my own merge, and this checkout IS the work it merged" falls
|
|
545
|
+
* back to holding.
|
|
546
|
+
*
|
|
547
|
+
* ⚠ What this function still CANNOT prove: it stops a LAZY or MISTAKEN
|
|
548
|
+
* `--merge-commit`, not an adversary who controls the run. `HEAD` is this
|
|
549
|
+
* run's own checkout — a hostile run can move it (`git checkout`,
|
|
550
|
+
* `git reset`) to whatever it likes before calling this, and `targetShaOf`
|
|
551
|
+
* (below) reads a local ref (`origin/HEAD`/`master`/`main`) a hostile run has
|
|
552
|
+
* already had every opportunity to rewrite. Every input here is something the
|
|
553
|
+
* SAME process that calls this function could have fabricated; the binding
|
|
554
|
+
* only raises the cost of an honest mistake, it does not authenticate the
|
|
555
|
+
* caller.
|
|
556
|
+
*
|
|
557
|
+
* Pinned in the generator's `test/template/revalidate.test.ts`
|
|
558
|
+
* — absent in a generated rig — › "the item's own REAL squash merge (actual
|
|
559
|
+
* file content, not an empty commit), nothing foreign, correctly named by
|
|
560
|
+
* --merge-commit", › "no --merge-commit supplied at all, even though the
|
|
561
|
+
* target moved by exactly one commit that would otherwise exempt it", › "a
|
|
562
|
+
* --merge-commit that does not match the target's new tip (the old recorded
|
|
563
|
+
* baseline sha)", › "a foreign commit plus the real merge in the same range,
|
|
564
|
+
* --merge-commit correctly naming the real one", › "an empty-message commit
|
|
565
|
+
* landing before the real merge, --merge-commit correctly naming the real
|
|
566
|
+
* one (a commit COUNT still catches it even though its subject line is
|
|
567
|
+
* blank)", › "a foreign EMPTY commit ahead of the genuine squash still holds
|
|
568
|
+
* — only the commit COUNT refuses it (conditions 2, 3, 5, 6 and 7 all pass
|
|
569
|
+
* on their own)", › "holds when content also drifted even though --merge-commit
|
|
570
|
+
* correctly identifies the target advance", › "rejects a target that moved
|
|
571
|
+
* backward off the recorded baseline, even though the final commit alone
|
|
572
|
+
* would look like a correct single-commit identity match", › "only exempts
|
|
573
|
+
* at BEFORE_CLOSE — the identical own-merge shape still holds on claim:scope
|
|
574
|
+
* at BEFORE_PR", › "a single FOREIGN commit named as --merge-commit does not
|
|
575
|
+
* exempt it — reproduces the gate-hold attack and proves it now holds", ›
|
|
576
|
+
* "a FABRICATED --merge-commit sharing HEAD's tree, correctly rooted at the
|
|
577
|
+
* baseline, but naming a commit the target never actually advanced to,
|
|
578
|
+
* still holds (condition 2 is load-bearing on its own)", › "HEAD already at
|
|
579
|
+
* the merge commit is vacuous, and still holds (a fast-forwarded checkout
|
|
580
|
+
* must not self-satisfy the exemption)", › "a squash merge of a branch that
|
|
581
|
+
* was NOT up to date still holds, even with a correctly-named
|
|
582
|
+
* --merge-commit (acceptable: the safe side)", › "a tree that matches by
|
|
583
|
+
* coincidence but shares no ancestry with the recorded baseline still holds
|
|
584
|
+
* (lineage, not just content)", › "a --merge-commit that is well-formed but
|
|
585
|
+
* names no object this repository has holds (git failure is the safe side,
|
|
586
|
+
* not a crash)", › "a foreign commit landed by another route is exempted
|
|
587
|
+
* when its tree is byte-identical to HEAD's (condition 7 judges content,
|
|
588
|
+
* not provenance)", › "pins evidence.ownMergeAdvance on the genuine
|
|
589
|
+
* own-merge case" and › "carries no evidence.ownMergeAdvance when content
|
|
590
|
+
* also drifted (the `!scopeContentMoved &&` conjunct only ever governs
|
|
591
|
+
* whether this evidence is emitted, never the verdict — content drift
|
|
592
|
+
* already holds on its own)".
|
|
593
|
+
*/
|
|
594
|
+
const isAncestorOf = (projectRoot, ancestor, descendant) => {
|
|
595
|
+
try {
|
|
596
|
+
execFileSync('git', ['-C', projectRoot, 'merge-base', '--is-ancestor', ancestor, descendant], {
|
|
597
|
+
env: withoutGitLocation(),
|
|
598
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
599
|
+
});
|
|
600
|
+
return true;
|
|
601
|
+
} catch {
|
|
602
|
+
return false;
|
|
603
|
+
}
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
const revParseOrNull = (projectRoot, revision) => {
|
|
607
|
+
try {
|
|
608
|
+
return execFileSync('git', ['-C', projectRoot, 'rev-parse', '--verify', revision], {
|
|
609
|
+
encoding: 'utf8',
|
|
610
|
+
env: withoutGitLocation(),
|
|
611
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
612
|
+
}).trim();
|
|
613
|
+
} catch {
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
const isOwnMergeAdvance = (projectRoot, fromSha, toSha, mergeCommit) => {
|
|
619
|
+
if (
|
|
620
|
+
!GIT_OBJECT_ID.test(fromSha ?? '') ||
|
|
621
|
+
!GIT_OBJECT_ID.test(toSha ?? '') ||
|
|
622
|
+
!GIT_OBJECT_ID.test(mergeCommit ?? '') ||
|
|
623
|
+
fromSha === toSha ||
|
|
624
|
+
toSha !== mergeCommit
|
|
625
|
+
) {
|
|
626
|
+
return false;
|
|
627
|
+
}
|
|
628
|
+
if (!isAncestorOf(projectRoot, fromSha, toSha)) return false;
|
|
629
|
+
let count;
|
|
630
|
+
try {
|
|
631
|
+
count = execFileSync(
|
|
632
|
+
'git',
|
|
633
|
+
['-C', projectRoot, 'rev-list', '--count', `${fromSha}..${toSha}`],
|
|
634
|
+
{
|
|
635
|
+
encoding: 'utf8',
|
|
636
|
+
env: withoutGitLocation(),
|
|
637
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
638
|
+
},
|
|
639
|
+
).trim();
|
|
640
|
+
} catch {
|
|
641
|
+
return false;
|
|
642
|
+
}
|
|
643
|
+
if (count !== '1') return false;
|
|
644
|
+
|
|
645
|
+
// The non-attestable part: bind the exempted commit to THIS checkout, not
|
|
646
|
+
// merely to the caller's say-so. See the doc comment above for what
|
|
647
|
+
// conditions 5-7 prove and what they still cannot.
|
|
648
|
+
const head = revParseOrNull(projectRoot, 'HEAD');
|
|
649
|
+
if (head === null) return false;
|
|
650
|
+
// 5. Vacuity guard — HEAD must be the genuine pre-merge checkout.
|
|
651
|
+
if (head === mergeCommit || isAncestorOf(projectRoot, mergeCommit, head)) return false;
|
|
652
|
+
// 6. Lineage guard — HEAD must descend from the same recorded baseline.
|
|
653
|
+
if (!isAncestorOf(projectRoot, fromSha, head)) return false;
|
|
654
|
+
// 7. The actual content binding.
|
|
655
|
+
const mergeTree = revParseOrNull(projectRoot, `${mergeCommit}^{tree}`);
|
|
656
|
+
const headTree = revParseOrNull(projectRoot, `${head}^{tree}`);
|
|
657
|
+
if (mergeTree === null || headTree === null) return false;
|
|
658
|
+
return mergeTree === headTree;
|
|
659
|
+
};
|
|
660
|
+
|
|
450
661
|
const readClaim = (projectRoot, path) => {
|
|
451
662
|
const raw = readRepositoryFile(projectRoot, path, {
|
|
452
663
|
label: 'claim record',
|
|
@@ -586,6 +797,7 @@ export const revalidateClaim = ({
|
|
|
586
797
|
point,
|
|
587
798
|
claimedState = 'in-progress',
|
|
588
799
|
targetSha,
|
|
800
|
+
mergeCommit = null,
|
|
589
801
|
allowCreate = false,
|
|
590
802
|
isResume = false,
|
|
591
803
|
}) => {
|
|
@@ -708,7 +920,7 @@ export const revalidateClaim = ({
|
|
|
708
920
|
let claim;
|
|
709
921
|
try {
|
|
710
922
|
const read = readClaim(projectRoot, path);
|
|
711
|
-
if (objectOf(projectRoot, read.raw) !== committedObject) {
|
|
923
|
+
if (objectOf(projectRoot, read.raw, pointer) !== committedObject) {
|
|
712
924
|
throw new Error('tracked claim worktree content diverges from its committed Git version');
|
|
713
925
|
}
|
|
714
926
|
claim = read.parsed;
|
|
@@ -745,9 +957,24 @@ export const revalidateClaim = ({
|
|
|
745
957
|
pairedFacts,
|
|
746
958
|
});
|
|
747
959
|
|
|
748
|
-
const
|
|
749
|
-
|
|
750
|
-
|
|
960
|
+
const scopeContentMoved = claim.fingerprints.scope.value !== current.scope.value;
|
|
961
|
+
const scopeTargetMoved = claim.fingerprints.scope.targetSha !== current.scope.targetSha;
|
|
962
|
+
// The only widening this change makes: a target that moved by nothing but
|
|
963
|
+
// this item's own squash merge is not scope drift. Content drift and
|
|
964
|
+
// commentary drift (below) stay hold-authoritative no matter what moved the
|
|
965
|
+
// target, and this only ever runs at BEFORE_CLOSE — BEFORE_PR has no merge
|
|
966
|
+
// of its own yet to exempt.
|
|
967
|
+
const scopeTargetIsOwnMerge =
|
|
968
|
+
point === 'BEFORE_CLOSE' &&
|
|
969
|
+
scopeTargetMoved &&
|
|
970
|
+
!scopeContentMoved &&
|
|
971
|
+
isOwnMergeAdvance(
|
|
972
|
+
projectRoot,
|
|
973
|
+
claim.fingerprints.scope.targetSha,
|
|
974
|
+
current.scope.targetSha,
|
|
975
|
+
mergeCommit,
|
|
976
|
+
);
|
|
977
|
+
const scopeMoved = scopeContentMoved || (scopeTargetMoved && !scopeTargetIsOwnMerge);
|
|
751
978
|
const commentaryMoved = claim.fingerprints.commentary.value !== current.commentary.value;
|
|
752
979
|
const movedFingerprintSet = [
|
|
753
980
|
...(scopeMoved ? ['scope'] : []),
|
|
@@ -763,6 +990,9 @@ export const revalidateClaim = ({
|
|
|
763
990
|
evidence: {
|
|
764
991
|
claim: pointer,
|
|
765
992
|
...(commentaryMoved && point !== 'BEFORE_CLOSE' ? { observedFingerprintSet: ['commentary'] } : {}),
|
|
993
|
+
...(scopeTargetIsOwnMerge
|
|
994
|
+
? { ownMergeAdvance: { from: claim.fingerprints.scope.targetSha, to: current.scope.targetSha } }
|
|
995
|
+
: {}),
|
|
766
996
|
},
|
|
767
997
|
identity: fingerprintIdentity(current),
|
|
768
998
|
});
|
|
@@ -138,7 +138,7 @@ export const recordCompletedTier = ({ changedFiles, projectRoot, statePath, runD
|
|
|
138
138
|
if (!declared || declared.length === 0) {
|
|
139
139
|
throw new Error(
|
|
140
140
|
'nothing in this project declares an elevated path, so no tier can be ' +
|
|
141
|
-
'computed: add an `elevated-paths` block to
|
|
141
|
+
'computed: add an `elevated-paths` block to AGENTS.md or a rule file. ' +
|
|
142
142
|
'Treating the absence as `normal` would ration on a declaration that ' +
|
|
143
143
|
'does not exist.',
|
|
144
144
|
);
|
|
@@ -157,7 +157,7 @@ export const recordCompletedTier = ({ changedFiles, projectRoot, statePath, runD
|
|
|
157
157
|
// and silently relocating it would make it useless.
|
|
158
158
|
//
|
|
159
159
|
// Note the asymmetry, which is deliberate: the DECLARATION is read from the
|
|
160
|
-
// given `projectRoot` (the worktree's own `
|
|
160
|
+
// given `projectRoot` (the worktree's own `AGENTS.md` is the rulebook the
|
|
161
161
|
// change was written against), while the STATE goes to the checkout that
|
|
162
162
|
// outlives the task.
|
|
163
163
|
const file = statePath ?? join(mainCheckoutRoot(projectRoot), '.claude', 'queue.state.json');
|
|
@@ -22,9 +22,10 @@ import { dirname, join } from 'node:path';
|
|
|
22
22
|
// Imported, never copied: one home for the elevated declaration and one home for
|
|
23
23
|
// the lane rule. A second copy of either would drift, and each copy would keep
|
|
24
24
|
// passing on its own.
|
|
25
|
-
// `readDeclaredPaths` unions
|
|
26
|
-
//
|
|
27
|
-
//
|
|
25
|
+
// `readDeclaredPaths` unions AGENTS.md (the canonical rulebook since RP-186)
|
|
26
|
+
// and CLAUDE.md with every .claude/rules/*.md declaration, so this sweep sees
|
|
27
|
+
// exactly what the gate sweep sees — including the paths a stack layer
|
|
28
|
+
// contributes for its own shape.
|
|
28
29
|
import { elevatedPathsIn, laneOf, readDeclaredPaths } from './detect-missed-gate.mjs';
|
|
29
30
|
// The one credential vocabulary (`guard-secret-file`, the commit sweep and this
|
|
30
31
|
// diagnostic all read it): a second list of token shapes here would drift.
|