xtrm-tools 0.7.4 → 0.7.7
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/.xtrm/config/hooks.json +3 -0
- package/.xtrm/ext-src/auto-session-name/index.ts +29 -0
- package/.xtrm/ext-src/auto-session-name/package.json +16 -0
- package/.xtrm/ext-src/auto-update/index.ts +71 -0
- package/.xtrm/ext-src/auto-update/package.json +16 -0
- package/.xtrm/ext-src/beads/index.ts +232 -0
- package/.xtrm/ext-src/beads/package.json +19 -0
- package/.xtrm/ext-src/compact-header/index.ts +69 -0
- package/.xtrm/ext-src/compact-header/package.json +16 -0
- package/.xtrm/ext-src/core/adapter.ts +52 -0
- package/.xtrm/ext-src/core/guard-rules.ts +100 -0
- package/.xtrm/ext-src/core/lib.ts +3 -0
- package/.xtrm/ext-src/core/logger.ts +45 -0
- package/.xtrm/ext-src/core/package.json +18 -0
- package/.xtrm/ext-src/core/runner.ts +71 -0
- package/.xtrm/ext-src/core/session-state.ts +59 -0
- package/.xtrm/ext-src/custom-footer/.pi/structured-returns/83051fe4-97da-4e2c-bdaa-343b32f4e714.combined.log +7 -0
- package/.xtrm/ext-src/custom-footer/.pi/structured-returns/83051fe4-97da-4e2c-bdaa-343b32f4e714.stderr.log +0 -0
- package/.xtrm/ext-src/custom-footer/.pi/structured-returns/83051fe4-97da-4e2c-bdaa-343b32f4e714.stdout.log +7 -0
- package/.xtrm/ext-src/custom-footer/index.ts +398 -0
- package/.xtrm/ext-src/custom-footer/package.json +19 -0
- package/.xtrm/ext-src/custom-provider-qwen-cli/index.ts +363 -0
- package/.xtrm/ext-src/custom-provider-qwen-cli/package.json +1 -0
- package/.xtrm/ext-src/git-checkpoint/index.ts +53 -0
- package/.xtrm/ext-src/git-checkpoint/package.json +16 -0
- package/.xtrm/ext-src/lsp-bootstrap/index.ts +134 -0
- package/.xtrm/ext-src/lsp-bootstrap/package.json +17 -0
- package/.xtrm/ext-src/pi-serena-compact/index.ts +121 -0
- package/.xtrm/ext-src/pi-serena-compact/package.json +16 -0
- package/.xtrm/ext-src/quality-gates/index.ts +66 -0
- package/.xtrm/ext-src/quality-gates/package.json +19 -0
- package/.xtrm/ext-src/service-skills/index.ts +108 -0
- package/.xtrm/ext-src/service-skills/package.json +19 -0
- package/.xtrm/ext-src/session-flow/index.ts +96 -0
- package/.xtrm/ext-src/session-flow/package.json +19 -0
- package/.xtrm/ext-src/xtrm-loader/index.ts +152 -0
- package/.xtrm/ext-src/xtrm-loader/package.json +19 -0
- package/.xtrm/ext-src/xtrm-ui/format.ts +282 -0
- package/.xtrm/ext-src/xtrm-ui/index.ts +1112 -0
- package/.xtrm/ext-src/xtrm-ui/package.json +21 -0
- package/.xtrm/ext-src/xtrm-ui/themes/pidex-dark.json +85 -0
- package/.xtrm/ext-src/xtrm-ui/themes/pidex-light.json +85 -0
- package/cli/dist/index.cjs +64 -2
- package/cli/dist/index.cjs.map +1 -1
- package/cli/package.json +1 -1
- package/package.json +2 -1
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { ExtensionAPI, ToolResultEvent } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
// Serena/GitNexus MCP tool names that produce verbose output
|
|
4
|
+
const COMPACT_TOOLS = new Set([
|
|
5
|
+
// Serena symbol operations
|
|
6
|
+
"find_symbol",
|
|
7
|
+
"find_referencing_symbols",
|
|
8
|
+
"get_symbols_overview",
|
|
9
|
+
"jet_brains_find_symbol",
|
|
10
|
+
"jet_brains_find_referencing_symbols",
|
|
11
|
+
"jet_brains_get_symbols_overview",
|
|
12
|
+
"jet_brains_type_hierarchy",
|
|
13
|
+
|
|
14
|
+
// Serena file operations
|
|
15
|
+
"read_file",
|
|
16
|
+
"create_text_file",
|
|
17
|
+
"replace_content",
|
|
18
|
+
"replace_lines",
|
|
19
|
+
"delete_lines",
|
|
20
|
+
"insert_at_line",
|
|
21
|
+
|
|
22
|
+
// Serena search/navigation
|
|
23
|
+
"search_for_pattern",
|
|
24
|
+
"list_dir",
|
|
25
|
+
"find_file",
|
|
26
|
+
|
|
27
|
+
// Serena symbol editing
|
|
28
|
+
"replace_symbol_body",
|
|
29
|
+
"insert_after_symbol",
|
|
30
|
+
"insert_before_symbol",
|
|
31
|
+
"rename_symbol",
|
|
32
|
+
|
|
33
|
+
// GitNexus
|
|
34
|
+
"gitnexus_query",
|
|
35
|
+
"gitnexus_context",
|
|
36
|
+
"gitnexus_impact",
|
|
37
|
+
"gitnexus_detect_changes",
|
|
38
|
+
"gitnexus_list_repos",
|
|
39
|
+
|
|
40
|
+
// Serena memory
|
|
41
|
+
"read_memory",
|
|
42
|
+
"write_memory",
|
|
43
|
+
"list_memories",
|
|
44
|
+
|
|
45
|
+
// Other verbose tools
|
|
46
|
+
"execute_shell_command",
|
|
47
|
+
"structured_return",
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
// Tools that should show more output even when compacted
|
|
51
|
+
const PRESERVE_OUTPUT_TOOLS = new Set([
|
|
52
|
+
"read_file",
|
|
53
|
+
"read_memory",
|
|
54
|
+
"execute_shell_command",
|
|
55
|
+
"structured_return",
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
function isSerenaTool(toolName: string): boolean {
|
|
59
|
+
return COMPACT_TOOLS.has(toolName);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getTextContent(content: Array<{ type: string; text?: string }>): string {
|
|
63
|
+
const item = content.find((c) => c.type === "text");
|
|
64
|
+
return item?.text ?? "";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function truncateLines(text: string, maxLines: number, maxLineLen = 180): string {
|
|
68
|
+
const lines = text.split("\n");
|
|
69
|
+
const truncated = lines.map(line =>
|
|
70
|
+
line.length > maxLineLen ? line.slice(0, maxLineLen) + "…" : line
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
if (truncated.length <= maxLines) return truncated.join("\n");
|
|
74
|
+
return truncated.slice(0, maxLines).join("\n") + `\n… +${truncated.length - maxLines} more lines`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function compactResult(
|
|
78
|
+
toolName: string,
|
|
79
|
+
content: Array<{ type: string; text?: string }>,
|
|
80
|
+
maxLines: number = 6,
|
|
81
|
+
): Array<{ type: string; text: string }> {
|
|
82
|
+
const textContent = getTextContent(content);
|
|
83
|
+
|
|
84
|
+
if (!textContent) {
|
|
85
|
+
return [{ type: "text", text: "✓ No output" }];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// For certain tools, show more output
|
|
89
|
+
const effectiveMaxLines = PRESERVE_OUTPUT_TOOLS.has(toolName) ? 12 : maxLines;
|
|
90
|
+
|
|
91
|
+
const compacted = truncateLines(textContent, effectiveMaxLines, 180);
|
|
92
|
+
|
|
93
|
+
return [{ type: "text", text: compacted }];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export default function serenaCompactExtension(pi: ExtensionAPI): void {
|
|
97
|
+
let toolsExpanded = false;
|
|
98
|
+
|
|
99
|
+
// Track tools expanded state
|
|
100
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
101
|
+
toolsExpanded = ctx.ui.getToolsExpanded();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
pi.on("session_switch", async (_event, ctx) => {
|
|
105
|
+
toolsExpanded = ctx.ui.getToolsExpanded();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Compact Serena tool results
|
|
109
|
+
pi.on("tool_result", async (event: ToolResultEvent) => {
|
|
110
|
+
// Only handle Serena/MCP tools
|
|
111
|
+
if (!isSerenaTool(event.toolName)) return undefined;
|
|
112
|
+
|
|
113
|
+
// If tools are expanded, don't compact
|
|
114
|
+
if (toolsExpanded) return undefined;
|
|
115
|
+
|
|
116
|
+
// Compact the content
|
|
117
|
+
const compacted = compactResult(event.toolName, event.content, 6);
|
|
118
|
+
|
|
119
|
+
return { content: compacted };
|
|
120
|
+
});
|
|
121
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-serena-compact",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Compact output for Serena MCP tools in Pi",
|
|
5
|
+
"keywords": ["pi", "pi-extension", "serena", "mcp"],
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "index.ts",
|
|
9
|
+
"peerDependencies": {
|
|
10
|
+
"@mariozechner/pi-coding-agent": "^0.56.0",
|
|
11
|
+
"@mariozechner/pi-tui": "^0.56.0"
|
|
12
|
+
},
|
|
13
|
+
"pi": {
|
|
14
|
+
"extensions": ["./index.ts"]
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { SubprocessRunner, EventAdapter } from "@xtrm/pi-core";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import * as fs from "node:fs";
|
|
5
|
+
|
|
6
|
+
function resolveQualityHook(cwd: string, ext: string): { runner: string; scriptPath: string } | null {
|
|
7
|
+
if ([".ts", ".tsx", ".js", ".jsx", ".cjs", ".mjs"].includes(ext)) {
|
|
8
|
+
const scriptPath = path.join(cwd, ".claude", "hooks", "quality-check.cjs");
|
|
9
|
+
return { runner: "node", scriptPath };
|
|
10
|
+
}
|
|
11
|
+
if (ext === ".py") {
|
|
12
|
+
const scriptPath = path.join(cwd, ".claude", "hooks", "quality-check.py");
|
|
13
|
+
return { runner: "python3", scriptPath };
|
|
14
|
+
}
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default function (pi: ExtensionAPI) {
|
|
19
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
20
|
+
if (!EventAdapter.isMutatingFileTool(event)) return undefined;
|
|
21
|
+
|
|
22
|
+
const cwd = ctx.cwd || process.cwd();
|
|
23
|
+
const filePath = EventAdapter.extractPathFromToolInput(event, cwd);
|
|
24
|
+
if (!filePath) return undefined;
|
|
25
|
+
|
|
26
|
+
const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
|
|
27
|
+
const ext = path.extname(fullPath);
|
|
28
|
+
const resolved = resolveQualityHook(cwd, ext);
|
|
29
|
+
if (!resolved) return undefined;
|
|
30
|
+
if (!fs.existsSync(resolved.scriptPath)) return undefined;
|
|
31
|
+
|
|
32
|
+
const hookInput = JSON.stringify({
|
|
33
|
+
tool_name: event.toolName,
|
|
34
|
+
tool_input: event.input,
|
|
35
|
+
cwd,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const result = await SubprocessRunner.run(resolved.runner, [resolved.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
|
+
const details = (result.stdout || result.stderr || "").trim();
|
|
47
|
+
if (!details) return undefined;
|
|
48
|
+
return {
|
|
49
|
+
content: [...event.content, { type: "text", text: `\n\n**Quality Gate**: ${details}` }],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (result.code === 2) {
|
|
54
|
+
const details = (result.stderr || result.stdout || "Unknown error").trim();
|
|
55
|
+
if (ctx.hasUI) {
|
|
56
|
+
ctx.ui.notify(`Quality Gate failed for ${path.basename(fullPath)}`, "error");
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
isError: true,
|
|
60
|
+
content: [...event.content, { type: "text", text: `\n\n**Quality Gate FAILED**:\n${details}` }],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return undefined;
|
|
65
|
+
});
|
|
66
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@xtrm/pi-quality-gates",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "xtrm Pi extension: quality-gates",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./index.ts"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [
|
|
10
|
+
"pi",
|
|
11
|
+
"extension",
|
|
12
|
+
"xtrm"
|
|
13
|
+
],
|
|
14
|
+
"author": "xtrm",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@xtrm/pi-core": "^1.0.0"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { SubprocessRunner } from "@xtrm/pi-core";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import * as fs from "node:fs";
|
|
5
|
+
|
|
6
|
+
const SERVICE_REGISTRY_FILES = [
|
|
7
|
+
"service-registry.json",
|
|
8
|
+
path.join(".claude", "skills", "service-registry.json"),
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
const GLOBAL_SKILL_ROOTS = [
|
|
12
|
+
path.join(process.env.HOME || "", ".agents", "skills"),
|
|
13
|
+
path.join(process.env.HOME || "", ".claude", "skills"),
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
export default function (pi: ExtensionAPI) {
|
|
17
|
+
const getCwd = (ctx: any) => ctx.cwd || process.cwd();
|
|
18
|
+
|
|
19
|
+
const resolveRegistryPath = (cwd: string): string | null => {
|
|
20
|
+
for (const rel of SERVICE_REGISTRY_FILES) {
|
|
21
|
+
const candidate = path.join(cwd, rel);
|
|
22
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const resolveSkillScript = (cwd: string, skillName: string, scriptName: string): string | null => {
|
|
28
|
+
const localPath = path.join(cwd, ".claude", "skills", skillName, "scripts", scriptName);
|
|
29
|
+
if (fs.existsSync(localPath)) return localPath;
|
|
30
|
+
|
|
31
|
+
for (const root of GLOBAL_SKILL_ROOTS) {
|
|
32
|
+
if (!root) continue;
|
|
33
|
+
const candidate = path.join(root, skillName, "scripts", scriptName);
|
|
34
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return null;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// 1. Catalog Injection
|
|
41
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
42
|
+
const cwd = getCwd(ctx);
|
|
43
|
+
const registryPath = resolveRegistryPath(cwd);
|
|
44
|
+
if (!registryPath) return undefined;
|
|
45
|
+
|
|
46
|
+
const catalogerPath = resolveSkillScript(cwd, "using-service-skills", "cataloger.py");
|
|
47
|
+
if (!catalogerPath) return undefined;
|
|
48
|
+
|
|
49
|
+
const result = await SubprocessRunner.run("python3", [catalogerPath], {
|
|
50
|
+
cwd,
|
|
51
|
+
env: {
|
|
52
|
+
...process.env,
|
|
53
|
+
CLAUDE_PROJECT_DIR: cwd,
|
|
54
|
+
SERVICE_REGISTRY_PATH: registryPath,
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
if (result.code === 0 && result.stdout.trim()) {
|
|
59
|
+
return { systemPrompt: event.systemPrompt + "\n\n" + result.stdout.trim() };
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const toClaudeToolName = (toolName: string): string => {
|
|
65
|
+
if (toolName === "bash") return "Bash";
|
|
66
|
+
if (toolName === "read_file") return "Read";
|
|
67
|
+
if (toolName === "write" || toolName === "create_text_file") return "Write";
|
|
68
|
+
if (toolName === "edit" || toolName === "replace_content" || toolName === "replace_lines" || toolName === "insert_at_line" || toolName === "delete_lines") return "Edit";
|
|
69
|
+
if (toolName === "search_for_pattern") return "Grep";
|
|
70
|
+
if (toolName === "find_file" || toolName === "list_dir") return "Glob";
|
|
71
|
+
return toolName;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// 2. Drift Detection (skill activation is before_agent_start only — not per-tool)
|
|
75
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
76
|
+
const cwd = getCwd(ctx);
|
|
77
|
+
const registryPath = resolveRegistryPath(cwd);
|
|
78
|
+
if (!registryPath) return undefined;
|
|
79
|
+
|
|
80
|
+
const driftDetectorPath = resolveSkillScript(cwd, "updating-service-skills", "drift_detector.py");
|
|
81
|
+
if (!driftDetectorPath) return undefined;
|
|
82
|
+
|
|
83
|
+
const hookInput = JSON.stringify({
|
|
84
|
+
tool_name: toClaudeToolName(event.toolName),
|
|
85
|
+
tool_input: event.input,
|
|
86
|
+
cwd,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const result = await SubprocessRunner.run("python3", [driftDetectorPath], {
|
|
90
|
+
cwd,
|
|
91
|
+
input: hookInput,
|
|
92
|
+
env: {
|
|
93
|
+
...process.env,
|
|
94
|
+
CLAUDE_PROJECT_DIR: cwd,
|
|
95
|
+
SERVICE_REGISTRY_PATH: registryPath,
|
|
96
|
+
},
|
|
97
|
+
timeoutMs: 10000,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
if (result.code === 0 && result.stdout.trim()) {
|
|
101
|
+
const newContent = [...event.content];
|
|
102
|
+
newContent.push({ type: "text", text: "\n\n" + result.stdout.trim() });
|
|
103
|
+
return { content: newContent };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return undefined;
|
|
107
|
+
});
|
|
108
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@xtrm/pi-service-skills",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "xtrm Pi extension: service-skills",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./index.ts"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [
|
|
10
|
+
"pi",
|
|
11
|
+
"extension",
|
|
12
|
+
"xtrm"
|
|
13
|
+
],
|
|
14
|
+
"author": "xtrm",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@xtrm/pi-core": "^1.0.0"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { isBashToolResult } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
import { SubprocessRunner, EventAdapter } from "@xtrm/pi-core";
|
|
4
|
+
|
|
5
|
+
function isClaimCommand(command: string): { isClaim: boolean; issueId: string | null } {
|
|
6
|
+
if (!/\bbd\s+update\b/.test(command) || !/--claim\b/.test(command)) {
|
|
7
|
+
return { isClaim: false, issueId: null };
|
|
8
|
+
}
|
|
9
|
+
const match = command.match(/\bbd\s+update\s+(\S+)/);
|
|
10
|
+
return { isClaim: true, issueId: match?.[1] ?? null };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isWorktree(cwd: string): boolean {
|
|
14
|
+
return cwd.includes("/.xtrm/worktrees/") || cwd.includes("/.claude/worktrees/");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getSessionId(ctx: any): string {
|
|
18
|
+
return ctx?.sessionManager?.getSessionId?.() ?? ctx?.sessionId ?? ctx?.session_id ?? process.pid.toString();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function getSessionClaim(cwd: string, sessionId: string): Promise<string | null> {
|
|
22
|
+
const claimResult = await SubprocessRunner.run("bd", ["kv", "get", `claimed:${sessionId}`], { cwd });
|
|
23
|
+
if (claimResult.code !== 0) return null;
|
|
24
|
+
const claimId = claimResult.stdout.trim();
|
|
25
|
+
return claimId.length > 0 ? claimId : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function isClaimStillInProgress(cwd: string, issueId: string): Promise<boolean> {
|
|
29
|
+
const showResult = await SubprocessRunner.run("bd", ["show", issueId, "--json"], { cwd });
|
|
30
|
+
if (showResult.code === 0 && showResult.stdout.trim()) {
|
|
31
|
+
try {
|
|
32
|
+
const parsed = JSON.parse(showResult.stdout);
|
|
33
|
+
const record = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
34
|
+
if (record?.status) return record.status === "in_progress";
|
|
35
|
+
} catch {
|
|
36
|
+
// fall back to text parsing below
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const listResult = await SubprocessRunner.run("bd", ["list", "--status=in_progress"], { cwd });
|
|
41
|
+
if (listResult.code !== 0) return false;
|
|
42
|
+
const issuePattern = new RegExp(`^\\s*[◐●]?\\s*${issueId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "m");
|
|
43
|
+
return issuePattern.test(listResult.stdout);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export default function (pi: ExtensionAPI) {
|
|
47
|
+
const getCwd = (ctx: any) => ctx.cwd || process.cwd();
|
|
48
|
+
let lastStopNoticeIssue: string | null = null;
|
|
49
|
+
let lastWorktreeReminderCwd: string | null = null;
|
|
50
|
+
|
|
51
|
+
// Claim sync: notify when a bd update --claim command is run.
|
|
52
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
53
|
+
if (!isBashToolResult(event)) return undefined;
|
|
54
|
+
const cwd = getCwd(ctx);
|
|
55
|
+
if (!EventAdapter.isBeadsProject(cwd)) return undefined;
|
|
56
|
+
|
|
57
|
+
const command = event.input.command || "";
|
|
58
|
+
const { isClaim, issueId } = isClaimCommand(command);
|
|
59
|
+
if (!isClaim || !issueId) return undefined;
|
|
60
|
+
|
|
61
|
+
const text = `\n\nSession Flow: claimed ${issueId}. Work in this session is tracked.`;
|
|
62
|
+
return { content: [...event.content, { type: "text", text }] };
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Stop gate: warn (non-blocking) if this session's claimed issue is still in progress.
|
|
66
|
+
// IMPORTANT: never call sendUserMessage() from agent_end, it always triggers a new turn.
|
|
67
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
68
|
+
const cwd = getCwd(ctx);
|
|
69
|
+
if (!EventAdapter.isBeadsProject(cwd)) return undefined;
|
|
70
|
+
|
|
71
|
+
const sessionId = getSessionId(ctx);
|
|
72
|
+
const claimId = await getSessionClaim(cwd, sessionId);
|
|
73
|
+
|
|
74
|
+
if (claimId) {
|
|
75
|
+
const inProgress = await isClaimStillInProgress(cwd, claimId);
|
|
76
|
+
if (inProgress) {
|
|
77
|
+
if (lastStopNoticeIssue !== claimId && ctx.hasUI) {
|
|
78
|
+
ctx.ui.notify(`Stop blocked: close your issue first: bd close ${claimId}`, "warning");
|
|
79
|
+
lastStopNoticeIssue = claimId;
|
|
80
|
+
}
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (lastStopNoticeIssue === claimId) {
|
|
85
|
+
lastStopNoticeIssue = null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (isWorktree(cwd) && ctx.hasUI && lastWorktreeReminderCwd !== cwd) {
|
|
90
|
+
ctx.ui.notify("Run `xt end` to create a PR and clean up this worktree.", "info");
|
|
91
|
+
lastWorktreeReminderCwd = cwd;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return undefined;
|
|
95
|
+
});
|
|
96
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@xtrm/pi-session-flow",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "xtrm Pi extension: session-flow",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./index.ts"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [
|
|
10
|
+
"pi",
|
|
11
|
+
"extension",
|
|
12
|
+
"xtrm"
|
|
13
|
+
],
|
|
14
|
+
"author": "xtrm",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@xtrm/pi-core": "^1.0.0"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
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 { homedir } from "node:os";
|
|
5
|
+
import { Logger } from "@xtrm/pi-core";
|
|
6
|
+
|
|
7
|
+
const logger = new Logger({ namespace: "xtrm-loader" });
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Recursively find markdown files in a directory.
|
|
11
|
+
*/
|
|
12
|
+
function findMarkdownFiles(dir: string, basePath: string = ""): string[] {
|
|
13
|
+
const results: string[] = [];
|
|
14
|
+
if (!fs.existsSync(dir)) return results;
|
|
15
|
+
|
|
16
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
17
|
+
for (const entry of entries) {
|
|
18
|
+
const relativePath = basePath ? `${basePath}/${entry.name}` : entry.name;
|
|
19
|
+
if (entry.isDirectory()) {
|
|
20
|
+
results.push(...findMarkdownFiles(path.join(dir, entry.name), relativePath));
|
|
21
|
+
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
22
|
+
results.push(relativePath);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return results;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function resolveUsingXtrmSkillPath(cwd: string): string | null {
|
|
29
|
+
const candidates = [
|
|
30
|
+
path.join(homedir(), ".agents", "skills", "using-xtrm", "SKILL.md"),
|
|
31
|
+
path.join(homedir(), ".pi", "agent", "skills", "using-xtrm", "SKILL.md"),
|
|
32
|
+
path.join(cwd, ".pi", "skills", "using-xtrm", "SKILL.md"),
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
for (const candidate of candidates) {
|
|
36
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Load a skill file, stripping YAML frontmatter.
|
|
43
|
+
*/
|
|
44
|
+
function loadSkillContent(skillPath: string): string | null {
|
|
45
|
+
try {
|
|
46
|
+
const content = fs.readFileSync(skillPath, "utf8");
|
|
47
|
+
return content.replace(/^---[\s\S]*?---\n/, "").trim();
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export default function (pi: ExtensionAPI) {
|
|
54
|
+
let projectContext: string = "";
|
|
55
|
+
let usingXtrmContent: string | null = null;
|
|
56
|
+
|
|
57
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
58
|
+
const cwd = ctx.cwd;
|
|
59
|
+
const contextParts: string[] = [];
|
|
60
|
+
|
|
61
|
+
// 0. Load using-xtrm skill (global/project fallback paths)
|
|
62
|
+
const usingXtrmPath = resolveUsingXtrmSkillPath(cwd);
|
|
63
|
+
usingXtrmContent = usingXtrmPath ? loadSkillContent(usingXtrmPath) : null;
|
|
64
|
+
if (usingXtrmPath && usingXtrmContent) {
|
|
65
|
+
logger.info(`Loaded using-xtrm skill from ${usingXtrmPath}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 1. Architecture & Roadmap
|
|
69
|
+
const roadmapPaths = [
|
|
70
|
+
path.join(cwd, "architecture", "project_roadmap.md"),
|
|
71
|
+
path.join(cwd, "ROADMAP.md"),
|
|
72
|
+
path.join(cwd, "architecture", "index.md"),
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
for (const p of roadmapPaths) {
|
|
76
|
+
if (fs.existsSync(p)) {
|
|
77
|
+
const content = await fs.promises.readFile(p, "utf8");
|
|
78
|
+
contextParts.push(`## Project Roadmap & Architecture (${path.relative(cwd, p)})\n\n${content}`);
|
|
79
|
+
break; // Only load the first one found
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 2. Project Rules (.claude/rules)
|
|
84
|
+
const rulesDir = path.join(cwd, ".claude", "rules");
|
|
85
|
+
if (fs.existsSync(rulesDir)) {
|
|
86
|
+
const ruleFiles = findMarkdownFiles(rulesDir);
|
|
87
|
+
if (ruleFiles.length > 0) {
|
|
88
|
+
const rulesContent = (
|
|
89
|
+
await Promise.all(
|
|
90
|
+
ruleFiles.map(async (f) => {
|
|
91
|
+
const content = await fs.promises.readFile(path.join(rulesDir, f), "utf8");
|
|
92
|
+
return `### Rule: ${f}\n${content}`;
|
|
93
|
+
}),
|
|
94
|
+
)
|
|
95
|
+
).join("\n\n");
|
|
96
|
+
contextParts.push(`## Project Rules\n\n${rulesContent}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 3. Project Skills (.claude/skills)
|
|
101
|
+
const skillsDir = path.join(cwd, ".claude", "skills");
|
|
102
|
+
if (fs.existsSync(skillsDir)) {
|
|
103
|
+
const skillFiles = findMarkdownFiles(skillsDir);
|
|
104
|
+
if (skillFiles.length > 0) {
|
|
105
|
+
const skillsContent = skillFiles
|
|
106
|
+
.map((f) => `- ${f} (Path: .claude/skills/${f})`)
|
|
107
|
+
.join("\n");
|
|
108
|
+
contextParts.push(
|
|
109
|
+
`## 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.`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
projectContext = contextParts.join("\n\n---\n\n");
|
|
115
|
+
|
|
116
|
+
if (projectContext && ctx.hasUI) {
|
|
117
|
+
ctx.ui.notify("XTRM-Loader: Project context and skills indexed", "info");
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
122
|
+
const parts: string[] = [];
|
|
123
|
+
|
|
124
|
+
// Prepend using-xtrm skill (session operating manual)
|
|
125
|
+
if (usingXtrmContent) {
|
|
126
|
+
parts.push("# XTRM Session Operating Manual\n\n" + usingXtrmContent);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Inject .xtrm/memory.md if present (synthesized project context)
|
|
130
|
+
const memoryPath = path.join(ctx.cwd, ".xtrm", "memory.md");
|
|
131
|
+
if (fs.existsSync(memoryPath)) {
|
|
132
|
+
try {
|
|
133
|
+
const memoryContent = fs.readFileSync(memoryPath, "utf8").trim();
|
|
134
|
+
if (memoryContent) {
|
|
135
|
+
parts.push(memoryContent);
|
|
136
|
+
logger.info(`Injected .xtrm/memory.md (${memoryContent.length} chars)`);
|
|
137
|
+
}
|
|
138
|
+
} catch { /* fail open */ }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Append project context
|
|
142
|
+
if (projectContext) {
|
|
143
|
+
parts.push("# Project Intelligence Context\n\n" + projectContext);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (parts.length === 0) return undefined;
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
systemPrompt: event.systemPrompt + "\n\n" + parts.join("\n\n---\n\n"),
|
|
150
|
+
};
|
|
151
|
+
});
|
|
152
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@xtrm/pi-xtrm-loader",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "xtrm Pi extension: xtrm-loader",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./index.ts"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [
|
|
10
|
+
"pi",
|
|
11
|
+
"extension",
|
|
12
|
+
"xtrm"
|
|
13
|
+
],
|
|
14
|
+
"author": "xtrm",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@xtrm/pi-core": "^1.0.0"
|
|
18
|
+
}
|
|
19
|
+
}
|