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
7 changes: 6 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,12 @@ src/
domain.ts
drift.ts
errors.ts
frontmatter.ts
generate.ts
index.ts
mcp.ts
renderers.ts
review-state.ts
```
<!-- /generate -->

Expand Down Expand Up @@ -101,7 +104,7 @@ Two-way sync between the local backlog and GitHub Issues:
## Testing

<!-- generate:test-summary -->
12 test files in `tests/` using Vitest:
13 test files in `tests/` using Vitest:

- `api.test.ts`
- `cli.test.ts`
Expand All @@ -113,6 +116,7 @@ Two-way sync between the local backlog and GitHub Issues:
- `generate.test.ts`
- `github-adapter.test.ts`
- `mcp.test.ts`
- `review-state.test.ts`
- `ship-sync.test.ts`
- `witness.test.ts`

Expand All @@ -128,5 +132,6 @@ shell command execution during witness capture.
- `@flyingrobots/bijou-node`
- `@git-stunts/plumbing`
- `@modelcontextprotocol/sdk`
- `yaml`
- `zod`
<!-- /generate -->
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,8 +307,10 @@ in one sentence, the cycle is too big. Split it.
- Backlog maintenance.

Closing the cycle packet does not mean `main` has accepted it yet.
Review-stage visibility is not yet a repo-native METHOD query.
Branch and PR context carry that state for now.
Review-stage visibility now has a repo-native METHOD query:
`method review-state`. It summarizes branch and PR state when forge
context is available; the underlying review work still lives on the
branch and PR.

6. **PR / review** - review the full cycle packet until merge or
rejection.
Expand Down Expand Up @@ -364,6 +366,7 @@ If you can answer these questions by reading the repo, you do not need
a standup:

- What is actively open in this workspace? -> `method status`
- What is under review? -> `method review-state`
- What is committed? -> each design doc names its sponsors and hill
- What is next? -> `ls docs/method/backlog/asap/`
- What failed and why? -> `ls docs/method/retro/`
Expand All @@ -372,9 +375,10 @@ a standup:
If any of these are unclear, the docs are incomplete. Fix the docs,
not the process.

Review state is not yet part of METHOD's repo-native coordination
surface. For now it rides on branch and PR context outside the core
filesystem queries.
Review state now has a repo-native query surface through
`method review-state`. It still reads branch and PR context rather than
the filesystem alone, but that coordination question no longer requires
ad hoc `gh` reconstruction.

### BEARING.md

Expand Down
5 changes: 5 additions & 0 deletions docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ Show backlog lanes, active cycles, and legend health.
Check active cycle playback questions against test descriptions in tests/.
First cut scans tests/**/*.test.* and tests/**/*.spec.* only.

### `method review-state [--pr NUMBER | --current-branch] [--json]`

Query PR review / merge-readiness state for the current branch or an explicit PR.
Defaults to --current-branch when no selector flag is provided.

### `method mcp`

Start an MCP (Model Context Protocol) server on stdio.
Expand Down
9 changes: 9 additions & 0 deletions docs/MCP.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ workspace-agnostic — a single instance can serve multiple projects.
## Tools

<!-- generate:mcp-tools -->
### `method_review_state`

Get PR review / merge-readiness state for the current branch or an explicit PR. `pr` and `currentBranch` are mutually exclusive; when `pr` is omitted, current-branch resolution is the default behavior.

**Parameters:**

- `pr` (optional) `integer` — Explicit PR number to inspect
- `currentBranch` (optional) `boolean` — Resolve the PR from the current branch (default when pr is omitted)

### `method_status`

Get the current status of the METHOD workspace (backlog lanes, active cycles, legend health)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,81 @@
---
title: "Review State Query"
legend: PROCESS
lane: cool-ideas
owner: "METHOD maintainers"
priority: medium
acceptance_criteria:
- "The review-state surface reports CI/check status, unresolved review-thread count, review decision, and bot-review pending state for the active PR."
- "The output contract is explicit for both CLI and MCP: `method review-state` supports `--json`, `method_review_state` returns the standard structured-content envelope, and both share the same result schema."
- "The output includes a deterministic merge_ready boolean plus a canonical blocker schema naming exactly which states still prevent merge."
- "Selector precedence is explicit: `method review-state` defaults to `--current-branch`, rejects `--pr` with `--current-branch`, and defines deterministic no-PR and ambiguous-PR outputs."
- "Humans and agents can answer 'is this PR actually mergeable yet?' without manually reconstructing GitHub thread/check state."
legend: "PROCESS"
cycle: "0034-review-state-query"
source_backlog: "docs/method/backlog/cool-ideas/PROCESS_review-state-query.md"
---

# Review State Query

Source backlog item: `docs/method/backlog/cool-ideas/PROCESS_review-state-query.md`
Legend: PROCESS

## Sponsors

- Human: Backlog operator
- Agent: Implementation agent

## Hill

Land a METHOD-native review-state surface that answers "what is under
review and is it merge-ready?" without forcing humans or agents to
reconstruct `gh` state ad hoc. The first cut must ship one shared
engine behind both CLI and MCP, with deterministic selector behavior,
structured blockers, and explicit handling for unresolved threads,
checks, sticky review decisions, and observable bot cooldowns.

## Playback Questions

### Human

- [ ] Can I run `method review-state` on the current branch or an
explicit PR and get a bounded summary of blockers and
merge-readiness?
- [ ] When no PR exists or multiple PRs match the branch, do I get a
deterministic result instead of a vague shell failure?

### Agent

- [ ] Does the CLI `--json` output exactly match the MCP
`structuredContent.result` contract?
- [ ] Do unresolved review threads remain the primary blockers when
`reviewDecision` is still `CHANGES_REQUESTED`?
- [ ] Does the result surface explicit bot cooldown / review-in-progress
states only when they are observable in GitHub data?

## Accessibility and Assistive Reading

- Linear truth / reduced-complexity posture:
default CLI output must stay short, ordered, and label-driven so a
human can scan it top-to-bottom without parsing raw JSON or nested
GitHub state.
- Non-visual or alternate-reading expectations:
JSON mode and MCP output must carry the full machine contract so
screen-reader or agent callers do not lose fidelity compared with the
prose summary.

## Localization and Directionality

- Locale / wording / formatting assumptions:
the first cut is English-only and preserves GitHub enum vocabulary
(`APPROVED`, `CHANGES_REQUESTED`, `REVIEW_REQUIRED`) rather than
inventing localized labels.
- Logical direction / layout assumptions:
output is plain left-to-right text / JSON with no layout-specific
affordances.

## Agent Inspectability and Explainability

- What must be explicit and deterministic for agents:
selector rules, result field presence, blocker types, and null/zero
behavior for `no-pr` / `ambiguous-pr` cases must be fixed and tested.
- What must be attributable, evidenced, or governed:
cooldown blockers must only appear when the latest observable bot
signal actually contains a rate-limit message; the engine must not
invent hidden policy state.

## Backlog Context

METHOD currently says review state is not part of the repo-native
coordination surface. A dedicated query or signpost that summarizes what
is under review, awaiting merge, or blocked in PR state would make the
Expand Down
5 changes: 3 additions & 2 deletions docs/method/process.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ METHOD cycles run as a calm pull-design-test-playback-close-review-ship-sync loo
boundaries or on a dedicated triage branch.
- Repo-level ship surfaces such as `BEARING.md` and `CHANGELOG.md`
reflect merged `main` state, not branch-local closeout state.
- Review visibility is currently outside METHOD's repo-native
coordination surface; branch and PR context carry it for now.
- Review visibility is available through `method review-state`, which
summarizes branch and PR context without turning METHOD into a forge
cockpit.
- All cycle work must be done on a branch named `cycles/####-slug`.
- Once a full cycle is complete (after the retro), the operator must
push the branch and open a PR to the target branch (usually `main`).
Expand Down
58 changes: 57 additions & 1 deletion src/cli-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export type ParsedCommand =
| { command: 'pull'; item: string }
| { command: 'close'; cycle?: string; driftCheck?: 'yes' | 'no'; outcome: Outcome }
| { command: 'drift'; cycle?: string }
| { command: 'review-state'; pr?: number; currentBranch?: boolean; json?: boolean }
| { command: 'status' }
| { command: 'mcp' }
| { command: 'sync'; adapter: 'github'; push?: boolean; pull?: boolean }
Expand Down Expand Up @@ -41,6 +42,8 @@ export function parseCliArgs(argv: readonly string[]): ParsedCommand {
return parseCloseArgs(rest);
case 'drift':
return parseDriftArgs(rest);
case 'review-state':
return parseReviewStateArgs(rest);
case 'status':
if (rest.length > 0) {
throw new MethodError('`status` does not take any arguments.');
Expand Down Expand Up @@ -77,7 +80,7 @@ export function parseCliArgs(argv: readonly string[]): ParsedCommand {
}
}

export const CLI_TOPICS = ['init', 'inbox', 'pull', 'close', 'status', 'drift', 'mcp', 'sync'] as const;
export const CLI_TOPICS = ['init', 'inbox', 'pull', 'close', 'status', 'drift', 'review-state', 'mcp', 'sync'] as const;

export function usage(topic?: string): string {
if (topic === 'init') {
Expand All @@ -104,6 +107,15 @@ export function usage(topic?: string): string {
].join('\n');
}

if (topic === 'review-state') {
return [
'Usage: method review-state [--pr NUMBER | --current-branch] [--json]',
'',
'Query PR review / merge-readiness state for the current branch or an explicit PR.',
'Defaults to --current-branch when no selector flag is provided.',
].join('\n');
}

if (topic === 'mcp') {
return 'Usage: method mcp\n\nStart an MCP (Model Context Protocol) server on stdio.';
}
Expand All @@ -129,6 +141,7 @@ export function usage(topic?: string): string {
' pull <item> Promote a backlog item into a cycle.',
' close [cycle] Write a retro for an active cycle.',
' drift [cycle] Check active cycle playback questions against tests.',
' review-state Query PR review / merge-readiness state.',
' status Show backlog, active cycles, and legend health.',
' mcp Start the MCP server over stdio.',
' sync github [--push|--pull] Sync backlog with GitHub Issues.',
Expand Down Expand Up @@ -238,6 +251,41 @@ function parseDriftArgs(args: readonly string[]): ParsedCommand {
return { command: 'drift', cycle: args[0] };
}

function parseReviewStateArgs(args: readonly string[]): ParsedCommand {
let pr: number | undefined;
let currentBranch: boolean | undefined;
let json = false;

for (let index = 0; index < args.length; index += 1) {
const value = args[index];
if (value === '--json') {
json = true;
continue;
}
if (value === '--current-branch') {
currentBranch = true;
continue;
}
if (value === '--pr') {
const rawValue = requireOptionValue(args, index, '--pr');
pr = parsePositiveInteger(rawValue, '--pr');
index += 1;
continue;
}
if (value.startsWith('--pr=')) {
pr = parsePositiveInteger(value.slice('--pr='.length), '--pr');
continue;
}
throw new MethodError(`Unknown option: ${value}`);
}

if (pr !== undefined && currentBranch) {
throw new MethodError('`review-state` accepts either `--pr` or `--current-branch`, not both.');
}

return { command: 'review-state', pr, currentBranch, json };
}

function requireOptionValue(args: readonly string[], index: number, optionName: string): string {
const value = args[index + 1];
if (value === undefined || value.startsWith('-')) {
Expand All @@ -259,3 +307,11 @@ function parseOutcomeValue(value: string): Outcome {
}
throw new MethodError('`--outcome` must be `hill-met`, `partial`, or `not-met`.');
}

function parsePositiveInteger(value: string, optionName: string): number {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new MethodError(`${optionName} must be a positive integer.`);
}
return parsed;
}
27 changes: 18 additions & 9 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
#!/usr/bin/env node

import { realpathSync } from 'node:fs';
import { relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
Expand All @@ -12,17 +11,16 @@ import { initWorkspace, Workspace } from './index.js';
import { loadConfig } from './config.js';
import { createMcpServer } from './mcp.js';
import { GitHubAdapter } from './adapters/github.js';

import { queryReviewState, renderReviewStateText, type ReviewStateQueryOptions, type ReviewStateResult } from './review-state.js';
type Writer = Pick<NodeJS.WritableStream, 'write'>;
type ConfirmPrompt = (options: { title: string; defaultValue: boolean }) => Promise<boolean>;

export interface RunCliOptions {
cwd?: string;
stdout?: Writer;
stderr?: Writer;
confirmPrompt?: ConfirmPrompt;
reviewStateQuery?: (options: ReviewStateQueryOptions) => Promise<ReviewStateResult>;
}

export async function runCli(
argv: readonly string[],
options: RunCliOptions = {},
Expand All @@ -33,7 +31,6 @@ export async function runCli(
const ctx = createNodeContext();
const promptConfirm = options.confirmPrompt
?? ((promptOptions) => confirm({ title: promptOptions.title, defaultValue: promptOptions.defaultValue, ctx }));

try {
const parsed = parseCliArgs(argv);
if (parsed.command === 'help') {
Expand Down Expand Up @@ -87,8 +84,23 @@ export async function runCli(
return report.exitCode;
}

if (parsed.command === 'review-state') {
const reviewStateQuery = options.reviewStateQuery ?? queryReviewState;
const result = await reviewStateQuery({
cwd: root,
pr: parsed.pr,
currentBranch: parsed.currentBranch,
});
if (parsed.json) {
stdout.write(`${JSON.stringify(result, null, 2)}\n`);
} else {
stdout.write(`${renderReviewStateText(result)}\n`);
}
return 0;
}

if (parsed.command === 'mcp') {
const server = createMcpServer();
const server = createMcpServer({ reviewStateQuery: options.reviewStateQuery ?? queryReviewState });
const transport = new StdioServerTransport();
await server.connect(transport);
// Let it run indefinitely
Expand Down Expand Up @@ -163,7 +175,6 @@ export async function runCli(
return 0;
}
}

const status = workspace.status();
stdout.write(renderStatus(status));
return 0;
Expand All @@ -173,11 +184,9 @@ export async function runCli(
return 1;
}
}

export async function main(argv: readonly string[] = process.argv.slice(2)): Promise<number> {
return runCli(argv);
}

if (process.argv[1] !== undefined && fileURLToPath(import.meta.url) === realpathSync(resolve(process.argv[1]))) {
main().then((code) => {
process.exitCode = code;
Expand Down
Loading
Loading