Merge pull request #89 from pikasTech/code-queue/issue-20-terminal-unread-triage

Add codex unread triage and commander PR closeout boundary
This commit is contained in:
Lyon
2026-05-23 16:22:59 +08:00
committed by GitHub
13 changed files with 667 additions and 24 deletions
+405 -5
View File
@@ -20,6 +20,7 @@ const supervisorRecentCompletedLimit = 3;
const supervisorPromptPreviewChars = 70;
const supervisorBodyPreviewChars = 70;
const supervisorRecentBodyPreviewChars = 50;
const unreadTriageCountLimit = 12;
const diagnosticsIdPreviewLimit = 3;
const diagnosticsReasonPreviewLimit = 2;
const mutationQueueIdPreviewLimit = 15;
@@ -269,6 +270,21 @@ interface CodexTasksOptions {
view: "supervisor" | "full";
}
type CodexUnreadAction = "summary" | "mark-read";
interface CodexUnreadOptions {
queueId: string | undefined;
requestedLimit: number;
limit: number;
beforeId: string | undefined;
statusFilter: string[] | null;
repoFilter: string | undefined;
issueFilter: string | undefined;
action: CodexUnreadAction;
confirm: boolean;
dryRun: boolean;
}
interface CodexTasksEntry {
taskId: string;
queueId: string | null;
@@ -2180,6 +2196,64 @@ function parseTasksOptions(args: string[]): CodexTasksOptions {
};
}
function normalizeRepoFilter(value: string | undefined): string | undefined {
if (value === undefined) return undefined;
const trimmed = value.trim()
.replace(/^https?:\/\/github\.com\//iu, "")
.replace(/\.git$/iu, "")
.replace(/^\/+|\/+$/gu, "");
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(trimmed)) {
throw new Error(`--repo must be owner/name; got ${value}`);
}
return trimmed;
}
function normalizeIssueFilter(value: string | undefined): string | undefined {
if (value === undefined) return undefined;
const trimmed = value.trim().replace(/^#/u, "");
const number = Number(trimmed);
if (!Number.isInteger(number) || number <= 0) throw new Error(`--issue must be a positive issue number; got ${value}`);
return `#${number}`;
}
function parseUnreadOptions(args: string[]): CodexUnreadOptions {
const first = args[0];
const hasSubcommand = first !== undefined && !first.startsWith("--");
const subcommand = hasSubcommand ? first : "summary";
if (subcommand !== "summary" && subcommand !== "list" && subcommand !== "mark-read") {
throw new Error(`codex unread subcommand must be summary, list, or mark-read; got ${subcommand}`);
}
const optionArgs = hasSubcommand ? args.slice(1) : args;
assertKnownOptions(optionArgs, {
flags: ["--mark-read", "--confirm", "--dry-run"],
valueOptions: ["--queue", "--queue-id", "--limit", "--status", "--repo", "--issue", "--before-id", "--beforeId"],
}, "codex unread");
const statusRaw = optionValue(optionArgs, ["--status"]);
const statusFilter = statusRaw === undefined
? null
: statusRaw.split(/[,\s]+/u).map((item) => item.trim()).filter(Boolean);
if (statusFilter !== null) {
const supported = new Set(["succeeded", "failed", "canceled"]);
const unsupported = statusFilter.filter((status) => !supported.has(status));
if (unsupported.length > 0) throw new Error(`unsupported --status value for codex unread: ${unsupported.join(", ")}; supported terminal statuses: ${Array.from(supported).join(", ")}`);
}
const requestedLimit = positiveIntegerOption(optionArgs, ["--limit"], defaultTasksLimit);
const action: CodexUnreadAction = subcommand === "mark-read" || hasFlag(optionArgs, "--mark-read") ? "mark-read" : "summary";
const explicitDryRun = hasFlag(optionArgs, "--dry-run");
return {
queueId: optionValue(optionArgs, ["--queue", "--queue-id"]),
requestedLimit,
limit: Math.min(requestedLimit, maxTasksLimit),
beforeId: optionValue(optionArgs, ["--before-id", "--beforeId"]),
statusFilter,
repoFilter: normalizeRepoFilter(optionValue(optionArgs, ["--repo"])),
issueFilter: normalizeIssueFilter(optionValue(optionArgs, ["--issue"])),
action,
confirm: hasFlag(optionArgs, "--confirm"),
dryRun: explicitDryRun || action === "summary",
};
}
function parseQueuesOptions(args: string[]): CodexQueuesOptions {
assertKnownOptions(args, {
flags: ["--full", "--all"],
@@ -2983,6 +3057,290 @@ function loadCodexTasks(taskArgs: CodexTasksOptions, fetcher: CodexResponseFetch
return { upstream: response.upstream, page: { queue: asRecord(response.body.queue), pagination: asRecord(response.body.pagination) ?? {}, tasks } };
}
function unreadLoadOptions(options: CodexUnreadOptions): CodexTasksOptions {
return {
queueId: options.queueId,
requestedLimit: maxTasksLimit,
limit: maxTasksLimit,
beforeId: undefined,
unreadOnly: true,
statusFilter: null,
view: "full",
};
}
function unreadTriageCommand(options: CodexUnreadOptions, action: CodexUnreadAction = "summary", extra: string[] = []): string {
const args = ["codex", "unread"];
if (action === "mark-read") args.push("mark-read");
if (options.queueId !== undefined) args.push("--queue", options.queueId);
if (options.repoFilter !== undefined) args.push("--repo", options.repoFilter);
if (options.issueFilter !== undefined) args.push("--issue", options.issueFilter.replace(/^#/u, ""));
if (options.statusFilter !== null) args.push("--status", options.statusFilter.join(","));
if (options.requestedLimit !== defaultTasksLimit) args.push("--limit", String(options.requestedLimit));
if (options.beforeId !== undefined) args.push("--before-id", options.beforeId);
args.push(...extra);
return `bun scripts/cli.ts ${args.join(" ")}`;
}
function taskTriageSearchText(task: Record<string, unknown>): string {
return [
asString(task.displayPrompt),
asString(task.basePrompt),
asString(task.prompt),
asString(task.cwd),
asString(task.lastError),
...stringList(task.referenceTaskIds),
].join("\n");
}
function taskRepoRefs(task: Record<string, unknown>): string[] {
const text = taskTriageSearchText(task);
const repos = new Set<string>();
for (const match of text.matchAll(/\b([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#\d{1,6}\b/gu)) {
const repo = match[1] ?? "";
if (repo.length > 0) repos.add(repo);
}
for (const match of text.matchAll(/\bgithub\.com[:/]([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?(?:[#/\s?]|$)/giu)) {
const owner = match[1] ?? "";
const name = (match[2] ?? "").replace(/\.git$/iu, "");
if (owner.length > 0 && name.length > 0) repos.add(`${owner}/${name}`);
}
return Array.from(repos).sort((left, right) => left.localeCompare(right));
}
function taskIssueRefsForTriage(task: Record<string, unknown>): string[] {
const text = taskTriageSearchText(task);
const issues = new Set<string>();
for (const match of text.matchAll(/#(\d{1,6})\b/gu)) issues.add(`#${match[1]}`);
for (const match of text.matchAll(/\/issues\/(\d{1,6})\b/gu)) issues.add(`#${match[1]}`);
return Array.from(issues)
.sort((left, right) => Number(left.slice(1)) - Number(right.slice(1)));
}
function lowerSet(values: string[]): Set<string> {
return new Set(values.map((value) => value.toLowerCase()));
}
function taskMatchesUnreadFilters(task: Record<string, unknown>, options: CodexUnreadOptions): boolean {
if (!taskUnreadTerminal(task)) return false;
if (!taskMatchesStatusFilter(task, options.statusFilter)) return false;
if (options.queueId !== undefined && asString(task.queueId) !== options.queueId) return false;
if (options.repoFilter !== undefined && !lowerSet(taskRepoRefs(task)).has(options.repoFilter.toLowerCase())) return false;
if (options.issueFilter !== undefined && !taskIssueRefsForTriage(task).includes(options.issueFilter)) return false;
return true;
}
function unreadSortedCandidates(tasks: Record<string, unknown>[], options: CodexUnreadOptions): Record<string, unknown>[] {
return sortCompletedWatchTasks(tasks.filter((task) => taskMatchesUnreadFilters(task, options)));
}
function unreadPageCandidates(candidates: Record<string, unknown>[], options: CodexUnreadOptions): Record<string, unknown>[] {
if (options.beforeId === undefined) return candidates;
const beforeIndex = candidates.findIndex((task) => taskOverviewCandidateKey(task) === options.beforeId);
return beforeIndex < 0 ? candidates : candidates.slice(beforeIndex + 1);
}
function incrementCount(map: Map<string, number>, key: string): void {
map.set(key, (map.get(key) ?? 0) + 1);
}
function countBucket(map: Map<string, number>, limit = unreadTriageCountLimit): Record<string, unknown> {
const rows = Array.from(map.entries())
.map(([key, count]) => ({ key, count }))
.sort((left, right) => right.count - left.count || left.key.localeCompare(right.key));
return {
totalDistinct: rows.length,
returned: Math.min(rows.length, limit),
truncated: rows.length > limit,
items: rows.slice(0, limit),
};
}
function unreadTriageCounts(candidates: Record<string, unknown>[]): Record<string, unknown> {
const byRepo = new Map<string, number>();
const byIssue = new Map<string, number>();
const byStatus = new Map<string, number>();
const byQueue = new Map<string, number>();
for (const task of candidates) {
incrementCount(byStatus, asString(task.status) || "unknown");
incrementCount(byQueue, asString(task.queueId) || "unknown");
const repos = taskRepoRefs(task);
const issues = taskIssueRefsForTriage(task);
if (repos.length === 0) incrementCount(byRepo, "unknown");
for (const repo of repos) incrementCount(byRepo, repo);
if (issues.length === 0) incrementCount(byIssue, "unknown");
for (const issue of issues) incrementCount(byIssue, issue);
}
return {
totalUnreadTerminal: candidates.length,
byRepo: countBucket(byRepo),
byIssue: countBucket(byIssue),
byStatus: countBucket(byStatus),
byQueue: countBucket(byQueue),
};
}
function unreadTriageItem(task: Record<string, unknown>): Record<string, unknown> {
const taskId = taskOverviewCandidateKey(task);
return {
id: taskId,
queue: asString(task.queueId) || null,
status: asString(task.status) || null,
repos: taskRepoRefs(task),
issues: taskIssueRefsForTriage(task),
updatedAt: asString(task.updatedAt) || null,
finishedAt: asString(task.finishedAt) || null,
commands: {
show: `bun scripts/cli.ts codex task ${taskId}`,
detail: `bun scripts/cli.ts codex task ${taskId} --detail`,
trace: `bun scripts/cli.ts codex task ${taskId} --trace --tail --limit ${defaultTraceLimit}`,
output: `bun scripts/cli.ts codex output ${taskId} --tail --limit ${defaultOutputLimit}`,
read: `bun scripts/cli.ts codex read ${taskId}`,
},
};
}
function unreadTriageSummary(upstream: { ok: unknown; status: unknown }, page: CodexTasksTaskPage, options: CodexUnreadOptions): {
result: Record<string, unknown>;
visibleCandidates: Record<string, unknown>[];
} {
const allCandidates = unreadSortedCandidates(page.tasks, options);
const pagedCandidates = unreadPageCandidates(allCandidates, options);
const visibleCandidates = pagedCandidates.slice(0, options.limit);
const hasMore = pagedCandidates.length > visibleCandidates.length;
const nextBeforeId = hasMore ? taskOverviewCandidateKey(visibleCandidates.at(-1) ?? {}) || null : null;
const nextCommand = nextBeforeId === null ? null : unreadTriageCommand({ ...options, beforeId: nextBeforeId });
const readCommand = unreadTriageCommand(options, "mark-read", ["--confirm"]);
return {
visibleCandidates,
result: {
ok: true,
upstream,
unreadTriage: {
filters: {
queueId: options.queueId ?? null,
repo: options.repoFilter ?? null,
issue: options.issueFilter ?? null,
status: options.statusFilter,
requestedLimit: options.requestedLimit,
limit: options.limit,
limitCapped: options.requestedLimit > options.limit,
beforeId: options.beforeId ?? null,
},
readOnly: options.action === "summary" || options.dryRun,
bounded: true,
disclosure: {
policy: "summary counts plus bounded task ids only; no raw prompt, final response, trace, or output is included by default",
countBucketLimit: unreadTriageCountLimit,
itemLimit: options.limit,
mutationPolicy: "batch mark-read requires codex unread mark-read --confirm; per-task read remains codex read <taskId>",
},
counts: unreadTriageCounts(allCandidates),
newest: {
count: allCandidates.length,
returned: visibleCandidates.length,
hasMore,
nextBeforeId,
commands: {
next: nextCommand,
showTemplate: "bun scripts/cli.ts codex task <taskId>",
detailTemplate: "bun scripts/cli.ts codex task <taskId> --detail",
traceTemplate: `bun scripts/cli.ts codex task <taskId> --trace --tail --limit ${defaultTraceLimit}`,
outputTemplate: `bun scripts/cli.ts codex output <taskId> --tail --limit ${defaultOutputLimit}`,
readTemplate: "bun scripts/cli.ts codex read <taskId>",
},
items: visibleCandidates.map(unreadTriageItem),
},
commands: {
refresh: unreadTriageCommand(options),
tasksUnread: taskListCommand({
queueId: options.queueId,
requestedLimit: Math.min(options.requestedLimit, defaultTasksLimit),
limit: Math.min(options.limit, defaultTasksLimit),
beforeId: undefined,
unreadOnly: true,
statusFilter: options.statusFilter,
view: "supervisor",
}),
byRepo: "bun scripts/cli.ts codex unread --repo owner/name",
byIssue: "bun scripts/cli.ts codex unread --issue <number>",
byStatus: "bun scripts/cli.ts codex unread --status succeeded|failed|canceled",
byQueue: "bun scripts/cli.ts codex unread --queue <queueId>",
perTaskRead: "bun scripts/cli.ts codex read <taskId>",
batchReadDryRun: unreadTriageCommand(options, "mark-read", ["--dry-run"]),
batchReadConfirm: readCommand,
},
},
},
};
}
function codexUnreadMutationGuard(result: Record<string, unknown>, visibleCandidates: Record<string, unknown>[], options: CodexUnreadOptions): Record<string, unknown> {
const triage = asRecord(result.unreadTriage) ?? {};
return {
...result,
ok: false,
unreadTriage: {
...triage,
readOnly: true,
mutation: {
requested: true,
confirmed: false,
blocked: true,
reason: "batch mark-read requires --confirm; default codex unread output is read-only",
candidateTaskIds: visibleCandidates.map(taskOverviewCandidateKey),
command: unreadTriageCommand(options, "mark-read", ["--confirm"]),
},
},
};
}
function codexUnreadMutationResult(result: Record<string, unknown>, options: CodexUnreadOptions, results: Record<string, unknown>[]): Record<string, unknown> {
const triage = asRecord(result.unreadTriage) ?? {};
const failed = results.filter((item) => item.ok === false);
return {
...result,
ok: failed.length === 0,
unreadTriage: {
...triage,
readOnly: false,
mutation: {
requested: true,
confirmed: true,
action: "mark-read",
limit: options.limit,
attempted: results.length,
succeeded: results.length - failed.length,
failed: failed.length,
results,
commands: {
refresh: unreadTriageCommand({ ...options, action: "summary", confirm: false, dryRun: true }),
tasksUnread: `bun scripts/cli.ts codex unread --limit ${defaultTasksLimit}`,
},
},
},
};
}
function codexUnreadTriage(taskArgs: string[], fetcher: CodexResponseFetcher = coreInternalFetch): unknown {
const options = parseUnreadOptions(taskArgs);
const { upstream, page } = loadCodexTasks(unreadLoadOptions(options), fetcher);
const { result, visibleCandidates } = unreadTriageSummary(upstream, page, options);
if (options.action !== "mark-read" || options.dryRun) return result;
if (!options.confirm) return codexUnreadMutationGuard(result, visibleCandidates, options);
const results: Record<string, unknown>[] = [];
for (const task of visibleCandidates) {
const taskId = taskOverviewCandidateKey(task);
try {
const response = unwrapCodexResponse(fetcher(codeQueueProxyPath(`/api/tasks/${encodeURIComponent(taskId)}/read`), { method: "POST", body: {} }));
results.push({ taskId, ok: true, upstream: response.upstream });
} catch (error) {
results.push({ taskId, ok: false, error: error instanceof Error ? error.message : String(error) });
}
}
return codexUnreadMutationResult(result, options, results);
}
function codexTasksOverviewResult(
taskPage: CodexTasksTaskPage,
upstream: { ok: unknown; status: unknown },
@@ -3163,6 +3521,10 @@ export function codexTasksQueryForTest(taskArgs: string[], fetcher: CodexRespons
return codexTasksQuery(taskArgs, fetcher);
}
export function codexUnreadTriageForTest(taskArgs: string[], fetcher: CodexResponseFetcher): unknown {
return codexUnreadTriage(taskArgs, fetcher);
}
async function codexTasksQueryAsync(taskArgs: string[], fetcher: AsyncCodexResponseFetcher): Promise<unknown> {
const options = parseTasksOptions(taskArgs);
const byId = new Map<string, Record<string, unknown>>();
@@ -3186,6 +3548,41 @@ async function codexTasksQueryAsync(taskArgs: string[], fetcher: AsyncCodexRespo
return codexTasksOverviewResult(page, response.upstream, options, summaries, degraded);
}
async function codexUnreadTriageAsync(taskArgs: string[], fetcher: AsyncCodexResponseFetcher): Promise<unknown> {
const options = parseUnreadOptions(taskArgs);
const loadOptions = unreadLoadOptions(options);
const byId = new Map<string, Record<string, unknown>>();
const response = unwrapCodexResponse(await fetcher(codeQueueProxyPath(`/api/tasks/overview${tasksListQueryString(loadOptions)}`)));
const pageTasks = asArray(response.body.tasks).map((task) => asRecord(task)).filter((task): task is Record<string, unknown> => task !== null);
for (const task of pageTasks) {
const taskId = taskOverviewCandidateKey(task);
if (taskId.length === 0) continue;
const existing = byId.get(taskId);
if (existing === undefined || taskTimelineMs(task) >= taskTimelineMs(existing)) byId.set(taskId, task);
}
const tasks = Array.from(byId.values()).sort((left, right) => {
const leftTime = taskTimelineMs(left);
const rightTime = taskTimelineMs(right);
if (leftTime !== rightTime) return rightTime - leftTime;
return asString(left.id).localeCompare(asString(right.id));
});
const page: CodexTasksTaskPage = { queue: asRecord(response.body.queue), pagination: asRecord(response.body.pagination) ?? {}, tasks };
const { result, visibleCandidates } = unreadTriageSummary(response.upstream, page, options);
if (options.action !== "mark-read" || options.dryRun) return result;
if (!options.confirm) return codexUnreadMutationGuard(result, visibleCandidates, options);
const results: Record<string, unknown>[] = [];
for (const task of visibleCandidates) {
const taskId = taskOverviewCandidateKey(task);
try {
const readResponse = unwrapCodexResponse(await fetcher(codeQueueProxyPath(`/api/tasks/${encodeURIComponent(taskId)}/read`), { method: "POST", body: {} }));
results.push({ taskId, ok: true, upstream: readResponse.upstream });
} catch (error) {
results.push({ taskId, ok: false, error: error instanceof Error ? error.message : String(error) });
}
}
return codexUnreadMutationResult(result, options, results);
}
async function codexTaskSummaryAsync(taskId: string, options: CodexTaskOptions, fetcher: AsyncCodexResponseFetcher): Promise<unknown> {
const summaryPath = codeQueueProxyPath(`/api/tasks/${encodeURIComponent(taskId)}/summary${queryString({ toolLimit: options.toolLimit })}`);
const summaryResponse = unwrapCodexResponse(await fetcher(summaryPath));
@@ -3242,7 +3639,7 @@ export async function codexJudgeQueryAsync(taskId: string, optionArgs: string[],
return codexTaskJudgeAsync(taskId, parseJudgeOptions(optionArgs), fetcher);
}
export { codexQueuesQueryAsync, codexTasksQueryAsync };
export { codexQueuesQueryAsync, codexTasksQueryAsync, codexUnreadTriageAsync };
function requireQueueId(args: string[], command: string): string {
const index = args.indexOf("--queue");
@@ -3337,7 +3734,7 @@ function compactQueueRow(value: unknown): Record<string, unknown> {
updatedAt: record.updatedAt ?? null,
commands: {
tasks: record.id === undefined || record.id === null ? null : `bun scripts/cli.ts codex tasks --queue ${String(record.id)} --limit ${defaultTasksLimit}`,
unread: record.id === undefined || record.id === null ? null : `bun scripts/cli.ts codex tasks --queue ${String(record.id)} --unread --limit ${defaultTasksLimit}`,
unread: record.id === undefined || record.id === null ? null : `bun scripts/cli.ts codex unread --queue ${String(record.id)} --limit ${defaultTasksLimit}`,
},
};
}
@@ -3438,7 +3835,7 @@ function compactQueuesResponse(body: Record<string, unknown>, options: CodexQueu
first: queueListCommand({ full: options.full, limit: options.limit, offset: 0 }),
full: queueListCommand({ full: true, limit: options.limit, offset: 0 }),
tasks: `bun scripts/cli.ts codex tasks --view supervisor --limit ${Math.min(options.limit, defaultTasksLimit)}`,
unread: `bun scripts/cli.ts codex tasks --unread --limit ${Math.min(options.limit, defaultTasksLimit)}`,
unread: `bun scripts/cli.ts codex unread --limit ${Math.min(options.limit, defaultTasksLimit)}`,
raw: "bun scripts/cli.ts microservice proxy code-queue /api/queues --raw --full",
},
},
@@ -4077,7 +4474,7 @@ function codexReadTaskWithFetcher(taskId: string, fetcher: CodexResponseFetcher)
detail: `bun scripts/cli.ts codex task ${taskId} --detail`,
trace: `bun scripts/cli.ts codex task ${taskId} --trace --tail --limit ${defaultTraceLimit}`,
output: `bun scripts/cli.ts codex output ${taskId} --tail --limit ${defaultOutputLimit}`,
unread: `bun scripts/cli.ts codex tasks --unread --limit ${defaultTasksLimit}`,
unread: `bun scripts/cli.ts codex unread --limit ${defaultTasksLimit}`,
},
};
}
@@ -5633,6 +6030,9 @@ export async function runCodeQueueCommand(config: UniDeskConfig, args: string[])
if (action === "tasks" || action === "overview") {
return codexTasksQuery(args.slice(1));
}
if (action === "unread" || action === "terminal-unread") {
return codexUnreadTriage(args.slice(1));
}
if (action === "dev-ready" || action === "health") {
assertKnownOptions(args.slice(1), {}, `codex ${action}`);
return codeQueueDevReady();
@@ -5673,5 +6073,5 @@ export async function runCodeQueueCommand(config: UniDeskConfig, args: string[])
const taskId = requireTaskId(taskIdArg, "codex steer");
return codexSteerTask(taskId, args.slice(2));
}
throw new Error("codex command must be one of: prompt-lint, submit, enqueue, task, summary, show, tasks, overview, output, judge, read, mark-read, dev-ready, health, skills-sync, pr-preflight, runtime-preflight, queues, queue list, queue create, queue merge, move, steer, interrupt, cancel");
throw new Error("codex command must be one of: prompt-lint, submit, enqueue, task, summary, show, tasks, overview, unread, terminal-unread, output, judge, read, mark-read, dev-ready, health, skills-sync, pr-preflight, runtime-preflight, queues, queue list, queue create, queue merge, move, steer, interrupt, cancel");
}
+58
View File
@@ -144,6 +144,63 @@ function issueEntryPlan(): Record<string, unknown> {
};
}
function prCloseoutPlan(): Record<string, unknown> {
return {
mutation: false,
purpose: "PR closeout checklist for commander sessions and PR-bound GPT-5.5 runners; this plan is read-only and does not merge or close anything.",
runnerBoundary: {
mayCreateUpdateComment: true,
maySelfCloseOrMergeOrdinaryPrWithinTaskBoundary: true,
conditions: [
"task explicitly authorizes PR closeout or merge/close",
"PR is ordinary UniDesk source work, not production, release/v1, destructive rollback, secret, database, or runtime hot patch",
"checks required by the task have passed or unrelated broad failures are documented",
"base/head are reviewed and merge/close target matches the task boundary",
],
commanderRequiredWhen: [
"conflicts or ambiguous ownership",
"failed required checks without accepted explanation",
"production/runtime/release/security/database scope",
"user or commander explicitly reserves final action",
],
},
readOnlyChecklist: [
{
actor: "runner-or-host",
command: "bun scripts/cli.ts gh pr view <number> --repo pikasTech/unidesk --json body,title,state,stateDetail,head,base,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup",
mutation: false,
},
{
actor: "runner-or-host",
command: "bun scripts/cli.ts gh pr files <number> --repo pikasTech/unidesk --limit 30",
mutation: false,
},
{
actor: "runner-or-host",
command: "comment or hand off changed files, validation, residual risks, and read-only mergeability/status evidence",
mutation: false,
},
],
finalAction: {
ordinaryRunnerAllowed: true,
hostCommanderAllowedAfterReview: true,
tools: [
"system gh pr merge <number> --repo pikasTech/unidesk",
"GitHub UI merge/close controls",
"bun scripts/cli.ts gh pr close <number> --repo pikasTech/unidesk",
],
sourceMergeClosePolicy: "Use repo-owned, auditable GitHub paths; do not directly push target branches as a merge substitute.",
},
unideskCliBoundary: {
mergeSupported: false,
closeSupported: true,
command: "bun scripts/cli.ts gh pr merge <number> --repo pikasTech/unidesk",
degradedReason: "unsupported-command",
automatedMergeImplemented: false,
},
};
}
function traceSummaryPlan(): Record<string, unknown> {
return {
mutation: false,
@@ -203,6 +260,7 @@ function commanderPlan(args: string[]): Record<string, unknown> {
promptGuidance: promptGuidancePlan(),
traceSummary: traceSummaryPlan(),
issueEntries: issueEntryPlan(),
prCloseout: prCloseoutPlan(),
claudeqqApproval: {
mutation: false,
commandShape: "bun scripts/cli.ts commander approval request --action <action> --dry-run",
+15 -1
View File
@@ -5556,7 +5556,21 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
return prState(options.repo, token, number, sub === "close" ? "closed" : "open", false);
}
if (sub === "merge") {
return unsupportedCommand("pr merge", options.repo, "PR merge is intentionally unsupported in this phase; use create/comment/read only.");
return unsupportedCommand(
"pr merge",
options.repo,
"PR merge is intentionally unsupported by the UniDesk REST CLI; PR-bound GPT-5.5 runners may self-close/merge ordinary in-boundary PRs after checks using repo-owned GitHub paths, while high-risk or ambiguous PRs stay commander-reviewed.",
{
closeoutBoundary: {
runnerAllowed: ["pr create", "pr update/edit", "pr comment", "pr read/view", "pr close"],
ordinaryRunnerFinalActionAllowed: true,
commanderRequiredWhen: ["conflicts", "failed required checks", "production/runtime/release/security/database scope", "ambiguous task boundary"],
hostAllowedToolsAfterReview: ["system gh pr merge", "GitHub UI merge/close"],
unideskCliMergeSupported: false,
degradedReason: "unsupported-command",
},
},
);
}
if (sub !== "list" && !isPrReadCommand(sub)) {
return unsupportedCommand(`pr ${sub ?? ""}`.trim(), options.repo, "PR supported commands are list, files, diff --stat, read/view, create, update/edit, close, reopen, comment create/delete, and unsupported merge/delete.");
+9 -1
View File
@@ -58,6 +58,7 @@ export function rootHelp(): unknown {
{ command: "codex pr-preflight [--remote] [--push-dry-run --push-dry-run-ref refs/heads/probe/<name>] [--pr-create-dry-run --pr-create-dry-run-head <head>] [--issue N] [--full|--raw]", description: "Read-only PR admission check with compact commander output by default; use --full or --raw to expand the full runtime preflight, tool, and observation payload." },
{ command: "codex task <taskId> [--detail] [--trace --tail|--from-start|--after-seq N|--before-seq N --limit N] [--full]", description: "Fetch the bounded review view by default; --detail is still capped, while --full/trace/output explicitly expand evidence." },
{ command: "codex tasks [--view supervisor|full] [--queue id] [--status status[,status]] [--unread|--unread-only] [--limit N] [--before-id id]", description: "Show the low-noise supervisor view by default: compact task rows, tiny local sections, activity counts, diagnostics, and drill-down commands; use --view full for detailed rows." },
{ command: "codex unread [summary|mark-read] [--queue id] [--repo owner/name] [--issue N] [--status succeeded,failed,canceled] [--limit N] [--confirm]", description: "Summarize unread terminal backlog by repo, issue, status and queue without raw prompts; batch mark-read requires the explicit mark-read subcommand plus --confirm." },
{ command: "codex output <taskId> [--tail|--from-start|--after-seq N|--before-seq N --limit N] [--full-text]", description: "Fetch paged raw Code Queue output records; default caps large limits/text previews, --full-text explicitly expands one seq window." },
{ command: "codex read <taskId>", description: "Mark one reviewed terminal task read and return terminal metadata plus final response; prompt/tool logs stay behind drill-down commands." },
{ command: "codex dev-ready", description: "Fetch execution-container readiness, including sanitized skill injection status from /api/dev-ready." },
@@ -246,7 +247,7 @@ function scheduleHelp(): unknown {
function codexHelp(): unknown {
return {
command: "codex deploy|prompt-lint|submit|task|tasks|output|read|dev-ready|skills-sync|pr-preflight|judge|steer|interrupt|cancel|queues|queue|move",
command: "codex deploy|prompt-lint|submit|task|tasks|unread|output|read|dev-ready|skills-sync|pr-preflight|judge|steer|interrupt|cancel|queues|queue|move",
output: "json",
usage: [
"bun scripts/cli.ts codex deploy <commitId> # disabled legacy deployment entry",
@@ -256,6 +257,8 @@ function codexHelp(): unknown {
"bun scripts/cli.ts codex submit --prompt-file /tmp/code-queue-prompt.md --queue <id> --dry-run",
"bun scripts/cli.ts codex task <taskId> [--detail] [--trace --tail|--from-start|--after-seq N|--before-seq N --limit N] [--full]",
"bun scripts/cli.ts codex tasks [--view supervisor|full] [--queue id] [--status succeeded,running] [--unread|--unread-only] [--limit N] [--before-id id]",
"bun scripts/cli.ts codex unread [--repo owner/name] [--issue N] [--limit N]",
"bun scripts/cli.ts codex unread mark-read [--repo owner/name] [--issue N] [--limit N] --confirm",
"bun scripts/cli.ts codex output <taskId> [--tail|--from-start|--after-seq N|--before-seq N --limit N] [--full-text]",
"bun scripts/cli.ts codex read <taskId>",
"bun scripts/cli.ts codex dev-ready",
@@ -277,6 +280,11 @@ function codexHelp(): unknown {
default: "codex read marks a terminal task read and returns terminal metadata, final response, last error/judge, counts, and drill-down commands.",
disclosure: "Full prompt, tool logs, and feedback prompts are not printed by codex read; use codex task/detail/trace/output for progressive disclosure.",
},
unreadTriage: {
default: "codex unread is read-only by default and returns counts plus bounded task ids grouped by repo, issue, status and queue.",
mutationGuard: "Batch mark-read is blocked unless the explicit mark-read subcommand is used with --confirm; use codex read <taskId> for per-task review.",
disclosure: "Raw prompt, final response, trace and output are omitted; use the returned task/detail/trace/output/read commands for drill-down.",
},
examples: {
promptLint: "bun scripts/cli.ts codex prompt-lint --prompt-file /tmp/code-queue-prompt.md",
stdin: [
+6 -4
View File
@@ -4,7 +4,7 @@ import { type DebugDispatchCommand, isDebugDispatchCommand } from "./debug";
import { summarizeMicroserviceHealthResponse, summarizeMicroserviceObservation, summarizeMicroserviceProxyResponse } from "./microservices";
import { parseNetworkPerfOptions, runNetworkPerf } from "./network-perf";
import { isSshSkillDiscoveryArgs, parseSshArgs } from "./ssh";
import { codexJudgeQueryAsync, codexOutputQueryAsync, codexPrPreflightQueryAsync, codexQueuesQueryAsync, codexTaskQueryAsync, codexTasksQueryAsync } from "./code-queue";
import { codexJudgeQueryAsync, codexOutputQueryAsync, codexPrPreflightQueryAsync, codexQueuesQueryAsync, codexTaskQueryAsync, codexTasksQueryAsync, codexUnreadTriageAsync } from "./code-queue";
import { runDecisionCenterCommandAsync } from "./decision-center";
import {
artifactRegistryReadonlyResultFromCommand,
@@ -781,8 +781,8 @@ function dispatchedTaskShape(remoteCommandShape: string): string {
async function remoteCodeQueue(session: FrontendSession, args: string[]): Promise<unknown> {
const action = args[1] ?? "task";
if (action !== "task" && action !== "summary" && action !== "show" && action !== "tasks" && action !== "overview" && action !== "queues" && action !== "queue-list" && action !== "output" && action !== "judge" && action !== "pr-preflight" && action !== "runtime-preflight") {
throw new Error("remote codex command must be: codex task <taskId>, codex tasks, codex queues, codex output <taskId>, codex judge <taskId> --attempt N, or codex pr-preflight [--remote]");
if (action !== "task" && action !== "summary" && action !== "show" && action !== "tasks" && action !== "overview" && action !== "unread" && action !== "terminal-unread" && action !== "queues" && action !== "queue-list" && action !== "output" && action !== "judge" && action !== "pr-preflight" && action !== "runtime-preflight") {
throw new Error("remote codex command must be: codex task <taskId>, codex tasks, codex unread, codex queues, codex output <taskId>, codex judge <taskId> --attempt N, or codex pr-preflight [--remote]");
}
const taskId = args[2];
if ((action === "task" || action === "summary" || action === "show" || action === "output" || action === "judge") && (taskId === undefined || taskId.length === 0)) {
@@ -802,6 +802,8 @@ async function remoteCodeQueue(session: FrontendSession, args: string[]): Promis
transport: "frontend",
result: action === "tasks" || action === "overview"
? await codexTasksQueryAsync(args.slice(1), fetcher)
: action === "unread" || action === "terminal-unread"
? await codexUnreadTriageAsync(args.slice(2), fetcher)
: action === "queues" || action === "queue-list"
? await codexQueuesQueryAsync(args.slice(2), fetcher)
: action === "output"
@@ -876,7 +878,7 @@ async function runRemoteCliOverFrontend(options: RemoteCliOptions, config: UniDe
emitRemoteJson(name, {
transport: "frontend",
baseUrl: session.baseUrl,
commands: ["debug health", "debug dispatch", "debug task", "ssh <providerId> <command>", "ssh <providerId> skills", "artifact-registry status|health", "ci publish-user-service --dry-run", "ci publish-backend-core --dry-run", "microservice list", "microservice status <id>", "microservice health <id>", "microservice diagnostics <id>", "microservice tunnel-self-test <id>", "microservice proxy <id> <path>", "decision upload <markdown-file>", "decision list", "decision show <id>", "codex task <taskId>", "codex tasks", "codex queues", "codex judge <taskId> --attempt N", "codex pr-preflight [--remote]", "network perf"],
commands: ["debug health", "debug dispatch", "debug task", "ssh <providerId> <command>", "ssh <providerId> skills", "artifact-registry status|health", "ci publish-user-service --dry-run", "ci publish-backend-core --dry-run", "microservice list", "microservice status <id>", "microservice health <id>", "microservice diagnostics <id>", "microservice tunnel-self-test <id>", "microservice proxy <id> <path>", "decision upload <markdown-file>", "decision list", "decision show <id>", "codex task <taskId>", "codex tasks", "codex unread", "codex queues", "codex judge <taskId> --attempt N", "codex pr-preflight [--remote]", "network perf"],
});
return 0;
}