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
95 changes: 55 additions & 40 deletions coverage/coverage-final.json

Large diffs are not rendered by default.

578 changes: 570 additions & 8 deletions package-lock.json

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,18 +46,22 @@
"@anthropic-ai/claude-agent-sdk": "^0.2.63",
"@azure/identity": "^4.13.0",
"@inquirer/prompts": "^8.3.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"@octokit/auth-oauth-device": "^7.1.5",
"@octokit/rest": "^21.1.1",
"@openai/codex": "^0.1.0",
"@opencode-ai/sdk": "^1.2.10",
"azure-devops-node-api": "^14.1.0",
"better-sqlite3": "^12.8.0",
"chalk": "^5.4.1",
"commander": "^14.0.3",
"glob": "^11.0.1",
"open": "^10.2.0"
"open": "^10.2.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@github/copilot-sdk": "^0.1.32",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.13.4",
"@vitest/coverage-v8": "^4.0.18",
"semantic-release": "^25.0.3",
Expand Down
3 changes: 2 additions & 1 deletion src/agents/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@ const AGENTS: Record<AgentName, BootFn> = {

/**
* All registered agent names — useful for CLI help text and validation.
* Safe to cast: Object.keys() on a Record<AgentName, …> always returns AgentName values.
*/
export const AGENT_NAMES = Object.keys(AGENTS) as AgentName[];
export const AGENT_NAMES: readonly AgentName[] = Object.keys(AGENTS) as AgentName[];

/**
* Boot an agent by name.
Expand Down
8 changes: 3 additions & 5 deletions src/agents/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,11 +234,9 @@ export async function boot(opts: AgentBootOptions): Promise<SpecAgent> {

fileLoggerStorage.getStore()?.agentEvent("spec", "completed", `${Date.now() - startTime}ms`);
return {
data: {
content: cleanedContent,
valid: validation.valid,
validationReason: validation.reason,
},
data: validation.valid
? { content: cleanedContent, valid: true }
: { content: cleanedContent, valid: false, validationReason: validation.reason },
success: true,
durationMs: Date.now() - startTime,
};
Expand Down
23 changes: 15 additions & 8 deletions src/agents/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,18 @@ export interface ExecutorData {
}

/** Domain payload for the spec agent. */
export interface SpecData {
/** The cleaned spec content. */
content: string;
/** Whether the spec passed structural validation. */
valid: boolean;
/** Validation failure reason, if any. */
validationReason?: string;
}
export type SpecData =
| {
/** The cleaned spec content. */
content: string;
/** Spec passed structural validation. */
valid: true;
}
| {
/** The cleaned spec content (may be partial). */
content: string;
/** Spec failed structural validation. */
valid: false;
/** Human-readable reason for the validation failure. */
validationReason: string;
};
36 changes: 35 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ export function parseArgs(argv: string[]): [ParsedArgs, Set<string>] {
)
.addOption(
new Option("--source <name>", "Issue source").choices(
DATASOURCE_NAMES as string[],
[...DATASOURCE_NAMES],
),
)
.option(
Expand Down Expand Up @@ -368,6 +368,40 @@ async function main() {
process.exit(0);
}

// ── MCP subcommand ─────────────────────────────────────────
if (rawArgv[0] === "mcp") {
const mcpProgram = new Command("dispatch-mcp")
.exitOverride()
.configureOutput({ writeOut: () => {}, writeErr: () => {} })
.helpOption(false)
.allowUnknownOption(true)
.allowExcessArguments(true)
.option("--port <number>", "Port to listen on", (v: string) => parseInt(v, 10), 9110)
.option("--host <host>", "Host to bind to", "127.0.0.1")
.option("--cwd <dir>", "Working directory", (v: string) => resolve(v));

try {
mcpProgram.parse(rawArgv.slice(1), { from: "user" });
} catch (err) {
if (err instanceof CommanderError) {
log.error(err.message);
process.exit(1);
}
throw err;
}

const mcpOpts = mcpProgram.opts<{ port: number; host: string; cwd?: string }>();
const { startMcpServer } = await import("./mcp/index.js");
await startMcpServer({
port: mcpOpts.port,
host: mcpOpts.host,
cwd: mcpOpts.cwd ?? process.cwd(),
});
// startMcpServer installs signal handlers and the http server keeps the
// event loop alive; we only reach here if something calls process.exit().
return;
}
Comment on lines +371 to +403
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description claims no runtime behavior/public API changes, but this adds a new dispatch mcp subcommand and introduces an MCP HTTP server + SQLite persistence layer (new dependencies like better-sqlite3 and @modelcontextprotocol/sdk). Please update the PR description (or split into a separate PR) to reflect these functional additions and their operational impact.

Copilot uses AI. Check for mistakes.

const [args, explicitFlags] = parseArgs(rawArgv);

// Enable verbose logging before anything else
Expand Down
7 changes: 4 additions & 3 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,9 +192,10 @@ export function validateConfigValue(key: ConfigKey, value: string): string | nul
return null;
}

default:
return `Unknown config key "${key}"`;
}
default: {
const _exhaustive: never = key;
return `Unknown config key "${_exhaustive}"`;
} }
}

/**
Expand Down
13 changes: 10 additions & 3 deletions src/datasources/azdevops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ export async function detectWorkItemType(
if (!Array.isArray(types) || types.length === 0) return null;

const names = types.map((t) => t.name).filter((n): n is string => !!n);
const preferred = ["User Story", "Product Backlog Item", "Requirement", "Issue"];
const preferred = ["User Story", "Product Backlog Item", "Requirement", "Issue"] as const;
for (const p of preferred) {
if (names.includes(p)) return p;
}
Expand Down Expand Up @@ -154,7 +154,7 @@ export async function detectDoneState(

// Fallback: check for known terminal states in priority order
const names = states.map((s) => s.name).filter((n): n is string => !!n);
const fallbacks = ["Done", "Closed", "Resolved", "Completed"];
const fallbacks = ["Done", "Closed", "Resolved", "Completed"] as const;
for (const f of fallbacks) {
if (names.includes(f)) {
doneStateCache.set(cacheKey, f);
Expand Down Expand Up @@ -266,6 +266,8 @@ export const datasource: Datasource = {

const wiql = `SELECT [System.Id] FROM workitems WHERE ${conditions.join(" AND ")} ORDER BY [System.CreatedDate] DESC`;

// The SDK's queryByWiql accepts a partial TeamContext with only `project` set.
// Cast is safe: we only use the project field for routing, matching SDK usage patterns.
const queryResult = await witApi.queryByWiql({ query: wiql }, { project } as TeamContext);
const workItemRefs = queryResult.workItems ?? [];
if (workItemRefs.length === 0) return [];
Expand All @@ -286,6 +288,8 @@ export const datasource: Datasource = {
for (let i = 0; i < itemsArray.length; i += CONCURRENCY) {
const batch = itemsArray.slice(i, i + CONCURRENCY);
const batchResults = await Promise.all(
// item.id is guaranteed non-null here: the ids array was built by filtering
// out null ids from workItemRefs, and itemsArray came from getWorkItems(ids).
batch.map((item) => fetchComments(item.id!, project, connection))
);
commentsArray.push(...batchResults);
Expand Down Expand Up @@ -330,7 +334,8 @@ export const datasource: Datasource = {
{ op: "add", path: "/fields/System.Title", value: title },
{ op: "add", path: "/fields/System.Description", value: body },
];
// customHeaders is the first arg (pass null), document second, id third
// The azure-devops-node-api SDK's updateWorkItem signature requires a `customHeaders`
// first argument; passing null is the documented way to omit it (no typed alternative).
await witApi.updateWorkItem(null as any, document as JsonPatchDocument, Number(issueId));
},

Expand All @@ -354,6 +359,7 @@ export const datasource: Datasource = {
const document = [
{ op: "add", path: "/fields/System.State", value: state },
];
// null as any: SDK customHeaders param — passing null is the documented way to omit it.
await witApi.updateWorkItem(null as any, document as JsonPatchDocument, Number(issueId));
},

Expand All @@ -380,6 +386,7 @@ export const datasource: Datasource = {
];

const item = await witApi.createWorkItem(
// null as any: SDK customHeaders param — passing null is the documented way to omit it.
null as any,
document as JsonPatchDocument,
project,
Expand Down
7 changes: 2 additions & 5 deletions src/datasources/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { log } from "../helpers/logger.js";
import { InvalidBranchNameError, isValidBranchName } from "../helpers/branch-validation.js";
import { getGithubOctokit } from "../helpers/auth.js";
import { getGitRemoteUrl, parseGitHubRemoteUrl } from "./index.js";
import { RequestError } from "@octokit/request-error";

export { InvalidBranchNameError } from "../helpers/branch-validation.js";

Expand Down Expand Up @@ -352,11 +353,7 @@ export const datasource: Datasource = {
// If a PR already exists for this branch, retrieve its URL.
// Octokit throws a RequestError with status 422 for validation
// failures, including "A pull request already exists".
const isValidationError =
typeof err === "object" &&
err !== null &&
"status" in err &&
(err as { status: number }).status === 422;
const isValidationError = err instanceof RequestError && err.status === 422;

if (isValidationError) {
const { data: prs } = await octokit.rest.pulls.list({
Expand Down
5 changes: 3 additions & 2 deletions src/datasources/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type { Datasource, DatasourceName } from "./interface.js";
import { DATASOURCE_NAMES } from "./interface.js";
import { datasource as githubDatasource } from "./github.js";
import { datasource as azdevopsDatasource } from "./azdevops.js";
import { datasource as mdDatasource } from "./md.js";
Expand All @@ -26,9 +27,9 @@ const DATASOURCES: Partial<Record<DatasourceName, Datasource>> = {
};

/**
* All registered datasource names — useful for CLI help text and validation.
* All registered datasource names — re-exported from the canonical definition in interface.ts.
*/
export const DATASOURCE_NAMES = Object.keys(DATASOURCES) as DatasourceName[];
export { DATASOURCE_NAMES } from "./interface.js";

/**
* Get a datasource by name.
Expand Down
4 changes: 3 additions & 1 deletion src/datasources/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,10 @@ export interface DispatchLifecycleOptions {
username?: string;
}

export const DATASOURCE_NAMES = ["github", "azdevops", "md"] as const;

/** Valid datasource backend names. */
export type DatasourceName = "github" | "azdevops" | "md";
export type DatasourceName = typeof DATASOURCE_NAMES[number];

/**
* Interface that all datasource implementations must satisfy.
Expand Down
3 changes: 2 additions & 1 deletion src/helpers/gitignore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ export async function ensureGitignoreEntry(repoRoot: string, entry: string): Pro
try {
contents = await readFile(gitignorePath, "utf8");
} catch (err: unknown) {
if (err instanceof Error && "code" in err && (err as NodeJS.ErrnoException).code === "ENOENT") {
// "code" in err is a runtime guard that proves the property exists
if (err instanceof Error && "code" in err && (err as { code?: unknown }).code === "ENOENT") {
// File doesn't exist — will be created below
} else {
log.warn(`Could not read .gitignore: ${String(err)}`);
Expand Down
9 changes: 7 additions & 2 deletions src/helpers/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,19 @@ const LOG_LEVEL_SEVERITY: Record<LogLevel, number> = {
error: 3,
};

/** Type predicate: returns true if `level` is a valid `LogLevel` key. */
function isLogLevel(level: string): level is LogLevel {
return Object.hasOwn(LOG_LEVEL_SEVERITY, level);
}

/**
* Resolve the effective log level from environment variables.
* Priority: LOG_LEVEL > DEBUG > default ("info").
*/
function resolveLogLevel(): LogLevel {
const envLevel = process.env.LOG_LEVEL?.toLowerCase();
if (envLevel && Object.hasOwn(LOG_LEVEL_SEVERITY, envLevel)) {
return envLevel as LogLevel;
if (envLevel && isLogLevel(envLevel)) {
return envLevel;
}
if (process.env.DEBUG) {
return "debug";
Expand Down
Loading
Loading