pi-graphite 0.0.1 → 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/README.md CHANGED
@@ -1,3 +1,76 @@
1
1
  # pi-graphite
2
2
 
3
- Reserved package name for pi-graphite.
3
+ Structured pi tools that wrap the [Graphite](https://graphite.com) `gt` CLI so
4
+ agents (and humans) can drive stacked PR workflows safely from pi.
5
+
6
+ This package is **Layer A** of the planned design — one tool per Graphite
7
+ domain resource (repo / stack / branch / PR / recovery), not one tool per `gt`
8
+ subcommand and not a workflow orchestrator.
9
+
10
+ ## Requirements
11
+
12
+ - `gt` CLI installed and authenticated (`gt auth`).
13
+ - A pi runtime that loads npm or local pi packages.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ # global
19
+ pi install npm:pi-graphite
20
+
21
+ # project-local
22
+ pi install -l npm:pi-graphite
23
+
24
+ # from a local checkout
25
+ pi install /path/to/pi-graphite
26
+ # or, for one session
27
+ pi -e /path/to/pi-graphite
28
+ ```
29
+
30
+ ## Registered tools
31
+
32
+ | Tool | Resource | Wraps |
33
+ | ---------------------------- | -------- | ------------------------------------------------- |
34
+ | `graphite_repo` | repo | `gt trunk`, `gt init`, `gt log short` |
35
+ | `graphite_stack_view` | stack | `gt log` / `gt log short` / `gt log long` |
36
+ | `graphite_stack_restack` | stack | `gt restack` (+ `--branch/--downstack/--upstack/--only`) |
37
+ | `graphite_stack_reorganize` | stack | `gt move`, `gt fold`, `gt split --by-file` |
38
+ | `graphite_branch_inspect` | branch | `gt info` (+ `gt parent`, `gt children`) |
39
+ | `graphite_branch_create` | branch | `gt create` |
40
+ | `graphite_branch_update` | branch | `gt modify`, `gt absorb`, `gt squash`, `gt pop`, `gt rename`, `gt delete` |
41
+ | `graphite_branch_tracking` | branch | `gt track`, `gt untrack`, `gt freeze`, `gt unfreeze` |
42
+ | `graphite_branch_navigate` | branch | `gt checkout`, `gt up`, `gt down`, `gt top`, `gt bottom` |
43
+ | `graphite_remote_sync` | remote | `gt sync`, `gt get` |
44
+ | `graphite_pr_submit` | PR | `gt submit` (dry-run by default) |
45
+ | `graphite_pr_lifecycle` | PR | `gt pr`, `gt merge`, `gt unlink` |
46
+ | `graphite_recovery` | recovery | `gt continue`, `gt abort`, `gt undo` |
47
+
48
+ ## Conventions
49
+
50
+ - Every tool requires an absolute `cwd`.
51
+ - `gt` is invoked with `--cwd <cwd> --no-interactive` by default. No shell strings.
52
+ - Remote / destructive operations require explicit ack flags:
53
+ - `graphite_pr_submit` defaults to `--dry-run`; `apply: true` needs `confirmRemote: true`.
54
+ - `graphite_pr_lifecycle action=merge` defaults to `--dry-run`; `apply: true` needs `confirmRemote: true`.
55
+ - `graphite_remote_sync` with `force` or `deleteAll` needs `confirmDestructive: true`.
56
+ - `graphite_branch_update action=delete close:true` needs `confirmRemote: true`.
57
+ - `graphite_stack_reorganize action=fold foldClose:true` needs `confirmRemote: true`.
58
+ - Output is ANSI-stripped and truncated to ~50 KB / 2000 lines.
59
+ - Stderr is parsed into structured `hints` (e.g. `notInitialized`, `conflictHalted`,
60
+ `checkedOutElsewhere`, `restackNeeded`, `trunkOutOfSync`).
61
+
62
+ ## Intentional non-goals (in Layer A)
63
+
64
+ - No `graphite_raw` passthrough. Use bash for arbitrary `gt` flags.
65
+ - No workflow orchestration (plan → apply across multiple commands).
66
+ - No wrapping of `gt add/cherry-pick/rebase/reset/restore` passthroughs.
67
+ - No wrapping of browser/help commands (`dash`, `docs`, `guide`, `changelog`,
68
+ `feedback`, `demo`, `completion`, `fish`).
69
+ - `gt reorder` (editor-only) and `gt split --by-commit / --by-hunk`
70
+ (interactive-only) are intentionally not exposed.
71
+
72
+ Layer B (workflow tools) and Layer C (raw escape hatch) are planned, not built.
73
+
74
+ ## License
75
+
76
+ MIT
package/package.json CHANGED
@@ -1,20 +1,31 @@
1
1
  {
2
2
  "name": "pi-graphite",
3
- "version": "0.0.1",
4
- "description": "Reserved package name for pi-graphite.",
5
- "main": "index.js",
3
+ "version": "0.1.1",
4
+ "description": "Structured pi tools for the Graphite (gt) CLI.",
5
+ "keywords": [
6
+ "pi",
7
+ "pi-package",
8
+ "graphite",
9
+ "gt",
10
+ "stacked-prs"
11
+ ],
12
+ "license": "MIT",
6
13
  "files": [
7
- "index.js",
14
+ "src",
8
15
  "README.md",
9
16
  "LICENSE"
10
17
  ],
18
+ "pi": {
19
+ "extensions": [
20
+ "./src/index.ts"
21
+ ]
22
+ },
23
+ "peerDependencies": {
24
+ "@earendil-works/pi-ai": "*",
25
+ "@earendil-works/pi-coding-agent": "*",
26
+ "typebox": "*"
27
+ },
11
28
  "scripts": {
12
29
  "test": "node -e \"console.log('no tests')\""
13
- },
14
- "keywords": [
15
- "pi",
16
- "graphite"
17
- ],
18
- "author": "",
19
- "license": "MIT"
30
+ }
20
31
  }
package/src/index.ts ADDED
@@ -0,0 +1,65 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ import { registerRepo } from "./tools/repo";
4
+ import {
5
+ registerStackView,
6
+ registerStackRestack,
7
+ registerStackReorganize,
8
+ } from "./tools/stack";
9
+ import {
10
+ registerBranchInspect,
11
+ registerBranchCreate,
12
+ registerBranchUpdate,
13
+ registerBranchTracking,
14
+ registerBranchNavigate,
15
+ } from "./tools/branch";
16
+ import { registerRemoteSync } from "./tools/remote";
17
+ import { registerPrSubmit, registerPrLifecycle } from "./tools/pr";
18
+ import { registerRecovery } from "./tools/recovery";
19
+
20
+ /**
21
+ * pi-graphite — Layer A (Domain Resource).
22
+ *
23
+ * Registers structured tools that wrap the Graphite (`gt`) CLI:
24
+ *
25
+ * graphite_repo
26
+ * graphite_stack_view
27
+ * graphite_stack_restack
28
+ * graphite_stack_reorganize
29
+ * graphite_branch_inspect
30
+ * graphite_branch_create
31
+ * graphite_branch_update
32
+ * graphite_branch_tracking
33
+ * graphite_branch_navigate
34
+ * graphite_remote_sync
35
+ * graphite_pr_submit
36
+ * graphite_pr_lifecycle
37
+ * graphite_recovery
38
+ *
39
+ * Conventions:
40
+ * - Every tool requires absolute `cwd`.
41
+ * - `gt` is invoked with --cwd <cwd> --no-interactive by default.
42
+ * - Remote / destructive operations require explicit `confirmRemote` /
43
+ * `confirmDestructive` flags. Submit/merge default to dry-run.
44
+ * - Output is ANSI-stripped and truncated to ~50KB / 2000 lines.
45
+ */
46
+ export default function (pi: ExtensionAPI) {
47
+ registerRepo(pi);
48
+
49
+ registerStackView(pi);
50
+ registerStackRestack(pi);
51
+ registerStackReorganize(pi);
52
+
53
+ registerBranchInspect(pi);
54
+ registerBranchCreate(pi);
55
+ registerBranchUpdate(pi);
56
+ registerBranchTracking(pi);
57
+ registerBranchNavigate(pi);
58
+
59
+ registerRemoteSync(pi);
60
+
61
+ registerPrSubmit(pi);
62
+ registerPrLifecycle(pi);
63
+
64
+ registerRecovery(pi);
65
+ }
@@ -0,0 +1,132 @@
1
+ import { spawn, type ChildProcessByStdio } from "node:child_process";
2
+ import type { Readable } from "node:stream";
3
+ import { resolve as resolvePath } from "node:path";
4
+
5
+ export interface GtRunOptions {
6
+ cwd: string;
7
+ signal?: AbortSignal;
8
+ /** Extra env vars merged into process.env */
9
+ env?: Record<string, string>;
10
+ }
11
+
12
+ export interface GtRunResult {
13
+ command: string;
14
+ args: string[];
15
+ cwd: string;
16
+ exitCode: number;
17
+ stdout: string;
18
+ stderr: string;
19
+ timedOut: boolean;
20
+ spawnError?: string;
21
+ }
22
+
23
+ const ANSI = /\x1b\[[0-9;]*[a-zA-Z]/g;
24
+ const MAX_BYTES = 50 * 1024;
25
+ const MAX_LINES = 2000;
26
+
27
+ export function stripAnsi(s: string): string {
28
+ return s.replace(ANSI, "");
29
+ }
30
+
31
+ export function truncateOutput(s: string): string {
32
+ if (s.length <= MAX_BYTES) {
33
+ const lines = s.split("\n");
34
+ if (lines.length <= MAX_LINES) return s;
35
+ const kept = lines.slice(0, MAX_LINES).join("\n");
36
+ return `${kept}\n... [truncated: ${lines.length - MAX_LINES} more lines]`;
37
+ }
38
+ const kept = s.slice(0, MAX_BYTES);
39
+ return `${kept}\n... [truncated: ${s.length - MAX_BYTES} more bytes]`;
40
+ }
41
+
42
+ /**
43
+ * Run `gt` with structured args. Never builds a shell string.
44
+ *
45
+ * - Always injects --cwd <abs>.
46
+ * - Always injects --no-interactive. No escape hatch by design: agent-driven
47
+ * tools must never block on a TTY prompt.
48
+ * - Does not inject --quiet (we want stderr diagnostics).
49
+ */
50
+ export async function runGt(
51
+ rawArgs: string[],
52
+ opts: GtRunOptions,
53
+ ): Promise<GtRunResult> {
54
+ const cwd = resolvePath(opts.cwd);
55
+ const args = ["--cwd", cwd, "--no-interactive"];
56
+ args.push(...rawArgs);
57
+
58
+ return new Promise<GtRunResult>((resolve) => {
59
+ let child: ChildProcessByStdio<null, Readable, Readable>;
60
+ try {
61
+ child = spawn("gt", args, {
62
+ cwd,
63
+ env: { ...process.env, ...(opts.env ?? {}) },
64
+ stdio: ["ignore", "pipe", "pipe"],
65
+ });
66
+ } catch (e) {
67
+ resolve({
68
+ command: "gt",
69
+ args,
70
+ cwd,
71
+ exitCode: -1,
72
+ stdout: "",
73
+ stderr: "",
74
+ timedOut: false,
75
+ spawnError: (e as Error).message,
76
+ });
77
+ return;
78
+ }
79
+
80
+ let stdout = "";
81
+ let stderr = "";
82
+ let killed = false;
83
+
84
+ child.stdout?.on("data", (d) => {
85
+ stdout += d.toString();
86
+ if (stdout.length > MAX_BYTES * 4) stdout = stdout.slice(-MAX_BYTES * 2);
87
+ });
88
+ child.stderr?.on("data", (d) => {
89
+ stderr += d.toString();
90
+ if (stderr.length > MAX_BYTES * 4) stderr = stderr.slice(-MAX_BYTES * 2);
91
+ });
92
+
93
+ const onAbort = () => {
94
+ killed = true;
95
+ try {
96
+ child.kill("SIGTERM");
97
+ setTimeout(() => {
98
+ try {
99
+ child.kill("SIGKILL");
100
+ } catch {}
101
+ }, 1500).unref?.();
102
+ } catch {}
103
+ };
104
+ opts.signal?.addEventListener("abort", onAbort, { once: true });
105
+
106
+ child.on("error", (err) => {
107
+ resolve({
108
+ command: "gt",
109
+ args,
110
+ cwd,
111
+ exitCode: -1,
112
+ stdout: stripAnsi(stdout),
113
+ stderr: stripAnsi(stderr),
114
+ timedOut: false,
115
+ spawnError: err.message,
116
+ });
117
+ });
118
+
119
+ child.on("close", (code) => {
120
+ opts.signal?.removeEventListener("abort", onAbort);
121
+ resolve({
122
+ command: "gt",
123
+ args,
124
+ cwd,
125
+ exitCode: code ?? -1,
126
+ stdout: truncateOutput(stripAnsi(stdout)),
127
+ stderr: truncateOutput(stripAnsi(stderr)),
128
+ timedOut: killed,
129
+ });
130
+ });
131
+ });
132
+ }
@@ -0,0 +1,80 @@
1
+ import type { GtRunResult } from "./exec";
2
+
3
+ export interface GtHints {
4
+ notInGitRepo?: boolean;
5
+ notInitialized?: boolean;
6
+ notAuthenticated?: boolean;
7
+ conflictHalted?: boolean;
8
+ checkedOutElsewhere?: { branch?: string; worktree?: string };
9
+ restackNeeded?: boolean;
10
+ trunkOutOfSync?: boolean;
11
+ branchNotTracked?: boolean;
12
+ noChangesStaged?: boolean;
13
+ prMissing?: boolean;
14
+ }
15
+
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],
27
+ ];
28
+
29
+ export function parseHints(r: GtRunResult): GtHints {
30
+ const text = `${r.stdout}\n${r.stderr}`;
31
+ const hints: GtHints = {};
32
+ 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
+ }
41
+ }
42
+ return hints;
43
+ }
44
+
45
+ export interface FormattedResult {
46
+ ok: boolean;
47
+ result: GtRunResult;
48
+ hints: GtHints;
49
+ }
50
+
51
+ export function formatResult(r: GtRunResult): FormattedResult {
52
+ return {
53
+ ok: r.exitCode === 0 && !r.spawnError && !r.timedOut,
54
+ result: r,
55
+ hints: parseHints(r),
56
+ };
57
+ }
58
+
59
+ /** Render a formatted result into a single text block for the LLM. */
60
+ export function renderText(label: string, f: FormattedResult): string {
61
+ const r = f.result;
62
+ const lines: string[] = [];
63
+ 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}`);
66
+ if (r.stdout.trim()) {
67
+ lines.push("--- stdout ---");
68
+ lines.push(r.stdout.replace(/\s+$/, ""));
69
+ }
70
+ if (r.stderr.trim()) {
71
+ lines.push("--- stderr ---");
72
+ lines.push(r.stderr.replace(/\s+$/, ""));
73
+ }
74
+ const hintKeys = Object.keys(f.hints);
75
+ if (hintKeys.length) {
76
+ lines.push("--- hints ---");
77
+ lines.push(JSON.stringify(f.hints));
78
+ }
79
+ return `[${label}] ${f.ok ? "ok" : "fail"}\n${lines.join("\n")}`;
80
+ }
@@ -0,0 +1,50 @@
1
+ import { Type, type Static } from "typebox";
2
+ import { StringEnum } from "@earendil-works/pi-ai";
3
+
4
+ export { Type, StringEnum };
5
+ export type { Static };
6
+
7
+ /** Stage mode shared by create / modify. */
8
+ export const StageMode = StringEnum([
9
+ "none",
10
+ "all",
11
+ "update",
12
+ "patch",
13
+ ] as const);
14
+
15
+ export function stageArgs(mode: "none" | "all" | "update" | "patch"): string[] {
16
+ switch (mode) {
17
+ case "none":
18
+ return [];
19
+ case "all":
20
+ return ["--all"];
21
+ case "update":
22
+ return ["--update"];
23
+ case "patch":
24
+ return ["--patch"];
25
+ }
26
+ }
27
+
28
+ /** Common cwd param. Required so we always pass an absolute path to gt. */
29
+ export const CwdParam = Type.String({
30
+ description: "Absolute path to the repository working directory.",
31
+ });
32
+
33
+ /** Helper to require an explicit ack flag for irreversible/remote ops. */
34
+ export function requireConfirm(
35
+ flag: boolean | undefined,
36
+ what: string,
37
+ ): void {
38
+ if (!flag) {
39
+ throw new Error(
40
+ `Refused: ${what} would mutate remote or destructive state. Pass the matching confirm flag (e.g. confirmRemote: true) to proceed.`,
41
+ );
42
+ }
43
+ }
44
+
45
+ /** Shape returned to the LLM by every tool. */
46
+ export type ToolReturn = {
47
+ content: Array<{ type: "text"; text: string }>;
48
+ details: Record<string, unknown>;
49
+ isError?: boolean;
50
+ };