xtrm-tools 2.4.3 → 2.4.4

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/cli/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xtrm-cli",
3
- "version": "2.4.3",
3
+ "version": "2.4.4",
4
4
  "description": "Claude Code tools installer (skills, hooks, MCP servers)",
5
5
  "main": "./dist/index.js",
6
6
  "type": "module",
@@ -78,7 +78,7 @@ export default function (pi: ExtensionAPI) {
78
78
  if (inProgress) {
79
79
  return {
80
80
  block: true,
81
- reason: `Active claim [${claim}] — close it first.\n bd close ${claim}\n git push -u origin <feature-branch>\n gh pr create --fill && gh pr merge --squash\n`,
81
+ reason: `Active claim [${claim}] — close it first.\n bd close ${claim}\n (Pi workflow) publish/merge are external steps; do not rely on xtrm finish.\n`,
82
82
  };
83
83
  }
84
84
  }
@@ -16,6 +16,7 @@ export const PI_MUTATING_FILE_TOOLS = [
16
16
  ];
17
17
 
18
18
  export const SAFE_BASH_PREFIXES = [
19
+ // Git read-only
19
20
  "git status",
20
21
  "git log",
21
22
  "git diff",
@@ -30,10 +31,41 @@ export const SAFE_BASH_PREFIXES = [
30
31
  "git worktree",
31
32
  "git checkout -b",
32
33
  "git switch -c",
34
+ // Tools
33
35
  "gh",
34
36
  "bd",
35
- "touch .beads/",
36
37
  "npx gitnexus",
38
+ "xtrm finish",
39
+ // Read-only filesystem
40
+ "cat",
41
+ "ls",
42
+ "head",
43
+ "tail",
44
+ "pwd",
45
+ "which",
46
+ "type",
47
+ "env",
48
+ "printenv",
49
+ "find",
50
+ "grep",
51
+ "rg",
52
+ "fd",
53
+ "wc",
54
+ "sort",
55
+ "uniq",
56
+ "cut",
57
+ "awk",
58
+ "jq",
59
+ "yq",
60
+ "bat",
61
+ "less",
62
+ "more",
63
+ "file",
64
+ "stat",
65
+ "du",
66
+ "tree",
67
+ // Allowed writes (specific paths)
68
+ "touch .beads/",
37
69
  ];
38
70
 
39
71
  export const DANGEROUS_BASH_PATTERNS = [
@@ -67,4 +99,4 @@ export const DANGEROUS_BASH_PATTERNS = [
67
99
  "(?:^|\\s)python\\s+-c\\b",
68
100
  "(?:^|\\s)perl\\s+-e\\b",
69
101
  "(?:^|\\s)ruby\\s+-e\\b",
70
- ];
102
+ ];
@@ -132,14 +132,14 @@ export default function (pi: ExtensionAPI) {
132
132
  const modelId = ctx.model?.id || "no-model";
133
133
  const modelChip = chip(modelId);
134
134
 
135
- const sep = theme.fg("dim", " | ");
135
+ const sep = " ";
136
136
 
137
137
  const brandModel = `${brand} ${modelChip}`;
138
- const leftParts = [brandModel, usageStr, cwdStr];
139
-
140
138
  const beadChip = buildBeadChip();
141
- const branchWithChip = branchStr ? `${branchStr} ${beadChip}`.trim() : beadChip;
142
- if (branchWithChip) leftParts.push(branchWithChip);
139
+ const leftParts = [brandModel, usageStr];
140
+ if (beadChip) leftParts.push(beadChip);
141
+ leftParts.push(cwdStr);
142
+ if (branchStr) leftParts.push(branchStr);
143
143
 
144
144
  const left = leftParts.join(sep);
145
145
  return [truncateToWidth(left, width)];
@@ -16,7 +16,17 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core";
16
16
  import type { AssistantMessage, TextContent } from "@mariozechner/pi-ai";
17
17
  import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
18
18
  import { Key } from "@mariozechner/pi-tui";
19
- import { extractTodoItems, isSafeCommand, markCompletedSteps, type TodoItem } from "./utils.js";
19
+ import {
20
+ extractTodoItems,
21
+ isSafeCommand,
22
+ markCompletedSteps,
23
+ type TodoItem,
24
+ getShortId,
25
+ isBeadsProject,
26
+ deriveEpicTitle,
27
+ createPlanIssues,
28
+ bdClaim,
29
+ } from "./utils.js";
20
30
 
21
31
  // Tools
22
32
  const PLAN_MODE_TOOLS = ["read", "bash", "grep", "find", "ls", "questionnaire"];
@@ -39,6 +49,8 @@ export default function planModeExtension(pi: ExtensionAPI): void {
39
49
  let planModeEnabled = false;
40
50
  let executionMode = false;
41
51
  let todoItems: TodoItem[] = [];
52
+ let epicId: string | null = null;
53
+ let issueIds: Map<number, string> = new Map(); // step -> issue ID
42
54
 
43
55
  pi.registerFlag("plan", {
44
56
  description: "Start in plan mode (read-only exploration)",
@@ -57,15 +69,17 @@ export default function planModeExtension(pi: ExtensionAPI): void {
57
69
  ctx.ui.setStatus("plan-mode", undefined);
58
70
  }
59
71
 
60
- // Widget showing todo list
72
+ // Widget showing todo list with bd issue IDs
61
73
  if (executionMode && todoItems.length > 0) {
62
74
  const lines = todoItems.map((item) => {
75
+ const issueId = issueIds.get(item.step);
76
+ const idLabel = issueId ? `[${getShortId(issueId)}] ` : "";
63
77
  if (item.completed) {
64
78
  return (
65
- ctx.ui.theme.fg("success", "☑ ") + ctx.ui.theme.fg("muted", ctx.ui.theme.strikethrough(item.text))
79
+ ctx.ui.theme.fg("success", "☑ ") + ctx.ui.theme.fg("muted", ctx.ui.theme.strikethrough(`${idLabel}${item.text}`))
66
80
  );
67
81
  }
68
- return `${ctx.ui.theme.fg("muted", "☐ ")}${item.text}`;
82
+ return `${ctx.ui.theme.fg("muted", "☐ ")}${idLabel}${item.text}`;
69
83
  });
70
84
  ctx.ui.setWidget("plan-todos", lines);
71
85
  } else {
@@ -77,6 +91,8 @@ export default function planModeExtension(pi: ExtensionAPI): void {
77
91
  planModeEnabled = !planModeEnabled;
78
92
  executionMode = false;
79
93
  todoItems = [];
94
+ epicId = null;
95
+ issueIds.clear();
80
96
 
81
97
  if (planModeEnabled) {
82
98
  pi.setActiveTools(PLAN_MODE_TOOLS);
@@ -93,6 +109,8 @@ export default function planModeExtension(pi: ExtensionAPI): void {
93
109
  enabled: planModeEnabled,
94
110
  todos: todoItems,
95
111
  executing: executionMode,
112
+ epicId,
113
+ issueIds: Object.fromEntries(issueIds),
96
114
  });
97
115
  }
98
116
 
@@ -108,7 +126,11 @@ export default function planModeExtension(pi: ExtensionAPI): void {
108
126
  ctx.ui.notify("No todos. Create a plan first with /plan", "info");
109
127
  return;
110
128
  }
111
- const list = todoItems.map((item, i) => `${i + 1}. ${item.completed ? "✓" : "○"} ${item.text}`).join("\n");
129
+ const list = todoItems.map((item, i) => {
130
+ const issueId = issueIds.get(item.step);
131
+ const idLabel = issueId ? `[${getShortId(issueId)}] ` : "";
132
+ return `${i + 1}. ${item.completed ? "✓" : "○"} ${idLabel}${item.text}`;
133
+ }).join("\n");
112
134
  ctx.ui.notify(`Plan Progress:\n${list}`, "info");
113
135
  },
114
136
  });
@@ -187,7 +209,11 @@ Do NOT attempt to make changes - just describe what you would do.`,
187
209
 
188
210
  if (executionMode && todoItems.length > 0) {
189
211
  const remaining = todoItems.filter((t) => !t.completed);
190
- const todoList = remaining.map((t) => `${t.step}. ${t.text}`).join("\n");
212
+ const todoList = remaining.map((t) => {
213
+ const issueId = issueIds.get(t.step);
214
+ const idLabel = issueId ? `[${getShortId(issueId)}] ` : "";
215
+ return `${t.step}. ${idLabel}${t.text}`;
216
+ }).join("\n");
191
217
  return {
192
218
  message: {
193
219
  customType: "plan-execution-context",
@@ -221,13 +247,19 @@ After completing a step, include a [DONE:n] tag in your response.`,
221
247
  // Check if execution is complete
222
248
  if (executionMode && todoItems.length > 0) {
223
249
  if (todoItems.every((t) => t.completed)) {
224
- const completedList = todoItems.map((t) => `~~${t.text}~~`).join("\n");
250
+ const completedList = todoItems.map((t) => {
251
+ const issueId = issueIds.get(t.step);
252
+ const idLabel = issueId ? `[${getShortId(issueId)}] ` : "";
253
+ return `~~${idLabel}${t.text}~~`;
254
+ }).join("\n");
225
255
  pi.sendMessage(
226
256
  { customType: "plan-complete", content: `**Plan Complete!** ✓\n\n${completedList}`, display: true },
227
257
  { triggerTurn: false },
228
258
  );
229
259
  executionMode = false;
230
260
  todoItems = [];
261
+ epicId = null;
262
+ issueIds.clear();
231
263
  pi.setActiveTools(NORMAL_MODE_TOOLS);
232
264
  updateStatus(ctx);
233
265
  persistState(); // Save cleared state so resume doesn't restore old execution mode
@@ -246,31 +278,72 @@ After completing a step, include a [DONE:n] tag in your response.`,
246
278
  }
247
279
  }
248
280
 
249
- // Show plan steps and prompt for next action
281
+ // Auto-create epic + issues if in a beads project
282
+ const cwd = ctx.cwd || process.cwd();
283
+ if (todoItems.length > 0 && isBeadsProject(cwd) && !epicId) {
284
+ // Derive epic title from user prompt or first step
285
+ const epicTitle = deriveEpicTitle(event.messages);
286
+
287
+ ctx.ui.notify("Creating epic and issues in bd...", "info");
288
+ const result = await createPlanIssues(epicTitle, todoItems, cwd);
289
+
290
+ if (result) {
291
+ epicId = result.epic.id;
292
+ for (const issue of result.issues) {
293
+ // Match issue to todo by title similarity
294
+ const matchingTodo = todoItems.find(t =>
295
+ issue.title.toLowerCase().includes(t.text.toLowerCase().slice(0, 30)) ||
296
+ t.text.toLowerCase().includes(issue.title.toLowerCase().slice(0, 30))
297
+ );
298
+ if (matchingTodo) {
299
+ issueIds.set(matchingTodo.step, issue.id);
300
+ }
301
+ }
302
+ ctx.ui.notify(`Created epic ${getShortId(epicId)} with ${result.issues.length} issues`, "info");
303
+ }
304
+ }
305
+
306
+ // Show plan steps with bd issue IDs
250
307
  if (todoItems.length > 0) {
251
- const todoListText = todoItems.map((t, i) => `${i + 1}. ☐ ${t.text}`).join("\n");
308
+ const todoListText = todoItems.map((t, i) => {
309
+ const issueId = issueIds.get(t.step);
310
+ const idLabel = issueId ? `[${getShortId(issueId)}] ` : "";
311
+ return `${i + 1}. ☐ ${idLabel}${t.text}`;
312
+ }).join("\n");
313
+
314
+ const epicLabel = epicId ? `\n\nEpic: ${getShortId(epicId)}` : "";
252
315
  pi.sendMessage(
253
316
  {
254
317
  customType: "plan-todo-list",
255
- content: `**Plan Steps (${todoItems.length}):**\n\n${todoListText}`,
318
+ content: `**Plan Steps (${todoItems.length}):**${epicLabel}\n\n${todoListText}`,
256
319
  display: true,
257
320
  },
258
321
  { triggerTurn: false },
259
322
  );
260
323
  }
261
324
 
262
- const choice = await ctx.ui.select("Plan mode - what next?", [
263
- todoItems.length > 0 ? "Execute the plan (track progress)" : "Execute the plan",
325
+ // Show "Start implementation?" prompt (no approval dialog for issue creation)
326
+ const choice = await ctx.ui.select("Start implementation?", [
327
+ todoItems.length > 0 ? "Yes, start with first step" : "Yes",
264
328
  "Stay in plan mode",
265
329
  "Refine the plan",
266
330
  ]);
267
331
 
268
- if (choice?.startsWith("Execute")) {
332
+ if (choice?.startsWith("Yes")) {
333
+ // Auto-exit plan mode and start execution
269
334
  planModeEnabled = false;
270
335
  executionMode = todoItems.length > 0;
271
336
  pi.setActiveTools(NORMAL_MODE_TOOLS);
272
337
  updateStatus(ctx);
273
338
 
339
+ // Auto-claim first issue if available
340
+ if (todoItems.length > 0 && issueIds.size > 0) {
341
+ const firstIssueId = issueIds.get(todoItems[0].step);
342
+ if (firstIssueId) {
343
+ await bdClaim(firstIssueId, cwd);
344
+ }
345
+ }
346
+
274
347
  const execMessage =
275
348
  todoItems.length > 0
276
349
  ? `Execute the plan. Start with: ${todoItems[0].text}`
@@ -298,12 +371,16 @@ After completing a step, include a [DONE:n] tag in your response.`,
298
371
  // Restore persisted state
299
372
  const planModeEntry = entries
300
373
  .filter((e: { type: string; customType?: string }) => e.type === "custom" && e.customType === "plan-mode")
301
- .pop() as { data?: { enabled: boolean; todos?: TodoItem[]; executing?: boolean } } | undefined;
374
+ .pop() as { data?: { enabled: boolean; todos?: TodoItem[]; executing?: boolean; epicId?: string; issueIds?: Record<number, string> } } | undefined;
302
375
 
303
376
  if (planModeEntry?.data) {
304
377
  planModeEnabled = planModeEntry.data.enabled ?? planModeEnabled;
305
378
  todoItems = planModeEntry.data.todos ?? todoItems;
306
379
  executionMode = planModeEntry.data.executing ?? executionMode;
380
+ epicId = planModeEntry.data.epicId ?? null;
381
+ if (planModeEntry.data.issueIds) {
382
+ issueIds = new Map(Object.entries(planModeEntry.data.issueIds).map(([k, v]) => [Number(k), v]));
383
+ }
307
384
  }
308
385
 
309
386
  // On resume: re-scan messages to rebuild completion state
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "@xtrm/pi-plan-mode",
3
+ "version": "1.0.0",
4
+ "description": "Plan mode extension for Pi - read-only exploration with plan extraction and bd integration",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./index.ts"
8
+ },
9
+ "keywords": ["pi", "extension", "plan-mode", "xtrm"],
10
+ "author": "xtrm",
11
+ "license": "MIT"
12
+ }
@@ -3,6 +3,10 @@
3
3
  * Extracted for testability.
4
4
  */
5
5
 
6
+ import { existsSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { spawnSync } from "node:child_process";
9
+
6
10
  // Destructive commands blocked in plan mode
7
11
  const DESTRUCTIVE_PATTERNS = [
8
12
  /\brm\b/i,
@@ -134,7 +138,8 @@ export function extractTodoItems(message: string): TodoItem[] {
134
138
  const planSection = message.slice(message.indexOf(headerMatch[0]) + headerMatch[0].length);
135
139
  const numberedPattern = /^\s*(\d+)[.)]\s+\*{0,2}([^*\n]+)/gm;
136
140
 
137
- for (const match of planSection.matchAll(numberedPattern)) {
141
+ const matches = Array.from(planSection.matchAll(numberedPattern));
142
+ for (const match of matches) {
138
143
  const text = match[2]
139
144
  .trim()
140
145
  .replace(/\*{1,2}$/, "")
@@ -151,7 +156,8 @@ export function extractTodoItems(message: string): TodoItem[] {
151
156
 
152
157
  export function extractDoneSteps(message: string): number[] {
153
158
  const steps: number[] = [];
154
- for (const match of message.matchAll(/\[DONE:(\d+)\]/gi)) {
159
+ const matches = Array.from(message.matchAll(/\[DONE:(\d+)\]/gi));
160
+ for (const match of matches) {
155
161
  const step = Number(match[1]);
156
162
  if (Number.isFinite(step)) steps.push(step);
157
163
  }
@@ -166,3 +172,153 @@ export function markCompletedSteps(text: string, items: TodoItem[]): number {
166
172
  }
167
173
  return doneSteps.length;
168
174
  }
175
+
176
+ // =============================================================================
177
+ // bd (beads) Integration Functions
178
+ // =============================================================================
179
+
180
+ /**
181
+ * Extract short ID from full bd issue ID.
182
+ * Example: "jaggers-agent-tools-xr9b.1" → "xr9b.1"
183
+ */
184
+ export function getShortId(fullId: string): string {
185
+ const parts = fullId.split("-");
186
+ // Last part is the ID (e.g., "xr9b.1")
187
+ return parts[parts.length - 1];
188
+ }
189
+
190
+ /**
191
+ * Check if a directory is a beads project (has .beads directory).
192
+ */
193
+ export function isBeadsProject(cwd: string): boolean {
194
+ return existsSync(join(cwd, ".beads"));
195
+ }
196
+
197
+ /**
198
+ * Derive epic title from user prompt or conversation messages.
199
+ */
200
+ export function deriveEpicTitle(messages: Array<{ role: string; content?: unknown }>): string {
201
+ // Find the last user message
202
+ for (let i = messages.length - 1; i >= 0; i--) {
203
+ const msg = messages[i];
204
+ if (msg.role === "user") {
205
+ const content = msg.content;
206
+ if (typeof content === "string") {
207
+ // Extract first sentence or first 50 chars
208
+ const firstSentence = content.split(/[.!?\n]/)[0].trim();
209
+ if (firstSentence.length > 10 && firstSentence.length < 80) {
210
+ return firstSentence;
211
+ }
212
+ if (firstSentence.length >= 80) {
213
+ return `${firstSentence.slice(0, 77)}...`;
214
+ }
215
+ }
216
+ }
217
+ }
218
+ return "Plan execution";
219
+ }
220
+
221
+ /**
222
+ * Run a bd command and return the result.
223
+ */
224
+ function runBd(args: string[], cwd: string): { stdout: string; stderr: string; status: number } {
225
+ const result = spawnSync("bd", args, {
226
+ cwd,
227
+ encoding: "utf8",
228
+ timeout: 30000,
229
+ });
230
+ return {
231
+ stdout: result.stdout || "",
232
+ stderr: result.stderr || "",
233
+ status: result.status ?? 1,
234
+ };
235
+ }
236
+
237
+ /**
238
+ * Create an epic in bd.
239
+ */
240
+ export function bdCreateEpic(title: string, cwd: string): { id: string; title: string } | null {
241
+ const result = runBd(["create", title, "-t", "epic", "-p", "1", "--json"], cwd);
242
+ if (result.status === 0) {
243
+ try {
244
+ const data = JSON.parse(result.stdout);
245
+ if (Array.isArray(data) && data[0]) {
246
+ return { id: data[0].id, title: data[0].title };
247
+ }
248
+ } catch {
249
+ // Parse the ID from stdout if JSON parse fails
250
+ const match = result.stdout.match(/Created issue:\s*(\S+)/);
251
+ if (match) {
252
+ return { id: match[1], title };
253
+ }
254
+ }
255
+ }
256
+ return null;
257
+ }
258
+
259
+ /**
260
+ * Create a task issue in bd under an epic.
261
+ */
262
+ export function bdCreateIssue(
263
+ title: string,
264
+ description: string,
265
+ parentId: string,
266
+ cwd: string,
267
+ ): { id: string; title: string } | null {
268
+ const result = runBd(
269
+ ["create", title, "-t", "task", "-p", "1", "--parent", parentId, "-d", description, "--json"],
270
+ cwd,
271
+ );
272
+ if (result.status === 0) {
273
+ try {
274
+ const data = JSON.parse(result.stdout);
275
+ if (Array.isArray(data) && data[0]) {
276
+ return { id: data[0].id, title: data[0].title };
277
+ }
278
+ } catch {
279
+ const match = result.stdout.match(/Created issue:\s*(\S+)/);
280
+ if (match) {
281
+ return { id: match[1], title };
282
+ }
283
+ }
284
+ }
285
+ return null;
286
+ }
287
+
288
+ /**
289
+ * Claim an issue in bd.
290
+ */
291
+ export function bdClaim(issueId: string, cwd: string): boolean {
292
+ const result = runBd(["update", issueId, "--claim"], cwd);
293
+ return result.status === 0;
294
+ }
295
+
296
+ /**
297
+ * Result of creating plan issues.
298
+ */
299
+ export interface PlanIssuesResult {
300
+ epic: { id: string; title: string };
301
+ issues: Array<{ id: string; title: string }>;
302
+ }
303
+
304
+ /**
305
+ * Create an epic and issues from todo items.
306
+ */
307
+ export function createPlanIssues(
308
+ epicTitle: string,
309
+ todos: TodoItem[],
310
+ cwd: string,
311
+ ): PlanIssuesResult | null {
312
+ const epic = bdCreateEpic(epicTitle, cwd);
313
+ if (!epic) return null;
314
+
315
+ const issues: Array<{ id: string; title: string }> = [];
316
+ for (const todo of todos) {
317
+ const issue = bdCreateIssue(todo.text, `Step ${todo.step} of plan: ${epicTitle}`, epic.id, cwd);
318
+ if (issue) {
319
+ issues.push(issue);
320
+ }
321
+ }
322
+
323
+ return { epic, issues };
324
+ }
@@ -84,7 +84,12 @@ export default function (pi: ExtensionAPI) {
84
84
 
85
85
  const ensured = await ensureWorktreeSessionState(cwd, issueId);
86
86
  if (ensured.ok) {
87
- const text = `\n\n🧭 Session Flow: ${ensured.message}`;
87
+ const state = readSessionState(cwd);
88
+ const worktreePath = state?.worktreePath;
89
+ const nextStep = worktreePath
90
+ ? `\nNext: cd ${worktreePath} && pi (sandboxed session)`
91
+ : "";
92
+ const text = `\n\n🧭 Session Flow: ${ensured.message}${nextStep}`;
88
93
  return { content: [...event.content, { type: "text", text }] };
89
94
  }
90
95
  return undefined;
@@ -99,18 +104,27 @@ export default function (pi: ExtensionAPI) {
99
104
  if (state.phase === "waiting-merge" || state.phase === "pending-cleanup") {
100
105
  const pr = state.prNumber != null ? `#${state.prNumber}` : "(pending PR)";
101
106
  const url = state.prUrl ? ` ${state.prUrl}` : "";
102
- pi.sendUserMessage(`🚫 PR ${pr}${url} not yet merged. Run: xtrm finish`);
107
+ pi.sendUserMessage(
108
+ `⚠ PR ${pr}${url} is still pending. xtrm finish is deprecated for Pi workflow. ` +
109
+ "Use xtpi publish (when available) and external merge/cleanup steps.",
110
+ );
103
111
  return undefined;
104
112
  }
105
113
 
106
114
  if (state.phase === "conflicting") {
107
115
  const files = state.conflictFiles?.length ? state.conflictFiles.join(", ") : "unknown files";
108
- pi.sendUserMessage(`🚫 Merge conflicts in: ${files}. Resolve, push, then: xtrm finish`);
116
+ pi.sendUserMessage(
117
+ `⚠ Conflicts in: ${files}. xtrm finish is deprecated for Pi workflow. ` +
118
+ "Resolve conflicts, then continue with publish-only flow.",
119
+ );
109
120
  return undefined;
110
121
  }
111
122
 
112
123
  if (state.phase === "claimed" || state.phase === "phase1-done") {
113
- pi.sendUserMessage(`⚠ Session has an active worktree at ${state.worktreePath}. Consider running: xtrm finish`);
124
+ pi.sendUserMessage(
125
+ `⚠ Session has an active worktree at ${state.worktreePath}. ` +
126
+ "Use publish-only workflow (no automatic push/PR/merge).",
127
+ );
114
128
  }
115
129
  return undefined;
116
130
  });