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.
- package/CHANGELOG.md +7 -0
- package/core/__tests__/services/staleness-checker.test.ts +204 -0
- package/core/__tests__/storage/storage-manager.test.ts +379 -0
- package/core/commands/analysis.ts +59 -1
- package/core/commands/analytics.ts +9 -0
- package/core/commands/command-data.ts +16 -0
- package/core/commands/commands.ts +7 -0
- package/core/commands/register.ts +1 -0
- package/core/index.ts +4 -0
- package/core/schemas/project.ts +3 -0
- package/core/services/index.ts +3 -0
- package/core/services/staleness-checker.ts +262 -0
- package/core/services/sync-service.ts +3 -0
- package/dist/bin/prjct.mjs +642 -364
- package/package.json +1 -1
|
@@ -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(),
|
package/core/schemas/project.ts
CHANGED
|
@@ -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
|
// =============================================================================
|
package/core/services/index.ts
CHANGED
|
@@ -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')
|