pi-teams 0.5.2 → 0.7.3

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.
@@ -1,8 +1,9 @@
1
+ // Project: pi-teams
1
2
  import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
2
3
  import fs from "node:fs";
3
4
  import path from "node:path";
4
5
  import os from "node:os";
5
- import { createTask, updateTask, readTask, listTasks } from "./tasks";
6
+ import { createTask, updateTask, readTask, listTasks, submitPlan, evaluatePlan } from "./tasks";
6
7
  import * as paths from "./paths";
7
8
  import * as teams from "./teams";
8
9
 
@@ -43,6 +44,24 @@ describe("Tasks Utilities", () => {
43
44
  expect(taskData.status).toBe("in_progress");
44
45
  });
45
46
 
47
+ it("should submit a plan successfully", async () => {
48
+ const task = await createTask("test-team", "Test Subject", "Test Description");
49
+ const plan = "Step 1: Do something\nStep 2: Profit";
50
+ const updated = await submitPlan("test-team", task.id, plan);
51
+ expect(updated.status).toBe("planning");
52
+ expect(updated.plan).toBe(plan);
53
+
54
+ const taskData = JSON.parse(fs.readFileSync(path.join(testDir, `${task.id}.json`), "utf-8"));
55
+ expect(taskData.status).toBe("planning");
56
+ expect(taskData.plan).toBe(plan);
57
+ });
58
+
59
+ it("should fail to submit an empty plan", async () => {
60
+ const task = await createTask("test-team", "Empty Test", "Should fail");
61
+ await expect(submitPlan("test-team", task.id, "")).rejects.toThrow("Plan must not be empty");
62
+ await expect(submitPlan("test-team", task.id, " ")).rejects.toThrow("Plan must not be empty");
63
+ });
64
+
46
65
  it("should list tasks", async () => {
47
66
  await createTask("test-team", "Task 1", "Desc 1");
48
67
  await createTask("test-team", "Task 2", "Desc 2");
@@ -74,4 +93,50 @@ describe("Tasks Utilities", () => {
74
93
 
75
94
  fs.unlinkSync(commonLockFile);
76
95
  });
96
+
97
+ it("should approve a plan successfully", async () => {
98
+ const task = await createTask("test-team", "Plan Test", "Should be approved");
99
+ await submitPlan("test-team", task.id, "Wait for it...");
100
+
101
+ const approved = await evaluatePlan("test-team", task.id, "approve");
102
+ expect(approved.status).toBe("in_progress");
103
+ expect(approved.planFeedback).toBe("");
104
+ });
105
+
106
+ it("should reject a plan with feedback", async () => {
107
+ const task = await createTask("test-team", "Plan Test", "Should be rejected");
108
+ await submitPlan("test-team", task.id, "Wait for it...");
109
+
110
+ const feedback = "Not good enough!";
111
+ const rejected = await evaluatePlan("test-team", task.id, "reject", feedback);
112
+ expect(rejected.status).toBe("planning");
113
+ expect(rejected.planFeedback).toBe(feedback);
114
+ });
115
+
116
+ it("should fail to evaluate a task not in 'planning' status", async () => {
117
+ const task = await createTask("test-team", "Status Test", "Invalid status for eval");
118
+ // status is "pending"
119
+ await expect(evaluatePlan("test-team", task.id, "approve")).rejects.toThrow("must be in 'planning' status");
120
+ });
121
+
122
+ it("should fail to evaluate a task without a plan", async () => {
123
+ const task = await createTask("test-team", "Plan Missing Test", "No plan submitted");
124
+ await updateTask("test-team", task.id, { status: "planning" }); // bypass submitPlan to have no plan
125
+ await expect(evaluatePlan("test-team", task.id, "approve")).rejects.toThrow("no plan has been submitted");
126
+ });
127
+
128
+ it("should fail to reject a plan without feedback", async () => {
129
+ const task = await createTask("test-team", "Feedback Test", "Should require feedback");
130
+ await submitPlan("test-team", task.id, "My plan");
131
+ await expect(evaluatePlan("test-team", task.id, "reject")).rejects.toThrow("Feedback is required when rejecting a plan");
132
+ await expect(evaluatePlan("test-team", task.id, "reject", " ")).rejects.toThrow("Feedback is required when rejecting a plan");
133
+ });
134
+
135
+ it("should sanitize task IDs in all file operations", async () => {
136
+ const dirtyId = "../evil-id";
137
+ // sanitizeName should throw on this dirtyId
138
+ await expect(readTask("test-team", dirtyId)).rejects.toThrow(/Invalid name: "..\/evil-id"/);
139
+ await expect(updateTask("test-team", dirtyId, { status: "in_progress" })).rejects.toThrow(/Invalid name: "..\/evil-id"/);
140
+ await expect(evaluatePlan("test-team", dirtyId, "approve")).rejects.toThrow(/Invalid name: "..\/evil-id"/);
141
+ });
77
142
  });
@@ -1,9 +1,11 @@
1
+ // Project: pi-teams
1
2
  import fs from "node:fs";
2
3
  import path from "node:path";
3
4
  import { TaskFile } from "./models";
4
5
  import { taskDir, sanitizeName } from "./paths";
5
6
  import { teamExists } from "./teams";
6
7
  import { withLock } from "./lock";
8
+ import { runHook } from "./hooks";
7
9
 
8
10
  export function getTaskId(teamName: string): string {
9
11
  const dir = taskDir(teamName);
@@ -12,6 +14,12 @@ export function getTaskId(teamName: string): string {
12
14
  return ids.length > 0 ? (Math.max(...ids) + 1).toString() : "1";
13
15
  }
14
16
 
17
+ function getTaskPath(teamName: string, taskId: string): string {
18
+ const dir = taskDir(teamName);
19
+ const safeTaskId = sanitizeName(taskId);
20
+ return path.join(dir, `${safeTaskId}.json`);
21
+ }
22
+
15
23
  export async function createTask(
16
24
  teamName: string,
17
25
  subject: string,
@@ -48,9 +56,7 @@ export async function updateTask(
48
56
  updates: Partial<TaskFile>,
49
57
  retries?: number
50
58
  ): Promise<TaskFile> {
51
- const dir = taskDir(teamName);
52
- const safeTaskId = sanitizeName(taskId);
53
- const p = path.join(dir, `${safeTaskId}.json`);
59
+ const p = getTaskPath(teamName, taskId);
54
60
 
55
61
  return await withLock(p, async () => {
56
62
  if (!fs.existsSync(p)) throw new Error(`Task ${taskId} not found`);
@@ -62,15 +68,81 @@ export async function updateTask(
62
68
  return updated;
63
69
  }
64
70
 
71
+ fs.writeFileSync(p, JSON.stringify(updated, null, 2));
72
+
73
+ if (updates.status === "completed") {
74
+ await runHook(teamName, "task_completed", updated);
75
+ }
76
+
77
+ return updated;
78
+ }, retries);
79
+ }
80
+
81
+ /**
82
+ * Submits a plan for a task, updating its status to "planning".
83
+ * @param teamName The name of the team
84
+ * @param taskId The ID of the task
85
+ * @param plan The content of the plan
86
+ * @returns The updated task
87
+ */
88
+ export async function submitPlan(teamName: string, taskId: string, plan: string): Promise<TaskFile> {
89
+ if (!plan || !plan.trim()) throw new Error("Plan must not be empty");
90
+ return await updateTask(teamName, taskId, { status: "planning", plan });
91
+ }
92
+
93
+ /**
94
+ * Evaluates a submitted plan for a task.
95
+ * @param teamName The name of the team
96
+ * @param taskId The ID of the task
97
+ * @param action The evaluation action: "approve" or "reject"
98
+ * @param feedback Optional feedback for the evaluation (required for rejection)
99
+ * @param retries Number of times to retry acquiring the lock
100
+ * @returns The updated task
101
+ */
102
+ export async function evaluatePlan(
103
+ teamName: string,
104
+ taskId: string,
105
+ action: "approve" | "reject",
106
+ feedback?: string,
107
+ retries?: number
108
+ ): Promise<TaskFile> {
109
+ const p = getTaskPath(teamName, taskId);
110
+
111
+ return await withLock(p, async () => {
112
+ if (!fs.existsSync(p)) throw new Error(`Task ${taskId} not found`);
113
+ const task: TaskFile = JSON.parse(fs.readFileSync(p, "utf-8"));
114
+
115
+ // 1. Validate state: Only "planning" tasks can be evaluated
116
+ if (task.status !== "planning") {
117
+ throw new Error(
118
+ `Cannot evaluate plan for task ${taskId} because its status is '${task.status}'. ` +
119
+ `Tasks must be in 'planning' status to be evaluated.`
120
+ );
121
+ }
122
+
123
+ // 2. Validate plan presence
124
+ if (!task.plan || !task.plan.trim()) {
125
+ throw new Error(`Cannot evaluate plan for task ${taskId} because no plan has been submitted.`);
126
+ }
127
+
128
+ // 3. Require feedback for rejections
129
+ if (action === "reject" && (!feedback || !feedback.trim())) {
130
+ throw new Error("Feedback is required when rejecting a plan.");
131
+ }
132
+
133
+ // 4. Perform update
134
+ const updates: Partial<TaskFile> = action === "approve"
135
+ ? { status: "in_progress", planFeedback: "" }
136
+ : { status: "planning", planFeedback: feedback };
137
+
138
+ const updated = { ...task, ...updates };
65
139
  fs.writeFileSync(p, JSON.stringify(updated, null, 2));
66
140
  return updated;
67
141
  }, retries);
68
142
  }
69
143
 
70
144
  export async function readTask(teamName: string, taskId: string, retries?: number): Promise<TaskFile> {
71
- const dir = taskDir(teamName);
72
- const safeTaskId = sanitizeName(taskId);
73
- const p = path.join(dir, `${safeTaskId}.json`);
145
+ const p = getTaskPath(teamName, taskId);
74
146
  if (!fs.existsSync(p)) throw new Error(`Task ${taskId} not found`);
75
147
  return await withLock(p, async () => {
76
148
  return JSON.parse(fs.readFileSync(p, "utf-8"));
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Terminal Adapter Interface
3
+ *
4
+ * Abstracts terminal multiplexer operations (tmux, iTerm2, Zellij)
5
+ * to provide a unified API for spawning, managing, and terminating panes.
6
+ */
7
+
8
+ import { spawnSync } from "node:child_process";
9
+
10
+ /**
11
+ * Options for spawning a new terminal pane
12
+ */
13
+ export interface SpawnOptions {
14
+ /** Name/identifier for the pane */
15
+ name: string;
16
+ /** Working directory for the new pane */
17
+ cwd: string;
18
+ /** Command to execute in the pane */
19
+ command: string;
20
+ /** Environment variables to set (key-value pairs) */
21
+ env: Record<string, string>;
22
+ }
23
+
24
+ /**
25
+ * Terminal Adapter Interface
26
+ *
27
+ * Implementations provide terminal-specific logic for pane management.
28
+ */
29
+ export interface TerminalAdapter {
30
+ /** Unique name identifier for this terminal type */
31
+ readonly name: string;
32
+
33
+ /**
34
+ * Detect if this terminal is currently available/active.
35
+ * Should check for terminal-specific environment variables or processes.
36
+ *
37
+ * @returns true if this terminal should be used
38
+ */
39
+ detect(): boolean;
40
+
41
+ /**
42
+ * Spawn a new terminal pane with the given options.
43
+ *
44
+ * @param options - Spawn configuration
45
+ * @returns Pane ID that can be used for subsequent operations
46
+ * @throws Error if spawn fails
47
+ */
48
+ spawn(options: SpawnOptions): string;
49
+
50
+ /**
51
+ * Kill/terminate a terminal pane.
52
+ * Should be idempotent - no error if pane doesn't exist.
53
+ *
54
+ * @param paneId - The pane ID returned from spawn()
55
+ */
56
+ kill(paneId: string): void;
57
+
58
+ /**
59
+ * Check if a terminal pane is still alive/active.
60
+ *
61
+ * @param paneId - The pane ID returned from spawn()
62
+ * @returns true if pane exists and is active
63
+ */
64
+ isAlive(paneId: string): boolean;
65
+
66
+ /**
67
+ * Set the title of the current terminal pane/window.
68
+ * Used for identifying panes in the terminal UI.
69
+ *
70
+ * @param title - The title to set
71
+ */
72
+ setTitle(title: string): void;
73
+ }
74
+
75
+ /**
76
+ * Base helper for adapters to execute commands synchronously.
77
+ */
78
+ export function execCommand(command: string, args: string[]): { stdout: string; stderr: string; status: number | null } {
79
+ const result = spawnSync(command, args, { encoding: "utf-8" });
80
+ return {
81
+ stdout: result.stdout?.toString() ?? "",
82
+ stderr: result.stderr?.toString() ?? "",
83
+ status: result.status,
84
+ };
85
+ }