diff --git a/CHANGELOG.md b/CHANGELOG.md
index 573151a17..fcc4845c5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -313,6 +313,7 @@ See [docs/releases/v0.0.7.md](docs/releases/v0.0.7.md) for full notes and [docs/
### Removed
- None.
+- Add private local maintainer profiles for PR Review so OK Code can load external maintainer workflows without committing `.okcode/` files to the target repo.
## [0.0.6] - 2026-03-28
diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts
index 6b859dc47..41e9248fa 100644
--- a/apps/server/src/git/Layers/GitCore.test.ts
+++ b/apps/server/src/git/Layers/GitCore.test.ts
@@ -1705,6 +1705,49 @@ it.layer(TestLayer)("git integration", (it) => {
}),
);
+ it.effect(
+ "resets upstream to the current branch when stale tracking points at a different branch",
+ () =>
+ Effect.gen(function* () {
+ const tmp = yield* makeTmpDir();
+ const remote = yield* makeTmpDir();
+ yield* git(remote, ["init", "--bare"]);
+
+ yield* initRepoWithCommit(tmp);
+ const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find(
+ (branch) => branch.current,
+ )!.name;
+ yield* git(tmp, ["remote", "add", "origin", remote]);
+ yield* git(tmp, ["push", "-u", "origin", initialBranch]);
+
+ const staleBranch = "codex/update-create-markdown-2-0-1";
+ yield* git(tmp, ["checkout", "-b", staleBranch]);
+ yield* writeTextFile(path.join(tmp, "stale.txt"), "stale\n");
+ yield* git(tmp, ["add", "stale.txt"]);
+ yield* git(tmp, ["commit", "-m", "stale branch"]);
+ yield* git(tmp, ["push", "-u", "origin", staleBranch]);
+
+ const featureBranch = "fresh-auth-flow";
+ yield* git(tmp, ["checkout", initialBranch]);
+ yield* git(tmp, ["checkout", "-b", featureBranch]);
+ yield* writeTextFile(path.join(tmp, "fresh.txt"), "fresh\n");
+ yield* git(tmp, ["add", "fresh.txt"]);
+ yield* git(tmp, ["commit", "-m", "fresh branch"]);
+ yield* git(tmp, ["push", "-u", "origin", featureBranch]);
+ yield* git(tmp, ["branch", "--set-upstream-to", `origin/${staleBranch}`]);
+
+ const core = yield* GitCore;
+ const pushed = yield* core.pushCurrentBranch(tmp, null);
+
+ expect(pushed.status).toBe("pushed");
+ expect(pushed.setUpstream).toBe(true);
+ expect(pushed.upstreamBranch).toBe(`origin/${featureBranch}`);
+ expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe(
+ `origin/${featureBranch}`,
+ );
+ }),
+ );
+
it.effect("includes command context when worktree removal fails", () =>
Effect.gen(function* () {
const tmp = yield* makeTmpDir();
diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts
index c5e084c17..c521fd076 100644
--- a/apps/server/src/git/Layers/GitCore.ts
+++ b/apps/server/src/git/Layers/GitCore.ts
@@ -1433,16 +1433,34 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute"
Effect.catch(() => Effect.succeed(null)),
);
if (currentUpstream) {
- yield* runGit("GitCore.pushCurrentBranch.pushUpstream", cwd, [
+ if (
+ currentUpstream.upstreamBranch === branch ||
+ currentUpstream.remoteName !== "origin"
+ ) {
+ yield* runGit("GitCore.pushCurrentBranch.pushUpstream", cwd, [
+ "push",
+ currentUpstream.remoteName,
+ `HEAD:${currentUpstream.upstreamBranch}`,
+ ]);
+ return {
+ status: "pushed" as const,
+ branch,
+ upstreamBranch: currentUpstream.upstreamRef,
+ setUpstream: false,
+ };
+ }
+
+ yield* runGit("GitCore.pushCurrentBranch.pushResetUpstream", cwd, [
"push",
+ "-u",
currentUpstream.remoteName,
- `HEAD:${currentUpstream.upstreamBranch}`,
+ branch,
]);
return {
status: "pushed" as const,
branch,
- upstreamBranch: currentUpstream.upstreamRef,
- setUpstream: false,
+ upstreamBranch: `${currentUpstream.remoteName}/${branch}`,
+ setUpstream: true,
};
}
diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts
index 79acc11ed..3c329acce 100644
--- a/apps/server/src/git/Layers/GitManager.test.ts
+++ b/apps/server/src/git/Layers/GitManager.test.ts
@@ -1427,6 +1427,74 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
}),
);
+ it.effect("prefers the current local branch for head and ignores stale PR base metadata", () =>
+ Effect.gen(function* () {
+ const repoDir = yield* makeTempDir("okcode-git-manager-");
+ yield* initRepo(repoDir);
+ const remoteDir = yield* createBareRemote();
+ yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
+ yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
+
+ yield* runGit(repoDir, ["checkout", "-b", "codex/update-create-markdown-2-0-1"]);
+ fs.writeFileSync(path.join(repoDir, "old.txt"), "old\n");
+ yield* runGit(repoDir, ["add", "old.txt"]);
+ yield* runGit(repoDir, ["commit", "-m", "Old branch commit"]);
+ yield* runGit(repoDir, ["push", "-u", "origin", "codex/update-create-markdown-2-0-1"]);
+
+ yield* runGit(repoDir, ["checkout", "main"]);
+ yield* runGit(repoDir, ["checkout", "-b", "fresh-auth-flow"]);
+ fs.writeFileSync(path.join(repoDir, "fresh.txt"), "fresh\n");
+ yield* runGit(repoDir, ["add", "fresh.txt"]);
+ yield* runGit(repoDir, ["commit", "-m", "Fresh branch commit"]);
+ yield* runGit(repoDir, ["push", "-u", "origin", "fresh-auth-flow"]);
+ yield* runGit(repoDir, [
+ "branch",
+ "--set-upstream-to",
+ "origin/codex/update-create-markdown-2-0-1",
+ ]);
+ yield* runGit(repoDir, [
+ "config",
+ "branch.fresh-auth-flow.gh-merge-base",
+ "codex/update-create-markdown-2-0-1",
+ ]);
+
+ const { manager, ghCalls } = yield* makeManager({
+ ghScenario: {
+ prListSequence: [
+ JSON.stringify([]),
+ JSON.stringify([
+ {
+ number: 233,
+ title: "Fresh auth flow",
+ url: "https://github.com/pingdotgg/codething-mvp/pull/233",
+ baseRefName: "main",
+ headRefName: "fresh-auth-flow",
+ },
+ ]),
+ ],
+ },
+ });
+
+ const result = yield* runStackedAction(manager, {
+ cwd: repoDir,
+ action: "commit_push_pr",
+ });
+
+ expect(result.pr.status).toBe("created");
+ expect(result.pr.number).toBe(233);
+ expect(
+ ghCalls.some((call) => call.includes("pr create --base main --head fresh-auth-flow")),
+ ).toBe(true);
+ expect(
+ ghCalls.some((call) =>
+ call.includes(
+ "pr create --base codex/update-create-markdown-2-0-1 --head codex/update-create-markdown-2-0-1",
+ ),
+ ),
+ ).toBe(false);
+ }),
+ );
+
it.effect("rejects push/pr actions from detached HEAD", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("okcode-git-manager-");
diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts
index 59ad1e3f0..778aaae50 100644
--- a/apps/server/src/git/Layers/GitManager.ts
+++ b/apps/server/src/git/Layers/GitManager.ts
@@ -519,9 +519,6 @@ export const makeGitManager = Effect.gen(function* () {
const headBranchFromUpstream = details.upstreamRef
? extractBranchFromRef(details.upstreamRef)
: "";
- const headBranch =
- headBranchFromUpstream.length > 0 ? headBranchFromUpstream : details.branch;
-
const [remoteRepository, originRepository] = yield* Effect.all(
[
resolveRemoteRepositoryContext(cwd, remoteName),
@@ -539,6 +536,11 @@ export const makeGitManager = Effect.gen(function* () {
remoteName !== "origin" &&
remoteRepository.repositoryNameWithOwner !== null;
+ const headBranch =
+ isCrossRepository && headBranchFromUpstream.length > 0
+ ? headBranchFromUpstream
+ : details.branch;
+
const ownerHeadSelector =
remoteRepository.ownerLogin && headBranch.length > 0
? `${remoteRepository.ownerLogin}:${headBranch}`
@@ -557,6 +559,12 @@ export const makeGitManager = Effect.gen(function* () {
);
}
appendUnique(headSelectors, details.branch);
+ appendUnique(
+ headSelectors,
+ headBranchFromUpstream.length > 0 && headBranchFromUpstream !== details.branch
+ ? headBranchFromUpstream
+ : null,
+ );
appendUnique(headSelectors, headBranch !== details.branch ? headBranch : null);
if (!isCrossRepository && shouldProbeRemoteOwnedSelectors) {
appendUnique(headSelectors, ownerHeadSelector);
@@ -662,27 +670,31 @@ export const makeGitManager = Effect.gen(function* () {
cwd: string,
branch: string,
upstreamRef: string | null,
- headContext: Pick,
+ headContext: Pick,
) =>
Effect.gen(function* () {
+ const defaultFromGh = yield* gitHubCli
+ .getDefaultBranch({ cwd })
+ .pipe(Effect.catch(() => Effect.succeed(null)));
+ const fallbackBase = defaultFromGh ?? "main";
const configured = yield* gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`);
- if (configured) return configured;
+ if (configured && configured !== headContext.headBranch && configured === fallbackBase) {
+ return configured;
+ }
if (upstreamRef && !headContext.isCrossRepository) {
const upstreamBranch = extractBranchFromRef(upstreamRef);
- if (upstreamBranch.length > 0 && upstreamBranch !== branch) {
+ if (
+ upstreamBranch.length > 0 &&
+ upstreamBranch !== branch &&
+ upstreamBranch !== headContext.headBranch &&
+ upstreamBranch === fallbackBase
+ ) {
return upstreamBranch;
}
}
- const defaultFromGh = yield* gitHubCli
- .getDefaultBranch({ cwd })
- .pipe(Effect.catch(() => Effect.succeed(null)));
- if (defaultFromGh) {
- return defaultFromGh;
- }
-
- return "main";
+ return fallbackBase;
});
const resolvePreferredRemoteName = (cwd: string, branch: string) =>
@@ -1014,7 +1026,13 @@ export const makeGitManager = Effect.gen(function* () {
};
}
- const baseBranch = yield* resolveBaseBranch(cwd, branch, details.upstreamRef, headContext);
+ let baseBranch = yield* resolveBaseBranch(cwd, branch, details.upstreamRef, headContext);
+ if (baseBranch === headContext.headBranch) {
+ const defaultBase = yield* gitHubCli
+ .getDefaultBranch({ cwd })
+ .pipe(Effect.catch(() => Effect.succeed(null)));
+ baseBranch = defaultBase && defaultBase !== headContext.headBranch ? defaultBase : "main";
+ }
const rangeContext = yield* gitCore.readRangeContext(cwd, baseBranch);
const generated = yield* textGeneration.generatePrContent({
diff --git a/apps/server/src/prReview/Layers/PrReview.ts b/apps/server/src/prReview/Layers/PrReview.ts
index dfbdec5bc..454109ce8 100644
--- a/apps/server/src/prReview/Layers/PrReview.ts
+++ b/apps/server/src/prReview/Layers/PrReview.ts
@@ -21,6 +21,8 @@ import type {
PrWorkflowStepRunResult,
} from "@okcode/contracts";
import { GitHubCli } from "../../git/Services/GitHubCli.ts";
+import { runProcess } from "../../processRunner";
+import { decodePrReviewLocalCommandAction } from "../localProfiles.ts";
import { RepoReviewConfig } from "../Services/RepoReviewConfig.ts";
import { PrReviewProjection } from "../Services/PrReviewProjection.ts";
import { WorkflowEngine } from "../Services/WorkflowEngine.ts";
@@ -592,6 +594,75 @@ const makePrReview = Effect.gen(function* () {
}) => Effect.Effect,
): Effect.Effect => getRepoOwnerAndName(cwd).pipe(Effect.flatMap(f));
+ const executeLocalCommandAction = (input: {
+ action: string;
+ prNumber: number;
+ step: { id: string; title: string; requiresConfirmation: boolean };
+ }): Effect.Effect =>
+ Effect.tryPromise({
+ try: async () => {
+ const decoded = decodePrReviewLocalCommandAction(input.action);
+ if (!decoded) {
+ return {
+ stepId: input.step.id,
+ status: "failed",
+ summary: `Workflow step ${input.step.title} is missing a runnable local command.`,
+ requiresConfirmation: input.step.requiresConfirmation,
+ } satisfies PrWorkflowStepRunResult;
+ }
+
+ const args = decoded.args.map((entry) =>
+ entry.replaceAll("{{prNumber}}", String(input.prNumber)),
+ );
+ try {
+ const result = await runProcess(args[0] ?? decoded.label, args.slice(1), {
+ cwd: decoded.cwd,
+ timeoutMs: 10 * 60_000,
+ allowNonZeroExit: true,
+ maxBufferBytes: 512 * 1024,
+ outputMode: "truncate",
+ });
+ const stderr = result.stderr.trim();
+ const stdout = result.stdout.trim();
+ const snippet = stderr || stdout;
+ if (result.code !== 0 || result.timedOut) {
+ return {
+ stepId: input.step.id,
+ status: "failed",
+ summary:
+ snippet.length > 0
+ ? snippet.slice(0, 280)
+ : `${decoded.label} failed${result.timedOut ? " (timed out)" : ""}.`,
+ requiresConfirmation: input.step.requiresConfirmation,
+ } satisfies PrWorkflowStepRunResult;
+ }
+ return {
+ stepId: input.step.id,
+ status: "done",
+ summary:
+ snippet.length > 0
+ ? snippet.slice(0, 280)
+ : `${decoded.label} completed successfully.`,
+ requiresConfirmation: input.step.requiresConfirmation,
+ } satisfies PrWorkflowStepRunResult;
+ } catch (error) {
+ return {
+ stepId: input.step.id,
+ status: "failed",
+ summary:
+ error instanceof Error ? error.message.slice(0, 280) : `${decoded.label} failed.`,
+ requiresConfirmation: input.step.requiresConfirmation,
+ } satisfies PrWorkflowStepRunResult;
+ }
+ },
+ catch: (cause) =>
+ new PrReviewError({
+ operation: "runWorkflowStep",
+ detail: `Failed to execute workflow step ${input.step.title}.`,
+ cause,
+ }),
+ });
+
const service: PrReviewShape = {
getConfig: ({ cwd }) => repoReviewConfig.getConfig({ cwd }),
watchRepoConfig: ({ cwd, onChange }) => repoReviewConfig.watchRepo({ cwd, onChange }),
@@ -807,6 +878,18 @@ const makePrReview = Effect.gen(function* () {
} else if (step.kind === "reviewAction") {
status = "blocked";
summary = "Submit a review from the action rail to complete this step.";
+ } else if (
+ config.source === "localProfile" &&
+ typeof step.action === "string" &&
+ decodePrReviewLocalCommandAction(step.action)
+ ) {
+ const commandResult = yield* executeLocalCommandAction({
+ action: step.action,
+ prNumber: input.prNumber,
+ step,
+ });
+ status = commandResult.status;
+ summary = commandResult.summary;
} else if (step.kind === "skillSet" && step.skillSet) {
summary = `Skill set ${step.skillSet} is ready to run.`;
}
diff --git a/apps/server/src/prReview/Layers/RepoReviewConfig.ts b/apps/server/src/prReview/Layers/RepoReviewConfig.ts
index cc5243122..b4dcc1ee5 100644
--- a/apps/server/src/prReview/Layers/RepoReviewConfig.ts
+++ b/apps/server/src/prReview/Layers/RepoReviewConfig.ts
@@ -1,4 +1,5 @@
import fs from "node:fs";
+import os from "node:os";
import path from "node:path";
import { promises as fsPromises } from "node:fs";
@@ -14,12 +15,29 @@ import type {
} from "@okcode/contracts";
import { Effect, Layer } from "effect";
import YAML from "yaml";
+import { runProcess } from "../../processRunner";
+import {
+ encodePrReviewLocalCommandAction,
+ parseGitHubRepositoryNameWithOwnerFromRemoteUrl,
+} from "../localProfiles.ts";
import { RepoReviewConfig, type RepoReviewConfigShape } from "../Services/RepoReviewConfig.ts";
import { PrReviewConfigError } from "../Errors.ts";
const REVIEW_RULES_RELATIVE_PATH = ".okcode/review-rules.md";
const WORKFLOWS_RELATIVE_DIR = ".okcode/workflows";
const SKILL_SETS_RELATIVE_DIR = ".okcode/skill-sets";
+const LOCAL_PROFILE_RELATIVE_DIR = "pr-review-profiles";
+
+type LocalProfileDefinition = {
+ id: string;
+ title: string;
+ body: string;
+ repositories: string[];
+ adapter: "openclawMaintainer";
+ maintainersRepo: string;
+ relativePath: string;
+ absolutePath: string;
+};
const DEFAULT_BLOCKING_RULES: PrReviewRuleDefinition[] = [
{
@@ -178,6 +196,7 @@ function normalizeRuleDefinitions(value: unknown): PrReviewRuleDefinition[] {
function normalizeMentionGroups(value: unknown): PrReviewRules["mentionGroups"] {
if (!Array.isArray(value)) return [];
+
const groups: Array = [];
for (const [index, entry] of value.entries()) {
if (!entry || typeof entry !== "object") continue;
@@ -249,6 +268,76 @@ function normalizeWorkflowSteps(value: unknown): PrWorkflowStep[] {
.filter((entry): entry is PrWorkflowStep => entry !== null);
}
+function normalizeStringArray(value: unknown): string[] {
+ if (!Array.isArray(value)) {
+ return [];
+ }
+ return value
+ .map((entry) => (typeof entry === "string" ? entry.trim() : ""))
+ .filter((entry) => entry.length > 0);
+}
+
+function normalizeRepositoryMatcher(value: string): string {
+ return value.trim().toLowerCase();
+}
+
+function resolveOkcodeHome(): string {
+ const raw = process.env.OKCODE_HOME?.trim();
+ if (!raw) {
+ return path.join(os.homedir(), ".okcode");
+ }
+ if (raw === "~") {
+ return os.homedir();
+ }
+ if (raw.startsWith("~/") || raw.startsWith("~\\")) {
+ return path.join(os.homedir(), raw.slice(2));
+ }
+ return path.resolve(raw);
+}
+
+function resolveMaybeHomePath(input: string): string {
+ const trimmed = input.trim();
+ if (trimmed === "~") {
+ return os.homedir();
+ }
+ if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) {
+ return path.join(os.homedir(), trimmed.slice(2));
+ }
+ return path.resolve(trimmed);
+}
+
+function readTitleAndDescription(input: {
+ raw: string | null;
+ fallbackTitle: string;
+ fallbackDescription?: string | null;
+}): { title: string; description: string | null; body: string } {
+ if (!input.raw) {
+ return {
+ title: input.fallbackTitle,
+ description: input.fallbackDescription ?? null,
+ body: "",
+ };
+ }
+ try {
+ const { frontmatter, body } = splitFrontmatter(input.raw);
+ const title =
+ typeof frontmatter.title === "string" && frontmatter.title.trim().length > 0
+ ? frontmatter.title.trim()
+ : input.fallbackTitle;
+ const description =
+ typeof frontmatter.description === "string" && frontmatter.description.trim().length > 0
+ ? frontmatter.description.trim()
+ : (input.fallbackDescription ?? null);
+ return { title, description, body };
+ } catch {
+ return {
+ title: input.fallbackTitle,
+ description: input.fallbackDescription ?? null,
+ body: input.raw.trim(),
+ };
+ }
+}
+
function parseRulesDocument(input: ParsedFrontmatter): {
rules: PrReviewRules;
issues: PrReviewConfigIssue[];
@@ -410,6 +499,70 @@ function parseSkillSetDocument(input: ParsedFrontmatter):
}
}
+function parseLocalProfileDocument(input: { absolutePath: string; raw: string }): {
+ profile: LocalProfileDefinition | null;
+ issues: PrReviewConfigIssue[];
+} {
+ const relativePath = path.join(LOCAL_PROFILE_RELATIVE_DIR, path.basename(input.absolutePath));
+ try {
+ const { frontmatter, body } = splitFrontmatter(input.raw);
+ const repositories = normalizeStringArray(frontmatter.repositories).map(
+ normalizeRepositoryMatcher,
+ );
+ const adapterRaw = typeof frontmatter.adapter === "string" ? frontmatter.adapter.trim() : "";
+ const maintainersRepoRaw =
+ typeof frontmatter.maintainersRepo === "string" ? frontmatter.maintainersRepo.trim() : "";
+ const id =
+ typeof frontmatter.id === "string" && frontmatter.id.trim().length > 0
+ ? frontmatter.id.trim()
+ : path.basename(input.absolutePath, path.extname(input.absolutePath));
+ const title =
+ typeof frontmatter.title === "string" && frontmatter.title.trim().length > 0
+ ? frontmatter.title.trim()
+ : id;
+
+ if (repositories.length === 0) {
+ return {
+ profile: null,
+ issues: [toIssue("warning", relativePath, "Profile is missing repositories[] matchers.")],
+ };
+ }
+ if (adapterRaw !== "openclawMaintainer") {
+ return {
+ profile: null,
+ issues: [
+ toIssue("warning", relativePath, `Unsupported adapter "${adapterRaw || "(missing)"}".`),
+ ],
+ };
+ }
+ if (maintainersRepoRaw.length === 0) {
+ return {
+ profile: null,
+ issues: [toIssue("warning", relativePath, "Profile is missing maintainersRepo.")],
+ };
+ }
+
+ return {
+ profile: {
+ id,
+ title,
+ body,
+ repositories,
+ adapter: "openclawMaintainer",
+ maintainersRepo: resolveMaybeHomePath(maintainersRepoRaw),
+ relativePath,
+ absolutePath: input.absolutePath,
+ },
+ issues: [],
+ };
+ } catch (error) {
+ return {
+ profile: null,
+ issues: [toIssue("error", relativePath, `Failed to parse frontmatter: ${String(error)}`)],
+ };
+ }
+}
+
async function readMarkdownFile(
cwd: string,
relativePath: string,
@@ -426,6 +579,20 @@ async function readMarkdownFile(
}
}
+async function readMarkdownFileAbsolute(
+ absolutePath: string,
+): Promise<{ exists: boolean; raw: string | null; absolutePath: string }> {
+ try {
+ const raw = await fsPromises.readFile(absolutePath, "utf8");
+ return { exists: true, raw, absolutePath };
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
+ return { exists: false, raw: null, absolutePath };
+ }
+ throw error;
+ }
+}
+
async function listMarkdownFiles(cwd: string, relativeDir: string): Promise {
const absoluteDir = path.join(cwd, relativeDir);
try {
@@ -442,6 +609,292 @@ async function listMarkdownFiles(cwd: string, relativeDir: string): Promise {
+ try {
+ const entries = await fsPromises.readdir(absoluteDir, { withFileTypes: true });
+ return entries
+ .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".md"))
+ .map((entry) => path.join(absoluteDir, entry.name))
+ .toSorted((a, b) => a.localeCompare(b));
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
+ return [];
+ }
+ throw error;
+ }
+}
+
+async function determineGitHubRepositoryNameWithOwner(cwd: string): Promise {
+ const remote = await runProcess("git", ["remote", "get-url", "origin"], {
+ cwd,
+ timeoutMs: 5_000,
+ allowNonZeroExit: true,
+ });
+ const remoteUrl = remote.stdout.trim() || remote.stderr.trim();
+ return parseGitHubRepositoryNameWithOwnerFromRemoteUrl(remoteUrl);
+}
+
+async function buildOpenClawMaintainerConfig(input: {
+ profile: LocalProfileDefinition;
+ issues: PrReviewConfigIssue[];
+}): Promise {
+ const workflowPath = path.join(input.profile.maintainersRepo, ".agents/skills/PR_WORKFLOW.md");
+ const reviewSkillPath = path.join(
+ input.profile.maintainersRepo,
+ ".agents/skills/review-pr/SKILL.md",
+ );
+ const prepareSkillPath = path.join(
+ input.profile.maintainersRepo,
+ ".agents/skills/prepare-pr/SKILL.md",
+ );
+ const mergeSkillPath = path.join(
+ input.profile.maintainersRepo,
+ ".agents/skills/merge-pr/SKILL.md",
+ );
+
+ const workflowFile = await readMarkdownFileAbsolute(workflowPath);
+ const reviewSkillFile = await readMarkdownFileAbsolute(reviewSkillPath);
+ const prepareSkillFile = await readMarkdownFileAbsolute(prepareSkillPath);
+ const mergeSkillFile = await readMarkdownFileAbsolute(mergeSkillPath);
+
+ if (!workflowFile.exists) {
+ input.issues.push(
+ toIssue(
+ "warning",
+ input.profile.relativePath,
+ `Maintainer workflow file not found at ${workflowPath}. Falling back to a minimal local profile.`,
+ ),
+ );
+ }
+ for (const missing of [
+ { label: "review-pr skill", file: reviewSkillFile },
+ { label: "prepare-pr skill", file: prepareSkillFile },
+ { label: "merge-pr skill", file: mergeSkillFile },
+ ]) {
+ if (!missing.file.exists) {
+ input.issues.push(
+ toIssue(
+ "warning",
+ input.profile.relativePath,
+ `${missing.label} not found at ${missing.file.absolutePath}.`,
+ ),
+ );
+ }
+ }
+
+ const workflowDoc = readTitleAndDescription({
+ raw: workflowFile.raw,
+ fallbackTitle: "OpenClaw Maintainer PR Workflow",
+ fallbackDescription: "Private maintainer workflow loaded from a local OK Code profile.",
+ });
+ const reviewSkillDoc = readTitleAndDescription({
+ raw: reviewSkillFile.raw,
+ fallbackTitle: "review-pr",
+ fallbackDescription: "Run the read-only maintainer review workflow.",
+ });
+ const prepareSkillDoc = readTitleAndDescription({
+ raw: prepareSkillFile.raw,
+ fallbackTitle: "prepare-pr",
+ fallbackDescription: "Resolve findings, re-run gates, and push safely.",
+ });
+ const mergeSkillDoc = readTitleAndDescription({
+ raw: mergeSkillFile.raw,
+ fallbackTitle: "merge-pr",
+ fallbackDescription: "Verify readiness and perform the deterministic squash merge.",
+ });
+
+ const workflowId = `${input.profile.id}-workflow`;
+ const skillSets: PrSkillSetDefinition[] = [
+ {
+ id: "review-pr",
+ title: reviewSkillDoc.title,
+ description: reviewSkillDoc.description,
+ skills: ["review-pr"],
+ allowedTools: ["local-command"],
+ runPolicy: "script-first",
+ body: reviewSkillDoc.body,
+ relativePath: reviewSkillPath,
+ },
+ {
+ id: "prepare-pr",
+ title: prepareSkillDoc.title,
+ description: prepareSkillDoc.description,
+ skills: ["prepare-pr"],
+ allowedTools: ["local-command"],
+ runPolicy: "script-first",
+ body: prepareSkillDoc.body,
+ relativePath: prepareSkillPath,
+ },
+ {
+ id: "merge-pr",
+ title: mergeSkillDoc.title,
+ description: mergeSkillDoc.description,
+ skills: ["merge-pr"],
+ allowedTools: ["local-command"],
+ runPolicy: "script-first",
+ body: mergeSkillDoc.body,
+ relativePath: mergeSkillPath,
+ },
+ ];
+
+ const workflow: PrWorkflowDefinition = {
+ id: workflowId,
+ title: workflowDoc.title,
+ description: workflowDoc.description,
+ appliesTo: ["pull-request"],
+ blocking: true,
+ body:
+ input.profile.body.length > 0
+ ? input.profile.body
+ : workflowDoc.body || "Local private maintainer workflow.",
+ relativePath: workflowPath,
+ steps: [
+ {
+ id: "review-pr",
+ title: reviewSkillDoc.title,
+ kind: "skillSet",
+ blocking: true,
+ action: encodePrReviewLocalCommandAction({
+ kind: "localCommand",
+ cwd: input.profile.maintainersRepo,
+ args: ["scripts/pr-review", "{{prNumber}}"],
+ label: "review-pr",
+ }),
+ skillSet: "review-pr",
+ requiresConfirmation: true,
+ successMessage: "Review artifacts refreshed.",
+ failureMessage: "The review workflow did not complete successfully.",
+ description: reviewSkillDoc.description,
+ },
+ {
+ id: "prepare-pr",
+ title: prepareSkillDoc.title,
+ kind: "skillSet",
+ blocking: true,
+ action: encodePrReviewLocalCommandAction({
+ kind: "localCommand",
+ cwd: input.profile.maintainersRepo,
+ args: ["scripts/pr-prepare", "run", "{{prNumber}}"],
+ label: "prepare-pr",
+ }),
+ skillSet: "prepare-pr",
+ requiresConfirmation: true,
+ successMessage: "Preparation completed and push safety checks passed.",
+ failureMessage: "Preparation failed or left the PR not ready to merge.",
+ description: prepareSkillDoc.description,
+ },
+ {
+ id: "merge-pr",
+ title: mergeSkillDoc.title,
+ kind: "skillSet",
+ blocking: true,
+ action: encodePrReviewLocalCommandAction({
+ kind: "localCommand",
+ cwd: input.profile.maintainersRepo,
+ args: ["scripts/pr-merge", "run", "{{prNumber}}"],
+ label: "merge-pr",
+ }),
+ skillSet: "merge-pr",
+ requiresConfirmation: true,
+ successMessage: "Merge workflow completed.",
+ failureMessage: "Merge verification failed or GitHub rejected the merge.",
+ description: mergeSkillDoc.description,
+ },
+ ],
+ };
+
+ return {
+ source: "localProfile",
+ rules: {
+ version: "1",
+ title: input.profile.title,
+ mergePolicy: "maintainer-script-first",
+ conflictPolicy: "workflow-verification-before-merge",
+ requiredChecks: [],
+ requiredApprovals: 0,
+ blockingRules: [
+ {
+ id: "phase-order",
+ title: "Run review, prepare, and merge in order",
+ description: "The local maintainer flow expects review-pr, prepare-pr, then merge-pr.",
+ },
+ {
+ id: "artifact-handoff",
+ title: "Artifacts are mandatory",
+ description:
+ "review-pr and prepare-pr must generate the structured handoff artifacts before merge.",
+ },
+ ],
+ advisoryRules: [
+ {
+ id: "human-judgment",
+ title: "Maintainers provide judgment",
+ description:
+ "Use the local workflow to gather truth, but pause at each phase boundary for judgment.",
+ },
+ ],
+ mentionGroups: [],
+ body:
+ workflowDoc.body.length > 0
+ ? workflowDoc.body
+ : "Private maintainer workflow loaded from a local OK Code profile.",
+ relativePath: workflowPath,
+ defaultWorkflow: workflowId,
+ },
+ workflows: [workflow],
+ skillSets,
+ defaultWorkflowId: workflowId,
+ issues: input.issues,
+ } satisfies PrReviewConfig;
+}
+
+async function loadLocalProfileConfig(cwd: string): Promise<{
+ config: PrReviewConfig | null;
+ issues: PrReviewConfigIssue[];
+}> {
+ const repositoryNameWithOwner = await determineGitHubRepositoryNameWithOwner(cwd);
+ if (!repositoryNameWithOwner) {
+ return { config: null, issues: [] };
+ }
+
+ const normalizedRepository = normalizeRepositoryMatcher(repositoryNameWithOwner);
+ const profileDir = path.join(resolveOkcodeHome(), LOCAL_PROFILE_RELATIVE_DIR);
+ const profilePaths = await listMarkdownFilesAbsolute(profileDir);
+ if (profilePaths.length === 0) {
+ return { config: null, issues: [] };
+ }
+
+ const issues: PrReviewConfigIssue[] = [];
+ for (const profilePath of profilePaths) {
+ const profileFile = await readMarkdownFileAbsolute(profilePath);
+ if (!profileFile.exists || !profileFile.raw) {
+ continue;
+ }
+ const parsed = parseLocalProfileDocument({
+ absolutePath: profilePath,
+ raw: profileFile.raw,
+ });
+ issues.push(...parsed.issues);
+ if (!parsed.profile) {
+ continue;
+ }
+ if (!parsed.profile.repositories.includes(normalizedRepository)) {
+ continue;
+ }
+ if (parsed.profile.adapter === "openclawMaintainer") {
+ return {
+ config: await buildOpenClawMaintainerConfig({
+ profile: parsed.profile,
+ issues: [...issues],
+ }),
+ issues,
+ };
+ }
+ }
+
+ return { config: null, issues };
+}
+
type CacheEntry = {
config: PrReviewConfig;
stale: boolean;
@@ -453,7 +906,21 @@ const makeRepoReviewConfig = Effect.sync(() => {
string,
Set<(payload: PrReviewRepoConfigUpdatedPayload) => void>
>();
- const watcherByCwd = new Map();
+ const watcherByCwd = new Map();
+
+ const emitChange = (cwd: string, relativePaths: string[]) => {
+ const cached = cache.get(cwd);
+ if (cached) {
+ cached.stale = true;
+ }
+ const payload: PrReviewRepoConfigUpdatedPayload = {
+ cwd,
+ relativePaths,
+ };
+ for (const listener of listenersByCwd.get(cwd) ?? []) {
+ listener(payload);
+ }
+ };
const loadConfig = async (cwd: string): Promise => {
const issues: PrReviewConfigIssue[] = [];
@@ -511,21 +978,35 @@ const makeRepoReviewConfig = Effect.sync(() => {
issues.push(...parsed.issues);
}
- const defaultWorkflowId = workflows.some(
- (workflow) => workflow.id === parsedRules.rules.defaultWorkflow,
- )
- ? parsedRules.rules.defaultWorkflow
- : (workflows[0]?.id ?? DEFAULT_WORKFLOW.id);
+ if (rulesSource.exists || workflowPaths.length > 0 || skillSetPaths.length > 0) {
+ const defaultWorkflowId = workflows.some(
+ (workflow) => workflow.id === parsedRules.rules.defaultWorkflow,
+ )
+ ? parsedRules.rules.defaultWorkflow
+ : (workflows[0]?.id ?? DEFAULT_WORKFLOW.id);
+
+ return {
+ source: "repo",
+ rules: parsedRules.rules,
+ workflows,
+ skillSets,
+ defaultWorkflowId,
+ issues,
+ };
+ }
+
+ const localProfileResult = await loadLocalProfileConfig(cwd);
+ if (localProfileResult.config) {
+ return localProfileResult.config;
+ }
+ issues.push(...localProfileResult.issues);
return {
- source:
- rulesSource.exists || workflowPaths.length > 0 || skillSetPaths.length > 0
- ? "repo"
- : "default",
+ source: "default",
rules: parsedRules.rules,
workflows,
skillSets,
- defaultWorkflowId,
+ defaultWorkflowId: workflows[0]?.id ?? DEFAULT_WORKFLOW.id,
issues,
};
};
@@ -560,24 +1041,35 @@ const makeRepoReviewConfig = Effect.sync(() => {
listeners.add(onChange);
if (!watcherByCwd.has(cwd)) {
- const watcher = fs.watch(cwd, { recursive: true }, (_eventType, filename) => {
- const normalized = String(filename ?? "").replaceAll("\\", "/");
- if (normalized.length === 0 || !normalized.startsWith(".okcode")) {
- return;
- }
- const cached = cache.get(cwd);
- if (cached) {
- cached.stale = true;
- }
- const payload: PrReviewRepoConfigUpdatedPayload = {
- cwd,
- relativePaths: [normalized],
- };
- for (const listener of listenersByCwd.get(cwd) ?? []) {
- listener(payload);
- }
- });
- watcherByCwd.set(cwd, watcher);
+ const watchers: fs.FSWatcher[] = [];
+ watchers.push(
+ fs.watch(cwd, { recursive: true }, (_eventType, filename) => {
+ const normalized = String(filename ?? "").replaceAll("\\", "/");
+ if (normalized.length === 0 || !normalized.startsWith(".okcode")) {
+ return;
+ }
+ emitChange(cwd, [normalized]);
+ }),
+ );
+
+ const okcodeHome = resolveOkcodeHome();
+ if (fs.existsSync(okcodeHome)) {
+ watchers.push(
+ fs.watch(okcodeHome, { recursive: true }, (_eventType, filename) => {
+ const normalized = String(filename ?? "").replaceAll("\\", "/");
+ if (
+ normalized.length === 0 ||
+ (!normalized.startsWith(`${LOCAL_PROFILE_RELATIVE_DIR}/`) &&
+ normalized !== LOCAL_PROFILE_RELATIVE_DIR)
+ ) {
+ return;
+ }
+ emitChange(cwd, [normalized]);
+ }),
+ );
+ }
+
+ watcherByCwd.set(cwd, watchers);
}
},
catch: (cause) =>
diff --git a/apps/server/src/prReview/localProfiles.test.ts b/apps/server/src/prReview/localProfiles.test.ts
new file mode 100644
index 000000000..265505e26
--- /dev/null
+++ b/apps/server/src/prReview/localProfiles.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ decodePrReviewLocalCommandAction,
+ encodePrReviewLocalCommandAction,
+ parseGitHubRepositoryNameWithOwnerFromRemoteUrl,
+} from "./localProfiles";
+
+describe("prReview local profiles", () => {
+ it("parses GitHub HTTPS and SSH remotes", () => {
+ expect(
+ parseGitHubRepositoryNameWithOwnerFromRemoteUrl("https://github.com/openclaw/openclaw.git"),
+ ).toBe("openclaw/openclaw");
+ expect(
+ parseGitHubRepositoryNameWithOwnerFromRemoteUrl("git@github.com:OpenClaw/maintainers.git"),
+ ).toBe("OpenClaw/maintainers");
+ });
+
+ it("round-trips encoded local command actions", () => {
+ const encoded = encodePrReviewLocalCommandAction({
+ kind: "localCommand",
+ cwd: "/Users/val/Documents/GitHub/OpenClaw/maintainers",
+ args: ["scripts/pr-review", "{{prNumber}}"],
+ label: "review-pr",
+ });
+
+ expect(decodePrReviewLocalCommandAction(encoded)).toEqual({
+ kind: "localCommand",
+ cwd: "/Users/val/Documents/GitHub/OpenClaw/maintainers",
+ args: ["scripts/pr-review", "{{prNumber}}"],
+ label: "review-pr",
+ });
+ });
+
+ it("rejects malformed encoded local command actions", () => {
+ expect(decodePrReviewLocalCommandAction("okcode:local-command:not-base64")).toBeNull();
+ expect(decodePrReviewLocalCommandAction("scripts/pr-review 123")).toBeNull();
+ });
+});
diff --git a/apps/server/src/prReview/localProfiles.ts b/apps/server/src/prReview/localProfiles.ts
new file mode 100644
index 000000000..0864cbe7b
--- /dev/null
+++ b/apps/server/src/prReview/localProfiles.ts
@@ -0,0 +1,64 @@
+export interface PrReviewLocalCommandAction {
+ readonly kind: "localCommand";
+ readonly cwd: string;
+ readonly args: readonly string[];
+ readonly label: string;
+}
+
+const LOCAL_COMMAND_PREFIX = "okcode:local-command:";
+
+export function encodePrReviewLocalCommandAction(input: PrReviewLocalCommandAction): string {
+ return `${LOCAL_COMMAND_PREFIX}${Buffer.from(JSON.stringify(input), "utf8").toString("base64url")}`;
+}
+
+export function decodePrReviewLocalCommandAction(
+ value: string | null | undefined,
+): PrReviewLocalCommandAction | null {
+ const trimmed = value?.trim() ?? "";
+ if (!trimmed.startsWith(LOCAL_COMMAND_PREFIX)) {
+ return null;
+ }
+
+ try {
+ const raw = trimmed.slice(LOCAL_COMMAND_PREFIX.length);
+ const parsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8")) as {
+ kind?: unknown;
+ cwd?: unknown;
+ args?: unknown;
+ label?: unknown;
+ };
+ if (
+ parsed.kind !== "localCommand" ||
+ typeof parsed.cwd !== "string" ||
+ parsed.cwd.trim().length === 0 ||
+ !Array.isArray(parsed.args) ||
+ parsed.args.some((entry) => typeof entry !== "string") ||
+ typeof parsed.label !== "string" ||
+ parsed.label.trim().length === 0
+ ) {
+ return null;
+ }
+ return {
+ kind: "localCommand",
+ cwd: parsed.cwd.trim(),
+ args: parsed.args.map((entry) => entry.trim()).filter((entry) => entry.length > 0),
+ label: parsed.label.trim(),
+ };
+ } catch {
+ return null;
+ }
+}
+
+export function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null {
+ const trimmed = url?.trim() ?? "";
+ if (trimmed.length === 0) {
+ return null;
+ }
+
+ const match =
+ /^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https:\/\/github\.com\/|git:\/\/github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec(
+ trimmed,
+ );
+ const repositoryNameWithOwner = match?.[1]?.trim() ?? "";
+ return repositoryNameWithOwner.length > 0 ? repositoryNameWithOwner : null;
+}
diff --git a/apps/web/src/components/pr-review/PrReviewShell.tsx b/apps/web/src/components/pr-review/PrReviewShell.tsx
index a144453de..9bea1b0c2 100644
--- a/apps/web/src/components/pr-review/PrReviewShell.tsx
+++ b/apps/web/src/components/pr-review/PrReviewShell.tsx
@@ -52,6 +52,13 @@ import {
const BOOL_SCHEMA = Schema.Boolean;
+function resolvePrReviewConfigPath(projectCwd: string, configPath: string): string {
+ if (/^(?:[A-Za-z]:[\\/]|\/)/.test(configPath)) {
+ return configPath;
+ }
+ return joinPath(projectCwd, configPath);
+}
+
export function PrReviewShell({
project,
projects,
@@ -359,10 +366,12 @@ export function PrReviewShell({
onOpenConflictDrawer: () => setConflictDrawerOpen(true),
onOpenRules: () => {
if (!configQuery.data) return;
- void openPathInEditor(joinPath(project.cwd, configQuery.data.rules.relativePath));
+ void openPathInEditor(
+ resolvePrReviewConfigPath(project.cwd, configQuery.data.rules.relativePath),
+ );
},
onOpenWorkflow: (relativePath: string) => {
- void openPathInEditor(joinPath(project.cwd, relativePath));
+ void openPathInEditor(resolvePrReviewConfigPath(project.cwd, relativePath));
},
onReplyToThread: async (threadId: string, body: string) => {
await replyToThreadMutation.mutateAsync({ threadId, body });
diff --git a/apps/web/src/components/pr-review/PrWorkflowPanel.tsx b/apps/web/src/components/pr-review/PrWorkflowPanel.tsx
index df7e88e41..2fc82ec5c 100644
--- a/apps/web/src/components/pr-review/PrWorkflowPanel.tsx
+++ b/apps/web/src/components/pr-review/PrWorkflowPanel.tsx
@@ -51,7 +51,11 @@ export function PrWorkflowPanel({
Repo workflow
- {config?.source === "default" ? "Using default repo workflow" : "Loaded from .okcode"}
+ {config?.source === "default"
+ ? "Using default repo workflow"
+ : config?.source === "localProfile"
+ ? "Loaded from local maintainer profile"
+ : "Loaded from .okcode"}