prjct-cli 0.46.0 → 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,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
+ }