@nseng-ai/ccc 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +48 -0
- package/src/api/handlers.ts +75 -0
- package/src/api/index.ts +11 -0
- package/src/cmux/branch-slug.ts +174 -0
- package/src/cmux/claude-plan-tab.ts +124 -0
- package/src/cmux/command-surfaces.ts +51 -0
- package/src/cmux/dispatch-from-trunk.ts +177 -0
- package/src/cmux/dispatch-prompt.ts +459 -0
- package/src/cmux/objective-sidebar.ts +373 -0
- package/src/cmux/prompt-file.ts +34 -0
- package/src/cmux/sidebar.ts +386 -0
- package/src/cmux/slot-checkout.ts +46 -0
- package/src/cmux/slot-dispatch-plan.ts +560 -0
- package/src/cmux/slot-open-branch.ts +241 -0
- package/src/cmux/slot.ts +126 -0
- package/src/cmux/workspace-summary.ts +222 -0
- package/src/cmux/worktree-description.ts +87 -0
- package/src/ns/autobranch/checkpoint.ts +35 -0
- package/src/ns/autobranch/flow.ts +18 -0
- package/src/ns/autoslot-presentation.ts +33 -0
- package/src/ns/autoslot.ts +2 -0
- package/src/ns/cli-command-io.ts +23 -0
- package/src/ns/cli.ts +186 -0
- package/src/ns/land.ts +12 -0
- package/src/ns/trunk-pull.ts +2 -0
- package/src/pi/claude-plan-tab.ts +32 -0
- package/src/pi/command-backed-skills.ts +7 -0
- package/src/pi/dispatch-from-trunk.ts +41 -0
- package/src/pi/dispatch-prompt.ts +41 -0
- package/src/pi/extension.ts +23 -0
- package/src/pi/index.ts +11 -0
- package/src/pi/sidebar.ts +58 -0
- package/src/pi/slot-dispatch-plan.ts +70 -0
- package/src/pi/slot-open-branch.ts +39 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import {
|
|
2
|
+
findLatestBranchContextEvidence,
|
|
3
|
+
type BranchContextEvidence,
|
|
4
|
+
} from "@nseng-ai/branch-context/api";
|
|
5
|
+
|
|
6
|
+
import { CCC_WORKSPACE_OPEN_BRANCH_COMMAND_NAME } from "./command-surfaces.ts";
|
|
7
|
+
import { openBranchInCmuxSlot } from "./slot.ts";
|
|
8
|
+
import { createCccSlotClient } from "./slot-checkout.ts";
|
|
9
|
+
import type { SlotClient } from "@nseng-ai/slots/api";
|
|
10
|
+
import type {
|
|
11
|
+
AutocompleteItem,
|
|
12
|
+
CommandContext,
|
|
13
|
+
ExtensionAPI,
|
|
14
|
+
} from "@nseng-ai/capability-kit/cmux/types";
|
|
15
|
+
|
|
16
|
+
interface BranchCandidate {
|
|
17
|
+
name: string;
|
|
18
|
+
scope: "local" | "remote";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type ResolvedBranch =
|
|
22
|
+
| { inferred: false; branchName: string }
|
|
23
|
+
| { inferred: true; branchName: string; evidence: BranchContextEvidence }
|
|
24
|
+
| { error: string };
|
|
25
|
+
|
|
26
|
+
export interface CccSlotOpenBranchOptions {
|
|
27
|
+
slotClient?: SlotClient;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface HandleCccSlotOpenBranchOptions {
|
|
31
|
+
pi: Pick<ExtensionAPI, "exec">;
|
|
32
|
+
args: string;
|
|
33
|
+
ctx: CommandContext;
|
|
34
|
+
options?: CccSlotOpenBranchOptions;
|
|
35
|
+
notifyProgress: (message: string) => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const COMMAND_NAME = CCC_WORKSPACE_OPEN_BRANCH_COMMAND_NAME;
|
|
39
|
+
const MAX_COMPLETIONS = 30;
|
|
40
|
+
const BRANCH_FORMAT = "%(refname:short)\t%(refname)";
|
|
41
|
+
|
|
42
|
+
export async function handleCccSlotOpenBranch(
|
|
43
|
+
options: HandleCccSlotOpenBranchOptions,
|
|
44
|
+
): Promise<void> {
|
|
45
|
+
const { pi, args, ctx } = options;
|
|
46
|
+
const explicitBranch = args.trim();
|
|
47
|
+
options.notifyProgress(
|
|
48
|
+
explicitBranch.length > 0
|
|
49
|
+
? `Opening cmux workspace for ${explicitBranch}…`
|
|
50
|
+
: "Resolving branch context to open…",
|
|
51
|
+
);
|
|
52
|
+
await ctx.waitForIdle();
|
|
53
|
+
|
|
54
|
+
const resolved: ResolvedBranch =
|
|
55
|
+
explicitBranch.length > 0
|
|
56
|
+
? { branchName: explicitBranch, inferred: false }
|
|
57
|
+
: await resolveInferredBranchContext(ctx);
|
|
58
|
+
|
|
59
|
+
if ("error" in resolved) {
|
|
60
|
+
ctx.ui.notify(resolved.error, "error");
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (resolved.inferred) {
|
|
65
|
+
const confirmed = await confirmInferredBranch(ctx, resolved.evidence);
|
|
66
|
+
if (!confirmed) {
|
|
67
|
+
ctx.ui.notify("Cancelled; no cmux workspace was opened.", "info");
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const branch = resolved.branchName;
|
|
73
|
+
if (resolved.inferred) {
|
|
74
|
+
options.notifyProgress(`Opening cmux workspace for ${branch}…`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const launched = await openBranchInCmuxSlot({
|
|
78
|
+
pi,
|
|
79
|
+
cwd: ctx.cwd,
|
|
80
|
+
branchName: branch,
|
|
81
|
+
slotClient: options.options?.slotClient ?? createCccSlotClient({ cwd: ctx.cwd }),
|
|
82
|
+
notify: (message, level) => ctx.ui.notify(message, level),
|
|
83
|
+
});
|
|
84
|
+
if ("error" in launched) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function resolveInferredBranchContext(ctx: {
|
|
90
|
+
sessionManager?: { getBranch?: () => unknown[] };
|
|
91
|
+
}): Promise<
|
|
92
|
+
{ inferred: true; branchName: string; evidence: BranchContextEvidence } | { error: string }
|
|
93
|
+
> {
|
|
94
|
+
const entries = ctx.sessionManager?.getBranch?.() ?? [];
|
|
95
|
+
const evidence = findLatestBranchContextEvidence(entries);
|
|
96
|
+
if (!evidence) {
|
|
97
|
+
return {
|
|
98
|
+
error: `Usage: /${COMMAND_NAME} <branch>\nNo latest [branch-context-output] branch found in the current session branch.`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return { inferred: true, branchName: evidence.branch, evidence };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function confirmInferredBranch(
|
|
105
|
+
ctx: {
|
|
106
|
+
hasUI?: boolean;
|
|
107
|
+
ui: {
|
|
108
|
+
confirm?: (title: string, message: string) => Promise<boolean>;
|
|
109
|
+
notify(message: string, level?: "info" | "warning" | "error"): void;
|
|
110
|
+
};
|
|
111
|
+
},
|
|
112
|
+
evidence: BranchContextEvidence,
|
|
113
|
+
): Promise<boolean> {
|
|
114
|
+
if (!ctx.hasUI || ctx.ui.confirm === undefined) {
|
|
115
|
+
ctx.ui.notify(
|
|
116
|
+
`Cannot infer /${COMMAND_NAME} branch without an interactive confirmation UI.`,
|
|
117
|
+
"error",
|
|
118
|
+
);
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const details = formatInferredBranchConfirmation(evidence);
|
|
123
|
+
return ctx.ui.confirm("Use branch context?", details);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function formatInferredBranchConfirmation(evidence: BranchContextEvidence): string {
|
|
127
|
+
return [
|
|
128
|
+
`Use branch "${evidence.branch}" from the latest [branch-context-output] and open it in a new cmux workspace?`,
|
|
129
|
+
"",
|
|
130
|
+
`Key: ${evidence.key}`,
|
|
131
|
+
`Branch creation: ${evidence.branchCreation}`,
|
|
132
|
+
`Start point: ${evidence.startPoint}`,
|
|
133
|
+
`Commit: ${evidence.commit}`,
|
|
134
|
+
`Source file: ${evidence.sourceFile}`,
|
|
135
|
+
].join("\n");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function extractCommandArgumentPrefix(textBeforeCursor: string): string | undefined {
|
|
139
|
+
const commandPrefix = `/${COMMAND_NAME}`;
|
|
140
|
+
if (!textBeforeCursor.startsWith(commandPrefix)) {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const rest = textBeforeCursor.slice(commandPrefix.length);
|
|
145
|
+
if (!rest.startsWith(" ")) {
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const argumentPrefix = rest.slice(1);
|
|
150
|
+
return /\s/.test(argumentPrefix.trim()) ? undefined : argumentPrefix;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function getBranchCompletions(
|
|
154
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
155
|
+
cwd: string,
|
|
156
|
+
argumentPrefix: string,
|
|
157
|
+
): Promise<AutocompleteItem[]> {
|
|
158
|
+
const trimmedPrefix = argumentPrefix.trim();
|
|
159
|
+
if (/\s/.test(trimmedPrefix)) {
|
|
160
|
+
return [];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const candidates = await listBranchCandidates(pi, cwd);
|
|
164
|
+
if (!candidates) {
|
|
165
|
+
return [];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return filterBranchCandidates(candidates, trimmedPrefix)
|
|
169
|
+
.slice(0, MAX_COMPLETIONS)
|
|
170
|
+
.map((candidate) => ({
|
|
171
|
+
value: candidate.name,
|
|
172
|
+
label: candidate.name,
|
|
173
|
+
description: candidate.scope,
|
|
174
|
+
}));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function listBranchCandidates(
|
|
178
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
179
|
+
cwd: string,
|
|
180
|
+
): Promise<BranchCandidate[] | undefined> {
|
|
181
|
+
const result = await pi.exec(
|
|
182
|
+
"git",
|
|
183
|
+
["for-each-ref", `--format=${BRANCH_FORMAT}`, "refs/heads", "refs/remotes"],
|
|
184
|
+
{ cwd, timeout: 5_000 },
|
|
185
|
+
);
|
|
186
|
+
if (result.code !== 0) {
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const seen = new Set<string>();
|
|
191
|
+
const candidates: BranchCandidate[] = [];
|
|
192
|
+
for (const line of result.stdout.split("\n")) {
|
|
193
|
+
const trimmedLine = line.trim();
|
|
194
|
+
if (trimmedLine.length === 0) {
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const [name, ref] = trimmedLine.split("\t");
|
|
199
|
+
if (!name || !ref || name.endsWith("/HEAD")) {
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (seen.has(name)) {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
seen.add(name);
|
|
207
|
+
candidates.push({
|
|
208
|
+
name,
|
|
209
|
+
scope: ref.startsWith("refs/heads/") ? "local" : "remote",
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return candidates;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function filterBranchCandidates(candidates: BranchCandidate[], prefix: string): BranchCandidate[] {
|
|
217
|
+
if (prefix.length === 0) {
|
|
218
|
+
return sortBranchCandidates(candidates);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const exactMatches = candidates.filter((candidate) => candidate.name === prefix);
|
|
222
|
+
if (exactMatches.length > 0) {
|
|
223
|
+
return sortBranchCandidates(exactMatches);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const prefixMatches = candidates.filter((candidate) => candidate.name.startsWith(prefix));
|
|
227
|
+
if (prefixMatches.length > 0) {
|
|
228
|
+
return sortBranchCandidates(prefixMatches);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return sortBranchCandidates(candidates.filter((candidate) => candidate.name.includes(prefix)));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function sortBranchCandidates(candidates: BranchCandidate[]): BranchCandidate[] {
|
|
235
|
+
return [...candidates].sort((left, right) => {
|
|
236
|
+
if (left.scope !== right.scope) {
|
|
237
|
+
return left.scope === "local" ? -1 : 1;
|
|
238
|
+
}
|
|
239
|
+
return left.name.localeCompare(right.name);
|
|
240
|
+
});
|
|
241
|
+
}
|
package/src/cmux/slot.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { piExecApiToCommandExecApi } from "@nseng-ai/foundation/command";
|
|
2
|
+
import type { SlotCheckoutTarget, SlotClient } from "@nseng-ai/slots/api";
|
|
3
|
+
import { checkoutSlot, formatSlotCheckoutFailureCause } from "./slot-checkout.ts";
|
|
4
|
+
import { RealCmuxGateway, type CmuxGatewayFailure } from "@nseng-ai/capability-kit/cmux/gateway";
|
|
5
|
+
import { getWorktreeDescription } from "./worktree-description.ts";
|
|
6
|
+
import type { ExtensionAPI, NotifyLevel } from "@nseng-ai/capability-kit/cmux/types";
|
|
7
|
+
|
|
8
|
+
export interface BranchCmuxSlotCheckoutOptions {
|
|
9
|
+
pi: Pick<ExtensionAPI, "exec">;
|
|
10
|
+
cwd: string;
|
|
11
|
+
branchName: string;
|
|
12
|
+
env?: NodeJS.ProcessEnv | Record<string, string | undefined>;
|
|
13
|
+
slotClient: SlotClient;
|
|
14
|
+
notify: (message: string, level: NotifyLevel) => void;
|
|
15
|
+
onStatus?: (message: string) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface OpenBranchInCmuxSlotOptions extends BranchCmuxSlotCheckoutOptions {
|
|
19
|
+
command?: string;
|
|
20
|
+
description?: string;
|
|
21
|
+
successMessage?: (target: SlotCheckoutTarget) => string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface OpenCmuxWorkspaceOptions {
|
|
25
|
+
description: string;
|
|
26
|
+
command?: string;
|
|
27
|
+
failureHeading?: string;
|
|
28
|
+
failureDetails?: readonly string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function checkoutBranchCmuxSlot(
|
|
32
|
+
options: BranchCmuxSlotCheckoutOptions,
|
|
33
|
+
): Promise<SlotCheckoutTarget | { error: string }> {
|
|
34
|
+
const { branchName, notify, onStatus } = options;
|
|
35
|
+
onStatus?.("checking out branch slot…");
|
|
36
|
+
const checkout = await checkoutSlot(options.slotClient, { kind: "branch", branchName });
|
|
37
|
+
if (!checkout.ok) {
|
|
38
|
+
const cause = formatSlotCheckoutFailureCause(checkout.failure);
|
|
39
|
+
notify(formatSlotCheckoutFailure(branchName, cause), "error");
|
|
40
|
+
return { error: cause };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return checkout.target;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function openBranchInCmuxSlot(
|
|
47
|
+
options: OpenBranchInCmuxSlotOptions,
|
|
48
|
+
): Promise<SlotCheckoutTarget | { error: string }> {
|
|
49
|
+
const { pi, command, description, notify, onStatus, successMessage } = options;
|
|
50
|
+
const target = await checkoutBranchCmuxSlot(options);
|
|
51
|
+
if ("error" in target) return target;
|
|
52
|
+
onStatus?.("opening cmux workspace…");
|
|
53
|
+
const workspaceDescription =
|
|
54
|
+
description ?? (await getWorktreeDescription(pi, target.worktreePath, target.branchName));
|
|
55
|
+
const workspaceOptions: OpenCmuxWorkspaceOptions = {
|
|
56
|
+
description: workspaceDescription,
|
|
57
|
+
failureHeading: "Checked out the branch slot, but failed to open the cmux workspace.",
|
|
58
|
+
failureDetails: [`Branch: ${target.branchName}`, `Worktree: ${target.worktreePath}`],
|
|
59
|
+
};
|
|
60
|
+
if (command !== undefined) {
|
|
61
|
+
workspaceOptions.command = command;
|
|
62
|
+
}
|
|
63
|
+
const launched = await openCmuxWorkspace(pi, target, workspaceOptions);
|
|
64
|
+
if ("error" in launched) {
|
|
65
|
+
notify(launched.error, "error");
|
|
66
|
+
return launched;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
notify(
|
|
70
|
+
successMessage?.(target) ?? `Opened cmux workspace for branch: ${target.branchName}`,
|
|
71
|
+
"info",
|
|
72
|
+
);
|
|
73
|
+
return target;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function openCmuxWorkspace(
|
|
77
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
78
|
+
target: SlotCheckoutTarget,
|
|
79
|
+
options: OpenCmuxWorkspaceOptions,
|
|
80
|
+
): Promise<{ ok: true } | { error: string }> {
|
|
81
|
+
const cmux = new RealCmuxGateway(piExecApiToCommandExecApi(pi));
|
|
82
|
+
const result = await cmux.openWorkspace({
|
|
83
|
+
cwd: target.worktreePath,
|
|
84
|
+
name: target.branchName,
|
|
85
|
+
description: options.description,
|
|
86
|
+
workspaceCwd: target.worktreePath,
|
|
87
|
+
...(options.command === undefined ? {} : { command: options.command }),
|
|
88
|
+
});
|
|
89
|
+
if (result.type === "success") return { ok: true };
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
error: formatCmuxWorkspaceFailure(result.failure, options),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function buildNewWorkspaceArgs(
|
|
97
|
+
target: SlotCheckoutTarget,
|
|
98
|
+
options: Pick<OpenCmuxWorkspaceOptions, "description" | "command">,
|
|
99
|
+
): string[] {
|
|
100
|
+
const args = [
|
|
101
|
+
"new-workspace",
|
|
102
|
+
"--name",
|
|
103
|
+
target.branchName,
|
|
104
|
+
"--description",
|
|
105
|
+
options.description,
|
|
106
|
+
"--cwd",
|
|
107
|
+
target.worktreePath,
|
|
108
|
+
];
|
|
109
|
+
if (options.command !== undefined) {
|
|
110
|
+
args.push("--command", options.command);
|
|
111
|
+
}
|
|
112
|
+
return args;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function formatSlotCheckoutFailure(branchName: string, cause: string): string {
|
|
116
|
+
return ["Failed to check out branch slot.", `Branch: ${branchName}`, "", cause].join("\n");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function formatCmuxWorkspaceFailure(
|
|
120
|
+
failure: CmuxGatewayFailure,
|
|
121
|
+
options: OpenCmuxWorkspaceOptions,
|
|
122
|
+
): string {
|
|
123
|
+
const heading = options.failureHeading ?? "cmux new-workspace failed.";
|
|
124
|
+
const lines = [heading, ...(options.failureDetails ?? []), failure.message];
|
|
125
|
+
return lines.filter((line) => line.length > 0).join("\n");
|
|
126
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
import { failure, ok, usageError, type ClinkrExit } from "@nseng-ai/clinkr";
|
|
4
|
+
import type { CommandExecApi } from "@nseng-ai/foundation/command";
|
|
5
|
+
import {
|
|
6
|
+
RealCmuxGateway,
|
|
7
|
+
type CmuxGateway,
|
|
8
|
+
type CmuxGatewayFailure,
|
|
9
|
+
} from "@nseng-ai/capability-kit/cmux/gateway";
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_CMUX_WORKSPACE_SUMMARY_STATUS_KEY = "pi-summary";
|
|
12
|
+
export const CMUX_WORKSPACE_SUMMARY_COMMAND_TIMEOUT_MS = 30_000;
|
|
13
|
+
|
|
14
|
+
export const cmuxWorkspaceSummaryRequestSchema = z.strictObject({
|
|
15
|
+
workspace: z
|
|
16
|
+
.string()
|
|
17
|
+
.optional()
|
|
18
|
+
.describe("Caller cmux workspace id/ref. Defaults to CMUX_WORKSPACE_ID, then CMUX_TAB_ID."),
|
|
19
|
+
title: z.string().describe("Workspace title."),
|
|
20
|
+
description: z.string().optional().describe("Workspace description."),
|
|
21
|
+
statusKey: z
|
|
22
|
+
.string()
|
|
23
|
+
.default(DEFAULT_CMUX_WORKSPACE_SUMMARY_STATUS_KEY)
|
|
24
|
+
.describe("cmux status key to clear."),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const cmuxCommandFailureSchema = z.strictObject({
|
|
28
|
+
command: z.array(z.string()),
|
|
29
|
+
exitCode: z.number().int(),
|
|
30
|
+
stdout: z.string(),
|
|
31
|
+
stderr: z.string(),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const cmuxWorkspaceSummaryErrorSchema = z.strictObject({
|
|
35
|
+
code: z.string(),
|
|
36
|
+
message: z.string(),
|
|
37
|
+
commandFailure: cmuxCommandFailureSchema.nullable(),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export const cmuxWorkspaceSummaryResultSchema = z.strictObject({
|
|
41
|
+
success: z.boolean(),
|
|
42
|
+
workspace: z.string().nullable(),
|
|
43
|
+
title: z.string(),
|
|
44
|
+
description: z.string().nullable(),
|
|
45
|
+
statusKey: z.string(),
|
|
46
|
+
error: cmuxWorkspaceSummaryErrorSchema.nullable(),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export type CmuxWorkspaceSummaryRequest = z.infer<typeof cmuxWorkspaceSummaryRequestSchema>;
|
|
50
|
+
export type CmuxWorkspaceSummaryResult = z.infer<typeof cmuxWorkspaceSummaryResultSchema>;
|
|
51
|
+
|
|
52
|
+
type CmuxWorkspaceSummaryFailureCode =
|
|
53
|
+
| "missing-workspace"
|
|
54
|
+
| "missing-description"
|
|
55
|
+
| "rename-workspace-failed"
|
|
56
|
+
| "set-description-failed"
|
|
57
|
+
| "clear-status-failed";
|
|
58
|
+
|
|
59
|
+
interface CmuxWorkspaceSummaryFailure {
|
|
60
|
+
code: CmuxWorkspaceSummaryFailureCode;
|
|
61
|
+
message: string;
|
|
62
|
+
commandFailure?: CmuxGatewayFailure["commandFailure"];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ApplyCmuxWorkspaceSummaryOptions {
|
|
66
|
+
request: CmuxWorkspaceSummaryRequest;
|
|
67
|
+
commands: CommandExecApi;
|
|
68
|
+
cwd: string;
|
|
69
|
+
env: Record<string, string | undefined>;
|
|
70
|
+
cmux?: CmuxGateway;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function applyCmuxWorkspaceSummaryCommand(
|
|
74
|
+
options: ApplyCmuxWorkspaceSummaryOptions,
|
|
75
|
+
): Promise<ClinkrExit<CmuxWorkspaceSummaryResult>> {
|
|
76
|
+
const workspace =
|
|
77
|
+
nonBlank(options.request.workspace) ??
|
|
78
|
+
nonBlank(options.env["CMUX_WORKSPACE_ID"]) ??
|
|
79
|
+
nonBlank(options.env["CMUX_TAB_ID"]);
|
|
80
|
+
if (workspace === undefined) {
|
|
81
|
+
return failedExit(options.request, null, {
|
|
82
|
+
code: "missing-workspace",
|
|
83
|
+
message:
|
|
84
|
+
"Not running inside a cmux caller workspace (CMUX_WORKSPACE_ID/CMUX_TAB_ID missing).",
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const description = nonBlank(options.request.description);
|
|
89
|
+
if (description === undefined) {
|
|
90
|
+
return failedExit(options.request, workspace, {
|
|
91
|
+
code: "missing-description",
|
|
92
|
+
message: "Provide --description.",
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const cmux = options.cmux ?? new RealCmuxGateway(options.commands);
|
|
97
|
+
const context = {
|
|
98
|
+
cwd: options.cwd,
|
|
99
|
+
env: options.env,
|
|
100
|
+
timeoutMs: CMUX_WORKSPACE_SUMMARY_COMMAND_TIMEOUT_MS,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const renameResult = await cmux.renameWorkspace({
|
|
104
|
+
...context,
|
|
105
|
+
workspace,
|
|
106
|
+
title: options.request.title,
|
|
107
|
+
});
|
|
108
|
+
if (renameResult.type === "failed") {
|
|
109
|
+
return failedExit(
|
|
110
|
+
options.request,
|
|
111
|
+
workspace,
|
|
112
|
+
commandFailure(
|
|
113
|
+
"rename-workspace-failed",
|
|
114
|
+
"Failed to rename cmux workspace.",
|
|
115
|
+
renameResult.failure,
|
|
116
|
+
),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const descriptionResult = await cmux.setWorkspaceDescription({
|
|
121
|
+
...context,
|
|
122
|
+
workspace,
|
|
123
|
+
description,
|
|
124
|
+
});
|
|
125
|
+
if (descriptionResult.type === "failed") {
|
|
126
|
+
return failedExit(
|
|
127
|
+
options.request,
|
|
128
|
+
workspace,
|
|
129
|
+
commandFailure(
|
|
130
|
+
"set-description-failed",
|
|
131
|
+
"Failed to set cmux workspace description.",
|
|
132
|
+
descriptionResult.failure,
|
|
133
|
+
),
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const clearStatusResult = await cmux.clearStatus({
|
|
138
|
+
...context,
|
|
139
|
+
workspace,
|
|
140
|
+
statusKey: options.request.statusKey,
|
|
141
|
+
});
|
|
142
|
+
if (clearStatusResult.type === "failed") {
|
|
143
|
+
return failedExit(
|
|
144
|
+
options.request,
|
|
145
|
+
workspace,
|
|
146
|
+
commandFailure(
|
|
147
|
+
"clear-status-failed",
|
|
148
|
+
"Failed to clear cmux workspace status.",
|
|
149
|
+
clearStatusResult.failure,
|
|
150
|
+
),
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return ok({
|
|
155
|
+
success: true,
|
|
156
|
+
workspace,
|
|
157
|
+
title: options.request.title,
|
|
158
|
+
description,
|
|
159
|
+
statusKey: options.request.statusKey,
|
|
160
|
+
error: null,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function renderCmuxWorkspaceSummaryHuman(data: CmuxWorkspaceSummaryResult): string {
|
|
165
|
+
if (data.success) return `Applied cmux workspace summary: ${data.title}\n`;
|
|
166
|
+
return `${data.error?.message ?? "Unknown cmux summary failure."}\n`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function commandFailure(
|
|
170
|
+
code: Exclude<CmuxWorkspaceSummaryFailureCode, "missing-workspace" | "missing-description">,
|
|
171
|
+
baseMessage: string,
|
|
172
|
+
failure: CmuxGatewayFailure,
|
|
173
|
+
): CmuxWorkspaceSummaryFailure {
|
|
174
|
+
const commandFailureValue = failure.commandFailure;
|
|
175
|
+
if (commandFailureValue === undefined) {
|
|
176
|
+
return { code, message: failure.message };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const details = commandFailureValue.stderr.trim() || commandFailureValue.stdout.trim();
|
|
180
|
+
const message =
|
|
181
|
+
details.length > 0
|
|
182
|
+
? `${baseMessage} exit ${commandFailureValue.exitCode}: ${details}`
|
|
183
|
+
: `${baseMessage} exit ${commandFailureValue.exitCode}.`;
|
|
184
|
+
return { code, message, commandFailure: commandFailureValue };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function failedExit(
|
|
188
|
+
request: CmuxWorkspaceSummaryRequest,
|
|
189
|
+
workspace: string | null,
|
|
190
|
+
failureResult: CmuxWorkspaceSummaryFailure,
|
|
191
|
+
): ClinkrExit<CmuxWorkspaceSummaryResult> {
|
|
192
|
+
const result: CmuxWorkspaceSummaryResult = {
|
|
193
|
+
success: false,
|
|
194
|
+
workspace,
|
|
195
|
+
title: request.title,
|
|
196
|
+
description: null,
|
|
197
|
+
statusKey: request.statusKey,
|
|
198
|
+
error: {
|
|
199
|
+
code: failureResult.code,
|
|
200
|
+
message: failureResult.message,
|
|
201
|
+
commandFailure:
|
|
202
|
+
failureResult.commandFailure === undefined
|
|
203
|
+
? null
|
|
204
|
+
: {
|
|
205
|
+
command: failureResult.commandFailure.command,
|
|
206
|
+
exitCode: failureResult.commandFailure.exitCode,
|
|
207
|
+
stdout: failureResult.commandFailure.stdout,
|
|
208
|
+
stderr: failureResult.commandFailure.stderr,
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
if (failureResult.code === "missing-workspace" || failureResult.code === "missing-description") {
|
|
213
|
+
return usageError(failureResult.message, result);
|
|
214
|
+
}
|
|
215
|
+
return failure(failureResult.code, failureResult.message, result);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function nonBlank(value: string | undefined): string | undefined {
|
|
219
|
+
if (value === undefined) return undefined;
|
|
220
|
+
const trimmed = value.trim();
|
|
221
|
+
return trimmed.length === 0 ? undefined : trimmed;
|
|
222
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
const GIT_TIMEOUT_MS = 5_000;
|
|
2
|
+
|
|
3
|
+
interface ExecRuntime {
|
|
4
|
+
exec(
|
|
5
|
+
command: string,
|
|
6
|
+
args: string[],
|
|
7
|
+
options?: { cwd?: string; timeout?: number },
|
|
8
|
+
): Promise<{ code: number; stdout: string; stderr: string; killed?: boolean }>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function getWorktreeDescription(
|
|
12
|
+
pi: ExecRuntime,
|
|
13
|
+
worktreePath: string,
|
|
14
|
+
branchName: string,
|
|
15
|
+
): Promise<string> {
|
|
16
|
+
const repoName = await getGitRepositoryName(pi, worktreePath);
|
|
17
|
+
return repoName ? `${repoName}/${branchName}` : branchName;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function getGitRepositoryName(
|
|
21
|
+
pi: ExecRuntime,
|
|
22
|
+
cwd: string,
|
|
23
|
+
): Promise<string | undefined> {
|
|
24
|
+
const remote = await pi.exec("git", ["remote", "get-url", "origin"], {
|
|
25
|
+
cwd,
|
|
26
|
+
timeout: GIT_TIMEOUT_MS,
|
|
27
|
+
});
|
|
28
|
+
if (remote.code === 0) {
|
|
29
|
+
const repoName = repositoryNameFromPath(remote.stdout.trim());
|
|
30
|
+
if (repoName) {
|
|
31
|
+
return repoName;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const commonDir = await pi.exec(
|
|
36
|
+
"git",
|
|
37
|
+
["rev-parse", "--path-format=absolute", "--git-common-dir"],
|
|
38
|
+
{
|
|
39
|
+
cwd,
|
|
40
|
+
timeout: GIT_TIMEOUT_MS,
|
|
41
|
+
},
|
|
42
|
+
);
|
|
43
|
+
if (commonDir.code !== 0) {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return repositoryNameFromGitCommonDir(commonDir.stdout.trim());
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function repositoryNameFromGitCommonDir(path: string): string | undefined {
|
|
51
|
+
const normalized = path.replace(/[\\/]+$/, "");
|
|
52
|
+
if (normalized.length === 0) {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const base = basenameForAnyPath(normalized);
|
|
57
|
+
if (base === ".git") {
|
|
58
|
+
return basenameForAnyPath(dirnameForAnyPath(normalized)) || undefined;
|
|
59
|
+
}
|
|
60
|
+
return stripGitSuffix(base) || undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function repositoryNameFromPath(path: string): string | undefined {
|
|
64
|
+
const normalized = path.trim().replace(/[\\/]+$/, "");
|
|
65
|
+
if (normalized.length === 0) {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const separatorIndex = Math.max(normalized.lastIndexOf("/"), normalized.lastIndexOf(":"));
|
|
70
|
+
return stripGitSuffix(normalized.slice(separatorIndex + 1)) || undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function stripGitSuffix(value: string): string {
|
|
74
|
+
return value.endsWith(".git") ? value.slice(0, -4) : value;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function basenameForAnyPath(path: string): string {
|
|
78
|
+
const normalized = path.replace(/[\\/]+$/, "");
|
|
79
|
+
const parts = normalized.split(/[\\/]/);
|
|
80
|
+
return parts[parts.length - 1] ?? "";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function dirnameForAnyPath(path: string): string {
|
|
84
|
+
const normalized = path.replace(/[\\/]+$/, "");
|
|
85
|
+
const separatorIndex = Math.max(normalized.lastIndexOf("/"), normalized.lastIndexOf("\\"));
|
|
86
|
+
return separatorIndex >= 0 ? normalized.slice(0, separatorIndex) : "";
|
|
87
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createCommitWithPreparedMessage,
|
|
3
|
+
prepareCheckpointMessage,
|
|
4
|
+
type CommandResult,
|
|
5
|
+
type PreparedCheckpointMessage,
|
|
6
|
+
} from "@nseng-ai/capability-kit/checkpoint-flow";
|
|
7
|
+
import { createTextGenerator } from "@nseng-ai/ns/kernel/context";
|
|
8
|
+
import type { PendingWorktreeSnapshot } from "@nseng-ai/capability-kit/pending-worktree";
|
|
9
|
+
import { selectCheckpointModelRef } from "@nseng-ai/capability-kit/text-generation";
|
|
10
|
+
|
|
11
|
+
export type { CommandResult, PreparedCheckpointMessage };
|
|
12
|
+
|
|
13
|
+
export async function prepareAutobranchCheckpointMessage(
|
|
14
|
+
snapshot: Pick<PendingWorktreeSnapshot, "status" | "diff">,
|
|
15
|
+
env: Record<string, string | undefined>,
|
|
16
|
+
): Promise<PreparedCheckpointMessage> {
|
|
17
|
+
return prepareCheckpointMessage({
|
|
18
|
+
status: snapshot.status,
|
|
19
|
+
diff: snapshot.diff,
|
|
20
|
+
modelRef: selectCheckpointModelRef(env),
|
|
21
|
+
textGenerator: createTextGenerator(),
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function commitAutobranchCheckpointMessage(
|
|
26
|
+
exec: (command: string, args: string[], cwd: string, timeout: number) => Promise<CommandResult>,
|
|
27
|
+
cwd: string,
|
|
28
|
+
message: string,
|
|
29
|
+
): Promise<{ summary: string } | { error: string }> {
|
|
30
|
+
return createCommitWithPreparedMessage({
|
|
31
|
+
cwd,
|
|
32
|
+
message,
|
|
33
|
+
exec,
|
|
34
|
+
});
|
|
35
|
+
}
|