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.
- package/README.md +49 -71
- package/extensions/index.ts +161 -144
- package/package.json +1 -1
- package/src/adapters/iterm2-adapter.ts +158 -0
- package/src/adapters/terminal-registry.ts +92 -0
- package/src/adapters/tmux-adapter.ts +77 -0
- package/src/adapters/zellij-adapter.ts +62 -0
- package/src/utils/hooks.test.ts +75 -0
- package/src/utils/hooks.ts +35 -0
- package/src/utils/lock.test.ts +1 -0
- package/src/utils/lock.ts +4 -2
- package/src/utils/messaging.test.ts +36 -1
- package/src/utils/messaging.ts +35 -0
- package/src/utils/models.ts +4 -1
- package/src/utils/tasks.test.ts +66 -1
- package/src/utils/tasks.ts +78 -6
- package/src/utils/terminal-adapter.ts +85 -0
package/src/utils/tasks.test.ts
CHANGED
|
@@ -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
|
});
|
package/src/utils/tasks.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
+
}
|