pi-graphite 0.1.1 → 0.2.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-graphite",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Structured pi tools for the Graphite (gt) CLI.",
5
5
  "keywords": [
6
6
  "pi",
package/src/lib/exec.ts CHANGED
@@ -28,6 +28,14 @@ export function stripAnsi(s: string): string {
28
28
  return s.replace(ANSI, "");
29
29
  }
30
30
 
31
+ /**
32
+ * Replace Graphite's internal product name "Charcoal" with "Graphite" so
33
+ * users and the LLM see a consistent product name. Casing is preserved.
34
+ */
35
+ export function sanitizeBranding(s: string): string {
36
+ return s.replace(/charcoal/gi, (m) => (m[0] === m[0].toUpperCase() ? "Graphite" : "graphite"));
37
+ }
38
+
31
39
  export function truncateOutput(s: string): string {
32
40
  if (s.length <= MAX_BYTES) {
33
41
  const lines = s.split("\n");
@@ -109,8 +117,8 @@ export async function runGt(
109
117
  args,
110
118
  cwd,
111
119
  exitCode: -1,
112
- stdout: stripAnsi(stdout),
113
- stderr: stripAnsi(stderr),
120
+ stdout: sanitizeBranding(stripAnsi(stdout)),
121
+ stderr: sanitizeBranding(stripAnsi(stderr)),
114
122
  timedOut: false,
115
123
  spawnError: err.message,
116
124
  });
@@ -123,8 +131,8 @@ export async function runGt(
123
131
  args,
124
132
  cwd,
125
133
  exitCode: code ?? -1,
126
- stdout: truncateOutput(stripAnsi(stdout)),
127
- stderr: truncateOutput(stripAnsi(stderr)),
134
+ stdout: truncateOutput(sanitizeBranding(stripAnsi(stdout))),
135
+ stderr: truncateOutput(sanitizeBranding(stripAnsi(stderr))),
128
136
  timedOut: killed,
129
137
  });
130
138
  });
package/src/lib/result.ts CHANGED
@@ -1,5 +1,10 @@
1
+ import { spawn } from "node:child_process";
1
2
  import type { GtRunResult } from "./exec";
2
3
 
4
+ /**
5
+ * Structured failure hints. Only populated when the underlying `gt` command
6
+ * exited non-zero. Never inferred from successful output.
7
+ */
3
8
  export interface GtHints {
4
9
  notInGitRepo?: boolean;
5
10
  notInitialized?: boolean;
@@ -11,58 +16,104 @@ export interface GtHints {
11
16
  branchNotTracked?: boolean;
12
17
  noChangesStaged?: boolean;
13
18
  prMissing?: boolean;
19
+ operatingOnTrunk?: boolean;
20
+ invalidArgument?: string;
14
21
  }
15
22
 
16
- const PATTERNS: Array<[keyof GtHints | "checkedOutElsewhere", RegExp]> = [
17
- ["notInGitRepo", /not (a|in a) git repository/i],
18
- ["notInitialized", /run\s+`?gt\s+init`?|graphite (is )?not (yet )?initialized/i],
19
- ["notAuthenticated", /run\s+`?gt\s+auth`?|auth token|not authenticated|unauthorized/i],
20
- ["conflictHalted", /merge\s+conflict|rebase\s+conflict|halted|run\s+`?gt\s+continue`?/i],
21
- ["restackNeeded", /needs?\s+to\s+be\s+restacked|run\s+`?gt\s+restack`?/i],
22
- ["trunkOutOfSync", /trunk\s+is\s+out\s+of\s+sync|out\s+of\s+sync\s+trunk/i],
23
- ["branchNotTracked", /not\s+tracked\s+by\s+graphite|untracked\s+branch/i],
24
- ["noChangesStaged", /no\s+(staged\s+)?changes\s+to\s+(commit|amend)/i],
25
- ["prMissing", /no\s+pull\s+request|pr\s+not\s+found/i],
26
- ["checkedOutElsewhere", /checked\s+out\s+in\s+(another|the)\s+worktree/i],
23
+ // Patterns run only on failure text.
24
+ const PATTERNS: Array<[Exclude<keyof GtHints, "checkedOutElsewhere" | "invalidArgument">, RegExp]> = [
25
+ [
26
+ "notInGitRepo",
27
+ /(?:fatal|ERROR)[^\n]*not\s+(?:a|in a)\s+git\s+repository|No \.git repository found/i,
28
+ ],
29
+ [
30
+ "notInitialized",
31
+ // Output is sanitized before hint parsing, so Charcoal is replaced with
32
+ // Graphite. We still allow the original spelling defensively in case the
33
+ // sanitizer is bypassed.
34
+ /Graphite has not been initialized|Charcoal has not been initialized|graphite is not (?:yet )?initialized|run\s+`?gt\s+init`?/i,
35
+ ],
36
+ [
37
+ "notAuthenticated",
38
+ /Run\s+`?gt\s+auth`?|invalid auth token|unauthorized|please log in|missing auth token/i,
39
+ ],
40
+ [
41
+ "conflictHalted",
42
+ /merge\s+conflict|rebase\s+conflict|halted\s+by|run\s+`?gt\s+continue`?|gt\s+abort/i,
43
+ ],
44
+ [
45
+ "restackNeeded",
46
+ /needs?\s+to\s+be\s+restacked|run\s+`?gt\s+restack`?|stack\s+is\s+out\s+of\s+date/i,
47
+ ],
48
+ [
49
+ "trunkOutOfSync",
50
+ /trunk\s+is\s+out\s+of\s+sync|--ignore-out-of-sync-trunk/i,
51
+ ],
52
+ [
53
+ "branchNotTracked",
54
+ /not\s+tracked\s+by\s+graphite|on\s+(?:an\s+)?untracked\s+branch|Cannot perform this operation on (?:an\s+)?untracked/i,
55
+ ],
56
+ [
57
+ "noChangesStaged",
58
+ /no\s+(?:staged\s+)?changes\s+to\s+(?:commit|amend)/i,
59
+ ],
60
+ [
61
+ "prMissing",
62
+ /no\s+pull\s+request|PR\s+not\s+found|branch\s+has\s+no\s+associated\s+pull\s+request/i,
63
+ ],
64
+ [
65
+ "operatingOnTrunk",
66
+ /Cannot perform this operation on the trunk branch/i,
67
+ ],
27
68
  ];
28
69
 
29
- export function parseHints(r: GtRunResult): GtHints {
70
+ function parseHints(r: GtRunResult): GtHints {
30
71
  const text = `${r.stdout}\n${r.stderr}`;
31
72
  const hints: GtHints = {};
32
73
  for (const [key, re] of PATTERNS) {
33
- if (re.test(text)) {
34
- if (key === "checkedOutElsewhere") {
35
- const m = text.match(/branch\s+`?([^\s`'"]+)`?[^\n]*checked\s+out\s+in[^\n]*?(\S+\/[^\s)]+)?/i);
36
- hints.checkedOutElsewhere = { branch: m?.[1], worktree: m?.[2] };
37
- } else {
38
- (hints as Record<string, unknown>)[key] = true;
39
- }
40
- }
74
+ if (re.test(text)) (hints as Record<string, unknown>)[key] = true;
75
+ }
76
+ if (/checked\s+out\s+in\s+(?:another|the)\s+worktree/i.test(text)) {
77
+ const m = text.match(
78
+ /branch\s+`?([^\s`'"]+)`?[^\n]*?checked\s+out\s+in[^\n]*?(\/[^\s)]+)?/i,
79
+ );
80
+ hints.checkedOutElsewhere = { branch: m?.[1], worktree: m?.[2] };
41
81
  }
82
+ const invalid = text.match(/Unknown argument:\s*([^\n]+)/i);
83
+ if (invalid) hints.invalidArgument = invalid[1].trim();
42
84
  return hints;
43
85
  }
44
86
 
45
87
  export interface FormattedResult {
46
88
  ok: boolean;
89
+ isFailure: boolean;
47
90
  result: GtRunResult;
91
+ /** Empty object on success. Populated only on failure. */
48
92
  hints: GtHints;
93
+ /** Recovery suggestion derived from hints + auxiliary probes. */
94
+ suggestion?: string;
49
95
  }
50
96
 
51
97
  export function formatResult(r: GtRunResult): FormattedResult {
98
+ const isFailure = r.exitCode !== 0 || r.timedOut || !!r.spawnError;
52
99
  return {
53
- ok: r.exitCode === 0 && !r.spawnError && !r.timedOut,
100
+ ok: !isFailure,
101
+ isFailure,
54
102
  result: r,
55
- hints: parseHints(r),
103
+ hints: isFailure ? parseHints(r) : {},
56
104
  };
57
105
  }
58
106
 
59
- /** Render a formatted result into a single text block for the LLM. */
107
+ /** Render a formatted result into a single text block. */
60
108
  export function renderText(label: string, f: FormattedResult): string {
61
109
  const r = f.result;
62
110
  const lines: string[] = [];
63
111
  lines.push(`$ gt ${r.args.join(" ")}`);
64
- lines.push(`# cwd=${r.cwd} exit=${r.exitCode}${r.timedOut ? " (aborted)" : ""}`);
65
- if (r.spawnError) lines.push(`# spawn-error: ${r.spawnError}`);
112
+ lines.push(
113
+ `# cwd=${r.cwd} exit=${r.exitCode}${r.timedOut ? " (aborted)" : ""}${
114
+ r.spawnError ? ` (spawn-error: ${r.spawnError})` : ""
115
+ }`,
116
+ );
66
117
  if (r.stdout.trim()) {
67
118
  lines.push("--- stdout ---");
68
119
  lines.push(r.stdout.replace(/\s+$/, ""));
@@ -71,10 +122,155 @@ export function renderText(label: string, f: FormattedResult): string {
71
122
  lines.push("--- stderr ---");
72
123
  lines.push(r.stderr.replace(/\s+$/, ""));
73
124
  }
74
- const hintKeys = Object.keys(f.hints);
75
- if (hintKeys.length) {
125
+ if (f.isFailure && Object.keys(f.hints).length) {
76
126
  lines.push("--- hints ---");
77
127
  lines.push(JSON.stringify(f.hints));
78
128
  }
129
+ if (f.isFailure && f.suggestion) {
130
+ lines.push("--- suggestion ---");
131
+ lines.push(f.suggestion);
132
+ }
79
133
  return `[${label}] ${f.ok ? "ok" : "fail"}\n${lines.join("\n")}`;
80
134
  }
135
+
136
+ /* ----------------------- auxiliary probes (best-effort) ----------------------- */
137
+
138
+ function execText(cmd: string, args: string[], cwd: string): Promise<string> {
139
+ return new Promise((resolve) => {
140
+ let out = "";
141
+ let child;
142
+ try {
143
+ child = spawn(cmd, args, { cwd, stdio: ["ignore", "pipe", "ignore"] });
144
+ } catch {
145
+ resolve("");
146
+ return;
147
+ }
148
+ child.stdout.on("data", (d) => {
149
+ out += d.toString();
150
+ if (out.length > 4096) out = out.slice(-4096);
151
+ });
152
+ child.on("error", () => resolve(""));
153
+ child.on("close", () => resolve(out));
154
+ });
155
+ }
156
+
157
+ async function detectCurrentBranch(cwd: string): Promise<string | undefined> {
158
+ const t = (await execText("git", ["-C", cwd, "branch", "--show-current"], cwd)).trim();
159
+ return t || undefined;
160
+ }
161
+
162
+ async function detectTrunk(cwd: string): Promise<string | undefined> {
163
+ const out = await execText("gt", ["--cwd", cwd, "--no-interactive", "trunk"], cwd);
164
+ // gt trunk prints the trunk name on its own line. Take last non-empty line.
165
+ const cleaned = out
166
+ .replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "")
167
+ .replace(/charcoal/gi, (m) => (m[0] === m[0].toUpperCase() ? "Graphite" : "graphite"));
168
+ const line = cleaned
169
+ .split("\n")
170
+ .map((s) => s.trim())
171
+ .filter(Boolean)
172
+ // Skip Graphite banner / init lines so we keep the actual trunk name.
173
+ .filter((s) => !/Graphite|Welcome/i.test(s) && !/initialized/i.test(s))
174
+ .pop();
175
+ return line || undefined;
176
+ }
177
+
178
+ /** Enrich a failed FormattedResult with an actionable suggestion. */
179
+ export async function enrichFailure(cwd: string, f: FormattedResult): Promise<void> {
180
+ if (!f.isFailure) return;
181
+ const parts: string[] = [];
182
+
183
+ if (f.hints.branchNotTracked) {
184
+ const [branch, trunk] = await Promise.all([
185
+ detectCurrentBranch(cwd),
186
+ detectTrunk(cwd),
187
+ ]);
188
+ const b = branch ?? "<current-branch>";
189
+ const p = trunk ?? "<trunk>";
190
+ parts.push(
191
+ `Current branch (${b}) is not tracked by Graphite. To track it, call: ` +
192
+ `graphite_branch_tracking({ action: "track", branch: "${b}", parent: "${p}" }). ` +
193
+ `Verify parent is correct before applying.`,
194
+ );
195
+ }
196
+ if (f.hints.notInitialized) {
197
+ parts.push(
198
+ `Graphite not initialized in this repo. Call: graphite_repo({ action: "init", trunk: "<trunk-branch>" }).`,
199
+ );
200
+ }
201
+ if (f.hints.conflictHalted) {
202
+ parts.push(
203
+ `A Graphite command is halted by a conflict. After resolving in git, call: ` +
204
+ `graphite_recovery({ action: "continue" }) (or "abort").`,
205
+ );
206
+ }
207
+ if (f.hints.restackNeeded) {
208
+ parts.push(`Stack is out of date. Call: graphite_stack_restack().`);
209
+ }
210
+ if (f.hints.trunkOutOfSync) {
211
+ parts.push(
212
+ `Trunk is out of sync with remote. Call: graphite_remote_sync({ action: "sync" }) first.`,
213
+ );
214
+ }
215
+ if (f.hints.notAuthenticated) {
216
+ parts.push(
217
+ `Graphite is not authenticated. Run \`gt auth\` interactively or set GRAPHITE_AUTH_TOKEN.`,
218
+ );
219
+ }
220
+ if (f.hints.checkedOutElsewhere) {
221
+ const b = f.hints.checkedOutElsewhere.branch ?? "<branch>";
222
+ parts.push(
223
+ `Branch ${b} is checked out in another worktree. Switch to that worktree, or use ` +
224
+ `\`graphite_branch_create({ onto: "${b}", ... })\` to stack a new branch on top.`,
225
+ );
226
+ }
227
+ if (f.hints.invalidArgument) {
228
+ parts.push(
229
+ `gt rejected an unknown argument (${f.hints.invalidArgument}). The local gt version may not support this flag; remove it and retry.`,
230
+ );
231
+ }
232
+ if (f.hints.operatingOnTrunk) {
233
+ parts.push(
234
+ `Operation refused on the trunk branch. Check out a non-trunk branch first (graphite_branch_navigate).`,
235
+ );
236
+ }
237
+
238
+ if (parts.length) f.suggestion = parts.join(" ");
239
+ }
240
+
241
+ /**
242
+ * Format a GtRunResult; if it failed, enrich and throw an Error whose message
243
+ * contains the rendered output plus the recovery suggestion. The thrown Error
244
+ * makes the tool result `isError: true` so the agent sees a real failure.
245
+ */
246
+ export async function ensureSuccess(
247
+ label: string,
248
+ r: GtRunResult,
249
+ cwd: string,
250
+ ): Promise<FormattedResult> {
251
+ const f = formatResult(r);
252
+ if (f.isFailure) {
253
+ await enrichFailure(cwd, f);
254
+ throw new Error(renderText(label, f));
255
+ }
256
+ return f;
257
+ }
258
+
259
+ /**
260
+ * Run multiple gt calls and ensure all succeed. If any failed, enriches each
261
+ * failed result and throws an Error containing every rendered block so the
262
+ * agent sees the full picture, not just the first error.
263
+ */
264
+ export async function ensureAllSuccess(
265
+ items: Array<{ label: string; result: GtRunResult }>,
266
+ cwd: string,
267
+ ): Promise<FormattedResult[]> {
268
+ const formatted = items.map((i) => ({ label: i.label, f: formatResult(i.result) }));
269
+ const failed = formatted.filter((x) => x.f.isFailure);
270
+ if (failed.length) {
271
+ await Promise.all(failed.map((x) => enrichFailure(cwd, x.f)));
272
+ const blocks = formatted.map((x) => renderText(x.label, x.f));
273
+ throw new Error(blocks.join("\n\n"));
274
+ }
275
+ return formatted.map((x) => x.f);
276
+ }
@@ -1,6 +1,11 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { runGt } from "../lib/exec";
3
- import { formatResult, renderText } from "../lib/result";
2
+ import { runGt, type GtRunResult } from "../lib/exec";
3
+ import {
4
+ ensureAllSuccess,
5
+ ensureSuccess,
6
+ renderText,
7
+ type FormattedResult,
8
+ } from "../lib/result";
4
9
  import {
5
10
  CwdParam,
6
11
  StageMode,
@@ -13,61 +18,128 @@ import {
13
18
 
14
19
  /* --------------------------- branch_inspect --------------------------- */
15
20
 
21
+ interface InspectSection {
22
+ label: string;
23
+ args: string[];
24
+ /** Display heading; null hides the section in the rendered output. */
25
+ heading: string | null;
26
+ }
27
+
16
28
  export function registerBranchInspect(pi: ExtensionAPI) {
17
29
  pi.registerTool({
18
30
  name: "graphite_branch_inspect",
19
31
  label: "Graphite: branch inspect",
20
32
  description:
21
- "Inspect a branch: parent, children, PR body, diff, and diffstat. Read-only.",
33
+ "Inspect a branch with separately-labeled sections: summary (parent/PR), parent, children, diffstat, body, and (optionally) diff or patch. Read-only.",
22
34
  promptSnippet:
23
- "graphite_branch_inspect: read-only branch metadata + diff/patch/stat",
35
+ "graphite_branch_inspect: structured `gt info` + parent/children sections, optional diffstat/body/diff",
24
36
  parameters: Type.Object({
25
37
  cwd: CwdParam,
26
- branch: Type.Optional(Type.String({ description: "Branch to inspect (default current)." })),
27
- include: Type.Optional(
28
- Type.Array(StringEnum(["body", "diff", "patch", "stat"] as const), {
29
- description:
30
- "Pass to `gt info`. `diff` and `patch` are mutually exclusive (diff wins). `stat` modifies diff/patch.",
31
- }),
38
+ branch: Type.Optional(
39
+ Type.String({ description: "Branch to inspect (default current)." }),
40
+ ),
41
+ body: Type.Optional(
42
+ Type.Boolean({ description: "Include the PR body section." }),
43
+ ),
44
+ stat: Type.Optional(
45
+ Type.Boolean({ description: "Include the diffstat section." }),
46
+ ),
47
+ diff: Type.Optional(
48
+ Type.Boolean({ description: "Include the full diff. Takes precedence over `patch`." }),
49
+ ),
50
+ patch: Type.Optional(
51
+ Type.Boolean({ description: "Include per-commit patch. Ignored if `diff` is set." }),
32
52
  ),
33
53
  withParentChildren: Type.Optional(
34
- Type.Boolean({ description: "Also run `gt parent` and `gt children`." }),
54
+ Type.Boolean({
55
+ description:
56
+ "Also run `gt parent` and `gt children` (only meaningful when inspecting the currently checked-out branch).",
57
+ }),
35
58
  ),
36
59
  }),
37
60
  async execute(_id, p, signal): Promise<ToolReturn> {
38
- const infoArgs = ["info"];
39
- if (p.branch) infoArgs.push(p.branch);
40
- const inc = new Set(p.include ?? []);
41
- if (inc.has("body")) infoArgs.push("--body");
42
- if (inc.has("diff")) infoArgs.push("--diff");
43
- else if (inc.has("patch")) infoArgs.push("--patch");
44
- if (inc.has("stat")) infoArgs.push("--stat");
61
+ const branchArg = p.branch ? [p.branch] : [];
62
+
63
+ const sections: InspectSection[] = [
64
+ {
65
+ label: "gt info",
66
+ heading: "## summary",
67
+ args: ["info", ...branchArg],
68
+ },
69
+ ];
45
70
 
46
- const tasks: Promise<unknown>[] = [runGt(infoArgs, { cwd: p.cwd, signal })];
47
71
  if (p.withParentChildren) {
48
- tasks.push(runGt(["parent"], { cwd: p.cwd, signal }));
49
- tasks.push(runGt(["children"], { cwd: p.cwd, signal }));
72
+ sections.push({ label: "gt parent", heading: "## parent", args: ["parent"] });
73
+ sections.push({ label: "gt children", heading: "## children", args: ["children"] });
50
74
  }
51
- const [info, parent, children] = (await Promise.all(tasks)) as Awaited<
52
- ReturnType<typeof runGt>
53
- >[];
54
75
 
55
- const blocks: string[] = [renderText(`gt ${infoArgs.join(" ")}`, formatResult(info))];
56
- if (parent) blocks.push(renderText("gt parent", formatResult(parent)));
57
- if (children) blocks.push(renderText("gt children", formatResult(children)));
76
+ if (p.stat && !p.diff && !p.patch) {
77
+ sections.push({
78
+ label: "gt info --stat",
79
+ heading: "## diffstat",
80
+ args: ["info", ...branchArg, "--stat"],
81
+ });
82
+ }
83
+
84
+ if (p.body) {
85
+ sections.push({
86
+ label: "gt info --body",
87
+ heading: "## body",
88
+ args: ["info", ...branchArg, "--body"],
89
+ });
90
+ }
91
+
92
+ if (p.diff) {
93
+ const args = ["info", ...branchArg, "--diff"];
94
+ if (p.stat) args.push("--stat");
95
+ sections.push({ label: "gt info --diff", heading: "## diff", args });
96
+ } else if (p.patch) {
97
+ const args = ["info", ...branchArg, "--patch"];
98
+ if (p.stat) args.push("--stat");
99
+ sections.push({ label: "gt info --patch", heading: "## patch", args });
100
+ }
101
+
102
+ const results = await Promise.all(
103
+ sections.map((s) => runGt(s.args, { cwd: p.cwd, signal })),
104
+ );
105
+
106
+ const formatted = await ensureAllSuccess(
107
+ sections.map((s, i) => ({ label: s.label, result: results[i] })),
108
+ p.cwd,
109
+ );
110
+
111
+ // Compose section-by-section structured output.
112
+ const blocks: string[] = [];
113
+ formatted.forEach((f, i) => {
114
+ const s = sections[i];
115
+ if (s.heading) blocks.push(s.heading);
116
+ blocks.push(stripChrome(f.result.stdout));
117
+ blocks.push("");
118
+ });
119
+ blocks.push("--- raw ---");
120
+ formatted.forEach((f, i) => {
121
+ blocks.push(renderText(sections[i].label, f));
122
+ blocks.push("");
123
+ });
124
+
125
+ const details: Record<string, FormattedResult> = {};
126
+ sections.forEach((s, i) => {
127
+ const key = s.label.replace(/^gt\s+/, "").replace(/[^a-z0-9]/gi, "_");
128
+ details[key] = formatted[i];
129
+ });
58
130
 
59
131
  return {
60
- content: [{ type: "text", text: blocks.join("\n\n") }],
61
- details: {
62
- info: formatResult(info),
63
- parent: parent ? formatResult(parent) : undefined,
64
- children: children ? formatResult(children) : undefined,
65
- },
132
+ content: [{ type: "text", text: blocks.join("\n").trim() }],
133
+ details,
66
134
  };
67
135
  },
68
136
  });
69
137
  }
70
138
 
139
+ function stripChrome(s: string): string {
140
+ return s.replace(/\s+$/, "");
141
+ }
142
+
71
143
  /* --------------------------- branch_create --------------------------- */
72
144
 
73
145
  export function registerBranchCreate(pi: ExtensionAPI) {
@@ -107,10 +179,11 @@ export function registerBranchCreate(pi: ExtensionAPI) {
107
179
  if (p.onto) args.push("--onto", p.onto);
108
180
  if (p.insert) args.push("--insert");
109
181
  args.push(p.ai ? "--ai" : "--no-ai");
182
+ const label = `gt ${args.join(" ")}`;
110
183
  const r = await runGt(args, { cwd: p.cwd, signal });
111
- const f = formatResult(r);
184
+ const f = await ensureSuccess(label, r, p.cwd);
112
185
  return {
113
- content: [{ type: "text", text: renderText(`gt ${args.join(" ")}`, f) }],
186
+ content: [{ type: "text", text: renderText(label, f) }],
114
187
  details: { result: f },
115
188
  };
116
189
  },
@@ -224,10 +297,11 @@ export function registerBranchUpdate(pi: ExtensionAPI) {
224
297
  }
225
298
  }
226
299
 
227
- const r = await runGt(args, { cwd: p.cwd, signal });
228
- const f = formatResult(r);
300
+ const label = `gt ${args.join(" ")}`;
301
+ const r: GtRunResult = await runGt(args, { cwd: p.cwd, signal });
302
+ const f = await ensureSuccess(label, r, p.cwd);
229
303
  return {
230
- content: [{ type: "text", text: renderText(`gt ${args.join(" ")}`, f) }],
304
+ content: [{ type: "text", text: renderText(label, f) }],
231
305
  details: { action: p.action, result: f },
232
306
  };
233
307
  },
@@ -276,10 +350,11 @@ export function registerBranchTracking(pi: ExtensionAPI) {
276
350
  if (p.branch) args.push(p.branch);
277
351
  break;
278
352
  }
353
+ const label = `gt ${args.join(" ")}`;
279
354
  const r = await runGt(args, { cwd: p.cwd, signal });
280
- const f = formatResult(r);
355
+ const f = await ensureSuccess(label, r, p.cwd);
281
356
  return {
282
- content: [{ type: "text", text: renderText(`gt ${args.join(" ")}`, f) }],
357
+ content: [{ type: "text", text: renderText(label, f) }],
283
358
  details: { action: p.action, result: f },
284
359
  };
285
360
  },
@@ -341,10 +416,11 @@ export function registerBranchNavigate(pi: ExtensionAPI) {
341
416
  args = ["bottom"];
342
417
  break;
343
418
  }
419
+ const label = `gt ${args.join(" ")}`;
344
420
  const r = await runGt(args, { cwd: p.cwd, signal });
345
- const f = formatResult(r);
421
+ const f = await ensureSuccess(label, r, p.cwd);
346
422
  return {
347
- content: [{ type: "text", text: renderText(`gt ${args.join(" ")}`, f) }],
423
+ content: [{ type: "text", text: renderText(label, f) }],
348
424
  details: { action: p.action, result: f },
349
425
  };
350
426
  },
package/src/tools/pr.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { runGt } from "../lib/exec";
3
- import { formatResult, renderText } from "../lib/result";
3
+ import { ensureSuccess, renderText } from "../lib/result";
4
4
  import {
5
5
  CwdParam,
6
6
  StringEnum,
@@ -11,6 +11,12 @@ import {
11
11
 
12
12
  /* ------------------------------ pr_submit ------------------------------ */
13
13
 
14
+ function shellQuote(s: string): string {
15
+ if (s === "") return "''";
16
+ if (/^[A-Za-z0-9_./:@%+=-]+$/.test(s)) return s;
17
+ return `'${s.replace(/'/g, `'"'"'`)}'`;
18
+ }
19
+
14
20
  export function registerPrSubmit(pi: ExtensionAPI) {
15
21
  pi.registerTool({
16
22
  name: "graphite_pr_submit",
@@ -21,6 +27,7 @@ export function registerPrSubmit(pi: ExtensionAPI) {
21
27
  "graphite_pr_submit: plan or apply `gt submit` for a branch or stack",
22
28
  promptGuidelines: [
23
29
  "Always call graphite_pr_submit with apply:false (default) first to see the dry-run plan, then call again with apply:true and confirmRemote:true to actually submit.",
30
+ "`gt submit` cannot set PR title/body inline. If you pass `title`/`body` to graphite_pr_submit, the tool will return a `gh pr edit` command for you to run via bash to apply the metadata.",
24
31
  ],
25
32
  parameters: Type.Object({
26
33
  cwd: CwdParam,
@@ -62,12 +69,27 @@ export function registerPrSubmit(pi: ExtensionAPI) {
62
69
  ignoreOutOfSyncTrunk: Type.Optional(Type.Boolean()),
63
70
  view: Type.Optional(Type.Boolean({ description: "--view (open PR in browser after submit)." })),
64
71
  confirmRemote: Type.Optional(Type.Boolean()),
72
+
73
+ title: Type.Optional(
74
+ Type.String({
75
+ description:
76
+ "Desired PR title. `gt submit` has no inline flag for this; the tool emits a `gh pr edit` command to run after submit.",
77
+ }),
78
+ ),
79
+ body: Type.Optional(
80
+ Type.String({
81
+ description:
82
+ "Desired PR body. `gt submit` has no inline flag for this; the tool emits a `gh pr edit` command to run after submit.",
83
+ }),
84
+ ),
65
85
  }),
66
86
  async execute(_id, p, signal): Promise<ToolReturn> {
67
87
  const apply = p.apply === true;
68
88
  if (apply) requireConfirm(p.confirmRemote, "gt submit (push branches + create/update PRs)");
69
89
  if (p.forcePush) requireConfirm(p.confirmRemote, "gt submit --force");
70
90
 
91
+ const wantsCustomMetadata = p.title != null || p.body != null;
92
+
71
93
  const args: string[] = ["submit"];
72
94
  if (!apply) args.push("--dry-run");
73
95
 
@@ -88,7 +110,10 @@ export function registerPrSubmit(pi: ExtensionAPI) {
88
110
  if (p.comment) args.push("--comment", p.comment);
89
111
  if (p.targetTrunk) args.push("--target-trunk", p.targetTrunk);
90
112
 
91
- const editMode = p.editMode ?? "none";
113
+ // If the caller supplied title/body, force --no-edit so gt doesn't try
114
+ // to prompt or open a web editor with conflicting metadata. The actual
115
+ // metadata is applied via the suggested `gh pr edit` command instead.
116
+ const editMode = wantsCustomMetadata ? "none" : (p.editMode ?? "none");
92
117
  if (editMode === "none") args.push("--no-edit");
93
118
  else if (editMode === "cli") args.push("--edit", "--cli");
94
119
  else if (editMode === "web") args.push("--web");
@@ -99,11 +124,45 @@ export function registerPrSubmit(pi: ExtensionAPI) {
99
124
  if (p.ignoreOutOfSyncTrunk) args.push("--ignore-out-of-sync-trunk");
100
125
  if (p.view) args.push("--view");
101
126
 
127
+ const label = `gt ${args.join(" ")}`;
102
128
  const r = await runGt(args, { cwd: p.cwd, signal });
103
- const f = formatResult(r);
129
+ const f = await ensureSuccess(label, r, p.cwd);
130
+
131
+ const blocks: string[] = [renderText(label, f)];
132
+
133
+ let metadataNote: string | undefined;
134
+ if (wantsCustomMetadata) {
135
+ const ghParts: string[] = ["gh", "pr", "edit"];
136
+ if (p.branch) ghParts.push(p.branch);
137
+ if (p.title != null) ghParts.push("--title", shellQuote(p.title));
138
+ if (p.body != null) ghParts.push("--body", shellQuote(p.body));
139
+ const ghCmd = ghParts.join(" ");
140
+
141
+ metadataNote = [
142
+ "## metadata note",
143
+ "`gt submit` has no flag to set PR title/body inline.",
144
+ "To apply the title/body you supplied, run the following via the bash tool" +
145
+ " (gt does not run gh for you; this keeps the tool composable):",
146
+ ghCmd,
147
+ p.stack === true || (!p.branch && p.stack !== false)
148
+ ? "If this submit covered multiple PRs, repeat `gh pr edit <branch>` for each PR that needs metadata."
149
+ : undefined,
150
+ ]
151
+ .filter((x): x is string => Boolean(x))
152
+ .join("\n");
153
+
154
+ blocks.push(metadataNote);
155
+ }
156
+
104
157
  return {
105
- content: [{ type: "text", text: renderText(`gt ${args.join(" ")}`, f) }],
106
- details: { apply, result: f },
158
+ content: [{ type: "text", text: blocks.join("\n\n") }],
159
+ details: {
160
+ apply,
161
+ editMode,
162
+ wantsCustomMetadata,
163
+ metadataNote,
164
+ result: f,
165
+ },
107
166
  };
108
167
  },
109
168
  });
@@ -139,40 +198,27 @@ export function registerPrLifecycle(pi: ExtensionAPI) {
139
198
  confirmRemote: Type.Optional(Type.Boolean()),
140
199
  }),
141
200
  async execute(_id, p, signal): Promise<ToolReturn> {
201
+ let args: string[];
142
202
  if (p.action === "open_url") {
143
- const args = ["pr"];
203
+ args = ["pr"];
144
204
  if (p.branch) args.push(p.branch);
145
205
  if (p.stack) args.push("--stack");
146
- const r = await runGt(args, { cwd: p.cwd, signal });
147
- const f = formatResult(r);
148
- return {
149
- content: [{ type: "text", text: renderText(`gt ${args.join(" ")}`, f) }],
150
- details: { result: f },
151
- };
152
- }
153
-
154
- if (p.action === "merge") {
206
+ } else if (p.action === "merge") {
155
207
  const apply = p.apply === true;
156
208
  if (apply) requireConfirm(p.confirmRemote, "gt merge (merges PRs on GitHub)");
157
- const args = ["merge"];
209
+ args = ["merge"];
158
210
  if (!apply) args.push("--dry-run");
159
211
  if (p.confirm) args.push("--confirm");
160
- const r = await runGt(args, { cwd: p.cwd, signal });
161
- const f = formatResult(r);
162
- return {
163
- content: [{ type: "text", text: renderText(`gt ${args.join(" ")}`, f) }],
164
- details: { apply, result: f },
165
- };
212
+ } else {
213
+ args = ["unlink"];
214
+ if (p.branch) args.push(p.branch);
166
215
  }
167
-
168
- // unlink
169
- const args = ["unlink"];
170
- if (p.branch) args.push(p.branch);
216
+ const label = `gt ${args.join(" ")}`;
171
217
  const r = await runGt(args, { cwd: p.cwd, signal });
172
- const f = formatResult(r);
218
+ const f = await ensureSuccess(label, r, p.cwd);
173
219
  return {
174
- content: [{ type: "text", text: renderText(`gt ${args.join(" ")}`, f) }],
175
- details: { result: f },
220
+ content: [{ type: "text", text: renderText(label, f) }],
221
+ details: { action: p.action, result: f },
176
222
  };
177
223
  },
178
224
  });
@@ -1,6 +1,6 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { runGt } from "../lib/exec";
3
- import { formatResult, renderText } from "../lib/result";
3
+ import { ensureSuccess, renderText } from "../lib/result";
4
4
  import { CwdParam, StringEnum, Type, type ToolReturn } from "../lib/schema";
5
5
 
6
6
  export function registerRecovery(pi: ExtensionAPI) {
@@ -41,10 +41,11 @@ export function registerRecovery(pi: ExtensionAPI) {
41
41
  if (p.force) args.push("--force");
42
42
  break;
43
43
  }
44
+ const label = `gt ${args.join(" ")}`;
44
45
  const r = await runGt(args, { cwd: p.cwd, signal });
45
- const f = formatResult(r);
46
+ const f = await ensureSuccess(label, r, p.cwd);
46
47
  return {
47
- content: [{ type: "text", text: renderText(`gt ${args.join(" ")}`, f) }],
48
+ content: [{ type: "text", text: renderText(label, f) }],
48
49
  details: { action: p.action, result: f },
49
50
  };
50
51
  },
@@ -1,6 +1,6 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { runGt } from "../lib/exec";
3
- import { formatResult, renderText } from "../lib/result";
3
+ import { ensureSuccess, renderText } from "../lib/result";
4
4
  import {
5
5
  CwdParam,
6
6
  StringEnum,
@@ -96,10 +96,11 @@ export function registerRemoteSync(pi: ExtensionAPI) {
96
96
  if (p.unfrozen) args.push("--unfrozen");
97
97
  }
98
98
 
99
+ const label = `gt ${args.join(" ")}`;
99
100
  const r = await runGt(args, { cwd: p.cwd, signal });
100
- const f = formatResult(r);
101
+ const f = await ensureSuccess(label, r, p.cwd);
101
102
  return {
102
- content: [{ type: "text", text: renderText(`gt ${args.join(" ")}`, f) }],
103
+ content: [{ type: "text", text: renderText(label, f) }],
103
104
  details: { action: p.action, result: f },
104
105
  };
105
106
  },
package/src/tools/repo.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { runGt } from "../lib/exec";
3
- import { formatResult, renderText } from "../lib/result";
3
+ import { ensureAllSuccess, ensureSuccess, renderText } from "../lib/result";
4
4
  import { CwdParam, StringEnum, Type, type ToolReturn } from "../lib/schema";
5
5
 
6
6
  const params = Type.Object({
@@ -42,8 +42,13 @@ export function registerRepo(pi: ExtensionAPI) {
42
42
  runGt(["trunk"], { cwd, signal }),
43
43
  runGt(["log", "short"], { cwd, signal }),
44
44
  ]);
45
- const ft = formatResult(trunk);
46
- const fl = formatResult(log);
45
+ const [ft, fl] = await ensureAllSuccess(
46
+ [
47
+ { label: "gt trunk", result: trunk },
48
+ { label: "gt log short", result: log },
49
+ ],
50
+ cwd,
51
+ );
47
52
  return {
48
53
  content: [
49
54
  {
@@ -60,40 +65,34 @@ export function registerRepo(pi: ExtensionAPI) {
60
65
  if (p.trunk) args.push("--trunk", p.trunk);
61
66
  if (p.reset) args.push("--reset");
62
67
  const r = await runGt(args, { cwd, signal });
63
- const f = formatResult(r);
68
+ const f = await ensureSuccess(`gt ${args.join(" ")}`, r, cwd);
64
69
  return {
65
- content: [{ type: "text", text: renderText("gt init", f) }],
70
+ content: [{ type: "text", text: renderText(`gt ${args.join(" ")}`, f) }],
66
71
  details: { result: f },
67
72
  };
68
73
  }
69
74
 
70
75
  if (p.action === "set_trunk") {
71
76
  if (!p.trunk) throw new Error("action=set_trunk requires `trunk`.");
72
- const args = ["trunk"];
73
- if (p.addAdditionalTrunk) args.push("--add");
74
- // gt trunk --add prompts for trunk name; not all gt versions accept positional.
75
- // Fall back to gt init --trunk for non-additional sets.
76
77
  if (!p.addAdditionalTrunk) {
77
- const r = await runGt(["init", "--trunk", p.trunk], { cwd, signal });
78
- const f = formatResult(r);
78
+ const args = ["init", "--trunk", p.trunk];
79
+ const r = await runGt(args, { cwd, signal });
80
+ const f = await ensureSuccess(`gt ${args.join(" ")}`, r, cwd);
79
81
  return {
80
82
  content: [
81
- { type: "text", text: renderText(`gt init --trunk ${p.trunk}`, f) },
83
+ { type: "text", text: renderText(`gt ${args.join(" ")}`, f) },
82
84
  ],
83
85
  details: { result: f },
84
86
  };
85
87
  }
86
- // Additional trunk: `gt trunk --add` typically prompts; with
87
- // --no-interactive it will fail fast. Surface that failure to the
88
- // caller instead of pretending it worked.
89
- const r = await runGt(args, { cwd, signal });
90
- const f = formatResult(r);
88
+ // `gt trunk --add` is interactive (prompts for the new trunk name).
89
+ // With --no-interactive this will fail; surface that failure rather
90
+ // than silently misleading the caller.
91
+ const r = await runGt(["trunk", "--add"], { cwd, signal });
92
+ const f = await ensureSuccess("gt trunk --add", r, cwd);
91
93
  return {
92
94
  content: [{ type: "text", text: renderText("gt trunk --add", f) }],
93
- details: {
94
- result: f,
95
- note: "`gt trunk --add` is interactive; this call runs with --no-interactive and may fail. Add the trunk via `gt config` or `gt init --trunk` instead.",
96
- },
95
+ details: { result: f },
97
96
  };
98
97
  }
99
98
 
@@ -102,20 +101,21 @@ export function registerRepo(pi: ExtensionAPI) {
102
101
  runGt(["trunk"], { cwd, signal }),
103
102
  runGt(["trunk", "--all"], { cwd, signal }),
104
103
  ]);
104
+ const [ft, fta] = await ensureAllSuccess(
105
+ [
106
+ { label: "gt trunk", result: trunk },
107
+ { label: "gt trunk --all", result: trunkAll },
108
+ ],
109
+ cwd,
110
+ );
105
111
  return {
106
112
  content: [
107
113
  {
108
114
  type: "text",
109
- text: [
110
- renderText("gt trunk", formatResult(trunk)),
111
- renderText("gt trunk --all", formatResult(trunkAll)),
112
- ].join("\n\n"),
115
+ text: [renderText("gt trunk", ft), renderText("gt trunk --all", fta)].join("\n\n"),
113
116
  },
114
117
  ],
115
- details: {
116
- trunk: formatResult(trunk),
117
- trunkAll: formatResult(trunkAll),
118
- },
118
+ details: { trunk: ft, trunkAll: fta },
119
119
  };
120
120
  },
121
121
  });
@@ -1,6 +1,6 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { runGt } from "../lib/exec";
3
- import { formatResult, renderText } from "../lib/result";
3
+ import { ensureSuccess, renderText } from "../lib/result";
4
4
  import {
5
5
  CwdParam,
6
6
  StringEnum,
@@ -29,7 +29,7 @@ export function registerStackView(pi: ExtensionAPI) {
29
29
  scope: Type.Optional(
30
30
  StringEnum(["all_trunks", "current_stack", "default"] as const, {
31
31
  description:
32
- "all_trunks adds --all, current_stack adds --stack, default does neither.",
32
+ "all_trunks adds --all (only supported on mode=full/default). current_stack adds --stack. default does neither.",
33
33
  }),
34
34
  ),
35
35
  showUntracked: Type.Optional(Type.Boolean()),
@@ -37,17 +37,34 @@ export function registerStackView(pi: ExtensionAPI) {
37
37
  steps: Type.Optional(Type.Integer({ minimum: 1 })),
38
38
  }),
39
39
  async execute(_id, p, signal): Promise<ToolReturn> {
40
- const sub = p.mode === "short" ? ["log", "short"] : p.mode === "long" ? ["log", "long"] : ["log"];
40
+ // Empirically, current gt rejects `--all` on `gt log short` / `gt log long`
41
+ // ("Unknown argument: all"). Block that combination at the tool layer
42
+ // instead of exposing it to the model.
43
+ if (p.scope === "all_trunks" && (p.mode === "short" || p.mode === "long")) {
44
+ throw new Error(
45
+ "graphite_stack_view: scope='all_trunks' is not supported with mode='short'/'long' " +
46
+ "(current `gt` rejects `--all` on those forms). Omit `mode` (or use mode='full') to use --all.",
47
+ );
48
+ }
49
+
50
+ const sub =
51
+ p.mode === "short"
52
+ ? ["log", "short"]
53
+ : p.mode === "long"
54
+ ? ["log", "long"]
55
+ : ["log"];
41
56
  const args = [...sub];
42
57
  if (p.scope === "all_trunks") args.push("--all");
43
58
  if (p.scope === "current_stack") args.push("--stack");
44
59
  if (p.showUntracked) args.push("--show-untracked");
45
60
  if (p.reverse) args.push("--reverse");
46
61
  if (p.steps != null) args.push("--steps", String(p.steps));
62
+
63
+ const label = `gt ${args.join(" ")}`;
47
64
  const r = await runGt(args, { cwd: p.cwd, signal });
48
- const f = formatResult(r);
65
+ const f = await ensureSuccess(label, r, p.cwd);
49
66
  return {
50
- content: [{ type: "text", text: renderText(`gt ${args.join(" ")}`, f) }],
67
+ content: [{ type: "text", text: renderText(label, f) }],
51
68
  details: { result: f },
52
69
  };
53
70
  },
@@ -85,10 +102,12 @@ export function registerStackRestack(pi: ExtensionAPI) {
85
102
  if (p.scope === "downstack") args.push("--downstack");
86
103
  else if (p.scope === "upstack") args.push("--upstack");
87
104
  else if (p.scope === "only") args.push("--only");
105
+
106
+ const label = `gt ${args.join(" ")}`;
88
107
  const r = await runGt(args, { cwd: p.cwd, signal });
89
- const f = formatResult(r);
108
+ const f = await ensureSuccess(label, r, p.cwd);
90
109
  return {
91
- content: [{ type: "text", text: renderText(`gt ${args.join(" ")}`, f) }],
110
+ content: [{ type: "text", text: renderText(label, f) }],
92
111
  details: { result: f },
93
112
  };
94
113
  },
@@ -132,44 +151,34 @@ export function registerStackReorganize(pi: ExtensionAPI) {
132
151
  confirmRemote: Type.Optional(Type.Boolean()),
133
152
  }),
134
153
  async execute(_id, p, signal): Promise<ToolReturn> {
154
+ let args: string[];
135
155
  if (p.action === "move_branch") {
136
156
  if (!p.onto) throw new Error("action=move_branch requires `onto`.");
137
- const args = ["move", "--onto", p.onto];
157
+ args = ["move", "--onto", p.onto];
138
158
  if (p.source) args.push("--source", p.source);
139
159
  if (p.onlyMove) args.push("--only");
140
- const r = await runGt(args, { cwd: p.cwd, signal });
141
- const f = formatResult(r);
142
- return {
143
- content: [{ type: "text", text: renderText(`gt ${args.join(" ")}`, f) }],
144
- details: { result: f },
145
- };
146
- }
147
-
148
- if (p.action === "fold") {
160
+ } else if (p.action === "fold") {
149
161
  if (p.foldClose) requireConfirm(p.confirmRemote, "fold --close");
150
- const args = ["fold"];
162
+ args = ["fold"];
151
163
  if (p.foldKeep) args.push("--keep");
152
164
  if (p.foldStack) args.push("--stack");
153
165
  if (p.foldClose) args.push("--close");
154
- const r = await runGt(args, { cwd: p.cwd, signal });
155
- const f = formatResult(r);
156
- return {
157
- content: [{ type: "text", text: renderText(`gt ${args.join(" ")}`, f) }],
158
- details: { result: f },
159
- };
166
+ } else {
167
+ if (!p.filePatterns || p.filePatterns.length === 0) {
168
+ throw new Error(
169
+ "action=split_by_file requires `filePatterns` (one or more pathspecs).",
170
+ );
171
+ }
172
+ args = ["split", "--by-file"];
173
+ for (const pat of p.filePatterns) args.push("-f", pat);
160
174
  }
161
175
 
162
- // split_by_file
163
- if (!p.filePatterns || p.filePatterns.length === 0) {
164
- throw new Error("action=split_by_file requires `filePatterns` (one or more pathspecs).");
165
- }
166
- const args = ["split", "--by-file"];
167
- for (const pat of p.filePatterns) args.push("-f", pat);
176
+ const label = `gt ${args.join(" ")}`;
168
177
  const r = await runGt(args, { cwd: p.cwd, signal });
169
- const f = formatResult(r);
178
+ const f = await ensureSuccess(label, r, p.cwd);
170
179
  return {
171
- content: [{ type: "text", text: renderText(`gt ${args.join(" ")}`, f) }],
172
- details: { result: f },
180
+ content: [{ type: "text", text: renderText(label, f) }],
181
+ details: { action: p.action, result: f },
173
182
  };
174
183
  },
175
184
  });