prjct-cli 0.42.0 → 0.44.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/core/agentic/command-executor.ts +15 -5
  3. package/core/ai-tools/formatters.ts +302 -0
  4. package/core/ai-tools/generator.ts +124 -0
  5. package/core/ai-tools/index.ts +15 -0
  6. package/core/ai-tools/registry.ts +195 -0
  7. package/core/cli/linear.ts +61 -2
  8. package/core/commands/analysis.ts +36 -2
  9. package/core/commands/commands.ts +2 -2
  10. package/core/commands/planning.ts +8 -4
  11. package/core/commands/shipping.ts +9 -7
  12. package/core/commands/workflow.ts +67 -17
  13. package/core/index.ts +3 -1
  14. package/core/infrastructure/ai-provider.ts +11 -36
  15. package/core/integrations/issue-tracker/types.ts +7 -1
  16. package/core/integrations/linear/client.ts +56 -24
  17. package/core/integrations/linear/index.ts +3 -0
  18. package/core/integrations/linear/sync.ts +313 -0
  19. package/core/schemas/index.ts +3 -0
  20. package/core/schemas/issues.ts +144 -0
  21. package/core/schemas/state.ts +3 -0
  22. package/core/services/sync-service.ts +71 -4
  23. package/core/utils/agent-stream.ts +138 -0
  24. package/core/utils/branding.ts +2 -3
  25. package/core/utils/next-steps.ts +95 -0
  26. package/core/utils/output.ts +26 -0
  27. package/core/workflow/index.ts +6 -0
  28. package/core/workflow/state-machine.ts +185 -0
  29. package/dist/bin/prjct.mjs +2382 -541
  30. package/package.json +1 -1
  31. package/templates/_bases/tracker-base.md +11 -0
  32. package/templates/commands/done.md +18 -13
  33. package/templates/commands/git.md +143 -54
  34. package/templates/commands/merge.md +121 -13
  35. package/templates/commands/review.md +1 -1
  36. package/templates/commands/ship.md +165 -20
  37. package/templates/commands/sync.md +17 -0
  38. package/templates/commands/task.md +123 -17
  39. package/templates/global/ANTIGRAVITY.md +2 -4
  40. package/templates/global/CLAUDE.md +115 -28
  41. package/templates/global/CURSOR.mdc +1 -3
  42. package/templates/global/GEMINI.md +2 -4
  43. package/templates/global/WINDSURF.md +1 -3
  44. package/templates/subagents/workflow/prjct-shipper.md +1 -2
@@ -283,45 +283,20 @@ export function hasProviderConfig(provider: AIProviderName): boolean {
283
283
  * Get provider-specific branding
284
284
  */
285
285
  export function getProviderBranding(provider: AIProviderName): ProviderBranding {
286
- const config = Providers[provider]
287
-
288
- if (provider === 'gemini') {
289
- return {
290
- commitFooter: `🤖 Generated with [p/](https://www.prjct.app/)
291
- Designed for [Gemini](${config.websiteUrl})`,
292
- signature: '⚡ prjct + Gemini',
293
- }
294
- }
295
-
296
- if (provider === 'cursor') {
297
- return {
298
- commitFooter: `🤖 Generated with [p/](https://www.prjct.app/)
299
- Built with [Cursor](${config.websiteUrl})`,
300
- signature: '⚡ prjct + Cursor',
301
- }
302
- }
303
-
304
- if (provider === 'antigravity') {
305
- return {
306
- commitFooter: `🤖 Generated with [p/](https://www.prjct.app/)
307
- Powered by [Antigravity](${config.websiteUrl})`,
308
- signature: '⚡ prjct + Antigravity',
309
- }
310
- }
311
-
312
- if (provider === 'windsurf') {
313
- return {
314
- commitFooter: `🤖 Generated with [p/](https://www.prjct.app/)
315
- Built with [Windsurf](${config.websiteUrl})`,
316
- signature: '⚡ prjct + Windsurf',
317
- }
286
+ // Generic commit footer for all providers
287
+ const commitFooter = `Generated with [p/](https://www.prjct.app/)`
288
+
289
+ const signatures: Record<AIProviderName, string> = {
290
+ claude: '⚡ prjct + Claude',
291
+ gemini: '⚡ prjct + Gemini',
292
+ cursor: '⚡ prjct + Cursor',
293
+ antigravity: '⚡ prjct + Antigravity',
294
+ windsurf: '⚡ prjct + Windsurf',
318
295
  }
319
296
 
320
- // Default: Claude
321
297
  return {
322
- commitFooter: `🤖 Generated with [p/](https://www.prjct.app/)
323
- Designed for [Claude](${config.websiteUrl})`,
324
- signature: '⚡ prjct + Claude',
298
+ commitFooter,
299
+ signature: signatures[provider] || '⚡ prjct',
325
300
  }
326
301
  }
327
302
 
@@ -75,10 +75,16 @@ export interface CreateIssueInput {
75
75
  }
76
76
 
77
77
  /**
78
- * Update input for enriching issues
78
+ * Update input for issues
79
79
  */
80
80
  export interface UpdateIssueInput {
81
+ title?: string
81
82
  description?: string
83
+ priority?: IssuePriority
84
+ assigneeId?: string | null // null to unassign
85
+ stateId?: string
86
+ projectId?: string
87
+ labels?: string[]
82
88
  // Provider-specific: may update custom fields for AC, etc.
83
89
  customFields?: Record<string, unknown>
84
90
  }
@@ -84,7 +84,8 @@ export class LinearProvider implements IssueTrackerProvider {
84
84
  // Verify connection
85
85
  try {
86
86
  const viewer = await this.sdk.viewer
87
- console.log(`[linear] Connected as ${viewer.name} (${viewer.email})`)
87
+ // Use stderr for logs to not break JSON output
88
+ console.error(`[linear] Connected as ${viewer.name} (${viewer.email})`)
88
89
  } catch (error) {
89
90
  this.sdk = null
90
91
  throw new Error(`Linear connection failed: ${(error as Error).message}`)
@@ -93,16 +94,28 @@ export class LinearProvider implements IssueTrackerProvider {
93
94
 
94
95
  /**
95
96
  * Get issues assigned to current user
97
+ * Filters by configured team if defaultTeamId is set
96
98
  */
97
99
  async fetchAssignedIssues(options?: FetchOptions): Promise<Issue[]> {
98
100
  if (!this.sdk) throw new Error('Linear not initialized')
99
101
 
100
102
  const viewer = await this.sdk.viewer
103
+
104
+ // Build filter - always filter by team if configured
105
+ const filter: Record<string, unknown> = {}
106
+
107
+ if (!options?.includeCompleted) {
108
+ filter.state = { type: { nin: ['completed', 'canceled'] } }
109
+ }
110
+
111
+ // Filter by configured team to only show relevant issues
112
+ if (this.config?.defaultTeamId) {
113
+ filter.team = { id: { eq: this.config.defaultTeamId } }
114
+ }
115
+
101
116
  const assignedIssues = await viewer.assignedIssues({
102
117
  first: options?.limit || 50,
103
- filter: options?.includeCompleted
104
- ? undefined
105
- : { state: { type: { nin: ['completed', 'canceled'] } } },
118
+ filter: Object.keys(filter).length > 0 ? filter : undefined,
106
119
  })
107
120
 
108
121
  return Promise.all(
@@ -128,28 +141,34 @@ export class LinearProvider implements IssueTrackerProvider {
128
141
  }
129
142
 
130
143
  /**
131
- * Get a single issue by ID or identifier (e.g., "ENG-123")
144
+ * Get a single issue by ID or identifier (e.g., "PRJ-123")
132
145
  */
133
146
  async fetchIssue(id: string): Promise<Issue | null> {
134
147
  if (!this.sdk) throw new Error('Linear not initialized')
135
148
 
136
149
  try {
137
- // Check if it looks like an identifier (e.g., "ENG-123")
150
+ // Check if it looks like an identifier (e.g., "PRJ-123")
138
151
  if (id.includes('-') && /^[A-Z]+-\d+$/.test(id)) {
139
- // Use raw GraphQL to search by identifier
140
- const result = await this.sdk.client.rawRequest(`
141
- query SearchByIdentifier($query: String!) {
142
- searchIssues(query: $query, first: 1) {
143
- nodes {
144
- id
145
- }
146
- }
147
- }
148
- `, { query: id }) as { data?: { searchIssues?: { nodes?: Array<{ id: string }> } } }
149
-
150
- if (result.data?.searchIssues?.nodes?.length) {
151
- const issue = await this.sdk.issue(result.data.searchIssues.nodes[0].id)
152
- return this.mapIssue(issue)
152
+ // Parse identifier into team key and issue number
153
+ const match = id.match(/^([A-Z]+)-(\d+)$/)
154
+ if (!match) return null
155
+
156
+ const [, teamKey, numberStr] = match
157
+ const issueNumber = parseInt(numberStr, 10)
158
+
159
+ // Find team by key
160
+ const teams = await this.sdk.teams({ first: 50 })
161
+ const team = teams.nodes.find((t) => t.key === teamKey)
162
+ if (!team) return null
163
+
164
+ // Query issue by team and number
165
+ const issues = await team.issues({
166
+ first: 1,
167
+ filter: { number: { eq: issueNumber } },
168
+ })
169
+
170
+ if (issues.nodes.length > 0) {
171
+ return this.mapIssue(issues.nodes[0])
153
172
  }
154
173
  return null
155
174
  }
@@ -197,15 +216,28 @@ export class LinearProvider implements IssueTrackerProvider {
197
216
  async updateIssue(id: string, input: UpdateIssueInput): Promise<Issue> {
198
217
  if (!this.sdk) throw new Error('Linear not initialized')
199
218
 
200
- // Get the issue first to get UUID
219
+ // Get the issue first to get UUID (if identifier like PRJ-123 was passed)
201
220
  const issue = await this.fetchIssue(id)
202
221
  if (!issue) {
203
222
  throw new Error(`Issue ${id} not found`)
204
223
  }
205
224
 
206
- await this.sdk.updateIssue(issue.id, {
207
- description: input.description,
208
- })
225
+ // Build update payload with all supported fields
226
+ const updatePayload: Record<string, unknown> = {}
227
+
228
+ if (input.title !== undefined) updatePayload.title = input.title
229
+ if (input.description !== undefined) updatePayload.description = input.description
230
+ if (input.priority !== undefined) updatePayload.priority = PRIORITY_TO_LINEAR[input.priority]
231
+ if (input.assigneeId !== undefined) updatePayload.assigneeId = input.assigneeId
232
+ if (input.stateId !== undefined) updatePayload.stateId = input.stateId
233
+ if (input.projectId !== undefined) updatePayload.projectId = input.projectId
234
+
235
+ // Handle labels - need to resolve names to IDs
236
+ if (input.labels !== undefined && issue.team) {
237
+ updatePayload.labelIds = await this.resolveLabelIds(issue.team.id, input.labels)
238
+ }
239
+
240
+ await this.sdk.updateIssue(issue.id, updatePayload)
209
241
 
210
242
  // Fetch updated issue
211
243
  const updated = await this.fetchIssue(issue.id)
@@ -9,6 +9,9 @@ export { LinearProvider, linearProvider } from './client'
9
9
  // Service layer with caching (preferred API)
10
10
  export { LinearService, linearService } from './service'
11
11
 
12
+ // Sync layer for bidirectional sync with issues.json
13
+ export { LinearSync, linearSync } from './sync'
14
+
12
15
  // Cache utilities
13
16
  export {
14
17
  issueCache,
@@ -0,0 +1,313 @@
1
+ /**
2
+ * Linear Sync Layer
3
+ *
4
+ * Bidirectional sync between Linear and local prjct storage.
5
+ * Uses issues.json as local cache with 30-minute staleness.
6
+ *
7
+ * Architecture:
8
+ * Linear (source of truth)
9
+ * ↕
10
+ * Sync Layer (this file)
11
+ * ↕
12
+ * storage/issues.json ← FULL COPY of Linear issues
13
+ * ↕
14
+ * state.json.currentTask.linearId ← DIRECT LINK
15
+ */
16
+
17
+ import { readFile, writeFile, mkdir } from 'fs/promises'
18
+ import { existsSync } from 'fs'
19
+ import { join } from 'path'
20
+ import { linearService } from './service'
21
+ import { getProjectPath } from '../../schemas/schemas'
22
+ import {
23
+ type IssuesJson,
24
+ type CachedIssue,
25
+ type SyncResult,
26
+ createEmptyIssues,
27
+ parseIssues,
28
+ } from '../../schemas/issues'
29
+ import type { Issue } from '../issue-tracker/types'
30
+
31
+ // Default staleness threshold: 30 minutes
32
+ const DEFAULT_STALE_AFTER = 30 * 60 * 1000
33
+
34
+ export class LinearSync {
35
+ /**
36
+ * Pull all assigned issues from Linear and store in issues.json
37
+ * This is the main sync operation - call on `p. sync`
38
+ */
39
+ async pullAll(projectId: string): Promise<SyncResult> {
40
+ const storagePath = join(getProjectPath(projectId), 'storage')
41
+ const issuesPath = join(storagePath, 'issues.json')
42
+
43
+ // Ensure storage directory exists
44
+ if (!existsSync(storagePath)) {
45
+ await mkdir(storagePath, { recursive: true })
46
+ }
47
+
48
+ const timestamp = new Date().toISOString()
49
+ const errors: Array<{ issueId: string; error: string }> = []
50
+
51
+ try {
52
+ // Fetch all assigned issues from Linear
53
+ const issues = await linearService.fetchAssignedIssues({ limit: 100 })
54
+
55
+ // Convert to cached format
56
+ const issuesMap: Record<string, CachedIssue> = {}
57
+ for (const issue of issues) {
58
+ try {
59
+ issuesMap[issue.externalId] = this.toCachedIssue(issue, timestamp)
60
+ } catch (err) {
61
+ errors.push({
62
+ issueId: issue.externalId || issue.id,
63
+ error: (err as Error).message,
64
+ })
65
+ }
66
+ }
67
+
68
+ // Write to issues.json
69
+ const issuesJson: IssuesJson = {
70
+ provider: 'linear',
71
+ lastSync: timestamp,
72
+ staleAfter: DEFAULT_STALE_AFTER,
73
+ issues: issuesMap,
74
+ }
75
+
76
+ await writeFile(issuesPath, JSON.stringify(issuesJson, null, 2))
77
+
78
+ return {
79
+ provider: 'linear',
80
+ fetched: issues.length,
81
+ updated: Object.keys(issuesMap).length,
82
+ errors,
83
+ timestamp,
84
+ }
85
+ } catch (err) {
86
+ errors.push({
87
+ issueId: 'all',
88
+ error: (err as Error).message,
89
+ })
90
+ return {
91
+ provider: 'linear',
92
+ fetched: 0,
93
+ updated: 0,
94
+ errors,
95
+ timestamp,
96
+ }
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Get issue from local cache, fetch from API if not found or stale
102
+ * Local-first approach for performance
103
+ */
104
+ async getIssue(projectId: string, identifier: string): Promise<CachedIssue | null> {
105
+ const issuesJson = await this.loadIssues(projectId)
106
+
107
+ // Check local cache first
108
+ if (issuesJson && issuesJson.issues[identifier]) {
109
+ const cachedIssue = issuesJson.issues[identifier]
110
+
111
+ // Check if cached issue is still fresh (within fetchedAt + some grace period)
112
+ const fetchedAt = new Date(cachedIssue.fetchedAt).getTime()
113
+ const now = Date.now()
114
+ const issueStaleness = 10 * 60 * 1000 // 10 minutes for individual issues
115
+
116
+ if (now - fetchedAt < issueStaleness) {
117
+ return cachedIssue
118
+ }
119
+ }
120
+
121
+ // Not in cache or stale - fetch from API and update cache
122
+ try {
123
+ const issue = await linearService.fetchIssue(identifier)
124
+ if (!issue) return null
125
+
126
+ const timestamp = new Date().toISOString()
127
+ const cachedIssue = this.toCachedIssue(issue, timestamp)
128
+
129
+ // Update cache with this single issue
130
+ await this.updateIssueInCache(projectId, identifier, cachedIssue)
131
+
132
+ return cachedIssue
133
+ } catch {
134
+ // API failed, return cached version if available (even if stale)
135
+ if (issuesJson?.issues[identifier]) {
136
+ return issuesJson.issues[identifier]
137
+ }
138
+ return null
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Get issue from local cache ONLY (no API call)
144
+ * Use for fast lookups when you know the issue should be cached
145
+ */
146
+ async getIssueLocal(projectId: string, identifier: string): Promise<CachedIssue | null> {
147
+ const issuesJson = await this.loadIssues(projectId)
148
+ return issuesJson?.issues[identifier] || null
149
+ }
150
+
151
+ /**
152
+ * Push local status change to Linear
153
+ * Called when task status changes (in_progress, done)
154
+ */
155
+ async pushStatus(
156
+ projectId: string,
157
+ identifier: string,
158
+ status: 'in_progress' | 'done'
159
+ ): Promise<void> {
160
+ // Update Linear
161
+ if (status === 'in_progress') {
162
+ await linearService.markInProgress(identifier)
163
+ } else if (status === 'done') {
164
+ await linearService.markDone(identifier)
165
+ }
166
+
167
+ // Update local cache to reflect the change
168
+ const issuesJson = await this.loadIssues(projectId)
169
+ if (issuesJson?.issues[identifier]) {
170
+ const cachedStatus = status === 'done' ? 'done' : 'in_progress'
171
+ issuesJson.issues[identifier].status = cachedStatus
172
+ issuesJson.issues[identifier].fetchedAt = new Date().toISOString()
173
+
174
+ await this.saveIssues(projectId, issuesJson)
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Check if the local issues cache is stale
180
+ * Staleness = lastSync is older than staleAfter threshold
181
+ */
182
+ async isStale(projectId: string): Promise<boolean> {
183
+ const issuesJson = await this.loadIssues(projectId)
184
+
185
+ if (!issuesJson || !issuesJson.lastSync) {
186
+ return true // No cache = stale
187
+ }
188
+
189
+ const lastSyncTime = new Date(issuesJson.lastSync).getTime()
190
+ const now = Date.now()
191
+ const staleAfter = issuesJson.staleAfter || DEFAULT_STALE_AFTER
192
+
193
+ return now - lastSyncTime > staleAfter
194
+ }
195
+
196
+ /**
197
+ * Get sync status for display
198
+ */
199
+ async getSyncStatus(projectId: string): Promise<{
200
+ hasCache: boolean
201
+ lastSync: string | null
202
+ issueCount: number
203
+ isStale: boolean
204
+ }> {
205
+ const issuesJson = await this.loadIssues(projectId)
206
+
207
+ if (!issuesJson) {
208
+ return {
209
+ hasCache: false,
210
+ lastSync: null,
211
+ issueCount: 0,
212
+ isStale: true,
213
+ }
214
+ }
215
+
216
+ return {
217
+ hasCache: true,
218
+ lastSync: issuesJson.lastSync || null,
219
+ issueCount: Object.keys(issuesJson.issues).length,
220
+ isStale: await this.isStale(projectId),
221
+ }
222
+ }
223
+
224
+ /**
225
+ * List all cached issues
226
+ */
227
+ async listCachedIssues(projectId: string): Promise<CachedIssue[]> {
228
+ const issuesJson = await this.loadIssues(projectId)
229
+ if (!issuesJson) return []
230
+
231
+ return Object.values(issuesJson.issues)
232
+ }
233
+
234
+ // =============================================================================
235
+ // Private Helpers
236
+ // =============================================================================
237
+
238
+ /**
239
+ * Load issues.json from disk
240
+ */
241
+ private async loadIssues(projectId: string): Promise<IssuesJson | null> {
242
+ const issuesPath = join(getProjectPath(projectId), 'storage', 'issues.json')
243
+
244
+ if (!existsSync(issuesPath)) {
245
+ return null
246
+ }
247
+
248
+ try {
249
+ const content = await readFile(issuesPath, 'utf-8')
250
+ return parseIssues(JSON.parse(content))
251
+ } catch {
252
+ return null
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Save issues.json to disk
258
+ */
259
+ private async saveIssues(projectId: string, issuesJson: IssuesJson): Promise<void> {
260
+ const storagePath = join(getProjectPath(projectId), 'storage')
261
+ const issuesPath = join(storagePath, 'issues.json')
262
+
263
+ if (!existsSync(storagePath)) {
264
+ await mkdir(storagePath, { recursive: true })
265
+ }
266
+
267
+ await writeFile(issuesPath, JSON.stringify(issuesJson, null, 2))
268
+ }
269
+
270
+ /**
271
+ * Update a single issue in the cache
272
+ */
273
+ private async updateIssueInCache(
274
+ projectId: string,
275
+ identifier: string,
276
+ issue: CachedIssue
277
+ ): Promise<void> {
278
+ let issuesJson = await this.loadIssues(projectId)
279
+
280
+ if (!issuesJson) {
281
+ issuesJson = createEmptyIssues('linear')
282
+ }
283
+
284
+ issuesJson.issues[identifier] = issue
285
+ await this.saveIssues(projectId, issuesJson)
286
+ }
287
+
288
+ /**
289
+ * Convert API Issue to CachedIssue format
290
+ */
291
+ private toCachedIssue(issue: Issue, timestamp: string): CachedIssue {
292
+ return {
293
+ id: issue.id,
294
+ identifier: issue.externalId,
295
+ title: issue.title,
296
+ description: issue.description,
297
+ status: issue.status,
298
+ priority: issue.priority,
299
+ type: issue.type,
300
+ assignee: issue.assignee,
301
+ labels: issue.labels,
302
+ team: issue.team,
303
+ project: issue.project,
304
+ url: issue.url,
305
+ createdAt: issue.createdAt,
306
+ updatedAt: issue.updatedAt,
307
+ fetchedAt: timestamp,
308
+ }
309
+ }
310
+ }
311
+
312
+ // Singleton instance
313
+ export const linearSync = new LinearSync()
@@ -15,6 +15,9 @@
15
15
  // State (current task + queue)
16
16
  export * from './state'
17
17
 
18
+ // Issues (local cache of issue tracker issues)
19
+ export * from './issues'
20
+
18
21
  // Project metadata
19
22
  export * from './project'
20
23
 
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Issues Schema
3
+ *
4
+ * Defines the structure for issues.json - local cache of issue tracker issues.
5
+ * Used for bidirectional sync with Linear/JIRA/etc.
6
+ *
7
+ * Location: ~/.prjct-cli/projects/{projectId}/storage/issues.json
8
+ */
9
+
10
+ import { z } from 'zod'
11
+
12
+ // =============================================================================
13
+ // Issue Provider Types
14
+ // =============================================================================
15
+
16
+ export const IssueProviderSchema = z.enum(['linear', 'jira', 'github', 'monday', 'asana', 'none'])
17
+ export const IssueStatusSchema = z.enum(['backlog', 'todo', 'in_progress', 'in_review', 'done', 'cancelled'])
18
+ export const IssuePrioritySchema = z.enum(['none', 'urgent', 'high', 'medium', 'low'])
19
+ export const IssueTypeSchema = z.enum(['feature', 'bug', 'improvement', 'task', 'chore', 'epic'])
20
+
21
+ // =============================================================================
22
+ // Cached Issue Schema
23
+ // =============================================================================
24
+
25
+ /**
26
+ * Single cached issue from provider
27
+ */
28
+ export const CachedIssueSchema = z.object({
29
+ // Core identifiers
30
+ id: z.string(), // Provider UUID
31
+ identifier: z.string(), // Human-readable ID (e.g., "PRJ-123")
32
+
33
+ // Issue content
34
+ title: z.string(),
35
+ description: z.string().optional(),
36
+
37
+ // State
38
+ status: IssueStatusSchema,
39
+ priority: IssuePrioritySchema,
40
+ type: IssueTypeSchema.optional(),
41
+
42
+ // Metadata
43
+ assignee: z.object({
44
+ id: z.string(),
45
+ name: z.string(),
46
+ email: z.string().optional(),
47
+ }).optional(),
48
+ labels: z.array(z.string()).default([]),
49
+ team: z.object({
50
+ id: z.string(),
51
+ name: z.string(),
52
+ key: z.string().optional(),
53
+ }).optional(),
54
+ project: z.object({
55
+ id: z.string(),
56
+ name: z.string(),
57
+ }).optional(),
58
+
59
+ // URLs and timestamps
60
+ url: z.string(),
61
+ createdAt: z.string(), // ISO8601 from provider
62
+ updatedAt: z.string(), // ISO8601 from provider
63
+ fetchedAt: z.string(), // ISO8601 when we cached it
64
+ })
65
+
66
+ // =============================================================================
67
+ // Issues JSON Schema (Root)
68
+ // =============================================================================
69
+
70
+ /**
71
+ * Root schema for issues.json
72
+ * Maps identifier -> CachedIssue for quick lookup
73
+ */
74
+ export const IssuesJsonSchema = z.object({
75
+ // Provider info
76
+ provider: IssueProviderSchema,
77
+
78
+ // Sync metadata
79
+ lastSync: z.string(), // ISO8601 of last full sync
80
+ staleAfter: z.number().default(1800000), // 30 minutes in ms
81
+
82
+ // Issues map: identifier -> issue
83
+ issues: z.record(z.string(), CachedIssueSchema),
84
+ })
85
+
86
+ // =============================================================================
87
+ // Sync Result Schema
88
+ // =============================================================================
89
+
90
+ export const SyncResultSchema = z.object({
91
+ provider: IssueProviderSchema,
92
+ fetched: z.number(),
93
+ updated: z.number(),
94
+ errors: z.array(z.object({
95
+ issueId: z.string(),
96
+ error: z.string(),
97
+ })),
98
+ timestamp: z.string(),
99
+ })
100
+
101
+ // =============================================================================
102
+ // Inferred Types
103
+ // =============================================================================
104
+
105
+ export type IssueProvider = z.infer<typeof IssueProviderSchema>
106
+ export type IssueStatus = z.infer<typeof IssueStatusSchema>
107
+ export type IssuePriority = z.infer<typeof IssuePrioritySchema>
108
+ export type IssueType = z.infer<typeof IssueTypeSchema>
109
+ export type CachedIssue = z.infer<typeof CachedIssueSchema>
110
+ export type IssuesJson = z.infer<typeof IssuesJsonSchema>
111
+ export type SyncResult = z.infer<typeof SyncResultSchema>
112
+
113
+ // =============================================================================
114
+ // Validation Helpers
115
+ // =============================================================================
116
+
117
+ /** Parse and validate issues.json content */
118
+ export const parseIssues = (data: unknown): IssuesJson => IssuesJsonSchema.parse(data)
119
+
120
+ /** Safe parse with error result */
121
+ export const safeParseIssues = (data: unknown) => IssuesJsonSchema.safeParse(data)
122
+
123
+ // =============================================================================
124
+ // Defaults
125
+ // =============================================================================
126
+
127
+ export const DEFAULT_ISSUES: IssuesJson = {
128
+ provider: 'none',
129
+ lastSync: '',
130
+ staleAfter: 1800000, // 30 minutes
131
+ issues: {},
132
+ }
133
+
134
+ /**
135
+ * Create empty issues.json for a provider
136
+ */
137
+ export function createEmptyIssues(provider: IssueProvider): IssuesJson {
138
+ return {
139
+ provider,
140
+ lastSync: '',
141
+ staleAfter: 1800000,
142
+ issues: {},
143
+ }
144
+ }
@@ -64,6 +64,9 @@ export const CurrentTaskSchema = z.object({
64
64
  subtasks: z.array(SubtaskSchema).optional(),
65
65
  currentSubtaskIndex: z.number().optional(),
66
66
  subtaskProgress: SubtaskProgressSchema.optional(),
67
+ // Linear integration - bidirectional sync
68
+ linearId: z.string().optional(), // "PRJ-123" - Linear identifier
69
+ linearUuid: z.string().optional(), // Linear internal UUID for API calls
67
70
  })
68
71
 
69
72
  export const PreviousTaskSchema = z.object({