prjct-cli 0.60.1 → 0.61.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,76 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.61.0] - 2026-02-05
4
+
5
+ ### Features
6
+
7
+ - add .prjct-state.md local state file for persistence (PRJ-112) (#102)
8
+
9
+
10
+ ## [0.61.0] - 2026-02-05
11
+
12
+ ### Features
13
+
14
+ - **Local state file (PRJ-112)**: New `.prjct-state.md` file generated in project root for local persistence
15
+
16
+ ### Implementation Details
17
+
18
+ Created `LocalStateGenerator` service that generates a markdown file showing current task state. Integrated via write-through pattern - `StateStorage.write()` now also generates the local state file. Also hooks into `sync-service.ts` for state.json updates during sync.
19
+
20
+ ### Learnings
21
+
22
+ - Write-through pattern: JSON storage triggers MD generation automatically
23
+ - State can be written from multiple entry points (storage class + sync service) - need hooks in both places
24
+
25
+ ### Test Plan
26
+
27
+ #### For QA
28
+ 1. Run `prjct sync` on any project - verify `.prjct-state.md` is generated in project root
29
+ 2. Start a task with `p. task "test"` - verify `.prjct-state.md` updates with task info
30
+ 3. Check that subtasks, progress, and status are displayed correctly
31
+ 4. Verify the file has "DO NOT EDIT" header comment
32
+
33
+ #### For Users
34
+ - New `.prjct-state.md` file in project root shows current task state
35
+ - Automatic - file updates whenever prjct state changes
36
+ - No breaking changes
37
+
38
+
39
+ ## [0.60.2] - 2026-02-05
40
+
41
+ ### Performance
42
+
43
+ - parallelize agent/skill loading with Promise.all (PRJ-110) (#101)
44
+
45
+
46
+ ## [0.60.2] - 2026-02-05
47
+
48
+ ### Performance
49
+
50
+ - **Parallel agent/skill loading (PRJ-110)**: Agent and skill loading now uses `Promise.all` for parallel I/O
51
+
52
+ ### Implementation Details
53
+
54
+ Refactored `loadAgents()` and `loadSkills()` in `core/agentic/orchestrator-executor.ts` to use `Promise.all` with map instead of sequential for loops. Also parallelized `loadAllAgents()` in `core/domain/agent-loader.ts`. Pattern: collect items → map to async promises → Promise.all → filter nulls with type guard.
55
+
56
+ ### Learnings
57
+
58
+ - Use `Promise.all(items.map(async (item) => ...))` for parallel async operations
59
+ - Return null for failed items, then filter - can't push to array in parallel
60
+ - Collect unique items first (deduplication), then parallelize reads
61
+
62
+ ### Test Plan
63
+
64
+ #### For QA
65
+ 1. Run `prjct sync --yes` - verify agents load successfully
66
+ 2. Run `p. task "test"` - verify orchestrator works
67
+ 3. Check no errors in agent/skill loading output
68
+
69
+ #### For Users
70
+ - Agent and skill loading is now faster (parallel I/O)
71
+ - No changes needed - improvement is automatic
72
+
73
+
3
74
  ## [0.60.1] - 2026-02-05
4
75
 
5
76
  ### Bug Fixes
@@ -341,13 +341,15 @@ export class OrchestratorExecutor {
341
341
  *
342
342
  * Reads agent markdown files from {globalPath}/agents/
343
343
  * and extracts their content and skills from frontmatter.
344
+ *
345
+ * Uses parallel file reads for performance (PRJ-110).
344
346
  */
345
347
  async loadAgents(domains: string[], projectId: string): Promise<LoadedAgent[]> {
346
348
  const globalPath = pathManager.getGlobalProjectPath(projectId)
347
349
  const agentsDir = path.join(globalPath, 'agents')
348
- const agents: LoadedAgent[] = []
349
350
 
350
- for (const domain of domains) {
351
+ // Load all domain agents in parallel
352
+ const agentPromises = domains.map(async (domain): Promise<LoadedAgent | null> => {
351
353
  // Try exact match first, then variations
352
354
  const possibleNames = [`${domain}.md`, `${domain}-agent.md`, `prjct-${domain}.md`]
353
355
 
@@ -357,21 +359,22 @@ export class OrchestratorExecutor {
357
359
  const content = await fs.readFile(filePath, 'utf-8')
358
360
  const { frontmatter, body } = this.parseAgentFile(content)
359
361
 
360
- agents.push({
362
+ return {
361
363
  name: fileName.replace('.md', ''),
362
364
  domain,
363
365
  content: body,
364
366
  skills: frontmatter.skills || [],
365
367
  filePath,
366
- })
367
-
368
- // Found one, no need to try other variations
369
- break
370
- } catch {}
368
+ }
369
+ } catch {
370
+ // Try next variation
371
+ }
371
372
  }
372
- }
373
+ return null
374
+ })
373
375
 
374
- return agents
376
+ const results = await Promise.all(agentPromises)
377
+ return results.filter((agent): agent is LoadedAgent => agent !== null)
375
378
  }
376
379
 
377
380
  /**
@@ -400,51 +403,46 @@ export class OrchestratorExecutor {
400
403
  * Load skills from agent frontmatter
401
404
  *
402
405
  * Skills are stored in ~/.claude/skills/{name}.md
406
+ *
407
+ * Uses parallel file reads for performance (PRJ-110).
403
408
  */
404
409
  async loadSkills(agents: LoadedAgent[]): Promise<LoadedSkill[]> {
405
410
  const skillsDir = path.join(os.homedir(), '.claude', 'skills')
406
- const skills: LoadedSkill[] = []
407
- const loadedSkillNames = new Set<string>()
408
411
 
412
+ // Collect unique skill names from all agents
413
+ const uniqueSkillNames = new Set<string>()
409
414
  for (const agent of agents) {
410
415
  for (const skillName of agent.skills) {
411
- // Skip if already loaded
412
- if (loadedSkillNames.has(skillName)) continue
416
+ uniqueSkillNames.add(skillName)
417
+ }
418
+ }
413
419
 
420
+ // Load all skills in parallel
421
+ const skillPromises = Array.from(uniqueSkillNames).map(
422
+ async (skillName): Promise<LoadedSkill | null> => {
414
423
  // Check both patterns: flat file and subdirectory (ecosystem standard)
415
424
  const flatPath = path.join(skillsDir, `${skillName}.md`)
416
425
  const subdirPath = path.join(skillsDir, skillName, 'SKILL.md')
417
426
 
418
- let content: string | null = null
419
- let resolvedPath = flatPath
420
-
421
427
  // Prefer subdirectory format (ecosystem standard)
422
428
  try {
423
- content = await fs.readFile(subdirPath, 'utf-8')
424
- resolvedPath = subdirPath
429
+ const content = await fs.readFile(subdirPath, 'utf-8')
430
+ return { name: skillName, content, filePath: subdirPath }
425
431
  } catch {
426
432
  // Fall back to flat file
427
433
  try {
428
- content = await fs.readFile(flatPath, 'utf-8')
429
- resolvedPath = flatPath
434
+ const content = await fs.readFile(flatPath, 'utf-8')
435
+ return { name: skillName, content, filePath: flatPath }
430
436
  } catch {
431
437
  // Skill not found - not an error, just skip
432
- console.warn(`Skill not found: ${skillName}`)
438
+ return null
433
439
  }
434
440
  }
435
-
436
- if (content) {
437
- skills.push({
438
- name: skillName,
439
- content,
440
- filePath: resolvedPath,
441
- })
442
- loadedSkillNames.add(skillName)
443
- }
444
441
  }
445
- }
442
+ )
446
443
 
447
- return skills
444
+ const results = await Promise.all(skillPromises)
445
+ return results.filter((skill): skill is LoadedSkill => skill !== null)
448
446
  }
449
447
 
450
448
  /**
@@ -94,22 +94,22 @@ class AgentLoader {
94
94
 
95
95
  /**
96
96
  * Load all agents for the project
97
+ *
98
+ * Uses parallel file reads for performance (PRJ-110).
97
99
  */
98
100
  async loadAllAgents(): Promise<Agent[]> {
99
101
  try {
100
102
  const files = await fs.readdir(this.agentsDir)
101
103
  const agentFiles = files.filter((f) => f.endsWith('.md') && !f.startsWith('.'))
102
104
 
103
- const agents: Agent[] = []
104
- for (const file of agentFiles) {
105
+ // Load all agents in parallel
106
+ const agentPromises = agentFiles.map((file) => {
105
107
  const agentName = file.replace('.md', '')
106
- const agent = await this.loadAgent(agentName)
107
- if (agent) {
108
- agents.push(agent)
109
- }
110
- }
108
+ return this.loadAgent(agentName)
109
+ })
111
110
 
112
- return agents
111
+ const results = await Promise.all(agentPromises)
112
+ return results.filter((agent): agent is Agent => agent !== null)
113
113
  } catch (error) {
114
114
  if (isNotFoundError(error)) {
115
115
  return [] // Agents directory doesn't exist yet
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Local State Generator
3
+ *
4
+ * Generates .prjct-state.md in the project root for local persistence.
5
+ * This provides a quick reference to current task state without needing
6
+ * to access global storage.
7
+ *
8
+ * @see PRJ-112
9
+ */
10
+
11
+ import fs from 'node:fs/promises'
12
+ import path from 'node:path'
13
+ import type { StateJson } from '../schemas/state'
14
+ import { isNotFoundError } from '../types/fs'
15
+
16
+ const LOCAL_STATE_FILENAME = '.prjct-state.md'
17
+
18
+ // Extended runtime types (state.json has fields not in strict schema)
19
+ interface RuntimeTask {
20
+ description: string
21
+ startedAt: string
22
+ linearId?: string
23
+ branch?: string
24
+ status?: string
25
+ subtasks?: Array<{
26
+ description: string
27
+ status: string
28
+ }>
29
+ currentSubtaskIndex?: number
30
+ }
31
+
32
+ interface RuntimePreviousTask {
33
+ description: string
34
+ status: string
35
+ prUrl?: string
36
+ }
37
+
38
+ class LocalStateGenerator {
39
+ /**
40
+ * Generate .prjct-state.md in the project root
41
+ */
42
+ async generate(projectPath: string, state: StateJson): Promise<void> {
43
+ const filePath = path.join(projectPath, LOCAL_STATE_FILENAME)
44
+ const content = this.toMarkdown(state)
45
+ await fs.writeFile(filePath, content, 'utf-8')
46
+ }
47
+
48
+ /**
49
+ * Remove local state file
50
+ */
51
+ async remove(projectPath: string): Promise<void> {
52
+ const filePath = path.join(projectPath, LOCAL_STATE_FILENAME)
53
+ try {
54
+ await fs.unlink(filePath)
55
+ } catch (error) {
56
+ if (!isNotFoundError(error)) throw error
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Check if local state file exists
62
+ */
63
+ async exists(projectPath: string): Promise<boolean> {
64
+ const filePath = path.join(projectPath, LOCAL_STATE_FILENAME)
65
+ try {
66
+ await fs.access(filePath)
67
+ return true
68
+ } catch {
69
+ return false
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Convert state to markdown format
75
+ * Note: Uses runtime types since state.json has fields not in strict Zod schema
76
+ */
77
+ private toMarkdown(state: StateJson): string {
78
+ const lines: string[] = [
79
+ '<!-- Auto-generated by prjct - DO NOT EDIT -->',
80
+ '<!-- This file provides local state persistence for AI tools -->',
81
+ '',
82
+ '# prjct State',
83
+ '',
84
+ ]
85
+
86
+ if (state.currentTask) {
87
+ // Cast to runtime type (state.json has additional fields)
88
+ const task = state.currentTask as unknown as RuntimeTask
89
+ lines.push('## Current Task')
90
+ lines.push('')
91
+ lines.push(`**${task.description}**`)
92
+ lines.push('')
93
+ lines.push(`- Started: ${task.startedAt}`)
94
+ if (task.linearId) {
95
+ lines.push(`- Linear: ${task.linearId}`)
96
+ }
97
+ if (task.branch) {
98
+ lines.push(`- Branch: ${task.branch}`)
99
+ }
100
+ lines.push(`- Status: ${task.status || 'active'}`)
101
+ lines.push('')
102
+
103
+ // Subtasks
104
+ if (task.subtasks && task.subtasks.length > 0) {
105
+ lines.push('### Subtasks')
106
+ lines.push('')
107
+
108
+ task.subtasks.forEach((subtask, index) => {
109
+ const statusIcon =
110
+ subtask.status === 'completed' ? '✅' : subtask.status === 'in_progress' ? '▶️' : '⏳'
111
+ const isActive = index === task.currentSubtaskIndex ? ' ← **Active**' : ''
112
+ lines.push(`${index + 1}. ${statusIcon} ${subtask.description}${isActive}`)
113
+ })
114
+
115
+ lines.push('')
116
+
117
+ // Progress
118
+ const completed = task.subtasks.filter((s) => s.status === 'completed').length
119
+ const total = task.subtasks.length
120
+ const percentage = Math.round((completed / total) * 100)
121
+ lines.push(`**Progress**: ${completed}/${total} (${percentage}%)`)
122
+ lines.push('')
123
+ }
124
+ } else {
125
+ lines.push('*No active task*')
126
+ lines.push('')
127
+ lines.push('Start a task with `p. task "description"`')
128
+ lines.push('')
129
+ }
130
+
131
+ // Previous task info
132
+ if (state.previousTask) {
133
+ // Cast to runtime type
134
+ const prevTask = state.previousTask as unknown as RuntimePreviousTask
135
+ lines.push('---')
136
+ lines.push('')
137
+ lines.push('## Previous Task')
138
+ lines.push('')
139
+ lines.push(`**${prevTask.description}**`)
140
+ lines.push('')
141
+ lines.push(`- Status: ${prevTask.status}`)
142
+ if (prevTask.prUrl) {
143
+ lines.push(`- PR: ${prevTask.prUrl}`)
144
+ }
145
+ lines.push('')
146
+ }
147
+
148
+ // Footer
149
+ lines.push('---')
150
+ lines.push(`*Last updated: ${state.lastUpdated || new Date().toISOString()}*`)
151
+ lines.push('')
152
+
153
+ return lines.join('\n')
154
+ }
155
+ }
156
+
157
+ export const localStateGenerator = new LocalStateGenerator()
158
+ export default localStateGenerator
@@ -33,6 +33,7 @@ import { metricsStorage } from '../storage/metrics-storage'
33
33
  import dateHelper from '../utils/date-helper'
34
34
  import { ContextFileGenerator } from './context-generator'
35
35
  import type { SyncDiff } from './diff-generator'
36
+ import { localStateGenerator } from './local-state-generator'
36
37
  import { type StackDetection, StackDetector } from './stack-detector'
37
38
 
38
39
  const execAsync = promisify(exec)
@@ -826,6 +827,16 @@ You are the ${name} expert for this project. Apply best practices for the detect
826
827
  }
827
828
 
828
829
  await fs.writeFile(statePath, JSON.stringify(state, null, 2), 'utf-8')
830
+
831
+ // Also generate local .prjct-state.md (PRJ-112)
832
+ try {
833
+ await localStateGenerator.generate(
834
+ this.projectPath,
835
+ state as import('../schemas/state').StateJson
836
+ )
837
+ } catch {
838
+ // Silently fail - local state is optional
839
+ }
829
840
  }
830
841
 
831
842
  // ==========================================================================
@@ -3,6 +3,9 @@
3
3
  *
4
4
  * Manages current task state via storage/state.json
5
5
  * Generates context/now.md for Claude
6
+ *
7
+ * Note: Local .prjct-state.md is generated by sync-service which has access
8
+ * to projectPath. This class only has projectId.
6
9
  */
7
10
 
8
11
  import { generateUUID } from '../schemas'