prjct-cli 1.6.13 → 1.7.1

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/CHANGELOG.md CHANGED
@@ -1,12 +1,70 @@
1
1
  # Changelog
2
2
 
3
- ## [1.6.13] - 2026-02-07
3
+ ## [1.7.1] - 2026-02-07
4
4
 
5
- ### Refactoring
5
+ ### Bug Fixes
6
+
7
+ - add Zod validation on all storage reads (PRJ-279) (#140)
8
+
9
+
10
+ ## [1.7.1] - 2026-02-07
11
+
12
+ ### Bug Fix
13
+ - **Add Zod validation on all storage reads (PRJ-279)**: Created `safeRead<T>()` utility that wraps `JSON.parse` + `schema.safeParse()`. All 5 `StorageManager` subclasses (state, queue, ideas, shipped, metrics) now validate reads against their Zod schemas. Corrupted files produce a logged warning + `.backup` file instead of silently crashing downstream.
14
+
15
+ ### Implementation Details
16
+ Created `core/storage/safe-reader.ts` with a `ValidationSchema` interface decoupled from Zod generics to avoid strict type parameter matching. The `StorageManager` base class accepts an optional schema via constructor — subclasses pass their Zod schema with a single import + arg change. `safeRead` returns the raw parsed JSON (not Zod-transformed `result.data`) to preserve extra fields for forward compatibility. Also fixed `ShippedJsonSchema` which used `items` instead of `shipped` (pre-existing schema bug), and made `changes` optional to match actual data.
6
17
 
7
- - remove unused templates and dead code (PRJ-293) (#138)
8
- - remove unused templates and dead code (PRJ-293)
18
+ ### Learnings
19
+ - Zod's default `strip` mode silently drops unknown keys from `result.data` — must return raw JSON to preserve extra state.json fields (projectId, stack, domains, etc.)
20
+ - `ShippedJsonSchema` had `items` instead of `shipped` as the array key — pre-existing schema/data mismatch
21
+ - `ValidationSchema` interface avoids Zod generic constraints while still providing type-safe validation
22
+
23
+ ### Test Plan
24
+
25
+ #### For QA
26
+ 1. Create a valid `state.json` — verify it reads correctly with no warnings
27
+ 2. Corrupt a storage file with invalid JSON — verify `.backup` is created and defaults returned
28
+ 3. Write valid JSON with wrong schema — verify `.backup` and defaults
29
+ 4. Add extra fields not in schema — verify they are preserved after read
30
+ 5. Run `bun test` — verify all 438 tests pass (16 new for `safeRead`)
31
+
32
+ #### For Users
33
+ **What changed:** Storage reads are now validated against Zod schemas. Corrupted files no longer cause silent crashes.
34
+ **How to use:** No action needed — automatic.
35
+ **Breaking changes:** None.
9
36
 
37
+ ## [1.7.0] - 2026-02-07
38
+
39
+ ### Features
40
+
41
+ - use relative timestamps to reduce token waste (PRJ-274) (#139)
42
+ - use relative timestamps to reduce token waste (PRJ-274)
43
+
44
+ ## [1.6.16] - 2026-02-07
45
+
46
+ ### Improvement
47
+ - **Use relative timestamps to reduce token waste (PRJ-274)**: Added `toRelative()` function using `date-fns` `formatDistanceToNowStrict`. Replaced raw ISO-8601 timestamps in Markdown context files (`now.md`, `ideas.md`, `shipped.md`) with human-readable relative time ("5 minutes ago", "3 days ago"). JSON storage retains full ISO timestamps — no data loss.
48
+
49
+ ### Implementation Details
50
+ Added `date-fns` as a dependency and created a thin `toRelative(date)` wrapper around `formatDistanceToNowStrict` in `core/utils/date-helper.ts`. Updated `toMarkdown()` in `state-storage.ts` (Started/Paused fields), `ideas-storage.ts` (all 3 sections: pending, converted, archived), and `shipped-storage.ts` (ship date per entry). 6 new unit tests added covering minutes, hours, days, months, Date objects, and ISO string inputs.
51
+
52
+ ### Learnings
53
+ - `date-fns` `formatDistanceToNowStrict` gives exact units ("5 minutes ago" not "about 5 minutes ago") — better for token efficiency
54
+ - Tests need `setSystemTime()` from `bun:test` since `formatDistanceToNowStrict` uses system clock internally
55
+
56
+ ### Test Plan
57
+
58
+ #### For QA
59
+ 1. Run `bun test core/__tests__/utils/date-helper.test.ts` — verify all 55 tests pass (6 new for `toRelative`)
60
+ 2. Run `bun run build` — verify build succeeds
61
+ 3. Run `prjct sync` — verify `context/now.md` shows relative timestamps instead of raw ISO
62
+ 4. Check `ideas.md` and `shipped.md` for relative date format
63
+
64
+ #### For Users
65
+ **What changed:** Timestamps in context files now show "5 minutes ago", "3 days ago" instead of raw ISO-8601 strings.
66
+ **How to use:** No action needed — automatic.
67
+ **Breaking changes:** None.
10
68
 
11
69
  ## [1.6.15] - 2026-02-07
12
70
 
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Safe Reader Tests
3
+ *
4
+ * Tests for Zod-validated storage reads:
5
+ * - Valid data passes through
6
+ * - Corrupted JSON creates .backup + returns null
7
+ * - Valid JSON with wrong schema creates .backup + returns null
8
+ * - Missing files return null (no backup)
9
+ * - Extra fields are preserved (forward compatibility)
10
+ */
11
+
12
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
13
+ import fs from 'node:fs/promises'
14
+ import os from 'node:os'
15
+ import path from 'node:path'
16
+ import { z } from 'zod'
17
+ import { safeRead } from '../../storage/safe-reader'
18
+
19
+ // =============================================================================
20
+ // Test Schema
21
+ // =============================================================================
22
+
23
+ const TestSchema = z.object({
24
+ name: z.string(),
25
+ count: z.number(),
26
+ items: z.array(z.string()),
27
+ })
28
+
29
+ type TestData = z.infer<typeof TestSchema>
30
+
31
+ // =============================================================================
32
+ // Setup
33
+ // =============================================================================
34
+
35
+ let tmpDir: string
36
+
37
+ beforeEach(async () => {
38
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'prjct-safe-reader-test-'))
39
+ })
40
+
41
+ afterEach(async () => {
42
+ await fs.rm(tmpDir, { recursive: true, force: true })
43
+ })
44
+
45
+ // =============================================================================
46
+ // Tests
47
+ // =============================================================================
48
+
49
+ describe('safeRead', () => {
50
+ describe('valid data', () => {
51
+ it('should return validated data for valid JSON matching schema', async () => {
52
+ const filePath = path.join(tmpDir, 'valid.json')
53
+ const data: TestData = { name: 'test', count: 42, items: ['a', 'b'] }
54
+ await fs.writeFile(filePath, JSON.stringify(data, null, 2))
55
+
56
+ const result = await safeRead<TestData>(filePath, TestSchema)
57
+
58
+ expect(result).toEqual(data)
59
+ })
60
+
61
+ it('should preserve extra fields not in schema', async () => {
62
+ const filePath = path.join(tmpDir, 'extra-fields.json')
63
+ const data = {
64
+ name: 'test',
65
+ count: 1,
66
+ items: [],
67
+ extraField: 'preserved',
68
+ nested: { deep: true },
69
+ }
70
+ await fs.writeFile(filePath, JSON.stringify(data, null, 2))
71
+
72
+ const result = await safeRead<typeof data>(filePath, TestSchema)
73
+
74
+ expect(result).not.toBeNull()
75
+ expect(result!.name).toBe('test')
76
+ expect(result!.extraField).toBe('preserved')
77
+ expect(result!.nested).toEqual({ deep: true })
78
+ })
79
+
80
+ it('should not create .backup for valid data', async () => {
81
+ const filePath = path.join(tmpDir, 'no-backup.json')
82
+ const data: TestData = { name: 'ok', count: 0, items: [] }
83
+ await fs.writeFile(filePath, JSON.stringify(data))
84
+
85
+ await safeRead<TestData>(filePath, TestSchema)
86
+
87
+ const backupExists = await fs
88
+ .access(`${filePath}.backup`)
89
+ .then(() => true)
90
+ .catch(() => false)
91
+ expect(backupExists).toBe(false)
92
+ })
93
+ })
94
+
95
+ describe('missing files', () => {
96
+ it('should return null for non-existent file', async () => {
97
+ const result = await safeRead<TestData>(path.join(tmpDir, 'missing.json'), TestSchema)
98
+
99
+ expect(result).toBeNull()
100
+ })
101
+
102
+ it('should not create .backup for missing file', async () => {
103
+ const filePath = path.join(tmpDir, 'missing.json')
104
+ await safeRead<TestData>(filePath, TestSchema)
105
+
106
+ const backupExists = await fs
107
+ .access(`${filePath}.backup`)
108
+ .then(() => true)
109
+ .catch(() => false)
110
+ expect(backupExists).toBe(false)
111
+ })
112
+ })
113
+
114
+ describe('corrupted JSON', () => {
115
+ it('should return null for malformed JSON', async () => {
116
+ const filePath = path.join(tmpDir, 'malformed.json')
117
+ await fs.writeFile(filePath, 'not valid json {{{')
118
+
119
+ const result = await safeRead<TestData>(filePath, TestSchema)
120
+
121
+ expect(result).toBeNull()
122
+ })
123
+
124
+ it('should create .backup for malformed JSON', async () => {
125
+ const filePath = path.join(tmpDir, 'malformed.json')
126
+ const badContent = 'not valid json {{{'
127
+ await fs.writeFile(filePath, badContent)
128
+
129
+ await safeRead<TestData>(filePath, TestSchema)
130
+
131
+ const backup = await fs.readFile(`${filePath}.backup`, 'utf-8')
132
+ expect(backup).toBe(badContent)
133
+ })
134
+
135
+ it('should return null for empty file', async () => {
136
+ const filePath = path.join(tmpDir, 'empty.json')
137
+ await fs.writeFile(filePath, '')
138
+
139
+ const result = await safeRead<TestData>(filePath, TestSchema)
140
+
141
+ expect(result).toBeNull()
142
+ })
143
+ })
144
+
145
+ describe('valid JSON with wrong schema', () => {
146
+ it('should return null when required field is missing', async () => {
147
+ const filePath = path.join(tmpDir, 'missing-field.json')
148
+ await fs.writeFile(filePath, JSON.stringify({ name: 'test' })) // missing count and items
149
+
150
+ const result = await safeRead<TestData>(filePath, TestSchema)
151
+
152
+ expect(result).toBeNull()
153
+ })
154
+
155
+ it('should create .backup when schema validation fails', async () => {
156
+ const filePath = path.join(tmpDir, 'wrong-schema.json')
157
+ const data = { name: 123, count: 'not a number', items: 'not an array' }
158
+ await fs.writeFile(filePath, JSON.stringify(data))
159
+
160
+ await safeRead<TestData>(filePath, TestSchema)
161
+
162
+ const backupExists = await fs
163
+ .access(`${filePath}.backup`)
164
+ .then(() => true)
165
+ .catch(() => false)
166
+ expect(backupExists).toBe(true)
167
+ })
168
+
169
+ it('should return null when field has wrong type', async () => {
170
+ const filePath = path.join(tmpDir, 'wrong-type.json')
171
+ await fs.writeFile(filePath, JSON.stringify({ name: 42, count: 1, items: [] }))
172
+
173
+ const result = await safeRead<TestData>(filePath, TestSchema)
174
+
175
+ expect(result).toBeNull()
176
+ })
177
+
178
+ it('should return null when array contains wrong types', async () => {
179
+ const filePath = path.join(tmpDir, 'wrong-array.json')
180
+ await fs.writeFile(filePath, JSON.stringify({ name: 'test', count: 1, items: [1, 2, 3] }))
181
+
182
+ const result = await safeRead<TestData>(filePath, TestSchema)
183
+
184
+ expect(result).toBeNull()
185
+ })
186
+ })
187
+
188
+ describe('optional fields and defaults', () => {
189
+ it('should handle schema with optional fields', async () => {
190
+ const OptionalSchema = z.object({
191
+ name: z.string(),
192
+ description: z.string().optional(),
193
+ })
194
+
195
+ const filePath = path.join(tmpDir, 'optional.json')
196
+ await fs.writeFile(filePath, JSON.stringify({ name: 'test' }))
197
+
198
+ const result = await safeRead<z.infer<typeof OptionalSchema>>(filePath, OptionalSchema)
199
+
200
+ expect(result).not.toBeNull()
201
+ expect(result!.name).toBe('test')
202
+ expect(result!.description).toBeUndefined()
203
+ })
204
+
205
+ it('should handle schema with nullable fields', async () => {
206
+ const NullableSchema = z.object({
207
+ currentTask: z.object({ id: z.string() }).nullable(),
208
+ lastUpdated: z.string(),
209
+ })
210
+
211
+ const filePath = path.join(tmpDir, 'nullable.json')
212
+ await fs.writeFile(filePath, JSON.stringify({ currentTask: null, lastUpdated: '2026-01-01' }))
213
+
214
+ const result = await safeRead<z.infer<typeof NullableSchema>>(filePath, NullableSchema)
215
+
216
+ expect(result).not.toBeNull()
217
+ expect(result!.currentTask).toBeNull()
218
+ })
219
+ })
220
+
221
+ describe('integration with StorageManager pattern', () => {
222
+ it('should work with real StateJsonSchema', async () => {
223
+ // Import the actual schema used in production
224
+ const { StateJsonSchema } = await import('../../schemas/state')
225
+
226
+ const filePath = path.join(tmpDir, 'state.json')
227
+ const stateData = {
228
+ currentTask: null,
229
+ lastUpdated: '2026-02-07T00:00:00.000Z',
230
+ // Extra fields that exist in real state.json but not in schema
231
+ projectId: 'test-123',
232
+ stack: { language: 'TypeScript', framework: 'Hono' },
233
+ }
234
+ await fs.writeFile(filePath, JSON.stringify(stateData, null, 2))
235
+
236
+ const result = await safeRead<typeof stateData>(filePath, StateJsonSchema)
237
+
238
+ expect(result).not.toBeNull()
239
+ expect(result!.currentTask).toBeNull()
240
+ expect(result!.projectId).toBe('test-123') // Extra field preserved
241
+ })
242
+
243
+ it('should reject corrupted state data', async () => {
244
+ const { StateJsonSchema } = await import('../../schemas/state')
245
+
246
+ const filePath = path.join(tmpDir, 'bad-state.json')
247
+ // currentTask should be an object or null, not a number
248
+ const badData = { currentTask: 42, lastUpdated: '2026-02-07' }
249
+ await fs.writeFile(filePath, JSON.stringify(badData))
250
+
251
+ const result = await safeRead(filePath, StateJsonSchema)
252
+
253
+ expect(result).toBeNull()
254
+ // Backup should exist
255
+ const backupExists = await fs
256
+ .access(`${filePath}.backup`)
257
+ .then(() => true)
258
+ .catch(() => false)
259
+ expect(backupExists).toBe(true)
260
+ })
261
+ })
262
+ })
@@ -21,6 +21,7 @@ import {
21
21
  isToday,
22
22
  isWithinLastDays,
23
23
  parseDate,
24
+ toRelative,
24
25
  } from '../../utils/date-helper'
25
26
 
26
27
  describe('DateHelper', () => {
@@ -402,4 +403,47 @@ describe('DateHelper', () => {
402
403
  expect(original.getMinutes()).toBe(30)
403
404
  })
404
405
  })
406
+
407
+ describe('toRelative', () => {
408
+ it('should show minutes for dates within 1 hour', () => {
409
+ setSystemTime(new Date('2025-10-15T12:00:00.000Z'))
410
+ const fiveMinAgo = '2025-10-15T11:55:00.000Z'
411
+ expect(toRelative(fiveMinAgo)).toBe('5 minutes ago')
412
+ setSystemTime()
413
+ })
414
+
415
+ it('should show hours for dates within 24 hours', () => {
416
+ setSystemTime(new Date('2025-10-15T12:00:00.000Z'))
417
+ const threeHoursAgo = '2025-10-15T09:00:00.000Z'
418
+ expect(toRelative(threeHoursAgo)).toBe('3 hours ago')
419
+ setSystemTime()
420
+ })
421
+
422
+ it('should show days for dates within 7 days', () => {
423
+ setSystemTime(new Date('2025-10-15T12:00:00.000Z'))
424
+ const twoDaysAgo = '2025-10-13T12:00:00.000Z'
425
+ expect(toRelative(twoDaysAgo)).toBe('2 days ago')
426
+ setSystemTime()
427
+ })
428
+
429
+ it('should show months for dates older than 30 days', () => {
430
+ setSystemTime(new Date('2025-10-15T12:00:00.000Z'))
431
+ const twoMonthsAgo = '2025-08-15T12:00:00.000Z'
432
+ expect(toRelative(twoMonthsAgo)).toBe('2 months ago')
433
+ setSystemTime()
434
+ })
435
+
436
+ it('should accept Date objects', () => {
437
+ setSystemTime(new Date('2025-10-15T12:00:00.000Z'))
438
+ const date = new Date('2025-10-15T11:00:00.000Z')
439
+ expect(toRelative(date)).toBe('1 hour ago')
440
+ setSystemTime()
441
+ })
442
+
443
+ it('should accept ISO string timestamps', () => {
444
+ setSystemTime(new Date('2025-10-15T12:00:00.000Z'))
445
+ expect(toRelative('2025-10-14T12:00:00.000Z')).toBe('1 day ago')
446
+ setSystemTime()
447
+ })
448
+ })
405
449
  })
@@ -56,7 +56,7 @@ export const ShippedItemSchema = z.object({
56
56
  type: ShipTypeSchema,
57
57
  agent: z.string().optional(), // "fe+be", "be", "fe"
58
58
  description: z.string().optional(),
59
- changes: z.array(ShipChangeSchema),
59
+ changes: z.array(ShipChangeSchema).optional(),
60
60
  codeSnippets: z.array(z.string()).optional(),
61
61
  commit: CommitInfoSchema.optional(),
62
62
  codeMetrics: CodeMetricsSchema.optional(),
@@ -69,7 +69,7 @@ export const ShippedItemSchema = z.object({
69
69
  })
70
70
 
71
71
  export const ShippedJsonSchema = z.object({
72
- items: z.array(ShippedItemSchema),
72
+ shipped: z.array(ShippedItemSchema),
73
73
  lastUpdated: z.string(),
74
74
  })
75
75
 
@@ -104,6 +104,6 @@ export const safeParseShipped = (data: unknown) => ShippedJsonSchema.safeParse(d
104
104
  // =============================================================================
105
105
 
106
106
  export const DEFAULT_SHIPPED: ShippedJson = {
107
- items: [],
107
+ shipped: [],
108
108
  lastUpdated: '',
109
109
  }
@@ -6,13 +6,14 @@
6
6
  */
7
7
 
8
8
  import { generateUUID } from '../schemas'
9
+ import { IdeasJsonSchema } from '../schemas/ideas'
9
10
  import type { Idea, IdeaPriority, IdeaStatus, IdeasJson } from '../types'
10
- import { getTimestamp } from '../utils/date-helper'
11
+ import { getTimestamp, toRelative } from '../utils/date-helper'
11
12
  import { StorageManager } from './storage-manager'
12
13
 
13
14
  class IdeasStorage extends StorageManager<IdeasJson> {
14
15
  constructor() {
15
- super('ideas.json')
16
+ super('ideas.json', IdeasJsonSchema)
16
17
  }
17
18
 
18
19
  protected getDefault(): IdeasJson {
@@ -45,10 +46,10 @@ class IdeasStorage extends StorageManager<IdeasJson> {
45
46
  lines.push('## Brain Dump')
46
47
  if (pending.length > 0) {
47
48
  pending.forEach((idea) => {
48
- const date = idea.addedAt.split('T')[0]
49
+ const rel = toRelative(idea.addedAt)
49
50
  const tags = idea.tags.length > 0 ? ` ${idea.tags.map((t) => `#${t}`).join(' ')}` : ''
50
51
  const priority = idea.priority !== 'medium' ? ` [${idea.priority.toUpperCase()}]` : ''
51
- lines.push(`- ${idea.text}${priority} _(${date})_${tags}`)
52
+ lines.push(`- ${idea.text}${priority} _(${rel})_${tags}`)
52
53
  })
53
54
  } else {
54
55
  lines.push('_No pending ideas_')
@@ -59,9 +60,9 @@ class IdeasStorage extends StorageManager<IdeasJson> {
59
60
  if (converted.length > 0) {
60
61
  lines.push('## Converted')
61
62
  converted.forEach((idea) => {
62
- const date = idea.addedAt.split('T')[0]
63
+ const rel = toRelative(idea.addedAt)
63
64
  const feat = idea.convertedTo ? ` \u2192 ${idea.convertedTo}` : ''
64
- lines.push(`- \u2713 ${idea.text}${feat} _(${date})_`)
65
+ lines.push(`- \u2713 ${idea.text}${feat} _(${rel})_`)
65
66
  })
66
67
  lines.push('')
67
68
  }
@@ -70,8 +71,8 @@ class IdeasStorage extends StorageManager<IdeasJson> {
70
71
  if (archived.length > 0) {
71
72
  lines.push('## Archived')
72
73
  archived.forEach((idea) => {
73
- const date = idea.addedAt.split('T')[0]
74
- lines.push(`- ${idea.text} _(${date})_`)
74
+ const rel = toRelative(idea.addedAt)
75
+ lines.push(`- ${idea.text} _(${rel})_`)
75
76
  })
76
77
  lines.push('')
77
78
  }
@@ -18,13 +18,14 @@ import {
18
18
  estimateCostSaved,
19
19
  formatCost,
20
20
  type MetricsJson,
21
+ MetricsJsonSchema,
21
22
  } from '../schemas/metrics'
22
23
  import { getTimestamp } from '../utils/date-helper'
23
24
  import { StorageManager } from './storage-manager'
24
25
 
25
26
  class MetricsStorage extends StorageManager<MetricsJson> {
26
27
  constructor() {
27
- super('metrics.json')
28
+ super('metrics.json', MetricsJsonSchema)
28
29
  }
29
30
 
30
31
  protected getDefault(): MetricsJson {
@@ -7,12 +7,13 @@
7
7
 
8
8
  import { generateUUID } from '../schemas'
9
9
  import type { Priority, QueueJson, QueueTask, TaskSection } from '../schemas/state'
10
+ import { QueueJsonSchema } from '../schemas/state'
10
11
  import { getTimestamp } from '../utils/date-helper'
11
12
  import { StorageManager } from './storage-manager'
12
13
 
13
14
  class QueueStorage extends StorageManager<QueueJson> {
14
15
  constructor() {
15
- super('queue.json')
16
+ super('queue.json', QueueJsonSchema)
16
17
  }
17
18
 
18
19
  protected getDefault(): QueueJson {
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Safe Reader
3
+ *
4
+ * Wraps JSON.parse + Zod schema.safeParse() for validated storage reads.
5
+ * On corruption: logs warning, creates .backup, returns null.
6
+ *
7
+ * Uses schema for structural validation only — returns the raw parsed
8
+ * data (not Zod-transformed) to preserve extra fields that may exist
9
+ * in storage files but aren't in the schema yet (forward compatibility).
10
+ *
11
+ * @version 1.0.0
12
+ */
13
+
14
+ import fs from 'node:fs/promises'
15
+ import type { ZodError } from 'zod'
16
+ import { isNotFoundError } from '../types/fs'
17
+
18
+ /**
19
+ * Minimal interface for Zod-like validation.
20
+ * Decoupled from Zod generics to avoid strict type parameter matching.
21
+ */
22
+ export interface ValidationSchema {
23
+ safeParse(data: unknown): { success: boolean; error?: ZodError }
24
+ }
25
+
26
+ /**
27
+ * Read and validate a JSON file against a Zod schema.
28
+ *
29
+ * Flow:
30
+ * 1. Read file → if missing, return null
31
+ * 2. JSON.parse → if malformed JSON, backup + return null
32
+ * 3. schema.safeParse → if valid, return raw parsed data as T
33
+ * 4. If invalid but parseable JSON, backup + return null
34
+ *
35
+ * Returns raw parsed JSON (not Zod-transformed) to preserve extra fields.
36
+ *
37
+ * @returns Validated data or null if file is missing/corrupted
38
+ */
39
+ export async function safeRead<T>(filePath: string, schema: ValidationSchema): Promise<T | null> {
40
+ let content: string
41
+
42
+ // Step 1: Read file
43
+ try {
44
+ content = await fs.readFile(filePath, 'utf-8')
45
+ } catch (error) {
46
+ if (isNotFoundError(error)) {
47
+ return null
48
+ }
49
+ throw error
50
+ }
51
+
52
+ // Step 2: JSON.parse
53
+ let raw: unknown
54
+ try {
55
+ raw = JSON.parse(content)
56
+ } catch {
57
+ // Malformed JSON — backup and return null
58
+ await createBackup(filePath, content)
59
+ logCorruption(filePath, 'Malformed JSON')
60
+ return null
61
+ }
62
+
63
+ // Step 3: Validate against schema
64
+ const result = schema.safeParse(raw)
65
+ if (result.success) {
66
+ // Return raw data to preserve extra fields not in schema
67
+ return raw as T
68
+ }
69
+
70
+ // Step 4: Validation failed — backup and return null
71
+ await createBackup(filePath, content)
72
+ logCorruption(filePath, formatZodError(result.error!))
73
+ return null
74
+ }
75
+
76
+ /**
77
+ * Create a .backup of a corrupted file
78
+ */
79
+ async function createBackup(filePath: string, content: string): Promise<void> {
80
+ const backupPath = `${filePath}.backup`
81
+ try {
82
+ await fs.writeFile(backupPath, content, 'utf-8')
83
+ } catch {
84
+ // Best-effort backup — don't throw if it fails
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Log corruption warning to stderr
90
+ */
91
+ function logCorruption(filePath: string, reason: string): void {
92
+ console.error(`[prjct] Warning: Corrupted storage file: ${filePath}`)
93
+ console.error(`[prjct] Reason: ${reason}`)
94
+ console.error(`[prjct] A .backup file has been created. Returning defaults.`)
95
+ }
96
+
97
+ /**
98
+ * Format Zod error into a readable string
99
+ */
100
+ function formatZodError(error: ZodError): string {
101
+ return error.issues
102
+ .slice(0, 3)
103
+ .map((issue) => `${issue.path.join('.')}: ${issue.message}`)
104
+ .join('; ')
105
+ }
@@ -6,13 +6,14 @@
6
6
  */
7
7
 
8
8
  import { generateUUID } from '../schemas'
9
+ import { ShippedJsonSchema } from '../schemas/shipped'
9
10
  import type { ShippedFeature, ShippedJson } from '../types'
10
- import { getTimestamp } from '../utils/date-helper'
11
+ import { getTimestamp, toRelative } from '../utils/date-helper'
11
12
  import { StorageManager } from './storage-manager'
12
13
 
13
14
  class ShippedStorage extends StorageManager<ShippedJson> {
14
15
  constructor() {
15
- super('shipped.json')
16
+ super('shipped.json', ShippedJsonSchema)
16
17
  }
17
18
 
18
19
  protected getDefault(): ShippedJson {
@@ -72,13 +73,10 @@ class ShippedStorage extends StorageManager<ShippedJson> {
72
73
  .sort((a, b) => new Date(b.shippedAt).getTime() - new Date(a.shippedAt).getTime())
73
74
 
74
75
  ships.forEach((ship) => {
75
- const date = new Date(ship.shippedAt).toLocaleDateString('en-US', {
76
- month: 'short',
77
- day: 'numeric',
78
- })
76
+ const rel = toRelative(ship.shippedAt)
79
77
  const version = ship.version ? ` v${ship.version}` : ''
80
78
  const duration = ship.duration ? ` (${ship.duration})` : ''
81
- lines.push(`- **${ship.name}**${version}${duration} - ${date}`)
79
+ lines.push(`- **${ship.name}**${version}${duration} - ${rel}`)
82
80
  if (ship.description) {
83
81
  lines.push(` _${ship.description}_`)
84
82
  }
@@ -16,13 +16,14 @@ import type {
16
16
  Subtask,
17
17
  SubtaskSummary,
18
18
  } from '../schemas/state'
19
- import { getTimestamp } from '../utils/date-helper'
19
+ import { StateJsonSchema } from '../schemas/state'
20
+ import { getTimestamp, toRelative } from '../utils/date-helper'
20
21
  import { md } from '../utils/markdown-builder'
21
22
  import { StorageManager } from './storage-manager'
22
23
 
23
24
  class StateStorage extends StorageManager<StateJson> {
24
25
  constructor() {
25
- super('state.json')
26
+ super('state.json', StateJsonSchema)
26
27
  }
27
28
 
28
29
  protected getDefault(): StateJson {
@@ -52,7 +53,7 @@ class StateStorage extends StorageManager<StateJson> {
52
53
  const task = data.currentTask!
53
54
  m.bold(task.description)
54
55
  .blank()
55
- .raw(`Started: ${task.startedAt}`)
56
+ .raw(`Started: ${toRelative(task.startedAt)}`)
56
57
  .raw(`Session: ${task.sessionId}`)
57
58
  .maybe(task.featureId, (m, id) => m.raw(`Feature: ${id}`))
58
59
 
@@ -122,7 +123,7 @@ class StateStorage extends StorageManager<StateJson> {
122
123
  m.hr()
123
124
  .h2('Paused')
124
125
  .bold(prev.description)
125
- .raw(`Paused: ${prev.pausedAt}`)
126
+ .raw(`Paused: ${toRelative(prev.pausedAt)}`)
126
127
  .maybe(prev.pauseReason, (m, reason) => m.raw(`Reason: ${reason}`))
127
128
  .blank()
128
129
  .italic('Use /p:resume to continue')