gateproof 0.2.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +1396 -320
  2. package/dist/cloudflare/index.d.ts +4 -6
  3. package/dist/cloudflare/index.d.ts.map +1 -1
  4. package/dist/cloudflare/index.js +9 -43
  5. package/dist/cloudflare/index.js.map +1 -1
  6. package/dist/index.d.ts +263 -66
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1327 -204
  9. package/dist/index.js.map +1 -1
  10. package/package.json +18 -40
  11. package/dist/act.d.ts +0 -33
  12. package/dist/act.d.ts.map +0 -1
  13. package/dist/act.js +0 -25
  14. package/dist/act.js.map +0 -1
  15. package/dist/action-executors.d.ts +0 -22
  16. package/dist/action-executors.d.ts.map +0 -1
  17. package/dist/action-executors.js +0 -135
  18. package/dist/action-executors.js.map +0 -1
  19. package/dist/assert.d.ts +0 -39
  20. package/dist/assert.d.ts.map +0 -1
  21. package/dist/assert.js +0 -88
  22. package/dist/assert.js.map +0 -1
  23. package/dist/cli/gateproof.d.ts +0 -3
  24. package/dist/cli/gateproof.d.ts.map +0 -1
  25. package/dist/cli/gateproof.js +0 -472
  26. package/dist/cli/gateproof.js.map +0 -1
  27. package/dist/cloudflare/analytics.d.ts +0 -9
  28. package/dist/cloudflare/analytics.d.ts.map +0 -1
  29. package/dist/cloudflare/analytics.js +0 -98
  30. package/dist/cloudflare/analytics.js.map +0 -1
  31. package/dist/cloudflare/cli-stream.d.ts +0 -7
  32. package/dist/cloudflare/cli-stream.d.ts.map +0 -1
  33. package/dist/cloudflare/cli-stream.js +0 -85
  34. package/dist/cloudflare/cli-stream.js.map +0 -1
  35. package/dist/cloudflare/polling-backend.d.ts +0 -18
  36. package/dist/cloudflare/polling-backend.d.ts.map +0 -1
  37. package/dist/cloudflare/polling-backend.js +0 -53
  38. package/dist/cloudflare/polling-backend.js.map +0 -1
  39. package/dist/cloudflare/workers-logs.d.ts +0 -9
  40. package/dist/cloudflare/workers-logs.d.ts.map +0 -1
  41. package/dist/cloudflare/workers-logs.js +0 -51
  42. package/dist/cloudflare/workers-logs.js.map +0 -1
  43. package/dist/constants.d.ts +0 -11
  44. package/dist/constants.d.ts.map +0 -1
  45. package/dist/constants.js +0 -11
  46. package/dist/constants.js.map +0 -1
  47. package/dist/http-backend.d.ts +0 -23
  48. package/dist/http-backend.d.ts.map +0 -1
  49. package/dist/http-backend.js +0 -124
  50. package/dist/http-backend.js.map +0 -1
  51. package/dist/observe.d.ts +0 -26
  52. package/dist/observe.d.ts.map +0 -1
  53. package/dist/observe.js +0 -84
  54. package/dist/observe.js.map +0 -1
  55. package/dist/prd/define-prd.d.ts +0 -7
  56. package/dist/prd/define-prd.d.ts.map +0 -1
  57. package/dist/prd/define-prd.js +0 -8
  58. package/dist/prd/define-prd.js.map +0 -1
  59. package/dist/prd/index.d.ts +0 -5
  60. package/dist/prd/index.d.ts.map +0 -1
  61. package/dist/prd/index.js +0 -4
  62. package/dist/prd/index.js.map +0 -1
  63. package/dist/prd/runner.d.ts +0 -22
  64. package/dist/prd/runner.d.ts.map +0 -1
  65. package/dist/prd/runner.js +0 -221
  66. package/dist/prd/runner.js.map +0 -1
  67. package/dist/prd/scope-check.d.ts +0 -28
  68. package/dist/prd/scope-check.d.ts.map +0 -1
  69. package/dist/prd/scope-check.js +0 -135
  70. package/dist/prd/scope-check.js.map +0 -1
  71. package/dist/prd/types.d.ts +0 -22
  72. package/dist/prd/types.d.ts.map +0 -1
  73. package/dist/prd/types.js +0 -2
  74. package/dist/prd/types.js.map +0 -1
  75. package/dist/provider.d.ts +0 -6
  76. package/dist/provider.d.ts.map +0 -1
  77. package/dist/provider.js +0 -2
  78. package/dist/provider.js.map +0 -1
  79. package/dist/report.d.ts +0 -67
  80. package/dist/report.d.ts.map +0 -1
  81. package/dist/report.js +0 -51
  82. package/dist/report.js.map +0 -1
  83. package/dist/test-helpers.d.ts +0 -12
  84. package/dist/test-helpers.d.ts.map +0 -1
  85. package/dist/test-helpers.js +0 -33
  86. package/dist/test-helpers.js.map +0 -1
  87. package/dist/types.d.ts +0 -41
  88. package/dist/types.d.ts.map +0 -1
  89. package/dist/types.js +0 -2
  90. package/dist/types.js.map +0 -1
  91. package/dist/utils.d.ts +0 -22
  92. package/dist/utils.d.ts.map +0 -1
  93. package/dist/utils.js +0 -49
  94. package/dist/utils.js.map +0 -1
  95. package/dist/validation.d.ts +0 -6
  96. package/dist/validation.d.ts.map +0 -1
  97. package/dist/validation.js +0 -38
  98. package/dist/validation.js.map +0 -1
package/README.md CHANGED
@@ -1,440 +1,1516 @@
1
- # gateproof
1
+ # Gateproof
2
2
 
3
- Build software in reverse. PRD defines what should exist. Gates verify reality. Agent iterations refine until gates pass.
3
+ Gateproof runs the proof, sends in the worker, and keeps going until the live claim is true.
4
4
 
5
- ## What gateproof does
5
+ ## Tutorial
6
6
 
7
- gateproof enables **agent iterations** with minimal context overhead.
7
+ Goal: Start with one tiny gate that is small on purpose and complete on purpose.
8
8
 
9
- **The workflow:**
10
- 1. PRD defines stories (what should exist)
11
- 2. Gates verify reality (does it work?)
12
- 3. Agent gets PRD + gate failure (minimal context)
13
- 4. Agent fixes, gates re-run
14
- 5. Iterate until all gates pass
9
+ ### examples/hello-world/plan.ts
15
10
 
16
- **Why this works:**
17
- - PRD is single source of truth (clear intent, minimal context)
18
- - Gates provide concrete feedback (not vague requirements)
19
- - Agent gets context only when needed (efficient)
20
- - Iteration ensures correctness (converges to working code)
11
+ ```ts
12
+ import { Effect } from "effect";
13
+ import {
14
+ Act,
15
+ Assert,
16
+ Gate,
17
+ Plan,
18
+ createHttpObserveResource,
19
+ type ScopeFile,
20
+ } from "../../src/index";
21
+ import { HELLO_WORLD_PORT } from "./server";
21
22
 
22
- gateproof **executes gates**. It does not define intent, plans, or workflows. A gate is a test specification: observe logs, run actions, assert results. gateproof runs it and returns evidence.
23
+ const baseUrl = `http://127.0.0.1:${HELLO_WORLD_PORT}`;
23
24
 
24
- **Authority chain:**
25
- - **PRD (`prd.ts`)** — authority on intent, order, and dependencies (if you use the PRD runner)
26
- - **Gate implementations** — authority on how reality is observed
27
- - **gateproof runtime** — authority on enforcement only
28
-
29
- gateproof never decides *what* to build. It returns results; your CI/CD decides whether you are allowed to proceed.
30
-
31
- ## Agent skill: prdts-maker
32
-
33
- This repo is agent-first. Use the `prdts-maker` skill to turn a prompt into a working `prd.ts`.
34
-
35
- **How to use it:**
36
- - Provide a prompt (big blob of text is fine).
37
- - Ask the agent to run the `prdts-maker` skill and output a complete `prd.ts`.
38
- - Save and run: `bun run prd.ts`.
39
-
40
- **Example prompt:**
41
- ```text
42
- @prdts-maker Create prd.ts for:
43
- - User can sign up
44
- - Email verification works (depends on signup)
45
- - User can log in (depends on verification)
46
- Include gate files under ./gates/.
47
- ```
25
+ const scope = {
26
+ spec: {
27
+ title: "Hello World",
28
+ tutorial: {
29
+ goal: "Prove one tiny thing.",
30
+ outcome: "The run only passes when the live response says hello world.",
31
+ },
32
+ howTo: {
33
+ task: "Run one complete gate from one file.",
34
+ done: "The endpoint returns 200 and the body contains hello world.",
35
+ },
36
+ explanation: {
37
+ summary: "Even the smallest run is still a real proof loop.",
38
+ },
39
+ },
40
+ plan: Plan.define({
41
+ goals: [
42
+ {
43
+ id: "hello-world",
44
+ title: "GET / returns hello world",
45
+ gate: Gate.define({
46
+ observe: createHttpObserveResource({
47
+ url: `${baseUrl}/`,
48
+ }),
49
+ act: [Act.exec(`curl -sf ${baseUrl}/`)],
50
+ assert: [
51
+ Assert.httpResponse({ status: 200 }),
52
+ Assert.responseBodyIncludes("hello world"),
53
+ Assert.noErrors(),
54
+ ],
55
+ }),
56
+ },
57
+ ],
58
+ loop: {
59
+ maxIterations: 1,
60
+ stopOnFailure: true,
61
+ },
62
+ }),
63
+ } satisfies ScopeFile;
48
64
 
49
- ## CLI: npx gateproof prdts
65
+ export default scope;
50
66
 
51
- Generate a `prd.ts` from a prompt without opening the repo.
67
+ if (import.meta.main) {
68
+ const result = await Effect.runPromise(Plan.runLoop(scope.plan));
69
+ console.log(JSON.stringify(result, null, 2));
52
70
 
53
- ```bash
54
- echo "Build a signup flow with email verification and login" | npx gateproof prdts --stdout
55
- npx gateproof prdts --in stories.txt --out prd.ts
71
+ if (result.status !== "pass") {
72
+ process.exitCode = 1;
73
+ }
74
+ }
56
75
  ```
57
76
 
58
- This calls Opencode directly. Set `OPENCODE_ZEN_API_KEY` (or pass `--api-key`).
77
+ Outcome: The loop only passes when the live response says hello world.
78
+
79
+ ## First Case Study: Cinder
80
+
81
+ ### alchemy.run.ts
82
+
83
+ ```ts
84
+ import alchemy from "alchemy";
85
+ import { Buffer } from "node:buffer";
86
+ import { mkdir } from "node:fs/promises";
87
+ import { R2RestStateStore } from "alchemy/state";
88
+ import {
89
+ CloudflareApiError,
90
+ DurableObjectNamespace,
91
+ KVNamespace,
92
+ R2Bucket,
93
+ Worker,
94
+ createBucket,
95
+ createCloudflareApi,
96
+ getBucket,
97
+ } from "alchemy/cloudflare";
98
+
99
+ function requireEnv(name: string): string {
100
+ const value = process.env[name]?.trim();
101
+
102
+ if (!value) {
103
+ throw new Error(`${name} is required for cinder provisioning`);
104
+ }
59
105
 
60
- Paste mode (interactive stdin):
106
+ return value;
107
+ }
61
108
 
62
- ```bash
63
- npx gateproof prdts
64
- # paste a prompt, then Ctrl-D
65
- ```
109
+ const githubPat = requireEnv("GITHUB_PAT");
110
+ const webhookSecret = requireEnv("GITHUB_WEBHOOK_SECRET");
111
+ const internalToken = requireEnv("CINDER_INTERNAL_TOKEN");
112
+ const fixtureRepo = process.env.CINDER_FIXTURE_REPO?.trim() || "acoyfellow/cinder-prd-test";
113
+ const fixtureBranch = process.env.CINDER_FIXTURE_BRANCH?.trim() || "main";
114
+ const fixtureWorkflow =
115
+ process.env.CINDER_FIXTURE_WORKFLOW?.trim() || "cinder-proof.yml";
116
+ const githubApiBase = "https://api.github.com";
117
+ const stateBucketName = process.env.CINDER_STATE_BUCKET?.trim();
118
+ const stateBucketRegion = process.env.CINDER_STATE_REGION?.trim() || "auto";
119
+
120
+ const fixtureCargoToml = `[package]
121
+ name = "cinder-proof"
122
+ version = "0.1.0"
123
+ edition = "2021"
124
+
125
+ [dependencies]
126
+ serde = { version = "1", features = ["derive"] }
127
+ serde_json = "1"
128
+ `;
129
+
130
+ const fixtureCargoLock = `# This file is automatically @generated by Cargo.
131
+ # It is not intended for manual editing.
132
+ version = 4
133
+
134
+ [[package]]
135
+ name = "cinder-proof"
136
+ version = "0.1.0"
137
+ dependencies = [
138
+ "serde",
139
+ "serde_json",
140
+ ]
141
+
142
+ [[package]]
143
+ name = "itoa"
144
+ version = "1.0.17"
145
+ source = "registry+https://github.com/rust-lang/crates.io-index"
146
+ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
147
+
148
+ [[package]]
149
+ name = "memchr"
150
+ version = "2.8.0"
151
+ source = "registry+https://github.com/rust-lang/crates.io-index"
152
+ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
153
+
154
+ [[package]]
155
+ name = "proc-macro2"
156
+ version = "1.0.106"
157
+ source = "registry+https://github.com/rust-lang/crates.io-index"
158
+ checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
159
+ dependencies = [
160
+ "unicode-ident",
161
+ ]
162
+
163
+ [[package]]
164
+ name = "quote"
165
+ version = "1.0.44"
166
+ source = "registry+https://github.com/rust-lang/crates.io-index"
167
+ checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
168
+ dependencies = [
169
+ "proc-macro2",
170
+ ]
171
+
172
+ [[package]]
173
+ name = "serde"
174
+ version = "1.0.228"
175
+ source = "registry+https://github.com/rust-lang/crates.io-index"
176
+ checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
177
+ dependencies = [
178
+ "serde_core",
179
+ "serde_derive",
180
+ ]
181
+
182
+ [[package]]
183
+ name = "serde_core"
184
+ version = "1.0.228"
185
+ source = "registry+https://github.com/rust-lang/crates.io-index"
186
+ checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
187
+ dependencies = [
188
+ "serde_derive",
189
+ ]
190
+
191
+ [[package]]
192
+ name = "serde_derive"
193
+ version = "1.0.228"
194
+ source = "registry+https://github.com/rust-lang/crates.io-index"
195
+ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
196
+ dependencies = [
197
+ "proc-macro2",
198
+ "quote",
199
+ "syn",
200
+ ]
201
+
202
+ [[package]]
203
+ name = "serde_json"
204
+ version = "1.0.149"
205
+ source = "registry+https://github.com/rust-lang/crates.io-index"
206
+ checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
207
+ dependencies = [
208
+ "itoa",
209
+ "memchr",
210
+ "serde",
211
+ "serde_core",
212
+ "zmij",
213
+ ]
214
+
215
+ [[package]]
216
+ name = "syn"
217
+ version = "2.0.117"
218
+ source = "registry+https://github.com/rust-lang/crates.io-index"
219
+ checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
220
+ dependencies = [
221
+ "proc-macro2",
222
+ "quote",
223
+ "unicode-ident",
224
+ ]
225
+
226
+ [[package]]
227
+ name = "unicode-ident"
228
+ version = "1.0.24"
229
+ source = "registry+https://github.com/rust-lang/crates.io-index"
230
+ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
231
+
232
+ [[package]]
233
+ name = "zmij"
234
+ version = "1.0.21"
235
+ source = "registry+https://github.com/rust-lang/crates.io-index"
236
+ checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
237
+ `;
238
+
239
+ const fixtureMainRs = `use serde::{Deserialize, Serialize};
240
+
241
+ #[derive(Serialize, Deserialize)]
242
+ struct ProofMessage {
243
+ ok: bool,
244
+ source: &'static str,
245
+ }
66
246
 
67
- To target a different Opencode base URL or model:
247
+ fn main() {
248
+ let message = ProofMessage {
249
+ ok: true,
250
+ source: "cinder-proof",
251
+ };
68
252
 
69
- ```bash
70
- npx gateproof prdts --endpoint https://opencode.ai/zen/v1 --model big-pickle --in stories.txt --out prd.ts
71
- ```
253
+ println!("{}", serde_json::to_string(&message).unwrap());
254
+ }
255
+ `;
256
+
257
+ const fixtureWorkflowContents = `name: cinder-proof
258
+ on:
259
+ workflow_dispatch:
260
+ jobs:
261
+ cargo-build:
262
+ runs-on: [self-hosted, cinder]
263
+ timeout-minutes: 20
264
+ steps:
265
+ - uses: actions/checkout@v4
266
+ - name: Build fixture
267
+ run: cargo build --locked
268
+ - name: Run fixture
269
+ run: cargo run --locked
270
+ `;
271
+
272
+ function parseFixtureRepository(repoRef: string) {
273
+ const [owner, name, ...extra] = repoRef.split("/");
274
+
275
+ if (!owner || !name || extra.length > 0) {
276
+ throw new Error(
277
+ `CINDER_FIXTURE_REPO must be "owner/name" but received "${repoRef}"`,
278
+ );
279
+ }
72
280
 
73
- ## Agent Iterations: The Loop
281
+ return { owner, name };
282
+ }
74
283
 
75
- The core innovation: agents work from PRD only, gates verify, iterate until correct.
284
+ async function githubRequest(
285
+ path: string,
286
+ init: RequestInit = {},
287
+ okStatuses: number[] = [200],
288
+ ) {
289
+ const response = await fetch(`${githubApiBase}${path}`, {
290
+ ...init,
291
+ headers: {
292
+ Accept: "application/vnd.github+json",
293
+ Authorization: `Bearer ${githubPat}`,
294
+ "User-Agent": "cinder-provisioner",
295
+ "X-GitHub-Api-Version": "2022-11-28",
296
+ ...(init.headers ?? {}),
297
+ },
298
+ });
76
299
 
77
- **The iteration loop:**
78
- 1. Run PRD → executes gates in dependency order
79
- 2. Gate fails → agent gets: codebase context (e.g., `AGENTS.md`) + failure output
80
- 3. Agent fixes → makes changes to codebase
81
- 4. Loop repeats → re-run PRD, check if gates pass
82
- 5. All gates pass → done
300
+ if (okStatuses.includes(response.status)) {
301
+ return response;
302
+ }
83
303
 
84
- **Why minimal context:**
85
- - Agent starts with PRD only (no full codebase upfront)
86
- - Agent gets context only when gates fail (just-in-time)
87
- - PRD stays as authority (what to build)
88
- - Gates provide concrete feedback (what's wrong)
304
+ const body = await response.text();
305
+ throw new Error(
306
+ `GitHub API ${init.method ?? "GET"} ${path} failed with ${response.status}: ${body}`,
307
+ );
308
+ }
89
309
 
90
- **Example loop script:**
91
- ```bash
92
- # patterns/prd/agent-iteration-loop.sh
93
- while true; do
94
- bun run prd.ts || {
95
- # Gate failed - agent gets PRD + failure output
96
- agent --context prd.ts --failure "$(cat gate-output.txt)"
97
- # Agent fixes, loop continues
98
- }
99
- break # All gates passed
100
- done
101
- ```
310
+ async function ensureFixtureRepository() {
311
+ const { owner, name } = parseFixtureRepository(fixtureRepo);
312
+ const existing = await githubRequest(`/repos/${owner}/${name}`, {}, [200, 404]);
102
313
 
103
- **The guardrails:**
104
- - Max failures (default: 5) auto-pause if stuck
105
- - Git diff check → agent must make changes
106
- - Pause file → manual control
314
+ if (existing.status === 200) {
315
+ return (await existing.json()) as { default_branch: string; full_name: string };
316
+ }
107
317
 
108
- This solves the context management problem: agents don't need full codebase context upfront. They get minimal context (PRD), concrete feedback (gate failures), and iterate until correct.
318
+ const viewer = (await (
319
+ await githubRequest("/user")
320
+ ).json()) as { login: string };
109
321
 
110
- ## Anatomy of a prd.ts (1 list)
322
+ if (viewer.login !== owner) {
323
+ throw new Error(
324
+ `Fixture repo ${fixtureRepo} is missing and cannot be auto-created because PAT owner "${viewer.login}" does not match "${owner}"`,
325
+ );
326
+ }
111
327
 
112
- 1. **Instructions**: each story title encodes behavior + evidence + scope (the agent's marching orders).
113
- 2. **Stories**: `stories[]` holds `{ id, title, gateFile, dependsOn?, progress? }` in execution order.
114
- 3. **Gates**: `gateFile` points at a gate script that observes logs, acts, and asserts evidence.
115
- 4. **Loop state**: `runPrd(...)` returns success or the `failedStory` plus gate evidence (actions/stages/errors).
116
- 5. **Loop instructions**: on failure, feed the agent `prd.ts` + gate output, fix code, re-run PRD until pass.
328
+ const created = await githubRequest(
329
+ "/user/repos",
330
+ {
331
+ method: "POST",
332
+ body: JSON.stringify({
333
+ name,
334
+ description: "Canonical GitHub proof fixture for Cinder",
335
+ auto_init: true,
336
+ private: false,
337
+ }),
338
+ },
339
+ [201],
340
+ );
117
341
 
118
- ## Stories as gates
342
+ return (await created.json()) as { default_branch: string; full_name: string };
343
+ }
119
344
 
120
- A PRD (Product Requirements Document) defines stories. Stories are gates. Each story references a gate file. The gate file verifies the story against reality.
345
+ async function ensureFixtureBranch(repo: { default_branch: string }) {
346
+ if (fixtureBranch === repo.default_branch) {
347
+ return;
348
+ }
121
349
 
122
- Reality is the source of truth; gates make it enforceable in CI.
350
+ const { owner, name } = parseFixtureRepository(fixtureRepo);
351
+ const branchResponse = await githubRequest(
352
+ `/repos/${owner}/${name}/git/ref/heads/${encodeURIComponent(fixtureBranch)}`,
353
+ {},
354
+ [200, 404],
355
+ );
123
356
 
124
- ### prd.ts example
357
+ if (branchResponse.status === 200) {
358
+ return;
359
+ }
125
360
 
126
- ```typescript
127
- // prd.ts
128
- import { definePrd } from "gateproof/prd";
361
+ const defaultBranchRef = (await (
362
+ await githubRequest(
363
+ `/repos/${owner}/${name}/git/ref/heads/${encodeURIComponent(repo.default_branch)}`,
364
+ )
365
+ ).json()) as { object: { sha: string } };
129
366
 
130
- export const prd = definePrd({
131
- stories: [
367
+ await githubRequest(
368
+ `/repos/${owner}/${name}/git/refs`,
132
369
  {
133
- id: "user-signup",
134
- title: "User can sign up",
135
- gateFile: "./gates/user-signup.gate.ts",
136
- progress: ["signup_page_live", "user_created"],
370
+ method: "POST",
371
+ body: JSON.stringify({
372
+ ref: `refs/heads/${fixtureBranch}`,
373
+ sha: defaultBranchRef.object.sha,
374
+ }),
137
375
  },
376
+ [201],
377
+ );
378
+ }
379
+
380
+ async function upsertFixtureFile(path: string, content: string, message: string) {
381
+ const { owner, name } = parseFixtureRepository(fixtureRepo);
382
+ const encodedPath = path
383
+ .split("/")
384
+ .map((segment) => encodeURIComponent(segment))
385
+ .join("/");
386
+ const existing = await githubRequest(
387
+ `/repos/${owner}/${name}/contents/${encodedPath}?ref=${encodeURIComponent(fixtureBranch)}`,
388
+ {},
389
+ [200, 404],
390
+ );
391
+ const sha =
392
+ existing.status === 200
393
+ ? ((await existing.json()) as { sha: string }).sha
394
+ : undefined;
395
+
396
+ await githubRequest(
397
+ `/repos/${owner}/${name}/contents/${encodedPath}`,
138
398
  {
139
- id: "email-verification",
140
- title: "User receives verification email",
141
- gateFile: "./gates/email-verification.gate.ts",
142
- dependsOn: ["user-signup"],
143
- progress: ["email_sent", "verification_link_valid"],
399
+ method: "PUT",
400
+ body: JSON.stringify({
401
+ message,
402
+ branch: fixtureBranch,
403
+ content: Buffer.from(content, "utf8").toString("base64"),
404
+ sha,
405
+ }),
144
406
  },
145
- ] as const, // keep story IDs as literal types
146
- });
147
-
148
- // Make it executable
149
- if (import.meta.main) {
150
- const { runPrd } = await import("gateproof/prd");
151
- const result = await runPrd(prd);
152
- if (!result.success) {
153
- if (result.failedStory) console.error(`Failed at: ${result.failedStory.id}`);
154
- process.exit(1);
155
- }
156
- process.exit(0);
407
+ [200, 201],
408
+ );
157
409
  }
158
- ```
159
-
160
- Each story references a gate file. The gate file uses gateproof's API:
161
-
162
- ```typescript
163
- // gates/user-signup.gate.ts
164
- import { Gate, Act, Assert } from "gateproof";
165
- import { CloudflareProvider } from "gateproof/cloudflare";
166
410
 
167
- export async function run() {
168
- const provider = CloudflareProvider({
169
- accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
170
- apiToken: process.env.CLOUDFLARE_API_TOKEN!,
411
+ async function upsertFixtureWebhook(webhookUrl: string) {
412
+ const { owner, name } = parseFixtureRepository(fixtureRepo);
413
+ const hooks = (await (
414
+ await githubRequest(`/repos/${owner}/${name}/hooks`)
415
+ ).json()) as Array<{ id: number; name: string; config?: { url?: string } }>;
416
+ const existing = hooks.find(
417
+ (hook) => hook.name === "web" && hook.config?.url === webhookUrl,
418
+ );
419
+ const body = JSON.stringify({
420
+ active: true,
421
+ events: ["workflow_job"],
422
+ config: {
423
+ url: webhookUrl,
424
+ content_type: "json",
425
+ insecure_ssl: "0",
426
+ secret: webhookSecret,
427
+ },
171
428
  });
172
429
 
173
- const result = await Gate.run({
174
- name: "user-signup",
175
- observe: provider.observe({ backend: "analytics", dataset: "worker_logs" }),
176
- act: [Act.browser({ url: "https://app.example.com/signup" })],
177
- assert: [
178
- Assert.noErrors(),
179
- Assert.hasAction("user_created"),
180
- ],
181
- });
430
+ if (existing) {
431
+ await githubRequest(
432
+ `/repos/${owner}/${name}/hooks/${existing.id}`,
433
+ {
434
+ method: "PATCH",
435
+ body,
436
+ },
437
+ [200],
438
+ );
439
+ return;
440
+ }
182
441
 
183
- return { status: result.status };
442
+ await githubRequest(
443
+ `/repos/${owner}/${name}/hooks`,
444
+ {
445
+ method: "POST",
446
+ body,
447
+ },
448
+ [201],
449
+ );
184
450
  }
185
- ```
186
451
 
187
- **gateproof does not own your PRD’s intent or state.** If you choose to use `gateproof/prd`, your PRD must match a small capsule shape (`stories[]` with `id/title/gateFile/dependsOn?/progress?`). The optional `progress` list is for your own tracking (or agent guidance); gateproof does not interpret or mutate it. Otherwise, orchestrate gates however you want — gateproof only cares about executing gate files.
452
+ async function syncFixtureRepository(webhookUrl: string) {
453
+ const repo = await ensureFixtureRepository();
454
+ await ensureFixtureBranch(repo);
455
+
456
+ await upsertFixtureFile("Cargo.toml", fixtureCargoToml, "chore: sync cinder fixture Cargo.toml");
457
+ await upsertFixtureFile("Cargo.lock", fixtureCargoLock, "chore: sync cinder fixture Cargo.lock");
458
+ await upsertFixtureFile("src/main.rs", fixtureMainRs, "chore: sync cinder fixture main.rs");
459
+ await upsertFixtureFile(
460
+ `.github/workflows/${fixtureWorkflow}`,
461
+ fixtureWorkflowContents,
462
+ "chore: sync cinder fixture workflow",
463
+ );
464
+ await upsertFixtureWebhook(webhookUrl);
465
+ }
188
466
 
189
- Stories execute in dependency order. The runner stops on first failure. Progress is not declared. It is proven.
467
+ async function ensureStateBucket(bucketName: string, locationHint: string) {
468
+ const accountId = requireEnv("CLOUDFLARE_ACCOUNT_ID");
469
+ const apiToken = requireEnv("CLOUDFLARE_API_TOKEN");
470
+ const api = await createCloudflareApi({ accountId, apiToken } as any);
471
+
472
+ try {
473
+ await getBucket(api, bucketName);
474
+ return { accountId, apiToken };
475
+ } catch (error) {
476
+ if (!(error instanceof CloudflareApiError) || error.status !== 404) {
477
+ throw error;
478
+ }
479
+ }
190
480
 
191
- ## How it works
481
+ await createBucket(api, bucketName, { locationHint });
482
+ return { accountId, apiToken };
483
+ }
192
484
 
193
- The PRD defines stories. Stories reference gate files. Gate files use gateproof's API. Gates can be enforced in CI before merge/deploy.
485
+ const stateStoreCredentials = stateBucketName
486
+ ? await ensureStateBucket(stateBucketName, stateBucketRegion)
487
+ : null;
488
+
489
+ export const app = await alchemy("cinder", {
490
+ stage: process.env.CINDER_STAGE ?? "production",
491
+ ...(stateBucketName && stateStoreCredentials
492
+ ? {
493
+ stateStore: (scope) =>
494
+ new R2RestStateStore(scope, {
495
+ accountId: stateStoreCredentials.accountId,
496
+ apiToken: stateStoreCredentials.apiToken,
497
+ bucketName: stateBucketName,
498
+ } as any),
499
+ }
500
+ : {}),
501
+ });
194
502
 
195
- **The sequence:** PRD story gate file → gate execution → story marked "done" only when gate passes.
503
+ export const cacheBucket = await R2Bucket("cinder-cache", {
504
+ empty: false,
505
+ });
196
506
 
197
- **For agent iterations:** PRD gate fails → agent fixes → gate re-runs → loop until pass.
507
+ export const runnerState = await KVNamespace("cinder-runner-state");
198
508
 
199
- Run your PRD:
509
+ export const runnerPool = await DurableObjectNamespace("RunnerPool", {
510
+ className: "RunnerPool",
511
+ sqlite: true,
512
+ });
200
513
 
201
- ```bash
202
- bun run prd.ts
203
- ```
514
+ export const jobQueue = await DurableObjectNamespace("JobQueue", {
515
+ className: "JobQueue",
516
+ sqlite: true,
517
+ });
204
518
 
205
- Run agent iteration loop:
519
+ export const cacheWorker = await Worker("cinder-cache-worker", {
520
+ entrypoint: "./crates/cinder-cache/build/worker/shim.mjs",
521
+ bindings: {
522
+ CACHE_BUCKET: cacheBucket,
523
+ CINDER_INTERNAL_TOKEN: alchemy.secret(internalToken),
524
+ },
525
+ });
206
526
 
207
- ```bash
208
- bash patterns/prd/agent-iteration-loop.sh
209
- ```
527
+ export const orchestrator = await Worker("cinder-orchestrator", {
528
+ entrypoint: "./crates/cinder-orchestrator/build/worker/shim.mjs",
529
+ bindings: {
530
+ CACHE_BUCKET: cacheBucket,
531
+ RUNNER_STATE: runnerState,
532
+ RUNNER_POOL: runnerPool,
533
+ JOB_QUEUE: jobQueue,
534
+ GITHUB_WEBHOOK_SECRET: alchemy.secret(webhookSecret),
535
+ CINDER_INTERNAL_TOKEN: alchemy.secret(internalToken),
536
+ GITHUB_PAT: alchemy.secret(githubPat),
537
+ CINDER_CACHE_WORKER_URL: cacheWorker.url!,
538
+ CINDER_FIXTURE_REPO: fixtureRepo,
539
+ CINDER_FIXTURE_BRANCH: fixtureBranch,
540
+ CINDER_FIXTURE_WORKFLOW: fixtureWorkflow,
541
+ },
542
+ });
210
543
 
211
- ## Hardening `prd.ts` (recommended)
544
+ await app.finalize();
545
+ await syncFixtureRepository(`${orchestrator.url}/webhook/github`);
212
546
 
213
- Treat `prd.ts` like code: typecheck + validate before push + enforce in CI.
547
+ const runtimeDirectory = new URL("./.gateproof/", import.meta.url);
548
+ const runtimeFile = new URL("./.gateproof/runtime.json", import.meta.url);
214
549
 
215
- - **Validate PRD**:
550
+ await mkdir(runtimeDirectory, { recursive: true });
216
551
 
217
- ```bash
218
- bun run prd:validate
552
+ await Bun.write(
553
+ runtimeFile,
554
+ `${JSON.stringify(
555
+ {
556
+ generatedAt: new Date().toISOString(),
557
+ stage: process.env.CINDER_STAGE ?? "production",
558
+ orchestratorName: orchestrator.name,
559
+ orchestratorUrl: orchestrator.url,
560
+ cacheWorkerName: cacheWorker.name,
561
+ cacheWorkerUrl: cacheWorker.url,
562
+ fixtureRepo,
563
+ fixtureBranch,
564
+ fixtureWorkflow,
565
+ },
566
+ null,
567
+ 2,
568
+ )}\n`,
569
+ );
570
+
571
+ console.log(`Wrote runtime outputs to ${runtimeFile.pathname}`);
219
572
  ```
220
573
 
221
- - **Pre-push (default for everyone on your team)**: add to your `prepush` script (Husky calls it).
574
+ ### plan.ts
575
+
576
+ ```ts
577
+ import { Effect } from "effect";
578
+ import crypto from "node:crypto";
579
+ import { existsSync, readFileSync, rmSync } from "node:fs";
580
+ import type { ScopeFile } from "gateproof";
581
+ import {
582
+ Act,
583
+ Assert,
584
+ Gate,
585
+ Plan,
586
+ Require,
587
+ } from "gateproof";
588
+ import { Cloudflare } from "gateproof/cloudflare";
589
+
590
+ type RuntimeState = {
591
+ orchestratorName?: string;
592
+ orchestratorUrl?: string;
593
+ cacheWorkerUrl?: string;
594
+ fixtureRepo?: string;
595
+ fixtureBranch?: string;
596
+ fixtureWorkflow?: string;
597
+ };
598
+
599
+ function isRecord(value: unknown): value is Record<string, unknown> {
600
+ return typeof value === "object" && value !== null;
601
+ }
222
602
 
223
- ```json
224
- {
225
- "scripts": {
226
- "prepush": "bun run typecheck && bun run prd:validate && bun test"
603
+ function readOptionalEnv(name: string): string | undefined {
604
+ const value = process.env[name];
605
+ if (typeof value !== "string") {
606
+ return undefined;
227
607
  }
228
- }
229
- ```
230
608
 
231
- - **CI**: run the validator before running PRD/tests.
609
+ const trimmed = value.trim();
610
+ return trimmed.length > 0 ? trimmed : undefined;
611
+ }
232
612
 
233
- ```yaml
234
- - name: Validate PRD
235
- run: bun run prd:validate
236
- ```
613
+ function loadRuntimeState(): RuntimeState | null {
614
+ const runtimeFile = new URL("./.gateproof/runtime.json", import.meta.url);
237
615
 
238
- - **Monorepo**: validate any PRD file by path.
616
+ if (!existsSync(runtimeFile)) {
617
+ return null;
618
+ }
239
619
 
240
- ```bash
241
- bun run scripts/prd-validate.ts packages/api/prd.ts
242
- ```
620
+ try {
621
+ const parsed: unknown = JSON.parse(readFileSync(runtimeFile, "utf8"));
622
+ if (!isRecord(parsed)) {
623
+ return null;
624
+ }
625
+
626
+ return {
627
+ orchestratorName:
628
+ typeof parsed.orchestratorName === "string" ? parsed.orchestratorName : undefined,
629
+ orchestratorUrl:
630
+ typeof parsed.orchestratorUrl === "string" ? parsed.orchestratorUrl : undefined,
631
+ cacheWorkerUrl:
632
+ typeof parsed.cacheWorkerUrl === "string" ? parsed.cacheWorkerUrl : undefined,
633
+ fixtureRepo: typeof parsed.fixtureRepo === "string" ? parsed.fixtureRepo : undefined,
634
+ fixtureBranch:
635
+ typeof parsed.fixtureBranch === "string" ? parsed.fixtureBranch : undefined,
636
+ fixtureWorkflow:
637
+ typeof parsed.fixtureWorkflow === "string" ? parsed.fixtureWorkflow : undefined,
638
+ };
639
+ } catch {
640
+ return null;
641
+ }
642
+ }
243
643
 
244
- ## Design notes
644
+ function resolveLocalRunnerId(): string {
645
+ try {
646
+ const hostname = readFileSync("/etc/hostname", "utf8").trim();
647
+ return `cinder-${hostname || "unknown"}`;
648
+ } catch {
649
+ return "cinder-unknown";
650
+ }
651
+ }
245
652
 
246
- - [Effect and Schema: Gateproof's Foundation](docs/effect-and-schema.md)
653
+ const runtimeState = loadRuntimeState();
654
+ const baseUrl = readOptionalEnv("CINDER_BASE_URL") ?? runtimeState?.orchestratorUrl ?? "";
655
+ const cacheWorkerUrl =
656
+ readOptionalEnv("CINDER_CACHE_WORKER_URL") ?? runtimeState?.cacheWorkerUrl ?? "";
657
+ const workerName =
658
+ readOptionalEnv("CINDER_WORKER_NAME") ?? runtimeState?.orchestratorName ?? "cinder-orchestrator";
659
+ const fixtureRepo =
660
+ readOptionalEnv("CINDER_FIXTURE_REPO") ?? runtimeState?.fixtureRepo ?? "acoyfellow/cinder-prd-test";
661
+ const fixtureBranch = readOptionalEnv("CINDER_FIXTURE_BRANCH") ?? runtimeState?.fixtureBranch ?? "";
662
+ const fixtureWorkflow =
663
+ readOptionalEnv("CINDER_FIXTURE_WORKFLOW") ?? runtimeState?.fixtureWorkflow ?? "";
664
+ const internalToken = readOptionalEnv("CINDER_INTERNAL_TOKEN") ?? "";
665
+
666
+ const missKey = crypto.randomBytes(32).toString("hex");
667
+ const newKey = crypto.randomBytes(32).toString("hex");
668
+ const speedThresholdMs = Number(process.env.SPEED_THRESHOLD_MS ?? "60000");
669
+ const testRepo = process.env.TEST_REPO ?? "";
670
+ const harnessBaseUrl = "http://127.0.0.1:9000";
671
+ const harnessRunUrl = `${harnessBaseUrl}/test/run`;
672
+ const localRunnerId = resolveLocalRunnerId();
673
+ const agentLogPath = "/tmp/cinder-agent-proof.log";
674
+ const agentPidPath = "/tmp/cinder-agent-proof.pid";
675
+ const runnerJobPath = "/tmp/cinder-proof-runner-job.json";
676
+ const queuePayloadPath = "/tmp/cinder-proof-queue-payload.json";
677
+
678
+ let managedHarness: ReturnType<typeof Bun.spawn> | null = null;
679
+
680
+ async function canReachLocalHarness(): Promise<boolean> {
681
+ try {
682
+ const response = await fetch(harnessBaseUrl);
683
+ return response.ok || response.status === 404;
684
+ } catch {
685
+ return false;
686
+ }
687
+ }
247
688
 
248
- ## Writing good gates (agent-first)
689
+ async function ensureLocalHarness(): Promise<void> {
690
+ if (await canReachLocalHarness()) {
691
+ return;
692
+ }
249
693
 
250
- Gates can fail loudly. They can also pass on silence if you write weak assertions.
694
+ managedHarness = Bun.spawn({
695
+ cmd: ["bun", "harness.ts"],
696
+ cwd: process.cwd(),
697
+ stdout: "inherit",
698
+ stderr: "inherit",
699
+ });
251
700
 
252
- - **Always assert at least one positive signal**: `Assert.hasAction(...)` and/or `Assert.hasStage(...)`. If your backend can be silent, add an explicit “evidence must exist” custom assertion.
253
- - **Don’t rely on absence-only checks**: `Assert.noErrors()` alone can pass if you collect no logs.
254
- - **Treat observability as part of the system**: your confidence is bounded by what you can observe.
701
+ const deadline = Date.now() + 5_000;
702
+ while (Date.now() < deadline) {
703
+ if (await canReachLocalHarness()) {
704
+ return;
705
+ }
255
706
 
256
- ## Limits / Non-goals
707
+ await Bun.sleep(100);
708
+ }
257
709
 
258
- - **Not a planner or orchestrator**: gateproof executes gates; your PRD (or CI) decides what to run and in what context.
259
- - **Not a truth oracle**: if your backend drops logs, a gate can be wrong. Gateproof can’t fix missing telemetry.
260
- - **Enforcement is external**: gateproof returns results; CI/CD decides whether to block merge/deploy.
710
+ throw new Error("cinder proof harness did not start on 127.0.0.1:9000");
711
+ }
261
712
 
262
- ## Common objections (and answers)
713
+ function stopManagedHarness(): void {
714
+ if (!managedHarness) {
715
+ return;
716
+ }
263
717
 
264
- - **"Isn't this just E2E tests?"** Similar goal, different anchor. Gates are evidence-first (logs/telemetry + explicit assertions), not DOM-only. The contract is: observe → act → assert → evidence.
718
+ managedHarness.kill();
719
+ managedHarness = null;
720
+ }
265
721
 
266
- - **"What about flaky telemetry?"** Gates don't fix missing telemetry. They make the dependency explicit. If your backend drops logs, a gate can be wrong — but you'll know immediately, not in production.
722
+ function stopManagedAgent(): void {
723
+ if (!existsSync(agentPidPath)) {
724
+ return;
725
+ }
267
726
 
268
- - **"Isn't this overhead?"** It can be. The pitch isn't "gate everything." It's "gate the few transitions that are expensive to get wrong." Start with one critical path.
727
+ try {
728
+ const pid = Number.parseInt(readFileSync(agentPidPath, "utf8").trim(), 10);
729
+ if (Number.isFinite(pid)) {
730
+ process.kill(pid);
731
+ }
732
+ } catch {
733
+ // Ignore stale proof agent state.
734
+ }
269
735
 
270
- - **"Will this lock us in?"** Gates are just TypeScript files. If you stop using gateproof, you keep the scripts and the intent. No vendor lock-in.
736
+ try {
737
+ rmSync(agentPidPath, { force: true });
738
+ } catch {
739
+ // Ignore pidfile cleanup failures during shutdown.
740
+ }
741
+ }
271
742
 
272
- ## Quick Start
743
+ async function ensureColdBuildBaseline(): Promise<void> {
744
+ if (readOptionalEnv("COLD_BUILD_MS")) {
745
+ return;
746
+ }
273
747
 
274
- The API is minimal: three concepts (Gate, Act, Assert). Here's a gate:
748
+ if (!testRepo) {
749
+ return;
750
+ }
275
751
 
276
- ```typescript
277
- import { Gate, Act, Assert } from "gateproof";
278
- import { CloudflareProvider } from "gateproof/cloudflare";
752
+ try {
753
+ const response = await fetch(harnessRunUrl, {
754
+ method: "POST",
755
+ headers: {
756
+ "Content-Type": "application/json",
757
+ },
758
+ body: JSON.stringify({
759
+ repo: testRepo,
760
+ with_cache: false,
761
+ }),
762
+ });
763
+
764
+ if (!response.ok) {
765
+ return;
766
+ }
767
+
768
+ const parsed: unknown = await response.json();
769
+ if (!isRecord(parsed)) {
770
+ return;
771
+ }
772
+
773
+ const buildDurationMs = parsed.build_duration_ms;
774
+ if (typeof buildDurationMs !== "number" || !Number.isFinite(buildDurationMs)) {
775
+ return;
776
+ }
777
+
778
+ process.env.COLD_BUILD_MS = String(buildDurationMs);
779
+ } catch {
780
+ // Let the existing prerequisite fail clearly if the harness is unavailable.
781
+ }
782
+ }
279
783
 
280
- const provider = CloudflareProvider({
281
- accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
282
- apiToken: process.env.CLOUDFLARE_API_TOKEN!,
283
- });
784
+ await ensureColdBuildBaseline();
284
785
 
285
- const result = await Gate.run({
286
- name: "api-health-check",
287
- observe: provider.observe({ backend: "analytics", dataset: "worker_logs" }),
288
- act: [Act.browser({ url: "https://my-worker.workers.dev" })],
289
- assert: [Assert.noErrors(), Assert.hasAction("request_received")],
786
+ const workerLogs = Cloudflare.observe({
787
+ accountId: readOptionalEnv("CLOUDFLARE_ACCOUNT_ID") ?? "",
788
+ apiToken: readOptionalEnv("CLOUDFLARE_API_TOKEN") ?? "",
789
+ workerName,
790
+ sinceMs: 120_000,
791
+ pollInterval: 1_000,
290
792
  });
291
793
 
292
- if (result.status !== "success") process.exit(1);
293
- ```
294
-
295
- This gate is a story verification. The PRD points at it.
794
+ if (!process.env.CINDER_BASE_URL && baseUrl) {
795
+ process.env.CINDER_BASE_URL = baseUrl;
796
+ }
296
797
 
297
- ## Core API
798
+ if (!process.env.CINDER_WORKER_NAME && workerName) {
799
+ process.env.CINDER_WORKER_NAME = workerName;
800
+ }
298
801
 
299
- ### Gate.run(spec)
300
- Run a gate. Returns a result with status, logs, and evidence.
301
- `spec.name` is optional metadata for labeling a gate.
802
+ if (!process.env.CINDER_FIXTURE_REPO && fixtureRepo) {
803
+ process.env.CINDER_FIXTURE_REPO = fixtureRepo;
804
+ }
302
805
 
303
- ### Actions
304
- ```typescript
305
- Act.exec("command") // Run shell command
306
- Act.browser({ url, headless? }) // Browser automation (needs playwright)
307
- Act.wait(ms) // Sleep
308
- Act.deploy({ worker }) // Deploy marker
309
- ```
806
+ if (!process.env.CINDER_FIXTURE_BRANCH && fixtureBranch) {
807
+ process.env.CINDER_FIXTURE_BRANCH = fixtureBranch;
808
+ }
310
809
 
311
- ### Assertions
312
- ```typescript
313
- Assert.noErrors() // No error logs
314
- Assert.hasAction("name") // Action was logged
315
- Assert.hasStage("worker") // Stage was seen
316
- Assert.custom("name", fn) // Custom: (logs) => boolean
317
- ```
810
+ if (!process.env.CINDER_FIXTURE_WORKFLOW && fixtureWorkflow) {
811
+ process.env.CINDER_FIXTURE_WORKFLOW = fixtureWorkflow;
812
+ }
318
813
 
319
- ### Result
320
- ```typescript
321
- {
322
- status: "success" | "failed" | "timeout",
323
- durationMs: number,
324
- logs: Log[],
325
- evidence: {
326
- requestIds: string[],
327
- stagesSeen: string[],
328
- actionsSeen: string[],
329
- errorTags: string[]
814
+ const scope = {
815
+ spec: {
816
+ title: "Cinder",
817
+ tutorial: {
818
+ goal: "Prove cinder on a live deployment, not just deploy it.",
819
+ outcome:
820
+ "Webhook intake, queueing, runner registration, cache paths, and the speed claim all go green.",
821
+ },
822
+ howTo: {
823
+ task: "Run the cinder proof loop against already-provisioned infrastructure.",
824
+ done:
825
+ "Cinder only exits green when the live system can do the work and the speed claim holds.",
826
+ },
827
+ explanation: {
828
+ summary:
829
+ "alchemy.run.ts creates the infrastructure once and writes .gateproof/runtime.json. This file is only the acceptance loop for the live product.",
830
+ },
330
831
  },
331
- error?: Error
832
+ plan: Plan.define({
833
+ goals: [
834
+ {
835
+ id: "webhook",
836
+ title: "A GitHub webhook queues a runnable job",
837
+ gate: Gate.define({
838
+ observe: workerLogs,
839
+ prerequisites: [
840
+ Require.env(
841
+ "CLOUDFLARE_ACCOUNT_ID",
842
+ "CLOUDFLARE_ACCOUNT_ID is required for Cloudflare worker log observation.",
843
+ ),
844
+ Require.env(
845
+ "CLOUDFLARE_API_TOKEN",
846
+ "CLOUDFLARE_API_TOKEN is required for Cloudflare worker log observation.",
847
+ ),
848
+ Require.env(
849
+ "GITHUB_PAT",
850
+ "GITHUB_PAT is required to dispatch the GitHub proof workflow.",
851
+ ),
852
+ Require.env(
853
+ "CINDER_FIXTURE_BRANCH",
854
+ "Run bun run provision first or set CINDER_FIXTURE_BRANCH for the GitHub proof fixture.",
855
+ ),
856
+ Require.env(
857
+ "CINDER_FIXTURE_WORKFLOW",
858
+ "Run bun run provision first or set CINDER_FIXTURE_WORKFLOW for the GitHub proof fixture.",
859
+ ),
860
+ Require.env(
861
+ "CINDER_INTERNAL_TOKEN",
862
+ "CINDER_INTERNAL_TOKEN is required for internal API access.",
863
+ ),
864
+ Require.env(
865
+ "CINDER_BASE_URL",
866
+ "Run bun run provision first or set CINDER_BASE_URL to the live orchestrator URL.",
867
+ ),
868
+ ],
869
+ act: [
870
+ Act.exec(
871
+ `bun -e 'const repo = ${JSON.stringify(fixtureRepo)};
872
+ const workflow = ${JSON.stringify(fixtureWorkflow)};
873
+ const branch = ${JSON.stringify(fixtureBranch)};
874
+ const token = process.env.GITHUB_PAT;
875
+ if (!token) {
876
+ throw new Error("GITHUB_PAT is required");
332
877
  }
333
- ```
334
-
335
- ## PRD Runner
336
-
337
- gateproof provides a PRD runner that executes stories in dependency order:
338
-
339
- ```typescript
340
- import { definePrd, runPrd } from "gateproof/prd";
341
-
342
- const prd = definePrd({
343
- stories: [
878
+ const headers = {
879
+ Accept: "application/vnd.github+json",
880
+ Authorization: "Bearer " + token,
881
+ "X-GitHub-Api-Version": "2022-11-28",
882
+ };
883
+ const listUrl =
884
+ "https://api.github.com/repos/" +
885
+ repo +
886
+ "/actions/workflows/" +
887
+ workflow +
888
+ "/runs?event=workflow_dispatch&branch=" +
889
+ encodeURIComponent(branch) +
890
+ "&per_page=20";
891
+ const response = await fetch(listUrl, { headers });
892
+ if (!response.ok) {
893
+ throw new Error("GitHub workflow run listing failed: " + response.status);
894
+ }
895
+ const payload = await response.json();
896
+ const runs = Array.isArray(payload.workflow_runs) ? payload.workflow_runs : [];
897
+ for (const run of runs) {
898
+ if (typeof run?.id !== "number" || run.status === "completed") {
899
+ continue;
900
+ }
901
+ const cancelResponse = await fetch(
902
+ "https://api.github.com/repos/" + repo + "/actions/runs/" + run.id + "/cancel",
344
903
  {
345
- id: "story-1",
346
- title: "First story",
347
- gateFile: "./gates/story-1.gate.ts",
904
+ method: "POST",
905
+ headers,
348
906
  },
349
- {
350
- id: "story-2",
351
- title: "Second story",
352
- gateFile: "./gates/story-2.gate.ts",
353
- dependsOn: ["story-1"],
907
+ );
908
+ if (!cancelResponse.ok && cancelResponse.status !== 409) {
909
+ throw new Error("GitHub workflow cancel failed: " + cancelResponse.status);
910
+ }
911
+ }'`,
912
+ {
913
+ timeoutMs: 60_000,
914
+ },
915
+ ),
916
+ Act.exec(
917
+ `curl -sf -X POST https://api.github.com/repos/${fixtureRepo}/actions/workflows/${fixtureWorkflow}/dispatches \
918
+ -H "Accept: application/vnd.github+json" \
919
+ -H "Authorization: Bearer $GITHUB_PAT" \
920
+ -H "X-GitHub-Api-Version: 2022-11-28" \
921
+ -d '${JSON.stringify({ ref: fixtureBranch })}'`,
922
+ ),
923
+ Act.exec("sleep 25"),
924
+ ],
925
+ assert: [
926
+ Assert.noErrors(),
927
+ Assert.hasAction("webhook_received"),
928
+ Assert.hasAction("signature_verified"),
929
+ Assert.hasAction("job_queued"),
930
+ ],
931
+ timeoutMs: 40_000,
932
+ }),
933
+ },
934
+ {
935
+ id: "queue",
936
+ title: "A queued job can be inspected without dequeueing",
937
+ gate: Gate.define({
938
+ observe: workerLogs,
939
+ prerequisites: [
940
+ Require.env(
941
+ "CLOUDFLARE_ACCOUNT_ID",
942
+ "CLOUDFLARE_ACCOUNT_ID is required for Cloudflare worker log observation.",
943
+ ),
944
+ Require.env(
945
+ "CLOUDFLARE_API_TOKEN",
946
+ "CLOUDFLARE_API_TOKEN is required for Cloudflare worker log observation.",
947
+ ),
948
+ Require.env(
949
+ "CINDER_INTERNAL_TOKEN",
950
+ "CINDER_INTERNAL_TOKEN is required for queue inspection.",
951
+ ),
952
+ Require.env(
953
+ "CINDER_BASE_URL",
954
+ "Run bun run provision first or set CINDER_BASE_URL to the live orchestrator URL.",
955
+ ),
956
+ ],
957
+ act: [
958
+ Act.exec(
959
+ `sh -c 'curl -sf ${baseUrl}/jobs/peek \
960
+ -H "Authorization: Bearer ${internalToken}" \
961
+ | tee "${queuePayloadPath}"'`,
962
+ ),
963
+ ],
964
+ assert: [
965
+ Assert.noErrors(),
966
+ Assert.responseBodyIncludes("repo_full_name"),
967
+ Assert.responseBodyIncludes("repo_clone_url"),
968
+ Assert.responseBodyIncludes("runner_registration_url"),
969
+ Assert.responseBodyIncludes("runner_registration_token"),
970
+ Assert.responseBodyIncludes("cache_key"),
971
+ ],
972
+ timeoutMs: 8_000,
973
+ }),
974
+ },
975
+ {
976
+ id: "runner",
977
+ title: "A runner can execute a queued GitHub job",
978
+ gate: Gate.define({
979
+ observe: workerLogs,
980
+ prerequisites: [
981
+ Require.env(
982
+ "CLOUDFLARE_ACCOUNT_ID",
983
+ "CLOUDFLARE_ACCOUNT_ID is required for Cloudflare worker log observation.",
984
+ ),
985
+ Require.env(
986
+ "CLOUDFLARE_API_TOKEN",
987
+ "CLOUDFLARE_API_TOKEN is required for Cloudflare worker log observation.",
988
+ ),
989
+ Require.env(
990
+ "CINDER_INTERNAL_TOKEN",
991
+ "CINDER_INTERNAL_TOKEN is required for runner registration.",
992
+ ),
993
+ Require.env(
994
+ "CINDER_BASE_URL",
995
+ "Run bun run provision first or set CINDER_BASE_URL to the live orchestrator URL.",
996
+ ),
997
+ Require.env(
998
+ "GITHUB_PAT",
999
+ "GITHUB_PAT is required to confirm the queued GitHub run completed.",
1000
+ ),
1001
+ ],
1002
+ act: [
1003
+ Act.exec(
1004
+ `curl -sf ${baseUrl}/jobs/peek \
1005
+ -H "Authorization: Bearer ${internalToken}" \
1006
+ > "${runnerJobPath}"`,
1007
+ ),
1008
+ Act.exec(
1009
+ `bun -e 'import crypto from "node:crypto";
1010
+ import { readFileSync } from "node:fs";
1011
+ const payload = JSON.parse(readFileSync(${JSON.stringify(runnerJobPath)}, "utf8"));
1012
+ if (typeof payload.cache_key !== "string" || payload.cache_key.length === 0) {
1013
+ throw new Error("runner job payload missing cache_key");
1014
+ }
1015
+ const key = payload.cache_key;
1016
+ const token = ${JSON.stringify(internalToken)};
1017
+ if (!token) {
1018
+ throw new Error("CINDER_INTERNAL_TOKEN is required for fixture cache reset");
1019
+ }
1020
+ let base = ${JSON.stringify(cacheWorkerUrl)};
1021
+ const restoreProbe = await fetch(
1022
+ ${JSON.stringify(baseUrl)} + "/cache/restore/" + key,
1023
+ {
1024
+ method: "POST",
1025
+ headers: {
1026
+ Authorization: "Bearer " + token,
354
1027
  },
355
- ] as const, // keep story IDs as literal types
356
- });
357
-
358
- const result = await runPrd(prd);
359
- if (!result.success) {
360
- console.error(`Failed at: ${result.failedStory?.id}`);
1028
+ },
1029
+ );
1030
+ if (!restoreProbe.ok) {
1031
+ throw new Error("fixture cache reset probe failed: " + restoreProbe.status);
1032
+ }
1033
+ const restorePayload = await restoreProbe.json();
1034
+ if (typeof restorePayload.url === "string" && restorePayload.url.length > 0) {
1035
+ base = new URL(restorePayload.url).origin;
1036
+ }
1037
+ if (!base) {
1038
+ throw new Error("cache worker base URL is required for fixture cache reset");
1039
+ }
1040
+ const exp = Math.floor(Date.now() / 1000) + 3600;
1041
+ const sig = crypto
1042
+ .createHmac("sha256", token)
1043
+ .update("delete:" + key + ":" + exp)
1044
+ .digest("hex");
1045
+ const response = await fetch(
1046
+ base.replace(/\\/$/, "") +
1047
+ "/objects/" +
1048
+ key +
1049
+ "?op=delete&exp=" +
1050
+ exp +
1051
+ "&sig=" +
1052
+ sig,
1053
+ {
1054
+ method: "DELETE",
1055
+ },
1056
+ );
1057
+ if (!response.ok && response.status !== 404) {
1058
+ throw new Error("fixture cache reset failed: " + response.status);
1059
+ }
1060
+ console.log("fixture cache reset");'`,
1061
+ ),
1062
+ Act.exec(
1063
+ `sh -c 'if [ -f "${agentPidPath}" ] && kill -0 "$(cat "${agentPidPath}")" 2>/dev/null; then exit 0; fi; : >"${agentLogPath}"; cargo run --quiet -p cinder-agent -- --url "${baseUrl}" --token "${internalToken}" --poll-ms 250 >"${agentLogPath}" 2>&1 & echo $! >"${agentPidPath}"; sleep 5'`,
1064
+ ),
1065
+ Act.exec(
1066
+ `bun -e 'import { existsSync, readFileSync } from "node:fs";
1067
+ const payload = JSON.parse(readFileSync(${JSON.stringify(runnerJobPath)}, "utf8"));
1068
+ if (typeof payload.run_id !== "number") {
1069
+ throw new Error("queue payload missing run_id");
1070
+ }
1071
+ if (typeof payload.repo_full_name !== "string" || payload.repo_full_name.length === 0) {
1072
+ throw new Error("queue payload missing repo_full_name");
1073
+ }
1074
+ const token = process.env.GITHUB_PAT;
1075
+ if (!token) {
1076
+ throw new Error("GITHUB_PAT is required");
1077
+ }
1078
+ const headers = {
1079
+ Accept: "application/vnd.github+json",
1080
+ Authorization: "Bearer " + token,
1081
+ "X-GitHub-Api-Version": "2022-11-28",
1082
+ };
1083
+ const deadline = Date.now() + 600000;
1084
+ let run = null;
1085
+ while (Date.now() < deadline) {
1086
+ const response = await fetch(
1087
+ "https://api.github.com/repos/" + payload.repo_full_name + "/actions/runs/" + payload.run_id,
1088
+ { headers },
1089
+ );
1090
+ if (!response.ok) {
1091
+ if (response.status >= 500) {
1092
+ await Bun.sleep(2000);
1093
+ continue;
1094
+ }
1095
+ throw new Error("GitHub workflow run fetch failed: " + response.status);
1096
+ }
1097
+ run = await response.json();
1098
+ if (run.status === "completed") {
1099
+ break;
1100
+ }
1101
+ await Bun.sleep(2000);
1102
+ }
1103
+ if (!run || run.status !== "completed") {
1104
+ throw new Error("GitHub workflow run did not complete");
1105
+ }
1106
+ const logNeedle = "completed with exit code 0";
1107
+ const logDeadline = Date.now() + 30000;
1108
+ while (Date.now() < logDeadline) {
1109
+ if (existsSync(${JSON.stringify(agentLogPath)})) {
1110
+ const logContents = readFileSync(${JSON.stringify(agentLogPath)}, "utf8");
1111
+ if (logContents.includes(logNeedle)) {
1112
+ break;
1113
+ }
1114
+ }
1115
+ await Bun.sleep(500);
1116
+ }
1117
+ console.log(JSON.stringify(run));
1118
+ if (existsSync(${JSON.stringify(agentLogPath)})) {
1119
+ console.log(readFileSync(${JSON.stringify(agentLogPath)}, "utf8"));
1120
+ }
1121
+ if (run.conclusion !== "success") {
361
1122
  process.exit(1);
1123
+ }'`,
1124
+ {
1125
+ timeoutMs: 600_000,
1126
+ },
1127
+ ),
1128
+ ],
1129
+ assert: [
1130
+ Assert.noErrors(),
1131
+ Assert.hasAction("runner_registered"),
1132
+ Assert.hasAction("runner_pool_updated"),
1133
+ Assert.hasAction("job_dequeued"),
1134
+ Assert.responseBodyIncludes(`"conclusion":"success"`),
1135
+ Assert.responseBodyIncludes("starting github runner for job"),
1136
+ Assert.responseBodyIncludes("completed with exit code 0"),
1137
+ ],
1138
+ timeoutMs: 600_000,
1139
+ }),
1140
+ },
1141
+ {
1142
+ id: "cache-restore",
1143
+ title: "The fixture cache key currently restores as a cold miss",
1144
+ gate: Gate.define({
1145
+ prerequisites: [
1146
+ Require.env(
1147
+ "CLOUDFLARE_ACCOUNT_ID",
1148
+ "CLOUDFLARE_ACCOUNT_ID is required for Cloudflare worker log observation.",
1149
+ ),
1150
+ Require.env(
1151
+ "CLOUDFLARE_API_TOKEN",
1152
+ "CLOUDFLARE_API_TOKEN is required for Cloudflare worker log observation.",
1153
+ ),
1154
+ Require.env(
1155
+ "CINDER_INTERNAL_TOKEN",
1156
+ "CINDER_INTERNAL_TOKEN is required for cache restore.",
1157
+ ),
1158
+ Require.env(
1159
+ "CINDER_BASE_URL",
1160
+ "Run bun run provision first or set CINDER_BASE_URL to the live orchestrator URL.",
1161
+ ),
1162
+ ],
1163
+ act: [
1164
+ Act.exec(
1165
+ `bun -e 'import { readFileSync } from "node:fs";
1166
+ const payload = JSON.parse(readFileSync(${JSON.stringify(runnerJobPath)}, "utf8"));
1167
+ if (typeof payload.job_id !== "number") {
1168
+ throw new Error("runner job payload missing job_id");
362
1169
  }
363
- ```
1170
+ const needle = "cache miss for job " + payload.job_id;
1171
+ const deadline = Date.now() + 5000;
1172
+ while (Date.now() < deadline) {
1173
+ const log = readFileSync(${JSON.stringify(agentLogPath)}, "utf8");
1174
+ if (log.includes(needle)) {
1175
+ console.log(needle);
1176
+ process.exit(0);
1177
+ }
1178
+ await Bun.sleep(250);
1179
+ }
1180
+ throw new Error("agent log missing cache miss marker for job " + payload.job_id);'`,
1181
+ ),
1182
+ ],
1183
+ assert: [
1184
+ Assert.noErrors(),
1185
+ Assert.responseBodyIncludes("cache miss for job"),
1186
+ ],
1187
+ timeoutMs: 5_000,
1188
+ }),
1189
+ },
1190
+ {
1191
+ id: "cache-push",
1192
+ title: "The cache upload path returns a real cache-worker upload URL",
1193
+ gate: Gate.define({
1194
+ observe: workerLogs,
1195
+ prerequisites: [
1196
+ Require.env(
1197
+ "CLOUDFLARE_ACCOUNT_ID",
1198
+ "CLOUDFLARE_ACCOUNT_ID is required for Cloudflare worker log observation.",
1199
+ ),
1200
+ Require.env(
1201
+ "CLOUDFLARE_API_TOKEN",
1202
+ "CLOUDFLARE_API_TOKEN is required for Cloudflare worker log observation.",
1203
+ ),
1204
+ Require.env(
1205
+ "CINDER_INTERNAL_TOKEN",
1206
+ "CINDER_INTERNAL_TOKEN is required for cache upload.",
1207
+ ),
1208
+ Require.env(
1209
+ "CINDER_BASE_URL",
1210
+ "Run bun run provision first or set CINDER_BASE_URL to the live orchestrator URL.",
1211
+ ),
1212
+ ],
1213
+ act: [
1214
+ Act.exec(
1215
+ `sh -c 'rm -f /tmp/cinder-proof-cache-push.tar.xz /tmp/cinder-proof-cache-push-download.tar.xz /tmp/cinder-proof-cache-push-upload.json /tmp/cinder-proof-cache-push-restore.json /tmp/cinder-proof-cache-push-list.txt; tmpdir="$(mktemp -d)"; printf "proof\\n" > "$tmpdir/proof.txt"; tar -cJf /tmp/cinder-proof-cache-push.tar.xz -C "$tmpdir" proof.txt; rm -rf "$tmpdir"'`,
1216
+ ),
1217
+ Act.exec(
1218
+ `bun -e 'import { writeFileSync } from "node:fs";
1219
+ const response = await fetch(
1220
+ ${JSON.stringify(baseUrl)} + "/cache/upload",
1221
+ {
1222
+ method: "POST",
1223
+ headers: {
1224
+ "Content-Type": "application/json",
1225
+ Authorization: "Bearer " + ${JSON.stringify(internalToken)},
1226
+ },
1227
+ body: JSON.stringify({
1228
+ key: ${JSON.stringify(newKey)},
1229
+ content_type: "application/x-xz",
1230
+ size_bytes: 1024,
1231
+ }),
1232
+ },
1233
+ );
1234
+ if (!response.ok) {
1235
+ throw new Error("cache upload failed: " + response.status);
1236
+ }
1237
+ const upload = await response.json();
1238
+ if (typeof upload.url !== "string" || upload.url.length === 0) {
1239
+ throw new Error("cache upload response missing url");
1240
+ }
1241
+ if (!upload.url.includes("/objects/")) {
1242
+ throw new Error("cache upload returned non-worker url");
1243
+ }
1244
+ writeFileSync("/tmp/cinder-proof-cache-push-upload.json", JSON.stringify(upload));
1245
+ console.log(JSON.stringify(upload));'`,
1246
+ ),
1247
+ Act.exec(
1248
+ `bun -e 'import { readFileSync } from "node:fs";
1249
+ const upload = JSON.parse(readFileSync("/tmp/cinder-proof-cache-push-upload.json", "utf8"));
1250
+ const archive = readFileSync("/tmp/cinder-proof-cache-push.tar.xz");
1251
+ const response = await fetch(upload.url, {
1252
+ method: "PUT",
1253
+ body: archive,
1254
+ });
1255
+ if (!response.ok) {
1256
+ throw new Error("cache object upload failed: " + response.status);
1257
+ }
1258
+ console.log("cache object uploaded");'`,
1259
+ ),
1260
+ Act.exec(
1261
+ `bun -e 'import { writeFileSync } from "node:fs";
1262
+ const response = await fetch(
1263
+ ${JSON.stringify(baseUrl)} + "/cache/restore/" + ${JSON.stringify(newKey)},
1264
+ {
1265
+ method: "POST",
1266
+ headers: {
1267
+ Authorization: "Bearer " + ${JSON.stringify(internalToken)},
1268
+ },
1269
+ },
1270
+ );
1271
+ if (!response.ok) {
1272
+ throw new Error("cache restore failed: " + response.status);
1273
+ }
1274
+ const restore = await response.json();
1275
+ if (restore.miss === true) {
1276
+ throw new Error("cache restore returned miss after upload");
1277
+ }
1278
+ if (typeof restore.url !== "string" || restore.url.length === 0) {
1279
+ throw new Error("cache restore response missing url");
1280
+ }
1281
+ writeFileSync("/tmp/cinder-proof-cache-push-restore.json", JSON.stringify(restore));
1282
+ console.log(JSON.stringify(restore));'`,
1283
+ ),
1284
+ Act.exec(
1285
+ `bun -e 'import { readFileSync, writeFileSync } from "node:fs";
1286
+ const restore = JSON.parse(readFileSync("/tmp/cinder-proof-cache-push-restore.json", "utf8"));
1287
+ const response = await fetch(restore.url);
1288
+ if (!response.ok) {
1289
+ throw new Error("cache object download failed: " + response.status);
1290
+ }
1291
+ const bytes = new Uint8Array(await response.arrayBuffer());
1292
+ writeFileSync("/tmp/cinder-proof-cache-push-download.tar.xz", bytes);
1293
+ console.log("cache object downloaded");'`,
1294
+ ),
1295
+ Act.exec(
1296
+ `sh -c 'test -s /tmp/cinder-proof-cache-push-download.tar.xz && tar -tJf /tmp/cinder-proof-cache-push-download.tar.xz > /tmp/cinder-proof-cache-push-list.txt && cat /tmp/cinder-proof-cache-push-list.txt'`,
1297
+ ),
1298
+ ],
1299
+ assert: [
1300
+ Assert.noErrors(),
1301
+ Assert.responseBodyIncludes("proof.txt"),
1302
+ ],
1303
+ timeoutMs: 8_000,
1304
+ }),
1305
+ },
1306
+ {
1307
+ id: "speed-claim",
1308
+ title: "A warm workflow run can complete with a real cache hit",
1309
+ gate: Gate.define({
1310
+ observe: workerLogs,
1311
+ prerequisites: [
1312
+ Require.env(
1313
+ "CLOUDFLARE_ACCOUNT_ID",
1314
+ "CLOUDFLARE_ACCOUNT_ID is required for Cloudflare worker log observation.",
1315
+ ),
1316
+ Require.env(
1317
+ "CLOUDFLARE_API_TOKEN",
1318
+ "CLOUDFLARE_API_TOKEN is required for Cloudflare worker log observation.",
1319
+ ),
1320
+ Require.env(
1321
+ "GITHUB_PAT",
1322
+ "GITHUB_PAT is required to dispatch and confirm the warm workflow run.",
1323
+ ),
1324
+ ],
1325
+ act: [
1326
+ Act.exec(
1327
+ `bun -e 'import { readFileSync } from "node:fs";
1328
+ const payload = JSON.parse(readFileSync(${JSON.stringify(runnerJobPath)}, "utf8"));
1329
+ if (typeof payload.run_id !== "number") {
1330
+ throw new Error("runner job payload missing run_id");
1331
+ }
1332
+ console.log(String(payload.run_id));' > /tmp/cinder-proof-speed-before.txt`,
1333
+ ),
1334
+ Act.exec(
1335
+ `curl -sf -X POST https://api.github.com/repos/${fixtureRepo}/actions/workflows/${fixtureWorkflow}/dispatches \
1336
+ -H "Accept: application/vnd.github+json" \
1337
+ -H "Authorization: Bearer $GITHUB_PAT" \
1338
+ -H "X-GitHub-Api-Version: 2022-11-28" \
1339
+ -d '${JSON.stringify({ ref: fixtureBranch })}'`,
1340
+ ),
1341
+ Act.exec("sleep 5"),
1342
+ Act.exec(
1343
+ `bun -e 'import { readFileSync } from "node:fs";
1344
+ const repo = ${JSON.stringify(fixtureRepo)};
1345
+ const workflow = ${JSON.stringify(fixtureWorkflow)};
1346
+ const branch = ${JSON.stringify(fixtureBranch)};
1347
+ const token = process.env.GITHUB_PAT;
1348
+ if (!token) {
1349
+ throw new Error("GITHUB_PAT is required");
1350
+ }
1351
+ const previousId = readFileSync("/tmp/cinder-proof-speed-before.txt", "utf8").trim();
1352
+ const headers = {
1353
+ Accept: "application/vnd.github+json",
1354
+ Authorization: "Bearer " + token,
1355
+ "X-GitHub-Api-Version": "2022-11-28",
1356
+ };
1357
+ const listUrl =
1358
+ "https://api.github.com/repos/" +
1359
+ repo +
1360
+ "/actions/workflows/" +
1361
+ workflow +
1362
+ "/runs?event=workflow_dispatch&branch=" +
1363
+ encodeURIComponent(branch) +
1364
+ "&per_page=5";
1365
+ const deadline = Date.now() + 600000;
1366
+ let run = null;
1367
+ while (Date.now() < deadline) {
1368
+ const listResponse = await fetch(listUrl, { headers });
1369
+ if (!listResponse.ok) {
1370
+ throw new Error("GitHub workflow run listing failed: " + listResponse.status);
1371
+ }
1372
+ const listPayload = await listResponse.json();
1373
+ const runs = Array.isArray(listPayload.workflow_runs) ? listPayload.workflow_runs : [];
1374
+ const candidate = runs.find((entry) => typeof entry?.id === "number" && String(entry.id) !== previousId);
1375
+ if (candidate && typeof candidate.id === "number") {
1376
+ const runResponse = await fetch(
1377
+ "https://api.github.com/repos/" + repo + "/actions/runs/" + candidate.id,
1378
+ { headers },
1379
+ );
1380
+ if (!runResponse.ok) {
1381
+ throw new Error("GitHub workflow run fetch failed: " + runResponse.status);
1382
+ }
1383
+ run = await runResponse.json();
1384
+ if (run.status === "completed") {
1385
+ break;
1386
+ }
1387
+ }
1388
+ await Bun.sleep(2000);
1389
+ }
1390
+ if (!run || run.status !== "completed") {
1391
+ throw new Error("warm GitHub workflow run did not complete");
1392
+ }
1393
+ console.log(JSON.stringify(run));
1394
+ console.log(readFileSync(${JSON.stringify(agentLogPath)}, "utf8"));'`,
1395
+ {
1396
+ timeoutMs: 600_000,
1397
+ },
1398
+ ),
1399
+ ],
1400
+ assert: [
1401
+ Assert.noErrors(),
1402
+ Assert.responseBodyIncludes(`"conclusion":"success"`),
1403
+ Assert.responseBodyIncludes("cache restored for job"),
1404
+ ],
1405
+ timeoutMs: 600_000,
1406
+ }),
1407
+ },
1408
+ ],
1409
+ loop: {
1410
+ maxIterations: 1,
1411
+ stopOnFailure: true,
1412
+ },
1413
+ cleanup: {
1414
+ actions: [
1415
+ Act.exec(
1416
+ `if [ -n "${internalToken}" ] && [ -n "${baseUrl}" ]; then curl -sf -X DELETE ${baseUrl}/runners/${localRunnerId} -H "Authorization: Bearer ${internalToken}" >/dev/null; else exit 0; fi`,
1417
+ ),
1418
+ ],
1419
+ },
1420
+ }),
1421
+ } satisfies ScopeFile;
1422
+
1423
+ export default scope;
1424
+
1425
+ if (import.meta.main) {
1426
+ stopManagedAgent();
1427
+
1428
+ if (testRepo) {
1429
+ await ensureLocalHarness();
1430
+ }
364
1431
 
365
- The runner:
366
- - Validates dependencies (unknown IDs and cycles throw)
367
- - Topologically sorts stories by `dependsOn`
368
- - Executes gates in order
369
- - **Stops on first failure**
1432
+ await ensureColdBuildBaseline();
370
1433
 
371
- ## Plug Your Backend
1434
+ try {
1435
+ const result = await Effect.runPromise(
1436
+ Plan.runLoop(scope.plan, {
1437
+ maxIterations: scope.plan.loop?.maxIterations,
1438
+ }),
1439
+ );
372
1440
 
373
- gateproof works with any observability backend. Just implement the `Backend` interface:
1441
+ console.log(JSON.stringify(result, null, 2));
374
1442
 
375
- ```typescript
376
- interface Backend {
377
- start(): Effect.Effect<LogStream, ObservabilityError>;
378
- stop(): Effect.Effect<void, ObservabilityError>;
1443
+ if (result.status !== "pass") {
1444
+ process.exitCode = 1;
1445
+ }
1446
+ } finally {
1447
+ stopManagedAgent();
1448
+ stopManagedHarness();
1449
+ }
1450
+
1451
+ process.exit(process.exitCode ?? 0);
379
1452
  }
380
1453
  ```
381
1454
 
382
- See `patterns/` for examples including:
383
- - Cloudflare Analytics Engine
384
- - Cloudflare Workers Logs API
385
- - CLI Stream (local dev)
386
- - Custom backends
1455
+ Status: Historical artifacts are available locally
387
1456
 
388
- ## Cloudflare Backends
1457
+ The preserved Cinder files are present and typechecked against the local Gateproof package. Reproducing the live result still requires Cloudflare infrastructure and Cinder environment variables.
389
1458
 
390
- ```typescript
391
- const provider = CloudflareProvider({ accountId, apiToken });
1459
+ ## Roadmap
392
1460
 
393
- // Analytics Engine
394
- provider.observe({ backend: "analytics", dataset: "worker_logs" })
1461
+ Gateproof is not ready to fully dogfood itself on a case study like Cinder yet. The next phase is about tightening the guardrails, not adding another rewrite.
395
1462
 
396
- // Workers Logs API
397
- provider.observe({ backend: "workers-logs", workerName: "my-worker" })
1463
+ - Save the latest real proof result to disk so the loop always has a concrete last-known truth.
1464
+ - Make finalize refuse to ship unless the saved real proof result is fully green.
1465
+ - Separate the real proof path from side experiments so exploration can happen without polluting the proof story.
1466
+ - Let plans choose direct evidence when log tailing is flaky, so a valid live pass does not fail on observation noise alone.
1467
+ - Dogfood Gateproof on Cinder again only after those guardrails are in place.
398
1468
 
399
- // CLI Stream (local dev)
400
- provider.observe({ backend: "cli-stream", workerName: "my-worker" })
401
- ```
1469
+ ## How To
402
1470
 
403
- ## Examples
1471
+ Task: Run one complete gate from one file.
404
1472
 
405
- See `patterns/` for complete examples:
406
- - `patterns/basic/` - Basic usage patterns
407
- - `patterns/cloudflare/` - Cloudflare-specific patterns
408
- - `patterns/ci-cd/` - CI/CD integration
409
- - `patterns/advanced/` - Advanced patterns
410
- - `patterns/prd/` - PRD-as-code + agent iteration loop examples
411
- - `patterns/agent-first/` - Spec interview → PRD stories (agent-first)
412
- - `examples/hello-world-agent/` - Minimal agent with 5 tools + end-to-end gates
1473
+ Done when: The endpoint returns 200 and the body contains hello world.
413
1474
 
414
- Run the hello-world agent example (requires `OPENCODE_ZEN_API_KEY` and network access to `opencode.ai`):
1475
+ Run it:
415
1476
 
416
1477
  ```bash
417
- export OPENCODE_ZEN_API_KEY="your_key_here"
418
- bun run examples/hello-world-agent/prd.ts
1478
+ bun run example:hello-world:worker
1479
+ bun run alchemy.run.ts
1480
+ bun run plan.ts
419
1481
  ```
420
1482
 
421
- ## CI/CD
1483
+ ## Breaking Changes In 0.4.0
422
1484
 
423
- gateproof enforces gates in CI/CD. See `patterns/ci-cd/github-actions.ts` for examples.
1485
+ - `Prd.*` is gone
1486
+ - `Claim.*` is gone
1487
+ - `plan.ts` is the canonical entrypoint
1488
+ - `Plan.*` replaces the old front door
424
1489
 
425
- Run your PRD in CI:
1490
+ ## Reference
426
1491
 
427
- ```yaml
428
- - name: Run PRD
429
- run: bun run prd.ts
430
- ```
1492
+ Files:
1493
+ - `examples/hello-world/plan.ts`
1494
+ - `alchemy.run.ts`
1495
+ - `plan.ts`
1496
+
1497
+ Canonical gates:
1498
+ - GET / returns hello world
431
1499
 
432
- ## Requirements
1500
+ Loop:
1501
+ - `maxIterations: 1`
1502
+ - `stopOnFailure: true`
433
1503
 
434
- - Node.js 18+ or Bun
435
- - `playwright` (optional, for Act.browser)
436
- - Cloudflare credentials (for CloudflareProvider, or bring your own backend)
1504
+ Core API:
1505
+ - `Gate.define(...)`
1506
+ - `Plan.define(...)`
1507
+ - `Plan.run(...)`
1508
+ - `Plan.runLoop(...)`
1509
+ - `Cloudflare.observe(...)`
1510
+ - `Assert.hasAction(...)`
1511
+ - `Assert.responseBodyIncludes(...)`
1512
+ - `Assert.numericDeltaFromEnv(...)`
437
1513
 
438
- ## License
1514
+ ## Explanation
439
1515
 
440
- MIT
1516
+ Root plan.ts stays small. Gateproof itself is built forward.