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
90 changes: 90 additions & 0 deletions apps/gittensory-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -8349,6 +8349,9 @@
"publicSafeSummary": {
"type": "string"
},
"explanationCard": {
"$ref": "#/components/schemas/AgentActionExplanationCard"
},
"approvalRequired": {
"type": "boolean"
},
Expand Down Expand Up @@ -8380,11 +8383,98 @@
"why",
"blockedBy",
"publicSafeSummary",
"explanationCard",
"approvalRequired",
"safetyClass",
"payload"
]
},
"AgentActionExplanationCard": {
"type": "object",
"properties": {
"summary": {
"type": "string"
},
"whyNow": {
"type": "string"
},
"scoreabilityBlocker": {
"type": "string"
},
"risk": {
"type": "string"
},
"maintainerFriction": {
"type": "string"
},
"expectedImpact": {
"type": "string"
},
"blockerGroups": {
"type": "array",
"items": {
"type": "object",
"properties": {
"category": {
"type": "string",
"enum": [
"branch",
"account",
"queue",
"scoreability",
"risk",
"maintainer",
"unknown"
]
},
"items": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"category",
"items"
]
}
},
"rerunWhen": {
"type": "string"
},
"publicSafe": {
"type": "object",
"properties": {
"summary": {
"type": "string"
},
"whyNow": {
"type": "string"
},
"rerunWhen": {
"type": "string"
}
},
"required": [
"summary",
"whyNow",
"rerunWhen"
]
}
},
"required": [
"summary",
"whyNow",
"scoreabilityBlocker",
"risk",
"maintainerFriction",
"expectedImpact",
"blockerGroups",
"rerunWhen",
"publicSafe"
]
},
"AgentContextSnapshot": {
"type": "object",
"properties": {
Expand Down
8 changes: 7 additions & 1 deletion packages/gittensory-mcp/bin/gittensory-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -648,7 +648,13 @@ function outputAgentPayload(payload, options, summary) {
const label = action.actionType ?? action.actionKind ?? action.recommendation ?? "action";
const detail = action.recommendation ?? action.actionKind ?? action.summary ?? label;
process.stdout.write(`- ${label}: ${detail}\n`);
if (action.rerunWhen) process.stdout.write(` rerun: ${action.rerunWhen}\n`);
if (action.explanationCard) {
process.stdout.write(` why now: ${action.explanationCard.whyNow}\n`);
process.stdout.write(` impact: ${action.explanationCard.expectedImpact}\n`);
process.stdout.write(` rerun: ${action.explanationCard.rerunWhen}\n`);
} else if (action.rerunWhen) {
process.stdout.write(` rerun: ${action.rerunWhen}\n`);
}
}
}

Expand Down
24 changes: 24 additions & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1957,6 +1957,29 @@ export const RegistryChangeReportSchema = z
})
.openapi("RegistryChangeReport");

export const AgentActionExplanationCardSchema = z
.object({
summary: z.string(),
whyNow: z.string(),
scoreabilityBlocker: z.string(),
risk: z.string(),
maintainerFriction: z.string(),
expectedImpact: z.string(),
blockerGroups: z.array(
z.object({
category: z.enum(["branch", "account", "queue", "scoreability", "risk", "maintainer", "unknown"]),
items: z.array(z.string()),
}),
),
rerunWhen: z.string(),
publicSafe: z.object({
summary: z.string(),
whyNow: z.string(),
rerunWhen: z.string(),
}),
})
.openapi("AgentActionExplanationCard");

export const AgentActionSchema = z
.object({
id: z.string(),
Expand All @@ -1983,6 +2006,7 @@ export const AgentActionSchema = z
blockedBy: z.array(z.string()),
rerunWhen: z.string().nullable().optional(),
publicSafeSummary: z.string(),
explanationCard: AgentActionExplanationCardSchema,
approvalRequired: z.boolean(),
safetyClass: z.enum(["private", "public_safe", "approval_required"]),
payload: z.record(z.string(), z.unknown()),
Expand Down
129 changes: 129 additions & 0 deletions src/services/agent-action-explanation-card.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import type { AgentActionBlockerCategory, AgentActionExplanationCard, AgentActionRecord } from "../types";

type AgentActionExplanationInput = Pick<
AgentActionRecord,
"actionType" | "status" | "why" | "scoreabilityImpact" | "riskImpact" | "maintainerImpact" | "blockedBy" | "rerunWhen" | "publicSafeSummary" | "safetyClass"
>;

const BLOCKER_CATEGORY_ORDER: AgentActionBlockerCategory[] = ["branch", "account", "queue", "scoreability", "risk", "maintainer", "unknown"];
const PUBLIC_FORBIDDEN_PATTERN =
/\b(wallets?|hotkeys?|coldkeys?|seed phrases?|mnemonics?|raw[-_\s]?trust scores?|trust scores?|private reviewability|reviewability internals?|private scoreability|scoreability|public score estimates?|estimated scores?|score estimates?|score previews?|reward estimates?|payouts?|farming|reward optimization|private rankings?)\b/gi;
const TOKEN_OR_PATH_PATTERN = /\bgithub_pat_[A-Za-z0-9_]+|\bgh[pousr]_[A-Za-z0-9_]+|\/Users\/\S+|\/home\/\S+|\/tmp\/\S+|[A-Z]:\\Users\\\S+/gi;

export function withAgentActionExplanationCard(action: AgentActionRecord): AgentActionRecord {
return { ...action, explanationCard: buildAgentActionExplanationCard(action) };
}

export function buildAgentActionExplanationCard(action: AgentActionExplanationInput): AgentActionExplanationCard {
const whyNow = compactText(whyNowForAction(action));
const rerunWhen = compactText(action.rerunWhen ?? "Rerun when the referenced repo, branch, queue, or validation signal changes.");
return {
summary: compactText(summaryForAction(action)),
whyNow,
scoreabilityBlocker: compactText(scoreabilityBlockerForAction(action)),
risk: compactText(action.riskImpact ?? riskForAction(action)),
maintainerFriction: compactText(action.maintainerImpact ?? maintainerFrictionForAction(action)),
expectedImpact: compactText(expectedImpactForAction(action)),
blockerGroups: groupBlockers(action.blockedBy),
rerunWhen,
publicSafe: {
summary: sanitizePublicCardText(action.publicSafeSummary || summaryForAction(action)),
whyNow: sanitizePublicCardText(publicWhyNowForAction(action, whyNow)),
rerunWhen: sanitizePublicCardText(rerunWhen),
},
};
}

function summaryForAction(action: AgentActionExplanationInput): string {
if (action.actionType === "cleanup_existing_prs") return "Cleanup first: reduce existing PR pressure before starting new work.";
if (action.actionType === "monitor_existing_pr") return "Wait on existing work: land, update, or close the current PR before adding more.";
if (action.actionType === "preflight_branch") return "Preflight the branch: fix branch-level readiness before posting maintainer-facing context.";
if (action.actionType === "explain_score_blockers") return "Resolve blockers: separate private scoreability context from public PR copy.";
if (action.actionType === "prepare_pr_packet") return "Prepare public-safe PR text: turn private analysis into maintainer-friendly evidence.";
if (action.actionType === "explain_repo_fit") return action.status === "watch" ? "Watch this repo: current signals argue against acting now." : "Explain repo fit: verify this lane before choosing work.";
if (action.status === "watch") return "Avoid for now: wait for better repo, queue, or account signals.";
return "Pursue now: this action is the current ranked next step.";
}

function whyNowForAction(action: AgentActionExplanationInput): string {
if (action.actionType === "cleanup_existing_prs") return "Open PR pressure is the most actionable signal before new submissions.";
if (action.actionType === "monitor_existing_pr") return "Existing work can affect scoreability and maintainer load before another action is useful.";
if (action.actionType === "preflight_branch") return "Branch findings are directly actionable and should be fixed before public PR copy.";
if (action.actionType === "explain_score_blockers") return "Blockers are gating the next useful step, so they should be handled before new work.";
if (action.actionType === "prepare_pr_packet") return "A concise packet keeps public context focused on linked work, validation, and next steps.";
if (action.status === "watch") return "The safer action is to wait until the blockers or queue signals improve.";
return action.why.find((line) => line.trim().length > 0) ?? "Current deterministic planning signals rank this action ahead of other available next steps.";
}

function publicWhyNowForAction(action: AgentActionExplanationInput, whyNow: string): string {
if (action.safetyClass === "public_safe") return whyNow;
return action.publicSafeSummary || "Use the private card for planning and keep public output focused on review hygiene.";
}

function scoreabilityBlockerForAction(action: AgentActionExplanationInput): string {
if (action.scoreabilityImpact) return action.scoreabilityImpact;
const blockers = action.blockedBy.filter((blocker) => categorizeBlocker(blocker) === "scoreability");
if (blockers.length > 0) return `Scoreability blockers: ${blockers.join(", ")}.`;
if (action.status === "blocked") return "Blocked action; inspect the grouped blockers before proceeding.";
return "No hard scoreability blocker is visible in current signals.";
}

function riskForAction(action: AgentActionExplanationInput): string {
if (action.status === "watch") return "Acting now may add review load or collide with stronger repo signals.";
if (action.actionType === "cleanup_existing_prs") return "Leaving existing PRs unresolved can increase stale or duplicate review pressure.";
if (action.actionType === "prepare_pr_packet") return "Public copy should avoid private planning, scoring, or identity context.";
return "No major action-specific risk is visible in the current card.";
}

function maintainerFrictionForAction(action: AgentActionExplanationInput): string {
if (action.actionType === "cleanup_existing_prs") return "Cleanup reduces queue noise before asking maintainers to review new work.";
if (action.actionType === "preflight_branch" || action.actionType === "prepare_pr_packet") return "Focused branch evidence makes review faster and less ambiguous.";
if (action.status === "watch") return "Waiting avoids adding low-confidence work to the maintainer queue.";
return "Narrow, validated work is easier for maintainers to review.";
}

function expectedImpactForAction(action: AgentActionExplanationInput): string {
if (action.actionType === "cleanup_existing_prs") return "Lower active review pressure and make future work easier to score and review.";
if (action.actionType === "monitor_existing_pr") return "Convert current open work into a clearer merged, closed, or updated state.";
if (action.actionType === "preflight_branch") return "Move branch metadata toward a ready public-safe PR packet.";
if (action.actionType === "explain_score_blockers") return "Turn blocker details into a concrete cleanup or wait condition.";
if (action.actionType === "prepare_pr_packet") return "Produce maintainer-facing copy that excludes private planning context.";
if (action.status === "watch") return "Avoid low-confidence effort until rerun conditions improve.";
return "Advance toward one narrow, validated contribution path.";
}

function groupBlockers(blockers: string[]): AgentActionExplanationCard["blockerGroups"] {
const grouped = new Map<AgentActionBlockerCategory, string[]>();
for (const blocker of blockers.map((value) => compactText(value)).filter(Boolean)) {
const category = categorizeBlocker(blocker);
grouped.set(category, [...(grouped.get(category) ?? []), blocker]);
}
return BLOCKER_CATEGORY_ORDER.flatMap((category) => {
const items = [...new Set(grouped.get(category) ?? [])].slice(0, 6);
return items.length > 0 ? [{ category, items }] : [];
});
}

function categorizeBlocker(blocker: string): AgentActionBlockerCategory {
const value = blocker.toLowerCase();
if (/branch|preflight|linked|issue|validation|test|draft|diff|file|metadata|eligib/.test(value)) return "branch";
if (/account|credibility|contributor|official|miner|profile|role|author/.test(value)) return "account";
if (/queue|open[_\s-]?pr|review|duplicate|collision|stale|approved|pending|merge|close/.test(value)) return "queue";
if (/score|scoreability|inactive[_\s-]?allocation|allocation|gate|blocker/.test(value)) return "scoreability";
if (/risk|reward|payout|farming|uncertain/.test(value)) return "risk";
if (/maintainer|friction|intake|label|policy/.test(value)) return "maintainer";
return "unknown";
}

function sanitizePublicCardText(value: string): string {
return compactText(value)
.replace(TOKEN_OR_PATH_PATTERN, "<redacted>")
.replace(PUBLIC_FORBIDDEN_PATTERN, "private context")
.replace(/private context(?:[,\s]+private context)+/gi, "private context")
.replace(/\s+([,.])/g, "$1")
.trim();
}

function compactText(value: string): string {
return value.replace(/\s+/g, " ").trim().slice(0, 300);
}
6 changes: 4 additions & 2 deletions src/services/agent-orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { buildContributorFit, buildContributorOutcomeHistory, buildContributorPr
import { buildContributorOpenPrMonitor, type ContributorOpenPrMonitor } from "../signals/contributor-open-pr-monitor";
import { buildLocalBranchAnalysis, findCurrentBranchPullRequest, type LocalBranchAnalysis, type LocalBranchAnalysisInput } from "../signals/local-branch";
import { loadRepoFocusManifest } from "../signals/focus-manifest-loader";
import { withAgentActionExplanationCard } from "./agent-action-explanation-card";
import type {
AgentActionRecord,
AgentActionStatus,
Expand Down Expand Up @@ -125,7 +126,7 @@ export async function getAgentRunBundle(env: Env, runId: string): Promise<AgentR
const [actions, contextSnapshots] = await Promise.all([listAgentActions(env, runId), listAgentContextSnapshots(env, runId)]);
return {
run,
actions,
actions: actions.map(withAgentActionExplanationCard),
contextSnapshots,
summary: summarizeRun(run, actions),
};
Expand Down Expand Up @@ -596,7 +597,7 @@ function actionRecord(args: {
evidence?: RecommendationEvidence | undefined;
}): AgentActionRecord {
const evidence = args.evidence ?? defaultRecommendationEvidence(args.actionType);
return {
const action: AgentActionRecord = {
id: `${args.run.id}:${String(args.index).padStart(2, "0")}:${args.actionType}`,
runId: args.run.id,
actionType: args.actionType,
Expand All @@ -620,6 +621,7 @@ function actionRecord(args: {
},
createdAt: nowIso(),
};
return withAgentActionExplanationCard(action);
}

function decisionPackEvidence(pack: ContributorDecisionPack, decision: RepoDecision, sourceSummary: string): RecommendationEvidence {
Expand Down
21 changes: 21 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,26 @@ export type AgentActionType =
| "explain_repo_fit";
export type AgentActionStatus = "recommended" | "ready" | "blocked" | "watch" | "needs_input";
export type AgentSafetyClass = "private" | "public_safe" | "approval_required";
export type AgentActionBlockerCategory = "branch" | "account" | "queue" | "scoreability" | "risk" | "maintainer" | "unknown";

export type AgentActionExplanationCard = {
summary: string;
whyNow: string;
scoreabilityBlocker: string;
risk: string;
maintainerFriction: string;
expectedImpact: string;
blockerGroups: Array<{
category: AgentActionBlockerCategory;
items: string[];
}>;
rerunWhen: string;
publicSafe: {
summary: string;
whyNow: string;
rerunWhen: string;
};
};

export type AgentRunRecord = {
id: string;
Expand Down Expand Up @@ -646,6 +666,7 @@ export type AgentActionRecord = {
blockedBy: string[];
rerunWhen?: string | null | undefined;
publicSafeSummary: string;
explanationCard?: AgentActionExplanationCard | undefined;
approvalRequired: boolean;
safetyClass: AgentSafetyClass;
payload: Record<string, JsonValue>;
Expand Down
8 changes: 7 additions & 1 deletion test/integration/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -648,7 +648,7 @@ describe("api routes", () => {
expect(agentPlan.status).toBe(200);
const agentPlanPayload = (await agentPlan.json()) as {
run: { id: string; status: string; mode: string; surface: string; payload: Record<string, unknown> };
actions: Array<{ actionType: string; publicSafeSummary: string; payload: Record<string, unknown> }>;
actions: Array<{ actionType: string; publicSafeSummary: string; explanationCard?: { whyNow: string; rerunWhen: string; publicSafe: Record<string, string> }; payload: Record<string, unknown> }>;
contextSnapshots: Array<{ payload: Record<string, unknown> }>;
};
expect(agentPlanPayload.run).toMatchObject({ status: "completed", mode: "copilot", surface: "api" });
Expand All @@ -661,6 +661,12 @@ describe("api routes", () => {
});
expect(agentPlanPayload.actions.length).toBeGreaterThan(0);
expect(agentPlanPayload.actions[0]?.publicSafeSummary).not.toMatch(/wallet|hotkey|reward estimate|payout|farming|raw trust score/i);
expect(agentPlanPayload.actions[0]?.explanationCard).toMatchObject({
whyNow: expect.any(String),
rerunWhen: expect.any(String),
publicSafe: expect.any(Object),
});
expect(JSON.stringify(agentPlanPayload.actions[0]?.explanationCard?.publicSafe)).not.toMatch(/wallet|hotkey|reward estimate|payout|farming|raw trust score|private reviewability|public score estimate|scoreability/i);
expect(agentPlanPayload.actions[0]?.payload).toHaveProperty("decision");
expect(agentPlanPayload.actions[0]?.payload.recommendationEvidence).toMatchObject({
confidence: expect.stringMatching(/^(high|medium|low)$/),
Expand Down
Loading