prjct-cli 0.45.5 → 0.47.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.
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Subtask Progress Display
3
+ *
4
+ * Clean, minimal visual display of subtask progress.
5
+ * No tables - just a clean list with animated status.
6
+ *
7
+ * @see PRJ-138
8
+ * @module utils/subtask-table
9
+ */
10
+
11
+ // ANSI codes
12
+ const RESET = '\x1b[0m'
13
+ const BOLD = '\x1b[1m'
14
+ const DIM = '\x1b[2m'
15
+ const GREEN = '\x1b[32m'
16
+ const YELLOW = '\x1b[33m'
17
+ const WHITE = '\x1b[37m'
18
+ const GRAY = '\x1b[90m'
19
+
20
+ // Color palette for domains (cycle through these)
21
+ const DOMAIN_COLOR_PALETTE = [
22
+ '\x1b[36m', // Cyan
23
+ '\x1b[35m', // Magenta
24
+ '\x1b[33m', // Yellow
25
+ '\x1b[34m', // Blue
26
+ '\x1b[32m', // Green
27
+ '\x1b[91m', // Light Red
28
+ '\x1b[95m', // Light Magenta
29
+ '\x1b[96m', // Light Cyan
30
+ ]
31
+
32
+ /**
33
+ * Get consistent color for a domain name using hash
34
+ * Same domain name always returns same color
35
+ */
36
+ function getDomainColor(domain: string): string {
37
+ let hash = 0
38
+ for (const char of domain) {
39
+ hash = (hash << 5) - hash + char.charCodeAt(0)
40
+ hash = hash & hash
41
+ }
42
+ const index = Math.abs(hash) % DOMAIN_COLOR_PALETTE.length
43
+ return DOMAIN_COLOR_PALETTE[index]
44
+ }
45
+
46
+ // Hide/show cursor
47
+ const HIDE_CURSOR = '\x1b[?25l'
48
+ const SHOW_CURSOR = '\x1b[?25h'
49
+
50
+ // Spinner frames (dots animation)
51
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
52
+
53
+ export type SubtaskStatus = 'pending' | 'in_progress' | 'completed' | 'failed' | 'blocked'
54
+
55
+ export interface SubtaskDisplay {
56
+ id: string
57
+ domain: string
58
+ description: string
59
+ status: SubtaskStatus
60
+ }
61
+
62
+ /**
63
+ * Format a single subtask line
64
+ */
65
+ function formatSubtaskLine(
66
+ index: number,
67
+ subtask: SubtaskDisplay,
68
+ spinnerFrame: string = '▶'
69
+ ): string {
70
+ const num = `${DIM}${String(index + 1).padStart(2)}${RESET}`
71
+ const domainColor = getDomainColor(subtask.domain)
72
+ const domain = `${domainColor}${subtask.domain.padEnd(10)}${RESET}`
73
+ const desc =
74
+ subtask.description.length > 32
75
+ ? subtask.description.slice(0, 29) + '...'
76
+ : subtask.description.padEnd(32)
77
+
78
+ let status: string
79
+ switch (subtask.status) {
80
+ case 'completed':
81
+ status = `${GREEN}✓ Complete${RESET}`
82
+ break
83
+ case 'in_progress':
84
+ status = `${YELLOW}${spinnerFrame} Working...${RESET}`
85
+ break
86
+ case 'pending':
87
+ status = `${GRAY}○ Pending${RESET}`
88
+ break
89
+ case 'failed':
90
+ status = `\x1b[31m✗ Failed${RESET}`
91
+ break
92
+ case 'blocked':
93
+ status = `${GRAY}⊘ Blocked${RESET}`
94
+ break
95
+ default:
96
+ status = `${GRAY}○ ${subtask.status}${RESET}`
97
+ }
98
+
99
+ return ` ${num} ${domain} ${desc} ${status}`
100
+ }
101
+
102
+ /**
103
+ * Render static subtask progress (no animation)
104
+ */
105
+ export function renderSubtaskProgress(subtasks: SubtaskDisplay[]): string {
106
+ if (subtasks.length === 0) return ''
107
+
108
+ const lines: string[] = []
109
+
110
+ lines.push('')
111
+ lines.push(` ${BOLD}${WHITE}SUBTASK PROGRESS${RESET}`)
112
+ lines.push(` ${DIM}${'─'.repeat(58)}${RESET}`)
113
+
114
+ for (let i = 0; i < subtasks.length; i++) {
115
+ lines.push(formatSubtaskLine(i, subtasks[i]))
116
+ }
117
+
118
+ lines.push('')
119
+
120
+ return lines.join('\n')
121
+ }
122
+
123
+ /**
124
+ * Print static subtask progress
125
+ */
126
+ export function printSubtaskProgress(subtasks: SubtaskDisplay[]): void {
127
+ console.log(renderSubtaskProgress(subtasks))
128
+ }
129
+
130
+ /**
131
+ * Animated subtask progress with spinner
132
+ * Returns a controller to update/stop the animation
133
+ */
134
+ export function createSubtaskAnimation(subtasks: SubtaskDisplay[]) {
135
+ let frameIndex = 0
136
+ let intervalId: ReturnType<typeof setInterval> | null = null
137
+ let lastOutput = ''
138
+
139
+ const render = () => {
140
+ const spinnerFrame = SPINNER_FRAMES[frameIndex % SPINNER_FRAMES.length]
141
+ const lines: string[] = []
142
+
143
+ lines.push('')
144
+ lines.push(` ${BOLD}${WHITE}SUBTASK PROGRESS${RESET}`)
145
+ lines.push(` ${DIM}${'─'.repeat(58)}${RESET}`)
146
+
147
+ for (let i = 0; i < subtasks.length; i++) {
148
+ lines.push(formatSubtaskLine(i, subtasks[i], spinnerFrame))
149
+ }
150
+
151
+ lines.push('')
152
+
153
+ return lines.join('\n')
154
+ }
155
+
156
+ const clear = () => {
157
+ if (lastOutput) {
158
+ const lineCount = lastOutput.split('\n').length
159
+ // Move up and clear each line
160
+ process.stdout.write(`\x1b[${lineCount}A`)
161
+ for (let i = 0; i < lineCount; i++) {
162
+ process.stdout.write('\x1b[2K\n')
163
+ }
164
+ process.stdout.write(`\x1b[${lineCount}A`)
165
+ }
166
+ }
167
+
168
+ const draw = () => {
169
+ clear()
170
+ lastOutput = render()
171
+ process.stdout.write(lastOutput)
172
+ frameIndex++
173
+ }
174
+
175
+ return {
176
+ /**
177
+ * Start the animation
178
+ */
179
+ start: () => {
180
+ process.stdout.write(HIDE_CURSOR)
181
+ lastOutput = render()
182
+ process.stdout.write(lastOutput)
183
+ intervalId = setInterval(draw, 80)
184
+ },
185
+
186
+ /**
187
+ * Update subtask status
188
+ */
189
+ update: (index: number, status: SubtaskStatus) => {
190
+ if (index >= 0 && index < subtasks.length) {
191
+ subtasks[index].status = status
192
+ }
193
+ },
194
+
195
+ /**
196
+ * Stop animation and show final state
197
+ */
198
+ stop: () => {
199
+ if (intervalId) {
200
+ clearInterval(intervalId)
201
+ intervalId = null
202
+ }
203
+ clear()
204
+ // Print final state with static icons
205
+ const finalLines: string[] = []
206
+ finalLines.push('')
207
+ finalLines.push(` ${BOLD}${WHITE}SUBTASK PROGRESS${RESET}`)
208
+ finalLines.push(` ${DIM}${'─'.repeat(58)}${RESET}`)
209
+ for (let i = 0; i < subtasks.length; i++) {
210
+ finalLines.push(formatSubtaskLine(i, subtasks[i], '▶'))
211
+ }
212
+ finalLines.push('')
213
+ process.stdout.write(finalLines.join('\n'))
214
+ process.stdout.write(SHOW_CURSOR)
215
+ },
216
+
217
+ /**
218
+ * Get current subtasks state
219
+ */
220
+ getSubtasks: () => [...subtasks],
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Simple progress line
226
+ * Output: "Progress: 2/4 subtasks complete"
227
+ */
228
+ export function renderProgressLine(completed: number, total: number): string {
229
+ return ` ${DIM}Progress:${RESET} ${completed}/${total} subtasks complete`
230
+ }
231
+
232
+ // Legacy exports for backwards compatibility
233
+ export const renderSubtaskTable = renderSubtaskProgress
234
+ export const printSubtaskTable = printSubtaskProgress
@@ -4,3 +4,4 @@
4
4
  */
5
5
 
6
6
  export * from './state-machine'
7
+ export * from './workflow-preferences'
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Workflow Preferences - Natural Language Driven Hooks
3
+ *
4
+ * Users configure workflow hooks via natural language.
5
+ * The LLM interprets preferences and stores them in memory.
6
+ *
7
+ * Scopes:
8
+ * - permanent: persisted via memorySystem.recordDecision()
9
+ * - session: in-memory Map, cleared on process exit
10
+ * - once: consumed after first use
11
+ *
12
+ * @see PRJ-137
13
+ * @module workflow/workflow-preferences
14
+ */
15
+
16
+ import { exec } from 'node:child_process'
17
+ import { promisify } from 'node:util'
18
+ import memorySystem from '../agentic/memory-system'
19
+
20
+ const execAsync = promisify(exec)
21
+
22
+ // ANSI colors
23
+ const DIM = '\x1b[2m'
24
+ const GREEN = '\x1b[32m'
25
+ const RED = '\x1b[31m'
26
+ const YELLOW = '\x1b[33m'
27
+ const RESET = '\x1b[0m'
28
+
29
+ export type PreferenceScope = 'permanent' | 'session' | 'once'
30
+ export type HookPhase = 'before' | 'after' | 'skip'
31
+ export type HookCommand = 'task' | 'done' | 'ship' | 'sync'
32
+
33
+ export interface WorkflowPreference {
34
+ hook: HookPhase
35
+ command: HookCommand
36
+ action: string // command to run or 'true' for skip
37
+ scope: PreferenceScope
38
+ createdAt: string
39
+ }
40
+
41
+ export interface HookResult {
42
+ success: boolean
43
+ failed?: string
44
+ skipped?: string[]
45
+ output?: string
46
+ }
47
+
48
+ // Session and once preferences (in-memory)
49
+ const sessionPreferences: Map<string, WorkflowPreference> = new Map()
50
+ const oncePreferences: Map<string, WorkflowPreference> = new Map()
51
+
52
+ /**
53
+ * Generate a key for a preference
54
+ */
55
+ function prefKey(hook: HookPhase, command: HookCommand): string {
56
+ return `workflow:${hook}_${command}`
57
+ }
58
+
59
+ /**
60
+ * Set a workflow preference
61
+ */
62
+ export async function setWorkflowPreference(
63
+ projectId: string,
64
+ pref: WorkflowPreference
65
+ ): Promise<void> {
66
+ const key = prefKey(pref.hook, pref.command)
67
+
68
+ switch (pref.scope) {
69
+ case 'permanent':
70
+ // Use memory system for persistent storage
71
+ await memorySystem.recordDecision(projectId, key, pref.action, 'workflow')
72
+ break
73
+ case 'session':
74
+ sessionPreferences.set(key, pref)
75
+ break
76
+ case 'once':
77
+ oncePreferences.set(key, pref)
78
+ break
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Get workflow preferences for a command
84
+ * Combines permanent + session + once preferences
85
+ */
86
+ export async function getWorkflowPreferences(
87
+ projectId: string,
88
+ command: HookCommand
89
+ ): Promise<{
90
+ before?: string
91
+ after?: string
92
+ skip?: boolean
93
+ }> {
94
+ const result: {
95
+ before?: string
96
+ after?: string
97
+ skip?: boolean
98
+ } = {}
99
+
100
+ // Check each phase
101
+ for (const phase of ['before', 'after', 'skip'] as const) {
102
+ const key = prefKey(phase, command)
103
+
104
+ // Check once first (highest priority)
105
+ const once = oncePreferences.get(key)
106
+ if (once) {
107
+ if (phase === 'skip') {
108
+ result.skip = once.action === 'true'
109
+ } else {
110
+ result[phase] = once.action
111
+ }
112
+ continue
113
+ }
114
+
115
+ // Check session
116
+ const session = sessionPreferences.get(key)
117
+ if (session) {
118
+ if (phase === 'skip') {
119
+ result.skip = session.action === 'true'
120
+ } else {
121
+ result[phase] = session.action
122
+ }
123
+ continue
124
+ }
125
+
126
+ // Check permanent (via memory system)
127
+ const permanent = await memorySystem.getSmartDecision(projectId, key)
128
+ if (permanent) {
129
+ if (phase === 'skip') {
130
+ result.skip = permanent === 'true'
131
+ } else {
132
+ result[phase] = permanent
133
+ }
134
+ }
135
+ }
136
+
137
+ return result
138
+ }
139
+
140
+ /**
141
+ * Run workflow hooks for a command
142
+ * Consumes 'once' preferences after use
143
+ */
144
+ export async function runWorkflowHooks(
145
+ projectId: string,
146
+ phase: 'before' | 'after',
147
+ command: HookCommand,
148
+ options: { projectPath?: string; skipHooks?: boolean } = {}
149
+ ): Promise<HookResult> {
150
+ if (options.skipHooks) {
151
+ return { success: true }
152
+ }
153
+
154
+ const prefs = await getWorkflowPreferences(projectId, command)
155
+
156
+ // Check if this step should be skipped
157
+ if (prefs.skip) {
158
+ return { success: true, skipped: [command] }
159
+ }
160
+
161
+ const action = prefs[phase]
162
+ if (!action) {
163
+ return { success: true }
164
+ }
165
+
166
+ // Consume 'once' preference if it exists
167
+ const key = prefKey(phase, command)
168
+ if (oncePreferences.has(key)) {
169
+ oncePreferences.delete(key)
170
+ }
171
+
172
+ console.log(`\n${DIM}Running ${phase}-${command}: ${action}${RESET}`)
173
+
174
+ try {
175
+ const startTime = Date.now()
176
+ await execAsync(action, {
177
+ timeout: 60000,
178
+ cwd: options.projectPath || process.cwd(),
179
+ env: { ...process.env },
180
+ })
181
+ const elapsed = Date.now() - startTime
182
+ const timeStr = elapsed > 1000 ? `${(elapsed / 1000).toFixed(1)}s` : `${elapsed}ms`
183
+ console.log(`${GREEN}✓${RESET} ${DIM}(${timeStr})${RESET}`)
184
+ return { success: true }
185
+ } catch (error) {
186
+ console.log(`${RED}✗ failed${RESET}`)
187
+ const errorMessage = (error as Error).message || 'Unknown error'
188
+ console.log(`${DIM}${errorMessage.split('\n')[0]}${RESET}`)
189
+ return { success: false, failed: action, output: errorMessage }
190
+ }
191
+ }
192
+
193
+ /**
194
+ * List all workflow preferences for a project
195
+ */
196
+ export async function listWorkflowPreferences(projectId: string): Promise<
197
+ Array<{
198
+ key: string
199
+ action: string
200
+ scope: PreferenceScope
201
+ }>
202
+ > {
203
+ const results: Array<{
204
+ key: string
205
+ action: string
206
+ scope: PreferenceScope
207
+ }> = []
208
+
209
+ const commands: HookCommand[] = ['task', 'done', 'ship', 'sync']
210
+ const phases: HookPhase[] = ['before', 'after', 'skip']
211
+
212
+ for (const command of commands) {
213
+ for (const phase of phases) {
214
+ const key = prefKey(phase, command)
215
+
216
+ // Check once
217
+ const once = oncePreferences.get(key)
218
+ if (once) {
219
+ results.push({ key: `${phase} ${command}`, action: once.action, scope: 'once' })
220
+ continue
221
+ }
222
+
223
+ // Check session
224
+ const session = sessionPreferences.get(key)
225
+ if (session) {
226
+ results.push({ key: `${phase} ${command}`, action: session.action, scope: 'session' })
227
+ continue
228
+ }
229
+
230
+ // Check permanent
231
+ const permanent = await memorySystem.getSmartDecision(projectId, key)
232
+ if (permanent) {
233
+ results.push({ key: `${phase} ${command}`, action: permanent, scope: 'permanent' })
234
+ }
235
+ }
236
+ }
237
+
238
+ return results
239
+ }
240
+
241
+ /**
242
+ * Remove a workflow preference
243
+ */
244
+ export async function removeWorkflowPreference(
245
+ projectId: string,
246
+ hook: HookPhase,
247
+ command: HookCommand
248
+ ): Promise<boolean> {
249
+ const key = prefKey(hook, command)
250
+
251
+ // Remove from all scopes
252
+ oncePreferences.delete(key)
253
+ sessionPreferences.delete(key)
254
+
255
+ // For permanent, we record an empty value
256
+ // (the memory system will treat low-confidence empty values as non-existent)
257
+ await memorySystem.recordDecision(projectId, key, '', 'workflow:remove')
258
+
259
+ return true
260
+ }
261
+
262
+ /**
263
+ * Format workflow preferences for display
264
+ */
265
+ export function formatWorkflowPreferences(
266
+ preferences: Array<{
267
+ key: string
268
+ action: string
269
+ scope: PreferenceScope
270
+ }>
271
+ ): string {
272
+ if (preferences.length === 0) {
273
+ return `${DIM}No workflow preferences configured.${RESET}\n\nSet one: "p. workflow antes de ship corre los tests"`
274
+ }
275
+
276
+ const lines: string[] = ['', 'WORKFLOW PREFERENCES', '────────────────────────────']
277
+
278
+ for (const pref of preferences) {
279
+ const scopeBadge =
280
+ pref.scope === 'permanent'
281
+ ? `${GREEN}permanent${RESET}`
282
+ : pref.scope === 'session'
283
+ ? `${YELLOW}session${RESET}`
284
+ : `${DIM}once${RESET}`
285
+
286
+ lines.push(` [${scopeBadge}] ${pref.key.padEnd(15)} → ${pref.action}`)
287
+ }
288
+
289
+ lines.push('')
290
+ lines.push(`${DIM}Modify: "p. workflow antes de ship corre npm test"${RESET}`)
291
+ lines.push(`${DIM}Remove: "p. workflow quita el hook de ship"${RESET}`)
292
+
293
+ return lines.join('\n')
294
+ }
295
+
296
+ /**
297
+ * Clear all session preferences (for testing)
298
+ */
299
+ export function clearSessionPreferences(): void {
300
+ sessionPreferences.clear()
301
+ oncePreferences.clear()
302
+ }
303
+
304
+ export default {
305
+ setWorkflowPreference,
306
+ getWorkflowPreferences,
307
+ runWorkflowHooks,
308
+ listWorkflowPreferences,
309
+ removeWorkflowPreference,
310
+ formatWorkflowPreferences,
311
+ clearSessionPreferences,
312
+ }