Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
10 changes: 7 additions & 3 deletions packages/adapters/claude/src/classify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,13 @@ function readBlockedSentinel(path: string): BlockedSentinel | null {
const parsed = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
if (typeof parsed.question !== "string" || parsed.question.length === 0) return null;
const context = typeof parsed.context === "string" ? parsed.context : undefined;
return context === undefined
? { question: parsed.question }
: { question: parsed.question, context };
// Only "complexity" is a recognized non-default kind; anything else (or
// absent) is a plain question.
const kind = parsed.kind === "complexity" ? "complexity" : undefined;
const out: BlockedSentinel = { question: parsed.question };
if (context !== undefined) out.context = context;
if (kind !== undefined) out.kind = kind;
return out;
} catch {
return null;
}
Expand Down
36 changes: 36 additions & 0 deletions packages/adapters/claude/test/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,42 @@ describe("classifyStop", () => {
}
});

test("a blocked.json with kind 'complexity' surfaces the complexity pause kind", () => {
const { cwd, middle, transcript } = writeMiddleDir();
writeFileSync(
join(middle, "blocked.json"),
JSON.stringify({ question: "4 designs, no winner", kind: "complexity" }),
);
const result = claudeAdapter.classifyStop({
payload: { cwd },
transcriptPath: transcript,
sentinelPresent: true,
worktree: cwd,
});
expect(result.kind).toBe("asked-question");
if (result.kind === "asked-question") {
expect(result.sentinel).toEqual({ question: "4 designs, no winner", kind: "complexity" });
}
});

test("an unrecognized kind falls back to a plain question (kind omitted)", () => {
const { cwd, middle, transcript } = writeMiddleDir();
writeFileSync(
join(middle, "blocked.json"),
JSON.stringify({ question: "Q", kind: "whatever" }),
);
const result = claudeAdapter.classifyStop({
payload: { cwd },
transcriptPath: transcript,
sentinelPresent: true,
worktree: cwd,
});
expect(result.kind).toBe("asked-question");
if (result.kind === "asked-question") {
expect(result.sentinel).toEqual({ question: "Q" });
}
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

test("asked-question tolerates a malformed/contentless blocked.json (sentinel → null)", () => {
const { cwd, middle, transcript } = writeMiddleDir();
writeFileSync(join(middle, "blocked.json"), "{ not valid json");
Expand Down
107 changes: 107 additions & 0 deletions packages/cli/src/commands/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";

/** Overrides for {@link runConfig} — lets a caller (or a test) point at a config file other than the default. */
export type ConfigOptions = {
/** Override the per-repo config path (defaults to `<repoPath>/.middle/config.toml`). */
configFile?: string;
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* The keys `mm config` can set, with where they live and how the value is
* validated/normalized. v1 ships only `auto_dispatch`; the table is the
* extension point for further keys.
*/
const SETTABLE: Record<string, { section: string; normalize: (raw: string) => string | null }> = {
auto_dispatch: {
section: "recommender",
normalize: (raw) => (raw === "true" || raw === "false" ? raw : null),
},
};

/**
* Set `key = value` within `[section]`, preserving the rest of the file
* byte-for-byte (comments, ordering, unrelated keys). Replaces the key in place
* if present in that section, inserts it just under the section header if the
* section exists, or appends a fresh section. The match is scoped to the target
* section so an identically-named key in another section is never touched.
*/
function setTomlKey(source: string, section: string, key: string, value: string): string {
const lines = source.split("\n");
// A TOML table header: `[section]`, tolerating whitespace inside the brackets
// (`[ section ]`) and a trailing line comment (`[section] # note`) — both are
// valid TOML the bare-`[section]` form would miss, appending a duplicate
// table. Returns the trimmed section name, or null if the line isn't a header.
const headerRe = /^\s*\[([^\]]+)\]\s*(?:#.*)?$/;
const headerName = (line: string): string | null => {
const m = headerRe.exec(line);
return m ? m[1]!.trim() : null;
};
// Escape the key — `SETTABLE` is the extension point, and a future key with
// regex metacharacters must match literally, not as a pattern.
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const keyRe = new RegExp(`^(\\s*)${escapedKey}\\s*=`);
let sectionStart = -1;
for (let i = 0; i < lines.length; i += 1) {
if (headerName(lines[i]!) === section) {
sectionStart = i;
break;
}
}
const assignment = `${key} = ${value}`;
if (sectionStart === -1) {
// No such section — append it. Keep exactly one blank line of separation.
const trimmed = source.replace(/\n+$/, "");
return `${trimmed}\n\n[${section}]\n${assignment}\n`;
}
// Scan the section body (until the next header or EOF) for the key.
for (let i = sectionStart + 1; i < lines.length; i += 1) {
if (headerName(lines[i]!) !== null) break; // next section — key absent in this one
if (keyRe.test(lines[i]!)) {
lines[i] = lines[i]!.replace(keyRe, `$1${key} =`).replace(/=.*/, `= ${value}`);
return lines.join("\n");
}
}
// Section exists but lacks the key — insert right after the header.
lines.splice(sectionStart + 1, 0, assignment);
return lines.join("\n");
}

/**
* `mm config <repo> <key> <value>` — set a per-repo config value in
* `<repo>/.middle/config.toml`, preserving the file's comments and layout. v1
* supports `auto_dispatch <true|false>` (the `[recommender]` toggle the
* auto-dispatch loop reads). Returns a process exit code: 0 on success, 1 on error.
*/
export function runConfig(
repoPath: string,
key: string,
value: string,
opts: ConfigOptions = {},
): number {
const spec = SETTABLE[key];
if (!spec) {
const known = Object.keys(SETTABLE).join(", ");
console.error(`mm config: unknown key "${key}" (settable keys: ${known})`);
return 1;
}
const normalized = spec.normalize(value);
if (normalized === null) {
console.error(`mm config: invalid value "${value}" for ${key}`);
return 1;
}
const configFile = opts.configFile ?? join(repoPath, ".middle", "config.toml");
if (!existsSync(configFile)) {
console.error(`mm config: no config at ${configFile} (run \`mm init\` first)`);
return 1;
}
try {
const updated = setTomlKey(readFileSync(configFile, "utf8"), spec.section, key, normalized);
writeFileSync(configFile, updated);
console.log(`mm config: set ${spec.section}.${key} = ${normalized}`);
return 0;
} catch (error) {
console.error(`mm config: ${(error as Error).message}`);
return 1;
}
}
87 changes: 87 additions & 0 deletions packages/cli/src/commands/pause.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import type { Database } from "bun:sqlite";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { loadConfig } from "@middle/core";
import { openAndMigrate } from "@middle/dispatcher/src/db.ts";
import { clearPaused, setPausedUntil } from "@middle/dispatcher/src/repo-config.ts";
import { deriveRepoSlug } from "../paths.ts";

/**
* Overrides for {@link runPause} / {@link runResume} — the config and db paths
* and the repo-slug derivation, so a caller (or a test) can redirect them away
* from the on-disk defaults and the live git remote.
*/
export type PauseResumeOptions = {
/** Override the global config path (defaults to `~/.middle/config.toml`). */
configPath?: string;
/** Override the database path (defaults to the config's `db_path`). */
dbPath?: string;
/** Resolve the repo's `owner/name` slug (defaults to the git-remote derivation). */
resolveSlug?: (repoPath: string) => Promise<string>;
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/** Resolve the db path + the repo slug shared by `mm pause` and `mm resume`. */
async function resolve(
command: string,
repoPath: string,
opts: PauseResumeOptions,
): Promise<{ dbPath: string; repo: string } | null> {
if (!existsSync(join(repoPath, ".git"))) {
console.error(`mm ${command}: "${repoPath}" is not a git repository`);
return null;
}
let dbPath: string;
try {
dbPath = opts.dbPath ?? loadConfig({ globalPath: opts.configPath }).global.dbPath;
} catch (error) {
console.error(`mm ${command}: failed to load config — ${(error as Error).message}`);
return null;
}
const repo = await (opts.resolveSlug ?? deriveRepoSlug)(repoPath);
return { dbPath, repo };
}

/**
* `mm pause <repo>` — suspend auto-dispatch for a repo by setting its
* `repo_config.paused_until`. With no duration the pause is indefinite (cleared
* by `mm resume`). The auto-dispatch loop skips a paused repo. Returns a process
* exit code: 0 on success, 1 on error.
*/
export async function runPause(repoPath: string, opts: PauseResumeOptions = {}): Promise<number> {
let db: Database | null = null;
try {
const resolved = await resolve("pause", repoPath, opts);
if (!resolved) return 1;
db = openAndMigrate(resolved.dbPath);
setPausedUntil(db, resolved.repo);
console.log(`mm pause: ${resolved.repo} auto-dispatch paused (resume with \`mm resume\`)`);
return 0;
} catch (error) {
console.error(`mm pause: ${(error as Error).message}`);
return 1;
} finally {
db?.close();
}
}

/**
* `mm resume <repo>` — clear a repo's pause (`repo_config.paused_until`), so the
* auto-dispatch loop considers it again. A no-op if the repo was never paused.
* Returns a process exit code: 0 on success, 1 on error.
*/
export async function runResume(repoPath: string, opts: PauseResumeOptions = {}): Promise<number> {
let db: Database | null = null;
try {
const resolved = await resolve("resume", repoPath, opts);
if (!resolved) return 1;
db = openAndMigrate(resolved.dbPath);
clearPaused(db, resolved.repo);
console.log(`mm resume: ${resolved.repo} auto-dispatch resumed`);
return 0;
} catch (error) {
console.error(`mm resume: ${(error as Error).message}`);
return 1;
} finally {
db?.close();
}
}
24 changes: 23 additions & 1 deletion packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*
* Public surface:
* - the `mm` CLI: `init`, `uninit`, `start`, `stop`, `status`, `doctor`,
* `dispatch`, `run-recommender`, `docs`, `version`
* `dispatch`, `pause`, `resume`, `config`, `run-recommender`, `docs`, `version`
*
* Where things live:
* - `commands/` — one `run*` function per subcommand
Expand All @@ -23,10 +23,12 @@
* claude-md: false
*/
import { Command } from "commander";
import { runConfig } from "./commands/config.ts";
import { runDispatch } from "./commands/dispatch.ts";
import { runDocs } from "./commands/docs.ts";
import { runDoctor } from "./commands/doctor.ts";
import { runInit } from "./commands/init.ts";
import { runPause, runResume } from "./commands/pause.ts";
import { runRecommender } from "./commands/run-recommender.ts";
import { runStart } from "./commands/start.ts";
import { runStatus } from "./commands/status.ts";
Expand Down Expand Up @@ -94,6 +96,26 @@ program
.argument("<repo>", "path to the local repo checkout")
.action(async (repo: string) => process.exit(await runRecommender(repo)));

program
.command("pause")
.description("Pause auto-dispatch for a repo (set repo_config.paused_until)")
.argument("<repo>", "path to the local repo checkout")
.action(async (repo: string) => process.exit(await runPause(repo)));

program
.command("resume")
.description("Resume auto-dispatch for a repo (clear its pause)")
.argument("<repo>", "path to the local repo checkout")
.action(async (repo: string) => process.exit(await runResume(repo)));

program
.command("config")
.description("Set a per-repo config value (e.g. auto_dispatch true)")
.argument("<repo>", "path to the local repo checkout")
.argument("<key>", "config key (e.g. auto_dispatch)")
.argument("<value>", "the value to set")
.action((repo: string, key: string, value: string) => process.exit(runConfig(repo, key, value)));

program
.command("docs")
.description(
Expand Down
22 changes: 21 additions & 1 deletion packages/cli/src/paths.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { homedir } from "node:os";
import { join } from "node:path";
import { basename, join } from "node:path";

/** middle's per-user home — `~/.middle`. */
export function middleHome(): string {
Expand All @@ -10,3 +10,23 @@ export function middleHome(): string {
export function defaultPidFile(): string {
return join(middleHome(), "dispatcher.pid");
}

/**
* Derive an `owner/name` slug from a repo checkout's `origin` remote, falling
* back to its directory name. This is the same key the dispatcher's workflows
* and `repo_config` rows use (a manual dispatch / recommender run resolves the
* slug the same way), so DB-keyed commands like `mm pause` must derive it
* identically or they'd write a row the auto-dispatch loop never reads.
*/
export async function deriveRepoSlug(repoPath: string): Promise<string> {
const proc = Bun.spawn(["git", "-C", repoPath, "remote", "get-url", "origin"], {
stdout: "pipe",
stderr: "ignore",
});
const url = (await new Response(proc.stdout).text()).trim();
if ((await proc.exited) === 0 && url) {
const match = /[:/]([^/]+\/[^/]+?)(?:\.git)?$/.exec(url);
if (match) return match[1]!;
}
return basename(repoPath);
}
Loading