prjct-cli 0.10.13 → 0.11.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 (44) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/CLAUDE.md +47 -2
  3. package/bin/dev.js +217 -0
  4. package/bin/prjct +10 -0
  5. package/bin/serve.js +78 -0
  6. package/core/agentic/command-executor.js +38 -112
  7. package/core/agentic/prompt-builder.js +72 -0
  8. package/core/bus/index.js +322 -0
  9. package/core/command-registry.js +65 -0
  10. package/core/domain/snapshot-manager.js +375 -0
  11. package/core/plugin/hooks.js +313 -0
  12. package/core/plugin/index.js +52 -0
  13. package/core/plugin/loader.js +331 -0
  14. package/core/plugin/registry.js +325 -0
  15. package/core/plugins/webhook.js +143 -0
  16. package/core/session/index.js +449 -0
  17. package/core/session/metrics.js +293 -0
  18. package/package.json +18 -4
  19. package/templates/agentic/agent-routing.md +42 -9
  20. package/templates/agentic/checklist-routing.md +98 -0
  21. package/templates/checklists/accessibility.md +33 -0
  22. package/templates/checklists/architecture.md +28 -0
  23. package/templates/checklists/code-quality.md +28 -0
  24. package/templates/checklists/data.md +33 -0
  25. package/templates/checklists/documentation.md +33 -0
  26. package/templates/checklists/infrastructure.md +33 -0
  27. package/templates/checklists/performance.md +33 -0
  28. package/templates/checklists/security.md +33 -0
  29. package/templates/checklists/testing.md +33 -0
  30. package/templates/checklists/ux-ui.md +37 -0
  31. package/templates/commands/bug.md +27 -1
  32. package/templates/commands/done.md +176 -54
  33. package/templates/commands/feature.md +38 -1
  34. package/templates/commands/history.md +176 -0
  35. package/templates/commands/init.md +28 -1
  36. package/templates/commands/now.md +191 -9
  37. package/templates/commands/pause.md +176 -12
  38. package/templates/commands/redo.md +142 -0
  39. package/templates/commands/resume.md +166 -62
  40. package/templates/commands/serve.md +121 -0
  41. package/templates/commands/ship.md +45 -1
  42. package/templates/commands/sync.md +34 -1
  43. package/templates/commands/task.md +27 -1
  44. package/templates/commands/undo.md +152 -0
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Webhook Plugin for prjct-cli
3
+ *
4
+ * Sends HTTP POST requests to configured webhooks on events.
5
+ * Useful for integrating with Slack, Discord, Zapier, etc.
6
+ *
7
+ * @version 1.0.0
8
+ *
9
+ * Configuration in prjct.config.json:
10
+ * {
11
+ * "plugins": ["webhook"],
12
+ * "webhook": {
13
+ * "url": "https://hooks.example.com/...",
14
+ * "events": ["session.completed", "feature.shipped"],
15
+ * "secret": "optional-signing-secret"
16
+ * }
17
+ * }
18
+ */
19
+
20
+ const { EventTypes } = require('../bus')
21
+ const { HookPoints } = require('../plugin/hooks')
22
+
23
+ const plugin = {
24
+ name: 'webhook',
25
+ version: '1.0.0',
26
+ description: 'Send HTTP webhooks on events',
27
+
28
+ // Plugin state
29
+ config: null,
30
+ enabled: false,
31
+
32
+ /**
33
+ * Activate plugin
34
+ */
35
+ async activate({ config }) {
36
+ this.config = config
37
+
38
+ if (!config.url) {
39
+ console.warn('[webhook] No URL configured, plugin disabled')
40
+ return
41
+ }
42
+
43
+ this.enabled = true
44
+ this.events = config.events || [
45
+ EventTypes.SESSION_COMPLETED,
46
+ EventTypes.FEATURE_SHIPPED,
47
+ EventTypes.SNAPSHOT_CREATED
48
+ ]
49
+ },
50
+
51
+ /**
52
+ * Deactivate plugin
53
+ */
54
+ async deactivate() {
55
+ this.enabled = false
56
+ },
57
+
58
+ /**
59
+ * Event handlers
60
+ */
61
+ events: {
62
+ [EventTypes.SESSION_COMPLETED]: async function(data) {
63
+ await plugin.sendWebhook('session.completed', data)
64
+ },
65
+
66
+ [EventTypes.FEATURE_SHIPPED]: async function(data) {
67
+ await plugin.sendWebhook('feature.shipped', data)
68
+ },
69
+
70
+ [EventTypes.SNAPSHOT_CREATED]: async function(data) {
71
+ await plugin.sendWebhook('snapshot.created', data)
72
+ },
73
+
74
+ [EventTypes.TASK_COMPLETED]: async function(data) {
75
+ await plugin.sendWebhook('task.completed', data)
76
+ }
77
+ },
78
+
79
+ /**
80
+ * Hook handlers
81
+ */
82
+ hooks: {
83
+ [HookPoints.AFTER_FEATURE_SHIP]: async function(data) {
84
+ await plugin.sendWebhook('feature.shipped', {
85
+ feature: data.feature,
86
+ version: data.version,
87
+ timestamp: data.timestamp
88
+ })
89
+ }
90
+ },
91
+
92
+ /**
93
+ * Send webhook request
94
+ * @param {string} event - Event type
95
+ * @param {Object} data - Event data
96
+ */
97
+ async sendWebhook(event, data) {
98
+ if (!this.enabled || !this.config.url) return
99
+
100
+ // Check if this event should be sent
101
+ if (this.config.events && !this.config.events.includes(event)) {
102
+ return
103
+ }
104
+
105
+ const payload = {
106
+ event,
107
+ timestamp: new Date().toISOString(),
108
+ source: 'prjct-cli',
109
+ data
110
+ }
111
+
112
+ try {
113
+ const headers = {
114
+ 'Content-Type': 'application/json',
115
+ 'User-Agent': 'prjct-cli/webhook'
116
+ }
117
+
118
+ // Add signature if secret is configured
119
+ if (this.config.secret) {
120
+ const crypto = require('crypto')
121
+ const signature = crypto
122
+ .createHmac('sha256', this.config.secret)
123
+ .update(JSON.stringify(payload))
124
+ .digest('hex')
125
+ headers['X-Prjct-Signature'] = `sha256=${signature}`
126
+ }
127
+
128
+ const response = await fetch(this.config.url, {
129
+ method: 'POST',
130
+ headers,
131
+ body: JSON.stringify(payload)
132
+ })
133
+
134
+ if (!response.ok) {
135
+ console.error(`[webhook] Request failed: ${response.status}`)
136
+ }
137
+ } catch (error) {
138
+ console.error(`[webhook] Error sending webhook:`, error.message)
139
+ }
140
+ }
141
+ }
142
+
143
+ module.exports = plugin
@@ -0,0 +1,449 @@
1
+ /**
2
+ * SessionManager - Structured Session Tracking
3
+ *
4
+ * Tracks work sessions with metrics, timeline, and duration.
5
+ * Inspired by OpenCode's session system but simplified.
6
+ *
7
+ * Storage: ~/.prjct-cli/projects/{projectId}/sessions/
8
+ *
9
+ * @version 1.0.0
10
+ */
11
+
12
+ const fs = require('fs').promises
13
+ const path = require('path')
14
+ const pathManager = require('../infrastructure/path-manager')
15
+ const configManager = require('../infrastructure/config-manager')
16
+ const { eventBus, emit } = require('../bus')
17
+
18
+ /**
19
+ * Session Schema
20
+ * @typedef {Object} Session
21
+ * @property {string} id - Unique session ID (sess_xxxx)
22
+ * @property {string} projectId - Project identifier
23
+ * @property {string} task - Task description
24
+ * @property {'active'|'paused'|'completed'} status - Current status
25
+ * @property {string} startedAt - ISO timestamp when started
26
+ * @property {string} [pausedAt] - ISO timestamp when paused
27
+ * @property {string} [completedAt] - ISO timestamp when completed
28
+ * @property {number} duration - Total duration in seconds
29
+ * @property {Object} metrics - Automatic metrics
30
+ * @property {Array} timeline - Event history
31
+ */
32
+
33
+ class SessionManager {
34
+ constructor(projectPath) {
35
+ this.projectPath = projectPath
36
+ this.projectId = null
37
+ this.sessionDir = null
38
+ this.initialized = false
39
+ }
40
+
41
+ /**
42
+ * Initialize session manager for project
43
+ */
44
+ async initialize() {
45
+ this.projectId = await configManager.getProjectId(this.projectPath)
46
+ if (!this.projectId) {
47
+ throw new Error('No prjct project found. Run /p:init first.')
48
+ }
49
+
50
+ const globalPath = pathManager.getGlobalProjectPath(this.projectId)
51
+ this.sessionDir = path.join(globalPath, 'sessions')
52
+
53
+ await fs.mkdir(this.sessionDir, { recursive: true })
54
+ this.initialized = true
55
+ }
56
+
57
+ /**
58
+ * Generate unique session ID
59
+ */
60
+ generateId() {
61
+ const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'
62
+ let id = 'sess_'
63
+ for (let i = 0; i < 8; i++) {
64
+ id += chars.charAt(Math.floor(Math.random() * chars.length))
65
+ }
66
+ return id
67
+ }
68
+
69
+ /**
70
+ * Get current active session
71
+ * @returns {Promise<Session|null>}
72
+ */
73
+ async getCurrent() {
74
+ if (!this.initialized) await this.initialize()
75
+
76
+ const currentPath = path.join(this.sessionDir, 'current.json')
77
+ try {
78
+ const content = await fs.readFile(currentPath, 'utf-8')
79
+ return JSON.parse(content)
80
+ } catch {
81
+ return null
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Create a new session
87
+ * @param {string} task - Task description
88
+ * @returns {Promise<Session>}
89
+ */
90
+ async create(task) {
91
+ if (!this.initialized) await this.initialize()
92
+
93
+ // Check if there's already an active session
94
+ const current = await this.getCurrent()
95
+ if (current && current.status === 'active') {
96
+ throw new Error(`Session already active: "${current.task}". Use /p:done or /p:pause first.`)
97
+ }
98
+
99
+ const now = new Date().toISOString()
100
+ const session = {
101
+ id: this.generateId(),
102
+ projectId: this.projectId,
103
+ task,
104
+ status: 'active',
105
+ startedAt: now,
106
+ pausedAt: null,
107
+ completedAt: null,
108
+ duration: 0,
109
+ metrics: {
110
+ filesChanged: 0,
111
+ linesAdded: 0,
112
+ linesRemoved: 0,
113
+ commits: 0,
114
+ snapshots: []
115
+ },
116
+ timeline: [
117
+ { type: 'start', at: now }
118
+ ]
119
+ }
120
+
121
+ // Save as current session
122
+ await this.saveCurrent(session)
123
+
124
+ // Log to session history
125
+ await this.logEvent('session_started', { sessionId: session.id, task })
126
+
127
+ // Emit event for plugins
128
+ await emit.sessionStarted({
129
+ sessionId: session.id,
130
+ task,
131
+ projectId: this.projectId
132
+ })
133
+
134
+ return session
135
+ }
136
+
137
+ /**
138
+ * Resume a paused session or continue active session
139
+ * @param {string} [task] - Optional new task (creates new session if provided)
140
+ * @returns {Promise<Session>}
141
+ */
142
+ async resume(task = null) {
143
+ if (!this.initialized) await this.initialize()
144
+
145
+ const current = await this.getCurrent()
146
+
147
+ // If task provided and different from current, create new session
148
+ if (task && (!current || current.task !== task)) {
149
+ return this.create(task)
150
+ }
151
+
152
+ // If no current session, need a task
153
+ if (!current) {
154
+ if (!task) {
155
+ throw new Error('No active session. Provide a task to start one.')
156
+ }
157
+ return this.create(task)
158
+ }
159
+
160
+ // If already active, just return it
161
+ if (current.status === 'active') {
162
+ return current
163
+ }
164
+
165
+ // Resume paused session
166
+ const now = new Date().toISOString()
167
+ current.status = 'active'
168
+ current.timeline.push({ type: 'resume', at: now })
169
+
170
+ await this.saveCurrent(current)
171
+ await this.logEvent('session_resumed', { sessionId: current.id })
172
+
173
+ // Emit event for plugins
174
+ await emit.sessionResumed({
175
+ sessionId: current.id,
176
+ task: current.task,
177
+ projectId: this.projectId
178
+ })
179
+
180
+ return current
181
+ }
182
+
183
+ /**
184
+ * Pause current session
185
+ * @returns {Promise<Session>}
186
+ */
187
+ async pause() {
188
+ if (!this.initialized) await this.initialize()
189
+
190
+ const current = await this.getCurrent()
191
+ if (!current) {
192
+ throw new Error('No active session to pause.')
193
+ }
194
+
195
+ if (current.status === 'paused') {
196
+ return current // Already paused
197
+ }
198
+
199
+ const now = new Date().toISOString()
200
+ current.status = 'paused'
201
+ current.pausedAt = now
202
+ current.duration = this.calculateDuration(current)
203
+ current.timeline.push({ type: 'pause', at: now })
204
+
205
+ await this.saveCurrent(current)
206
+ await this.logEvent('session_paused', { sessionId: current.id, duration: current.duration })
207
+
208
+ // Emit event for plugins
209
+ await emit.sessionPaused({
210
+ sessionId: current.id,
211
+ task: current.task,
212
+ duration: current.duration,
213
+ projectId: this.projectId
214
+ })
215
+
216
+ return current
217
+ }
218
+
219
+ /**
220
+ * Complete current session
221
+ * @returns {Promise<Session>}
222
+ */
223
+ async complete() {
224
+ if (!this.initialized) await this.initialize()
225
+
226
+ const current = await this.getCurrent()
227
+ if (!current) {
228
+ throw new Error('No active session to complete.')
229
+ }
230
+
231
+ const now = new Date().toISOString()
232
+ current.status = 'completed'
233
+ current.completedAt = now
234
+ current.duration = this.calculateDuration(current)
235
+ current.metrics = await this.calculateMetrics(current)
236
+ current.timeline.push({ type: 'complete', at: now })
237
+
238
+ // Archive session
239
+ await this.archive(current)
240
+
241
+ // Clear current
242
+ await this.clearCurrent()
243
+
244
+ // Log completion
245
+ await this.logEvent('session_completed', {
246
+ sessionId: current.id,
247
+ task: current.task,
248
+ duration: current.duration,
249
+ metrics: current.metrics
250
+ })
251
+
252
+ // Emit event for plugins
253
+ await emit.sessionCompleted({
254
+ sessionId: current.id,
255
+ task: current.task,
256
+ duration: current.duration,
257
+ metrics: current.metrics,
258
+ projectId: this.projectId
259
+ })
260
+
261
+ return current
262
+ }
263
+
264
+ /**
265
+ * Calculate total duration in seconds
266
+ * @param {Session} session
267
+ * @returns {number}
268
+ */
269
+ calculateDuration(session) {
270
+ let totalMs = 0
271
+ let lastStart = null
272
+
273
+ for (const event of session.timeline) {
274
+ if (event.type === 'start' || event.type === 'resume') {
275
+ lastStart = new Date(event.at)
276
+ } else if (event.type === 'pause' || event.type === 'complete') {
277
+ if (lastStart) {
278
+ totalMs += new Date(event.at) - lastStart
279
+ lastStart = null
280
+ }
281
+ }
282
+ }
283
+
284
+ // If still active, count from last start to now
285
+ if (lastStart && session.status === 'active') {
286
+ totalMs += Date.now() - lastStart
287
+ }
288
+
289
+ return Math.round(totalMs / 1000)
290
+ }
291
+
292
+ /**
293
+ * Calculate metrics for session
294
+ * @param {Session} session
295
+ * @returns {Promise<Object>}
296
+ */
297
+ async calculateMetrics(session) {
298
+ const metrics = { ...session.metrics }
299
+
300
+ try {
301
+ const { exec } = require('child_process')
302
+ const { promisify } = require('util')
303
+ const execAsync = promisify(exec)
304
+
305
+ // Get git stats since session start
306
+ const since = session.startedAt.split('T')[0]
307
+
308
+ // Count commits
309
+ const { stdout: commitCount } = await execAsync(
310
+ `git rev-list --count --since="${since}" HEAD 2>/dev/null || echo "0"`,
311
+ { cwd: this.projectPath }
312
+ )
313
+ metrics.commits = parseInt(commitCount.trim()) || 0
314
+
315
+ // Get diff stats
316
+ const { stdout: diffStat } = await execAsync(
317
+ `git diff --stat HEAD~${Math.max(metrics.commits, 1)} 2>/dev/null || echo ""`,
318
+ { cwd: this.projectPath }
319
+ )
320
+
321
+ // Parse diff stats
322
+ const lines = diffStat.split('\n')
323
+ const summaryLine = lines[lines.length - 2] || ''
324
+ const match = summaryLine.match(/(\d+) files? changed(?:, (\d+) insertions?)?(?:, (\d+) deletions?)?/)
325
+
326
+ if (match) {
327
+ metrics.filesChanged = parseInt(match[1]) || 0
328
+ metrics.linesAdded = parseInt(match[2]) || 0
329
+ metrics.linesRemoved = parseInt(match[3]) || 0
330
+ }
331
+ } catch {
332
+ // Keep existing metrics if git fails
333
+ }
334
+
335
+ return metrics
336
+ }
337
+
338
+ /**
339
+ * Save current session
340
+ * @param {Session} session
341
+ */
342
+ async saveCurrent(session) {
343
+ const currentPath = path.join(this.sessionDir, 'current.json')
344
+ await fs.writeFile(currentPath, JSON.stringify(session, null, 2))
345
+ }
346
+
347
+ /**
348
+ * Clear current session file
349
+ */
350
+ async clearCurrent() {
351
+ const currentPath = path.join(this.sessionDir, 'current.json')
352
+ try {
353
+ await fs.unlink(currentPath)
354
+ } catch {
355
+ // File might not exist
356
+ }
357
+ }
358
+
359
+ /**
360
+ * Archive completed session
361
+ * @param {Session} session
362
+ */
363
+ async archive(session) {
364
+ const date = new Date(session.completedAt)
365
+ const yearMonth = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
366
+ const archiveDir = path.join(this.sessionDir, 'archive', yearMonth)
367
+
368
+ await fs.mkdir(archiveDir, { recursive: true })
369
+
370
+ const archivePath = path.join(archiveDir, `${session.id}.json`)
371
+ await fs.writeFile(archivePath, JSON.stringify(session, null, 2))
372
+ }
373
+
374
+ /**
375
+ * Get session history
376
+ * @param {number} limit - Max sessions to return
377
+ * @returns {Promise<Session[]>}
378
+ */
379
+ async getHistory(limit = 10) {
380
+ if (!this.initialized) await this.initialize()
381
+
382
+ const sessions = []
383
+ const archiveDir = path.join(this.sessionDir, 'archive')
384
+
385
+ try {
386
+ const months = await fs.readdir(archiveDir)
387
+ const sortedMonths = months.sort().reverse()
388
+
389
+ for (const month of sortedMonths) {
390
+ if (sessions.length >= limit) break
391
+
392
+ const monthDir = path.join(archiveDir, month)
393
+ const files = await fs.readdir(monthDir)
394
+
395
+ for (const file of files.sort().reverse()) {
396
+ if (sessions.length >= limit) break
397
+ if (!file.endsWith('.json')) continue
398
+
399
+ const content = await fs.readFile(path.join(monthDir, file), 'utf-8')
400
+ sessions.push(JSON.parse(content))
401
+ }
402
+ }
403
+ } catch {
404
+ // Archive might not exist yet
405
+ }
406
+
407
+ return sessions
408
+ }
409
+
410
+ /**
411
+ * Log event to memory
412
+ * @param {string} action
413
+ * @param {Object} data
414
+ */
415
+ async logEvent(action, data) {
416
+ const globalPath = pathManager.getGlobalProjectPath(this.projectId)
417
+ const memoryPath = path.join(globalPath, 'memory', 'context.jsonl')
418
+
419
+ const entry = JSON.stringify({
420
+ timestamp: new Date().toISOString(),
421
+ action,
422
+ ...data
423
+ }) + '\n'
424
+
425
+ try {
426
+ await fs.appendFile(memoryPath, entry)
427
+ } catch {
428
+ // Memory file might not exist
429
+ }
430
+ }
431
+
432
+ /**
433
+ * Format duration as human readable
434
+ * @param {number} seconds
435
+ * @returns {string}
436
+ */
437
+ static formatDuration(seconds) {
438
+ if (seconds < 60) return `${seconds}s`
439
+ if (seconds < 3600) return `${Math.round(seconds / 60)}m`
440
+
441
+ const hours = Math.floor(seconds / 3600)
442
+ const minutes = Math.round((seconds % 3600) / 60)
443
+
444
+ if (minutes === 0) return `${hours}h`
445
+ return `${hours}h ${minutes}m`
446
+ }
447
+ }
448
+
449
+ module.exports = SessionManager