prjct-cli 1.2.2 → 1.4.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 +107 -0
- package/bin/prjct.ts +26 -0
- package/core/commands/analysis.ts +30 -2
- package/core/index.ts +26 -1
- package/core/services/session-tracker.ts +287 -0
- package/core/services/staleness-checker.ts +52 -0
- package/core/services/sync-service.ts +16 -0
- package/core/services/sync-verifier.ts +273 -0
- package/core/types/config.ts +14 -0
- package/dist/bin/prjct.mjs +1382 -859
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,112 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.4.0] - 2026-02-06
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- programmatic verification checks for sync workflow (PRJ-106) (#115)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
## [1.3.1] - 2026-02-06
|
|
11
|
+
|
|
12
|
+
### Features
|
|
13
|
+
|
|
14
|
+
- **Programmatic verification checks for sync workflow (PRJ-106)**: Post-sync validation with built-in and custom checks
|
|
15
|
+
|
|
16
|
+
### Implementation Details
|
|
17
|
+
|
|
18
|
+
New `SyncVerifier` service (`core/services/sync-verifier.ts`) that runs verification checks after every sync. Three built-in checks run automatically:
|
|
19
|
+
- **Context files exist** — verifies `context/CLAUDE.md` was generated
|
|
20
|
+
- **JSON files valid** — validates `storage/state.json` syntax
|
|
21
|
+
- **No sensitive data** — scans context files for leaked API keys, passwords, secrets
|
|
22
|
+
|
|
23
|
+
Custom checks configurable in `.prjct/prjct.config.json`:
|
|
24
|
+
```json
|
|
25
|
+
{
|
|
26
|
+
"verification": {
|
|
27
|
+
"checks": [
|
|
28
|
+
{ "name": "Lint CLAUDE.md", "command": "npx markdownlint CLAUDE.md" },
|
|
29
|
+
{ "name": "Custom validator", "script": ".prjct/verify.sh" }
|
|
30
|
+
],
|
|
31
|
+
"failFast": false
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Integration: wired into `sync-service.ts` after file generation (step 11), results returned in `SyncResult.verification`. Display in `showSyncResult()` shows pass/fail per check with timing.
|
|
37
|
+
|
|
38
|
+
### Learnings
|
|
39
|
+
|
|
40
|
+
- Non-critical verification must be wrapped in try/catch so it never breaks the sync workflow
|
|
41
|
+
- Config types must match optional fields between `LocalConfig` and `VerificationConfig` (both `checks` must be optional)
|
|
42
|
+
- Built-in + custom extensibility pattern (always run built-ins, then user commands) provides good defaults with flexibility
|
|
43
|
+
|
|
44
|
+
### Test Plan
|
|
45
|
+
|
|
46
|
+
#### For QA
|
|
47
|
+
1. Run `prjct sync --yes` — verify "Verified" section with 3 checks passing
|
|
48
|
+
2. Add custom check to `.prjct/prjct.config.json` — verify it runs after sync
|
|
49
|
+
3. Add failing custom check (`command: "exit 1"`) — verify `✗` with error
|
|
50
|
+
4. Set `failFast: true` with failing check — verify remaining checks skipped
|
|
51
|
+
5. Run `bun run build && bun run typecheck` — zero errors
|
|
52
|
+
|
|
53
|
+
#### For Users
|
|
54
|
+
**What changed:** `prjct sync` now validates generated output with pass/fail checks
|
|
55
|
+
**How to use:** Built-in checks run automatically. Add custom checks in `.prjct/prjct.config.json`
|
|
56
|
+
**Breaking changes:** None
|
|
57
|
+
|
|
58
|
+
## [1.3.0] - 2026-02-06
|
|
59
|
+
|
|
60
|
+
### Features
|
|
61
|
+
|
|
62
|
+
- session state tracking for multi-command workflows (PRJ-109) (#114)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
## [1.3.0] - 2026-02-06
|
|
66
|
+
|
|
67
|
+
### Features
|
|
68
|
+
|
|
69
|
+
- session state tracking for multi-command workflows (PRJ-109)
|
|
70
|
+
|
|
71
|
+
### Implementation Details
|
|
72
|
+
|
|
73
|
+
New `SessionTracker` service (`core/services/session-tracker.ts`) that manages lightweight session lifecycle for tracking command sequences and file access across CLI invocations.
|
|
74
|
+
|
|
75
|
+
Key behaviors:
|
|
76
|
+
- **Auto-create**: Sessions start automatically on first CLI command
|
|
77
|
+
- **Auto-resume**: Subsequent commands within 30 min extend the existing session
|
|
78
|
+
- **Auto-expire**: Sessions expire after 30 minutes of idle time, cleaned up on next startup
|
|
79
|
+
- **Command tracking**: Records command name, timestamp, and duration (up to 50 commands)
|
|
80
|
+
- **File tracking**: Records file reads/writes with timestamps (up to 200 records)
|
|
81
|
+
|
|
82
|
+
Integration points:
|
|
83
|
+
- `core/index.ts` — touch/track in main CLI dispatch (all standard commands)
|
|
84
|
+
- `bin/prjct.ts` — `trackSession()` helper for context/hooks/doctor commands
|
|
85
|
+
- `core/commands/analysis.ts` — session info in `prjct status` output (JSON + human-readable)
|
|
86
|
+
- `core/services/staleness-checker.ts` — `getSessionInfo()` and `formatSessionInfo()` with box-drawing display
|
|
87
|
+
|
|
88
|
+
Storage: `~/.prjct-cli/projects/{projectId}/storage/session.json`
|
|
89
|
+
|
|
90
|
+
### Learnings
|
|
91
|
+
|
|
92
|
+
- Non-critical tracking should always be wrapped in try/catch with silent fail — session tracking must never break CLI commands
|
|
93
|
+
- Touch-on-every-command pattern is simple but effective for session detection — no explicit start/stop needed
|
|
94
|
+
- Box-drawing characters (`┌─┐│└─┘`) provide clean structured output without external dependencies
|
|
95
|
+
|
|
96
|
+
### Test Plan
|
|
97
|
+
|
|
98
|
+
#### For QA
|
|
99
|
+
1. Run `prjct status` — verify "Session: ▶ Active" with duration, commands, idle timer
|
|
100
|
+
2. Wait 30+ minutes, run `prjct status` — verify "Session: ○ No active session"
|
|
101
|
+
3. Run multiple commands in sequence — verify command count increments
|
|
102
|
+
4. Run `prjct status --json` — verify session object in JSON output
|
|
103
|
+
5. Run `bun run build && bun run typecheck` — zero errors
|
|
104
|
+
|
|
105
|
+
#### For Users
|
|
106
|
+
**What changed:** CLI now tracks session state across commands for workflow visibility
|
|
107
|
+
**How to use:** Run `prjct status` to see active session info
|
|
108
|
+
**Breaking changes:** None
|
|
109
|
+
|
|
3
110
|
## [1.2.2] - 2026-02-06
|
|
4
111
|
|
|
5
112
|
### 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')
|
|
@@ -529,6 +529,28 @@ export class AnalysisCommands extends PrjctCommandsBase {
|
|
|
529
529
|
console.log('')
|
|
530
530
|
}
|
|
531
531
|
|
|
532
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
533
|
+
// VERIFICATION - Post-sync validation checks
|
|
534
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
535
|
+
if (result.verification) {
|
|
536
|
+
const v = result.verification
|
|
537
|
+
if (v.passed) {
|
|
538
|
+
const items = v.checks.map((c) => `${c.name} (${c.durationMs}ms)`)
|
|
539
|
+
out.section('Verified')
|
|
540
|
+
out.list(items, { bullet: '✓' })
|
|
541
|
+
} else {
|
|
542
|
+
out.section('Verification')
|
|
543
|
+
const items = v.checks.map((c) =>
|
|
544
|
+
c.passed ? `✓ ${c.name}` : `✗ ${c.name}${c.error ? ` — ${c.error}` : ''}`
|
|
545
|
+
)
|
|
546
|
+
out.list(items)
|
|
547
|
+
if (v.skippedCount > 0) {
|
|
548
|
+
out.warn(`${v.skippedCount} check(s) skipped (fail-fast)`)
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
console.log('')
|
|
552
|
+
}
|
|
553
|
+
|
|
532
554
|
// ═══════════════════════════════════════════════════════════════════════
|
|
533
555
|
// NEXT STEPS - Clear call to action
|
|
534
556
|
// ═══════════════════════════════════════════════════════════════════════
|
|
@@ -761,23 +783,29 @@ export class AnalysisCommands extends PrjctCommandsBase {
|
|
|
761
783
|
const checker = createStalenessChecker(projectPath)
|
|
762
784
|
const status = await checker.check(projectId)
|
|
763
785
|
|
|
786
|
+
// Get session info
|
|
787
|
+
const sessionInfo = await checker.getSessionInfo(projectId)
|
|
788
|
+
|
|
764
789
|
// JSON output mode
|
|
765
790
|
if (options.json) {
|
|
766
791
|
console.log(
|
|
767
792
|
JSON.stringify({
|
|
768
793
|
success: true,
|
|
769
794
|
...status,
|
|
795
|
+
session: sessionInfo,
|
|
770
796
|
})
|
|
771
797
|
)
|
|
772
|
-
return { success: true, data: status }
|
|
798
|
+
return { success: true, data: { ...status, session: sessionInfo } }
|
|
773
799
|
}
|
|
774
800
|
|
|
775
801
|
// Human-readable output
|
|
776
802
|
console.log('')
|
|
777
803
|
console.log(checker.formatStatus(status))
|
|
778
804
|
console.log('')
|
|
805
|
+
console.log(checker.formatSessionInfo(sessionInfo))
|
|
806
|
+
console.log('')
|
|
779
807
|
|
|
780
|
-
return { success: true, data: status }
|
|
808
|
+
return { success: true, data: { ...status, session: sessionInfo } }
|
|
781
809
|
} catch (error) {
|
|
782
810
|
const errMsg = (error as Error).message
|
|
783
811
|
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.
|
|
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
|
*/
|
|
@@ -35,6 +35,7 @@ import { ContextFileGenerator } from './context-generator'
|
|
|
35
35
|
import type { SyncDiff } from './diff-generator'
|
|
36
36
|
import { localStateGenerator } from './local-state-generator'
|
|
37
37
|
import { type StackDetection, StackDetector } from './stack-detector'
|
|
38
|
+
import { syncVerifier, type VerificationReport } from './sync-verifier'
|
|
38
39
|
|
|
39
40
|
const execAsync = promisify(exec)
|
|
40
41
|
|
|
@@ -106,6 +107,7 @@ interface SyncResult {
|
|
|
106
107
|
contextFiles: string[]
|
|
107
108
|
aiTools: AIToolResult[]
|
|
108
109
|
syncMetrics?: SyncMetrics
|
|
110
|
+
verification?: VerificationReport
|
|
109
111
|
error?: string
|
|
110
112
|
// Preview mode fields
|
|
111
113
|
isPreview?: boolean
|
|
@@ -246,6 +248,19 @@ class SyncService {
|
|
|
246
248
|
await commandInstaller.installGlobalConfig()
|
|
247
249
|
await commandInstaller.syncCommands()
|
|
248
250
|
|
|
251
|
+
// 11. Run verification checks (built-in + custom from config)
|
|
252
|
+
let verification: VerificationReport | undefined
|
|
253
|
+
try {
|
|
254
|
+
const localConfig = await configManager.readConfig(this.projectPath)
|
|
255
|
+
verification = await syncVerifier.verify(
|
|
256
|
+
this.projectPath,
|
|
257
|
+
this.globalPath,
|
|
258
|
+
localConfig?.verification
|
|
259
|
+
)
|
|
260
|
+
} catch {
|
|
261
|
+
// Verification is non-critical — don't fail sync
|
|
262
|
+
}
|
|
263
|
+
|
|
249
264
|
return {
|
|
250
265
|
success: true,
|
|
251
266
|
projectId: this.projectId,
|
|
@@ -263,6 +278,7 @@ class SyncService {
|
|
|
263
278
|
success: r.success,
|
|
264
279
|
})),
|
|
265
280
|
syncMetrics,
|
|
281
|
+
verification,
|
|
266
282
|
}
|
|
267
283
|
} catch (error) {
|
|
268
284
|
return {
|