palmier 0.1.0

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.
@@ -0,0 +1,224 @@
1
+ import { execSync } from "child_process";
2
+ import * as fs from "fs";
3
+ import { StringCodec } from "nats";
4
+ import { loadConfig } from "../config.js";
5
+ import { connectNats } from "../nats-client.js";
6
+ import { listTasks, parseTaskFile, writeTaskFile, getTaskDir } from "../task.js";
7
+ import { installTaskTimer, removeTaskTimer, getTaskStatus } from "../systemd.js";
8
+ import type { RpcMessage, TaskWithStatus } from "../types.js";
9
+
10
+ /**
11
+ * Start the persistent NATS RPC handler.
12
+ */
13
+ export async function serveCommand(): Promise<void> {
14
+ const config = loadConfig();
15
+ const nc = await connectNats(config);
16
+ const sc = StringCodec();
17
+
18
+ const subject = `user.${config.userId}.agent.${config.agentId}.rpc.*`;
19
+ console.log(`Subscribing to: ${subject}`);
20
+
21
+ const sub = nc.subscribe(subject);
22
+
23
+ // Graceful shutdown
24
+ const shutdown = async () => {
25
+ console.log("Shutting down...");
26
+ sub.unsubscribe();
27
+ await nc.drain();
28
+ process.exit(0);
29
+ };
30
+
31
+ process.on("SIGINT", shutdown);
32
+ process.on("SIGTERM", shutdown);
33
+
34
+ // On startup, clean up orphaned pending-hooks keys for this agent
35
+ try {
36
+ const js = nc.jetstream();
37
+ const kv = await js.views.kv("pending-hooks");
38
+ const keys = await kv.keys();
39
+ for await (const key of keys) {
40
+ if (key.startsWith(`${config.agentId}.`)) {
41
+ console.log(`Cleaning up orphaned hook key: ${key}`);
42
+ await kv.delete(key);
43
+ }
44
+ }
45
+ } catch (err) {
46
+ console.error(`Warning: could not clean up pending-hooks KV: ${err}`);
47
+ }
48
+
49
+ console.log("Agent serving. Waiting for RPC messages...");
50
+
51
+ for await (const msg of sub) {
52
+ let request: RpcMessage;
53
+ try {
54
+ request = JSON.parse(sc.decode(msg.data)) as RpcMessage;
55
+ } catch {
56
+ console.error("Failed to parse RPC message");
57
+ if (msg.reply) {
58
+ msg.respond(sc.encode(JSON.stringify({ error: "Invalid JSON" })));
59
+ }
60
+ continue;
61
+ }
62
+
63
+ console.log(`RPC: ${request.method}`);
64
+
65
+ let response: unknown;
66
+ try {
67
+ response = await handleRpc(request);
68
+ } catch (err) {
69
+ console.error(`RPC error (${request.method}):`, err);
70
+ response = { error: String(err) };
71
+ }
72
+
73
+ if (msg.reply) {
74
+ msg.respond(sc.encode(JSON.stringify(response)));
75
+ }
76
+ }
77
+
78
+ async function handleRpc(request: RpcMessage): Promise<unknown> {
79
+ switch (request.method) {
80
+ case "task.list": {
81
+ const tasks = listTasks(config.projectRoot);
82
+ const tasksWithStatus: TaskWithStatus[] = tasks.map((task) => ({
83
+ ...task,
84
+ status: getTaskStatus(task.frontmatter.id),
85
+ }));
86
+ return { tasks: tasksWithStatus };
87
+ }
88
+
89
+ case "task.create": {
90
+ const params = request.params as {
91
+ id: string;
92
+ name: string;
93
+ user_prompt: string;
94
+ triggers: Array<{ type: "cron" | "once"; value: string }>;
95
+ requires_confirmation: boolean;
96
+ suppress_permissions: boolean;
97
+ enabled: boolean;
98
+ body: string;
99
+ };
100
+
101
+ const taskDir = getTaskDir(config.projectRoot, params.id);
102
+ const task = {
103
+ frontmatter: {
104
+ id: params.id,
105
+ name: params.name,
106
+ user_prompt: params.user_prompt,
107
+ triggers: params.triggers || [],
108
+ requires_confirmation: params.requires_confirmation ?? true,
109
+ suppress_permissions: params.suppress_permissions ?? false,
110
+ enabled: params.enabled ?? true,
111
+ },
112
+ body: params.body || "",
113
+ };
114
+
115
+ writeTaskFile(taskDir, task);
116
+ installTaskTimer(config, task);
117
+
118
+ return { ok: true, task_id: params.id };
119
+ }
120
+
121
+ case "task.update": {
122
+ const params = request.params as {
123
+ id: string;
124
+ name?: string;
125
+ user_prompt?: string;
126
+ triggers?: Array<{ type: "cron" | "once"; value: string }>;
127
+ requires_confirmation?: boolean;
128
+ suppress_permissions?: boolean;
129
+ enabled?: boolean;
130
+ body?: string;
131
+ };
132
+
133
+ const taskDir = getTaskDir(config.projectRoot, params.id);
134
+ const existing = parseTaskFile(taskDir);
135
+
136
+ // Merge updates
137
+ if (params.name !== undefined) existing.frontmatter.name = params.name;
138
+ if (params.user_prompt !== undefined) existing.frontmatter.user_prompt = params.user_prompt;
139
+ if (params.triggers !== undefined) existing.frontmatter.triggers = params.triggers;
140
+ if (params.requires_confirmation !== undefined)
141
+ existing.frontmatter.requires_confirmation = params.requires_confirmation;
142
+ if (params.suppress_permissions !== undefined)
143
+ existing.frontmatter.suppress_permissions = params.suppress_permissions;
144
+ if (params.enabled !== undefined) existing.frontmatter.enabled = params.enabled;
145
+ if (params.body !== undefined) existing.body = params.body;
146
+
147
+ writeTaskFile(taskDir, existing);
148
+
149
+ // Reinstall timer with updated config
150
+ removeTaskTimer(params.id);
151
+ installTaskTimer(config, existing);
152
+
153
+ return { ok: true, task_id: params.id };
154
+ }
155
+
156
+ case "task.delete": {
157
+ const params = request.params as { id: string };
158
+ const taskDir = getTaskDir(config.projectRoot, params.id);
159
+
160
+ removeTaskTimer(params.id);
161
+
162
+ // Remove task directory
163
+ if (fs.existsSync(taskDir)) {
164
+ fs.rmSync(taskDir, { recursive: true, force: true });
165
+ }
166
+
167
+ return { ok: true, task_id: params.id };
168
+ }
169
+
170
+ case "task.generate": {
171
+ const params = request.params as { prompt: string };
172
+
173
+ try {
174
+ const output = execSync(`claude -p "${params.prompt.replace(/"/g, '\\"')}"`, {
175
+ encoding: "utf-8",
176
+ cwd: config.projectRoot,
177
+ timeout: 120_000,
178
+ });
179
+ return { ok: true, output };
180
+ } catch (err: unknown) {
181
+ const error = err as { stdout?: string; stderr?: string };
182
+ return { error: "claude command failed", stdout: error.stdout, stderr: error.stderr };
183
+ }
184
+ }
185
+
186
+ case "task.run": {
187
+ const params = request.params as { id: string };
188
+ const serviceName = `palmier-task-${params.id}.service`;
189
+
190
+ try {
191
+ execSync(`systemctl --user start ${serviceName}`, { stdio: "inherit" });
192
+ return { ok: true, task_id: params.id };
193
+ } catch (err) {
194
+ return { error: `Failed to start task service: ${err}` };
195
+ }
196
+ }
197
+
198
+ case "task.status": {
199
+ const params = request.params as { id: string };
200
+ const status = getTaskStatus(params.id);
201
+ return { task_id: params.id, status };
202
+ }
203
+
204
+ case "task.logs": {
205
+ const params = request.params as { id: string };
206
+ const serviceName = `palmier-task-${params.id}.service`;
207
+
208
+ try {
209
+ const logs = execSync(
210
+ `journalctl --user -u ${serviceName} -n 100 --no-pager`,
211
+ { encoding: "utf-8" }
212
+ );
213
+ return { task_id: params.id, logs };
214
+ } catch (err: unknown) {
215
+ const error = err as { stdout?: string; stderr?: string };
216
+ return { task_id: params.id, logs: error.stdout || "", error: error.stderr };
217
+ }
218
+ }
219
+
220
+ default:
221
+ return { error: `Unknown method: ${request.method}` };
222
+ }
223
+ }
224
+ }
package/src/config.ts ADDED
@@ -0,0 +1,40 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { homedir } from "os";
4
+ import type { AgentConfig } from "./types.js";
5
+
6
+ const CONFIG_DIR = path.join(homedir(), ".config", "palmier");
7
+ const CONFIG_FILE = path.join(CONFIG_DIR, "agent.json");
8
+
9
+ /**
10
+ * Load agent configuration from ~/.config/palmier/agent.json.
11
+ * Throws if the file is missing or invalid.
12
+ */
13
+ export function loadConfig(): AgentConfig {
14
+ if (!fs.existsSync(CONFIG_FILE)) {
15
+ throw new Error(
16
+ "Agent not provisioned. Run `palmier init --token <token>` first.\n" +
17
+ `Expected config at: ${CONFIG_FILE}`
18
+ );
19
+ }
20
+
21
+ const raw = fs.readFileSync(CONFIG_FILE, "utf-8");
22
+ const config = JSON.parse(raw) as AgentConfig;
23
+
24
+ if (!config.agentId || !config.natsUrl || !config.natsToken) {
25
+ throw new Error("Invalid agent config: missing required fields (agentId, natsUrl, natsToken)");
26
+ }
27
+
28
+ return config;
29
+ }
30
+
31
+ /**
32
+ * Persist agent configuration to ~/.config/palmier/agent.json.
33
+ * Creates parent directories if needed.
34
+ */
35
+ export function saveConfig(config: AgentConfig): void {
36
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
37
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
38
+ }
39
+
40
+ export { CONFIG_DIR, CONFIG_FILE };
package/src/index.ts ADDED
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+
3
+ import "dotenv/config";
4
+ import { Command } from "commander";
5
+ import { initCommand } from "./commands/init.js";
6
+ import { runCommand } from "./commands/run.js";
7
+ import { hookCommand } from "./commands/hook.js";
8
+ import { serveCommand } from "./commands/serve.js";
9
+
10
+ const program = new Command();
11
+
12
+ program
13
+ .name("palmier")
14
+ .description("Palmier agent CLI")
15
+ .version("0.1.0");
16
+
17
+ program
18
+ .command("init")
19
+ .description("Provision this agent with a provisioning token")
20
+ .requiredOption("--token <token>", "Provisioning token from palmier dashboard")
21
+ .action(async (options: { token: string }) => {
22
+ await initCommand({ token: options.token });
23
+ });
24
+
25
+ program
26
+ .command("run <task-id>")
27
+ .description("Execute a task by ID")
28
+ .action(async (taskId: string) => {
29
+ await runCommand(taskId);
30
+ });
31
+
32
+ program
33
+ .command("hook")
34
+ .description("Handle a Claude Code hook event (reads from stdin)")
35
+ .action(async () => {
36
+ await hookCommand();
37
+ });
38
+
39
+ program
40
+ .command("serve", { isDefault: true })
41
+ .description("Start the persistent NATS RPC handler")
42
+ .action(async () => {
43
+ await serveCommand();
44
+ });
45
+
46
+ program.parseAsync(process.argv).catch((err) => {
47
+ console.error(err);
48
+ process.exit(1);
49
+ });
@@ -0,0 +1,15 @@
1
+ import { connect, type NatsConnection } from "nats";
2
+ import type { AgentConfig } from "./types.js";
3
+
4
+ /**
5
+ * Connect to NATS using the agent config's TCP URL and token auth.
6
+ */
7
+ export async function connectNats(config: AgentConfig): Promise<NatsConnection> {
8
+ const nc = await connect({
9
+ servers: config.natsUrl,
10
+ token: config.natsToken,
11
+ });
12
+
13
+ console.log(`Connected to NATS at ${config.natsUrl}`);
14
+ return nc;
15
+ }
package/src/systemd.ts ADDED
@@ -0,0 +1,232 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { homedir } from "os";
4
+ import { execSync } from "child_process";
5
+ import type { AgentConfig } from "./types.js";
6
+ import type { ParsedTask, TaskStatus } from "./types.js";
7
+
8
+ const UNIT_DIR = path.join(homedir(), ".config", "systemd", "user");
9
+
10
+ function getTimerName(taskId: string): string {
11
+ return `palmier-task-${taskId}.timer`;
12
+ }
13
+
14
+ function getServiceName(taskId: string): string {
15
+ return `palmier-task-${taskId}.service`;
16
+ }
17
+
18
+ /**
19
+ * Convert a cron expression (5-field) to a systemd OnCalendar string.
20
+ * Handles basic cron patterns: minute hour day-of-month month day-of-week
21
+ */
22
+ export function cronToOnCalendar(cron: string): string {
23
+ const parts = cron.trim().split(/\s+/);
24
+ if (parts.length !== 5) {
25
+ throw new Error(`Invalid cron expression (expected 5 fields): ${cron}`);
26
+ }
27
+
28
+ const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
29
+
30
+ // Map cron day-of-week names/numbers to systemd abbreviated names
31
+ const dowMap: Record<string, string> = {
32
+ "0": "Sun",
33
+ "1": "Mon",
34
+ "2": "Tue",
35
+ "3": "Wed",
36
+ "4": "Thu",
37
+ "5": "Fri",
38
+ "6": "Sat",
39
+ "7": "Sun",
40
+ };
41
+
42
+ // Convert day-of-week
43
+ let dow = dayOfWeek === "*" ? "*" : dayOfWeek;
44
+ if (dowMap[dow]) {
45
+ dow = dowMap[dow];
46
+ }
47
+
48
+ // Build OnCalendar string
49
+ // Format: DayOfWeek Year-Month-Day Hour:Minute:Second
50
+ const monthPart = month === "*" ? "*" : month.padStart(2, "0");
51
+ const dayPart = dayOfMonth === "*" ? "*" : dayOfMonth.padStart(2, "0");
52
+ const hourPart = hour === "*" ? "*" : hour.padStart(2, "0");
53
+ const minutePart = minute === "*" ? "*" : minute.padStart(2, "0");
54
+
55
+ if (dow === "*") {
56
+ return `*-${monthPart}-${dayPart} ${hourPart}:${minutePart}:00`;
57
+ }
58
+
59
+ return `${dow} *-${monthPart}-${dayPart} ${hourPart}:${minutePart}:00`;
60
+ }
61
+
62
+ /**
63
+ * Install a systemd user timer + service for a task.
64
+ */
65
+ export function installTaskTimer(config: AgentConfig, task: ParsedTask): void {
66
+ fs.mkdirSync(UNIT_DIR, { recursive: true });
67
+
68
+ const taskId = task.frontmatter.id;
69
+ const serviceName = getServiceName(taskId);
70
+ const timerName = getTimerName(taskId);
71
+
72
+ // Determine the palmier binary path
73
+ const palmierBin = process.argv[1] || "palmier";
74
+
75
+ // Generate service unit
76
+ const serviceContent = `[Unit]
77
+ Description=Palmier Task: ${task.frontmatter.name || taskId}
78
+
79
+ [Service]
80
+ Type=oneshot
81
+ ExecStart=${palmierBin} run ${taskId}
82
+ WorkingDirectory=${config.projectRoot}
83
+ Environment=PATH=${process.env.PATH || "/usr/local/bin:/usr/bin:/bin"}
84
+ `;
85
+
86
+ // Generate timer unit with OnCalendar entries for each cron trigger
87
+ const onCalendarLines: string[] = [];
88
+ for (const trigger of task.frontmatter.triggers || []) {
89
+ if (trigger.type === "cron") {
90
+ onCalendarLines.push(`OnCalendar=${cronToOnCalendar(trigger.value)}`);
91
+ } else if (trigger.type === "once") {
92
+ // "once" triggers use OnActiveSec or a specific timestamp
93
+ onCalendarLines.push(`OnActiveSec=${trigger.value}`);
94
+ }
95
+ }
96
+
97
+ const timerContent = `[Unit]
98
+ Description=Timer for Palmier Task: ${task.frontmatter.name || taskId}
99
+
100
+ [Timer]
101
+ ${onCalendarLines.join("\n")}
102
+ Persistent=true
103
+
104
+ [Install]
105
+ WantedBy=timers.target
106
+ `;
107
+
108
+ // Write unit files
109
+ fs.writeFileSync(path.join(UNIT_DIR, serviceName), serviceContent, "utf-8");
110
+ fs.writeFileSync(path.join(UNIT_DIR, timerName), timerContent, "utf-8");
111
+
112
+ // Reload and enable
113
+ daemonReload();
114
+
115
+ if (task.frontmatter.enabled) {
116
+ execSync(`systemctl --user enable --now ${timerName}`, { stdio: "inherit" });
117
+ } else {
118
+ execSync(`systemctl --user enable ${timerName}`, { stdio: "inherit" });
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Remove a task's systemd timer and service files.
124
+ */
125
+ export function removeTaskTimer(taskId: string): void {
126
+ const timerName = getTimerName(taskId);
127
+ const serviceName = getServiceName(taskId);
128
+
129
+ // Stop and disable
130
+ try {
131
+ execSync(`systemctl --user stop ${timerName}`, { stdio: "inherit" });
132
+ } catch {
133
+ // Timer might not be running
134
+ }
135
+
136
+ try {
137
+ execSync(`systemctl --user disable ${timerName}`, { stdio: "inherit" });
138
+ } catch {
139
+ // Timer might not be enabled
140
+ }
141
+
142
+ // Remove unit files
143
+ const timerPath = path.join(UNIT_DIR, timerName);
144
+ const servicePath = path.join(UNIT_DIR, serviceName);
145
+
146
+ if (fs.existsSync(timerPath)) fs.unlinkSync(timerPath);
147
+ if (fs.existsSync(servicePath)) fs.unlinkSync(servicePath);
148
+
149
+ daemonReload();
150
+ }
151
+
152
+ /**
153
+ * Query systemd for task status information.
154
+ */
155
+ export function getTaskStatus(taskId: string): TaskStatus {
156
+ const timerName = getTimerName(taskId);
157
+ const serviceName = getServiceName(taskId);
158
+
159
+ let state: TaskStatus["state"] = "inactive";
160
+ let lastRun: string | undefined;
161
+ let lastResult: number | undefined;
162
+ let nextRun: string | undefined;
163
+
164
+ // Check if timer is active
165
+ try {
166
+ const activeState = execSync(`systemctl --user is-active ${timerName}`, {
167
+ encoding: "utf-8",
168
+ }).trim();
169
+ if (activeState === "active") {
170
+ state = "active";
171
+ }
172
+ } catch {
173
+ // Not active
174
+ }
175
+
176
+ // Check if service has failed
177
+ try {
178
+ const serviceState = execSync(`systemctl --user is-failed ${serviceName}`, {
179
+ encoding: "utf-8",
180
+ }).trim();
181
+ if (serviceState === "failed") {
182
+ state = "failed";
183
+ }
184
+ } catch {
185
+ // Not failed
186
+ }
187
+
188
+ // Get last run time and result
189
+ try {
190
+ const props = execSync(
191
+ `systemctl --user show ${serviceName} --property=ExecMainStartTimestamp,ExecMainStatus`,
192
+ { encoding: "utf-8" }
193
+ );
194
+ for (const line of props.split("\n")) {
195
+ if (line.startsWith("ExecMainStartTimestamp=") && line.trim() !== "ExecMainStartTimestamp=") {
196
+ const val = line.split("=", 2)[1].trim();
197
+ if (val) lastRun = val;
198
+ }
199
+ if (line.startsWith("ExecMainStatus=")) {
200
+ const val = parseInt(line.split("=", 2)[1].trim(), 10);
201
+ if (!isNaN(val)) lastResult = val;
202
+ }
203
+ }
204
+ } catch {
205
+ // Couldn't get properties
206
+ }
207
+
208
+ // Get next run time from timer
209
+ try {
210
+ const timerProps = execSync(
211
+ `systemctl --user show ${timerName} --property=NextElapseUSecRealtime`,
212
+ { encoding: "utf-8" }
213
+ );
214
+ for (const line of timerProps.split("\n")) {
215
+ if (line.startsWith("NextElapseUSecRealtime=")) {
216
+ const val = line.split("=", 2)[1].trim();
217
+ if (val) nextRun = val;
218
+ }
219
+ }
220
+ } catch {
221
+ // Couldn't get next run
222
+ }
223
+
224
+ return { state, lastRun, lastResult, nextRun };
225
+ }
226
+
227
+ /**
228
+ * Run systemctl --user daemon-reload.
229
+ */
230
+ export function daemonReload(): void {
231
+ execSync("systemctl --user daemon-reload", { stdio: "inherit" });
232
+ }
package/src/task.ts ADDED
@@ -0,0 +1,91 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
4
+ import type { ParsedTask, TaskFrontmatter } from "./types.js";
5
+
6
+ /**
7
+ * Parse a TASK.md file from the given task directory.
8
+ */
9
+ export function parseTaskFile(taskDir: string): ParsedTask {
10
+ const filePath = path.join(taskDir, "TASK.md");
11
+
12
+ if (!fs.existsSync(filePath)) {
13
+ throw new Error(`TASK.md not found at: ${filePath}`);
14
+ }
15
+
16
+ const content = fs.readFileSync(filePath, "utf-8");
17
+ return parseTaskContent(content);
18
+ }
19
+
20
+ /**
21
+ * Parse TASK.md content string into frontmatter + body.
22
+ */
23
+ function parseTaskContent(content: string): ParsedTask {
24
+ const fmRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
25
+ const match = content.match(fmRegex);
26
+
27
+ if (!match) {
28
+ throw new Error("TASK.md is missing valid YAML frontmatter delimiters (---)");
29
+ }
30
+
31
+ const frontmatter = parseYaml(match[1]) as TaskFrontmatter;
32
+ const body = (match[2] || "").trim();
33
+
34
+ if (!frontmatter.id) {
35
+ throw new Error("TASK.md frontmatter must include at least: id");
36
+ }
37
+
38
+ return { frontmatter, body };
39
+ }
40
+
41
+ /**
42
+ * Write a TASK.md file to the given task directory.
43
+ * Creates the directory if it doesn't exist.
44
+ */
45
+ export function writeTaskFile(taskDir: string, task: ParsedTask): void {
46
+ fs.mkdirSync(taskDir, { recursive: true });
47
+
48
+ const yamlStr = stringifyYaml(task.frontmatter).trim();
49
+ const content = `---\n${yamlStr}\n---\n${task.body}\n`;
50
+
51
+ const filePath = path.join(taskDir, "TASK.md");
52
+ fs.writeFileSync(filePath, content, "utf-8");
53
+ }
54
+
55
+ /**
56
+ * List all tasks from projectRoot/tasks/{id}/TASK.md.
57
+ */
58
+ export function listTasks(projectRoot: string): ParsedTask[] {
59
+ const tasksDir = path.join(projectRoot, "tasks");
60
+
61
+ if (!fs.existsSync(tasksDir)) {
62
+ return [];
63
+ }
64
+
65
+ const entries = fs.readdirSync(tasksDir, { withFileTypes: true });
66
+ const tasks: ParsedTask[] = [];
67
+
68
+ for (const entry of entries) {
69
+ if (!entry.isDirectory()) continue;
70
+
71
+ const taskDir = path.join(tasksDir, entry.name);
72
+ const taskFile = path.join(taskDir, "TASK.md");
73
+
74
+ if (!fs.existsSync(taskFile)) continue;
75
+
76
+ try {
77
+ tasks.push(parseTaskFile(taskDir));
78
+ } catch (err) {
79
+ console.error(`Warning: failed to parse task in ${taskDir}: ${err}`);
80
+ }
81
+ }
82
+
83
+ return tasks;
84
+ }
85
+
86
+ /**
87
+ * Get the directory path for a task by its ID.
88
+ */
89
+ export function getTaskDir(projectRoot: string, taskId: string): string {
90
+ return path.join(projectRoot, "tasks", taskId);
91
+ }