@diff-review-system/drs 4.0.0-rc.4 → 4.0.1

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 (315) hide show
  1. package/.pi/agents/describe/pr-describer.md +14 -0
  2. package/.pi/agents/review/unified-reviewer.md +31 -1
  3. package/.pi/agents/task/agents-md-updater.md +3 -1
  4. package/.pi/agents/task/review-issue-fixer.md +18 -1
  5. package/.pi/agents/visual/pr-explainer.md +205 -0
  6. package/.pi/workflows/github-pr-describe.yaml +10 -7
  7. package/.pi/workflows/github-pr-fix-review-issues-stacked.yaml +148 -0
  8. package/.pi/workflows/github-pr-post-comment.yaml +10 -10
  9. package/.pi/workflows/github-pr-review-post.yaml +19 -8
  10. package/.pi/workflows/github-pr-review.yaml +348 -7
  11. package/.pi/workflows/github-pr-show-changes.yaml +8 -8
  12. package/.pi/workflows/github-pr-update-agents-md-stacked.yaml +103 -0
  13. package/.pi/workflows/github-pr-visual-explain.yaml +35 -0
  14. package/.pi/workflows/gitlab-mr-describe.yaml +8 -5
  15. package/.pi/workflows/gitlab-mr-fix-review-issues-stacked.yaml +144 -0
  16. package/.pi/workflows/gitlab-mr-post-comment.yaml +8 -8
  17. package/.pi/workflows/gitlab-mr-review.yaml +348 -5
  18. package/.pi/workflows/gitlab-mr-show-changes.yaml +6 -6
  19. package/.pi/workflows/gitlab-mr-update-agents-md-stacked.yaml +100 -0
  20. package/.pi/workflows/gitlab-mr-visual-explain.yaml +33 -0
  21. package/.pi/workflows/local-fix-review-issues.yaml +82 -13
  22. package/.pi/workflows/local-review.yaml +9 -2
  23. package/.pi/workflows/local-update-agents-md.yaml +1 -1
  24. package/.pi/workflows/local-visual-explain.yaml +31 -0
  25. package/.pi/workflows/release-changelog-finalize.yaml +47 -0
  26. package/.pi/workflows/tag-changelog-update.yaml +4 -4
  27. package/README.md +91 -27
  28. package/dist/ci/runner.d.ts.map +1 -1
  29. package/dist/ci/runner.js +3 -1
  30. package/dist/ci/runner.js.map +1 -1
  31. package/dist/cli/index.js +48 -6
  32. package/dist/cli/index.js.map +1 -1
  33. package/dist/cli/run-agent.d.ts +2 -0
  34. package/dist/cli/run-agent.d.ts.map +1 -1
  35. package/dist/cli/run-agent.js +4 -0
  36. package/dist/cli/run-agent.js.map +1 -1
  37. package/dist/cli/workflow.d.ts +56 -2
  38. package/dist/cli/workflow.d.ts.map +1 -1
  39. package/dist/cli/workflow.js +2165 -85
  40. package/dist/cli/workflow.js.map +1 -1
  41. package/dist/github/client.d.ts +12 -0
  42. package/dist/github/client.d.ts.map +1 -1
  43. package/dist/github/client.js +27 -0
  44. package/dist/github/client.js.map +1 -1
  45. package/dist/github/platform-adapter.d.ts +6 -1
  46. package/dist/github/platform-adapter.d.ts.map +1 -1
  47. package/dist/github/platform-adapter.js +84 -8
  48. package/dist/github/platform-adapter.js.map +1 -1
  49. package/dist/gitlab/client.d.ts +11 -0
  50. package/dist/gitlab/client.d.ts.map +1 -1
  51. package/dist/gitlab/client.js +11 -0
  52. package/dist/gitlab/client.js.map +1 -1
  53. package/dist/gitlab/platform-adapter.d.ts +3 -1
  54. package/dist/gitlab/platform-adapter.d.ts.map +1 -1
  55. package/dist/gitlab/platform-adapter.js +32 -1
  56. package/dist/gitlab/platform-adapter.js.map +1 -1
  57. package/dist/lib/comment-formatter.d.ts +8 -0
  58. package/dist/lib/comment-formatter.d.ts.map +1 -1
  59. package/dist/lib/comment-formatter.js +12 -4
  60. package/dist/lib/comment-formatter.js.map +1 -1
  61. package/dist/lib/comment-poster.d.ts.map +1 -1
  62. package/dist/lib/comment-poster.js +28 -1
  63. package/dist/lib/comment-poster.js.map +1 -1
  64. package/dist/lib/config.d.ts +50 -11
  65. package/dist/lib/config.d.ts.map +1 -1
  66. package/dist/lib/config.js +163 -28
  67. package/dist/lib/config.js.map +1 -1
  68. package/dist/lib/context-compression.d.ts +10 -0
  69. package/dist/lib/context-compression.d.ts.map +1 -1
  70. package/dist/lib/context-compression.js +101 -13
  71. package/dist/lib/context-compression.js.map +1 -1
  72. package/dist/lib/context-loader.d.ts +2 -1
  73. package/dist/lib/context-loader.d.ts.map +1 -1
  74. package/dist/lib/context-loader.js +70 -1
  75. package/dist/lib/context-loader.js.map +1 -1
  76. package/dist/lib/describe-core.d.ts.map +1 -1
  77. package/dist/lib/describe-core.js +3 -2
  78. package/dist/lib/describe-core.js.map +1 -1
  79. package/dist/lib/diff-lines.d.ts +9 -0
  80. package/dist/lib/diff-lines.d.ts.map +1 -1
  81. package/dist/lib/diff-lines.js +17 -9
  82. package/dist/lib/diff-lines.js.map +1 -1
  83. package/dist/lib/exit.js +4 -4
  84. package/dist/lib/exit.js.map +1 -1
  85. package/dist/lib/html-artifact.d.ts +14 -0
  86. package/dist/lib/html-artifact.d.ts.map +1 -0
  87. package/dist/lib/html-artifact.js +59 -0
  88. package/dist/lib/html-artifact.js.map +1 -0
  89. package/dist/lib/issue-parser.js +3 -3
  90. package/dist/lib/issue-parser.js.map +1 -1
  91. package/dist/lib/json-output-schema.d.ts +70 -0
  92. package/dist/lib/json-output-schema.d.ts.map +1 -1
  93. package/dist/lib/json-output-schema.js +40 -0
  94. package/dist/lib/json-output-schema.js.map +1 -1
  95. package/dist/lib/platform-client.d.ts +26 -0
  96. package/dist/lib/platform-client.d.ts.map +1 -1
  97. package/dist/lib/review-artifact.d.ts +69 -0
  98. package/dist/lib/review-artifact.d.ts.map +1 -0
  99. package/dist/lib/review-artifact.js +171 -0
  100. package/dist/lib/review-artifact.js.map +1 -0
  101. package/dist/lib/review-core.d.ts +6 -4
  102. package/dist/lib/review-core.d.ts.map +1 -1
  103. package/dist/lib/review-core.js +71 -151
  104. package/dist/lib/review-core.js.map +1 -1
  105. package/dist/lib/review-orchestrator.d.ts +23 -0
  106. package/dist/lib/review-orchestrator.d.ts.map +1 -1
  107. package/dist/lib/review-orchestrator.js +20 -13
  108. package/dist/lib/review-orchestrator.js.map +1 -1
  109. package/dist/lib/review-usage.d.ts +4 -0
  110. package/dist/lib/review-usage.d.ts.map +1 -1
  111. package/dist/lib/review-usage.js +25 -0
  112. package/dist/lib/review-usage.js.map +1 -1
  113. package/dist/lib/trace-collector.d.ts +105 -0
  114. package/dist/lib/trace-collector.d.ts.map +1 -0
  115. package/dist/lib/trace-collector.js +255 -0
  116. package/dist/lib/trace-collector.js.map +1 -0
  117. package/dist/lib/trace-html.d.ts +3 -0
  118. package/dist/lib/trace-html.d.ts.map +1 -0
  119. package/dist/lib/trace-html.js +349 -0
  120. package/dist/lib/trace-html.js.map +1 -0
  121. package/dist/lib/workflow-artifacts.d.ts +54 -0
  122. package/dist/lib/workflow-artifacts.d.ts.map +1 -0
  123. package/dist/lib/workflow-artifacts.js +150 -0
  124. package/dist/lib/workflow-artifacts.js.map +1 -0
  125. package/dist/pi/sdk.d.ts.map +1 -1
  126. package/dist/pi/sdk.js +570 -6
  127. package/dist/pi/sdk.js.map +1 -1
  128. package/dist/runtime/agent-loader.js +2 -2
  129. package/dist/runtime/client.d.ts +2 -0
  130. package/dist/runtime/client.d.ts.map +1 -1
  131. package/dist/runtime/client.js +11 -5
  132. package/dist/runtime/client.js.map +1 -1
  133. package/package.json +21 -15
  134. package/.pi/workflows/github-pr-describe-post.yaml +0 -24
  135. package/.pi/workflows/gitlab-mr-describe-post.yaml +0 -22
  136. package/.pi/workflows/gitlab-mr-review-code-quality.yaml +0 -31
  137. package/.pi/workflows/gitlab-mr-review-post-code-quality.yaml +0 -40
  138. package/.pi/workflows/gitlab-mr-review-post.yaml +0 -30
  139. package/.pi/workflows/local-staged-review.yaml +0 -17
  140. package/dist/cli/run-agent.test.d.ts +0 -2
  141. package/dist/cli/run-agent.test.d.ts.map +0 -1
  142. package/dist/cli/run-agent.test.js +0 -204
  143. package/dist/cli/run-agent.test.js.map +0 -1
  144. package/dist/cli/workflow.test.d.ts +0 -2
  145. package/dist/cli/workflow.test.d.ts.map +0 -1
  146. package/dist/cli/workflow.test.js +0 -1410
  147. package/dist/cli/workflow.test.js.map +0 -1
  148. package/dist/github/client.test.d.ts +0 -2
  149. package/dist/github/client.test.d.ts.map +0 -1
  150. package/dist/github/client.test.js +0 -206
  151. package/dist/github/client.test.js.map +0 -1
  152. package/dist/github/platform-adapter.test.d.ts +0 -2
  153. package/dist/github/platform-adapter.test.d.ts.map +0 -1
  154. package/dist/github/platform-adapter.test.js +0 -40
  155. package/dist/github/platform-adapter.test.js.map +0 -1
  156. package/dist/gitlab/diff-parser.test.d.ts +0 -2
  157. package/dist/gitlab/diff-parser.test.d.ts.map +0 -1
  158. package/dist/gitlab/diff-parser.test.js +0 -315
  159. package/dist/gitlab/diff-parser.test.js.map +0 -1
  160. package/dist/gitlab/platform-adapter.test.d.ts +0 -2
  161. package/dist/gitlab/platform-adapter.test.d.ts.map +0 -1
  162. package/dist/gitlab/platform-adapter.test.js +0 -21
  163. package/dist/gitlab/platform-adapter.test.js.map +0 -1
  164. package/dist/index.test.d.ts +0 -2
  165. package/dist/index.test.d.ts.map +0 -1
  166. package/dist/index.test.js +0 -7
  167. package/dist/index.test.js.map +0 -1
  168. package/dist/lib/code-quality-report.test.d.ts +0 -2
  169. package/dist/lib/code-quality-report.test.d.ts.map +0 -1
  170. package/dist/lib/code-quality-report.test.js +0 -327
  171. package/dist/lib/code-quality-report.test.js.map +0 -1
  172. package/dist/lib/comment-formatter.test.d.ts +0 -2
  173. package/dist/lib/comment-formatter.test.d.ts.map +0 -1
  174. package/dist/lib/comment-formatter.test.js +0 -727
  175. package/dist/lib/comment-formatter.test.js.map +0 -1
  176. package/dist/lib/comment-manager.test.d.ts +0 -2
  177. package/dist/lib/comment-manager.test.d.ts.map +0 -1
  178. package/dist/lib/comment-manager.test.js +0 -680
  179. package/dist/lib/comment-manager.test.js.map +0 -1
  180. package/dist/lib/comment-poster.test.d.ts +0 -5
  181. package/dist/lib/comment-poster.test.d.ts.map +0 -1
  182. package/dist/lib/comment-poster.test.js +0 -255
  183. package/dist/lib/comment-poster.test.js.map +0 -1
  184. package/dist/lib/config-model-overrides.test.d.ts +0 -2
  185. package/dist/lib/config-model-overrides.test.d.ts.map +0 -1
  186. package/dist/lib/config-model-overrides.test.js +0 -218
  187. package/dist/lib/config-model-overrides.test.js.map +0 -1
  188. package/dist/lib/config.test.d.ts +0 -2
  189. package/dist/lib/config.test.d.ts.map +0 -1
  190. package/dist/lib/config.test.js +0 -353
  191. package/dist/lib/config.test.js.map +0 -1
  192. package/dist/lib/context-compression.test.d.ts +0 -2
  193. package/dist/lib/context-compression.test.d.ts.map +0 -1
  194. package/dist/lib/context-compression.test.js +0 -337
  195. package/dist/lib/context-compression.test.js.map +0 -1
  196. package/dist/lib/context-loader.test.d.ts +0 -2
  197. package/dist/lib/context-loader.test.d.ts.map +0 -1
  198. package/dist/lib/context-loader.test.js +0 -212
  199. package/dist/lib/context-loader.test.js.map +0 -1
  200. package/dist/lib/cursor-fix-link.test.d.ts +0 -2
  201. package/dist/lib/cursor-fix-link.test.d.ts.map +0 -1
  202. package/dist/lib/cursor-fix-link.test.js +0 -70
  203. package/dist/lib/cursor-fix-link.test.js.map +0 -1
  204. package/dist/lib/describe-core.test.d.ts +0 -2
  205. package/dist/lib/describe-core.test.d.ts.map +0 -1
  206. package/dist/lib/describe-core.test.js +0 -208
  207. package/dist/lib/describe-core.test.js.map +0 -1
  208. package/dist/lib/describe-output-path.test.d.ts +0 -2
  209. package/dist/lib/describe-output-path.test.d.ts.map +0 -1
  210. package/dist/lib/describe-output-path.test.js +0 -51
  211. package/dist/lib/describe-output-path.test.js.map +0 -1
  212. package/dist/lib/describe-parser.test.d.ts +0 -2
  213. package/dist/lib/describe-parser.test.d.ts.map +0 -1
  214. package/dist/lib/describe-parser.test.js +0 -282
  215. package/dist/lib/describe-parser.test.js.map +0 -1
  216. package/dist/lib/description-executor.test.d.ts +0 -2
  217. package/dist/lib/description-executor.test.d.ts.map +0 -1
  218. package/dist/lib/description-executor.test.js +0 -135
  219. package/dist/lib/description-executor.test.js.map +0 -1
  220. package/dist/lib/description-formatter.test.d.ts +0 -2
  221. package/dist/lib/description-formatter.test.d.ts.map +0 -1
  222. package/dist/lib/description-formatter.test.js +0 -57
  223. package/dist/lib/description-formatter.test.js.map +0 -1
  224. package/dist/lib/diff-lines.test.d.ts +0 -2
  225. package/dist/lib/diff-lines.test.d.ts.map +0 -1
  226. package/dist/lib/diff-lines.test.js +0 -13
  227. package/dist/lib/diff-lines.test.js.map +0 -1
  228. package/dist/lib/diff-parser.test.d.ts +0 -2
  229. package/dist/lib/diff-parser.test.d.ts.map +0 -1
  230. package/dist/lib/diff-parser.test.js +0 -335
  231. package/dist/lib/diff-parser.test.js.map +0 -1
  232. package/dist/lib/error-comment-poster.test.d.ts +0 -2
  233. package/dist/lib/error-comment-poster.test.d.ts.map +0 -1
  234. package/dist/lib/error-comment-poster.test.js +0 -128
  235. package/dist/lib/error-comment-poster.test.js.map +0 -1
  236. package/dist/lib/exit.test.d.ts +0 -2
  237. package/dist/lib/exit.test.d.ts.map +0 -1
  238. package/dist/lib/exit.test.js +0 -120
  239. package/dist/lib/exit.test.js.map +0 -1
  240. package/dist/lib/issue-parser.test.d.ts +0 -2
  241. package/dist/lib/issue-parser.test.d.ts.map +0 -1
  242. package/dist/lib/issue-parser.test.js +0 -281
  243. package/dist/lib/issue-parser.test.js.map +0 -1
  244. package/dist/lib/json-output-schema.test.d.ts +0 -2
  245. package/dist/lib/json-output-schema.test.d.ts.map +0 -1
  246. package/dist/lib/json-output-schema.test.js +0 -92
  247. package/dist/lib/json-output-schema.test.js.map +0 -1
  248. package/dist/lib/json-output.test.d.ts +0 -2
  249. package/dist/lib/json-output.test.d.ts.map +0 -1
  250. package/dist/lib/json-output.test.js +0 -141
  251. package/dist/lib/json-output.test.js.map +0 -1
  252. package/dist/lib/logger.test.d.ts +0 -2
  253. package/dist/lib/logger.test.d.ts.map +0 -1
  254. package/dist/lib/logger.test.js +0 -324
  255. package/dist/lib/logger.test.js.map +0 -1
  256. package/dist/lib/position-validator.test.d.ts +0 -2
  257. package/dist/lib/position-validator.test.d.ts.map +0 -1
  258. package/dist/lib/position-validator.test.js +0 -128
  259. package/dist/lib/position-validator.test.js.map +0 -1
  260. package/dist/lib/prompt-budget.test.d.ts +0 -2
  261. package/dist/lib/prompt-budget.test.d.ts.map +0 -1
  262. package/dist/lib/prompt-budget.test.js +0 -55
  263. package/dist/lib/prompt-budget.test.js.map +0 -1
  264. package/dist/lib/repository-validator.test.d.ts +0 -5
  265. package/dist/lib/repository-validator.test.d.ts.map +0 -1
  266. package/dist/lib/repository-validator.test.js +0 -341
  267. package/dist/lib/repository-validator.test.js.map +0 -1
  268. package/dist/lib/review-core.test.d.ts +0 -2
  269. package/dist/lib/review-core.test.d.ts.map +0 -1
  270. package/dist/lib/review-core.test.js +0 -614
  271. package/dist/lib/review-core.test.js.map +0 -1
  272. package/dist/lib/review-orchestrator.test.d.ts +0 -2
  273. package/dist/lib/review-orchestrator.test.d.ts.map +0 -1
  274. package/dist/lib/review-orchestrator.test.js +0 -552
  275. package/dist/lib/review-orchestrator.test.js.map +0 -1
  276. package/dist/lib/review-output-path.test.d.ts +0 -2
  277. package/dist/lib/review-output-path.test.d.ts.map +0 -1
  278. package/dist/lib/review-output-path.test.js +0 -83
  279. package/dist/lib/review-output-path.test.js.map +0 -1
  280. package/dist/lib/review-parser.test.d.ts +0 -2
  281. package/dist/lib/review-parser.test.d.ts.map +0 -1
  282. package/dist/lib/review-parser.test.js +0 -130
  283. package/dist/lib/review-parser.test.js.map +0 -1
  284. package/dist/lib/review-usage.test.d.ts +0 -2
  285. package/dist/lib/review-usage.test.d.ts.map +0 -1
  286. package/dist/lib/review-usage.test.js +0 -83
  287. package/dist/lib/review-usage.test.js.map +0 -1
  288. package/dist/lib/unified-review-executor.d.ts +0 -58
  289. package/dist/lib/unified-review-executor.d.ts.map +0 -1
  290. package/dist/lib/unified-review-executor.js +0 -201
  291. package/dist/lib/unified-review-executor.js.map +0 -1
  292. package/dist/lib/unified-review-executor.test.d.ts +0 -5
  293. package/dist/lib/unified-review-executor.test.d.ts.map +0 -1
  294. package/dist/lib/unified-review-executor.test.js +0 -472
  295. package/dist/lib/unified-review-executor.test.js.map +0 -1
  296. package/dist/lib/write-json-output.test.d.ts +0 -2
  297. package/dist/lib/write-json-output.test.d.ts.map +0 -1
  298. package/dist/lib/write-json-output.test.js +0 -259
  299. package/dist/lib/write-json-output.test.js.map +0 -1
  300. package/dist/pi/sdk.test.d.ts +0 -2
  301. package/dist/pi/sdk.test.d.ts.map +0 -1
  302. package/dist/pi/sdk.test.js +0 -488
  303. package/dist/pi/sdk.test.js.map +0 -1
  304. package/dist/runtime/agent-loader.test.d.ts +0 -2
  305. package/dist/runtime/agent-loader.test.d.ts.map +0 -1
  306. package/dist/runtime/agent-loader.test.js +0 -277
  307. package/dist/runtime/agent-loader.test.js.map +0 -1
  308. package/dist/runtime/client.test.d.ts +0 -2
  309. package/dist/runtime/client.test.d.ts.map +0 -1
  310. package/dist/runtime/client.test.js +0 -772
  311. package/dist/runtime/client.test.js.map +0 -1
  312. package/dist/runtime/path-config.test.d.ts +0 -2
  313. package/dist/runtime/path-config.test.d.ts.map +0 -1
  314. package/dist/runtime/path-config.test.js +0 -112
  315. package/dist/runtime/path-config.test.js.map +0 -1
@@ -1,26 +1,30 @@
1
1
  import { mkdir, readFile, writeFile } from 'fs/promises';
2
- import { dirname } from 'path';
2
+ import { dirname, join } from 'path';
3
3
  import simpleGit from 'simple-git';
4
4
  import chalk from 'chalk';
5
- import { getDescriberModelOverride, loadWorkflowSourceInfo, normalizeAgentConfig, resolveAgentRunConfig, } from '../lib/config.js';
5
+ import { getDescriberModelOverride, getReviewAgentIds, loadWorkflowSourceInfo, normalizeAgentConfig, resolveAgentRunConfig, } from '../lib/config.js';
6
6
  import { resolveWithinWorkingDir } from '../lib/path-utils.js';
7
7
  import { parseDiff, getChangedFiles, getFilesWithDiffs } from '../lib/diff-parser.js';
8
- import { parseValidLinesFromPatch } from '../lib/diff-lines.js';
8
+ import { parseDiffLineInfo } from '../lib/diff-lines.js';
9
9
  import { connectToRuntime, executeReview, filterIgnoredFiles, } from '../lib/review-orchestrator.js';
10
- import { ExitError, setExitHandler } from '../lib/exit.js';
11
10
  import { postReviewComments } from '../lib/comment-poster.js';
12
- import { findExistingCommentById } from '../lib/comment-manager.js';
11
+ import { findExistingCommentById, createIssueFingerprint } from '../lib/comment-manager.js';
13
12
  import { removeErrorComment } from '../lib/error-comment-poster.js';
14
13
  import { runDescribeIfEnabled } from '../lib/description-executor.js';
15
14
  import { buildBaseInstructions } from '../lib/review-core.js';
16
15
  import { resolveCursorFixLinkOptions } from '../lib/cursor-fix-link.js';
17
- import { enforceRepoBranchMatch, getCanonicalDiffCommand, resolveBaseBranch, } from '../lib/repository-validator.js';
16
+ import { extractHtmlDocument, parseArtifactOutputPointer, readArtifactOutputPointer, validateHtmlArtifact, } from '../lib/html-artifact.js';
17
+ import { getCanonicalDiffCommand, resolveBaseBranch } from '../lib/repository-validator.js';
18
18
  import { formatCodeQualityReport, generateCodeQualityReport } from '../lib/code-quality-report.js';
19
19
  import { createGitHubClient } from '../github/client.js';
20
20
  import { GitHubPlatformAdapter } from '../github/platform-adapter.js';
21
21
  import { createGitLabClient } from '../gitlab/client.js';
22
22
  import { GitLabPlatformAdapter } from '../gitlab/platform-adapter.js';
23
+ import { loadWorkflowArtifact, saveWorkflowArtifact, updateWorkflowArtifact, workflowArtifactExists, } from '../lib/workflow-artifacts.js';
24
+ import { addReviewArtifactFinding, createReviewArtifactPayload, getReviewArtifactStatus, isReviewArtifactPayload, updateReviewArtifactFindings, } from '../lib/review-artifact.js';
23
25
  import { runAgent } from './run-agent.js';
26
+ import { TraceCollector } from '../lib/trace-collector.js';
27
+ import { renderTraceHtml } from '../lib/trace-html.js';
24
28
  function createWorkflowLock() {
25
29
  return { current: Promise.resolve() };
26
30
  }
@@ -85,6 +89,321 @@ function getNodeNeeds(node) {
85
89
  }
86
90
  return node.needs;
87
91
  }
92
+ function getControlTargets(node) {
93
+ const targets = [];
94
+ if (node.target)
95
+ targets.push(node.target);
96
+ if (node.exit)
97
+ targets.push(node.exit);
98
+ if (node.default)
99
+ targets.push(node.default);
100
+ if (node.cases)
101
+ targets.push(...Object.values(node.cases));
102
+ return targets;
103
+ }
104
+ function validateWorkflowControlTargets(nodes) {
105
+ for (const [nodeId, node] of Object.entries(nodes)) {
106
+ for (const target of getControlTargets(node)) {
107
+ if (!nodes[target]) {
108
+ throw new Error(`Workflow node "${nodeId}" targets unknown node "${target}".`);
109
+ }
110
+ }
111
+ }
112
+ }
113
+ function validateWorkflowControlRouteDirection(nodes, executionOrder) {
114
+ const segments = splitWorkflowSegments(nodes, executionOrder);
115
+ for (let segmentIndex = 0; segmentIndex < segments.length; segmentIndex++) {
116
+ const segment = segments[segmentIndex];
117
+ if (segment.type !== 'control') {
118
+ continue;
119
+ }
120
+ const node = nodes[segment.nodeId];
121
+ if (!node || node.control === 'loop') {
122
+ continue;
123
+ }
124
+ for (const target of getControlTargets(node)) {
125
+ const targetIndex = findWorkflowSegmentIndex(segments, target);
126
+ if (targetIndex <= segmentIndex) {
127
+ throw new Error(`Workflow control node "${segment.nodeId}" cannot jump backward to "${target}". Use control: loop with maxIterations for repeated execution.`);
128
+ }
129
+ }
130
+ }
131
+ }
132
+ function validateWorkflowPassThroughShape(nodeId, node) {
133
+ if (node.control !== 'passThrough') {
134
+ return;
135
+ }
136
+ if (!node.target) {
137
+ throw new Error(`Workflow passThrough node "${nodeId}" must define "target".`);
138
+ }
139
+ const forbiddenKeys = [
140
+ 'if',
141
+ 'then',
142
+ 'else',
143
+ 'exit',
144
+ 'cases',
145
+ 'default',
146
+ 'maxIterations',
147
+ 'onMaxIterations',
148
+ 'value',
149
+ ];
150
+ const extraFields = forbiddenKeys.filter((key) => key in node);
151
+ if (extraFields.length > 0) {
152
+ throw new Error(`Workflow passThrough node "${nodeId}" must not define extra control logic: ${extraFields.join(', ')}.`);
153
+ }
154
+ }
155
+ const WORKFLOW_NODE_FIELDS = new Set([
156
+ 'agent',
157
+ 'agentsFrom',
158
+ 'control',
159
+ 'action',
160
+ 'with',
161
+ 'needs',
162
+ 'if',
163
+ 'target',
164
+ 'exit',
165
+ 'maxIterations',
166
+ 'onMaxIterations',
167
+ 'value',
168
+ 'cases',
169
+ 'default',
170
+ 'input',
171
+ 'output',
172
+ 'writes',
173
+ 'json',
174
+ ]);
175
+ const EXECUTABLE_NODE_FIELDS = new Set([
176
+ 'agent',
177
+ 'agentsFrom',
178
+ 'action',
179
+ 'with',
180
+ 'needs',
181
+ 'if',
182
+ 'input',
183
+ 'output',
184
+ 'writes',
185
+ 'json',
186
+ ]);
187
+ const CONTROL_NODE_FIELDS = {
188
+ loop: new Set([
189
+ 'control',
190
+ 'needs',
191
+ 'if',
192
+ 'target',
193
+ 'exit',
194
+ 'maxIterations',
195
+ 'onMaxIterations',
196
+ 'output',
197
+ ]),
198
+ switch: new Set(['control', 'needs', 'value', 'cases', 'default', 'output']),
199
+ end: new Set(['control', 'needs', 'output']),
200
+ passThrough: new Set(['control', 'needs', 'target', 'output']),
201
+ };
202
+ const ACTION_OPTION_FIELDS = {
203
+ write: new Set(),
204
+ 'git-diff': new Set(['staged']),
205
+ 'git-add': new Set(['path', 'paths']),
206
+ 'git-branch': new Set(['name', 'from', 'force']),
207
+ 'git-commit': new Set(['message', 'path', 'paths']),
208
+ 'git-push': new Set(['remote', 'branch', 'remoteBranch', 'setUpstream', 'force']),
209
+ 'has-diff': new Set(['path', 'paths']),
210
+ 'stack-guard': new Set(['source', 'allowStackedSource', 'reservedPrefixes']),
211
+ 'review-threshold': new Set(['review', 'severity', 'minIssues']),
212
+ 'save-artifact': new Set([
213
+ 'kind',
214
+ 'source',
215
+ 'artifact',
216
+ 'payload',
217
+ 'platform',
218
+ 'project',
219
+ 'projectId',
220
+ 'owner',
221
+ 'repo',
222
+ 'subject',
223
+ 'changeKind',
224
+ 'changeNumber',
225
+ 'pr',
226
+ 'mr',
227
+ 'branch',
228
+ ]),
229
+ 'load-artifact': new Set([
230
+ 'kind',
231
+ 'source',
232
+ 'id',
233
+ 'platform',
234
+ 'project',
235
+ 'projectId',
236
+ 'owner',
237
+ 'repo',
238
+ 'subject',
239
+ 'changeKind',
240
+ 'changeNumber',
241
+ 'pr',
242
+ 'mr',
243
+ 'branch',
244
+ ]),
245
+ 'artifact-exists': new Set([
246
+ 'kind',
247
+ 'source',
248
+ 'id',
249
+ 'platform',
250
+ 'project',
251
+ 'projectId',
252
+ 'owner',
253
+ 'repo',
254
+ 'subject',
255
+ 'changeKind',
256
+ 'changeNumber',
257
+ 'pr',
258
+ 'mr',
259
+ 'branch',
260
+ ]),
261
+ 'create-review-artifact': new Set(['source', 'review']),
262
+ 'review-artifact-status': new Set(['artifact']),
263
+ 'review-artifact-add-finding': new Set(['artifact', 'issue', 'source']),
264
+ 'review-artifact-update-findings': new Set([
265
+ 'artifact',
266
+ 'state',
267
+ 'disposition',
268
+ 'ids',
269
+ 'fingerprints',
270
+ 'severity',
271
+ ]),
272
+ 'review-artifact-promote-finding': new Set(['artifact', 'ids', 'fingerprints', 'severity']),
273
+ 'review-artifact-resolve-finding': new Set(['artifact', 'ids', 'fingerprints', 'severity']),
274
+ 'verify-fix': new Set(['artifact', 'review', 'fixChange', 'severity', 'minIssues']),
275
+ 'create-change-request': new Set([
276
+ 'platform',
277
+ 'owner',
278
+ 'repo',
279
+ 'project',
280
+ 'projectId',
281
+ 'sourceBranch',
282
+ 'head',
283
+ 'targetBranch',
284
+ 'base',
285
+ 'title',
286
+ 'body',
287
+ 'draft',
288
+ 'reuseExisting',
289
+ ]),
290
+ 'create-pr': new Set([
291
+ 'platform',
292
+ 'owner',
293
+ 'repo',
294
+ 'project',
295
+ 'projectId',
296
+ 'sourceBranch',
297
+ 'head',
298
+ 'targetBranch',
299
+ 'base',
300
+ 'title',
301
+ 'body',
302
+ 'draft',
303
+ 'reuseExisting',
304
+ ]),
305
+ 'create-mr': new Set([
306
+ 'platform',
307
+ 'owner',
308
+ 'repo',
309
+ 'project',
310
+ 'projectId',
311
+ 'sourceBranch',
312
+ 'head',
313
+ 'targetBranch',
314
+ 'base',
315
+ 'title',
316
+ 'body',
317
+ 'draft',
318
+ 'reuseExisting',
319
+ ]),
320
+ 'change-source': new Set([
321
+ 'type',
322
+ 'staged',
323
+ 'from',
324
+ 'to',
325
+ 'includePrereleaseFrom',
326
+ 'owner',
327
+ 'repo',
328
+ 'pr',
329
+ 'project',
330
+ 'projectId',
331
+ 'mr',
332
+ 'mrIid',
333
+ 'source',
334
+ 'fixChange',
335
+ ]),
336
+ review: new Set(['source', 'reviewArtifact', 'severity', 'artifact']),
337
+ 'review-context': new Set(['source', 'file', 'baseBranch']),
338
+ describe: new Set(['source', 'post', 'postDescription']),
339
+ 'code-quality-report': new Set(['review', 'path']),
340
+ 'post-comment': new Set([
341
+ 'source',
342
+ 'platform',
343
+ 'owner',
344
+ 'repo',
345
+ 'project',
346
+ 'projectId',
347
+ 'pr',
348
+ 'mr',
349
+ 'prNumber',
350
+ 'mrIid',
351
+ 'body',
352
+ 'marker',
353
+ ]),
354
+ 'post-review-comments': new Set(['source', 'review', 'removeErrorComment']),
355
+ 'post-fix-status': new Set([
356
+ 'platform',
357
+ 'owner',
358
+ 'repo',
359
+ 'project',
360
+ 'projectId',
361
+ 'pr',
362
+ 'mr',
363
+ 'source',
364
+ 'reviewArtifact',
365
+ 'fixReview',
366
+ 'fixChange',
367
+ 'severity',
368
+ 'stackedPrUrl',
369
+ 'marker',
370
+ ]),
371
+ };
372
+ function validateAllowedFields(nodeId, value, allowed, subject) {
373
+ const unknownFields = Object.keys(value).filter((key) => !allowed.has(key));
374
+ if (unknownFields.length > 0) {
375
+ throw new Error(`Workflow ${subject} "${nodeId}" has unsupported field(s): ${unknownFields.join(', ')}.`);
376
+ }
377
+ }
378
+ function validateWorkflowNodeShape(nodeId, node) {
379
+ validateAllowedFields(nodeId, node, WORKFLOW_NODE_FIELDS, 'node');
380
+ const kind = getNodeKind(node);
381
+ if (kind === 'control') {
382
+ const control = node.control;
383
+ if (!control || !CONTROL_NODE_FIELDS[control]) {
384
+ throw new Error(`Workflow control node "${nodeId}" has unsupported control "${String(control)}".`);
385
+ }
386
+ validateAllowedFields(nodeId, node, CONTROL_NODE_FIELDS[control], 'control node');
387
+ return;
388
+ }
389
+ validateAllowedFields(nodeId, node, EXECUTABLE_NODE_FIELDS, 'node');
390
+ if (node.action && node.with) {
391
+ const allowed = ACTION_OPTION_FIELDS[node.action];
392
+ if (!allowed) {
393
+ throw new Error(`Workflow action node "${nodeId}" has unsupported action "${node.action}".`);
394
+ }
395
+ validateAllowedFields(nodeId, node.with, allowed, `node "${nodeId}" with`);
396
+ }
397
+ }
398
+ function validateWorkflowNodeKinds(nodes) {
399
+ for (const [nodeId, node] of Object.entries(nodes)) {
400
+ validateWorkflowNodeShape(nodeId, node);
401
+ validateWorkflowPassThroughShape(nodeId, node);
402
+ }
403
+ }
404
+ function hasWorkflowControlNodes(nodes) {
405
+ return Object.values(nodes).some((node) => node.control !== undefined);
406
+ }
88
407
  function getWorkflowExecutionOrder(nodes) {
89
408
  const nodeIds = Object.keys(nodes);
90
409
  const visiting = new Set();
@@ -115,7 +434,13 @@ function getWorkflowExecutionOrder(nodes) {
115
434
  for (const nodeId of nodeIds) {
116
435
  visit(nodeId);
117
436
  }
118
- return order;
437
+ validateWorkflowNodeKinds(nodes);
438
+ validateWorkflowControlTargets(nodes);
439
+ const standaloneEndNodes = order.filter((nodeId) => nodes[nodeId]?.control === 'end' && getNodeNeeds(nodes[nodeId] ?? {}).length === 0);
440
+ const nonStandaloneEndNodes = order.filter((nodeId) => !standaloneEndNodes.includes(nodeId));
441
+ const executionOrder = [...nonStandaloneEndNodes, ...standaloneEndNodes];
442
+ validateWorkflowControlRouteDirection(nodes, executionOrder);
443
+ return executionOrder;
119
444
  }
120
445
  function getWorkflowNodes(workflowName, workflow) {
121
446
  const nodes = workflow.nodes;
@@ -174,23 +499,83 @@ function renderTemplate(template, context) {
174
499
  return stringifyTemplateValue(value);
175
500
  });
176
501
  }
502
+ async function flushWorkflowTrace(traceCollector, workflowName, inputs, startedAt, workingDir, options) {
503
+ const workflowTrace = traceCollector.buildWorkflowTrace(workflowName, inputs, startedAt);
504
+ const scope = {
505
+ platform: 'local',
506
+ projectId: workflowName,
507
+ subject: 'trace',
508
+ };
509
+ const savedJson = await saveWorkflowArtifact(workingDir, {
510
+ kind: 'trace',
511
+ scope,
512
+ payload: workflowTrace,
513
+ });
514
+ const traceHtmlPath = join(dirname(savedJson.path), 'trace.html');
515
+ const html = renderTraceHtml(workflowTrace);
516
+ await writeWorkflowFile(workingDir, traceHtmlPath, html);
517
+ if (!options.jsonOutput) {
518
+ console.log(chalk.green(`\n✓ Trace saved to ${savedJson.latestPath}`));
519
+ console.log(chalk.green(`✓ Trace viewer saved to ${traceHtmlPath}`));
520
+ }
521
+ }
177
522
  async function resolveWorkflowInput(key, input, workingDir) {
178
523
  if (typeof input === 'string') {
179
524
  return input;
180
525
  }
181
- const hasValue = input.value !== undefined;
526
+ const hasValue = input.value !== undefined || input.default !== undefined;
182
527
  const hasFile = input.file !== undefined;
183
528
  if (hasValue && hasFile) {
184
- throw new Error(`Workflow input "${key}" cannot define both value and file.`);
529
+ throw new Error(`Workflow input "${key}" cannot define both value/default and file.`);
185
530
  }
186
531
  if (hasValue) {
187
- return input.value ?? '';
532
+ return String(input.value ?? input.default ?? '');
188
533
  }
189
534
  if (hasFile) {
190
535
  const inputPath = resolveWithinWorkingDir(workingDir, input.file ?? '', 'read');
191
536
  return readFile(inputPath, 'utf-8');
192
537
  }
193
- throw new Error(`Workflow input "${key}" must define value or file.`);
538
+ if (input.required === true) {
539
+ return '';
540
+ }
541
+ return '';
542
+ }
543
+ function getWorkflowInputConfigType(input) {
544
+ return typeof input === 'string' ? 'string' : (input.type ?? 'string');
545
+ }
546
+ function validateResolvedWorkflowInput(key, input, value) {
547
+ if (typeof input === 'string') {
548
+ return;
549
+ }
550
+ if (input.required === true && value.trim() === '') {
551
+ throw new Error(`Workflow input "${key}" is required.`);
552
+ }
553
+ const type = getWorkflowInputConfigType(input);
554
+ if (type === 'boolean') {
555
+ if (normalizeWorkflowBooleanLike(value) === undefined) {
556
+ throw new Error(`Workflow input "${key}" must be a boolean value.`);
557
+ }
558
+ return;
559
+ }
560
+ if (type === 'number') {
561
+ if (value.trim() === '' || !Number.isFinite(Number(value))) {
562
+ throw new Error(`Workflow input "${key}" must be a number.`);
563
+ }
564
+ return;
565
+ }
566
+ if (type === 'enum') {
567
+ const allowedValues = input.values?.map(String) ?? [];
568
+ if (allowedValues.length === 0) {
569
+ throw new Error(`Workflow input "${key}" with type enum must define values.`);
570
+ }
571
+ if (!allowedValues.includes(value)) {
572
+ throw new Error(`Workflow input "${key}" must be one of: ${allowedValues.join(', ')}.`);
573
+ }
574
+ return;
575
+ }
576
+ if (type !== 'string') {
577
+ throw new Error(`Workflow input "${key}" has unsupported type "${type}".`);
578
+ }
194
579
  }
195
580
  async function resolveWorkflowInputs(workflow, options, workingDir) {
196
581
  const values = {};
@@ -204,6 +589,9 @@ async function resolveWorkflowInputs(workflow, options, workingDir) {
204
589
  const resolvedPath = resolveWithinWorkingDir(workingDir, filePath, 'read');
205
590
  values[key] = await readFile(resolvedPath, 'utf-8');
206
591
  }
592
+ for (const [key, input] of Object.entries(workflow.inputs ?? {})) {
593
+ validateResolvedWorkflowInput(key, input, values[key] ?? '');
594
+ }
207
595
  return values;
208
596
  }
209
597
  function resolveAgentsFrom(config, agentsFrom) {
@@ -213,14 +601,16 @@ function resolveAgentsFrom(config, agentsFrom) {
213
601
  throw new Error(`Unsupported workflow agentsFrom "${agentsFrom}". ` + 'Currently supported: review.agents.');
214
602
  }
215
603
  function getNodeKind(node) {
216
- const configuredKinds = [node.agent, node.agentsFrom, node.action].filter((value) => value !== undefined).length;
604
+ const configuredKinds = [node.agent, node.agentsFrom, node.action, node.control].filter((value) => value !== undefined).length;
217
605
  if (configuredKinds !== 1) {
218
- throw new Error('Workflow node must define exactly one of agent, agentsFrom, or action.');
606
+ throw new Error('Workflow node must define exactly one of agent, agentsFrom, action, or control.');
219
607
  }
220
608
  if (node.agent !== undefined)
221
609
  return 'agent';
222
610
  if (node.agentsFrom !== undefined)
223
611
  return 'agents';
612
+ if (node.control !== undefined)
613
+ return 'control';
224
614
  return 'action';
225
615
  }
226
616
  function hasConfiguredAgentPrompt(config, agentId) {
@@ -247,6 +637,35 @@ async function writeWorkflowFile(workingDir, relativeOutputPath, content) {
247
637
  await mkdir(dirname(outputPath), { recursive: true });
248
638
  await writeFile(outputPath, content, 'utf-8');
249
639
  }
640
+ async function formatWorkflowNodeWriteContent(workingDir, nodeId, writes, content) {
641
+ if (!/\.html?$/i.test(writes)) {
642
+ return content;
643
+ }
644
+ const pointer = parseArtifactOutputPointer(content);
645
+ if (pointer) {
646
+ if (pointer.outputPath !== writes) {
647
+ throw new Error(`Workflow node "${nodeId}" artifact pointer wrote "${pointer.outputPath}" but workflow expected "${writes}".`);
648
+ }
649
+ return readArtifactOutputPointer(workingDir, pointer);
650
+ }
651
+ try {
652
+ const html = extractHtmlDocument(content);
653
+ validateHtmlArtifact(html);
654
+ return html;
655
+ }
656
+ catch (error) {
657
+ try {
658
+ return await readArtifactOutputPointer(workingDir, {
659
+ outputType: 'artifact_output',
660
+ outputPath: writes,
661
+ });
662
+ }
663
+ catch {
664
+ // If the agent did not write a valid artifact itself, surface the response validation error.
665
+ }
666
+ throw new Error(`Workflow node "${nodeId}" produced invalid HTML output: ${error instanceof Error ? error.message : String(error)}`);
667
+ }
668
+ }
250
669
  function renderNodeWritesPath(nodeId, node, context) {
251
670
  if (!node.writes) {
252
671
  return undefined;
@@ -257,7 +676,7 @@ function renderNodeWritesPath(nodeId, node, context) {
257
676
  }
258
677
  return writes;
259
678
  }
260
- async function runAgentWorkflowNode(config, nodeId, node, options, workingDir, context) {
679
+ async function runAgentWorkflowNode(config, nodeId, node, options, workingDir, context, executionContext) {
261
680
  const agentId = node.agent;
262
681
  if (!agentId) {
263
682
  throw new Error(`Workflow node "${nodeId}" is missing agent.`);
@@ -267,17 +686,25 @@ async function runAgentWorkflowNode(config, nodeId, node, options, workingDir, c
267
686
  throw new Error(`Workflow agent node "${nodeId}" must define input or configure ` +
268
687
  `agents.overrides.${agentId}.run.prompt/promptFile.`);
269
688
  }
270
- const result = await runAgent(config, agentId, createAgentOptions(prompt, options, workingDir));
689
+ const agentOptions = createAgentOptions(prompt, options, workingDir);
690
+ if (executionContext?.traceCollector && prompt) {
691
+ agentOptions.traceCollector = executionContext.traceCollector;
692
+ executionContext.traceCollector.setContext(nodeId, agentId, prompt);
693
+ }
694
+ const result = await runAgent(config, agentId, agentOptions);
271
695
  const writes = renderNodeWritesPath(nodeId, node, context);
696
+ const output = writes
697
+ ? await formatWorkflowNodeWriteContent(workingDir, nodeId, writes, node.json === true ? JSON.stringify(result, null, 2) : result.response)
698
+ : result.response;
272
699
  if (writes) {
273
- await writeWorkflowFile(workingDir, writes, node.json === true ? JSON.stringify(result, null, 2) : result.response);
700
+ await writeWorkflowFile(workingDir, writes, output);
274
701
  }
275
702
  return {
276
703
  id: nodeId,
277
704
  type: 'agent',
278
705
  agent: agentId,
279
706
  response: result.response,
280
- output: result.response,
707
+ output,
281
708
  writes,
282
709
  };
283
710
  }
@@ -323,9 +750,64 @@ async function runActionWorkflowNode(config, nodeId, node, options, workingDir,
323
750
  if (node.action === 'git-add') {
324
751
  return runGitAddWorkflowNode(nodeId, node, workingDir, context, executionContext);
325
752
  }
753
+ if (node.action === 'git-branch') {
754
+ return runGitBranchWorkflowNode(nodeId, node, workingDir, context, executionContext);
755
+ }
326
756
  if (node.action === 'git-commit') {
327
757
  return runGitCommitWorkflowNode(nodeId, node, workingDir, context, executionContext);
328
758
  }
759
+ if (node.action === 'git-push') {
760
+ return runGitPushWorkflowNode(nodeId, node, workingDir, context, executionContext);
761
+ }
762
+ if (node.action === 'has-diff') {
763
+ return runHasDiffWorkflowNode(nodeId, node, workingDir, context, executionContext);
764
+ }
765
+ if (node.action === 'stack-guard') {
766
+ return runStackGuardWorkflowNode(nodeId, node, context);
767
+ }
768
+ if (node.action === 'review-threshold') {
769
+ return runReviewThresholdWorkflowNode(nodeId, node, context);
770
+ }
771
+ if (node.action === 'save-artifact') {
772
+ return runSaveArtifactWorkflowNode(nodeId, node, workingDir, context, executionContext);
773
+ }
774
+ if (node.action === 'load-artifact') {
775
+ return runLoadArtifactWorkflowNode(nodeId, node, workingDir, context, executionContext);
776
+ }
777
+ if (node.action === 'artifact-exists') {
778
+ return runArtifactExistsWorkflowNode(nodeId, node, workingDir, context, executionContext);
779
+ }
780
+ if (node.action === 'create-review-artifact') {
781
+ return runCreateReviewArtifactWorkflowNode(nodeId, node, context);
782
+ }
783
+ if (node.action === 'review-artifact-status') {
784
+ return runReviewArtifactStatusWorkflowNode(nodeId, node, context);
785
+ }
786
+ if (node.action === 'review-artifact-add-finding') {
787
+ return runReviewArtifactAddFindingWorkflowNode(nodeId, node, workingDir, context);
788
+ }
789
+ if (node.action === 'review-artifact-update-findings') {
790
+ return runReviewArtifactUpdateFindingsWorkflowNode(nodeId, node, workingDir, context);
791
+ }
792
+ if (node.action === 'verify-fix') {
793
+ return runVerifyFixWorkflowNode(nodeId, node, workingDir, context);
794
+ }
795
+ if (node.action === 'review-artifact-promote-finding') {
796
+ return runReviewArtifactUpdateFindingsWorkflowNode(nodeId, node, workingDir, context, {
797
+ disposition: 'confirmed',
798
+ });
799
+ }
800
+ if (node.action === 'review-artifact-resolve-finding') {
801
+ return runReviewArtifactUpdateFindingsWorkflowNode(nodeId, node, workingDir, context, {
802
+ state: 'resolved',
803
+ disposition: 'resolved',
804
+ });
805
+ }
806
+ if (node.action === 'create-change-request' ||
807
+ node.action === 'create-pr' ||
808
+ node.action === 'create-mr') {
809
+ return runCreateChangeRequestWorkflowNode(nodeId, node, context, executionContext);
810
+ }
329
811
  if (node.action === 'change-source') {
330
812
  return runChangeSourceWorkflowNode(nodeId, node, workingDir, context, executionContext);
331
813
  }
@@ -347,6 +829,9 @@ async function runActionWorkflowNode(config, nodeId, node, options, workingDir,
347
829
  if (node.action === 'post-review-comments') {
348
830
  return runPostReviewCommentsWorkflowNode(config, nodeId, node, options, workingDir, context, executionContext);
349
831
  }
832
+ if (node.action === 'post-fix-status') {
833
+ return runPostFixStatusWorkflowNode(nodeId, node, options, workingDir, context, executionContext);
834
+ }
350
835
  throw new Error(`Unsupported workflow action "${node.action}" in node "${nodeId}".`);
351
836
  }
352
837
  async function runWriteWorkflowNode(nodeId, node, workingDir, context) {
@@ -371,9 +856,13 @@ async function runWriteWorkflowNode(nodeId, node, workingDir, context) {
371
856
  writes: relativeOutputPath,
372
857
  };
373
858
  }
374
- function getBooleanActionOption(node, key) {
859
+ function getBooleanActionOption(node, key, context) {
375
860
  const value = node.with?.[key];
376
- return value === true || value === 'true';
861
+ if (typeof value === 'string' && context) {
862
+ const rendered = renderTemplate(value, context).trim().toLowerCase();
863
+ return rendered === 'true' || rendered === '1' || rendered === 'yes';
864
+ }
865
+ return value === true || value === 'true' || value === 1;
377
866
  }
378
867
  function getStringActionOption(node, key, context) {
379
868
  const value = node.with?.[key];
@@ -419,6 +908,12 @@ function getPathActionOption(nodeId, node, context, workingDir) {
419
908
  }
420
909
  return paths;
421
910
  }
911
+ function getOptionalPathActionOption(nodeId, node, context, workingDir) {
912
+ if (!hasActionOption(node, 'paths') && !hasActionOption(node, 'path')) {
913
+ return undefined;
914
+ }
915
+ return getPathActionOption(nodeId, node, context, workingDir);
916
+ }
422
917
  async function requireWorkflowGitRepo(nodeId, workingDir, executionContext) {
423
918
  const git = getWorkflowGitClient(executionContext, workingDir);
424
919
  const isRepo = await git.checkIsRepo();
@@ -433,7 +928,7 @@ async function runGitDiffWorkflowNode(nodeId, node, workingDir, context, executi
433
928
  if (!isRepo) {
434
929
  throw new Error(`Workflow git-diff node "${nodeId}" must run from a git repository.`);
435
930
  }
436
- const staged = getBooleanActionOption(node, 'staged');
931
+ const staged = getBooleanActionOption(node, 'staged', context);
437
932
  const diff = staged ? await git.diff(['--cached']) : await git.diff();
438
933
  const writes = renderNodeWritesPath(nodeId, node, context);
439
934
  if (writes) {
@@ -460,6 +955,24 @@ async function runGitAddWorkflowNode(nodeId, node, workingDir, context, executio
460
955
  output: paths,
461
956
  };
462
957
  }
958
+ async function runGitBranchWorkflowNode(nodeId, node, workingDir, context, executionContext) {
959
+ const git = await requireWorkflowGitRepo(nodeId, workingDir, executionContext);
960
+ const name = requireStringActionOption(nodeId, node, 'name', context);
961
+ const from = getStringActionOption(node, 'from', context)?.trim();
962
+ const force = getBooleanActionOption(node, 'force', context);
963
+ const args = ['checkout', force ? '-B' : '-b', name];
964
+ if (from) {
965
+ args.push(from);
966
+ }
967
+ await git.raw(args);
968
+ return {
969
+ id: nodeId,
970
+ type: 'action',
971
+ action: node.action,
972
+ response: `checked out branch ${name}`,
973
+ output: { branch: name, from, force },
974
+ };
975
+ }
463
976
  async function runGitCommitWorkflowNode(nodeId, node, workingDir, context, executionContext) {
464
977
  const git = await requireWorkflowGitRepo(nodeId, workingDir, executionContext);
465
978
  const message = requireStringActionOption(nodeId, node, 'message', context);
@@ -484,13 +997,464 @@ async function runGitCommitWorkflowNode(nodeId, node, workingDir, context, execu
484
997
  output,
485
998
  };
486
999
  }
487
- async function loadLocalChangeSource(nodeId, node, workingDir) {
1000
+ async function runGitPushWorkflowNode(nodeId, node, workingDir, context, executionContext) {
1001
+ const git = await requireWorkflowGitRepo(nodeId, workingDir, executionContext);
1002
+ const configuredRemote = getStringActionOption(node, 'remote', context)?.trim();
1003
+ const remote = configuredRemote && configuredRemote.length > 0 ? configuredRemote : 'origin';
1004
+ const branch = requireStringActionOption(nodeId, node, 'branch', context);
1005
+ const configuredRemoteBranch = getStringActionOption(node, 'remoteBranch', context)?.trim();
1006
+ const remoteBranch = configuredRemoteBranch && configuredRemoteBranch.length > 0 ? configuredRemoteBranch : branch;
1007
+ const setUpstream = !hasActionOption(node, 'setUpstream') || getBooleanActionOption(node, 'setUpstream', context);
1008
+ const force = getBooleanActionOption(node, 'force', context);
1009
+ const args = ['push'];
1010
+ if (setUpstream) {
1011
+ args.push('-u');
1012
+ }
1013
+ if (force) {
1014
+ args.push('--force-with-lease');
1015
+ }
1016
+ args.push(remote, `${branch}:${remoteBranch}`);
1017
+ await git.raw(args);
1018
+ return {
1019
+ id: nodeId,
1020
+ type: 'action',
1021
+ action: node.action,
1022
+ response: `pushed ${branch} to ${remote}/${remoteBranch}`,
1023
+ output: { remote, branch, remoteBranch, setUpstream, force },
1024
+ };
1025
+ }
1026
+ async function runHasDiffWorkflowNode(nodeId, node, workingDir, context, executionContext) {
1027
+ const git = await requireWorkflowGitRepo(nodeId, workingDir, executionContext);
1028
+ const paths = getOptionalPathActionOption(nodeId, node, context, workingDir);
1029
+ const diff = paths ? await git.diff(['--', ...paths]) : await git.diff();
1030
+ const files = paths ?? [];
1031
+ const changed = diff.trim().length > 0;
1032
+ return {
1033
+ id: nodeId,
1034
+ type: 'action',
1035
+ action: node.action,
1036
+ response: changed ? 'changes found' : 'no changes found',
1037
+ output: { changed, files, bytes: Buffer.byteLength(diff, 'utf8') },
1038
+ };
1039
+ }
1040
+ function runStackGuardWorkflowNode(nodeId, node, context) {
1041
+ const sourceArtifact = getStringActionOption(node, 'source', context) ?? 'change';
1042
+ const source = context.artifacts[sourceArtifact];
1043
+ if (!isReviewSource(source)) {
1044
+ throw new Error(`Workflow stack-guard node "${nodeId}" needs a ReviewSource artifact.`);
1045
+ }
1046
+ const pullRequest = isPullRequest(source.context.pullRequest)
1047
+ ? source.context.pullRequest
1048
+ : undefined;
1049
+ const sourceBranch = pullRequest?.sourceBranch ?? '';
1050
+ const allowStackedSource = getBooleanActionOption(node, 'allowStackedSource', context);
1051
+ const rawPrefixes = getStringActionOption(node, 'reservedPrefixes', context) ?? 'drs-fix/,drs-guidance/,drs-stack/';
1052
+ const reservedPrefixes = rawPrefixes
1053
+ .split(/[,\n]/)
1054
+ .map((prefix) => prefix.trim())
1055
+ .filter(Boolean);
1056
+ const matchingPrefix = reservedPrefixes.find((prefix) => sourceBranch.startsWith(prefix));
1057
+ const allowed = allowStackedSource || !matchingPrefix;
1058
+ const reason = allowed ? 'allowed' : `source branch uses reserved DRS prefix "${matchingPrefix}"`;
1059
+ return {
1060
+ id: nodeId,
1061
+ type: 'action',
1062
+ action: node.action,
1063
+ response: reason,
1064
+ output: { allowed, reason, sourceBranch, reservedPrefixes },
1065
+ };
1066
+ }
1067
+ function severityRank(severity) {
1068
+ const normalized = severity.trim().toUpperCase();
1069
+ if (normalized === 'CRITICAL')
1070
+ return 4;
1071
+ if (normalized === 'HIGH')
1072
+ return 3;
1073
+ if (normalized === 'MEDIUM')
1074
+ return 2;
1075
+ if (normalized === 'LOW')
1076
+ return 1;
1077
+ return 0;
1078
+ }
1079
+ function runReviewThresholdWorkflowNode(nodeId, node, context) {
1080
+ const reviewArtifact = getStringActionOption(node, 'review', context) ?? 'review';
1081
+ const reviewResult = context.artifacts[reviewArtifact];
1082
+ if (!isReviewResult(reviewResult)) {
1083
+ throw new Error(`Workflow review-threshold node "${nodeId}" needs a ReviewResult artifact.`);
1084
+ }
1085
+ const severity = (getStringActionOption(node, 'severity', context) ?? 'high').toUpperCase();
1086
+ const minIssues = hasActionOption(node, 'minIssues')
1087
+ ? requireNumberActionOption(nodeId, node, 'minIssues', context)
1088
+ : 1;
1089
+ const thresholdRank = severityRank(severity);
1090
+ if (thresholdRank === 0) {
1091
+ throw new Error(`Workflow review-threshold node "${nodeId}" has unsupported severity "${severity}".`);
1092
+ }
1093
+ const matchingIssues = reviewResult.issues.filter((issue) => severityRank(issue.severity) >= thresholdRank);
1094
+ const matched = matchingIssues.length >= minIssues;
1095
+ return {
1096
+ id: nodeId,
1097
+ type: 'action',
1098
+ action: node.action,
1099
+ response: matched ? `${matchingIssues.length} matching issue(s)` : 'threshold not met',
1100
+ output: { matched, count: matchingIssues.length, severity, minIssues },
1101
+ };
1102
+ }
1103
+ async function getCurrentBranch(workingDir, executionContext) {
1104
+ const git = await requireWorkflowGitRepo('artifact-scope', workingDir, executionContext);
1105
+ const branch = await git.branch();
1106
+ return branch.current ?? 'unknown';
1107
+ }
1108
+ async function resolveArtifactScope(nodeId, node, workingDir, context, executionContext) {
1109
+ const sourceArtifact = getStringActionOption(node, 'source', context);
1110
+ const source = sourceArtifact ? context.artifacts[sourceArtifact] : undefined;
1111
+ const reviewSource = isReviewSource(source) ? source : undefined;
1112
+ const sourceTarget = readSourcePostTarget(reviewSource);
1113
+ const explicitPlatform = getStringActionOption(node, 'platform', context);
1114
+ const platform = explicitPlatform ?? sourceTarget.platform ?? 'local';
1115
+ const projectId = getStringActionOption(node, 'project', context) ??
1116
+ getStringActionOption(node, 'projectId', context) ??
1117
+ (hasActionOption(node, 'owner') || hasActionOption(node, 'repo')
1118
+ ? `${requireStringActionOption(nodeId, node, 'owner', context)}/${requireStringActionOption(nodeId, node, 'repo', context)}`
1119
+ : undefined) ??
1120
+ sourceTarget.projectId ??
1121
+ 'local';
1122
+ const explicitSubject = getStringActionOption(node, 'subject', context)?.trim();
1123
+ if (explicitSubject) {
1124
+ return { platform, projectId, subject: explicitSubject };
1125
+ }
1126
+ const explicitChangeKind = getStringActionOption(node, 'changeKind', context)?.trim();
1127
+ const explicitChangeNumber = getStringActionOption(node, 'changeNumber', context)?.trim() ??
1128
+ getStringActionOption(node, 'pr', context)?.trim() ??
1129
+ getStringActionOption(node, 'mr', context)?.trim();
1130
+ const sourceChangeKind = sourceTarget.platform === 'gitlab' ? 'mr' : sourceTarget.platform ? 'pr' : undefined;
1131
+ const sourceChangeNumber = sourceTarget.prNumber;
1132
+ const changeKind = explicitChangeKind && explicitChangeKind.length > 0 ? explicitChangeKind : sourceChangeKind;
1133
+ const changeNumber = explicitChangeNumber ?? sourceChangeNumber;
1134
+ if (changeKind && changeNumber !== undefined) {
1135
+ return { platform, projectId, changeKind, changeNumber };
1136
+ }
1137
+ const configuredBranch = getStringActionOption(node, 'branch', context)?.trim();
1138
+ const branch = configuredBranch && configuredBranch.length > 0
1139
+ ? configuredBranch
1140
+ : await getCurrentBranch(workingDir, executionContext);
1141
+ return { platform, projectId, branch };
1142
+ }
1143
+ function getArtifactPayloadFromNode(nodeId, node, context) {
1144
+ const artifactName = getStringActionOption(node, 'artifact', context);
1145
+ if (artifactName) {
1146
+ if (!Object.prototype.hasOwnProperty.call(context.artifacts, artifactName)) {
1147
+ throw new Error(`Workflow node "${nodeId}" references unknown artifact "${artifactName}".`);
1148
+ }
1149
+ return context.artifacts[artifactName];
1150
+ }
1151
+ const payloadName = getStringActionOption(node, 'payload', context);
1152
+ if (payloadName && Object.prototype.hasOwnProperty.call(context.artifacts, payloadName)) {
1153
+ return context.artifacts[payloadName];
1154
+ }
1155
+ if (payloadName) {
1156
+ try {
1157
+ return JSON.parse(payloadName);
1158
+ }
1159
+ catch {
1160
+ return payloadName;
1161
+ }
1162
+ }
1163
+ throw new Error(`Workflow node "${nodeId}" must define with.artifact or with.payload.`);
1164
+ }
1165
+ async function runSaveArtifactWorkflowNode(nodeId, node, workingDir, context, executionContext) {
1166
+ const kind = requireStringActionOption(nodeId, node, 'kind', context);
1167
+ const payload = getArtifactPayloadFromNode(nodeId, node, context);
1168
+ const scope = await resolveArtifactScope(nodeId, node, workingDir, context, executionContext);
1169
+ const saved = await saveWorkflowArtifact(workingDir, { kind, scope, payload });
1170
+ return {
1171
+ id: nodeId,
1172
+ type: 'action',
1173
+ action: node.action,
1174
+ response: `saved ${kind} artifact ${saved.artifact.id}`,
1175
+ output: { ...saved.artifact, path: saved.path, latestPath: saved.latestPath },
1176
+ };
1177
+ }
1178
+ async function runLoadArtifactWorkflowNode(nodeId, node, workingDir, context, executionContext) {
1179
+ const kind = requireStringActionOption(nodeId, node, 'kind', context);
1180
+ const rawId = getStringActionOption(node, 'id', context)?.trim();
1181
+ const id = rawId && rawId.length > 0 ? rawId : undefined;
1182
+ const scope = await resolveArtifactScope(nodeId, node, workingDir, context, executionContext);
1183
+ const loaded = await loadWorkflowArtifact(workingDir, kind, scope, id);
1184
+ return {
1185
+ id: nodeId,
1186
+ type: 'action',
1187
+ action: node.action,
1188
+ response: `loaded ${kind} artifact ${loaded.artifact.id}`,
1189
+ output: { ...loaded.artifact, path: loaded.path },
1190
+ };
1191
+ }
1192
+ async function runArtifactExistsWorkflowNode(nodeId, node, workingDir, context, executionContext) {
1193
+ const kind = requireStringActionOption(nodeId, node, 'kind', context);
1194
+ const rawId = getStringActionOption(node, 'id', context)?.trim();
1195
+ const id = rawId && rawId.length > 0 ? rawId : undefined;
1196
+ const scope = await resolveArtifactScope(nodeId, node, workingDir, context, executionContext);
1197
+ const exists = await workflowArtifactExists(workingDir, kind, scope, id);
1198
+ return {
1199
+ id: nodeId,
1200
+ type: 'action',
1201
+ action: node.action,
1202
+ response: exists ? 'artifact exists' : 'artifact missing',
1203
+ output: { exists, kind, scope, id },
1204
+ };
1205
+ }
1206
+ function runCreateReviewArtifactWorkflowNode(nodeId, node, context) {
1207
+ const reviewArtifact = getStringActionOption(node, 'review', context) ?? 'review';
1208
+ const review = context.artifacts[reviewArtifact];
1209
+ if (!isReviewResult(review)) {
1210
+ throw new Error(`Workflow create-review-artifact node "${nodeId}" needs a ReviewResult artifact.`);
1211
+ }
1212
+ const sourceArtifact = getStringActionOption(node, 'source', context);
1213
+ const source = sourceArtifact ? context.artifacts[sourceArtifact] : undefined;
1214
+ const reviewSource = isReviewSource(source) ? source : undefined;
1215
+ const artifact = createReviewArtifactPayload(review, reviewSource);
1216
+ return {
1217
+ id: nodeId,
1218
+ type: 'action',
1219
+ action: node.action,
1220
+ response: `created review artifact ${artifact.reviewId}`,
1221
+ output: artifact,
1222
+ };
1223
+ }
1224
+ function getReviewArtifactPayloadFromValue(nodeId, value) {
1225
+ const envelope = value;
1226
+ const payload = envelope && typeof envelope === 'object' && 'payload' in envelope ? envelope.payload : value;
1227
+ if (!isReviewArtifactPayload(payload)) {
1228
+ throw new Error(`Workflow review-artifact-status node "${nodeId}" needs a review artifact payload.`);
1229
+ }
1230
+ return payload;
1231
+ }
1232
+ function getReviewArtifactEnvelopeFromValue(value) {
1233
+ if (!value || typeof value !== 'object') {
1234
+ return undefined;
1235
+ }
1236
+ const envelope = value;
1237
+ if (envelope.schemaVersion === 1 &&
1238
+ envelope.kind === 'review' &&
1239
+ typeof envelope.id === 'string' &&
1240
+ typeof envelope.createdAt === 'string' &&
1241
+ typeof envelope.updatedAt === 'string' &&
1242
+ envelope.scope &&
1243
+ typeof envelope.scope === 'object' &&
1244
+ isReviewArtifactPayload(envelope.payload)) {
1245
+ return envelope;
1246
+ }
1247
+ return undefined;
1248
+ }
1249
+ function getReviewArtifactInput(nodeId, node, context) {
1250
+ const artifactName = getStringActionOption(node, 'artifact', context) ?? 'reviewArtifact';
1251
+ const artifactValue = context.artifacts[artifactName];
1252
+ const artifact = getReviewArtifactPayloadFromValue(nodeId, artifactValue);
1253
+ return { artifact, envelope: getReviewArtifactEnvelopeFromValue(artifactValue) };
1254
+ }
1255
+ async function persistMutatedReviewArtifact(workingDir, payload, envelope) {
1256
+ if (!envelope) {
1257
+ return { output: payload, responseSuffix: '' };
1258
+ }
1259
+ const saved = await updateWorkflowArtifact(workingDir, { artifact: envelope, payload });
1260
+ return {
1261
+ output: { ...saved.artifact, path: saved.path, latestPath: saved.latestPath },
1262
+ responseSuffix: ` and saved ${saved.artifact.id}`,
1263
+ };
1264
+ }
1265
+ function parseListActionOption(node, key, context) {
1266
+ const value = getStringActionOption(node, key, context)?.trim();
1267
+ if (!value) {
1268
+ return undefined;
1269
+ }
1270
+ return value
1271
+ .split(/[,\n]/)
1272
+ .map((item) => item.trim())
1273
+ .filter(Boolean);
1274
+ }
1275
+ function parseReviewFindingState(nodeId, value) {
1276
+ if (!value) {
1277
+ return undefined;
1278
+ }
1279
+ if (value === 'open' || value === 'attempted' || value === 'resolved') {
1280
+ return value;
1281
+ }
1282
+ throw new Error(`Workflow node "${nodeId}" has invalid review finding state "${value}".`);
1283
+ }
1284
+ function parseReviewFindingDisposition(nodeId, value) {
1285
+ if (!value) {
1286
+ return undefined;
1287
+ }
1288
+ if (value === 'confirmed' ||
1289
+ value === 'uncertain' ||
1290
+ value === 'pre_existing' ||
1291
+ value === 'partial' ||
1292
+ value === 'still_open' ||
1293
+ value === 'regression' ||
1294
+ value === 'resolved') {
1295
+ return value;
1296
+ }
1297
+ throw new Error(`Workflow node "${nodeId}" has invalid review finding disposition "${value}".`);
1298
+ }
1299
+ function parseReviewFindingSource(nodeId, value) {
1300
+ if (!value) {
1301
+ return 'manual';
1302
+ }
1303
+ if (value === 'agent' || value === 'manual' || value === 'external') {
1304
+ return value;
1305
+ }
1306
+ throw new Error(`Workflow node "${nodeId}" has invalid review finding source "${value}".`);
1307
+ }
1308
+ function isReviewIssue(value) {
1309
+ if (!value || typeof value !== 'object') {
1310
+ return false;
1311
+ }
1312
+ const issue = value;
1313
+ return (typeof issue.severity === 'string' &&
1314
+ typeof issue.category === 'string' &&
1315
+ typeof issue.title === 'string' &&
1316
+ typeof issue.file === 'string' &&
1317
+ typeof issue.problem === 'string' &&
1318
+ typeof issue.solution === 'string');
1319
+ }
1320
+ function getReviewIssueFromNode(nodeId, node, context) {
1321
+ const issueName = requireStringActionOption(nodeId, node, 'issue', context);
1322
+ const issue = Object.prototype.hasOwnProperty.call(context.artifacts, issueName)
1323
+ ? context.artifacts[issueName]
1324
+ : JSON.parse(issueName);
1325
+ if (!isReviewIssue(issue)) {
1326
+ throw new Error(`Workflow review-artifact-add-finding node "${nodeId}" needs a ReviewIssue.`);
1327
+ }
1328
+ return issue;
1329
+ }
1330
+ function runReviewArtifactStatusWorkflowNode(nodeId, node, context) {
1331
+ const artifactName = getStringActionOption(node, 'artifact', context) ?? 'reviewArtifact';
1332
+ const artifactValue = context.artifacts[artifactName];
1333
+ const artifact = getReviewArtifactPayloadFromValue(nodeId, artifactValue);
1334
+ const status = getReviewArtifactStatus(artifact);
1335
+ return {
1336
+ id: nodeId,
1337
+ type: 'action',
1338
+ action: node.action,
1339
+ response: `${status.totalFindings} finding(s), ${status.openFindings} open`,
1340
+ output: status,
1341
+ };
1342
+ }
1343
+ async function runReviewArtifactAddFindingWorkflowNode(nodeId, node, workingDir, context) {
1344
+ const { artifact, envelope } = getReviewArtifactInput(nodeId, node, context);
1345
+ const issue = getReviewIssueFromNode(nodeId, node, context);
1346
+ const source = parseReviewFindingSource(nodeId, getStringActionOption(node, 'source', context));
1347
+ const updated = addReviewArtifactFinding(artifact, issue, source);
1348
+ const persisted = await persistMutatedReviewArtifact(workingDir, updated, envelope);
1349
+ const addedFinding = updated.findings[updated.findings.length - 1];
1350
+ return {
1351
+ id: nodeId,
1352
+ type: 'action',
1353
+ action: node.action,
1354
+ response: `added review finding ${addedFinding?.id ?? 'unknown'}${persisted.responseSuffix}`,
1355
+ output: persisted.output,
1356
+ };
1357
+ }
1358
+ async function runReviewArtifactUpdateFindingsWorkflowNode(nodeId, node, workingDir, context, defaults = {}) {
1359
+ const { artifact, envelope } = getReviewArtifactInput(nodeId, node, context);
1360
+ const state = parseReviewFindingState(nodeId, getStringActionOption(node, 'state', context) ?? defaults.state);
1361
+ const disposition = parseReviewFindingDisposition(nodeId, getStringActionOption(node, 'disposition', context) ?? defaults.disposition);
1362
+ if (!state && !disposition) {
1363
+ throw new Error(`Workflow node "${nodeId}" must define with.state or with.disposition.`);
1364
+ }
1365
+ const { artifact: updated, updatedIds } = updateReviewArtifactFindings(artifact, {
1366
+ ids: parseListActionOption(node, 'ids', context),
1367
+ fingerprints: parseListActionOption(node, 'fingerprints', context),
1368
+ severity: getStringActionOption(node, 'severity', context)?.trim().toUpperCase(),
1369
+ state,
1370
+ disposition,
1371
+ });
1372
+ const persisted = await persistMutatedReviewArtifact(workingDir, updated, envelope);
1373
+ return {
1374
+ id: nodeId,
1375
+ type: 'action',
1376
+ action: node.action,
1377
+ response: `updated ${updatedIds.length} review finding(s)${persisted.responseSuffix}`,
1378
+ output: {
1379
+ ...(typeof persisted.output === 'object' && persisted.output !== null
1380
+ ? persisted.output
1381
+ : {}),
1382
+ updatedIds,
1383
+ },
1384
+ };
1385
+ }
1386
+ async function runVerifyFixWorkflowNode(nodeId, node, workingDir, context) {
1387
+ const { artifact, envelope } = getReviewArtifactInput(nodeId, node, context);
1388
+ const reviewArtifact = getStringActionOption(node, 'review', context) ?? 'reReview';
1389
+ const review = context.artifacts[reviewArtifact];
1390
+ if (!isReviewResult(review)) {
1391
+ throw new Error(`Workflow verify-fix node "${nodeId}" needs a ReviewResult.`);
1392
+ }
1393
+ const severity = (getStringActionOption(node, 'severity', context) ?? 'high').toUpperCase();
1394
+ if (severityRank(severity) === 0) {
1395
+ throw new Error(`Workflow verify-fix node "${nodeId}" has unsupported severity "${severity}".`);
1396
+ }
1397
+ const minIssues = hasActionOption(node, 'minIssues')
1398
+ ? requireNumberActionOption(nodeId, node, 'minIssues', context)
1399
+ : 1;
1400
+ const fixChangeName = getStringActionOption(node, 'fixChange', context);
1401
+ const fixChange = fixChangeName ? context.artifacts[fixChangeName] : undefined;
1402
+ const fixSource = isReviewSource(fixChange) ? fixChange : undefined;
1403
+ const reconciliation = reconcileReviewArtifactFindings(artifact, review, {
1404
+ severity,
1405
+ minIssues,
1406
+ fixSource,
1407
+ });
1408
+ const persisted = await persistMutatedReviewArtifact(workingDir, reconciliation.artifact, envelope);
1409
+ const status = getReviewArtifactStatus(reconciliation.artifact);
1410
+ const output = {
1411
+ ...(typeof persisted.output === 'object' && persisted.output !== null ? persisted.output : {}),
1412
+ payload: reconciliation.artifact,
1413
+ verification: {
1414
+ severity,
1415
+ minIssues,
1416
+ shouldContinue: reconciliation.shouldContinue,
1417
+ actionableOpen: reconciliation.actionableOpen,
1418
+ fixFiles: fixSource?.files.length ?? 0,
1419
+ resolved: reconciliation.resolved,
1420
+ partial: reconciliation.partial,
1421
+ stillOpen: reconciliation.stillOpen,
1422
+ regression: reconciliation.regression,
1423
+ statuses: reconciliation.statuses.map((statusItem) => ({
1424
+ id: statusItem.finding.id,
1425
+ fingerprint: statusItem.finding.fingerprint,
1426
+ disposition: statusItem.disposition,
1427
+ severity: statusItem.finding.issue.severity,
1428
+ file: statusItem.finding.issue.file,
1429
+ line: statusItem.finding.issue.line,
1430
+ title: statusItem.finding.issue.title,
1431
+ verificationMissing: statusItem.verificationMissing === true,
1432
+ verificationRationale: statusItem.finding.verification?.rationale,
1433
+ })),
1434
+ },
1435
+ shouldContinue: reconciliation.shouldContinue,
1436
+ actionableOpen: reconciliation.actionableOpen,
1437
+ fixFiles: fixSource?.files.length ?? 0,
1438
+ updatedIds: reconciliation.statuses.map((statusItem) => statusItem.finding.id),
1439
+ status,
1440
+ };
1441
+ return {
1442
+ id: nodeId,
1443
+ type: 'action',
1444
+ action: node.action,
1445
+ response: reconciliation.shouldContinue
1446
+ ? `${reconciliation.actionableOpen} actionable finding(s) remain${persisted.responseSuffix}`
1447
+ : `fix verification converged${persisted.responseSuffix}`,
1448
+ output,
1449
+ };
1450
+ }
1451
+ async function loadLocalChangeSource(nodeId, node, workingDir, context) {
488
1452
  const git = simpleGit({ baseDir: workingDir });
489
1453
  const isRepo = await git.checkIsRepo();
490
1454
  if (!isRepo) {
491
1455
  throw new Error(`Workflow change-source node "${nodeId}" must run from a git repository.`);
492
1456
  }
493
- const staged = getBooleanActionOption(node, 'staged');
1457
+ const staged = getBooleanActionOption(node, 'staged', context);
494
1458
  const diffText = staged ? await git.diff(['--cached']) : await git.diff();
495
1459
  const diffs = parseDiff(diffText);
496
1460
  const changedFiles = getChangedFiles(diffs);
@@ -528,8 +1492,8 @@ async function resolveGitRangeToRef(git) {
528
1492
  function isStableSemverTag(tag) {
529
1493
  return /^v?\d+\.\d+\.\d+$/.test(tag);
530
1494
  }
531
- async function resolvePreviousGitRangeTag(nodeId, node, git, toRef) {
532
- const includePrerelease = getBooleanActionOption(node, 'includePrereleaseFrom');
1495
+ async function resolvePreviousGitRangeTag(nodeId, node, git, toRef, context) {
1496
+ const includePrerelease = getBooleanActionOption(node, 'includePrereleaseFrom', context);
533
1497
  const tagOutput = await git.raw(['tag', '--merged', toRef, '--sort=-v:refname']);
534
1498
  const tags = tagOutput
535
1499
  .split('\n')
@@ -544,11 +1508,9 @@ async function resolvePreviousGitRangeTag(nodeId, node, git, toRef) {
544
1508
  }
545
1509
  async function resolveGitRangeRefs(nodeId, node, context, git) {
546
1510
  const configuredToRef = getStringActionOption(node, 'to', context)?.trim();
547
- const toRef = configuredToRef ? configuredToRef : await resolveGitRangeToRef(git);
1511
+ const toRef = configuredToRef ?? (await resolveGitRangeToRef(git));
548
1512
  const configuredFromRef = getStringActionOption(node, 'from', context)?.trim();
549
- const fromRef = configuredFromRef
550
- ? configuredFromRef
551
- : await resolvePreviousGitRangeTag(nodeId, node, git, toRef);
1513
+ const fromRef = configuredFromRef ?? (await resolvePreviousGitRangeTag(nodeId, node, git, toRef, context));
552
1514
  if (!fromRef) {
553
1515
  throw new Error(`Workflow node "${nodeId}" could not infer the previous tag for ${toRef}. ` +
554
1516
  'Provide with.from explicitly.');
@@ -619,11 +1581,68 @@ async function loadGitLabChangeSource(nodeId, node, workingDir, context, executi
619
1581
  ]);
620
1582
  return createPlatformChangeSource('gitlab', `GitLab MR ${projectId}!${mrIid}`, projectId, pullRequest, changedFiles, workingDir);
621
1583
  }
1584
+ function combineVerificationPatch(originalPatch, fixPatch) {
1585
+ const sections = [];
1586
+ if (originalPatch) {
1587
+ sections.push(`# Original PR/MR diff\n${originalPatch}`);
1588
+ }
1589
+ if (fixPatch) {
1590
+ sections.push(`# Local fix diff\n${fixPatch}`);
1591
+ }
1592
+ return sections.join('\n\n');
1593
+ }
1594
+ async function loadFixVerificationChangeSource(nodeId, node, workingDir, context) {
1595
+ const sourceName = getStringActionOption(node, 'source', context) ?? 'change';
1596
+ const source = context.artifacts[sourceName];
1597
+ if (!isReviewSource(source)) {
1598
+ throw new Error(`Workflow fix-verification change-source node "${nodeId}" needs a source ReviewSource artifact.`);
1599
+ }
1600
+ const fixChangeName = getStringActionOption(node, 'fixChange', context) ?? 'fixChange';
1601
+ const fixChange = context.artifacts[fixChangeName];
1602
+ if (!isReviewSource(fixChange)) {
1603
+ throw new Error(`Workflow fix-verification change-source node "${nodeId}" needs a fixChange ReviewSource artifact.`);
1604
+ }
1605
+ const files = [...new Set([...source.files, ...fixChange.files])];
1606
+ const pullRequest = isPullRequest(source.context.pullRequest) ? source.context.pullRequest : null;
1607
+ const baseRef = pullRequest?.targetBranch ?? source.context.baseBranch ?? 'HEAD~1';
1608
+ const realDiffs = await tryGetRealPostFixDiff(workingDir, files, baseRef);
1609
+ return {
1610
+ ...source,
1611
+ name: `${source.name} with local fixes`,
1612
+ files,
1613
+ filesWithDiffs: realDiffs ??
1614
+ files.map((filename) => ({
1615
+ filename,
1616
+ patch: combineVerificationPatch((source.filesWithDiffs ?? []).find((f) => f.filename === filename)?.patch, (fixChange.filesWithDiffs ?? []).find((f) => f.filename === filename)?.patch),
1617
+ })),
1618
+ context: {
1619
+ ...source.context,
1620
+ sourceType: 'fix-verification',
1621
+ fixFiles: fixChange.files,
1622
+ },
1623
+ staged: fixChange.staged,
1624
+ };
1625
+ }
1626
+ async function tryGetRealPostFixDiff(workingDir, files, baseRef) {
1627
+ try {
1628
+ const git = simpleGit({ baseDir: workingDir });
1629
+ const isRepo = await git.checkIsRepo();
1630
+ if (!isRepo)
1631
+ return undefined;
1632
+ const diffText = await git.diff(['--no-ext-diff', '-M', baseRef, '--', ...files]);
1633
+ if (!diffText.trim())
1634
+ return undefined;
1635
+ return getFilesWithDiffs(parseDiff(diffText));
1636
+ }
1637
+ catch {
1638
+ return undefined;
1639
+ }
1640
+ }
622
1641
  async function runChangeSourceWorkflowNode(nodeId, node, workingDir, context, executionContext) {
623
1642
  const type = getStringActionOption(node, 'type', context) ?? 'local';
624
1643
  let source;
625
1644
  if (type === 'local') {
626
- source = await loadLocalChangeSource(nodeId, node, workingDir);
1645
+ source = await loadLocalChangeSource(nodeId, node, workingDir, context);
627
1646
  }
628
1647
  else if (type === 'git-range') {
629
1648
  source = await loadGitRangeChangeSource(nodeId, node, workingDir, context, executionContext);
@@ -634,9 +1653,12 @@ async function runChangeSourceWorkflowNode(nodeId, node, workingDir, context, ex
634
1653
  else if (type === 'gitlab-mr') {
635
1654
  source = await loadGitLabChangeSource(nodeId, node, workingDir, context, executionContext);
636
1655
  }
1656
+ else if (type === 'fix-verification') {
1657
+ source = await loadFixVerificationChangeSource(nodeId, node, workingDir, context);
1658
+ }
637
1659
  else {
638
1660
  throw new Error(`Unsupported workflow change-source type "${type}" in node "${nodeId}". ` +
639
- 'Currently supported: local, git-range, github-pr, gitlab-mr.');
1661
+ 'Currently supported: local, git-range, github-pr, gitlab-mr, fix-verification.');
640
1662
  }
641
1663
  const writes = renderNodeWritesPath(nodeId, node, context);
642
1664
  if (writes) {
@@ -724,9 +1746,84 @@ function resolvePostTarget(nodeId, node, context, executionContext, source) {
724
1746
  changedFiles: sourceTarget.changedFiles,
725
1747
  };
726
1748
  }
727
- function isPullRequest(value) {
728
- if (!value || typeof value !== 'object') {
729
- return false;
1749
+ async function runCreateChangeRequestWorkflowNode(nodeId, node, context, executionContext) {
1750
+ const sourceArtifact = getStringActionOption(node, 'source', context) ?? 'change';
1751
+ const source = isReviewSource(context.artifacts[sourceArtifact])
1752
+ ? context.artifacts[sourceArtifact]
1753
+ : undefined;
1754
+ const sourceTarget = readSourcePostTarget(source);
1755
+ const aliasPlatform = node.action === 'create-pr' ? 'github' : node.action === 'create-mr' ? 'gitlab' : undefined;
1756
+ const explicitPlatform = getStringActionOption(node, 'platform', context);
1757
+ const platform = aliasPlatform ?? explicitPlatform ?? sourceTarget.platform;
1758
+ if (!isWorkflowPlatform(platform)) {
1759
+ throw new Error(`Workflow change-request node "${nodeId}" must resolve with.platform to github or gitlab.`);
1760
+ }
1761
+ const projectId = resolvePostProjectId(nodeId, node, context, sourceTarget);
1762
+ const configuredSourceBranch = getStringActionOption(node, 'sourceBranch', context)?.trim();
1763
+ const configuredHead = getStringActionOption(node, 'head', context)?.trim();
1764
+ const sourceBranch = configuredSourceBranch && configuredSourceBranch.length > 0
1765
+ ? configuredSourceBranch
1766
+ : configuredHead;
1767
+ const configuredTargetBranch = getStringActionOption(node, 'targetBranch', context)?.trim();
1768
+ const configuredBase = getStringActionOption(node, 'base', context)?.trim();
1769
+ const targetBranch = configuredTargetBranch && configuredTargetBranch.length > 0
1770
+ ? configuredTargetBranch
1771
+ : configuredBase;
1772
+ if (!sourceBranch) {
1773
+ throw new Error(`Workflow change-request node "${nodeId}" must define with.sourceBranch.`);
1774
+ }
1775
+ if (!targetBranch) {
1776
+ throw new Error(`Workflow change-request node "${nodeId}" must define with.targetBranch.`);
1777
+ }
1778
+ const title = requireStringActionOption(nodeId, node, 'title', context);
1779
+ const body = getStringActionOption(node, 'body', context);
1780
+ const draft = getBooleanActionOption(node, 'draft', context);
1781
+ const reuseExisting = !hasActionOption(node, 'reuseExisting') ||
1782
+ getBooleanActionOption(node, 'reuseExisting', context);
1783
+ const platformClient = getWorkflowPlatformClient(executionContext, platform);
1784
+ const input = {
1785
+ sourceBranch,
1786
+ targetBranch,
1787
+ title,
1788
+ body,
1789
+ draft,
1790
+ };
1791
+ const existing = reuseExisting
1792
+ ? await platformClient.findChangeRequest?.(projectId, sourceBranch, targetBranch)
1793
+ : undefined;
1794
+ let operation = existing ? 'reused' : 'created';
1795
+ const changeRequest = existing ??
1796
+ (await platformClient.createChangeRequest(projectId, input).catch(async (error) => {
1797
+ if (reuseExisting) {
1798
+ try {
1799
+ const retryExisting = await platformClient.findChangeRequest?.(projectId, sourceBranch, targetBranch);
1800
+ if (retryExisting) {
1801
+ operation = 'reused';
1802
+ return retryExisting;
1803
+ }
1804
+ }
1805
+ catch {
1806
+ // ignore retry failure; fall through to throw original error
1807
+ }
1808
+ }
1809
+ throw error;
1810
+ }));
1811
+ return {
1812
+ id: nodeId,
1813
+ type: 'action',
1814
+ action: node.action,
1815
+ response: `${operation} ${platform} change request #${changeRequest.number}`,
1816
+ output: {
1817
+ platform,
1818
+ projectId,
1819
+ operation,
1820
+ ...changeRequest,
1821
+ },
1822
+ };
1823
+ }
1824
+ function isPullRequest(value) {
1825
+ if (!value || typeof value !== 'object') {
1826
+ return false;
730
1827
  }
731
1828
  const candidate = value;
732
1829
  return (typeof candidate.number === 'number' &&
@@ -755,19 +1852,24 @@ function createWorkflowLineValidator(platform, source) {
755
1852
  : [];
756
1853
  const patchSources = fileChanges.length > 0 ? fileChanges : (source.filesWithDiffs ?? []);
757
1854
  const validLinesMap = new Map();
1855
+ const changedLinesMap = new Map();
758
1856
  for (const file of patchSources) {
759
1857
  if ('status' in file && file.status === 'removed') {
760
1858
  continue;
761
1859
  }
762
1860
  const patch = file.patch;
763
1861
  if (patch) {
764
- validLinesMap.set(file.filename, parseValidLinesFromPatch(patch));
1862
+ const lineInfo = parseDiffLineInfo(patch);
1863
+ validLinesMap.set(file.filename, lineInfo.commentableLines);
1864
+ changedLinesMap.set(file.filename, lineInfo.addedLines);
765
1865
  }
766
1866
  }
767
1867
  return {
768
1868
  isValidLine(file, line) {
769
- const validLines = validLinesMap.get(file);
770
- return validLines !== undefined && validLines.has(line);
1869
+ return validLinesMap.get(file)?.has(line) ?? false;
1870
+ },
1871
+ isChangedLine(file, line) {
1872
+ return changedLinesMap.get(file)?.has(line) ?? false;
771
1873
  },
772
1874
  };
773
1875
  }
@@ -896,7 +1998,8 @@ async function runDescribeWorkflowNode(config, nodeId, node, options, workingDir
896
1998
  if (!target.pullRequest) {
897
1999
  throw new Error(`Workflow describe node "${nodeId}" needs a platform change-source target.`);
898
2000
  }
899
- const shouldPostDescription = getBooleanActionOption(node, 'post') || getBooleanActionOption(node, 'postDescription');
2001
+ const shouldPostDescription = getBooleanActionOption(node, 'post', context) ||
2002
+ getBooleanActionOption(node, 'postDescription', context);
900
2003
  const runtimeClient = await connectToRuntime(config, source.workingDir ?? workingDir, {
901
2004
  debug: options.debug,
902
2005
  modelOverrides: getDescriberModelOverride(config),
@@ -988,7 +2091,7 @@ async function runPostReviewCommentsWorkflowNode(config, nodeId, node, options,
988
2091
  ? createWorkflowInlinePosition(target.platform, source)
989
2092
  : undefined;
990
2093
  const shouldRemoveErrorComment = !hasActionOption(node, 'removeErrorComment') ||
991
- getBooleanActionOption(node, 'removeErrorComment');
2094
+ getBooleanActionOption(node, 'removeErrorComment', context);
992
2095
  await withWorkflowConsoleSuppressed(executionContext, options.jsonOutput === true, async () => {
993
2096
  if (shouldRemoveErrorComment) {
994
2097
  await removeErrorComment(target.platformClient, target.projectId, target.prNumber);
@@ -1015,6 +2118,256 @@ async function runPostReviewCommentsWorkflowNode(config, nodeId, node, options,
1015
2118
  },
1016
2119
  };
1017
2120
  }
2121
+ function getFixStatusDisposition(finding) {
2122
+ const disposition = finding.disposition;
2123
+ if (disposition === 'resolved')
2124
+ return 'resolved';
2125
+ if (disposition === 'partial')
2126
+ return 'partial';
2127
+ if (disposition === 'regression')
2128
+ return 'regression';
2129
+ return 'still-open';
2130
+ }
2131
+ function getVerificationDisposition(finding, verdict, thresholdRank) {
2132
+ if (severityRank(finding.issue.severity) < thresholdRank) {
2133
+ return finding.disposition;
2134
+ }
2135
+ if (!verdict) {
2136
+ if (finding.disposition === 'regression') {
2137
+ return 'regression';
2138
+ }
2139
+ return 'still_open';
2140
+ }
2141
+ return verdict.disposition === 'still_open' ? 'still_open' : verdict.disposition;
2142
+ }
2143
+ function reconcileReviewArtifactFindings(artifact, reReview, options) {
2144
+ const now = new Date().toISOString();
2145
+ const thresholdRank = severityRank(options.severity);
2146
+ const verdicts = new Map((reReview.verification?.findings ?? []).map((finding) => [finding.id, finding]));
2147
+ const existingFingerprints = new Set(artifact.findings.map((finding) => finding.fingerprint));
2148
+ const reconciledFindings = artifact.findings.map((finding) => {
2149
+ const verdict = verdicts.get(finding.id);
2150
+ const disposition = getVerificationDisposition(finding, verdict, thresholdRank);
2151
+ const issue = isReviewIssue(verdict?.issue) ? verdict.issue : finding.issue;
2152
+ const fingerprint = issue === finding.issue ? finding.fingerprint : createIssueFingerprint(issue);
2153
+ const shouldVerify = severityRank(finding.issue.severity) >= thresholdRank;
2154
+ const verification = shouldVerify
2155
+ ? {
2156
+ disposition: verdict?.disposition ?? 'missing',
2157
+ rationale: verdict?.rationale,
2158
+ verifiedAt: now,
2159
+ }
2160
+ : finding.verification;
2161
+ return {
2162
+ ...finding,
2163
+ issue,
2164
+ fingerprint,
2165
+ state: disposition === 'resolved' ? 'resolved' : 'open',
2166
+ disposition,
2167
+ verification,
2168
+ updatedAt: now,
2169
+ };
2170
+ });
2171
+ for (const issue of reReview.issues) {
2172
+ const fingerprint = createIssueFingerprint(issue);
2173
+ if (existingFingerprints.has(fingerprint)) {
2174
+ continue;
2175
+ }
2176
+ existingFingerprints.add(fingerprint);
2177
+ reconciledFindings.push({
2178
+ id: `R${reconciledFindings.length + 1}`,
2179
+ fingerprint,
2180
+ issue,
2181
+ state: 'open',
2182
+ disposition: 'regression',
2183
+ verification: undefined,
2184
+ source: 'agent',
2185
+ createdAt: now,
2186
+ updatedAt: now,
2187
+ });
2188
+ }
2189
+ const updatedArtifact = { ...artifact, findings: reconciledFindings };
2190
+ const statuses = updatedArtifact.findings.map((finding) => {
2191
+ const disposition = getFixStatusDisposition(finding);
2192
+ const verificationMissing = severityRank(finding.issue.severity) >= thresholdRank && !verdicts.has(finding.id);
2193
+ const diffSnippet = disposition === 'resolved' || disposition === 'regression'
2194
+ ? extractDiffSnippet(options.fixSource, finding.issue.file, finding.issue.line)
2195
+ : undefined;
2196
+ return { finding, disposition, diffSnippet, verificationMissing };
2197
+ });
2198
+ const actionableOpen = updatedArtifact.findings.filter((finding) => finding.state === 'open' &&
2199
+ severityRank(finding.issue.severity) >= thresholdRank &&
2200
+ (finding.disposition === 'still_open' ||
2201
+ finding.disposition === 'partial' ||
2202
+ finding.disposition === 'regression')).length;
2203
+ return {
2204
+ artifact: updatedArtifact,
2205
+ statuses,
2206
+ shouldContinue: actionableOpen >= options.minIssues,
2207
+ actionableOpen,
2208
+ resolved: updatedArtifact.findings.filter((finding) => finding.state === 'resolved').length,
2209
+ partial: updatedArtifact.findings.filter((finding) => finding.disposition === 'partial').length,
2210
+ stillOpen: updatedArtifact.findings.filter((finding) => finding.disposition === 'still_open')
2211
+ .length,
2212
+ regression: updatedArtifact.findings.filter((finding) => finding.disposition === 'regression')
2213
+ .length,
2214
+ };
2215
+ }
2216
+ function extractDiffSnippet(fixChange, filePath, line) {
2217
+ if (!fixChange?.filesWithDiffs) {
2218
+ return undefined;
2219
+ }
2220
+ const fileWithDiff = fixChange.filesWithDiffs.find((f) => f.filename === filePath);
2221
+ if (!fileWithDiff?.patch) {
2222
+ return undefined;
2223
+ }
2224
+ const lines = fileWithDiff.patch.split('\n');
2225
+ if (!line) {
2226
+ return lines.slice(0, 15).join('\n');
2227
+ }
2228
+ for (let i = 0; i < lines.length; i++) {
2229
+ const hunkMatch = lines[i]?.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/);
2230
+ if (!hunkMatch) {
2231
+ continue;
2232
+ }
2233
+ const newStart = Number.parseInt(hunkMatch[1], 10);
2234
+ const newLineCount = hunkMatch[2] ? Number.parseInt(hunkMatch[2], 10) : 1;
2235
+ if (line < newStart || line >= newStart + newLineCount) {
2236
+ continue;
2237
+ }
2238
+ let end = i + 1;
2239
+ while (end < lines.length && !lines[end]?.startsWith('@@')) {
2240
+ end++;
2241
+ }
2242
+ return lines.slice(i, end).join('\n');
2243
+ }
2244
+ return lines.slice(0, 15).join('\n');
2245
+ }
2246
+ function formatFixStatusComment(statuses, stackedPrUrl) {
2247
+ const lines = ['## Fix Status', ''];
2248
+ if (statuses.length === 0) {
2249
+ lines.push('No findings to report.');
2250
+ return lines.join('\n');
2251
+ }
2252
+ lines.push('| # | Severity | File | Issue | Status |');
2253
+ lines.push('|---|----------|------|-------|--------|');
2254
+ for (let i = 0; i < statuses.length; i++) {
2255
+ const s = statuses[i];
2256
+ const statusIcon = s.disposition === 'resolved'
2257
+ ? '✅ Resolved'
2258
+ : s.disposition === 'partial'
2259
+ ? '🟡 Partial'
2260
+ : s.disposition === 'regression'
2261
+ ? '🔴 Regression'
2262
+ : s.disposition === 'attempted'
2263
+ ? '🔧 Attempted'
2264
+ : s.verificationMissing
2265
+ ? '⚠️ Verification Missing'
2266
+ : '⚪ Still Open';
2267
+ const file = `${s.finding.issue.file}${s.finding.issue.line ? `:${s.finding.issue.line}` : ''}`;
2268
+ lines.push(`| ${i + 1} | ${s.finding.issue.severity} | ${file} | ${s.finding.issue.title} | ${statusIcon} |`);
2269
+ }
2270
+ const resolved = statuses.filter((s) => s.disposition === 'resolved' && s.diffSnippet);
2271
+ if (resolved.length > 0) {
2272
+ lines.push('');
2273
+ lines.push('### Fix Details');
2274
+ for (let i = 0; i < statuses.length; i++) {
2275
+ const s = statuses[i];
2276
+ if (s.disposition === 'resolved' && s.diffSnippet) {
2277
+ lines.push('');
2278
+ lines.push(`**#${i + 1} — ${s.finding.issue.title} (${s.disposition})**`);
2279
+ lines.push('```diff');
2280
+ lines.push(s.diffSnippet);
2281
+ lines.push('```');
2282
+ }
2283
+ }
2284
+ }
2285
+ if (stackedPrUrl) {
2286
+ lines.push('');
2287
+ lines.push(`Stacked fix PR: ${stackedPrUrl}`);
2288
+ }
2289
+ return lines.join('\n');
2290
+ }
2291
+ async function runPostFixStatusWorkflowNode(nodeId, node, options, workingDir, context, executionContext) {
2292
+ const sourceArtifact = getStringActionOption(node, 'source', context) ?? 'change';
2293
+ const source = isReviewSource(context.artifacts[sourceArtifact])
2294
+ ? context.artifacts[sourceArtifact]
2295
+ : undefined;
2296
+ const target = resolvePostTarget(nodeId, node, context, executionContext, source);
2297
+ const reviewArtifactName = getStringActionOption(node, 'reviewArtifact', context) ?? 'reviewArtifact';
2298
+ const reviewArtifactValue = context.artifacts[reviewArtifactName];
2299
+ let artifactPayload;
2300
+ if (isReviewArtifactPayload(reviewArtifactValue)) {
2301
+ artifactPayload = reviewArtifactValue;
2302
+ }
2303
+ else if (reviewArtifactValue &&
2304
+ typeof reviewArtifactValue === 'object' &&
2305
+ 'payload' in reviewArtifactValue) {
2306
+ const payload = reviewArtifactValue.payload;
2307
+ if (isReviewArtifactPayload(payload)) {
2308
+ artifactPayload = payload;
2309
+ }
2310
+ }
2311
+ if (!artifactPayload) {
2312
+ throw new Error(`Workflow post-fix-status node "${nodeId}" needs a review artifact (with.reviewArtifact).`);
2313
+ }
2314
+ const fixReviewName = getStringActionOption(node, 'fixReview', context);
2315
+ const fixReviewResult = fixReviewName ? context.artifacts[fixReviewName] : undefined;
2316
+ const hasReReview = isReviewResult(fixReviewResult);
2317
+ const fixChangeName = getStringActionOption(node, 'fixChange', context);
2318
+ const fixChange = fixChangeName ? context.artifacts[fixChangeName] : undefined;
2319
+ const fixSource = isReviewSource(fixChange) ? fixChange : undefined;
2320
+ const stackedPrUrl = getStringActionOption(node, 'stackedPrUrl', context);
2321
+ const marker = getStringActionOption(node, 'marker', context)?.trim() ?? 'drs-fix-status';
2322
+ const severity = (getStringActionOption(node, 'severity', context) ?? 'high').toUpperCase();
2323
+ const thresholdRank = severityRank(severity);
2324
+ const originalFindings = artifactPayload.findings.filter((finding) => severityRank(finding.issue.severity) >= thresholdRank);
2325
+ const statuses = originalFindings.map((finding) => {
2326
+ if (hasReReview) {
2327
+ const disposition = getFixStatusDisposition(finding);
2328
+ const diffSnippet = disposition === 'resolved' || disposition === 'regression'
2329
+ ? extractDiffSnippet(fixSource, finding.issue.file, finding.issue.line)
2330
+ : undefined;
2331
+ return { finding, disposition, diffSnippet };
2332
+ }
2333
+ const diffSnippet = extractDiffSnippet(fixSource, finding.issue.file, finding.issue.line);
2334
+ return { finding, disposition: 'attempted', diffSnippet };
2335
+ });
2336
+ const body = formatFixStatusComment(statuses, stackedPrUrl);
2337
+ const markedBody = formatMarkedComment(body, marker);
2338
+ let operation = 'created';
2339
+ try {
2340
+ const comments = await target.platformClient.getComments(target.projectId, target.prNumber);
2341
+ const existingComment = findExistingCommentById(comments, marker);
2342
+ if (existingComment) {
2343
+ await withWorkflowConsoleSuppressed(executionContext, options.jsonOutput === true, () => target.platformClient.updateComment(target.projectId, target.prNumber, existingComment.id, markedBody));
2344
+ operation = 'updated';
2345
+ }
2346
+ else {
2347
+ await withWorkflowConsoleSuppressed(executionContext, options.jsonOutput === true, () => target.platformClient.createComment(target.projectId, target.prNumber, markedBody));
2348
+ }
2349
+ }
2350
+ catch (error) {
2351
+ throw new Error(`Workflow post-fix-status node "${nodeId}" failed to post comment: ${error instanceof Error ? error.message : String(error)}`);
2352
+ }
2353
+ return {
2354
+ id: nodeId,
2355
+ type: 'action',
2356
+ action: node.action,
2357
+ response: `${operation} fix-status comment on ${target.platform} ${target.projectId}#${target.prNumber}`,
2358
+ output: {
2359
+ platform: target.platform,
2360
+ projectId: target.projectId,
2361
+ prNumber: target.prNumber,
2362
+ operation,
2363
+ resolved: statuses.filter((s) => s.disposition === 'resolved').length,
2364
+ partial: statuses.filter((s) => s.disposition === 'partial').length,
2365
+ stillOpen: statuses.filter((s) => s.disposition === 'still-open').length,
2366
+ regression: statuses.filter((s) => s.disposition === 'regression').length,
2367
+ attempted: statuses.filter((s) => s.disposition === 'attempted').length,
2368
+ },
2369
+ };
2370
+ }
1018
2371
  function isReviewSource(value) {
1019
2372
  if (!value || typeof value !== 'object') {
1020
2373
  return false;
@@ -1025,23 +2378,6 @@ function isReviewSource(value) {
1025
2378
  typeof candidate.context === 'object' &&
1026
2379
  candidate.context !== null);
1027
2380
  }
1028
- async function enforceWorkflowReviewTarget(config, source, workingDir) {
1029
- const platform = typeof source.context.platform === 'string' ? source.context.platform : undefined;
1030
- if (!isWorkflowPlatform(platform)) {
1031
- return;
1032
- }
1033
- const projectId = typeof source.context.projectId === 'string' ? source.context.projectId : undefined;
1034
- const pullRequest = isPullRequest(source.context.pullRequest)
1035
- ? source.context.pullRequest
1036
- : undefined;
1037
- if (!projectId || !pullRequest) {
1038
- throw new Error('Workflow platform review source is missing project or PR/MR metadata.');
1039
- }
1040
- await enforceRepoBranchMatch(workingDir, projectId, pullRequest, {
1041
- skipRepoCheck: config.review.skipRepoCheck,
1042
- skipBranchCheck: config.review.skipBranchCheck,
1043
- });
1044
- }
1045
2381
  async function runReviewWorkflowNode(config, nodeId, node, options, workingDir, context, executionContext) {
1046
2382
  const sourceArtifact = getStringActionOption(node, 'source', context) ?? 'change';
1047
2383
  const source = context.artifacts[sourceArtifact];
@@ -1049,10 +2385,46 @@ async function runReviewWorkflowNode(config, nodeId, node, options, workingDir,
1049
2385
  throw new Error(`Workflow review node "${nodeId}" needs a ReviewSource artifact. ` +
1050
2386
  'Set with.source to a change-source output.');
1051
2387
  }
2388
+ const reviewArtifactName = getStringActionOption(node, 'reviewArtifact', context);
2389
+ const reviewArtifactEnvelope = reviewArtifactName
2390
+ ? context.artifacts[reviewArtifactName]
2391
+ : undefined;
2392
+ const reviewArtifact = reviewArtifactName
2393
+ ? getReviewArtifactPayloadFromValue(nodeId, reviewArtifactEnvelope)
2394
+ : undefined;
2395
+ const reviewArtifactPath = reviewArtifactEnvelope && typeof reviewArtifactEnvelope === 'object'
2396
+ ? reviewArtifactEnvelope.path
2397
+ : undefined;
2398
+ const severity = getStringActionOption(node, 'severity', context)?.toUpperCase();
2399
+ const traceCollector = executionContext.traceCollector;
2400
+ const sourceForReview = reviewArtifact
2401
+ ? {
2402
+ ...source,
2403
+ context: {
2404
+ ...source.context,
2405
+ verification: {
2406
+ artifact: {
2407
+ reviewId: reviewArtifact.reviewId,
2408
+ findings: reviewArtifact.findings,
2409
+ },
2410
+ artifactPath: reviewArtifactPath,
2411
+ severity,
2412
+ },
2413
+ traceCollector,
2414
+ },
2415
+ }
2416
+ : {
2417
+ ...source,
2418
+ context: {
2419
+ ...source.context,
2420
+ traceCollector,
2421
+ },
2422
+ };
2423
+ if (traceCollector) {
2424
+ const agentIds = getReviewAgentIds(config);
2425
+ traceCollector.setContext(nodeId, agentIds[0] ?? 'review/unified-reviewer', '');
2426
+ }
1052
2427
  const reviewResult = await withWorkflowLock(executionContext.locks.exit, async () => {
1053
- const restoreExit = setExitHandler((code) => {
1054
- throw new ExitError(code);
1055
- });
1056
2428
  const originalLog = console.log;
1057
2429
  const originalWarn = console.warn;
1058
2430
  if (options.jsonOutput) {
@@ -1060,38 +2432,45 @@ async function runReviewWorkflowNode(config, nodeId, node, options, workingDir,
1060
2432
  console.warn = () => undefined;
1061
2433
  }
1062
2434
  try {
1063
- await enforceWorkflowReviewTarget(config, source, source.workingDir ?? workingDir);
1064
2435
  return await executeReview(config, {
1065
- ...source,
1066
- workingDir: source.workingDir ?? workingDir,
2436
+ ...sourceForReview,
2437
+ workingDir: sourceForReview.workingDir ?? workingDir,
1067
2438
  debug: options.debug,
1068
2439
  thinkingLevel: options.thinkingLevel,
1069
2440
  });
1070
2441
  }
1071
- catch (error) {
1072
- if (error instanceof ExitError) {
1073
- throw new Error(`Workflow review node "${nodeId}" failed: all review agents failed.`);
1074
- }
1075
- throw error;
1076
- }
1077
2442
  finally {
1078
2443
  if (options.jsonOutput) {
1079
2444
  console.log = originalLog;
1080
2445
  console.warn = originalWarn;
1081
2446
  }
1082
- restoreExit();
1083
2447
  }
1084
2448
  });
1085
2449
  const writes = renderNodeWritesPath(nodeId, node, context);
1086
2450
  if (writes) {
1087
2451
  await writeWorkflowFile(workingDir, writes, JSON.stringify(reviewResult, null, 2));
1088
2452
  }
2453
+ const artifactOutput = getStringActionOption(node, 'artifact', context)?.trim();
2454
+ const outputs = {};
2455
+ let artifactResponse = '';
2456
+ if (artifactOutput) {
2457
+ const reviewArtifact = createReviewArtifactPayload(reviewResult, source);
2458
+ const scope = await resolveArtifactScope(nodeId, node, workingDir, context, executionContext);
2459
+ const saved = await saveWorkflowArtifact(workingDir, {
2460
+ kind: 'review',
2461
+ scope,
2462
+ payload: reviewArtifact,
2463
+ });
2464
+ outputs[artifactOutput] = { ...saved.artifact, path: saved.path, latestPath: saved.latestPath };
2465
+ artifactResponse = `\nSaved review artifact ${saved.artifact.id}.`;
2466
+ }
1089
2467
  return {
1090
2468
  id: nodeId,
1091
2469
  type: 'action',
1092
2470
  action: node.action,
1093
- response: JSON.stringify(reviewResult.summary, null, 2),
2471
+ response: `${JSON.stringify(reviewResult.summary, null, 2)}${artifactResponse}`,
1094
2472
  output: reviewResult,
2473
+ outputs,
1095
2474
  writes,
1096
2475
  };
1097
2476
  }
@@ -1101,18 +2480,522 @@ function recordNodeArtifact(nodeId, node, result, artifacts) {
1101
2480
  if (node.output) {
1102
2481
  artifacts[node.output] = artifactValue;
1103
2482
  }
2483
+ if (result.outputs) {
2484
+ for (const [name, value] of Object.entries(result.outputs)) {
2485
+ artifacts[name] = value;
2486
+ }
2487
+ }
2488
+ }
2489
+ function parseWorkflowExpressionValue(value, context) {
2490
+ const trimmed = value.trim();
2491
+ if (!trimmed)
2492
+ return '';
2493
+ const templateReference = trimmed.match(/^\{\{\s*([^}]+?)\s*\}\}$/);
2494
+ if (templateReference && context) {
2495
+ const path = templateReference[1]?.trim() ?? '';
2496
+ const resolved = getPathValue(context, path);
2497
+ if (resolved === undefined) {
2498
+ throw new Error(`Unknown workflow template value "{{${path}}}".`);
2499
+ }
2500
+ return resolved;
2501
+ }
2502
+ if (trimmed === 'true')
2503
+ return true;
2504
+ if (trimmed === 'false')
2505
+ return false;
2506
+ if (trimmed === 'null')
2507
+ return null;
2508
+ if (/^-?\d+(\.\d+)?$/.test(trimmed))
2509
+ return Number(trimmed);
2510
+ if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
2511
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
2512
+ const inner = trimmed.slice(1, -1);
2513
+ return context ? renderTemplate(inner, context) : inner;
2514
+ }
2515
+ if (context && /^(inputs|nodes|artifacts|loop)\.[A-Za-z0-9_.-]+$/.test(trimmed)) {
2516
+ const resolved = getPathValue(context, trimmed);
2517
+ if (resolved === undefined) {
2518
+ throw new Error(`Unknown workflow expression value "${trimmed}".`);
2519
+ }
2520
+ return resolved;
2521
+ }
2522
+ try {
2523
+ return JSON.parse(trimmed);
2524
+ }
2525
+ catch {
2526
+ return trimmed;
2527
+ }
2528
+ }
2529
+ function isWorkflowTruthy(value) {
2530
+ if (value === undefined || value === null)
2531
+ return false;
2532
+ if (typeof value === 'boolean')
2533
+ return value;
2534
+ if (typeof value === 'number')
2535
+ return value !== 0;
2536
+ if (typeof value === 'string') {
2537
+ const normalized = value.trim().toLowerCase();
2538
+ return (normalized.length > 0 && normalized !== 'false' && normalized !== '0' && normalized !== 'no');
2539
+ }
2540
+ if (Array.isArray(value))
2541
+ return value.length > 0;
2542
+ if (typeof value === 'object')
2543
+ return Object.keys(value).length > 0;
2544
+ return true;
2545
+ }
2546
+ function normalizeWorkflowBooleanLike(value) {
2547
+ if (typeof value === 'boolean')
2548
+ return value;
2549
+ if (typeof value === 'number') {
2550
+ if (value === 1)
2551
+ return true;
2552
+ if (value === 0)
2553
+ return false;
2554
+ }
2555
+ if (typeof value === 'string') {
2556
+ const normalized = value.trim().toLowerCase();
2557
+ if (normalized === 'true' || normalized === '1' || normalized === 'yes')
2558
+ return true;
2559
+ if (normalized === 'false' || normalized === '0' || normalized === 'no')
2560
+ return false;
2561
+ }
2562
+ return undefined;
2563
+ }
2564
+ function compareWorkflowValues(left, operator, right) {
2565
+ if (operator === '==' || operator === '!=') {
2566
+ const leftBoolean = normalizeWorkflowBooleanLike(left);
2567
+ const rightBoolean = normalizeWorkflowBooleanLike(right);
2568
+ const matches = leftBoolean !== undefined || rightBoolean !== undefined
2569
+ ? leftBoolean === rightBoolean
2570
+ : String(left) === String(right);
2571
+ return operator === '==' ? matches : !matches;
2572
+ }
2573
+ const leftNumber = Number(left);
2574
+ const rightNumber = Number(right);
2575
+ if (!Number.isFinite(leftNumber) || !Number.isFinite(rightNumber)) {
2576
+ throw new Error(`Workflow expression operator "${operator}" requires numeric values.`);
2577
+ }
2578
+ if (operator === '>')
2579
+ return leftNumber > rightNumber;
2580
+ if (operator === '>=')
2581
+ return leftNumber >= rightNumber;
2582
+ if (operator === '<')
2583
+ return leftNumber < rightNumber;
2584
+ if (operator === '<=')
2585
+ return leftNumber <= rightNumber;
2586
+ throw new Error(`Unsupported workflow expression operator "${operator}".`);
2587
+ }
2588
+ function splitWorkflowExpressionOperator(expression, operator) {
2589
+ const parts = [];
2590
+ let current = '';
2591
+ let quote;
2592
+ let depth = 0;
2593
+ for (let i = 0; i < expression.length; i++) {
2594
+ const char = expression[i] ?? '';
2595
+ if ((char === '"' || char === "'") && expression[i - 1] !== '\\') {
2596
+ quote = quote === char ? undefined : (quote ?? char);
2597
+ current += char;
2598
+ continue;
2599
+ }
2600
+ if (!quote && char === '(') {
2601
+ depth += 1;
2602
+ current += char;
2603
+ continue;
2604
+ }
2605
+ if (!quote && char === ')' && depth > 0) {
2606
+ depth -= 1;
2607
+ current += char;
2608
+ continue;
2609
+ }
2610
+ if (!quote && depth === 0 && expression.slice(i, i + operator.length) === operator) {
2611
+ parts.push(current.trim());
2612
+ current = '';
2613
+ i += operator.length - 1;
2614
+ continue;
2615
+ }
2616
+ current += char;
2617
+ }
2618
+ parts.push(current.trim());
2619
+ return parts;
2620
+ }
2621
+ function stripWorkflowExpressionParens(expression) {
2622
+ const trimmed = expression.trim();
2623
+ if (!trimmed.startsWith('(') || !trimmed.endsWith(')')) {
2624
+ return trimmed;
2625
+ }
2626
+ let quote;
2627
+ let depth = 0;
2628
+ for (let i = 0; i < trimmed.length; i++) {
2629
+ const char = trimmed[i] ?? '';
2630
+ if ((char === '"' || char === "'") && trimmed[i - 1] !== '\\') {
2631
+ quote = quote === char ? undefined : (quote ?? char);
2632
+ continue;
2633
+ }
2634
+ if (quote)
2635
+ continue;
2636
+ if (char === '(')
2637
+ depth += 1;
2638
+ if (char === ')')
2639
+ depth -= 1;
2640
+ if (depth === 0 && i < trimmed.length - 1) {
2641
+ return trimmed;
2642
+ }
2643
+ }
2644
+ return trimmed.slice(1, -1).trim();
2645
+ }
2646
+ function evaluateWorkflowExpressionText(rendered, context) {
2647
+ rendered = stripWorkflowExpressionParens(rendered);
2648
+ const orParts = splitWorkflowExpressionOperator(rendered, '||');
2649
+ if (orParts.length > 1) {
2650
+ return orParts.some((part) => evaluateWorkflowExpressionText(part, context));
2651
+ }
2652
+ const andParts = splitWorkflowExpressionOperator(rendered, '&&');
2653
+ if (andParts.length > 1) {
2654
+ return andParts.every((part) => evaluateWorkflowExpressionText(part, context));
2655
+ }
2656
+ const match = rendered.match(/^(.+?)\s*(==|!=|>=|<=|>|<)\s*(.+)$/);
2657
+ if (!match) {
2658
+ return isWorkflowTruthy(parseWorkflowExpressionValue(rendered, context));
2659
+ }
2660
+ return compareWorkflowValues(parseWorkflowExpressionValue(match[1] ?? '', context), match[2] ?? '', parseWorkflowExpressionValue(match[3] ?? '', context));
2661
+ }
2662
+ function evaluateWorkflowExpression(expression, context) {
2663
+ return evaluateWorkflowExpressionText(expression.trim(), context);
2664
+ }
2665
+ function splitWorkflowSegments(workflowNodes, executionOrder) {
2666
+ const segments = [];
2667
+ let currentDag = [];
2668
+ for (const nodeId of executionOrder) {
2669
+ const node = workflowNodes[nodeId];
2670
+ if (!node) {
2671
+ throw new Error(`Workflow references unknown node "${nodeId}".`);
2672
+ }
2673
+ if (node.control !== undefined) {
2674
+ if (currentDag.length > 0) {
2675
+ segments.push({ type: 'dag', nodeIds: currentDag });
2676
+ currentDag = [];
2677
+ }
2678
+ segments.push({ type: 'control', nodeId });
2679
+ }
2680
+ else {
2681
+ currentDag.push(nodeId);
2682
+ }
2683
+ }
2684
+ if (currentDag.length > 0) {
2685
+ segments.push({ type: 'dag', nodeIds: currentDag });
2686
+ }
2687
+ return segments;
2688
+ }
2689
+ function findWorkflowSegmentIndex(segments, targetNodeId) {
2690
+ return segments.findIndex((segment) => segment.type === 'control'
2691
+ ? segment.nodeId === targetNodeId
2692
+ : segment.nodeIds.includes(targetNodeId));
2693
+ }
2694
+ function computeActiveWorkflowNodes(workflowNodes, nodeIds, rootNodeId, includeRootDependencies = true) {
2695
+ const segmentNodeIds = new Set(nodeIds);
2696
+ const downstream = new Set([rootNodeId]);
2697
+ let changed = true;
2698
+ while (changed) {
2699
+ changed = false;
2700
+ for (const nodeId of nodeIds) {
2701
+ if (downstream.has(nodeId))
2702
+ continue;
2703
+ const needs = getNodeNeeds(workflowNodes[nodeId] ?? {});
2704
+ if (needs.some((dependency) => downstream.has(dependency))) {
2705
+ downstream.add(nodeId);
2706
+ changed = true;
2707
+ }
2708
+ }
2709
+ }
2710
+ const active = new Set(downstream);
2711
+ const includeDependencies = (nodeId) => {
2712
+ const node = workflowNodes[nodeId];
2713
+ if (!node)
2714
+ return;
2715
+ for (const dependency of getNodeNeeds(node)) {
2716
+ if (!segmentNodeIds.has(dependency) || active.has(dependency))
2717
+ continue;
2718
+ active.add(dependency);
2719
+ includeDependencies(dependency);
2720
+ }
2721
+ };
2722
+ if (includeRootDependencies) {
2723
+ for (const nodeId of downstream) {
2724
+ includeDependencies(nodeId);
2725
+ }
2726
+ }
2727
+ return active;
2728
+ }
2729
+ function createSkippedWorkflowNodeResult(nodeId) {
2730
+ return {
2731
+ id: nodeId,
2732
+ type: 'skipped',
2733
+ status: 'skipped',
2734
+ response: '',
2735
+ output: undefined,
2736
+ };
2737
+ }
2738
+ function getWorkflowNodeRunCondition(node) {
2739
+ return node.if;
2740
+ }
2741
+ function getSkippedWorkflowDependencies(node, nodes) {
2742
+ return getNodeNeeds(node).filter((dependency) => nodes[dependency]?.status === 'skipped');
2743
+ }
2744
+ function getWorkflowNodeSkipReason(node, context) {
2745
+ const skippedDependencies = getSkippedWorkflowDependencies(node, context.nodes);
2746
+ if (skippedDependencies.length > 0) {
2747
+ return `dependency skipped: ${skippedDependencies.join(', ')}`;
2748
+ }
2749
+ const ifExpression = getWorkflowNodeRunCondition(node);
2750
+ if (ifExpression !== undefined && !evaluateWorkflowExpression(ifExpression, context)) {
2751
+ return `if false: ${ifExpression}`;
2752
+ }
2753
+ return undefined;
2754
+ }
2755
+ function logWorkflowNodeRunning(nodeId, options) {
2756
+ if (!options.jsonOutput) {
2757
+ console.log(chalk.gray(`Running node ${nodeId}...`));
2758
+ }
2759
+ }
2760
+ function logWorkflowNodeSkipped(nodeId, reason, options) {
2761
+ if (!options.jsonOutput) {
2762
+ console.log(chalk.gray(`Skipping node ${nodeId} (${reason})`));
2763
+ }
2764
+ }
2765
+ function recordWorkflowNodeResult(nodeId, node, result, nodes, artifacts) {
2766
+ result.status ??= 'success';
2767
+ nodes[nodeId] = result;
2768
+ if (result.status !== 'skipped') {
2769
+ recordNodeArtifact(nodeId, node, result, artifacts);
2770
+ }
2771
+ }
2772
+ async function runWorkflowDagSegment(config, workflowNodes, nodeIds, activeNodeIds, options, workingDir, context, executionContext) {
2773
+ const completed = new Set();
2774
+ const segmentNodeIds = new Set(nodeIds);
2775
+ if (activeNodeIds) {
2776
+ for (const nodeId of nodeIds) {
2777
+ if (!activeNodeIds.has(nodeId)) {
2778
+ completed.add(nodeId);
2779
+ if (context.nodes[nodeId] === undefined) {
2780
+ logWorkflowNodeSkipped(nodeId, 'inactive branch', options);
2781
+ context.nodes[nodeId] = createSkippedWorkflowNodeResult(nodeId);
2782
+ }
2783
+ }
2784
+ }
2785
+ }
2786
+ while (completed.size < nodeIds.length) {
2787
+ const runnable = nodeIds.filter((nodeId) => {
2788
+ if (completed.has(nodeId))
2789
+ return false;
2790
+ const node = workflowNodes[nodeId];
2791
+ if (!node)
2792
+ return false;
2793
+ return getNodeNeeds(node).every((dependency) => completed.has(dependency) || !segmentNodeIds.has(dependency));
2794
+ });
2795
+ if (runnable.length === 0) {
2796
+ throw new Error('Workflow control runner could not make progress in a DAG segment.');
2797
+ }
2798
+ const settled = await Promise.allSettled(runnable.map(async (nodeId) => {
2799
+ const node = workflowNodes[nodeId];
2800
+ if (!node) {
2801
+ throw new Error(`Workflow references unknown node "${nodeId}".`);
2802
+ }
2803
+ const skipReason = getWorkflowNodeSkipReason(node, context);
2804
+ if (skipReason) {
2805
+ logWorkflowNodeSkipped(nodeId, skipReason, options);
2806
+ return { nodeId, node, result: createSkippedWorkflowNodeResult(nodeId) };
2807
+ }
2808
+ logWorkflowNodeRunning(nodeId, options);
2809
+ const result = await runSingleWorkflowNode(config, nodeId, node, options, workingDir, context, executionContext);
2810
+ return { nodeId, node, result };
2811
+ }));
2812
+ const firstRejection = settled.find((outcome) => outcome.status === 'rejected');
2813
+ if (firstRejection) {
2814
+ throw firstRejection.reason instanceof Error
2815
+ ? firstRejection.reason
2816
+ : new Error(String(firstRejection.reason));
2817
+ }
2818
+ for (const outcome of settled) {
2819
+ const { nodeId, node, result } = outcome.value;
2820
+ completed.add(nodeId);
2821
+ recordWorkflowNodeResult(nodeId, node, result, context.nodes, context.artifacts);
2822
+ }
2823
+ }
2824
+ }
2825
+ function runControlWorkflowNode(nodeId, node, context) {
2826
+ if (node.control === 'loop') {
2827
+ const expression = node.if;
2828
+ if (!expression) {
2829
+ throw new Error(`Workflow loop node "${nodeId}" must define if.`);
2830
+ }
2831
+ if (!node.target || !node.exit) {
2832
+ throw new Error(`Workflow loop node "${nodeId}" must define target and exit.`);
2833
+ }
2834
+ const rawMaxIterations = node.maxIterations;
2835
+ const renderedMaxIterations = typeof rawMaxIterations === 'string'
2836
+ ? renderTemplate(rawMaxIterations, context).trim()
2837
+ : String(rawMaxIterations ?? '');
2838
+ const configuredMaxIterations = Number.parseInt(renderedMaxIterations, 10);
2839
+ if (renderedMaxIterations === '' ||
2840
+ !Number.isInteger(configuredMaxIterations) ||
2841
+ configuredMaxIterations <= 0) {
2842
+ throw new Error(`Workflow loop node "${nodeId}" must define a positive maxIterations.`);
2843
+ }
2844
+ const maxIterations = configuredMaxIterations;
2845
+ const shouldLoop = evaluateWorkflowExpression(expression, context);
2846
+ const current = context.loop[nodeId] ?? { iteration: 1, maxIterations };
2847
+ let nextNodeId = node.exit;
2848
+ let decision = 'exit';
2849
+ if (shouldLoop) {
2850
+ if (current.iteration >= maxIterations) {
2851
+ if (node.onMaxIterations === 'exit') {
2852
+ nextNodeId = node.exit;
2853
+ }
2854
+ else {
2855
+ throw new Error(`Workflow loop node "${nodeId}" reached maxIterations (${maxIterations}).`);
2856
+ }
2857
+ }
2858
+ else {
2859
+ decision = 'loop';
2860
+ nextNodeId = node.target;
2861
+ current.iteration += 1;
2862
+ }
2863
+ }
2864
+ current.maxIterations = maxIterations;
2865
+ current.lastDecision = decision;
2866
+ context.loop[nodeId] = current;
2867
+ return {
2868
+ nextNodeId,
2869
+ result: {
2870
+ id: nodeId,
2871
+ type: 'control',
2872
+ status: 'success',
2873
+ control: node.control,
2874
+ decision,
2875
+ target: nextNodeId,
2876
+ response: decision,
2877
+ output: {
2878
+ matched: shouldLoop,
2879
+ target: nextNodeId,
2880
+ iteration: current.iteration,
2881
+ maxIterations,
2882
+ },
2883
+ },
2884
+ };
2885
+ }
2886
+ if (node.control === 'switch') {
2887
+ if (!node.value || !node.cases) {
2888
+ throw new Error(`Workflow switch node "${nodeId}" must define value and cases.`);
2889
+ }
2890
+ const value = renderTemplate(node.value, context).trim();
2891
+ const nextNodeId = node.cases[value] ?? node.default;
2892
+ if (!nextNodeId) {
2893
+ throw new Error(`Workflow switch node "${nodeId}" has no case for "${value}" or default.`);
2894
+ }
2895
+ return {
2896
+ nextNodeId,
2897
+ result: {
2898
+ id: nodeId,
2899
+ type: 'control',
2900
+ status: 'success',
2901
+ control: node.control,
2902
+ decision: value,
2903
+ target: nextNodeId,
2904
+ response: value,
2905
+ output: { value, target: nextNodeId },
2906
+ },
2907
+ };
2908
+ }
2909
+ if (node.control === 'end') {
2910
+ return {
2911
+ ended: true,
2912
+ result: {
2913
+ id: nodeId,
2914
+ type: 'control',
2915
+ status: 'success',
2916
+ control: node.control,
2917
+ decision: 'end',
2918
+ response: 'end',
2919
+ output: { ended: true },
2920
+ },
2921
+ };
2922
+ }
2923
+ if (node.control === 'passThrough') {
2924
+ const result = {
2925
+ id: nodeId,
2926
+ type: 'control',
2927
+ control: 'passThrough',
2928
+ target: node.target,
2929
+ decision: 'pass',
2930
+ response: `passed through to ${node.target}`,
2931
+ };
2932
+ return { result, nextNodeId: node.target };
2933
+ }
2934
+ throw new Error(`Unsupported workflow control "${node.control}" in node "${nodeId}".`);
2935
+ }
2936
+ async function runControlWorkflow(config, workflowNodes, executionOrder, options, workingDir, context, executionContext) {
2937
+ const segments = splitWorkflowSegments(workflowNodes, executionOrder);
2938
+ let segmentIndex = 0;
2939
+ while (segmentIndex < segments.length) {
2940
+ const segment = segments[segmentIndex];
2941
+ if (segment.type === 'dag') {
2942
+ await runWorkflowDagSegment(config, workflowNodes, segment.nodeIds, segment.activeNodeIds, options, workingDir, context, executionContext);
2943
+ segmentIndex += 1;
2944
+ continue;
2945
+ }
2946
+ const node = workflowNodes[segment.nodeId];
2947
+ if (!node) {
2948
+ throw new Error(`Workflow references unknown node "${segment.nodeId}".`);
2949
+ }
2950
+ const skipReason = getWorkflowNodeSkipReason(node, context);
2951
+ if (skipReason) {
2952
+ logWorkflowNodeSkipped(segment.nodeId, skipReason, options);
2953
+ }
2954
+ else {
2955
+ logWorkflowNodeRunning(segment.nodeId, options);
2956
+ }
2957
+ const { result, nextNodeId, ended } = skipReason
2958
+ ? { result: createSkippedWorkflowNodeResult(segment.nodeId) }
2959
+ : runControlWorkflowNode(segment.nodeId, node, context);
2960
+ recordWorkflowNodeResult(segment.nodeId, node, result, context.nodes, context.artifacts);
2961
+ if (ended) {
2962
+ return;
2963
+ }
2964
+ if (!nextNodeId) {
2965
+ segmentIndex += 1;
2966
+ continue;
2967
+ }
2968
+ const targetIndex = findWorkflowSegmentIndex(segments, nextNodeId);
2969
+ if (targetIndex < 0) {
2970
+ throw new Error(`Workflow control node "${segment.nodeId}" targets unknown node "${nextNodeId}".`);
2971
+ }
2972
+ if (node.control !== 'loop' && targetIndex <= segmentIndex) {
2973
+ throw new Error(`Workflow control node "${segment.nodeId}" cannot jump backward to "${nextNodeId}". Use control: loop with maxIterations for repeated execution.`);
2974
+ }
2975
+ const targetSegment = segments[targetIndex];
2976
+ if (targetSegment.type === 'dag') {
2977
+ targetSegment.activeNodeIds = computeActiveWorkflowNodes(workflowNodes, targetSegment.nodeIds, nextNodeId, !(node.control === 'loop' && nextNodeId === node.target));
2978
+ }
2979
+ segmentIndex = targetIndex;
2980
+ }
1104
2981
  }
1105
2982
  function formatWorkflowJson(result) {
1106
2983
  return JSON.stringify(result, null, 2);
1107
2984
  }
1108
2985
  async function runSingleWorkflowNode(config, nodeId, node, options, workingDir, context, executionContext) {
1109
2986
  const kind = getNodeKind(node);
2987
+ if (getWorkflowNodeSkipReason(node, context)) {
2988
+ return createSkippedWorkflowNodeResult(nodeId);
2989
+ }
1110
2990
  if (kind === 'agent') {
1111
- return runAgentWorkflowNode(config, nodeId, node, options, workingDir, context);
2991
+ return runAgentWorkflowNode(config, nodeId, node, options, workingDir, context, executionContext);
1112
2992
  }
1113
2993
  if (kind === 'agents') {
1114
2994
  return runAgentsWorkflowNode(config, nodeId, node, options, workingDir, context);
1115
2995
  }
2996
+ if (kind === 'control') {
2997
+ throw new Error(`Workflow control node "${nodeId}" cannot run in the static DAG executor.`);
2998
+ }
1116
2999
  return runActionWorkflowNode(config, nodeId, node, options, workingDir, context, executionContext);
1117
3000
  }
1118
3001
  export async function runWorkflow(config, workflowName, options = {}) {
@@ -1125,10 +3008,12 @@ export async function runWorkflow(config, workflowName, options = {}) {
1125
3008
  const inputs = await resolveWorkflowInputs(workflow, options, workingDir);
1126
3009
  const nodes = {};
1127
3010
  const artifacts = {};
1128
- const context = { inputs, nodes, artifacts };
3011
+ const loop = {};
3012
+ const context = { inputs, nodes, artifacts, loop };
1129
3013
  const executionContext = {
1130
3014
  gitClients: new Map(),
1131
3015
  platformClients: {},
3016
+ traceCollector: options.trace ? new TraceCollector() : undefined,
1132
3017
  locks: {
1133
3018
  exit: createWorkflowLock(),
1134
3019
  console: createWorkflowLock(),
@@ -1139,32 +3024,60 @@ export async function runWorkflow(config, workflowName, options = {}) {
1139
3024
  if (!options.jsonOutput) {
1140
3025
  console.log(chalk.gray(`Running workflow ${workflowName}...\n`));
1141
3026
  }
1142
- for (const wave of executionWaves) {
1143
- const results = await Promise.all(wave.map(async (nodeId) => {
1144
- const node = workflowNodes[nodeId];
1145
- if (!node) {
1146
- throw new Error(`Workflow references unknown node "${nodeId}".`);
3027
+ try {
3028
+ if (hasWorkflowControlNodes(workflowNodes)) {
3029
+ await runControlWorkflow(config, workflowNodes, executionOrder, options, workingDir, context, executionContext);
3030
+ }
3031
+ else {
3032
+ for (const wave of executionWaves) {
3033
+ const settled = await Promise.allSettled(wave.map(async (nodeId) => {
3034
+ const node = workflowNodes[nodeId];
3035
+ if (!node) {
3036
+ throw new Error(`Workflow references unknown node "${nodeId}".`);
3037
+ }
3038
+ const skipReason = getWorkflowNodeSkipReason(node, context);
3039
+ if (skipReason) {
3040
+ logWorkflowNodeSkipped(nodeId, skipReason, options);
3041
+ return { nodeId, node, result: createSkippedWorkflowNodeResult(nodeId) };
3042
+ }
3043
+ logWorkflowNodeRunning(nodeId, options);
3044
+ const result = await runSingleWorkflowNode(config, nodeId, node, options, workingDir, context, executionContext);
3045
+ return { nodeId, node, result };
3046
+ }));
3047
+ const firstRejection = settled.find((outcome) => outcome.status === 'rejected');
3048
+ if (firstRejection) {
3049
+ throw firstRejection.reason instanceof Error
3050
+ ? firstRejection.reason
3051
+ : new Error(String(firstRejection.reason));
3052
+ }
3053
+ for (const outcome of settled) {
3054
+ const { nodeId, node, result } = outcome.value;
3055
+ recordWorkflowNodeResult(nodeId, node, result, nodes, artifacts);
3056
+ }
3057
+ }
3058
+ }
3059
+ }
3060
+ catch (error) {
3061
+ if (executionContext.traceCollector && executionContext.traceCollector.getTraces().length > 0) {
3062
+ try {
3063
+ await flushWorkflowTrace(executionContext.traceCollector, workflowName, inputs, new Date().toISOString(), workingDir, options);
1147
3064
  }
1148
- if (!options.jsonOutput) {
1149
- console.log(chalk.gray(`Running node ${nodeId}...`));
3065
+ catch (flushError) {
3066
+ console.error(chalk.yellow('Warning:'), 'Failed to persist workflow trace on failure:', flushError instanceof Error ? flushError.message : String(flushError));
1150
3067
  }
1151
- const result = await runSingleWorkflowNode(config, nodeId, node, options, workingDir, context, executionContext);
1152
- return { nodeId, node, result };
1153
- }));
1154
- for (const { nodeId, node, result } of results) {
1155
- nodes[nodeId] = result;
1156
- recordNodeArtifact(nodeId, node, result, artifacts);
1157
3068
  }
3069
+ throw error;
1158
3070
  }
1159
3071
  const lastNodeId = executionOrder[executionOrder.length - 1];
1160
3072
  const lastNode = workflowNodes[lastNodeId];
1161
- const outputKey = lastNode.output ?? lastNodeId;
3073
+ const outputKey = workflow.output ?? lastNode.output ?? lastNodeId;
1162
3074
  const result = {
1163
3075
  timestamp: new Date().toISOString(),
1164
3076
  workflow: workflowName,
1165
3077
  inputs,
1166
3078
  nodes,
1167
3079
  artifacts,
3080
+ loop,
1168
3081
  output: artifacts[outputKey],
1169
3082
  };
1170
3083
  if (options.outputPath) {
@@ -1173,6 +3086,9 @@ export async function runWorkflow(config, workflowName, options = {}) {
1173
3086
  console.log(chalk.green(`\n✓ Workflow output saved to ${options.outputPath}`));
1174
3087
  }
1175
3088
  }
3089
+ if (executionContext.traceCollector && executionContext.traceCollector.getTraces().length > 0) {
3090
+ await flushWorkflowTrace(executionContext.traceCollector, workflowName, inputs, result.timestamp, workingDir, options);
3091
+ }
1176
3092
  if (options.jsonOutput) {
1177
3093
  console.log(formatWorkflowJson(result));
1178
3094
  }
@@ -1226,4 +3142,168 @@ export function listWorkflows(config, options = {}) {
1226
3142
  }
1227
3143
  return entries;
1228
3144
  }
3145
+ function getWorkflowNodeRoutes(node) {
3146
+ if (node.control === 'loop') {
3147
+ return { target: node.target, exit: node.exit };
3148
+ }
3149
+ if (node.control === 'switch') {
3150
+ return { cases: node.cases, default: node.default };
3151
+ }
3152
+ if (node.control === 'passThrough') {
3153
+ return { target: node.target };
3154
+ }
3155
+ return undefined;
3156
+ }
3157
+ function formatWorkflowInput(input) {
3158
+ if (typeof input === 'string') {
3159
+ return JSON.stringify(input);
3160
+ }
3161
+ if (input.file !== undefined) {
3162
+ return `file:${input.file}`;
3163
+ }
3164
+ return JSON.stringify(input.value ?? '');
3165
+ }
3166
+ function buildWorkflowDetail(name, workflow, workingDir) {
3167
+ const sourceInfo = loadWorkflowSourceInfo(workingDir);
3168
+ const info = sourceInfo[name] ?? {
3169
+ source: 'packaged',
3170
+ overridesPackaged: false,
3171
+ };
3172
+ const workflowNodes = getWorkflowNodes(name, workflow);
3173
+ getWorkflowExecutionOrder(workflowNodes);
3174
+ return {
3175
+ name,
3176
+ source: info.source,
3177
+ overridden: info.overridesPackaged,
3178
+ description: workflow.description,
3179
+ inputs: workflow.inputs ?? {},
3180
+ output: workflow.output,
3181
+ nodes: Object.entries(workflowNodes).map(([nodeId, node]) => ({
3182
+ id: nodeId,
3183
+ kind: getNodeKind(node),
3184
+ needs: getNodeNeeds(node),
3185
+ agent: node.agent,
3186
+ agentsFrom: node.agentsFrom,
3187
+ action: node.action,
3188
+ control: node.control,
3189
+ if: node.if,
3190
+ input: node.input,
3191
+ with: node.with,
3192
+ output: node.output,
3193
+ writes: node.writes,
3194
+ json: node.json,
3195
+ routes: getWorkflowNodeRoutes(node),
3196
+ })),
3197
+ };
3198
+ }
3199
+ export function showWorkflow(config, workflowName, options = {}) {
3200
+ const workflow = config.workflows?.[workflowName];
3201
+ if (!workflow) {
3202
+ throw new Error(`Unknown workflow "${workflowName}".`);
3203
+ }
3204
+ const detail = buildWorkflowDetail(workflowName, workflow, options.workingDir ?? process.cwd());
3205
+ if (options.json) {
3206
+ console.log(JSON.stringify(detail, null, 2));
3207
+ return detail;
3208
+ }
3209
+ const sourceLabel = detail.source === 'packaged' ? chalk.gray('packaged') : chalk.cyan('project');
3210
+ const overridden = detail.overridden ? ` ${chalk.yellow('(overrides packaged)')}` : '';
3211
+ console.log(chalk.bold(`\nWorkflow: ${detail.name}\n`));
3212
+ console.log(` Source: ${sourceLabel}${overridden}`);
3213
+ if (detail.description) {
3214
+ console.log(` Description: ${detail.description}`);
3215
+ }
3216
+ if (detail.output) {
3217
+ console.log(` Output: ${detail.output}`);
3218
+ }
3219
+ console.log(chalk.bold('\nInputs:'));
3220
+ const inputEntries = Object.entries(detail.inputs);
3221
+ if (inputEntries.length === 0) {
3222
+ console.log(chalk.gray(' (none)'));
3223
+ }
3224
+ else {
3225
+ for (const [key, input] of inputEntries) {
3226
+ console.log(` ${key}: ${formatWorkflowInput(input)}`);
3227
+ }
3228
+ }
3229
+ console.log(chalk.bold('\nNodes:'));
3230
+ for (const node of detail.nodes) {
3231
+ console.log(` ${node.id} (${node.kind})`);
3232
+ if (node.needs.length > 0) {
3233
+ console.log(` needs: ${node.needs.join(', ')}`);
3234
+ }
3235
+ if (node.agent)
3236
+ console.log(` agent: ${node.agent}`);
3237
+ if (node.agentsFrom)
3238
+ console.log(` agentsFrom: ${node.agentsFrom}`);
3239
+ if (node.action)
3240
+ console.log(` action: ${node.action}`);
3241
+ if (node.control)
3242
+ console.log(` control: ${node.control}`);
3243
+ if (node.if)
3244
+ console.log(` if: ${node.if}`);
3245
+ if (node.output)
3246
+ console.log(` output: ${node.output}`);
3247
+ if (node.writes)
3248
+ console.log(` writes: ${node.writes}`);
3249
+ if (node.input)
3250
+ console.log(` input: ${node.input.split('\n')[0]}`);
3251
+ if (node.with && Object.keys(node.with).length > 0) {
3252
+ console.log(` with: ${JSON.stringify(node.with)}`);
3253
+ }
3254
+ if (node.routes && Object.keys(node.routes).length > 0) {
3255
+ console.log(` routes: ${JSON.stringify(node.routes)}`);
3256
+ }
3257
+ }
3258
+ console.log('');
3259
+ return detail;
3260
+ }
3261
+ function validateSingleWorkflow(config, workflowName, workingDir) {
3262
+ const workflow = config.workflows?.[workflowName];
3263
+ if (!workflow) {
3264
+ return { name: workflowName, valid: false, error: `Unknown workflow "${workflowName}".` };
3265
+ }
3266
+ try {
3267
+ const sourceInfo = loadWorkflowSourceInfo(workingDir);
3268
+ const workflowNodes = getWorkflowNodes(workflowName, workflow);
3269
+ const executionOrder = getWorkflowExecutionOrder(workflowNodes);
3270
+ return {
3271
+ name: workflowName,
3272
+ valid: true,
3273
+ source: sourceInfo[workflowName]?.source ?? 'packaged',
3274
+ waves: getWorkflowExecutionWaves(workflowNodes, executionOrder),
3275
+ };
3276
+ }
3277
+ catch (error) {
3278
+ return {
3279
+ name: workflowName,
3280
+ valid: false,
3281
+ error: error instanceof Error ? error.message : String(error),
3282
+ };
3283
+ }
3284
+ }
3285
+ export function validateWorkflows(config, workflowName, options = {}) {
3286
+ const workingDir = options.workingDir ?? process.cwd();
3287
+ const names = workflowName ? [workflowName] : Object.keys(config.workflows ?? {}).sort();
3288
+ const results = names.map((name) => validateSingleWorkflow(config, name, workingDir));
3289
+ if (options.json) {
3290
+ console.log(JSON.stringify(results, null, 2));
3291
+ return results;
3292
+ }
3293
+ console.log(chalk.bold('\nWorkflow Validation:\n'));
3294
+ for (const result of results) {
3295
+ if (result.valid) {
3296
+ console.log(` ${chalk.green('✓')} ${result.name}`);
3297
+ if (result.waves) {
3298
+ console.log(` waves: ${result.waves.map((wave) => `[${wave.join(', ')}]`).join(' -> ')}`);
3299
+ }
3300
+ }
3301
+ else {
3302
+ console.log(` ${chalk.red('✗')} ${result.name}`);
3303
+ console.log(` ${chalk.red(result.error ?? 'invalid workflow')}`);
3304
+ }
3305
+ }
3306
+ console.log('');
3307
+ return results;
3308
+ }
1229
3309
  //# sourceMappingURL=workflow.js.map