@haoyiyin/workflow 0.2.0 → 0.2.3

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 (62) hide show
  1. package/package.json +15 -10
  2. package/scripts/postinstall.js +2 -2
  3. package/src/agents/contracts.ts +559 -0
  4. package/src/agents/dispatcher-enhanced.ts +350 -0
  5. package/src/agents/dispatcher.ts +680 -0
  6. package/src/agents/index.ts +48 -0
  7. package/src/agents/resilience.ts +255 -0
  8. package/src/agents/token-budget.ts +83 -0
  9. package/src/agents/types.ts +73 -0
  10. package/src/guard/main-agent.ts +245 -0
  11. package/src/hooks/builtin/index.ts +8 -0
  12. package/src/hooks/builtin/on-error.ts +23 -0
  13. package/src/hooks/builtin/post-execute.ts +40 -0
  14. package/src/hooks/builtin/post-plan.ts +23 -0
  15. package/src/hooks/builtin/pre-execute.ts +30 -0
  16. package/src/hooks/builtin/pre-plan.ts +26 -0
  17. package/src/hooks/index.ts +7 -0
  18. package/src/hooks/loader.ts +98 -0
  19. package/src/hooks/manager.ts +99 -0
  20. package/src/hooks/types-enhanced.ts +38 -0
  21. package/src/hooks/types.ts +35 -0
  22. package/src/index.ts +127 -0
  23. package/src/persistence/index.ts +17 -0
  24. package/src/persistence/plan-md.ts +141 -0
  25. package/src/persistence/state-md.ts +167 -0
  26. package/src/persistence/types.ts +89 -0
  27. package/src/router/classifier.ts +610 -0
  28. package/src/router/guard.ts +483 -0
  29. package/src/router/index.ts +22 -0
  30. package/src/router/router.ts +108 -0
  31. package/src/router/types.ts +127 -0
  32. package/src/skills/agents-md/SKILL.md +45 -0
  33. package/src/skills/agents-md/index.ts +33 -0
  34. package/src/skills/execute-plan/SKILL.md +60 -0
  35. package/src/skills/execute-plan/index.ts +970 -0
  36. package/src/skills/index.ts +13 -0
  37. package/src/skills/quick-task/SKILL.md +54 -0
  38. package/src/skills/quick-task/index.ts +346 -0
  39. package/src/skills/registry.ts +59 -0
  40. package/src/skills/review-diff/SKILL.md +53 -0
  41. package/src/skills/review-diff/index.ts +394 -0
  42. package/src/skills/skill.ts +59 -0
  43. package/src/skills/systematic-debugging/SKILL.md +56 -0
  44. package/src/skills/systematic-debugging/index.ts +404 -0
  45. package/src/skills/tdd/SKILL.md +52 -0
  46. package/src/skills/tdd/index.ts +409 -0
  47. package/src/skills/to-plan/SKILL.md +56 -0
  48. package/src/skills/to-plan/index-enhanced.ts +551 -0
  49. package/src/skills/to-plan/index.ts +586 -0
  50. package/src/skills/types.ts +47 -0
  51. package/src/state/cleanup.ts +118 -0
  52. package/src/state/index.ts +8 -0
  53. package/src/state/manager.ts +96 -0
  54. package/src/state/persistence.ts +77 -0
  55. package/src/state/types.ts +30 -0
  56. package/src/state/validator.ts +78 -0
  57. package/src/types.ts +102 -0
  58. package/src/utils/compress.ts +347 -0
  59. package/src/utils/git.ts +82 -0
  60. package/src/utils/index.ts +6 -0
  61. package/src/utils/logger.ts +23 -0
  62. package/src/utils/paths.ts +55 -0
@@ -0,0 +1,347 @@
1
+ /**
2
+ * Text Compression Utility - Compresses verbose output to terse prose
3
+ *
4
+ * Pure text transformation. No subagent dispatch needed.
5
+ * Use case: Take verbose AI output and compress it to essential facts.
6
+ *
7
+ * Pattern: Input text → Compression rules → Terse output
8
+ */
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Schemas
12
+ // ---------------------------------------------------------------------------
13
+
14
+ export interface CompressOptions {
15
+ style: 'terse' | 'bullets' | 'oneline'
16
+ maxLength?: number
17
+ }
18
+
19
+ export interface CompressResult {
20
+ compressed: string
21
+ originalLength: number
22
+ compressedLength: number
23
+ compressionRatio: number
24
+ sectionsRemoved: number
25
+ }
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Compression rules (pure functions, immutable)
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /** Words and phrases that can be removed without losing meaning */
32
+ const FILLER_PATTERNS: ReadonlyArray<{ pattern: RegExp; replacement: string }> = [
33
+ { pattern: /\b(?:basically|essentially|fundamentally)\b/gi, replacement: '' },
34
+ { pattern: /\b(?:actually|literally|virtually)\b/gi, replacement: '' },
35
+ { pattern: /\b(?:obviously|clearly|evidently)\b/gi, replacement: '' },
36
+ { pattern: /\b(?:interestingly|notably|importantly)\b/gi, replacement: '' },
37
+ { pattern: /\b(?:I think|I believe|in my opinion|it seems that|it appears that)\b/gi, replacement: '' },
38
+ { pattern: /\b(?:as mentioned|as noted|as discussed|as previously stated)\b/gi, replacement: '' },
39
+ { pattern: /\b(?:it is worth noting that|it should be noted that|note that)\b/gi, replacement: '' },
40
+ { pattern: /\b(?:in order to)\b/gi, replacement: 'to' },
41
+ { pattern: /\b(?:due to the fact that)\b/gi, replacement: 'because' },
42
+ { pattern: /\b(?:at this point in time)\b/gi, replacement: 'now' },
43
+ { pattern: /\b(?:in the event that)\b/gi, replacement: 'if' },
44
+ { pattern: /\b(?:a number of)\b/gi, replacement: 'several' },
45
+ { pattern: /\b(?:the majority of)\b/gi, replacement: 'most' },
46
+ { pattern: /\b(?:a large number of)\b/gi, replacement: 'many' },
47
+ { pattern: /\b(?:in the near future)\b/gi, replacement: 'soon' },
48
+ { pattern: /\b(?:has the ability to)\b/gi, replacement: 'can' },
49
+ { pattern: /\b(?:is able to)\b/gi, replacement: 'can' },
50
+ { pattern: /\b(?:is capable of)\b/gi, replacement: 'can' },
51
+ { pattern: /\b(?:make a decision)\b/gi, replacement: 'decide' },
52
+ { pattern: /\b(?:take into account)\b/gi, replacement: 'consider' },
53
+ { pattern: /\b(?:with regard to|in regard to|with respect to)\b/gi, replacement: 'about' },
54
+ { pattern: /\b(?:in the process of)\b/gi, replacement: '' },
55
+ { pattern: /\b(?:please note that)\b/gi, replacement: '' },
56
+ { pattern: /\b(?:I would like to|I want to|I will now|let me)\b/gi, replacement: '' },
57
+ { pattern: /\b(?:here is|here are|below is|below are)\b/gi, replacement: '' },
58
+ { pattern: /\b(?:the following(?: is| are)?)\b/gi, replacement: '' },
59
+ ]
60
+
61
+ /** Section headers that can be collapsed */
62
+ const SECTION_PATTERNS: ReadonlyArray<RegExp> = [
63
+ /^#{1,4}\s+Introduction\b/gim,
64
+ /^#{1,4}\s+Overview\b/gim,
65
+ /^#{1,4}\s+Background\b/gim,
66
+ /^#{1,4}\s+Context\b/gim,
67
+ /^#{1,4}\s+Disclaimer\b/gim,
68
+ /^#{1,4}\s+Note\b/gim,
69
+ /^#{1,4}\s+Notes?\b/gim,
70
+ /^#{1,4}\s+Additional Information\b/gim,
71
+ ]
72
+
73
+ /** Lines that are purely transitional and can be removed */
74
+ const TRANSITION_PATTERNS: ReadonlyArray<RegExp> = [
75
+ /^(?:Now|Next|Then|Finally|Firstly|Secondly|Thirdly|Lastly),\s*.+$/gim,
76
+ /^(?:Moving on|Let'?s move on|Let'?s continue).+$/gim,
77
+ /^(?:To summarize|In summary|To conclude|In conclusion|Wrapping up).*$/gim,
78
+ /^(?:Without further ado|As you can see).*$/gim,
79
+ ]
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Core compression functions (pure, immutable)
83
+ // ---------------------------------------------------------------------------
84
+
85
+ /**
86
+ * Apply all filler-word replacements to the text.
87
+ * Each rule is applied sequentially on the accumulated result (immutable pattern).
88
+ */
89
+ function removeFillerWords(text: string): string {
90
+ let result = text
91
+ for (const { pattern, replacement } of FILLER_PATTERNS) {
92
+ result = result.replace(pattern, replacement)
93
+ }
94
+ return result
95
+ }
96
+
97
+ /**
98
+ * Remove known section headers that add no information.
99
+ */
100
+ function removeBoilerplateSections(text: string): string {
101
+ let result = text
102
+ for (const pattern of SECTION_PATTERNS) {
103
+ // Remove the header line and the following paragraph until next header or blank line
104
+ result = result.replace(
105
+ new RegExp(`${pattern.source}\\n*(?:[^#\\n]\\n*)*`, 'gim'),
106
+ '',
107
+ )
108
+ }
109
+ return result
110
+ }
111
+
112
+ /**
113
+ * Remove transitional sentences that don't carry content.
114
+ */
115
+ function removeTransitions(text: string): string {
116
+ let result = text
117
+ for (const pattern of TRANSITION_PATTERNS) {
118
+ result = result.replace(pattern, '')
119
+ }
120
+ return result
121
+ }
122
+
123
+ /**
124
+ * Collapse multiple blank lines into a single blank line.
125
+ */
126
+ function collapseWhitespace(text: string): string {
127
+ return text
128
+ .replace(/\n{3,}/g, '\n\n')
129
+ .replace(/[ \t]+/g, ' ')
130
+ .replace(/^[ \t]+/gm, '')
131
+ .replace(/[ \t]+$/gm, '')
132
+ .trim()
133
+ }
134
+
135
+ /**
136
+ * Collapse repeated sentences (simple cosine of word overlap).
137
+ */
138
+ function deduplicateSentences(text: string): string {
139
+ const sentences = text.match(/[^.!?\n]+[.!?]?/g) ?? []
140
+ if (sentences.length <= 1) return text
141
+
142
+ const seen = new Set<string>()
143
+ const kept: string[] = []
144
+
145
+ for (const sentence of sentences) {
146
+ // Normalize for comparison: lowercase, strip punctuation, sort words
147
+ const normalized = sentence
148
+ .toLowerCase()
149
+ .replace(/[^a-z0-9\s]/g, '')
150
+ .trim()
151
+ .split(/\s+/)
152
+ .filter((w) => w.length > 2)
153
+ .sort()
154
+ .join(' ')
155
+
156
+ if (normalized.length < 10) {
157
+ kept.push(sentence)
158
+ continue
159
+ }
160
+
161
+ if (seen.has(normalized)) continue
162
+
163
+ // Check for near-duplicates (Jaccard similarity)
164
+ let isDuplicate = false
165
+ const words = new Set(normalized.split(' '))
166
+ for (const existing of seen) {
167
+ const existingWords = new Set(existing.split(' '))
168
+ const intersection = [...words].filter((w) => existingWords.has(w)).length
169
+ const union = new Set([...words, ...existingWords]).size
170
+ if (union > 0 && intersection / union > 0.7) {
171
+ isDuplicate = true
172
+ break
173
+ }
174
+ }
175
+
176
+ if (!isDuplicate) {
177
+ seen.add(normalized)
178
+ kept.push(sentence)
179
+ }
180
+ }
181
+
182
+ return kept.join(' ')
183
+ }
184
+
185
+ /**
186
+ * Extract the key sentences: first sentence, sentences with numbers/data,
187
+ * sentences with key action verbs.
188
+ */
189
+ function extractKeySentences(text: string): string {
190
+ const sentences = text.match(/[^.!?\n]+[.!?]?/g) ?? []
191
+ if (sentences.length <= 3) return text
192
+
193
+ const scored = sentences.map((s, i) => {
194
+ let score = 0
195
+ // First sentence is important
196
+ if (i === 0) score += 3
197
+ // Sentences with data/numbers
198
+ if (/\d/.test(s)) score += 2
199
+ // Sentences with code indicators
200
+ if (/[`'']|```|function|class|import|export|const|let|var/.test(s)) score += 2
201
+ // Sentences with action verbs
202
+ if (/\b(must|should|need|require|fix|change|update|create|delete|remove|add)\b/i.test(s)) score += 2
203
+ // Short sentences (likely statements of fact)
204
+ if (s.length < 80) score += 1
205
+ // Last sentence (conclusion)
206
+ if (i === sentences.length - 1) score += 2
207
+ return { sentence: s, score }
208
+ })
209
+
210
+ // Keep top-scoring sentences (at least 2, at most 60% of original)
211
+ const keepCount = Math.max(2, Math.ceil(sentences.length * 0.6))
212
+ const sorted = [...scored].sort((a, b) => b.score - a.score)
213
+ const kept = sorted.slice(0, keepCount)
214
+ // Restore original order
215
+ const keptSet = new Set(kept.map((k) => k.sentence))
216
+ return sentences.filter((s) => keptSet.has(s)).join(' ')
217
+ }
218
+
219
+ /**
220
+ * Convert prose to bullet points by splitting on sentence boundaries
221
+ * and prefixing each meaningful chunk with "-".
222
+ */
223
+ function toBullets(text: string): string {
224
+ const sentences = text.match(/[^.!?\n]+[.!?]?/g) ?? []
225
+ if (sentences.length <= 1) return text
226
+
227
+ return sentences
228
+ .map((s) => s.trim())
229
+ .filter((s) => s.length > 5)
230
+ .map((s) => `- ${s}`)
231
+ .join('\n')
232
+ }
233
+
234
+ /**
235
+ * Convert text to a single line by replacing all newlines with spaces.
236
+ */
237
+ function toOneLine(text: string): string {
238
+ return text.replace(/\n+/g, ' ').replace(/\s{2,}/g, ' ').trim()
239
+ }
240
+
241
+ /**
242
+ * Truncate text to maxLength characters, preserving word boundaries.
243
+ */
244
+ function truncateToMaxLength(text: string, maxLength: number): string {
245
+ if (text.length <= maxLength) return text
246
+ const truncated = text.slice(0, maxLength)
247
+ const lastSpace = truncated.lastIndexOf(' ')
248
+ if (lastSpace > maxLength * 0.8) {
249
+ return truncated.slice(0, lastSpace) + '...'
250
+ }
251
+ return truncated + '...'
252
+ }
253
+
254
+ /**
255
+ * Count the number of sections (markdown headers) in the text.
256
+ */
257
+ function countSections(text: string): number {
258
+ const matches = text.match(/^#{1,4}\s+.+$/gm)
259
+ return matches ? matches.length : 0
260
+ }
261
+
262
+ // ---------------------------------------------------------------------------
263
+ // Main compression pipeline
264
+ // ---------------------------------------------------------------------------
265
+
266
+ /**
267
+ * Compress verbose text through a pipeline of transformations.
268
+ * All steps are pure functions that return new strings (immutable pattern).
269
+ */
270
+ export function compressText(text: string, options: CompressOptions): CompressResult {
271
+ const originalLength = text.length
272
+ const sectionsBefore = countSections(text)
273
+
274
+ // Stage 1: Remove filler words and verbose phrases
275
+ let result = removeFillerWords(text)
276
+
277
+ // Stage 2: Remove boilerplate sections
278
+ result = removeBoilerplateSections(result)
279
+
280
+ // Stage 3: Remove transitional fluff
281
+ result = removeTransitions(result)
282
+
283
+ // Stage 4: Deduplicate near-identical sentences
284
+ result = deduplicateSentences(result)
285
+
286
+ // Stage 5: Extract key sentences when text is lengthy
287
+ if (result.length > 500) {
288
+ result = extractKeySentences(result)
289
+ }
290
+
291
+ // Stage 6: Apply style formatting
292
+ switch (options.style) {
293
+ case 'bullets':
294
+ result = toBullets(result)
295
+ break
296
+ case 'oneline':
297
+ result = toOneLine(result)
298
+ break
299
+ case 'terse':
300
+ // Terse is the default prose after all prior stages
301
+ break
302
+ }
303
+
304
+ // Stage 7: Collapse whitespace
305
+ result = collapseWhitespace(result)
306
+
307
+ // Stage 8: Truncate if needed
308
+ if (options.maxLength && result.length > options.maxLength) {
309
+ result = truncateToMaxLength(result, options.maxLength)
310
+ }
311
+
312
+ const sectionsAfter = countSections(result)
313
+ const compressedLength = result.length
314
+ const compressionRatio =
315
+ originalLength > 0
316
+ ? Math.round(((originalLength - compressedLength) / originalLength) * 100) / 100
317
+ : 0
318
+
319
+ return {
320
+ compressed: result,
321
+ originalLength,
322
+ compressedLength,
323
+ compressionRatio,
324
+ sectionsRemoved: Math.max(0, sectionsBefore - sectionsAfter),
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Convenience function for terse compression
330
+ */
331
+ export function compressTerse(text: string, maxLength?: number): string {
332
+ return compressText(text, { style: 'terse', maxLength }).compressed
333
+ }
334
+
335
+ /**
336
+ * Convenience function for bullet compression
337
+ */
338
+ export function compressBullets(text: string, maxLength?: number): string {
339
+ return compressText(text, { style: 'bullets', maxLength }).compressed
340
+ }
341
+
342
+ /**
343
+ * Convenience function for one-line compression
344
+ */
345
+ export function compressOneLine(text: string, maxLength?: number): string {
346
+ return compressText(text, { style: 'oneline', maxLength }).compressed
347
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Git utility functions
3
+ */
4
+ import { execSync } from 'child_process'
5
+
6
+ export function getCurrentBranch(cwd: string): string {
7
+ try {
8
+ return execSync('git rev-parse --abbrev-ref HEAD', {
9
+ cwd,
10
+ encoding: 'utf-8',
11
+ }).trim()
12
+ } catch {
13
+ throw new Error('Not a git repository')
14
+ }
15
+ }
16
+
17
+ export function getBaseBranch(cwd: string): string | null {
18
+ try {
19
+ // Try main first
20
+ execSync('git rev-parse --verify main', { cwd })
21
+ return 'main'
22
+ } catch {
23
+ try {
24
+ // Fallback to master
25
+ execSync('git rev-parse --verify master', { cwd })
26
+ return 'master'
27
+ } catch {
28
+ return null
29
+ }
30
+ }
31
+ }
32
+
33
+ export function isWorktree(cwd: string): boolean {
34
+ try {
35
+ const gitDir = execSync('git rev-parse --git-dir', {
36
+ cwd,
37
+ encoding: 'utf-8',
38
+ }).trim()
39
+ return gitDir.includes('.git/worktrees')
40
+ } catch {
41
+ return false
42
+ }
43
+ }
44
+
45
+ export function getWorktreePath(cwd: string): string | null {
46
+ if (!isWorktree(cwd)) return null
47
+
48
+ try {
49
+ return execSync('git rev-parse --show-toplevel', {
50
+ cwd,
51
+ encoding: 'utf-8',
52
+ }).trim()
53
+ } catch {
54
+ return null
55
+ }
56
+ }
57
+
58
+ export function hasUncommittedChanges(cwd: string): boolean {
59
+ try {
60
+ const status = execSync('git status --porcelain', {
61
+ cwd,
62
+ encoding: 'utf-8',
63
+ })
64
+ return status.trim().length > 0
65
+ } catch {
66
+ return false
67
+ }
68
+ }
69
+
70
+ export function commitChanges(cwd: string, message: string): void {
71
+ execSync('git add -A', { cwd })
72
+ execSync(`git commit -m "${message}"`, { cwd })
73
+ }
74
+
75
+ export function mergeBranch(cwd: string, branch: string, base: string): void {
76
+ execSync(`git checkout ${base}`, { cwd })
77
+ execSync(`git merge ${branch}`, { cwd })
78
+ }
79
+
80
+ export function deleteBranch(cwd: string, branch: string): void {
81
+ execSync(`git branch -D ${branch}`, { cwd })
82
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Utilities index
3
+ */
4
+ export * from './logger.js'
5
+ export * from './paths.js'
6
+ export * from './git.js'
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Logger utility for yi-workflow
3
+ */
4
+ import type { Logger } from '../types.js'
5
+
6
+ export function createLogger(verbose = false): Logger {
7
+ return {
8
+ info: (message: string, ...args: unknown[]) => {
9
+ console.log(`[INFO] ${message}`, ...args)
10
+ },
11
+ warn: (message: string, ...args: unknown[]) => {
12
+ console.warn(`[WARN] ${message}`, ...args)
13
+ },
14
+ error: (message: string, ...args: unknown[]) => {
15
+ console.error(`[ERROR] ${message}`, ...args)
16
+ },
17
+ debug: (message: string, ...args: unknown[]) => {
18
+ if (verbose) {
19
+ console.log(`[DEBUG] ${message}`, ...args)
20
+ }
21
+ },
22
+ }
23
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Path management utilities
3
+ */
4
+ import { join, dirname, resolve } from 'path'
5
+ import { fileURLToPath } from 'url'
6
+ import { homedir } from 'os'
7
+
8
+ const __filename = fileURLToPath(import.meta.url)
9
+ const __dirname = dirname(__filename)
10
+
11
+ export function getPackageRoot(): string {
12
+ return resolve(__dirname, '../..')
13
+ }
14
+
15
+ export function getConfigPath(): string {
16
+ return join(homedir(), '.config', 'yi-workflow', 'config.json')
17
+ }
18
+
19
+ export function getCachePath(): string {
20
+ return join(homedir(), '.cache', 'yi-workflow')
21
+ }
22
+
23
+ export function getPiPath(cwd: string): string {
24
+ return join(cwd, '.pi')
25
+ }
26
+
27
+ export function getPlansPath(cwd: string): string {
28
+ return join(cwd, '.pi', 'plans')
29
+ }
30
+
31
+ export function getStatePath(cwd: string): string {
32
+ return join(cwd, '.pi', 'yi-workflow', 'state')
33
+ }
34
+
35
+ export function getActivePlansPath(cwd: string): string {
36
+ return join(getPlansPath(cwd), 'active')
37
+ }
38
+
39
+ export function getArchivePlansPath(cwd: string): string {
40
+ return join(getPlansPath(cwd), 'archive')
41
+ }
42
+
43
+ export function getActiveStatePath(cwd: string): string {
44
+ return join(getStatePath(cwd), 'active')
45
+ }
46
+
47
+ export function getArchiveStatePath(cwd: string): string {
48
+ return join(getStatePath(cwd), 'archive')
49
+ }
50
+
51
+ export function generatePlanFileName(topic: string): string {
52
+ const date = new Date().toISOString().split('T')[0]
53
+ const sanitized = topic.toLowerCase().replace(/[^a-z0-9]+/g, '-')
54
+ return `${date}-${sanitized}-implementation-plan.md`
55
+ }