Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 51 additions & 32 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

GLaDOS reviews open PRs with a pending review request for the authenticated user, and replies when humans respond on its review-comment threads.

1. **Full review (Phase B):** GitHub Search finds `review-requested:@me` PRs → clone → Cursor SDK local agent → post summary + inline comments (`APPROVE` or `REQUEST_CHANGES`).
2. **Thread replies (Phase A):** On every PR notification wake-up (and before each full review) → inspect GLaDOS review threads → agree (reply + resolve) or disagree (reply, leave open). Settled agreements are never re-raised on later reviews of that PR.
1. **Full review:** GitHub Search finds `review-requested:@me` PRs → clone → Cursor SDK local agent → post summary + inline comments (`APPROVE` or `REQUEST_CHANGES`).
2. **Thread replies (optional feature):** On every PR notification wake-up (and before each full review) → inspect GLaDOS review threads → agree (reply + resolve) or disagree (reply, leave open). Settled agreements are never re-raised on later reviews of that PR.

TypeScript, ESM (`"type": "module"`), Node ≥ 22.13. Run scripts with `tsx`.

Expand All @@ -13,7 +13,7 @@ TypeScript, ESM (`"type": "module"`), Node ≥ 22.13. Run scripts with `tsx`.
export GLADOS_TOKEN='ghp_...' # GitHub PAT: search + notifications + repo + pull_requests
export CURSOR_API_KEY='cursor_...'

npm run notifications # Phase A + Phase B for pending reviews; Phase A for PR notifications; clear inbox
npm run notifications # review-requested PRs + PR notification wake-ups; clear inbox
npm run notifications -- --all # include read notifications when listing inbox
npm run smoke # local Cursor SDK smoke test (cwd = this repo)
npm test # unit tests (node:test via tsx)
Expand All @@ -31,7 +31,7 @@ Only `src/cli/` contains runnable entrypoints. Everything else is library code.
```
src/
cli/
notifications.ts # entrypoint: review-requested PRs, then PR notification wake-ups, inbox cleanup
notifications.ts # entrypoint: wires features; inbox cleanup
smoke.ts # entrypoint: one-shot local Agent.prompt smoke test

types.ts # NotificationThread, PullRequestRef
Expand All @@ -44,18 +44,38 @@ src/
pr.ts # search review-requested PRs, pending-reviewer check, notification → PR ref
diff.ts # getCommentableLines() — RIGHT-side lines that accept comments
reviews.ts # postGithubReview() — validate vs diff, then createReview
threads.ts # GraphQL: list/get review threads, reply, resolve

review/
process.ts # processPrReview() — Phase A then Phase B wiring only
thread-process.ts # processPrThreadReplies() / processReviewThreads() — Phase A orchestration
agent.ts # runAgentReview() / runThreadReplies() — Cursor SDK + sandbox/env isolation
payload.ts # review prompt, parse, GitHub formatting, personality, strip control markers
threads.ts # pure: awaiting/settled classification, Phase A prompt/parse, suppress settled findings
threads.ts # GraphQL: list/get review threads, reply, resolve (thread-replies I/O)

review/ # core PR review feature — no thread-reply knowledge
process.ts # processPrReview() — orchestration only
agent.ts # runAgentReview() / promptLocalAgent() — Cursor SDK + sandbox/env
payload.ts # review prompt, parse, GitHub formatting, personality

thread-replies/ # optional feature — delete this folder to rip out
index.ts # public API only (import from here outside the folder)
process.ts # Phase A + compose with full review
agent.ts # runThreadReplies()
logic.ts # classification, markers, suppress, Phase A prompt/parse
```

Co-located `*.test.ts` files use Node’s built-in test runner (`npm test`).

### Feature isolation / rip-out

| Feature | Folder | CLI wiring |
|---------|--------|------------|
| Core PR review | `src/review/` | `processPrReview` |
| Thread replies | `src/thread-replies/` | `processPrReviewWithThreadReplies`, `processPrThreadReplies` |

Today the CLI imports only from `thread-replies/` for reviews (that composer calls core review pieces). To disable thread replies:

1. In `cli/notifications.ts`, call `processPrReview` instead of `processPrReviewWithThreadReplies`, and remove the PR-notification → `processPrThreadReplies` branch (mark those notifications done like others).
2. Delete `src/thread-replies/`.
3. Delete `src/github/threads.ts` if unused.
4. Trim this guide’s Phase A sections.

New optional features get their own top-level folder under `src/` with an `index.ts` public surface; CLI wires them. Do not push feature logic into `review/`.

## Flow

```
Expand All @@ -64,16 +84,14 @@ cli/notifications.ts
github/pr.listReviewRequestedPullRequests() # is:open is:pr review-requested:@me
github/notifications.listNotifications()
→ per review-requested PR:
review/process.processPrReview()
→ withPrLock
→ isReviewRequestedForUser() # skip if not on pending list
→ withPrWorkspace → /tmp/glados-*/repository
→ thread-process.processReviewThreads() # Phase A
→ agent.runAgentReview(+ settled context) # Phase B
→ suppressSettledFindings()
→ payload.buildGithubReview() → reviews.postGithubReview()
thread-replies.processPrReviewWithThreadReplies()
→ withPrLock + withPrWorkspace
→ Phase A (processReviewThreads)
→ review/agent.runAgentReview(+ settled context)
→ suppressSettledFindings + sanitize markers
→ payload.buildGithubReview → reviews.postGithubReview
→ per PullRequest notification (wake-up only; GitHub coalesces PR activity):
thread-process.processPrThreadReplies() # Phase A only; clone only if a reply is needed
thread-replies.processPrThreadReplies() # Phase A only; clone only if a reply is needed
markNotificationDone() only if Phase A complete
→ other notifications: markNotificationDone()
```
Expand All @@ -84,7 +102,7 @@ cli/notifications.ts

**Per-PR lock:** `withPrLock()` serializes overlapping local CLI runs so two processes cannot reply/review the same PR at once.

## Phase A — thread replies
## Phase A — thread replies (`src/thread-replies/`)

**Awaiting:** root comment by GLaDOS, unresolved, not settled, and there is an unacknowledged human reply (anyone) after GLaDOS’s last acknowledgment.

Expand All @@ -93,15 +111,15 @@ cli/notifications.ts
**Actions:**
- Agree → controlled reply (`<!-- glados:reply-to:… -->` + `<!-- glados:agree -->`) → `resolveReviewThread` when `viewerCanResolve`.
- Disagree → controlled reply with acknowledgment marker only; leave open. Back-and-forth continues indefinitely whenever someone replies after GLaDOS.
- Agreed but unresolved → retry resolve only (no new agent reply). If `viewerCanResolve` is false, leave Phase A incomplete so the notification is retained for later retry; still treat the issue as settled for Phase B.
- Agreed but unresolved → retry resolve only (no new agent reply). If `viewerCanResolve` is false, leave Phase A incomplete so the notification is retained for later retry; still treat the issue as settled for the following full review.

**Race safety:** re-fetch the target thread immediately before each write; compare full comment id/body/`updatedAt` snapshots. After posting, reconcile — if a human raced in, the thread stays awaiting.

**Markers:** appended only by `formatReplyBody()` / controlled code. Strip from all model-authored review text via `stripGladosControlMarkers()`. Root findings never count as agreements.
**Markers:** appended only by `formatReplyBody()` / controlled code in `thread-replies/logic.ts`. Stripped before posting reviews via `sanitizeReviewForPost()`. Root findings never count as agreements.

## Phase B — full review
## Full review (`src/review/`)

Inject settled findings (original body + agreement reason) into `buildReviewPrompt()`. After parse, `suppressSettledFindings()` removes exact text repeats and regenerates the summary if anything was dropped. Open/disagreed threads do **not** suppress new findings.
When composed with thread replies, settled findings (original body + agreement reason) are injected as `extraContext` into `buildReviewPrompt()`. After parse, `suppressSettledFindings()` removes exact text repeats. Open/disagreed threads do **not** suppress new findings.

**Approve vs request changes:** `critical` or `high` → `REQUEST_CHANGES`; else `APPROVE`.

Expand Down Expand Up @@ -131,9 +149,9 @@ Inject settled findings (original body + agreement reason) into `buildReviewProm
| Reviewer instructions / JSON schema | `review/payload.ts` → `buildReviewPrompt()` |
| Severity levels | `review/payload.ts` → `SEVERITIES` + `Severity` |
| GLaDOS voice | `review/payload.ts` → `applyPersonality()` (post-time rewrite) |
| Thread reply prompt / classification / suppress | `review/threads.ts` |
| Phase A orchestration | `review/thread-process.ts` |
| Phase B wiring | `review/process.ts` (no business logic) |
| Thread reply prompt / classification / suppress | `thread-replies/logic.ts` |
| Phase A + compose with review | `thread-replies/process.ts` |
| Core review wiring | `review/process.ts` (no business logic) |
| Clone / lock / cleanup | `git/workspace.ts` |
| GitHub I/O | `github/` |

Expand All @@ -145,8 +163,9 @@ Design detail: `docs/superpowers/specs/2026-08-05-review-thread-replies-design.m

- **ESM imports** use `.js` extensions in TypeScript source.
- **New runnable scripts** go in `src/cli/` only; wire in `package.json`.
- **New library code** goes in `github/`, `git/`, or `review/` — not a generic `utils/`.
- **Keep modules small:** `agent.ts` = SDK + isolation; `payload.ts` / `threads.ts` = pure data; `process.ts` / `thread-process.ts` = wiring.
- **New library code** goes in a domain folder (`github/`, `git/`, `review/`) or a feature folder (`thread-replies/`, …) — not a generic `utils/`.
- **Feature folders** expose a small `index.ts` public API; outside code imports only from that.
- **Keep modules small:** `agent.ts` = SDK + isolation; `payload.ts` / `logic.ts` = pure data; `process.ts` = wiring.
- **Tests** live next to the code they cover (`*.test.ts`); add cases when changing classification, markers, locks, or suppression.
- **Minimize scope** — match existing style, no over-abstraction.

Expand All @@ -157,4 +176,4 @@ Design detail: `docs/superpowers/specs/2026-08-05-review-thread-replies-design.m
| `GLADOS_TOKEN` | GitHub API (search, notifications, clone auth, reviews, thread reply/resolve) |
| `CURSOR_API_KEY` | Cursor SDK local agent runs |

`GLADOS_TOKEN` needs access to arbitrary repos that send review requests (`repo` scope or equivalent). Resolving conversations additionally requires being the PR author **or** having write access on the repo — when that fails, agreement still settles for Phase B but Phase A stays incomplete for retry.
`GLADOS_TOKEN` needs access to arbitrary repos that send review requests (`repo` scope or equivalent). Resolving conversations additionally requires being the PR author **or** having write access on the repo — when that fails, agreement still settles for the following full review but Phase A stays incomplete for retry.
31 changes: 16 additions & 15 deletions docs/superpowers/specs/2026-08-05-review-thread-replies-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,14 @@ GLaDOS must respond when humans reply on its review comment threads: agree (shor

```
cli/notifications.ts
├─ review-requested PRs → processPrReview()
│ 1. preparePrWorkspace()
│ 2. processReviewThreads() # Phase A
│ 3. runAgentReview(+ settled) # Phase B
│ 4. postGithubReview()
├─ review-requested PRs → processPrReviewWithThreadReplies()
│ 1. withPrWorkspace()
│ 2. processReviewThreads() # Phase A (thread-replies/)
│ 3. runAgentReview(+ settled) # core review/
│ 4. suppress + sanitize + post
└─ inbox: reply on a GLaDOS review thread
processReviewThreads() only
└─ inbox: PullRequest notification wake-up
processPrThreadReplies() only
→ markNotificationDone() only after successful processing
```

Expand Down Expand Up @@ -71,14 +71,15 @@ Open / disagreed threads are **not** suppressors; new findings on the same topic
| Module | Responsibility |
|--------|----------------|
| `github/threads.ts` | List PR review threads (GraphQL), create thread reply, resolve thread; identify authenticated user |
| `review/threads.ts` | Pure: awaiting vs settled classification; prompt/parse for Phase A; format settled context for Phase B |
| `review/agent.ts` | `runThreadReplies()` for Phase A |
| `review/thread-process.ts` | Orchestrate Phase A thread reads, replies, resolution and retries |
| `review/process.ts` | Run Phase A, pass settled context into Phase B, post full review |
| `cli/notifications.ts` | Treat every PR notification as a fresh Phase A wake-up |

Keep `process.ts` as wiring only; GitHub I/O in `github/`; prompt/parse and
classification in `review/threads.ts`.
| `thread-replies/` | Isolated feature folder (public API via `index.ts`) |
| `thread-replies/logic.ts` | Pure: awaiting vs settled classification; prompt/parse for Phase A; format settled context; suppress |
| `thread-replies/agent.ts` | `runThreadReplies()` for Phase A |
| `thread-replies/process.ts` | Phase A orchestration + `processPrReviewWithThreadReplies` composer |
| `review/` | Core PR review only — no thread-reply imports |
| `cli/notifications.ts` | Wires features; every PR notification is a Phase A wake-up |

Keep feature logic inside `thread-replies/`; GitHub I/O in `github/`; core
review stays replaceable without knowing about threads.

## GitHub API notes

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"scripts": {
"smoke": "tsx src/cli/smoke.ts",
"notifications": "tsx src/cli/notifications.ts",
"test": "tsx --test src/git/workspace.test.ts src/github/pr.test.ts src/review/agent.test.ts src/review/payload.test.ts src/review/threads.test.ts",
"test": "tsx --test 'src/**/*.test.ts'",
"typecheck": "tsc --noEmit"
},
"dependencies": {
Expand Down
9 changes: 6 additions & 3 deletions src/cli/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import {
pullRequestRefFromNotification,
subjectUrlToWebUrl,
} from "../github/pr.js";
import { processPrReview } from "../review/process.js";
import { processPrThreadReplies } from "../review/thread-process.js";
import {
processPrReviewWithThreadReplies,
processPrThreadReplies,
} from "../thread-replies/index.js";

const token = process.env.GLADOS_TOKEN;
if (!token) {
Expand All @@ -36,7 +38,8 @@ try {
for (const pr of reviewRequestedPrs) {
console.log(` ${pr.owner}/${pr.repo} #${pr.prNumber}`);
console.log(` ${pr.prUrl}`);
await processPrReview(pr, {
// Feature compose: Phase A + Phase B. Swap for processPrReview to rip out.
await processPrReviewWithThreadReplies(pr, {
githubToken: token,
cursorApiKey,
});
Expand Down
56 changes: 8 additions & 48 deletions src/review/agent.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,20 @@
import { Agent } from "@cursor/sdk";
import { mkdir } from "node:fs/promises";
import { basename, dirname, join } from "node:path";
import type { ReviewThread } from "../github/threads.js";
import {
buildReviewPrompt,
parseReviewResult,
type ReviewPayload,
} from "./payload.js";
import {
buildThreadReplyPrompt,
parseThreadReplyResult,
validateThreadReplyDecisions,
type ThreadReplyDecision,
} from "./threads.js";

export async function runAgentReview(
repoDir: string,
prUrl: string,
cursorApiKey: string,
settledContext = "",
extraContext = "",
): Promise<ReviewPayload> {
const result = await promptAgent(
buildReviewPrompt(prUrl, settledContext),
const result = await promptLocalAgent(
buildReviewPrompt(prUrl, extraContext),
repoDir,
cursorApiKey,
);
Expand All @@ -44,44 +37,11 @@ export async function runAgentReview(
}
}

export async function runThreadReplies(
repoDir: string,
prUrl: string,
threads: ReviewThread[],
cursorApiKey: string,
): Promise<ThreadReplyDecision[]> {
if (threads.length === 0) return [];

const result = await promptAgent(
buildThreadReplyPrompt(prUrl, threads),
repoDir,
cursorApiKey,
);

if (result.status !== "finished") {
throw new Error(`Thread reply agent ${result.status}: ${result.id}`);
}

const raw = result.result?.trim();
if (!raw) {
throw new Error("Agent returned empty thread replies");
}

try {
const decisions = parseThreadReplyResult(raw);
validateThreadReplyDecisions(
decisions,
threads.map((thread) => thread.id),
);
return decisions;
} catch (err) {
console.error("Could not parse thread reply JSON:");
console.log(raw);
throw err;
}
}

async function promptAgent(
/**
* Shared local Cursor SDK prompt entry. Features outside this module may call
* this; they own their own prompts and result parsing.
*/
export async function promptLocalAgent(
prompt: string,
repoDir: string,
cursorApiKey: string,
Expand Down
20 changes: 0 additions & 20 deletions src/review/payload.test.ts

This file was deleted.

Loading
Loading