xtrm-cli 2.1.20 → 2.1.28
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/extensions/beads.ts +96 -0
- package/extensions/core/adapter.ts +45 -0
- package/extensions/core/lib.ts +3 -0
- package/extensions/core/logger.ts +45 -0
- package/extensions/core/runner.ts +71 -0
- package/extensions/main-guard-post-push.ts +44 -0
- package/extensions/main-guard.ts +126 -0
- package/extensions/quality-gates.ts +67 -0
- package/extensions/service-skills.ts +88 -0
- package/extensions/xtrm-loader.ts +89 -0
- package/package.json +1 -1
- package/test/extensions/beads.test.ts +166 -0
- package/test/extensions/extension-harness.ts +85 -0
- package/test/extensions/main-guard.test.ts +77 -0
- package/test/extensions/quality-gates.test.ts +79 -0
- package/test/extensions/xtrm-loader.test.ts +53 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { ExtensionAPI, ToolCallEvent, ToolResultEvent } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { isToolCallEventType, isBashToolResult } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import * as fs from "node:fs";
|
|
5
|
+
import { SubprocessRunner, EventAdapter, Logger } from "./core/lib";
|
|
6
|
+
|
|
7
|
+
const logger = new Logger({ namespace: "beads" });
|
|
8
|
+
|
|
9
|
+
export default function (pi: ExtensionAPI) {
|
|
10
|
+
const getCwd = (ctx: any) => ctx.cwd || process.cwd();
|
|
11
|
+
const isBeadsProject = (cwd: string) => fs.existsSync(path.join(cwd, ".beads"));
|
|
12
|
+
|
|
13
|
+
// Pin session to PID — stable for the life of the Pi process, consistent across all extension handlers
|
|
14
|
+
const sessionId = process.pid.toString();
|
|
15
|
+
|
|
16
|
+
const getSessionClaim = async (cwd: string): Promise<string | null> => {
|
|
17
|
+
const result = await SubprocessRunner.run("bd", ["kv", "get", `claimed:${sessionId}`], { cwd });
|
|
18
|
+
if (result.code === 0) return result.stdout.trim();
|
|
19
|
+
return null;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const hasTrackableWork = async (cwd: string): Promise<boolean> => {
|
|
23
|
+
const result = await SubprocessRunner.run("bd", ["list"], { cwd });
|
|
24
|
+
if (result.code === 0 && result.stdout.includes("Total:")) {
|
|
25
|
+
const m = result.stdout.match(/Total:\s*\d+\s+issues?\s*\((\d+)\s+open,\s*(\d+)\s+in progress\)/);
|
|
26
|
+
if (m) {
|
|
27
|
+
const open = parseInt(m[1], 10);
|
|
28
|
+
const inProgress = parseInt(m[2], 10);
|
|
29
|
+
return (open + inProgress) > 0;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return false;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
36
|
+
const cwd = getCwd(ctx);
|
|
37
|
+
if (!isBeadsProject(cwd)) return undefined;
|
|
38
|
+
|
|
39
|
+
if (EventAdapter.isMutatingFileTool(event)) {
|
|
40
|
+
const claim = await getSessionClaim(cwd);
|
|
41
|
+
if (!claim) {
|
|
42
|
+
const hasWork = await hasTrackableWork(cwd);
|
|
43
|
+
if (hasWork) {
|
|
44
|
+
if (ctx.hasUI) {
|
|
45
|
+
ctx.ui.notify("Beads: Edit blocked. Claim an issue first.", "warning");
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
block: true,
|
|
49
|
+
reason: `No active issue claim for this session (pid:${sessionId}).\n bd update <id> --claim\n bd kv set "claimed:${sessionId}" "<id>"`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (isToolCallEventType("bash", event)) {
|
|
56
|
+
const command = event.input.command;
|
|
57
|
+
if (command && /\bgit\s+commit\b/.test(command)) {
|
|
58
|
+
const claim = await getSessionClaim(cwd);
|
|
59
|
+
if (claim) {
|
|
60
|
+
return {
|
|
61
|
+
block: true,
|
|
62
|
+
reason: `Resolve open claim [${claim}] before committing.`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return undefined;
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
72
|
+
if (isBashToolResult(event)) {
|
|
73
|
+
const command = event.input.command;
|
|
74
|
+
|
|
75
|
+
// Auto-claim on bd update --claim regardless of exit code.
|
|
76
|
+
// bd returns exit 1 with "already in_progress" when status unchanged — still a valid claim intent.
|
|
77
|
+
if (command && /\bbd\s+update\b/.test(command) && /--claim\b/.test(command)) {
|
|
78
|
+
const issueMatch = command.match(/\bbd\s+update\s+(\S+)/);
|
|
79
|
+
if (issueMatch) {
|
|
80
|
+
const issueId = issueMatch[1];
|
|
81
|
+
const cwd = getCwd(ctx);
|
|
82
|
+
await SubprocessRunner.run("bd", ["kv", "set", `claimed:${sessionId}`, issueId], { cwd });
|
|
83
|
+
const claimNotice = `\n\n✅ **Beads**: Session \`pid:${sessionId}\` claimed issue \`${issueId}\`. File edits are now unblocked.`;
|
|
84
|
+
return { content: [...event.content, { type: "text", text: claimNotice }] };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (command && /\bbd\s+close\b/.test(command) && !event.isError) {
|
|
89
|
+
const reminder = "\n\n**Beads Insight**: Work completed. Consider if this session produced insights worth persisting via `bd remember`.";
|
|
90
|
+
const newContent = [...event.content, { type: "text", text: reminder }];
|
|
91
|
+
return { content: newContent };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return undefined;
|
|
95
|
+
});
|
|
96
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ExtensionAPI, ToolCallEvent } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export class EventAdapter {
|
|
4
|
+
/**
|
|
5
|
+
* Checks if the tool event is a mutating file operation (write, edit, etc).
|
|
6
|
+
*/
|
|
7
|
+
static isMutatingFileTool(event: ToolCallEvent<any, any>): boolean {
|
|
8
|
+
const tools = [
|
|
9
|
+
"write",
|
|
10
|
+
"edit",
|
|
11
|
+
"replace_content",
|
|
12
|
+
"replace_lines",
|
|
13
|
+
"delete_lines",
|
|
14
|
+
"insert_at_line",
|
|
15
|
+
"create_text_file",
|
|
16
|
+
"rename_symbol",
|
|
17
|
+
"replace_symbol_body",
|
|
18
|
+
"insert_after_symbol",
|
|
19
|
+
"insert_before_symbol",
|
|
20
|
+
];
|
|
21
|
+
return tools.includes(event.toolName);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Extracts the target path from a tool input, resolving against the current working directory.
|
|
26
|
+
*/
|
|
27
|
+
static extractPathFromToolInput(event: ToolCallEvent<any, any>, cwd: string): string | null {
|
|
28
|
+
const input = event.input;
|
|
29
|
+
if (!input) return null;
|
|
30
|
+
|
|
31
|
+
const pathRaw = input.path || input.file || input.filePath;
|
|
32
|
+
if (typeof pathRaw === "string") {
|
|
33
|
+
return pathRaw; // Usually Pi passes absolute paths anyway or paths relative to root
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Safely formats a block reason string to ensure UI readiness.
|
|
41
|
+
*/
|
|
42
|
+
static formatBlockReason(prefix: string, details: string): string {
|
|
43
|
+
return `${prefix}: ${details}`;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export type LogLevel = "debug" | "info" | "warn" | "error";
|
|
2
|
+
|
|
3
|
+
export interface LoggerOptions {
|
|
4
|
+
namespace: string;
|
|
5
|
+
level?: LogLevel;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class Logger {
|
|
9
|
+
private namespace: string;
|
|
10
|
+
private level: LogLevel;
|
|
11
|
+
|
|
12
|
+
constructor(options: LoggerOptions) {
|
|
13
|
+
this.namespace = options.namespace;
|
|
14
|
+
this.level = options.level || "info";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
private shouldLog(level: LogLevel): boolean {
|
|
18
|
+
const levels: LogLevel[] = ["debug", "info", "warn", "error"];
|
|
19
|
+
return levels.indexOf(level) >= levels.indexOf(this.level);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
debug(message: string, ...args: any[]) {
|
|
23
|
+
if (this.shouldLog("debug")) {
|
|
24
|
+
console.debug(`[${this.namespace}] DEBUG: ${message}`, ...args);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
info(message: string, ...args: any[]) {
|
|
29
|
+
if (this.shouldLog("info")) {
|
|
30
|
+
console.info(`[${this.namespace}] INFO: ${message}`, ...args);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
warn(message: string, ...args: any[]) {
|
|
35
|
+
if (this.shouldLog("warn")) {
|
|
36
|
+
console.warn(`[${this.namespace}] WARN: ${message}`, ...args);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
error(message: string, ...args: any[]) {
|
|
41
|
+
if (this.shouldLog("error")) {
|
|
42
|
+
console.error(`[${this.namespace}] ERROR: ${message}`, ...args);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { execFile, spawnSync } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
|
|
6
|
+
export interface RunOptions {
|
|
7
|
+
timeoutMs?: number;
|
|
8
|
+
cwd?: string;
|
|
9
|
+
env?: Record<string, string>;
|
|
10
|
+
input?: string; // Standard input
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface RunResult {
|
|
14
|
+
code: number;
|
|
15
|
+
stdout: string;
|
|
16
|
+
stderr: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class SubprocessRunner {
|
|
20
|
+
/**
|
|
21
|
+
* Run a command deterministically with a timeout and optional stdin.
|
|
22
|
+
*/
|
|
23
|
+
static async run(
|
|
24
|
+
command: string,
|
|
25
|
+
args: string[],
|
|
26
|
+
options: RunOptions = {}
|
|
27
|
+
): Promise<RunResult> {
|
|
28
|
+
const timeout = options.timeoutMs ?? 10000;
|
|
29
|
+
const cwd = options.cwd ?? process.cwd();
|
|
30
|
+
const env = { ...process.env, ...options.env };
|
|
31
|
+
|
|
32
|
+
if (options.input !== undefined) {
|
|
33
|
+
// Use spawnSync for stdin support if input is provided
|
|
34
|
+
const result = spawnSync(command, args, {
|
|
35
|
+
cwd,
|
|
36
|
+
env,
|
|
37
|
+
input: options.input,
|
|
38
|
+
encoding: "utf8",
|
|
39
|
+
timeout,
|
|
40
|
+
maxBuffer: 1024 * 1024 * 10,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
code: result.status ?? 1,
|
|
45
|
+
stdout: (result.stdout ?? "").trim(),
|
|
46
|
+
stderr: (result.stderr ?? "").trim(),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const result = await execFileAsync(command, args, {
|
|
52
|
+
timeout,
|
|
53
|
+
cwd,
|
|
54
|
+
env,
|
|
55
|
+
maxBuffer: 1024 * 1024 * 10,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
code: 0,
|
|
60
|
+
stdout: result.stdout.trim(),
|
|
61
|
+
stderr: result.stderr.trim(),
|
|
62
|
+
};
|
|
63
|
+
} catch (error: any) {
|
|
64
|
+
return {
|
|
65
|
+
code: error.code ?? 1,
|
|
66
|
+
stdout: (error.stdout ?? "").trim(),
|
|
67
|
+
stderr: (error.stderr ?? error.message ?? "").trim(),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ExtensionAPI, ToolResultEvent } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { isBashToolResult } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
import { SubprocessRunner, Logger } from "./core/lib";
|
|
4
|
+
|
|
5
|
+
const logger = new Logger({ namespace: "main-guard-post-push" });
|
|
6
|
+
|
|
7
|
+
export default function (pi: ExtensionAPI) {
|
|
8
|
+
const getProtectedBranches = (): string[] => {
|
|
9
|
+
const env = process.env.MAIN_GUARD_PROTECTED_BRANCHES;
|
|
10
|
+
if (env) return env.split(",").map(b => b.trim()).filter(Boolean);
|
|
11
|
+
return ["main", "master"];
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
15
|
+
const cwd = ctx.cwd || process.cwd();
|
|
16
|
+
if (!isBashToolResult(event) || event.isError) return undefined;
|
|
17
|
+
|
|
18
|
+
const cmd = event.input.command.trim();
|
|
19
|
+
if (!/\bgit\s+push\b/.test(cmd)) return undefined;
|
|
20
|
+
|
|
21
|
+
// Check if we pushed to a protected branch
|
|
22
|
+
const protectedBranches = getProtectedBranches();
|
|
23
|
+
const tokens = cmd.split(/\s+/);
|
|
24
|
+
const lastToken = tokens[tokens.length - 1];
|
|
25
|
+
if (protectedBranches.some(b => lastToken === b || lastToken.endsWith(`:${b}`))) {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Success! Suggest PR workflow
|
|
30
|
+
const reminder = "\n\n**Main-Guard**: Push successful. Next steps:\n" +
|
|
31
|
+
" 1. `gh pr create --fill` (if not already open)\n" +
|
|
32
|
+
" 2. `gh pr merge --squash` (once approved)\n" +
|
|
33
|
+
" 3. `git checkout main && git reset --hard origin/main` (sync local)";
|
|
34
|
+
|
|
35
|
+
const newContent = [...event.content];
|
|
36
|
+
newContent.push({ type: "text", text: reminder });
|
|
37
|
+
|
|
38
|
+
if (ctx.hasUI) {
|
|
39
|
+
ctx.ui.notify("Main-Guard: Suggesting PR workflow", "info");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return { content: newContent };
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { ExtensionAPI, ToolCallEvent } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
import { SubprocessRunner, EventAdapter, Logger } from "./core/lib";
|
|
4
|
+
|
|
5
|
+
const logger = new Logger({ namespace: "main-guard" });
|
|
6
|
+
|
|
7
|
+
export default function (pi: ExtensionAPI) {
|
|
8
|
+
const getProtectedBranches = (): string[] => {
|
|
9
|
+
const env = process.env.MAIN_GUARD_PROTECTED_BRANCHES;
|
|
10
|
+
if (env) return env.split(",").map(b => b.trim()).filter(Boolean);
|
|
11
|
+
return ["main", "master"];
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const getCurrentBranch = async (cwd: string): Promise<string | null> => {
|
|
15
|
+
const result = await SubprocessRunner.run("git", ["branch", "--show-current"], { cwd });
|
|
16
|
+
if (result.code === 0) return result.stdout;
|
|
17
|
+
return null;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const protectedPaths = [".env", ".git/", "node_modules/"];
|
|
21
|
+
|
|
22
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
23
|
+
const cwd = ctx.cwd || process.cwd();
|
|
24
|
+
|
|
25
|
+
// 1. Safety Check: Protected Paths (Global)
|
|
26
|
+
if (EventAdapter.isMutatingFileTool(event)) {
|
|
27
|
+
const path = EventAdapter.extractPathFromToolInput(event, cwd);
|
|
28
|
+
if (path && protectedPaths.some((p) => path.includes(p))) {
|
|
29
|
+
const reason = `Path "${path}" is protected. Edits to sensitive system files are restricted.`;
|
|
30
|
+
if (ctx.hasUI) {
|
|
31
|
+
ctx.ui.notify(`Safety: Blocked edit to protected path`, "error");
|
|
32
|
+
}
|
|
33
|
+
return { block: true, reason };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 2. Safety Check: Dangerous Commands (Global)
|
|
38
|
+
if (isToolCallEventType("bash", event)) {
|
|
39
|
+
const cmd = event.input.command.trim();
|
|
40
|
+
if (cmd.includes("rm -rf") && !cmd.includes("--help")) {
|
|
41
|
+
if (ctx.hasUI) {
|
|
42
|
+
const ok = await ctx.ui.confirm("Dangerous Command", `Allow execution of: ${cmd}?`);
|
|
43
|
+
if (!ok) return { block: true, reason: "Blocked by user confirmation" };
|
|
44
|
+
} else {
|
|
45
|
+
return { block: true, reason: "Dangerous command 'rm -rf' blocked in non-interactive mode" };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 3. Main-Guard: Branch Protection
|
|
51
|
+
const protectedBranches = getProtectedBranches();
|
|
52
|
+
const branch = await getCurrentBranch(cwd);
|
|
53
|
+
|
|
54
|
+
if (branch && protectedBranches.includes(branch)) {
|
|
55
|
+
// A. Mutating File Tools on Main
|
|
56
|
+
if (EventAdapter.isMutatingFileTool(event)) {
|
|
57
|
+
const reason = `On protected branch '${branch}'. Checkout a feature branch first: \`git checkout -b feature/<name>\``;
|
|
58
|
+
if (ctx.hasUI) {
|
|
59
|
+
ctx.ui.notify(`Main-Guard: Blocked edit on ${branch}`, "error");
|
|
60
|
+
}
|
|
61
|
+
return { block: true, reason };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// B. Bash Commands on Main
|
|
65
|
+
if (isToolCallEventType("bash", event)) {
|
|
66
|
+
const cmd = event.input.command.trim();
|
|
67
|
+
|
|
68
|
+
// Emergency override
|
|
69
|
+
if (process.env.MAIN_GUARD_ALLOW_BASH === "1") return undefined;
|
|
70
|
+
|
|
71
|
+
// Enforce squash-only PR merges
|
|
72
|
+
if (/^gh\s+pr\s+merge\b/.test(cmd)) {
|
|
73
|
+
if (!/--squash\b/.test(cmd)) {
|
|
74
|
+
const reason = "Squash only: use `gh pr merge --squash` (or MAIN_GUARD_ALLOW_BASH=1)";
|
|
75
|
+
return { block: true, reason };
|
|
76
|
+
}
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Safe allowlist
|
|
81
|
+
const SAFE_BASH_PATTERNS = [
|
|
82
|
+
/^git\s+(status|log|diff|branch|show|describe|fetch|remote|config)\b/,
|
|
83
|
+
/^git\s+pull\b/,
|
|
84
|
+
/^git\s+stash\b/,
|
|
85
|
+
/^git\s+worktree\b/,
|
|
86
|
+
/^git\s+checkout\s+-b\s+\S+/,
|
|
87
|
+
/^git\s+switch\s+-c\s+\S+/,
|
|
88
|
+
...protectedBranches.map(b => new RegExp(`^git\\s+reset\\s+--hard\\s+origin/${b}\\b`)),
|
|
89
|
+
/^gh\s+/,
|
|
90
|
+
/^bd\s+/,
|
|
91
|
+
/^touch\s+\.beads\//,
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
if (SAFE_BASH_PATTERNS.some(p => p.test(cmd))) {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Specific blocks
|
|
99
|
+
if (/\bgit\s+commit\b/.test(cmd)) {
|
|
100
|
+
return { block: true, reason: `No commits on '${branch}' — use a feature branch.` };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (/\bgit\s+push\b/.test(cmd)) {
|
|
104
|
+
const tokens = cmd.split(/\s+/);
|
|
105
|
+
const lastToken = tokens[tokens.length - 1];
|
|
106
|
+
const explicitProtected = protectedBranches.some(b => lastToken === b || lastToken.endsWith(`:${b}`));
|
|
107
|
+
const impliedProtected = tokens.length <= 3 && protectedBranches.includes(branch);
|
|
108
|
+
|
|
109
|
+
if (explicitProtected || impliedProtected) {
|
|
110
|
+
return { block: true, reason: `No direct push to '${branch}' — push a feature branch and open a PR.` };
|
|
111
|
+
}
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Default deny
|
|
116
|
+
const reason = `Bash restricted on '${branch}'. Allowed: git status/log/diff/pull/stash, gh, bd.\n Exit: git checkout -b feature/<name>`;
|
|
117
|
+
if (ctx.hasUI) {
|
|
118
|
+
ctx.ui.notify("Main-Guard: Command blocked", "error");
|
|
119
|
+
}
|
|
120
|
+
return { block: true, reason };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return undefined;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { ExtensionAPI, ToolCallEvent, ToolResultEvent } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { isToolCallEventType, isBashToolResult } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
import { SubprocessRunner, Logger } from "./core/lib";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import * as fs from "node:fs";
|
|
6
|
+
|
|
7
|
+
const logger = new Logger({ namespace: "service-skills" });
|
|
8
|
+
|
|
9
|
+
export default function (pi: ExtensionAPI) {
|
|
10
|
+
const getCwd = (ctx: any) => ctx.cwd || process.cwd();
|
|
11
|
+
|
|
12
|
+
// 1. Catalog Injection
|
|
13
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
14
|
+
const cwd = getCwd(ctx);
|
|
15
|
+
const catalogerPath = path.join(cwd, ".claude", "skills", "using-service-skills", "scripts", "cataloger.py");
|
|
16
|
+
if (!fs.existsSync(catalogerPath)) return undefined;
|
|
17
|
+
|
|
18
|
+
const result = await SubprocessRunner.run("python3", [catalogerPath], {
|
|
19
|
+
cwd,
|
|
20
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: cwd }
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
if (result.code === 0 && result.stdout.trim()) {
|
|
24
|
+
return { systemPrompt: event.systemPrompt + "\n\n" + result.stdout.trim() };
|
|
25
|
+
}
|
|
26
|
+
return undefined;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// 2. Territory Activation
|
|
30
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
31
|
+
const cwd = getCwd(ctx);
|
|
32
|
+
const activatorPath = path.join(cwd, ".claude", "skills", "using-service-skills", "scripts", "skill_activator.py");
|
|
33
|
+
if (!fs.existsSync(activatorPath)) return undefined;
|
|
34
|
+
|
|
35
|
+
const hookInput = JSON.stringify({
|
|
36
|
+
tool_name: event.toolName === "bash" ? "Bash" : event.toolName,
|
|
37
|
+
tool_input: event.input,
|
|
38
|
+
cwd: cwd
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const result = await SubprocessRunner.run("python3", [activatorPath], {
|
|
42
|
+
cwd,
|
|
43
|
+
input: hookInput,
|
|
44
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: cwd },
|
|
45
|
+
timeoutMs: 5000
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
if (result.code === 0 && result.stdout.trim()) {
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(result.stdout.trim());
|
|
51
|
+
const context = parsed.hookSpecificOutput?.additionalContext;
|
|
52
|
+
if (context && ctx.hasUI) {
|
|
53
|
+
ctx.ui.notify(context, "info");
|
|
54
|
+
}
|
|
55
|
+
} catch (e) {
|
|
56
|
+
logger.error("Failed to parse skill_activator output", e);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// 3. Drift Detection
|
|
63
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
64
|
+
const cwd = getCwd(ctx);
|
|
65
|
+
const driftDetectorPath = path.join(cwd, ".claude", "skills", "updating-service-skills", "scripts", "drift_detector.py");
|
|
66
|
+
if (!fs.existsSync(driftDetectorPath)) return undefined;
|
|
67
|
+
|
|
68
|
+
const hookInput = JSON.stringify({
|
|
69
|
+
tool_name: event.toolName === "bash" ? "Bash" : event.toolName,
|
|
70
|
+
tool_input: event.input,
|
|
71
|
+
cwd: cwd
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const result = await SubprocessRunner.run("python3", [driftDetectorPath], {
|
|
75
|
+
cwd,
|
|
76
|
+
input: hookInput,
|
|
77
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: cwd },
|
|
78
|
+
timeoutMs: 10000
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
if (result.code === 0 && result.stdout.trim()) {
|
|
82
|
+
const newContent = [...event.content];
|
|
83
|
+
newContent.push({ type: "text", text: "\n\n" + result.stdout.trim() });
|
|
84
|
+
return { content: newContent };
|
|
85
|
+
}
|
|
86
|
+
return undefined;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
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
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { ExtensionHarness } from "./extension-harness";
|
|
3
|
+
import beadsExtension from "../../extensions/beads";
|
|
4
|
+
import { SubprocessRunner } from "../../extensions/core/lib";
|
|
5
|
+
import * as fs from "node:fs";
|
|
6
|
+
|
|
7
|
+
vi.mock("../../extensions/core/lib", async () => {
|
|
8
|
+
return {
|
|
9
|
+
SubprocessRunner: {
|
|
10
|
+
run: vi.fn(),
|
|
11
|
+
},
|
|
12
|
+
EventAdapter: {
|
|
13
|
+
isMutatingFileTool: vi.fn((event) => event.toolName === "write"),
|
|
14
|
+
},
|
|
15
|
+
Logger: vi.fn().mockImplementation(function() {
|
|
16
|
+
this.debug = vi.fn();
|
|
17
|
+
this.info = vi.fn();
|
|
18
|
+
this.warn = vi.fn();
|
|
19
|
+
this.error = vi.fn();
|
|
20
|
+
}),
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
vi.mock("node:fs", () => ({
|
|
25
|
+
existsSync: vi.fn(),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
describe("Beads Extension", () => {
|
|
29
|
+
let harness: ExtensionHarness;
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
vi.resetAllMocks();
|
|
33
|
+
harness = new ExtensionHarness();
|
|
34
|
+
(fs.existsSync as any).mockReturnValue(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("should block edits when claim check fails", async () => {
|
|
38
|
+
(SubprocessRunner.run as any).mockImplementation(async (cmd: string, args: string[]) => {
|
|
39
|
+
if (args[0] === "kv" && args[1] === "get") return { code: 1, stdout: "", stderr: "" };
|
|
40
|
+
if (args[0] === "list") {
|
|
41
|
+
return { code: 0, stdout: "Total: 5 issues (3 open, 2 in progress)", stderr: "" };
|
|
42
|
+
}
|
|
43
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
beadsExtension(harness.pi);
|
|
47
|
+
|
|
48
|
+
const result = await harness.emit("tool_call", {
|
|
49
|
+
toolName: "write",
|
|
50
|
+
input: { path: "src/main.ts" },
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(result).toBeDefined();
|
|
54
|
+
if (result) {
|
|
55
|
+
expect(result.block).toBe(true);
|
|
56
|
+
expect(result.reason).toContain("No active issue claim");
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("should allow edits when an issue is claimed", async () => {
|
|
61
|
+
(SubprocessRunner.run as any).mockImplementation(async (cmd: string, args: string[]) => {
|
|
62
|
+
if (args[0] === "kv" && args[1] === "get") return { code: 0, stdout: "issue-123", stderr: "" };
|
|
63
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
beadsExtension(harness.pi);
|
|
67
|
+
|
|
68
|
+
const result = await harness.emit("tool_call", {
|
|
69
|
+
toolName: "write",
|
|
70
|
+
input: { path: "src/main.ts" },
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(result).toBeUndefined();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("should block git commit when an issue is claimed", async () => {
|
|
77
|
+
(SubprocessRunner.run as any).mockImplementation(async (cmd: string, args: string[]) => {
|
|
78
|
+
if (args[0] === "kv" && args[1] === "get") return { code: 0, stdout: "issue-123", stderr: "" };
|
|
79
|
+
if (args[0] === "list") {
|
|
80
|
+
return { code: 0, stdout: "Total: 1 issues (0 open, 1 in progress)\n◐ issue-123 Title", stderr: "" };
|
|
81
|
+
}
|
|
82
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
beadsExtension(harness.pi);
|
|
86
|
+
|
|
87
|
+
const result = await harness.emit("tool_call", {
|
|
88
|
+
toolName: "bash",
|
|
89
|
+
input: { command: "git commit -m 'feat: something'" },
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(result).toBeDefined();
|
|
93
|
+
if (result) {
|
|
94
|
+
expect(result.block).toBe(true);
|
|
95
|
+
expect(result.reason).toContain("Resolve open claim [issue-123]");
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("should inject memory reminder on bd close", async () => {
|
|
100
|
+
(SubprocessRunner.run as any).mockResolvedValue({ code: 0, stdout: "", stderr: "" });
|
|
101
|
+
|
|
102
|
+
beadsExtension(harness.pi);
|
|
103
|
+
|
|
104
|
+
const result = await harness.emit("tool_result", {
|
|
105
|
+
toolName: "bash",
|
|
106
|
+
input: { command: "bd close issue-123" },
|
|
107
|
+
content: [{ type: "text", text: "Issue closed successfully." }],
|
|
108
|
+
isError: false,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
expect(result.content).toHaveLength(2);
|
|
112
|
+
expect(result.content[1].text).toContain("Beads Insight");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("should auto-claim session on bd update --claim", async () => {
|
|
116
|
+
const kvSetCalls: string[][] = [];
|
|
117
|
+
(SubprocessRunner.run as any).mockImplementation(async (cmd: string, args: string[]) => {
|
|
118
|
+
if (args[0] === "kv" && args[1] === "set") {
|
|
119
|
+
kvSetCalls.push(args);
|
|
120
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
121
|
+
}
|
|
122
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
beadsExtension(harness.pi);
|
|
126
|
+
|
|
127
|
+
const result = await harness.emit("tool_result", {
|
|
128
|
+
toolName: "bash",
|
|
129
|
+
input: { command: "bd update issue-456 --claim" },
|
|
130
|
+
content: [{ type: "text", text: "Updated issue: issue-456" }],
|
|
131
|
+
isError: false,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
expect(kvSetCalls.length).toBe(1);
|
|
135
|
+
expect(kvSetCalls[0][2]).toBe(`claimed:${process.pid}`);
|
|
136
|
+
expect(kvSetCalls[0][3]).toBe("issue-456");
|
|
137
|
+
expect(result.content[1].text).toContain("claimed issue");
|
|
138
|
+
expect(result.content[1].text).toContain("issue-456");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
it("should auto-claim even when bd update --claim returns exit 1 (already in_progress)", async () => {
|
|
143
|
+
const kvSetCalls: string[][] = [];
|
|
144
|
+
(SubprocessRunner.run as any).mockImplementation(async (cmd: string, args: string[]) => {
|
|
145
|
+
if (args[0] === "kv" && args[1] === "set") {
|
|
146
|
+
kvSetCalls.push(args);
|
|
147
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
148
|
+
}
|
|
149
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
beadsExtension(harness.pi);
|
|
153
|
+
|
|
154
|
+
const result = await harness.emit("tool_result", {
|
|
155
|
+
toolName: "bash",
|
|
156
|
+
input: { command: "bd update issue-789 --claim" },
|
|
157
|
+
content: [{ type: "text", text: "already in_progress" }],
|
|
158
|
+
isError: true,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
expect(kvSetCalls.length).toBe(1);
|
|
162
|
+
expect(kvSetCalls[0][2]).toBe(`claimed:${process.pid}`);
|
|
163
|
+
expect(kvSetCalls[0][3]).toBe("issue-789");
|
|
164
|
+
expect(result.content[1].text).toContain("issue-789");
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
export interface MockUI {
|
|
4
|
+
notify: any;
|
|
5
|
+
confirm: any;
|
|
6
|
+
select: any;
|
|
7
|
+
setStatus: any;
|
|
8
|
+
theme: {
|
|
9
|
+
fg: any;
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface MockSessionManager {
|
|
14
|
+
sessionId: string;
|
|
15
|
+
getEntries: any;
|
|
16
|
+
getLeafEntry: any;
|
|
17
|
+
getBranch: any;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface MockContext {
|
|
21
|
+
cwd: string;
|
|
22
|
+
hasUI: boolean;
|
|
23
|
+
ui: MockUI;
|
|
24
|
+
sessionManager: MockSessionManager;
|
|
25
|
+
getSystemPrompt: any;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class ExtensionHarness {
|
|
29
|
+
public handlers: Record<string, Function[]> = {};
|
|
30
|
+
public commands: Record<string, any> = {};
|
|
31
|
+
public tools: Record<string, any> = {};
|
|
32
|
+
public ctx: MockContext;
|
|
33
|
+
public pi: any;
|
|
34
|
+
|
|
35
|
+
constructor(cwd: string = "/mock/project") {
|
|
36
|
+
this.ctx = {
|
|
37
|
+
cwd,
|
|
38
|
+
hasUI: true,
|
|
39
|
+
ui: {
|
|
40
|
+
notify: vi.fn(),
|
|
41
|
+
confirm: vi.fn().mockResolvedValue(true),
|
|
42
|
+
select: vi.fn().mockResolvedValue(""),
|
|
43
|
+
setStatus: vi.fn(),
|
|
44
|
+
theme: {
|
|
45
|
+
fg: vi.fn((_color: string, text: string) => text),
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
sessionManager: {
|
|
49
|
+
sessionId: "mock-session-123",
|
|
50
|
+
getEntries: vi.fn().mockReturnValue([]),
|
|
51
|
+
getLeafEntry: vi.fn().mockReturnValue({ id: "last-entry" }),
|
|
52
|
+
getBranch: vi.fn().mockReturnValue([]),
|
|
53
|
+
},
|
|
54
|
+
getSystemPrompt: vi.fn().mockReturnValue("Default system prompt"),
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
this.pi = {
|
|
58
|
+
on: (event: string, handler: Function) => {
|
|
59
|
+
if (!this.handlers[event]) this.handlers[event] = [];
|
|
60
|
+
this.handlers[event].push(handler);
|
|
61
|
+
},
|
|
62
|
+
exec: vi.fn().mockResolvedValue({ code: 0, stdout: "", stderr: "" }),
|
|
63
|
+
registerCommand: (cmd: any) => {
|
|
64
|
+
this.commands[cmd.name] = cmd;
|
|
65
|
+
},
|
|
66
|
+
registerTool: (tool: any) => {
|
|
67
|
+
this.tools[tool.name] = tool;
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async emit(event: string, data: any) {
|
|
73
|
+
if (this.handlers[event]) {
|
|
74
|
+
let lastResult: any = undefined;
|
|
75
|
+
for (const handler of this.handlers[event]) {
|
|
76
|
+
console.log("EXECUTING HANDLER"); const res = await handler(data, this.ctx); console.log("HANDLER RESULT", res);
|
|
77
|
+
if (res !== undefined) {
|
|
78
|
+
lastResult = res; console.log("EMIT FOUND RESULT", res);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return lastResult;
|
|
82
|
+
}
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { ExtensionHarness } from "./extension-harness";
|
|
3
|
+
import mainGuardExtension from "../../extensions/main-guard";
|
|
4
|
+
import { SubprocessRunner } from "../../extensions/core/lib";
|
|
5
|
+
|
|
6
|
+
vi.mock("../../extensions/core/lib", async () => {
|
|
7
|
+
return {
|
|
8
|
+
SubprocessRunner: {
|
|
9
|
+
run: vi.fn(),
|
|
10
|
+
},
|
|
11
|
+
EventAdapter: {
|
|
12
|
+
isMutatingFileTool: vi.fn((event) => event.toolName === "write" || event.toolName === "edit"),
|
|
13
|
+
extractPathFromToolInput: vi.fn((event) => event.input.path),
|
|
14
|
+
},
|
|
15
|
+
Logger: vi.fn().mockImplementation(function() {
|
|
16
|
+
this.debug = vi.fn();
|
|
17
|
+
this.info = vi.fn();
|
|
18
|
+
this.warn = vi.fn();
|
|
19
|
+
this.error = vi.fn();
|
|
20
|
+
}),
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("Main Guard Extension", () => {
|
|
25
|
+
let harness: ExtensionHarness;
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
vi.resetAllMocks();
|
|
29
|
+
harness = new ExtensionHarness();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("should block edits on main branch", async () => {
|
|
33
|
+
(SubprocessRunner.run as any).mockResolvedValue({ code: 0, stdout: "main" });
|
|
34
|
+
|
|
35
|
+
mainGuardExtension(harness.pi);
|
|
36
|
+
|
|
37
|
+
const result = await harness.emit("tool_call", {
|
|
38
|
+
toolName: "write",
|
|
39
|
+
input: { path: "src/main.ts" },
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
expect(result).toEqual({
|
|
43
|
+
block: true,
|
|
44
|
+
reason: expect.stringContaining("On protected branch 'main'"),
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("should allow edits on feature branches", async () => {
|
|
49
|
+
(SubprocessRunner.run as any).mockResolvedValue({ code: 0, stdout: "feature/abc" });
|
|
50
|
+
|
|
51
|
+
mainGuardExtension(harness.pi);
|
|
52
|
+
|
|
53
|
+
const result = await harness.emit("tool_call", {
|
|
54
|
+
toolName: "write",
|
|
55
|
+
input: { path: "src/main.ts" },
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
expect(result).toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("should block rm -rf with confirmation", async () => {
|
|
62
|
+
(SubprocessRunner.run as any).mockResolvedValue({ code: 0, stdout: "feature/abc" });
|
|
63
|
+
harness.ctx.ui.confirm = vi.fn().mockResolvedValue(false);
|
|
64
|
+
|
|
65
|
+
mainGuardExtension(harness.pi);
|
|
66
|
+
|
|
67
|
+
const result = await harness.emit("tool_call", {
|
|
68
|
+
toolName: "bash",
|
|
69
|
+
input: { command: "rm -rf /important/stuff" },
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
expect(result).toEqual({
|
|
73
|
+
block: true,
|
|
74
|
+
reason: "Blocked by user confirmation",
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { ExtensionHarness } from "./extension-harness";
|
|
3
|
+
import qualityGatesExtension from "../../extensions/quality-gates";
|
|
4
|
+
import { SubprocessRunner } from "../../extensions/core/lib";
|
|
5
|
+
import * as fs from "node:fs";
|
|
6
|
+
|
|
7
|
+
vi.mock("../../extensions/core/lib", async () => {
|
|
8
|
+
return {
|
|
9
|
+
SubprocessRunner: {
|
|
10
|
+
run: vi.fn(),
|
|
11
|
+
},
|
|
12
|
+
EventAdapter: {
|
|
13
|
+
isMutatingFileTool: vi.fn((event) => event.toolName === "write" || event.toolName === "edit"),
|
|
14
|
+
extractPathFromToolInput: vi.fn((event) => event.input.path),
|
|
15
|
+
},
|
|
16
|
+
Logger: vi.fn().mockImplementation(function() {
|
|
17
|
+
this.debug = vi.fn();
|
|
18
|
+
this.info = vi.fn();
|
|
19
|
+
this.warn = vi.fn();
|
|
20
|
+
this.error = vi.fn();
|
|
21
|
+
}),
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
vi.mock("node:fs", () => ({
|
|
26
|
+
existsSync: vi.fn(),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
describe("Quality Gates Extension", () => {
|
|
30
|
+
let harness: ExtensionHarness;
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
vi.resetAllMocks();
|
|
34
|
+
harness = new ExtensionHarness();
|
|
35
|
+
(fs.existsSync as any).mockReturnValue(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("should run quality check for .ts files", async () => {
|
|
39
|
+
(SubprocessRunner.run as any).mockResolvedValue({
|
|
40
|
+
code: 0,
|
|
41
|
+
stdout: "Passed",
|
|
42
|
+
stderr: "ESLint auto-fixed issues",
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
qualityGatesExtension(harness.pi);
|
|
46
|
+
|
|
47
|
+
const result = await harness.emit("tool_result", {
|
|
48
|
+
toolName: "write",
|
|
49
|
+
input: { path: "src/main.ts" },
|
|
50
|
+
content: [{ type: "text", text: "Original content" }],
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(SubprocessRunner.run).toHaveBeenCalledWith(
|
|
54
|
+
"node",
|
|
55
|
+
expect.arrayContaining([expect.stringContaining("quality-check.cjs")]),
|
|
56
|
+
expect.any(Object)
|
|
57
|
+
);
|
|
58
|
+
expect(result.content[1].text).toContain("ESLint auto-fixed issues");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("should fail tool result when quality check returns status 2", async () => {
|
|
62
|
+
(SubprocessRunner.run as any).mockResolvedValue({
|
|
63
|
+
code: 2,
|
|
64
|
+
stdout: "",
|
|
65
|
+
stderr: "Compilation failed: error TS1234",
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
qualityGatesExtension(harness.pi);
|
|
69
|
+
|
|
70
|
+
const result = await harness.emit("tool_result", {
|
|
71
|
+
toolName: "write",
|
|
72
|
+
input: { path: "src/main.ts" },
|
|
73
|
+
content: [{ type: "text", text: "Original content" }],
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(result.isError).toBe(true);
|
|
77
|
+
expect(result.content[1].text).toContain("Compilation failed");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { ExtensionHarness } from "./extension-harness";
|
|
3
|
+
import xtrmLoaderExtension from "../../extensions/xtrm-loader";
|
|
4
|
+
import * as fs from "node:fs";
|
|
5
|
+
|
|
6
|
+
vi.mock("node:fs", () => ({
|
|
7
|
+
existsSync: vi.fn(),
|
|
8
|
+
readFileSync: vi.fn(),
|
|
9
|
+
readdirSync: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
describe("XTRM Loader Extension", () => {
|
|
13
|
+
let harness: ExtensionHarness;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
vi.resetAllMocks();
|
|
17
|
+
harness = new ExtensionHarness();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("should load project roadmap and rules", async () => {
|
|
21
|
+
(fs.existsSync as any).mockImplementation((p: string) => {
|
|
22
|
+
if (p.endsWith("ROADMAP.md")) return true;
|
|
23
|
+
if (p.endsWith(".claude/rules")) return true;
|
|
24
|
+
if (p.endsWith(".claude/skills")) return false;
|
|
25
|
+
return false;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
(fs.readFileSync as any).mockImplementation((p: string) => {
|
|
29
|
+
if (p.endsWith("ROADMAP.md")) return "My Roadmap Content";
|
|
30
|
+
if (p.endsWith("rule1.md")) return "Rule 1 Content";
|
|
31
|
+
return "";
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
(fs.readdirSync as any).mockImplementation((p: string) => {
|
|
35
|
+
if (p.endsWith(".claude/rules")) return [{ name: "rule1.md", isFile: () => true, isDirectory: () => false }];
|
|
36
|
+
return [];
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
xtrmLoaderExtension(harness.pi);
|
|
40
|
+
|
|
41
|
+
// Trigger session_start to load data
|
|
42
|
+
await harness.emit("session_start", {});
|
|
43
|
+
|
|
44
|
+
// Trigger before_agent_start to see injection
|
|
45
|
+
const result = await harness.emit("before_agent_start", {
|
|
46
|
+
systemPrompt: "Base prompt"
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
expect(result.systemPrompt).toContain("My Roadmap Content");
|
|
50
|
+
expect(result.systemPrompt).toContain("Rule 1 Content");
|
|
51
|
+
expect(harness.ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("context and skills indexed"), "info");
|
|
52
|
+
});
|
|
53
|
+
});
|