workflow-ai 1.0.64 → 1.0.66

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 (160) hide show
  1. package/README.md +377 -277
  2. package/configs/agent-health-rules.yaml +75 -0
  3. package/configs/pipeline.yaml +24 -7
  4. package/package.json +1 -1
  5. package/src/init.mjs +20 -3
  6. package/src/lib/agent-health-registry.mjs +245 -0
  7. package/src/lib/agent-spawner.mjs +47 -6
  8. package/src/lib/artifact-snapshot.mjs +233 -0
  9. package/src/lib/error-classifier.mjs +311 -0
  10. package/src/lib/test-error-classifier.mjs +60 -0
  11. package/src/lib/test-extends.mjs +58 -0
  12. package/src/lib/test-version.mjs +21 -0
  13. package/src/runner.mjs +215 -58
  14. package/src/scripts/move-to-review.js +5 -7
  15. package/src/scripts/reset-agent-health.js +62 -0
  16. package/src/skills/coach/SKILL.md +1 -0
  17. package/src/skills/coach/tests/cases/TC-COACH-001/current/meta.json +93 -94
  18. package/src/skills/coach/tests/cases/TC-COACH-002/current/meta.json +93 -94
  19. package/src/skills/create-plan/SKILL.md +1 -0
  20. package/src/skills/create-plan/knowledge/test-hygiene.md +47 -0
  21. package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-001/current/claude-sonnet/trial-1.md +23 -31
  22. package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-001/current/claude-sonnet/trial-2.md +20 -35
  23. package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-001/current/claude-sonnet/trial-3.md +36 -19
  24. package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-001/current/judge.json +1 -1
  25. package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-001/current/kilo-deepseek/trial-2.md +11 -5
  26. package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-001/current/kilo-deepseek/trial-3.md +12 -16
  27. package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-001/current/kilo-glm/trial-1.md +15 -9
  28. package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-001/current/kilo-glm/trial-3.md +15 -14
  29. package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-001/current/kilo-minimax/trial-1.md +22 -18
  30. package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-001/current/kilo-minimax/trial-2.md +24 -16
  31. package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-001/current/kilo-minimax/trial-3.md +13 -20
  32. package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-001/current/meta.json +2 -2
  33. package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-002/current/claude-sonnet/trial-1.md +14 -19
  34. package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-002/current/claude-sonnet/trial-2.md +24 -14
  35. package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-002/current/claude-sonnet/trial-3.md +20 -19
  36. package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-002/current/judge.json +16 -17
  37. package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-002/current/kilo-deepseek/trial-1.md +0 -7
  38. package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-002/current/kilo-deepseek/trial-2.md +9 -10
  39. package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-002/current/kilo-deepseek/trial-3.md +5 -5
  40. package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-002/current/kilo-glm/trial-1.md +20 -4
  41. package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-002/current/kilo-glm/trial-2.md +36 -9
  42. package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-002/current/kilo-glm/trial-3.md +9 -6
  43. package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-002/current/kilo-minimax/trial-1.md +4 -12
  44. package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-002/current/kilo-minimax/trial-2.md +6 -8
  45. package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-002/current/kilo-minimax/trial-3.md +8 -4
  46. package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-002/current/meta.json +10 -11
  47. package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-003/current/claude-sonnet/trial-1.md +30 -0
  48. package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-003/current/claude-sonnet/trial-2.md +30 -0
  49. package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-003/current/claude-sonnet/trial-3.md +30 -0
  50. package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-003/current/judge.json +165 -0
  51. package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-003/current/kilo-deepseek/trial-1.md +5 -0
  52. package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-003/current/kilo-deepseek/trial-2.md +26 -0
  53. package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-003/current/kilo-deepseek/trial-3.md +5 -0
  54. package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-003/current/kilo-glm/trial-1.md +39 -0
  55. package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-003/current/kilo-glm/trial-2.md +37 -0
  56. package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-003/current/kilo-glm/trial-3.md +45 -0
  57. package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-003/current/kilo-minimax/trial-1.md +26 -0
  58. package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-003/current/kilo-minimax/trial-2.md +27 -0
  59. package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-003/current/kilo-minimax/trial-3.md +7 -0
  60. package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-003/current/meta.json +117 -0
  61. package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-003-parent-plan-mandatory.yaml +41 -0
  62. package/src/skills/decompose-gaps/tests/index.yaml +5 -0
  63. package/src/skills/decompose-gaps/tests/rubrics/parent-plan-mandatory.md +22 -0
  64. package/src/skills/decompose-gaps/workflows/decompose.md +5 -2
  65. package/src/skills/decompose-plan/knowledge/atomicity-checklist.md +31 -5
  66. package/src/skills/decompose-plan/knowledge/capabilities.md +29 -5
  67. package/src/skills/decompose-plan/knowledge/human-task-rules.md +15 -0
  68. package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-004/current/claude-sonnet/trial-1.md +55 -0
  69. package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-004/current/claude-sonnet/trial-2.md +49 -0
  70. package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-004/current/claude-sonnet/trial-3.md +49 -0
  71. package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-004/current/judge.json +163 -0
  72. package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-004/current/kilo-deepseek/trial-1.md +104 -0
  73. package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-004/current/kilo-deepseek/trial-2.md +45 -0
  74. package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-004/current/kilo-deepseek/trial-3.md +58 -0
  75. package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-004/current/kilo-glm/trial-1.md +193 -0
  76. package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-004/current/kilo-glm/trial-2.md +202 -0
  77. package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-004/current/kilo-glm/trial-3.md +155 -0
  78. package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-004/current/kilo-minimax/trial-1.md +52 -0
  79. package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-004/current/kilo-minimax/trial-2.md +17 -0
  80. package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-004/current/kilo-minimax/trial-3.md +0 -0
  81. package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-004/current/meta.json +115 -0
  82. package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-004-executor-atomicity.yaml +64 -0
  83. package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-005/current/claude-sonnet/trial-1.md +59 -0
  84. package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-005/current/claude-sonnet/trial-2.md +204 -0
  85. package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-005/current/claude-sonnet/trial-3.md +213 -0
  86. package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-005/current/judge.json +163 -0
  87. package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-005/current/kilo-deepseek/trial-1.md +0 -0
  88. package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-005/current/kilo-deepseek/trial-2.md +57 -0
  89. package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-005/current/kilo-deepseek/trial-3.md +54 -0
  90. package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-005/current/kilo-glm/trial-1.md +147 -0
  91. package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-005/current/kilo-glm/trial-2.md +165 -0
  92. package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-005/current/kilo-glm/trial-3.md +133 -0
  93. package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-005/current/kilo-minimax/trial-1.md +81 -0
  94. package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-005/current/kilo-minimax/trial-2.md +108 -0
  95. package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-005/current/kilo-minimax/trial-3.md +3 -0
  96. package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-005/current/meta.json +114 -0
  97. package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-005-capabilities-registry.yaml +78 -0
  98. package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-006/current/claude-sonnet/trial-1.md +225 -0
  99. package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-006/current/claude-sonnet/trial-2.md +66 -0
  100. package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-006/current/claude-sonnet/trial-3.md +36 -0
  101. package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-006/current/judge.json +163 -0
  102. package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-006/current/kilo-deepseek/trial-1.md +42 -0
  103. package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-006/current/kilo-deepseek/trial-2.md +67 -0
  104. package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-006/current/kilo-deepseek/trial-3.md +40 -0
  105. package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-006/current/kilo-glm/trial-1.md +122 -0
  106. package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-006/current/kilo-glm/trial-2.md +131 -0
  107. package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-006/current/kilo-glm/trial-3.md +138 -0
  108. package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-006/current/kilo-minimax/trial-1.md +41 -0
  109. package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-006/current/kilo-minimax/trial-2.md +88 -0
  110. package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-006/current/kilo-minimax/trial-3.md +0 -0
  111. package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-006/current/meta.json +115 -0
  112. package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-006-dod-threshold.yaml +72 -0
  113. package/src/skills/decompose-plan/tests/index.yaml +15 -0
  114. package/src/skills/decompose-plan/tests/rubrics/capabilities-registry.md +21 -0
  115. package/src/skills/decompose-plan/tests/rubrics/dod-threshold.md +21 -0
  116. package/src/skills/decompose-plan/tests/rubrics/executor-atomicity.md +21 -0
  117. package/src/skills/decompose-plan/workflows/decompose.md +38 -5
  118. package/src/skills/execute-task/tests/cases/TC-EXECUTE-TASK-001/current/meta.json +87 -88
  119. package/src/skills/execute-task/tests/cases/TC-EXECUTE-TASK-005/current/meta.json +87 -88
  120. package/src/skills/manual-testing/SKILL.md +6 -4
  121. package/src/skills/manual-testing/tests/cases/TC-MANUAL-TESTING-001/current/claude-sonnet/trial-1.md +29 -16
  122. package/src/skills/manual-testing/tests/cases/TC-MANUAL-TESTING-001/current/claude-sonnet/trial-2.md +21 -54
  123. package/src/skills/manual-testing/tests/cases/TC-MANUAL-TESTING-001/current/claude-sonnet/trial-3.md +18 -23
  124. package/src/skills/manual-testing/tests/cases/TC-MANUAL-TESTING-001/current/judge.json +17 -17
  125. package/src/skills/manual-testing/tests/cases/TC-MANUAL-TESTING-001/current/meta.json +19 -19
  126. package/src/skills/manual-testing/tests/cases/TC-MANUAL-TESTING-002/current/claude-sonnet/trial-1.md +27 -30
  127. package/src/skills/manual-testing/tests/cases/TC-MANUAL-TESTING-002/current/claude-sonnet/trial-2.md +16 -23
  128. package/src/skills/manual-testing/tests/cases/TC-MANUAL-TESTING-002/current/claude-sonnet/trial-3.md +35 -28
  129. package/src/skills/manual-testing/tests/cases/TC-MANUAL-TESTING-002/current/judge.json +13 -13
  130. package/src/skills/manual-testing/tests/cases/TC-MANUAL-TESTING-002/current/meta.json +15 -15
  131. package/src/skills/manual-testing/tests/cases/TC-MANUAL-TESTING-003/current/claude-sonnet/trial-1.md +76 -0
  132. package/src/skills/manual-testing/tests/cases/TC-MANUAL-TESTING-003/current/claude-sonnet/trial-2.md +71 -0
  133. package/src/skills/manual-testing/tests/cases/TC-MANUAL-TESTING-003/current/claude-sonnet/trial-3.md +85 -0
  134. package/src/skills/manual-testing/tests/cases/TC-MANUAL-TESTING-003/current/judge.json +46 -0
  135. package/src/skills/manual-testing/tests/cases/TC-MANUAL-TESTING-003/current/meta.json +36 -0
  136. package/src/skills/manual-testing/tests/cases/TC-MANUAL-TESTING-003-qa-non-ui-assertion.yaml +65 -0
  137. package/src/skills/manual-testing/tests/index.yaml +5 -0
  138. package/src/skills/manual-testing/tests/rubrics/qa-non-ui-assertion.md +31 -0
  139. package/src/skills/review-result/SKILL.md +1 -0
  140. package/src/skills/review-result/knowledge/test-hygiene.md +44 -0
  141. package/src/skills/review-result/scripts/verify-artifacts.js +157 -14
  142. package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-003/current/claude-sonnet/trial-1.md +7 -0
  143. package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-003/current/claude-sonnet/trial-2.md +7 -0
  144. package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-003/current/claude-sonnet/trial-3.md +7 -0
  145. package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-003/current/judge.json +163 -0
  146. package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-003/current/kilo-deepseek/trial-1.md +5 -0
  147. package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-003/current/kilo-deepseek/trial-2.md +5 -0
  148. package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-003/current/kilo-deepseek/trial-3.md +11 -0
  149. package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-003/current/kilo-glm/trial-1.md +16 -0
  150. package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-003/current/kilo-glm/trial-2.md +18 -0
  151. package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-003/current/kilo-glm/trial-3.md +17 -0
  152. package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-003/current/kilo-minimax/trial-1.md +17 -0
  153. package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-003/current/kilo-minimax/trial-2.md +31 -0
  154. package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-003/current/kilo-minimax/trial-3.md +5 -0
  155. package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-003/current/meta.json +115 -0
  156. package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-003-test-isolation.yaml +50 -0
  157. package/src/skills/review-result/tests/fixtures/QA-904-test-isolation-violation/QA-904.md +51 -0
  158. package/src/skills/review-result/tests/fixtures/QA-904-test-isolation-violation/example-test.mjs +36 -0
  159. package/src/skills/review-result/tests/index.yaml +5 -0
  160. package/src/skills/review-result/tests/rubrics/test-isolation.md +20 -0
@@ -0,0 +1,233 @@
1
+ import { execFileSync } from 'child_process';
2
+ import { readdirSync, statSync, readFileSync, existsSync } from 'fs';
3
+ import { join, relative, isAbsolute } from 'path';
4
+ import { createHash } from 'crypto';
5
+
6
+ const DEFAULT_SNAPSHOT_MAX_FILE_SIZE = 524288;
7
+ const DEFAULT_EXCLUDE_PATTERNS = [
8
+ '.workflow/logs/**',
9
+ '.workflow/state/**',
10
+ '**/.git/**',
11
+ '**/node_modules/**',
12
+ '**/*.tmp'
13
+ ];
14
+ const DEFAULT_INCLUDE_PATHS = [
15
+ '.workflow/tickets',
16
+ '.workflow/plans',
17
+ '.workflow/reports',
18
+ 'src',
19
+ 'configs'
20
+ ];
21
+
22
+ function matchesGlob(filePath, pattern) {
23
+ // Normalize to forward slashes, strip leading ./
24
+ const normPath = filePath.replace(/\\/g, '/');
25
+ const p = pattern.replace(/\\/g, '/').replace(/^\.\//, '');
26
+
27
+ // Build regex from glob pattern.
28
+ // Rules:
29
+ // **/ at the very start → optional prefix of zero-or-more directories
30
+ // /** at the end → any file/subdir below this directory
31
+ // **/{seg}/** → {seg} appears anywhere as a path component
32
+ // **/*.ext → any file with .ext in any subdirectory
33
+ // * → any characters except /
34
+ let result = '^';
35
+ let i = 0;
36
+
37
+ while (i < p.length) {
38
+ const ch = p[i];
39
+
40
+ if (ch === '*' && p[i + 1] === '*') {
41
+ // Double-star glob
42
+ if (i === 0 && p[i + 2] === '/') {
43
+ // **/ at the very start: zero-or-more leading directories
44
+ result += '(?:.+/)?';
45
+ i += 3;
46
+ } else if (p[i - 1] === '/' && i + 2 >= p.length) {
47
+ // /** at the end (preceded by /): any suffix including sub-paths
48
+ result += '.*';
49
+ i += 2;
50
+ } else if (p[i - 1] === '/' && p[i + 2] === '/') {
51
+ // /**/ in the middle: optional sub-path segment
52
+ result += '(?:.+/)?';
53
+ i += 3;
54
+ } else {
55
+ // ** in any other position: match anything
56
+ result += '.*';
57
+ i += 2;
58
+ }
59
+ } else if (ch === '*') {
60
+ // Single star: any characters except /
61
+ result += '[^/]*';
62
+ i++;
63
+ } else if (ch === '?') {
64
+ result += '[^/]';
65
+ i++;
66
+ } else if (/[.+^${}()|[\]\\]/.test(ch)) {
67
+ result += '\\' + ch;
68
+ i++;
69
+ } else {
70
+ result += ch;
71
+ i++;
72
+ }
73
+ }
74
+
75
+ result += '$';
76
+
77
+ try {
78
+ return new RegExp(result).test(normPath);
79
+ } catch {
80
+ return false;
81
+ }
82
+ }
83
+
84
+ function shouldExclude(path, excludePatterns) {
85
+ for (const pattern of excludePatterns) {
86
+ if (matchesGlob(path, pattern)) {
87
+ return true;
88
+ }
89
+ }
90
+ return false;
91
+ }
92
+
93
+ function computeFileHash(filePath, maxSize) {
94
+ try {
95
+ const stats = statSync(filePath);
96
+ if (stats.size > maxSize) {
97
+ return null;
98
+ }
99
+ const content = readFileSync(filePath);
100
+ return createHash('sha1').update(content).digest('hex');
101
+ } catch (e) {
102
+ return null;
103
+ }
104
+ }
105
+
106
+ function walkDirectory(dirPath, basePath, excludePatterns, maxFileSize) {
107
+ const result = new Map();
108
+
109
+ if (!existsSync(dirPath)) {
110
+ return result;
111
+ }
112
+
113
+ function walk(dir) {
114
+ try {
115
+ const entries = readdirSync(dir);
116
+
117
+ for (const entry of entries) {
118
+ const fullPath = join(dir, entry);
119
+ const relativePath = relative(basePath, fullPath).replace(/\\/g, '/');
120
+
121
+ if (shouldExclude(relativePath, excludePatterns)) {
122
+ continue;
123
+ }
124
+
125
+ try {
126
+ const stats = statSync(fullPath);
127
+
128
+ if (stats.isDirectory()) {
129
+ walk(fullPath);
130
+ } else if (stats.isFile()) {
131
+ const sha1 = computeFileHash(fullPath, maxFileSize);
132
+ result.set(relativePath, {
133
+ mtime: stats.mtimeMs,
134
+ size: stats.size,
135
+ sha1
136
+ });
137
+ }
138
+ } catch (e) {
139
+ console.warn(`[WARN] artifact-snapshot: skip ${relativePath}: ${e.message}`);
140
+ }
141
+ }
142
+ } catch (e) {
143
+ console.warn(`[WARN] artifact-snapshot: walk ${dir}: ${e.message}`);
144
+ }
145
+ }
146
+
147
+ walk(dirPath);
148
+ return result;
149
+ }
150
+
151
+ export async function snapshot(projectRoot, options = {}) {
152
+ const includePaths = options.includePaths || DEFAULT_INCLUDE_PATHS;
153
+ const excludePatterns = options.excludePatterns || DEFAULT_EXCLUDE_PATTERNS;
154
+ const snapshotMaxFileSize = options.snapshotMaxFileSize || DEFAULT_SNAPSHOT_MAX_FILE_SIZE;
155
+
156
+ let gitOutput = '';
157
+ const gitEnabled = existsSync(join(projectRoot, '.git'));
158
+
159
+ if (gitEnabled) {
160
+ try {
161
+ gitOutput = execFileSync('git', ['status', '--porcelain=v1', '-z'], {
162
+ cwd: projectRoot,
163
+ encoding: 'utf8',
164
+ maxBuffer: 10 * 1024 * 1024
165
+ });
166
+ } catch (e) {
167
+ console.warn(`[WARN] artifact-snapshot: git status failed: ${e.message}`);
168
+ gitOutput = '';
169
+ }
170
+ }
171
+
172
+ const fsMap = new Map();
173
+
174
+ for (const includePath of includePaths) {
175
+ const fullPath = isAbsolute(includePath)
176
+ ? includePath
177
+ : join(projectRoot, includePath);
178
+
179
+ if (existsSync(fullPath)) {
180
+ const dirMap = walkDirectory(fullPath, projectRoot, excludePatterns, snapshotMaxFileSize);
181
+ for (const [key, value] of dirMap) {
182
+ fsMap.set(key, value);
183
+ }
184
+ }
185
+ }
186
+
187
+ return {
188
+ git: gitOutput,
189
+ fs: fsMap,
190
+ timestamp: Date.now()
191
+ };
192
+ }
193
+
194
+ export function diff(before, after) {
195
+ const created = [];
196
+ const changed = [];
197
+ const deleted = [];
198
+
199
+ const beforeFs = before.fs;
200
+ const afterFs = after.fs;
201
+
202
+ const beforeFiles = new Set(beforeFs.keys());
203
+ const afterFiles = new Set(afterFs.keys());
204
+
205
+ for (const file of afterFiles) {
206
+ if (!beforeFiles.has(file)) {
207
+ created.push(file);
208
+ } else {
209
+ const beforeMeta = beforeFs.get(file);
210
+ const afterMeta = afterFs.get(file);
211
+
212
+ if (beforeMeta.mtime !== afterMeta.mtime ||
213
+ beforeMeta.size !== afterMeta.size ||
214
+ beforeMeta.sha1 !== afterMeta.sha1) {
215
+ changed.push(file);
216
+ }
217
+ }
218
+ }
219
+
220
+ for (const file of beforeFiles) {
221
+ if (!afterFiles.has(file)) {
222
+ deleted.push(file);
223
+ }
224
+ }
225
+
226
+ return { changed, created, deleted };
227
+ }
228
+
229
+ export function isEmpty(diffResult) {
230
+ return diffResult.changed.length === 0 &&
231
+ diffResult.created.length === 0 &&
232
+ diffResult.deleted.length === 0;
233
+ }
@@ -0,0 +1,311 @@
1
+ import { load as loadYaml } from './js-yaml.mjs';
2
+ import { readFileSync, existsSync } from 'fs';
3
+ import { resolve, join } from 'path';
4
+
5
+ const STDERR_MATCH_LIMIT = 64 * 1024;
6
+ const MATCH_TIMEOUT_MS = 100;
7
+ const SUPPORTED_VERSION = '1.0';
8
+ const MIN_UTC_MIDNIGHT_DELAY_MS = 30 * 60 * 1000;
9
+
10
+ export class InvalidRulesConfigError extends Error {
11
+ constructor(message) {
12
+ super(message);
13
+ this.name = 'InvalidRulesConfigError';
14
+ if (Error.captureStackTrace) {
15
+ Error.captureStackTrace(this, this.constructor);
16
+ }
17
+ }
18
+ }
19
+
20
+ function parseConfigFile(configPath) {
21
+ if (!existsSync(configPath)) {
22
+ return { common: [], agents: {} };
23
+ }
24
+ const content = readFileSync(configPath, 'utf-8');
25
+ const config = loadYaml(content);
26
+ if (!config || typeof config !== 'object') {
27
+ return { common: [], agents: {} };
28
+ }
29
+ return config;
30
+ }
31
+
32
+ function validateVersion(version) {
33
+ if (version !== SUPPORTED_VERSION) {
34
+ throw new InvalidRulesConfigError(
35
+ `Unsupported rules version: ${version}. Update runner or downgrade rules file.`
36
+ );
37
+ }
38
+ }
39
+
40
+ function compilePattern(pattern) {
41
+ if (!pattern) {
42
+ return null;
43
+ }
44
+ try {
45
+ return new RegExp(pattern);
46
+ } catch (e) {
47
+ throw new InvalidRulesConfigError(`Invalid regex pattern: ${pattern}. ${e.message}`);
48
+ }
49
+ }
50
+
51
+ function resolveExtends(agentsConfig, agentId, visited = new Set()) {
52
+ if (!agentsConfig[agentId]) {
53
+ return null;
54
+ }
55
+ if (!agentsConfig[agentId].extends) {
56
+ return null;
57
+ }
58
+ const extendsTarget = agentsConfig[agentId].extends;
59
+ if (visited.has(agentId)) {
60
+ throw new InvalidRulesConfigError('chained extends not supported');
61
+ }
62
+ if (!agentsConfig[extendsTarget]) {
63
+ throw new InvalidRulesConfigError(`extends target '${extendsTarget}' not found`);
64
+ }
65
+ if (agentsConfig[extendsTarget].extends) {
66
+ throw new InvalidRulesConfigError('chained extends not supported');
67
+ }
68
+ visited.add(agentId);
69
+ return agentsConfig[extendsTarget].rules || [];
70
+ }
71
+
72
+ function buildRules(rulesData, compiledRules = []) {
73
+ if (!Array.isArray(rulesData)) {
74
+ return compiledRules;
75
+ }
76
+ for (const rule of rulesData) {
77
+ if (!rule || !rule.id || !rule.class || !rule.ttl) {
78
+ continue;
79
+ }
80
+ compiledRules.push({
81
+ id: rule.id,
82
+ class: rule.class,
83
+ ttl: rule.ttl,
84
+ pattern: compilePattern(rule.pattern),
85
+ exitCodes: rule.exit_codes === 'any' ? 'any' : (
86
+ Array.isArray(rule.exit_codes)
87
+ ? rule.exit_codes.filter(c => typeof c === 'number')
88
+ : []
89
+ ),
90
+ });
91
+ }
92
+ return compiledRules;
93
+ }
94
+
95
+ export function loadRules(projectRoot, configPath) {
96
+ const defaultPath = join(projectRoot, '.workflow/config/agent-health-rules.yaml');
97
+ const fullPath = configPath || defaultPath;
98
+ let config;
99
+ try {
100
+ config = parseConfigFile(fullPath);
101
+ } catch (e) {
102
+ if (e instanceof InvalidRulesConfigError) {
103
+ throw e;
104
+ }
105
+ return { common: [], agents: new Map() };
106
+ }
107
+ if (!config || typeof config !== 'object') {
108
+ return { common: [], agents: new Map() };
109
+ }
110
+ if (config.version) {
111
+ validateVersion(config.version);
112
+ }
113
+ const commonRules = buildRules(config.common || []);
114
+ const agents = new Map();
115
+ const agentsConfig = config.agents || {};
116
+ for (const agentId of Object.keys(agentsConfig)) {
117
+ const agentConfig = agentsConfig[agentId];
118
+ if (!agentConfig) {
119
+ continue;
120
+ }
121
+ const inheritedRules = resolveExtends(agentsConfig, agentId);
122
+ const ownRules = buildRules(agentConfig.rules || []);
123
+ const finalRules = [...ownRules];
124
+ if (inheritedRules && inheritedRules.length > 0) {
125
+ finalRules.push(...buildRules(inheritedRules));
126
+ }
127
+ agents.set(agentId, finalRules);
128
+ }
129
+ return { common: commonRules, agents };
130
+ }
131
+
132
+ function truncateStderr(stderr) {
133
+ if (!stderr || stderr.length <= STDERR_MATCH_LIMIT) {
134
+ return stderr;
135
+ }
136
+ const halfLimit = STDERR_MATCH_LIMIT / 2;
137
+ const head = stderr.slice(0, halfLimit);
138
+ const tail = stderr.slice(-halfLimit);
139
+ return head + '\n...[TRUNCATED]...\n' + tail;
140
+ }
141
+
142
+ function matchRule(rule, exitCode, truncatedStderr) {
143
+ const exitCodesMatch = rule.exitCodes === 'any' ||
144
+ rule.exitCodes.includes(exitCode);
145
+ if (!exitCodesMatch) {
146
+ return false;
147
+ }
148
+ if (!rule.pattern) {
149
+ return true;
150
+ }
151
+ try {
152
+ return rule.pattern.test(truncatedStderr);
153
+ } catch (e) {
154
+ return false;
155
+ }
156
+ }
157
+
158
+ function matchWithTimeout(regex, text) {
159
+ return new Promise((resolve) => {
160
+ const timeoutId = setTimeout(() => {
161
+ resolve(false);
162
+ }, MATCH_TIMEOUT_MS);
163
+ try {
164
+ const result = regex.test(text);
165
+ clearTimeout(timeoutId);
166
+ resolve(result);
167
+ } catch (e) {
168
+ clearTimeout(timeoutId);
169
+ resolve(false);
170
+ }
171
+ });
172
+ }
173
+
174
+ export async function classify(rules, agentId, { exitCode, stderr }) {
175
+ const truncatedStderr = truncateStderr(stderr);
176
+ const agentRules = rules.agents.get(agentId) || [];
177
+ for (const rule of agentRules) {
178
+ const matched = rule.pattern
179
+ ? await matchWithTimeout(rule.pattern, truncatedStderr)
180
+ : matchRule(rule, exitCode, truncatedStderr);
181
+ if (matched) {
182
+ return {
183
+ class: rule.class,
184
+ rule_id: rule.id,
185
+ ttl: rule.ttl,
186
+ reason: truncatedStderr,
187
+ };
188
+ }
189
+ }
190
+ for (const rule of rules.common) {
191
+ const matched = rule.pattern
192
+ ? await matchWithTimeout(rule.pattern, truncatedStderr)
193
+ : matchRule(rule, exitCode, truncatedStderr);
194
+ if (matched) {
195
+ return {
196
+ class: rule.class,
197
+ rule_id: rule.id,
198
+ ttl: rule.ttl,
199
+ reason: truncatedStderr,
200
+ };
201
+ }
202
+ }
203
+ return null;
204
+ }
205
+
206
+ /**
207
+ * Онлайн-проверка stderr на "фатальные" паттерны для агента.
208
+ * Используется spawn-хендлером для раннего kill зависшего процесса,
209
+ * когда дочерний агент уходит в retry-цикл на HTTP 429 / quota без завершения.
210
+ *
211
+ * Проверяет только правила самого агента (не common), и только те,
212
+ * у которых class ∈ {unavailable, misconfigured} — это сигналы, после которых
213
+ * продолжать вызов бессмысленно.
214
+ *
215
+ * @param {{common: Array, agents: Map}} rules — результат loadRules()
216
+ * @param {string} agentId
217
+ * @param {string} stderrText — весь накопленный stderr (до STDERR_MATCH_LIMIT)
218
+ * @returns {{rule_id: string, class: string, ttl: string, reason: string} | null}
219
+ */
220
+ export function scanStderrForFatalRule(rules, agentId, stderrText) {
221
+ if (!stderrText || !agentId) return null;
222
+ const truncated = truncateStderr(stderrText);
223
+ const agentRules = rules.agents.get(agentId) || [];
224
+ for (const rule of agentRules) {
225
+ if (rule.class !== 'unavailable' && rule.class !== 'misconfigured') continue;
226
+ if (!rule.pattern) continue;
227
+ try {
228
+ if (rule.pattern.test(truncated)) {
229
+ return {
230
+ rule_id: rule.id,
231
+ class: rule.class,
232
+ ttl: rule.ttl,
233
+ reason: truncated,
234
+ };
235
+ }
236
+ } catch {
237
+ // broken regex — пропускаем
238
+ }
239
+ }
240
+ return null;
241
+ }
242
+
243
+ export function parseTtl(ttl, now = Date.now()) {
244
+ if (ttl === 'infinite') {
245
+ return Number.MAX_SAFE_INTEGER;
246
+ }
247
+ const untilMatch = ttl.match(/^(\d+)d$/);
248
+ if (untilMatch) {
249
+ return now + parseInt(untilMatch[1], 10) * 24 * 60 * 60 * 1000;
250
+ }
251
+ const hourMatch = ttl.match(/^(\d+)h$/);
252
+ if (hourMatch) {
253
+ return now + parseInt(hourMatch[1], 10) * 60 * 60 * 1000;
254
+ }
255
+ const minMatch = ttl.match(/^(\d+)m$/);
256
+ if (minMatch) {
257
+ return now + parseInt(minMatch[1], 10) * 60 * 1000;
258
+ }
259
+ if (ttl === 'until_utc_midnight') {
260
+ const nextMidnight = new Date(now);
261
+ nextMidnight.setUTCHours(24, 0, 0, 0);
262
+ const minDelay = now + MIN_UTC_MIDNIGHT_DELAY_MS;
263
+ return Math.max(nextMidnight.getTime(), minDelay);
264
+ }
265
+ throw new Error(`Invalid TTL format: ${ttl}. Expected Nm, Nh, Nd, until_utc_midnight, or infinite.`);
266
+ }
267
+
268
+ export function classifySync(rules, agentId, { exitCode, stderr }) {
269
+ const truncatedStderr = truncateStderr(stderr);
270
+ const agentRules = rules.agents.get(agentId) || [];
271
+ for (const rule of agentRules) {
272
+ const matched = rule.pattern
273
+ ? (() => {
274
+ try {
275
+ return rule.pattern.test(truncatedStderr);
276
+ } catch (e) {
277
+ return false;
278
+ }
279
+ })()
280
+ : matchRule(rule, exitCode, truncatedStderr);
281
+ if (matched) {
282
+ return {
283
+ class: rule.class,
284
+ rule_id: rule.id,
285
+ ttl: rule.ttl,
286
+ reason: truncatedStderr,
287
+ };
288
+ }
289
+ }
290
+ for (const rule of rules.common) {
291
+ const matched = rule.pattern
292
+ ? (() => {
293
+ try {
294
+ return rule.pattern.test(truncatedStderr);
295
+ } catch (e) {
296
+ return false;
297
+ }
298
+ })()
299
+ : matchRule(rule, exitCode, truncatedStderr);
300
+ if (matched) {
301
+ return {
302
+ class: rule.class,
303
+ rule_id: rule.id,
304
+ ttl: rule.ttl,
305
+ reason: truncatedStderr,
306
+ };
307
+ }
308
+ }
309
+ return null;
310
+ }
311
+
@@ -0,0 +1,60 @@
1
+ import { loadRules, classify, parseTtl, classifySync, InvalidRulesConfigError } from './error-classifier.mjs';
2
+ import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'fs';
3
+ import path from 'path';
4
+ import os from 'os';
5
+
6
+ const tmp = mkdtempSync(path.join(os.tmpdir(), 'ec-test-'));
7
+ const configDir = path.join(tmp, '.workflow/config');
8
+ mkdirSync(configDir, { recursive: true });
9
+ const configPath = path.join(configDir, 'agent-health-rules.yaml');
10
+
11
+ writeFileSync(configPath, `version: "1.0"
12
+ common:
13
+ - id: "net-econnreset"
14
+ class: "transient"
15
+ ttl: "5m"
16
+ pattern: "ECONNRESET|ETIMEDOUT"
17
+ exit_codes: "any"
18
+ agents:
19
+ qwen-code:
20
+ rules:
21
+ - id: "qwen-quota"
22
+ class: "unavailable"
23
+ ttl: "until_utc_midnight"
24
+ pattern: "Qwen OAuth quota exceeded"
25
+ exit_codes: "any"
26
+ `);
27
+
28
+ const rules = loadRules(tmp);
29
+ console.log('loadRules result:', { commonLen: rules.common.length, agentsSize: rules.agents.size });
30
+
31
+ const result1 = await classify(rules, 'qwen-code', { exitCode: 1, stderr: 'Qwen OAuth quota exceeded' });
32
+ console.log('classify qwen-quota:', result1?.class, result1?.rule_id);
33
+
34
+ const result2 = await classify(rules, 'qwen-code', { exitCode: 1, stderr: 'Some other error' });
35
+ console.log('classify no match:', result2);
36
+
37
+ const result3 = await classify(rules, 'claude-sonnet', { exitCode: 1, stderr: 'ECONNRESET' });
38
+ console.log('classify common:', result3?.class, result3?.rule_id);
39
+
40
+ const result4 = classifySync(rules, 'qwen-code', { exitCode: 1, stderr: 'Qwen OAuth quota exceeded' });
41
+ console.log('classifySync qwen-quota:', result4?.class, result4?.rule_id);
42
+
43
+ const ttl1 = parseTtl('5m');
44
+ console.log('parseTtl 5m:', typeof ttl1, ttl1 > Date.now());
45
+
46
+ const ttl2 = parseTtl('infinite');
47
+ console.log('parseTtl infinite:', ttl2 === Number.MAX_SAFE_INTEGER);
48
+
49
+ const now = new Date('2026-04-21T12:00:00Z').getTime();
50
+ const ttl3 = parseTtl('until_utc_midnight', now);
51
+ console.log('parseTtl until_utc_midnight:', ttl3 >= now);
52
+
53
+ const ttl4 = parseTtl('1h');
54
+ console.log('parseTtl 1h:', ttl4 > now);
55
+
56
+ const ttl5 = parseTtl('1d');
57
+ console.log('parseTtl 1d:', ttl5 > now);
58
+
59
+ rmSync(tmp, { recursive: true, force: true });
60
+ console.log('All tests passed!');
@@ -0,0 +1,58 @@
1
+ import { loadRules, classify, classifySync } from './error-classifier.mjs';
2
+ import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'fs';
3
+ import path from 'path';
4
+ import os from 'os';
5
+
6
+ const tmp = mkdtempSync(path.join(os.tmpdir(), 'ec-test-'));
7
+ const configDir = path.join(tmp, '.workflow/config');
8
+ mkdirSync(configDir, { recursive: true });
9
+ const configPath = path.join(configDir, 'agent-health-rules.yaml');
10
+
11
+ writeFileSync(configPath, `version: "1.0"
12
+ common:
13
+ - id: "common-test"
14
+ class: "transient"
15
+ ttl: "5m"
16
+ pattern: "common-error"
17
+ exit_codes: "any"
18
+ agents:
19
+ claude-sonnet:
20
+ rules:
21
+ - id: "claude-specific"
22
+ class: "unavailable"
23
+ ttl: "10m"
24
+ pattern: "claude-specific-error"
25
+ exit_codes: "any"
26
+ claude-opus:
27
+ extends: claude-sonnet
28
+ kilo-glm:
29
+ rules:
30
+ - id: "kilo-own"
31
+ class: "misconfigured"
32
+ ttl: "1h"
33
+ pattern: "kilo-error"
34
+ exit_codes: "any"
35
+ `);
36
+
37
+ const rules = loadRules(tmp);
38
+ console.log('loadRules with extends:');
39
+
40
+ const opusRules = rules.agents.get('claude-opus');
41
+ console.log('claude-opus rules (from extends):', opusRules?.length);
42
+ console.log('claude-opus first rule:', opusRules?.[0]?.id);
43
+
44
+ const glmRules = rules.agents.get('kilo-glm');
45
+ console.log('kilo-glm rules (own):', glmRules?.length);
46
+ console.log('kilo-glm first rule:', glmRules?.[0]?.id);
47
+
48
+ const test1 = classifySync(rules, 'claude-opus', { exitCode: 1, stderr: 'claude-specific-error' });
49
+ console.log('classify claude-opus with specific error:', test1?.class, test1?.rule_id);
50
+
51
+ const test2 = classifySync(rules, 'claude-opus', { exitCode: 1, stderr: 'common-error' });
52
+ console.log('classify claude-opus with common error:', test2?.class, test2?.rule_id);
53
+
54
+ const test3 = classifySync(rules, 'kilo-glm', { exitCode: 1, stderr: 'kilo-error' });
55
+ console.log('classify kilo-glm with own error:', test3?.class, test3?.rule_id);
56
+
57
+ rmSync(tmp, { recursive: true, force: true });
58
+ console.log('Extends tests passed!');
@@ -0,0 +1,21 @@
1
+ import { InvalidRulesConfigError } from './error-classifier.mjs';
2
+ import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'fs';
3
+ import path from 'path';
4
+ import os from 'os';
5
+
6
+ const tmp = mkdtempSync(path.join(os.tmpdir(), 'ec-test-'));
7
+ const configDir = path.join(tmp, '.workflow/config');
8
+ mkdirSync(configDir, { recursive: true });
9
+ const configPath = path.join(configDir, 'agent-health-rules.yaml');
10
+
11
+ writeFileSync(configPath, 'version: "2.0"\ncommon: []');
12
+
13
+ try {
14
+ const { loadRules } = await import('./error-classifier.mjs');
15
+ loadRules(tmp);
16
+ console.log('ERROR: should have thrown');
17
+ } catch (e) {
18
+ console.log('Version error works:', e.name === 'InvalidRulesConfigError', e.message.includes('2.0'));
19
+ }
20
+
21
+ rmSync(tmp, { recursive: true, force: true });