# Project export: ScopeLint

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: OpenAI Build Week
- Tagline: ScopeLint checks every pull request against your contract, automatically. It catches scope creep where it actually happens, in the code, before it becomes unbillable work.
- Devpost: https://devpost.com/software/vk-open-ai-build-week-challenge
- GitHub: https://github.com/vinayvkejriwal/scopelint
- Demo: https://github.com/vinayvkejriwal/scopelint-demo-acme
- Video: https://www.youtube.com/embed/xoHvSku2paw?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 1 GitHub contributor(s) — Vinay Kejriwal (5 commits)

## Devpost submission (written by the team)

### Inspiration

Consulting and agency teams operate on a fragile assumption: that scope stays where the contract says it should. In practice, scope creep doesn't announce itself in a meeting, it slips in through a pull request, an "extra" feature added mid-sprint that nobody flags until it's already built and unbillable. Every existing scope-monitoring tool watches conversations, tasks, and emails. None of them watch the one place the actual, billable work materializes: the code itself. That gap is ScopeLint.

### What it does

ScopeLint is a GitHub Action and CLI that checks every pull request against a project's statement of work, automatically, the same way tests and linters already run. Commit the contract as scope.md, and GPT-5.6 classifies each functional area of a pull request as in scope, out of scope, or gray area, citing the exact clause it matched. Out-of-scope work gets a client-ready change order draft attached on the spot. A running scope ledger tracks cumulative drift and estimated unbilled hours across the life of a project. A bundled replay mode with canned fixtures lets anyone try it with zero API key.

### How we built it

Built solo, end to end, with Codex, staged deliberately across six phases: the offline CLI skeleton and replay mode, live GPT-5.6 classification with structured JSON output, GitHub Actions integration and PR-comment posting, the scope ledger, tests and documentation, and finally a full demo project, an Express loyalty API with a real statement of work and three staged pull requests, used for this submission's live evidence. The classifier runs on gpt-5.6-terra, chosen for its balance of structured reasoning and cost on a per-pull-request task.

### Challenges we ran into

Real infrastructure friction, not algorithm design, ate most of the time: git authentication with a personal access token that needed the workflow scope before GitHub would accept a change to .github/workflows/, a Codex session that reported creating a file it hadn't actually written to disk (caught only when a live pull request check failed with a missing-key error), and pacing Codex's effort level up or down per stage to make $100 in credits last across all six build phases plus a full second demo repository.

### Accomplishments we're proud of

A working, live demo where every claim is verifiable, not staged screenshots. The three pull requests in the demo repository actually run ScopeLint through GitHub Actions, call GPT-5.6 live, and post real verdicts a judge can click into and inspect themselves, including the model correctly refusing to cite a contract clause when the scope document didn't actually contain one, rather than hallucinating a match. Shipping a full CLI, GitHub Action, ledger system, test suite, and a second demo repository with staged scenarios, solo, inside a single day's build window.

### What we learned

That most of the risk in a build like this isn't the AI reasoning, it's the plumbing around it. GitHub Actions rejecting a workflow file over a missing token scope, git authentication breaking silently, an agent reporting success on a file it hadn't actually written, these cost more time than designing the classification prompt itself. We also learned to pace an AI coding agent's effort level deliberately: high reasoning for structurally complex stages like the GitHub Action and the demo repository, lower effort for templated work like documentation and tests, to make a fixed credit budget stretch across a full six-stage build.

### What's next

A hosted dashboard aggregating the scope ledger across every repo a consulting team manages, so a delivery lead sees drift trends across an entire client portfolio, not just one project. Direct integrations so an approved change order pushes straight into Jira or a billing tool instead of sitting in a pull request comment. Support for multiple scope documents per repo, for teams running several concurrent workstreams against one codebase.

## README (from the GitHub repository)

![ScopeLint: checks every pull request against your statement of work, automatically](docs/screenshots/title-card.png)

# ScopeLint

ScopeLint lints code changes against the contract. It compares a pull-request diff with the repository's `scope.md` statement of work, classifies each functional area as in scope, out of scope, or a gray area, and drafts a client-ready change order when work falls outside the agreement.

## The problem

Scope creep quietly erodes project margins: teams do useful work that was never priced, while clients lose a clear record of what changed. ScopeLint makes the contract part of pull-request review, so questionable work is visible before it is merged.

## Quickstart: replay mode (no API key)

```sh
git clone <owner>/scopelint
cd scopelint
npm install
npx scopelint init
npx scopelint check --diff-file fixtures/diffs/pr2-admin-dashboard.diff --replay
```

Replay mode reads the canned response paired with the diff filename, so it is useful for demos and CI-free evaluation. The final command prints an out-of-scope verdict and a draft change order without contacting the OpenAI API.

## Live mode setup

Create a local `.env` file (it is ignored by Git) with your API key:

```text
OPENAI_API_KEY=your_key_here
```

Then run a live check:

```sh
npx scopelint check --diff-file fixtures/diffs/pr2-admin-dashboard.diff
```

ScopeLint uses `gpt-5.6-terra` by default. Pass `--model <id>` to choose a different compatible model.

To run ScopeLint automatically for pull requests, grant the workflow permission to update pull-request comments:

```yaml
name: ScopeLint
on: pull_request
permissions:
  pull-requests: write
  contents: read
jobs:
  scopelint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: <owner>/scopelint@main
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
```

The action fetches the pull-request diff, posts one marked ScopeLint comment, and updates that same comment on later runs. Use its `scope-path`, `fail-on`, and `model` inputs as needed.

## Demo repository walkthrough

`scopelint-demo-acme` is the companion Express API project. Its staged pull requests demonstrate an in-scope points-accrual feature, an out-of-scope admin analytics endpoint, and a mixed accrual/payment-adapter change. The exported diffs and replay responses live in this repository under `fixtures/`.

The demo project is available beside this repository at `../scopelint-demo-acme`, with these branches ready to open as pull requests:

- `feat/points-accrual-engine`
- `feat/admin-analytics-dashboard`
- `fix/accrual-rounding-payment-adapter`

## How it works

```mermaid
flowchart LR
  A[scope.md contract] --> C[ScopeLint classifier]
  B[Pull request diff] --> C
  C --> D[Terminal verdict]
  C --> E[Pull request comment]
  C --> F[Optional scope ledger]
```

## Built with Codex and GPT-5.6

ScopeLint was built end-to-end in a single primary Codex session, staged deliberately
across six phases: project skeleton and offline replay mode, live GPT-5.6 classification,
GitHub Actions integration, the scope ledger, tests and documentation, and finally the
Acme demo project used in this submission's live pull requests. A short second session
generated and staged the demo repository's branches.

Codex accelerated the build most visibly in three places: scaffolding the composite
GitHub Action (`action.yml`) and its PR-comment update logic, generating the structured
JSON schema validation and retry handling for the classifier, and producing the three
matched diff/canned-response fixture pairs used in ScopeLint's zero-API-key replay mode.

The build wasn't one-shot. Mid-session, Codex initially reported creating the GitHub
Actions caller workflow file when it hadn't actually written it to disk, caught only
when the file was missing from a live pull request check. Effort level was also tuned
down for templated stages (README, tests) and kept high for structurally complex ones
(the Action, the demo repo), to manage Codex credit usage across the full build.

The classifier itself runs on `gpt-5.6-terra`, chosen for its balance of structured
reasoning and cost on a per-pull-request classification task.

Codex session ID: `019f7b1b-5a14-7420-81c7-774cfa15a943`

![ScopeLint verdict comment on a pull request](docs/screenshots/Verdict-comment1.png)
![ScopeLint verdict comment on a pull request](docs/screenshots/Verdict-comment2.png)
![ScopeLint running automatically in GitHub Actions](docs/screenshots/Actions-passing.png)

## Roadmap

- Hosted dashboard and multi-repository views
- Authentication and persistent hosted storage
- Jira and Slack integrations
- IDE extensions
- Team analytics for recurring out-of-scope work

## License

MIT. See [LICENSE](LICENSE).


## Detected evidence (automated analysis)

Indexed codebase: 12 recognized source files, 37 KB.
- OpenAI (technology) — detected in the code
- TypeScript (language) — detected in the code

## Codebase structure (from repository index)

### Files (25 of 25)

```
.DS_Store
.gitignore
action.yml
bin/scopelint.mjs
docs/screenshots/.DS_Store
fixtures/diffs/pr1-points-accrual.diff
fixtures/diffs/pr2-admin-dashboard.diff
fixtures/diffs/pr3-rounding-payment-adapter.diff
fixtures/responses/pr1-points-accrual.json
fixtures/responses/pr2-admin-dashboard.json
fixtures/responses/pr3-rounding-payment-adapter.json
LICENSE
package.json
README.md
src/classify.ts
src/cli.ts
src/comment.ts
src/diff.ts
src/ledger.ts
src/replay.ts
src/types.ts
templates/scope-template.md
test/classify.test.ts
test/comment.test.ts
tsconfig.json
```

### Dependencies

- package.json: @types/node@^24.0.0, commander@^14.0.0, dotenv@^17.0.0, openai@^6.0.0, tsx@^4.20.0, typescript@^5.8.0, vitest@^3.2.0

### Recent commits (newest first)

- Add title card image to README
- Add Codex and GPT-5.6 build section with screenshots
- Add Codex and GPT-5.6 build section to README
- Complete ScopeLint MVP: CLI, GitHub Action, ledger, tests, Acme demo fixtures
- Initial commit

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

### templates/scope-template.md

```markdown
# Statement of work: <project name>

## 1. Deliverables
1.1 <deliverable>
1.2 <deliverable>

## 2. Exclusions
2.1 <excluded work>
2.2 <excluded work>

## 3. Revision policy
3.1 <policy>

```

### package.json

```
{
  "name": "scopelint",
  "version": "0.1.0",
  "description": "Lint code changes against a project's statement of work.",
  "type": "module",
  "bin": {
    "scopelint": "./bin/scopelint.mjs"
  },
  "scripts": {
    "check": "tsx src/cli.ts check",
    "test": "vitest run",
    "typecheck": "tsc --noEmit"
  },
  "engines": {
    "node": ">=20"
  },
  "keywords": [
    "cli",
    "github-actions",
    "scope-creep"
  ],
  "license": "MIT",
  "dependencies": {
    "commander": "^14.0.0",
    "dotenv": "^17.0.0",
    "openai": "^6.0.0",
    "tsx": "^4.20.0"
  },
  "devDependencies": {
    "@types/node": "^24.0.0",
    "typescript": "^5.8.0",
    "vitest": "^3.2.0"
  }
}

```

### src/cli.ts

```typescript
#!/usr/bin/env node
import "dotenv/config";
import { access, copyFile, readFile } from "node:fs/promises";
import { constants } from "node:fs";
import { resolve } from "node:path";
import { Command } from "commander";
import { MODEL, classify } from "./classify.js";
import { postPullRequestComment } from "./comment.js";
import { getDiff } from "./diff.js";
import { appendLedgerRun, regenerateLedgerReport } from "./ledger.js";
import type { ClassificationResult, VerdictName } from "./types.js";

const program = new Command();

program
  .name("scopelint")
  .description("Lint code changes against a project's statement of work.")
  .version("0.1.0");

program
  .command("init")
  .description("Create scope.md from ScopeLint's contract template.")
  .action(async () => {
    const target = resolve("scope.md");
    try {
      await access(target, constants.F_OK);
      throw new Error("scope.md already exists; it was not changed.");
    } catch (error) {
      if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
    }

    await copyFile(new URL("../templates/scope-template.md", import.meta.url), target);
    console.log("Created scope.md from the ScopeLint template.");
  });

program
  .command("check")
  .description("Classify a code diff against a scope contract.")
  .option("--scope <path>", "path to the scope file", "scope.md")
  .option("--diff-file <path>", "read a unified diff from a file")
  .option("--base <ref>", "Git base reference for local diff mode", "origin/main")
  .option("--replay", "use the canned fixture response paired with the diff file")
  .option("--model <id>", "model identifier for live classification", MODEL)
  .option("--fail-on <verdict>", "exit 1 when this verdict occurs")
  .option("--update-ledger", "append verdicts to .scopelint/ledger.json and regenerate SCOPE_LEDGER.md")
  .action(async (options) => {
    const scopePath = resolve(options.scope);
    let scope: string;
    try {
      scope = await readFile(scopePath, "utf8");
    } catch (error) {
      if ((error as NodeJS.ErrnoException).code === "ENOENT") {
        throw new Error(`Scope file not found: ${options.scope}. Run \"scopelint init\" or pass --scope.`);
      }
      throw error;
    }

    const diff = await getDiff({ diffFile: options.diffFile, base: options.base });
    const result = await classify(scope, diff, {
      replay: options.replay || process.env.SCOPELINT_REPLAY === "1",
      diffFile: options.diffFile,
      model: options.model,
    });

    printResult(result);
    if (options.updateLedger) {
      if (process.env.GITHUB_ACTIONS === "true") {
        throw new Error("--update-ledger is a local workflow and cannot run in GitHub Actions.");
      }
      await appendLedgerRun(result);
      console.log("Scope ledger updated: .scopelint/ledger.json and SCOPE_LEDGER.md.");
    }
    if (process.env.GITHUB_ACTIONS === "true" && !options.diffFile) {
      const commentResult = await postPullRequestComment(result);
      console.log(`ScopeLint pull request comment ${commentResult}.`);
    }
    if (options.failOn) {
      validateFailOn(options.failOn);
      if (result.summary[options.failOn as VerdictName] > 0) process.exitCode = 1;
    }
  });

program
  .command("report")
  .description("Regenerate SCOPE_LEDGER.md from .scopelint/ledger.json.")
  .action(async () => {
    const totals = await regenerateLedgerReport();
    console.log("Cumulative ScopeLint totals:");
    console.log(`  In scope: ${totals.in_scope} hours`);
    console.log(`  Out of scope: ${totals.out_of_scope} hours`);
    console.log(`  Gray area: ${totals.gray_area} hours`);
    console.log(`  Out-of-scope effort: ${totals.outOfScopePercent.toFixed(1)}%`);
  });

void program.parseAsync().catch((error: Error) => {
  console.error(`ScopeLint error: ${error.message}`);
  process.exitCode = 1;
});

function validateFailOn(value: string): asserts value is VerdictName {
  if (!["out_of_scope", "gray_area"].includes(value)) {
    throw new Error('--fail-on must be "out_of_scope" or "gray_area".');
  }
}

function printResult(result: ClassificationResult): void {
  const rows = result.verdicts.map((entry) => [
    entry.area,
    entry.verdict.replaceAll("_", " ").toUpperCase(),
    entry.matched_clause ?? "—",
    String(entry.estimated_effort_hours),
  ]);
  const headers = ["AREA", "VERDICT", "CLAUSE", "EFFORT (HOURS)"];
  const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => row[index].length)));
  const format = (row: string[]) => row.map((cell, index) => cell.padEnd(widths[index])).join("  ");

  console.log(format(headers));
  console.log(widths.map((width) => "-".repeat(width)).join("  "));
  rows.forEach((row) => console.log(format(row)));
  console.log(`\nSummary: ${result.summary.in_scope} in scope, ${result.summary.out_of_scope} out of scope, ${result.summary.gray_area} gray area.`);

  for (const entry of result.verdicts.filter((item) => item.verdict === "out_of_scope")) {
    console.log(`\nDraft change order: ${entry.area}\n${entry.change_order_draft}`);
  }
}

```

### action.yml

```yaml
name: ScopeLint
description: Lint pull request changes against the repository statement of work.
inputs:
  scope-path:
    description: Path to the statement of work markdown file.
    required: false
    default: scope.md
  fail-on:
    description: Exit with status 1 when this verdict occurs (out_of_scope or gray_area).
    required: false
    default: ""
  model:
    description: OpenAI model identifier for live classification.
    required: false
    default: gpt-5.6-terra
runs:
  using: composite
  steps:
    - name: Set up Node.js
      uses: actions/setup-node@v4
      with:
        node-version: 20
    - name: Install ScopeLint dependencies
      shell: bash
      working-directory: ${{ github.action_path }}
      run: npm ci --omit=dev --ignore-scripts
    - name: Check pull request scope
      shell: bash
      env:
        INPUT_SCOPE_PATH: ${{ inputs.scope-path }}
        INPUT_FAIL_ON: ${{ inputs.fail-on }}
        INPUT_MODEL: ${{ inputs.model }}
      run: |
        args=(check --scope "$INPUT_SCOPE_PATH" --model "$INPUT_MODEL")
        if [[ -n "$INPUT_FAIL_ON" ]]; then
          args+=(--fail-on "$INPUT_FAIL_ON")
        fi
        node "$GITHUB_ACTION_PATH/bin/scopelint.mjs" "${args[@]}"

```

### src/types.ts

```typescript
export const VERDICTS = ["in_scope", "out_of_scope", "gray_area"] as const;

export type VerdictName = (typeof VERDICTS)[number];

export interface Verdict {
  area: string;
  verdict: VerdictName;
  matched_clause: string | null;
  rationale: string;
  estimated_effort_hours: number;
  change_order_draft: string | null;
}

export interface ClassificationResult {
  verdicts: Verdict[];
  summary: Record<VerdictName, number>;
}

```

### src/replay.ts

```typescript
import { basename, extname, join } from "node:path";
import { readFile } from "node:fs/promises";
import { validateClassification } from "./classify.js";
import type { ClassificationResult } from "./types.js";

export async function loadReplayResponse(diffFile: string): Promise<ClassificationResult> {
  const diffName = basename(diffFile, extname(diffFile));
  const responsePath = join("fixtures", "responses", `${diffName}.json`);

  let raw: string;
  try {
    raw = await readFile(responsePath, "utf8");
  } catch (error) {
    const code = (error as NodeJS.ErrnoException).code;
    if (code === "ENOENT") {
      throw new Error(`Replay response not found: ${responsePath}`);
    }
    throw error;
  }

  try {
    return validateClassification(JSON.parse(raw));
  } catch (error) {
    throw new Error(`Invalid replay response at ${responsePath}: ${(error as Error).message}`);
  }
}

```

### test/classify.test.ts

```typescript
import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
import { validateClassification } from "../src/classify.js";

describe("validateClassification", () => {
  it("accepts a valid canned classifier response", async () => {
    const fixture = JSON.parse(
      await readFile("fixtures/responses/pr2-admin-dashboard.json", "utf8"),
    );

    expect(validateClassification(fixture)).toMatchObject({
      summary: { in_scope: 0, out_of_scope: 1, gray_area: 0 },
      verdicts: [{ verdict: "out_of_scope", matched_clause: "2.1" }],
    });
  });

  it("rejects an out-of-scope verdict without a change-order draft", () => {
    expect(() =>
      validateClassification({
        verdicts: [
          {
            area: "Admin analytics",
            verdict: "out_of_scope",
            matched_clause: "2.1",
            rationale: "The scope excludes it.",
            estimated_effort_hours: 4,
            change_order_draft: null,
          },
        ],
        summary: { in_scope: 0, out_of_scope: 1, gray_area: 0 },
      }),
    ).toThrow("change_order_draft is required");
  });
});

```

### test/comment.test.ts

```typescript
import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { COMMENT_MARKER, postPullRequestComment, renderVerdictComment } from "../src/comment.js";
import type { ClassificationResult } from "../src/types.js";

const result: ClassificationResult = {
  verdicts: [
    {
      area: "Admin analytics endpoint",
      verdict: "out_of_scope",
      matched_clause: "2.1",
      rationale: "The scope excludes reporting dashboards.",
      estimated_effort_hours: 8,
      change_order_draft: "Please approve a change order for this work.",
    },
  ],
  summary: { in_scope: 0, out_of_scope: 1, gray_area: 0 },
};

describe("pull request comments", () => {
  afterEach(() => {
    vi.unstubAllGlobals();
    vi.unstubAllEnvs();
  });

  it("updates the existing marked comment instead of creating another", async () => {
    const eventDirectory = await mkdtemp(join(tmpdir(), "scopelint-comment-test-"));
    const eventPath = join(eventDirectory, "event.json");
    await writeFile(eventPath, JSON.stringify({ number: 42 }));
    vi.stubEnv("GITHUB_EVENT_PATH", eventPath);
    vi.stubEnv("GITHUB_REPOSITORY", "acme/loyalty-api");
    vi.stubEnv("GITHUB_TOKEN", "test-token");

    const fetchMock = vi
      .fn()
      .mockResolvedValueOnce(new Response(JSON.stringify([{ id: 7, body: `old ${COMMENT_MARKER}` }])))
      .mockResolvedValueOnce(new Response("{}", { status: 200 }));
    vi.stubGlobal("fetch", fetchMock);

    await expect(postPullRequestComment(result)).resolves.toBe("updated");
    expect(fetchMock).toHaveBeenCalledTimes(2);
    expect(fetchMock.mock.calls[1][0]).toBe(
      "https://api.github.com/repos/acme/loyalty-api/issues/comments/7",
    );
    expect(fetchMock.mock.calls[1][1]).toMatchObject({ method: "PATCH" });
    expect(renderVerdictComment(result)).toContain(COMMENT_MARKER);
  });
});

```

### src/diff.ts

```typescript
import { readFile } from "node:fs/promises";
import { execFile } from "node:child_process";
import { promisify } from "node:util";

const execFileAsync = promisify(execFile);

export interface DiffOptions {
  diffFile?: string;
  base: string;
}

export async function getDiff(options: DiffOptions): Promise<string> {
  if (options.diffFile) {
    try {
      return await readFile(options.diffFile, "utf8");
    } catch (error) {
      const code = (error as NodeJS.ErrnoException).code;
      if (code === "ENOENT") throw new Error(`Diff file not found: ${options.diffFile}`);
      throw error;
    }
  }

  if (process.env.GITHUB_ACTIONS === "true") {
    return getGitHubPullRequestDiff();
  }

  try {
    const { stdout: mergeBase } = await execFileAsync("git", ["merge-base", options.base, "HEAD"]);
    const { stdout: diff } = await execFileAsync("git", ["diff", mergeBase.trim(), "HEAD"]);
    return diff;
  } catch (error) {
    throw new Error(
      `Unable to obtain a Git diff against ${options.base}: ${(error as Error).message}`,
    );
  }
}

export interface PullRequestContext {
  number: number;
  repository: string;
  token: string;
}

export async function getGitHubPullRequestDiff(): Promise<string> {
  const context = await getGitHubPullRequestContext();
  const response = await fetch(
    `https://api.github.com/repos/${context.repository}/pulls/${context.number}`,
    {
      headers: githubHeaders(context.token, "application/vnd.github.v3.diff"),
    },
  );

  if (!response.ok) {
    throw new Error(
      `Unable to fetch pull request diff (${response.status} ${response.statusText}): ${await response.text()}`,
    );
  }

  return response.text();
}

export async function getGitHubPullRequestContext(): Promise<PullRequestContext> {
  const eventPath = process.env.GITHUB_EVENT_PATH;
  const repository = process.env.GITHUB_REPOSITORY;
  const token = process.env.GITHUB_TOKEN;

  if (!eventPath) throw new Error("GITHUB_EVENT_PATH is required in GitHub Actions mode.");
  if (!repository) throw new Error("GITHUB_REPOSITORY is required in GitHub Actions mode.");
  if (!token) throw new Error("GITHUB_TOKEN is required in GitHub Actions mode.");

  let payload: unknown;
  try {
    payload = JSON.parse(await readFile(eventPath, "utf8"));
  } catch (error) {
    throw new Error(`Unable to read the GitHub event payload: ${(error as Error).message}`);
  }

  const number = getPullRequestNumber(payload);
  if (!number) {
    throw new Error("The GitHub event payload does not contain a pull request number.");
  }

  return { number, repository, token };
}

export function githubHeaders(token: string, accept = "application/vnd.github+json"): HeadersInit {
  return {
    Accept: accept,
    Authorization: `Bearer ${token}`,
    "User-Agent": "ScopeLint",
    "X-GitHub-Api-Version": "2022-11-28",
  };
}

function getPullRequestNumber(payload: unknown): number | undefined {
  if (typeof payload !== "object" || payload === null) return undefined;
  const event = payload as { number?: unknown; pull_request?: { number?: unknown } };
  const number = event.number ?? event.pull_request?.number;
  return typeof number === "number" && Number.isInteger(number) ? number : undefined;
}

```

### src/comment.ts

```typescript
import { getGitHubPullRequestContext, githubHeaders } from "./diff.js";
import type { ClassificationResult, Verdict, VerdictName } from "./types.js";

export const COMMENT_MARKER = "<!-- scopelint -->";

interface IssueComment {
  id: number;
  body: string | null;
}

export function renderVerdictComment(result: ClassificationResult): string {
  const rows = result.verdicts
    .map(
      (verdict) =>
        `| ${escapeTableCell(verdict.area)} | ${badge(verdict.verdict)} | ${escapeTableCell(verdict.matched_clause ?? "—")} | ${verdict.estimated_effort_hours} |`,
    )
    .join("\n");
  const table = [
    "| Area | Verdict | Matched clause | Estimated hours |",
    "| --- | --- | --- | ---: |",
    rows,
  ].join("\n");
  const changeOrders = result.verdicts
    .filter((verdict) => verdict.verdict === "out_of_scope")
    .map(renderChangeOrder)
    .join("\n\n");
  const grayAreas = result.verdicts
    .filter((verdict) => verdict.verdict === "gray_area")
    .map((verdict) => `- **Human call needed — ${verdict.area}:** ${verdict.rationale}`)
    .join("\n");

  return [
    COMMENT_MARKER,
    "## ScopeLint verdict",
    `**Summary:** ${result.summary.in_scope} in scope, ${result.summary.out_of_scope} out of scope, ${result.summary.gray_area} gray area.`,
    table,
    changeOrders,
    grayAreas,
    "Checked against scope.md by ScopeLint.",
  ]
    .filter(Boolean)
    .join("\n\n");
}

export async function postPullRequestComment(result: ClassificationResult): Promise<"created" | "updated"> {
  const context = await getGitHubPullRequestContext();
  const body = renderVerdictComment(result);
  const baseUrl = `https://api.github.com/repos/${context.repository}/issues`;
  const existingComment = await findScopeLintComment(
    `${baseUrl}/${context.number}/comments?per_page=100`,
    context.token,
  );

  if (existingComment) {
    await githubJson(`${baseUrl}/comments/${existingComment.id}`, context.token, "PATCH", { body });
    return "updated";
  }

  await githubJson(`${baseUrl}/${context.number}/comments`, context.token, "POST", { body });
  return "created";
}

async function findScopeLintComment(url: string, token: string): Promise<IssueComment | undefined> {
  let nextUrl: string | undefined = url;

  while (nextUrl) {
    const response = await fetch(nextUrl, { headers: githubHeaders(token) });
    if (!response.ok) {
      throw new Error(
        `Unable to list pull request comments (${response.status} ${response.statusText}): ${await response.text()}`,
      );
    }

    const comments = (await response.json()) as IssueComment[];
    const matchingComment = comments.find((comment) => comment.body?.includes(COMMENT_MARKER));
    if (matchingComment) return matchingComment;
    nextUrl = nextPageUrl(response.headers.get("link"));
  }

  return undefined;
}

async function githubJson(
  url: string,
  token: string,
  method: "POST" | "PATCH",
  payload: { body: string },
): Promise<void> {
  const response = await fetch(url, {
    method,
    headers: { ...githubHeaders(token), "Content-Type": "application/json" },
    body: JSON.stringify(payload),
  });

  if (!response.ok) {
    throw new Error(
      `Unable to ${method === "POST" ? "create" : "update"} ScopeLint comment (${response.status} ${response.statusText}): ${await response.text()}`,
    );
  }
}

function renderChangeOrder(verdict: Verdict): string {
  return `<details>\n<summary>Draft change order: ${verdict.area}</summary>\n\n${verdict.change_order_draft}\n\n</details>`;
}

function badge(verdict: VerdictName): string {
  return verdict.replaceAll("_", " ").toUpperCase();
}

function escapeTableCell(value: string): string {
  return value.replaceAll("|", "\\|").replaceAll("\n", " ");
}

function nextPageUrl(linkHeader: string | null): string | undefined {
  if (!linkHeader) return undefined;
  const next = linkHeader
    .split(",")
    .find((link) => /;\s*rel="next"/.test(link));
  return next?.match(/<([^>]+)>/)?.[1];
}

```

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