# Project export: Signoff: Verified delivery for coding agents

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: UC Berkeley AI Hackathon 2026
- Tagline: ASI-native agent that turns software requests into fixed completion contracts, delegates coding, verifies the exact result in-browser, and only signs off when the agreed evidence passes.
- Devpost: https://devpost.com/software/signoff-verified-delivery-for-coding-agents
- GitHub: https://github.com/senxd/Signoff
- Team: 2 GitHub contributor(s) — grahamwinter (6 commits), Cursor (6 commits)

## Devpost submission (written by the team)

### Inspiration

Coding agents are getting good at producing code, but they are still too willing to declare themselves “done.” We wanted a workflow where a user does not have to babysit an agent conversation or manually reconstruct whether the final PR actually satisfied the request. Signoff came from a simple belief: a coding agent should not be able to move the goalposts after it starts working, and it should not certify its own output. The user should approve the definition of done first, then review proof instead of vibes.

### What it does

Signoff is verified delivery for coding agents. From ASI:One, a user asks for a bounded Next.js task. Signoff turns that request into a frozen completion contract with objective, machine-checkable criteria. After the user approves it, Signoff authorizes a Stripe test-mode payment, delegates implementation, opens a GitHub PR, runs independent Browserbase verification against the finished preview, and returns a deterministic verdict. The result can be satisfied, not_satisfied, or verification_error. Payment is captured only after the accepted checks pass. If the app builds but fails an agreed criterion, like horizontal overflow on mobile, Signoff refuses to sign off.

### How we built it

We built Signoff as an ASI/Agentverse-facing uAgent backed by a TypeScript/Bun control plane. The Python uAgent handles the chat flow: drafting the contract, showing acceptance criteria, freezing the approved scope, and reporting status and proof back to ASI:One. The backend uses Hono, Zod, and Bun to manage jobs, contract hashing, verification state, proof bundles, and API routes. Contracts are canonicalized and hashed so repair attempts keep the same definition of done. A deterministic verdict policy checks required evidence, commit SHA consistency, and criterion results. For execution, Signoff targets a known Next.js demo repo, creates signoff/... branches, applies or delegates changes, runs builds, opens draft PRs with Octokit, and starts a preview through Cloudflare. Browserbase plus Playwright-core runs the frozen criteria in a real browser, captures screenshots, records replay links, and returns structured observed values. Stripe Checkout uses manual capture so the payment authorization is captured only after verified completion. The proof page packages the contract hash, PR, commit SHAs, Browserbase results, timeline, screenshots, verdict, and payment state into one reviewable artifact.

### Challenges we ran into

The hardest part was keeping the product honest. It is easy to say “AI verified the work,” but much harder to define what proof means, what evidence is trusted, and what happens when evidence is missing. We had to separate subjective quality from objective acceptance criteria. We also had to make ASI central instead of treating it as a thin router. The important product moment is not just starting a backend job; it is the ASI-visible lifecycle of intent, contract, approval, proof, verdict, and refusal when needed. Browser automation and live infrastructure added the usual demo sharp edges: Browserbase session limits, preview timing, GitHub credentials, payment state, and runner setup. The red path mattered as much as the green path, so we built prepared success, failure, repair, verification-error, and SHA-mismatch runs.

### Accomplishments we're proud of

We are proud that Signoff fails closed. A build passing is not enough. A PR existing is not enough. Screenshots alone are not enough. The deterministic policy requires every frozen criterion to have usable machine evidence, and it blocks merge/payment eligibility when the runtime commit does not match the implementation commit. We are also proud of the repair loop. A failed attempt can be sent back under the same contract, but the executor cannot rewrite the acceptance criteria. The final successful repair only passes after all criteria are rerun. Most of all, we like the product shape: “Delegate the task. Review the proof, not the conversation.”

### What we learned

We learned that verification is mostly about boundaries. The executor should edit code, but it should not hold payment credentials, verifier authority, or release authority. Browserbase should measure the browser, but deterministic policy should decide the verdict. ASI should own the contract and explain the decision to the user. We also learned that failure demos are more persuasive than happy paths. The moment Signoff says, “I can’t sign off because scrollWidth=927 and innerWidth=390,” the product becomes clear.

### What's next

Next, we want to generalize Signoff beyond the prepared Next.js demo path. That means stronger repo survey, broader criteria generation, more framework support, production-grade sandboxing, GitHub App installation flows, durable Redis-backed timelines, and tighter Sentry release-scoped runtime gates. Longer term, Signoff could become the delivery layer for coding agents: fixed contracts, independent verification, proof bundles, repair attempts, and outcome-based payment across many agent executors. The agent can write the code, but Signoff decides whether it was actually delivered.

## README (from the GitHub repository)

# Signoff

Verified software delivery through ASI:One.

Signoff lets a technical user delegate a bounded Next.js task without supervising
a coding agent step by step. From chat, it drafts objective completion criteria,
quotes the task, waits for approval, runs the executor, verifies the finished PR
in Browserbase, and signs off only when the frozen checks pass.

The core promise:

```text
Delegate the task. Review the proof, not the conversation.
```

## Install

```bash
bun install
```

Python dependencies for the ASI/Agentverse broker:

```bash
python3 -m venv .venv
. .venv/bin/activate
pip install -r agentverse/requirements.txt
```

## Configure

Copy `.env.example` to `.env` and fill in the services you want active.
The scaffold works without Redis, Browserbase, Sentry, GitHub, or the executor
webhook, but those integrations will be marked as skipped.
Stripe is required for the hackathon payment demo.

## Run the orchestrator

```bash
bun run dev
```

## Run the ASI broker

```bash
. .venv/bin/activate
python agentverse/signoff_agent.py
```

## Test locally

```bash
curl -X POST http://localhost:8787/jobs \
  -H 'content-type: application/json' \
  -d '{"goal":"Improve the mobile dashboard layout in the demo Next.js repo","previewUrl":"https://example.com"}'
```

Open the returned `artifacts.proofPageUrl` to inspect the proof page. Approve the
contract in ASI, authorize the Stripe test payment, then review the PR, replay,
screenshots, and criterion-level verdict when the job finishes.


## Detected evidence (automated analysis)

Indexed codebase: 29 recognized source files, 241 KB.
- Python (language) — detected in the code
- Redis (technology) — detected in the code
- TypeScript (language) — detected in the code
- AI coding agent: Claude Code — evidence: config files committed to the repository
- AI coding agent: Cursor — evidence: config files committed to the repository; commit authorship or trailers

## Codebase structure (from repository index)

### Files (38 of 38)

```
.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc
.env.example
.gitattributes
.gitignore
agentverse/README.md
agentverse/requirements.txt
agentverse/signoff_agent.py
ASI_NATIVE_REPORT.md
bun.lock
CLAUDE.md
CRITIQUE_BRIEF.md
DEMO_EVIDENCE.md
index.ts
package.json
PLAN.md
README.md
REFINEMENTS.md
scripts/browserbase-verify.mjs
scripts/check-setup.ts
scripts/deploy-agentverse.sh
scripts/register_chat_agent.py
scripts/test-repair-e2e.ts
scripts/test-repair-setup.ts
scripts/test-verdict-policy.ts
SETUP.md
src/browserbase/verify.ts
src/config/env.ts
src/contracts.ts
src/executor/executor.ts
src/github/app.ts
src/github/pr.ts
src/jobs/orchestrator.ts
src/payments/stripe.ts
src/sentry/gate.ts
src/server.ts
src/state/store.ts
SUBMISSION_CHECKLIST.md
tsconfig.json
```

### Dependencies

- agentverse/requirements.txt: python-dotenv, requests, uagents
- package.json: @browserbasehq/sdk@^2.14.1, @octokit/rest@^22.0.1, @sentry/node@^10.59.0, @types/bun@latest, dotenv@^17.4.2, hono@^4.12.26, nanoid@^5.1.15, playwright-core@^1.61.0, redis@^6.0.0, stripe@^22.2.2, typescript@^5, zod@^4.4.3

### Recent commits (newest first)

- Fix agent message formatting: bold instead of backticks, additive progress log, price from server
- Use mailbox-first Agentverse deployment and return 400 for invalid jobs.
- Relax repair e2e test when payment is mock-authorized.
- Enable one automatic repair attempt for watchlist demo jobs.
- Load control env in the agent and add Agentverse redeploy script.
- Switch demo target to signoff-demo-app and harden GitHub execution.
- Poll Stripe after approve and push job updates to ASI chat.
- Make Signoff chat copy more conversational
- Initial public Signoff release

## Key source files (fetched from GitHub, selected and truncated for size)

### SUBMISSION_CHECKLIST.md

```markdown
# Signoff Submission Checklist

## Public Artifacts

- Agentverse profile: https://agentverse.ai/agents/details/agent1qtp9z99kad93vwtqm9vfs6dqh3ngrqrt02yh9qh446f3r50ujkpzzhay3wt/profile
- Implementation branch: `origin/codex/stripe-payment-browserbase-verifier`
- Runner proof base: https://alumni-cents-columns-selections.trycloudflare.com
- Local demo recording draft: `/private/tmp/signoff-demo-draft.webm`

Still approval-gated:

- Public repository: https://github.com/senxd/Signoff.
- Create the public ASI:One shared-chat URL from the validated transcript.
- Publish or upload the final narrated 3-5 minute demo video.

## Demo Script

1. Open in ASI:One with Signoff selected.
2. Explain the product in one sentence:
   "Signoff is asynchronous, fixed-price software delivery with independent proof: the executor can change code, but it cannot change the accepted criteria or certify itself."
3. Show the contract shape:
   - fixed Watchlist mobile goal
   - frozen criteria
   - contract hash
   - Stripe authorization before work starts
4. Show W1 green path:
   - Job `hm6jdwiUc2`
   - PR #19
   - Browserbase replay
   - `scrollWidth=390`, `innerWidth=390`
   - Stripe captured
5. Show W2 red path:
   - Job `C1Hk2msP2w`
   - PR #20
   - build passed but `scrollWidth=927`, `innerWidth=390`
   - merge/payment eligibility false
   - Stripe authorization cancelled
6. Show W3 repair path:
   - Job `WvtF5uPHbO`
   - attempt 1 failed under the same contract hash
   - attempt 2 passed all criteria
   - PR #23 check success
   - Stripe captured only after final satisfaction
7. Show W5 / G1 robustness:
   - `JtjMuHdPch` is `verification_error`, not success
   - `bun run test:policy` proves SHA mismatch is `verification_error`
8. Close:
   "Delegate the task. Review the proof, not the conversation."

## Commands To Revalidate

```bash
bun run check
bun run test:policy
bun run setup:check
curl -s https://alumni-cents-columns-selections.trycloudflare.com/jobs/hm6jdwiUc2
curl -s https://alumni-cents-columns-selections.trycloudflare.com/jobs/C1Hk2msP2w
curl -s https://alumni-cents-columns-selections.trycloudflare.com/jobs/WvtF5uPHbO
curl -s https://alumni-cents-columns-selections.trycloudflare.com/jobs/JtjMuHdPch
```

```

### CLAUDE.md

```markdown
---
description: Use Bun instead of Node.js, npm, pnpm, or vite.
globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json"
alwaysApply: false
---

Default to using Bun instead of Node.js.

- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
- Use `bun test` instead of `jest` or `vitest`
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
- Use `bunx <package> <command>` instead of `npx <package> <command>`
- Bun automatically loads .env, so don't use dotenv.

## APIs

- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
- `Bun.redis` for Redis. Don't use `ioredis`.
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
- `WebSocket` is built-in. Don't use `ws`.
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
- Bun.$`ls` instead of execa.

## Testing

Use `bun test` to run tests.

```ts#index.test.ts
import { test, expect } from "bun:test";

test("hello world", () => {
  expect(1).toBe(1);
});
```

## Frontend

Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.

Server:

```ts#index.ts
import index from "./index.html"

Bun.serve({
  routes: {
    "/": index,
    "/api/users/:id": {
      GET: (req) => {
        return new Response(JSON.stringify({ id: req.params.id }));
      },
    },
  },
  // optional websocket support
  websocket: {
    open: (ws) => {
      ws.send("Hello, world!");
    },
    message: (ws, message) => {
      ws.send(message);
    },
    close: (ws) => {
      // handle close
    }
  },
  development: {
    hmr: true,
    console: true,
  }
})
```

HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.

```html#index.html
<html>
  <body>
    <h1>Hello, world!</h1>
    <script type="module" src="./frontend.tsx"></script>
  </body>
</html>
```

With the following `frontend.tsx`:

```tsx#frontend.tsx
import React from "react";
import { createRoot } from "react-dom/client";

// import .css files directly and it works
import './index.css';

const root = createRoot(document.body);

export default function Frontend() {
  return <h1>Hello, world!</h1>;
}

root.render(<Frontend />);
```

Then, run index.ts

```sh
bun --hot ./index.ts
```

For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.

```

### package.json

```
{
  "name": "signoff",
  "module": "index.ts",
  "type": "module",
  "private": true,
  "scripts": {
    "dev": "bun --watch index.ts",
    "start": "bun index.ts",
    "check": "tsc --noEmit",
    "setup:check": "bun scripts/check-setup.ts",
    "test:policy": "bun scripts/test-verdict-policy.ts",
    "test:repair": "bun scripts/test-repair-setup.ts",
    "test:repair:e2e": "bun scripts/test-repair-e2e.ts"
  },
  "devDependencies": {
    "@types/bun": "latest"
  },
  "peerDependencies": {
    "typescript": "^5"
  },
  "dependencies": {
    "@browserbasehq/sdk": "^2.14.1",
    "@octokit/rest": "^22.0.1",
    "@sentry/node": "^10.59.0",
    "dotenv": "^17.4.2",
    "hono": "^4.12.26",
    "nanoid": "^5.1.15",
    "playwright-core": "^1.61.0",
    "redis": "^6.0.0",
    "stripe": "^22.2.2",
    "zod": "^4.4.3"
  }
}

```

### agentverse/requirements.txt

```
uagents
requests
python-dotenv

```

### index.ts

```typescript
import { app } from "./src/server";
import { env } from "./src/config/env";

Bun.serve({
  port: env.port,
  hostname: env.host,
  fetch: app.fetch,
});

console.log(`signoff orchestrator listening on http://${env.host}:${env.port}`);

```

### src/server.ts

```typescript
import { Hono } from "hono";
import { serveStatic } from "hono/bun";
import { ZodError } from "zod";
import {
  approveCompletionJob,
  createCompletionJob,
  getCompletionEvents,
  getCompletionJob,
  syncPaymentAndMaybeRun,
} from "./jobs/orchestrator";
import { handleStripeWebhook } from "./payments/stripe";
import {
  completeGitHubConnection,
  createGitHubConnectionStart,
  getGitHubConnection,
  getGitHubConnectionForSender,
} from "./github/app";

export const app = new Hono();

function escapeHtml(value: unknown) {
  return String(value)
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&#39;");
}

app.get("/health", (c) =>
  c.json({
    ok: true,
    service: "signoff-completion-contracts",
  }),
);

app.post("/jobs", async (c) => {
  try {
    const body = await c.req.json();
    const job = await createCompletionJob(body);
    return c.json(job, 202);
  } catch (error) {
    if (error instanceof ZodError) {
      return c.json(
        {
          error: "invalid_request",
          message: error.issues.map((issue) => issue.message).join("; "),
        },
        400,
      );
    }
    throw error;
  }
});

app.post("/jobs/:id/approve", async (c) => {
  const body = await c.req.json().catch(() => ({}));
  const job = await approveCompletionJob(c.req.param("id"), body.approvedBy ?? "asi-one");
  if (!job) return c.json({ error: "job not found" }, 404);
  return c.json(job);
});

app.get("/jobs/:id", async (c) => {
  const job = await getCompletionJob(c.req.param("id"));
  if (!job) return c.json({ error: "job not found" }, 404);
  return c.json(job);
});

app.get("/jobs/:id/events", async (c) => {
  return c.json(await getCompletionEvents(c.req.param("id")));
});

app.post("/jobs/:id/payment/sync", async (c) => {
  const job = await syncPaymentAndMaybeRun(c.req.param("id"));
  if (!job) return c.json({ error: "job not found" }, 404);
  return c.json(job);
});

app.get("/jobs/:id/payment/sync", async (c) => {
  const job = await syncPaymentAndMaybeRun(c.req.param("id"));
  if (!job) return c.html("<h1>Job not found</h1>", 404);
  return c.redirect(`/jobs/${job.id}/proof?stripe=synced`);
});

app.post("/stripe/webhook", async (c) => {
  const rawBody = await c.req.text();
  const result = await handleStripeWebhook(rawBody, c.req.header("stripe-signature"));
  if ("jobId" in result && result.status === "authorized") {
    void syncPaymentAndMaybeRun(result.jobId);
  }
  return c.json(result);
});

app.get("/github/connect/start", (c) => {
  const sender = c.req.query("sender") ?? "asi-one";
  const conversationId = c.req.query("conversationId") ?? undefined;

  try {
    const { connection, installUrl } = createGitHubConnectionStart({
      sender,
      conversationId,
    });
    return c.json({
      connectionId: connection.connectionId,
      expiresAt: connection.expiresAt,
      installUrl,
    });
  } catch (error) {
    return c.json(
      {
        error: error instanceof Error ? error.message : String(error),
      },
      500,
    );
  }
});

app.get("/github/connect/status", (c) => {
  const sender = c.req.query("sender");
  const connectionId = c.req.query("connectionId");
  const connection = connectionId
    ? getGitHubConnection(connectionId)
    : sender
      ? getGitHubConnectionForSender(sender)
      : undefined;

  if (!connection) return c.json({ error: "GitHub connection not found" }, 404);
  return c.json(connection);
});

app.get("/github/connect/callback", async (c) => {
  try {
    const connection = await completeGitHubConnection({
      state: c.req.query("state") ?? "",
      code: c.req.query("code") ?? undefined,
      installationId: c.req.query("installation_id") ?? undefined,
      setupAction: c.req.query("setup_action") ?? undefined,
    });
    return c.html(`<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>GitHub connected</title>
    <style>
      body { font-family: ui-sans-serif, system-ui, sans-serif; margin: 32px; color: #151515; }
      main { max-width: 720px; margin: 0 auto; }
      code { background: #f5f5f5; border-radius: 6px; padding: 2px 5px; }
    </style>
  </head>
  <body>
    <main>
      <h1>GitHub connected</h1>
      <p>Return to ASI:One. Signoff can now inspect the selected repository before preparing a completion contract.</p>
      <p>Connection: <code>${escapeHtml(connection.connectionId)}</code></p>
      <p>Installation: <code>${escapeHtml(connection.installationId)}</code></p>
      <p>Repositories: <code>${escapeHtml(connection.repositories.map((repo) => repo.fullName).join(", "))}</code></p>
      <p>${escapeHtml(connection.validationSummary)}</p>
    </main>
  </body>
</html>`);
  } catch (error) {
    return c.html(
      `<h1>GitHub connection failed</h1><pre>${escapeHtml(error instanceof Error ? error.message : String(error))}</pre>`,
      400,
    );
  }
});

app.get("/jobs/:id/proof", async (c) => {
  const job = await getCompletionJob(c.req.param("id"));
  if (!job) return c.html("<h1>Job not found</h1>", 404);

  const screenshots = job.artifacts.screenshotUrls
    .map((url) => `<img src="${url}" alt="Verification screenshot" />`)
    .join("");
  const criteria = job.criteria
    .map((criterion) => {
      const evidence = job.artifacts.verification?.criteria.find(
        (item) => item.criterionId === criterion.id,
      );
      return `<tr>
        <td><code>${escapeHtml(criterion.id)}</code></td>
        <td>${escapeHtml(criterion.description)}</td>
        <td><strong>${escapeHtml(evidence?.status ?? "pending")}</strong></td>
        <td><pre>${escapeHtml(evidence ? JSON.stringify(evidence.observed ?? evidence.error ?? {}, null, 2) : "")}</pre></td>
      </tr>`;
    })
    .join("");
  const events = await getCompletionEvents(job.id);
  const eventRows = events
    .map(
      (event) =>
        `<li><
[truncated — 3949 more characters]
```

### src/github/app.ts

```typescript
import { createHmac, createSign, randomBytes } from "node:crypto";
import { existsSync } from "node:fs";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { env } from "../config/env";

type GitHubRepository = {
  id: number;
  name: string;
  fullName: string;
  private: boolean;
};

export type GitHubConnection = {
  connectionId: string;
  sender: string;
  conversationId?: string;
  nonce: string;
  expiresAt: string;
  consumedAt?: string;
  installationId?: number;
  setupAction?: string;
  githubUserId?: number;
  githubLogin?: string;
  accountLogin?: string;
  repositorySelection?: string;
  repositories: GitHubRepository[];
  validated: boolean;
  validationSummary: string;
  createdAt: string;
  updatedAt: string;
};

export type GitHubInstallationTokenRole = "survey" | "executor" | "verifier";

const pendingConnections = new Map<string, GitHubConnection>();
const connectionsById = new Map<string, GitHubConnection>();
const connectionBySender = new Map<string, string>();

const connectionsFile = join(
  env.signoffStateDir ?? (existsSync("/opt/signoff") ? "/opt/signoff/state" : "/private/tmp/signoff-state"),
  "github-connections.json",
);

async function persistConnections() {
  await mkdir(join(connectionsFile, ".."), { recursive: true });
  await writeFile(
    connectionsFile,
    `${JSON.stringify([...connectionsById.values()], null, 2)}\n`,
    "utf8",
  );
}

async function loadPersistedConnections() {
  try {
    const raw = await readFile(connectionsFile, "utf8");
    const connections = JSON.parse(raw) as GitHubConnection[];
    for (const connection of connections) {
      connectionsById.set(connection.connectionId, connection);
      connectionBySender.set(connection.sender, connection.connectionId);
    }
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
  }
}

void loadPersistedConnections();

function now() {
  return new Date().toISOString();
}

function base64url(input: string | Buffer) {
  return Buffer.from(input)
    .toString("base64")
    .replaceAll("+", "-")
    .replaceAll("/", "_")
    .replaceAll("=", "");
}

function fromBase64url(input: string) {
  const normalized = input.replaceAll("-", "+").replaceAll("_", "/");
  return Buffer.from(normalized, "base64").toString("utf8");
}

function stateSecret() {
  const secret = env.githubConnectionStateSecret ?? env.runnerSharedSecret;
  if (!secret) {
    throw new Error("GITHUB_CONNECTION_STATE_SECRET or RUNNER_SHARED_SECRET is required.");
  }
  return secret;
}

function signState(payload: Record<string, unknown>) {
  const body = base64url(JSON.stringify(payload));
  const signature = base64url(createHmac("sha256", stateSecret()).update(body).digest());
  return `${body}.${signature}`;
}

function verifyState(state: string) {
  const [body, signature] = state.split(".");
  if (!body || !signature) throw new Error("Invalid GitHub connection state.");

  const expected = base64url(createHmac("sha256", stateSecret()).update(body).digest());
  if (signature !== expected) throw new Error("Invalid GitHub connection state signature.");

  const payload = JSON.parse(fromBase64url(body)) as {
    connectionId: string;
    nonce: string;
    exp: number;
  };
  if (!payload.connectionId || !payload.nonce || !payload.exp) {
    throw new Error("Invalid GitHub connection state payload.");
  }
  if (Date.now() > payload.exp) throw new Error("GitHub connection state expired.");
  return payload;
}

function normalizePrivateKey(key: string) {
  return key.includes("\\n") ? key.replaceAll("\\n", "\n") : key;
}

function createAppJwt() {
  if (!env.githubAppId || !env.githubAppPrivateKey) {
    throw new Error("GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY are required.");
  }

  const issuedAt = Math.floor(Date.now() / 1000) - 60;
  const payload = {
    iat: issuedAt,
    exp: issuedAt + 540,
    iss: env.githubAppId,
  };
  const header = { alg: "RS256", typ: "JWT" };
  const unsigned = `${base64url(JSON.stringify(header))}.${base64url(JSON.stringify(payload))}`;
  const signer = createSign("RSA-SHA256");
  signer.update(unsigned);
  signer.end();
  const signature = signer.sign(normalizePrivateKey(env.githubAppPrivateKey));
  return `${unsigned}.${base64url(signature)}`;
}

async function githubJson<T>(url: string, init: RequestInit = {}) {
  const response = await fetch(url, {
    ...init,
    headers: {
      accept: "application/vnd.github+json",
      "content-type": "application/json",
      "x-github-api-version": "2022-11-28",
      ...init.headers,
    },
  });
  if (!response.ok) {
    const body = await response.text().catch(() => "");
    throw new Error(`GitHub API ${response.status}: ${body || response.statusText}`);
  }
  return (await response.json()) as T;
}

async function exchangeCodeForUserToken(code: string) {
  if (!env.githubAppClientId || !env.githubAppClientSecret) return undefined;

  const response = await fetch("https://github.com/login/oauth/access_token", {
    method: "POST",
    headers: {
      accept: "application/json",
      "content-type": "application/json",
    },
    body: JSON.stringify({
      client_id: env.githubAppClientId,
      client_secret: env.githubAppClientSecret,
      code,
    }),
  });
  if (!response.ok) {
    throw new Error(`GitHub OAuth exchange failed with HTTP ${response.status}`);
  }
  const data = (await response.json()) as { access_token?: string; error_description?: string };
  if (!data.access_token) {
    throw new Error(data.error_description ?? "GitHub OAuth exchange did not return an access token.");
  }
  return data.access_token;
}

async function getGitHubUser(token: string) {
  return githubJson<{ id: number; login: string }>("https://api.github.com/user", {
    headers: {
      authorization: `Bearer ${token}`,
    },
  });
}

async function userCanAccessInstallation(token: string, installationId: number) {
  const data = await githubJson<{
    inst
[truncated — 7037 more characters]
```

### scripts/register_chat_agent.py

```python
import os
from pathlib import Path

from dotenv import load_dotenv
from uagents_core.utils.registration import (
    RegistrationRequestCredentials,
    register_chat_agent,
)

load_dotenv("/opt/signoff/secrets/control.env")
load_dotenv()

agent_mailbox = os.environ.get("AGENT_MAILBOX", "true").lower() not in {"0", "false", "no"}
if agent_mailbox:
    raise SystemExit(
        "AGENT_MAILBOX is enabled; start agentverse/signoff_agent.py to publish mailbox details instead."
    )

readme_path = Path(__file__).resolve().parent.parent / "agentverse" / "README.md"

register_chat_agent(
    os.environ.get("AGENT_NAME", "signoff"),
    os.environ["AGENT_ENDPOINT"],
    active=True,
    track_interactions=False,
    description="Verified software delivery with fixed criteria, PRs, Browserbase proof, and payment-gated signoff.",
    readme=readme_path.read_text(encoding="utf-8"),
    credentials=RegistrationRequestCredentials(
        agentverse_api_key=os.environ["AGENTVERSE_KEY"],
        agent_seed_phrase=os.environ["AGENT_SEED_PHRASE"],
    ),
)

```

### scripts/test-repair-setup.ts

```typescript
import { createInitialContract } from "../src/contracts";
import { shouldApplyWatchlistFakeMobilePatch } from "../src/executor/executor";

function assert(condition: boolean, message: string) {
  if (!condition) throw new Error(message);
}

const defaults = createInitialContract("repair-defaults", {
  goal: "Make the Watchlist page work well on mobile.",
  requestedBy: "test",
});

assert(defaults.maxRepairAttempts === 1, "default maxRepairAttempts should be 1");

const firstAttempt = {
  ...defaults,
  demoFixture: "watchlist_mobile" as const,
  maxRepairAttempts: 1,
  repairAttempts: 0,
};
assert(
  shouldApplyWatchlistFakeMobilePatch(firstAttempt),
  "first attempt with repair enabled should use the intentional overflow failure patch",
);

const repairAttempt = {
  ...firstAttempt,
  repairAttempts: 1,
};
assert(
  !shouldApplyWatchlistFakeMobilePatch(repairAttempt),
  "repair attempt should apply the real mobile patch",
);

const noRepair = {
  ...defaults,
  demoFixture: "watchlist_mobile" as const,
  maxRepairAttempts: 0,
  repairAttempts: 0,
};
assert(
  !shouldApplyWatchlistFakeMobilePatch(noRepair),
  "jobs without repair should not use the failure patch",
);

console.log("repair setup: ok");

```

### scripts/test-repair-e2e.ts

```typescript
import { getCompletionJob, approveCompletionJob, createCompletionJob, runJob } from "../src/jobs/orchestrator";
import { store } from "../src/state/store";

const job = await createCompletionJob({
  goal: "Repair e2e: make the Watchlist page work well on mobile while preserving the desktop table.",
  requestedBy: "repair-e2e",
  demoFixture: "watchlist_mobile",
});

await approveCompletionJob(job.id, "repair-e2e");

const approved = await getCompletionJob(job.id);
if (!approved) throw new Error("job missing after approve");
if (approved.maxRepairAttempts !== 1) {
  throw new Error(`expected maxRepairAttempts=1, got ${approved.maxRepairAttempts}`);
}

approved.status = "authorized";
approved.payment.status = "authorized";
await store.saveJob(approved);

console.log(`repair-e2e job ${job.id} starting with maxRepairAttempts=${approved.maxRepairAttempts}`);
await runJob(job.id);

const final = await getCompletionJob(job.id);
if (!final) throw new Error("job missing after run");

console.log(
  JSON.stringify(
    {
      id: final.id,
      status: final.status,
      repairAttempts: final.repairAttempts,
      verdict: final.verdict.outcome,
      payment: final.payment.status,
      pr: final.artifacts.pullRequestUrl,
      replay: final.artifacts.browserbaseReplayUrl,
    },
    null,
    2,
  ),
);

if (final.repairAttempts < 1) {
  throw new Error(`expected at least one repair attempt, got ${final.repairAttempts}`);
}
if (final.verdict.outcome !== "satisfied") {
  throw new Error(`expected satisfied verdict after repair, got ${final.verdict.outcome}: ${final.verdict.reason}`);
}

const events = await store.getEvents(job.id);
const repairStarted = events.some((entry) => entry.type === "repair.started");
if (!repairStarted) {
  throw new Error("expected repair.started event after first not_satisfied attempt");
}

if (final.payment.status === "captured") {
  if (final.status !== "completed") {
    throw new Error(`expected completed status when payment captured, got ${final.status}`);
  }
} else {
  console.log(`repair-e2e note: mock authorization left payment ${final.payment.status}; verdict still satisfied after repair`);
}

console.log("repair-e2e: ok");

```

[18 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]