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 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. 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
  */
@@ -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 {