prjct-cli 1.2.2 → 1.3.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/CHANGELOG.md CHANGED
@@ -1,5 +1,57 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.3.0] - 2026-02-06
4
+
5
+ ### Features
6
+
7
+ - session state tracking for multi-command workflows (PRJ-109) (#114)
8
+
9
+
10
+ ## [1.3.0] - 2026-02-06
11
+
12
+ ### Features
13
+
14
+ - session state tracking for multi-command workflows (PRJ-109)
15
+
16
+ ### Implementation Details
17
+
18
+ New `SessionTracker` service (`core/services/session-tracker.ts`) that manages lightweight session lifecycle for tracking command sequences and file access across CLI invocations.
19
+
20
+ Key behaviors:
21
+ - **Auto-create**: Sessions start automatically on first CLI command
22
+ - **Auto-resume**: Subsequent commands within 30 min extend the existing session
23
+ - **Auto-expire**: Sessions expire after 30 minutes of idle time, cleaned up on next startup
24
+ - **Command tracking**: Records command name, timestamp, and duration (up to 50 commands)
25
+ - **File tracking**: Records file reads/writes with timestamps (up to 200 records)
26
+
27
+ Integration points:
28
+ - `core/index.ts` — touch/track in main CLI dispatch (all standard commands)
29
+ - `bin/prjct.ts` — `trackSession()` helper for context/hooks/doctor commands
30
+ - `core/commands/analysis.ts` — session info in `prjct status` output (JSON + human-readable)
31
+ - `core/services/staleness-checker.ts` — `getSessionInfo()` and `formatSessionInfo()` with box-drawing display
32
+
33
+ Storage: `~/.prjct-cli/projects/{projectId}/storage/session.json`
34
+
35
+ ### Learnings
36
+
37
+ - Non-critical tracking should always be wrapped in try/catch with silent fail — session tracking must never break CLI commands
38
+ - Touch-on-every-command pattern is simple but effective for session detection — no explicit start/stop needed
39
+ - Box-drawing characters (`┌─┐│└─┘`) provide clean structured output without external dependencies
40
+
41
+ ### Test Plan
42
+
43
+ #### For QA
44
+ 1. Run `prjct status` — verify "Session: ▶ Active" with duration, commands, idle timer
45
+ 2. Wait 30+ minutes, run `prjct status` — verify "Session: ○ No active session"
46
+ 3. Run multiple commands in sequence — verify command count increments
47
+ 4. Run `prjct status --json` — verify session object in JSON output
48
+ 5. Run `bun run build && bun run typecheck` — zero errors
49
+
50
+ #### For Users
51
+ **What changed:** CLI now tracks session state across commands for workflow visibility
52
+ **How to use:** Run `prjct status` to see active session info
53
+ **Breaking changes:** None
54
+
3
55
  ## [1.2.2] - 2026-02-06
4
56
 
5
57
  ### Performance
package/bin/prjct.ts CHANGED
@@ -64,6 +64,26 @@ if (isQuietMode) {
64
64
 
65
65
  // Colors for output (chalk respects NO_COLOR env)
66
66
 
67
+ // Session tracking for commands that bypass core/index.ts
68
+ async function trackSession(command: string): Promise<() => void> {
69
+ const start = Date.now()
70
+ try {
71
+ const projectId = await configManager.getProjectId(process.cwd())
72
+ if (projectId) {
73
+ const { sessionTracker } = await import('../core/services/session-tracker')
74
+ await sessionTracker.expireIfStale(projectId)
75
+ await sessionTracker.touch(projectId)
76
+ return () => {
77
+ const durationMs = Date.now() - start
78
+ sessionTracker.trackCommand(projectId, command, durationMs).catch(() => {})
79
+ }
80
+ }
81
+ } catch {
82
+ // Non-critical
83
+ }
84
+ return () => {}
85
+ }
86
+
67
87
  if (args[0] === 'start' || args[0] === 'setup') {
68
88
  // Interactive setup with beautiful UI
69
89
  const { runStart } = await import('../core/cli/start')
@@ -99,22 +119,28 @@ if (args[0] === 'start' || args[0] === 'setup') {
99
119
  console.error('No prjct project found. Run "prjct init" first.')
100
120
  process.exitCode = 1
101
121
  } else {
122
+ const done = await trackSession('context')
102
123
  const { runContextTool } = await import('../core/context-tools')
103
124
  const result = await runContextTool(args.slice(1), projectId, projectPath)
104
125
  console.log(JSON.stringify(result, null, 2))
105
126
  process.exitCode = result.tool === 'error' ? 1 : 0
127
+ done()
106
128
  }
107
129
  } else if (args[0] === 'hooks') {
108
130
  // Git hooks management
131
+ const done = await trackSession('hooks')
109
132
  const { hooksService } = await import('../core/services/hooks-service')
110
133
  const subcommand = args[1] || 'status'
111
134
  const exitCode = await hooksService.run(process.cwd(), subcommand)
112
135
  process.exitCode = exitCode
136
+ done()
113
137
  } else if (args[0] === 'doctor') {
114
138
  // Health check command
139
+ const done = await trackSession('doctor')
115
140
  const { doctorService } = await import('../core/services/doctor-service')
116
141
  const exitCode = await doctorService.run(process.cwd())
117
142
  process.exitCode = exitCode
143
+ done()
118
144
  } else if (args[0] === 'uninstall') {
119
145
  // Complete system removal
120
146
  const { uninstall } = await import('../core/commands/uninstall')
@@ -761,23 +761,29 @@ export class AnalysisCommands extends PrjctCommandsBase {
761
761
  const checker = createStalenessChecker(projectPath)
762
762
  const status = await checker.check(projectId)
763
763
 
764
+ // Get session info
765
+ const sessionInfo = await checker.getSessionInfo(projectId)
766
+
764
767
  // JSON output mode
765
768
  if (options.json) {
766
769
  console.log(
767
770
  JSON.stringify({
768
771
  success: true,
769
772
  ...status,
773
+ session: sessionInfo,
770
774
  })
771
775
  )
772
- return { success: true, data: status }
776
+ return { success: true, data: { ...status, session: sessionInfo } }
773
777
  }
774
778
 
775
779
  // Human-readable output
776
780
  console.log('')
777
781
  console.log(checker.formatStatus(status))
778
782
  console.log('')
783
+ console.log(checker.formatSessionInfo(sessionInfo))
784
+ console.log('')
779
785
 
780
- return { success: true, data: status }
786
+ return { success: true, data: { ...status, session: sessionInfo } }
781
787
  } catch (error) {
782
788
  const errMsg = (error as Error).message
783
789
  if (options.json) {
package/core/index.ts CHANGED
@@ -13,6 +13,8 @@ import path from 'node:path'
13
13
  import chalk from 'chalk'
14
14
  import type { CommandMeta } from './commands/registry'
15
15
  import { detectAllProviders, detectAntigravity } from './infrastructure/ai-provider'
16
+ import configManager from './infrastructure/config-manager'
17
+ import { sessionTracker } from './services/session-tracker'
16
18
  import out from './utils/output'
17
19
 
18
20
  interface ParsedCommandArgs {
@@ -79,6 +81,19 @@ async function main(): Promise<void> {
79
81
  // 4. Parse arguments
80
82
  const { parsedArgs, options } = parseCommandArgs(cmd, rawArgs)
81
83
 
84
+ // 4.5. Session tracking — touch/create session before command execution
85
+ let projectId: string | null = null
86
+ const commandStartTime = Date.now()
87
+ try {
88
+ projectId = await configManager.getProjectId(process.cwd())
89
+ if (projectId) {
90
+ await sessionTracker.expireIfStale(projectId)
91
+ await sessionTracker.touch(projectId)
92
+ }
93
+ } catch {
94
+ // Session tracking is non-critical — silent fail
95
+ }
96
+
82
97
  // 5. Instantiate commands handler
83
98
  const commands = new PrjctCommands()
84
99
 
@@ -149,7 +164,17 @@ async function main(): Promise<void> {
149
164
  }
150
165
  }
151
166
 
152
- // 7. Display result
167
+ // 7. Track command in session
168
+ if (projectId) {
169
+ const durationMs = Date.now() - commandStartTime
170
+ try {
171
+ await sessionTracker.trackCommand(projectId, commandName, durationMs)
172
+ } catch {
173
+ // Non-critical
174
+ }
175
+ }
176
+
177
+ // 8. Display result
153
178
  if (result?.message) {
154
179
  console.log(result.message)
155
180
  }
@@ -0,0 +1,287 @@
1
+ /**
2
+ * SessionTracker - Lightweight session state tracking for multi-command workflows
3
+ *
4
+ * Tracks command sequences and file access across CLI invocations.
5
+ * Sessions auto-create on first command and expire after 30 min idle.
6
+ *
7
+ * Storage: ~/.prjct-cli/projects/{projectId}/storage/session.json
8
+ *
9
+ * @see PRJ-109
10
+ */
11
+
12
+ import fs from 'node:fs/promises'
13
+ import path from 'node:path'
14
+ import pathManager from '../infrastructure/path-manager'
15
+ import { isNotFoundError } from '../types/fs'
16
+ import { formatDuration, getTimestamp } from '../utils/date-helper'
17
+
18
+ // =============================================================================
19
+ // TYPES
20
+ // =============================================================================
21
+
22
+ export interface CommandRecord {
23
+ command: string
24
+ timestamp: string
25
+ durationMs: number
26
+ }
27
+
28
+ export interface FileRecord {
29
+ path: string
30
+ operation: 'read' | 'write'
31
+ timestamp: string
32
+ }
33
+
34
+ export interface SessionData {
35
+ id: string
36
+ projectId: string
37
+ status: 'active' | 'expired'
38
+ createdAt: string
39
+ lastActivity: string
40
+ commands: CommandRecord[]
41
+ files: FileRecord[]
42
+ }
43
+
44
+ export interface SessionFile {
45
+ current: SessionData | null
46
+ config: {
47
+ idleTimeoutMs: number
48
+ }
49
+ }
50
+
51
+ export interface SessionInfo {
52
+ active: boolean
53
+ id: string | null
54
+ duration: string | null
55
+ idleSince: string | null
56
+ idleMs: number
57
+ expiresIn: string | null
58
+ commandCount: number
59
+ commands: string[]
60
+ filesRead: number
61
+ filesWritten: number
62
+ }
63
+
64
+ // =============================================================================
65
+ // CONSTANTS
66
+ // =============================================================================
67
+
68
+ const SESSION_FILENAME = 'session.json'
69
+ const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1000 // 30 minutes
70
+ const MAX_COMMAND_HISTORY = 50
71
+ const MAX_FILE_HISTORY = 200
72
+
73
+ // =============================================================================
74
+ // SESSION TRACKER
75
+ // =============================================================================
76
+
77
+ class SessionTracker {
78
+ private getPath(projectId: string): string {
79
+ return pathManager.getStoragePath(projectId, SESSION_FILENAME)
80
+ }
81
+
82
+ /**
83
+ * Read session file from disk
84
+ */
85
+ private async read(projectId: string): Promise<SessionFile> {
86
+ const filePath = this.getPath(projectId)
87
+ try {
88
+ const content = await fs.readFile(filePath, 'utf-8')
89
+ return JSON.parse(content) as SessionFile
90
+ } catch (error) {
91
+ if (isNotFoundError(error) || error instanceof SyntaxError) {
92
+ return this.getDefault()
93
+ }
94
+ throw error
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Write session file to disk
100
+ */
101
+ private async write(projectId: string, data: SessionFile): Promise<void> {
102
+ const filePath = this.getPath(projectId)
103
+ await fs.mkdir(path.dirname(filePath), { recursive: true })
104
+ await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8')
105
+ }
106
+
107
+ private getDefault(): SessionFile {
108
+ return {
109
+ current: null,
110
+ config: {
111
+ idleTimeoutMs: DEFAULT_IDLE_TIMEOUT_MS,
112
+ },
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Check if a session has expired based on idle timeout
118
+ */
119
+ private isExpired(session: SessionData, timeoutMs: number): boolean {
120
+ const lastActivity = new Date(session.lastActivity).getTime()
121
+ const now = Date.now()
122
+ return now - lastActivity > timeoutMs
123
+ }
124
+
125
+ /**
126
+ * Touch session — create new or resume existing.
127
+ * Called at the start of every CLI command.
128
+ * Returns the active session.
129
+ */
130
+ async touch(projectId: string): Promise<SessionData> {
131
+ const file = await this.read(projectId)
132
+ const now = getTimestamp()
133
+
134
+ // If active session exists and not expired, resume it
135
+ if (file.current && !this.isExpired(file.current, file.config.idleTimeoutMs)) {
136
+ file.current.lastActivity = now
137
+ await this.write(projectId, file)
138
+ return file.current
139
+ }
140
+
141
+ // Create new session (old one expired or doesn't exist)
142
+ const session: SessionData = {
143
+ id: crypto.randomUUID(),
144
+ projectId,
145
+ status: 'active',
146
+ createdAt: now,
147
+ lastActivity: now,
148
+ commands: [],
149
+ files: [],
150
+ }
151
+
152
+ file.current = session
153
+ await this.write(projectId, file)
154
+ return session
155
+ }
156
+
157
+ /**
158
+ * Record a command execution in the current session
159
+ */
160
+ async trackCommand(projectId: string, command: string, durationMs: number): Promise<void> {
161
+ const file = await this.read(projectId)
162
+ if (!file.current) return
163
+
164
+ const now = getTimestamp()
165
+ file.current.lastActivity = now
166
+ file.current.commands.push({
167
+ command,
168
+ timestamp: now,
169
+ durationMs,
170
+ })
171
+
172
+ // Trim old commands if over limit
173
+ if (file.current.commands.length > MAX_COMMAND_HISTORY) {
174
+ file.current.commands = file.current.commands.slice(-MAX_COMMAND_HISTORY)
175
+ }
176
+
177
+ await this.write(projectId, file)
178
+ }
179
+
180
+ /**
181
+ * Record a file access in the current session
182
+ */
183
+ async trackFile(projectId: string, filePath: string, operation: 'read' | 'write'): Promise<void> {
184
+ const file = await this.read(projectId)
185
+ if (!file.current) return
186
+
187
+ const now = getTimestamp()
188
+ file.current.lastActivity = now
189
+ file.current.files.push({
190
+ path: filePath,
191
+ operation,
192
+ timestamp: now,
193
+ })
194
+
195
+ // Trim old file records if over limit
196
+ if (file.current.files.length > MAX_FILE_HISTORY) {
197
+ file.current.files = file.current.files.slice(-MAX_FILE_HISTORY)
198
+ }
199
+
200
+ await this.write(projectId, file)
201
+ }
202
+
203
+ /**
204
+ * Get session info for display (used by `prjct status`)
205
+ */
206
+ async getInfo(projectId: string): Promise<SessionInfo> {
207
+ const file = await this.read(projectId)
208
+
209
+ if (!file.current || this.isExpired(file.current, file.config.idleTimeoutMs)) {
210
+ return {
211
+ active: false,
212
+ id: null,
213
+ duration: null,
214
+ idleSince: null,
215
+ idleMs: 0,
216
+ expiresIn: null,
217
+ commandCount: 0,
218
+ commands: [],
219
+ filesRead: 0,
220
+ filesWritten: 0,
221
+ }
222
+ }
223
+
224
+ const session = file.current
225
+ const now = Date.now()
226
+ const createdAt = new Date(session.createdAt).getTime()
227
+ const lastActivity = new Date(session.lastActivity).getTime()
228
+ const idleMs = now - lastActivity
229
+ const timeoutMs = file.config.idleTimeoutMs
230
+ const expiresInMs = Math.max(0, timeoutMs - idleMs)
231
+
232
+ const uniqueCommands = session.commands.map((c) => c.command)
233
+ const filesRead = new Set(
234
+ session.files.filter((f) => f.operation === 'read').map((f) => f.path)
235
+ ).size
236
+ const filesWritten = new Set(
237
+ session.files.filter((f) => f.operation === 'write').map((f) => f.path)
238
+ ).size
239
+
240
+ return {
241
+ active: true,
242
+ id: session.id,
243
+ duration: formatDuration(now - createdAt),
244
+ idleSince: session.lastActivity,
245
+ idleMs,
246
+ expiresIn: formatDuration(expiresInMs),
247
+ commandCount: session.commands.length,
248
+ commands: uniqueCommands,
249
+ filesRead,
250
+ filesWritten,
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Expire the current session (cleanup)
256
+ */
257
+ async expire(projectId: string): Promise<void> {
258
+ const file = await this.read(projectId)
259
+ if (file.current) {
260
+ file.current.status = 'expired'
261
+ file.current = null
262
+ await this.write(projectId, file)
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Check and expire stale session if needed.
268
+ * Called on startup to clean up leftover sessions.
269
+ * Returns true if a session was expired.
270
+ */
271
+ async expireIfStale(projectId: string): Promise<boolean> {
272
+ const file = await this.read(projectId)
273
+ if (file.current && this.isExpired(file.current, file.config.idleTimeoutMs)) {
274
+ file.current = null
275
+ await this.write(projectId, file)
276
+ return true
277
+ }
278
+ return false
279
+ }
280
+ }
281
+
282
+ // =============================================================================
283
+ // EXPORTS
284
+ // =============================================================================
285
+
286
+ export const sessionTracker = new SessionTracker()
287
+ export default sessionTracker
@@ -12,6 +12,7 @@ import fs from 'node:fs/promises'
12
12
  import path from 'node:path'
13
13
  import { promisify } from 'node:util'
14
14
  import pathManager from '../infrastructure/path-manager'
15
+ import { type SessionInfo, sessionTracker } from './session-tracker'
15
16
 
16
17
  const execAsync = promisify(exec)
17
18
 
@@ -248,6 +249,57 @@ export class StalenessChecker {
248
249
  return lines.join('\n')
249
250
  }
250
251
 
252
+ /**
253
+ * Get session info for the project
254
+ */
255
+ async getSessionInfo(projectId: string): Promise<SessionInfo> {
256
+ return sessionTracker.getInfo(projectId)
257
+ }
258
+
259
+ /**
260
+ * Format session info for display
261
+ */
262
+ formatSessionInfo(info: SessionInfo): string {
263
+ const lines: string[] = []
264
+
265
+ if (!info.active) {
266
+ lines.push('Session: ○ No active session')
267
+ return lines.join('\n')
268
+ }
269
+
270
+ lines.push(`Session: ▶ Active (${info.duration})`)
271
+
272
+ const details: string[] = []
273
+ if (info.commandCount > 0) {
274
+ // Show unique command sequence
275
+ const seen = new Set<string>()
276
+ const unique: string[] = []
277
+ for (const cmd of info.commands) {
278
+ if (!seen.has(cmd)) {
279
+ seen.add(cmd)
280
+ unique.push(cmd)
281
+ }
282
+ }
283
+ details.push(`Commands: ${unique.join(' → ')} (${info.commandCount} total)`)
284
+ }
285
+ if (info.filesRead > 0 || info.filesWritten > 0) {
286
+ details.push(`Files: ${info.filesRead} read, ${info.filesWritten} written`)
287
+ }
288
+ details.push(`Idle: ${info.expiresIn} until timeout`)
289
+
290
+ if (details.length > 0) {
291
+ const maxLen = Math.max(...details.map((l) => l.length))
292
+ const border = '─'.repeat(maxLen + 2)
293
+ lines.push(`┌${border}┐`)
294
+ for (const detail of details) {
295
+ lines.push(`│ ${detail.padEnd(maxLen)} │`)
296
+ }
297
+ lines.push(`└${border}┘`)
298
+ }
299
+
300
+ return lines.join('\n')
301
+ }
302
+
251
303
  /**
252
304
  * Get a short warning message if stale (for other commands)
253
305
  */