hone-ai 0.2.0 → 0.9.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.
package/src/status.ts CHANGED
@@ -1,28 +1,28 @@
1
- import { readdirSync, existsSync } from 'fs';
2
- import { join } from 'path';
3
- import { getPlansDir } from './config';
4
- import { loadTaskFile, calculateStatus } from './prds';
5
- import type { Task, TaskFile } from './prds';
1
+ import { readdirSync, existsSync } from 'fs'
2
+ import { join } from 'path'
3
+ import { getPlansDir } from './config'
4
+ import { loadTaskFile, calculateStatus } from './prds'
5
+ import type { Task, TaskFile } from './prds'
6
6
 
7
7
  export interface TaskFileStatus {
8
- filename: string;
9
- feature: string;
10
- completedCount: number;
11
- totalCount: number;
12
- nextTask: Task | null;
8
+ filename: string
9
+ feature: string
10
+ completedCount: number
11
+ totalCount: number
12
+ nextTask: Task | null
13
13
  }
14
14
 
15
15
  /**
16
16
  * Get all task files in .plans/ directory
17
17
  */
18
18
  export function listTaskFiles(): string[] {
19
- const plansDir = getPlansDir();
19
+ const plansDir = getPlansDir()
20
20
  if (!existsSync(plansDir)) {
21
- return [];
21
+ return []
22
22
  }
23
-
24
- const files = readdirSync(plansDir);
25
- return files.filter(file => file.startsWith('tasks-') && file.endsWith('.yml'));
23
+
24
+ const files = readdirSync(plansDir)
25
+ return files.filter(file => file.startsWith('tasks-') && file.endsWith('.yml'))
26
26
  }
27
27
 
28
28
  /**
@@ -31,64 +31,62 @@ export function listTaskFiles(): string[] {
31
31
  */
32
32
  export function findNextTask(taskFile: TaskFile): Task | null {
33
33
  if (!taskFile.tasks || taskFile.tasks.length === 0) {
34
- return null;
34
+ return null
35
35
  }
36
-
36
+
37
37
  // Find first pending task where all dependencies are satisfied
38
38
  for (const task of taskFile.tasks) {
39
39
  if (task.status === 'pending') {
40
40
  // Check if all dependencies are completed or cancelled
41
- const dependencies = task.dependencies || [];
41
+ const dependencies = task.dependencies || []
42
42
  const allDepsCompleted = dependencies.every(depId => {
43
- const depTask = taskFile.tasks.find(t => t.id === depId);
44
- return depTask && (depTask.status === 'completed' || depTask.status === 'cancelled');
45
- });
46
-
43
+ const depTask = taskFile.tasks.find(t => t.id === depId)
44
+ return depTask && (depTask.status === 'completed' || depTask.status === 'cancelled')
45
+ })
46
+
47
47
  if (allDepsCompleted) {
48
- return task;
48
+ return task
49
49
  }
50
50
  }
51
51
  }
52
-
53
- return null;
52
+
53
+ return null
54
54
  }
55
55
 
56
56
  /**
57
57
  * Get status for a single task file
58
58
  */
59
59
  export async function getTaskFileStatus(taskFilename: string): Promise<TaskFileStatus | null> {
60
- const taskFile = await loadTaskFile(taskFilename);
60
+ const taskFile = await loadTaskFile(taskFilename)
61
61
  if (!taskFile) {
62
- return null;
62
+ return null
63
63
  }
64
-
65
- const { status, completedCount, totalCount } = calculateStatus(taskFile);
66
-
64
+
65
+ const { status, completedCount, totalCount } = calculateStatus(taskFile)
66
+
67
67
  // Skip fully completed files
68
68
  if (status === 'completed') {
69
- return null;
69
+ return null
70
70
  }
71
-
72
- const nextTask = findNextTask(taskFile);
73
-
71
+
72
+ const nextTask = findNextTask(taskFile)
73
+
74
74
  return {
75
75
  filename: taskFilename,
76
76
  feature: taskFile.feature,
77
77
  completedCount,
78
78
  totalCount,
79
- nextTask
80
- };
79
+ nextTask,
80
+ }
81
81
  }
82
82
 
83
83
  /**
84
84
  * List all incomplete task files with their status
85
85
  */
86
86
  export async function listIncompleteTaskFiles(): Promise<TaskFileStatus[]> {
87
- const taskFiles = listTaskFiles();
88
- const statusList = await Promise.all(
89
- taskFiles.map(file => getTaskFileStatus(file))
90
- );
91
-
87
+ const taskFiles = listTaskFiles()
88
+ const statusList = await Promise.all(taskFiles.map(file => getTaskFileStatus(file)))
89
+
92
90
  // Filter out null (completed files) and return
93
- return statusList.filter((s): s is TaskFileStatus => s !== null);
91
+ return statusList.filter((s): s is TaskFileStatus => s !== null)
94
92
  }
@@ -1,64 +1,96 @@
1
- import { describe, test, expect, beforeEach, afterEach, beforeAll, afterAll } from 'bun:test';
2
- import { generateTasksFromPRD } from './task-generator';
3
- import { writeFile, mkdir, rm } from 'fs/promises';
4
- import { join } from 'path';
5
- import { existsSync } from 'fs';
1
+ import { describe, test, expect, beforeEach, afterEach, beforeAll, afterAll, mock } from 'bun:test'
2
+ import { generateTasksFromPRD } from './task-generator'
3
+ import { writeFile, mkdir, rm, readFile } from 'fs/promises'
4
+ import { join } from 'path'
5
+ import { existsSync } from 'fs'
6
6
 
7
7
  // Set test environment
8
- const originalEnv = process.env.BUN_ENV;
8
+ const originalEnv = process.env.BUN_ENV
9
9
  beforeAll(() => {
10
- process.env.BUN_ENV = 'test';
11
- });
10
+ process.env.BUN_ENV = 'test'
11
+ })
12
12
  afterAll(() => {
13
- process.env.BUN_ENV = originalEnv;
14
- });
13
+ process.env.BUN_ENV = originalEnv
14
+ })
15
15
 
16
- const TEST_WORKSPACE = join(process.cwd(), '.test-task-generator');
17
- const TEST_PLANS_DIR = join(TEST_WORKSPACE, '.plans');
16
+ const TEST_WORKSPACE = join(process.cwd(), '.test-task-generator')
17
+ const TEST_PLANS_DIR = join(TEST_WORKSPACE, '.plans')
18
18
 
19
19
  describe('task-generator', () => {
20
20
  beforeEach(async () => {
21
+ // Mock AgentClient to avoid real API calls
22
+ mock.module('./agent-client', () => ({
23
+ AgentClient: function () {
24
+ return {
25
+ messages: {
26
+ create: mock().mockResolvedValue({
27
+ content: [
28
+ {
29
+ type: 'text',
30
+ text: JSON.stringify([
31
+ {
32
+ id: 'task-001',
33
+ title: 'Test task',
34
+ description: 'Test task description',
35
+ status: 'pending',
36
+ dependencies: [],
37
+ acceptance_criteria: ['Task works'],
38
+ completed_at: null,
39
+ },
40
+ ]),
41
+ },
42
+ ],
43
+ }),
44
+ },
45
+ }
46
+ },
47
+ }))
48
+
49
+ // Increase timeout to handle directory operations
21
50
  // Create test workspace
22
51
  if (existsSync(TEST_WORKSPACE)) {
23
- await rm(TEST_WORKSPACE, { recursive: true, force: true });
52
+ await rm(TEST_WORKSPACE, { recursive: true, force: true })
24
53
  }
25
- await mkdir(TEST_WORKSPACE, { recursive: true });
26
- await mkdir(TEST_PLANS_DIR, { recursive: true });
27
-
54
+ await mkdir(TEST_WORKSPACE, { recursive: true })
55
+ await mkdir(TEST_PLANS_DIR, { recursive: true })
56
+
28
57
  // Change to test workspace
29
- process.chdir(TEST_WORKSPACE);
30
- });
31
-
58
+ process.chdir(TEST_WORKSPACE)
59
+ })
60
+
32
61
  afterEach(async () => {
62
+ // Restore mocks
63
+ mock.restore()
64
+
33
65
  // Restore original directory and cleanup
34
- process.chdir(join(TEST_WORKSPACE, '..'));
66
+ process.chdir(join(TEST_WORKSPACE, '..'))
35
67
  if (existsSync(TEST_WORKSPACE)) {
36
- await rm(TEST_WORKSPACE, { recursive: true, force: true });
68
+ await rm(TEST_WORKSPACE, { recursive: true, force: true })
37
69
  }
38
- });
39
-
70
+ })
71
+
40
72
  test('throws error if PRD file does not exist', async () => {
41
- const nonExistentPath = join(TEST_PLANS_DIR, 'prd-nonexistent.md');
42
-
43
- await expect(generateTasksFromPRD(nonExistentPath)).rejects.toThrow('File not found');
44
- });
45
-
73
+ const nonExistentPath = join(TEST_PLANS_DIR, 'prd-nonexistent.md')
74
+
75
+ await expect(generateTasksFromPRD(nonExistentPath)).rejects.toThrow('File not found')
76
+ })
77
+
46
78
  test('throws error if PRD filename format is invalid', async () => {
47
- const invalidPath = join(TEST_PLANS_DIR, 'invalid-filename.md');
48
- await writeFile(invalidPath, '# Test PRD', 'utf-8');
49
-
50
- await expect(generateTasksFromPRD(invalidPath)).rejects.toThrow('Invalid PRD filename format');
51
- });
52
-
79
+ const invalidPath = join(TEST_PLANS_DIR, 'invalid-filename.md')
80
+ await writeFile(invalidPath, '# Test PRD', 'utf-8')
81
+
82
+ await expect(generateTasksFromPRD(invalidPath)).rejects.toThrow('Invalid PRD filename format')
83
+ })
84
+
53
85
  test('throws error if PRD filename has no feature name', async () => {
54
- const invalidPath = join(TEST_PLANS_DIR, 'prd-.md');
55
- await writeFile(invalidPath, '# Test PRD', 'utf-8');
56
-
57
- await expect(generateTasksFromPRD(invalidPath)).rejects.toThrow('Invalid PRD filename format');
58
- });
59
-
86
+ const invalidPath = join(TEST_PLANS_DIR, 'prd-.md')
87
+ await writeFile(invalidPath, '# Test PRD', 'utf-8')
88
+
89
+ await expect(generateTasksFromPRD(invalidPath)).rejects.toThrow('Invalid PRD filename format')
90
+ })
91
+
60
92
  test('extracts feature name correctly from PRD filename', async () => {
61
- const prdPath = join(TEST_PLANS_DIR, 'prd-test-feature.md');
93
+ const prdPath = join(TEST_PLANS_DIR, 'prd-test-feature.md')
62
94
  const prdContent = `# PRD: Test Feature
63
95
 
64
96
  ## Overview
@@ -66,28 +98,23 @@ Simple test feature for unit testing.
66
98
 
67
99
  ## Requirements
68
100
  - REQ-1: Basic requirement
69
- `;
70
-
71
- await writeFile(prdPath, prdContent, 'utf-8');
72
-
73
- // Mock API key for this test
74
- const originalApiKey = process.env.ANTHROPIC_API_KEY;
75
- process.env.ANTHROPIC_API_KEY = 'test-key';
76
-
77
- try {
78
- // This will fail at the API call, but we're testing the filename parsing
79
- await generateTasksFromPRD(prdPath);
80
- } catch (error) {
81
- // Expected to fail at API call since we're using a fake key
82
- // But if it failed at filename parsing, it would have thrown earlier
83
- expect(error).toBeDefined();
84
- } finally {
85
- // Restore original API key
86
- if (originalApiKey) {
87
- process.env.ANTHROPIC_API_KEY = originalApiKey;
88
- } else {
89
- delete process.env.ANTHROPIC_API_KEY;
90
- }
91
- }
92
- });
93
- });
101
+ `
102
+
103
+ await writeFile(prdPath, prdContent, 'utf-8')
104
+
105
+ // With AgentClient mocked, this should succeed and create the tasks file
106
+ const result = await generateTasksFromPRD(prdPath)
107
+
108
+ // Verify the feature name was correctly extracted from the filename
109
+ expect(result).toBe('tasks-test-feature.yml')
110
+
111
+ // Verify the tasks file was created with correct content
112
+ const tasksFilePath = join(process.cwd(), '.plans', 'tasks-test-feature.yml')
113
+ expect(existsSync(tasksFilePath)).toBe(true)
114
+
115
+ // Read and verify the content contains the correct feature name
116
+ const tasksContent = await readFile(tasksFilePath, 'utf-8')
117
+ expect(tasksContent).toContain('feature: test-feature')
118
+ expect(tasksContent).toContain('prd: ./prd-test-feature.md')
119
+ })
120
+ })
@@ -1,84 +1,87 @@
1
- import { loadConfig, resolveModelForPhase } from './config';
2
- import { readFile, writeFile } from 'fs/promises';
3
- import { join, basename } from 'path';
4
- import { existsSync } from 'fs';
5
- import { exitWithError, ErrorMessages } from './errors';
6
- import { AgentClient } from './agent-client';
1
+ import { loadConfig, resolveModelForPhase } from './config'
2
+ import { readFile, writeFile } from 'fs/promises'
3
+ import { join, basename } from 'path'
4
+ import { existsSync } from 'fs'
5
+ import { exitWithError, ErrorMessages } from './errors'
6
+ import { AgentClient } from './agent-client'
7
7
 
8
8
  interface Task {
9
- id: string;
10
- title: string;
11
- description: string;
12
- status: 'pending' | 'in_progress' | 'completed';
13
- dependencies: string[];
14
- acceptance_criteria: string[];
15
- completed_at: string | null;
9
+ id: string
10
+ title: string
11
+ description: string
12
+ status: 'pending' | 'in_progress' | 'completed'
13
+ dependencies: string[]
14
+ acceptance_criteria: string[]
15
+ completed_at: string | null
16
16
  }
17
17
 
18
18
  interface TasksFile {
19
- feature: string;
20
- prd: string;
21
- created_at: string;
22
- updated_at: string;
23
- tasks: Task[];
19
+ feature: string
20
+ prd: string
21
+ created_at: string
22
+ updated_at: string
23
+ tasks: Task[]
24
24
  }
25
25
 
26
26
  export async function generateTasksFromPRD(prdFilePath: string): Promise<string> {
27
27
  // Validate PRD file exists
28
28
  if (!existsSync(prdFilePath)) {
29
- const { message, details } = ErrorMessages.FILE_NOT_FOUND(prdFilePath);
30
- exitWithError(message, details);
29
+ const { message, details } = ErrorMessages.FILE_NOT_FOUND(prdFilePath)
30
+ exitWithError(message, details)
31
31
  }
32
-
32
+
33
33
  // Read PRD content
34
- const prdContent = await readFile(prdFilePath, 'utf-8');
35
-
34
+ const prdContent = await readFile(prdFilePath, 'utf-8')
35
+
36
36
  // Extract feature name from PRD filename (prd-feature-name.md -> feature-name)
37
- const prdFilename = basename(prdFilePath);
38
- const featureMatch = prdFilename.match(/^prd-(.+)\.md$/);
37
+ const prdFilename = basename(prdFilePath)
38
+ const featureMatch = prdFilename.match(/^prd-(.+)\.md$/)
39
39
  if (!featureMatch || !featureMatch[1]) {
40
- throw new Error(`Invalid PRD filename format: ${prdFilename}. Expected: prd-<feature-name>.md`);
40
+ throw new Error(`Invalid PRD filename format: ${prdFilename}. Expected: prd-<feature-name>.md`)
41
41
  }
42
- const featureName = featureMatch[1];
43
-
44
- console.log('\nAnalyzing PRD and generating tasks...');
45
-
46
- const tasks = await generateTasksWithAI(prdContent);
47
-
42
+ const featureName = featureMatch[1]
43
+
44
+ console.log('\nAnalyzing PRD and generating tasks...')
45
+
46
+ const tasks = await generateTasksWithAI(prdContent)
47
+
48
48
  // Create tasks file
49
- const now = new Date().toISOString();
49
+ const now = new Date().toISOString()
50
50
  const tasksFile: TasksFile = {
51
51
  feature: featureName,
52
52
  prd: `./${prdFilename}`,
53
53
  created_at: now,
54
54
  updated_at: now,
55
- tasks
56
- };
57
-
55
+ tasks,
56
+ }
57
+
58
58
  // Convert to YAML format
59
- const yamlContent = formatAsYAML(tasksFile);
60
-
59
+ const yamlContent = formatAsYAML(tasksFile)
60
+
61
61
  // Save to .plans/tasks-<feature-name>.yml
62
- const tasksFilename = `tasks-${featureName}.yml`;
63
- const tasksFilePath = join(process.cwd(), '.plans', tasksFilename);
64
-
65
- await writeFile(tasksFilePath, yamlContent, 'utf-8');
66
-
67
- console.log(`✓ Generated ${tasks.length} tasks`);
68
- console.log(`✓ Saved to .plans/${tasksFilename}\n`);
69
-
70
- return tasksFilename;
62
+ const tasksFilename = `tasks-${featureName}.yml`
63
+ const tasksFilePath = join(process.cwd(), '.plans', tasksFilename)
64
+
65
+ await writeFile(tasksFilePath, yamlContent, 'utf-8')
66
+
67
+ console.log(`✓ Generated ${tasks.length} tasks`)
68
+ console.log(`✓ Saved to .plans/${tasksFilename}\n`)
69
+ console.log(
70
+ `Now run "hone run .plans/${tasksFilename} -i ${tasks.length}" to execute the tasks\n`
71
+ )
72
+
73
+ return tasksFilename
71
74
  }
72
75
 
73
76
  async function generateTasksWithAI(prdContent: string): Promise<Task[]> {
74
- const config = await loadConfig();
75
- const model = resolveModelForPhase(config, 'prdToTasks');
76
-
77
+ const config = await loadConfig()
78
+ const model = resolveModelForPhase(config, 'prdToTasks')
79
+
77
80
  const client = new AgentClient({
78
81
  agent: config.defaultAgent,
79
- model
80
- });
81
-
82
+ model,
83
+ })
84
+
82
85
  const systemPrompt = `You are a technical project manager breaking down a PRD into implementable tasks.
83
86
 
84
87
  Generate an ordered list of tasks following these guidelines:
@@ -99,7 +102,7 @@ Generate an ordered list of tasks following these guidelines:
99
102
  - Standard features
100
103
  - Polish and refinements
101
104
 
102
- 3. **Dependencies**:
105
+ 3. **Dependencies**:
103
106
  - Identify which tasks must complete before others
104
107
  - Use task IDs in dependencies array
105
108
  - Keep dependency chains reasonable (don't over-constrain)
@@ -122,7 +125,7 @@ Example output:
122
125
  "completed_at": null
123
126
  },
124
127
  {
125
- "id": "task-002",
128
+ "id": "task-002",
126
129
  "title": "Implement core API client",
127
130
  "description": "Create API client with authentication, error handling, and retry logic. Should support all required endpoints.",
128
131
  "status": "pending",
@@ -136,98 +139,108 @@ Example output:
136
139
  }
137
140
  ]
138
141
 
139
- Now analyze this PRD and generate tasks:`;
142
+ Now analyze this PRD and generate tasks:`
140
143
 
141
144
  try {
142
145
  const response = await client.messages.create({
143
146
  max_tokens: 8000,
144
- messages: [{
145
- role: 'user',
146
- content: prdContent
147
- }],
148
- system: systemPrompt
149
- });
150
-
151
- const content = response.content[0];
147
+ messages: [
148
+ {
149
+ role: 'user',
150
+ content: prdContent,
151
+ },
152
+ ],
153
+ system: systemPrompt,
154
+ })
155
+
156
+ const content = response.content[0]
152
157
  if (!content || content.type !== 'text') {
153
- throw new Error('Invalid response from AI');
158
+ throw new Error('Invalid response from AI')
154
159
  }
155
-
160
+
156
161
  // Extract JSON array from response (handle cases where AI wraps in markdown code blocks)
157
- let jsonText = content.text.trim();
158
- const jsonMatch = jsonText.match(/```(?:json)?\s*(\[[\s\S]*\])\s*```/);
162
+ let jsonText = content.text.trim()
163
+ const jsonMatch = jsonText.match(/```(?:json)?\s*(\[[\s\S]*\])\s*```/)
159
164
  if (jsonMatch && jsonMatch[1]) {
160
- jsonText = jsonMatch[1];
165
+ jsonText = jsonMatch[1]
161
166
  }
162
-
167
+
163
168
  try {
164
- const tasks = JSON.parse(jsonText);
165
-
169
+ const tasks = JSON.parse(jsonText)
170
+
166
171
  if (!Array.isArray(tasks)) {
167
- throw new Error('Response is not an array');
172
+ throw new Error('Response is not an array')
168
173
  }
169
-
174
+
170
175
  // Validate task structure
171
176
  for (const task of tasks) {
172
- if (!task.id || !task.title || !task.description || !task.status ||
173
- !Array.isArray(task.dependencies) || !Array.isArray(task.acceptance_criteria)) {
174
- throw new Error(`Invalid task structure: ${JSON.stringify(task)}`);
177
+ if (
178
+ !task.id ||
179
+ !task.title ||
180
+ !task.description ||
181
+ !task.status ||
182
+ !Array.isArray(task.dependencies) ||
183
+ !Array.isArray(task.acceptance_criteria)
184
+ ) {
185
+ throw new Error(`Invalid task structure: ${JSON.stringify(task)}`)
175
186
  }
176
187
  }
177
-
178
- return tasks;
188
+
189
+ return tasks
179
190
  } catch (error) {
180
- throw new Error(`Failed to parse AI response as JSON: ${error instanceof Error ? error.message : error}`);
191
+ throw new Error(
192
+ `Failed to parse AI response as JSON: ${error instanceof Error ? error.message : error}`
193
+ )
181
194
  }
182
195
  } catch (error) {
183
- const { message, details } = ErrorMessages.NETWORK_ERROR_FINAL(error);
184
- exitWithError(message, details);
185
- throw error; // Never reached but satisfies TypeScript
196
+ const { message, details } = ErrorMessages.NETWORK_ERROR_FINAL(error)
197
+ exitWithError(message, details)
198
+ throw error // Never reached but satisfies TypeScript
186
199
  }
187
200
  }
188
201
 
189
202
  function formatAsYAML(tasksFile: TasksFile): string {
190
- const lines: string[] = [];
191
-
192
- lines.push(`feature: ${tasksFile.feature}`);
193
- lines.push(`prd: ${tasksFile.prd}`);
194
- lines.push(`created_at: ${tasksFile.created_at}`);
195
- lines.push(`updated_at: ${tasksFile.updated_at}`);
196
- lines.push('');
197
- lines.push('tasks:');
198
-
203
+ const lines: string[] = []
204
+
205
+ lines.push(`feature: ${tasksFile.feature}`)
206
+ lines.push(`prd: ${tasksFile.prd}`)
207
+ lines.push(`created_at: ${tasksFile.created_at}`)
208
+ lines.push(`updated_at: ${tasksFile.updated_at}`)
209
+ lines.push('')
210
+ lines.push('tasks:')
211
+
199
212
  for (const task of tasksFile.tasks) {
200
- lines.push(` - id: ${task.id}`);
201
- lines.push(` title: "${task.title}"`);
202
-
213
+ lines.push(` - id: ${task.id}`)
214
+ lines.push(` title: "${task.title}"`)
215
+
203
216
  // Multi-line description with proper YAML indentation
204
- lines.push(` description: |`);
205
- const descLines = task.description.split('\n');
217
+ lines.push(` description: |`)
218
+ const descLines = task.description.split('\n')
206
219
  for (const line of descLines) {
207
- lines.push(` ${line}`);
220
+ lines.push(` ${line}`)
208
221
  }
209
-
210
- lines.push(` status: ${task.status}`);
211
-
222
+
223
+ lines.push(` status: ${task.status}`)
224
+
212
225
  // Dependencies
213
226
  if (task.dependencies.length === 0) {
214
- lines.push(` dependencies: []`);
227
+ lines.push(` dependencies: []`)
215
228
  } else {
216
- lines.push(` dependencies:`);
229
+ lines.push(` dependencies:`)
217
230
  for (const dep of task.dependencies) {
218
- lines.push(` - ${dep}`);
231
+ lines.push(` - ${dep}`)
219
232
  }
220
233
  }
221
-
234
+
222
235
  // Acceptance criteria
223
- lines.push(` acceptance_criteria:`);
236
+ lines.push(` acceptance_criteria:`)
224
237
  for (const criterion of task.acceptance_criteria) {
225
- lines.push(` - "${criterion}"`);
238
+ lines.push(` - "${criterion}"`)
226
239
  }
227
-
228
- lines.push(` completed_at: ${task.completed_at || 'null'}`);
229
- lines.push('');
240
+
241
+ lines.push(` completed_at: ${task.completed_at || 'null'}`)
242
+ lines.push('')
230
243
  }
231
-
232
- return lines.join('\n');
244
+
245
+ return lines.join('\n')
233
246
  }