xtrm-cli 0.5.0 → 0.5.27
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/.pi/structured-returns/0e4a7405-1ac3-4ae1-8dbc-d31507b2e2e4.combined.log +17 -0
- package/.pi/structured-returns/0e4a7405-1ac3-4ae1-8dbc-d31507b2e2e4.stderr.log +0 -0
- package/.pi/structured-returns/0e4a7405-1ac3-4ae1-8dbc-d31507b2e2e4.stdout.log +17 -0
- package/dist/index.cjs +969 -1059
- package/dist/index.cjs.map +1 -1
- package/package.json +1 -1
- package/src/commands/clean.ts +7 -6
- package/src/commands/debug.ts +255 -0
- package/src/commands/docs.ts +180 -0
- package/src/commands/help.ts +92 -171
- package/src/commands/init.ts +9 -32
- package/src/commands/install-pi.ts +9 -16
- package/src/commands/install.ts +150 -2
- package/src/commands/pi-install.ts +10 -44
- package/src/core/context.ts +4 -52
- package/src/core/diff.ts +3 -16
- package/src/core/preflight.ts +0 -1
- package/src/index.ts +7 -4
- package/src/types/config.ts +0 -2
- package/src/utils/config-injector.ts +3 -3
- package/src/utils/pi-extensions.ts +41 -0
- package/src/utils/worktree-session.ts +86 -50
- package/test/extensions/beads-claim-lifecycle.test.ts +93 -0
- package/test/extensions/beads-parity.test.ts +94 -0
- package/test/extensions/extension-harness.ts +5 -5
- package/test/extensions/quality-gates-parity.test.ts +89 -0
- package/test/extensions/session-flow.test.ts +91 -0
- package/test/extensions/xtrm-loader.test.ts +38 -20
- package/test/install-pi.test.ts +22 -11
- package/test/pi-extensions.test.ts +50 -0
- package/test/session-launcher.test.ts +28 -38
- package/extensions/beads.ts +0 -109
- package/extensions/core/adapter.ts +0 -45
- package/extensions/core/lib.ts +0 -3
- package/extensions/core/logger.ts +0 -45
- package/extensions/core/runner.ts +0 -71
- package/extensions/custom-footer.ts +0 -160
- package/extensions/main-guard-post-push.ts +0 -44
- package/extensions/main-guard.ts +0 -126
- package/extensions/minimal-mode.ts +0 -201
- package/extensions/quality-gates.ts +0 -67
- package/extensions/service-skills.ts +0 -150
- package/extensions/xtrm-loader.ts +0 -89
- package/hooks/gitnexus-impact-reminder.py +0 -13
- package/src/commands/finish.ts +0 -25
- package/src/core/session-state.ts +0 -139
- package/src/core/xtrm-finish.ts +0 -267
- package/src/tests/session-flow-parity.test.ts +0 -118
- package/src/tests/session-state.test.ts +0 -124
- package/src/tests/xtrm-finish.test.ts +0 -148
|
@@ -1,201 +0,0 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
-
import {
|
|
3
|
-
createBashTool,
|
|
4
|
-
createEditTool,
|
|
5
|
-
createFindTool,
|
|
6
|
-
createGrepTool,
|
|
7
|
-
createLsTool,
|
|
8
|
-
createReadTool,
|
|
9
|
-
createWriteTool,
|
|
10
|
-
} from "@mariozechner/pi-coding-agent";
|
|
11
|
-
import { Text } from "@mariozechner/pi-tui";
|
|
12
|
-
|
|
13
|
-
function getTextContent(result: any): string {
|
|
14
|
-
if (!result?.content || !Array.isArray(result.content)) return "";
|
|
15
|
-
return result.content
|
|
16
|
-
.filter((c: any) => c?.type === "text" && typeof c.text === "string")
|
|
17
|
-
.map((c: any) => c.text)
|
|
18
|
-
.join("\n")
|
|
19
|
-
.trim();
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function oneLine(s: string): string {
|
|
23
|
-
return (s || "").replace(/\s+/g, " ").trim();
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function summarize(result: any): { text: string; isError: boolean } {
|
|
27
|
-
const raw = getTextContent(result);
|
|
28
|
-
if (!raw) return { text: "done", isError: false };
|
|
29
|
-
const line = oneLine(raw.split("\n").find((l) => l.trim()) || "");
|
|
30
|
-
const lower = line.toLowerCase();
|
|
31
|
-
const isError = lower.includes("error") || lower.includes("failed") || lower.includes("exception");
|
|
32
|
-
return { text: line.slice(0, 140) || "done", isError };
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const toolCache = new Map<string, ReturnType<typeof createBuiltInTools>>();
|
|
36
|
-
function createBuiltInTools(cwd: string) {
|
|
37
|
-
return {
|
|
38
|
-
read: createReadTool(cwd),
|
|
39
|
-
bash: createBashTool(cwd),
|
|
40
|
-
edit: createEditTool(cwd),
|
|
41
|
-
write: createWriteTool(cwd),
|
|
42
|
-
find: createFindTool(cwd),
|
|
43
|
-
grep: createGrepTool(cwd),
|
|
44
|
-
ls: createLsTool(cwd),
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
function getBuiltInTools(cwd: string) {
|
|
48
|
-
let tools = toolCache.get(cwd);
|
|
49
|
-
if (!tools) {
|
|
50
|
-
tools = createBuiltInTools(cwd);
|
|
51
|
-
toolCache.set(cwd, tools);
|
|
52
|
-
}
|
|
53
|
-
return tools;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export default function (pi: ExtensionAPI) {
|
|
57
|
-
let minimalEnabled = true;
|
|
58
|
-
let thinkingStatusEnabled = true;
|
|
59
|
-
let spinnerTimer: NodeJS.Timeout | null = null;
|
|
60
|
-
let spinnerIndex = 0;
|
|
61
|
-
const frames = ["thinking ", "thinking. ", "thinking.. ", "thinking..."];
|
|
62
|
-
|
|
63
|
-
const clearSpinner = (ctx: any) => {
|
|
64
|
-
if (spinnerTimer) {
|
|
65
|
-
clearInterval(spinnerTimer);
|
|
66
|
-
spinnerTimer = null;
|
|
67
|
-
}
|
|
68
|
-
if (ctx?.hasUI) {
|
|
69
|
-
ctx.ui.setStatus("thinking", undefined);
|
|
70
|
-
ctx.ui.setHeader(undefined);
|
|
71
|
-
}
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
const mountThinkingHeader = (ctx: any) => {
|
|
75
|
-
if (!ctx?.hasUI) return;
|
|
76
|
-
ctx.ui.setHeader((_tui: any, theme: any) => ({
|
|
77
|
-
invalidate() {},
|
|
78
|
-
render(width: number): string[] {
|
|
79
|
-
const text = frames[spinnerIndex];
|
|
80
|
-
return [oneLine(theme.fg("accent", text)).slice(0, width)];
|
|
81
|
-
},
|
|
82
|
-
}));
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
const startSpinner = (ctx: any) => {
|
|
86
|
-
if (!thinkingStatusEnabled || !ctx?.hasUI) return;
|
|
87
|
-
clearSpinner(ctx);
|
|
88
|
-
spinnerIndex = 0;
|
|
89
|
-
ctx.ui.setStatus("thinking", frames[spinnerIndex]);
|
|
90
|
-
mountThinkingHeader(ctx);
|
|
91
|
-
spinnerTimer = setInterval(() => {
|
|
92
|
-
spinnerIndex = (spinnerIndex + 1) % frames.length;
|
|
93
|
-
ctx.ui.setStatus("thinking", frames[spinnerIndex]);
|
|
94
|
-
mountThinkingHeader(ctx);
|
|
95
|
-
}, 220);
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
const renderCollapsedResult = (result: any, theme: any) => {
|
|
99
|
-
const s = summarize(result);
|
|
100
|
-
const icon = s.isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
|
|
101
|
-
const text = s.isError ? theme.fg("error", s.text) : theme.fg("muted", s.text);
|
|
102
|
-
if (minimalEnabled) return new Text(` ${icon} ${text}`, 0, 0);
|
|
103
|
-
return new Text(theme.fg("muted", ` → ${s.text}`), 0, 0);
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
const renderExpandedResult = (result: any, theme: any) => {
|
|
107
|
-
const text = getTextContent(result);
|
|
108
|
-
if (!text) return new Text("", 0, 0);
|
|
109
|
-
const output = text.split("\n").map((line) => theme.fg("toolOutput", line)).join("\n");
|
|
110
|
-
return new Text(`\n${output}`, 0, 0);
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
pi.registerTool({
|
|
114
|
-
name: "bash",
|
|
115
|
-
label: "bash",
|
|
116
|
-
description: getBuiltInTools(process.cwd()).bash.description,
|
|
117
|
-
parameters: getBuiltInTools(process.cwd()).bash.parameters,
|
|
118
|
-
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
119
|
-
return getBuiltInTools(ctx.cwd).bash.execute(toolCallId, params, signal, onUpdate);
|
|
120
|
-
},
|
|
121
|
-
renderCall(args, theme) {
|
|
122
|
-
const cmd = oneLine(args.command || "");
|
|
123
|
-
return new Text(`${theme.fg("toolTitle", theme.bold("bash"))}(${theme.fg("accent", cmd || "...")})`, 0, 0);
|
|
124
|
-
},
|
|
125
|
-
renderResult(result, { expanded }, theme) {
|
|
126
|
-
return expanded ? renderExpandedResult(result, theme) : renderCollapsedResult(result, theme);
|
|
127
|
-
},
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
for (const name of ["read", "write", "edit", "find", "grep", "ls"] as const) {
|
|
131
|
-
pi.registerTool({
|
|
132
|
-
name,
|
|
133
|
-
label: name,
|
|
134
|
-
description: (getBuiltInTools(process.cwd()) as any)[name].description,
|
|
135
|
-
parameters: (getBuiltInTools(process.cwd()) as any)[name].parameters,
|
|
136
|
-
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
137
|
-
return (getBuiltInTools(ctx.cwd) as any)[name].execute(toolCallId, params, signal, onUpdate);
|
|
138
|
-
},
|
|
139
|
-
renderCall(args, theme) {
|
|
140
|
-
const suffix = oneLine(args.path || args.pattern || "");
|
|
141
|
-
return new Text(`${theme.fg("toolTitle", theme.bold(name))}${suffix ? `(${theme.fg("accent", suffix)})` : ""}`, 0, 0);
|
|
142
|
-
},
|
|
143
|
-
renderResult(result, { expanded }, theme) {
|
|
144
|
-
return expanded ? renderExpandedResult(result, theme) : renderCollapsedResult(result, theme);
|
|
145
|
-
},
|
|
146
|
-
});
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
pi.registerCommand("minimal-on", {
|
|
150
|
-
description: "Enable minimal collapsed tool output",
|
|
151
|
-
handler: async (_args, ctx) => {
|
|
152
|
-
minimalEnabled = true;
|
|
153
|
-
ctx.ui.notify("Minimal mode enabled", "info");
|
|
154
|
-
},
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
pi.registerCommand("minimal-off", {
|
|
158
|
-
description: "Disable minimal collapsed tool output",
|
|
159
|
-
handler: async (_args, ctx) => {
|
|
160
|
-
minimalEnabled = false;
|
|
161
|
-
ctx.ui.notify("Minimal mode disabled", "info");
|
|
162
|
-
},
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
pi.registerCommand("minimal-toggle", {
|
|
166
|
-
description: "Toggle minimal collapsed tool output",
|
|
167
|
-
handler: async (_args, ctx) => {
|
|
168
|
-
minimalEnabled = !minimalEnabled;
|
|
169
|
-
ctx.ui.notify(`Minimal mode ${minimalEnabled ? "enabled" : "disabled"}`, "info");
|
|
170
|
-
},
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
pi.registerCommand("thinking-status-toggle", {
|
|
174
|
-
description: "Toggle flashing thinking status indicator",
|
|
175
|
-
handler: async (_args, ctx) => {
|
|
176
|
-
thinkingStatusEnabled = !thinkingStatusEnabled;
|
|
177
|
-
if (!thinkingStatusEnabled) clearSpinner(ctx);
|
|
178
|
-
ctx.ui.notify(`Thinking status ${thinkingStatusEnabled ? "enabled" : "disabled"}`, "info");
|
|
179
|
-
},
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
pi.on("turn_start", async (_event, ctx) => {
|
|
183
|
-
startSpinner(ctx);
|
|
184
|
-
return undefined;
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
pi.on("turn_end", async (_event, ctx) => {
|
|
188
|
-
clearSpinner(ctx);
|
|
189
|
-
return undefined;
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
pi.on("agent_end", async (_event, ctx) => {
|
|
193
|
-
clearSpinner(ctx);
|
|
194
|
-
return undefined;
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
pi.on("session_shutdown", async (_event, ctx) => {
|
|
198
|
-
clearSpinner(ctx);
|
|
199
|
-
return undefined;
|
|
200
|
-
});
|
|
201
|
-
}
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
import type { ExtensionAPI, ToolResultEvent } from "@mariozechner/pi-coding-agent";
|
|
2
|
-
import { SubprocessRunner, EventAdapter, Logger } from "./core/lib";
|
|
3
|
-
import * as path from "node:path";
|
|
4
|
-
import * as fs from "node:fs";
|
|
5
|
-
|
|
6
|
-
const logger = new Logger({ namespace: "quality-gates" });
|
|
7
|
-
|
|
8
|
-
export default function (pi: ExtensionAPI) {
|
|
9
|
-
pi.on("tool_result", async (event, ctx) => {
|
|
10
|
-
if (!EventAdapter.isMutatingFileTool(event)) return undefined;
|
|
11
|
-
|
|
12
|
-
const cwd = ctx.cwd || process.cwd();
|
|
13
|
-
const filePath = EventAdapter.extractPathFromToolInput(event, cwd);
|
|
14
|
-
if (!filePath) return undefined;
|
|
15
|
-
|
|
16
|
-
const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
|
|
17
|
-
const ext = path.extname(fullPath);
|
|
18
|
-
|
|
19
|
-
let scriptPath: string | null = null;
|
|
20
|
-
let runner: string = "node";
|
|
21
|
-
|
|
22
|
-
if ([".ts", ".tsx", ".js", ".jsx"].includes(ext)) {
|
|
23
|
-
scriptPath = path.join(cwd, ".claude", "hooks", "quality-check.cjs");
|
|
24
|
-
runner = "node";
|
|
25
|
-
} else if (ext === ".py") {
|
|
26
|
-
scriptPath = path.join(cwd, ".claude", "hooks", "quality-check.py");
|
|
27
|
-
runner = "python3";
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
if (!scriptPath || !fs.existsSync(scriptPath)) return undefined;
|
|
31
|
-
|
|
32
|
-
const hookInput = JSON.stringify({
|
|
33
|
-
tool_name: event.toolName,
|
|
34
|
-
tool_input: event.input,
|
|
35
|
-
cwd: cwd,
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
const result = await SubprocessRunner.run(runner, [scriptPath], {
|
|
39
|
-
cwd,
|
|
40
|
-
input: hookInput,
|
|
41
|
-
env: { ...process.env, CLAUDE_PROJECT_DIR: cwd },
|
|
42
|
-
timeoutMs: 30000,
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
if (result.code === 0) {
|
|
46
|
-
if (result.stderr && result.stderr.trim()) {
|
|
47
|
-
const newContent = [...event.content];
|
|
48
|
-
newContent.push({ type: "text", text: `\n\n**Quality Gate**: ${result.stderr.trim()}` });
|
|
49
|
-
return { content: newContent };
|
|
50
|
-
}
|
|
51
|
-
return undefined;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
if (result.code === 2) {
|
|
55
|
-
const newContent = [...event.content];
|
|
56
|
-
newContent.push({ type: "text", text: `\n\n**Quality Gate FAILED**:\n${result.stderr || result.stdout || "Unknown error"}` });
|
|
57
|
-
|
|
58
|
-
if (ctx.hasUI) {
|
|
59
|
-
ctx.ui.notify(`Quality Gate failed for ${path.basename(fullPath)}`, "error");
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
return { isError: true, content: newContent };
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
return undefined;
|
|
66
|
-
});
|
|
67
|
-
}
|
|
@@ -1,150 +0,0 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
-
import { SubprocessRunner, Logger } from "./core/lib";
|
|
3
|
-
import * as path from "node:path";
|
|
4
|
-
import * as fs from "node:fs";
|
|
5
|
-
|
|
6
|
-
const logger = new Logger({ namespace: "service-skills" });
|
|
7
|
-
|
|
8
|
-
const SERVICE_REGISTRY_FILES = [
|
|
9
|
-
"service-registry.json",
|
|
10
|
-
path.join(".claude", "skills", "service-registry.json"),
|
|
11
|
-
];
|
|
12
|
-
|
|
13
|
-
const GLOBAL_SKILL_ROOTS = [
|
|
14
|
-
path.join(process.env.HOME || "", ".agents", "skills"),
|
|
15
|
-
path.join(process.env.HOME || "", ".claude", "skills"),
|
|
16
|
-
];
|
|
17
|
-
|
|
18
|
-
export default function (pi: ExtensionAPI) {
|
|
19
|
-
const getCwd = (ctx: any) => ctx.cwd || process.cwd();
|
|
20
|
-
|
|
21
|
-
const resolveRegistryPath = (cwd: string): string | null => {
|
|
22
|
-
for (const rel of SERVICE_REGISTRY_FILES) {
|
|
23
|
-
const candidate = path.join(cwd, rel);
|
|
24
|
-
if (fs.existsSync(candidate)) return candidate;
|
|
25
|
-
}
|
|
26
|
-
return null;
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
const resolveSkillScript = (cwd: string, skillName: string, scriptName: string): string | null => {
|
|
30
|
-
const localPath = path.join(cwd, ".claude", "skills", skillName, "scripts", scriptName);
|
|
31
|
-
if (fs.existsSync(localPath)) return localPath;
|
|
32
|
-
|
|
33
|
-
for (const root of GLOBAL_SKILL_ROOTS) {
|
|
34
|
-
if (!root) continue;
|
|
35
|
-
const candidate = path.join(root, skillName, "scripts", scriptName);
|
|
36
|
-
if (fs.existsSync(candidate)) return candidate;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
return null;
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
const toClaudeToolName = (toolName: string): string => {
|
|
43
|
-
if (toolName === "bash") return "Bash";
|
|
44
|
-
if (toolName === "read_file") return "Read";
|
|
45
|
-
if (toolName === "write" || toolName === "create_text_file") return "Write";
|
|
46
|
-
if (toolName === "edit" || toolName === "replace_content" || toolName === "replace_lines" || toolName === "insert_at_line" || toolName === "delete_lines") return "Edit";
|
|
47
|
-
if (toolName === "search_for_pattern") return "Grep";
|
|
48
|
-
if (toolName === "find_file" || toolName === "list_dir") return "Glob";
|
|
49
|
-
return toolName;
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
// 1. Catalog Injection
|
|
53
|
-
pi.on("before_agent_start", async (event, ctx) => {
|
|
54
|
-
const cwd = getCwd(ctx);
|
|
55
|
-
const registryPath = resolveRegistryPath(cwd);
|
|
56
|
-
if (!registryPath) return undefined;
|
|
57
|
-
|
|
58
|
-
const catalogerPath = resolveSkillScript(cwd, "using-service-skills", "cataloger.py");
|
|
59
|
-
if (!catalogerPath) return undefined;
|
|
60
|
-
|
|
61
|
-
const result = await SubprocessRunner.run("python3", [catalogerPath], {
|
|
62
|
-
cwd,
|
|
63
|
-
env: {
|
|
64
|
-
...process.env,
|
|
65
|
-
CLAUDE_PROJECT_DIR: cwd,
|
|
66
|
-
SERVICE_REGISTRY_PATH: registryPath,
|
|
67
|
-
},
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
if (result.code === 0 && result.stdout.trim()) {
|
|
71
|
-
return { systemPrompt: event.systemPrompt + "\n\n" + result.stdout.trim() };
|
|
72
|
-
}
|
|
73
|
-
return undefined;
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
// 2. Territory Activation (optional; no-op when script not available)
|
|
77
|
-
pi.on("tool_call", async (event, ctx) => {
|
|
78
|
-
const cwd = getCwd(ctx);
|
|
79
|
-
const registryPath = resolveRegistryPath(cwd);
|
|
80
|
-
if (!registryPath) return undefined;
|
|
81
|
-
|
|
82
|
-
const activatorPath = resolveSkillScript(cwd, "using-service-skills", "skill_activator.py");
|
|
83
|
-
if (!activatorPath) return undefined;
|
|
84
|
-
|
|
85
|
-
const hookInput = JSON.stringify({
|
|
86
|
-
tool_name: toClaudeToolName(event.toolName),
|
|
87
|
-
tool_input: event.input,
|
|
88
|
-
cwd,
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
const result = await SubprocessRunner.run("python3", [activatorPath], {
|
|
92
|
-
cwd,
|
|
93
|
-
input: hookInput,
|
|
94
|
-
env: {
|
|
95
|
-
...process.env,
|
|
96
|
-
CLAUDE_PROJECT_DIR: cwd,
|
|
97
|
-
SERVICE_REGISTRY_PATH: registryPath,
|
|
98
|
-
},
|
|
99
|
-
timeoutMs: 5000,
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
if (result.code === 0 && result.stdout.trim()) {
|
|
103
|
-
try {
|
|
104
|
-
const parsed = JSON.parse(result.stdout.trim());
|
|
105
|
-
const context = parsed.hookSpecificOutput?.additionalContext;
|
|
106
|
-
if (context && ctx.hasUI) {
|
|
107
|
-
ctx.ui.notify(context, "info");
|
|
108
|
-
}
|
|
109
|
-
} catch (e) {
|
|
110
|
-
logger.error("Failed to parse skill_activator output", e);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
return undefined;
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
// 3. Drift Detection
|
|
118
|
-
pi.on("tool_result", async (event, ctx) => {
|
|
119
|
-
const cwd = getCwd(ctx);
|
|
120
|
-
const registryPath = resolveRegistryPath(cwd);
|
|
121
|
-
if (!registryPath) return undefined;
|
|
122
|
-
|
|
123
|
-
const driftDetectorPath = resolveSkillScript(cwd, "updating-service-skills", "drift_detector.py");
|
|
124
|
-
if (!driftDetectorPath) return undefined;
|
|
125
|
-
|
|
126
|
-
const hookInput = JSON.stringify({
|
|
127
|
-
tool_name: toClaudeToolName(event.toolName),
|
|
128
|
-
tool_input: event.input,
|
|
129
|
-
cwd,
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
const result = await SubprocessRunner.run("python3", [driftDetectorPath], {
|
|
133
|
-
cwd,
|
|
134
|
-
input: hookInput,
|
|
135
|
-
env: {
|
|
136
|
-
...process.env,
|
|
137
|
-
CLAUDE_PROJECT_DIR: cwd,
|
|
138
|
-
SERVICE_REGISTRY_PATH: registryPath,
|
|
139
|
-
},
|
|
140
|
-
timeoutMs: 10000,
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
if (result.code === 0 && result.stdout.trim()) {
|
|
144
|
-
const newContent = [...event.content];
|
|
145
|
-
newContent.push({ type: "text", text: "\n\n" + result.stdout.trim() });
|
|
146
|
-
return { content: newContent };
|
|
147
|
-
}
|
|
148
|
-
return undefined;
|
|
149
|
-
});
|
|
150
|
-
}
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
-
import * as fs from "node:fs";
|
|
3
|
-
import * as path from "node:path";
|
|
4
|
-
import { Logger } from "./core/lib";
|
|
5
|
-
|
|
6
|
-
const logger = new Logger({ namespace: "xtrm-loader" });
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Recursively find markdown files in a directory.
|
|
10
|
-
*/
|
|
11
|
-
function findMarkdownFiles(dir: string, basePath: string = ""): string[] {
|
|
12
|
-
const results: string[] = [];
|
|
13
|
-
if (!fs.existsSync(dir)) return results;
|
|
14
|
-
|
|
15
|
-
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
16
|
-
for (const entry of entries) {
|
|
17
|
-
const relativePath = basePath ? `${basePath}/${entry.name}` : entry.name;
|
|
18
|
-
if (entry.isDirectory()) {
|
|
19
|
-
results.push(...findMarkdownFiles(path.join(dir, entry.name), relativePath));
|
|
20
|
-
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
21
|
-
results.push(relativePath);
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
return results;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export default function (pi: ExtensionAPI) {
|
|
28
|
-
let projectContext: string = "";
|
|
29
|
-
|
|
30
|
-
pi.on("session_start", async (_event, ctx) => {
|
|
31
|
-
const cwd = ctx.cwd;
|
|
32
|
-
const contextParts: string[] = [];
|
|
33
|
-
|
|
34
|
-
// 1. Architecture & Roadmap
|
|
35
|
-
const roadmapPaths = [
|
|
36
|
-
path.join(cwd, "architecture", "project_roadmap.md"),
|
|
37
|
-
path.join(cwd, "ROADMAP.md"),
|
|
38
|
-
path.join(cwd, "architecture", "index.md")
|
|
39
|
-
];
|
|
40
|
-
|
|
41
|
-
for (const p of roadmapPaths) {
|
|
42
|
-
if (fs.existsSync(p)) {
|
|
43
|
-
const content = fs.readFileSync(p, "utf8");
|
|
44
|
-
contextParts.push(`## Project Roadmap & Architecture (${path.relative(cwd, p)})\n\n${content}`);
|
|
45
|
-
break; // Only load the first one found
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// 2. Project Rules (.claude/rules)
|
|
50
|
-
const rulesDir = path.join(cwd, ".claude", "rules");
|
|
51
|
-
if (fs.existsSync(rulesDir)) {
|
|
52
|
-
const ruleFiles = findMarkdownFiles(rulesDir);
|
|
53
|
-
if (ruleFiles.length > 0) {
|
|
54
|
-
const rulesContent = ruleFiles.map(f => {
|
|
55
|
-
const content = fs.readFileSync(path.join(rulesDir, f), "utf8");
|
|
56
|
-
return `### Rule: ${f}\n${content}`;
|
|
57
|
-
}).join("\n\n");
|
|
58
|
-
contextParts.push(`## Project Rules\n\n${rulesContent}`);
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// 3. Project Skills (.claude/skills)
|
|
63
|
-
const skillsDir = path.join(cwd, ".claude", "skills");
|
|
64
|
-
if (fs.existsSync(skillsDir)) {
|
|
65
|
-
const skillFiles = findMarkdownFiles(skillsDir);
|
|
66
|
-
if (skillFiles.length > 0) {
|
|
67
|
-
const skillsContent = skillFiles.map(f => {
|
|
68
|
-
// We only want to list the paths/names so the agent knows what it can read
|
|
69
|
-
return `- ${f} (Path: .claude/skills/${f})`;
|
|
70
|
-
}).join("\n");
|
|
71
|
-
contextParts.push(`## Available Project Skills\n\nExisting service skills and workflows found in .claude/skills/:\n\n${skillsContent}\n\nUse the read tool to load any of these skills if relevant to the current task.`);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
projectContext = contextParts.join("\n\n---\n\n");
|
|
76
|
-
|
|
77
|
-
if (projectContext && ctx.hasUI) {
|
|
78
|
-
ctx.ui.notify("XTRM-Loader: Project context and skills indexed", "info");
|
|
79
|
-
}
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
pi.on("before_agent_start", async (event) => {
|
|
83
|
-
if (!projectContext) return undefined;
|
|
84
|
-
|
|
85
|
-
return {
|
|
86
|
-
systemPrompt: event.systemPrompt + "\n\n# Project Intelligence Context\n\n" + projectContext
|
|
87
|
-
};
|
|
88
|
-
});
|
|
89
|
-
}
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
import sys
|
|
3
|
-
import os
|
|
4
|
-
|
|
5
|
-
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
|
6
|
-
from agent_context import AgentContext
|
|
7
|
-
|
|
8
|
-
try:
|
|
9
|
-
ctx = AgentContext()
|
|
10
|
-
ctx.fail_open()
|
|
11
|
-
except Exception as e:
|
|
12
|
-
print(f"Hook error: {e}", file=sys.stderr)
|
|
13
|
-
sys.exit(0)
|
package/src/commands/finish.ts
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import { Command } from 'commander';
|
|
2
|
-
import kleur from 'kleur';
|
|
3
|
-
import { runXtrmFinish } from '../core/xtrm-finish.js';
|
|
4
|
-
|
|
5
|
-
export function createFinishCommand(): Command {
|
|
6
|
-
return new Command('finish')
|
|
7
|
-
.description('Complete session closure lifecycle (phase1 + merge polling + cleanup)')
|
|
8
|
-
.option('--poll-interval-ms <ms>', 'Polling interval for PR state checks', (v) => Number(v), 5000)
|
|
9
|
-
.option('--timeout-ms <ms>', 'Maximum wait time before pending-cleanup', (v) => Number(v), 10 * 60 * 1000)
|
|
10
|
-
.action(async (opts) => {
|
|
11
|
-
const result = await runXtrmFinish({
|
|
12
|
-
cwd: process.cwd(),
|
|
13
|
-
pollIntervalMs: opts.pollIntervalMs,
|
|
14
|
-
timeoutMs: opts.timeoutMs,
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
if (result.ok) {
|
|
18
|
-
console.log(kleur.green(`\n✓ ${result.message}\n`));
|
|
19
|
-
return;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
console.error(kleur.red(`\n✗ ${result.message}\n`));
|
|
23
|
-
process.exitCode = 1;
|
|
24
|
-
});
|
|
25
|
-
}
|
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
import { execSync } from 'node:child_process';
|
|
2
|
-
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
|
|
5
|
-
export const SESSION_STATE_FILE = '.xtrm-session-state.json';
|
|
6
|
-
|
|
7
|
-
export const SESSION_PHASES = [
|
|
8
|
-
'claimed',
|
|
9
|
-
'phase1-done',
|
|
10
|
-
'waiting-merge',
|
|
11
|
-
'conflicting',
|
|
12
|
-
'pending-cleanup',
|
|
13
|
-
'merged',
|
|
14
|
-
'cleanup-done',
|
|
15
|
-
] as const;
|
|
16
|
-
|
|
17
|
-
export type SessionPhase = typeof SESSION_PHASES[number];
|
|
18
|
-
|
|
19
|
-
export interface SessionState {
|
|
20
|
-
issueId: string;
|
|
21
|
-
branch: string;
|
|
22
|
-
worktreePath: string;
|
|
23
|
-
prNumber: number | null;
|
|
24
|
-
prUrl: string | null;
|
|
25
|
-
phase: SessionPhase;
|
|
26
|
-
conflictFiles: string[];
|
|
27
|
-
startedAt: string;
|
|
28
|
-
lastChecked: string;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const ALLOWED_TRANSITIONS: Record<SessionPhase, SessionPhase[]> = {
|
|
32
|
-
claimed: ['phase1-done', 'waiting-merge', 'conflicting', 'pending-cleanup', 'cleanup-done'],
|
|
33
|
-
'phase1-done': ['waiting-merge', 'conflicting', 'pending-cleanup', 'cleanup-done'],
|
|
34
|
-
'waiting-merge': ['conflicting', 'pending-cleanup', 'merged', 'cleanup-done'],
|
|
35
|
-
conflicting: ['waiting-merge', 'pending-cleanup', 'merged', 'cleanup-done'],
|
|
36
|
-
'pending-cleanup': ['waiting-merge', 'conflicting', 'merged', 'cleanup-done'],
|
|
37
|
-
merged: ['cleanup-done'],
|
|
38
|
-
'cleanup-done': [],
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
const nowIso = () => new Date().toISOString();
|
|
42
|
-
|
|
43
|
-
function isPhase(value: unknown): value is SessionPhase {
|
|
44
|
-
return typeof value === 'string' && (SESSION_PHASES as readonly string[]).includes(value);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function normalizeState(value: unknown): SessionState {
|
|
48
|
-
if (!value || typeof value !== 'object') {
|
|
49
|
-
throw new Error('Invalid session state payload');
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const state = value as Partial<SessionState>;
|
|
53
|
-
if (!state.issueId || !state.branch || !state.worktreePath || !state.phase) {
|
|
54
|
-
throw new Error('Session state requires issueId, branch, worktreePath, and phase');
|
|
55
|
-
}
|
|
56
|
-
if (!isPhase(state.phase)) throw new Error(`Invalid session phase: ${String(state.phase)}`);
|
|
57
|
-
|
|
58
|
-
return {
|
|
59
|
-
issueId: String(state.issueId),
|
|
60
|
-
branch: String(state.branch),
|
|
61
|
-
worktreePath: String(state.worktreePath),
|
|
62
|
-
prNumber: state.prNumber ?? null,
|
|
63
|
-
prUrl: state.prUrl ?? null,
|
|
64
|
-
phase: state.phase,
|
|
65
|
-
conflictFiles: Array.isArray(state.conflictFiles) ? state.conflictFiles.map(String) : [],
|
|
66
|
-
startedAt: state.startedAt || nowIso(),
|
|
67
|
-
lastChecked: nowIso(),
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function findRepoRoot(cwd: string): string | null {
|
|
72
|
-
try {
|
|
73
|
-
return execSync('git rev-parse --show-toplevel', {
|
|
74
|
-
encoding: 'utf8',
|
|
75
|
-
cwd,
|
|
76
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
77
|
-
timeout: 5000,
|
|
78
|
-
}).trim();
|
|
79
|
-
} catch {
|
|
80
|
-
return null;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
export function findSessionStateFile(startCwd: string = process.cwd()): string | null {
|
|
85
|
-
let current = path.resolve(startCwd);
|
|
86
|
-
for (;;) {
|
|
87
|
-
const candidate = path.join(current, SESSION_STATE_FILE);
|
|
88
|
-
if (existsSync(candidate)) return candidate;
|
|
89
|
-
const parent = path.dirname(current);
|
|
90
|
-
if (parent === current) return null;
|
|
91
|
-
current = parent;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
export function readSessionState(startCwd: string = process.cwd()): SessionState | null {
|
|
96
|
-
const filePath = findSessionStateFile(startCwd);
|
|
97
|
-
if (!filePath) return null;
|
|
98
|
-
|
|
99
|
-
try {
|
|
100
|
-
const parsed = JSON.parse(readFileSync(filePath, 'utf8'));
|
|
101
|
-
return normalizeState(parsed);
|
|
102
|
-
} catch {
|
|
103
|
-
return null;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
export function writeSessionState(state: Partial<SessionState>, cwd: string = process.cwd()): string {
|
|
108
|
-
const filePath = findSessionStateFile(cwd)
|
|
109
|
-
?? (findRepoRoot(cwd) ? path.join(findRepoRoot(cwd) as string, SESSION_STATE_FILE) : path.join(cwd, SESSION_STATE_FILE));
|
|
110
|
-
|
|
111
|
-
const normalized = normalizeState(state);
|
|
112
|
-
writeFileSync(filePath, JSON.stringify(normalized, null, 2) + '\n', 'utf8');
|
|
113
|
-
return filePath;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export function updateSessionPhase(nextPhase: SessionPhase, startCwd: string = process.cwd(), patch: Partial<SessionState> = {}): SessionState {
|
|
117
|
-
const filePath = findSessionStateFile(startCwd);
|
|
118
|
-
if (!filePath) throw new Error('Session state file not found');
|
|
119
|
-
|
|
120
|
-
const current = readSessionState(startCwd);
|
|
121
|
-
if (!current) throw new Error('Session state file invalid');
|
|
122
|
-
|
|
123
|
-
if (!isPhase(nextPhase)) {
|
|
124
|
-
throw new Error(`Invalid session phase: ${String(nextPhase)}`);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
if (current.phase !== nextPhase && !ALLOWED_TRANSITIONS[current.phase].includes(nextPhase)) {
|
|
128
|
-
throw new Error(`Invalid phase transition: ${current.phase} -> ${nextPhase}`);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
const nextState = normalizeState({
|
|
132
|
-
...current,
|
|
133
|
-
...patch,
|
|
134
|
-
phase: nextPhase,
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
writeFileSync(filePath, JSON.stringify(nextState, null, 2) + '\n', 'utf8');
|
|
138
|
-
return nextState;
|
|
139
|
-
}
|