pi-messenger 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.
Files changed (45) hide show
  1. package/ARCHITECTURE.md +244 -0
  2. package/CHANGELOG.md +418 -0
  3. package/README.md +394 -0
  4. package/banner.png +0 -0
  5. package/config-overlay.ts +172 -0
  6. package/config.ts +178 -0
  7. package/crew/agents/crew-docs-scout.md +55 -0
  8. package/crew/agents/crew-gap-analyst.md +105 -0
  9. package/crew/agents/crew-github-scout.md +111 -0
  10. package/crew/agents/crew-interview-generator.md +79 -0
  11. package/crew/agents/crew-plan-sync.md +64 -0
  12. package/crew/agents/crew-practice-scout.md +62 -0
  13. package/crew/agents/crew-repo-scout.md +65 -0
  14. package/crew/agents/crew-reviewer.md +58 -0
  15. package/crew/agents/crew-web-scout.md +85 -0
  16. package/crew/agents/crew-worker.md +95 -0
  17. package/crew/agents.ts +200 -0
  18. package/crew/handlers/interview.ts +211 -0
  19. package/crew/handlers/plan.ts +358 -0
  20. package/crew/handlers/review.ts +341 -0
  21. package/crew/handlers/status.ts +257 -0
  22. package/crew/handlers/sync.ts +232 -0
  23. package/crew/handlers/task.ts +511 -0
  24. package/crew/handlers/work.ts +289 -0
  25. package/crew/id-allocator.ts +44 -0
  26. package/crew/index.ts +229 -0
  27. package/crew/state.ts +116 -0
  28. package/crew/store.ts +480 -0
  29. package/crew/types.ts +164 -0
  30. package/crew/utils/artifacts.ts +65 -0
  31. package/crew/utils/config.ts +104 -0
  32. package/crew/utils/discover.ts +170 -0
  33. package/crew/utils/install.ts +373 -0
  34. package/crew/utils/progress.ts +107 -0
  35. package/crew/utils/result.ts +16 -0
  36. package/crew/utils/truncate.ts +79 -0
  37. package/crew-overlay.ts +259 -0
  38. package/handlers.ts +799 -0
  39. package/index.ts +591 -0
  40. package/lib.ts +232 -0
  41. package/overlay.ts +687 -0
  42. package/package.json +20 -0
  43. package/skills/pi-messenger-crew/SKILL.md +140 -0
  44. package/store.ts +1068 -0
  45. package/tsconfig.json +19 -0
package/crew/store.ts ADDED
@@ -0,0 +1,480 @@
1
+ /**
2
+ * Crew - Store Operations
3
+ *
4
+ * Simplified PRD-based storage: plan.json + tasks/*.json
5
+ */
6
+
7
+ import * as fs from "node:fs";
8
+ import * as path from "node:path";
9
+ import { execSync } from "node:child_process";
10
+ import type { Plan, Task, TaskEvidence } from "./types.js";
11
+ import { allocateTaskId } from "./id-allocator.js";
12
+
13
+ // =============================================================================
14
+ // Directory Helpers
15
+ // =============================================================================
16
+
17
+ function ensureDir(dir: string): void {
18
+ if (!fs.existsSync(dir)) {
19
+ fs.mkdirSync(dir, { recursive: true });
20
+ }
21
+ }
22
+
23
+ export function getCrewDir(cwd: string): string {
24
+ return path.join(cwd, ".pi", "messenger", "crew");
25
+ }
26
+
27
+ function getTasksDir(cwd: string): string {
28
+ return path.join(getCrewDir(cwd), "tasks");
29
+ }
30
+
31
+ function getBlocksDir(cwd: string): string {
32
+ return path.join(getCrewDir(cwd), "blocks");
33
+ }
34
+
35
+ // =============================================================================
36
+ // JSON Helpers
37
+ // =============================================================================
38
+
39
+ function readJson<T>(filePath: string): T | null {
40
+ if (!fs.existsSync(filePath)) return null;
41
+ try {
42
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ function writeJson(filePath: string, data: unknown): void {
49
+ ensureDir(path.dirname(filePath));
50
+ const temp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
51
+ fs.writeFileSync(temp, JSON.stringify(data, null, 2));
52
+ fs.renameSync(temp, filePath);
53
+ }
54
+
55
+ function readText(filePath: string): string | null {
56
+ if (!fs.existsSync(filePath)) return null;
57
+ try {
58
+ return fs.readFileSync(filePath, "utf-8");
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ function writeText(filePath: string, content: string): void {
65
+ ensureDir(path.dirname(filePath));
66
+ const temp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
67
+ fs.writeFileSync(temp, content);
68
+ fs.renameSync(temp, filePath);
69
+ }
70
+
71
+ // =============================================================================
72
+ // Plan Operations
73
+ // =============================================================================
74
+
75
+ export function getPlan(cwd: string): Plan | null {
76
+ return readJson<Plan>(path.join(getCrewDir(cwd), "plan.json"));
77
+ }
78
+
79
+ export function createPlan(cwd: string, prdPath: string): Plan {
80
+ const now = new Date().toISOString();
81
+
82
+ const plan: Plan = {
83
+ prd: prdPath,
84
+ created_at: now,
85
+ updated_at: now,
86
+ task_count: 0,
87
+ completed_count: 0,
88
+ };
89
+
90
+ writeJson(path.join(getCrewDir(cwd), "plan.json"), plan);
91
+ return plan;
92
+ }
93
+
94
+ export function updatePlan(cwd: string, updates: Partial<Plan>): Plan | null {
95
+ const plan = getPlan(cwd);
96
+ if (!plan) return null;
97
+
98
+ const updated: Plan = {
99
+ ...plan,
100
+ ...updates,
101
+ updated_at: new Date().toISOString(),
102
+ };
103
+
104
+ writeJson(path.join(getCrewDir(cwd), "plan.json"), updated);
105
+ return updated;
106
+ }
107
+
108
+ export function deletePlan(cwd: string): boolean {
109
+ const planPath = path.join(getCrewDir(cwd), "plan.json");
110
+ const planMdPath = path.join(getCrewDir(cwd), "plan.md");
111
+ const tasksDir = getTasksDir(cwd);
112
+
113
+ let deleted = false;
114
+
115
+ // Delete plan.json
116
+ if (fs.existsSync(planPath)) {
117
+ fs.unlinkSync(planPath);
118
+ deleted = true;
119
+ }
120
+
121
+ // Delete plan.md
122
+ if (fs.existsSync(planMdPath)) {
123
+ fs.unlinkSync(planMdPath);
124
+ }
125
+
126
+ // Delete all task files
127
+ if (fs.existsSync(tasksDir)) {
128
+ for (const file of fs.readdirSync(tasksDir)) {
129
+ fs.unlinkSync(path.join(tasksDir, file));
130
+ }
131
+ }
132
+
133
+ return deleted;
134
+ }
135
+
136
+ // =============================================================================
137
+ // Plan Spec Operations
138
+ // =============================================================================
139
+
140
+ export function getPlanSpec(cwd: string): string | null {
141
+ return readText(path.join(getCrewDir(cwd), "plan.md"));
142
+ }
143
+
144
+ export function setPlanSpec(cwd: string, content: string): void {
145
+ writeText(path.join(getCrewDir(cwd), "plan.md"), content);
146
+ updatePlan(cwd, {}); // Touch updated_at
147
+ }
148
+
149
+ // =============================================================================
150
+ // Task Operations
151
+ // =============================================================================
152
+
153
+ export function createTask(
154
+ cwd: string,
155
+ title: string,
156
+ description?: string,
157
+ dependsOn?: string[]
158
+ ): Task {
159
+ const id = allocateTaskId(cwd);
160
+ const now = new Date().toISOString();
161
+
162
+ const task: Task = {
163
+ id,
164
+ title,
165
+ status: "todo",
166
+ depends_on: dependsOn ?? [],
167
+ created_at: now,
168
+ updated_at: now,
169
+ attempt_count: 0,
170
+ };
171
+
172
+ writeJson(path.join(getTasksDir(cwd), `${id}.json`), task);
173
+
174
+ // Create task spec file
175
+ const specContent = description
176
+ ? `# ${title}\n\n${description}\n`
177
+ : `# ${title}\n\n*Spec pending*\n`;
178
+ writeText(path.join(getTasksDir(cwd), `${id}.md`), specContent);
179
+
180
+ // Update plan task count
181
+ const plan = getPlan(cwd);
182
+ if (plan) {
183
+ updatePlan(cwd, { task_count: plan.task_count + 1 });
184
+ }
185
+
186
+ return task;
187
+ }
188
+
189
+ export function getTask(cwd: string, taskId: string): Task | null {
190
+ return readJson<Task>(path.join(getTasksDir(cwd), `${taskId}.json`));
191
+ }
192
+
193
+ export function updateTask(cwd: string, taskId: string, updates: Partial<Task>): Task | null {
194
+ const task = getTask(cwd, taskId);
195
+ if (!task) return null;
196
+
197
+ const updated: Task = {
198
+ ...task,
199
+ ...updates,
200
+ updated_at: new Date().toISOString(),
201
+ };
202
+
203
+ writeJson(path.join(getTasksDir(cwd), `${taskId}.json`), updated);
204
+ return updated;
205
+ }
206
+
207
+ export function getTasks(cwd: string): Task[] {
208
+ const dir = getTasksDir(cwd);
209
+ if (!fs.existsSync(dir)) return [];
210
+
211
+ const tasks: Task[] = [];
212
+ for (const file of fs.readdirSync(dir)) {
213
+ if (!file.endsWith(".json")) continue;
214
+ const task = readJson<Task>(path.join(dir, file));
215
+ if (task) tasks.push(task);
216
+ }
217
+
218
+ // Sort by ID number (task-1, task-2, ...)
219
+ return tasks.sort((a, b) => {
220
+ const aNum = parseInt(a.id.replace("task-", ""));
221
+ const bNum = parseInt(b.id.replace("task-", ""));
222
+ return aNum - bNum;
223
+ });
224
+ }
225
+
226
+ export function getTaskSpec(cwd: string, taskId: string): string | null {
227
+ return readText(path.join(getTasksDir(cwd), `${taskId}.md`));
228
+ }
229
+
230
+ export function setTaskSpec(cwd: string, taskId: string, content: string): void {
231
+ writeText(path.join(getTasksDir(cwd), `${taskId}.md`), content);
232
+ updateTask(cwd, taskId, {}); // Touch updated_at
233
+ }
234
+
235
+ // =============================================================================
236
+ // Task Lifecycle Operations
237
+ // =============================================================================
238
+
239
+ export function startTask(cwd: string, taskId: string, agentName: string): Task | null {
240
+ const task = getTask(cwd, taskId);
241
+ if (!task || task.status !== "todo") return null;
242
+
243
+ // Capture current git commit
244
+ let baseCommit: string | undefined;
245
+ try {
246
+ baseCommit = execSync("git rev-parse HEAD", { cwd, encoding: "utf-8" }).trim();
247
+ } catch {
248
+ // Not a git repo or git not available
249
+ }
250
+
251
+ return updateTask(cwd, taskId, {
252
+ status: "in_progress",
253
+ started_at: new Date().toISOString(),
254
+ base_commit: baseCommit,
255
+ assigned_to: agentName,
256
+ attempt_count: task.attempt_count + 1,
257
+ });
258
+ }
259
+
260
+ export function completeTask(
261
+ cwd: string,
262
+ taskId: string,
263
+ summary: string,
264
+ evidence?: TaskEvidence
265
+ ): Task | null {
266
+ const task = getTask(cwd, taskId);
267
+ if (!task || task.status !== "in_progress") return null;
268
+
269
+ const updated = updateTask(cwd, taskId, {
270
+ status: "done",
271
+ completed_at: new Date().toISOString(),
272
+ summary,
273
+ evidence,
274
+ assigned_to: undefined,
275
+ });
276
+
277
+ // Update plan completed count
278
+ if (updated) {
279
+ const plan = getPlan(cwd);
280
+ if (plan) {
281
+ updatePlan(cwd, { completed_count: plan.completed_count + 1 });
282
+ }
283
+ }
284
+
285
+ return updated;
286
+ }
287
+
288
+ export function blockTask(cwd: string, taskId: string, reason: string): Task | null {
289
+ const task = getTask(cwd, taskId);
290
+ if (!task) return null;
291
+
292
+ // Write block context to blocks directory
293
+ const blockPath = path.join(getBlocksDir(cwd), `${taskId}.md`);
294
+ writeText(blockPath, `# Blocked: ${task.title}\n\n**Reason:** ${reason}\n\n**Blocked at:** ${new Date().toISOString()}\n`);
295
+
296
+ return updateTask(cwd, taskId, {
297
+ status: "blocked",
298
+ blocked_reason: reason,
299
+ assigned_to: undefined,
300
+ });
301
+ }
302
+
303
+ export function unblockTask(cwd: string, taskId: string): Task | null {
304
+ const task = getTask(cwd, taskId);
305
+ if (!task || task.status !== "blocked") return null;
306
+
307
+ // Remove block file if exists
308
+ const blockPath = path.join(getBlocksDir(cwd), `${taskId}.md`);
309
+ try {
310
+ fs.unlinkSync(blockPath);
311
+ } catch {
312
+ // Ignore if doesn't exist
313
+ }
314
+
315
+ return updateTask(cwd, taskId, {
316
+ status: "todo",
317
+ blocked_reason: undefined,
318
+ });
319
+ }
320
+
321
+ export function resetTask(cwd: string, taskId: string, cascade: boolean = false): Task[] {
322
+ const task = getTask(cwd, taskId);
323
+ if (!task) return [];
324
+
325
+ const resetTasks: Task[] = [];
326
+ const wasDone = task.status === "done";
327
+
328
+ // Reset this task
329
+ const updated = updateTask(cwd, taskId, {
330
+ status: "todo",
331
+ started_at: undefined,
332
+ completed_at: undefined,
333
+ base_commit: undefined,
334
+ assigned_to: undefined,
335
+ summary: undefined,
336
+ evidence: undefined,
337
+ blocked_reason: undefined,
338
+ // Keep attempt_count for tracking
339
+ });
340
+ if (updated) resetTasks.push(updated);
341
+
342
+ // If cascade, reset all tasks that depend on this one
343
+ if (cascade) {
344
+ const allTasks = getTasks(cwd);
345
+ for (const t of allTasks) {
346
+ if (t.depends_on.includes(taskId) && t.status !== "todo") {
347
+ const cascaded = resetTask(cwd, t.id, true);
348
+ resetTasks.push(...cascaded);
349
+ }
350
+ }
351
+ }
352
+
353
+ // Update plan completed count if needed
354
+ if (wasDone && resetTasks.length > 0) {
355
+ const plan = getPlan(cwd);
356
+ if (plan) {
357
+ const doneTasks = getTasks(cwd).filter(t => t.status === "done");
358
+ updatePlan(cwd, { completed_count: doneTasks.length });
359
+ }
360
+ }
361
+
362
+ return resetTasks;
363
+ }
364
+
365
+ // =============================================================================
366
+ // Ready Tasks (Dependency Resolution)
367
+ // =============================================================================
368
+
369
+ export function getReadyTasks(cwd: string): Task[] {
370
+ const tasks = getTasks(cwd);
371
+ const doneIds = new Set(tasks.filter(t => t.status === "done").map(t => t.id));
372
+
373
+ return tasks.filter(task => {
374
+ // Must be in "todo" status
375
+ if (task.status !== "todo") return false;
376
+
377
+ // All dependencies must be done
378
+ return task.depends_on.every(depId => doneIds.has(depId));
379
+ });
380
+ }
381
+
382
+ // =============================================================================
383
+ // Validation
384
+ // =============================================================================
385
+
386
+ export interface ValidationResult {
387
+ valid: boolean;
388
+ errors: string[];
389
+ warnings: string[];
390
+ }
391
+
392
+ export function validatePlan(cwd: string): ValidationResult {
393
+ const errors: string[] = [];
394
+ const warnings: string[] = [];
395
+
396
+ const plan = getPlan(cwd);
397
+ if (!plan) {
398
+ return { valid: false, errors: ["No plan found"], warnings: [] };
399
+ }
400
+
401
+ const tasks = getTasks(cwd);
402
+
403
+ // Check for orphan dependencies
404
+ const taskIds = new Set(tasks.map(t => t.id));
405
+ for (const task of tasks) {
406
+ for (const depId of task.depends_on) {
407
+ if (!taskIds.has(depId)) {
408
+ errors.push(`Task ${task.id} depends on non-existent task ${depId}`);
409
+ }
410
+ }
411
+ }
412
+
413
+ // Check for circular dependencies
414
+ const visited = new Set<string>();
415
+ const recursionStack = new Set<string>();
416
+
417
+ function hasCycle(taskId: string): boolean {
418
+ if (recursionStack.has(taskId)) return true;
419
+ if (visited.has(taskId)) return false;
420
+
421
+ visited.add(taskId);
422
+ recursionStack.add(taskId);
423
+
424
+ const task = tasks.find(t => t.id === taskId);
425
+ if (task) {
426
+ for (const depId of task.depends_on) {
427
+ if (hasCycle(depId)) return true;
428
+ }
429
+ }
430
+
431
+ recursionStack.delete(taskId);
432
+ return false;
433
+ }
434
+
435
+ for (const task of tasks) {
436
+ visited.clear();
437
+ recursionStack.clear();
438
+ if (hasCycle(task.id)) {
439
+ errors.push(`Circular dependency detected involving task ${task.id}`);
440
+ }
441
+ }
442
+
443
+ // Check for tasks without specs
444
+ for (const task of tasks) {
445
+ const spec = getTaskSpec(cwd, task.id);
446
+ if (!spec || spec.includes("*Spec pending*")) {
447
+ warnings.push(`Task ${task.id} has no detailed spec`);
448
+ }
449
+ }
450
+
451
+ // Check plan spec
452
+ const planSpec = getPlanSpec(cwd);
453
+ if (!planSpec || planSpec.includes("*Spec pending*")) {
454
+ warnings.push("Plan has no detailed spec");
455
+ }
456
+
457
+ // Check task counts
458
+ if (plan.task_count !== tasks.length) {
459
+ warnings.push(`Plan task_count (${plan.task_count}) doesn't match actual tasks (${tasks.length})`);
460
+ }
461
+
462
+ const actualDone = tasks.filter(t => t.status === "done").length;
463
+ if (plan.completed_count !== actualDone) {
464
+ warnings.push(`Plan completed_count (${plan.completed_count}) doesn't match actual (${actualDone})`);
465
+ }
466
+
467
+ return {
468
+ valid: errors.length === 0,
469
+ errors,
470
+ warnings,
471
+ };
472
+ }
473
+
474
+ // =============================================================================
475
+ // Plan Existence Check
476
+ // =============================================================================
477
+
478
+ export function hasPlan(cwd: string): boolean {
479
+ return getPlan(cwd) !== null;
480
+ }
package/crew/types.ts ADDED
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Crew - Type Definitions
3
+ *
4
+ * Simplified PRD-based workflow types.
5
+ */
6
+
7
+ import type { MaxOutputConfig } from "./utils/truncate.js";
8
+ import type { AgentProgress } from "./utils/progress.js";
9
+ import type { CrewAgentConfig } from "./utils/discover.js";
10
+
11
+ // =============================================================================
12
+ // Plan Types
13
+ // =============================================================================
14
+
15
+ export interface Plan {
16
+ prd: string; // Path to PRD file (relative to cwd)
17
+ created_at: string; // ISO timestamp
18
+ updated_at: string; // ISO timestamp
19
+ task_count: number; // Total tasks
20
+ completed_count: number; // Completed tasks
21
+ }
22
+
23
+ // =============================================================================
24
+ // Task Types
25
+ // =============================================================================
26
+
27
+ export type TaskStatus = "todo" | "in_progress" | "done" | "blocked";
28
+
29
+ export interface TaskEvidence {
30
+ commits?: string[]; // Commit SHAs
31
+ tests?: string[]; // Test commands/files run
32
+ prs?: string[]; // PR URLs
33
+ }
34
+
35
+ export interface Task {
36
+ id: string; // task-N format
37
+ title: string;
38
+ status: TaskStatus;
39
+ depends_on: string[]; // Task IDs this depends on
40
+ created_at: string; // ISO timestamp
41
+ updated_at: string; // ISO timestamp
42
+ started_at?: string; // When task.start was called
43
+ completed_at?: string; // When task.done was called
44
+ base_commit?: string; // Git commit SHA at task.start
45
+ assigned_to?: string; // Agent name currently working on it
46
+ summary?: string; // Completion summary from task.done
47
+ evidence?: TaskEvidence; // Evidence from task.done
48
+ blocked_reason?: string; // Reason from task.block
49
+ attempt_count: number; // How many times attempted (for auto-block)
50
+ last_review?: ReviewFeedback; // Feedback from last review (for retry)
51
+ }
52
+
53
+ export interface ReviewFeedback {
54
+ verdict: ReviewVerdict;
55
+ summary: string;
56
+ issues: string[];
57
+ suggestions: string[];
58
+ reviewed_at: string; // ISO timestamp
59
+ }
60
+
61
+ // =============================================================================
62
+ // Crew Params (Tool Parameters)
63
+ // =============================================================================
64
+
65
+ export interface CrewParams {
66
+ // Action
67
+ action?: string;
68
+
69
+ // Plan
70
+ prd?: string; // PRD file path for plan action
71
+
72
+ // Task IDs
73
+ id?: string; // Task ID (task-N)
74
+ taskId?: string; // Swarm task ID (for claim/unclaim/complete)
75
+
76
+ // Creation
77
+ title?: string;
78
+ dependsOn?: string[];
79
+
80
+ // Completion
81
+ summary?: string;
82
+ evidence?: TaskEvidence;
83
+
84
+ // Content
85
+ content?: string; // Task description/spec content
86
+
87
+ // Review
88
+ target?: string; // Task ID to review
89
+ type?: "plan" | "impl";
90
+
91
+ // Work options
92
+ autonomous?: boolean;
93
+ concurrency?: number;
94
+
95
+ // Task reset
96
+ cascade?: boolean;
97
+
98
+ // Coordination (existing)
99
+ spec?: string;
100
+ to?: string | string[];
101
+ message?: string;
102
+ replyTo?: string;
103
+ paths?: string[];
104
+ reason?: string;
105
+ name?: string;
106
+ notes?: string;
107
+ release?: string[] | boolean;
108
+ autoRegisterPath?: "add" | "remove" | "list";
109
+ }
110
+
111
+ // =============================================================================
112
+ // Review Types
113
+ // =============================================================================
114
+
115
+ export type ReviewVerdict = "SHIP" | "NEEDS_WORK" | "MAJOR_RETHINK";
116
+
117
+ export interface ReviewResult {
118
+ verdict: ReviewVerdict;
119
+ summary: string;
120
+ issues?: string[];
121
+ suggestions?: string[];
122
+ }
123
+
124
+ // =============================================================================
125
+ // Agent Spawning Types
126
+ // =============================================================================
127
+
128
+ export interface AgentTask {
129
+ agent: string;
130
+ task: string;
131
+ maxOutput?: MaxOutputConfig;
132
+ }
133
+
134
+ export interface AgentResult {
135
+ agent: string;
136
+ exitCode: number;
137
+ output: string;
138
+ truncated: boolean;
139
+ progress: AgentProgress;
140
+ config?: CrewAgentConfig;
141
+ error?: string;
142
+ artifactPaths?: {
143
+ input: string;
144
+ output: string;
145
+ jsonl: string;
146
+ metadata: string;
147
+ };
148
+ }
149
+
150
+ // =============================================================================
151
+ // Callback Types
152
+ // =============================================================================
153
+
154
+ export type AppendEntryFn = (type: string, data: unknown) => void;
155
+
156
+ // =============================================================================
157
+ // Generated Task (from plan phase)
158
+ // =============================================================================
159
+
160
+ export interface GeneratedTask {
161
+ title: string;
162
+ description: string;
163
+ dependsOn?: string[]; // Task titles (resolved to IDs during creation)
164
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Crew - Debug Artifacts
3
+ *
4
+ * Writes debug files for troubleshooting agent failures.
5
+ */
6
+
7
+ import * as fs from "node:fs";
8
+ import * as path from "node:path";
9
+
10
+ export interface ArtifactPaths {
11
+ inputPath: string;
12
+ outputPath: string;
13
+ jsonlPath: string;
14
+ metadataPath: string;
15
+ }
16
+
17
+ export function getArtifactPaths(
18
+ artifactsDir: string,
19
+ runId: string,
20
+ agent: string,
21
+ index?: number
22
+ ): ArtifactPaths {
23
+ const suffix = index !== undefined ? `_${index}` : "";
24
+ const safeAgent = agent.replace(/[^\w.-]/g, "_");
25
+ const base = `${runId}_${safeAgent}${suffix}`;
26
+
27
+ return {
28
+ inputPath: path.join(artifactsDir, `${base}_input.md`),
29
+ outputPath: path.join(artifactsDir, `${base}_output.md`),
30
+ jsonlPath: path.join(artifactsDir, `${base}.jsonl`),
31
+ metadataPath: path.join(artifactsDir, `${base}_meta.json`),
32
+ };
33
+ }
34
+
35
+ export function ensureArtifactsDir(dir: string): void {
36
+ fs.mkdirSync(dir, { recursive: true });
37
+ }
38
+
39
+ export function writeArtifact(filePath: string, content: string): void {
40
+ fs.writeFileSync(filePath, content, "utf-8");
41
+ }
42
+
43
+ export function writeMetadata(filePath: string, metadata: object): void {
44
+ fs.writeFileSync(filePath, JSON.stringify(metadata, null, 2), "utf-8");
45
+ }
46
+
47
+ export function appendJsonl(filePath: string, line: string): void {
48
+ fs.appendFileSync(filePath, `${line}\n`);
49
+ }
50
+
51
+ export function cleanupOldArtifacts(dir: string, maxAgeDays: number): void {
52
+ if (!fs.existsSync(dir)) return;
53
+ const cutoff = Date.now() - (maxAgeDays * 24 * 60 * 60 * 1000);
54
+
55
+ for (const file of fs.readdirSync(dir)) {
56
+ const filePath = path.join(dir, file);
57
+ try {
58
+ if (fs.statSync(filePath).mtimeMs < cutoff) {
59
+ fs.unlinkSync(filePath);
60
+ }
61
+ } catch {
62
+ // Ignore errors
63
+ }
64
+ }
65
+ }