im-pickle-rick 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.
Files changed (128) hide show
  1. package/README.md +242 -0
  2. package/bin.js +3 -0
  3. package/dist/pickle +0 -0
  4. package/dist/worker-executor.js +207 -0
  5. package/package.json +53 -0
  6. package/src/games/GameSidebarManager.test.ts +64 -0
  7. package/src/games/GameSidebarManager.ts +78 -0
  8. package/src/games/gameboy/GameboyView.test.ts +25 -0
  9. package/src/games/gameboy/GameboyView.ts +100 -0
  10. package/src/games/gameboy/gameboy-polyfills.ts +313 -0
  11. package/src/games/index.test.ts +9 -0
  12. package/src/games/index.ts +4 -0
  13. package/src/games/snake/SnakeGame.test.ts +35 -0
  14. package/src/games/snake/SnakeGame.ts +145 -0
  15. package/src/games/snake/SnakeView.test.ts +25 -0
  16. package/src/games/snake/SnakeView.ts +290 -0
  17. package/src/index.test.ts +24 -0
  18. package/src/index.ts +141 -0
  19. package/src/services/commands/worker.test.ts +14 -0
  20. package/src/services/commands/worker.ts +262 -0
  21. package/src/services/config/index.ts +2 -0
  22. package/src/services/config/settings.test.ts +42 -0
  23. package/src/services/config/settings.ts +220 -0
  24. package/src/services/config/state.test.ts +88 -0
  25. package/src/services/config/state.ts +130 -0
  26. package/src/services/config/types.ts +39 -0
  27. package/src/services/execution/index.ts +1 -0
  28. package/src/services/execution/pickle-source.test.ts +88 -0
  29. package/src/services/execution/pickle-source.ts +264 -0
  30. package/src/services/execution/prompt.test.ts +93 -0
  31. package/src/services/execution/prompt.ts +322 -0
  32. package/src/services/execution/sequential.test.ts +91 -0
  33. package/src/services/execution/sequential.ts +422 -0
  34. package/src/services/execution/worker-client.ts +94 -0
  35. package/src/services/execution/worker-executor.ts +41 -0
  36. package/src/services/execution/worker.test.ts +73 -0
  37. package/src/services/git/branch.test.ts +147 -0
  38. package/src/services/git/branch.ts +128 -0
  39. package/src/services/git/diff.test.ts +113 -0
  40. package/src/services/git/diff.ts +323 -0
  41. package/src/services/git/index.ts +4 -0
  42. package/src/services/git/pr.test.ts +104 -0
  43. package/src/services/git/pr.ts +192 -0
  44. package/src/services/git/worktree.test.ts +99 -0
  45. package/src/services/git/worktree.ts +141 -0
  46. package/src/services/providers/base.test.ts +86 -0
  47. package/src/services/providers/base.ts +438 -0
  48. package/src/services/providers/codex.test.ts +39 -0
  49. package/src/services/providers/codex.ts +208 -0
  50. package/src/services/providers/gemini.test.ts +40 -0
  51. package/src/services/providers/gemini.ts +169 -0
  52. package/src/services/providers/index.test.ts +28 -0
  53. package/src/services/providers/index.ts +41 -0
  54. package/src/services/providers/opencode.test.ts +64 -0
  55. package/src/services/providers/opencode.ts +228 -0
  56. package/src/services/providers/types.ts +44 -0
  57. package/src/skills/code-implementer.md +105 -0
  58. package/src/skills/code-researcher.md +78 -0
  59. package/src/skills/implementation-planner.md +105 -0
  60. package/src/skills/plan-reviewer.md +100 -0
  61. package/src/skills/prd-drafter.md +123 -0
  62. package/src/skills/research-reviewer.md +79 -0
  63. package/src/skills/ruthless-refactorer.md +52 -0
  64. package/src/skills/ticket-manager.md +135 -0
  65. package/src/types/index.ts +2 -0
  66. package/src/types/rpc.ts +14 -0
  67. package/src/types/tasks.ts +50 -0
  68. package/src/types.d.ts +9 -0
  69. package/src/ui/common.ts +28 -0
  70. package/src/ui/components/FilePickerView.test.ts +79 -0
  71. package/src/ui/components/FilePickerView.ts +161 -0
  72. package/src/ui/components/MultiLineInput.test.ts +27 -0
  73. package/src/ui/components/MultiLineInput.ts +233 -0
  74. package/src/ui/components/SessionChip.test.ts +69 -0
  75. package/src/ui/components/SessionChip.ts +481 -0
  76. package/src/ui/components/ToyboxSidebar.test.ts +36 -0
  77. package/src/ui/components/ToyboxSidebar.ts +329 -0
  78. package/src/ui/components/refactor_plan.md +35 -0
  79. package/src/ui/controllers/DashboardController.integration.test.ts +43 -0
  80. package/src/ui/controllers/DashboardController.ts +650 -0
  81. package/src/ui/dashboard.test.ts +43 -0
  82. package/src/ui/dashboard.ts +309 -0
  83. package/src/ui/dialogs/DashboardDialog.test.ts +146 -0
  84. package/src/ui/dialogs/DashboardDialog.ts +399 -0
  85. package/src/ui/dialogs/Dialog.test.ts +50 -0
  86. package/src/ui/dialogs/Dialog.ts +241 -0
  87. package/src/ui/dialogs/DialogSidebar.test.ts +60 -0
  88. package/src/ui/dialogs/DialogSidebar.ts +71 -0
  89. package/src/ui/dialogs/DiffViewDialog.test.ts +57 -0
  90. package/src/ui/dialogs/DiffViewDialog.ts +510 -0
  91. package/src/ui/dialogs/PRPreviewDialog.test.ts +50 -0
  92. package/src/ui/dialogs/PRPreviewDialog.ts +346 -0
  93. package/src/ui/dialogs/test-utils.ts +232 -0
  94. package/src/ui/file-picker-utils.test.ts +71 -0
  95. package/src/ui/file-picker-utils.ts +200 -0
  96. package/src/ui/input-chrome.test.ts +62 -0
  97. package/src/ui/input-chrome.ts +172 -0
  98. package/src/ui/logger.test.ts +68 -0
  99. package/src/ui/logger.ts +45 -0
  100. package/src/ui/mock-factory.ts +6 -0
  101. package/src/ui/spinner.test.ts +65 -0
  102. package/src/ui/spinner.ts +41 -0
  103. package/src/ui/test-setup.ts +300 -0
  104. package/src/ui/theme.test.ts +23 -0
  105. package/src/ui/theme.ts +16 -0
  106. package/src/ui/views/LandingView.integration.test.ts +21 -0
  107. package/src/ui/views/LandingView.test.ts +24 -0
  108. package/src/ui/views/LandingView.ts +221 -0
  109. package/src/ui/views/LogView.test.ts +24 -0
  110. package/src/ui/views/LogView.ts +277 -0
  111. package/src/ui/views/ToyboxView.test.ts +46 -0
  112. package/src/ui/views/ToyboxView.ts +323 -0
  113. package/src/utils/clipboard.test.ts +86 -0
  114. package/src/utils/clipboard.ts +100 -0
  115. package/src/utils/index.test.ts +68 -0
  116. package/src/utils/index.ts +95 -0
  117. package/src/utils/persona.test.ts +12 -0
  118. package/src/utils/persona.ts +8 -0
  119. package/src/utils/project-root.test.ts +38 -0
  120. package/src/utils/project-root.ts +22 -0
  121. package/src/utils/resources.test.ts +64 -0
  122. package/src/utils/resources.ts +92 -0
  123. package/src/utils/search.test.ts +48 -0
  124. package/src/utils/search.ts +103 -0
  125. package/src/utils/session-tracker.test.ts +46 -0
  126. package/src/utils/session-tracker.ts +67 -0
  127. package/src/utils/spinner.test.ts +54 -0
  128. package/src/utils/spinner.ts +87 -0
@@ -0,0 +1,130 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { existsSync } from "node:fs";
4
+ import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
5
+ import { SessionStateSchema, type SessionState } from "./types.js";
6
+ import { findProjectRoot } from "../../utils/project-root.js";
7
+ import { loadSettings } from "./settings.js";
8
+
9
+ export const GLOBAL_SESSIONS_DIR = join(homedir(), ".gemini", "extensions", "pickle-rick", "sessions");
10
+
11
+ export interface SessionSummary {
12
+ original_prompt: string;
13
+ status: string;
14
+ started_at: string;
15
+ session_dir: string;
16
+ }
17
+
18
+ // Helper to get session path relative to CWD
19
+ export function getSessionPath(cwd: string, sessionId: string): string {
20
+ return join(cwd, ".pickle", "sessions", sessionId);
21
+ }
22
+
23
+ export async function loadState(sessionDir: string): Promise<SessionState | null> {
24
+ const path = join(sessionDir, "state.json");
25
+ if (!existsSync(path)) return null;
26
+
27
+ try {
28
+ const content = await readFile(path, "utf-8");
29
+ const json = JSON.parse(content);
30
+ return SessionStateSchema.parse(json);
31
+ } catch (e) {
32
+ console.error("Failed to load state:", e);
33
+ return null;
34
+ }
35
+ }
36
+
37
+ export async function saveState(sessionDir: string, state: SessionState): Promise<void> {
38
+ const path = join(sessionDir, "state.json");
39
+ await writeFile(path, JSON.stringify(state, null, 2), "utf-8");
40
+ }
41
+
42
+ export async function createSession(cwd: string, prompt: string, is_prd_mode: boolean = false): Promise<SessionState> {
43
+ const root = findProjectRoot(cwd);
44
+ const today = new Date().toISOString().split("T")[0];
45
+ const hash = Math.random().toString(36).substring(2, 10);
46
+ const sessionId = `${today}-${hash}`;
47
+ const sessionDir = getSessionPath(root, sessionId);
48
+
49
+ await mkdir(sessionDir, { recursive: true });
50
+
51
+ // Load settings from ~/.pickle/settings.json to get max_iterations
52
+ const settings = await loadSettings();
53
+ const maxIterations = settings.max_iterations ?? 10;
54
+
55
+ const state: SessionState = {
56
+ active: true,
57
+ working_dir: root,
58
+ step: "prd",
59
+ iteration: 1,
60
+ max_iterations: maxIterations,
61
+ max_time_minutes: 60,
62
+ worker_timeout_seconds: 1200,
63
+ start_time_epoch: Math.floor(Date.now() / 1000),
64
+ completion_promise: "I AM DONE",
65
+ original_prompt: prompt,
66
+ current_ticket: null,
67
+ history: [],
68
+ is_prd_mode,
69
+ started_at: new Date().toISOString(),
70
+ session_dir: sessionDir
71
+ };
72
+
73
+ await saveState(sessionDir, state);
74
+ return state;
75
+ }
76
+
77
+ export async function listSessions(cwd?: string): Promise<SessionSummary[]> {
78
+ const sessionDirs = new Set<string>();
79
+
80
+ // 1. Check Global Sessions
81
+ if (existsSync(GLOBAL_SESSIONS_DIR)) {
82
+ try {
83
+ const entries = await readdir(GLOBAL_SESSIONS_DIR, { withFileTypes: true });
84
+ for (const entry of entries) {
85
+ if (entry.isDirectory()) {
86
+ sessionDirs.add(join(GLOBAL_SESSIONS_DIR, entry.name));
87
+ }
88
+ }
89
+ } catch (e) {}
90
+ }
91
+
92
+ // 2. Check Local Sessions (if CWD provided)
93
+ if (cwd) {
94
+ try {
95
+ const root = findProjectRoot(cwd);
96
+ const localDir = join(root, ".pickle", "sessions");
97
+ if (existsSync(localDir)) {
98
+ const entries = await readdir(localDir, { withFileTypes: true });
99
+ for (const entry of entries) {
100
+ if (entry.isDirectory()) {
101
+ sessionDirs.add(join(localDir, entry.name));
102
+ }
103
+ }
104
+ }
105
+ } catch (e) {
106
+ // Project root might not exist yet
107
+ }
108
+ }
109
+
110
+ const sessions: SessionSummary[] = [];
111
+
112
+ for (const sessionDir of sessionDirs) {
113
+ const state = await loadState(sessionDir);
114
+
115
+ if (state) {
116
+ const status = state.active && state.step !== "done"
117
+ ? `${state.step.toUpperCase()} (Iteration ${state.iteration})`
118
+ : "Done";
119
+
120
+ sessions.push({
121
+ original_prompt: state.original_prompt,
122
+ status,
123
+ started_at: state.started_at,
124
+ session_dir: state.session_dir
125
+ });
126
+ }
127
+ }
128
+
129
+ return sessions.sort((a, b) => new Date(b.started_at).getTime() - new Date(a.started_at).getTime());
130
+ }
@@ -0,0 +1,39 @@
1
+ import { z } from "zod";
2
+
3
+ // Settings schema for ~/.pickle/settings.json
4
+ export const PickleSettingsSchema = z.object({
5
+ model: z.object({
6
+ provider: z.enum(["gemini", "opencode", "claude", "cursor", "codex", "qwen", "droid", "copilot"]).optional(),
7
+ model: z.string().optional(),
8
+ }).optional(),
9
+ max_iterations: z.number().optional(),
10
+ });
11
+
12
+ export type PickleSettings = z.infer<typeof PickleSettingsSchema>;
13
+
14
+ export const SessionStateSchema = z.object({
15
+ active: z.boolean(),
16
+ working_dir: z.string(),
17
+ step: z.enum(["prd", "breakdown", "research", "plan", "implement", "refactor", "done"]),
18
+ iteration: z.number(),
19
+ max_iterations: z.number(),
20
+ max_time_minutes: z.number(),
21
+ worker_timeout_seconds: z.number(),
22
+ start_time_epoch: z.number(),
23
+ completion_promise: z.string().nullable(),
24
+ original_prompt: z.string(),
25
+ current_ticket: z.string().nullable(),
26
+ history: z.array(z.any()), // Placeholder
27
+ is_prd_mode: z.boolean().optional(),
28
+ interrogation_history: z.array(z.object({
29
+ role: z.enum(["user", "agent"]),
30
+ content: z.string(),
31
+ timestamp: z.string()
32
+ })).optional(),
33
+ started_at: z.string(),
34
+ session_dir: z.string(),
35
+ cli_mode: z.boolean().optional(),
36
+ gemini_session_id: z.string().optional(),
37
+ });
38
+
39
+ export type SessionState = z.infer<typeof SessionStateSchema>;
@@ -0,0 +1 @@
1
+ export * from "./sequential.js";
@@ -0,0 +1,88 @@
1
+ import { expect, test, describe, beforeEach, afterEach } from "bun:test";
2
+ import { PickleTaskSource } from "./pickle-source.js";
3
+ import { writeFile, mkdir, rm } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { existsSync, writeFileSync } from "node:fs";
6
+ import type { SessionState } from "../config/types.js";
7
+
8
+ describe("PickleTaskSource Sequencing", () => {
9
+
10
+ // TODO: Fix ENOENT race condition in test setup
11
+ test.skip("should sort tickets by order field", async () => {
12
+ const testDir = join(process.cwd(), `.tmp_test_session_${Date.now()}_${Math.random().toString(36).slice(2)}`);
13
+ await mkdir(testDir, { recursive: true });
14
+
15
+ const fullState: SessionState = {
16
+ active: true,
17
+ working_dir: process.cwd(),
18
+ session_dir: testDir,
19
+ step: "research",
20
+ iteration: 1,
21
+ max_iterations: 10,
22
+ max_time_minutes: 30,
23
+ worker_timeout_seconds: 60,
24
+ start_time_epoch: Date.now(),
25
+ completion_promise: "DONE",
26
+ original_prompt: "test",
27
+ current_ticket: null,
28
+ history: [],
29
+ started_at: new Date().toISOString()
30
+ };
31
+ writeFileSync(join(testDir, "state.json"), JSON.stringify(fullState));
32
+
33
+ // Create tickets in mixed order
34
+ await writeFile(join(testDir, "ticket_3.md"), `---\nid: t3\ntitle: Third\nstatus: Triage\norder: 30\n---\nBody`);
35
+ await writeFile(join(testDir, "ticket_1.md"), `---\nid: t1\ntitle: First\nstatus: Triage\norder: 10\n---\nBody`);
36
+ await writeFile(join(testDir, "ticket_2.md"), `---\nid: t2\ntitle: Second\nstatus: Triage\norder: 20\n---\nBody`);
37
+
38
+ const source = new PickleTaskSource(testDir);
39
+
40
+ const task1 = await source.getNextTask();
41
+ expect(task1?.id).toBe("t1");
42
+ await source.markComplete("t1");
43
+
44
+ const task2 = await source.getNextTask();
45
+ expect(task2?.id).toBe("t2");
46
+ await source.markComplete("t2");
47
+
48
+ const task3 = await source.getNextTask();
49
+ expect(task3?.id).toBe("t3");
50
+
51
+ await rm(testDir, { recursive: true });
52
+ });
53
+
54
+ // TODO: Fix ENOENT race condition in test setup
55
+ test.skip("should fallback to birthtime if order is identical", async () => {
56
+ const testDir = join(process.cwd(), `.tmp_test_session_${Date.now()}_${Math.random().toString(36).slice(2)}`);
57
+ await mkdir(testDir, { recursive: true });
58
+
59
+ const fullState: SessionState = {
60
+ active: true,
61
+ working_dir: process.cwd(),
62
+ session_dir: testDir,
63
+ step: "research",
64
+ iteration: 1,
65
+ max_iterations: 10,
66
+ max_time_minutes: 30,
67
+ worker_timeout_seconds: 60,
68
+ start_time_epoch: Date.now(),
69
+ completion_promise: "DONE",
70
+ original_prompt: "test",
71
+ current_ticket: null,
72
+ history: [],
73
+ started_at: new Date().toISOString()
74
+ };
75
+ writeFileSync(join(testDir, "state.json"), JSON.stringify(fullState));
76
+
77
+ await writeFile(join(testDir, "ticket_a.md"), `---\nid: ta\ntitle: A\nstatus: Triage\norder: 10\n---\nBody`);
78
+ // Wait a bit to ensure different birthtime
79
+ await new Promise(r => setTimeout(r, 100));
80
+ await writeFile(join(testDir, "ticket_b.md"), `---\nid: tb\ntitle: B\nstatus: Triage\norder: 10\n---\nBody`);
81
+
82
+ const source = new PickleTaskSource(testDir);
83
+ const task1 = await source.getNextTask();
84
+ expect(task1?.id).toBe("ta");
85
+
86
+ await rm(testDir, { recursive: true });
87
+ });
88
+ });
@@ -0,0 +1,264 @@
1
+ import { readFile, readdir, stat, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { Task, TaskSource } from "../../types/tasks.js";
4
+ import { loadState, saveState } from "../config/state.js";
5
+ import type { SessionState } from "../config/types.js";
6
+ export class PickleTaskSource implements TaskSource {
7
+ constructor(private sessionDir: string) {}
8
+
9
+ private async getState(): Promise<SessionState> {
10
+ const state = await loadState(this.sessionDir);
11
+ if (!state) throw new Error(`State not found in ${this.sessionDir}`);
12
+ return state;
13
+ }
14
+
15
+ private async saveState(state: SessionState) {
16
+ await saveState(this.sessionDir, state);
17
+ }
18
+
19
+ async getNextTask(): Promise<Task | null> {
20
+ const state = await this.getState();
21
+
22
+ // 1. PRD Phase
23
+ if (state.step === "prd") {
24
+ return {
25
+ id: "phase-prd",
26
+ title: "Draft PRD",
27
+ body: state.original_prompt,
28
+ completed: false,
29
+ metadata: { type: "phase", phase: "prd" }
30
+ };
31
+ }
32
+
33
+ // 2. Breakdown Phase
34
+ if (state.step === "breakdown") {
35
+ return {
36
+ id: "phase-breakdown",
37
+ title: "Breakdown Tickets",
38
+ body: "Break down the PRD into atomic Linear tickets in the session directory.",
39
+ completed: false,
40
+ metadata: { type: "phase", phase: "breakdown" }
41
+ };
42
+ }
43
+
44
+ // 3. Ticket Loop
45
+ // If we are already working on a ticket, return it
46
+ if (state.current_ticket) {
47
+ const ticket = await this.getTask(state.current_ticket);
48
+ if (ticket && !ticket.completed) {
49
+ return ticket;
50
+ }
51
+ // If completed (should have been marked), or invalid, fall through to find next
52
+ }
53
+
54
+ // Find next available ticket
55
+ const nextTicket = await this.findNextTicket(state.session_dir);
56
+ if (nextTicket) {
57
+ // Update state to lock onto this ticket
58
+ state.current_ticket = nextTicket.id;
59
+ state.step = "research"; // Reset loop phase
60
+ await this.saveState(state);
61
+ return nextTicket;
62
+ }
63
+
64
+ return null; // No more work
65
+ }
66
+
67
+ async getTask(id: string): Promise<Task | null> {
68
+ if (id === "phase-prd" || id === "phase-breakdown") {
69
+ // Re-construct phase tasks if asked
70
+ const state = await this.getState();
71
+ if (id === "phase-prd") return { id, title: "Draft PRD", completed: state.step !== "prd", body: state.original_prompt };
72
+ if (id === "phase-breakdown") return { id, title: "Breakdown", completed: state.step !== "breakdown" && state.step !== "prd", body: "Breakdown..." };
73
+ return null;
74
+ }
75
+
76
+ // It's a ticket ID
77
+ const ticketFile = await this.findTicketFile(this.sessionDir, id);
78
+ if (!ticketFile) return null;
79
+
80
+ const content = await readFile(ticketFile, "utf-8");
81
+ // Simple frontmatter parse
82
+ const statusMatch = content.match(/status:\s*(.+)/);
83
+ const titleMatch = content.match(/title:\s*(.+)/);
84
+
85
+ const status = statusMatch ? statusMatch[1].trim() : "Unknown";
86
+ const title = titleMatch ? titleMatch[1].trim() : "Untitled";
87
+ const isDone = status.toLowerCase() === "done" || status.toLowerCase() === "canceled";
88
+
89
+ return {
90
+ id: id,
91
+ title: title,
92
+ body: content, // The full ticket content
93
+ completed: isDone,
94
+ metadata: { type: "ticket", path: ticketFile, status }
95
+ };
96
+ }
97
+
98
+ async getAllTasks(): Promise<Task[]> {
99
+ const state = await this.getState();
100
+ const all: Task[] = [];
101
+ await this.scanTickets(state.session_dir, all);
102
+ // Add Phase tasks if applicable?
103
+ // Actually, just returning tickets is fine for verification.
104
+ return all;
105
+ }
106
+
107
+ async markComplete(id: string): Promise<void> {
108
+ const state = await this.getState();
109
+
110
+ if (id === "phase-prd") {
111
+ state.step = "breakdown";
112
+ await this.saveState(state);
113
+ return;
114
+ }
115
+
116
+ if (id === "phase-breakdown") {
117
+ state.step = "research"; // Ready for first ticket
118
+ state.current_ticket = null; // Ensure we scan
119
+ await this.saveState(state);
120
+ return;
121
+ }
122
+
123
+ // Ticket
124
+ const ticketFile = await this.findTicketFile(this.sessionDir, id);
125
+ if (ticketFile) {
126
+ let content = await readFile(ticketFile, "utf-8");
127
+ content = content.replace(/status:\s*.+/, "status: Done");
128
+ content = content.replace(/updated:\s*.+/, `updated: ${new Date().toISOString().split("T")[0]}`);
129
+ await writeFile(ticketFile, content, "utf-8");
130
+
131
+ state.current_ticket = null; // Release lock
132
+ await this.saveState(state);
133
+ }
134
+ }
135
+
136
+ async countRemaining(): Promise<number> {
137
+ const state = await this.getState();
138
+ if (state.step === "prd") return 2 + await this.countTickets(state.session_dir, false); // PRD + Breakdown + Tickets
139
+ if (state.step === "breakdown") return 1 + await this.countTickets(state.session_dir, false);
140
+ return this.countTickets(state.session_dir, false);
141
+ }
142
+
143
+ // --- Helpers ---
144
+
145
+ private async findTicketFile(dir: string, id: string): Promise<string | null> {
146
+ // Search recursively
147
+ const entries = await readdir(dir);
148
+ for (const entry of entries) {
149
+ const fullPath = join(dir, entry);
150
+ const entryStat = await stat(fullPath);
151
+ if (entryStat.isDirectory()) {
152
+ const found = await this.findTicketFile(fullPath, id);
153
+ if (found) return found;
154
+ } else if (entry.endsWith(".md")) {
155
+ const content = await readFile(fullPath, "utf-8");
156
+ if (content.includes(`id: ${id}`)) {
157
+ return fullPath;
158
+ }
159
+ }
160
+ }
161
+ return null;
162
+ }
163
+
164
+ private async findNextTicket(dir: string): Promise<Task | null> {
165
+ const allTickets: Task[] = [];
166
+ await this.scanTickets(dir, allTickets);
167
+
168
+ // Filter out Parent/Epic tickets
169
+ const implementable = allTickets.filter(t => {
170
+ const isParentId = t.id === "parent" || t.id === "linear_ticket_parent" || t.id === "task_priority_parent";
171
+ const isEpicTitle = t.title.toLowerCase().includes("[epic]");
172
+ // Epics are usually in the root of the session dir, not in a subdirectory hash
173
+ const isRootFile = t.metadata?.path && !join(this.sessionDir, t.id).includes(t.metadata.path);
174
+
175
+ return !isParentId && !isEpicTitle;
176
+ });
177
+
178
+ // Sort by order (asc) then birthtime (asc)
179
+ implementable.sort((a, b) => {
180
+ const orderA = a.metadata?.order ?? Infinity;
181
+ const orderB = b.metadata?.order ?? Infinity;
182
+ if (orderA !== orderB) return orderA - orderB;
183
+
184
+ const timeA = a.metadata?.birthtime ?? 0;
185
+ const timeB = b.metadata?.birthtime ?? 0;
186
+ return timeA - timeB;
187
+ });
188
+
189
+ const next = implementable.find(t => !t.completed);
190
+ return next || null;
191
+ }
192
+
193
+ private async scanTickets(dir: string, list: Task[]) {
194
+ try {
195
+ const entries = await readdir(dir);
196
+ for (const entry of entries) {
197
+ // Slop Filter: Skip ignored directories
198
+ if (entry === ".git" || entry === "node_modules" || entry === ".pickle") {
199
+ continue;
200
+ }
201
+
202
+ const fullPath = join(dir, entry);
203
+ try {
204
+ const entryStat = await stat(fullPath);
205
+ if (entryStat.isDirectory()) {
206
+ await this.scanTickets(fullPath, list);
207
+ } else if (entry.endsWith(".md")) {
208
+ const content = await readFile(fullPath, "utf-8");
209
+ const idMatch = content.match(/id:\s*(.+)/);
210
+ const statusMatch = content.match(/status:\s*(.+)/);
211
+ const titleMatch = content.match(/title:\s*(.+)/);
212
+ const orderMatch = content.match(/order:\s*(\d+)/);
213
+
214
+ let id = "";
215
+ let status = "Triage";
216
+ let title = "Untitled";
217
+ let order = orderMatch ? parseInt(orderMatch[1], 10) : Infinity;
218
+ const birthtime = entryStat.birthtime.getTime();
219
+
220
+ if (idMatch) {
221
+ id = idMatch[1].trim();
222
+ if (statusMatch) status = statusMatch[1].trim();
223
+ if (titleMatch) title = titleMatch[1].trim();
224
+ } else {
225
+ // Fallback: Try to extract ID from filename
226
+ const filenameMatch = entry.match(/^linear_ticket_(.+)\.md$/);
227
+ if (filenameMatch) {
228
+ id = filenameMatch[1];
229
+ // Attempt to find title in content (# Title)
230
+ const headerMatch = content.match(/^#\s+(.+)$/m);
231
+ if (headerMatch) title = headerMatch[1].trim();
232
+ console.warn(`⚠️ Warning: No frontmatter ID in ${entry}. Using filename ID: ${id}`);
233
+ }
234
+ }
235
+
236
+ if (id) {
237
+ const isDone = status.toLowerCase() === "done" || status.toLowerCase() === "canceled";
238
+ list.push({
239
+ id,
240
+ title,
241
+ completed: isDone,
242
+ metadata: { path: fullPath, status, order, birthtime }
243
+ });
244
+ }
245
+ }
246
+ } catch (e) {
247
+ // Ignore file access errors
248
+ }
249
+ }
250
+ } catch (e) {
251
+ // Ignore dir access errors
252
+ }
253
+ }
254
+
255
+ private async countTickets(dir: string, done: boolean): Promise<number> {
256
+ const all: Task[] = [];
257
+ await this.scanTickets(dir, all);
258
+ return all.filter(t => {
259
+ const isParentId = t.id === "parent" || t.id === "linear_ticket_parent" || t.id === "task_priority_parent";
260
+ const isEpicTitle = t.title.toLowerCase().includes("[epic]");
261
+ return t.completed === done && !isParentId && !isEpicTitle;
262
+ }).length;
263
+ }
264
+ }
@@ -0,0 +1,93 @@
1
+ import { expect, test, describe, mock, beforeEach } from "bun:test";
2
+ import { buildPrompt } from "./prompt.js";
3
+ import type { SessionState } from "../config/types.js";
4
+ import type { Task } from "../../types/tasks.js";
5
+
6
+ // Mock utilities
7
+ mock.module("../../utils/resources.js", () => ({
8
+ resolveSkillPath: (name: string) => `/mock/skills/${name}.md`,
9
+ getExtensionRoot: () => "/mock/extension",
10
+ getCliCommand: () => "pickle"
11
+ }));
12
+
13
+ mock.module("../../utils/persona.js", () => ({
14
+ PICKLE_PERSONA: "I AM PICKLE RICK"
15
+ }));
16
+
17
+ // Mock fs/promises and fs
18
+ const mockFiles: Record<string, string> = {
19
+ "/mock/skills/prd-drafter.md": "PRD Skill content",
20
+ "/mock/skills/ticket-manager.md": "Ticket Skill content",
21
+ "/mock/skills/code-researcher.md": "Research Skill content",
22
+ "/mock/session/ticket1/linear_ticket_ticket1.md": "status: Triage\ntitle: Ticket 1",
23
+ };
24
+
25
+ const mockExists: Record<string, boolean> = {
26
+ "/mock/skills/prd-drafter.md": true,
27
+ "/mock/skills/ticket-manager.md": true,
28
+ "/mock/skills/code-researcher.md": true,
29
+ "/mock/session/ticket1/linear_ticket_ticket1.md": true,
30
+ };
31
+
32
+ mock.module("node:fs/promises", () => ({
33
+ readFile: async (path: string) => {
34
+ if (mockFiles[path]) return mockFiles[path];
35
+ throw new Error(`File not found: ${path}`);
36
+ }
37
+ }));
38
+
39
+ mock.module("node:fs", () => ({
40
+ existsSync: (path: string) => !!mockExists[path],
41
+ readdirSync: (path: string) => []
42
+ }));
43
+
44
+ describe("Prompt Logic (buildPrompt)", () => {
45
+ const baseState: SessionState = {
46
+ active: true,
47
+ working_dir: "/mock/working",
48
+ session_dir: "/mock/session",
49
+ step: "prd",
50
+ iteration: 1,
51
+ max_iterations: 10,
52
+ max_time_minutes: 30,
53
+ worker_timeout_seconds: 300,
54
+ start_time_epoch: Date.now(),
55
+ completion_promise: "DONE",
56
+ original_prompt: "Test prompt",
57
+ current_ticket: null,
58
+ history: [],
59
+ started_at: new Date().toISOString()
60
+ };
61
+
62
+ test("should generate PRD phase prompt", async () => {
63
+ const task: Task = { id: "phase-prd", title: "PRD", body: "", completed: false };
64
+ const prompt = await buildPrompt(baseState, task);
65
+
66
+ expect(prompt).toContain("Phase: REQUIREMENTS");
67
+ expect(prompt).toContain("PRD Skill content");
68
+ expect(prompt).toContain("I AM PICKLE RICK");
69
+ });
70
+
71
+ test("should generate Breakdown phase prompt", async () => {
72
+ const task: Task = { id: "phase-breakdown", title: "Breakdown", body: "", completed: false };
73
+ const prompt = await buildPrompt(baseState, task);
74
+
75
+ expect(prompt).toContain("Phase: BREAKDOWN");
76
+ expect(prompt).toContain("Ticket Skill content");
77
+ });
78
+
79
+ test("should generate Research phase prompt for a new ticket", async () => {
80
+ const task: Task = { id: "ticket1", title: "Ticket 1", body: "", completed: false };
81
+ const prompt = await buildPrompt(baseState, task);
82
+
83
+ expect(prompt).toContain("Phase: RESEARCH (Ticket: Ticket 1)");
84
+ expect(prompt).toContain("Research Skill content");
85
+ expect(prompt).toContain("linear_ticket_ticket1.md");
86
+ });
87
+
88
+ test("should fail if ticket file is missing", async () => {
89
+ const task: Task = { id: "missing", title: "Missing", body: "", completed: false };
90
+
91
+ expect(buildPrompt(baseState, task)).rejects.toThrow("CRITICAL ERROR: Ticket file missing");
92
+ });
93
+ });