prjct-cli 0.58.0 → 0.59.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.
@@ -185,6 +185,22 @@ export const COMMANDS: CommandMeta[] = [
185
185
  hasTemplate: true,
186
186
  requiresProject: true,
187
187
  },
188
+ {
189
+ name: 'status',
190
+ group: 'core',
191
+ description: 'Check if CLAUDE.md context is stale and needs resync',
192
+ usage: { claude: '/p:status', terminal: 'prjct status' },
193
+ params: '[--json]',
194
+ implemented: true,
195
+ hasTemplate: false,
196
+ requiresProject: true,
197
+ features: [
198
+ 'Compares current HEAD with last sync commit',
199
+ 'Counts commits and days since sync',
200
+ 'Detects significant file changes',
201
+ 'Configurable staleness thresholds',
202
+ ],
203
+ },
188
204
  {
189
205
  name: 'help',
190
206
  group: 'core',
@@ -202,6 +202,13 @@ class PrjctCommands {
202
202
  return this.analysis.stats(projectPath, options)
203
203
  }
204
204
 
205
+ async status(
206
+ projectPath: string = process.cwd(),
207
+ options: { json?: boolean } = {}
208
+ ): Promise<CommandResult> {
209
+ return this.analysis.status(projectPath, options)
210
+ }
211
+
205
212
  // ========== Context Commands ==========
206
213
 
207
214
  async context(
@@ -85,6 +85,7 @@ export function registerAllCommands(): void {
85
85
  commandRegistry.registerMethod('analyze', analysis, 'analyze', getMeta('analyze'))
86
86
  commandRegistry.registerMethod('sync', analysis, 'sync', getMeta('sync'))
87
87
  commandRegistry.registerMethod('stats', analysis, 'stats', getMeta('stats'))
88
+ commandRegistry.registerMethod('status', analysis, 'status', getMeta('status'))
88
89
 
89
90
  // Setup commands
90
91
  commandRegistry.registerMethod('start', setup, 'start', getMeta('start'))
package/core/index.ts CHANGED
@@ -116,6 +116,10 @@ async function main(): Promise<void> {
116
116
  json: options.json === true,
117
117
  export: options.export === true,
118
118
  }),
119
+ status: () =>
120
+ commands.status(process.cwd(), {
121
+ json: options.json === true,
122
+ }),
119
123
  help: (p) => commands.help(p || ''),
120
124
  // Maintenance
121
125
  recover: () => commands.recover(),
@@ -25,6 +25,9 @@ export const ProjectItemSchema = z.object({
25
25
  commitCount: z.number(),
26
26
  createdAt: z.string(), // ISO8601
27
27
  lastSync: z.string(), // ISO8601
28
+ // Staleness tracking (PRJ-120)
29
+ lastSyncCommit: z.string().optional(), // Git commit hash at last sync
30
+ lastSyncBranch: z.string().optional(), // Git branch at last sync
28
31
  })
29
32
 
30
33
  // =============================================================================
@@ -39,5 +39,8 @@ export type { IndexOptions, RelevantContext, ScanResult } from './project-index'
39
39
  // Project Index - Persistent scanning with scoring
40
40
  export { createProjectIndexer, ProjectIndexer, RELEVANCE_THRESHOLD } from './project-index'
41
41
  export { ProjectService, projectService } from './project-service'
42
+ export type { StalenessConfig, StalenessStatus } from './staleness-checker'
43
+ // Staleness Checker - Detect when CLAUDE.md is stale (PRJ-120)
44
+ export { createStalenessChecker, StalenessChecker } from './staleness-checker'
42
45
  export type { SyncResult } from './sync-service'
43
46
  export { SyncService, syncService } from './sync-service'
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Staleness Checker Service (PRJ-120)
3
+ *
4
+ * Detects when CLAUDE.md context is stale and needs resync.
5
+ * Uses git commit history to determine if significant changes have occurred.
6
+ *
7
+ * Pattern from Warp Agent: "Use VCS to detect recent changes"
8
+ */
9
+
10
+ import { exec } from 'node:child_process'
11
+ import fs from 'node:fs/promises'
12
+ import path from 'node:path'
13
+ import { promisify } from 'node:util'
14
+ import pathManager from '../infrastructure/path-manager'
15
+
16
+ const execAsync = promisify(exec)
17
+
18
+ // =============================================================================
19
+ // TYPES
20
+ // =============================================================================
21
+
22
+ export interface StalenessStatus {
23
+ isStale: boolean
24
+ reason: string | null
25
+ lastSyncCommit: string | null
26
+ currentCommit: string | null
27
+ commitsSinceSync: number
28
+ daysSinceSync: number
29
+ changedFiles: string[]
30
+ significantChanges: string[] // Files that likely affect context (package.json, etc.)
31
+ }
32
+
33
+ export interface StalenessConfig {
34
+ commitThreshold: number // Number of commits before considered stale (default: 10)
35
+ dayThreshold: number // Days before considered stale (default: 3)
36
+ significantFiles: string[] // Files that trigger staleness warning
37
+ }
38
+
39
+ const DEFAULT_CONFIG: StalenessConfig = {
40
+ commitThreshold: 10,
41
+ dayThreshold: 3,
42
+ significantFiles: [
43
+ 'package.json',
44
+ 'tsconfig.json',
45
+ 'Cargo.toml',
46
+ 'go.mod',
47
+ 'requirements.txt',
48
+ 'pyproject.toml',
49
+ '.env.example',
50
+ 'docker-compose.yml',
51
+ 'Dockerfile',
52
+ ],
53
+ }
54
+
55
+ // =============================================================================
56
+ // STALENESS CHECKER
57
+ // =============================================================================
58
+
59
+ export class StalenessChecker {
60
+ private projectPath: string
61
+ private config: StalenessConfig
62
+
63
+ constructor(projectPath: string, config: Partial<StalenessConfig> = {}) {
64
+ this.projectPath = projectPath
65
+ this.config = { ...DEFAULT_CONFIG, ...config }
66
+ }
67
+
68
+ /**
69
+ * Check if the project context is stale
70
+ */
71
+ async check(projectId: string): Promise<StalenessStatus> {
72
+ const status: StalenessStatus = {
73
+ isStale: false,
74
+ reason: null,
75
+ lastSyncCommit: null,
76
+ currentCommit: null,
77
+ commitsSinceSync: 0,
78
+ daysSinceSync: 0,
79
+ changedFiles: [],
80
+ significantChanges: [],
81
+ }
82
+
83
+ try {
84
+ // Read project.json to get last sync info
85
+ const projectJsonPath = path.join(pathManager.getGlobalProjectPath(projectId), 'project.json')
86
+
87
+ let projectJson: Record<string, unknown> = {}
88
+ try {
89
+ projectJson = JSON.parse(await fs.readFile(projectJsonPath, 'utf-8'))
90
+ } catch {
91
+ // No project.json = definitely stale
92
+ status.isStale = true
93
+ status.reason = 'No sync history found. Run `prjct sync` to initialize.'
94
+ return status
95
+ }
96
+
97
+ status.lastSyncCommit = (projectJson.lastSyncCommit as string) || null
98
+ const lastSync = projectJson.lastSync as string
99
+
100
+ // Get current HEAD commit
101
+ try {
102
+ const { stdout } = await execAsync('git rev-parse --short HEAD', {
103
+ cwd: this.projectPath,
104
+ })
105
+ status.currentCommit = stdout.trim()
106
+ } catch {
107
+ // Not a git repo
108
+ status.reason = 'Not a git repository'
109
+ return status
110
+ }
111
+
112
+ // If no last sync commit, we can't compare
113
+ if (!status.lastSyncCommit) {
114
+ status.isStale = true
115
+ status.reason = 'No sync commit recorded. Run `prjct sync` to track.'
116
+ return status
117
+ }
118
+
119
+ // Same commit = not stale
120
+ if (status.lastSyncCommit === status.currentCommit) {
121
+ status.reason = 'Context is up to date'
122
+ return status
123
+ }
124
+
125
+ // Count commits since last sync
126
+ try {
127
+ const { stdout } = await execAsync(`git rev-list --count ${status.lastSyncCommit}..HEAD`, {
128
+ cwd: this.projectPath,
129
+ })
130
+ status.commitsSinceSync = parseInt(stdout.trim(), 10) || 0
131
+ } catch {
132
+ // Commit might not exist anymore (rebased, etc.)
133
+ status.isStale = true
134
+ status.reason = 'Sync commit no longer exists (history changed). Run `prjct sync`.'
135
+ return status
136
+ }
137
+
138
+ // Calculate days since sync
139
+ if (lastSync) {
140
+ const syncDate = new Date(lastSync)
141
+ const now = new Date()
142
+ status.daysSinceSync = Math.floor(
143
+ (now.getTime() - syncDate.getTime()) / (1000 * 60 * 60 * 24)
144
+ )
145
+ }
146
+
147
+ // Get changed files since last sync
148
+ try {
149
+ const { stdout } = await execAsync(`git diff --name-only ${status.lastSyncCommit}..HEAD`, {
150
+ cwd: this.projectPath,
151
+ })
152
+ status.changedFiles = stdout.trim().split('\n').filter(Boolean)
153
+ } catch {
154
+ status.changedFiles = []
155
+ }
156
+
157
+ // Check for significant file changes
158
+ status.significantChanges = status.changedFiles.filter((file) =>
159
+ this.config.significantFiles.some((sig) => file.endsWith(sig) || file.includes(sig))
160
+ )
161
+
162
+ // Determine staleness
163
+ if (status.commitsSinceSync >= this.config.commitThreshold) {
164
+ status.isStale = true
165
+ status.reason = `${status.commitsSinceSync} commits since last sync (threshold: ${this.config.commitThreshold})`
166
+ } else if (status.daysSinceSync >= this.config.dayThreshold) {
167
+ status.isStale = true
168
+ status.reason = `${status.daysSinceSync} days since last sync (threshold: ${this.config.dayThreshold})`
169
+ } else if (status.significantChanges.length > 0) {
170
+ status.isStale = true
171
+ status.reason = `Significant files changed: ${status.significantChanges.join(', ')}`
172
+ } else if (status.commitsSinceSync > 0) {
173
+ // Not stale yet, but has changes
174
+ status.reason = `${status.commitsSinceSync} commits since sync (threshold: ${this.config.commitThreshold})`
175
+ } else {
176
+ status.reason = 'Context is up to date'
177
+ }
178
+
179
+ return status
180
+ } catch (error) {
181
+ status.reason = `Error checking staleness: ${(error as Error).message}`
182
+ return status
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Format staleness status for display
188
+ */
189
+ formatStatus(status: StalenessStatus): string {
190
+ const lines: string[] = []
191
+
192
+ if (status.isStale) {
193
+ lines.push('CLAUDE.md status: ⚠️ STALE')
194
+ } else {
195
+ lines.push('CLAUDE.md status: ✓ Fresh')
196
+ }
197
+
198
+ lines.push('─────────────────────────────')
199
+
200
+ if (status.lastSyncCommit) {
201
+ lines.push(`Last sync: ${status.lastSyncCommit}`)
202
+ }
203
+ if (status.currentCommit) {
204
+ lines.push(`Current: ${status.currentCommit}`)
205
+ }
206
+ if (status.commitsSinceSync > 0) {
207
+ lines.push(`Commits since: ${status.commitsSinceSync}`)
208
+ }
209
+ if (status.daysSinceSync > 0) {
210
+ lines.push(`Days since: ${status.daysSinceSync}`)
211
+ }
212
+ if (status.changedFiles.length > 0) {
213
+ lines.push(`Files changed: ${status.changedFiles.length}`)
214
+ }
215
+ if (status.significantChanges.length > 0) {
216
+ lines.push(``)
217
+ lines.push(`Significant changes:`)
218
+ for (const file of status.significantChanges.slice(0, 5)) {
219
+ lines.push(` • ${file}`)
220
+ }
221
+ if (status.significantChanges.length > 5) {
222
+ lines.push(` ... and ${status.significantChanges.length - 5} more`)
223
+ }
224
+ }
225
+
226
+ if (status.reason) {
227
+ lines.push(``)
228
+ lines.push(status.reason)
229
+ }
230
+
231
+ if (status.isStale) {
232
+ lines.push(``)
233
+ lines.push(`Run \`prjct sync\` to update context`)
234
+ }
235
+
236
+ return lines.join('\n')
237
+ }
238
+
239
+ /**
240
+ * Get a short warning message if stale (for other commands)
241
+ */
242
+ getWarning(status: StalenessStatus): string | null {
243
+ if (!status.isStale) return null
244
+
245
+ if (status.commitsSinceSync > 0) {
246
+ return `⚠️ Context stale (${status.commitsSinceSync} commits behind). Run \`prjct sync\``
247
+ }
248
+ if (status.daysSinceSync > 0) {
249
+ return `⚠️ Context stale (${status.daysSinceSync} days old). Run \`prjct sync\``
250
+ }
251
+ return `⚠️ Context may be stale. Run \`prjct sync\``
252
+ }
253
+ }
254
+
255
+ // =============================================================================
256
+ // EXPORTS
257
+ // =============================================================================
258
+
259
+ export const createStalenessChecker = (projectPath: string, config?: Partial<StalenessConfig>) =>
260
+ new StalenessChecker(projectPath, config)
261
+
262
+ export default StalenessChecker
@@ -776,6 +776,9 @@ You are the ${name} expert for this project. Apply best practices for the detect
776
776
  hasUncommittedChanges: git.hasChanges,
777
777
  createdAt: existing.createdAt || dateHelper.getTimestamp(),
778
778
  lastSync: dateHelper.getTimestamp(),
779
+ // Staleness tracking (PRJ-120)
780
+ lastSyncCommit: git.recentCommits[0]?.hash || null,
781
+ lastSyncBranch: git.branch,
779
782
  }
780
783
 
781
784
  await fs.writeFile(projectJsonPath, JSON.stringify(updated, null, 2), 'utf-8')