@miphamai/cli 0.3.0 → 0.5.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.
Files changed (74) hide show
  1. package/bin/mipham.ts +188 -0
  2. package/dist/mipham +0 -0
  3. package/package.json +9 -9
  4. package/skills/mipham/om-artifact.mipham-skill.md +69 -0
  5. package/skills/mipham/om-model-optimize.mipham-skill.md +9 -6
  6. package/skills/mipham/om-security.mipham-skill.md +11 -8
  7. package/skills/standard/doc-generator.SKILL.md +6 -1
  8. package/skills/standard/github-ops.SKILL.md +12 -7
  9. package/skills/standard/memory.SKILL.md +1 -0
  10. package/skills/standard/self-review.SKILL.md +1 -0
  11. package/skills/standard/superpower.SKILL.md +7 -7
  12. package/skills/standard/web-search.SKILL.md +3 -0
  13. package/src/agent/agent-context.ts +56 -0
  14. package/src/agent/agent-registry.ts +134 -0
  15. package/src/agent/sub-agent.ts +73 -89
  16. package/src/agent/types.ts +39 -0
  17. package/src/agent-view/agent-view-manager.ts +217 -0
  18. package/src/agent-view/dashboard.tsx +218 -0
  19. package/src/agent-view/session-peek.tsx +72 -0
  20. package/src/agent-view/session-row.tsx +70 -0
  21. package/src/artifacts/manifest.ts +101 -0
  22. package/src/artifacts/server.ts +374 -0
  23. package/src/artifacts/versioning.ts +127 -0
  24. package/src/core/context-compact.ts +50 -0
  25. package/src/core/context-drain.ts +54 -0
  26. package/src/core/context-microcompact.ts +132 -0
  27. package/src/core/context-snip.ts +74 -0
  28. package/src/core/context-token.ts +108 -0
  29. package/src/core/context.ts +169 -0
  30. package/src/core/engine.ts +207 -58
  31. package/src/core/hooks-config.ts +52 -0
  32. package/src/core/hooks-executor.ts +113 -0
  33. package/src/core/hooks.ts +90 -30
  34. package/src/core/instructions.ts +12 -1
  35. package/src/core/memory/memory-loader.ts +23 -0
  36. package/src/core/memory/memory-manager.ts +192 -0
  37. package/src/core/memory/memory-writer.ts +72 -0
  38. package/src/core/permission-config.ts +34 -0
  39. package/src/core/permission-rules.ts +66 -0
  40. package/src/core/permission.ts +224 -34
  41. package/src/index.tsx +30 -2
  42. package/src/mcp/transport.ts +42 -5
  43. package/src/plugin/plugin-loader.ts +36 -0
  44. package/src/plugin/plugin-manager.ts +125 -0
  45. package/src/plugin/plugin-validator.ts +41 -0
  46. package/src/providers/fetch-utils.ts +1 -3
  47. package/src/providers/openai-compat.ts +2 -11
  48. package/src/shared/constants.ts +8 -1
  49. package/src/shared/types.ts +82 -2
  50. package/src/skills/fork-executor.ts +40 -0
  51. package/src/skills/loader.ts +48 -2
  52. package/src/tools/agent/agent.ts +20 -11
  53. package/src/tools/agent/skill.ts +32 -2
  54. package/src/tools/artifact/artifact.ts +144 -0
  55. package/src/tools/computer/app-launcher.ts +39 -0
  56. package/src/tools/computer/browser.ts +48 -0
  57. package/src/tools/computer/computer-use.ts +88 -0
  58. package/src/tools/computer/playwright.d.ts +7 -0
  59. package/src/tools/computer/screenshot.ts +57 -0
  60. package/src/tools/index.ts +6 -0
  61. package/src/ui/app.tsx +20 -7
  62. package/src/ui/chat.tsx +9 -5
  63. package/src/ui/commands.ts +185 -40
  64. package/src/ui/input.tsx +203 -27
  65. package/src/ui/picker.tsx +2 -5
  66. package/src/ui/vim-motions.ts +96 -0
  67. package/src/workflow/budget.ts +33 -0
  68. package/src/workflow/journal.ts +105 -0
  69. package/src/workflow/primitives/agent.ts +51 -0
  70. package/src/workflow/primitives/parallel.ts +8 -0
  71. package/src/workflow/primitives/phase.ts +19 -0
  72. package/src/workflow/primitives/pipeline.ts +24 -0
  73. package/src/workflow/runtime.ts +87 -0
  74. package/src/workflow/sandbox.ts +75 -0
package/src/ui/input.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  import React, { useState, useEffect, useRef } from 'react'
2
- import { Box, Text } from 'ink'
2
+ import { Box, Text, useInput } from 'ink'
3
3
  import TextInput from 'ink-text-input'
4
+ import { VimMotionEngine, type VimMode } from './vim-motions.js'
4
5
 
5
6
  interface InputBarProps {
6
7
  onSubmit: (input: string) => void
@@ -10,34 +11,95 @@ interface InputBarProps {
10
11
  // ── Status verbs — English (Claude Code-aligned) + Chinese (Mipham originals) ──
11
12
 
12
13
  const STATUS_EN = [
13
- 'Doodling', 'Forging', 'Germinating', 'Mosmosing', 'Cerebrating',
14
- 'Boogieing', 'Finagling', 'Misting', 'Recombobulating', 'Slithering',
15
- 'Effecting', 'Roosting', 'Crystallizing', 'Canoodling', 'Billowing',
16
- 'Warping', 'Improvising', 'Metamorphing', 'Gusting', 'Whiring',
17
- 'Topsy-turvying', 'Flibbertigibetting', 'Wibbling', 'Thundering',
18
- 'Moseying', 'Julienning', 'Evaporating', 'Ruminating', 'Whisking',
19
- 'Scampering', 'Meandering', 'Concoting', 'Gesticulating', 'Flamebeing',
20
- 'Quantumizing', 'Waddling', 'Fluttering', 'Sprouting', 'Elucidating',
21
- 'Embellishing', 'Razzmatizing', 'Pondering', 'Cogitating', 'Garnishing',
22
- 'Actualizing', 'Zigzagging', 'Mustering', 'Frolicking', 'Hullaballooing',
23
- 'Doing', 'Fermenting', 'Considering', 'Skedaddling', 'Actioning',
24
- 'Orbiting', 'Perambulating', 'Drizzling', 'Schlepping', 'Ionizing',
14
+ 'Doodling',
15
+ 'Forging',
16
+ 'Germinating',
17
+ 'Mosmosing',
18
+ 'Cerebrating',
19
+ 'Boogieing',
20
+ 'Finagling',
21
+ 'Misting',
22
+ 'Recombobulating',
23
+ 'Slithering',
24
+ 'Effecting',
25
+ 'Roosting',
26
+ 'Crystallizing',
27
+ 'Canoodling',
28
+ 'Billowing',
29
+ 'Warping',
30
+ 'Improvising',
31
+ 'Metamorphing',
32
+ 'Gusting',
33
+ 'Whiring',
34
+ 'Topsy-turvying',
35
+ 'Flibbertigibetting',
36
+ 'Wibbling',
37
+ 'Thundering',
38
+ 'Moseying',
39
+ 'Julienning',
40
+ 'Evaporating',
41
+ 'Ruminating',
42
+ 'Whisking',
43
+ 'Scampering',
44
+ 'Meandering',
45
+ 'Concoting',
46
+ 'Gesticulating',
47
+ 'Flamebeing',
48
+ 'Quantumizing',
49
+ 'Waddling',
50
+ 'Fluttering',
51
+ 'Sprouting',
52
+ 'Elucidating',
53
+ 'Embellishing',
54
+ 'Razzmatizing',
55
+ 'Pondering',
56
+ 'Cogitating',
57
+ 'Garnishing',
58
+ 'Actualizing',
59
+ 'Zigzagging',
60
+ 'Mustering',
61
+ 'Frolicking',
62
+ 'Hullaballooing',
63
+ 'Doing',
64
+ 'Fermenting',
65
+ 'Considering',
66
+ 'Skedaddling',
67
+ 'Actioning',
68
+ 'Orbiting',
69
+ 'Perambulating',
70
+ 'Drizzling',
71
+ 'Schlepping',
72
+ 'Ionizing',
25
73
  'Scurrying',
26
74
  ]
27
75
 
28
76
  const STATUS_CN = [
29
- '思忖中', '推演中', '凝炼中', '熬煮中', '锻造中',
30
- '研磨中', '解构中', '冥思中', '编织中', '萃取中',
31
- '烹制中', '淬火中', '雕琢中', '融汇中', '觉照中',
32
- '运转中', '参详中', '化合中', '浸润中', '焙烤中',
77
+ '思忖中',
78
+ '推演中',
79
+ '凝炼中',
80
+ '熬煮中',
81
+ '锻造中',
82
+ '研磨中',
83
+ '解构中',
84
+ '冥思中',
85
+ '编织中',
86
+ '萃取中',
87
+ '烹制中',
88
+ '淬火中',
89
+ '雕琢中',
90
+ '融汇中',
91
+ '觉照中',
92
+ '运转中',
93
+ '参详中',
94
+ '化合中',
95
+ '浸润中',
96
+ '焙烤中',
33
97
  ]
34
98
 
35
99
  // Merge both — bilingual status verbs
36
100
  const STATUS_GERUNDS = [...STATUS_EN, ...STATUS_CN]
37
101
 
38
- const STATUS_PAST = [
39
- 'Brewed', 'Churned', 'Cooked', 'Sautéed', 'Cogitated', 'Crunched',
40
- ]
102
+ const STATUS_PAST = ['Brewed', 'Churned', 'Cooked', 'Sautéed', 'Cogitated', 'Crunched']
41
103
 
42
104
  function pick<T>(arr: T[]): T {
43
105
  return arr[Math.floor(Math.random() * arr.length)]!
@@ -76,6 +138,110 @@ export function InputBar({ onSubmit, isLoading }: InputBarProps) {
76
138
  prevLoading.current = isLoading
77
139
  }, [isLoading])
78
140
 
141
+ const [vimMode, setVimMode] = useState<VimMode>('insert')
142
+ // NOTE: Dual mode state — React state (vimMode) drives UI re-renders (prompt color,
143
+ // placeholder); engine mode (vimEngine.current.mode) drives logic inside useInput so
144
+ // the handler always reads the authoritative mode without stale-closure risk.
145
+ const vimEngine = useRef(new VimMotionEngine())
146
+ const vimPending = useRef<string | null>(null)
147
+ const [searchMode, setSearchMode] = useState(false)
148
+ const [searchQuery, setSearchQuery] = useState('')
149
+
150
+ // ── Vim motions: intercept keys in normal mode ──
151
+
152
+ useInput((input, key) => {
153
+ // Escape toggles between insert and normal mode
154
+ if (key.escape) {
155
+ // Cancel search mode if active
156
+ if (searchMode) {
157
+ setSearchMode(false)
158
+ setSearchQuery('')
159
+ return
160
+ }
161
+ // Clear any pending multi-key sequence
162
+ if (vimPending.current) {
163
+ vimPending.current = null
164
+ }
165
+ setVimMode((prev) => (prev === 'insert' ? 'normal' : 'insert'))
166
+ vimEngine.current.mode = vimEngine.current.mode === 'insert' ? 'normal' : 'insert'
167
+ return
168
+ }
169
+
170
+ if (vimEngine.current.mode !== 'normal') return
171
+
172
+ // Handle search mode — collect query characters
173
+ if (searchMode) {
174
+ if (key.return) {
175
+ const action = vimEngine.current.handleSearch(value, searchQuery)
176
+ if (action.text !== undefined) setValue(action.text)
177
+ // NOTE: action.cursor is not settable on ink-text-input — user repositions manually
178
+ setSearchMode(false)
179
+ setSearchQuery('')
180
+ return
181
+ }
182
+ if (key.backspace || key.delete) {
183
+ setSearchQuery((q) => q.slice(0, -1))
184
+ return
185
+ }
186
+ // Accumulate printable characters
187
+ if (input && input.length === 1 && !key.escape && !key.return) {
188
+ setSearchQuery((q) => q + input)
189
+ }
190
+ return
191
+ }
192
+
193
+ // Handle pending two-key sequences (dd, yy)
194
+ if (vimPending.current !== null) {
195
+ if (vimPending.current === 'd' && input === 'd') {
196
+ const action = vimEngine.current.handleDD(value)
197
+ setValue(action.text ?? value)
198
+ } else if (vimPending.current === 'y' && input === 'y') {
199
+ vimEngine.current.handleYY(value)
200
+ }
201
+ // Always clear pending — even when second key doesn't match
202
+ vimPending.current = null
203
+ return
204
+ }
205
+
206
+ // Handle p (paste) — pastes clipboard at cursor position
207
+ if (input === 'p') {
208
+ const action = vimEngine.current.handlePaste(value, value.length)
209
+ if (action.text !== undefined) setValue(action.text)
210
+ return
211
+ }
212
+
213
+ // Handle u (undo)
214
+ if (input === 'u') {
215
+ const action = vimEngine.current.handleUndo(value)
216
+ if (action.text !== undefined) setValue(action.text)
217
+ return
218
+ }
219
+
220
+ // Handle / (enter search mode)
221
+ if (input === '/') {
222
+ setSearchMode(true)
223
+ setSearchQuery('')
224
+ return
225
+ }
226
+
227
+ // Handle single-key motions (h, j, k, l, w, b, 0, $, d, y)
228
+ const action = vimEngine.current.handleNormal(input, value, value.length)
229
+ if (!action) return
230
+
231
+ if (action.pending) {
232
+ vimPending.current = action.pending
233
+ return
234
+ }
235
+
236
+ if (action.text !== undefined) {
237
+ setValue(action.text)
238
+ }
239
+
240
+ // NOTE: action.cursor is returned by motions (h/j/k/l/w/b/0/$) but
241
+ // ink-text-input does not expose a programmatic cursor-position API.
242
+ // The cursor hint is informational only; the user repositions manually.
243
+ })
244
+
79
245
  const handleSubmit = (val: string) => {
80
246
  if (!val.trim() || isLoading) return
81
247
  onSubmit(val)
@@ -85,18 +251,28 @@ export function InputBar({ onSubmit, isLoading }: InputBarProps) {
85
251
  return (
86
252
  <Box marginTop={1}>
87
253
  <Box marginRight={1}>
88
- <Text color={isLoading ? 'yellow' : 'cyan'}>{'>'}</Text>
254
+ <Text color={vimMode === 'normal' ? 'magenta' : isLoading ? 'yellow' : 'cyan'}>
255
+ {vimMode === 'normal' ? ':' : '>'}
256
+ </Text>
89
257
  </Box>
90
258
  <TextInput
91
259
  value={value}
92
- onChange={setValue}
260
+ onChange={(val) => {
261
+ // Block text changes during search mode — keys go to search query
262
+ if (searchMode) return
263
+ setValue(val)
264
+ }}
93
265
  onSubmit={handleSubmit}
94
266
  placeholder={
95
- isLoading
96
- ? `${verb}...`
97
- : completionVerb
98
- ? completionVerb
99
- : 'Type a message (Esc to cancel)...'
267
+ searchMode
268
+ ? `/${searchQuery}`
269
+ : vimMode === 'normal'
270
+ ? '[NORMAL] h/j/k/l w/b 0/$ dd yy p u / (Esc to insert)'
271
+ : isLoading
272
+ ? `${verb}...`
273
+ : completionVerb
274
+ ? completionVerb
275
+ : 'Type a message (Esc to cancel)...'
100
276
  }
101
277
  />
102
278
  </Box>
package/src/ui/picker.tsx CHANGED
@@ -1,6 +1,6 @@
1
- import React, { useState, useCallback, useEffect } from 'react'
1
+ import React, { useState, useCallback } from 'react'
2
2
  import { Box, Text, useInput } from 'ink'
3
- import type { MiphamConfig, ProviderConfig, ModelInfo } from '../shared/index.ts'
3
+ import type { MiphamConfig, ProviderConfig } from '../shared/index.ts'
4
4
 
5
5
  interface PickerProps {
6
6
  config: MiphamConfig
@@ -113,9 +113,6 @@ export function ModelPicker({
113
113
  // Provider labels
114
114
  const providerColor = (p: ProviderConfig) => (p.id === currentProvider ? 'green' : 'white')
115
115
 
116
- const modelColor = (m: ModelInfo) =>
117
- selectedProvider?.id === currentProvider && m.id === currentModel ? 'green' : 'white'
118
-
119
116
  return (
120
117
  <Box flexDirection="column" borderStyle="round" borderColor="cyan" padding={1}>
121
118
  {/* Title */}
@@ -0,0 +1,96 @@
1
+ export type VimMode = 'insert' | 'normal'
2
+
3
+ export class VimMotionEngine {
4
+ mode: VimMode = 'insert'
5
+ private clipboard = ''
6
+ private undoStack: string[] = []
7
+
8
+ /** Handle a keypress in normal mode. Returns new cursor position delta and any text mutation. */
9
+ handleNormal(key: string, text: string, cursor: number): VimAction | null {
10
+ switch (key) {
11
+ case 'h':
12
+ return { cursor: cursor - 1 }
13
+ case 'j':
14
+ return { cursor: text.length } // end of line (like $)
15
+ case 'k':
16
+ return { cursor: 0 } // start of line (like 0)
17
+ case 'l':
18
+ return { cursor: cursor + 1 }
19
+ case '0':
20
+ return { cursor: 0 }
21
+ case '$':
22
+ return { cursor: text.length }
23
+ case 'w':
24
+ return { cursor: this.nextWord(text, cursor) }
25
+ case 'b':
26
+ return { cursor: this.prevWord(text, cursor) }
27
+ case 'd':
28
+ return { pending: 'd' }
29
+ case 'y':
30
+ return { pending: 'y' }
31
+ default:
32
+ return null
33
+ }
34
+ }
35
+
36
+ /** Handle dd — delete entire line */
37
+ handleDD(text: string): VimAction {
38
+ this.clipboard = text
39
+ this.pushUndo(text)
40
+ return { text: '', cursor: 0 }
41
+ }
42
+
43
+ /** Handle yy — yank entire line */
44
+ handleYY(text: string): VimAction {
45
+ this.clipboard = text
46
+ return { cursor: 0 } // no change
47
+ }
48
+
49
+ /** Handle p — paste after cursor */
50
+ handlePaste(text: string, cursor: number): VimAction {
51
+ const newText = text.slice(0, cursor) + this.clipboard + text.slice(cursor)
52
+ this.pushUndo(text)
53
+ return { text: newText, cursor: cursor + this.clipboard.length }
54
+ }
55
+
56
+ /** Handle u — undo */
57
+ handleUndo(text: string): VimAction {
58
+ const prev = this.undoStack.pop()
59
+ if (prev !== undefined) {
60
+ return { text: prev, cursor: prev.length }
61
+ }
62
+ return { cursor: text.length }
63
+ }
64
+
65
+ /** Handle /search */
66
+ handleSearch(text: string, query: string): VimAction {
67
+ const idx = text.indexOf(query)
68
+ return idx >= 0 ? { cursor: idx } : { cursor: 0 }
69
+ }
70
+
71
+ private nextWord(text: string, cursor: number): number {
72
+ // Skip word characters, then skip whitespace
73
+ let i = cursor
74
+ while (i < text.length && text[i] !== ' ') i++
75
+ while (i < text.length && text[i] === ' ') i++
76
+ return i
77
+ }
78
+
79
+ private prevWord(text: string, cursor: number): number {
80
+ let i = cursor - 1
81
+ while (i > 0 && text[i] === ' ') i--
82
+ while (i > 0 && text[i] !== ' ') i--
83
+ return i > 0 ? i + 1 : 0
84
+ }
85
+
86
+ private pushUndo(text: string): void {
87
+ this.undoStack.push(text)
88
+ if (this.undoStack.length > 50) this.undoStack.shift()
89
+ }
90
+ }
91
+
92
+ export interface VimAction {
93
+ text?: string
94
+ cursor?: number
95
+ pending?: string
96
+ }
@@ -0,0 +1,33 @@
1
+ export interface Budget {
2
+ total: number | null
3
+ spent(): number
4
+ remaining(): number
5
+ consume(tokens: number): void
6
+ }
7
+
8
+ /**
9
+ * Create a token budget tracker.
10
+ * If totalTokens is null, the budget is unlimited.
11
+ */
12
+ export function createBudget(totalTokens: number | null): Budget {
13
+ let spent = 0
14
+
15
+ return {
16
+ total: totalTokens,
17
+
18
+ spent(): number {
19
+ return spent
20
+ },
21
+
22
+ remaining(): number {
23
+ return totalTokens === null ? Infinity : Math.max(0, totalTokens - spent)
24
+ },
25
+
26
+ consume(tokens: number): void {
27
+ spent += tokens
28
+ if (totalTokens !== null && spent >= totalTokens) {
29
+ throw new Error('Token budget exceeded')
30
+ }
31
+ },
32
+ }
33
+ }
@@ -0,0 +1,105 @@
1
+ import {
2
+ mkdirSync,
3
+ writeFileSync,
4
+ readFileSync,
5
+ existsSync,
6
+ appendFileSync,
7
+ readdirSync,
8
+ } from 'node:fs'
9
+ import { join } from 'node:path'
10
+ import { homedir } from 'node:os'
11
+
12
+ const WORKFLOW_DIR = join(homedir(), '.mipham', 'workflows')
13
+
14
+ export interface JournalEntry {
15
+ seq: number
16
+ type: 'agent' | 'phase' | 'log'
17
+ prompt?: string
18
+ opts?: Record<string, unknown>
19
+ result?: unknown
20
+ message?: string
21
+ }
22
+
23
+ export interface JournalState {
24
+ seq: number
25
+ phases: string[]
26
+ }
27
+
28
+ /**
29
+ * Create a journal for a workflow run.
30
+ * Returns the path to the run directory.
31
+ */
32
+ export function createJournal(runId: string, script: string): string {
33
+ const dir = join(WORKFLOW_DIR, runId)
34
+ mkdirSync(dir, { recursive: true })
35
+
36
+ writeFileSync(join(dir, 'script.js'), script, 'utf-8')
37
+ writeFileSync(join(dir, 'journal.jsonl'), '', 'utf-8')
38
+ writeFileSync(join(dir, 'state.json'), JSON.stringify({ seq: 0, phases: [] }), 'utf-8')
39
+
40
+ return dir
41
+ }
42
+
43
+ /**
44
+ * Append an agent call to the journal. Returns the new sequence number.
45
+ */
46
+ export function appendJournal(runId: string, entry: Omit<JournalEntry, 'seq'>): number {
47
+ const dir = join(WORKFLOW_DIR, runId)
48
+ const state = readState(runId)
49
+
50
+ const seq = state.seq + 1
51
+ const fullEntry: JournalEntry = { seq, ...entry }
52
+
53
+ appendFileSync(join(dir, 'journal.jsonl'), JSON.stringify(fullEntry) + '\n', 'utf-8')
54
+
55
+ state.seq = seq
56
+ writeFileSync(join(dir, 'state.json'), JSON.stringify(state), 'utf-8')
57
+
58
+ return seq
59
+ }
60
+
61
+ /**
62
+ * Load all journal entries for a run. Returns empty array if run not found.
63
+ */
64
+ export function loadJournal(runId: string): JournalEntry[] {
65
+ const journalPath = join(WORKFLOW_DIR, runId, 'journal.jsonl')
66
+ if (!existsSync(journalPath)) return []
67
+
68
+ const raw = readFileSync(journalPath, 'utf-8')
69
+ return raw
70
+ .split('\n')
71
+ .filter((line) => line.trim())
72
+ .map((line) => JSON.parse(line) as JournalEntry)
73
+ }
74
+
75
+ function readState(runId: string): JournalState {
76
+ const statePath = join(WORKFLOW_DIR, runId, 'state.json')
77
+ if (!existsSync(statePath)) return { seq: 0, phases: [] }
78
+ return JSON.parse(readFileSync(statePath, 'utf-8'))
79
+ }
80
+
81
+ /**
82
+ * Load the saved script for a run. Returns empty string if not found.
83
+ */
84
+ export function loadScript(runId: string): string {
85
+ const scriptPath = join(WORKFLOW_DIR, runId, 'script.js')
86
+ if (!existsSync(scriptPath)) return ''
87
+ return readFileSync(scriptPath, 'utf-8')
88
+ }
89
+
90
+ /**
91
+ * List all workflow run IDs.
92
+ */
93
+ export function listRuns(): string[] {
94
+ if (!existsSync(WORKFLOW_DIR)) return []
95
+ return readdirSync(WORKFLOW_DIR, { withFileTypes: true })
96
+ .filter((d) => d.isDirectory())
97
+ .map((d) => d.name)
98
+ }
99
+
100
+ /**
101
+ * Get the journal directory path for a run.
102
+ */
103
+ export function getRunDir(runId: string): string {
104
+ return join(WORKFLOW_DIR, runId)
105
+ }
@@ -0,0 +1,51 @@
1
+ import { SubAgent } from '../../agent/sub-agent'
2
+ import type { ProviderRegistry } from '../../providers/registry'
3
+ import type { ToolDefinition } from '../../shared/index.ts'
4
+
5
+ export interface WorkflowAgentOpts {
6
+ label?: string
7
+ phase?: string
8
+ schema?: object
9
+ model?: string
10
+ provider?: string
11
+ effort?: 'low' | 'medium' | 'high' | 'max'
12
+ }
13
+
14
+ /**
15
+ * Workflow agent() primitive — creates a SubAgent with optional
16
+ * provider/model override and structured output schema.
17
+ */
18
+ export async function workflowAgent(
19
+ prompt: string,
20
+ registry: ProviderRegistry,
21
+ toolRegistry: Map<string, ToolDefinition>,
22
+ opts: WorkflowAgentOpts = {},
23
+ ): Promise<unknown> {
24
+ // If provider override, switch temporarily
25
+ if (opts.provider) {
26
+ registry.switchProvider(opts.provider, opts.model)
27
+ } else if (opts.model) {
28
+ registry.switchProvider(registry.getActive().config.id, opts.model)
29
+ }
30
+
31
+ const sub = new SubAgent(registry, toolRegistry)
32
+ const result = await sub.execute(prompt, opts.label || 'workflow-agent', {
33
+ type: 'general',
34
+ modelOverride: opts.model,
35
+ allowedTools: undefined, // use all tools by default
36
+ })
37
+
38
+ // If schema is provided, attempt to parse the result as JSON
39
+ if (opts.schema) {
40
+ try {
41
+ const parsed = JSON.parse(result)
42
+ // Basic validation: return parsed object
43
+ return parsed
44
+ } catch {
45
+ // Return raw text if JSON parse fails
46
+ return { raw: result }
47
+ }
48
+ }
49
+
50
+ return result
51
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * parallel() — barrier: executes all thunks concurrently, waits for all.
3
+ * Failed thunks resolve to null. Never throws.
4
+ */
5
+ export async function parallel<T>(thunks: Array<() => Promise<T>>): Promise<(T | null)[]> {
6
+ const results = await Promise.allSettled(thunks.map((t) => t()))
7
+ return results.map((r) => (r.status === 'fulfilled' ? r.value : null))
8
+ }
@@ -0,0 +1,19 @@
1
+ let currentPhase: string = ''
2
+
3
+ /**
4
+ * Set the current workflow phase title.
5
+ * In a real implementation, this emits to a progress tracker.
6
+ */
7
+ export function phase(title: string): void {
8
+ currentPhase = title
9
+ }
10
+
11
+ /** Get the current workflow phase title. */
12
+ export function getCurrentPhase(): string {
13
+ return currentPhase
14
+ }
15
+
16
+ /** Reset the current phase state (useful for testing). */
17
+ export function resetPhase(): void {
18
+ currentPhase = ''
19
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * pipeline() — no barrier: each item flows through all stages independently.
3
+ * Item A can be in stage 3 while item B is still in stage 1.
4
+ * Failed items become null and skip remaining stages.
5
+ */
6
+ export async function pipeline<T, R>(
7
+ items: T[],
8
+ ...stages: Array<(item: T, index: number, original: T) => Promise<R>>
9
+ ): Promise<(R | null)[]> {
10
+ // Process each item through all stages concurrently
11
+ return Promise.all(
12
+ items.map(async (item, index) => {
13
+ let current: unknown = item
14
+ for (const stage of stages) {
15
+ try {
16
+ current = await stage(current as T, index, item)
17
+ } catch {
18
+ return null // item failed, skip remaining stages
19
+ }
20
+ }
21
+ return current as R
22
+ }),
23
+ )
24
+ }